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.
184 lines
6.4 KiB
Python
Executable File
184 lines
6.4 KiB
Python
Executable File
#!/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()
|