Revert WhatsApp theme bridge

The native messaging host, extension shim, and lifecycle machinery are too complicated for the value this integration provides.
This commit is contained in:
David Heinemeier Hansson
2026-08-01 12:44:40 -07:00
parent 237405215d
commit 66427571bc
15 changed files with 1 additions and 964 deletions
-224
View File
@@ -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
-34
View File
@@ -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
-1
View File
@@ -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() {
-28
View File
@@ -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
-1
View File
@@ -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
-1
View File
@@ -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
+1 -1
View File
@@ -2,4 +2,4 @@
--ozone-platform-hint=wayland
--password-store=gnome-libsecret
--enable-features=TouchpadOverscrollHistoryNavigation
--load-extension=/usr/share/omarchy/default/chromium/extensions/copy-url,/usr/share/omarchy/default/chromium/extensions/yt-dlp,/usr/share/omarchy/default/chromium/extensions/whatsapp-slim,/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
@@ -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();
@@ -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);
});
@@ -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"
}
}
@@ -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 (_) {}
}
});
})();
@@ -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/"
]
}
-1
View File
@@ -4,4 +4,3 @@
# messaging host to talk to.
omarchy-install-chromium-copy-url
omarchy-install-chromium-ytdlp
omarchy-install-chromium-theme
-24
View File
@@ -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
-273
View File
@@ -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"