feat(app): expose local sharing and verified transfers

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

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

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

Test Plan:
- `just frontend-test` -- passed (91/91)
- `just build` -- passed with TypeScript, Vite, and release Tauri compilation
- `just test` -- passed on the completed stack (708 workspace tests)
- `just clippy` -- passed on the completed stack
- `git diff --cached --check` -- passed
This commit is contained in:
ddidderr committed 2026-08-10 13:59:40 +02:00
1 parent 60fd7ba0c2
commit 71dbf27d8b
42 files changed
+7457 -2101

No files matched your search

@@ -1,264 +1,443 @@
import { useCallback, useMemo, useState } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { TopBar } from '../components/topbar/TopBar';
import { KebabItem } from '../components/topbar/KebabMenu';
import { ResultsBar } from '../components/grid/ResultsBar';
import { GameGrid } from '../components/grid/GameGrid';
import { GameDetailModal } from '../components/modals/GameDetailModal';
import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDownloadModal';
import { SettingsDialog } from '../components/modals/SettingsDialog';
import { NoDirectoryState } from '../components/empty/NoDirectoryState';
import { EmptyResultsState } from '../components/empty/EmptyResultsState';
import { CallToPlayTicker } from '../components/calltoplay/CallToPlayTicker';
import { CallToPlayOverlay } from '../components/calltoplay/CallToPlayOverlay';
import { TopBar } from "../components/topbar/TopBar";
import { KebabItem } from "../components/topbar/KebabMenu";
import { ResultsBar } from "../components/grid/ResultsBar";
import { GameGrid } from "../components/grid/GameGrid";
import { GameDetailModal } from "../components/modals/GameDetailModal";
import { ConfirmRemoveDownloadModal } from "../components/modals/ConfirmRemoveDownloadModal";
import { SettingsDialog } from "../components/modals/SettingsDialog";
import { NoDirectoryState } from "../components/empty/NoDirectoryState";
import { EmptyResultsState } from "../components/empty/EmptyResultsState";
import { CallToPlayTicker } from "../components/calltoplay/CallToPlayTicker";
import { CallToPlayOverlay } from "../components/calltoplay/CallToPlayOverlay";
import { useGameDirectory } from '../hooks/useGameDirectory';
import { useGames } from '../hooks/useGames';
import { useGameActions } from '../hooks/useGameActions';
import { useThumbnails } from '../hooks/useThumbnails';
import { useSettings } from '../hooks/useSettings';
import { useCallToPlay } from '../hooks/useCallToPlay';
import { useGameDirectory } from "../hooks/useGameDirectory";
import { useGames } from "../hooks/useGames";
import { useGameActions } from "../hooks/useGameActions";
import { useThumbnails } from "../hooks/useThumbnails";
import { useSettings } from "../hooks/useSettings";
import { useCallToPlay } from "../hooks/useCallToPlay";
import { useStreamInstallCapability } from "../hooks/useStreamInstallCapability";
import { useLocalNetworkSharing } from "../hooks/useLocalNetworkSharing";
import { useIdentityDiagnostic } from "../hooks/useIdentityDiagnostic";
import { Game } from '../lib/types';
import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState';
import { Game } from "../lib/types";
import {
AsyncOwner,
ownCompanionWindowCreation,
windowAsyncScope,
} from "../lib/asyncOwnership";
import {
isLocalNetworkSharingActive,
localNetworkSharingNotice,
} from "../lib/localNetworkSharing";
import {
EPHEMERAL_IDENTITY_NOTICE,
IDENTITY_STATUS_UNAVAILABLE_NOTICE,
} from "../lib/identityDiagnostic";
import {
applyFilterAndSort,
countByFilter,
needsUpdate,
} from "../lib/gameState";
const openLogsWindow = async () => {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
try {
const existing = await WebviewWindow.getByLabel('unpack-logs');
if (existing) {
await existing.setFocus();
return;
}
const win = new WebviewWindow('unpack-logs', {
url: '/?view=unpack-logs',
title: 'Unpack Logs',
width: 900,
height: 700,
resizable: true,
});
await win.once<unknown>('tauri://error', (event) => {
console.error('Error opening unpack logs window:', event.payload);
});
} catch (err) {
console.error('Error opening unpack logs window:', err);
interface CompanionWindowOptions {
label: string;
url: string;
title: string;
width: number;
height: number;
}
const openCompanionWindow = async (
owner: AsyncOwner,
options: CompanionWindowOptions,
): Promise<void> => {
try {
let windowModule:
| typeof import("@tauri-apps/api/webviewWindow")
| undefined;
if (
!await owner.applyIfActive(
() => import("@tauri-apps/api/webviewWindow"),
(loaded) => {
windowModule = loaded;
},
) || windowModule === undefined
) {
return;
}
const { WebviewWindow } = windowModule;
const existingResult: Array<
Awaited<ReturnType<typeof WebviewWindow.getByLabel>>
> = [];
if (
!await owner.applyIfActive(
() => WebviewWindow.getByLabel(options.label),
(found) => {
existingResult.push(found);
},
)
) {
return;
}
const existingWindow = existingResult[0];
if (existingWindow) {
await owner.applyIfActive(() => existingWindow.setFocus(), () => {});
return;
}
if (!owner.isActive()) return;
const win = new WebviewWindow(options.label, {
url: options.url,
title: options.title,
width: options.width,
height: options.height,
resizable: true,
});
const result = await ownCompanionWindowCreation(owner, {
registerCreated: (handler) => win.once("tauri://created", handler),
registerError: (handler) =>
win.once<unknown>("tauri://error", (event) => handler(event.payload)),
destroy: () => win.destroy(),
});
if (!owner.isActive()) return;
if (result.kind === "error") {
console.error(`Error opening ${options.title}:`, result.payload);
} else if (result.kind === "registration-error") {
console.error(
`Error owning ${options.title} creation:`,
result.error,
);
}
} catch (err) {
if (owner.isActive()) console.error(`Error opening ${options.title}:`, err);
}
};
const openMainLogsWindow = async () => {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
try {
const existing = await WebviewWindow.getByLabel('main-logs');
if (existing) {
await existing.setFocus();
return;
}
const win = new WebviewWindow('main-logs', {
url: '/?view=main-logs',
title: 'Application Logs',
width: 980,
height: 720,
resizable: true,
});
await win.once<unknown>('tauri://error', (event) => {
console.error('Error opening application logs window:', event.payload);
});
} catch (err) {
console.error('Error opening application logs window:', err);
}
const UNPACK_LOG_WINDOW: CompanionWindowOptions = {
label: "unpack-logs",
url: "/?view=unpack-logs",
title: "Unpack Logs",
width: 900,
height: 700,
};
const MAIN_LOG_WINDOW: CompanionWindowOptions = {
label: "main-logs",
url: "/?view=main-logs",
title: "Application Logs",
width: 980,
height: 720,
};
export const MainWindow = () => {
const { settings, set: setSetting } = useSettings();
const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory();
const games = useGames(rescan);
const actions = useGameActions(games, settings);
const thumbnails = useThumbnails();
const callToPlay = useCallToPlay(settings.username);
const { settings, set: setSetting, ready: settingsReady } = useSettings();
const localNetworkSharing = useLocalNetworkSharing();
const identityDiagnostic = useIdentityDiagnostic();
const {
gameDir,
ready: gameDirectoryReady,
hasGameDirectory,
setGameDir,
rescan,
} = useGameDirectory(localNetworkSharing.ready);
const games = useGames(rescan);
const actions = useGameActions(games, settings);
const thumbnails = useThumbnails(games.games.map((game) => game.id));
const sharingActive = isLocalNetworkSharingActive(
localNetworkSharing.snapshot,
);
const callToPlay = useCallToPlay(settings.username, sharingActive);
const windowActionOwnerRef = useRef<AsyncOwner | null>(null);
const [openGameId, setOpenGameId] = useState<string | null>(null);
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
const visibleGames = useMemo(
() => hasGameDirectory ? games.games : [],
[games.games, hasGameDirectory],
);
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
useEffect(() => {
const owner = new AsyncOwner((error) => {
console.error("Failed to clean up a main-window action:", error);
});
windowActionOwnerRef.current = owner;
return () => {
if (windowActionOwnerRef.current === owner) {
windowActionOwnerRef.current = null;
}
windowAsyncScope.adopt(owner.dispose());
};
}, []);
// Query is local UI state (no need to persist).
const [query, setQuery] = useState('');
const filteredGames = useMemo(
() => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
[visibleGames, settings.filter, settings.sort, query],
);
const [openGameId, setOpenGameId] = useState<string | null>(null);
const supportsStreamedInstall = useStreamInstallCapability(openGameId);
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
const visibleGames = useMemo(
() => hasGameDirectory ? games.games : [],
[games.games, hasGameDirectory],
);
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
const openGame = useMemo<Game | null>(
() => openGameId ? games.games.find(g => g.id === openGameId) ?? null : null,
[openGameId, games.games],
);
const removeGame = useMemo<Game | null>(
() => removeGameId ? games.games.find(g => g.id === removeGameId) ?? null : null,
[removeGameId, games.games],
);
// Query is local UI state (no need to persist).
const [query, setQuery] = useState("");
const filteredGames = useMemo(
() =>
applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
[visibleGames, settings.filter, settings.sort, query],
);
const pickDirectory = useCallback(async () => {
const picked = await open({ multiple: false, directory: true });
if (typeof picked === 'string' && picked) setGameDir(picked);
}, [setGameDir]);
const openGame = useMemo<Game | null>(
() =>
openGameId ? games.games.find((g) => g.id === openGameId) ?? null : null,
[openGameId, games.games],
);
const removeGame = useMemo<Game | null>(
() =>
removeGameId
? games.games.find((g) => g.id === removeGameId) ?? null
: null,
[removeGameId, games.games],
);
const handlePrimary = useCallback((game: Game) => {
if (game.installed) {
if (needsUpdate(game)) actions.update(game.id);
else actions.play(game.id);
} else {
actions.install(game.id);
const pickDirectory = useCallback(async () => {
const owner = windowActionOwnerRef.current;
if (owner === null || !owner.isActive()) return;
try {
await owner.applyIfActive(
() => open({ multiple: false, directory: true }),
(picked) => {
if (typeof picked === "string" && picked) setGameDir(picked);
},
);
} catch (error) {
if (owner.isActive()) {
console.error("Failed to choose a game directory:", error);
}
}
}, [setGameDir]);
const handlePrimary = useCallback((game: Game) => {
if (game.installed) {
if (needsUpdate(game)) actions.update(game.id);
else actions.play(game.id);
} else {
actions.install(game.id);
}
}, [actions]);
const handleUninstall = useCallback((game: Game) => {
actions.uninstall(game.id);
}, [actions]);
const handleRemoveDownload = useCallback((game: Game) => {
setRemoveGameId(game.id);
}, []);
const confirmRemoveDownload = useCallback((game: Game) => {
actions.removeDownload(game.id);
setRemoveGameId(null);
setOpenGameId((current) => current === game.id ? null : current);
}, [actions]);
const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [
{ kind: "item", label: "Settings", onClick: () => setSettingsOpen(true) },
{ kind: "item", label: "Refresh library", onClick: () => rescan() },
{ kind: "separator" },
{
kind: "item",
label: "Application logs",
onClick: () => {
const owner = windowActionOwnerRef.current;
if (owner) {
windowAsyncScope.adopt(openCompanionWindow(owner, MAIN_LOG_WINDOW));
}
}, [actions]);
},
},
{
kind: "item",
label: "Unpack logs",
onClick: () => {
const owner = windowActionOwnerRef.current;
if (owner) {
windowAsyncScope.adopt(openCompanionWindow(owner, UNPACK_LOG_WINDOW));
}
},
},
], [rescan]);
const handleUninstall = useCallback((game: Game) => {
actions.uninstall(game.id);
}, [actions]);
const rootStyle = { "--accent": settings.accent } as React.CSSProperties;
const className = [
"launcher",
`bg-${settings.bg}`,
`density-${settings.density}`,
].join(" ");
const handleRemoveDownload = useCallback((game: Game) => {
setRemoveGameId(game.id);
}, []);
if (
!settingsReady ||
!localNetworkSharing.ready ||
!identityDiagnostic.ready ||
!gameDirectoryReady
) {
return <div className={className} style={rootStyle} aria-busy="true" />;
}
const confirmRemoveDownload = useCallback((game: Game) => {
actions.removeDownload(game.id);
setRemoveGameId(null);
setOpenGameId(current => current === game.id ? null : current);
}, [actions]);
const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [
{ kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) },
{ kind: 'item', label: 'Refresh library', onClick: () => rescan() },
{ kind: 'separator' },
{ kind: 'item', label: 'Application logs', onClick: () => void openMainLogsWindow() },
{ kind: 'item', label: 'Unpack logs', onClick: () => void openLogsWindow() },
], [rescan]);
const rootStyle = { '--accent': settings.accent } as React.CSSProperties;
const className = [
'launcher',
`bg-${settings.bg}`,
`density-${settings.density}`,
].join(' ');
return (
<div className={className} style={rootStyle}>
<TopBar
accent={settings.accent}
peerCount={games.totalPeerCount}
filter={settings.filter}
setFilter={(v) => setSetting('filter', v)}
counts={counts}
query={query}
setQuery={setQuery}
sort={settings.sort}
setSort={(v) => setSetting('sort', v)}
kebabItems={kebabItems}
nominations={callToPlay.nominations}
onOpenCallToPlay={() => {
setFocusedCallId(null);
setCallToPlayOpen(true);
}}
/>
<main className="grid-wrap">
<CallToPlayTicker
nominations={callToPlay.nominations}
games={games.games}
accent={settings.accent}
onOpen={(callId) => {
setFocusedCallId(callId);
setCallToPlayOpen(true);
}}
/>
{hasGameDirectory ? (
<>
<ResultsBar shown={filteredGames.length} total={counts.all} />
{filteredGames.length === 0 ? (
visibleGames.length === 0 ? (
<EmptyResultsState
title="Scanning for games"
hint="Looking for game bundles in your selected directory…"
/>
) : (
<EmptyResultsState
title="Nothing matches"
hint="No games match the current filter or search query."
/>
)
) : (
<GameGrid
games={filteredGames}
aspect={settings.aspect}
getThumbnail={thumbnails.get}
onOpen={(g) => setOpenGameId(g.id)}
onPrimary={handlePrimary}
onCancelDownload={(g) => actions.cancelDownload(g.id)}
/>
)}
</>
) : (
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
)}
</main>
{openGame && (
<GameDetailModal
game={openGame}
thumbnailUrl={thumbnails.get(openGame.id)}
onClose={() => setOpenGameId(null)}
onPrimary={handlePrimary}
onStreamInstall={(g) => actions.streamInstall(g.id)}
onUninstall={handleUninstall}
onRemoveDownload={handleRemoveDownload}
onCancelDownload={(g) => actions.cancelDownload(g.id)}
onStartServer={(g) => actions.startServer(g.id)}
onViewFiles={(g) => actions.viewFiles(g.id)}
/>
)}
{removeGame && (
<ConfirmRemoveDownloadModal
game={removeGame}
onCancel={() => setRemoveGameId(null)}
onConfirm={confirmRemoveDownload}
/>
)}
{settingsOpen && (
<SettingsDialog
settings={settings}
gameDir={gameDir}
hasGameDirectory={hasGameDirectory}
onPickDirectory={() => void pickDirectory()}
onChange={setSetting}
onClose={() => setSettingsOpen(false)}
/>
)}
{callToPlayOpen && (
<CallToPlayOverlay
nominations={callToPlay.nominations}
games={games.games}
actorId={callToPlay.actorId}
actions={callToPlay.actions}
focusId={focusedCallId}
transportReady={callToPlay.transportReady}
error={callToPlay.error}
getThumbnail={thumbnails.get}
totalPeerCount={games.totalPeerCount}
onLaunch={handlePrimary}
onClose={() => {
setCallToPlayOpen(false);
setFocusedCallId(null);
}}
/>
)}
return (
<div className={className} style={rootStyle}>
<TopBar
accent={settings.accent}
peerCount={games.totalPeerCount}
filter={settings.filter}
setFilter={(v) => setSetting("filter", v)}
counts={counts}
query={query}
setQuery={setQuery}
sort={settings.sort}
setSort={(v) => setSetting("sort", v)}
kebabItems={kebabItems}
nominations={callToPlay.nominations}
onOpenCallToPlay={() => {
setFocusedCallId(null);
setCallToPlayOpen(true);
}}
/>
<main className="grid-wrap">
<div
className={`network-notice network-notice-sharing is-${localNetworkSharing.snapshot.phase}`}
role="status"
>
{localNetworkSharingNotice(localNetworkSharing.snapshot)}
</div>
);
{localNetworkSharing.error &&
localNetworkSharing.snapshot.persistenceProblem === null && (
<div className="network-notice is-error" role="status">
{localNetworkSharing.error}
</div>
)}
{identityDiagnostic.snapshot.diagnostic === "ephemeral" && (
<div className="network-notice identity-notice" role="status">
{EPHEMERAL_IDENTITY_NOTICE}
</div>
)}
{identityDiagnostic.unavailable && (
<div className="network-notice identity-notice" role="status">
{IDENTITY_STATUS_UNAVAILABLE_NOTICE}
</div>
)}
{games.protocolMismatch && (
<div className="network-notice" role="status">
Nearby devices are running a different Lanspread version
</div>
)}
<CallToPlayTicker
nominations={callToPlay.nominations}
games={games.games}
accent={settings.accent}
onOpen={(callId) => {
setFocusedCallId(callId);
setCallToPlayOpen(true);
}}
/>
{hasGameDirectory
? (
<>
<ResultsBar shown={filteredGames.length} total={counts.all} />
{filteredGames.length === 0
? (
visibleGames.length === 0
? (
<EmptyResultsState
title="Scanning for games"
hint="Looking for game bundles in your selected directory…"
/>
)
: (
<EmptyResultsState
title="Nothing matches"
hint="No games match the current filter or search query."
/>
)
)
: (
<GameGrid
games={filteredGames}
aspect={settings.aspect}
getThumbnail={thumbnails.get}
onOpen={(g) => setOpenGameId(g.id)}
onPrimary={handlePrimary}
onCancelDownload={(g) => actions.cancelDownload(g.id)}
/>
)}
</>
)
: (
<NoDirectoryState
onChooseDirectory={() => windowAsyncScope.adopt(pickDirectory())}
/>
)}
</main>
{openGame && (
<GameDetailModal
game={openGame}
thumbnailUrl={thumbnails.get(openGame.id)}
supportsStreamedInstall={supportsStreamedInstall}
onClose={() => setOpenGameId(null)}
onPrimary={handlePrimary}
onStreamInstall={(g) => actions.streamInstall(g.id)}
onUninstall={handleUninstall}
onRemoveDownload={handleRemoveDownload}
onCancelDownload={(g) => actions.cancelDownload(g.id)}
onStartServer={(g) => actions.startServer(g.id)}
onViewFiles={(g) => actions.viewFiles(g.id)}
/>
)}
{removeGame && (
<ConfirmRemoveDownloadModal
game={removeGame}
onCancel={() => setRemoveGameId(null)}
onConfirm={confirmRemoveDownload}
/>
)}
{settingsOpen && (
<SettingsDialog
settings={settings}
gameDir={gameDir}
hasGameDirectory={hasGameDirectory}
onPickDirectory={() => windowAsyncScope.adopt(pickDirectory())}
onChange={setSetting}
localNetworkSharing={localNetworkSharing.snapshot}
localNetworkSharingReady={localNetworkSharing.ready}
localNetworkSharingBusy={localNetworkSharing.busy}
localNetworkSharingError={localNetworkSharing.error}
onLocalNetworkSharingChange={localNetworkSharing.setEnabled}
onClose={() => setSettingsOpen(false)}
/>
)}
{callToPlayOpen && (
<CallToPlayOverlay
nominations={callToPlay.nominations}
games={games.games}
actorId={callToPlay.actorId}
actions={callToPlay.actions}
focusId={focusedCallId}
transportReady={callToPlay.transportReady && sharingActive}
error={sharingActive
? callToPlay.error
: "Local network sharing is off."}
getThumbnail={thumbnails.get}
totalPeerCount={games.totalPeerCount}
onLaunch={handlePrimary}
onClose={() => {
setCallToPlayOpen(false);
setFocusedCallId(null);
}}
/>
)}
</div>
);
};