From 237405215dbb6502db8bda2059be6dc753763c07 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Sat, 1 Aug 2026 14:16:06 -0400 Subject: [PATCH] Make WhatsApp Web follow your Omarchy light/dark theme (#6484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * Harden Chromium theme bridge * Address Chromium theme bridge review --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: David Heinemeier Hansson --- bin/omarchy-chromium-theme-host | 224 ++++++++++++++ bin/omarchy-chromium-theme-refresh | 34 +++ bin/omarchy-install-browser | 1 + bin/omarchy-install-chromium-theme | 28 ++ bin/omarchy-refresh-chromium | 1 + bin/omarchy-theme-set | 1 + config/chromium-flags.conf | 2 +- .../extensions/whatsapp-theme/background.js | 103 +++++++ .../extensions/whatsapp-theme/content.js | 60 ++++ .../extensions/whatsapp-theme/manifest.json | 30 ++ .../omarchy-prefers-color-scheme.js | 174 +++++++++++ .../com.omarchy.theme.json | 9 + install/user/chromium.sh | 1 + migrations/1785593125.sh | 24 ++ test/shell.d/chromium-theme-test.sh | 273 ++++++++++++++++++ 15 files changed, 964 insertions(+), 1 deletion(-) create mode 100755 bin/omarchy-chromium-theme-host create mode 100755 bin/omarchy-chromium-theme-refresh create mode 100755 bin/omarchy-install-chromium-theme create mode 100644 default/chromium/extensions/whatsapp-theme/background.js create mode 100644 default/chromium/extensions/whatsapp-theme/content.js create mode 100644 default/chromium/extensions/whatsapp-theme/manifest.json create mode 100644 default/chromium/extensions/whatsapp-theme/omarchy-prefers-color-scheme.js create mode 100644 default/chromium/native-messaging-hosts/com.omarchy.theme.json create mode 100644 migrations/1785593125.sh create mode 100644 test/shell.d/chromium-theme-test.sh diff --git a/bin/omarchy-chromium-theme-host b/bin/omarchy-chromium-theme-host new file mode 100755 index 00000000..80b6d82b --- /dev/null +++ b/bin/omarchy-chromium-theme-host @@ -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 diff --git a/bin/omarchy-chromium-theme-refresh b/bin/omarchy-chromium-theme-refresh new file mode 100755 index 00000000..1e1f9b65 --- /dev/null +++ b/bin/omarchy-chromium-theme-refresh @@ -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 diff --git a/bin/omarchy-install-browser b/bin/omarchy-install-browser index 90fc4f00..7cdff5f8 100755 --- a/bin/omarchy-install-browser +++ b/bin/omarchy-install-browser @@ -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() { diff --git a/bin/omarchy-install-chromium-theme b/bin/omarchy-install-chromium-theme new file mode 100755 index 00000000..747fee23 --- /dev/null +++ b/bin/omarchy-install-chromium-theme @@ -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 diff --git a/bin/omarchy-refresh-chromium b/bin/omarchy-refresh-chromium index 368373f4..ecce0b1c 100755 --- a/bin/omarchy-refresh-chromium +++ b/bin/omarchy-refresh-chromium @@ -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 diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index d987d97a..bd56aa01 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -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 diff --git a/config/chromium-flags.conf b/config/chromium-flags.conf index 1fe08716..4b7e4755 100644 --- a/config/chromium-flags.conf +++ b/config/chromium-flags.conf @@ -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 diff --git a/default/chromium/extensions/whatsapp-theme/background.js b/default/chromium/extensions/whatsapp-theme/background.js new file mode 100644 index 00000000..2be56614 --- /dev/null +++ b/default/chromium/extensions/whatsapp-theme/background.js @@ -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(); diff --git a/default/chromium/extensions/whatsapp-theme/content.js b/default/chromium/extensions/whatsapp-theme/content.js new file mode 100644 index 00000000..6b8e008d --- /dev/null +++ b/default/chromium/extensions/whatsapp-theme/content.js @@ -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); +}); diff --git a/default/chromium/extensions/whatsapp-theme/manifest.json b/default/chromium/extensions/whatsapp-theme/manifest.json new file mode 100644 index 00000000..1e046f46 --- /dev/null +++ b/default/chromium/extensions/whatsapp-theme/manifest.json @@ -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" + } +} diff --git a/default/chromium/extensions/whatsapp-theme/omarchy-prefers-color-scheme.js b/default/chromium/extensions/whatsapp-theme/omarchy-prefers-color-scheme.js new file mode 100644 index 00000000..3e1d0b49 --- /dev/null +++ b/default/chromium/extensions/whatsapp-theme/omarchy-prefers-color-scheme.js @@ -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 (_) {} + } + }); +})(); diff --git a/default/chromium/native-messaging-hosts/com.omarchy.theme.json b/default/chromium/native-messaging-hosts/com.omarchy.theme.json new file mode 100644 index 00000000..85918c69 --- /dev/null +++ b/default/chromium/native-messaging-hosts/com.omarchy.theme.json @@ -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/" + ] +} diff --git a/install/user/chromium.sh b/install/user/chromium.sh index a5b29adb..607fd0a7 100644 --- a/install/user/chromium.sh +++ b/install/user/chromium.sh @@ -4,3 +4,4 @@ # messaging host to talk to. omarchy-install-chromium-copy-url omarchy-install-chromium-ytdlp +omarchy-install-chromium-theme diff --git a/migrations/1785593125.sh b/migrations/1785593125.sh new file mode 100644 index 00000000..78ab5470 --- /dev/null +++ b/migrations/1785593125.sh @@ -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 diff --git a/test/shell.d/chromium-theme-test.sh b/test/shell.d/chromium-theme-test.sh new file mode 100644 index 00000000..8385dfb3 --- /dev/null +++ b/test/shell.d/chromium-theme-test.sh @@ -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"