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(Registry, 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, Registry
├── 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 |
legacy |
Earlier extension ids, newest first; preserves desktop enable state after a rename | |
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 registry | ctx.add(Registry, 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. - Registries 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. - Bind each contract component with
bindExtensionduring the provider's setup souseExtensionreads the provider's context, not the consumer's. Props retain their getters. Leave data methods such asChanges.diffs()unwrapped.
ctx.provide(FileTree, { Tree: bindExtension((props) => <Tree {...props} />) })
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
loading --> blocked: requires provider disabled or failed
blocked --> loading: provider returns
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. - Window setup is synchronous: its
undefinedreturn type rejects async functions, and the host fails thenable-returning JavaScript setups instead of waiting for them. Put async work increateKeyedorcreateLatest, withctx.signalor a signal derived from it. Only main setup may be async. After eachawaitthere is no owner; return if the signal aborted before registering or changing state. Late registrations remain safe: the host releases them when the owner or instance ended.
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. ctx.screen.current()andctx.sessions.current()are published together in one batched effect after the screen's first render, with v2's original session-mount timing. Renders already receive their owning screen and session directly. Both public accessors are undefined during the first render, on Home, on a draft, before the interface mounts and once the route leaves the mounted screen. The screen has nosessionproperty.- All panel callbacks take one named input:
list({ session, screen, open }),render({ tab, session, screen }),focus({ tab, session, screen, restored }),close({ tab, session, screen }),normalize({ id, session, screen }). Session fields are getters; the screen is the constant owning screen. Render tab fields are live getters too; lifecycle callbacks receive their event's tab. Stored ids andrestoreddescribe that call. Do not destructure live getters. Everysession.*slot gets a non-nullscreen;window.bottomdoes not. - Render effects can run before public publication too: change-list views call
Changes.watch(input.screen, source), notctx.screen.current(), so their first demand is never lost. fallback: trueopts a tab into selection when the stored selection is missing or cannot be selected; false or omission opts out. The host takes the first eligible regular tab, then afirsttab, then a pinned tab, preserving order within each tier. Closing a tab still selects its neighbour.
ctx.add(Panel, {
id: "main",
region: "side",
list: (input) => (input.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} screen={props.screen} 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; rejects repeated extension ids and missing or duplicate providers |
Registry.define<T>(id) |
A list your extension owns 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 |
bindExtension(component) |
One contract component under its provider's context; data methods stay plain |
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.
// 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()))
Pairing's shipping page uses a query, not createLatest. It passes the query's abort signal directly to the no-input Ipc method; options are the only argument. The generation in the key asks again when main comes back:
const screenActive = useQuery(() => ({
queryKey: [ctx.id, "screen-active", display()?.generation],
queryFn: (input) => {
const live = display()
if (!live) throw new Error("Pairing's main side is not active")
return live.value.screenActive({ signal: input.signal })
},
enabled: !!display(),
gcTime: 0,
}))
Catalog
Each line links to the file whose TSDoc covers every field.
SDK icon fields use IconName from @opencode/ui/icons/catalog, a dependency-free module with no Solid or CSS imports. The UI renderer reads that same catalog: there is no copied name list or dependency on a UI component's props, and no artwork in the general-purpose util package.
Registries
| Registry | 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 |
The servers the app lists, and each one's live ServerRef |
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>. A method without an input schema takes only optional call options:pairing.screenActive({ signal }), never anundefinedplaceholder. - 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 |
inactive, "blocked" |
A hard dependency is disabled, failed or itself blocked | An unavailable state; resume when it returns |
- 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, blocked, restarting): nothing can check or install.
showToast({ title: ctx.t("common.requestFailed") })
}
The pairing settings page waits for its main side only where it needs it. The link and QR code come from the local server's ServerRef, so they render without main; the display setting renders only while main is active, so it never offers a switch that cannot answer:
// Only the display setting needs pairing's main side; the link and QR code come from the server, so they never wait.
const display = () => {
const live = props.pairing()
return live.status === "active" ? live : undefined
}
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(mutate)edits a deep-mutable draft and must return nothing orundefined. Returning the next value is a compile error and throws at runtime in both processes; useset(next)to replace the complete value, including main primitives and lists. PlainSchema.Structfields work withoutmutableKey. Window calls wait for load and apply in call order with each other; main writes reach disk immediately. Runtime-key stores have the same contract.Storage.remove(key, { from })reads asinitialagain and never importsfromagain.- Renaming an extension moves four things: enable state (
Definition.legacy), 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.
Declare earlier ids newest first, for example Extension.define({ id: "details", legacy: ["summary"] }). The current id's explicit enable setting always wins. Without one, the first earlier id with a setting wins; with no setting at all, the extension is enabled. Both desktop hosts use this rule. Old rows stay intact, and enable/disable changes write only under the current id. A failed startup snapshot is unknown, so the window waits for the manager list rather than running disabled extensions.
The enable preference is separate from activation: an enabled extension with an unavailable hard dependency is blocked. The window publishes disabled providers before starting enabled entries, so consumers settle as blocked regardless of declaration order.
legacy: ["summary"],
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 |
Duplicate extension ids, missing or duplicate providers, wrong-process stores, readonly drafts, panel and slot inputs, no-input Ipc options |
| Registry compile checks | sdk/registries.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 work, 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 a window entry that adds a settings page and a command. The window asks the local server for its addresses and pairing codes through ctx.servers: that server's ServerRef is already authenticated in the window, so its credentials never leave main. 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. It carries only what needs the main process, the display sleep blocker. The schemas type both sides and encode every value.
import { Schema } from "effect"
import { Ipc } from "../sdk"
/**
* Keeps this machine's display awake while another device uses it. The window asks the local server for addresses and
* pairing codes through its own `ServerRef`; only the display sleep blocker needs the main process.
*/
export const Pairing = Ipc.define({
id: "pairing",
methods: {
/** Whether main holds the display sleep blocker. */
screenActive: { output: Schema.Boolean },
/** Holds or releases the display sleep blocker, and remembers the choice. */
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. The addresses pairing links use are a window store, which the settings page reads.
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"] }),
// An address of this computer the server cannot see (a VPN, tunnel or proxy), and the address links use.
links: Store.global(Schema.Struct({ custom: Schema.String, selected: Schema.String }), { custom: "", selected: "" }),
},
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.
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.set(enabled)
}
if (stored.value) keepScreenActive(true)
ctx.scope.addFinalizer(release)
ctx.provide(Pairing, {
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 receives the desktop's own server, found throughctx.serversbybuiltin, andctx.uses.pairing. The search entry for the display setting, like the setting itself, exists only while main answers (seedisplayunder 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 servers = ctx.servers
const pairing = ctx.uses.pairing
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()))
// The desktop's own server. Its ref is already authenticated in this window, so codes need no main process.
const local = () =>
servers
.list()
.map((id) => servers.get(id))
.find((server) => server?.builtin)
ctx.add(SettingsPage, {
id: "pairing",
icon: "server",
available: "desktop",
get title() {
return ctx.t("title")
},
get entries() {
const pairingEntry = { id: "pairing", title: ctx.t("title"), keywords: "pair device qr local" }
// The display setting exists only while pairing's main side answers.
if (pairing().status !== "active") return [pairingEntry]
return [
pairingEntry,
{
id: "settings-keep-screen-active",
title: ctx.t("screenActive.title"),
description: ctx.t("screenActive.description"),
keywords: "display sleep awake local",
},
]
},
render: () => (
<Suspense>
<Page server={local} pairing={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") },
microsoftOffice,
)
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) },
{ ...microsoftOffice, renderer: eager(microsoftOfficeRenderer) },
)
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, aRegistryor 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.