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
+3
-3
@@ -17,7 +17,7 @@ stow -d kde -t "$HOME" spectacle helium
|
|||||||
| [theme](theme/README.md) | Darwin Breeze KDE colors and generated app themes |
|
| [theme](theme/README.md) | Darwin Breeze KDE colors and generated app themes |
|
||||||
| [helium](helium/README.md) | Browser flags; colors come from GTK |
|
| [helium](helium/README.md) | Browser flags; colors come from GTK |
|
||||||
| [spectacle](spectacle/README.md) | Screenshots to clipboard with a notification, no preview |
|
| [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:
|
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
|
./kde/spectacle/apply.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The apply scripts require a running KDE Plasma 6 session. Ngó Sen icons need
|
The apply scripts require a running KDE Plasma 6 session. Ngó Sen and NetBird
|
||||||
an additional install step described in the tray-icons README.
|
icons need an additional install step described in the tray-icons README.
|
||||||
|
|
||||||
Packages can be selected independently; Helium follows the Darwin Breeze
|
Packages can be selected independently; Helium follows the Darwin Breeze
|
||||||
colors only when the theme is applied.
|
colors only when the theme is applied.
|
||||||
@@ -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
|
- On KDE Wayland don't start fcitx5 with `fcitx5 -d`; use
|
||||||
`restart-fcitx5.sh` so KWin launches it.
|
`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
|
||||||
|
|
||||||
`qbittorrent-tray-mono.svg` is qBittorrent's monochrome tray icon scaled
|
`qbittorrent-tray-mono.svg` is qBittorrent's monochrome tray icon scaled
|
||||||
|
|||||||
Reference in new issue
Block a user