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:
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -19,7 +19,6 @@ copy_chromium_flags() {
|
|||||||
cp -f "$OMARCHY_PATH/config/chromium-flags.conf" "$1"
|
cp -f "$OMARCHY_PATH/config/chromium-flags.conf" "$1"
|
||||||
omarchy-install-chromium-copy-url
|
omarchy-install-chromium-copy-url
|
||||||
omarchy-install-chromium-ytdlp
|
omarchy-install-chromium-ytdlp
|
||||||
omarchy-install-chromium-theme
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_firefox_preferences() {
|
setup_firefox_preferences() {
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -18,7 +18,6 @@ omarchy-refresh-config chromium-flags.conf
|
|||||||
# Install/refresh the native messaging hosts used by the bundled extensions
|
# Install/refresh the native messaging hosts used by the bundled extensions
|
||||||
omarchy-install-chromium-copy-url
|
omarchy-install-chromium-copy-url
|
||||||
omarchy-install-chromium-ytdlp
|
omarchy-install-chromium-ytdlp
|
||||||
omarchy-install-chromium-theme
|
|
||||||
|
|
||||||
# Re-install Google accounts if previously configured
|
# Re-install Google accounts if previously configured
|
||||||
if [[ $INSTALL_GOOGLE_ACCOUNTS == "true" ]]; then
|
if [[ $INSTALL_GOOGLE_ACCOUNTS == "true" ]]; then
|
||||||
|
|||||||
@@ -199,7 +199,6 @@ post_theme_commands=(
|
|||||||
omarchy-theme-set-pi
|
omarchy-theme-set-pi
|
||||||
omarchy-theme-set-claude
|
omarchy-theme-set-claude
|
||||||
omarchy-theme-set-browser
|
omarchy-theme-set-browser
|
||||||
omarchy-chromium-theme-refresh
|
|
||||||
omarchy-theme-set-vscode
|
omarchy-theme-set-vscode
|
||||||
omarchy-theme-set-obsidian
|
omarchy-theme-set-obsidian
|
||||||
omarchy-theme-set-keyboard
|
omarchy-theme-set-keyboard
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
--ozone-platform-hint=wayland
|
--ozone-platform-hint=wayland
|
||||||
--password-store=gnome-libsecret
|
--password-store=gnome-libsecret
|
||||||
--enable-features=TouchpadOverscrollHistoryNavigation
|
--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/"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -4,4 +4,3 @@
|
|||||||
# messaging host to talk to.
|
# messaging host to talk to.
|
||||||
omarchy-install-chromium-copy-url
|
omarchy-install-chromium-copy-url
|
||||||
omarchy-install-chromium-ytdlp
|
omarchy-install-chromium-ytdlp
|
||||||
omarchy-install-chromium-theme
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
Reference in New Issue
Block a user