diff --git a/.gitignore b/.gitignore
index 587b886..aa9d31d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/scripts/render_bundle_icons.py b/scripts/render_bundle_icons.py
new file mode 100644
index 0000000..ace2e79
--- /dev/null
+++ b/scripts/render_bundle_icons.py
@@ -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()
diff --git a/src-tauri/dmg/background.png b/src-tauri/dmg/background.png
new file mode 100644
index 0000000..1fb20b4
Binary files /dev/null and b/src-tauri/dmg/background.png differ
diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png
index d0c61e5..2bb6945 100644
Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ
diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png
index 9602e68..f8c26ce 100644
Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ
diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png
index e64e6f1..ad176ea 100644
Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ
diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png
index 4ded15d..fa819ae 100644
Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ
diff --git a/src-tauri/icons/brand/openadapt-mark.png b/src-tauri/icons/brand/openadapt-mark.png
new file mode 100644
index 0000000..0d1e09b
Binary files /dev/null and b/src-tauri/icons/brand/openadapt-mark.png differ
diff --git a/src-tauri/icons/brand/openadapt-mark.svg b/src-tauri/icons/brand/openadapt-mark.svg
new file mode 100644
index 0000000..29c23d5
--- /dev/null
+++ b/src-tauri/icons/brand/openadapt-mark.svg
@@ -0,0 +1,3 @@
+
diff --git a/src-tauri/icons/icon-1024.png b/src-tauri/icons/icon-1024.png
new file mode 100644
index 0000000..ca4cc0b
Binary files /dev/null and b/src-tauri/icons/icon-1024.png differ
diff --git a/src-tauri/icons/icon-512.png b/src-tauri/icons/icon-512.png
new file mode 100644
index 0000000..8b8790c
Binary files /dev/null and b/src-tauri/icons/icon-512.png differ
diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns
new file mode 100644
index 0000000..4caf96f
Binary files /dev/null and b/src-tauri/icons/icon.icns differ
diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico
index e200a26..998fdee 100644
Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ
diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png
index bfe0288..ca4cc0b 100644
Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index e8609ae..88d4497 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -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",
diff --git a/tests/test_bundle_icons.py b/tests/test_bundle_icons.py
new file mode 100644
index 0000000..b3f1335
--- /dev/null
+++ b/tests/test_bundle_icons.py
@@ -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("= 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