Technical guide · extension v0.8.3

Inside ON-AIR
Meeting Trigger

A Manifest V3 browser extension for Chrome, Chromium and Firefox. When a Google Meet, Zoom, Teams or custom meeting tab opens, it switches on an ON-AIR sign, a smart plug, a Home Assistant automation, a phone notification or any HTTP endpoint. When the last meeting tab closes, it switches them off again. This page explains every part of it and what the code does, with a simulator you can run.

← Home & install github.com/mveplus/onair-meeting-trigger ~4,500 lines of plain JavaScript no build step · no dependencies 118 unit tests
On Air
click me

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.

Architecture Browser tab events and a one-minute alarm feed background.js, which uses shared.js and three storage areas, and sends requests to listener, webhook and ON-AIR sign targets. The popup, options and diagnostics pages talk to the worker through messages and storage. BROWSER Tabs & windows onCreated · onUpdated onRemoved · onActivated windows.onFocusChanged chrome.alarms "onair-reconcile" · 1 min EXTENSION PAGES Toolbar popup popup.html / popup.js Settings page options.html / options.js Diagnostics diagnostics.html / .js + DevTools panel EXTENSION CORE background.js service worker / event page tick(reason) · ensureCurrent() · computeState() 400 ms debounce → edge? applySideEffects() → dispatchTarget() ×N reconcilePass() (alarm only) callUrl(): timeout · retry · AbortController onMessage: GET_STATE · TEST_TARGET · SET_PAUSE … shared.js pure helpers · unit-tested in Node matchService · applyTemplate · extractSecrets … STORAGE sync config (no secrets) local secrets · pause activityLog debugLogs session runtimeState lastDispatch offFailed TARGETS (HTTP) listener your own app / script GET ?state=ON&service=… httpHook Tasmota · Shelly Home Assistant · Ntfy any ON URL / OFF URL iotHybrid · ON-AIR sign 1 · LAN /api/set?state=1 2 · cloud POST ?thing&mode verify: /api/status Cloud bridge Lambda → AWS IoT Device Shadow readback ESP32-C6 sign onair-led-sign-firmware (separate repo)

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 keyValueWhy
permissionsstorage, tabs, alarmsSave settings, read tab URLs, run the 1-minute heartbeat.
optional_host_permissionshttp://*/*, https://*/*Requested per target origin, only when needed. Unused grants are revoked on save.
backgroundbackground.js, type: moduleChrome runs it as a service worker. The Firefox build rewrites this to an event page.
action / options_page / devtools_pagepopup.html / options.html / devtools.htmlThe 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

On Air
ON-AIR sign · iotHybrid · verifyoff
Tasmota plug · httpHook · alwaysoff
Ntfy push · httpHook · single0 sent
OFF worker awake

    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.

    1. 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). onUpdated fires for every title, favicon and loading change, so isRelevantTabUpdate only lets a URL change or a finished load through. Activation and focus only matter in "active tab" mode.

    2. 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 from storage.session, once per worker lifetime. This is what stops a freshly woken worker from treating "still in a meeting" as a new meeting.

    3. 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".

    4. Same as before? Stop here. sameState

      Most ticks end here. Only the heartbeat alarm goes on to a reconcile pass (see Staying in sync).

    5. Wait 400 ms and look again. DEBOUNCE_MS

      Navigations fire bursts of events. A setTimeout waits 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.

    6. Commit the new state. commitCurrent → saveCurrent

      The in-memory current is updated and written to storage.session under runtimeState. It only writes when something changed.

    7. Fire every enabled target once. applySideEffects → dispatchTarget

      The previous edge's AbortController is 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 with Promise.allSettled.

    8. Record the outcome. recordDispatch · recordOffFailures · logActivity

      A summary goes to storage.session.lastDispatch for the popup's health line ("✓ All 3 targets updated 2m ago"). Targets whose OFF failed are remembered in offFailed. If diagnostics are on, a structured entry with per-target latency and errors is appended to the activity log.

    9. Tell the popup. broadcastState

      A STATE_CHANGED message 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.

    ServiceBuilt-in prefixes
    Google Meethttps://meet.google.com/
    Microsoft Teamshttps://teams.microsoft.com/
    Zoomhttps://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

    runListenerTarget

    The 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

    runHttpHookTarget

    Separate 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

    runIotHybridTarget

    For 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=OFF stays inside the {url} value.
    • In bodies that start with { or [, values are JSON-string escaped, and Content-Type: application/json is 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 as https://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:

    PolicyOn the heartbeatOffered forUse it when
    singleNothing. Fires only on the edge.all types; the only option for listenersThe target is a notification. Re-firing would spam.
    verifyReads the device's actual mode. Re-sends only if it drifted.ON-AIR signA readback exists: /api/status on the LAN, else the cloud shadow via GET ?thing=….
    alwaysRe-sends the current state every minute.webhook, ON-AIR signThe 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.onChanged or a CONFIG_UPDATED message.
    • The toolbar icon is only re-set when colour/grey actually changes, since setIcon decodes 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.

    AreaKeyContents
    storage.syncconfigServices, trigger mode, targets, custom services, preferences. Credentials blanked. Chrome mirrors this to your browser account.
    storage.localsecretsPer-target tokens, passwords, secret headers and secret URLs, keyed by target id.
    pause{until}: 0 = running, −1 = until resumed, otherwise epoch ms.
    debugLogs, activityLogThe diagnostics switch and a ring buffer of at most 200 structured events.
    theme etc.Per-device UI preferences.
    storage.sessionruntimeStateLast applied {state, service, url}. Survives worker restarts; cleared when the browser closes.
    lastDispatchSummary of the last send, for the popup health line.
    offFailedTargets 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 as Authorization, X-Api-Key or Cookie.
    • URLs that are themselves keys: userinfo (user:pass@), query parameters like token, api_key or sig, 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. fetch strips only Authorization on a cross-origin redirect. A custom X-API-Token would follow the redirect to another host. Any request carrying credentials uses redirect: "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, so 10.evil.com doesn'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.id isn'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

    MessageFromWhat the worker does
    GET_STATEpopupReturns {state, service, detected, pause}.
    STATE_CHANGEDworker → popupBroadcast after an edge, pause or resume.
    SET_PAUSE / RESUMEpopupWrites pause, ticks and broadcasts.
    TEST_TARGETsettingsRuns dispatchTarget for the given target and state and returns status, latency and route (local or cloud).
    CONFIG_UPDATEDsettingsDrops the config cache and ticks so new settings apply now.

    10File map

    extension/manifest.json

    MV3 manifest. Its version is the source of truth for releases.

    extension/background.js897 lines

    Tab listeners, tick(), debounce, executors, retries, reconcile, persisted state, activity log, message handler.

    extension/shared.js1,045 lines

    All 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 JS

    Settings UI: target cards, templates, permissions, save bar, import and export, Test buttons.

    extension/popup.html / popup.js204 lines JS

    Status card, health line, pause controls.

    extension/diagnostics.html / .js144 lines JS

    Activity log viewer with filters.

    extension/devtools.html / .js

    Registers the DevTools panel that hosts the diagnostics page.

    tests/*.test.js

    Node built-in test runner specs for shared.js and the Firefox manifest. npm test, no dependencies.

    scripts/build-zip.sh

    Builds dist.zip (Chrome) and dist-firefox.zip.

    scripts/firefox-manifest.mjs

    Turns the Chrome manifest into the Firefox one.

    scripts/release.sh

    Bumps the version, promotes the changelog, makes an annotated tag and pushes it.

    scripts/cws-upload.sh / amo-submit.sh

    Store 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

    1. Version check.

      The workflow fails if manifest.json's version differs from the tag. This is why tags only come from release.sh.

    2. Build both zips.

      scripts/build-zip.sh.

    3. GitHub release.

      Any existing release for the tag is deleted first (the repo uses immutable releases), then recreated with the zips attached.

    4. 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".

    5. Firefox Add-ons.

      web-ext sign --channel listed. Skips if the AMO keys are missing.