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,
|
rowFromPayload,
|
||||||
rowsFromHistory,
|
rowsFromHistory,
|
||||||
} from './lib/mainLogs';
|
} from './lib/mainLogs';
|
||||||
|
import { AsyncOwner, windowAsyncScope } from './lib/asyncOwnership';
|
||||||
|
|
||||||
import './MainLogsWindow.css';
|
import './MainLogsWindow.css';
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ export const MainLogsWindow = () => {
|
|||||||
const pausedRef = useRef(false);
|
const pausedRef = useRef(false);
|
||||||
const lastHistorySequenceRef = useRef(0);
|
const lastHistorySequenceRef = useRef(0);
|
||||||
const historyLineCountsRef = useRef<Map<string, number>>(new Map());
|
const historyLineCountsRef = useRef<Map<string, number>>(new Map());
|
||||||
|
const viewOwnerRef = useRef<AsyncOwner | null>(null);
|
||||||
|
const copyStatusTimerRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
const appendVisibleRows = useCallback((rows: MainLogRow[]) => {
|
const appendVisibleRows = useCallback((rows: MainLogRow[]) => {
|
||||||
setLogs(current => capLogRows([...current, ...rows]));
|
setLogs(current => capLogRows([...current, ...rows]));
|
||||||
@@ -54,10 +57,18 @@ export const MainLogsWindow = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
const owner = new AsyncOwner(error => {
|
||||||
let unlisten: (() => void) | undefined;
|
console.error('Failed to unregister main log listener:', error);
|
||||||
|
});
|
||||||
|
viewOwnerRef.current = owner;
|
||||||
|
owner.own(() => {
|
||||||
|
if (copyStatusTimerRef.current !== undefined) {
|
||||||
|
window.clearTimeout(copyStatusTimerRef.current);
|
||||||
|
copyStatusTimerRef.current = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleIncomingRow = (row: MainLogRow) => {
|
const handleIncomingRow = owner.guard((row: MainLogRow) => {
|
||||||
if (!historyLoadedRef.current) {
|
if (!historyLoadedRef.current) {
|
||||||
initialBufferRef.current = capLogRows([...initialBufferRef.current, row]);
|
initialBufferRef.current = capLogRows([...initialBufferRef.current, row]);
|
||||||
return;
|
return;
|
||||||
@@ -76,43 +87,55 @@ export const MainLogsWindow = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
appendVisibleRows([row]);
|
appendVisibleRows([row]);
|
||||||
};
|
});
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
try {
|
try {
|
||||||
unlisten = await listen<MainLogLinePayload>('main-log-line', event => {
|
const registered = await owner.register(() =>
|
||||||
handleIncomingRow(rowFromPayload(event.payload));
|
listen<MainLogLinePayload>('main-log-line', event => {
|
||||||
});
|
handleIncomingRow(rowFromPayload(event.payload));
|
||||||
|
})
|
||||||
const history = await invoke<MainLogHistoryPayload>('get_main_logs');
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
lastHistorySequenceRef.current = history.lastSequence;
|
|
||||||
const historyRows = rowsFromHistory(history.contents);
|
|
||||||
const historyLineCounts = lineCountsFromRows(historyRows);
|
|
||||||
const liveRows = dedupeBufferedRows(
|
|
||||||
historyLineCounts,
|
|
||||||
initialBufferRef.current,
|
|
||||||
lastHistorySequenceRef.current,
|
|
||||||
);
|
);
|
||||||
initialBufferRef.current = [];
|
if (!registered) return;
|
||||||
historyLineCountsRef.current = historyLineCounts;
|
|
||||||
historyLoadedRef.current = true;
|
|
||||||
|
|
||||||
if (pausedRef.current) {
|
|
||||||
setLogs(capLogRows(historyRows));
|
|
||||||
bufferPausedRows(liveRows);
|
|
||||||
} else {
|
|
||||||
setLogs(capLogRows([...historyRows, ...liveRows]));
|
|
||||||
}
|
|
||||||
setLoadError(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (owner.isActive()) {
|
||||||
historyLoadedRef.current = true;
|
historyLoadedRef.current = true;
|
||||||
setLoadError(err instanceof Error ? err.message : String(err));
|
setLoadError(err instanceof Error ? err.message : String(err));
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} finally {
|
return;
|
||||||
if (!cancelled) {
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await owner.applyIfActive(
|
||||||
|
() => invoke<MainLogHistoryPayload>('get_main_logs'),
|
||||||
|
history => {
|
||||||
|
lastHistorySequenceRef.current = history.lastSequence;
|
||||||
|
const historyRows = rowsFromHistory(history.contents);
|
||||||
|
const historyLineCounts = lineCountsFromRows(historyRows);
|
||||||
|
const liveRows = dedupeBufferedRows(
|
||||||
|
historyLineCounts,
|
||||||
|
initialBufferRef.current,
|
||||||
|
lastHistorySequenceRef.current,
|
||||||
|
);
|
||||||
|
initialBufferRef.current = [];
|
||||||
|
historyLineCountsRef.current = historyLineCounts;
|
||||||
|
historyLoadedRef.current = true;
|
||||||
|
|
||||||
|
if (pausedRef.current) {
|
||||||
|
setLogs(capLogRows(historyRows));
|
||||||
|
bufferPausedRows(liveRows);
|
||||||
|
} else {
|
||||||
|
setLogs(capLogRows([...historyRows, ...liveRows]));
|
||||||
|
}
|
||||||
|
setLoadError(null);
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (owner.isActive()) {
|
||||||
|
historyLoadedRef.current = true;
|
||||||
|
setLoadError(err instanceof Error ? err.message : String(err));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,12 +144,12 @@ export const MainLogsWindow = () => {
|
|||||||
void setup();
|
void setup();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
windowAsyncScope.adopt(owner.dispose());
|
||||||
|
if (viewOwnerRef.current === owner) viewOwnerRef.current = null;
|
||||||
historyLoadedRef.current = false;
|
historyLoadedRef.current = false;
|
||||||
initialBufferRef.current = [];
|
initialBufferRef.current = [];
|
||||||
lastHistorySequenceRef.current = 0;
|
lastHistorySequenceRef.current = 0;
|
||||||
historyLineCountsRef.current = new Map();
|
historyLineCountsRef.current = new Map();
|
||||||
unlisten?.();
|
|
||||||
};
|
};
|
||||||
}, [appendVisibleRows, bufferPausedRows]);
|
}, [appendVisibleRows, bufferPausedRows]);
|
||||||
|
|
||||||
@@ -156,9 +179,10 @@ export const MainLogsWindow = () => {
|
|||||||
const viewport = viewportRef.current;
|
const viewport = viewportRef.current;
|
||||||
if (!viewport) return;
|
if (!viewport) return;
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
viewport.scrollTop = viewport.scrollHeight;
|
viewport.scrollTop = viewport.scrollHeight;
|
||||||
});
|
});
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
}, [autoScroll, filteredRows.length, lastVisibleRow?.id]);
|
}, [autoScroll, filteredRows.length, lastVisibleRow?.id]);
|
||||||
|
|
||||||
const flushPausedRows = useCallback(() => {
|
const flushPausedRows = useCallback(() => {
|
||||||
@@ -189,13 +213,24 @@ export const MainLogsWindow = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const copyFilteredLogs = useCallback(async () => {
|
const copyFilteredLogs = useCallback(async () => {
|
||||||
|
const owner = viewOwnerRef.current;
|
||||||
|
if (!owner) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n'));
|
if (!await owner.applyIfActive(
|
||||||
setCopyStatus('Copied');
|
() => navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n')),
|
||||||
|
() => setCopyStatus('Copied'),
|
||||||
|
)) return;
|
||||||
} catch {
|
} catch {
|
||||||
|
if (!owner.isActive()) return;
|
||||||
setCopyStatus('Copy failed');
|
setCopyStatus('Copy failed');
|
||||||
}
|
}
|
||||||
window.setTimeout(() => setCopyStatus(null), 1600);
|
if (copyStatusTimerRef.current !== undefined) {
|
||||||
|
window.clearTimeout(copyStatusTimerRef.current);
|
||||||
|
}
|
||||||
|
copyStatusTimerRef.current = window.setTimeout(owner.guard(() => {
|
||||||
|
copyStatusTimerRef.current = undefined;
|
||||||
|
setCopyStatus(null);
|
||||||
|
}), 1600);
|
||||||
}, [filteredRows]);
|
}, [filteredRows]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -219,7 +254,7 @@ export const MainLogsWindow = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="settings-button"
|
className="settings-button"
|
||||||
onClick={() => void copyFilteredLogs()}
|
onClick={() => windowAsyncScope.adopt(copyFilteredLogs())}
|
||||||
disabled={filteredRows.length === 0}
|
disabled={filteredRows.length === 0}
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
import { AsyncOwner, windowAsyncScope } from './lib/asyncOwnership';
|
||||||
|
|
||||||
import './UnpackLogsWindow.css';
|
import './UnpackLogsWindow.css';
|
||||||
|
|
||||||
interface UnpackLogEntry {
|
interface UnpackLogEntry {
|
||||||
@@ -49,28 +51,56 @@ export const UnpackLogsWindow = () => {
|
|||||||
const [errorsOnly, setErrorsOnly] = useState(false);
|
const [errorsOnly, setErrorsOnly] = useState(false);
|
||||||
const [regexInput, setRegexInput] = useState('');
|
const [regexInput, setRegexInput] = useState('');
|
||||||
const [selectedOriginalIndex, setSelectedOriginalIndex] = useState<number | null>(null);
|
const [selectedOriginalIndex, setSelectedOriginalIndex] = useState<number | null>(null);
|
||||||
|
const refreshOwnerRef = useRef<AsyncOwner | null>(null);
|
||||||
|
|
||||||
|
const loadLogs = useCallback((owner: AsyncOwner): Promise<boolean> =>
|
||||||
|
owner.applyLatestIfActive(
|
||||||
|
() => invoke<UnpackLogEntry[]>('get_unpack_logs'),
|
||||||
|
unpackLogs => setLogs(unpackLogs),
|
||||||
|
), []);
|
||||||
|
|
||||||
const refreshLogs = useCallback(async () => {
|
const refreshLogs = useCallback(async () => {
|
||||||
const unpackLogs = await invoke<UnpackLogEntry[]>('get_unpack_logs');
|
const owner = refreshOwnerRef.current;
|
||||||
setLogs(unpackLogs);
|
if (!owner) return;
|
||||||
}, []);
|
try {
|
||||||
|
await loadLogs(owner);
|
||||||
|
} catch (error) {
|
||||||
|
if (owner.isActive()) console.error('Failed to refresh unpack logs:', error);
|
||||||
|
}
|
||||||
|
}, [loadLogs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unlisten: (() => void) | undefined;
|
const owner = new AsyncOwner(error => {
|
||||||
|
console.error('Failed to unregister unpack log listener:', error);
|
||||||
|
});
|
||||||
|
refreshOwnerRef.current = owner;
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
await refreshLogs();
|
try {
|
||||||
unlisten = await listen('unpack-logs-updated', () => {
|
const registered = await owner.register(() => listen(
|
||||||
void refreshLogs();
|
'unpack-logs-updated',
|
||||||
});
|
owner.guard(() => {
|
||||||
|
void loadLogs(owner).catch(error => {
|
||||||
|
if (owner.isActive()) {
|
||||||
|
console.error('Failed to refresh unpack logs:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
if (!registered) return;
|
||||||
|
await loadLogs(owner);
|
||||||
|
} catch (error) {
|
||||||
|
if (owner.isActive()) console.error('Failed to set up unpack logs:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void setup();
|
void setup();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlisten?.();
|
windowAsyncScope.adopt(owner.dispose());
|
||||||
|
if (refreshOwnerRef.current === owner) refreshOwnerRef.current = null;
|
||||||
};
|
};
|
||||||
}, [refreshLogs]);
|
}, [loadLogs]);
|
||||||
|
|
||||||
const { regex, regexError } = useMemo(() => {
|
const { regex, regexError } = useMemo(() => {
|
||||||
if (!regexInput) {
|
if (!regexInput) {
|
||||||
|
|||||||
@@ -1,137 +1,160 @@
|
|||||||
import { CSSProperties, MouseEvent } from 'react';
|
import { CSSProperties, MouseEvent } from "react";
|
||||||
|
|
||||||
import { Game } from '../lib/types';
|
import { Game } from "../lib/types";
|
||||||
import {
|
import {
|
||||||
downloadProgressPercent,
|
downloadProgressAriaLabel,
|
||||||
formatDownloadBytes,
|
downloadProgressPercent,
|
||||||
formatDownloadEta,
|
downloadProgressTransferLabel,
|
||||||
formatDownloadSpeed,
|
formatDownloadBytes,
|
||||||
formatDownloadSpeedShort,
|
formatDownloadEta,
|
||||||
} from '../lib/gameState';
|
formatDownloadSpeed,
|
||||||
|
formatDownloadSpeedShort,
|
||||||
|
} from "../lib/gameState";
|
||||||
|
|
||||||
import { Icon } from './Icon';
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
game: Game;
|
game: Game;
|
||||||
size?: 'md' | 'lg';
|
size?: "md" | "lg";
|
||||||
full?: boolean;
|
full?: boolean;
|
||||||
onCancel?: (game: Game) => void;
|
onCancel?: (game: Game) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressStats = (game: Game) => {
|
const progressStats = (game: Game) => {
|
||||||
const progress = game.download_progress;
|
const progress = game.download_progress;
|
||||||
const downloaded = progress?.downloaded_bytes ?? 0;
|
const downloaded = progress?.downloaded_bytes ?? 0;
|
||||||
const total = progress?.total_bytes ?? game.size;
|
const total = progress?.total_bytes ?? game.size;
|
||||||
const speed = progress?.bytes_per_second ?? 0;
|
const speed = progress?.bytes_per_second ?? 0;
|
||||||
const remaining = Math.max(0, total - downloaded);
|
const remaining = Math.max(0, total - downloaded);
|
||||||
const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY;
|
const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pct: Math.min(99, Math.round(downloadProgressPercent(game))),
|
pct: Math.min(99, Math.round(downloadProgressPercent(game))),
|
||||||
downloaded,
|
downloaded,
|
||||||
total,
|
total,
|
||||||
speed,
|
speed,
|
||||||
eta: etaSeconds,
|
eta: etaSeconds,
|
||||||
activePeerCount: progress?.active_peer_count ?? 0,
|
activePeerCount: progress?.active_peer_count ?? 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DownloadProgress = ({ game, size = 'md', full = false, onCancel }: Props) => {
|
export const DownloadProgress = (
|
||||||
const stats = progressStats(game);
|
{ game, size = "md", full = false, onCancel }: Props,
|
||||||
const progressStyle = {
|
) => {
|
||||||
'--download-progress': `${stats.pct}%`,
|
const stats = progressStats(game);
|
||||||
} as CSSProperties;
|
const transferLabel = downloadProgressTransferLabel(game);
|
||||||
const className = [
|
const ariaLabel = downloadProgressAriaLabel(game);
|
||||||
'dl',
|
const progressStyle = {
|
||||||
size === 'lg' ? 'dl-lg' : 'dl-md',
|
"--download-progress": `${stats.pct}%`,
|
||||||
full ? 'dl-full' : '',
|
} as CSSProperties;
|
||||||
].filter(Boolean).join(' ');
|
const className = [
|
||||||
|
"dl",
|
||||||
|
size === "lg" ? "dl-lg" : "dl-md",
|
||||||
|
full ? "dl-full" : "",
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
const handleCancel = (event: MouseEvent<HTMLButtonElement>) => {
|
const handleCancel = (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onCancel?.(game);
|
onCancel?.(game);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (size === 'lg') {
|
if (size === "lg") {
|
||||||
const peerUnit = stats.activePeerCount === 1 ? 'peer' : 'peers';
|
const peerUnit = stats.activePeerCount === 1 ? "peer" : "peers";
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={className}
|
|
||||||
role="progressbar"
|
|
||||||
aria-label={`Downloading ${game.name}`}
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={100}
|
|
||||||
aria-valuenow={stats.pct}
|
|
||||||
style={progressStyle}
|
|
||||||
>
|
|
||||||
<div className="dl-fill" aria-hidden />
|
|
||||||
<div className="dl-lg-grid">
|
|
||||||
<div className="dl-lg-primary">
|
|
||||||
<span className="dl-pulse" aria-hidden />
|
|
||||||
<span className="dl-label">Downloading</span>
|
|
||||||
</div>
|
|
||||||
<div className="dl-lg-secondary">
|
|
||||||
<span className="dl-bytes">
|
|
||||||
<strong>{formatDownloadBytes(stats.downloaded)}</strong>
|
|
||||||
<span className="dl-of"> / {formatDownloadBytes(stats.total)}</span>
|
|
||||||
</span>
|
|
||||||
<span className="dl-sep">·</span>
|
|
||||||
<span className="dl-speed">{formatDownloadSpeed(stats.speed)}</span>
|
|
||||||
{stats.activePeerCount > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="dl-sep dl-sep-peers">·</span>
|
|
||||||
<span
|
|
||||||
className="dl-peers"
|
|
||||||
title={`Downloading from ${stats.activePeerCount} ${peerUnit} on the LAN`}
|
|
||||||
>
|
|
||||||
<Icon.users />
|
|
||||||
<span>{stats.activePeerCount}</span>
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span className="dl-sep dl-sep-eta">·</span>
|
|
||||||
<span className="dl-eta">{formatDownloadEta(stats.eta)} left</span>
|
|
||||||
</div>
|
|
||||||
<div className="dl-lg-pct">
|
|
||||||
{stats.pct}
|
|
||||||
<span className="dl-pct-sym">%</span>
|
|
||||||
</div>
|
|
||||||
{onCancel && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="dl-cancel"
|
|
||||||
onClick={handleCancel}
|
|
||||||
aria-label={`Cancel download of ${game.name}`}
|
|
||||||
>
|
|
||||||
<Icon.close />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-label={`Downloading ${game.name}`}
|
aria-label={ariaLabel}
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuemax={100}
|
aria-valuemax={100}
|
||||||
aria-valuenow={stats.pct}
|
aria-valuenow={stats.pct}
|
||||||
title={`${stats.pct}% · ${formatDownloadSpeed(stats.speed)} · ${formatDownloadEta(stats.eta)} left`}
|
style={progressStyle}
|
||||||
style={progressStyle}
|
>
|
||||||
>
|
<div className="dl-fill" aria-hidden />
|
||||||
<div className="dl-fill" aria-hidden />
|
<div className="dl-lg-grid">
|
||||||
<div className="dl-md-row">
|
<div className="dl-lg-primary">
|
||||||
<span className="dl-pct">
|
<span className="dl-pulse" aria-hidden />
|
||||||
<span className="dl-pulse" aria-hidden />
|
<span className="dl-label">{transferLabel ?? "Downloading"}</span>
|
||||||
{stats.pct}
|
</div>
|
||||||
<span className="dl-pct-sym">%</span>
|
<div className="dl-lg-secondary">
|
||||||
|
<span className="dl-bytes">
|
||||||
|
<strong>{formatDownloadBytes(stats.downloaded)}</strong>
|
||||||
|
<span className="dl-of">
|
||||||
|
/ {formatDownloadBytes(stats.total)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="dl-sep">·</span>
|
||||||
|
<span className="dl-speed">{formatDownloadSpeed(stats.speed)}</span>
|
||||||
|
{stats.activePeerCount > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="dl-sep dl-sep-peers">·</span>
|
||||||
|
<span
|
||||||
|
className="dl-peers"
|
||||||
|
title={`Downloading from ${stats.activePeerCount} ${peerUnit} on the LAN`}
|
||||||
|
>
|
||||||
|
<Icon.users />
|
||||||
|
<span>{stats.activePeerCount}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="dl-speed">{formatDownloadSpeedShort(stats.speed)}</span>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
<span className="dl-sep dl-sep-eta">·</span>
|
||||||
|
<span className="dl-eta">{formatDownloadEta(stats.eta)} left</span>
|
||||||
|
</div>
|
||||||
|
<div className="dl-lg-pct">
|
||||||
|
{stats.pct}
|
||||||
|
<span className="dl-pct-sym">%</span>
|
||||||
|
</div>
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dl-cancel"
|
||||||
|
onClick={handleCancel}
|
||||||
|
aria-label={`Cancel download of ${game.name}`}
|
||||||
|
>
|
||||||
|
<Icon.close />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={stats.pct}
|
||||||
|
title={`${transferLabel ? `${transferLabel} · ` : ""}${stats.pct}% · ${
|
||||||
|
formatDownloadSpeed(stats.speed)
|
||||||
|
} · ${formatDownloadEta(stats.eta)} left`}
|
||||||
|
style={progressStyle}
|
||||||
|
>
|
||||||
|
<div className="dl-fill" aria-hidden />
|
||||||
|
<div className="dl-md-row">
|
||||||
|
{transferLabel
|
||||||
|
? (
|
||||||
|
<span className="dl-md-status">
|
||||||
|
<span className="dl-pulse" aria-hidden />
|
||||||
|
<span>{transferLabel}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<span className="dl-pct">
|
||||||
|
<span className="dl-pulse" aria-hidden />
|
||||||
|
{stats.pct}
|
||||||
|
<span className="dl-pct-sym">%</span>
|
||||||
|
</span>
|
||||||
|
<span className="dl-speed">
|
||||||
|
{formatDownloadSpeedShort(stats.speed)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,90 +1,109 @@
|
|||||||
import { JSX, KeyboardEvent } from 'react';
|
import { JSX, KeyboardEvent } from "react";
|
||||||
|
|
||||||
import { Game } from '../../lib/types';
|
import { Game } from "../../lib/types";
|
||||||
import { CoverAspect } from '../../hooks/useSettings';
|
import { CoverAspect } from "../../hooks/useSettings";
|
||||||
import { formatBytes } from '../../lib/format';
|
import { formatBytes } from "../../lib/format";
|
||||||
import { hasNewerLocalVersion } from '../../lib/gameState';
|
import {
|
||||||
|
gameTransferStatusPresentation,
|
||||||
|
hasNewerLocalVersion,
|
||||||
|
} from "../../lib/gameState";
|
||||||
|
|
||||||
import { GameCover } from './GameCover';
|
import { GameCover } from "./GameCover";
|
||||||
import { StateChip } from '../StateChip';
|
import { StateChip } from "../StateChip";
|
||||||
import { ActionButton } from '../ActionButton';
|
import { ActionButton } from "../ActionButton";
|
||||||
import { Icon } from '../Icon';
|
import { Icon } from "../Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
game: Game;
|
game: Game;
|
||||||
aspect: CoverAspect;
|
aspect: CoverAspect;
|
||||||
thumbnailUrl: string | null;
|
thumbnailUrl: string | null;
|
||||||
onOpen: (game: Game) => void;
|
onOpen: (game: Game) => void;
|
||||||
onPrimary: (game: Game) => void;
|
onPrimary: (game: Game) => void;
|
||||||
onCancelDownload: (game: Game) => void;
|
onCancelDownload: (game: Game) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metaSeparator = (...parts: Array<string | null | undefined>): JSX.Element[] => {
|
const metaSeparator = (
|
||||||
const filtered = parts.filter(Boolean) as string[];
|
...parts: Array<string | null | undefined>
|
||||||
const out: JSX.Element[] = [];
|
): JSX.Element[] => {
|
||||||
filtered.forEach((p, i) => {
|
const filtered = parts.filter(Boolean) as string[];
|
||||||
if (i > 0) out.push(<span key={`d${i}`} className="card-dot">·</span>);
|
const out: JSX.Element[] = [];
|
||||||
out.push(<span key={`p${i}`}>{p}</span>);
|
filtered.forEach((p, i) => {
|
||||||
});
|
if (i > 0) out.push(<span key={`d${i}`} className="card-dot">·</span>);
|
||||||
return out;
|
out.push(<span key={`p${i}`}>{p}</span>);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GameCard = ({
|
export const GameCard = ({
|
||||||
game,
|
game,
|
||||||
aspect,
|
aspect,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
onOpen,
|
onOpen,
|
||||||
onPrimary,
|
onPrimary,
|
||||||
onCancelDownload,
|
onCancelDownload,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onOpen(game);
|
onOpen(game);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const newerThanExpected = hasNewerLocalVersion(game);
|
const newerThanExpected = hasNewerLocalVersion(game);
|
||||||
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
const transferStatus = gameTransferStatusPresentation(game.transfer_status);
|
||||||
const statusMessage = hasOutbound
|
const hasOutbound = game.active_outbound_transfers !== undefined &&
|
||||||
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}`
|
game.active_outbound_transfers > 0;
|
||||||
: (game.status_message ?? (newerThanExpected ? 'Newer than expected' : ''));
|
const statusMessage = transferStatus?.message ??
|
||||||
const statusLevel = hasOutbound
|
(hasOutbound
|
||||||
? 'info'
|
? `Sharing to ${game.active_outbound_transfers} peer${
|
||||||
: (game.status_level ?? (newerThanExpected ? 'warning' : undefined));
|
game.active_outbound_transfers === 1 ? "" : "s"
|
||||||
|
}`
|
||||||
|
: (game.status_message ??
|
||||||
|
(newerThanExpected ? "Newer than expected" : "")));
|
||||||
|
const statusLevel = transferStatus?.level ??
|
||||||
|
(hasOutbound
|
||||||
|
? "info"
|
||||||
|
: (game.status_level ?? (newerThanExpected ? "warning" : undefined)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="card"
|
className="card"
|
||||||
onClick={() => onOpen(game)}
|
onClick={() => onOpen(game)}
|
||||||
onKeyDown={onKey}
|
onKeyDown={onKey}
|
||||||
aria-label={game.name}
|
aria-label={game.name}
|
||||||
|
>
|
||||||
|
<div className="card-cover-wrap" data-aspect={aspect}>
|
||||||
|
<GameCover game={game} aspect={aspect} thumbnailUrl={thumbnailUrl} />
|
||||||
|
<StateChip game={game} />
|
||||||
|
{game.peer_count > 0 && (
|
||||||
|
<div
|
||||||
|
className="card-mp"
|
||||||
|
title={`${game.peer_count} peer${
|
||||||
|
game.peer_count === 1 ? "" : "s"
|
||||||
|
} have this`}
|
||||||
|
>
|
||||||
|
<Icon.users />
|
||||||
|
<span>{game.peer_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="card-title" title={game.name}>{game.name}</div>
|
||||||
|
<div className="card-meta">
|
||||||
|
{metaSeparator(formatBytes(game.size), game.genre || null)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`card-status${statusLevel ? ` is-${statusLevel}` : ""}`}
|
||||||
>
|
>
|
||||||
<div className="card-cover-wrap" data-aspect={aspect}>
|
{statusMessage}
|
||||||
<GameCover game={game} aspect={aspect} thumbnailUrl={thumbnailUrl} />
|
</div>
|
||||||
<StateChip game={game} />
|
<ActionButton
|
||||||
{game.peer_count > 0 && (
|
game={game}
|
||||||
<div className="card-mp" title={`${game.peer_count} peer${game.peer_count === 1 ? '' : 's'} have this`}>
|
full
|
||||||
<Icon.users />
|
onClick={() => onPrimary(game)}
|
||||||
<span>{game.peer_count}</span>
|
onCancelDownload={onCancelDownload}
|
||||||
</div>
|
/>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</button>
|
||||||
<div className="card-body">
|
);
|
||||||
<div className="card-title" title={game.name}>{game.name}</div>
|
|
||||||
<div className="card-meta">
|
|
||||||
{metaSeparator(formatBytes(game.size), game.genre || null)}
|
|
||||||
</div>
|
|
||||||
<div className={`card-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
|
||||||
{statusMessage}
|
|
||||||
</div>
|
|
||||||
<ActionButton
|
|
||||||
game={game}
|
|
||||||
full
|
|
||||||
onClick={() => onPrimary(game)}
|
|
||||||
onCancelDownload={onCancelDownload}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,187 +1,213 @@
|
|||||||
import { Modal } from '../Modal';
|
import { Modal } from "../Modal";
|
||||||
import { Icon } from '../Icon';
|
import { Icon } from "../Icon";
|
||||||
import { GameCover } from '../grid/GameCover';
|
import { GameCover } from "../grid/GameCover";
|
||||||
import { StateChip } from '../StateChip';
|
import { StateChip } from "../StateChip";
|
||||||
import { ActionButton } from '../ActionButton';
|
import { ActionButton } from "../ActionButton";
|
||||||
|
|
||||||
import { Game, InstallStatus } from '../../lib/types';
|
import { Game, InstallStatus } from "../../lib/types";
|
||||||
import { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
|
import {
|
||||||
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
canStreamInstall,
|
||||||
|
gameStatusLabel,
|
||||||
|
gameTransferStatusPresentation,
|
||||||
|
hasNewerLocalVersion,
|
||||||
|
isInProgress,
|
||||||
|
} from "../../lib/gameState";
|
||||||
|
import { formatBytes, formatEtiVersion, formatPlayers } from "../../lib/format";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
game: Game;
|
game: Game;
|
||||||
thumbnailUrl: string | null;
|
thumbnailUrl: string | null;
|
||||||
onClose: () => void;
|
supportsStreamedInstall: boolean;
|
||||||
onPrimary: (game: Game) => void;
|
onClose: () => void;
|
||||||
onStreamInstall: (game: Game) => void;
|
onPrimary: (game: Game) => void;
|
||||||
onUninstall: (game: Game) => void;
|
onStreamInstall: (game: Game) => void;
|
||||||
onRemoveDownload: (game: Game) => void;
|
onUninstall: (game: Game) => void;
|
||||||
onCancelDownload: (game: Game) => void;
|
onRemoveDownload: (game: Game) => void;
|
||||||
onStartServer: (game: Game) => void;
|
onCancelDownload: (game: Game) => void;
|
||||||
onViewFiles: (game: Game) => void;
|
onStartServer: (game: Game) => void;
|
||||||
|
onViewFiles: (game: Game) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsFromGame = (game: Game): string[] => {
|
const tagsFromGame = (game: Game): string[] => {
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
if (game.genre) tags.push(game.genre);
|
if (game.genre) tags.push(game.genre);
|
||||||
if (game.publisher) tags.push(game.publisher);
|
if (game.publisher) tags.push(game.publisher);
|
||||||
if (game.release_year) tags.push(game.release_year);
|
if (game.release_year) tags.push(game.release_year);
|
||||||
return tags;
|
return tags;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GameDetailModal = ({
|
export const GameDetailModal = ({
|
||||||
game,
|
game,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
onClose,
|
supportsStreamedInstall,
|
||||||
onPrimary,
|
onClose,
|
||||||
onStreamInstall,
|
onPrimary,
|
||||||
onUninstall,
|
onStreamInstall,
|
||||||
onRemoveDownload,
|
onUninstall,
|
||||||
onCancelDownload,
|
onRemoveDownload,
|
||||||
onStartServer,
|
onCancelDownload,
|
||||||
onViewFiles,
|
onStartServer,
|
||||||
|
onViewFiles,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const tags = tagsFromGame(game);
|
const tags = tagsFromGame(game);
|
||||||
// Some game metadata contains a literal <br>; keep sanitization exact.
|
// Some game metadata contains a literal <br>; keep sanitization exact.
|
||||||
const description = game.description.split('<br>').join('');
|
const description = game.description.split("<br>").join("");
|
||||||
const canRemoveDownload = game.downloaded
|
const canRemoveDownload = game.downloaded &&
|
||||||
&& !game.installed
|
!game.installed &&
|
||||||
&& !isInProgress(game.install_status);
|
!isInProgress(game.install_status);
|
||||||
const showStreamInstall = canStreamInstall(game);
|
const showStreamInstall = canStreamInstall(game, supportsStreamedInstall);
|
||||||
const canViewFiles = game.downloaded
|
const canViewFiles = game.downloaded ||
|
||||||
|| game.installed
|
game.installed ||
|
||||||
|| game.install_status === InstallStatus.Downloading
|
game.install_status === InstallStatus.Downloading ||
|
||||||
|| game.install_status === InstallStatus.Installing;
|
game.install_status === InstallStatus.Installing;
|
||||||
const newerThanExpected = hasNewerLocalVersion(game);
|
const newerThanExpected = hasNewerLocalVersion(game);
|
||||||
const newerStatus = newerThanExpected
|
const newerStatus = newerThanExpected
|
||||||
? `Local version ${formatEtiVersion(game.local_version)} is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
|
? `Local version ${
|
||||||
: undefined;
|
formatEtiVersion(game.local_version)
|
||||||
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
} is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
|
||||||
const outboundStatus = hasOutbound
|
: undefined;
|
||||||
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}.`
|
const hasOutbound = game.active_outbound_transfers !== undefined &&
|
||||||
: undefined;
|
game.active_outbound_transfers > 0;
|
||||||
const statusMessage = outboundStatus ?? game.status_message ?? newerStatus;
|
const outboundStatus = hasOutbound
|
||||||
const statusLevel = hasOutbound
|
? `Sharing to ${game.active_outbound_transfers} peer${
|
||||||
? 'info'
|
game.active_outbound_transfers === 1 ? "" : "s"
|
||||||
: (game.status_level ?? (newerStatus ? 'warning' : undefined));
|
}.`
|
||||||
return (
|
: undefined;
|
||||||
<Modal onClose={onClose}>
|
const transferStatus = gameTransferStatusPresentation(game.transfer_status);
|
||||||
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
|
const statusMessage = transferStatus?.message ?? outboundStatus ??
|
||||||
<Icon.close />
|
game.status_message ?? newerStatus;
|
||||||
|
const statusLevel = transferStatus?.level ??
|
||||||
|
(hasOutbound
|
||||||
|
? "info"
|
||||||
|
: (game.status_level ?? (newerStatus ? "warning" : undefined)));
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose}>
|
||||||
|
<button
|
||||||
|
className="modal-close"
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<Icon.close />
|
||||||
|
</button>
|
||||||
|
<div className="modal-hero">
|
||||||
|
<GameCover
|
||||||
|
game={game}
|
||||||
|
aspect="banner"
|
||||||
|
thumbnailUrl={thumbnailUrl}
|
||||||
|
hideTitle
|
||||||
|
/>
|
||||||
|
<div className="modal-hero-fade" />
|
||||||
|
<div className="modal-hero-text">
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="modal-tags">
|
||||||
|
{tags.map((t) => <span key={t} className="modal-tag">{t}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 className="modal-title">{game.name}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="modal-state">
|
||||||
|
<StateChip game={game} showNone />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="modal-meta">
|
||||||
|
<div className="meta-cell">
|
||||||
|
<div className="meta-label">Size</div>
|
||||||
|
<div className="meta-value">{formatBytes(game.size)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="meta-cell">
|
||||||
|
<div className="meta-label">Players</div>
|
||||||
|
<div className="meta-value">
|
||||||
|
<Icon.users /> {formatPlayers(game.max_players)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="meta-cell">
|
||||||
|
<div className="meta-label">Version</div>
|
||||||
|
<div className="meta-value meta-mono">
|
||||||
|
{formatEtiVersion(game.eti_game_version ?? game.local_version)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="meta-cell">
|
||||||
|
<div className="meta-label">Status</div>
|
||||||
|
<div className="meta-value">{gameStatusLabel(game)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{description && <p className="modal-desc">{description}</p>}
|
||||||
|
|
||||||
|
{statusMessage && (
|
||||||
|
<p
|
||||||
|
className={`modal-status${statusLevel ? ` is-${statusLevel}` : ""}`}
|
||||||
|
>
|
||||||
|
{statusMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<ActionButton
|
||||||
|
game={game}
|
||||||
|
size="lg"
|
||||||
|
onClick={() => onPrimary(game)}
|
||||||
|
onCancelDownload={onCancelDownload}
|
||||||
|
/>
|
||||||
|
{showStreamInstall && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn"
|
||||||
|
title="Install without keeping archive files"
|
||||||
|
onClick={() => onStreamInstall(game)}
|
||||||
|
>
|
||||||
|
<Icon.install />
|
||||||
|
<span>Low disk install</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="modal-hero">
|
)}
|
||||||
<GameCover game={game} aspect="banner" thumbnailUrl={thumbnailUrl} hideTitle />
|
{game.installed && game.can_host_server === true && (
|
||||||
<div className="modal-hero-fade" />
|
<button
|
||||||
<div className="modal-hero-text">
|
type="button"
|
||||||
{tags.length > 0 && (
|
className="act-btn act-lg act-server"
|
||||||
<div className="modal-tags">
|
onClick={() => onStartServer(game)}
|
||||||
{tags.map(t => <span key={t} className="modal-tag">{t}</span>)}
|
>
|
||||||
</div>
|
<Icon.server />
|
||||||
)}
|
<span className="act-label">Start Server</span>
|
||||||
<h2 className="modal-title">{game.name}</h2>
|
</button>
|
||||||
</div>
|
)}
|
||||||
<div className="modal-state">
|
{game.installed && (
|
||||||
<StateChip game={game} showNone />
|
<button
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
className="ghost-btn ghost-danger"
|
||||||
|
onClick={() => onUninstall(game)}
|
||||||
<div className="modal-body">
|
>
|
||||||
<div className="modal-meta">
|
<Icon.trash />
|
||||||
<div className="meta-cell">
|
<span>Uninstall</span>
|
||||||
<div className="meta-label">Size</div>
|
</button>
|
||||||
<div className="meta-value">{formatBytes(game.size)}</div>
|
)}
|
||||||
</div>
|
{canRemoveDownload && (
|
||||||
<div className="meta-cell">
|
<button
|
||||||
<div className="meta-label">Players</div>
|
type="button"
|
||||||
<div className="meta-value">
|
className="ghost-btn ghost-danger"
|
||||||
<Icon.users /> {formatPlayers(game.max_players)}
|
onClick={() => onRemoveDownload(game)}
|
||||||
</div>
|
>
|
||||||
</div>
|
<Icon.trash />
|
||||||
<div className="meta-cell">
|
<span>Remove files</span>
|
||||||
<div className="meta-label">Version</div>
|
</button>
|
||||||
<div className="meta-value meta-mono">
|
)}
|
||||||
{formatEtiVersion(game.eti_game_version ?? game.local_version)}
|
{canViewFiles && (
|
||||||
</div>
|
<>
|
||||||
</div>
|
<div className="modal-actions-spacer" />
|
||||||
<div className="meta-cell">
|
<button
|
||||||
<div className="meta-label">Status</div>
|
type="button"
|
||||||
<div className="meta-value">{gameStatusLabel(game)}</div>
|
className="ghost-btn"
|
||||||
</div>
|
onClick={() => onViewFiles(game)}
|
||||||
</div>
|
>
|
||||||
|
<Icon.folder />
|
||||||
{description && (
|
<span>View Files</span>
|
||||||
<p className="modal-desc">{description}</p>
|
</button>
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
{statusMessage && (
|
</div>
|
||||||
<p className={`modal-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
</div>
|
||||||
{statusMessage}
|
</Modal>
|
||||||
</p>
|
);
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="modal-actions">
|
|
||||||
<ActionButton
|
|
||||||
game={game}
|
|
||||||
size="lg"
|
|
||||||
onClick={() => onPrimary(game)}
|
|
||||||
onCancelDownload={onCancelDownload}
|
|
||||||
/>
|
|
||||||
{showStreamInstall && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-btn"
|
|
||||||
title="Install without keeping archive files"
|
|
||||||
onClick={() => onStreamInstall(game)}
|
|
||||||
>
|
|
||||||
<Icon.install />
|
|
||||||
<span>Low disk install</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{game.installed && game.can_host_server === true && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="act-btn act-lg act-server"
|
|
||||||
onClick={() => onStartServer(game)}
|
|
||||||
>
|
|
||||||
<Icon.server />
|
|
||||||
<span className="act-label">Start Server</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{game.installed && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-btn ghost-danger"
|
|
||||||
onClick={() => onUninstall(game)}
|
|
||||||
>
|
|
||||||
<Icon.trash />
|
|
||||||
<span>Uninstall</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canRemoveDownload && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-btn ghost-danger"
|
|
||||||
onClick={() => onRemoveDownload(game)}
|
|
||||||
>
|
|
||||||
<Icon.trash />
|
|
||||||
<span>Remove files</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canViewFiles && (
|
|
||||||
<>
|
|
||||||
<div className="modal-actions-spacer" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-btn"
|
|
||||||
onClick={() => onViewFiles(game)}
|
|
||||||
>
|
|
||||||
<Icon.folder />
|
|
||||||
<span>View Files</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
LANGUAGE_OPTIONS,
|
LANGUAGE_OPTIONS,
|
||||||
type UISettings,
|
type UISettings,
|
||||||
} from '../../hooks/useSettings';
|
} from '../../hooks/useSettings';
|
||||||
|
import {
|
||||||
|
type LocalNetworkSharingSnapshot,
|
||||||
|
displayedSharingTarget,
|
||||||
|
} from '../../lib/localNetworkSharing';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: UISettings;
|
settings: UISettings;
|
||||||
@@ -18,6 +22,11 @@ interface Props {
|
|||||||
hasGameDirectory: boolean;
|
hasGameDirectory: boolean;
|
||||||
onPickDirectory: () => void;
|
onPickDirectory: () => void;
|
||||||
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||||
|
localNetworkSharing: LocalNetworkSharingSnapshot;
|
||||||
|
localNetworkSharingReady: boolean;
|
||||||
|
localNetworkSharingBusy: boolean;
|
||||||
|
localNetworkSharingError: string | null;
|
||||||
|
onLocalNetworkSharingChange: (enabled: boolean) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +68,28 @@ const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInpu
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
interface SettingsSwitchProps {
|
||||||
|
checked: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsSwitch = ({ checked, disabled, onChange }: SettingsSwitchProps) => (
|
||||||
|
<label className={`settings-switch ${checked ? 'is-checked' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
aria-label="Local network sharing"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={event => onChange(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="settings-switch-track" aria-hidden="true">
|
||||||
|
<span className="settings-switch-thumb" />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
interface GameFolderFieldProps {
|
interface GameFolderFieldProps {
|
||||||
path: string;
|
path: string;
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
@@ -88,6 +119,11 @@ export const SettingsDialog = ({
|
|||||||
hasGameDirectory,
|
hasGameDirectory,
|
||||||
onPickDirectory,
|
onPickDirectory,
|
||||||
onChange,
|
onChange,
|
||||||
|
localNetworkSharing,
|
||||||
|
localNetworkSharingReady,
|
||||||
|
localNetworkSharingBusy,
|
||||||
|
localNetworkSharingError,
|
||||||
|
onLocalNetworkSharingChange,
|
||||||
onClose,
|
onClose,
|
||||||
}: Props) => (
|
}: Props) => (
|
||||||
<Modal onClose={onClose} className="settings-modal">
|
<Modal onClose={onClose} className="settings-modal">
|
||||||
@@ -123,6 +159,25 @@ export const SettingsDialog = ({
|
|||||||
</Row>
|
</Row>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-title">Network</div>
|
||||||
|
<Row
|
||||||
|
label="Local network sharing"
|
||||||
|
hint="Allow nearby Lanspread devices to browse and request games from this library."
|
||||||
|
>
|
||||||
|
<SettingsSwitch
|
||||||
|
checked={displayedSharingTarget(localNetworkSharing)}
|
||||||
|
disabled={!localNetworkSharingReady || localNetworkSharingBusy}
|
||||||
|
onChange={onLocalNetworkSharingChange}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
{localNetworkSharingError && (
|
||||||
|
<div className="settings-inline-error" role="status">
|
||||||
|
{localNetworkSharingError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<div className="settings-section-title">Appearance</div>
|
<div className="settings-section-title">Appearance</div>
|
||||||
<Row label="Accent color" hint="Used for primary actions and highlights">
|
<Row label="Accent color" hint="Used for primary actions and highlights">
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CALL_TO_PLAY_CONNECTING_MESSAGE,
|
CALL_TO_PLAY_CONNECTING_MESSAGE,
|
||||||
callToPlayEvent,
|
|
||||||
callToPlayPublishErrorMessage,
|
callToPlayPublishErrorMessage,
|
||||||
extendDeadline,
|
extendDeadline,
|
||||||
pruneCallToPlayEvents,
|
|
||||||
reduceCallToPlayEvents,
|
reduceCallToPlayEvents,
|
||||||
|
replaceCallToPlayView,
|
||||||
} from '../lib/callToPlay';
|
} from '../lib/callToPlay';
|
||||||
import { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types';
|
import { CallToPlayAsyncScope } from '../lib/callToPlayOwnership';
|
||||||
|
import { windowAsyncScope } from '../lib/asyncOwnership';
|
||||||
|
import {
|
||||||
|
CallToPlayAction,
|
||||||
|
CallToPlayLocalIntent,
|
||||||
|
CallToPlayReceipt,
|
||||||
|
CallToPlayView,
|
||||||
|
Nomination,
|
||||||
|
} from '../lib/types';
|
||||||
|
|
||||||
export interface CallToPlayActions {
|
export interface CallToPlayActions {
|
||||||
createNomination: (
|
createNomination: (
|
||||||
@@ -36,129 +43,177 @@ export interface UseCallToPlay {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeEvents = (
|
type PublishOutcome =
|
||||||
previous: ReadonlyMap<string, CallToPlayEvent>,
|
| { kind: 'connecting' }
|
||||||
incoming: ReadonlyArray<CallToPlayEvent>,
|
| { kind: 'settled'; accepted: boolean }
|
||||||
): Map<string, CallToPlayEvent> => {
|
| { kind: 'failed'; error: unknown };
|
||||||
const next = new Map(previous);
|
|
||||||
for (const event of incoming) next.set(event.id, event);
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCallToPlay = (username: string): UseCallToPlay => {
|
export const useCallToPlay = (
|
||||||
|
username: string,
|
||||||
|
localNetworkSharingEnabled = true,
|
||||||
|
): UseCallToPlay => {
|
||||||
const actor = useMemo(() => {
|
const actor = useMemo(() => {
|
||||||
const trimmed = username.trim();
|
const trimmed = username.trim();
|
||||||
return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander';
|
return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander';
|
||||||
}, [username]);
|
}, [username]);
|
||||||
const [events, setEvents] = useState<ReadonlyMap<string, CallToPlayEvent>>(() => new Map());
|
const [view, setView] = useState<CallToPlayView>({ events: [] });
|
||||||
const [actorId, setActorId] = useState<string | null>(null);
|
const [actorId, setActorId] = useState<string | null>(null);
|
||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
const [transportReady, setTransportReady] = useState(false);
|
const [transportReady, setTransportReady] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const asyncScopeRef = useRef<CallToPlayAsyncScope | null>(null);
|
||||||
|
const actorRef = useRef(actor);
|
||||||
|
actorRef.current = actor;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
const current = Date.now();
|
setNow(Date.now());
|
||||||
setNow(current);
|
|
||||||
setEvents(previous => pruneCallToPlayEvents(previous, current));
|
|
||||||
}, 1_000);
|
}, 1_000);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
const scope = new CallToPlayAsyncScope(
|
||||||
let unlisten: UnlistenFn | undefined;
|
(callback, delayMilliseconds) => {
|
||||||
let retry: number | undefined;
|
const timer = window.setTimeout(() => void callback(), delayMilliseconds);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
},
|
||||||
|
cleanupError => console.error('Failed to clean up Call to Play:', cleanupError),
|
||||||
|
retryError => console.error('Call to Play retry failed:', retryError),
|
||||||
|
);
|
||||||
|
asyncScopeRef.current = scope;
|
||||||
|
|
||||||
|
const syncDisplayName = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await scope.applyMutationIfActive(
|
||||||
|
() => invoke<boolean>('set_call_to_play_display_name', {
|
||||||
|
displayName: actorRef.current,
|
||||||
|
}),
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (scope.isActive()) {
|
||||||
|
console.error('set_call_to_play_display_name failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const requestSnapshot = async (): Promise<boolean> => {
|
const requestSnapshot = async (): Promise<boolean> => {
|
||||||
|
let ready = false;
|
||||||
try {
|
try {
|
||||||
const peerId = await invoke<string | null>('request_call_to_play_events');
|
const applied = await scope.applyIfActive(
|
||||||
if (cancelled) return false;
|
() => invoke<string | null>('request_call_to_play_view'),
|
||||||
const ready = peerId !== null;
|
peerId => {
|
||||||
setActorId(peerId);
|
ready = peerId !== null;
|
||||||
setTransportReady(ready);
|
setActorId(peerId);
|
||||||
if (ready) {
|
setTransportReady(ready);
|
||||||
setError(current =>
|
if (ready) {
|
||||||
current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current
|
setError(current =>
|
||||||
);
|
current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current
|
||||||
}
|
);
|
||||||
if (ready && retry !== undefined) {
|
scope.stopRetry();
|
||||||
window.clearInterval(retry);
|
void syncDisplayName();
|
||||||
retry = undefined;
|
}
|
||||||
}
|
},
|
||||||
return ready;
|
);
|
||||||
|
return applied && ready;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!scope.isActive()) return false;
|
||||||
setTransportReady(false);
|
setTransportReady(false);
|
||||||
console.error('request_call_to_play_events failed:', err);
|
console.error('request_call_to_play_view failed:', err);
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async () => {
|
const register = async () => {
|
||||||
try {
|
try {
|
||||||
unlisten = await listen<CallToPlayEvent[]>('call-to-play-events', event => {
|
const registered = await scope.registerListener(() =>
|
||||||
setEvents(previous => mergeEvents(previous, event.payload));
|
listen<CallToPlayView>('call-to-play-view', scope.guard(event => {
|
||||||
});
|
setView(previous => replaceCallToPlayView(previous, event.payload));
|
||||||
if (cancelled) {
|
}))
|
||||||
unlisten();
|
);
|
||||||
return;
|
if (!registered) return;
|
||||||
}
|
|
||||||
const ready = await requestSnapshot();
|
const ready = await requestSnapshot();
|
||||||
if (!cancelled && !ready) {
|
if (scope.isActive() && !ready) scope.startRetry(requestSnapshot, 2_000);
|
||||||
retry = window.setInterval(() => void requestSnapshot(), 2_000);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!scope.isActive()) return;
|
||||||
console.error('Failed to register Call to Play listener:', err);
|
console.error('Failed to register Call to Play listener:', err);
|
||||||
if (!cancelled) {
|
setTransportReady(false);
|
||||||
setTransportReady(false);
|
setError('Call to Play networking is unavailable.');
|
||||||
setError('Call to Play networking is unavailable.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void register();
|
void scope.applyIfActive(register, () => {});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
if (asyncScopeRef.current === scope) asyncScopeRef.current = null;
|
||||||
unlisten?.();
|
windowAsyncScope.adopt(scope.dispose());
|
||||||
if (retry !== undefined) window.clearInterval(retry);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scope = asyncScopeRef.current;
|
||||||
|
if (scope === null || !scope.isActive()) return;
|
||||||
|
void scope.applyMutationIfActive(
|
||||||
|
() => invoke<boolean>('set_call_to_play_display_name', { displayName: actor }),
|
||||||
|
() => {},
|
||||||
|
).catch(err => {
|
||||||
|
if (scope.isActive()) console.error('set_call_to_play_display_name failed:', err);
|
||||||
|
});
|
||||||
|
}, [actor]);
|
||||||
|
|
||||||
const publish = useCallback(async (
|
const publish = useCallback(async (
|
||||||
callId: string,
|
callId: string | null,
|
||||||
action: CallToPlayAction,
|
action: CallToPlayAction,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (actorId === null) {
|
const scope = asyncScopeRef.current;
|
||||||
setTransportReady(false);
|
if (scope === null || !scope.isActive() || !localNetworkSharingEnabled) return false;
|
||||||
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
|
|
||||||
return false;
|
const publishOutcome = scope.guardLatestAction((outcome: PublishOutcome) => {
|
||||||
}
|
if (outcome.kind === 'connecting') {
|
||||||
const event = callToPlayEvent(callId, actorId, actor, action);
|
|
||||||
try {
|
|
||||||
const accepted = await invoke<boolean>('publish_call_to_play', { event });
|
|
||||||
if (!accepted) {
|
|
||||||
setTransportReady(false);
|
setTransportReady(false);
|
||||||
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
|
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
|
||||||
return false;
|
} else if (outcome.kind === 'failed') {
|
||||||
|
setError(callToPlayPublishErrorMessage(outcome.error));
|
||||||
|
} else if (!outcome.accepted) {
|
||||||
|
setTransportReady(false);
|
||||||
|
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
|
||||||
|
} else {
|
||||||
|
setTransportReady(true);
|
||||||
|
setError(null);
|
||||||
}
|
}
|
||||||
setTransportReady(true);
|
});
|
||||||
setError(null);
|
if (actorId === null) {
|
||||||
return true;
|
publishOutcome({ kind: 'connecting' });
|
||||||
} catch (err) {
|
|
||||||
console.error('publish_call_to_play failed:', err);
|
|
||||||
setError(callToPlayPublishErrorMessage(err));
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [actor, actorId]);
|
const intent: CallToPlayLocalIntent = { call_id: callId, action };
|
||||||
|
let acceptedResult = false;
|
||||||
|
try {
|
||||||
|
const completed = await scope.applyMutationIfActive(
|
||||||
|
() => invoke<CallToPlayReceipt>('publish_call_to_play', {
|
||||||
|
intent,
|
||||||
|
displayName: actor,
|
||||||
|
}),
|
||||||
|
_receipt => {
|
||||||
|
acceptedResult = true;
|
||||||
|
publishOutcome({ kind: 'settled', accepted: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return completed && acceptedResult;
|
||||||
|
} catch (err) {
|
||||||
|
if (!scope.isActive()) return false;
|
||||||
|
console.error('publish_call_to_play failed:', err);
|
||||||
|
publishOutcome({ kind: 'failed', error: err });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [actor, actorId, localNetworkSharingEnabled]);
|
||||||
|
|
||||||
const actions = useMemo<CallToPlayActions>(() => ({
|
const actions = useMemo<CallToPlayActions>(() => ({
|
||||||
createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => {
|
createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => {
|
||||||
const createdAt = Date.now();
|
const createdAt = Date.now();
|
||||||
const callId = globalThis.crypto.randomUUID();
|
|
||||||
const deadline = scheduledFor ?? createdAt + durationMinutes * 60_000;
|
const deadline = scheduledFor ?? createdAt + durationMinutes * 60_000;
|
||||||
void publish(callId, {
|
void publish(null, {
|
||||||
Create: {
|
Create: {
|
||||||
game_id: gameId,
|
game_id: gameId,
|
||||||
max_players: Math.max(2, Math.min(64, Math.round(maxPlayers))),
|
max_players: Math.max(2, Math.min(64, Math.round(maxPlayers))),
|
||||||
@@ -178,7 +233,6 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
|||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
void publish(callId, {
|
void publish(callId, {
|
||||||
SendMessage: {
|
SendMessage: {
|
||||||
message_id: globalThis.crypto.randomUUID(),
|
|
||||||
text: trimmed,
|
text: trimmed,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -192,9 +246,15 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
|||||||
}), [publish]);
|
}), [publish]);
|
||||||
|
|
||||||
const nominations = useMemo(
|
const nominations = useMemo(
|
||||||
() => reduceCallToPlayEvents([...events.values()], now),
|
() => reduceCallToPlayEvents(view.events, now),
|
||||||
[events, now],
|
[view, now],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { nominations, actions, actorId, transportReady, error };
|
return {
|
||||||
|
nominations,
|
||||||
|
actions,
|
||||||
|
actorId,
|
||||||
|
transportReady: transportReady && localNetworkSharingEnabled,
|
||||||
|
error,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,153 +1,219 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { ask } from '@tauri-apps/plugin-dialog';
|
import { ask } from "@tauri-apps/plugin-dialog";
|
||||||
|
|
||||||
import { type UseGamesResult } from './useGames';
|
import { type UseGamesResult } from "./useGames";
|
||||||
import { type UISettings } from './useSettings';
|
import { type UISettings } from "./useSettings";
|
||||||
|
import { AsyncOwner, windowAsyncScope } from "../lib/asyncOwnership";
|
||||||
|
|
||||||
export interface GameActions {
|
export interface GameActions {
|
||||||
play: (id: string) => Promise<void>;
|
play: (id: string) => Promise<void>;
|
||||||
startServer: (id: string) => Promise<void>;
|
startServer: (id: string) => Promise<void>;
|
||||||
install: (id: string) => Promise<void>;
|
install: (id: string) => Promise<void>;
|
||||||
streamInstall: (id: string) => Promise<void>;
|
streamInstall: (id: string) => Promise<void>;
|
||||||
update: (id: string) => Promise<void>;
|
update: (id: string) => Promise<void>;
|
||||||
uninstall: (id: string) => Promise<void>;
|
uninstall: (id: string) => Promise<void>;
|
||||||
removeDownload: (id: string) => Promise<void>;
|
removeDownload: (id: string) => Promise<void>;
|
||||||
cancelDownload: (id: string) => Promise<void>;
|
cancelDownload: (id: string) => Promise<void>;
|
||||||
viewFiles: (id: string) => Promise<void>;
|
viewFiles: (id: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin wrappers over the backend `run_game` / `install_game` / `update_game`
|
* Thin wrappers over the backend `run_game` / `install_game` / `update_game`
|
||||||
* / `uninstall_game` / `remove_downloaded_game` commands. Peer-backed downloads
|
* / `uninstall_game` / `remove_downloaded_game` commands. Operation state is
|
||||||
* are marked as "checking peers" until the backend emits an authoritative
|
* derived only from authoritative backend snapshots; cancellation waits for
|
||||||
* operation snapshot; cancellation waits for the backend to clear that snapshot.
|
* the backend to clear that snapshot.
|
||||||
*/
|
*/
|
||||||
export const useGameActions = (
|
export const useGameActions = (
|
||||||
games: UseGamesResult,
|
games: UseGamesResult,
|
||||||
settings: Pick<UISettings, 'language' | 'username'>,
|
settings: Pick<UISettings, "language" | "username">,
|
||||||
): GameActions => {
|
): GameActions => {
|
||||||
const play = useCallback(async (id: string) => {
|
const ownerRef = useRef<AsyncOwner | null>(null);
|
||||||
try {
|
|
||||||
await invoke('run_game', {
|
|
||||||
id,
|
|
||||||
language: settings.language,
|
|
||||||
username: settings.username,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('run_game failed:', err);
|
|
||||||
}
|
|
||||||
}, [settings.language, settings.username]);
|
|
||||||
|
|
||||||
const startServer = useCallback(async (id: string) => {
|
useEffect(() => {
|
||||||
try {
|
const owner = new AsyncOwner((error) => {
|
||||||
await invoke('start_server', {
|
console.error("Failed to clean up a game action:", error);
|
||||||
id,
|
});
|
||||||
language: settings.language,
|
ownerRef.current = owner;
|
||||||
username: settings.username,
|
return () => {
|
||||||
});
|
if (ownerRef.current === owner) ownerRef.current = null;
|
||||||
} catch (err) {
|
windowAsyncScope.adopt(owner.dispose());
|
||||||
console.error('start_server failed:', err);
|
|
||||||
}
|
|
||||||
}, [settings.language, settings.username]);
|
|
||||||
|
|
||||||
const install = useCallback(async (id: string) => {
|
|
||||||
try {
|
|
||||||
const success = await invoke<boolean>('install_game', {
|
|
||||||
id,
|
|
||||||
language: settings.language,
|
|
||||||
username: settings.username,
|
|
||||||
});
|
|
||||||
if (!success) return;
|
|
||||||
|
|
||||||
const game = games.games.find(item => item.id === id);
|
|
||||||
if (!game?.downloaded) {
|
|
||||||
games.markChecking(id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('install_game failed:', err);
|
|
||||||
}
|
|
||||||
}, [games, settings.language, settings.username]);
|
|
||||||
|
|
||||||
const streamInstall = useCallback(async (id: string) => {
|
|
||||||
try {
|
|
||||||
const success = await invoke<boolean>('stream_install_game', { id });
|
|
||||||
if (success) games.markChecking(id);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('stream_install_game failed:', err);
|
|
||||||
}
|
|
||||||
}, [games]);
|
|
||||||
|
|
||||||
const update = useCallback(async (id: string) => {
|
|
||||||
try {
|
|
||||||
const game = games.games.find(item => item.id === id);
|
|
||||||
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
|
|
||||||
const confirmed = await ask(
|
|
||||||
`Peers are currently downloading this game from you. Updating will abort their downloads. Do you want to proceed?`,
|
|
||||||
{ title: 'Active Transfers in Progress', kind: 'warning' }
|
|
||||||
);
|
|
||||||
if (!confirmed) return;
|
|
||||||
}
|
|
||||||
const success = await invoke<boolean>('update_game', {
|
|
||||||
id,
|
|
||||||
language: settings.language,
|
|
||||||
username: settings.username,
|
|
||||||
});
|
|
||||||
if (success) games.markChecking(id);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('update_game failed:', err);
|
|
||||||
}
|
|
||||||
}, [games, settings.language, settings.username]);
|
|
||||||
|
|
||||||
const uninstall = useCallback(async (id: string) => {
|
|
||||||
try {
|
|
||||||
await invoke('uninstall_game', { id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('uninstall_game failed:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeDownload = useCallback(async (id: string) => {
|
|
||||||
try {
|
|
||||||
const game = games.games.find(item => item.id === id);
|
|
||||||
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
|
|
||||||
const confirmed = await ask(
|
|
||||||
`Peers are currently downloading this game from you. Removing game files will abort their downloads. Do you want to proceed?`,
|
|
||||||
{ title: 'Active Transfers in Progress', kind: 'warning' }
|
|
||||||
);
|
|
||||||
if (!confirmed) return;
|
|
||||||
}
|
|
||||||
await invoke('remove_downloaded_game', { id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('remove_downloaded_game failed:', err);
|
|
||||||
}
|
|
||||||
}, [games]);
|
|
||||||
|
|
||||||
const cancelDownload = useCallback(async (id: string) => {
|
|
||||||
try {
|
|
||||||
await invoke('cancel_download', { id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('cancel_download failed:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const viewFiles = useCallback(async (id: string) => {
|
|
||||||
try {
|
|
||||||
await invoke('open_game_files', { id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('open_game_files failed:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
play,
|
|
||||||
startServer,
|
|
||||||
install,
|
|
||||||
streamInstall,
|
|
||||||
update,
|
|
||||||
uninstall,
|
|
||||||
removeDownload,
|
|
||||||
cancelDownload,
|
|
||||||
viewFiles,
|
|
||||||
};
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyOwned = useCallback(async <T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
apply: (value: T) => void = () => {},
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const owner = ownerRef.current;
|
||||||
|
if (owner === null || !owner.isActive()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await owner.applyIfActive(operation, apply);
|
||||||
|
} catch (error) {
|
||||||
|
if (!owner.isActive()) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const play = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await applyOwned(
|
||||||
|
() =>
|
||||||
|
invoke("run_game", {
|
||||||
|
id,
|
||||||
|
language: settings.language,
|
||||||
|
username: settings.username,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("run_game failed:", err);
|
||||||
|
}
|
||||||
|
}, [applyOwned, settings.language, settings.username]);
|
||||||
|
|
||||||
|
const startServer = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await applyOwned(
|
||||||
|
() =>
|
||||||
|
invoke("start_server", {
|
||||||
|
id,
|
||||||
|
language: settings.language,
|
||||||
|
username: settings.username,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("start_server failed:", err);
|
||||||
|
}
|
||||||
|
}, [applyOwned, settings.language, settings.username]);
|
||||||
|
|
||||||
|
const install = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await applyOwned(
|
||||||
|
() =>
|
||||||
|
invoke<boolean>("install_game", {
|
||||||
|
id,
|
||||||
|
language: settings.language,
|
||||||
|
username: settings.username,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("install_game failed:", err);
|
||||||
|
}
|
||||||
|
}, [applyOwned, settings.language, settings.username]);
|
||||||
|
|
||||||
|
const streamInstall = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await applyOwned(
|
||||||
|
() =>
|
||||||
|
invoke<boolean>("stream_install_game", {
|
||||||
|
id,
|
||||||
|
language: settings.language,
|
||||||
|
username: settings.username,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("stream_install_game failed:", err);
|
||||||
|
}
|
||||||
|
}, [applyOwned, settings.language, settings.username]);
|
||||||
|
|
||||||
|
const update = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
const game = games.games.find((item) => item.id === id);
|
||||||
|
if (
|
||||||
|
game && game.active_outbound_transfers &&
|
||||||
|
game.active_outbound_transfers > 0
|
||||||
|
) {
|
||||||
|
let confirmed = false;
|
||||||
|
if (
|
||||||
|
!await applyOwned(
|
||||||
|
() =>
|
||||||
|
ask(
|
||||||
|
`Peers are currently downloading this game from you. Updating will abort their downloads. Do you want to proceed?`,
|
||||||
|
{ title: "Active Transfers in Progress", kind: "warning" },
|
||||||
|
),
|
||||||
|
(answer) => {
|
||||||
|
confirmed = answer;
|
||||||
|
},
|
||||||
|
) || !confirmed
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await applyOwned(
|
||||||
|
() =>
|
||||||
|
invoke<boolean>("update_game", {
|
||||||
|
id,
|
||||||
|
language: settings.language,
|
||||||
|
username: settings.username,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("update_game failed:", err);
|
||||||
|
}
|
||||||
|
}, [applyOwned, games, settings.language, settings.username]);
|
||||||
|
|
||||||
|
const uninstall = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await applyOwned(() => invoke("uninstall_game", { id }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("uninstall_game failed:", err);
|
||||||
|
}
|
||||||
|
}, [applyOwned]);
|
||||||
|
|
||||||
|
const removeDownload = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
const game = games.games.find((item) => item.id === id);
|
||||||
|
if (
|
||||||
|
game && game.active_outbound_transfers &&
|
||||||
|
game.active_outbound_transfers > 0
|
||||||
|
) {
|
||||||
|
let confirmed = false;
|
||||||
|
if (
|
||||||
|
!await applyOwned(
|
||||||
|
() =>
|
||||||
|
ask(
|
||||||
|
`Peers are currently downloading this game from you. Removing game files will abort their downloads. Do you want to proceed?`,
|
||||||
|
{ title: "Active Transfers in Progress", kind: "warning" },
|
||||||
|
),
|
||||||
|
(answer) => {
|
||||||
|
confirmed = answer;
|
||||||
|
},
|
||||||
|
) || !confirmed
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await applyOwned(() => invoke("remove_downloaded_game", { id }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("remove_downloaded_game failed:", err);
|
||||||
|
}
|
||||||
|
}, [applyOwned, games]);
|
||||||
|
|
||||||
|
const cancelDownload = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await applyOwned(() => invoke("cancel_download", { id }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("cancel_download failed:", err);
|
||||||
|
}
|
||||||
|
}, [applyOwned]);
|
||||||
|
|
||||||
|
const viewFiles = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await applyOwned(() => invoke("open_game_files", { id }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("open_game_files failed:", err);
|
||||||
|
}
|
||||||
|
}, [applyOwned]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
play,
|
||||||
|
startServer,
|
||||||
|
install,
|
||||||
|
streamInstall,
|
||||||
|
update,
|
||||||
|
uninstall,
|
||||||
|
removeDownload,
|
||||||
|
cancelDownload,
|
||||||
|
viewFiles,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,92 +1,143 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { load } from '@tauri-apps/plugin-store';
|
import { load } from '@tauri-apps/plugin-store';
|
||||||
|
|
||||||
|
import { acceptGameDirectory, hydrateGameDirectory } from '../lib/gameDirectory';
|
||||||
|
import { windowAsyncScope } from '../lib/asyncOwnership';
|
||||||
|
import {
|
||||||
|
startAdmittedPersistenceEffect,
|
||||||
|
windowPersistenceScope,
|
||||||
|
} from '../lib/frontendPersistence';
|
||||||
import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store';
|
import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store';
|
||||||
|
|
||||||
|
const requestGameDirectory = (requestedPath: string): Promise<string> =>
|
||||||
|
acceptGameDirectory(requestedPath, {
|
||||||
|
updateBackend: path => invoke<unknown>('update_game_directory', { path }),
|
||||||
|
persist: async acceptedPath => {
|
||||||
|
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||||
|
await store.set(GAME_DIR_KEY, acceptedPath);
|
||||||
|
},
|
||||||
|
reportPersistenceError: error => {
|
||||||
|
console.error('Failed to persist accepted game directory:', error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the user's selected game directory. Hydrates from the persistent store
|
* Owns the backend-accepted game directory. Both restored and newly selected
|
||||||
* on mount, writes back on every change, and pushes the value to the Tauri
|
* paths enter frontend state only after the backend returns its canonical path.
|
||||||
* backend so it can scan/rescan.
|
|
||||||
*/
|
*/
|
||||||
export const useGameDirectory = () => {
|
export const useGameDirectory = (backendPolicyReady = true) => {
|
||||||
const [gameDir, setGameDir] = useState('');
|
const [gameDir, setAcceptedGameDir] = useState('');
|
||||||
const [gameDirExists, setGameDirExists] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const acceptedGameDirRef = useRef('');
|
||||||
|
const mountedRef = useRef(false);
|
||||||
|
const acceptingRef = useRef(false);
|
||||||
|
const selectionVersionRef = useRef(0);
|
||||||
|
const updateQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||||
|
const hydrationRef = useRef<Promise<void>>(Promise.resolve());
|
||||||
|
|
||||||
|
const shutdown = useCallback(async (): Promise<void> => {
|
||||||
|
acceptingRef.current = false;
|
||||||
|
await hydrationRef.current;
|
||||||
|
await updateQueueRef.current;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enqueueUpdate = useCallback((
|
||||||
|
requestedPath: () => string,
|
||||||
|
failureMessage: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!acceptingRef.current) return Promise.resolve();
|
||||||
|
|
||||||
|
const update = updateQueueRef.current.then(async () => {
|
||||||
|
const path = requestedPath();
|
||||||
|
if (!path.trim()) return;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const hydrate = async () => {
|
|
||||||
try {
|
try {
|
||||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
const acceptedPath = await requestGameDirectory(path);
|
||||||
const saved = await store.get<string>(GAME_DIR_KEY);
|
acceptedGameDirRef.current = acceptedPath;
|
||||||
if (saved && !cancelled) setGameDir(saved);
|
if (mountedRef.current && acceptingRef.current) {
|
||||||
} catch (err) {
|
setAcceptedGameDir(acceptedPath);
|
||||||
console.error('Failed to load game directory:', err);
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(failureMessage, error);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
void hydrate();
|
updateQueueRef.current = update;
|
||||||
return () => {
|
windowAsyncScope.adopt(update);
|
||||||
cancelled = true;
|
return update;
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gameDir.trim()) {
|
if (!backendPolicyReady) {
|
||||||
setGameDirExists(false);
|
setReady(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
setReady(false);
|
||||||
const sync = async () => {
|
return startAdmittedPersistenceEffect(
|
||||||
try {
|
windowPersistenceScope,
|
||||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
shutdown,
|
||||||
await store.set(GAME_DIR_KEY, gameDir);
|
() => {
|
||||||
} catch (err) {
|
let cancelled = false;
|
||||||
console.error('Failed to persist game directory:', err);
|
mountedRef.current = true;
|
||||||
}
|
acceptingRef.current = true;
|
||||||
|
const initialSelectionVersion = selectionVersionRef.current;
|
||||||
|
const hydration = hydrateGameDirectory({
|
||||||
|
loadSavedPath: async () => {
|
||||||
|
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||||
|
return await store.get<string>(GAME_DIR_KEY);
|
||||||
|
},
|
||||||
|
acceptSavedPath: async saved => {
|
||||||
|
if (
|
||||||
|
!cancelled
|
||||||
|
&& acceptingRef.current
|
||||||
|
&& selectionVersionRef.current === initialSelectionVersion
|
||||||
|
) {
|
||||||
|
await enqueueUpdate(
|
||||||
|
() => saved,
|
||||||
|
'Failed to restore game directory:',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reportLoadError: error => {
|
||||||
|
console.error('Failed to load game directory:', error);
|
||||||
|
},
|
||||||
|
}).finally(() => {
|
||||||
|
if (!cancelled && mountedRef.current && acceptingRef.current) {
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
hydrationRef.current = hydration;
|
||||||
|
windowAsyncScope.adopt(hydration);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
mountedRef.current = false;
|
||||||
|
acceptingRef.current = false;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [backendPolicyReady, enqueueUpdate, shutdown]);
|
||||||
|
|
||||||
let exists = false;
|
const setGameDir = useCallback((requestedPath: string) => {
|
||||||
try {
|
selectionVersionRef.current += 1;
|
||||||
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
|
void enqueueUpdate(
|
||||||
} catch (err) {
|
() => requestedPath,
|
||||||
console.error('Failed to validate game directory:', err);
|
'Failed to update game directory:',
|
||||||
}
|
);
|
||||||
if (cancelled) return;
|
}, [enqueueUpdate]);
|
||||||
setGameDirExists(exists);
|
|
||||||
if (!exists) return;
|
|
||||||
|
|
||||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
|
||||||
console.error('Failed to push game directory to backend:', err),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
void sync();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [gameDir]);
|
|
||||||
|
|
||||||
const hasGameDirectory = gameDir.trim() !== '' && gameDirExists;
|
|
||||||
|
|
||||||
const rescan = useCallback(() => {
|
const rescan = useCallback(() => {
|
||||||
if (!gameDir.trim()) {
|
void enqueueUpdate(
|
||||||
setGameDirExists(false);
|
() => acceptedGameDirRef.current,
|
||||||
return;
|
'Failed to rescan game directory:',
|
||||||
}
|
);
|
||||||
const sync = async () => {
|
}, [enqueueUpdate]);
|
||||||
let exists = false;
|
|
||||||
try {
|
|
||||||
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to validate game directory:', err);
|
|
||||||
}
|
|
||||||
setGameDirExists(exists);
|
|
||||||
if (!exists) return;
|
|
||||||
|
|
||||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
return {
|
||||||
console.error('Failed to rescan game directory:', err),
|
gameDir,
|
||||||
);
|
ready,
|
||||||
};
|
hasGameDirectory: gameDir !== '',
|
||||||
void sync();
|
setGameDir,
|
||||||
}, [gameDir]);
|
rescan,
|
||||||
|
shutdown,
|
||||||
return { gameDir, gameDirExists, hasGameDirectory, setGameDir, rescan };
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,188 +1,271 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DownloadProgressPayload,
|
DownloadProgressPayload,
|
||||||
Game,
|
Game,
|
||||||
GamesListPayload,
|
GamesListPayload,
|
||||||
InstallStatus,
|
GameTransferStatusSnapshot,
|
||||||
} from '../lib/types';
|
ProtocolMismatch,
|
||||||
|
} from "../lib/types";
|
||||||
import {
|
import {
|
||||||
activeStatusById,
|
activeStatusById,
|
||||||
isInProgress,
|
applyDownloadProgress,
|
||||||
mergeGameUpdate,
|
applyGameTransferStatusSnapshot,
|
||||||
normalizeGamesListPayload,
|
gameTransferStatusFor,
|
||||||
} from '../lib/gameState';
|
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
|
||||||
|
mergeGameUpdate,
|
||||||
|
newestGameTransferStatusSnapshot,
|
||||||
|
} from "../lib/gameState";
|
||||||
|
import {
|
||||||
|
AsyncOwner,
|
||||||
|
type AsyncRegistration,
|
||||||
|
registerSequentially,
|
||||||
|
windowAsyncScope,
|
||||||
|
} from "../lib/asyncOwnership";
|
||||||
|
import {
|
||||||
|
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
|
||||||
|
newestProtocolMismatchSnapshot,
|
||||||
|
type ProtocolMismatchSnapshot,
|
||||||
|
} from "../lib/protocolMismatch";
|
||||||
|
|
||||||
interface PendingPatch {
|
/** Owns the games list and derives card status from backend snapshots. */
|
||||||
install_status?: InstallStatus;
|
|
||||||
clearStatus?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyPatch = (game: Game, patch: PendingPatch): Game => {
|
|
||||||
let next: Game = { ...game };
|
|
||||||
if (patch.install_status !== undefined) next.install_status = patch.install_status;
|
|
||||||
if (patch.clearStatus) {
|
|
||||||
next.status_message = undefined;
|
|
||||||
next.status_level = undefined;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the games list and derives card status from backend snapshots. Returns
|
|
||||||
* a fire-and-forget `markChecking` helper so action calls can immediately show
|
|
||||||
* a "Checking peers…" state until the next backend snapshot arrives.
|
|
||||||
*/
|
|
||||||
export interface UseGamesResult {
|
export interface UseGamesResult {
|
||||||
games: Game[];
|
games: Game[];
|
||||||
setGames: React.Dispatch<React.SetStateAction<Game[]>>;
|
setGames: React.Dispatch<React.SetStateAction<Game[]>>;
|
||||||
totalPeerCount: number;
|
totalPeerCount: number;
|
||||||
requestGames: () => Promise<void>;
|
protocolMismatch: ProtocolMismatch | null;
|
||||||
markChecking: (id: string) => void;
|
requestGames: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
||||||
const [games, setGames] = useState<Game[]>([]);
|
const [games, setGames] = useState<Game[]>([]);
|
||||||
const [totalPeerCount, setTotalPeerCount] = useState(0);
|
const [totalPeerCount, setTotalPeerCount] = useState(0);
|
||||||
const rescanRef = useRef(rescanGameDir);
|
const [protocolMismatchSnapshot, setProtocolMismatchSnapshot] = useState(
|
||||||
rescanRef.current = rescanGameDir;
|
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
|
||||||
|
);
|
||||||
|
const transferStatusRef = useRef<GameTransferStatusSnapshot>(
|
||||||
|
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
|
||||||
|
);
|
||||||
|
const rescanRef = useRef(rescanGameDir);
|
||||||
|
rescanRef.current = rescanGameDir;
|
||||||
|
|
||||||
const markChecking = useCallback((id: string) => {
|
const requestGames = useCallback(async () => {
|
||||||
setGames(prev => prev.map(item =>
|
try {
|
||||||
item.id === id && !isInProgress(item.install_status)
|
await invoke("request_games");
|
||||||
? applyPatch(item, {
|
} catch (err) {
|
||||||
install_status: InstallStatus.CheckingPeers,
|
console.error("request_games failed:", err);
|
||||||
clearStatus: true,
|
}
|
||||||
})
|
}, []);
|
||||||
: item
|
|
||||||
));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const requestGames = useCallback(async () => {
|
useEffect(() => {
|
||||||
try {
|
const owner = new AsyncOwner((error) => {
|
||||||
await invoke('request_games');
|
console.error("Failed to unregister game listener:", error);
|
||||||
} catch (err) {
|
});
|
||||||
console.error('request_games failed:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleErrorEvent = (
|
||||||
const unlisteners: UnlistenFn[] = [];
|
id: string,
|
||||||
let cancelled = false;
|
message: string,
|
||||||
|
{ triggerRescan = false }: { triggerRescan?: boolean } = {},
|
||||||
const handleErrorEvent = (
|
) => {
|
||||||
id: string,
|
setGames((prev) =>
|
||||||
message: string,
|
prev.map((item) =>
|
||||||
{ triggerRescan = false }: { triggerRescan?: boolean } = {},
|
item.id === id
|
||||||
) => {
|
? {
|
||||||
setGames(prev => prev.map(item => item.id === id
|
...item,
|
||||||
? {
|
status_message: message,
|
||||||
...item,
|
status_level: "error",
|
||||||
install_status: item.installed
|
|
||||||
? InstallStatus.Installed
|
|
||||||
: InstallStatus.NotInstalled,
|
|
||||||
status_message: message,
|
|
||||||
status_level: 'error',
|
|
||||||
download_progress: undefined,
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
if (triggerRescan) rescanRef.current();
|
|
||||||
};
|
|
||||||
|
|
||||||
const register = async () => {
|
|
||||||
try {
|
|
||||||
unlisteners.push(await listen('games-list-updated', (event) => {
|
|
||||||
const payload = normalizeGamesListPayload(
|
|
||||||
event.payload as GamesListPayload | Game[],
|
|
||||||
);
|
|
||||||
const activeStatuses = activeStatusById(payload.active_operations);
|
|
||||||
setGames(prev => {
|
|
||||||
const previousById = new Map(prev.map(item => [item.id, item]));
|
|
||||||
return payload.games.map(game => mergeGameUpdate(
|
|
||||||
game,
|
|
||||||
previousById.get(game.id),
|
|
||||||
activeStatuses.get(game.id),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-download-failed', (e) => {
|
|
||||||
handleErrorEvent(e.payload as string, 'Download failed. Please try again.', {
|
|
||||||
triggerRescan: true,
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-download-peers-gone', (e) => {
|
|
||||||
handleErrorEvent(e.payload as string, 'Failed: all peers gone.', {
|
|
||||||
triggerRescan: true,
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-download-progress', (e) => {
|
|
||||||
const { id, ...download_progress } = e.payload as DownloadProgressPayload;
|
|
||||||
setGames(prev => prev.map(item => item.id === id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
download_progress,
|
|
||||||
}
|
|
||||||
: item));
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-no-peers', (e) => {
|
|
||||||
handleErrorEvent(e.payload as string, 'No peers currently have this game.');
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-install-finished', () => {
|
|
||||||
rescanRef.current();
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-install-failed', (e) => {
|
|
||||||
handleErrorEvent(e.payload as string, 'Install failed. Please try again.');
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-uninstall-failed', (e) => {
|
|
||||||
handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.');
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-remove-download-finished', () => {
|
|
||||||
rescanRef.current();
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-remove-download-failed', (e) => {
|
|
||||||
handleErrorEvent(e.payload as string, 'Remove failed. Please try again.', {
|
|
||||||
triggerRescan: true,
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('peer-count-updated', (e) => {
|
|
||||||
setTotalPeerCount(e.payload as number);
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
await invoke('request_games').catch(err =>
|
|
||||||
console.error('request_games failed:', err),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to register game listeners:', err);
|
|
||||||
}
|
}
|
||||||
};
|
: item
|
||||||
|
)
|
||||||
void register();
|
);
|
||||||
|
if (triggerRescan) rescanRef.current();
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
unlisteners.forEach(fn => fn());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
games,
|
|
||||||
setGames,
|
|
||||||
totalPeerCount,
|
|
||||||
requestGames,
|
|
||||||
markChecking,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyProtocolMismatchSnapshot = (
|
||||||
|
candidate: ProtocolMismatchSnapshot,
|
||||||
|
) => {
|
||||||
|
setProtocolMismatchSnapshot((current) =>
|
||||||
|
newestProtocolMismatchSnapshot(current, candidate)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const mismatchRegistration: AsyncRegistration = () =>
|
||||||
|
listen<ProtocolMismatchSnapshot>(
|
||||||
|
"protocol-mismatch-updated",
|
||||||
|
owner.guard((event) => applyProtocolMismatchSnapshot(event.payload)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyTransferStatusSnapshot = (
|
||||||
|
candidate: GameTransferStatusSnapshot,
|
||||||
|
) => {
|
||||||
|
const current = transferStatusRef.current;
|
||||||
|
const newest = newestGameTransferStatusSnapshot(current, candidate);
|
||||||
|
if (newest === current) return;
|
||||||
|
transferStatusRef.current = newest;
|
||||||
|
setGames((previous) => applyGameTransferStatusSnapshot(previous, newest));
|
||||||
|
};
|
||||||
|
|
||||||
|
const transferStatusRegistration: AsyncRegistration = () =>
|
||||||
|
listen<GameTransferStatusSnapshot>(
|
||||||
|
"game-transfer-status-updated",
|
||||||
|
owner.guard((event) => applyTransferStatusSnapshot(event.payload)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const registrations: AsyncRegistration[] = [
|
||||||
|
() =>
|
||||||
|
listen<GamesListPayload>(
|
||||||
|
"games-list-updated",
|
||||||
|
owner.guard((event) => {
|
||||||
|
const payload = event.payload;
|
||||||
|
const transferStatus = newestGameTransferStatusSnapshot(
|
||||||
|
transferStatusRef.current,
|
||||||
|
payload.transfer_status,
|
||||||
|
);
|
||||||
|
transferStatusRef.current = transferStatus;
|
||||||
|
const activeStatuses = activeStatusById(payload.active_operations);
|
||||||
|
setGames((prev) => {
|
||||||
|
const previousById = new Map(prev.map((item) => [item.id, item]));
|
||||||
|
return applyGameTransferStatusSnapshot(
|
||||||
|
payload.games.map((game) =>
|
||||||
|
mergeGameUpdate(
|
||||||
|
game,
|
||||||
|
previousById.get(game.id),
|
||||||
|
activeStatuses.get(game.id),
|
||||||
|
gameTransferStatusFor(transferStatus, game.id),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
transferStatus,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
listen<string>(
|
||||||
|
"game-download-failed",
|
||||||
|
owner.guard((event) => {
|
||||||
|
handleErrorEvent(
|
||||||
|
event.payload,
|
||||||
|
"Download failed. Please try again.",
|
||||||
|
{
|
||||||
|
triggerRescan: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
listen<DownloadProgressPayload>(
|
||||||
|
"game-download-progress",
|
||||||
|
owner.guard((event) => {
|
||||||
|
const transferStatus = transferStatusRef.current;
|
||||||
|
setGames((prev) =>
|
||||||
|
applyDownloadProgress(prev, transferStatus, event.payload)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
listen(
|
||||||
|
"game-install-finished",
|
||||||
|
owner.guard(() => {
|
||||||
|
rescanRef.current();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
listen<string>(
|
||||||
|
"game-install-failed",
|
||||||
|
owner.guard((event) => {
|
||||||
|
handleErrorEvent(
|
||||||
|
event.payload,
|
||||||
|
"Install failed. Please try again.",
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
listen<string>(
|
||||||
|
"game-uninstall-failed",
|
||||||
|
owner.guard((event) => {
|
||||||
|
handleErrorEvent(
|
||||||
|
event.payload,
|
||||||
|
"Uninstall failed. Please try again.",
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
listen(
|
||||||
|
"game-remove-download-finished",
|
||||||
|
owner.guard(() => {
|
||||||
|
rescanRef.current();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
listen<string>(
|
||||||
|
"game-remove-download-failed",
|
||||||
|
owner.guard((event) => {
|
||||||
|
handleErrorEvent(
|
||||||
|
event.payload,
|
||||||
|
"Remove failed. Please try again.",
|
||||||
|
{
|
||||||
|
triggerRescan: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
listen<number>(
|
||||||
|
"peer-count-updated",
|
||||||
|
owner.guard((event) => {
|
||||||
|
setTotalPeerCount(event.payload);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const register = async () => {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
!await registerSequentially(owner, [
|
||||||
|
mismatchRegistration,
|
||||||
|
transferStatusRegistration,
|
||||||
|
...registrations,
|
||||||
|
])
|
||||||
|
) return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to register game listeners:", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await owner.applyIfActive(
|
||||||
|
() => invoke<ProtocolMismatchSnapshot>("get_protocol_mismatch"),
|
||||||
|
applyProtocolMismatchSnapshot,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (owner.isActive()) {
|
||||||
|
console.error("get_protocol_mismatch failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await owner.applyIfActive(
|
||||||
|
() => invoke("request_games"),
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (owner.isActive()) console.error("request_games failed:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void register();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
windowAsyncScope.adopt(owner.dispose());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
games,
|
||||||
|
setGames,
|
||||||
|
totalPeerCount,
|
||||||
|
protocolMismatch: protocolMismatchSnapshot.mismatch,
|
||||||
|
requestGames,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
import { AsyncOwner, windowAsyncScope } from '../lib/asyncOwnership';
|
||||||
|
import {
|
||||||
|
INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT,
|
||||||
|
type IdentityDiagnosticSnapshot,
|
||||||
|
newestIdentityDiagnosticSnapshot,
|
||||||
|
} from '../lib/identityDiagnostic';
|
||||||
|
|
||||||
|
export interface UseIdentityDiagnostic {
|
||||||
|
snapshot: IdentityDiagnosticSnapshot;
|
||||||
|
ready: boolean;
|
||||||
|
unavailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useIdentityDiagnostic = (): UseIdentityDiagnostic => {
|
||||||
|
const [snapshot, setSnapshot] = useState(INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [unavailable, setUnavailable] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const owner = new AsyncOwner(error => {
|
||||||
|
console.error('Failed to clean up identity diagnostic state:', error);
|
||||||
|
});
|
||||||
|
const applySnapshot = (candidate: IdentityDiagnosticSnapshot) => {
|
||||||
|
setSnapshot(current => newestIdentityDiagnosticSnapshot(current, candidate));
|
||||||
|
setUnavailable(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async () => {
|
||||||
|
try {
|
||||||
|
if (!await owner.register(() =>
|
||||||
|
listen<IdentityDiagnosticSnapshot>(
|
||||||
|
'identity-diagnostic-updated',
|
||||||
|
owner.guard(event => applySnapshot(event.payload)),
|
||||||
|
)
|
||||||
|
)) return;
|
||||||
|
await owner.applyIfActive(
|
||||||
|
() => invoke<IdentityDiagnosticSnapshot>('get_identity_diagnostic'),
|
||||||
|
applySnapshot,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (owner.isActive()) {
|
||||||
|
console.error('Failed to initialize identity diagnostic state:', error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (owner.isActive()) setReady(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
windowAsyncScope.adopt(register());
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
windowAsyncScope.adopt(owner.dispose());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { snapshot, ready, unavailable };
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
import { windowAsyncScope } from '../lib/asyncOwnership';
|
||||||
|
import {
|
||||||
|
INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
|
||||||
|
LocalNetworkSharingAsyncScope,
|
||||||
|
type LocalNetworkSharingSnapshot,
|
||||||
|
newestLocalNetworkSharingSnapshot,
|
||||||
|
} from '../lib/localNetworkSharing';
|
||||||
|
|
||||||
|
const CHANGE_FAILED = 'Local network sharing could not be changed.';
|
||||||
|
|
||||||
|
interface MutationResult {
|
||||||
|
snapshot: LocalNetworkSharingSnapshot | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseLocalNetworkSharing {
|
||||||
|
snapshot: LocalNetworkSharingSnapshot;
|
||||||
|
ready: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
error: string | null;
|
||||||
|
setEnabled: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLocalNetworkSharing = (): UseLocalNetworkSharing => {
|
||||||
|
const [snapshot, setSnapshot] = useState(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [pendingInvokes, setPendingInvokes] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const scopeRef = useRef<LocalNetworkSharingAsyncScope | null>(null);
|
||||||
|
|
||||||
|
const applySnapshot = useCallback((candidate: LocalNetworkSharingSnapshot) => {
|
||||||
|
setSnapshot(current => newestLocalNetworkSharingSnapshot(current, candidate));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scope = new LocalNetworkSharingAsyncScope(cleanupError => {
|
||||||
|
console.error('Failed to clean up Local network sharing state:', cleanupError);
|
||||||
|
});
|
||||||
|
scopeRef.current = scope;
|
||||||
|
|
||||||
|
const register = async () => {
|
||||||
|
try {
|
||||||
|
if (!await scope.registerListener(() =>
|
||||||
|
listen<LocalNetworkSharingSnapshot>(
|
||||||
|
'local-network-sharing-updated',
|
||||||
|
scope.guard(event => applySnapshot(event.payload)),
|
||||||
|
)
|
||||||
|
)) return;
|
||||||
|
|
||||||
|
await scope.applyIfActive(
|
||||||
|
() => invoke<LocalNetworkSharingSnapshot>('get_local_network_sharing'),
|
||||||
|
applySnapshot,
|
||||||
|
);
|
||||||
|
} catch (registrationError) {
|
||||||
|
if (scope.isActive()) {
|
||||||
|
console.error('Failed to initialize Local network sharing state:', registrationError);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (scope.isActive()) setReady(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const registration = register();
|
||||||
|
windowAsyncScope.adopt(registration);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (scopeRef.current === scope) scopeRef.current = null;
|
||||||
|
windowAsyncScope.adopt(scope.dispose());
|
||||||
|
};
|
||||||
|
}, [applySnapshot]);
|
||||||
|
|
||||||
|
const setEnabled = useCallback((enabled: boolean) => {
|
||||||
|
const scope = scopeRef.current;
|
||||||
|
if (scope === null || !scope.isActive()) return;
|
||||||
|
|
||||||
|
setPendingInvokes(count => count + 1);
|
||||||
|
const mutation = scope.applyMutationIfActive<MutationResult>(async () => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
snapshot: await invoke<LocalNetworkSharingSnapshot>(
|
||||||
|
'set_local_network_sharing',
|
||||||
|
{ enabled },
|
||||||
|
),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (mutationError) {
|
||||||
|
console.error('Failed to change Local network sharing:', mutationError);
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
snapshot: await invoke<LocalNetworkSharingSnapshot>(
|
||||||
|
'get_local_network_sharing',
|
||||||
|
),
|
||||||
|
error: CHANGE_FAILED,
|
||||||
|
};
|
||||||
|
} catch (queryError) {
|
||||||
|
console.error(
|
||||||
|
'Failed to resynchronize Local network sharing:',
|
||||||
|
queryError,
|
||||||
|
);
|
||||||
|
return { snapshot: null, error: CHANGE_FAILED };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, result => {
|
||||||
|
if (result.snapshot !== null) applySnapshot(result.snapshot);
|
||||||
|
setError(result.error);
|
||||||
|
});
|
||||||
|
const settled = mutation.finally(scope.guard(() => {
|
||||||
|
setPendingInvokes(count => Math.max(0, count - 1));
|
||||||
|
}));
|
||||||
|
windowAsyncScope.adopt(settled);
|
||||||
|
}, [applySnapshot]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot,
|
||||||
|
ready,
|
||||||
|
busy: pendingInvokes > 0 || snapshot.pendingTarget !== null,
|
||||||
|
error,
|
||||||
|
setEnabled,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { load } from '@tauri-apps/plugin-store';
|
import { load } from '@tauri-apps/plugin-store';
|
||||||
|
|
||||||
import { type GameFilter, type GameSort, type LauncherLanguage } from '../lib/types';
|
import { type GameFilter, type GameSort, type LauncherLanguage } from '../lib/types';
|
||||||
import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store';
|
import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store';
|
||||||
|
import {
|
||||||
|
createSerializedAsyncWriter,
|
||||||
|
mergeHydratedState,
|
||||||
|
windowAsyncScope,
|
||||||
|
} from '../lib/asyncOwnership';
|
||||||
|
import {
|
||||||
|
startAdmittedPersistenceEffect,
|
||||||
|
windowPersistenceScope,
|
||||||
|
} from '../lib/frontendPersistence';
|
||||||
|
|
||||||
export type Density = 'compact' | 'normal' | 'large';
|
export type Density = 'compact' | 'normal' | 'large';
|
||||||
export type CoverAspect = 'box' | 'square' | 'banner';
|
export type CoverAspect = 'box' | 'square' | 'banner';
|
||||||
@@ -102,8 +111,21 @@ export interface UseSettings {
|
|||||||
settings: UISettings;
|
settings: UISettings;
|
||||||
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
|
shutdown: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settingsWriter = createSerializedAsyncWriter<Partial<UISettings>>(
|
||||||
|
async patch => {
|
||||||
|
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||||
|
const saved = await store.get<StoredUISettings>(UI_SETTINGS_KEY);
|
||||||
|
await store.set(UI_SETTINGS_KEY, {
|
||||||
|
...sanitize(saved ?? undefined),
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error => console.error('Failed to persist UI settings:', error),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads UI preferences from the Tauri persistent store once on mount and
|
* Loads UI preferences from the Tauri persistent store once on mount and
|
||||||
* writes every change back through it. Components only see a synchronous
|
* writes every change back through it. Components only see a synchronous
|
||||||
@@ -112,44 +134,69 @@ export interface UseSettings {
|
|||||||
export const useSettings = (): UseSettings => {
|
export const useSettings = (): UseSettings => {
|
||||||
const [settings, setSettings] = useState<UISettings>(DEFAULT_SETTINGS);
|
const [settings, setSettings] = useState<UISettings>(DEFAULT_SETTINGS);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const settingsRef = useRef(DEFAULT_SETTINGS);
|
||||||
|
const hydratedRef = useRef(false);
|
||||||
|
const pendingEditsRef = useRef<Partial<UISettings>>({});
|
||||||
|
const acceptingRef = useRef(false);
|
||||||
|
const hydrationRef = useRef<Promise<void>>(Promise.resolve());
|
||||||
|
|
||||||
useEffect(() => {
|
const shutdown = useCallback(async (): Promise<void> => {
|
||||||
let cancelled = false;
|
acceptingRef.current = false;
|
||||||
const init = async () => {
|
await hydrationRef.current;
|
||||||
try {
|
await settingsWriter.closeAndWait();
|
||||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
|
||||||
const saved = await store.get<StoredUISettings>(UI_SETTINGS_KEY);
|
|
||||||
if (!cancelled) {
|
|
||||||
setSettings(sanitize(saved ?? undefined));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load UI settings:', err);
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setReady(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void init();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => startAdmittedPersistenceEffect(
|
||||||
|
windowPersistenceScope,
|
||||||
|
shutdown,
|
||||||
|
() => {
|
||||||
|
let cancelled = false;
|
||||||
|
acceptingRef.current = true;
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
await settingsWriter.waitForIdle();
|
||||||
|
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||||
|
const saved = await store.get<StoredUISettings>(UI_SETTINGS_KEY);
|
||||||
|
if (!cancelled && acceptingRef.current) {
|
||||||
|
const restored = mergeHydratedState(
|
||||||
|
sanitize(saved ?? undefined),
|
||||||
|
pendingEditsRef.current,
|
||||||
|
);
|
||||||
|
pendingEditsRef.current = {};
|
||||||
|
hydratedRef.current = true;
|
||||||
|
settingsRef.current = restored;
|
||||||
|
setSettings(restored);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load UI settings:', err);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled && acceptingRef.current) setReady(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const hydration = init();
|
||||||
|
hydrationRef.current = hydration;
|
||||||
|
windowAsyncScope.adopt(hydration);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
acceptingRef.current = false;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
), [shutdown]);
|
||||||
|
|
||||||
const set = useCallback(<K extends keyof UISettings>(key: K, value: UISettings[K]) => {
|
const set = useCallback(<K extends keyof UISettings>(key: K, value: UISettings[K]) => {
|
||||||
setSettings(prev => {
|
if (!acceptingRef.current) return;
|
||||||
const next = { ...prev, [key]: value };
|
|
||||||
void persist(next);
|
const next = { ...settingsRef.current, [key]: value };
|
||||||
return next;
|
if (!hydratedRef.current) {
|
||||||
});
|
pendingEditsRef.current = {
|
||||||
|
...pendingEditsRef.current,
|
||||||
|
[key]: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
settingsRef.current = next;
|
||||||
|
setSettings(next);
|
||||||
|
windowAsyncScope.adopt(settingsWriter.enqueue({ [key]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { settings, set, ready };
|
return { settings, set, ready, shutdown };
|
||||||
};
|
|
||||||
|
|
||||||
const persist = async (settings: UISettings): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
|
||||||
await store.set(UI_SETTINGS_KEY, settings);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to persist UI settings:', err);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
import { AsyncOwner, windowAsyncScope } from '../lib/asyncOwnership';
|
||||||
|
|
||||||
|
interface CapabilityResult {
|
||||||
|
gameId: string;
|
||||||
|
supported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily resolves catalog-owned Stream Install support for the game whose
|
||||||
|
* detail modal is currently open. Replacing or closing the selection disposes
|
||||||
|
* and joins that generation, so a late result cannot affect another game.
|
||||||
|
*/
|
||||||
|
export const useStreamInstallCapability = (gameId: string | null): boolean => {
|
||||||
|
const [result, setResult] = useState<CapabilityResult | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameId === null) return;
|
||||||
|
|
||||||
|
const owner = new AsyncOwner(error => {
|
||||||
|
console.error('Failed to clean up Stream Install capability lookup:', error);
|
||||||
|
});
|
||||||
|
const lookup = owner.applyIfActive(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
return await invoke<boolean>('supports_streamed_install', { id: gameId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to resolve Stream Install capability for ${gameId}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supported => setResult({ gameId, supported }),
|
||||||
|
);
|
||||||
|
windowAsyncScope.adopt(lookup);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
windowAsyncScope.adopt(owner.dispose());
|
||||||
|
};
|
||||||
|
}, [gameId]);
|
||||||
|
|
||||||
|
return result?.gameId === gameId && result.supported;
|
||||||
|
};
|
||||||
@@ -1,28 +1,41 @@
|
|||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createThumbnailRequestKey,
|
||||||
|
startOwnedThumbnailGeneration,
|
||||||
|
ThumbnailRequestGeneration,
|
||||||
|
thumbnailIdsFromRequestKey,
|
||||||
|
} from '../lib/thumbnailRequests';
|
||||||
|
import { windowAsyncScope } from '../lib/asyncOwnership';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazy, per-id cache for cover thumbnails. Returns `null` until the value is
|
* Per-id cache for cover thumbnails belonging to the supplied game ids.
|
||||||
* known; returns the empty string when the backend has nothing for the id, so
|
* Returns `null` until the value is known; returns the empty string when the
|
||||||
* callers can fall back to the placeholder cover art.
|
* backend has nothing for the id, so callers can use the placeholder cover.
|
||||||
*/
|
*/
|
||||||
export const useThumbnails = () => {
|
export const useThumbnails = (ids: readonly string[]) => {
|
||||||
const [thumbnails, setThumbnails] = useState<Map<string, string>>(new Map());
|
const [thumbnails, setThumbnails] = useState<Map<string, string>>(new Map());
|
||||||
const pending = useRef<Set<string>>(new Set());
|
const thumbnailsRef = useRef(thumbnails);
|
||||||
|
const requestKey = createThumbnailRequestKey(ids);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const generation = new ThumbnailRequestGeneration(
|
||||||
|
id => invoke<string>('get_game_thumbnail', { gameId: id }),
|
||||||
|
(id, url) => {
|
||||||
|
const next = new Map(thumbnailsRef.current).set(id, url);
|
||||||
|
thumbnailsRef.current = next;
|
||||||
|
setThumbnails(next);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const missingIds = thumbnailIdsFromRequestKey(requestKey)
|
||||||
|
.filter(id => !thumbnailsRef.current.has(id));
|
||||||
|
|
||||||
|
return startOwnedThumbnailGeneration(windowAsyncScope, generation, missingIds);
|
||||||
|
}, [requestKey]);
|
||||||
|
|
||||||
const get = useCallback((id: string): string | null => {
|
const get = useCallback((id: string): string | null => {
|
||||||
if (thumbnails.has(id)) return thumbnails.get(id) ?? '';
|
if (thumbnails.has(id)) return thumbnails.get(id) ?? '';
|
||||||
if (pending.current.has(id)) return null;
|
|
||||||
pending.current.add(id);
|
|
||||||
invoke<string>('get_game_thumbnail', { gameId: id })
|
|
||||||
.then(url => {
|
|
||||||
pending.current.delete(id);
|
|
||||||
setThumbnails(prev => new Map(prev).set(id, url));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
pending.current.delete(id);
|
|
||||||
setThumbnails(prev => new Map(prev).set(id, ''));
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}, [thumbnails]);
|
}, [thumbnails]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
export type AsyncCleanup = () => void | Promise<void>;
|
||||||
|
export type AsyncRegistration = () => Promise<AsyncCleanup>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes promises whose lexical React cleanup cannot await. The Tauri event
|
||||||
|
* API removes its JavaScript callback synchronously; this scope owns the
|
||||||
|
* remaining backend acknowledgement while the webview realm is alive.
|
||||||
|
*/
|
||||||
|
export class AsyncAdoptionScope {
|
||||||
|
private readonly pending = new Set<Promise<unknown>>();
|
||||||
|
private readonly disposers = new Set<() => Promise<void>>();
|
||||||
|
private acceptingDisposers = true;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly reportFailure: (error: unknown) => void = () => {},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public adopt<T>(promise: Promise<T>): void {
|
||||||
|
this.pending.add(promise);
|
||||||
|
void promise.then(
|
||||||
|
() => this.pending.delete(promise),
|
||||||
|
(error) => {
|
||||||
|
this.pending.delete(promise);
|
||||||
|
try {
|
||||||
|
this.reportFailure(error);
|
||||||
|
} catch {
|
||||||
|
// Reporting must not create a second unhandled rejection.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerDisposer(
|
||||||
|
dispose: () => Promise<void>,
|
||||||
|
): (() => void) | undefined {
|
||||||
|
if (!this.acceptingDisposers) return undefined;
|
||||||
|
|
||||||
|
this.disposers.add(dispose);
|
||||||
|
return () => this.disposers.delete(dispose);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalidates and drains all mounted scopes before native webview destruction. */
|
||||||
|
public async disposeOwned(): Promise<void> {
|
||||||
|
this.acceptingDisposers = false;
|
||||||
|
const disposers = [...this.disposers];
|
||||||
|
this.disposers.clear();
|
||||||
|
const pending: Promise<void>[] = [];
|
||||||
|
for (const dispose of disposers) {
|
||||||
|
try {
|
||||||
|
const disposal = dispose();
|
||||||
|
pending.push(disposal);
|
||||||
|
this.adopt(disposal);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
this.reportFailure(error);
|
||||||
|
} catch {
|
||||||
|
// Reporting must not interrupt the other disposers.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.allSettled(pending);
|
||||||
|
await this.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async drain(): Promise<void> {
|
||||||
|
while (this.pending.size > 0) {
|
||||||
|
await Promise.allSettled([...this.pending]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const windowAsyncScope = new AsyncAdoptionScope((error) => {
|
||||||
|
console.error("Detached frontend cleanup failed:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mergeHydratedState = <T extends object>(
|
||||||
|
restored: T,
|
||||||
|
pendingEdits: Partial<T>,
|
||||||
|
): T => ({ ...restored, ...pendingEdits });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns asynchronous registrations and result publication for one mounted scope.
|
||||||
|
* Disposal invalidates the scope and invokes owned cleanup synchronously. Its
|
||||||
|
* returned promise drains asynchronous cleanup acknowledgements, including a
|
||||||
|
* listener that finishes registering after disposal.
|
||||||
|
*/
|
||||||
|
export class AsyncOwner {
|
||||||
|
private disposed = false;
|
||||||
|
private cleanups: AsyncCleanup[] = [];
|
||||||
|
private readonly pending = new Set<Promise<unknown>>();
|
||||||
|
private readonly pendingRegistrations = new Set<Promise<unknown>>();
|
||||||
|
private readonly pendingCleanups = new Set<Promise<unknown>>();
|
||||||
|
private latestOperation = 0;
|
||||||
|
private readonly releaseWindowOwnership: () => void;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly reportCleanupError: (error: unknown) => void = () => {},
|
||||||
|
adoptionScope: AsyncAdoptionScope = windowAsyncScope,
|
||||||
|
) {
|
||||||
|
const release = adoptionScope.registerDisposer(
|
||||||
|
() => this.disposeForWindowClose(),
|
||||||
|
);
|
||||||
|
if (release === undefined) {
|
||||||
|
this.disposed = true;
|
||||||
|
this.releaseWindowOwnership = () => {};
|
||||||
|
} else {
|
||||||
|
this.releaseWindowOwnership = release;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isActive(): boolean {
|
||||||
|
return !this.disposed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public guard<Args extends unknown[]>(
|
||||||
|
callback: (...args: Args) => void,
|
||||||
|
): (...args: Args) => void {
|
||||||
|
return (...args) => {
|
||||||
|
if (this.isActive()) callback(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public register(registration: AsyncRegistration): Promise<boolean> {
|
||||||
|
if (!this.isActive()) return Promise.resolve(false);
|
||||||
|
|
||||||
|
return this.trackRegistration(this.registerInner(registration));
|
||||||
|
}
|
||||||
|
|
||||||
|
public own(cleanup: AsyncCleanup): boolean {
|
||||||
|
if (!this.isActive()) {
|
||||||
|
void this.release(cleanup);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleanups.push(cleanup);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public release(cleanup: AsyncCleanup): Promise<void> {
|
||||||
|
return this.trackCleanup(this.runCleanup(cleanup));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async registerInner(
|
||||||
|
registration: AsyncRegistration,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isActive()) return false;
|
||||||
|
|
||||||
|
let cleanup: AsyncCleanup;
|
||||||
|
try {
|
||||||
|
cleanup = await registration();
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.isActive()) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isActive()) {
|
||||||
|
await this.release(cleanup);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.own(cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyIfActive<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
apply: (value: T) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isActive()) return Promise.resolve(false);
|
||||||
|
|
||||||
|
return this.track(this.applyActive(operation, apply));
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyLatestIfActive<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
apply: (value: T) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isActive()) return Promise.resolve(false);
|
||||||
|
|
||||||
|
const generation = ++this.latestOperation;
|
||||||
|
return this.track(this.applyActive(operation, (value) => {
|
||||||
|
if (generation === this.latestOperation) apply(value);
|
||||||
|
}, generation));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyActive<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
apply: (value: T) => void,
|
||||||
|
generation?: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isActive()) return false;
|
||||||
|
|
||||||
|
let value: T;
|
||||||
|
try {
|
||||||
|
value = await operation();
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
!this.isActive() ||
|
||||||
|
(generation !== undefined && generation !== this.latestOperation)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.isActive() ||
|
||||||
|
(generation !== undefined && generation !== this.latestOperation)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
apply(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): Promise<void> {
|
||||||
|
this.beginDispose();
|
||||||
|
return this.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drains every admitted operation before native webview destruction. */
|
||||||
|
public disposeForWindowClose(): Promise<void> {
|
||||||
|
this.beginDispose();
|
||||||
|
return this.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginDispose(): void {
|
||||||
|
if (!this.isActive()) return;
|
||||||
|
|
||||||
|
this.disposed = true;
|
||||||
|
this.releaseWindowOwnership();
|
||||||
|
this.latestOperation += 1;
|
||||||
|
const cleanups = this.cleanups.reverse();
|
||||||
|
this.cleanups = [];
|
||||||
|
for (const cleanup of cleanups) void this.release(cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async drain(): Promise<void> {
|
||||||
|
while (this.pending.size > 0) {
|
||||||
|
await Promise.allSettled([...this.pending]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private track<T>(promise: Promise<T>): Promise<T> {
|
||||||
|
this.pending.add(promise);
|
||||||
|
void promise.then(
|
||||||
|
() => this.pending.delete(promise),
|
||||||
|
() => this.pending.delete(promise),
|
||||||
|
);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackRegistration<T>(promise: Promise<T>): Promise<T> {
|
||||||
|
this.pendingRegistrations.add(promise);
|
||||||
|
void promise.then(
|
||||||
|
() => this.pendingRegistrations.delete(promise),
|
||||||
|
() => this.pendingRegistrations.delete(promise),
|
||||||
|
);
|
||||||
|
return this.track(promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackCleanup(promise: Promise<void>): Promise<void> {
|
||||||
|
this.pendingCleanups.add(promise);
|
||||||
|
void promise.then(
|
||||||
|
() => this.pendingCleanups.delete(promise),
|
||||||
|
() => this.pendingCleanups.delete(promise),
|
||||||
|
);
|
||||||
|
return this.track(promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runCleanup(cleanup: AsyncCleanup): Promise<void> {
|
||||||
|
try {
|
||||||
|
await cleanup();
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
this.reportCleanupError(error);
|
||||||
|
} catch {
|
||||||
|
// Cleanup reporting must not interrupt the remaining cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanionWindowCreationPort {
|
||||||
|
registerCreated: (handler: () => void) => Promise<AsyncCleanup>;
|
||||||
|
registerError: (handler: (payload: unknown) => void) => Promise<AsyncCleanup>;
|
||||||
|
destroy: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompanionWindowCreationResult =
|
||||||
|
| { kind: "created" }
|
||||||
|
| { kind: "error"; payload: unknown }
|
||||||
|
| { kind: "registration-error"; error: unknown };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns one native companion-window creation through exactly one created/error
|
||||||
|
* outcome. Parent disposal keeps the internal callbacks alive until that
|
||||||
|
* outcome, drains both unlisten acknowledgements, and destroys a window that
|
||||||
|
* finishes creating after close admission has ended.
|
||||||
|
*/
|
||||||
|
export const ownCompanionWindowCreation = async (
|
||||||
|
owner: AsyncOwner,
|
||||||
|
port: CompanionWindowCreationPort,
|
||||||
|
): Promise<CompanionWindowCreationResult> => {
|
||||||
|
let settle!: (result: CompanionWindowCreationResult) => void;
|
||||||
|
let settled = false;
|
||||||
|
const outcome = new Promise<CompanionWindowCreationResult>((resolve) => {
|
||||||
|
settle = (result) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const register = (
|
||||||
|
operation: () => Promise<AsyncCleanup>,
|
||||||
|
): Promise<AsyncCleanup> => {
|
||||||
|
try {
|
||||||
|
return operation();
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const createdRegistration = register(() =>
|
||||||
|
port.registerCreated(() => settle({ kind: "created" }))
|
||||||
|
);
|
||||||
|
const errorRegistration = register(() =>
|
||||||
|
port.registerError((payload) => settle({ kind: "error", payload }))
|
||||||
|
);
|
||||||
|
void createdRegistration.catch((error) =>
|
||||||
|
settle({ kind: "registration-error", error })
|
||||||
|
);
|
||||||
|
void errorRegistration.catch((error) =>
|
||||||
|
settle({ kind: "registration-error", error })
|
||||||
|
);
|
||||||
|
|
||||||
|
let parentClosing = false;
|
||||||
|
let completion: Promise<CompanionWindowCreationResult> | undefined;
|
||||||
|
const complete = (): Promise<CompanionWindowCreationResult> => {
|
||||||
|
if (completion !== undefined) return completion;
|
||||||
|
|
||||||
|
completion = (async () => {
|
||||||
|
const result = await outcome;
|
||||||
|
const registrations = await Promise.allSettled([
|
||||||
|
createdRegistration,
|
||||||
|
errorRegistration,
|
||||||
|
]);
|
||||||
|
const cleanupResults = await Promise.allSettled(
|
||||||
|
registrations.flatMap((registration) =>
|
||||||
|
registration.status === "fulfilled"
|
||||||
|
? [Promise.resolve().then(registration.value)]
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleanupFailure = cleanupResults.find(
|
||||||
|
(cleanup) => cleanup.status === "rejected",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
result.kind === "registration-error" ||
|
||||||
|
(result.kind === "created" && parentClosing)
|
||||||
|
) {
|
||||||
|
await port.destroy();
|
||||||
|
}
|
||||||
|
if (cleanupFailure?.status === "rejected") throw cleanupFailure.reason;
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
return completion;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!owner.own(async () => {
|
||||||
|
parentClosing = true;
|
||||||
|
await complete();
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
parentClosing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return complete();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const registerSequentially = async (
|
||||||
|
owner: AsyncOwner,
|
||||||
|
registrations: ReadonlyArray<AsyncRegistration>,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
for (const registration of registrations) {
|
||||||
|
if (!await owner.register(registration)) return false;
|
||||||
|
}
|
||||||
|
return owner.isActive();
|
||||||
|
} catch (error) {
|
||||||
|
await owner.dispose();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SerializedAsyncWriter<T> {
|
||||||
|
enqueue: (value: T) => Promise<void>;
|
||||||
|
waitForIdle: () => Promise<void>;
|
||||||
|
closeAndWait: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializes writes and keeps a failed write from poisoning later work. */
|
||||||
|
export const createSerializedAsyncWriter = <T>(
|
||||||
|
write: (value: T) => Promise<void>,
|
||||||
|
reportFailure: (error: unknown) => void,
|
||||||
|
): SerializedAsyncWriter<T> => {
|
||||||
|
let tail = Promise.resolve();
|
||||||
|
let accepting = true;
|
||||||
|
|
||||||
|
const enqueue = (value: T): Promise<void> => {
|
||||||
|
if (!accepting) return Promise.resolve();
|
||||||
|
|
||||||
|
tail = tail.then(async () => {
|
||||||
|
try {
|
||||||
|
await write(value);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
reportFailure(error);
|
||||||
|
} catch {
|
||||||
|
// Reporting must not prevent a newer value from being written.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return tail;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForIdle = async (): Promise<void> => {
|
||||||
|
let observed: Promise<void>;
|
||||||
|
do {
|
||||||
|
observed = tail;
|
||||||
|
await observed;
|
||||||
|
} while (observed !== tail);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueue,
|
||||||
|
waitForIdle,
|
||||||
|
closeAndWait: () => {
|
||||||
|
accepting = false;
|
||||||
|
return waitForIdle();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
CallToPlayAction,
|
CallToPlayAction,
|
||||||
CallToPlayEvent,
|
CallToPlayView,
|
||||||
|
CallToPlayViewEvent,
|
||||||
CallToPlayParticipant,
|
CallToPlayParticipant,
|
||||||
Nomination,
|
Nomination,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -13,17 +14,22 @@ export const CALL_TO_PLAY_CONNECTING_MESSAGE =
|
|||||||
|
|
||||||
export const callToPlayPublishErrorMessage = (error: unknown): string => {
|
export const callToPlayPublishErrorMessage = (error: unknown): string => {
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
if (detail.includes('Call to Play event is obsolete')
|
if (detail.includes('unknown or expired')
|
||||||
|| detail.includes('Call to Play history is missing')
|
|| detail.includes('already terminal')
|
||||||
) {
|
) {
|
||||||
return 'This Call to Play has expired or already finished.';
|
return 'This Call to Play has expired or already finished.';
|
||||||
}
|
}
|
||||||
if (detail.includes('Call to Play event history is full')) {
|
if (detail.includes('local event history is full')) {
|
||||||
return 'Call to Play has reached its active update limit. Start or cancel an active call, then try again.';
|
return 'Call to Play has reached its active update limit. Start or cancel an active call, then try again.';
|
||||||
}
|
}
|
||||||
return 'Could not send this Call to Play update.';
|
return 'Could not send this Call to Play update.';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const replaceCallToPlayView = (
|
||||||
|
_previous: CallToPlayView,
|
||||||
|
incoming: CallToPlayView,
|
||||||
|
): CallToPlayView => incoming;
|
||||||
|
|
||||||
export const extendDeadline = (
|
export const extendDeadline = (
|
||||||
now: number,
|
now: number,
|
||||||
currentDeadline: number,
|
currentDeadline: number,
|
||||||
@@ -44,7 +50,7 @@ interface MutableNomination extends Nomination {
|
|||||||
messageIds: Set<string>;
|
messageIds: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const compareEvents = (a: CallToPlayEvent, b: CallToPlayEvent): number =>
|
const compareEvents = (a: CallToPlayViewEvent, b: CallToPlayViewEvent): number =>
|
||||||
a.at - b.at || a.id.localeCompare(b.id);
|
a.at - b.at || a.id.localeCompare(b.id);
|
||||||
|
|
||||||
type CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create'];
|
type CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create'];
|
||||||
@@ -117,7 +123,7 @@ export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const reduceCallToPlayEvents = (
|
export const reduceCallToPlayEvents = (
|
||||||
input: ReadonlyArray<CallToPlayEvent>,
|
input: ReadonlyArray<CallToPlayViewEvent>,
|
||||||
now: number,
|
now: number,
|
||||||
): Nomination[] => {
|
): Nomination[] => {
|
||||||
const nominations = [...groupEvents(input).values()]
|
const nominations = [...groupEvents(input).values()]
|
||||||
@@ -126,35 +132,11 @@ export const reduceCallToPlayEvents = (
|
|||||||
return sortNominations(nominations);
|
return sortNominations(nominations);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pruneCallToPlayEvents = (
|
|
||||||
previous: ReadonlyMap<string, CallToPlayEvent>,
|
|
||||||
now: number,
|
|
||||||
): ReadonlyMap<string, CallToPlayEvent> => {
|
|
||||||
const retiredEventIds = new Set<string>();
|
|
||||||
for (const events of groupEvents([...previous.values()]).values()) {
|
|
||||||
if (deriveNomination(events, now) !== null) continue;
|
|
||||||
|
|
||||||
const hasCreate = events.some(event => createPayload(event.action) !== null);
|
|
||||||
const expiredTombstone = events.some(event =>
|
|
||||||
(event.action === 'Start' || event.action === 'Cancel')
|
|
||||||
&& now - event.at > TERMINAL_RETENTION_MS
|
|
||||||
);
|
|
||||||
if (hasCreate || expiredTombstone) {
|
|
||||||
for (const event of events) retiredEventIds.add(event.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (retiredEventIds.size === 0) return previous;
|
|
||||||
|
|
||||||
const next = new Map(previous);
|
|
||||||
for (const eventId of retiredEventIds) next.delete(eventId);
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupEvents = (
|
const groupEvents = (
|
||||||
input: ReadonlyArray<CallToPlayEvent>,
|
input: ReadonlyArray<CallToPlayViewEvent>,
|
||||||
): Map<string, CallToPlayEvent[]> => {
|
): Map<string, CallToPlayViewEvent[]> => {
|
||||||
const unique = new Map(input.map(event => [event.id, event]));
|
const unique = new Map(input.map(event => [event.id, event]));
|
||||||
const byCall = new Map<string, CallToPlayEvent[]>();
|
const byCall = new Map<string, CallToPlayViewEvent[]>();
|
||||||
for (const event of unique.values()) {
|
for (const event of unique.values()) {
|
||||||
const events = byCall.get(event.call_id) ?? [];
|
const events = byCall.get(event.call_id) ?? [];
|
||||||
events.push(event);
|
events.push(event);
|
||||||
@@ -164,7 +146,7 @@ const groupEvents = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deriveNomination = (
|
const deriveNomination = (
|
||||||
events: CallToPlayEvent[],
|
events: CallToPlayViewEvent[],
|
||||||
now: number,
|
now: number,
|
||||||
): Nomination | null => {
|
): Nomination | null => {
|
||||||
events.sort(compareEvents);
|
events.sort(compareEvents);
|
||||||
@@ -176,15 +158,15 @@ const deriveNomination = (
|
|||||||
const nomination: MutableNomination = {
|
const nomination: MutableNomination = {
|
||||||
id: create.call_id,
|
id: create.call_id,
|
||||||
gameId: payload.game_id,
|
gameId: payload.game_id,
|
||||||
creatorId: create.actor_id,
|
creatorId: create.author_id,
|
||||||
creator: create.actor_name,
|
creator: create.author_name,
|
||||||
maxPlayers: payload.max_players,
|
maxPlayers: payload.max_players,
|
||||||
createdAt: create.at,
|
createdAt: create.at,
|
||||||
scheduledFor: payload.scheduled_for,
|
scheduledFor: payload.scheduled_for,
|
||||||
deadline: payload.deadline,
|
deadline: payload.deadline,
|
||||||
participants: {
|
participants: {
|
||||||
[create.actor_id]: {
|
[create.author_id]: {
|
||||||
name: create.actor_name,
|
name: create.author_name,
|
||||||
status: payload.scheduled_for === null ? 'ready' : 'in',
|
status: payload.scheduled_for === null ? 'ready' : 'in',
|
||||||
joinedAt: create.at,
|
joinedAt: create.at,
|
||||||
},
|
},
|
||||||
@@ -214,7 +196,7 @@ const deriveNomination = (
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
|
const applyEvent = (nomination: MutableNomination, event: CallToPlayViewEvent): void => {
|
||||||
if (isTerminal(nomination)) return;
|
if (isTerminal(nomination)) return;
|
||||||
|
|
||||||
const action = event.action;
|
const action = event.action;
|
||||||
@@ -225,9 +207,9 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
|||||||
|
|
||||||
const response = respondPayload(action);
|
const response = respondPayload(action);
|
||||||
if (response) {
|
if (response) {
|
||||||
const existing = nomination.participants[event.actor_id];
|
const existing = nomination.participants[event.author_id];
|
||||||
nomination.participants[event.actor_id] = {
|
nomination.participants[event.author_id] = {
|
||||||
name: event.actor_name,
|
name: event.author_name,
|
||||||
status: response.ready_at === null ? 'ready' : 'pending',
|
status: response.ready_at === null ? 'ready' : 'pending',
|
||||||
joinedAt: existing?.joinedAt ?? event.at,
|
joinedAt: existing?.joinedAt ?? event.at,
|
||||||
...(response.ready_at === null ? {} : { readyAt: response.ready_at }),
|
...(response.ready_at === null ? {} : { readyAt: response.ready_at }),
|
||||||
@@ -236,12 +218,12 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = messagePayload(action);
|
const message = messagePayload(action);
|
||||||
if (message && !nomination.messageIds.has(message.message_id)) {
|
if (message && !nomination.messageIds.has(event.id)) {
|
||||||
nomination.messageIds.add(message.message_id);
|
nomination.messageIds.add(event.id);
|
||||||
nomination.messages.push({
|
nomination.messages.push({
|
||||||
id: message.message_id,
|
id: event.id,
|
||||||
fromId: event.actor_id,
|
fromId: event.author_id,
|
||||||
from: event.actor_name,
|
from: event.author_name,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
at: event.at,
|
at: event.at,
|
||||||
});
|
});
|
||||||
@@ -251,7 +233,7 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
|||||||
|
|
||||||
const extension = addTimePayload(action);
|
const extension = addTimePayload(action);
|
||||||
if (extension
|
if (extension
|
||||||
&& event.actor_id === nomination.creatorId
|
&& event.author_id === nomination.creatorId
|
||||||
) {
|
) {
|
||||||
nomination.deadline = extension.deadline;
|
nomination.deadline = extension.deadline;
|
||||||
nomination.state = 'open';
|
nomination.state = 'open';
|
||||||
@@ -260,32 +242,32 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
|||||||
|
|
||||||
const applyUnitAction = (
|
const applyUnitAction = (
|
||||||
nomination: MutableNomination,
|
nomination: MutableNomination,
|
||||||
event: CallToPlayEvent,
|
event: CallToPlayViewEvent,
|
||||||
action: Extract<CallToPlayAction, string>,
|
action: Extract<CallToPlayAction, string>,
|
||||||
): void => {
|
): void => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'Rsvp': {
|
case 'Rsvp': {
|
||||||
const existing = nomination.participants[event.actor_id];
|
const existing = nomination.participants[event.author_id];
|
||||||
nomination.participants[event.actor_id] = {
|
nomination.participants[event.author_id] = {
|
||||||
name: event.actor_name,
|
name: event.author_name,
|
||||||
status: 'in',
|
status: 'in',
|
||||||
joinedAt: existing?.joinedAt ?? event.at,
|
joinedAt: existing?.joinedAt ?? event.at,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'Leave':
|
case 'Leave':
|
||||||
if (event.actor_id !== nomination.creatorId) {
|
if (event.author_id !== nomination.creatorId) {
|
||||||
delete nomination.participants[event.actor_id];
|
delete nomination.participants[event.author_id];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Cancel':
|
case 'Cancel':
|
||||||
if (event.actor_id === nomination.creatorId) {
|
if (event.author_id === nomination.creatorId) {
|
||||||
nomination.state = 'cancelled';
|
nomination.state = 'cancelled';
|
||||||
nomination.terminalAt = event.at;
|
nomination.terminalAt = event.at;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Start':
|
case 'Start':
|
||||||
if (event.actor_id === nomination.creatorId) {
|
if (event.author_id === nomination.creatorId) {
|
||||||
nomination.state = 'running';
|
nomination.state = 'running';
|
||||||
nomination.terminalAt = event.at;
|
nomination.terminalAt = event.at;
|
||||||
}
|
}
|
||||||
@@ -293,21 +275,6 @@ const applyUnitAction = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const callToPlayEvent = (
|
|
||||||
callId: string,
|
|
||||||
actorId: string,
|
|
||||||
actorName: string,
|
|
||||||
action: CallToPlayAction,
|
|
||||||
at = Date.now(),
|
|
||||||
): CallToPlayEvent => ({
|
|
||||||
id: globalThis.crypto.randomUUID(),
|
|
||||||
call_id: callId,
|
|
||||||
actor_id: actorId,
|
|
||||||
actor_name: actorName,
|
|
||||||
at,
|
|
||||||
action,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const formatClock = (timestamp: number): string => {
|
export const formatClock = (timestamp: number): string => {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import {
|
||||||
|
AsyncOwner,
|
||||||
|
type AsyncCleanup,
|
||||||
|
type AsyncRegistration,
|
||||||
|
windowAsyncScope,
|
||||||
|
} from './asyncOwnership';
|
||||||
|
|
||||||
|
export type CallToPlayRetryCallback = () => Promise<void>;
|
||||||
|
export type CallToPlayRetryScheduler = (
|
||||||
|
callback: CallToPlayRetryCallback,
|
||||||
|
delayMilliseconds: number,
|
||||||
|
) => AsyncCleanup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the Call to Play listener, snapshots, actions, and retry timer as one
|
||||||
|
* lifecycle. A retry is scheduled only after the preceding attempt settles.
|
||||||
|
*/
|
||||||
|
export class CallToPlayAsyncScope {
|
||||||
|
private readonly owner: AsyncOwner;
|
||||||
|
private retryAttempt: (() => Promise<boolean>) | undefined;
|
||||||
|
private retryCleanup: AsyncCleanup | undefined;
|
||||||
|
private retryToken: object | undefined;
|
||||||
|
private retryRunning = false;
|
||||||
|
private retryEnabled = false;
|
||||||
|
private retryGeneration = 0;
|
||||||
|
private retryDelayMilliseconds = 0;
|
||||||
|
private actionGeneration = 0;
|
||||||
|
private mutationTail: Promise<void> = Promise.resolve();
|
||||||
|
private mutationAdmissionOpen = true;
|
||||||
|
private readonly releaseWindowOwnership: () => void;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly scheduleRetry: CallToPlayRetryScheduler,
|
||||||
|
reportCleanupFailure: (error: unknown) => void = () => {},
|
||||||
|
private readonly reportRetryError: (error: unknown) => void = () => {},
|
||||||
|
) {
|
||||||
|
this.owner = new AsyncOwner(reportCleanupFailure);
|
||||||
|
this.releaseWindowOwnership = windowAsyncScope.registerDisposer(
|
||||||
|
() => this.disposeForWindowClose(),
|
||||||
|
) ?? (() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public isActive(): boolean {
|
||||||
|
return this.owner.isActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
public guard<Args extends unknown[]>(
|
||||||
|
callback: (...args: Args) => void,
|
||||||
|
): (...args: Args) => void {
|
||||||
|
return this.owner.guard(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerListener(registration: AsyncRegistration): Promise<boolean> {
|
||||||
|
return this.owner.register(registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyIfActive<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
apply: (value: T) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.owner.applyIfActive(operation, apply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admits a backend mutation in user order. Once admitted, a mutation runs
|
||||||
|
* even if the React scope is disposed while it is queued; disposal closes
|
||||||
|
* admission and drains the already-admitted sequence. Result publication
|
||||||
|
* remains lifecycle-guarded.
|
||||||
|
*/
|
||||||
|
public applyMutationIfActive<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
apply: (value: T) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isActive() || !this.mutationAdmissionOpen) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = this.mutationTail.then(async () => {
|
||||||
|
const value = await operation();
|
||||||
|
if (!this.isActive()) return false;
|
||||||
|
apply(value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
this.mutationTail = mutation.then(
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
return mutation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public guardLatestAction<Args extends unknown[]>(
|
||||||
|
publish: (...args: Args) => void,
|
||||||
|
): (...args: Args) => void {
|
||||||
|
const generation = ++this.actionGeneration;
|
||||||
|
return this.owner.guard((...args) => {
|
||||||
|
if (generation === this.actionGeneration) publish(...args);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public startRetry(
|
||||||
|
attempt: () => Promise<boolean>,
|
||||||
|
delayMilliseconds: number,
|
||||||
|
): void {
|
||||||
|
if (!this.isActive() || this.retryEnabled) return;
|
||||||
|
|
||||||
|
this.retryAttempt = attempt;
|
||||||
|
this.retryDelayMilliseconds = delayMilliseconds;
|
||||||
|
this.retryEnabled = true;
|
||||||
|
this.retryGeneration += 1;
|
||||||
|
this.scheduleNextRetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
public stopRetry(): void {
|
||||||
|
this.retryEnabled = false;
|
||||||
|
this.retryGeneration += 1;
|
||||||
|
this.clearScheduledRetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): Promise<void> {
|
||||||
|
this.releaseWindowOwnership();
|
||||||
|
this.stopRetry();
|
||||||
|
this.mutationAdmissionOpen = false;
|
||||||
|
return Promise.all([this.mutationTail, this.owner.dispose()]).then(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private disposeForWindowClose(): Promise<void> {
|
||||||
|
this.releaseWindowOwnership();
|
||||||
|
this.stopRetry();
|
||||||
|
this.mutationAdmissionOpen = false;
|
||||||
|
return Promise.all([
|
||||||
|
this.mutationTail,
|
||||||
|
this.owner.disposeForWindowClose(),
|
||||||
|
]).then(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNextRetry(): void {
|
||||||
|
if (
|
||||||
|
!this.isActive()
|
||||||
|
|| !this.retryEnabled
|
||||||
|
|| this.retryRunning
|
||||||
|
|| this.retryCleanup !== undefined
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = this.retryGeneration;
|
||||||
|
const token = {};
|
||||||
|
const cleanup = this.scheduleRetry(
|
||||||
|
async () => {
|
||||||
|
await this.owner.applyIfActive(
|
||||||
|
() => this.runRetry(generation, token),
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
this.retryDelayMilliseconds,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!this.isActive()
|
||||||
|
|| !this.retryEnabled
|
||||||
|
|| generation !== this.retryGeneration
|
||||||
|
) {
|
||||||
|
void this.owner.release(cleanup);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.retryToken = token;
|
||||||
|
this.retryCleanup = cleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runRetry(generation: number, token: object): Promise<void> {
|
||||||
|
if (token !== this.retryToken) return;
|
||||||
|
|
||||||
|
this.retryToken = undefined;
|
||||||
|
this.retryCleanup = undefined;
|
||||||
|
if (
|
||||||
|
!this.isActive()
|
||||||
|
|| !this.retryEnabled
|
||||||
|
|| generation !== this.retryGeneration
|
||||||
|
|| this.retryRunning
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempt = this.retryAttempt;
|
||||||
|
if (attempt === undefined) return;
|
||||||
|
|
||||||
|
this.retryRunning = true;
|
||||||
|
try {
|
||||||
|
const ready = await attempt();
|
||||||
|
if (
|
||||||
|
ready
|
||||||
|
&& this.isActive()
|
||||||
|
&& this.retryEnabled
|
||||||
|
&& generation === this.retryGeneration
|
||||||
|
) {
|
||||||
|
this.stopRetry();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
this.isActive()
|
||||||
|
&& this.retryEnabled
|
||||||
|
&& generation === this.retryGeneration
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
this.reportRetryError(error);
|
||||||
|
} catch {
|
||||||
|
// Retry error reporting must not detach the owned loop.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.retryRunning = false;
|
||||||
|
this.scheduleNextRetry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearScheduledRetry(): void {
|
||||||
|
this.retryToken = undefined;
|
||||||
|
const cleanup = this.retryCleanup;
|
||||||
|
this.retryCleanup = undefined;
|
||||||
|
if (cleanup === undefined) return;
|
||||||
|
|
||||||
|
void this.owner.release(cleanup);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { type AsyncCleanup } from './asyncOwnership';
|
||||||
|
|
||||||
|
export interface FrontendCloseRequest {
|
||||||
|
preventDefault: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrontendBootstrapOptions {
|
||||||
|
registerCloseRequested: (
|
||||||
|
handler: (event: FrontendCloseRequest) => void | Promise<void>,
|
||||||
|
) => Promise<AsyncCleanup>;
|
||||||
|
disposeOwners: () => Promise<void>;
|
||||||
|
drainPersistence: () => Promise<void>;
|
||||||
|
destroyWindow: () => Promise<void>;
|
||||||
|
render: () => void;
|
||||||
|
reportFailure: (message: string, error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = (
|
||||||
|
options: FrontendBootstrapOptions,
|
||||||
|
message: string,
|
||||||
|
error: unknown,
|
||||||
|
): void => {
|
||||||
|
try {
|
||||||
|
options.reportFailure(message, error);
|
||||||
|
} catch {
|
||||||
|
// Reporting must not turn a handled bootstrap failure into a rejection.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runHandled = async (
|
||||||
|
options: FrontendBootstrapOptions,
|
||||||
|
message: string,
|
||||||
|
operation: () => void | Promise<void>,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
} catch (error) {
|
||||||
|
report(options, message, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs the webview's close boundary before mounting any React hooks.
|
||||||
|
* Registration failure closes the empty realm instead of running without an
|
||||||
|
* ownership boundary.
|
||||||
|
*/
|
||||||
|
export const bootstrapFrontend = async (
|
||||||
|
options: FrontendBootstrapOptions,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
let closing: Promise<void> | undefined;
|
||||||
|
let publishCloseCleanup!: (cleanup: AsyncCleanup) => void;
|
||||||
|
const closeCleanupReady = new Promise<AsyncCleanup>(resolve => {
|
||||||
|
publishCloseCleanup = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalizeWindow = (): Promise<void> => {
|
||||||
|
if (closing !== undefined) return closing;
|
||||||
|
|
||||||
|
// Calling every operation closes its admission or starts its cleanup
|
||||||
|
// synchronously, before any asynchronous drain is awaited.
|
||||||
|
const ownerDrain = runHandled(
|
||||||
|
options,
|
||||||
|
'Failed to dispose frontend window owners:',
|
||||||
|
options.disposeOwners,
|
||||||
|
);
|
||||||
|
const persistenceDrain = runHandled(
|
||||||
|
options,
|
||||||
|
'Failed to drain frontend persistence:',
|
||||||
|
options.drainPersistence,
|
||||||
|
);
|
||||||
|
const listenerDrain = runHandled(
|
||||||
|
options,
|
||||||
|
'Failed to unregister the window close listener:',
|
||||||
|
async () => {
|
||||||
|
const cleanup = await closeCleanupReady;
|
||||||
|
await cleanup();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
closing = Promise.all([
|
||||||
|
ownerDrain,
|
||||||
|
persistenceDrain,
|
||||||
|
listenerDrain,
|
||||||
|
])
|
||||||
|
.then(() => runHandled(
|
||||||
|
options,
|
||||||
|
'Failed to destroy the frontend window:',
|
||||||
|
options.destroyWindow,
|
||||||
|
));
|
||||||
|
return closing;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cleanupCloseListener = await options.registerCloseRequested(event => {
|
||||||
|
event.preventDefault();
|
||||||
|
return finalizeWindow();
|
||||||
|
});
|
||||||
|
publishCloseCleanup(cleanupCloseListener);
|
||||||
|
} catch (error) {
|
||||||
|
publishCloseCleanup(() => {});
|
||||||
|
report(options, 'Failed to install the frontend close-drain boundary:', error);
|
||||||
|
|
||||||
|
// Nothing has rendered, but close both admissions in case a module-level
|
||||||
|
// owner was registered while loading the bundle, then explicitly await
|
||||||
|
// destruction of the empty webview.
|
||||||
|
await finalizeWindow();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closing !== undefined) {
|
||||||
|
// A close request may arrive after the native listener is callable but
|
||||||
|
// before its registration promise acknowledges ownership. The active
|
||||||
|
// finalizer now owns the late cleanup; never mount React into a realm
|
||||||
|
// that has already begun closing.
|
||||||
|
await closing;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
options.render();
|
||||||
|
} catch (error) {
|
||||||
|
report(options, 'Failed to render the frontend:', error);
|
||||||
|
await finalizeWindow();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
export type PersistenceShutdown = () => Promise<void>;
|
||||||
|
export type PersistenceEffectStart = () => void | (() => void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns persistence queues for one webview. Closing admission is synchronous
|
||||||
|
* and permanent; draining waits for every queue admitted before close without
|
||||||
|
* pulling unrelated network operations into window shutdown.
|
||||||
|
*/
|
||||||
|
export class PersistenceShutdownScope {
|
||||||
|
private accepting = true;
|
||||||
|
private readonly shutdownTasks = new Set<PersistenceShutdown>();
|
||||||
|
private readonly pending = new Set<Promise<void>>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly reportFailure: (error: unknown) => void = () => {},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public register(shutdown: PersistenceShutdown): (() => void) | undefined {
|
||||||
|
if (!this.accepting) return undefined;
|
||||||
|
|
||||||
|
this.shutdownTasks.add(shutdown);
|
||||||
|
return () => this.shutdownTasks.delete(shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async closeAndDrain(): Promise<void> {
|
||||||
|
if (this.accepting) {
|
||||||
|
this.accepting = false;
|
||||||
|
const tasks = [...this.shutdownTasks];
|
||||||
|
this.shutdownTasks.clear();
|
||||||
|
for (const shutdown of tasks) this.start(shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (this.pending.size > 0) {
|
||||||
|
await Promise.allSettled([...this.pending]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private start(shutdown: PersistenceShutdown): void {
|
||||||
|
let pending: Promise<void>;
|
||||||
|
try {
|
||||||
|
pending = shutdown();
|
||||||
|
} catch (error) {
|
||||||
|
this.report(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pending.add(pending);
|
||||||
|
void pending.then(
|
||||||
|
() => this.pending.delete(pending),
|
||||||
|
error => {
|
||||||
|
this.pending.delete(pending);
|
||||||
|
this.report(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private report(error: unknown): void {
|
||||||
|
try {
|
||||||
|
this.reportFailure(error);
|
||||||
|
} catch {
|
||||||
|
// Reporting cannot be allowed to escape the shutdown scope.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically admits a passive effect before it starts persistence work. Once
|
||||||
|
* window shutdown closes the scope, later effects receive no admission and
|
||||||
|
* cannot reopen hydration or write queues.
|
||||||
|
*/
|
||||||
|
export const startAdmittedPersistenceEffect = (
|
||||||
|
scope: PersistenceShutdownScope,
|
||||||
|
shutdown: PersistenceShutdown,
|
||||||
|
start: PersistenceEffectStart,
|
||||||
|
): (() => void) => {
|
||||||
|
const unregister = scope.register(shutdown);
|
||||||
|
if (unregister === undefined) return () => {};
|
||||||
|
|
||||||
|
let cleanup: void | (() => void);
|
||||||
|
try {
|
||||||
|
cleanup = start();
|
||||||
|
} catch (error) {
|
||||||
|
unregister();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanup?.();
|
||||||
|
unregister();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const windowPersistenceScope = new PersistenceShutdownScope(error => {
|
||||||
|
console.error('Failed to drain frontend persistence:', error);
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export interface GameDirectoryUpdatePorts {
|
||||||
|
updateBackend: (requestedPath: string) => Promise<unknown>;
|
||||||
|
persist: (acceptedPath: string) => Promise<void>;
|
||||||
|
reportPersistenceError: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameDirectoryHydrationPorts {
|
||||||
|
loadSavedPath: () => Promise<string | null | undefined>;
|
||||||
|
acceptSavedPath: (savedPath: string) => Promise<void>;
|
||||||
|
reportLoadError: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settles restoration before the caller marks game-directory state ready.
|
||||||
|
* Missing state and load/acceptance failures are completed outcomes: the UI
|
||||||
|
* may then show the chooser, but never while a saved path is still being
|
||||||
|
* accepted by the backend.
|
||||||
|
*/
|
||||||
|
export const hydrateGameDirectory = async (
|
||||||
|
ports: GameDirectoryHydrationPorts,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const savedPath = await ports.loadSavedPath();
|
||||||
|
if (savedPath?.trim()) await ports.acceptSavedPath(savedPath);
|
||||||
|
} catch (error) {
|
||||||
|
ports.reportLoadError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the backend to accept a game directory and persists only the canonical
|
||||||
|
* path returned by that successful request.
|
||||||
|
*
|
||||||
|
* Backend acknowledgement is the commit point. Persistence is best-effort: a
|
||||||
|
* store failure must not make the UI retain a path that no longer matches the
|
||||||
|
* already-updated backend.
|
||||||
|
*/
|
||||||
|
export const acceptGameDirectory = async (
|
||||||
|
requestedPath: string,
|
||||||
|
ports: GameDirectoryUpdatePorts,
|
||||||
|
): Promise<string> => {
|
||||||
|
const acceptedPath = await ports.updateBackend(requestedPath);
|
||||||
|
if (typeof acceptedPath !== 'string' || !acceptedPath.trim()) {
|
||||||
|
throw new Error('update_game_directory returned an invalid accepted path');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ports.persist(acceptedPath);
|
||||||
|
} catch (error) {
|
||||||
|
ports.reportPersistenceError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acceptedPath;
|
||||||
|
};
|
||||||
@@ -1,178 +1,315 @@
|
|||||||
import {
|
import {
|
||||||
ActiveOperation,
|
ActiveOperation,
|
||||||
ActiveOperationKind,
|
ActiveOperationKind,
|
||||||
DerivedState,
|
DerivedState,
|
||||||
Game,
|
DownloadProgressPayload,
|
||||||
GameFilter,
|
Game,
|
||||||
GameSort,
|
GameFilter,
|
||||||
GamesListPayload,
|
GameSort,
|
||||||
InstallStatus,
|
GameTransferStatus,
|
||||||
} from './types';
|
GameTransferStatusSnapshot,
|
||||||
|
InstallStatus,
|
||||||
|
StatusLevel,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
|
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
|
||||||
InstallStatus.CheckingPeers,
|
InstallStatus.Downloading,
|
||||||
InstallStatus.Downloading,
|
InstallStatus.Installing,
|
||||||
InstallStatus.Installing,
|
InstallStatus.Uninstalling,
|
||||||
InstallStatus.Uninstalling,
|
InstallStatus.Removing,
|
||||||
InstallStatus.Removing,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const isInProgress = (status: InstallStatus): boolean =>
|
export const isInProgress = (status: InstallStatus): boolean =>
|
||||||
IN_PROGRESS_INSTALL_STATUSES.has(status);
|
IN_PROGRESS_INSTALL_STATUSES.has(status);
|
||||||
|
|
||||||
export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => {
|
export const installStatusFromActiveOperation = (
|
||||||
switch (op) {
|
op: ActiveOperationKind,
|
||||||
case ActiveOperationKind.Downloading:
|
): InstallStatus => {
|
||||||
return InstallStatus.Downloading;
|
switch (op) {
|
||||||
case ActiveOperationKind.Installing:
|
case ActiveOperationKind.Downloading:
|
||||||
case ActiveOperationKind.Updating:
|
return InstallStatus.Downloading;
|
||||||
return InstallStatus.Installing;
|
case ActiveOperationKind.Installing:
|
||||||
case ActiveOperationKind.Uninstalling:
|
case ActiveOperationKind.Updating:
|
||||||
return InstallStatus.Uninstalling;
|
return InstallStatus.Installing;
|
||||||
case ActiveOperationKind.RemovingDownload:
|
case ActiveOperationKind.Uninstalling:
|
||||||
return InstallStatus.Removing;
|
return InstallStatus.Uninstalling;
|
||||||
}
|
case ActiveOperationKind.RemovingDownload:
|
||||||
|
return InstallStatus.Removing;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const activeStatusById = (ops: ActiveOperation[] = []): Map<string, InstallStatus> =>
|
export const activeStatusById = (
|
||||||
new Map(ops.map(op => [op.id, installStatusFromActiveOperation(op.operation)]));
|
ops: ActiveOperation[] = [],
|
||||||
|
): Map<string, InstallStatus> =>
|
||||||
|
new Map(
|
||||||
|
ops.map((op) => [op.id, installStatusFromActiveOperation(op.operation)]),
|
||||||
|
);
|
||||||
|
|
||||||
export const normalizeGamesListPayload = (
|
export const INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT: GameTransferStatusSnapshot =
|
||||||
payload: GamesListPayload | Game[],
|
{
|
||||||
): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload;
|
revision: 0,
|
||||||
|
statuses: {},
|
||||||
|
openAttempts: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keeps listener events and GamesList snapshots monotonic across async races. */
|
||||||
|
export const newestGameTransferStatusSnapshot = (
|
||||||
|
current: GameTransferStatusSnapshot,
|
||||||
|
candidate: GameTransferStatusSnapshot,
|
||||||
|
): GameTransferStatusSnapshot =>
|
||||||
|
candidate.revision > current.revision ? candidate : current;
|
||||||
|
|
||||||
|
export interface GameTransferStatusPresentation {
|
||||||
|
message: string;
|
||||||
|
level: StatusLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gameTransferStatusPresentation = (
|
||||||
|
status: GameTransferStatus | undefined,
|
||||||
|
): GameTransferStatusPresentation | undefined => {
|
||||||
|
switch (status) {
|
||||||
|
case GameTransferStatus.Verifying:
|
||||||
|
return {
|
||||||
|
message: "Verifying downloaded chunks",
|
||||||
|
level: "info",
|
||||||
|
};
|
||||||
|
case GameTransferStatus.Retrying:
|
||||||
|
return {
|
||||||
|
message: "A source sent invalid data; retrying another nearby peer",
|
||||||
|
level: "warning",
|
||||||
|
};
|
||||||
|
case GameTransferStatus.Exhausted:
|
||||||
|
return {
|
||||||
|
message: "No nearby peer could provide the verified catalog version",
|
||||||
|
level: "error",
|
||||||
|
};
|
||||||
|
case undefined:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Transient verification/retry activity replaces ordinary download copy. */
|
||||||
|
export const downloadProgressTransferLabel = (
|
||||||
|
game: Game,
|
||||||
|
): string | undefined => {
|
||||||
|
if (game.transfer_status === GameTransferStatus.Exhausted) return undefined;
|
||||||
|
return gameTransferStatusPresentation(game.transfer_status)?.message;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadProgressAriaLabel = (game: Game): string => {
|
||||||
|
const transferLabel = downloadProgressTransferLabel(game);
|
||||||
|
return transferLabel
|
||||||
|
? `${transferLabel}: ${game.name}`
|
||||||
|
: `Downloading ${game.name}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gameTransferStatusFor = (
|
||||||
|
snapshot: GameTransferStatusSnapshot,
|
||||||
|
gameId: string,
|
||||||
|
): GameTransferStatus | undefined =>
|
||||||
|
Object.prototype.hasOwnProperty.call(snapshot.statuses, gameId)
|
||||||
|
? snapshot.statuses[gameId]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
export const gameTransferOpenAttemptFor = (
|
||||||
|
snapshot: GameTransferStatusSnapshot,
|
||||||
|
gameId: string,
|
||||||
|
): string | undefined =>
|
||||||
|
Object.prototype.hasOwnProperty.call(snapshot.openAttempts, gameId)
|
||||||
|
? snapshot.openAttempts[gameId]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
export const applyDownloadProgress = (
|
||||||
|
games: Game[],
|
||||||
|
snapshot: GameTransferStatusSnapshot,
|
||||||
|
payload: DownloadProgressPayload,
|
||||||
|
): Game[] => {
|
||||||
|
const openAttempt = gameTransferOpenAttemptFor(snapshot, payload.id);
|
||||||
|
if (openAttempt === undefined || openAttempt !== payload.attemptId) {
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, ...downloadProgress } = payload;
|
||||||
|
return games.map((game) =>
|
||||||
|
game.id === id ? { ...game, download_progress: downloadProgress } : game
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyGameTransferStatusSnapshot = (
|
||||||
|
games: Game[],
|
||||||
|
snapshot: GameTransferStatusSnapshot,
|
||||||
|
): Game[] =>
|
||||||
|
games.map((game) => {
|
||||||
|
const openAttempt = gameTransferOpenAttemptFor(snapshot, game.id);
|
||||||
|
return {
|
||||||
|
...game,
|
||||||
|
transfer_status: gameTransferStatusFor(snapshot, game.id),
|
||||||
|
download_progress: openAttempt !== undefined &&
|
||||||
|
game.download_progress?.attemptId === openAttempt
|
||||||
|
? game.download_progress
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reconcile a freshly received backend snapshot. Core operation status is
|
* Reconcile a freshly received backend snapshot. Core operation status is
|
||||||
* derived only from the backend active-operation snapshot plus installed state.
|
* derived only from the backend active-operation snapshot plus installed state.
|
||||||
*/
|
*/
|
||||||
export const mergeGameUpdate = (
|
export const mergeGameUpdate = (
|
||||||
incoming: Game,
|
incoming: Game,
|
||||||
previous?: Game,
|
previous?: Game,
|
||||||
activeStatus?: InstallStatus,
|
activeStatus?: InstallStatus,
|
||||||
|
transferStatus?: GameTransferStatus,
|
||||||
): Game => {
|
): Game => {
|
||||||
const installStatus = activeStatus
|
const installStatus = activeStatus ??
|
||||||
?? (incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled);
|
(incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled);
|
||||||
|
|
||||||
const localStateChanged = previous !== undefined
|
const localStateChanged = previous !== undefined &&
|
||||||
&& (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded);
|
(previous.installed !== incoming.installed ||
|
||||||
const statusChanged = previous !== undefined
|
previous.downloaded !== incoming.downloaded);
|
||||||
&& previous.install_status !== installStatus;
|
const statusChanged = previous !== undefined &&
|
||||||
const clearStatus = localStateChanged
|
previous.install_status !== installStatus;
|
||||||
|| (statusChanged && (activeStatus !== undefined || isInProgress(previous.install_status)));
|
const clearStatus = localStateChanged ||
|
||||||
|
(statusChanged &&
|
||||||
|
(activeStatus !== undefined || isInProgress(previous.install_status)));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...incoming,
|
...incoming,
|
||||||
availability: incoming.availability,
|
availability: incoming.availability,
|
||||||
install_status: installStatus,
|
install_status: installStatus,
|
||||||
status_message: clearStatus ? undefined : previous?.status_message,
|
status_message: clearStatus ? undefined : previous?.status_message,
|
||||||
status_level: clearStatus ? undefined : previous?.status_level,
|
status_level: clearStatus ? undefined : previous?.status_level,
|
||||||
download_progress: installStatus === InstallStatus.Downloading
|
transfer_status: transferStatus,
|
||||||
? previous?.download_progress
|
download_progress: installStatus === InstallStatus.Downloading
|
||||||
: undefined,
|
? previous?.download_progress
|
||||||
peer_count: incoming.peer_count ?? 0,
|
: undefined,
|
||||||
};
|
peer_count: incoming.peer_count ?? 0,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Visual card state — used for state chip color and action button styling. */
|
/** Visual card state — used for state chip color and action button styling. */
|
||||||
export const deriveState = (game: Game): DerivedState => {
|
export const deriveState = (game: Game): DerivedState => {
|
||||||
if (game.install_status === InstallStatus.Downloading) return 'downloading';
|
if (game.install_status === InstallStatus.Downloading) return "downloading";
|
||||||
if (isInProgress(game.install_status)) return 'busy';
|
if (isInProgress(game.install_status)) return "busy";
|
||||||
if (game.installed) return 'installed';
|
if (game.installed) return "installed";
|
||||||
if (game.downloaded) return 'local';
|
if (game.downloaded) return "local";
|
||||||
return 'none';
|
return "none";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isInstalledNotShareable = (game: Game): boolean =>
|
export const isInstalledNotShareable = (game: Game): boolean =>
|
||||||
game.installed && !game.downloaded;
|
game.installed && !game.downloaded;
|
||||||
|
|
||||||
export const stateChipLabel = (game: Game): string => {
|
export const stateChipLabel = (game: Game): string => {
|
||||||
const state = deriveState(game);
|
const state = deriveState(game);
|
||||||
if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable';
|
if (state === "installed" && isInstalledNotShareable(game)) {
|
||||||
switch (state) {
|
return "Not shareable";
|
||||||
case 'installed': return 'Installed';
|
}
|
||||||
case 'local': return 'Local';
|
switch (state) {
|
||||||
case 'downloading': return 'Downloading';
|
case "installed":
|
||||||
case 'busy': return 'Working';
|
return "Installed";
|
||||||
case 'none': return '';
|
case "local":
|
||||||
}
|
return "Local";
|
||||||
|
case "downloading":
|
||||||
|
return "Downloading";
|
||||||
|
case "busy":
|
||||||
|
return "Working";
|
||||||
|
case "none":
|
||||||
|
return "";
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const gameStatusLabel = (game: Game): string => {
|
export const gameStatusLabel = (game: Game): string => {
|
||||||
const state = deriveState(game);
|
const state = deriveState(game);
|
||||||
if (state === 'installed' && isInstalledNotShareable(game)) {
|
if (state === "installed" && isInstalledNotShareable(game)) {
|
||||||
return 'Installed, not shareable';
|
return "Installed, not shareable";
|
||||||
}
|
}
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'installed': return 'Installed';
|
case "installed":
|
||||||
case 'local': return 'Downloaded';
|
return "Installed";
|
||||||
case 'downloading': return 'Downloading';
|
case "local":
|
||||||
case 'busy': return 'Working…';
|
return "Downloaded";
|
||||||
case 'none': return 'Not downloaded';
|
case "downloading":
|
||||||
}
|
return "Downloading";
|
||||||
|
case "busy":
|
||||||
|
return "Working…";
|
||||||
|
case "none":
|
||||||
|
return "Not downloaded";
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isUnavailable = (game: Game): boolean =>
|
export const isUnavailable = (game: Game): boolean =>
|
||||||
!game.installed
|
!game.installed &&
|
||||||
&& !game.downloaded
|
!game.downloaded &&
|
||||||
&& game.peer_count === 0
|
game.peer_count === 0 &&
|
||||||
&& game.install_status === InstallStatus.NotInstalled;
|
game.install_status === InstallStatus.NotInstalled;
|
||||||
|
|
||||||
const parseVersionStamp = (version: string | undefined): number | null => {
|
const parseVersionStamp = (version: string | undefined): number | null => {
|
||||||
if (!version || !/^\d{8}$/.test(version)) return null;
|
if (!version || !/^\d{8}$/.test(version)) return null;
|
||||||
const parsed = parseInt(version, 10);
|
const parsed = parseInt(version, 10);
|
||||||
return Number.isNaN(parsed) ? null : parsed;
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const compareVersionStamps = (
|
export const compareVersionStamps = (
|
||||||
left: string | undefined,
|
left: string | undefined,
|
||||||
right: string | undefined,
|
right: string | undefined,
|
||||||
): number | null => {
|
): number | null => {
|
||||||
const parsedLeft = parseVersionStamp(left);
|
const parsedLeft = parseVersionStamp(left);
|
||||||
const parsedRight = parseVersionStamp(right);
|
const parsedRight = parseVersionStamp(right);
|
||||||
if (parsedLeft === null || parsedRight === null) return null;
|
if (parsedLeft === null || parsedRight === null) return null;
|
||||||
return parsedLeft - parsedRight;
|
return parsedLeft - parsedRight;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hasNewerLocalVersion = (game: Game): boolean =>
|
export const hasNewerLocalVersion = (game: Game): boolean =>
|
||||||
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
|
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
|
||||||
|
|
||||||
export const needsUpdate = (game: Game): boolean => {
|
export const needsUpdate = (game: Game): boolean => {
|
||||||
if (!game.installed) return false;
|
if (!game.installed) return false;
|
||||||
if (game.peer_count <= 0) return false;
|
if (game.peer_count <= 0) return false;
|
||||||
if (!game.local_version && game.eti_game_version) return true;
|
if (!game.local_version && game.eti_game_version) return true;
|
||||||
return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0;
|
return (compareVersionStamps(game.eti_game_version, game.local_version) ??
|
||||||
|
0) > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const canStreamInstall = (game: Game): boolean =>
|
export const canStreamInstall = (
|
||||||
!game.downloaded
|
game: Game,
|
||||||
&& !game.installed
|
catalogSupported: boolean,
|
||||||
&& game.peer_count > 0
|
): boolean =>
|
||||||
&& !isInProgress(game.install_status);
|
catalogSupported &&
|
||||||
|
!game.downloaded &&
|
||||||
|
!game.installed &&
|
||||||
|
game.peer_count > 0 &&
|
||||||
|
!isInProgress(game.install_status);
|
||||||
|
|
||||||
/** What pressing the card's main action button should do, given the state. */
|
/** What pressing the card's main action button should do, given the state. */
|
||||||
export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled';
|
export type PrimaryAction =
|
||||||
|
| "play"
|
||||||
|
| "install"
|
||||||
|
| "update"
|
||||||
|
| "download"
|
||||||
|
| "busy"
|
||||||
|
| "disabled";
|
||||||
|
|
||||||
export const primaryActionFor = (game: Game): PrimaryAction => {
|
export const primaryActionFor = (game: Game): PrimaryAction => {
|
||||||
if (isInProgress(game.install_status)) return 'busy';
|
if (isInProgress(game.install_status)) return "busy";
|
||||||
if (isUnavailable(game)) return 'disabled';
|
if (isUnavailable(game)) return "disabled";
|
||||||
if (!game.installed) return game.downloaded ? 'install' : 'download';
|
if (!game.installed) return game.downloaded ? "install" : "download";
|
||||||
if (needsUpdate(game)) return 'update';
|
if (needsUpdate(game)) return "update";
|
||||||
return 'play';
|
return "play";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatBytesPerSecond = (bytesPerSecond: number): string => {
|
export const formatBytesPerSecond = (bytesPerSecond: number): string => {
|
||||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
|
||||||
let value = Math.max(0, bytesPerSecond);
|
let value = Math.max(0, bytesPerSecond);
|
||||||
let unitIndex = 0;
|
let unitIndex = 0;
|
||||||
|
|
||||||
while (value >= 1000 && unitIndex < units.length - 1) {
|
while (value >= 1000 && unitIndex < units.length - 1) {
|
||||||
value /= 1000;
|
value /= 1000;
|
||||||
unitIndex += 1;
|
unitIndex += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`;
|
if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`;
|
||||||
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
||||||
return `${value.toFixed(precision)} ${units[unitIndex]}`;
|
return `${value.toFixed(precision)} ${units[unitIndex]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
@@ -180,137 +317,145 @@ const GB = 1024 * 1024 * 1024;
|
|||||||
const DECIMAL_MB = 1_000_000;
|
const DECIMAL_MB = 1_000_000;
|
||||||
|
|
||||||
const stripTrailingDecimalZeros = (value: string): string =>
|
const stripTrailingDecimalZeros = (value: string): string =>
|
||||||
value.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
|
value.replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
|
||||||
|
|
||||||
export const downloadProgressPercent = (game: Game): number => {
|
export const downloadProgressPercent = (game: Game): number => {
|
||||||
const progress = game.download_progress;
|
const progress = game.download_progress;
|
||||||
if (!progress || progress.total_bytes <= 0) return 0;
|
if (!progress || progress.total_bytes <= 0) return 0;
|
||||||
|
|
||||||
return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100));
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDownloadSpeed = (bytesPerSecond: number): string => {
|
export const formatDownloadSpeed = (bytesPerSecond: number): string => {
|
||||||
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
||||||
return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`;
|
return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDownloadSpeedShort = (bytesPerSecond: number): string => {
|
export const formatDownloadSpeedShort = (bytesPerSecond: number): string => {
|
||||||
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
||||||
return `${Math.round(mb)} MB/s`;
|
return `${Math.round(mb)} MB/s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDownloadBytes = (bytes: number): string => {
|
export const formatDownloadBytes = (bytes: number): string => {
|
||||||
const safeBytes = Math.max(0, bytes);
|
const safeBytes = Math.max(0, bytes);
|
||||||
if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`;
|
if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`;
|
||||||
|
|
||||||
const gb = safeBytes / GB;
|
const gb = safeBytes / GB;
|
||||||
return `${stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))} GB`;
|
return `${
|
||||||
|
stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))
|
||||||
|
} GB`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDownloadEta = (seconds: number): string => {
|
export const formatDownloadEta = (seconds: number): string => {
|
||||||
if (!Number.isFinite(seconds) || seconds <= 0) return '—';
|
if (!Number.isFinite(seconds) || seconds <= 0) return "—";
|
||||||
if (seconds < 60) return `${Math.round(seconds)} s`;
|
if (seconds < 60) return `${Math.round(seconds)} s`;
|
||||||
|
|
||||||
const minutes = Math.round(seconds / 60);
|
const minutes = Math.round(seconds / 60);
|
||||||
if (minutes < 60) return `${minutes} min`;
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
|
||||||
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
|
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const inProgressLabel = (game: Game): string | undefined => {
|
export const inProgressLabel = (game: Game): string | undefined => {
|
||||||
switch (game.install_status) {
|
switch (game.install_status) {
|
||||||
case InstallStatus.CheckingPeers:
|
case InstallStatus.Downloading:
|
||||||
return 'Checking peers…';
|
return game.download_progress
|
||||||
case InstallStatus.Downloading:
|
? `Downloading… ${
|
||||||
return game.download_progress
|
formatBytesPerSecond(game.download_progress.bytes_per_second)
|
||||||
? `Downloading… ${formatBytesPerSecond(game.download_progress.bytes_per_second)}`
|
}`
|
||||||
: 'Downloading…';
|
: "Downloading…";
|
||||||
case InstallStatus.Installing:
|
case InstallStatus.Installing:
|
||||||
return 'Installing…';
|
return "Installing…";
|
||||||
case InstallStatus.Uninstalling:
|
case InstallStatus.Uninstalling:
|
||||||
return 'Uninstalling…';
|
return "Uninstalling…";
|
||||||
case InstallStatus.Removing:
|
case InstallStatus.Removing:
|
||||||
return 'Removing…';
|
return "Removing…";
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actionLabel = (game: Game): string => {
|
export const actionLabel = (game: Game): string => {
|
||||||
const busy = inProgressLabel(game);
|
const busy = inProgressLabel(game);
|
||||||
if (busy) return busy;
|
if (busy) return busy;
|
||||||
if (isUnavailable(game)) return 'Unavailable';
|
if (isUnavailable(game)) return "Unavailable";
|
||||||
if (!game.installed) return game.downloaded ? 'Install' : 'Download';
|
if (!game.installed) return game.downloaded ? "Install" : "Download";
|
||||||
if (needsUpdate(game)) return 'Update';
|
if (needsUpdate(game)) return "Update";
|
||||||
return 'Play';
|
return "Play";
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Counts shown on filter pills. */
|
/** Counts shown on filter pills. */
|
||||||
export interface FilterCounts {
|
export interface FilterCounts {
|
||||||
all: number;
|
all: number;
|
||||||
local: number;
|
local: number;
|
||||||
installed: number;
|
installed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDownloading = (game: Game): boolean =>
|
const isDownloading = (game: Game): boolean =>
|
||||||
game.install_status === InstallStatus.Downloading;
|
game.install_status === InstallStatus.Downloading;
|
||||||
|
|
||||||
const isNetworkGame = (game: Game): boolean =>
|
const isNetworkGame = (game: Game): boolean =>
|
||||||
game.installed || game.downloaded || isDownloading(game) || game.peer_count > 0;
|
game.installed || game.downloaded || isDownloading(game) ||
|
||||||
|
game.peer_count > 0 || game.transfer_status === GameTransferStatus.Exhausted;
|
||||||
|
|
||||||
export const countByFilter = (games: Game[]): FilterCounts => ({
|
export const countByFilter = (games: Game[]): FilterCounts => ({
|
||||||
all: games.filter(isNetworkGame).length,
|
all: games.filter(isNetworkGame).length,
|
||||||
local: games.filter(g => g.installed || g.downloaded || isDownloading(g)).length,
|
local:
|
||||||
installed: games.filter(g => g.installed).length,
|
games.filter((g) => g.installed || g.downloaded || isDownloading(g)).length,
|
||||||
|
installed: games.filter((g) => g.installed).length,
|
||||||
});
|
});
|
||||||
|
|
||||||
const matchesFilter = (game: Game, filter: GameFilter): boolean => {
|
const matchesFilter = (game: Game, filter: GameFilter): boolean => {
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case 'local':
|
case "local":
|
||||||
return game.installed || game.downloaded || isDownloading(game);
|
return game.installed || game.downloaded || isDownloading(game);
|
||||||
case 'installed':
|
case "installed":
|
||||||
return game.installed;
|
return game.installed;
|
||||||
case 'all':
|
case "all":
|
||||||
return isNetworkGame(game);
|
return isNetworkGame(game);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATE_SORT_ORDER: Record<DerivedState, number> = {
|
const STATE_SORT_ORDER: Record<DerivedState, number> = {
|
||||||
installed: 0,
|
installed: 0,
|
||||||
local: 1,
|
local: 1,
|
||||||
downloading: 2,
|
downloading: 2,
|
||||||
busy: 3,
|
busy: 3,
|
||||||
none: 4,
|
none: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
const compareByState = (a: Game, b: Game): number => {
|
const compareByState = (a: Game, b: Game): number => {
|
||||||
const diff = STATE_SORT_ORDER[deriveState(a)] - STATE_SORT_ORDER[deriveState(b)];
|
const diff = STATE_SORT_ORDER[deriveState(a)] -
|
||||||
return diff !== 0 ? diff : a.name.localeCompare(b.name);
|
STATE_SORT_ORDER[deriveState(b)];
|
||||||
|
return diff !== 0 ? diff : a.name.localeCompare(b.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const applyFilterAndSort = (
|
export const applyFilterAndSort = (
|
||||||
games: Game[],
|
games: Game[],
|
||||||
filter: GameFilter,
|
filter: GameFilter,
|
||||||
sort: GameSort,
|
sort: GameSort,
|
||||||
query: string,
|
query: string,
|
||||||
): Game[] => {
|
): Game[] => {
|
||||||
let list = games.filter(g => matchesFilter(g, filter));
|
let list = games.filter((g) => matchesFilter(g, filter));
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (q) {
|
if (q) {
|
||||||
list = list.filter(g =>
|
list = list.filter((g) =>
|
||||||
g.name.toLowerCase().includes(q)
|
g.name.toLowerCase().includes(q) ||
|
||||||
|| (g.genre?.toLowerCase().includes(q) ?? false)
|
(g.genre?.toLowerCase().includes(q) ?? false) ||
|
||||||
|| (g.publisher?.toLowerCase().includes(q) ?? false),
|
(g.publisher?.toLowerCase().includes(q) ?? false)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
case 'az':
|
case "az":
|
||||||
return [...list].sort((a, b) => a.name.localeCompare(b.name));
|
return [...list].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
case 'sizeDesc':
|
case "sizeDesc":
|
||||||
return [...list].sort((a, b) => b.size - a.size);
|
return [...list].sort((a, b) => b.size - a.size);
|
||||||
case 'sizeAsc':
|
case "sizeAsc":
|
||||||
return [...list].sort((a, b) => a.size - b.size);
|
return [...list].sort((a, b) => a.size - b.size);
|
||||||
case 'status':
|
case "status":
|
||||||
return [...list].sort(compareByState);
|
return [...list].sort(compareByState);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type IdentityDiagnostic = 'ephemeral';
|
||||||
|
|
||||||
|
export interface IdentityDiagnosticSnapshot {
|
||||||
|
revision: number;
|
||||||
|
diagnostic: IdentityDiagnostic | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT: IdentityDiagnosticSnapshot = {
|
||||||
|
revision: 0,
|
||||||
|
diagnostic: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EPHEMERAL_IDENTITY_NOTICE =
|
||||||
|
"This installation's network identity could not be saved and will change the next time Lanspread starts.";
|
||||||
|
|
||||||
|
export const IDENTITY_STATUS_UNAVAILABLE_NOTICE =
|
||||||
|
"This installation's network identity status could not be checked.";
|
||||||
|
|
||||||
|
export const newestIdentityDiagnosticSnapshot = (
|
||||||
|
current: IdentityDiagnosticSnapshot,
|
||||||
|
candidate: IdentityDiagnosticSnapshot,
|
||||||
|
): IdentityDiagnosticSnapshot => candidate.revision > current.revision ? candidate : current;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import {
|
||||||
|
AsyncOwner,
|
||||||
|
type AsyncAdoptionScope,
|
||||||
|
type AsyncRegistration,
|
||||||
|
windowAsyncScope,
|
||||||
|
} from './asyncOwnership';
|
||||||
|
|
||||||
|
export type LocalNetworkSharingPhase =
|
||||||
|
| 'waitingForGameDirectory'
|
||||||
|
| 'disabled'
|
||||||
|
| 'enabling'
|
||||||
|
| 'enabled'
|
||||||
|
| 'disabling';
|
||||||
|
|
||||||
|
export type SharingPersistenceProblem = 'load' | 'save';
|
||||||
|
|
||||||
|
export interface LocalNetworkSharingSnapshot {
|
||||||
|
revision: number;
|
||||||
|
enabled: boolean;
|
||||||
|
pendingTarget: boolean | null;
|
||||||
|
phase: LocalNetworkSharingPhase;
|
||||||
|
persistenceProblem: SharingPersistenceProblem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT: LocalNetworkSharingSnapshot = {
|
||||||
|
revision: 0,
|
||||||
|
enabled: false,
|
||||||
|
pendingTarget: null,
|
||||||
|
phase: 'disabled',
|
||||||
|
persistenceProblem: 'load',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const newestLocalNetworkSharingSnapshot = (
|
||||||
|
current: LocalNetworkSharingSnapshot,
|
||||||
|
candidate: LocalNetworkSharingSnapshot,
|
||||||
|
): LocalNetworkSharingSnapshot => candidate.revision > current.revision ? candidate : current;
|
||||||
|
|
||||||
|
export const displayedSharingTarget = (snapshot: LocalNetworkSharingSnapshot): boolean =>
|
||||||
|
snapshot.pendingTarget ?? snapshot.enabled;
|
||||||
|
|
||||||
|
export const isLocalNetworkSharingActive = (snapshot: LocalNetworkSharingSnapshot): boolean =>
|
||||||
|
snapshot.phase === 'enabled' && snapshot.pendingTarget !== false;
|
||||||
|
|
||||||
|
export const localNetworkSharingNotice = (
|
||||||
|
snapshot: LocalNetworkSharingSnapshot,
|
||||||
|
): string => {
|
||||||
|
if (snapshot.persistenceProblem === 'load') {
|
||||||
|
return 'Local network sharing is off because its setting could not be loaded.';
|
||||||
|
}
|
||||||
|
if (snapshot.persistenceProblem === 'save') {
|
||||||
|
if (!snapshot.enabled && snapshot.phase === 'disabled') {
|
||||||
|
return 'Local network sharing is off, but this setting could not be saved.';
|
||||||
|
}
|
||||||
|
return 'This Local network sharing setting could not be saved and may change after restart.';
|
||||||
|
}
|
||||||
|
if (snapshot.pendingTarget === false && snapshot.phase !== 'disabled') {
|
||||||
|
return 'Stopping Local network sharing…';
|
||||||
|
}
|
||||||
|
if (snapshot.pendingTarget === true && snapshot.phase !== 'enabled') {
|
||||||
|
return 'Starting Local network sharing…';
|
||||||
|
}
|
||||||
|
switch (snapshot.phase) {
|
||||||
|
case 'waitingForGameDirectory':
|
||||||
|
return 'Local network sharing will start after you choose a game folder.';
|
||||||
|
case 'disabled':
|
||||||
|
return 'Local network sharing is off. Nearby devices cannot browse or request games from this library.';
|
||||||
|
case 'enabling':
|
||||||
|
return 'Starting Local network sharing…';
|
||||||
|
case 'enabled':
|
||||||
|
return 'Local network sharing is on.';
|
||||||
|
case 'disabling':
|
||||||
|
return 'Stopping Local network sharing…';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns one listener/query lifecycle and serializes explicitly targeted sharing
|
||||||
|
* mutations. Disposal closes admission and drains already-admitted invokes;
|
||||||
|
* result publication remains guarded by the mounted owner.
|
||||||
|
*/
|
||||||
|
export class LocalNetworkSharingAsyncScope {
|
||||||
|
private readonly owner: AsyncOwner;
|
||||||
|
private mutationTail: Promise<void> = Promise.resolve();
|
||||||
|
private mutationAdmissionOpen = true;
|
||||||
|
private readonly releaseWindowOwnership: () => void;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
reportCleanupFailure: (error: unknown) => void = () => {},
|
||||||
|
adoptionScope: AsyncAdoptionScope = windowAsyncScope,
|
||||||
|
) {
|
||||||
|
this.owner = new AsyncOwner(reportCleanupFailure, adoptionScope);
|
||||||
|
this.releaseWindowOwnership = adoptionScope.registerDisposer(
|
||||||
|
() => this.disposeForWindowClose(),
|
||||||
|
) ?? (() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public isActive(): boolean {
|
||||||
|
return this.owner.isActive() && this.mutationAdmissionOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
public guard<Args extends unknown[]>(
|
||||||
|
callback: (...args: Args) => void,
|
||||||
|
): (...args: Args) => void {
|
||||||
|
return this.owner.guard(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerListener(registration: AsyncRegistration): Promise<boolean> {
|
||||||
|
return this.owner.register(registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyIfActive<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
apply: (value: T) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.owner.applyIfActive(operation, apply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyMutationIfActive<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
apply: (value: T) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isActive()) return Promise.resolve(false);
|
||||||
|
|
||||||
|
const mutation = this.mutationTail.then(async () => {
|
||||||
|
const value = await operation();
|
||||||
|
if (!this.owner.isActive()) return false;
|
||||||
|
apply(value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
this.mutationTail = mutation.then(
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
return mutation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): Promise<void> {
|
||||||
|
this.releaseWindowOwnership();
|
||||||
|
this.mutationAdmissionOpen = false;
|
||||||
|
return Promise.all([this.mutationTail, this.owner.dispose()]).then(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private disposeForWindowClose(): Promise<void> {
|
||||||
|
this.releaseWindowOwnership();
|
||||||
|
this.mutationAdmissionOpen = false;
|
||||||
|
return Promise.all([
|
||||||
|
this.mutationTail,
|
||||||
|
this.owner.disposeForWindowClose(),
|
||||||
|
]).then(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { type ProtocolMismatch } from "./types";
|
||||||
|
|
||||||
|
export interface ProtocolMismatchSnapshot {
|
||||||
|
revision: number;
|
||||||
|
mismatch: ProtocolMismatch | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INITIAL_PROTOCOL_MISMATCH_SNAPSHOT: ProtocolMismatchSnapshot = {
|
||||||
|
revision: 0,
|
||||||
|
mismatch: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keeps listener events and bootstrap queries monotonic across async races. */
|
||||||
|
export const newestProtocolMismatchSnapshot = (
|
||||||
|
current: ProtocolMismatchSnapshot,
|
||||||
|
candidate: ProtocolMismatchSnapshot,
|
||||||
|
): ProtocolMismatchSnapshot =>
|
||||||
|
candidate.revision > current.revision ? candidate : current;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { type AsyncAdoptionScope } from './asyncOwnership';
|
||||||
|
|
||||||
|
export type ThumbnailLoader = (id: string) => Promise<string>;
|
||||||
|
export type ThumbnailPublisher = (id: string, url: string) => void;
|
||||||
|
|
||||||
|
export const createThumbnailRequestKey = (ids: readonly string[]): string =>
|
||||||
|
JSON.stringify([...new Set(ids)].sort());
|
||||||
|
|
||||||
|
export const thumbnailIdsFromRequestKey = (key: string): string[] =>
|
||||||
|
JSON.parse(key) as string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns one effect generation of thumbnail requests. Loading can only begin via
|
||||||
|
* `start`, and disposing the generation prevents both later starts and late
|
||||||
|
* publications from requests that are already in flight.
|
||||||
|
*/
|
||||||
|
export class ThumbnailRequestGeneration {
|
||||||
|
private disposed = false;
|
||||||
|
private readonly pending = new Set<Promise<void>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly load: ThumbnailLoader,
|
||||||
|
private readonly publish: ThumbnailPublisher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
start(ids: readonly string[]): Promise<void> {
|
||||||
|
if (this.disposed) return Promise.resolve();
|
||||||
|
|
||||||
|
const loading = Promise.all(ids.map(id => this.loadOne(id))).then(() => {});
|
||||||
|
this.pending.add(loading);
|
||||||
|
void loading.then(
|
||||||
|
() => this.pending.delete(loading),
|
||||||
|
() => this.pending.delete(loading),
|
||||||
|
);
|
||||||
|
return loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): Promise<void> {
|
||||||
|
this.disposed = true;
|
||||||
|
return this.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drain(): Promise<void> {
|
||||||
|
while (this.pending.size > 0) {
|
||||||
|
await Promise.allSettled([...this.pending]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadOne(id: string): Promise<void> {
|
||||||
|
let url: string;
|
||||||
|
try {
|
||||||
|
url = await this.load(id);
|
||||||
|
} catch {
|
||||||
|
url = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.disposed) this.publish(id, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers the generation with window-close admission before any load starts. */
|
||||||
|
export const startOwnedThumbnailGeneration = (
|
||||||
|
scope: AsyncAdoptionScope,
|
||||||
|
generation: ThumbnailRequestGeneration,
|
||||||
|
ids: readonly string[],
|
||||||
|
): (() => void) => {
|
||||||
|
const releaseWindowOwnership = scope.registerDisposer(() => generation.dispose());
|
||||||
|
if (releaseWindowOwnership === undefined) {
|
||||||
|
scope.adopt(generation.dispose());
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.adopt(generation.start(ids));
|
||||||
|
return () => {
|
||||||
|
releaseWindowOwnership();
|
||||||
|
scope.adopt(generation.dispose());
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,137 +1,183 @@
|
|||||||
export enum InstallStatus {
|
export enum InstallStatus {
|
||||||
NotInstalled = 'NotInstalled',
|
NotInstalled = "NotInstalled",
|
||||||
CheckingPeers = 'CheckingPeers',
|
Downloading = "Downloading",
|
||||||
Downloading = 'Downloading',
|
Installing = "Installing",
|
||||||
Installing = 'Installing',
|
Uninstalling = "Uninstalling",
|
||||||
Uninstalling = 'Uninstalling',
|
Removing = "Removing",
|
||||||
Removing = 'Removing',
|
Installed = "Installed",
|
||||||
Installed = 'Installed',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum GameAvailability {
|
export enum GameAvailability {
|
||||||
Ready = 'Ready',
|
Ready = "Ready",
|
||||||
LocalOnly = 'LocalOnly',
|
LocalOnly = "LocalOnly",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ActiveOperationKind {
|
export enum ActiveOperationKind {
|
||||||
Downloading = 'Downloading',
|
Downloading = "Downloading",
|
||||||
Installing = 'Installing',
|
Installing = "Installing",
|
||||||
Updating = 'Updating',
|
Updating = "Updating",
|
||||||
Uninstalling = 'Uninstalling',
|
Uninstalling = "Uninstalling",
|
||||||
RemovingDownload = 'RemovingDownload',
|
RemovingDownload = "RemovingDownload",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StatusLevel = 'info' | 'warning' | 'error';
|
export type StatusLevel = "info" | "warning" | "error";
|
||||||
|
|
||||||
|
export enum GameTransferStatus {
|
||||||
|
Verifying = "verifying",
|
||||||
|
Retrying = "retrying",
|
||||||
|
Exhausted = "exhausted",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameTransferStatusSnapshot {
|
||||||
|
revision: number;
|
||||||
|
statuses: Record<string, GameTransferStatus>;
|
||||||
|
openAttempts: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadProgress {
|
export interface DownloadProgress {
|
||||||
downloaded_bytes: number;
|
attemptId: string;
|
||||||
total_bytes: number;
|
downloaded_bytes: number;
|
||||||
bytes_per_second: number;
|
total_bytes: number;
|
||||||
active_peer_count: number;
|
bytes_per_second: number;
|
||||||
|
active_peer_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadProgressPayload extends DownloadProgress {
|
export interface DownloadProgressPayload extends DownloadProgress {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Game {
|
export interface Game {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
/** Bytes. */
|
/** Bytes. */
|
||||||
size: number;
|
size: number;
|
||||||
/** Raw bytes — unused in UI, kept for parity with backend payload. */
|
/** Raw bytes — unused in UI, kept for parity with backend payload. */
|
||||||
thumbnail?: Uint8Array | number[];
|
thumbnail?: Uint8Array | number[];
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
availability: GameAvailability;
|
availability: GameAvailability;
|
||||||
install_status: InstallStatus;
|
install_status: InstallStatus;
|
||||||
eti_game_version?: string;
|
eti_game_version?: string;
|
||||||
local_version?: string;
|
local_version?: string;
|
||||||
/** Optional richer metadata surfaced by the backend. */
|
/** Optional richer metadata surfaced by the backend. */
|
||||||
release_year?: string;
|
release_year?: string;
|
||||||
publisher?: string;
|
publisher?: string;
|
||||||
max_players?: number;
|
max_players?: number;
|
||||||
version?: string;
|
version?: string;
|
||||||
genre?: string;
|
genre?: string;
|
||||||
status_message?: string;
|
status_message?: string;
|
||||||
status_level?: StatusLevel;
|
status_level?: StatusLevel;
|
||||||
download_progress?: DownloadProgress;
|
transfer_status?: GameTransferStatus;
|
||||||
peer_count: number;
|
download_progress?: DownloadProgress;
|
||||||
can_host_server?: boolean;
|
peer_count: number;
|
||||||
active_outbound_transfers?: number;
|
can_host_server?: boolean;
|
||||||
installed_peer_count?: number;
|
active_outbound_transfers?: number;
|
||||||
|
installed_peer_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveOperation {
|
export interface ActiveOperation {
|
||||||
id: string;
|
id: string;
|
||||||
operation: ActiveOperationKind;
|
operation: ActiveOperationKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GamesListPayload {
|
export interface GamesListPayload {
|
||||||
games: Game[];
|
games: Game[];
|
||||||
active_operations?: ActiveOperation[];
|
active_operations?: ActiveOperation[];
|
||||||
|
transfer_status: GameTransferStatusSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtocolMismatch {
|
||||||
|
observed: number | null;
|
||||||
|
expected: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Library filter chip — what subset of the catalog to show. */
|
/** Library filter chip — what subset of the catalog to show. */
|
||||||
export type GameFilter = 'all' | 'local' | 'installed';
|
export type GameFilter = "all" | "local" | "installed";
|
||||||
|
|
||||||
/** Library sort order. */
|
/** Library sort order. */
|
||||||
export type GameSort = 'az' | 'sizeDesc' | 'sizeAsc' | 'status';
|
export type GameSort = "az" | "sizeDesc" | "sizeAsc" | "status";
|
||||||
|
|
||||||
/** Visual state of a card. Derived from backend operation status and local flags. */
|
/** Visual state of a card. Derived from backend operation status and local flags. */
|
||||||
export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'busy';
|
export type DerivedState =
|
||||||
|
| "installed"
|
||||||
|
| "local"
|
||||||
|
| "downloading"
|
||||||
|
| "none"
|
||||||
|
| "busy";
|
||||||
|
|
||||||
/** Two-character language code passed through to game scripts. */
|
/** Two-character language code passed through to game scripts. */
|
||||||
export type LauncherLanguage = 'en' | 'de';
|
export type LauncherLanguage = "en" | "de";
|
||||||
|
|
||||||
export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending';
|
export type CallToPlayParticipantStatus = "ready" | "in" | "pending";
|
||||||
|
|
||||||
export interface CallToPlayParticipant {
|
export interface CallToPlayParticipant {
|
||||||
name: string;
|
name: string;
|
||||||
status: CallToPlayParticipantStatus;
|
status: CallToPlayParticipantStatus;
|
||||||
joinedAt: number;
|
joinedAt: number;
|
||||||
readyAt?: number;
|
readyAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CallToPlayMessage {
|
export interface CallToPlayMessage {
|
||||||
id: string;
|
id: string;
|
||||||
fromId: string;
|
fromId: string;
|
||||||
from: string;
|
from: string;
|
||||||
text: string;
|
text: string;
|
||||||
at: number;
|
at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Nomination {
|
export interface Nomination {
|
||||||
id: string;
|
id: string;
|
||||||
gameId: string;
|
gameId: string;
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
creator: string;
|
creator: string;
|
||||||
maxPlayers: number;
|
maxPlayers: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
scheduledFor: number | null;
|
scheduledFor: number | null;
|
||||||
deadline: number;
|
deadline: number;
|
||||||
participants: Record<string, CallToPlayParticipant>;
|
participants: Record<string, CallToPlayParticipant>;
|
||||||
messages: CallToPlayMessage[];
|
messages: CallToPlayMessage[];
|
||||||
state: 'open' | 'done' | 'running' | 'cancelled';
|
state: "open" | "done" | "running" | "cancelled";
|
||||||
terminalAt: number | null;
|
terminalAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CallToPlayAction =
|
export type CallToPlayAction =
|
||||||
| { Create: { game_id: string; max_players: number; scheduled_for: number | null; deadline: number } }
|
| {
|
||||||
| { Respond: { ready_at: number | null } }
|
Create: {
|
||||||
| 'Rsvp'
|
game_id: string;
|
||||||
| { SendMessage: { message_id: string; text: string } }
|
max_players: number;
|
||||||
| 'Leave'
|
scheduled_for: number | null;
|
||||||
| 'Cancel'
|
deadline: number;
|
||||||
| 'Start'
|
};
|
||||||
| { AddTime: { deadline: number } };
|
}
|
||||||
|
| { Respond: { ready_at: number | null } }
|
||||||
|
| "Rsvp"
|
||||||
|
| { SendMessage: { text: string } }
|
||||||
|
| "Leave"
|
||||||
|
| "Cancel"
|
||||||
|
| "Start"
|
||||||
|
| { AddTime: { deadline: number } };
|
||||||
|
|
||||||
export interface CallToPlayEvent {
|
export interface CallToPlayViewEvent {
|
||||||
id: string;
|
id: string;
|
||||||
call_id: string;
|
call_id: string;
|
||||||
actor_id: string;
|
author_id: string;
|
||||||
actor_name: string;
|
author_name: string;
|
||||||
at: number;
|
at: number;
|
||||||
action: CallToPlayAction;
|
action: CallToPlayAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallToPlayView {
|
||||||
|
events: CallToPlayViewEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallToPlayLocalIntent {
|
||||||
|
call_id: string | null;
|
||||||
|
action: CallToPlayAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallToPlayReceipt {
|
||||||
|
call_id: string;
|
||||||
|
event_id: string;
|
||||||
|
revision: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,32 @@
|
|||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { windowAsyncScope } from "./lib/asyncOwnership";
|
||||||
|
import { bootstrapFrontend } from "./lib/frontendBootstrap";
|
||||||
|
import { windowPersistenceScope } from "./lib/frontendPersistence";
|
||||||
import "./styles/tokens.css";
|
import "./styles/tokens.css";
|
||||||
import "./styles/launcher.css";
|
import "./styles/launcher.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
const render = () => {
|
||||||
<App />
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
);
|
<App />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentWindow = getCurrentWindow();
|
||||||
|
|
||||||
|
// This is the JavaScript realm's terminal task. bootstrapFrontend handles every
|
||||||
|
// failure path and does not mount the app unless close ownership is installed.
|
||||||
|
void bootstrapFrontend({
|
||||||
|
registerCloseRequested: handler => currentWindow.onCloseRequested(handler),
|
||||||
|
disposeOwners: () => windowAsyncScope.disposeOwned(),
|
||||||
|
drainPersistence: () => windowPersistenceScope.closeAndDrain(),
|
||||||
|
destroyWindow: () => currentWindow.destroy(),
|
||||||
|
render,
|
||||||
|
reportFailure: (message, error) => console.error(message, error),
|
||||||
|
}).catch(error => {
|
||||||
|
// The helper handles expected bootstrap and teardown failures. Keep this
|
||||||
|
// terminal observer for programming errors at the JavaScript realm root.
|
||||||
|
console.error('Unexpected frontend bootstrap failure:', error);
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,24 +19,24 @@
|
|||||||
.bg-gradient {
|
.bg-gradient {
|
||||||
background:
|
background:
|
||||||
radial-gradient(
|
radial-gradient(
|
||||||
ellipse 80% 50% at 50% -10%,
|
ellipse 80% 50% at 50% -10%,
|
||||||
color-mix(in srgb, var(--accent) 22%, transparent) 0%,
|
color-mix(in srgb, var(--accent) 22%, transparent) 0%,
|
||||||
transparent 60%
|
transparent 60%
|
||||||
),
|
),
|
||||||
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
|
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
|
||||||
}
|
}
|
||||||
.bg-animated {
|
.bg-animated {
|
||||||
background:
|
background:
|
||||||
radial-gradient(
|
radial-gradient(
|
||||||
ellipse 60% 40% at 20% 0%,
|
ellipse 60% 40% at 20% 0%,
|
||||||
color-mix(in srgb, var(--accent) 24%, transparent) 0%,
|
color-mix(in srgb, var(--accent) 24%, transparent) 0%,
|
||||||
transparent 55%
|
transparent 55%
|
||||||
),
|
),
|
||||||
radial-gradient(
|
radial-gradient(
|
||||||
ellipse 55% 40% at 85% 8%,
|
ellipse 55% 40% at 85% 8%,
|
||||||
color-mix(in srgb, var(--accent) 16%, transparent) 0%,
|
color-mix(in srgb, var(--accent) 16%, transparent) 0%,
|
||||||
transparent 55%
|
transparent 55%
|
||||||
),
|
),
|
||||||
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
|
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
|
||||||
background-size: 145% 130%, 140% 125%, 100% 100%;
|
background-size: 145% 130%, 140% 125%, 100% 100%;
|
||||||
animation: bgshift 22s ease-in-out infinite alternate;
|
animation: bgshift 22s ease-in-out infinite alternate;
|
||||||
@@ -474,6 +474,28 @@
|
|||||||
padding: 4px 4px 16px;
|
padding: 4px 4px 16px;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
.network-notice {
|
||||||
|
margin: 0 4px 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--warn) 45%, var(--bd-1));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--warn) 12%, var(--bg-2));
|
||||||
|
color: var(--t-1);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.network-notice-sharing.is-enabled {
|
||||||
|
border-color: color-mix(in srgb, var(--ok) 40%, var(--bd-1));
|
||||||
|
background: color-mix(in srgb, var(--ok) 9%, var(--bg-2));
|
||||||
|
}
|
||||||
|
.network-notice-sharing.is-disabled,
|
||||||
|
.network-notice.is-error {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 42%, var(--bd-1));
|
||||||
|
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
|
||||||
|
}
|
||||||
|
.identity-notice {
|
||||||
|
border-color: color-mix(in srgb, var(--warn) 45%, var(--bd-1));
|
||||||
|
}
|
||||||
.results-count {
|
.results-count {
|
||||||
color: var(--t-2);
|
color: var(--t-2);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
@@ -582,15 +604,15 @@
|
|||||||
.cover-grain {
|
.cover-grain {
|
||||||
background-image:
|
background-image:
|
||||||
repeating-linear-gradient(
|
repeating-linear-gradient(
|
||||||
0deg,
|
0deg,
|
||||||
rgba(255, 255, 255, 0.018) 0 1px,
|
rgba(255, 255, 255, 0.018) 0 1px,
|
||||||
transparent 1px 3px
|
transparent 1px 3px
|
||||||
),
|
),
|
||||||
repeating-linear-gradient(
|
repeating-linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
rgba(0, 0, 0, 0.04) 0 1px,
|
rgba(0, 0, 0, 0.04) 0 1px,
|
||||||
transparent 1px 3px
|
transparent 1px 3px
|
||||||
);
|
);
|
||||||
mix-blend-mode: overlay;
|
mix-blend-mode: overlay;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
@@ -982,6 +1004,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--t-1);
|
color: var(--t-1);
|
||||||
}
|
}
|
||||||
|
.dl-md-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.dl-md-status > span:last-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.dl-md .dl-pulse {
|
.dl-md .dl-pulse {
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
@@ -1066,6 +1100,9 @@
|
|||||||
color: color-mix(in srgb, var(--accent) 80%, white);
|
color: color-mix(in srgb, var(--accent) 80%, white);
|
||||||
}
|
}
|
||||||
.dl-lg-primary .dl-label {
|
.dl-lg-primary .dl-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.dl-lg-secondary {
|
.dl-lg-secondary {
|
||||||
@@ -1510,6 +1547,59 @@
|
|||||||
.settings-row-control {
|
.settings-row-control {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.settings-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.settings-switch input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.settings-switch-track {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
border: 1px solid var(--bd-3);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-4);
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
.settings-switch-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--t-2);
|
||||||
|
transition: transform 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
.settings-switch.is-checked .settings-switch-track {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 75%, var(--bd-2));
|
||||||
|
background: color-mix(in srgb, var(--accent) 55%, var(--bg-3));
|
||||||
|
}
|
||||||
|
.settings-switch.is-checked .settings-switch-thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.settings-switch input:focus-visible + .settings-switch-track {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--accent) 65%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.settings-switch:has(input:disabled) {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.settings-inline-error {
|
||||||
|
margin-top: -6px;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.settings-foot {
|
.settings-foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1791,52 +1881,67 @@
|
|||||||
/* ─── Top-bar entry button ─── */
|
/* ─── Top-bar entry button ─── */
|
||||||
.ctp-btn {
|
.ctp-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex; align-items: center; gap: 8px;
|
display: inline-flex;
|
||||||
height: 36px; padding: 0 14px;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
|
background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
|
||||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
|
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--t-1);
|
color: var(--t-1);
|
||||||
font: inherit; font-size: 12.5px; font-weight: 700;
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: background .15s, border-color .15s;
|
transition: background .15s, border-color .15s;
|
||||||
}
|
}
|
||||||
.ctp-btn svg { color: var(--accent); flex-shrink: 0; }
|
.ctp-btn svg {
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.ctp-btn:hover {
|
.ctp-btn:hover {
|
||||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
|
background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
|
||||||
border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2));
|
border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2));
|
||||||
}
|
}
|
||||||
.ctp-badge {
|
.ctp-badge {
|
||||||
display: inline-grid; place-items: center;
|
display: inline-grid;
|
||||||
min-width: 18px; height: 18px;
|
place-items: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 10.5px; font-weight: 800;
|
font-size: 10.5px;
|
||||||
|
font-weight: 800;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Ticker strip ─── */
|
/* ─── Ticker strip ─── */
|
||||||
.ctp-ticker-stack {
|
.ctp-ticker-stack {
|
||||||
display: flex; flex-direction: column; gap: 8px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
.ctp-ticker-stack .ctp-ticker { margin-bottom: 0; }
|
.ctp-ticker-stack .ctp-ticker {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
.ctp-ticker {
|
.ctp-ticker {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
10px /* LED */
|
10px /* LED */
|
||||||
98px /* status */
|
98px /* status */
|
||||||
220px /* game */
|
220px /* game */
|
||||||
130px /* by */
|
130px /* by */
|
||||||
88px /* count */
|
88px /* count */
|
||||||
170px /* time */
|
170px /* time */
|
||||||
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
|
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
|
||||||
max-content /* bubbles */
|
max-content /* bubbles */
|
||||||
82px; /* cta */
|
82px; /* cta */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
column-gap: 12px;
|
column-gap: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1845,22 +1950,34 @@
|
|||||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
|
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: var(--t-1);
|
color: var(--t-1);
|
||||||
font: inherit; font-size: 12.5px;
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background .15s, border-color .15s;
|
transition: background .15s, border-color .15s;
|
||||||
}
|
}
|
||||||
.ctp-ticker:hover { background: color-mix(in srgb, var(--accent) 16%, var(--bg-2)); }
|
.ctp-ticker:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, var(--bg-2));
|
||||||
|
}
|
||||||
.ctp-ticker-dot {
|
.ctp-ticker-dot {
|
||||||
width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0;
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
|
||||||
animation: ctp-tickerpulse 1.6s ease-out infinite;
|
animation: ctp-tickerpulse 1.6s ease-out infinite;
|
||||||
}
|
}
|
||||||
@keyframes ctp-tickerpulse {
|
@keyframes ctp-tickerpulse {
|
||||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
|
0% {
|
||||||
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); }
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.ctp-ticker-label {
|
.ctp-ticker-label {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -1870,36 +1987,87 @@
|
|||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.ctp-ticker-game { font-weight: 700; color: var(--t-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.ctp-ticker-game {
|
||||||
.ctp-ticker-by { color: var(--t-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
font-weight: 700;
|
||||||
.ctp-ticker-sep { display: none; }
|
color: var(--t-1);
|
||||||
.ctp-ticker-ready, .ctp-ticker-time { color: var(--t-2); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ctp-ticker-by {
|
||||||
|
color: var(--t-3);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ctp-ticker-sep {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ctp-ticker-ready,
|
||||||
|
.ctp-ticker-time {
|
||||||
|
color: var(--t-2);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.ctp-ticker-more {
|
.ctp-ticker-more {
|
||||||
color: var(--t-3);
|
color: var(--t-3);
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.ctp-ticker-cta {
|
.ctp-ticker-cta {
|
||||||
display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.ctp-ticker-cta svg { transform: rotate(-90deg); }
|
.ctp-ticker-cta svg {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Overlay modal ─── */
|
/* ─── Overlay modal ─── */
|
||||||
.ctp-modal { width: min(720px, 100%); }
|
.ctp-modal {
|
||||||
|
width: min(720px, 100%);
|
||||||
|
}
|
||||||
.ctp-head-row {
|
.ctp-head-row {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-right: 36px;
|
margin-right: 36px;
|
||||||
}
|
}
|
||||||
.ctp-head-new { height: 40px; padding: 0 18px; font-size: 13px; margin-top: 14px; width: 100%; }
|
.ctp-head-new {
|
||||||
.ctp-head { padding: 26px 28px 14px; border-bottom: 1px solid var(--bd-1); }
|
height: 40px;
|
||||||
.ctp-head h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; color: var(--t-1); }
|
padding: 0 18px;
|
||||||
.ctp-head-sub { margin: 6px 0 0; font-size: 12.5px; color: var(--t-3); max-width: 52ch; }
|
font-size: 13px;
|
||||||
|
margin-top: 14px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.ctp-head {
|
||||||
|
padding: 26px 28px 14px;
|
||||||
|
border-bottom: 1px solid var(--bd-1);
|
||||||
|
}
|
||||||
|
.ctp-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.ctp-head-sub {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--t-3);
|
||||||
|
max-width: 52ch;
|
||||||
|
}
|
||||||
.ctp-body {
|
.ctp-body {
|
||||||
padding: 18px 24px 24px;
|
padding: 18px 24px 24px;
|
||||||
display: flex; flex-direction: column; gap: 14px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
max-height: 66vh;
|
max-height: 66vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@@ -1912,42 +2080,86 @@
|
|||||||
|
|
||||||
/* ─── Nomination card ─── */
|
/* ─── Nomination card ─── */
|
||||||
.ctp-card {
|
.ctp-card {
|
||||||
display: flex; flex-direction: column; gap: 12px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
background: rgba(255,255,255,0.025);
|
background: rgba(255,255,255,0.025);
|
||||||
border: 1px solid var(--bd-1);
|
border: 1px solid var(--bd-1);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
|
.ctp-card.is-done {
|
||||||
.ctp-card.is-expired { border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); }
|
border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2));
|
||||||
.ctp-card.is-terminal { opacity: 0.72; }
|
}
|
||||||
.ctp-card.is-running { border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2)); }
|
.ctp-card.is-expired {
|
||||||
.ctp-card.is-cancelled { border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2)); }
|
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
|
||||||
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
|
}
|
||||||
|
.ctp-card.is-terminal {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
.ctp-card.is-running {
|
||||||
|
border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2));
|
||||||
|
}
|
||||||
|
.ctp-card.is-cancelled {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2));
|
||||||
|
}
|
||||||
|
.ctp-card.is-focused {
|
||||||
|
border-color: var(--accent);
|
||||||
|
animation: ctp-cardflash 1.4s ease-out 1;
|
||||||
|
}
|
||||||
@keyframes ctp-cardflash {
|
@keyframes ctp-cardflash {
|
||||||
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
|
0% {
|
||||||
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ctp-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.ctp-card-top { display: flex; align-items: flex-start; gap: 12px; position: relative; }
|
|
||||||
.ctp-card-cover {
|
.ctp-card-cover {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 52px; height: 52px;
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.ctp-card-cover-missing {
|
.ctp-card-cover-missing {
|
||||||
width: 100%; height: 100%;
|
width: 100%;
|
||||||
display: grid; place-items: center;
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
color: var(--t-3);
|
color: var(--t-3);
|
||||||
background: color-mix(in srgb, var(--bd-2) 55%, transparent);
|
background: color-mix(in srgb, var(--bd-2) 55%, transparent);
|
||||||
border: 1px dashed var(--bd-2);
|
border: 1px dashed var(--bd-2);
|
||||||
}
|
}
|
||||||
.ctp-card-cover-missing svg { width: 22px; height: 22px; }
|
.ctp-card-cover-missing svg {
|
||||||
.ctp-card-info { flex: 1; min-width: 0; }
|
width: 22px;
|
||||||
.ctp-card-title { font-size: 14.5px; font-weight: 700; color: var(--t-1); }
|
height: 22px;
|
||||||
.ctp-card-sub { margin-top: 3px; font-size: 11.5px; color: var(--t-3); }
|
}
|
||||||
.ctp-card-sub strong { color: var(--t-2); font-weight: 700; }
|
.ctp-card-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ctp-card-title {
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.ctp-card-sub {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--t-3);
|
||||||
|
}
|
||||||
|
.ctp-card-sub strong {
|
||||||
|
color: var(--t-2);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
.ctp-card-timer {
|
.ctp-card-timer {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
@@ -1956,25 +2168,45 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); }
|
.ctp-card-timer[data-urgency="mid"] {
|
||||||
.ctp-card-timer[data-urgency="high"] { color: var(--danger); }
|
color: var(--warn);
|
||||||
.ctp-card.is-done .ctp-card-timer { color: var(--ok); font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; }
|
}
|
||||||
.ctp-card.is-expired .ctp-card-timer { color: var(--danger); }
|
.ctp-card-timer[data-urgency="high"] {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.ctp-card.is-done .ctp-card-timer {
|
||||||
|
color: var(--ok);
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.ctp-card.is-expired .ctp-card-timer {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
.ctp-cancel-link {
|
.ctp-cancel-link {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
padding: 6px 2px;
|
padding: 6px 2px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: var(--t-3);
|
color: var(--t-3);
|
||||||
font: inherit; font-size: 11.5px; font-weight: 600;
|
font: inherit;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color .15s;
|
transition: color .15s;
|
||||||
}
|
}
|
||||||
.ctp-cancel-link:hover { color: #fca5a5; }
|
.ctp-cancel-link:hover {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
.ctp-cancel-confirm {
|
.ctp-cancel-confirm {
|
||||||
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: rgba(239,68,68,0.08);
|
background: rgba(239,68,68,0.08);
|
||||||
@@ -1984,8 +2216,16 @@
|
|||||||
color: #fca5a5;
|
color: #fca5a5;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.ctp-cancel-confirm-btns { display: inline-flex; gap: 8px; flex-shrink: 0; }
|
.ctp-cancel-confirm-btns {
|
||||||
.ctp-cancel-confirm-btns .ghost-btn { height: 32px; padding: 0 12px; font-size: 12px; }
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ctp-cancel-confirm-btns .ghost-btn {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.ctp-progress {
|
.ctp-progress {
|
||||||
height: 5px;
|
height: 5px;
|
||||||
@@ -1993,37 +2233,66 @@
|
|||||||
background: rgba(255,255,255,0.06);
|
background: rgba(255,255,255,0.06);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.ctp-progress-fill { height: 100%; transition: width 1s linear; }
|
.ctp-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
transition: width 1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
.ctp-roster { display: flex; flex-direction: column; gap: 8px; }
|
.ctp-roster {
|
||||||
.ctp-roster-count { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--t-3); }
|
display: flex;
|
||||||
.ctp-avatars { display: flex; flex-wrap: wrap; gap: 6px; }
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.ctp-roster-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--t-3);
|
||||||
|
}
|
||||||
|
.ctp-avatars {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.ctp-avatar {
|
.ctp-avatar {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
.ctp-avatar-dot {
|
.ctp-avatar-dot {
|
||||||
width: 30px; height: 30px;
|
width: 30px;
|
||||||
display: grid; place-items: center;
|
height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 10.5px; font-weight: 800;
|
font-size: 10.5px;
|
||||||
|
font-weight: 800;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
.ctp-avatar.is-pending .ctp-avatar-dot { opacity: 0.55; }
|
.ctp-avatar.is-pending .ctp-avatar-dot {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
.ctp-avatar-check {
|
.ctp-avatar-check {
|
||||||
position: absolute; bottom: -2px; right: -2px;
|
position: absolute;
|
||||||
width: 14px; height: 14px;
|
bottom: -2px;
|
||||||
display: grid; place-items: center;
|
right: -2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--ok);
|
background: var(--ok);
|
||||||
color: #06240f;
|
color: #06240f;
|
||||||
border: 2px solid var(--bg-2);
|
border: 2px solid var(--bg-2);
|
||||||
}
|
}
|
||||||
.ctp-avatar-pending {
|
.ctp-avatar-pending {
|
||||||
position: absolute; bottom: -6px; left: 50%;
|
position: absolute;
|
||||||
|
bottom: -6px;
|
||||||
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
font-size: 9px; font-weight: 700;
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
color: var(--t-2);
|
color: var(--t-2);
|
||||||
background: var(--bg-3);
|
background: var(--bg-3);
|
||||||
border: 1px solid var(--bd-2);
|
border: 1px solid var(--bd-2);
|
||||||
@@ -2033,64 +2302,122 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.ctp-avatar-empty {
|
.ctp-avatar-empty {
|
||||||
width: 30px; height: 30px;
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1.5px dashed var(--bd-2);
|
border: 1.5px dashed var(--bd-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctp-actions {
|
.ctp-actions {
|
||||||
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
.ctp-actions .act-btn { height: 36px; padding: 0 16px; font-size: 12.5px; }
|
.ctp-actions .act-btn {
|
||||||
.ctp-actions .ghost-btn { height: 36px; padding: 0 14px; font-size: 12.5px; }
|
height: 36px;
|
||||||
.ctp-note { font-size: 12.5px; color: var(--t-3); }
|
padding: 0 16px;
|
||||||
.ctp-note-launch { color: var(--ok); font-weight: 600; }
|
font-size: 12.5px;
|
||||||
.ctp-me-status { font-size: 12.5px; font-weight: 600; color: var(--t-2); }
|
}
|
||||||
.ctp-buffer-group { display: inline-flex; gap: 6px; }
|
.ctp-actions .ghost-btn {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.ctp-note {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--t-3);
|
||||||
|
}
|
||||||
|
.ctp-note-launch {
|
||||||
|
color: var(--ok);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ctp-me-status {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-2);
|
||||||
|
}
|
||||||
|
.ctp-buffer-group {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.ctp-buffer-btn {
|
.ctp-buffer-btn {
|
||||||
height: 36px; padding: 0 11px;
|
height: 36px;
|
||||||
|
padding: 0 11px;
|
||||||
background: rgba(255,255,255,0.04);
|
background: rgba(255,255,255,0.04);
|
||||||
border: 1px solid var(--bd-2);
|
border: 1px solid var(--bd-2);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
color: var(--t-1);
|
color: var(--t-1);
|
||||||
font: inherit; font-size: 12px; font-weight: 600;
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .15s, border-color .15s;
|
transition: background .15s, border-color .15s;
|
||||||
}
|
}
|
||||||
.ctp-buffer-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); }
|
.ctp-buffer-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border-color: var(--bd-3);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Create-nomination form ─── */
|
/* ─── Create-nomination form ─── */
|
||||||
.ctp-create-cta {
|
.ctp-create-cta {
|
||||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1.5px dashed var(--bd-3);
|
border: 1.5px dashed var(--bd-3);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: var(--t-2);
|
color: var(--t-2);
|
||||||
font: inherit; font-size: 13px; font-weight: 700;
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color .15s, color .15s, background .15s;
|
transition: border-color .15s, color .15s, background .15s;
|
||||||
}
|
}
|
||||||
.ctp-create-cta:hover { border-color: var(--accent); color: var(--t-1); background: rgba(255,255,255,0.02); }
|
.ctp-create-cta:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--t-1);
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
.ctp-create {
|
.ctp-create {
|
||||||
display: flex; flex-direction: column; gap: 14px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: rgba(255,255,255,0.03);
|
background: rgba(255,255,255,0.03);
|
||||||
border: 1px solid var(--bd-2);
|
border: 1px solid var(--bd-2);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
.ctp-create-row { display: flex; flex-direction: column; gap: 6px; position: relative; }
|
.ctp-create-row {
|
||||||
.ctp-create-row-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ctp-create-row-inline {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
.ctp-create-label {
|
.ctp-create-label {
|
||||||
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
color: var(--t-3);
|
color: var(--t-3);
|
||||||
}
|
}
|
||||||
.ctp-create-search { width: 100%; }
|
.ctp-create-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.ctp-create-matches {
|
.ctp-create-matches {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 4px);
|
top: calc(100% + 4px);
|
||||||
left: 0; right: 0;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -2101,61 +2428,111 @@
|
|||||||
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
|
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
.ctp-create-matches button {
|
.ctp-create-matches button {
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 9px 10px;
|
padding: 9px 10px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--t-1);
|
color: var(--t-1);
|
||||||
font: inherit; font-size: 12.5px; font-weight: 600;
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.ctp-create-matches button:hover { background: rgba(255,255,255,0.06); }
|
.ctp-create-matches button:hover {
|
||||||
.ctp-create-match-meta { color: var(--t-3); font-size: 11px; font-weight: 500; }
|
background: rgba(255,255,255,0.06);
|
||||||
.ctp-create-nomatch { padding: 10px; font-size: 12px; color: var(--t-3); }
|
}
|
||||||
|
.ctp-create-match-meta {
|
||||||
|
color: var(--t-3);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ctp-create-nomatch {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-3);
|
||||||
|
}
|
||||||
.ctp-create-num {
|
.ctp-create-num {
|
||||||
width: 70px; height: 34px;
|
width: 70px;
|
||||||
|
height: 34px;
|
||||||
background: var(--bg-3);
|
background: var(--bg-3);
|
||||||
border: 1px solid var(--bd-1);
|
border: 1px solid var(--bd-1);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
color: var(--t-1);
|
color: var(--t-1);
|
||||||
font: inherit; font-size: 13px; font-weight: 600;
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.ctp-create-hint { font-size: 11.5px; color: var(--t-3); }
|
.ctp-create-hint {
|
||||||
.ctp-duration-opts { display: inline-flex; gap: 6px; flex-wrap: wrap; }
|
font-size: 11.5px;
|
||||||
|
color: var(--t-3);
|
||||||
|
}
|
||||||
|
.ctp-duration-opts {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
.ctp-duration-btn {
|
.ctp-duration-btn {
|
||||||
height: 32px; padding: 0 13px;
|
height: 32px;
|
||||||
|
padding: 0 13px;
|
||||||
background: var(--bg-3);
|
background: var(--bg-3);
|
||||||
border: 1px solid var(--bd-1);
|
border: 1px solid var(--bd-1);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
color: var(--t-2);
|
color: var(--t-2);
|
||||||
font: inherit; font-size: 12.5px; font-weight: 700;
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .15s, color .15s, border-color .15s;
|
transition: background .15s, color .15s, border-color .15s;
|
||||||
}
|
}
|
||||||
.ctp-duration-btn:hover { color: var(--t-1); }
|
.ctp-duration-btn:hover {
|
||||||
.ctp-duration-btn.is-active { color: white; }
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.ctp-duration-btn.is-active {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
.ctp-create-foot {
|
.ctp-create-foot {
|
||||||
display: flex; justify-content: flex-end; gap: 10px;
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
.ctp-create-foot .act-btn,
|
.ctp-create-foot .act-btn,
|
||||||
.ctp-create-foot .ghost-btn { height: 40px; padding: 0 18px; }
|
.ctp-create-foot .ghost-btn {
|
||||||
.ctp-time-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
|
height: 40px;
|
||||||
|
padding: 0 18px;
|
||||||
|
}
|
||||||
|
.ctp-time-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
/* ─── Time stepper ─── */
|
/* ─── Time stepper ─── */
|
||||||
.ctp-timepick {
|
.ctp-timepick {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.ctp-timepick-field {
|
.ctp-timepick-field {
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 3px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
}
|
}
|
||||||
.ctp-time-step {
|
.ctp-time-step {
|
||||||
width: 54px; height: 24px;
|
width: 54px;
|
||||||
display: grid; place-items: center;
|
height: 24px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
background: var(--bg-3);
|
background: var(--bg-3);
|
||||||
border: 1px solid var(--bd-1);
|
border: 1px solid var(--bd-1);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -2163,54 +2540,91 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .12s, color .12s, border-color .12s;
|
transition: background .12s, color .12s, border-color .12s;
|
||||||
}
|
}
|
||||||
.ctp-time-step:hover { background: var(--bg-4); color: var(--t-1); border-color: var(--bd-3); }
|
.ctp-time-step:hover {
|
||||||
.ctp-time-step:active { background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); }
|
background: var(--bg-4);
|
||||||
|
color: var(--t-1);
|
||||||
|
border-color: var(--bd-3);
|
||||||
|
}
|
||||||
|
.ctp-time-step:active {
|
||||||
|
background: color-mix(in srgb, var(--accent) 30%, var(--bg-3));
|
||||||
|
}
|
||||||
.ctp-time-cell {
|
.ctp-time-cell {
|
||||||
width: 54px; height: 44px;
|
width: 54px;
|
||||||
display: grid; place-items: center;
|
height: 44px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
background: var(--bg-3);
|
background: var(--bg-3);
|
||||||
border: 1px solid var(--bd-2);
|
border: 1px solid var(--bd-2);
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
font-size: 26px; font-weight: 700;
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--t-1);
|
color: var(--t-1);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
.ctp-time-editinput {
|
.ctp-time-editinput {
|
||||||
width: 132px; height: 44px;
|
width: 132px;
|
||||||
|
height: 44px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--bg-3);
|
background: var(--bg-3);
|
||||||
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2));
|
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2));
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
font: inherit; font-size: 26px; font-weight: 700;
|
font: inherit;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--t-1);
|
color: var(--t-1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
.ctp-time-editinput:focus { outline: none; }
|
.ctp-time-editinput:focus {
|
||||||
.ctp-time-editinput::placeholder { color: var(--t-4); }
|
outline: none;
|
||||||
.ctp-time-colon { font-size: 26px; font-weight: 700; color: var(--t-2); padding-bottom: 2px; }
|
}
|
||||||
|
.ctp-time-editinput::placeholder {
|
||||||
|
color: var(--t-4);
|
||||||
|
}
|
||||||
|
.ctp-time-colon {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--t-2);
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
.ctp-time-type {
|
.ctp-time-type {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
height: 30px; padding: 0 12px;
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px dashed var(--bd-3);
|
border: 1px dashed var(--bd-3);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
color: var(--t-3);
|
color: var(--t-3);
|
||||||
font: inherit; font-size: 11.5px; font-weight: 600;
|
font: inherit;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color .12s, border-color .12s;
|
transition: color .12s, border-color .12s;
|
||||||
}
|
}
|
||||||
.ctp-time-type:hover { color: var(--t-1); border-color: var(--accent); }
|
.ctp-time-type:hover {
|
||||||
.ctp-day-row { gap: 5px; }
|
color: var(--t-1);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.ctp-day-row {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
.ctp-day-label {
|
.ctp-day-label {
|
||||||
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
color: var(--t-3);
|
color: var(--t-3);
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
.ctp-day-btn { height: 26px; padding: 0 10px; font-size: 11px; border-radius: 6px; }
|
.ctp-day-btn {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Quick-bar status variants — LED + label + row tint per status:
|
/* ─── Quick-bar status variants — LED + label + row tint per status:
|
||||||
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
|
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
|
||||||
@@ -2220,95 +2634,199 @@
|
|||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
border-color: var(--bd-2);
|
border-color: var(--bd-2);
|
||||||
}
|
}
|
||||||
.ctp-ticker[data-status="scheduled"]:hover { background: var(--bg-3); }
|
.ctp-ticker[data-status="scheduled"]:hover {
|
||||||
|
background: var(--bg-3);
|
||||||
|
}
|
||||||
.ctp-ticker[data-status="soon"] {
|
.ctp-ticker[data-status="soon"] {
|
||||||
background: color-mix(in srgb, var(--warn) 10%, var(--bg-2));
|
background: color-mix(in srgb, var(--warn) 10%, var(--bg-2));
|
||||||
border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2));
|
border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2));
|
||||||
animation: ctp-soon-glow 2.4s ease-in-out infinite;
|
animation: ctp-soon-glow 2.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.ctp-ticker[data-status="soon"]:hover { background: color-mix(in srgb, var(--warn) 16%, var(--bg-2)); }
|
.ctp-ticker[data-status="soon"]:hover {
|
||||||
|
background: color-mix(in srgb, var(--warn) 16%, var(--bg-2));
|
||||||
|
}
|
||||||
@keyframes ctp-soon-glow {
|
@keyframes ctp-soon-glow {
|
||||||
0%, 100% { box-shadow: 0 0 0 0 transparent; }
|
0%,
|
||||||
50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent); }
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 transparent;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.ctp-ticker[data-status="ready"] {
|
.ctp-ticker[data-status="ready"] {
|
||||||
background: color-mix(in srgb, var(--ok) 11%, var(--bg-2));
|
background: color-mix(in srgb, var(--ok) 11%, var(--bg-2));
|
||||||
border-color: color-mix(in srgb, var(--ok) 55%, var(--bd-2));
|
border-color: color-mix(in srgb, var(--ok) 55%, var(--bd-2));
|
||||||
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent);
|
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent);
|
||||||
}
|
}
|
||||||
.ctp-ticker[data-status="ready"]:hover { background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); }
|
.ctp-ticker[data-status="ready"]:hover {
|
||||||
|
background: color-mix(in srgb, var(--ok) 17%, var(--bg-2));
|
||||||
|
}
|
||||||
.ctp-ticker[data-status="expired"] {
|
.ctp-ticker[data-status="expired"] {
|
||||||
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
|
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
|
||||||
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
|
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
|
||||||
}
|
}
|
||||||
.ctp-ticker[data-status="expired"]:hover { background: color-mix(in srgb, var(--danger) 13%, var(--bg-2)); }
|
.ctp-ticker[data-status="expired"]:hover {
|
||||||
|
background: color-mix(in srgb, var(--danger) 13%, var(--bg-2));
|
||||||
|
}
|
||||||
.ctp-ticker[data-status="running"] {
|
.ctp-ticker[data-status="running"] {
|
||||||
background: color-mix(in srgb, var(--ok) 5%, var(--bg-2));
|
background: color-mix(in srgb, var(--ok) 5%, var(--bg-2));
|
||||||
border-color: color-mix(in srgb, var(--ok) 25%, var(--bd-2));
|
border-color: color-mix(in srgb, var(--ok) 25%, var(--bd-2));
|
||||||
}
|
}
|
||||||
.ctp-ticker[data-status="running"]:hover { background: color-mix(in srgb, var(--ok) 9%, var(--bg-2)); }
|
.ctp-ticker[data-status="running"]:hover {
|
||||||
|
background: color-mix(in srgb, var(--ok) 9%, var(--bg-2));
|
||||||
|
}
|
||||||
.ctp-ticker[data-status="cancelled"] {
|
.ctp-ticker[data-status="cancelled"] {
|
||||||
background: color-mix(in srgb, var(--danger) 4%, var(--bg-2));
|
background: color-mix(in srgb, var(--danger) 4%, var(--bg-2));
|
||||||
border-color: color-mix(in srgb, var(--danger) 22%, var(--bd-2));
|
border-color: color-mix(in srgb, var(--danger) 22%, var(--bd-2));
|
||||||
}
|
}
|
||||||
.ctp-ticker[data-status="cancelled"]:hover { background: color-mix(in srgb, var(--danger) 8%, var(--bg-2)); }
|
.ctp-ticker[data-status="cancelled"]:hover {
|
||||||
.ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; }
|
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
|
||||||
.ctp-ticker-dot[data-status="soon"] { background: var(--warn); animation: ctp-tickerpulse-warn 1.6s ease-out infinite; }
|
}
|
||||||
.ctp-ticker-dot[data-status="ready"] { background: var(--ok); animation: none; box-shadow: 0 0 6px var(--ok); }
|
.ctp-ticker-dot[data-status="scheduled"] {
|
||||||
.ctp-ticker-dot[data-status="expired"] { background: var(--danger); animation: none; box-shadow: none; }
|
background: var(--t-3);
|
||||||
.ctp-ticker-dot[data-status="running"] { background: var(--ok); animation: none; box-shadow: none; opacity: 0.75; }
|
animation: none;
|
||||||
.ctp-ticker-dot[data-status="cancelled"] { background: var(--danger); animation: none; box-shadow: none; opacity: 0.65; }
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.ctp-ticker-dot[data-status="soon"] {
|
||||||
|
background: var(--warn);
|
||||||
|
animation: ctp-tickerpulse-warn 1.6s ease-out infinite;
|
||||||
|
}
|
||||||
|
.ctp-ticker-dot[data-status="ready"] {
|
||||||
|
background: var(--ok);
|
||||||
|
animation: none;
|
||||||
|
box-shadow: 0 0 6px var(--ok);
|
||||||
|
}
|
||||||
|
.ctp-ticker-dot[data-status="expired"] {
|
||||||
|
background: var(--danger);
|
||||||
|
animation: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.ctp-ticker-dot[data-status="running"] {
|
||||||
|
background: var(--ok);
|
||||||
|
animation: none;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.ctp-ticker-dot[data-status="cancelled"] {
|
||||||
|
background: var(--danger);
|
||||||
|
animation: none;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
@keyframes ctp-tickerpulse-warn {
|
@keyframes ctp-tickerpulse-warn {
|
||||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
|
0% {
|
||||||
70% { box-shadow: 0 0 0 6px transparent; }
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent);
|
||||||
100% { box-shadow: 0 0 0 0 transparent; }
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 6px transparent;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ctp-ticker-label[data-status="scheduled"] {
|
||||||
|
color: var(--t-2);
|
||||||
|
}
|
||||||
|
.ctp-ticker-label[data-status="soon"] {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
.ctp-ticker-label[data-status="ready"] {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.ctp-ticker-label[data-status="expired"] {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.ctp-ticker-label[data-status="running"] {
|
||||||
|
color: color-mix(in srgb, var(--ok) 75%, var(--t-2));
|
||||||
|
}
|
||||||
|
.ctp-ticker-label[data-status="cancelled"] {
|
||||||
|
color: color-mix(in srgb, var(--danger) 70%, var(--t-2));
|
||||||
|
}
|
||||||
|
.ctp-ticker[data-status="soon"] .ctp-ticker-cta {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
.ctp-ticker[data-status="ready"] .ctp-ticker-cta {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.ctp-ticker[data-status="expired"] .ctp-ticker-cta {
|
||||||
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); }
|
|
||||||
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
|
|
||||||
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
|
|
||||||
.ctp-ticker-label[data-status="expired"] { color: var(--danger); }
|
|
||||||
.ctp-ticker-label[data-status="running"] { color: color-mix(in srgb, var(--ok) 75%, var(--t-2)); }
|
|
||||||
.ctp-ticker-label[data-status="cancelled"] { color: color-mix(in srgb, var(--danger) 70%, var(--t-2)); }
|
|
||||||
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
|
|
||||||
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
|
|
||||||
.ctp-ticker[data-status="expired"] .ctp-ticker-cta { color: var(--danger); }
|
|
||||||
.ctp-ticker[data-status="running"] .ctp-ticker-cta,
|
.ctp-ticker[data-status="running"] .ctp-ticker-cta,
|
||||||
.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta { color: var(--t-2); }
|
.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta {
|
||||||
|
color: var(--t-2);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Quick-bar inline chat preview ─── */
|
/* ─── Quick-bar inline chat preview ─── */
|
||||||
.ctp-ticker-chat {
|
.ctp-ticker-chat {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: inline-flex; align-items: center; gap: 5px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
color: var(--t-3);
|
color: var(--t-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.ctp-ticker-chat svg { flex-shrink: 0; opacity: 0.7; }
|
.ctp-ticker-chat svg {
|
||||||
.ctp-ticker-chat b { flex-shrink: 0; font-weight: 700; }
|
flex-shrink: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.ctp-ticker-chat b {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
.ctp-ticker-chat-text {
|
.ctp-ticker-chat-text {
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
color: var(--t-2);
|
color: var(--t-2);
|
||||||
}
|
}
|
||||||
.ctp-card.is-checkin { border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2)); }
|
.ctp-card.is-checkin {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2));
|
||||||
|
}
|
||||||
.ctp-checkin-note {
|
.ctp-checkin-note {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: color-mix(in srgb, var(--accent) 13%, transparent);
|
background: color-mix(in srgb, var(--accent) 13%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
|
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 12.5px; font-weight: 600;
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--t-1);
|
color: var(--t-1);
|
||||||
}
|
}
|
||||||
.ctp-checkin-note svg { color: var(--accent); flex-shrink: 0; }
|
.ctp-checkin-note svg {
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.ctp-card-timer.is-sched {
|
.ctp-card-timer.is-sched {
|
||||||
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.ctp-card-clock {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.ctp-card-until {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-3);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.ctp-avatar.is-in .ctp-avatar-dot {
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
.ctp-card-clock { font-size: 18px; line-height: 1; }
|
|
||||||
.ctp-card-until { font-size: 10.5px; font-weight: 600; color: var(--t-3); letter-spacing: 0.01em; }
|
|
||||||
.ctp-avatar.is-in .ctp-avatar-dot { opacity: 0.8; }
|
|
||||||
.ctp-avatar-in {
|
.ctp-avatar-in {
|
||||||
position: absolute; bottom: -6px; left: 50%;
|
position: absolute;
|
||||||
|
bottom: -6px;
|
||||||
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
font-size: 8.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;
|
font-size: 8.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
color: var(--t-2);
|
color: var(--t-2);
|
||||||
background: var(--bg-3);
|
background: var(--bg-3);
|
||||||
border: 1px solid var(--bd-2);
|
border: 1px solid var(--bd-2);
|
||||||
@@ -2319,37 +2837,60 @@
|
|||||||
|
|
||||||
/* ─── Ticker mini ready-bubbles ─── */
|
/* ─── Ticker mini ready-bubbles ─── */
|
||||||
.ctp-ticker-bubbles {
|
.ctp-ticker-bubbles {
|
||||||
display: inline-flex; align-items: center;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
}
|
}
|
||||||
.ctp-mini {
|
.ctp-mini {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 22px; height: 22px;
|
width: 22px;
|
||||||
display: grid; place-items: center;
|
height: 22px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
margin-left: -6px;
|
margin-left: -6px;
|
||||||
border: 2px solid var(--bg-2);
|
border: 2px solid var(--bg-2);
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 8px; font-weight: 800; letter-spacing: 0.02em;
|
font-size: 8px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.ctp-mini:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.ctp-mini[data-state="ready"] {
|
||||||
|
box-shadow: 0 0 0 1.5px var(--ok);
|
||||||
|
}
|
||||||
|
.ctp-mini[data-state="pending"] {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.ctp-mini[data-state="in"] {
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
.ctp-mini:first-child { margin-left: 0; }
|
|
||||||
.ctp-mini[data-state="ready"] { box-shadow: 0 0 0 1.5px var(--ok); }
|
|
||||||
.ctp-mini[data-state="pending"] { opacity: 0.75; }
|
|
||||||
.ctp-mini[data-state="in"] { opacity: 0.85; }
|
|
||||||
.ctp-mini-check {
|
.ctp-mini-check {
|
||||||
position: absolute; bottom: -3px; right: -4px;
|
position: absolute;
|
||||||
width: 11px; height: 11px;
|
bottom: -3px;
|
||||||
display: grid; place-items: center;
|
right: -4px;
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--ok);
|
background: var(--ok);
|
||||||
color: #06240f;
|
color: #06240f;
|
||||||
border: 1.5px solid var(--bg-2);
|
border: 1.5px solid var(--bg-2);
|
||||||
}
|
}
|
||||||
.ctp-mini-check svg { width: 7px; height: 7px; }
|
.ctp-mini-check svg {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
}
|
||||||
.ctp-mini-tag {
|
.ctp-mini-tag {
|
||||||
position: absolute; bottom: -6px; right: -7px;
|
position: absolute;
|
||||||
|
bottom: -6px;
|
||||||
|
right: -7px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 8px; font-weight: 700;
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
line-height: 11px;
|
line-height: 11px;
|
||||||
padding: 0 3px;
|
padding: 0 3px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -2359,51 +2900,83 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.ctp-mini-more { background: var(--bg-4); color: var(--t-2); font-size: 8.5px; }
|
.ctp-mini-more {
|
||||||
|
background: var(--bg-4);
|
||||||
|
color: var(--t-2);
|
||||||
|
font-size: 8.5px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Per-call chat ─── */
|
/* ─── Per-call chat ─── */
|
||||||
.ctp-chat {
|
.ctp-chat {
|
||||||
display: flex; flex-direction: column;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
border-top: 1px solid var(--bd-1);
|
border-top: 1px solid var(--bd-1);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
.ctp-chat-toggle {
|
.ctp-chat-toggle {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 4px 2px;
|
padding: 4px 2px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: var(--t-3);
|
color: var(--t-3);
|
||||||
font: inherit; font-size: 11.5px; font-weight: 700;
|
font: inherit;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color .15s;
|
transition: color .15s;
|
||||||
}
|
}
|
||||||
.ctp-chat-toggle:hover { color: var(--t-1); }
|
.ctp-chat-toggle:hover {
|
||||||
.ctp-chat-toggle svg { flex-shrink: 0; }
|
color: var(--t-1);
|
||||||
.ctp-chat-count { color: var(--t-4); font-weight: 600; }
|
}
|
||||||
|
.ctp-chat-toggle svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ctp-chat-count {
|
||||||
|
color: var(--t-4);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.ctp-chat-unread {
|
.ctp-chat-unread {
|
||||||
min-width: 16px; height: 16px;
|
min-width: 16px;
|
||||||
display: grid; place-items: center;
|
height: 16px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 9.5px; font-weight: 800;
|
font-size: 9.5px;
|
||||||
|
font-weight: 800;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.ctp-chat-preview {
|
.ctp-chat-preview {
|
||||||
flex: 1; min-width: 0;
|
flex: 1;
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--t-3);
|
color: var(--t-3);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.ctp-chat-preview b { font-weight: 700; }
|
.ctp-chat-preview b {
|
||||||
.ctp-chat-chevron { margin-left: auto; flex-shrink: 0; transition: transform .15s; }
|
font-weight: 700;
|
||||||
.ctp-chat.is-open .ctp-chat-chevron { transform: rotate(180deg); }
|
}
|
||||||
|
.ctp-chat-chevron {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform .15s;
|
||||||
|
}
|
||||||
|
.ctp-chat.is-open .ctp-chat-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
.ctp-chat-list {
|
.ctp-chat-list {
|
||||||
display: flex; flex-direction: column; gap: 6px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
max-height: 168px;
|
max-height: 168px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
@@ -2412,26 +2985,57 @@
|
|||||||
border: 1px solid var(--bd-1);
|
border: 1px solid var(--bd-1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.ctp-chat-msg { font-size: 12px; line-height: 1.45; color: var(--t-2); overflow-wrap: anywhere; }
|
.ctp-chat-msg {
|
||||||
.ctp-chat-msg b { font-weight: 700; }
|
font-size: 12px;
|
||||||
.ctp-chat-text { color: var(--t-1); }
|
line-height: 1.45;
|
||||||
.ctp-chat-time { margin-left: 6px; font-size: 10px; color: var(--t-4); font-variant-numeric: tabular-nums; }
|
color: var(--t-2);
|
||||||
.ctp-chat-empty { font-size: 11.5px; color: var(--t-4); text-align: center; padding: 6px 0; }
|
overflow-wrap: anywhere;
|
||||||
.ctp-chat-form { display: flex; gap: 6px; }
|
}
|
||||||
|
.ctp-chat-msg b {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ctp-chat-text {
|
||||||
|
color: var(--t-1);
|
||||||
|
}
|
||||||
|
.ctp-chat-time {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-4);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ctp-chat-empty {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--t-4);
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.ctp-chat-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.ctp-chat-input {
|
.ctp-chat-input {
|
||||||
flex: 1; height: 32px;
|
flex: 1;
|
||||||
|
height: 32px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
background: var(--bg-3);
|
background: var(--bg-3);
|
||||||
border: 1px solid var(--bd-1);
|
border: 1px solid var(--bd-1);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
color: var(--t-1);
|
color: var(--t-1);
|
||||||
font: inherit; font-size: 12px;
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ctp-chat-input::placeholder {
|
||||||
|
color: var(--t-4);
|
||||||
|
}
|
||||||
|
.ctp-chat-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2));
|
||||||
}
|
}
|
||||||
.ctp-chat-input::placeholder { color: var(--t-4); }
|
|
||||||
.ctp-chat-input:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); }
|
|
||||||
.ctp-chat-send {
|
.ctp-chat-send {
|
||||||
width: 32px; height: 32px;
|
width: 32px;
|
||||||
display: grid; place-items: center;
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
background: color-mix(in srgb, var(--accent) 24%, var(--bg-3));
|
background: color-mix(in srgb, var(--accent) 24%, var(--bg-3));
|
||||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
|
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -2440,25 +3044,34 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: background .15s;
|
transition: background .15s;
|
||||||
}
|
}
|
||||||
.ctp-chat-send:hover { background: color-mix(in srgb, var(--accent) 40%, var(--bg-3)); }
|
.ctp-chat-send:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 40%, var(--bg-3));
|
||||||
|
}
|
||||||
|
|
||||||
.ctp-transport-note {
|
.ctp-transport-note {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
color: var(--t-3);
|
color: var(--t-3);
|
||||||
}
|
}
|
||||||
.ctp-transport-note.is-error { color: var(--danger); }
|
.ctp-transport-note.is-error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
@container launcher (max-width: 1280px) {
|
@container launcher (max-width: 1280px) {
|
||||||
.ctp-ticker {
|
.ctp-ticker {
|
||||||
grid-template-columns: 10px 86px minmax(120px, 1fr) 78px 130px max-content 70px;
|
grid-template-columns: 10px 86px minmax(120px, 1fr) 78px 130px max-content
|
||||||
|
70px;
|
||||||
}
|
}
|
||||||
.ctp-ticker-by,
|
.ctp-ticker-by,
|
||||||
.ctp-ticker-chat { display: none; }
|
.ctp-ticker-chat {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@container launcher (max-width: 800px) {
|
@container launcher (max-width: 800px) {
|
||||||
.ctp-ticker {
|
.ctp-ticker {
|
||||||
grid-template-columns: 10px 78px minmax(100px, 1fr) 76px 64px;
|
grid-template-columns: 10px 78px minmax(100px, 1fr) 76px 64px;
|
||||||
}
|
}
|
||||||
.ctp-ticker-time,
|
.ctp-ticker-time,
|
||||||
.ctp-ticker-bubbles { display: none; }
|
.ctp-ticker-bubbles {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,264 +1,443 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
|
||||||
import { TopBar } from '../components/topbar/TopBar';
|
import { TopBar } from "../components/topbar/TopBar";
|
||||||
import { KebabItem } from '../components/topbar/KebabMenu';
|
import { KebabItem } from "../components/topbar/KebabMenu";
|
||||||
import { ResultsBar } from '../components/grid/ResultsBar';
|
import { ResultsBar } from "../components/grid/ResultsBar";
|
||||||
import { GameGrid } from '../components/grid/GameGrid';
|
import { GameGrid } from "../components/grid/GameGrid";
|
||||||
import { GameDetailModal } from '../components/modals/GameDetailModal';
|
import { GameDetailModal } from "../components/modals/GameDetailModal";
|
||||||
import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDownloadModal';
|
import { ConfirmRemoveDownloadModal } from "../components/modals/ConfirmRemoveDownloadModal";
|
||||||
import { SettingsDialog } from '../components/modals/SettingsDialog';
|
import { SettingsDialog } from "../components/modals/SettingsDialog";
|
||||||
import { NoDirectoryState } from '../components/empty/NoDirectoryState';
|
import { NoDirectoryState } from "../components/empty/NoDirectoryState";
|
||||||
import { EmptyResultsState } from '../components/empty/EmptyResultsState';
|
import { EmptyResultsState } from "../components/empty/EmptyResultsState";
|
||||||
import { CallToPlayTicker } from '../components/calltoplay/CallToPlayTicker';
|
import { CallToPlayTicker } from "../components/calltoplay/CallToPlayTicker";
|
||||||
import { CallToPlayOverlay } from '../components/calltoplay/CallToPlayOverlay';
|
import { CallToPlayOverlay } from "../components/calltoplay/CallToPlayOverlay";
|
||||||
|
|
||||||
import { useGameDirectory } from '../hooks/useGameDirectory';
|
import { useGameDirectory } from "../hooks/useGameDirectory";
|
||||||
import { useGames } from '../hooks/useGames';
|
import { useGames } from "../hooks/useGames";
|
||||||
import { useGameActions } from '../hooks/useGameActions';
|
import { useGameActions } from "../hooks/useGameActions";
|
||||||
import { useThumbnails } from '../hooks/useThumbnails';
|
import { useThumbnails } from "../hooks/useThumbnails";
|
||||||
import { useSettings } from '../hooks/useSettings';
|
import { useSettings } from "../hooks/useSettings";
|
||||||
import { useCallToPlay } from '../hooks/useCallToPlay';
|
import { useCallToPlay } from "../hooks/useCallToPlay";
|
||||||
|
import { useStreamInstallCapability } from "../hooks/useStreamInstallCapability";
|
||||||
|
import { useLocalNetworkSharing } from "../hooks/useLocalNetworkSharing";
|
||||||
|
import { useIdentityDiagnostic } from "../hooks/useIdentityDiagnostic";
|
||||||
|
|
||||||
import { Game } from '../lib/types';
|
import { Game } from "../lib/types";
|
||||||
import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState';
|
import {
|
||||||
|
AsyncOwner,
|
||||||
|
ownCompanionWindowCreation,
|
||||||
|
windowAsyncScope,
|
||||||
|
} from "../lib/asyncOwnership";
|
||||||
|
import {
|
||||||
|
isLocalNetworkSharingActive,
|
||||||
|
localNetworkSharingNotice,
|
||||||
|
} from "../lib/localNetworkSharing";
|
||||||
|
import {
|
||||||
|
EPHEMERAL_IDENTITY_NOTICE,
|
||||||
|
IDENTITY_STATUS_UNAVAILABLE_NOTICE,
|
||||||
|
} from "../lib/identityDiagnostic";
|
||||||
|
import {
|
||||||
|
applyFilterAndSort,
|
||||||
|
countByFilter,
|
||||||
|
needsUpdate,
|
||||||
|
} from "../lib/gameState";
|
||||||
|
|
||||||
const openLogsWindow = async () => {
|
interface CompanionWindowOptions {
|
||||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
|
label: string;
|
||||||
try {
|
url: string;
|
||||||
const existing = await WebviewWindow.getByLabel('unpack-logs');
|
title: string;
|
||||||
if (existing) {
|
width: number;
|
||||||
await existing.setFocus();
|
height: number;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
const win = new WebviewWindow('unpack-logs', {
|
const openCompanionWindow = async (
|
||||||
url: '/?view=unpack-logs',
|
owner: AsyncOwner,
|
||||||
title: 'Unpack Logs',
|
options: CompanionWindowOptions,
|
||||||
width: 900,
|
): Promise<void> => {
|
||||||
height: 700,
|
try {
|
||||||
resizable: true,
|
let windowModule:
|
||||||
});
|
| typeof import("@tauri-apps/api/webviewWindow")
|
||||||
await win.once<unknown>('tauri://error', (event) => {
|
| undefined;
|
||||||
console.error('Error opening unpack logs window:', event.payload);
|
if (
|
||||||
});
|
!await owner.applyIfActive(
|
||||||
} catch (err) {
|
() => import("@tauri-apps/api/webviewWindow"),
|
||||||
console.error('Error opening unpack logs window:', err);
|
(loaded) => {
|
||||||
|
windowModule = loaded;
|
||||||
|
},
|
||||||
|
) || windowModule === undefined
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { WebviewWindow } = windowModule;
|
||||||
|
const existingResult: Array<
|
||||||
|
Awaited<ReturnType<typeof WebviewWindow.getByLabel>>
|
||||||
|
> = [];
|
||||||
|
if (
|
||||||
|
!await owner.applyIfActive(
|
||||||
|
() => WebviewWindow.getByLabel(options.label),
|
||||||
|
(found) => {
|
||||||
|
existingResult.push(found);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existingWindow = existingResult[0];
|
||||||
|
if (existingWindow) {
|
||||||
|
await owner.applyIfActive(() => existingWindow.setFocus(), () => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!owner.isActive()) return;
|
||||||
|
const win = new WebviewWindow(options.label, {
|
||||||
|
url: options.url,
|
||||||
|
title: options.title,
|
||||||
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
|
resizable: true,
|
||||||
|
});
|
||||||
|
const result = await ownCompanionWindowCreation(owner, {
|
||||||
|
registerCreated: (handler) => win.once("tauri://created", handler),
|
||||||
|
registerError: (handler) =>
|
||||||
|
win.once<unknown>("tauri://error", (event) => handler(event.payload)),
|
||||||
|
destroy: () => win.destroy(),
|
||||||
|
});
|
||||||
|
if (!owner.isActive()) return;
|
||||||
|
if (result.kind === "error") {
|
||||||
|
console.error(`Error opening ${options.title}:`, result.payload);
|
||||||
|
} else if (result.kind === "registration-error") {
|
||||||
|
console.error(
|
||||||
|
`Error owning ${options.title} creation:`,
|
||||||
|
result.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (owner.isActive()) console.error(`Error opening ${options.title}:`, err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openMainLogsWindow = async () => {
|
const UNPACK_LOG_WINDOW: CompanionWindowOptions = {
|
||||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
|
label: "unpack-logs",
|
||||||
try {
|
url: "/?view=unpack-logs",
|
||||||
const existing = await WebviewWindow.getByLabel('main-logs');
|
title: "Unpack Logs",
|
||||||
if (existing) {
|
width: 900,
|
||||||
await existing.setFocus();
|
height: 700,
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
const win = new WebviewWindow('main-logs', {
|
const MAIN_LOG_WINDOW: CompanionWindowOptions = {
|
||||||
url: '/?view=main-logs',
|
label: "main-logs",
|
||||||
title: 'Application Logs',
|
url: "/?view=main-logs",
|
||||||
width: 980,
|
title: "Application Logs",
|
||||||
height: 720,
|
width: 980,
|
||||||
resizable: true,
|
height: 720,
|
||||||
});
|
|
||||||
await win.once<unknown>('tauri://error', (event) => {
|
|
||||||
console.error('Error opening application logs window:', event.payload);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error opening application logs window:', err);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MainWindow = () => {
|
export const MainWindow = () => {
|
||||||
const { settings, set: setSetting } = useSettings();
|
const { settings, set: setSetting, ready: settingsReady } = useSettings();
|
||||||
const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory();
|
const localNetworkSharing = useLocalNetworkSharing();
|
||||||
const games = useGames(rescan);
|
const identityDiagnostic = useIdentityDiagnostic();
|
||||||
const actions = useGameActions(games, settings);
|
const {
|
||||||
const thumbnails = useThumbnails();
|
gameDir,
|
||||||
const callToPlay = useCallToPlay(settings.username);
|
ready: gameDirectoryReady,
|
||||||
|
hasGameDirectory,
|
||||||
|
setGameDir,
|
||||||
|
rescan,
|
||||||
|
} = useGameDirectory(localNetworkSharing.ready);
|
||||||
|
const games = useGames(rescan);
|
||||||
|
const actions = useGameActions(games, settings);
|
||||||
|
const thumbnails = useThumbnails(games.games.map((game) => game.id));
|
||||||
|
const sharingActive = isLocalNetworkSharingActive(
|
||||||
|
localNetworkSharing.snapshot,
|
||||||
|
);
|
||||||
|
const callToPlay = useCallToPlay(settings.username, sharingActive);
|
||||||
|
const windowActionOwnerRef = useRef<AsyncOwner | null>(null);
|
||||||
|
|
||||||
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
useEffect(() => {
|
||||||
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
const owner = new AsyncOwner((error) => {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
console.error("Failed to clean up a main-window action:", error);
|
||||||
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
|
});
|
||||||
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
|
windowActionOwnerRef.current = owner;
|
||||||
const visibleGames = useMemo(
|
return () => {
|
||||||
() => hasGameDirectory ? games.games : [],
|
if (windowActionOwnerRef.current === owner) {
|
||||||
[games.games, hasGameDirectory],
|
windowActionOwnerRef.current = null;
|
||||||
);
|
}
|
||||||
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
|
windowAsyncScope.adopt(owner.dispose());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Query is local UI state (no need to persist).
|
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||||
const [query, setQuery] = useState('');
|
const supportsStreamedInstall = useStreamInstallCapability(openGameId);
|
||||||
const filteredGames = useMemo(
|
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
||||||
() => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
[visibleGames, settings.filter, settings.sort, query],
|
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
|
||||||
);
|
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
|
||||||
|
const visibleGames = useMemo(
|
||||||
|
() => hasGameDirectory ? games.games : [],
|
||||||
|
[games.games, hasGameDirectory],
|
||||||
|
);
|
||||||
|
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
|
||||||
|
|
||||||
const openGame = useMemo<Game | null>(
|
// Query is local UI state (no need to persist).
|
||||||
() => openGameId ? games.games.find(g => g.id === openGameId) ?? null : null,
|
const [query, setQuery] = useState("");
|
||||||
[openGameId, games.games],
|
const filteredGames = useMemo(
|
||||||
);
|
() =>
|
||||||
const removeGame = useMemo<Game | null>(
|
applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
|
||||||
() => removeGameId ? games.games.find(g => g.id === removeGameId) ?? null : null,
|
[visibleGames, settings.filter, settings.sort, query],
|
||||||
[removeGameId, games.games],
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const pickDirectory = useCallback(async () => {
|
const openGame = useMemo<Game | null>(
|
||||||
const picked = await open({ multiple: false, directory: true });
|
() =>
|
||||||
if (typeof picked === 'string' && picked) setGameDir(picked);
|
openGameId ? games.games.find((g) => g.id === openGameId) ?? null : null,
|
||||||
}, [setGameDir]);
|
[openGameId, games.games],
|
||||||
|
);
|
||||||
|
const removeGame = useMemo<Game | null>(
|
||||||
|
() =>
|
||||||
|
removeGameId
|
||||||
|
? games.games.find((g) => g.id === removeGameId) ?? null
|
||||||
|
: null,
|
||||||
|
[removeGameId, games.games],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePrimary = useCallback((game: Game) => {
|
const pickDirectory = useCallback(async () => {
|
||||||
if (game.installed) {
|
const owner = windowActionOwnerRef.current;
|
||||||
if (needsUpdate(game)) actions.update(game.id);
|
if (owner === null || !owner.isActive()) return;
|
||||||
else actions.play(game.id);
|
|
||||||
} else {
|
try {
|
||||||
actions.install(game.id);
|
await owner.applyIfActive(
|
||||||
|
() => open({ multiple: false, directory: true }),
|
||||||
|
(picked) => {
|
||||||
|
if (typeof picked === "string" && picked) setGameDir(picked);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (owner.isActive()) {
|
||||||
|
console.error("Failed to choose a game directory:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [setGameDir]);
|
||||||
|
|
||||||
|
const handlePrimary = useCallback((game: Game) => {
|
||||||
|
if (game.installed) {
|
||||||
|
if (needsUpdate(game)) actions.update(game.id);
|
||||||
|
else actions.play(game.id);
|
||||||
|
} else {
|
||||||
|
actions.install(game.id);
|
||||||
|
}
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const handleUninstall = useCallback((game: Game) => {
|
||||||
|
actions.uninstall(game.id);
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const handleRemoveDownload = useCallback((game: Game) => {
|
||||||
|
setRemoveGameId(game.id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmRemoveDownload = useCallback((game: Game) => {
|
||||||
|
actions.removeDownload(game.id);
|
||||||
|
setRemoveGameId(null);
|
||||||
|
setOpenGameId((current) => current === game.id ? null : current);
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [
|
||||||
|
{ kind: "item", label: "Settings", onClick: () => setSettingsOpen(true) },
|
||||||
|
{ kind: "item", label: "Refresh library", onClick: () => rescan() },
|
||||||
|
{ kind: "separator" },
|
||||||
|
{
|
||||||
|
kind: "item",
|
||||||
|
label: "Application logs",
|
||||||
|
onClick: () => {
|
||||||
|
const owner = windowActionOwnerRef.current;
|
||||||
|
if (owner) {
|
||||||
|
windowAsyncScope.adopt(openCompanionWindow(owner, MAIN_LOG_WINDOW));
|
||||||
}
|
}
|
||||||
}, [actions]);
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "item",
|
||||||
|
label: "Unpack logs",
|
||||||
|
onClick: () => {
|
||||||
|
const owner = windowActionOwnerRef.current;
|
||||||
|
if (owner) {
|
||||||
|
windowAsyncScope.adopt(openCompanionWindow(owner, UNPACK_LOG_WINDOW));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], [rescan]);
|
||||||
|
|
||||||
const handleUninstall = useCallback((game: Game) => {
|
const rootStyle = { "--accent": settings.accent } as React.CSSProperties;
|
||||||
actions.uninstall(game.id);
|
const className = [
|
||||||
}, [actions]);
|
"launcher",
|
||||||
|
`bg-${settings.bg}`,
|
||||||
|
`density-${settings.density}`,
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
const handleRemoveDownload = useCallback((game: Game) => {
|
if (
|
||||||
setRemoveGameId(game.id);
|
!settingsReady ||
|
||||||
}, []);
|
!localNetworkSharing.ready ||
|
||||||
|
!identityDiagnostic.ready ||
|
||||||
|
!gameDirectoryReady
|
||||||
|
) {
|
||||||
|
return <div className={className} style={rootStyle} aria-busy="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
const confirmRemoveDownload = useCallback((game: Game) => {
|
return (
|
||||||
actions.removeDownload(game.id);
|
<div className={className} style={rootStyle}>
|
||||||
setRemoveGameId(null);
|
<TopBar
|
||||||
setOpenGameId(current => current === game.id ? null : current);
|
accent={settings.accent}
|
||||||
}, [actions]);
|
peerCount={games.totalPeerCount}
|
||||||
|
filter={settings.filter}
|
||||||
const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [
|
setFilter={(v) => setSetting("filter", v)}
|
||||||
{ kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) },
|
counts={counts}
|
||||||
{ kind: 'item', label: 'Refresh library', onClick: () => rescan() },
|
query={query}
|
||||||
{ kind: 'separator' },
|
setQuery={setQuery}
|
||||||
{ kind: 'item', label: 'Application logs', onClick: () => void openMainLogsWindow() },
|
sort={settings.sort}
|
||||||
{ kind: 'item', label: 'Unpack logs', onClick: () => void openLogsWindow() },
|
setSort={(v) => setSetting("sort", v)}
|
||||||
], [rescan]);
|
kebabItems={kebabItems}
|
||||||
|
nominations={callToPlay.nominations}
|
||||||
const rootStyle = { '--accent': settings.accent } as React.CSSProperties;
|
onOpenCallToPlay={() => {
|
||||||
const className = [
|
setFocusedCallId(null);
|
||||||
'launcher',
|
setCallToPlayOpen(true);
|
||||||
`bg-${settings.bg}`,
|
}}
|
||||||
`density-${settings.density}`,
|
/>
|
||||||
].join(' ');
|
<main className="grid-wrap">
|
||||||
|
<div
|
||||||
return (
|
className={`network-notice network-notice-sharing is-${localNetworkSharing.snapshot.phase}`}
|
||||||
<div className={className} style={rootStyle}>
|
role="status"
|
||||||
<TopBar
|
>
|
||||||
accent={settings.accent}
|
{localNetworkSharingNotice(localNetworkSharing.snapshot)}
|
||||||
peerCount={games.totalPeerCount}
|
|
||||||
filter={settings.filter}
|
|
||||||
setFilter={(v) => setSetting('filter', v)}
|
|
||||||
counts={counts}
|
|
||||||
query={query}
|
|
||||||
setQuery={setQuery}
|
|
||||||
sort={settings.sort}
|
|
||||||
setSort={(v) => setSetting('sort', v)}
|
|
||||||
kebabItems={kebabItems}
|
|
||||||
nominations={callToPlay.nominations}
|
|
||||||
onOpenCallToPlay={() => {
|
|
||||||
setFocusedCallId(null);
|
|
||||||
setCallToPlayOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<main className="grid-wrap">
|
|
||||||
<CallToPlayTicker
|
|
||||||
nominations={callToPlay.nominations}
|
|
||||||
games={games.games}
|
|
||||||
accent={settings.accent}
|
|
||||||
onOpen={(callId) => {
|
|
||||||
setFocusedCallId(callId);
|
|
||||||
setCallToPlayOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{hasGameDirectory ? (
|
|
||||||
<>
|
|
||||||
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
|
||||||
{filteredGames.length === 0 ? (
|
|
||||||
visibleGames.length === 0 ? (
|
|
||||||
<EmptyResultsState
|
|
||||||
title="Scanning for games"
|
|
||||||
hint="Looking for game bundles in your selected directory…"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyResultsState
|
|
||||||
title="Nothing matches"
|
|
||||||
hint="No games match the current filter or search query."
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<GameGrid
|
|
||||||
games={filteredGames}
|
|
||||||
aspect={settings.aspect}
|
|
||||||
getThumbnail={thumbnails.get}
|
|
||||||
onOpen={(g) => setOpenGameId(g.id)}
|
|
||||||
onPrimary={handlePrimary}
|
|
||||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{openGame && (
|
|
||||||
<GameDetailModal
|
|
||||||
game={openGame}
|
|
||||||
thumbnailUrl={thumbnails.get(openGame.id)}
|
|
||||||
onClose={() => setOpenGameId(null)}
|
|
||||||
onPrimary={handlePrimary}
|
|
||||||
onStreamInstall={(g) => actions.streamInstall(g.id)}
|
|
||||||
onUninstall={handleUninstall}
|
|
||||||
onRemoveDownload={handleRemoveDownload}
|
|
||||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
|
||||||
onStartServer={(g) => actions.startServer(g.id)}
|
|
||||||
onViewFiles={(g) => actions.viewFiles(g.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{removeGame && (
|
|
||||||
<ConfirmRemoveDownloadModal
|
|
||||||
game={removeGame}
|
|
||||||
onCancel={() => setRemoveGameId(null)}
|
|
||||||
onConfirm={confirmRemoveDownload}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{settingsOpen && (
|
|
||||||
<SettingsDialog
|
|
||||||
settings={settings}
|
|
||||||
gameDir={gameDir}
|
|
||||||
hasGameDirectory={hasGameDirectory}
|
|
||||||
onPickDirectory={() => void pickDirectory()}
|
|
||||||
onChange={setSetting}
|
|
||||||
onClose={() => setSettingsOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{callToPlayOpen && (
|
|
||||||
<CallToPlayOverlay
|
|
||||||
nominations={callToPlay.nominations}
|
|
||||||
games={games.games}
|
|
||||||
actorId={callToPlay.actorId}
|
|
||||||
actions={callToPlay.actions}
|
|
||||||
focusId={focusedCallId}
|
|
||||||
transportReady={callToPlay.transportReady}
|
|
||||||
error={callToPlay.error}
|
|
||||||
getThumbnail={thumbnails.get}
|
|
||||||
totalPeerCount={games.totalPeerCount}
|
|
||||||
onLaunch={handlePrimary}
|
|
||||||
onClose={() => {
|
|
||||||
setCallToPlayOpen(false);
|
|
||||||
setFocusedCallId(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
{localNetworkSharing.error &&
|
||||||
|
localNetworkSharing.snapshot.persistenceProblem === null && (
|
||||||
|
<div className="network-notice is-error" role="status">
|
||||||
|
{localNetworkSharing.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{identityDiagnostic.snapshot.diagnostic === "ephemeral" && (
|
||||||
|
<div className="network-notice identity-notice" role="status">
|
||||||
|
{EPHEMERAL_IDENTITY_NOTICE}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{identityDiagnostic.unavailable && (
|
||||||
|
<div className="network-notice identity-notice" role="status">
|
||||||
|
{IDENTITY_STATUS_UNAVAILABLE_NOTICE}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{games.protocolMismatch && (
|
||||||
|
<div className="network-notice" role="status">
|
||||||
|
Nearby devices are running a different Lanspread version
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CallToPlayTicker
|
||||||
|
nominations={callToPlay.nominations}
|
||||||
|
games={games.games}
|
||||||
|
accent={settings.accent}
|
||||||
|
onOpen={(callId) => {
|
||||||
|
setFocusedCallId(callId);
|
||||||
|
setCallToPlayOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{hasGameDirectory
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
||||||
|
{filteredGames.length === 0
|
||||||
|
? (
|
||||||
|
visibleGames.length === 0
|
||||||
|
? (
|
||||||
|
<EmptyResultsState
|
||||||
|
title="Scanning for games"
|
||||||
|
hint="Looking for game bundles in your selected directory…"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<EmptyResultsState
|
||||||
|
title="Nothing matches"
|
||||||
|
hint="No games match the current filter or search query."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<GameGrid
|
||||||
|
games={filteredGames}
|
||||||
|
aspect={settings.aspect}
|
||||||
|
getThumbnail={thumbnails.get}
|
||||||
|
onOpen={(g) => setOpenGameId(g.id)}
|
||||||
|
onPrimary={handlePrimary}
|
||||||
|
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<NoDirectoryState
|
||||||
|
onChooseDirectory={() => windowAsyncScope.adopt(pickDirectory())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{openGame && (
|
||||||
|
<GameDetailModal
|
||||||
|
game={openGame}
|
||||||
|
thumbnailUrl={thumbnails.get(openGame.id)}
|
||||||
|
supportsStreamedInstall={supportsStreamedInstall}
|
||||||
|
onClose={() => setOpenGameId(null)}
|
||||||
|
onPrimary={handlePrimary}
|
||||||
|
onStreamInstall={(g) => actions.streamInstall(g.id)}
|
||||||
|
onUninstall={handleUninstall}
|
||||||
|
onRemoveDownload={handleRemoveDownload}
|
||||||
|
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||||
|
onStartServer={(g) => actions.startServer(g.id)}
|
||||||
|
onViewFiles={(g) => actions.viewFiles(g.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{removeGame && (
|
||||||
|
<ConfirmRemoveDownloadModal
|
||||||
|
game={removeGame}
|
||||||
|
onCancel={() => setRemoveGameId(null)}
|
||||||
|
onConfirm={confirmRemoveDownload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settingsOpen && (
|
||||||
|
<SettingsDialog
|
||||||
|
settings={settings}
|
||||||
|
gameDir={gameDir}
|
||||||
|
hasGameDirectory={hasGameDirectory}
|
||||||
|
onPickDirectory={() => windowAsyncScope.adopt(pickDirectory())}
|
||||||
|
onChange={setSetting}
|
||||||
|
localNetworkSharing={localNetworkSharing.snapshot}
|
||||||
|
localNetworkSharingReady={localNetworkSharing.ready}
|
||||||
|
localNetworkSharingBusy={localNetworkSharing.busy}
|
||||||
|
localNetworkSharingError={localNetworkSharing.error}
|
||||||
|
onLocalNetworkSharingChange={localNetworkSharing.setEnabled}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{callToPlayOpen && (
|
||||||
|
<CallToPlayOverlay
|
||||||
|
nominations={callToPlay.nominations}
|
||||||
|
games={games.games}
|
||||||
|
actorId={callToPlay.actorId}
|
||||||
|
actions={callToPlay.actions}
|
||||||
|
focusId={focusedCallId}
|
||||||
|
transportReady={callToPlay.transportReady && sharingActive}
|
||||||
|
error={sharingActive
|
||||||
|
? callToPlay.error
|
||||||
|
: "Local network sharing is off."}
|
||||||
|
getThumbnail={thumbnails.get}
|
||||||
|
totalPeerCount={games.totalPeerCount}
|
||||||
|
onLaunch={handlePrimary}
|
||||||
|
onClose={() => {
|
||||||
|
setCallToPlayOpen(false);
|
||||||
|
setFocusedCallId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,776 @@
|
|||||||
|
import {
|
||||||
|
AsyncAdoptionScope,
|
||||||
|
type AsyncCleanup,
|
||||||
|
AsyncOwner,
|
||||||
|
createSerializedAsyncWriter,
|
||||||
|
mergeHydratedState,
|
||||||
|
ownCompanionWindowCreation,
|
||||||
|
registerSequentially,
|
||||||
|
} from "../src/lib/asyncOwnership.ts";
|
||||||
|
|
||||||
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertArrayEquals = <T>(actual: T[], expected: T[], message: string) => {
|
||||||
|
assertEquals(JSON.stringify(actual), JSON.stringify(expected), message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deferred = <T>() => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("unmount before listener resolution cleans the late listener and stops registration", async () => {
|
||||||
|
const owner = new AsyncOwner();
|
||||||
|
const first = deferred<AsyncCleanup>();
|
||||||
|
let firstRegistrations = 0;
|
||||||
|
let secondRegistrations = 0;
|
||||||
|
let firstCleanups = 0;
|
||||||
|
|
||||||
|
const registration = registerSequentially(owner, [
|
||||||
|
() => {
|
||||||
|
firstRegistrations += 1;
|
||||||
|
return first.promise;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
secondRegistrations += 1;
|
||||||
|
return Promise.resolve(() => {});
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
firstRegistrations,
|
||||||
|
1,
|
||||||
|
"the first listener should start immediately",
|
||||||
|
);
|
||||||
|
const disposal = owner.dispose();
|
||||||
|
first.resolve(() => {
|
||||||
|
firstCleanups += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
await registration,
|
||||||
|
false,
|
||||||
|
"disposed registration should report cancellation",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
firstCleanups,
|
||||||
|
1,
|
||||||
|
"the late listener should unlisten immediately",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
secondRegistrations,
|
||||||
|
0,
|
||||||
|
"no later listener should start after disposal",
|
||||||
|
);
|
||||||
|
await disposal;
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("partial listener registration is fully cleaned across an in-flight listener", async () => {
|
||||||
|
const owner = new AsyncOwner();
|
||||||
|
const second = deferred<AsyncCleanup>();
|
||||||
|
const secondStarted = deferred<void>();
|
||||||
|
let firstCleanups = 0;
|
||||||
|
let secondCleanups = 0;
|
||||||
|
let thirdRegistrations = 0;
|
||||||
|
|
||||||
|
const registration = registerSequentially(owner, [
|
||||||
|
() =>
|
||||||
|
Promise.resolve(() => {
|
||||||
|
firstCleanups += 1;
|
||||||
|
}),
|
||||||
|
() => {
|
||||||
|
secondStarted.resolve();
|
||||||
|
return second.promise;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
thirdRegistrations += 1;
|
||||||
|
return Promise.resolve(() => {});
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await secondStarted.promise;
|
||||||
|
const disposal = owner.dispose();
|
||||||
|
assertEquals(
|
||||||
|
firstCleanups,
|
||||||
|
1,
|
||||||
|
"an owned listener should clean up during disposal",
|
||||||
|
);
|
||||||
|
|
||||||
|
second.resolve(() => {
|
||||||
|
secondCleanups += 1;
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
await registration,
|
||||||
|
false,
|
||||||
|
"the in-flight registration should stop the sequence",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
secondCleanups,
|
||||||
|
1,
|
||||||
|
"the in-flight listener should clean up when it resolves",
|
||||||
|
);
|
||||||
|
assertEquals(thirdRegistrations, 0, "the third listener should never start");
|
||||||
|
await disposal;
|
||||||
|
|
||||||
|
await owner.dispose();
|
||||||
|
assertEquals(
|
||||||
|
firstCleanups,
|
||||||
|
1,
|
||||||
|
"repeated disposal must not duplicate owned cleanup",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
secondCleanups,
|
||||||
|
1,
|
||||||
|
"repeated disposal must not duplicate late cleanup",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("listener registration failure cleans the partial scope and skips later listeners", async () => {
|
||||||
|
const owner = new AsyncOwner();
|
||||||
|
const failure = new Error("registration failed");
|
||||||
|
let firstCleanups = 0;
|
||||||
|
let thirdRegistrations = 0;
|
||||||
|
let reported: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await registerSequentially(owner, [
|
||||||
|
() =>
|
||||||
|
Promise.resolve(() => {
|
||||||
|
firstCleanups += 1;
|
||||||
|
}),
|
||||||
|
() => Promise.reject(failure),
|
||||||
|
() => {
|
||||||
|
thirdRegistrations += 1;
|
||||||
|
return Promise.resolve(() => {});
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
reported = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(reported, failure, "the registration error should be preserved");
|
||||||
|
assertEquals(
|
||||||
|
firstCleanups,
|
||||||
|
1,
|
||||||
|
"earlier listeners should clean up on setup failure",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
thirdRegistrations,
|
||||||
|
0,
|
||||||
|
"later listeners should not start after setup failure",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
owner.isActive(),
|
||||||
|
false,
|
||||||
|
"a failed registration scope should stay disposed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("late refresh results and post-disposal refreshes cannot publish", async () => {
|
||||||
|
const owner = new AsyncOwner();
|
||||||
|
const refresh = deferred<string>();
|
||||||
|
let refreshStarts = 0;
|
||||||
|
const applied: string[] = [];
|
||||||
|
|
||||||
|
const pending = owner.applyIfActive(
|
||||||
|
() => {
|
||||||
|
refreshStarts += 1;
|
||||||
|
return refresh.promise;
|
||||||
|
},
|
||||||
|
(value) => applied.push(value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const disposal = owner.dispose();
|
||||||
|
refresh.resolve("stale");
|
||||||
|
assertEquals(
|
||||||
|
await pending,
|
||||||
|
false,
|
||||||
|
"a late refresh should report cancellation",
|
||||||
|
);
|
||||||
|
assertArrayEquals(applied, [], "a late refresh must not publish state");
|
||||||
|
|
||||||
|
const afterDispose = await owner.applyIfActive(
|
||||||
|
() => {
|
||||||
|
refreshStarts += 1;
|
||||||
|
return Promise.resolve("newer");
|
||||||
|
},
|
||||||
|
(value) => applied.push(value),
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
afterDispose,
|
||||||
|
false,
|
||||||
|
"a disposed owner should reject new refresh work",
|
||||||
|
);
|
||||||
|
assertEquals(refreshStarts, 1, "refresh work must not start after disposal");
|
||||||
|
assertArrayEquals(
|
||||||
|
applied,
|
||||||
|
[],
|
||||||
|
"post-disposal refreshes must not publish state",
|
||||||
|
);
|
||||||
|
await disposal;
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("disposal joins a late asynchronous listener cleanup", async () => {
|
||||||
|
const owner = new AsyncOwner();
|
||||||
|
const listener = deferred<AsyncCleanup>();
|
||||||
|
const cleanupDone = deferred<void>();
|
||||||
|
let cleanupStarts = 0;
|
||||||
|
let disposalSettled = false;
|
||||||
|
|
||||||
|
const registration = owner.register(() => listener.promise);
|
||||||
|
const disposal = owner.dispose().then(() => {
|
||||||
|
disposalSettled = true;
|
||||||
|
});
|
||||||
|
listener.resolve(async () => {
|
||||||
|
cleanupStarts += 1;
|
||||||
|
await cleanupDone.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
assertEquals(
|
||||||
|
cleanupStarts,
|
||||||
|
1,
|
||||||
|
"the late cleanup should start immediately on resolution",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
disposalSettled,
|
||||||
|
false,
|
||||||
|
"disposal must wait for the asynchronous cleanup",
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanupDone.resolve();
|
||||||
|
assertEquals(
|
||||||
|
await registration,
|
||||||
|
false,
|
||||||
|
"the disposed registration should remain cancelled",
|
||||||
|
);
|
||||||
|
await disposal;
|
||||||
|
assertEquals(
|
||||||
|
disposalSettled,
|
||||||
|
true,
|
||||||
|
"disposal should settle after cleanup completion",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("asynchronous cleanup rejection is reported and disposal still drains", async () => {
|
||||||
|
const cleanupDone = deferred<void>();
|
||||||
|
const failure = new Error("unlisten failed");
|
||||||
|
const reported: unknown[] = [];
|
||||||
|
const owner = new AsyncOwner((error) => reported.push(error));
|
||||||
|
|
||||||
|
await owner.register(() =>
|
||||||
|
Promise.resolve(async () => {
|
||||||
|
await cleanupDone.promise;
|
||||||
|
throw failure;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const disposal = owner.dispose();
|
||||||
|
cleanupDone.resolve();
|
||||||
|
await disposal;
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
reported.length,
|
||||||
|
1,
|
||||||
|
"the cleanup failure should be reported once",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
reported[0],
|
||||||
|
failure,
|
||||||
|
"the original cleanup failure should be reported",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("a root adoption scope drains work and observes rejection", async () => {
|
||||||
|
const first = deferred<void>();
|
||||||
|
const failure = new Error("adopted cleanup failed");
|
||||||
|
const reported: unknown[] = [];
|
||||||
|
const scope = new AsyncAdoptionScope((error) => reported.push(error));
|
||||||
|
let drained = false;
|
||||||
|
|
||||||
|
scope.adopt(first.promise);
|
||||||
|
scope.adopt(Promise.reject(failure));
|
||||||
|
const drain = scope.drain().then(() => {
|
||||||
|
drained = true;
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assertEquals(drained, false, "the adoption scope should retain pending work");
|
||||||
|
assertEquals(
|
||||||
|
reported.length,
|
||||||
|
1,
|
||||||
|
"an adopted rejection should be observed once",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
reported[0],
|
||||||
|
failure,
|
||||||
|
"the adoption scope should report the original error",
|
||||||
|
);
|
||||||
|
|
||||||
|
first.resolve();
|
||||||
|
await drain;
|
||||||
|
assertEquals(
|
||||||
|
drained,
|
||||||
|
true,
|
||||||
|
"the adoption scope should drain after all work settles",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("window close drains every admitted operation and rejects late owner registration", async () => {
|
||||||
|
const scope = new AsyncAdoptionScope();
|
||||||
|
const cleanupDone = deferred<void>();
|
||||||
|
const unrelatedInvoke = deferred<string>();
|
||||||
|
const owner = new AsyncOwner(() => {}, scope);
|
||||||
|
let cleanupStarts = 0;
|
||||||
|
|
||||||
|
await owner.register(() =>
|
||||||
|
Promise.resolve(async () => {
|
||||||
|
cleanupStarts += 1;
|
||||||
|
await cleanupDone.promise;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const unrelated = owner.applyIfActive(
|
||||||
|
() => unrelatedInvoke.promise,
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
let closeDrained = false;
|
||||||
|
const close = scope.disposeOwned().then(() => {
|
||||||
|
closeDrained = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lateOwner = new AsyncOwner(() => {}, scope);
|
||||||
|
assertEquals(
|
||||||
|
lateOwner.isActive(),
|
||||||
|
false,
|
||||||
|
"owner admission must close synchronously",
|
||||||
|
);
|
||||||
|
assertEquals(cleanupStarts, 1, "listener cleanup should start during close");
|
||||||
|
assertEquals(closeDrained, false, "window close must await listener cleanup");
|
||||||
|
|
||||||
|
cleanupDone.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
assertEquals(
|
||||||
|
closeDrained,
|
||||||
|
false,
|
||||||
|
"an admitted invoke must retain the webview after listener cleanup",
|
||||||
|
);
|
||||||
|
|
||||||
|
unrelatedInvoke.resolve("settled during close");
|
||||||
|
assertEquals(
|
||||||
|
await unrelated,
|
||||||
|
false,
|
||||||
|
"unrelated late invoke publication stays suppressed",
|
||||||
|
);
|
||||||
|
await close;
|
||||||
|
assertEquals(
|
||||||
|
closeDrained,
|
||||||
|
true,
|
||||||
|
"window close must join every admitted operation",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("closing during a dialog prevents its chained invoke from starting", async () => {
|
||||||
|
const scope = new AsyncAdoptionScope();
|
||||||
|
const owner = new AsyncOwner(() => {}, scope);
|
||||||
|
const dialog = deferred<boolean>();
|
||||||
|
let invokes = 0;
|
||||||
|
|
||||||
|
const action = (async () => {
|
||||||
|
let confirmed = false;
|
||||||
|
if (
|
||||||
|
!await owner.applyIfActive(
|
||||||
|
() => dialog.promise,
|
||||||
|
(answer) => {
|
||||||
|
confirmed = answer;
|
||||||
|
},
|
||||||
|
) || !confirmed
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await owner.applyIfActive(
|
||||||
|
() => {
|
||||||
|
invokes += 1;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const close = scope.disposeOwned();
|
||||||
|
dialog.resolve(true);
|
||||||
|
await action;
|
||||||
|
await close;
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
invokes,
|
||||||
|
0,
|
||||||
|
"a dialog result must not start work after close admission",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("closing during a helper import prevents later helper side effects", async () => {
|
||||||
|
const scope = new AsyncAdoptionScope();
|
||||||
|
const owner = new AsyncOwner(() => {}, scope);
|
||||||
|
const imported = deferred<string>();
|
||||||
|
let helperStarts = 0;
|
||||||
|
|
||||||
|
const helper = (async () => {
|
||||||
|
let moduleName: string | undefined;
|
||||||
|
if (
|
||||||
|
!await owner.applyIfActive(
|
||||||
|
() => imported.promise,
|
||||||
|
(value) => {
|
||||||
|
moduleName = value;
|
||||||
|
},
|
||||||
|
) || moduleName === undefined
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await owner.applyIfActive(
|
||||||
|
() => {
|
||||||
|
helperStarts += 1;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const close = scope.disposeOwned();
|
||||||
|
imported.resolve("window helper");
|
||||||
|
await helper;
|
||||||
|
await close;
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
helperStarts,
|
||||||
|
0,
|
||||||
|
"a late import must not focus or create a window",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("close after companion construction drains creation and both listeners before destroy", async () => {
|
||||||
|
const scope = new AsyncAdoptionScope();
|
||||||
|
const owner = new AsyncOwner(() => {}, scope);
|
||||||
|
const createdCleanupDone = deferred<void>();
|
||||||
|
const errorCleanupDone = deferred<void>();
|
||||||
|
const bothCleanupsStarted = deferred<void>();
|
||||||
|
const companionDestroyStarted = deferred<void>();
|
||||||
|
const companionDestroyDone = deferred<void>();
|
||||||
|
const order: string[] = [];
|
||||||
|
let createdHandler: (() => void) | undefined;
|
||||||
|
let errorHandler: ((payload: unknown) => void) | undefined;
|
||||||
|
let cleanupStarts = 0;
|
||||||
|
let latePublications = 0;
|
||||||
|
let rootDestroyed = false;
|
||||||
|
|
||||||
|
const noteCleanupStart = () => {
|
||||||
|
cleanupStarts += 1;
|
||||||
|
if (cleanupStarts === 2) bothCleanupsStarted.resolve();
|
||||||
|
};
|
||||||
|
const creation = ownCompanionWindowCreation(owner, {
|
||||||
|
registerCreated: (handler) => {
|
||||||
|
createdHandler = handler;
|
||||||
|
return Promise.resolve(async () => {
|
||||||
|
order.push("created-listener-cleanup");
|
||||||
|
noteCleanupStart();
|
||||||
|
await createdCleanupDone.promise;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
registerError: (handler) => {
|
||||||
|
errorHandler = handler;
|
||||||
|
return Promise.resolve(async () => {
|
||||||
|
order.push("error-listener-cleanup");
|
||||||
|
noteCleanupStart();
|
||||||
|
await errorCleanupDone.promise;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
destroy: async () => {
|
||||||
|
order.push("companion-destroy");
|
||||||
|
companionDestroyStarted.resolve();
|
||||||
|
await companionDestroyDone.promise;
|
||||||
|
},
|
||||||
|
}).then((result) => {
|
||||||
|
if (owner.isActive()) latePublications += 1;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
scope.adopt(creation);
|
||||||
|
|
||||||
|
if (createdHandler === undefined || errorHandler === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"construction must synchronously begin both creation listeners",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const close = scope.disposeOwned().then(() => {
|
||||||
|
order.push("root-destroy");
|
||||||
|
rootDestroyed = true;
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
assertEquals(
|
||||||
|
rootDestroyed,
|
||||||
|
false,
|
||||||
|
"close must wait for the native creation outcome",
|
||||||
|
);
|
||||||
|
|
||||||
|
order.push("created-event");
|
||||||
|
createdHandler();
|
||||||
|
errorHandler(new Error("late losing outcome"));
|
||||||
|
await bothCleanupsStarted.promise;
|
||||||
|
assertEquals(
|
||||||
|
rootDestroyed,
|
||||||
|
false,
|
||||||
|
"root destruction must wait for both native unlisten acknowledgements",
|
||||||
|
);
|
||||||
|
|
||||||
|
createdCleanupDone.resolve();
|
||||||
|
errorCleanupDone.resolve();
|
||||||
|
await companionDestroyStarted.promise;
|
||||||
|
assertEquals(
|
||||||
|
rootDestroyed,
|
||||||
|
false,
|
||||||
|
"a companion created after close must be destroyed before its parent realm",
|
||||||
|
);
|
||||||
|
companionDestroyDone.resolve();
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
(await creation).kind,
|
||||||
|
"created",
|
||||||
|
"the first native outcome must win exactly once",
|
||||||
|
);
|
||||||
|
await close;
|
||||||
|
assertEquals(
|
||||||
|
latePublications,
|
||||||
|
0,
|
||||||
|
"late creation must not publish into disposed React state",
|
||||||
|
);
|
||||||
|
assertArrayEquals(
|
||||||
|
order,
|
||||||
|
[
|
||||||
|
"created-event",
|
||||||
|
"created-listener-cleanup",
|
||||||
|
"error-listener-cleanup",
|
||||||
|
"companion-destroy",
|
||||||
|
"root-destroy",
|
||||||
|
],
|
||||||
|
"creation, listener cleanup, companion destruction, and root destruction stay ordered",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("listener-first bootstrap keeps an update that arrives during the initial refresh", async () => {
|
||||||
|
const owner = new AsyncOwner();
|
||||||
|
const initial = deferred<string>();
|
||||||
|
const update = deferred<string>();
|
||||||
|
const published: string[] = [];
|
||||||
|
let emitUpdate: (() => Promise<boolean>) | undefined;
|
||||||
|
const order: string[] = [];
|
||||||
|
|
||||||
|
await owner.register(() => {
|
||||||
|
order.push("listener");
|
||||||
|
emitUpdate = () =>
|
||||||
|
owner.applyLatestIfActive(
|
||||||
|
() => update.promise,
|
||||||
|
(value) => published.push(value),
|
||||||
|
);
|
||||||
|
return Promise.resolve(() => {});
|
||||||
|
});
|
||||||
|
order.push("snapshot");
|
||||||
|
const initialRefresh = owner.applyLatestIfActive(
|
||||||
|
() => initial.promise,
|
||||||
|
(value) => published.push(value),
|
||||||
|
);
|
||||||
|
const eventRefresh = emitUpdate?.();
|
||||||
|
if (eventRefresh === undefined) throw new Error("listener was not installed");
|
||||||
|
|
||||||
|
initial.resolve("stale-initial");
|
||||||
|
assertEquals(
|
||||||
|
await initialRefresh,
|
||||||
|
false,
|
||||||
|
"the event should supersede the bootstrap snapshot",
|
||||||
|
);
|
||||||
|
update.resolve("event-update");
|
||||||
|
assertEquals(await eventRefresh, true, "the event refresh should publish");
|
||||||
|
|
||||||
|
assertArrayEquals(
|
||||||
|
order,
|
||||||
|
["listener", "snapshot"],
|
||||||
|
"the listener must precede the snapshot",
|
||||||
|
);
|
||||||
|
assertArrayEquals(
|
||||||
|
published,
|
||||||
|
["event-update"],
|
||||||
|
"the bootstrap interval must not lose updates",
|
||||||
|
);
|
||||||
|
await owner.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("out-of-order refresh completion publishes only the latest request", async () => {
|
||||||
|
const owner = new AsyncOwner();
|
||||||
|
const older = deferred<string>();
|
||||||
|
const newer = deferred<string>();
|
||||||
|
const published: string[] = [];
|
||||||
|
|
||||||
|
const olderRefresh = owner.applyLatestIfActive(
|
||||||
|
() => older.promise,
|
||||||
|
(value) => published.push(value),
|
||||||
|
);
|
||||||
|
const newerRefresh = owner.applyLatestIfActive(
|
||||||
|
() => newer.promise,
|
||||||
|
(value) => published.push(value),
|
||||||
|
);
|
||||||
|
|
||||||
|
newer.resolve("newer");
|
||||||
|
assertEquals(await newerRefresh, true, "the newest refresh should publish");
|
||||||
|
older.resolve("older");
|
||||||
|
assertEquals(
|
||||||
|
await olderRefresh,
|
||||||
|
false,
|
||||||
|
"the older refresh should be suppressed",
|
||||||
|
);
|
||||||
|
assertArrayEquals(
|
||||||
|
published,
|
||||||
|
["newer"],
|
||||||
|
"older completion must not overwrite newer state",
|
||||||
|
);
|
||||||
|
await owner.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("settings hydration keeps saved fields while applying newer user edits", () => {
|
||||||
|
const restored = {
|
||||||
|
accent: "saved-accent",
|
||||||
|
density: "saved-density",
|
||||||
|
username: "saved-user",
|
||||||
|
};
|
||||||
|
const pendingEdits = { accent: "user-edit" };
|
||||||
|
|
||||||
|
const merged = mergeHydratedState(restored, pendingEdits);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
merged.accent,
|
||||||
|
"user-edit",
|
||||||
|
"the newer edit should win its field",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
merged.density,
|
||||||
|
"saved-density",
|
||||||
|
"unrelated saved fields must survive",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
merged.username,
|
||||||
|
"saved-user",
|
||||||
|
"the full saved baseline must be retained",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("serialized writes do not start a newer value before the older value settles", async () => {
|
||||||
|
const firstDone = deferred<void>();
|
||||||
|
const firstStarted = deferred<void>();
|
||||||
|
const secondDone = deferred<void>();
|
||||||
|
const secondStarted = deferred<void>();
|
||||||
|
const starts: number[] = [];
|
||||||
|
const writer = createSerializedAsyncWriter<number>((value) => {
|
||||||
|
starts.push(value);
|
||||||
|
if (value === 1) {
|
||||||
|
firstStarted.resolve();
|
||||||
|
return firstDone.promise;
|
||||||
|
}
|
||||||
|
secondStarted.resolve();
|
||||||
|
return secondDone.promise;
|
||||||
|
}, () => {});
|
||||||
|
|
||||||
|
const firstWrite = writer.enqueue(1);
|
||||||
|
const secondWrite = writer.enqueue(2);
|
||||||
|
await firstStarted.promise;
|
||||||
|
assertArrayEquals(
|
||||||
|
starts,
|
||||||
|
[1],
|
||||||
|
"only the oldest write should start initially",
|
||||||
|
);
|
||||||
|
|
||||||
|
firstDone.resolve();
|
||||||
|
await firstWrite;
|
||||||
|
await secondStarted.promise;
|
||||||
|
assertArrayEquals(
|
||||||
|
starts,
|
||||||
|
[1, 2],
|
||||||
|
"the newer write should start after the older write",
|
||||||
|
);
|
||||||
|
|
||||||
|
secondDone.resolve();
|
||||||
|
await secondWrite;
|
||||||
|
await writer.waitForIdle();
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("a failed serialized write is reported and does not block the newer value", async () => {
|
||||||
|
const firstDone = deferred<void>();
|
||||||
|
const firstStarted = deferred<void>();
|
||||||
|
const secondStarted = deferred<void>();
|
||||||
|
const failure = new Error("first write failed");
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
const starts: number[] = [];
|
||||||
|
const writer = createSerializedAsyncWriter<number>(async (value) => {
|
||||||
|
starts.push(value);
|
||||||
|
if (value === 1) {
|
||||||
|
firstStarted.resolve();
|
||||||
|
await firstDone.promise;
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
secondStarted.resolve();
|
||||||
|
}, (error) => errors.push(error));
|
||||||
|
|
||||||
|
const firstWrite = writer.enqueue(1);
|
||||||
|
const secondWrite = writer.enqueue(2);
|
||||||
|
await firstStarted.promise;
|
||||||
|
assertArrayEquals(
|
||||||
|
starts,
|
||||||
|
[1],
|
||||||
|
"the failed write should still own the queue first",
|
||||||
|
);
|
||||||
|
|
||||||
|
firstDone.resolve();
|
||||||
|
await firstWrite;
|
||||||
|
await secondStarted.promise;
|
||||||
|
await secondWrite;
|
||||||
|
|
||||||
|
assertArrayEquals(
|
||||||
|
starts,
|
||||||
|
[1, 2],
|
||||||
|
"a newer write should run after the failure is handled",
|
||||||
|
);
|
||||||
|
assertEquals(errors.length, 1, "the failed write should be reported once");
|
||||||
|
assertEquals(
|
||||||
|
errors[0],
|
||||||
|
failure,
|
||||||
|
"the original write failure should be reported",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("closing a writer stops admission and drains the pending write", async () => {
|
||||||
|
const pending = deferred<void>();
|
||||||
|
const started = deferred<void>();
|
||||||
|
const writes: number[] = [];
|
||||||
|
const writer = createSerializedAsyncWriter<number>(async (value) => {
|
||||||
|
writes.push(value);
|
||||||
|
started.resolve();
|
||||||
|
await pending.promise;
|
||||||
|
}, () => {});
|
||||||
|
|
||||||
|
void writer.enqueue(1);
|
||||||
|
await started.promise;
|
||||||
|
let drained = false;
|
||||||
|
const drain = writer.closeAndWait().then(() => {
|
||||||
|
drained = true;
|
||||||
|
});
|
||||||
|
await writer.enqueue(2);
|
||||||
|
|
||||||
|
assertEquals(drained, false, "close must wait for the admitted write");
|
||||||
|
assertArrayEquals(writes, [1], "close must reject newer writes");
|
||||||
|
pending.resolve();
|
||||||
|
await drain;
|
||||||
|
assertEquals(drained, true, "close should settle after the pending write");
|
||||||
|
});
|
||||||
@@ -9,13 +9,13 @@ import {
|
|||||||
phaseOf,
|
phaseOf,
|
||||||
bumpTime,
|
bumpTime,
|
||||||
normalizeTimeInput,
|
normalizeTimeInput,
|
||||||
pruneCallToPlayEvents,
|
|
||||||
readyCountOf,
|
readyCountOf,
|
||||||
reduceCallToPlayEvents,
|
reduceCallToPlayEvents,
|
||||||
|
replaceCallToPlayView,
|
||||||
sortNominations,
|
sortNominations,
|
||||||
statusOf,
|
statusOf,
|
||||||
} from '../src/lib/callToPlay.ts';
|
} from '../src/lib/callToPlay.ts';
|
||||||
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
|
import { type CallToPlayAction, type CallToPlayViewEvent } from '../src/lib/types.ts';
|
||||||
|
|
||||||
const NOW = 1_000_000;
|
const NOW = 1_000_000;
|
||||||
|
|
||||||
@@ -33,11 +33,11 @@ const event = (
|
|||||||
action: CallToPlayAction,
|
action: CallToPlayAction,
|
||||||
at = NOW,
|
at = NOW,
|
||||||
actorName = actorId,
|
actorName = actorId,
|
||||||
): CallToPlayEvent => ({
|
): CallToPlayViewEvent => ({
|
||||||
id,
|
id,
|
||||||
call_id: 'call-1',
|
call_id: 'call-1',
|
||||||
actor_id: actorId,
|
author_id: actorId,
|
||||||
actor_name: actorName,
|
author_name: actorName,
|
||||||
at,
|
at,
|
||||||
action,
|
action,
|
||||||
});
|
});
|
||||||
@@ -45,7 +45,7 @@ const event = (
|
|||||||
const create = (
|
const create = (
|
||||||
scheduledFor: number | null = null,
|
scheduledFor: number | null = null,
|
||||||
deadline = NOW + 30 * 60_000,
|
deadline = NOW + 30 * 60_000,
|
||||||
): CallToPlayEvent => event('create', 'Alice', {
|
): CallToPlayViewEvent => event('create', 'Alice', {
|
||||||
Create: {
|
Create: {
|
||||||
game_id: 'game-1',
|
game_id: 'game-1',
|
||||||
max_players: 3,
|
max_players: 3,
|
||||||
@@ -187,17 +187,17 @@ Deno.test('publish failures distinguish startup and store outcomes', () => {
|
|||||||
'peer startup message',
|
'peer startup message',
|
||||||
);
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
callToPlayPublishErrorMessage('Call to Play event is obsolete'),
|
callToPlayPublishErrorMessage('Call-to-Play call x is unknown or expired'),
|
||||||
'This Call to Play has expired or already finished.',
|
'This Call to Play has expired or already finished.',
|
||||||
'obsolete call message',
|
'obsolete call message',
|
||||||
);
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
callToPlayPublishErrorMessage('Call to Play history is missing'),
|
callToPlayPublishErrorMessage('Call-to-Play call x is already terminal'),
|
||||||
'This Call to Play has expired or already finished.',
|
'This Call to Play has expired or already finished.',
|
||||||
'missing expired history message',
|
'missing expired history message',
|
||||||
);
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
callToPlayPublishErrorMessage(new Error('Call to Play event history is full')),
|
callToPlayPublishErrorMessage(new Error('Call-to-Play local event history is full')),
|
||||||
'Call to Play has reached its active update limit. Start or cancel an active call, then try again.',
|
'Call to Play has reached its active update limit. Start or cancel an active call, then try again.',
|
||||||
'active history limit message',
|
'active history limit message',
|
||||||
);
|
);
|
||||||
@@ -210,15 +210,12 @@ Deno.test('publish failures distinguish startup and store outcomes', () => {
|
|||||||
|
|
||||||
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
|
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
|
||||||
const message = event('message-event', 'Bob', {
|
const message = event('message-event', 'Bob', {
|
||||||
SendMessage: { message_id: 'message-1', text: 'Ready?' },
|
SendMessage: { text: 'Ready?' },
|
||||||
}, NOW + 2);
|
}, NOW + 2);
|
||||||
const duplicateMessage = event('other-event', 'Bob', {
|
const events = [message, create(), message];
|
||||||
SendMessage: { message_id: 'message-1', text: 'duplicate' },
|
|
||||||
}, NOW + 3);
|
|
||||||
const events = [message, create(), message, duplicateMessage];
|
|
||||||
const [nomination] = reduceCallToPlayEvents(events, NOW + 4);
|
const [nomination] = reduceCallToPlayEvents(events, NOW + 4);
|
||||||
assertEquals(nomination.messages.length, 1, 'unique message id');
|
assertEquals(nomination.messages.length, 1, 'event IDs deduplicate messages');
|
||||||
assertEquals(nomination.messages[0].text, 'Ready?', 'first message wins');
|
assertEquals(nomination.messages[0].text, 'Ready?', 'message retained');
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('actions timestamped before creation cannot mutate a call', () => {
|
Deno.test('actions timestamped before creation cannot mutate a call', () => {
|
||||||
@@ -237,11 +234,11 @@ Deno.test('terminal calls retain complete read-only history for fifteen minutes'
|
|||||||
create(),
|
create(),
|
||||||
event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
|
event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
|
||||||
event('message', 'Bob', {
|
event('message', 'Bob', {
|
||||||
SendMessage: { message_id: 'message-1', text: 'Launching' },
|
SendMessage: { text: 'Launching' },
|
||||||
}, NOW + 2),
|
}, NOW + 2),
|
||||||
event('start', 'Alice', 'Start', NOW + 3),
|
event('start', 'Alice', 'Start', NOW + 3),
|
||||||
event('late-message', 'Bob', {
|
event('late-message', 'Bob', {
|
||||||
SendMessage: { message_id: 'message-2', text: 'Too late' },
|
SendMessage: { text: 'Too late' },
|
||||||
}, NOW + 4),
|
}, NOW + 4),
|
||||||
];
|
];
|
||||||
const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS);
|
const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS);
|
||||||
@@ -273,34 +270,13 @@ Deno.test('terminal calls sort last and do not contribute to the badge', () => {
|
|||||||
assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
|
assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('retired calls are pruned from the frontend raw event map', () => {
|
Deno.test('incoming Call to Play views replace removed author slices wholesale', () => {
|
||||||
const terminalEvents = [
|
const previous = { events: [create(), event('join', 'Bob', 'Rsvp', NOW + 1)] };
|
||||||
create(),
|
const incoming = { events: [create()] };
|
||||||
event('start', 'Alice', 'Start', NOW + 1),
|
|
||||||
];
|
|
||||||
const map = new Map(terminalEvents.map(item => [item.id, item]));
|
|
||||||
|
|
||||||
assertEquals(
|
const replaced = replaceCallToPlayView(previous, incoming);
|
||||||
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS).size,
|
assertEquals(replaced.events.length, 1, 'departed author slice is removed');
|
||||||
2,
|
assertEquals(replaced.events[0].id, 'create', 'incoming projection is authoritative');
|
||||||
'visible terminal history stays cached',
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS + 1).size,
|
|
||||||
0,
|
|
||||||
'retired terminal history is pruned',
|
|
||||||
);
|
|
||||||
|
|
||||||
const tombstone = event('terminal-only', 'Alice', 'Cancel', NOW + 1);
|
|
||||||
const tombstoneMap = new Map([[tombstone.id, tombstone]]);
|
|
||||||
assertEquals(
|
|
||||||
pruneCallToPlayEvents(
|
|
||||||
tombstoneMap,
|
|
||||||
NOW + 1 + TERMINAL_RETENTION_MS + 1,
|
|
||||||
).size,
|
|
||||||
0,
|
|
||||||
'backend tombstone is pruned too',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
|
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import {
|
||||||
|
CallToPlayAsyncScope,
|
||||||
|
type CallToPlayRetryCallback,
|
||||||
|
type CallToPlayRetryScheduler,
|
||||||
|
} from '../src/lib/callToPlayOwnership.ts';
|
||||||
|
import { type AsyncCleanup } from '../src/lib/asyncOwnership.ts';
|
||||||
|
|
||||||
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertArrayEquals = <T>(actual: T[], expected: T[], message: string) => {
|
||||||
|
assertEquals(JSON.stringify(actual), JSON.stringify(expected), message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deferred = <T>() => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ScheduledRetry {
|
||||||
|
callback: CallToPlayRetryCallback;
|
||||||
|
cancelled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ManualRetryScheduler {
|
||||||
|
private readonly scheduled: ScheduledRetry[] = [];
|
||||||
|
|
||||||
|
public readonly schedule: CallToPlayRetryScheduler = callback => {
|
||||||
|
const retry = { callback, cancelled: false };
|
||||||
|
this.scheduled.push(retry);
|
||||||
|
return () => {
|
||||||
|
retry.cancelled = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
public pendingCount(): number {
|
||||||
|
return this.scheduled.filter(retry => !retry.cancelled).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public fireNext(): Promise<void> {
|
||||||
|
const index = this.scheduled.findIndex(retry => !retry.cancelled);
|
||||||
|
if (index < 0) throw new Error('no retry is scheduled');
|
||||||
|
const [retry] = this.scheduled.splice(index, 1);
|
||||||
|
return retry.callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const noRetry: CallToPlayRetryScheduler = () => () => {};
|
||||||
|
|
||||||
|
Deno.test('queued listener callbacks are ignored and a late listener unlistens after disposal', async () => {
|
||||||
|
const scope = new CallToPlayAsyncScope(noRetry);
|
||||||
|
const listener = deferred<AsyncCleanup>();
|
||||||
|
const cleanupDone = deferred<void>();
|
||||||
|
const published: string[] = [];
|
||||||
|
let callback: ((value: string) => void) | undefined;
|
||||||
|
let unlistens = 0;
|
||||||
|
|
||||||
|
const registration = scope.registerListener(() => {
|
||||||
|
callback = scope.guard(value => published.push(value));
|
||||||
|
return listener.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
const disposal = scope.dispose();
|
||||||
|
callback?.('queued-after-dispose');
|
||||||
|
listener.resolve(async () => {
|
||||||
|
unlistens += 1;
|
||||||
|
await cleanupDone.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
assertEquals(unlistens, 1, 'the listener should unlisten as soon as registration resolves');
|
||||||
|
cleanupDone.resolve();
|
||||||
|
assertEquals(await registration, false, 'the late registration should report cancellation');
|
||||||
|
await disposal;
|
||||||
|
assertArrayEquals(published, [], 'a queued listener must not publish after disposal');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('retry attempts never overlap and stop after readiness', async () => {
|
||||||
|
const scheduler = new ManualRetryScheduler();
|
||||||
|
const scope = new CallToPlayAsyncScope(scheduler.schedule);
|
||||||
|
const first = deferred<boolean>();
|
||||||
|
const second = deferred<boolean>();
|
||||||
|
const firstStarted = deferred<void>();
|
||||||
|
const secondStarted = deferred<void>();
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
scope.startRetry(() => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts === 1) {
|
||||||
|
firstStarted.resolve();
|
||||||
|
return first.promise;
|
||||||
|
}
|
||||||
|
secondStarted.resolve();
|
||||||
|
return second.promise;
|
||||||
|
}, 2_000);
|
||||||
|
|
||||||
|
assertEquals(scheduler.pendingCount(), 1, 'starting retry should schedule one attempt');
|
||||||
|
const firstRun = scheduler.fireNext();
|
||||||
|
await firstStarted.promise;
|
||||||
|
assertEquals(attempts, 1, 'the first retry should start');
|
||||||
|
assertEquals(scheduler.pendingCount(), 0, 'no retry should queue while one is in flight');
|
||||||
|
|
||||||
|
scope.startRetry(() => Promise.resolve(false), 2_000);
|
||||||
|
assertEquals(scheduler.pendingCount(), 0, 'starting again must not overlap the in-flight retry');
|
||||||
|
|
||||||
|
first.resolve(false);
|
||||||
|
await firstRun;
|
||||||
|
assertEquals(scheduler.pendingCount(), 1, 'a failed attempt should schedule its successor');
|
||||||
|
|
||||||
|
const secondRun = scheduler.fireNext();
|
||||||
|
await secondStarted.promise;
|
||||||
|
assertEquals(attempts, 2, 'the successor should start only after the first settled');
|
||||||
|
assertEquals(scheduler.pendingCount(), 0, 'the second in-flight retry must be exclusive');
|
||||||
|
|
||||||
|
second.resolve(true);
|
||||||
|
await secondRun;
|
||||||
|
assertEquals(scheduler.pendingCount(), 0, 'readiness should stop the retry loop');
|
||||||
|
await scope.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('disposal waits for an in-flight retry attempt', async () => {
|
||||||
|
const scheduler = new ManualRetryScheduler();
|
||||||
|
const scope = new CallToPlayAsyncScope(scheduler.schedule);
|
||||||
|
const attempt = deferred<boolean>();
|
||||||
|
const started = deferred<void>();
|
||||||
|
let disposalSettled = false;
|
||||||
|
|
||||||
|
scope.startRetry(() => {
|
||||||
|
started.resolve();
|
||||||
|
return attempt.promise;
|
||||||
|
}, 2_000);
|
||||||
|
const retry = scheduler.fireNext();
|
||||||
|
await started.promise;
|
||||||
|
|
||||||
|
const disposal = scope.dispose().then(() => {
|
||||||
|
disposalSettled = true;
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
assertEquals(disposalSettled, false, 'disposal must join the running retry');
|
||||||
|
|
||||||
|
attempt.resolve(false);
|
||||||
|
await retry;
|
||||||
|
await disposal;
|
||||||
|
assertEquals(disposalSettled, true, 'disposal should settle after the retry exits');
|
||||||
|
assertEquals(scheduler.pendingCount(), 0, 'a disposed retry must not schedule a successor');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('late snapshots and actions cannot publish after their scope is disposed', async () => {
|
||||||
|
const scope = new CallToPlayAsyncScope(noRetry);
|
||||||
|
const snapshot = deferred<string>();
|
||||||
|
const action = deferred<boolean>();
|
||||||
|
const publications: string[] = [];
|
||||||
|
let snapshotStarts = 0;
|
||||||
|
let actionStarts = 0;
|
||||||
|
|
||||||
|
const pendingSnapshot = scope.applyIfActive(
|
||||||
|
() => {
|
||||||
|
snapshotStarts += 1;
|
||||||
|
return snapshot.promise;
|
||||||
|
},
|
||||||
|
value => publications.push(`snapshot:${value}`),
|
||||||
|
);
|
||||||
|
const pendingAction = scope.applyIfActive(
|
||||||
|
() => {
|
||||||
|
actionStarts += 1;
|
||||||
|
return action.promise;
|
||||||
|
},
|
||||||
|
value => publications.push(`action:${value}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const disposal = scope.dispose();
|
||||||
|
snapshot.resolve('stale');
|
||||||
|
action.resolve(true);
|
||||||
|
|
||||||
|
assertEquals(await pendingSnapshot, false, 'the late snapshot should report cancellation');
|
||||||
|
assertEquals(await pendingAction, false, 'the late action should report cancellation');
|
||||||
|
await disposal;
|
||||||
|
assertArrayEquals(publications, [], 'late work must not publish into a disposed hook');
|
||||||
|
|
||||||
|
const postDisposeAction = await scope.applyIfActive(
|
||||||
|
() => {
|
||||||
|
actionStarts += 1;
|
||||||
|
return Promise.resolve(true);
|
||||||
|
},
|
||||||
|
value => publications.push(`post-dispose:${value}`),
|
||||||
|
);
|
||||||
|
assertEquals(postDisposeAction, false, 'an action must not start after disposal');
|
||||||
|
assertEquals(snapshotStarts, 1, 'the snapshot should start exactly once');
|
||||||
|
assertEquals(actionStarts, 1, 'only the pre-disposal action should start');
|
||||||
|
assertArrayEquals(publications, [], 'post-disposal actions must not publish');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('an older action completion cannot overwrite a newer action status', async () => {
|
||||||
|
const scope = new CallToPlayAsyncScope(noRetry);
|
||||||
|
const older = deferred<boolean>();
|
||||||
|
const newer = deferred<boolean>();
|
||||||
|
const statuses: string[] = [];
|
||||||
|
|
||||||
|
const publishOlder = scope.guardLatestAction((accepted: boolean) => {
|
||||||
|
statuses.push(`older:${accepted}`);
|
||||||
|
});
|
||||||
|
const olderAction = scope.applyIfActive(
|
||||||
|
() => older.promise,
|
||||||
|
accepted => publishOlder(accepted),
|
||||||
|
);
|
||||||
|
|
||||||
|
const publishNewer = scope.guardLatestAction((accepted: boolean) => {
|
||||||
|
statuses.push(`newer:${accepted}`);
|
||||||
|
});
|
||||||
|
const newerAction = scope.applyIfActive(
|
||||||
|
() => newer.promise,
|
||||||
|
accepted => publishNewer(accepted),
|
||||||
|
);
|
||||||
|
|
||||||
|
newer.resolve(true);
|
||||||
|
assertEquals(await newerAction, true, 'the newer action should complete normally');
|
||||||
|
older.resolve(false);
|
||||||
|
assertEquals(await olderAction, true, 'the older action result should still complete');
|
||||||
|
|
||||||
|
assertArrayEquals(statuses, ['newer:true'], 'only the newest action may publish status');
|
||||||
|
await scope.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('backend mutations start in user order even when the first invoke is delayed', async () => {
|
||||||
|
const scope = new CallToPlayAsyncScope(noRetry);
|
||||||
|
const first = deferred<string>();
|
||||||
|
const second = deferred<string>();
|
||||||
|
const started: string[] = [];
|
||||||
|
const applied: string[] = [];
|
||||||
|
|
||||||
|
const firstMutation = scope.applyMutationIfActive(
|
||||||
|
() => {
|
||||||
|
started.push('display-name:older');
|
||||||
|
return first.promise;
|
||||||
|
},
|
||||||
|
value => applied.push(value),
|
||||||
|
);
|
||||||
|
const secondMutation = scope.applyMutationIfActive(
|
||||||
|
() => {
|
||||||
|
started.push('action:newer');
|
||||||
|
return second.promise;
|
||||||
|
},
|
||||||
|
value => applied.push(value),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
assertArrayEquals(started, ['display-name:older'], 'the newer mutation must remain queued');
|
||||||
|
|
||||||
|
first.resolve('older-applied');
|
||||||
|
assertEquals(await firstMutation, true, 'the first mutation should publish while active');
|
||||||
|
await Promise.resolve();
|
||||||
|
assertArrayEquals(
|
||||||
|
started,
|
||||||
|
['display-name:older', 'action:newer'],
|
||||||
|
'the second backend invoke must start after the first settles',
|
||||||
|
);
|
||||||
|
|
||||||
|
second.resolve('newer-applied');
|
||||||
|
assertEquals(await secondMutation, true, 'the second mutation should publish while active');
|
||||||
|
assertArrayEquals(
|
||||||
|
applied,
|
||||||
|
['older-applied', 'newer-applied'],
|
||||||
|
'mutation results should publish in backend order',
|
||||||
|
);
|
||||||
|
await scope.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('disposal drains admitted mutations and rejects later admission', async () => {
|
||||||
|
const scope = new CallToPlayAsyncScope(noRetry);
|
||||||
|
const first = deferred<void>();
|
||||||
|
const second = deferred<void>();
|
||||||
|
const started: string[] = [];
|
||||||
|
const publications: string[] = [];
|
||||||
|
|
||||||
|
const firstMutation = scope.applyMutationIfActive(
|
||||||
|
() => {
|
||||||
|
started.push('first');
|
||||||
|
return first.promise;
|
||||||
|
},
|
||||||
|
() => publications.push('first'),
|
||||||
|
);
|
||||||
|
const secondMutation = scope.applyMutationIfActive(
|
||||||
|
() => {
|
||||||
|
started.push('second');
|
||||||
|
return second.promise;
|
||||||
|
},
|
||||||
|
() => publications.push('second'),
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
const disposal = scope.dispose().then(() => {
|
||||||
|
disposed = true;
|
||||||
|
});
|
||||||
|
const rejected = await scope.applyMutationIfActive(
|
||||||
|
() => {
|
||||||
|
started.push('rejected');
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
() => publications.push('rejected'),
|
||||||
|
);
|
||||||
|
assertEquals(rejected, false, 'a mutation submitted after disposal must be rejected');
|
||||||
|
assertEquals(disposed, false, 'disposal must wait for admitted mutations');
|
||||||
|
|
||||||
|
first.resolve();
|
||||||
|
assertEquals(await firstMutation, false, 'disposed scopes suppress the first result');
|
||||||
|
await Promise.resolve();
|
||||||
|
assertArrayEquals(started, ['first', 'second'], 'the admitted successor must still run');
|
||||||
|
assertEquals(disposed, false, 'disposal must also wait for the admitted successor');
|
||||||
|
|
||||||
|
second.resolve();
|
||||||
|
assertEquals(await secondMutation, false, 'disposed scopes suppress the second result');
|
||||||
|
await disposal;
|
||||||
|
assertEquals(disposed, true, 'disposal should settle after the admitted queue drains');
|
||||||
|
assertArrayEquals(started, ['first', 'second'], 'no post-disposal mutation may start');
|
||||||
|
assertArrayEquals(publications, [], 'disposed mutation results must not publish');
|
||||||
|
});
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import {
|
||||||
|
bootstrapFrontend,
|
||||||
|
type FrontendCloseRequest,
|
||||||
|
} from '../src/lib/frontendBootstrap.ts';
|
||||||
|
|
||||||
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deferred = <T>() => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test('close-listener registration failure destroys the empty window without mounting hooks', async () => {
|
||||||
|
const registrationFailure = new Error('registration failed');
|
||||||
|
const destructionStarted = deferred<void>();
|
||||||
|
const destruction = deferred<void>();
|
||||||
|
const order: string[] = [];
|
||||||
|
const reported: unknown[] = [];
|
||||||
|
let renders = 0;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapFrontend({
|
||||||
|
registerCloseRequested: () => Promise.reject(registrationFailure),
|
||||||
|
disposeOwners: () => {
|
||||||
|
order.push('dispose owners');
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
drainPersistence: () => {
|
||||||
|
order.push('drain persistence');
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
destroyWindow: () => {
|
||||||
|
order.push('destroy window');
|
||||||
|
destructionStarted.resolve();
|
||||||
|
return destruction.promise;
|
||||||
|
},
|
||||||
|
render: () => {
|
||||||
|
renders += 1;
|
||||||
|
},
|
||||||
|
reportFailure: (_message, error) => reported.push(error),
|
||||||
|
}).then(result => {
|
||||||
|
settled = true;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
await destructionStarted.promise;
|
||||||
|
assertEquals(renders, 0, 'React and its hooks must remain unmounted');
|
||||||
|
assertEquals(settled, false, 'bootstrap must explicitly await window destruction');
|
||||||
|
assertEquals(
|
||||||
|
JSON.stringify(order),
|
||||||
|
JSON.stringify(['dispose owners', 'drain persistence', 'destroy window']),
|
||||||
|
'admissions must close and drain before destroying the empty window',
|
||||||
|
);
|
||||||
|
|
||||||
|
destruction.resolve();
|
||||||
|
assertEquals(await bootstrap, false, 'registration failure must report no render');
|
||||||
|
assertEquals(renders, 0, 'hooks must never mount after failure cleanup');
|
||||||
|
assertEquals(reported.length, 1, 'the registration failure must be handled once');
|
||||||
|
assertEquals(reported[0], registrationFailure, 'the original failure must be reported');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('bootstrap handles failure of the fail-closed destroy action', async () => {
|
||||||
|
const registrationFailure = new Error('registration failed');
|
||||||
|
const destructionFailure = new Error('destroy failed');
|
||||||
|
const reported: unknown[] = [];
|
||||||
|
let renders = 0;
|
||||||
|
|
||||||
|
const rendered = await bootstrapFrontend({
|
||||||
|
registerCloseRequested: () => Promise.reject(registrationFailure),
|
||||||
|
disposeOwners: () => Promise.resolve(),
|
||||||
|
drainPersistence: () => Promise.resolve(),
|
||||||
|
destroyWindow: () => Promise.reject(destructionFailure),
|
||||||
|
render: () => {
|
||||||
|
renders += 1;
|
||||||
|
},
|
||||||
|
reportFailure: (_message, error) => reported.push(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(rendered, false, 'a failed registration must never render');
|
||||||
|
assertEquals(renders, 0, 'destroy failure must not fall back to mounting hooks');
|
||||||
|
assertEquals(reported.length, 2, 'both terminal failures must be handled');
|
||||||
|
assertEquals(reported[0], registrationFailure, 'registration failure must be preserved');
|
||||||
|
assertEquals(reported[1], destructionFailure, 'destroy failure must be observed');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('successful bootstrap mounts only after close registration resolves', async () => {
|
||||||
|
const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>();
|
||||||
|
const registrationAcknowledged = deferred<() => void>();
|
||||||
|
let renders = 0;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapFrontend({
|
||||||
|
registerCloseRequested: handler => {
|
||||||
|
registered.resolve(handler);
|
||||||
|
return registrationAcknowledged.promise;
|
||||||
|
},
|
||||||
|
disposeOwners: () => Promise.resolve(),
|
||||||
|
drainPersistence: () => Promise.resolve(),
|
||||||
|
destroyWindow: () => Promise.resolve(),
|
||||||
|
render: () => {
|
||||||
|
renders += 1;
|
||||||
|
},
|
||||||
|
reportFailure: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await registered.promise;
|
||||||
|
assertEquals(renders, 0, 'render must wait for registration acknowledgement');
|
||||||
|
registrationAcknowledged.resolve(() => {});
|
||||||
|
assertEquals(await bootstrap, true, 'successful registration should render the app');
|
||||||
|
assertEquals(renders, 1, 'the app should mount once');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('close before registration acknowledgement owns late cleanup and suppresses render', async () => {
|
||||||
|
const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>();
|
||||||
|
const registrationAcknowledged = deferred<() => void>();
|
||||||
|
const cleanupDone = deferred<void>();
|
||||||
|
const order: string[] = [];
|
||||||
|
let renders = 0;
|
||||||
|
let prevented = 0;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapFrontend({
|
||||||
|
registerCloseRequested: handler => {
|
||||||
|
registered.resolve(handler);
|
||||||
|
return registrationAcknowledged.promise;
|
||||||
|
},
|
||||||
|
disposeOwners: () => {
|
||||||
|
order.push('dispose owners');
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
drainPersistence: () => {
|
||||||
|
order.push('drain persistence');
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
destroyWindow: () => {
|
||||||
|
order.push('destroy window');
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
render: () => {
|
||||||
|
renders += 1;
|
||||||
|
},
|
||||||
|
reportFailure: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = (await registered.promise)({
|
||||||
|
preventDefault: () => {
|
||||||
|
prevented += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assertEquals(prevented, 1, 'the pre-acknowledgement close must be intercepted');
|
||||||
|
assertEquals(renders, 0, 'React must remain unmounted while registration is unresolved');
|
||||||
|
|
||||||
|
registrationAcknowledged.resolve(async () => {
|
||||||
|
order.push('unlisten');
|
||||||
|
await cleanupDone.promise;
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
assertEquals(renders, 0, 'registration acknowledgement must not render after close began');
|
||||||
|
assertEquals(
|
||||||
|
order.includes('destroy window'),
|
||||||
|
false,
|
||||||
|
'the late listener cleanup must drain before destruction',
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanupDone.resolve();
|
||||||
|
await close;
|
||||||
|
assertEquals(await bootstrap, false, 'a pre-render close must report no mounted app');
|
||||||
|
assertEquals(renders, 0, 'a closing realm must never mount React');
|
||||||
|
assertEquals(
|
||||||
|
JSON.stringify(order),
|
||||||
|
JSON.stringify(['dispose owners', 'drain persistence', 'unlisten', 'destroy window']),
|
||||||
|
'late listener ownership must join the same close finalizer',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('render failure drains and unregisters before handled window destruction', async () => {
|
||||||
|
const renderFailure = new Error('render failed');
|
||||||
|
const unlistenFailure = new Error('unlisten failed');
|
||||||
|
const destroyFailure = new Error('destroy failed');
|
||||||
|
const ownerDrained = deferred<void>();
|
||||||
|
const persistenceDrained = deferred<void>();
|
||||||
|
const finalizerStarted = deferred<void>();
|
||||||
|
const order: string[] = [];
|
||||||
|
const reported: unknown[] = [];
|
||||||
|
let cleanups = 0;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapFrontend({
|
||||||
|
registerCloseRequested: () => {
|
||||||
|
order.push('register');
|
||||||
|
return Promise.resolve(async () => {
|
||||||
|
order.push('unlisten');
|
||||||
|
cleanups += 1;
|
||||||
|
finalizerStarted.resolve();
|
||||||
|
throw unlistenFailure;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
disposeOwners: async () => {
|
||||||
|
order.push('dispose owners');
|
||||||
|
await ownerDrained.promise;
|
||||||
|
},
|
||||||
|
drainPersistence: async () => {
|
||||||
|
order.push('drain persistence');
|
||||||
|
await persistenceDrained.promise;
|
||||||
|
},
|
||||||
|
destroyWindow: () => {
|
||||||
|
order.push('destroy window');
|
||||||
|
return Promise.reject(destroyFailure);
|
||||||
|
},
|
||||||
|
render: () => {
|
||||||
|
order.push('render');
|
||||||
|
throw renderFailure;
|
||||||
|
},
|
||||||
|
reportFailure: (_message, error) => reported.push(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
await finalizerStarted.promise;
|
||||||
|
assertEquals(cleanups, 1, 'the installed close listener must unregister exactly once');
|
||||||
|
assertEquals(
|
||||||
|
JSON.stringify(order),
|
||||||
|
JSON.stringify(['register', 'render', 'dispose owners', 'drain persistence', 'unlisten']),
|
||||||
|
'render failure must synchronously start every frontend finalizer',
|
||||||
|
);
|
||||||
|
|
||||||
|
ownerDrained.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
assertEquals(
|
||||||
|
order.includes('destroy window'),
|
||||||
|
false,
|
||||||
|
'window destruction must wait for persistence as well as owner cleanup',
|
||||||
|
);
|
||||||
|
persistenceDrained.resolve();
|
||||||
|
|
||||||
|
assertEquals(await bootstrap, false, 'a render failure must be fully handled');
|
||||||
|
assertEquals(
|
||||||
|
JSON.stringify(order),
|
||||||
|
JSON.stringify([
|
||||||
|
'register',
|
||||||
|
'render',
|
||||||
|
'dispose owners',
|
||||||
|
'drain persistence',
|
||||||
|
'unlisten',
|
||||||
|
'destroy window',
|
||||||
|
]),
|
||||||
|
'destruction must be the terminal finalizer',
|
||||||
|
);
|
||||||
|
assertEquals(reported.length, 3, 'render, cleanup, and destroy failures must be observed');
|
||||||
|
assertEquals(reported[0], renderFailure, 'the render failure must be reported first');
|
||||||
|
assertEquals(reported[1], unlistenFailure, 'listener cleanup rejection must be reported');
|
||||||
|
assertEquals(reported[2], destroyFailure, 'window destroy rejection must be reported');
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
PersistenceShutdownScope,
|
||||||
|
startAdmittedPersistenceEffect,
|
||||||
|
} from '../src/lib/frontendPersistence.ts';
|
||||||
|
|
||||||
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deferred = <T>() => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test('window close drains admitted persistence and rejects later registration', async () => {
|
||||||
|
const first = deferred<void>();
|
||||||
|
const starts: string[] = [];
|
||||||
|
const scope = new PersistenceShutdownScope();
|
||||||
|
|
||||||
|
scope.register(() => {
|
||||||
|
starts.push('first');
|
||||||
|
return first.promise;
|
||||||
|
});
|
||||||
|
let drained = false;
|
||||||
|
const drain = scope.closeAndDrain().then(() => {
|
||||||
|
drained = true;
|
||||||
|
});
|
||||||
|
const lateRegistration = scope.register(() => {
|
||||||
|
starts.push('late');
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(lateRegistration, undefined, 'closed persistence admission must stay closed');
|
||||||
|
assertEquals(JSON.stringify(starts), JSON.stringify(['first']), 'late queues must not start');
|
||||||
|
assertEquals(drained, false, 'close must remain pending while persistence is active');
|
||||||
|
first.resolve();
|
||||||
|
await drain;
|
||||||
|
assertEquals(drained, true, 'close should settle after every admitted queue');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('settings hydration cannot start after close wins before its passive effect', async () => {
|
||||||
|
const scope = new PersistenceShutdownScope();
|
||||||
|
await scope.closeAndDrain();
|
||||||
|
let storeLoads = 0;
|
||||||
|
let publications = 0;
|
||||||
|
|
||||||
|
const cleanup = startAdmittedPersistenceEffect(
|
||||||
|
scope,
|
||||||
|
() => Promise.resolve(),
|
||||||
|
() => {
|
||||||
|
storeLoads += 1;
|
||||||
|
publications += 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
assertEquals(storeLoads, 0, 'closed admission must prevent the settings store load');
|
||||||
|
assertEquals(publications, 0, 'closed admission must prevent settings publication');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('game-directory hydration cannot load or invoke after pre-effect close', async () => {
|
||||||
|
const scope = new PersistenceShutdownScope();
|
||||||
|
await scope.closeAndDrain();
|
||||||
|
let storeLoads = 0;
|
||||||
|
let backendInvokes = 0;
|
||||||
|
|
||||||
|
const cleanup = startAdmittedPersistenceEffect(
|
||||||
|
scope,
|
||||||
|
() => Promise.resolve(),
|
||||||
|
() => {
|
||||||
|
storeLoads += 1;
|
||||||
|
backendInvokes += 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
assertEquals(storeLoads, 0, 'closed admission must prevent the directory store load');
|
||||||
|
assertEquals(backendInvokes, 0, 'closed admission must prevent the directory invoke');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('persistence shutdown rejection is observed without blocking other queues', async () => {
|
||||||
|
const failure = new Error('persistence drain failed');
|
||||||
|
const reported: unknown[] = [];
|
||||||
|
const scope = new PersistenceShutdownScope(error => reported.push(error));
|
||||||
|
let secondFinished = false;
|
||||||
|
|
||||||
|
scope.register(() => Promise.reject(failure));
|
||||||
|
scope.register(async () => {
|
||||||
|
secondFinished = true;
|
||||||
|
});
|
||||||
|
await scope.closeAndDrain();
|
||||||
|
|
||||||
|
assertEquals(reported.length, 1, 'the failed shutdown should be reported once');
|
||||||
|
assertEquals(reported[0], failure, 'the original shutdown failure should be reported');
|
||||||
|
assertEquals(secondFinished, true, 'one failure must not skip another persistence queue');
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
acceptGameDirectory,
|
||||||
|
hydrateGameDirectory,
|
||||||
|
} from '../src/lib/gameDirectory.ts';
|
||||||
|
|
||||||
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deferred = <T>() => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test('game directory is neither persisted nor used before backend acknowledgement', async () => {
|
||||||
|
const acknowledgement = deferred<unknown>();
|
||||||
|
const persisted: string[] = [];
|
||||||
|
let displayedPath = '/old/path';
|
||||||
|
|
||||||
|
const update = acceptGameDirectory('/picked/../path', {
|
||||||
|
updateBackend: requestedPath => {
|
||||||
|
assertEquals(
|
||||||
|
requestedPath,
|
||||||
|
'/picked/../path',
|
||||||
|
'backend should receive the requested path',
|
||||||
|
);
|
||||||
|
return acknowledgement.promise;
|
||||||
|
},
|
||||||
|
persist: acceptedPath => {
|
||||||
|
persisted.push(acceptedPath);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
reportPersistenceError: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(displayedPath, '/old/path', 'pending update should retain the old UI path');
|
||||||
|
assertEquals(persisted.length, 0, 'pending update should not write the requested path');
|
||||||
|
|
||||||
|
acknowledgement.resolve('/canonical/path');
|
||||||
|
displayedPath = await update;
|
||||||
|
|
||||||
|
assertEquals(displayedPath, '/canonical/path', 'UI should use the acknowledged path');
|
||||||
|
assertEquals(persisted.length, 1, 'acknowledged path should be persisted once');
|
||||||
|
assertEquals(persisted[0], '/canonical/path', 'store should receive the canonical path');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('backend rejection leaves the old game directory untouched', async () => {
|
||||||
|
const rejected = new Error('directory rejected');
|
||||||
|
const persisted: string[] = [];
|
||||||
|
let displayedPath = '/old/path';
|
||||||
|
|
||||||
|
try {
|
||||||
|
displayedPath = await acceptGameDirectory('/rejected/path', {
|
||||||
|
updateBackend: () => Promise.reject(rejected),
|
||||||
|
persist: acceptedPath => {
|
||||||
|
persisted.push(acceptedPath);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
reportPersistenceError: () => {},
|
||||||
|
});
|
||||||
|
throw new Error('expected backend rejection');
|
||||||
|
} catch (error) {
|
||||||
|
assertEquals(error, rejected, 'backend error should be preserved');
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(displayedPath, '/old/path', 'rejection should retain the old UI path');
|
||||||
|
assertEquals(persisted.length, 0, 'rejection should not change persistent state');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('invalid backend acknowledgement is not persisted', async () => {
|
||||||
|
let persisted = false;
|
||||||
|
let rejected = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await acceptGameDirectory('/picked/path', {
|
||||||
|
updateBackend: () => Promise.resolve(' '),
|
||||||
|
persist: () => {
|
||||||
|
persisted = true;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
reportPersistenceError: () => {},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(rejected, true, 'empty acknowledgement should be rejected');
|
||||||
|
assertEquals(persisted, false, 'invalid acknowledgement should not be persisted');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('persistence failure does not undo an acknowledged backend update', async () => {
|
||||||
|
const persistenceError = new Error('store unavailable');
|
||||||
|
let reportedError: unknown;
|
||||||
|
|
||||||
|
const acceptedPath = await acceptGameDirectory('/picked/path', {
|
||||||
|
updateBackend: () => Promise.resolve('/canonical/path'),
|
||||||
|
persist: () => Promise.reject(persistenceError),
|
||||||
|
reportPersistenceError: error => {
|
||||||
|
reportedError = error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(acceptedPath, '/canonical/path', 'backend acknowledgement should remain accepted');
|
||||||
|
assertEquals(reportedError, persistenceError, 'persistence failure should be reported');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('saved game-directory hydration settles only after backend acceptance', async () => {
|
||||||
|
const acceptance = deferred<void>();
|
||||||
|
let settled = false;
|
||||||
|
const hydration = hydrateGameDirectory({
|
||||||
|
loadSavedPath: () => Promise.resolve('/saved/path'),
|
||||||
|
acceptSavedPath: path => {
|
||||||
|
assertEquals(path, '/saved/path', 'saved path should be restored');
|
||||||
|
return acceptance.promise;
|
||||||
|
},
|
||||||
|
reportLoadError: () => {},
|
||||||
|
}).then(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
assertEquals(settled, false, 'hydration must wait for backend acceptance');
|
||||||
|
acceptance.resolve();
|
||||||
|
await hydration;
|
||||||
|
assertEquals(settled, true, 'hydration should settle after acceptance');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('missing or failed game-directory state still completes hydration', async () => {
|
||||||
|
let accepts = 0;
|
||||||
|
await hydrateGameDirectory({
|
||||||
|
loadSavedPath: () => Promise.resolve(undefined),
|
||||||
|
acceptSavedPath: () => {
|
||||||
|
accepts += 1;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
reportLoadError: () => {},
|
||||||
|
});
|
||||||
|
assertEquals(accepts, 0, 'missing state should not invoke the backend');
|
||||||
|
|
||||||
|
const failure = new Error('store unavailable');
|
||||||
|
let reported: unknown;
|
||||||
|
await hydrateGameDirectory({
|
||||||
|
loadSavedPath: () => Promise.reject(failure),
|
||||||
|
acceptSavedPath: () => Promise.resolve(),
|
||||||
|
reportLoadError: error => {
|
||||||
|
reported = error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assertEquals(reported, failure, 'load failure should be reported before ready');
|
||||||
|
});
|
||||||
@@ -1,286 +1,418 @@
|
|||||||
import {
|
import {
|
||||||
actionLabel,
|
actionLabel,
|
||||||
activeStatusById,
|
activeStatusById,
|
||||||
applyFilterAndSort,
|
applyFilterAndSort,
|
||||||
canStreamInstall,
|
applyGameTransferStatusSnapshot,
|
||||||
countByFilter,
|
canStreamInstall,
|
||||||
deriveState,
|
countByFilter,
|
||||||
downloadProgressPercent,
|
deriveState,
|
||||||
formatDownloadBytes,
|
downloadProgressPercent,
|
||||||
formatBytesPerSecond,
|
formatBytesPerSecond,
|
||||||
formatDownloadEta,
|
formatDownloadBytes,
|
||||||
formatDownloadSpeed,
|
formatDownloadEta,
|
||||||
formatDownloadSpeedShort,
|
formatDownloadSpeed,
|
||||||
gameStatusLabel,
|
formatDownloadSpeedShort,
|
||||||
mergeGameUpdate,
|
gameStatusLabel,
|
||||||
stateChipLabel,
|
mergeGameUpdate,
|
||||||
} from '../src/lib/gameState.ts';
|
stateChipLabel,
|
||||||
|
} from "../src/lib/gameState.ts";
|
||||||
import {
|
import {
|
||||||
ActiveOperationKind,
|
ActiveOperationKind,
|
||||||
GameAvailability,
|
type Game,
|
||||||
InstallStatus,
|
GameAvailability,
|
||||||
type Game,
|
GameTransferStatus,
|
||||||
} from '../src/lib/types.ts';
|
InstallStatus,
|
||||||
|
} from "../src/lib/types.ts";
|
||||||
|
|
||||||
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||||
if (actual !== expected) {
|
if (actual !== expected) {
|
||||||
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const game = (overrides: Partial<Game> = {}): Game => ({
|
const game = (overrides: Partial<Game> = {}): Game => ({
|
||||||
id: 'game',
|
id: "game",
|
||||||
name: 'Game',
|
name: "Game",
|
||||||
description: '',
|
description: "",
|
||||||
size: 0,
|
size: 0,
|
||||||
downloaded: false,
|
downloaded: false,
|
||||||
installed: false,
|
installed: false,
|
||||||
availability: GameAvailability.LocalOnly,
|
availability: GameAvailability.LocalOnly,
|
||||||
install_status: InstallStatus.NotInstalled,
|
install_status: InstallStatus.NotInstalled,
|
||||||
|
peer_count: 1,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("snapshot keeps installing visible until installed state settles", () => {
|
||||||
|
const fromDownloading = game({
|
||||||
|
install_status: InstallStatus.Downloading,
|
||||||
|
});
|
||||||
|
const installing = mergeGameUpdate(
|
||||||
|
game({ downloaded: true }),
|
||||||
|
fromDownloading,
|
||||||
|
InstallStatus.Installing,
|
||||||
|
);
|
||||||
|
const installedWhileActive = mergeGameUpdate(
|
||||||
|
game({ downloaded: true, installed: true }),
|
||||||
|
installing,
|
||||||
|
InstallStatus.Installing,
|
||||||
|
);
|
||||||
|
const settled = mergeGameUpdate(
|
||||||
|
game({ downloaded: true, installed: true }),
|
||||||
|
installedWhileActive,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
installing.install_status,
|
||||||
|
InstallStatus.Installing,
|
||||||
|
"active install snapshot should render Installing",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
installedWhileActive.install_status,
|
||||||
|
InstallStatus.Installing,
|
||||||
|
"installed local state should not override an active install snapshot",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
settled.install_status,
|
||||||
|
InstallStatus.Installed,
|
||||||
|
"cleared active snapshot with installed local state should render Installed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("active operation snapshot is the source of busy status", () => {
|
||||||
|
const statuses = activeStatusById([
|
||||||
|
{ id: "game", operation: ActiveOperationKind.Downloading },
|
||||||
|
{ id: "other", operation: ActiveOperationKind.Updating },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
statuses.get("game"),
|
||||||
|
InstallStatus.Downloading,
|
||||||
|
"download operation should render Downloading",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
statuses.get("other"),
|
||||||
|
InstallStatus.Installing,
|
||||||
|
"update operation should render Installing",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("download progress is preserved only while actively downloading", () => {
|
||||||
|
const downloading = game({
|
||||||
|
install_status: InstallStatus.Downloading,
|
||||||
|
download_progress: {
|
||||||
|
attemptId: "1",
|
||||||
|
downloaded_bytes: 50,
|
||||||
|
total_bytes: 100,
|
||||||
|
bytes_per_second: 12_500_000,
|
||||||
|
active_peer_count: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stillDownloading = mergeGameUpdate(
|
||||||
|
game(),
|
||||||
|
downloading,
|
||||||
|
InstallStatus.Downloading,
|
||||||
|
);
|
||||||
|
const settled = mergeGameUpdate(game({ downloaded: true }), stillDownloading);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
stillDownloading.download_progress?.downloaded_bytes,
|
||||||
|
50,
|
||||||
|
"active download snapshot should keep progress",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
stillDownloading.download_progress?.active_peer_count,
|
||||||
|
2,
|
||||||
|
"active download snapshot should keep live peer count",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
settled.download_progress,
|
||||||
|
undefined,
|
||||||
|
"settled snapshot should clear progress",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("downloading action label includes current speed", () => {
|
||||||
|
const downloading = game({
|
||||||
|
install_status: InstallStatus.Downloading,
|
||||||
|
download_progress: {
|
||||||
|
attemptId: "1",
|
||||||
|
downloaded_bytes: 50,
|
||||||
|
total_bytes: 100,
|
||||||
|
bytes_per_second: 12_500_000,
|
||||||
|
active_peer_count: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
formatBytesPerSecond(12_500_000),
|
||||||
|
"12.5 MB/s",
|
||||||
|
"speed formatter should use compact decimal units",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
actionLabel(downloading),
|
||||||
|
"Downloading… 12.5 MB/s",
|
||||||
|
"download label should include speed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("downloading state is distinct and stays on the local filter", () => {
|
||||||
|
const downloading = game({
|
||||||
|
id: "downloading",
|
||||||
|
name: "Downloading",
|
||||||
|
install_status: InstallStatus.Downloading,
|
||||||
|
});
|
||||||
|
const local = game({
|
||||||
|
id: "local",
|
||||||
|
name: "Local",
|
||||||
|
downloaded: true,
|
||||||
|
});
|
||||||
|
const remote = game({
|
||||||
|
id: "remote",
|
||||||
|
name: "Remote",
|
||||||
peer_count: 1,
|
peer_count: 1,
|
||||||
...overrides,
|
});
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
deriveState(downloading),
|
||||||
|
"downloading",
|
||||||
|
"download operation should render the dedicated downloading state",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
countByFilter([downloading, local, remote]).local,
|
||||||
|
2,
|
||||||
|
"local filter count should include in-flight downloads",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
applyFilterAndSort([downloading, local, remote], "local", "status", "")
|
||||||
|
.length,
|
||||||
|
2,
|
||||||
|
"local filter should include in-flight downloads",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('snapshot keeps installing visible until installed state settles', () => {
|
Deno.test("sticky transfer exhaustion keeps a departed remote game visible until cleared", () => {
|
||||||
const fromDownloading = game({
|
const remoteOnly = game({
|
||||||
install_status: InstallStatus.Downloading,
|
id: "remote-only",
|
||||||
});
|
name: "Remote only",
|
||||||
const installing = mergeGameUpdate(
|
peer_count: 1,
|
||||||
game({ downloaded: true }),
|
});
|
||||||
fromDownloading,
|
assertEquals(
|
||||||
InstallStatus.Installing,
|
applyFilterAndSort([remoteOnly], "all", "az", "").length,
|
||||||
);
|
1,
|
||||||
const installedWhileActive = mergeGameUpdate(
|
"a remote-only game should initially be visible",
|
||||||
game({ downloaded: true, installed: true }),
|
);
|
||||||
installing,
|
|
||||||
InstallStatus.Installing,
|
|
||||||
);
|
|
||||||
const settled = mergeGameUpdate(
|
|
||||||
game({ downloaded: true, installed: true }),
|
|
||||||
installedWhileActive,
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(
|
const peerDeparted = mergeGameUpdate(
|
||||||
installing.install_status,
|
game({ id: "remote-only", name: "Remote only", peer_count: 0 }),
|
||||||
InstallStatus.Installing,
|
remoteOnly,
|
||||||
'active install snapshot should render Installing',
|
);
|
||||||
);
|
assertEquals(
|
||||||
assertEquals(
|
peerDeparted.install_status,
|
||||||
installedWhileActive.install_status,
|
InstallStatus.NotInstalled,
|
||||||
InstallStatus.Installing,
|
"an empty active-operation snapshot should leave the game idle",
|
||||||
'installed local state should not override an active install snapshot',
|
);
|
||||||
);
|
assertEquals(
|
||||||
assertEquals(
|
peerDeparted.peer_count,
|
||||||
settled.install_status,
|
0,
|
||||||
InstallStatus.Installed,
|
"the sole remote source should be absent from the next catalog snapshot",
|
||||||
'cleared active snapshot with installed local state should render Installed',
|
);
|
||||||
);
|
assertEquals(
|
||||||
|
applyFilterAndSort([peerDeparted], "all", "az", "").length,
|
||||||
|
0,
|
||||||
|
"an idle remote-only game should be invisible before exhaustion arrives",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [exhausted] = applyGameTransferStatusSnapshot([peerDeparted], {
|
||||||
|
revision: 1,
|
||||||
|
statuses: { "remote-only": GameTransferStatus.Exhausted },
|
||||||
|
openAttempts: {},
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
exhausted.transfer_status,
|
||||||
|
GameTransferStatus.Exhausted,
|
||||||
|
"the sticky snapshot should preserve the exact terminal status",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
applyFilterAndSort([exhausted], "all", "az", "").length,
|
||||||
|
1,
|
||||||
|
"terminal exhaustion should keep the departed remote game visible",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
countByFilter([exhausted]).all,
|
||||||
|
1,
|
||||||
|
"the All-filter count should match terminal exhaustion visibility",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [cleared] = applyGameTransferStatusSnapshot([exhausted], {
|
||||||
|
revision: 2,
|
||||||
|
statuses: {},
|
||||||
|
openAttempts: {},
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
cleared.transfer_status,
|
||||||
|
undefined,
|
||||||
|
"a full replacement that omits the game should clear exhaustion",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
applyFilterAndSort([cleared], "all", "az", "").length,
|
||||||
|
0,
|
||||||
|
"clearing exhaustion should remove the otherwise-invisible game",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('active operation snapshot is the source of busy status', () => {
|
Deno.test("transient transfer activity does not resurrect an invisible game", () => {
|
||||||
const statuses = activeStatusById([
|
const departed = game({ peer_count: 0 });
|
||||||
{ id: 'game', operation: ActiveOperationKind.Downloading },
|
for (
|
||||||
{ id: 'other', operation: ActiveOperationKind.Updating },
|
const transferStatus of [
|
||||||
]);
|
GameTransferStatus.Verifying,
|
||||||
|
GameTransferStatus.Retrying,
|
||||||
|
]
|
||||||
|
) {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
statuses.get('game'),
|
applyFilterAndSort(
|
||||||
InstallStatus.Downloading,
|
[{ ...departed, transfer_status: transferStatus }],
|
||||||
'download operation should render Downloading',
|
"all",
|
||||||
);
|
"az",
|
||||||
assertEquals(
|
"",
|
||||||
statuses.get('other'),
|
).length,
|
||||||
InstallStatus.Installing,
|
0,
|
||||||
'update operation should render Installing',
|
`${transferStatus} should not make an otherwise-invisible game visible`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('download progress is preserved only while actively downloading', () => {
|
Deno.test("download progress formatting matches the progress-bar layouts", () => {
|
||||||
const downloading = game({
|
const downloading = game({
|
||||||
install_status: InstallStatus.Downloading,
|
install_status: InstallStatus.Downloading,
|
||||||
download_progress: {
|
download_progress: {
|
||||||
downloaded_bytes: 50,
|
attemptId: "1",
|
||||||
total_bytes: 100,
|
downloaded_bytes: 12 * 1024 * 1024 * 1024,
|
||||||
bytes_per_second: 12_500_000,
|
total_bytes: 35 * 1024 * 1024 * 1024,
|
||||||
active_peer_count: 2,
|
bytes_per_second: 49_400_000,
|
||||||
},
|
active_peer_count: 3,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const stillDownloading = mergeGameUpdate(
|
assertEquals(
|
||||||
game(),
|
Math.round(downloadProgressPercent(downloading)),
|
||||||
downloading,
|
34,
|
||||||
InstallStatus.Downloading,
|
"progress percent should come from backend byte counters",
|
||||||
);
|
);
|
||||||
const settled = mergeGameUpdate(game({ downloaded: true }), stillDownloading);
|
assertEquals(
|
||||||
|
formatDownloadSpeed(49_400_000),
|
||||||
assertEquals(
|
"49.4 MB/s",
|
||||||
stillDownloading.download_progress?.downloaded_bytes,
|
"large bar speed format",
|
||||||
50,
|
);
|
||||||
'active download snapshot should keep progress',
|
assertEquals(
|
||||||
);
|
formatDownloadSpeedShort(49_400_000),
|
||||||
assertEquals(
|
"49 MB/s",
|
||||||
stillDownloading.download_progress?.active_peer_count,
|
"card speed format",
|
||||||
2,
|
);
|
||||||
'active download snapshot should keep live peer count',
|
assertEquals(
|
||||||
);
|
formatDownloadBytes(12 * 1024 * 1024 * 1024),
|
||||||
assertEquals(
|
"12 GB",
|
||||||
settled.download_progress,
|
"downloaded byte format should avoid noisy trailing decimals",
|
||||||
undefined,
|
);
|
||||||
'settled snapshot should clear progress',
|
assertEquals(
|
||||||
);
|
formatDownloadEta(485),
|
||||||
|
"8 min",
|
||||||
|
"eta format should stay compact",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('downloading action label includes current speed', () => {
|
Deno.test("stream install is available only for idle remote games", () => {
|
||||||
const downloading = game({
|
assertEquals(
|
||||||
install_status: InstallStatus.Downloading,
|
canStreamInstall(
|
||||||
download_progress: {
|
game({ downloaded: false, installed: false, peer_count: 1 }),
|
||||||
downloaded_bytes: 50,
|
true,
|
||||||
total_bytes: 100,
|
),
|
||||||
bytes_per_second: 12_500_000,
|
true,
|
||||||
active_peer_count: 2,
|
"catalog-supported remote-only idle games should allow streamed install",
|
||||||
},
|
);
|
||||||
});
|
assertEquals(
|
||||||
|
canStreamInstall(
|
||||||
assertEquals(
|
game({ downloaded: false, installed: false, peer_count: 1 }),
|
||||||
formatBytesPerSecond(12_500_000),
|
false,
|
||||||
'12.5 MB/s',
|
),
|
||||||
'speed formatter should use compact decimal units',
|
false,
|
||||||
);
|
"catalog-unsupported games should not expose streamed install",
|
||||||
assertEquals(
|
);
|
||||||
actionLabel(downloading),
|
assertEquals(
|
||||||
'Downloading… 12.5 MB/s',
|
canStreamInstall(
|
||||||
'download label should include speed',
|
game({ downloaded: true, installed: false, peer_count: 1 }),
|
||||||
);
|
true,
|
||||||
});
|
),
|
||||||
|
false,
|
||||||
Deno.test('downloading state is distinct and stays on the local filter', () => {
|
"downloaded games should install from local archives",
|
||||||
const downloading = game({
|
);
|
||||||
id: 'downloading',
|
assertEquals(
|
||||||
name: 'Downloading',
|
canStreamInstall(
|
||||||
install_status: InstallStatus.Downloading,
|
game({ downloaded: false, installed: true, peer_count: 1 }),
|
||||||
});
|
true,
|
||||||
const local = game({
|
),
|
||||||
id: 'local',
|
false,
|
||||||
name: 'Local',
|
"installed games should not expose streamed install",
|
||||||
downloaded: true,
|
);
|
||||||
});
|
assertEquals(
|
||||||
const remote = game({
|
canStreamInstall(
|
||||||
id: 'remote',
|
game({ downloaded: false, installed: false, peer_count: 0 }),
|
||||||
name: 'Remote',
|
true,
|
||||||
peer_count: 1,
|
),
|
||||||
});
|
false,
|
||||||
|
"games without peers should not expose streamed install",
|
||||||
assertEquals(
|
);
|
||||||
deriveState(downloading),
|
assertEquals(
|
||||||
'downloading',
|
canStreamInstall(
|
||||||
'download operation should render the dedicated downloading state',
|
game({
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
countByFilter([downloading, local, remote]).local,
|
|
||||||
2,
|
|
||||||
'local filter count should include in-flight downloads',
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
applyFilterAndSort([downloading, local, remote], 'local', 'status', '').length,
|
|
||||||
2,
|
|
||||||
'local filter should include in-flight downloads',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('download progress formatting matches the progress-bar layouts', () => {
|
|
||||||
const downloading = game({
|
|
||||||
install_status: InstallStatus.Downloading,
|
|
||||||
download_progress: {
|
|
||||||
downloaded_bytes: 12 * 1024 * 1024 * 1024,
|
|
||||||
total_bytes: 35 * 1024 * 1024 * 1024,
|
|
||||||
bytes_per_second: 49_400_000,
|
|
||||||
active_peer_count: 3,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
Math.round(downloadProgressPercent(downloading)),
|
|
||||||
34,
|
|
||||||
'progress percent should come from backend byte counters',
|
|
||||||
);
|
|
||||||
assertEquals(formatDownloadSpeed(49_400_000), '49.4 MB/s', 'large bar speed format');
|
|
||||||
assertEquals(formatDownloadSpeedShort(49_400_000), '49 MB/s', 'card speed format');
|
|
||||||
assertEquals(
|
|
||||||
formatDownloadBytes(12 * 1024 * 1024 * 1024),
|
|
||||||
'12 GB',
|
|
||||||
'downloaded byte format should avoid noisy trailing decimals',
|
|
||||||
);
|
|
||||||
assertEquals(formatDownloadEta(485), '8 min', 'eta format should stay compact');
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('stream install is available only for idle remote games', () => {
|
|
||||||
assertEquals(
|
|
||||||
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 1 })),
|
|
||||||
true,
|
|
||||||
'remote-only idle games should allow streamed install',
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
canStreamInstall(game({ downloaded: true, installed: false, peer_count: 1 })),
|
|
||||||
false,
|
|
||||||
'downloaded games should install from local archives',
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
canStreamInstall(game({ downloaded: false, installed: true, peer_count: 1 })),
|
|
||||||
false,
|
|
||||||
'installed games should not expose streamed install',
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 0 })),
|
|
||||||
false,
|
|
||||||
'games without peers should not expose streamed install',
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
canStreamInstall(game({
|
|
||||||
downloaded: false,
|
|
||||||
installed: false,
|
|
||||||
peer_count: 1,
|
|
||||||
install_status: InstallStatus.CheckingPeers,
|
|
||||||
})),
|
|
||||||
false,
|
|
||||||
'busy games should not expose streamed install',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('streamed local installs are labeled installed but not shareable', () => {
|
|
||||||
const streamed = game({
|
|
||||||
downloaded: false,
|
downloaded: false,
|
||||||
installed: true,
|
installed: false,
|
||||||
install_status: InstallStatus.Installed,
|
peer_count: 1,
|
||||||
});
|
install_status: InstallStatus.Installing,
|
||||||
const downloadedInstall = game({
|
}),
|
||||||
downloaded: true,
|
true,
|
||||||
installed: true,
|
),
|
||||||
install_status: InstallStatus.Installed,
|
false,
|
||||||
});
|
"busy games should not expose streamed install",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
assertEquals(
|
Deno.test("streamed local installs are labeled installed but not shareable", () => {
|
||||||
deriveState(streamed),
|
const streamed = game({
|
||||||
'installed',
|
downloaded: false,
|
||||||
'streamed local installs should keep installed visual state',
|
installed: true,
|
||||||
);
|
install_status: InstallStatus.Installed,
|
||||||
assertEquals(
|
});
|
||||||
stateChipLabel(streamed),
|
const downloadedInstall = game({
|
||||||
'Not shareable',
|
downloaded: true,
|
||||||
'card chip should make the non-shareable state visible',
|
installed: true,
|
||||||
);
|
install_status: InstallStatus.Installed,
|
||||||
assertEquals(
|
});
|
||||||
gameStatusLabel(streamed),
|
|
||||||
'Installed, not shareable',
|
assertEquals(
|
||||||
'detail status should spell out installed plus non-shareable',
|
deriveState(streamed),
|
||||||
);
|
"installed",
|
||||||
assertEquals(
|
"streamed local installs should keep installed visual state",
|
||||||
stateChipLabel(downloadedInstall),
|
);
|
||||||
'Installed',
|
assertEquals(
|
||||||
'normal downloaded installs should keep the installed chip label',
|
stateChipLabel(streamed),
|
||||||
);
|
"Not shareable",
|
||||||
assertEquals(
|
"card chip should make the non-shareable state visible",
|
||||||
gameStatusLabel(downloadedInstall),
|
);
|
||||||
'Installed',
|
assertEquals(
|
||||||
'normal downloaded installs should keep the installed detail label',
|
gameStatusLabel(streamed),
|
||||||
);
|
"Installed, not shareable",
|
||||||
|
"detail status should spell out installed plus non-shareable",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
stateChipLabel(downloadedInstall),
|
||||||
|
"Installed",
|
||||||
|
"normal downloaded installs should keep the installed chip label",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
gameStatusLabel(downloadedInstall),
|
||||||
|
"Installed",
|
||||||
|
"normal downloaded installs should keep the installed detail label",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
EPHEMERAL_IDENTITY_NOTICE,
|
||||||
|
IDENTITY_STATUS_UNAVAILABLE_NOTICE,
|
||||||
|
INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT,
|
||||||
|
type IdentityDiagnosticSnapshot,
|
||||||
|
newestIdentityDiagnosticSnapshot,
|
||||||
|
} from '../src/lib/identityDiagnostic.ts';
|
||||||
|
|
||||||
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test('newer identity event beats delayed query and a later clear wins', () => {
|
||||||
|
const delayedQuery: IdentityDiagnosticSnapshot = { revision: 1, diagnostic: null };
|
||||||
|
const ephemeral: IdentityDiagnosticSnapshot = {
|
||||||
|
revision: 2,
|
||||||
|
diagnostic: 'ephemeral',
|
||||||
|
};
|
||||||
|
const afterEvent = newestIdentityDiagnosticSnapshot(
|
||||||
|
INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT,
|
||||||
|
ephemeral,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
newestIdentityDiagnosticSnapshot(afterEvent, delayedQuery),
|
||||||
|
ephemeral,
|
||||||
|
'delayed query must lose',
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleared: IdentityDiagnosticSnapshot = { revision: 3, diagnostic: null };
|
||||||
|
assertEquals(
|
||||||
|
newestIdentityDiagnosticSnapshot(ephemeral, cleared),
|
||||||
|
cleared,
|
||||||
|
'higher-revision clear must win',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
newestIdentityDiagnosticSnapshot(cleared, ephemeral),
|
||||||
|
cleared,
|
||||||
|
'stale event must not resurrect diagnostic',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('ephemeral identity copy is exact and contains no implementation detail', () => {
|
||||||
|
assertEquals(
|
||||||
|
EPHEMERAL_IDENTITY_NOTICE,
|
||||||
|
"This installation's network identity could not be saved and will change the next time Lanspread starts.",
|
||||||
|
'identity diagnostic copy',
|
||||||
|
);
|
||||||
|
for (const forbidden of ['path', 'key', 'permission', '.json', 'error']) {
|
||||||
|
if (EPHEMERAL_IDENTITY_NOTICE.toLowerCase().includes(forbidden)) {
|
||||||
|
throw new Error(`identity copy leaked forbidden detail: ${forbidden}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('identity initialization failure has a distinct redacted warning', () => {
|
||||||
|
assertEquals(
|
||||||
|
IDENTITY_STATUS_UNAVAILABLE_NOTICE,
|
||||||
|
"This installation's network identity status could not be checked.",
|
||||||
|
'unavailable diagnostic copy',
|
||||||
|
);
|
||||||
|
if (IDENTITY_STATUS_UNAVAILABLE_NOTICE.includes('could not be saved')) {
|
||||||
|
throw new Error('unavailable status must not claim confirmed ephemeral identity');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { AsyncAdoptionScope } from '../src/lib/asyncOwnership.ts';
|
||||||
|
import {
|
||||||
|
INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
|
||||||
|
LocalNetworkSharingAsyncScope,
|
||||||
|
type LocalNetworkSharingSnapshot,
|
||||||
|
displayedSharingTarget,
|
||||||
|
isLocalNetworkSharingActive,
|
||||||
|
localNetworkSharingNotice,
|
||||||
|
newestLocalNetworkSharingSnapshot,
|
||||||
|
} from '../src/lib/localNetworkSharing.ts';
|
||||||
|
|
||||||
|
const assert = (condition: boolean, message: string) => {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = (
|
||||||
|
revision: number,
|
||||||
|
overrides: Partial<LocalNetworkSharingSnapshot> = {},
|
||||||
|
): LocalNetworkSharingSnapshot => ({
|
||||||
|
...INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
|
||||||
|
revision,
|
||||||
|
persistenceProblem: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('sharing bootstrap remains visibly fail-closed if listener and query fail', () => {
|
||||||
|
assert(!displayedSharingTarget(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT), 'fallback switch');
|
||||||
|
assert(
|
||||||
|
!isLocalNetworkSharingActive(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT),
|
||||||
|
'fallback network admission',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
localNetworkSharingNotice(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT),
|
||||||
|
'Local network sharing is off because its setting could not be loaded.',
|
||||||
|
'fallback copy',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('delayed sharing query cannot overwrite a newer listener event', () => {
|
||||||
|
const delayedQuery = snapshot(1, { enabled: false, phase: 'disabled' });
|
||||||
|
const event = snapshot(2, { enabled: true, phase: 'enabled' });
|
||||||
|
const afterEvent = newestLocalNetworkSharingSnapshot(
|
||||||
|
INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
newestLocalNetworkSharingSnapshot(afterEvent, delayedQuery),
|
||||||
|
event,
|
||||||
|
'newer listener event must win',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
newestLocalNetworkSharingSnapshot(event, { ...event, enabled: false }),
|
||||||
|
event,
|
||||||
|
'equal revisions must not rewrite state',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('pending policy target is distinct from effective network activity', () => {
|
||||||
|
const waiting = snapshot(1, {
|
||||||
|
enabled: true,
|
||||||
|
phase: 'waitingForGameDirectory',
|
||||||
|
});
|
||||||
|
assert(displayedSharingTarget(waiting), 'waiting policy should remain checked');
|
||||||
|
assert(!isLocalNetworkSharingActive(waiting), 'waiting policy must not claim active sharing');
|
||||||
|
|
||||||
|
const disabling = snapshot(2, {
|
||||||
|
enabled: true,
|
||||||
|
pendingTarget: false,
|
||||||
|
phase: 'enabled',
|
||||||
|
});
|
||||||
|
assert(!displayedSharingTarget(disabling), 'pending explicit off target should drive switch');
|
||||||
|
assert(
|
||||||
|
!isLocalNetworkSharingActive(disabling),
|
||||||
|
'an admitted off target must close network actions before the core event arrives',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('sharing notices use exact redacted state copy', () => {
|
||||||
|
assertEquals(
|
||||||
|
localNetworkSharingNotice(snapshot(1, { enabled: false, phase: 'disabled' })),
|
||||||
|
'Local network sharing is off. Nearby devices cannot browse or request games from this library.',
|
||||||
|
'disabled copy',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
localNetworkSharingNotice(snapshot(2, { phase: 'waitingForGameDirectory' })),
|
||||||
|
'Local network sharing will start after you choose a game folder.',
|
||||||
|
'waiting copy',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
localNetworkSharingNotice(snapshot(3, {
|
||||||
|
enabled: true,
|
||||||
|
pendingTarget: false,
|
||||||
|
phase: 'enabled',
|
||||||
|
})),
|
||||||
|
'Stopping Local network sharing…',
|
||||||
|
'pending-off copy before core Disabling event',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
localNetworkSharingNotice(snapshot(4, {
|
||||||
|
enabled: false,
|
||||||
|
phase: 'disabled',
|
||||||
|
persistenceProblem: 'load',
|
||||||
|
})),
|
||||||
|
'Local network sharing is off because its setting could not be loaded.',
|
||||||
|
'load failure copy',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
localNetworkSharingNotice(snapshot(5, {
|
||||||
|
enabled: false,
|
||||||
|
phase: 'disabled',
|
||||||
|
persistenceProblem: 'save',
|
||||||
|
})),
|
||||||
|
'Local network sharing is off, but this setting could not be saved.',
|
||||||
|
'save failure copy',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('sharing mutations execute in admission order', async () => {
|
||||||
|
const adoption = new AsyncAdoptionScope();
|
||||||
|
const scope = new LocalNetworkSharingAsyncScope(() => {}, adoption);
|
||||||
|
const order: string[] = [];
|
||||||
|
let releaseFirst = () => {};
|
||||||
|
const firstGate = new Promise<void>(resolve => {
|
||||||
|
releaseFirst = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = scope.applyMutationIfActive(async () => {
|
||||||
|
order.push('first-start');
|
||||||
|
await firstGate;
|
||||||
|
order.push('first-finish');
|
||||||
|
return 'first';
|
||||||
|
}, value => order.push(`${value}-apply`));
|
||||||
|
const second = scope.applyMutationIfActive(async () => {
|
||||||
|
order.push('second-start');
|
||||||
|
return 'second';
|
||||||
|
}, value => order.push(`${value}-apply`));
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
assertEquals(order, ['first-start'], 'second mutation must remain queued');
|
||||||
|
releaseFirst();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
assertEquals(
|
||||||
|
order,
|
||||||
|
['first-start', 'first-finish', 'first-apply', 'second-start', 'second-apply'],
|
||||||
|
'mutation order',
|
||||||
|
);
|
||||||
|
await scope.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('failed mutation does not poison its admitted successor', async () => {
|
||||||
|
const adoption = new AsyncAdoptionScope();
|
||||||
|
const scope = new LocalNetworkSharingAsyncScope(() => {}, adoption);
|
||||||
|
const order: string[] = [];
|
||||||
|
const first = scope.applyMutationIfActive(async () => {
|
||||||
|
order.push('first');
|
||||||
|
throw new Error('injected');
|
||||||
|
}, () => order.push('unexpected-apply'));
|
||||||
|
const second = scope.applyMutationIfActive(async () => {
|
||||||
|
order.push('second');
|
||||||
|
return true;
|
||||||
|
}, () => order.push('second-apply'));
|
||||||
|
|
||||||
|
await first.catch(() => false);
|
||||||
|
assert(await second, 'successor should still publish');
|
||||||
|
assertEquals(order, ['first', 'second', 'second-apply'], 'successor ordering');
|
||||||
|
await scope.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('sharing scope drains admitted mutation and rejects late work on dispose', async () => {
|
||||||
|
const adoption = new AsyncAdoptionScope();
|
||||||
|
const scope = new LocalNetworkSharingAsyncScope(() => {}, adoption);
|
||||||
|
let release = () => {};
|
||||||
|
const gate = new Promise<void>(resolve => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
let published = false;
|
||||||
|
const admitted = scope.applyMutationIfActive(async () => {
|
||||||
|
await gate;
|
||||||
|
return true;
|
||||||
|
}, () => {
|
||||||
|
published = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const disposal = scope.dispose();
|
||||||
|
assert(
|
||||||
|
!await scope.applyMutationIfActive(async () => true, () => {}),
|
||||||
|
'dispose must close mutation admission',
|
||||||
|
);
|
||||||
|
release();
|
||||||
|
await Promise.all([admitted, disposal]);
|
||||||
|
assert(!published, 'disposed scope must suppress late React publication');
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
|
||||||
|
newestProtocolMismatchSnapshot,
|
||||||
|
type ProtocolMismatchSnapshot,
|
||||||
|
} from "../src/lib/protocolMismatch.ts";
|
||||||
|
|
||||||
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("delayed bootstrap snapshot cannot overwrite a newer mismatch event", () => {
|
||||||
|
const delayedQuery = INITIAL_PROTOCOL_MISMATCH_SNAPSHOT;
|
||||||
|
const event: ProtocolMismatchSnapshot = {
|
||||||
|
revision: 1,
|
||||||
|
mismatch: { observed: 7, expected: 8 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const afterEvent = newestProtocolMismatchSnapshot(
|
||||||
|
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
newestProtocolMismatchSnapshot(afterEvent, delayedQuery),
|
||||||
|
event,
|
||||||
|
"delayed query must lose to event",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("new runtime generation clears an older mismatch", () => {
|
||||||
|
const mismatch: ProtocolMismatchSnapshot = {
|
||||||
|
revision: 4,
|
||||||
|
mismatch: { observed: null, expected: 8 },
|
||||||
|
};
|
||||||
|
const cleared: ProtocolMismatchSnapshot = { revision: 5, mismatch: null };
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
newestProtocolMismatchSnapshot(mismatch, cleared),
|
||||||
|
cleared,
|
||||||
|
"new generation clear must win",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
createThumbnailRequestKey,
|
||||||
|
startOwnedThumbnailGeneration,
|
||||||
|
ThumbnailRequestGeneration,
|
||||||
|
thumbnailIdsFromRequestKey,
|
||||||
|
} from '../src/lib/thumbnailRequests.ts';
|
||||||
|
import { AsyncAdoptionScope } from '../src/lib/asyncOwnership.ts';
|
||||||
|
|
||||||
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deferred = <T>() => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test('thumbnail request keys are stable and deduplicate ids', () => {
|
||||||
|
const first = createThumbnailRequestKey(['bravo', 'alpha', 'bravo']);
|
||||||
|
const second = createThumbnailRequestKey(['alpha', 'bravo']);
|
||||||
|
|
||||||
|
assertEquals(first, second, 'equivalent id sets should have one effect key');
|
||||||
|
assertEquals(
|
||||||
|
JSON.stringify(thumbnailIdsFromRequestKey(first)),
|
||||||
|
JSON.stringify(['alpha', 'bravo']),
|
||||||
|
'the effect should receive each id once in stable order',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('constructing a thumbnail generation does not start a request', async () => {
|
||||||
|
let starts = 0;
|
||||||
|
const generation = new ThumbnailRequestGeneration(
|
||||||
|
() => {
|
||||||
|
starts += 1;
|
||||||
|
return Promise.resolve('url');
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(starts, 0, 'request work must wait for the owning effect to start it');
|
||||||
|
await generation.dispose();
|
||||||
|
await generation.start(['game']);
|
||||||
|
assertEquals(starts, 0, 'a disposed generation must not start later work');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('a thumbnail generation cannot start after window admission closes', async () => {
|
||||||
|
const scope = new AsyncAdoptionScope();
|
||||||
|
await scope.disposeOwned();
|
||||||
|
let starts = 0;
|
||||||
|
const generation = new ThumbnailRequestGeneration(
|
||||||
|
() => {
|
||||||
|
starts += 1;
|
||||||
|
return Promise.resolve('url');
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleanup = startOwnedThumbnailGeneration(scope, generation, ['game']);
|
||||||
|
cleanup();
|
||||||
|
await scope.drain();
|
||||||
|
|
||||||
|
assertEquals(starts, 0, 'closed window admission must prevent the first invoke');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('an unmounted thumbnail generation suppresses its in-flight result', async () => {
|
||||||
|
const result = deferred<string>();
|
||||||
|
const published: string[] = [];
|
||||||
|
const generation = new ThumbnailRequestGeneration(
|
||||||
|
() => result.promise,
|
||||||
|
(id, url) => published.push(`${id}:${url}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const loading = generation.start(['game']);
|
||||||
|
const disposal = generation.dispose();
|
||||||
|
result.resolve('thumbnail-url');
|
||||||
|
await loading;
|
||||||
|
await disposal;
|
||||||
|
|
||||||
|
assertEquals(published.length, 0, 'an in-flight result must not publish after unmount');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('a replaced thumbnail generation cannot overwrite the current generation', async () => {
|
||||||
|
const abandonedResult = deferred<string>();
|
||||||
|
const published: string[] = [];
|
||||||
|
const abandoned = new ThumbnailRequestGeneration(
|
||||||
|
() => abandonedResult.promise,
|
||||||
|
(_id, url) => published.push(url),
|
||||||
|
);
|
||||||
|
const abandonedLoading = abandoned.start(['game']);
|
||||||
|
|
||||||
|
const abandonedDisposal = abandoned.dispose();
|
||||||
|
const current = new ThumbnailRequestGeneration(
|
||||||
|
() => Promise.resolve('current-url'),
|
||||||
|
(_id, url) => published.push(url),
|
||||||
|
);
|
||||||
|
await current.start(['game']);
|
||||||
|
abandonedResult.resolve('stale-url');
|
||||||
|
await abandonedLoading;
|
||||||
|
await abandonedDisposal;
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
JSON.stringify(published),
|
||||||
|
JSON.stringify(['current-url']),
|
||||||
|
'only the current effect generation may publish',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('an active failed thumbnail request publishes the empty fallback', async () => {
|
||||||
|
const failure = new Error('thumbnail unavailable');
|
||||||
|
const published: string[] = [];
|
||||||
|
const generation = new ThumbnailRequestGeneration(
|
||||||
|
() => Promise.reject(failure),
|
||||||
|
(id, url) => published.push(`${id}:${url}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
await generation.start(['game']);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
JSON.stringify(published),
|
||||||
|
JSON.stringify(['game:']),
|
||||||
|
'active failures should retain the placeholder-cover behavior',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import {
|
||||||
|
applyDownloadProgress,
|
||||||
|
applyGameTransferStatusSnapshot,
|
||||||
|
downloadProgressAriaLabel,
|
||||||
|
downloadProgressTransferLabel,
|
||||||
|
gameTransferStatusPresentation,
|
||||||
|
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
|
||||||
|
newestGameTransferStatusSnapshot,
|
||||||
|
} from "../src/lib/gameState.ts";
|
||||||
|
import {
|
||||||
|
type DownloadProgressPayload,
|
||||||
|
type Game,
|
||||||
|
GameAvailability,
|
||||||
|
GameTransferStatus,
|
||||||
|
type GameTransferStatusSnapshot,
|
||||||
|
InstallStatus,
|
||||||
|
} from "../src/lib/types.ts";
|
||||||
|
|
||||||
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`${message}: expected ${JSON.stringify(expected)}, got ${
|
||||||
|
JSON.stringify(actual)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const game = (id: string): Game => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
description: "",
|
||||||
|
size: 0,
|
||||||
|
downloaded: false,
|
||||||
|
installed: false,
|
||||||
|
availability: GameAvailability.LocalOnly,
|
||||||
|
install_status: InstallStatus.Downloading,
|
||||||
|
peer_count: 1,
|
||||||
|
download_progress: {
|
||||||
|
attemptId: `${id}-attempt`,
|
||||||
|
downloaded_bytes: 10,
|
||||||
|
total_bytes: 100,
|
||||||
|
bytes_per_second: 5,
|
||||||
|
active_peer_count: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = (
|
||||||
|
revision: number,
|
||||||
|
statuses: GameTransferStatusSnapshot["statuses"],
|
||||||
|
openAttempts: GameTransferStatusSnapshot["openAttempts"] = {},
|
||||||
|
): GameTransferStatusSnapshot => ({ revision, statuses, openAttempts });
|
||||||
|
|
||||||
|
const progress = (
|
||||||
|
id: string,
|
||||||
|
attemptId: string,
|
||||||
|
downloadedBytes: number,
|
||||||
|
): DownloadProgressPayload => ({
|
||||||
|
id,
|
||||||
|
attemptId,
|
||||||
|
downloaded_bytes: downloadedBytes,
|
||||||
|
total_bytes: 100,
|
||||||
|
bytes_per_second: 5,
|
||||||
|
active_peer_count: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("newer transfer event beats stale and equal GamesList snapshots", () => {
|
||||||
|
const event = snapshot(2, { alpha: GameTransferStatus.Retrying });
|
||||||
|
assertEquals(
|
||||||
|
newestGameTransferStatusSnapshot(event, snapshot(1, {})),
|
||||||
|
event,
|
||||||
|
"a delayed older GamesList snapshot must not replace the listener event",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
newestGameTransferStatusSnapshot(
|
||||||
|
event,
|
||||||
|
snapshot(2, { alpha: GameTransferStatus.Exhausted }),
|
||||||
|
),
|
||||||
|
event,
|
||||||
|
"an equal revision replay must not change the accepted full snapshot",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("higher revision full replacement clears omitted game statuses", () => {
|
||||||
|
const games = applyGameTransferStatusSnapshot(
|
||||||
|
[game("alpha"), game("bravo")],
|
||||||
|
snapshot(4, {
|
||||||
|
alpha: GameTransferStatus.Verifying,
|
||||||
|
bravo: GameTransferStatus.Exhausted,
|
||||||
|
}, { alpha: "alpha-attempt", bravo: "bravo-attempt" }),
|
||||||
|
);
|
||||||
|
const replacement = applyGameTransferStatusSnapshot(
|
||||||
|
games,
|
||||||
|
snapshot(
|
||||||
|
5,
|
||||||
|
{ bravo: GameTransferStatus.Retrying },
|
||||||
|
{ alpha: "alpha-attempt", bravo: "bravo-attempt" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
replacement.map((item) => item.transfer_status),
|
||||||
|
[undefined, GameTransferStatus.Retrying],
|
||||||
|
"omission must mean None while the listed game retains its exact status",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
replacement.map((item) => item.download_progress?.downloaded_bytes),
|
||||||
|
[10, 10],
|
||||||
|
"typed status replacement must not mutate direct byte progress",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("progress is fenced by the newest exact open attempt", () => {
|
||||||
|
const attemptA = "9007199254740992";
|
||||||
|
const attemptB = "9007199254740993";
|
||||||
|
const withoutProgress = [{ ...game("alpha"), download_progress: undefined }];
|
||||||
|
const openA = snapshot(1, {}, { alpha: attemptA });
|
||||||
|
const openB = snapshot(2, {}, { alpha: attemptB });
|
||||||
|
const terminalB = snapshot(3, {}, {});
|
||||||
|
|
||||||
|
const progressA = applyDownloadProgress(
|
||||||
|
withoutProgress,
|
||||||
|
openA,
|
||||||
|
progress("alpha", attemptA, 10),
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
progressA[0].download_progress?.attemptId,
|
||||||
|
attemptA,
|
||||||
|
"A progress should be accepted while A is the open attempt",
|
||||||
|
);
|
||||||
|
|
||||||
|
const beganB = applyGameTransferStatusSnapshot(progressA, openB);
|
||||||
|
assertEquals(
|
||||||
|
beganB[0].download_progress,
|
||||||
|
undefined,
|
||||||
|
"a newer B Begin snapshot must clear already-rendered A progress",
|
||||||
|
);
|
||||||
|
const delayedA = applyDownloadProgress(
|
||||||
|
beganB,
|
||||||
|
openB,
|
||||||
|
progress("alpha", attemptA, 20),
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
delayedA[0].download_progress,
|
||||||
|
undefined,
|
||||||
|
"delayed A progress must be rejected after B becomes authoritative",
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressB = applyDownloadProgress(
|
||||||
|
delayedA,
|
||||||
|
openB,
|
||||||
|
progress("alpha", attemptB, 30),
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
progressB[0].download_progress?.downloaded_bytes,
|
||||||
|
30,
|
||||||
|
"progress for the exact current B attempt must be accepted",
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishedB = applyGameTransferStatusSnapshot(progressB, terminalB);
|
||||||
|
assertEquals(
|
||||||
|
finishedB[0].download_progress,
|
||||||
|
undefined,
|
||||||
|
"terminal replacement must clear B progress with the open attempt",
|
||||||
|
);
|
||||||
|
const lateB = applyDownloadProgress(
|
||||||
|
finishedB,
|
||||||
|
terminalB,
|
||||||
|
progress("alpha", attemptB, 40),
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
lateB[0].download_progress,
|
||||||
|
undefined,
|
||||||
|
"late B progress must remain rejected after terminal settlement",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("transfer status lookup uses only own game-id properties", () => {
|
||||||
|
const [projected] = applyGameTransferStatusSnapshot(
|
||||||
|
[game("toString")],
|
||||||
|
snapshot(3, {}),
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
projected.transfer_status,
|
||||||
|
undefined,
|
||||||
|
"prototype properties must not become catalog game statuses",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
newestGameTransferStatusSnapshot(
|
||||||
|
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
|
||||||
|
snapshot(1, {}),
|
||||||
|
).revision,
|
||||||
|
1,
|
||||||
|
"the backend revision-one bootstrap must replace the frontend initial state",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("transfer notices use the exact copy and severity contract", () => {
|
||||||
|
assertEquals(
|
||||||
|
gameTransferStatusPresentation(GameTransferStatus.Verifying),
|
||||||
|
{ message: "Verifying downloaded chunks", level: "info" },
|
||||||
|
"verification copy",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
gameTransferStatusPresentation(GameTransferStatus.Retrying),
|
||||||
|
{
|
||||||
|
message: "A source sent invalid data; retrying another nearby peer",
|
||||||
|
level: "warning",
|
||||||
|
},
|
||||||
|
"retry copy",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
gameTransferStatusPresentation(GameTransferStatus.Exhausted),
|
||||||
|
{
|
||||||
|
message: "No nearby peer could provide the verified catalog version",
|
||||||
|
level: "error",
|
||||||
|
},
|
||||||
|
"exhaustion copy",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
gameTransferStatusPresentation(undefined),
|
||||||
|
undefined,
|
||||||
|
"None must remain invisible",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("download progress promotes transient verification and retry copy", () => {
|
||||||
|
const verifying = {
|
||||||
|
...game("Alpha"),
|
||||||
|
transfer_status: GameTransferStatus.Verifying,
|
||||||
|
};
|
||||||
|
const retrying = {
|
||||||
|
...game("Bravo"),
|
||||||
|
transfer_status: GameTransferStatus.Retrying,
|
||||||
|
};
|
||||||
|
const exhausted = {
|
||||||
|
...game("Charlie"),
|
||||||
|
transfer_status: GameTransferStatus.Exhausted,
|
||||||
|
};
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
downloadProgressTransferLabel(verifying),
|
||||||
|
"Verifying downloaded chunks",
|
||||||
|
"verification should replace the primary progress copy",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
downloadProgressAriaLabel(verifying),
|
||||||
|
"Verifying downloaded chunks: Alpha",
|
||||||
|
"verification should replace the progressbar accessible name",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
downloadProgressTransferLabel(retrying),
|
||||||
|
"A source sent invalid data; retrying another nearby peer",
|
||||||
|
"retry should replace the primary progress copy",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
downloadProgressAriaLabel(retrying),
|
||||||
|
"A source sent invalid data; retrying another nearby peer: Bravo",
|
||||||
|
"retry should replace the progressbar accessible name",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
downloadProgressTransferLabel(exhausted),
|
||||||
|
undefined,
|
||||||
|
"terminal exhaustion should remain on the card/modal status surface",
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user