2026-04-15 01:32:59 +08:00
|
|
|
/**
|
|
|
|
|
* 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 };
|
|
|
|
|
|
2026-04-15 01:32:59 +08:00
|
|
|
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)
|
2026-04-15 01:32:59 +08:00
|
|
|
|
|
|
|
|
// First launch - user hasn't made a choice yet
|
2026-07-21 21:11:13 +03:00
|
|
|
| ({ target: null; status: 'not-configured' } & DesktopBootAvailability)
|
2026-04-15 01:32:59 +08:00
|
|
|
|
|
|
|
|
// 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);
|
2026-04-15 01:32:59 +08:00
|
|
|
|
|
|
|
|
// ── 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);
|
2026-04-15 01:32:59 +08:00
|
|
|
|
|
|
|
|
// ── 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 */
|
2026-06-02 00:43:05 +03:00
|
|
|
const VALID_STATUSES = ['ok', 'not-configured', 'unreachable', 'incompatible', 'wrong-service', 'missing'] as const;
|
2026-04-15 01:32:59 +08:00
|
|
|
|
|
|
|
|
/** 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 } : {};
|
2026-04-15 01:32:59 +08:00
|
|
|
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 } };
|
2026-04-15 01:32:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 } };
|
2026-04-15 01:32:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 } };
|
2026-04-15 01:32:59 +08:00
|
|
|
} 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 } };
|
2026-04-15 01:32:59 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
if (status === 'incompatible' || status === 'wrong-service') {
|
2026-04-15 01:32:59 +08:00
|
|
|
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 } };
|
2026-04-15 01:32:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 } };
|
2026-04-15 01:32:59 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 } };
|
2026-04-15 01:32:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 } : {};
|
2026-04-15 01:32:59 +08:00
|
|
|
|
|
|
|
|
// 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 };
|
2026-04-15 01:32:59 +08:00
|
|
|
} else if (outcome.target === 'remote') {
|
2026-07-21 21:11:13 +03:00
|
|
|
return { screen: 'main', hostId: outcome.hostId, url: outcome.url, ...availability };
|
2026-04-15 01:32:59 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 };
|
2026-04-15 01:32:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Recovery screens - something is wrong
|
|
|
|
|
if (outcome.target === 'local' && outcome.status === 'unreachable') {
|
2026-07-21 21:11:13 +03:00
|
|
|
return { screen: 'chooser', ...availability };
|
2026-04-15 01:32:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 };
|
2026-06-02 00:43:05 +03:00
|
|
|
} 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 };
|
2026-04-15 01:32:59 +08:00
|
|
|
} 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 };
|
2026-04-15 01:32:59 +08:00
|
|
|
} else if (outcome.status === 'missing') {
|
2026-07-21 21:11:13 +03:00
|
|
|
return { screen: 'recovery', variant: 'remote-missing', hostId: outcome.hostId, ...availability };
|
2026-04-15 01:32:59 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 = {
|
2026-06-03 02:42:00 +03:00
|
|
|
isDesktopShell: boolean;
|
2026-04-15 01:32:59 +08:00
|
|
|
isDesktopLocalOriginActive: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Whether the initial loading screen can be dismissed.
|
|
|
|
|
*
|
2026-06-03 02:42:00 +03:00
|
|
|
* Desktop shells must wait until a valid boot outcome is injected by the native host.
|
2026-04-15 01:32:59 +08:00
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-03 02:42:00 +03:00
|
|
|
* Boot/recovery UI can render in the desktop startup window before the local
|
2026-04-15 01:32:59 +08:00
|
|
|
* desktop HTTP origin is active. In that state, same-origin reloads and
|
2026-06-03 02:42:00 +03:00
|
|
|
* `/api/*` requests cannot recover the app, so callers must restart desktop.
|
2026-04-15 01:32:59 +08:00
|
|
|
*/
|
|
|
|
|
export function shouldRestartDesktopBootFlow(input: DesktopBootFlowRestartInput): boolean {
|
2026-06-03 02:42:00 +03:00
|
|
|
return input.isDesktopShell && !input.isDesktopLocalOriginActive;
|
2026-04-15 01:32:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-03 02:42:00 +03:00
|
|
|
* Read the boot outcome injected by the native desktop host.
|
2026-04-15 01:32:59 +08:00
|
|
|
* 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';
|
|
|
|
|
}
|