Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
import type { OpencodeClient, PermissionRequest, Project, QuestionRequest } from "@opencode-ai/sdk/v2/client"
|
|
import { retry } from "./retry"
|
|
import type { GlobalState, State } from "./types"
|
|
import { runtimeFetch } from "../lib/runtime-fetch"
|
|
|
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
|
|
|
/**
|
|
* SDK returns `{ data, error, response }` without throwing on non-2xx.
|
|
* The silent `x.data!` / `x.data ?? []` pattern lets HTTP 5xx warmup
|
|
* errors become empty state. Wrap into a real Error so retry() fires.
|
|
*/
|
|
function unwrap<T>(
|
|
result: { data?: T; error?: unknown; response?: { status?: number } },
|
|
name: string,
|
|
): T {
|
|
if (result.error) {
|
|
const rawError = result.error
|
|
const status = result.response?.status
|
|
const message = typeof rawError === "object" && rawError !== null && "message" in rawError
|
|
? String((rawError as { message?: unknown }).message)
|
|
: String(rawError)
|
|
const err = new Error(`${name} failed${status ? ` (${status})` : ""}: ${message}`)
|
|
if (status !== undefined) {
|
|
;(err as Error & { status?: number }).status = status
|
|
}
|
|
throw err
|
|
}
|
|
if (result.data === undefined) {
|
|
// No error + no data: ambiguous, treat as transient so retry fires.
|
|
const err = new Error(`${name} returned no data`)
|
|
;(err as Error & { status?: number }).status = 503
|
|
throw err
|
|
}
|
|
return result.data
|
|
}
|
|
|
|
const requestSignature = (items: Array<{ id: string }> | undefined): string => {
|
|
if (!items || items.length === 0) return ""
|
|
return items
|
|
.map((item) => item.id)
|
|
.sort(cmp)
|
|
.join("|")
|
|
}
|
|
|
|
function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
|
|
return input.reduce<Record<string, T[]>>((acc, item) => {
|
|
if (!item?.id || !item.sessionID) return acc
|
|
const list = acc[item.sessionID]
|
|
if (list) list.push(item)
|
|
else acc[item.sessionID] = [item]
|
|
return acc
|
|
}, {})
|
|
}
|
|
|
|
function projectID(directory: string, projects: Project[]) {
|
|
return projects.find(
|
|
(project) => project.worktree === directory || project.sandboxes?.includes(directory),
|
|
)?.id
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bootstrap global state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function bootstrapGlobal(
|
|
sdk: OpencodeClient,
|
|
set: (patch: Partial<GlobalState>) => void,
|
|
) {
|
|
const results = await Promise.allSettled([
|
|
retry(() => sdk.path.get().then((x) => set({ path: unwrap(x, "path.get") }))),
|
|
retry(() => sdk.global.config.get().then((x) => set({ config: unwrap(x, "global.config.get") }))),
|
|
retry(() =>
|
|
sdk.project.list().then((x) => {
|
|
const data = unwrap(x, "project.list")
|
|
const projects = data
|
|
.filter((p): p is Project => !!p?.id)
|
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
|
.sort((a, b) => cmp(a.id, b.id))
|
|
set({ projects })
|
|
}),
|
|
),
|
|
retry(() => sdk.provider.list().then((x) => set({ providers: unwrap(x, "provider.list") }))),
|
|
])
|
|
|
|
const errors = results
|
|
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
|
.map((r) => r.reason)
|
|
if (errors.length) {
|
|
console.error("[bootstrap] global bootstrap failed", errors[0])
|
|
}
|
|
|
|
// If ALL requests failed, OpenCode is likely down — fetch the OpenChamber
|
|
// health endpoint (outside the readiness gate) to get the actual error reason.
|
|
if (errors.length === results.length) {
|
|
let message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
|
|
try {
|
|
const healthRes = await runtimeFetch('/health', { signal: AbortSignal.timeout(4000) })
|
|
if (healthRes.ok) {
|
|
const health = await healthRes.json()
|
|
if (health.lastOpenCodeError) {
|
|
message = health.lastOpenCodeError
|
|
} else if (!health.openCodeRunning) {
|
|
message = "OpenCode process is not running"
|
|
}
|
|
}
|
|
} catch {
|
|
// health endpoint itself unreachable — use the original error
|
|
}
|
|
set({ ready: true, error: { type: "init", message } })
|
|
} else {
|
|
set({ ready: true, error: undefined })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bootstrap per-directory state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function bootstrapDirectory(input: {
|
|
directory: string
|
|
sdk: OpencodeClient
|
|
getState: () => State
|
|
set: (patch: Partial<State>) => void
|
|
global: {
|
|
config: Record<string, unknown>
|
|
projects: Project[]
|
|
providers: { all: unknown[]; connected: unknown[]; default: Record<string, unknown> }
|
|
}
|
|
loadSessions: (directory: string) => Promise<void> | void
|
|
}) {
|
|
const { directory, sdk, getState, set, global: g } = input
|
|
const state = getState()
|
|
const loading = state.status !== "complete"
|
|
|
|
// Seed from global state while we fetch directory-specific data
|
|
const seededProject = projectID(directory, g.projects)
|
|
if (seededProject) set({ project: seededProject })
|
|
if (state.provider.all.length === 0 && g.providers.all.length > 0) {
|
|
set({ provider: g.providers as State["provider"] })
|
|
}
|
|
if (Object.keys(state.config ?? {}).length === 0 && Object.keys(g.config ?? {}).length > 0) {
|
|
set({ config: g.config as State["config"] })
|
|
}
|
|
if (loading) set({ status: "partial" })
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 1: Critical path — block until these resolve so the UI can render.
|
|
// These are the minimum data needed to show a functional chat interface.
|
|
// ---------------------------------------------------------------------------
|
|
const phase1Results = await Promise.allSettled([
|
|
seededProject
|
|
? Promise.resolve()
|
|
: retry(() => sdk.project.current().then((x) => set({ project: unwrap(x, "project.current").id }))),
|
|
retry(() => sdk.provider.list().then((x) => set({ provider: unwrap(x, "provider.list") }))),
|
|
retry(() => sdk.config.get().then((x) => set({ config: unwrap(x, "config.get") }))),
|
|
retry(() =>
|
|
sdk.path.get().then((x) => {
|
|
const data = unwrap(x, "path.get")
|
|
set({ path: data })
|
|
const next = projectID(data?.directory ?? directory, g.projects)
|
|
if (next) set({ project: next })
|
|
}),
|
|
),
|
|
retry(() => sdk.session.status().then((x) => set({ session_status: unwrap(x, "session.status") }))),
|
|
])
|
|
|
|
const phase1Errors = phase1Results
|
|
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
|
.map((r) => r.reason)
|
|
|
|
// path.get and session.status have no global-state fallback.
|
|
// If either fails, the UI cannot safely advance to "complete".
|
|
const [, , , pathResult, sessionStatusResult] = phase1Results
|
|
const criticalPhase1Failed =
|
|
pathResult.status === "rejected" || sessionStatusResult.status === "rejected"
|
|
|
|
if (phase1Errors.length === phase1Results.length || criticalPhase1Failed) {
|
|
console.error(`[bootstrap] directory bootstrap failed for ${directory}`, phase1Errors[0])
|
|
return
|
|
}
|
|
|
|
// Mark ready after critical data arrives so the UI can paint.
|
|
if (loading) set({ status: "complete" })
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 2: Deferrable — fetch after first paint without blocking.
|
|
// These enrich the UI but aren't required for basic functionality.
|
|
// ---------------------------------------------------------------------------
|
|
void Promise.allSettled([
|
|
retry(() => sdk.app.agents().then((x) => set({ agent: unwrap(x, "app.agents") }))),
|
|
retry(() => sdk.command.list().then((x) => set({ command: unwrap(x, "command.list") }))),
|
|
retry(() => sdk.mcp.status().then((x) => set({ mcp: unwrap(x, "mcp.status") }))),
|
|
retry(() => sdk.lsp.status().then((x) => set({ lsp: unwrap(x, "lsp.status") }))),
|
|
retry(() =>
|
|
sdk.vcs.get().then((x) => {
|
|
const current = getState()
|
|
if (x.error) {
|
|
throw new Error(`vcs.get failed: ${String(x.error)}`)
|
|
}
|
|
set({ vcs: x.data ?? current.vcs })
|
|
}),
|
|
),
|
|
retry(async () => {
|
|
const before = getState()
|
|
const beforeSignatures = new Map(
|
|
Object.entries(before.question ?? {}).map(([sessionID, questions]) => [sessionID, requestSignature(questions)]),
|
|
)
|
|
const x = await sdk.question.list(directory ? { directory } : undefined)
|
|
if (x.error) {
|
|
const status = (x as { response?: { status?: number } }).response?.status
|
|
const err = new Error(`question.list failed${status ? ` (${status})` : ""}: ${String(x.error)}`)
|
|
if (status !== undefined) (err as Error & { status?: number }).status = status
|
|
throw err
|
|
}
|
|
const grouped = groupBySession(
|
|
(x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID),
|
|
)
|
|
const current = getState()
|
|
const merged = { ...current.question }
|
|
for (const [sessionID, questions] of Object.entries(grouped)) {
|
|
merged[sessionID] = questions
|
|
.filter((q) => !!q?.id)
|
|
.sort((a, b) => cmp(a.id, b.id))
|
|
}
|
|
for (const sessionID of beforeSignatures.keys()) {
|
|
if (grouped[sessionID]) continue
|
|
const beforeSignature = beforeSignatures.get(sessionID) ?? ""
|
|
const currentSignature = requestSignature(current.question[sessionID])
|
|
if (currentSignature !== beforeSignature) continue
|
|
delete merged[sessionID]
|
|
}
|
|
set({ question: merged })
|
|
}),
|
|
retry(async () => {
|
|
const before = getState()
|
|
const beforeSignatures = new Map(
|
|
Object.entries(before.permission ?? {}).map(([sessionID, permissions]) => [sessionID, requestSignature(permissions)]),
|
|
)
|
|
const x = await sdk.permission.list(directory ? { directory } : undefined)
|
|
if (x.error) {
|
|
const status = (x as { response?: { status?: number } }).response?.status
|
|
const err = new Error(`permission.list failed${status ? ` (${status})` : ""}: ${String(x.error)}`)
|
|
if (status !== undefined) (err as Error & { status?: number }).status = status
|
|
throw err
|
|
}
|
|
const grouped = groupBySession(
|
|
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm?.sessionID),
|
|
)
|
|
const current = getState()
|
|
const merged = { ...current.permission }
|
|
for (const [sessionID, perms] of Object.entries(grouped)) {
|
|
merged[sessionID] = perms
|
|
.filter((p) => !!p?.id)
|
|
.sort((a, b) => cmp(a.id, b.id))
|
|
}
|
|
for (const sessionID of beforeSignatures.keys()) {
|
|
if (grouped[sessionID]) continue
|
|
const beforeSignature = beforeSignatures.get(sessionID) ?? ""
|
|
const currentSignature = requestSignature(current.permission[sessionID])
|
|
if (currentSignature !== beforeSignature) continue
|
|
delete merged[sessionID]
|
|
}
|
|
set({ permission: merged })
|
|
}),
|
|
]).then((results) => {
|
|
const errors = results
|
|
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
|
.map((r) => r.reason)
|
|
if (errors.length) {
|
|
console.error(`[bootstrap] deferred phase failed for ${directory}`, errors[0])
|
|
}
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 3: Lazy — session list can be large; don't block on it.
|
|
// ---------------------------------------------------------------------------
|
|
void Promise.resolve(input.loadSessions(directory)).catch((err) => {
|
|
console.error(`[bootstrap] session load failed for ${directory}`, err)
|
|
})
|
|
}
|