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:
co-authored by
Claude Opus 5
David Heinemeier Hansson
parent
1bc89105d2
commit
237405215d
@@ -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/"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user