From 1e3b58b4eaefa04ea64e2c536de744e74c185273 Mon Sep 17 00:00:00 2001 From: sunrioa <178722768+sunrioa@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:27:14 +0800 Subject: [PATCH 1/7] fix(release): restore the macOS icon at 16px and 32px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electron-builder 26.15.2 replaced its icon generator, and from then until 26.15.3 the .icns it produced stored the 16px and 32px faces as PNG data in the legacy icp4/icp5/icp6 slots. macOS does not decode those, so every place that draws the icon small — Finder list rows, the Trash, Activity Monitor, and the installer DMG's own volume icon — showed noise, while 128px and up stayed correct and hid the breakage. 26.16.1 carries the upstream fix (icons toolset 1.2.3), which writes those sizes as ic04/ic05 ARGB again. The icon comes from a toolset the builder downloads at build time, so a version pin alone says nothing about what was produced. The macOS packaging verifier now reads the bundled icon and rejects the broken shape, which is the check that would have caught this before it shipped. The reproducible-ZIP patch moves to the new version unchanged. 26.16.1 sets -mtm=off and -mta=off for 7z archives upstream but still not for zip, which is the branch Windows artifacts take. Refs: https://github.com/electron-userland/electron-builder/issues/9940 Generated-by: Claude Code Co-Authored-By: Claude Opus 5 --- LICENSE | 6 +- apps/desktop/package.json | 2 +- package-lock.json | 135 +++++++++--------- ....3.patch => app-builder-lib+26.16.1.patch} | 3 +- scripts/verify-macos-dmg.mjs | 62 ++++++++ scripts/verify-packaged-app.test.mjs | 71 +++++++++ 6 files changed, 208 insertions(+), 71 deletions(-) rename patches/{app-builder-lib+26.15.3.patch => app-builder-lib+26.16.1.patch} (84%) diff --git a/LICENSE b/LICENSE index f5e5f923d6..bef189df78 100644 --- a/LICENSE +++ b/LICENSE @@ -469,11 +469,11 @@ THE SOFTWARE. electron-builder dependency patches -Source: https://www.npmjs.com/package/app-builder-lib/v/26.15.3 +Source: https://www.npmjs.com/package/app-builder-lib/v/26.16.1 https://www.npmjs.com/package/electron-updater/v/6.8.9 Repository: https://github.com/electron-userland/electron-builder -Versions: app-builder-lib 26.15.3; electron-updater 6.8.9 -Dependency patch: patches/app-builder-lib+26.15.3.patch +Versions: app-builder-lib 26.16.1; electron-updater 6.8.9 +Dependency patch: patches/app-builder-lib+26.16.1.patch patches/electron-updater+6.8.9.patch License: MIT diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c82a641045..298cc9adb8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -91,7 +91,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "electron": "43.4.1", - "electron-builder": "26.15.3", + "electron-builder": "26.16.1", "esbuild": "^0.28.1", "linkedom": "^0.18.13", "react": "^19.3.0", diff --git a/package-lock.json b/package-lock.json index 98985884c8..df92807f82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "electron": "43.4.1", - "electron-builder": "26.15.3", + "electron-builder": "26.16.1", "esbuild": "^0.28.1", "linkedom": "^0.18.13", "react": "^19.3.0", @@ -1966,9 +1966,9 @@ } }, "node_modules/@electron/universal/node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -1997,9 +1997,9 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -2054,9 +2054,9 @@ } }, "node_modules/@electron/universal/node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3691,13 +3691,13 @@ } }, "node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "dev": true, "license": "MIT", "engines": { - "node": ">= 20.19.0" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -4390,15 +4390,18 @@ ] }, "node_modules/@peculiar/asn1-schema": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", - "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", "dev": true, "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/json-schema": { @@ -6464,9 +6467,9 @@ } }, "node_modules/app-builder-lib": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", - "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "version": "26.16.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.16.1.tgz", + "integrity": "sha512-FhaO6YOup01ZfQW0Z6gt3AyukJjv1gW4uFK47jTgwcHZKqyN/fSlK2LqPf9tAeZYLP2bRJLDzeOkRImsw2X4Pg==", "dev": true, "license": "MIT", "dependencies": { @@ -6478,13 +6481,13 @@ "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", - "@noble/hashes": "^2.2.0", + "@noble/hashes": "^1.8.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", - "builder-util": "26.15.3", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", @@ -6492,7 +6495,7 @@ "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "26.15.3", + "electron-publish": "26.16.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", @@ -6516,8 +6519,8 @@ "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.15.3", - "electron-builder-squirrel-windows": "26.15.3" + "dmg-builder": "26.16.1", + "electron-builder-squirrel-windows": "26.16.1" } }, "node_modules/app-builder-lib/node_modules/@electron/asar": { @@ -6606,9 +6609,9 @@ "license": "MIT" }, "node_modules/app-builder-lib/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -7326,9 +7329,9 @@ "license": "MIT" }, "node_modules/builder-util": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", - "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "version": "26.16.0", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.16.0.tgz", + "integrity": "sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg==", "dev": true, "license": "MIT", "dependencies": { @@ -8847,9 +8850,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -8887,14 +8890,14 @@ } }, "node_modules/dmg-builder": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", - "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "version": "26.16.1", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.16.1.tgz", + "integrity": "sha512-pnI/3Qb24Uk+rMTgIUrsVUKosVgwmBUdF8Zeb8TexOSbpq8MWc7v6l+n+FrEqVkjNZwzBN+XpDS9ENgZ/rkWAw==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.16.1", + "builder-util": "26.16.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } @@ -9101,18 +9104,18 @@ } }, "node_modules/electron-builder": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", - "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "version": "26.16.1", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.16.1.tgz", + "integrity": "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.16.1", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", - "dmg-builder": "26.15.3", + "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", @@ -9127,15 +9130,15 @@ } }, "node_modules/electron-builder-squirrel-windows": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", - "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "version": "26.16.1", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.16.1.tgz", + "integrity": "sha512-w0y44wSaT1l6R7CAGmeHn4nHPfvzDyCAU1xJyi1w9SbPYJpYn76SmHDzqHf8Y7l91cPWTdPYBpGQtB2T5mJ08A==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.16.1", + "builder-util": "26.16.0", "electron-winstaller": "5.4.0" } }, @@ -9212,15 +9215,15 @@ } }, "node_modules/electron-publish": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", - "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "version": "26.16.0", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.16.0.tgz", + "integrity": "sha512-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q==", "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", - "builder-util": "26.15.3", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", @@ -9314,9 +9317,9 @@ "peer": true }, "node_modules/electron-winstaller/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "peer": true, @@ -9890,9 +9893,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -12718,9 +12721,9 @@ } }, "node_modules/node-abi": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", - "integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==", + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.35.0.tgz", + "integrity": "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -14483,9 +14486,9 @@ "peer": true }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "peer": true, diff --git a/patches/app-builder-lib+26.15.3.patch b/patches/app-builder-lib+26.16.1.patch similarity index 84% rename from patches/app-builder-lib+26.15.3.patch rename to patches/app-builder-lib+26.16.1.patch index bbd3bb6520..32abdf80ec 100644 --- a/patches/app-builder-lib+26.15.3.patch +++ b/patches/app-builder-lib+26.16.1.patch @@ -1,7 +1,8 @@ diff --git a/node_modules/app-builder-lib/out/targets/archive.js b/node_modules/app-builder-lib/out/targets/archive.js +index 9595b4f..fd8fe92 100644 --- a/node_modules/app-builder-lib/out/targets/archive.js +++ b/node_modules/app-builder-lib/out/targets/archive.js -@@ -101,6 +101,7 @@ function compute7zCompressArgs(format, options = {}) { +@@ -144,6 +144,7 @@ function compute7zCompressArgs(format, options = {}) { // For all other formats the codec is implicit from the output file extension. args.push(`-mm=${storeOnly ? "Copy" : "Deflate"}`); args.push("-mcu"); diff --git a/scripts/verify-macos-dmg.mjs b/scripts/verify-macos-dmg.mjs index 21a5e6a05b..fb1dbb1470 100644 --- a/scripts/verify-macos-dmg.mjs +++ b/scripts/verify-macos-dmg.mjs @@ -121,6 +121,67 @@ function assertSingleArchitecture(output, subject, expectedArch) { } } +/** + * macOS draws the 16px and 32px faces of an ICNS from the ARGB slots `ic04` + * and `ic05`. PNG data packed into the legacy `icp4`/`icp5`/`icp6` slots is + * not decoded, so it renders as noise — which is what a Finder list row, the + * Dock's Trash, Activity Monitor and the DMG's own volume icon show. Every + * larger size keeps working, so nothing else in this verifier notices. + * + * electron-builder shipped that exact shape from 26.15.2 through 26.15.3 + * (electron-userland/electron-builder#9940). The icon is generated by a + * toolset the builder downloads at build time, so the bundle is the only + * place the result can be seen: assert on it here rather than on a version + * pin that says nothing about what was produced. + */ +const UNRENDERABLE_ICNS_SLOTS = new Map([ + ['icp4', '16x16'], + ['icp5', '32x32'], + ['icp6', '64x64'], +]); +const RENDERED_ICNS_SLOTS = new Map([ + ['ic04', '16x16'], + ['ic05', '32x32'], +]); +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function readIcnsSlots(icns) { + if (icns.length < 8 || icns.subarray(0, 4).toString('latin1') !== 'icns') { + throw new Error('Maka icon is not an ICNS archive.'); + } + const slots = new Map(); + // Every entry declares its own length including the 8-byte header, so a zero + // or truncated length is the difference between a parse error and a loop. + for (let offset = 8; offset + 8 <= icns.length; ) { + const type = icns.subarray(offset, offset + 4).toString('latin1'); + const length = icns.readUInt32BE(offset + 4); + if (length < 8 || offset + length > icns.length) { + throw new Error(`Maka icon entry ${type} declares an unusable length of ${length}.`); + } + slots.set(type, icns.subarray(offset + 8, offset + length)); + offset += length; + } + return slots; +} + +export async function assertRenderableAppIcon(resourcesPath, { readIcon = readFile } = {}) { + const slots = readIcnsSlots(await readIcon(join(resourcesPath, 'icon.icns'))); + const unrenderable = [...UNRENDERABLE_ICNS_SLOTS] + .filter(([type]) => slots.get(type)?.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) + .map(([type, size]) => `${size} (${type})`); + if (unrenderable.length > 0) { + throw new Error( + `Maka icon stores PNG data in ICNS slots macOS does not render: ${unrenderable.join(', ')}.`, + ); + } + const missing = [...RENDERED_ICNS_SLOTS] + .filter(([type]) => !slots.has(type)) + .map(([type, size]) => `${size} (${type})`); + if (missing.length > 0) { + throw new Error(`Maka icon is missing the sizes macOS draws smallest: ${missing.join(', ')}.`); + } +} + async function readPlistValue(run, infoPlist, key) { const { stdout } = await run('plutil', ['-extract', key, 'raw', '-o', '-', infoPlist]); return stdout.trim(); @@ -167,6 +228,7 @@ export async function verifyPackagedMacApp( await requirePath(executable); await assertPackagedResources(resources, { requirePath, forbidPath }); + await assertRenderableAppIcon(resources); await assertPackagedUpdateConfiguration(resources, { channel }); await assertPackagedDependencyClosure(resources); diff --git a/scripts/verify-packaged-app.test.mjs b/scripts/verify-packaged-app.test.mjs index 9abda0c910..d9f2bf9e10 100644 --- a/scripts/verify-packaged-app.test.mjs +++ b/scripts/verify-packaged-app.test.mjs @@ -238,6 +238,31 @@ describe('asarLookupPath', () => { const roots = []; +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** An ICNS archive carrying `slots` verbatim, so a test can state the exact + * entry shape a generator produced without shipping a binary fixture. */ +function icnsWith(slots) { + const entries = slots.map(([type, payload]) => { + const entry = Buffer.alloc(8 + payload.length); + entry.write(type, 0, 'latin1'); + entry.writeUInt32BE(entry.length, 4); + payload.copy(entry, 8); + return entry; + }); + const body = Buffer.concat(entries); + const header = Buffer.alloc(8); + header.write('icns', 0, 'latin1'); + header.writeUInt32BE(header.length + body.length, 4); + return Buffer.concat([header, body]); +} + +const RENDERABLE_ICNS = icnsWith([ + ['ic04', Buffer.from('ARGBfixture')], + ['ic05', Buffer.from('ARGBfixture')], + ['ic07', Buffer.concat([PNG_SIGNATURE, Buffer.from('128px')])], +]); + const PTY_PACKAGES = ['@xterm/headless', '@xterm/addon-unicode11']; const COVERING_NOTICES = 'Header\n\nPackage: react@19.2.0\nDeclared license: MIT\n'; @@ -333,6 +358,7 @@ test('accepts the Intel Mach-O architecture for an x64 package', async () => { join(resources, 'app-update.yml'), 'provider: github\nowner: apache\nrepo: maka\nchannel: dev\nupdaterCacheDirName: "@makadesktop-updater"\n', ); + await writeFile(join(resources, 'icon.icns'), RENDERABLE_ICNS); const version = '0.2.0-dev.14.20260902'; const app = join(dirname(resources), 'Maka.app'); await mkdir(join(app, 'Contents'), { recursive: true }); @@ -367,6 +393,51 @@ test('accepts the Intel Mach-O architecture for an x64 package', async () => { }); }); +describe('assertRenderableAppIcon', () => { + const withIcon = async (t, icns) => { + const resources = await mkdtemp(join(tmpdir(), 'maka-icon-')); + t.after(() => rm(resources, { recursive: true, force: true })); + await writeFile(join(resources, 'icon.icns'), icns); + return resources; + }; + + test('accepts an icon whose small sizes are stored as ARGB', async (t) => { + const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); + await assertRenderableAppIcon(await withIcon(t, RENDERABLE_ICNS)); + }); + + test('rejects PNG data in the legacy slots macOS does not decode', async (t) => { + const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); + // The shape electron-builder 26.15.2-26.15.3 produced: every size present, + // the large ones fine, and 16px/32px unreadable where a person sees them. + const resources = await withIcon( + t, + icnsWith([ + ['icp4', Buffer.concat([PNG_SIGNATURE, Buffer.from('16px')])], + ['icp5', Buffer.concat([PNG_SIGNATURE, Buffer.from('32px')])], + ['ic07', Buffer.concat([PNG_SIGNATURE, Buffer.from('128px')])], + ]), + ); + await assert.rejects(assertRenderableAppIcon(resources), /16x16 \(icp4\), 32x32 \(icp5\)/); + }); + + test('rejects an icon that carries no small sizes at all', async (t) => { + const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); + const resources = await withIcon( + t, + icnsWith([['ic07', Buffer.concat([PNG_SIGNATURE, Buffer.from('128px')])]]), + ); + await assert.rejects(assertRenderableAppIcon(resources), /missing the sizes/); + }); + + test('refuses to guess at a truncated entry instead of looping on it', async (t) => { + const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); + const icns = icnsWith([['ic04', Buffer.from('ARGBfixture')]]); + icns.writeUInt32BE(0, 12); + await assert.rejects(assertRenderableAppIcon(await withIcon(t, icns)), /unusable length/); + }); +}); + describe('assertPackagedDependencyClosure', () => { test('accepts an artifact whose asar, bundle record, and shipped notices match', async () => { const resources = await makeResources(); From 9933280a608e3b30ea18162c75774cdd2963d5cf Mon Sep 17 00:00:00 2001 From: sunrioa <178722768+sunrioa@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:11:44 +0800 Subject: [PATCH 2/7] fix(release): require the macOS icon's small sizes to decode The ICNS gate only checked that `ic04` and `ic05` existed, so an empty or truncated payload in exactly the sizes it protects still passed, and the archive's own length was never compared with the file. Unpack the ARGB planes and require exactly four full planes with no bytes left over, require the entries to account for the whole archive, and read each PNG slot's IHDR so art at the wrong size fails too. That also catches the 512px and 1024px art that the toolset electron-builder 26.15.3 pinned put in `ic13` and `ic14`. The tests now build real ARGB payloads, which `iconutil` unpacks, instead of an ASCII stand-in. Generated-by: Claude Code Co-Authored-By: Claude Opus 5.5 --- scripts/verify-macos-dmg.mjs | 89 ++++++++++++++-- scripts/verify-packaged-app.test.mjs | 146 +++++++++++++++++++++++++-- 2 files changed, 219 insertions(+), 16 deletions(-) diff --git a/scripts/verify-macos-dmg.mjs b/scripts/verify-macos-dmg.mjs index fb1dbb1470..0a99bedca9 100644 --- a/scripts/verify-macos-dmg.mjs +++ b/scripts/verify-macos-dmg.mjs @@ -139,9 +139,22 @@ const UNRENDERABLE_ICNS_SLOTS = new Map([ ['icp5', '32x32'], ['icp6', '64x64'], ]); -const RENDERED_ICNS_SLOTS = new Map([ - ['ic04', '16x16'], - ['ic05', '32x32'], +// Side length of each slot's art. `iconutil` and the builder's toolset both +// write `ic04`/`ic05` as ARGB planes and every larger size as PNG. The toolset +// 26.15.3 pinned also put 512px and 1024px art in `ic13`/`ic14`. +const ARGB_ICNS_SLOTS = new Map([ + ['ic04', 16], + ['ic05', 32], +]); +const PNG_ICNS_SLOTS = new Map([ + ['ic07', 128], + ['ic08', 256], + ['ic09', 512], + ['ic10', 1024], + ['ic11', 32], + ['ic12', 64], + ['ic13', 256], + ['ic14', 512], ]); const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); @@ -149,10 +162,20 @@ function readIcnsSlots(icns) { if (icns.length < 8 || icns.subarray(0, 4).toString('latin1') !== 'icns') { throw new Error('Maka icon is not an ICNS archive.'); } + // A header that disagrees with the file is a truncated or padded archive, + // and its entries cannot be trusted to end where they claim. + const declared = icns.readUInt32BE(4); + if (declared !== icns.length) { + throw new Error(`Maka icon declares ${declared} bytes but holds ${icns.length}.`); + } const slots = new Map(); // Every entry declares its own length including the 8-byte header, so a zero // or truncated length is the difference between a parse error and a loop. - for (let offset = 8; offset + 8 <= icns.length; ) { + // The entries must account for every byte after the header. + for (let offset = 8; offset < icns.length; ) { + if (offset + 8 > icns.length) { + throw new Error(`Maka icon ends inside an entry header at byte ${offset}.`); + } const type = icns.subarray(offset, offset + 4).toString('latin1'); const length = icns.readUInt32BE(offset + 4); if (length < 8 || offset + length > icns.length) { @@ -164,22 +187,74 @@ function readIcnsSlots(icns) { return slots; } +/** + * An `ic04`/`ic05` payload is `ARGB` followed by the alpha, red, green and + * blue planes, packed the ICNS way: a control byte below 0x80 copies the next + * `control + 1` bytes, and one from 0x80 up repeats the next byte + * `control - 0x80 + 3` times. macOS can draw it only if it unpacks to exactly + * four `side` × `side` planes with no bytes left over. + */ +function isDecodableArgb(payload, side) { + if (payload.subarray(0, 4).toString('latin1') !== 'ARGB') return false; + const expected = 4 * side * side; + let produced = 0; + for (let offset = 4; offset < payload.length; ) { + const control = payload[offset]; + const literal = control < 0x80; + const count = literal ? control + 1 : control - 0x80 + 3; + const consumed = 1 + (literal ? count : 1); + if (offset + consumed > payload.length) return false; + produced += count; + if (produced > expected) return false; + offset += consumed; + } + return produced === expected; +} + +/** Width and height from a PNG's IHDR, or undefined when there is none. */ +function pngSize(payload) { + if (payload.length < 24 || payload.subarray(12, 16).toString('latin1') !== 'IHDR') { + return undefined; + } + return { width: payload.readUInt32BE(16), height: payload.readUInt32BE(20) }; +} + export async function assertRenderableAppIcon(resourcesPath, { readIcon = readFile } = {}) { const slots = readIcnsSlots(await readIcon(join(resourcesPath, 'icon.icns'))); + const isPng = (payload) => payload?.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE); const unrenderable = [...UNRENDERABLE_ICNS_SLOTS] - .filter(([type]) => slots.get(type)?.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) + .filter(([type]) => isPng(slots.get(type))) .map(([type, size]) => `${size} (${type})`); if (unrenderable.length > 0) { throw new Error( `Maka icon stores PNG data in ICNS slots macOS does not render: ${unrenderable.join(', ')}.`, ); } - const missing = [...RENDERED_ICNS_SLOTS] + const missing = [...ARGB_ICNS_SLOTS] .filter(([type]) => !slots.has(type)) - .map(([type, size]) => `${size} (${type})`); + .map(([type, side]) => `${side}x${side} (${type})`); if (missing.length > 0) { throw new Error(`Maka icon is missing the sizes macOS draws smallest: ${missing.join(', ')}.`); } + // Presence alone would accept an empty or truncated payload in exactly the + // sizes this check exists to protect. + const undecodable = [...ARGB_ICNS_SLOTS] + .filter(([type, side]) => !isDecodableArgb(slots.get(type), side)) + .map(([type, side]) => `${side}x${side} (${type})`); + if (undecodable.length > 0) { + throw new Error(`Maka icon has small sizes macOS cannot decode: ${undecodable.join(', ')}.`); + } + const misdrawn = [...PNG_ICNS_SLOTS] + .filter(([type]) => isPng(slots.get(type))) + .flatMap(([type, side]) => { + const size = pngSize(slots.get(type)); + if (size?.width === side && size.height === side) return []; + const found = size ? `${size.width}x${size.height}` : 'no readable size'; + return [`${type} holds ${found} where macOS expects ${side}x${side}`]; + }); + if (misdrawn.length > 0) { + throw new Error(`Maka icon stores art at the wrong size: ${misdrawn.join('; ')}.`); + } } async function readPlistValue(run, infoPlist, key) { diff --git a/scripts/verify-packaged-app.test.mjs b/scripts/verify-packaged-app.test.mjs index d9f2bf9e10..43ec512aae 100644 --- a/scripts/verify-packaged-app.test.mjs +++ b/scripts/verify-packaged-app.test.mjs @@ -257,10 +257,64 @@ function icnsWith(slots) { return Buffer.concat([header, body]); } +/** Packs one ARGB plane the ICNS way: runs of 3-130 equal bytes, literals of up to 128. */ +function packIcnsPlane(plane) { + const packed = []; + for (let i = 0; i < plane.length; ) { + let run = 1; + while (run < 130 && i + run < plane.length && plane[i + run] === plane[i]) run += 1; + if (run >= 3) { + packed.push(0x80 + run - 3, plane[i]); + i += run; + continue; + } + let end = i + 1; + while ( + end < plane.length && + end - i < 128 && + !(plane[end] === plane[end + 1] && plane[end] === plane[end + 2]) + ) { + end += 1; + } + packed.push(end - i - 1, ...plane.subarray(i, end)); + i = end; + } + return Buffer.from(packed); +} + +/** A real `ic04`/`ic05` payload, which `iconutil` unpacks to a `side` px image: + * a transparent border around opaque gradients, so it holds runs and literals. */ +function argbPayload(side) { + const planes = [0, 1, 2, 3].map((channel) => { + const plane = Buffer.alloc(side * side); + for (let y = 0; y < side; y += 1) { + for (let x = 0; x < side; x += 1) { + const edge = x === 0 || y === 0 || x === side - 1 || y === side - 1; + plane[y * side + x] = + channel === 0 ? (edge ? 0 : 255) : (channel * 60 + x * 7 + y * 3) & 0xff; + } + } + return packIcnsPlane(plane); + }); + return Buffer.concat([Buffer.from('ARGB', 'latin1'), ...planes]); +} + +/** The PNG signature and IHDR of a `side` px square, which is all the check reads. */ +function pngHead(side) { + const ihdr = Buffer.alloc(25); + ihdr.writeUInt32BE(13, 0); + ihdr.write('IHDR', 4, 'latin1'); + ihdr.writeUInt32BE(side, 8); + ihdr.writeUInt32BE(side, 12); + ihdr.set([8, 6, 0, 0, 0], 16); + return Buffer.concat([PNG_SIGNATURE, ihdr]); +} + const RENDERABLE_ICNS = icnsWith([ - ['ic04', Buffer.from('ARGBfixture')], - ['ic05', Buffer.from('ARGBfixture')], - ['ic07', Buffer.concat([PNG_SIGNATURE, Buffer.from('128px')])], + ['ic04', argbPayload(16)], + ['ic05', argbPayload(32)], + ['ic07', pngHead(128)], + ['ic13', pngHead(256)], ]); const PTY_PACKAGES = ['@xterm/headless', '@xterm/addon-unicode11']; @@ -413,9 +467,9 @@ describe('assertRenderableAppIcon', () => { const resources = await withIcon( t, icnsWith([ - ['icp4', Buffer.concat([PNG_SIGNATURE, Buffer.from('16px')])], - ['icp5', Buffer.concat([PNG_SIGNATURE, Buffer.from('32px')])], - ['ic07', Buffer.concat([PNG_SIGNATURE, Buffer.from('128px')])], + ['icp4', pngHead(16)], + ['icp5', pngHead(32)], + ['ic07', pngHead(128)], ]), ); await assert.rejects(assertRenderableAppIcon(resources), /16x16 \(icp4\), 32x32 \(icp5\)/); @@ -423,19 +477,93 @@ describe('assertRenderableAppIcon', () => { test('rejects an icon that carries no small sizes at all', async (t) => { const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); + const resources = await withIcon(t, icnsWith([['ic07', pngHead(128)]])); + await assert.rejects(assertRenderableAppIcon(resources), /missing the sizes/); + }); + + test('rejects small sizes whose ARGB planes do not unpack to the full image', async (t) => { + const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); + const whole = argbPayload(16); + for (const ic04 of [ + Buffer.alloc(0), + Buffer.from([0x80]), + Buffer.from('ARGB', 'latin1'), + // The last packed token cut short. + whole.subarray(0, whole.length - 1), + // Bytes left over once all four planes are full. + Buffer.concat([whole, Buffer.from([0x00, 0xff])]), + // The right planes behind the wrong magic. + Buffer.concat([Buffer.from('PNGX', 'latin1'), whole.subarray(4)]), + ]) { + const resources = await withIcon( + t, + icnsWith([ + ['ic04', ic04], + ['ic05', argbPayload(32)], + ]), + ); + await assert.rejects( + assertRenderableAppIcon(resources), + /small sizes macOS cannot decode: 16x16 \(ic04\)\./, + ); + } + const swapped = await withIcon( + t, + icnsWith([ + ['ic04', argbPayload(16)], + ['ic05', argbPayload(16)], + ]), + ); + await assert.rejects(assertRenderableAppIcon(swapped), /cannot decode: 32x32 \(ic05\)\./); + }); + + test('rejects PNG art at the wrong size for its slot', async (t) => { + const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); + // The retina slots as the toolset electron-builder 26.15.3 pinned wrote + // them, and a PNG slot with no IHDR to read a size from. const resources = await withIcon( t, - icnsWith([['ic07', Buffer.concat([PNG_SIGNATURE, Buffer.from('128px')])]]), + icnsWith([ + ['ic04', argbPayload(16)], + ['ic05', argbPayload(32)], + ['ic13', pngHead(512)], + ['ic14', pngHead(1024)], + ['ic07', PNG_SIGNATURE], + ]), + ); + await assert.rejects( + assertRenderableAppIcon(resources), + new RegExp( + 'ic07 holds no readable size where macOS expects 128x128; ' + + 'ic13 holds 512x512 where macOS expects 256x256; ' + + 'ic14 holds 1024x1024 where macOS expects 512x512\\.', + ), ); - await assert.rejects(assertRenderableAppIcon(resources), /missing the sizes/); }); test('refuses to guess at a truncated entry instead of looping on it', async (t) => { const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); - const icns = icnsWith([['ic04', Buffer.from('ARGBfixture')]]); + const icns = icnsWith([['ic04', argbPayload(16)]]); icns.writeUInt32BE(0, 12); await assert.rejects(assertRenderableAppIcon(await withIcon(t, icns)), /unusable length/); }); + + test('rejects an archive whose header does not match its entries', async (t) => { + const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); + const understated = Buffer.from(RENDERABLE_ICNS); + understated.writeUInt32BE(8, 4); + await assert.rejects( + assertRenderableAppIcon(await withIcon(t, understated)), + new RegExp(`declares 8 bytes but holds ${understated.length}\\.`), + ); + // Bytes after the last entry that are too few to be another one. + const padded = Buffer.concat([RENDERABLE_ICNS, Buffer.from([0, 0, 0])]); + padded.writeUInt32BE(padded.length, 4); + await assert.rejects( + assertRenderableAppIcon(await withIcon(t, padded)), + /ends inside an entry header/, + ); + }); }); describe('assertPackagedDependencyClosure', () => { From 10fc393f7b5cf40b883d75920fc394b47ab6c0f0 Mon Sep 17 00:00:00 2001 From: sunrioa <178722768+sunrioa@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:18:58 +0800 Subject: [PATCH 3/7] test(release): cover every branch of the macOS icon gate A mutation pass over the ICNS gate found one redundant check and four branches no test reached. Drop the early overflow return, which the final exact-length comparison already covers, and add the smallest inputs that reach an entry running past the archive, a PNG whose IHDR is missing or cut short, and art that is not square. Two cases that caught nothing the others missed are gone; the empty and one-byte payloads the review reproduced stay. Generated-by: Claude Code Co-Authored-By: Claude Opus 5.5 --- scripts/verify-macos-dmg.mjs | 1 - scripts/verify-packaged-app.test.mjs | 35 ++++++++++++++-------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/scripts/verify-macos-dmg.mjs b/scripts/verify-macos-dmg.mjs index 0a99bedca9..03b6a30931 100644 --- a/scripts/verify-macos-dmg.mjs +++ b/scripts/verify-macos-dmg.mjs @@ -205,7 +205,6 @@ function isDecodableArgb(payload, side) { const consumed = 1 + (literal ? count : 1); if (offset + consumed > payload.length) return false; produced += count; - if (produced > expected) return false; offset += consumed; } return produced === expected; diff --git a/scripts/verify-packaged-app.test.mjs b/scripts/verify-packaged-app.test.mjs index 43ec512aae..299eb5801e 100644 --- a/scripts/verify-packaged-app.test.mjs +++ b/scripts/verify-packaged-app.test.mjs @@ -299,13 +299,13 @@ function argbPayload(side) { return Buffer.concat([Buffer.from('ARGB', 'latin1'), ...planes]); } -/** The PNG signature and IHDR of a `side` px square, which is all the check reads. */ -function pngHead(side) { +/** The PNG signature and IHDR of a `width` × `height` image, which is all the check reads. */ +function pngHead(width, height = width) { const ihdr = Buffer.alloc(25); ihdr.writeUInt32BE(13, 0); ihdr.write('IHDR', 4, 'latin1'); - ihdr.writeUInt32BE(side, 8); - ihdr.writeUInt32BE(side, 12); + ihdr.writeUInt32BE(width, 8); + ihdr.writeUInt32BE(height, 12); ihdr.set([8, 6, 0, 0, 0], 16); return Buffer.concat([PNG_SIGNATURE, ihdr]); } @@ -485,9 +485,9 @@ describe('assertRenderableAppIcon', () => { const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); const whole = argbPayload(16); for (const ic04 of [ + // The empty and one-byte payloads the review reproduced. Buffer.alloc(0), Buffer.from([0x80]), - Buffer.from('ARGB', 'latin1'), // The last packed token cut short. whole.subarray(0, whole.length - 1), // Bytes left over once all four planes are full. @@ -507,20 +507,13 @@ describe('assertRenderableAppIcon', () => { /small sizes macOS cannot decode: 16x16 \(ic04\)\./, ); } - const swapped = await withIcon( - t, - icnsWith([ - ['ic04', argbPayload(16)], - ['ic05', argbPayload(16)], - ]), - ); - await assert.rejects(assertRenderableAppIcon(swapped), /cannot decode: 32x32 \(ic05\)\./); }); test('rejects PNG art at the wrong size for its slot', async (t) => { const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); // The retina slots as the toolset electron-builder 26.15.3 pinned wrote - // them, and a PNG slot with no IHDR to read a size from. + // them, PNG slots whose IHDR is missing or cut short, and art that is + // not square. const resources = await withIcon( t, icnsWith([ @@ -528,13 +521,17 @@ describe('assertRenderableAppIcon', () => { ['ic05', argbPayload(32)], ['ic13', pngHead(512)], ['ic14', pngHead(1024)], - ['ic07', PNG_SIGNATURE], + ['ic07', Buffer.concat([PNG_SIGNATURE, Buffer.alloc(17)])], + ['ic08', pngHead(256).subarray(0, 20)], + ['ic11', pngHead(32, 16)], ]), ); await assert.rejects( assertRenderableAppIcon(resources), new RegExp( 'ic07 holds no readable size where macOS expects 128x128; ' + + 'ic08 holds no readable size where macOS expects 256x256; ' + + 'ic11 holds 32x16 where macOS expects 32x32; ' + 'ic13 holds 512x512 where macOS expects 256x256; ' + 'ic14 holds 1024x1024 where macOS expects 512x512\\.', ), @@ -543,9 +540,11 @@ describe('assertRenderableAppIcon', () => { test('refuses to guess at a truncated entry instead of looping on it', async (t) => { const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); - const icns = icnsWith([['ic04', argbPayload(16)]]); - icns.writeUInt32BE(0, 12); - await assert.rejects(assertRenderableAppIcon(await withIcon(t, icns)), /unusable length/); + for (const length of [0, 1_000]) { + const icns = icnsWith([['ic04', argbPayload(16)]]); + icns.writeUInt32BE(length, 12); + await assert.rejects(assertRenderableAppIcon(await withIcon(t, icns)), /unusable length/); + } }); test('rejects an archive whose header does not match its entries', async (t) => { From 801af646579665fa63b4b7c781142220158b472a Mon Sep 17 00:00:00 2001 From: sunrioa <178722768+sunrioa@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:18:58 +0800 Subject: [PATCH 4/7] fix(release): keep the macOS update ZIP on the system zip electron-builder 26.16 builds macOS ZIPs with 7za instead of the system zip, so the patch that keeps Windows ZIPs reproducible now also strips every modification time from the archive Squirrel.Mac unpacks, and the first delta update after this release would miss the blockmap almost entirely. Keep macOS ZIPs on the system zip, as 26.15.3 did: the update archive is then byte-identical to one 26.15.3 builds from the same app. A macOS-only test in check:release builds the update ZIP the way the zip target does, unpacks it with ditto, and checks that framework symlinks and modification times survive. Generated-by: Claude Code Co-Authored-By: Claude Opus 5.5 --- LICENSE | 5 ++- package.json | 2 +- patches/app-builder-lib+26.16.1.patch | 14 ++++++- scripts/macos-update-archive.test.mjs | 58 +++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 scripts/macos-update-archive.test.mjs diff --git a/LICENSE b/LICENSE index bef189df78..d5c479160a 100644 --- a/LICENSE +++ b/LICENSE @@ -478,8 +478,9 @@ Dependency patch: patches/app-builder-lib+26.16.1.patch License: MIT Maka redistributes source patches that omit modification and access times -from ZIP archives and skip withdrawn prereleases whose update metadata is -missing. The following MIT License applies to that material: +from 7-Zip ZIP archives, keep macOS ZIP archives on the system zip, and skip +withdrawn prereleases whose update metadata is missing. The following MIT +License applies to that material: The MIT License (MIT) diff --git a/package.json b/package.json index ba0b9adb45..5ec39c7790 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "check:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs --check", "generate:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs", "check:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-nightly.test.mjs scripts/desktop-release-targets.test.mjs scripts/verify-linux-harness.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/audit-shipped-dependencies.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/qualify-released-cli-state-root.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/prepare-windows-upgrade-baseline.test.mjs scripts/windows-package-source-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-nightly.test.mjs scripts/desktop-release-targets.test.mjs scripts/verify-linux-harness.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/audit-shipped-dependencies.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/qualify-released-cli-state-root.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/macos-update-archive.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/prepare-windows-upgrade-baseline.test.mjs scripts/windows-package-source-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos.mjs arm64", "package:macos-x64": "node scripts/package-macos.mjs x64", "verify:macos": "node scripts/verify-macos-dmg.mjs", diff --git a/patches/app-builder-lib+26.16.1.patch b/patches/app-builder-lib+26.16.1.patch index 32abdf80ec..f6a1cd2378 100644 --- a/patches/app-builder-lib+26.16.1.patch +++ b/patches/app-builder-lib+26.16.1.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/app-builder-lib/out/targets/archive.js b/node_modules/app-builder-lib/out/targets/archive.js -index 9595b4f..fd8fe92 100644 +index 9595b4f..e5ae6d8 100644 --- a/node_modules/app-builder-lib/out/targets/archive.js +++ b/node_modules/app-builder-lib/out/targets/archive.js @@ -144,6 +144,7 @@ function compute7zCompressArgs(format, options = {}) { @@ -10,3 +10,15 @@ index 9595b4f..fd8fe92 100644 } return args; } +@@ -163,6 +164,11 @@ async function archive(format, outFile, dirToArchive, options = {}) { + builder_util_1.log.warn({ reason: "7z doesn't support NFD-normalized filenames" }, `using zip`); + use7z = false; + } ++ // Maka: keep macOS ZIPs on the system zip, as 26.15.3 did, so the update ++ // archive Squirrel.Mac unpacks is built the way shipped releases were. ++ if (process.platform === "darwin" && format === "zip" && options.preserveSymlinks) { ++ use7z = false; ++ } + if (use7z) { + const args = compute7zCompressArgs(format, options); + // Modern 7-Zip (24.09) dereferences symlinks by default; the 7-Zip 16.02 bundled before diff --git a/scripts/macos-update-archive.test.mjs b/scripts/macos-update-archive.test.mjs new file mode 100644 index 0000000000..a546544b3b --- /dev/null +++ b/scripts/macos-update-archive.test.mjs @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, readlink, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { promisify } from 'node:util'; +import { archive } from 'app-builder-lib/out/targets/archive.js'; + +// electron-builder 26.16 moved macOS ZIPs from the system zip to 7za, where +// the patch that keeps Windows ZIPs reproducible also drops every +// modification time. The patch keeps the macOS update archive on the system +// zip, as the releases already shipped were built. +test('the macOS update ZIP keeps bundle symlinks and modification times', { + skip: process.platform !== 'darwin' && 'the macOS update ZIP is built on macOS', +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-macos-archive-')); + t.after(() => rm(root, { recursive: true, force: true })); + const app = join(root, 'Maka.app'); + const versions = join(app, 'Contents', 'Frameworks', 'Fixture.framework', 'Versions'); + await mkdir(join(versions, 'A'), { recursive: true }); + await symlink('A', join(versions, 'Current')); + const binary = join(versions, 'A', 'Fixture'); + await writeFile(binary, '#!/bin/sh\n'); + const modified = new Date('2024-01-01T00:00:00Z'); + await utimes(binary, modified, modified); + + const zip = join(root, 'Maka.zip'); + // What ArchiveTarget passes for a macOS `zip` target. + await archive('zip', zip, app, { withoutDir: false, preserveSymlinks: true }); + // Squirrel.Mac and verify-macos-autoupdate both unpack with ditto. + const out = join(root, 'out'); + await promisify(execFile)('ditto', ['-x', '-k', zip, out]); + + const extracted = join(out, 'Maka.app', 'Contents', 'Frameworks', 'Fixture.framework'); + assert.equal(await readlink(join(extracted, 'Versions', 'Current')), 'A'); + const unpacked = await stat(join(extracted, 'Versions', 'A', 'Fixture')); + assert.equal(unpacked.mtime.toISOString(), modified.toISOString()); +}); From 71104a092c9d2a25cc1a3b0b55ebda69874bd2a2 Mon Sep 17 00:00:00 2001 From: sunrioa <178722768+sunrioa@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:23:28 +0800 Subject: [PATCH 5/7] test(release): route the macOS update ZIP test through the release lane The CI planner requires every test that check:release runs to reach the release-contract lane, and the new macOS update ZIP test did not. Generated-by: Claude Code Co-Authored-By: Claude Opus 5.5 --- scripts/ci-test-plan.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 41820e568f..70ff444d7b 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -84,6 +84,7 @@ const RELEASE_CONTRACT_FILES = new Set([ 'scripts/product-nightly.test.mjs', 'scripts/verify-packaged-app.mjs', 'scripts/verify-packaged-app.test.mjs', + 'scripts/macos-update-archive.test.mjs', 'scripts/verify-windows-autoupdate.mjs', 'scripts/verify-windows-installer-lifecycle.mjs', 'scripts/verify-windows-x64.mjs', From 55a89db5c8c1139b08eb50a8b8c9429c7ee4cabe Mon Sep 17 00:00:00 2001 From: sunrioa <178722768+sunrioa@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:27:37 +0800 Subject: [PATCH 6/7] docs: list the macOS update ZIP test in the Windows skip inventory The test only runs on macOS, so Windows skips it, and the inventory of Windows-excluded tests has to name it. Generated-by: Claude Code Co-Authored-By: Claude Opus 5.5 --- docs/windows-test-inventory.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 9c0649dd5a..0a7692fcc6 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -17,9 +17,9 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t |---|---:| | windows-backend-gap | 27 | | portable-candidate | 40 | -| platform-contract | 38 | +| platform-contract | 39 | -Total Windows-excluded declarations: **105** +Total Windows-excluded declarations: **106** ## Inventory @@ -126,6 +126,7 @@ Total Windows-excluded declarations: **105** | portable-candidate | `packages/storage/src/__tests__/stable-storage.test.ts` hardenDirectory re-chmods a pre-existing world-accessible directory to 0700 | `process.platform === 'win32'` | | platform-contract | `packages/storage/src/__tests__/usage-stores.test.ts` classifies a renamed or replaced live root as a draining persistence failure | `process.platform === 'win32' ? 'Windows does not permit renaming a directory with an open SQLite database' : false` | | platform-contract | `packages/storage/src/__tests__/workspace-identity.test.ts` an unmarked read-only workspace fails without leaving marker state | `process.platform === 'win32' ? 'POSIX permissions are required to create a read-only workspace fixture' : false` | +| platform-contract | `scripts/macos-update-archive.test.mjs` the macOS update ZIP keeps bundle symlinks and modification times | `process.platform !== 'darwin' && 'the macOS update ZIP is built on macOS'` | | portable-candidate | `scripts/qualify-released-cli-state-root.test.mjs` starts the liveness window after a delayed Runtime Host Ready | `process.platform === 'win32'` | | portable-candidate | `scripts/qualify-released-cli-state-root.test.mjs` starts the liveness window after the real Runtime Host is ready | `process.platform === 'win32'` | | portable-candidate | `scripts/qualify-released-cli-state-root.test.mjs` rejects a Runtime Host that fails verifier shutdown | `process.platform === 'win32'` | From b82ad9f36b42515e2d8c30c1e624eb06f002fa9a Mon Sep 17 00:00:00 2001 From: sunrioa <178722768+sunrioa@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:39:55 +0800 Subject: [PATCH 7/7] fix(release): decode the macOS icon's planes and PNG art in full The ICNS gate compared only the total an ARGB payload unpacks to, so a run that spilled from one plane into the next passed although macOS unpacks each plane on its own and draws every later plane shifted; and it read only the IHDR of PNG slots, so art with no image data passed. Reject a token that runs past its plane, and require each PNG slot to decode: valid CRCs from IHDR through IEND with nothing after, and IDAT that inflates to one filter byte and one scanline per declared row. The tests build real PNGs and cover the review's plane-crossing payload. Generated-by: Claude Code Co-Authored-By: Claude Opus 5.5 --- scripts/verify-macos-dmg.mjs | 71 ++++++++++++--- scripts/verify-packaged-app.test.mjs | 125 +++++++++++++++++++++------ 2 files changed, 158 insertions(+), 38 deletions(-) diff --git a/scripts/verify-macos-dmg.mjs b/scripts/verify-macos-dmg.mjs index 03b6a30931..0ef0f7e5be 100644 --- a/scripts/verify-macos-dmg.mjs +++ b/scripts/verify-macos-dmg.mjs @@ -30,6 +30,7 @@ import { import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { crc32, inflateSync } from 'node:zlib'; import { FILESYSTEM_WORKER_PROTOCOL_VERSION } from '../packages/runtime/dist/filesystem-worker/protocol.js'; import { readProductManifestIdentity } from './product-release-identity.mjs'; import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs'; @@ -191,12 +192,14 @@ function readIcnsSlots(icns) { * An `ic04`/`ic05` payload is `ARGB` followed by the alpha, red, green and * blue planes, packed the ICNS way: a control byte below 0x80 copies the next * `control + 1` bytes, and one from 0x80 up repeats the next byte - * `control - 0x80 + 3` times. macOS can draw it only if it unpacks to exactly - * four `side` × `side` planes with no bytes left over. + * `control - 0x80 + 3` times. macOS unpacks each plane on its own and cuts a + * token off at the plane's end, so a token that runs past its plane shifts + * every plane after it: it can draw the art only if each plane ends exactly + * on a token boundary and four `side` × `side` planes leave no bytes over. */ function isDecodableArgb(payload, side) { if (payload.subarray(0, 4).toString('latin1') !== 'ARGB') return false; - const expected = 4 * side * side; + const plane = side * side; let produced = 0; for (let offset = 4; offset < payload.length; ) { const control = payload[offset]; @@ -204,18 +207,60 @@ function isDecodableArgb(payload, side) { const count = literal ? control + 1 : control - 0x80 + 3; const consumed = 1 + (literal ? count : 1); if (offset + consumed > payload.length) return false; + if (Math.floor(produced / plane) !== Math.floor((produced + count - 1) / plane)) return false; produced += count; offset += consumed; } - return produced === expected; + return produced === 4 * plane; } -/** Width and height from a PNG's IHDR, or undefined when there is none. */ -function pngSize(payload) { - if (payload.length < 24 || payload.subarray(12, 16).toString('latin1') !== 'IHDR') { +// Channels per pixel for each PNG color type. +const PNG_CHANNELS = new Map([ + [0, 1], + [2, 3], + [3, 1], + [4, 2], + [6, 4], +]); + +/** + * The size of a PNG slot's art, or undefined unless it decodes: every chunk's + * CRC holds from IHDR through IEND with nothing after it, and the IDAT stream + * inflates to exactly one filter byte (0-4) and one scanline per row of the + * size IHDR declares. + */ +function decodedPngSize(payload) { + let header; + const data = []; + let offset = PNG_SIGNATURE.length; + for (let ended = false; !ended; ) { + if (offset + 12 > payload.length) return undefined; + const length = payload.readUInt32BE(offset); + if (offset + 12 + length > payload.length) return undefined; + const chunk = payload.subarray(offset + 4, offset + 8 + length); + if (crc32(chunk) !== payload.readUInt32BE(offset + 8 + length)) return undefined; + const type = chunk.subarray(0, 4).toString('latin1'); + if (header === undefined && type !== 'IHDR') return undefined; + if (type === 'IHDR') header = chunk.subarray(4); + if (type === 'IDAT') data.push(chunk.subarray(4)); + ended = type === 'IEND'; + offset += 12 + length; + } + if (offset !== payload.length || header.length !== 13) return undefined; + const width = header.readUInt32BE(0); + const height = header.readUInt32BE(4); + const stride = 1 + Math.ceil((width * PNG_CHANNELS.get(header[9]) * header[8]) / 8); + let pixels; + try { + pixels = inflateSync(Buffer.concat(data)); + } catch { return undefined; } - return { width: payload.readUInt32BE(16), height: payload.readUInt32BE(20) }; + if (pixels.length !== height * stride) return undefined; + for (let row = 0; row < pixels.length; row += stride) { + if (pixels[row] > 4) return undefined; + } + return { width, height }; } export async function assertRenderableAppIcon(resourcesPath, { readIcon = readFile } = {}) { @@ -246,13 +291,13 @@ export async function assertRenderableAppIcon(resourcesPath, { readIcon = readFi const misdrawn = [...PNG_ICNS_SLOTS] .filter(([type]) => isPng(slots.get(type))) .flatMap(([type, side]) => { - const size = pngSize(slots.get(type)); - if (size?.width === side && size.height === side) return []; - const found = size ? `${size.width}x${size.height}` : 'no readable size'; - return [`${type} holds ${found} where macOS expects ${side}x${side}`]; + const size = decodedPngSize(slots.get(type)); + if (!size) return [`${type} does not decode`]; + if (size.width === side && size.height === side) return []; + return [`${type} holds ${size.width}x${size.height} where macOS expects ${side}x${side}`]; }); if (misdrawn.length > 0) { - throw new Error(`Maka icon stores art at the wrong size: ${misdrawn.join('; ')}.`); + throw new Error(`Maka icon has PNG art macOS cannot draw: ${misdrawn.join('; ')}.`); } } diff --git a/scripts/verify-packaged-app.test.mjs b/scripts/verify-packaged-app.test.mjs index 299eb5801e..5d883f8ed3 100644 --- a/scripts/verify-packaged-app.test.mjs +++ b/scripts/verify-packaged-app.test.mjs @@ -22,6 +22,7 @@ import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promise import { tmpdir } from 'node:os'; import { dirname, join, relative } from 'node:path'; import { after, describe, test } from 'node:test'; +import { crc32, deflateSync } from 'node:zlib'; import { createPackage } from '@electron/asar'; import { FileMatcher, @@ -299,22 +300,38 @@ function argbPayload(side) { return Buffer.concat([Buffer.from('ARGB', 'latin1'), ...planes]); } -/** The PNG signature and IHDR of a `width` × `height` image, which is all the check reads. */ -function pngHead(width, height = width) { - const ihdr = Buffer.alloc(25); - ihdr.writeUInt32BE(13, 0); - ihdr.write('IHDR', 4, 'latin1'); - ihdr.writeUInt32BE(width, 8); - ihdr.writeUInt32BE(height, 12); - ihdr.set([8, 6, 0, 0, 0], 16); - return Buffer.concat([PNG_SIGNATURE, ihdr]); +/** One PNG chunk, with its CRC. */ +function pngChunk(type, data) { + const chunk = Buffer.alloc(12 + data.length); + chunk.writeUInt32BE(data.length, 0); + chunk.write(type, 4, 'latin1'); + data.copy(chunk, 8); + chunk.writeUInt32BE(crc32(chunk.subarray(4, 8 + data.length)), 8 + data.length); + return chunk; +} + +/** A real 8-bit RGBA PNG of a `width` × `height` image. `rows` and `filter` + * let a test write fewer scanlines than IHDR declares or an unknown filter. */ +function pngImage(width, height = width, { rows = height, filter = 0 } = {}) { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header.set([8, 6, 0, 0, 0], 8); + const scanline = Buffer.alloc(1 + width * 4, 0x7f); + scanline[0] = filter; + return Buffer.concat([ + PNG_SIGNATURE, + pngChunk('IHDR', header), + pngChunk('IDAT', deflateSync(Buffer.concat(Array.from({ length: rows }, () => scanline)))), + pngChunk('IEND', Buffer.alloc(0)), + ]); } const RENDERABLE_ICNS = icnsWith([ ['ic04', argbPayload(16)], ['ic05', argbPayload(32)], - ['ic07', pngHead(128)], - ['ic13', pngHead(256)], + ['ic07', pngImage(128)], + ['ic13', pngImage(256)], ]); const PTY_PACKAGES = ['@xterm/headless', '@xterm/addon-unicode11']; @@ -467,9 +484,9 @@ describe('assertRenderableAppIcon', () => { const resources = await withIcon( t, icnsWith([ - ['icp4', pngHead(16)], - ['icp5', pngHead(32)], - ['ic07', pngHead(128)], + ['icp4', pngImage(16)], + ['icp5', pngImage(32)], + ['ic07', pngImage(128)], ]), ); await assert.rejects(assertRenderableAppIcon(resources), /16x16 \(icp4\), 32x32 \(icp5\)/); @@ -477,7 +494,7 @@ describe('assertRenderableAppIcon', () => { test('rejects an icon that carries no small sizes at all', async (t) => { const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); - const resources = await withIcon(t, icnsWith([['ic07', pngHead(128)]])); + const resources = await withIcon(t, icnsWith([['ic07', pngImage(128)]])); await assert.rejects(assertRenderableAppIcon(resources), /missing the sizes/); }); @@ -494,6 +511,28 @@ describe('assertRenderableAppIcon', () => { Buffer.concat([whole, Buffer.from([0x00, 0xff])]), // The right planes behind the wrong magic. Buffer.concat([Buffer.from('PNGX', 'latin1'), whole.subarray(4)]), + // 1024 bytes in all, as the review built it, but a run spills four + // bytes of the first plane into the second: planes of 260, 252, 256 + // and 256, which macOS draws with every plane after the first shifted. + Buffer.from([ + ...Buffer.from('ARGB'), + 0xff, + 1, + 0xff, + 1, + 0xff, + 2, + 0xf7, + 2, + 0xff, + 3, + 0xfb, + 3, + 0xff, + 4, + 0xfb, + 4, + ]), ]) { const resources = await withIcon( t, @@ -512,32 +551,68 @@ describe('assertRenderableAppIcon', () => { test('rejects PNG art at the wrong size for its slot', async (t) => { const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); // The retina slots as the toolset electron-builder 26.15.3 pinned wrote - // them, PNG slots whose IHDR is missing or cut short, and art that is - // not square. + // them, and art that is not square. const resources = await withIcon( t, icnsWith([ ['ic04', argbPayload(16)], ['ic05', argbPayload(32)], - ['ic13', pngHead(512)], - ['ic14', pngHead(1024)], - ['ic07', Buffer.concat([PNG_SIGNATURE, Buffer.alloc(17)])], - ['ic08', pngHead(256).subarray(0, 20)], - ['ic11', pngHead(32, 16)], + ['ic13', pngImage(512)], + ['ic14', pngImage(1024)], + ['ic11', pngImage(32, 16)], ]), ); await assert.rejects( assertRenderableAppIcon(resources), new RegExp( - 'ic07 holds no readable size where macOS expects 128x128; ' + - 'ic08 holds no readable size where macOS expects 256x256; ' + - 'ic11 holds 32x16 where macOS expects 32x32; ' + + 'ic11 holds 32x16 where macOS expects 32x32; ' + 'ic13 holds 512x512 where macOS expects 256x256; ' + 'ic14 holds 1024x1024 where macOS expects 512x512\\.', ), ); }); + test('rejects PNG art that does not decode', async (t) => { + const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); + const whole = pngImage(128); + const signatureAndHeader = whole.subarray(0, 33); + const end = pngChunk('IEND', Buffer.alloc(0)); + const badCrc = Buffer.from(whole); + badCrc[29] ^= 0xff; + const overlong = Buffer.from(whole); + overlong.writeUInt32BE(1, whole.length - 12); + for (const ic07 of [ + // A signature and IHDR alone, as the review's fixture was. + signatureAndHeader, + badCrc, + // Bytes after IEND. + Buffer.concat([whole, Buffer.from([0])]), + // A chunk longer than what is left. + overlong, + // IHDR not first, and one byte short. + Buffer.concat([PNG_SIGNATURE, pngChunk('pHYs', Buffer.alloc(9)), whole.subarray(8)]), + Buffer.concat([PNG_SIGNATURE, pngChunk('IHDR', whole.subarray(16, 28)), whole.subarray(33)]), + // IDAT that does not inflate. + Buffer.concat([signatureAndHeader, pngChunk('IDAT', Buffer.from('not deflate')), end]), + // A scanline short, and an unknown row filter. + pngImage(128, 128, { rows: 127 }), + pngImage(128, 128, { filter: 5 }), + ]) { + const resources = await withIcon( + t, + icnsWith([ + ['ic04', argbPayload(16)], + ['ic05', argbPayload(32)], + ['ic07', ic07], + ]), + ); + await assert.rejects( + assertRenderableAppIcon(resources), + /PNG art macOS cannot draw: ic07 does not decode\./, + ); + } + }); + test('refuses to guess at a truncated entry instead of looping on it', async (t) => { const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs'); for (const length of [0, 1_000]) {