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