* 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>
274 lines
11 KiB
Bash
274 lines
11 KiB
Bash
#!/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"
|