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

202 lines
6.9 KiB
TypeScript

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 = <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 snapshot = (
revision: number,
overrides: Partial<LocalNetworkSharingSnapshot> = {},
): 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<void>(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<void>(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');
});