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.
This commit is contained in:
tri committed 2026-11-06 11:20:04 +07:00
1 parent fa77bc2514
commit 8425ceb709
7 files changed
+263 -3

No files matched your search

+16
View File
@@ -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"
@@ -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"
@@ -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
+183
View File
@@ -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()
@@ -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"