Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
207 lines
7.2 KiB
JavaScript
207 lines
7.2 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const zlib = require("node:zlib");
|
|
const { VALID_VARIANTS } = require("../electron/bridges/appIconManager.cjs");
|
|
|
|
const APP_ICON_VARIANTS = [...VALID_VARIANTS];
|
|
|
|
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;
|
|
}
|
|
|
|
function readRgbaPng(png, label) {
|
|
assert.equal(png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
|
|
|
|
let offset = 8;
|
|
let width;
|
|
let height;
|
|
const imageData = [];
|
|
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);
|
|
assert.deepEqual(
|
|
[...data.subarray(8, 13)],
|
|
[8, 6, 0, 0, 0],
|
|
`${label} must be an 8-bit, non-interlaced RGBA PNG`,
|
|
);
|
|
} else if (type === "IDAT") {
|
|
imageData.push(data);
|
|
} else if (type === "IEND") {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const bytesPerPixel = 4;
|
|
const stride = width * bytesPerPixel;
|
|
const raw = zlib.inflateSync(Buffer.concat(imageData));
|
|
assert.equal(raw.length, (stride + 1) * height, `${label} has unexpected PNG data`);
|
|
|
|
let sourceOffset = 0;
|
|
let previous = Buffer.alloc(stride);
|
|
let minX = width;
|
|
let minY = height;
|
|
let maxX = -1;
|
|
let maxY = -1;
|
|
const rows = [];
|
|
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 >= bytesPerPixel ? current[index - bytesPerPixel] : 0;
|
|
const up = previous[index];
|
|
const upperLeft = index >= bytesPerPixel ? previous[index - bytesPerPixel] : 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;
|
|
}
|
|
|
|
for (let x = 0; x < width; x += 1) {
|
|
if (current[x * bytesPerPixel + 3] <= 8) continue;
|
|
minX = Math.min(minX, x);
|
|
minY = Math.min(minY, y);
|
|
maxX = Math.max(maxX, x);
|
|
maxY = Math.max(maxY, y);
|
|
}
|
|
rows.push(current);
|
|
previous = current;
|
|
}
|
|
|
|
return { width, height, minX, minY, maxX, maxY, rows };
|
|
}
|
|
|
|
function readRgbaPngAlphaBounds(file) {
|
|
const image = readRgbaPng(fs.readFileSync(file), file);
|
|
assert.equal(image.width, 1024, `${file} must keep the 1024px app-icon canvas`);
|
|
assert.equal(image.height, 1024, `${file} must keep the 1024px app-icon canvas`);
|
|
return {
|
|
minX: image.minX,
|
|
minY: image.minY,
|
|
maxX: image.maxX,
|
|
maxY: image.maxY,
|
|
};
|
|
}
|
|
|
|
function readIcnsEntry(file, expectedType) {
|
|
const icns = fs.readFileSync(file);
|
|
assert.equal(icns.subarray(0, 4).toString("ascii"), "icns", `${file} must be ICNS`);
|
|
assert.equal(icns.readUInt32BE(4), icns.length, `${file} has an invalid ICNS length`);
|
|
|
|
for (let offset = 8; offset < icns.length;) {
|
|
const type = icns.subarray(offset, offset + 4).toString("ascii");
|
|
const length = icns.readUInt32BE(offset + 4);
|
|
assert.ok(length >= 8 && offset + length <= icns.length, `${file} has an invalid ${type} entry`);
|
|
if (type === expectedType) return icns.subarray(offset + 8, offset + length);
|
|
offset += length;
|
|
}
|
|
assert.fail(`${file} is missing the ${expectedType} representation`);
|
|
}
|
|
|
|
function assertSmallIcnsRepresentation(file, type, expectedPixels) {
|
|
const image = readRgbaPng(readIcnsEntry(file, type), `${file}:${type}`);
|
|
assert.equal(image.width, expectedPixels);
|
|
assert.equal(image.height, expectedPixels);
|
|
|
|
let unexpectedGreenPixels = 0;
|
|
for (const row of image.rows) {
|
|
for (let x = 0; x < image.width; x += 1) {
|
|
const offset = x * 4;
|
|
const [red, green, blue, alpha] = row.subarray(offset, offset + 4);
|
|
if (alpha > 8 && green > red + 20 && green > blue + 20) {
|
|
unexpectedGreenPixels += 1;
|
|
}
|
|
}
|
|
}
|
|
assert.equal(
|
|
unexpectedGreenPixels,
|
|
0,
|
|
`${file}:${type} contains green noise instead of the navy/white app artwork`,
|
|
);
|
|
}
|
|
|
|
test("main process leaves macOS Dock icon to the packaged app bundle", () => {
|
|
const mainProcess = fs.readFileSync(
|
|
path.join(__dirname, "../electron/main.cjs"),
|
|
"utf8",
|
|
);
|
|
|
|
assert.equal(
|
|
mainProcess.includes("app.dock.setIcon"),
|
|
false,
|
|
"Do not override the macOS Dock icon at runtime; it can render at a different size than the bundled .icns icon.",
|
|
);
|
|
});
|
|
|
|
test("macOS packages a native ICNS and sizes runtime Dock icons separately", () => {
|
|
const projectRoot = path.join(__dirname, "..");
|
|
const config = require("../electron-builder.config.cjs");
|
|
assert.equal(config.mac?.icon ?? config.icon, "build/icon.icns");
|
|
const packagedIcon = path.join(projectRoot, "build/icon.icns");
|
|
assert.ok(readIcnsEntry(packagedIcon, "ic04").length > 0);
|
|
assert.ok(readIcnsEntry(packagedIcon, "ic05").length > 0);
|
|
assertSmallIcnsRepresentation(packagedIcon, "ic11", 32);
|
|
assertSmallIcnsRepresentation(packagedIcon, "ic12", 64);
|
|
const generator = fs.readFileSync(
|
|
path.join(projectRoot, "scripts/generate-mac-icon.sh"),
|
|
"utf8",
|
|
);
|
|
assert.match(generator, /SMALL_VECTOR/);
|
|
assert.match(generator, /x="104\\\.0" y="104\\\.0"/);
|
|
assert.deepEqual(
|
|
readRgbaPngAlphaBounds(path.join(projectRoot, "public/icon.png")),
|
|
{ minX: 61, minY: 61, maxX: 962, maxY: 962 },
|
|
"The packaged icon already looks correct when Netcatty is not running",
|
|
);
|
|
|
|
for (const variant of APP_ICON_VARIANTS) {
|
|
const iconFile = path.join(
|
|
projectRoot,
|
|
"public/icons/variants/macos",
|
|
`${variant}.png`,
|
|
);
|
|
assert.deepEqual(
|
|
readRgbaPngAlphaBounds(iconFile),
|
|
{ minX: 100, minY: 100, maxX: 923, maxY: 923 },
|
|
`${path.relative(projectRoot, iconFile)} must render on the 824px macOS icon grid`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("non-macOS runtime icons preserve their existing desktop sizing", () => {
|
|
const projectRoot = path.join(__dirname, "..");
|
|
assert.deepEqual(
|
|
readRgbaPngAlphaBounds(path.join(projectRoot, "public/icon-win.png")),
|
|
{ minX: 0, minY: 0, maxX: 1023, maxY: 1023 },
|
|
"The packaged Windows icon must remain full bleed",
|
|
);
|
|
|
|
for (const variant of APP_ICON_VARIANTS) {
|
|
const iconFile = path.join(projectRoot, "public/icons/variants", `${variant}.png`);
|
|
assert.deepEqual(
|
|
readRgbaPngAlphaBounds(iconFile),
|
|
{ minX: 61, minY: 61, maxX: 962, maxY: 962 },
|
|
`${path.relative(projectRoot, iconFile)} must keep the existing desktop runtime size`,
|
|
);
|
|
}
|
|
});
|