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,201 @@
|
||||
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');
|
||||
});
|
||||
Reference in New Issue
Block a user