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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user