Files
ddidderr 71dbf27d8b 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
2026-08-10 13:59:40 +02:00

267 lines
7.4 KiB
TypeScript

import {
applyDownloadProgress,
applyGameTransferStatusSnapshot,
downloadProgressAriaLabel,
downloadProgressTransferLabel,
gameTransferStatusPresentation,
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
newestGameTransferStatusSnapshot,
} from "../src/lib/gameState.ts";
import {
type DownloadProgressPayload,
type Game,
GameAvailability,
GameTransferStatus,
type GameTransferStatusSnapshot,
InstallStatus,
} from "../src/lib/types.ts";
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`${message}: expected ${JSON.stringify(expected)}, got ${
JSON.stringify(actual)
}`,
);
}
};
const game = (id: string): Game => ({
id,
name: id,
description: "",
size: 0,
downloaded: false,
installed: false,
availability: GameAvailability.LocalOnly,
install_status: InstallStatus.Downloading,
peer_count: 1,
download_progress: {
attemptId: `${id}-attempt`,
downloaded_bytes: 10,
total_bytes: 100,
bytes_per_second: 5,
active_peer_count: 1,
},
});
const snapshot = (
revision: number,
statuses: GameTransferStatusSnapshot["statuses"],
openAttempts: GameTransferStatusSnapshot["openAttempts"] = {},
): GameTransferStatusSnapshot => ({ revision, statuses, openAttempts });
const progress = (
id: string,
attemptId: string,
downloadedBytes: number,
): DownloadProgressPayload => ({
id,
attemptId,
downloaded_bytes: downloadedBytes,
total_bytes: 100,
bytes_per_second: 5,
active_peer_count: 1,
});
Deno.test("newer transfer event beats stale and equal GamesList snapshots", () => {
const event = snapshot(2, { alpha: GameTransferStatus.Retrying });
assertEquals(
newestGameTransferStatusSnapshot(event, snapshot(1, {})),
event,
"a delayed older GamesList snapshot must not replace the listener event",
);
assertEquals(
newestGameTransferStatusSnapshot(
event,
snapshot(2, { alpha: GameTransferStatus.Exhausted }),
),
event,
"an equal revision replay must not change the accepted full snapshot",
);
});
Deno.test("higher revision full replacement clears omitted game statuses", () => {
const games = applyGameTransferStatusSnapshot(
[game("alpha"), game("bravo")],
snapshot(4, {
alpha: GameTransferStatus.Verifying,
bravo: GameTransferStatus.Exhausted,
}, { alpha: "alpha-attempt", bravo: "bravo-attempt" }),
);
const replacement = applyGameTransferStatusSnapshot(
games,
snapshot(
5,
{ bravo: GameTransferStatus.Retrying },
{ alpha: "alpha-attempt", bravo: "bravo-attempt" },
),
);
assertEquals(
replacement.map((item) => item.transfer_status),
[undefined, GameTransferStatus.Retrying],
"omission must mean None while the listed game retains its exact status",
);
assertEquals(
replacement.map((item) => item.download_progress?.downloaded_bytes),
[10, 10],
"typed status replacement must not mutate direct byte progress",
);
});
Deno.test("progress is fenced by the newest exact open attempt", () => {
const attemptA = "9007199254740992";
const attemptB = "9007199254740993";
const withoutProgress = [{ ...game("alpha"), download_progress: undefined }];
const openA = snapshot(1, {}, { alpha: attemptA });
const openB = snapshot(2, {}, { alpha: attemptB });
const terminalB = snapshot(3, {}, {});
const progressA = applyDownloadProgress(
withoutProgress,
openA,
progress("alpha", attemptA, 10),
);
assertEquals(
progressA[0].download_progress?.attemptId,
attemptA,
"A progress should be accepted while A is the open attempt",
);
const beganB = applyGameTransferStatusSnapshot(progressA, openB);
assertEquals(
beganB[0].download_progress,
undefined,
"a newer B Begin snapshot must clear already-rendered A progress",
);
const delayedA = applyDownloadProgress(
beganB,
openB,
progress("alpha", attemptA, 20),
);
assertEquals(
delayedA[0].download_progress,
undefined,
"delayed A progress must be rejected after B becomes authoritative",
);
const progressB = applyDownloadProgress(
delayedA,
openB,
progress("alpha", attemptB, 30),
);
assertEquals(
progressB[0].download_progress?.downloaded_bytes,
30,
"progress for the exact current B attempt must be accepted",
);
const finishedB = applyGameTransferStatusSnapshot(progressB, terminalB);
assertEquals(
finishedB[0].download_progress,
undefined,
"terminal replacement must clear B progress with the open attempt",
);
const lateB = applyDownloadProgress(
finishedB,
terminalB,
progress("alpha", attemptB, 40),
);
assertEquals(
lateB[0].download_progress,
undefined,
"late B progress must remain rejected after terminal settlement",
);
});
Deno.test("transfer status lookup uses only own game-id properties", () => {
const [projected] = applyGameTransferStatusSnapshot(
[game("toString")],
snapshot(3, {}),
);
assertEquals(
projected.transfer_status,
undefined,
"prototype properties must not become catalog game statuses",
);
assertEquals(
newestGameTransferStatusSnapshot(
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
snapshot(1, {}),
).revision,
1,
"the backend revision-one bootstrap must replace the frontend initial state",
);
});
Deno.test("transfer notices use the exact copy and severity contract", () => {
assertEquals(
gameTransferStatusPresentation(GameTransferStatus.Verifying),
{ message: "Verifying downloaded chunks", level: "info" },
"verification copy",
);
assertEquals(
gameTransferStatusPresentation(GameTransferStatus.Retrying),
{
message: "A source sent invalid data; retrying another nearby peer",
level: "warning",
},
"retry copy",
);
assertEquals(
gameTransferStatusPresentation(GameTransferStatus.Exhausted),
{
message: "No nearby peer could provide the verified catalog version",
level: "error",
},
"exhaustion copy",
);
assertEquals(
gameTransferStatusPresentation(undefined),
undefined,
"None must remain invisible",
);
});
Deno.test("download progress promotes transient verification and retry copy", () => {
const verifying = {
...game("Alpha"),
transfer_status: GameTransferStatus.Verifying,
};
const retrying = {
...game("Bravo"),
transfer_status: GameTransferStatus.Retrying,
};
const exhausted = {
...game("Charlie"),
transfer_status: GameTransferStatus.Exhausted,
};
assertEquals(
downloadProgressTransferLabel(verifying),
"Verifying downloaded chunks",
"verification should replace the primary progress copy",
);
assertEquals(
downloadProgressAriaLabel(verifying),
"Verifying downloaded chunks: Alpha",
"verification should replace the progressbar accessible name",
);
assertEquals(
downloadProgressTransferLabel(retrying),
"A source sent invalid data; retrying another nearby peer",
"retry should replace the primary progress copy",
);
assertEquals(
downloadProgressAriaLabel(retrying),
"A source sent invalid data; retrying another nearby peer: Bravo",
"retry should replace the progressbar accessible name",
);
assertEquals(
downloadProgressTransferLabel(exhausted),
undefined,
"terminal exhaustion should remain on the card/modal status surface",
);
});