diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3c3f82937..446b5b4d1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -79,6 +79,45 @@ jobs: - name: Build Windows Store package run: npm run build:win:store -- --publish never + # Store certification rejected 1.9.0 under 10.1.1.11 "On Device Tiles" because + # the package carried electron-builder's vendored placeholder tiles: it reads + # them from build/appx/ and, when a name is missing there, silently substitutes + # a blank SampleAppx.*.png instead of failing. Nothing in the build output says + # so — the only way to know is to look inside the package. This step does that, + # comparing every committed asset against the one actually packaged, so a + # renamed, dropped or substituted tile fails the build instead of failing + # certification days later. + - name: Verify Store tiles are in the package + shell: pwsh + run: | + $appx = Get-ChildItem release -Recurse -Filter *.appx | Select-Object -First 1 + if (-not $appx) { throw "no .appx found under release/" } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($appx.FullName) + try { + # OPC parts are "/" separated, but normalise anyway rather than trust it. + $entries = @{} + foreach ($e in $archive.Entries) { $entries[$e.FullName.Replace("\", "/")] = $e } + $sha = [System.Security.Cryptography.SHA256]::Create() + $problems = @() + $expected = Get-ChildItem build/appx -Filter *.png + foreach ($asset in $expected) { + $entry = $entries["assets/$($asset.Name)"] + if (-not $entry) { $problems += "missing from package: $($asset.Name)"; continue } + $stream = $entry.Open() + try { $packaged = [BitConverter]::ToString($sha.ComputeHash($stream)) } + finally { $stream.Dispose() } + $source = [BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($asset.FullName))) + if ($packaged -ne $source) { $problems += "packaged copy differs from build/appx: $($asset.Name)" } + } + if ($problems) { + $problems | ForEach-Object { Write-Output "::error::$_" } + throw "$($problems.Count) tile asset problem(s) in $($appx.Name)" + } + Write-Output "$($expected.Count) tile assets present in $($appx.Name), byte-identical to build/appx/" + } + finally { $archive.Dispose() } + - name: Upload Windows Store package uses: actions/upload-artifact@v4 with: diff --git a/build/appx/LargeTile.png b/build/appx/LargeTile.png new file mode 100644 index 000000000..955002ca0 Binary files /dev/null and b/build/appx/LargeTile.png differ diff --git a/build/appx/LargeTile.scale-125.png b/build/appx/LargeTile.scale-125.png new file mode 100644 index 000000000..a524b9892 Binary files /dev/null and b/build/appx/LargeTile.scale-125.png differ diff --git a/build/appx/LargeTile.scale-150.png b/build/appx/LargeTile.scale-150.png new file mode 100644 index 000000000..1d9daabcd Binary files /dev/null and b/build/appx/LargeTile.scale-150.png differ diff --git a/build/appx/LargeTile.scale-200.png b/build/appx/LargeTile.scale-200.png new file mode 100644 index 000000000..2be9fdbd0 Binary files /dev/null and b/build/appx/LargeTile.scale-200.png differ diff --git a/build/appx/SmallTile.png b/build/appx/SmallTile.png new file mode 100644 index 000000000..84db5c43a Binary files /dev/null and b/build/appx/SmallTile.png differ diff --git a/build/appx/SmallTile.scale-125.png b/build/appx/SmallTile.scale-125.png new file mode 100644 index 000000000..84a4dbc1b Binary files /dev/null and b/build/appx/SmallTile.scale-125.png differ diff --git a/build/appx/SmallTile.scale-150.png b/build/appx/SmallTile.scale-150.png new file mode 100644 index 000000000..826beb19a Binary files /dev/null and b/build/appx/SmallTile.scale-150.png differ diff --git a/build/appx/SmallTile.scale-200.png b/build/appx/SmallTile.scale-200.png new file mode 100644 index 000000000..392a55e14 Binary files /dev/null and b/build/appx/SmallTile.scale-200.png differ diff --git a/build/appx/SmallTile.scale-400.png b/build/appx/SmallTile.scale-400.png new file mode 100644 index 000000000..612647a6a Binary files /dev/null and b/build/appx/SmallTile.scale-400.png differ diff --git a/build/appx/SplashScreen.png b/build/appx/SplashScreen.png new file mode 100644 index 000000000..09d61e199 Binary files /dev/null and b/build/appx/SplashScreen.png differ diff --git a/build/appx/SplashScreen.scale-125.png b/build/appx/SplashScreen.scale-125.png new file mode 100644 index 000000000..cf2f3eabd Binary files /dev/null and b/build/appx/SplashScreen.scale-125.png differ diff --git a/build/appx/SplashScreen.scale-150.png b/build/appx/SplashScreen.scale-150.png new file mode 100644 index 000000000..cb7e947b9 Binary files /dev/null and b/build/appx/SplashScreen.scale-150.png differ diff --git a/build/appx/SplashScreen.scale-200.png b/build/appx/SplashScreen.scale-200.png new file mode 100644 index 000000000..bab81848a Binary files /dev/null and b/build/appx/SplashScreen.scale-200.png differ diff --git a/build/appx/Square150x150Logo.png b/build/appx/Square150x150Logo.png new file mode 100644 index 000000000..5286d619c Binary files /dev/null and b/build/appx/Square150x150Logo.png differ diff --git a/build/appx/Square150x150Logo.scale-125.png b/build/appx/Square150x150Logo.scale-125.png new file mode 100644 index 000000000..629b30cc3 Binary files /dev/null and b/build/appx/Square150x150Logo.scale-125.png differ diff --git a/build/appx/Square150x150Logo.scale-150.png b/build/appx/Square150x150Logo.scale-150.png new file mode 100644 index 000000000..c958fef46 Binary files /dev/null and b/build/appx/Square150x150Logo.scale-150.png differ diff --git a/build/appx/Square150x150Logo.scale-200.png b/build/appx/Square150x150Logo.scale-200.png new file mode 100644 index 000000000..b971b4b68 Binary files /dev/null and b/build/appx/Square150x150Logo.scale-200.png differ diff --git a/build/appx/Square44x44Logo.png b/build/appx/Square44x44Logo.png new file mode 100644 index 000000000..80e4c7ba5 Binary files /dev/null and b/build/appx/Square44x44Logo.png differ diff --git a/build/appx/Square44x44Logo.scale-125.png b/build/appx/Square44x44Logo.scale-125.png new file mode 100644 index 000000000..373454aed Binary files /dev/null and b/build/appx/Square44x44Logo.scale-125.png differ diff --git a/build/appx/Square44x44Logo.scale-150.png b/build/appx/Square44x44Logo.scale-150.png new file mode 100644 index 000000000..7eb10b838 Binary files /dev/null and b/build/appx/Square44x44Logo.scale-150.png differ diff --git a/build/appx/Square44x44Logo.scale-200.png b/build/appx/Square44x44Logo.scale-200.png new file mode 100644 index 000000000..956978a5a Binary files /dev/null and b/build/appx/Square44x44Logo.scale-200.png differ diff --git a/build/appx/Square44x44Logo.scale-400.png b/build/appx/Square44x44Logo.scale-400.png new file mode 100644 index 000000000..4eba02881 Binary files /dev/null and b/build/appx/Square44x44Logo.scale-400.png differ diff --git a/build/appx/Square44x44Logo.targetsize-16.png b/build/appx/Square44x44Logo.targetsize-16.png new file mode 100644 index 000000000..f925fba2f Binary files /dev/null and b/build/appx/Square44x44Logo.targetsize-16.png differ diff --git a/build/appx/Square44x44Logo.targetsize-16_altform-unplated.png b/build/appx/Square44x44Logo.targetsize-16_altform-unplated.png new file mode 100644 index 000000000..f925fba2f Binary files /dev/null and b/build/appx/Square44x44Logo.targetsize-16_altform-unplated.png differ diff --git a/build/appx/Square44x44Logo.targetsize-24.png b/build/appx/Square44x44Logo.targetsize-24.png new file mode 100644 index 000000000..2f8de2de5 Binary files /dev/null and b/build/appx/Square44x44Logo.targetsize-24.png differ diff --git a/build/appx/Square44x44Logo.targetsize-24_altform-unplated.png b/build/appx/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 000000000..2f8de2de5 Binary files /dev/null and b/build/appx/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/build/appx/Square44x44Logo.targetsize-256.png b/build/appx/Square44x44Logo.targetsize-256.png new file mode 100644 index 000000000..715df394a Binary files /dev/null and b/build/appx/Square44x44Logo.targetsize-256.png differ diff --git a/build/appx/Square44x44Logo.targetsize-256_altform-unplated.png b/build/appx/Square44x44Logo.targetsize-256_altform-unplated.png new file mode 100644 index 000000000..715df394a Binary files /dev/null and b/build/appx/Square44x44Logo.targetsize-256_altform-unplated.png differ diff --git a/build/appx/Square44x44Logo.targetsize-32.png b/build/appx/Square44x44Logo.targetsize-32.png new file mode 100644 index 000000000..f7988b76f Binary files /dev/null and b/build/appx/Square44x44Logo.targetsize-32.png differ diff --git a/build/appx/Square44x44Logo.targetsize-32_altform-unplated.png b/build/appx/Square44x44Logo.targetsize-32_altform-unplated.png new file mode 100644 index 000000000..f7988b76f Binary files /dev/null and b/build/appx/Square44x44Logo.targetsize-32_altform-unplated.png differ diff --git a/build/appx/Square44x44Logo.targetsize-48.png b/build/appx/Square44x44Logo.targetsize-48.png new file mode 100644 index 000000000..4ee2a85a7 Binary files /dev/null and b/build/appx/Square44x44Logo.targetsize-48.png differ diff --git a/build/appx/Square44x44Logo.targetsize-48_altform-unplated.png b/build/appx/Square44x44Logo.targetsize-48_altform-unplated.png new file mode 100644 index 000000000..4ee2a85a7 Binary files /dev/null and b/build/appx/Square44x44Logo.targetsize-48_altform-unplated.png differ diff --git a/build/appx/StoreLogo.png b/build/appx/StoreLogo.png new file mode 100644 index 000000000..2607fb708 Binary files /dev/null and b/build/appx/StoreLogo.png differ diff --git a/build/appx/StoreLogo.scale-125.png b/build/appx/StoreLogo.scale-125.png new file mode 100644 index 000000000..0cb0f4d48 Binary files /dev/null and b/build/appx/StoreLogo.scale-125.png differ diff --git a/build/appx/StoreLogo.scale-150.png b/build/appx/StoreLogo.scale-150.png new file mode 100644 index 000000000..17b9b7946 Binary files /dev/null and b/build/appx/StoreLogo.scale-150.png differ diff --git a/build/appx/StoreLogo.scale-200.png b/build/appx/StoreLogo.scale-200.png new file mode 100644 index 000000000..14f279e62 Binary files /dev/null and b/build/appx/StoreLogo.scale-200.png differ diff --git a/build/appx/StoreLogo.scale-400.png b/build/appx/StoreLogo.scale-400.png new file mode 100644 index 000000000..b3983f532 Binary files /dev/null and b/build/appx/StoreLogo.scale-400.png differ diff --git a/build/appx/Wide310x150Logo.png b/build/appx/Wide310x150Logo.png new file mode 100644 index 000000000..774e4edba Binary files /dev/null and b/build/appx/Wide310x150Logo.png differ diff --git a/build/appx/Wide310x150Logo.scale-125.png b/build/appx/Wide310x150Logo.scale-125.png new file mode 100644 index 000000000..2c84739da Binary files /dev/null and b/build/appx/Wide310x150Logo.scale-125.png differ diff --git a/build/appx/Wide310x150Logo.scale-150.png b/build/appx/Wide310x150Logo.scale-150.png new file mode 100644 index 000000000..f8653be10 Binary files /dev/null and b/build/appx/Wide310x150Logo.scale-150.png differ diff --git a/build/appx/Wide310x150Logo.scale-200.png b/build/appx/Wide310x150Logo.scale-200.png new file mode 100644 index 000000000..4c148fb1d Binary files /dev/null and b/build/appx/Wide310x150Logo.scale-200.png differ diff --git a/electron-builder.json5 b/electron-builder.json5 index 13c1cb2c5..2db884032 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -22,7 +22,14 @@ "buildDependenciesFromSource": false, "compression": "normal", "directories": { - "output": "release/${version}" + "output": "release/${version}", + // Electron-builder's default, spelled out because the appx target depends on it: + // it looks for Store tile assets in `/appx/` and, when that + // directory is absent, silently packages its own vendored placeholders instead + // (winCodeSign's SampleAppx.*.png — blank white squares). Shipping those failed + // Store certification 10.1.1.11 "On Device Tiles". `build/appx/` is committed and + // regenerated by `npm run assets:appx`. + "buildResources": "build" }, "files": [ "dist", diff --git a/package.json b/package.json index 42d2f41eb..8b2065643 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "format": "biome format --write .", "i18n:check": "node scripts/i18n-check.mjs", "docs:check": "node scripts/check-docs.mjs", + "assets:appx": "node scripts/generate-appx-assets.mjs", "preview": "vite preview", "build:native:mac": "node scripts/build-macos-screencapturekit-helper.mjs", "build:mac": "npm run build:native:mac && npm run fetch:ffmpeg:mac && npm run build:native:compositor:mac && tsc && vite build && electron-builder --mac", diff --git a/scripts/generate-appx-assets.mjs b/scripts/generate-appx-assets.mjs new file mode 100644 index 000000000..b3e96c93c --- /dev/null +++ b/scripts/generate-appx-assets.mjs @@ -0,0 +1,338 @@ +// Generates the Microsoft Store (AppX/MSIX) tile assets into build/appx/. +// +// Why this exists: electron-builder only ships BRANDED tiles if it finds them in +// `/appx/` (AppXTarget.computeUserAssets -> packager.getResource(undefined, "appx")). +// With that directory missing it silently falls back to its vendored placeholders +// — SampleAppx.50x50.png, SampleAppx.150x150.png, SampleAppx.44x44.png, +// SampleAppx.310x150.png — and ships an Electron-generic tile. That is exactly what +// Store certification rejected under 10.1.1.11 "On Device Tiles": +// "The available product tile icons include a default image." +// So build/appx/ is committed to the repo; this script only regenerates it when the +// app icon changes. No build step depends on it. +// +// Why hand-rolled PNG I/O: the project has no image library in its dependency tree +// (no sharp, no jimp), and adding one — with prebuilt native binaries — to draw seven +// static logos would cost far more than the ~150 lines below. Node's zlib does the +// only hard part. +// +// Layout follows Microsoft's tile guidance: the icon sits on a TRANSPARENT canvas with +// padding rather than bleeding to the edges, because `appx.backgroundColor` is +// "transparent" in electron-builder.json5, so Windows paints the tile in the user's +// accent colour behind these assets. The small assets (store logo, 44x44 app-list icon) +// are full-bleed instead: Windows plates and crops those itself. +// +// Run: node scripts/generate-appx-assets.mjs +// Verify: the generated names must keep matching electron-builder's expectations — +// StoreLogo / Square150x150Logo / Square44x44Logo / Wide310x150Logo are the four it +// substitutes placeholders for, and SmallTile / LargeTile / SplashScreen are opt-in +// (their manifest attributes only appear when the file is present). + +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { deflateSync, inflateSync } from "node:zlib"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SOURCE_ICON = path.join(ROOT, "icons", "icons", "png", "1024x1024.png"); +const OUT_DIR = path.join(ROOT, "build", "appx"); + +/** + * One entry per logical asset. + * + * `fill` is the icon's edge length as a fraction of the canvas's SHORT side, and + * `shiftY` nudges it up as a fraction of canvas height — the tiles that carry + * `` (150x150 and the wide tile, per showNameOnTiles in + * electron-builder.json5) get the product name drawn across their bottom band, and + * a dead-centred logo sits under that text. + * + * `scales` are the MRT scale qualifiers emitted next to the unqualified 100% file. + * 400% is only worth its bytes on the assets that stay small on screen; the big tiles + * stop at 200%. Every variant is a DOWNSCALE of the 1024px master — nothing here + * upsamples. + */ +const ASSETS = [ + { name: "StoreLogo", width: 50, height: 50, fill: 1, scales: [125, 150, 200, 400] }, + { name: "Square44x44Logo", width: 44, height: 44, fill: 1, scales: [125, 150, 200, 400] }, + { name: "SmallTile", width: 71, height: 71, fill: 0.7, scales: [125, 150, 200, 400] }, + { + name: "Square150x150Logo", + width: 150, + height: 150, + fill: 0.66, + shiftY: -0.08, + scales: [125, 150, 200], + }, + { + name: "Wide310x150Logo", + width: 310, + height: 150, + fill: 0.6, + shiftY: -0.08, + scales: [125, 150, 200], + }, + { name: "LargeTile", width: 310, height: 310, fill: 0.55, scales: [125, 150, 200] }, + { name: "SplashScreen", width: 620, height: 300, fill: 0.6, scales: [125, 150, 200] }, +]; + +/** + * Square44x44Logo is also consumed by target size rather than by scale: the taskbar, + * task-view and Start's app list ask for an exact pixel size. `altform-unplated` is the + * variant Windows uses where it does NOT draw its accent-coloured plate behind the icon + * (taskbar, title bar) — same art, different qualifier. + */ +const TARGET_SIZES = [16, 24, 32, 48, 256]; + +// --- PNG decoding ----------------------------------------------------------------- + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c; + } + return table; +})(); + +function crc32(buffer) { + let c = -1; + for (let i = 0; i < buffer.length; i++) c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8); + return (c ^ -1) >>> 0; +} + +function paethPredictor(a, b, c) { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + if (pa <= pb && pa <= pc) return a; + return pb <= pc ? b : c; +} + +/** Decodes an 8-bit RGBA, non-interlaced PNG into flat RGBA bytes. */ +function decodePng(buffer) { + if (!buffer.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error("not a PNG"); + + let width = 0; + let height = 0; + const idat = []; + let offset = 8; + while (offset < buffer.length) { + const length = buffer.readUInt32BE(offset); + const type = buffer.toString("ascii", offset + 4, offset + 8); + const body = buffer.subarray(offset + 8, offset + 8 + length); + if (type === "IHDR") { + width = body.readUInt32BE(0); + height = body.readUInt32BE(4); + // The master icon is committed as RGBA8; anything else would need a converter + // this script deliberately does not carry. + if (body[8] !== 8 || body[9] !== 6 || body[12] !== 0) { + throw new Error( + `${SOURCE_ICON}: expected 8-bit RGBA non-interlaced, got depth=${body[8]} colorType=${body[9]} interlace=${body[12]}`, + ); + } + } else if (type === "IDAT") { + idat.push(body); + } else if (type === "IEND") { + break; + } + offset += 12 + length; + } + + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * 4; + const pixels = new Uint8ClampedArray(width * height * 4); + let pos = 0; + for (let y = 0; y < height; y++) { + const filter = raw[pos++]; + const row = raw.subarray(pos, pos + stride); + pos += stride; + const out = y * stride; + const prev = out - stride; + for (let x = 0; x < stride; x++) { + const left = x >= 4 ? pixels[out + x - 4] : 0; + const up = y > 0 ? pixels[prev + x] : 0; + const upLeft = y > 0 && x >= 4 ? pixels[prev + x - 4] : 0; + let value = row[x]; + if (filter === 1) value += left; + else if (filter === 2) value += up; + else if (filter === 3) value += (left + up) >> 1; + else if (filter === 4) value += paethPredictor(left, up, upLeft); + else if (filter !== 0) throw new Error(`unsupported PNG row filter ${filter}`); + pixels[out + x] = value & 0xff; + } + } + return { width, height, pixels }; +} + +// --- PNG encoding ----------------------------------------------------------------- + +function chunk(type, body) { + const length = Buffer.alloc(4); + length.writeUInt32BE(body.length, 0); + const typed = Buffer.concat([Buffer.from(type, "ascii"), body]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(typed), 0); + return Buffer.concat([length, typed, crc]); +} + +function encodePng(width, height, pixels) { + const stride = width * 4; + // Paeth on every row: these assets are mostly flat colour and long transparent runs, + // which the predictor turns into zeroes for deflate to collapse. + const raw = Buffer.alloc(height * (stride + 1)); + for (let y = 0; y < height; y++) { + const dst = y * (stride + 1); + raw[dst] = 4; + const src = y * stride; + const prev = src - stride; + for (let x = 0; x < stride; x++) { + const left = x >= 4 ? pixels[src + x - 4] : 0; + const up = y > 0 ? pixels[prev + x] : 0; + const upLeft = y > 0 && x >= 4 ? pixels[prev + x - 4] : 0; + raw[dst + 1 + x] = (pixels[src + x] - paethPredictor(left, up, upLeft)) & 0xff; + } + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // colour type: RGBA + return Buffer.concat([ + PNG_SIGNATURE, + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +// --- Resampling and composition --------------------------------------------------- + +/** + * Box-filter (exact area average) resample in PREMULTIPLIED alpha. + * + * Averaging straight RGBA would pull the fully transparent black outside the icon's + * rounded corners into the visible edge and leave a dark fringe at small sizes. + */ +function resample(src, srcWidth, srcHeight, dstWidth, dstHeight) { + const out = new Uint8ClampedArray(dstWidth * dstHeight * 4); + const xRatio = srcWidth / dstWidth; + const yRatio = srcHeight / dstHeight; + for (let dy = 0; dy < dstHeight; dy++) { + const y0 = dy * yRatio; + const y1 = (dy + 1) * yRatio; + const sy0 = Math.floor(y0); + const sy1 = Math.min(srcHeight, Math.ceil(y1)); + for (let dx = 0; dx < dstWidth; dx++) { + const x0 = dx * xRatio; + const x1 = (dx + 1) * xRatio; + const sx0 = Math.floor(x0); + const sx1 = Math.min(srcWidth, Math.ceil(x1)); + let r = 0; + let g = 0; + let b = 0; + let alpha = 0; + let weight = 0; + for (let sy = sy0; sy < sy1; sy++) { + const wy = Math.min(sy + 1, y1) - Math.max(sy, y0); + if (wy <= 0) continue; + for (let sx = sx0; sx < sx1; sx++) { + const wx = Math.min(sx + 1, x1) - Math.max(sx, x0); + if (wx <= 0) continue; + const w = wx * wy; + const o = (sy * srcWidth + sx) * 4; + const a = (src[o + 3] / 255) * w; + r += src[o] * a; + g += src[o + 1] * a; + b += src[o + 2] * a; + alpha += a; + weight += w; + } + } + const o = (dy * dstWidth + dx) * 4; + if (alpha > 0) { + out[o] = Math.round(r / alpha); + out[o + 1] = Math.round(g / alpha); + out[o + 2] = Math.round(b / alpha); + } + out[o + 3] = Math.round((alpha / weight) * 255); + } + } + return out; +} + +/** Draws the square icon, scaled to `size`, onto a transparent canvas. */ +function compose(icon, canvasWidth, canvasHeight, size, shiftY) { + const scaled = resample(icon.pixels, icon.width, icon.height, size, size); + const canvas = new Uint8ClampedArray(canvasWidth * canvasHeight * 4); + const left = Math.round((canvasWidth - size) / 2); + const top = Math.round((canvasHeight - size) / 2 + shiftY * canvasHeight); + for (let y = 0; y < size; y++) { + const dy = top + y; + if (dy < 0 || dy >= canvasHeight) continue; + canvas.set(scaled.subarray(y * size * 4, (y + 1) * size * 4), (dy * canvasWidth + left) * 4); + } + return canvas; +} + +// --- Generation ------------------------------------------------------------------- + +async function main() { + const icon = decodePng(await readFile(SOURCE_ICON)); + + // Wipe first: a renamed or dropped asset left behind in build/appx/ would still be + // mapped into the package by electron-builder, which copies the directory wholesale. + await rm(OUT_DIR, { recursive: true, force: true }); + await mkdir(OUT_DIR, { recursive: true }); + + const written = []; + const write = async (name, width, height, pixels) => { + const file = path.join(OUT_DIR, name); + await writeFile(file, encodePng(width, height, pixels)); + written.push(name); + }; + + for (const asset of ASSETS) { + const shiftY = asset.shiftY ?? 0; + for (const scale of [100, ...(asset.scales ?? [])]) { + const width = Math.round((asset.width * scale) / 100); + const height = Math.round((asset.height * scale) / 100); + const size = Math.round(Math.min(width, height) * asset.fill); + if (size > icon.width) { + throw new Error( + `${asset.name} at ${scale}% needs a ${size}px icon; master is ${icon.width}px`, + ); + } + // The 100% variant stays unqualified so it is also the neutral MRT candidate: + // if resources.pri ever fails to resolve a scale, Windows still finds art. + const suffix = scale === 100 ? "" : `.scale-${scale}`; + await write( + `${asset.name}${suffix}.png`, + width, + height, + compose(icon, width, height, size, shiftY), + ); + } + } + + for (const target of TARGET_SIZES) { + const pixels = resample(icon.pixels, icon.width, icon.height, target, target); + await write(`Square44x44Logo.targetsize-${target}.png`, target, target, pixels); + await write( + `Square44x44Logo.targetsize-${target}_altform-unplated.png`, + target, + target, + pixels, + ); + } + + const total = (await readdir(OUT_DIR)).length; + console.log( + `${written.length} assets written to ${path.relative(ROOT, OUT_DIR)} (${total} files)`, + ); +} + +await main(); diff --git a/technical-documentation/engineering/build-and-packaging.md b/technical-documentation/engineering/build-and-packaging.md index 11d8eae30..9b9ffe09c 100644 --- a/technical-documentation/engineering/build-and-packaging.md +++ b/technical-documentation/engineering/build-and-packaging.md @@ -84,6 +84,14 @@ Diagnosing a suspected stale addon: serde embeds its field-name literals in the The default electron-builder target is NSIS, with an assisted installer that allows users to change the installation directory. `npm run build:win:store` explicitly selects the configured `appx` target for Microsoft Store packaging. The AppX identity, publisher, capabilities, and Store languages come from `electron-builder.json5`. Release CI builds and retains both the NSIS installer and AppX package, although the GitHub release publisher currently downloads only the `openscreen-windows` NSIS artifact. +#### Store tile assets + +`win.icon` covers the NSIS installer and the executable, but **not** the Store package: AppX tiles are separate PNG assets that electron-builder reads from `/appx/`, meaning `build/appx/` here. When that directory does not exist it substitutes its own vendored placeholders — `SampleAppx.50x50.png`, `SampleAppx.150x150.png`, `SampleAppx.44x44.png` and `SampleAppx.310x150.png` from the winCodeSign bundle, which are blank white squares. Nothing warns about it; the package builds and installs, and the tile is simply not the product's. That is what failed Store certification 10.1.1.11 "On Device Tiles" ("The available product tile icons include a default image") on the 1.9.0 submission. + +`build/appx/` is therefore committed, and `npm run assets:appx` (`scripts/generate-appx-assets.mjs`) regenerates it from `icons/icons/png/1024x1024.png` — **run it whenever the app icon changes**. No build step calls it, because generated-at-build assets would go missing exactly when someone packages from a checkout that skipped the step. The script decodes and writes PNGs directly on `node:zlib` rather than pulling an image library into the dependency tree for seven static logos. + +Four of the file names are load-bearing: `StoreLogo`, `Square150x150Logo`, `Square44x44Logo` and `Wide310x150Logo` are the ones electron-builder replaces with placeholders when absent. `SmallTile`, `LargeTile` and `SplashScreen` are opt-in — their `` manifest attributes are emitted only when a matching file is present. Each logo also ships `.scale-125/150/200` variants (plus `.scale-400` on the small assets) and, for the 44x44 app-list icon, `.targetsize-*` and `.targetsize-*_altform-unplated` variants for the taskbar and Start list. Any `.scale-`/`.targetsize-` file switches electron-builder into its `makepri.exe` path, which generates `resources.pri` and packages it alongside; the unqualified 100% file of every asset is kept as the neutral fallback candidate so an unresolved qualifier still finds art. Because `appx.backgroundColor` is `transparent`, Windows paints the tile in the user's accent colour, so the tiles are drawn as a padded logo on a transparent canvas rather than full-bleed art — and the two tiles carrying `showNameOnTiles` shift their logo up to clear the name band. + ### macOS > **The macOS job is currently disabled** (`if: false` in `build.yml`) because 1.8.0 ships Windows-only. That flag is release-branch-only and must not reach `main` when promoting, or every later release becomes Windows-only too. Until it is lifted, the macOS packaging path — including the compositor and ffmpeg steps described above — is exercised only by `npm run build:mac` locally.