Files
openchamber/packages/ui/src/lib/desktopBoot.ts
T

310 lines
11 KiB
TypeScript
Raw Normal View History

/**
* Authoritative desktop boot outcome types and UI-facing resolver.
*
* The Rust backend computes a `DesktopBootOutcome` at startup and injects
* it as `window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__`. This module provides
* pure functions to read that outcome and derive the minimal UI state
* needed for the loading/chooser/recovery/main decision.
*/
// ── Boot outcome (must match Rust injection) ──
/**
* Structured boot outcome type.
*
* Instead of 8 magic string kinds, we use a structured type that clearly
* separates the target (local/remote/null) from the status (ok/not-configured/error).
*
* This makes it easier to add new states without updating multiple files and
* allows UI to reason about outcomes with simple status checks.
*/
2026-07-21 21:11:13 +03:00
type DesktopBootAvailability = { localAvailable?: boolean };
export type DesktopBootOutcome =
// Main screens - CLI or remote connection is working
2026-07-21 21:11:13 +03:00
| ({ target: 'local'; status: 'ok' } & DesktopBootAvailability)
| ({ target: 'remote'; status: 'ok'; hostId: string; url: string } & DesktopBootAvailability)
// First launch - user hasn't made a choice yet
2026-07-21 21:11:13 +03:00
| ({ target: null; status: 'not-configured' } & DesktopBootAvailability)
// Recovery screens - something is wrong
2026-07-21 21:11:13 +03:00
| ({ target: 'local'; status: 'unreachable' } & DesktopBootAvailability)
| ({ target: 'remote'; status: 'unreachable'; hostId: string; url: string } & DesktopBootAvailability)
| ({ target: 'remote'; status: 'incompatible'; hostId: string; url: string } & DesktopBootAvailability)
| ({ target: 'remote'; status: 'wrong-service'; hostId: string; url: string } & DesktopBootAvailability)
| ({ target: 'remote'; status: 'missing'; hostId: string } & DesktopBootAvailability);
// ── UI-facing view ──
export type DesktopBootView =
2026-07-21 21:11:13 +03:00
| ({ screen: 'main' } & DesktopBootAvailability)
| ({ screen: 'main'; hostId: string; url: string } & DesktopBootAvailability)
| ({ screen: 'chooser' } & DesktopBootAvailability)
| ({ screen: 'recovery'; variant: 'local-unavailable' } & DesktopBootAvailability)
| ({ screen: 'recovery'; variant: 'remote-unreachable'; hostId: string; url: string } & DesktopBootAvailability)
| ({ screen: 'recovery'; variant: 'remote-incompatible'; hostId: string; url: string } & DesktopBootAvailability)
| ({ screen: 'recovery'; variant: 'remote-wrong-service'; hostId: string; url: string } & DesktopBootAvailability)
| ({ screen: 'recovery'; variant: 'remote-missing'; hostId: string } & DesktopBootAvailability);
// ── Resolver inputs ──
export type DesktopBootViewInput = {
isDesktopShell: boolean;
bootOutcome: DesktopBootOutcome | null;
};
// ── Public API ──
/** Valid target values */
const VALID_TARGETS = ['local', 'remote', null] as const;
/** Valid status values */
const VALID_STATUSES = ['ok', 'not-configured', 'unreachable', 'incompatible', 'wrong-service', 'missing'] as const;
/** Return type for `validateBootOutcome`. */
type ValidationResult =
| { valid: true; outcome: DesktopBootOutcome }
| { valid: false };
/**
* Runtime-validate a raw injected payload.
* Returns a tagged result so callers can distinguish "not set yet" (null raw)
* from "set but malformed" (valid: false).
*/
function validateBootOutcome(raw: unknown): ValidationResult {
if (!raw || typeof raw !== 'object') {
return { valid: false };
}
const record = raw as Record<string, unknown>;
2026-07-21 21:11:13 +03:00
const availability = record.localAvailable === false ? { localAvailable: false } : {};
const target = record.target;
const status = record.status;
// Validate target
if (target !== null && (typeof target !== 'string' || !VALID_TARGETS.includes(target as never))) {
return { valid: false };
}
// Validate status
if (typeof status !== 'string' || !VALID_STATUSES.includes(status as never)) {
return { valid: false };
}
// Validate required fields per combination
if (target === 'remote' || target === 'local') {
if (status === 'ok' && target === 'local') {
// { target: 'local'; status: 'ok' } is valid
2026-07-21 21:11:13 +03:00
return { valid: true, outcome: { target: 'local', status: 'ok', ...availability } };
}
if (status === 'ok' && target === 'remote') {
// { target: 'remote'; status: 'ok' } requires hostId and url
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
return { valid: false };
}
2026-07-21 21:11:13 +03:00
return { valid: true, outcome: { target: 'remote', status: 'ok', hostId: record.hostId, url: record.url, ...availability } };
}
if (status === 'unreachable') {
if (target === 'local') {
// { target: 'local'; status: 'unreachable' } is valid
2026-07-21 21:11:13 +03:00
return { valid: true, outcome: { target: 'local', status: 'unreachable', ...availability } };
} else {
// { target: 'remote'; status: 'unreachable' } requires hostId and url
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
return { valid: false };
}
2026-07-21 21:11:13 +03:00
return { valid: true, outcome: { target: 'remote', status: 'unreachable', hostId: record.hostId, url: record.url, ...availability } };
}
}
if (status === 'incompatible' || status === 'wrong-service') {
if (target !== 'remote') return { valid: false };
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
return { valid: false };
}
2026-07-21 21:11:13 +03:00
return { valid: true, outcome: { target: 'remote', status, hostId: record.hostId, url: record.url, ...availability } };
}
if (status === 'missing') {
if (target !== 'remote') return { valid: false };
if (typeof record.hostId !== 'string') {
return { valid: false };
}
2026-07-21 21:11:13 +03:00
return { valid: true, outcome: { target: 'remote', status: 'missing', hostId: record.hostId, ...availability } };
}
}
if (target === null) {
if (status === 'not-configured') {
// { target: null; status: 'not-configured' } is valid (first launch)
2026-07-21 21:11:13 +03:00
return { valid: true, outcome: { target: null, status: 'not-configured', ...availability } };
}
if (status === 'missing') {
// { target: null; status: 'missing' } would be redundant with not-configured
return { valid: false };
}
}
return { valid: false };
}
/**
* Derive the minimal UI view from the injected boot outcome.
*
* Returns `null` when not in desktop shell, when the outcome is not yet
* known, or when the injected payload is malformed.
*/
export function resolveDesktopBootView(
input: DesktopBootViewInput,
): DesktopBootView | null {
if (!input.isDesktopShell) {
return null;
}
const outcome = input.bootOutcome;
if (!outcome) {
return null;
}
2026-07-21 21:11:13 +03:00
const availability = outcome.localAvailable === false ? { localAvailable: false } : {};
// Main screens - CLI or remote connection is working
if (outcome.status === 'ok') {
if (outcome.target === 'local') {
2026-07-21 21:11:13 +03:00
return { screen: 'main', ...availability };
} else if (outcome.target === 'remote') {
2026-07-21 21:11:13 +03:00
return { screen: 'main', hostId: outcome.hostId, url: outcome.url, ...availability };
}
}
// First launch - user hasn't made a choice yet
if (outcome.target === null && outcome.status === 'not-configured') {
2026-07-21 21:11:13 +03:00
return { screen: 'chooser', ...availability };
}
// Recovery screens - something is wrong
if (outcome.target === 'local' && outcome.status === 'unreachable') {
2026-07-21 21:11:13 +03:00
return { screen: 'chooser', ...availability };
}
if (outcome.target === 'remote') {
if (outcome.status === 'unreachable') {
2026-07-21 21:11:13 +03:00
return { screen: 'recovery', variant: 'remote-unreachable', hostId: outcome.hostId, url: outcome.url, ...availability };
} else if (outcome.status === 'incompatible') {
2026-07-21 21:11:13 +03:00
return { screen: 'recovery', variant: 'remote-incompatible', hostId: outcome.hostId, url: outcome.url, ...availability };
} else if (outcome.status === 'wrong-service') {
2026-07-21 21:11:13 +03:00
return { screen: 'recovery', variant: 'remote-wrong-service', hostId: outcome.hostId, url: outcome.url, ...availability };
} else if (outcome.status === 'missing') {
2026-07-21 21:11:13 +03:00
return { screen: 'recovery', variant: 'remote-missing', hostId: outcome.hostId, ...availability };
}
}
// Unknown outcome — defensive null.
return null;
}
// ── Loading gate ──
export type BootInjectionStatus =
| 'not-injected'
| 'malformed'
| 'valid';
export type InitialLoadingState = {
isDesktopShell: boolean;
isInitialized: boolean;
bootOutcomeKnown: boolean;
/**
* Whether the resolved boot view is 'main'.
* When false (chooser/recovery), splash dismisses on bootOutcomeKnown alone.
* When true or absent, splash also requires isInitialized.
*/
bootViewIsMain?: boolean;
};
export type DesktopBootFlowRestartInput = {
isDesktopShell: boolean;
isDesktopLocalOriginActive: boolean;
};
/**
* Whether the initial loading screen can be dismissed.
*
* Desktop shells must wait until a valid boot outcome is injected by the native host.
* For non-main views (chooser, recovery), the splash can dismiss as soon as
* the outcome is known — `isInitialized` is not required because OpenCode
* may not be available in those flows.
* For main views, both `isInitialized` and `bootOutcomeKnown` are required.
* Non-desktop shells only need the app to be initialized.
*/
export function canDismissInitialLoading(state: InitialLoadingState): boolean {
if (!state.isDesktopShell) {
return state.isInitialized;
}
if (!state.bootOutcomeKnown) {
return false;
}
// Non-main boot views (chooser, recovery) can dismiss without waiting for init.
if (state.bootViewIsMain === false) {
return true;
}
return state.isInitialized;
}
/**
* Boot/recovery UI can render in the desktop startup window before the local
* desktop HTTP origin is active. In that state, same-origin reloads and
* `/api/*` requests cannot recover the app, so callers must restart desktop.
*/
export function shouldRestartDesktopBootFlow(input: DesktopBootFlowRestartInput): boolean {
return input.isDesktopShell && !input.isDesktopLocalOriginActive;
}
/**
* Read the boot outcome injected by the native desktop host.
* Returns `null` when not in desktop, when the outcome has not been set yet,
* or when the injected payload is malformed.
*/
export function getInjectedBootOutcome(): DesktopBootOutcome | null {
const status = getBootInjectionStatus();
if (status !== 'valid') {
return null;
}
const raw = (window as { __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: unknown })
.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__;
const result = validateBootOutcome(raw);
return result.valid ? result.outcome : null;
}
/**
* Check the injection status of the desktop boot outcome.
*
* Distinguishes three states:
* - `'not-injected'`: the global is absent or null (keep waiting)
* - `'malformed'`: the global is present but failed validation (deterministic failure)
* - `'valid'`: the global is present and passes validation
*/
export function getBootInjectionStatus(): BootInjectionStatus {
if (typeof window === 'undefined') {
return 'not-injected';
}
const raw = (window as { __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: unknown })
.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__;
if (raw === undefined || raw === null) {
return 'not-injected';
}
const result = validateBootOutcome(raw);
return result.valid ? 'valid' : 'malformed';
}