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