index ↓
settings pages
typing a url or picking from many entities on the car screen is painful. a settings page moves that to the phone, the desktop app, or the device page here. it is one self-contained html file in your bundle, rendered in a webview with a bridge to your app's config, a native fetch, and oauth.
s1 - declare it
point manifest.json at the built file. it must be a single self-contained html file, all js and css inlined. the device refuses to install a bundle whose settings page is over 1 MiB, and the whole install fails. keep it lean.
{
"id": "…",
"name": "My App",
"version": "0.1.0",
"settings": "settings.html"
}bun create bridgething scaffolds all of this. a react page under settings/ that builds on preact, a single-file vite build with size warnings at 200 KiB, and the manifest entry.
// package.json: the scaffold ships this wiring
"build": "vite build && vite build -c vite.settings.config.ts"s2 - talk to the companion
inside the page, import @bridgething/client/settings. it is a tiny bridge sdk. it reads and writes your app's declared config and writes the doc namespace: shared per-app key/value state.
import { settings } from '@bridgething/client/settings';
const { webappId, name } = await settings.context();
// the user's saved config values, and the manifest schema <ConfigForm/> renders
const fields = await settings.config.fields();
const values = await settings.config.list();
await settings.config.set('base_url', url);
// the shared doc namespace: what your phone-side page authors, your on-device webapp reads
await settings.doc.set('selected_entities', ids.join(','));
const off = settings.onDocChanged((key, value) => sync(key, value));
// the companion's theme, already painted as --bt-* css variables; hear it flip
settings.onThemeChanged(theme => redraw(theme.scheme, theme.colors));
settings.done(); // closes the sheeton the device side, your webapp reads the same doc namespace over the normal client:
// on-device webapp: adopt what the settings page authored, live
const saved = await client.doc.get({ key: 'selected_entities' });
client.doc.onChanged(c => applySelection(c.value));the companion keeps your page loaded after the user leaves it and shows the same document the next time they open your settings. whatever the page read from the device can be stale by then, so reread it inonRefresh.
// the page is on screen again, or the app behind it was updated: reread what you hold
settings.onRefresh(async context => {
title.textContent = context.name;
showSelection((await settings.doc.get('selected_entities')).value);
});onRefreshfires on every return. each time the page comes back on screen, and whenever the app's name, version or config fields change. it carries the current context.doneonly works on screen. adone()from a page the user already left does nothing.- a reload is a new page. if the page navigates or reloads itself, the host treats what loads as a fresh document. calls the old one had in flight are never answered.
- a crashing page gets a retry. a page that crashes the webview before it finishes loading, or twice in a row on screen, shows a failure with a retry instead of loading again.
- the phone blurs on hide. when the settings screen goes away, the phone app takes focus off whatever field had it, which commits a form field still being typed.
s3 - the config form
every app that declares config gets a settings screen, page or no page. without one, the phone app, the desktop app and the device page show a default page that is just <ConfigForm/> and a done button. a custom page embeds the same form from @bridgething/client/form and adds whatever else it needs around it.
import { ConfigForm, ConfigFormError, closeSettings, type ConfigFormHandle } from '@bridgething/client/form';
import { useRef, useState } from 'react';
export function Settings() {
const form = useRef<ConfigFormHandle>(null);
const [status, setStatus] = useState('');
async function connect() {
try {
const values = await form.current!.flush(); // what the device holds, half-typed fields included
await signIn(values.base_url, values.token);
} catch (err) {
if (!(err instanceof ConfigFormError)) throw err;
setStatus(err.message); // err.kind: 'refused' | 'unavailable' | 'replaced'
}
}
async function close() {
try {
await closeSettings(form.current); // flushes, then closes unless an edit was refused
} catch (err) {
setStatus(err instanceof Error ? err.message : String(err)); // the reason also shows under its field
}
}
return (
<main>
<ConfigForm ref={form} />
<button onClick={connect}>Connect</button>
<button onClick={close}>Done</button>
<p>{status}</p>
</main>
);
}it renders every field the manifest declares and writes each one to the device as the user commits it. a toggle or a choice commits on tap. a text or number field commits when it loses focus, or on enter.
keep each field's label short and put help text in description, which the form shows under the label. an enum stores its choices values and shows choiceLabels(value to display text) in their place.
a touch webview does not always take focus off a field when a button is tapped. so a button that acts on the values calls flush() first: it writes every field still being typed, waits for the device, and resolves with the values the device holds. a done button calls closeSettings(form.current), which flushes and closes unless an edit was refused, and rejects with that error so the page can show it. flush()rejects with a ConfigFormError:
refused. a typed value broke its field's rules, or the device refused the write.fieldsmaps each config key to its message, andmessagereads<label>: <reason>. the form shows the same message under the field.unavailable. the form never loaded.messageis the load failure, and the form offers its own retry.replaced. the host pointed the page at another app whileflush()waited. the edits still land on the app they were made for.
onChange reports written values only, never text still being typed. it fires the moment a field commits, and again with the device's value if the write is refused. the form rereads its fields and values on every refresh. it styles off --bt-* css variables.
those variables come from the companion. settings.context() carriestheme: { scheme, colors, text, fonts }, and the sdk paints it as --bt-* plus--bt-scheme, repainting when the phone or desktop flips between light and dark. use them in your own css and the page matches the app. the painted values have zero specificity, so a :root rule of yours wins; set them there to pin your own palette, at the cost of no longer following the app between light and dark.
:root {
color-scheme: var(--bt-scheme);
}
body {
background: var(--bt-color-bg);
color: var(--bt-color-fg);
font-family: var(--bt-font-body);
}
button {
border: 1px solid var(--bt-color-accent);
background: var(--bt-color-accent-soft);
color: var(--bt-color-fg);
}s4 - fetching from the page
the page has real internet, but it renders from a null origin, so the webview's own fetch is blocked by cors against anything that does not send permissive headers. settings.fetch sidesteps that: the host makes the request natively, outside the webview, and hands you back a real Response.
// a real Response, executed natively by the host
const res = await settings.fetch('https://api.example.com/things', {
headers: { authorization: `Bearer ${token}` },
timeoutMs: 30_000,
});
if (!res.ok) throw new Error(`${res.status}`);
const things = await res.json();third-party sdks call the global fetch and will not know about yours. installFetch swaps it out and returns a function that puts the original back.
import { settings } from '@bridgething/client/settings';
// third-party sdks that call fetch() now go through the host
const restore = settings.installFetch();
const client = new SomeVendorSdk({ token });
// restore(); // put the page's own fetch back- 1 MiB, each way. request and response bodies are both capped. this is for json and tokens, not for downloading anything.
- no streaming. the reply is buffered whole before you see it.
- websockets still work from the page directly
- the device page has no native fetch. a browser cannot make the request outside the page, so
settings.fetchrejects there with a plainError, not aSettingsFetchError. use a websocket, or ask the user to open the phone or desktop app.
import { SettingsFetchError } from '@bridgething/client/settings';
try {
await settings.fetch(url);
} catch (err) {
if (err instanceof SettingsFetchError) {
// err.kind: 'network' | 'timeout' | 'invalid_url'
}
}s5 - connect an account
oauth works from settings.auth.authorize. it opens the provider in the real browser and resolves with the callback url.
every provider uses https://bridgething.com/oauth/callback as the redirect uri. register that url with your provider and it works from the phone app and the desktop app.
pkce happens in your page. the verifier stays in page memory and the exchange goes oversettings.fetch, so no secret needs to exist anywhere. providers that demand a client secret cannot be done from a page; put that exchange in a desktop extension or on a server you run.
import { settings } from '@bridgething/client/settings';
const CLIENT_ID = '…';
const REDIRECT = 'https://bridgething.com/oauth/callback';
// 1. pkce, in the page. the verifier never leaves it.
const verifier = crypto.randomUUID() + crypto.randomUUID();
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const state = crypto.randomUUID();
// 2. build the provider's authorize url yourself
const authorize = new URL('https://discord.com/oauth2/authorize');
authorize.search = new URLSearchParams({
client_id: CLIENT_ID,
response_type: 'code',
scope: 'identify rpc', // identify reads the account here; rpc is what the desktop extension needs
redirect_uri: REDIRECT,
code_challenge: challenge,
code_challenge_method: 'S256',
state,
}).toString();
// 3. hand it to the host. resolves with the full callback url.
const callback = await settings.auth.authorize(authorize);
if (callback.searchParams.get('state') !== state) throw new Error('state mismatch');
// 4. exchange the code over settings.fetch
const token = await settings
.fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: CLIENT_ID,
grant_type: 'authorization_code',
code: callback.searchParams.get('code') ?? '',
redirect_uri: REDIRECT,
code_verifier: verifier,
}),
})
.then(r => r.json());
// 5. store it. the device app reads config; the extension reads ctx.config.
await settings.config.set('discord_access_token', token.access_token);
await settings.config.set('discord_refresh_token', token.refresh_token);
// 6. now you can actually use it
const me = await settings
.fetch('https://discord.com/api/users/@me', {
headers: { authorization: `Bearer ${token.access_token}` },
})
.then(r => r.json());
await settings.config.set('discord_user', me.username);the token now lives in your app's config, which means the device webapp reads it over client.config and a desktop extension reads it over ctx.config(device). refresh it wherever you use it: the webapp through client.net, the extension through its own fetch.
- declare the keys you write in the manifest's
config, assecretfields for tokens. an undeclared key is rejected. - one authorize at a time per host. a second while one is pending fails with
busy. - check
stateyourself. the host hands you the url unread. - the device page cannot authorize. it fails with
unsupported, and the token exchange needssettings.fetch, which it lacks too.
import { AuthorizeError } from '@bridgething/client/settings';
try {
await settings.auth.authorize(url);
} catch (err) {
if (err instanceof AuthorizeError) {
// err.kind: 'cancelled' | 'busy' | 'unsupported'
}
}s6 - files and media
a user can hand your app a file from the companion: a photo, a looping video, a gif for the background.settings.pick opens the native picker and hands back a handle, never the bytes, andsettings.blob.put streams the file to the device under a key. your webapp then reads it by url.
import { BlobError, settings } from '@bridgething/client/settings';
// the photo picker on the phone, a file dialog on the desktop and the device page. null if the user backs out.
const picked = await settings.pick({ kind: 'video' }); // 'image' | 'video' | 'any'
if (picked) {
preview.src = picked.preview ?? ''; // small jpeg data url; a video's is its first frame
try {
await settings.blob.put('background', picked.handle, {
onProgress: (sent, total) => (bar.value = sent / total),
});
} catch (err) {
if (err instanceof BlobError && err.kind === 'too_large') showTooBig();
}
}the companion makes picks fit the device before they upload, and the only knob is cover: [w, h], the box the result must cover. it defaults to [800, 800] for stills and [800, 480] for video. each edge is a whole number from 1 to 4294967295, and anything else rejects.
- animated images pass through. gif, apng and animated webp arrive byte for byte. crop and placement are yours to do in the webapp.
- stills shrink, never grow. anything larger than the box scales down until it just covers it, keeping its format. heic becomes jpeg.
- video becomes h.264. mp4, no audio, at most 30 fps, scaled to cover the box.
anyis untouched.- one picker at a time. a
pickwhile another is still open rejects withbusy. - the device page uploads picks as picked. a browser has nothing to transcode with, so a pick there skips all of the above and carries no preview.
bytes the page builds itself go through a handle too, in chunks:
// bytes the page makes itself, like a cropped canvas
const png = await new Promise<Blob>(done => canvas.toBlob(b => done(b!), 'image/png'));
const handle = await settings.blob.create('image/png');
await settings.blob.append(handle, png);
await settings.blob.put('cropped', handle);
const stored = await settings.blob.list(); // [{ key, sha256, mime, size }]
await settings.blob.delete('cropped');on the device, client.blob lists what the companion stored:
// on-device webapp: show it, and follow replacements live
const res = await client.blob.list();
const bg = res.ok ? res.response.entries.find(e => e.key === 'background') : undefined;
client.blob.onChanged(c => c.key === 'background' && setBackground(c.url ?? null)); // url is null once deleted
// bg.url is /.bridgething/blob/background?v=<sha256>, served by the daemon on the page's own origin
<video src={bg.url} autoPlay loop muted playsInline />- 32 MiB per file, 64 MiB per app. the device refuses past either and never evicts to make room, so delete what you replace.
- keys are 1 to 256 bytes. no control characters, and not
.or..: a browser folds those out of the url, so the file never loads. - gifs are expensive. the car thing decodes every gif frame on a cortex-a53 that also runs everything else, and a big one starves now-playing. video decodes in hardware, so pick
kind: 'video'for animated backgrounds. - uploads ride the slow lane.
putstreams behind everything else so now-playing stays smooth, which means a large video over a car's bluetooth link takes minutes, so showonProgress: it reports the bytes the device has confirmed a few times a second and ends on the total. a put that picks up an interrupted upload starts at the bytes the device already has, not at 0.putresolves when the device has the file, and bytes the device already holds finish at once. - handles are temporary. a handle lives in the companion, not on the device, and a
putthe device stores uses it up, so pick or create again to store the same bytes under a second key. a failedputkeeps it, so the upload can be retried with it. aputkeeps its handle alive for as long as it streams. a handle nothing is uploading expires after 30 minutes unused. the companion also holds at most 128 MiB of them, and a pick or append past that rejects withbusyuntil aputlands or a handle expires; nothing already held is dropped. a handle that expired or was used up rejects withunknown_handle, so pick again.
pick and every blob call reject with a BlobError. branch on itskind:
unsupported. the companion or the car thing predates these calls, or the host cannot do the work.disconnected. no car thing is linked, or the link dropped mid-upload.too_large. the file is over 32 MiB.quota_exceeded. the put would take the app past 64 MiB.storage_full. the car thing has no room left.not_media. the picked file is not the kind you asked for.unknown_handle. the handle is not live: never made, expired, or used up by a storedput.busy. a picker is already open, or held handles fill the companion's whole handle budget.no_gesture. the pick did not start from a tap or click, so the browser opened no picker. callpickfrom the tap handler itself.failed. anything else. readmessage.
s7 - a worked example
the home assistant example app ships a settings page: <ConfigForm/> for the server url and token, a connect button that flushes the form and dials with what the device holds, then an entity picker that writes the selection to the doc namespace and rereads it on refresh.