Docs

ADR-0019: Configurable browser User-Agent

Status: Active · Date: 2026-07-25

References

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):

  1. A plain-string setting with a computed default (tranquil-config). The default is derived at load by stripping the Electron/… and Tranquil/… tokens from the live User-Agent, so it tracks the bundled Chromium across upgrades with no version string to rot. The schema is a plain string with no enum, so any typed value is accepted.
  2. 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.
  3. 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 a set-user-agent IPC message. (c) The main process holds the current value and rewrites the User-Agent request header on every guest request via onBeforeSendHeaders, 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 enum dropdown (rejected). Pulsar’s config core rejects any value outside a declared enum, and settings-view renders an enum as 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 the onBeforeSendHeaders rewrite 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 sessionwebContents.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