* Make WhatsApp Web follow your Omarchy light/dark theme WhatsApp Web's "System default" theme follows prefers-color-scheme and repaints live, so a small theme bridge is enough to make it track the active Omarchy theme with no reload and no WhatsApp-specific CSS. - omarchy-chromium-theme-host: push-only native messaging host that reads the active theme and emits it on connect and on every theme-set. Unlike copy-url/ yt-dlp (one-shot), it stays connected and pushes, since theme-following needs the page to learn about changes while it is running. - omarchy-chromium-theme-refresh: SIGUSR1s the running host(s); called from omarchy-theme-set's post_theme_commands. - whatsapp-theme extension: decides dark vs. light from the theme background's WCAG luminance and drives a prefers-color-scheme shim, so WhatsApp's own theme does the repaint. Wired like copy-url/yt-dlp and whatsapp-slim: bundled under default/chromium/extensions, added to --load-extension, host manifest registered from the fresh-install/refresh/browser-install paths, existing users covered by a migration. The host is named com.omarchy.theme (a generic theme bridge) rather than WhatsApp-specific, so other bundled web-app extensions can follow the theme by connecting to it and adding their id to the host manifest's allowed_origins. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address review on the WhatsApp theme bridge Light/dark was decided by weighting raw sRGB bytes, which the comment above it already described as WCAG relative luminance. sRGB is gamma-encoded, so the weights only mean anything once each channel is linearized — the two steps the shell already does in Panel.qml. Every shipped theme classifies the same either way; a mid-tone custom background does not (#808080 reads 0.502 unlinearized and 0.216 linearized). Drop the `tabs` permission. The WhatsApp host permission is what lets tabs.query filter by url and what populates tab urls in onUpdated, so `tabs` only widened this to every tab's url and title. Tabs without permission arrive with url unset and fall out on the existing guard. Give the two new bin commands their metadata directives. Without a summary they failed test/cli's command metadata check. Cover all three: the classifier over unambiguous and mid-tone backgrounds, and the manifest for the permission it should no longer ask for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Scope color-scheme listeners to their query and test the real host Registrations all shared one Set keyed only by callback, so an app that gave the same callback to both the dark and the light query and later detached one detached the other too, leaving the query it still held deaf to theme changes. Record the owning MediaQueryList and match on it. Adds native dedupe behaviour while there: registering the same callback twice fired it twice. addListener is a legacy alias of addEventListener("change"), so the two share one registration space and either remover cancels either add — which is also why useEvent had nothing left to select and is gone. The refresh test signalled a synthetic sleeper carrying its own USR1 trap, so it proved the refresh command sends a signal but would have stayed green through any regression in the host's own trap, watchdog wait, or second write. Drive the real host over a FIFO instead, count framed messages, and assert the second one is a usable theme. Verified by neutering the host's USR1 trap: the old test passed, this one fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Harden Chromium theme bridge * Address Chromium theme bridge review --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: David Heinemeier Hansson <david@hey.com>
225 lines
7.7 KiB
Bash
Executable File
225 lines
7.7 KiB
Bash
Executable File
#!/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
|