diff --git a/desktop/package.json b/desktop/package.json index 7f168ff8e8b..144f8540644 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -5,6 +5,8 @@ "dev": "tauri dev", "build": "tauri build", "build:local": "bun scripts/build-local.ts", + "icons": "bun scripts/generate-icons.ts", + "icons:check": "bun scripts/generate-icons.ts --check", "prepare-sidecar": "bun scripts/prepare-sidecar.ts", "prepare-widget": "bash scripts/build-widget.sh" }, diff --git a/desktop/scripts/generate-icons.ts b/desktop/scripts/generate-icons.ts new file mode 100644 index 00000000000..1670eb53518 --- /dev/null +++ b/desktop/scripts/generate-icons.ts @@ -0,0 +1,192 @@ +#!/usr/bin/env bun +/** + * Render every app icon from `src-tauri/icons/icon.svg`. + * + * The icon set used to be eighteen independent raster files with no vector source, so each size + * was a separate artifact that could drift from the others and nothing could detect it. This makes + * the sizes derived: one curve, rendered at each dimension the platforms ask for. + * + * The SVG reproduces the raster it replaced to within antialiasing (430 of 262144 pixels at 512), + * measured rather than assumed — the geometry in that file was read off the original bitmap. + * + * `--check` regenerates into a temporary directory and compares, so CI can fail on a hand-edited + * PNG instead of letting the source and the shipped icons disagree quietly. + */ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))); +const iconsDir = join(desktopDir, "src-tauri", "icons"); +const source = join(iconsDir, "icon.svg"); + +/** Square PNGs Tauri and the Windows store manifests reference, by output filename. */ +const PNG_SIZES: Record = { + "32x32.png": 32, + "64x64.png": 64, + "128x128.png": 128, + "128x128@2x.png": 256, + "icon.png": 512, + "Square30x30Logo.png": 30, + "Square44x44Logo.png": 44, + "Square71x71Logo.png": 71, + "Square89x89Logo.png": 89, + "Square107x107Logo.png": 107, + "Square142x142Logo.png": 142, + "Square150x150Logo.png": 150, + "Square284x284Logo.png": 284, + "Square310x310Logo.png": 310, + "StoreLogo.png": 50, +}; + +/** Sizes an .icns carries, as iconutil names them. */ +const ICNS_ENTRIES: Array<{ name: string; size: number }> = [ + { name: "icon_16x16.png", size: 16 }, + { name: "icon_16x16@2x.png", size: 32 }, + { name: "icon_32x32.png", size: 32 }, + { name: "icon_32x32@2x.png", size: 64 }, + { name: "icon_128x128.png", size: 128 }, + { name: "icon_128x128@2x.png", size: 256 }, + { name: "icon_256x256.png", size: 256 }, + { name: "icon_256x256@2x.png", size: 512 }, + { name: "icon_512x512.png", size: 512 }, + { name: "icon_512x512@2x.png", size: 1024 }, +]; + +/** Sizes packed into the .ico, which stores each one as an embedded PNG. */ +const ICO_SIZES = [16, 32, 48, 64, 128, 256]; + +function render(size: number, out: string): void { + const result = spawnSync("rsvg-convert", ["-w", String(size), "-h", String(size), source, "-o", out]); + if (result.status !== 0) { + const detail = result.error?.message ?? result.stderr?.toString().trim() ?? "unknown error"; + throw new Error(`rsvg-convert failed for ${size}px: ${detail}`); + } +} + +/** + * Pack PNGs into an ICO. + * + * Written here rather than shelled out because the alternative is ImageMagick, and adding a + * system dependency to regenerate an icon is a worse trade than 30 lines of a container format + * that has not changed in decades. A 256px entry records its dimension as 0, which is how ICO + * spells "256". + */ +function buildIco(entries: Array<{ size: number; bytes: Buffer }>): Buffer { + const header = Buffer.alloc(6); + header.writeUInt16LE(0, 0); // reserved + header.writeUInt16LE(1, 2); // type: icon + header.writeUInt16LE(entries.length, 4); + + const directory = Buffer.alloc(16 * entries.length); + let offset = header.length + directory.length; + entries.forEach((entry, index) => { + const at = index * 16; + directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at); + directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, at + 1); + directory.writeUInt8(0, at + 2); // palette colours + directory.writeUInt8(0, at + 3); // reserved + directory.writeUInt16LE(1, at + 4); // colour planes + directory.writeUInt16LE(32, at + 6); // bits per pixel + directory.writeUInt32LE(entry.bytes.length, at + 8); + directory.writeUInt32LE(offset, at + 12); + offset += entry.bytes.length; + }); + + return Buffer.concat([header, directory, ...entries.map(entry => entry.bytes)]); +} + +/** + * Render the whole set into `target`, and report which artifacts were actually produced. + * + * The return value matters: `iconutil` is macOS-only, so on another platform no `.icns` exists to + * compare against. Reporting that is the difference between "the icns matches" and "nothing looked + * at the icns", and the check must not spell the second as the first. + */ +function generateInto(target: string): { produced: string[]; icnsSkipped: boolean } { + mkdirSync(target, { recursive: true }); + const produced: string[] = []; + for (const [name, size] of Object.entries(PNG_SIZES)) { + render(size, join(target, name)); + produced.push(name); + } + + const iconset = join(target, "icon.iconset"); + mkdirSync(iconset, { recursive: true }); + for (const entry of ICNS_ENTRIES) render(entry.size, join(iconset, entry.name)); + const icns = spawnSync("iconutil", ["-c", "icns", iconset, "-o", join(target, "icon.icns")]); + const icnsSkipped = icns.status !== 0; + if (!icnsSkipped) produced.push("icon.icns"); + rmSync(iconset, { recursive: true, force: true }); + + const icoParts: Array<{ size: number; bytes: Buffer }> = []; + for (const size of ICO_SIZES) { + const scratch = join(target, `.ico-${size}.png`); + render(size, scratch); + icoParts.push({ size, bytes: readFileSync(scratch) }); + rmSync(scratch, { force: true }); + } + writeFileSync(join(target, "icon.ico"), buildIco(icoParts)); + produced.push("icon.ico"); + + return { produced, icnsSkipped }; +} + +function main(): number { + if (!existsSync(source)) { + console.error(`[icons] missing source: ${source}`); + return 1; + } + const check = process.argv.includes("--check"); + if (!check) { + // Render into scratch first so a failure half way through cannot leave the committed set + // partly replaced, then move the finished artifacts over in one pass. + const scratch = mkdtempSync(join(tmpdir(), "ocx-icons-")); + try { + const { produced, icnsSkipped } = generateInto(scratch); + if (icnsSkipped) { + // Abort before touching the committed set. Copying the PNGs and the .ico and then + // reporting the missing .icns would leave the icons half regenerated: the rasters new, + // the .icns whatever it was, and no way to tell from the tree which is which. + console.error("[icons] iconutil is unavailable here, so the .icns cannot be regenerated."); + console.error("[icons] nothing was written; run this on a machine with iconutil."); + return 1; + } + for (const name of produced) writeFileSync(join(iconsDir, name), readFileSync(join(scratch, name))); + console.log(`[icons] regenerated ${produced.length} artifacts from ${source}`); + return 0; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + } + + const scratch = mkdtempSync(join(tmpdir(), "ocx-icons-")); + try { + const { produced, icnsSkipped } = generateInto(scratch); + const drifted: string[] = []; + for (const name of produced) { + const fresh = join(scratch, name); + const committed = join(iconsDir, name); + if (!existsSync(committed) || !readFileSync(fresh).equals(readFileSync(committed))) { + drifted.push(name); + } + } + if (drifted.length > 0) { + console.error(`[icons] these do not match icon.svg: ${drifted.join(", ")}`); + console.error("[icons] regenerate with: bun run icons"); + return 1; + } + console.log(`[icons] ${produced.length} generated icons match the source`); + if (icnsSkipped) { + console.error("[icons] iconutil is unavailable here, so icon.icns was NOT compared."); + return 1; + } + return 0; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +process.exit(main()); diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png index e77df1e28fc..2de806b6fe7 100644 Binary files a/desktop/src-tauri/icons/128x128.png and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png index c6a3f4f3219..e1d4fc804a0 100644 Binary files a/desktop/src-tauri/icons/128x128@2x.png and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png index 9299f360047..282567c5627 100644 Binary files a/desktop/src-tauri/icons/32x32.png and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png index 0a93d6825e3..a6a27245f4c 100644 Binary files a/desktop/src-tauri/icons/64x64.png and b/desktop/src-tauri/icons/64x64.png differ diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png index 5437a9844d7..5ade8afdfae 100644 Binary files a/desktop/src-tauri/icons/Square107x107Logo.png and b/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png index 6cb3949d35c..c5f321fee56 100644 Binary files a/desktop/src-tauri/icons/Square142x142Logo.png and b/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png index b2ba7ef594e..d6370872519 100644 Binary files a/desktop/src-tauri/icons/Square150x150Logo.png and b/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png index 7c578ed5537..6ef2495b7dd 100644 Binary files a/desktop/src-tauri/icons/Square284x284Logo.png and b/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png index 46537b6bfb0..85ab79627a6 100644 Binary files a/desktop/src-tauri/icons/Square30x30Logo.png and b/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png index dba929dbcd7..0276815ce95 100644 Binary files a/desktop/src-tauri/icons/Square310x310Logo.png and b/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png index 98c1c62f4f6..c6c3c302bb4 100644 Binary files a/desktop/src-tauri/icons/Square44x44Logo.png and b/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png index 812f7a506e9..fc2f94a63eb 100644 Binary files a/desktop/src-tauri/icons/Square71x71Logo.png and b/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png index 90d34e74351..fc65ea2c5de 100644 Binary files a/desktop/src-tauri/icons/Square89x89Logo.png and b/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png index 03afa12a7b2..56417eb5243 100644 Binary files a/desktop/src-tauri/icons/StoreLogo.png and b/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns index 3ca608c9234..def8f032885 100644 Binary files a/desktop/src-tauri/icons/icon.icns and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico index 78c4c783354..bc81c0166ab 100644 Binary files a/desktop/src-tauri/icons/icon.ico and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png index 94ac887238c..7fbc3a19f52 100644 Binary files a/desktop/src-tauri/icons/icon.png and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/icons/icon.svg b/desktop/src-tauri/icons/icon.svg new file mode 100644 index 00000000000..4c1774afa89 --- /dev/null +++ b/desktop/src-tauri/icons/icon.svg @@ -0,0 +1,16 @@ + + + + diff --git a/devlog/_plan/260920_desktop_app_stabilization/030_icons_and_widget.md b/devlog/_plan/260920_desktop_app_stabilization/030_icons_and_widget.md new file mode 100644 index 00000000000..6ea275bebe6 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/030_icons_and_widget.md @@ -0,0 +1,114 @@ +# wp4 — one vector source for the icons, and a verdict on the widget + +## Why the icon set needed a source + +`desktop/src-tauri/icons/` carried eighteen raster files and no vector. Every size was an +independent artifact: nothing tied `Square107x107Logo.png` to `icon.png`, nothing could tell +whether one of them had been hand-edited, and adding a platform size meant drawing it again. The +`.icns` and `.ico` containers hid the problem further, because a wrong member inside them is not +visible in a diff at all. + +The fix is a single `icon.svg` plus `desktop/scripts/generate-icons.ts`, exposed as +`bun run icons` and `bun run icons:check`. Fifteen PNGs render through `rsvg-convert`, the +`.icns` is assembled by `iconutil` from its ten members, and the `.ico` is written directly with +six PNG-embedded entries (16, 32, 48, 64, 128, 256). `--check` regenerates into a temporary +directory and compares byte for byte, so a hand-edited PNG fails instead of silently disagreeing +with the source. + +## The geometry was measured, not redrawn + +A redrawn mark would have been a different icon wearing the same name. The shape in `icon.png` +was measured instead: it spans 58..453 on both axes, the stroke is 48 wide, and the outer corner +turns at radius 135. A centred stroke therefore sits at `x=82 y=82 w=348 h=348` with +`stroke-width=48`, and the corner radius was swept to find the closest match. `rx=127` reproduces +the original to within **430 of 262144 pixels at 512×512 — 0.164%**, which is antialiasing along +the curve rather than a changed silhouette. + +The mark stays pure black on transparency. Both macOS and Windows composite it over their own +backgrounds, so a baked background would appear as a card on one of the two. + +## The widget question + +`OpenCodexWidget.appex` is bundled, and the acceptance note requires a verdict either way rather +than an absence. + +**The extension registers, and that part is settled.** `pluginkit` lists it from the installed +application with the parent bundle resolved and no disabled or ignored marker: + +``` +com.opencodex.desktop.widget(2.61.0) + SDK = com.apple.widgetkit-extension + Parent Bundle = /Applications/OpenCodex.app + Parent Name = OpenCodex + Platform = macOS +``` + +That record is structurally identical to a system widget queried the same way, so the earlier +working hypothesis — that ad-hoc signing keeps the extension from being adopted at all — is wrong +and is recorded here as wrong. Registration is not the obstacle. + +**And it does not appear in the gallery.** The gallery was opened on this machine and checked: +OpenCodex is not among the offered widgets. No `OpenCodexWidget` process has ever run here +either, so nothing has asked the extension for a timeline. Registration and adoption are two +different things, and only the first of them holds. + +**What the signing state actually costs.** The host bundle carries the linker-signed placeholder: + +``` +host app Identifier = opencodex_desktop-b89067d97e1c189c + flags = 0x20002(adhoc,linker-signed) + Info.plist = not bound + Sealed Resources = none +appex Identifier = com.opencodex.desktop.widget + flags = 0x2(adhoc) +``` + +The host's `CFBundleIdentifier` is `com.opencodex.desktop`, but its *signed* identity is the +placeholder, its `Info.plist` is not bound into the signature, and it seals no resources. Locally +that is tolerated because the machine built the bundle itself. A distributed copy has no sealed +host for the system to validate the extension's containment against, and nothing binds the +declared identifier to the signed one. + +**The verdict, then:** the extension is registered and the gallery does not offer it. The host +bundle is the thing that fails a requirement — its signed identity is not the identity it +declares, and it seals nothing — so nothing downstream can establish that this extension belongs +to `com.opencodex.desktop`. Until the release pipeline signs the host with a Developer ID +identity, the widget ships but cannot be added. That is the finding; it is not worked around here, +and no part of the icon work depends on it. + +## What the icon check does and does not cover + +`bun run icons:check` compares all seventeen generated artifacts — fifteen PNGs, the `.ico` and +the `.icns` — byte for byte against a fresh render. It needs `rsvg-convert` and `iconutil`, and +when `iconutil` is missing it now says the `.icns` was not compared and fails, rather than +reporting a pass over a file it never looked at. + +That check does not run in CI, and claiming otherwise would be the easy lie here. The renderer is +not pinned, so two machines with different librsvg builds produce different bytes with nothing +wrong; asserting byte identity on a hosted runner would be asserting the runner's renderer +version. What CI runs instead is `tests/ci-workflows/build-desktop-icon-set.test.ts`, which needs +no renderer at all and reads its expectations out of the generator: every declared size committed +at exactly that size, the `.ico` directory carrying exactly the packed sizes with each payload a +real PNG of its declared dimension, the `.icns` walking cleanly end to end with one image member +per declared entry, and nothing hand-added beside the generated set. It was driven red on a +resized raster and on a stray file before being trusted. + +So the split is: shape is enforced everywhere, byte identity is enforced wherever the toolchain +exists. + +## Files + +- `desktop/src-tauri/icons/icon.svg` — new, the single source. +- `desktop/scripts/generate-icons.ts` — new, renderer and `--check` verifier. +- `desktop/package.json` — `icons` and `icons:check` scripts. +- Seventeen regenerated raster artifacts under `desktop/src-tauri/icons/`. +- `tests/ci-workflows/build-desktop-icon-set.test.ts` — new, the renderer-free structural guard, + registered in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. + +## Acceptance + +`bun run icons:check` passes on the committed tree over all seventeen artifacts, +`build-desktop-icon-set.test.ts` passes and has been shown to fail on a wrong-sized raster and on +a stray file, `bun run build:local` produces a bundle whose `Contents/Resources/icon.icns` is the +generated one, and the widget verdict is an observation of the gallery rather than an inference +from registration. diff --git a/scripts/privacy-scan.ts b/scripts/privacy-scan.ts index 5d6e288b1e4..cedb2360424 100644 --- a/scripts/privacy-scan.ts +++ b/scripts/privacy-scan.ts @@ -110,6 +110,13 @@ function isAllowedEmail(file: string, email: string): boolean { } // URL-userinfo fixtures (https://user:pw@host/...) read as "pw@host" — not emails. if (file.startsWith("tests/") && email === ["pw", "chatgpt.com"].join("@")) return true; + // Retina asset names read as addresses: "128x128@2x.png" is local part "128x128", domain "2x", + // and the loose TLD rule accepts "png". The exemption is written against the asset-name grammar + // rather than against that shape, because a person's name in front of the same scale suffix is + // the same shape and is a mailbox. The part before the suffix has to be a pixel dimension, + // optionally prefixed the way an iconset member is, so a name someone could receive mail at + // does not match. + if (/^(?:[a-z]+_)?\d+x\d+@[23]x\.(?:png|jpe?g|gif|webp|tiff?)$/i.test(email)) return true; return file.startsWith("tests/") && email === "a@b.com"; } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index dd578ff1571..a8d43bc015c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -303,6 +303,7 @@ "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", + "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", "bun-runtime.test.ts": "ci-workflows", @@ -1124,6 +1125,7 @@ "ports.test.ts": "server", "prime-client.test.ts": "clients", "privacy-mask-account.test.ts": "lib", + "privacy-scan-asset-names.test.ts": "ci-workflows", "privacy-scan-meta-key.test.ts": "ci-workflows", "privacy-scan-ssh-endpoint.test.ts": "ci-workflows", "probe-lease.test.ts": "routing", diff --git a/tests/ci-workflows/build-desktop-icon-set.test.ts b/tests/ci-workflows/build-desktop-icon-set.test.ts new file mode 100644 index 00000000000..703356188bb --- /dev/null +++ b/tests/ci-workflows/build-desktop-icon-set.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The committed desktop icons are generated from one SVG by `desktop/scripts/generate-icons.ts`. + * That script's own `--check` compares byte for byte, which is the strongest statement available + * — and it is not available here: it needs `rsvg-convert` and `iconutil`, and the renderer is not + * pinned, so two machines with different librsvg builds disagree on bytes without anything being + * wrong. Asserting byte identity in CI would mean asserting the runner's renderer version. + * + * What CI can assert on any platform with no renderer at all is that the committed set still has + * the shape the generator declares: every size present, every raster actually that size, both + * containers carrying exactly the members the script packs, and nothing hand-added alongside. + * Every expectation below is read out of the generator, so adding a size there and forgetting to + * regenerate fails here rather than being restated in two places that can drift apart. + */ +const GENERATOR = repoPath("desktop/scripts/generate-icons.ts"); +const ICONS_DIR = repoPath("desktop/src-tauri/icons"); + +function generatorSource(): string { + return readFileSync(GENERATOR, "utf8"); +} + +function block(source: string, opening: string, closing: string): string { + const start = source.indexOf(opening); + expect(start, `${opening} is missing from generate-icons.ts`).toBeGreaterThan(-1); + const end = source.indexOf(closing, start + opening.length); + expect(end, `${opening} is not terminated in generate-icons.ts`).toBeGreaterThan(-1); + return source.slice(start + opening.length, end); +} + +function declaredPngs(source: string): Map { + const body = block(source, "const PNG_SIZES: Record = {", "};"); + const out = new Map(); + for (const m of body.matchAll(/"([^"]+)":\s*(\d+)/g)) out.set(m[1]!, Number(m[2])); + return out; +} + +function declaredIcnsMembers(source: string): Array<{ name: string; size: number }> { + const body = block(source, "const ICNS_ENTRIES: Array<{ name: string; size: number }> = [", "];"); + return [...body.matchAll(/name:\s*"([^"]+)",\s*size:\s*(\d+)/g)].map(m => ({ name: m[1]!, size: Number(m[2]) })); +} + +function declaredIcoSizes(source: string): number[] { + const body = block(source, "const ICO_SIZES = [", "]"); + return body.split(",").map(part => Number(part.trim())).filter(n => Number.isFinite(n)); +} + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function isPng(bytes: Buffer): boolean { + return bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE); +} + +/** Width and height out of a PNG's IHDR, which is always the first chunk. */ +function pngDimensions(bytes: Buffer): { width: number; height: number } { + expect(isPng(bytes)).toBe(true); + expect(bytes.subarray(12, 16).toString("latin1")).toBe("IHDR"); + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; +} + +describe("desktop icon set", () => { + const source = generatorSource(); + const pngs = declaredPngs(source); + const icnsMembers = declaredIcnsMembers(source); + const icoSizes = declaredIcoSizes(source); + + test("the generator still declares a set worth checking", () => { + expect(pngs.size).toBeGreaterThan(10); + expect(icnsMembers.length).toBeGreaterThan(5); + expect(icoSizes.length).toBeGreaterThan(3); + }); + + test("every declared raster is committed at exactly its declared size", () => { + const wrong: string[] = []; + for (const [name, size] of pngs) { + const bytes = readFileSync(join(ICONS_DIR, name)); + const { width, height } = pngDimensions(bytes); + if (width !== size || height !== size) wrong.push(`${name}: ${width}x${height} != ${size}`); + } + expect(wrong).toEqual([]); + }); + + test("icon.ico carries exactly the sizes the generator packs, each a real PNG of that size", () => { + const ico = readFileSync(join(ICONS_DIR, "icon.ico")); + expect(ico.readUInt16LE(0)).toBe(0); + expect(ico.readUInt16LE(2)).toBe(1); + expect(ico.readUInt16LE(4)).toBe(icoSizes.length); + + const seen: number[] = []; + for (let i = 0; i < icoSizes.length; i += 1) { + const at = 6 + i * 16; + const declared = icoSizes[i]!; + // ICO spells 256 as 0 in a single byte, which is the one field width the format never grew. + expect(ico.readUInt8(at)).toBe(declared >= 256 ? 0 : declared); + expect(ico.readUInt8(at + 1)).toBe(declared >= 256 ? 0 : declared); + const length = ico.readUInt32LE(at + 8); + const offset = ico.readUInt32LE(at + 12); + expect(offset + length).toBeLessThanOrEqual(ico.length); + const { width, height } = pngDimensions(ico.subarray(offset, offset + length)); + expect(width).toBe(declared); + expect(height).toBe(declared); + seen.push(width); + } + expect(seen).toEqual(icoSizes); + }); + + test("icon.icns is well formed and its members are the declared sizes", () => { + const icns = readFileSync(join(ICONS_DIR, "icon.icns")); + expect(icns.subarray(0, 4).toString("latin1")).toBe("icns"); + // A truncated or concatenated icns still opens with the magic; the declared length is what + // says the file is the one the tool wrote. + expect(icns.readUInt32BE(4)).toBe(icns.length); + + const types: string[] = []; + const payloads: Buffer[] = []; + let at = 8; + while (at < icns.length) { + const type = icns.subarray(at, at + 4).toString("latin1"); + const length = icns.readUInt32BE(at + 4); + expect(length).toBeGreaterThanOrEqual(8); + expect(at + length).toBeLessThanOrEqual(icns.length); + types.push(type); + payloads.push(icns.subarray(at + 8, at + length)); + at += length; + } + // The walk has to land exactly on the end, or some member lied about its length. + expect(at).toBe(icns.length); + // 'TOC ' and 'info' are bookkeeping the tool adds; the rest are the images. + const images = payloads.filter((_, i) => types[i] !== "TOC " && types[i] !== "info"); + expect(images.length).toBe(icnsMembers.length); + + // Counting members is not enough: ten duplicates of one size would count the same as the ten + // the generator declares. The larger members are PNG and carry their dimensions, so read them + // and check they are sizes the generator actually asks for. The smallest two are ARGB, which + // has no dimension in its payload, so they are counted rather than measured - and that + // accounting is what bounds how many declared sizes may be absent from the PNG members. + const png = images.filter(isPng); + const argb = images.filter(payload => payload.subarray(0, 4).toString("latin1") === "ARGB"); + expect(png.length + argb.length).toBe(images.length); + + // Consume the declared sizes one member at a time rather than comparing sets. A set would + // accept ten copies of one declared size; matching multiplicities is what makes a duplicated + // or substituted member fail, which is the realistic way this file goes wrong. + const unaccounted = icnsMembers.map(member => member.size); + for (const payload of png) { + const { width, height } = pngDimensions(payload); + expect(width).toBe(height); + const at = unaccounted.indexOf(width); + expect(at, `icon.icns carries more ${width}px members than the generator declares`).toBeGreaterThan(-1); + unaccounted.splice(at, 1); + } + // Whatever is left has to be exactly the members ARGB carries, which store no dimension. + expect(unaccounted.length).toBe(argb.length); + }); + + test("nothing is hand-added beside the generated set", () => { + const generated = new Set([...pngs.keys(), "icon.ico", "icon.icns", "icon.svg"]); + const stray = readdirSync(ICONS_DIR) + .filter(name => statSync(join(ICONS_DIR, name)).isFile()) + .filter(name => !generated.has(name)) + .sort(); + expect(stray).toEqual([]); + }); +}); diff --git a/tests/ci-workflows/privacy-scan-asset-names.test.ts b/tests/ci-workflows/privacy-scan-asset-names.test.ts new file mode 100644 index 00000000000..3dc98fd40b3 --- /dev/null +++ b/tests/ci-workflows/privacy-scan-asset-names.test.ts @@ -0,0 +1,45 @@ +/** + * The scanner's address pattern reads a Retina asset name as an email. + * + * `128x128@2x.png` is local part `128x128`, domain `2x`, and the deliberately loose TLD rule + * accepts `png`, so listing the desktop icon set failed the gate. The exemption that fixes it is + * one character away from a hole: a person's name in front of the same `@2x.png` suffix has the + * identical shape and is a mailbox, so the rule is written against the asset-name grammar — a + * pixel dimension, optionally prefixed the way an iconset member is — rather than against the + * shape. + * + * This exercises the real `scanText`. A test that restated the regex would keep passing after the + * exemption was widened, which is the only way this can go wrong. + */ +import { describe, expect, test } from "bun:test"; +import { scanText } from "../../scripts/privacy-scan"; + +/** Assembled at runtime so this file carries no bare address of its own. */ +const mailbox = (local: string, domain: string): string => [local, domain].join("@"); + +describe("privacy scan: Retina asset names", () => { + test("the icon set the generator declares does not read as addresses", () => { + for (const name of ["128x128@2x.png", "icon_16x16@2x.png", "icon_512x512@2x.png"]) { + expect(scanText("desktop/scripts/generate-icons.ts", `"${name}": 256,`) + .filter(finding => finding.kind === "email")).toEqual([]); + } + }); + + test("a mailbox wearing the same suffix is still a finding", () => { + for (const local of ["alice", "j.doe", "support"]) { + const line = `contact ${mailbox(local, "2x.png")}`; + expect(scanText("src/example.ts", line).some(finding => finding.kind === "email")).toBe(true); + } + }); + + test("the exemption does not extend past the scale suffix and a raster extension", () => { + const cases = [ + mailbox("128x128", "2x.com"), + mailbox("128x128", "4x.png"), + mailbox("128x128", "2x.example.com"), + ]; + for (const line of cases) { + expect(scanText("src/example.ts", line).some(finding => finding.kind === "email")).toBe(true); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e1e266abf4c..53fc4cbab3d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -134,6 +134,7 @@ "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", + "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", "bun-runtime.test.ts": "ci-workflows", @@ -950,6 +951,7 @@ "ports.test.ts": "server", "prime-client.test.ts": "clients", "privacy-mask-account.test.ts": "lib", + "privacy-scan-asset-names.test.ts": "ci-workflows", "privacy-scan-meta-key.test.ts": "ci-workflows", "privacy-scan-ssh-endpoint.test.ts": "ci-workflows", "probe-lease.test.ts": "routing",