index ↓
extensions
the desktop app runs one deno process per installed webapp, with the host access your manifest asks for, talking to the car thing over the forward surface.
e1 - what it is, and where it runs
an extension gives your webapp deno and node apis on the computer running the desktop app. pc stats, discord rpc, hotkeys, a local database, spawning ffmpeg.
- desktop only. a webapp that ships one is installed from the desktop app.
- handle the extension being away.
available.forwardis false while the desktop app is closed or the car thing is on a phone. render both states. - it starts with the desktop app, device or no device. connect and disconnect arrive as events.
- one process per webapp. a crash restarts with backoff and shows a status row in the desktop app.
e2 - the trust model
the desktop app launches deno with the manifest's permission list.
one descriptor string per entry:
all- full host access.net,net:host,net:host:portread,read:pathwrite,write:pathrun,run:binaryenv,env:VARsys,sys:kindffi,ffi:path
a leading ~ in a read, write, run, or ffi scope expands
to the home directory. net, env, and sys scopes pass through as written.
before installing, the user sees:
-
a native extension badge on the store card, with the permission list in plain
words.
allreads as full host access. - a link to your public github repo. every version that declares an extension needs one.
- a confirmation at install in the desktop app, listing the same permissions.
the scaffold writes ["all"]. narrow it before you publish.
e3 - scaffold one
bun create bridgething my-app --extension
combines with --launcher and --overlay. it writes extension/main.ts, the
@bridgething/extension dependency, an esbuild target that bundles it to
dist/extension/desktop.mjs, and the manifest block.
{
"id": "…",
"name": "My App",
"version": "0.1.0",
"extension": {
"entry": "extension/desktop.mjs",
"permissions": ["all"],
"api": 1
}
} -
entryis the path to the bundled extension inside the zip. a missing file refuses the install. apiis the host protocol revision.1is the frozen contract.permissionsare the deno descriptors from above.
e4 - the shape of one
import { asJson, defineExtension, json } from '@bridgething/extension';
let timer: ReturnType<typeof setInterval> | undefined;
defineExtension({
start(ctx) {
ctx.log.info(`up, data in ${ctx.dataDir}`);
ctx.on('device', event => {
if (event.type !== 'connected') return;
event.device.send(json({ type: 'hello', name: ctx.webapp.name }));
});
ctx.on('message', (device, message) => {
const payload = asJson<{ type: string }>(message);
if (payload?.type === 'ping') device.send(json({ type: 'pong' }));
});
timer = setInterval(() => ctx.broadcast(json({ tick: Date.now() })), 5_000);
},
stop() {
clearInterval(timer);
},
}); console.log corrupts the host protocol. log with ctx.log. console.error goes
to stderr and is free for scratch debugging.
register listeners synchronously at the top of start. the host replays every live device the moment
start is invoked, so a listener added after an await misses them.
start must return. available.forward stays false on the device until the promise it returns settles. put a long-running loop
in a detached task.
a complete one is at packages/webapps/catalog/system-info.
e5 - the ctx contract
ctx is handed to start.
| member | what it does |
|---|---|
| ctx.api | host protocol revision. 1. |
| ctx.webapp | { id, name, version } from your manifest. |
| ctx.dataDir | a directory you can write to. the kv store lives in it. |
| ctx.devices | the connected car things, recomputed on read. |
| ctx.device(id) |
a handle for any id, connected or not. never throws. carries id, name,
active, connected, config, and send.
|
| ctx.broadcast(msg) | forward to every connected device where your webapp is the active one. |
| ctx.config(device) | that device's saved settings, keyed by manifest config key. values are strings. |
| ctx.on('device') | event.type is connected, disconnected, or active.
|
| ctx.on('message') | (device, message) for forwards coming back from the webapp. |
| ctx.on('config') | (device, key, value) when the settings page writes. value is
null when the key was cleared.
|
| ctx.kv | get / set / delete / list, a persistent json store
scoped to you. get resolves undefined on a miss.
|
| ctx.auth.authorize(url) | opens the system browser and resolves with the full callback url. see connect an account. |
| ctx.log | debug / info / warn / error into the desktop app's log,
tagged with your webapp name.
|
failures from kv and auth reject with an ExtensionError carrying a
kind of host-error, disconnected, or write-failed.
e6 - the two halves talk over forward
fire and forget in both directions, with three encodings: text, json, and
binary. correlate a reply with its request yourself.
// the webapp, on the car thing
const caps = await client.capabilities.get();
const live = caps.ok && caps.response.capabilities.available.forward;
client.forward.onJson(msg => apply(msg));
await client.forward.json({ type: 'ping' });
binary payloads arrive as a Uint8Array.
-
capabilities.available.forward is true while a connected host
runs an extension for your webapp.
- a send to a backgrounded webapp is dropped. check
device.active.
e7 - the dev loop
bun run dev # page in the browser + extension under deno, both on the connected car thing
bun run dev:device # the same, with the page on the car thing's own screen
bun run build # webapp, settings page, and dist/extension/desktop.mjs
bun run share # zip dist/, extension included
the extension is bundled on every save and restarted under deno with the manifest's permissions. the dev server
links to the daemon over usb as an extension host and makes the webapp active so forwards route.
ctx.log.* prints in the vite terminal. the deno npm package is fetched into
node_modules on first use.
ctx.auth.authorize opens the browser, and the provider ends on bridgething.com's callback page. copy that
page's address and paste it at http://localhost:5173/__extension/authorize.
bun run push installs the webapp only. the desktop app runs the extension once the webapp is installed
there. disable it there while you develop, or the webapp hears both copies.
e8 - publishing it
the catalog entry mirrors the manifest block. see publishing apps.
{
"version": "0.1.0",
"released_at": "2026-08-01T00:00:00Z",
"download": { "url": "…", "size": 402118, "sha256": "…" },
"permissions": ["net.fetch"],
"extension": {
"desktop": true,
"permissions": ["all"]
},
"min_libbridgething_version": "0.12.0",
"changelog": "Initial release."
}
desktop is always true.
e9 - a worked example: discord
a discord app needs two things from outside the webapp: who you are, and what is happening in your voice channel.
identity comes from the settings page. it builds the authorize url with scopes
identify rpc, calls settings.auth.authorize, exchanges the code with
settings.fetch, and writes the tokens with settings.config.set. the whole thing is in
connect an account.
ask for both scopes. discord refuses AUTHENTICATE and SUBSCRIBE on a token without
rpc.
voice state comes from the extension, over a unix socket owned by the
discord desktop client:
// extension/main.ts, on the desktop
import { defineExtension, json, type ExtensionContext } from '@bridgething/extension';
const CLIENT_ID = '…'; // the discord application id the settings page uses
const TOKEN_KEY = 'discord_access_token';
const socket = `${Deno.env.get('XDG_RUNTIME_DIR') ?? '/tmp'}/discord-ipc-0`;
let latest = { type: 'voice', speaking: false, channel: null as string | null };
let session: Promise<void> | null = null;
let live: Deno.Conn | null = null;
let lastToken: string | null = null;
function frame(op: number, payload: unknown): Uint8Array {
const body = new TextEncoder().encode(JSON.stringify(payload));
const out = new Uint8Array(8 + body.length);
const head = new DataView(out.buffer);
head.setUint32(0, op, true);
head.setUint32(4, body.length, true);
out.set(body, 8);
return out;
}
// readFrames is yours: 4 byte op, 4 byte length, then json
async function pump(ctx: ExtensionContext, conn: Deno.Conn) {
for await (const event of readFrames(conn)) {
if (event.evt !== 'VOICE_STATE_UPDATE') continue;
latest = { type: 'voice', speaking: event.data.speaking, channel: event.data.channel_id };
ctx.broadcast(json(latest));
}
}
async function open(ctx: ExtensionContext, token: string) {
const conn = await Deno.connect({ transport: 'unix', path: socket });
live = conn;
try {
await conn.write(frame(0, { v: 1, client_id: CLIENT_ID }));
await conn.write(frame(1, { cmd: 'AUTHENTICATE', args: { access_token: token }, nonce: crypto.randomUUID() }));
await conn.write(frame(1, { cmd: 'SUBSCRIBE', evt: 'VOICE_STATE_UPDATE', nonce: crypto.randomUUID() }));
await pump(ctx, conn);
} finally {
if (live === conn) { // skip the close when a rotation already took the socket
live = null;
conn.close();
}
}
}
// one socket per process, cleared on any exit so the next event reopens it
function connect(ctx: ExtensionContext) {
if (!lastToken || session) return;
session = open(ctx, lastToken)
.catch(err => ctx.log.error('discord ipc: ' + err))
.finally(() => {
session = null;
});
}
// a rotated token needs a new socket, discord will not re-auth the live one
async function use(ctx: ExtensionContext, token: string | null) {
if (token !== lastToken) {
lastToken = token;
const closing = session;
live?.close();
live = null;
await closing;
}
connect(ctx);
}
defineExtension({
start(ctx) {
// ctx.devices is empty here, so read the config off the event
ctx.on('device', event => {
if (event.type === 'disconnected') return;
event.device.send(json(latest)); // late joiners get current state instead of the next change
void use(ctx, ctx.config(event.device)[TOKEN_KEY] ?? lastToken);
});
ctx.on('config', (device, key, value) => {
if (key !== TOKEN_KEY) return;
void use(ctx, value); // hold the last token so a reconnect skips the settings page
});
},
});
when the socket ends, the next event reopens it. a cleared token closes it.
permissions for this one are env:XDG_RUNTIME_DIR plus read and write on the socket
path. the desktop app spawns deno with --no-prompt, so a missing permission crashes the extension.