Compare commits

...
Author SHA1 Message Date
netcatty-bot 90e7132ac6 fix: address Codex review on PR #3551 2026-09-29 08:17:00 +00:00
netcatty-bot 50043a2995 fix(#3549): automated AI fix 2026-09-29 07:14:43 +00:00
6 changed files with 294 additions and 11 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 692 B

After

Width:  |  Height:  |  Size: 545 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+65 -11
View File
@@ -1,29 +1,83 @@
#!/usr/bin/env python3
"""Generate public/tray-icon.ico from public/icon-win.png with multiple
sizes so Windows can pick the right pixel dimensions per DPI scale.
"""Generate the Windows/Linux tray assets from the flat cat glyph.
Sources are the macOS template masks (public/tray-iconTemplate*.png):
a single-color cat glyph that fills its canvas with transparent corners.
The old assets were the rounded-square app icon (navy chip with a small cat
inside), which rendered as a tiny, low-contrast blob in the notification area
on dark taskbars. Tray apps are expected to ship a full-bleed single-color
glyph instead, so we recolor the template mask with the brand sky blue
(#0ea5e9 - the same color as the "bright" app-icon variant) which stays
readable on both dark and light taskbars.
Outputs (all in public/):
tray-icon.png 22x22 colored glyph (Linux base)
tray-icon@2x.png 44x44 colored glyph (Linux HiDPI representation)
tray-icon.ico 16/20/24/32/40/48/64 (Windows picks one per DPI)
Sizes mirror what Explorer requests for the notification area on typical
DPI scale factors (100/125/150/175/200/250/300/400 %):
16, 20, 24, 32, 40, 48, 64
DPI scale factors (100/125/150/175/200/250/300/400 %).
Run: python3 scripts/generate-tray-ico.py
Requires: Pillow (pip install Pillow)
"""
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "public" / "icon-win.png"
OUT = ROOT / "public" / "tray-icon.ico"
TEMPLATE = ROOT / "public" / "tray-iconTemplate.png"
TEMPLATE_2X = ROOT / "public" / "tray-iconTemplate@2x.png"
OUT_PNG = ROOT / "public" / "tray-icon.png"
OUT_PNG_2X = ROOT / "public" / "tray-icon@2x.png"
OUT_ICO = ROOT / "public" / "tray-icon.ico"
SIZES = [(16, 16), (20, 20), (24, 24), (32, 32), (40, 40), (48, 48), (64, 64)]
# Brand sky blue, sampled from public/icons/variants/bright.png.
BRAND_RGB = (14, 165, 233)
def recolor_template(template_path: Path, out_path: Path) -> None:
"""Turn the black template glyph into a flat brand-colored glyph."""
if not template_path.exists():
raise SystemExit(f"template not found: {template_path}")
glyph = Image.open(template_path).convert("RGBA")
pixels = glyph.load()
width, height = glyph.size
for y in range(height):
for x in range(width):
r, g, b, a = pixels[x, y]
if r == 0 and g == 0 and b == 0:
pixels[x, y] = (*BRAND_RGB, a)
elif a != 0:
# Antialiased leftovers: keep the stored luminance so soft
# edges stay soft instead of snapping to full saturation.
gray = (r + g + b) // 3
pixels[x, y] = (
(BRAND_RGB[0] * gray) // 255,
(BRAND_RGB[1] * gray) // 255,
(BRAND_RGB[2] * gray) // 255,
a,
)
glyph.save(out_path, format="PNG")
print(f"wrote {out_path.relative_to(ROOT)} ({glyph.size[0]}x{glyph.size[1]})")
def main() -> None:
if not SOURCE.exists():
raise SystemExit(f"source icon not found: {SOURCE}")
src = Image.open(SOURCE).convert("RGBA")
src.save(OUT, format="ICO", sizes=SIZES)
print(f"wrote {OUT.relative_to(ROOT)} ({', '.join(f'{w}x{h}' for w, h in SIZES)})")
recolor_template(TEMPLATE, OUT_PNG)
recolor_template(TEMPLATE_2X, OUT_PNG_2X)
source = Image.open(OUT_PNG_2X).convert("RGBA")
# The 44px glyph can only produce entries <= 44px; Pillow drops anything
# larger. Upscale to the biggest request first so the high-DPI entries
# for 300/400 % scaling (48/64) are real resamples instead of silently
# missing from the .ico directory.
largest = max(SIZES)[0]
if largest > source.size[0]:
source = source.resize((largest, largest), Image.LANCZOS)
source.save(OUT_ICO, format="ICO", sizes=SIZES)
print(f"wrote {OUT_ICO.relative_to(ROOT)} ({', '.join(f'{w}x{h}' for w, h in SIZES)})")
if __name__ == "__main__":
+229
View File
@@ -0,0 +1,229 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const projectRoot = path.join(__dirname, "..");
// Brand sky blue sampled from public/icons/variants/bright.png - the same
// color the tray glyphs are recolored with by scripts/generate-tray-ico.py.
const BRAND_RGB = [14, 165, 233];
function readRgbaPng(png, label) {
assert.equal(png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a", `${label} is a PNG`);
let offset = 8;
let width = 0;
let height = 0;
const idat = [];
while (offset < png.length) {
const length = png.readUInt32BE(offset);
const type = png.subarray(offset + 4, offset + 8).toString("ascii");
const data = png.subarray(offset + 8, offset + 8 + length);
offset += length + 12;
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
// 8-bit, color type 6 (RGBA), non-interlaced
assert.deepEqual(
[...data.subarray(8, 13)],
[8, 6, 0, 0, 0],
`${label} must be an 8-bit RGBA PNG`,
);
} else if (type === "IDAT") {
idat.push(data);
} else if (type === "IEND") {
break;
}
}
return { width, height, idat };
}
function paethPredictor(left, up, upperLeft) {
const estimate = left + up - upperLeft;
const leftDistance = Math.abs(estimate - left);
const upDistance = Math.abs(estimate - up);
const upperLeftDistance = Math.abs(estimate - upperLeft);
if (leftDistance <= upDistance && leftDistance <= upperLeftDistance) return left;
if (upDistance <= upperLeftDistance) return up;
return upperLeft;
}
// Inflate and unfilter full RGBA pixel rows so the color/alpha assertions can
// work on real pixel values (Pillow writes filters 0-4).
function readPngPixels(png, label) {
const { width, height, idat } = readRgbaPng(png, label);
const zlib = require("node:zlib");
const stride = width * 4;
const raw = zlib.inflateSync(Buffer.concat(idat));
assert.equal(raw.length, (stride + 1) * height, `${label} has unexpected PNG data`);
const pixels = [];
let sourceOffset = 0;
let previous = Buffer.alloc(stride);
for (let y = 0; y < height; y += 1) {
const filter = raw[sourceOffset];
sourceOffset += 1;
const current = Buffer.from(raw.subarray(sourceOffset, sourceOffset + stride));
sourceOffset += stride;
for (let index = 0; index < stride; index += 1) {
const left = index >= 4 ? current[index - 4] : 0;
const up = previous[index];
const upperLeft = index >= 4 ? previous[index - 4] : 0;
let predictor;
if (filter === 0) predictor = 0;
else if (filter === 1) predictor = left;
else if (filter === 2) predictor = up;
else if (filter === 3) predictor = Math.floor((left + up) / 2);
else if (filter === 4) predictor = paethPredictor(left, up, upperLeft);
else assert.fail(`${label} uses unsupported PNG filter ${filter}`);
current[index] = (current[index] + predictor) & 0xff;
}
pixels.push(current);
previous = current;
}
return { width, height, pixels };
}
function assertBrandGlyph(png, expectedSize, label, { colorTolerance = 3, marginFraction = 0.15 } = {}) {
const image = readPngPixels(png, label);
assert.equal(image.width, expectedSize, `${label} width`);
assert.equal(image.height, expectedSize, `${label} height`);
// Corners must be transparent: the tray asset is a full-bleed glyph, not
// the rounded-square app chip that looked small and dark in the tray.
for (const [x, y] of [[0, 0], [image.width - 1, 0], [0, image.height - 1], [image.width - 1, image.height - 1]]) {
assert.equal(
image.pixels[y][x * 4 + 3],
0,
`${label} corner (${x},${y}) must be transparent`,
);
}
// Glyph must span (nearly) the full canvas so it does not look shrunk next
// to other tray apps. Allow a small proportional margin because the 44px
// HiDPI mask is rasterized from SVG with slightly different rounding.
const maxMargin = Math.ceil(expectedSize * marginFraction);
let minX = image.width;
let minY = image.height;
let maxX = -1;
let maxY = -1;
let opaque = 0;
let offColor = 0;
for (let y = 0; y < image.height; y += 1) {
const row = image.pixels[y];
for (let x = 0; x < image.width; x += 1) {
const a = row[x * 4 + 3];
if (a === 0) continue;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
if (a < 128) continue;
opaque += 1;
const r = row[x * 4];
const g = row[x * 4 + 1];
const b = row[x * 4 + 2];
if (
Math.abs(r - BRAND_RGB[0]) > colorTolerance
|| Math.abs(g - BRAND_RGB[1]) > colorTolerance
|| Math.abs(b - BRAND_RGB[2]) > colorTolerance
) {
offColor += 1;
}
}
}
assert.ok(
minX <= maxMargin
&& minY <= maxMargin
&& maxX >= image.width - 1 - maxMargin
&& maxY >= image.height - 1 - maxMargin,
`${label} glyph must fill the canvas (alpha bounds ${minX},${minY}..${maxX},${maxY})`,
);
assert.ok(
offColor === 0,
`${label} opaque pixels must be the brand sky blue (off-color pixels: ${offColor})`,
);
assert.ok(opaque > 0, `${label} must have visible pixels`);
}
function readIcoEntries(file, label) {
const ico = fs.readFileSync(file);
assert.equal(ico.readUInt16LE(0), 0, `${label} reserved field`);
assert.equal(ico.readUInt16LE(2), 1, `${label} must be an icon resource`);
const count = ico.readUInt16LE(4);
const entries = [];
for (let i = 0; i < count; i += 1) {
const entry = ico.subarray(6 + i * 16, 6 + (i + 1) * 16);
const pixelWidth = entry[0] === 0 ? 256 : entry[0];
const pixelHeight = entry[1] === 0 ? 256 : entry[1];
const bytesInRes = entry.readUInt32LE(8);
const imageOffset = entry.readUInt32LE(12);
entries.push({ pixelWidth, pixelHeight, bytesInRes, imageOffset });
}
return entries;
}
test("Linux tray icon is a full-bleed brand-colored glyph", () => {
assertBrandGlyph(
fs.readFileSync(path.join(projectRoot, "public/tray-icon.png")),
22,
"tray-icon.png",
);
});
test("Linux tray HiDPI representation matches the base glyph", () => {
assertBrandGlyph(
fs.readFileSync(path.join(projectRoot, "public/tray-icon@2x.png")),
44,
"tray-icon@2x.png",
);
});
test("Windows tray .ico carries one glyph entry per notification-area DPI size", () => {
const file = path.join(projectRoot, "public/tray-icon.ico");
const expectedSizes = [16, 20, 24, 32, 40, 48, 64];
const entries = readIcoEntries(file, file);
assert.deepEqual(
entries.map((entry) => entry.pixelWidth),
expectedSizes,
"the .ico must carry 16/20/24/32/40/48/64 so the shell picks the right size per DPI scale",
);
const decoded = entries.map((entry) =>
readRgbaPng(
fs.readFileSync(file).subarray(entry.imageOffset, entry.imageOffset + entry.bytesInRes),
`tray-icon.ico:${entry.pixelWidth}x${entry.pixelHeight}`,
),
);
for (let i = 0; i < entries.length; i += 1) {
assert.equal(decoded[i].width, entries[i].pixelWidth, `${file} entry ${entries[i].pixelWidth} width`);
assert.equal(decoded[i].height, entries[i].pixelHeight, `${file} entry ${entries[i].pixelWidth} height`);
}
// The smallest entry is what renders at 100 % scaling; make sure it is a
// brand-colored glyph, not the old navy chip. Allow a slightly larger
// color tolerance because the entry is a resample of the 44px glyph and
// resampling overshoots slightly at glyph edges.
const smallest = fs.readFileSync(file).subarray(
entries[0].imageOffset,
entries[0].imageOffset + entries[0].bytesInRes,
);
assertBrandGlyph(smallest, 16, "tray-icon.ico 16px entry", { colorTolerance: 25 });
});
test("macOS template masks stay single-color so the system can tint them", () => {
for (const file of ["public/tray-iconTemplate.png", "public/tray-iconTemplate@2x.png"]) {
const png = path.join(projectRoot, file);
const { width, height, pixels } = readPngPixels(fs.readFileSync(png), file);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (pixels[y][x * 4 + 3] === 0) continue;
assert.equal(pixels[y][x * 4], 0, `${file} red channel must stay black`);
assert.equal(pixels[y][x * 4 + 1], 0, `${file} green channel must stay black`);
assert.equal(pixels[y][x * 4 + 2], 0, `${file} blue channel must stay black`);
}
}
assert.ok(width > 0 && height > 0);
}
});