From 50e5614d6e9ef8f2bc5f07b170956f6a3aa0e51d Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:36:59 +1000 Subject: [PATCH 01/16] feat(gui-extensions): add lifetime primitives, typed composition and a pure main host core Phase 1 of the extension SDK overhaul. Built-ins are unchanged; the old shapes stay until they migrate. - Renderer: Live for declared dependencies, createActive (with otherwise), createLatest, createVisitState, declared Store.app/Store.session with app stores preloaded before setup and queued writes, SessionView.visit, registrations that follow the current Solid owner and dispose when late, layout writes held until the session is located, requires gating, and an error boundary per contribution. - Typing: Extension.define with provides/uses/requires/stores, Setup exposing only declared dependencies, and Extension.compose failing to compile on a missing or duplicate provider (RemotesProvided checks renderer remotes against the main composition). - Main: a lifecycle core without Electron (queue, generations, withdrawal, cleanup deadline, kept-scope handoff, last good revision on a failed reload, stall log) behind a thin Electron adapter, plus Scope with Effect's names and MainApp.restart(handoff, { keep }). - Tests: table tests per primitive, type-level composition tests, the extension graph test, lifecycle tables and a seeded lifecycle fuzz test. - Lint: bun run lint:changed fails when a touched GUI package file has any oxlint problem, including the warn-level anti-slop rules; CI runs it on pull requests. --- .github/workflows/check.yml | 8 + package.json | 1 + .../component-tests/extension-graph.spec.ts | 83 +++ .../extension-host.fixture.tsx | 205 +++++- .../component-tests/extension-host.spec.ts | 215 +++++- packages/app/src/runtime/extension/host.tsx | 650 +++++++++++++++--- packages/app/src/runtime/extension/located.ts | 28 + packages/app/src/runtime/extension/remote.ts | 88 ++- packages/app/src/runtime/extension/render.tsx | 19 +- packages/app/src/runtime/extension/root.tsx | 19 +- .../app/src/runtime/extension/services.tsx | 230 +++++-- .../app/src/runtime/extension/setting-dev.tsx | 13 +- packages/app/src/runtime/extension/stores.ts | 124 ++++ packages/app/src/runtime/extension/view.ts | 12 + .../test-browser/extension-primitives.test.ts | 269 ++++++++ packages/desktop/src/main/extension/host.ts | 559 +++++++-------- .../src/main/extension/lifecycle.test.ts | 617 +++++++++++++++++ .../desktop/src/main/extension/lifecycle.ts | 393 +++++++++++ packages/gui-extensions/AGENTS.md | 3 +- .../src/sdk/compose.typecheck.ts | 103 +++ packages/gui-extensions/src/sdk/context.ts | 69 ++ packages/gui-extensions/src/sdk/core.ts | 235 ++++++- packages/gui-extensions/src/sdk/index.ts | 8 + packages/gui-extensions/src/sdk/main.ts | 32 +- packages/gui-extensions/src/sdk/reactive.ts | 163 +++++ packages/gui-extensions/src/sdk/scope.test.ts | 107 +++ packages/gui-extensions/src/sdk/scope.ts | 123 ++++ packages/gui-extensions/src/sdk/services.ts | 57 +- packages/gui-extensions/src/sdk/solid.ts | 26 +- script/lint-changed.ts | 52 ++ 30 files changed, 3956 insertions(+), 555 deletions(-) create mode 100644 packages/app/component-tests/extension-graph.spec.ts create mode 100644 packages/app/src/runtime/extension/located.ts create mode 100644 packages/app/src/runtime/extension/stores.ts create mode 100644 packages/app/test-browser/extension-primitives.test.ts create mode 100644 packages/desktop/src/main/extension/lifecycle.test.ts create mode 100644 packages/desktop/src/main/extension/lifecycle.ts create mode 100644 packages/gui-extensions/src/sdk/compose.typecheck.ts create mode 100644 packages/gui-extensions/src/sdk/context.ts create mode 100644 packages/gui-extensions/src/sdk/reactive.ts create mode 100644 packages/gui-extensions/src/sdk/scope.test.ts create mode 100644 packages/gui-extensions/src/sdk/scope.ts create mode 100644 script/lint-changed.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 75d0e15972bb..27a11e5ff9c3 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -24,3 +24,11 @@ jobs: - name: Run checks run: bun run check + + # Every GUI package file (app, desktop, gui-extensions, ui, session-ui) a pull request adds or edits must be free of + # oxlint problems, warn-level rules (anti-slop) included. Other packages are not affected. + - name: Lint changed files + if: github.event_name == 'pull_request' + run: | + git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }} + bun run lint:changed ${{ github.event.pull_request.base.sha }} diff --git a/package.json b/package.json index ab516e66e70a..a26de5f7f748 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dev:storybook": "bun --cwd packages/storybook storybook", "bench:devex": "bun run --cwd packages/app test:bench:devex", "lint": "oxlint && ast-grep scan -c script/ast-grep/gui-extensions/sgconfig.yml", + "lint:changed": "bun script/lint-changed.ts", "lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src", "lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages", "test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml", diff --git a/packages/app/component-tests/extension-graph.spec.ts b/packages/app/component-tests/extension-graph.spec.ts new file mode 100644 index 000000000000..b50d775f8685 --- /dev/null +++ b/packages/app/component-tests/extension-graph.spec.ts @@ -0,0 +1,83 @@ +import { fileURLToPath } from "node:url" +import type { Definition } from "@opencode/gui-extensions/sdk" +import { expect, story } from "../../storybook/playwright/story" + +const source = (path: string) => `/@fs/${fileURLToPath(new URL(path, import.meta.url)).replaceAll("\\", "/")}` + +const modules = { + fixture: source("./extension-host.fixture.tsx"), + builtins: source("../../gui-extensions/src/renderer.ts"), +} + +story.beforeEach(async ({ mount }) => { + // Any story loads the app; the fixture mounts the real host beside it. + await mount("ui-line-comment--editor") +}) + +// The built-in renderer composition as a graph: `requires` edges must not form a cycle, and every optional (`uses`) +// edge gets one row that boots the real host with that provider disabled. The first row disables nothing. +story("built-ins: no requires cycle, and each consumer activates without each optional provider", async ({ page }) => { + const result = await page.evaluate(async (modules) => { + const [{ mountExtensions, until }, { builtins }] = await Promise.all([ + import(modules.fixture), + import(modules.builtins), + ]) + + // The window this host serves has no OS, so OS-specific built-ins are left out as the app leaves them out. + const composition: readonly Definition[] = builtins + const definitions = composition.filter((definition) => !definition.os) + const ids = (tokens: Definition["provides"]) => Object.values(tokens ?? {}).map((token) => token.id) + const providerOf = (token: string) => definitions.find((definition) => ids(definition.provides).includes(token))?.id + + const requires = new Map( + definitions.map((definition) => [ + definition.id, + ids(definition.requires).flatMap((token) => providerOf(token) ?? []), + ]), + ) + + const cycles = definitions.flatMap((definition) => { + const walk = (id: string, path: readonly string[]): string[][] => + path.includes(id) + ? id === definition.id + ? [[...path, id]] + : [] + : (requires.get(id) ?? []).flatMap((next) => walk(next, [...path, id])) + + return walk(definition.id, []) + }) + + const edges = definitions.flatMap((consumer) => + ids(consumer.uses).flatMap((token) => { + const provider = providerOf(token) + + return provider ? [{ consumer: consumer.id, provider, token }] : [] + }), + ) + + const rows = [{ consumer: "*", provider: "", token: "" }, ...edges] + const outcomes = [] + + for (const row of rows) { + const host = mountExtensions({ definitions, disabled: row.provider ? [row.provider] : [] }) + host.release() + await until(() => host.ready()) + const consumers = row.consumer === "*" ? definitions.map((definition) => definition.id) : [row.consumer] + outcomes.push({ + ...row, + results: consumers.map((id) => ({ id, status: host.status(id), failure: host.failure(id)?.error ?? null })), + }) + host.unmount() + } + + return { cycles, outcomes } + }, modules) + + expect(result.cycles).toEqual([]) + expect(result.outcomes).toEqual( + result.outcomes.map((outcome) => ({ + ...outcome, + results: outcome.results.map((item) => ({ id: item.id, status: "active", failure: null })), + })), + ) +}) diff --git a/packages/app/component-tests/extension-host.fixture.tsx b/packages/app/component-tests/extension-host.fixture.tsx index 7cec3f5a0f18..70aad2d13bde 100644 --- a/packages/app/component-tests/extension-host.fixture.tsx +++ b/packages/app/component-tests/extension-host.fixture.tsx @@ -1,21 +1,63 @@ import { DialogProvider } from "@opencode/ui/context/dialog" -import type { Setup } from "@opencode/gui-extensions/sdk" -import { createSignal } from "solid-js" +import { + App, + Layout, + Native, + Preferences, + Sessions, + Storage, + Surfaces, + System, + type Definition, + type Setup, + type StoreOptions, +} from "@opencode/gui-extensions/sdk" +import { createSignal, getOwner, runWithOwner, Show } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { Schema } from "effect" import { render } from "solid-js/web" +import type { Platform } from "@/runtime/platform/platform" +import { Persist, persisted } from "@/runtime/persistence/storage" import { ExtensionHostProvider, useExtensionHost } from "../src/runtime/extension/host" +import { ExtensionSlot } from "../src/runtime/extension/render" +import { persistedHandle } from "../src/runtime/extension/stores" import { LanguageProvider } from "../src/runtime/i18n/language" +export { createActive, Dialogs, Service, Slot, Store } from "@opencode/gui-extensions/sdk" + +export { Schema } + +type Host = ReturnType + +/** A value the fixture's storage holds as JSON. */ +type Json = string | number | boolean | null | readonly Json[] | { readonly [key: string]: Json } + +export { createSignal, getOwner, runWithOwner } + +/** Resolves once `check` holds, checking every frame; rejects after five seconds. */ +export async function until(check: () => boolean) { + const deadline = performance.now() + 5000 + + while (!check()) { + if (performance.now() > deadline) throw new Error("The fixture never reached the expected state") + await new Promise((resolve) => requestAnimationFrame(resolve)) + } +} + /** Mounts the real extension host with one extension whose every renderer load settles when the test says so. */ export function mountExtensionHost() { const loads: PromiseWithResolvers<{ default: Setup }>[] = [] const [disabled, setDisabled] = createSignal>(new Set()) - const state = { host: undefined as ReturnType | undefined } + const hosts: Host[] = [] const container = document.createElement("div") document.body.appendChild(container) + function Capture() { - state.host = useExtensionHost() + hosts.push(useExtensionHost()) + return null } + const dispose = render( () => ( @@ -27,6 +69,7 @@ export function mountExtensionHost() { renderer: () => { const load = Promise.withResolvers<{ default: Setup }>() loads.push(load) + return load.promise }, }, @@ -41,6 +84,7 @@ export function mountExtensionHost() { ), container, ) + return { unmount: () => { dispose() @@ -49,14 +93,159 @@ export function mountExtensionHost() { /** Resolves the nth renderer load (the first by default) with this setup. */ load: (setup: Setup, index = 0) => loads[index].resolve({ default: setup }), /** Rejects the nth renderer load. */ - fail: (index: number, error: unknown) => loads[index].reject(error), + fail: (index: number, cause: unknown) => loads[index].reject(cause), /** Renderer loads requested so far. */ count: () => loads.length, - reload: () => state.host?.reload("fixture"), + reload: () => hosts[0]?.reload("fixture"), disable: () => setDisabled(new Set(["fixture"])), enable: () => setDisabled(new Set()), - status: () => state.host?.state.status.fixture, + status: () => hosts[0]?.state.status.fixture, /** Contributions the host holds for a point; readable after the host unmounts. */ - entries: (point: string) => state.host?.state.entries[point]?.length ?? 0, + entries: (point: string) => hosts[0]?.state.entries[point]?.length ?? 0, + } +} + +/** + * Mounts the real host over these definitions, before any session mounts, with the host services faked at their + * boundary. Storage is the real persisted store of a desktop window whose reads wait until `release()`; `stored` seeds + * it. Renders the `shell.bottom` slot once the startup gate opens. + */ +export function mountExtensions(input: { + definitions: readonly Definition[] + disabled?: readonly string[] + stored?: Readonly> +}) { + const held = Promise.withResolvers() + const [disabled, setDisabled] = createSignal>(new Set(input.disabled ?? [])) + const hosts: Host[] = [] + const container = document.createElement("div") + document.body.appendChild(container) + + const platform: Platform = { + platform: "desktop", + windowID: "extension-host-fixture", + openExternal: () => undefined, + openDirectoryPickerDialog: async () => null, + restart: async () => undefined, + notify: async () => undefined, + storage: () => ({ + getItem: async (key: string) => { + await held.promise + + return key in (input.stored ?? {}) ? JSON.stringify(input.stored?.[key]) : null + }, + setItem: async () => undefined, + removeItem: async () => undefined, + }), + } + + const storage = (extension: string): Storage => ({ + store>(key: string, options: StoreOptions) { + const pair = persisted(Persist.global(`extension.${extension}.${key}`), options.schema, options.initial, platform) + + return persistedHandle({ + store: pair[0], + update: (mutation: (draft: S["Type"]) => void) => pair[1](produce(mutation)), + ready: pair[3], + init: pair[3].promise, + }) + }, + memory: (_key, options) => { + const [value, set] = createStore(options.initial) + + return [value, (mutation) => set(produce(mutation))] as const + }, + remove() {}, + }) + + const layout: Layout = { + narrow: () => false, + ready: () => true, + open() {}, + close() {}, + toggle() {}, + state: () => "closed", + stored: () => [], + side: { opened: () => false, toggle() {} }, + dock: { opened: () => false, placement: () => "bottom" }, + scroll: { get: () => undefined, set() {} }, + settings() {}, + project() {}, + } + + const app: App = { + channel: "dev", + platform: "desktop", + font: () => "monospace", + locale: () => "en", + direction: () => "ltr", + setDirection() {}, + routing: () => false, + path: () => "/", + keybind: () => [], + keys: (bind) => bind.split("+"), + matches: () => false, + servers: () => [], + on: () => () => undefined, + } + + const services = [ + { token: App, create: () => app }, + { token: Native, create: () => undefined }, + { token: Sessions, create: () => ({ list: () => [], current: () => undefined }) }, + { token: Layout, create: () => layout }, + { token: Storage, create: storage }, + { token: System, create: () => ({ copy: async () => {}, save: async () => false, open() {} }) }, + { + token: Preferences, + create: () => ({ releaseNotes: () => false, setReleaseNotes() {}, mobileDiffWrap: () => false }), + }, + { token: Surfaces, create: () => ({ View: () => null, capture: async () => undefined }) }, + ] + + function Host() { + return ( + + + + ) + } + + function Capture() { + const host = useExtensionHost() + hosts.push(host) + + return ( + + + + ) + } + + const dispose = render( + () => ( + + + + + + ), + container, + ) + + return { + container, + unmount: () => { + dispose() + container.remove() + }, + /** Lets the held storage reads answer. */ + release: () => held.resolve(), + disable: (ids: readonly string[]) => setDisabled(new Set(ids)), + reload: (id: string) => hosts[0]?.reload(id), + ready: () => hosts[0]?.ready() ?? false, + status: (id: string) => hosts[0]?.state.status[id], + failure: (id: string) => hosts[0]?.state.failures[id], + entries: (point: string) => hosts[0]?.state.entries[point]?.length ?? 0, } } diff --git a/packages/app/component-tests/extension-host.spec.ts b/packages/app/component-tests/extension-host.spec.ts index 9b792326b113..691ee412dd3a 100644 --- a/packages/app/component-tests/extension-host.spec.ts +++ b/packages/app/component-tests/extension-host.spec.ts @@ -1,9 +1,13 @@ import { fileURLToPath } from "node:url" -import type { Context, Dialogs } from "@opencode/gui-extensions/sdk" +import type { Owner } from "solid-js" +import type { Context, Dialogs, Service } from "@opencode/gui-extensions/sdk" import { expect, story } from "../../storybook/playwright/story" const fixture = `/@fs/${fileURLToPath(new URL("./extension-host.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}` +/** What the scoped-registration case keeps from inside its extension. */ +type Scope = { owner?: Owner | null; end: () => void; ctx?: Context } + story.beforeEach(async ({ mount }) => { // Any story loads the app; the fixture mounts the real host beside it. await mount("ui-line-comment--editor") @@ -17,8 +21,10 @@ story("an extension that finishes loading after the host unmounts is never set u host.unmount() host.load(() => void state.setups++) await new Promise((resolve) => setTimeout(resolve, 100)) + return state.setups }, fixture) + expect(setups).toBe(0) }) @@ -36,6 +42,7 @@ story("an async setup that resolves after the host unmounts releases everything await resume.promise ctx.add(point, "after") ctx.cleanup(() => void cleaned.push("registered")) + return () => void cleaned.push("returned") }) await started.promise @@ -43,8 +50,10 @@ story("an async setup that resolves after the host unmounts releases everything host.unmount() resume.resolve() await new Promise((resolve) => setTimeout(resolve, 100)) + return { before, after: host.entries(point.id), cleaned } }, fixture) + expect(result).toEqual({ before: 1, after: 0, cleaned: ["registered", "returned"] }) }) @@ -62,17 +71,19 @@ story("older loads neither set up nor fail over the replacement after reloads", await new Promise((resolve) => setTimeout(resolve, 100)) const outcome = { setups, status: host.status() } host.unmount() + return outcome }, fixture) + expect(result).toEqual({ setups: ["third"], status: "active" }) }) story("a dialog service kept from before a reload opens and closes nothing under the replacement", async ({ page }) => { const result = await page.evaluate(async (fixture) => { - const { mountExtensionHost } = await import(fixture) + const { mountExtensionHost, Dialogs } = await import(fixture) const host = mountExtensionHost() const services: Dialogs[] = [] - const setup = (ctx: Context) => void services.push(ctx.use({ kind: "host" as const, id: "dialog" }) as Dialogs) + const setup = (ctx: Context) => void services.push(ctx.use(Dialogs)) const text = (value: string) => () => Object.assign(document.createElement("p"), { textContent: value }) const shown = (value: string) => !!document.body.textContent?.includes(value) const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -89,26 +100,30 @@ story("a dialog service kept from before a reload opens and closes nothing under await wait(300) const outcome = { stale: shown("stale dialog"), fresh: shown("fresh dialog") } host.unmount() + return outcome }, fixture) + expect(result).toEqual({ stale: false, fresh: true }) }) story("a dialog pushed in the same tick as a reload never mounts", async ({ page }) => { const shown = await page.evaluate(async (fixture) => { - const { mountExtensionHost } = await import(fixture) + const { mountExtensionHost, Dialogs } = await import(fixture) const host = mountExtensionHost() const services: Dialogs[] = [] const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - host.load((ctx: Context) => void services.push(ctx.use({ kind: "host" as const, id: "dialog" }) as Dialogs), 0) + host.load((ctx: Context) => void services.push(ctx.use(Dialogs)), 0) await wait(20) services[0].push(() => Object.assign(document.createElement("p"), { textContent: "same tick dialog" })) host.reload() await wait(300) const outcome = !!document.body.textContent?.includes("same tick dialog") host.unmount() + return outcome }, fixture) + expect(shown).toBe(false) }) @@ -124,6 +139,7 @@ story("an extension reloaded while disabled starts when it is enabled again", as await wait(20) host.reload() const afterReload = host.status() + // A load the reload started finishes while the extension is still disabled. if (host.count() > 1) host.load(() => void setups.push("while disabled"), 1) await wait(20) @@ -133,7 +149,196 @@ story("an extension reloaded while disabled starts when it is enabled again", as await wait(50) const outcome = { afterReload, setups, status: host.status() } host.unmount() + return outcome }, fixture) + expect(result).toEqual({ afterReload: "disabled", setups: ["first", "enabled"], status: "active" }) }) + +story( + "a registration is withdrawn with the scope that made it, and at once when that scope already ended", + async ({ page }) => { + const result = await page.evaluate(async (fixture) => { + const { mountExtensions, until, createActive, createSignal, getOwner, runWithOwner } = await import(fixture) + const point = { kind: "point" as const, id: "fixture-scoped" } + const scope: Scope = { end: () => {} } + + const host = mountExtensions({ + definitions: [ + { + id: "fixture", + renderer: async () => ({ + default: (ctx: Context) => { + const [on, set] = createSignal(true) + scope.end = () => set(false) + scope.ctx = ctx + createActive(on, () => { + scope.owner = getOwner() + ctx.add(point, "during") + }) + }, + }), + }, + ], + }) + + await until(() => host.status("fixture") === "active") + const during = host.entries(point.id) + scope.end() + const ended = host.entries(point.id) + // A captured owner of a generation that ended, as async work resuming late would hold. + runWithOwner(scope.owner, () => scope.ctx?.add(point, "late")) + const late = host.entries(point.id) + host.unmount() + + return { during, ended, late } + }, fixture) + + expect(result).toEqual({ during: 1, ended: 0, late: 0 }) + }, +) + +story("a contribution that throws renders nothing and records the error; the others stay", async ({ page }) => { + const result = await page.evaluate(async (fixture) => { + const { mountExtensions, until, Slot } = await import(fixture) + const text = (value: string) => () => Object.assign(document.createElement("p"), { textContent: value }) + + const host = mountExtensions({ + definitions: [ + { + id: "fixture", + renderer: async () => ({ + default: (ctx: Context) => { + ctx.add(Slot, { + at: "shell.bottom", + render: () => { + throw new Error("broken contribution") + }, + }) + ctx.add(Slot, { at: "shell.bottom", render: text("kept") }) + }, + }), + }, + { + id: "other", + renderer: async () => ({ + default: (ctx: Context) => void ctx.add(Slot, { at: "shell.bottom", render: text("other") }), + }), + }, + ], + }) + + await until(() => !!host.failure("fixture") && !!host.container.textContent?.includes("other")) + const failure = host.failure("fixture") + + const outcome = { + text: host.container.textContent, + status: [host.status("fixture"), host.status("other")], + failure: { phase: failure?.phase, named: !!failure?.error.includes("broken contribution") }, + } + + host.unmount() + + return outcome + }, fixture) + + expect(result).toEqual({ + text: "keptother", + status: ["active", "active"], + failure: { phase: "render", named: true }, + }) +}) + +story("an extension that requires a service starts once it is active and restarts with it", async ({ page }) => { + const result = await page.evaluate(async (fixture) => { + const { mountExtensions, until, Service } = await import(fixture) + const Tree: Service<{ version: number }, "provider.tree"> = Service.define("provider.tree") + const providerLoad = Promise.withResolvers() + const log: string[] = [] + const versions = { value: 0 } + + const definitions = [ + { + id: "provider", + provides: { tree: Tree }, + renderer: async () => { + await providerLoad.promise + + return { default: (ctx: Context) => void ctx.provide(Tree, { version: ++versions.value }) } + }, + }, + { + id: "consumer", + requires: { tree: Tree }, + renderer: async () => ({ + default: (ctx: Context & { requires: { tree: { version: number } } }) => { + const version = ctx.requires.tree.version + log.push(`setup ${version}`) + ctx.cleanup(() => void log.push(`cleanup ${version}`)) + }, + }), + }, + ] + + // Hard contracts gate startup: a consumer whose provider is disabled settles the gate without starting. + const gated = mountExtensions({ definitions, disabled: ["provider"] }) + await until(() => gated.ready()) + const blocked = { status: gated.status("consumer"), log: [...log] } + gated.unmount() + + const host = mountExtensions({ definitions }) + await new Promise((resolve) => setTimeout(resolve, 50)) + const waiting = { status: host.status("consumer"), log: [...log] } + providerLoad.resolve() + await until(() => host.status("consumer") === "active") + host.reload("provider") + await until(() => log.length === 3) + host.disable(["provider"]) + await until(() => log.length === 4) + const outcome = { blocked, waiting, log, after: host.status("consumer") } + host.unmount() + + return outcome + }, fixture) + + expect(result).toEqual({ + blocked: { status: "loading", log: [] }, + waiting: { status: "loading", log: [] }, + log: ["setup 1", "cleanup 1", "setup 2", "cleanup 2"], + after: "loading", + }) +}) + +story("declared app stores load before setup, so setup reads the stored value", async ({ page }) => { + const result = await page.evaluate(async (fixture) => { + const { mountExtensions, until, Schema, Store } = await import(fixture) + const Prefs = Schema.Struct({ open: Schema.Boolean }) + const seen: unknown[] = [] + + const host = mountExtensions({ + stored: { "extension.fixture.prefs": { open: true } }, + definitions: [ + { + id: "fixture", + stores: { prefs: Store.app(Prefs, { open: false }) }, + renderer: async () => ({ + default: (ctx: Context & { stores: { prefs: { value: { open: boolean } } } }) => + void seen.push(ctx.stores.prefs.value.open), + }), + }, + ], + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + const held = { status: host.status("fixture"), seen: [...seen] } + host.release() + await until(() => host.status("fixture") === "active") + const outcome = { held, seen } + host.unmount() + + return outcome + }, fixture) + + expect(result).toEqual({ held: { status: "loading", seen: [] }, seen: [true] }) +}) diff --git a/packages/app/src/runtime/extension/host.tsx b/packages/app/src/runtime/extension/host.tsx index d1febce56272..c17674c9a536 100644 --- a/packages/app/src/runtime/extension/host.tsx +++ b/packages/app/src/runtime/extension/host.tsx @@ -1,11 +1,14 @@ import { batch, + catchError, createContext, createMemo, + createRenderEffect, createResource, createRoot, ErrorBoundary, getOwner, + on, onCleanup, onMount, runWithOwner, @@ -23,8 +26,12 @@ import { pluralCategory } from "@opencode/ui/context/i18n" import { Dialogs, ExtensionContext, + LifetimeContext, Link, Links, + Live, + Sessions, + Storage, type Catalog, type Cleanup, type Context, @@ -32,59 +39,111 @@ import { type Host, type LinkHandler, type Messages, + type Persisted, type Point, type Remote, + type RemoteClient, type RemoteSpec, type Service, + type SessionRef, + type SetupContext, + type Token, } from "@opencode/gui-extensions/sdk" import { useLanguage } from "@/runtime/i18n/language" +import { createSessionStore, whenLoaded } from "./stores" type Entry = { key: string; point: string; extension: string; value: Accessor } + export type Item = { readonly key: string; readonly extension: string; readonly value: T } -type Instance = { definition: Definition; context: Context; dispose: () => void } -type Bound = readonly { + +/** The context the host builds: every `Setup` of the definition receives it, and so does the untyped form. */ +type InstanceContext = SetupContext & Context + +type Instance = { + definition: Definition + context: InstanceContext + dispose: () => void + /** Opens the declared stores. Settles once the app stores have loaded. */ + prepare(): Promise + /** Starts loading the declared session stores for a mounted session. */ + preload(session: SessionRef): void +} + +/** A host service. `register` withdraws what it returns with the caller's owner, else with the extension. */ +export type HostService = { readonly token: Host - create(extension: string, owner: Owner | null, context: Context): unknown -}[] + // SAFETY: each service returns the value its token types; `use` hands it back only through that token's overload. + // oxlint-disable-next-line anti-slop/no-unknown-returns -- see SAFETY above + create(extension: string, owner: Owner | null, context: Context, register: (fn: Cleanup) => Cleanup): unknown +} + +type Bound = readonly HostService[] + export type ExtensionStatus = "loading" | "active" | "failed" | "disabled" +/** The last error an extension raised: in its setup or an effect, or while one of its contributions rendered. */ +export type ExtensionFailure = { readonly phase: "setup" | "render"; readonly error: string } + +type Absent = Exclude, { readonly status: "active" }> + +/** A provider as the gate and `requires` see it: its generation while active, else why it is absent. */ +type Provider = Absent | { readonly status: "active"; readonly generation: number } + +type HostState = { + entries: Record + services: Record + status: Record + failures: Record +} + +type Stores = Record | ((session: SessionRef) => Persisted)> + +const pending: Absent = { status: "pending" } + +const inactive = { + disabled: { status: "inactive", reason: "disabled" }, + failed: { status: "inactive", reason: "failed" }, + restarting: { status: "inactive", reason: "restarting" }, +} as const satisfies Record + const HostContext = createContext>() export function useExtensionHost() { const host = useContext(HostContext) + if (!host) throw new Error("Extension host is unavailable") + return host } -export function ExtensionHostProvider( - props: ParentProps<{ - definitions: readonly Definition[] - disabled: Accessor | undefined> - services: Bound - remote?: (token: Remote) => unknown - }>, -) { +type HostInput = { + definitions: readonly Definition[] + disabled: Accessor | undefined> + services: Bound + /** The renderer client of a remote while it is available; omitted where there is no main process. */ + remote?: (token: Remote) => RemoteClient | undefined + /** How many times a remote became available. Reactive. */ + generation?: (token: Remote) => number + /** An extension's main entry failed. Reactive. */ + failed?: (id: string) => boolean +} + +export function ExtensionHostProvider(props: ParentProps) { const host = createHost(props) + return {props.children} } -function createHost(input: { - definitions: readonly Definition[] - disabled: Accessor | undefined> - services: Bound - remote?: (token: Remote) => unknown -}) { +function createHost(input: HostInput) { const language = useLanguage() const owner = getOwner() const hosts = new Map(input.services.map((service) => [service.token.id, service])) - const provided = new Map() + const provided = new Map & { readonly status: "active" } }>() + const generations = new Map() + // Entries are indexed by point and services versioned by token, so a change wakes only its own readers. - const [state, setState] = createStore({ - entries: {} as Record, - services: {} as Record, - status: {} as Record, - errors: {} as Record, - }) + const [state, setState] = createStore({ entries: {}, services: {}, status: {}, failures: {} }) + const instances = new Map() const memos = new Map[]>>() const sequence = { value: 0 } @@ -94,34 +153,117 @@ function createHost(input: { // neither sets up nor reports a failure over its replacement. const loads = new Map() - const items = (point: Point) => { - const existing = memos.get(point.id) - if (existing) return existing() as readonly Item[] - // Reuse each item object while its value is unchanged so keyed renders do not remount. + // The extension that provides a token: the one declaring it in `provides`, else the one whose id prefixes it. + const providers = new Map() + + const providerOf = (id: string) => { + if (providers.has(id)) return providers.get(id) + + const found = + input.definitions.find((definition) => + Object.values(definition.provides ?? {}).some((token) => token.id === id), + ) ?? input.definitions.find((definition) => definition.id === id || id.startsWith(`${definition.id}.`)) + + providers.set(id, found?.id) + + return found?.id + } + + // Why a token has no active provider. Main-side providers report through the installed list. + const absent = (id: string, generation: number, main: boolean): Absent => { + const extension = providerOf(id) + + if (!extension) return inactive.disabled + + if (main ? input.disabled()?.has(extension) : state.status[extension] === "disabled") return inactive.disabled + + if (main ? input.failed?.(extension) : state.status[extension] === "failed") return inactive.failed + + // A renderer provider that is up but does not provide the token (e.g. on this platform) is not coming. + if (!main && state.status[extension] === "active") return inactive.disabled + + return generation > 0 ? inactive.restarting : pending + } + + const serviceLive = (token: Service): Live => { + void state.services[token.id] + + return provided.get(token.id)?.live ?? absent(token.id, generations.get(token.id) ?? 0, false) + } + + // One object per remote generation, so reading a provider allocates nothing while it is unchanged. + const actives = new Map() + + const providerState = (token: Token): Provider => { + if (token.kind === "service") return serviceLive(token) + + if (!input.remote) return inactive.disabled + + if (!input.remote(token)) return absent(token.id, input.generation?.(token) ?? 0, true) + + const generation = input.generation?.(token) ?? 1 + const cached = actives.get(token.id) + + if (cached?.generation === generation) return cached + + const active = { status: "active", generation } as const + + actives.set(token.id, active) + + return active + } + + const satisfied = (definition: Definition) => + Object.values(definition.requires ?? {}).every((token) => untrack(() => providerState(token)).status === "active") + + // Reuses each item object while its value is unchanged so keyed renders do not remount. + const createItems = (id: string) => { const cache = new Map>() + const created = runWithOwner(owner, () => createMemo(() => { - const next = (state.entries[point.id] ?? []).flatMap((entry) => { + const next = (state.entries[id] ?? []).flatMap((entry) => { const value = entry.value() + if (value === undefined) return [] + const previous = cache.get(entry.key) + if (previous?.value === value) return [previous] + const item = { key: entry.key, extension: entry.extension, value } + cache.set(entry.key, item) + return [item] }) + if (cache.size > next.length) { const live = new Set(next.map((item) => item.key)) + cache.forEach((_, key) => { if (!live.has(key)) cache.delete(key) }) } + return next }), - )! - memos.set(point.id, created) - return created() as unknown as readonly Item[] + ) + + const memo = created ?? (() => []) + + memos.set(id, memo) + + return memo + } + + const items = (point: Point) => { + const memo = memos.get(point.id) ?? createItems(point.id) + + // SAFETY: only `add` stores entries, under the id of the typed point it was given, so this point's items are T. + return memo() as readonly Item[] } + const list = (point: Point) => items(point).map((item) => item.value) const links: Links = { @@ -132,34 +274,43 @@ function createHost(input: { (best, item) => (!best || (item.priority ?? 0) > (best.priority ?? 0) ? item : best), undefined, ) + if (!handler) return false + handler.open(link) + return true }, } + hosts.set(Links.id, { token: Links, create: () => links }) const dialog = useDialog() + hosts.set(Dialogs.id, { token: Dialogs, // Bound to the instance that asked, so an older async call after a disable or reload opens and closes nothing. - create: (extension, _, context): Dialogs => { + create: (extension, _, context, register): Dialogs => { const open = (method: "show" | "push") => (render: () => JSX.Element) => { if (context.signal.aborted) return + const id = `extension:${extension}:${sequence.value++}` - // Closes this dialog, not whichever is on top, when the extension goes away. - const release = context.cleanup(() => dialog.close(id)) + // Closes this dialog, not whichever is on top, when the extension or the scope that opened it goes away. + const release = register(() => dialog.close(id)) + void dialog[method]( () => { // The dialog's root disposes when it closes or another dialog replaces it. onCleanup(() => void release()) + return ( { onMount(() => { dialog.close(id) - fail(extension, error) + fail(extension, error, "render") }) + return null }} > @@ -173,6 +324,7 @@ function createHost(input: { context.signal, ) } + return { show: open("show"), push: open("push"), @@ -186,44 +338,85 @@ function createHost(input: { const activate = async (definition: Definition) => { const load = definition.renderer + // A disabled extension, e.g. one reloaded from settings, stays disabled: marking it loading would make the // enable watcher skip it later. Before the list loads nothing activates; the watcher starts each entry then. if (!load || input.disabled()?.has(definition.id) !== false) return + + setState("status", definition.id, "loading") + + // Waits for its hard contracts; the requirement watcher starts it once they are active. + if (!satisfied(definition)) return + const attempt = {} const current = () => loads.get(definition.id) === attempt + loads.set(definition.id, attempt) - setState("status", definition.id, "loading") + // The current language's catalog loads with the entry, so the first render is already translated. const [module, messages] = await Promise.all([ - load().catch((error: unknown) => { - if (current()) fail(definition.id, error) + load().catch((cause: unknown) => { + if (current()) fail(definition.id, cause) + return undefined }), loadMessages(definition.i18n, untrack(language.locale)), ]) + if (!current()) return + loads.delete(definition.id) + if (!module || lifetime.disposed) return + // Disabling mid-load deactivates, which drops this load before it gets here. if (instances.has(definition.id)) return + runWithOwner(owner, () => createRoot((dispose) => { - const instance = createInstance(definition, dispose, getOwner(), messages) + const root = getOwner() + const instance = createInstance(definition, dispose, root, messages) + instances.set(definition.id, instance) - // Setup runs synchronously inside the extension root so its effects and memos are owned. - void Promise.try(() => untrack(() => module.default(instance.context))).then( - (cleanup) => { + + // Setup and every scope under it read the extension's context, e.g. `createActive` with a token. + if (root) root.context = { ...root.context, [ExtensionContext.id]: instance.context } + + const crash = (cause: unknown) => { + if (instances.get(definition.id) !== instance) return + + batch(() => { + deactivate(definition.id) + fail(definition.id, cause) + }) + } + + // SAFETY: the host context implements the parameter of every `Setup` of this definition, and the untyped one. + const setup = module.default as (ctx: InstanceContext) => void | Cleanup | Promise + + const start = () => + void Promise.try(() => + // Errors from the extension's own effects, at setup or later, fail the extension and nothing else. + catchError( + () => untrack(() => setup(instance.context)), + (cause) => queueMicrotask(() => crash(cause)), + ), + ).then((cleanup) => { // Registered first: if the extension already went away, the cleanup runs now. - if (typeof cleanup === "function") instance.context.cleanup(cleanup) + if (cleanup) instance.context.cleanup(cleanup) + if (instances.get(definition.id) !== instance) return + setState("status", definition.id, "active") - }, - (error: unknown) => { - if (instances.get(definition.id) !== instance) return - deactivate(definition.id) - fail(definition.id, error) - }, - ) + }, crash) + + // Setup runs synchronously inside the extension root so its effects and memos are owned. + if (Object.keys(definition.stores ?? {}).length === 0) return start() + + // App stores load in their storage namespace's one read before setup, so setup reads them as plain values. + Promise.try(instance.prepare).then(() => { + if (instances.get(definition.id) === instance) runWithOwner(root, start) + }, crash) }), ) } @@ -237,84 +430,259 @@ function createHost(input: { const extension = definition.id const controller = new AbortController() const cleanups = new Set() + const [catalog] = createResource(language.locale, (locale) => loadMessages(definition.i18n, locale), { initialValue: initial, }) + const messages = () => catalog.latest - const created = new Map() + const created = new Map>() + // Promise.try runs the cleanup synchronously and isolates a throw from the others. const release = (fn: Cleanup) => - void Promise.try(fn).catch((error: unknown) => console.error(`[extension] ${extension}`, error)) + void Promise.try(fn).catch((cause: unknown) => console.error(`[extension] ${extension}`, cause)) + const own = (fn: Cleanup): Cleanup => { // Work that outlives the extension, e.g. after an await in setup, is released as soon as it registers. if (controller.signal.aborted) { release(fn) + return () => {} } + const cleanup = () => { if (cleanups.delete(cleanup)) return fn() } + cleanups.add(cleanup) + return cleanup } + + // The extension went away, or the scope registering (e.g. through a captured owner) already ended. + const late = () => controller.signal.aborted || !!useContext(LifetimeContext)?.ended + + // Withdrawn with the current owner, or with the extension when there is none (after an await). + const register = (fn: Cleanup): Cleanup => { + if (late()) { + release(fn) + + return () => {} + } + + const cleanup = own(fn) + const scope = getOwner() + + if (scope && scope !== root) onCleanup(() => void cleanup()) + + return cleanup + } + + const clients = new Map; live: Live }>() + + // One accessor per token, and one Live object per transition, so readers re-run only when the provider changes. + const remoteLive = (token: Remote): Live => { + if (!input.remote) return inactive.disabled + + const client = input.remote(token) + + if (!client) return absent(token.id, input.generation?.(token) ?? 0, true) + + const generation = input.generation?.(token) ?? 1 + const cached = clients.get(token.id) + + if (cached?.client === client && cached.live.status === "active" && cached.live.generation === generation) + return cached.live + + const value = Object.assign({}, client, { + on: (name: string, listener: Parameters[1]) => register(client.on(name, listener)), + }) + + const live = { status: "active", value, generation } as const + + clients.set(token.id, { client, live }) + + return live + } + + const lives = new Map>>() + + const liveOf = (token: Token) => { + const existing = lives.get(token.id) + + if (existing) return existing + + const read = Live.accessor(token.kind === "service" ? () => serviceLive(token) : () => remoteLive(token)) + + lives.set(token.id, read) + + return read + } + + const value = (read: Accessor>) => { + const current = read() + + return current.status === "active" ? current.value : undefined + } + + // Declared tokens are followed through Live; an undeclared one keeps the older accessor, undefined while absent. + const declared = new Set( + [...Object.values(definition.uses ?? {}), ...Object.values(definition.requires ?? {})].map((token) => token.id), + ) + + const legacies = new Map>() + + const legacyOf = (token: Token) => { + const existing = legacies.get(token.id) + + if (existing) return existing + + const read = liveOf(token) + const legacy = () => value(read) + + legacies.set(token.id, legacy) + + return legacy + } + + const stores: Stores = {} + const context = { id: extension, signal: controller.signal, cleanup: own, - add(point: Point, item: unknown) { - if (controller.signal.aborted) return () => {} + add(point: Point, item: T | (() => T | undefined)) { + if (late()) return () => {} + // Work after an await in setup has no owner; fall back to the extension root. - const value = + const read = + // SAFETY: a function item is the SDK's reactive form; points take no function values. + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- see SAFETY above typeof item === "function" - ? runWithOwner(getOwner() ?? root, () => createMemo(item as () => unknown))! + ? runWithOwner(getOwner() ?? root, () => createMemo(item as () => T | undefined)) : () => item + const key = `${extension}/${++sequence.value}` - setState("entries", point.id, (entries = []) => [...entries, { key, point: point.id, extension, value }]) - return own(() => setState("entries", point.id, (entries = []) => entries.filter((entry) => entry.key !== key))) + + setState("entries", point.id, (entries = []) => [ + ...entries, + { key, point: point.id, extension, value: read ?? (() => undefined) }, + ]) + + return register(() => + setState("entries", point.id, (entries = []) => entries.filter((entry) => entry.key !== key)), + ) }, list, - provide(token: Service | Remote, impl: unknown) { + provide(token: Service | Remote, impl: T) { if (token.kind === "remote") throw new Error("Remotes are provided by an extension's main entry") - if (controller.signal.aborted) return () => {} - provided.set(token.id, { extension, impl }) - setState("services", token.id, (value = 0) => value + 1) - return own(() => { - if (provided.get(token.id)?.extension !== extension) return + + if (late()) return () => {} + + const generation = (generations.get(token.id) ?? 0) + 1 + + generations.set(token.id, generation) + + const entry = { live: { status: "active", value: impl, generation } as const } + + provided.set(token.id, entry) + setState("services", token.id, (version = 0) => version + 1) + + return register(() => { + if (provided.get(token.id) !== entry) return + provided.delete(token.id) - setState("services", token.id, (value = 0) => value + 1) + setState("services", token.id, (version = 0) => version + 1) }) }, - use(token: Host | Service | Remote) { - if (token.kind === "host") { - if (created.has(token.id)) return created.get(token.id) - const service = hosts.get(token.id) - if (!service) throw new Error(`Host service "${token.id}" is unavailable`) - const value = service.create(extension, root, context) - created.set(token.id, value) - return value - } - if (token.kind === "service") - return () => { - void state.services[token.id] - return provided.get(token.id)?.impl - } - return () => input.remote?.(token) + use(token: Host | Token) { + if (token.kind !== "host") return declared.has(token.id) ? liveOf(token) : legacyOf(token) + + if (created.has(token.id)) return created.get(token.id) + + const service = hosts.get(token.id) + + if (!service) throw new Error(`Host service "${token.id}" is unavailable`) + + const made = service.create(extension, root, typed, register) + + created.set(token.id, made) + + return made }, + uses: Object.fromEntries(Object.entries(definition.uses ?? {}).map(([name, token]) => [name, liveOf(token)])), + // The host starts the extension only while every hard contract is active, and restarts it when one changes. + requires: Object.fromEntries( + Object.entries(definition.requires ?? {}).map(([name, token]) => [name, untrack(() => value(liveOf(token)))]), + ), + stores, t(key: string, params?: Record) { const template = messages()[key] + if (template !== undefined) return resolveTemplate(template, params) + + // SAFETY: a key the extension's catalog lacks is one of the app's shared keys, such as `common.*`. return language.t(key as Parameters[0], params) }, plural(key: string, count: number, params?: Record) { const current = messages() const template = current[`${key}.${pluralCategory(language.intl(), count)}`] ?? current[`${key}.other`] + if (template !== undefined) return resolveTemplate(template, { ...params, count }) + + // SAFETY: a key the extension's catalog lacks is one of the app's shared keys, such as `common.*`. return language.plural(key as Parameters[0], count, params) }, - } as unknown as Context + } + + // SAFETY: one implementation serves the overloads of `Context` and `SetupContext`; each method checks its token kind. + // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above + const typed = context as unknown as InstanceContext + + const sessions: ReturnType>[] = [] + return { definition, - context, + context: typed, + prepare() { + const storage = typed.use(Storage) + + const app = Object.entries(definition.stores ?? {}).flatMap(([name, declaration]) => { + if (declaration.scope === "app") { + const handle = storage.store(name, { ...declaration, scope: "app" }) + + stores[name] = handle + + return [whenLoaded(handle)] + } + + const store = createSessionStore({ + open: (session) => storage.store(name, { ...declaration, scope: { session } }), + owner: root, + }) + + stores[name] = store.get + sessions.push(store) + + return [] + }) + + if (sessions.length > 0) { + own(() => sessions.forEach((store) => store.dispose())) + + const list = typed.use(Sessions) + + // Drops the stores of sessions whose tabs closed. + createRenderEffect(() => { + const keys = new Set(list.list().map((session) => session.key)) + + untrack(() => sessions.forEach((store) => store.prune(keys))) + }) + } + + return Promise.all(app) + }, + preload: (session) => sessions.forEach((store) => store.get(session)), dispose() { controller.abort() // One batch: every contribution and service of the extension disappears in the same frame. @@ -323,6 +691,7 @@ function createHost(input: { cleanups.clear() Object.entries(state.entries).forEach(([point, entries]) => { if (!entries?.some((entry) => entry.extension === extension)) return + setState("entries", point, (list = []) => list.filter((entry) => entry.extension !== extension)) }) }) @@ -334,17 +703,25 @@ function createHost(input: { const deactivate = (id: string) => { loads.delete(id) + const instance = instances.get(id) + if (!instance) return + instances.delete(id) instance.dispose() } - const fail = (id: string, error: unknown) => { - console.error(`[extension] ${id}`, error) + const fail = (id: string, cause: unknown, phase: ExtensionFailure["phase"] = "setup") => { + console.error(`[extension] ${id}`, cause) batch(() => { - setState("status", id, "failed") - setState("errors", id, error instanceof Error ? (error.stack ?? error.message) : String(error)) + // A contribution that fails to render renders nothing; the extension and its other contributions stay. + if (phase === "setup") setState("status", id, "failed") + + setState("failures", id, { + phase, + error: cause instanceof Error ? (cause.stack ?? cause.message) : String(cause), + }) }) } @@ -352,34 +729,87 @@ function createHost(input: { const latest = (definition: Definition) => replaced.get(definition.id) ?? definition // The startup gate: once every entry settled it stays open, so enabling or reloading an extension later - // never unmounts the app. + // never unmounts the app. An entry waiting on a hard contract that is disabled or failed has settled too. const ready = createMemo( (settled) => settled || (!!input.disabled() && input.definitions.every((definition) => { if (!definition.renderer) return true + const status = state.status[definition.id] - return status === "active" || status === "failed" || status === "disabled" + + if (status === "active" || status === "failed" || status === "disabled") return true + + return Object.values(definition.requires ?? {}).some((token) => { + const provider = providerState(token) + + return provider.status === "inactive" && provider.reason !== "restarting" + }) })), false, ) createMemo(() => { const disabled = input.disabled() + if (!disabled) return + untrack(() => - input.definitions.forEach((definition) => { - if (disabled.has(definition.id)) { - deactivate(definition.id) - setState("status", definition.id, "disabled") - return - } - if (instances.has(definition.id) || state.status[definition.id] === "loading") return - void activate(latest(definition)) - }), + batch(() => + input.definitions.forEach((definition) => { + if (disabled.has(definition.id)) { + deactivate(definition.id) + setState("status", definition.id, "disabled") + + return + } + + if (instances.has(definition.id) || state.status[definition.id] === "loading") return + + void activate(latest(definition)) + }), + ), + ) + }) + + // `requires` gating: an extension starts once every hard contract is active and restarts with a new generation of + // any of them. Activation is never ordered by the graph; only these extensions wait, and only for their contracts. + input.definitions.forEach((definition) => { + const tokens = Object.values(definition.requires ?? {}) + + if (tokens.length === 0) return + + const key = createMemo(() => { + const providers = tokens.map(providerState) + + return providers.every((provider) => provider.status === "active") + ? providers.map((provider) => (provider.status === "active" ? provider.generation : 0)).join(",") + : undefined + }) + + createRenderEffect( + on( + key, + (value) => + untrack(() => + batch(() => { + const id = definition.id + + deactivate(id) + + if (input.disabled()?.has(id) !== false) return + + if (value === undefined) return setState("status", id, "loading") + + void activate(latest(definition)) + }), + ), + { defer: true }, + ), ) }) + onCleanup(() => { lifetime.disposed = true loads.clear() @@ -393,16 +823,25 @@ function createHost(input: { items, links, definitions: () => input.definitions.map(latest), - context: (id: string) => instances.get(id)?.context, + context: (id: string): Context | undefined => instances.get(id)?.context, fail, + /** Starts loading every active extension's declared session stores for a session that mounts. */ + preload(session: SessionRef) { + instances.forEach((instance) => instance.preload(session)) + }, /** `next` replaces the definition, e.g. after a development hot update. */ reload(id: string, next?: Definition) { const definition = next ?? input.definitions.find((item) => item.id === id) + if (!definition) return + if (next) replaced.set(id, next) - deactivate(id) - setState("errors", id, undefined) - void activate(latest(definition)) + + batch(() => { + deactivate(id) + setState("failures", id, undefined) + void activate(latest(definition)) + }) }, } } @@ -411,13 +850,18 @@ function createHost(input: { async function loadMessages(catalog: Catalog | undefined, locale: string): Promise { const english = catalog?.en ?? {} const source = catalog?.[locale] + if (!source || locale === "en") return english + const loaded = + // SAFETY: a catalog entry is either inline messages or the loader of a locale module, as `Catalog` types it. + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- see SAFETY above typeof source === "function" ? await source().then( (module) => module.default, () => ({}), ) : source + return { ...english, ...loaded } } diff --git a/packages/app/src/runtime/extension/located.ts b/packages/app/src/runtime/extension/located.ts new file mode 100644 index 000000000000..2293703400d0 --- /dev/null +++ b/packages/app/src/runtime/extension/located.ts @@ -0,0 +1,28 @@ +import { batch, createEffect, createSignal, untrack } from "solid-js" +import type { SessionRef } from "@opencode/gui-extensions/sdk" + +/** + * Layout writes find a session's stored layout through its location, which is unknown until the server reports it + * (for example after a server re-authenticates). `hold` keeps such a write and runs it, in order, once the location is + * known, so no write is lost. + */ +export function createLocatedWrites() { + const [pending, setPending] = createSignal void }[]>([]) + // Syncs held writes with the session locations the server reports. + createEffect(() => { + const held = pending() + + if (held.length === 0) return + const located = held.filter((item) => item.session.location) + + if (located.length === 0) return + setPending(held.filter((item) => !located.includes(item))) + untrack(() => batch(() => located.forEach((item) => item.run()))) + }) + + return { + hold(session: SessionRef, run: () => void) { + setPending((held) => [...held, { session, run }]) + }, + } +} diff --git a/packages/app/src/runtime/extension/remote.ts b/packages/app/src/runtime/extension/remote.ts index 001ffb12a1d3..4c167f10f94f 100644 --- a/packages/app/src/runtime/extension/remote.ts +++ b/packages/app/src/runtime/extension/remote.ts @@ -4,65 +4,106 @@ import { Schema } from "effect" import type { Remote, RemoteClient, RemoteSpec } from "@opencode/gui-extensions/sdk" import type { Bridge } from "@opencode/gui-extensions/sdk/bridge" +type Codec = NonNullable + +/** A value a remote's codec decodes to; each remote's `RemoteClient` gives it that remote's own type. */ +type Decoded = Codec["Type"] + +/** A value as it crosses the bridge, encoded by a remote's codec. */ +type Encoded = Codec["Encoded"] + +type RemoteState = { + available: Record + values: Record + // Counts each time a remote becomes available, so users can tell a returning provider from the one they had. + generations: Record +} + /** Renderer-side clients for remotes that extensions provide in the main process. */ export function createRemotes(bridge: Bridge | undefined) { - const [state, setState] = createStore({ - available: {} as Record, - values: {} as Record, - }) + const [state, setState] = createStore({ available: {}, values: {}, generations: {} }) + + const setAvailable = (id: string, available: boolean) => { + if (available && !state.available[id]) setState("generations", id, (value = 0) => value + 1) + + setState("available", id, available) + } + const specs = new Map() const clients = new Map>() - const listeners = new Map void>>() + const listeners = new Map void>>() const subscribed = new Set() // How many availability and state events reached each remote. Events are newer than any snapshot, so a // subscribe reply only fills in what no event changed while it was in flight. const changes = new Map() + const changesOf = (id: string) => { const existing = changes.get(id) + if (existing) return existing + const created = { available: 0, state: 0 } + changes.set(id, created) + return created } - const decodeState = (id: string, value: unknown) => { + const decodeState = (id: string, value: Encoded): Decoded => { const schema = specs.get(id)?.state + return schema ? Schema.decodeUnknownSync(schema)(value) : value } const stop = bridge?.on((message) => { if (message.type === "state") { if (!specs.has(message.remote)) return + changesOf(message.remote).state++ setState("values", message.remote, reconcile(decodeState(message.remote, message.state))) + return } + if (message.type === "available") { const changed = changesOf(message.remote) + changed.available++ + // Going away clears the state too. if (!message.available) changed.state++ + batch(() => { - setState("available", message.remote, message.available) + setAvailable(message.remote, message.available) + if (!message.available) setState("values", message.remote, undefined) }) + return } + if (message.type !== "event") return + const schema = specs.get(message.remote)?.events?.[message.name] const data = schema ? Schema.decodeUnknownSync(schema)(message.data) : message.data + listeners.get(message.remote)?.forEach((listener) => listener(message.name, data)) }) const subscribe = (connected: Bridge, token: Remote) => { if (subscribed.has(token.id)) return + subscribed.add(token.id) specs.set(token.id, token.spec) + const before = { ...changesOf(token.id) } + void connected.subscribe(token.id).then((result) => { const after = changesOf(token.id) + batch(() => { - if (after.available === before.available) setState("available", token.id, result.available) + if (after.available === before.available) setAvailable(token.id, result.available) + if (after.state === before.state && result.state !== undefined) setState("values", token.id, decodeState(token.id, result.state)) }) @@ -73,44 +114,63 @@ export function createRemotes(bridge: Bridge | undefined) { const methods = Object.fromEntries( Object.entries(token.spec.methods).map(([name, method]) => [ name, - async (input: unknown, options?: { signal?: AbortSignal }) => { + async (input: Decoded, options?: { signal?: AbortSignal }) => { const encoded = method.input ? Schema.encodeUnknownSync(method.input)(input) : null const output = await connected.call({ remote: token.id, method: name, input: encoded }, options?.signal) + return method.output ? Schema.decodeUnknownSync(method.output)(output) : undefined }, ]), ) - return Object.assign(methods, { + + const client = Object.assign(methods, { state: () => state.values[token.id], - on(name: string, listener: (data: unknown) => void) { + on(name: string, listener: (data: Decoded) => void) { const set = listeners.get(token.id) ?? new Set() - const wrapped = (event: string, data: unknown) => { + + const wrapped = (event: string, data: Decoded) => { if (event === name) listener(data) } + set.add(wrapped) listeners.set(token.id, set) + return () => { set.delete(wrapped) } }, - }) as unknown as RemoteClient + }) + + // SAFETY: `methods` holds one codec-checked function per method of the token's spec, beside `state` and `on`. + return client as RemoteClient } const client = (token: Remote) => { if (!bridge) return undefined + subscribe(bridge, token) + if (!state.available[token.id]) return undefined + const existing = clients.get(token.id) + if (existing) return existing + const created = create(bridge, token) + clients.set(token.id, created) + return created } return { client, + /** How many times the remote became available; 0 until it first is. Reactive. */ + generation: (token: Remote) => state.generations[token.id] ?? 0, /** A client typed by its token, for host code that uses one remote directly. */ - typed: (token: Remote) => client(token) as RemoteClient | undefined, + typed: (token: Remote) => + // SAFETY: the client was built from this token's spec, so its methods, state and events are those of `S`. + client(token) as RemoteClient | undefined, /** Stops listening to the bridge. Call it when the owner of these clients goes away. */ dispose() { stop?.() diff --git a/packages/app/src/runtime/extension/render.tsx b/packages/app/src/runtime/extension/render.tsx index 5c54040f04b6..a05d6fede3a5 100644 --- a/packages/app/src/runtime/extension/render.tsx +++ b/packages/app/src/runtime/extension/render.tsx @@ -4,15 +4,21 @@ import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/mark import { ExtensionContext, Slot, Style, type SessionView, type SlotMap } from "@opencode/gui-extensions/sdk" import { useExtensionHost } from "./host" -/** Renders one extension contribution with its context and error isolation. */ +/** + * Renders one extension contribution with its context and error isolation: a contribution that throws records the + * error and renders nothing, and the rest of the window keeps working. Contributions never render on timeline rows; + * the session header slot is per timeline. + */ export function Contribution(props: { extension: string; children: () => JSX.Element }) { const host = useExtensionHost() + return ( {(context) => ( { - onMount(() => host.fail(props.extension, error)) + onMount(() => host.fail(props.extension, error, "render")) + return null }} > @@ -26,17 +32,22 @@ export function Contribution(props: { extension: string; children: () => JSX.Ele export function ExtensionSlot(props: { at: At; input: SlotMap[At] }) { const host = useExtensionHost() + const items = createMemo(() => host .items(Slot) .filter((item) => item.value.at === props.at) .toSorted((a, b) => (a.value.order ?? 0) - (b.value.order ?? 0)), ) + return ( {(item) => ( - {() => (item.value.render as (input: SlotMap[At]) => JSX.Element)(props.input)} + {() => + // SAFETY: items are filtered to `at === props.at`, and a slot's render takes that slot's input. + (item.value.render as (input: SlotMap[At]) => JSX.Element)(props.input) + } )} @@ -45,6 +56,7 @@ export function ExtensionSlot(props: { at: At; input: export function ExtensionStyles() { const host = useExtensionHost() + return ( {(item) => } @@ -56,6 +68,7 @@ export function ExtensionStyles() { export function ExtensionLinks(props: ParentProps<{ session: SessionView }>) { const host = useExtensionHost() const markdown = useMarkdown() + return ( () + const ExtensionHotReload = import.meta.env.DEV ? lazy(() => import("./hmr")) : undefined export function useExtensionServices() { const value = useContext(ServicesContext) + if (!value) throw new Error("Extension services are unavailable") + return value } @@ -35,20 +38,27 @@ export function ExtensionRoot(props: ParentProps) { const remotes = createRemotes(bridge) onCleanup(remotes.dispose) const installed = createInstalled(bridge) + const disabled = createMemo(() => { if (!installed.loaded()) return undefined + return new Set(installed.list().flatMap((item) => (item.enabled ? [] : [item.id]))) }) + const os = platform.platform === "desktop" ? platform.os : undefined // Built-ins only: installed `.ocdx` archives run their main entry until that format ships renderer bundles. const definitions = builtins.filter((definition) => !definition.os || (!!os && definition.os.includes(os))) + const failed = (id: string) => installed.list().some((item) => item.id === id && item.error !== undefined) + return ( remotes.client(token)} + remote={bridge ? (token) => remotes.client(token) : undefined} + generation={remotes.generation} + failed={failed} > {ExtensionHotReload && ( @@ -57,11 +67,7 @@ export function ExtensionRoot(props: ParentProps) { )} - installed.list().some((item) => item.id === id && item.error !== undefined)} - > - {props.children} - + {props.children} @@ -72,6 +78,7 @@ export function ExtensionRoot(props: ParentProps) { export function ExtensionAttachment(props: ParentProps) { const host = useExtensionHost() const attachment = createExtensionAttachment(useExtensionServices()) + return ( diff --git a/packages/app/src/runtime/extension/services.tsx b/packages/app/src/runtime/extension/services.tsx index 640a23506899..c7d48a96f522 100644 --- a/packages/app/src/runtime/extension/services.tsx +++ b/packages/app/src/runtime/extension/services.tsx @@ -14,7 +14,7 @@ import { type Owner, } from "solid-js" import { createStore, produce, type Store } from "solid-js/store" -import { Predicate } from "effect" +import { Predicate, type Schema } from "effect" import { useDialog } from "@opencode/ui/context/dialog" import { base64Encode } from "@opencode/util/encode" import { @@ -27,12 +27,13 @@ import { Storage, Surfaces, System, - type Host, type PanelState, type ServerRef, type SessionRef, type SessionView, type StorageScope, + type StoreFrom, + type StoreOptions, } from "@opencode/gui-extensions/sdk" import { usePlatform } from "@/runtime/platform/platform" import { same } from "@/runtime/persistence/equality" @@ -49,8 +50,10 @@ import { formatKeybindParts, useCommand } from "@/shell/commands/command" import { createMediaQuery } from "@solid-primitives/media" import { useIsRouting, useLocation } from "@solidjs/router" import { useLanguage } from "@/runtime/i18n/language" -import { useExtensionHost } from "./host" +import { useExtensionHost, type HostService } from "./host" +import { createLocatedWrites } from "./located" import type { Region } from "./panels" +import { persistedHandle } from "./stores" import { createSurfaces } from "./surface" type Attached = { @@ -73,8 +76,8 @@ type Attached = { servers: Accessor } -export type HostService = { readonly token: Host; create(extension: string, owner: Owner | null): unknown } -type StorageFrom = Parameters[1]["from"] +/** What persistence imports a store's older value from. */ +type CopyFrom = NonNullable[0], string>["copyFrom"]> /** Services the host owns. Session and layout attach once the app interface mounts. */ export function createExtensionServices() { @@ -86,25 +89,30 @@ export function createExtensionServices() { const narrow = () => !desktop() const [attached, setAttached] = createSignal() const removed = new Set<(value: { server: string; directory: string }) => void>() - const memory = new Map, (mutation: (draft: object) => void) => void]>() + const memory = new Map>() const current = () => attached() + const surfaces = createSurfaces({ bridge: platform.extensions, zoom: () => platform.webviewZoom?.() ?? 1, dialog: () => !!dialog.active, }) - const target = (extension: string, key: string, scope: StorageScope | undefined, from: StorageFrom | undefined) => { + const target = (extension: string, key: string, scope: StorageScope | undefined, from: StoreFrom | undefined) => { const name = `extension.${extension}.${key}` - const copyFrom = typeof from === "string" ? { key: from } : from + const copyFrom = copySpec(from) + if (!scope || scope === "app") return { ...Persist.global(name), copyFrom } const connected = requireAttached(attached()) + if ("session" in scope) { const location = scope.session.location + if (!location) throw new Error("Session storage requires a session location") connected.scoped(name) const server = connected.scope(scope.session.server.id) const directory = base64Encode(location.directory) + return { ...Persist.serverSession(server, directory, scope.session.id, name), copyFrom: @@ -112,7 +120,9 @@ export function createExtensionServices() { copyFrom, } } + if (!scope.directory) return { ...Persist.serverGlobal(connected.scope(scope.server), name), copyFrom } + return { ...Persist.serverWorkspace(connected.scope(scope.server), base64Encode(scope.directory), name), copyFrom } } @@ -121,21 +131,35 @@ export function createExtensionServices() { token: Storage, create: (extension, owner) => ({ - store(key, options) { + store>(key: string, options: StoreOptions) { // Persistence owns effects and resources; code after an await in setup has no owner. const pair = runWithOwner(getOwner() ?? owner, () => persisted(target(extension, key, options.scope, options.from), options.schema, options.initial, platform), )! - return [pair[0], (mutation: (draft: object) => void) => pair[1](produce(mutation)), pair[3]] as never + + // A `Persisted` for typed contexts and the older tuple for the rest, as `PersistedStorage` documents. + return persistedHandle({ + store: pair[0], + update: (mutation: (draft: S["Type"]) => void) => pair[1](produce(mutation)), + ready: pair[3], + init: pair[3].promise, + }) }, - memory(key, options) { + memory(key: string, options: { readonly initial: T }) { const name = `${extension}.${key}` const existing = memory.get(name) - if (existing) return existing as never - const [store, setStore] = createStore(options.initial) - const value = [store, (mutation: (draft: object) => void) => setStore(produce(mutation))] as const + + if (existing) { + // SAFETY: a memory key is one extension's store, which that extension always opens with the same shape. + return existing as readonly [Store, (mutation: (draft: T) => void) => void] + } + + const [store, setStore] = createStore(options.initial) + const value = [store, (mutation: (draft: T) => void) => setStore(produce(mutation))] as const + memory.set(name, value) - return value as never + + return value }, remove(key, options) { removePersisted(target(extension, key, options?.scope, undefined), platform) @@ -155,6 +179,7 @@ export function createExtensionServices() { link.download = file.name link.click() URL.revokeObjectURL(url) + return true }, open(url) { @@ -181,9 +206,10 @@ export function createExtensionServices() { }, { token: App, - create: () => + create: (_extension, _owner, _context, register) => ({ version: platform.version, + // SAFETY: the build sets VITE_OPENCODE_CHANNEL to one of the release channels, or leaves it unset locally. channel: (import.meta.env.VITE_OPENCODE_CHANNEL ?? "local") as App["channel"], platform: platform.platform, font: () => requireAttached(current()).font(), @@ -198,9 +224,10 @@ export function createExtensionServices() { servers: () => current()?.servers() ?? [], on(_event, handler) { removed.add(handler) - return () => { + + return register(() => { removed.delete(handler) - } + }) }, }) satisfies App, }, @@ -255,6 +282,7 @@ export function createExtensionServices() { services, attach(value: Attached) { setAttached(() => value) + return () => { if (attached() === value) setAttached(undefined) } @@ -271,7 +299,9 @@ const AttachmentContext = createContext() + const server = (id: string): ServerRef | undefined => { const conn = connection(id) + if (!conn) return const existing = serverRefs.get(id) + if (existing) return existing const key = ServerConnection.Key.make(id) + const live = runWithOwner(owner, () => createMemo((previous) => global.serverCtx(key) ?? previous, global.ensureServerCtx(conn)), )! + const ref: ServerRef = { id, get name() { @@ -339,7 +374,9 @@ export function createExtensionAttachment(services: ExtensionServices) { return live().sdk.connection.status() === "connected" }, } + serverRefs.set(id, ref) + return ref } @@ -351,9 +388,11 @@ export function createExtensionAttachment(services: ExtensionServices) { tabs.store.forEach((tab) => { if (tab.type !== "session") return const target = server(tab.server) + if (!target) return Array.from(new Set([tab.sessionId, tab.routeSessionId ?? tab.sessionId])).forEach((id) => { const key = `${tab.server}\n${id}` + if (refs.has(key)) return refs.set(key, { key, @@ -369,29 +408,38 @@ export function createExtensionAttachment(services: ExtensionServices) { }) }) }) + return Array.from(refs.values()) }) const routed = createMemo(() => { const value = route() + return value.type === "session" ? `${value.server}\n${value.sessionId}` : undefined }) + const current = createMemo(() => { const key = routed() + if (!key) return void mounted.revision + return views.get(key) }) const scope = (id: string) => { const conn = connection(id) + if (!conn) throw new Error(`Server ${id} is unavailable`) + return global.ensureServerCtx(conn).sdk.scope } const stateKey = (session: SessionRef) => { const location = session.location + if (!location) return + return SessionStateKey.from( scope(session.server.id), SessionRouteKey.fromRoute(base64Encode(location.directory), session.id), @@ -400,9 +448,11 @@ export function createExtensionAttachment(services: ExtensionServices) { const shellTab = (session: SessionRef) => findSessionTab(tabs.store, ServerConnection.Key.make(session.server.id), session.id) + const sideOpened = (session: SessionRef) => !!tabs.pane(shellTab(session), "side") const dockOpened = (session: SessionRef) => !!tabs.pane(shellTab(session), "dock") const setDock = (session: SessionRef, opened: boolean) => tabs.setPane(shellTab(session), "dock", opened) + // A token per session whose side region is open, new each time the region opens. const sideVisits = createMemo>( (previous) => @@ -413,6 +463,7 @@ export function createExtensionAttachment(services: ExtensionServices) { ), new Map(), ) + // The panel whose toggle opened a side region, by the region's token. However the region closes, it reopens with // a new token, so a region reopened any other way belongs to the user. const openedFor = new WeakMap() @@ -421,32 +472,46 @@ export function createExtensionAttachment(services: ExtensionServices) { const provider = (key: string) => { const extension = key.slice(0, key.indexOf(":")) const matches = host.items(Panel).filter((item) => item.extension === extension) + return matches.find((item) => item.value.region === "side") ?? matches[0] } + const mountedView = (session: SessionRef) => { const view = current() + return view?.key === session.key ? view : undefined } // Counts routing visits: each change of the routed session, including to none (e.g. Home), starts the next one. const visit = createMemo(on(routed, (_key, _previous, count: number = 0) => count + 1)) + // `SessionView.visit`: a new object for each routing visit. + const token = createMemo(on(visit, () => ({}))) + // The narrow-screen view belongs to the routed, mounted session for one visit, and reads as the conversation once // another visit starts. A view selected for a session that is not routed (e.g. a file link that opens Files on // another session) belongs to the next visit, which the navigation that follows starts. The dock's view follows // the dock's own per-session state instead. - const [mobile, setMobile] = createStore({ session: undefined as string | undefined, view: "session", visit: 0 }) + const [mobile, setMobile] = createStore<{ session: string | undefined; view: string; visit: number }>({ + session: undefined, + view: "session", + visit: 0, + }) + const mobileView = createMemo(() => mobile.session === current()?.key && mobile.visit === visit() ? mobile.view : "session", ) + const selectMobile = (session: SessionRef, view: string) => setMobile({ session: session.key, view, visit: session.key === routed() ? visit() : visit() + 1 }) // The side tabs a mounted session lists right now, plus `adding` as if it were stored; unmounted sessions have none. const listed = (session: SessionRef, value: string, adding?: string) => { const view = mountedView(session) + if (!view) return [] const all = layout.panel.state(value).all const stored = adding && !all.includes(adding) ? [...all, adding] : all + return untrack(() => host .items(Panel) @@ -454,18 +519,26 @@ export function createExtensionAttachment(services: ExtensionServices) { .flatMap((item) => { const prefix = `${item.extension}:` const open = stored.flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : [])) + return item.value.list(view, open).map((tab) => ({ key: `${prefix}${tab.id}`, tab })) }), ) } + // Writes made before a session's location is known wait for it rather than being dropped. + const located = createLocatedWrites() + const open = (key: string, session: SessionRef, options?: Parameters[2]) => { const item = provider(key) + if (item?.value.region === "dock") return setDock(session, true) const value = stateKey(session) - if (!value) return + + if (!value) return located.hold(session, () => open(key, session, options)) + // focus: false adds the tab quietly: no selection, no region change, no preview replacement. if (options?.focus === false && !options.preview) return layout.panel.append(value, key) + // A select keeps the narrow-screen view and dock, as a background open does, and opens the side region too. if (options?.select) return batch(() => { @@ -480,14 +553,19 @@ export function createExtensionAttachment(services: ExtensionServices) { batch(() => { if (narrow() && !options?.background) { setDock(session, false) + if (item?.value.mobile) selectMobile(session, `${item.extension}:${item.value.id}`) + // A tab its panel does not list, or a launcher, stays unstored: the open only selects the panel's view. if (mountedView(session) && !known.some((entry) => entry.key === key && entry.tab.kind !== "launcher")) return } + // A background open keeps the narrow-screen view, but its tab still shows once the window is wide. if (!narrow() || options?.background) tabs.setPane(shellTab(session), "side", true) + // Pinned tabs are listed without being stored; opening one only selects it. if (known.some((entry) => entry.key === key && entry.tab.kind === "pinned")) return layout.panel.focus(value, key) + if (options?.preview) return layout.panel.preview(value, key, launchers) layout.panel.open(value, key, launchers, first) }) @@ -495,17 +573,21 @@ export function createExtensionAttachment(services: ExtensionServices) { const close = (key: string, session: SessionRef) => { const item = provider(key) + if (item?.value.region === "dock") return setDock(session, false) const value = stateKey(session) - if (!value) return + + if (!value) return located.hold(session, () => close(key, session)) const tab = listed(session, value).find((entry) => entry.key === key)?.tab layout.panel.close(value, key) const view = mountedView(session) + if (view && tab) item?.value.close?.(tab, view) } // The routed session's side region, which knows the fallback selection the stored state lacks. const [region, setRegion] = createSignal() + const opened = createMemo( () => Array.from(new Set((region()?.entries() ?? []).flatMap((entry) => entry.tab.file ?? []))), [], @@ -515,10 +597,13 @@ export function createExtensionAttachment(services: ExtensionServices) { const state = (key: string, session: SessionRef): PanelState => { if (provider(key)?.value.region === "dock") return dockOpened(session) ? "visible" : "closed" const value = stateKey(session) + if (!value) return "closed" const panel = layout.panel.state(value) const active = mountedView(session) ? (region()?.active() ?? panel.active) : panel.active + if (active !== key) return panel.all.includes(key) ? "open" : "closed" + return sideOpened(session) ? "visible" : "active" } @@ -528,10 +613,13 @@ export function createExtensionAttachment(services: ExtensionServices) { const [projects, setProjects] = createSignal([]) createEffect(() => { const pending = projects() + const ready = pending.flatMap((request) => { const server = servers.list.find((conn) => ServerConnection.key(conn) === request.server) + return server ? [{ request, server }] : [] }) + if (ready.length === 0) return setProjects(pending.filter((request) => !ready.some((item) => item.request === request))) untrack(() => @@ -541,6 +629,7 @@ export function createExtensionAttachment(services: ExtensionServices) { title: request.title, onSelect: (value) => { const directory = Array.isArray(value) ? value[0] : value + if (!directory) return const key = ServerConnection.key(server) servers.projects.forServer(key).open(directory) @@ -551,6 +640,40 @@ export function createExtensionAttachment(services: ExtensionServices) { ) }) + const toggle = (key: string, session: SessionRef) => { + if (provider(key)?.value.region === "dock") return setDock(session, !dockOpened(session)) + const value = stateKey(session) + + if (!value) return located.hold(session, () => toggle(key, session)) + const region = sideVisits().get(session.key) + + if (state(key, session) === "visible") { + batch(() => { + close(key, session) + + // Closing the last panel the region was opened for also closes the region. + if (region && openedFor.get(region) === key && layout.panel.state(value).all.length === 0) + tabs.setPane(shellTab(session), "side", false) + }) + + return + } + + // A panel opened into an open region makes the region the user's. + if (region) openedFor.delete(region) + open(key, session) + const opened = sideVisits().get(session.key) + + if (!region && opened) openedFor.set(opened, key) + } + + const setScroll = (session: SessionRef, key: string, next: { readonly x: number; readonly y: number }) => { + const value = stateKey(session) + + if (!value) return located.hold(session, () => setScroll(session, key, next)) + layout.panel.setScroll(value, key, next) + } + const detach = services.attach({ sessions, current, @@ -568,36 +691,20 @@ export function createExtensionAttachment(services: ExtensionServices) { setReleaseNotes: settings.general.setReleaseNotes, mobileDiffWrap: settings.general.mobileDiffWrap, }, + // SAFETY: an extension names a page it contributed through `Setting`, which settings lists as an extension tab. settings: (page) => surface.open(page as Parameters[0]), layout: { ready: layout.ready, open, close, - toggle(key, session) { - if (provider(key)?.value.region === "dock") return setDock(session, !dockOpened(session)) - const value = stateKey(session) - if (!value) return - const region = sideVisits().get(session.key) - if (state(key, session) === "visible") { - batch(() => { - close(key, session) - // Closing the last panel the region was opened for also closes the region. - if (region && openedFor.get(region) === key && layout.panel.state(value).all.length === 0) - tabs.setPane(shellTab(session), "side", false) - }) - return - } - // A panel opened into an open region makes the region the user's. - if (region) openedFor.delete(region) - open(key, session) - const opened = sideVisits().get(session.key) - if (!region && opened) openedFor.set(opened, key) - }, + toggle, state, stored(extension, session) { const value = stateKey(session) + if (!value) return [] const prefix = `${extension}:` + return layout.panel .state(value) .all.flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : [])) @@ -613,15 +720,14 @@ export function createExtensionAttachment(services: ExtensionServices) { scroll: { get(session, key) { const value = stateKey(session) + return value ? layout.panel.scroll(value, key) : undefined }, - set(session, key, next) { - const value = stateKey(session) - if (value) layout.panel.setScroll(value, key, next) - }, + set: setScroll, }, }, }) + onCleanup(detach) return { @@ -629,6 +735,7 @@ export function createExtensionAttachment(services: ExtensionServices) { current, region(value: Region) { setRegion(() => value) + return () => { if (region() === value) setRegion(undefined) } @@ -642,12 +749,18 @@ export function createExtensionAttachment(services: ExtensionServices) { current: mobileView, select(view: string) { const session = current() + if (session) selectMobile(session, view) }, }, + /** The current routing visit, which `SessionView.visit` returns. */ + visit: token, mount(key: string, view: SessionView) { views.set(key, view) setMounted("revision", (value) => value + 1) + // The session's declared stores start loading now, before its regions read them. + untrack(() => host.preload(view)) + return () => { if (views.get(key) !== view) return views.delete(key) @@ -659,19 +772,32 @@ export function createExtensionAttachment(services: ExtensionServices) { function requireAttached(value: Attached | undefined) { if (!value) throw new Error("The app interface is not mounted") + return value } +/** A store's `from` in the object form persistence takes. */ +function copySpec(from: StoreFrom | undefined) { + // SAFETY: `StoreFrom` is an older key alone or an object with a key and a pick, as the SDK types it. + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- see SAFETY above + return typeof from === "string" ? { key: from } : from +} + /** Imports a session's entry from an app key that holds every session's state under one field. */ -function sessionCopy(from: StorageFrom, session: SessionStateKey) { - if (typeof from !== "object" || !from.sessions) return - const field = from.sessions +function sessionCopy(from: StoreFrom | undefined, session: SessionStateKey): CopyFrom | undefined { + const spec = copySpec(from) + + if (!spec || !("sessions" in spec) || !spec.sessions) return + + const field = spec.sessions + return { - key: from.key, - storage: Persist.global(from.key).storage, - pick: (value: unknown) => { + key: spec.key, + storage: Persist.global(spec.key).storage, + pick: (value) => { const sessions = Predicate.isObject(value) ? value[field] : undefined - return from.pick(Predicate.isObject(sessions) ? sessions[session] : undefined) + + return spec.pick(Predicate.isObject(sessions) ? sessions[session] : undefined) }, } } diff --git a/packages/app/src/runtime/extension/setting-dev.tsx b/packages/app/src/runtime/extension/setting-dev.tsx index 6aaf7ec16647..2531a935942a 100644 --- a/packages/app/src/runtime/extension/setting-dev.tsx +++ b/packages/app/src/runtime/extension/setting-dev.tsx @@ -14,7 +14,8 @@ export function GuiExtensionsSettings() { const language = useLanguage() const host = useExtensionHost() const bridge = usePlatform().extensions - const [installed, setInstalled] = createStore({ list: [] as Installed[] }) + const [installed, setInstalled] = createStore<{ list: Installed[] }>({ list: [] }) + if (bridge) { void bridge.manager.list().then((list) => setInstalled("list", reconcile([...list]))) onCleanup( @@ -23,15 +24,23 @@ export function GuiExtensionsSettings() { }), ) } + const enabled = (id: string) => installed.list.find((item) => item.id === id)?.enabled ?? true + const status = (id: string) => { const value = host.state.status[id] ?? "loading" + if (value === "active") return language.t("settings.guiExtensions.status.active") + if (value === "failed") return language.t("settings.guiExtensions.status.failed") + if (value === "disabled") return language.t("settings.guiExtensions.status.disabled") + return language.t("settings.guiExtensions.status.loading") } + const toggle = (id: string, next: boolean) => void (next ? bridge?.manager.enable(id) : bridge?.manager.disable(id)) + // Main entries reload through the manager; the renderer entry reloads here. const reload = (id: string) => { host.reload(id) @@ -60,7 +69,7 @@ export function GuiExtensionsSettings() { description={ <> {status(definition.id)} - + {(error) =>
{error()}
}
diff --git a/packages/app/src/runtime/extension/stores.ts b/packages/app/src/runtime/extension/stores.ts new file mode 100644 index 000000000000..df897daf899f --- /dev/null +++ b/packages/app/src/runtime/extension/stores.ts @@ -0,0 +1,124 @@ +import { + batch, + createMemo, + createRenderEffect, + createRoot, + createSignal, + on, + untrack, + type Accessor, + type Owner, +} from "solid-js" +import type { Persisted, SessionRef } from "@opencode/gui-extensions/sdk" + +const loads = new WeakMap>() + +/** + * What `Storage.store` returns: a `Persisted` whose `update` waits until the stored value has loaded, then applies in + * call order, and the older `[store, update, ready]` tuple. + */ +export function persistedHandle(input: { + readonly store: T + readonly update: (mutation: (draft: T) => void) => void + readonly ready: Accessor + /** The storage read; undefined when storage answered synchronously. */ + readonly init: Promise | undefined +}) { + const [loaded, setLoaded] = createSignal(!input.init) + const queue: ((draft: T) => void)[] = [] + + // Registered after the store's own hydration on the same read, so queued changes apply over the stored value. + const load = input.init?.then(() => + batch(() => { + queue.splice(0).forEach(input.update) + setLoaded(true) + }), + ) + + void load?.catch(() => undefined) + + // SAFETY: the properties defined here are `Persisted`'s, so the tuple is both shapes. + const handle = Object.defineProperties([input.store, input.update, input.ready] as const, { + value: { get: () => (loaded() ? input.store : undefined) }, + ready: { value: loaded }, + update: { + value: (mutation: (draft: T) => void) => { + if (untrack(loaded)) return input.update(mutation) + queue.push(mutation) + }, + }, + }) as readonly [T, (mutation: (draft: T) => void) => void, Accessor] & Persisted + + if (load) loads.set(handle, load) + + return handle +} + +/** Settles once a handle from `persistedHandle` has loaded; rejects when its storage read failed. */ +export function whenLoaded(handle: Persisted) { + return loads.get(handle) ?? Promise.resolve() +} + +/** + * A declared session store: one handle per session. A session's storage needs its location, so the store opens once + * the location is known; changes made before then wait and apply in order. + */ +export function createSessionStore(input: { + readonly open: (session: SessionRef) => Persisted + readonly owner: Owner | null +}) { + const entries = new Map; readonly dispose: () => void }>() + + const create = (session: SessionRef) => + createRoot((dispose) => { + const queue: ((draft: T) => void)[] = [] + const directory = createMemo(() => session.location?.directory) + // A new directory opens the store again; the store from the old one disposes with the previous run. + const store = createMemo(on(directory, (value) => (value === undefined ? undefined : input.open(session)))) + // Hands changes made before the location was known to the store, which applies them once it has loaded. + createRenderEffect(() => { + const current = store() + + if (current && queue.length > 0) untrack(() => queue.splice(0).forEach((mutation) => current.update(mutation))) + }) + + const handle: Persisted = { + get value() { + return store()?.value + }, + ready: () => store()?.ready() ?? false, + update(mutation) { + const current = untrack(store) + + if (current) return current.update(mutation) + queue.push(mutation) + }, + } + + return { handle, dispose } + }, input.owner) + + return { + get(session: SessionRef) { + const existing = entries.get(session.key) + + if (existing) return existing.handle + const created = create(session) + entries.set(session.key, created) + + return created.handle + }, + /** Drops the stores of sessions no tab owns any more. */ + prune(keys: ReadonlySet) { + entries.forEach((entry, key) => { + if (keys.has(key)) return + entry.dispose() + entries.delete(key) + }) + }, + dispose() { + entries.forEach((entry) => entry.dispose()) + entries.clear() + }, + } +} diff --git a/packages/app/src/runtime/extension/view.ts b/packages/app/src/runtime/extension/view.ts index e1a259b39ce0..29f1f9bb36ff 100644 --- a/packages/app/src/runtime/extension/view.ts +++ b/packages/app/src/runtime/extension/view.ts @@ -77,16 +77,20 @@ export function createSessionView(session: SessionModel) { search: (query, options) => options?.kind === "any" ? file.searchFilesAndDirectories(query) : file.searchFiles(query, options), selection: { + // SAFETY: the file model returns the view cache's selection for the path, which its schema types as a line range. get: (path) => file.selectedLines(path) as LineRange | null | undefined, set: (path, range) => void file.setSelectedLines(path, range), }, scroll: { get: (path) => ({ + // SAFETY: the file model returns the view cache's offsets for the path, which its schema types as numbers. top: file.scrollTop(path) as number | undefined, + // SAFETY: as above. left: file.scrollLeft(path) as number | undefined, }), set(path, value) { if (value.top !== undefined) file.setScrollTop(path, value.top) + if (value.left !== undefined) file.setScrollLeft(path, value.left) }, }, @@ -100,15 +104,18 @@ export function createSessionView(session: SessionModel) { } const commentFile = (id: string) => comments.all().find((item) => item.id === id)?.file + const comment: Comments = { list: (path) => (path ? comments.list(path) : comments.all()), add: comments.add, update(id, text) { const path = commentFile(id) + if (path) comments.update(path, id, text) }, remove(id) { const path = commentFile(id) + if (path) comments.remove(path, id) }, focus: { current: comments.focus, set: (value) => void comments.setFocus(value) }, @@ -124,6 +131,7 @@ export function createSessionView(session: SessionModel) { // Only a project opened at this exact directory; a session in a project subfolder has none. const listedProject = createMemo(() => { const directory = pathKey(location().directory) + return server.ctx.projects .list() .find( @@ -142,6 +150,9 @@ export function createSessionView(session: SessionModel) { get tab() { return session.layout.tabKey() ?? "" }, + get visit() { + return attachment.visit() + }, server: serverRef, get pending() { return server.ctx.data.session.creating(session.identity.sessionID() ?? "") @@ -153,6 +164,7 @@ export function createSessionView(session: SessionModel) { // Raw metadata stands in until global sync lists the project. get project() { const info = session.data.info() + return (info && server.ctx.projects.detailsForSession(info)) || session.project() }, get listedProject() { diff --git a/packages/app/test-browser/extension-primitives.test.ts b/packages/app/test-browser/extension-primitives.test.ts new file mode 100644 index 000000000000..9b97ce36c834 --- /dev/null +++ b/packages/app/test-browser/extension-primitives.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, test } from "bun:test" +import { createComponent, createRoot, createSignal, onCleanup, type Accessor } from "solid-js" +import { produce } from "solid-js/store" +import { Schema } from "effect" +import { + createActive, + createLatest, + createVisitState, + ExtensionContext, + Live, + Sessions, + type Context, + type Persisted, + type SessionRef, +} from "@opencode/gui-extensions/sdk" +import type { Platform } from "@/runtime/platform/platform" +import { Persist, persisted } from "@/runtime/persistence/storage" +import { createLocatedWrites } from "@/runtime/extension/located" +import { createSessionStore, persistedHandle, whenLoaded } from "@/runtime/extension/stores" + +const pending = { status: "pending" } as const + +const restarting = { status: "inactive", reason: "restarting" } as const + +const first = { status: "active", value: "first", generation: 1 } as const + +const second = { status: "active", value: "second", generation: 2 } as const + +/** A session the store and layout code can key and locate; they read nothing else. */ +function session(key: string, location: Accessor<{ directory: string } | undefined>) { + const value = { + key, + get location() { + return location() + }, + } + + // SAFETY: the code under test reads only `key` and `location`, which this value provides. + // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above + return value as unknown as SessionRef +} + +describe("extension primitives", () => { + test.each([ + { + name: "a Live source runs once per generation and otherwise between them", + start: () => { + const [read, write] = createSignal>(pending) + + return { + source: Live.accessor(read), + steps: [first, { ...first }, restarting, second, pending].map((step) => () => write(step)), + } + }, + expected: [ + "otherwise", + "end otherwise", + "run first", + "end first", + "otherwise", + "end otherwise", + "run second", + "end second", + "otherwise", + ], + }, + { + name: "a plain accessor runs once per value identity and otherwise while it is empty", + start: () => { + const [read, write] = createSignal(undefined) + + return { + source: read, + steps: ["first", "first", undefined, "second", "third"].map((step) => () => write(step)), + } + }, + expected: [ + "otherwise", + "end otherwise", + "run first", + "end first", + "otherwise", + "end otherwise", + "run second", + "end second", + "run third", + ], + }, + ])("createActive: $name", (row) => { + const log: string[] = [] + const scenario = row.start() + + const dispose = createRoot((dispose) => { + createActive( + scenario.source, + (value) => { + log.push(`run ${value}`) + onCleanup(() => log.push(`end ${value}`)) + }, + { + otherwise: () => { + log.push("otherwise") + onCleanup(() => log.push("end otherwise")) + }, + }, + ) + + return dispose + }) + + scenario.steps.forEach((step) => step()) + expect(log).toEqual(row.expected) + dispose() + }) + + test("createLatest aborts the previous request and drops its late reply", async () => { + const [key, setKey] = createSignal("a") + const requests = new Map }>() + + const root = createRoot((dispose) => ({ + dispose, + latest: createLatest(key, (value, signal) => { + const reply = Promise.withResolvers() + + requests.set(value, { signal, reply }) + + return reply.promise + }), + })) + + expect(root.latest.loading).toBe(true) + setKey("b") + requests.get("b")?.reply.resolve("result b") + requests.get("a")?.reply.resolve("result a") + await Bun.sleep(0) + expect({ + aborted: [requests.get("a")?.signal.aborted, requests.get("b")?.signal.aborted], + latest: root.latest.latest, + loading: root.latest.loading, + }).toEqual({ aborted: [true, false], latest: "result b", loading: false }) + root.dispose() + expect(requests.get("b")?.signal.aborted).toBe(true) + }) + + test("createVisitState returns to its initial value on every routing visit", () => { + const [visit, setVisit] = createSignal({}) + const sessions = { list: () => [], current: () => ({ visit: visit() }) } + const fake = { use: (token: typeof Sessions) => (token === Sessions ? sessions : undefined) } + // SAFETY: `createVisitState` reads only `use(Sessions).current().visit`, which this fake provides. + // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above + const context = fake as unknown as Context + const captured: ReturnType>[] = [] + + const dispose = createRoot((dispose) => { + createComponent(ExtensionContext.Provider, { + value: context, + get children() { + captured.push(createVisitState("initial")) + + return null + }, + }) + + return dispose + }) + + const [value, set] = captured[0] ?? [() => "missing", () => undefined] + + const steps = [ + { action: () => set("chosen"), expected: "chosen" }, + { action: () => setVisit({}), expected: "initial" }, + { action: () => set("again"), expected: "again" }, + { action: () => setVisit({}), expected: "initial" }, + ] + + expect(steps.map((step) => (step.action(), value()))).toEqual(steps.map((step) => step.expected)) + dispose() + }) + + test.each([ + { name: "while the storage read is held", location: { directory: "/repo" } }, + { name: "while the session location is unknown", location: undefined }, + ])("store writes made $name apply in order over the stored value", async (row) => { + const held = Promise.withResolvers() + const Items = Schema.Struct({ items: Schema.mutable(Schema.Array(Schema.String)) }) + const key = `extension-store-${crypto.randomUUID()}` + + const platform: Platform = { + platform: "desktop", + windowID: "extension-store-test", + openExternal: () => undefined, + restart: async () => undefined, + notify: async () => undefined, + openDirectoryPickerDialog: async () => null, + storage: () => ({ + getItem: async () => { + await held.promise + + return JSON.stringify({ items: ["stored"] }) + }, + setItem: async () => undefined, + removeItem: async () => undefined, + }), + } + + const [location, setLocation] = createSignal<{ directory: string } | undefined>(row.location) + const opened: Persisted<(typeof Items)["Type"]>[] = [] + + const root = createRoot((dispose) => { + const store = createSessionStore({ + open: () => { + const pair = persisted(Persist.global(key), Items, { items: [] }, platform) + + const handle = persistedHandle({ + store: pair[0], + update: (mutation: (draft: (typeof Items)["Type"]) => void) => pair[1](produce(mutation)), + ready: pair[3], + init: pair[3].promise, + }) + + opened.push(handle) + + return handle + }, + owner: null, + }) + + return { + dispose: () => { + store.dispose() + dispose() + }, + handle: store.get(session("server\nses_store", location)), + } + }) + + root.handle.update((draft) => void draft.items.push("first")) + root.handle.update((draft) => void draft.items.push("second")) + + const before = { value: root.handle.value, ready: root.handle.ready() } + + setLocation({ directory: "/repo" }) + held.resolve() + await Promise.all(opened.map(whenLoaded)) + expect({ before, after: root.handle.value?.items, ready: root.handle.ready() }).toEqual({ + before: { value: undefined, ready: false }, + after: ["stored", "first", "second"], + ready: true, + }) + root.dispose() + }) + + test("a layout write held while the session location is unknown runs once, in order, when it is known", () => { + const [location, setLocation] = createSignal<{ directory: string } | undefined>() + const target = session("server\nses_layout", location) + const log: string[] = [] + const root = createRoot((dispose) => ({ dispose, writes: createLocatedWrites() })) + + root.writes.hold(target, () => log.push("open")) + root.writes.hold(target, () => log.push("scroll")) + + const before = [...log] + + setLocation({ directory: "/repo" }) + setLocation({ directory: "/repo" }) + expect({ before, after: log }).toEqual({ before: [], after: ["open", "scroll"] }) + root.dispose() + }) +}) diff --git a/packages/desktop/src/main/extension/host.ts b/packages/desktop/src/main/extension/host.ts index 12def6ffc910..e7b0113d963f 100644 --- a/packages/desktop/src/main/extension/host.ts +++ b/packages/desktop/src/main/extension/host.ts @@ -11,8 +11,8 @@ import { type Caller, type Catalog, type Cleanup, - type Context, type Host, + type MainContext, type MainServer, type Messages, type OS, @@ -26,7 +26,7 @@ import { type Service, type Setup, } from "@opencode/gui-extensions/sdk/main" -import { Exit, Predicate, Schema } from "effect" +import { Exit, Match, Predicate, Schema } from "effect" import type { Accessor } from "solid-js" import type { ExtensionEndpoint, ExtensionInstalled } from "../../shared/ipc-rpc/extensions" import { @@ -52,41 +52,43 @@ import type { Database } from "../storage/database" import type { StateStore } from "../storage/state" import { getLastFocusedWindow, getMainWindows, onMainWindow } from "../windows" import { ExtensionError } from "./error" +import { createLifecycle, type Instance, type Log, type Revision } from "./lifecycle" import { createManager } from "./manager" import { evaluateMain } from "./module" import { createMainStorage, namespace } from "./storage" import { createSurfaces } from "./surfaces" -const CLEANUP_TIMEOUT_MS = 3_000 - export type ExtensionHost = ReturnType type Loaded = { readonly setup: Setup; readonly i18n?: Catalog } -type Method = (input: unknown, caller: Caller) => unknown + +/** A remote method with its spec erased: remotes of every spec share one table, and `call` runs the spec's codecs. */ +type Method = RemoteImpl[string] + +/** A value one of a remote's schemas governs, erased like the methods that take it. */ +type Value = Parameters[0] + type Provider = { readonly extension: string readonly signal: AbortSignal readonly spec: RemoteSpec readonly methods: ReadonlyMap - readonly state?: (window: number) => unknown - readonly listeners: Set<(name: string, data: unknown) => void> -} -type Entry = { readonly point: string; readonly extension: string; readonly value: unknown } -type Outcome = { readonly ok: true } | { readonly ok: false; readonly error: unknown } -type Instance = { - readonly context: Context - readonly catalog?: Catalog - messages: Messages - /** Runs setup once. The cleanup it returns belongs to the instance, even when setup settles after disposal. */ - start(setup: Setup): Promise - /** - * Withdraws everything the instance contributed at once, then settles once its cleanups and a setup still - * running have finished, or the timeout passed. Idempotent. - */ - dispose(): Promise + readonly state?: (window: number) => Value + readonly listeners: Set<(name: string, data: Value) => void> } -const os: OS = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux" +/** A contribution as `add` stores it, with its point's item type erased; `list` restores it. */ +type Item = Parameters[1] + +type Entry = { readonly point: string; readonly extension: string; readonly value: Item } + +type Translation = { readonly catalog?: Catalog; messages: Messages } + +const os = Match.value(process.platform).pipe( + Match.when("darwin", () => "macos" as const), + Match.when("win32", () => "windows" as const), + Match.orElse(() => "linux" as const), +) satisfies OS /** * The main-process GUI extension host. Every main extension is an app-scoped singleton; windows @@ -101,45 +103,47 @@ export function createHost(input: { /** The server endpoints each window last pushed. */ readonly servers: Map readonly restart: (handoff?: () => void | Promise) => Promise - readonly log: (message: string, data: Record) => void + readonly log: Log /** Extension logs at their own level. */ - readonly write: (level: "debug" | "info" | "warn" | "error", message: string, data: Record) => void + readonly write: >>( + level: "debug" | "info" | "warn" | "error", + message: string, + data: Data, + ) => void }) { const local = builtins.filter((definition) => !definition.os || definition.os.includes(os)) + const manager = createManager( input.db, (id) => id.startsWith("opencode") || builtins.some((definition) => definition.id === id), ) + const surfaces = createSurfaces() const entries = new Map() const services = new Map() const remotes = new Map() - const active = new Map() - // One serialized lifecycle per extension: each operation starts after the previous one fully settled. - const queues = new Map>() - // Each extension's current generation. Stopping aborts it, so the activations queued or running under it - // wind down at once; operations queued afterwards run under a fresh generation. - const generations = new Map() - const errors = new Map() + // Each live instance's catalog and messages, so a locale change reaches them. + const translations = new Set() const reloads = new Map() const closed = new Set<(win: BrowserWindow) => void>() const status = { sequence: 0, disposed: false, menubarQueued: false } - // Extensions running a restart handoff. Disposal skips them, so a failed handoff leaves them working and a - // successful one keeps their state and menu items until the app has quit. - const restarting = new Set() const broadcast = (event: DesktopEvent) => getMainWindows().forEach((win) => emitIpcEvent(win.webContents, event)) const changed = () => broadcast(new ExtensionsChanged({ list: installed() })) const subscribers = (remote: string, window?: number) => { const ids = input.subscriptions.get(remote) + if (!ids) return [] + return [...ids] .filter((id) => window === undefined || id === window) .flatMap((id) => { const win = BrowserWindow.fromId(id) + if (win && !win.isDestroyed()) return [win] ids.delete(id) + return [] }) } @@ -147,9 +151,11 @@ export function createHost(input: { const pushState = (remote: string, provider: Provider, window?: number) => { const schema = provider.spec.state const stateOf = provider.state + if (!schema || !stateOf) return subscribers(remote, window).forEach((win) => { const encoded = Schema.encodeUnknownExit(schema)(stateOf(win.id)) + if (Exit.isFailure(encoded)) return input.log("extension state encoding failed", { remote, cause: String(encoded.cause) }) emitIpcEvent(win.webContents, new ExtensionState({ remote, state: encoded.value })) @@ -160,23 +166,29 @@ export function createHost(input: { const items = [...entries.values()].flatMap((entry) => { if (entry.point !== Menubar.id) return [] const item = read(entry) + return isMenubar(item) ? [{ id: `${entry.extension}.${item.id}`, extension: entry.extension, item }] : [] }) + const published = new Set(items.map((item) => item.id)) + // `after` names a sibling from the same extension by its local id, or a built-in item. return items.map((entry) => { const after = entry.item.after const sibling = `${entry.extension}.${after}` + return { ...entry, after: after === undefined ? undefined : published.has(sibling) ? sibling : after } }) } const publishMenubar = () => { status.menubarQueued = false + if (status.disposed) return refreshMenu() broadcast(new ExtensionMenubarChanged({ items: menubar() })) } + const scheduleMenubar = () => { if (status.menubarQueued) return status.menubarQueued = true @@ -184,41 +196,52 @@ export function createHost(input: { } const menubar = (): BridgeMenubarItem[] => - menubarItems().map((entry) => ({ - menu: entry.item.menu, - id: entry.id, - label: entry.item.label, - ...(entry.after === undefined ? {} : { after: entry.after }), - enabled: entry.item.enabled?.() ?? true, - })) + menubarItems().map((entry) => { + const item = { + menu: entry.item.menu, + id: entry.id, + label: entry.item.label, + enabled: entry.item.enabled?.() ?? true, + } + + // The IPC schema takes `after` as an optional key: it is left out rather than undefined. + return entry.after === undefined ? item : { ...item, after: entry.after } + }) const server = (id: string): MainServer | undefined => { const sidecar = SidecarCredentials.get() + // The app's own server is known here first-hand; the renderer never holds its credential. if (id === "sidecar") { if (!sidecar) return undefined const authorization = SidecarCredentials.authorization(sidecar, sidecar.url) + return { id, url: sidecar.url, headers: authorization ? { authorization } : {}, local: true } } + const endpoint = [...input.servers] .reverse() .flatMap(([window, list]) => { if (BrowserWindow.fromId(window)) return list input.servers.delete(window) + return [] }) .find((item) => item.id === id) + if (!endpoint) return undefined + const authorization = endpoint.password ? `Basic ${Buffer.from(`${endpoint.username ?? "opencode"}:${endpoint.password}`).toString("base64")}` : SidecarCredentials.authorization(sidecar, endpoint.url) + return { id, url: endpoint.url, headers: authorization ? { authorization } : {}, local: !!sidecar && URL.canParse(endpoint.url) && new URL(endpoint.url).origin === sidecar.url, - ...(endpoint.username === undefined ? {} : { username: endpoint.username }), - ...(endpoint.password === undefined ? {} : { password: endpoint.password }), + username: endpoint.username, + password: endpoint.password, } } @@ -230,25 +253,35 @@ export function createHost(input: { log: (level, message, data) => input.write(level, message, data ?? {}), } satisfies Omit - const restart = (id: string, handoff?: () => void | Promise) => { - restarting.add(id) - return input.restart(handoff).finally(() => restarting.delete(id)) - } + const lifecycle = createLifecycle({ + loader: (id) => loader(id), + enabled: (id) => manager.enabled(id), + changed: () => changed(), + log: input.log, + }) - const loader = (id: string): (() => Promise) | undefined => { + const loader = (id: string): (() => Promise) | undefined => { const builtin = local.find((definition) => definition.id === id) + if (builtin) { const main = builtin.main + if (!main) return undefined - return () => main().then((module) => ({ setup: module.default, i18n: builtin.i18n })) + + return () => main().then((module) => prepare(id, { setup: module.default, i18n: builtin.i18n })) } + const manifest = manager.installed().find((item) => item.id === id)?.manifest const entry = manifest?.main + if (!manifest || !entry) return undefined + return () => { const source = manager.file(id, entry) + if (!source) return Promise.reject(new ExtensionError("invalidManifest")) - return evaluateMain(source.toString("utf8"), manifest.imports.main ?? []) + + return evaluateMain(source.toString("utf8"), manifest.imports.main ?? []).then((loaded) => prepare(id, loaded)) } } @@ -256,68 +289,31 @@ export function createHost(input: { const english = catalog?.en ?? {} const locale = nativeLocale() const source = locale === "en" ? undefined : catalog?.[locale] + if (!source) return english - const loaded = typeof source === "function" ? (await source()).default : source - return { ...english, ...loaded } - } + const loaded = Predicate.isFunction(source) ? (await source()).default : source - const fail = (id: string, error: unknown) => { - errors.set(id, error instanceof Error ? error.message : String(error)) - input.log("extension failed", { id, error }) - changed() - return undefined + return { ...english, ...loaded } } - const createInstance = (id: string, catalog: Catalog | undefined): Instance => { - const controller = new AbortController() - // The host's record of what the instance contributed; the host withdraws it the moment disposal starts. - const contributions = new Set<() => void>() - // The extension's own cleanups; they run after withdrawal, under a timeout. - const cleanups = new Set() - // Cleanups registered after disposal started, such as the one a late setup returns. - const late = new Set>() + /** A loaded revision of the extension's main code; each instance of it gets its own setup context. */ + const prepare = + (id: string, loaded: Loaded): Revision => + (instance) => + createContext(id, loaded, instance) + + const createContext = (id: string, loaded: Loaded, instance: Instance): ReturnType => { + const signal = instance.scope.signal + const contribute = instance.contribute + // Registered first, so it runs last: the instance's surfaces go after everything else it contributed. They are + // owned by the instance, so releasing them never touches a replacement's. + contribute(() => surfaces.releaseOwner(instance)) + const translation: Translation = { catalog: loaded.i18n, messages: loaded.i18n?.en ?? {} } + translations.add(translation) + contribute(() => { + translations.delete(translation) + }) const clients = new WeakMap() - // Owns this instance's native surfaces, so releasing them never touches a replacement's. - const owner = {} - const lifecycle: { setup: Promise; disposed?: Promise } = { setup: Promise.resolve() } - const run = (fn: Cleanup) => - Promise.resolve() - .then(fn) - .then( - () => undefined, - (error: unknown) => input.log("extension cleanup failed", { id, error }), - ) - const withdraw = (fn: () => void) => { - // Host bookkeeping does not throw by design; isolating it keeps one failure from leaving the rest behind. - try { - fn() - } catch (error) { - input.log("extension withdrawal failed", { id, error }) - } - } - const contribute = (fn: () => void): Cleanup => { - if (controller.signal.aborted) { - withdraw(fn) - return () => {} - } - const remove = () => { - if (contributions.delete(remove)) fn() - } - contributions.add(remove) - return remove - } - const own = (fn: Cleanup): Cleanup => { - // Registered after disposal started: it runs now, and disposal waits for it too. - if (controller.signal.aborted) { - late.add(run(fn)) - return () => {} - } - const cleanup = () => { - if (cleanups.delete(cleanup)) return fn() - } - cleanups.add(cleanup) - return cleanup - } const hosts = new Map([ [ @@ -329,6 +325,7 @@ export function createHost(input: { on: (event, handler) => { if (event === "open") return contribute(onMainWindow(handler)) closed.add(handler) + return contribute(() => { closed.delete(handler) }) @@ -339,85 +336,112 @@ export function createHost(input: { Surfaces.id, { create: (view, win) => { - const surface = surfaces.create(owner, view, win) + const surface = surfaces.create(instance, view, win) + // Created by a setup that outlived its instance: taken down at once, like any late contribution. - if (controller.signal.aborted) withdraw(surface.dispose) + if (signal.aborted) contribute(surface.dispose) + return surface }, } satisfies Surfaces, ], [MainStorage.id, createMainStorage(input.state, id)], [Cli.id, input.cli], - [MainApp.id, { ...mainApp, restart: (handoff) => restart(id, handoff) } satisfies MainApp], + [ + MainApp.id, + { + ...mainApp, + restart: (handoff, options) => + lifecycle.restart(options?.keep ?? instance.scope, () => input.restart(handoff)), + } satisfies MainApp, + ], ]) function add(point: Point, item: T | (() => T | undefined)): Cleanup { - if (controller.signal.aborted) return () => {} + if (signal.aborted) return () => {} + const key = `${id}/${++status.sequence}` entries.set(key, { point: point.id, extension: id, value: item }) + if (point.id === Menubar.id) scheduleMenubar() + return contribute(() => { if (entries.delete(key) && point.id === Menubar.id) scheduleMenubar() }) } function list(point: Point): readonly T[] { - // Items were added through the same typed point, so they are that point's type. return [...entries.values()].flatMap((entry) => { if (entry.point !== point.id) return [] const value = read(entry) + + // SAFETY: only `add` stores entries, under the id of the typed point it was given, so this point's items are T. return value === undefined ? [] : [value as T] }) } function provide(token: Service, impl: T): Cleanup function provide(token: Remote, impl: RemoteImpl): Provided - function provide(token: Service | Remote, impl: unknown): unknown { + function provide(token: Service | Remote, impl: T | RemoteImpl) { if (token.kind === "service") { - if (controller.signal.aborted) return () => {} + if (signal.aborted) return () => {} + const entry = { extension: id, impl } services.set(token.id, entry) + return contribute(() => { if (services.get(token.id) === entry) services.delete(token.id) }) } - return provideRemote(token, impl) + + // SAFETY: the overloads pair a Remote token only with a RemoteImpl; provideRemote checks each method at runtime. + return provideRemote(token, impl as RemoteImpl) } - const provideRemote = (token: Remote, impl: unknown): Provided => { + const provideRemote = (token: Remote, impl: RemoteImpl): Provided => { const remote = token.id const current = remotes.get(remote) + if (current && current.extension !== id) throw new Error(`Remote "${remote}" is already provided by ${current.extension}`) + const methods = new Map( Object.keys(token.spec.methods).map((name) => { const method = Predicate.hasProperty(impl, name) ? impl[name] : undefined + if (!isMethod(method)) throw new Error(`Remote "${remote}" is missing method "${name}"`) - return [name, (value: unknown, caller: Caller) => method.call(impl, value, caller)] as const + + return [name, (value: Value, caller: Caller) => method.call(impl, value, caller)] as const }), ) + const stateOf = Predicate.hasProperty(impl, "state") ? impl.state : undefined + const provider: Provider = { extension: id, - signal: controller.signal, + signal, spec: token.spec, methods, state: isState(stateOf) ? (window) => stateOf.call(impl, window) : undefined, listeners: new Set(), } + const live = () => remotes.get(remote) === provider - const dispose = controller.signal.aborted + + const dispose = signal.aborted ? () => {} : contribute(() => { if (!live()) return remotes.delete(remote) broadcast(new ExtensionAvailable({ remote, available: false })) }) - if (!controller.signal.aborted) { + + if (!signal.aborted) { remotes.set(remote, provider) broadcast(new ExtensionAvailable({ remote, available: true })) pushState(remote, provider) } + return { changed(window) { if (live()) pushState(remote, provider, window) @@ -425,14 +449,18 @@ export function createHost(input: { emit(name, data, window) { if (!live()) return const schema = token.spec.events?.[name] + if (!schema) throw new Error(`Remote "${remote}" has no event "${name}"`) provider.listeners.forEach((listener) => listener(name, data)) const encoded = Schema.encodeUnknownExit(schema)(data) + if (Exit.isFailure(encoded)) return input.log("extension event encoding failed", { remote, name, cause: String(encoded.cause) }) const event = new ExtensionEvent({ remote, name, data: encoded.value }) + if (window === undefined) return broadcast(event) const win = BrowserWindow.fromId(window) + if (win && !win.isDestroyed()) emitIpcEvent(win.webContents, event) }, dispose, @@ -442,19 +470,25 @@ export function createHost(input: { function use(token: Host): T function use(token: Service): Accessor function use(token: Remote): Accessor | undefined> - function use(token: Host | Service | Remote): unknown { + function use(token: Host | Service | Remote) { if (token.kind === "host") { if (!hosts.has(token.id)) throw new Error(`Host service "${token.id}" is unavailable in the main process`) + return hosts.get(token.id) } + if (token.kind === "service") return () => services.get(token.id)?.impl + return () => { const provider = remotes.get(token.id) + if (!provider) return undefined const cached = clients.get(provider) + if (cached) return cached const created = client(token.id, provider) clients.set(provider, created) + return created } } @@ -464,172 +498,62 @@ export function createHost(input: { ...Object.fromEntries( [...provider.methods].map(([name, method]) => [ name, - async (value: unknown, options?: { readonly signal?: AbortSignal }) => { + async (value: Value, options?: { readonly signal?: AbortSignal }) => { // A client kept past its provider's disposal reaches nothing. if (remotes.get(remote) !== provider) throw new ExtensionError("unavailable") + return method(value, { window: 0, - signal: AbortSignal.any([ - controller.signal, - provider.signal, - ...(options?.signal ? [options.signal] : []), - ]), + signal: AbortSignal.any([signal, provider.signal, ...(options?.signal ? [options.signal] : [])]), }) }, ]), ), state: () => provider.state?.(0), - on: (name: string, listener: (data: unknown) => void) => { - const handler = (event: string, data: unknown) => { + on: (name: string, listener: (data: Value) => void) => { + const handler = (event: string, data: Value) => { if (event === name) listener(data) } + provider.listeners.add(handler) + return contribute(() => { provider.listeners.delete(handler) }) }, }) - const instance: Instance = { - catalog, - messages: catalog?.en ?? {}, - context: { - id, - signal: controller.signal, - cleanup: own, - add, - list, - provide, - use, - t: (key: string, params?: Params) => - formatNativeTemplate(instance.messages[key] ?? nativeMessage(key) ?? key, params), - plural: (key: string, count: number, params?: Params) => { - const category = nativePluralCategory(count) - const template = - instance.messages[`${key}.${category}`] ?? - instance.messages[`${key}.other`] ?? - nativeMessage(`${key}.${category}`) ?? - nativeMessage(`${key}.other`) ?? - key - return formatNativeTemplate(template, { ...params, count }) - }, - }, - start(setup) { - const outcome = Promise.resolve() - .then(() => setup(instance.context)) - .then( - (cleanup): Outcome => { - // A setup that settles after disposal started has its cleanup run now; disposal waits for it. - if (typeof cleanup === "function") own(cleanup) - return { ok: true } - }, - (error: unknown): Outcome => ({ ok: false, error }), - ) - lifecycle.setup = outcome - return outcome - }, - dispose() { - if (lifecycle.disposed) return lifecycle.disposed - controller.abort() - // The host withdraws first and synchronously: a disposed instance is unreachable even if a cleanup hangs. - ;[...contributions].reverse().forEach(withdraw) - withdraw(() => surfaces.releaseOwner(owner)) - const timer = { id: undefined as ReturnType | undefined } - const deadline = new Promise((resolve) => { - timer.id = setTimeout(() => { - input.log("extension cleanup timed out", { id }) - resolve() - }, CLEANUP_TIMEOUT_MS) - }) - // Cleanups run in reverse order; past the deadline the rest start without waiting, so a hung one - // holds up neither the others nor the extension's lifecycle. - const released = [...cleanups] - .reverse() - .reduce( - (chain: Promise, cleanup) => chain.then(() => Promise.race([run(cleanup), deadline])), - Promise.resolve(), - ) - // A setup still running settles, and the cleanups it registers late run, before the next lifecycle step. - const settled = Promise.race([lifecycle.setup.then(() => Promise.all(late)), deadline]) - lifecycle.disposed = Promise.all([released, settled]).then(() => clearTimeout(timer.id)) - return lifecycle.disposed + const context: MainContext = { + id, + signal, + scope: instance.scope, + cleanup: (fn) => instance.scope.addFinalizer(fn), + add, + list, + provide, + use, + t: (key: string, params?: Params) => + formatNativeTemplate(translation.messages[key] ?? nativeMessage(key) ?? key, params), + plural: (key: string, count: number, params?: Params) => { + const category = nativePluralCategory(count) + + const template = + translation.messages[`${key}.${category}`] ?? + translation.messages[`${key}.other`] ?? + nativeMessage(`${key}.${category}`) ?? + nativeMessage(`${key}.other`) ?? + key + + return formatNativeTemplate(template, { ...params, count }) }, } - return instance - } - - // The caller sees the step's own outcome; the queue only waits for it to settle, so a failed step never - // stalls the steps behind it. - const enqueue = (id: string, task: () => Promise) => { - const step = (queues.get(id) ?? Promise.resolve()).then(task).then(() => undefined) - const tail: Promise = step - .catch(() => undefined) - .finally(() => { - if (queues.get(id) === tail) queues.delete(id) - }) - queues.set(id, tail) - return step - } - - const generation = (id: string) => { - const current = generations.get(id) - if (current) return current.signal - const created = new AbortController() - generations.set(id, created) - return created.signal - } - const activate = (id: string) => { - const signal = generation(id) - return enqueue(id, () => launch(id, signal)) - } - - const launch = async (id: string, signal: AbortSignal) => { - const load = loader(id) - const current = () => !signal.aborted && manager.enabled(id) && !status.disposed - if (!load || active.has(id) || !current()) return - errors.delete(id) - const loaded = await until( - signal, - load().catch((error: unknown) => (current() ? fail(id, error) : undefined)), - ) - // Stopped, disabled, or quitting while the code loaded: drop it, it may belong to an older revision. - if (!loaded || active.has(id) || !current()) return - const instance = createInstance(id, loaded.i18n) - active.set(id, instance) - instance.messages = - (await until( - signal, - resolveMessages(loaded.i18n).catch(() => instance.messages), - )) ?? instance.messages - // Stopping removed the instance and began disposing it; its setup never runs. - if (!current()) { - if (active.get(id) === instance) active.delete(id) - return instance.dispose() + return { + ready: resolveMessages(loaded.i18n).then((messages) => { + translation.messages = messages + }), + setup: () => loaded.setup(context), } - // A stop during setup settles this step once the disposal has, however long setup takes. - const outcome = await Promise.race([instance.start(loaded.setup), aborted(signal).then(() => instance.dispose())]) - if (!outcome || outcome.ok || active.get(id) !== instance) return - fail(id, outcome.error) - active.delete(id) - await instance.dispose() - } - - /** - * Stops the extension at once and queues its teardown: activations queued before it go stale, the instance - * withdraws everything it contributed now, and the queue moves on only after its disposal settled. `after` - * runs inside the queue once the teardown finished. - */ - const deactivate = (id: string, after?: () => void) => { - generations.get(id)?.abort() - generations.delete(id) - const instance = active.get(id) - active.delete(id) - void instance?.dispose() - return enqueue(id, async () => { - await instance?.dispose() - after?.() - }) } const known = (id: string) => @@ -644,7 +568,7 @@ export function createHost(input: { builtin: true, enabled: manager.enabled(definition.id), ...revision(reloads.get(definition.id)?.toString()), - ...failure(errors.get(definition.id)), + ...failure(lifecycle.failure(definition.id)), })), ...manager.installed().map((item) => ({ id: item.id, @@ -653,7 +577,7 @@ export function createHost(input: { builtin: false, enabled: item.enabled, ...revision(item.revision), - ...failure(item.manifest ? errors.get(item.id) : "invalidManifest"), + ...failure(item.manifest ? lifecycle.failure(item.id) : "invalidManifest"), })), ] @@ -676,15 +600,17 @@ export function createHost(input: { closed.forEach((listener) => listener(win)) }) } + getMainWindows().forEach(wire) const stopWindows = onMainWindow(wire) const stopLocale = onNativeTranslations(() => { const locale = nativeLocale() void Promise.all( - [...active.values()].map(async (instance) => { - const messages = await resolveMessages(instance.catalog).catch(() => instance.catalog?.en ?? {}) - if (nativeLocale() === locale) instance.messages = messages + [...translations].map(async (translation) => { + const messages = await resolveMessages(translation.catalog).catch(() => translation.catalog?.en ?? {}) + + if (nativeLocale() === locale) translation.messages = messages }), ).then(scheduleMenubar) }) @@ -704,22 +630,27 @@ export function createHost(input: { /** Activates every enabled main extension. The app calls this once its first window is up. */ async start() { const ids = [...local.map((definition) => definition.id), ...manager.installed().map((item) => item.id)] - await Promise.all(ids.map(activate)) + await Promise.all(ids.map((id) => lifecycle.activate(id))) }, /** Activates the extension a remote belongs to ahead of `start`. Remote ids start with their extension's id. */ demand(remote: string) { if (remotes.has(remote)) return + const id = [...local.map((definition) => definition.id), ...manager.installed().map((item) => item.id)].find( (id) => remote === id || remote.startsWith(`${id}.`), ) - if (id) void activate(id) + + if (id) void lifecycle.activate(id) }, - snapshot(remote: string, window: number): { available: boolean; state?: unknown } { + snapshot(remote: string, window: number) { const provider = remotes.get(remote) + if (!provider) return { available: false } const schema = provider.spec.state const stateOf = provider.state + if (!schema || !stateOf) return { available: true } + return { available: true, state: Schema.encodeUnknownSync(schema)(stateOf(window)) } }, async call( @@ -727,24 +658,31 @@ export function createHost(input: { caller: Caller, ) { const provider = remotes.get(request.remote) + if (!provider) throw new ExtensionError("unavailable") const method = provider.methods.get(request.method) const spec = method ? provider.spec.methods[request.method] : undefined + if (!method || !spec) throw new ExtensionError("method") + const value = spec.input - ? await Schema.decodeUnknownPromise(spec.input)(request.input).catch((error: unknown) => { - throw new ExtensionError("input", { cause: error, message: String(error) }) + ? await Schema.decodeUnknownPromise(spec.input)(request.input).catch((cause: unknown) => { + throw new ExtensionError("input", { cause, message: String(cause) }) }) : undefined + // Withdrawn while the input decoded: the disposed instance is not called. if (remotes.get(request.remote) !== provider) throw new ExtensionError("unavailable") + const output = await method(value, { window: caller.window, signal: AbortSignal.any([caller.signal, provider.signal]), }) + if (!spec.output) return undefined - return Schema.encodeUnknownPromise(spec.output)(output).catch((error: unknown) => { - throw new ExtensionError("output", { cause: error, message: String(error) }) + + return Schema.encodeUnknownPromise(spec.output)(output).catch((cause: unknown) => { + throw new ExtensionError("output", { cause, message: String(cause) }) }) }, surface: (window: number, id: string, layout?: BridgeLayout) => surfaces.layout(window, id, layout), @@ -753,6 +691,7 @@ export function createHost(input: { menubar, runMenubar(window: number, id: string) { const entry = menubarItems().find((item) => item.id === id) + if (!entry || !(entry.item.enabled?.() ?? true)) return entry.item.run(BrowserWindow.fromId(window) ?? undefined) }, @@ -761,101 +700,89 @@ export function createHost(input: { if (!known(id)) throw new ExtensionError("notFound") manager.setEnabled(id, true) changed() - await activate(id) + await lifecycle.activate(id) }, async disable(id: string) { if (!known(id)) throw new ExtensionError("notFound") manager.setEnabled(id, false) - await deactivate(id) + await lifecycle.deactivate(id) changed() }, + /** A development tool: packaged builds refuse it. A reload that fails keeps the last good revision running. */ async reload(id: string) { if (!known(id)) throw new ExtensionError("notFound") + if (local.some((definition) => definition.id === id)) reloads.set(id, (reloads.get(id) ?? 0) + 1) else manager.bump(id) - errors.delete(id) - await deactivate(id) + await lifecycle.reload(id) changed() - await activate(id) }, async install(source: Uint8Array | string) { const manifest = await manager.install(source) - errors.delete(manifest.id) - await deactivate(manifest.id) + lifecycle.forget(manifest.id) + await lifecycle.deactivate(manifest.id) changed() - await activate(manifest.id) + await lifecycle.activate(manifest.id) }, async remove(id: string) { if (local.some((definition) => definition.id === id)) throw new ExtensionError("builtin") + if (!known(id)) throw new ExtensionError("notFound") // Inside the queue, so an activation queued meanwhile finds the extension already gone. - await deactivate(id, () => { + await lifecycle.deactivate(id, () => { manager.remove(id) input.state.clear(namespace(id)) - errors.delete(id) + lifecycle.forget(id) }) changed() }, source(id: string) { const manifest = manager.installed().find((item) => item.id === id)?.manifest + if (!manifest) throw new ExtensionError("notFound") const file = manager.file(id, manifest.renderer) + if (!file) throw new ExtensionError("notFound") + return file.toString("utf8") }, - /** Disposes every main extension but one running a restart handoff; quitting awaits their async cleanups. */ + /** Disposes every main extension but one a restart handoff keeps; quitting awaits their async cleanups. */ async dispose() { status.disposed = true stopWindows() stopLocale() - // Every other extension with an instance or a queued step stops; quitting waits for each lifecycle to settle. - // The menu is not rebuilt from here on (`publishMenubar`), so a restarting extension's items stay in it. - await Promise.all( - [...new Set([...active.keys(), ...queues.keys()])] - .filter((id) => !restarting.has(id)) - .map((id) => deactivate(id)), - ) + // The menu is not rebuilt from here on (`publishMenubar`), so a kept extension's items stay in it. + await lifecycle.dispose() }, } } -/** The promise's value, or undefined as soon as the signal aborts; the promise itself keeps running. */ -function until(signal: AbortSignal, promise: Promise) { - return Promise.race([promise, aborted(signal)]) -} - -function aborted(signal: AbortSignal) { - return new Promise((resolve) => { - if (signal.aborted) return resolve(undefined) - signal.addEventListener("abort", () => resolve(undefined), { once: true }) - }) -} - function read(entry: Entry) { return isGetter(entry.value) ? entry.value() : entry.value } -function isGetter(value: unknown): value is () => unknown { - return typeof value === "function" +// Installed extensions are plain JavaScript: these check the shapes the SDK types promise before the host relies on them. +function isGetter(value: Item): value is () => Item { + return Predicate.isFunction(value) } function isMethod(value: unknown): value is Method { - return typeof value === "function" + return Predicate.isFunction(value) } -function isState(value: unknown): value is (window: number) => unknown { - return typeof value === "function" +function isState(value: unknown): value is (window: number) => Value { + return Predicate.isFunction(value) } -function isMenubar(value: unknown): value is Menubar { +function isMenubar(value: Item): value is Menubar { return ( Predicate.hasProperty(value, "id") && Predicate.hasProperty(value, "menu") && Predicate.hasProperty(value, "label") && Predicate.hasProperty(value, "run") && - typeof value.id === "string" && - typeof value.label === "string" && - typeof value.run === "function" + Predicate.isString(value.id) && + Predicate.isString(value.label) && + Predicate.isFunction(value.run) ) } diff --git a/packages/desktop/src/main/extension/lifecycle.test.ts b/packages/desktop/src/main/extension/lifecycle.test.ts new file mode 100644 index 000000000000..ed5ac773ce8a --- /dev/null +++ b/packages/desktop/src/main/extension/lifecycle.test.ts @@ -0,0 +1,617 @@ +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test" +import { createLifecycle, type Instance, type Revision } from "./lifecycle" + +// The host's cleanup deadline and setup stall log, as `lifecycle.ts` sets them. +const CLEANUP_MS = 3_000 + +const STALL_MS = 10_000 + +type Wait = number | "hang" + +type Plan = { + readonly load?: Wait + readonly loadFails?: boolean + readonly ready?: Wait + readonly setup?: Wait + readonly setupFails?: boolean + /** The finalizer setup adds before its await. */ + readonly cleanup?: Wait + /** The cleanup setup returns. */ + readonly returned?: Wait +} + +type Made = { readonly id: string; readonly revision: string; readonly label: string; readonly instance: Instance } + +type Contribution = { readonly made: Made; readonly kind: "remote" | "listener" | "surface"; readonly window?: number } + +beforeEach(() => jest.useFakeTimers()) + +afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() +}) + +// Fake timers leave setImmediate real: one turn drains every pending microtask. +const flush = () => new Promise((resolve) => setImmediate(resolve)) + +const advance = async (ms: number, step = 10) => { + for (let left = ms; left > 0; left -= step) { + jest.advanceTimersByTime(Math.min(step, left)) + await flush() + } +} + +const wait = (value: Wait | undefined) => { + if (value === "hang") return new Promise(() => {}) + + if (!value) return Promise.resolve() + + return new Promise((resolve) => setTimeout(resolve, value)) +} + +/** + * A fake host around the real lifecycle: extensions whose code loads, prepares, sets up and cleans up on a plan, and + * a registry of what each instance contributed, including surfaces in windows that open and close. + */ +function world() { + const logs: { readonly message: string; readonly data: object }[] = [] + const events: string[] = [] + const enabled = new Map() + const revisions = new Map() + const versions = new Map() + const made: Made[] = [] + const contributions = new Set() + const violations: string[] = [] + const windows = new Set() + const openers = new Set<(window: number) => void>() + const counter = { window: 0 } + // Setups that finished after their instance stopped, and those that did while a replacement ran. + const stats = { late: 0, overlapping: 0 } + /** The instances neither stopped nor stopping. */ + const live = (id?: string) => made.filter((item) => !item.instance.scope.signal.aborted && (!id || item.id === id)) + + const contribute = (owner: Made, kind: Contribution["kind"], window?: number, withdraw?: () => void) => { + const entry: Contribution = { made: owner, kind, window } + contributions.add(entry) + owner.instance.contribute(() => { + // Only the owner's own disposal withdraws what it contributed. + if (!owner.instance.scope.signal.aborted) violations.push(`${kind} of live ${owner.label} withdrawn`) + contributions.delete(entry) + withdraw?.() + }) + } + + const revision = + (id: string, name: string, plan: Plan): Revision => + (instance) => { + const owner: Made = { id, revision: name, label: `${name}#${made.length + 1}`, instance } + made.push(owner) + + return { + ready: wait(plan.ready), + setup: async () => { + events.push(`setup ${owner.label}`) + contribute(owner, "remote") + instance.scope.addFinalizer(async () => { + events.push(`cleanup ${owner.label}`) + await wait(plan.cleanup) + events.push(`cleanup ${owner.label} done`) + }) + await wait(plan.setup) + + if (plan.setupFails) throw new Error(`${owner.label} setup failed`) + + if (instance.scope.signal.aborted) stats.late++ + + if (instance.scope.signal.aborted && live(id).length) stats.overlapping++ + // Registered after an await without checking the signal: a stopped instance withdraws them at once. + windows.forEach((window) => contribute(owner, "surface", window)) + const opener = (window: number) => contribute(owner, "surface", window) + openers.add(opener) + contribute(owner, "listener", undefined, () => openers.delete(opener)) + events.push(`ready ${owner.label}`) + + return async () => { + events.push(`returned ${owner.label}`) + await wait(plan.returned) + events.push(`returned ${owner.label} done`) + } + }, + } + } + + const lifecycle = createLifecycle({ + loader: (id) => { + const current = revisions.get(id) + + if (!current) return undefined + + return () => + wait(current.plan.load).then(() => { + if (current.plan.loadFails) throw new Error(`${current.name} load failed`) + + return revision(id, current.name, current.plan) + }) + }, + enabled: (id) => enabled.get(id) ?? true, + changed: () => {}, + log: (message, data) => logs.push({ message, data }), + }) + + /** Makes `plan` the extension's current code under a new revision name: a1, a2, … */ + const revise = (id: string, plan: Plan = {}) => { + const version = (versions.get(id) ?? 0) + 1 + versions.set(id, version) + revisions.set(id, { name: `${id}${version}`, plan }) + + return `${id}${version}` + } + + return { + lifecycle, + logs, + stats, + events, + made, + contributions, + violations, + enabled, + revise, + live, + open() { + const window = ++counter.window + windows.add(window) + ;[...openers].forEach((opener) => opener(window)) + + return window + }, + close(window: number) { + windows.delete(window) + // The host releases a closed window's surfaces itself, whichever instance owns them. + contributions.forEach((entry) => { + if (entry.window === window) contributions.delete(entry) + }) + }, + // The host's `install` and `remove` around the lifecycle. + async install(id: string, plan: Plan = {}) { + revise(id, plan) + enabled.set(id, true) + lifecycle.forget(id) + await lifecycle.deactivate(id) + await lifecycle.activate(id) + }, + uninstall(id: string) { + return lifecycle.deactivate(id, () => { + revisions.delete(id) + lifecycle.forget(id) + }) + }, + } +} + +type World = ReturnType + +const settled = (promise: Promise) => { + const state = { settled: false, rejected: false } + void promise.then( + () => (state.settled = true), + () => { + state.settled = true + state.rejected = true + }, + ) + + return state +} + +/** The invariants every lifecycle state keeps. */ +function check(w: World) { + const counts = Map.groupBy(w.live(), (item) => item.id) + counts.forEach((items, id) => { + if (items.length > 1) throw new Error(`${items.length} live instances of ${id}: ${items.map((i) => i.label)}`) + }) + w.contributions.forEach((entry) => { + if (entry.made.instance.scope.signal.aborted) + throw new Error(`${entry.kind} of stopped ${entry.made.label} is still contributed`) + }) + + if (w.violations.length) throw new Error(w.violations.join("; ")) +} + +describe("extension lifecycle contracts", () => { + test.each([ + { + name: "reload", + stop: (w: World) => { + w.revise("a") + + return w.lifecycle.reload("a") + }, + }, + { + name: "disable then enable", + stop: (w: World) => { + w.enabled.set("a", false) + void w.lifecycle.deactivate("a") + w.enabled.set("a", true) + + return w.lifecycle.activate("a") + }, + }, + { name: "reinstall", stop: (w: World) => w.install("a") }, + ])("$name during async setup stops the instance at once and starts the next after its late cleanup", async (row) => { + const w = world() + w.revise("a", { setup: 50, returned: 30 }) + void w.lifecycle.activate("a") + await advance(10) + expect(w.events).toEqual(["setup a1#1"]) + const done = settled(row.stop(w)) + // Withdrawn synchronously, before any cleanup runs. + expect(w.live("a")).toEqual([]) + expect(w.contributions.size).toBe(0) + await advance(200) + check(w) + expect(done.settled).toBe(true) + const next = w.made[1] + expect(w.live("a")).toEqual([next]) + // The setup that outlived its instance contributed nothing that stayed, and its returned cleanup finished + // before the next instance's setup started. + expect(w.events).toEqual([ + "setup a1#1", + "cleanup a1#1", + "cleanup a1#1 done", + "ready a1#1", + "returned a1#1", + "returned a1#1 done", + `setup ${next.label}`, + `ready ${next.label}`, + ]) + }) + + test("operations on one extension run in call order, each after the previous one settled", async () => { + const w = world() + w.revise("a", { cleanup: 40 }) + void w.lifecycle.activate("a") + await advance(10) + w.enabled.set("a", false) + const disabled = settled(w.lifecycle.deactivate("a")) + w.enabled.set("a", true) + const enabled = settled(w.lifecycle.activate("a")) + await advance(30) + expect(disabled.settled).toBe(false) + expect(w.made).toHaveLength(1) + await advance(20) + expect(disabled.settled).toBe(true) + expect(enabled.settled).toBe(true) + expect(w.events).toEqual([ + "setup a1#1", + "ready a1#1", + "returned a1#1", + "returned a1#1 done", + "cleanup a1#1", + "cleanup a1#1 done", + "setup a1#2", + "ready a1#2", + ]) + }) + + test("a hung cleanup holds the queue only until the deadline", async () => { + const w = world() + w.revise("a", { cleanup: "hang" }) + void w.lifecycle.activate("a") + await advance(10) + w.enabled.set("a", false) + const disabled = settled(w.lifecycle.deactivate("a")) + w.enabled.set("a", true) + void w.lifecycle.activate("a") + await advance(CLEANUP_MS - 20) + expect(disabled.settled).toBe(false) + expect(w.made).toHaveLength(1) + await advance(20) + expect(disabled.settled).toBe(true) + expect(w.live("a").map((item) => item.label)).toEqual(["a1#2"]) + expect(w.logs.map((entry) => entry.message)).toContain("scope finalizer timed out") + }) + + test.each([ + { phase: "loading", plan: { load: "hang" }, setup: false }, + { phase: "preparing", plan: { ready: "hang" }, setup: false }, + { phase: "setting up", plan: { setup: "hang" }, setup: true }, + { phase: "an activation waits behind a hung setup", plan: { setup: "hang" }, queued: true, setup: true }, + { phase: "tearing down", plan: { cleanup: "hang" }, teardown: true, setup: true }, + ] satisfies { phase: string; plan: Plan; setup: boolean; queued?: boolean; teardown?: boolean }[])( + "quitting while $phase settles by the deadline and leaves nothing behind", + async (row) => { + const w = world() + w.revise("a", row.plan) + w.revise("b") + void w.lifecycle.activate("a") + void w.lifecycle.activate("b") + await advance(10) + + if (row.queued) void w.lifecycle.activate("a") + + if (row.teardown) void w.lifecycle.deactivate("a") + const quit = settled(w.lifecycle.dispose()) + expect(w.live()).toEqual([]) + expect(w.contributions.size).toBe(0) + await advance(CLEANUP_MS + 20) + expect(quit.settled).toBe(true) + expect(w.events.includes("setup a1#1")).toBe(row.setup) + check(w) + }, + ) + + test("a restart handoff keeps its scope's instance through quitting until the handoff settles", async () => { + const w = world() + w.revise("a") + w.revise("b") + await Promise.all([w.lifecycle.activate("a"), w.lifecycle.activate("b")]) + const [a, b] = w.made + const handoff = { reject: (_: Error) => {} } + + const restart = settled( + w.lifecycle.restart(a.instance.scope, async () => { + await w.lifecycle.dispose() + await new Promise((_, reject) => (handoff.reject = reject)) + }), + ) + + await advance(CLEANUP_MS) + expect(w.live()).toEqual([a]) + expect([...w.contributions].filter((entry) => entry.made === b)).toEqual([]) + expect([...w.contributions].some((entry) => entry.made === a)).toBe(true) + handoff.reject(new Error("install failed")) + await advance(10) + expect(restart).toEqual({ settled: true, rejected: true }) + expect(w.live()).toEqual([a]) + // Once the handoff settled, quitting stops it like any other. + const quit = settled(w.lifecycle.dispose()) + await advance(10) + expect(quit.settled).toBe(true) + expect(w.live()).toEqual([]) + }) + + test("a restart handoff rejects a scope that is not an instance's", async () => { + const w = world() + w.revise("a") + await w.lifecycle.activate("a") + const ran = { handoff: false } + const fork = w.made[0].instance.scope.fork("handoff") + const restart = settled(w.lifecycle.restart(fork, async () => void (ran.handoff = true))) + await advance(10) + expect(restart).toEqual({ settled: true, rejected: true }) + expect(ran.handoff).toBe(false) + }) + + test.each([ + { name: "fails to load", plan: { loadFails: true }, live: "a1#1", failed: true }, + { name: "fails to set up", plan: { setupFails: true }, live: "a1#3", failed: true }, + { name: "succeeds", plan: {}, live: "a2#2", failed: false }, + ] satisfies { name: string; plan: Plan; live: string; failed: boolean }[])( + "a reload that $name leaves the last good revision running", + async (row) => { + const w = world() + w.revise("a") + await w.lifecycle.activate("a") + const previous = w.made[0] + w.revise("a", row.plan) + await w.lifecycle.reload("a") + check(w) + expect(w.live("a").map((item) => item.label)).toEqual([row.live]) + // The same instance when the new code failed to load: it kept running through the reload. + expect(previous.instance.scope.signal.aborted).toBe(row.live !== previous.label) + expect(w.lifecycle.failure("a") !== undefined).toBe(row.failed) + }, + ) + + test("a failed reload with no good revision leaves the extension stopped and failed", async () => { + const w = world() + w.revise("a", { setupFails: true }) + await w.lifecycle.activate("a") + w.revise("a", { setupFails: true }) + await w.lifecycle.reload("a") + expect(w.live("a")).toEqual([]) + expect(w.lifecycle.failure("a")).toBe("a2#2 setup failed") + }) + + test.each([ + { name: "a setup that hangs is logged once", setup: "hang", logged: 1 }, + { name: "a setup that finishes before the stall deadline is not logged", setup: STALL_MS - 1_000, logged: 0 }, + ] satisfies { name: string; setup: Wait; logged: number }[])("$name", async (row) => { + const w = world() + w.revise("a", { setup: row.setup }) + void w.lifecycle.activate("a") + await advance(STALL_MS * 2, 500) + expect(w.logs.filter((entry) => entry.message === "extension setup stalled")).toEqual( + Array.from({ length: row.logged }, () => ({ + message: "extension setup stalled", + data: { id: "a", ms: STALL_MS }, + })), + ) + }) +}) + +/** A deterministic random source, so a failing seed replays. */ +function random(seed: number) { + const state = { value: seed >>> 0 } + + const next = () => { + state.value = (state.value + 0x6d2b79f5) >>> 0 + const mixed = Math.imul(state.value ^ (state.value >>> 15), 1 | state.value) + const spread = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed + + return ((spread ^ (spread >>> 14)) >>> 0) / 4294967296 + } + + return { + next, + chance: (p: number) => next() < p, + int: (max: number) => Math.floor(next() * max), + pick: (items: readonly T[]) => items[Math.floor(next() * items.length)], + } +} + +type Random = ReturnType + +// Mostly short; sometimes it hangs, or outlasts the cleanup deadline and then finishes while a replacement runs. +const delay = (rng: Random, hang: number, max: number): Wait => { + if (rng.chance(hang)) return "hang" + + if (rng.chance(0.08)) return CLEANUP_MS + rng.int(CLEANUP_MS) + + return rng.chance(0.3) ? 0 : 1 + rng.int(max) +} + +const plan = (rng: Random): Plan => ({ + load: delay(rng, 0.04, 40), + loadFails: rng.chance(0.12), + ready: delay(rng, 0.03, 20), + setup: delay(rng, 0.05, 60), + setupFails: rng.chance(0.12), + cleanup: delay(rng, 0.1, 50), + returned: delay(rng, 0.05, 30), +}) + +const SEEDS = 1_000 + +const STEPS = 40 + +const IDS = ["a", "b", "c"] + +/** How often the runs reached the cases the invariants are about, so a generator change cannot make them vacuous. */ +const reached = { failedReloads: 0, lateSetups: 0, overlappingSetups: 0, hungFinalizers: 0, stalls: 0 } + +async function fuzz(seed: number, trace: string[]) { + const rng = random(seed) + const w = world() + const pending: { readonly op: string; readonly state: { settled: boolean } }[] = [] + // Operations that stop or replace an extension; a reload checked against the last good revision must be the latest. + const superseded = new Map() + const model = { quitting: false } + + const run = (op: string, promise: Promise) => { + trace.push(op) + pending.push({ op, state: settled(promise) }) + } + + const supersede = (id: string) => superseded.set(id, (superseded.get(id) ?? 0) + 1) + IDS.forEach((id) => w.revise(id, plan(rng))) + + const ops = { + activate: (id) => run(`activate ${id}`, w.lifecycle.activate(id)), + enable: (id) => { + w.enabled.set(id, true) + run(`enable ${id}`, w.lifecycle.activate(id)) + }, + disable: (id) => { + supersede(id) + w.enabled.set(id, false) + run(`disable ${id}`, w.lifecycle.deactivate(id)) + }, + reload: (id) => { + supersede(id) + const next = plan(rng) + const name = w.revise(id, next) + const marker = superseded.get(id) + // The live instance whose setup finished: the revision a failed reload must leave running. + const good = w.live(id).find((item) => w.events.includes(`ready ${item.label}`)) + run( + `reload ${id} -> ${name} ${JSON.stringify(next)}`, + w.lifecycle.reload(id).then(() => { + const stale = superseded.get(id) !== marker || model.quitting || w.enabled.get(id) === false + + if (stale || !good || !(next.loadFails || next.setupFails)) return + reached.failedReloads++ + + if (!w.live(id).some((item) => item.revision === good.revision)) + w.violations.push(`failed reload to ${name} left no instance of ${good.revision} running`) + + if (w.lifecycle.failure(id) === undefined) w.violations.push(`failed reload to ${name} recorded nothing`) + }), + ) + }, + install: (id) => { + supersede(id) + run(`install ${id}`, w.install(id, plan(rng))) + }, + remove: (id) => { + supersede(id) + run(`remove ${id}`, w.uninstall(id)) + }, + open: () => trace.push(`open window ${w.open()}`), + close: () => { + const window = rng.pick([...new Set([...w.contributions].flatMap((entry) => entry.window ?? []))]) + + if (window === undefined) return + trace.push(`close window ${window}`) + w.close(window) + }, + quit: () => { + model.quitting = true + IDS.forEach(supersede) + run("quit", w.lifecycle.dispose()) + }, + wait: () => {}, + } satisfies Record void> + + const weights: [keyof typeof ops, number][] = [ + ["activate", 2], + ["enable", 1.5], + ["disable", 1.5], + ["reload", 3], + ["install", 0.7], + ["remove", 0.7], + ["open", 1], + ["close", 1], + ["quit", 0.05], + ["wait", 2], + ] + + const bounds = weights.map(([op], index) => ({ + op, + below: weights.slice(0, index + 1).reduce((sum, [, weight]) => sum + weight, 0), + })) + + const choose = () => { + const target = rng.next() * bounds[bounds.length - 1].below + + return bounds.find((bound) => target < bound.below)?.op ?? "wait" + } + + for (let step = 0; step < STEPS; step++) { + ops[choose()](rng.pick(IDS)) + check(w) + const long = rng.chance(0.1) + await advance(long ? CLEANUP_MS + 500 : rng.int(120), long ? 250 : 10) + check(w) + } + + // Quitting settles by the cleanup deadline, whatever hangs, and so does every operation still queued. + ops.quit() + await advance(CLEANUP_MS + 200, 20) + const stuck = pending.flatMap((item) => (item.state.settled ? [] : [item.op])) + + if (stuck.length) throw new Error(`still pending ${CLEANUP_MS + 200}ms after quit: ${stuck.join(", ")}`) + check(w) + + if (w.live().length) throw new Error(`live after quit: ${w.live().map((item) => item.label)}`) + + if (w.contributions.size) throw new Error(`${w.contributions.size} contributions left after quit`) + reached.lateSetups += w.stats.late + reached.overlappingSetups += w.stats.overlapping + reached.hungFinalizers += w.logs.filter((entry) => entry.message === "scope finalizer timed out").length + reached.stalls += w.logs.filter((entry) => entry.message === "extension setup stalled").length +} + +test(`lifecycle invariants hold over ${SEEDS} random operation sequences`, async () => { + for (let seed = 1; seed <= SEEDS; seed++) { + const trace: string[] = [] + await fuzz(seed, trace).catch((cause: unknown) => { + throw new Error(`seed ${seed}: ${cause instanceof Error ? cause.message : String(cause)}\n${trace.join("\n")}`) + }) + jest.clearAllTimers() + } + + Object.values(reached).forEach((count) => expect(count).toBeGreaterThan(20)) +}) diff --git a/packages/desktop/src/main/extension/lifecycle.ts b/packages/desktop/src/main/extension/lifecycle.ts new file mode 100644 index 000000000000..f8a941d4c392 --- /dev/null +++ b/packages/desktop/src/main/extension/lifecycle.ts @@ -0,0 +1,393 @@ +import { Scope, type Cleanup } from "@opencode/gui-extensions/sdk/main" +import { Predicate } from "effect" + +/** How long disposal waits for an instance's finalizers and a setup still running. */ +const CLEANUP_TIMEOUT_MS = 3_000 + +/** A setup still running after this long is logged once. */ +const SETUP_STALL_MS = 10_000 + +/** One running copy of an extension's main code, from setup until it is stopped. */ +export interface Instance { + readonly id: string + /** Closes when the instance goes away, right after the host withdrew what the instance contributed. */ + readonly scope: Scope + /** + * Records something the host registered for the instance. The host withdraws it synchronously the moment disposal + * starts, before any finalizer runs, and at once when registered after that. The function returned withdraws it early. + */ + contribute(withdraw: () => void): Cleanup +} + +/** One loaded revision of an extension's main code. Each call prepares a fresh instance of it. */ +export type Revision = (instance: Instance) => { + /** Settles before setup runs; setup never runs when the extension stops first. A failure here does not stop setup. */ + readonly ready: Promise + readonly setup: () => void | Cleanup | Promise +} + +/** Writes a structured log entry; the logger serializes each field of `data` as it is. */ +export type Log = >>(message: string, data: Data) => void + +type Outcome = { readonly ok: true } | { readonly ok: false; readonly error: unknown } + +/** Where an instance's setup and disposal stand. */ +type Progress = { setup: Promise; disposed?: Promise } + +type Running = { + readonly instance: Instance + readonly revision: Revision + readonly ready: Promise + /** Its setup succeeded while it was active. */ + good: boolean + /** Runs setup once. The cleanup it returns belongs to the instance, even when setup settles after disposal. */ + start(): Promise + /** + * Withdraws everything the instance contributed at once, then settles once its finalizers and a setup still + * running have finished, or the deadline passed. Idempotent. + */ + dispose(): Promise +} + +/** + * The extension lifecycle without Electron: one serialized queue of operations per extension, the instances they + * start and stop, and what the host withdraws when an instance goes away. + */ +export function createLifecycle(input: { + /** Loads the extension's current main code; undefined when it has none. */ + readonly loader: (id: string) => (() => Promise) | undefined + readonly enabled: (id: string) => boolean + /** A failure was recorded. */ + readonly changed: () => void + readonly log: Log +}) { + const active = new Map() + // One serialized lifecycle per extension: each operation starts after the previous one fully settled. + const queues = new Map>() + // Each extension's current generation. Stopping aborts it, so the activations queued or running under it + // wind down at once; operations queued afterwards run under a fresh generation. + const generations = new Map() + // The revision whose setup last succeeded since the extension was last stopped; a failed reload returns to it. + const last = new Map() + const errors = new Map() + // Scopes a restart handoff keeps. Quitting skips their instances, so a failed handoff leaves them working and a + // successful one keeps their state and contributions until the app has quit. + const kept = new Set() + const status = { quitting: false } + + const current = (id: string, signal: AbortSignal) => !signal.aborted && input.enabled(id) && !status.quitting + + const fail = (id: string, cause: unknown) => { + errors.set(id, cause instanceof Error ? cause.message : String(cause)) + input.log("extension failed", { id, error: cause }) + input.changed() + } + + const create = (id: string, revision: Revision): Running => { + const scope = Scope.make(id, { timeout: CLEANUP_TIMEOUT_MS, log: input.log }) + // The host's record of what the instance contributed; the host withdraws it the moment disposal starts. + const contributions = new Set<() => void>() + + const withdraw = (fn: () => void) => { + // Host bookkeeping does not throw by design; isolating it keeps one failure from leaving the rest behind. + try { + fn() + } catch (error) { + input.log("extension withdrawal failed", { id, error }) + } + } + + const instance: Instance = { + id, + scope, + contribute(fn) { + if (scope.signal.aborted) { + withdraw(fn) + + return () => {} + } + + const remove = () => { + if (contributions.delete(remove)) fn() + } + + contributions.add(remove) + + return remove + }, + } + + const prepared = revision(instance) + const progress: Progress = { setup: Promise.resolve(undefined) } + + return { + instance, + revision, + ready: prepared.ready.catch(() => undefined), + good: false, + start() { + const stall = setTimeout(() => input.log("extension setup stalled", { id, ms: SETUP_STALL_MS }), SETUP_STALL_MS) + + const outcome = Promise.resolve() + .then(prepared.setup) + .then( + (cleanup): Outcome => { + // A setup that settles after disposal started has its cleanup run now; disposal waits for it. + if (Predicate.isFunction(cleanup)) scope.addFinalizer(cleanup) + + return { ok: true } + }, + (cause: unknown): Outcome => ({ ok: false, error: cause }), + ) + .finally(() => clearTimeout(stall)) + + progress.setup = outcome + + return outcome + }, + dispose() { + if (progress.disposed) return progress.disposed + + // Aborts the signal now; the finalizers start only after the withdrawal below. + const closed = scope.close() + + // The host withdraws first and synchronously: a disposed instance is unreachable even if a finalizer hangs. + ;[...contributions].reverse().forEach(withdraw) + + // A setup still running settles, and the finalizers it adds late run, before the next lifecycle step. + const settled = within( + progress.setup.then(() => scope.close()), + () => input.log("extension cleanup timed out", { id }), + ) + + progress.disposed = Promise.all([closed, settled]).then(() => undefined) + + return progress.disposed + }, + } + } + + // The caller sees the step's own outcome; the queue only waits for it to settle, so a failed step never + // stalls the steps behind it. + const enqueue = (id: string, task: () => Promise) => { + const step = (queues.get(id) ?? Promise.resolve()).then(task).then(() => undefined) + + const tail: Promise = step + .catch(() => undefined) + .finally(() => { + if (queues.get(id) === tail) queues.delete(id) + }) + + queues.set(id, tail) + + return step + } + + const generation = (id: string) => { + const existing = generations.get(id) + + if (existing) return existing.signal + const created = new AbortController() + generations.set(id, created) + + return created.signal + } + + const stop = (id: string) => { + generations.get(id)?.abort() + generations.delete(id) + } + + /** The loaded revision, or undefined when loading failed (recorded) or the extension stopped meanwhile. */ + const load = (id: string, signal: AbortSignal, loader: () => Promise) => + until( + signal, + loader().catch((cause: unknown) => { + if (current(id, signal)) fail(id, cause) + + return undefined + }), + ) + + /** Starts an instance of the revision. Resolves with its setup's outcome, or undefined when it stopped first. */ + const run = async (id: string, signal: AbortSignal, revision: Revision) => { + const running = create(id, revision) + active.set(id, running) + await until(signal, running.ready) + + // Stopping removed the instance and began disposing it; its setup never runs. + if (!current(id, signal)) { + if (active.get(id) === running) active.delete(id) + await running.dispose() + + return undefined + } + + const outcome = await until(signal, running.start()) + + // A stop during setup settles this step once the disposal has, however long setup takes. + if (!outcome) { + await running.dispose() + + return undefined + } + + if (active.get(id) !== running) return undefined + + if (outcome.ok) { + running.good = true + last.set(id, revision) + + return outcome + } + + fail(id, outcome.error) + active.delete(id) + await running.dispose() + + return outcome + } + + const activate = (id: string) => { + const signal = generation(id) + + return enqueue(id, async () => { + const loader = input.loader(id) + + if (!loader || active.has(id) || !current(id, signal)) return + errors.delete(id) + const revision = await load(id, signal, loader) + + // Stopped, disabled, or quitting while the code loaded: drop it, it may belong to an older revision. + if (!revision || active.has(id) || !current(id, signal)) return + await run(id, signal, revision) + }) + } + + /** + * Stops the extension at once and queues its teardown: activations queued before it go stale, the instance + * withdraws everything it contributed now, and the queue moves on only after its disposal settled. `after` + * runs inside the queue once the teardown finished. + */ + const deactivate = (id: string, after?: () => void) => { + stop(id) + last.delete(id) + const running = active.get(id) + active.delete(id) + void running?.dispose() + + return enqueue(id, async () => { + await running?.dispose() + after?.() + }) + } + + /** + * Replaces the extension with its current code. A good instance keeps running until that code loaded; a reload that + * fails to load or set up leaves the last good revision running, with the failure recorded. + */ + const reload = (id: string) => { + errors.delete(id) + stop(id) + const previous = active.get(id) + // An instance still starting is no revision to keep: it stops at once, as on disable. + const stopped = previous && !previous.good ? previous : undefined + + if (stopped) { + active.delete(id) + void stopped.dispose() + } + + const signal = generation(id) + + return enqueue(id, async () => { + await stopped?.dispose() + const loader = input.loader(id) + + if (!loader || !current(id, signal)) return + const fallback = last.get(id) + const revision = await load(id, signal, loader) + + if (!current(id, signal)) return + const running = active.get(id) + + if (!revision) { + if (!running && fallback) await run(id, signal, fallback) + + return + } + + if (running) { + active.delete(id) + await running.dispose() + + if (!current(id, signal)) return + } + + const outcome = await run(id, signal, revision) + + if (outcome?.ok === false && fallback) await run(id, signal, fallback) + }) + } + + return { + activate, + deactivate, + reload, + failure: (id: string) => errors.get(id), + forget(id: string) { + errors.delete(id) + }, + /** + * Runs a restart handoff that keeps `keep`, an instance's scope: quitting skips that instance until the handoff + * settles. Rejects without running the handoff when no instance has that scope. + */ + restart(keep: Scope, handoff: () => Promise) { + if (![...active.values()].some((running) => running.instance.scope === keep)) + return Promise.reject(new Error("MainApp.restart keeps only an active extension's ctx.scope")) + kept.add(keep) + + return Promise.resolve() + .then(handoff) + .finally(() => kept.delete(keep)) + }, + /** Stops every extension but a kept one; settles once each lifecycle has. */ + async dispose() { + status.quitting = true + // Every other extension with an instance or a queued step stops; quitting waits for each lifecycle to settle. + await Promise.all( + [...new Set([...active.keys(), ...queues.keys()])].flatMap((id) => { + const running = active.get(id) + + return running && kept.has(running.instance.scope) ? [] : [deactivate(id)] + }), + ) + }, + } +} + +/** + * The promise's value, or undefined as soon as the signal aborts; the promise itself keeps running. Once the promise + * settled, a later abort changes nothing: a reload aborts the generation of an instance it keeps. + */ +function until(signal: AbortSignal, promise: Promise) { + if (signal.aborted) return Promise.resolve(undefined) + + return new Promise((resolve, reject) => { + const abort = () => resolve(undefined) + signal.addEventListener("abort", abort, { once: true }) + void promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)) + }) +} + +/** Waits for the promise until the cleanup deadline, then reports and stops waiting. */ +function within(promise: Promise, timedOut: () => void) { + const deadline = Promise.withResolvers() + + const timer = setTimeout(() => { + timedOut() + deadline.resolve() + }, CLEANUP_TIMEOUT_MS) + + return Promise.race([promise, deadline.promise]).finally(() => clearTimeout(timer)) +} diff --git a/packages/gui-extensions/AGENTS.md b/packages/gui-extensions/AGENTS.md index 3bc3c43ecd8e..490af679fcc6 100644 --- a/packages/gui-extensions/AGENTS.md +++ b/packages/gui-extensions/AGENTS.md @@ -8,6 +8,7 @@ Built-in features of the desktop and web app, each behind the SDK in `src/sdk/`. - `src/renderer.ts` and `src/main.ts` are the only files that list the built-ins. Main never imports renderer code. - Another extension may import only your `contract.ts` (tokens and schemas, no runtime code). Every consumer must still work when the provider is disabled: `ctx.use(Service)` is an accessor that can return `undefined` (see "Failure is part of the contract"). - Never import `@opencode/app`, `@opencode/desktop`, or `@/` paths. Import CSS with `?inline` and contribute it through `ctx.add(Style, css)`. No module-level state: keep state inside `setup`. `bun run lint` enforces these rules. +- `bun run lint:changed` must pass before you finish: every file you add or edit has no oxlint problem at all, including the warn-level anti-slop rules (`unknown` parameters and returns, unchecked type assertions, widened types, unsafe dictionaries, missing spacing). Touching a file means leaving the whole file clean, older warnings included. Fix the code; suppress only with a `SAFETY:` comment that states a real checked invariant. ## Host boundary @@ -21,7 +22,7 @@ An instance lives from `setup` until it is disabled, reloaded, removed, or its w - Everything registered through `ctx` (contributions, services, remotes, menu items, surfaces) is withdrawn by the host when the instance goes away. Anything else you start (timers, DOM or remote listeners, subscriptions) needs `ctx.cleanup`. A cleanup registered after disposal runs at once. - `setup` may be async. After every `await`, return if `ctx.signal.aborted` before touching state or contributing. Pass `ctx.signal`, or a signal derived from it, to remote calls and long work. - Never keep a value from a shorter lifetime in a longer one. Read a server's `client`, `data` and `url` from its live `ServerRef` each time: a restarted or re-authenticated server gets a new controller under the same id. Keep per-session state in a session-scoped store, or in a map keyed by session that you prune. -- Main: `MainApp.restart(handoff)` keeps the calling extension active until the handoff settles; when it rejects, return to a state the user can retry from. +- Main: `MainApp.restart(handoff, { keep: ctx.scope })` keeps the extension that owns `ctx.scope` (the caller by default) active until the handoff settles; when it rejects, return to a state the user can retry from. `ctx.cleanup` adds a finalizer to `ctx.scope`. ## Failure is part of the contract diff --git a/packages/gui-extensions/src/sdk/compose.typecheck.ts b/packages/gui-extensions/src/sdk/compose.typecheck.ts new file mode 100644 index 000000000000..d7e4f8f1eb56 --- /dev/null +++ b/packages/gui-extensions/src/sdk/compose.typecheck.ts @@ -0,0 +1,103 @@ +// Type-level contract of typed composition, checked by `bun typecheck`. Nothing imports this file. Each +// `@ts-expect-error` fails the typecheck if its line stops being an error. +import type { Accessor } from "solid-js" +import { Schema } from "effect" +import { + Extension, + Remote, + Service, + Store, + type Composition, + type Duplicate, + type Live, + type Missing, + type MissingMain, + type RemotesProvided, + type Setup, +} from "./index" + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false + +const equal = (value: Equal) => value + +const Tree = Service.define<{ open(path: string): void }, "fixture.tree">("fixture.tree") + +const Changes = Service.define<{ count(): number }, "fixture.changes">("fixture.changes") + +const Unlisted = Service.define<{ ping(): void }, "fixture.unlisted">("fixture.unlisted") + +const Pane = Remote.define({ id: "fixture.pane", methods: { open: { input: Schema.String } } }) + +const View = Schema.Struct({ open: Schema.Boolean }) + +const TreeProvider = Extension.define({ id: "tree", provides: { tree: Tree } }) + +const PaneProvider = Extension.define({ id: "pane", provides: { pane: Pane } }) + +const Consumer = Extension.define({ + id: "consumer", + uses: { changes: Changes }, + requires: { tree: Tree, pane: Pane }, + stores: { view: Store.app(View, { open: false }), draft: Store.session(View, { open: true }) }, +}) + +// A composition with every hard provider and no duplicate compiles, and is the identity at runtime. +export const renderer = Extension.compose(TreeProvider, PaneProvider, Consumer) + +equal(true) + +// A `requires` token nobody provides. +// @ts-expect-error the composition is missing a provider of fixture.tree +Extension.compose(PaneProvider, Consumer) + +equal, { readonly "missing provider": Missing<"fixture.tree"> }>( + true, +) + +// Two providers of one token. +const Second = Extension.define({ id: "second", provides: { tree: Tree } }) + +// @ts-expect-error two extensions provide fixture.tree +Extension.compose(TreeProvider, Second, PaneProvider, Consumer) + +equal< + Composition<[typeof TreeProvider, typeof Second, typeof PaneProvider, typeof Consumer]>, + { readonly "duplicate provider": Duplicate<"fixture.tree"> } +>(true) + +// A renderer Remote needs an entry with a main module that provides it in the main composition. +export const main = Extension.compose({ ...PaneProvider, main: async () => ({ default: () => undefined }) }) + +export const remotes: RemotesProvided = true + +export const mainless = Extension.compose(PaneProvider) + +// @ts-expect-error no main entry provides fixture.pane +export const unprovided: RemotesProvided = true + +equal, MissingMain<"fixture.pane">>(true) + +// The typed context exposes only what the definition declares. +export const setup: Setup = (ctx) => { + equal>>(true) + const changes = ctx.use(Changes) + equal>>(true) + ctx.requires.tree.open("a.ts") + void ctx.requires.pane.open("https://example.com") + equal(true) + equal["value"], { readonly open: boolean } | undefined>(true) + // @ts-expect-error fixture.unlisted is not declared in uses + ctx.use(Unlisted) + // @ts-expect-error fixture.tree is required, not used: its value is `ctx.requires.tree` + ctx.use(Tree) + // @ts-expect-error the consumer declares no provides + ctx.provide(Tree, { open: () => undefined }) + // @ts-expect-error no store named missing + void ctx.stores.missing +} + +export const provider: Setup = (ctx) => { + ctx.provide(Tree, { open: () => undefined }) + // @ts-expect-error the implementation must match the token + ctx.provide(Tree, { close: () => undefined }) +} diff --git a/packages/gui-extensions/src/sdk/context.ts b/packages/gui-extensions/src/sdk/context.ts new file mode 100644 index 000000000000..464497c4cde5 --- /dev/null +++ b/packages/gui-extensions/src/sdk/context.ts @@ -0,0 +1,69 @@ +import type { Accessor } from "solid-js" +import type { + BaseContext, + Cleanup, + Declared, + DeclaredStores, + Definition, + Host, + Live, + Persisted, + Remote, + RemoteClient, + RemoteSpec, + Service, + StoreDeclaration, + TokenValue, +} from "./core" +import type { PersistedStorage, SessionRef, Storage } from "./services" + +/** What every renderer entry gets. */ +export interface Context extends BaseContext { + use(token: Host): T + /** + * @deprecated Declare the token in `uses` and read `ctx.uses` (or `use` it from `Setup`), which + * follows the provider through `Live`: one object per transition, so every action can answer while the provider is + * pending or inactive. An undeclared token gives this older accessor, undefined while there is no provider. + */ + use(token: Service): Accessor + /** @deprecated See `use(Service)`. */ + use(token: Remote): Accessor | undefined> +} + +type Handle = + S extends StoreDeclaration + ? Scope extends "app" + ? Persisted + : (session: SessionRef) => Persisted + : never + +type Provides = Declared[keyof Declared] + +type Uses = Declared[keyof Declared] + +/** The context `Setup` receives: the host's members, and only the contracts the definition declares. */ +export interface SetupContext extends Omit { + /** `Storage.store` returns a `Persisted` here; see `PersistedStorage`. */ + use(token: Host): PersistedStorage + use(token: Host): T + /** The accessor `uses` holds for a declared token: the provider followed through `Live`. */ + use>(token: T): Accessor>> + /** Provides a service the definition declares in `provides`. */ + provide, Service>>(token: T, impl: TokenValue): Cleanup + /** Each optional contract, followed live. */ + readonly uses: { readonly [K in keyof Declared]: Accessor[K]>>> } + /** Each hard contract's value. Setup runs only while all are active and restarts when one changes. */ + readonly requires: { readonly [K in keyof Declared]: TokenValue[K]> } + /** App stores are loaded before setup; a session store's value is undefined until that session's store loads. */ + readonly stores: { readonly [K in keyof DeclaredStores]: Handle[K]> } +} + +type Result = void | Cleanup | Promise + +/** + * A renderer entry. `Setup` types the context from the definition's declarations. + * A bare `Setup` takes the older untyped context; new entries should pass their definition. + */ +export type Setup = [D] extends [never] + ? (ctx: Context) => Result + : (ctx: SetupContext) => Result diff --git a/packages/gui-extensions/src/sdk/core.ts b/packages/gui-extensions/src/sdk/core.ts index e3ac2858b18e..8bbe0f546f6e 100644 --- a/packages/gui-extensions/src/sdk/core.ts +++ b/packages/gui-extensions/src/sdk/core.ts @@ -2,8 +2,11 @@ import type { Schema } from "effect" import type { Accessor } from "solid-js" export type Cleanup = () => void | Promise + export type OS = "macos" | "windows" | "linux" + export type Params = Record + export type Messages = Readonly> /** English ships inline; other locales load when the user picks them. */ @@ -11,23 +14,41 @@ export type Catalog = { readonly en: Messages } & { readonly [locale: string]: Messages | (() => Promise<{ readonly default: Messages }>) } -export type Setup = (ctx: Context) => void | Cleanup | Promise +type Result = void | Cleanup | Promise + +/** An entry that takes the context untyped by its definition. The main process still uses this form. */ +export type Setup = (ctx: Context) => Result export interface Definition { /** Prefix of every id the extension creates: commands, panels, settings, storage, services, points. */ readonly id: string readonly os?: readonly OS[] readonly i18n?: Catalog - readonly renderer?: () => Promise<{ readonly default: Setup }> + /** Contracts this extension provides: Services from its renderer entry, Remotes from its main entry. */ + readonly provides?: Tokens + /** Optional contracts. Each is a `Live` accessor in `ctx.uses`; the extension must work while one is inactive. */ + readonly uses?: Tokens + /** + * Hard contracts. The host starts the extension only while every one is active and restarts it with them; the + * values are plain in `ctx.requires`. Use it only where the extension is meaningless without the contract. + */ + readonly requires?: Tokens + /** State the host stores for the extension and loads before it is read. See `Store`. */ + readonly stores?: Readonly> + readonly renderer?: () => Promise<{ readonly default: (ctx: never) => Result }> readonly main?: () => Promise<{ readonly default: Setup }> } -export const Extension = { - define: (definition: Definition) => definition, +// Loose on purpose: checking an entry's module while its definition is still being inferred would be circular. +type Entries = { + readonly renderer?: () => Promise + readonly main?: () => Promise } declare const brand: unique symbol +declare const problem: unique symbol + /** A named place that accepts contributions. The owner of the point decides how to render its items. */ export interface Point { readonly kind: "point" @@ -36,9 +57,9 @@ export interface Point { } /** An in-process contract. Any interface, no schema, never crosses IPC. */ -export interface Service { +export interface Service { readonly kind: "service" - readonly id: string + readonly id: Id readonly [brand]?: T } @@ -50,10 +71,12 @@ export interface Host { } type Codec = Schema.ConstraintCodec + export interface RemoteMethod { readonly input?: Codec readonly output?: Codec } + export interface RemoteSpec { readonly id: string readonly state?: Codec @@ -68,6 +91,11 @@ export interface Remote { readonly spec: S } +/** A contract one extension provides and others declare in `uses` or `requires`. */ +export type Token = Service | Remote + +export type Tokens = Readonly> + type TypeOf = C extends Codec ? C["Type"] : void /** What the renderer gets from `use(remote)`. Methods are async; state is synced per window. */ @@ -84,6 +112,27 @@ export type RemoteClient = { ): Cleanup } +/** The value a token gives its users: the service itself, or the client of a remote. */ +export type TokenValue = T extends Remote ? RemoteClient : T extends Service ? V : never + +/** + * A provider as its users see it. One object per transition, so a reader re-runs only when the provider changes. + * `generation` counts activations: a provider that restarts comes back with a new one. + */ +export type Live = + | { readonly status: "pending" } + | { readonly status: "active"; readonly value: T; readonly generation: number } + | { readonly status: "inactive"; readonly reason: "disabled" | "failed" | "restarting" } + +const live = Symbol.for("opencode.extension.live") + +export const Live = { + /** Host: marks the accessor `use` returns, so `createActive` follows its generations. */ + accessor: (read: () => Live): Accessor> => Object.assign(read, { [live]: true }), + /** The accessor is one the host marked with `Live.accessor`. */ + is: (source: Accessor): source is Accessor> => live in source, +} + /** Identifies the renderer window a remote call came from. Main uses it to scope state and events. */ export interface Caller { readonly window: number @@ -110,33 +159,195 @@ export interface Provided { dispose(): void } -export interface Context { +/** What every entry gets, in the renderer and in main. */ +export interface BaseContext { readonly id: string /** Aborts when the extension is disabled, reloaded, or the window closes. */ readonly signal: AbortSignal /** Runs when the extension goes away; runs at once if it already has, e.g. after an await in setup. */ cleanup(fn: Cleanup): Cleanup - /** Contribute an item. Pass a function to contribute reactively; return undefined to withdraw. */ + /** + * Contribute an item. Pass a function to contribute reactively; return undefined to withdraw. The item is withdrawn + * with the current owner (for example a `createActive` generation or a component), else with the extension. + */ add(point: Point, item: T | (() => T | undefined)): Cleanup /** Read contributions to a point this extension owns. Reactive. */ list(point: Point): readonly T[] provide(token: Service, impl: T): Cleanup provide(token: Remote, impl: RemoteImpl): Provided + /** Resolves this extension's catalog, then the app's shared keys. */ + t(key: string, params?: Params): string + plural(key: string, count: number, params?: Params): string +} + +/** The main process context. The renderer's `Context` follows providers through `Live` instead. */ +export interface Context extends BaseContext { use(token: Host): T /** Follows the provider live: undefined until it exists, and again after it goes away. */ use(token: Service): Accessor use(token: Remote): Accessor | undefined> - /** Resolves this extension's catalog, then the app's shared keys. */ - t(key: string, params?: Params): string - plural(key: string, count: number, params?: Params): string +} + +/** Moves an older stored value into a store once. */ +export type StoreFrom = + | string + | { + readonly key: string + /** + * For session scope: `key` is an app key (e.g. "layout") whose field `sessions` holds every session's + * state by the host's session key. pick receives only this session's entry, or undefined. + */ + readonly sessions?: string + // SAFETY: the older value is stored JSON with no schema of its own; the store decodes what pick returns. + // oxlint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns -- see SAFETY above + pick(value: unknown): unknown + } + +type StoreSchema = Schema.ConstraintCodec + +/** A store an extension declares in `Extension.define({ stores })`. Its key is the store's name. */ +export interface StoreDeclaration< + S extends StoreSchema = StoreSchema, + Scope extends "app" | "session" = "app" | "session", +> { + readonly scope: Scope + readonly schema: S + readonly initial: S["Type"] + /** Imports an older host key once, as `Storage.store`'s `from` does. */ + readonly from?: StoreFrom +} + +export const Store = { + /** One value for the app. The host loads it before setup, so `ctx.stores.name.value` is never undefined. */ + app: ( + schema: S, + initial: NoInfer, + from?: StoreFrom, + ): StoreDeclaration => ({ + scope: "app", + schema, + initial, + from, + }), + /** One value per session. The host loads it when the session mounts; `value` is undefined until then. */ + session: ( + schema: S, + initial: NoInfer, + from?: StoreFrom, + ): StoreDeclaration => ({ scope: "session", schema, initial, from }), +} + +/** Stored state. `V` is the value's type while it may still be loading. */ +export interface Persisted { + /** Undefined until the stored value has loaded. Reactive. */ + readonly value: V + /** The stored value has loaded. Reactive. */ + ready(): boolean + /** Changes the stored value. Waits until it has loaded, then applies in call order. */ + update(mutation: (draft: T) => void): void +} + +/** The tokens a definition declares under `K`; `{}` when it declares none. */ +export type Declared = D extends { readonly [P in K]?: infer M } + ? M extends Tokens + ? M + : {} + : {} + +/** The stores a definition declares; `{}` when it declares none. */ +export type DeclaredStores = D extends { readonly stores?: infer M } + ? M extends Readonly> + ? M + : {} + : {} + +/** The id a token declares, for compile errors. */ +export type TokenId = T extends Remote ? S["id"] : T extends Service ? Id : never + +/** `Extension.compose`: a `requires` token no extension in the composition provides. */ +export interface Missing { + readonly [problem]: Id +} + +/** `Extension.compose`: a token two extensions in the composition provide. */ +export interface Duplicate { + readonly [problem]: Id +} + +/** `RemotesProvided`: a renderer `uses` or `requires` of a Remote that no main entry provides. */ +export interface MissingMain { + readonly [problem]: Id +} + +type Literal = Id extends string ? (string extends Id ? never : Id) : never + +// Distributes over a union of definitions, so each contributes its own declared ids. +type Ids = D extends unknown + ? TokenId[keyof Declared]> + : never + +type Others = { [J in keyof Ds]: J extends I ? never : Ds[J] }[number] + +type MissingIds = Exclude, Ids> + +type DuplicateIds = { + [I in keyof Ds]: Literal> & Ids, "provides"> +}[number] + +/** Compile errors for a composition; `unknown` when it is valid. */ +export type Composition = [MissingIds] extends [never] + ? [DuplicateIds] extends [never] + ? unknown + : { readonly "duplicate provider": Duplicate> } + : { readonly "missing provider": Missing> } + +type RemoteIds = D extends unknown + ? TokenId[keyof Declared], Remote>> + : never + +type MissingRemotes = Exclude< + RemoteIds | RemoteIds, + RemoteIds, "provides"> +> + +/** + * `true` when every Remote the renderer composition `R` declares in `uses` or `requires` is provided by an entry with a + * main module in the main composition `M`; otherwise an error type naming the remote. Check it once in a file that + * imports both compositions: `const remotes: RemotesProvided = true`. + */ +export type RemotesProvided = [ + MissingRemotes, +] extends [never] + ? true + : MissingMain> + +export const Extension = { + /** + * Returns the definition with its declarations typed; `Setup` reads them. The entries are checked + * where the definition is composed, so `renderer: () => import("./renderer")` may name `Setup`. + */ + define: & Entries>(definition: D): D => definition, + /** + * Returns the definitions as they are. Fails to compile when a `requires` token has no provider in the composition, + * or when two extensions provide the same token. + */ + compose: (...definitions: Ds & Composition): Ds => definitions, } export const Point = { define: (id: string): Point => ({ kind: "point", id }), } +function defineService(id: Id): Service +/** @deprecated Pass the id as a second type argument too, so composition errors can name it. */ +function defineService(id: string): Service +function defineService(id: string) { + return { kind: "service", id } +} + export const Service = { - define: (id: string): Service => ({ kind: "service", id }), + /** `Service.define("file.tree")`. */ + define: defineService, } export const Host = { diff --git a/packages/gui-extensions/src/sdk/index.ts b/packages/gui-extensions/src/sdk/index.ts index 45a8c077b01d..cf6d9393197a 100644 --- a/packages/gui-extensions/src/sdk/index.ts +++ b/packages/gui-extensions/src/sdk/index.ts @@ -1,4 +1,12 @@ export * from "./core" + export * from "./points" + export * from "./services" + export * from "./solid" + +export * from "./reactive" + +// The renderer's context follows providers through Live; these replace the main-process forms core exports. +export type { Context, Setup, SetupContext } from "./context" diff --git a/packages/gui-extensions/src/sdk/main.ts b/packages/gui-extensions/src/sdk/main.ts index 9ad45181b7c9..7dd8667c6921 100644 --- a/packages/gui-extensions/src/sdk/main.ts +++ b/packages/gui-extensions/src/sdk/main.ts @@ -1,9 +1,18 @@ import type { BrowserWindow, NativeImage, WebContentsView } from "electron" import type { Schema } from "effect" -import { Host, Point, type Cleanup } from "./core" +import { Host, Point, type Cleanup, type Context } from "./core" +import type { Scope } from "./scope" export * from "./core" +export * from "./scope" + +/** The setup context in the main process. */ +export interface MainContext extends Context { + /** The instance's lifetime: `signal` is its signal and `cleanup` adds its finalizers. */ + readonly scope: Scope +} + export interface Windows { get(id: number): BrowserWindow | undefined list(): readonly BrowserWindow[] @@ -63,12 +72,18 @@ export interface MainApp { readonly packaged: boolean server(id: string): MainServer | undefined /** - * Marks the app as quitting and disposes every other extension, then runs handoff (e.g. quitAndInstall) or - * relaunches. The caller stays active through the handoff and keeps running when it fails (the promise rejects). + * Marks the app as quitting and disposes every extension but the one whose `keep` scope is passed (the caller's + * `ctx.scope` by default), then runs handoff (e.g. quitAndInstall) or relaunches. The kept scope outlives shutdown + * until the handoff settles, and keeps running when it fails (the promise rejects). Only an extension's `ctx.scope` + * can be kept. */ - restart(handoff?: () => void | Promise): Promise - /** Writes to the desktop log file (included in exported debug logs). */ - log(level: "debug" | "info" | "warn" | "error", message: string, data?: Record): void + restart(handoff?: () => void | Promise, options?: { readonly keep?: Scope }): Promise + /** Writes to the desktop log file (included in exported debug logs); each field of `data` is serialized as it is. */ + log>>( + level: "debug" | "info" | "warn" | "error", + message: string, + data?: Data, + ): void } export interface Menubar { @@ -81,8 +96,13 @@ export interface Menubar { } export const Windows = Host.define("window") + export const Surfaces = Host.define("surface") + export const MainStorage = Host.define("storage") + export const Cli = Host.define("cli") + export const MainApp = Host.define("app") + export const Menubar = Point.define("menubar") diff --git a/packages/gui-extensions/src/sdk/reactive.ts b/packages/gui-extensions/src/sdk/reactive.ts new file mode 100644 index 000000000000..7caee91a1016 --- /dev/null +++ b/packages/gui-extensions/src/sdk/reactive.ts @@ -0,0 +1,163 @@ +import { + createMemo, + createRenderEffect, + createRoot, + createSignal, + getOwner, + on, + onCleanup, + runWithOwner, + untrack, + type Accessor, +} from "solid-js" +import { createStore } from "solid-js/store" +import { Live, type Remote, type RemoteClient, type Service, type Token } from "./core" +import { Sessions } from "./services" +import { LifetimeContext, useExtension } from "./solid" + +type Falsy = undefined | null | false + +/** + * A token, a `Live` accessor such as `ctx.uses.name`, or any accessor. A token the definition declares is followed + * through `Live`; an undeclared one follows the identity of its provider's value. + */ +export type ActiveSource = Token | Accessor + +/** What a source gives while it is active. A plain accessor is active while its value is not undefined, null or false. */ +export type ActiveValue = + S extends Remote + ? RemoteClient + : S extends Service + ? T + : S extends Accessor> + ? T + : S extends Accessor + ? Exclude + : never + +/** One run of `createActive`: the source's value, or none while it is not active. */ +type Run = { readonly value: unknown; readonly generation?: number } | undefined + +/** + * Runs `fn` once per active generation of a token or `Live` accessor, or once per identity of a plain accessor's value. + * Each run, and each run of `otherwise` while the source is not active, has its own owner: its `onCleanup` and its + * registrations end with it. This is the way extension code runs side effects reactively. + */ +export function createActive( + source: S, + fn: (value: ActiveValue) => void, + options?: { readonly otherwise?: () => void }, +) { + const input: ActiveSource = source + const read = isToken(input) ? follow(input) : input + + const active = Live.is(read) + ? createMemo( + () => { + const value = read() + + return value.status === "active" ? value : undefined + }, + undefined, + // A Live source changes generation, not merely object, when its provider restarts. + { equals: (previous, next) => previous?.generation === next?.generation }, + ) + : createMemo( + () => { + const value = read() + + return value === undefined || value === null || value === false ? undefined : { value } + }, + undefined, + { equals: (previous, next) => previous?.value === next?.value }, + ) + + createRenderEffect( + on(active, (current) => { + // SAFETY: `current.value` comes from `source`, and `ActiveValue` is what that source gives while active. + const run = current === undefined ? options?.otherwise : () => fn(current.value as ActiveValue) + + if (!run) return + + // A root per run, so a registration made through a captured owner after the run ended disposes at once. + const lifetime = { ended: false } + const scope = createRoot((dispose) => ({ owner: getOwner(), dispose })) + + onCleanup(() => { + lifetime.ended = true + scope.dispose() + }) + + if (scope.owner) scope.owner.context = { ...scope.owner.context, [LifetimeContext.id]: lifetime } + runWithOwner(scope.owner, run) + }), + ) +} + +function isToken(source: ActiveSource): source is Token { + return "kind" in source +} + +/** The extension context's accessor for a token. */ +function follow(token: Token): Accessor { + const ctx = useExtension() + + return token.kind === "service" ? ctx.use(token) : ctx.use(token) +} + +/** + * The latest result of `fetch` for the source's current value. Never suspends. A new value aborts the previous request + * through its signal and drops its reply; so does the owner going away. `latest` keeps the last result meanwhile. + */ +export function createLatest( + source: S, + fetch: (value: ActiveValue, signal: AbortSignal) => Promise, +): { readonly latest: T | undefined; readonly loading: boolean; readonly error: unknown } { + const [state, setState] = createStore<{ latest: T | undefined; loading: boolean; error: unknown }>({ + latest: undefined, + loading: false, + error: undefined, + }) + + createActive( + source, + (value) => { + const controller = new AbortController() + + onCleanup(() => controller.abort()) + setState("loading", true) + void Promise.try(() => fetch(value, controller.signal)).then( + (latest) => { + if (!controller.signal.aborted) setState({ latest, loading: false, error: undefined }) + }, + (cause: unknown) => { + if (!controller.signal.aborted) setState({ loading: false, error: cause }) + }, + ) + }, + { otherwise: () => setState("loading", false) }, + ) + + return state +} + +/** State that returns to `initial` on every routing visit of the current session (`SessionView.visit`). */ +export function createVisitState(initial: T) { + const sessions = useExtension().use(Sessions) + const visit = () => sessions.current()?.visit + + const [state, setState] = createSignal<{ readonly visit: object | undefined; readonly value: T }>({ + visit: undefined, + value: initial, + }) + + const value = () => { + const current = state() + + return current.visit !== undefined && current.visit === visit() ? current.value : initial + } + + const set = (next: T) => void setState({ visit: untrack(visit), value: next }) + + return [value, set] as const +} diff --git a/packages/gui-extensions/src/sdk/scope.test.ts b/packages/gui-extensions/src/sdk/scope.test.ts new file mode 100644 index 000000000000..c36c6c8f4b4a --- /dev/null +++ b/packages/gui-extensions/src/sdk/scope.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, expect, jest, test } from "bun:test" +import { Scope } from "./scope" + +const TIMEOUT = 1_000 + +beforeEach(() => jest.useFakeTimers()) + +afterEach(() => jest.useRealTimers()) + +// Fake timers leave setImmediate real: one turn drains every pending microtask. +const flush = () => new Promise((resolve) => setImmediate(resolve)) + +const fixture = () => { + const events: string[] = [] + const logs: { message: string; data: object }[] = [] + const scope = Scope.make("test", { timeout: TIMEOUT, log: (message, data) => logs.push({ message, data }) }) + + const record = (name: string) => () => { + events.push(name) + } + + return { events, logs, scope, record } +} + +const settled = (promise: Promise) => { + const state = { settled: false } + void promise.then(() => (state.settled = true)) + + return state +} + +test("close aborts the signal at once and runs finalizers in reverse, past one that fails", async () => { + const world = fixture() + world.scope.addFinalizer(world.record("first")) + world.scope.addFinalizer(() => { + world.events.push("failing") + throw new Error("boom") + }) + world.scope.addFinalizer(world.record("last")) + const closing = world.scope.close() + expect(world.scope.signal.aborted).toBe(true) + // Finalizers start only once the closing code yields. + expect(world.events).toEqual([]) + await closing + expect(world.events).toEqual(["last", "failing", "first"]) + expect(world.logs.map((entry) => entry.message)).toEqual(["scope finalizer failed"]) +}) + +test("a finalizer added after close runs at once, and closing again waits for it", async () => { + const world = fixture() + await world.scope.close() + const release = { resolve: () => {} } + world.scope.addFinalizer(async () => { + world.events.push("late") + await new Promise((resolve) => (release.resolve = resolve)) + world.events.push("late done") + }) + await flush() + expect(world.events).toEqual(["late"]) + const again = settled(world.scope.close()) + await flush() + expect(again.settled).toBe(false) + release.resolve() + await flush() + expect(again.settled).toBe(true) + expect(world.events).toEqual(["late", "late done"]) +}) + +test("a fork closes with its parent in reverse order like a finalizer, or earlier on its own", async () => { + const world = fixture() + world.scope.addFinalizer(world.record("parent before fork")) + const fork = world.scope.fork("child") + fork.addFinalizer(world.record("child")) + const alone = world.scope.fork("alone") + alone.addFinalizer(world.record("alone")) + world.scope.addFinalizer(world.record("parent after fork")) + + await alone.close() + expect(world.events).toEqual(["alone"]) + await world.scope.close() + expect(fork.signal.aborted).toBe(true) + // The fork that closed on its own left the parent: it does not run again. + expect(world.events).toEqual(["alone", "parent after fork", "child", "parent before fork"]) + expect(world.scope.fork("after close").signal.aborted).toBe(true) +}) + +test("the deadline gives up on a hung finalizer, logs it, and runs the rest", async () => { + const world = fixture() + world.scope.addFinalizer(world.record("first")) + world.scope.addFinalizer(function hung() { + world.events.push("hung") + + return new Promise(() => {}) + }) + world.scope.addFinalizer(world.record("last")) + const closing = settled(world.scope.close()) + await flush() + jest.advanceTimersByTime(TIMEOUT - 1) + await flush() + expect(world.events).toEqual(["last", "hung"]) + expect(closing.settled).toBe(false) + jest.advanceTimersByTime(1) + await flush() + expect(world.events).toEqual(["last", "hung", "first"]) + expect(closing.settled).toBe(true) + expect(world.logs).toEqual([{ message: "scope finalizer timed out", data: { scope: "test", finalizer: "hung" } }]) +}) diff --git a/packages/gui-extensions/src/sdk/scope.ts b/packages/gui-extensions/src/sdk/scope.ts new file mode 100644 index 000000000000..e49dca6b7490 --- /dev/null +++ b/packages/gui-extensions/src/sdk/scope.ts @@ -0,0 +1,123 @@ +import type { Cleanup } from "./core" + +/** + * A lifetime, named after Effect's `Scope` without its runtime. Closing it aborts `signal`, then runs its finalizers + * in reverse order, each isolated from the others' failures, under a deadline. + */ +export interface Scope { + /** Aborts when the scope closes. */ + readonly signal: AbortSignal + /** Runs `fn` when the scope closes, or at once when it already has. The function returned runs it early instead. */ + addFinalizer(fn: Cleanup): Cleanup + /** A child scope. It closes with this one, in reverse order like a finalizer, or earlier on its own. */ + fork(name: string): Scope + /** + * Aborts `signal` at once; the finalizers start once the calling code yields. Settles when they have, or at the + * deadline: a finalizer still running then is logged and no longer waited for, and the rest start. Closing again + * also waits for the finalizers added since. + */ + close(): Promise +} + +/** Writes a structured log entry; the logger serializes each field of `data` as it is. */ +type Log = >>(message: string, data: Data) => void + +type Options = { readonly timeout: number; readonly log: Log } + +// A fork has no label: it logs its own finalizers. +type Finalizer = { readonly label?: string; readonly run: Cleanup } + +export const Scope = { + make: (name: string, options: Options): Scope => create(name, options), +} + +function create(name: string, options: Options, detach?: () => void): Scope { + const controller = new AbortController() + const finalizers = new Set() + // Finalizers that started and have not settled or been given up on. + const running = new Map, string | undefined>() + // The finalizers registered before closing run on this chain, in reverse. + const closing = { closed: false, chain: Promise.resolve() } + + const start = (finalizer: Finalizer) => { + const run: Promise = Promise.resolve() + .then(finalizer.run) + .then( + () => undefined, + (cause: unknown) => options.log("scope finalizer failed", { scope: name, error: cause }), + ) + .finally(() => running.delete(run)) + + running.set(run, finalizer.label) + + return run + } + + const settle = (deadline: Deadline) => + Promise.race([Promise.all([closing.chain, ...running.keys()]), deadline.passed]) + .then(() => undefined) + .finally(deadline.cancel) + + const deadline = (): Deadline => { + const passed = Promise.withResolvers() + + const timer = setTimeout(() => { + running.forEach((label, run) => { + running.delete(run) + + if (label !== undefined) options.log("scope finalizer timed out", { scope: name, finalizer: label }) + }) + passed.resolve() + }, options.timeout) + + return { passed: passed.promise, cancel: () => clearTimeout(timer) } + } + + const scope: Scope = { + signal: controller.signal, + addFinalizer(fn) { + const finalizer = { label: fn.name || "anonymous", run: fn } + + if (closing.closed) { + void start(finalizer) + + return () => {} + } + + finalizers.add(finalizer) + + return () => { + if (finalizers.delete(finalizer)) return fn() + } + }, + fork(child) { + const finalizer: Finalizer = { run: () => forked.close() } + const forked = create(`${name}/${child}`, options, () => finalizers.delete(finalizer)) + + if (closing.closed) void forked.close() + else finalizers.add(finalizer) + + return forked + }, + close() { + if (closing.closed) return settle(deadline()) + detach?.() + controller.abort() + const first = deadline() + const ordered = [...finalizers].reverse() + finalizers.clear() + closing.closed = true + closing.chain = ordered.reduce( + (chain: Promise, finalizer) => + chain.then(() => Promise.race([start(finalizer), first.passed]).then(() => undefined)), + Promise.resolve(), + ) + + return settle(first) + }, + } + + return scope +} + +type Deadline = { readonly passed: Promise; readonly cancel: () => void } diff --git a/packages/gui-extensions/src/sdk/services.ts b/packages/gui-extensions/src/sdk/services.ts index d68d66d17e61..855a554e0c7d 100644 --- a/packages/gui-extensions/src/sdk/services.ts +++ b/packages/gui-extensions/src/sdk/services.ts @@ -3,7 +3,7 @@ import type { LocationRef, OpenCodeClient, ProjectListOutput, WorktreeDirectory import type { Schema } from "effect" import type { Accessor, JSX } from "solid-js" import type { Store } from "solid-js/store" -import { Host, type Cleanup, type OS } from "./core" +import { Host, type Cleanup, type OS, type Persisted, type StoreFrom } from "./core" import type { IconName, Link } from "./points" export interface ServerRef { @@ -184,6 +184,8 @@ export interface BackgroundTask { /** A mounted session route. Slot inputs and panel renders receive this. */ export interface SessionView extends SessionRef { + /** A new object each time the session is routed, e.g. after Home and back. Keep per-visit state keyed by it. */ + readonly visit: object /** `sandboxes` includes worktrees found on disk; `name` and `icon` carry the user's local overrides. */ readonly project: Project | undefined /** @@ -221,6 +223,7 @@ export interface Layout { * Panel keys are `${extension}:${tab id}`. Works for sessions that are not mounted. On narrow screens, a plain or * `preview` open selects the panel's mobile view and closes the dock; opening a tab its panel does not list, or a * launcher, stores nothing. A launcher is never selected on narrow screens, but a stored one stays the preview slot. + * Writes (`open`, `close`, `toggle`, `scroll.set`) made while `session.location` is unknown wait until it is known. */ open( key: string, @@ -263,30 +266,26 @@ export type StorageScope = | { readonly server: string; readonly directory?: string } | { readonly session: SessionRef } +export interface StoreOptions> { + readonly schema: S + readonly initial: S["Type"] + readonly scope?: StorageScope + /** + * Imports an older host key of the same storage once (the raw stored key, e.g. "workspace:terminal"). + * With pick, only the picked part of the old JSON is copied and the old key stays for its other owners. + */ + readonly from?: StoreFrom +} + export interface Storage { - /** Durable, schema-decoded, synced across windows. */ + /** + * @deprecated The `[store, update, ready]` tuple lets code read and write before the value loads. Declare `stores` + * in the definition for keys known up front; for dynamic keys, `Setup` types this as a + * `Persisted` (see `PersistedStorage`). Durable, schema-decoded, synced across windows. + */ store>( key: string, - options: { - readonly schema: S - readonly initial: S["Type"] - readonly scope?: StorageScope - /** - * Imports an older host key of the same storage once (the raw stored key, e.g. "workspace:terminal"). - * With pick, only the picked part of the old JSON is copied and the old key stays for its other owners. - */ - readonly from?: - | string - | { - readonly key: string - /** - * For session scope: `key` is an app key (e.g. "layout") whose field `sessions` holds every session's - * state by the host's session key. pick receives only this session's entry, or undefined. - */ - readonly sessions?: string - pick(value: unknown): unknown - } - }, + options: StoreOptions, ): readonly [Store, (mutation: (draft: S["Type"]) => void) => void, Accessor] /** Window-local and kept across extension reloads. */ memory( @@ -296,6 +295,11 @@ export interface Storage { remove(key: string, options?: { readonly scope?: StorageScope }): void } +/** `Storage` as `Setup` sees it: `store` is for dynamic keys and returns a `Persisted`. */ +export interface PersistedStorage extends Omit { + store>(key: string, options: StoreOptions): Persisted +} + export interface System { copy(text: string): Promise save(file: { readonly name: string; readonly content: string }): Promise @@ -393,12 +397,21 @@ export interface Surfaces { } export const Sessions = Host.define("session") + export const Layout = Host.define("layout") + export const Storage = Host.define("storage") + export const System = Host.define("system") + export const Native = Host.define("native") + export const App = Host.define("app") + export const Dialogs = Host.define("dialog") + export const Links = Host.define("link") + export const Preferences = Host.define("preferences") + export const Surfaces = Host.define("surface") diff --git a/packages/gui-extensions/src/sdk/solid.ts b/packages/gui-extensions/src/sdk/solid.ts index 8642b0a392b7..763d2cb8d349 100644 --- a/packages/gui-extensions/src/sdk/solid.ts +++ b/packages/gui-extensions/src/sdk/solid.ts @@ -1,13 +1,24 @@ import { createContext, useContext, type Accessor } from "solid-js" -import type { Context } from "./core" +import type { Context, SetupContext } from "./context" +import type { Definition } from "./core" -/** The host provides this around every contribution it renders. */ +/** The host provides this around every contribution it renders and in the extension's setup. */ export const ExtensionContext = createContext() -export function useExtension() { +/** + * Marks a scope that ends before the extension does, such as a `createActive` generation. The host disposes at once a + * registration made in a scope that already ended. + */ +export const LifetimeContext = createContext<{ readonly ended: boolean }>() + +/** The extension's context. Pass its definition, `useExtension()`, for the typed declarations. */ +export function useExtension() { const context = useContext(ExtensionContext) + if (!context) throw new Error("useExtension must run inside an extension contribution") - return context + + // SAFETY: the host's context for an extension also implements `SetupContext` of that extension's definition. + return context as [D] extends [never] ? Context : SetupContext } export interface PanelSidebar { @@ -40,7 +51,9 @@ export const PanelContext = createContext() export function usePanel() { const frame = useContext(PanelContext) + if (!frame) throw new Error("usePanel must run inside a panel render") + return frame } @@ -57,10 +70,13 @@ export function useDrawer() { * cancel. Load lazy chunks this way from setup, so they are compiled before a session first opens. */ export function onIdle(fn: () => void) { - if (typeof requestIdleCallback === "function") { + if (typeof requestIdleCallback !== "undefined") { const id = requestIdleCallback(fn) + return () => cancelIdleCallback(id) } + const id = setTimeout(fn, 200) + return () => clearTimeout(id) } diff --git a/script/lint-changed.ts b/script/lint-changed.ts new file mode 100644 index 000000000000..c676183e4b3d --- /dev/null +++ b/script/lint-changed.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env bun +// Fails when a GUI package file this branch adds or edits has any oxlint problem, including warn-level rules (anti-slop): +// a change that touches a file leaves the whole file clean. Untouched files and other packages are not checked. +// Usage: bun script/lint-changed.ts [base-ref] (default: merge base with upstream/v2, origin/v2, or v2) +import { $ } from "bun" + +type Diagnostic = { + message: string + code: string + filename: string + labels: { span: { line: number; column: number } }[] +} + +const base = await resolveBase(process.argv[2] ?? process.env.LINT_BASE) +const tracked = (await $`git diff --name-only --diff-filter=ACMR ${base}`.text()).split("\n") +const untracked = (await $`git ls-files --others --exclude-standard`.text()).split("\n") +// The packages .oxlintrc.json applies the anti-slop rules to. +const packages = ["packages/app/", "packages/desktop/", "packages/gui-extensions/", "packages/ui/", "packages/session-ui/"] +const files = [...new Set([...tracked, ...untracked])].filter( + (file) => + /\.(ts|tsx)$/.test(file) && packages.some((prefix) => file.startsWith(prefix)) && Bun.file(file).size > 0, +) + +if (files.length === 0) { + console.log(`lint:changed: no changed TypeScript files since ${base.slice(0, 10)}`) + process.exit(0) +} + +const report = JSON.parse(await $`bunx oxlint --format json ${files}`.nothrow().quiet().text()) as { + diagnostics: Diagnostic[] +} + +report.diagnostics.forEach((diagnostic) => { + const span = diagnostic.labels[0]?.span + console.log(`${diagnostic.filename}:${span?.line}:${span?.column} ${diagnostic.code} ${diagnostic.message}`) +}) +const dirty = new Set(report.diagnostics.map((diagnostic) => diagnostic.filename)) +console.log( + `lint:changed: ${report.diagnostics.length} problem(s) in ${dirty.size} of ${files.length} changed files since ${base.slice(0, 10)}`, +) +process.exit(report.diagnostics.length > 0 ? 1 : 0) + +async function resolveBase(explicit: string | undefined) { + if (explicit) return (await $`git rev-parse ${explicit}`.text()).trim() + const refs = ["upstream/v2", "origin/v2", "v2"] + const found = await Promise.all( + refs.map(async (ref) => (await $`git rev-parse --verify --quiet ${ref}`.nothrow().quiet()).exitCode === 0), + ) + const ref = refs.find((_, index) => found[index]) + if (!ref) throw new Error("lint:changed: no v2 ref found; pass a base ref") + return (await $`git merge-base HEAD ${ref}`.text()).trim() +} From 25d6a311ad86039fccbb17ccfd6d975b073e27db Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:33:02 +1000 Subject: [PATCH 02/16] refactor(gui-extensions): migrate built-ins to declared dependencies and lifetime primitives Phase 2 of the extension SDK overhaul. - Every built-in declares provides / uses / requires / stores and reads dependencies as Live values; actions that need an optional dependency answer while it is inactive. - About 88 raw effects replaced: createActive for side work per provider generation or identity, memos for derived values, createLatest for async data, createVisitState for per-visit state, handlers for user intent. - Review and summary state live in declared stores (same keys and legacy imports); session-store writes made before load are queued; session stores keep their own session identity. - Layout.sidebar.opened() replaces review's mirrored sidebar preference; Remote.ref declares a remote by id with type-only imports, so the browser's pane remote adds no schemas to startup. - src/renderer.ts and src/main.ts export Extension.compose; builtins.typecheck.ts fails to compile when a renderer remote has no main provider; the graph test covers every declared optional edge. - Deprecated shapes and Live.legacy are removed, and oxlint bans createEffect, createRenderEffect and createComputed in extension code outside src/sdk. - Solid computations created without an owner in tab-popover and the composer editor are created under their component; the btw keeper fails on the warning. --- .oxlintrc.json | 52 +++ .../browser-pane-restore.spec.ts | 117 +++--- .../component-tests/extension-graph.spec.ts | 3 +- .../extension-host.fixture.tsx | 8 +- .../app/e2e/regression/btw-sidebar.spec.ts | 9 + .../app/e2e/regression/settings-wsl.spec.ts | 37 ++ .../app/e2e/utils/settings-wsl.fixture.tsx | 136 ++++--- packages/app/src/composer/editor/editor.tsx | 131 +++++-- .../app/src/runtime/extension/attachment.ts | 15 + packages/app/src/runtime/extension/host.tsx | 61 +-- packages/app/src/runtime/extension/panels.tsx | 57 ++- packages/app/src/runtime/extension/root.tsx | 7 +- .../app/src/runtime/extension/services.tsx | 30 +- packages/app/src/runtime/extension/stores.ts | 55 ++- .../app/src/shell/titlebar/tab-popover.tsx | 18 +- .../test-browser/extension-primitives.test.ts | 57 ++- packages/desktop/src/main/extension/host.ts | 10 +- packages/gui-extensions/AGENTS.md | 16 +- .../gui-extensions/src/browser/contract.ts | 4 +- packages/gui-extensions/src/browser/index.ts | 10 +- packages/gui-extensions/src/browser/model.ts | 184 ++++++--- .../src/browser/panel.fixture.tsx | 213 ++++++++--- packages/gui-extensions/src/browser/panel.tsx | 252 ++++++++---- .../gui-extensions/src/browser/renderer.tsx | 44 ++- packages/gui-extensions/src/btw/model.ts | 24 +- packages/gui-extensions/src/btw/panel.tsx | 15 +- packages/gui-extensions/src/btw/renderer.tsx | 4 +- .../gui-extensions/src/builtins.typecheck.ts | 42 ++ packages/gui-extensions/src/debug/bar.tsx | 225 +++++++---- .../gui-extensions/src/debug/renderer.tsx | 4 +- .../gui-extensions/src/file/artifact-view.tsx | 156 +++++--- packages/gui-extensions/src/file/browser.tsx | 74 ++-- packages/gui-extensions/src/file/context.ts | 13 +- packages/gui-extensions/src/file/contract.ts | 2 +- packages/gui-extensions/src/file/index.ts | 21 +- packages/gui-extensions/src/file/list.tsx | 69 ++-- .../gui-extensions/src/file/open-in-app.tsx | 63 +-- packages/gui-extensions/src/file/renderer.tsx | 145 ++++--- packages/gui-extensions/src/file/sidebar.tsx | 69 ++-- packages/gui-extensions/src/file/tree-v2.tsx | 136 ++++--- packages/gui-extensions/src/file/tree.tsx | 89 +++-- packages/gui-extensions/src/file/view.tsx | 154 +++++--- packages/gui-extensions/src/main.ts | 11 +- packages/gui-extensions/src/pairing/index.ts | 3 + packages/gui-extensions/src/pairing/main.ts | 19 +- packages/gui-extensions/src/pairing/page.tsx | 45 ++- .../gui-extensions/src/pairing/renderer.tsx | 7 +- packages/gui-extensions/src/renderer.ts | 14 +- .../gui-extensions/src/review/contract.ts | 2 +- packages/gui-extensions/src/review/index.ts | 51 ++- packages/gui-extensions/src/review/mobile.tsx | 44 ++- packages/gui-extensions/src/review/model.ts | 361 ++++++++++-------- packages/gui-extensions/src/review/panel.tsx | 37 +- .../gui-extensions/src/review/renderer.tsx | 131 ++++--- .../src/sdk/compose.typecheck.ts | 17 + packages/gui-extensions/src/sdk/context.ts | 39 +- packages/gui-extensions/src/sdk/core.ts | 71 +++- packages/gui-extensions/src/sdk/main.ts | 8 +- packages/gui-extensions/src/sdk/reactive.ts | 33 +- packages/gui-extensions/src/sdk/services.ts | 20 +- packages/gui-extensions/src/ssh/cover.tsx | 35 +- packages/gui-extensions/src/ssh/dialog.tsx | 93 +++-- packages/gui-extensions/src/ssh/i18n/en.ts | 1 + packages/gui-extensions/src/ssh/index.ts | 3 + packages/gui-extensions/src/ssh/main.ts | 18 +- packages/gui-extensions/src/ssh/renderer.tsx | 103 +++-- packages/gui-extensions/src/ssh/state.ts | 77 +++- .../gui-extensions/src/summary/background.tsx | 26 +- packages/gui-extensions/src/summary/index.ts | 16 +- .../gui-extensions/src/summary/popover.tsx | 87 +++-- .../gui-extensions/src/summary/renderer.tsx | 73 ++-- .../src/summary/server-panel.tsx | 127 ++++-- packages/gui-extensions/src/terminal/model.ts | 187 ++++++--- .../gui-extensions/src/terminal/panel.tsx | 179 +++++---- .../gui-extensions/src/terminal/renderer.tsx | 52 ++- packages/gui-extensions/src/terminal/tab.tsx | 40 +- .../gui-extensions/src/terminal/terminal.tsx | 216 ++++++++--- .../gui-extensions/src/updater/actions.tsx | 21 +- packages/gui-extensions/src/updater/index.ts | 3 + packages/gui-extensions/src/updater/main.ts | 27 +- .../gui-extensions/src/updater/renderer.tsx | 39 +- packages/gui-extensions/src/usage/catalog.ts | 26 +- .../gui-extensions/src/usage/renderer.tsx | 9 +- packages/gui-extensions/src/usage/tab.tsx | 46 ++- packages/gui-extensions/src/wsl/index.ts | 3 + packages/gui-extensions/src/wsl/main.ts | 40 +- packages/gui-extensions/src/wsl/model.ts | 64 +++- packages/gui-extensions/src/wsl/probes.ts | 24 +- packages/gui-extensions/src/wsl/renderer.tsx | 59 ++- packages/ui/src/feedback/toast/toast.tsx | 38 +- 90 files changed, 3789 insertions(+), 1687 deletions(-) create mode 100644 packages/app/src/runtime/extension/attachment.ts create mode 100644 packages/gui-extensions/src/builtins.typecheck.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index fbc0b2040dcc..fc13a6f0859c 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -63,8 +63,60 @@ "anti-slop-effect/prefer-effect-match": "warn" } }, + { + "files": ["packages/gui-extensions/src/*.ts", "packages/gui-extensions/src/*.tsx"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "solid-js", + "importNames": ["createEffect", "createRenderEffect", "createComputed"], + "message": "Extension code runs side effects through the SDK: createActive(source, fn, { otherwise }) per provider generation or value, createMemo or a plain function for derived values, createLatest for async data, createVisitState for per-visit state, and the handler for logic a user action causes. An escape hatch needs an oxlint-disable comment with a reason." + } + ] + } + ] + } + }, { "files": ["packages/gui-extensions/src/*/**"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "solid-js", + "importNames": ["createEffect", "createRenderEffect", "createComputed"], + "message": "Extension code runs side effects through the SDK: createActive(source, fn, { otherwise }) per provider generation or value, createMemo or a plain function for derived values, createLatest for async data, createVisitState for per-visit state, and the handler for logic a user action causes. An escape hatch needs an oxlint-disable comment with a reason." + } + ], + "patterns": [ + { + "regex": "^@opencode/(app|desktop)(/|$)", + "message": "GUI extensions never import the app or desktop packages. Use the SDK." + }, + { + "regex": "^@/", + "message": "GUI extensions never import app internals. Use the SDK." + }, + { + "group": ["../*/*", "!../*/contract", "!../sdk/*"], + "message": "Import another extension only through its contract.ts." + }, + { + "regex": "\\.css$", + "message": "Import CSS with ?inline and contribute it with ctx.add(Style, css)." + } + ] + } + ] + } + }, + { + "files": ["packages/gui-extensions/src/sdk/**"], "rules": { "no-restricted-imports": [ "error", diff --git a/packages/app/component-tests/browser-pane-restore.spec.ts b/packages/app/component-tests/browser-pane-restore.spec.ts index 701c9a71f488..cf66269984ac 100644 --- a/packages/app/component-tests/browser-pane-restore.spec.ts +++ b/packages/app/component-tests/browser-pane-restore.spec.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from "node:url" import { expect, story } from "../../storybook/playwright/story" const source = (path: string) => `/@fs/${fileURLToPath(new URL(path, import.meta.url)).replaceAll("\\", "/")}` + const modules = { fixture: source("../../gui-extensions/src/browser/panel.fixture.tsx"), host: source("../src/runtime/extension/host.tsx"), @@ -13,49 +14,73 @@ const modules = { fileRenderer: source("../../gui-extensions/src/file/renderer.tsx"), } -story( - "keeps a restored browser tab selected and undrawn until the desktop's first inventory", - async ({ mount, page }) => { - // Any story loads the app styles; the fixture mounts the real side region and extensions beside it. - await mount("ui-line-comment--editor") - await page.evaluate(async (modules) => { - const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([ - import(modules.fixture), - import(modules.host), - import(modules.panels), - import(modules.language), - import(modules.browser), - import(modules.file), - ]) - mountBrowserRegion({ - LanguageProvider: language.LanguageProvider, - ExtensionHostProvider: host.ExtensionHostProvider, - useExtensionHost: host.useExtensionHost, - createRegion: panels.createRegion, - definitions: [ - { ...browser.default, renderer: () => import(modules.browserRenderer) }, - { ...file.default, renderer: () => import(modules.fileRenderer) }, - ], - }) - }, modules) - const root = page.getByTestId("browser-region-fixture") - const tabs = root.getByRole("tab") - const tree = root.getByTestId("tree") - await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible() - await expect(tabs).toHaveText(["alpha.ts"]) - await expect(tree).toHaveText('{"tab":"changes"}') - - // Beta was left on its browser tab, which the desktop has not reported yet. - await root.getByRole("button", { name: "Beta", exact: true }).click() - await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible() - await expect(root.getByTestId("selected")).toHaveText(/^browser:tab_/) - await expect(tabs).toHaveText(["beta.ts"]) - // No fallback tab was selected, so the file tab's selection never switched the tree to All files. - await expect(tree).toHaveText('{"tab":"changes"}') - - await root.getByRole("button", { name: "First inventory", exact: true }).click() - await expect(tabs).toHaveText(["beta.ts", "Preview"]) - await expect(root.getByRole("tab", { name: "Preview", exact: true })).toHaveAttribute("aria-selected", "true") - await expect(tree).toHaveText('{"tab":"changes"}') - }, -) +story.beforeEach(async ({ mount, page }) => { + // Any story loads the app styles; the fixture mounts the real side region and extensions beside it. + await mount("ui-line-comment--editor") + await page.evaluate(async (modules) => { + const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([ + import(modules.fixture), + import(modules.host), + import(modules.panels), + import(modules.language), + import(modules.browser), + import(modules.file), + ]) + + mountBrowserRegion({ + LanguageProvider: language.LanguageProvider, + ExtensionHostProvider: host.ExtensionHostProvider, + useExtensionHost: host.useExtensionHost, + createRegion: panels.createRegion, + definitions: [ + { ...browser.default, renderer: () => import(modules.browserRenderer) }, + { ...file.default, renderer: () => import(modules.fileRenderer) }, + ], + }) + }, modules) +}) + +story("keeps a restored browser tab selected and undrawn until the desktop's first inventory", async ({ page }) => { + const root = page.getByTestId("browser-region-fixture") + const tabs = root.getByRole("tab") + const tree = root.getByTestId("tree") + await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible() + await expect(tabs).toHaveText(["alpha.ts"]) + await expect(tree).toHaveText('{"tab":"changes"}') + + // Beta was left on its browser tab, which the desktop has not reported yet. + await root.getByRole("button", { name: "Beta", exact: true }).click() + await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible() + await expect(root.getByTestId("selected")).toHaveText(/^browser:tab_/) + await expect(tabs).toHaveText(["beta.ts"]) + // No fallback tab was selected, so the file tab's selection never switched the tree to All files. + await expect(tree).toHaveText('{"tab":"changes"}') + + await root.getByRole("button", { name: "First inventory", exact: true }).click() + await expect(tabs).toHaveText(["beta.ts", "Preview"]) + await expect(root.getByRole("tab", { name: "Preview", exact: true })).toHaveAttribute("aria-selected", "true") + await expect(tree).toHaveText('{"tab":"changes"}') +}) + +story("keeps the browser tabs while the pane's remote is away and registers them again when it returns", async ({ + page, +}) => { + const root = page.getByTestId("browser-region-fixture") + const tabs = root.getByRole("tab") + await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible() + await root.getByRole("button", { name: "Beta", exact: true }).click() + await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible() + await root.getByRole("button", { name: "First inventory", exact: true }).click() + await expect(tabs).toHaveText(["beta.ts", "Preview"]) + + // The pane's main extension reloads: every binding goes with it, and the strip keeps the tab it will restore. + await root.getByRole("button", { name: "Pane away", exact: true }).click() + await expect(tabs).toHaveText(["beta.ts", "Preview"]) + await expect(root.getByRole("tab", { name: "Preview", exact: true })).toHaveAttribute("aria-selected", "true") + + // Both attachments register again at once, without a retry timer; Beta hands main the tab to restore. + await root.getByRole("button", { name: "Pane back", exact: true }).click() + await expect(root.getByText("Registrations: 4", { exact: true })).toBeVisible() + await expect(root.getByText("Beta restores: 1", { exact: true })).toBeVisible() + await expect(tabs).toHaveText(["beta.ts", "Preview"]) +}) diff --git a/packages/app/component-tests/extension-graph.spec.ts b/packages/app/component-tests/extension-graph.spec.ts index b50d775f8685..48a7505cb263 100644 --- a/packages/app/component-tests/extension-graph.spec.ts +++ b/packages/app/component-tests/extension-graph.spec.ts @@ -47,11 +47,12 @@ story("built-ins: no requires cycle, and each consumer activates without each op return walk(definition.id, []) }) + // A renderer that uses the remote its own main entry provides depends on no other extension. const edges = definitions.flatMap((consumer) => ids(consumer.uses).flatMap((token) => { const provider = providerOf(token) - return provider ? [{ consumer: consumer.id, provider, token }] : [] + return provider && provider !== consumer.id ? [{ consumer: consumer.id, provider, token }] : [] }), ) diff --git a/packages/app/component-tests/extension-host.fixture.tsx b/packages/app/component-tests/extension-host.fixture.tsx index 70aad2d13bde..de11981db9ae 100644 --- a/packages/app/component-tests/extension-host.fixture.tsx +++ b/packages/app/component-tests/extension-host.fixture.tsx @@ -46,7 +46,7 @@ export async function until(check: () => boolean) { /** Mounts the real extension host with one extension whose every renderer load settles when the test says so. */ export function mountExtensionHost() { - const loads: PromiseWithResolvers<{ default: Setup }>[] = [] + const loads: PromiseWithResolvers<{ default: Setup }>[] = [] const [disabled, setDisabled] = createSignal>(new Set()) const hosts: Host[] = [] const container = document.createElement("div") @@ -67,7 +67,7 @@ export function mountExtensionHost() { { id: "fixture", renderer: () => { - const load = Promise.withResolvers<{ default: Setup }>() + const load = Promise.withResolvers<{ default: Setup }>() loads.push(load) return load.promise @@ -91,7 +91,7 @@ export function mountExtensionHost() { container.remove() }, /** Resolves the nth renderer load (the first by default) with this setup. */ - load: (setup: Setup, index = 0) => loads[index].resolve({ default: setup }), + load: (setup: Setup, index = 0) => loads[index].resolve({ default: setup }), /** Rejects the nth renderer load. */ fail: (index: number, cause: unknown) => loads[index].reject(cause), /** Renderer loads requested so far. */ @@ -146,7 +146,6 @@ export function mountExtensions(input: { return persistedHandle({ store: pair[0], update: (mutation: (draft: S["Type"]) => void) => pair[1](produce(mutation)), - ready: pair[3], init: pair[3].promise, }) }, @@ -167,6 +166,7 @@ export function mountExtensions(input: { state: () => "closed", stored: () => [], side: { opened: () => false, toggle() {} }, + sidebar: { opened: () => true }, dock: { opened: () => false, placement: () => "bottom" }, scroll: { get: () => undefined, set() {} }, settings() {}, diff --git a/packages/app/e2e/regression/btw-sidebar.spec.ts b/packages/app/e2e/regression/btw-sidebar.spec.ts index 8cca1e66c11a..179da39ed294 100644 --- a/packages/app/e2e/regression/btw-sidebar.spec.ts +++ b/packages/app/e2e/regression/btw-sidebar.spec.ts @@ -11,14 +11,22 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page const generated = Promise.withResolvers() const main = { id: "ses_btw_sidebar", title: "Side question session" } const other = { id: "ses_btw_sidebar_other", title: "Other side question session" } + const ownerWarnings: string[] = [] + page.on("console", (message) => { + if (message.text().includes("computations created outside a `createRoot` or `render`")) + ownerWarnings.push(message.text()) + }) + const { editor } = await openSession(page, { name: "BtwSidebar", sessions: [main, other], onPrompt: (input) => prompts.push(input), generate: async (input) => { generations.push(input) + if (input.sessionID === other.id) return { text: "This answer belongs to the **other session**." } await generated.promise + return { text: "The retry loop uses **exponential backoff** and stops after three attempts.\n\n```ts\nconst delay = 2 ** attempt\n```", } @@ -69,4 +77,5 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page await expectSessionTitle(page, main.title) await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0) await expect(panel).toHaveCount(0) + expect(ownerWarnings).toEqual([]) }) diff --git a/packages/app/e2e/regression/settings-wsl.spec.ts b/packages/app/e2e/regression/settings-wsl.spec.ts index af80a91989d1..a0e8357af972 100644 --- a/packages/app/e2e/regression/settings-wsl.spec.ts +++ b/packages/app/e2e/regression/settings-wsl.spec.ts @@ -30,6 +30,7 @@ for (const mode of ["failed", "stopped", "ready"] as const) { await page.getByRole("menuitem", { name: "Retry start", exact: true }).click() await expect(page.getByLabel("WSL actions")).toHaveText("start:wsl:Ubuntu") } + await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeEnabled() await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click() await expect(page.getByLabel("WSL actions")).toContainText("update:Ubuntu") @@ -64,10 +65,33 @@ test("adding a WSL server while the WSL extension is down shows that WSL is unav await expect(dialog.getByText("WSL is unavailable", { exact: true })).toBeVisible() }) +test("adding an SSH server while the SSH extension is down fails instead of connecting forever", async ({ page }) => { + await mockOpenCodeServer(page, { + directory: "/repo", + project: project({ id: "proj_ssh_unavailable", directory: "/repo", name: "SSH project" }), + provider: NO_PROVIDER, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready" })}`) + const settings = page.getByTestId("settings-screen") + await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(1) + await page.getByRole("checkbox", { name: "SSH extension" }).uncheck() + + await settings.getByRole("button", { name: "Add server", exact: true }).press("Enter") + await page.getByRole("menuitem", { name: "Add SSH server", exact: true }).click() + const dialog = page.getByRole("dialog") + await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox") + await dialog.getByRole("button", { name: "Add server", exact: true }).click() + await expect(dialog.getByRole("alert")).toHaveText("Request failed") + await expect(dialog.getByRole("button", { name: "Add server", exact: true })).toBeEnabled() +}) + test("an open session's terminal follows its WSL server to the endpoint it restarts on", async ({ page }) => { const restarted = "http://127.0.0.1:4098" const directory = "/home/ubuntu/project" const wsl = session({ id: "ses_wsl", directory, title: "WSL session" }) + const config = { directory, project: project({ id: "proj_wsl", directory }), @@ -75,11 +99,13 @@ test("an open session's terminal follows its WSL server to the endpoint it resta sessions: [wsl], pageMessages: () => ({ items: [] }), } + const servers = await mockServers(page, { [SERVER]: { ...config, sessions: [] }, [REMOTE_SERVER]: { ...config, pty: { prefix: "pty_before" } }, [restarted]: { ...config, pty: { prefix: "pty_after" } }, }) + const path = `/server/${base64Encode("wsl:Ubuntu")}/session/${wsl.id}` await page.goto( `/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready", wsl: REMOTE_SERVER, restart: restarted, path })}`, @@ -107,6 +133,7 @@ test("an open session's terminal follows its WSL server to the endpoint it resta test("WSL session and draft tabs outlive the extension going away until the server is removed", async ({ page }) => { const directory = "/home/ubuntu/project" const wsl = session({ id: "ses_wsl_tabs", directory, title: "WSL tabs session" }) + const config = { directory, project: project({ id: "proj_wsl_tabs", directory }), @@ -114,6 +141,7 @@ test("WSL session and draft tabs outlive the extension going away until the serv sessions: [wsl], pageMessages: () => ({ items: [] }), } + await mockServers(page, { [SERVER]: { ...config, sessions: [] }, [REMOTE_SERVER]: config }) const href = `/server/${base64Encode("wsl:Ubuntu")}/session/${wsl.id}` await page.goto( @@ -126,11 +154,13 @@ test("WSL session and draft tabs outlive the extension going away until the serv await editor.fill("keep this draft") await expect(editor).toHaveText("keep this draft") const tabs = page.locator("a[data-titlebar-tab-link]") + const expectTabs = async () => { await expect(tabs).toHaveCount(2) await expect(tabs.nth(0)).toHaveAttribute("href", href) await expect(tabs.nth(1)).toHaveAttribute("href", /^\/new-session\?draftId=/) } + await expectTabs() const extension = page.getByRole("checkbox", { name: "WSL extension" }) @@ -155,6 +185,7 @@ test("WSL session and draft tabs outlive the extension going away until the serv test("an SSH host that asks for sign-in again opens the dialog once per selected tab", async ({ page }) => { const directory = "/home/box/project" const box = session({ id: "ses_ssh_offer", directory, title: "SSH offer session" }) + const config = { directory, project: project({ id: "proj_ssh_offer", directory }), @@ -162,6 +193,7 @@ test("an SSH host that asks for sign-in again opens the dialog once per selected sessions: [box], pageMessages: () => ({ items: [] }), } + await mockServers(page, { [SERVER]: { ...config, sessions: [] }, [REMOTE_SERVER]: config }) const path = `/server/${base64Encode("ssh:box")}/session/${box.id}` await page.goto( @@ -199,14 +231,17 @@ test("an SSH host that asks for sign-in again opens the dialog once per selected test("an open session moves to the controller a new SSH sign-in creates", async ({ page }) => { const directory = "/home/box/project" const model = { id: "box-model", name: "Box Model" } + const box = session({ id: "ses_ssh", directory, title: "SSH session", model: { id: model.id, providerID: "opencode" }, }) + const remote = { password: "ssh-1" } const prompts: unknown[] = [] + const config = { directory, project: project({ id: "proj_ssh", directory }), @@ -214,6 +249,7 @@ test("an open session moves to the controller a new SSH sign-in creates", async sessions: [box], pageMessages: () => ({ items: [] }), } + const servers = await mockServers(page, { [SERVER]: { ...config, sessions: [] }, [REMOTE_SERVER]: { @@ -222,6 +258,7 @@ test("an open session moves to the controller a new SSH sign-in creates", async onPrompt: (input) => prompts.push(input.body.text), }, }) + const path = `/server/${base64Encode("ssh:box")}/session/${box.id}` await page.goto( `/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready", ssh: REMOTE_SERVER, path })}`, diff --git a/packages/app/e2e/utils/settings-wsl.fixture.tsx b/packages/app/e2e/utils/settings-wsl.fixture.tsx index 0848a08a146d..b3bd90574e40 100644 --- a/packages/app/e2e/utils/settings-wsl.fixture.tsx +++ b/packages/app/e2e/utils/settings-wsl.fixture.tsx @@ -10,6 +10,12 @@ import { PlatformProvider, type Platform } from "../../src/runtime/platform/plat import { ServerConnection } from "../../src/runtime/server/registry" import { useExtensionServers } from "../../src/runtime/extension/servers" +/** The input of a WSL or SSH method this fixture answers. */ +type FixtureInput = { id?: string; name?: string } + +/** A main-side method: SSH `start` answers with the state revision, the others with nothing. */ +type FixtureMethod = (input: FixtureInput) => number | void + // A desktop window. `wsl` is the Ubuntu server's endpoint (default `server`); updating OpenCode restarts it on `restart` // (default `wsl`). `ssh` adds a saved SSH server `box`, ready on that endpoint with password `ssh-1`; each connect // brings up a new remote server with the next password (`ssh-2`, ...). `storage=async` stores in localStorage behind @@ -25,23 +31,28 @@ export function mount(input: { hold?: string | null }) { const root = document.getElementById("root") + if (!root) throw new Error("Missing fixture root") const history = createMemoryHistory() history.set({ value: input.path ?? "/settings", replace: true, scroll: false }) const endpoint = { url: input.wsl ?? input.server } const ready = () => ({ kind: "ready" as const, url: endpoint.url, password: null }) const held = Promise.withResolvers() + const storage = (name?: string) => { const item = (key: string) => (name ? `${name}:${key}` : key) + return { getItem: async (key: string) => { if (input.hold && key.includes(input.hold)) await held.promise + return localStorage.getItem(item(key)) }, setItem: async (key: string, value: string) => localStorage.setItem(item(key), value), removeItem: async (key: string) => localStorage.removeItem(item(key)), } } + render(() => { const [store, setStore] = createStore<{ calls: string[] @@ -77,12 +88,13 @@ export function mount(input: { servers: [ { config: { id: "wsl:Ubuntu", distro: "Ubuntu" }, - runtime: - input.mode === "ready" - ? ready() - : input.mode === "failed" - ? { kind: "failed", message: "WSL failed to start" } - : { kind: "stopped" }, + runtime: ( + { + ready: ready(), + failed: { kind: "failed", message: "WSL failed to start" }, + stopped: { kind: "stopped" }, + } satisfies Record + )[input.mode], }, ], opencodeChecks: { @@ -97,71 +109,87 @@ export function mount(input: { }, }, }) + // The main-process WSL and SSH extensions, as the extension bridge sees them. const listeners = new Set<(message: BridgeMessage) => void>() const snapshot = (remote: string) => structuredClone(unwrap(remote === "ssh" ? store.ssh : store.state)) + const publish = (remote: string) => listeners.forEach((listener) => listener({ type: "state", remote, state: snapshot(remote) })) + // The contract state is deeply readonly, so each action replaces the changed branch. const setRuntime = (id: string | undefined, runtime: WslServerRuntime) => setStore("state", (state) => ({ servers: state.servers.map((server) => (server.config.id === id ? { ...server, runtime } : server)), })) + const setSsh = (item: Partial) => setStore("ssh", (state) => ({ revision: state.revision + 1, servers: state.servers.map((server) => ({ ...server, ...item })), })) - const methods: Record unknown>> = { - wsl: { - // Like main: stops the distro's server, updates OpenCode, then starts the server again on a new endpoint. - installOpencode(value) { - const name = value.name ?? "" - const id = store.state.servers.find((server) => server.config.distro === name)?.config.id - setStore("calls", (calls) => [...calls, `update:${value.name}`]) - setRuntime(id, { kind: "stopped" }) - publish("wsl") - setStore("state", (state) => ({ - opencodeChecks: { - ...state.opencodeChecks, - [name]: { ...state.opencodeChecks[name]!, version: "current", matchesDesktop: true }, - }, - })) - endpoint.url = input.restart ?? endpoint.url - setRuntime(id, ready()) - }, - startServer(value) { - setStore("calls", (calls) => [...calls, `start:${value.id}`]) - setRuntime(value.id, ready()) - }, - removeServer(value) { - setStore("calls", (calls) => [...calls, `remove:${value.id}`]) - setStore("state", (state) => ({ servers: state.servers.filter((server) => server.config.id !== value.id) })) - }, + + const wsl = { + // Like main: stops the distro's server, updates OpenCode, then starts the server again on a new endpoint. + installOpencode(value) { + const name = value.name ?? "" + const id = store.state.servers.find((server) => server.config.distro === name)?.config.id + setStore("calls", (calls) => [...calls, `update:${value.name}`]) + setRuntime(id, { kind: "stopped" }) + publish("wsl") + setStore("state", (state) => ({ + opencodeChecks: { + ...state.opencodeChecks, + [name]: { ...state.opencodeChecks[name]!, version: "current", matchesDesktop: true }, + }, + })) + endpoint.url = input.restart ?? endpoint.url + setRuntime(id, ready()) }, - ssh: { - // Like main: a connect opens a new tunnel to a remote server with a new password. - start() { - setStore("connects", (count) => count + 1) - setSsh({ stage: "ready", http: { url: input.ssh ?? "", password: `ssh-${store.connects + 1}` } }) - return store.ssh.revision - }, + startServer(value) { + setStore("calls", (calls) => [...calls, `start:${value.id}`]) + setRuntime(value.id, ready()) }, - } + removeServer(value) { + setStore("calls", (calls) => [...calls, `remove:${value.id}`]) + setStore("state", (state) => ({ servers: state.servers.filter((server) => server.config.id !== value.id) })) + }, + } satisfies Readonly> + + const ssh = { + // Like main: a connect opens a new tunnel to a remote server with a new password. + start() { + setStore("connects", (count) => count + 1) + setSsh({ stage: "ready", http: { url: input.ssh ?? "", password: `ssh-${store.connects + 1}` } }) + + return store.ssh.revision + }, + } satisfies Readonly> + + const methods = new Map>>([ + ["wsl", wsl], + ["ssh", ssh], + ]) + const bridge: Bridge = { async call(request) { - const method = methods[request.remote]?.[request.method] + const method = methods.get(request.remote)?.[request.method] + if (!method) throw new Error("Unexpected fixture action") - const result = method(request.input as { id?: string; name?: string }) + // SAFETY: every WSL and SSH method the fixture answers takes a struct of these optional string fields. + const result = method(request.input as FixtureInput) publish(request.remote) + return result ?? null }, async subscribe(remote) { if (remote === "wsl" || remote === "ssh") return { available: true, state: snapshot(remote) } + return { available: false } }, on(listener) { listeners.add(listener) + return () => listeners.delete(listener) }, surface: () => undefined, @@ -179,9 +207,11 @@ export function mount(input: { asset: () => "", }, } + const unused = async () => { throw new Error("Unexpected fixture action") } + const platform: Platform = { platform: "desktop", os: "windows", @@ -191,14 +221,18 @@ export function mount(input: { notify: async () => undefined, restart: unused, extensions: bridge, - ...(input.storage === "async" ? { storage } : {}), } + + if (input.storage === "async") platform.storage = storage + function Interface() { const extensions = useExtensionServers() + const servers = createMemo(() => [ { type: "sidecar", variant: "base", displayName: "Local Server", http: { url: input.server } }, ...extensions.list(), ]) + return ( ) } + return ( @@ -222,11 +257,26 @@ export function mount(input: { const available = event.currentTarget.checked setStore("available", available) listeners.forEach((listener) => listener({ type: "available", remote: "wsl", available })) + if (available) publish("wsl") }} /> WSL extension + {/* The SSH extension's main side going away and coming back. */} + {/* The SSH tunnel dropping, which only main notices. */}