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

326 lines
11 KiB
TypeScript

import {
CallToPlayAsyncScope,
type CallToPlayRetryCallback,
type CallToPlayRetryScheduler,
} from '../src/lib/callToPlayOwnership.ts';
import { type AsyncCleanup } from '../src/lib/asyncOwnership.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const assertArrayEquals = <T>(actual: T[], expected: T[], message: string) => {
assertEquals(JSON.stringify(actual), JSON.stringify(expected), message);
};
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 };
};
interface ScheduledRetry {
callback: CallToPlayRetryCallback;
cancelled: boolean;
}
class ManualRetryScheduler {
private readonly scheduled: ScheduledRetry[] = [];
public readonly schedule: CallToPlayRetryScheduler = callback => {
const retry = { callback, cancelled: false };
this.scheduled.push(retry);
return () => {
retry.cancelled = true;
};
};
public pendingCount(): number {
return this.scheduled.filter(retry => !retry.cancelled).length;
}
public fireNext(): Promise<void> {
const index = this.scheduled.findIndex(retry => !retry.cancelled);
if (index < 0) throw new Error('no retry is scheduled');
const [retry] = this.scheduled.splice(index, 1);
return retry.callback();
}
}
const noRetry: CallToPlayRetryScheduler = () => () => {};
Deno.test('queued listener callbacks are ignored and a late listener unlistens after disposal', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const listener = deferred<AsyncCleanup>();
const cleanupDone = deferred<void>();
const published: string[] = [];
let callback: ((value: string) => void) | undefined;
let unlistens = 0;
const registration = scope.registerListener(() => {
callback = scope.guard(value => published.push(value));
return listener.promise;
});
const disposal = scope.dispose();
callback?.('queued-after-dispose');
listener.resolve(async () => {
unlistens += 1;
await cleanupDone.promise;
});
await Promise.resolve();
assertEquals(unlistens, 1, 'the listener should unlisten as soon as registration resolves');
cleanupDone.resolve();
assertEquals(await registration, false, 'the late registration should report cancellation');
await disposal;
assertArrayEquals(published, [], 'a queued listener must not publish after disposal');
});
Deno.test('retry attempts never overlap and stop after readiness', async () => {
const scheduler = new ManualRetryScheduler();
const scope = new CallToPlayAsyncScope(scheduler.schedule);
const first = deferred<boolean>();
const second = deferred<boolean>();
const firstStarted = deferred<void>();
const secondStarted = deferred<void>();
let attempts = 0;
scope.startRetry(() => {
attempts += 1;
if (attempts === 1) {
firstStarted.resolve();
return first.promise;
}
secondStarted.resolve();
return second.promise;
}, 2_000);
assertEquals(scheduler.pendingCount(), 1, 'starting retry should schedule one attempt');
const firstRun = scheduler.fireNext();
await firstStarted.promise;
assertEquals(attempts, 1, 'the first retry should start');
assertEquals(scheduler.pendingCount(), 0, 'no retry should queue while one is in flight');
scope.startRetry(() => Promise.resolve(false), 2_000);
assertEquals(scheduler.pendingCount(), 0, 'starting again must not overlap the in-flight retry');
first.resolve(false);
await firstRun;
assertEquals(scheduler.pendingCount(), 1, 'a failed attempt should schedule its successor');
const secondRun = scheduler.fireNext();
await secondStarted.promise;
assertEquals(attempts, 2, 'the successor should start only after the first settled');
assertEquals(scheduler.pendingCount(), 0, 'the second in-flight retry must be exclusive');
second.resolve(true);
await secondRun;
assertEquals(scheduler.pendingCount(), 0, 'readiness should stop the retry loop');
await scope.dispose();
});
Deno.test('disposal waits for an in-flight retry attempt', async () => {
const scheduler = new ManualRetryScheduler();
const scope = new CallToPlayAsyncScope(scheduler.schedule);
const attempt = deferred<boolean>();
const started = deferred<void>();
let disposalSettled = false;
scope.startRetry(() => {
started.resolve();
return attempt.promise;
}, 2_000);
const retry = scheduler.fireNext();
await started.promise;
const disposal = scope.dispose().then(() => {
disposalSettled = true;
});
await Promise.resolve();
assertEquals(disposalSettled, false, 'disposal must join the running retry');
attempt.resolve(false);
await retry;
await disposal;
assertEquals(disposalSettled, true, 'disposal should settle after the retry exits');
assertEquals(scheduler.pendingCount(), 0, 'a disposed retry must not schedule a successor');
});
Deno.test('late snapshots and actions cannot publish after their scope is disposed', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const snapshot = deferred<string>();
const action = deferred<boolean>();
const publications: string[] = [];
let snapshotStarts = 0;
let actionStarts = 0;
const pendingSnapshot = scope.applyIfActive(
() => {
snapshotStarts += 1;
return snapshot.promise;
},
value => publications.push(`snapshot:${value}`),
);
const pendingAction = scope.applyIfActive(
() => {
actionStarts += 1;
return action.promise;
},
value => publications.push(`action:${value}`),
);
const disposal = scope.dispose();
snapshot.resolve('stale');
action.resolve(true);
assertEquals(await pendingSnapshot, false, 'the late snapshot should report cancellation');
assertEquals(await pendingAction, false, 'the late action should report cancellation');
await disposal;
assertArrayEquals(publications, [], 'late work must not publish into a disposed hook');
const postDisposeAction = await scope.applyIfActive(
() => {
actionStarts += 1;
return Promise.resolve(true);
},
value => publications.push(`post-dispose:${value}`),
);
assertEquals(postDisposeAction, false, 'an action must not start after disposal');
assertEquals(snapshotStarts, 1, 'the snapshot should start exactly once');
assertEquals(actionStarts, 1, 'only the pre-disposal action should start');
assertArrayEquals(publications, [], 'post-disposal actions must not publish');
});
Deno.test('an older action completion cannot overwrite a newer action status', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const older = deferred<boolean>();
const newer = deferred<boolean>();
const statuses: string[] = [];
const publishOlder = scope.guardLatestAction((accepted: boolean) => {
statuses.push(`older:${accepted}`);
});
const olderAction = scope.applyIfActive(
() => older.promise,
accepted => publishOlder(accepted),
);
const publishNewer = scope.guardLatestAction((accepted: boolean) => {
statuses.push(`newer:${accepted}`);
});
const newerAction = scope.applyIfActive(
() => newer.promise,
accepted => publishNewer(accepted),
);
newer.resolve(true);
assertEquals(await newerAction, true, 'the newer action should complete normally');
older.resolve(false);
assertEquals(await olderAction, true, 'the older action result should still complete');
assertArrayEquals(statuses, ['newer:true'], 'only the newest action may publish status');
await scope.dispose();
});
Deno.test('backend mutations start in user order even when the first invoke is delayed', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const first = deferred<string>();
const second = deferred<string>();
const started: string[] = [];
const applied: string[] = [];
const firstMutation = scope.applyMutationIfActive(
() => {
started.push('display-name:older');
return first.promise;
},
value => applied.push(value),
);
const secondMutation = scope.applyMutationIfActive(
() => {
started.push('action:newer');
return second.promise;
},
value => applied.push(value),
);
await Promise.resolve();
assertArrayEquals(started, ['display-name:older'], 'the newer mutation must remain queued');
first.resolve('older-applied');
assertEquals(await firstMutation, true, 'the first mutation should publish while active');
await Promise.resolve();
assertArrayEquals(
started,
['display-name:older', 'action:newer'],
'the second backend invoke must start after the first settles',
);
second.resolve('newer-applied');
assertEquals(await secondMutation, true, 'the second mutation should publish while active');
assertArrayEquals(
applied,
['older-applied', 'newer-applied'],
'mutation results should publish in backend order',
);
await scope.dispose();
});
Deno.test('disposal drains admitted mutations and rejects later admission', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const first = deferred<void>();
const second = deferred<void>();
const started: string[] = [];
const publications: string[] = [];
const firstMutation = scope.applyMutationIfActive(
() => {
started.push('first');
return first.promise;
},
() => publications.push('first'),
);
const secondMutation = scope.applyMutationIfActive(
() => {
started.push('second');
return second.promise;
},
() => publications.push('second'),
);
await Promise.resolve();
let disposed = false;
const disposal = scope.dispose().then(() => {
disposed = true;
});
const rejected = await scope.applyMutationIfActive(
() => {
started.push('rejected');
return Promise.resolve();
},
() => publications.push('rejected'),
);
assertEquals(rejected, false, 'a mutation submitted after disposal must be rejected');
assertEquals(disposed, false, 'disposal must wait for admitted mutations');
first.resolve();
assertEquals(await firstMutation, false, 'disposed scopes suppress the first result');
await Promise.resolve();
assertArrayEquals(started, ['first', 'second'], 'the admitted successor must still run');
assertEquals(disposed, false, 'disposal must also wait for the admitted successor');
second.resolve();
assertEquals(await secondMutation, false, 'disposed scopes suppress the second result');
await disposal;
assertEquals(disposed, true, 'disposal should settle after the admitted queue drains');
assertArrayEquals(started, ['first', 'second'], 'no post-disposal mutation may start');
assertArrayEquals(publications, [], 'disposed mutation results must not publish');
});