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.forward is 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:port
  • read, read:path
  • write, write:path
  • run, run:binary
  • env, env:VAR
  • sys, sys:kind
  • ffi, 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. all reads 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
  }
}
  • entry is the path to the bundled extension inside the zip. a missing file refuses the install.
  • api is the host protocol revision. 1 is the frozen contract.
  • permissions are 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.