01The big picture
One background script does the real work. It watches tabs, decides whether you are in a meeting, and sends HTTP requests to the outputs you configured. Everything else is UI or pure helper logic.
Red wires are what happens on a meeting edge: every enabled target gets one HTTP request. The ON-AIR sign tries the LAN first and falls back to the cloud bridge.
The extension has no content scripts and never reads page contents. Meeting detection uses only the URL of each tab, which the tabs permission exposes. Requests go only to the hosts you configure: the extension declares http://*/* and https://*/* as optional host permissions, and the settings page asks for each target's origin when you save it.
| Manifest key | Value | Why |
|---|---|---|
permissions | storage, tabs, alarms | Save settings, read tab URLs, run the 1-minute heartbeat. |
optional_host_permissions | http://*/*, https://*/* | Requested per target origin, only when needed. Unused grants are revoked on save. |
background | background.js, type: module | Chrome runs it as a service worker. The Firefox build rewrites this to an event page. |
action / options_page / devtools_page | popup.html / options.html / devtools.html | The three UI surfaces. |
02Try it
This simulator runs a small JavaScript copy of the worker's decision logic in your browser. Open and close tabs, pause, put the worker to sleep or knock the sign out of sync, and watch the trace on the right. The function names match background.js. No real requests are sent.
Browser tabs
Click a tab's address to make it the active tab. A red dot means the URL matches a meeting service.
Settings
Outputs
Things to try: open Meet, close it again within 400 ms (the debounce swallows the flicker). Open Meet, suspend the worker, then fire the heartbeat with and without the old bug. Switch the sign off by hand and fire the heartbeat to watch verify fix it. Take the sign offline while in a meeting, close the meeting, bring the sign back and fire the heartbeat.
03Life of a meeting
What happens between you clicking a Meet link and the sign lighting up, in the order the code runs it.
- A tab event wakes the worker. background.js · chrome.tabs.on*
Listeners on tab create, update, remove, activate and window focus/remove all call
tick(reason).onUpdatedfires for every title, favicon and loading change, soisRelevantTabUpdateonly lets a URL change or a finished load through. Activation and focus only matter in "active tab" mode. - Load config, pause and last state. getConfig · getPause · ensureCurrent
getConfig()returns a cached, migrated config with secrets merged back in.ensureCurrent()hydrates the last state the worker applied fromstorage.session, once per worker lifetime. This is what stops a freshly woken worker from treating "still in a meeting" as a new meeting. - Work out the desired state. computeState → matchService
Query tabs, drop any that are mid-close, and look for a URL that matches an enabled service. The result is
{state: "ON", service: "meet", url}or OFF. If you have paused the extension, the result is forced to OFF, but the detected service is kept so the popup can say "In Google Meet · sign held off". - Same as before? Stop here. sameState
Most ticks end here. Only the heartbeat alarm goes on to a reconcile pass (see Staying in sync).
- Wait 400 ms and look again. DEBOUNCE_MS
Navigations fire bursts of events. A
setTimeoutwaits 400 ms, recomputes the state, and fires only if it still differs from what was last applied. A tab that redirects through a meeting URL and away again never triggers anything. - Commit the new state. commitCurrent → saveCurrent
The in-memory
currentis updated and written tostorage.sessionunderruntimeState. It only writes when something changed. - Fire every enabled target once. applySideEffects → dispatchTarget
The previous edge's
AbortControlleris aborted first, so a slow retrying ON can never land after this OFF. The toolbar icon is updated in parallel. Template variables are built once ({state} {service} {url} {ts}) and each target runs through its executor concurrently withPromise.allSettled. - Record the outcome. recordDispatch · recordOffFailures · logActivity
A summary goes to
storage.session.lastDispatchfor the popup's health line ("✓ All 3 targets updated 2m ago"). Targets whose OFF failed are remembered inoffFailed. If diagnostics are on, a structured entry with per-target latency and errors is appended to the activity log. - Tell the popup. broadcastState
A
STATE_CHANGEDmessage updates an open popup. If no popup is open the send rejects, and that rejection is expected and ignored.
// background.js — the heart of tick(), abridged const detected = await computeState(cfg); const next = isPaused(pause) ? { ...OFF_STATE } : detected; if (sameState(next, current)) { if (reason === "alarm" && !isPaused(pause)) await reconcilePass(cfg, current); return; } debounceTimer = setTimeout(async () => { const prev = { ...current }; const next2 = isPaused(pause2) ? { ...OFF_STATE } : await computeState(cfg2); await commitCurrent(next2); if (sameState(next2, prev)) return; // settled back: no edge await applySideEffects(current, cfg2, reason); broadcastState(pause2); }, DEBOUNCE_MS);
04Meeting detection
A meeting is a tab whose URL starts with a known prefix. The matching is stricter than it sounds.
| Service | Built-in prefixes |
|---|---|
| Google Meet | https://meet.google.com/ |
| Microsoft Teams | https://teams.microsoft.com/ |
| Zoom | https://zoom.us/, https://app.zoom.us/ |
| Custom (yours) | Any name plus a list of prefixes, e.g. https://meet.jit.si/. A prefix typed without a scheme gets https:// added. |
Origin first, then path
urlMatchesPrefix parses both URLs and requires the exact origin to match before comparing the path. A naive startsWith would let https://meet.google.com.evil.example/ count as a Meet tab. The simulator's "Look-alike" button shows the difference. Custom services are checked before the built-ins, so a custom name wins on overlap.
Two trigger modes
- Any matching tab is open (
ANY_TAB, default). The sign is ON whenever a meeting tab exists anywhere, even in a background window or before you click Join. The worker asks Chrome to filter tabs by match pattern (serviceMatchPatterns) instead of listing every tab. It falls back to a full listing when a prefix can't be expressed as a pattern, such as one with a port or query string. - Only the active tab matches (
ACTIVE_TAB). Only the focused tab of the last-focused window counts, so switching away from the meeting turns the sign off.
Closing tabs in Firefox
In Firefox, tabs.query still returns a tab while its onRemoved listeners run. Querying right after closing a meeting tab therefore still found the meeting, and the sign stayed ON until the next heartbeat. The worker now puts closing tab and window ids in a closing set for 10 seconds, and withoutClosingTabs filters them out.
05Targets
A target is one output. There are three types. Each has an executor in background.js that returns {ok, status, error, via, ms}, which feeds the popup, the log and the Test buttons.
Listener
runListenerTargetThe simplest one: a single URL called on every change, for your own local app or script. If the URL has no {tokens}, the state is appended as query parameters.
GET http://127.0.0.1:8765/event ?state=ON&service=meet &url=https%3A%2F%2Fmeet.google.com &ts=1760000000000
Reconcile: single only.
Webhook
runHttpHookTargetSeparate ON and OFF URLs, any method, custom headers, Basic auth, a templated body, and a success rule: accepted status codes (default 200, 202, 204) plus an optional "response contains" string. Templates pre-fill Tasmota, Shelly, Home Assistant and Ntfy.
GET http://192.168.1.17/cm
?cmnd=Power%20On
→ 200 {"POWER":"ON"}
Reconcile: single or always.
ON-AIR sign
runIotHybridTargetFor the companion ESP32 sign. It tries the LAN API first with a short timeout and no retries. If that fails, it falls back to the cloud bridge with the normal timeout and retries. Modes are 0 off, 1 on, 2 breathing.
GET http://onair.local/api/set?state=1
X-API-Token: …
↳ on failure:
POST https://…/?thing=onair-sign&mode=1
Authorization: Bearer …
Reconcile: single, verify or always.
The request engine
Listener, webhook and cloud requests go through callUrl(). Each attempt gets its own timeout (the "request timeout" setting, 1–20 s, default 3 s) combined with the edge's abort signal. Fast network errors are retried up to RETRY_MAX = 2 times with exponential backoff (250 ms, then 500 ms, capped at 2 s). A timeout is not retried, because a host that is down would only multiply the wait. A bad HTTP status is not retried either. Deciding success from status and body is left to httpHookSuccess(), the same function the settings page's Test button uses.
// shared.js — one rule for "did it work?", used live and by Test export function httpHookSuccess(target, state, response) { if (!response || response.error) return false; if (target?.checkStatus === false) return true; const okStatus = normalizeStatusCodes(target?.statusCodes).includes(response.status); const match = state === "ON" ? (target?.matchOn || "") : (target?.matchOff || ""); return okStatus && (match ? String(response.text || "").includes(match) : true); }
Test buttons run the real thing. The settings page has no request code of its own. Its Test ON and Test OFF buttons send a TEST_TARGET message, and the worker runs the same dispatchTarget() a real meeting would. A target that passes its test behaves the same way live. Tests use their own abort signal, so a meeting edge can't cancel them.
06Templates & escaping
URLs and bodies can contain {state}, {service}, {url}, {url_raw} and {ts}. Values are escaped for where they land, so a crafted meeting link can't inject parameters into your webhook.
- In URLs, values are
encodeURIComponent-escaped. A meeting link containing&state=OFFstays inside the{url}value. - In bodies that start with
{or[, values are JSON-string escaped, andContent-Type: application/jsonis added unless you set one. {url_raw}is the escape hatch: always substituted unescaped.{url}respects the privacy setting. Off (the default) sends only the origin, such ashttps://meet.google.com, so the meeting ID never leaves the browser.- Parts you still have to fill in are written
YOUR_*. The settings page flags them as warnings and doesn't request permission for placeholder hosts.
Highlighted values were substituted. Try turning the full meeting URL on: in the unescaped version the meeting link's own &state=OFF becomes a second, conflicting parameter.
07Staying in sync
MV3 background scripts are not long-lived. Chrome stops the service worker after about 30 seconds idle, and Firefox unloads the event page. Most of the subtle code exists so the sign stays correct anyway.
The 1-minute heartbeat
A chrome.alarms alarm named onair-reconcile fires every minute. It wakes the worker and calls tick("alarm"), which recomputes the state from scratch. A tab event missed while the worker was asleep is corrected within a minute. The alarm is created on install, on browser startup and at every worker start, so a reload doesn't lose it.
Persisted state: the duplicate notification bug
The worker's current state used to live only in a module variable. When the worker was stopped and restarted, it came back as OFF. The heartbeat then saw the meeting tab, read OFF → ON as a new edge, and fired every target again. Ntfy users got a fresh "in a meeting" push every few minutes. The fix stores the last applied state in chrome.storage.session (in memory, cleared on browser restart, never synced) and hydrates it at the top of tick(). Use the "Replay the old bug" checkbox in the simulator to see both behaviours.
Reconcile policy, per target
An edge fires every target once. What the heartbeat does afterwards depends on each target's policy:
| Policy | On the heartbeat | Offered for | Use it when |
|---|---|---|---|
single | Nothing. Fires only on the edge. | all types; the only option for listeners | The target is a notification. Re-firing would spam. |
verify | Reads the device's actual mode. Re-sends only if it drifted. | ON-AIR sign | A readback exists: /api/status on the LAN, else the cloud shadow via GET ?thing=…. |
always | Re-sends the current state every minute. | webhook, ON-AIR sign | The setter is idempotent, like a smart plug's Power On. |
resolveReconcile clamps a policy to what the type supports. A hand-edited import can't put verify on an Ntfy hook. Defaults are single for listeners and webhooks and verify for the sign.
Two browsers, one sign
OFF only means "no meeting in this browser". If your laptop is in a meeting and your desktop browser is idle, the desktop re-asserting OFF every minute would fight the laptop. So while a browser is OFF, the heartbeat holds (heartbeatShouldFire). It only retries a target whose own OFF failed. Those targets are tracked in storage.session.offFailed by trackOffFailures: a failed OFF adds the target, a delivered OFF removes it, and a new ON edge clears the list.
export function heartbeatShouldFire(state, offFailed) { return state === "ON" || offFailed === true; }
Edge cancellation
Each edge owns an AbortController. A new edge aborts the previous one, which cancels its in-flight fetches and pending retries. The cancelled results are tagged superseded and ignored. Without this, a slow ON that is still retrying could land after a quick OFF and leave the sign lit. The reconcile pass runs under the current edge's signal, so a new edge also cancels a stale re-assert.
Small efficiencies
- The config is cached in memory and dropped on
storage.onChangedor aCONFIG_UPDATEDmessage. - The toolbar icon is only re-set when colour/grey actually changes, since
setIcondecodes four PNGs. - Activity-log writes are serialized through a promise chain, so concurrent edge, worker-start and reconcile writes can't overwrite each other.
- Reconcile passes are only logged when something was actually sent, so the log shows remediations and not a per-minute heartbeat.
08Storage & secrets
Settings sync across your browsers. Credentials don't.
| Area | Key | Contents |
|---|---|---|
storage.sync | config | Services, trigger mode, targets, custom services, preferences. Credentials blanked. Chrome mirrors this to your browser account. |
storage.local | secrets | Per-target tokens, passwords, secret headers and secret URLs, keyed by target id. |
pause | {until}: 0 = running, −1 = until resumed, otherwise epoch ms. | |
debugLogs, activityLog | The diagnostics switch and a ring buffer of at most 200 structured events. | |
theme etc. | Per-device UI preferences. | |
storage.session | runtimeState | Last applied {state, service, url}. Survives worker restarts; cleared when the browser closes. |
lastDispatch | Summary of the last send, for the popup health line. | |
offFailed | Targets whose OFF didn't land, for the heartbeat retry. |
How secrets are found and split
extractSecrets(cfg) returns a sanitized copy for sync and a secrets map for local storage. applySecrets merges them back when the config loads. Detection works by pattern, not a fixed list:
- Fields: the sign's local and cloud tokens and the webhook's Basic-auth password.
- Headers whose name matches
auth|token|key|secret|cookie|session|passw, such asAuthorization,X-Api-KeyorCookie. - URLs that are themselves keys: userinfo (
user:pass@), query parameters liketoken,api_keyorsig, and known webhook shapes for Home Assistant, Slack, Discord and IFTTT.
If loadConfig finds a secret in synced storage (for example, from an older version), it moves it to local storage and rewrites the synced copy. Exports run through redactSecrets unless you tick "Include secrets", and imports list every host the imported targets would contact before they are applied.
Other guard rails
- No credentialed redirects.
fetchstrips onlyAuthorizationon a cross-origin redirect. A customX-API-Tokenwould follow the redirect to another host. Any request carrying credentials usesredirect: "error"(redirectPolicyFor). - Cleartext warnings. A token sent over plain
http://to a public host is flagged on the target card. LAN addresses (RFC 1918, loopback, link-local, CGNAT/Tailscale,.local,.lan, IPv6 ULA) are allowed, since the sign lives there. IP ranges are only checked against real IP literals, so10.evil.comdoesn't count as LAN. - Clamped inputs. Modes must be 0, 1 or 2, timeouts are bounded, and unknown target types are dropped on load.
- Messages from elsewhere are ignored. The worker drops any message whose
sender.idisn't this extension.
09The UI pages
Three surfaces, all plain HTML and ES modules that import shared.js for their text and logic.
Toolbar popup
A status card in red (ON AIR), neutral (Off air) or paused, with the detected meeting. Below it is a health line built from lastDispatch by describeDispatchHealth, for example "✓ All 3 targets updated 2m ago" or which target failed and why. Pause buttons hold the sign OFF for an hour or until you resume. The popup paints first from a localStorage cache to avoid a flash while the worker wakes, then asks GET_STATE and listens for STATE_CHANGED.
Settings page
Cards in order: Meeting detection, Targets, Preferences, Diagnostics, Backup & restore. General controls save instantly. Target edits use a sticky save bar that appears only when editsSignature() differs from the saved baseline, and it names any site the browser will ask you to allow. On save, the page requests the missing host permissions (this has to be the first await of the click, because Firefox enforces the user gesture strictly), splits secrets, writes both storage areas, revokes origins no target uses any more, and sends CONFIG_UPDATED. All dynamic HTML goes through esc().
Diagnostics and the DevTools panel
One viewer, diagnostics.html, shown two ways: as a tab from Settings, and as an "ON-AIR" panel in DevTools registered by devtools.js. It reads activityLog directly from storage.local and live-updates through storage.onChanged, so it works even while the worker is asleep. Entries are turned into readable lines in shared.js (describeLogEntry, logSeverity) and can be filtered by changes, reconcile, worker or errors.
Messages between pages and the worker
| Message | From | What the worker does |
|---|---|---|
GET_STATE | popup | Returns {state, service, detected, pause}. |
STATE_CHANGED | worker → popup | Broadcast after an edge, pause or resume. |
SET_PAUSE / RESUME | popup | Writes pause, ticks and broadcasts. |
TEST_TARGET | settings | Runs dispatchTarget for the given target and state and returns status, latency and route (local or cloud). |
CONFIG_UPDATED | settings | Drops the config cache and ticks so new settings apply now. |
10File map
extension/manifest.jsonMV3 manifest. Its version is the source of truth for releases.
extension/background.js897 linesTab listeners, tick(), debounce, executors, retries, reconcile, persisted state, activity log, message handler.
extension/shared.js1,045 linesAll pure logic: matching, templating, clamping, secrets, reconcile rules, UI text. No browser globals at import time, so Node can test it.
extension/options.html / options.js1,375 lines JSSettings UI: target cards, templates, permissions, save bar, import and export, Test buttons.
extension/popup.html / popup.js204 lines JSStatus card, health line, pause controls.
extension/diagnostics.html / .js144 lines JSActivity log viewer with filters.
extension/devtools.html / .jsRegisters the DevTools panel that hosts the diagnostics page.
tests/*.test.jsNode built-in test runner specs for shared.js and the Firefox manifest. npm test, no dependencies.
scripts/build-zip.shBuilds dist.zip (Chrome) and dist-firefox.zip.
scripts/firefox-manifest.mjsTurns the Chrome manifest into the Firefox one.
scripts/release.shBumps the version, promotes the changelog, makes an annotated tag and pushes it.
scripts/cws-upload.sh / amo-submit.shStore uploads, called from CI.
11Build, test, release
There is no bundler. The extension/ folder is the shipped package as-is.
# unit tests (Node's built-in runner) npm test # load unpacked: chrome://extensions → Developer mode → Load unpacked → extension/ # package both browsers scripts/build-zip.sh # → dist.zip, dist-firefox.zip # cut a release from a clean, up-to-date main scripts/release.sh --dry-run 0.8.4 scripts/release.sh 0.8.4
Firefox from the same source
Firefox MV3 has no extension service workers. toFirefoxManifest() rewrites background.service_worker to background.scripts (the same module, run as an event page), swaps options_page for options_ui, and adds the Gecko id onair-meeting-trigger@mveplus with minimum version 140 and data_collection_permissions: none. The code itself uses chrome.*, which works in both browsers.
What a tag triggers
- Version check.
The workflow fails if
manifest.json's version differs from the tag. This is why tags only come fromrelease.sh. - Build both zips.
scripts/build-zip.sh. - GitHub release.
Any existing release for the tag is deleted first (the repo uses immutable releases), then recreated with the zips attached.
- Chrome Web Store draft.
Uploaded through the v2 API, but not published. If the previous version is still in review, the step skips with a warning. A person clicks "Submit for review".
- Firefox Add-ons.
web-ext sign --channel listed. Skips if the AMO keys are missing.