From 8425ceb70932f40523ed72ff3755aa4c737f90a1 Mon Sep 17 00:00:00 2001 From: Tri Vo Khac Date: Fri, 6 Nov 2026 11:20:04 +0700 Subject: [PATCH] feat(tray-icons): shrink and recolor the NetBird tray icons NetBird sends its tray icon as a pixmap, so an icon theme can't replace it. patch.py rewrites the 48x48 PNGs embedded in netbird-ui in place: the glyph is scaled to 80% to get Breeze's padding and the white variants take the panel text color (#dedede). A pacman hook re-patches the binary after every netbird-ui upgrade, and restore.sh puts the original back. --- kde/README.md | 6 +- .../share/netbird-breeze-icons/apply.sh | 16 ++ .../share/netbird-breeze-icons/install.sh | 15 ++ .../netbird-breeze-icons.hook | 11 ++ .../share/netbird-breeze-icons/patch.py | 183 ++++++++++++++++++ .../share/netbird-breeze-icons/restore.sh | 12 ++ kde/tray-icons/README.md | 23 +++ 7 files changed, 263 insertions(+), 3 deletions(-) create mode 100755 kde/tray-icons/.local/share/netbird-breeze-icons/apply.sh create mode 100755 kde/tray-icons/.local/share/netbird-breeze-icons/install.sh create mode 100644 kde/tray-icons/.local/share/netbird-breeze-icons/netbird-breeze-icons.hook create mode 100755 kde/tray-icons/.local/share/netbird-breeze-icons/patch.py create mode 100755 kde/tray-icons/.local/share/netbird-breeze-icons/restore.sh diff --git a/kde/README.md b/kde/README.md index e980b1b..6bb45f6 100644 --- a/kde/README.md +++ b/kde/README.md @@ -17,7 +17,7 @@ stow -d kde -t "$HOME" spectacle helium | [theme](theme/README.md) | Darwin Breeze KDE colors and generated app themes | | [helium](helium/README.md) | Browser flags; colors come from GTK | | [spectacle](spectacle/README.md) | Screenshots to clipboard with a notification, no preview | -| [tray-icons](tray-icons/README.md) | Ngó Sen and qBittorrent tray icons | +| [tray-icons](tray-icons/README.md) | Ngó Sen, NetBird and qBittorrent tray icons | Theme and Spectacle need an additional apply step after stowing their package: @@ -28,8 +28,8 @@ Theme and Spectacle need an additional apply step after stowing their package: ./kde/spectacle/apply.py ``` -The apply scripts require a running KDE Plasma 6 session. Ngó Sen icons need -an additional install step described in the tray-icons README. +The apply scripts require a running KDE Plasma 6 session. Ngó Sen and NetBird +icons need an additional install step described in the tray-icons README. Packages can be selected independently; Helium follows the Darwin Breeze colors only when the theme is applied. diff --git a/kde/tray-icons/.local/share/netbird-breeze-icons/apply.sh b/kde/tray-icons/.local/share/netbird-breeze-icons/apply.sh new file mode 100755 index 0000000..c6a4e6f --- /dev/null +++ b/kde/tray-icons/.local/share/netbird-breeze-icons/apply.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Patch netbird-ui's tray icons to Breeze proportions. +# Runs as root; the pacman hook re-runs it after every netbird-ui install/upgrade. +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +ROOT=${ROOT:-} # for dry-run testing only +bin=$ROOT/usr/bin/netbird-ui +[[ -f $bin ]] || exit 0 + +# Still the file the package installed: keep it as the original for restore.sh +sum=$(zcat "$ROOT"/var/lib/pacman/local/netbird-ui*/mtree 2>/dev/null \ + | grep -F './usr/bin/netbird-ui ' | grep -o 'sha256digest=[0-9a-f]*' | cut -d= -f2 || true) +if [[ -n $sum && $(sha256sum <"$bin" | cut -d' ' -f1) == "$sum" ]]; then + install -Dm755 "$bin" "$here/orig/netbird-ui" +fi +python3 -I "$here/patch.py" "$bin" diff --git a/kde/tray-icons/.local/share/netbird-breeze-icons/install.sh b/kde/tray-icons/.local/share/netbird-breeze-icons/install.sh new file mode 100755 index 0000000..f7fd328 --- /dev/null +++ b/kde/tray-icons/.local/share/netbird-breeze-icons/install.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Install the netbird-ui tray icon patch and its pacman hook. Usage: sudo ./install.sh +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +ROOT=${ROOT:-} # for dry-run testing only +[[ $EUID -eq 0 || -n $ROOT ]] || { echo "Must be run with sudo"; exit 1; } +dest=$ROOT/usr/local/share/netbird-breeze-icons + +install -Dm755 "$here/patch.py" "$dest/patch.py" +install -m755 "$here/apply.sh" "$dest/apply.sh" +install -Dm644 "$here/netbird-breeze-icons.hook" "$ROOT/etc/pacman.d/hooks/netbird-breeze-icons.hook" +ROOT=$ROOT "$dest/apply.sh" + +echo "Done. Restart the NetBird UI (without sudo):" +echo " pkill -x netbird-ui; setsid -f netbird-ui --daemon-addr unix:///var/run/netbird/main.sock >/dev/null 2>&1" diff --git a/kde/tray-icons/.local/share/netbird-breeze-icons/netbird-breeze-icons.hook b/kde/tray-icons/.local/share/netbird-breeze-icons/netbird-breeze-icons.hook new file mode 100644 index 0000000..e836523 --- /dev/null +++ b/kde/tray-icons/.local/share/netbird-breeze-icons/netbird-breeze-icons.hook @@ -0,0 +1,11 @@ +[Trigger] +Operation = Install +Operation = Upgrade +Type = Package +Target = netbird-ui +Target = netbird-ui-bin + +[Action] +Description = Patching NetBird tray icons to match Breeze... +When = PostTransaction +Exec = /usr/local/share/netbird-breeze-icons/apply.sh diff --git a/kde/tray-icons/.local/share/netbird-breeze-icons/patch.py b/kde/tray-icons/.local/share/netbird-breeze-icons/patch.py new file mode 100755 index 0000000..c58261a --- /dev/null +++ b/kde/tray-icons/.local/share/netbird-breeze-icons/patch.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Shrink and recolor the tray icons embedded in the netbird-ui binary. + +NetBird sends its tray icon as a pixmap, so an icon theme can't replace it. +This rewrites the 48x48 monochrome PNGs inside the binary in place: the +glyph is scaled to SCALE so it gets the padding of a Breeze status icon, +and the white variants take the panel's text color. Each new PNG must fit +in the bytes of the old one; the rest is zero-filled, which Go's PNG +decoder never reads because it stops at IEND. + +Usage: patch.py BINARY [--check] +""" + +import argparse +import os +from pathlib import Path +import struct +import sys +import tempfile +import zlib + +SIZE = 48 +SCALE = 0.8 +# DarwinBreezeDark ForegroundNormal, as the other tray icons are drawn +LIGHT = (222, 222, 222) +# A glyph narrower than this already has padding (already patched) +MAX_PADDED_WIDTH = round(SIZE * 0.9) +SIG = b"\x89PNG\r\n\x1a\n" + +Pixels = list[list[float]] # rows of premultiplied RGBA floats + + +def decode(png: bytes) -> tuple[int, int, bytes] | None: + """Return (width, height, RGBA rows) for an 8-bit RGBA PNG, else None.""" + pos, idat, ihdr = 8, b"", b"" + while pos < len(png): + (length,) = struct.unpack(">I", png[pos:pos + 4]) + kind, body = png[pos + 4:pos + 8], png[pos + 8:pos + 8 + length] + if kind == b"IHDR": + ihdr = body + elif kind == b"IDAT": + idat += body + elif kind == b"IEND": + break + pos += 12 + length + w, h, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", ihdr) + if (depth, color, interlace) != (8, 6, 0): + return None + raw, stride, out = zlib.decompress(idat), w * 4, bytearray() + prev = bytearray(stride) + for y in range(h): + ftype = raw[y * (stride + 1)] + line = bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)]) + for i in range(stride): + a = line[i - 4] if i >= 4 else 0 + b = prev[i] + c = prev[i - 4] if i >= 4 else 0 + if ftype == 1: + line[i] = (line[i] + a) & 0xFF + elif ftype == 2: + line[i] = (line[i] + b) & 0xFF + elif ftype == 3: + line[i] = (line[i] + (a + b) // 2) & 0xFF + elif ftype == 4: + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + pred = a if pa <= pb and pa <= pc else b if pb <= pc else c + line[i] = (line[i] + pred) & 0xFF + out += line + prev = line + return w, h, bytes(out) + + +def encode(w: int, h: int, rgba: bytes) -> bytes: + def chunk(kind: bytes, body: bytes) -> bytes: + crc = zlib.crc32(kind + body) + return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", crc) + + raw = b"".join(b"\0" + rgba[y * w * 4:(y + 1) * w * 4] for y in range(h)) + return (SIG + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")) + + +def glyph_width(rgba: bytes) -> int: + xs = [i // 4 % SIZE for i in range(3, len(rgba), 4) if rgba[i] > 16] + return max(xs) - min(xs) + 1 if xs else 0 + + +def is_light(rgba: bytes) -> bool | None: + """True for a white glyph, False for black, None if not monochrome.""" + lum = [rgba[i - 3] for i in range(3, len(rgba), 4) if rgba[i] > 200] + if not lum or any(abs(rgba[i - 3] - rgba[i - 2]) > 8 for i in range(3, len(rgba), 4) + if rgba[i] > 200): + return None + if all(v > 230 for v in lum): + return True + if all(v < 25 for v in lum): + return False + return None + + +def shrink(rgba: bytes, light: bool) -> bytes: + """Box-filter the image to SCALE, centered on a SIZE canvas.""" + src: Pixels = [] + for y in range(SIZE): + row: list[float] = [] + for x in range(SIZE): + r, g, b, a = rgba[(y * SIZE + x) * 4:(y * SIZE + x) * 4 + 4] + row += [r * a, g * a, b * a, a] + src.append(row) + inner = SIZE * SCALE + off = (SIZE - inner) / 2 + step = 1 / SCALE + out = bytearray(SIZE * SIZE * 4) + + def spans(o: int) -> list[tuple[int, float]]: + lo = (o - off) * step + hi = lo + step + res = [] + for s in range(max(0, int(lo)), min(SIZE, int(hi) + 1)): + cover = min(hi, s + 1) - max(lo, s) + if cover > 0: + res.append((s, cover)) + return res + + for oy in range(SIZE): + ys = spans(oy) + for ox in range(SIZE): + acc = [0.0] * 4 + for sy, wy in ys: + for sx, wx in spans(ox): + k = wy * wx + for c in range(4): + acc[c] += src[sy][sx * 4 + c] * k + area = step * step + a = acc[3] / area + if a < 0.5: + continue + color = LIGHT if light else tuple(round(acc[c] / acc[3]) for c in range(3)) + out[(oy * SIZE + ox) * 4:(oy * SIZE + ox) * 4 + 4] = bytes( + [*color, min(255, round(a))]) + return bytes(out) + + +def patch(data: bytearray) -> int: + count, pos = 0, 0 + while (pos := data.find(SIG, pos)) >= 0: + end = data.find(b"IEND", pos) + 8 + old = bytes(data[pos:end]) + img = decode(old) if old[16:24] == struct.pack(">II", SIZE, SIZE) else None + if img is not None: + light = is_light(img[2]) + if light is not None and glyph_width(img[2]) > MAX_PADDED_WIDTH: + new = encode(SIZE, SIZE, shrink(img[2], light)) + if len(new) > len(old): + sys.exit(f"Patched icon at {pos} is larger than the original") + data[pos:end] = new + bytes(len(old) - len(new)) + count += 1 + pos = end + return count + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("binary", type=Path) + parser.add_argument("--check", action="store_true", + help="only print how many icons would be patched") + args = parser.parse_args() + data = bytearray(args.binary.read_bytes()) + count = patch(data) + print(f"{args.binary}: {count} tray icons {'to patch' if args.check else 'patched'}") + if args.check or not count: + return + mode = args.binary.stat().st_mode + fd, tmp = tempfile.mkstemp(dir=args.binary.parent, prefix=".netbird-ui.") + with os.fdopen(fd, "wb") as f: + f.write(data) + os.chmod(tmp, mode & 0o7777) + os.replace(tmp, args.binary) + + +if __name__ == "__main__": + main() diff --git a/kde/tray-icons/.local/share/netbird-breeze-icons/restore.sh b/kde/tray-icons/.local/share/netbird-breeze-icons/restore.sh new file mode 100755 index 0000000..c399fe2 --- /dev/null +++ b/kde/tray-icons/.local/share/netbird-breeze-icons/restore.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Put the original netbird-ui back and remove the hook. Usage: sudo ./restore.sh +set -euo pipefail +ROOT=${ROOT:-} # for dry-run testing only +[[ $EUID -eq 0 || -n $ROOT ]] || { echo "Must be run with sudo"; exit 1; } +dest=$ROOT/usr/local/share/netbird-breeze-icons +[[ -f $dest/orig/netbird-ui ]] || { echo "No original binary in $dest/orig"; exit 1; } +install -m755 "$dest/orig/netbird-ui" "$ROOT/usr/bin/netbird-ui" +rm -f "$ROOT/etc/pacman.d/hooks/netbird-breeze-icons.hook" +rm -rf "$dest" +echo "Restored. Restart the NetBird UI (without sudo):" +echo " pkill -x netbird-ui; setsid -f netbird-ui --daemon-addr unix:///var/run/netbird/main.sock >/dev/null 2>&1" diff --git a/kde/tray-icons/README.md b/kde/tray-icons/README.md index 14137e0..d4b33d7 100644 --- a/kde/tray-icons/README.md +++ b/kde/tray-icons/README.md @@ -31,6 +31,29 @@ systemctl --user restart plasma-plasmashell - On KDE Wayland don't start fcitx5 with `fcitx5 -d`; use `restart-fcitx5.sh` so KWin launches it. +## NetBird (netbird-ui) + +Scales NetBird's tray icons down to 80% so they get the padding of Breeze +status icons, and draws the white ones in the panel's text color +(`#dedede`) instead of pure white. + +```sh +sudo ~/.local/share/netbird-breeze-icons/install.sh +pkill -x netbird-ui; setsid -f netbird-ui --daemon-addr unix:///var/run/netbird/main.sock >/dev/null 2>&1 +``` + +- NetBird sends the tray icon as raw pixels, not an icon name, so an icon + theme can't replace it. `patch.py` rewrites the 48x48 PNGs embedded in + `/usr/bin/netbird-ui` in place instead; each new PNG fits in the bytes + of the old one. +- `install.sh` copies `patch.py` and `apply.sh` to + `/usr/local/share/netbird-breeze-icons/` and installs the pacman hook + `/etc/pacman.d/hooks/netbird-breeze-icons.hook` to re-patch the binary + after every `netbird-ui`/`netbird-ui-bin` upgrade. +- The original binary is kept in `/usr/local/share/netbird-breeze-icons/orig/`; + `sudo ~/.local/share/netbird-breeze-icons/restore.sh` puts it back and + removes the hook. + ## qBittorrent `qbittorrent-tray-mono.svg` is qBittorrent's monochrome tray icon scaled