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, or to the desktop app. 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, for rendering a form)
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));
settings.done(); // closes the sheet on 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)); s3 - 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
import { SettingsFetchError } from '@bridgething/client/settings';
try {
await settings.fetch(url);
} catch (err) {
if (err instanceof SettingsFetchError) {
// err.kind: 'network' | 'timeout' | 'invalid_url'
}
} s4 - 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 over
settings.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.
import { AuthorizeError } from '@bridgething/client/settings';
try {
await settings.auth.authorize(url);
} catch (err) {
if (err instanceof AuthorizeError) {
// err.kind: 'cancelled' | 'busy' | 'unsupported'
}
} s5 - a worked example
the home assistant example app ships a settings page