diff --git a/.github/workflows/_ci-desktop-macos.yml b/.github/workflows/_ci-desktop-macos.yml index 728f17f324..d19ffa72eb 100644 --- a/.github/workflows/_ci-desktop-macos.yml +++ b/.github/workflows/_ci-desktop-macos.yml @@ -31,6 +31,8 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Verify macOS icon representations + run: scripts/macos-icon.sh --check - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 with: workspaces: desktop/src-tauri diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5e60a91be..e0e9268ce9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,10 @@ jobs: - 'scripts/normative-corpus.json' - 'justfile' desktop: + - 'scripts/generate-macos-icon.py' + - 'scripts/macos-icon.sh' + - 'bin/uv' + - 'bin/.uv-*.pkg' - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' - 'desktop/**' diff --git a/bin/.uv-0.12.21.pkg b/bin/.uv-0.12.21.pkg new file mode 120000 index 0000000000..383f4511d4 --- /dev/null +++ b/bin/.uv-0.12.21.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/uv b/bin/uv new file mode 120000 index 0000000000..5fb7b9ec00 --- /dev/null +++ b/bin/uv @@ -0,0 +1 @@ +.uv-0.12.21.pkg \ No newline at end of file diff --git a/bin/uvx b/bin/uvx new file mode 120000 index 0000000000..5fb7b9ec00 --- /dev/null +++ b/bin/uvx @@ -0,0 +1 @@ +.uv-0.12.21.pkg \ No newline at end of file diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns index 2cd02c29ba..748ab79b90 100644 Binary files a/desktop/src-tauri/icons/icon.icns and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/tests/e2e/image-attachment-gallery.spec.ts b/desktop/tests/e2e/image-attachment-gallery.spec.ts index 8743626891..710324203c 100644 --- a/desktop/tests/e2e/image-attachment-gallery.spec.ts +++ b/desktop/tests/e2e/image-attachment-gallery.spec.ts @@ -710,7 +710,10 @@ test("hidden spoiler images are excluded from gallery navigation until revealed" .last(); await expect(row).toBeVisible(); - await row.locator(`img[src*="${SPOILER_VISIBLE_SHA}"]`).click(); + const visibleImage = row.locator(`img[src*="${SPOILER_VISIBLE_SHA}"]`); + await visibleImage.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await visibleImage.click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await expect(page.getByRole("button", { name: "Next image" })).toHaveCount(0); @@ -723,7 +726,13 @@ test("hidden spoiler images are excluded from gallery navigation until revealed" await spoiler.click(); await expect(spoiler).toHaveAttribute("data-revealed", "true"); - await row.locator(`img[src*="${SPOILER_VISIBLE_SHA}"]`).click(); + // The gallery snapshots visible images when opened; the state attribute + // changes before the spoiler image finishes its opacity transition. + await expect(spoiler.locator(`img[src*="${SPOILER_HIDDEN_SHA}"]`)).toHaveCSS( + "opacity", + "1", + ); + await visibleImage.click(); await expect(dialog).toBeVisible(); await expect(page.getByRole("button", { name: "Next image" })).toBeVisible(); }); diff --git a/scripts/generate-macos-icon.py b/scripts/generate-macos-icon.py new file mode 100644 index 0000000000..0108e6ad27 --- /dev/null +++ b/scripts/generate-macos-icon.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Build the legacy macOS icon without relying on system corner masking. + +Run: scripts/macos-icon.sh [--check] + +The wrapper pins uv, Python and Pillow. Verification compares the complete set +of decoded RGBA representations, not PNG encoding or ICNS container bytes: +Apple's system iconutil may encode the same pixels differently across macOS. +The shared artwork and other platforms' icons are intentionally untouched. +""" + +# /// script +# requires-python = "==3.14.3" +# dependencies = ["Pillow==12.2.0"] +# /// + +import argparse +from pathlib import Path +import subprocess +import tempfile + +from PIL import Image, ImageDraw + + +ROOT = Path(__file__).resolve().parent.parent +ICONS = ROOT / "desktop/src-tauri/icons" + + +def generate(output): + # An 824px tile on a 1024px canvas leaves the macOS optical inset. + # Work at 4x resolution to antialias the rounded edge at every icon size. + scale = 4 + canvas = Image.new("RGBA", (1024 * scale, 1024 * scale)) + with Image.open(ICONS / "buzz-source.png") as source: + tile = source.convert("RGBA").resize( + (824 * scale, 824 * scale), Image.Resampling.LANCZOS + ) + mask = Image.new("L", tile.size) + ImageDraw.Draw(mask).rounded_rectangle( + (0, 0, tile.width - 1, tile.height - 1), radius=185 * scale, fill=255 + ) + canvas.paste(tile, (100 * scale, 100 * scale), mask) + + with tempfile.TemporaryDirectory(prefix="buzz-macos-icon-") as temporary: + iconset = Path(temporary) / "Buzz.iconset" + iconset.mkdir() + for size in (16, 32, 128, 256, 512): + for density in (1, 2): + suffix = "@2x" if density == 2 else "" + canvas.resize( + (size * density, size * density), Image.Resampling.LANCZOS + ).save(iconset / f"icon_{size}x{size}{suffix}.png") + subprocess.run( + ["/usr/bin/iconutil", "-c", "icns", str(iconset), "-o", str(output)], + check=True, + ) + + +def verify(candidate, reference): + """Reject missing/extra representations and any decoded pixel drift.""" + with tempfile.TemporaryDirectory(prefix="buzz-icon-check-") as temporary: + sets = [] + for name, icon in (("candidate", candidate), ("reference", reference)): + iconset = Path(temporary) / f"{name}.iconset" + subprocess.run( + ["/usr/bin/iconutil", "-c", "iconset", str(icon), "-o", str(iconset)], + check=True, + ) + sets.append({p.name: p for p in iconset.glob("*.png")}) + if sets[0].keys() != sets[1].keys(): + raise ValueError("macOS icon representation set drifted") + for name in sorted(sets[0]): + with Image.open(sets[0][name]) as a, Image.open(sets[1][name]) as b: + if a.size != b.size or a.convert("RGBA").tobytes() != b.convert("RGBA").tobytes(): + raise ValueError(f"macOS icon pixel drift: {name}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="verify without modifying the icon") + parser.add_argument("--output", type=Path, default=ICONS / "icon.icns") + args = parser.parse_args() + if args.check: + with tempfile.TemporaryDirectory(prefix="buzz-icon-regenerate-") as temporary: + generated = Path(temporary) / "icon.icns" + generate(generated) + verify(args.output, generated) + print("macOS icon: all decoded representations match") + else: + generate(args.output) + + +if __name__ == "__main__": + main() diff --git a/scripts/macos-icon.sh b/scripts/macos-icon.sh new file mode 100755 index 0000000000..a42a72cae2 --- /dev/null +++ b/scripts/macos-icon.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# Hermit pins uv; inline script metadata pins Python and Pillow exactly. +# uv script environments are isolated from the caller's Python packages. +exec "$ROOT/bin/uv" run --managed-python --python 3.14.3 \ + --script "$ROOT/scripts/generate-macos-icon.py" "$@"