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"