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, rowFromPayload,
rowsFromHistory, rowsFromHistory,
} from './lib/mainLogs'; } from './lib/mainLogs';
import { AsyncOwner, windowAsyncScope } from './lib/asyncOwnership';
import './MainLogsWindow.css'; import './MainLogsWindow.css';
@@ -43,6 +44,8 @@ export const MainLogsWindow = () => {
const pausedRef = useRef(false); const pausedRef = useRef(false);
const lastHistorySequenceRef = useRef(0); const lastHistorySequenceRef = useRef(0);
const historyLineCountsRef = useRef<Map<string, number>>(new Map()); 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[]) => { const appendVisibleRows = useCallback((rows: MainLogRow[]) => {
setLogs(current => capLogRows([...current, ...rows])); setLogs(current => capLogRows([...current, ...rows]));
@@ -54,10 +57,18 @@ export const MainLogsWindow = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
let cancelled = false; const owner = new AsyncOwner(error => {
let unlisten: (() => void) | undefined; 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) { if (!historyLoadedRef.current) {
initialBufferRef.current = capLogRows([...initialBufferRef.current, row]); initialBufferRef.current = capLogRows([...initialBufferRef.current, row]);
return; return;
@@ -76,43 +87,55 @@ export const MainLogsWindow = () => {
return; return;
} }
appendVisibleRows([row]); appendVisibleRows([row]);
}; });
const setup = async () => { const setup = async () => {
try { try {
unlisten = await listen<MainLogLinePayload>('main-log-line', event => { const registered = await owner.register(() =>
handleIncomingRow(rowFromPayload(event.payload)); 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,
); );
initialBufferRef.current = []; if (!registered) return;
historyLineCountsRef.current = historyLineCounts;
historyLoadedRef.current = true;
if (pausedRef.current) {
setLogs(capLogRows(historyRows));
bufferPausedRows(liveRows);
} else {
setLogs(capLogRows([...historyRows, ...liveRows]));
}
setLoadError(null);
} catch (err) { } catch (err) {
if (!cancelled) { if (owner.isActive()) {
historyLoadedRef.current = true; historyLoadedRef.current = true;
setLoadError(err instanceof Error ? err.message : String(err)); setLoadError(err instanceof Error ? err.message : String(err));
setLoading(false);
} }
} finally { return;
if (!cancelled) { }
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); setLoading(false);
} }
} }
@@ -121,12 +144,12 @@ export const MainLogsWindow = () => {
void setup(); void setup();
return () => { return () => {
cancelled = true; windowAsyncScope.adopt(owner.dispose());
if (viewOwnerRef.current === owner) viewOwnerRef.current = null;
historyLoadedRef.current = false; historyLoadedRef.current = false;
initialBufferRef.current = []; initialBufferRef.current = [];
lastHistorySequenceRef.current = 0; lastHistorySequenceRef.current = 0;
historyLineCountsRef.current = new Map(); historyLineCountsRef.current = new Map();
unlisten?.();
}; };
}, [appendVisibleRows, bufferPausedRows]); }, [appendVisibleRows, bufferPausedRows]);
@@ -156,9 +179,10 @@ export const MainLogsWindow = () => {
const viewport = viewportRef.current; const viewport = viewportRef.current;
if (!viewport) return; if (!viewport) return;
requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
viewport.scrollTop = viewport.scrollHeight; viewport.scrollTop = viewport.scrollHeight;
}); });
return () => cancelAnimationFrame(frame);
}, [autoScroll, filteredRows.length, lastVisibleRow?.id]); }, [autoScroll, filteredRows.length, lastVisibleRow?.id]);
const flushPausedRows = useCallback(() => { const flushPausedRows = useCallback(() => {
@@ -189,13 +213,24 @@ export const MainLogsWindow = () => {
}, []); }, []);
const copyFilteredLogs = useCallback(async () => { const copyFilteredLogs = useCallback(async () => {
const owner = viewOwnerRef.current;
if (!owner) return;
try { try {
await navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n')); if (!await owner.applyIfActive(
setCopyStatus('Copied'); () => navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n')),
() => setCopyStatus('Copied'),
)) return;
} catch { } catch {
if (!owner.isActive()) return;
setCopyStatus('Copy failed'); 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]); }, [filteredRows]);
return ( return (
@@ -219,7 +254,7 @@ export const MainLogsWindow = () => {
</button> </button>
<button <button
className="settings-button" className="settings-button"
onClick={() => void copyFilteredLogs()} onClick={() => windowAsyncScope.adopt(copyFilteredLogs())}
disabled={filteredRows.length === 0} disabled={filteredRows.length === 0}
> >
Copy Copy
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { AsyncOwner, windowAsyncScope } from './lib/asyncOwnership';
import './UnpackLogsWindow.css'; import './UnpackLogsWindow.css';
interface UnpackLogEntry { interface UnpackLogEntry {
@@ -49,28 +51,56 @@ export const UnpackLogsWindow = () => {
const [errorsOnly, setErrorsOnly] = useState(false); const [errorsOnly, setErrorsOnly] = useState(false);
const [regexInput, setRegexInput] = useState(''); const [regexInput, setRegexInput] = useState('');
const [selectedOriginalIndex, setSelectedOriginalIndex] = useState<number | null>(null); 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 refreshLogs = useCallback(async () => {
const unpackLogs = await invoke<UnpackLogEntry[]>('get_unpack_logs'); const owner = refreshOwnerRef.current;
setLogs(unpackLogs); if (!owner) return;
}, []); try {
await loadLogs(owner);
} catch (error) {
if (owner.isActive()) console.error('Failed to refresh unpack logs:', error);
}
}, [loadLogs]);
useEffect(() => { 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 () => { const setup = async () => {
await refreshLogs(); try {
unlisten = await listen('unpack-logs-updated', () => { const registered = await owner.register(() => listen(
void refreshLogs(); '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(); void setup();
return () => { return () => {
unlisten?.(); windowAsyncScope.adopt(owner.dispose());
if (refreshOwnerRef.current === owner) refreshOwnerRef.current = null;
}; };
}, [refreshLogs]); }, [loadLogs]);
const { regex, regexError } = useMemo(() => { const { regex, regexError } = useMemo(() => {
if (!regexInput) { 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 { import {
downloadProgressPercent, downloadProgressAriaLabel,
formatDownloadBytes, downloadProgressPercent,
formatDownloadEta, downloadProgressTransferLabel,
formatDownloadSpeed, formatDownloadBytes,
formatDownloadSpeedShort, formatDownloadEta,
} from '../lib/gameState'; formatDownloadSpeed,
formatDownloadSpeedShort,
} from "../lib/gameState";
import { Icon } from './Icon'; import { Icon } from "./Icon";
interface Props { interface Props {
game: Game; game: Game;
size?: 'md' | 'lg'; size?: "md" | "lg";
full?: boolean; full?: boolean;
onCancel?: (game: Game) => void; onCancel?: (game: Game) => void;
} }
const progressStats = (game: Game) => { const progressStats = (game: Game) => {
const progress = game.download_progress; const progress = game.download_progress;
const downloaded = progress?.downloaded_bytes ?? 0; const downloaded = progress?.downloaded_bytes ?? 0;
const total = progress?.total_bytes ?? game.size; const total = progress?.total_bytes ?? game.size;
const speed = progress?.bytes_per_second ?? 0; const speed = progress?.bytes_per_second ?? 0;
const remaining = Math.max(0, total - downloaded); const remaining = Math.max(0, total - downloaded);
const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY; const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY;
return { return {
pct: Math.min(99, Math.round(downloadProgressPercent(game))), pct: Math.min(99, Math.round(downloadProgressPercent(game))),
downloaded, downloaded,
total, total,
speed, speed,
eta: etaSeconds, eta: etaSeconds,
activePeerCount: progress?.active_peer_count ?? 0, activePeerCount: progress?.active_peer_count ?? 0,
}; };
}; };
export const DownloadProgress = ({ game, size = 'md', full = false, onCancel }: Props) => { export const DownloadProgress = (
const stats = progressStats(game); { game, size = "md", full = false, onCancel }: Props,
const progressStyle = { ) => {
'--download-progress': `${stats.pct}%`, const stats = progressStats(game);
} as CSSProperties; const transferLabel = downloadProgressTransferLabel(game);
const className = [ const ariaLabel = downloadProgressAriaLabel(game);
'dl', const progressStyle = {
size === 'lg' ? 'dl-lg' : 'dl-md', "--download-progress": `${stats.pct}%`,
full ? 'dl-full' : '', } as CSSProperties;
].filter(Boolean).join(' '); const className = [
"dl",
size === "lg" ? "dl-lg" : "dl-md",
full ? "dl-full" : "",
].filter(Boolean).join(" ");
const handleCancel = (event: MouseEvent<HTMLButtonElement>) => { const handleCancel = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation(); event.stopPropagation();
onCancel?.(game); onCancel?.(game);
}; };
if (size === 'lg') { if (size === "lg") {
const peerUnit = stats.activePeerCount === 1 ? 'peer' : 'peers'; 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>
);
}
return ( return (
<div <div
className={className} className={className}
role="progressbar" role="progressbar"
aria-label={`Downloading ${game.name}`} aria-label={ariaLabel}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
aria-valuenow={stats.pct} aria-valuenow={stats.pct}
title={`${stats.pct}% · ${formatDownloadSpeed(stats.speed)} · ${formatDownloadEta(stats.eta)} left`} style={progressStyle}
style={progressStyle} >
> <div className="dl-fill" aria-hidden />
<div className="dl-fill" aria-hidden /> <div className="dl-lg-grid">
<div className="dl-md-row"> <div className="dl-lg-primary">
<span className="dl-pct"> <span className="dl-pulse" aria-hidden />
<span className="dl-pulse" aria-hidden /> <span className="dl-label">{transferLabel ?? "Downloading"}</span>
{stats.pct} </div>
<span className="dl-pct-sym">%</span> <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>
<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>
</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 { Game } from "../../lib/types";
import { CoverAspect } from '../../hooks/useSettings'; import { CoverAspect } from "../../hooks/useSettings";
import { formatBytes } from '../../lib/format'; import { formatBytes } from "../../lib/format";
import { hasNewerLocalVersion } from '../../lib/gameState'; import {
gameTransferStatusPresentation,
hasNewerLocalVersion,
} from "../../lib/gameState";
import { GameCover } from './GameCover'; import { GameCover } from "./GameCover";
import { StateChip } from '../StateChip'; import { StateChip } from "../StateChip";
import { ActionButton } from '../ActionButton'; import { ActionButton } from "../ActionButton";
import { Icon } from '../Icon'; import { Icon } from "../Icon";
interface Props { interface Props {
game: Game; game: Game;
aspect: CoverAspect; aspect: CoverAspect;
thumbnailUrl: string | null; thumbnailUrl: string | null;
onOpen: (game: Game) => void; onOpen: (game: Game) => void;
onPrimary: (game: Game) => void; onPrimary: (game: Game) => void;
onCancelDownload: (game: Game) => void; onCancelDownload: (game: Game) => void;
} }
const metaSeparator = (...parts: Array<string | null | undefined>): JSX.Element[] => { const metaSeparator = (
const filtered = parts.filter(Boolean) as string[]; ...parts: Array<string | null | undefined>
const out: JSX.Element[] = []; ): JSX.Element[] => {
filtered.forEach((p, i) => { const filtered = parts.filter(Boolean) as string[];
if (i > 0) out.push(<span key={`d${i}`} className="card-dot">·</span>); const out: JSX.Element[] = [];
out.push(<span key={`p${i}`}>{p}</span>); filtered.forEach((p, i) => {
}); if (i > 0) out.push(<span key={`d${i}`} className="card-dot">·</span>);
return out; out.push(<span key={`p${i}`}>{p}</span>);
});
return out;
}; };
export const GameCard = ({ export const GameCard = ({
game, game,
aspect, aspect,
thumbnailUrl, thumbnailUrl,
onOpen, onOpen,
onPrimary, onPrimary,
onCancelDownload, onCancelDownload,
}: Props) => { }: Props) => {
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => { const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
onOpen(game); onOpen(game);
} }
}; };
const newerThanExpected = hasNewerLocalVersion(game); const newerThanExpected = hasNewerLocalVersion(game);
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0; const transferStatus = gameTransferStatusPresentation(game.transfer_status);
const statusMessage = hasOutbound const hasOutbound = game.active_outbound_transfers !== undefined &&
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}` game.active_outbound_transfers > 0;
: (game.status_message ?? (newerThanExpected ? 'Newer than expected' : '')); const statusMessage = transferStatus?.message ??
const statusLevel = hasOutbound (hasOutbound
? 'info' ? `Sharing to ${game.active_outbound_transfers} peer${
: (game.status_level ?? (newerThanExpected ? 'warning' : undefined)); 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 ( return (
<button <button
type="button" type="button"
className="card" className="card"
onClick={() => onOpen(game)} onClick={() => onOpen(game)}
onKeyDown={onKey} onKeyDown={onKey}
aria-label={game.name} 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}> {statusMessage}
<GameCover game={game} aspect={aspect} thumbnailUrl={thumbnailUrl} /> </div>
<StateChip game={game} /> <ActionButton
{game.peer_count > 0 && ( game={game}
<div className="card-mp" title={`${game.peer_count} peer${game.peer_count === 1 ? '' : 's'} have this`}> full
<Icon.users /> onClick={() => onPrimary(game)}
<span>{game.peer_count}</span> onCancelDownload={onCancelDownload}
</div> />
)} </div>
</div> </button>
<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>
);
}; };
@@ -1,187 +1,213 @@
import { Modal } from '../Modal'; import { Modal } from "../Modal";
import { Icon } from '../Icon'; import { Icon } from "../Icon";
import { GameCover } from '../grid/GameCover'; import { GameCover } from "../grid/GameCover";
import { StateChip } from '../StateChip'; import { StateChip } from "../StateChip";
import { ActionButton } from '../ActionButton'; import { ActionButton } from "../ActionButton";
import { Game, InstallStatus } from '../../lib/types'; import { Game, InstallStatus } from "../../lib/types";
import { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState'; import {
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format'; canStreamInstall,
gameStatusLabel,
gameTransferStatusPresentation,
hasNewerLocalVersion,
isInProgress,
} from "../../lib/gameState";
import { formatBytes, formatEtiVersion, formatPlayers } from "../../lib/format";
interface Props { interface Props {
game: Game; game: Game;
thumbnailUrl: string | null; thumbnailUrl: string | null;
onClose: () => void; supportsStreamedInstall: boolean;
onPrimary: (game: Game) => void; onClose: () => void;
onStreamInstall: (game: Game) => void; onPrimary: (game: Game) => void;
onUninstall: (game: Game) => void; onStreamInstall: (game: Game) => void;
onRemoveDownload: (game: Game) => void; onUninstall: (game: Game) => void;
onCancelDownload: (game: Game) => void; onRemoveDownload: (game: Game) => void;
onStartServer: (game: Game) => void; onCancelDownload: (game: Game) => void;
onViewFiles: (game: Game) => void; onStartServer: (game: Game) => void;
onViewFiles: (game: Game) => void;
} }
const tagsFromGame = (game: Game): string[] => { const tagsFromGame = (game: Game): string[] => {
const tags: string[] = []; const tags: string[] = [];
if (game.genre) tags.push(game.genre); if (game.genre) tags.push(game.genre);
if (game.publisher) tags.push(game.publisher); if (game.publisher) tags.push(game.publisher);
if (game.release_year) tags.push(game.release_year); if (game.release_year) tags.push(game.release_year);
return tags; return tags;
}; };
export const GameDetailModal = ({ export const GameDetailModal = ({
game, game,
thumbnailUrl, thumbnailUrl,
onClose, supportsStreamedInstall,
onPrimary, onClose,
onStreamInstall, onPrimary,
onUninstall, onStreamInstall,
onRemoveDownload, onUninstall,
onCancelDownload, onRemoveDownload,
onStartServer, onCancelDownload,
onViewFiles, onStartServer,
onViewFiles,
}: Props) => { }: Props) => {
const tags = tagsFromGame(game); const tags = tagsFromGame(game);
// Some game metadata contains a literal <br>; keep sanitization exact. // Some game metadata contains a literal <br>; keep sanitization exact.
const description = game.description.split('<br>').join(''); const description = game.description.split("<br>").join("");
const canRemoveDownload = game.downloaded const canRemoveDownload = game.downloaded &&
&& !game.installed !game.installed &&
&& !isInProgress(game.install_status); !isInProgress(game.install_status);
const showStreamInstall = canStreamInstall(game); const showStreamInstall = canStreamInstall(game, supportsStreamedInstall);
const canViewFiles = game.downloaded const canViewFiles = game.downloaded ||
|| game.installed game.installed ||
|| game.install_status === InstallStatus.Downloading game.install_status === InstallStatus.Downloading ||
|| game.install_status === InstallStatus.Installing; game.install_status === InstallStatus.Installing;
const newerThanExpected = hasNewerLocalVersion(game); const newerThanExpected = hasNewerLocalVersion(game);
const newerStatus = newerThanExpected const newerStatus = newerThanExpected
? `Local version ${formatEtiVersion(game.local_version)} is newer than expected ${formatEtiVersion(game.eti_game_version)}.` ? `Local version ${
: undefined; formatEtiVersion(game.local_version)
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0; } is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
const outboundStatus = hasOutbound : undefined;
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}.` const hasOutbound = game.active_outbound_transfers !== undefined &&
: undefined; game.active_outbound_transfers > 0;
const statusMessage = outboundStatus ?? game.status_message ?? newerStatus; const outboundStatus = hasOutbound
const statusLevel = hasOutbound ? `Sharing to ${game.active_outbound_transfers} peer${
? 'info' game.active_outbound_transfers === 1 ? "" : "s"
: (game.status_level ?? (newerStatus ? 'warning' : undefined)); }.`
return ( : undefined;
<Modal onClose={onClose}> const transferStatus = gameTransferStatusPresentation(game.transfer_status);
<button className="modal-close" type="button" onClick={onClose} aria-label="Close"> const statusMessage = transferStatus?.message ?? outboundStatus ??
<Icon.close /> 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> </button>
<div className="modal-hero"> )}
<GameCover game={game} aspect="banner" thumbnailUrl={thumbnailUrl} hideTitle /> {game.installed && game.can_host_server === true && (
<div className="modal-hero-fade" /> <button
<div className="modal-hero-text"> type="button"
{tags.length > 0 && ( className="act-btn act-lg act-server"
<div className="modal-tags"> onClick={() => onStartServer(game)}
{tags.map(t => <span key={t} className="modal-tag">{t}</span>)} >
</div> <Icon.server />
)} <span className="act-label">Start Server</span>
<h2 className="modal-title">{game.name}</h2> </button>
</div> )}
<div className="modal-state"> {game.installed && (
<StateChip game={game} showNone /> <button
</div> type="button"
</div> className="ghost-btn ghost-danger"
onClick={() => onUninstall(game)}
<div className="modal-body"> >
<div className="modal-meta"> <Icon.trash />
<div className="meta-cell"> <span>Uninstall</span>
<div className="meta-label">Size</div> </button>
<div className="meta-value">{formatBytes(game.size)}</div> )}
</div> {canRemoveDownload && (
<div className="meta-cell"> <button
<div className="meta-label">Players</div> type="button"
<div className="meta-value"> className="ghost-btn ghost-danger"
<Icon.users /> {formatPlayers(game.max_players)} onClick={() => onRemoveDownload(game)}
</div> >
</div> <Icon.trash />
<div className="meta-cell"> <span>Remove files</span>
<div className="meta-label">Version</div> </button>
<div className="meta-value meta-mono"> )}
{formatEtiVersion(game.eti_game_version ?? game.local_version)} {canViewFiles && (
</div> <>
</div> <div className="modal-actions-spacer" />
<div className="meta-cell"> <button
<div className="meta-label">Status</div> type="button"
<div className="meta-value">{gameStatusLabel(game)}</div> className="ghost-btn"
</div> onClick={() => onViewFiles(game)}
</div> >
<Icon.folder />
{description && ( <span>View Files</span>
<p className="modal-desc">{description}</p> </button>
)} </>
)}
{statusMessage && ( </div>
<p className={`modal-status${statusLevel ? ` is-${statusLevel}` : ''}`}> </div>
{statusMessage} </Modal>
</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>
);
}; };
@@ -11,6 +11,10 @@ import {
LANGUAGE_OPTIONS, LANGUAGE_OPTIONS,
type UISettings, type UISettings,
} from '../../hooks/useSettings'; } from '../../hooks/useSettings';
import {
type LocalNetworkSharingSnapshot,
displayedSharingTarget,
} from '../../lib/localNetworkSharing';
interface Props { interface Props {
settings: UISettings; settings: UISettings;
@@ -18,6 +22,11 @@ interface Props {
hasGameDirectory: boolean; hasGameDirectory: boolean;
onPickDirectory: () => void; onPickDirectory: () => void;
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => 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; onClose: () => void;
} }
@@ -59,6 +68,28 @@ const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInpu
</div> </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 { interface GameFolderFieldProps {
path: string; path: string;
isValid: boolean; isValid: boolean;
@@ -88,6 +119,11 @@ export const SettingsDialog = ({
hasGameDirectory, hasGameDirectory,
onPickDirectory, onPickDirectory,
onChange, onChange,
localNetworkSharing,
localNetworkSharingReady,
localNetworkSharingBusy,
localNetworkSharingError,
onLocalNetworkSharingChange,
onClose, onClose,
}: Props) => ( }: Props) => (
<Modal onClose={onClose} className="settings-modal"> <Modal onClose={onClose} className="settings-modal">
@@ -123,6 +159,25 @@ export const SettingsDialog = ({
</Row> </Row>
</section> </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"> <section className="settings-section">
<div className="settings-section-title">Appearance</div> <div className="settings-section-title">Appearance</div>
<Row label="Accent color" hint="Used for primary actions and highlights"> <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 { invoke } from '@tauri-apps/api/core';
import { listen, UnlistenFn } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { import {
CALL_TO_PLAY_CONNECTING_MESSAGE, CALL_TO_PLAY_CONNECTING_MESSAGE,
callToPlayEvent,
callToPlayPublishErrorMessage, callToPlayPublishErrorMessage,
extendDeadline, extendDeadline,
pruneCallToPlayEvents,
reduceCallToPlayEvents, reduceCallToPlayEvents,
replaceCallToPlayView,
} from '../lib/callToPlay'; } 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 { export interface CallToPlayActions {
createNomination: ( createNomination: (
@@ -36,129 +43,177 @@ export interface UseCallToPlay {
error: string | null; error: string | null;
} }
const mergeEvents = ( type PublishOutcome =
previous: ReadonlyMap<string, CallToPlayEvent>, | { kind: 'connecting' }
incoming: ReadonlyArray<CallToPlayEvent>, | { kind: 'settled'; accepted: boolean }
): Map<string, CallToPlayEvent> => { | { kind: 'failed'; error: unknown };
const next = new Map(previous);
for (const event of incoming) next.set(event.id, event);
return next;
};
export const useCallToPlay = (username: string): UseCallToPlay => { export const useCallToPlay = (
username: string,
localNetworkSharingEnabled = true,
): UseCallToPlay => {
const actor = useMemo(() => { const actor = useMemo(() => {
const trimmed = username.trim(); const trimmed = username.trim();
return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander'; return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander';
}, [username]); }, [username]);
const [events, setEvents] = useState<ReadonlyMap<string, CallToPlayEvent>>(() => new Map()); const [view, setView] = useState<CallToPlayView>({ events: [] });
const [actorId, setActorId] = useState<string | null>(null); const [actorId, setActorId] = useState<string | null>(null);
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
const [transportReady, setTransportReady] = useState(false); const [transportReady, setTransportReady] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const asyncScopeRef = useRef<CallToPlayAsyncScope | null>(null);
const actorRef = useRef(actor);
actorRef.current = actor;
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
const current = Date.now(); setNow(Date.now());
setNow(current);
setEvents(previous => pruneCallToPlayEvents(previous, current));
}, 1_000); }, 1_000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, []); }, []);
useEffect(() => { useEffect(() => {
let cancelled = false; const scope = new CallToPlayAsyncScope(
let unlisten: UnlistenFn | undefined; (callback, delayMilliseconds) => {
let retry: number | undefined; 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> => { const requestSnapshot = async (): Promise<boolean> => {
let ready = false;
try { try {
const peerId = await invoke<string | null>('request_call_to_play_events'); const applied = await scope.applyIfActive(
if (cancelled) return false; () => invoke<string | null>('request_call_to_play_view'),
const ready = peerId !== null; peerId => {
setActorId(peerId); ready = peerId !== null;
setTransportReady(ready); setActorId(peerId);
if (ready) { setTransportReady(ready);
setError(current => if (ready) {
current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current setError(current =>
); current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current
} );
if (ready && retry !== undefined) { scope.stopRetry();
window.clearInterval(retry); void syncDisplayName();
retry = undefined; }
} },
return ready; );
return applied && ready;
} catch (err) { } catch (err) {
if (!cancelled) { if (!scope.isActive()) return false;
setTransportReady(false); setTransportReady(false);
console.error('request_call_to_play_events failed:', err); console.error('request_call_to_play_view failed:', err);
}
return false; return false;
} }
}; };
const register = async () => { const register = async () => {
try { try {
unlisten = await listen<CallToPlayEvent[]>('call-to-play-events', event => { const registered = await scope.registerListener(() =>
setEvents(previous => mergeEvents(previous, event.payload)); listen<CallToPlayView>('call-to-play-view', scope.guard(event => {
}); setView(previous => replaceCallToPlayView(previous, event.payload));
if (cancelled) { }))
unlisten(); );
return; if (!registered) return;
}
const ready = await requestSnapshot(); const ready = await requestSnapshot();
if (!cancelled && !ready) { if (scope.isActive() && !ready) scope.startRetry(requestSnapshot, 2_000);
retry = window.setInterval(() => void requestSnapshot(), 2_000);
}
} catch (err) { } catch (err) {
if (!scope.isActive()) return;
console.error('Failed to register Call to Play listener:', err); console.error('Failed to register Call to Play listener:', err);
if (!cancelled) { setTransportReady(false);
setTransportReady(false); setError('Call to Play networking is unavailable.');
setError('Call to Play networking is unavailable.');
}
} }
}; };
void register(); void scope.applyIfActive(register, () => {});
return () => { return () => {
cancelled = true; if (asyncScopeRef.current === scope) asyncScopeRef.current = null;
unlisten?.(); windowAsyncScope.adopt(scope.dispose());
if (retry !== undefined) window.clearInterval(retry);
}; };
}, []); }, []);
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 ( const publish = useCallback(async (
callId: string, callId: string | null,
action: CallToPlayAction, action: CallToPlayAction,
): Promise<boolean> => { ): Promise<boolean> => {
if (actorId === null) { const scope = asyncScopeRef.current;
setTransportReady(false); if (scope === null || !scope.isActive() || !localNetworkSharingEnabled) return false;
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
return false; const publishOutcome = scope.guardLatestAction((outcome: PublishOutcome) => {
} if (outcome.kind === 'connecting') {
const event = callToPlayEvent(callId, actorId, actor, action);
try {
const accepted = await invoke<boolean>('publish_call_to_play', { event });
if (!accepted) {
setTransportReady(false); setTransportReady(false);
setError(CALL_TO_PLAY_CONNECTING_MESSAGE); 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); if (actorId === null) {
return true; publishOutcome({ kind: 'connecting' });
} catch (err) {
console.error('publish_call_to_play failed:', err);
setError(callToPlayPublishErrorMessage(err));
return false; 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>(() => ({ const actions = useMemo<CallToPlayActions>(() => ({
createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => { createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => {
const createdAt = Date.now(); const createdAt = Date.now();
const callId = globalThis.crypto.randomUUID();
const deadline = scheduledFor ?? createdAt + durationMinutes * 60_000; const deadline = scheduledFor ?? createdAt + durationMinutes * 60_000;
void publish(callId, { void publish(null, {
Create: { Create: {
game_id: gameId, game_id: gameId,
max_players: Math.max(2, Math.min(64, Math.round(maxPlayers))), max_players: Math.max(2, Math.min(64, Math.round(maxPlayers))),
@@ -178,7 +233,6 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
if (!trimmed) return; if (!trimmed) return;
void publish(callId, { void publish(callId, {
SendMessage: { SendMessage: {
message_id: globalThis.crypto.randomUUID(),
text: trimmed, text: trimmed,
}, },
}); });
@@ -192,9 +246,15 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
}), [publish]); }), [publish]);
const nominations = useMemo( const nominations = useMemo(
() => reduceCallToPlayEvents([...events.values()], now), () => reduceCallToPlayEvents(view.events, now),
[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 { useCallback, useEffect, useRef } from "react";
import { invoke } from '@tauri-apps/api/core'; import { invoke } from "@tauri-apps/api/core";
import { ask } from '@tauri-apps/plugin-dialog'; import { ask } from "@tauri-apps/plugin-dialog";
import { type UseGamesResult } from './useGames'; import { type UseGamesResult } from "./useGames";
import { type UISettings } from './useSettings'; import { type UISettings } from "./useSettings";
import { AsyncOwner, windowAsyncScope } from "../lib/asyncOwnership";
export interface GameActions { export interface GameActions {
play: (id: string) => Promise<void>; play: (id: string) => Promise<void>;
startServer: (id: string) => Promise<void>; startServer: (id: string) => Promise<void>;
install: (id: string) => Promise<void>; install: (id: string) => Promise<void>;
streamInstall: (id: string) => Promise<void>; streamInstall: (id: string) => Promise<void>;
update: (id: string) => Promise<void>; update: (id: string) => Promise<void>;
uninstall: (id: string) => Promise<void>; uninstall: (id: string) => Promise<void>;
removeDownload: (id: string) => Promise<void>; removeDownload: (id: string) => Promise<void>;
cancelDownload: (id: string) => Promise<void>; cancelDownload: (id: string) => Promise<void>;
viewFiles: (id: string) => Promise<void>; viewFiles: (id: string) => Promise<void>;
} }
/** /**
* Thin wrappers over the backend `run_game` / `install_game` / `update_game` * Thin wrappers over the backend `run_game` / `install_game` / `update_game`
* / `uninstall_game` / `remove_downloaded_game` commands. Peer-backed downloads * / `uninstall_game` / `remove_downloaded_game` commands. Operation state is
* are marked as "checking peers" until the backend emits an authoritative * derived only from authoritative backend snapshots; cancellation waits for
* operation snapshot; cancellation waits for the backend to clear that snapshot. * the backend to clear that snapshot.
*/ */
export const useGameActions = ( export const useGameActions = (
games: UseGamesResult, games: UseGamesResult,
settings: Pick<UISettings, 'language' | 'username'>, settings: Pick<UISettings, "language" | "username">,
): GameActions => { ): GameActions => {
const play = useCallback(async (id: string) => { const ownerRef = useRef<AsyncOwner | null>(null);
try {
await invoke('run_game', {
id,
language: settings.language,
username: settings.username,
});
} catch (err) {
console.error('run_game failed:', err);
}
}, [settings.language, settings.username]);
const startServer = useCallback(async (id: string) => { useEffect(() => {
try { const owner = new AsyncOwner((error) => {
await invoke('start_server', { console.error("Failed to clean up a game action:", error);
id, });
language: settings.language, ownerRef.current = owner;
username: settings.username, return () => {
}); if (ownerRef.current === owner) ownerRef.current = null;
} catch (err) { windowAsyncScope.adopt(owner.dispose());
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,
}; };
}, []);
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 { invoke } from '@tauri-apps/api/core';
import { load } from '@tauri-apps/plugin-store'; 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'; 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 * Owns the backend-accepted game directory. Both restored and newly selected
* on mount, writes back on every change, and pushes the value to the Tauri * paths enter frontend state only after the backend returns its canonical path.
* backend so it can scan/rescan.
*/ */
export const useGameDirectory = () => { export const useGameDirectory = (backendPolicyReady = true) => {
const [gameDir, setGameDir] = useState(''); const [gameDir, setAcceptedGameDir] = useState('');
const [gameDirExists, setGameDirExists] = useState(false); 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 { try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); const acceptedPath = await requestGameDirectory(path);
const saved = await store.get<string>(GAME_DIR_KEY); acceptedGameDirRef.current = acceptedPath;
if (saved && !cancelled) setGameDir(saved); if (mountedRef.current && acceptingRef.current) {
} catch (err) { setAcceptedGameDir(acceptedPath);
console.error('Failed to load game directory:', err); }
} catch (error) {
console.error(failureMessage, error);
} }
}; });
void hydrate(); updateQueueRef.current = update;
return () => { windowAsyncScope.adopt(update);
cancelled = true; return update;
};
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!gameDir.trim()) { if (!backendPolicyReady) {
setGameDirExists(false); setReady(false);
return; return;
} }
let cancelled = false; setReady(false);
const sync = async () => { return startAdmittedPersistenceEffect(
try { windowPersistenceScope,
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); shutdown,
await store.set(GAME_DIR_KEY, gameDir); () => {
} catch (err) { let cancelled = false;
console.error('Failed to persist game directory:', err); 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; const setGameDir = useCallback((requestedPath: string) => {
try { selectionVersionRef.current += 1;
exists = await invoke<boolean>('game_directory_exists', { path: gameDir }); void enqueueUpdate(
} catch (err) { () => requestedPath,
console.error('Failed to validate game directory:', err); 'Failed to update game directory:',
} );
if (cancelled) return; }, [enqueueUpdate]);
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 rescan = useCallback(() => { const rescan = useCallback(() => {
if (!gameDir.trim()) { void enqueueUpdate(
setGameDirExists(false); () => acceptedGameDirRef.current,
return; 'Failed to rescan game directory:',
} );
const sync = async () => { }, [enqueueUpdate]);
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;
invoke('update_game_directory', { path: gameDir }).catch(err => return {
console.error('Failed to rescan game directory:', err), gameDir,
); ready,
}; hasGameDirectory: gameDir !== '',
void sync(); setGameDir,
}, [gameDir]); rescan,
shutdown,
return { gameDir, gameDirExists, hasGameDirectory, setGameDir, rescan }; };
}; };
@@ -1,188 +1,271 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from '@tauri-apps/api/core'; import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from '@tauri-apps/api/event'; import { listen } from "@tauri-apps/api/event";
import { import {
DownloadProgressPayload, DownloadProgressPayload,
Game, Game,
GamesListPayload, GamesListPayload,
InstallStatus, GameTransferStatusSnapshot,
} from '../lib/types'; ProtocolMismatch,
} from "../lib/types";
import { import {
activeStatusById, activeStatusById,
isInProgress, applyDownloadProgress,
mergeGameUpdate, applyGameTransferStatusSnapshot,
normalizeGamesListPayload, gameTransferStatusFor,
} from '../lib/gameState'; 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 { /** Owns the games list and derives card status from backend snapshots. */
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.
*/
export interface UseGamesResult { export interface UseGamesResult {
games: Game[]; games: Game[];
setGames: React.Dispatch<React.SetStateAction<Game[]>>; setGames: React.Dispatch<React.SetStateAction<Game[]>>;
totalPeerCount: number; totalPeerCount: number;
requestGames: () => Promise<void>; protocolMismatch: ProtocolMismatch | null;
markChecking: (id: string) => void; requestGames: () => Promise<void>;
} }
export const useGames = (rescanGameDir: () => void): UseGamesResult => { export const useGames = (rescanGameDir: () => void): UseGamesResult => {
const [games, setGames] = useState<Game[]>([]); const [games, setGames] = useState<Game[]>([]);
const [totalPeerCount, setTotalPeerCount] = useState(0); const [totalPeerCount, setTotalPeerCount] = useState(0);
const rescanRef = useRef(rescanGameDir); const [protocolMismatchSnapshot, setProtocolMismatchSnapshot] = useState(
rescanRef.current = rescanGameDir; 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) => { const requestGames = useCallback(async () => {
setGames(prev => prev.map(item => try {
item.id === id && !isInProgress(item.install_status) await invoke("request_games");
? applyPatch(item, { } catch (err) {
install_status: InstallStatus.CheckingPeers, console.error("request_games failed:", err);
clearStatus: true, }
}) }, []);
: item
));
}, []);
const requestGames = useCallback(async () => { useEffect(() => {
try { const owner = new AsyncOwner((error) => {
await invoke('request_games'); console.error("Failed to unregister game listener:", error);
} catch (err) { });
console.error('request_games failed:', err);
}
}, []);
useEffect(() => { const handleErrorEvent = (
const unlisteners: UnlistenFn[] = []; id: string,
let cancelled = false; message: string,
{ triggerRescan = false }: { triggerRescan?: boolean } = {},
const handleErrorEvent = ( ) => {
id: string, setGames((prev) =>
message: string, prev.map((item) =>
{ triggerRescan = false }: { triggerRescan?: boolean } = {}, item.id === id
) => { ? {
setGames(prev => prev.map(item => item.id === id ...item,
? { status_message: message,
...item, status_level: "error",
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);
} }
}; : item
)
void register(); );
if (triggerRescan) rescanRef.current();
return () => {
cancelled = true;
unlisteners.forEach(fn => fn());
};
}, []);
return {
games,
setGames,
totalPeerCount,
requestGames,
markChecking,
}; };
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 { load } from '@tauri-apps/plugin-store';
import { type GameFilter, type GameSort, type LauncherLanguage } from '../lib/types'; import { type GameFilter, type GameSort, type LauncherLanguage } from '../lib/types';
import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store'; 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 Density = 'compact' | 'normal' | 'large';
export type CoverAspect = 'box' | 'square' | 'banner'; export type CoverAspect = 'box' | 'square' | 'banner';
@@ -102,8 +111,21 @@ export interface UseSettings {
settings: UISettings; settings: UISettings;
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void; set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
ready: boolean; 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 * Loads UI preferences from the Tauri persistent store once on mount and
* writes every change back through it. Components only see a synchronous * writes every change back through it. Components only see a synchronous
@@ -112,44 +134,69 @@ export interface UseSettings {
export const useSettings = (): UseSettings => { export const useSettings = (): UseSettings => {
const [settings, setSettings] = useState<UISettings>(DEFAULT_SETTINGS); const [settings, setSettings] = useState<UISettings>(DEFAULT_SETTINGS);
const [ready, setReady] = useState(false); 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(() => { const shutdown = useCallback(async (): Promise<void> => {
let cancelled = false; acceptingRef.current = false;
const init = async () => { await hydrationRef.current;
try { await settingsWriter.closeAndWait();
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;
};
}, []); }, []);
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]) => { const set = useCallback(<K extends keyof UISettings>(key: K, value: UISettings[K]) => {
setSettings(prev => { if (!acceptingRef.current) return;
const next = { ...prev, [key]: value };
void persist(next); const next = { ...settingsRef.current, [key]: value };
return next; if (!hydratedRef.current) {
}); pendingEditsRef.current = {
...pendingEditsRef.current,
[key]: value,
};
}
settingsRef.current = next;
setSettings(next);
windowAsyncScope.adopt(settingsWriter.enqueue({ [key]: value }));
}, []); }, []);
return { settings, set, ready }; return { settings, set, ready, shutdown };
};
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);
}
}; };
@@ -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 { 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 * Per-id cache for cover thumbnails belonging to the supplied game ids.
* known; returns the empty string when the backend has nothing for the id, so * Returns `null` until the value is known; returns the empty string when the
* callers can fall back to the placeholder cover art. * 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 [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 => { const get = useCallback((id: string): string | null => {
if (thumbnails.has(id)) return thumbnails.get(id) ?? ''; 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; return null;
}, [thumbnails]); }, [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 { import {
CallToPlayAction, CallToPlayAction,
CallToPlayEvent, CallToPlayView,
CallToPlayViewEvent,
CallToPlayParticipant, CallToPlayParticipant,
Nomination, Nomination,
} from './types'; } from './types';
@@ -13,17 +14,22 @@ export const CALL_TO_PLAY_CONNECTING_MESSAGE =
export const callToPlayPublishErrorMessage = (error: unknown): string => { export const callToPlayPublishErrorMessage = (error: unknown): string => {
const detail = error instanceof Error ? error.message : String(error); const detail = error instanceof Error ? error.message : String(error);
if (detail.includes('Call to Play event is obsolete') if (detail.includes('unknown or expired')
|| detail.includes('Call to Play history is missing') || detail.includes('already terminal')
) { ) {
return 'This Call to Play has expired or already finished.'; 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 '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.'; return 'Could not send this Call to Play update.';
}; };
export const replaceCallToPlayView = (
_previous: CallToPlayView,
incoming: CallToPlayView,
): CallToPlayView => incoming;
export const extendDeadline = ( export const extendDeadline = (
now: number, now: number,
currentDeadline: number, currentDeadline: number,
@@ -44,7 +50,7 @@ interface MutableNomination extends Nomination {
messageIds: Set<string>; 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); a.at - b.at || a.id.localeCompare(b.id);
type CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create']; type CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create'];
@@ -117,7 +123,7 @@ export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus
}; };
export const reduceCallToPlayEvents = ( export const reduceCallToPlayEvents = (
input: ReadonlyArray<CallToPlayEvent>, input: ReadonlyArray<CallToPlayViewEvent>,
now: number, now: number,
): Nomination[] => { ): Nomination[] => {
const nominations = [...groupEvents(input).values()] const nominations = [...groupEvents(input).values()]
@@ -126,35 +132,11 @@ export const reduceCallToPlayEvents = (
return sortNominations(nominations); 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 = ( const groupEvents = (
input: ReadonlyArray<CallToPlayEvent>, input: ReadonlyArray<CallToPlayViewEvent>,
): Map<string, CallToPlayEvent[]> => { ): Map<string, CallToPlayViewEvent[]> => {
const unique = new Map(input.map(event => [event.id, event])); 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()) { for (const event of unique.values()) {
const events = byCall.get(event.call_id) ?? []; const events = byCall.get(event.call_id) ?? [];
events.push(event); events.push(event);
@@ -164,7 +146,7 @@ const groupEvents = (
}; };
const deriveNomination = ( const deriveNomination = (
events: CallToPlayEvent[], events: CallToPlayViewEvent[],
now: number, now: number,
): Nomination | null => { ): Nomination | null => {
events.sort(compareEvents); events.sort(compareEvents);
@@ -176,15 +158,15 @@ const deriveNomination = (
const nomination: MutableNomination = { const nomination: MutableNomination = {
id: create.call_id, id: create.call_id,
gameId: payload.game_id, gameId: payload.game_id,
creatorId: create.actor_id, creatorId: create.author_id,
creator: create.actor_name, creator: create.author_name,
maxPlayers: payload.max_players, maxPlayers: payload.max_players,
createdAt: create.at, createdAt: create.at,
scheduledFor: payload.scheduled_for, scheduledFor: payload.scheduled_for,
deadline: payload.deadline, deadline: payload.deadline,
participants: { participants: {
[create.actor_id]: { [create.author_id]: {
name: create.actor_name, name: create.author_name,
status: payload.scheduled_for === null ? 'ready' : 'in', status: payload.scheduled_for === null ? 'ready' : 'in',
joinedAt: create.at, joinedAt: create.at,
}, },
@@ -214,7 +196,7 @@ const deriveNomination = (
return result; return result;
}; };
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => { const applyEvent = (nomination: MutableNomination, event: CallToPlayViewEvent): void => {
if (isTerminal(nomination)) return; if (isTerminal(nomination)) return;
const action = event.action; const action = event.action;
@@ -225,9 +207,9 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
const response = respondPayload(action); const response = respondPayload(action);
if (response) { if (response) {
const existing = nomination.participants[event.actor_id]; const existing = nomination.participants[event.author_id];
nomination.participants[event.actor_id] = { nomination.participants[event.author_id] = {
name: event.actor_name, name: event.author_name,
status: response.ready_at === null ? 'ready' : 'pending', status: response.ready_at === null ? 'ready' : 'pending',
joinedAt: existing?.joinedAt ?? event.at, joinedAt: existing?.joinedAt ?? event.at,
...(response.ready_at === null ? {} : { readyAt: response.ready_at }), ...(response.ready_at === null ? {} : { readyAt: response.ready_at }),
@@ -236,12 +218,12 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
} }
const message = messagePayload(action); const message = messagePayload(action);
if (message && !nomination.messageIds.has(message.message_id)) { if (message && !nomination.messageIds.has(event.id)) {
nomination.messageIds.add(message.message_id); nomination.messageIds.add(event.id);
nomination.messages.push({ nomination.messages.push({
id: message.message_id, id: event.id,
fromId: event.actor_id, fromId: event.author_id,
from: event.actor_name, from: event.author_name,
text: message.text, text: message.text,
at: event.at, at: event.at,
}); });
@@ -251,7 +233,7 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
const extension = addTimePayload(action); const extension = addTimePayload(action);
if (extension if (extension
&& event.actor_id === nomination.creatorId && event.author_id === nomination.creatorId
) { ) {
nomination.deadline = extension.deadline; nomination.deadline = extension.deadline;
nomination.state = 'open'; nomination.state = 'open';
@@ -260,32 +242,32 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
const applyUnitAction = ( const applyUnitAction = (
nomination: MutableNomination, nomination: MutableNomination,
event: CallToPlayEvent, event: CallToPlayViewEvent,
action: Extract<CallToPlayAction, string>, action: Extract<CallToPlayAction, string>,
): void => { ): void => {
switch (action) { switch (action) {
case 'Rsvp': { case 'Rsvp': {
const existing = nomination.participants[event.actor_id]; const existing = nomination.participants[event.author_id];
nomination.participants[event.actor_id] = { nomination.participants[event.author_id] = {
name: event.actor_name, name: event.author_name,
status: 'in', status: 'in',
joinedAt: existing?.joinedAt ?? event.at, joinedAt: existing?.joinedAt ?? event.at,
}; };
break; break;
} }
case 'Leave': case 'Leave':
if (event.actor_id !== nomination.creatorId) { if (event.author_id !== nomination.creatorId) {
delete nomination.participants[event.actor_id]; delete nomination.participants[event.author_id];
} }
break; break;
case 'Cancel': case 'Cancel':
if (event.actor_id === nomination.creatorId) { if (event.author_id === nomination.creatorId) {
nomination.state = 'cancelled'; nomination.state = 'cancelled';
nomination.terminalAt = event.at; nomination.terminalAt = event.at;
} }
break; break;
case 'Start': case 'Start':
if (event.actor_id === nomination.creatorId) { if (event.author_id === nomination.creatorId) {
nomination.state = 'running'; nomination.state = 'running';
nomination.terminalAt = event.at; 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 => { export const formatClock = (timestamp: number): string => {
const date = new Date(timestamp); const date = new Date(timestamp);
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; 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 { import {
ActiveOperation, ActiveOperation,
ActiveOperationKind, ActiveOperationKind,
DerivedState, DerivedState,
Game, DownloadProgressPayload,
GameFilter, Game,
GameSort, GameFilter,
GamesListPayload, GameSort,
InstallStatus, GameTransferStatus,
} from './types'; GameTransferStatusSnapshot,
InstallStatus,
StatusLevel,
} from "./types";
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([ const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
InstallStatus.CheckingPeers, InstallStatus.Downloading,
InstallStatus.Downloading, InstallStatus.Installing,
InstallStatus.Installing, InstallStatus.Uninstalling,
InstallStatus.Uninstalling, InstallStatus.Removing,
InstallStatus.Removing,
]); ]);
export const isInProgress = (status: InstallStatus): boolean => export const isInProgress = (status: InstallStatus): boolean =>
IN_PROGRESS_INSTALL_STATUSES.has(status); IN_PROGRESS_INSTALL_STATUSES.has(status);
export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => { export const installStatusFromActiveOperation = (
switch (op) { op: ActiveOperationKind,
case ActiveOperationKind.Downloading: ): InstallStatus => {
return InstallStatus.Downloading; switch (op) {
case ActiveOperationKind.Installing: case ActiveOperationKind.Downloading:
case ActiveOperationKind.Updating: return InstallStatus.Downloading;
return InstallStatus.Installing; case ActiveOperationKind.Installing:
case ActiveOperationKind.Uninstalling: case ActiveOperationKind.Updating:
return InstallStatus.Uninstalling; return InstallStatus.Installing;
case ActiveOperationKind.RemovingDownload: case ActiveOperationKind.Uninstalling:
return InstallStatus.Removing; return InstallStatus.Uninstalling;
} case ActiveOperationKind.RemovingDownload:
return InstallStatus.Removing;
}
}; };
export const activeStatusById = (ops: ActiveOperation[] = []): Map<string, InstallStatus> => export const activeStatusById = (
new Map(ops.map(op => [op.id, installStatusFromActiveOperation(op.operation)])); ops: ActiveOperation[] = [],
): Map<string, InstallStatus> =>
new Map(
ops.map((op) => [op.id, installStatusFromActiveOperation(op.operation)]),
);
export const normalizeGamesListPayload = ( export const INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT: GameTransferStatusSnapshot =
payload: GamesListPayload | Game[], {
): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload; 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 * Reconcile a freshly received backend snapshot. Core operation status is
* derived only from the backend active-operation snapshot plus installed state. * derived only from the backend active-operation snapshot plus installed state.
*/ */
export const mergeGameUpdate = ( export const mergeGameUpdate = (
incoming: Game, incoming: Game,
previous?: Game, previous?: Game,
activeStatus?: InstallStatus, activeStatus?: InstallStatus,
transferStatus?: GameTransferStatus,
): Game => { ): Game => {
const installStatus = activeStatus const installStatus = activeStatus ??
?? (incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled); (incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled);
const localStateChanged = previous !== undefined const localStateChanged = previous !== undefined &&
&& (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded); (previous.installed !== incoming.installed ||
const statusChanged = previous !== undefined previous.downloaded !== incoming.downloaded);
&& previous.install_status !== installStatus; const statusChanged = previous !== undefined &&
const clearStatus = localStateChanged previous.install_status !== installStatus;
|| (statusChanged && (activeStatus !== undefined || isInProgress(previous.install_status))); const clearStatus = localStateChanged ||
(statusChanged &&
(activeStatus !== undefined || isInProgress(previous.install_status)));
return { return {
...incoming, ...incoming,
availability: incoming.availability, availability: incoming.availability,
install_status: installStatus, install_status: installStatus,
status_message: clearStatus ? undefined : previous?.status_message, status_message: clearStatus ? undefined : previous?.status_message,
status_level: clearStatus ? undefined : previous?.status_level, status_level: clearStatus ? undefined : previous?.status_level,
download_progress: installStatus === InstallStatus.Downloading transfer_status: transferStatus,
? previous?.download_progress download_progress: installStatus === InstallStatus.Downloading
: undefined, ? previous?.download_progress
peer_count: incoming.peer_count ?? 0, : undefined,
}; peer_count: incoming.peer_count ?? 0,
};
}; };
/** Visual card state — used for state chip color and action button styling. */ /** Visual card state — used for state chip color and action button styling. */
export const deriveState = (game: Game): DerivedState => { export const deriveState = (game: Game): DerivedState => {
if (game.install_status === InstallStatus.Downloading) return 'downloading'; if (game.install_status === InstallStatus.Downloading) return "downloading";
if (isInProgress(game.install_status)) return 'busy'; if (isInProgress(game.install_status)) return "busy";
if (game.installed) return 'installed'; if (game.installed) return "installed";
if (game.downloaded) return 'local'; if (game.downloaded) return "local";
return 'none'; return "none";
}; };
export const isInstalledNotShareable = (game: Game): boolean => export const isInstalledNotShareable = (game: Game): boolean =>
game.installed && !game.downloaded; game.installed && !game.downloaded;
export const stateChipLabel = (game: Game): string => { export const stateChipLabel = (game: Game): string => {
const state = deriveState(game); const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable'; if (state === "installed" && isInstalledNotShareable(game)) {
switch (state) { return "Not shareable";
case 'installed': return 'Installed'; }
case 'local': return 'Local'; switch (state) {
case 'downloading': return 'Downloading'; case "installed":
case 'busy': return 'Working'; return "Installed";
case 'none': return ''; case "local":
} return "Local";
case "downloading":
return "Downloading";
case "busy":
return "Working";
case "none":
return "";
}
}; };
export const gameStatusLabel = (game: Game): string => { export const gameStatusLabel = (game: Game): string => {
const state = deriveState(game); const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) { if (state === "installed" && isInstalledNotShareable(game)) {
return 'Installed, not shareable'; return "Installed, not shareable";
} }
switch (state) { switch (state) {
case 'installed': return 'Installed'; case "installed":
case 'local': return 'Downloaded'; return "Installed";
case 'downloading': return 'Downloading'; case "local":
case 'busy': return 'Working…'; return "Downloaded";
case 'none': return 'Not downloaded'; case "downloading":
} return "Downloading";
case "busy":
return "Working…";
case "none":
return "Not downloaded";
}
}; };
export const isUnavailable = (game: Game): boolean => export const isUnavailable = (game: Game): boolean =>
!game.installed !game.installed &&
&& !game.downloaded !game.downloaded &&
&& game.peer_count === 0 game.peer_count === 0 &&
&& game.install_status === InstallStatus.NotInstalled; game.install_status === InstallStatus.NotInstalled;
const parseVersionStamp = (version: string | undefined): number | null => { const parseVersionStamp = (version: string | undefined): number | null => {
if (!version || !/^\d{8}$/.test(version)) return null; if (!version || !/^\d{8}$/.test(version)) return null;
const parsed = parseInt(version, 10); const parsed = parseInt(version, 10);
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
}; };
export const compareVersionStamps = ( export const compareVersionStamps = (
left: string | undefined, left: string | undefined,
right: string | undefined, right: string | undefined,
): number | null => { ): number | null => {
const parsedLeft = parseVersionStamp(left); const parsedLeft = parseVersionStamp(left);
const parsedRight = parseVersionStamp(right); const parsedRight = parseVersionStamp(right);
if (parsedLeft === null || parsedRight === null) return null; if (parsedLeft === null || parsedRight === null) return null;
return parsedLeft - parsedRight; return parsedLeft - parsedRight;
}; };
export const hasNewerLocalVersion = (game: Game): boolean => 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 => { export const needsUpdate = (game: Game): boolean => {
if (!game.installed) return false; if (!game.installed) return false;
if (game.peer_count <= 0) return false; if (game.peer_count <= 0) return false;
if (!game.local_version && game.eti_game_version) return true; if (!game.local_version && game.eti_game_version) return true;
return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0; return (compareVersionStamps(game.eti_game_version, game.local_version) ??
0) > 0;
}; };
export const canStreamInstall = (game: Game): boolean => export const canStreamInstall = (
!game.downloaded game: Game,
&& !game.installed catalogSupported: boolean,
&& game.peer_count > 0 ): boolean =>
&& !isInProgress(game.install_status); 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. */ /** 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 => { export const primaryActionFor = (game: Game): PrimaryAction => {
if (isInProgress(game.install_status)) return 'busy'; if (isInProgress(game.install_status)) return "busy";
if (isUnavailable(game)) return 'disabled'; if (isUnavailable(game)) return "disabled";
if (!game.installed) return game.downloaded ? 'install' : 'download'; if (!game.installed) return game.downloaded ? "install" : "download";
if (needsUpdate(game)) return 'update'; if (needsUpdate(game)) return "update";
return 'play'; return "play";
}; };
export const formatBytesPerSecond = (bytesPerSecond: number): string => { export const formatBytesPerSecond = (bytesPerSecond: number): string => {
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']; const units = ["B/s", "KB/s", "MB/s", "GB/s"];
let value = Math.max(0, bytesPerSecond); let value = Math.max(0, bytesPerSecond);
let unitIndex = 0; let unitIndex = 0;
while (value >= 1000 && unitIndex < units.length - 1) { while (value >= 1000 && unitIndex < units.length - 1) {
value /= 1000; value /= 1000;
unitIndex += 1; unitIndex += 1;
} }
if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`; if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`;
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2; const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(precision)} ${units[unitIndex]}`; return `${value.toFixed(precision)} ${units[unitIndex]}`;
}; };
const MB = 1024 * 1024; const MB = 1024 * 1024;
@@ -180,137 +317,145 @@ const GB = 1024 * 1024 * 1024;
const DECIMAL_MB = 1_000_000; const DECIMAL_MB = 1_000_000;
const stripTrailingDecimalZeros = (value: string): string => 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 => { export const downloadProgressPercent = (game: Game): number => {
const progress = game.download_progress; const progress = game.download_progress;
if (!progress || progress.total_bytes <= 0) return 0; 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 => { export const formatDownloadSpeed = (bytesPerSecond: number): string => {
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB; const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`; return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`;
}; };
export const formatDownloadSpeedShort = (bytesPerSecond: number): string => { export const formatDownloadSpeedShort = (bytesPerSecond: number): string => {
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB; const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
return `${Math.round(mb)} MB/s`; return `${Math.round(mb)} MB/s`;
}; };
export const formatDownloadBytes = (bytes: number): string => { export const formatDownloadBytes = (bytes: number): string => {
const safeBytes = Math.max(0, bytes); const safeBytes = Math.max(0, bytes);
if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`; if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`;
const gb = safeBytes / GB; const gb = safeBytes / GB;
return `${stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))} GB`; return `${
stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))
} GB`;
}; };
export const formatDownloadEta = (seconds: number): string => { export const formatDownloadEta = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds <= 0) return '—'; if (!Number.isFinite(seconds) || seconds <= 0) return "—";
if (seconds < 60) return `${Math.round(seconds)} s`; if (seconds < 60) return `${Math.round(seconds)} s`;
const minutes = Math.round(seconds / 60); const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} min`; 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 => { export const inProgressLabel = (game: Game): string | undefined => {
switch (game.install_status) { switch (game.install_status) {
case InstallStatus.CheckingPeers: case InstallStatus.Downloading:
return 'Checking peers…'; return game.download_progress
case InstallStatus.Downloading: ? `Downloading… ${
return game.download_progress formatBytesPerSecond(game.download_progress.bytes_per_second)
? `Downloading… ${formatBytesPerSecond(game.download_progress.bytes_per_second)}` }`
: 'Downloading…'; : "Downloading…";
case InstallStatus.Installing: case InstallStatus.Installing:
return 'Installing…'; return "Installing…";
case InstallStatus.Uninstalling: case InstallStatus.Uninstalling:
return 'Uninstalling…'; return "Uninstalling…";
case InstallStatus.Removing: case InstallStatus.Removing:
return 'Removing…'; return "Removing…";
default: default:
return undefined; return undefined;
} }
}; };
export const actionLabel = (game: Game): string => { export const actionLabel = (game: Game): string => {
const busy = inProgressLabel(game); const busy = inProgressLabel(game);
if (busy) return busy; if (busy) return busy;
if (isUnavailable(game)) return 'Unavailable'; if (isUnavailable(game)) return "Unavailable";
if (!game.installed) return game.downloaded ? 'Install' : 'Download'; if (!game.installed) return game.downloaded ? "Install" : "Download";
if (needsUpdate(game)) return 'Update'; if (needsUpdate(game)) return "Update";
return 'Play'; return "Play";
}; };
/** Counts shown on filter pills. */ /** Counts shown on filter pills. */
export interface FilterCounts { export interface FilterCounts {
all: number; all: number;
local: number; local: number;
installed: number; installed: number;
} }
const isDownloading = (game: Game): boolean => const isDownloading = (game: Game): boolean =>
game.install_status === InstallStatus.Downloading; game.install_status === InstallStatus.Downloading;
const isNetworkGame = (game: Game): boolean => 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 => ({ export const countByFilter = (games: Game[]): FilterCounts => ({
all: games.filter(isNetworkGame).length, all: games.filter(isNetworkGame).length,
local: games.filter(g => g.installed || g.downloaded || isDownloading(g)).length, local:
installed: games.filter(g => g.installed).length, games.filter((g) => g.installed || g.downloaded || isDownloading(g)).length,
installed: games.filter((g) => g.installed).length,
}); });
const matchesFilter = (game: Game, filter: GameFilter): boolean => { const matchesFilter = (game: Game, filter: GameFilter): boolean => {
switch (filter) { switch (filter) {
case 'local': case "local":
return game.installed || game.downloaded || isDownloading(game); return game.installed || game.downloaded || isDownloading(game);
case 'installed': case "installed":
return game.installed; return game.installed;
case 'all': case "all":
return isNetworkGame(game); return isNetworkGame(game);
} }
}; };
const STATE_SORT_ORDER: Record<DerivedState, number> = { const STATE_SORT_ORDER: Record<DerivedState, number> = {
installed: 0, installed: 0,
local: 1, local: 1,
downloading: 2, downloading: 2,
busy: 3, busy: 3,
none: 4, none: 4,
}; };
const compareByState = (a: Game, b: Game): number => { const compareByState = (a: Game, b: Game): number => {
const diff = STATE_SORT_ORDER[deriveState(a)] - STATE_SORT_ORDER[deriveState(b)]; const diff = STATE_SORT_ORDER[deriveState(a)] -
return diff !== 0 ? diff : a.name.localeCompare(b.name); STATE_SORT_ORDER[deriveState(b)];
return diff !== 0 ? diff : a.name.localeCompare(b.name);
}; };
export const applyFilterAndSort = ( export const applyFilterAndSort = (
games: Game[], games: Game[],
filter: GameFilter, filter: GameFilter,
sort: GameSort, sort: GameSort,
query: string, query: string,
): Game[] => { ): Game[] => {
let list = games.filter(g => matchesFilter(g, filter)); let list = games.filter((g) => matchesFilter(g, filter));
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (q) { if (q) {
list = list.filter(g => list = list.filter((g) =>
g.name.toLowerCase().includes(q) g.name.toLowerCase().includes(q) ||
|| (g.genre?.toLowerCase().includes(q) ?? false) (g.genre?.toLowerCase().includes(q) ?? false) ||
|| (g.publisher?.toLowerCase().includes(q) ?? false), (g.publisher?.toLowerCase().includes(q) ?? false)
); );
} }
switch (sort) { switch (sort) {
case 'az': case "az":
return [...list].sort((a, b) => a.name.localeCompare(b.name)); return [...list].sort((a, b) => a.name.localeCompare(b.name));
case 'sizeDesc': case "sizeDesc":
return [...list].sort((a, b) => b.size - a.size); return [...list].sort((a, b) => b.size - a.size);
case 'sizeAsc': case "sizeAsc":
return [...list].sort((a, b) => a.size - b.size); return [...list].sort((a, b) => a.size - b.size);
case 'status': case "status":
return [...list].sort(compareByState); 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 { export enum InstallStatus {
NotInstalled = 'NotInstalled', NotInstalled = "NotInstalled",
CheckingPeers = 'CheckingPeers', Downloading = "Downloading",
Downloading = 'Downloading', Installing = "Installing",
Installing = 'Installing', Uninstalling = "Uninstalling",
Uninstalling = 'Uninstalling', Removing = "Removing",
Removing = 'Removing', Installed = "Installed",
Installed = 'Installed',
} }
export enum GameAvailability { export enum GameAvailability {
Ready = 'Ready', Ready = "Ready",
LocalOnly = 'LocalOnly', LocalOnly = "LocalOnly",
} }
export enum ActiveOperationKind { export enum ActiveOperationKind {
Downloading = 'Downloading', Downloading = "Downloading",
Installing = 'Installing', Installing = "Installing",
Updating = 'Updating', Updating = "Updating",
Uninstalling = 'Uninstalling', Uninstalling = "Uninstalling",
RemovingDownload = 'RemovingDownload', 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 { export interface DownloadProgress {
downloaded_bytes: number; attemptId: string;
total_bytes: number; downloaded_bytes: number;
bytes_per_second: number; total_bytes: number;
active_peer_count: number; bytes_per_second: number;
active_peer_count: number;
} }
export interface DownloadProgressPayload extends DownloadProgress { export interface DownloadProgressPayload extends DownloadProgress {
id: string; id: string;
} }
export interface Game { export interface Game {
id: string; id: string;
name: string; name: string;
description: string; description: string;
/** Bytes. */ /** Bytes. */
size: number; size: number;
/** Raw bytes — unused in UI, kept for parity with backend payload. */ /** Raw bytes — unused in UI, kept for parity with backend payload. */
thumbnail?: Uint8Array | number[]; thumbnail?: Uint8Array | number[];
downloaded: boolean; downloaded: boolean;
installed: boolean; installed: boolean;
availability: GameAvailability; availability: GameAvailability;
install_status: InstallStatus; install_status: InstallStatus;
eti_game_version?: string; eti_game_version?: string;
local_version?: string; local_version?: string;
/** Optional richer metadata surfaced by the backend. */ /** Optional richer metadata surfaced by the backend. */
release_year?: string; release_year?: string;
publisher?: string; publisher?: string;
max_players?: number; max_players?: number;
version?: string; version?: string;
genre?: string; genre?: string;
status_message?: string; status_message?: string;
status_level?: StatusLevel; status_level?: StatusLevel;
download_progress?: DownloadProgress; transfer_status?: GameTransferStatus;
peer_count: number; download_progress?: DownloadProgress;
can_host_server?: boolean; peer_count: number;
active_outbound_transfers?: number; can_host_server?: boolean;
installed_peer_count?: number; active_outbound_transfers?: number;
installed_peer_count?: number;
} }
export interface ActiveOperation { export interface ActiveOperation {
id: string; id: string;
operation: ActiveOperationKind; operation: ActiveOperationKind;
} }
export interface GamesListPayload { export interface GamesListPayload {
games: Game[]; games: Game[];
active_operations?: ActiveOperation[]; active_operations?: ActiveOperation[];
transfer_status: GameTransferStatusSnapshot;
}
export interface ProtocolMismatch {
observed: number | null;
expected: number;
} }
/** Library filter chip — what subset of the catalog to show. */ /** 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. */ /** 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. */ /** 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. */ /** 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 { export interface CallToPlayParticipant {
name: string; name: string;
status: CallToPlayParticipantStatus; status: CallToPlayParticipantStatus;
joinedAt: number; joinedAt: number;
readyAt?: number; readyAt?: number;
} }
export interface CallToPlayMessage { export interface CallToPlayMessage {
id: string; id: string;
fromId: string; fromId: string;
from: string; from: string;
text: string; text: string;
at: number; at: number;
} }
export interface Nomination { export interface Nomination {
id: string; id: string;
gameId: string; gameId: string;
creatorId: string; creatorId: string;
creator: string; creator: string;
maxPlayers: number; maxPlayers: number;
createdAt: number; createdAt: number;
scheduledFor: number | null; scheduledFor: number | null;
deadline: number; deadline: number;
participants: Record<string, CallToPlayParticipant>; participants: Record<string, CallToPlayParticipant>;
messages: CallToPlayMessage[]; messages: CallToPlayMessage[];
state: 'open' | 'done' | 'running' | 'cancelled'; state: "open" | "done" | "running" | "cancelled";
terminalAt: number | null; terminalAt: number | null;
} }
export type CallToPlayAction = export type CallToPlayAction =
| { Create: { game_id: string; max_players: number; scheduled_for: number | null; deadline: number } } | {
| { Respond: { ready_at: number | null } } Create: {
| 'Rsvp' game_id: string;
| { SendMessage: { message_id: string; text: string } } max_players: number;
| 'Leave' scheduled_for: number | null;
| 'Cancel' deadline: number;
| 'Start' };
| { AddTime: { deadline: number } }; }
| { Respond: { ready_at: number | null } }
| "Rsvp"
| { SendMessage: { text: string } }
| "Leave"
| "Cancel"
| "Start"
| { AddTime: { deadline: number } };
export interface CallToPlayEvent { export interface CallToPlayViewEvent {
id: string; id: string;
call_id: string; call_id: string;
actor_id: string; author_id: string;
actor_name: string; author_name: string;
at: number; at: number;
action: CallToPlayAction; 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 ReactDOM from "react-dom/client";
import { getCurrentWindow } from "@tauri-apps/api/window";
import App from "./App"; 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/tokens.css";
import "./styles/launcher.css"; import "./styles/launcher.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( const render = () => {
<App /> 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 { .bg-gradient {
background: background:
radial-gradient( radial-gradient(
ellipse 80% 50% at 50% -10%, ellipse 80% 50% at 50% -10%,
color-mix(in srgb, var(--accent) 22%, transparent) 0%, color-mix(in srgb, var(--accent) 22%, transparent) 0%,
transparent 60% transparent 60%
), ),
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%); linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
} }
.bg-animated { .bg-animated {
background: background:
radial-gradient( radial-gradient(
ellipse 60% 40% at 20% 0%, ellipse 60% 40% at 20% 0%,
color-mix(in srgb, var(--accent) 24%, transparent) 0%, color-mix(in srgb, var(--accent) 24%, transparent) 0%,
transparent 55% transparent 55%
), ),
radial-gradient( radial-gradient(
ellipse 55% 40% at 85% 8%, ellipse 55% 40% at 85% 8%,
color-mix(in srgb, var(--accent) 16%, transparent) 0%, color-mix(in srgb, var(--accent) 16%, transparent) 0%,
transparent 55% transparent 55%
), ),
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%); linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
background-size: 145% 130%, 140% 125%, 100% 100%; background-size: 145% 130%, 140% 125%, 100% 100%;
animation: bgshift 22s ease-in-out infinite alternate; animation: bgshift 22s ease-in-out infinite alternate;
@@ -474,6 +474,28 @@
padding: 4px 4px 16px; padding: 4px 4px 16px;
gap: 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 { .results-count {
color: var(--t-2); color: var(--t-2);
font-size: 12.5px; font-size: 12.5px;
@@ -582,15 +604,15 @@
.cover-grain { .cover-grain {
background-image: background-image:
repeating-linear-gradient( repeating-linear-gradient(
0deg, 0deg,
rgba(255, 255, 255, 0.018) 0 1px, rgba(255, 255, 255, 0.018) 0 1px,
transparent 1px 3px transparent 1px 3px
), ),
repeating-linear-gradient( repeating-linear-gradient(
90deg, 90deg,
rgba(0, 0, 0, 0.04) 0 1px, rgba(0, 0, 0, 0.04) 0 1px,
transparent 1px 3px transparent 1px 3px
); );
mix-blend-mode: overlay; mix-blend-mode: overlay;
opacity: 0.7; opacity: 0.7;
} }
@@ -982,6 +1004,18 @@
align-items: center; align-items: center;
color: var(--t-1); 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 { .dl-md .dl-pulse {
margin-right: 6px; margin-right: 6px;
} }
@@ -1066,6 +1100,9 @@
color: color-mix(in srgb, var(--accent) 80%, white); color: color-mix(in srgb, var(--accent) 80%, white);
} }
.dl-lg-primary .dl-label { .dl-lg-primary .dl-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.dl-lg-secondary { .dl-lg-secondary {
@@ -1510,6 +1547,59 @@
.settings-row-control { .settings-row-control {
flex-shrink: 0; 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 { .settings-foot {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1791,52 +1881,67 @@
/* ─── Top-bar entry button ─── */ /* ─── Top-bar entry button ─── */
.ctp-btn { .ctp-btn {
position: relative; position: relative;
display: inline-flex; align-items: center; gap: 8px; display: inline-flex;
height: 36px; padding: 0 14px; align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px;
background: color-mix(in srgb, var(--accent) 14%, var(--bg-2)); 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: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 8px; border-radius: 8px;
color: var(--t-1); color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 700; font: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
transition: background .15s, border-color .15s; 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 { .ctp-btn:hover {
background: color-mix(in srgb, var(--accent) 22%, var(--bg-2)); background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2)); border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2));
} }
.ctp-badge { .ctp-badge {
display: inline-grid; place-items: center; display: inline-grid;
min-width: 18px; height: 18px; place-items: center;
min-width: 18px;
height: 18px;
padding: 0 5px; padding: 0 5px;
border-radius: 999px; border-radius: 999px;
background: var(--accent); background: var(--accent);
color: white; color: white;
font-size: 10.5px; font-weight: 800; font-size: 10.5px;
font-weight: 800;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* ─── Ticker strip ─── */ /* ─── Ticker strip ─── */
.ctp-ticker-stack { .ctp-ticker-stack {
display: flex; flex-direction: column; gap: 8px; display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px; margin-bottom: 14px;
} }
.ctp-ticker-stack .ctp-ticker { margin-bottom: 0; } .ctp-ticker-stack .ctp-ticker {
margin-bottom: 0;
}
.ctp-ticker { .ctp-ticker {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
10px /* LED */ 10px /* LED */
98px /* status */ 98px /* status */
220px /* game */ 220px /* game */
130px /* by */ 130px /* by */
88px /* count */ 88px /* count */
170px /* time */ 170px /* time */
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */ minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
max-content /* bubbles */ max-content /* bubbles */
82px; /* cta */ 82px; /* cta */
align-items: center; align-items: center;
column-gap: 12px; column-gap: 12px;
width: 100%; width: 100%;
@@ -1845,22 +1950,34 @@
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2)); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
border-radius: 10px; border-radius: 10px;
color: var(--t-1); color: var(--t-1);
font: inherit; font-size: 12.5px; font: inherit;
font-size: 12.5px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: background .15s, border-color .15s; 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 { .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); background: var(--accent);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent); box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
animation: ctp-tickerpulse 1.6s ease-out infinite; animation: ctp-tickerpulse 1.6s ease-out infinite;
} }
@keyframes ctp-tickerpulse { @keyframes ctp-tickerpulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } 0% {
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); } box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, 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 { .ctp-ticker-label {
font-weight: 700; font-weight: 700;
@@ -1870,36 +1987,87 @@
letter-spacing: 0.06em; letter-spacing: 0.06em;
flex-shrink: 0; flex-shrink: 0;
} }
.ctp-ticker-game { font-weight: 700; color: var(--t-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ctp-ticker-game {
.ctp-ticker-by { color: var(--t-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } font-weight: 700;
.ctp-ticker-sep { display: none; } color: var(--t-1);
.ctp-ticker-ready, .ctp-ticker-time { color: var(--t-2); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 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 { .ctp-ticker-more {
color: var(--t-3); color: var(--t-3);
font-size: 11.5px; font-size: 11.5px;
flex-shrink: 0; flex-shrink: 0;
} }
.ctp-ticker-cta { .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; font-weight: 700;
color: var(--accent); color: var(--accent);
} }
.ctp-ticker-cta svg { transform: rotate(-90deg); } .ctp-ticker-cta svg {
transform: rotate(-90deg);
}
/* ─── Overlay modal ─── */ /* ─── Overlay modal ─── */
.ctp-modal { width: min(720px, 100%); } .ctp-modal {
width: min(720px, 100%);
}
.ctp-head-row { .ctp-head-row {
display: flex; align-items: center; justify-content: space-between; display: flex;
align-items: center;
justify-content: space-between;
gap: 12px; gap: 12px;
margin-right: 36px; margin-right: 36px;
} }
.ctp-head-new { height: 40px; padding: 0 18px; font-size: 13px; margin-top: 14px; width: 100%; } .ctp-head-new {
.ctp-head { padding: 26px 28px 14px; border-bottom: 1px solid var(--bd-1); } height: 40px;
.ctp-head h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; color: var(--t-1); } padding: 0 18px;
.ctp-head-sub { margin: 6px 0 0; font-size: 12.5px; color: var(--t-3); max-width: 52ch; } 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 { .ctp-body {
padding: 18px 24px 24px; padding: 18px 24px 24px;
display: flex; flex-direction: column; gap: 14px; display: flex;
flex-direction: column;
gap: 14px;
max-height: 66vh; max-height: 66vh;
overflow: auto; overflow: auto;
} }
@@ -1912,42 +2080,86 @@
/* ─── Nomination card ─── */ /* ─── Nomination card ─── */
.ctp-card { .ctp-card {
display: flex; flex-direction: column; gap: 12px; display: flex;
flex-direction: column;
gap: 12px;
padding: 14px; padding: 14px;
background: rgba(255,255,255,0.025); background: rgba(255,255,255,0.025);
border: 1px solid var(--bd-1); border: 1px solid var(--bd-1);
border-radius: 12px; border-radius: 12px;
} }
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); } .ctp-card.is-done {
.ctp-card.is-expired { border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); } border-color: color-mix(in srgb, var(--ok) 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-expired {
.ctp-card.is-cancelled { border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2)); } border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; } }
.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 { @keyframes ctp-cardflash {
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); } 0% {
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } 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 { .ctp-card-cover {
position: relative; position: relative;
width: 52px; height: 52px; width: 52px;
height: 52px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
} }
.ctp-card-cover-missing { .ctp-card-cover-missing {
width: 100%; height: 100%; width: 100%;
display: grid; place-items: center; height: 100%;
display: grid;
place-items: center;
color: var(--t-3); color: var(--t-3);
background: color-mix(in srgb, var(--bd-2) 55%, transparent); background: color-mix(in srgb, var(--bd-2) 55%, transparent);
border: 1px dashed var(--bd-2); border: 1px dashed var(--bd-2);
} }
.ctp-card-cover-missing svg { width: 22px; height: 22px; } .ctp-card-cover-missing svg {
.ctp-card-info { flex: 1; min-width: 0; } width: 22px;
.ctp-card-title { font-size: 14.5px; font-weight: 700; color: var(--t-1); } height: 22px;
.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-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 { .ctp-card-timer {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-size: 18px; font-size: 18px;
@@ -1956,25 +2168,45 @@
flex-shrink: 0; flex-shrink: 0;
padding-top: 2px; padding-top: 2px;
} }
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); } .ctp-card-timer[data-urgency="mid"] {
.ctp-card-timer[data-urgency="high"] { color: var(--danger); } color: var(--warn);
.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="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 { .ctp-cancel-link {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex;
align-items: center;
gap: 6px;
align-self: flex-start; align-self: flex-start;
margin-top: -2px; margin-top: -2px;
padding: 6px 2px; padding: 6px 2px;
background: transparent; background: transparent;
border: 0; border: 0;
color: var(--t-3); color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600; font: inherit;
font-size: 11.5px;
font-weight: 600;
cursor: pointer; cursor: pointer;
transition: color .15s; transition: color .15s;
} }
.ctp-cancel-link:hover { color: #fca5a5; } .ctp-cancel-link:hover {
color: #fca5a5;
}
.ctp-cancel-confirm { .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; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
background: rgba(239,68,68,0.08); background: rgba(239,68,68,0.08);
@@ -1984,8 +2216,16 @@
color: #fca5a5; color: #fca5a5;
font-weight: 600; font-weight: 600;
} }
.ctp-cancel-confirm-btns { display: inline-flex; gap: 8px; flex-shrink: 0; } .ctp-cancel-confirm-btns {
.ctp-cancel-confirm-btns .ghost-btn { height: 32px; padding: 0 12px; font-size: 12px; } display: inline-flex;
gap: 8px;
flex-shrink: 0;
}
.ctp-cancel-confirm-btns .ghost-btn {
height: 32px;
padding: 0 12px;
font-size: 12px;
}
.ctp-progress { .ctp-progress {
height: 5px; height: 5px;
@@ -1993,37 +2233,66 @@
background: rgba(255,255,255,0.06); background: rgba(255,255,255,0.06);
overflow: hidden; 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 {
.ctp-roster-count { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--t-3); } display: flex;
.ctp-avatars { display: flex; flex-wrap: wrap; gap: 6px; } 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 { .ctp-avatar {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
} }
.ctp-avatar-dot { .ctp-avatar-dot {
width: 30px; height: 30px; width: 30px;
display: grid; place-items: center; height: 30px;
display: grid;
place-items: center;
border-radius: 999px; border-radius: 999px;
color: white; color: white;
font-size: 10.5px; font-weight: 800; font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.01em; 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 { .ctp-avatar-check {
position: absolute; bottom: -2px; right: -2px; position: absolute;
width: 14px; height: 14px; bottom: -2px;
display: grid; place-items: center; right: -2px;
width: 14px;
height: 14px;
display: grid;
place-items: center;
border-radius: 999px; border-radius: 999px;
background: var(--ok); background: var(--ok);
color: #06240f; color: #06240f;
border: 2px solid var(--bg-2); border: 2px solid var(--bg-2);
} }
.ctp-avatar-pending { .ctp-avatar-pending {
position: absolute; bottom: -6px; left: 50%; position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
font-size: 9px; font-weight: 700; font-size: 9px;
font-weight: 700;
color: var(--t-2); color: var(--t-2);
background: var(--bg-3); background: var(--bg-3);
border: 1px solid var(--bd-2); border: 1px solid var(--bd-2);
@@ -2033,64 +2302,122 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.ctp-avatar-empty { .ctp-avatar-empty {
width: 30px; height: 30px; width: 30px;
height: 30px;
border-radius: 999px; border-radius: 999px;
border: 1.5px dashed var(--bd-2); border: 1.5px dashed var(--bd-2);
} }
.ctp-actions { .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; padding-top: 2px;
} }
.ctp-actions .act-btn { height: 36px; padding: 0 16px; font-size: 12.5px; } .ctp-actions .act-btn {
.ctp-actions .ghost-btn { height: 36px; padding: 0 14px; font-size: 12.5px; } height: 36px;
.ctp-note { font-size: 12.5px; color: var(--t-3); } padding: 0 16px;
.ctp-note-launch { color: var(--ok); font-weight: 600; } font-size: 12.5px;
.ctp-me-status { font-size: 12.5px; font-weight: 600; color: var(--t-2); } }
.ctp-buffer-group { display: inline-flex; gap: 6px; } .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 { .ctp-buffer-btn {
height: 36px; padding: 0 11px; height: 36px;
padding: 0 11px;
background: rgba(255,255,255,0.04); background: rgba(255,255,255,0.04);
border: 1px solid var(--bd-2); border: 1px solid var(--bd-2);
border-radius: 7px; border-radius: 7px;
color: var(--t-1); color: var(--t-1);
font: inherit; font-size: 12px; font-weight: 600; font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background .15s, border-color .15s; 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 ─── */ /* ─── Create-nomination form ─── */
.ctp-create-cta { .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; height: 48px;
background: transparent; background: transparent;
border: 1.5px dashed var(--bd-3); border: 1.5px dashed var(--bd-3);
border-radius: 12px; border-radius: 12px;
color: var(--t-2); color: var(--t-2);
font: inherit; font-size: 13px; font-weight: 700; font: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer; cursor: pointer;
transition: border-color .15s, color .15s, background .15s; 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 { .ctp-create {
display: flex; flex-direction: column; gap: 14px; display: flex;
flex-direction: column;
gap: 14px;
padding: 16px; padding: 16px;
background: rgba(255,255,255,0.03); background: rgba(255,255,255,0.03);
border: 1px solid var(--bd-2); border: 1px solid var(--bd-2);
border-radius: 12px; border-radius: 12px;
} }
.ctp-create-row { display: flex; flex-direction: column; gap: 6px; position: relative; } .ctp-create-row {
.ctp-create-row-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; } 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 { .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); color: var(--t-3);
} }
.ctp-create-search { width: 100%; } .ctp-create-search {
width: 100%;
}
.ctp-create-matches { .ctp-create-matches {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
left: 0; right: 0; left: 0;
right: 0;
z-index: 20; z-index: 20;
max-height: 220px; max-height: 220px;
overflow: auto; overflow: auto;
@@ -2101,61 +2428,111 @@
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5); box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
} }
.ctp-create-matches button { .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%; width: 100%;
padding: 9px 10px; padding: 9px 10px;
background: transparent; background: transparent;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
color: var(--t-1); 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; text-align: left;
cursor: pointer; cursor: pointer;
} }
.ctp-create-matches button:hover { background: rgba(255,255,255,0.06); } .ctp-create-matches button:hover {
.ctp-create-match-meta { color: var(--t-3); font-size: 11px; font-weight: 500; } background: rgba(255,255,255,0.06);
.ctp-create-nomatch { padding: 10px; font-size: 12px; color: var(--t-3); } }
.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 { .ctp-create-num {
width: 70px; height: 34px; width: 70px;
height: 34px;
background: var(--bg-3); background: var(--bg-3);
border: 1px solid var(--bd-1); border: 1px solid var(--bd-1);
border-radius: 7px; border-radius: 7px;
color: var(--t-1); color: var(--t-1);
font: inherit; font-size: 13px; font-weight: 600; font: inherit;
font-size: 13px;
font-weight: 600;
text-align: center; text-align: center;
} }
.ctp-create-hint { font-size: 11.5px; color: var(--t-3); } .ctp-create-hint {
.ctp-duration-opts { display: inline-flex; gap: 6px; flex-wrap: wrap; } font-size: 11.5px;
color: var(--t-3);
}
.ctp-duration-opts {
display: inline-flex;
gap: 6px;
flex-wrap: wrap;
}
.ctp-duration-btn { .ctp-duration-btn {
height: 32px; padding: 0 13px; height: 32px;
padding: 0 13px;
background: var(--bg-3); background: var(--bg-3);
border: 1px solid var(--bd-1); border: 1px solid var(--bd-1);
border-radius: 7px; border-radius: 7px;
color: var(--t-2); color: var(--t-2);
font: inherit; font-size: 12.5px; font-weight: 700; font: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer; cursor: pointer;
transition: background .15s, color .15s, border-color .15s; transition: background .15s, color .15s, border-color .15s;
} }
.ctp-duration-btn:hover { color: var(--t-1); } .ctp-duration-btn:hover {
.ctp-duration-btn.is-active { color: white; } color: var(--t-1);
}
.ctp-duration-btn.is-active {
color: white;
}
.ctp-create-foot { .ctp-create-foot {
display: flex; justify-content: flex-end; gap: 10px; display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 4px; padding-top: 4px;
} }
.ctp-create-foot .act-btn, .ctp-create-foot .act-btn,
.ctp-create-foot .ghost-btn { height: 40px; padding: 0 18px; } .ctp-create-foot .ghost-btn {
.ctp-time-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; } height: 40px;
padding: 0 18px;
}
.ctp-time-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 4px;
}
/* ─── Time stepper ─── */ /* ─── Time stepper ─── */
.ctp-timepick { .ctp-timepick {
display: flex; align-items: center; gap: 6px; display: flex;
align-items: center;
gap: 6px;
margin-top: 6px; margin-top: 6px;
} }
.ctp-timepick-field { .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 { .ctp-time-step {
width: 54px; height: 24px; width: 54px;
display: grid; place-items: center; height: 24px;
display: grid;
place-items: center;
background: var(--bg-3); background: var(--bg-3);
border: 1px solid var(--bd-1); border: 1px solid var(--bd-1);
border-radius: 7px; border-radius: 7px;
@@ -2163,54 +2540,91 @@
cursor: pointer; cursor: pointer;
transition: background .12s, color .12s, border-color .12s; 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:hover {
.ctp-time-step:active { background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); } 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 { .ctp-time-cell {
width: 54px; height: 44px; width: 54px;
display: grid; place-items: center; height: 44px;
display: grid;
place-items: center;
background: var(--bg-3); background: var(--bg-3);
border: 1px solid var(--bd-2); border: 1px solid var(--bd-2);
border-radius: 9px; border-radius: 9px;
font-size: 26px; font-weight: 700; font-size: 26px;
font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--t-1); color: var(--t-1);
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.ctp-time-editinput { .ctp-time-editinput {
width: 132px; height: 44px; width: 132px;
height: 44px;
padding: 0; padding: 0;
background: var(--bg-3); background: var(--bg-3);
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2)); border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2));
border-radius: 9px; border-radius: 9px;
font: inherit; font-size: 26px; font-weight: 700; font: inherit;
font-size: 26px;
font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--t-1); color: var(--t-1);
text-align: center; text-align: center;
letter-spacing: 0.06em; letter-spacing: 0.06em;
} }
.ctp-time-editinput:focus { outline: none; } .ctp-time-editinput:focus {
.ctp-time-editinput::placeholder { color: var(--t-4); } outline: none;
.ctp-time-colon { font-size: 26px; font-weight: 700; color: var(--t-2); padding-bottom: 2px; } }
.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 { .ctp-time-type {
align-self: center; align-self: center;
margin-left: 8px; margin-left: 8px;
height: 30px; padding: 0 12px; height: 30px;
padding: 0 12px;
background: transparent; background: transparent;
border: 1px dashed var(--bd-3); border: 1px dashed var(--bd-3);
border-radius: 7px; border-radius: 7px;
color: var(--t-3); color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600; font: inherit;
font-size: 11.5px;
font-weight: 600;
cursor: pointer; cursor: pointer;
transition: color .12s, border-color .12s; transition: color .12s, border-color .12s;
} }
.ctp-time-type:hover { color: var(--t-1); border-color: var(--accent); } .ctp-time-type:hover {
.ctp-day-row { gap: 5px; } color: var(--t-1);
border-color: var(--accent);
}
.ctp-day-row {
gap: 5px;
}
.ctp-day-label { .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); color: var(--t-3);
margin-right: 4px; 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: /* ─── Quick-bar status variants — LED + label + row tint per status:
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) · SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
@@ -2220,95 +2634,199 @@
background: var(--bg-2); background: var(--bg-2);
border-color: var(--bd-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"] { .ctp-ticker[data-status="soon"] {
background: color-mix(in srgb, var(--warn) 10%, var(--bg-2)); background: color-mix(in srgb, var(--warn) 10%, var(--bg-2));
border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2)); border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2));
animation: ctp-soon-glow 2.4s ease-in-out infinite; 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 { @keyframes ctp-soon-glow {
0%, 100% { box-shadow: 0 0 0 0 transparent; } 0%,
50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent); } 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"] { .ctp-ticker[data-status="ready"] {
background: color-mix(in srgb, var(--ok) 11%, var(--bg-2)); background: color-mix(in srgb, var(--ok) 11%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 55%, var(--bd-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); 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"] { .ctp-ticker[data-status="expired"] {
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2)); background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-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"] { .ctp-ticker[data-status="running"] {
background: color-mix(in srgb, var(--ok) 5%, var(--bg-2)); background: color-mix(in srgb, var(--ok) 5%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 25%, var(--bd-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"] { .ctp-ticker[data-status="cancelled"] {
background: color-mix(in srgb, var(--danger) 4%, var(--bg-2)); background: color-mix(in srgb, var(--danger) 4%, var(--bg-2));
border-color: color-mix(in srgb, var(--danger) 22%, var(--bd-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[data-status="cancelled"]:hover {
.ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; } background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
.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="scheduled"] {
.ctp-ticker-dot[data-status="expired"] { background: var(--danger); animation: none; box-shadow: none; } background: var(--t-3);
.ctp-ticker-dot[data-status="running"] { background: var(--ok); animation: none; box-shadow: none; opacity: 0.75; } animation: none;
.ctp-ticker-dot[data-status="cancelled"] { background: var(--danger); animation: none; box-shadow: none; opacity: 0.65; } 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 { @keyframes ctp-tickerpulse-warn {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); } 0% {
70% { box-shadow: 0 0 0 6px transparent; } box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent);
100% { box-shadow: 0 0 0 0 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="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 ─── */ /* ─── Quick-bar inline chat preview ─── */
.ctp-ticker-chat { .ctp-ticker-chat {
min-width: 0; min-width: 0;
display: inline-flex; align-items: center; gap: 5px; display: inline-flex;
align-items: center;
gap: 5px;
color: var(--t-3); color: var(--t-3);
font-size: 12px; font-size: 12px;
} }
.ctp-ticker-chat svg { flex-shrink: 0; opacity: 0.7; } .ctp-ticker-chat svg {
.ctp-ticker-chat b { flex-shrink: 0; font-weight: 700; } flex-shrink: 0;
opacity: 0.7;
}
.ctp-ticker-chat b {
flex-shrink: 0;
font-weight: 700;
}
.ctp-ticker-chat-text { .ctp-ticker-chat-text {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--t-2); 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 { .ctp-checkin-note {
display: flex; align-items: center; gap: 8px; display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px; padding: 8px 12px;
background: color-mix(in srgb, var(--accent) 13%, transparent); background: color-mix(in srgb, var(--accent) 13%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
border-radius: 8px; border-radius: 8px;
font-size: 12.5px; font-weight: 600; font-size: 12.5px;
font-weight: 600;
color: var(--t-1); 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 { .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 { .ctp-avatar-in {
position: absolute; bottom: -6px; left: 50%; position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-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); color: var(--t-2);
background: var(--bg-3); background: var(--bg-3);
border: 1px solid var(--bd-2); border: 1px solid var(--bd-2);
@@ -2319,37 +2837,60 @@
/* ─── Ticker mini ready-bubbles ─── */ /* ─── Ticker mini ready-bubbles ─── */
.ctp-ticker-bubbles { .ctp-ticker-bubbles {
display: inline-flex; align-items: center; display: inline-flex;
align-items: center;
justify-self: end; justify-self: end;
} }
.ctp-mini { .ctp-mini {
position: relative; position: relative;
width: 22px; height: 22px; width: 22px;
display: grid; place-items: center; height: 22px;
display: grid;
place-items: center;
border-radius: 999px; border-radius: 999px;
margin-left: -6px; margin-left: -6px;
border: 2px solid var(--bg-2); border: 2px solid var(--bg-2);
color: white; 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 { .ctp-mini-check {
position: absolute; bottom: -3px; right: -4px; position: absolute;
width: 11px; height: 11px; bottom: -3px;
display: grid; place-items: center; right: -4px;
width: 11px;
height: 11px;
display: grid;
place-items: center;
border-radius: 999px; border-radius: 999px;
background: var(--ok); background: var(--ok);
color: #06240f; color: #06240f;
border: 1.5px solid var(--bg-2); 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 { .ctp-mini-tag {
position: absolute; bottom: -6px; right: -7px; position: absolute;
bottom: -6px;
right: -7px;
font-style: normal; font-style: normal;
font-size: 8px; font-weight: 700; font-size: 8px;
font-weight: 700;
line-height: 11px; line-height: 11px;
padding: 0 3px; padding: 0 3px;
border-radius: 999px; border-radius: 999px;
@@ -2359,51 +2900,83 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; 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 ─── */ /* ─── Per-call chat ─── */
.ctp-chat { .ctp-chat {
display: flex; flex-direction: column; display: flex;
flex-direction: column;
border-top: 1px solid var(--bd-1); border-top: 1px solid var(--bd-1);
margin-top: 2px; margin-top: 2px;
padding-top: 8px; padding-top: 8px;
} }
.ctp-chat-toggle { .ctp-chat-toggle {
display: flex; align-items: center; gap: 8px; display: flex;
align-items: center;
gap: 8px;
width: 100%; width: 100%;
padding: 4px 2px; padding: 4px 2px;
background: transparent; background: transparent;
border: 0; border: 0;
color: var(--t-3); color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 700; font: inherit;
font-size: 11.5px;
font-weight: 700;
cursor: pointer; cursor: pointer;
transition: color .15s; transition: color .15s;
} }
.ctp-chat-toggle:hover { color: var(--t-1); } .ctp-chat-toggle:hover {
.ctp-chat-toggle svg { flex-shrink: 0; } color: var(--t-1);
.ctp-chat-count { color: var(--t-4); font-weight: 600; } }
.ctp-chat-toggle svg {
flex-shrink: 0;
}
.ctp-chat-count {
color: var(--t-4);
font-weight: 600;
}
.ctp-chat-unread { .ctp-chat-unread {
min-width: 16px; height: 16px; min-width: 16px;
display: grid; place-items: center; height: 16px;
display: grid;
place-items: center;
padding: 0 4px; padding: 0 4px;
border-radius: 999px; border-radius: 999px;
background: var(--accent); background: var(--accent);
color: white; color: white;
font-size: 9.5px; font-weight: 800; font-size: 9.5px;
font-weight: 800;
flex-shrink: 0; flex-shrink: 0;
} }
.ctp-chat-preview { .ctp-chat-preview {
flex: 1; min-width: 0; flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500; font-weight: 500;
color: var(--t-3); color: var(--t-3);
text-align: left; text-align: left;
} }
.ctp-chat-preview b { font-weight: 700; } .ctp-chat-preview b {
.ctp-chat-chevron { margin-left: auto; flex-shrink: 0; transition: transform .15s; } font-weight: 700;
.ctp-chat.is-open .ctp-chat-chevron { transform: rotate(180deg); } }
.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 { .ctp-chat-list {
display: flex; flex-direction: column; gap: 6px; display: flex;
flex-direction: column;
gap: 6px;
max-height: 168px; max-height: 168px;
overflow-y: auto; overflow-y: auto;
margin: 8px 0; margin: 8px 0;
@@ -2412,26 +2985,57 @@
border: 1px solid var(--bd-1); border: 1px solid var(--bd-1);
border-radius: 8px; border-radius: 8px;
} }
.ctp-chat-msg { font-size: 12px; line-height: 1.45; color: var(--t-2); overflow-wrap: anywhere; } .ctp-chat-msg {
.ctp-chat-msg b { font-weight: 700; } font-size: 12px;
.ctp-chat-text { color: var(--t-1); } line-height: 1.45;
.ctp-chat-time { margin-left: 6px; font-size: 10px; color: var(--t-4); font-variant-numeric: tabular-nums; } color: var(--t-2);
.ctp-chat-empty { font-size: 11.5px; color: var(--t-4); text-align: center; padding: 6px 0; } overflow-wrap: anywhere;
.ctp-chat-form { display: flex; gap: 6px; } }
.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 { .ctp-chat-input {
flex: 1; height: 32px; flex: 1;
height: 32px;
padding: 0 10px; padding: 0 10px;
background: var(--bg-3); background: var(--bg-3);
border: 1px solid var(--bd-1); border: 1px solid var(--bd-1);
border-radius: 7px; border-radius: 7px;
color: var(--t-1); 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 { .ctp-chat-send {
width: 32px; height: 32px; width: 32px;
display: grid; place-items: center; height: 32px;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--accent) 24%, var(--bg-3)); 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: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 7px; border-radius: 7px;
@@ -2440,25 +3044,34 @@
flex-shrink: 0; flex-shrink: 0;
transition: background .15s; 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 { .ctp-transport-note {
margin-top: 10px; margin-top: 10px;
font-size: 11.5px; font-size: 11.5px;
color: var(--t-3); 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) { @container launcher (max-width: 1280px) {
.ctp-ticker { .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-by,
.ctp-ticker-chat { display: none; } .ctp-ticker-chat {
display: none;
}
} }
@container launcher (max-width: 800px) { @container launcher (max-width: 800px) {
.ctp-ticker { .ctp-ticker {
grid-template-columns: 10px 78px minmax(100px, 1fr) 76px 64px; grid-template-columns: 10px 78px minmax(100px, 1fr) 76px 64px;
} }
.ctp-ticker-time, .ctp-ticker-time,
.ctp-ticker-bubbles { display: none; } .ctp-ticker-bubbles {
display: none;
}
} }
@@ -1,264 +1,443 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { open } from '@tauri-apps/plugin-dialog'; import { open } from "@tauri-apps/plugin-dialog";
import { TopBar } from '../components/topbar/TopBar'; import { TopBar } from "../components/topbar/TopBar";
import { KebabItem } from '../components/topbar/KebabMenu'; import { KebabItem } from "../components/topbar/KebabMenu";
import { ResultsBar } from '../components/grid/ResultsBar'; import { ResultsBar } from "../components/grid/ResultsBar";
import { GameGrid } from '../components/grid/GameGrid'; import { GameGrid } from "../components/grid/GameGrid";
import { GameDetailModal } from '../components/modals/GameDetailModal'; import { GameDetailModal } from "../components/modals/GameDetailModal";
import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDownloadModal'; import { ConfirmRemoveDownloadModal } from "../components/modals/ConfirmRemoveDownloadModal";
import { SettingsDialog } from '../components/modals/SettingsDialog'; import { SettingsDialog } from "../components/modals/SettingsDialog";
import { NoDirectoryState } from '../components/empty/NoDirectoryState'; import { NoDirectoryState } from "../components/empty/NoDirectoryState";
import { EmptyResultsState } from '../components/empty/EmptyResultsState'; import { EmptyResultsState } from "../components/empty/EmptyResultsState";
import { CallToPlayTicker } from '../components/calltoplay/CallToPlayTicker'; import { CallToPlayTicker } from "../components/calltoplay/CallToPlayTicker";
import { CallToPlayOverlay } from '../components/calltoplay/CallToPlayOverlay'; import { CallToPlayOverlay } from "../components/calltoplay/CallToPlayOverlay";
import { useGameDirectory } from '../hooks/useGameDirectory'; import { useGameDirectory } from "../hooks/useGameDirectory";
import { useGames } from '../hooks/useGames'; import { useGames } from "../hooks/useGames";
import { useGameActions } from '../hooks/useGameActions'; import { useGameActions } from "../hooks/useGameActions";
import { useThumbnails } from '../hooks/useThumbnails'; import { useThumbnails } from "../hooks/useThumbnails";
import { useSettings } from '../hooks/useSettings'; import { useSettings } from "../hooks/useSettings";
import { useCallToPlay } from '../hooks/useCallToPlay'; 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 { Game } from "../lib/types";
import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState'; 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 () => { interface CompanionWindowOptions {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow'); label: string;
try { url: string;
const existing = await WebviewWindow.getByLabel('unpack-logs'); title: string;
if (existing) { width: number;
await existing.setFocus(); height: number;
return; }
}
const win = new WebviewWindow('unpack-logs', { const openCompanionWindow = async (
url: '/?view=unpack-logs', owner: AsyncOwner,
title: 'Unpack Logs', options: CompanionWindowOptions,
width: 900, ): Promise<void> => {
height: 700, try {
resizable: true, let windowModule:
}); | typeof import("@tauri-apps/api/webviewWindow")
await win.once<unknown>('tauri://error', (event) => { | undefined;
console.error('Error opening unpack logs window:', event.payload); if (
}); !await owner.applyIfActive(
} catch (err) { () => import("@tauri-apps/api/webviewWindow"),
console.error('Error opening unpack logs window:', err); (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 UNPACK_LOG_WINDOW: CompanionWindowOptions = {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow'); label: "unpack-logs",
try { url: "/?view=unpack-logs",
const existing = await WebviewWindow.getByLabel('main-logs'); title: "Unpack Logs",
if (existing) { width: 900,
await existing.setFocus(); height: 700,
return; };
}
const win = new WebviewWindow('main-logs', { const MAIN_LOG_WINDOW: CompanionWindowOptions = {
url: '/?view=main-logs', label: "main-logs",
title: 'Application Logs', url: "/?view=main-logs",
width: 980, title: "Application Logs",
height: 720, width: 980,
resizable: true, height: 720,
});
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);
}
}; };
export const MainWindow = () => { export const MainWindow = () => {
const { settings, set: setSetting } = useSettings(); const { settings, set: setSetting, ready: settingsReady } = useSettings();
const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory(); const localNetworkSharing = useLocalNetworkSharing();
const games = useGames(rescan); const identityDiagnostic = useIdentityDiagnostic();
const actions = useGameActions(games, settings); const {
const thumbnails = useThumbnails(); gameDir,
const callToPlay = useCallToPlay(settings.username); 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); useEffect(() => {
const [removeGameId, setRemoveGameId] = useState<string | null>(null); const owner = new AsyncOwner((error) => {
const [settingsOpen, setSettingsOpen] = useState(false); console.error("Failed to clean up a main-window action:", error);
const [callToPlayOpen, setCallToPlayOpen] = useState(false); });
const [focusedCallId, setFocusedCallId] = useState<string | null>(null); windowActionOwnerRef.current = owner;
const visibleGames = useMemo( return () => {
() => hasGameDirectory ? games.games : [], if (windowActionOwnerRef.current === owner) {
[games.games, hasGameDirectory], windowActionOwnerRef.current = null;
); }
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]); windowAsyncScope.adopt(owner.dispose());
};
}, []);
// Query is local UI state (no need to persist). const [openGameId, setOpenGameId] = useState<string | null>(null);
const [query, setQuery] = useState(''); const supportsStreamedInstall = useStreamInstallCapability(openGameId);
const filteredGames = useMemo( const [removeGameId, setRemoveGameId] = useState<string | null>(null);
() => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query), const [settingsOpen, setSettingsOpen] = useState(false);
[visibleGames, settings.filter, settings.sort, query], 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>( // Query is local UI state (no need to persist).
() => openGameId ? games.games.find(g => g.id === openGameId) ?? null : null, const [query, setQuery] = useState("");
[openGameId, games.games], const filteredGames = useMemo(
); () =>
const removeGame = useMemo<Game | null>( applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
() => removeGameId ? games.games.find(g => g.id === removeGameId) ?? null : null, [visibleGames, settings.filter, settings.sort, query],
[removeGameId, games.games], );
);
const pickDirectory = useCallback(async () => { const openGame = useMemo<Game | null>(
const picked = await open({ multiple: false, directory: true }); () =>
if (typeof picked === 'string' && picked) setGameDir(picked); openGameId ? games.games.find((g) => g.id === openGameId) ?? null : null,
}, [setGameDir]); [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) => { const pickDirectory = useCallback(async () => {
if (game.installed) { const owner = windowActionOwnerRef.current;
if (needsUpdate(game)) actions.update(game.id); if (owner === null || !owner.isActive()) return;
else actions.play(game.id);
} else { try {
actions.install(game.id); 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) => { const rootStyle = { "--accent": settings.accent } as React.CSSProperties;
actions.uninstall(game.id); const className = [
}, [actions]); "launcher",
`bg-${settings.bg}`,
`density-${settings.density}`,
].join(" ");
const handleRemoveDownload = useCallback((game: Game) => { if (
setRemoveGameId(game.id); !settingsReady ||
}, []); !localNetworkSharing.ready ||
!identityDiagnostic.ready ||
!gameDirectoryReady
) {
return <div className={className} style={rootStyle} aria-busy="true" />;
}
const confirmRemoveDownload = useCallback((game: Game) => { return (
actions.removeDownload(game.id); <div className={className} style={rootStyle}>
setRemoveGameId(null); <TopBar
setOpenGameId(current => current === game.id ? null : current); accent={settings.accent}
}, [actions]); peerCount={games.totalPeerCount}
filter={settings.filter}
const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [ setFilter={(v) => setSetting("filter", v)}
{ kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) }, counts={counts}
{ kind: 'item', label: 'Refresh library', onClick: () => rescan() }, query={query}
{ kind: 'separator' }, setQuery={setQuery}
{ kind: 'item', label: 'Application logs', onClick: () => void openMainLogsWindow() }, sort={settings.sort}
{ kind: 'item', label: 'Unpack logs', onClick: () => void openLogsWindow() }, setSort={(v) => setSetting("sort", v)}
], [rescan]); kebabItems={kebabItems}
nominations={callToPlay.nominations}
const rootStyle = { '--accent': settings.accent } as React.CSSProperties; onOpenCallToPlay={() => {
const className = [ setFocusedCallId(null);
'launcher', setCallToPlayOpen(true);
`bg-${settings.bg}`, }}
`density-${settings.density}`, />
].join(' '); <main className="grid-wrap">
<div
return ( className={`network-notice network-notice-sharing is-${localNetworkSharing.snapshot.phase}`}
<div className={className} style={rootStyle}> role="status"
<TopBar >
accent={settings.accent} {localNetworkSharingNotice(localNetworkSharing.snapshot)}
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);
}}
/>
)}
</div> </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, phaseOf,
bumpTime, bumpTime,
normalizeTimeInput, normalizeTimeInput,
pruneCallToPlayEvents,
readyCountOf, readyCountOf,
reduceCallToPlayEvents, reduceCallToPlayEvents,
replaceCallToPlayView,
sortNominations, sortNominations,
statusOf, statusOf,
} from '../src/lib/callToPlay.ts'; } 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; const NOW = 1_000_000;
@@ -33,11 +33,11 @@ const event = (
action: CallToPlayAction, action: CallToPlayAction,
at = NOW, at = NOW,
actorName = actorId, actorName = actorId,
): CallToPlayEvent => ({ ): CallToPlayViewEvent => ({
id, id,
call_id: 'call-1', call_id: 'call-1',
actor_id: actorId, author_id: actorId,
actor_name: actorName, author_name: actorName,
at, at,
action, action,
}); });
@@ -45,7 +45,7 @@ const event = (
const create = ( const create = (
scheduledFor: number | null = null, scheduledFor: number | null = null,
deadline = NOW + 30 * 60_000, deadline = NOW + 30 * 60_000,
): CallToPlayEvent => event('create', 'Alice', { ): CallToPlayViewEvent => event('create', 'Alice', {
Create: { Create: {
game_id: 'game-1', game_id: 'game-1',
max_players: 3, max_players: 3,
@@ -187,17 +187,17 @@ Deno.test('publish failures distinguish startup and store outcomes', () => {
'peer startup message', 'peer startup message',
); );
assertEquals( 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.', 'This Call to Play has expired or already finished.',
'obsolete call message', 'obsolete call message',
); );
assertEquals( 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.', 'This Call to Play has expired or already finished.',
'missing expired history message', 'missing expired history message',
); );
assertEquals( 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.', 'Call to Play has reached its active update limit. Start or cancel an active call, then try again.',
'active history limit message', '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', () => { Deno.test('reduction is order-independent and deduplicates events and messages', () => {
const message = event('message-event', 'Bob', { const message = event('message-event', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'Ready?' }, SendMessage: { text: 'Ready?' },
}, NOW + 2); }, NOW + 2);
const duplicateMessage = event('other-event', 'Bob', { const events = [message, create(), message];
SendMessage: { message_id: 'message-1', text: 'duplicate' },
}, NOW + 3);
const events = [message, create(), message, duplicateMessage];
const [nomination] = reduceCallToPlayEvents(events, NOW + 4); const [nomination] = reduceCallToPlayEvents(events, NOW + 4);
assertEquals(nomination.messages.length, 1, 'unique message id'); assertEquals(nomination.messages.length, 1, 'event IDs deduplicate messages');
assertEquals(nomination.messages[0].text, 'Ready?', 'first message wins'); assertEquals(nomination.messages[0].text, 'Ready?', 'message retained');
}); });
Deno.test('actions timestamped before creation cannot mutate a call', () => { 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(), create(),
event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1), event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
event('message', 'Bob', { event('message', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'Launching' }, SendMessage: { text: 'Launching' },
}, NOW + 2), }, NOW + 2),
event('start', 'Alice', 'Start', NOW + 3), event('start', 'Alice', 'Start', NOW + 3),
event('late-message', 'Bob', { event('late-message', 'Bob', {
SendMessage: { message_id: 'message-2', text: 'Too late' }, SendMessage: { text: 'Too late' },
}, NOW + 4), }, NOW + 4),
]; ];
const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS); 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'); assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
}); });
Deno.test('retired calls are pruned from the frontend raw event map', () => { Deno.test('incoming Call to Play views replace removed author slices wholesale', () => {
const terminalEvents = [ const previous = { events: [create(), event('join', 'Bob', 'Rsvp', NOW + 1)] };
create(), const incoming = { events: [create()] };
event('start', 'Alice', 'Start', NOW + 1),
];
const map = new Map(terminalEvents.map(item => [item.id, item]));
assertEquals( const replaced = replaceCallToPlayView(previous, incoming);
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS).size, assertEquals(replaced.events.length, 1, 'departed author slice is removed');
2, assertEquals(replaced.events[0].id, 'create', 'incoming projection is authoritative');
'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',
);
}); });
Deno.test('scheduled time input accepts design formats and wraps steppers', () => { 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 { import {
actionLabel, actionLabel,
activeStatusById, activeStatusById,
applyFilterAndSort, applyFilterAndSort,
canStreamInstall, applyGameTransferStatusSnapshot,
countByFilter, canStreamInstall,
deriveState, countByFilter,
downloadProgressPercent, deriveState,
formatDownloadBytes, downloadProgressPercent,
formatBytesPerSecond, formatBytesPerSecond,
formatDownloadEta, formatDownloadBytes,
formatDownloadSpeed, formatDownloadEta,
formatDownloadSpeedShort, formatDownloadSpeed,
gameStatusLabel, formatDownloadSpeedShort,
mergeGameUpdate, gameStatusLabel,
stateChipLabel, mergeGameUpdate,
} from '../src/lib/gameState.ts'; stateChipLabel,
} from "../src/lib/gameState.ts";
import { import {
ActiveOperationKind, ActiveOperationKind,
GameAvailability, type Game,
InstallStatus, GameAvailability,
type Game, GameTransferStatus,
} from '../src/lib/types.ts'; InstallStatus,
} from "../src/lib/types.ts";
const assertEquals = <T>(actual: T, expected: T, message: string) => { const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) { if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`); throw new Error(`${message}: expected ${expected}, got ${actual}`);
} }
}; };
const game = (overrides: Partial<Game> = {}): Game => ({ const game = (overrides: Partial<Game> = {}): Game => ({
id: 'game', id: "game",
name: 'Game', name: "Game",
description: '', description: "",
size: 0, size: 0,
downloaded: false, downloaded: false,
installed: false, installed: false,
availability: GameAvailability.LocalOnly, availability: GameAvailability.LocalOnly,
install_status: InstallStatus.NotInstalled, 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, 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', () => { Deno.test("sticky transfer exhaustion keeps a departed remote game visible until cleared", () => {
const fromDownloading = game({ const remoteOnly = game({
install_status: InstallStatus.Downloading, id: "remote-only",
}); name: "Remote only",
const installing = mergeGameUpdate( peer_count: 1,
game({ downloaded: true }), });
fromDownloading, assertEquals(
InstallStatus.Installing, applyFilterAndSort([remoteOnly], "all", "az", "").length,
); 1,
const installedWhileActive = mergeGameUpdate( "a remote-only game should initially be visible",
game({ downloaded: true, installed: true }), );
installing,
InstallStatus.Installing,
);
const settled = mergeGameUpdate(
game({ downloaded: true, installed: true }),
installedWhileActive,
);
assertEquals( const peerDeparted = mergeGameUpdate(
installing.install_status, game({ id: "remote-only", name: "Remote only", peer_count: 0 }),
InstallStatus.Installing, remoteOnly,
'active install snapshot should render Installing', );
); assertEquals(
assertEquals( peerDeparted.install_status,
installedWhileActive.install_status, InstallStatus.NotInstalled,
InstallStatus.Installing, "an empty active-operation snapshot should leave the game idle",
'installed local state should not override an active install snapshot', );
); assertEquals(
assertEquals( peerDeparted.peer_count,
settled.install_status, 0,
InstallStatus.Installed, "the sole remote source should be absent from the next catalog snapshot",
'cleared active snapshot with installed local state should render Installed', );
); 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', () => { Deno.test("transient transfer activity does not resurrect an invisible game", () => {
const statuses = activeStatusById([ const departed = game({ peer_count: 0 });
{ id: 'game', operation: ActiveOperationKind.Downloading }, for (
{ id: 'other', operation: ActiveOperationKind.Updating }, const transferStatus of [
]); GameTransferStatus.Verifying,
GameTransferStatus.Retrying,
]
) {
assertEquals( assertEquals(
statuses.get('game'), applyFilterAndSort(
InstallStatus.Downloading, [{ ...departed, transfer_status: transferStatus }],
'download operation should render Downloading', "all",
); "az",
assertEquals( "",
statuses.get('other'), ).length,
InstallStatus.Installing, 0,
'update operation should render Installing', `${transferStatus} should not make an otherwise-invisible game visible`,
); );
}
}); });
Deno.test('download progress is preserved only while actively downloading', () => { Deno.test("download progress formatting matches the progress-bar layouts", () => {
const downloading = game({ const downloading = game({
install_status: InstallStatus.Downloading, install_status: InstallStatus.Downloading,
download_progress: { download_progress: {
downloaded_bytes: 50, attemptId: "1",
total_bytes: 100, downloaded_bytes: 12 * 1024 * 1024 * 1024,
bytes_per_second: 12_500_000, total_bytes: 35 * 1024 * 1024 * 1024,
active_peer_count: 2, bytes_per_second: 49_400_000,
}, active_peer_count: 3,
}); },
});
const stillDownloading = mergeGameUpdate( assertEquals(
game(), Math.round(downloadProgressPercent(downloading)),
downloading, 34,
InstallStatus.Downloading, "progress percent should come from backend byte counters",
); );
const settled = mergeGameUpdate(game({ downloaded: true }), stillDownloading); assertEquals(
formatDownloadSpeed(49_400_000),
assertEquals( "49.4 MB/s",
stillDownloading.download_progress?.downloaded_bytes, "large bar speed format",
50, );
'active download snapshot should keep progress', assertEquals(
); formatDownloadSpeedShort(49_400_000),
assertEquals( "49 MB/s",
stillDownloading.download_progress?.active_peer_count, "card speed format",
2, );
'active download snapshot should keep live peer count', assertEquals(
); formatDownloadBytes(12 * 1024 * 1024 * 1024),
assertEquals( "12 GB",
settled.download_progress, "downloaded byte format should avoid noisy trailing decimals",
undefined, );
'settled snapshot should clear progress', assertEquals(
); formatDownloadEta(485),
"8 min",
"eta format should stay compact",
);
}); });
Deno.test('downloading action label includes current speed', () => { Deno.test("stream install is available only for idle remote games", () => {
const downloading = game({ assertEquals(
install_status: InstallStatus.Downloading, canStreamInstall(
download_progress: { game({ downloaded: false, installed: false, peer_count: 1 }),
downloaded_bytes: 50, true,
total_bytes: 100, ),
bytes_per_second: 12_500_000, true,
active_peer_count: 2, "catalog-supported remote-only idle games should allow streamed install",
}, );
}); assertEquals(
canStreamInstall(
assertEquals( game({ downloaded: false, installed: false, peer_count: 1 }),
formatBytesPerSecond(12_500_000), false,
'12.5 MB/s', ),
'speed formatter should use compact decimal units', false,
); "catalog-unsupported games should not expose streamed install",
assertEquals( );
actionLabel(downloading), assertEquals(
'Downloading… 12.5 MB/s', canStreamInstall(
'download label should include speed', game({ downloaded: true, installed: false, peer_count: 1 }),
); true,
}); ),
false,
Deno.test('downloading state is distinct and stays on the local filter', () => { "downloaded games should install from local archives",
const downloading = game({ );
id: 'downloading', assertEquals(
name: 'Downloading', canStreamInstall(
install_status: InstallStatus.Downloading, game({ downloaded: false, installed: true, peer_count: 1 }),
}); true,
const local = game({ ),
id: 'local', false,
name: 'Local', "installed games should not expose streamed install",
downloaded: true, );
}); assertEquals(
const remote = game({ canStreamInstall(
id: 'remote', game({ downloaded: false, installed: false, peer_count: 0 }),
name: 'Remote', true,
peer_count: 1, ),
}); false,
"games without peers should not expose streamed install",
assertEquals( );
deriveState(downloading), assertEquals(
'downloading', canStreamInstall(
'download operation should render the dedicated downloading state', game({
);
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({
downloaded: false, downloaded: false,
installed: true, installed: false,
install_status: InstallStatus.Installed, peer_count: 1,
}); install_status: InstallStatus.Installing,
const downloadedInstall = game({ }),
downloaded: true, true,
installed: true, ),
install_status: InstallStatus.Installed, false,
}); "busy games should not expose streamed install",
);
});
assertEquals( Deno.test("streamed local installs are labeled installed but not shareable", () => {
deriveState(streamed), const streamed = game({
'installed', downloaded: false,
'streamed local installs should keep installed visual state', installed: true,
); install_status: InstallStatus.Installed,
assertEquals( });
stateChipLabel(streamed), const downloadedInstall = game({
'Not shareable', downloaded: true,
'card chip should make the non-shareable state visible', installed: true,
); install_status: InstallStatus.Installed,
assertEquals( });
gameStatusLabel(streamed),
'Installed, not shareable', assertEquals(
'detail status should spell out installed plus non-shareable', deriveState(streamed),
); "installed",
assertEquals( "streamed local installs should keep installed visual state",
stateChipLabel(downloadedInstall), );
'Installed', assertEquals(
'normal downloaded installs should keep the installed chip label', stateChipLabel(streamed),
); "Not shareable",
assertEquals( "card chip should make the non-shareable state visible",
gameStatusLabel(downloadedInstall), );
'Installed', assertEquals(
'normal downloaded installs should keep the installed detail label', 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",
);
});