ADR-0019: Configurable browser User-Agent
Status: Active · Date: 2026-07-25
References
- Browser User-Agent — Delivery Notes — what shipped, where it lives, and the gotchas
- ADR-0011: Tranquil settings tab — the owned settings surface the combobox is injected into
- ADR-0002: A full Chromium browser, not a VS Code-style preview pane — why the browser is a first-class surface with settings of its own
- ADR-0005: Per-window session isolation — the per-window partitions the header rewrite attaches to
- ADR-0015: Electron IPC vs Guest↔Host RPC — the boundary the setting’s value crosses to reach the main process
Context
A Tranquil browser tab embeds its page in an Electron <webview> guest. Guests inherit
Electron’s default User-Agent, which appends runtime tokens — Tranquil/<version> and Electron/<version> — to the underlying Chrome string, and there is no configuration surface for
what a tab sends.
Setting a guest’s User-Agent in Electron has three seams, each with a gap. The <webview useragent> attribute[^1] applies before first navigation and is the simplest per-tab hook; setUserAgent() on the guest webContents and a session.webRequest.onBeforeSendHeaders header
rewrite[^2] change it live and per-request respectively; and the attribute / setUserAgent seams do not reliably reach subresource[^3] or WebSocket[^4] requests, which only the header rewrite
covers. One more wrinkle: Atom’s config lives in the renderer, but that header rewrite runs in
the main process — so the value has to cross the IPC boundary.
Decision
Add a configurable browser User-Agent — a tranquil.browserUserAgent setting whose default is a
clean, derived stock-Chrome string, applied to every browser guest through three layers. All in
owned packages (tranquil-config, tranquil-browser, and the tranquil-client main process — no
third-party code touched):
- A plain-string setting with a computed default (
tranquil-config). The default is derived at load by stripping theElectron/…andTranquil/…tokens from the live User-Agent, so it tracks the bundled Chromium across upgrades with no version string to rot. The schema is a plainstringwith noenum, so any typed value is accepted. - An editable combobox in the Tranquil settings tab. It replaces the auto-rendered text field with an input plus a preset listbox — Chrome / Firefox / Edge / Safari — so a user can pick a preset or type any custom string. Chrome is the derived default; Edge reuses that Chromium version; Firefox and Safari are fixed strings.
- Three apply layers. (a) New tabs receive the value through the
<webview useragent>attribute before first load, and it persists across in-tab navigations. (b) A renderer config-observer applies changes live to open tabs and pushes the value to the main process over aset-user-agentIPC message. (c) The main process holds the current value and rewrites theUser-Agentrequest header on every guest request viaonBeforeSendHeaders, covering the subresource/WebSocket requests the attribute misses.
Options considered
- Option A: A computed, derived default (chosen). Strip the runtime tokens from the live UA at load. Rejected alternative — a hardcoded default UA string: a fixed Chrome version goes stale as Electron/Chromium upgrade, so the derived string is strictly better as the out-of-the-box value.
- Option B: An owned editable combobox (chosen) vs. reusing stock settings-view’s
enumdropdown (rejected). Pulsar’s config core rejects any value outside a declaredenum, and settings-view renders anenumas a closed<select>— so “presets and free text in one control” is impossible natively. A plain-string schema plus our own combobox on the owned Tranquil tab is the only way to get both. - Option C: Apply globally (chosen) vs. a per-host override (rejected). A uniform browser identity is simpler; per-host UA switching is more machinery for no general benefit.
- Option D: Renderer-only (attribute +
setUserAgent) vs. renderer + a main-process header rewrite (chosen). Only theonBeforeSendHeadersrewrite reaches subresource and WebSocket requests, so the main-process layer is load-bearing for full coverage, not incidental.
Consequences
- Browser tabs present a clean, standard User-Agent by default, and users can pick a preset or set any value. Changes apply live to open tabs (on their next load) and to new tabs immediately.
- The default tracks Chromium automatically. Because Atom persists only overrides (never schema defaults), the computed default is recomputed each launch, so it stays current until a user overrides it. Edge derives from the same Chromium version; Firefox and Safari presets are fixed strings and need an occasional bump.
- A renderer→main IPC dependency (
set-user-agent) now carries the value to the header-rewrite layer; the main process seeds its own clean default so guest requests are clean even before the first push. - Re-evaluate if Electron changes how guest User-Agents are set, or the IPC bridge migrates to the guest↔host RPC layer (ADR-0015).
[^1]: Electron <webview> Tag — the useragent attribute “sets the user agent for the guest page before the page is navigated to. Once the page is loaded, use the setUserAgent method.” https://www.electronjs.org/docs/latest/api/webview-tag [^2]: Electron session — webContents.setUserAgent() and session.webRequest.onBeforeSendHeaders() for rewriting outgoing request headers. https://www.electronjs.org/docs/latest/api/session [^3]: electron/electron #7203 — a User-Agent set on a <webview> is not used for some (subresource) requests. https://github.com/electron/electron/issues/7203 [^4]: electron/electron #7205 — a custom User-Agent set via setUserAgent() is not applied to WebSocket connections. https://github.com/electron/electron/issues/7205