diff --git a/crates/lanspread-tauri-deno-ts/src/MainLogsWindow.tsx b/crates/lanspread-tauri-deno-ts/src/MainLogsWindow.tsx index 29cdf62..5c3eb4d 100644 --- a/crates/lanspread-tauri-deno-ts/src/MainLogsWindow.tsx +++ b/crates/lanspread-tauri-deno-ts/src/MainLogsWindow.tsx @@ -19,6 +19,7 @@ import { rowFromPayload, rowsFromHistory, } from './lib/mainLogs'; +import { AsyncOwner, windowAsyncScope } from './lib/asyncOwnership'; import './MainLogsWindow.css'; @@ -43,6 +44,8 @@ export const MainLogsWindow = () => { const pausedRef = useRef(false); const lastHistorySequenceRef = useRef(0); const historyLineCountsRef = useRef>(new Map()); + const viewOwnerRef = useRef(null); + const copyStatusTimerRef = useRef(undefined); const appendVisibleRows = useCallback((rows: MainLogRow[]) => { setLogs(current => capLogRows([...current, ...rows])); @@ -54,10 +57,18 @@ export const MainLogsWindow = () => { }, []); useEffect(() => { - let cancelled = false; - let unlisten: (() => void) | undefined; + const owner = new AsyncOwner(error => { + console.error('Failed to unregister main log listener:', error); + }); + viewOwnerRef.current = owner; + owner.own(() => { + if (copyStatusTimerRef.current !== undefined) { + window.clearTimeout(copyStatusTimerRef.current); + copyStatusTimerRef.current = undefined; + } + }); - const handleIncomingRow = (row: MainLogRow) => { + const handleIncomingRow = owner.guard((row: MainLogRow) => { if (!historyLoadedRef.current) { initialBufferRef.current = capLogRows([...initialBufferRef.current, row]); return; @@ -76,43 +87,55 @@ export const MainLogsWindow = () => { return; } appendVisibleRows([row]); - }; + }); const setup = async () => { try { - unlisten = await listen('main-log-line', event => { - handleIncomingRow(rowFromPayload(event.payload)); - }); - - const history = await invoke('get_main_logs'); - if (cancelled) return; - - lastHistorySequenceRef.current = history.lastSequence; - const historyRows = rowsFromHistory(history.contents); - const historyLineCounts = lineCountsFromRows(historyRows); - const liveRows = dedupeBufferedRows( - historyLineCounts, - initialBufferRef.current, - lastHistorySequenceRef.current, + const registered = await owner.register(() => + listen('main-log-line', event => { + handleIncomingRow(rowFromPayload(event.payload)); + }) ); - initialBufferRef.current = []; - historyLineCountsRef.current = historyLineCounts; - historyLoadedRef.current = true; - - if (pausedRef.current) { - setLogs(capLogRows(historyRows)); - bufferPausedRows(liveRows); - } else { - setLogs(capLogRows([...historyRows, ...liveRows])); - } - setLoadError(null); + if (!registered) return; } catch (err) { - if (!cancelled) { + if (owner.isActive()) { historyLoadedRef.current = true; setLoadError(err instanceof Error ? err.message : String(err)); + setLoading(false); } - } finally { - if (!cancelled) { + return; + } + + try { + await owner.applyIfActive( + () => invoke('get_main_logs'), + history => { + lastHistorySequenceRef.current = history.lastSequence; + const historyRows = rowsFromHistory(history.contents); + const historyLineCounts = lineCountsFromRows(historyRows); + const liveRows = dedupeBufferedRows( + historyLineCounts, + initialBufferRef.current, + lastHistorySequenceRef.current, + ); + initialBufferRef.current = []; + historyLineCountsRef.current = historyLineCounts; + historyLoadedRef.current = true; + + if (pausedRef.current) { + setLogs(capLogRows(historyRows)); + bufferPausedRows(liveRows); + } else { + setLogs(capLogRows([...historyRows, ...liveRows])); + } + setLoadError(null); + setLoading(false); + }, + ); + } catch (err) { + if (owner.isActive()) { + historyLoadedRef.current = true; + setLoadError(err instanceof Error ? err.message : String(err)); setLoading(false); } } @@ -121,12 +144,12 @@ export const MainLogsWindow = () => { void setup(); return () => { - cancelled = true; + windowAsyncScope.adopt(owner.dispose()); + if (viewOwnerRef.current === owner) viewOwnerRef.current = null; historyLoadedRef.current = false; initialBufferRef.current = []; lastHistorySequenceRef.current = 0; historyLineCountsRef.current = new Map(); - unlisten?.(); }; }, [appendVisibleRows, bufferPausedRows]); @@ -156,9 +179,10 @@ export const MainLogsWindow = () => { const viewport = viewportRef.current; if (!viewport) return; - requestAnimationFrame(() => { + const frame = requestAnimationFrame(() => { viewport.scrollTop = viewport.scrollHeight; }); + return () => cancelAnimationFrame(frame); }, [autoScroll, filteredRows.length, lastVisibleRow?.id]); const flushPausedRows = useCallback(() => { @@ -189,13 +213,24 @@ export const MainLogsWindow = () => { }, []); const copyFilteredLogs = useCallback(async () => { + const owner = viewOwnerRef.current; + if (!owner) return; try { - await navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n')); - setCopyStatus('Copied'); + if (!await owner.applyIfActive( + () => navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n')), + () => setCopyStatus('Copied'), + )) return; } catch { + if (!owner.isActive()) return; setCopyStatus('Copy failed'); } - window.setTimeout(() => setCopyStatus(null), 1600); + if (copyStatusTimerRef.current !== undefined) { + window.clearTimeout(copyStatusTimerRef.current); + } + copyStatusTimerRef.current = window.setTimeout(owner.guard(() => { + copyStatusTimerRef.current = undefined; + setCopyStatus(null); + }), 1600); }, [filteredRows]); return ( @@ -219,7 +254,7 @@ export const MainLogsWindow = () => { - )} - - - ); - } + if (size === "lg") { + const peerUnit = stats.activePeerCount === 1 ? "peer" : "peers"; return ( -
-
-
- - - {stats.pct} - % +
+
+
+
+ + {transferLabel ?? "Downloading"} +
+
+ + {formatDownloadBytes(stats.downloaded)} + + / {formatDownloadBytes(stats.total)} + + + · + {formatDownloadSpeed(stats.speed)} + {stats.activePeerCount > 0 && ( + <> + · + + + {stats.activePeerCount} - {formatDownloadSpeedShort(stats.speed)} -
+ + )} + · + {formatDownloadEta(stats.eta)} left +
+
+ {stats.pct} + % +
+ {onCancel && ( + + )}
+
); + } + + return ( +
+
+
+ {transferLabel + ? ( + + + {transferLabel} + + ) + : ( + <> + + + {stats.pct} + % + + + {formatDownloadSpeedShort(stats.speed)} + + + )} +
+
+ ); }; diff --git a/crates/lanspread-tauri-deno-ts/src/components/grid/GameCard.tsx b/crates/lanspread-tauri-deno-ts/src/components/grid/GameCard.tsx index 52ce025..c8d08e6 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/grid/GameCard.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/grid/GameCard.tsx @@ -1,90 +1,109 @@ -import { JSX, KeyboardEvent } from 'react'; +import { JSX, KeyboardEvent } from "react"; -import { Game } from '../../lib/types'; -import { CoverAspect } from '../../hooks/useSettings'; -import { formatBytes } from '../../lib/format'; -import { hasNewerLocalVersion } from '../../lib/gameState'; +import { Game } from "../../lib/types"; +import { CoverAspect } from "../../hooks/useSettings"; +import { formatBytes } from "../../lib/format"; +import { + gameTransferStatusPresentation, + hasNewerLocalVersion, +} from "../../lib/gameState"; -import { GameCover } from './GameCover'; -import { StateChip } from '../StateChip'; -import { ActionButton } from '../ActionButton'; -import { Icon } from '../Icon'; +import { GameCover } from "./GameCover"; +import { StateChip } from "../StateChip"; +import { ActionButton } from "../ActionButton"; +import { Icon } from "../Icon"; interface Props { - game: Game; - aspect: CoverAspect; - thumbnailUrl: string | null; - onOpen: (game: Game) => void; - onPrimary: (game: Game) => void; - onCancelDownload: (game: Game) => void; + game: Game; + aspect: CoverAspect; + thumbnailUrl: string | null; + onOpen: (game: Game) => void; + onPrimary: (game: Game) => void; + onCancelDownload: (game: Game) => void; } -const metaSeparator = (...parts: Array): JSX.Element[] => { - const filtered = parts.filter(Boolean) as string[]; - const out: JSX.Element[] = []; - filtered.forEach((p, i) => { - if (i > 0) out.push(·); - out.push({p}); - }); - return out; +const metaSeparator = ( + ...parts: Array +): JSX.Element[] => { + const filtered = parts.filter(Boolean) as string[]; + const out: JSX.Element[] = []; + filtered.forEach((p, i) => { + if (i > 0) out.push(·); + out.push({p}); + }); + return out; }; export const GameCard = ({ - game, - aspect, - thumbnailUrl, - onOpen, - onPrimary, - onCancelDownload, + game, + aspect, + thumbnailUrl, + onOpen, + onPrimary, + onCancelDownload, }: Props) => { - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onOpen(game); - } - }; - const newerThanExpected = hasNewerLocalVersion(game); - const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0; - const statusMessage = hasOutbound - ? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}` - : (game.status_message ?? (newerThanExpected ? 'Newer than expected' : '')); - const statusLevel = hasOutbound - ? 'info' - : (game.status_level ?? (newerThanExpected ? 'warning' : undefined)); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(game); + } + }; + const newerThanExpected = hasNewerLocalVersion(game); + const transferStatus = gameTransferStatusPresentation(game.transfer_status); + const hasOutbound = game.active_outbound_transfers !== undefined && + game.active_outbound_transfers > 0; + const statusMessage = transferStatus?.message ?? + (hasOutbound + ? `Sharing to ${game.active_outbound_transfers} peer${ + game.active_outbound_transfers === 1 ? "" : "s" + }` + : (game.status_message ?? + (newerThanExpected ? "Newer than expected" : ""))); + const statusLevel = transferStatus?.level ?? + (hasOutbound + ? "info" + : (game.status_level ?? (newerThanExpected ? "warning" : undefined))); - return ( - - ); + {statusMessage} +
+ onPrimary(game)} + onCancelDownload={onCancelDownload} + /> +
+ + ); }; diff --git a/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx b/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx index 117ba36..82f959e 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx @@ -1,187 +1,213 @@ -import { Modal } from '../Modal'; -import { Icon } from '../Icon'; -import { GameCover } from '../grid/GameCover'; -import { StateChip } from '../StateChip'; -import { ActionButton } from '../ActionButton'; +import { Modal } from "../Modal"; +import { Icon } from "../Icon"; +import { GameCover } from "../grid/GameCover"; +import { StateChip } from "../StateChip"; +import { ActionButton } from "../ActionButton"; -import { Game, InstallStatus } from '../../lib/types'; -import { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState'; -import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format'; +import { Game, InstallStatus } from "../../lib/types"; +import { + canStreamInstall, + gameStatusLabel, + gameTransferStatusPresentation, + hasNewerLocalVersion, + isInProgress, +} from "../../lib/gameState"; +import { formatBytes, formatEtiVersion, formatPlayers } from "../../lib/format"; interface Props { - game: Game; - thumbnailUrl: string | null; - onClose: () => void; - onPrimary: (game: Game) => void; - onStreamInstall: (game: Game) => void; - onUninstall: (game: Game) => void; - onRemoveDownload: (game: Game) => void; - onCancelDownload: (game: Game) => void; - onStartServer: (game: Game) => void; - onViewFiles: (game: Game) => void; + game: Game; + thumbnailUrl: string | null; + supportsStreamedInstall: boolean; + onClose: () => void; + onPrimary: (game: Game) => void; + onStreamInstall: (game: Game) => void; + onUninstall: (game: Game) => void; + onRemoveDownload: (game: Game) => void; + onCancelDownload: (game: Game) => void; + onStartServer: (game: Game) => void; + onViewFiles: (game: Game) => void; } const tagsFromGame = (game: Game): string[] => { - const tags: string[] = []; - if (game.genre) tags.push(game.genre); - if (game.publisher) tags.push(game.publisher); - if (game.release_year) tags.push(game.release_year); - return tags; + const tags: string[] = []; + if (game.genre) tags.push(game.genre); + if (game.publisher) tags.push(game.publisher); + if (game.release_year) tags.push(game.release_year); + return tags; }; export const GameDetailModal = ({ - game, - thumbnailUrl, - onClose, - onPrimary, - onStreamInstall, - onUninstall, - onRemoveDownload, - onCancelDownload, - onStartServer, - onViewFiles, + game, + thumbnailUrl, + supportsStreamedInstall, + onClose, + onPrimary, + onStreamInstall, + onUninstall, + onRemoveDownload, + onCancelDownload, + onStartServer, + onViewFiles, }: Props) => { - const tags = tagsFromGame(game); - // Some game metadata contains a literal
; keep sanitization exact. - const description = game.description.split('
').join(''); - const canRemoveDownload = game.downloaded - && !game.installed - && !isInProgress(game.install_status); - const showStreamInstall = canStreamInstall(game); - const canViewFiles = game.downloaded - || game.installed - || game.install_status === InstallStatus.Downloading - || game.install_status === InstallStatus.Installing; - const newerThanExpected = hasNewerLocalVersion(game); - const newerStatus = newerThanExpected - ? `Local version ${formatEtiVersion(game.local_version)} is newer than expected ${formatEtiVersion(game.eti_game_version)}.` - : undefined; - const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0; - const outboundStatus = hasOutbound - ? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}.` - : undefined; - const statusMessage = outboundStatus ?? game.status_message ?? newerStatus; - const statusLevel = hasOutbound - ? 'info' - : (game.status_level ?? (newerStatus ? 'warning' : undefined)); - return ( - - +
+ +
+
+ {tags.length > 0 && ( +
+ {tags.map((t) => {t})} +
+ )} +

{game.name}

+
+
+ +
+
+ +
+
+
+
Size
+
{formatBytes(game.size)}
+
+
+
Players
+
+ {formatPlayers(game.max_players)} +
+
+
+
Version
+
+ {formatEtiVersion(game.eti_game_version ?? game.local_version)} +
+
+
+
Status
+
{gameStatusLabel(game)}
+
+
+ + {description &&

{description}

} + + {statusMessage && ( +

+ {statusMessage} +

+ )} + +
+ onPrimary(game)} + onCancelDownload={onCancelDownload} + /> + {showStreamInstall && ( + -
- -
-
- {tags.length > 0 && ( -
- {tags.map(t => {t})} -
- )} -

{game.name}

-
-
- -
-
- -
-
-
-
Size
-
{formatBytes(game.size)}
-
-
-
Players
-
- {formatPlayers(game.max_players)} -
-
-
-
Version
-
- {formatEtiVersion(game.eti_game_version ?? game.local_version)} -
-
-
-
Status
-
{gameStatusLabel(game)}
-
-
- - {description && ( -

{description}

- )} - - {statusMessage && ( -

- {statusMessage} -

- )} - -
- onPrimary(game)} - onCancelDownload={onCancelDownload} - /> - {showStreamInstall && ( - - )} - {game.installed && game.can_host_server === true && ( - - )} - {game.installed && ( - - )} - {canRemoveDownload && ( - - )} - {canViewFiles && ( - <> -
- - - )} -
-
- - ); + )} + {game.installed && game.can_host_server === true && ( + + )} + {game.installed && ( + + )} + {canRemoveDownload && ( + + )} + {canViewFiles && ( + <> +
+ + + )} +
+
+ + ); }; diff --git a/crates/lanspread-tauri-deno-ts/src/components/modals/SettingsDialog.tsx b/crates/lanspread-tauri-deno-ts/src/components/modals/SettingsDialog.tsx index da0bf50..56f6626 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/modals/SettingsDialog.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/modals/SettingsDialog.tsx @@ -11,6 +11,10 @@ import { LANGUAGE_OPTIONS, type UISettings, } from '../../hooks/useSettings'; +import { + type LocalNetworkSharingSnapshot, + displayedSharingTarget, +} from '../../lib/localNetworkSharing'; interface Props { settings: UISettings; @@ -18,6 +22,11 @@ interface Props { hasGameDirectory: boolean; onPickDirectory: () => void; onChange: (key: K, value: UISettings[K]) => void; + localNetworkSharing: LocalNetworkSharingSnapshot; + localNetworkSharingReady: boolean; + localNetworkSharingBusy: boolean; + localNetworkSharingError: string | null; + onLocalNetworkSharingChange: (enabled: boolean) => void; onClose: () => void; } @@ -59,6 +68,28 @@ const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInpu
); +interface SettingsSwitchProps { + checked: boolean; + disabled: boolean; + onChange: (checked: boolean) => void; +} + +const SettingsSwitch = ({ checked, disabled, onChange }: SettingsSwitchProps) => ( + +); + interface GameFolderFieldProps { path: string; isValid: boolean; @@ -88,6 +119,11 @@ export const SettingsDialog = ({ hasGameDirectory, onPickDirectory, onChange, + localNetworkSharing, + localNetworkSharingReady, + localNetworkSharingBusy, + localNetworkSharingError, + onLocalNetworkSharingChange, onClose, }: Props) => ( @@ -123,6 +159,25 @@ export const SettingsDialog = ({ +
+
Network
+ + + + {localNetworkSharingError && ( +
+ {localNetworkSharingError} +
+ )} +
+
Appearance
diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts index c794ec6..76c1462 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts @@ -1,16 +1,23 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import { listen, UnlistenFn } from '@tauri-apps/api/event'; +import { listen } from '@tauri-apps/api/event'; import { CALL_TO_PLAY_CONNECTING_MESSAGE, - callToPlayEvent, callToPlayPublishErrorMessage, extendDeadline, - pruneCallToPlayEvents, reduceCallToPlayEvents, + replaceCallToPlayView, } from '../lib/callToPlay'; -import { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types'; +import { CallToPlayAsyncScope } from '../lib/callToPlayOwnership'; +import { windowAsyncScope } from '../lib/asyncOwnership'; +import { + CallToPlayAction, + CallToPlayLocalIntent, + CallToPlayReceipt, + CallToPlayView, + Nomination, +} from '../lib/types'; export interface CallToPlayActions { createNomination: ( @@ -36,129 +43,177 @@ export interface UseCallToPlay { error: string | null; } -const mergeEvents = ( - previous: ReadonlyMap, - incoming: ReadonlyArray, -): Map => { - const next = new Map(previous); - for (const event of incoming) next.set(event.id, event); - return next; -}; +type PublishOutcome = + | { kind: 'connecting' } + | { kind: 'settled'; accepted: boolean } + | { kind: 'failed'; error: unknown }; -export const useCallToPlay = (username: string): UseCallToPlay => { +export const useCallToPlay = ( + username: string, + localNetworkSharingEnabled = true, +): UseCallToPlay => { const actor = useMemo(() => { const trimmed = username.trim(); return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander'; }, [username]); - const [events, setEvents] = useState>(() => new Map()); + const [view, setView] = useState({ events: [] }); const [actorId, setActorId] = useState(null); const [now, setNow] = useState(Date.now()); const [transportReady, setTransportReady] = useState(false); const [error, setError] = useState(null); + const asyncScopeRef = useRef(null); + const actorRef = useRef(actor); + actorRef.current = actor; useEffect(() => { const timer = window.setInterval(() => { - const current = Date.now(); - setNow(current); - setEvents(previous => pruneCallToPlayEvents(previous, current)); + setNow(Date.now()); }, 1_000); return () => window.clearInterval(timer); }, []); useEffect(() => { - let cancelled = false; - let unlisten: UnlistenFn | undefined; - let retry: number | undefined; + const scope = new CallToPlayAsyncScope( + (callback, delayMilliseconds) => { + const timer = window.setTimeout(() => void callback(), delayMilliseconds); + return () => window.clearTimeout(timer); + }, + cleanupError => console.error('Failed to clean up Call to Play:', cleanupError), + retryError => console.error('Call to Play retry failed:', retryError), + ); + asyncScopeRef.current = scope; + + const syncDisplayName = async (): Promise => { + try { + await scope.applyMutationIfActive( + () => invoke('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 => { + let ready = false; try { - const peerId = await invoke('request_call_to_play_events'); - if (cancelled) return false; - const ready = peerId !== null; - setActorId(peerId); - setTransportReady(ready); - if (ready) { - setError(current => - current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current - ); - } - if (ready && retry !== undefined) { - window.clearInterval(retry); - retry = undefined; - } - return ready; + const applied = await scope.applyIfActive( + () => invoke('request_call_to_play_view'), + peerId => { + ready = peerId !== null; + setActorId(peerId); + setTransportReady(ready); + if (ready) { + setError(current => + current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current + ); + scope.stopRetry(); + void syncDisplayName(); + } + }, + ); + return applied && ready; } catch (err) { - if (!cancelled) { - setTransportReady(false); - console.error('request_call_to_play_events failed:', err); - } + if (!scope.isActive()) return false; + setTransportReady(false); + console.error('request_call_to_play_view failed:', err); return false; } }; const register = async () => { try { - unlisten = await listen('call-to-play-events', event => { - setEvents(previous => mergeEvents(previous, event.payload)); - }); - if (cancelled) { - unlisten(); - return; - } + const registered = await scope.registerListener(() => + listen('call-to-play-view', scope.guard(event => { + setView(previous => replaceCallToPlayView(previous, event.payload)); + })) + ); + if (!registered) return; + const ready = await requestSnapshot(); - if (!cancelled && !ready) { - retry = window.setInterval(() => void requestSnapshot(), 2_000); - } + if (scope.isActive() && !ready) scope.startRetry(requestSnapshot, 2_000); } catch (err) { + if (!scope.isActive()) return; console.error('Failed to register Call to Play listener:', err); - if (!cancelled) { - setTransportReady(false); - setError('Call to Play networking is unavailable.'); - } + setTransportReady(false); + setError('Call to Play networking is unavailable.'); } }; - void register(); + void scope.applyIfActive(register, () => {}); return () => { - cancelled = true; - unlisten?.(); - if (retry !== undefined) window.clearInterval(retry); + if (asyncScopeRef.current === scope) asyncScopeRef.current = null; + windowAsyncScope.adopt(scope.dispose()); }; }, []); + useEffect(() => { + const scope = asyncScopeRef.current; + if (scope === null || !scope.isActive()) return; + void scope.applyMutationIfActive( + () => invoke('set_call_to_play_display_name', { displayName: actor }), + () => {}, + ).catch(err => { + if (scope.isActive()) console.error('set_call_to_play_display_name failed:', err); + }); + }, [actor]); + const publish = useCallback(async ( - callId: string, + callId: string | null, action: CallToPlayAction, ): Promise => { - if (actorId === null) { - setTransportReady(false); - setError(CALL_TO_PLAY_CONNECTING_MESSAGE); - return false; - } - const event = callToPlayEvent(callId, actorId, actor, action); - try { - const accepted = await invoke('publish_call_to_play', { event }); - if (!accepted) { + const scope = asyncScopeRef.current; + if (scope === null || !scope.isActive() || !localNetworkSharingEnabled) return false; + + const publishOutcome = scope.guardLatestAction((outcome: PublishOutcome) => { + if (outcome.kind === 'connecting') { setTransportReady(false); setError(CALL_TO_PLAY_CONNECTING_MESSAGE); - return false; + } else if (outcome.kind === 'failed') { + setError(callToPlayPublishErrorMessage(outcome.error)); + } else if (!outcome.accepted) { + setTransportReady(false); + setError(CALL_TO_PLAY_CONNECTING_MESSAGE); + } else { + setTransportReady(true); + setError(null); } - setTransportReady(true); - setError(null); - return true; - } catch (err) { - console.error('publish_call_to_play failed:', err); - setError(callToPlayPublishErrorMessage(err)); + }); + if (actorId === null) { + publishOutcome({ kind: 'connecting' }); return false; } - }, [actor, actorId]); + const intent: CallToPlayLocalIntent = { call_id: callId, action }; + let acceptedResult = false; + try { + const completed = await scope.applyMutationIfActive( + () => invoke('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(() => ({ createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => { const createdAt = Date.now(); - const callId = globalThis.crypto.randomUUID(); const deadline = scheduledFor ?? createdAt + durationMinutes * 60_000; - void publish(callId, { + void publish(null, { Create: { game_id: gameId, max_players: Math.max(2, Math.min(64, Math.round(maxPlayers))), @@ -178,7 +233,6 @@ export const useCallToPlay = (username: string): UseCallToPlay => { if (!trimmed) return; void publish(callId, { SendMessage: { - message_id: globalThis.crypto.randomUUID(), text: trimmed, }, }); @@ -192,9 +246,15 @@ export const useCallToPlay = (username: string): UseCallToPlay => { }), [publish]); const nominations = useMemo( - () => reduceCallToPlayEvents([...events.values()], now), - [events, now], + () => reduceCallToPlayEvents(view.events, now), + [view, now], ); - return { nominations, actions, actorId, transportReady, error }; + return { + nominations, + actions, + actorId, + transportReady: transportReady && localNetworkSharingEnabled, + error, + }; }; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts index bd4c012..38363b7 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts @@ -1,153 +1,219 @@ -import { useCallback } from 'react'; -import { invoke } from '@tauri-apps/api/core'; -import { ask } from '@tauri-apps/plugin-dialog'; +import { useCallback, useEffect, useRef } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { ask } from "@tauri-apps/plugin-dialog"; -import { type UseGamesResult } from './useGames'; -import { type UISettings } from './useSettings'; +import { type UseGamesResult } from "./useGames"; +import { type UISettings } from "./useSettings"; +import { AsyncOwner, windowAsyncScope } from "../lib/asyncOwnership"; export interface GameActions { - play: (id: string) => Promise; - startServer: (id: string) => Promise; - install: (id: string) => Promise; - streamInstall: (id: string) => Promise; - update: (id: string) => Promise; - uninstall: (id: string) => Promise; - removeDownload: (id: string) => Promise; - cancelDownload: (id: string) => Promise; - viewFiles: (id: string) => Promise; + play: (id: string) => Promise; + startServer: (id: string) => Promise; + install: (id: string) => Promise; + streamInstall: (id: string) => Promise; + update: (id: string) => Promise; + uninstall: (id: string) => Promise; + removeDownload: (id: string) => Promise; + cancelDownload: (id: string) => Promise; + viewFiles: (id: string) => Promise; } /** * Thin wrappers over the backend `run_game` / `install_game` / `update_game` - * / `uninstall_game` / `remove_downloaded_game` commands. Peer-backed downloads - * are marked as "checking peers" until the backend emits an authoritative - * operation snapshot; cancellation waits for the backend to clear that snapshot. + * / `uninstall_game` / `remove_downloaded_game` commands. Operation state is + * derived only from authoritative backend snapshots; cancellation waits for + * the backend to clear that snapshot. */ export const useGameActions = ( - games: UseGamesResult, - settings: Pick, + games: UseGamesResult, + settings: Pick, ): GameActions => { - const play = useCallback(async (id: string) => { - try { - await invoke('run_game', { - id, - language: settings.language, - username: settings.username, - }); - } catch (err) { - console.error('run_game failed:', err); - } - }, [settings.language, settings.username]); + const ownerRef = useRef(null); - const startServer = useCallback(async (id: string) => { - try { - await invoke('start_server', { - id, - language: settings.language, - username: settings.username, - }); - } catch (err) { - console.error('start_server failed:', err); - } - }, [settings.language, settings.username]); - - const install = useCallback(async (id: string) => { - try { - const success = await invoke('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('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('update_game', { - id, - language: settings.language, - username: settings.username, - }); - if (success) games.markChecking(id); - } catch (err) { - console.error('update_game failed:', err); - } - }, [games, settings.language, settings.username]); - - const uninstall = useCallback(async (id: string) => { - try { - await invoke('uninstall_game', { id }); - } catch (err) { - console.error('uninstall_game failed:', err); - } - }, []); - - const removeDownload = useCallback(async (id: string) => { - try { - const game = games.games.find(item => item.id === id); - if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) { - const confirmed = await ask( - `Peers are currently downloading this game from you. Removing game files will abort their downloads. Do you want to proceed?`, - { title: 'Active Transfers in Progress', kind: 'warning' } - ); - if (!confirmed) return; - } - await invoke('remove_downloaded_game', { id }); - } catch (err) { - console.error('remove_downloaded_game failed:', err); - } - }, [games]); - - const cancelDownload = useCallback(async (id: string) => { - try { - await invoke('cancel_download', { id }); - } catch (err) { - console.error('cancel_download failed:', err); - } - }, []); - - const viewFiles = useCallback(async (id: string) => { - try { - await invoke('open_game_files', { id }); - } catch (err) { - console.error('open_game_files failed:', err); - } - }, []); - - return { - play, - startServer, - install, - streamInstall, - update, - uninstall, - removeDownload, - cancelDownload, - viewFiles, + useEffect(() => { + const owner = new AsyncOwner((error) => { + console.error("Failed to clean up a game action:", error); + }); + ownerRef.current = owner; + return () => { + if (ownerRef.current === owner) ownerRef.current = null; + windowAsyncScope.adopt(owner.dispose()); }; + }, []); + + const applyOwned = useCallback(async ( + operation: () => Promise, + apply: (value: T) => void = () => {}, + ): Promise => { + 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("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("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("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, + }; }; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useGameDirectory.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useGameDirectory.ts index cf9a0a1..0c49077 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useGameDirectory.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useGameDirectory.ts @@ -1,92 +1,143 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { load } from '@tauri-apps/plugin-store'; +import { acceptGameDirectory, hydrateGameDirectory } from '../lib/gameDirectory'; +import { windowAsyncScope } from '../lib/asyncOwnership'; +import { + startAdmittedPersistenceEffect, + windowPersistenceScope, +} from '../lib/frontendPersistence'; import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store'; +const requestGameDirectory = (requestedPath: string): Promise => + acceptGameDirectory(requestedPath, { + updateBackend: path => invoke('update_game_directory', { path }), + persist: async acceptedPath => { + const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); + await store.set(GAME_DIR_KEY, acceptedPath); + }, + reportPersistenceError: error => { + console.error('Failed to persist accepted game directory:', error); + }, + }); + /** - * Owns the user's selected game directory. Hydrates from the persistent store - * on mount, writes back on every change, and pushes the value to the Tauri - * backend so it can scan/rescan. + * Owns the backend-accepted game directory. Both restored and newly selected + * paths enter frontend state only after the backend returns its canonical path. */ -export const useGameDirectory = () => { - const [gameDir, setGameDir] = useState(''); - const [gameDirExists, setGameDirExists] = useState(false); +export const useGameDirectory = (backendPolicyReady = true) => { + const [gameDir, setAcceptedGameDir] = useState(''); + const [ready, setReady] = useState(false); + const acceptedGameDirRef = useRef(''); + const mountedRef = useRef(false); + const acceptingRef = useRef(false); + const selectionVersionRef = useRef(0); + const updateQueueRef = useRef>(Promise.resolve()); + const hydrationRef = useRef>(Promise.resolve()); + + const shutdown = useCallback(async (): Promise => { + acceptingRef.current = false; + await hydrationRef.current; + await updateQueueRef.current; + }, []); + + const enqueueUpdate = useCallback(( + requestedPath: () => string, + failureMessage: string, + ): Promise => { + if (!acceptingRef.current) return Promise.resolve(); + + const update = updateQueueRef.current.then(async () => { + const path = requestedPath(); + if (!path.trim()) return; - useEffect(() => { - let cancelled = false; - const hydrate = async () => { try { - const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); - const saved = await store.get(GAME_DIR_KEY); - if (saved && !cancelled) setGameDir(saved); - } catch (err) { - console.error('Failed to load game directory:', err); + const acceptedPath = await requestGameDirectory(path); + acceptedGameDirRef.current = acceptedPath; + if (mountedRef.current && acceptingRef.current) { + setAcceptedGameDir(acceptedPath); + } + } catch (error) { + console.error(failureMessage, error); } - }; - void hydrate(); - return () => { - cancelled = true; - }; + }); + updateQueueRef.current = update; + windowAsyncScope.adopt(update); + return update; }, []); useEffect(() => { - if (!gameDir.trim()) { - setGameDirExists(false); + if (!backendPolicyReady) { + setReady(false); return; } - let cancelled = false; - const sync = async () => { - try { - const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); - await store.set(GAME_DIR_KEY, gameDir); - } catch (err) { - console.error('Failed to persist game directory:', err); - } + setReady(false); + return startAdmittedPersistenceEffect( + windowPersistenceScope, + shutdown, + () => { + let cancelled = false; + mountedRef.current = true; + acceptingRef.current = true; + const initialSelectionVersion = selectionVersionRef.current; + const hydration = hydrateGameDirectory({ + loadSavedPath: async () => { + const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); + return await store.get(GAME_DIR_KEY); + }, + acceptSavedPath: async saved => { + if ( + !cancelled + && acceptingRef.current + && selectionVersionRef.current === initialSelectionVersion + ) { + await enqueueUpdate( + () => saved, + 'Failed to restore game directory:', + ); + } + }, + reportLoadError: error => { + console.error('Failed to load game directory:', error); + }, + }).finally(() => { + if (!cancelled && mountedRef.current && acceptingRef.current) { + setReady(true); + } + }); + hydrationRef.current = hydration; + windowAsyncScope.adopt(hydration); + return () => { + cancelled = true; + mountedRef.current = false; + acceptingRef.current = false; + }; + }, + ); + }, [backendPolicyReady, enqueueUpdate, shutdown]); - let exists = false; - try { - exists = await invoke('game_directory_exists', { path: gameDir }); - } catch (err) { - console.error('Failed to validate game directory:', err); - } - if (cancelled) return; - setGameDirExists(exists); - if (!exists) return; - - invoke('update_game_directory', { path: gameDir }).catch(err => - console.error('Failed to push game directory to backend:', err), - ); - }; - void sync(); - return () => { - cancelled = true; - }; - }, [gameDir]); - - const hasGameDirectory = gameDir.trim() !== '' && gameDirExists; + const setGameDir = useCallback((requestedPath: string) => { + selectionVersionRef.current += 1; + void enqueueUpdate( + () => requestedPath, + 'Failed to update game directory:', + ); + }, [enqueueUpdate]); const rescan = useCallback(() => { - if (!gameDir.trim()) { - setGameDirExists(false); - return; - } - const sync = async () => { - let exists = false; - try { - exists = await invoke('game_directory_exists', { path: gameDir }); - } catch (err) { - console.error('Failed to validate game directory:', err); - } - setGameDirExists(exists); - if (!exists) return; + void enqueueUpdate( + () => acceptedGameDirRef.current, + 'Failed to rescan game directory:', + ); + }, [enqueueUpdate]); - invoke('update_game_directory', { path: gameDir }).catch(err => - console.error('Failed to rescan game directory:', err), - ); - }; - void sync(); - }, [gameDir]); - - return { gameDir, gameDirExists, hasGameDirectory, setGameDir, rescan }; + return { + gameDir, + ready, + hasGameDirectory: gameDir !== '', + setGameDir, + rescan, + shutdown, + }; }; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts index aaa087a..ef5b84b 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts @@ -1,188 +1,271 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { invoke } from '@tauri-apps/api/core'; -import { listen, UnlistenFn } from '@tauri-apps/api/event'; +import { useCallback, useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { - DownloadProgressPayload, - Game, - GamesListPayload, - InstallStatus, -} from '../lib/types'; + DownloadProgressPayload, + Game, + GamesListPayload, + GameTransferStatusSnapshot, + ProtocolMismatch, +} from "../lib/types"; import { - activeStatusById, - isInProgress, - mergeGameUpdate, - normalizeGamesListPayload, -} from '../lib/gameState'; + activeStatusById, + applyDownloadProgress, + applyGameTransferStatusSnapshot, + gameTransferStatusFor, + INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT, + mergeGameUpdate, + newestGameTransferStatusSnapshot, +} from "../lib/gameState"; +import { + AsyncOwner, + type AsyncRegistration, + registerSequentially, + windowAsyncScope, +} from "../lib/asyncOwnership"; +import { + INITIAL_PROTOCOL_MISMATCH_SNAPSHOT, + newestProtocolMismatchSnapshot, + type ProtocolMismatchSnapshot, +} from "../lib/protocolMismatch"; -interface PendingPatch { - install_status?: InstallStatus; - clearStatus?: boolean; -} - -const applyPatch = (game: Game, patch: PendingPatch): Game => { - let next: Game = { ...game }; - if (patch.install_status !== undefined) next.install_status = patch.install_status; - if (patch.clearStatus) { - next.status_message = undefined; - next.status_level = undefined; - } - return next; -}; - -/** - * Owns the games list and derives card status from backend snapshots. Returns - * a fire-and-forget `markChecking` helper so action calls can immediately show - * a "Checking peers…" state until the next backend snapshot arrives. - */ +/** Owns the games list and derives card status from backend snapshots. */ export interface UseGamesResult { - games: Game[]; - setGames: React.Dispatch>; - totalPeerCount: number; - requestGames: () => Promise; - markChecking: (id: string) => void; + games: Game[]; + setGames: React.Dispatch>; + totalPeerCount: number; + protocolMismatch: ProtocolMismatch | null; + requestGames: () => Promise; } export const useGames = (rescanGameDir: () => void): UseGamesResult => { - const [games, setGames] = useState([]); - const [totalPeerCount, setTotalPeerCount] = useState(0); - const rescanRef = useRef(rescanGameDir); - rescanRef.current = rescanGameDir; + const [games, setGames] = useState([]); + const [totalPeerCount, setTotalPeerCount] = useState(0); + const [protocolMismatchSnapshot, setProtocolMismatchSnapshot] = useState( + INITIAL_PROTOCOL_MISMATCH_SNAPSHOT, + ); + const transferStatusRef = useRef( + INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT, + ); + const rescanRef = useRef(rescanGameDir); + rescanRef.current = rescanGameDir; - const markChecking = useCallback((id: string) => { - setGames(prev => prev.map(item => - item.id === id && !isInProgress(item.install_status) - ? applyPatch(item, { - install_status: InstallStatus.CheckingPeers, - clearStatus: true, - }) - : item - )); - }, []); + const requestGames = useCallback(async () => { + try { + await invoke("request_games"); + } catch (err) { + console.error("request_games failed:", err); + } + }, []); - const requestGames = useCallback(async () => { - try { - await invoke('request_games'); - } catch (err) { - console.error('request_games failed:', err); - } - }, []); + useEffect(() => { + const owner = new AsyncOwner((error) => { + console.error("Failed to unregister game listener:", error); + }); - useEffect(() => { - const unlisteners: UnlistenFn[] = []; - let cancelled = false; - - const handleErrorEvent = ( - id: string, - message: string, - { triggerRescan = false }: { triggerRescan?: boolean } = {}, - ) => { - setGames(prev => prev.map(item => item.id === id - ? { - ...item, - install_status: item.installed - ? InstallStatus.Installed - : InstallStatus.NotInstalled, - status_message: message, - status_level: 'error', - download_progress: undefined, - } - : item)); - if (triggerRescan) rescanRef.current(); - }; - - const register = async () => { - try { - unlisteners.push(await listen('games-list-updated', (event) => { - const payload = normalizeGamesListPayload( - event.payload as GamesListPayload | Game[], - ); - const activeStatuses = activeStatusById(payload.active_operations); - setGames(prev => { - const previousById = new Map(prev.map(item => [item.id, item])); - return payload.games.map(game => mergeGameUpdate( - game, - previousById.get(game.id), - activeStatuses.get(game.id), - )); - }); - })); - - unlisteners.push(await listen('game-download-failed', (e) => { - handleErrorEvent(e.payload as string, 'Download failed. Please try again.', { - triggerRescan: true, - }); - })); - - unlisteners.push(await listen('game-download-peers-gone', (e) => { - handleErrorEvent(e.payload as string, 'Failed: all peers gone.', { - triggerRescan: true, - }); - })); - - unlisteners.push(await listen('game-download-progress', (e) => { - const { id, ...download_progress } = e.payload as DownloadProgressPayload; - setGames(prev => prev.map(item => item.id === id - ? { - ...item, - download_progress, - } - : item)); - })); - - unlisteners.push(await listen('game-no-peers', (e) => { - handleErrorEvent(e.payload as string, 'No peers currently have this game.'); - })); - - unlisteners.push(await listen('game-install-finished', () => { - rescanRef.current(); - })); - - unlisteners.push(await listen('game-install-failed', (e) => { - handleErrorEvent(e.payload as string, 'Install failed. Please try again.'); - })); - - unlisteners.push(await listen('game-uninstall-failed', (e) => { - handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.'); - })); - - unlisteners.push(await listen('game-remove-download-finished', () => { - rescanRef.current(); - })); - - unlisteners.push(await listen('game-remove-download-failed', (e) => { - handleErrorEvent(e.payload as string, 'Remove failed. Please try again.', { - triggerRescan: true, - }); - })); - - unlisteners.push(await listen('peer-count-updated', (e) => { - setTotalPeerCount(e.payload as number); - })); - - if (!cancelled) { - await invoke('request_games').catch(err => - console.error('request_games failed:', err), - ); - } - } catch (err) { - console.error('Failed to register game listeners:', err); + const handleErrorEvent = ( + id: string, + message: string, + { triggerRescan = false }: { triggerRescan?: boolean } = {}, + ) => { + setGames((prev) => + prev.map((item) => + item.id === id + ? { + ...item, + status_message: message, + status_level: "error", } - }; - - void register(); - - return () => { - cancelled = true; - unlisteners.forEach(fn => fn()); - }; - }, []); - - return { - games, - setGames, - totalPeerCount, - requestGames, - markChecking, + : item + ) + ); + if (triggerRescan) rescanRef.current(); }; + + const applyProtocolMismatchSnapshot = ( + candidate: ProtocolMismatchSnapshot, + ) => { + setProtocolMismatchSnapshot((current) => + newestProtocolMismatchSnapshot(current, candidate) + ); + }; + const mismatchRegistration: AsyncRegistration = () => + listen( + "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( + "game-transfer-status-updated", + owner.guard((event) => applyTransferStatusSnapshot(event.payload)), + ); + + const registrations: AsyncRegistration[] = [ + () => + listen( + "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( + "game-download-failed", + owner.guard((event) => { + handleErrorEvent( + event.payload, + "Download failed. Please try again.", + { + triggerRescan: true, + }, + ); + }), + ), + () => + listen( + "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( + "game-install-failed", + owner.guard((event) => { + handleErrorEvent( + event.payload, + "Install failed. Please try again.", + ); + }), + ), + () => + listen( + "game-uninstall-failed", + owner.guard((event) => { + handleErrorEvent( + event.payload, + "Uninstall failed. Please try again.", + ); + }), + ), + () => + listen( + "game-remove-download-finished", + owner.guard(() => { + rescanRef.current(); + }), + ), + () => + listen( + "game-remove-download-failed", + owner.guard((event) => { + handleErrorEvent( + event.payload, + "Remove failed. Please try again.", + { + triggerRescan: true, + }, + ); + }), + ), + () => + listen( + "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("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, + }; }; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useIdentityDiagnostic.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useIdentityDiagnostic.ts new file mode 100644 index 0000000..9c2f9fb --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useIdentityDiagnostic.ts @@ -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( + 'identity-diagnostic-updated', + owner.guard(event => applySnapshot(event.payload)), + ) + )) return; + await owner.applyIfActive( + () => invoke('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 }; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useLocalNetworkSharing.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useLocalNetworkSharing.ts new file mode 100644 index 0000000..dafdd0e --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useLocalNetworkSharing.ts @@ -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(null); + const scopeRef = useRef(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( + 'local-network-sharing-updated', + scope.guard(event => applySnapshot(event.payload)), + ) + )) return; + + await scope.applyIfActive( + () => invoke('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(async () => { + try { + return { + snapshot: await invoke( + 'set_local_network_sharing', + { enabled }, + ), + error: null, + }; + } catch (mutationError) { + console.error('Failed to change Local network sharing:', mutationError); + try { + return { + snapshot: await invoke( + '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, + }; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useSettings.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useSettings.ts index a1432d5..1ac7fc6 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useSettings.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useSettings.ts @@ -1,8 +1,17 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { load } from '@tauri-apps/plugin-store'; import { type GameFilter, type GameSort, type LauncherLanguage } from '../lib/types'; import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store'; +import { + createSerializedAsyncWriter, + mergeHydratedState, + windowAsyncScope, +} from '../lib/asyncOwnership'; +import { + startAdmittedPersistenceEffect, + windowPersistenceScope, +} from '../lib/frontendPersistence'; export type Density = 'compact' | 'normal' | 'large'; export type CoverAspect = 'box' | 'square' | 'banner'; @@ -102,8 +111,21 @@ export interface UseSettings { settings: UISettings; set: (key: K, value: UISettings[K]) => void; ready: boolean; + shutdown: () => Promise; } +const settingsWriter = createSerializedAsyncWriter>( + async patch => { + const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); + const saved = await store.get(UI_SETTINGS_KEY); + await store.set(UI_SETTINGS_KEY, { + ...sanitize(saved ?? undefined), + ...patch, + }); + }, + error => console.error('Failed to persist UI settings:', error), +); + /** * Loads UI preferences from the Tauri persistent store once on mount and * writes every change back through it. Components only see a synchronous @@ -112,44 +134,69 @@ export interface UseSettings { export const useSettings = (): UseSettings => { const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [ready, setReady] = useState(false); + const settingsRef = useRef(DEFAULT_SETTINGS); + const hydratedRef = useRef(false); + const pendingEditsRef = useRef>({}); + const acceptingRef = useRef(false); + const hydrationRef = useRef>(Promise.resolve()); - useEffect(() => { - let cancelled = false; - const init = async () => { - try { - const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); - const saved = await store.get(UI_SETTINGS_KEY); - if (!cancelled) { - setSettings(sanitize(saved ?? undefined)); - } - } catch (err) { - console.error('Failed to load UI settings:', err); - } finally { - if (!cancelled) setReady(true); - } - }; - void init(); - return () => { - cancelled = true; - }; + const shutdown = useCallback(async (): Promise => { + acceptingRef.current = false; + await hydrationRef.current; + await settingsWriter.closeAndWait(); }, []); + useEffect(() => startAdmittedPersistenceEffect( + windowPersistenceScope, + shutdown, + () => { + let cancelled = false; + acceptingRef.current = true; + const init = async () => { + try { + await settingsWriter.waitForIdle(); + const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); + const saved = await store.get(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((key: K, value: UISettings[K]) => { - setSettings(prev => { - const next = { ...prev, [key]: value }; - void persist(next); - return next; - }); + if (!acceptingRef.current) return; + + const next = { ...settingsRef.current, [key]: value }; + if (!hydratedRef.current) { + pendingEditsRef.current = { + ...pendingEditsRef.current, + [key]: value, + }; + } + settingsRef.current = next; + setSettings(next); + windowAsyncScope.adopt(settingsWriter.enqueue({ [key]: value })); }, []); - return { settings, set, ready }; -}; - -const persist = async (settings: UISettings): Promise => { - try { - const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); - await store.set(UI_SETTINGS_KEY, settings); - } catch (err) { - console.error('Failed to persist UI settings:', err); - } + return { settings, set, ready, shutdown }; }; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useStreamInstallCapability.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useStreamInstallCapability.ts new file mode 100644 index 0000000..02deed7 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useStreamInstallCapability.ts @@ -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(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('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; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useThumbnails.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useThumbnails.ts index bd867ee..9feed6e 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useThumbnails.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useThumbnails.ts @@ -1,28 +1,41 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; +import { + createThumbnailRequestKey, + startOwnedThumbnailGeneration, + ThumbnailRequestGeneration, + thumbnailIdsFromRequestKey, +} from '../lib/thumbnailRequests'; +import { windowAsyncScope } from '../lib/asyncOwnership'; + /** - * Lazy, per-id cache for cover thumbnails. Returns `null` until the value is - * known; returns the empty string when the backend has nothing for the id, so - * callers can fall back to the placeholder cover art. + * Per-id cache for cover thumbnails belonging to the supplied game ids. + * Returns `null` until the value is known; returns the empty string when the + * backend has nothing for the id, so callers can use the placeholder cover. */ -export const useThumbnails = () => { +export const useThumbnails = (ids: readonly string[]) => { const [thumbnails, setThumbnails] = useState>(new Map()); - const pending = useRef>(new Set()); + const thumbnailsRef = useRef(thumbnails); + const requestKey = createThumbnailRequestKey(ids); + + useEffect(() => { + const generation = new ThumbnailRequestGeneration( + id => invoke('get_game_thumbnail', { gameId: id }), + (id, url) => { + const next = new Map(thumbnailsRef.current).set(id, url); + thumbnailsRef.current = next; + setThumbnails(next); + }, + ); + const missingIds = thumbnailIdsFromRequestKey(requestKey) + .filter(id => !thumbnailsRef.current.has(id)); + + return startOwnedThumbnailGeneration(windowAsyncScope, generation, missingIds); + }, [requestKey]); const get = useCallback((id: string): string | null => { if (thumbnails.has(id)) return thumbnails.get(id) ?? ''; - if (pending.current.has(id)) return null; - pending.current.add(id); - invoke('get_game_thumbnail', { gameId: id }) - .then(url => { - pending.current.delete(id); - setThumbnails(prev => new Map(prev).set(id, url)); - }) - .catch(() => { - pending.current.delete(id); - setThumbnails(prev => new Map(prev).set(id, '')); - }); return null; }, [thumbnails]); diff --git a/crates/lanspread-tauri-deno-ts/src/lib/asyncOwnership.ts b/crates/lanspread-tauri-deno-ts/src/lib/asyncOwnership.ts new file mode 100644 index 0000000..0a3f120 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/asyncOwnership.ts @@ -0,0 +1,444 @@ +export type AsyncCleanup = () => void | Promise; +export type AsyncRegistration = () => Promise; + +/** + * 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>(); + private readonly disposers = new Set<() => Promise>(); + private acceptingDisposers = true; + + public constructor( + private readonly reportFailure: (error: unknown) => void = () => {}, + ) {} + + public adopt(promise: Promise): 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) | 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 { + this.acceptingDisposers = false; + const disposers = [...this.disposers]; + this.disposers.clear(); + const pending: Promise[] = []; + 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 { + 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 = ( + restored: T, + pendingEdits: Partial, +): 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>(); + private readonly pendingRegistrations = new Set>(); + private readonly pendingCleanups = new Set>(); + 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( + callback: (...args: Args) => void, + ): (...args: Args) => void { + return (...args) => { + if (this.isActive()) callback(...args); + }; + } + + public register(registration: AsyncRegistration): Promise { + 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 { + return this.trackCleanup(this.runCleanup(cleanup)); + } + + private async registerInner( + registration: AsyncRegistration, + ): Promise { + 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( + operation: () => Promise, + apply: (value: T) => void, + ): Promise { + if (!this.isActive()) return Promise.resolve(false); + + return this.track(this.applyActive(operation, apply)); + } + + public applyLatestIfActive( + operation: () => Promise, + apply: (value: T) => void, + ): Promise { + 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( + operation: () => Promise, + apply: (value: T) => void, + generation?: number, + ): Promise { + 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 { + this.beginDispose(); + return this.drain(); + } + + /** Drains every admitted operation before native webview destruction. */ + public disposeForWindowClose(): Promise { + 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 { + while (this.pending.size > 0) { + await Promise.allSettled([...this.pending]); + } + } + + private track(promise: Promise): Promise { + this.pending.add(promise); + void promise.then( + () => this.pending.delete(promise), + () => this.pending.delete(promise), + ); + return promise; + } + + private trackRegistration(promise: Promise): Promise { + this.pendingRegistrations.add(promise); + void promise.then( + () => this.pendingRegistrations.delete(promise), + () => this.pendingRegistrations.delete(promise), + ); + return this.track(promise); + } + + private trackCleanup(promise: Promise): Promise { + 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 { + 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; + registerError: (handler: (payload: unknown) => void) => Promise; + destroy: () => Promise; +} + +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 => { + let settle!: (result: CompanionWindowCreationResult) => void; + let settled = false; + const outcome = new Promise((resolve) => { + settle = (result) => { + if (settled) return; + settled = true; + resolve(result); + }; + }); + + const register = ( + operation: () => Promise, + ): Promise => { + 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 | undefined; + const complete = (): Promise => { + 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, +): Promise => { + 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 { + enqueue: (value: T) => Promise; + waitForIdle: () => Promise; + closeAndWait: () => Promise; +} + +/** Serializes writes and keeps a failed write from poisoning later work. */ +export const createSerializedAsyncWriter = ( + write: (value: T) => Promise, + reportFailure: (error: unknown) => void, +): SerializedAsyncWriter => { + let tail = Promise.resolve(); + let accepting = true; + + const enqueue = (value: T): Promise => { + 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 => { + let observed: Promise; + do { + observed = tail; + await observed; + } while (observed !== tail); + }; + + return { + enqueue, + waitForIdle, + closeAndWait: () => { + accepting = false; + return waitForIdle(); + }, + }; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts b/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts index 921aaec..d57e374 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts @@ -1,6 +1,7 @@ import { CallToPlayAction, - CallToPlayEvent, + CallToPlayView, + CallToPlayViewEvent, CallToPlayParticipant, Nomination, } from './types'; @@ -13,17 +14,22 @@ export const CALL_TO_PLAY_CONNECTING_MESSAGE = export const callToPlayPublishErrorMessage = (error: unknown): string => { const detail = error instanceof Error ? error.message : String(error); - if (detail.includes('Call to Play event is obsolete') - || detail.includes('Call to Play history is missing') + if (detail.includes('unknown or expired') + || detail.includes('already terminal') ) { return 'This Call to Play has expired or already finished.'; } - if (detail.includes('Call to Play event history is full')) { + if (detail.includes('local event history is full')) { return 'Call to Play has reached its active update limit. Start or cancel an active call, then try again.'; } return 'Could not send this Call to Play update.'; }; +export const replaceCallToPlayView = ( + _previous: CallToPlayView, + incoming: CallToPlayView, +): CallToPlayView => incoming; + export const extendDeadline = ( now: number, currentDeadline: number, @@ -44,7 +50,7 @@ interface MutableNomination extends Nomination { messageIds: Set; } -const compareEvents = (a: CallToPlayEvent, b: CallToPlayEvent): number => +const compareEvents = (a: CallToPlayViewEvent, b: CallToPlayViewEvent): number => a.at - b.at || a.id.localeCompare(b.id); type CreatePayload = Extract['Create']; @@ -117,7 +123,7 @@ export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus }; export const reduceCallToPlayEvents = ( - input: ReadonlyArray, + input: ReadonlyArray, now: number, ): Nomination[] => { const nominations = [...groupEvents(input).values()] @@ -126,35 +132,11 @@ export const reduceCallToPlayEvents = ( return sortNominations(nominations); }; -export const pruneCallToPlayEvents = ( - previous: ReadonlyMap, - now: number, -): ReadonlyMap => { - const retiredEventIds = new Set(); - for (const events of groupEvents([...previous.values()]).values()) { - if (deriveNomination(events, now) !== null) continue; - - const hasCreate = events.some(event => createPayload(event.action) !== null); - const expiredTombstone = events.some(event => - (event.action === 'Start' || event.action === 'Cancel') - && now - event.at > TERMINAL_RETENTION_MS - ); - if (hasCreate || expiredTombstone) { - for (const event of events) retiredEventIds.add(event.id); - } - } - if (retiredEventIds.size === 0) return previous; - - const next = new Map(previous); - for (const eventId of retiredEventIds) next.delete(eventId); - return next; -}; - const groupEvents = ( - input: ReadonlyArray, -): Map => { + input: ReadonlyArray, +): Map => { const unique = new Map(input.map(event => [event.id, event])); - const byCall = new Map(); + const byCall = new Map(); for (const event of unique.values()) { const events = byCall.get(event.call_id) ?? []; events.push(event); @@ -164,7 +146,7 @@ const groupEvents = ( }; const deriveNomination = ( - events: CallToPlayEvent[], + events: CallToPlayViewEvent[], now: number, ): Nomination | null => { events.sort(compareEvents); @@ -176,15 +158,15 @@ const deriveNomination = ( const nomination: MutableNomination = { id: create.call_id, gameId: payload.game_id, - creatorId: create.actor_id, - creator: create.actor_name, + creatorId: create.author_id, + creator: create.author_name, maxPlayers: payload.max_players, createdAt: create.at, scheduledFor: payload.scheduled_for, deadline: payload.deadline, participants: { - [create.actor_id]: { - name: create.actor_name, + [create.author_id]: { + name: create.author_name, status: payload.scheduled_for === null ? 'ready' : 'in', joinedAt: create.at, }, @@ -214,7 +196,7 @@ const deriveNomination = ( return result; }; -const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => { +const applyEvent = (nomination: MutableNomination, event: CallToPlayViewEvent): void => { if (isTerminal(nomination)) return; const action = event.action; @@ -225,9 +207,9 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void const response = respondPayload(action); if (response) { - const existing = nomination.participants[event.actor_id]; - nomination.participants[event.actor_id] = { - name: event.actor_name, + const existing = nomination.participants[event.author_id]; + nomination.participants[event.author_id] = { + name: event.author_name, status: response.ready_at === null ? 'ready' : 'pending', joinedAt: existing?.joinedAt ?? event.at, ...(response.ready_at === null ? {} : { readyAt: response.ready_at }), @@ -236,12 +218,12 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void } const message = messagePayload(action); - if (message && !nomination.messageIds.has(message.message_id)) { - nomination.messageIds.add(message.message_id); + if (message && !nomination.messageIds.has(event.id)) { + nomination.messageIds.add(event.id); nomination.messages.push({ - id: message.message_id, - fromId: event.actor_id, - from: event.actor_name, + id: event.id, + fromId: event.author_id, + from: event.author_name, text: message.text, at: event.at, }); @@ -251,7 +233,7 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void const extension = addTimePayload(action); if (extension - && event.actor_id === nomination.creatorId + && event.author_id === nomination.creatorId ) { nomination.deadline = extension.deadline; nomination.state = 'open'; @@ -260,32 +242,32 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void const applyUnitAction = ( nomination: MutableNomination, - event: CallToPlayEvent, + event: CallToPlayViewEvent, action: Extract, ): void => { switch (action) { case 'Rsvp': { - const existing = nomination.participants[event.actor_id]; - nomination.participants[event.actor_id] = { - name: event.actor_name, + const existing = nomination.participants[event.author_id]; + nomination.participants[event.author_id] = { + name: event.author_name, status: 'in', joinedAt: existing?.joinedAt ?? event.at, }; break; } case 'Leave': - if (event.actor_id !== nomination.creatorId) { - delete nomination.participants[event.actor_id]; + if (event.author_id !== nomination.creatorId) { + delete nomination.participants[event.author_id]; } break; case 'Cancel': - if (event.actor_id === nomination.creatorId) { + if (event.author_id === nomination.creatorId) { nomination.state = 'cancelled'; nomination.terminalAt = event.at; } break; case 'Start': - if (event.actor_id === nomination.creatorId) { + if (event.author_id === nomination.creatorId) { nomination.state = 'running'; nomination.terminalAt = event.at; } @@ -293,21 +275,6 @@ const applyUnitAction = ( } }; -export const callToPlayEvent = ( - callId: string, - actorId: string, - actorName: string, - action: CallToPlayAction, - at = Date.now(), -): CallToPlayEvent => ({ - id: globalThis.crypto.randomUUID(), - call_id: callId, - actor_id: actorId, - actor_name: actorName, - at, - action, -}); - export const formatClock = (timestamp: number): string => { const date = new Date(timestamp); return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/callToPlayOwnership.ts b/crates/lanspread-tauri-deno-ts/src/lib/callToPlayOwnership.ts new file mode 100644 index 0000000..c60a683 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/callToPlayOwnership.ts @@ -0,0 +1,223 @@ +import { + AsyncOwner, + type AsyncCleanup, + type AsyncRegistration, + windowAsyncScope, +} from './asyncOwnership'; + +export type CallToPlayRetryCallback = () => Promise; +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) | 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 = 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( + callback: (...args: Args) => void, + ): (...args: Args) => void { + return this.owner.guard(callback); + } + + public registerListener(registration: AsyncRegistration): Promise { + return this.owner.register(registration); + } + + public applyIfActive( + operation: () => Promise, + apply: (value: T) => void, + ): Promise { + 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( + operation: () => Promise, + apply: (value: T) => void, + ): Promise { + 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( + 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, + 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 { + this.releaseWindowOwnership(); + this.stopRetry(); + this.mutationAdmissionOpen = false; + return Promise.all([this.mutationTail, this.owner.dispose()]).then(() => {}); + } + + private disposeForWindowClose(): Promise { + 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 { + 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); + } +} diff --git a/crates/lanspread-tauri-deno-ts/src/lib/frontendBootstrap.ts b/crates/lanspread-tauri-deno-ts/src/lib/frontendBootstrap.ts new file mode 100644 index 0000000..cf73c0b --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/frontendBootstrap.ts @@ -0,0 +1,127 @@ +import { type AsyncCleanup } from './asyncOwnership'; + +export interface FrontendCloseRequest { + preventDefault: () => void; +} + +export interface FrontendBootstrapOptions { + registerCloseRequested: ( + handler: (event: FrontendCloseRequest) => void | Promise, + ) => Promise; + disposeOwners: () => Promise; + drainPersistence: () => Promise; + destroyWindow: () => Promise; + 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, +): Promise => { + 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 => { + let closing: Promise | undefined; + let publishCloseCleanup!: (cleanup: AsyncCleanup) => void; + const closeCleanupReady = new Promise(resolve => { + publishCloseCleanup = resolve; + }); + + const finalizeWindow = (): Promise => { + 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; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/frontendPersistence.ts b/crates/lanspread-tauri-deno-ts/src/lib/frontendPersistence.ts new file mode 100644 index 0000000..a55298a --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/frontendPersistence.ts @@ -0,0 +1,95 @@ +export type PersistenceShutdown = () => Promise; +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(); + private readonly pending = new Set>(); + + 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 { + 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; + 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); +}); diff --git a/crates/lanspread-tauri-deno-ts/src/lib/gameDirectory.ts b/crates/lanspread-tauri-deno-ts/src/lib/gameDirectory.ts new file mode 100644 index 0000000..a7a2290 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/gameDirectory.ts @@ -0,0 +1,54 @@ +export interface GameDirectoryUpdatePorts { + updateBackend: (requestedPath: string) => Promise; + persist: (acceptedPath: string) => Promise; + reportPersistenceError: (error: unknown) => void; +} + +export interface GameDirectoryHydrationPorts { + loadSavedPath: () => Promise; + acceptSavedPath: (savedPath: string) => Promise; + 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 => { + 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 => { + 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; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts index ec7a3a2..d3551eb 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts @@ -1,178 +1,315 @@ import { - ActiveOperation, - ActiveOperationKind, - DerivedState, - Game, - GameFilter, - GameSort, - GamesListPayload, - InstallStatus, -} from './types'; + ActiveOperation, + ActiveOperationKind, + DerivedState, + DownloadProgressPayload, + Game, + GameFilter, + GameSort, + GameTransferStatus, + GameTransferStatusSnapshot, + InstallStatus, + StatusLevel, +} from "./types"; const IN_PROGRESS_INSTALL_STATUSES = new Set([ - InstallStatus.CheckingPeers, - InstallStatus.Downloading, - InstallStatus.Installing, - InstallStatus.Uninstalling, - InstallStatus.Removing, + InstallStatus.Downloading, + InstallStatus.Installing, + InstallStatus.Uninstalling, + InstallStatus.Removing, ]); export const isInProgress = (status: InstallStatus): boolean => - IN_PROGRESS_INSTALL_STATUSES.has(status); + IN_PROGRESS_INSTALL_STATUSES.has(status); -export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => { - switch (op) { - case ActiveOperationKind.Downloading: - return InstallStatus.Downloading; - case ActiveOperationKind.Installing: - case ActiveOperationKind.Updating: - return InstallStatus.Installing; - case ActiveOperationKind.Uninstalling: - return InstallStatus.Uninstalling; - case ActiveOperationKind.RemovingDownload: - return InstallStatus.Removing; - } +export const installStatusFromActiveOperation = ( + op: ActiveOperationKind, +): InstallStatus => { + switch (op) { + case ActiveOperationKind.Downloading: + return InstallStatus.Downloading; + case ActiveOperationKind.Installing: + case ActiveOperationKind.Updating: + return InstallStatus.Installing; + case ActiveOperationKind.Uninstalling: + return InstallStatus.Uninstalling; + case ActiveOperationKind.RemovingDownload: + return InstallStatus.Removing; + } }; -export const activeStatusById = (ops: ActiveOperation[] = []): Map => - new Map(ops.map(op => [op.id, installStatusFromActiveOperation(op.operation)])); +export const activeStatusById = ( + ops: ActiveOperation[] = [], +): Map => + new Map( + ops.map((op) => [op.id, installStatusFromActiveOperation(op.operation)]), + ); -export const normalizeGamesListPayload = ( - payload: GamesListPayload | Game[], -): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload; +export const INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT: GameTransferStatusSnapshot = + { + revision: 0, + statuses: {}, + openAttempts: {}, + }; + +/** Keeps listener events and GamesList snapshots monotonic across async races. */ +export const newestGameTransferStatusSnapshot = ( + current: GameTransferStatusSnapshot, + candidate: GameTransferStatusSnapshot, +): GameTransferStatusSnapshot => + candidate.revision > current.revision ? candidate : current; + +export interface GameTransferStatusPresentation { + message: string; + level: StatusLevel; +} + +export const gameTransferStatusPresentation = ( + status: GameTransferStatus | undefined, +): GameTransferStatusPresentation | undefined => { + switch (status) { + case GameTransferStatus.Verifying: + return { + message: "Verifying downloaded chunks", + level: "info", + }; + case GameTransferStatus.Retrying: + return { + message: "A source sent invalid data; retrying another nearby peer", + level: "warning", + }; + case GameTransferStatus.Exhausted: + return { + message: "No nearby peer could provide the verified catalog version", + level: "error", + }; + case undefined: + return undefined; + } +}; + +/** Transient verification/retry activity replaces ordinary download copy. */ +export const downloadProgressTransferLabel = ( + game: Game, +): string | undefined => { + if (game.transfer_status === GameTransferStatus.Exhausted) return undefined; + return gameTransferStatusPresentation(game.transfer_status)?.message; +}; + +export const downloadProgressAriaLabel = (game: Game): string => { + const transferLabel = downloadProgressTransferLabel(game); + return transferLabel + ? `${transferLabel}: ${game.name}` + : `Downloading ${game.name}`; +}; + +export const gameTransferStatusFor = ( + snapshot: GameTransferStatusSnapshot, + gameId: string, +): GameTransferStatus | undefined => + Object.prototype.hasOwnProperty.call(snapshot.statuses, gameId) + ? snapshot.statuses[gameId] + : undefined; + +export const gameTransferOpenAttemptFor = ( + snapshot: GameTransferStatusSnapshot, + gameId: string, +): string | undefined => + Object.prototype.hasOwnProperty.call(snapshot.openAttempts, gameId) + ? snapshot.openAttempts[gameId] + : undefined; + +export const applyDownloadProgress = ( + games: Game[], + snapshot: GameTransferStatusSnapshot, + payload: DownloadProgressPayload, +): Game[] => { + const openAttempt = gameTransferOpenAttemptFor(snapshot, payload.id); + if (openAttempt === undefined || openAttempt !== payload.attemptId) { + return games; + } + + const { id, ...downloadProgress } = payload; + return games.map((game) => + game.id === id ? { ...game, download_progress: downloadProgress } : game + ); +}; + +export const applyGameTransferStatusSnapshot = ( + games: Game[], + snapshot: GameTransferStatusSnapshot, +): Game[] => + games.map((game) => { + const openAttempt = gameTransferOpenAttemptFor(snapshot, game.id); + return { + ...game, + transfer_status: gameTransferStatusFor(snapshot, game.id), + download_progress: openAttempt !== undefined && + game.download_progress?.attemptId === openAttempt + ? game.download_progress + : undefined, + }; + }); /** * Reconcile a freshly received backend snapshot. Core operation status is * derived only from the backend active-operation snapshot plus installed state. */ export const mergeGameUpdate = ( - incoming: Game, - previous?: Game, - activeStatus?: InstallStatus, + incoming: Game, + previous?: Game, + activeStatus?: InstallStatus, + transferStatus?: GameTransferStatus, ): Game => { - const installStatus = activeStatus - ?? (incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled); + const installStatus = activeStatus ?? + (incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled); - const localStateChanged = previous !== undefined - && (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded); - const statusChanged = previous !== undefined - && previous.install_status !== installStatus; - const clearStatus = localStateChanged - || (statusChanged && (activeStatus !== undefined || isInProgress(previous.install_status))); + const localStateChanged = previous !== undefined && + (previous.installed !== incoming.installed || + previous.downloaded !== incoming.downloaded); + const statusChanged = previous !== undefined && + previous.install_status !== installStatus; + const clearStatus = localStateChanged || + (statusChanged && + (activeStatus !== undefined || isInProgress(previous.install_status))); - return { - ...incoming, - availability: incoming.availability, - install_status: installStatus, - status_message: clearStatus ? undefined : previous?.status_message, - status_level: clearStatus ? undefined : previous?.status_level, - download_progress: installStatus === InstallStatus.Downloading - ? previous?.download_progress - : undefined, - peer_count: incoming.peer_count ?? 0, - }; + return { + ...incoming, + availability: incoming.availability, + install_status: installStatus, + status_message: clearStatus ? undefined : previous?.status_message, + status_level: clearStatus ? undefined : previous?.status_level, + transfer_status: transferStatus, + download_progress: installStatus === InstallStatus.Downloading + ? previous?.download_progress + : undefined, + peer_count: incoming.peer_count ?? 0, + }; }; /** Visual card state — used for state chip color and action button styling. */ export const deriveState = (game: Game): DerivedState => { - if (game.install_status === InstallStatus.Downloading) return 'downloading'; - if (isInProgress(game.install_status)) return 'busy'; - if (game.installed) return 'installed'; - if (game.downloaded) return 'local'; - return 'none'; + if (game.install_status === InstallStatus.Downloading) return "downloading"; + if (isInProgress(game.install_status)) return "busy"; + if (game.installed) return "installed"; + if (game.downloaded) return "local"; + return "none"; }; export const isInstalledNotShareable = (game: Game): boolean => - game.installed && !game.downloaded; + game.installed && !game.downloaded; export const stateChipLabel = (game: Game): string => { - const state = deriveState(game); - if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable'; - switch (state) { - case 'installed': return 'Installed'; - case 'local': return 'Local'; - case 'downloading': return 'Downloading'; - case 'busy': return 'Working'; - case 'none': return ''; - } + const state = deriveState(game); + if (state === "installed" && isInstalledNotShareable(game)) { + return "Not shareable"; + } + switch (state) { + case "installed": + return "Installed"; + case "local": + return "Local"; + case "downloading": + return "Downloading"; + case "busy": + return "Working"; + case "none": + return ""; + } }; export const gameStatusLabel = (game: Game): string => { - const state = deriveState(game); - if (state === 'installed' && isInstalledNotShareable(game)) { - return 'Installed, not shareable'; - } - switch (state) { - case 'installed': return 'Installed'; - case 'local': return 'Downloaded'; - case 'downloading': return 'Downloading'; - case 'busy': return 'Working…'; - case 'none': return 'Not downloaded'; - } + const state = deriveState(game); + if (state === "installed" && isInstalledNotShareable(game)) { + return "Installed, not shareable"; + } + switch (state) { + case "installed": + return "Installed"; + case "local": + return "Downloaded"; + case "downloading": + return "Downloading"; + case "busy": + return "Working…"; + case "none": + return "Not downloaded"; + } }; export const isUnavailable = (game: Game): boolean => - !game.installed - && !game.downloaded - && game.peer_count === 0 - && game.install_status === InstallStatus.NotInstalled; + !game.installed && + !game.downloaded && + game.peer_count === 0 && + game.install_status === InstallStatus.NotInstalled; const parseVersionStamp = (version: string | undefined): number | null => { - if (!version || !/^\d{8}$/.test(version)) return null; - const parsed = parseInt(version, 10); - return Number.isNaN(parsed) ? null : parsed; + if (!version || !/^\d{8}$/.test(version)) return null; + const parsed = parseInt(version, 10); + return Number.isNaN(parsed) ? null : parsed; }; export const compareVersionStamps = ( - left: string | undefined, - right: string | undefined, + left: string | undefined, + right: string | undefined, ): number | null => { - const parsedLeft = parseVersionStamp(left); - const parsedRight = parseVersionStamp(right); - if (parsedLeft === null || parsedRight === null) return null; - return parsedLeft - parsedRight; + const parsedLeft = parseVersionStamp(left); + const parsedRight = parseVersionStamp(right); + if (parsedLeft === null || parsedRight === null) return null; + return parsedLeft - parsedRight; }; export const hasNewerLocalVersion = (game: Game): boolean => - (compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0; + (compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0; export const needsUpdate = (game: Game): boolean => { - if (!game.installed) return false; - if (game.peer_count <= 0) return false; - if (!game.local_version && game.eti_game_version) return true; - return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0; + if (!game.installed) return false; + if (game.peer_count <= 0) return false; + if (!game.local_version && game.eti_game_version) return true; + return (compareVersionStamps(game.eti_game_version, game.local_version) ?? + 0) > 0; }; -export const canStreamInstall = (game: Game): boolean => - !game.downloaded - && !game.installed - && game.peer_count > 0 - && !isInProgress(game.install_status); +export const canStreamInstall = ( + game: Game, + catalogSupported: boolean, +): boolean => + catalogSupported && + !game.downloaded && + !game.installed && + game.peer_count > 0 && + !isInProgress(game.install_status); /** What pressing the card's main action button should do, given the state. */ -export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled'; +export type PrimaryAction = + | "play" + | "install" + | "update" + | "download" + | "busy" + | "disabled"; export const primaryActionFor = (game: Game): PrimaryAction => { - if (isInProgress(game.install_status)) return 'busy'; - if (isUnavailable(game)) return 'disabled'; - if (!game.installed) return game.downloaded ? 'install' : 'download'; - if (needsUpdate(game)) return 'update'; - return 'play'; + if (isInProgress(game.install_status)) return "busy"; + if (isUnavailable(game)) return "disabled"; + if (!game.installed) return game.downloaded ? "install" : "download"; + if (needsUpdate(game)) return "update"; + return "play"; }; export const formatBytesPerSecond = (bytesPerSecond: number): string => { - const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']; - let value = Math.max(0, bytesPerSecond); - let unitIndex = 0; + const units = ["B/s", "KB/s", "MB/s", "GB/s"]; + let value = Math.max(0, bytesPerSecond); + let unitIndex = 0; - while (value >= 1000 && unitIndex < units.length - 1) { - value /= 1000; - unitIndex += 1; - } + while (value >= 1000 && unitIndex < units.length - 1) { + value /= 1000; + unitIndex += 1; + } - if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`; - const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2; - return `${value.toFixed(precision)} ${units[unitIndex]}`; + if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`; + const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(precision)} ${units[unitIndex]}`; }; const MB = 1024 * 1024; @@ -180,137 +317,145 @@ const GB = 1024 * 1024 * 1024; const DECIMAL_MB = 1_000_000; const stripTrailingDecimalZeros = (value: string): string => - value.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, ''); + value.replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, ""); export const downloadProgressPercent = (game: Game): number => { - const progress = game.download_progress; - if (!progress || progress.total_bytes <= 0) return 0; + const progress = game.download_progress; + if (!progress || progress.total_bytes <= 0) return 0; - return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100)); + return Math.max( + 0, + Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100), + ); }; export const formatDownloadSpeed = (bytesPerSecond: number): string => { - const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB; - return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`; + const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB; + return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`; }; export const formatDownloadSpeedShort = (bytesPerSecond: number): string => { - const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB; - return `${Math.round(mb)} MB/s`; + const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB; + return `${Math.round(mb)} MB/s`; }; export const formatDownloadBytes = (bytes: number): string => { - const safeBytes = Math.max(0, bytes); - if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`; + const safeBytes = Math.max(0, bytes); + if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`; - const gb = safeBytes / GB; - return `${stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))} GB`; + const gb = safeBytes / GB; + return `${ + stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2)) + } GB`; }; export const formatDownloadEta = (seconds: number): string => { - if (!Number.isFinite(seconds) || seconds <= 0) return '—'; - if (seconds < 60) return `${Math.round(seconds)} s`; + if (!Number.isFinite(seconds) || seconds <= 0) return "—"; + if (seconds < 60) return `${Math.round(seconds)} s`; - const minutes = Math.round(seconds / 60); - if (minutes < 60) return `${minutes} min`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} min`; - return `${Math.floor(minutes / 60)} h ${minutes % 60} min`; + return `${Math.floor(minutes / 60)} h ${minutes % 60} min`; }; export const inProgressLabel = (game: Game): string | undefined => { - switch (game.install_status) { - case InstallStatus.CheckingPeers: - return 'Checking peers…'; - case InstallStatus.Downloading: - return game.download_progress - ? `Downloading… ${formatBytesPerSecond(game.download_progress.bytes_per_second)}` - : 'Downloading…'; - case InstallStatus.Installing: - return 'Installing…'; - case InstallStatus.Uninstalling: - return 'Uninstalling…'; - case InstallStatus.Removing: - return 'Removing…'; - default: - return undefined; - } + switch (game.install_status) { + case InstallStatus.Downloading: + return game.download_progress + ? `Downloading… ${ + formatBytesPerSecond(game.download_progress.bytes_per_second) + }` + : "Downloading…"; + case InstallStatus.Installing: + return "Installing…"; + case InstallStatus.Uninstalling: + return "Uninstalling…"; + case InstallStatus.Removing: + return "Removing…"; + default: + return undefined; + } }; export const actionLabel = (game: Game): string => { - const busy = inProgressLabel(game); - if (busy) return busy; - if (isUnavailable(game)) return 'Unavailable'; - if (!game.installed) return game.downloaded ? 'Install' : 'Download'; - if (needsUpdate(game)) return 'Update'; - return 'Play'; + const busy = inProgressLabel(game); + if (busy) return busy; + if (isUnavailable(game)) return "Unavailable"; + if (!game.installed) return game.downloaded ? "Install" : "Download"; + if (needsUpdate(game)) return "Update"; + return "Play"; }; /** Counts shown on filter pills. */ export interface FilterCounts { - all: number; - local: number; - installed: number; + all: number; + local: number; + installed: number; } const isDownloading = (game: Game): boolean => - game.install_status === InstallStatus.Downloading; + game.install_status === InstallStatus.Downloading; const isNetworkGame = (game: Game): boolean => - game.installed || game.downloaded || isDownloading(game) || game.peer_count > 0; + game.installed || game.downloaded || isDownloading(game) || + game.peer_count > 0 || game.transfer_status === GameTransferStatus.Exhausted; export const countByFilter = (games: Game[]): FilterCounts => ({ - all: games.filter(isNetworkGame).length, - local: games.filter(g => g.installed || g.downloaded || isDownloading(g)).length, - installed: games.filter(g => g.installed).length, + all: games.filter(isNetworkGame).length, + local: + games.filter((g) => g.installed || g.downloaded || isDownloading(g)).length, + installed: games.filter((g) => g.installed).length, }); const matchesFilter = (game: Game, filter: GameFilter): boolean => { - switch (filter) { - case 'local': - return game.installed || game.downloaded || isDownloading(game); - case 'installed': - return game.installed; - case 'all': - return isNetworkGame(game); - } + switch (filter) { + case "local": + return game.installed || game.downloaded || isDownloading(game); + case "installed": + return game.installed; + case "all": + return isNetworkGame(game); + } }; const STATE_SORT_ORDER: Record = { - installed: 0, - local: 1, - downloading: 2, - busy: 3, - none: 4, + installed: 0, + local: 1, + downloading: 2, + busy: 3, + none: 4, }; const compareByState = (a: Game, b: Game): number => { - const diff = STATE_SORT_ORDER[deriveState(a)] - STATE_SORT_ORDER[deriveState(b)]; - return diff !== 0 ? diff : a.name.localeCompare(b.name); + const diff = STATE_SORT_ORDER[deriveState(a)] - + STATE_SORT_ORDER[deriveState(b)]; + return diff !== 0 ? diff : a.name.localeCompare(b.name); }; export const applyFilterAndSort = ( - games: Game[], - filter: GameFilter, - sort: GameSort, - query: string, + games: Game[], + filter: GameFilter, + sort: GameSort, + query: string, ): Game[] => { - let list = games.filter(g => matchesFilter(g, filter)); - const q = query.trim().toLowerCase(); - if (q) { - list = list.filter(g => - g.name.toLowerCase().includes(q) - || (g.genre?.toLowerCase().includes(q) ?? false) - || (g.publisher?.toLowerCase().includes(q) ?? false), - ); - } - switch (sort) { - case 'az': - return [...list].sort((a, b) => a.name.localeCompare(b.name)); - case 'sizeDesc': - return [...list].sort((a, b) => b.size - a.size); - case 'sizeAsc': - return [...list].sort((a, b) => a.size - b.size); - case 'status': - return [...list].sort(compareByState); - } + let list = games.filter((g) => matchesFilter(g, filter)); + const q = query.trim().toLowerCase(); + if (q) { + list = list.filter((g) => + g.name.toLowerCase().includes(q) || + (g.genre?.toLowerCase().includes(q) ?? false) || + (g.publisher?.toLowerCase().includes(q) ?? false) + ); + } + switch (sort) { + case "az": + return [...list].sort((a, b) => a.name.localeCompare(b.name)); + case "sizeDesc": + return [...list].sort((a, b) => b.size - a.size); + case "sizeAsc": + return [...list].sort((a, b) => a.size - b.size); + case "status": + return [...list].sort(compareByState); + } }; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/identityDiagnostic.ts b/crates/lanspread-tauri-deno-ts/src/lib/identityDiagnostic.ts new file mode 100644 index 0000000..bc9af54 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/identityDiagnostic.ts @@ -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; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/localNetworkSharing.ts b/crates/lanspread-tauri-deno-ts/src/lib/localNetworkSharing.ts new file mode 100644 index 0000000..1f00f39 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/localNetworkSharing.ts @@ -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 = 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( + callback: (...args: Args) => void, + ): (...args: Args) => void { + return this.owner.guard(callback); + } + + public registerListener(registration: AsyncRegistration): Promise { + return this.owner.register(registration); + } + + public applyIfActive( + operation: () => Promise, + apply: (value: T) => void, + ): Promise { + return this.owner.applyIfActive(operation, apply); + } + + public applyMutationIfActive( + operation: () => Promise, + apply: (value: T) => void, + ): Promise { + 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 { + this.releaseWindowOwnership(); + this.mutationAdmissionOpen = false; + return Promise.all([this.mutationTail, this.owner.dispose()]).then(() => {}); + } + + private disposeForWindowClose(): Promise { + this.releaseWindowOwnership(); + this.mutationAdmissionOpen = false; + return Promise.all([ + this.mutationTail, + this.owner.disposeForWindowClose(), + ]).then(() => {}); + } +} diff --git a/crates/lanspread-tauri-deno-ts/src/lib/protocolMismatch.ts b/crates/lanspread-tauri-deno-ts/src/lib/protocolMismatch.ts new file mode 100644 index 0000000..22bff33 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/protocolMismatch.ts @@ -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; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/thumbnailRequests.ts b/crates/lanspread-tauri-deno-ts/src/lib/thumbnailRequests.ts new file mode 100644 index 0000000..111a573 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/thumbnailRequests.ts @@ -0,0 +1,78 @@ +import { type AsyncAdoptionScope } from './asyncOwnership'; + +export type ThumbnailLoader = (id: string) => Promise; +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>(); + + constructor( + private readonly load: ThumbnailLoader, + private readonly publish: ThumbnailPublisher, + ) {} + + start(ids: readonly string[]): Promise { + 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 { + this.disposed = true; + return this.drain(); + } + + private async drain(): Promise { + while (this.pending.size > 0) { + await Promise.allSettled([...this.pending]); + } + } + + private async loadOne(id: string): Promise { + 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()); + }; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/types.ts b/crates/lanspread-tauri-deno-ts/src/lib/types.ts index 82c220c..8dece8a 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/types.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/types.ts @@ -1,137 +1,183 @@ export enum InstallStatus { - NotInstalled = 'NotInstalled', - CheckingPeers = 'CheckingPeers', - Downloading = 'Downloading', - Installing = 'Installing', - Uninstalling = 'Uninstalling', - Removing = 'Removing', - Installed = 'Installed', + NotInstalled = "NotInstalled", + Downloading = "Downloading", + Installing = "Installing", + Uninstalling = "Uninstalling", + Removing = "Removing", + Installed = "Installed", } export enum GameAvailability { - Ready = 'Ready', - LocalOnly = 'LocalOnly', + Ready = "Ready", + LocalOnly = "LocalOnly", } export enum ActiveOperationKind { - Downloading = 'Downloading', - Installing = 'Installing', - Updating = 'Updating', - Uninstalling = 'Uninstalling', - RemovingDownload = 'RemovingDownload', + Downloading = "Downloading", + Installing = "Installing", + Updating = "Updating", + Uninstalling = "Uninstalling", + RemovingDownload = "RemovingDownload", } -export type StatusLevel = 'info' | 'warning' | 'error'; +export type StatusLevel = "info" | "warning" | "error"; + +export enum GameTransferStatus { + Verifying = "verifying", + Retrying = "retrying", + Exhausted = "exhausted", +} + +export interface GameTransferStatusSnapshot { + revision: number; + statuses: Record; + openAttempts: Record; +} export interface DownloadProgress { - downloaded_bytes: number; - total_bytes: number; - bytes_per_second: number; - active_peer_count: number; + attemptId: string; + downloaded_bytes: number; + total_bytes: number; + bytes_per_second: number; + active_peer_count: number; } export interface DownloadProgressPayload extends DownloadProgress { - id: string; + id: string; } export interface Game { - id: string; - name: string; - description: string; - /** Bytes. */ - size: number; - /** Raw bytes — unused in UI, kept for parity with backend payload. */ - thumbnail?: Uint8Array | number[]; - downloaded: boolean; - installed: boolean; - availability: GameAvailability; - install_status: InstallStatus; - eti_game_version?: string; - local_version?: string; - /** Optional richer metadata surfaced by the backend. */ - release_year?: string; - publisher?: string; - max_players?: number; - version?: string; - genre?: string; - status_message?: string; - status_level?: StatusLevel; - download_progress?: DownloadProgress; - peer_count: number; - can_host_server?: boolean; - active_outbound_transfers?: number; - installed_peer_count?: number; + id: string; + name: string; + description: string; + /** Bytes. */ + size: number; + /** Raw bytes — unused in UI, kept for parity with backend payload. */ + thumbnail?: Uint8Array | number[]; + downloaded: boolean; + installed: boolean; + availability: GameAvailability; + install_status: InstallStatus; + eti_game_version?: string; + local_version?: string; + /** Optional richer metadata surfaced by the backend. */ + release_year?: string; + publisher?: string; + max_players?: number; + version?: string; + genre?: string; + status_message?: string; + status_level?: StatusLevel; + transfer_status?: GameTransferStatus; + download_progress?: DownloadProgress; + peer_count: number; + can_host_server?: boolean; + active_outbound_transfers?: number; + installed_peer_count?: number; } export interface ActiveOperation { - id: string; - operation: ActiveOperationKind; + id: string; + operation: ActiveOperationKind; } export interface GamesListPayload { - games: Game[]; - active_operations?: ActiveOperation[]; + games: Game[]; + active_operations?: ActiveOperation[]; + transfer_status: GameTransferStatusSnapshot; +} + +export interface ProtocolMismatch { + observed: number | null; + expected: number; } /** Library filter chip — what subset of the catalog to show. */ -export type GameFilter = 'all' | 'local' | 'installed'; +export type GameFilter = "all" | "local" | "installed"; /** Library sort order. */ -export type GameSort = 'az' | 'sizeDesc' | 'sizeAsc' | 'status'; +export type GameSort = "az" | "sizeDesc" | "sizeAsc" | "status"; /** Visual state of a card. Derived from backend operation status and local flags. */ -export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'busy'; +export type DerivedState = + | "installed" + | "local" + | "downloading" + | "none" + | "busy"; /** Two-character language code passed through to game scripts. */ -export type LauncherLanguage = 'en' | 'de'; +export type LauncherLanguage = "en" | "de"; -export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending'; +export type CallToPlayParticipantStatus = "ready" | "in" | "pending"; export interface CallToPlayParticipant { - name: string; - status: CallToPlayParticipantStatus; - joinedAt: number; - readyAt?: number; + name: string; + status: CallToPlayParticipantStatus; + joinedAt: number; + readyAt?: number; } export interface CallToPlayMessage { - id: string; - fromId: string; - from: string; - text: string; - at: number; + id: string; + fromId: string; + from: string; + text: string; + at: number; } export interface Nomination { - id: string; - gameId: string; - creatorId: string; - creator: string; - maxPlayers: number; - createdAt: number; - scheduledFor: number | null; - deadline: number; - participants: Record; - messages: CallToPlayMessage[]; - state: 'open' | 'done' | 'running' | 'cancelled'; - terminalAt: number | null; + id: string; + gameId: string; + creatorId: string; + creator: string; + maxPlayers: number; + createdAt: number; + scheduledFor: number | null; + deadline: number; + participants: Record; + messages: CallToPlayMessage[]; + state: "open" | "done" | "running" | "cancelled"; + terminalAt: number | null; } export type CallToPlayAction = - | { Create: { game_id: string; max_players: number; scheduled_for: number | null; deadline: number } } - | { Respond: { ready_at: number | null } } - | 'Rsvp' - | { SendMessage: { message_id: string; text: string } } - | 'Leave' - | 'Cancel' - | 'Start' - | { AddTime: { deadline: number } }; + | { + Create: { + game_id: string; + max_players: number; + scheduled_for: number | null; + deadline: number; + }; + } + | { Respond: { ready_at: number | null } } + | "Rsvp" + | { SendMessage: { text: string } } + | "Leave" + | "Cancel" + | "Start" + | { AddTime: { deadline: number } }; -export interface CallToPlayEvent { - id: string; - call_id: string; - actor_id: string; - actor_name: string; - at: number; - action: CallToPlayAction; +export interface CallToPlayViewEvent { + id: string; + call_id: string; + author_id: string; + author_name: string; + at: number; + action: CallToPlayAction; +} + +export interface CallToPlayView { + events: CallToPlayViewEvent[]; +} + +export interface CallToPlayLocalIntent { + call_id: string | null; + action: CallToPlayAction; +} + +export interface CallToPlayReceipt { + call_id: string; + event_id: string; + revision: number; } diff --git a/crates/lanspread-tauri-deno-ts/src/main.tsx b/crates/lanspread-tauri-deno-ts/src/main.tsx index 306429e..59c6a1a 100644 --- a/crates/lanspread-tauri-deno-ts/src/main.tsx +++ b/crates/lanspread-tauri-deno-ts/src/main.tsx @@ -1,9 +1,32 @@ import ReactDOM from "react-dom/client"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import App from "./App"; +import { windowAsyncScope } from "./lib/asyncOwnership"; +import { bootstrapFrontend } from "./lib/frontendBootstrap"; +import { windowPersistenceScope } from "./lib/frontendPersistence"; import "./styles/tokens.css"; import "./styles/launcher.css"; -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - -); +const render = () => { + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + ); +}; + +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); +}); diff --git a/crates/lanspread-tauri-deno-ts/src/styles/launcher.css b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css index f391f1a..89fa7f0 100644 --- a/crates/lanspread-tauri-deno-ts/src/styles/launcher.css +++ b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css @@ -19,24 +19,24 @@ .bg-gradient { background: radial-gradient( - ellipse 80% 50% at 50% -10%, - color-mix(in srgb, var(--accent) 22%, transparent) 0%, - transparent 60% - ), + ellipse 80% 50% at 50% -10%, + color-mix(in srgb, var(--accent) 22%, transparent) 0%, + transparent 60% + ), linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%); } .bg-animated { background: radial-gradient( - ellipse 60% 40% at 20% 0%, - color-mix(in srgb, var(--accent) 24%, transparent) 0%, - transparent 55% - ), + ellipse 60% 40% at 20% 0%, + color-mix(in srgb, var(--accent) 24%, transparent) 0%, + transparent 55% + ), radial-gradient( - ellipse 55% 40% at 85% 8%, - color-mix(in srgb, var(--accent) 16%, transparent) 0%, - transparent 55% - ), + ellipse 55% 40% at 85% 8%, + color-mix(in srgb, var(--accent) 16%, transparent) 0%, + transparent 55% + ), linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%); background-size: 145% 130%, 140% 125%, 100% 100%; animation: bgshift 22s ease-in-out infinite alternate; @@ -474,6 +474,28 @@ padding: 4px 4px 16px; gap: 16px; } +.network-notice { + margin: 0 4px 16px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--warn) 45%, var(--bd-1)); + border-radius: 8px; + background: color-mix(in srgb, var(--warn) 12%, var(--bg-2)); + color: var(--t-1); + font-size: 12.5px; + font-weight: 600; +} +.network-notice-sharing.is-enabled { + border-color: color-mix(in srgb, var(--ok) 40%, var(--bd-1)); + background: color-mix(in srgb, var(--ok) 9%, var(--bg-2)); +} +.network-notice-sharing.is-disabled, +.network-notice.is-error { + border-color: color-mix(in srgb, var(--danger) 42%, var(--bd-1)); + background: color-mix(in srgb, var(--danger) 8%, var(--bg-2)); +} +.identity-notice { + border-color: color-mix(in srgb, var(--warn) 45%, var(--bd-1)); +} .results-count { color: var(--t-2); font-size: 12.5px; @@ -582,15 +604,15 @@ .cover-grain { background-image: repeating-linear-gradient( - 0deg, - rgba(255, 255, 255, 0.018) 0 1px, - transparent 1px 3px - ), + 0deg, + rgba(255, 255, 255, 0.018) 0 1px, + transparent 1px 3px + ), repeating-linear-gradient( - 90deg, - rgba(0, 0, 0, 0.04) 0 1px, - transparent 1px 3px - ); + 90deg, + rgba(0, 0, 0, 0.04) 0 1px, + transparent 1px 3px + ); mix-blend-mode: overlay; opacity: 0.7; } @@ -982,6 +1004,18 @@ align-items: center; color: var(--t-1); } +.dl-md-status { + display: inline-flex; + align-items: center; + min-width: 0; + color: var(--t-1); +} +.dl-md-status > span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .dl-md .dl-pulse { margin-right: 6px; } @@ -1066,6 +1100,9 @@ color: color-mix(in srgb, var(--accent) 80%, white); } .dl-lg-primary .dl-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } .dl-lg-secondary { @@ -1510,6 +1547,59 @@ .settings-row-control { flex-shrink: 0; } +.settings-switch { + position: relative; + display: inline-flex; + cursor: pointer; +} +.settings-switch input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} +.settings-switch-track { + position: relative; + width: 40px; + height: 22px; + border: 1px solid var(--bd-3); + border-radius: 999px; + background: var(--bg-4); + transition: background 120ms ease, border-color 120ms ease; +} +.settings-switch-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--t-2); + transition: transform 120ms ease, background 120ms ease; +} +.settings-switch.is-checked .settings-switch-track { + border-color: color-mix(in srgb, var(--accent) 75%, var(--bd-2)); + background: color-mix(in srgb, var(--accent) 55%, var(--bg-3)); +} +.settings-switch.is-checked .settings-switch-thumb { + transform: translateX(18px); + background: white; +} +.settings-switch input:focus-visible + .settings-switch-track { + outline: 2px solid color-mix(in srgb, var(--accent) 65%, transparent); + outline-offset: 2px; +} +.settings-switch:has(input:disabled) { + cursor: wait; + opacity: 0.55; +} +.settings-inline-error { + margin-top: -6px; + color: var(--danger); + font-size: 11.5px; + font-weight: 600; +} .settings-foot { display: flex; align-items: center; @@ -1791,52 +1881,67 @@ /* ─── Top-bar entry button ─── */ .ctp-btn { position: relative; - display: inline-flex; align-items: center; gap: 8px; - height: 36px; padding: 0 14px; + display: inline-flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 14px; background: color-mix(in srgb, var(--accent) 14%, var(--bg-2)); border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2)); border-radius: 8px; color: var(--t-1); - font: inherit; font-size: 12.5px; font-weight: 700; + font: inherit; + font-size: 12.5px; + font-weight: 700; cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: background .15s, border-color .15s; } -.ctp-btn svg { color: var(--accent); flex-shrink: 0; } +.ctp-btn svg { + color: var(--accent); + flex-shrink: 0; +} .ctp-btn:hover { background: color-mix(in srgb, var(--accent) 22%, var(--bg-2)); border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2)); } .ctp-badge { - display: inline-grid; place-items: center; - min-width: 18px; height: 18px; + display: inline-grid; + place-items: center; + min-width: 18px; + height: 18px; padding: 0 5px; border-radius: 999px; background: var(--accent); color: white; - font-size: 10.5px; font-weight: 800; + font-size: 10.5px; + font-weight: 800; font-variant-numeric: tabular-nums; } /* ─── Ticker strip ─── */ .ctp-ticker-stack { - display: flex; flex-direction: column; gap: 8px; + display: flex; + flex-direction: column; + gap: 8px; margin-bottom: 14px; } -.ctp-ticker-stack .ctp-ticker { margin-bottom: 0; } +.ctp-ticker-stack .ctp-ticker { + margin-bottom: 0; +} .ctp-ticker { display: grid; grid-template-columns: - 10px /* LED */ - 98px /* status */ - 220px /* game */ - 130px /* by */ - 88px /* count */ - 170px /* time */ - minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */ - max-content /* bubbles */ - 82px; /* cta */ + 10px /* LED */ + 98px /* status */ + 220px /* game */ + 130px /* by */ + 88px /* count */ + 170px /* time */ + minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */ + max-content /* bubbles */ + 82px; /* cta */ align-items: center; column-gap: 12px; width: 100%; @@ -1845,22 +1950,34 @@ border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2)); border-radius: 10px; color: var(--t-1); - font: inherit; font-size: 12.5px; + font: inherit; + font-size: 12.5px; cursor: pointer; text-align: left; transition: background .15s, border-color .15s; } -.ctp-ticker:hover { background: color-mix(in srgb, var(--accent) 16%, var(--bg-2)); } +.ctp-ticker:hover { + background: color-mix(in srgb, var(--accent) 16%, var(--bg-2)); +} .ctp-ticker-dot { - width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 999px; + flex-shrink: 0; background: var(--accent); box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent); animation: ctp-tickerpulse 1.6s ease-out infinite; } @keyframes ctp-tickerpulse { - 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } - 70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); } - 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); + } + 70% { + box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); + } + 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); + } } .ctp-ticker-label { font-weight: 700; @@ -1870,36 +1987,87 @@ letter-spacing: 0.06em; flex-shrink: 0; } -.ctp-ticker-game { font-weight: 700; color: var(--t-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.ctp-ticker-by { color: var(--t-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.ctp-ticker-sep { display: none; } -.ctp-ticker-ready, .ctp-ticker-time { color: var(--t-2); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ctp-ticker-game { + font-weight: 700; + color: var(--t-1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ctp-ticker-by { + color: var(--t-3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ctp-ticker-sep { + display: none; +} +.ctp-ticker-ready, +.ctp-ticker-time { + color: var(--t-2); + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .ctp-ticker-more { color: var(--t-3); font-size: 11.5px; flex-shrink: 0; } .ctp-ticker-cta { - display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 4px; font-weight: 700; color: var(--accent); } -.ctp-ticker-cta svg { transform: rotate(-90deg); } +.ctp-ticker-cta svg { + transform: rotate(-90deg); +} /* ─── Overlay modal ─── */ -.ctp-modal { width: min(720px, 100%); } +.ctp-modal { + width: min(720px, 100%); +} .ctp-head-row { - display: flex; align-items: center; justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; gap: 12px; margin-right: 36px; } -.ctp-head-new { height: 40px; padding: 0 18px; font-size: 13px; margin-top: 14px; width: 100%; } -.ctp-head { padding: 26px 28px 14px; border-bottom: 1px solid var(--bd-1); } -.ctp-head h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; color: var(--t-1); } -.ctp-head-sub { margin: 6px 0 0; font-size: 12.5px; color: var(--t-3); max-width: 52ch; } +.ctp-head-new { + height: 40px; + padding: 0 18px; + font-size: 13px; + margin-top: 14px; + width: 100%; +} +.ctp-head { + padding: 26px 28px 14px; + border-bottom: 1px solid var(--bd-1); +} +.ctp-head h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--t-1); +} +.ctp-head-sub { + margin: 6px 0 0; + font-size: 12.5px; + color: var(--t-3); + max-width: 52ch; +} .ctp-body { padding: 18px 24px 24px; - display: flex; flex-direction: column; gap: 14px; + display: flex; + flex-direction: column; + gap: 14px; max-height: 66vh; overflow: auto; } @@ -1912,42 +2080,86 @@ /* ─── Nomination card ─── */ .ctp-card { - display: flex; flex-direction: column; gap: 12px; + display: flex; + flex-direction: column; + gap: 12px; padding: 14px; background: rgba(255,255,255,0.025); border: 1px solid var(--bd-1); border-radius: 12px; } -.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); } -.ctp-card.is-expired { border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); } -.ctp-card.is-terminal { opacity: 0.72; } -.ctp-card.is-running { border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2)); } -.ctp-card.is-cancelled { border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2)); } -.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; } +.ctp-card.is-done { + border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); +} +.ctp-card.is-expired { + border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); +} +.ctp-card.is-terminal { + opacity: 0.72; +} +.ctp-card.is-running { + border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2)); +} +.ctp-card.is-cancelled { + border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2)); +} +.ctp-card.is-focused { + border-color: var(--accent); + animation: ctp-cardflash 1.4s ease-out 1; +} @keyframes ctp-cardflash { - 0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); } - 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } + 0% { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); + } + 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); + } +} +.ctp-card-top { + display: flex; + align-items: flex-start; + gap: 12px; + position: relative; } -.ctp-card-top { display: flex; align-items: flex-start; gap: 12px; position: relative; } .ctp-card-cover { position: relative; - width: 52px; height: 52px; + width: 52px; + height: 52px; flex-shrink: 0; border-radius: 8px; overflow: hidden; } .ctp-card-cover-missing { - width: 100%; height: 100%; - display: grid; place-items: center; + width: 100%; + height: 100%; + display: grid; + place-items: center; color: var(--t-3); background: color-mix(in srgb, var(--bd-2) 55%, transparent); border: 1px dashed var(--bd-2); } -.ctp-card-cover-missing svg { width: 22px; height: 22px; } -.ctp-card-info { flex: 1; min-width: 0; } -.ctp-card-title { font-size: 14.5px; font-weight: 700; color: var(--t-1); } -.ctp-card-sub { margin-top: 3px; font-size: 11.5px; color: var(--t-3); } -.ctp-card-sub strong { color: var(--t-2); font-weight: 700; } +.ctp-card-cover-missing svg { + width: 22px; + height: 22px; +} +.ctp-card-info { + flex: 1; + min-width: 0; +} +.ctp-card-title { + font-size: 14.5px; + font-weight: 700; + color: var(--t-1); +} +.ctp-card-sub { + margin-top: 3px; + font-size: 11.5px; + color: var(--t-3); +} +.ctp-card-sub strong { + color: var(--t-2); + font-weight: 700; +} .ctp-card-timer { font-variant-numeric: tabular-nums; font-size: 18px; @@ -1956,25 +2168,45 @@ flex-shrink: 0; padding-top: 2px; } -.ctp-card-timer[data-urgency="mid"] { color: var(--warn); } -.ctp-card-timer[data-urgency="high"] { color: var(--danger); } -.ctp-card.is-done .ctp-card-timer { color: var(--ok); font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; } -.ctp-card.is-expired .ctp-card-timer { color: var(--danger); } +.ctp-card-timer[data-urgency="mid"] { + color: var(--warn); +} +.ctp-card-timer[data-urgency="high"] { + color: var(--danger); +} +.ctp-card.is-done .ctp-card-timer { + color: var(--ok); + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.ctp-card.is-expired .ctp-card-timer { + color: var(--danger); +} .ctp-cancel-link { - display: inline-flex; align-items: center; gap: 6px; + display: inline-flex; + align-items: center; + gap: 6px; align-self: flex-start; margin-top: -2px; padding: 6px 2px; background: transparent; border: 0; color: var(--t-3); - font: inherit; font-size: 11.5px; font-weight: 600; + font: inherit; + font-size: 11.5px; + font-weight: 600; cursor: pointer; transition: color .15s; } -.ctp-cancel-link:hover { color: #fca5a5; } +.ctp-cancel-link:hover { + color: #fca5a5; +} .ctp-cancel-confirm { - display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; gap: 10px; padding: 10px 12px; background: rgba(239,68,68,0.08); @@ -1984,8 +2216,16 @@ color: #fca5a5; font-weight: 600; } -.ctp-cancel-confirm-btns { display: inline-flex; gap: 8px; flex-shrink: 0; } -.ctp-cancel-confirm-btns .ghost-btn { height: 32px; padding: 0 12px; font-size: 12px; } +.ctp-cancel-confirm-btns { + display: inline-flex; + gap: 8px; + flex-shrink: 0; +} +.ctp-cancel-confirm-btns .ghost-btn { + height: 32px; + padding: 0 12px; + font-size: 12px; +} .ctp-progress { height: 5px; @@ -1993,37 +2233,66 @@ background: rgba(255,255,255,0.06); overflow: hidden; } -.ctp-progress-fill { height: 100%; transition: width 1s linear; } +.ctp-progress-fill { + height: 100%; + transition: width 1s linear; +} -.ctp-roster { display: flex; flex-direction: column; gap: 8px; } -.ctp-roster-count { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--t-3); } -.ctp-avatars { display: flex; flex-wrap: wrap; gap: 6px; } +.ctp-roster { + display: flex; + flex-direction: column; + gap: 8px; +} +.ctp-roster-count { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--t-3); +} +.ctp-avatars { + display: flex; + flex-wrap: wrap; + gap: 6px; +} .ctp-avatar { position: relative; display: inline-flex; } .ctp-avatar-dot { - width: 30px; height: 30px; - display: grid; place-items: center; + width: 30px; + height: 30px; + display: grid; + place-items: center; border-radius: 999px; color: white; - font-size: 10.5px; font-weight: 800; + font-size: 10.5px; + font-weight: 800; letter-spacing: 0.01em; } -.ctp-avatar.is-pending .ctp-avatar-dot { opacity: 0.55; } +.ctp-avatar.is-pending .ctp-avatar-dot { + opacity: 0.55; +} .ctp-avatar-check { - position: absolute; bottom: -2px; right: -2px; - width: 14px; height: 14px; - display: grid; place-items: center; + position: absolute; + bottom: -2px; + right: -2px; + width: 14px; + height: 14px; + display: grid; + place-items: center; border-radius: 999px; background: var(--ok); color: #06240f; border: 2px solid var(--bg-2); } .ctp-avatar-pending { - position: absolute; bottom: -6px; left: 50%; + position: absolute; + bottom: -6px; + left: 50%; transform: translateX(-50%); - font-size: 9px; font-weight: 700; + font-size: 9px; + font-weight: 700; color: var(--t-2); background: var(--bg-3); border: 1px solid var(--bd-2); @@ -2033,64 +2302,122 @@ font-variant-numeric: tabular-nums; } .ctp-avatar-empty { - width: 30px; height: 30px; + width: 30px; + height: 30px; border-radius: 999px; border: 1.5px dashed var(--bd-2); } .ctp-actions { - display: flex; align-items: center; flex-wrap: wrap; gap: 8px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; padding-top: 2px; } -.ctp-actions .act-btn { height: 36px; padding: 0 16px; font-size: 12.5px; } -.ctp-actions .ghost-btn { height: 36px; padding: 0 14px; font-size: 12.5px; } -.ctp-note { font-size: 12.5px; color: var(--t-3); } -.ctp-note-launch { color: var(--ok); font-weight: 600; } -.ctp-me-status { font-size: 12.5px; font-weight: 600; color: var(--t-2); } -.ctp-buffer-group { display: inline-flex; gap: 6px; } +.ctp-actions .act-btn { + height: 36px; + padding: 0 16px; + font-size: 12.5px; +} +.ctp-actions .ghost-btn { + height: 36px; + padding: 0 14px; + font-size: 12.5px; +} +.ctp-note { + font-size: 12.5px; + color: var(--t-3); +} +.ctp-note-launch { + color: var(--ok); + font-weight: 600; +} +.ctp-me-status { + font-size: 12.5px; + font-weight: 600; + color: var(--t-2); +} +.ctp-buffer-group { + display: inline-flex; + gap: 6px; +} .ctp-buffer-btn { - height: 36px; padding: 0 11px; + height: 36px; + padding: 0 11px; background: rgba(255,255,255,0.04); border: 1px solid var(--bd-2); border-radius: 7px; color: var(--t-1); - font: inherit; font-size: 12px; font-weight: 600; + font: inherit; + font-size: 12px; + font-weight: 600; cursor: pointer; transition: background .15s, border-color .15s; } -.ctp-buffer-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); } +.ctp-buffer-btn:hover { + background: rgba(255,255,255,0.08); + border-color: var(--bd-3); +} /* ─── Create-nomination form ─── */ .ctp-create-cta { - display: flex; align-items: center; justify-content: center; gap: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; height: 48px; background: transparent; border: 1.5px dashed var(--bd-3); border-radius: 12px; color: var(--t-2); - font: inherit; font-size: 13px; font-weight: 700; + font: inherit; + font-size: 13px; + font-weight: 700; cursor: pointer; transition: border-color .15s, color .15s, background .15s; } -.ctp-create-cta:hover { border-color: var(--accent); color: var(--t-1); background: rgba(255,255,255,0.02); } +.ctp-create-cta:hover { + border-color: var(--accent); + color: var(--t-1); + background: rgba(255,255,255,0.02); +} .ctp-create { - display: flex; flex-direction: column; gap: 14px; + display: flex; + flex-direction: column; + gap: 14px; padding: 16px; background: rgba(255,255,255,0.03); border: 1px solid var(--bd-2); border-radius: 12px; } -.ctp-create-row { display: flex; flex-direction: column; gap: 6px; position: relative; } -.ctp-create-row-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; } +.ctp-create-row { + display: flex; + flex-direction: column; + gap: 6px; + position: relative; +} +.ctp-create-row-inline { + flex-direction: row; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} .ctp-create-label { - font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; color: var(--t-3); } -.ctp-create-search { width: 100%; } +.ctp-create-search { + width: 100%; +} .ctp-create-matches { position: absolute; top: calc(100% + 4px); - left: 0; right: 0; + left: 0; + right: 0; z-index: 20; max-height: 220px; overflow: auto; @@ -2101,61 +2428,111 @@ box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5); } .ctp-create-matches button { - display: flex; align-items: center; justify-content: space-between; gap: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; width: 100%; padding: 9px 10px; background: transparent; border: 0; border-radius: 6px; color: var(--t-1); - font: inherit; font-size: 12.5px; font-weight: 600; + font: inherit; + font-size: 12.5px; + font-weight: 600; text-align: left; cursor: pointer; } -.ctp-create-matches button:hover { background: rgba(255,255,255,0.06); } -.ctp-create-match-meta { color: var(--t-3); font-size: 11px; font-weight: 500; } -.ctp-create-nomatch { padding: 10px; font-size: 12px; color: var(--t-3); } +.ctp-create-matches button:hover { + background: rgba(255,255,255,0.06); +} +.ctp-create-match-meta { + color: var(--t-3); + font-size: 11px; + font-weight: 500; +} +.ctp-create-nomatch { + padding: 10px; + font-size: 12px; + color: var(--t-3); +} .ctp-create-num { - width: 70px; height: 34px; + width: 70px; + height: 34px; background: var(--bg-3); border: 1px solid var(--bd-1); border-radius: 7px; color: var(--t-1); - font: inherit; font-size: 13px; font-weight: 600; + font: inherit; + font-size: 13px; + font-weight: 600; text-align: center; } -.ctp-create-hint { font-size: 11.5px; color: var(--t-3); } -.ctp-duration-opts { display: inline-flex; gap: 6px; flex-wrap: wrap; } +.ctp-create-hint { + font-size: 11.5px; + color: var(--t-3); +} +.ctp-duration-opts { + display: inline-flex; + gap: 6px; + flex-wrap: wrap; +} .ctp-duration-btn { - height: 32px; padding: 0 13px; + height: 32px; + padding: 0 13px; background: var(--bg-3); border: 1px solid var(--bd-1); border-radius: 7px; color: var(--t-2); - font: inherit; font-size: 12.5px; font-weight: 700; + font: inherit; + font-size: 12.5px; + font-weight: 700; cursor: pointer; transition: background .15s, color .15s, border-color .15s; } -.ctp-duration-btn:hover { color: var(--t-1); } -.ctp-duration-btn.is-active { color: white; } +.ctp-duration-btn:hover { + color: var(--t-1); +} +.ctp-duration-btn.is-active { + color: white; +} .ctp-create-foot { - display: flex; justify-content: flex-end; gap: 10px; + display: flex; + justify-content: flex-end; + gap: 10px; padding-top: 4px; } .ctp-create-foot .act-btn, -.ctp-create-foot .ghost-btn { height: 40px; padding: 0 18px; } -.ctp-time-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; } +.ctp-create-foot .ghost-btn { + height: 40px; + padding: 0 18px; +} +.ctp-time-row { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-top: 4px; +} /* ─── Time stepper ─── */ .ctp-timepick { - display: flex; align-items: center; gap: 6px; + display: flex; + align-items: center; + gap: 6px; margin-top: 6px; } .ctp-timepick-field { - display: flex; flex-direction: column; align-items: center; gap: 3px; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; } .ctp-time-step { - width: 54px; height: 24px; - display: grid; place-items: center; + width: 54px; + height: 24px; + display: grid; + place-items: center; background: var(--bg-3); border: 1px solid var(--bd-1); border-radius: 7px; @@ -2163,54 +2540,91 @@ cursor: pointer; transition: background .12s, color .12s, border-color .12s; } -.ctp-time-step:hover { background: var(--bg-4); color: var(--t-1); border-color: var(--bd-3); } -.ctp-time-step:active { background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); } +.ctp-time-step:hover { + background: var(--bg-4); + color: var(--t-1); + border-color: var(--bd-3); +} +.ctp-time-step:active { + background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); +} .ctp-time-cell { - width: 54px; height: 44px; - display: grid; place-items: center; + width: 54px; + height: 44px; + display: grid; + place-items: center; background: var(--bg-3); border: 1px solid var(--bd-2); border-radius: 9px; - font-size: 26px; font-weight: 700; + font-size: 26px; + font-weight: 700; font-variant-numeric: tabular-nums; color: var(--t-1); letter-spacing: 0.02em; } .ctp-time-editinput { - width: 132px; height: 44px; + width: 132px; + height: 44px; padding: 0; background: var(--bg-3); border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2)); border-radius: 9px; - font: inherit; font-size: 26px; font-weight: 700; + font: inherit; + font-size: 26px; + font-weight: 700; font-variant-numeric: tabular-nums; color: var(--t-1); text-align: center; letter-spacing: 0.06em; } -.ctp-time-editinput:focus { outline: none; } -.ctp-time-editinput::placeholder { color: var(--t-4); } -.ctp-time-colon { font-size: 26px; font-weight: 700; color: var(--t-2); padding-bottom: 2px; } +.ctp-time-editinput:focus { + outline: none; +} +.ctp-time-editinput::placeholder { + color: var(--t-4); +} +.ctp-time-colon { + font-size: 26px; + font-weight: 700; + color: var(--t-2); + padding-bottom: 2px; +} .ctp-time-type { align-self: center; margin-left: 8px; - height: 30px; padding: 0 12px; + height: 30px; + padding: 0 12px; background: transparent; border: 1px dashed var(--bd-3); border-radius: 7px; color: var(--t-3); - font: inherit; font-size: 11.5px; font-weight: 600; + font: inherit; + font-size: 11.5px; + font-weight: 600; cursor: pointer; transition: color .12s, border-color .12s; } -.ctp-time-type:hover { color: var(--t-1); border-color: var(--accent); } -.ctp-day-row { gap: 5px; } +.ctp-time-type:hover { + color: var(--t-1); + border-color: var(--accent); +} +.ctp-day-row { + gap: 5px; +} .ctp-day-label { - font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; color: var(--t-3); margin-right: 4px; } -.ctp-day-btn { height: 26px; padding: 0 10px; font-size: 11px; border-radius: 6px; } +.ctp-day-btn { + height: 26px; + padding: 0 10px; + font-size: 11px; + border-radius: 6px; +} /* ─── Quick-bar status variants — LED + label + row tint per status: SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) · @@ -2220,95 +2634,199 @@ background: var(--bg-2); border-color: var(--bd-2); } -.ctp-ticker[data-status="scheduled"]:hover { background: var(--bg-3); } +.ctp-ticker[data-status="scheduled"]:hover { + background: var(--bg-3); +} .ctp-ticker[data-status="soon"] { background: color-mix(in srgb, var(--warn) 10%, var(--bg-2)); border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2)); animation: ctp-soon-glow 2.4s ease-in-out infinite; } -.ctp-ticker[data-status="soon"]:hover { background: color-mix(in srgb, var(--warn) 16%, var(--bg-2)); } +.ctp-ticker[data-status="soon"]:hover { + background: color-mix(in srgb, var(--warn) 16%, var(--bg-2)); +} @keyframes ctp-soon-glow { - 0%, 100% { box-shadow: 0 0 0 0 transparent; } - 50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent); } + 0%, + 100% { + box-shadow: 0 0 0 0 transparent; + } + 50% { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent); + } } .ctp-ticker[data-status="ready"] { background: color-mix(in srgb, var(--ok) 11%, var(--bg-2)); border-color: color-mix(in srgb, var(--ok) 55%, var(--bd-2)); box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent); } -.ctp-ticker[data-status="ready"]:hover { background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); } +.ctp-ticker[data-status="ready"]:hover { + background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); +} .ctp-ticker[data-status="expired"] { background: color-mix(in srgb, var(--danger) 8%, var(--bg-2)); border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); } -.ctp-ticker[data-status="expired"]:hover { background: color-mix(in srgb, var(--danger) 13%, var(--bg-2)); } +.ctp-ticker[data-status="expired"]:hover { + background: color-mix(in srgb, var(--danger) 13%, var(--bg-2)); +} .ctp-ticker[data-status="running"] { background: color-mix(in srgb, var(--ok) 5%, var(--bg-2)); border-color: color-mix(in srgb, var(--ok) 25%, var(--bd-2)); } -.ctp-ticker[data-status="running"]:hover { background: color-mix(in srgb, var(--ok) 9%, var(--bg-2)); } +.ctp-ticker[data-status="running"]:hover { + background: color-mix(in srgb, var(--ok) 9%, var(--bg-2)); +} .ctp-ticker[data-status="cancelled"] { background: color-mix(in srgb, var(--danger) 4%, var(--bg-2)); border-color: color-mix(in srgb, var(--danger) 22%, var(--bd-2)); } -.ctp-ticker[data-status="cancelled"]:hover { background: color-mix(in srgb, var(--danger) 8%, var(--bg-2)); } -.ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; } -.ctp-ticker-dot[data-status="soon"] { background: var(--warn); animation: ctp-tickerpulse-warn 1.6s ease-out infinite; } -.ctp-ticker-dot[data-status="ready"] { background: var(--ok); animation: none; box-shadow: 0 0 6px var(--ok); } -.ctp-ticker-dot[data-status="expired"] { background: var(--danger); animation: none; box-shadow: none; } -.ctp-ticker-dot[data-status="running"] { background: var(--ok); animation: none; box-shadow: none; opacity: 0.75; } -.ctp-ticker-dot[data-status="cancelled"] { background: var(--danger); animation: none; box-shadow: none; opacity: 0.65; } +.ctp-ticker[data-status="cancelled"]:hover { + background: color-mix(in srgb, var(--danger) 8%, var(--bg-2)); +} +.ctp-ticker-dot[data-status="scheduled"] { + background: var(--t-3); + animation: none; + box-shadow: none; +} +.ctp-ticker-dot[data-status="soon"] { + background: var(--warn); + animation: ctp-tickerpulse-warn 1.6s ease-out infinite; +} +.ctp-ticker-dot[data-status="ready"] { + background: var(--ok); + animation: none; + box-shadow: 0 0 6px var(--ok); +} +.ctp-ticker-dot[data-status="expired"] { + background: var(--danger); + animation: none; + box-shadow: none; +} +.ctp-ticker-dot[data-status="running"] { + background: var(--ok); + animation: none; + box-shadow: none; + opacity: 0.75; +} +.ctp-ticker-dot[data-status="cancelled"] { + background: var(--danger); + animation: none; + box-shadow: none; + opacity: 0.65; +} @keyframes ctp-tickerpulse-warn { - 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); } - 70% { box-shadow: 0 0 0 6px transparent; } - 100% { box-shadow: 0 0 0 0 transparent; } + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); + } + 70% { + box-shadow: 0 0 0 6px transparent; + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} +.ctp-ticker-label[data-status="scheduled"] { + color: var(--t-2); +} +.ctp-ticker-label[data-status="soon"] { + color: var(--warn); +} +.ctp-ticker-label[data-status="ready"] { + color: var(--ok); +} +.ctp-ticker-label[data-status="expired"] { + color: var(--danger); +} +.ctp-ticker-label[data-status="running"] { + color: color-mix(in srgb, var(--ok) 75%, var(--t-2)); +} +.ctp-ticker-label[data-status="cancelled"] { + color: color-mix(in srgb, var(--danger) 70%, var(--t-2)); +} +.ctp-ticker[data-status="soon"] .ctp-ticker-cta { + color: var(--warn); +} +.ctp-ticker[data-status="ready"] .ctp-ticker-cta { + color: var(--ok); +} +.ctp-ticker[data-status="expired"] .ctp-ticker-cta { + color: var(--danger); } -.ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); } -.ctp-ticker-label[data-status="soon"] { color: var(--warn); } -.ctp-ticker-label[data-status="ready"] { color: var(--ok); } -.ctp-ticker-label[data-status="expired"] { color: var(--danger); } -.ctp-ticker-label[data-status="running"] { color: color-mix(in srgb, var(--ok) 75%, var(--t-2)); } -.ctp-ticker-label[data-status="cancelled"] { color: color-mix(in srgb, var(--danger) 70%, var(--t-2)); } -.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); } -.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); } -.ctp-ticker[data-status="expired"] .ctp-ticker-cta { color: var(--danger); } .ctp-ticker[data-status="running"] .ctp-ticker-cta, -.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta { color: var(--t-2); } +.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta { + color: var(--t-2); +} /* ─── Quick-bar inline chat preview ─── */ .ctp-ticker-chat { min-width: 0; - display: inline-flex; align-items: center; gap: 5px; + display: inline-flex; + align-items: center; + gap: 5px; color: var(--t-3); font-size: 12px; } -.ctp-ticker-chat svg { flex-shrink: 0; opacity: 0.7; } -.ctp-ticker-chat b { flex-shrink: 0; font-weight: 700; } +.ctp-ticker-chat svg { + flex-shrink: 0; + opacity: 0.7; +} +.ctp-ticker-chat b { + flex-shrink: 0; + font-weight: 700; +} .ctp-ticker-chat-text { - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: var(--t-2); } -.ctp-card.is-checkin { border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2)); } +.ctp-card.is-checkin { + border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2)); +} .ctp-checkin-note { - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; padding: 8px 12px; background: color-mix(in srgb, var(--accent) 13%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent); border-radius: 8px; - font-size: 12.5px; font-weight: 600; + font-size: 12.5px; + font-weight: 600; color: var(--t-1); } -.ctp-checkin-note svg { color: var(--accent); flex-shrink: 0; } +.ctp-checkin-note svg { + color: var(--accent); + flex-shrink: 0; +} .ctp-card-timer.is-sched { - display: flex; flex-direction: column; align-items: flex-end; gap: 2px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} +.ctp-card-clock { + font-size: 18px; + line-height: 1; +} +.ctp-card-until { + font-size: 10.5px; + font-weight: 600; + color: var(--t-3); + letter-spacing: 0.01em; +} +.ctp-avatar.is-in .ctp-avatar-dot { + opacity: 0.8; } -.ctp-card-clock { font-size: 18px; line-height: 1; } -.ctp-card-until { font-size: 10.5px; font-weight: 600; color: var(--t-3); letter-spacing: 0.01em; } -.ctp-avatar.is-in .ctp-avatar-dot { opacity: 0.8; } .ctp-avatar-in { - position: absolute; bottom: -6px; left: 50%; + position: absolute; + bottom: -6px; + left: 50%; transform: translateX(-50%); - font-size: 8.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; + font-size: 8.5px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; color: var(--t-2); background: var(--bg-3); border: 1px solid var(--bd-2); @@ -2319,37 +2837,60 @@ /* ─── Ticker mini ready-bubbles ─── */ .ctp-ticker-bubbles { - display: inline-flex; align-items: center; + display: inline-flex; + align-items: center; justify-self: end; } .ctp-mini { position: relative; - width: 22px; height: 22px; - display: grid; place-items: center; + width: 22px; + height: 22px; + display: grid; + place-items: center; border-radius: 999px; margin-left: -6px; border: 2px solid var(--bg-2); color: white; - font-size: 8px; font-weight: 800; letter-spacing: 0.02em; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.02em; +} +.ctp-mini:first-child { + margin-left: 0; +} +.ctp-mini[data-state="ready"] { + box-shadow: 0 0 0 1.5px var(--ok); +} +.ctp-mini[data-state="pending"] { + opacity: 0.75; +} +.ctp-mini[data-state="in"] { + opacity: 0.85; } -.ctp-mini:first-child { margin-left: 0; } -.ctp-mini[data-state="ready"] { box-shadow: 0 0 0 1.5px var(--ok); } -.ctp-mini[data-state="pending"] { opacity: 0.75; } -.ctp-mini[data-state="in"] { opacity: 0.85; } .ctp-mini-check { - position: absolute; bottom: -3px; right: -4px; - width: 11px; height: 11px; - display: grid; place-items: center; + position: absolute; + bottom: -3px; + right: -4px; + width: 11px; + height: 11px; + display: grid; + place-items: center; border-radius: 999px; background: var(--ok); color: #06240f; border: 1.5px solid var(--bg-2); } -.ctp-mini-check svg { width: 7px; height: 7px; } +.ctp-mini-check svg { + width: 7px; + height: 7px; +} .ctp-mini-tag { - position: absolute; bottom: -6px; right: -7px; + position: absolute; + bottom: -6px; + right: -7px; font-style: normal; - font-size: 8px; font-weight: 700; + font-size: 8px; + font-weight: 700; line-height: 11px; padding: 0 3px; border-radius: 999px; @@ -2359,51 +2900,83 @@ font-variant-numeric: tabular-nums; white-space: nowrap; } -.ctp-mini-more { background: var(--bg-4); color: var(--t-2); font-size: 8.5px; } +.ctp-mini-more { + background: var(--bg-4); + color: var(--t-2); + font-size: 8.5px; +} /* ─── Per-call chat ─── */ .ctp-chat { - display: flex; flex-direction: column; + display: flex; + flex-direction: column; border-top: 1px solid var(--bd-1); margin-top: 2px; padding-top: 8px; } .ctp-chat-toggle { - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; width: 100%; padding: 4px 2px; background: transparent; border: 0; color: var(--t-3); - font: inherit; font-size: 11.5px; font-weight: 700; + font: inherit; + font-size: 11.5px; + font-weight: 700; cursor: pointer; transition: color .15s; } -.ctp-chat-toggle:hover { color: var(--t-1); } -.ctp-chat-toggle svg { flex-shrink: 0; } -.ctp-chat-count { color: var(--t-4); font-weight: 600; } +.ctp-chat-toggle:hover { + color: var(--t-1); +} +.ctp-chat-toggle svg { + flex-shrink: 0; +} +.ctp-chat-count { + color: var(--t-4); + font-weight: 600; +} .ctp-chat-unread { - min-width: 16px; height: 16px; - display: grid; place-items: center; + min-width: 16px; + height: 16px; + display: grid; + place-items: center; padding: 0 4px; border-radius: 999px; background: var(--accent); color: white; - font-size: 9.5px; font-weight: 800; + font-size: 9.5px; + font-weight: 800; flex-shrink: 0; } .ctp-chat-preview { - flex: 1; min-width: 0; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-weight: 500; color: var(--t-3); text-align: left; } -.ctp-chat-preview b { font-weight: 700; } -.ctp-chat-chevron { margin-left: auto; flex-shrink: 0; transition: transform .15s; } -.ctp-chat.is-open .ctp-chat-chevron { transform: rotate(180deg); } +.ctp-chat-preview b { + font-weight: 700; +} +.ctp-chat-chevron { + margin-left: auto; + flex-shrink: 0; + transition: transform .15s; +} +.ctp-chat.is-open .ctp-chat-chevron { + transform: rotate(180deg); +} .ctp-chat-list { - display: flex; flex-direction: column; gap: 6px; + display: flex; + flex-direction: column; + gap: 6px; max-height: 168px; overflow-y: auto; margin: 8px 0; @@ -2412,26 +2985,57 @@ border: 1px solid var(--bd-1); border-radius: 8px; } -.ctp-chat-msg { font-size: 12px; line-height: 1.45; color: var(--t-2); overflow-wrap: anywhere; } -.ctp-chat-msg b { font-weight: 700; } -.ctp-chat-text { color: var(--t-1); } -.ctp-chat-time { margin-left: 6px; font-size: 10px; color: var(--t-4); font-variant-numeric: tabular-nums; } -.ctp-chat-empty { font-size: 11.5px; color: var(--t-4); text-align: center; padding: 6px 0; } -.ctp-chat-form { display: flex; gap: 6px; } +.ctp-chat-msg { + font-size: 12px; + line-height: 1.45; + color: var(--t-2); + overflow-wrap: anywhere; +} +.ctp-chat-msg b { + font-weight: 700; +} +.ctp-chat-text { + color: var(--t-1); +} +.ctp-chat-time { + margin-left: 6px; + font-size: 10px; + color: var(--t-4); + font-variant-numeric: tabular-nums; +} +.ctp-chat-empty { + font-size: 11.5px; + color: var(--t-4); + text-align: center; + padding: 6px 0; +} +.ctp-chat-form { + display: flex; + gap: 6px; +} .ctp-chat-input { - flex: 1; height: 32px; + flex: 1; + height: 32px; padding: 0 10px; background: var(--bg-3); border: 1px solid var(--bd-1); border-radius: 7px; color: var(--t-1); - font: inherit; font-size: 12px; + font: inherit; + font-size: 12px; +} +.ctp-chat-input::placeholder { + color: var(--t-4); +} +.ctp-chat-input:focus { + outline: none; + border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); } -.ctp-chat-input::placeholder { color: var(--t-4); } -.ctp-chat-input:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); } .ctp-chat-send { - width: 32px; height: 32px; - display: grid; place-items: center; + width: 32px; + height: 32px; + display: grid; + place-items: center; background: color-mix(in srgb, var(--accent) 24%, var(--bg-3)); border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2)); border-radius: 7px; @@ -2440,25 +3044,34 @@ flex-shrink: 0; transition: background .15s; } -.ctp-chat-send:hover { background: color-mix(in srgb, var(--accent) 40%, var(--bg-3)); } +.ctp-chat-send:hover { + background: color-mix(in srgb, var(--accent) 40%, var(--bg-3)); +} .ctp-transport-note { margin-top: 10px; font-size: 11.5px; color: var(--t-3); } -.ctp-transport-note.is-error { color: var(--danger); } +.ctp-transport-note.is-error { + color: var(--danger); +} @container launcher (max-width: 1280px) { .ctp-ticker { - grid-template-columns: 10px 86px minmax(120px, 1fr) 78px 130px max-content 70px; + grid-template-columns: 10px 86px minmax(120px, 1fr) 78px 130px max-content + 70px; } .ctp-ticker-by, - .ctp-ticker-chat { display: none; } + .ctp-ticker-chat { + display: none; + } } @container launcher (max-width: 800px) { .ctp-ticker { grid-template-columns: 10px 78px minmax(100px, 1fr) 76px 64px; } .ctp-ticker-time, - .ctp-ticker-bubbles { display: none; } + .ctp-ticker-bubbles { + display: none; + } } diff --git a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx index 6a1d091..68fed45 100644 --- a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx +++ b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx @@ -1,264 +1,443 @@ -import { useCallback, useMemo, useState } from 'react'; -import { open } from '@tauri-apps/plugin-dialog'; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; -import { TopBar } from '../components/topbar/TopBar'; -import { KebabItem } from '../components/topbar/KebabMenu'; -import { ResultsBar } from '../components/grid/ResultsBar'; -import { GameGrid } from '../components/grid/GameGrid'; -import { GameDetailModal } from '../components/modals/GameDetailModal'; -import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDownloadModal'; -import { SettingsDialog } from '../components/modals/SettingsDialog'; -import { NoDirectoryState } from '../components/empty/NoDirectoryState'; -import { EmptyResultsState } from '../components/empty/EmptyResultsState'; -import { CallToPlayTicker } from '../components/calltoplay/CallToPlayTicker'; -import { CallToPlayOverlay } from '../components/calltoplay/CallToPlayOverlay'; +import { TopBar } from "../components/topbar/TopBar"; +import { KebabItem } from "../components/topbar/KebabMenu"; +import { ResultsBar } from "../components/grid/ResultsBar"; +import { GameGrid } from "../components/grid/GameGrid"; +import { GameDetailModal } from "../components/modals/GameDetailModal"; +import { ConfirmRemoveDownloadModal } from "../components/modals/ConfirmRemoveDownloadModal"; +import { SettingsDialog } from "../components/modals/SettingsDialog"; +import { NoDirectoryState } from "../components/empty/NoDirectoryState"; +import { EmptyResultsState } from "../components/empty/EmptyResultsState"; +import { CallToPlayTicker } from "../components/calltoplay/CallToPlayTicker"; +import { CallToPlayOverlay } from "../components/calltoplay/CallToPlayOverlay"; -import { useGameDirectory } from '../hooks/useGameDirectory'; -import { useGames } from '../hooks/useGames'; -import { useGameActions } from '../hooks/useGameActions'; -import { useThumbnails } from '../hooks/useThumbnails'; -import { useSettings } from '../hooks/useSettings'; -import { useCallToPlay } from '../hooks/useCallToPlay'; +import { useGameDirectory } from "../hooks/useGameDirectory"; +import { useGames } from "../hooks/useGames"; +import { useGameActions } from "../hooks/useGameActions"; +import { useThumbnails } from "../hooks/useThumbnails"; +import { useSettings } from "../hooks/useSettings"; +import { useCallToPlay } from "../hooks/useCallToPlay"; +import { useStreamInstallCapability } from "../hooks/useStreamInstallCapability"; +import { useLocalNetworkSharing } from "../hooks/useLocalNetworkSharing"; +import { useIdentityDiagnostic } from "../hooks/useIdentityDiagnostic"; -import { Game } from '../lib/types'; -import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState'; +import { Game } from "../lib/types"; +import { + AsyncOwner, + ownCompanionWindowCreation, + windowAsyncScope, +} from "../lib/asyncOwnership"; +import { + isLocalNetworkSharingActive, + localNetworkSharingNotice, +} from "../lib/localNetworkSharing"; +import { + EPHEMERAL_IDENTITY_NOTICE, + IDENTITY_STATUS_UNAVAILABLE_NOTICE, +} from "../lib/identityDiagnostic"; +import { + applyFilterAndSort, + countByFilter, + needsUpdate, +} from "../lib/gameState"; -const openLogsWindow = async () => { - const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow'); - try { - const existing = await WebviewWindow.getByLabel('unpack-logs'); - if (existing) { - await existing.setFocus(); - return; - } - const win = new WebviewWindow('unpack-logs', { - url: '/?view=unpack-logs', - title: 'Unpack Logs', - width: 900, - height: 700, - resizable: true, - }); - await win.once('tauri://error', (event) => { - console.error('Error opening unpack logs window:', event.payload); - }); - } catch (err) { - console.error('Error opening unpack logs window:', err); +interface CompanionWindowOptions { + label: string; + url: string; + title: string; + width: number; + height: number; +} + +const openCompanionWindow = async ( + owner: AsyncOwner, + options: CompanionWindowOptions, +): Promise => { + try { + let windowModule: + | typeof import("@tauri-apps/api/webviewWindow") + | undefined; + if ( + !await owner.applyIfActive( + () => import("@tauri-apps/api/webviewWindow"), + (loaded) => { + windowModule = loaded; + }, + ) || windowModule === undefined + ) { + return; } + + const { WebviewWindow } = windowModule; + const existingResult: Array< + Awaited> + > = []; + 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("tauri://error", (event) => handler(event.payload)), + destroy: () => win.destroy(), + }); + if (!owner.isActive()) return; + if (result.kind === "error") { + console.error(`Error opening ${options.title}:`, result.payload); + } else if (result.kind === "registration-error") { + console.error( + `Error owning ${options.title} creation:`, + result.error, + ); + } + } catch (err) { + if (owner.isActive()) console.error(`Error opening ${options.title}:`, err); + } }; -const openMainLogsWindow = async () => { - const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow'); - try { - const existing = await WebviewWindow.getByLabel('main-logs'); - if (existing) { - await existing.setFocus(); - return; - } - const win = new WebviewWindow('main-logs', { - url: '/?view=main-logs', - title: 'Application Logs', - width: 980, - height: 720, - resizable: true, - }); - await win.once('tauri://error', (event) => { - console.error('Error opening application logs window:', event.payload); - }); - } catch (err) { - console.error('Error opening application logs window:', err); - } +const UNPACK_LOG_WINDOW: CompanionWindowOptions = { + label: "unpack-logs", + url: "/?view=unpack-logs", + title: "Unpack Logs", + width: 900, + height: 700, +}; + +const MAIN_LOG_WINDOW: CompanionWindowOptions = { + label: "main-logs", + url: "/?view=main-logs", + title: "Application Logs", + width: 980, + height: 720, }; export const MainWindow = () => { - const { settings, set: setSetting } = useSettings(); - const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory(); - const games = useGames(rescan); - const actions = useGameActions(games, settings); - const thumbnails = useThumbnails(); - const callToPlay = useCallToPlay(settings.username); + const { settings, set: setSetting, ready: settingsReady } = useSettings(); + const localNetworkSharing = useLocalNetworkSharing(); + const identityDiagnostic = useIdentityDiagnostic(); + const { + gameDir, + ready: gameDirectoryReady, + hasGameDirectory, + setGameDir, + rescan, + } = useGameDirectory(localNetworkSharing.ready); + const games = useGames(rescan); + const actions = useGameActions(games, settings); + const thumbnails = useThumbnails(games.games.map((game) => game.id)); + const sharingActive = isLocalNetworkSharingActive( + localNetworkSharing.snapshot, + ); + const callToPlay = useCallToPlay(settings.username, sharingActive); + const windowActionOwnerRef = useRef(null); - const [openGameId, setOpenGameId] = useState(null); - const [removeGameId, setRemoveGameId] = useState(null); - const [settingsOpen, setSettingsOpen] = useState(false); - const [callToPlayOpen, setCallToPlayOpen] = useState(false); - const [focusedCallId, setFocusedCallId] = useState(null); - const visibleGames = useMemo( - () => hasGameDirectory ? games.games : [], - [games.games, hasGameDirectory], - ); - const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]); + useEffect(() => { + const owner = new AsyncOwner((error) => { + console.error("Failed to clean up a main-window action:", error); + }); + windowActionOwnerRef.current = owner; + return () => { + if (windowActionOwnerRef.current === owner) { + windowActionOwnerRef.current = null; + } + windowAsyncScope.adopt(owner.dispose()); + }; + }, []); - // Query is local UI state (no need to persist). - const [query, setQuery] = useState(''); - const filteredGames = useMemo( - () => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query), - [visibleGames, settings.filter, settings.sort, query], - ); + const [openGameId, setOpenGameId] = useState(null); + const supportsStreamedInstall = useStreamInstallCapability(openGameId); + const [removeGameId, setRemoveGameId] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const [callToPlayOpen, setCallToPlayOpen] = useState(false); + const [focusedCallId, setFocusedCallId] = useState(null); + const visibleGames = useMemo( + () => hasGameDirectory ? games.games : [], + [games.games, hasGameDirectory], + ); + const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]); - const openGame = useMemo( - () => openGameId ? games.games.find(g => g.id === openGameId) ?? null : null, - [openGameId, games.games], - ); - const removeGame = useMemo( - () => removeGameId ? games.games.find(g => g.id === removeGameId) ?? null : null, - [removeGameId, games.games], - ); + // Query is local UI state (no need to persist). + const [query, setQuery] = useState(""); + const filteredGames = useMemo( + () => + applyFilterAndSort(visibleGames, settings.filter, settings.sort, query), + [visibleGames, settings.filter, settings.sort, query], + ); - const pickDirectory = useCallback(async () => { - const picked = await open({ multiple: false, directory: true }); - if (typeof picked === 'string' && picked) setGameDir(picked); - }, [setGameDir]); + const openGame = useMemo( + () => + openGameId ? games.games.find((g) => g.id === openGameId) ?? null : null, + [openGameId, games.games], + ); + const removeGame = useMemo( + () => + removeGameId + ? games.games.find((g) => g.id === removeGameId) ?? null + : null, + [removeGameId, games.games], + ); - const handlePrimary = useCallback((game: Game) => { - if (game.installed) { - if (needsUpdate(game)) actions.update(game.id); - else actions.play(game.id); - } else { - actions.install(game.id); + const pickDirectory = useCallback(async () => { + const owner = windowActionOwnerRef.current; + if (owner === null || !owner.isActive()) return; + + try { + await owner.applyIfActive( + () => open({ multiple: false, directory: true }), + (picked) => { + if (typeof picked === "string" && picked) setGameDir(picked); + }, + ); + } catch (error) { + if (owner.isActive()) { + console.error("Failed to choose a game directory:", error); + } + } + }, [setGameDir]); + + const handlePrimary = useCallback((game: Game) => { + if (game.installed) { + if (needsUpdate(game)) actions.update(game.id); + else actions.play(game.id); + } else { + actions.install(game.id); + } + }, [actions]); + + const handleUninstall = useCallback((game: Game) => { + actions.uninstall(game.id); + }, [actions]); + + const handleRemoveDownload = useCallback((game: Game) => { + setRemoveGameId(game.id); + }, []); + + const confirmRemoveDownload = useCallback((game: Game) => { + actions.removeDownload(game.id); + setRemoveGameId(null); + setOpenGameId((current) => current === game.id ? null : current); + }, [actions]); + + const kebabItems: ReadonlyArray = useMemo(() => [ + { kind: "item", label: "Settings", onClick: () => setSettingsOpen(true) }, + { kind: "item", label: "Refresh library", onClick: () => rescan() }, + { kind: "separator" }, + { + kind: "item", + label: "Application logs", + onClick: () => { + const owner = windowActionOwnerRef.current; + if (owner) { + windowAsyncScope.adopt(openCompanionWindow(owner, MAIN_LOG_WINDOW)); } - }, [actions]); + }, + }, + { + kind: "item", + label: "Unpack logs", + onClick: () => { + const owner = windowActionOwnerRef.current; + if (owner) { + windowAsyncScope.adopt(openCompanionWindow(owner, UNPACK_LOG_WINDOW)); + } + }, + }, + ], [rescan]); - const handleUninstall = useCallback((game: Game) => { - actions.uninstall(game.id); - }, [actions]); + const rootStyle = { "--accent": settings.accent } as React.CSSProperties; + const className = [ + "launcher", + `bg-${settings.bg}`, + `density-${settings.density}`, + ].join(" "); - const handleRemoveDownload = useCallback((game: Game) => { - setRemoveGameId(game.id); - }, []); + if ( + !settingsReady || + !localNetworkSharing.ready || + !identityDiagnostic.ready || + !gameDirectoryReady + ) { + return
; + } - const confirmRemoveDownload = useCallback((game: Game) => { - actions.removeDownload(game.id); - setRemoveGameId(null); - setOpenGameId(current => current === game.id ? null : current); - }, [actions]); - - const kebabItems: ReadonlyArray = useMemo(() => [ - { kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) }, - { kind: 'item', label: 'Refresh library', onClick: () => rescan() }, - { kind: 'separator' }, - { kind: 'item', label: 'Application logs', onClick: () => void openMainLogsWindow() }, - { kind: 'item', label: 'Unpack logs', onClick: () => void openLogsWindow() }, - ], [rescan]); - - const rootStyle = { '--accent': settings.accent } as React.CSSProperties; - const className = [ - 'launcher', - `bg-${settings.bg}`, - `density-${settings.density}`, - ].join(' '); - - return ( -
- 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); - }} - /> -
- { - setFocusedCallId(callId); - setCallToPlayOpen(true); - }} - /> - {hasGameDirectory ? ( - <> - - {filteredGames.length === 0 ? ( - visibleGames.length === 0 ? ( - - ) : ( - - ) - ) : ( - setOpenGameId(g.id)} - onPrimary={handlePrimary} - onCancelDownload={(g) => actions.cancelDownload(g.id)} - /> - )} - - ) : ( - void pickDirectory()} /> - )} -
- - {openGame && ( - 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 && ( - setRemoveGameId(null)} - onConfirm={confirmRemoveDownload} - /> - )} - - {settingsOpen && ( - void pickDirectory()} - onChange={setSetting} - onClose={() => setSettingsOpen(false)} - /> - )} - - {callToPlayOpen && ( - { - setCallToPlayOpen(false); - setFocusedCallId(null); - }} - /> - )} + return ( +
+ 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); + }} + /> +
+
+ {localNetworkSharingNotice(localNetworkSharing.snapshot)}
- ); + {localNetworkSharing.error && + localNetworkSharing.snapshot.persistenceProblem === null && ( +
+ {localNetworkSharing.error} +
+ )} + {identityDiagnostic.snapshot.diagnostic === "ephemeral" && ( +
+ {EPHEMERAL_IDENTITY_NOTICE} +
+ )} + {identityDiagnostic.unavailable && ( +
+ {IDENTITY_STATUS_UNAVAILABLE_NOTICE} +
+ )} + {games.protocolMismatch && ( +
+ Nearby devices are running a different Lanspread version +
+ )} + { + setFocusedCallId(callId); + setCallToPlayOpen(true); + }} + /> + {hasGameDirectory + ? ( + <> + + {filteredGames.length === 0 + ? ( + visibleGames.length === 0 + ? ( + + ) + : ( + + ) + ) + : ( + setOpenGameId(g.id)} + onPrimary={handlePrimary} + onCancelDownload={(g) => actions.cancelDownload(g.id)} + /> + )} + + ) + : ( + windowAsyncScope.adopt(pickDirectory())} + /> + )} +
+ + {openGame && ( + 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 && ( + setRemoveGameId(null)} + onConfirm={confirmRemoveDownload} + /> + )} + + {settingsOpen && ( + windowAsyncScope.adopt(pickDirectory())} + onChange={setSetting} + localNetworkSharing={localNetworkSharing.snapshot} + localNetworkSharingReady={localNetworkSharing.ready} + localNetworkSharingBusy={localNetworkSharing.busy} + localNetworkSharingError={localNetworkSharing.error} + onLocalNetworkSharingChange={localNetworkSharing.setEnabled} + onClose={() => setSettingsOpen(false)} + /> + )} + + {callToPlayOpen && ( + { + setCallToPlayOpen(false); + setFocusedCallId(null); + }} + /> + )} +
+ ); }; diff --git a/crates/lanspread-tauri-deno-ts/tests/asyncOwnership.test.ts b/crates/lanspread-tauri-deno-ts/tests/asyncOwnership.test.ts new file mode 100644 index 0000000..4564245 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/tests/asyncOwnership.test.ts @@ -0,0 +1,776 @@ +import { + AsyncAdoptionScope, + type AsyncCleanup, + AsyncOwner, + createSerializedAsyncWriter, + mergeHydratedState, + ownCompanionWindowCreation, + registerSequentially, +} from "../src/lib/asyncOwnership.ts"; + +const assertEquals = (actual: T, expected: T, message: string) => { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +}; + +const assertArrayEquals = (actual: T[], expected: T[], message: string) => { + assertEquals(JSON.stringify(actual), JSON.stringify(expected), message); +}; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((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(); + 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(); + const secondStarted = deferred(); + 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(); + 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(); + const cleanupDone = deferred(); + 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(); + 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(); + 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(); + const unrelatedInvoke = deferred(); + 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(); + 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(); + 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(); + const errorCleanupDone = deferred(); + const bothCleanupsStarted = deferred(); + const companionDestroyStarted = deferred(); + const companionDestroyDone = deferred(); + 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(); + const update = deferred(); + const published: string[] = []; + let emitUpdate: (() => Promise) | 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(); + const newer = deferred(); + 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(); + const firstStarted = deferred(); + const secondDone = deferred(); + const secondStarted = deferred(); + const starts: number[] = []; + const writer = createSerializedAsyncWriter((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(); + const firstStarted = deferred(); + const secondStarted = deferred(); + const failure = new Error("first write failed"); + const errors: unknown[] = []; + const starts: number[] = []; + const writer = createSerializedAsyncWriter(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(); + const started = deferred(); + const writes: number[] = []; + const writer = createSerializedAsyncWriter(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"); +}); diff --git a/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts b/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts index 16fca28..7c0a98c 100644 --- a/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts +++ b/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts @@ -9,13 +9,13 @@ import { phaseOf, bumpTime, normalizeTimeInput, - pruneCallToPlayEvents, readyCountOf, reduceCallToPlayEvents, + replaceCallToPlayView, sortNominations, statusOf, } from '../src/lib/callToPlay.ts'; -import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts'; +import { type CallToPlayAction, type CallToPlayViewEvent } from '../src/lib/types.ts'; const NOW = 1_000_000; @@ -33,11 +33,11 @@ const event = ( action: CallToPlayAction, at = NOW, actorName = actorId, -): CallToPlayEvent => ({ +): CallToPlayViewEvent => ({ id, call_id: 'call-1', - actor_id: actorId, - actor_name: actorName, + author_id: actorId, + author_name: actorName, at, action, }); @@ -45,7 +45,7 @@ const event = ( const create = ( scheduledFor: number | null = null, deadline = NOW + 30 * 60_000, -): CallToPlayEvent => event('create', 'Alice', { +): CallToPlayViewEvent => event('create', 'Alice', { Create: { game_id: 'game-1', max_players: 3, @@ -187,17 +187,17 @@ Deno.test('publish failures distinguish startup and store outcomes', () => { 'peer startup message', ); assertEquals( - callToPlayPublishErrorMessage('Call to Play event is obsolete'), + callToPlayPublishErrorMessage('Call-to-Play call x is unknown or expired'), 'This Call to Play has expired or already finished.', 'obsolete call message', ); assertEquals( - callToPlayPublishErrorMessage('Call to Play history is missing'), + callToPlayPublishErrorMessage('Call-to-Play call x is already terminal'), 'This Call to Play has expired or already finished.', 'missing expired history message', ); assertEquals( - callToPlayPublishErrorMessage(new Error('Call to Play event history is full')), + callToPlayPublishErrorMessage(new Error('Call-to-Play local event history is full')), 'Call to Play has reached its active update limit. Start or cancel an active call, then try again.', 'active history limit message', ); @@ -210,15 +210,12 @@ Deno.test('publish failures distinguish startup and store outcomes', () => { Deno.test('reduction is order-independent and deduplicates events and messages', () => { const message = event('message-event', 'Bob', { - SendMessage: { message_id: 'message-1', text: 'Ready?' }, + SendMessage: { text: 'Ready?' }, }, NOW + 2); - const duplicateMessage = event('other-event', 'Bob', { - SendMessage: { message_id: 'message-1', text: 'duplicate' }, - }, NOW + 3); - const events = [message, create(), message, duplicateMessage]; + const events = [message, create(), message]; const [nomination] = reduceCallToPlayEvents(events, NOW + 4); - assertEquals(nomination.messages.length, 1, 'unique message id'); - assertEquals(nomination.messages[0].text, 'Ready?', 'first message wins'); + assertEquals(nomination.messages.length, 1, 'event IDs deduplicate messages'); + assertEquals(nomination.messages[0].text, 'Ready?', 'message retained'); }); Deno.test('actions timestamped before creation cannot mutate a call', () => { @@ -237,11 +234,11 @@ Deno.test('terminal calls retain complete read-only history for fifteen minutes' create(), event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1), event('message', 'Bob', { - SendMessage: { message_id: 'message-1', text: 'Launching' }, + SendMessage: { text: 'Launching' }, }, NOW + 2), event('start', 'Alice', 'Start', NOW + 3), event('late-message', 'Bob', { - SendMessage: { message_id: 'message-2', text: 'Too late' }, + SendMessage: { text: 'Too late' }, }, NOW + 4), ]; const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS); @@ -273,34 +270,13 @@ Deno.test('terminal calls sort last and do not contribute to the badge', () => { assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status'); }); -Deno.test('retired calls are pruned from the frontend raw event map', () => { - const terminalEvents = [ - create(), - event('start', 'Alice', 'Start', NOW + 1), - ]; - const map = new Map(terminalEvents.map(item => [item.id, item])); +Deno.test('incoming Call to Play views replace removed author slices wholesale', () => { + const previous = { events: [create(), event('join', 'Bob', 'Rsvp', NOW + 1)] }; + const incoming = { events: [create()] }; - assertEquals( - pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS).size, - 2, - 'visible terminal history stays cached', - ); - assertEquals( - pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS + 1).size, - 0, - 'retired terminal history is pruned', - ); - - const tombstone = event('terminal-only', 'Alice', 'Cancel', NOW + 1); - const tombstoneMap = new Map([[tombstone.id, tombstone]]); - assertEquals( - pruneCallToPlayEvents( - tombstoneMap, - NOW + 1 + TERMINAL_RETENTION_MS + 1, - ).size, - 0, - 'backend tombstone is pruned too', - ); + const replaced = replaceCallToPlayView(previous, incoming); + assertEquals(replaced.events.length, 1, 'departed author slice is removed'); + assertEquals(replaced.events[0].id, 'create', 'incoming projection is authoritative'); }); Deno.test('scheduled time input accepts design formats and wraps steppers', () => { diff --git a/crates/lanspread-tauri-deno-ts/tests/callToPlayOwnership.test.ts b/crates/lanspread-tauri-deno-ts/tests/callToPlayOwnership.test.ts new file mode 100644 index 0000000..e2e13d0 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/tests/callToPlayOwnership.test.ts @@ -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 = (actual: T, expected: T, message: string) => { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +}; + +const assertArrayEquals = (actual: T[], expected: T[], message: string) => { + assertEquals(JSON.stringify(actual), JSON.stringify(expected), message); +}; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((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 { + 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(); + const cleanupDone = deferred(); + 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(); + const second = deferred(); + const firstStarted = deferred(); + const secondStarted = deferred(); + 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(); + const started = deferred(); + 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(); + const action = deferred(); + 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(); + const newer = deferred(); + 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(); + const second = deferred(); + 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(); + const second = deferred(); + 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'); +}); diff --git a/crates/lanspread-tauri-deno-ts/tests/frontendBootstrap.test.ts b/crates/lanspread-tauri-deno-ts/tests/frontendBootstrap.test.ts new file mode 100644 index 0000000..9193300 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/tests/frontendBootstrap.test.ts @@ -0,0 +1,257 @@ +import { + bootstrapFrontend, + type FrontendCloseRequest, +} from '../src/lib/frontendBootstrap.ts'; + +const assertEquals = (actual: T, expected: T, message: string) => { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +}; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((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(); + const destruction = deferred(); + 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>(); + 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>(); + const registrationAcknowledged = deferred<() => void>(); + const cleanupDone = deferred(); + 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(); + const persistenceDrained = deferred(); + const finalizerStarted = deferred(); + 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'); +}); diff --git a/crates/lanspread-tauri-deno-ts/tests/frontendPersistence.test.ts b/crates/lanspread-tauri-deno-ts/tests/frontendPersistence.test.ts new file mode 100644 index 0000000..4b5c29e --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/tests/frontendPersistence.test.ts @@ -0,0 +1,103 @@ +import { + PersistenceShutdownScope, + startAdmittedPersistenceEffect, +} from '../src/lib/frontendPersistence.ts'; + +const assertEquals = (actual: T, expected: T, message: string) => { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +}; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((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(); + 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'); +}); diff --git a/crates/lanspread-tauri-deno-ts/tests/gameDirectory.test.ts b/crates/lanspread-tauri-deno-ts/tests/gameDirectory.test.ts new file mode 100644 index 0000000..875004f --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/tests/gameDirectory.test.ts @@ -0,0 +1,157 @@ +import { + acceptGameDirectory, + hydrateGameDirectory, +} from '../src/lib/gameDirectory.ts'; + +const assertEquals = (actual: T, expected: T, message: string) => { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +}; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((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(); + 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(); + 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'); +}); diff --git a/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts b/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts index 333f23b..2005986 100644 --- a/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts +++ b/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts @@ -1,286 +1,418 @@ import { - actionLabel, - activeStatusById, - applyFilterAndSort, - canStreamInstall, - countByFilter, - deriveState, - downloadProgressPercent, - formatDownloadBytes, - formatBytesPerSecond, - formatDownloadEta, - formatDownloadSpeed, - formatDownloadSpeedShort, - gameStatusLabel, - mergeGameUpdate, - stateChipLabel, -} from '../src/lib/gameState.ts'; + actionLabel, + activeStatusById, + applyFilterAndSort, + applyGameTransferStatusSnapshot, + canStreamInstall, + countByFilter, + deriveState, + downloadProgressPercent, + formatBytesPerSecond, + formatDownloadBytes, + formatDownloadEta, + formatDownloadSpeed, + formatDownloadSpeedShort, + gameStatusLabel, + mergeGameUpdate, + stateChipLabel, +} from "../src/lib/gameState.ts"; import { - ActiveOperationKind, - GameAvailability, - InstallStatus, - type Game, -} from '../src/lib/types.ts'; + ActiveOperationKind, + type Game, + GameAvailability, + GameTransferStatus, + InstallStatus, +} from "../src/lib/types.ts"; const assertEquals = (actual: T, expected: T, message: string) => { - if (actual !== expected) { - throw new Error(`${message}: expected ${expected}, got ${actual}`); - } + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } }; const game = (overrides: Partial = {}): Game => ({ - id: 'game', - name: 'Game', - description: '', - size: 0, - downloaded: false, - installed: false, - availability: GameAvailability.LocalOnly, - install_status: InstallStatus.NotInstalled, + id: "game", + name: "Game", + description: "", + size: 0, + downloaded: false, + installed: false, + availability: GameAvailability.LocalOnly, + install_status: InstallStatus.NotInstalled, + peer_count: 1, + ...overrides, +}); + +Deno.test("snapshot keeps installing visible until installed state settles", () => { + const fromDownloading = game({ + install_status: InstallStatus.Downloading, + }); + const installing = mergeGameUpdate( + game({ downloaded: true }), + fromDownloading, + InstallStatus.Installing, + ); + const installedWhileActive = mergeGameUpdate( + game({ downloaded: true, installed: true }), + installing, + InstallStatus.Installing, + ); + const settled = mergeGameUpdate( + game({ downloaded: true, installed: true }), + installedWhileActive, + ); + + assertEquals( + installing.install_status, + InstallStatus.Installing, + "active install snapshot should render Installing", + ); + assertEquals( + installedWhileActive.install_status, + InstallStatus.Installing, + "installed local state should not override an active install snapshot", + ); + assertEquals( + settled.install_status, + InstallStatus.Installed, + "cleared active snapshot with installed local state should render Installed", + ); +}); + +Deno.test("active operation snapshot is the source of busy status", () => { + const statuses = activeStatusById([ + { id: "game", operation: ActiveOperationKind.Downloading }, + { id: "other", operation: ActiveOperationKind.Updating }, + ]); + + assertEquals( + statuses.get("game"), + InstallStatus.Downloading, + "download operation should render Downloading", + ); + assertEquals( + statuses.get("other"), + InstallStatus.Installing, + "update operation should render Installing", + ); +}); + +Deno.test("download progress is preserved only while actively downloading", () => { + const downloading = game({ + install_status: InstallStatus.Downloading, + download_progress: { + attemptId: "1", + downloaded_bytes: 50, + total_bytes: 100, + bytes_per_second: 12_500_000, + active_peer_count: 2, + }, + }); + + const stillDownloading = mergeGameUpdate( + game(), + downloading, + InstallStatus.Downloading, + ); + const settled = mergeGameUpdate(game({ downloaded: true }), stillDownloading); + + assertEquals( + stillDownloading.download_progress?.downloaded_bytes, + 50, + "active download snapshot should keep progress", + ); + assertEquals( + stillDownloading.download_progress?.active_peer_count, + 2, + "active download snapshot should keep live peer count", + ); + assertEquals( + settled.download_progress, + undefined, + "settled snapshot should clear progress", + ); +}); + +Deno.test("downloading action label includes current speed", () => { + const downloading = game({ + install_status: InstallStatus.Downloading, + download_progress: { + attemptId: "1", + downloaded_bytes: 50, + total_bytes: 100, + bytes_per_second: 12_500_000, + active_peer_count: 2, + }, + }); + + assertEquals( + formatBytesPerSecond(12_500_000), + "12.5 MB/s", + "speed formatter should use compact decimal units", + ); + assertEquals( + actionLabel(downloading), + "Downloading… 12.5 MB/s", + "download label should include speed", + ); +}); + +Deno.test("downloading state is distinct and stays on the local filter", () => { + const downloading = game({ + id: "downloading", + name: "Downloading", + install_status: InstallStatus.Downloading, + }); + const local = game({ + id: "local", + name: "Local", + downloaded: true, + }); + const remote = game({ + id: "remote", + name: "Remote", peer_count: 1, - ...overrides, + }); + + assertEquals( + deriveState(downloading), + "downloading", + "download operation should render the dedicated downloading state", + ); + assertEquals( + countByFilter([downloading, local, remote]).local, + 2, + "local filter count should include in-flight downloads", + ); + assertEquals( + applyFilterAndSort([downloading, local, remote], "local", "status", "") + .length, + 2, + "local filter should include in-flight downloads", + ); }); -Deno.test('snapshot keeps installing visible until installed state settles', () => { - const fromDownloading = game({ - install_status: InstallStatus.Downloading, - }); - const installing = mergeGameUpdate( - game({ downloaded: true }), - fromDownloading, - InstallStatus.Installing, - ); - const installedWhileActive = mergeGameUpdate( - game({ downloaded: true, installed: true }), - installing, - InstallStatus.Installing, - ); - const settled = mergeGameUpdate( - game({ downloaded: true, installed: true }), - installedWhileActive, - ); +Deno.test("sticky transfer exhaustion keeps a departed remote game visible until cleared", () => { + const remoteOnly = game({ + id: "remote-only", + name: "Remote only", + peer_count: 1, + }); + assertEquals( + applyFilterAndSort([remoteOnly], "all", "az", "").length, + 1, + "a remote-only game should initially be visible", + ); - assertEquals( - installing.install_status, - InstallStatus.Installing, - 'active install snapshot should render Installing', - ); - assertEquals( - installedWhileActive.install_status, - InstallStatus.Installing, - 'installed local state should not override an active install snapshot', - ); - assertEquals( - settled.install_status, - InstallStatus.Installed, - 'cleared active snapshot with installed local state should render Installed', - ); + const peerDeparted = mergeGameUpdate( + game({ id: "remote-only", name: "Remote only", peer_count: 0 }), + remoteOnly, + ); + assertEquals( + peerDeparted.install_status, + InstallStatus.NotInstalled, + "an empty active-operation snapshot should leave the game idle", + ); + assertEquals( + peerDeparted.peer_count, + 0, + "the sole remote source should be absent from the next catalog snapshot", + ); + assertEquals( + applyFilterAndSort([peerDeparted], "all", "az", "").length, + 0, + "an idle remote-only game should be invisible before exhaustion arrives", + ); + + const [exhausted] = applyGameTransferStatusSnapshot([peerDeparted], { + revision: 1, + statuses: { "remote-only": GameTransferStatus.Exhausted }, + openAttempts: {}, + }); + assertEquals( + exhausted.transfer_status, + GameTransferStatus.Exhausted, + "the sticky snapshot should preserve the exact terminal status", + ); + assertEquals( + applyFilterAndSort([exhausted], "all", "az", "").length, + 1, + "terminal exhaustion should keep the departed remote game visible", + ); + assertEquals( + countByFilter([exhausted]).all, + 1, + "the All-filter count should match terminal exhaustion visibility", + ); + + const [cleared] = applyGameTransferStatusSnapshot([exhausted], { + revision: 2, + statuses: {}, + openAttempts: {}, + }); + assertEquals( + cleared.transfer_status, + undefined, + "a full replacement that omits the game should clear exhaustion", + ); + assertEquals( + applyFilterAndSort([cleared], "all", "az", "").length, + 0, + "clearing exhaustion should remove the otherwise-invisible game", + ); }); -Deno.test('active operation snapshot is the source of busy status', () => { - const statuses = activeStatusById([ - { id: 'game', operation: ActiveOperationKind.Downloading }, - { id: 'other', operation: ActiveOperationKind.Updating }, - ]); - +Deno.test("transient transfer activity does not resurrect an invisible game", () => { + const departed = game({ peer_count: 0 }); + for ( + const transferStatus of [ + GameTransferStatus.Verifying, + GameTransferStatus.Retrying, + ] + ) { assertEquals( - statuses.get('game'), - InstallStatus.Downloading, - 'download operation should render Downloading', - ); - assertEquals( - statuses.get('other'), - InstallStatus.Installing, - 'update operation should render Installing', + applyFilterAndSort( + [{ ...departed, transfer_status: transferStatus }], + "all", + "az", + "", + ).length, + 0, + `${transferStatus} should not make an otherwise-invisible game visible`, ); + } }); -Deno.test('download progress is preserved only while actively downloading', () => { - const downloading = game({ - install_status: InstallStatus.Downloading, - download_progress: { - downloaded_bytes: 50, - total_bytes: 100, - bytes_per_second: 12_500_000, - active_peer_count: 2, - }, - }); +Deno.test("download progress formatting matches the progress-bar layouts", () => { + const downloading = game({ + install_status: InstallStatus.Downloading, + download_progress: { + attemptId: "1", + downloaded_bytes: 12 * 1024 * 1024 * 1024, + total_bytes: 35 * 1024 * 1024 * 1024, + bytes_per_second: 49_400_000, + active_peer_count: 3, + }, + }); - const stillDownloading = mergeGameUpdate( - game(), - downloading, - InstallStatus.Downloading, - ); - const settled = mergeGameUpdate(game({ downloaded: true }), stillDownloading); - - assertEquals( - stillDownloading.download_progress?.downloaded_bytes, - 50, - 'active download snapshot should keep progress', - ); - assertEquals( - stillDownloading.download_progress?.active_peer_count, - 2, - 'active download snapshot should keep live peer count', - ); - assertEquals( - settled.download_progress, - undefined, - 'settled snapshot should clear progress', - ); + assertEquals( + Math.round(downloadProgressPercent(downloading)), + 34, + "progress percent should come from backend byte counters", + ); + assertEquals( + formatDownloadSpeed(49_400_000), + "49.4 MB/s", + "large bar speed format", + ); + assertEquals( + formatDownloadSpeedShort(49_400_000), + "49 MB/s", + "card speed format", + ); + assertEquals( + formatDownloadBytes(12 * 1024 * 1024 * 1024), + "12 GB", + "downloaded byte format should avoid noisy trailing decimals", + ); + assertEquals( + formatDownloadEta(485), + "8 min", + "eta format should stay compact", + ); }); -Deno.test('downloading action label includes current speed', () => { - const downloading = game({ - install_status: InstallStatus.Downloading, - download_progress: { - downloaded_bytes: 50, - total_bytes: 100, - bytes_per_second: 12_500_000, - active_peer_count: 2, - }, - }); - - assertEquals( - formatBytesPerSecond(12_500_000), - '12.5 MB/s', - 'speed formatter should use compact decimal units', - ); - assertEquals( - actionLabel(downloading), - 'Downloading… 12.5 MB/s', - 'download label should include speed', - ); -}); - -Deno.test('downloading state is distinct and stays on the local filter', () => { - const downloading = game({ - id: 'downloading', - name: 'Downloading', - install_status: InstallStatus.Downloading, - }); - const local = game({ - id: 'local', - name: 'Local', - downloaded: true, - }); - const remote = game({ - id: 'remote', - name: 'Remote', - peer_count: 1, - }); - - assertEquals( - deriveState(downloading), - 'downloading', - 'download operation should render the dedicated downloading state', - ); - assertEquals( - countByFilter([downloading, local, remote]).local, - 2, - 'local filter count should include in-flight downloads', - ); - assertEquals( - applyFilterAndSort([downloading, local, remote], 'local', 'status', '').length, - 2, - 'local filter should include in-flight downloads', - ); -}); - -Deno.test('download progress formatting matches the progress-bar layouts', () => { - const downloading = game({ - install_status: InstallStatus.Downloading, - download_progress: { - downloaded_bytes: 12 * 1024 * 1024 * 1024, - total_bytes: 35 * 1024 * 1024 * 1024, - bytes_per_second: 49_400_000, - active_peer_count: 3, - }, - }); - - assertEquals( - Math.round(downloadProgressPercent(downloading)), - 34, - 'progress percent should come from backend byte counters', - ); - assertEquals(formatDownloadSpeed(49_400_000), '49.4 MB/s', 'large bar speed format'); - assertEquals(formatDownloadSpeedShort(49_400_000), '49 MB/s', 'card speed format'); - assertEquals( - formatDownloadBytes(12 * 1024 * 1024 * 1024), - '12 GB', - 'downloaded byte format should avoid noisy trailing decimals', - ); - assertEquals(formatDownloadEta(485), '8 min', 'eta format should stay compact'); -}); - -Deno.test('stream install is available only for idle remote games', () => { - assertEquals( - canStreamInstall(game({ downloaded: false, installed: false, peer_count: 1 })), - true, - 'remote-only idle games should allow streamed install', - ); - assertEquals( - canStreamInstall(game({ downloaded: true, installed: false, peer_count: 1 })), - false, - 'downloaded games should install from local archives', - ); - assertEquals( - canStreamInstall(game({ downloaded: false, installed: true, peer_count: 1 })), - false, - 'installed games should not expose streamed install', - ); - assertEquals( - canStreamInstall(game({ downloaded: false, installed: false, peer_count: 0 })), - false, - 'games without peers should not expose streamed install', - ); - assertEquals( - canStreamInstall(game({ - downloaded: false, - installed: false, - peer_count: 1, - install_status: InstallStatus.CheckingPeers, - })), - false, - 'busy games should not expose streamed install', - ); -}); - -Deno.test('streamed local installs are labeled installed but not shareable', () => { - const streamed = game({ +Deno.test("stream install is available only for idle remote games", () => { + assertEquals( + canStreamInstall( + game({ downloaded: false, installed: false, peer_count: 1 }), + true, + ), + true, + "catalog-supported remote-only idle games should allow streamed install", + ); + assertEquals( + canStreamInstall( + game({ downloaded: false, installed: false, peer_count: 1 }), + false, + ), + false, + "catalog-unsupported games should not expose streamed install", + ); + assertEquals( + canStreamInstall( + game({ downloaded: true, installed: false, peer_count: 1 }), + true, + ), + false, + "downloaded games should install from local archives", + ); + assertEquals( + canStreamInstall( + game({ downloaded: false, installed: true, peer_count: 1 }), + true, + ), + false, + "installed games should not expose streamed install", + ); + assertEquals( + canStreamInstall( + game({ downloaded: false, installed: false, peer_count: 0 }), + true, + ), + false, + "games without peers should not expose streamed install", + ); + assertEquals( + canStreamInstall( + game({ downloaded: false, - installed: true, - install_status: InstallStatus.Installed, - }); - const downloadedInstall = game({ - downloaded: true, - installed: true, - install_status: InstallStatus.Installed, - }); + installed: false, + peer_count: 1, + install_status: InstallStatus.Installing, + }), + true, + ), + false, + "busy games should not expose streamed install", + ); +}); - assertEquals( - deriveState(streamed), - 'installed', - 'streamed local installs should keep installed visual state', - ); - assertEquals( - stateChipLabel(streamed), - 'Not shareable', - 'card chip should make the non-shareable state visible', - ); - assertEquals( - gameStatusLabel(streamed), - 'Installed, not shareable', - 'detail status should spell out installed plus non-shareable', - ); - assertEquals( - stateChipLabel(downloadedInstall), - 'Installed', - 'normal downloaded installs should keep the installed chip label', - ); - assertEquals( - gameStatusLabel(downloadedInstall), - 'Installed', - 'normal downloaded installs should keep the installed detail label', - ); +Deno.test("streamed local installs are labeled installed but not shareable", () => { + const streamed = game({ + downloaded: false, + installed: true, + install_status: InstallStatus.Installed, + }); + const downloadedInstall = game({ + downloaded: true, + installed: true, + install_status: InstallStatus.Installed, + }); + + assertEquals( + deriveState(streamed), + "installed", + "streamed local installs should keep installed visual state", + ); + assertEquals( + stateChipLabel(streamed), + "Not shareable", + "card chip should make the non-shareable state visible", + ); + assertEquals( + gameStatusLabel(streamed), + "Installed, not shareable", + "detail status should spell out installed plus non-shareable", + ); + assertEquals( + stateChipLabel(downloadedInstall), + "Installed", + "normal downloaded installs should keep the installed chip label", + ); + assertEquals( + gameStatusLabel(downloadedInstall), + "Installed", + "normal downloaded installs should keep the installed detail label", + ); }); diff --git a/crates/lanspread-tauri-deno-ts/tests/identityDiagnostic.test.ts b/crates/lanspread-tauri-deno-ts/tests/identityDiagnostic.test.ts new file mode 100644 index 0000000..ab86e46 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/tests/identityDiagnostic.test.ts @@ -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 = (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'); + } +}); diff --git a/crates/lanspread-tauri-deno-ts/tests/localNetworkSharing.test.ts b/crates/lanspread-tauri-deno-ts/tests/localNetworkSharing.test.ts new file mode 100644 index 0000000..b523966 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/tests/localNetworkSharing.test.ts @@ -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 = (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 => ({ + ...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(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(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'); +}); diff --git a/crates/lanspread-tauri-deno-ts/tests/protocolMismatch.test.ts b/crates/lanspread-tauri-deno-ts/tests/protocolMismatch.test.ts new file mode 100644 index 0000000..a647347 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/tests/protocolMismatch.test.ts @@ -0,0 +1,45 @@ +import { + INITIAL_PROTOCOL_MISMATCH_SNAPSHOT, + newestProtocolMismatchSnapshot, + type ProtocolMismatchSnapshot, +} from "../src/lib/protocolMismatch.ts"; + +const assertEquals = (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", + ); +}); diff --git a/crates/lanspread-tauri-deno-ts/tests/thumbnailRequests.test.ts b/crates/lanspread-tauri-deno-ts/tests/thumbnailRequests.test.ts new file mode 100644 index 0000000..3c4bcda --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/tests/thumbnailRequests.test.ts @@ -0,0 +1,130 @@ +import { + createThumbnailRequestKey, + startOwnedThumbnailGeneration, + ThumbnailRequestGeneration, + thumbnailIdsFromRequestKey, +} from '../src/lib/thumbnailRequests.ts'; +import { AsyncAdoptionScope } from '../src/lib/asyncOwnership.ts'; + +const assertEquals = (actual: T, expected: T, message: string) => { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +}; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((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(); + 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(); + 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', + ); +}); diff --git a/crates/lanspread-tauri-deno-ts/tests/transferStatus.test.ts b/crates/lanspread-tauri-deno-ts/tests/transferStatus.test.ts new file mode 100644 index 0000000..19c5c1f --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/tests/transferStatus.test.ts @@ -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 = (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", + ); +});