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

158 lines
5.5 KiB
TypeScript

import {
acceptGameDirectory,
hydrateGameDirectory,
} from '../src/lib/gameDirectory.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
Deno.test('game directory is neither persisted nor used before backend acknowledgement', async () => {
const acknowledgement = deferred<unknown>();
const persisted: string[] = [];
let displayedPath = '/old/path';
const update = acceptGameDirectory('/picked/../path', {
updateBackend: requestedPath => {
assertEquals(
requestedPath,
'/picked/../path',
'backend should receive the requested path',
);
return acknowledgement.promise;
},
persist: acceptedPath => {
persisted.push(acceptedPath);
return Promise.resolve();
},
reportPersistenceError: () => {},
});
assertEquals(displayedPath, '/old/path', 'pending update should retain the old UI path');
assertEquals(persisted.length, 0, 'pending update should not write the requested path');
acknowledgement.resolve('/canonical/path');
displayedPath = await update;
assertEquals(displayedPath, '/canonical/path', 'UI should use the acknowledged path');
assertEquals(persisted.length, 1, 'acknowledged path should be persisted once');
assertEquals(persisted[0], '/canonical/path', 'store should receive the canonical path');
});
Deno.test('backend rejection leaves the old game directory untouched', async () => {
const rejected = new Error('directory rejected');
const persisted: string[] = [];
let displayedPath = '/old/path';
try {
displayedPath = await acceptGameDirectory('/rejected/path', {
updateBackend: () => Promise.reject(rejected),
persist: acceptedPath => {
persisted.push(acceptedPath);
return Promise.resolve();
},
reportPersistenceError: () => {},
});
throw new Error('expected backend rejection');
} catch (error) {
assertEquals(error, rejected, 'backend error should be preserved');
}
assertEquals(displayedPath, '/old/path', 'rejection should retain the old UI path');
assertEquals(persisted.length, 0, 'rejection should not change persistent state');
});
Deno.test('invalid backend acknowledgement is not persisted', async () => {
let persisted = false;
let rejected = false;
try {
await acceptGameDirectory('/picked/path', {
updateBackend: () => Promise.resolve(' '),
persist: () => {
persisted = true;
return Promise.resolve();
},
reportPersistenceError: () => {},
});
} catch {
rejected = true;
}
assertEquals(rejected, true, 'empty acknowledgement should be rejected');
assertEquals(persisted, false, 'invalid acknowledgement should not be persisted');
});
Deno.test('persistence failure does not undo an acknowledged backend update', async () => {
const persistenceError = new Error('store unavailable');
let reportedError: unknown;
const acceptedPath = await acceptGameDirectory('/picked/path', {
updateBackend: () => Promise.resolve('/canonical/path'),
persist: () => Promise.reject(persistenceError),
reportPersistenceError: error => {
reportedError = error;
},
});
assertEquals(acceptedPath, '/canonical/path', 'backend acknowledgement should remain accepted');
assertEquals(reportedError, persistenceError, 'persistence failure should be reported');
});
Deno.test('saved game-directory hydration settles only after backend acceptance', async () => {
const acceptance = deferred<void>();
let settled = false;
const hydration = hydrateGameDirectory({
loadSavedPath: () => Promise.resolve('/saved/path'),
acceptSavedPath: path => {
assertEquals(path, '/saved/path', 'saved path should be restored');
return acceptance.promise;
},
reportLoadError: () => {},
}).then(() => {
settled = true;
});
await Promise.resolve();
assertEquals(settled, false, 'hydration must wait for backend acceptance');
acceptance.resolve();
await hydration;
assertEquals(settled, true, 'hydration should settle after acceptance');
});
Deno.test('missing or failed game-directory state still completes hydration', async () => {
let accepts = 0;
await hydrateGameDirectory({
loadSavedPath: () => Promise.resolve(undefined),
acceptSavedPath: () => {
accepts += 1;
return Promise.resolve();
},
reportLoadError: () => {},
});
assertEquals(accepts, 0, 'missing state should not invoke the backend');
const failure = new Error('store unavailable');
let reported: unknown;
await hydrateGameDirectory({
loadSavedPath: () => Promise.reject(failure),
acceptSavedPath: () => Promise.resolve(),
reportLoadError: error => {
reported = error;
},
});
assertEquals(reported, failure, 'load failure should be reported before ready');
});