| .. | ||
| src | ||
| AGENTS.md | ||
| bunfig.toml | ||
| happydom.ts | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
GUI extensions
Every feature of the desktop and web app that is not the core shell is an extension: the terminal, review, files, the browser, SSH, WSL, the updater and more. Each one builds only on the SDK in src/sdk. The SDK is typed so that the common bugs fail to compile or fail the lint, and every declaration has TSDoc, so editor hovers answer most questions. This guide shows how the parts fit, with code from the extensions that ship. Build your first extension walks through src/pairing, a small built-in that uses both processes. bun run lint checks every code block marked with its source against that source.
- Window SDK:
@opencode/gui-extensions/sdk(index.ts) - Main-process SDK:
@opencode/gui-extensions/sdk/main(main.ts) - Rules for agents:
AGENTS.md
flowchart LR
subgraph main["Electron main (desktop only)"]
mh["main host<br/>packages/desktop/src/main/extension"] --> me["main.ts entries"]
end
subgraph window["Each window (desktop or web)"]
wh["window host<br/>packages/app/src/runtime/extension"] --> we["renderer.tsx entries"]
we -- "ctx.add(Point, item)" --> wh
we -- "Contract (in process)" --> other["other extensions"]
end
me -- "Ipc: methods, state, events<br/>(schemas over the bridge)" --> we
Anatomy
src/pairing/
├── index.ts Extension.define: id, provides, uses, requires, stores, i18n
├── contract.ts tokens other code may import: Ipc, Contract, Point
├── renderer.tsx window entry, default export Setup<typeof definition>
├── main.ts main entry, default export MainSetup<typeof definition> (desktop only)
├── page.tsx heavy UI, loaded with lazy()
└── i18n/en.ts English copy; other locales load when picked
Unit tests of logic that carries a contract sit beside the code as *.test.ts, as in src/ssh/ and src/updater/.
Extension.define is the manifest. The host reads it before any entry loads.
| Field | What it declares | In the context |
|---|---|---|
id |
Prefix of every id: commands, panel keys, stored keys, contract and Ipc ids | ctx.id |
os |
The operating systems it runs on; omit it to run everywhere, the web included | |
provides |
Contracts from the window entry, Ipcs from the main entry | ctx.provide(token, impl), and ctx.uses.name() |
uses |
Optional dependencies other extensions provide; it works while one is missing | ctx.uses.name() is Live<T> |
requires |
Hard dependencies; setup runs only while all are active | ctx.requires.name is T |
stores |
State the host stores: window stores load before they are read, main's always | ctx.stores.name, each process its own |
i18n |
The extension's copy | ctx.t, ctx.plural |
src/renderer.tsandsrc/main.tslist the built-ins, each throughExtension.compose. These are the only files that name extensions.- Another extension imports only your
contract.ts. - A window entry loads with the app. Keep it small and put heavy UI behind
lazy().
The context
Setup receives one context. Components read the same object with useExtension<typeof definition>().
| Member | Window | Main |
|---|---|---|
| Host APIs | ctx.layout, ctx.sessions, ctx.storage, ctx.desktop, … (see the catalog) |
ctx.storage, ctx.windows, … |
| Optional dependencies | ctx.uses.name: Accessor<Live<T>>, provides included |
|
| Hard dependencies | ctx.requires.name: T |
|
| Declared stores | ctx.stores.name: Persisted, or (session) => Persisted |
ctx.stores.name: Persisted, loaded |
| Contribute to a point | ctx.add(Point, item) |
ctx.add(MenubarItem, item) |
| Provide a dependency | ctx.provide(Contract, impl) |
ctx.provide(Ipc, impl) |
| Lifetime | Solid's onCleanup and ctx.signal |
ctx.scope (signal, addFinalizer) |
| Copy | ctx.t(key, params), ctx.plural(key, count) |
the same |
- Host APIs are getters: an API you never read costs nothing.
- No host API throws before the app interface mounts: reads return their documented defaults (
ctx.layout.ready()is false), and writes such asctx.layout.openwait, then apply in call order. A dialogctx.dialogs.openopens meanwhile shows after the interface's first render. - Points and contracts are tokens:
ctx.add(Command, …),ctx.provide(FileTree, …). - Reading a token you did not declare is a compile error. A key that names one token in
providesand another inusesis one too.
const setup: Setup<typeof definition> = (ctx) => {
const changes = ctx.uses.changes // Accessor<Live<Changes>>
const prefs = ctx.stores.prefs.value // loaded before setup
ctx.add(Command, { id: "open", title: ctx.t("open"), run: () => ctx.layout.settings(ctx.id) })
}
Lifetimes
stateDiagram-v2
[*] --> loading: enabled
loading --> active: setup settled
loading --> failed: setup threw
active --> failed: an effect threw
active --> loading: reload, or a requires provider restarted
active --> disabled: disabled
active --> [*]: window closed
note right of active
Everything registered through ctx is withdrawn on leaving:
contributions, contracts, Ipc listeners, dialogs, embeds.
A registration made after its owner ended is withdrawn at once.
end note
- A registration ends with the current Solid owner: a component, a
createKeyedrun, or else the extension. - Teardown of other work:
onCleanupin the window,ctx.scope.addFinalizerin main. Setup returns nothing. - Setup may be async. After each
awaitthere is no owner. Return ifctx.signal.aborted(main:ctx.scope.signal).
The updater listens to its main side's check event once per generation of that side. When main restarts, the run ends and its listener goes with it:
// Beta builds answer the app menu's Check for Updates in the focused window instead of a native dialog. The
// listener ends with the generation of the main side that sends it.
createKeyed(updater, (client) => void client.on("check", () => act("check")))
The routed session
flowchart LR
route["route: A → B → A"] --> objects["MountedSession objects: A₁, B₁, A₂"]
route --> screen["one SessionScreen: file, comment, composer"]
objects -- "props.session / input.session (getters)" --> renders["panel and slot renders, mounted once"]
objects -- "ctx.sessions.current()" --> setup["setup and handlers"]
screen -- "ctx.screen.current()" --> actions["actions on the routed session"]
- Each routed session gets its own frozen
MountedSession:key,id,tab,server,directoryandvisitnever change on one object, andlocation,projectand the other fields read that session's data, never the route's. It has nothing that acts on another session. - A session that moves to another directory gets a new object too, with the new
directoryand the samekeyandvisit. Key per-session state bykey. - Renders stay mounted. The host hands them the next object through a reactive getter, so read
props.session(panels),input.session(slots) orstate.session(tab labels) where you use it, and never copy it into a variable. - The workspace files, line comments and composer follow the route, so they belong to the session screen. An action through it targets the session routed at that moment. Read it inside a render or a handler, not once in setup. Key per-screen state, such as cached tab objects, by the screen; key per-session state by
session.key. - The screen and its session are two different checks:
| Read | Defined | Undefined |
|---|---|---|
ctx.screen.current() |
While a session screen is mounted: one object from its first render until it unmounts | On Home, on a draft (/new-session renders the new-session page, not a session screen), and before the app interface mounts |
ctx.screen.current()?.session |
While that screen routes a session: the same object as ctx.sessions.current() |
Whenever ctx.sessions.current() is: during the screen's first render, before it registers the routed session, and while the route has left the screen for Home or a draft that has not replaced it yet |
ctx.add(Panel, {
id: "main",
region: "side",
list: (_session, open) => (open.includes("main") ? [tab] : []),
// Runs once; `props.session` returns B's object after a switch to B, without a remount.
render: (props) => <View session={props.session} tab={props.tab} />,
})
// The composer follows the route, so it belongs to the screen: the file reaches the session routed when this runs.
ctx.add(Command, {
id: "attach-readme",
title: ctx.t("command.attachReadme"),
get enabled() {
return !!ctx.screen.current()
},
run: () => ctx.screen.current()?.composer.attach({ type: "file", path: "README.md" }),
})
Primitives
| Primitive | Use it for |
|---|---|
Extension.define(definition) |
The manifest, typed so Setup<typeof definition> sees the declarations |
Extension.compose(...definitions) |
A process's list; fails to compile on a missing or duplicate provider |
Point.define<T>(id) |
A place your extension renders and others contribute to |
Contract.define<T, Id>(id) |
An in-process API one extension provides to others |
Ipc.define(spec) / Ipc.ref<typeof T>(id) |
The main ↔ window contract, or a reference to it that loads no schemas |
Store.global / Store.session / Store.main |
Declared window state the host loads before you read it, and main state |
createKeyed(source, fn, { otherwise, equals }) |
Side effects per provider generation or per value; the only sanctioned effect |
createLatest(source, fetch) |
Async data that never suspends and drops stale replies |
createVisitState(initial) |
State that resets each time the user routes back to the session |
useExtension / usePanel / useDrawer |
The context, the panel frame, and the narrow-screen drawer in components |
onIdle(fn) |
Preloading a lazy chunk while the app is idle |
Scope (main) |
ctx.scope: signal, addFinalizer, fork, close |
Lifetimes shows createKeyed in the updater.
// Async data: `latest` stays while a new request runs; a stale reply is dropped.
const info = createLatest(ctx.uses.pairing, (pairing, signal) => pairing.info(undefined, { signal }))
// Forget a selection when the user goes Home and back.
const [selected, setSelected] = createVisitState<string | undefined>(undefined)
// Compile the settings page while the app idles, not when settings opens.
const Page = lazy(() => import("./page"))
onCleanup(onIdle(() => void Page.preload()))
Catalog
Each line links to the file whose TSDoc covers every field.
Points
| Point | Process | What an item is |
|---|---|---|
Command |
window | A palette command with an optional keybind and slash command |
MenuItem |
window | An item of a host menu: the side panel + menu, Add server, a server row |
Panel |
window | Tabs in the session's side region, or the dock |
SettingsPage |
window | A settings page, a section on a host page, or rows in a host section |
Server |
window | A source of servers, such as SSH hosts |
LinkHandler |
window | Opens local links, such as file paths in messages |
TitlebarItem |
window | A titlebar pill, or the dev channel badge as a toggle |
Slot |
window | Content for window.bottom, session.header, session.panel.end, session.panel.sidebar |
Style |
window | CSS imported with ?inline |
MenubarItem |
main | An item of the native app menu |
Host APIs
| Property | Type (window, main) | What it does |
|---|---|---|
ctx.layout |
Layout |
Side panel tabs, the dock, scroll offsets, settings, open project |
ctx.sessions |
Sessions |
Sessions of open tabs, and the mounted MountedSession |
ctx.screen |
Screen |
The session screen's files, comments and composer |
ctx.storage |
Storage |
Stores for keys known only at runtime, and window memory |
ctx.system |
System |
Clipboard, saving files, openExternal |
ctx.desktop |
Desktop | undefined |
Desktop-only: reveal, launch, installed, zoom, forceFocus |
ctx.dialogs |
Dialogs |
open returns a handle to close; a dialog closes with its owner |
ctx.links |
Links |
Routes a local link to the best LinkHandler |
ctx.embeds |
Embeds |
Shows a web page the main entry created |
ctx.build |
Build |
Version, channel, platform, packaged |
ctx.locale |
Locale |
Locale and writing direction |
ctx.appearance |
Appearance |
The user's mono font |
ctx.router |
Router |
The route path, and whether it is changing |
ctx.keybinds |
Keybinds |
Display and matching of command keybinds |
ctx.servers |
Servers |
Ids of the servers the app lists |
ctx.workspaces |
Workspaces |
on("remove", …) when a workspace is removed |
ctx.scope (main) |
Scope |
The instance's lifetime |
ctx.storage (main) |
Storage |
Synchronous storage, the same Persisted shape |
ctx.log (main) |
Log |
The desktop log file |
ctx.lifecycle (main) |
Lifecycle |
restart(handoff, { keep }) |
ctx.build (main) |
Build |
The same shape; platform is "desktop" |
ctx.serverEndpoints (main) |
ServerEndpoints |
URL and credentials of a server the windows use |
ctx.windows (main) |
Windows |
The app's main windows |
ctx.embeds (main) |
Embeds |
create(view, window) places a web page |
ctx.cli (main) |
Cli |
The opencode CLI the app runs |
Ipc: main ↔ window
An Ipc is the typed contract between an extension's main entry and its windows. Its schemas encode every value that crosses the bridge.
sequenceDiagram
participant W as updater window (ctx.uses.updater)
participant B as bridge
participant M as updater main (ctx.provide)
W->>B: subscribe
B->>M: state(window)
M-->>W: available + state snapshot
W->>B: check(), input encoded
B->>M: decoded input, Caller { window, signal }
M->>M: the state moves to "checking", provider.changed()
M-->>W: state push (an event always wins over an older snapshot)
M-->>W: reply, output encoded
M-->>W: emit("check", null, window): the app menu asks this window to check
- Define it in
contract.ts. Its id is your extension id, or<id>.<name>. - Your own Ipc goes in
providesonly: the main entry provides it, and the window entry reads it asctx.uses.name. Another extension's Ipc goes inuses. - On the web there is no main process: the Ipc is always
inactive. IpcsProvided<typeof renderer, typeof main>fails to compile when a window provides, uses or requires an Ipc that no main entry provides.src/builtins.typecheck.tschecks the built-ins.- To name an Ipc without loading its schemas at startup, declare a reference and load the full token from the chunk that needs it:
// browser/index.ts: a type-only import, so no schemas load at startup
import type { BrowserPane } from "./ipc"
const Pane = Ipc.ref<typeof BrowserPane>("browser.pane")
export default Extension.define({ id: "browser", provides: { pane: Pane } })
// browser/model.ts: a chunk that loads with the first session and imports the full token
const pane = ctx.uses.pane.load(BrowserPane) // pending until this runs, here and in other extensions
A reference cannot go in requires: setup would wait for a load that only setup can make.
Live and failure handling
ctx.uses.name() returns Live<T>, one object per provider transition.
| Status | When | What the user must see |
|---|---|---|
pending |
The provider has not started, e.g. main is not up yet | A loading state, or nothing to click |
active |
value is the contract or client; generation counts restarts |
The feature |
inactive, "restarting" |
It was active and comes back | Keep what the user had; resume after |
inactive, "disabled" |
Turned off, not composed, or not on this platform (every Ipc on the web) | An unavailable state, never a dead button |
inactive, "failed" |
Its setup threw | An unavailable state |
- Offer an action only while it can answer, or let it answer with an unavailable state. Never a silent
return, never an endless spinner. createKeyedruns once per active generation. What the run registers ends with that generation.otherwiseruns while there is none.- An Ipc that goes away is a suspension, not a failure: keep what the user had and resume when it returns. No retry timer.
- An Ipc call can reject while the event that explains it is still in flight. When main reports an outcome as an event, let the event decide.
- A contribution that throws renders nothing and records the error. The rest of the window keeps working.
The updater's actions branch on the Live value and still answer while its main side is missing:
const act = (name: "check" | "install") => {
const live = updater()
if (live.status === "active") return void import("./actions").then((module) => module[name](ctx, live.value))
// Not loaded yet, or gone (disabled, failed, restarting): nothing can check or install.
showToast({ title: ctx.t("common.requestFailed") })
}
The pairing settings page renders only while its main side is up, so it never offers a control that cannot answer:
// The page shows nothing until the main side is up, and again while it is away.
export default function PairingPage(props: { pairing: Accessor<Live<Client>> }) {
const client = () => {
const live = props.pairing()
return live.status === "active" ? live.value : undefined
}
return <Show when={client()}>{(client) => <SettingsPairing client={client()} />}</Show>
}
Stored state
Desktop windows load storage over IPC; the web reads it synchronously. A read before load passes every web test and still breaks desktop, so declare your stores.
| Kind | Value | Loads |
|---|---|---|
Store.global(schema, initial, from?) |
window ctx.stores.name.value: never undefined |
Before setup |
Store.session(schema, initial, from?) |
window ctx.stores.name(session).value: undefined until loaded |
When the session mounts |
Store.main(schema, initial, from?) |
main ctx.stores.name.value: always defined |
Synchronously |
ctx.storage.store(key, options) |
window value: undefined until loaded |
When opened; for runtime keys |
main ctx.storage.store(key, options) |
value: always defined |
Synchronously; runtime keys |
Each process's ctx.stores holds only its own stores: reading a Store.main store in the window, or a window store in main, fails to compile.
Details lists older homes, newest first: its earlier id's namespace, then the app key before it.
// Stored under the extension's earlier id `summary`, and before extensions in the app settings.
prefs: Store.global(Prefs, { projectExpanded: true, serverExpanded: true }, [
"extension.summary.prefs",
{ key: "settings.v3", pick: (value: { sessionSummary?: unknown } | null) => value?.sessionSummary },
]),
Review imports one session's slice of an app key that holds every session:
// The mode, selected file and open files of each session.
session: Store.session(
SessionState,
{ open: [] },
{
key: "layout",
sessions: "sessionView",
// An entry that holds only other fields, such as its scroll, holds no review state.
pick: (entry: { reviewMode?: unknown; reviewFile?: unknown; reviewOpen?: unknown } | undefined) =>
entry && [entry.reviewMode, entry.reviewFile, entry.reviewOpen].some((field) => field !== undefined)
? { mode: entry.reviewMode, file: entry.reviewFile, open: entry.reviewOpen }
: undefined,
},
),
SSH keeps a main store, imported from a key of the desktop settings file. { state: [namespace, key] } names a key of another main storage namespace instead, and file another settings file:
// The saved hosts, which main keeps; stored before in the desktop settings file.
servers: Store.main(Schema.Array(SshConfig), [], { settings: "ssh.servers" }),
- Keys live in your namespace:
extension.<id>.<name>in the window, the store's name underextension.<id>in main. fromimports an older value once, while the store holds none. A list names older homes, newest first. Withpick, the older key stays for its other owners; a pick returns undefined when that key holds none of its fields, never an object of undefined fields, so the next home is read.update(fn)edits the draft, or returns the next value, which replaces the stored one, in both processes. In the window it waits for the load, then applies in call order.Storage.remove(key, { from })reads asinitialagain and never importsfromagain.- Renaming an extension moves three things: stored keys (
from), command ids (the keybind rename map inpackages/app/src/settings/keybinds/migration.ts), and panel keys (Panel.legacy). Never drop user data.
Runtime rules
- Derive, don't sync. A value computed from other state is a
createMemoor a plain function. Never an effect that calls a setter. - Effects only for external sync.
createKeyedsyncs with something outside Solid: the DOM, a widget, an embed, an Ipc subscription. Logic a user action causes goes in the handler. - Key an object source by its fields. A
createKeyedsource that builds an object, such as{ directory, tab }, is a new key on every read. Passequalsto compare the fields that should run it again. - The lint bans raw effects.
createEffect,createRenderEffectandcreateComputedmay not be imported outsidesrc/sdk. An escape hatch needs anoxlint-disablecomment with a reason. - Suspense only on first load. Wrap each
lazy()component in its own<Suspense>, read async data through.latestorcreateLatest, and never read a refetching resource in render: it blanks the nearest boundary. - Stable objects. Return the same object from
Panel.listand reactive contributions while nothing changed, so the host never remounts. - No timeline work.
session.headeris the only timeline surface. - No module state. Keep state inside setup; module state survives a reload and leaks across windows.
Testing
| Gate | Where | Catches |
|---|---|---|
| Composition compile checks | sdk/compose.typecheck.ts, builtins.typecheck.ts |
A missing requires provider, two providers of one token, a window Ipc no main entry provides, a key naming two tokens, a store read in the wrong process |
| Point compile checks | sdk/points.typecheck.ts |
A MenuItem field its menu ignores |
| Graph matrix | packages/app/component-tests/extension-graph.spec.ts |
A requires cycle; a consumer that fails when one optional provider is disabled |
| Keeper suites | packages/app/e2e/regression/ |
What the user sees, per product area |
| Unit tests | *.test.ts beside the code |
Pure logic with a contract: paths, migrations, protocols |
| Lint gate | bun run lint (oxlint, ast-grep, script/sdk-docs.ts); bun run lint:changed |
Raw effects, app imports, module state, undocumented SDK, a guide block that differs from its shipping source |
- Test a main entry through its
Ipccontract with real inputs, not Electron mocks. - The hosts own their contracts: main storage in
packages/desktop/src/main/extension/storage.test.ts, the real window host inpackages/app/component-tests/extension-host.spec.ts. An extension does not test them again. - Drive the race the user hits: a reload during async setup, a store that loads late, an Ipc that goes away and returns.
Build your first extension
Pairing shows another device how to reach this machine's server, and keeps the display awake. It is small and uses both processes: a main entry that owns a power-save blocker and the server's credentials, and a window entry that adds a settings page and a command. Each block below is its shipping source.
- Define the contract.
contract.tsholds the tokens other code may import, here the Ipc between pairing's main and window entries. The schemas type both sides and encode every value.
import { Schema } from "effect"
import { Ipc } from "../sdk"
export const PairingInfo = Schema.Struct({ urls: Schema.Array(Schema.String) })
/** Pairs other devices with this machine's local server and keeps its display awake. */
export const Pairing = Ipc.define({
id: "pairing",
methods: {
/** The local server's advertised URLs. */
info: { output: PairingInfo },
/** A single-use code for an `/auth/connect/:code` link. */
code: { output: Schema.String },
screenActive: { output: Schema.Boolean },
setScreenActive: { input: Schema.Boolean },
},
})
- Write the definition. Pairing provides its Ipc, so its window entry reads it as
ctx.uses.pairingwith no second declaration. Whether main keeps the display awake is a main store, imported once from the key the desktop kept it under before.
import { Schema } from "effect"
import { Extension, Store } from "../sdk"
import { Pairing } from "./contract"
import en from "./i18n/en"
export default Extension.define({
id: "pairing",
provides: { pairing: Pairing },
stores: {
// Whether main keeps the display awake; stored before in the desktop's own settings namespace.
keepScreenActive: Store.main(Schema.Boolean, false, { state: ["opencode.settings", "keepScreenActive"] }),
},
i18n: { en },
})
- Provide it from main.
MainSetup<typeof definition>typesctx.stores, which main reads synchronously. A finalizer releases the blocker when the instance goes away, and the sidecar's credentials never leave main.
import { powerSaveBlocker } from "electron"
import type { MainSetup } from "../sdk/main"
import { Pairing } from "./contract"
import type definition from "./index"
/** The display sleep blocker this instance holds, if any. */
type Blocker = { id?: number }
const setup: MainSetup<typeof definition> = (ctx) => {
const stored = ctx.stores.keepScreenActive
const blocker: Blocker = {}
const release = () => {
if (blocker.id === undefined) return
powerSaveBlocker.stop(blocker.id)
blocker.id = undefined
}
const keepScreenActive = (enabled: boolean) => {
if (enabled && blocker.id === undefined) blocker.id = powerSaveBlocker.start("prevent-display-sleep")
if (!enabled) release()
stored.update(() => enabled)
}
if (stored.value) keepScreenActive(true)
ctx.scope.addFinalizer(release)
const client = async () => {
const server = ctx.serverEndpoints.get("sidecar")
if (!server) throw new Error("The local desktop server is not ready")
const { OpenCode } = await import("@opencode/client/promise")
return OpenCode.make({ baseUrl: server.url, headers: server.headers })
}
ctx.provide(Pairing, {
info: async () => ({ urls: (await (await client()).server.info()).urls }),
code: async () => (await (await client()).server.pair()).code,
screenActive: () => blocker.id !== undefined && powerSaveBlocker.isStarted(blocker.id),
setScreenActive: (enabled) => keepScreenActive(enabled),
})
}
export default setup
- Write the window entry. Pairing exists only on desktop, so setup returns at once on the web. The settings page is heavy, so it loads behind
lazy()and compiles while the app idles; its search entries are indexed without mounting it. The page receivesctx.uses.pairingand renders nothing until main answers (seePairingPageunder Live and failure handling).
import { lazy, onCleanup, Suspense } from "solid-js"
import { onIdle, Command, SettingsPage, type Setup } from "../sdk"
import type definition from "./index"
const setup: Setup<typeof definition> = (ctx) => {
if (!ctx.desktop) return
const layout = ctx.layout
const Page = lazy(() => import("./page"))
// Settings rows are small; load them while idle so settings opens without a blank row.
onCleanup(onIdle(() => void Page.preload()))
ctx.add(SettingsPage, {
id: "pairing",
icon: "server",
available: "desktop",
get title() {
return ctx.t("title")
},
get entries() {
return [
{ id: "pairing", title: ctx.t("title"), keywords: "pair device qr local" },
{
id: "settings-keep-screen-active",
title: ctx.t("screenActive.title"),
description: ctx.t("screenActive.description"),
keywords: "display sleep awake local",
},
]
},
render: () => (
<Suspense>
<Page pairing={ctx.uses.pairing} />
</Suspense>
),
})
ctx.add(Command, {
id: "open",
get title() {
return ctx.t("command.title")
},
get group() {
return ctx.t("command.category.server")
},
run: () => layout.settings("pairing"),
})
}
export default setup
- Register it. A built-in joins both lists, each composed with
Extension.compose. The main list names every built-in, so their ids stay reserved, and adds pairing's main entry:
/**
* Built-in extensions with their main entries. Lists every built-in so their ids stay reserved. `builtins.typecheck.ts`
* checks that it provides every Ipc the renderer composition uses.
*/
export const builtins = Extension.compose(
context,
btw,
debug,
terminal,
file,
review,
details,
{ ...browser, main: () => import("./browser/main") },
{ ...pairing, main: () => import("./pairing/main") },
{ ...updater, main: () => import("./updater/main") },
{ ...ssh, main: () => import("./ssh/main") },
{ ...wsl, main: () => import("./wsl/main") },
)
The window list adds each window entry, loaded with the app:
/**
* Built-in extensions with their renderer entries. The only place host builds name extensions. `builtins.typecheck.ts`
* checks this composition against the main one.
*/
export const builtins = Extension.compose(
{ ...context, renderer: eager(contextRenderer) },
{ ...btw, renderer: eager(btwRenderer) },
{ ...debug, renderer: eager(debugRenderer) },
{ ...terminal, renderer: eager(terminalRenderer) },
{ ...file, renderer: eager(fileRenderer) },
{ ...review, renderer: eager(reviewRenderer) },
{ ...details, renderer: eager(detailsRenderer) },
{ ...browser, renderer: eager(browserRenderer) },
{ ...pairing, renderer: eager(pairingRenderer) },
{ ...updater, renderer: eager(updaterRenderer) },
{ ...ssh, renderer: eager(sshRenderer) },
{ ...wsl, renderer: eager(wslRenderer) },
)
IpcsProvided checks the two lists against each other. Drop pairing's main entry and this line fails to compile, naming pairing:
export const ipcs: IpcsProvided<typeof builtins, typeof mainBuiltins> = true
Your own extension takes the same steps:
- Pick the id. It prefixes your commands, panel keys, stored keys and Ipc ids. A later rename needs the migrations under Stored state.
- Define it. Create
src/<id>/index.tswithExtension.define({ id, i18n: { en } }), andsrc/<id>/i18n/en.tswith your copy. - Declare the tokens. Put any token another extension or your main entry needs in
contract.ts: aContract, aPointor anIpc. Your own tokens go inprovides. What others provide goes inuses, or inrequiresonly if the extension is meaningless without it. - Write the window entry. In
renderer.tsx, aSetup<typeof definition>contributes withctx.add, branches each action onctx.uses.<name>(), runs side work increateKeyed, and keeps heavy UI behindlazy()with anonIdlepreload. - Write the main entry, if you need Node or Electron. In
main.ts, aMainSetup<typeof definition>provides your Ipc withctx.provideand tears down withctx.scope.addFinalizer. - Declare your state. Use
Store.global,Store.sessionorStore.main, with afromfor every older home of the value. - Register both entries in
src/renderer.tsandsrc/main.ts, as above. The graph matrix then boots each new optional edge with its provider disabled. - Test it. Unit-test pure logic beside the code. Prove what the user sees with a case in the area's keeper suite in
packages/app/e2e/regression/. - Check it. Run
bun run lint, which includesscript/sdk-docs.ts, thenbun run lint:changed, andbun typecheckinpackages/gui-extensions.