Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ yarn-error.log*
src-tauri/target/
src-tauri/gen/
src-tauri/WixTools/
src-tauri/icons/*.icns
# icon.icns is the macOS bundle icon. Track it. Ignoring *.icns is why the
# v0.15.0 DMG shipped Tauri's green minus-bar placeholder.

# IDE
.vscode/
Expand Down
216 changes: 216 additions & 0 deletions scripts/render_bundle_icons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""Render app icons and the macOS DMG window from the OpenAdapt robot mark.

The v0.15.0 DMG shipped a green rounded square with a bar. Finder read that
as a minus. This script paints the website robot mark onto a full-bleed ink
square. macOS applies the squircle mask; do not bake one. It also paints a
drag-to-Applications DMG background.

Requires Pillow. macOS ``iconutil`` writes ``icon.icns``. Optional
``rsvg-convert`` rasterizes the SVG mark at the target size.

Brand mark: ``src-tauri/icons/brand/openadapt-mark.png`` copied from
``openadapt-web`` ``public/apple-touch-icon.png``, plus
``openadapt-mark.svg`` from ``public/images/favicon.svg``.

Design tokens: ``--ink`` #0B1220, ``--inset-text`` #E2E8F0.
"""

from __future__ import annotations

import hashlib
import shutil
import subprocess
import tempfile
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont, ImageOps

ROOT = Path(__file__).resolve().parents[1]
ICONS = ROOT / "src-tauri" / "icons"
MARK = ICONS / "brand" / "openadapt-mark.png"
MARK_SVG = ICONS / "brand" / "openadapt-mark.svg"
DMG_BG = ROOT / "src-tauri" / "dmg" / "background.png"

# Canonical tokens from src/styles/vendor/openadapt-web/tokens.json.
INK = (11, 18, 32, 255) # --ink #0B1220
INSET_TEXT = (226, 232, 240, 255) # --inset-text #E2E8F0
INSET_MUTED = (226, 232, 240, 170)
FORBIDDEN_PNG_SHA256 = "bfe0288bd6f672e2883e771ae509769f25e704bb"

ICONSET_MAP = {
"icon_16x16.png": 16,
"icon_16x16@2x.png": 32,
"icon_32x32.png": 32,
"icon_32x32@2x.png": 64,
"icon_128x128.png": 128,
"icon_128x128@2x.png": 256,
"icon_256x256.png": 256,
"icon_256x256@2x.png": 512,
"icon_512x512.png": 512,
"icon_512x512@2x.png": 1024,
}

# Tauri DMG defaults (bundle.macOS.dmg).
DMG_SIZE = (660, 400)
APP_POSITION = (180, 170)
APPLICATION_FOLDER_POSITION = (480, 170)


def sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()


def load_mark(min_px: int) -> Image.Image:
"""Load the robot mark as RGBA, preferring a sharp SVG raster."""
rsvg = shutil.which("rsvg-convert")
if MARK_SVG.is_file() and rsvg:
with tempfile.TemporaryDirectory(prefix="oa-mark-") as raw:
out = Path(raw) / "mark.png"
subprocess.run(
[
rsvg,
"-w",
str(min_px),
"-h",
str(min_px),
"-o",
str(out),
str(MARK_SVG),
],
check=True,
)
return Image.open(out).convert("RGBA")
return Image.open(MARK).convert("RGBA")


def robot_layer(size: int) -> Image.Image:
"""Return a size x size luminance mask of the robot glyph."""
mark = load_mark(max(size * 2, 512))
# Flatten onto white so a transparent SVG and an opaque PNG share one path.
flat = Image.new("RGBA", mark.size, (255, 255, 255, 255))
flat.alpha_composite(mark)
glyph = ImageOps.invert(flat.convert("L"))
box = glyph.getbbox()
if box:
glyph = glyph.crop(box)
pad = int(size * 0.18)
inner = max(1, size - pad * 2)
fitted = ImageOps.contain(glyph, (inner, inner), Image.Resampling.LANCZOS)
layer = Image.new("L", (size, size), 0)
x = (size - fitted.width) // 2
y = (size - fitted.height) // 2
layer.paste(fitted, (x, y))
return layer


def render_app_icon(size: int) -> Image.Image:
"""Full-bleed ink square. Do not bake a squircle; the OS applies the mask."""
img = Image.new("RGBA", (size, size), INK)
robot = robot_layer(size)
glyph = Image.new("RGBA", (size, size), (255, 255, 255, 255))
glyph.putalpha(robot)
img.alpha_composite(glyph)
return img


def write_png(path: Path, image: Image.Image) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
image.save(path, "PNG")


def render_icns() -> None:
iconutil = shutil.which("iconutil")
if not iconutil:
raise SystemExit("iconutil is required on macOS to write icon.icns")
with tempfile.TemporaryDirectory(prefix="oa-iconset-") as raw:
iconset = Path(raw) / "OpenAdapt.iconset"
iconset.mkdir()
for name, size in ICONSET_MAP.items():
render_app_icon(size).save(iconset / name, "PNG")
out = ICONS / "icon.icns"
subprocess.run(
[iconutil, "-c", "icns", "-o", str(out), str(iconset)],
check=True,
)


def render_ico() -> None:
# PIL writes one ICO image per `sizes` entry by downsampling this master.
# Saving a 16px image with append_images produced a 16px-only ICO.
sizes = ((16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256))
ICONS.mkdir(parents=True, exist_ok=True)
render_app_icon(256).save(ICONS / "icon.ico", format="ICO", sizes=sizes)


def _font(size: int) -> ImageFont.ImageFont:
candidates: list[tuple[str, int | None]] = [
("/System/Library/Fonts/Avenir Next.ttc", 0),
("/System/Library/Fonts/SFNS.ttf", None),
("/System/Library/Fonts/Helvetica.ttc", 0),
("/System/Library/Fonts/Supplemental/Arial.ttf", None),
]
for path, index in candidates:
try:
if index is None:
return ImageFont.truetype(path, size=size)
return ImageFont.truetype(path, size=size, index=index)
except OSError:
continue
return ImageFont.load_default()


def dmg_background() -> Image.Image:
"""Ink plate, wordmark, drag hint, arrow. No empty slots under the icons."""
width, height = DMG_SIZE
img = Image.new("RGBA", (width, height), INK)
draw = ImageDraw.Draw(img)
font = _font(22)
small = _font(13)
draw.text((330, 40), "OpenAdapt Desktop", fill=INSET_TEXT, font=font, anchor="mt")
draw.text((330, 68), "Drag to Applications", fill=INSET_MUTED, font=small, anchor="mt")
left_x, y = APP_POSITION
right_x, _ = APPLICATION_FOLDER_POSITION
# Leave room for the ~128px Finder icons centered on those points.
start = (left_x + 72, y)
end = (right_x - 72, y)
draw.line((start, end), fill=INSET_MUTED, width=3)
draw.polygon(
[
(end[0], end[1]),
(end[0] - 14, end[1] - 8),
(end[0] - 14, end[1] + 8),
],
fill=INSET_MUTED,
)
robot = robot_layer(36)
badge = Image.new("RGBA", (36, 36), (255, 255, 255, 255))
badge.putalpha(robot)
img.alpha_composite(badge, (330 - 18, height - 56))
return img.convert("RGB")


def main() -> None:
if not MARK.is_file():
raise SystemExit(f"missing brand mark: {MARK}")
master = render_app_icon(1024)
write_png(ICONS / "icon.png", master)
digest = sha256_file(ICONS / "icon.png")
if digest.startswith(FORBIDDEN_PNG_SHA256):
raise SystemExit("icon.png is still the minus-bar placeholder")
write_png(ICONS / "32x32.png", render_app_icon(32))
write_png(ICONS / "64x64.png", render_app_icon(64))
write_png(ICONS / "128x128.png", render_app_icon(128))
write_png(ICONS / "128x128@2x.png", render_app_icon(256))
write_png(ICONS / "icon-512.png", render_app_icon(512))
write_png(ICONS / "icon-1024.png", master)
render_ico()
render_icns()
write_png(DMG_BG, dmg_background())
print("wrote", ICONS / "icon.icns")
print("wrote", DMG_BG)
print("icon.png sha256", digest)


if __name__ == "__main__":
main()
Binary file added src-tauri/dmg/background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/128x128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/128x128@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/64x64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons/brand/openadapt-mark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src-tauri/icons/brand/openadapt-mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons/icon-1024.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons/icon.icns
Binary file not shown.
Binary file modified src-tauri/icons/icon.ico
Binary file not shown.
Binary file modified src-tauri/icons/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 23 additions & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,36 @@
"appimage"
],
"icon": [
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon-512.png",
"icons/icon-1024.png",
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico"
],
"externalBin": [
"binaries/openadapt-engine"
],
"macOS": {
"entitlements": "Entitlements.plist"
"entitlements": "Entitlements.plist",
"dmg": {
"background": "dmg/background.png",
"windowSize": {
"width": 660,
"height": 400
},
"appPosition": {
"x": 180,
"y": 170
},
"applicationFolderPosition": {
"x": 480,
"y": 170
}
}
},
"windows": {
"digestAlgorithm": "sha256",
Expand Down
79 changes: 79 additions & 0 deletions tests/test_bundle_icons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Guard the macOS app icon and branded DMG window against the placeholder."""

from __future__ import annotations

import hashlib
import json
import struct
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
ICONS = ROOT / "src-tauri" / "icons"
DMG_BG = ROOT / "src-tauri" / "dmg" / "background.png"
TAURI_CONF = ROOT / "src-tauri" / "tauri.conf.json"

# SHA-256 prefix of the green rounded-square-with-bar placeholder Finder
# reads as a red minus. The v0.15.0 DMG shipped it.
FORBIDDEN_PNG_SHA256 = "bfe0288bd6f672e2883e771ae509769f25e704bb"


def _png_dimensions(path: Path) -> tuple[int, int]:
data = path.read_bytes()
assert data[:8] == b"\x89PNG\r\n\x1a\n", f"{path} is not a PNG"
assert data[12:16] == b"IHDR", f"{path} PNG has no IHDR"
width, height = struct.unpack(">II", data[16:24])
return width, height


def test_icon_png_is_not_the_minus_bar_placeholder() -> None:
icon = ICONS / "icon.png"
assert icon.is_file()
digest = hashlib.sha256(icon.read_bytes()).hexdigest()
assert not digest.startswith(FORBIDDEN_PNG_SHA256), (
f"icon.png still matches the minus-bar placeholder ({digest})"
)


def test_icon_ico_contains_windows_sizes() -> None:
data = (ICONS / "icon.ico").read_bytes()
reserved, itype, count = struct.unpack_from("<HHH", data, 0)
assert reserved == 0
assert itype == 1
assert count >= 6


def test_icon_icns_exists_and_is_listed_in_tauri_conf() -> None:
icns = ICONS / "icon.icns"
assert icns.is_file()
assert icns.read_bytes()[:4] == b"icns"
config = json.loads(TAURI_CONF.read_text(encoding="utf-8"))
icons = config["bundle"]["icon"]
assert "icons/icon.icns" in icons
assert "icons/icon.ico" in icons
assert "icons/icon.png" in icons


def test_dmg_background_is_configured_and_660x400() -> None:
config = json.loads(TAURI_CONF.read_text(encoding="utf-8"))
dmg = config["bundle"]["macOS"]["dmg"]
background = dmg["background"]
assert background
path = ROOT / "src-tauri" / background
assert path.is_file(), f"missing DMG background {path}"
assert path == DMG_BG
assert _png_dimensions(path) == (660, 400)
assert dmg["windowSize"] == {"width": 660, "height": 400}
assert dmg["appPosition"] == {"x": 180, "y": 170}
assert dmg["applicationFolderPosition"] == {"x": 480, "y": 170}


def test_gitignore_tracks_icon_icns() -> None:
gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8")
lines = [
line.strip()
for line in gitignore.splitlines()
if line.strip() and not line.strip().startswith("#")
]
ignored = "src-tauri/icons/*.icns" in lines
tracked = "!src-tauri/icons/icon.icns" in lines
assert not ignored or tracked