mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-13 23:17:33 +00:00
The DMG opened as a plain Finder window. It now shows the app icon's figure on the GridCraft colour, with GridCraft.app and the Applications link side by side. Same approach as PhotoCraft's and VectorCraft's (storytold/photocraft#1201, storytold/vectorcraft#493). - packaging/macos/dmg/: background.svg (source; text outlined, no paper grain, the figure an <image> of assets/app-icon), background.tiff (1x + 2x HiDPI, 16-colour palette, Deflate, sRGB; ~26 KB) and dmg-layout.DS_Store (Finder view settings: window, icon size 128, positions, background). - generate.py writes the TIFF and the layout on any OS, byte-for-byte reproducible, with nothing from the machine that ran it (no paths, users, disk names or volume UUIDs). - package.sh copies them into the staging folder; the image is still built with hdiutil makehybrid. Nothing extra runs in the signing job. - The volume name drops the version: the layout finds the background through an alias that includes the volume name. The DMG file name keeps the version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
6.0 KiB
Python
159 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here).
|
|
|
|
Needs resvg on PATH and:
|
|
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
|
|
|
|
Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing
|
|
from the machine that made them: no local paths, user or disk names, dates or volume UUIDs.
|
|
|
|
python3 packaging/macos/dmg/generate.py
|
|
"""
|
|
|
|
import datetime
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
from ds_store import DSStore
|
|
from mac_alias import Alias, TargetInfo, VolumeInfo
|
|
from PIL import Image, ImageCms
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
# Keep in sync with package.sh (volume name) and README.md (layout).
|
|
VOLUME = "GridCraft"
|
|
WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size
|
|
TITLE_BAR = 32
|
|
ICON_SIZE = 128
|
|
PALETTE = 16 # colours in background.tiff
|
|
ICONS = {"GridCraft.app": (326, 205), "Applications": (574, 205)}
|
|
# A fixed date for the alias and the colour profile. Finder never matches it against the image
|
|
# (each build is a new volume): it finds the background by volume name and path.
|
|
FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc)
|
|
|
|
|
|
def render(svg, width, out):
|
|
# The SVG's text is already outlined and no fonts are loaded, so every machine draws the
|
|
# same pixels.
|
|
subprocess.run(
|
|
["resvg", "--skip-system-fonts", "-w", str(width), svg, out],
|
|
check=True,
|
|
)
|
|
with Image.open(out) as im:
|
|
return im.convert("RGB")
|
|
|
|
|
|
def quantize(im):
|
|
# No dithering: dither noise compresses badly and the artwork has flat fills.
|
|
return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE)
|
|
|
|
|
|
def srgb_profile():
|
|
# LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed.
|
|
icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes())
|
|
d = FIXED_DATE
|
|
icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0))
|
|
if icc[84:100] != bytes(16):
|
|
sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date")
|
|
return bytes(icc)
|
|
|
|
|
|
def write_tiff(svg, out):
|
|
"""1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
one, two = (
|
|
quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2)
|
|
)
|
|
two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page
|
|
# Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette
|
|
# keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's.
|
|
one.save(
|
|
out,
|
|
"TIFF",
|
|
save_all=True,
|
|
append_images=[two],
|
|
compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate)
|
|
dpi=(72, 72),
|
|
icc_profile=srgb_profile(),
|
|
)
|
|
with Image.open(out) as im:
|
|
im.seek(1)
|
|
if im.info.get("dpi") != (144, 144):
|
|
sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)")
|
|
strips = []
|
|
for page in (0, 1):
|
|
im.seek(page)
|
|
strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts
|
|
# TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff
|
|
# leaves the padding byte after it uninitialised, so the file changed from run to run with
|
|
# identical pixels. Zero it: nothing refers to that byte.
|
|
starts = {o for o, _ in strips}
|
|
with open(out, "r+b") as f:
|
|
for o, n in strips:
|
|
if (o + n) % 2 and o + n not in starts:
|
|
f.seek(o + n)
|
|
f.write(bytes(1))
|
|
|
|
|
|
def background_alias():
|
|
# What Finder stores for /.background/background.tiff on the volume, minus anything about the
|
|
# machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable),
|
|
# the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids
|
|
# are only hints, as the volume's creation date.
|
|
volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0")
|
|
volume.posix_path = f"/Volumes/{VOLUME}"
|
|
target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0")
|
|
target.folder_name = ".background"
|
|
target.cnid_path = [25]
|
|
target.carbon_path = f"{VOLUME}:.background:\0background.tiff"
|
|
target.posix_path = "/.background/background.tiff"
|
|
return Alias(volume=volume, target=target).to_bytes()
|
|
|
|
|
|
def write_ds_store(out):
|
|
if os.path.exists(out):
|
|
os.remove(out)
|
|
with DSStore.open(out, "w+") as d:
|
|
d["."]["bwsp"] = {
|
|
"ShowStatusBar": False,
|
|
"ShowToolbar": False,
|
|
"ShowTabView": False,
|
|
"ContainerShowSidebar": False,
|
|
"ShowSidebar": False,
|
|
"WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}",
|
|
}
|
|
d["."]["icvp"] = {
|
|
"viewOptionsVersion": 1,
|
|
"backgroundType": 2,
|
|
"backgroundImageAlias": background_alias(),
|
|
"backgroundColorRed": 1.0,
|
|
"backgroundColorGreen": 1.0,
|
|
"backgroundColorBlue": 1.0,
|
|
"gridOffsetX": 0.0,
|
|
"gridOffsetY": 0.0,
|
|
"gridSpacing": 100.0,
|
|
"arrangeBy": "none",
|
|
"showIconPreview": True,
|
|
"showItemInfo": False,
|
|
"labelOnBottom": True,
|
|
"textSize": 16.0,
|
|
"iconSize": float(ICON_SIZE),
|
|
}
|
|
d["."]["vSrn"] = ("long", 1)
|
|
for name, pos in ICONS.items():
|
|
d[name]["Iloc"] = pos
|
|
|
|
|
|
def main():
|
|
if not shutil.which("resvg"):
|
|
sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)")
|
|
write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff"))
|
|
write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|