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