Make WhatsApp Web follow your Omarchy light/dark theme (#6484)

* Make WhatsApp Web follow your Omarchy light/dark theme

WhatsApp Web's "System default" theme follows prefers-color-scheme and
repaints live, so a small theme bridge is enough to make it track the active
Omarchy theme with no reload and no WhatsApp-specific CSS.

- omarchy-chromium-theme-host: push-only native messaging host that reads the
  active theme and emits it on connect and on every theme-set. Unlike copy-url/
  yt-dlp (one-shot), it stays connected and pushes, since theme-following needs
  the page to learn about changes while it is running.
- omarchy-chromium-theme-refresh: SIGUSR1s the running host(s); called from
  omarchy-theme-set's post_theme_commands.
- whatsapp-theme extension: decides dark vs. light from the theme background's
  WCAG luminance and drives a prefers-color-scheme shim, so WhatsApp's own
  theme does the repaint.

Wired like copy-url/yt-dlp and whatsapp-slim: bundled under
default/chromium/extensions, added to --load-extension, host manifest
registered from the fresh-install/refresh/browser-install paths, existing users
covered by a migration.

The host is named com.omarchy.theme (a generic theme bridge) rather than
WhatsApp-specific, so other bundled web-app extensions can follow the theme by
connecting to it and adding their id to the host manifest's allowed_origins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address review on the WhatsApp theme bridge

Light/dark was decided by weighting raw sRGB bytes, which the comment above
it already described as WCAG relative luminance. sRGB is gamma-encoded, so
the weights only mean anything once each channel is linearized — the two
steps the shell already does in Panel.qml. Every shipped theme classifies the
same either way; a mid-tone custom background does not (#808080 reads 0.502
unlinearized and 0.216 linearized).

Drop the `tabs` permission. The WhatsApp host permission is what lets
tabs.query filter by url and what populates tab urls in onUpdated, so `tabs`
only widened this to every tab's url and title. Tabs without permission
arrive with url unset and fall out on the existing guard.

Give the two new bin commands their metadata directives. Without a summary
they failed test/cli's command metadata check.

Cover all three: the classifier over unambiguous and mid-tone backgrounds,
and the manifest for the permission it should no longer ask for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Scope color-scheme listeners to their query and test the real host

Registrations all shared one Set keyed only by callback, so an app that gave
the same callback to both the dark and the light query and later detached one
detached the other too, leaving the query it still held deaf to theme
changes. Record the owning MediaQueryList and match on it. Adds native
dedupe behaviour while there: registering the same callback twice fired it
twice. addListener is a legacy alias of addEventListener("change"), so the
two share one registration space and either remover cancels either add —
which is also why useEvent had nothing left to select and is gone.

The refresh test signalled a synthetic sleeper carrying its own USR1 trap, so
it proved the refresh command sends a signal but would have stayed green
through any regression in the host's own trap, watchdog wait, or second
write. Drive the real host over a FIFO instead, count framed messages, and
assert the second one is a usable theme. Verified by neutering the host's
USR1 trap: the old test passed, this one fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Harden Chromium theme bridge

* Address Chromium theme bridge review

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
This commit is contained in:
Scott Jones
2026-08-01 13:16:06 -05:00
committed by GitHub
co-authored by Claude Opus 5 David Heinemeier Hansson
parent 1bc89105d2
commit 237405215d
15 changed files with 964 additions and 1 deletions
@@ -0,0 +1,103 @@
// MV3 service worker for the Omarchy WhatsApp theme extension.
//
// Holds the long-lived native-messaging port to the shared Omarchy theme bridge
// (com.omarchy.theme / omarchy-chromium-theme-host), which pushes the active
// theme on connect and on every omarchy theme-set. We rebroadcast pushes to
// WhatsApp tabs and answer the content script's theme requests. We never write
// to the port — the host is push-only.
const HOST = "com.omarchy.theme";
const WHATSAPP_URL_PATTERNS = ["*://web.whatsapp.com/*"];
let port = null;
let reconnectTimer = null;
function connect() {
// The service worker can reach this from three directions at once: the
// module-level call below, onInstalled, and onStartup. Without this guard each
// one opens its own port, and every port spawns a separate long-lived native
// host — so the theme-set refresh then signals N hosts and every theme change
// gets broadcast to the same tabs N times.
if (port) return;
try {
port = chrome.runtime.connectNative(HOST);
console.log("[omarchy] native port connected");
} catch (e) {
console.warn("[omarchy] connectNative threw:", e);
scheduleReconnect();
return;
}
port.onMessage.addListener((theme) => {
if (!theme || theme.error) {
console.warn("[omarchy] native host error:", theme && theme.error);
return;
}
console.log("[omarchy] theme pushed by native host:", theme.theme_name, theme.bg);
chrome.storage.local.set({ theme });
broadcast(theme);
});
port.onDisconnect.addListener(() => {
const err = chrome.runtime.lastError;
console.warn("[omarchy] native host disconnected:", err && err.message);
port = null;
scheduleReconnect();
});
// No request needed: the host is push-only. It emits the current theme as soon
// as it starts, then again on every theme change (driven by omarchy's
// theme-set refresh). We never write to the port.
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 3000);
}
function broadcast(theme) {
chrome.tabs.query({ url: WHATSAPP_URL_PATTERNS }, (tabs) => {
console.log("[omarchy] broadcasting theme to", tabs.length, "whatsapp tab(s)");
for (const t of tabs) {
chrome.tabs.sendMessage(t.id, { type: "omarchy-theme", theme }).catch(() => {});
}
});
}
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg && msg.type === "request-theme") {
chrome.storage.local.get("theme").then(({ theme }) => sendResponse(theme || null));
return true;
}
// Kept for parity with the shared engine, which asks for a guaranteed-current
// theme right before it would drive an app's own color mode. Storage is
// already current: omarchy fires its theme-set refresh after the new theme's
// files are final, the host pushes immediately, and we write storage on that
// push — all before the content script gets the broadcast that makes it ask.
if (msg && msg.type === "request-fresh-theme") {
chrome.storage.local.get("theme").then(({ theme }) => sendResponse(theme || null));
return true;
}
});
// The WhatsApp host permission is what grants tab urls here and lets the query
// above filter by url, so the `tabs` permission — which would hand over every
// tab's url and title — is not needed. Tabs we have no permission for arrive
// with url unset and fall out on the guard below. If a url ever went missing on
// a tab we do own, content.js still asks for the theme itself at document_start,
// so this listener is the redundant path rather than the only one.
chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
if (info.status !== "complete") return;
if (!tab.url || !tab.url.includes("web.whatsapp.com")) return;
chrome.storage.local.get("theme").then(({ theme }) => {
if (theme) chrome.tabs.sendMessage(tabId, { type: "omarchy-theme", theme }).catch(() => {});
});
});
chrome.runtime.onInstalled.addListener(connect);
chrome.runtime.onStartup.addListener(connect);
connect();
@@ -0,0 +1,60 @@
// Follows the active Omarchy theme's light/dark for WhatsApp Web.
//
// The shared theme bridge (com.omarchy.theme) pushes the current theme to the
// service worker, which broadcasts it here. We decide dark vs. light from the
// WCAG relative luminance of the terminal background — not the theme's day/night
// name — then hand that to the MAIN-world prefers-color-scheme shim
// (omarchy-prefers-color-scheme.js). The shim flips window.matchMedia, so
// WhatsApp Web's "System default" theme repaints live with no reload.
//
// That's the whole extension: WhatsApp themes itself, so there's no CSS to
// inject. Matching WhatsApp's surfaces to the exact omarchy palette would be a
// separate, heavier layer and is intentionally left out of this version.
// sRGB is gamma-encoded, so the WCAG weights only mean anything once each
// channel is linearized — the same two steps the shell uses in
// Panel.qml's colorChannelLuminance. Weighting the raw bytes instead lands
// mid-tone backgrounds on the wrong side: #808080 reads 0.502 that way and
// 0.216 once linearized. Every shipped theme is far enough from the middle to
// classify the same either way; a custom one need not be.
function channelLuminance(byte) {
const channel = byte / 255;
return channel <= 0.03928
? channel / 12.92
: Math.pow((channel + 0.055) / 1.055, 2.4);
}
function isDarkTheme(theme) {
const hex = ((theme && theme.bg) || "").replace(/^#/, "");
if (hex.length < 6) return null;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
if ([r, g, b].some(Number.isNaN)) return null;
const luminance =
0.2126 * channelLuminance(r) +
0.7152 * channelLuminance(g) +
0.0722 * channelLuminance(b);
return luminance < 0.5;
}
let lastDark = null;
function applyTheme(theme) {
const dark = isDarkTheme(theme);
if (dark === null || dark === lastDark) return;
lastDark = dark;
document.documentElement.style.colorScheme = dark ? "dark" : "light";
document.dispatchEvent(
new CustomEvent("omarchy:set-color-scheme", { detail: { dark } })
);
}
// Themes arrive two ways: pushed live by the service worker on an omarchy
// theme-set, and fetched once on load.
chrome.runtime.onMessage.addListener((msg) => {
if (msg && msg.type === "omarchy-theme") applyTheme(msg.theme);
});
chrome.runtime.sendMessage({ type: "request-theme" }, (theme) => {
if (theme) applyTheme(theme);
});
@@ -0,0 +1,30 @@
{
"manifest_version": 3,
"name": "WhatsApp Omarchy Theme",
"version": "1.0",
"description": "Make WhatsApp Web follow your current Omarchy light/dark theme, this extension is installed by Omarchy",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApWxRYnzXjhKFK8oDs721taskS9MbEid55l4V5qVpDTdm4Idrmj6NTdvm1Z2SW/lbGOSk95kxhyEH35tVo6QW2GC9K+0V6lvAzZv9nqMcfeDowQsyPPBopoh5soGBANw2lyHduADPUmbVT9J0/C+erYWx4QM7v08R6aZIF6j2KgOLnCo7aWLpVA40fA2eDHj1CLtcnAnSefRYBB4b5ac8Ir3Mjh4ojEROYQ51kEgxAZJF9HhvFbMFuN9AqD/wV2HdHSflMhxpNocGkhkVNAkXNIlcjQKrVo2B0EBB16Q4/QbYol93vnJ+rojWnds0tGbnLgYsdK7uacYh8l+4dKu6OQIDAQAB",
"permissions": ["nativeMessaging", "storage"],
"host_permissions": ["*://web.whatsapp.com/*"],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["*://web.whatsapp.com/*"],
"js": ["omarchy-prefers-color-scheme.js"],
"run_at": "document_start",
"all_frames": false,
"world": "MAIN"
},
{
"matches": ["*://web.whatsapp.com/*"],
"js": ["content.js"],
"run_at": "document_start",
"all_frames": false
}
],
"action": {
"default_title": "WhatsApp Omarchy Theme"
}
}
@@ -0,0 +1,174 @@
// Omarchy web-app theming — prefers-color-scheme shim (app-agnostic).
//
// Runs in the page's MAIN world at document_start. Spoofs
// window.matchMedia('(prefers-color-scheme: ...)') so a web app's "sync with
// system" appearance follows the active Omarchy theme instead of the real OS
// setting. The engine (omarchy-runtime.js, isolated world) dispatches an
// `omarchy:set-color-scheme` event on every theme apply; we flip the spoofed
// value here and notify the app's registered media-query listeners, so an app
// on "System default" repaints live with no reload.
(function () {
if (window.__omarchyPCSInstalled) return;
window.__omarchyPCSInstalled = true;
const orig = window.matchMedia.bind(window);
let isDark = orig("(prefers-color-scheme: dark)").matches;
const listeners = new Set();
const owners = new Set();
function makeProxy(query) {
const wantsDark = /dark/i.test(query);
const wantsLight = /light/i.test(query);
const target = orig(query);
// Registrations live in one shared Set, so they have to record which
// MediaQueryList they came from. Without that, an app that hands the same
// callback to both the dark and the light query and later detaches one
// would silently detach the other too, and the query it still holds would
// stop hearing theme changes. `addListener` is a legacy alias of
// `addEventListener("change")`, so the two share a registration space and
// either remover cancels either add.
const owner = {};
let onchange = null;
let proxy = null;
function captureFrom(options) {
return typeof options === "boolean" ? options : !!options?.capture;
}
function has(cb, capture) {
for (const e of listeners)
if (e.owner === owner && e.cb === cb && e.capture === capture) return true;
return false;
}
function add(cb, options = false) {
// Native listeners dedupe on identity; adding twice must not fire twice.
const capture = captureFrom(options);
if (
(typeof cb !== "function" && typeof cb?.handleEvent !== "function") ||
options?.signal?.aborted ||
has(cb, capture)
)
return;
const entry = {
owner,
cb,
capture,
once: !!options?.once,
signal: options?.signal,
wantsDark,
wantsLight,
};
listeners.add(entry);
if (entry.signal) {
entry.abort = () => listeners.delete(entry);
entry.signal.addEventListener("abort", entry.abort, { once: true });
}
}
function remove(cb, options = false) {
const capture = captureFrom(options);
for (const e of listeners) {
if (e.owner !== owner || e.cb !== cb || e.capture !== capture) continue;
listeners.delete(e);
if (e.signal) e.signal.removeEventListener("abort", e.abort);
}
}
proxy = new Proxy(target, {
get(_t, prop) {
if (prop === "matches") {
if (wantsDark) return isDark;
if (wantsLight) return !isDark;
return target.matches;
}
if (prop === "media") return query;
if (prop === "onchange") return onchange;
if (prop === "addEventListener") {
return (evt, cb, options) => {
if (evt === "change") add(cb, options);
};
}
if (prop === "removeEventListener") {
return (evt, cb, options) => {
if (evt === "change") remove(cb, options);
};
}
if (prop === "addListener") {
// deprecated API — single callback arg
return (cb) => add(cb);
}
if (prop === "removeListener") {
return (cb) => remove(cb);
}
const v = target[prop];
return typeof v === "function" ? v.bind(target) : v;
},
set(_t, prop, value) {
if (prop === "onchange") {
onchange = typeof value === "function" ? value : null;
if (onchange) owners.add(owner);
else owners.delete(owner);
return true;
}
return Reflect.set(target, prop, value);
},
});
owner.proxy = proxy;
owner.onchange = () => onchange;
owner.wantsDark = wantsDark;
owner.wantsLight = wantsLight;
return proxy;
}
window.matchMedia = function (query) {
if (typeof query === "string" && /prefers-color-scheme/i.test(query)) {
return makeProxy(query);
}
return orig(query);
};
document.addEventListener("omarchy:set-color-scheme", (ev) => {
const next = !!(ev.detail && ev.detail.dark);
if (next === isDark) return;
isDark = next;
for (const entry of listeners) {
const { owner, cb, wantsDark, wantsLight } = entry;
const matches = wantsDark ? isDark : wantsLight ? !isDark : false;
const media = wantsDark
? "(prefers-color-scheme: dark)"
: wantsLight
? "(prefers-color-scheme: light)"
: "";
try {
if (entry.once) listeners.delete(entry);
if (entry.signal) entry.signal.removeEventListener("abort", entry.abort);
// Shaped like a MediaQueryListEvent; apps read .matches. The legacy
// addListener callback takes the same argument, so there is nothing to
// branch on here.
const event = { matches, media, target: owner.proxy, currentTarget: owner.proxy };
if (typeof cb === "function") cb.call(owner.proxy, event);
else cb.handleEvent(event);
} catch (_) {}
}
for (const owner of owners) {
const cb = owner.onchange();
if (!cb) continue;
const matches = owner.wantsDark ? isDark : owner.wantsLight ? !isDark : false;
const media = owner.wantsDark
? "(prefers-color-scheme: dark)"
: owner.wantsLight
? "(prefers-color-scheme: light)"
: "";
try {
cb.call(owner.proxy, {
matches,
media,
target: owner.proxy,
currentTarget: owner.proxy,
});
} catch (_) {}
}
});
})();
@@ -0,0 +1,9 @@
{
"name": "com.omarchy.theme",
"description": "Omarchy theme bridge host — pushes the active theme to web-app extensions",
"path": "__HOST_PATH__",
"type": "stdio",
"allowed_origins": [
"chrome-extension://ndpkabodcpddojgepdideonokpblpeln/"
]
}