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:
1 parent
fa77bc2514
commit
8425ceb709
7 files changed
+263
-3
No files matched your search
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user