Revert WhatsApp theme bridge

The native messaging host, extension shim, and lifecycle machinery are too complicated for the value this integration provides.
This commit is contained in:
David Heinemeier Hansson
2026-08-01 12:44:40 -07:00
parent 237405215d
commit 66427571bc
15 changed files with 1 additions and 964 deletions
@@ -1,103 +0,0 @@
// 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();
@@ -1,60 +0,0 @@
// 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);
});
@@ -1,30 +0,0 @@
{
"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"
}
}
@@ -1,174 +0,0 @@
// 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 (_) {}
}
});
})();
@@ -1,9 +0,0 @@
{
"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/"
]
}