Allow Desktop to skip its in-process OpenChamber server with OPENCHAMBER_SKIP_LOCAL_SERVER=1 while continuing to load the packaged UI shell. Carry local runtime availability through the boot contract so unavailable or unconfigured remotes enter a remote-only chooser instead of offering broken local recovery actions. The chooser can select saved instances, add a server by URL, or redeem an OpenChamber pairing link over direct or E2EE relay transports. Keep additional windows, Mini Chat, background startup, and unreachable-host recovery functional without a local origin. Render boot and recovery surfaces with the active theme background rather than exposing the native vibrancy backing. Document the environment variable and cover serverless boot routing plus malformed pairing imports with focused tests.
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
type DesktopBootAvailability = { localAvailable?: boolean };
|
|
|
|
export type DesktopBootOutcome =
|
|
// Main screens - CLI or remote connection is working
|
|
| ({ target: 'local'; status: 'ok' } & DesktopBootAvailability)
|
|
| ({ target: 'remote'; status: 'ok'; hostId: string; url: string } & DesktopBootAvailability)
|
|
|
|
// First launch - user hasn't made a choice yet
|
|
| ({ target: null; status: 'not-configured' } & DesktopBootAvailability)
|
|
|
|
// Recovery screens - something is wrong
|
|
| ({ 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 =
|
|
| ({ 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>;
|
|
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
|
|
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 };
|
|
}
|
|
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
|
|
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 };
|
|
}
|
|
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 };
|
|
}
|
|
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 };
|
|
}
|
|
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)
|
|
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;
|
|
}
|
|
const availability = outcome.localAvailable === false ? { localAvailable: false } : {};
|
|
|
|
// Main screens - CLI or remote connection is working
|
|
if (outcome.status === 'ok') {
|
|
if (outcome.target === 'local') {
|
|
return { screen: 'main', ...availability };
|
|
} else if (outcome.target === 'remote') {
|
|
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') {
|
|
return { screen: 'chooser', ...availability };
|
|
}
|
|
|
|
// Recovery screens - something is wrong
|
|
if (outcome.target === 'local' && outcome.status === 'unreachable') {
|
|
return { screen: 'chooser', ...availability };
|
|
}
|
|
|
|
if (outcome.target === 'remote') {
|
|
if (outcome.status === 'unreachable') {
|
|
return { screen: 'recovery', variant: 'remote-unreachable', hostId: outcome.hostId, url: outcome.url, ...availability };
|
|
} else if (outcome.status === 'incompatible') {
|
|
return { screen: 'recovery', variant: 'remote-incompatible', hostId: outcome.hostId, url: outcome.url, ...availability };
|
|
} else if (outcome.status === 'wrong-service') {
|
|
return { screen: 'recovery', variant: 'remote-wrong-service', hostId: outcome.hostId, url: outcome.url, ...availability };
|
|
} else if (outcome.status === 'missing') {
|
|
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';
|
|
}
|