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
+224
View File
@@ -0,0 +1,224 @@
#!/bin/bash
# omarchy:summary=Native messaging host: push the active Omarchy theme to Chromium web apps
# omarchy:hidden=true
# Native messaging host for the Omarchy theme bridge.
#
# Reads the active Omarchy theme and reports background / foreground / accent /
# selection / browser-chrome colors to a web-app extension as length-prefixed
# JSON on stdout. It's a shared bridge keyed by name (com.omarchy.theme): any
# bundled web-app extension that wants to follow the theme connects to it and
# lists its ID in the host manifest's allowed_origins.
#
# This host is PUSH-ONLY: it never parses inbound messages. That's deliberate.
# Reading Chromium's length-prefixed framing in bash means blocking in
# `head -c4`, which a trap cannot interrupt — so a signal-driven wakeup would
# need a background reader plus a FIFO. Instead we let omarchy's theme-set hook
# signal us with SIGUSR1 (see bin/omarchy-theme-set) and drop inbound
# stdio entirely. The extension gets a push on connect and a push on every
# theme change, which is all it ever needs.
#
# This differs from Omarchy's other bundled hosts (omarchy-chromium-copy-url-host,
# -ytdlp-host): those are one-shot — read a frame, act, exit. Theme-following is
# the inverse direction (system -> extension), so we stay alive to push; only
# their framing idioms carry over, not their structure.
#
# Because it ships in-tree, `omarchy-theme-set` runs the refresh command on every
# switch, so no polling fallback is needed (the standalone AUR build of this host
# carries one for old Omarchy).
set -uo pipefail
# Omarchy 4+ keeps the active theme state here; theme/ is a directory Omarchy
# replaces wholesale on every switch (same layout `omarchy-theme-set` writes).
CURRENT="$HOME/.local/state/omarchy/current"
# Pidfiles live here so the theme-set.d hook can SIGUSR1 every running host.
RUNDIR="$XDG_RUNTIME_DIR/omarchy-theme"
PIDFILE="$RUNDIR/$$.pid"
WATCHDOG_PID=""
# ---------------------------------------------------------------- lifecycle --
cleanup() {
rm -f "$PIDFILE"
[[ -n $WATCHDOG_PID ]] && kill "$WATCHDOG_PID" 2>/dev/null
return 0
}
# ------------------------------------------------------------------ parsing --
trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
read_text() {
[[ -f $1 ]] || return 0
local text
text=$(<"$1") || return 0
trim "$text"
}
# The terminal background lives under [colors.primary] in the theme's
# alacritty.toml. Only that section counts — other sections define their own
# `background` keys that would otherwise match.
parse_alacritty_bg() {
local file="$1" line s in_primary=0
[[ -f $file ]] || return 0
while IFS= read -r line || [[ -n $line ]]; do
s=$(trim "$line")
if [[ $s == \[* ]]; then
if [[ $s == "[colors.primary]" ]]; then in_primary=1; else in_primary=0; fi
continue
fi
if ((in_primary)) && [[ $s =~ ^background[[:space:]]*=[[:space:]]*\"(#[0-9a-fA-F]{6,8})\" ]]; then
printf '%s' "${BASH_REMATCH[1]}"
return 0
fi
done <"$file"
}
# colors.toml is a flat list of `name = "#rrggbb"` pairs. Populates COLORS.
declare -A COLORS=()
parse_colors_toml() {
local file="$1" line s
COLORS=()
[[ -f $file ]] || return 0
while IFS= read -r line || [[ -n $line ]]; do
s=$(trim "$line")
if [[ $s =~ ^([A-Za-z0-9_]+)[[:space:]]*=[[:space:]]*\"(#[0-9a-fA-F]{6,8})\" ]]; then
COLORS["${BASH_REMATCH[1]}"]="${BASH_REMATCH[2]}"
fi
done <"$file"
}
# omarchy ships the browser chrome color as 'r,g,b' decimal CSV in chromium.theme.
# Most stock themes omit the file — omarchy-theme-set-browser then falls back to
# #1c2027, but we return empty and let the extension pick its own fallback so it
# can be theme-aware about the choice.
parse_chromium_theme() {
local file="$1" text r g b extra c
[[ -f $file ]] || return 0
text=$(<"$file") || return 0
text=$(trim "$text")
IFS=, read -r r g b extra <<<"$text"
[[ -n ${extra:-} ]] && return 0
r=$(trim "${r:-}") g=$(trim "${g:-}") b=$(trim "${b:-}")
for c in "$r" "$g" "$b"; do
[[ $c =~ ^[0-9]+$ ]] || return 0
((c >= 0 && c <= 255)) || return 0
done
printf '#%02x%02x%02x' "$r" "$g" "$b"
}
# --------------------------------------------------------------------- JSON --
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
printf '%s' "$s"
}
# Emit a JSON string, or bare null when empty, so the extension can tell "theme
# didn't define this color" from "empty string".
json_value() {
if [[ -z ${1:-} ]]; then
printf 'null'
else
printf '"%s"' "$(json_escape "$1")"
fi
}
build_state() {
local name bg fg accent selection chrome
name=$(read_text "$CURRENT/theme.name")
bg=$(parse_alacritty_bg "$CURRENT/theme/alacritty.toml")
parse_colors_toml "$CURRENT/theme/colors.toml"
chrome=$(parse_chromium_theme "$CURRENT/theme/chromium.theme")
[[ -z $bg ]] && bg="${COLORS[background]:-}"
[[ -z $bg ]] && bg="#1e1e2e" # last-resort fallback
fg="${COLORS[foreground]:-}"
accent="${COLORS[accent]:-}"
selection="${COLORS[selection_background]:-}"
# No day/night flag by design — the extension decides dark vs. light purely
# from the WCAG relative luminance of bg.
printf '{"theme_name":%s,"bg":%s,"fg":%s,"accent":%s,"selection_bg":%s,"chrome":%s}' \
"$(json_value "$name")" \
"$(json_value "$bg")" \
"$(json_value "$fg")" \
"$(json_value "$accent")" \
"$(json_value "$selection")" \
"$(json_value "$chrome")"
}
# ------------------------------------------------------------------ framing --
# Chromium expects each message prefixed with its byte length as a native-endian
# (little, on every platform we target) uint32.
emit() {
local json="$1" len
len=$(LC_ALL=C printf '%s' "$json" | wc -c)
printf '%b%s' \
"\\x$(printf '%02x' $((len & 0xff)))\\x$(printf '%02x' $((len >> 8 & 0xff)))\\x$(printf '%02x' $((len >> 16 & 0xff)))\\x$(printf '%02x' $((len >> 24 & 0xff)))" \
"$json" || exit 0 # stdout closed: the browser is gone
}
emit_theme() {
local state
state=$(build_state) || return 0
emit "$state"
}
# --------------------------------------------------------------------- main --
main() {
local starttime
trap cleanup EXIT
# Arm signal handlers before publishing the pidfile so refresh cannot signal
# the host during a window where USR1 still has its default fatal disposition.
trap 'exit 0' TERM INT
trap 'emit_theme' USR1
mkdir -p "$RUNDIR"
starttime=$(awk '{print $22}' "/proc/$$/stat")
[[ -n $starttime ]] || exit 1
printf '%s %s\n' "$$" "$starttime" >"$PIDFILE"
# Push the current theme immediately on connect, before arming the watchdog —
# if stdin is already at EOF the watchdog fires at once, and we'd otherwise be
# killed before saying anything.
emit_theme
# The browser talks to us by closing the pipe, not by sending anything we care
# about. Drain stdin so its writes never block, and exit when it hangs up.
#
# The `<&3` is load-bearing. Bash gives every background job /dev/null as stdin
# unless it's redirected explicitly, so a bare read loop here would see EOF
# instantly and kill us right after the first push — leaving the extension in a
# reconnect loop. Duplicating the real stdin onto fd 3 overrides the default.
exec 3<&0
(while IFS= read -r; do :; done <&3; kill -TERM "$$" 2>/dev/null) &
WATCHDOG_PID=$!
# Stay alive until the browser closes the pipe. A SIGUSR1 interrupts `wait`,
# runs the USR1 trap to push, and we resume waiting; when the watchdog exits
# (browser gone) `wait` succeeds and we fall through to the EXIT trap.
while ! wait "$WATCHDOG_PID"; do :; done
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
fi
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# omarchy:summary=Signal running theme-bridge hosts to push the current theme
# omarchy:hidden=true
# Nudge every running Omarchy theme-bridge host to re-read and push the current
# theme, so web-app extensions repaint live on a theme switch instead of waiting
# for the next reload. Called by omarchy-theme-set.
#
# The hosts (omarchy-chromium-theme-host, spoken to over com.omarchy.theme) are
# push-only and can't watch the filesystem, so each drops a pidfile in RUNDIR on
# connect and waits for SIGUSR1. We signal them here. Dead/garbage pidfiles are
# pruned. A no-op when no browser is connected — nothing to signal.
set -uo pipefail
RUNDIR="$XDG_RUNTIME_DIR/omarchy-theme"
[[ -d $RUNDIR ]] || exit 0
shopt -s nullglob
for pidfile in "$RUNDIR"/*.pid; do
pid=""
starttime=""
extra=""
read -r pid starttime extra <"$pidfile" || true
current_starttime=""
[[ $pid =~ ^[0-9]+$ && $starttime =~ ^[0-9]+$ && -z ${extra:-} ]] &&
current_starttime=$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null) || true
if [[ -n $current_starttime && $current_starttime == "$starttime" ]] &&
kill -USR1 "$pid" 2>/dev/null; then
continue
fi
# Host is gone (or the pidfile is garbage) — prune it.
rm -f "$pidfile"
done
+1
View File
@@ -19,6 +19,7 @@ copy_chromium_flags() {
cp -f "$OMARCHY_PATH/config/chromium-flags.conf" "$1"
omarchy-install-chromium-copy-url
omarchy-install-chromium-ytdlp
omarchy-install-chromium-theme
}
setup_firefox_preferences() {
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# omarchy:summary=Install the native messaging host for the Omarchy theme bridge
set -euo pipefail
HOST_NAME="com.omarchy.theme"
HOST_PATH="$OMARCHY_PATH/bin/omarchy-chromium-theme-host"
TEMPLATE="$OMARCHY_PATH/default/chromium/native-messaging-hosts/$HOST_NAME.json"
browser_dirs=(
"$HOME/.config/chromium"
"$HOME/.config/google-chrome"
"$HOME/.config/google-chrome-beta"
"$HOME/.config/google-chrome-unstable"
"$HOME/.config/BraveSoftware/Brave-Browser"
"$HOME/.config/BraveSoftware/Brave-Browser-Beta"
"$HOME/.config/BraveSoftware/Brave-Browser-Nightly"
"$HOME/.config/microsoft-edge"
"$HOME/.config/microsoft-edge-dev"
)
manifest=$(sed "s|__HOST_PATH__|$HOST_PATH|g" "$TEMPLATE")
for dir in "${browser_dirs[@]}"; do
mkdir -p "$dir/NativeMessagingHosts"
printf '%s\n' "$manifest" >"$dir/NativeMessagingHosts/$HOST_NAME.json"
done
+1
View File
@@ -18,6 +18,7 @@ omarchy-refresh-config chromium-flags.conf
# Install/refresh the native messaging hosts used by the bundled extensions
omarchy-install-chromium-copy-url
omarchy-install-chromium-ytdlp
omarchy-install-chromium-theme
# Re-install Google accounts if previously configured
if [[ $INSTALL_GOOGLE_ACCOUNTS == "true" ]]; then
+1
View File
@@ -199,6 +199,7 @@ post_theme_commands=(
omarchy-theme-set-pi
omarchy-theme-set-claude
omarchy-theme-set-browser
omarchy-chromium-theme-refresh
omarchy-theme-set-vscode
omarchy-theme-set-obsidian
omarchy-theme-set-keyboard
+1 -1
View File
@@ -2,4 +2,4 @@
--ozone-platform-hint=wayland
--password-store=gnome-libsecret
--enable-features=TouchpadOverscrollHistoryNavigation
--load-extension=/usr/share/omarchy/default/chromium/extensions/copy-url,/usr/share/omarchy/default/chromium/extensions/yt-dlp,/usr/share/omarchy/default/chromium/extensions/whatsapp-slim
--load-extension=/usr/share/omarchy/default/chromium/extensions/copy-url,/usr/share/omarchy/default/chromium/extensions/yt-dlp,/usr/share/omarchy/default/chromium/extensions/whatsapp-slim,/usr/share/omarchy/default/chromium/extensions/whatsapp-theme
@@ -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/"
]
}
+1
View File
@@ -4,3 +4,4 @@
# messaging host to talk to.
omarchy-install-chromium-copy-url
omarchy-install-chromium-ytdlp
omarchy-install-chromium-theme
+24
View File
@@ -0,0 +1,24 @@
echo "Add the WhatsApp Omarchy Theme extension and register its native messaging host"
WHATSAPP_THEME_EXT="$OMARCHY_PATH/default/chromium/extensions/whatsapp-theme"
add_whatsapp_theme_extension() {
local file=$1
[[ -f $file ]] || return 0
grep -q "extensions/whatsapp-theme" "$file" && return 0
if grep -q "^--load-extension=" "$file"; then
sed -i --follow-symlinks "s|^--load-extension=\(.*\)$|--load-extension=\1,$WHATSAPP_THEME_EXT|" "$file"
else
echo "--load-extension=$WHATSAPP_THEME_EXT" >>"$file"
fi
}
for conf in chromium chrome google-chrome brave brave-beta brave-nightly brave-origin-beta microsoft-edge-stable; do
add_whatsapp_theme_extension "$HOME/.config/$conf-flags.conf"
done
# The extension follows the theme over native messaging (com.omarchy.theme), so
# register its host manifest in every Chromium profile root.
omarchy-install-chromium-theme
+273
View File
@@ -0,0 +1,273 @@
#!/bin/bash
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
export PATH="$ROOT/bin:$PATH"
TMPDIR=""
cleanup() {
[[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR"
}
trap cleanup EXIT
require_command jq
require_command node
EXT_DIR="$ROOT/default/chromium/extensions/whatsapp-theme"
# The manifest key pins the extension id so the native host manifest's
# allowed_origins can be hardcoded. Derive it the same way Chromium does.
theme_id=$(node - <<'JS' "$EXT_DIR/manifest.json"
const crypto = require('crypto')
const fs = require('fs')
const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'))
const hash = crypto.createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest()
const alphabet = 'abcdefghijklmnop'
let id = ''
for (const byte of hash.subarray(0, 16)) {
id += alphabet[byte >> 4]
id += alphabet[byte & 0x0f]
}
process.stdout.write(id)
JS
)
[[ $theme_id == "ndpkabodcpddojgepdideonokpblpeln" ]] ||
fail "whatsapp-theme extension manifest has the stable id" "$theme_id"
pass "whatsapp-theme extension manifest has the stable id"
jq -e '
.manifest_version == 3 and
(.permissions | index("nativeMessaging")) and
(.host_permissions | index("*://web.whatsapp.com/*")) and
.background.service_worker == "background.js" and
(.content_scripts | map(.world) | index("MAIN")) and
(.content_scripts | map(.js) | add | index("omarchy-prefers-color-scheme.js")) and
(.content_scripts | map(.js) | add | index("content.js"))
' "$EXT_DIR/manifest.json" >/dev/null ||
fail "whatsapp-theme extension declares its host, content script, and prefers-color-scheme shim"
grep -q 'connectNative(HOST)' "$EXT_DIR/background.js" &&
grep -q '"com.omarchy.theme"' "$EXT_DIR/background.js" ||
fail "whatsapp-theme extension connects to the com.omarchy.theme bridge"
pass "whatsapp-theme extension follows the theme over native messaging"
# The WhatsApp host permission already grants tab urls and lets tabs.query filter
# by url, so `tabs` would only add every other tab's url and title.
jq -e '(.permissions | index("tabs")) | not' "$EXT_DIR/manifest.json" >/dev/null ||
fail "whatsapp-theme extension asks for no broader tab access than it needs"
pass "whatsapp-theme extension asks for no broader tab access than it needs"
# Light/dark comes from WCAG relative luminance, which needs each channel
# linearized first. Mid-tones are where weighting the raw bytes diverges.
scheme_check=$(node - "$EXT_DIR/content.js" <<'JS'
const fs = require('fs')
const source = fs.readFileSync(process.argv[2], 'utf8')
const match = source.match(/function channelLuminance[\s\S]*?\n}\n[\s\S]*?function isDarkTheme[\s\S]*?\n}\n/)
if (!match) {
process.stdout.write('no-classifier')
process.exit(0)
}
const isDarkTheme = new Function(match[0] + '; return isDarkTheme')()
const cases = [
['#1e1e2e', true], // catppuccin, unambiguously dark
['#eff1f5', false], // catppuccin-latte, unambiguously light
['#000000', true],
['#ffffff', false],
['#808080', true], // gamma-encoded weighting calls this light
]
const bad = cases.filter(([bg, want]) => isDarkTheme({ bg }) !== want).map(([bg]) => bg)
if (isDarkTheme({ bg: 'nope' }) !== null || isDarkTheme({}) !== null) bad.push('malformed')
process.stdout.write(bad.length ? 'wrong:' + bad.join(',') : 'ok')
JS
)
[[ $scheme_check == "ok" ]] ||
fail "whatsapp-theme classifies light and dark by linearized luminance" "$scheme_check"
pass "whatsapp-theme classifies light and dark by linearized luminance"
# The shim must preserve the MediaQueryList listener shapes web apps can use,
# including EventListener objects and the onchange property.
listener_check=$(node - "$EXT_DIR/omarchy-prefers-color-scheme.js" <<'JS'
const fs = require('fs')
const vm = require('vm')
let themeChange
const context = {
window: {
matchMedia(query) {
return { matches: query.includes('dark'), media: query }
},
},
document: {
addEventListener(type, cb) {
if (type === 'omarchy:set-color-scheme') themeChange = cb
},
},
}
vm.runInNewContext(fs.readFileSync(process.argv[2], 'utf8'), context)
const query = context.window.matchMedia('(prefers-color-scheme: dark)')
const calls = []
query.addEventListener('change', { handleEvent: (event) => calls.push(['object', event.matches]) })
query.addEventListener('change', (event) => calls.push(['once', event.matches]), { once: true })
const controller = new AbortController()
query.addEventListener('change', () => calls.push(['aborted']), { signal: controller.signal })
controller.abort()
query.onchange = function (event) {
calls.push(['onchange', event.matches, this === query])
}
themeChange({ detail: { dark: false } })
themeChange({ detail: { dark: true } })
process.stdout.write(JSON.stringify(calls))
JS
)
[[ $listener_check == '[["object",false],["once",false],["onchange",false,true],["object",true],["onchange",true,true]]' ]] ||
fail "prefers-color-scheme shim supports listener objects and onchange" "$listener_check"
pass "prefers-color-scheme shim supports listener objects and onchange"
TMPDIR=$(mktemp -d)
test_home="$TMPDIR/home"
native_manifest="$test_home/.config/chromium/NativeMessagingHosts/com.omarchy.theme.json"
HOME="$test_home" OMARCHY_PATH="$ROOT" omarchy-install-chromium-theme
[[ -f $native_manifest ]] || fail "theme native host installer creates fresh Chromium profile root"
jq -e --arg path "$ROOT/bin/omarchy-chromium-theme-host" '
.name == "com.omarchy.theme" and
.path == $path and
(.allowed_origins | index("chrome-extension://ndpkabodcpddojgepdideonokpblpeln/"))
' "$native_manifest" >/dev/null || fail "theme native host manifest uses Omarchy host path and extension id"
pass "theme native host installer registers the stable extension id"
# Chromium ships in the base packages, so a first install marks every migration
# as already applied; the user install has to register the host itself.
grep -q 'omarchy-install-chromium-theme' "$ROOT/install/user/chromium.sh" ||
fail "user install runs the theme native messaging host setup"
fresh_home="$TMPDIR/fresh-install"
HOME="$fresh_home" OMARCHY_PATH="$ROOT" PATH="$ROOT/bin:$PATH" \
bash -euo pipefail -c 'source "$ROOT/install/user/chromium.sh"'
[[ -f $fresh_home/.config/chromium/NativeMessagingHosts/com.omarchy.theme.json ]] ||
fail "fresh install registers the theme native messaging host"
pass "fresh install registers the theme native messaging host"
# The host reads the active theme and frames it for the extension.
theme_home="$TMPDIR/theme-home"
current="$theme_home/.local/state/omarchy/current"
mkdir -p "$current/theme"
printf 'tokyo-night' >"$current/theme.name"
printf '[colors.primary]\nbackground = "#1a1b26"\n' >"$current/theme/alacritty.toml"
printf 'accent = "#7aa2f7"\n' >"$current/theme/colors.toml"
theme_json=$(HOME="$theme_home" XDG_RUNTIME_DIR="$TMPDIR/run" \
bash -c 'source "$1"; build_state' bash "$ROOT/bin/omarchy-chromium-theme-host")
jq -e '.theme_name == "tokyo-night" and .bg == "#1a1b26" and .accent == "#7aa2f7"' <<<"$theme_json" >/dev/null ||
fail "theme host reads the active Omarchy theme" "$theme_json"
pass "theme host reads the active Omarchy theme"
framed=$(XDG_RUNTIME_DIR="$TMPDIR/run" \
bash -c 'source "$1"; emit "hi"' bash "$ROOT/bin/omarchy-chromium-theme-host" |
od -An -v -tx1 | tr -d ' \n')
[[ $framed == "020000006869" ]] ||
fail "theme host frames messages with a little-endian length prefix" "$framed"
pass "theme host frames messages with a little-endian length prefix"
# omarchy-theme-set calls the refresh to SIGUSR1 every running host; stale
# pidfiles are pruned.
run_dir="$TMPDIR/run/omarchy-theme"
mkdir -p "$run_dir"
echo 999999 >"$run_dir/999999.pid"
# A host can be killed before its EXIT trap removes the pidfile. If Linux later
# reuses that PID, the refresh must not send SIGUSR1 to the unrelated process.
echo "$$ 0" >"$run_dir/reused.pid"
marker="$TMPDIR/partial-pid-signalled"
MARKER="$marker" bash -c 'trap "touch \"$MARKER\"" USR1; while :; do sleep 1; done' &
partial_pid=$!
echo "$partial_pid" >"$run_dir/partial.pid"
# Drive the real host rather than a stand-in sleeper: a synthetic process with
# its own USR1 trap would keep this green even if the host's trap, its watchdog
# wait, or its second framed write regressed. Hold stdin open through a FIFO so
# the watchdog does not see EOF and exit.
host_out="$TMPDIR/host.out"
host_in="$TMPDIR/host.in"
mkfifo "$host_in"
: >"$host_out"
HOME="$theme_home" XDG_RUNTIME_DIR="$TMPDIR/run" \
omarchy-chromium-theme-host <"$host_in" >"$host_out" 2>/dev/null &
host_pid=$!
# Keeping a writer attached is what holds the FIFO open.
sleep 300 >"$host_in" &
host_writer=$!
frame_count() {
node - "$host_out" <<'JS'
const fs = require('fs')
const buf = fs.readFileSync(process.argv[2])
let at = 0
let frames = 0
while (at + 4 <= buf.length) {
const len = buf.readUInt32LE(at)
if (at + 4 + len > buf.length) break
JSON.parse(buf.subarray(at + 4, at + 4 + len).toString('utf8'))
frames++
at += 4 + len
}
process.stdout.write(String(frames))
JS
}
# The host pushes once on connect, before it arms the watchdog.
for _ in $(seq 1 100); do [[ $(frame_count) -ge 1 ]] && break; sleep 0.05; done
[[ $(frame_count) -ge 1 ]] ||
fail "theme host pushes the current theme on connect" "$(od -An -tx1 "$host_out" | head -2)"
[[ -s $run_dir/$host_pid.pid ]] ||
fail "theme host writes its pidfile where the refresh looks" "$(ls "$run_dir")"
watchdog_pid=$(pgrep -P "$host_pid")
[[ -n $watchdog_pid && -z $(pgrep -P "$watchdog_pid") ]] ||
fail "theme host watchdog does not spawn a child that can be orphaned"
XDG_RUNTIME_DIR="$TMPDIR/run" omarchy-chromium-theme-refresh
for _ in $(seq 1 100); do [[ $(frame_count) -ge 2 ]] && break; sleep 0.05; done
frames=$(frame_count)
# Read this before killing the host, which cleans its own pidfile up on exit.
kept_pidfile=$([[ -s $run_dir/$host_pid.pid ]] && echo yes || echo no)
kill "$host_pid" "$host_writer" 2>/dev/null
wait "$host_pid" 2>/dev/null
kill "$partial_pid" 2>/dev/null
wait "$partial_pid" 2>/dev/null
(( frames >= 2 )) ||
fail "theme-set refresh makes the running host push again" "frames=$frames"
# The second frame has to be a usable theme, not just bytes on the pipe.
second=$(node - "$host_out" <<'JS'
const fs = require('fs')
const buf = fs.readFileSync(process.argv[2])
const out = []
let at = 0
while (at + 4 <= buf.length) {
const len = buf.readUInt32LE(at)
if (at + 4 + len > buf.length) break
out.push(JSON.parse(buf.subarray(at + 4, at + 4 + len).toString('utf8')))
at += 4 + len
}
process.stdout.write(JSON.stringify(out[1] || null))
JS
)
jq -e '.theme_name == "tokyo-night" and .bg == "#1a1b26"' <<<"$second" >/dev/null ||
fail "theme host re-pushes a usable theme on refresh" "$second"
[[ ! -f $run_dir/999999.pid ]] || fail "theme-set refresh prunes stale pidfiles"
[[ ! -f $run_dir/reused.pid ]] || fail "theme-set refresh prunes reused pidfiles"
[[ ! -f $run_dir/partial.pid && ! -f $marker ]] ||
fail "theme-set refresh rejects pidfiles without process start time"
# The refresh ran while the host was alive, so its own pidfile had to survive;
# the host removes it through its EXIT trap once we kill it above.
[[ $kept_pidfile == "yes" ]] || fail "theme-set refresh keeps live pidfiles" "$(ls "$run_dir")"
pass "theme-set refresh signals running hosts and prunes stale pidfiles"