feat: compose local platform login host

This commit is contained in:
2026-09-05 18:31:16 +08:00
parent 1a3308ecd0
commit e1b290ea22
2 changed files with 373 additions and 0 deletions
@@ -0,0 +1,195 @@
import { resolveLocalStartup } from '../../framework/config/local-startup.ts';
import type { ResolveRuntimeConfigOptions } from '../../framework/config/runtime-config.ts';
import { createLocalLoginSources, type LocalHostSnapshot, type LocalLoginSources } from '../../framework/adapters/local/login-sources.ts';
import type { LocalStoragePort } from '../../framework/adapters/local/local-storage.ts';
import { PlatformRuntime } from '../../framework/platform/runtime.ts';
import type { PlatformState } from '../../framework/platform/stores/platform-types.ts';
import { PlatformUiController } from '../../framework/presentation/platform-ui-controller.ts';
import type { PlatformViewPort, FrameScheduler } from '../../framework/presentation/ui-contracts.ts';
import { createDiagnosticGameEntry } from './local-login-game-entry.ts';
import { createLocalLoginWire } from './local-login-wire.ts';
import { LocalLoginTransport, type EvidenceSink } from './local-login-transport.ts';
export interface LocalLoginHostOptions {
configOptions: ResolveRuntimeConfigOptions;
storage: LocalStoragePort;
random: () => number;
now: () => number;
sampleHost: () => LocalHostSnapshot;
view: PlatformViewPort;
scheduler: FrameScheduler;
loadResources: () => Promise<void>;
waitForMinimumDisplay: () => Promise<void>;
createSocket: (url: string) => WebSocket;
record: EvidenceSink;
onBusy: (busy: boolean) => void;
onFailure: (error: unknown) => void;
}
export interface LocalLoginHost {
start(): Promise<void>;
login(): Promise<void>;
stop(): void;
getSnapshot(): { state: PlatformState | null; ready: boolean; busy: boolean };
}
class LocalLoginHostFault extends Error {
readonly errors: readonly unknown[];
constructor(errors: readonly unknown[]) {
super('Local login host failed with additional observer or cleanup errors');
this.name = 'LocalLoginHostFault';
this.errors = Object.freeze([...errors]);
}
}
function collected(errors: readonly unknown[]): unknown {
return errors.length === 1 ? errors[0] : new LocalLoginHostFault(errors);
}
/** Development composition only; Runtime remains the sole business state owner. */
export function createLocalLoginHost(options: LocalLoginHostOptions): LocalLoginHost {
let runtime: PlatformRuntime | null = null;
let controller: PlatformUiController | null = null;
let sources: LocalLoginSources | null = null;
let startup: Promise<void> | null = null;
let loginOperation: Promise<void> | null = null;
let terminal = false;
let busy = false;
let cleanupErrors: unknown[] | null = null;
let serverFailure: { data: unknown } | null = null;
function requireActive(): void {
if (terminal) throw new Error('Local login host is stopped or terminal');
}
function setBusy(value: boolean): void {
if (busy === value) return;
busy = value;
options.onBusy(value);
}
function shutdown(errors: unknown[]): void {
terminal = true;
cleanupErrors = errors;
// Disable input before releasing either owner; dispose preserves drawn output.
try { setBusy(false); } catch (error) { errors.push(error); }
try { controller?.dispose(); } catch (error) { errors.push(error); }
try { runtime?.stop(); } catch (error) { errors.push(error); }
cleanupErrors = null;
}
function fail(error: unknown): void {
if (cleanupErrors !== null) {
if (!cleanupErrors.includes(error)) cleanupErrors.push(error);
return;
}
if (terminal) return;
const errors = [error];
shutdown(errors);
options.onFailure(collected(errors));
}
function wireFault(error: unknown): void {
if (serverFailure === null) { fail(error); return; }
// The Wire scope marker follows the existing denial/kick handler. Keep the
// original server payload, and retain any independent Wire cleanup errors.
const extras = error instanceof Error && 'errors' in error
? (error as Error & { errors: readonly unknown[] }).errors.slice(1) : [];
fail(collected([serverFailure.data, ...extras]));
}
function transportFault(error: unknown): void {
fail(serverFailure === null ? error : collected([serverFailure.data, error]));
}
async function readyTask(task: () => Promise<void>): Promise<void> {
try { requireActive(); await task(); }
catch (error) { fail(error); throw error; }
}
function start(): Promise<void> {
if (startup !== null) return startup;
if (terminal) return Promise.reject(new Error('Local login host is stopped or terminal'));
startup = Promise.resolve().then(async () => {
requireActive();
const context = await resolveLocalStartup(options.configOptions);
requireActive();
options.record({ kind: 'startup-config', data: context.config });
requireActive();
const entry = createDiagnosticGameEntry(context.config.identity.gameid, options.record);
sources = createLocalLoginSources({ context, storage: options.storage, random: options.random,
now: options.now, sampleHost: options.sampleHost,
isSessionActive: () => busy || runtime?.state.app.phase === 'logged-in' });
controller = new PlatformUiController({
render: model => {
if (terminal) return;
try { options.view.render(model); }
catch (error) { fail(error); throw error; }
},
activate: page => {
if (terminal) return;
try {
options.view.activate(page);
if (!terminal && page === 'lobby') setBusy(false);
} catch (error) { fail(error); throw error; }
},
showOverlay: model => {
if (terminal) return;
if (model.kind === 'denial') serverFailure = { data: model.outcome.data };
if (model.kind === 'kicked') serverFailure = { data: model.data };
try { options.view.showOverlay(model); }
catch (error) { transportFault(error); throw error; }
if (model.kind === 'fatal') fail(model.error);
},
}, options.scheduler, fail);
runtime = new PlatformRuntime({ gameEntry: entry,
resolveRuntimeConfig: async () => context.config,
createWireClient: () => createLocalLoginWire({ context, record: options.record, onFault: wireFault,
transportFactory: () => new LocalLoginTransport({ context, createSocket: options.createSocket,
record: options.record, onFault: transportFault }) }),
scene: controller, loadResources: () => readyTask(options.loadResources),
waitForMinimumDisplay: () => readyTask(options.waitForMinimumDisplay),
getLoginDeviceSnapshot: sources.getLoginDeviceSnapshot,
});
controller.connect(runtime);
requireActive();
await runtime.start();
requireActive();
}).catch(error => { fail(error); throw error; });
return startup;
}
function login(): Promise<void> {
if (terminal) return Promise.reject(new Error('Local login host is stopped or terminal'));
if (busy && loginOperation !== null) return loginOperation;
if (runtime === null || controller === null || sources === null || !runtime.ready) {
return Promise.reject(new Error('Local login host is not ready for login'));
}
const activeSources = sources;
const activeController = controller;
loginOperation = Promise.resolve().then(async () => {
requireActive();
const account = await activeSources.prepareLogin();
requireActive();
activeController.login(account);
}).catch(error => { fail(error); throw error; });
try { setBusy(true); } catch (error) { fail(error); }
return loginOperation;
}
return {
start, login,
stop() {
if (terminal) return;
const errors: unknown[] = [];
shutdown(errors);
if (errors.length > 0) {
const error = collected(errors);
options.onFailure(error);
throw error;
}
},
getSnapshot: () => ({ state: runtime === null ? null : runtime.state,
ready: !terminal && runtime !== null && runtime.ready, busy }),
};
}