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:
2026-08-10 13:59:40 +02:00
parent 60fd7ba0c2
commit 71dbf27d8b
42 changed files with 7457 additions and 2101 deletions
@@ -0,0 +1,444 @@
export type AsyncCleanup = () => void | Promise<void>;
export type AsyncRegistration = () => Promise<AsyncCleanup>;
/**
* Observes promises whose lexical React cleanup cannot await. The Tauri event
* API removes its JavaScript callback synchronously; this scope owns the
* remaining backend acknowledgement while the webview realm is alive.
*/
export class AsyncAdoptionScope {
private readonly pending = new Set<Promise<unknown>>();
private readonly disposers = new Set<() => Promise<void>>();
private acceptingDisposers = true;
public constructor(
private readonly reportFailure: (error: unknown) => void = () => {},
) {}
public adopt<T>(promise: Promise<T>): void {
this.pending.add(promise);
void promise.then(
() => this.pending.delete(promise),
(error) => {
this.pending.delete(promise);
try {
this.reportFailure(error);
} catch {
// Reporting must not create a second unhandled rejection.
}
},
);
}
public registerDisposer(
dispose: () => Promise<void>,
): (() => void) | undefined {
if (!this.acceptingDisposers) return undefined;
this.disposers.add(dispose);
return () => this.disposers.delete(dispose);
}
/** Invalidates and drains all mounted scopes before native webview destruction. */
public async disposeOwned(): Promise<void> {
this.acceptingDisposers = false;
const disposers = [...this.disposers];
this.disposers.clear();
const pending: Promise<void>[] = [];
for (const dispose of disposers) {
try {
const disposal = dispose();
pending.push(disposal);
this.adopt(disposal);
} catch (error) {
try {
this.reportFailure(error);
} catch {
// Reporting must not interrupt the other disposers.
}
}
}
await Promise.allSettled(pending);
await this.drain();
}
public async drain(): Promise<void> {
while (this.pending.size > 0) {
await Promise.allSettled([...this.pending]);
}
}
}
export const windowAsyncScope = new AsyncAdoptionScope((error) => {
console.error("Detached frontend cleanup failed:", error);
});
export const mergeHydratedState = <T extends object>(
restored: T,
pendingEdits: Partial<T>,
): T => ({ ...restored, ...pendingEdits });
/**
* Owns asynchronous registrations and result publication for one mounted scope.
* Disposal invalidates the scope and invokes owned cleanup synchronously. Its
* returned promise drains asynchronous cleanup acknowledgements, including a
* listener that finishes registering after disposal.
*/
export class AsyncOwner {
private disposed = false;
private cleanups: AsyncCleanup[] = [];
private readonly pending = new Set<Promise<unknown>>();
private readonly pendingRegistrations = new Set<Promise<unknown>>();
private readonly pendingCleanups = new Set<Promise<unknown>>();
private latestOperation = 0;
private readonly releaseWindowOwnership: () => void;
public constructor(
private readonly reportCleanupError: (error: unknown) => void = () => {},
adoptionScope: AsyncAdoptionScope = windowAsyncScope,
) {
const release = adoptionScope.registerDisposer(
() => this.disposeForWindowClose(),
);
if (release === undefined) {
this.disposed = true;
this.releaseWindowOwnership = () => {};
} else {
this.releaseWindowOwnership = release;
}
}
public isActive(): boolean {
return !this.disposed;
}
public guard<Args extends unknown[]>(
callback: (...args: Args) => void,
): (...args: Args) => void {
return (...args) => {
if (this.isActive()) callback(...args);
};
}
public register(registration: AsyncRegistration): Promise<boolean> {
if (!this.isActive()) return Promise.resolve(false);
return this.trackRegistration(this.registerInner(registration));
}
public own(cleanup: AsyncCleanup): boolean {
if (!this.isActive()) {
void this.release(cleanup);
return false;
}
this.cleanups.push(cleanup);
return true;
}
public release(cleanup: AsyncCleanup): Promise<void> {
return this.trackCleanup(this.runCleanup(cleanup));
}
private async registerInner(
registration: AsyncRegistration,
): Promise<boolean> {
if (!this.isActive()) return false;
let cleanup: AsyncCleanup;
try {
cleanup = await registration();
} catch (error) {
if (!this.isActive()) return false;
throw error;
}
if (!this.isActive()) {
await this.release(cleanup);
return false;
}
return this.own(cleanup);
}
public applyIfActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
): Promise<boolean> {
if (!this.isActive()) return Promise.resolve(false);
return this.track(this.applyActive(operation, apply));
}
public applyLatestIfActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
): Promise<boolean> {
if (!this.isActive()) return Promise.resolve(false);
const generation = ++this.latestOperation;
return this.track(this.applyActive(operation, (value) => {
if (generation === this.latestOperation) apply(value);
}, generation));
}
private async applyActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
generation?: number,
): Promise<boolean> {
if (!this.isActive()) return false;
let value: T;
try {
value = await operation();
} catch (error) {
if (
!this.isActive() ||
(generation !== undefined && generation !== this.latestOperation)
) {
return false;
}
throw error;
}
if (
!this.isActive() ||
(generation !== undefined && generation !== this.latestOperation)
) {
return false;
}
apply(value);
return true;
}
public dispose(): Promise<void> {
this.beginDispose();
return this.drain();
}
/** Drains every admitted operation before native webview destruction. */
public disposeForWindowClose(): Promise<void> {
this.beginDispose();
return this.drain();
}
private beginDispose(): void {
if (!this.isActive()) return;
this.disposed = true;
this.releaseWindowOwnership();
this.latestOperation += 1;
const cleanups = this.cleanups.reverse();
this.cleanups = [];
for (const cleanup of cleanups) void this.release(cleanup);
}
public async drain(): Promise<void> {
while (this.pending.size > 0) {
await Promise.allSettled([...this.pending]);
}
}
private track<T>(promise: Promise<T>): Promise<T> {
this.pending.add(promise);
void promise.then(
() => this.pending.delete(promise),
() => this.pending.delete(promise),
);
return promise;
}
private trackRegistration<T>(promise: Promise<T>): Promise<T> {
this.pendingRegistrations.add(promise);
void promise.then(
() => this.pendingRegistrations.delete(promise),
() => this.pendingRegistrations.delete(promise),
);
return this.track(promise);
}
private trackCleanup(promise: Promise<void>): Promise<void> {
this.pendingCleanups.add(promise);
void promise.then(
() => this.pendingCleanups.delete(promise),
() => this.pendingCleanups.delete(promise),
);
return this.track(promise);
}
private async runCleanup(cleanup: AsyncCleanup): Promise<void> {
try {
await cleanup();
} catch (error) {
try {
this.reportCleanupError(error);
} catch {
// Cleanup reporting must not interrupt the remaining cleanup.
}
}
}
}
export interface CompanionWindowCreationPort {
registerCreated: (handler: () => void) => Promise<AsyncCleanup>;
registerError: (handler: (payload: unknown) => void) => Promise<AsyncCleanup>;
destroy: () => Promise<void>;
}
export type CompanionWindowCreationResult =
| { kind: "created" }
| { kind: "error"; payload: unknown }
| { kind: "registration-error"; error: unknown };
/**
* Owns one native companion-window creation through exactly one created/error
* outcome. Parent disposal keeps the internal callbacks alive until that
* outcome, drains both unlisten acknowledgements, and destroys a window that
* finishes creating after close admission has ended.
*/
export const ownCompanionWindowCreation = async (
owner: AsyncOwner,
port: CompanionWindowCreationPort,
): Promise<CompanionWindowCreationResult> => {
let settle!: (result: CompanionWindowCreationResult) => void;
let settled = false;
const outcome = new Promise<CompanionWindowCreationResult>((resolve) => {
settle = (result) => {
if (settled) return;
settled = true;
resolve(result);
};
});
const register = (
operation: () => Promise<AsyncCleanup>,
): Promise<AsyncCleanup> => {
try {
return operation();
} catch (error) {
return Promise.reject(error);
}
};
const createdRegistration = register(() =>
port.registerCreated(() => settle({ kind: "created" }))
);
const errorRegistration = register(() =>
port.registerError((payload) => settle({ kind: "error", payload }))
);
void createdRegistration.catch((error) =>
settle({ kind: "registration-error", error })
);
void errorRegistration.catch((error) =>
settle({ kind: "registration-error", error })
);
let parentClosing = false;
let completion: Promise<CompanionWindowCreationResult> | undefined;
const complete = (): Promise<CompanionWindowCreationResult> => {
if (completion !== undefined) return completion;
completion = (async () => {
const result = await outcome;
const registrations = await Promise.allSettled([
createdRegistration,
errorRegistration,
]);
const cleanupResults = await Promise.allSettled(
registrations.flatMap((registration) =>
registration.status === "fulfilled"
? [Promise.resolve().then(registration.value)]
: []
),
);
const cleanupFailure = cleanupResults.find(
(cleanup) => cleanup.status === "rejected",
);
if (
result.kind === "registration-error" ||
(result.kind === "created" && parentClosing)
) {
await port.destroy();
}
if (cleanupFailure?.status === "rejected") throw cleanupFailure.reason;
return result;
})();
return completion;
};
if (
!owner.own(async () => {
parentClosing = true;
await complete();
})
) {
parentClosing = true;
}
return complete();
};
export const registerSequentially = async (
owner: AsyncOwner,
registrations: ReadonlyArray<AsyncRegistration>,
): Promise<boolean> => {
try {
for (const registration of registrations) {
if (!await owner.register(registration)) return false;
}
return owner.isActive();
} catch (error) {
await owner.dispose();
throw error;
}
};
export interface SerializedAsyncWriter<T> {
enqueue: (value: T) => Promise<void>;
waitForIdle: () => Promise<void>;
closeAndWait: () => Promise<void>;
}
/** Serializes writes and keeps a failed write from poisoning later work. */
export const createSerializedAsyncWriter = <T>(
write: (value: T) => Promise<void>,
reportFailure: (error: unknown) => void,
): SerializedAsyncWriter<T> => {
let tail = Promise.resolve();
let accepting = true;
const enqueue = (value: T): Promise<void> => {
if (!accepting) return Promise.resolve();
tail = tail.then(async () => {
try {
await write(value);
} catch (error) {
try {
reportFailure(error);
} catch {
// Reporting must not prevent a newer value from being written.
}
}
});
return tail;
};
const waitForIdle = async (): Promise<void> => {
let observed: Promise<void>;
do {
observed = tail;
await observed;
} while (observed !== tail);
};
return {
enqueue,
waitForIdle,
closeAndWait: () => {
accepting = false;
return waitForIdle();
},
};
};
@@ -1,6 +1,7 @@
import {
CallToPlayAction,
CallToPlayEvent,
CallToPlayView,
CallToPlayViewEvent,
CallToPlayParticipant,
Nomination,
} from './types';
@@ -13,17 +14,22 @@ export const CALL_TO_PLAY_CONNECTING_MESSAGE =
export const callToPlayPublishErrorMessage = (error: unknown): string => {
const detail = error instanceof Error ? error.message : String(error);
if (detail.includes('Call to Play event is obsolete')
|| detail.includes('Call to Play history is missing')
if (detail.includes('unknown or expired')
|| detail.includes('already terminal')
) {
return 'This Call to Play has expired or already finished.';
}
if (detail.includes('Call to Play event history is full')) {
if (detail.includes('local event history is full')) {
return 'Call to Play has reached its active update limit. Start or cancel an active call, then try again.';
}
return 'Could not send this Call to Play update.';
};
export const replaceCallToPlayView = (
_previous: CallToPlayView,
incoming: CallToPlayView,
): CallToPlayView => incoming;
export const extendDeadline = (
now: number,
currentDeadline: number,
@@ -44,7 +50,7 @@ interface MutableNomination extends Nomination {
messageIds: Set<string>;
}
const compareEvents = (a: CallToPlayEvent, b: CallToPlayEvent): number =>
const compareEvents = (a: CallToPlayViewEvent, b: CallToPlayViewEvent): number =>
a.at - b.at || a.id.localeCompare(b.id);
type CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create'];
@@ -117,7 +123,7 @@ export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus
};
export const reduceCallToPlayEvents = (
input: ReadonlyArray<CallToPlayEvent>,
input: ReadonlyArray<CallToPlayViewEvent>,
now: number,
): Nomination[] => {
const nominations = [...groupEvents(input).values()]
@@ -126,35 +132,11 @@ export const reduceCallToPlayEvents = (
return sortNominations(nominations);
};
export const pruneCallToPlayEvents = (
previous: ReadonlyMap<string, CallToPlayEvent>,
now: number,
): ReadonlyMap<string, CallToPlayEvent> => {
const retiredEventIds = new Set<string>();
for (const events of groupEvents([...previous.values()]).values()) {
if (deriveNomination(events, now) !== null) continue;
const hasCreate = events.some(event => createPayload(event.action) !== null);
const expiredTombstone = events.some(event =>
(event.action === 'Start' || event.action === 'Cancel')
&& now - event.at > TERMINAL_RETENTION_MS
);
if (hasCreate || expiredTombstone) {
for (const event of events) retiredEventIds.add(event.id);
}
}
if (retiredEventIds.size === 0) return previous;
const next = new Map(previous);
for (const eventId of retiredEventIds) next.delete(eventId);
return next;
};
const groupEvents = (
input: ReadonlyArray<CallToPlayEvent>,
): Map<string, CallToPlayEvent[]> => {
input: ReadonlyArray<CallToPlayViewEvent>,
): Map<string, CallToPlayViewEvent[]> => {
const unique = new Map(input.map(event => [event.id, event]));
const byCall = new Map<string, CallToPlayEvent[]>();
const byCall = new Map<string, CallToPlayViewEvent[]>();
for (const event of unique.values()) {
const events = byCall.get(event.call_id) ?? [];
events.push(event);
@@ -164,7 +146,7 @@ const groupEvents = (
};
const deriveNomination = (
events: CallToPlayEvent[],
events: CallToPlayViewEvent[],
now: number,
): Nomination | null => {
events.sort(compareEvents);
@@ -176,15 +158,15 @@ const deriveNomination = (
const nomination: MutableNomination = {
id: create.call_id,
gameId: payload.game_id,
creatorId: create.actor_id,
creator: create.actor_name,
creatorId: create.author_id,
creator: create.author_name,
maxPlayers: payload.max_players,
createdAt: create.at,
scheduledFor: payload.scheduled_for,
deadline: payload.deadline,
participants: {
[create.actor_id]: {
name: create.actor_name,
[create.author_id]: {
name: create.author_name,
status: payload.scheduled_for === null ? 'ready' : 'in',
joinedAt: create.at,
},
@@ -214,7 +196,7 @@ const deriveNomination = (
return result;
};
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
const applyEvent = (nomination: MutableNomination, event: CallToPlayViewEvent): void => {
if (isTerminal(nomination)) return;
const action = event.action;
@@ -225,9 +207,9 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
const response = respondPayload(action);
if (response) {
const existing = nomination.participants[event.actor_id];
nomination.participants[event.actor_id] = {
name: event.actor_name,
const existing = nomination.participants[event.author_id];
nomination.participants[event.author_id] = {
name: event.author_name,
status: response.ready_at === null ? 'ready' : 'pending',
joinedAt: existing?.joinedAt ?? event.at,
...(response.ready_at === null ? {} : { readyAt: response.ready_at }),
@@ -236,12 +218,12 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
}
const message = messagePayload(action);
if (message && !nomination.messageIds.has(message.message_id)) {
nomination.messageIds.add(message.message_id);
if (message && !nomination.messageIds.has(event.id)) {
nomination.messageIds.add(event.id);
nomination.messages.push({
id: message.message_id,
fromId: event.actor_id,
from: event.actor_name,
id: event.id,
fromId: event.author_id,
from: event.author_name,
text: message.text,
at: event.at,
});
@@ -251,7 +233,7 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
const extension = addTimePayload(action);
if (extension
&& event.actor_id === nomination.creatorId
&& event.author_id === nomination.creatorId
) {
nomination.deadline = extension.deadline;
nomination.state = 'open';
@@ -260,32 +242,32 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
const applyUnitAction = (
nomination: MutableNomination,
event: CallToPlayEvent,
event: CallToPlayViewEvent,
action: Extract<CallToPlayAction, string>,
): void => {
switch (action) {
case 'Rsvp': {
const existing = nomination.participants[event.actor_id];
nomination.participants[event.actor_id] = {
name: event.actor_name,
const existing = nomination.participants[event.author_id];
nomination.participants[event.author_id] = {
name: event.author_name,
status: 'in',
joinedAt: existing?.joinedAt ?? event.at,
};
break;
}
case 'Leave':
if (event.actor_id !== nomination.creatorId) {
delete nomination.participants[event.actor_id];
if (event.author_id !== nomination.creatorId) {
delete nomination.participants[event.author_id];
}
break;
case 'Cancel':
if (event.actor_id === nomination.creatorId) {
if (event.author_id === nomination.creatorId) {
nomination.state = 'cancelled';
nomination.terminalAt = event.at;
}
break;
case 'Start':
if (event.actor_id === nomination.creatorId) {
if (event.author_id === nomination.creatorId) {
nomination.state = 'running';
nomination.terminalAt = event.at;
}
@@ -293,21 +275,6 @@ const applyUnitAction = (
}
};
export const callToPlayEvent = (
callId: string,
actorId: string,
actorName: string,
action: CallToPlayAction,
at = Date.now(),
): CallToPlayEvent => ({
id: globalThis.crypto.randomUUID(),
call_id: callId,
actor_id: actorId,
actor_name: actorName,
at,
action,
});
export const formatClock = (timestamp: number): string => {
const date = new Date(timestamp);
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
@@ -0,0 +1,223 @@
import {
AsyncOwner,
type AsyncCleanup,
type AsyncRegistration,
windowAsyncScope,
} from './asyncOwnership';
export type CallToPlayRetryCallback = () => Promise<void>;
export type CallToPlayRetryScheduler = (
callback: CallToPlayRetryCallback,
delayMilliseconds: number,
) => AsyncCleanup;
/**
* Owns the Call to Play listener, snapshots, actions, and retry timer as one
* lifecycle. A retry is scheduled only after the preceding attempt settles.
*/
export class CallToPlayAsyncScope {
private readonly owner: AsyncOwner;
private retryAttempt: (() => Promise<boolean>) | undefined;
private retryCleanup: AsyncCleanup | undefined;
private retryToken: object | undefined;
private retryRunning = false;
private retryEnabled = false;
private retryGeneration = 0;
private retryDelayMilliseconds = 0;
private actionGeneration = 0;
private mutationTail: Promise<void> = Promise.resolve();
private mutationAdmissionOpen = true;
private readonly releaseWindowOwnership: () => void;
public constructor(
private readonly scheduleRetry: CallToPlayRetryScheduler,
reportCleanupFailure: (error: unknown) => void = () => {},
private readonly reportRetryError: (error: unknown) => void = () => {},
) {
this.owner = new AsyncOwner(reportCleanupFailure);
this.releaseWindowOwnership = windowAsyncScope.registerDisposer(
() => this.disposeForWindowClose(),
) ?? (() => {});
}
public isActive(): boolean {
return this.owner.isActive();
}
public guard<Args extends unknown[]>(
callback: (...args: Args) => void,
): (...args: Args) => void {
return this.owner.guard(callback);
}
public registerListener(registration: AsyncRegistration): Promise<boolean> {
return this.owner.register(registration);
}
public applyIfActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
): Promise<boolean> {
return this.owner.applyIfActive(operation, apply);
}
/**
* Admits a backend mutation in user order. Once admitted, a mutation runs
* even if the React scope is disposed while it is queued; disposal closes
* admission and drains the already-admitted sequence. Result publication
* remains lifecycle-guarded.
*/
public applyMutationIfActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
): Promise<boolean> {
if (!this.isActive() || !this.mutationAdmissionOpen) {
return Promise.resolve(false);
}
const mutation = this.mutationTail.then(async () => {
const value = await operation();
if (!this.isActive()) return false;
apply(value);
return true;
});
this.mutationTail = mutation.then(
() => {},
() => {},
);
return mutation;
}
public guardLatestAction<Args extends unknown[]>(
publish: (...args: Args) => void,
): (...args: Args) => void {
const generation = ++this.actionGeneration;
return this.owner.guard((...args) => {
if (generation === this.actionGeneration) publish(...args);
});
}
public startRetry(
attempt: () => Promise<boolean>,
delayMilliseconds: number,
): void {
if (!this.isActive() || this.retryEnabled) return;
this.retryAttempt = attempt;
this.retryDelayMilliseconds = delayMilliseconds;
this.retryEnabled = true;
this.retryGeneration += 1;
this.scheduleNextRetry();
}
public stopRetry(): void {
this.retryEnabled = false;
this.retryGeneration += 1;
this.clearScheduledRetry();
}
public dispose(): Promise<void> {
this.releaseWindowOwnership();
this.stopRetry();
this.mutationAdmissionOpen = false;
return Promise.all([this.mutationTail, this.owner.dispose()]).then(() => {});
}
private disposeForWindowClose(): Promise<void> {
this.releaseWindowOwnership();
this.stopRetry();
this.mutationAdmissionOpen = false;
return Promise.all([
this.mutationTail,
this.owner.disposeForWindowClose(),
]).then(() => {});
}
private scheduleNextRetry(): void {
if (
!this.isActive()
|| !this.retryEnabled
|| this.retryRunning
|| this.retryCleanup !== undefined
) {
return;
}
const generation = this.retryGeneration;
const token = {};
const cleanup = this.scheduleRetry(
async () => {
await this.owner.applyIfActive(
() => this.runRetry(generation, token),
() => {},
);
},
this.retryDelayMilliseconds,
);
if (
!this.isActive()
|| !this.retryEnabled
|| generation !== this.retryGeneration
) {
void this.owner.release(cleanup);
return;
}
this.retryToken = token;
this.retryCleanup = cleanup;
}
private async runRetry(generation: number, token: object): Promise<void> {
if (token !== this.retryToken) return;
this.retryToken = undefined;
this.retryCleanup = undefined;
if (
!this.isActive()
|| !this.retryEnabled
|| generation !== this.retryGeneration
|| this.retryRunning
) {
return;
}
const attempt = this.retryAttempt;
if (attempt === undefined) return;
this.retryRunning = true;
try {
const ready = await attempt();
if (
ready
&& this.isActive()
&& this.retryEnabled
&& generation === this.retryGeneration
) {
this.stopRetry();
}
} catch (error) {
if (
this.isActive()
&& this.retryEnabled
&& generation === this.retryGeneration
) {
try {
this.reportRetryError(error);
} catch {
// Retry error reporting must not detach the owned loop.
}
}
} finally {
this.retryRunning = false;
this.scheduleNextRetry();
}
}
private clearScheduledRetry(): void {
this.retryToken = undefined;
const cleanup = this.retryCleanup;
this.retryCleanup = undefined;
if (cleanup === undefined) return;
void this.owner.release(cleanup);
}
}
@@ -0,0 +1,127 @@
import { type AsyncCleanup } from './asyncOwnership';
export interface FrontendCloseRequest {
preventDefault: () => void;
}
export interface FrontendBootstrapOptions {
registerCloseRequested: (
handler: (event: FrontendCloseRequest) => void | Promise<void>,
) => Promise<AsyncCleanup>;
disposeOwners: () => Promise<void>;
drainPersistence: () => Promise<void>;
destroyWindow: () => Promise<void>;
render: () => void;
reportFailure: (message: string, error: unknown) => void;
}
const report = (
options: FrontendBootstrapOptions,
message: string,
error: unknown,
): void => {
try {
options.reportFailure(message, error);
} catch {
// Reporting must not turn a handled bootstrap failure into a rejection.
}
};
const runHandled = async (
options: FrontendBootstrapOptions,
message: string,
operation: () => void | Promise<void>,
): Promise<void> => {
try {
await operation();
} catch (error) {
report(options, message, error);
}
};
/**
* Installs the webview's close boundary before mounting any React hooks.
* Registration failure closes the empty realm instead of running without an
* ownership boundary.
*/
export const bootstrapFrontend = async (
options: FrontendBootstrapOptions,
): Promise<boolean> => {
let closing: Promise<void> | undefined;
let publishCloseCleanup!: (cleanup: AsyncCleanup) => void;
const closeCleanupReady = new Promise<AsyncCleanup>(resolve => {
publishCloseCleanup = resolve;
});
const finalizeWindow = (): Promise<void> => {
if (closing !== undefined) return closing;
// Calling every operation closes its admission or starts its cleanup
// synchronously, before any asynchronous drain is awaited.
const ownerDrain = runHandled(
options,
'Failed to dispose frontend window owners:',
options.disposeOwners,
);
const persistenceDrain = runHandled(
options,
'Failed to drain frontend persistence:',
options.drainPersistence,
);
const listenerDrain = runHandled(
options,
'Failed to unregister the window close listener:',
async () => {
const cleanup = await closeCleanupReady;
await cleanup();
},
);
closing = Promise.all([
ownerDrain,
persistenceDrain,
listenerDrain,
])
.then(() => runHandled(
options,
'Failed to destroy the frontend window:',
options.destroyWindow,
));
return closing;
};
try {
const cleanupCloseListener = await options.registerCloseRequested(event => {
event.preventDefault();
return finalizeWindow();
});
publishCloseCleanup(cleanupCloseListener);
} catch (error) {
publishCloseCleanup(() => {});
report(options, 'Failed to install the frontend close-drain boundary:', error);
// Nothing has rendered, but close both admissions in case a module-level
// owner was registered while loading the bundle, then explicitly await
// destruction of the empty webview.
await finalizeWindow();
return false;
}
if (closing !== undefined) {
// A close request may arrive after the native listener is callable but
// before its registration promise acknowledges ownership. The active
// finalizer now owns the late cleanup; never mount React into a realm
// that has already begun closing.
await closing;
return false;
}
try {
options.render();
} catch (error) {
report(options, 'Failed to render the frontend:', error);
await finalizeWindow();
return false;
}
return true;
};
@@ -0,0 +1,95 @@
export type PersistenceShutdown = () => Promise<void>;
export type PersistenceEffectStart = () => void | (() => void);
/**
* Owns persistence queues for one webview. Closing admission is synchronous
* and permanent; draining waits for every queue admitted before close without
* pulling unrelated network operations into window shutdown.
*/
export class PersistenceShutdownScope {
private accepting = true;
private readonly shutdownTasks = new Set<PersistenceShutdown>();
private readonly pending = new Set<Promise<void>>();
public constructor(
private readonly reportFailure: (error: unknown) => void = () => {},
) {}
public register(shutdown: PersistenceShutdown): (() => void) | undefined {
if (!this.accepting) return undefined;
this.shutdownTasks.add(shutdown);
return () => this.shutdownTasks.delete(shutdown);
}
public async closeAndDrain(): Promise<void> {
if (this.accepting) {
this.accepting = false;
const tasks = [...this.shutdownTasks];
this.shutdownTasks.clear();
for (const shutdown of tasks) this.start(shutdown);
}
while (this.pending.size > 0) {
await Promise.allSettled([...this.pending]);
}
}
private start(shutdown: PersistenceShutdown): void {
let pending: Promise<void>;
try {
pending = shutdown();
} catch (error) {
this.report(error);
return;
}
this.pending.add(pending);
void pending.then(
() => this.pending.delete(pending),
error => {
this.pending.delete(pending);
this.report(error);
},
);
}
private report(error: unknown): void {
try {
this.reportFailure(error);
} catch {
// Reporting cannot be allowed to escape the shutdown scope.
}
}
}
/**
* Atomically admits a passive effect before it starts persistence work. Once
* window shutdown closes the scope, later effects receive no admission and
* cannot reopen hydration or write queues.
*/
export const startAdmittedPersistenceEffect = (
scope: PersistenceShutdownScope,
shutdown: PersistenceShutdown,
start: PersistenceEffectStart,
): (() => void) => {
const unregister = scope.register(shutdown);
if (unregister === undefined) return () => {};
let cleanup: void | (() => void);
try {
cleanup = start();
} catch (error) {
unregister();
throw error;
}
return () => {
cleanup?.();
unregister();
};
};
export const windowPersistenceScope = new PersistenceShutdownScope(error => {
console.error('Failed to drain frontend persistence:', error);
});
@@ -0,0 +1,54 @@
export interface GameDirectoryUpdatePorts {
updateBackend: (requestedPath: string) => Promise<unknown>;
persist: (acceptedPath: string) => Promise<void>;
reportPersistenceError: (error: unknown) => void;
}
export interface GameDirectoryHydrationPorts {
loadSavedPath: () => Promise<string | null | undefined>;
acceptSavedPath: (savedPath: string) => Promise<void>;
reportLoadError: (error: unknown) => void;
}
/**
* Settles restoration before the caller marks game-directory state ready.
* Missing state and load/acceptance failures are completed outcomes: the UI
* may then show the chooser, but never while a saved path is still being
* accepted by the backend.
*/
export const hydrateGameDirectory = async (
ports: GameDirectoryHydrationPorts,
): Promise<void> => {
try {
const savedPath = await ports.loadSavedPath();
if (savedPath?.trim()) await ports.acceptSavedPath(savedPath);
} catch (error) {
ports.reportLoadError(error);
}
};
/**
* Asks the backend to accept a game directory and persists only the canonical
* path returned by that successful request.
*
* Backend acknowledgement is the commit point. Persistence is best-effort: a
* store failure must not make the UI retain a path that no longer matches the
* already-updated backend.
*/
export const acceptGameDirectory = async (
requestedPath: string,
ports: GameDirectoryUpdatePorts,
): Promise<string> => {
const acceptedPath = await ports.updateBackend(requestedPath);
if (typeof acceptedPath !== 'string' || !acceptedPath.trim()) {
throw new Error('update_game_directory returned an invalid accepted path');
}
try {
await ports.persist(acceptedPath);
} catch (error) {
ports.reportPersistenceError(error);
}
return acceptedPath;
};
@@ -1,178 +1,315 @@
import {
ActiveOperation,
ActiveOperationKind,
DerivedState,
Game,
GameFilter,
GameSort,
GamesListPayload,
InstallStatus,
} from './types';
ActiveOperation,
ActiveOperationKind,
DerivedState,
DownloadProgressPayload,
Game,
GameFilter,
GameSort,
GameTransferStatus,
GameTransferStatusSnapshot,
InstallStatus,
StatusLevel,
} from "./types";
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
InstallStatus.CheckingPeers,
InstallStatus.Downloading,
InstallStatus.Installing,
InstallStatus.Uninstalling,
InstallStatus.Removing,
InstallStatus.Downloading,
InstallStatus.Installing,
InstallStatus.Uninstalling,
InstallStatus.Removing,
]);
export const isInProgress = (status: InstallStatus): boolean =>
IN_PROGRESS_INSTALL_STATUSES.has(status);
IN_PROGRESS_INSTALL_STATUSES.has(status);
export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => {
switch (op) {
case ActiveOperationKind.Downloading:
return InstallStatus.Downloading;
case ActiveOperationKind.Installing:
case ActiveOperationKind.Updating:
return InstallStatus.Installing;
case ActiveOperationKind.Uninstalling:
return InstallStatus.Uninstalling;
case ActiveOperationKind.RemovingDownload:
return InstallStatus.Removing;
}
export const installStatusFromActiveOperation = (
op: ActiveOperationKind,
): InstallStatus => {
switch (op) {
case ActiveOperationKind.Downloading:
return InstallStatus.Downloading;
case ActiveOperationKind.Installing:
case ActiveOperationKind.Updating:
return InstallStatus.Installing;
case ActiveOperationKind.Uninstalling:
return InstallStatus.Uninstalling;
case ActiveOperationKind.RemovingDownload:
return InstallStatus.Removing;
}
};
export const activeStatusById = (ops: ActiveOperation[] = []): Map<string, InstallStatus> =>
new Map(ops.map(op => [op.id, installStatusFromActiveOperation(op.operation)]));
export const activeStatusById = (
ops: ActiveOperation[] = [],
): Map<string, InstallStatus> =>
new Map(
ops.map((op) => [op.id, installStatusFromActiveOperation(op.operation)]),
);
export const normalizeGamesListPayload = (
payload: GamesListPayload | Game[],
): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload;
export const INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT: GameTransferStatusSnapshot =
{
revision: 0,
statuses: {},
openAttempts: {},
};
/** Keeps listener events and GamesList snapshots monotonic across async races. */
export const newestGameTransferStatusSnapshot = (
current: GameTransferStatusSnapshot,
candidate: GameTransferStatusSnapshot,
): GameTransferStatusSnapshot =>
candidate.revision > current.revision ? candidate : current;
export interface GameTransferStatusPresentation {
message: string;
level: StatusLevel;
}
export const gameTransferStatusPresentation = (
status: GameTransferStatus | undefined,
): GameTransferStatusPresentation | undefined => {
switch (status) {
case GameTransferStatus.Verifying:
return {
message: "Verifying downloaded chunks",
level: "info",
};
case GameTransferStatus.Retrying:
return {
message: "A source sent invalid data; retrying another nearby peer",
level: "warning",
};
case GameTransferStatus.Exhausted:
return {
message: "No nearby peer could provide the verified catalog version",
level: "error",
};
case undefined:
return undefined;
}
};
/** Transient verification/retry activity replaces ordinary download copy. */
export const downloadProgressTransferLabel = (
game: Game,
): string | undefined => {
if (game.transfer_status === GameTransferStatus.Exhausted) return undefined;
return gameTransferStatusPresentation(game.transfer_status)?.message;
};
export const downloadProgressAriaLabel = (game: Game): string => {
const transferLabel = downloadProgressTransferLabel(game);
return transferLabel
? `${transferLabel}: ${game.name}`
: `Downloading ${game.name}`;
};
export const gameTransferStatusFor = (
snapshot: GameTransferStatusSnapshot,
gameId: string,
): GameTransferStatus | undefined =>
Object.prototype.hasOwnProperty.call(snapshot.statuses, gameId)
? snapshot.statuses[gameId]
: undefined;
export const gameTransferOpenAttemptFor = (
snapshot: GameTransferStatusSnapshot,
gameId: string,
): string | undefined =>
Object.prototype.hasOwnProperty.call(snapshot.openAttempts, gameId)
? snapshot.openAttempts[gameId]
: undefined;
export const applyDownloadProgress = (
games: Game[],
snapshot: GameTransferStatusSnapshot,
payload: DownloadProgressPayload,
): Game[] => {
const openAttempt = gameTransferOpenAttemptFor(snapshot, payload.id);
if (openAttempt === undefined || openAttempt !== payload.attemptId) {
return games;
}
const { id, ...downloadProgress } = payload;
return games.map((game) =>
game.id === id ? { ...game, download_progress: downloadProgress } : game
);
};
export const applyGameTransferStatusSnapshot = (
games: Game[],
snapshot: GameTransferStatusSnapshot,
): Game[] =>
games.map((game) => {
const openAttempt = gameTransferOpenAttemptFor(snapshot, game.id);
return {
...game,
transfer_status: gameTransferStatusFor(snapshot, game.id),
download_progress: openAttempt !== undefined &&
game.download_progress?.attemptId === openAttempt
? game.download_progress
: undefined,
};
});
/**
* Reconcile a freshly received backend snapshot. Core operation status is
* derived only from the backend active-operation snapshot plus installed state.
*/
export const mergeGameUpdate = (
incoming: Game,
previous?: Game,
activeStatus?: InstallStatus,
incoming: Game,
previous?: Game,
activeStatus?: InstallStatus,
transferStatus?: GameTransferStatus,
): Game => {
const installStatus = activeStatus
?? (incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled);
const installStatus = activeStatus ??
(incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled);
const localStateChanged = previous !== undefined
&& (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded);
const statusChanged = previous !== undefined
&& previous.install_status !== installStatus;
const clearStatus = localStateChanged
|| (statusChanged && (activeStatus !== undefined || isInProgress(previous.install_status)));
const localStateChanged = previous !== undefined &&
(previous.installed !== incoming.installed ||
previous.downloaded !== incoming.downloaded);
const statusChanged = previous !== undefined &&
previous.install_status !== installStatus;
const clearStatus = localStateChanged ||
(statusChanged &&
(activeStatus !== undefined || isInProgress(previous.install_status)));
return {
...incoming,
availability: incoming.availability,
install_status: installStatus,
status_message: clearStatus ? undefined : previous?.status_message,
status_level: clearStatus ? undefined : previous?.status_level,
download_progress: installStatus === InstallStatus.Downloading
? previous?.download_progress
: undefined,
peer_count: incoming.peer_count ?? 0,
};
return {
...incoming,
availability: incoming.availability,
install_status: installStatus,
status_message: clearStatus ? undefined : previous?.status_message,
status_level: clearStatus ? undefined : previous?.status_level,
transfer_status: transferStatus,
download_progress: installStatus === InstallStatus.Downloading
? previous?.download_progress
: undefined,
peer_count: incoming.peer_count ?? 0,
};
};
/** Visual card state — used for state chip color and action button styling. */
export const deriveState = (game: Game): DerivedState => {
if (game.install_status === InstallStatus.Downloading) return 'downloading';
if (isInProgress(game.install_status)) return 'busy';
if (game.installed) return 'installed';
if (game.downloaded) return 'local';
return 'none';
if (game.install_status === InstallStatus.Downloading) return "downloading";
if (isInProgress(game.install_status)) return "busy";
if (game.installed) return "installed";
if (game.downloaded) return "local";
return "none";
};
export const isInstalledNotShareable = (game: Game): boolean =>
game.installed && !game.downloaded;
game.installed && !game.downloaded;
export const stateChipLabel = (game: Game): string => {
const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable';
switch (state) {
case 'installed': return 'Installed';
case 'local': return 'Local';
case 'downloading': return 'Downloading';
case 'busy': return 'Working';
case 'none': return '';
}
const state = deriveState(game);
if (state === "installed" && isInstalledNotShareable(game)) {
return "Not shareable";
}
switch (state) {
case "installed":
return "Installed";
case "local":
return "Local";
case "downloading":
return "Downloading";
case "busy":
return "Working";
case "none":
return "";
}
};
export const gameStatusLabel = (game: Game): string => {
const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) {
return 'Installed, not shareable';
}
switch (state) {
case 'installed': return 'Installed';
case 'local': return 'Downloaded';
case 'downloading': return 'Downloading';
case 'busy': return 'Working…';
case 'none': return 'Not downloaded';
}
const state = deriveState(game);
if (state === "installed" && isInstalledNotShareable(game)) {
return "Installed, not shareable";
}
switch (state) {
case "installed":
return "Installed";
case "local":
return "Downloaded";
case "downloading":
return "Downloading";
case "busy":
return "Working…";
case "none":
return "Not downloaded";
}
};
export const isUnavailable = (game: Game): boolean =>
!game.installed
&& !game.downloaded
&& game.peer_count === 0
&& game.install_status === InstallStatus.NotInstalled;
!game.installed &&
!game.downloaded &&
game.peer_count === 0 &&
game.install_status === InstallStatus.NotInstalled;
const parseVersionStamp = (version: string | undefined): number | null => {
if (!version || !/^\d{8}$/.test(version)) return null;
const parsed = parseInt(version, 10);
return Number.isNaN(parsed) ? null : parsed;
if (!version || !/^\d{8}$/.test(version)) return null;
const parsed = parseInt(version, 10);
return Number.isNaN(parsed) ? null : parsed;
};
export const compareVersionStamps = (
left: string | undefined,
right: string | undefined,
left: string | undefined,
right: string | undefined,
): number | null => {
const parsedLeft = parseVersionStamp(left);
const parsedRight = parseVersionStamp(right);
if (parsedLeft === null || parsedRight === null) return null;
return parsedLeft - parsedRight;
const parsedLeft = parseVersionStamp(left);
const parsedRight = parseVersionStamp(right);
if (parsedLeft === null || parsedRight === null) return null;
return parsedLeft - parsedRight;
};
export const hasNewerLocalVersion = (game: Game): boolean =>
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
export const needsUpdate = (game: Game): boolean => {
if (!game.installed) return false;
if (game.peer_count <= 0) return false;
if (!game.local_version && game.eti_game_version) return true;
return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0;
if (!game.installed) return false;
if (game.peer_count <= 0) return false;
if (!game.local_version && game.eti_game_version) return true;
return (compareVersionStamps(game.eti_game_version, game.local_version) ??
0) > 0;
};
export const canStreamInstall = (game: Game): boolean =>
!game.downloaded
&& !game.installed
&& game.peer_count > 0
&& !isInProgress(game.install_status);
export const canStreamInstall = (
game: Game,
catalogSupported: boolean,
): boolean =>
catalogSupported &&
!game.downloaded &&
!game.installed &&
game.peer_count > 0 &&
!isInProgress(game.install_status);
/** What pressing the card's main action button should do, given the state. */
export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled';
export type PrimaryAction =
| "play"
| "install"
| "update"
| "download"
| "busy"
| "disabled";
export const primaryActionFor = (game: Game): PrimaryAction => {
if (isInProgress(game.install_status)) return 'busy';
if (isUnavailable(game)) return 'disabled';
if (!game.installed) return game.downloaded ? 'install' : 'download';
if (needsUpdate(game)) return 'update';
return 'play';
if (isInProgress(game.install_status)) return "busy";
if (isUnavailable(game)) return "disabled";
if (!game.installed) return game.downloaded ? "install" : "download";
if (needsUpdate(game)) return "update";
return "play";
};
export const formatBytesPerSecond = (bytesPerSecond: number): string => {
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let value = Math.max(0, bytesPerSecond);
let unitIndex = 0;
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
let value = Math.max(0, bytesPerSecond);
let unitIndex = 0;
while (value >= 1000 && unitIndex < units.length - 1) {
value /= 1000;
unitIndex += 1;
}
while (value >= 1000 && unitIndex < units.length - 1) {
value /= 1000;
unitIndex += 1;
}
if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`;
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(precision)} ${units[unitIndex]}`;
if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`;
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(precision)} ${units[unitIndex]}`;
};
const MB = 1024 * 1024;
@@ -180,137 +317,145 @@ const GB = 1024 * 1024 * 1024;
const DECIMAL_MB = 1_000_000;
const stripTrailingDecimalZeros = (value: string): string =>
value.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
value.replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
export const downloadProgressPercent = (game: Game): number => {
const progress = game.download_progress;
if (!progress || progress.total_bytes <= 0) return 0;
const progress = game.download_progress;
if (!progress || progress.total_bytes <= 0) return 0;
return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100));
return Math.max(
0,
Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100),
);
};
export const formatDownloadSpeed = (bytesPerSecond: number): string => {
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`;
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`;
};
export const formatDownloadSpeedShort = (bytesPerSecond: number): string => {
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
return `${Math.round(mb)} MB/s`;
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
return `${Math.round(mb)} MB/s`;
};
export const formatDownloadBytes = (bytes: number): string => {
const safeBytes = Math.max(0, bytes);
if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`;
const safeBytes = Math.max(0, bytes);
if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`;
const gb = safeBytes / GB;
return `${stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))} GB`;
const gb = safeBytes / GB;
return `${
stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))
} GB`;
};
export const formatDownloadEta = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds <= 0) return '—';
if (seconds < 60) return `${Math.round(seconds)} s`;
if (!Number.isFinite(seconds) || seconds <= 0) return "—";
if (seconds < 60) return `${Math.round(seconds)} s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} min`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} min`;
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
};
export const inProgressLabel = (game: Game): string | undefined => {
switch (game.install_status) {
case InstallStatus.CheckingPeers:
return 'Checking peers…';
case InstallStatus.Downloading:
return game.download_progress
? `Downloading… ${formatBytesPerSecond(game.download_progress.bytes_per_second)}`
: 'Downloading…';
case InstallStatus.Installing:
return 'Installing…';
case InstallStatus.Uninstalling:
return 'Uninstalling…';
case InstallStatus.Removing:
return 'Removing…';
default:
return undefined;
}
switch (game.install_status) {
case InstallStatus.Downloading:
return game.download_progress
? `Downloading… ${
formatBytesPerSecond(game.download_progress.bytes_per_second)
}`
: "Downloading…";
case InstallStatus.Installing:
return "Installing…";
case InstallStatus.Uninstalling:
return "Uninstalling…";
case InstallStatus.Removing:
return "Removing…";
default:
return undefined;
}
};
export const actionLabel = (game: Game): string => {
const busy = inProgressLabel(game);
if (busy) return busy;
if (isUnavailable(game)) return 'Unavailable';
if (!game.installed) return game.downloaded ? 'Install' : 'Download';
if (needsUpdate(game)) return 'Update';
return 'Play';
const busy = inProgressLabel(game);
if (busy) return busy;
if (isUnavailable(game)) return "Unavailable";
if (!game.installed) return game.downloaded ? "Install" : "Download";
if (needsUpdate(game)) return "Update";
return "Play";
};
/** Counts shown on filter pills. */
export interface FilterCounts {
all: number;
local: number;
installed: number;
all: number;
local: number;
installed: number;
}
const isDownloading = (game: Game): boolean =>
game.install_status === InstallStatus.Downloading;
game.install_status === InstallStatus.Downloading;
const isNetworkGame = (game: Game): boolean =>
game.installed || game.downloaded || isDownloading(game) || game.peer_count > 0;
game.installed || game.downloaded || isDownloading(game) ||
game.peer_count > 0 || game.transfer_status === GameTransferStatus.Exhausted;
export const countByFilter = (games: Game[]): FilterCounts => ({
all: games.filter(isNetworkGame).length,
local: games.filter(g => g.installed || g.downloaded || isDownloading(g)).length,
installed: games.filter(g => g.installed).length,
all: games.filter(isNetworkGame).length,
local:
games.filter((g) => g.installed || g.downloaded || isDownloading(g)).length,
installed: games.filter((g) => g.installed).length,
});
const matchesFilter = (game: Game, filter: GameFilter): boolean => {
switch (filter) {
case 'local':
return game.installed || game.downloaded || isDownloading(game);
case 'installed':
return game.installed;
case 'all':
return isNetworkGame(game);
}
switch (filter) {
case "local":
return game.installed || game.downloaded || isDownloading(game);
case "installed":
return game.installed;
case "all":
return isNetworkGame(game);
}
};
const STATE_SORT_ORDER: Record<DerivedState, number> = {
installed: 0,
local: 1,
downloading: 2,
busy: 3,
none: 4,
installed: 0,
local: 1,
downloading: 2,
busy: 3,
none: 4,
};
const compareByState = (a: Game, b: Game): number => {
const diff = STATE_SORT_ORDER[deriveState(a)] - STATE_SORT_ORDER[deriveState(b)];
return diff !== 0 ? diff : a.name.localeCompare(b.name);
const diff = STATE_SORT_ORDER[deriveState(a)] -
STATE_SORT_ORDER[deriveState(b)];
return diff !== 0 ? diff : a.name.localeCompare(b.name);
};
export const applyFilterAndSort = (
games: Game[],
filter: GameFilter,
sort: GameSort,
query: string,
games: Game[],
filter: GameFilter,
sort: GameSort,
query: string,
): Game[] => {
let list = games.filter(g => matchesFilter(g, filter));
const q = query.trim().toLowerCase();
if (q) {
list = list.filter(g =>
g.name.toLowerCase().includes(q)
|| (g.genre?.toLowerCase().includes(q) ?? false)
|| (g.publisher?.toLowerCase().includes(q) ?? false),
);
}
switch (sort) {
case 'az':
return [...list].sort((a, b) => a.name.localeCompare(b.name));
case 'sizeDesc':
return [...list].sort((a, b) => b.size - a.size);
case 'sizeAsc':
return [...list].sort((a, b) => a.size - b.size);
case 'status':
return [...list].sort(compareByState);
}
let list = games.filter((g) => matchesFilter(g, filter));
const q = query.trim().toLowerCase();
if (q) {
list = list.filter((g) =>
g.name.toLowerCase().includes(q) ||
(g.genre?.toLowerCase().includes(q) ?? false) ||
(g.publisher?.toLowerCase().includes(q) ?? false)
);
}
switch (sort) {
case "az":
return [...list].sort((a, b) => a.name.localeCompare(b.name));
case "sizeDesc":
return [...list].sort((a, b) => b.size - a.size);
case "sizeAsc":
return [...list].sort((a, b) => a.size - b.size);
case "status":
return [...list].sort(compareByState);
}
};
@@ -0,0 +1,22 @@
export type IdentityDiagnostic = 'ephemeral';
export interface IdentityDiagnosticSnapshot {
revision: number;
diagnostic: IdentityDiagnostic | null;
}
export const INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT: IdentityDiagnosticSnapshot = {
revision: 0,
diagnostic: null,
};
export const EPHEMERAL_IDENTITY_NOTICE =
"This installation's network identity could not be saved and will change the next time Lanspread starts.";
export const IDENTITY_STATUS_UNAVAILABLE_NOTICE =
"This installation's network identity status could not be checked.";
export const newestIdentityDiagnosticSnapshot = (
current: IdentityDiagnosticSnapshot,
candidate: IdentityDiagnosticSnapshot,
): IdentityDiagnosticSnapshot => candidate.revision > current.revision ? candidate : current;
@@ -0,0 +1,151 @@
import {
AsyncOwner,
type AsyncAdoptionScope,
type AsyncRegistration,
windowAsyncScope,
} from './asyncOwnership';
export type LocalNetworkSharingPhase =
| 'waitingForGameDirectory'
| 'disabled'
| 'enabling'
| 'enabled'
| 'disabling';
export type SharingPersistenceProblem = 'load' | 'save';
export interface LocalNetworkSharingSnapshot {
revision: number;
enabled: boolean;
pendingTarget: boolean | null;
phase: LocalNetworkSharingPhase;
persistenceProblem: SharingPersistenceProblem | null;
}
export const INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT: LocalNetworkSharingSnapshot = {
revision: 0,
enabled: false,
pendingTarget: null,
phase: 'disabled',
persistenceProblem: 'load',
};
export const newestLocalNetworkSharingSnapshot = (
current: LocalNetworkSharingSnapshot,
candidate: LocalNetworkSharingSnapshot,
): LocalNetworkSharingSnapshot => candidate.revision > current.revision ? candidate : current;
export const displayedSharingTarget = (snapshot: LocalNetworkSharingSnapshot): boolean =>
snapshot.pendingTarget ?? snapshot.enabled;
export const isLocalNetworkSharingActive = (snapshot: LocalNetworkSharingSnapshot): boolean =>
snapshot.phase === 'enabled' && snapshot.pendingTarget !== false;
export const localNetworkSharingNotice = (
snapshot: LocalNetworkSharingSnapshot,
): string => {
if (snapshot.persistenceProblem === 'load') {
return 'Local network sharing is off because its setting could not be loaded.';
}
if (snapshot.persistenceProblem === 'save') {
if (!snapshot.enabled && snapshot.phase === 'disabled') {
return 'Local network sharing is off, but this setting could not be saved.';
}
return 'This Local network sharing setting could not be saved and may change after restart.';
}
if (snapshot.pendingTarget === false && snapshot.phase !== 'disabled') {
return 'Stopping Local network sharing…';
}
if (snapshot.pendingTarget === true && snapshot.phase !== 'enabled') {
return 'Starting Local network sharing…';
}
switch (snapshot.phase) {
case 'waitingForGameDirectory':
return 'Local network sharing will start after you choose a game folder.';
case 'disabled':
return 'Local network sharing is off. Nearby devices cannot browse or request games from this library.';
case 'enabling':
return 'Starting Local network sharing…';
case 'enabled':
return 'Local network sharing is on.';
case 'disabling':
return 'Stopping Local network sharing…';
}
};
/**
* Owns one listener/query lifecycle and serializes explicitly targeted sharing
* mutations. Disposal closes admission and drains already-admitted invokes;
* result publication remains guarded by the mounted owner.
*/
export class LocalNetworkSharingAsyncScope {
private readonly owner: AsyncOwner;
private mutationTail: Promise<void> = Promise.resolve();
private mutationAdmissionOpen = true;
private readonly releaseWindowOwnership: () => void;
public constructor(
reportCleanupFailure: (error: unknown) => void = () => {},
adoptionScope: AsyncAdoptionScope = windowAsyncScope,
) {
this.owner = new AsyncOwner(reportCleanupFailure, adoptionScope);
this.releaseWindowOwnership = adoptionScope.registerDisposer(
() => this.disposeForWindowClose(),
) ?? (() => {});
}
public isActive(): boolean {
return this.owner.isActive() && this.mutationAdmissionOpen;
}
public guard<Args extends unknown[]>(
callback: (...args: Args) => void,
): (...args: Args) => void {
return this.owner.guard(callback);
}
public registerListener(registration: AsyncRegistration): Promise<boolean> {
return this.owner.register(registration);
}
public applyIfActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
): Promise<boolean> {
return this.owner.applyIfActive(operation, apply);
}
public applyMutationIfActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
): Promise<boolean> {
if (!this.isActive()) return Promise.resolve(false);
const mutation = this.mutationTail.then(async () => {
const value = await operation();
if (!this.owner.isActive()) return false;
apply(value);
return true;
});
this.mutationTail = mutation.then(
() => {},
() => {},
);
return mutation;
}
public dispose(): Promise<void> {
this.releaseWindowOwnership();
this.mutationAdmissionOpen = false;
return Promise.all([this.mutationTail, this.owner.dispose()]).then(() => {});
}
private disposeForWindowClose(): Promise<void> {
this.releaseWindowOwnership();
this.mutationAdmissionOpen = false;
return Promise.all([
this.mutationTail,
this.owner.disposeForWindowClose(),
]).then(() => {});
}
}
@@ -0,0 +1,18 @@
import { type ProtocolMismatch } from "./types";
export interface ProtocolMismatchSnapshot {
revision: number;
mismatch: ProtocolMismatch | null;
}
export const INITIAL_PROTOCOL_MISMATCH_SNAPSHOT: ProtocolMismatchSnapshot = {
revision: 0,
mismatch: null,
};
/** Keeps listener events and bootstrap queries monotonic across async races. */
export const newestProtocolMismatchSnapshot = (
current: ProtocolMismatchSnapshot,
candidate: ProtocolMismatchSnapshot,
): ProtocolMismatchSnapshot =>
candidate.revision > current.revision ? candidate : current;
@@ -0,0 +1,78 @@
import { type AsyncAdoptionScope } from './asyncOwnership';
export type ThumbnailLoader = (id: string) => Promise<string>;
export type ThumbnailPublisher = (id: string, url: string) => void;
export const createThumbnailRequestKey = (ids: readonly string[]): string =>
JSON.stringify([...new Set(ids)].sort());
export const thumbnailIdsFromRequestKey = (key: string): string[] =>
JSON.parse(key) as string[];
/**
* Owns one effect generation of thumbnail requests. Loading can only begin via
* `start`, and disposing the generation prevents both later starts and late
* publications from requests that are already in flight.
*/
export class ThumbnailRequestGeneration {
private disposed = false;
private readonly pending = new Set<Promise<void>>();
constructor(
private readonly load: ThumbnailLoader,
private readonly publish: ThumbnailPublisher,
) {}
start(ids: readonly string[]): Promise<void> {
if (this.disposed) return Promise.resolve();
const loading = Promise.all(ids.map(id => this.loadOne(id))).then(() => {});
this.pending.add(loading);
void loading.then(
() => this.pending.delete(loading),
() => this.pending.delete(loading),
);
return loading;
}
dispose(): Promise<void> {
this.disposed = true;
return this.drain();
}
private async drain(): Promise<void> {
while (this.pending.size > 0) {
await Promise.allSettled([...this.pending]);
}
}
private async loadOne(id: string): Promise<void> {
let url: string;
try {
url = await this.load(id);
} catch {
url = '';
}
if (!this.disposed) this.publish(id, url);
}
}
/** Registers the generation with window-close admission before any load starts. */
export const startOwnedThumbnailGeneration = (
scope: AsyncAdoptionScope,
generation: ThumbnailRequestGeneration,
ids: readonly string[],
): (() => void) => {
const releaseWindowOwnership = scope.registerDisposer(() => generation.dispose());
if (releaseWindowOwnership === undefined) {
scope.adopt(generation.dispose());
return () => {};
}
scope.adopt(generation.start(ids));
return () => {
releaseWindowOwnership();
scope.adopt(generation.dispose());
};
};
+137 -91
View File
@@ -1,137 +1,183 @@
export enum InstallStatus {
NotInstalled = 'NotInstalled',
CheckingPeers = 'CheckingPeers',
Downloading = 'Downloading',
Installing = 'Installing',
Uninstalling = 'Uninstalling',
Removing = 'Removing',
Installed = 'Installed',
NotInstalled = "NotInstalled",
Downloading = "Downloading",
Installing = "Installing",
Uninstalling = "Uninstalling",
Removing = "Removing",
Installed = "Installed",
}
export enum GameAvailability {
Ready = 'Ready',
LocalOnly = 'LocalOnly',
Ready = "Ready",
LocalOnly = "LocalOnly",
}
export enum ActiveOperationKind {
Downloading = 'Downloading',
Installing = 'Installing',
Updating = 'Updating',
Uninstalling = 'Uninstalling',
RemovingDownload = 'RemovingDownload',
Downloading = "Downloading",
Installing = "Installing",
Updating = "Updating",
Uninstalling = "Uninstalling",
RemovingDownload = "RemovingDownload",
}
export type StatusLevel = 'info' | 'warning' | 'error';
export type StatusLevel = "info" | "warning" | "error";
export enum GameTransferStatus {
Verifying = "verifying",
Retrying = "retrying",
Exhausted = "exhausted",
}
export interface GameTransferStatusSnapshot {
revision: number;
statuses: Record<string, GameTransferStatus>;
openAttempts: Record<string, string>;
}
export interface DownloadProgress {
downloaded_bytes: number;
total_bytes: number;
bytes_per_second: number;
active_peer_count: number;
attemptId: string;
downloaded_bytes: number;
total_bytes: number;
bytes_per_second: number;
active_peer_count: number;
}
export interface DownloadProgressPayload extends DownloadProgress {
id: string;
id: string;
}
export interface Game {
id: string;
name: string;
description: string;
/** Bytes. */
size: number;
/** Raw bytes — unused in UI, kept for parity with backend payload. */
thumbnail?: Uint8Array | number[];
downloaded: boolean;
installed: boolean;
availability: GameAvailability;
install_status: InstallStatus;
eti_game_version?: string;
local_version?: string;
/** Optional richer metadata surfaced by the backend. */
release_year?: string;
publisher?: string;
max_players?: number;
version?: string;
genre?: string;
status_message?: string;
status_level?: StatusLevel;
download_progress?: DownloadProgress;
peer_count: number;
can_host_server?: boolean;
active_outbound_transfers?: number;
installed_peer_count?: number;
id: string;
name: string;
description: string;
/** Bytes. */
size: number;
/** Raw bytes — unused in UI, kept for parity with backend payload. */
thumbnail?: Uint8Array | number[];
downloaded: boolean;
installed: boolean;
availability: GameAvailability;
install_status: InstallStatus;
eti_game_version?: string;
local_version?: string;
/** Optional richer metadata surfaced by the backend. */
release_year?: string;
publisher?: string;
max_players?: number;
version?: string;
genre?: string;
status_message?: string;
status_level?: StatusLevel;
transfer_status?: GameTransferStatus;
download_progress?: DownloadProgress;
peer_count: number;
can_host_server?: boolean;
active_outbound_transfers?: number;
installed_peer_count?: number;
}
export interface ActiveOperation {
id: string;
operation: ActiveOperationKind;
id: string;
operation: ActiveOperationKind;
}
export interface GamesListPayload {
games: Game[];
active_operations?: ActiveOperation[];
games: Game[];
active_operations?: ActiveOperation[];
transfer_status: GameTransferStatusSnapshot;
}
export interface ProtocolMismatch {
observed: number | null;
expected: number;
}
/** Library filter chip — what subset of the catalog to show. */
export type GameFilter = 'all' | 'local' | 'installed';
export type GameFilter = "all" | "local" | "installed";
/** Library sort order. */
export type GameSort = 'az' | 'sizeDesc' | 'sizeAsc' | 'status';
export type GameSort = "az" | "sizeDesc" | "sizeAsc" | "status";
/** Visual state of a card. Derived from backend operation status and local flags. */
export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'busy';
export type DerivedState =
| "installed"
| "local"
| "downloading"
| "none"
| "busy";
/** Two-character language code passed through to game scripts. */
export type LauncherLanguage = 'en' | 'de';
export type LauncherLanguage = "en" | "de";
export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending';
export type CallToPlayParticipantStatus = "ready" | "in" | "pending";
export interface CallToPlayParticipant {
name: string;
status: CallToPlayParticipantStatus;
joinedAt: number;
readyAt?: number;
name: string;
status: CallToPlayParticipantStatus;
joinedAt: number;
readyAt?: number;
}
export interface CallToPlayMessage {
id: string;
fromId: string;
from: string;
text: string;
at: number;
id: string;
fromId: string;
from: string;
text: string;
at: number;
}
export interface Nomination {
id: string;
gameId: string;
creatorId: string;
creator: string;
maxPlayers: number;
createdAt: number;
scheduledFor: number | null;
deadline: number;
participants: Record<string, CallToPlayParticipant>;
messages: CallToPlayMessage[];
state: 'open' | 'done' | 'running' | 'cancelled';
terminalAt: number | null;
id: string;
gameId: string;
creatorId: string;
creator: string;
maxPlayers: number;
createdAt: number;
scheduledFor: number | null;
deadline: number;
participants: Record<string, CallToPlayParticipant>;
messages: CallToPlayMessage[];
state: "open" | "done" | "running" | "cancelled";
terminalAt: number | null;
}
export type CallToPlayAction =
| { Create: { game_id: string; max_players: number; scheduled_for: number | null; deadline: number } }
| { Respond: { ready_at: number | null } }
| 'Rsvp'
| { SendMessage: { message_id: string; text: string } }
| 'Leave'
| 'Cancel'
| 'Start'
| { AddTime: { deadline: number } };
| {
Create: {
game_id: string;
max_players: number;
scheduled_for: number | null;
deadline: number;
};
}
| { Respond: { ready_at: number | null } }
| "Rsvp"
| { SendMessage: { text: string } }
| "Leave"
| "Cancel"
| "Start"
| { AddTime: { deadline: number } };
export interface CallToPlayEvent {
id: string;
call_id: string;
actor_id: string;
actor_name: string;
at: number;
action: CallToPlayAction;
export interface CallToPlayViewEvent {
id: string;
call_id: string;
author_id: string;
author_name: string;
at: number;
action: CallToPlayAction;
}
export interface CallToPlayView {
events: CallToPlayViewEvent[];
}
export interface CallToPlayLocalIntent {
call_id: string | null;
action: CallToPlayAction;
}
export interface CallToPlayReceipt {
call_id: string;
event_id: string;
revision: number;
}