macOS DMG: branded window background and icon layout
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>
This commit is contained in:
parent
fb823899c5
commit
be33eba7d1
|
|
@ -18,6 +18,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||||
| `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | |
|
| `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | |
|
||||||
| `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later |
|
| `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later |
|
||||||
|
| `packaging/macos/dmg/background.svg` | @XusBadia | original (hand-written SVG, derived from the GridCraft app icon `assets/app-icon/gridcraft.svg`, referenced via `<image>`, not copied; text is outlined glyph paths; `packaging/macos/dmg/generate.py` renders it to `background.tiff`) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | GridCraft macOS DMG window background for the Finder window; added 2026-10-08 |
|
||||||
|
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||||
| `assets/app-icon/gridcraft-1024.png` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, rendered from gridcraft.svg |
|
| `assets/app-icon/gridcraft-1024.png` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, rendered from gridcraft.svg |
|
||||||
| `assets/app-icon/gridcraft-macos-512.png` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS grid render |
|
| `assets/app-icon/gridcraft-macos-512.png` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS grid render |
|
||||||
| `assets/app-icon/gridcraft.icns` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS bundle icon |
|
| `assets/app-icon/gridcraft.icns` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, macOS bundle icon |
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,9 @@ and upload their artifacts, the environment refuses macOS and Windows, and no re
|
||||||
- **Notarization:** the app is zipped and sent with `xcrun notarytool submit --wait`, then
|
- **Notarization:** the app is zipped and sent with `xcrun notarytool submit --wait`, then
|
||||||
stapled. The DMG (with an `Applications` link) is signed, notarized and stapled too, and
|
stapled. The DMG (with an `Applications` link) is signed, notarized and stapled too, and
|
||||||
checked with `codesign --verify --strict`, `stapler validate` and `spctl -a -vvv`.
|
checked with `codesign --verify --strict`, `stapler validate` and `spctl -a -vvv`.
|
||||||
|
Its Finder window (background, icon size and positions) comes from
|
||||||
|
[`packaging/macos/dmg/`](../packaging/macos/dmg/README.md), and its volume is named `GridCraft`
|
||||||
|
without the version, which the window's background needs; the DMG file name keeps the version.
|
||||||
- **CLI:** the universal `gridcraft-cli` is signed, zipped and notarized.
|
- **CLI:** the universal `gridcraft-cli` is signed, zipped and notarized.
|
||||||
|
|
||||||
Locally, without certificates, the script signs ad-hoc (`codesign -s -`) and skips notarization:
|
Locally, without certificates, the script signs ad-hoc (`codesign -s -`) and skips notarization:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# macOS DMG window
|
||||||
|
|
||||||
|
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
|
||||||
|
`Applications` link side by side. `package.sh` copies these files into the image; nothing here is
|
||||||
|
generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no
|
||||||
|
Finder scripting on CI, nothing extra installed in the signing job).
|
||||||
|
|
||||||
|
| File | What |
|
||||||
|
|---|---|
|
||||||
|
| `background.svg` | Source of the background: the app icon (`assets/app-icon/gridcraft.svg`, linked, not copied) cropped as a cover on the GridCraft colour field (`#1f9d55`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. |
|
||||||
|
| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. |
|
||||||
|
| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, GridCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. |
|
||||||
|
| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. |
|
||||||
|
|
||||||
|
## The volume name has no version
|
||||||
|
|
||||||
|
The mounted volume is called `GridCraft`, not `GridCraft <version>`. The layout points at the background
|
||||||
|
through an alias that includes the volume name, and Finder resolves it by that name: with a
|
||||||
|
versioned name the window keeps its size and icon positions but shows no background (tested).
|
||||||
|
The DMG file name (`gridcraft-<version>-macos-<arch>.dmg`) still carries the version.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Finder draws the icon labels in black in light and dark mode** when a window has a background,
|
||||||
|
so the area under both icons stays light (Paper).
|
||||||
|
- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of
|
||||||
|
the label strip under it.
|
||||||
|
|
||||||
|
## Regenerate
|
||||||
|
|
||||||
|
Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
|
||||||
|
python3 packaging/macos/dmg/generate.py # needs resvg on PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the
|
||||||
|
SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background
|
||||||
|
alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG,
|
||||||
|
outline it (`usvg` from resvg converts text to paths). The window is 660 × 432 with Finder's 32 pt
|
||||||
|
title bar; the content area is 660 × 400.
|
||||||
|
|
||||||
|
This follows VectorCraft's `packaging/macos/dmg/` (storytold/vectorcraft#493).
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,158 @@
|
||||||
|
#!/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()
|
||||||
|
|
@ -131,10 +131,15 @@ STAGE="$WORK/dmg"
|
||||||
mkdir -p "$STAGE"
|
mkdir -p "$STAGE"
|
||||||
ditto "$APP" "$STAGE/GridCraft.app"
|
ditto "$APP" "$STAGE/GridCraft.app"
|
||||||
ln -s /Applications "$STAGE/Applications"
|
ln -s /Applications "$STAGE/Applications"
|
||||||
|
# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md).
|
||||||
|
mkdir -p "$STAGE/.background"
|
||||||
|
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
|
||||||
|
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
|
||||||
rm -f "$DMG" "$WORK/raw.dmg"
|
rm -f "$DMG" "$WORK/raw.dmg"
|
||||||
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
|
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
|
||||||
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
|
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
|
||||||
hdiutil makehybrid -hfs -hfs-volume-name "GridCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
|
# The volume name has no version: .DS_Store finds the background through an alias that includes it.
|
||||||
|
hdiutil makehybrid -hfs -hfs-volume-name "GridCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
|
||||||
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
|
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
|
||||||
rm -f "$WORK/raw.dmg"
|
rm -f "$WORK/raw.dmg"
|
||||||
sign "$DMG"
|
sign "$DMG"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue