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:
@@ -19,6 +19,7 @@ import {
|
||||
rowFromPayload,
|
||||
rowsFromHistory,
|
||||
} from './lib/mainLogs';
|
||||
import { AsyncOwner, windowAsyncScope } from './lib/asyncOwnership';
|
||||
|
||||
import './MainLogsWindow.css';
|
||||
|
||||
@@ -43,6 +44,8 @@ export const MainLogsWindow = () => {
|
||||
const pausedRef = useRef(false);
|
||||
const lastHistorySequenceRef = useRef(0);
|
||||
const historyLineCountsRef = useRef<Map<string, number>>(new Map());
|
||||
const viewOwnerRef = useRef<AsyncOwner | null>(null);
|
||||
const copyStatusTimerRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const appendVisibleRows = useCallback((rows: MainLogRow[]) => {
|
||||
setLogs(current => capLogRows([...current, ...rows]));
|
||||
@@ -54,10 +57,18 @@ export const MainLogsWindow = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
const owner = new AsyncOwner(error => {
|
||||
console.error('Failed to unregister main log listener:', error);
|
||||
});
|
||||
viewOwnerRef.current = owner;
|
||||
owner.own(() => {
|
||||
if (copyStatusTimerRef.current !== undefined) {
|
||||
window.clearTimeout(copyStatusTimerRef.current);
|
||||
copyStatusTimerRef.current = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const handleIncomingRow = (row: MainLogRow) => {
|
||||
const handleIncomingRow = owner.guard((row: MainLogRow) => {
|
||||
if (!historyLoadedRef.current) {
|
||||
initialBufferRef.current = capLogRows([...initialBufferRef.current, row]);
|
||||
return;
|
||||
@@ -76,43 +87,55 @@ export const MainLogsWindow = () => {
|
||||
return;
|
||||
}
|
||||
appendVisibleRows([row]);
|
||||
};
|
||||
});
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
unlisten = await listen<MainLogLinePayload>('main-log-line', event => {
|
||||
handleIncomingRow(rowFromPayload(event.payload));
|
||||
});
|
||||
|
||||
const history = await invoke<MainLogHistoryPayload>('get_main_logs');
|
||||
if (cancelled) return;
|
||||
|
||||
lastHistorySequenceRef.current = history.lastSequence;
|
||||
const historyRows = rowsFromHistory(history.contents);
|
||||
const historyLineCounts = lineCountsFromRows(historyRows);
|
||||
const liveRows = dedupeBufferedRows(
|
||||
historyLineCounts,
|
||||
initialBufferRef.current,
|
||||
lastHistorySequenceRef.current,
|
||||
const registered = await owner.register(() =>
|
||||
listen<MainLogLinePayload>('main-log-line', event => {
|
||||
handleIncomingRow(rowFromPayload(event.payload));
|
||||
})
|
||||
);
|
||||
initialBufferRef.current = [];
|
||||
historyLineCountsRef.current = historyLineCounts;
|
||||
historyLoadedRef.current = true;
|
||||
|
||||
if (pausedRef.current) {
|
||||
setLogs(capLogRows(historyRows));
|
||||
bufferPausedRows(liveRows);
|
||||
} else {
|
||||
setLogs(capLogRows([...historyRows, ...liveRows]));
|
||||
}
|
||||
setLoadError(null);
|
||||
if (!registered) return;
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
if (owner.isActive()) {
|
||||
historyLoadedRef.current = true;
|
||||
setLoadError(err instanceof Error ? err.message : String(err));
|
||||
setLoading(false);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await owner.applyIfActive(
|
||||
() => invoke<MainLogHistoryPayload>('get_main_logs'),
|
||||
history => {
|
||||
lastHistorySequenceRef.current = history.lastSequence;
|
||||
const historyRows = rowsFromHistory(history.contents);
|
||||
const historyLineCounts = lineCountsFromRows(historyRows);
|
||||
const liveRows = dedupeBufferedRows(
|
||||
historyLineCounts,
|
||||
initialBufferRef.current,
|
||||
lastHistorySequenceRef.current,
|
||||
);
|
||||
initialBufferRef.current = [];
|
||||
historyLineCountsRef.current = historyLineCounts;
|
||||
historyLoadedRef.current = true;
|
||||
|
||||
if (pausedRef.current) {
|
||||
setLogs(capLogRows(historyRows));
|
||||
bufferPausedRows(liveRows);
|
||||
} else {
|
||||
setLogs(capLogRows([...historyRows, ...liveRows]));
|
||||
}
|
||||
setLoadError(null);
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (owner.isActive()) {
|
||||
historyLoadedRef.current = true;
|
||||
setLoadError(err instanceof Error ? err.message : String(err));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -121,12 +144,12 @@ export const MainLogsWindow = () => {
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
windowAsyncScope.adopt(owner.dispose());
|
||||
if (viewOwnerRef.current === owner) viewOwnerRef.current = null;
|
||||
historyLoadedRef.current = false;
|
||||
initialBufferRef.current = [];
|
||||
lastHistorySequenceRef.current = 0;
|
||||
historyLineCountsRef.current = new Map();
|
||||
unlisten?.();
|
||||
};
|
||||
}, [appendVisibleRows, bufferPausedRows]);
|
||||
|
||||
@@ -156,9 +179,10 @@ export const MainLogsWindow = () => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const frame = requestAnimationFrame(() => {
|
||||
viewport.scrollTop = viewport.scrollHeight;
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [autoScroll, filteredRows.length, lastVisibleRow?.id]);
|
||||
|
||||
const flushPausedRows = useCallback(() => {
|
||||
@@ -189,13 +213,24 @@ export const MainLogsWindow = () => {
|
||||
}, []);
|
||||
|
||||
const copyFilteredLogs = useCallback(async () => {
|
||||
const owner = viewOwnerRef.current;
|
||||
if (!owner) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n'));
|
||||
setCopyStatus('Copied');
|
||||
if (!await owner.applyIfActive(
|
||||
() => navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n')),
|
||||
() => setCopyStatus('Copied'),
|
||||
)) return;
|
||||
} catch {
|
||||
if (!owner.isActive()) return;
|
||||
setCopyStatus('Copy failed');
|
||||
}
|
||||
window.setTimeout(() => setCopyStatus(null), 1600);
|
||||
if (copyStatusTimerRef.current !== undefined) {
|
||||
window.clearTimeout(copyStatusTimerRef.current);
|
||||
}
|
||||
copyStatusTimerRef.current = window.setTimeout(owner.guard(() => {
|
||||
copyStatusTimerRef.current = undefined;
|
||||
setCopyStatus(null);
|
||||
}), 1600);
|
||||
}, [filteredRows]);
|
||||
|
||||
return (
|
||||
@@ -219,7 +254,7 @@ export const MainLogsWindow = () => {
|
||||
</button>
|
||||
<button
|
||||
className="settings-button"
|
||||
onClick={() => void copyFilteredLogs()}
|
||||
onClick={() => windowAsyncScope.adopt(copyFilteredLogs())}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
Copy
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
import { AsyncOwner, windowAsyncScope } from './lib/asyncOwnership';
|
||||
|
||||
import './UnpackLogsWindow.css';
|
||||
|
||||
interface UnpackLogEntry {
|
||||
@@ -49,28 +51,56 @@ export const UnpackLogsWindow = () => {
|
||||
const [errorsOnly, setErrorsOnly] = useState(false);
|
||||
const [regexInput, setRegexInput] = useState('');
|
||||
const [selectedOriginalIndex, setSelectedOriginalIndex] = useState<number | null>(null);
|
||||
const refreshOwnerRef = useRef<AsyncOwner | null>(null);
|
||||
|
||||
const loadLogs = useCallback((owner: AsyncOwner): Promise<boolean> =>
|
||||
owner.applyLatestIfActive(
|
||||
() => invoke<UnpackLogEntry[]>('get_unpack_logs'),
|
||||
unpackLogs => setLogs(unpackLogs),
|
||||
), []);
|
||||
|
||||
const refreshLogs = useCallback(async () => {
|
||||
const unpackLogs = await invoke<UnpackLogEntry[]>('get_unpack_logs');
|
||||
setLogs(unpackLogs);
|
||||
}, []);
|
||||
const owner = refreshOwnerRef.current;
|
||||
if (!owner) return;
|
||||
try {
|
||||
await loadLogs(owner);
|
||||
} catch (error) {
|
||||
if (owner.isActive()) console.error('Failed to refresh unpack logs:', error);
|
||||
}
|
||||
}, [loadLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
const owner = new AsyncOwner(error => {
|
||||
console.error('Failed to unregister unpack log listener:', error);
|
||||
});
|
||||
refreshOwnerRef.current = owner;
|
||||
|
||||
const setup = async () => {
|
||||
await refreshLogs();
|
||||
unlisten = await listen('unpack-logs-updated', () => {
|
||||
void refreshLogs();
|
||||
});
|
||||
try {
|
||||
const registered = await owner.register(() => listen(
|
||||
'unpack-logs-updated',
|
||||
owner.guard(() => {
|
||||
void loadLogs(owner).catch(error => {
|
||||
if (owner.isActive()) {
|
||||
console.error('Failed to refresh unpack logs:', error);
|
||||
}
|
||||
});
|
||||
}),
|
||||
));
|
||||
if (!registered) return;
|
||||
await loadLogs(owner);
|
||||
} catch (error) {
|
||||
if (owner.isActive()) console.error('Failed to set up unpack logs:', error);
|
||||
}
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
windowAsyncScope.adopt(owner.dispose());
|
||||
if (refreshOwnerRef.current === owner) refreshOwnerRef.current = null;
|
||||
};
|
||||
}, [refreshLogs]);
|
||||
}, [loadLogs]);
|
||||
|
||||
const { regex, regexError } = useMemo(() => {
|
||||
if (!regexInput) {
|
||||
|
||||
@@ -1,137 +1,160 @@
|
||||
import { CSSProperties, MouseEvent } from 'react';
|
||||
import { CSSProperties, MouseEvent } from "react";
|
||||
|
||||
import { Game } from '../lib/types';
|
||||
import { Game } from "../lib/types";
|
||||
import {
|
||||
downloadProgressPercent,
|
||||
formatDownloadBytes,
|
||||
formatDownloadEta,
|
||||
formatDownloadSpeed,
|
||||
formatDownloadSpeedShort,
|
||||
} from '../lib/gameState';
|
||||
downloadProgressAriaLabel,
|
||||
downloadProgressPercent,
|
||||
downloadProgressTransferLabel,
|
||||
formatDownloadBytes,
|
||||
formatDownloadEta,
|
||||
formatDownloadSpeed,
|
||||
formatDownloadSpeedShort,
|
||||
} from "../lib/gameState";
|
||||
|
||||
import { Icon } from './Icon';
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
size?: 'md' | 'lg';
|
||||
full?: boolean;
|
||||
onCancel?: (game: Game) => void;
|
||||
game: Game;
|
||||
size?: "md" | "lg";
|
||||
full?: boolean;
|
||||
onCancel?: (game: Game) => void;
|
||||
}
|
||||
|
||||
const progressStats = (game: Game) => {
|
||||
const progress = game.download_progress;
|
||||
const downloaded = progress?.downloaded_bytes ?? 0;
|
||||
const total = progress?.total_bytes ?? game.size;
|
||||
const speed = progress?.bytes_per_second ?? 0;
|
||||
const remaining = Math.max(0, total - downloaded);
|
||||
const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY;
|
||||
const progress = game.download_progress;
|
||||
const downloaded = progress?.downloaded_bytes ?? 0;
|
||||
const total = progress?.total_bytes ?? game.size;
|
||||
const speed = progress?.bytes_per_second ?? 0;
|
||||
const remaining = Math.max(0, total - downloaded);
|
||||
const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY;
|
||||
|
||||
return {
|
||||
pct: Math.min(99, Math.round(downloadProgressPercent(game))),
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
eta: etaSeconds,
|
||||
activePeerCount: progress?.active_peer_count ?? 0,
|
||||
};
|
||||
return {
|
||||
pct: Math.min(99, Math.round(downloadProgressPercent(game))),
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
eta: etaSeconds,
|
||||
activePeerCount: progress?.active_peer_count ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const DownloadProgress = ({ game, size = 'md', full = false, onCancel }: Props) => {
|
||||
const stats = progressStats(game);
|
||||
const progressStyle = {
|
||||
'--download-progress': `${stats.pct}%`,
|
||||
} as CSSProperties;
|
||||
const className = [
|
||||
'dl',
|
||||
size === 'lg' ? 'dl-lg' : 'dl-md',
|
||||
full ? 'dl-full' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
export const DownloadProgress = (
|
||||
{ game, size = "md", full = false, onCancel }: Props,
|
||||
) => {
|
||||
const stats = progressStats(game);
|
||||
const transferLabel = downloadProgressTransferLabel(game);
|
||||
const ariaLabel = downloadProgressAriaLabel(game);
|
||||
const progressStyle = {
|
||||
"--download-progress": `${stats.pct}%`,
|
||||
} as CSSProperties;
|
||||
const className = [
|
||||
"dl",
|
||||
size === "lg" ? "dl-lg" : "dl-md",
|
||||
full ? "dl-full" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
const handleCancel = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onCancel?.(game);
|
||||
};
|
||||
const handleCancel = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onCancel?.(game);
|
||||
};
|
||||
|
||||
if (size === 'lg') {
|
||||
const peerUnit = stats.activePeerCount === 1 ? 'peer' : 'peers';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="progressbar"
|
||||
aria-label={`Downloading ${game.name}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={stats.pct}
|
||||
style={progressStyle}
|
||||
>
|
||||
<div className="dl-fill" aria-hidden />
|
||||
<div className="dl-lg-grid">
|
||||
<div className="dl-lg-primary">
|
||||
<span className="dl-pulse" aria-hidden />
|
||||
<span className="dl-label">Downloading</span>
|
||||
</div>
|
||||
<div className="dl-lg-secondary">
|
||||
<span className="dl-bytes">
|
||||
<strong>{formatDownloadBytes(stats.downloaded)}</strong>
|
||||
<span className="dl-of"> / {formatDownloadBytes(stats.total)}</span>
|
||||
</span>
|
||||
<span className="dl-sep">·</span>
|
||||
<span className="dl-speed">{formatDownloadSpeed(stats.speed)}</span>
|
||||
{stats.activePeerCount > 0 && (
|
||||
<>
|
||||
<span className="dl-sep dl-sep-peers">·</span>
|
||||
<span
|
||||
className="dl-peers"
|
||||
title={`Downloading from ${stats.activePeerCount} ${peerUnit} on the LAN`}
|
||||
>
|
||||
<Icon.users />
|
||||
<span>{stats.activePeerCount}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="dl-sep dl-sep-eta">·</span>
|
||||
<span className="dl-eta">{formatDownloadEta(stats.eta)} left</span>
|
||||
</div>
|
||||
<div className="dl-lg-pct">
|
||||
{stats.pct}
|
||||
<span className="dl-pct-sym">%</span>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="dl-cancel"
|
||||
onClick={handleCancel}
|
||||
aria-label={`Cancel download of ${game.name}`}
|
||||
>
|
||||
<Icon.close />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (size === "lg") {
|
||||
const peerUnit = stats.activePeerCount === 1 ? "peer" : "peers";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="progressbar"
|
||||
aria-label={`Downloading ${game.name}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={stats.pct}
|
||||
title={`${stats.pct}% · ${formatDownloadSpeed(stats.speed)} · ${formatDownloadEta(stats.eta)} left`}
|
||||
style={progressStyle}
|
||||
>
|
||||
<div className="dl-fill" aria-hidden />
|
||||
<div className="dl-md-row">
|
||||
<span className="dl-pct">
|
||||
<span className="dl-pulse" aria-hidden />
|
||||
{stats.pct}
|
||||
<span className="dl-pct-sym">%</span>
|
||||
<div
|
||||
className={className}
|
||||
role="progressbar"
|
||||
aria-label={ariaLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={stats.pct}
|
||||
style={progressStyle}
|
||||
>
|
||||
<div className="dl-fill" aria-hidden />
|
||||
<div className="dl-lg-grid">
|
||||
<div className="dl-lg-primary">
|
||||
<span className="dl-pulse" aria-hidden />
|
||||
<span className="dl-label">{transferLabel ?? "Downloading"}</span>
|
||||
</div>
|
||||
<div className="dl-lg-secondary">
|
||||
<span className="dl-bytes">
|
||||
<strong>{formatDownloadBytes(stats.downloaded)}</strong>
|
||||
<span className="dl-of">
|
||||
/ {formatDownloadBytes(stats.total)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="dl-sep">·</span>
|
||||
<span className="dl-speed">{formatDownloadSpeed(stats.speed)}</span>
|
||||
{stats.activePeerCount > 0 && (
|
||||
<>
|
||||
<span className="dl-sep dl-sep-peers">·</span>
|
||||
<span
|
||||
className="dl-peers"
|
||||
title={`Downloading from ${stats.activePeerCount} ${peerUnit} on the LAN`}
|
||||
>
|
||||
<Icon.users />
|
||||
<span>{stats.activePeerCount}</span>
|
||||
</span>
|
||||
<span className="dl-speed">{formatDownloadSpeedShort(stats.speed)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<span className="dl-sep dl-sep-eta">·</span>
|
||||
<span className="dl-eta">{formatDownloadEta(stats.eta)} left</span>
|
||||
</div>
|
||||
<div className="dl-lg-pct">
|
||||
{stats.pct}
|
||||
<span className="dl-pct-sym">%</span>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="dl-cancel"
|
||||
onClick={handleCancel}
|
||||
aria-label={`Cancel download of ${game.name}`}
|
||||
>
|
||||
<Icon.close />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="progressbar"
|
||||
aria-label={ariaLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={stats.pct}
|
||||
title={`${transferLabel ? `${transferLabel} · ` : ""}${stats.pct}% · ${
|
||||
formatDownloadSpeed(stats.speed)
|
||||
} · ${formatDownloadEta(stats.eta)} left`}
|
||||
style={progressStyle}
|
||||
>
|
||||
<div className="dl-fill" aria-hidden />
|
||||
<div className="dl-md-row">
|
||||
{transferLabel
|
||||
? (
|
||||
<span className="dl-md-status">
|
||||
<span className="dl-pulse" aria-hidden />
|
||||
<span>{transferLabel}</span>
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<span className="dl-pct">
|
||||
<span className="dl-pulse" aria-hidden />
|
||||
{stats.pct}
|
||||
<span className="dl-pct-sym">%</span>
|
||||
</span>
|
||||
<span className="dl-speed">
|
||||
{formatDownloadSpeedShort(stats.speed)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,90 +1,109 @@
|
||||
import { JSX, KeyboardEvent } from 'react';
|
||||
import { JSX, KeyboardEvent } from "react";
|
||||
|
||||
import { Game } from '../../lib/types';
|
||||
import { CoverAspect } from '../../hooks/useSettings';
|
||||
import { formatBytes } from '../../lib/format';
|
||||
import { hasNewerLocalVersion } from '../../lib/gameState';
|
||||
import { Game } from "../../lib/types";
|
||||
import { CoverAspect } from "../../hooks/useSettings";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import {
|
||||
gameTransferStatusPresentation,
|
||||
hasNewerLocalVersion,
|
||||
} from "../../lib/gameState";
|
||||
|
||||
import { GameCover } from './GameCover';
|
||||
import { StateChip } from '../StateChip';
|
||||
import { ActionButton } from '../ActionButton';
|
||||
import { Icon } from '../Icon';
|
||||
import { GameCover } from "./GameCover";
|
||||
import { StateChip } from "../StateChip";
|
||||
import { ActionButton } from "../ActionButton";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
aspect: CoverAspect;
|
||||
thumbnailUrl: string | null;
|
||||
onOpen: (game: Game) => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
game: Game;
|
||||
aspect: CoverAspect;
|
||||
thumbnailUrl: string | null;
|
||||
onOpen: (game: Game) => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
}
|
||||
|
||||
const metaSeparator = (...parts: Array<string | null | undefined>): JSX.Element[] => {
|
||||
const filtered = parts.filter(Boolean) as string[];
|
||||
const out: JSX.Element[] = [];
|
||||
filtered.forEach((p, i) => {
|
||||
if (i > 0) out.push(<span key={`d${i}`} className="card-dot">·</span>);
|
||||
out.push(<span key={`p${i}`}>{p}</span>);
|
||||
});
|
||||
return out;
|
||||
const metaSeparator = (
|
||||
...parts: Array<string | null | undefined>
|
||||
): JSX.Element[] => {
|
||||
const filtered = parts.filter(Boolean) as string[];
|
||||
const out: JSX.Element[] = [];
|
||||
filtered.forEach((p, i) => {
|
||||
if (i > 0) out.push(<span key={`d${i}`} className="card-dot">·</span>);
|
||||
out.push(<span key={`p${i}`}>{p}</span>);
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
export const GameCard = ({
|
||||
game,
|
||||
aspect,
|
||||
thumbnailUrl,
|
||||
onOpen,
|
||||
onPrimary,
|
||||
onCancelDownload,
|
||||
game,
|
||||
aspect,
|
||||
thumbnailUrl,
|
||||
onOpen,
|
||||
onPrimary,
|
||||
onCancelDownload,
|
||||
}: Props) => {
|
||||
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onOpen(game);
|
||||
}
|
||||
};
|
||||
const newerThanExpected = hasNewerLocalVersion(game);
|
||||
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
||||
const statusMessage = hasOutbound
|
||||
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}`
|
||||
: (game.status_message ?? (newerThanExpected ? 'Newer than expected' : ''));
|
||||
const statusLevel = hasOutbound
|
||||
? 'info'
|
||||
: (game.status_level ?? (newerThanExpected ? 'warning' : undefined));
|
||||
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen(game);
|
||||
}
|
||||
};
|
||||
const newerThanExpected = hasNewerLocalVersion(game);
|
||||
const transferStatus = gameTransferStatusPresentation(game.transfer_status);
|
||||
const hasOutbound = game.active_outbound_transfers !== undefined &&
|
||||
game.active_outbound_transfers > 0;
|
||||
const statusMessage = transferStatus?.message ??
|
||||
(hasOutbound
|
||||
? `Sharing to ${game.active_outbound_transfers} peer${
|
||||
game.active_outbound_transfers === 1 ? "" : "s"
|
||||
}`
|
||||
: (game.status_message ??
|
||||
(newerThanExpected ? "Newer than expected" : "")));
|
||||
const statusLevel = transferStatus?.level ??
|
||||
(hasOutbound
|
||||
? "info"
|
||||
: (game.status_level ?? (newerThanExpected ? "warning" : undefined)));
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card"
|
||||
onClick={() => onOpen(game)}
|
||||
onKeyDown={onKey}
|
||||
aria-label={game.name}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card"
|
||||
onClick={() => onOpen(game)}
|
||||
onKeyDown={onKey}
|
||||
aria-label={game.name}
|
||||
>
|
||||
<div className="card-cover-wrap" data-aspect={aspect}>
|
||||
<GameCover game={game} aspect={aspect} thumbnailUrl={thumbnailUrl} />
|
||||
<StateChip game={game} />
|
||||
{game.peer_count > 0 && (
|
||||
<div
|
||||
className="card-mp"
|
||||
title={`${game.peer_count} peer${
|
||||
game.peer_count === 1 ? "" : "s"
|
||||
} have this`}
|
||||
>
|
||||
<Icon.users />
|
||||
<span>{game.peer_count}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="card-title" title={game.name}>{game.name}</div>
|
||||
<div className="card-meta">
|
||||
{metaSeparator(formatBytes(game.size), game.genre || null)}
|
||||
</div>
|
||||
<div
|
||||
className={`card-status${statusLevel ? ` is-${statusLevel}` : ""}`}
|
||||
>
|
||||
<div className="card-cover-wrap" data-aspect={aspect}>
|
||||
<GameCover game={game} aspect={aspect} thumbnailUrl={thumbnailUrl} />
|
||||
<StateChip game={game} />
|
||||
{game.peer_count > 0 && (
|
||||
<div className="card-mp" title={`${game.peer_count} peer${game.peer_count === 1 ? '' : 's'} have this`}>
|
||||
<Icon.users />
|
||||
<span>{game.peer_count}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="card-title" title={game.name}>{game.name}</div>
|
||||
<div className="card-meta">
|
||||
{metaSeparator(formatBytes(game.size), game.genre || null)}
|
||||
</div>
|
||||
<div className={`card-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
||||
{statusMessage}
|
||||
</div>
|
||||
<ActionButton
|
||||
game={game}
|
||||
full
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
{statusMessage}
|
||||
</div>
|
||||
<ActionButton
|
||||
game={game}
|
||||
full
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,187 +1,213 @@
|
||||
import { Modal } from '../Modal';
|
||||
import { Icon } from '../Icon';
|
||||
import { GameCover } from '../grid/GameCover';
|
||||
import { StateChip } from '../StateChip';
|
||||
import { ActionButton } from '../ActionButton';
|
||||
import { Modal } from "../Modal";
|
||||
import { Icon } from "../Icon";
|
||||
import { GameCover } from "../grid/GameCover";
|
||||
import { StateChip } from "../StateChip";
|
||||
import { ActionButton } from "../ActionButton";
|
||||
|
||||
import { Game, InstallStatus } from '../../lib/types';
|
||||
import { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
|
||||
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
||||
import { Game, InstallStatus } from "../../lib/types";
|
||||
import {
|
||||
canStreamInstall,
|
||||
gameStatusLabel,
|
||||
gameTransferStatusPresentation,
|
||||
hasNewerLocalVersion,
|
||||
isInProgress,
|
||||
} from "../../lib/gameState";
|
||||
import { formatBytes, formatEtiVersion, formatPlayers } from "../../lib/format";
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
thumbnailUrl: string | null;
|
||||
onClose: () => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
onStreamInstall: (game: Game) => void;
|
||||
onUninstall: (game: Game) => void;
|
||||
onRemoveDownload: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
onStartServer: (game: Game) => void;
|
||||
onViewFiles: (game: Game) => void;
|
||||
game: Game;
|
||||
thumbnailUrl: string | null;
|
||||
supportsStreamedInstall: boolean;
|
||||
onClose: () => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
onStreamInstall: (game: Game) => void;
|
||||
onUninstall: (game: Game) => void;
|
||||
onRemoveDownload: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
onStartServer: (game: Game) => void;
|
||||
onViewFiles: (game: Game) => void;
|
||||
}
|
||||
|
||||
const tagsFromGame = (game: Game): string[] => {
|
||||
const tags: string[] = [];
|
||||
if (game.genre) tags.push(game.genre);
|
||||
if (game.publisher) tags.push(game.publisher);
|
||||
if (game.release_year) tags.push(game.release_year);
|
||||
return tags;
|
||||
const tags: string[] = [];
|
||||
if (game.genre) tags.push(game.genre);
|
||||
if (game.publisher) tags.push(game.publisher);
|
||||
if (game.release_year) tags.push(game.release_year);
|
||||
return tags;
|
||||
};
|
||||
|
||||
export const GameDetailModal = ({
|
||||
game,
|
||||
thumbnailUrl,
|
||||
onClose,
|
||||
onPrimary,
|
||||
onStreamInstall,
|
||||
onUninstall,
|
||||
onRemoveDownload,
|
||||
onCancelDownload,
|
||||
onStartServer,
|
||||
onViewFiles,
|
||||
game,
|
||||
thumbnailUrl,
|
||||
supportsStreamedInstall,
|
||||
onClose,
|
||||
onPrimary,
|
||||
onStreamInstall,
|
||||
onUninstall,
|
||||
onRemoveDownload,
|
||||
onCancelDownload,
|
||||
onStartServer,
|
||||
onViewFiles,
|
||||
}: Props) => {
|
||||
const tags = tagsFromGame(game);
|
||||
// Some game metadata contains a literal <br>; keep sanitization exact.
|
||||
const description = game.description.split('<br>').join('');
|
||||
const canRemoveDownload = game.downloaded
|
||||
&& !game.installed
|
||||
&& !isInProgress(game.install_status);
|
||||
const showStreamInstall = canStreamInstall(game);
|
||||
const canViewFiles = game.downloaded
|
||||
|| game.installed
|
||||
|| game.install_status === InstallStatus.Downloading
|
||||
|| game.install_status === InstallStatus.Installing;
|
||||
const newerThanExpected = hasNewerLocalVersion(game);
|
||||
const newerStatus = newerThanExpected
|
||||
? `Local version ${formatEtiVersion(game.local_version)} is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
|
||||
: undefined;
|
||||
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
||||
const outboundStatus = hasOutbound
|
||||
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}.`
|
||||
: undefined;
|
||||
const statusMessage = outboundStatus ?? game.status_message ?? newerStatus;
|
||||
const statusLevel = hasOutbound
|
||||
? 'info'
|
||||
: (game.status_level ?? (newerStatus ? 'warning' : undefined));
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
|
||||
<Icon.close />
|
||||
const tags = tagsFromGame(game);
|
||||
// Some game metadata contains a literal <br>; keep sanitization exact.
|
||||
const description = game.description.split("<br>").join("");
|
||||
const canRemoveDownload = game.downloaded &&
|
||||
!game.installed &&
|
||||
!isInProgress(game.install_status);
|
||||
const showStreamInstall = canStreamInstall(game, supportsStreamedInstall);
|
||||
const canViewFiles = game.downloaded ||
|
||||
game.installed ||
|
||||
game.install_status === InstallStatus.Downloading ||
|
||||
game.install_status === InstallStatus.Installing;
|
||||
const newerThanExpected = hasNewerLocalVersion(game);
|
||||
const newerStatus = newerThanExpected
|
||||
? `Local version ${
|
||||
formatEtiVersion(game.local_version)
|
||||
} is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
|
||||
: undefined;
|
||||
const hasOutbound = game.active_outbound_transfers !== undefined &&
|
||||
game.active_outbound_transfers > 0;
|
||||
const outboundStatus = hasOutbound
|
||||
? `Sharing to ${game.active_outbound_transfers} peer${
|
||||
game.active_outbound_transfers === 1 ? "" : "s"
|
||||
}.`
|
||||
: undefined;
|
||||
const transferStatus = gameTransferStatusPresentation(game.transfer_status);
|
||||
const statusMessage = transferStatus?.message ?? outboundStatus ??
|
||||
game.status_message ?? newerStatus;
|
||||
const statusLevel = transferStatus?.level ??
|
||||
(hasOutbound
|
||||
? "info"
|
||||
: (game.status_level ?? (newerStatus ? "warning" : undefined)));
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<button
|
||||
className="modal-close"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon.close />
|
||||
</button>
|
||||
<div className="modal-hero">
|
||||
<GameCover
|
||||
game={game}
|
||||
aspect="banner"
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
hideTitle
|
||||
/>
|
||||
<div className="modal-hero-fade" />
|
||||
<div className="modal-hero-text">
|
||||
{tags.length > 0 && (
|
||||
<div className="modal-tags">
|
||||
{tags.map((t) => <span key={t} className="modal-tag">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="modal-title">{game.name}</h2>
|
||||
</div>
|
||||
<div className="modal-state">
|
||||
<StateChip game={game} showNone />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="modal-meta">
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Size</div>
|
||||
<div className="meta-value">{formatBytes(game.size)}</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Players</div>
|
||||
<div className="meta-value">
|
||||
<Icon.users /> {formatPlayers(game.max_players)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Version</div>
|
||||
<div className="meta-value meta-mono">
|
||||
{formatEtiVersion(game.eti_game_version ?? game.local_version)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Status</div>
|
||||
<div className="meta-value">{gameStatusLabel(game)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && <p className="modal-desc">{description}</p>}
|
||||
|
||||
{statusMessage && (
|
||||
<p
|
||||
className={`modal-status${statusLevel ? ` is-${statusLevel}` : ""}`}
|
||||
>
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<ActionButton
|
||||
game={game}
|
||||
size="lg"
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
{showStreamInstall && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
title="Install without keeping archive files"
|
||||
onClick={() => onStreamInstall(game)}
|
||||
>
|
||||
<Icon.install />
|
||||
<span>Low disk install</span>
|
||||
</button>
|
||||
<div className="modal-hero">
|
||||
<GameCover game={game} aspect="banner" thumbnailUrl={thumbnailUrl} hideTitle />
|
||||
<div className="modal-hero-fade" />
|
||||
<div className="modal-hero-text">
|
||||
{tags.length > 0 && (
|
||||
<div className="modal-tags">
|
||||
{tags.map(t => <span key={t} className="modal-tag">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="modal-title">{game.name}</h2>
|
||||
</div>
|
||||
<div className="modal-state">
|
||||
<StateChip game={game} showNone />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="modal-meta">
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Size</div>
|
||||
<div className="meta-value">{formatBytes(game.size)}</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Players</div>
|
||||
<div className="meta-value">
|
||||
<Icon.users /> {formatPlayers(game.max_players)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Version</div>
|
||||
<div className="meta-value meta-mono">
|
||||
{formatEtiVersion(game.eti_game_version ?? game.local_version)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Status</div>
|
||||
<div className="meta-value">{gameStatusLabel(game)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className="modal-desc">{description}</p>
|
||||
)}
|
||||
|
||||
{statusMessage && (
|
||||
<p className={`modal-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<ActionButton
|
||||
game={game}
|
||||
size="lg"
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
{showStreamInstall && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
title="Install without keeping archive files"
|
||||
onClick={() => onStreamInstall(game)}
|
||||
>
|
||||
<Icon.install />
|
||||
<span>Low disk install</span>
|
||||
</button>
|
||||
)}
|
||||
{game.installed && game.can_host_server === true && (
|
||||
<button
|
||||
type="button"
|
||||
className="act-btn act-lg act-server"
|
||||
onClick={() => onStartServer(game)}
|
||||
>
|
||||
<Icon.server />
|
||||
<span className="act-label">Start Server</span>
|
||||
</button>
|
||||
)}
|
||||
{game.installed && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn ghost-danger"
|
||||
onClick={() => onUninstall(game)}
|
||||
>
|
||||
<Icon.trash />
|
||||
<span>Uninstall</span>
|
||||
</button>
|
||||
)}
|
||||
{canRemoveDownload && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn ghost-danger"
|
||||
onClick={() => onRemoveDownload(game)}
|
||||
>
|
||||
<Icon.trash />
|
||||
<span>Remove files</span>
|
||||
</button>
|
||||
)}
|
||||
{canViewFiles && (
|
||||
<>
|
||||
<div className="modal-actions-spacer" />
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => onViewFiles(game)}
|
||||
>
|
||||
<Icon.folder />
|
||||
<span>View Files</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
)}
|
||||
{game.installed && game.can_host_server === true && (
|
||||
<button
|
||||
type="button"
|
||||
className="act-btn act-lg act-server"
|
||||
onClick={() => onStartServer(game)}
|
||||
>
|
||||
<Icon.server />
|
||||
<span className="act-label">Start Server</span>
|
||||
</button>
|
||||
)}
|
||||
{game.installed && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn ghost-danger"
|
||||
onClick={() => onUninstall(game)}
|
||||
>
|
||||
<Icon.trash />
|
||||
<span>Uninstall</span>
|
||||
</button>
|
||||
)}
|
||||
{canRemoveDownload && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn ghost-danger"
|
||||
onClick={() => onRemoveDownload(game)}
|
||||
>
|
||||
<Icon.trash />
|
||||
<span>Remove files</span>
|
||||
</button>
|
||||
)}
|
||||
{canViewFiles && (
|
||||
<>
|
||||
<div className="modal-actions-spacer" />
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => onViewFiles(game)}
|
||||
>
|
||||
<Icon.folder />
|
||||
<span>View Files</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
LANGUAGE_OPTIONS,
|
||||
type UISettings,
|
||||
} from '../../hooks/useSettings';
|
||||
import {
|
||||
type LocalNetworkSharingSnapshot,
|
||||
displayedSharingTarget,
|
||||
} from '../../lib/localNetworkSharing';
|
||||
|
||||
interface Props {
|
||||
settings: UISettings;
|
||||
@@ -18,6 +22,11 @@ interface Props {
|
||||
hasGameDirectory: boolean;
|
||||
onPickDirectory: () => void;
|
||||
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||
localNetworkSharing: LocalNetworkSharingSnapshot;
|
||||
localNetworkSharingReady: boolean;
|
||||
localNetworkSharingBusy: boolean;
|
||||
localNetworkSharingError: string | null;
|
||||
onLocalNetworkSharingChange: (enabled: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -59,6 +68,28 @@ const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInpu
|
||||
</div>
|
||||
);
|
||||
|
||||
interface SettingsSwitchProps {
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const SettingsSwitch = ({ checked, disabled, onChange }: SettingsSwitchProps) => (
|
||||
<label className={`settings-switch ${checked ? 'is-checked' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label="Local network sharing"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={event => onChange(event.target.checked)}
|
||||
/>
|
||||
<span className="settings-switch-track" aria-hidden="true">
|
||||
<span className="settings-switch-thumb" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
interface GameFolderFieldProps {
|
||||
path: string;
|
||||
isValid: boolean;
|
||||
@@ -88,6 +119,11 @@ export const SettingsDialog = ({
|
||||
hasGameDirectory,
|
||||
onPickDirectory,
|
||||
onChange,
|
||||
localNetworkSharing,
|
||||
localNetworkSharingReady,
|
||||
localNetworkSharingBusy,
|
||||
localNetworkSharingError,
|
||||
onLocalNetworkSharingChange,
|
||||
onClose,
|
||||
}: Props) => (
|
||||
<Modal onClose={onClose} className="settings-modal">
|
||||
@@ -123,6 +159,25 @@ export const SettingsDialog = ({
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">Network</div>
|
||||
<Row
|
||||
label="Local network sharing"
|
||||
hint="Allow nearby Lanspread devices to browse and request games from this library."
|
||||
>
|
||||
<SettingsSwitch
|
||||
checked={displayedSharingTarget(localNetworkSharing)}
|
||||
disabled={!localNetworkSharingReady || localNetworkSharingBusy}
|
||||
onChange={onLocalNetworkSharingChange}
|
||||
/>
|
||||
</Row>
|
||||
{localNetworkSharingError && (
|
||||
<div className="settings-inline-error" role="status">
|
||||
{localNetworkSharingError}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">Appearance</div>
|
||||
<Row label="Accent color" hint="Used for primary actions and highlights">
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
import {
|
||||
CALL_TO_PLAY_CONNECTING_MESSAGE,
|
||||
callToPlayEvent,
|
||||
callToPlayPublishErrorMessage,
|
||||
extendDeadline,
|
||||
pruneCallToPlayEvents,
|
||||
reduceCallToPlayEvents,
|
||||
replaceCallToPlayView,
|
||||
} from '../lib/callToPlay';
|
||||
import { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types';
|
||||
import { CallToPlayAsyncScope } from '../lib/callToPlayOwnership';
|
||||
import { windowAsyncScope } from '../lib/asyncOwnership';
|
||||
import {
|
||||
CallToPlayAction,
|
||||
CallToPlayLocalIntent,
|
||||
CallToPlayReceipt,
|
||||
CallToPlayView,
|
||||
Nomination,
|
||||
} from '../lib/types';
|
||||
|
||||
export interface CallToPlayActions {
|
||||
createNomination: (
|
||||
@@ -36,129 +43,177 @@ export interface UseCallToPlay {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const mergeEvents = (
|
||||
previous: ReadonlyMap<string, CallToPlayEvent>,
|
||||
incoming: ReadonlyArray<CallToPlayEvent>,
|
||||
): Map<string, CallToPlayEvent> => {
|
||||
const next = new Map(previous);
|
||||
for (const event of incoming) next.set(event.id, event);
|
||||
return next;
|
||||
};
|
||||
type PublishOutcome =
|
||||
| { kind: 'connecting' }
|
||||
| { kind: 'settled'; accepted: boolean }
|
||||
| { kind: 'failed'; error: unknown };
|
||||
|
||||
export const useCallToPlay = (username: string): UseCallToPlay => {
|
||||
export const useCallToPlay = (
|
||||
username: string,
|
||||
localNetworkSharingEnabled = true,
|
||||
): UseCallToPlay => {
|
||||
const actor = useMemo(() => {
|
||||
const trimmed = username.trim();
|
||||
return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander';
|
||||
}, [username]);
|
||||
const [events, setEvents] = useState<ReadonlyMap<string, CallToPlayEvent>>(() => new Map());
|
||||
const [view, setView] = useState<CallToPlayView>({ events: [] });
|
||||
const [actorId, setActorId] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [transportReady, setTransportReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const asyncScopeRef = useRef<CallToPlayAsyncScope | null>(null);
|
||||
const actorRef = useRef(actor);
|
||||
actorRef.current = actor;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const current = Date.now();
|
||||
setNow(current);
|
||||
setEvents(previous => pruneCallToPlayEvents(previous, current));
|
||||
setNow(Date.now());
|
||||
}, 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let unlisten: UnlistenFn | undefined;
|
||||
let retry: number | undefined;
|
||||
const scope = new CallToPlayAsyncScope(
|
||||
(callback, delayMilliseconds) => {
|
||||
const timer = window.setTimeout(() => void callback(), delayMilliseconds);
|
||||
return () => window.clearTimeout(timer);
|
||||
},
|
||||
cleanupError => console.error('Failed to clean up Call to Play:', cleanupError),
|
||||
retryError => console.error('Call to Play retry failed:', retryError),
|
||||
);
|
||||
asyncScopeRef.current = scope;
|
||||
|
||||
const syncDisplayName = async (): Promise<void> => {
|
||||
try {
|
||||
await scope.applyMutationIfActive(
|
||||
() => invoke<boolean>('set_call_to_play_display_name', {
|
||||
displayName: actorRef.current,
|
||||
}),
|
||||
() => {},
|
||||
);
|
||||
} catch (err) {
|
||||
if (scope.isActive()) {
|
||||
console.error('set_call_to_play_display_name failed:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const requestSnapshot = async (): Promise<boolean> => {
|
||||
let ready = false;
|
||||
try {
|
||||
const peerId = await invoke<string | null>('request_call_to_play_events');
|
||||
if (cancelled) return false;
|
||||
const ready = peerId !== null;
|
||||
setActorId(peerId);
|
||||
setTransportReady(ready);
|
||||
if (ready) {
|
||||
setError(current =>
|
||||
current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current
|
||||
);
|
||||
}
|
||||
if (ready && retry !== undefined) {
|
||||
window.clearInterval(retry);
|
||||
retry = undefined;
|
||||
}
|
||||
return ready;
|
||||
const applied = await scope.applyIfActive(
|
||||
() => invoke<string | null>('request_call_to_play_view'),
|
||||
peerId => {
|
||||
ready = peerId !== null;
|
||||
setActorId(peerId);
|
||||
setTransportReady(ready);
|
||||
if (ready) {
|
||||
setError(current =>
|
||||
current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current
|
||||
);
|
||||
scope.stopRetry();
|
||||
void syncDisplayName();
|
||||
}
|
||||
},
|
||||
);
|
||||
return applied && ready;
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setTransportReady(false);
|
||||
console.error('request_call_to_play_events failed:', err);
|
||||
}
|
||||
if (!scope.isActive()) return false;
|
||||
setTransportReady(false);
|
||||
console.error('request_call_to_play_view failed:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async () => {
|
||||
try {
|
||||
unlisten = await listen<CallToPlayEvent[]>('call-to-play-events', event => {
|
||||
setEvents(previous => mergeEvents(previous, event.payload));
|
||||
});
|
||||
if (cancelled) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
const registered = await scope.registerListener(() =>
|
||||
listen<CallToPlayView>('call-to-play-view', scope.guard(event => {
|
||||
setView(previous => replaceCallToPlayView(previous, event.payload));
|
||||
}))
|
||||
);
|
||||
if (!registered) return;
|
||||
|
||||
const ready = await requestSnapshot();
|
||||
if (!cancelled && !ready) {
|
||||
retry = window.setInterval(() => void requestSnapshot(), 2_000);
|
||||
}
|
||||
if (scope.isActive() && !ready) scope.startRetry(requestSnapshot, 2_000);
|
||||
} catch (err) {
|
||||
if (!scope.isActive()) return;
|
||||
console.error('Failed to register Call to Play listener:', err);
|
||||
if (!cancelled) {
|
||||
setTransportReady(false);
|
||||
setError('Call to Play networking is unavailable.');
|
||||
}
|
||||
setTransportReady(false);
|
||||
setError('Call to Play networking is unavailable.');
|
||||
}
|
||||
};
|
||||
|
||||
void register();
|
||||
void scope.applyIfActive(register, () => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
if (retry !== undefined) window.clearInterval(retry);
|
||||
if (asyncScopeRef.current === scope) asyncScopeRef.current = null;
|
||||
windowAsyncScope.adopt(scope.dispose());
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const scope = asyncScopeRef.current;
|
||||
if (scope === null || !scope.isActive()) return;
|
||||
void scope.applyMutationIfActive(
|
||||
() => invoke<boolean>('set_call_to_play_display_name', { displayName: actor }),
|
||||
() => {},
|
||||
).catch(err => {
|
||||
if (scope.isActive()) console.error('set_call_to_play_display_name failed:', err);
|
||||
});
|
||||
}, [actor]);
|
||||
|
||||
const publish = useCallback(async (
|
||||
callId: string,
|
||||
callId: string | null,
|
||||
action: CallToPlayAction,
|
||||
): Promise<boolean> => {
|
||||
if (actorId === null) {
|
||||
setTransportReady(false);
|
||||
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
|
||||
return false;
|
||||
}
|
||||
const event = callToPlayEvent(callId, actorId, actor, action);
|
||||
try {
|
||||
const accepted = await invoke<boolean>('publish_call_to_play', { event });
|
||||
if (!accepted) {
|
||||
const scope = asyncScopeRef.current;
|
||||
if (scope === null || !scope.isActive() || !localNetworkSharingEnabled) return false;
|
||||
|
||||
const publishOutcome = scope.guardLatestAction((outcome: PublishOutcome) => {
|
||||
if (outcome.kind === 'connecting') {
|
||||
setTransportReady(false);
|
||||
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
|
||||
return false;
|
||||
} else if (outcome.kind === 'failed') {
|
||||
setError(callToPlayPublishErrorMessage(outcome.error));
|
||||
} else if (!outcome.accepted) {
|
||||
setTransportReady(false);
|
||||
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
|
||||
} else {
|
||||
setTransportReady(true);
|
||||
setError(null);
|
||||
}
|
||||
setTransportReady(true);
|
||||
setError(null);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('publish_call_to_play failed:', err);
|
||||
setError(callToPlayPublishErrorMessage(err));
|
||||
});
|
||||
if (actorId === null) {
|
||||
publishOutcome({ kind: 'connecting' });
|
||||
return false;
|
||||
}
|
||||
}, [actor, actorId]);
|
||||
const intent: CallToPlayLocalIntent = { call_id: callId, action };
|
||||
let acceptedResult = false;
|
||||
try {
|
||||
const completed = await scope.applyMutationIfActive(
|
||||
() => invoke<CallToPlayReceipt>('publish_call_to_play', {
|
||||
intent,
|
||||
displayName: actor,
|
||||
}),
|
||||
_receipt => {
|
||||
acceptedResult = true;
|
||||
publishOutcome({ kind: 'settled', accepted: true });
|
||||
},
|
||||
);
|
||||
return completed && acceptedResult;
|
||||
} catch (err) {
|
||||
if (!scope.isActive()) return false;
|
||||
console.error('publish_call_to_play failed:', err);
|
||||
publishOutcome({ kind: 'failed', error: err });
|
||||
return false;
|
||||
}
|
||||
}, [actor, actorId, localNetworkSharingEnabled]);
|
||||
|
||||
const actions = useMemo<CallToPlayActions>(() => ({
|
||||
createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => {
|
||||
const createdAt = Date.now();
|
||||
const callId = globalThis.crypto.randomUUID();
|
||||
const deadline = scheduledFor ?? createdAt + durationMinutes * 60_000;
|
||||
void publish(callId, {
|
||||
void publish(null, {
|
||||
Create: {
|
||||
game_id: gameId,
|
||||
max_players: Math.max(2, Math.min(64, Math.round(maxPlayers))),
|
||||
@@ -178,7 +233,6 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
||||
if (!trimmed) return;
|
||||
void publish(callId, {
|
||||
SendMessage: {
|
||||
message_id: globalThis.crypto.randomUUID(),
|
||||
text: trimmed,
|
||||
},
|
||||
});
|
||||
@@ -192,9 +246,15 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
||||
}), [publish]);
|
||||
|
||||
const nominations = useMemo(
|
||||
() => reduceCallToPlayEvents([...events.values()], now),
|
||||
[events, now],
|
||||
() => reduceCallToPlayEvents(view.events, now),
|
||||
[view, now],
|
||||
);
|
||||
|
||||
return { nominations, actions, actorId, transportReady, error };
|
||||
return {
|
||||
nominations,
|
||||
actions,
|
||||
actorId,
|
||||
transportReady: transportReady && localNetworkSharingEnabled,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,153 +1,219 @@
|
||||
import { useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ask } from '@tauri-apps/plugin-dialog';
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ask } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import { type UseGamesResult } from './useGames';
|
||||
import { type UISettings } from './useSettings';
|
||||
import { type UseGamesResult } from "./useGames";
|
||||
import { type UISettings } from "./useSettings";
|
||||
import { AsyncOwner, windowAsyncScope } from "../lib/asyncOwnership";
|
||||
|
||||
export interface GameActions {
|
||||
play: (id: string) => Promise<void>;
|
||||
startServer: (id: string) => Promise<void>;
|
||||
install: (id: string) => Promise<void>;
|
||||
streamInstall: (id: string) => Promise<void>;
|
||||
update: (id: string) => Promise<void>;
|
||||
uninstall: (id: string) => Promise<void>;
|
||||
removeDownload: (id: string) => Promise<void>;
|
||||
cancelDownload: (id: string) => Promise<void>;
|
||||
viewFiles: (id: string) => Promise<void>;
|
||||
play: (id: string) => Promise<void>;
|
||||
startServer: (id: string) => Promise<void>;
|
||||
install: (id: string) => Promise<void>;
|
||||
streamInstall: (id: string) => Promise<void>;
|
||||
update: (id: string) => Promise<void>;
|
||||
uninstall: (id: string) => Promise<void>;
|
||||
removeDownload: (id: string) => Promise<void>;
|
||||
cancelDownload: (id: string) => Promise<void>;
|
||||
viewFiles: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrappers over the backend `run_game` / `install_game` / `update_game`
|
||||
* / `uninstall_game` / `remove_downloaded_game` commands. Peer-backed downloads
|
||||
* are marked as "checking peers" until the backend emits an authoritative
|
||||
* operation snapshot; cancellation waits for the backend to clear that snapshot.
|
||||
* / `uninstall_game` / `remove_downloaded_game` commands. Operation state is
|
||||
* derived only from authoritative backend snapshots; cancellation waits for
|
||||
* the backend to clear that snapshot.
|
||||
*/
|
||||
export const useGameActions = (
|
||||
games: UseGamesResult,
|
||||
settings: Pick<UISettings, 'language' | 'username'>,
|
||||
games: UseGamesResult,
|
||||
settings: Pick<UISettings, "language" | "username">,
|
||||
): GameActions => {
|
||||
const play = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('run_game', {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('run_game failed:', err);
|
||||
}
|
||||
}, [settings.language, settings.username]);
|
||||
const ownerRef = useRef<AsyncOwner | null>(null);
|
||||
|
||||
const startServer = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('start_server', {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('start_server failed:', err);
|
||||
}
|
||||
}, [settings.language, settings.username]);
|
||||
|
||||
const install = useCallback(async (id: string) => {
|
||||
try {
|
||||
const success = await invoke<boolean>('install_game', {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
});
|
||||
if (!success) return;
|
||||
|
||||
const game = games.games.find(item => item.id === id);
|
||||
if (!game?.downloaded) {
|
||||
games.markChecking(id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('install_game failed:', err);
|
||||
}
|
||||
}, [games, settings.language, settings.username]);
|
||||
|
||||
const streamInstall = useCallback(async (id: string) => {
|
||||
try {
|
||||
const success = await invoke<boolean>('stream_install_game', { id });
|
||||
if (success) games.markChecking(id);
|
||||
} catch (err) {
|
||||
console.error('stream_install_game failed:', err);
|
||||
}
|
||||
}, [games]);
|
||||
|
||||
const update = useCallback(async (id: string) => {
|
||||
try {
|
||||
const game = games.games.find(item => item.id === id);
|
||||
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
|
||||
const confirmed = await ask(
|
||||
`Peers are currently downloading this game from you. Updating will abort their downloads. Do you want to proceed?`,
|
||||
{ title: 'Active Transfers in Progress', kind: 'warning' }
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
const success = await invoke<boolean>('update_game', {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
});
|
||||
if (success) games.markChecking(id);
|
||||
} catch (err) {
|
||||
console.error('update_game failed:', err);
|
||||
}
|
||||
}, [games, settings.language, settings.username]);
|
||||
|
||||
const uninstall = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('uninstall_game', { id });
|
||||
} catch (err) {
|
||||
console.error('uninstall_game failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeDownload = useCallback(async (id: string) => {
|
||||
try {
|
||||
const game = games.games.find(item => item.id === id);
|
||||
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
|
||||
const confirmed = await ask(
|
||||
`Peers are currently downloading this game from you. Removing game files will abort their downloads. Do you want to proceed?`,
|
||||
{ title: 'Active Transfers in Progress', kind: 'warning' }
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
await invoke('remove_downloaded_game', { id });
|
||||
} catch (err) {
|
||||
console.error('remove_downloaded_game failed:', err);
|
||||
}
|
||||
}, [games]);
|
||||
|
||||
const cancelDownload = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('cancel_download', { id });
|
||||
} catch (err) {
|
||||
console.error('cancel_download failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const viewFiles = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('open_game_files', { id });
|
||||
} catch (err) {
|
||||
console.error('open_game_files failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
play,
|
||||
startServer,
|
||||
install,
|
||||
streamInstall,
|
||||
update,
|
||||
uninstall,
|
||||
removeDownload,
|
||||
cancelDownload,
|
||||
viewFiles,
|
||||
useEffect(() => {
|
||||
const owner = new AsyncOwner((error) => {
|
||||
console.error("Failed to clean up a game action:", error);
|
||||
});
|
||||
ownerRef.current = owner;
|
||||
return () => {
|
||||
if (ownerRef.current === owner) ownerRef.current = null;
|
||||
windowAsyncScope.adopt(owner.dispose());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const applyOwned = useCallback(async <T>(
|
||||
operation: () => Promise<T>,
|
||||
apply: (value: T) => void = () => {},
|
||||
): Promise<boolean> => {
|
||||
const owner = ownerRef.current;
|
||||
if (owner === null || !owner.isActive()) return false;
|
||||
|
||||
try {
|
||||
return await owner.applyIfActive(operation, apply);
|
||||
} catch (error) {
|
||||
if (!owner.isActive()) return false;
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const play = useCallback(async (id: string) => {
|
||||
try {
|
||||
await applyOwned(
|
||||
() =>
|
||||
invoke("run_game", {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("run_game failed:", err);
|
||||
}
|
||||
}, [applyOwned, settings.language, settings.username]);
|
||||
|
||||
const startServer = useCallback(async (id: string) => {
|
||||
try {
|
||||
await applyOwned(
|
||||
() =>
|
||||
invoke("start_server", {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("start_server failed:", err);
|
||||
}
|
||||
}, [applyOwned, settings.language, settings.username]);
|
||||
|
||||
const install = useCallback(async (id: string) => {
|
||||
try {
|
||||
await applyOwned(
|
||||
() =>
|
||||
invoke<boolean>("install_game", {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("install_game failed:", err);
|
||||
}
|
||||
}, [applyOwned, settings.language, settings.username]);
|
||||
|
||||
const streamInstall = useCallback(async (id: string) => {
|
||||
try {
|
||||
await applyOwned(
|
||||
() =>
|
||||
invoke<boolean>("stream_install_game", {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("stream_install_game failed:", err);
|
||||
}
|
||||
}, [applyOwned, settings.language, settings.username]);
|
||||
|
||||
const update = useCallback(async (id: string) => {
|
||||
try {
|
||||
const game = games.games.find((item) => item.id === id);
|
||||
if (
|
||||
game && game.active_outbound_transfers &&
|
||||
game.active_outbound_transfers > 0
|
||||
) {
|
||||
let confirmed = false;
|
||||
if (
|
||||
!await applyOwned(
|
||||
() =>
|
||||
ask(
|
||||
`Peers are currently downloading this game from you. Updating will abort their downloads. Do you want to proceed?`,
|
||||
{ title: "Active Transfers in Progress", kind: "warning" },
|
||||
),
|
||||
(answer) => {
|
||||
confirmed = answer;
|
||||
},
|
||||
) || !confirmed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await applyOwned(
|
||||
() =>
|
||||
invoke<boolean>("update_game", {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("update_game failed:", err);
|
||||
}
|
||||
}, [applyOwned, games, settings.language, settings.username]);
|
||||
|
||||
const uninstall = useCallback(async (id: string) => {
|
||||
try {
|
||||
await applyOwned(() => invoke("uninstall_game", { id }));
|
||||
} catch (err) {
|
||||
console.error("uninstall_game failed:", err);
|
||||
}
|
||||
}, [applyOwned]);
|
||||
|
||||
const removeDownload = useCallback(async (id: string) => {
|
||||
try {
|
||||
const game = games.games.find((item) => item.id === id);
|
||||
if (
|
||||
game && game.active_outbound_transfers &&
|
||||
game.active_outbound_transfers > 0
|
||||
) {
|
||||
let confirmed = false;
|
||||
if (
|
||||
!await applyOwned(
|
||||
() =>
|
||||
ask(
|
||||
`Peers are currently downloading this game from you. Removing game files will abort their downloads. Do you want to proceed?`,
|
||||
{ title: "Active Transfers in Progress", kind: "warning" },
|
||||
),
|
||||
(answer) => {
|
||||
confirmed = answer;
|
||||
},
|
||||
) || !confirmed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await applyOwned(() => invoke("remove_downloaded_game", { id }));
|
||||
} catch (err) {
|
||||
console.error("remove_downloaded_game failed:", err);
|
||||
}
|
||||
}, [applyOwned, games]);
|
||||
|
||||
const cancelDownload = useCallback(async (id: string) => {
|
||||
try {
|
||||
await applyOwned(() => invoke("cancel_download", { id }));
|
||||
} catch (err) {
|
||||
console.error("cancel_download failed:", err);
|
||||
}
|
||||
}, [applyOwned]);
|
||||
|
||||
const viewFiles = useCallback(async (id: string) => {
|
||||
try {
|
||||
await applyOwned(() => invoke("open_game_files", { id }));
|
||||
} catch (err) {
|
||||
console.error("open_game_files failed:", err);
|
||||
}
|
||||
}, [applyOwned]);
|
||||
|
||||
return {
|
||||
play,
|
||||
startServer,
|
||||
install,
|
||||
streamInstall,
|
||||
update,
|
||||
uninstall,
|
||||
removeDownload,
|
||||
cancelDownload,
|
||||
viewFiles,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,92 +1,143 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { load } from '@tauri-apps/plugin-store';
|
||||
|
||||
import { acceptGameDirectory, hydrateGameDirectory } from '../lib/gameDirectory';
|
||||
import { windowAsyncScope } from '../lib/asyncOwnership';
|
||||
import {
|
||||
startAdmittedPersistenceEffect,
|
||||
windowPersistenceScope,
|
||||
} from '../lib/frontendPersistence';
|
||||
import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store';
|
||||
|
||||
const requestGameDirectory = (requestedPath: string): Promise<string> =>
|
||||
acceptGameDirectory(requestedPath, {
|
||||
updateBackend: path => invoke<unknown>('update_game_directory', { path }),
|
||||
persist: async acceptedPath => {
|
||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||
await store.set(GAME_DIR_KEY, acceptedPath);
|
||||
},
|
||||
reportPersistenceError: error => {
|
||||
console.error('Failed to persist accepted game directory:', error);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Owns the user's selected game directory. Hydrates from the persistent store
|
||||
* on mount, writes back on every change, and pushes the value to the Tauri
|
||||
* backend so it can scan/rescan.
|
||||
* Owns the backend-accepted game directory. Both restored and newly selected
|
||||
* paths enter frontend state only after the backend returns its canonical path.
|
||||
*/
|
||||
export const useGameDirectory = () => {
|
||||
const [gameDir, setGameDir] = useState('');
|
||||
const [gameDirExists, setGameDirExists] = useState(false);
|
||||
export const useGameDirectory = (backendPolicyReady = true) => {
|
||||
const [gameDir, setAcceptedGameDir] = useState('');
|
||||
const [ready, setReady] = useState(false);
|
||||
const acceptedGameDirRef = useRef('');
|
||||
const mountedRef = useRef(false);
|
||||
const acceptingRef = useRef(false);
|
||||
const selectionVersionRef = useRef(0);
|
||||
const updateQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const hydrationRef = useRef<Promise<void>>(Promise.resolve());
|
||||
|
||||
const shutdown = useCallback(async (): Promise<void> => {
|
||||
acceptingRef.current = false;
|
||||
await hydrationRef.current;
|
||||
await updateQueueRef.current;
|
||||
}, []);
|
||||
|
||||
const enqueueUpdate = useCallback((
|
||||
requestedPath: () => string,
|
||||
failureMessage: string,
|
||||
): Promise<void> => {
|
||||
if (!acceptingRef.current) return Promise.resolve();
|
||||
|
||||
const update = updateQueueRef.current.then(async () => {
|
||||
const path = requestedPath();
|
||||
if (!path.trim()) return;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const hydrate = async () => {
|
||||
try {
|
||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||
const saved = await store.get<string>(GAME_DIR_KEY);
|
||||
if (saved && !cancelled) setGameDir(saved);
|
||||
} catch (err) {
|
||||
console.error('Failed to load game directory:', err);
|
||||
const acceptedPath = await requestGameDirectory(path);
|
||||
acceptedGameDirRef.current = acceptedPath;
|
||||
if (mountedRef.current && acceptingRef.current) {
|
||||
setAcceptedGameDir(acceptedPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(failureMessage, error);
|
||||
}
|
||||
};
|
||||
void hydrate();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
updateQueueRef.current = update;
|
||||
windowAsyncScope.adopt(update);
|
||||
return update;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameDir.trim()) {
|
||||
setGameDirExists(false);
|
||||
if (!backendPolicyReady) {
|
||||
setReady(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const sync = async () => {
|
||||
try {
|
||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||
await store.set(GAME_DIR_KEY, gameDir);
|
||||
} catch (err) {
|
||||
console.error('Failed to persist game directory:', err);
|
||||
}
|
||||
setReady(false);
|
||||
return startAdmittedPersistenceEffect(
|
||||
windowPersistenceScope,
|
||||
shutdown,
|
||||
() => {
|
||||
let cancelled = false;
|
||||
mountedRef.current = true;
|
||||
acceptingRef.current = true;
|
||||
const initialSelectionVersion = selectionVersionRef.current;
|
||||
const hydration = hydrateGameDirectory({
|
||||
loadSavedPath: async () => {
|
||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||
return await store.get<string>(GAME_DIR_KEY);
|
||||
},
|
||||
acceptSavedPath: async saved => {
|
||||
if (
|
||||
!cancelled
|
||||
&& acceptingRef.current
|
||||
&& selectionVersionRef.current === initialSelectionVersion
|
||||
) {
|
||||
await enqueueUpdate(
|
||||
() => saved,
|
||||
'Failed to restore game directory:',
|
||||
);
|
||||
}
|
||||
},
|
||||
reportLoadError: error => {
|
||||
console.error('Failed to load game directory:', error);
|
||||
},
|
||||
}).finally(() => {
|
||||
if (!cancelled && mountedRef.current && acceptingRef.current) {
|
||||
setReady(true);
|
||||
}
|
||||
});
|
||||
hydrationRef.current = hydration;
|
||||
windowAsyncScope.adopt(hydration);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
acceptingRef.current = false;
|
||||
};
|
||||
},
|
||||
);
|
||||
}, [backendPolicyReady, enqueueUpdate, shutdown]);
|
||||
|
||||
let exists = false;
|
||||
try {
|
||||
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
|
||||
} catch (err) {
|
||||
console.error('Failed to validate game directory:', err);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setGameDirExists(exists);
|
||||
if (!exists) return;
|
||||
|
||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||
console.error('Failed to push game directory to backend:', err),
|
||||
);
|
||||
};
|
||||
void sync();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [gameDir]);
|
||||
|
||||
const hasGameDirectory = gameDir.trim() !== '' && gameDirExists;
|
||||
const setGameDir = useCallback((requestedPath: string) => {
|
||||
selectionVersionRef.current += 1;
|
||||
void enqueueUpdate(
|
||||
() => requestedPath,
|
||||
'Failed to update game directory:',
|
||||
);
|
||||
}, [enqueueUpdate]);
|
||||
|
||||
const rescan = useCallback(() => {
|
||||
if (!gameDir.trim()) {
|
||||
setGameDirExists(false);
|
||||
return;
|
||||
}
|
||||
const sync = async () => {
|
||||
let exists = false;
|
||||
try {
|
||||
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
|
||||
} catch (err) {
|
||||
console.error('Failed to validate game directory:', err);
|
||||
}
|
||||
setGameDirExists(exists);
|
||||
if (!exists) return;
|
||||
void enqueueUpdate(
|
||||
() => acceptedGameDirRef.current,
|
||||
'Failed to rescan game directory:',
|
||||
);
|
||||
}, [enqueueUpdate]);
|
||||
|
||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||
console.error('Failed to rescan game directory:', err),
|
||||
);
|
||||
};
|
||||
void sync();
|
||||
}, [gameDir]);
|
||||
|
||||
return { gameDir, gameDirExists, hasGameDirectory, setGameDir, rescan };
|
||||
return {
|
||||
gameDir,
|
||||
ready,
|
||||
hasGameDirectory: gameDir !== '',
|
||||
setGameDir,
|
||||
rescan,
|
||||
shutdown,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,188 +1,271 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import {
|
||||
DownloadProgressPayload,
|
||||
Game,
|
||||
GamesListPayload,
|
||||
InstallStatus,
|
||||
} from '../lib/types';
|
||||
DownloadProgressPayload,
|
||||
Game,
|
||||
GamesListPayload,
|
||||
GameTransferStatusSnapshot,
|
||||
ProtocolMismatch,
|
||||
} from "../lib/types";
|
||||
import {
|
||||
activeStatusById,
|
||||
isInProgress,
|
||||
mergeGameUpdate,
|
||||
normalizeGamesListPayload,
|
||||
} from '../lib/gameState';
|
||||
activeStatusById,
|
||||
applyDownloadProgress,
|
||||
applyGameTransferStatusSnapshot,
|
||||
gameTransferStatusFor,
|
||||
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
|
||||
mergeGameUpdate,
|
||||
newestGameTransferStatusSnapshot,
|
||||
} from "../lib/gameState";
|
||||
import {
|
||||
AsyncOwner,
|
||||
type AsyncRegistration,
|
||||
registerSequentially,
|
||||
windowAsyncScope,
|
||||
} from "../lib/asyncOwnership";
|
||||
import {
|
||||
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
|
||||
newestProtocolMismatchSnapshot,
|
||||
type ProtocolMismatchSnapshot,
|
||||
} from "../lib/protocolMismatch";
|
||||
|
||||
interface PendingPatch {
|
||||
install_status?: InstallStatus;
|
||||
clearStatus?: boolean;
|
||||
}
|
||||
|
||||
const applyPatch = (game: Game, patch: PendingPatch): Game => {
|
||||
let next: Game = { ...game };
|
||||
if (patch.install_status !== undefined) next.install_status = patch.install_status;
|
||||
if (patch.clearStatus) {
|
||||
next.status_message = undefined;
|
||||
next.status_level = undefined;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Owns the games list and derives card status from backend snapshots. Returns
|
||||
* a fire-and-forget `markChecking` helper so action calls can immediately show
|
||||
* a "Checking peers…" state until the next backend snapshot arrives.
|
||||
*/
|
||||
/** Owns the games list and derives card status from backend snapshots. */
|
||||
export interface UseGamesResult {
|
||||
games: Game[];
|
||||
setGames: React.Dispatch<React.SetStateAction<Game[]>>;
|
||||
totalPeerCount: number;
|
||||
requestGames: () => Promise<void>;
|
||||
markChecking: (id: string) => void;
|
||||
games: Game[];
|
||||
setGames: React.Dispatch<React.SetStateAction<Game[]>>;
|
||||
totalPeerCount: number;
|
||||
protocolMismatch: ProtocolMismatch | null;
|
||||
requestGames: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
const [totalPeerCount, setTotalPeerCount] = useState(0);
|
||||
const rescanRef = useRef(rescanGameDir);
|
||||
rescanRef.current = rescanGameDir;
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
const [totalPeerCount, setTotalPeerCount] = useState(0);
|
||||
const [protocolMismatchSnapshot, setProtocolMismatchSnapshot] = useState(
|
||||
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
|
||||
);
|
||||
const transferStatusRef = useRef<GameTransferStatusSnapshot>(
|
||||
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
|
||||
);
|
||||
const rescanRef = useRef(rescanGameDir);
|
||||
rescanRef.current = rescanGameDir;
|
||||
|
||||
const markChecking = useCallback((id: string) => {
|
||||
setGames(prev => prev.map(item =>
|
||||
item.id === id && !isInProgress(item.install_status)
|
||||
? applyPatch(item, {
|
||||
install_status: InstallStatus.CheckingPeers,
|
||||
clearStatus: true,
|
||||
})
|
||||
: item
|
||||
));
|
||||
}, []);
|
||||
const requestGames = useCallback(async () => {
|
||||
try {
|
||||
await invoke("request_games");
|
||||
} catch (err) {
|
||||
console.error("request_games failed:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestGames = useCallback(async () => {
|
||||
try {
|
||||
await invoke('request_games');
|
||||
} catch (err) {
|
||||
console.error('request_games failed:', err);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const owner = new AsyncOwner((error) => {
|
||||
console.error("Failed to unregister game listener:", error);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unlisteners: UnlistenFn[] = [];
|
||||
let cancelled = false;
|
||||
|
||||
const handleErrorEvent = (
|
||||
id: string,
|
||||
message: string,
|
||||
{ triggerRescan = false }: { triggerRescan?: boolean } = {},
|
||||
) => {
|
||||
setGames(prev => prev.map(item => item.id === id
|
||||
? {
|
||||
...item,
|
||||
install_status: item.installed
|
||||
? InstallStatus.Installed
|
||||
: InstallStatus.NotInstalled,
|
||||
status_message: message,
|
||||
status_level: 'error',
|
||||
download_progress: undefined,
|
||||
}
|
||||
: item));
|
||||
if (triggerRescan) rescanRef.current();
|
||||
};
|
||||
|
||||
const register = async () => {
|
||||
try {
|
||||
unlisteners.push(await listen('games-list-updated', (event) => {
|
||||
const payload = normalizeGamesListPayload(
|
||||
event.payload as GamesListPayload | Game[],
|
||||
);
|
||||
const activeStatuses = activeStatusById(payload.active_operations);
|
||||
setGames(prev => {
|
||||
const previousById = new Map(prev.map(item => [item.id, item]));
|
||||
return payload.games.map(game => mergeGameUpdate(
|
||||
game,
|
||||
previousById.get(game.id),
|
||||
activeStatuses.get(game.id),
|
||||
));
|
||||
});
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-download-failed', (e) => {
|
||||
handleErrorEvent(e.payload as string, 'Download failed. Please try again.', {
|
||||
triggerRescan: true,
|
||||
});
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-download-peers-gone', (e) => {
|
||||
handleErrorEvent(e.payload as string, 'Failed: all peers gone.', {
|
||||
triggerRescan: true,
|
||||
});
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-download-progress', (e) => {
|
||||
const { id, ...download_progress } = e.payload as DownloadProgressPayload;
|
||||
setGames(prev => prev.map(item => item.id === id
|
||||
? {
|
||||
...item,
|
||||
download_progress,
|
||||
}
|
||||
: item));
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-no-peers', (e) => {
|
||||
handleErrorEvent(e.payload as string, 'No peers currently have this game.');
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-install-finished', () => {
|
||||
rescanRef.current();
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-install-failed', (e) => {
|
||||
handleErrorEvent(e.payload as string, 'Install failed. Please try again.');
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-uninstall-failed', (e) => {
|
||||
handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.');
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-remove-download-finished', () => {
|
||||
rescanRef.current();
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('game-remove-download-failed', (e) => {
|
||||
handleErrorEvent(e.payload as string, 'Remove failed. Please try again.', {
|
||||
triggerRescan: true,
|
||||
});
|
||||
}));
|
||||
|
||||
unlisteners.push(await listen('peer-count-updated', (e) => {
|
||||
setTotalPeerCount(e.payload as number);
|
||||
}));
|
||||
|
||||
if (!cancelled) {
|
||||
await invoke('request_games').catch(err =>
|
||||
console.error('request_games failed:', err),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to register game listeners:', err);
|
||||
const handleErrorEvent = (
|
||||
id: string,
|
||||
message: string,
|
||||
{ triggerRescan = false }: { triggerRescan?: boolean } = {},
|
||||
) => {
|
||||
setGames((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
status_message: message,
|
||||
status_level: "error",
|
||||
}
|
||||
};
|
||||
|
||||
void register();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisteners.forEach(fn => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
games,
|
||||
setGames,
|
||||
totalPeerCount,
|
||||
requestGames,
|
||||
markChecking,
|
||||
: item
|
||||
)
|
||||
);
|
||||
if (triggerRescan) rescanRef.current();
|
||||
};
|
||||
|
||||
const applyProtocolMismatchSnapshot = (
|
||||
candidate: ProtocolMismatchSnapshot,
|
||||
) => {
|
||||
setProtocolMismatchSnapshot((current) =>
|
||||
newestProtocolMismatchSnapshot(current, candidate)
|
||||
);
|
||||
};
|
||||
const mismatchRegistration: AsyncRegistration = () =>
|
||||
listen<ProtocolMismatchSnapshot>(
|
||||
"protocol-mismatch-updated",
|
||||
owner.guard((event) => applyProtocolMismatchSnapshot(event.payload)),
|
||||
);
|
||||
|
||||
const applyTransferStatusSnapshot = (
|
||||
candidate: GameTransferStatusSnapshot,
|
||||
) => {
|
||||
const current = transferStatusRef.current;
|
||||
const newest = newestGameTransferStatusSnapshot(current, candidate);
|
||||
if (newest === current) return;
|
||||
transferStatusRef.current = newest;
|
||||
setGames((previous) => applyGameTransferStatusSnapshot(previous, newest));
|
||||
};
|
||||
|
||||
const transferStatusRegistration: AsyncRegistration = () =>
|
||||
listen<GameTransferStatusSnapshot>(
|
||||
"game-transfer-status-updated",
|
||||
owner.guard((event) => applyTransferStatusSnapshot(event.payload)),
|
||||
);
|
||||
|
||||
const registrations: AsyncRegistration[] = [
|
||||
() =>
|
||||
listen<GamesListPayload>(
|
||||
"games-list-updated",
|
||||
owner.guard((event) => {
|
||||
const payload = event.payload;
|
||||
const transferStatus = newestGameTransferStatusSnapshot(
|
||||
transferStatusRef.current,
|
||||
payload.transfer_status,
|
||||
);
|
||||
transferStatusRef.current = transferStatus;
|
||||
const activeStatuses = activeStatusById(payload.active_operations);
|
||||
setGames((prev) => {
|
||||
const previousById = new Map(prev.map((item) => [item.id, item]));
|
||||
return applyGameTransferStatusSnapshot(
|
||||
payload.games.map((game) =>
|
||||
mergeGameUpdate(
|
||||
game,
|
||||
previousById.get(game.id),
|
||||
activeStatuses.get(game.id),
|
||||
gameTransferStatusFor(transferStatus, game.id),
|
||||
)
|
||||
),
|
||||
transferStatus,
|
||||
);
|
||||
});
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
listen<string>(
|
||||
"game-download-failed",
|
||||
owner.guard((event) => {
|
||||
handleErrorEvent(
|
||||
event.payload,
|
||||
"Download failed. Please try again.",
|
||||
{
|
||||
triggerRescan: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
listen<DownloadProgressPayload>(
|
||||
"game-download-progress",
|
||||
owner.guard((event) => {
|
||||
const transferStatus = transferStatusRef.current;
|
||||
setGames((prev) =>
|
||||
applyDownloadProgress(prev, transferStatus, event.payload)
|
||||
);
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
listen(
|
||||
"game-install-finished",
|
||||
owner.guard(() => {
|
||||
rescanRef.current();
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
listen<string>(
|
||||
"game-install-failed",
|
||||
owner.guard((event) => {
|
||||
handleErrorEvent(
|
||||
event.payload,
|
||||
"Install failed. Please try again.",
|
||||
);
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
listen<string>(
|
||||
"game-uninstall-failed",
|
||||
owner.guard((event) => {
|
||||
handleErrorEvent(
|
||||
event.payload,
|
||||
"Uninstall failed. Please try again.",
|
||||
);
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
listen(
|
||||
"game-remove-download-finished",
|
||||
owner.guard(() => {
|
||||
rescanRef.current();
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
listen<string>(
|
||||
"game-remove-download-failed",
|
||||
owner.guard((event) => {
|
||||
handleErrorEvent(
|
||||
event.payload,
|
||||
"Remove failed. Please try again.",
|
||||
{
|
||||
triggerRescan: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
listen<number>(
|
||||
"peer-count-updated",
|
||||
owner.guard((event) => {
|
||||
setTotalPeerCount(event.payload);
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
const register = async () => {
|
||||
try {
|
||||
if (
|
||||
!await registerSequentially(owner, [
|
||||
mismatchRegistration,
|
||||
transferStatusRegistration,
|
||||
...registrations,
|
||||
])
|
||||
) return;
|
||||
} catch (err) {
|
||||
console.error("Failed to register game listeners:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await owner.applyIfActive(
|
||||
() => invoke<ProtocolMismatchSnapshot>("get_protocol_mismatch"),
|
||||
applyProtocolMismatchSnapshot,
|
||||
);
|
||||
} catch (err) {
|
||||
if (owner.isActive()) {
|
||||
console.error("get_protocol_mismatch failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await owner.applyIfActive(
|
||||
() => invoke("request_games"),
|
||||
() => {},
|
||||
);
|
||||
} catch (err) {
|
||||
if (owner.isActive()) console.error("request_games failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
void register();
|
||||
|
||||
return () => {
|
||||
windowAsyncScope.adopt(owner.dispose());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
games,
|
||||
setGames,
|
||||
totalPeerCount,
|
||||
protocolMismatch: protocolMismatchSnapshot.mismatch,
|
||||
requestGames,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
import { AsyncOwner, windowAsyncScope } from '../lib/asyncOwnership';
|
||||
import {
|
||||
INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT,
|
||||
type IdentityDiagnosticSnapshot,
|
||||
newestIdentityDiagnosticSnapshot,
|
||||
} from '../lib/identityDiagnostic';
|
||||
|
||||
export interface UseIdentityDiagnostic {
|
||||
snapshot: IdentityDiagnosticSnapshot;
|
||||
ready: boolean;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
export const useIdentityDiagnostic = (): UseIdentityDiagnostic => {
|
||||
const [snapshot, setSnapshot] = useState(INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [unavailable, setUnavailable] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const owner = new AsyncOwner(error => {
|
||||
console.error('Failed to clean up identity diagnostic state:', error);
|
||||
});
|
||||
const applySnapshot = (candidate: IdentityDiagnosticSnapshot) => {
|
||||
setSnapshot(current => newestIdentityDiagnosticSnapshot(current, candidate));
|
||||
setUnavailable(false);
|
||||
};
|
||||
|
||||
const register = async () => {
|
||||
try {
|
||||
if (!await owner.register(() =>
|
||||
listen<IdentityDiagnosticSnapshot>(
|
||||
'identity-diagnostic-updated',
|
||||
owner.guard(event => applySnapshot(event.payload)),
|
||||
)
|
||||
)) return;
|
||||
await owner.applyIfActive(
|
||||
() => invoke<IdentityDiagnosticSnapshot>('get_identity_diagnostic'),
|
||||
applySnapshot,
|
||||
);
|
||||
} catch (error) {
|
||||
if (owner.isActive()) {
|
||||
console.error('Failed to initialize identity diagnostic state:', error);
|
||||
}
|
||||
} finally {
|
||||
if (owner.isActive()) setReady(true);
|
||||
}
|
||||
};
|
||||
windowAsyncScope.adopt(register());
|
||||
|
||||
return () => {
|
||||
windowAsyncScope.adopt(owner.dispose());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { snapshot, ready, unavailable };
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
import { windowAsyncScope } from '../lib/asyncOwnership';
|
||||
import {
|
||||
INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
|
||||
LocalNetworkSharingAsyncScope,
|
||||
type LocalNetworkSharingSnapshot,
|
||||
newestLocalNetworkSharingSnapshot,
|
||||
} from '../lib/localNetworkSharing';
|
||||
|
||||
const CHANGE_FAILED = 'Local network sharing could not be changed.';
|
||||
|
||||
interface MutationResult {
|
||||
snapshot: LocalNetworkSharingSnapshot | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface UseLocalNetworkSharing {
|
||||
snapshot: LocalNetworkSharingSnapshot;
|
||||
ready: boolean;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const useLocalNetworkSharing = (): UseLocalNetworkSharing => {
|
||||
const [snapshot, setSnapshot] = useState(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [pendingInvokes, setPendingInvokes] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scopeRef = useRef<LocalNetworkSharingAsyncScope | null>(null);
|
||||
|
||||
const applySnapshot = useCallback((candidate: LocalNetworkSharingSnapshot) => {
|
||||
setSnapshot(current => newestLocalNetworkSharingSnapshot(current, candidate));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const scope = new LocalNetworkSharingAsyncScope(cleanupError => {
|
||||
console.error('Failed to clean up Local network sharing state:', cleanupError);
|
||||
});
|
||||
scopeRef.current = scope;
|
||||
|
||||
const register = async () => {
|
||||
try {
|
||||
if (!await scope.registerListener(() =>
|
||||
listen<LocalNetworkSharingSnapshot>(
|
||||
'local-network-sharing-updated',
|
||||
scope.guard(event => applySnapshot(event.payload)),
|
||||
)
|
||||
)) return;
|
||||
|
||||
await scope.applyIfActive(
|
||||
() => invoke<LocalNetworkSharingSnapshot>('get_local_network_sharing'),
|
||||
applySnapshot,
|
||||
);
|
||||
} catch (registrationError) {
|
||||
if (scope.isActive()) {
|
||||
console.error('Failed to initialize Local network sharing state:', registrationError);
|
||||
}
|
||||
} finally {
|
||||
if (scope.isActive()) setReady(true);
|
||||
}
|
||||
};
|
||||
const registration = register();
|
||||
windowAsyncScope.adopt(registration);
|
||||
|
||||
return () => {
|
||||
if (scopeRef.current === scope) scopeRef.current = null;
|
||||
windowAsyncScope.adopt(scope.dispose());
|
||||
};
|
||||
}, [applySnapshot]);
|
||||
|
||||
const setEnabled = useCallback((enabled: boolean) => {
|
||||
const scope = scopeRef.current;
|
||||
if (scope === null || !scope.isActive()) return;
|
||||
|
||||
setPendingInvokes(count => count + 1);
|
||||
const mutation = scope.applyMutationIfActive<MutationResult>(async () => {
|
||||
try {
|
||||
return {
|
||||
snapshot: await invoke<LocalNetworkSharingSnapshot>(
|
||||
'set_local_network_sharing',
|
||||
{ enabled },
|
||||
),
|
||||
error: null,
|
||||
};
|
||||
} catch (mutationError) {
|
||||
console.error('Failed to change Local network sharing:', mutationError);
|
||||
try {
|
||||
return {
|
||||
snapshot: await invoke<LocalNetworkSharingSnapshot>(
|
||||
'get_local_network_sharing',
|
||||
),
|
||||
error: CHANGE_FAILED,
|
||||
};
|
||||
} catch (queryError) {
|
||||
console.error(
|
||||
'Failed to resynchronize Local network sharing:',
|
||||
queryError,
|
||||
);
|
||||
return { snapshot: null, error: CHANGE_FAILED };
|
||||
}
|
||||
}
|
||||
}, result => {
|
||||
if (result.snapshot !== null) applySnapshot(result.snapshot);
|
||||
setError(result.error);
|
||||
});
|
||||
const settled = mutation.finally(scope.guard(() => {
|
||||
setPendingInvokes(count => Math.max(0, count - 1));
|
||||
}));
|
||||
windowAsyncScope.adopt(settled);
|
||||
}, [applySnapshot]);
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
ready,
|
||||
busy: pendingInvokes > 0 || snapshot.pendingTarget !== null,
|
||||
error,
|
||||
setEnabled,
|
||||
};
|
||||
};
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { load } from '@tauri-apps/plugin-store';
|
||||
|
||||
import { type GameFilter, type GameSort, type LauncherLanguage } from '../lib/types';
|
||||
import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store';
|
||||
import {
|
||||
createSerializedAsyncWriter,
|
||||
mergeHydratedState,
|
||||
windowAsyncScope,
|
||||
} from '../lib/asyncOwnership';
|
||||
import {
|
||||
startAdmittedPersistenceEffect,
|
||||
windowPersistenceScope,
|
||||
} from '../lib/frontendPersistence';
|
||||
|
||||
export type Density = 'compact' | 'normal' | 'large';
|
||||
export type CoverAspect = 'box' | 'square' | 'banner';
|
||||
@@ -102,8 +111,21 @@ export interface UseSettings {
|
||||
settings: UISettings;
|
||||
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||
ready: boolean;
|
||||
shutdown: () => Promise<void>;
|
||||
}
|
||||
|
||||
const settingsWriter = createSerializedAsyncWriter<Partial<UISettings>>(
|
||||
async patch => {
|
||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||
const saved = await store.get<StoredUISettings>(UI_SETTINGS_KEY);
|
||||
await store.set(UI_SETTINGS_KEY, {
|
||||
...sanitize(saved ?? undefined),
|
||||
...patch,
|
||||
});
|
||||
},
|
||||
error => console.error('Failed to persist UI settings:', error),
|
||||
);
|
||||
|
||||
/**
|
||||
* Loads UI preferences from the Tauri persistent store once on mount and
|
||||
* writes every change back through it. Components only see a synchronous
|
||||
@@ -112,44 +134,69 @@ export interface UseSettings {
|
||||
export const useSettings = (): UseSettings => {
|
||||
const [settings, setSettings] = useState<UISettings>(DEFAULT_SETTINGS);
|
||||
const [ready, setReady] = useState(false);
|
||||
const settingsRef = useRef(DEFAULT_SETTINGS);
|
||||
const hydratedRef = useRef(false);
|
||||
const pendingEditsRef = useRef<Partial<UISettings>>({});
|
||||
const acceptingRef = useRef(false);
|
||||
const hydrationRef = useRef<Promise<void>>(Promise.resolve());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const init = async () => {
|
||||
try {
|
||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||
const saved = await store.get<StoredUISettings>(UI_SETTINGS_KEY);
|
||||
if (!cancelled) {
|
||||
setSettings(sanitize(saved ?? undefined));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load UI settings:', err);
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
};
|
||||
void init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
const shutdown = useCallback(async (): Promise<void> => {
|
||||
acceptingRef.current = false;
|
||||
await hydrationRef.current;
|
||||
await settingsWriter.closeAndWait();
|
||||
}, []);
|
||||
|
||||
useEffect(() => startAdmittedPersistenceEffect(
|
||||
windowPersistenceScope,
|
||||
shutdown,
|
||||
() => {
|
||||
let cancelled = false;
|
||||
acceptingRef.current = true;
|
||||
const init = async () => {
|
||||
try {
|
||||
await settingsWriter.waitForIdle();
|
||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||
const saved = await store.get<StoredUISettings>(UI_SETTINGS_KEY);
|
||||
if (!cancelled && acceptingRef.current) {
|
||||
const restored = mergeHydratedState(
|
||||
sanitize(saved ?? undefined),
|
||||
pendingEditsRef.current,
|
||||
);
|
||||
pendingEditsRef.current = {};
|
||||
hydratedRef.current = true;
|
||||
settingsRef.current = restored;
|
||||
setSettings(restored);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load UI settings:', err);
|
||||
} finally {
|
||||
if (!cancelled && acceptingRef.current) setReady(true);
|
||||
}
|
||||
};
|
||||
const hydration = init();
|
||||
hydrationRef.current = hydration;
|
||||
windowAsyncScope.adopt(hydration);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
acceptingRef.current = false;
|
||||
};
|
||||
},
|
||||
), [shutdown]);
|
||||
|
||||
const set = useCallback(<K extends keyof UISettings>(key: K, value: UISettings[K]) => {
|
||||
setSettings(prev => {
|
||||
const next = { ...prev, [key]: value };
|
||||
void persist(next);
|
||||
return next;
|
||||
});
|
||||
if (!acceptingRef.current) return;
|
||||
|
||||
const next = { ...settingsRef.current, [key]: value };
|
||||
if (!hydratedRef.current) {
|
||||
pendingEditsRef.current = {
|
||||
...pendingEditsRef.current,
|
||||
[key]: value,
|
||||
};
|
||||
}
|
||||
settingsRef.current = next;
|
||||
setSettings(next);
|
||||
windowAsyncScope.adopt(settingsWriter.enqueue({ [key]: value }));
|
||||
}, []);
|
||||
|
||||
return { settings, set, ready };
|
||||
};
|
||||
|
||||
const persist = async (settings: UISettings): Promise<void> => {
|
||||
try {
|
||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||
await store.set(UI_SETTINGS_KEY, settings);
|
||||
} catch (err) {
|
||||
console.error('Failed to persist UI settings:', err);
|
||||
}
|
||||
return { settings, set, ready, shutdown };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import { AsyncOwner, windowAsyncScope } from '../lib/asyncOwnership';
|
||||
|
||||
interface CapabilityResult {
|
||||
gameId: string;
|
||||
supported: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily resolves catalog-owned Stream Install support for the game whose
|
||||
* detail modal is currently open. Replacing or closing the selection disposes
|
||||
* and joins that generation, so a late result cannot affect another game.
|
||||
*/
|
||||
export const useStreamInstallCapability = (gameId: string | null): boolean => {
|
||||
const [result, setResult] = useState<CapabilityResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameId === null) return;
|
||||
|
||||
const owner = new AsyncOwner(error => {
|
||||
console.error('Failed to clean up Stream Install capability lookup:', error);
|
||||
});
|
||||
const lookup = owner.applyIfActive(
|
||||
async () => {
|
||||
try {
|
||||
return await invoke<boolean>('supports_streamed_install', { id: gameId });
|
||||
} catch (error) {
|
||||
console.error(`Failed to resolve Stream Install capability for ${gameId}:`, error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
supported => setResult({ gameId, supported }),
|
||||
);
|
||||
windowAsyncScope.adopt(lookup);
|
||||
|
||||
return () => {
|
||||
windowAsyncScope.adopt(owner.dispose());
|
||||
};
|
||||
}, [gameId]);
|
||||
|
||||
return result?.gameId === gameId && result.supported;
|
||||
};
|
||||
@@ -1,28 +1,41 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import {
|
||||
createThumbnailRequestKey,
|
||||
startOwnedThumbnailGeneration,
|
||||
ThumbnailRequestGeneration,
|
||||
thumbnailIdsFromRequestKey,
|
||||
} from '../lib/thumbnailRequests';
|
||||
import { windowAsyncScope } from '../lib/asyncOwnership';
|
||||
|
||||
/**
|
||||
* Lazy, per-id cache for cover thumbnails. Returns `null` until the value is
|
||||
* known; returns the empty string when the backend has nothing for the id, so
|
||||
* callers can fall back to the placeholder cover art.
|
||||
* Per-id cache for cover thumbnails belonging to the supplied game ids.
|
||||
* Returns `null` until the value is known; returns the empty string when the
|
||||
* backend has nothing for the id, so callers can use the placeholder cover.
|
||||
*/
|
||||
export const useThumbnails = () => {
|
||||
export const useThumbnails = (ids: readonly string[]) => {
|
||||
const [thumbnails, setThumbnails] = useState<Map<string, string>>(new Map());
|
||||
const pending = useRef<Set<string>>(new Set());
|
||||
const thumbnailsRef = useRef(thumbnails);
|
||||
const requestKey = createThumbnailRequestKey(ids);
|
||||
|
||||
useEffect(() => {
|
||||
const generation = new ThumbnailRequestGeneration(
|
||||
id => invoke<string>('get_game_thumbnail', { gameId: id }),
|
||||
(id, url) => {
|
||||
const next = new Map(thumbnailsRef.current).set(id, url);
|
||||
thumbnailsRef.current = next;
|
||||
setThumbnails(next);
|
||||
},
|
||||
);
|
||||
const missingIds = thumbnailIdsFromRequestKey(requestKey)
|
||||
.filter(id => !thumbnailsRef.current.has(id));
|
||||
|
||||
return startOwnedThumbnailGeneration(windowAsyncScope, generation, missingIds);
|
||||
}, [requestKey]);
|
||||
|
||||
const get = useCallback((id: string): string | null => {
|
||||
if (thumbnails.has(id)) return thumbnails.get(id) ?? '';
|
||||
if (pending.current.has(id)) return null;
|
||||
pending.current.add(id);
|
||||
invoke<string>('get_game_thumbnail', { gameId: id })
|
||||
.then(url => {
|
||||
pending.current.delete(id);
|
||||
setThumbnails(prev => new Map(prev).set(id, url));
|
||||
})
|
||||
.catch(() => {
|
||||
pending.current.delete(id);
|
||||
setThumbnails(prev => new Map(prev).set(id, ''));
|
||||
});
|
||||
return null;
|
||||
}, [thumbnails]);
|
||||
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
export type AsyncCleanup = () => void | Promise<void>;
|
||||
export type AsyncRegistration = () => Promise<AsyncCleanup>;
|
||||
|
||||
/**
|
||||
* Observes promises whose lexical React cleanup cannot await. The Tauri event
|
||||
* API removes its JavaScript callback synchronously; this scope owns the
|
||||
* remaining backend acknowledgement while the webview realm is alive.
|
||||
*/
|
||||
export class AsyncAdoptionScope {
|
||||
private readonly pending = new Set<Promise<unknown>>();
|
||||
private readonly disposers = new Set<() => Promise<void>>();
|
||||
private acceptingDisposers = true;
|
||||
|
||||
public constructor(
|
||||
private readonly reportFailure: (error: unknown) => void = () => {},
|
||||
) {}
|
||||
|
||||
public adopt<T>(promise: Promise<T>): void {
|
||||
this.pending.add(promise);
|
||||
void promise.then(
|
||||
() => this.pending.delete(promise),
|
||||
(error) => {
|
||||
this.pending.delete(promise);
|
||||
try {
|
||||
this.reportFailure(error);
|
||||
} catch {
|
||||
// Reporting must not create a second unhandled rejection.
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public registerDisposer(
|
||||
dispose: () => Promise<void>,
|
||||
): (() => void) | undefined {
|
||||
if (!this.acceptingDisposers) return undefined;
|
||||
|
||||
this.disposers.add(dispose);
|
||||
return () => this.disposers.delete(dispose);
|
||||
}
|
||||
|
||||
/** Invalidates and drains all mounted scopes before native webview destruction. */
|
||||
public async disposeOwned(): Promise<void> {
|
||||
this.acceptingDisposers = false;
|
||||
const disposers = [...this.disposers];
|
||||
this.disposers.clear();
|
||||
const pending: Promise<void>[] = [];
|
||||
for (const dispose of disposers) {
|
||||
try {
|
||||
const disposal = dispose();
|
||||
pending.push(disposal);
|
||||
this.adopt(disposal);
|
||||
} catch (error) {
|
||||
try {
|
||||
this.reportFailure(error);
|
||||
} catch {
|
||||
// Reporting must not interrupt the other disposers.
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(pending);
|
||||
await this.drain();
|
||||
}
|
||||
|
||||
public async drain(): Promise<void> {
|
||||
while (this.pending.size > 0) {
|
||||
await Promise.allSettled([...this.pending]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const windowAsyncScope = new AsyncAdoptionScope((error) => {
|
||||
console.error("Detached frontend cleanup failed:", error);
|
||||
});
|
||||
|
||||
export const mergeHydratedState = <T extends object>(
|
||||
restored: T,
|
||||
pendingEdits: Partial<T>,
|
||||
): T => ({ ...restored, ...pendingEdits });
|
||||
|
||||
/**
|
||||
* Owns asynchronous registrations and result publication for one mounted scope.
|
||||
* Disposal invalidates the scope and invokes owned cleanup synchronously. Its
|
||||
* returned promise drains asynchronous cleanup acknowledgements, including a
|
||||
* listener that finishes registering after disposal.
|
||||
*/
|
||||
export class AsyncOwner {
|
||||
private disposed = false;
|
||||
private cleanups: AsyncCleanup[] = [];
|
||||
private readonly pending = new Set<Promise<unknown>>();
|
||||
private readonly pendingRegistrations = new Set<Promise<unknown>>();
|
||||
private readonly pendingCleanups = new Set<Promise<unknown>>();
|
||||
private latestOperation = 0;
|
||||
private readonly releaseWindowOwnership: () => void;
|
||||
|
||||
public constructor(
|
||||
private readonly reportCleanupError: (error: unknown) => void = () => {},
|
||||
adoptionScope: AsyncAdoptionScope = windowAsyncScope,
|
||||
) {
|
||||
const release = adoptionScope.registerDisposer(
|
||||
() => this.disposeForWindowClose(),
|
||||
);
|
||||
if (release === undefined) {
|
||||
this.disposed = true;
|
||||
this.releaseWindowOwnership = () => {};
|
||||
} else {
|
||||
this.releaseWindowOwnership = release;
|
||||
}
|
||||
}
|
||||
|
||||
public isActive(): boolean {
|
||||
return !this.disposed;
|
||||
}
|
||||
|
||||
public guard<Args extends unknown[]>(
|
||||
callback: (...args: Args) => void,
|
||||
): (...args: Args) => void {
|
||||
return (...args) => {
|
||||
if (this.isActive()) callback(...args);
|
||||
};
|
||||
}
|
||||
|
||||
public register(registration: AsyncRegistration): Promise<boolean> {
|
||||
if (!this.isActive()) return Promise.resolve(false);
|
||||
|
||||
return this.trackRegistration(this.registerInner(registration));
|
||||
}
|
||||
|
||||
public own(cleanup: AsyncCleanup): boolean {
|
||||
if (!this.isActive()) {
|
||||
void this.release(cleanup);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.cleanups.push(cleanup);
|
||||
return true;
|
||||
}
|
||||
|
||||
public release(cleanup: AsyncCleanup): Promise<void> {
|
||||
return this.trackCleanup(this.runCleanup(cleanup));
|
||||
}
|
||||
|
||||
private async registerInner(
|
||||
registration: AsyncRegistration,
|
||||
): Promise<boolean> {
|
||||
if (!this.isActive()) return false;
|
||||
|
||||
let cleanup: AsyncCleanup;
|
||||
try {
|
||||
cleanup = await registration();
|
||||
} catch (error) {
|
||||
if (!this.isActive()) return false;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!this.isActive()) {
|
||||
await this.release(cleanup);
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.own(cleanup);
|
||||
}
|
||||
|
||||
public applyIfActive<T>(
|
||||
operation: () => Promise<T>,
|
||||
apply: (value: T) => void,
|
||||
): Promise<boolean> {
|
||||
if (!this.isActive()) return Promise.resolve(false);
|
||||
|
||||
return this.track(this.applyActive(operation, apply));
|
||||
}
|
||||
|
||||
public applyLatestIfActive<T>(
|
||||
operation: () => Promise<T>,
|
||||
apply: (value: T) => void,
|
||||
): Promise<boolean> {
|
||||
if (!this.isActive()) return Promise.resolve(false);
|
||||
|
||||
const generation = ++this.latestOperation;
|
||||
return this.track(this.applyActive(operation, (value) => {
|
||||
if (generation === this.latestOperation) apply(value);
|
||||
}, generation));
|
||||
}
|
||||
|
||||
private async applyActive<T>(
|
||||
operation: () => Promise<T>,
|
||||
apply: (value: T) => void,
|
||||
generation?: number,
|
||||
): Promise<boolean> {
|
||||
if (!this.isActive()) return false;
|
||||
|
||||
let value: T;
|
||||
try {
|
||||
value = await operation();
|
||||
} catch (error) {
|
||||
if (
|
||||
!this.isActive() ||
|
||||
(generation !== undefined && generation !== this.latestOperation)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.isActive() ||
|
||||
(generation !== undefined && generation !== this.latestOperation)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
apply(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
public dispose(): Promise<void> {
|
||||
this.beginDispose();
|
||||
return this.drain();
|
||||
}
|
||||
|
||||
/** Drains every admitted operation before native webview destruction. */
|
||||
public disposeForWindowClose(): Promise<void> {
|
||||
this.beginDispose();
|
||||
return this.drain();
|
||||
}
|
||||
|
||||
private beginDispose(): void {
|
||||
if (!this.isActive()) return;
|
||||
|
||||
this.disposed = true;
|
||||
this.releaseWindowOwnership();
|
||||
this.latestOperation += 1;
|
||||
const cleanups = this.cleanups.reverse();
|
||||
this.cleanups = [];
|
||||
for (const cleanup of cleanups) void this.release(cleanup);
|
||||
}
|
||||
|
||||
public async drain(): Promise<void> {
|
||||
while (this.pending.size > 0) {
|
||||
await Promise.allSettled([...this.pending]);
|
||||
}
|
||||
}
|
||||
|
||||
private track<T>(promise: Promise<T>): Promise<T> {
|
||||
this.pending.add(promise);
|
||||
void promise.then(
|
||||
() => this.pending.delete(promise),
|
||||
() => this.pending.delete(promise),
|
||||
);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private trackRegistration<T>(promise: Promise<T>): Promise<T> {
|
||||
this.pendingRegistrations.add(promise);
|
||||
void promise.then(
|
||||
() => this.pendingRegistrations.delete(promise),
|
||||
() => this.pendingRegistrations.delete(promise),
|
||||
);
|
||||
return this.track(promise);
|
||||
}
|
||||
|
||||
private trackCleanup(promise: Promise<void>): Promise<void> {
|
||||
this.pendingCleanups.add(promise);
|
||||
void promise.then(
|
||||
() => this.pendingCleanups.delete(promise),
|
||||
() => this.pendingCleanups.delete(promise),
|
||||
);
|
||||
return this.track(promise);
|
||||
}
|
||||
|
||||
private async runCleanup(cleanup: AsyncCleanup): Promise<void> {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
try {
|
||||
this.reportCleanupError(error);
|
||||
} catch {
|
||||
// Cleanup reporting must not interrupt the remaining cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface CompanionWindowCreationPort {
|
||||
registerCreated: (handler: () => void) => Promise<AsyncCleanup>;
|
||||
registerError: (handler: (payload: unknown) => void) => Promise<AsyncCleanup>;
|
||||
destroy: () => Promise<void>;
|
||||
}
|
||||
|
||||
export type CompanionWindowCreationResult =
|
||||
| { kind: "created" }
|
||||
| { kind: "error"; payload: unknown }
|
||||
| { kind: "registration-error"; error: unknown };
|
||||
|
||||
/**
|
||||
* Owns one native companion-window creation through exactly one created/error
|
||||
* outcome. Parent disposal keeps the internal callbacks alive until that
|
||||
* outcome, drains both unlisten acknowledgements, and destroys a window that
|
||||
* finishes creating after close admission has ended.
|
||||
*/
|
||||
export const ownCompanionWindowCreation = async (
|
||||
owner: AsyncOwner,
|
||||
port: CompanionWindowCreationPort,
|
||||
): Promise<CompanionWindowCreationResult> => {
|
||||
let settle!: (result: CompanionWindowCreationResult) => void;
|
||||
let settled = false;
|
||||
const outcome = new Promise<CompanionWindowCreationResult>((resolve) => {
|
||||
settle = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
});
|
||||
|
||||
const register = (
|
||||
operation: () => Promise<AsyncCleanup>,
|
||||
): Promise<AsyncCleanup> => {
|
||||
try {
|
||||
return operation();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
const createdRegistration = register(() =>
|
||||
port.registerCreated(() => settle({ kind: "created" }))
|
||||
);
|
||||
const errorRegistration = register(() =>
|
||||
port.registerError((payload) => settle({ kind: "error", payload }))
|
||||
);
|
||||
void createdRegistration.catch((error) =>
|
||||
settle({ kind: "registration-error", error })
|
||||
);
|
||||
void errorRegistration.catch((error) =>
|
||||
settle({ kind: "registration-error", error })
|
||||
);
|
||||
|
||||
let parentClosing = false;
|
||||
let completion: Promise<CompanionWindowCreationResult> | undefined;
|
||||
const complete = (): Promise<CompanionWindowCreationResult> => {
|
||||
if (completion !== undefined) return completion;
|
||||
|
||||
completion = (async () => {
|
||||
const result = await outcome;
|
||||
const registrations = await Promise.allSettled([
|
||||
createdRegistration,
|
||||
errorRegistration,
|
||||
]);
|
||||
const cleanupResults = await Promise.allSettled(
|
||||
registrations.flatMap((registration) =>
|
||||
registration.status === "fulfilled"
|
||||
? [Promise.resolve().then(registration.value)]
|
||||
: []
|
||||
),
|
||||
);
|
||||
|
||||
const cleanupFailure = cleanupResults.find(
|
||||
(cleanup) => cleanup.status === "rejected",
|
||||
);
|
||||
if (
|
||||
result.kind === "registration-error" ||
|
||||
(result.kind === "created" && parentClosing)
|
||||
) {
|
||||
await port.destroy();
|
||||
}
|
||||
if (cleanupFailure?.status === "rejected") throw cleanupFailure.reason;
|
||||
return result;
|
||||
})();
|
||||
return completion;
|
||||
};
|
||||
|
||||
if (
|
||||
!owner.own(async () => {
|
||||
parentClosing = true;
|
||||
await complete();
|
||||
})
|
||||
) {
|
||||
parentClosing = true;
|
||||
}
|
||||
|
||||
return complete();
|
||||
};
|
||||
|
||||
export const registerSequentially = async (
|
||||
owner: AsyncOwner,
|
||||
registrations: ReadonlyArray<AsyncRegistration>,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
for (const registration of registrations) {
|
||||
if (!await owner.register(registration)) return false;
|
||||
}
|
||||
return owner.isActive();
|
||||
} catch (error) {
|
||||
await owner.dispose();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export interface SerializedAsyncWriter<T> {
|
||||
enqueue: (value: T) => Promise<void>;
|
||||
waitForIdle: () => Promise<void>;
|
||||
closeAndWait: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Serializes writes and keeps a failed write from poisoning later work. */
|
||||
export const createSerializedAsyncWriter = <T>(
|
||||
write: (value: T) => Promise<void>,
|
||||
reportFailure: (error: unknown) => void,
|
||||
): SerializedAsyncWriter<T> => {
|
||||
let tail = Promise.resolve();
|
||||
let accepting = true;
|
||||
|
||||
const enqueue = (value: T): Promise<void> => {
|
||||
if (!accepting) return Promise.resolve();
|
||||
|
||||
tail = tail.then(async () => {
|
||||
try {
|
||||
await write(value);
|
||||
} catch (error) {
|
||||
try {
|
||||
reportFailure(error);
|
||||
} catch {
|
||||
// Reporting must not prevent a newer value from being written.
|
||||
}
|
||||
}
|
||||
});
|
||||
return tail;
|
||||
};
|
||||
|
||||
const waitForIdle = async (): Promise<void> => {
|
||||
let observed: Promise<void>;
|
||||
do {
|
||||
observed = tail;
|
||||
await observed;
|
||||
} while (observed !== tail);
|
||||
};
|
||||
|
||||
return {
|
||||
enqueue,
|
||||
waitForIdle,
|
||||
closeAndWait: () => {
|
||||
accepting = false;
|
||||
return waitForIdle();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
CallToPlayAction,
|
||||
CallToPlayEvent,
|
||||
CallToPlayView,
|
||||
CallToPlayViewEvent,
|
||||
CallToPlayParticipant,
|
||||
Nomination,
|
||||
} from './types';
|
||||
@@ -13,17 +14,22 @@ export const CALL_TO_PLAY_CONNECTING_MESSAGE =
|
||||
|
||||
export const callToPlayPublishErrorMessage = (error: unknown): string => {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
if (detail.includes('Call to Play event is obsolete')
|
||||
|| detail.includes('Call to Play history is missing')
|
||||
if (detail.includes('unknown or expired')
|
||||
|| detail.includes('already terminal')
|
||||
) {
|
||||
return 'This Call to Play has expired or already finished.';
|
||||
}
|
||||
if (detail.includes('Call to Play event history is full')) {
|
||||
if (detail.includes('local event history is full')) {
|
||||
return 'Call to Play has reached its active update limit. Start or cancel an active call, then try again.';
|
||||
}
|
||||
return 'Could not send this Call to Play update.';
|
||||
};
|
||||
|
||||
export const replaceCallToPlayView = (
|
||||
_previous: CallToPlayView,
|
||||
incoming: CallToPlayView,
|
||||
): CallToPlayView => incoming;
|
||||
|
||||
export const extendDeadline = (
|
||||
now: number,
|
||||
currentDeadline: number,
|
||||
@@ -44,7 +50,7 @@ interface MutableNomination extends Nomination {
|
||||
messageIds: Set<string>;
|
||||
}
|
||||
|
||||
const compareEvents = (a: CallToPlayEvent, b: CallToPlayEvent): number =>
|
||||
const compareEvents = (a: CallToPlayViewEvent, b: CallToPlayViewEvent): number =>
|
||||
a.at - b.at || a.id.localeCompare(b.id);
|
||||
|
||||
type CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create'];
|
||||
@@ -117,7 +123,7 @@ export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus
|
||||
};
|
||||
|
||||
export const reduceCallToPlayEvents = (
|
||||
input: ReadonlyArray<CallToPlayEvent>,
|
||||
input: ReadonlyArray<CallToPlayViewEvent>,
|
||||
now: number,
|
||||
): Nomination[] => {
|
||||
const nominations = [...groupEvents(input).values()]
|
||||
@@ -126,35 +132,11 @@ export const reduceCallToPlayEvents = (
|
||||
return sortNominations(nominations);
|
||||
};
|
||||
|
||||
export const pruneCallToPlayEvents = (
|
||||
previous: ReadonlyMap<string, CallToPlayEvent>,
|
||||
now: number,
|
||||
): ReadonlyMap<string, CallToPlayEvent> => {
|
||||
const retiredEventIds = new Set<string>();
|
||||
for (const events of groupEvents([...previous.values()]).values()) {
|
||||
if (deriveNomination(events, now) !== null) continue;
|
||||
|
||||
const hasCreate = events.some(event => createPayload(event.action) !== null);
|
||||
const expiredTombstone = events.some(event =>
|
||||
(event.action === 'Start' || event.action === 'Cancel')
|
||||
&& now - event.at > TERMINAL_RETENTION_MS
|
||||
);
|
||||
if (hasCreate || expiredTombstone) {
|
||||
for (const event of events) retiredEventIds.add(event.id);
|
||||
}
|
||||
}
|
||||
if (retiredEventIds.size === 0) return previous;
|
||||
|
||||
const next = new Map(previous);
|
||||
for (const eventId of retiredEventIds) next.delete(eventId);
|
||||
return next;
|
||||
};
|
||||
|
||||
const groupEvents = (
|
||||
input: ReadonlyArray<CallToPlayEvent>,
|
||||
): Map<string, CallToPlayEvent[]> => {
|
||||
input: ReadonlyArray<CallToPlayViewEvent>,
|
||||
): Map<string, CallToPlayViewEvent[]> => {
|
||||
const unique = new Map(input.map(event => [event.id, event]));
|
||||
const byCall = new Map<string, CallToPlayEvent[]>();
|
||||
const byCall = new Map<string, CallToPlayViewEvent[]>();
|
||||
for (const event of unique.values()) {
|
||||
const events = byCall.get(event.call_id) ?? [];
|
||||
events.push(event);
|
||||
@@ -164,7 +146,7 @@ const groupEvents = (
|
||||
};
|
||||
|
||||
const deriveNomination = (
|
||||
events: CallToPlayEvent[],
|
||||
events: CallToPlayViewEvent[],
|
||||
now: number,
|
||||
): Nomination | null => {
|
||||
events.sort(compareEvents);
|
||||
@@ -176,15 +158,15 @@ const deriveNomination = (
|
||||
const nomination: MutableNomination = {
|
||||
id: create.call_id,
|
||||
gameId: payload.game_id,
|
||||
creatorId: create.actor_id,
|
||||
creator: create.actor_name,
|
||||
creatorId: create.author_id,
|
||||
creator: create.author_name,
|
||||
maxPlayers: payload.max_players,
|
||||
createdAt: create.at,
|
||||
scheduledFor: payload.scheduled_for,
|
||||
deadline: payload.deadline,
|
||||
participants: {
|
||||
[create.actor_id]: {
|
||||
name: create.actor_name,
|
||||
[create.author_id]: {
|
||||
name: create.author_name,
|
||||
status: payload.scheduled_for === null ? 'ready' : 'in',
|
||||
joinedAt: create.at,
|
||||
},
|
||||
@@ -214,7 +196,7 @@ const deriveNomination = (
|
||||
return result;
|
||||
};
|
||||
|
||||
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
|
||||
const applyEvent = (nomination: MutableNomination, event: CallToPlayViewEvent): void => {
|
||||
if (isTerminal(nomination)) return;
|
||||
|
||||
const action = event.action;
|
||||
@@ -225,9 +207,9 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
||||
|
||||
const response = respondPayload(action);
|
||||
if (response) {
|
||||
const existing = nomination.participants[event.actor_id];
|
||||
nomination.participants[event.actor_id] = {
|
||||
name: event.actor_name,
|
||||
const existing = nomination.participants[event.author_id];
|
||||
nomination.participants[event.author_id] = {
|
||||
name: event.author_name,
|
||||
status: response.ready_at === null ? 'ready' : 'pending',
|
||||
joinedAt: existing?.joinedAt ?? event.at,
|
||||
...(response.ready_at === null ? {} : { readyAt: response.ready_at }),
|
||||
@@ -236,12 +218,12 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
||||
}
|
||||
|
||||
const message = messagePayload(action);
|
||||
if (message && !nomination.messageIds.has(message.message_id)) {
|
||||
nomination.messageIds.add(message.message_id);
|
||||
if (message && !nomination.messageIds.has(event.id)) {
|
||||
nomination.messageIds.add(event.id);
|
||||
nomination.messages.push({
|
||||
id: message.message_id,
|
||||
fromId: event.actor_id,
|
||||
from: event.actor_name,
|
||||
id: event.id,
|
||||
fromId: event.author_id,
|
||||
from: event.author_name,
|
||||
text: message.text,
|
||||
at: event.at,
|
||||
});
|
||||
@@ -251,7 +233,7 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
||||
|
||||
const extension = addTimePayload(action);
|
||||
if (extension
|
||||
&& event.actor_id === nomination.creatorId
|
||||
&& event.author_id === nomination.creatorId
|
||||
) {
|
||||
nomination.deadline = extension.deadline;
|
||||
nomination.state = 'open';
|
||||
@@ -260,32 +242,32 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
||||
|
||||
const applyUnitAction = (
|
||||
nomination: MutableNomination,
|
||||
event: CallToPlayEvent,
|
||||
event: CallToPlayViewEvent,
|
||||
action: Extract<CallToPlayAction, string>,
|
||||
): void => {
|
||||
switch (action) {
|
||||
case 'Rsvp': {
|
||||
const existing = nomination.participants[event.actor_id];
|
||||
nomination.participants[event.actor_id] = {
|
||||
name: event.actor_name,
|
||||
const existing = nomination.participants[event.author_id];
|
||||
nomination.participants[event.author_id] = {
|
||||
name: event.author_name,
|
||||
status: 'in',
|
||||
joinedAt: existing?.joinedAt ?? event.at,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'Leave':
|
||||
if (event.actor_id !== nomination.creatorId) {
|
||||
delete nomination.participants[event.actor_id];
|
||||
if (event.author_id !== nomination.creatorId) {
|
||||
delete nomination.participants[event.author_id];
|
||||
}
|
||||
break;
|
||||
case 'Cancel':
|
||||
if (event.actor_id === nomination.creatorId) {
|
||||
if (event.author_id === nomination.creatorId) {
|
||||
nomination.state = 'cancelled';
|
||||
nomination.terminalAt = event.at;
|
||||
}
|
||||
break;
|
||||
case 'Start':
|
||||
if (event.actor_id === nomination.creatorId) {
|
||||
if (event.author_id === nomination.creatorId) {
|
||||
nomination.state = 'running';
|
||||
nomination.terminalAt = event.at;
|
||||
}
|
||||
@@ -293,21 +275,6 @@ const applyUnitAction = (
|
||||
}
|
||||
};
|
||||
|
||||
export const callToPlayEvent = (
|
||||
callId: string,
|
||||
actorId: string,
|
||||
actorName: string,
|
||||
action: CallToPlayAction,
|
||||
at = Date.now(),
|
||||
): CallToPlayEvent => ({
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
call_id: callId,
|
||||
actor_id: actorId,
|
||||
actor_name: actorName,
|
||||
at,
|
||||
action,
|
||||
});
|
||||
|
||||
export const formatClock = (timestamp: number): string => {
|
||||
const date = new Date(timestamp);
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
AsyncOwner,
|
||||
type AsyncCleanup,
|
||||
type AsyncRegistration,
|
||||
windowAsyncScope,
|
||||
} from './asyncOwnership';
|
||||
|
||||
export type CallToPlayRetryCallback = () => Promise<void>;
|
||||
export type CallToPlayRetryScheduler = (
|
||||
callback: CallToPlayRetryCallback,
|
||||
delayMilliseconds: number,
|
||||
) => AsyncCleanup;
|
||||
|
||||
/**
|
||||
* Owns the Call to Play listener, snapshots, actions, and retry timer as one
|
||||
* lifecycle. A retry is scheduled only after the preceding attempt settles.
|
||||
*/
|
||||
export class CallToPlayAsyncScope {
|
||||
private readonly owner: AsyncOwner;
|
||||
private retryAttempt: (() => Promise<boolean>) | undefined;
|
||||
private retryCleanup: AsyncCleanup | undefined;
|
||||
private retryToken: object | undefined;
|
||||
private retryRunning = false;
|
||||
private retryEnabled = false;
|
||||
private retryGeneration = 0;
|
||||
private retryDelayMilliseconds = 0;
|
||||
private actionGeneration = 0;
|
||||
private mutationTail: Promise<void> = Promise.resolve();
|
||||
private mutationAdmissionOpen = true;
|
||||
private readonly releaseWindowOwnership: () => void;
|
||||
|
||||
public constructor(
|
||||
private readonly scheduleRetry: CallToPlayRetryScheduler,
|
||||
reportCleanupFailure: (error: unknown) => void = () => {},
|
||||
private readonly reportRetryError: (error: unknown) => void = () => {},
|
||||
) {
|
||||
this.owner = new AsyncOwner(reportCleanupFailure);
|
||||
this.releaseWindowOwnership = windowAsyncScope.registerDisposer(
|
||||
() => this.disposeForWindowClose(),
|
||||
) ?? (() => {});
|
||||
}
|
||||
|
||||
public isActive(): boolean {
|
||||
return this.owner.isActive();
|
||||
}
|
||||
|
||||
public guard<Args extends unknown[]>(
|
||||
callback: (...args: Args) => void,
|
||||
): (...args: Args) => void {
|
||||
return this.owner.guard(callback);
|
||||
}
|
||||
|
||||
public registerListener(registration: AsyncRegistration): Promise<boolean> {
|
||||
return this.owner.register(registration);
|
||||
}
|
||||
|
||||
public applyIfActive<T>(
|
||||
operation: () => Promise<T>,
|
||||
apply: (value: T) => void,
|
||||
): Promise<boolean> {
|
||||
return this.owner.applyIfActive(operation, apply);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admits a backend mutation in user order. Once admitted, a mutation runs
|
||||
* even if the React scope is disposed while it is queued; disposal closes
|
||||
* admission and drains the already-admitted sequence. Result publication
|
||||
* remains lifecycle-guarded.
|
||||
*/
|
||||
public applyMutationIfActive<T>(
|
||||
operation: () => Promise<T>,
|
||||
apply: (value: T) => void,
|
||||
): Promise<boolean> {
|
||||
if (!this.isActive() || !this.mutationAdmissionOpen) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const mutation = this.mutationTail.then(async () => {
|
||||
const value = await operation();
|
||||
if (!this.isActive()) return false;
|
||||
apply(value);
|
||||
return true;
|
||||
});
|
||||
this.mutationTail = mutation.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return mutation;
|
||||
}
|
||||
|
||||
public guardLatestAction<Args extends unknown[]>(
|
||||
publish: (...args: Args) => void,
|
||||
): (...args: Args) => void {
|
||||
const generation = ++this.actionGeneration;
|
||||
return this.owner.guard((...args) => {
|
||||
if (generation === this.actionGeneration) publish(...args);
|
||||
});
|
||||
}
|
||||
|
||||
public startRetry(
|
||||
attempt: () => Promise<boolean>,
|
||||
delayMilliseconds: number,
|
||||
): void {
|
||||
if (!this.isActive() || this.retryEnabled) return;
|
||||
|
||||
this.retryAttempt = attempt;
|
||||
this.retryDelayMilliseconds = delayMilliseconds;
|
||||
this.retryEnabled = true;
|
||||
this.retryGeneration += 1;
|
||||
this.scheduleNextRetry();
|
||||
}
|
||||
|
||||
public stopRetry(): void {
|
||||
this.retryEnabled = false;
|
||||
this.retryGeneration += 1;
|
||||
this.clearScheduledRetry();
|
||||
}
|
||||
|
||||
public dispose(): Promise<void> {
|
||||
this.releaseWindowOwnership();
|
||||
this.stopRetry();
|
||||
this.mutationAdmissionOpen = false;
|
||||
return Promise.all([this.mutationTail, this.owner.dispose()]).then(() => {});
|
||||
}
|
||||
|
||||
private disposeForWindowClose(): Promise<void> {
|
||||
this.releaseWindowOwnership();
|
||||
this.stopRetry();
|
||||
this.mutationAdmissionOpen = false;
|
||||
return Promise.all([
|
||||
this.mutationTail,
|
||||
this.owner.disposeForWindowClose(),
|
||||
]).then(() => {});
|
||||
}
|
||||
|
||||
private scheduleNextRetry(): void {
|
||||
if (
|
||||
!this.isActive()
|
||||
|| !this.retryEnabled
|
||||
|| this.retryRunning
|
||||
|| this.retryCleanup !== undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = this.retryGeneration;
|
||||
const token = {};
|
||||
const cleanup = this.scheduleRetry(
|
||||
async () => {
|
||||
await this.owner.applyIfActive(
|
||||
() => this.runRetry(generation, token),
|
||||
() => {},
|
||||
);
|
||||
},
|
||||
this.retryDelayMilliseconds,
|
||||
);
|
||||
if (
|
||||
!this.isActive()
|
||||
|| !this.retryEnabled
|
||||
|| generation !== this.retryGeneration
|
||||
) {
|
||||
void this.owner.release(cleanup);
|
||||
return;
|
||||
}
|
||||
this.retryToken = token;
|
||||
this.retryCleanup = cleanup;
|
||||
}
|
||||
|
||||
private async runRetry(generation: number, token: object): Promise<void> {
|
||||
if (token !== this.retryToken) return;
|
||||
|
||||
this.retryToken = undefined;
|
||||
this.retryCleanup = undefined;
|
||||
if (
|
||||
!this.isActive()
|
||||
|| !this.retryEnabled
|
||||
|| generation !== this.retryGeneration
|
||||
|| this.retryRunning
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = this.retryAttempt;
|
||||
if (attempt === undefined) return;
|
||||
|
||||
this.retryRunning = true;
|
||||
try {
|
||||
const ready = await attempt();
|
||||
if (
|
||||
ready
|
||||
&& this.isActive()
|
||||
&& this.retryEnabled
|
||||
&& generation === this.retryGeneration
|
||||
) {
|
||||
this.stopRetry();
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
this.isActive()
|
||||
&& this.retryEnabled
|
||||
&& generation === this.retryGeneration
|
||||
) {
|
||||
try {
|
||||
this.reportRetryError(error);
|
||||
} catch {
|
||||
// Retry error reporting must not detach the owned loop.
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.retryRunning = false;
|
||||
this.scheduleNextRetry();
|
||||
}
|
||||
}
|
||||
|
||||
private clearScheduledRetry(): void {
|
||||
this.retryToken = undefined;
|
||||
const cleanup = this.retryCleanup;
|
||||
this.retryCleanup = undefined;
|
||||
if (cleanup === undefined) return;
|
||||
|
||||
void this.owner.release(cleanup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { type AsyncCleanup } from './asyncOwnership';
|
||||
|
||||
export interface FrontendCloseRequest {
|
||||
preventDefault: () => void;
|
||||
}
|
||||
|
||||
export interface FrontendBootstrapOptions {
|
||||
registerCloseRequested: (
|
||||
handler: (event: FrontendCloseRequest) => void | Promise<void>,
|
||||
) => Promise<AsyncCleanup>;
|
||||
disposeOwners: () => Promise<void>;
|
||||
drainPersistence: () => Promise<void>;
|
||||
destroyWindow: () => Promise<void>;
|
||||
render: () => void;
|
||||
reportFailure: (message: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
const report = (
|
||||
options: FrontendBootstrapOptions,
|
||||
message: string,
|
||||
error: unknown,
|
||||
): void => {
|
||||
try {
|
||||
options.reportFailure(message, error);
|
||||
} catch {
|
||||
// Reporting must not turn a handled bootstrap failure into a rejection.
|
||||
}
|
||||
};
|
||||
|
||||
const runHandled = async (
|
||||
options: FrontendBootstrapOptions,
|
||||
message: string,
|
||||
operation: () => void | Promise<void>,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
report(options, message, error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Installs the webview's close boundary before mounting any React hooks.
|
||||
* Registration failure closes the empty realm instead of running without an
|
||||
* ownership boundary.
|
||||
*/
|
||||
export const bootstrapFrontend = async (
|
||||
options: FrontendBootstrapOptions,
|
||||
): Promise<boolean> => {
|
||||
let closing: Promise<void> | undefined;
|
||||
let publishCloseCleanup!: (cleanup: AsyncCleanup) => void;
|
||||
const closeCleanupReady = new Promise<AsyncCleanup>(resolve => {
|
||||
publishCloseCleanup = resolve;
|
||||
});
|
||||
|
||||
const finalizeWindow = (): Promise<void> => {
|
||||
if (closing !== undefined) return closing;
|
||||
|
||||
// Calling every operation closes its admission or starts its cleanup
|
||||
// synchronously, before any asynchronous drain is awaited.
|
||||
const ownerDrain = runHandled(
|
||||
options,
|
||||
'Failed to dispose frontend window owners:',
|
||||
options.disposeOwners,
|
||||
);
|
||||
const persistenceDrain = runHandled(
|
||||
options,
|
||||
'Failed to drain frontend persistence:',
|
||||
options.drainPersistence,
|
||||
);
|
||||
const listenerDrain = runHandled(
|
||||
options,
|
||||
'Failed to unregister the window close listener:',
|
||||
async () => {
|
||||
const cleanup = await closeCleanupReady;
|
||||
await cleanup();
|
||||
},
|
||||
);
|
||||
|
||||
closing = Promise.all([
|
||||
ownerDrain,
|
||||
persistenceDrain,
|
||||
listenerDrain,
|
||||
])
|
||||
.then(() => runHandled(
|
||||
options,
|
||||
'Failed to destroy the frontend window:',
|
||||
options.destroyWindow,
|
||||
));
|
||||
return closing;
|
||||
};
|
||||
|
||||
try {
|
||||
const cleanupCloseListener = await options.registerCloseRequested(event => {
|
||||
event.preventDefault();
|
||||
return finalizeWindow();
|
||||
});
|
||||
publishCloseCleanup(cleanupCloseListener);
|
||||
} catch (error) {
|
||||
publishCloseCleanup(() => {});
|
||||
report(options, 'Failed to install the frontend close-drain boundary:', error);
|
||||
|
||||
// Nothing has rendered, but close both admissions in case a module-level
|
||||
// owner was registered while loading the bundle, then explicitly await
|
||||
// destruction of the empty webview.
|
||||
await finalizeWindow();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (closing !== undefined) {
|
||||
// A close request may arrive after the native listener is callable but
|
||||
// before its registration promise acknowledges ownership. The active
|
||||
// finalizer now owns the late cleanup; never mount React into a realm
|
||||
// that has already begun closing.
|
||||
await closing;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
options.render();
|
||||
} catch (error) {
|
||||
report(options, 'Failed to render the frontend:', error);
|
||||
await finalizeWindow();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
export type PersistenceShutdown = () => Promise<void>;
|
||||
export type PersistenceEffectStart = () => void | (() => void);
|
||||
|
||||
/**
|
||||
* Owns persistence queues for one webview. Closing admission is synchronous
|
||||
* and permanent; draining waits for every queue admitted before close without
|
||||
* pulling unrelated network operations into window shutdown.
|
||||
*/
|
||||
export class PersistenceShutdownScope {
|
||||
private accepting = true;
|
||||
private readonly shutdownTasks = new Set<PersistenceShutdown>();
|
||||
private readonly pending = new Set<Promise<void>>();
|
||||
|
||||
public constructor(
|
||||
private readonly reportFailure: (error: unknown) => void = () => {},
|
||||
) {}
|
||||
|
||||
public register(shutdown: PersistenceShutdown): (() => void) | undefined {
|
||||
if (!this.accepting) return undefined;
|
||||
|
||||
this.shutdownTasks.add(shutdown);
|
||||
return () => this.shutdownTasks.delete(shutdown);
|
||||
}
|
||||
|
||||
public async closeAndDrain(): Promise<void> {
|
||||
if (this.accepting) {
|
||||
this.accepting = false;
|
||||
const tasks = [...this.shutdownTasks];
|
||||
this.shutdownTasks.clear();
|
||||
for (const shutdown of tasks) this.start(shutdown);
|
||||
}
|
||||
|
||||
while (this.pending.size > 0) {
|
||||
await Promise.allSettled([...this.pending]);
|
||||
}
|
||||
}
|
||||
|
||||
private start(shutdown: PersistenceShutdown): void {
|
||||
let pending: Promise<void>;
|
||||
try {
|
||||
pending = shutdown();
|
||||
} catch (error) {
|
||||
this.report(error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pending.add(pending);
|
||||
void pending.then(
|
||||
() => this.pending.delete(pending),
|
||||
error => {
|
||||
this.pending.delete(pending);
|
||||
this.report(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private report(error: unknown): void {
|
||||
try {
|
||||
this.reportFailure(error);
|
||||
} catch {
|
||||
// Reporting cannot be allowed to escape the shutdown scope.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically admits a passive effect before it starts persistence work. Once
|
||||
* window shutdown closes the scope, later effects receive no admission and
|
||||
* cannot reopen hydration or write queues.
|
||||
*/
|
||||
export const startAdmittedPersistenceEffect = (
|
||||
scope: PersistenceShutdownScope,
|
||||
shutdown: PersistenceShutdown,
|
||||
start: PersistenceEffectStart,
|
||||
): (() => void) => {
|
||||
const unregister = scope.register(shutdown);
|
||||
if (unregister === undefined) return () => {};
|
||||
|
||||
let cleanup: void | (() => void);
|
||||
try {
|
||||
cleanup = start();
|
||||
} catch (error) {
|
||||
unregister();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanup?.();
|
||||
unregister();
|
||||
};
|
||||
};
|
||||
|
||||
export const windowPersistenceScope = new PersistenceShutdownScope(error => {
|
||||
console.error('Failed to drain frontend persistence:', error);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
export interface GameDirectoryUpdatePorts {
|
||||
updateBackend: (requestedPath: string) => Promise<unknown>;
|
||||
persist: (acceptedPath: string) => Promise<void>;
|
||||
reportPersistenceError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface GameDirectoryHydrationPorts {
|
||||
loadSavedPath: () => Promise<string | null | undefined>;
|
||||
acceptSavedPath: (savedPath: string) => Promise<void>;
|
||||
reportLoadError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles restoration before the caller marks game-directory state ready.
|
||||
* Missing state and load/acceptance failures are completed outcomes: the UI
|
||||
* may then show the chooser, but never while a saved path is still being
|
||||
* accepted by the backend.
|
||||
*/
|
||||
export const hydrateGameDirectory = async (
|
||||
ports: GameDirectoryHydrationPorts,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const savedPath = await ports.loadSavedPath();
|
||||
if (savedPath?.trim()) await ports.acceptSavedPath(savedPath);
|
||||
} catch (error) {
|
||||
ports.reportLoadError(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Asks the backend to accept a game directory and persists only the canonical
|
||||
* path returned by that successful request.
|
||||
*
|
||||
* Backend acknowledgement is the commit point. Persistence is best-effort: a
|
||||
* store failure must not make the UI retain a path that no longer matches the
|
||||
* already-updated backend.
|
||||
*/
|
||||
export const acceptGameDirectory = async (
|
||||
requestedPath: string,
|
||||
ports: GameDirectoryUpdatePorts,
|
||||
): Promise<string> => {
|
||||
const acceptedPath = await ports.updateBackend(requestedPath);
|
||||
if (typeof acceptedPath !== 'string' || !acceptedPath.trim()) {
|
||||
throw new Error('update_game_directory returned an invalid accepted path');
|
||||
}
|
||||
|
||||
try {
|
||||
await ports.persist(acceptedPath);
|
||||
} catch (error) {
|
||||
ports.reportPersistenceError(error);
|
||||
}
|
||||
|
||||
return acceptedPath;
|
||||
};
|
||||
@@ -1,178 +1,315 @@
|
||||
import {
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
DerivedState,
|
||||
Game,
|
||||
GameFilter,
|
||||
GameSort,
|
||||
GamesListPayload,
|
||||
InstallStatus,
|
||||
} from './types';
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
DerivedState,
|
||||
DownloadProgressPayload,
|
||||
Game,
|
||||
GameFilter,
|
||||
GameSort,
|
||||
GameTransferStatus,
|
||||
GameTransferStatusSnapshot,
|
||||
InstallStatus,
|
||||
StatusLevel,
|
||||
} from "./types";
|
||||
|
||||
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
|
||||
InstallStatus.CheckingPeers,
|
||||
InstallStatus.Downloading,
|
||||
InstallStatus.Installing,
|
||||
InstallStatus.Uninstalling,
|
||||
InstallStatus.Removing,
|
||||
InstallStatus.Downloading,
|
||||
InstallStatus.Installing,
|
||||
InstallStatus.Uninstalling,
|
||||
InstallStatus.Removing,
|
||||
]);
|
||||
|
||||
export const isInProgress = (status: InstallStatus): boolean =>
|
||||
IN_PROGRESS_INSTALL_STATUSES.has(status);
|
||||
IN_PROGRESS_INSTALL_STATUSES.has(status);
|
||||
|
||||
export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => {
|
||||
switch (op) {
|
||||
case ActiveOperationKind.Downloading:
|
||||
return InstallStatus.Downloading;
|
||||
case ActiveOperationKind.Installing:
|
||||
case ActiveOperationKind.Updating:
|
||||
return InstallStatus.Installing;
|
||||
case ActiveOperationKind.Uninstalling:
|
||||
return InstallStatus.Uninstalling;
|
||||
case ActiveOperationKind.RemovingDownload:
|
||||
return InstallStatus.Removing;
|
||||
}
|
||||
export const installStatusFromActiveOperation = (
|
||||
op: ActiveOperationKind,
|
||||
): InstallStatus => {
|
||||
switch (op) {
|
||||
case ActiveOperationKind.Downloading:
|
||||
return InstallStatus.Downloading;
|
||||
case ActiveOperationKind.Installing:
|
||||
case ActiveOperationKind.Updating:
|
||||
return InstallStatus.Installing;
|
||||
case ActiveOperationKind.Uninstalling:
|
||||
return InstallStatus.Uninstalling;
|
||||
case ActiveOperationKind.RemovingDownload:
|
||||
return InstallStatus.Removing;
|
||||
}
|
||||
};
|
||||
|
||||
export const activeStatusById = (ops: ActiveOperation[] = []): Map<string, InstallStatus> =>
|
||||
new Map(ops.map(op => [op.id, installStatusFromActiveOperation(op.operation)]));
|
||||
export const activeStatusById = (
|
||||
ops: ActiveOperation[] = [],
|
||||
): Map<string, InstallStatus> =>
|
||||
new Map(
|
||||
ops.map((op) => [op.id, installStatusFromActiveOperation(op.operation)]),
|
||||
);
|
||||
|
||||
export const normalizeGamesListPayload = (
|
||||
payload: GamesListPayload | Game[],
|
||||
): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload;
|
||||
export const INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT: GameTransferStatusSnapshot =
|
||||
{
|
||||
revision: 0,
|
||||
statuses: {},
|
||||
openAttempts: {},
|
||||
};
|
||||
|
||||
/** Keeps listener events and GamesList snapshots monotonic across async races. */
|
||||
export const newestGameTransferStatusSnapshot = (
|
||||
current: GameTransferStatusSnapshot,
|
||||
candidate: GameTransferStatusSnapshot,
|
||||
): GameTransferStatusSnapshot =>
|
||||
candidate.revision > current.revision ? candidate : current;
|
||||
|
||||
export interface GameTransferStatusPresentation {
|
||||
message: string;
|
||||
level: StatusLevel;
|
||||
}
|
||||
|
||||
export const gameTransferStatusPresentation = (
|
||||
status: GameTransferStatus | undefined,
|
||||
): GameTransferStatusPresentation | undefined => {
|
||||
switch (status) {
|
||||
case GameTransferStatus.Verifying:
|
||||
return {
|
||||
message: "Verifying downloaded chunks",
|
||||
level: "info",
|
||||
};
|
||||
case GameTransferStatus.Retrying:
|
||||
return {
|
||||
message: "A source sent invalid data; retrying another nearby peer",
|
||||
level: "warning",
|
||||
};
|
||||
case GameTransferStatus.Exhausted:
|
||||
return {
|
||||
message: "No nearby peer could provide the verified catalog version",
|
||||
level: "error",
|
||||
};
|
||||
case undefined:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Transient verification/retry activity replaces ordinary download copy. */
|
||||
export const downloadProgressTransferLabel = (
|
||||
game: Game,
|
||||
): string | undefined => {
|
||||
if (game.transfer_status === GameTransferStatus.Exhausted) return undefined;
|
||||
return gameTransferStatusPresentation(game.transfer_status)?.message;
|
||||
};
|
||||
|
||||
export const downloadProgressAriaLabel = (game: Game): string => {
|
||||
const transferLabel = downloadProgressTransferLabel(game);
|
||||
return transferLabel
|
||||
? `${transferLabel}: ${game.name}`
|
||||
: `Downloading ${game.name}`;
|
||||
};
|
||||
|
||||
export const gameTransferStatusFor = (
|
||||
snapshot: GameTransferStatusSnapshot,
|
||||
gameId: string,
|
||||
): GameTransferStatus | undefined =>
|
||||
Object.prototype.hasOwnProperty.call(snapshot.statuses, gameId)
|
||||
? snapshot.statuses[gameId]
|
||||
: undefined;
|
||||
|
||||
export const gameTransferOpenAttemptFor = (
|
||||
snapshot: GameTransferStatusSnapshot,
|
||||
gameId: string,
|
||||
): string | undefined =>
|
||||
Object.prototype.hasOwnProperty.call(snapshot.openAttempts, gameId)
|
||||
? snapshot.openAttempts[gameId]
|
||||
: undefined;
|
||||
|
||||
export const applyDownloadProgress = (
|
||||
games: Game[],
|
||||
snapshot: GameTransferStatusSnapshot,
|
||||
payload: DownloadProgressPayload,
|
||||
): Game[] => {
|
||||
const openAttempt = gameTransferOpenAttemptFor(snapshot, payload.id);
|
||||
if (openAttempt === undefined || openAttempt !== payload.attemptId) {
|
||||
return games;
|
||||
}
|
||||
|
||||
const { id, ...downloadProgress } = payload;
|
||||
return games.map((game) =>
|
||||
game.id === id ? { ...game, download_progress: downloadProgress } : game
|
||||
);
|
||||
};
|
||||
|
||||
export const applyGameTransferStatusSnapshot = (
|
||||
games: Game[],
|
||||
snapshot: GameTransferStatusSnapshot,
|
||||
): Game[] =>
|
||||
games.map((game) => {
|
||||
const openAttempt = gameTransferOpenAttemptFor(snapshot, game.id);
|
||||
return {
|
||||
...game,
|
||||
transfer_status: gameTransferStatusFor(snapshot, game.id),
|
||||
download_progress: openAttempt !== undefined &&
|
||||
game.download_progress?.attemptId === openAttempt
|
||||
? game.download_progress
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Reconcile a freshly received backend snapshot. Core operation status is
|
||||
* derived only from the backend active-operation snapshot plus installed state.
|
||||
*/
|
||||
export const mergeGameUpdate = (
|
||||
incoming: Game,
|
||||
previous?: Game,
|
||||
activeStatus?: InstallStatus,
|
||||
incoming: Game,
|
||||
previous?: Game,
|
||||
activeStatus?: InstallStatus,
|
||||
transferStatus?: GameTransferStatus,
|
||||
): Game => {
|
||||
const installStatus = activeStatus
|
||||
?? (incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled);
|
||||
const installStatus = activeStatus ??
|
||||
(incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled);
|
||||
|
||||
const localStateChanged = previous !== undefined
|
||||
&& (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded);
|
||||
const statusChanged = previous !== undefined
|
||||
&& previous.install_status !== installStatus;
|
||||
const clearStatus = localStateChanged
|
||||
|| (statusChanged && (activeStatus !== undefined || isInProgress(previous.install_status)));
|
||||
const localStateChanged = previous !== undefined &&
|
||||
(previous.installed !== incoming.installed ||
|
||||
previous.downloaded !== incoming.downloaded);
|
||||
const statusChanged = previous !== undefined &&
|
||||
previous.install_status !== installStatus;
|
||||
const clearStatus = localStateChanged ||
|
||||
(statusChanged &&
|
||||
(activeStatus !== undefined || isInProgress(previous.install_status)));
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
availability: incoming.availability,
|
||||
install_status: installStatus,
|
||||
status_message: clearStatus ? undefined : previous?.status_message,
|
||||
status_level: clearStatus ? undefined : previous?.status_level,
|
||||
download_progress: installStatus === InstallStatus.Downloading
|
||||
? previous?.download_progress
|
||||
: undefined,
|
||||
peer_count: incoming.peer_count ?? 0,
|
||||
};
|
||||
return {
|
||||
...incoming,
|
||||
availability: incoming.availability,
|
||||
install_status: installStatus,
|
||||
status_message: clearStatus ? undefined : previous?.status_message,
|
||||
status_level: clearStatus ? undefined : previous?.status_level,
|
||||
transfer_status: transferStatus,
|
||||
download_progress: installStatus === InstallStatus.Downloading
|
||||
? previous?.download_progress
|
||||
: undefined,
|
||||
peer_count: incoming.peer_count ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
/** Visual card state — used for state chip color and action button styling. */
|
||||
export const deriveState = (game: Game): DerivedState => {
|
||||
if (game.install_status === InstallStatus.Downloading) return 'downloading';
|
||||
if (isInProgress(game.install_status)) return 'busy';
|
||||
if (game.installed) return 'installed';
|
||||
if (game.downloaded) return 'local';
|
||||
return 'none';
|
||||
if (game.install_status === InstallStatus.Downloading) return "downloading";
|
||||
if (isInProgress(game.install_status)) return "busy";
|
||||
if (game.installed) return "installed";
|
||||
if (game.downloaded) return "local";
|
||||
return "none";
|
||||
};
|
||||
|
||||
export const isInstalledNotShareable = (game: Game): boolean =>
|
||||
game.installed && !game.downloaded;
|
||||
game.installed && !game.downloaded;
|
||||
|
||||
export const stateChipLabel = (game: Game): string => {
|
||||
const state = deriveState(game);
|
||||
if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable';
|
||||
switch (state) {
|
||||
case 'installed': return 'Installed';
|
||||
case 'local': return 'Local';
|
||||
case 'downloading': return 'Downloading';
|
||||
case 'busy': return 'Working';
|
||||
case 'none': return '';
|
||||
}
|
||||
const state = deriveState(game);
|
||||
if (state === "installed" && isInstalledNotShareable(game)) {
|
||||
return "Not shareable";
|
||||
}
|
||||
switch (state) {
|
||||
case "installed":
|
||||
return "Installed";
|
||||
case "local":
|
||||
return "Local";
|
||||
case "downloading":
|
||||
return "Downloading";
|
||||
case "busy":
|
||||
return "Working";
|
||||
case "none":
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const gameStatusLabel = (game: Game): string => {
|
||||
const state = deriveState(game);
|
||||
if (state === 'installed' && isInstalledNotShareable(game)) {
|
||||
return 'Installed, not shareable';
|
||||
}
|
||||
switch (state) {
|
||||
case 'installed': return 'Installed';
|
||||
case 'local': return 'Downloaded';
|
||||
case 'downloading': return 'Downloading';
|
||||
case 'busy': return 'Working…';
|
||||
case 'none': return 'Not downloaded';
|
||||
}
|
||||
const state = deriveState(game);
|
||||
if (state === "installed" && isInstalledNotShareable(game)) {
|
||||
return "Installed, not shareable";
|
||||
}
|
||||
switch (state) {
|
||||
case "installed":
|
||||
return "Installed";
|
||||
case "local":
|
||||
return "Downloaded";
|
||||
case "downloading":
|
||||
return "Downloading";
|
||||
case "busy":
|
||||
return "Working…";
|
||||
case "none":
|
||||
return "Not downloaded";
|
||||
}
|
||||
};
|
||||
|
||||
export const isUnavailable = (game: Game): boolean =>
|
||||
!game.installed
|
||||
&& !game.downloaded
|
||||
&& game.peer_count === 0
|
||||
&& game.install_status === InstallStatus.NotInstalled;
|
||||
!game.installed &&
|
||||
!game.downloaded &&
|
||||
game.peer_count === 0 &&
|
||||
game.install_status === InstallStatus.NotInstalled;
|
||||
|
||||
const parseVersionStamp = (version: string | undefined): number | null => {
|
||||
if (!version || !/^\d{8}$/.test(version)) return null;
|
||||
const parsed = parseInt(version, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
if (!version || !/^\d{8}$/.test(version)) return null;
|
||||
const parsed = parseInt(version, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
export const compareVersionStamps = (
|
||||
left: string | undefined,
|
||||
right: string | undefined,
|
||||
left: string | undefined,
|
||||
right: string | undefined,
|
||||
): number | null => {
|
||||
const parsedLeft = parseVersionStamp(left);
|
||||
const parsedRight = parseVersionStamp(right);
|
||||
if (parsedLeft === null || parsedRight === null) return null;
|
||||
return parsedLeft - parsedRight;
|
||||
const parsedLeft = parseVersionStamp(left);
|
||||
const parsedRight = parseVersionStamp(right);
|
||||
if (parsedLeft === null || parsedRight === null) return null;
|
||||
return parsedLeft - parsedRight;
|
||||
};
|
||||
|
||||
export const hasNewerLocalVersion = (game: Game): boolean =>
|
||||
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
|
||||
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
|
||||
|
||||
export const needsUpdate = (game: Game): boolean => {
|
||||
if (!game.installed) return false;
|
||||
if (game.peer_count <= 0) return false;
|
||||
if (!game.local_version && game.eti_game_version) return true;
|
||||
return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0;
|
||||
if (!game.installed) return false;
|
||||
if (game.peer_count <= 0) return false;
|
||||
if (!game.local_version && game.eti_game_version) return true;
|
||||
return (compareVersionStamps(game.eti_game_version, game.local_version) ??
|
||||
0) > 0;
|
||||
};
|
||||
|
||||
export const canStreamInstall = (game: Game): boolean =>
|
||||
!game.downloaded
|
||||
&& !game.installed
|
||||
&& game.peer_count > 0
|
||||
&& !isInProgress(game.install_status);
|
||||
export const canStreamInstall = (
|
||||
game: Game,
|
||||
catalogSupported: boolean,
|
||||
): boolean =>
|
||||
catalogSupported &&
|
||||
!game.downloaded &&
|
||||
!game.installed &&
|
||||
game.peer_count > 0 &&
|
||||
!isInProgress(game.install_status);
|
||||
|
||||
/** What pressing the card's main action button should do, given the state. */
|
||||
export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled';
|
||||
export type PrimaryAction =
|
||||
| "play"
|
||||
| "install"
|
||||
| "update"
|
||||
| "download"
|
||||
| "busy"
|
||||
| "disabled";
|
||||
|
||||
export const primaryActionFor = (game: Game): PrimaryAction => {
|
||||
if (isInProgress(game.install_status)) return 'busy';
|
||||
if (isUnavailable(game)) return 'disabled';
|
||||
if (!game.installed) return game.downloaded ? 'install' : 'download';
|
||||
if (needsUpdate(game)) return 'update';
|
||||
return 'play';
|
||||
if (isInProgress(game.install_status)) return "busy";
|
||||
if (isUnavailable(game)) return "disabled";
|
||||
if (!game.installed) return game.downloaded ? "install" : "download";
|
||||
if (needsUpdate(game)) return "update";
|
||||
return "play";
|
||||
};
|
||||
|
||||
export const formatBytesPerSecond = (bytesPerSecond: number): string => {
|
||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
let value = Math.max(0, bytesPerSecond);
|
||||
let unitIndex = 0;
|
||||
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
|
||||
let value = Math.max(0, bytesPerSecond);
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1000 && unitIndex < units.length - 1) {
|
||||
value /= 1000;
|
||||
unitIndex += 1;
|
||||
}
|
||||
while (value >= 1000 && unitIndex < units.length - 1) {
|
||||
value /= 1000;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`;
|
||||
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
||||
return `${value.toFixed(precision)} ${units[unitIndex]}`;
|
||||
if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`;
|
||||
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
||||
return `${value.toFixed(precision)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
@@ -180,137 +317,145 @@ const GB = 1024 * 1024 * 1024;
|
||||
const DECIMAL_MB = 1_000_000;
|
||||
|
||||
const stripTrailingDecimalZeros = (value: string): string =>
|
||||
value.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
|
||||
value.replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
|
||||
|
||||
export const downloadProgressPercent = (game: Game): number => {
|
||||
const progress = game.download_progress;
|
||||
if (!progress || progress.total_bytes <= 0) return 0;
|
||||
const progress = game.download_progress;
|
||||
if (!progress || progress.total_bytes <= 0) return 0;
|
||||
|
||||
return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100));
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100),
|
||||
);
|
||||
};
|
||||
|
||||
export const formatDownloadSpeed = (bytesPerSecond: number): string => {
|
||||
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
||||
return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`;
|
||||
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
||||
return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`;
|
||||
};
|
||||
|
||||
export const formatDownloadSpeedShort = (bytesPerSecond: number): string => {
|
||||
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
||||
return `${Math.round(mb)} MB/s`;
|
||||
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
||||
return `${Math.round(mb)} MB/s`;
|
||||
};
|
||||
|
||||
export const formatDownloadBytes = (bytes: number): string => {
|
||||
const safeBytes = Math.max(0, bytes);
|
||||
if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`;
|
||||
const safeBytes = Math.max(0, bytes);
|
||||
if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`;
|
||||
|
||||
const gb = safeBytes / GB;
|
||||
return `${stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))} GB`;
|
||||
const gb = safeBytes / GB;
|
||||
return `${
|
||||
stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))
|
||||
} GB`;
|
||||
};
|
||||
|
||||
export const formatDownloadEta = (seconds: number): string => {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '—';
|
||||
if (seconds < 60) return `${Math.round(seconds)} s`;
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "—";
|
||||
if (seconds < 60) return `${Math.round(seconds)} s`;
|
||||
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
|
||||
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
|
||||
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
|
||||
};
|
||||
|
||||
export const inProgressLabel = (game: Game): string | undefined => {
|
||||
switch (game.install_status) {
|
||||
case InstallStatus.CheckingPeers:
|
||||
return 'Checking peers…';
|
||||
case InstallStatus.Downloading:
|
||||
return game.download_progress
|
||||
? `Downloading… ${formatBytesPerSecond(game.download_progress.bytes_per_second)}`
|
||||
: 'Downloading…';
|
||||
case InstallStatus.Installing:
|
||||
return 'Installing…';
|
||||
case InstallStatus.Uninstalling:
|
||||
return 'Uninstalling…';
|
||||
case InstallStatus.Removing:
|
||||
return 'Removing…';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
switch (game.install_status) {
|
||||
case InstallStatus.Downloading:
|
||||
return game.download_progress
|
||||
? `Downloading… ${
|
||||
formatBytesPerSecond(game.download_progress.bytes_per_second)
|
||||
}`
|
||||
: "Downloading…";
|
||||
case InstallStatus.Installing:
|
||||
return "Installing…";
|
||||
case InstallStatus.Uninstalling:
|
||||
return "Uninstalling…";
|
||||
case InstallStatus.Removing:
|
||||
return "Removing…";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const actionLabel = (game: Game): string => {
|
||||
const busy = inProgressLabel(game);
|
||||
if (busy) return busy;
|
||||
if (isUnavailable(game)) return 'Unavailable';
|
||||
if (!game.installed) return game.downloaded ? 'Install' : 'Download';
|
||||
if (needsUpdate(game)) return 'Update';
|
||||
return 'Play';
|
||||
const busy = inProgressLabel(game);
|
||||
if (busy) return busy;
|
||||
if (isUnavailable(game)) return "Unavailable";
|
||||
if (!game.installed) return game.downloaded ? "Install" : "Download";
|
||||
if (needsUpdate(game)) return "Update";
|
||||
return "Play";
|
||||
};
|
||||
|
||||
/** Counts shown on filter pills. */
|
||||
export interface FilterCounts {
|
||||
all: number;
|
||||
local: number;
|
||||
installed: number;
|
||||
all: number;
|
||||
local: number;
|
||||
installed: number;
|
||||
}
|
||||
|
||||
const isDownloading = (game: Game): boolean =>
|
||||
game.install_status === InstallStatus.Downloading;
|
||||
game.install_status === InstallStatus.Downloading;
|
||||
|
||||
const isNetworkGame = (game: Game): boolean =>
|
||||
game.installed || game.downloaded || isDownloading(game) || game.peer_count > 0;
|
||||
game.installed || game.downloaded || isDownloading(game) ||
|
||||
game.peer_count > 0 || game.transfer_status === GameTransferStatus.Exhausted;
|
||||
|
||||
export const countByFilter = (games: Game[]): FilterCounts => ({
|
||||
all: games.filter(isNetworkGame).length,
|
||||
local: games.filter(g => g.installed || g.downloaded || isDownloading(g)).length,
|
||||
installed: games.filter(g => g.installed).length,
|
||||
all: games.filter(isNetworkGame).length,
|
||||
local:
|
||||
games.filter((g) => g.installed || g.downloaded || isDownloading(g)).length,
|
||||
installed: games.filter((g) => g.installed).length,
|
||||
});
|
||||
|
||||
const matchesFilter = (game: Game, filter: GameFilter): boolean => {
|
||||
switch (filter) {
|
||||
case 'local':
|
||||
return game.installed || game.downloaded || isDownloading(game);
|
||||
case 'installed':
|
||||
return game.installed;
|
||||
case 'all':
|
||||
return isNetworkGame(game);
|
||||
}
|
||||
switch (filter) {
|
||||
case "local":
|
||||
return game.installed || game.downloaded || isDownloading(game);
|
||||
case "installed":
|
||||
return game.installed;
|
||||
case "all":
|
||||
return isNetworkGame(game);
|
||||
}
|
||||
};
|
||||
|
||||
const STATE_SORT_ORDER: Record<DerivedState, number> = {
|
||||
installed: 0,
|
||||
local: 1,
|
||||
downloading: 2,
|
||||
busy: 3,
|
||||
none: 4,
|
||||
installed: 0,
|
||||
local: 1,
|
||||
downloading: 2,
|
||||
busy: 3,
|
||||
none: 4,
|
||||
};
|
||||
|
||||
const compareByState = (a: Game, b: Game): number => {
|
||||
const diff = STATE_SORT_ORDER[deriveState(a)] - STATE_SORT_ORDER[deriveState(b)];
|
||||
return diff !== 0 ? diff : a.name.localeCompare(b.name);
|
||||
const diff = STATE_SORT_ORDER[deriveState(a)] -
|
||||
STATE_SORT_ORDER[deriveState(b)];
|
||||
return diff !== 0 ? diff : a.name.localeCompare(b.name);
|
||||
};
|
||||
|
||||
export const applyFilterAndSort = (
|
||||
games: Game[],
|
||||
filter: GameFilter,
|
||||
sort: GameSort,
|
||||
query: string,
|
||||
games: Game[],
|
||||
filter: GameFilter,
|
||||
sort: GameSort,
|
||||
query: string,
|
||||
): Game[] => {
|
||||
let list = games.filter(g => matchesFilter(g, filter));
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(g =>
|
||||
g.name.toLowerCase().includes(q)
|
||||
|| (g.genre?.toLowerCase().includes(q) ?? false)
|
||||
|| (g.publisher?.toLowerCase().includes(q) ?? false),
|
||||
);
|
||||
}
|
||||
switch (sort) {
|
||||
case 'az':
|
||||
return [...list].sort((a, b) => a.name.localeCompare(b.name));
|
||||
case 'sizeDesc':
|
||||
return [...list].sort((a, b) => b.size - a.size);
|
||||
case 'sizeAsc':
|
||||
return [...list].sort((a, b) => a.size - b.size);
|
||||
case 'status':
|
||||
return [...list].sort(compareByState);
|
||||
}
|
||||
let list = games.filter((g) => matchesFilter(g, filter));
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter((g) =>
|
||||
g.name.toLowerCase().includes(q) ||
|
||||
(g.genre?.toLowerCase().includes(q) ?? false) ||
|
||||
(g.publisher?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
}
|
||||
switch (sort) {
|
||||
case "az":
|
||||
return [...list].sort((a, b) => a.name.localeCompare(b.name));
|
||||
case "sizeDesc":
|
||||
return [...list].sort((a, b) => b.size - a.size);
|
||||
case "sizeAsc":
|
||||
return [...list].sort((a, b) => a.size - b.size);
|
||||
case "status":
|
||||
return [...list].sort(compareByState);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export type IdentityDiagnostic = 'ephemeral';
|
||||
|
||||
export interface IdentityDiagnosticSnapshot {
|
||||
revision: number;
|
||||
diagnostic: IdentityDiagnostic | null;
|
||||
}
|
||||
|
||||
export const INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT: IdentityDiagnosticSnapshot = {
|
||||
revision: 0,
|
||||
diagnostic: null,
|
||||
};
|
||||
|
||||
export const EPHEMERAL_IDENTITY_NOTICE =
|
||||
"This installation's network identity could not be saved and will change the next time Lanspread starts.";
|
||||
|
||||
export const IDENTITY_STATUS_UNAVAILABLE_NOTICE =
|
||||
"This installation's network identity status could not be checked.";
|
||||
|
||||
export const newestIdentityDiagnosticSnapshot = (
|
||||
current: IdentityDiagnosticSnapshot,
|
||||
candidate: IdentityDiagnosticSnapshot,
|
||||
): IdentityDiagnosticSnapshot => candidate.revision > current.revision ? candidate : current;
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
AsyncOwner,
|
||||
type AsyncAdoptionScope,
|
||||
type AsyncRegistration,
|
||||
windowAsyncScope,
|
||||
} from './asyncOwnership';
|
||||
|
||||
export type LocalNetworkSharingPhase =
|
||||
| 'waitingForGameDirectory'
|
||||
| 'disabled'
|
||||
| 'enabling'
|
||||
| 'enabled'
|
||||
| 'disabling';
|
||||
|
||||
export type SharingPersistenceProblem = 'load' | 'save';
|
||||
|
||||
export interface LocalNetworkSharingSnapshot {
|
||||
revision: number;
|
||||
enabled: boolean;
|
||||
pendingTarget: boolean | null;
|
||||
phase: LocalNetworkSharingPhase;
|
||||
persistenceProblem: SharingPersistenceProblem | null;
|
||||
}
|
||||
|
||||
export const INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT: LocalNetworkSharingSnapshot = {
|
||||
revision: 0,
|
||||
enabled: false,
|
||||
pendingTarget: null,
|
||||
phase: 'disabled',
|
||||
persistenceProblem: 'load',
|
||||
};
|
||||
|
||||
export const newestLocalNetworkSharingSnapshot = (
|
||||
current: LocalNetworkSharingSnapshot,
|
||||
candidate: LocalNetworkSharingSnapshot,
|
||||
): LocalNetworkSharingSnapshot => candidate.revision > current.revision ? candidate : current;
|
||||
|
||||
export const displayedSharingTarget = (snapshot: LocalNetworkSharingSnapshot): boolean =>
|
||||
snapshot.pendingTarget ?? snapshot.enabled;
|
||||
|
||||
export const isLocalNetworkSharingActive = (snapshot: LocalNetworkSharingSnapshot): boolean =>
|
||||
snapshot.phase === 'enabled' && snapshot.pendingTarget !== false;
|
||||
|
||||
export const localNetworkSharingNotice = (
|
||||
snapshot: LocalNetworkSharingSnapshot,
|
||||
): string => {
|
||||
if (snapshot.persistenceProblem === 'load') {
|
||||
return 'Local network sharing is off because its setting could not be loaded.';
|
||||
}
|
||||
if (snapshot.persistenceProblem === 'save') {
|
||||
if (!snapshot.enabled && snapshot.phase === 'disabled') {
|
||||
return 'Local network sharing is off, but this setting could not be saved.';
|
||||
}
|
||||
return 'This Local network sharing setting could not be saved and may change after restart.';
|
||||
}
|
||||
if (snapshot.pendingTarget === false && snapshot.phase !== 'disabled') {
|
||||
return 'Stopping Local network sharing…';
|
||||
}
|
||||
if (snapshot.pendingTarget === true && snapshot.phase !== 'enabled') {
|
||||
return 'Starting Local network sharing…';
|
||||
}
|
||||
switch (snapshot.phase) {
|
||||
case 'waitingForGameDirectory':
|
||||
return 'Local network sharing will start after you choose a game folder.';
|
||||
case 'disabled':
|
||||
return 'Local network sharing is off. Nearby devices cannot browse or request games from this library.';
|
||||
case 'enabling':
|
||||
return 'Starting Local network sharing…';
|
||||
case 'enabled':
|
||||
return 'Local network sharing is on.';
|
||||
case 'disabling':
|
||||
return 'Stopping Local network sharing…';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Owns one listener/query lifecycle and serializes explicitly targeted sharing
|
||||
* mutations. Disposal closes admission and drains already-admitted invokes;
|
||||
* result publication remains guarded by the mounted owner.
|
||||
*/
|
||||
export class LocalNetworkSharingAsyncScope {
|
||||
private readonly owner: AsyncOwner;
|
||||
private mutationTail: Promise<void> = Promise.resolve();
|
||||
private mutationAdmissionOpen = true;
|
||||
private readonly releaseWindowOwnership: () => void;
|
||||
|
||||
public constructor(
|
||||
reportCleanupFailure: (error: unknown) => void = () => {},
|
||||
adoptionScope: AsyncAdoptionScope = windowAsyncScope,
|
||||
) {
|
||||
this.owner = new AsyncOwner(reportCleanupFailure, adoptionScope);
|
||||
this.releaseWindowOwnership = adoptionScope.registerDisposer(
|
||||
() => this.disposeForWindowClose(),
|
||||
) ?? (() => {});
|
||||
}
|
||||
|
||||
public isActive(): boolean {
|
||||
return this.owner.isActive() && this.mutationAdmissionOpen;
|
||||
}
|
||||
|
||||
public guard<Args extends unknown[]>(
|
||||
callback: (...args: Args) => void,
|
||||
): (...args: Args) => void {
|
||||
return this.owner.guard(callback);
|
||||
}
|
||||
|
||||
public registerListener(registration: AsyncRegistration): Promise<boolean> {
|
||||
return this.owner.register(registration);
|
||||
}
|
||||
|
||||
public applyIfActive<T>(
|
||||
operation: () => Promise<T>,
|
||||
apply: (value: T) => void,
|
||||
): Promise<boolean> {
|
||||
return this.owner.applyIfActive(operation, apply);
|
||||
}
|
||||
|
||||
public applyMutationIfActive<T>(
|
||||
operation: () => Promise<T>,
|
||||
apply: (value: T) => void,
|
||||
): Promise<boolean> {
|
||||
if (!this.isActive()) return Promise.resolve(false);
|
||||
|
||||
const mutation = this.mutationTail.then(async () => {
|
||||
const value = await operation();
|
||||
if (!this.owner.isActive()) return false;
|
||||
apply(value);
|
||||
return true;
|
||||
});
|
||||
this.mutationTail = mutation.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return mutation;
|
||||
}
|
||||
|
||||
public dispose(): Promise<void> {
|
||||
this.releaseWindowOwnership();
|
||||
this.mutationAdmissionOpen = false;
|
||||
return Promise.all([this.mutationTail, this.owner.dispose()]).then(() => {});
|
||||
}
|
||||
|
||||
private disposeForWindowClose(): Promise<void> {
|
||||
this.releaseWindowOwnership();
|
||||
this.mutationAdmissionOpen = false;
|
||||
return Promise.all([
|
||||
this.mutationTail,
|
||||
this.owner.disposeForWindowClose(),
|
||||
]).then(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type ProtocolMismatch } from "./types";
|
||||
|
||||
export interface ProtocolMismatchSnapshot {
|
||||
revision: number;
|
||||
mismatch: ProtocolMismatch | null;
|
||||
}
|
||||
|
||||
export const INITIAL_PROTOCOL_MISMATCH_SNAPSHOT: ProtocolMismatchSnapshot = {
|
||||
revision: 0,
|
||||
mismatch: null,
|
||||
};
|
||||
|
||||
/** Keeps listener events and bootstrap queries monotonic across async races. */
|
||||
export const newestProtocolMismatchSnapshot = (
|
||||
current: ProtocolMismatchSnapshot,
|
||||
candidate: ProtocolMismatchSnapshot,
|
||||
): ProtocolMismatchSnapshot =>
|
||||
candidate.revision > current.revision ? candidate : current;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type AsyncAdoptionScope } from './asyncOwnership';
|
||||
|
||||
export type ThumbnailLoader = (id: string) => Promise<string>;
|
||||
export type ThumbnailPublisher = (id: string, url: string) => void;
|
||||
|
||||
export const createThumbnailRequestKey = (ids: readonly string[]): string =>
|
||||
JSON.stringify([...new Set(ids)].sort());
|
||||
|
||||
export const thumbnailIdsFromRequestKey = (key: string): string[] =>
|
||||
JSON.parse(key) as string[];
|
||||
|
||||
/**
|
||||
* Owns one effect generation of thumbnail requests. Loading can only begin via
|
||||
* `start`, and disposing the generation prevents both later starts and late
|
||||
* publications from requests that are already in flight.
|
||||
*/
|
||||
export class ThumbnailRequestGeneration {
|
||||
private disposed = false;
|
||||
private readonly pending = new Set<Promise<void>>();
|
||||
|
||||
constructor(
|
||||
private readonly load: ThumbnailLoader,
|
||||
private readonly publish: ThumbnailPublisher,
|
||||
) {}
|
||||
|
||||
start(ids: readonly string[]): Promise<void> {
|
||||
if (this.disposed) return Promise.resolve();
|
||||
|
||||
const loading = Promise.all(ids.map(id => this.loadOne(id))).then(() => {});
|
||||
this.pending.add(loading);
|
||||
void loading.then(
|
||||
() => this.pending.delete(loading),
|
||||
() => this.pending.delete(loading),
|
||||
);
|
||||
return loading;
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
this.disposed = true;
|
||||
return this.drain();
|
||||
}
|
||||
|
||||
private async drain(): Promise<void> {
|
||||
while (this.pending.size > 0) {
|
||||
await Promise.allSettled([...this.pending]);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadOne(id: string): Promise<void> {
|
||||
let url: string;
|
||||
try {
|
||||
url = await this.load(id);
|
||||
} catch {
|
||||
url = '';
|
||||
}
|
||||
|
||||
if (!this.disposed) this.publish(id, url);
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers the generation with window-close admission before any load starts. */
|
||||
export const startOwnedThumbnailGeneration = (
|
||||
scope: AsyncAdoptionScope,
|
||||
generation: ThumbnailRequestGeneration,
|
||||
ids: readonly string[],
|
||||
): (() => void) => {
|
||||
const releaseWindowOwnership = scope.registerDisposer(() => generation.dispose());
|
||||
if (releaseWindowOwnership === undefined) {
|
||||
scope.adopt(generation.dispose());
|
||||
return () => {};
|
||||
}
|
||||
|
||||
scope.adopt(generation.start(ids));
|
||||
return () => {
|
||||
releaseWindowOwnership();
|
||||
scope.adopt(generation.dispose());
|
||||
};
|
||||
};
|
||||
@@ -1,137 +1,183 @@
|
||||
export enum InstallStatus {
|
||||
NotInstalled = 'NotInstalled',
|
||||
CheckingPeers = 'CheckingPeers',
|
||||
Downloading = 'Downloading',
|
||||
Installing = 'Installing',
|
||||
Uninstalling = 'Uninstalling',
|
||||
Removing = 'Removing',
|
||||
Installed = 'Installed',
|
||||
NotInstalled = "NotInstalled",
|
||||
Downloading = "Downloading",
|
||||
Installing = "Installing",
|
||||
Uninstalling = "Uninstalling",
|
||||
Removing = "Removing",
|
||||
Installed = "Installed",
|
||||
}
|
||||
|
||||
export enum GameAvailability {
|
||||
Ready = 'Ready',
|
||||
LocalOnly = 'LocalOnly',
|
||||
Ready = "Ready",
|
||||
LocalOnly = "LocalOnly",
|
||||
}
|
||||
|
||||
export enum ActiveOperationKind {
|
||||
Downloading = 'Downloading',
|
||||
Installing = 'Installing',
|
||||
Updating = 'Updating',
|
||||
Uninstalling = 'Uninstalling',
|
||||
RemovingDownload = 'RemovingDownload',
|
||||
Downloading = "Downloading",
|
||||
Installing = "Installing",
|
||||
Updating = "Updating",
|
||||
Uninstalling = "Uninstalling",
|
||||
RemovingDownload = "RemovingDownload",
|
||||
}
|
||||
|
||||
export type StatusLevel = 'info' | 'warning' | 'error';
|
||||
export type StatusLevel = "info" | "warning" | "error";
|
||||
|
||||
export enum GameTransferStatus {
|
||||
Verifying = "verifying",
|
||||
Retrying = "retrying",
|
||||
Exhausted = "exhausted",
|
||||
}
|
||||
|
||||
export interface GameTransferStatusSnapshot {
|
||||
revision: number;
|
||||
statuses: Record<string, GameTransferStatus>;
|
||||
openAttempts: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
downloaded_bytes: number;
|
||||
total_bytes: number;
|
||||
bytes_per_second: number;
|
||||
active_peer_count: number;
|
||||
attemptId: string;
|
||||
downloaded_bytes: number;
|
||||
total_bytes: number;
|
||||
bytes_per_second: number;
|
||||
active_peer_count: number;
|
||||
}
|
||||
|
||||
export interface DownloadProgressPayload extends DownloadProgress {
|
||||
id: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Bytes. */
|
||||
size: number;
|
||||
/** Raw bytes — unused in UI, kept for parity with backend payload. */
|
||||
thumbnail?: Uint8Array | number[];
|
||||
downloaded: boolean;
|
||||
installed: boolean;
|
||||
availability: GameAvailability;
|
||||
install_status: InstallStatus;
|
||||
eti_game_version?: string;
|
||||
local_version?: string;
|
||||
/** Optional richer metadata surfaced by the backend. */
|
||||
release_year?: string;
|
||||
publisher?: string;
|
||||
max_players?: number;
|
||||
version?: string;
|
||||
genre?: string;
|
||||
status_message?: string;
|
||||
status_level?: StatusLevel;
|
||||
download_progress?: DownloadProgress;
|
||||
peer_count: number;
|
||||
can_host_server?: boolean;
|
||||
active_outbound_transfers?: number;
|
||||
installed_peer_count?: number;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Bytes. */
|
||||
size: number;
|
||||
/** Raw bytes — unused in UI, kept for parity with backend payload. */
|
||||
thumbnail?: Uint8Array | number[];
|
||||
downloaded: boolean;
|
||||
installed: boolean;
|
||||
availability: GameAvailability;
|
||||
install_status: InstallStatus;
|
||||
eti_game_version?: string;
|
||||
local_version?: string;
|
||||
/** Optional richer metadata surfaced by the backend. */
|
||||
release_year?: string;
|
||||
publisher?: string;
|
||||
max_players?: number;
|
||||
version?: string;
|
||||
genre?: string;
|
||||
status_message?: string;
|
||||
status_level?: StatusLevel;
|
||||
transfer_status?: GameTransferStatus;
|
||||
download_progress?: DownloadProgress;
|
||||
peer_count: number;
|
||||
can_host_server?: boolean;
|
||||
active_outbound_transfers?: number;
|
||||
installed_peer_count?: number;
|
||||
}
|
||||
|
||||
export interface ActiveOperation {
|
||||
id: string;
|
||||
operation: ActiveOperationKind;
|
||||
id: string;
|
||||
operation: ActiveOperationKind;
|
||||
}
|
||||
|
||||
export interface GamesListPayload {
|
||||
games: Game[];
|
||||
active_operations?: ActiveOperation[];
|
||||
games: Game[];
|
||||
active_operations?: ActiveOperation[];
|
||||
transfer_status: GameTransferStatusSnapshot;
|
||||
}
|
||||
|
||||
export interface ProtocolMismatch {
|
||||
observed: number | null;
|
||||
expected: number;
|
||||
}
|
||||
|
||||
/** Library filter chip — what subset of the catalog to show. */
|
||||
export type GameFilter = 'all' | 'local' | 'installed';
|
||||
export type GameFilter = "all" | "local" | "installed";
|
||||
|
||||
/** Library sort order. */
|
||||
export type GameSort = 'az' | 'sizeDesc' | 'sizeAsc' | 'status';
|
||||
export type GameSort = "az" | "sizeDesc" | "sizeAsc" | "status";
|
||||
|
||||
/** Visual state of a card. Derived from backend operation status and local flags. */
|
||||
export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'busy';
|
||||
export type DerivedState =
|
||||
| "installed"
|
||||
| "local"
|
||||
| "downloading"
|
||||
| "none"
|
||||
| "busy";
|
||||
|
||||
/** Two-character language code passed through to game scripts. */
|
||||
export type LauncherLanguage = 'en' | 'de';
|
||||
export type LauncherLanguage = "en" | "de";
|
||||
|
||||
export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending';
|
||||
export type CallToPlayParticipantStatus = "ready" | "in" | "pending";
|
||||
|
||||
export interface CallToPlayParticipant {
|
||||
name: string;
|
||||
status: CallToPlayParticipantStatus;
|
||||
joinedAt: number;
|
||||
readyAt?: number;
|
||||
name: string;
|
||||
status: CallToPlayParticipantStatus;
|
||||
joinedAt: number;
|
||||
readyAt?: number;
|
||||
}
|
||||
|
||||
export interface CallToPlayMessage {
|
||||
id: string;
|
||||
fromId: string;
|
||||
from: string;
|
||||
text: string;
|
||||
at: number;
|
||||
id: string;
|
||||
fromId: string;
|
||||
from: string;
|
||||
text: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface Nomination {
|
||||
id: string;
|
||||
gameId: string;
|
||||
creatorId: string;
|
||||
creator: string;
|
||||
maxPlayers: number;
|
||||
createdAt: number;
|
||||
scheduledFor: number | null;
|
||||
deadline: number;
|
||||
participants: Record<string, CallToPlayParticipant>;
|
||||
messages: CallToPlayMessage[];
|
||||
state: 'open' | 'done' | 'running' | 'cancelled';
|
||||
terminalAt: number | null;
|
||||
id: string;
|
||||
gameId: string;
|
||||
creatorId: string;
|
||||
creator: string;
|
||||
maxPlayers: number;
|
||||
createdAt: number;
|
||||
scheduledFor: number | null;
|
||||
deadline: number;
|
||||
participants: Record<string, CallToPlayParticipant>;
|
||||
messages: CallToPlayMessage[];
|
||||
state: "open" | "done" | "running" | "cancelled";
|
||||
terminalAt: number | null;
|
||||
}
|
||||
|
||||
export type CallToPlayAction =
|
||||
| { Create: { game_id: string; max_players: number; scheduled_for: number | null; deadline: number } }
|
||||
| { Respond: { ready_at: number | null } }
|
||||
| 'Rsvp'
|
||||
| { SendMessage: { message_id: string; text: string } }
|
||||
| 'Leave'
|
||||
| 'Cancel'
|
||||
| 'Start'
|
||||
| { AddTime: { deadline: number } };
|
||||
| {
|
||||
Create: {
|
||||
game_id: string;
|
||||
max_players: number;
|
||||
scheduled_for: number | null;
|
||||
deadline: number;
|
||||
};
|
||||
}
|
||||
| { Respond: { ready_at: number | null } }
|
||||
| "Rsvp"
|
||||
| { SendMessage: { text: string } }
|
||||
| "Leave"
|
||||
| "Cancel"
|
||||
| "Start"
|
||||
| { AddTime: { deadline: number } };
|
||||
|
||||
export interface CallToPlayEvent {
|
||||
id: string;
|
||||
call_id: string;
|
||||
actor_id: string;
|
||||
actor_name: string;
|
||||
at: number;
|
||||
action: CallToPlayAction;
|
||||
export interface CallToPlayViewEvent {
|
||||
id: string;
|
||||
call_id: string;
|
||||
author_id: string;
|
||||
author_name: string;
|
||||
at: number;
|
||||
action: CallToPlayAction;
|
||||
}
|
||||
|
||||
export interface CallToPlayView {
|
||||
events: CallToPlayViewEvent[];
|
||||
}
|
||||
|
||||
export interface CallToPlayLocalIntent {
|
||||
call_id: string | null;
|
||||
action: CallToPlayAction;
|
||||
}
|
||||
|
||||
export interface CallToPlayReceipt {
|
||||
call_id: string;
|
||||
event_id: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
import App from "./App";
|
||||
import { windowAsyncScope } from "./lib/asyncOwnership";
|
||||
import { bootstrapFrontend } from "./lib/frontendBootstrap";
|
||||
import { windowPersistenceScope } from "./lib/frontendPersistence";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/launcher.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<App />
|
||||
);
|
||||
const render = () => {
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<App />
|
||||
);
|
||||
};
|
||||
|
||||
const currentWindow = getCurrentWindow();
|
||||
|
||||
// This is the JavaScript realm's terminal task. bootstrapFrontend handles every
|
||||
// failure path and does not mount the app unless close ownership is installed.
|
||||
void bootstrapFrontend({
|
||||
registerCloseRequested: handler => currentWindow.onCloseRequested(handler),
|
||||
disposeOwners: () => windowAsyncScope.disposeOwned(),
|
||||
drainPersistence: () => windowPersistenceScope.closeAndDrain(),
|
||||
destroyWindow: () => currentWindow.destroy(),
|
||||
render,
|
||||
reportFailure: (message, error) => console.error(message, error),
|
||||
}).catch(error => {
|
||||
// The helper handles expected bootstrap and teardown failures. Keep this
|
||||
// terminal observer for programming errors at the JavaScript realm root.
|
||||
console.error('Unexpected frontend bootstrap failure:', error);
|
||||
});
|
||||
|
||||
@@ -19,24 +19,24 @@
|
||||
.bg-gradient {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 80% 50% at 50% -10%,
|
||||
color-mix(in srgb, var(--accent) 22%, transparent) 0%,
|
||||
transparent 60%
|
||||
),
|
||||
ellipse 80% 50% at 50% -10%,
|
||||
color-mix(in srgb, var(--accent) 22%, transparent) 0%,
|
||||
transparent 60%
|
||||
),
|
||||
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
|
||||
}
|
||||
.bg-animated {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 60% 40% at 20% 0%,
|
||||
color-mix(in srgb, var(--accent) 24%, transparent) 0%,
|
||||
transparent 55%
|
||||
),
|
||||
ellipse 60% 40% at 20% 0%,
|
||||
color-mix(in srgb, var(--accent) 24%, transparent) 0%,
|
||||
transparent 55%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 55% 40% at 85% 8%,
|
||||
color-mix(in srgb, var(--accent) 16%, transparent) 0%,
|
||||
transparent 55%
|
||||
),
|
||||
ellipse 55% 40% at 85% 8%,
|
||||
color-mix(in srgb, var(--accent) 16%, transparent) 0%,
|
||||
transparent 55%
|
||||
),
|
||||
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
|
||||
background-size: 145% 130%, 140% 125%, 100% 100%;
|
||||
animation: bgshift 22s ease-in-out infinite alternate;
|
||||
@@ -474,6 +474,28 @@
|
||||
padding: 4px 4px 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
.network-notice {
|
||||
margin: 0 4px 16px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--warn) 45%, var(--bd-1));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--warn) 12%, var(--bg-2));
|
||||
color: var(--t-1);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.network-notice-sharing.is-enabled {
|
||||
border-color: color-mix(in srgb, var(--ok) 40%, var(--bd-1));
|
||||
background: color-mix(in srgb, var(--ok) 9%, var(--bg-2));
|
||||
}
|
||||
.network-notice-sharing.is-disabled,
|
||||
.network-notice.is-error {
|
||||
border-color: color-mix(in srgb, var(--danger) 42%, var(--bd-1));
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
|
||||
}
|
||||
.identity-notice {
|
||||
border-color: color-mix(in srgb, var(--warn) 45%, var(--bd-1));
|
||||
}
|
||||
.results-count {
|
||||
color: var(--t-2);
|
||||
font-size: 12.5px;
|
||||
@@ -582,15 +604,15 @@
|
||||
.cover-grain {
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.018) 0 1px,
|
||||
transparent 1px 3px
|
||||
),
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.018) 0 1px,
|
||||
transparent 1px 3px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.04) 0 1px,
|
||||
transparent 1px 3px
|
||||
);
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.04) 0 1px,
|
||||
transparent 1px 3px
|
||||
);
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -982,6 +1004,18 @@
|
||||
align-items: center;
|
||||
color: var(--t-1);
|
||||
}
|
||||
.dl-md-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: var(--t-1);
|
||||
}
|
||||
.dl-md-status > span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-md .dl-pulse {
|
||||
margin-right: 6px;
|
||||
}
|
||||
@@ -1066,6 +1100,9 @@
|
||||
color: color-mix(in srgb, var(--accent) 80%, white);
|
||||
}
|
||||
.dl-lg-primary .dl-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-lg-secondary {
|
||||
@@ -1510,6 +1547,59 @@
|
||||
.settings-row-control {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.settings-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
.settings-switch input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.settings-switch-track {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--bd-3);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-4);
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.settings-switch-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--t-2);
|
||||
transition: transform 120ms ease, background 120ms ease;
|
||||
}
|
||||
.settings-switch.is-checked .settings-switch-track {
|
||||
border-color: color-mix(in srgb, var(--accent) 75%, var(--bd-2));
|
||||
background: color-mix(in srgb, var(--accent) 55%, var(--bg-3));
|
||||
}
|
||||
.settings-switch.is-checked .settings-switch-thumb {
|
||||
transform: translateX(18px);
|
||||
background: white;
|
||||
}
|
||||
.settings-switch input:focus-visible + .settings-switch-track {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 65%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.settings-switch:has(input:disabled) {
|
||||
cursor: wait;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.settings-inline-error {
|
||||
margin-top: -6px;
|
||||
color: var(--danger);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.settings-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1791,52 +1881,67 @@
|
||||
/* ─── Top-bar entry button ─── */
|
||||
.ctp-btn {
|
||||
position: relative;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
height: 36px; padding: 0 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
|
||||
border-radius: 8px;
|
||||
color: var(--t-1);
|
||||
font: inherit; font-size: 12.5px; font-weight: 700;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.ctp-btn svg { color: var(--accent); flex-shrink: 0; }
|
||||
.ctp-btn svg {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ctp-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
|
||||
border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2));
|
||||
}
|
||||
.ctp-badge {
|
||||
display: inline-grid; place-items: center;
|
||||
min-width: 18px; height: 18px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-size: 10.5px; font-weight: 800;
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ─── Ticker strip ─── */
|
||||
.ctp-ticker-stack {
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.ctp-ticker-stack .ctp-ticker { margin-bottom: 0; }
|
||||
.ctp-ticker-stack .ctp-ticker {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.ctp-ticker {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
10px /* LED */
|
||||
98px /* status */
|
||||
220px /* game */
|
||||
130px /* by */
|
||||
88px /* count */
|
||||
170px /* time */
|
||||
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
|
||||
max-content /* bubbles */
|
||||
82px; /* cta */
|
||||
10px /* LED */
|
||||
98px /* status */
|
||||
220px /* game */
|
||||
130px /* by */
|
||||
88px /* count */
|
||||
170px /* time */
|
||||
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
|
||||
max-content /* bubbles */
|
||||
82px; /* cta */
|
||||
align-items: center;
|
||||
column-gap: 12px;
|
||||
width: 100%;
|
||||
@@ -1845,22 +1950,34 @@
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
|
||||
border-radius: 10px;
|
||||
color: var(--t-1);
|
||||
font: inherit; font-size: 12.5px;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.ctp-ticker:hover { background: color-mix(in srgb, var(--accent) 16%, var(--bg-2)); }
|
||||
.ctp-ticker:hover {
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--bg-2));
|
||||
}
|
||||
.ctp-ticker-dot {
|
||||
width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
|
||||
animation: ctp-tickerpulse 1.6s ease-out infinite;
|
||||
}
|
||||
@keyframes ctp-tickerpulse {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
|
||||
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); }
|
||||
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
|
||||
}
|
||||
}
|
||||
.ctp-ticker-label {
|
||||
font-weight: 700;
|
||||
@@ -1870,36 +1987,87 @@
|
||||
letter-spacing: 0.06em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ctp-ticker-game { font-weight: 700; color: var(--t-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ctp-ticker-by { color: var(--t-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ctp-ticker-sep { display: none; }
|
||||
.ctp-ticker-ready, .ctp-ticker-time { color: var(--t-2); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ctp-ticker-game {
|
||||
font-weight: 700;
|
||||
color: var(--t-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ctp-ticker-by {
|
||||
color: var(--t-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ctp-ticker-sep {
|
||||
display: none;
|
||||
}
|
||||
.ctp-ticker-ready,
|
||||
.ctp-ticker-time {
|
||||
color: var(--t-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ctp-ticker-more {
|
||||
color: var(--t-3);
|
||||
font-size: 11.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ctp-ticker-cta {
|
||||
display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.ctp-ticker-cta svg { transform: rotate(-90deg); }
|
||||
.ctp-ticker-cta svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* ─── Overlay modal ─── */
|
||||
.ctp-modal { width: min(720px, 100%); }
|
||||
.ctp-modal {
|
||||
width: min(720px, 100%);
|
||||
}
|
||||
.ctp-head-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-right: 36px;
|
||||
}
|
||||
.ctp-head-new { height: 40px; padding: 0 18px; font-size: 13px; margin-top: 14px; width: 100%; }
|
||||
.ctp-head { padding: 26px 28px 14px; border-bottom: 1px solid var(--bd-1); }
|
||||
.ctp-head h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; color: var(--t-1); }
|
||||
.ctp-head-sub { margin: 6px 0 0; font-size: 12.5px; color: var(--t-3); max-width: 52ch; }
|
||||
.ctp-head-new {
|
||||
height: 40px;
|
||||
padding: 0 18px;
|
||||
font-size: 13px;
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
.ctp-head {
|
||||
padding: 26px 28px 14px;
|
||||
border-bottom: 1px solid var(--bd-1);
|
||||
}
|
||||
.ctp-head h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--t-1);
|
||||
}
|
||||
.ctp-head-sub {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--t-3);
|
||||
max-width: 52ch;
|
||||
}
|
||||
.ctp-body {
|
||||
padding: 18px 24px 24px;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-height: 66vh;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -1912,42 +2080,86 @@
|
||||
|
||||
/* ─── Nomination card ─── */
|
||||
.ctp-card {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: rgba(255,255,255,0.025);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
|
||||
.ctp-card.is-expired { border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); }
|
||||
.ctp-card.is-terminal { opacity: 0.72; }
|
||||
.ctp-card.is-running { border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2)); }
|
||||
.ctp-card.is-cancelled { border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2)); }
|
||||
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
|
||||
.ctp-card.is-done {
|
||||
border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2));
|
||||
}
|
||||
.ctp-card.is-expired {
|
||||
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
|
||||
}
|
||||
.ctp-card.is-terminal {
|
||||
opacity: 0.72;
|
||||
}
|
||||
.ctp-card.is-running {
|
||||
border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2));
|
||||
}
|
||||
.ctp-card.is-cancelled {
|
||||
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2));
|
||||
}
|
||||
.ctp-card.is-focused {
|
||||
border-color: var(--accent);
|
||||
animation: ctp-cardflash 1.4s ease-out 1;
|
||||
}
|
||||
@keyframes ctp-cardflash {
|
||||
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
|
||||
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
|
||||
0% {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
|
||||
}
|
||||
}
|
||||
.ctp-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
}
|
||||
.ctp-card-top { display: flex; align-items: flex-start; gap: 12px; position: relative; }
|
||||
.ctp-card-cover {
|
||||
position: relative;
|
||||
width: 52px; height: 52px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ctp-card-cover-missing {
|
||||
width: 100%; height: 100%;
|
||||
display: grid; place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--t-3);
|
||||
background: color-mix(in srgb, var(--bd-2) 55%, transparent);
|
||||
border: 1px dashed var(--bd-2);
|
||||
}
|
||||
.ctp-card-cover-missing svg { width: 22px; height: 22px; }
|
||||
.ctp-card-info { flex: 1; min-width: 0; }
|
||||
.ctp-card-title { font-size: 14.5px; font-weight: 700; color: var(--t-1); }
|
||||
.ctp-card-sub { margin-top: 3px; font-size: 11.5px; color: var(--t-3); }
|
||||
.ctp-card-sub strong { color: var(--t-2); font-weight: 700; }
|
||||
.ctp-card-cover-missing svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.ctp-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ctp-card-title {
|
||||
font-size: 14.5px;
|
||||
font-weight: 700;
|
||||
color: var(--t-1);
|
||||
}
|
||||
.ctp-card-sub {
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
color: var(--t-3);
|
||||
}
|
||||
.ctp-card-sub strong {
|
||||
color: var(--t-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
.ctp-card-timer {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 18px;
|
||||
@@ -1956,25 +2168,45 @@
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); }
|
||||
.ctp-card-timer[data-urgency="high"] { color: var(--danger); }
|
||||
.ctp-card.is-done .ctp-card-timer { color: var(--ok); font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.ctp-card.is-expired .ctp-card-timer { color: var(--danger); }
|
||||
.ctp-card-timer[data-urgency="mid"] {
|
||||
color: var(--warn);
|
||||
}
|
||||
.ctp-card-timer[data-urgency="high"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
.ctp-card.is-done .ctp-card-timer {
|
||||
color: var(--ok);
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.ctp-card.is-expired .ctp-card-timer {
|
||||
color: var(--danger);
|
||||
}
|
||||
.ctp-cancel-link {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
margin-top: -2px;
|
||||
padding: 6px 2px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--t-3);
|
||||
font: inherit; font-size: 11.5px; font-weight: 600;
|
||||
font: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color .15s;
|
||||
}
|
||||
.ctp-cancel-link:hover { color: #fca5a5; }
|
||||
.ctp-cancel-link:hover {
|
||||
color: #fca5a5;
|
||||
}
|
||||
.ctp-cancel-confirm {
|
||||
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(239,68,68,0.08);
|
||||
@@ -1984,8 +2216,16 @@
|
||||
color: #fca5a5;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ctp-cancel-confirm-btns { display: inline-flex; gap: 8px; flex-shrink: 0; }
|
||||
.ctp-cancel-confirm-btns .ghost-btn { height: 32px; padding: 0 12px; font-size: 12px; }
|
||||
.ctp-cancel-confirm-btns {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ctp-cancel-confirm-btns .ghost-btn {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ctp-progress {
|
||||
height: 5px;
|
||||
@@ -1993,37 +2233,66 @@
|
||||
background: rgba(255,255,255,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ctp-progress-fill { height: 100%; transition: width 1s linear; }
|
||||
.ctp-progress-fill {
|
||||
height: 100%;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
.ctp-roster { display: flex; flex-direction: column; gap: 8px; }
|
||||
.ctp-roster-count { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--t-3); }
|
||||
.ctp-avatars { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.ctp-roster {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.ctp-roster-count {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--t-3);
|
||||
}
|
||||
.ctp-avatars {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.ctp-avatar {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.ctp-avatar-dot {
|
||||
width: 30px; height: 30px;
|
||||
display: grid; place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
color: white;
|
||||
font-size: 10.5px; font-weight: 800;
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.ctp-avatar.is-pending .ctp-avatar-dot { opacity: 0.55; }
|
||||
.ctp-avatar.is-pending .ctp-avatar-dot {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.ctp-avatar-check {
|
||||
position: absolute; bottom: -2px; right: -2px;
|
||||
width: 14px; height: 14px;
|
||||
display: grid; place-items: center;
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--ok);
|
||||
color: #06240f;
|
||||
border: 2px solid var(--bg-2);
|
||||
}
|
||||
.ctp-avatar-pending {
|
||||
position: absolute; bottom: -6px; left: 50%;
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 9px; font-weight: 700;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--t-2);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-2);
|
||||
@@ -2033,64 +2302,122 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ctp-avatar-empty {
|
||||
width: 30px; height: 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px dashed var(--bd-2);
|
||||
}
|
||||
|
||||
.ctp-actions {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.ctp-actions .act-btn { height: 36px; padding: 0 16px; font-size: 12.5px; }
|
||||
.ctp-actions .ghost-btn { height: 36px; padding: 0 14px; font-size: 12.5px; }
|
||||
.ctp-note { font-size: 12.5px; color: var(--t-3); }
|
||||
.ctp-note-launch { color: var(--ok); font-weight: 600; }
|
||||
.ctp-me-status { font-size: 12.5px; font-weight: 600; color: var(--t-2); }
|
||||
.ctp-buffer-group { display: inline-flex; gap: 6px; }
|
||||
.ctp-actions .act-btn {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.ctp-actions .ghost-btn {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.ctp-note {
|
||||
font-size: 12.5px;
|
||||
color: var(--t-3);
|
||||
}
|
||||
.ctp-note-launch {
|
||||
color: var(--ok);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ctp-me-status {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--t-2);
|
||||
}
|
||||
.ctp-buffer-group {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.ctp-buffer-btn {
|
||||
height: 36px; padding: 0 11px;
|
||||
height: 36px;
|
||||
padding: 0 11px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid var(--bd-2);
|
||||
border-radius: 7px;
|
||||
color: var(--t-1);
|
||||
font: inherit; font-size: 12px; font-weight: 600;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.ctp-buffer-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); }
|
||||
.ctp-buffer-btn:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-color: var(--bd-3);
|
||||
}
|
||||
|
||||
/* ─── Create-nomination form ─── */
|
||||
.ctp-create-cta {
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 48px;
|
||||
background: transparent;
|
||||
border: 1.5px dashed var(--bd-3);
|
||||
border-radius: 12px;
|
||||
color: var(--t-2);
|
||||
font: inherit; font-size: 13px; font-weight: 700;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, color .15s, background .15s;
|
||||
}
|
||||
.ctp-create-cta:hover { border-color: var(--accent); color: var(--t-1); background: rgba(255,255,255,0.02); }
|
||||
.ctp-create-cta:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--t-1);
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
.ctp-create {
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid var(--bd-2);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.ctp-create-row { display: flex; flex-direction: column; gap: 6px; position: relative; }
|
||||
.ctp-create-row-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.ctp-create-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
.ctp-create-row-inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ctp-create-label {
|
||||
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--t-3);
|
||||
}
|
||||
.ctp-create-search { width: 100%; }
|
||||
.ctp-create-search {
|
||||
width: 100%;
|
||||
}
|
||||
.ctp-create-matches {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0; right: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
@@ -2101,61 +2428,111 @@
|
||||
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
|
||||
}
|
||||
.ctp-create-matches button {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: var(--t-1);
|
||||
font: inherit; font-size: 12.5px; font-weight: 600;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ctp-create-matches button:hover { background: rgba(255,255,255,0.06); }
|
||||
.ctp-create-match-meta { color: var(--t-3); font-size: 11px; font-weight: 500; }
|
||||
.ctp-create-nomatch { padding: 10px; font-size: 12px; color: var(--t-3); }
|
||||
.ctp-create-matches button:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
.ctp-create-match-meta {
|
||||
color: var(--t-3);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ctp-create-nomatch {
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--t-3);
|
||||
}
|
||||
.ctp-create-num {
|
||||
width: 70px; height: 34px;
|
||||
width: 70px;
|
||||
height: 34px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 7px;
|
||||
color: var(--t-1);
|
||||
font: inherit; font-size: 13px; font-weight: 600;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.ctp-create-hint { font-size: 11.5px; color: var(--t-3); }
|
||||
.ctp-duration-opts { display: inline-flex; gap: 6px; flex-wrap: wrap; }
|
||||
.ctp-create-hint {
|
||||
font-size: 11.5px;
|
||||
color: var(--t-3);
|
||||
}
|
||||
.ctp-duration-opts {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ctp-duration-btn {
|
||||
height: 32px; padding: 0 13px;
|
||||
height: 32px;
|
||||
padding: 0 13px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 7px;
|
||||
color: var(--t-2);
|
||||
font: inherit; font-size: 12.5px; font-weight: 700;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
.ctp-duration-btn:hover { color: var(--t-1); }
|
||||
.ctp-duration-btn.is-active { color: white; }
|
||||
.ctp-duration-btn:hover {
|
||||
color: var(--t-1);
|
||||
}
|
||||
.ctp-duration-btn.is-active {
|
||||
color: white;
|
||||
}
|
||||
.ctp-create-foot {
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.ctp-create-foot .act-btn,
|
||||
.ctp-create-foot .ghost-btn { height: 40px; padding: 0 18px; }
|
||||
.ctp-time-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
|
||||
.ctp-create-foot .ghost-btn {
|
||||
height: 40px;
|
||||
padding: 0 18px;
|
||||
}
|
||||
.ctp-time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* ─── Time stepper ─── */
|
||||
.ctp-timepick {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.ctp-timepick-field {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 3px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.ctp-time-step {
|
||||
width: 54px; height: 24px;
|
||||
display: grid; place-items: center;
|
||||
width: 54px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 7px;
|
||||
@@ -2163,54 +2540,91 @@
|
||||
cursor: pointer;
|
||||
transition: background .12s, color .12s, border-color .12s;
|
||||
}
|
||||
.ctp-time-step:hover { background: var(--bg-4); color: var(--t-1); border-color: var(--bd-3); }
|
||||
.ctp-time-step:active { background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); }
|
||||
.ctp-time-step:hover {
|
||||
background: var(--bg-4);
|
||||
color: var(--t-1);
|
||||
border-color: var(--bd-3);
|
||||
}
|
||||
.ctp-time-step:active {
|
||||
background: color-mix(in srgb, var(--accent) 30%, var(--bg-3));
|
||||
}
|
||||
.ctp-time-cell {
|
||||
width: 54px; height: 44px;
|
||||
display: grid; place-items: center;
|
||||
width: 54px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-2);
|
||||
border-radius: 9px;
|
||||
font-size: 26px; font-weight: 700;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--t-1);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.ctp-time-editinput {
|
||||
width: 132px; height: 44px;
|
||||
width: 132px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2));
|
||||
border-radius: 9px;
|
||||
font: inherit; font-size: 26px; font-weight: 700;
|
||||
font: inherit;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--t-1);
|
||||
text-align: center;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.ctp-time-editinput:focus { outline: none; }
|
||||
.ctp-time-editinput::placeholder { color: var(--t-4); }
|
||||
.ctp-time-colon { font-size: 26px; font-weight: 700; color: var(--t-2); padding-bottom: 2px; }
|
||||
.ctp-time-editinput:focus {
|
||||
outline: none;
|
||||
}
|
||||
.ctp-time-editinput::placeholder {
|
||||
color: var(--t-4);
|
||||
}
|
||||
.ctp-time-colon {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--t-2);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.ctp-time-type {
|
||||
align-self: center;
|
||||
margin-left: 8px;
|
||||
height: 30px; padding: 0 12px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--bd-3);
|
||||
border-radius: 7px;
|
||||
color: var(--t-3);
|
||||
font: inherit; font-size: 11.5px; font-weight: 600;
|
||||
font: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color .12s, border-color .12s;
|
||||
}
|
||||
.ctp-time-type:hover { color: var(--t-1); border-color: var(--accent); }
|
||||
.ctp-day-row { gap: 5px; }
|
||||
.ctp-time-type:hover {
|
||||
color: var(--t-1);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.ctp-day-row {
|
||||
gap: 5px;
|
||||
}
|
||||
.ctp-day-label {
|
||||
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--t-3);
|
||||
margin-right: 4px;
|
||||
}
|
||||
.ctp-day-btn { height: 26px; padding: 0 10px; font-size: 11px; border-radius: 6px; }
|
||||
.ctp-day-btn {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ─── Quick-bar status variants — LED + label + row tint per status:
|
||||
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
|
||||
@@ -2220,95 +2634,199 @@
|
||||
background: var(--bg-2);
|
||||
border-color: var(--bd-2);
|
||||
}
|
||||
.ctp-ticker[data-status="scheduled"]:hover { background: var(--bg-3); }
|
||||
.ctp-ticker[data-status="scheduled"]:hover {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.ctp-ticker[data-status="soon"] {
|
||||
background: color-mix(in srgb, var(--warn) 10%, var(--bg-2));
|
||||
border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2));
|
||||
animation: ctp-soon-glow 2.4s ease-in-out infinite;
|
||||
}
|
||||
.ctp-ticker[data-status="soon"]:hover { background: color-mix(in srgb, var(--warn) 16%, var(--bg-2)); }
|
||||
.ctp-ticker[data-status="soon"]:hover {
|
||||
background: color-mix(in srgb, var(--warn) 16%, var(--bg-2));
|
||||
}
|
||||
@keyframes ctp-soon-glow {
|
||||
0%, 100% { box-shadow: 0 0 0 0 transparent; }
|
||||
50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent); }
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent);
|
||||
}
|
||||
}
|
||||
.ctp-ticker[data-status="ready"] {
|
||||
background: color-mix(in srgb, var(--ok) 11%, var(--bg-2));
|
||||
border-color: color-mix(in srgb, var(--ok) 55%, var(--bd-2));
|
||||
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent);
|
||||
}
|
||||
.ctp-ticker[data-status="ready"]:hover { background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); }
|
||||
.ctp-ticker[data-status="ready"]:hover {
|
||||
background: color-mix(in srgb, var(--ok) 17%, var(--bg-2));
|
||||
}
|
||||
.ctp-ticker[data-status="expired"] {
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
|
||||
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
|
||||
}
|
||||
.ctp-ticker[data-status="expired"]:hover { background: color-mix(in srgb, var(--danger) 13%, var(--bg-2)); }
|
||||
.ctp-ticker[data-status="expired"]:hover {
|
||||
background: color-mix(in srgb, var(--danger) 13%, var(--bg-2));
|
||||
}
|
||||
.ctp-ticker[data-status="running"] {
|
||||
background: color-mix(in srgb, var(--ok) 5%, var(--bg-2));
|
||||
border-color: color-mix(in srgb, var(--ok) 25%, var(--bd-2));
|
||||
}
|
||||
.ctp-ticker[data-status="running"]:hover { background: color-mix(in srgb, var(--ok) 9%, var(--bg-2)); }
|
||||
.ctp-ticker[data-status="running"]:hover {
|
||||
background: color-mix(in srgb, var(--ok) 9%, var(--bg-2));
|
||||
}
|
||||
.ctp-ticker[data-status="cancelled"] {
|
||||
background: color-mix(in srgb, var(--danger) 4%, var(--bg-2));
|
||||
border-color: color-mix(in srgb, var(--danger) 22%, var(--bd-2));
|
||||
}
|
||||
.ctp-ticker[data-status="cancelled"]:hover { background: color-mix(in srgb, var(--danger) 8%, var(--bg-2)); }
|
||||
.ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; }
|
||||
.ctp-ticker-dot[data-status="soon"] { background: var(--warn); animation: ctp-tickerpulse-warn 1.6s ease-out infinite; }
|
||||
.ctp-ticker-dot[data-status="ready"] { background: var(--ok); animation: none; box-shadow: 0 0 6px var(--ok); }
|
||||
.ctp-ticker-dot[data-status="expired"] { background: var(--danger); animation: none; box-shadow: none; }
|
||||
.ctp-ticker-dot[data-status="running"] { background: var(--ok); animation: none; box-shadow: none; opacity: 0.75; }
|
||||
.ctp-ticker-dot[data-status="cancelled"] { background: var(--danger); animation: none; box-shadow: none; opacity: 0.65; }
|
||||
.ctp-ticker[data-status="cancelled"]:hover {
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
|
||||
}
|
||||
.ctp-ticker-dot[data-status="scheduled"] {
|
||||
background: var(--t-3);
|
||||
animation: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.ctp-ticker-dot[data-status="soon"] {
|
||||
background: var(--warn);
|
||||
animation: ctp-tickerpulse-warn 1.6s ease-out infinite;
|
||||
}
|
||||
.ctp-ticker-dot[data-status="ready"] {
|
||||
background: var(--ok);
|
||||
animation: none;
|
||||
box-shadow: 0 0 6px var(--ok);
|
||||
}
|
||||
.ctp-ticker-dot[data-status="expired"] {
|
||||
background: var(--danger);
|
||||
animation: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.ctp-ticker-dot[data-status="running"] {
|
||||
background: var(--ok);
|
||||
animation: none;
|
||||
box-shadow: none;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.ctp-ticker-dot[data-status="cancelled"] {
|
||||
background: var(--danger);
|
||||
animation: none;
|
||||
box-shadow: none;
|
||||
opacity: 0.65;
|
||||
}
|
||||
@keyframes ctp-tickerpulse-warn {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
|
||||
70% { box-shadow: 0 0 0 6px transparent; }
|
||||
100% { box-shadow: 0 0 0 0 transparent; }
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px transparent;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
.ctp-ticker-label[data-status="scheduled"] {
|
||||
color: var(--t-2);
|
||||
}
|
||||
.ctp-ticker-label[data-status="soon"] {
|
||||
color: var(--warn);
|
||||
}
|
||||
.ctp-ticker-label[data-status="ready"] {
|
||||
color: var(--ok);
|
||||
}
|
||||
.ctp-ticker-label[data-status="expired"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
.ctp-ticker-label[data-status="running"] {
|
||||
color: color-mix(in srgb, var(--ok) 75%, var(--t-2));
|
||||
}
|
||||
.ctp-ticker-label[data-status="cancelled"] {
|
||||
color: color-mix(in srgb, var(--danger) 70%, var(--t-2));
|
||||
}
|
||||
.ctp-ticker[data-status="soon"] .ctp-ticker-cta {
|
||||
color: var(--warn);
|
||||
}
|
||||
.ctp-ticker[data-status="ready"] .ctp-ticker-cta {
|
||||
color: var(--ok);
|
||||
}
|
||||
.ctp-ticker[data-status="expired"] .ctp-ticker-cta {
|
||||
color: var(--danger);
|
||||
}
|
||||
.ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); }
|
||||
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
|
||||
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
|
||||
.ctp-ticker-label[data-status="expired"] { color: var(--danger); }
|
||||
.ctp-ticker-label[data-status="running"] { color: color-mix(in srgb, var(--ok) 75%, var(--t-2)); }
|
||||
.ctp-ticker-label[data-status="cancelled"] { color: color-mix(in srgb, var(--danger) 70%, var(--t-2)); }
|
||||
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
|
||||
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
|
||||
.ctp-ticker[data-status="expired"] .ctp-ticker-cta { color: var(--danger); }
|
||||
.ctp-ticker[data-status="running"] .ctp-ticker-cta,
|
||||
.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta { color: var(--t-2); }
|
||||
.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta {
|
||||
color: var(--t-2);
|
||||
}
|
||||
|
||||
/* ─── Quick-bar inline chat preview ─── */
|
||||
.ctp-ticker-chat {
|
||||
min-width: 0;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--t-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ctp-ticker-chat svg { flex-shrink: 0; opacity: 0.7; }
|
||||
.ctp-ticker-chat b { flex-shrink: 0; font-weight: 700; }
|
||||
.ctp-ticker-chat svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ctp-ticker-chat b {
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ctp-ticker-chat-text {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--t-2);
|
||||
}
|
||||
.ctp-card.is-checkin { border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2)); }
|
||||
.ctp-card.is-checkin {
|
||||
border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2));
|
||||
}
|
||||
.ctp-checkin-note {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: color-mix(in srgb, var(--accent) 13%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px; font-weight: 600;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--t-1);
|
||||
}
|
||||
.ctp-checkin-note svg { color: var(--accent); flex-shrink: 0; }
|
||||
.ctp-checkin-note svg {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ctp-card-timer.is-sched {
|
||||
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
.ctp-card-clock {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.ctp-card-until {
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--t-3);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.ctp-avatar.is-in .ctp-avatar-dot {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ctp-card-clock { font-size: 18px; line-height: 1; }
|
||||
.ctp-card-until { font-size: 10.5px; font-weight: 600; color: var(--t-3); letter-spacing: 0.01em; }
|
||||
.ctp-avatar.is-in .ctp-avatar-dot { opacity: 0.8; }
|
||||
.ctp-avatar-in {
|
||||
position: absolute; bottom: -6px; left: 50%;
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 8.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
font-size: 8.5px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--t-2);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-2);
|
||||
@@ -2319,37 +2837,60 @@
|
||||
|
||||
/* ─── Ticker mini ready-bubbles ─── */
|
||||
.ctp-ticker-bubbles {
|
||||
display: inline-flex; align-items: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-self: end;
|
||||
}
|
||||
.ctp-mini {
|
||||
position: relative;
|
||||
width: 22px; height: 22px;
|
||||
display: grid; place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
margin-left: -6px;
|
||||
border: 2px solid var(--bg-2);
|
||||
color: white;
|
||||
font-size: 8px; font-weight: 800; letter-spacing: 0.02em;
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.ctp-mini:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.ctp-mini[data-state="ready"] {
|
||||
box-shadow: 0 0 0 1.5px var(--ok);
|
||||
}
|
||||
.ctp-mini[data-state="pending"] {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.ctp-mini[data-state="in"] {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ctp-mini:first-child { margin-left: 0; }
|
||||
.ctp-mini[data-state="ready"] { box-shadow: 0 0 0 1.5px var(--ok); }
|
||||
.ctp-mini[data-state="pending"] { opacity: 0.75; }
|
||||
.ctp-mini[data-state="in"] { opacity: 0.85; }
|
||||
.ctp-mini-check {
|
||||
position: absolute; bottom: -3px; right: -4px;
|
||||
width: 11px; height: 11px;
|
||||
display: grid; place-items: center;
|
||||
position: absolute;
|
||||
bottom: -3px;
|
||||
right: -4px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--ok);
|
||||
color: #06240f;
|
||||
border: 1.5px solid var(--bg-2);
|
||||
}
|
||||
.ctp-mini-check svg { width: 7px; height: 7px; }
|
||||
.ctp-mini-check svg {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
.ctp-mini-tag {
|
||||
position: absolute; bottom: -6px; right: -7px;
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
right: -7px;
|
||||
font-style: normal;
|
||||
font-size: 8px; font-weight: 700;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
line-height: 11px;
|
||||
padding: 0 3px;
|
||||
border-radius: 999px;
|
||||
@@ -2359,51 +2900,83 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ctp-mini-more { background: var(--bg-4); color: var(--t-2); font-size: 8.5px; }
|
||||
.ctp-mini-more {
|
||||
background: var(--bg-4);
|
||||
color: var(--t-2);
|
||||
font-size: 8.5px;
|
||||
}
|
||||
|
||||
/* ─── Per-call chat ─── */
|
||||
.ctp-chat {
|
||||
display: flex; flex-direction: column;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--bd-1);
|
||||
margin-top: 2px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.ctp-chat-toggle {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 4px 2px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--t-3);
|
||||
font: inherit; font-size: 11.5px; font-weight: 700;
|
||||
font: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: color .15s;
|
||||
}
|
||||
.ctp-chat-toggle:hover { color: var(--t-1); }
|
||||
.ctp-chat-toggle svg { flex-shrink: 0; }
|
||||
.ctp-chat-count { color: var(--t-4); font-weight: 600; }
|
||||
.ctp-chat-toggle:hover {
|
||||
color: var(--t-1);
|
||||
}
|
||||
.ctp-chat-toggle svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ctp-chat-count {
|
||||
color: var(--t-4);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ctp-chat-unread {
|
||||
min-width: 16px; height: 16px;
|
||||
display: grid; place-items: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-size: 9.5px; font-weight: 800;
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ctp-chat-preview {
|
||||
flex: 1; min-width: 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
color: var(--t-3);
|
||||
text-align: left;
|
||||
}
|
||||
.ctp-chat-preview b { font-weight: 700; }
|
||||
.ctp-chat-chevron { margin-left: auto; flex-shrink: 0; transition: transform .15s; }
|
||||
.ctp-chat.is-open .ctp-chat-chevron { transform: rotate(180deg); }
|
||||
.ctp-chat-preview b {
|
||||
font-weight: 700;
|
||||
}
|
||||
.ctp-chat-chevron {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
transition: transform .15s;
|
||||
}
|
||||
.ctp-chat.is-open .ctp-chat-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.ctp-chat-list {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 168px;
|
||||
overflow-y: auto;
|
||||
margin: 8px 0;
|
||||
@@ -2412,26 +2985,57 @@
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ctp-chat-msg { font-size: 12px; line-height: 1.45; color: var(--t-2); overflow-wrap: anywhere; }
|
||||
.ctp-chat-msg b { font-weight: 700; }
|
||||
.ctp-chat-text { color: var(--t-1); }
|
||||
.ctp-chat-time { margin-left: 6px; font-size: 10px; color: var(--t-4); font-variant-numeric: tabular-nums; }
|
||||
.ctp-chat-empty { font-size: 11.5px; color: var(--t-4); text-align: center; padding: 6px 0; }
|
||||
.ctp-chat-form { display: flex; gap: 6px; }
|
||||
.ctp-chat-msg {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--t-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ctp-chat-msg b {
|
||||
font-weight: 700;
|
||||
}
|
||||
.ctp-chat-text {
|
||||
color: var(--t-1);
|
||||
}
|
||||
.ctp-chat-time {
|
||||
margin-left: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--t-4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ctp-chat-empty {
|
||||
font-size: 11.5px;
|
||||
color: var(--t-4);
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.ctp-chat-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.ctp-chat-input {
|
||||
flex: 1; height: 32px;
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 7px;
|
||||
color: var(--t-1);
|
||||
font: inherit; font-size: 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ctp-chat-input::placeholder {
|
||||
color: var(--t-4);
|
||||
}
|
||||
.ctp-chat-input:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2));
|
||||
}
|
||||
.ctp-chat-input::placeholder { color: var(--t-4); }
|
||||
.ctp-chat-input:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); }
|
||||
.ctp-chat-send {
|
||||
width: 32px; height: 32px;
|
||||
display: grid; place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--accent) 24%, var(--bg-3));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
|
||||
border-radius: 7px;
|
||||
@@ -2440,25 +3044,34 @@
|
||||
flex-shrink: 0;
|
||||
transition: background .15s;
|
||||
}
|
||||
.ctp-chat-send:hover { background: color-mix(in srgb, var(--accent) 40%, var(--bg-3)); }
|
||||
.ctp-chat-send:hover {
|
||||
background: color-mix(in srgb, var(--accent) 40%, var(--bg-3));
|
||||
}
|
||||
|
||||
.ctp-transport-note {
|
||||
margin-top: 10px;
|
||||
font-size: 11.5px;
|
||||
color: var(--t-3);
|
||||
}
|
||||
.ctp-transport-note.is-error { color: var(--danger); }
|
||||
.ctp-transport-note.is-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
@container launcher (max-width: 1280px) {
|
||||
.ctp-ticker {
|
||||
grid-template-columns: 10px 86px minmax(120px, 1fr) 78px 130px max-content 70px;
|
||||
grid-template-columns: 10px 86px minmax(120px, 1fr) 78px 130px max-content
|
||||
70px;
|
||||
}
|
||||
.ctp-ticker-by,
|
||||
.ctp-ticker-chat { display: none; }
|
||||
.ctp-ticker-chat {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@container launcher (max-width: 800px) {
|
||||
.ctp-ticker {
|
||||
grid-template-columns: 10px 78px minmax(100px, 1fr) 76px 64px;
|
||||
}
|
||||
.ctp-ticker-time,
|
||||
.ctp-ticker-bubbles { display: none; }
|
||||
.ctp-ticker-bubbles {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,264 +1,443 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import { TopBar } from '../components/topbar/TopBar';
|
||||
import { KebabItem } from '../components/topbar/KebabMenu';
|
||||
import { ResultsBar } from '../components/grid/ResultsBar';
|
||||
import { GameGrid } from '../components/grid/GameGrid';
|
||||
import { GameDetailModal } from '../components/modals/GameDetailModal';
|
||||
import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDownloadModal';
|
||||
import { SettingsDialog } from '../components/modals/SettingsDialog';
|
||||
import { NoDirectoryState } from '../components/empty/NoDirectoryState';
|
||||
import { EmptyResultsState } from '../components/empty/EmptyResultsState';
|
||||
import { CallToPlayTicker } from '../components/calltoplay/CallToPlayTicker';
|
||||
import { CallToPlayOverlay } from '../components/calltoplay/CallToPlayOverlay';
|
||||
import { TopBar } from "../components/topbar/TopBar";
|
||||
import { KebabItem } from "../components/topbar/KebabMenu";
|
||||
import { ResultsBar } from "../components/grid/ResultsBar";
|
||||
import { GameGrid } from "../components/grid/GameGrid";
|
||||
import { GameDetailModal } from "../components/modals/GameDetailModal";
|
||||
import { ConfirmRemoveDownloadModal } from "../components/modals/ConfirmRemoveDownloadModal";
|
||||
import { SettingsDialog } from "../components/modals/SettingsDialog";
|
||||
import { NoDirectoryState } from "../components/empty/NoDirectoryState";
|
||||
import { EmptyResultsState } from "../components/empty/EmptyResultsState";
|
||||
import { CallToPlayTicker } from "../components/calltoplay/CallToPlayTicker";
|
||||
import { CallToPlayOverlay } from "../components/calltoplay/CallToPlayOverlay";
|
||||
|
||||
import { useGameDirectory } from '../hooks/useGameDirectory';
|
||||
import { useGames } from '../hooks/useGames';
|
||||
import { useGameActions } from '../hooks/useGameActions';
|
||||
import { useThumbnails } from '../hooks/useThumbnails';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useCallToPlay } from '../hooks/useCallToPlay';
|
||||
import { useGameDirectory } from "../hooks/useGameDirectory";
|
||||
import { useGames } from "../hooks/useGames";
|
||||
import { useGameActions } from "../hooks/useGameActions";
|
||||
import { useThumbnails } from "../hooks/useThumbnails";
|
||||
import { useSettings } from "../hooks/useSettings";
|
||||
import { useCallToPlay } from "../hooks/useCallToPlay";
|
||||
import { useStreamInstallCapability } from "../hooks/useStreamInstallCapability";
|
||||
import { useLocalNetworkSharing } from "../hooks/useLocalNetworkSharing";
|
||||
import { useIdentityDiagnostic } from "../hooks/useIdentityDiagnostic";
|
||||
|
||||
import { Game } from '../lib/types';
|
||||
import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState';
|
||||
import { Game } from "../lib/types";
|
||||
import {
|
||||
AsyncOwner,
|
||||
ownCompanionWindowCreation,
|
||||
windowAsyncScope,
|
||||
} from "../lib/asyncOwnership";
|
||||
import {
|
||||
isLocalNetworkSharingActive,
|
||||
localNetworkSharingNotice,
|
||||
} from "../lib/localNetworkSharing";
|
||||
import {
|
||||
EPHEMERAL_IDENTITY_NOTICE,
|
||||
IDENTITY_STATUS_UNAVAILABLE_NOTICE,
|
||||
} from "../lib/identityDiagnostic";
|
||||
import {
|
||||
applyFilterAndSort,
|
||||
countByFilter,
|
||||
needsUpdate,
|
||||
} from "../lib/gameState";
|
||||
|
||||
const openLogsWindow = async () => {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
|
||||
try {
|
||||
const existing = await WebviewWindow.getByLabel('unpack-logs');
|
||||
if (existing) {
|
||||
await existing.setFocus();
|
||||
return;
|
||||
}
|
||||
const win = new WebviewWindow('unpack-logs', {
|
||||
url: '/?view=unpack-logs',
|
||||
title: 'Unpack Logs',
|
||||
width: 900,
|
||||
height: 700,
|
||||
resizable: true,
|
||||
});
|
||||
await win.once<unknown>('tauri://error', (event) => {
|
||||
console.error('Error opening unpack logs window:', event.payload);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error opening unpack logs window:', err);
|
||||
interface CompanionWindowOptions {
|
||||
label: string;
|
||||
url: string;
|
||||
title: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const openCompanionWindow = async (
|
||||
owner: AsyncOwner,
|
||||
options: CompanionWindowOptions,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
let windowModule:
|
||||
| typeof import("@tauri-apps/api/webviewWindow")
|
||||
| undefined;
|
||||
if (
|
||||
!await owner.applyIfActive(
|
||||
() => import("@tauri-apps/api/webviewWindow"),
|
||||
(loaded) => {
|
||||
windowModule = loaded;
|
||||
},
|
||||
) || windowModule === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { WebviewWindow } = windowModule;
|
||||
const existingResult: Array<
|
||||
Awaited<ReturnType<typeof WebviewWindow.getByLabel>>
|
||||
> = [];
|
||||
if (
|
||||
!await owner.applyIfActive(
|
||||
() => WebviewWindow.getByLabel(options.label),
|
||||
(found) => {
|
||||
existingResult.push(found);
|
||||
},
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const existingWindow = existingResult[0];
|
||||
if (existingWindow) {
|
||||
await owner.applyIfActive(() => existingWindow.setFocus(), () => {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!owner.isActive()) return;
|
||||
const win = new WebviewWindow(options.label, {
|
||||
url: options.url,
|
||||
title: options.title,
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
resizable: true,
|
||||
});
|
||||
const result = await ownCompanionWindowCreation(owner, {
|
||||
registerCreated: (handler) => win.once("tauri://created", handler),
|
||||
registerError: (handler) =>
|
||||
win.once<unknown>("tauri://error", (event) => handler(event.payload)),
|
||||
destroy: () => win.destroy(),
|
||||
});
|
||||
if (!owner.isActive()) return;
|
||||
if (result.kind === "error") {
|
||||
console.error(`Error opening ${options.title}:`, result.payload);
|
||||
} else if (result.kind === "registration-error") {
|
||||
console.error(
|
||||
`Error owning ${options.title} creation:`,
|
||||
result.error,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (owner.isActive()) console.error(`Error opening ${options.title}:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
const openMainLogsWindow = async () => {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
|
||||
try {
|
||||
const existing = await WebviewWindow.getByLabel('main-logs');
|
||||
if (existing) {
|
||||
await existing.setFocus();
|
||||
return;
|
||||
}
|
||||
const win = new WebviewWindow('main-logs', {
|
||||
url: '/?view=main-logs',
|
||||
title: 'Application Logs',
|
||||
width: 980,
|
||||
height: 720,
|
||||
resizable: true,
|
||||
});
|
||||
await win.once<unknown>('tauri://error', (event) => {
|
||||
console.error('Error opening application logs window:', event.payload);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error opening application logs window:', err);
|
||||
}
|
||||
const UNPACK_LOG_WINDOW: CompanionWindowOptions = {
|
||||
label: "unpack-logs",
|
||||
url: "/?view=unpack-logs",
|
||||
title: "Unpack Logs",
|
||||
width: 900,
|
||||
height: 700,
|
||||
};
|
||||
|
||||
const MAIN_LOG_WINDOW: CompanionWindowOptions = {
|
||||
label: "main-logs",
|
||||
url: "/?view=main-logs",
|
||||
title: "Application Logs",
|
||||
width: 980,
|
||||
height: 720,
|
||||
};
|
||||
|
||||
export const MainWindow = () => {
|
||||
const { settings, set: setSetting } = useSettings();
|
||||
const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory();
|
||||
const games = useGames(rescan);
|
||||
const actions = useGameActions(games, settings);
|
||||
const thumbnails = useThumbnails();
|
||||
const callToPlay = useCallToPlay(settings.username);
|
||||
const { settings, set: setSetting, ready: settingsReady } = useSettings();
|
||||
const localNetworkSharing = useLocalNetworkSharing();
|
||||
const identityDiagnostic = useIdentityDiagnostic();
|
||||
const {
|
||||
gameDir,
|
||||
ready: gameDirectoryReady,
|
||||
hasGameDirectory,
|
||||
setGameDir,
|
||||
rescan,
|
||||
} = useGameDirectory(localNetworkSharing.ready);
|
||||
const games = useGames(rescan);
|
||||
const actions = useGameActions(games, settings);
|
||||
const thumbnails = useThumbnails(games.games.map((game) => game.id));
|
||||
const sharingActive = isLocalNetworkSharingActive(
|
||||
localNetworkSharing.snapshot,
|
||||
);
|
||||
const callToPlay = useCallToPlay(settings.username, sharingActive);
|
||||
const windowActionOwnerRef = useRef<AsyncOwner | null>(null);
|
||||
|
||||
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
|
||||
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
|
||||
const visibleGames = useMemo(
|
||||
() => hasGameDirectory ? games.games : [],
|
||||
[games.games, hasGameDirectory],
|
||||
);
|
||||
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
|
||||
useEffect(() => {
|
||||
const owner = new AsyncOwner((error) => {
|
||||
console.error("Failed to clean up a main-window action:", error);
|
||||
});
|
||||
windowActionOwnerRef.current = owner;
|
||||
return () => {
|
||||
if (windowActionOwnerRef.current === owner) {
|
||||
windowActionOwnerRef.current = null;
|
||||
}
|
||||
windowAsyncScope.adopt(owner.dispose());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Query is local UI state (no need to persist).
|
||||
const [query, setQuery] = useState('');
|
||||
const filteredGames = useMemo(
|
||||
() => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
|
||||
[visibleGames, settings.filter, settings.sort, query],
|
||||
);
|
||||
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||
const supportsStreamedInstall = useStreamInstallCapability(openGameId);
|
||||
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
|
||||
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
|
||||
const visibleGames = useMemo(
|
||||
() => hasGameDirectory ? games.games : [],
|
||||
[games.games, hasGameDirectory],
|
||||
);
|
||||
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
|
||||
|
||||
const openGame = useMemo<Game | null>(
|
||||
() => openGameId ? games.games.find(g => g.id === openGameId) ?? null : null,
|
||||
[openGameId, games.games],
|
||||
);
|
||||
const removeGame = useMemo<Game | null>(
|
||||
() => removeGameId ? games.games.find(g => g.id === removeGameId) ?? null : null,
|
||||
[removeGameId, games.games],
|
||||
);
|
||||
// Query is local UI state (no need to persist).
|
||||
const [query, setQuery] = useState("");
|
||||
const filteredGames = useMemo(
|
||||
() =>
|
||||
applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
|
||||
[visibleGames, settings.filter, settings.sort, query],
|
||||
);
|
||||
|
||||
const pickDirectory = useCallback(async () => {
|
||||
const picked = await open({ multiple: false, directory: true });
|
||||
if (typeof picked === 'string' && picked) setGameDir(picked);
|
||||
}, [setGameDir]);
|
||||
const openGame = useMemo<Game | null>(
|
||||
() =>
|
||||
openGameId ? games.games.find((g) => g.id === openGameId) ?? null : null,
|
||||
[openGameId, games.games],
|
||||
);
|
||||
const removeGame = useMemo<Game | null>(
|
||||
() =>
|
||||
removeGameId
|
||||
? games.games.find((g) => g.id === removeGameId) ?? null
|
||||
: null,
|
||||
[removeGameId, games.games],
|
||||
);
|
||||
|
||||
const handlePrimary = useCallback((game: Game) => {
|
||||
if (game.installed) {
|
||||
if (needsUpdate(game)) actions.update(game.id);
|
||||
else actions.play(game.id);
|
||||
} else {
|
||||
actions.install(game.id);
|
||||
const pickDirectory = useCallback(async () => {
|
||||
const owner = windowActionOwnerRef.current;
|
||||
if (owner === null || !owner.isActive()) return;
|
||||
|
||||
try {
|
||||
await owner.applyIfActive(
|
||||
() => open({ multiple: false, directory: true }),
|
||||
(picked) => {
|
||||
if (typeof picked === "string" && picked) setGameDir(picked);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (owner.isActive()) {
|
||||
console.error("Failed to choose a game directory:", error);
|
||||
}
|
||||
}
|
||||
}, [setGameDir]);
|
||||
|
||||
const handlePrimary = useCallback((game: Game) => {
|
||||
if (game.installed) {
|
||||
if (needsUpdate(game)) actions.update(game.id);
|
||||
else actions.play(game.id);
|
||||
} else {
|
||||
actions.install(game.id);
|
||||
}
|
||||
}, [actions]);
|
||||
|
||||
const handleUninstall = useCallback((game: Game) => {
|
||||
actions.uninstall(game.id);
|
||||
}, [actions]);
|
||||
|
||||
const handleRemoveDownload = useCallback((game: Game) => {
|
||||
setRemoveGameId(game.id);
|
||||
}, []);
|
||||
|
||||
const confirmRemoveDownload = useCallback((game: Game) => {
|
||||
actions.removeDownload(game.id);
|
||||
setRemoveGameId(null);
|
||||
setOpenGameId((current) => current === game.id ? null : current);
|
||||
}, [actions]);
|
||||
|
||||
const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [
|
||||
{ kind: "item", label: "Settings", onClick: () => setSettingsOpen(true) },
|
||||
{ kind: "item", label: "Refresh library", onClick: () => rescan() },
|
||||
{ kind: "separator" },
|
||||
{
|
||||
kind: "item",
|
||||
label: "Application logs",
|
||||
onClick: () => {
|
||||
const owner = windowActionOwnerRef.current;
|
||||
if (owner) {
|
||||
windowAsyncScope.adopt(openCompanionWindow(owner, MAIN_LOG_WINDOW));
|
||||
}
|
||||
}, [actions]);
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "item",
|
||||
label: "Unpack logs",
|
||||
onClick: () => {
|
||||
const owner = windowActionOwnerRef.current;
|
||||
if (owner) {
|
||||
windowAsyncScope.adopt(openCompanionWindow(owner, UNPACK_LOG_WINDOW));
|
||||
}
|
||||
},
|
||||
},
|
||||
], [rescan]);
|
||||
|
||||
const handleUninstall = useCallback((game: Game) => {
|
||||
actions.uninstall(game.id);
|
||||
}, [actions]);
|
||||
const rootStyle = { "--accent": settings.accent } as React.CSSProperties;
|
||||
const className = [
|
||||
"launcher",
|
||||
`bg-${settings.bg}`,
|
||||
`density-${settings.density}`,
|
||||
].join(" ");
|
||||
|
||||
const handleRemoveDownload = useCallback((game: Game) => {
|
||||
setRemoveGameId(game.id);
|
||||
}, []);
|
||||
if (
|
||||
!settingsReady ||
|
||||
!localNetworkSharing.ready ||
|
||||
!identityDiagnostic.ready ||
|
||||
!gameDirectoryReady
|
||||
) {
|
||||
return <div className={className} style={rootStyle} aria-busy="true" />;
|
||||
}
|
||||
|
||||
const confirmRemoveDownload = useCallback((game: Game) => {
|
||||
actions.removeDownload(game.id);
|
||||
setRemoveGameId(null);
|
||||
setOpenGameId(current => current === game.id ? null : current);
|
||||
}, [actions]);
|
||||
|
||||
const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [
|
||||
{ kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) },
|
||||
{ kind: 'item', label: 'Refresh library', onClick: () => rescan() },
|
||||
{ kind: 'separator' },
|
||||
{ kind: 'item', label: 'Application logs', onClick: () => void openMainLogsWindow() },
|
||||
{ kind: 'item', label: 'Unpack logs', onClick: () => void openLogsWindow() },
|
||||
], [rescan]);
|
||||
|
||||
const rootStyle = { '--accent': settings.accent } as React.CSSProperties;
|
||||
const className = [
|
||||
'launcher',
|
||||
`bg-${settings.bg}`,
|
||||
`density-${settings.density}`,
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div className={className} style={rootStyle}>
|
||||
<TopBar
|
||||
accent={settings.accent}
|
||||
peerCount={games.totalPeerCount}
|
||||
filter={settings.filter}
|
||||
setFilter={(v) => setSetting('filter', v)}
|
||||
counts={counts}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
sort={settings.sort}
|
||||
setSort={(v) => setSetting('sort', v)}
|
||||
kebabItems={kebabItems}
|
||||
nominations={callToPlay.nominations}
|
||||
onOpenCallToPlay={() => {
|
||||
setFocusedCallId(null);
|
||||
setCallToPlayOpen(true);
|
||||
}}
|
||||
/>
|
||||
<main className="grid-wrap">
|
||||
<CallToPlayTicker
|
||||
nominations={callToPlay.nominations}
|
||||
games={games.games}
|
||||
accent={settings.accent}
|
||||
onOpen={(callId) => {
|
||||
setFocusedCallId(callId);
|
||||
setCallToPlayOpen(true);
|
||||
}}
|
||||
/>
|
||||
{hasGameDirectory ? (
|
||||
<>
|
||||
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
||||
{filteredGames.length === 0 ? (
|
||||
visibleGames.length === 0 ? (
|
||||
<EmptyResultsState
|
||||
title="Scanning for games"
|
||||
hint="Looking for game bundles in your selected directory…"
|
||||
/>
|
||||
) : (
|
||||
<EmptyResultsState
|
||||
title="Nothing matches"
|
||||
hint="No games match the current filter or search query."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<GameGrid
|
||||
games={filteredGames}
|
||||
aspect={settings.aspect}
|
||||
getThumbnail={thumbnails.get}
|
||||
onOpen={(g) => setOpenGameId(g.id)}
|
||||
onPrimary={handlePrimary}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{openGame && (
|
||||
<GameDetailModal
|
||||
game={openGame}
|
||||
thumbnailUrl={thumbnails.get(openGame.id)}
|
||||
onClose={() => setOpenGameId(null)}
|
||||
onPrimary={handlePrimary}
|
||||
onStreamInstall={(g) => actions.streamInstall(g.id)}
|
||||
onUninstall={handleUninstall}
|
||||
onRemoveDownload={handleRemoveDownload}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
onStartServer={(g) => actions.startServer(g.id)}
|
||||
onViewFiles={(g) => actions.viewFiles(g.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{removeGame && (
|
||||
<ConfirmRemoveDownloadModal
|
||||
game={removeGame}
|
||||
onCancel={() => setRemoveGameId(null)}
|
||||
onConfirm={confirmRemoveDownload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{settingsOpen && (
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
gameDir={gameDir}
|
||||
hasGameDirectory={hasGameDirectory}
|
||||
onPickDirectory={() => void pickDirectory()}
|
||||
onChange={setSetting}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{callToPlayOpen && (
|
||||
<CallToPlayOverlay
|
||||
nominations={callToPlay.nominations}
|
||||
games={games.games}
|
||||
actorId={callToPlay.actorId}
|
||||
actions={callToPlay.actions}
|
||||
focusId={focusedCallId}
|
||||
transportReady={callToPlay.transportReady}
|
||||
error={callToPlay.error}
|
||||
getThumbnail={thumbnails.get}
|
||||
totalPeerCount={games.totalPeerCount}
|
||||
onLaunch={handlePrimary}
|
||||
onClose={() => {
|
||||
setCallToPlayOpen(false);
|
||||
setFocusedCallId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<div className={className} style={rootStyle}>
|
||||
<TopBar
|
||||
accent={settings.accent}
|
||||
peerCount={games.totalPeerCount}
|
||||
filter={settings.filter}
|
||||
setFilter={(v) => setSetting("filter", v)}
|
||||
counts={counts}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
sort={settings.sort}
|
||||
setSort={(v) => setSetting("sort", v)}
|
||||
kebabItems={kebabItems}
|
||||
nominations={callToPlay.nominations}
|
||||
onOpenCallToPlay={() => {
|
||||
setFocusedCallId(null);
|
||||
setCallToPlayOpen(true);
|
||||
}}
|
||||
/>
|
||||
<main className="grid-wrap">
|
||||
<div
|
||||
className={`network-notice network-notice-sharing is-${localNetworkSharing.snapshot.phase}`}
|
||||
role="status"
|
||||
>
|
||||
{localNetworkSharingNotice(localNetworkSharing.snapshot)}
|
||||
</div>
|
||||
);
|
||||
{localNetworkSharing.error &&
|
||||
localNetworkSharing.snapshot.persistenceProblem === null && (
|
||||
<div className="network-notice is-error" role="status">
|
||||
{localNetworkSharing.error}
|
||||
</div>
|
||||
)}
|
||||
{identityDiagnostic.snapshot.diagnostic === "ephemeral" && (
|
||||
<div className="network-notice identity-notice" role="status">
|
||||
{EPHEMERAL_IDENTITY_NOTICE}
|
||||
</div>
|
||||
)}
|
||||
{identityDiagnostic.unavailable && (
|
||||
<div className="network-notice identity-notice" role="status">
|
||||
{IDENTITY_STATUS_UNAVAILABLE_NOTICE}
|
||||
</div>
|
||||
)}
|
||||
{games.protocolMismatch && (
|
||||
<div className="network-notice" role="status">
|
||||
Nearby devices are running a different Lanspread version
|
||||
</div>
|
||||
)}
|
||||
<CallToPlayTicker
|
||||
nominations={callToPlay.nominations}
|
||||
games={games.games}
|
||||
accent={settings.accent}
|
||||
onOpen={(callId) => {
|
||||
setFocusedCallId(callId);
|
||||
setCallToPlayOpen(true);
|
||||
}}
|
||||
/>
|
||||
{hasGameDirectory
|
||||
? (
|
||||
<>
|
||||
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
||||
{filteredGames.length === 0
|
||||
? (
|
||||
visibleGames.length === 0
|
||||
? (
|
||||
<EmptyResultsState
|
||||
title="Scanning for games"
|
||||
hint="Looking for game bundles in your selected directory…"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<EmptyResultsState
|
||||
title="Nothing matches"
|
||||
hint="No games match the current filter or search query."
|
||||
/>
|
||||
)
|
||||
)
|
||||
: (
|
||||
<GameGrid
|
||||
games={filteredGames}
|
||||
aspect={settings.aspect}
|
||||
getThumbnail={thumbnails.get}
|
||||
onOpen={(g) => setOpenGameId(g.id)}
|
||||
onPrimary={handlePrimary}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<NoDirectoryState
|
||||
onChooseDirectory={() => windowAsyncScope.adopt(pickDirectory())}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{openGame && (
|
||||
<GameDetailModal
|
||||
game={openGame}
|
||||
thumbnailUrl={thumbnails.get(openGame.id)}
|
||||
supportsStreamedInstall={supportsStreamedInstall}
|
||||
onClose={() => setOpenGameId(null)}
|
||||
onPrimary={handlePrimary}
|
||||
onStreamInstall={(g) => actions.streamInstall(g.id)}
|
||||
onUninstall={handleUninstall}
|
||||
onRemoveDownload={handleRemoveDownload}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
onStartServer={(g) => actions.startServer(g.id)}
|
||||
onViewFiles={(g) => actions.viewFiles(g.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{removeGame && (
|
||||
<ConfirmRemoveDownloadModal
|
||||
game={removeGame}
|
||||
onCancel={() => setRemoveGameId(null)}
|
||||
onConfirm={confirmRemoveDownload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{settingsOpen && (
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
gameDir={gameDir}
|
||||
hasGameDirectory={hasGameDirectory}
|
||||
onPickDirectory={() => windowAsyncScope.adopt(pickDirectory())}
|
||||
onChange={setSetting}
|
||||
localNetworkSharing={localNetworkSharing.snapshot}
|
||||
localNetworkSharingReady={localNetworkSharing.ready}
|
||||
localNetworkSharingBusy={localNetworkSharing.busy}
|
||||
localNetworkSharingError={localNetworkSharing.error}
|
||||
onLocalNetworkSharingChange={localNetworkSharing.setEnabled}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{callToPlayOpen && (
|
||||
<CallToPlayOverlay
|
||||
nominations={callToPlay.nominations}
|
||||
games={games.games}
|
||||
actorId={callToPlay.actorId}
|
||||
actions={callToPlay.actions}
|
||||
focusId={focusedCallId}
|
||||
transportReady={callToPlay.transportReady && sharingActive}
|
||||
error={sharingActive
|
||||
? callToPlay.error
|
||||
: "Local network sharing is off."}
|
||||
getThumbnail={thumbnails.get}
|
||||
totalPeerCount={games.totalPeerCount}
|
||||
onLaunch={handlePrimary}
|
||||
onClose={() => {
|
||||
setCallToPlayOpen(false);
|
||||
setFocusedCallId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,776 @@
|
||||
import {
|
||||
AsyncAdoptionScope,
|
||||
type AsyncCleanup,
|
||||
AsyncOwner,
|
||||
createSerializedAsyncWriter,
|
||||
mergeHydratedState,
|
||||
ownCompanionWindowCreation,
|
||||
registerSequentially,
|
||||
} from "../src/lib/asyncOwnership.ts";
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertArrayEquals = <T>(actual: T[], expected: T[], message: string) => {
|
||||
assertEquals(JSON.stringify(actual), JSON.stringify(expected), message);
|
||||
};
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
Deno.test("unmount before listener resolution cleans the late listener and stops registration", async () => {
|
||||
const owner = new AsyncOwner();
|
||||
const first = deferred<AsyncCleanup>();
|
||||
let firstRegistrations = 0;
|
||||
let secondRegistrations = 0;
|
||||
let firstCleanups = 0;
|
||||
|
||||
const registration = registerSequentially(owner, [
|
||||
() => {
|
||||
firstRegistrations += 1;
|
||||
return first.promise;
|
||||
},
|
||||
() => {
|
||||
secondRegistrations += 1;
|
||||
return Promise.resolve(() => {});
|
||||
},
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
firstRegistrations,
|
||||
1,
|
||||
"the first listener should start immediately",
|
||||
);
|
||||
const disposal = owner.dispose();
|
||||
first.resolve(() => {
|
||||
firstCleanups += 1;
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
await registration,
|
||||
false,
|
||||
"disposed registration should report cancellation",
|
||||
);
|
||||
assertEquals(
|
||||
firstCleanups,
|
||||
1,
|
||||
"the late listener should unlisten immediately",
|
||||
);
|
||||
assertEquals(
|
||||
secondRegistrations,
|
||||
0,
|
||||
"no later listener should start after disposal",
|
||||
);
|
||||
await disposal;
|
||||
});
|
||||
|
||||
Deno.test("partial listener registration is fully cleaned across an in-flight listener", async () => {
|
||||
const owner = new AsyncOwner();
|
||||
const second = deferred<AsyncCleanup>();
|
||||
const secondStarted = deferred<void>();
|
||||
let firstCleanups = 0;
|
||||
let secondCleanups = 0;
|
||||
let thirdRegistrations = 0;
|
||||
|
||||
const registration = registerSequentially(owner, [
|
||||
() =>
|
||||
Promise.resolve(() => {
|
||||
firstCleanups += 1;
|
||||
}),
|
||||
() => {
|
||||
secondStarted.resolve();
|
||||
return second.promise;
|
||||
},
|
||||
() => {
|
||||
thirdRegistrations += 1;
|
||||
return Promise.resolve(() => {});
|
||||
},
|
||||
]);
|
||||
|
||||
await secondStarted.promise;
|
||||
const disposal = owner.dispose();
|
||||
assertEquals(
|
||||
firstCleanups,
|
||||
1,
|
||||
"an owned listener should clean up during disposal",
|
||||
);
|
||||
|
||||
second.resolve(() => {
|
||||
secondCleanups += 1;
|
||||
});
|
||||
assertEquals(
|
||||
await registration,
|
||||
false,
|
||||
"the in-flight registration should stop the sequence",
|
||||
);
|
||||
assertEquals(
|
||||
secondCleanups,
|
||||
1,
|
||||
"the in-flight listener should clean up when it resolves",
|
||||
);
|
||||
assertEquals(thirdRegistrations, 0, "the third listener should never start");
|
||||
await disposal;
|
||||
|
||||
await owner.dispose();
|
||||
assertEquals(
|
||||
firstCleanups,
|
||||
1,
|
||||
"repeated disposal must not duplicate owned cleanup",
|
||||
);
|
||||
assertEquals(
|
||||
secondCleanups,
|
||||
1,
|
||||
"repeated disposal must not duplicate late cleanup",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("listener registration failure cleans the partial scope and skips later listeners", async () => {
|
||||
const owner = new AsyncOwner();
|
||||
const failure = new Error("registration failed");
|
||||
let firstCleanups = 0;
|
||||
let thirdRegistrations = 0;
|
||||
let reported: unknown;
|
||||
|
||||
try {
|
||||
await registerSequentially(owner, [
|
||||
() =>
|
||||
Promise.resolve(() => {
|
||||
firstCleanups += 1;
|
||||
}),
|
||||
() => Promise.reject(failure),
|
||||
() => {
|
||||
thirdRegistrations += 1;
|
||||
return Promise.resolve(() => {});
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
reported = error;
|
||||
}
|
||||
|
||||
assertEquals(reported, failure, "the registration error should be preserved");
|
||||
assertEquals(
|
||||
firstCleanups,
|
||||
1,
|
||||
"earlier listeners should clean up on setup failure",
|
||||
);
|
||||
assertEquals(
|
||||
thirdRegistrations,
|
||||
0,
|
||||
"later listeners should not start after setup failure",
|
||||
);
|
||||
assertEquals(
|
||||
owner.isActive(),
|
||||
false,
|
||||
"a failed registration scope should stay disposed",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("late refresh results and post-disposal refreshes cannot publish", async () => {
|
||||
const owner = new AsyncOwner();
|
||||
const refresh = deferred<string>();
|
||||
let refreshStarts = 0;
|
||||
const applied: string[] = [];
|
||||
|
||||
const pending = owner.applyIfActive(
|
||||
() => {
|
||||
refreshStarts += 1;
|
||||
return refresh.promise;
|
||||
},
|
||||
(value) => applied.push(value),
|
||||
);
|
||||
|
||||
const disposal = owner.dispose();
|
||||
refresh.resolve("stale");
|
||||
assertEquals(
|
||||
await pending,
|
||||
false,
|
||||
"a late refresh should report cancellation",
|
||||
);
|
||||
assertArrayEquals(applied, [], "a late refresh must not publish state");
|
||||
|
||||
const afterDispose = await owner.applyIfActive(
|
||||
() => {
|
||||
refreshStarts += 1;
|
||||
return Promise.resolve("newer");
|
||||
},
|
||||
(value) => applied.push(value),
|
||||
);
|
||||
assertEquals(
|
||||
afterDispose,
|
||||
false,
|
||||
"a disposed owner should reject new refresh work",
|
||||
);
|
||||
assertEquals(refreshStarts, 1, "refresh work must not start after disposal");
|
||||
assertArrayEquals(
|
||||
applied,
|
||||
[],
|
||||
"post-disposal refreshes must not publish state",
|
||||
);
|
||||
await disposal;
|
||||
});
|
||||
|
||||
Deno.test("disposal joins a late asynchronous listener cleanup", async () => {
|
||||
const owner = new AsyncOwner();
|
||||
const listener = deferred<AsyncCleanup>();
|
||||
const cleanupDone = deferred<void>();
|
||||
let cleanupStarts = 0;
|
||||
let disposalSettled = false;
|
||||
|
||||
const registration = owner.register(() => listener.promise);
|
||||
const disposal = owner.dispose().then(() => {
|
||||
disposalSettled = true;
|
||||
});
|
||||
listener.resolve(async () => {
|
||||
cleanupStarts += 1;
|
||||
await cleanupDone.promise;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assertEquals(
|
||||
cleanupStarts,
|
||||
1,
|
||||
"the late cleanup should start immediately on resolution",
|
||||
);
|
||||
assertEquals(
|
||||
disposalSettled,
|
||||
false,
|
||||
"disposal must wait for the asynchronous cleanup",
|
||||
);
|
||||
|
||||
cleanupDone.resolve();
|
||||
assertEquals(
|
||||
await registration,
|
||||
false,
|
||||
"the disposed registration should remain cancelled",
|
||||
);
|
||||
await disposal;
|
||||
assertEquals(
|
||||
disposalSettled,
|
||||
true,
|
||||
"disposal should settle after cleanup completion",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("asynchronous cleanup rejection is reported and disposal still drains", async () => {
|
||||
const cleanupDone = deferred<void>();
|
||||
const failure = new Error("unlisten failed");
|
||||
const reported: unknown[] = [];
|
||||
const owner = new AsyncOwner((error) => reported.push(error));
|
||||
|
||||
await owner.register(() =>
|
||||
Promise.resolve(async () => {
|
||||
await cleanupDone.promise;
|
||||
throw failure;
|
||||
})
|
||||
);
|
||||
const disposal = owner.dispose();
|
||||
cleanupDone.resolve();
|
||||
await disposal;
|
||||
|
||||
assertEquals(
|
||||
reported.length,
|
||||
1,
|
||||
"the cleanup failure should be reported once",
|
||||
);
|
||||
assertEquals(
|
||||
reported[0],
|
||||
failure,
|
||||
"the original cleanup failure should be reported",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("a root adoption scope drains work and observes rejection", async () => {
|
||||
const first = deferred<void>();
|
||||
const failure = new Error("adopted cleanup failed");
|
||||
const reported: unknown[] = [];
|
||||
const scope = new AsyncAdoptionScope((error) => reported.push(error));
|
||||
let drained = false;
|
||||
|
||||
scope.adopt(first.promise);
|
||||
scope.adopt(Promise.reject(failure));
|
||||
const drain = scope.drain().then(() => {
|
||||
drained = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
assertEquals(drained, false, "the adoption scope should retain pending work");
|
||||
assertEquals(
|
||||
reported.length,
|
||||
1,
|
||||
"an adopted rejection should be observed once",
|
||||
);
|
||||
assertEquals(
|
||||
reported[0],
|
||||
failure,
|
||||
"the adoption scope should report the original error",
|
||||
);
|
||||
|
||||
first.resolve();
|
||||
await drain;
|
||||
assertEquals(
|
||||
drained,
|
||||
true,
|
||||
"the adoption scope should drain after all work settles",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("window close drains every admitted operation and rejects late owner registration", async () => {
|
||||
const scope = new AsyncAdoptionScope();
|
||||
const cleanupDone = deferred<void>();
|
||||
const unrelatedInvoke = deferred<string>();
|
||||
const owner = new AsyncOwner(() => {}, scope);
|
||||
let cleanupStarts = 0;
|
||||
|
||||
await owner.register(() =>
|
||||
Promise.resolve(async () => {
|
||||
cleanupStarts += 1;
|
||||
await cleanupDone.promise;
|
||||
})
|
||||
);
|
||||
const unrelated = owner.applyIfActive(
|
||||
() => unrelatedInvoke.promise,
|
||||
() => {},
|
||||
);
|
||||
let closeDrained = false;
|
||||
const close = scope.disposeOwned().then(() => {
|
||||
closeDrained = true;
|
||||
});
|
||||
|
||||
const lateOwner = new AsyncOwner(() => {}, scope);
|
||||
assertEquals(
|
||||
lateOwner.isActive(),
|
||||
false,
|
||||
"owner admission must close synchronously",
|
||||
);
|
||||
assertEquals(cleanupStarts, 1, "listener cleanup should start during close");
|
||||
assertEquals(closeDrained, false, "window close must await listener cleanup");
|
||||
|
||||
cleanupDone.resolve();
|
||||
await Promise.resolve();
|
||||
assertEquals(
|
||||
closeDrained,
|
||||
false,
|
||||
"an admitted invoke must retain the webview after listener cleanup",
|
||||
);
|
||||
|
||||
unrelatedInvoke.resolve("settled during close");
|
||||
assertEquals(
|
||||
await unrelated,
|
||||
false,
|
||||
"unrelated late invoke publication stays suppressed",
|
||||
);
|
||||
await close;
|
||||
assertEquals(
|
||||
closeDrained,
|
||||
true,
|
||||
"window close must join every admitted operation",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("closing during a dialog prevents its chained invoke from starting", async () => {
|
||||
const scope = new AsyncAdoptionScope();
|
||||
const owner = new AsyncOwner(() => {}, scope);
|
||||
const dialog = deferred<boolean>();
|
||||
let invokes = 0;
|
||||
|
||||
const action = (async () => {
|
||||
let confirmed = false;
|
||||
if (
|
||||
!await owner.applyIfActive(
|
||||
() => dialog.promise,
|
||||
(answer) => {
|
||||
confirmed = answer;
|
||||
},
|
||||
) || !confirmed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await owner.applyIfActive(
|
||||
() => {
|
||||
invokes += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
})();
|
||||
|
||||
const close = scope.disposeOwned();
|
||||
dialog.resolve(true);
|
||||
await action;
|
||||
await close;
|
||||
|
||||
assertEquals(
|
||||
invokes,
|
||||
0,
|
||||
"a dialog result must not start work after close admission",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("closing during a helper import prevents later helper side effects", async () => {
|
||||
const scope = new AsyncAdoptionScope();
|
||||
const owner = new AsyncOwner(() => {}, scope);
|
||||
const imported = deferred<string>();
|
||||
let helperStarts = 0;
|
||||
|
||||
const helper = (async () => {
|
||||
let moduleName: string | undefined;
|
||||
if (
|
||||
!await owner.applyIfActive(
|
||||
() => imported.promise,
|
||||
(value) => {
|
||||
moduleName = value;
|
||||
},
|
||||
) || moduleName === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await owner.applyIfActive(
|
||||
() => {
|
||||
helperStarts += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
})();
|
||||
|
||||
const close = scope.disposeOwned();
|
||||
imported.resolve("window helper");
|
||||
await helper;
|
||||
await close;
|
||||
|
||||
assertEquals(
|
||||
helperStarts,
|
||||
0,
|
||||
"a late import must not focus or create a window",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("close after companion construction drains creation and both listeners before destroy", async () => {
|
||||
const scope = new AsyncAdoptionScope();
|
||||
const owner = new AsyncOwner(() => {}, scope);
|
||||
const createdCleanupDone = deferred<void>();
|
||||
const errorCleanupDone = deferred<void>();
|
||||
const bothCleanupsStarted = deferred<void>();
|
||||
const companionDestroyStarted = deferred<void>();
|
||||
const companionDestroyDone = deferred<void>();
|
||||
const order: string[] = [];
|
||||
let createdHandler: (() => void) | undefined;
|
||||
let errorHandler: ((payload: unknown) => void) | undefined;
|
||||
let cleanupStarts = 0;
|
||||
let latePublications = 0;
|
||||
let rootDestroyed = false;
|
||||
|
||||
const noteCleanupStart = () => {
|
||||
cleanupStarts += 1;
|
||||
if (cleanupStarts === 2) bothCleanupsStarted.resolve();
|
||||
};
|
||||
const creation = ownCompanionWindowCreation(owner, {
|
||||
registerCreated: (handler) => {
|
||||
createdHandler = handler;
|
||||
return Promise.resolve(async () => {
|
||||
order.push("created-listener-cleanup");
|
||||
noteCleanupStart();
|
||||
await createdCleanupDone.promise;
|
||||
});
|
||||
},
|
||||
registerError: (handler) => {
|
||||
errorHandler = handler;
|
||||
return Promise.resolve(async () => {
|
||||
order.push("error-listener-cleanup");
|
||||
noteCleanupStart();
|
||||
await errorCleanupDone.promise;
|
||||
});
|
||||
},
|
||||
destroy: async () => {
|
||||
order.push("companion-destroy");
|
||||
companionDestroyStarted.resolve();
|
||||
await companionDestroyDone.promise;
|
||||
},
|
||||
}).then((result) => {
|
||||
if (owner.isActive()) latePublications += 1;
|
||||
return result;
|
||||
});
|
||||
scope.adopt(creation);
|
||||
|
||||
if (createdHandler === undefined || errorHandler === undefined) {
|
||||
throw new Error(
|
||||
"construction must synchronously begin both creation listeners",
|
||||
);
|
||||
}
|
||||
const close = scope.disposeOwned().then(() => {
|
||||
order.push("root-destroy");
|
||||
rootDestroyed = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assertEquals(
|
||||
rootDestroyed,
|
||||
false,
|
||||
"close must wait for the native creation outcome",
|
||||
);
|
||||
|
||||
order.push("created-event");
|
||||
createdHandler();
|
||||
errorHandler(new Error("late losing outcome"));
|
||||
await bothCleanupsStarted.promise;
|
||||
assertEquals(
|
||||
rootDestroyed,
|
||||
false,
|
||||
"root destruction must wait for both native unlisten acknowledgements",
|
||||
);
|
||||
|
||||
createdCleanupDone.resolve();
|
||||
errorCleanupDone.resolve();
|
||||
await companionDestroyStarted.promise;
|
||||
assertEquals(
|
||||
rootDestroyed,
|
||||
false,
|
||||
"a companion created after close must be destroyed before its parent realm",
|
||||
);
|
||||
companionDestroyDone.resolve();
|
||||
|
||||
assertEquals(
|
||||
(await creation).kind,
|
||||
"created",
|
||||
"the first native outcome must win exactly once",
|
||||
);
|
||||
await close;
|
||||
assertEquals(
|
||||
latePublications,
|
||||
0,
|
||||
"late creation must not publish into disposed React state",
|
||||
);
|
||||
assertArrayEquals(
|
||||
order,
|
||||
[
|
||||
"created-event",
|
||||
"created-listener-cleanup",
|
||||
"error-listener-cleanup",
|
||||
"companion-destroy",
|
||||
"root-destroy",
|
||||
],
|
||||
"creation, listener cleanup, companion destruction, and root destruction stay ordered",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("listener-first bootstrap keeps an update that arrives during the initial refresh", async () => {
|
||||
const owner = new AsyncOwner();
|
||||
const initial = deferred<string>();
|
||||
const update = deferred<string>();
|
||||
const published: string[] = [];
|
||||
let emitUpdate: (() => Promise<boolean>) | undefined;
|
||||
const order: string[] = [];
|
||||
|
||||
await owner.register(() => {
|
||||
order.push("listener");
|
||||
emitUpdate = () =>
|
||||
owner.applyLatestIfActive(
|
||||
() => update.promise,
|
||||
(value) => published.push(value),
|
||||
);
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
order.push("snapshot");
|
||||
const initialRefresh = owner.applyLatestIfActive(
|
||||
() => initial.promise,
|
||||
(value) => published.push(value),
|
||||
);
|
||||
const eventRefresh = emitUpdate?.();
|
||||
if (eventRefresh === undefined) throw new Error("listener was not installed");
|
||||
|
||||
initial.resolve("stale-initial");
|
||||
assertEquals(
|
||||
await initialRefresh,
|
||||
false,
|
||||
"the event should supersede the bootstrap snapshot",
|
||||
);
|
||||
update.resolve("event-update");
|
||||
assertEquals(await eventRefresh, true, "the event refresh should publish");
|
||||
|
||||
assertArrayEquals(
|
||||
order,
|
||||
["listener", "snapshot"],
|
||||
"the listener must precede the snapshot",
|
||||
);
|
||||
assertArrayEquals(
|
||||
published,
|
||||
["event-update"],
|
||||
"the bootstrap interval must not lose updates",
|
||||
);
|
||||
await owner.dispose();
|
||||
});
|
||||
|
||||
Deno.test("out-of-order refresh completion publishes only the latest request", async () => {
|
||||
const owner = new AsyncOwner();
|
||||
const older = deferred<string>();
|
||||
const newer = deferred<string>();
|
||||
const published: string[] = [];
|
||||
|
||||
const olderRefresh = owner.applyLatestIfActive(
|
||||
() => older.promise,
|
||||
(value) => published.push(value),
|
||||
);
|
||||
const newerRefresh = owner.applyLatestIfActive(
|
||||
() => newer.promise,
|
||||
(value) => published.push(value),
|
||||
);
|
||||
|
||||
newer.resolve("newer");
|
||||
assertEquals(await newerRefresh, true, "the newest refresh should publish");
|
||||
older.resolve("older");
|
||||
assertEquals(
|
||||
await olderRefresh,
|
||||
false,
|
||||
"the older refresh should be suppressed",
|
||||
);
|
||||
assertArrayEquals(
|
||||
published,
|
||||
["newer"],
|
||||
"older completion must not overwrite newer state",
|
||||
);
|
||||
await owner.dispose();
|
||||
});
|
||||
|
||||
Deno.test("settings hydration keeps saved fields while applying newer user edits", () => {
|
||||
const restored = {
|
||||
accent: "saved-accent",
|
||||
density: "saved-density",
|
||||
username: "saved-user",
|
||||
};
|
||||
const pendingEdits = { accent: "user-edit" };
|
||||
|
||||
const merged = mergeHydratedState(restored, pendingEdits);
|
||||
|
||||
assertEquals(
|
||||
merged.accent,
|
||||
"user-edit",
|
||||
"the newer edit should win its field",
|
||||
);
|
||||
assertEquals(
|
||||
merged.density,
|
||||
"saved-density",
|
||||
"unrelated saved fields must survive",
|
||||
);
|
||||
assertEquals(
|
||||
merged.username,
|
||||
"saved-user",
|
||||
"the full saved baseline must be retained",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("serialized writes do not start a newer value before the older value settles", async () => {
|
||||
const firstDone = deferred<void>();
|
||||
const firstStarted = deferred<void>();
|
||||
const secondDone = deferred<void>();
|
||||
const secondStarted = deferred<void>();
|
||||
const starts: number[] = [];
|
||||
const writer = createSerializedAsyncWriter<number>((value) => {
|
||||
starts.push(value);
|
||||
if (value === 1) {
|
||||
firstStarted.resolve();
|
||||
return firstDone.promise;
|
||||
}
|
||||
secondStarted.resolve();
|
||||
return secondDone.promise;
|
||||
}, () => {});
|
||||
|
||||
const firstWrite = writer.enqueue(1);
|
||||
const secondWrite = writer.enqueue(2);
|
||||
await firstStarted.promise;
|
||||
assertArrayEquals(
|
||||
starts,
|
||||
[1],
|
||||
"only the oldest write should start initially",
|
||||
);
|
||||
|
||||
firstDone.resolve();
|
||||
await firstWrite;
|
||||
await secondStarted.promise;
|
||||
assertArrayEquals(
|
||||
starts,
|
||||
[1, 2],
|
||||
"the newer write should start after the older write",
|
||||
);
|
||||
|
||||
secondDone.resolve();
|
||||
await secondWrite;
|
||||
await writer.waitForIdle();
|
||||
});
|
||||
|
||||
Deno.test("a failed serialized write is reported and does not block the newer value", async () => {
|
||||
const firstDone = deferred<void>();
|
||||
const firstStarted = deferred<void>();
|
||||
const secondStarted = deferred<void>();
|
||||
const failure = new Error("first write failed");
|
||||
const errors: unknown[] = [];
|
||||
const starts: number[] = [];
|
||||
const writer = createSerializedAsyncWriter<number>(async (value) => {
|
||||
starts.push(value);
|
||||
if (value === 1) {
|
||||
firstStarted.resolve();
|
||||
await firstDone.promise;
|
||||
throw failure;
|
||||
}
|
||||
secondStarted.resolve();
|
||||
}, (error) => errors.push(error));
|
||||
|
||||
const firstWrite = writer.enqueue(1);
|
||||
const secondWrite = writer.enqueue(2);
|
||||
await firstStarted.promise;
|
||||
assertArrayEquals(
|
||||
starts,
|
||||
[1],
|
||||
"the failed write should still own the queue first",
|
||||
);
|
||||
|
||||
firstDone.resolve();
|
||||
await firstWrite;
|
||||
await secondStarted.promise;
|
||||
await secondWrite;
|
||||
|
||||
assertArrayEquals(
|
||||
starts,
|
||||
[1, 2],
|
||||
"a newer write should run after the failure is handled",
|
||||
);
|
||||
assertEquals(errors.length, 1, "the failed write should be reported once");
|
||||
assertEquals(
|
||||
errors[0],
|
||||
failure,
|
||||
"the original write failure should be reported",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("closing a writer stops admission and drains the pending write", async () => {
|
||||
const pending = deferred<void>();
|
||||
const started = deferred<void>();
|
||||
const writes: number[] = [];
|
||||
const writer = createSerializedAsyncWriter<number>(async (value) => {
|
||||
writes.push(value);
|
||||
started.resolve();
|
||||
await pending.promise;
|
||||
}, () => {});
|
||||
|
||||
void writer.enqueue(1);
|
||||
await started.promise;
|
||||
let drained = false;
|
||||
const drain = writer.closeAndWait().then(() => {
|
||||
drained = true;
|
||||
});
|
||||
await writer.enqueue(2);
|
||||
|
||||
assertEquals(drained, false, "close must wait for the admitted write");
|
||||
assertArrayEquals(writes, [1], "close must reject newer writes");
|
||||
pending.resolve();
|
||||
await drain;
|
||||
assertEquals(drained, true, "close should settle after the pending write");
|
||||
});
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
phaseOf,
|
||||
bumpTime,
|
||||
normalizeTimeInput,
|
||||
pruneCallToPlayEvents,
|
||||
readyCountOf,
|
||||
reduceCallToPlayEvents,
|
||||
replaceCallToPlayView,
|
||||
sortNominations,
|
||||
statusOf,
|
||||
} from '../src/lib/callToPlay.ts';
|
||||
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
|
||||
import { type CallToPlayAction, type CallToPlayViewEvent } from '../src/lib/types.ts';
|
||||
|
||||
const NOW = 1_000_000;
|
||||
|
||||
@@ -33,11 +33,11 @@ const event = (
|
||||
action: CallToPlayAction,
|
||||
at = NOW,
|
||||
actorName = actorId,
|
||||
): CallToPlayEvent => ({
|
||||
): CallToPlayViewEvent => ({
|
||||
id,
|
||||
call_id: 'call-1',
|
||||
actor_id: actorId,
|
||||
actor_name: actorName,
|
||||
author_id: actorId,
|
||||
author_name: actorName,
|
||||
at,
|
||||
action,
|
||||
});
|
||||
@@ -45,7 +45,7 @@ const event = (
|
||||
const create = (
|
||||
scheduledFor: number | null = null,
|
||||
deadline = NOW + 30 * 60_000,
|
||||
): CallToPlayEvent => event('create', 'Alice', {
|
||||
): CallToPlayViewEvent => event('create', 'Alice', {
|
||||
Create: {
|
||||
game_id: 'game-1',
|
||||
max_players: 3,
|
||||
@@ -187,17 +187,17 @@ Deno.test('publish failures distinguish startup and store outcomes', () => {
|
||||
'peer startup message',
|
||||
);
|
||||
assertEquals(
|
||||
callToPlayPublishErrorMessage('Call to Play event is obsolete'),
|
||||
callToPlayPublishErrorMessage('Call-to-Play call x is unknown or expired'),
|
||||
'This Call to Play has expired or already finished.',
|
||||
'obsolete call message',
|
||||
);
|
||||
assertEquals(
|
||||
callToPlayPublishErrorMessage('Call to Play history is missing'),
|
||||
callToPlayPublishErrorMessage('Call-to-Play call x is already terminal'),
|
||||
'This Call to Play has expired or already finished.',
|
||||
'missing expired history message',
|
||||
);
|
||||
assertEquals(
|
||||
callToPlayPublishErrorMessage(new Error('Call to Play event history is full')),
|
||||
callToPlayPublishErrorMessage(new Error('Call-to-Play local event history is full')),
|
||||
'Call to Play has reached its active update limit. Start or cancel an active call, then try again.',
|
||||
'active history limit message',
|
||||
);
|
||||
@@ -210,15 +210,12 @@ Deno.test('publish failures distinguish startup and store outcomes', () => {
|
||||
|
||||
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
|
||||
const message = event('message-event', 'Bob', {
|
||||
SendMessage: { message_id: 'message-1', text: 'Ready?' },
|
||||
SendMessage: { text: 'Ready?' },
|
||||
}, NOW + 2);
|
||||
const duplicateMessage = event('other-event', 'Bob', {
|
||||
SendMessage: { message_id: 'message-1', text: 'duplicate' },
|
||||
}, NOW + 3);
|
||||
const events = [message, create(), message, duplicateMessage];
|
||||
const events = [message, create(), message];
|
||||
const [nomination] = reduceCallToPlayEvents(events, NOW + 4);
|
||||
assertEquals(nomination.messages.length, 1, 'unique message id');
|
||||
assertEquals(nomination.messages[0].text, 'Ready?', 'first message wins');
|
||||
assertEquals(nomination.messages.length, 1, 'event IDs deduplicate messages');
|
||||
assertEquals(nomination.messages[0].text, 'Ready?', 'message retained');
|
||||
});
|
||||
|
||||
Deno.test('actions timestamped before creation cannot mutate a call', () => {
|
||||
@@ -237,11 +234,11 @@ Deno.test('terminal calls retain complete read-only history for fifteen minutes'
|
||||
create(),
|
||||
event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
|
||||
event('message', 'Bob', {
|
||||
SendMessage: { message_id: 'message-1', text: 'Launching' },
|
||||
SendMessage: { text: 'Launching' },
|
||||
}, NOW + 2),
|
||||
event('start', 'Alice', 'Start', NOW + 3),
|
||||
event('late-message', 'Bob', {
|
||||
SendMessage: { message_id: 'message-2', text: 'Too late' },
|
||||
SendMessage: { text: 'Too late' },
|
||||
}, NOW + 4),
|
||||
];
|
||||
const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS);
|
||||
@@ -273,34 +270,13 @@ Deno.test('terminal calls sort last and do not contribute to the badge', () => {
|
||||
assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
|
||||
});
|
||||
|
||||
Deno.test('retired calls are pruned from the frontend raw event map', () => {
|
||||
const terminalEvents = [
|
||||
create(),
|
||||
event('start', 'Alice', 'Start', NOW + 1),
|
||||
];
|
||||
const map = new Map(terminalEvents.map(item => [item.id, item]));
|
||||
Deno.test('incoming Call to Play views replace removed author slices wholesale', () => {
|
||||
const previous = { events: [create(), event('join', 'Bob', 'Rsvp', NOW + 1)] };
|
||||
const incoming = { events: [create()] };
|
||||
|
||||
assertEquals(
|
||||
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS).size,
|
||||
2,
|
||||
'visible terminal history stays cached',
|
||||
);
|
||||
assertEquals(
|
||||
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS + 1).size,
|
||||
0,
|
||||
'retired terminal history is pruned',
|
||||
);
|
||||
|
||||
const tombstone = event('terminal-only', 'Alice', 'Cancel', NOW + 1);
|
||||
const tombstoneMap = new Map([[tombstone.id, tombstone]]);
|
||||
assertEquals(
|
||||
pruneCallToPlayEvents(
|
||||
tombstoneMap,
|
||||
NOW + 1 + TERMINAL_RETENTION_MS + 1,
|
||||
).size,
|
||||
0,
|
||||
'backend tombstone is pruned too',
|
||||
);
|
||||
const replaced = replaceCallToPlayView(previous, incoming);
|
||||
assertEquals(replaced.events.length, 1, 'departed author slice is removed');
|
||||
assertEquals(replaced.events[0].id, 'create', 'incoming projection is authoritative');
|
||||
});
|
||||
|
||||
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import {
|
||||
CallToPlayAsyncScope,
|
||||
type CallToPlayRetryCallback,
|
||||
type CallToPlayRetryScheduler,
|
||||
} from '../src/lib/callToPlayOwnership.ts';
|
||||
import { type AsyncCleanup } from '../src/lib/asyncOwnership.ts';
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertArrayEquals = <T>(actual: T[], expected: T[], message: string) => {
|
||||
assertEquals(JSON.stringify(actual), JSON.stringify(expected), message);
|
||||
};
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
interface ScheduledRetry {
|
||||
callback: CallToPlayRetryCallback;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
class ManualRetryScheduler {
|
||||
private readonly scheduled: ScheduledRetry[] = [];
|
||||
|
||||
public readonly schedule: CallToPlayRetryScheduler = callback => {
|
||||
const retry = { callback, cancelled: false };
|
||||
this.scheduled.push(retry);
|
||||
return () => {
|
||||
retry.cancelled = true;
|
||||
};
|
||||
};
|
||||
|
||||
public pendingCount(): number {
|
||||
return this.scheduled.filter(retry => !retry.cancelled).length;
|
||||
}
|
||||
|
||||
public fireNext(): Promise<void> {
|
||||
const index = this.scheduled.findIndex(retry => !retry.cancelled);
|
||||
if (index < 0) throw new Error('no retry is scheduled');
|
||||
const [retry] = this.scheduled.splice(index, 1);
|
||||
return retry.callback();
|
||||
}
|
||||
}
|
||||
|
||||
const noRetry: CallToPlayRetryScheduler = () => () => {};
|
||||
|
||||
Deno.test('queued listener callbacks are ignored and a late listener unlistens after disposal', async () => {
|
||||
const scope = new CallToPlayAsyncScope(noRetry);
|
||||
const listener = deferred<AsyncCleanup>();
|
||||
const cleanupDone = deferred<void>();
|
||||
const published: string[] = [];
|
||||
let callback: ((value: string) => void) | undefined;
|
||||
let unlistens = 0;
|
||||
|
||||
const registration = scope.registerListener(() => {
|
||||
callback = scope.guard(value => published.push(value));
|
||||
return listener.promise;
|
||||
});
|
||||
|
||||
const disposal = scope.dispose();
|
||||
callback?.('queued-after-dispose');
|
||||
listener.resolve(async () => {
|
||||
unlistens += 1;
|
||||
await cleanupDone.promise;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assertEquals(unlistens, 1, 'the listener should unlisten as soon as registration resolves');
|
||||
cleanupDone.resolve();
|
||||
assertEquals(await registration, false, 'the late registration should report cancellation');
|
||||
await disposal;
|
||||
assertArrayEquals(published, [], 'a queued listener must not publish after disposal');
|
||||
});
|
||||
|
||||
Deno.test('retry attempts never overlap and stop after readiness', async () => {
|
||||
const scheduler = new ManualRetryScheduler();
|
||||
const scope = new CallToPlayAsyncScope(scheduler.schedule);
|
||||
const first = deferred<boolean>();
|
||||
const second = deferred<boolean>();
|
||||
const firstStarted = deferred<void>();
|
||||
const secondStarted = deferred<void>();
|
||||
let attempts = 0;
|
||||
|
||||
scope.startRetry(() => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
firstStarted.resolve();
|
||||
return first.promise;
|
||||
}
|
||||
secondStarted.resolve();
|
||||
return second.promise;
|
||||
}, 2_000);
|
||||
|
||||
assertEquals(scheduler.pendingCount(), 1, 'starting retry should schedule one attempt');
|
||||
const firstRun = scheduler.fireNext();
|
||||
await firstStarted.promise;
|
||||
assertEquals(attempts, 1, 'the first retry should start');
|
||||
assertEquals(scheduler.pendingCount(), 0, 'no retry should queue while one is in flight');
|
||||
|
||||
scope.startRetry(() => Promise.resolve(false), 2_000);
|
||||
assertEquals(scheduler.pendingCount(), 0, 'starting again must not overlap the in-flight retry');
|
||||
|
||||
first.resolve(false);
|
||||
await firstRun;
|
||||
assertEquals(scheduler.pendingCount(), 1, 'a failed attempt should schedule its successor');
|
||||
|
||||
const secondRun = scheduler.fireNext();
|
||||
await secondStarted.promise;
|
||||
assertEquals(attempts, 2, 'the successor should start only after the first settled');
|
||||
assertEquals(scheduler.pendingCount(), 0, 'the second in-flight retry must be exclusive');
|
||||
|
||||
second.resolve(true);
|
||||
await secondRun;
|
||||
assertEquals(scheduler.pendingCount(), 0, 'readiness should stop the retry loop');
|
||||
await scope.dispose();
|
||||
});
|
||||
|
||||
Deno.test('disposal waits for an in-flight retry attempt', async () => {
|
||||
const scheduler = new ManualRetryScheduler();
|
||||
const scope = new CallToPlayAsyncScope(scheduler.schedule);
|
||||
const attempt = deferred<boolean>();
|
||||
const started = deferred<void>();
|
||||
let disposalSettled = false;
|
||||
|
||||
scope.startRetry(() => {
|
||||
started.resolve();
|
||||
return attempt.promise;
|
||||
}, 2_000);
|
||||
const retry = scheduler.fireNext();
|
||||
await started.promise;
|
||||
|
||||
const disposal = scope.dispose().then(() => {
|
||||
disposalSettled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assertEquals(disposalSettled, false, 'disposal must join the running retry');
|
||||
|
||||
attempt.resolve(false);
|
||||
await retry;
|
||||
await disposal;
|
||||
assertEquals(disposalSettled, true, 'disposal should settle after the retry exits');
|
||||
assertEquals(scheduler.pendingCount(), 0, 'a disposed retry must not schedule a successor');
|
||||
});
|
||||
|
||||
Deno.test('late snapshots and actions cannot publish after their scope is disposed', async () => {
|
||||
const scope = new CallToPlayAsyncScope(noRetry);
|
||||
const snapshot = deferred<string>();
|
||||
const action = deferred<boolean>();
|
||||
const publications: string[] = [];
|
||||
let snapshotStarts = 0;
|
||||
let actionStarts = 0;
|
||||
|
||||
const pendingSnapshot = scope.applyIfActive(
|
||||
() => {
|
||||
snapshotStarts += 1;
|
||||
return snapshot.promise;
|
||||
},
|
||||
value => publications.push(`snapshot:${value}`),
|
||||
);
|
||||
const pendingAction = scope.applyIfActive(
|
||||
() => {
|
||||
actionStarts += 1;
|
||||
return action.promise;
|
||||
},
|
||||
value => publications.push(`action:${value}`),
|
||||
);
|
||||
|
||||
const disposal = scope.dispose();
|
||||
snapshot.resolve('stale');
|
||||
action.resolve(true);
|
||||
|
||||
assertEquals(await pendingSnapshot, false, 'the late snapshot should report cancellation');
|
||||
assertEquals(await pendingAction, false, 'the late action should report cancellation');
|
||||
await disposal;
|
||||
assertArrayEquals(publications, [], 'late work must not publish into a disposed hook');
|
||||
|
||||
const postDisposeAction = await scope.applyIfActive(
|
||||
() => {
|
||||
actionStarts += 1;
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
value => publications.push(`post-dispose:${value}`),
|
||||
);
|
||||
assertEquals(postDisposeAction, false, 'an action must not start after disposal');
|
||||
assertEquals(snapshotStarts, 1, 'the snapshot should start exactly once');
|
||||
assertEquals(actionStarts, 1, 'only the pre-disposal action should start');
|
||||
assertArrayEquals(publications, [], 'post-disposal actions must not publish');
|
||||
});
|
||||
|
||||
Deno.test('an older action completion cannot overwrite a newer action status', async () => {
|
||||
const scope = new CallToPlayAsyncScope(noRetry);
|
||||
const older = deferred<boolean>();
|
||||
const newer = deferred<boolean>();
|
||||
const statuses: string[] = [];
|
||||
|
||||
const publishOlder = scope.guardLatestAction((accepted: boolean) => {
|
||||
statuses.push(`older:${accepted}`);
|
||||
});
|
||||
const olderAction = scope.applyIfActive(
|
||||
() => older.promise,
|
||||
accepted => publishOlder(accepted),
|
||||
);
|
||||
|
||||
const publishNewer = scope.guardLatestAction((accepted: boolean) => {
|
||||
statuses.push(`newer:${accepted}`);
|
||||
});
|
||||
const newerAction = scope.applyIfActive(
|
||||
() => newer.promise,
|
||||
accepted => publishNewer(accepted),
|
||||
);
|
||||
|
||||
newer.resolve(true);
|
||||
assertEquals(await newerAction, true, 'the newer action should complete normally');
|
||||
older.resolve(false);
|
||||
assertEquals(await olderAction, true, 'the older action result should still complete');
|
||||
|
||||
assertArrayEquals(statuses, ['newer:true'], 'only the newest action may publish status');
|
||||
await scope.dispose();
|
||||
});
|
||||
|
||||
Deno.test('backend mutations start in user order even when the first invoke is delayed', async () => {
|
||||
const scope = new CallToPlayAsyncScope(noRetry);
|
||||
const first = deferred<string>();
|
||||
const second = deferred<string>();
|
||||
const started: string[] = [];
|
||||
const applied: string[] = [];
|
||||
|
||||
const firstMutation = scope.applyMutationIfActive(
|
||||
() => {
|
||||
started.push('display-name:older');
|
||||
return first.promise;
|
||||
},
|
||||
value => applied.push(value),
|
||||
);
|
||||
const secondMutation = scope.applyMutationIfActive(
|
||||
() => {
|
||||
started.push('action:newer');
|
||||
return second.promise;
|
||||
},
|
||||
value => applied.push(value),
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
assertArrayEquals(started, ['display-name:older'], 'the newer mutation must remain queued');
|
||||
|
||||
first.resolve('older-applied');
|
||||
assertEquals(await firstMutation, true, 'the first mutation should publish while active');
|
||||
await Promise.resolve();
|
||||
assertArrayEquals(
|
||||
started,
|
||||
['display-name:older', 'action:newer'],
|
||||
'the second backend invoke must start after the first settles',
|
||||
);
|
||||
|
||||
second.resolve('newer-applied');
|
||||
assertEquals(await secondMutation, true, 'the second mutation should publish while active');
|
||||
assertArrayEquals(
|
||||
applied,
|
||||
['older-applied', 'newer-applied'],
|
||||
'mutation results should publish in backend order',
|
||||
);
|
||||
await scope.dispose();
|
||||
});
|
||||
|
||||
Deno.test('disposal drains admitted mutations and rejects later admission', async () => {
|
||||
const scope = new CallToPlayAsyncScope(noRetry);
|
||||
const first = deferred<void>();
|
||||
const second = deferred<void>();
|
||||
const started: string[] = [];
|
||||
const publications: string[] = [];
|
||||
|
||||
const firstMutation = scope.applyMutationIfActive(
|
||||
() => {
|
||||
started.push('first');
|
||||
return first.promise;
|
||||
},
|
||||
() => publications.push('first'),
|
||||
);
|
||||
const secondMutation = scope.applyMutationIfActive(
|
||||
() => {
|
||||
started.push('second');
|
||||
return second.promise;
|
||||
},
|
||||
() => publications.push('second'),
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
let disposed = false;
|
||||
const disposal = scope.dispose().then(() => {
|
||||
disposed = true;
|
||||
});
|
||||
const rejected = await scope.applyMutationIfActive(
|
||||
() => {
|
||||
started.push('rejected');
|
||||
return Promise.resolve();
|
||||
},
|
||||
() => publications.push('rejected'),
|
||||
);
|
||||
assertEquals(rejected, false, 'a mutation submitted after disposal must be rejected');
|
||||
assertEquals(disposed, false, 'disposal must wait for admitted mutations');
|
||||
|
||||
first.resolve();
|
||||
assertEquals(await firstMutation, false, 'disposed scopes suppress the first result');
|
||||
await Promise.resolve();
|
||||
assertArrayEquals(started, ['first', 'second'], 'the admitted successor must still run');
|
||||
assertEquals(disposed, false, 'disposal must also wait for the admitted successor');
|
||||
|
||||
second.resolve();
|
||||
assertEquals(await secondMutation, false, 'disposed scopes suppress the second result');
|
||||
await disposal;
|
||||
assertEquals(disposed, true, 'disposal should settle after the admitted queue drains');
|
||||
assertArrayEquals(started, ['first', 'second'], 'no post-disposal mutation may start');
|
||||
assertArrayEquals(publications, [], 'disposed mutation results must not publish');
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
bootstrapFrontend,
|
||||
type FrontendCloseRequest,
|
||||
} from '../src/lib/frontendBootstrap.ts';
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
};
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
Deno.test('close-listener registration failure destroys the empty window without mounting hooks', async () => {
|
||||
const registrationFailure = new Error('registration failed');
|
||||
const destructionStarted = deferred<void>();
|
||||
const destruction = deferred<void>();
|
||||
const order: string[] = [];
|
||||
const reported: unknown[] = [];
|
||||
let renders = 0;
|
||||
let settled = false;
|
||||
|
||||
const bootstrap = bootstrapFrontend({
|
||||
registerCloseRequested: () => Promise.reject(registrationFailure),
|
||||
disposeOwners: () => {
|
||||
order.push('dispose owners');
|
||||
return Promise.resolve();
|
||||
},
|
||||
drainPersistence: () => {
|
||||
order.push('drain persistence');
|
||||
return Promise.resolve();
|
||||
},
|
||||
destroyWindow: () => {
|
||||
order.push('destroy window');
|
||||
destructionStarted.resolve();
|
||||
return destruction.promise;
|
||||
},
|
||||
render: () => {
|
||||
renders += 1;
|
||||
},
|
||||
reportFailure: (_message, error) => reported.push(error),
|
||||
}).then(result => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
|
||||
await destructionStarted.promise;
|
||||
assertEquals(renders, 0, 'React and its hooks must remain unmounted');
|
||||
assertEquals(settled, false, 'bootstrap must explicitly await window destruction');
|
||||
assertEquals(
|
||||
JSON.stringify(order),
|
||||
JSON.stringify(['dispose owners', 'drain persistence', 'destroy window']),
|
||||
'admissions must close and drain before destroying the empty window',
|
||||
);
|
||||
|
||||
destruction.resolve();
|
||||
assertEquals(await bootstrap, false, 'registration failure must report no render');
|
||||
assertEquals(renders, 0, 'hooks must never mount after failure cleanup');
|
||||
assertEquals(reported.length, 1, 'the registration failure must be handled once');
|
||||
assertEquals(reported[0], registrationFailure, 'the original failure must be reported');
|
||||
});
|
||||
|
||||
Deno.test('bootstrap handles failure of the fail-closed destroy action', async () => {
|
||||
const registrationFailure = new Error('registration failed');
|
||||
const destructionFailure = new Error('destroy failed');
|
||||
const reported: unknown[] = [];
|
||||
let renders = 0;
|
||||
|
||||
const rendered = await bootstrapFrontend({
|
||||
registerCloseRequested: () => Promise.reject(registrationFailure),
|
||||
disposeOwners: () => Promise.resolve(),
|
||||
drainPersistence: () => Promise.resolve(),
|
||||
destroyWindow: () => Promise.reject(destructionFailure),
|
||||
render: () => {
|
||||
renders += 1;
|
||||
},
|
||||
reportFailure: (_message, error) => reported.push(error),
|
||||
});
|
||||
|
||||
assertEquals(rendered, false, 'a failed registration must never render');
|
||||
assertEquals(renders, 0, 'destroy failure must not fall back to mounting hooks');
|
||||
assertEquals(reported.length, 2, 'both terminal failures must be handled');
|
||||
assertEquals(reported[0], registrationFailure, 'registration failure must be preserved');
|
||||
assertEquals(reported[1], destructionFailure, 'destroy failure must be observed');
|
||||
});
|
||||
|
||||
Deno.test('successful bootstrap mounts only after close registration resolves', async () => {
|
||||
const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>();
|
||||
const registrationAcknowledged = deferred<() => void>();
|
||||
let renders = 0;
|
||||
|
||||
const bootstrap = bootstrapFrontend({
|
||||
registerCloseRequested: handler => {
|
||||
registered.resolve(handler);
|
||||
return registrationAcknowledged.promise;
|
||||
},
|
||||
disposeOwners: () => Promise.resolve(),
|
||||
drainPersistence: () => Promise.resolve(),
|
||||
destroyWindow: () => Promise.resolve(),
|
||||
render: () => {
|
||||
renders += 1;
|
||||
},
|
||||
reportFailure: () => {},
|
||||
});
|
||||
|
||||
await registered.promise;
|
||||
assertEquals(renders, 0, 'render must wait for registration acknowledgement');
|
||||
registrationAcknowledged.resolve(() => {});
|
||||
assertEquals(await bootstrap, true, 'successful registration should render the app');
|
||||
assertEquals(renders, 1, 'the app should mount once');
|
||||
});
|
||||
|
||||
Deno.test('close before registration acknowledgement owns late cleanup and suppresses render', async () => {
|
||||
const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>();
|
||||
const registrationAcknowledged = deferred<() => void>();
|
||||
const cleanupDone = deferred<void>();
|
||||
const order: string[] = [];
|
||||
let renders = 0;
|
||||
let prevented = 0;
|
||||
|
||||
const bootstrap = bootstrapFrontend({
|
||||
registerCloseRequested: handler => {
|
||||
registered.resolve(handler);
|
||||
return registrationAcknowledged.promise;
|
||||
},
|
||||
disposeOwners: () => {
|
||||
order.push('dispose owners');
|
||||
return Promise.resolve();
|
||||
},
|
||||
drainPersistence: () => {
|
||||
order.push('drain persistence');
|
||||
return Promise.resolve();
|
||||
},
|
||||
destroyWindow: () => {
|
||||
order.push('destroy window');
|
||||
return Promise.resolve();
|
||||
},
|
||||
render: () => {
|
||||
renders += 1;
|
||||
},
|
||||
reportFailure: () => {},
|
||||
});
|
||||
|
||||
const close = (await registered.promise)({
|
||||
preventDefault: () => {
|
||||
prevented += 1;
|
||||
},
|
||||
});
|
||||
assertEquals(prevented, 1, 'the pre-acknowledgement close must be intercepted');
|
||||
assertEquals(renders, 0, 'React must remain unmounted while registration is unresolved');
|
||||
|
||||
registrationAcknowledged.resolve(async () => {
|
||||
order.push('unlisten');
|
||||
await cleanupDone.promise;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assertEquals(renders, 0, 'registration acknowledgement must not render after close began');
|
||||
assertEquals(
|
||||
order.includes('destroy window'),
|
||||
false,
|
||||
'the late listener cleanup must drain before destruction',
|
||||
);
|
||||
|
||||
cleanupDone.resolve();
|
||||
await close;
|
||||
assertEquals(await bootstrap, false, 'a pre-render close must report no mounted app');
|
||||
assertEquals(renders, 0, 'a closing realm must never mount React');
|
||||
assertEquals(
|
||||
JSON.stringify(order),
|
||||
JSON.stringify(['dispose owners', 'drain persistence', 'unlisten', 'destroy window']),
|
||||
'late listener ownership must join the same close finalizer',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('render failure drains and unregisters before handled window destruction', async () => {
|
||||
const renderFailure = new Error('render failed');
|
||||
const unlistenFailure = new Error('unlisten failed');
|
||||
const destroyFailure = new Error('destroy failed');
|
||||
const ownerDrained = deferred<void>();
|
||||
const persistenceDrained = deferred<void>();
|
||||
const finalizerStarted = deferred<void>();
|
||||
const order: string[] = [];
|
||||
const reported: unknown[] = [];
|
||||
let cleanups = 0;
|
||||
|
||||
const bootstrap = bootstrapFrontend({
|
||||
registerCloseRequested: () => {
|
||||
order.push('register');
|
||||
return Promise.resolve(async () => {
|
||||
order.push('unlisten');
|
||||
cleanups += 1;
|
||||
finalizerStarted.resolve();
|
||||
throw unlistenFailure;
|
||||
});
|
||||
},
|
||||
disposeOwners: async () => {
|
||||
order.push('dispose owners');
|
||||
await ownerDrained.promise;
|
||||
},
|
||||
drainPersistence: async () => {
|
||||
order.push('drain persistence');
|
||||
await persistenceDrained.promise;
|
||||
},
|
||||
destroyWindow: () => {
|
||||
order.push('destroy window');
|
||||
return Promise.reject(destroyFailure);
|
||||
},
|
||||
render: () => {
|
||||
order.push('render');
|
||||
throw renderFailure;
|
||||
},
|
||||
reportFailure: (_message, error) => reported.push(error),
|
||||
});
|
||||
|
||||
await finalizerStarted.promise;
|
||||
assertEquals(cleanups, 1, 'the installed close listener must unregister exactly once');
|
||||
assertEquals(
|
||||
JSON.stringify(order),
|
||||
JSON.stringify(['register', 'render', 'dispose owners', 'drain persistence', 'unlisten']),
|
||||
'render failure must synchronously start every frontend finalizer',
|
||||
);
|
||||
|
||||
ownerDrained.resolve();
|
||||
await Promise.resolve();
|
||||
assertEquals(
|
||||
order.includes('destroy window'),
|
||||
false,
|
||||
'window destruction must wait for persistence as well as owner cleanup',
|
||||
);
|
||||
persistenceDrained.resolve();
|
||||
|
||||
assertEquals(await bootstrap, false, 'a render failure must be fully handled');
|
||||
assertEquals(
|
||||
JSON.stringify(order),
|
||||
JSON.stringify([
|
||||
'register',
|
||||
'render',
|
||||
'dispose owners',
|
||||
'drain persistence',
|
||||
'unlisten',
|
||||
'destroy window',
|
||||
]),
|
||||
'destruction must be the terminal finalizer',
|
||||
);
|
||||
assertEquals(reported.length, 3, 'render, cleanup, and destroy failures must be observed');
|
||||
assertEquals(reported[0], renderFailure, 'the render failure must be reported first');
|
||||
assertEquals(reported[1], unlistenFailure, 'listener cleanup rejection must be reported');
|
||||
assertEquals(reported[2], destroyFailure, 'window destroy rejection must be reported');
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
PersistenceShutdownScope,
|
||||
startAdmittedPersistenceEffect,
|
||||
} from '../src/lib/frontendPersistence.ts';
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
};
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
Deno.test('window close drains admitted persistence and rejects later registration', async () => {
|
||||
const first = deferred<void>();
|
||||
const starts: string[] = [];
|
||||
const scope = new PersistenceShutdownScope();
|
||||
|
||||
scope.register(() => {
|
||||
starts.push('first');
|
||||
return first.promise;
|
||||
});
|
||||
let drained = false;
|
||||
const drain = scope.closeAndDrain().then(() => {
|
||||
drained = true;
|
||||
});
|
||||
const lateRegistration = scope.register(() => {
|
||||
starts.push('late');
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
assertEquals(lateRegistration, undefined, 'closed persistence admission must stay closed');
|
||||
assertEquals(JSON.stringify(starts), JSON.stringify(['first']), 'late queues must not start');
|
||||
assertEquals(drained, false, 'close must remain pending while persistence is active');
|
||||
first.resolve();
|
||||
await drain;
|
||||
assertEquals(drained, true, 'close should settle after every admitted queue');
|
||||
});
|
||||
|
||||
Deno.test('settings hydration cannot start after close wins before its passive effect', async () => {
|
||||
const scope = new PersistenceShutdownScope();
|
||||
await scope.closeAndDrain();
|
||||
let storeLoads = 0;
|
||||
let publications = 0;
|
||||
|
||||
const cleanup = startAdmittedPersistenceEffect(
|
||||
scope,
|
||||
() => Promise.resolve(),
|
||||
() => {
|
||||
storeLoads += 1;
|
||||
publications += 1;
|
||||
},
|
||||
);
|
||||
cleanup();
|
||||
|
||||
assertEquals(storeLoads, 0, 'closed admission must prevent the settings store load');
|
||||
assertEquals(publications, 0, 'closed admission must prevent settings publication');
|
||||
});
|
||||
|
||||
Deno.test('game-directory hydration cannot load or invoke after pre-effect close', async () => {
|
||||
const scope = new PersistenceShutdownScope();
|
||||
await scope.closeAndDrain();
|
||||
let storeLoads = 0;
|
||||
let backendInvokes = 0;
|
||||
|
||||
const cleanup = startAdmittedPersistenceEffect(
|
||||
scope,
|
||||
() => Promise.resolve(),
|
||||
() => {
|
||||
storeLoads += 1;
|
||||
backendInvokes += 1;
|
||||
},
|
||||
);
|
||||
cleanup();
|
||||
|
||||
assertEquals(storeLoads, 0, 'closed admission must prevent the directory store load');
|
||||
assertEquals(backendInvokes, 0, 'closed admission must prevent the directory invoke');
|
||||
});
|
||||
|
||||
Deno.test('persistence shutdown rejection is observed without blocking other queues', async () => {
|
||||
const failure = new Error('persistence drain failed');
|
||||
const reported: unknown[] = [];
|
||||
const scope = new PersistenceShutdownScope(error => reported.push(error));
|
||||
let secondFinished = false;
|
||||
|
||||
scope.register(() => Promise.reject(failure));
|
||||
scope.register(async () => {
|
||||
secondFinished = true;
|
||||
});
|
||||
await scope.closeAndDrain();
|
||||
|
||||
assertEquals(reported.length, 1, 'the failed shutdown should be reported once');
|
||||
assertEquals(reported[0], failure, 'the original shutdown failure should be reported');
|
||||
assertEquals(secondFinished, true, 'one failure must not skip another persistence queue');
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
acceptGameDirectory,
|
||||
hydrateGameDirectory,
|
||||
} from '../src/lib/gameDirectory.ts';
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
};
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
Deno.test('game directory is neither persisted nor used before backend acknowledgement', async () => {
|
||||
const acknowledgement = deferred<unknown>();
|
||||
const persisted: string[] = [];
|
||||
let displayedPath = '/old/path';
|
||||
|
||||
const update = acceptGameDirectory('/picked/../path', {
|
||||
updateBackend: requestedPath => {
|
||||
assertEquals(
|
||||
requestedPath,
|
||||
'/picked/../path',
|
||||
'backend should receive the requested path',
|
||||
);
|
||||
return acknowledgement.promise;
|
||||
},
|
||||
persist: acceptedPath => {
|
||||
persisted.push(acceptedPath);
|
||||
return Promise.resolve();
|
||||
},
|
||||
reportPersistenceError: () => {},
|
||||
});
|
||||
|
||||
assertEquals(displayedPath, '/old/path', 'pending update should retain the old UI path');
|
||||
assertEquals(persisted.length, 0, 'pending update should not write the requested path');
|
||||
|
||||
acknowledgement.resolve('/canonical/path');
|
||||
displayedPath = await update;
|
||||
|
||||
assertEquals(displayedPath, '/canonical/path', 'UI should use the acknowledged path');
|
||||
assertEquals(persisted.length, 1, 'acknowledged path should be persisted once');
|
||||
assertEquals(persisted[0], '/canonical/path', 'store should receive the canonical path');
|
||||
});
|
||||
|
||||
Deno.test('backend rejection leaves the old game directory untouched', async () => {
|
||||
const rejected = new Error('directory rejected');
|
||||
const persisted: string[] = [];
|
||||
let displayedPath = '/old/path';
|
||||
|
||||
try {
|
||||
displayedPath = await acceptGameDirectory('/rejected/path', {
|
||||
updateBackend: () => Promise.reject(rejected),
|
||||
persist: acceptedPath => {
|
||||
persisted.push(acceptedPath);
|
||||
return Promise.resolve();
|
||||
},
|
||||
reportPersistenceError: () => {},
|
||||
});
|
||||
throw new Error('expected backend rejection');
|
||||
} catch (error) {
|
||||
assertEquals(error, rejected, 'backend error should be preserved');
|
||||
}
|
||||
|
||||
assertEquals(displayedPath, '/old/path', 'rejection should retain the old UI path');
|
||||
assertEquals(persisted.length, 0, 'rejection should not change persistent state');
|
||||
});
|
||||
|
||||
Deno.test('invalid backend acknowledgement is not persisted', async () => {
|
||||
let persisted = false;
|
||||
let rejected = false;
|
||||
|
||||
try {
|
||||
await acceptGameDirectory('/picked/path', {
|
||||
updateBackend: () => Promise.resolve(' '),
|
||||
persist: () => {
|
||||
persisted = true;
|
||||
return Promise.resolve();
|
||||
},
|
||||
reportPersistenceError: () => {},
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
assertEquals(rejected, true, 'empty acknowledgement should be rejected');
|
||||
assertEquals(persisted, false, 'invalid acknowledgement should not be persisted');
|
||||
});
|
||||
|
||||
Deno.test('persistence failure does not undo an acknowledged backend update', async () => {
|
||||
const persistenceError = new Error('store unavailable');
|
||||
let reportedError: unknown;
|
||||
|
||||
const acceptedPath = await acceptGameDirectory('/picked/path', {
|
||||
updateBackend: () => Promise.resolve('/canonical/path'),
|
||||
persist: () => Promise.reject(persistenceError),
|
||||
reportPersistenceError: error => {
|
||||
reportedError = error;
|
||||
},
|
||||
});
|
||||
|
||||
assertEquals(acceptedPath, '/canonical/path', 'backend acknowledgement should remain accepted');
|
||||
assertEquals(reportedError, persistenceError, 'persistence failure should be reported');
|
||||
});
|
||||
|
||||
Deno.test('saved game-directory hydration settles only after backend acceptance', async () => {
|
||||
const acceptance = deferred<void>();
|
||||
let settled = false;
|
||||
const hydration = hydrateGameDirectory({
|
||||
loadSavedPath: () => Promise.resolve('/saved/path'),
|
||||
acceptSavedPath: path => {
|
||||
assertEquals(path, '/saved/path', 'saved path should be restored');
|
||||
return acceptance.promise;
|
||||
},
|
||||
reportLoadError: () => {},
|
||||
}).then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assertEquals(settled, false, 'hydration must wait for backend acceptance');
|
||||
acceptance.resolve();
|
||||
await hydration;
|
||||
assertEquals(settled, true, 'hydration should settle after acceptance');
|
||||
});
|
||||
|
||||
Deno.test('missing or failed game-directory state still completes hydration', async () => {
|
||||
let accepts = 0;
|
||||
await hydrateGameDirectory({
|
||||
loadSavedPath: () => Promise.resolve(undefined),
|
||||
acceptSavedPath: () => {
|
||||
accepts += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
reportLoadError: () => {},
|
||||
});
|
||||
assertEquals(accepts, 0, 'missing state should not invoke the backend');
|
||||
|
||||
const failure = new Error('store unavailable');
|
||||
let reported: unknown;
|
||||
await hydrateGameDirectory({
|
||||
loadSavedPath: () => Promise.reject(failure),
|
||||
acceptSavedPath: () => Promise.resolve(),
|
||||
reportLoadError: error => {
|
||||
reported = error;
|
||||
},
|
||||
});
|
||||
assertEquals(reported, failure, 'load failure should be reported before ready');
|
||||
});
|
||||
@@ -1,286 +1,418 @@
|
||||
import {
|
||||
actionLabel,
|
||||
activeStatusById,
|
||||
applyFilterAndSort,
|
||||
canStreamInstall,
|
||||
countByFilter,
|
||||
deriveState,
|
||||
downloadProgressPercent,
|
||||
formatDownloadBytes,
|
||||
formatBytesPerSecond,
|
||||
formatDownloadEta,
|
||||
formatDownloadSpeed,
|
||||
formatDownloadSpeedShort,
|
||||
gameStatusLabel,
|
||||
mergeGameUpdate,
|
||||
stateChipLabel,
|
||||
} from '../src/lib/gameState.ts';
|
||||
actionLabel,
|
||||
activeStatusById,
|
||||
applyFilterAndSort,
|
||||
applyGameTransferStatusSnapshot,
|
||||
canStreamInstall,
|
||||
countByFilter,
|
||||
deriveState,
|
||||
downloadProgressPercent,
|
||||
formatBytesPerSecond,
|
||||
formatDownloadBytes,
|
||||
formatDownloadEta,
|
||||
formatDownloadSpeed,
|
||||
formatDownloadSpeedShort,
|
||||
gameStatusLabel,
|
||||
mergeGameUpdate,
|
||||
stateChipLabel,
|
||||
} from "../src/lib/gameState.ts";
|
||||
import {
|
||||
ActiveOperationKind,
|
||||
GameAvailability,
|
||||
InstallStatus,
|
||||
type Game,
|
||||
} from '../src/lib/types.ts';
|
||||
ActiveOperationKind,
|
||||
type Game,
|
||||
GameAvailability,
|
||||
GameTransferStatus,
|
||||
InstallStatus,
|
||||
} from "../src/lib/types.ts";
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
};
|
||||
|
||||
const game = (overrides: Partial<Game> = {}): Game => ({
|
||||
id: 'game',
|
||||
name: 'Game',
|
||||
description: '',
|
||||
size: 0,
|
||||
downloaded: false,
|
||||
installed: false,
|
||||
availability: GameAvailability.LocalOnly,
|
||||
install_status: InstallStatus.NotInstalled,
|
||||
id: "game",
|
||||
name: "Game",
|
||||
description: "",
|
||||
size: 0,
|
||||
downloaded: false,
|
||||
installed: false,
|
||||
availability: GameAvailability.LocalOnly,
|
||||
install_status: InstallStatus.NotInstalled,
|
||||
peer_count: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
Deno.test("snapshot keeps installing visible until installed state settles", () => {
|
||||
const fromDownloading = game({
|
||||
install_status: InstallStatus.Downloading,
|
||||
});
|
||||
const installing = mergeGameUpdate(
|
||||
game({ downloaded: true }),
|
||||
fromDownloading,
|
||||
InstallStatus.Installing,
|
||||
);
|
||||
const installedWhileActive = mergeGameUpdate(
|
||||
game({ downloaded: true, installed: true }),
|
||||
installing,
|
||||
InstallStatus.Installing,
|
||||
);
|
||||
const settled = mergeGameUpdate(
|
||||
game({ downloaded: true, installed: true }),
|
||||
installedWhileActive,
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
installing.install_status,
|
||||
InstallStatus.Installing,
|
||||
"active install snapshot should render Installing",
|
||||
);
|
||||
assertEquals(
|
||||
installedWhileActive.install_status,
|
||||
InstallStatus.Installing,
|
||||
"installed local state should not override an active install snapshot",
|
||||
);
|
||||
assertEquals(
|
||||
settled.install_status,
|
||||
InstallStatus.Installed,
|
||||
"cleared active snapshot with installed local state should render Installed",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("active operation snapshot is the source of busy status", () => {
|
||||
const statuses = activeStatusById([
|
||||
{ id: "game", operation: ActiveOperationKind.Downloading },
|
||||
{ id: "other", operation: ActiveOperationKind.Updating },
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
statuses.get("game"),
|
||||
InstallStatus.Downloading,
|
||||
"download operation should render Downloading",
|
||||
);
|
||||
assertEquals(
|
||||
statuses.get("other"),
|
||||
InstallStatus.Installing,
|
||||
"update operation should render Installing",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("download progress is preserved only while actively downloading", () => {
|
||||
const downloading = game({
|
||||
install_status: InstallStatus.Downloading,
|
||||
download_progress: {
|
||||
attemptId: "1",
|
||||
downloaded_bytes: 50,
|
||||
total_bytes: 100,
|
||||
bytes_per_second: 12_500_000,
|
||||
active_peer_count: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const stillDownloading = mergeGameUpdate(
|
||||
game(),
|
||||
downloading,
|
||||
InstallStatus.Downloading,
|
||||
);
|
||||
const settled = mergeGameUpdate(game({ downloaded: true }), stillDownloading);
|
||||
|
||||
assertEquals(
|
||||
stillDownloading.download_progress?.downloaded_bytes,
|
||||
50,
|
||||
"active download snapshot should keep progress",
|
||||
);
|
||||
assertEquals(
|
||||
stillDownloading.download_progress?.active_peer_count,
|
||||
2,
|
||||
"active download snapshot should keep live peer count",
|
||||
);
|
||||
assertEquals(
|
||||
settled.download_progress,
|
||||
undefined,
|
||||
"settled snapshot should clear progress",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("downloading action label includes current speed", () => {
|
||||
const downloading = game({
|
||||
install_status: InstallStatus.Downloading,
|
||||
download_progress: {
|
||||
attemptId: "1",
|
||||
downloaded_bytes: 50,
|
||||
total_bytes: 100,
|
||||
bytes_per_second: 12_500_000,
|
||||
active_peer_count: 2,
|
||||
},
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
formatBytesPerSecond(12_500_000),
|
||||
"12.5 MB/s",
|
||||
"speed formatter should use compact decimal units",
|
||||
);
|
||||
assertEquals(
|
||||
actionLabel(downloading),
|
||||
"Downloading… 12.5 MB/s",
|
||||
"download label should include speed",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("downloading state is distinct and stays on the local filter", () => {
|
||||
const downloading = game({
|
||||
id: "downloading",
|
||||
name: "Downloading",
|
||||
install_status: InstallStatus.Downloading,
|
||||
});
|
||||
const local = game({
|
||||
id: "local",
|
||||
name: "Local",
|
||||
downloaded: true,
|
||||
});
|
||||
const remote = game({
|
||||
id: "remote",
|
||||
name: "Remote",
|
||||
peer_count: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
deriveState(downloading),
|
||||
"downloading",
|
||||
"download operation should render the dedicated downloading state",
|
||||
);
|
||||
assertEquals(
|
||||
countByFilter([downloading, local, remote]).local,
|
||||
2,
|
||||
"local filter count should include in-flight downloads",
|
||||
);
|
||||
assertEquals(
|
||||
applyFilterAndSort([downloading, local, remote], "local", "status", "")
|
||||
.length,
|
||||
2,
|
||||
"local filter should include in-flight downloads",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('snapshot keeps installing visible until installed state settles', () => {
|
||||
const fromDownloading = game({
|
||||
install_status: InstallStatus.Downloading,
|
||||
});
|
||||
const installing = mergeGameUpdate(
|
||||
game({ downloaded: true }),
|
||||
fromDownloading,
|
||||
InstallStatus.Installing,
|
||||
);
|
||||
const installedWhileActive = mergeGameUpdate(
|
||||
game({ downloaded: true, installed: true }),
|
||||
installing,
|
||||
InstallStatus.Installing,
|
||||
);
|
||||
const settled = mergeGameUpdate(
|
||||
game({ downloaded: true, installed: true }),
|
||||
installedWhileActive,
|
||||
);
|
||||
Deno.test("sticky transfer exhaustion keeps a departed remote game visible until cleared", () => {
|
||||
const remoteOnly = game({
|
||||
id: "remote-only",
|
||||
name: "Remote only",
|
||||
peer_count: 1,
|
||||
});
|
||||
assertEquals(
|
||||
applyFilterAndSort([remoteOnly], "all", "az", "").length,
|
||||
1,
|
||||
"a remote-only game should initially be visible",
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
installing.install_status,
|
||||
InstallStatus.Installing,
|
||||
'active install snapshot should render Installing',
|
||||
);
|
||||
assertEquals(
|
||||
installedWhileActive.install_status,
|
||||
InstallStatus.Installing,
|
||||
'installed local state should not override an active install snapshot',
|
||||
);
|
||||
assertEquals(
|
||||
settled.install_status,
|
||||
InstallStatus.Installed,
|
||||
'cleared active snapshot with installed local state should render Installed',
|
||||
);
|
||||
const peerDeparted = mergeGameUpdate(
|
||||
game({ id: "remote-only", name: "Remote only", peer_count: 0 }),
|
||||
remoteOnly,
|
||||
);
|
||||
assertEquals(
|
||||
peerDeparted.install_status,
|
||||
InstallStatus.NotInstalled,
|
||||
"an empty active-operation snapshot should leave the game idle",
|
||||
);
|
||||
assertEquals(
|
||||
peerDeparted.peer_count,
|
||||
0,
|
||||
"the sole remote source should be absent from the next catalog snapshot",
|
||||
);
|
||||
assertEquals(
|
||||
applyFilterAndSort([peerDeparted], "all", "az", "").length,
|
||||
0,
|
||||
"an idle remote-only game should be invisible before exhaustion arrives",
|
||||
);
|
||||
|
||||
const [exhausted] = applyGameTransferStatusSnapshot([peerDeparted], {
|
||||
revision: 1,
|
||||
statuses: { "remote-only": GameTransferStatus.Exhausted },
|
||||
openAttempts: {},
|
||||
});
|
||||
assertEquals(
|
||||
exhausted.transfer_status,
|
||||
GameTransferStatus.Exhausted,
|
||||
"the sticky snapshot should preserve the exact terminal status",
|
||||
);
|
||||
assertEquals(
|
||||
applyFilterAndSort([exhausted], "all", "az", "").length,
|
||||
1,
|
||||
"terminal exhaustion should keep the departed remote game visible",
|
||||
);
|
||||
assertEquals(
|
||||
countByFilter([exhausted]).all,
|
||||
1,
|
||||
"the All-filter count should match terminal exhaustion visibility",
|
||||
);
|
||||
|
||||
const [cleared] = applyGameTransferStatusSnapshot([exhausted], {
|
||||
revision: 2,
|
||||
statuses: {},
|
||||
openAttempts: {},
|
||||
});
|
||||
assertEquals(
|
||||
cleared.transfer_status,
|
||||
undefined,
|
||||
"a full replacement that omits the game should clear exhaustion",
|
||||
);
|
||||
assertEquals(
|
||||
applyFilterAndSort([cleared], "all", "az", "").length,
|
||||
0,
|
||||
"clearing exhaustion should remove the otherwise-invisible game",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('active operation snapshot is the source of busy status', () => {
|
||||
const statuses = activeStatusById([
|
||||
{ id: 'game', operation: ActiveOperationKind.Downloading },
|
||||
{ id: 'other', operation: ActiveOperationKind.Updating },
|
||||
]);
|
||||
|
||||
Deno.test("transient transfer activity does not resurrect an invisible game", () => {
|
||||
const departed = game({ peer_count: 0 });
|
||||
for (
|
||||
const transferStatus of [
|
||||
GameTransferStatus.Verifying,
|
||||
GameTransferStatus.Retrying,
|
||||
]
|
||||
) {
|
||||
assertEquals(
|
||||
statuses.get('game'),
|
||||
InstallStatus.Downloading,
|
||||
'download operation should render Downloading',
|
||||
);
|
||||
assertEquals(
|
||||
statuses.get('other'),
|
||||
InstallStatus.Installing,
|
||||
'update operation should render Installing',
|
||||
applyFilterAndSort(
|
||||
[{ ...departed, transfer_status: transferStatus }],
|
||||
"all",
|
||||
"az",
|
||||
"",
|
||||
).length,
|
||||
0,
|
||||
`${transferStatus} should not make an otherwise-invisible game visible`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('download progress is preserved only while actively downloading', () => {
|
||||
const downloading = game({
|
||||
install_status: InstallStatus.Downloading,
|
||||
download_progress: {
|
||||
downloaded_bytes: 50,
|
||||
total_bytes: 100,
|
||||
bytes_per_second: 12_500_000,
|
||||
active_peer_count: 2,
|
||||
},
|
||||
});
|
||||
Deno.test("download progress formatting matches the progress-bar layouts", () => {
|
||||
const downloading = game({
|
||||
install_status: InstallStatus.Downloading,
|
||||
download_progress: {
|
||||
attemptId: "1",
|
||||
downloaded_bytes: 12 * 1024 * 1024 * 1024,
|
||||
total_bytes: 35 * 1024 * 1024 * 1024,
|
||||
bytes_per_second: 49_400_000,
|
||||
active_peer_count: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const stillDownloading = mergeGameUpdate(
|
||||
game(),
|
||||
downloading,
|
||||
InstallStatus.Downloading,
|
||||
);
|
||||
const settled = mergeGameUpdate(game({ downloaded: true }), stillDownloading);
|
||||
|
||||
assertEquals(
|
||||
stillDownloading.download_progress?.downloaded_bytes,
|
||||
50,
|
||||
'active download snapshot should keep progress',
|
||||
);
|
||||
assertEquals(
|
||||
stillDownloading.download_progress?.active_peer_count,
|
||||
2,
|
||||
'active download snapshot should keep live peer count',
|
||||
);
|
||||
assertEquals(
|
||||
settled.download_progress,
|
||||
undefined,
|
||||
'settled snapshot should clear progress',
|
||||
);
|
||||
assertEquals(
|
||||
Math.round(downloadProgressPercent(downloading)),
|
||||
34,
|
||||
"progress percent should come from backend byte counters",
|
||||
);
|
||||
assertEquals(
|
||||
formatDownloadSpeed(49_400_000),
|
||||
"49.4 MB/s",
|
||||
"large bar speed format",
|
||||
);
|
||||
assertEquals(
|
||||
formatDownloadSpeedShort(49_400_000),
|
||||
"49 MB/s",
|
||||
"card speed format",
|
||||
);
|
||||
assertEquals(
|
||||
formatDownloadBytes(12 * 1024 * 1024 * 1024),
|
||||
"12 GB",
|
||||
"downloaded byte format should avoid noisy trailing decimals",
|
||||
);
|
||||
assertEquals(
|
||||
formatDownloadEta(485),
|
||||
"8 min",
|
||||
"eta format should stay compact",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('downloading action label includes current speed', () => {
|
||||
const downloading = game({
|
||||
install_status: InstallStatus.Downloading,
|
||||
download_progress: {
|
||||
downloaded_bytes: 50,
|
||||
total_bytes: 100,
|
||||
bytes_per_second: 12_500_000,
|
||||
active_peer_count: 2,
|
||||
},
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
formatBytesPerSecond(12_500_000),
|
||||
'12.5 MB/s',
|
||||
'speed formatter should use compact decimal units',
|
||||
);
|
||||
assertEquals(
|
||||
actionLabel(downloading),
|
||||
'Downloading… 12.5 MB/s',
|
||||
'download label should include speed',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('downloading state is distinct and stays on the local filter', () => {
|
||||
const downloading = game({
|
||||
id: 'downloading',
|
||||
name: 'Downloading',
|
||||
install_status: InstallStatus.Downloading,
|
||||
});
|
||||
const local = game({
|
||||
id: 'local',
|
||||
name: 'Local',
|
||||
downloaded: true,
|
||||
});
|
||||
const remote = game({
|
||||
id: 'remote',
|
||||
name: 'Remote',
|
||||
peer_count: 1,
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
deriveState(downloading),
|
||||
'downloading',
|
||||
'download operation should render the dedicated downloading state',
|
||||
);
|
||||
assertEquals(
|
||||
countByFilter([downloading, local, remote]).local,
|
||||
2,
|
||||
'local filter count should include in-flight downloads',
|
||||
);
|
||||
assertEquals(
|
||||
applyFilterAndSort([downloading, local, remote], 'local', 'status', '').length,
|
||||
2,
|
||||
'local filter should include in-flight downloads',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('download progress formatting matches the progress-bar layouts', () => {
|
||||
const downloading = game({
|
||||
install_status: InstallStatus.Downloading,
|
||||
download_progress: {
|
||||
downloaded_bytes: 12 * 1024 * 1024 * 1024,
|
||||
total_bytes: 35 * 1024 * 1024 * 1024,
|
||||
bytes_per_second: 49_400_000,
|
||||
active_peer_count: 3,
|
||||
},
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
Math.round(downloadProgressPercent(downloading)),
|
||||
34,
|
||||
'progress percent should come from backend byte counters',
|
||||
);
|
||||
assertEquals(formatDownloadSpeed(49_400_000), '49.4 MB/s', 'large bar speed format');
|
||||
assertEquals(formatDownloadSpeedShort(49_400_000), '49 MB/s', 'card speed format');
|
||||
assertEquals(
|
||||
formatDownloadBytes(12 * 1024 * 1024 * 1024),
|
||||
'12 GB',
|
||||
'downloaded byte format should avoid noisy trailing decimals',
|
||||
);
|
||||
assertEquals(formatDownloadEta(485), '8 min', 'eta format should stay compact');
|
||||
});
|
||||
|
||||
Deno.test('stream install is available only for idle remote games', () => {
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 1 })),
|
||||
true,
|
||||
'remote-only idle games should allow streamed install',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: true, installed: false, peer_count: 1 })),
|
||||
false,
|
||||
'downloaded games should install from local archives',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: false, installed: true, peer_count: 1 })),
|
||||
false,
|
||||
'installed games should not expose streamed install',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 0 })),
|
||||
false,
|
||||
'games without peers should not expose streamed install',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({
|
||||
downloaded: false,
|
||||
installed: false,
|
||||
peer_count: 1,
|
||||
install_status: InstallStatus.CheckingPeers,
|
||||
})),
|
||||
false,
|
||||
'busy games should not expose streamed install',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('streamed local installs are labeled installed but not shareable', () => {
|
||||
const streamed = game({
|
||||
Deno.test("stream install is available only for idle remote games", () => {
|
||||
assertEquals(
|
||||
canStreamInstall(
|
||||
game({ downloaded: false, installed: false, peer_count: 1 }),
|
||||
true,
|
||||
),
|
||||
true,
|
||||
"catalog-supported remote-only idle games should allow streamed install",
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(
|
||||
game({ downloaded: false, installed: false, peer_count: 1 }),
|
||||
false,
|
||||
),
|
||||
false,
|
||||
"catalog-unsupported games should not expose streamed install",
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(
|
||||
game({ downloaded: true, installed: false, peer_count: 1 }),
|
||||
true,
|
||||
),
|
||||
false,
|
||||
"downloaded games should install from local archives",
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(
|
||||
game({ downloaded: false, installed: true, peer_count: 1 }),
|
||||
true,
|
||||
),
|
||||
false,
|
||||
"installed games should not expose streamed install",
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(
|
||||
game({ downloaded: false, installed: false, peer_count: 0 }),
|
||||
true,
|
||||
),
|
||||
false,
|
||||
"games without peers should not expose streamed install",
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(
|
||||
game({
|
||||
downloaded: false,
|
||||
installed: true,
|
||||
install_status: InstallStatus.Installed,
|
||||
});
|
||||
const downloadedInstall = game({
|
||||
downloaded: true,
|
||||
installed: true,
|
||||
install_status: InstallStatus.Installed,
|
||||
});
|
||||
installed: false,
|
||||
peer_count: 1,
|
||||
install_status: InstallStatus.Installing,
|
||||
}),
|
||||
true,
|
||||
),
|
||||
false,
|
||||
"busy games should not expose streamed install",
|
||||
);
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
deriveState(streamed),
|
||||
'installed',
|
||||
'streamed local installs should keep installed visual state',
|
||||
);
|
||||
assertEquals(
|
||||
stateChipLabel(streamed),
|
||||
'Not shareable',
|
||||
'card chip should make the non-shareable state visible',
|
||||
);
|
||||
assertEquals(
|
||||
gameStatusLabel(streamed),
|
||||
'Installed, not shareable',
|
||||
'detail status should spell out installed plus non-shareable',
|
||||
);
|
||||
assertEquals(
|
||||
stateChipLabel(downloadedInstall),
|
||||
'Installed',
|
||||
'normal downloaded installs should keep the installed chip label',
|
||||
);
|
||||
assertEquals(
|
||||
gameStatusLabel(downloadedInstall),
|
||||
'Installed',
|
||||
'normal downloaded installs should keep the installed detail label',
|
||||
);
|
||||
Deno.test("streamed local installs are labeled installed but not shareable", () => {
|
||||
const streamed = game({
|
||||
downloaded: false,
|
||||
installed: true,
|
||||
install_status: InstallStatus.Installed,
|
||||
});
|
||||
const downloadedInstall = game({
|
||||
downloaded: true,
|
||||
installed: true,
|
||||
install_status: InstallStatus.Installed,
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
deriveState(streamed),
|
||||
"installed",
|
||||
"streamed local installs should keep installed visual state",
|
||||
);
|
||||
assertEquals(
|
||||
stateChipLabel(streamed),
|
||||
"Not shareable",
|
||||
"card chip should make the non-shareable state visible",
|
||||
);
|
||||
assertEquals(
|
||||
gameStatusLabel(streamed),
|
||||
"Installed, not shareable",
|
||||
"detail status should spell out installed plus non-shareable",
|
||||
);
|
||||
assertEquals(
|
||||
stateChipLabel(downloadedInstall),
|
||||
"Installed",
|
||||
"normal downloaded installs should keep the installed chip label",
|
||||
);
|
||||
assertEquals(
|
||||
gameStatusLabel(downloadedInstall),
|
||||
"Installed",
|
||||
"normal downloaded installs should keep the installed detail label",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
EPHEMERAL_IDENTITY_NOTICE,
|
||||
IDENTITY_STATUS_UNAVAILABLE_NOTICE,
|
||||
INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT,
|
||||
type IdentityDiagnosticSnapshot,
|
||||
newestIdentityDiagnosticSnapshot,
|
||||
} from '../src/lib/identityDiagnostic.ts';
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Deno.test('newer identity event beats delayed query and a later clear wins', () => {
|
||||
const delayedQuery: IdentityDiagnosticSnapshot = { revision: 1, diagnostic: null };
|
||||
const ephemeral: IdentityDiagnosticSnapshot = {
|
||||
revision: 2,
|
||||
diagnostic: 'ephemeral',
|
||||
};
|
||||
const afterEvent = newestIdentityDiagnosticSnapshot(
|
||||
INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT,
|
||||
ephemeral,
|
||||
);
|
||||
assertEquals(
|
||||
newestIdentityDiagnosticSnapshot(afterEvent, delayedQuery),
|
||||
ephemeral,
|
||||
'delayed query must lose',
|
||||
);
|
||||
|
||||
const cleared: IdentityDiagnosticSnapshot = { revision: 3, diagnostic: null };
|
||||
assertEquals(
|
||||
newestIdentityDiagnosticSnapshot(ephemeral, cleared),
|
||||
cleared,
|
||||
'higher-revision clear must win',
|
||||
);
|
||||
assertEquals(
|
||||
newestIdentityDiagnosticSnapshot(cleared, ephemeral),
|
||||
cleared,
|
||||
'stale event must not resurrect diagnostic',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('ephemeral identity copy is exact and contains no implementation detail', () => {
|
||||
assertEquals(
|
||||
EPHEMERAL_IDENTITY_NOTICE,
|
||||
"This installation's network identity could not be saved and will change the next time Lanspread starts.",
|
||||
'identity diagnostic copy',
|
||||
);
|
||||
for (const forbidden of ['path', 'key', 'permission', '.json', 'error']) {
|
||||
if (EPHEMERAL_IDENTITY_NOTICE.toLowerCase().includes(forbidden)) {
|
||||
throw new Error(`identity copy leaked forbidden detail: ${forbidden}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('identity initialization failure has a distinct redacted warning', () => {
|
||||
assertEquals(
|
||||
IDENTITY_STATUS_UNAVAILABLE_NOTICE,
|
||||
"This installation's network identity status could not be checked.",
|
||||
'unavailable diagnostic copy',
|
||||
);
|
||||
if (IDENTITY_STATUS_UNAVAILABLE_NOTICE.includes('could not be saved')) {
|
||||
throw new Error('unavailable status must not claim confirmed ephemeral identity');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { AsyncAdoptionScope } from '../src/lib/asyncOwnership.ts';
|
||||
import {
|
||||
INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
|
||||
LocalNetworkSharingAsyncScope,
|
||||
type LocalNetworkSharingSnapshot,
|
||||
displayedSharingTarget,
|
||||
isLocalNetworkSharingActive,
|
||||
localNetworkSharingNotice,
|
||||
newestLocalNetworkSharingSnapshot,
|
||||
} from '../src/lib/localNetworkSharing.ts';
|
||||
|
||||
const assert = (condition: boolean, message: string) => {
|
||||
if (!condition) throw new Error(message);
|
||||
};
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const snapshot = (
|
||||
revision: number,
|
||||
overrides: Partial<LocalNetworkSharingSnapshot> = {},
|
||||
): LocalNetworkSharingSnapshot => ({
|
||||
...INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
|
||||
revision,
|
||||
persistenceProblem: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
Deno.test('sharing bootstrap remains visibly fail-closed if listener and query fail', () => {
|
||||
assert(!displayedSharingTarget(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT), 'fallback switch');
|
||||
assert(
|
||||
!isLocalNetworkSharingActive(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT),
|
||||
'fallback network admission',
|
||||
);
|
||||
assertEquals(
|
||||
localNetworkSharingNotice(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT),
|
||||
'Local network sharing is off because its setting could not be loaded.',
|
||||
'fallback copy',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('delayed sharing query cannot overwrite a newer listener event', () => {
|
||||
const delayedQuery = snapshot(1, { enabled: false, phase: 'disabled' });
|
||||
const event = snapshot(2, { enabled: true, phase: 'enabled' });
|
||||
const afterEvent = newestLocalNetworkSharingSnapshot(
|
||||
INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
|
||||
event,
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
newestLocalNetworkSharingSnapshot(afterEvent, delayedQuery),
|
||||
event,
|
||||
'newer listener event must win',
|
||||
);
|
||||
assertEquals(
|
||||
newestLocalNetworkSharingSnapshot(event, { ...event, enabled: false }),
|
||||
event,
|
||||
'equal revisions must not rewrite state',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('pending policy target is distinct from effective network activity', () => {
|
||||
const waiting = snapshot(1, {
|
||||
enabled: true,
|
||||
phase: 'waitingForGameDirectory',
|
||||
});
|
||||
assert(displayedSharingTarget(waiting), 'waiting policy should remain checked');
|
||||
assert(!isLocalNetworkSharingActive(waiting), 'waiting policy must not claim active sharing');
|
||||
|
||||
const disabling = snapshot(2, {
|
||||
enabled: true,
|
||||
pendingTarget: false,
|
||||
phase: 'enabled',
|
||||
});
|
||||
assert(!displayedSharingTarget(disabling), 'pending explicit off target should drive switch');
|
||||
assert(
|
||||
!isLocalNetworkSharingActive(disabling),
|
||||
'an admitted off target must close network actions before the core event arrives',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('sharing notices use exact redacted state copy', () => {
|
||||
assertEquals(
|
||||
localNetworkSharingNotice(snapshot(1, { enabled: false, phase: 'disabled' })),
|
||||
'Local network sharing is off. Nearby devices cannot browse or request games from this library.',
|
||||
'disabled copy',
|
||||
);
|
||||
assertEquals(
|
||||
localNetworkSharingNotice(snapshot(2, { phase: 'waitingForGameDirectory' })),
|
||||
'Local network sharing will start after you choose a game folder.',
|
||||
'waiting copy',
|
||||
);
|
||||
assertEquals(
|
||||
localNetworkSharingNotice(snapshot(3, {
|
||||
enabled: true,
|
||||
pendingTarget: false,
|
||||
phase: 'enabled',
|
||||
})),
|
||||
'Stopping Local network sharing…',
|
||||
'pending-off copy before core Disabling event',
|
||||
);
|
||||
assertEquals(
|
||||
localNetworkSharingNotice(snapshot(4, {
|
||||
enabled: false,
|
||||
phase: 'disabled',
|
||||
persistenceProblem: 'load',
|
||||
})),
|
||||
'Local network sharing is off because its setting could not be loaded.',
|
||||
'load failure copy',
|
||||
);
|
||||
assertEquals(
|
||||
localNetworkSharingNotice(snapshot(5, {
|
||||
enabled: false,
|
||||
phase: 'disabled',
|
||||
persistenceProblem: 'save',
|
||||
})),
|
||||
'Local network sharing is off, but this setting could not be saved.',
|
||||
'save failure copy',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('sharing mutations execute in admission order', async () => {
|
||||
const adoption = new AsyncAdoptionScope();
|
||||
const scope = new LocalNetworkSharingAsyncScope(() => {}, adoption);
|
||||
const order: string[] = [];
|
||||
let releaseFirst = () => {};
|
||||
const firstGate = new Promise<void>(resolve => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
|
||||
const first = scope.applyMutationIfActive(async () => {
|
||||
order.push('first-start');
|
||||
await firstGate;
|
||||
order.push('first-finish');
|
||||
return 'first';
|
||||
}, value => order.push(`${value}-apply`));
|
||||
const second = scope.applyMutationIfActive(async () => {
|
||||
order.push('second-start');
|
||||
return 'second';
|
||||
}, value => order.push(`${value}-apply`));
|
||||
|
||||
await Promise.resolve();
|
||||
assertEquals(order, ['first-start'], 'second mutation must remain queued');
|
||||
releaseFirst();
|
||||
await Promise.all([first, second]);
|
||||
assertEquals(
|
||||
order,
|
||||
['first-start', 'first-finish', 'first-apply', 'second-start', 'second-apply'],
|
||||
'mutation order',
|
||||
);
|
||||
await scope.dispose();
|
||||
});
|
||||
|
||||
Deno.test('failed mutation does not poison its admitted successor', async () => {
|
||||
const adoption = new AsyncAdoptionScope();
|
||||
const scope = new LocalNetworkSharingAsyncScope(() => {}, adoption);
|
||||
const order: string[] = [];
|
||||
const first = scope.applyMutationIfActive(async () => {
|
||||
order.push('first');
|
||||
throw new Error('injected');
|
||||
}, () => order.push('unexpected-apply'));
|
||||
const second = scope.applyMutationIfActive(async () => {
|
||||
order.push('second');
|
||||
return true;
|
||||
}, () => order.push('second-apply'));
|
||||
|
||||
await first.catch(() => false);
|
||||
assert(await second, 'successor should still publish');
|
||||
assertEquals(order, ['first', 'second', 'second-apply'], 'successor ordering');
|
||||
await scope.dispose();
|
||||
});
|
||||
|
||||
Deno.test('sharing scope drains admitted mutation and rejects late work on dispose', async () => {
|
||||
const adoption = new AsyncAdoptionScope();
|
||||
const scope = new LocalNetworkSharingAsyncScope(() => {}, adoption);
|
||||
let release = () => {};
|
||||
const gate = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
let published = false;
|
||||
const admitted = scope.applyMutationIfActive(async () => {
|
||||
await gate;
|
||||
return true;
|
||||
}, () => {
|
||||
published = true;
|
||||
});
|
||||
|
||||
const disposal = scope.dispose();
|
||||
assert(
|
||||
!await scope.applyMutationIfActive(async () => true, () => {}),
|
||||
'dispose must close mutation admission',
|
||||
);
|
||||
release();
|
||||
await Promise.all([admitted, disposal]);
|
||||
assert(!published, 'disposed scope must suppress late React publication');
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
|
||||
newestProtocolMismatchSnapshot,
|
||||
type ProtocolMismatchSnapshot,
|
||||
} from "../src/lib/protocolMismatch.ts";
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Deno.test("delayed bootstrap snapshot cannot overwrite a newer mismatch event", () => {
|
||||
const delayedQuery = INITIAL_PROTOCOL_MISMATCH_SNAPSHOT;
|
||||
const event: ProtocolMismatchSnapshot = {
|
||||
revision: 1,
|
||||
mismatch: { observed: 7, expected: 8 },
|
||||
};
|
||||
|
||||
const afterEvent = newestProtocolMismatchSnapshot(
|
||||
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
|
||||
event,
|
||||
);
|
||||
assertEquals(
|
||||
newestProtocolMismatchSnapshot(afterEvent, delayedQuery),
|
||||
event,
|
||||
"delayed query must lose to event",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("new runtime generation clears an older mismatch", () => {
|
||||
const mismatch: ProtocolMismatchSnapshot = {
|
||||
revision: 4,
|
||||
mismatch: { observed: null, expected: 8 },
|
||||
};
|
||||
const cleared: ProtocolMismatchSnapshot = { revision: 5, mismatch: null };
|
||||
|
||||
assertEquals(
|
||||
newestProtocolMismatchSnapshot(mismatch, cleared),
|
||||
cleared,
|
||||
"new generation clear must win",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
createThumbnailRequestKey,
|
||||
startOwnedThumbnailGeneration,
|
||||
ThumbnailRequestGeneration,
|
||||
thumbnailIdsFromRequestKey,
|
||||
} from '../src/lib/thumbnailRequests.ts';
|
||||
import { AsyncAdoptionScope } from '../src/lib/asyncOwnership.ts';
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
};
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
Deno.test('thumbnail request keys are stable and deduplicate ids', () => {
|
||||
const first = createThumbnailRequestKey(['bravo', 'alpha', 'bravo']);
|
||||
const second = createThumbnailRequestKey(['alpha', 'bravo']);
|
||||
|
||||
assertEquals(first, second, 'equivalent id sets should have one effect key');
|
||||
assertEquals(
|
||||
JSON.stringify(thumbnailIdsFromRequestKey(first)),
|
||||
JSON.stringify(['alpha', 'bravo']),
|
||||
'the effect should receive each id once in stable order',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('constructing a thumbnail generation does not start a request', async () => {
|
||||
let starts = 0;
|
||||
const generation = new ThumbnailRequestGeneration(
|
||||
() => {
|
||||
starts += 1;
|
||||
return Promise.resolve('url');
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
assertEquals(starts, 0, 'request work must wait for the owning effect to start it');
|
||||
await generation.dispose();
|
||||
await generation.start(['game']);
|
||||
assertEquals(starts, 0, 'a disposed generation must not start later work');
|
||||
});
|
||||
|
||||
Deno.test('a thumbnail generation cannot start after window admission closes', async () => {
|
||||
const scope = new AsyncAdoptionScope();
|
||||
await scope.disposeOwned();
|
||||
let starts = 0;
|
||||
const generation = new ThumbnailRequestGeneration(
|
||||
() => {
|
||||
starts += 1;
|
||||
return Promise.resolve('url');
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
const cleanup = startOwnedThumbnailGeneration(scope, generation, ['game']);
|
||||
cleanup();
|
||||
await scope.drain();
|
||||
|
||||
assertEquals(starts, 0, 'closed window admission must prevent the first invoke');
|
||||
});
|
||||
|
||||
Deno.test('an unmounted thumbnail generation suppresses its in-flight result', async () => {
|
||||
const result = deferred<string>();
|
||||
const published: string[] = [];
|
||||
const generation = new ThumbnailRequestGeneration(
|
||||
() => result.promise,
|
||||
(id, url) => published.push(`${id}:${url}`),
|
||||
);
|
||||
|
||||
const loading = generation.start(['game']);
|
||||
const disposal = generation.dispose();
|
||||
result.resolve('thumbnail-url');
|
||||
await loading;
|
||||
await disposal;
|
||||
|
||||
assertEquals(published.length, 0, 'an in-flight result must not publish after unmount');
|
||||
});
|
||||
|
||||
Deno.test('a replaced thumbnail generation cannot overwrite the current generation', async () => {
|
||||
const abandonedResult = deferred<string>();
|
||||
const published: string[] = [];
|
||||
const abandoned = new ThumbnailRequestGeneration(
|
||||
() => abandonedResult.promise,
|
||||
(_id, url) => published.push(url),
|
||||
);
|
||||
const abandonedLoading = abandoned.start(['game']);
|
||||
|
||||
const abandonedDisposal = abandoned.dispose();
|
||||
const current = new ThumbnailRequestGeneration(
|
||||
() => Promise.resolve('current-url'),
|
||||
(_id, url) => published.push(url),
|
||||
);
|
||||
await current.start(['game']);
|
||||
abandonedResult.resolve('stale-url');
|
||||
await abandonedLoading;
|
||||
await abandonedDisposal;
|
||||
|
||||
assertEquals(
|
||||
JSON.stringify(published),
|
||||
JSON.stringify(['current-url']),
|
||||
'only the current effect generation may publish',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('an active failed thumbnail request publishes the empty fallback', async () => {
|
||||
const failure = new Error('thumbnail unavailable');
|
||||
const published: string[] = [];
|
||||
const generation = new ThumbnailRequestGeneration(
|
||||
() => Promise.reject(failure),
|
||||
(id, url) => published.push(`${id}:${url}`),
|
||||
);
|
||||
|
||||
await generation.start(['game']);
|
||||
|
||||
assertEquals(
|
||||
JSON.stringify(published),
|
||||
JSON.stringify(['game:']),
|
||||
'active failures should retain the placeholder-cover behavior',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
applyDownloadProgress,
|
||||
applyGameTransferStatusSnapshot,
|
||||
downloadProgressAriaLabel,
|
||||
downloadProgressTransferLabel,
|
||||
gameTransferStatusPresentation,
|
||||
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
|
||||
newestGameTransferStatusSnapshot,
|
||||
} from "../src/lib/gameState.ts";
|
||||
import {
|
||||
type DownloadProgressPayload,
|
||||
type Game,
|
||||
GameAvailability,
|
||||
GameTransferStatus,
|
||||
type GameTransferStatusSnapshot,
|
||||
InstallStatus,
|
||||
} from "../src/lib/types.ts";
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`${message}: expected ${JSON.stringify(expected)}, got ${
|
||||
JSON.stringify(actual)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const game = (id: string): Game => ({
|
||||
id,
|
||||
name: id,
|
||||
description: "",
|
||||
size: 0,
|
||||
downloaded: false,
|
||||
installed: false,
|
||||
availability: GameAvailability.LocalOnly,
|
||||
install_status: InstallStatus.Downloading,
|
||||
peer_count: 1,
|
||||
download_progress: {
|
||||
attemptId: `${id}-attempt`,
|
||||
downloaded_bytes: 10,
|
||||
total_bytes: 100,
|
||||
bytes_per_second: 5,
|
||||
active_peer_count: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = (
|
||||
revision: number,
|
||||
statuses: GameTransferStatusSnapshot["statuses"],
|
||||
openAttempts: GameTransferStatusSnapshot["openAttempts"] = {},
|
||||
): GameTransferStatusSnapshot => ({ revision, statuses, openAttempts });
|
||||
|
||||
const progress = (
|
||||
id: string,
|
||||
attemptId: string,
|
||||
downloadedBytes: number,
|
||||
): DownloadProgressPayload => ({
|
||||
id,
|
||||
attemptId,
|
||||
downloaded_bytes: downloadedBytes,
|
||||
total_bytes: 100,
|
||||
bytes_per_second: 5,
|
||||
active_peer_count: 1,
|
||||
});
|
||||
|
||||
Deno.test("newer transfer event beats stale and equal GamesList snapshots", () => {
|
||||
const event = snapshot(2, { alpha: GameTransferStatus.Retrying });
|
||||
assertEquals(
|
||||
newestGameTransferStatusSnapshot(event, snapshot(1, {})),
|
||||
event,
|
||||
"a delayed older GamesList snapshot must not replace the listener event",
|
||||
);
|
||||
assertEquals(
|
||||
newestGameTransferStatusSnapshot(
|
||||
event,
|
||||
snapshot(2, { alpha: GameTransferStatus.Exhausted }),
|
||||
),
|
||||
event,
|
||||
"an equal revision replay must not change the accepted full snapshot",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("higher revision full replacement clears omitted game statuses", () => {
|
||||
const games = applyGameTransferStatusSnapshot(
|
||||
[game("alpha"), game("bravo")],
|
||||
snapshot(4, {
|
||||
alpha: GameTransferStatus.Verifying,
|
||||
bravo: GameTransferStatus.Exhausted,
|
||||
}, { alpha: "alpha-attempt", bravo: "bravo-attempt" }),
|
||||
);
|
||||
const replacement = applyGameTransferStatusSnapshot(
|
||||
games,
|
||||
snapshot(
|
||||
5,
|
||||
{ bravo: GameTransferStatus.Retrying },
|
||||
{ alpha: "alpha-attempt", bravo: "bravo-attempt" },
|
||||
),
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
replacement.map((item) => item.transfer_status),
|
||||
[undefined, GameTransferStatus.Retrying],
|
||||
"omission must mean None while the listed game retains its exact status",
|
||||
);
|
||||
assertEquals(
|
||||
replacement.map((item) => item.download_progress?.downloaded_bytes),
|
||||
[10, 10],
|
||||
"typed status replacement must not mutate direct byte progress",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("progress is fenced by the newest exact open attempt", () => {
|
||||
const attemptA = "9007199254740992";
|
||||
const attemptB = "9007199254740993";
|
||||
const withoutProgress = [{ ...game("alpha"), download_progress: undefined }];
|
||||
const openA = snapshot(1, {}, { alpha: attemptA });
|
||||
const openB = snapshot(2, {}, { alpha: attemptB });
|
||||
const terminalB = snapshot(3, {}, {});
|
||||
|
||||
const progressA = applyDownloadProgress(
|
||||
withoutProgress,
|
||||
openA,
|
||||
progress("alpha", attemptA, 10),
|
||||
);
|
||||
assertEquals(
|
||||
progressA[0].download_progress?.attemptId,
|
||||
attemptA,
|
||||
"A progress should be accepted while A is the open attempt",
|
||||
);
|
||||
|
||||
const beganB = applyGameTransferStatusSnapshot(progressA, openB);
|
||||
assertEquals(
|
||||
beganB[0].download_progress,
|
||||
undefined,
|
||||
"a newer B Begin snapshot must clear already-rendered A progress",
|
||||
);
|
||||
const delayedA = applyDownloadProgress(
|
||||
beganB,
|
||||
openB,
|
||||
progress("alpha", attemptA, 20),
|
||||
);
|
||||
assertEquals(
|
||||
delayedA[0].download_progress,
|
||||
undefined,
|
||||
"delayed A progress must be rejected after B becomes authoritative",
|
||||
);
|
||||
|
||||
const progressB = applyDownloadProgress(
|
||||
delayedA,
|
||||
openB,
|
||||
progress("alpha", attemptB, 30),
|
||||
);
|
||||
assertEquals(
|
||||
progressB[0].download_progress?.downloaded_bytes,
|
||||
30,
|
||||
"progress for the exact current B attempt must be accepted",
|
||||
);
|
||||
|
||||
const finishedB = applyGameTransferStatusSnapshot(progressB, terminalB);
|
||||
assertEquals(
|
||||
finishedB[0].download_progress,
|
||||
undefined,
|
||||
"terminal replacement must clear B progress with the open attempt",
|
||||
);
|
||||
const lateB = applyDownloadProgress(
|
||||
finishedB,
|
||||
terminalB,
|
||||
progress("alpha", attemptB, 40),
|
||||
);
|
||||
assertEquals(
|
||||
lateB[0].download_progress,
|
||||
undefined,
|
||||
"late B progress must remain rejected after terminal settlement",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("transfer status lookup uses only own game-id properties", () => {
|
||||
const [projected] = applyGameTransferStatusSnapshot(
|
||||
[game("toString")],
|
||||
snapshot(3, {}),
|
||||
);
|
||||
assertEquals(
|
||||
projected.transfer_status,
|
||||
undefined,
|
||||
"prototype properties must not become catalog game statuses",
|
||||
);
|
||||
assertEquals(
|
||||
newestGameTransferStatusSnapshot(
|
||||
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
|
||||
snapshot(1, {}),
|
||||
).revision,
|
||||
1,
|
||||
"the backend revision-one bootstrap must replace the frontend initial state",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("transfer notices use the exact copy and severity contract", () => {
|
||||
assertEquals(
|
||||
gameTransferStatusPresentation(GameTransferStatus.Verifying),
|
||||
{ message: "Verifying downloaded chunks", level: "info" },
|
||||
"verification copy",
|
||||
);
|
||||
assertEquals(
|
||||
gameTransferStatusPresentation(GameTransferStatus.Retrying),
|
||||
{
|
||||
message: "A source sent invalid data; retrying another nearby peer",
|
||||
level: "warning",
|
||||
},
|
||||
"retry copy",
|
||||
);
|
||||
assertEquals(
|
||||
gameTransferStatusPresentation(GameTransferStatus.Exhausted),
|
||||
{
|
||||
message: "No nearby peer could provide the verified catalog version",
|
||||
level: "error",
|
||||
},
|
||||
"exhaustion copy",
|
||||
);
|
||||
assertEquals(
|
||||
gameTransferStatusPresentation(undefined),
|
||||
undefined,
|
||||
"None must remain invisible",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("download progress promotes transient verification and retry copy", () => {
|
||||
const verifying = {
|
||||
...game("Alpha"),
|
||||
transfer_status: GameTransferStatus.Verifying,
|
||||
};
|
||||
const retrying = {
|
||||
...game("Bravo"),
|
||||
transfer_status: GameTransferStatus.Retrying,
|
||||
};
|
||||
const exhausted = {
|
||||
...game("Charlie"),
|
||||
transfer_status: GameTransferStatus.Exhausted,
|
||||
};
|
||||
|
||||
assertEquals(
|
||||
downloadProgressTransferLabel(verifying),
|
||||
"Verifying downloaded chunks",
|
||||
"verification should replace the primary progress copy",
|
||||
);
|
||||
assertEquals(
|
||||
downloadProgressAriaLabel(verifying),
|
||||
"Verifying downloaded chunks: Alpha",
|
||||
"verification should replace the progressbar accessible name",
|
||||
);
|
||||
assertEquals(
|
||||
downloadProgressTransferLabel(retrying),
|
||||
"A source sent invalid data; retrying another nearby peer",
|
||||
"retry should replace the primary progress copy",
|
||||
);
|
||||
assertEquals(
|
||||
downloadProgressAriaLabel(retrying),
|
||||
"A source sent invalid data; retrying another nearby peer: Bravo",
|
||||
"retry should replace the progressbar accessible name",
|
||||
);
|
||||
assertEquals(
|
||||
downloadProgressTransferLabel(exhausted),
|
||||
undefined,
|
||||
"terminal exhaustion should remain on the card/modal status surface",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user