diff --git a/.gitignore b/.gitignore
index 99cf66615..f310f3292 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ target/
/book/output
/craft-fonts
*.log
+__pycache__/
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 9cdf9aa6e..22a4c6091 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -26,6 +26,7 @@ next to them.
| `assets/dict/en_US-scowl-50.txt.gz` | English word list for Check Spelling (SCOWL 2020.12.07, size 50, en_US; 72,403 words) | Kevin Atkinson and the SCOWL contributors | | SCOWL license (permissive, keep the notice), [`assets/dict/LICENSE-SCOWL.txt`](assets/dict/LICENSE-SCOWL.txt), which also has the build recipe |
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
+| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code |
### Optional build input: craft-fonts (not files in this repo)
diff --git a/docs/releasing.md b/docs/releasing.md
index 1122b5251..3e67c7b54 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -85,6 +85,9 @@ in `ci.yml`. Rules: `../craftrules/standards/fonts.md`; build option: `docs/deve
ticket is stapled to the app. The app goes on a DMG (`hdiutil`, with an `Applications` link
to drag onto). The DMG is signed, notarized and stapled too. The script checks the results
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 `PhotoCraft`
+ without the version, which the window's background needs; the DMG file name keeps the version.
- **CLI:** the universal `photocraft-cli` is signed with the same Developer ID, the hardened
runtime and a secure timestamp (identifier `ai.storyteller.photocraft-cli`), zipped, and the zip
is sent to `notarytool`. Only `.app`, `.dmg` and `.pkg` can hold a stapled ticket, not a bare
diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md
new file mode 100644
index 000000000..0cdb20a91
--- /dev/null
+++ b/packaging/macos/dmg/README.md
@@ -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/photocraft-small.svg`, linked, not copied) cropped as a cover on the PhotoCraft colour field (`#2f7bf5`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. |
+| `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; about 90 KB). Goes to `.background/background.tiff`. |
+| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, PhotoCraft.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 `PhotoCraft`, not `PhotoCraft `. 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 (`photocraft--macos-.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).
diff --git a/packaging/macos/dmg/background.svg b/packaging/macos/dmg/background.svg
new file mode 100644
index 000000000..d93ae37c5
--- /dev/null
+++ b/packaging/macos/dmg/background.svg
@@ -0,0 +1,28 @@
+
diff --git a/packaging/macos/dmg/background.tiff b/packaging/macos/dmg/background.tiff
new file mode 100644
index 000000000..d9d675c6b
Binary files /dev/null and b/packaging/macos/dmg/background.tiff differ
diff --git a/packaging/macos/dmg/dmg-layout.DS_Store b/packaging/macos/dmg/dmg-layout.DS_Store
new file mode 100644
index 000000000..b93935043
Binary files /dev/null and b/packaging/macos/dmg/dmg-layout.DS_Store differ
diff --git a/packaging/macos/dmg/generate.py b/packaging/macos/dmg/generate.py
new file mode 100644
index 000000000..be6c7ed59
--- /dev/null
+++ b/packaging/macos/dmg/generate.py
@@ -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 = "PhotoCraft"
+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 = {"PhotoCraft.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 ~90 KB (RGB was ~200 KB; repository assets must stay small).
+ 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()
diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh
index b94ca5068..194b37929 100755
--- a/packaging/macos/package.sh
+++ b/packaging/macos/package.sh
@@ -134,10 +134,15 @@ STAGE="$WORK/dmg"
mkdir -p "$STAGE"
ditto "$APP" "$STAGE/PhotoCraft.app"
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"
# 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.
-hdiutil makehybrid -hfs -hfs-volume-name "PhotoCraft $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 "PhotoCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
sign "$DMG"