P
Peter Dongo
Guest
App Store screenshots are the chore everyone puts off until the last week, which for Shipaton entrants is the week you are reading this in. The reason they get put off is that they look like a design job and are actually a state-management job: getting the app into fifteen specific states, on three device classes, without a stray tooltip, a wrong keyboard, a "14 days left" trial badge, or somebody's real data in frame.
Here is the set, measured from fingertips/marketing/screenshots/final/:
18 cards, plus three contact sheets. The pipeline that produces them is 1,133 lines across eight files, and the compose step alone regenerated all 18 cards in 64.67 seconds while I was writing this paragraph.
I want to be exact about what "reproducible" bought, because it is the only reason the number matters: a copy change is a re-run, not a day of clicking. Git says the finished set has been regenerated twice since it was first built: once for a price card Apple caught in review, and once when the Mac's paste feature was deleted after a failed 2.4.5 appeal and the headline card's promise stopped being true ("The snippet lands exactly where your cursor was" became "The snippet is copied and you are back in the app you were already in, ready for Command-V").
The obvious approach is to drive the simulator. Tap through to the state, take the shot, move on. I wrote a small tool for it,
Three things had to be learned to get that far, and they are worth having before you start:
System Events click never reaches the Simulator. It fails with Connection is invalid. (-609), while reads through the same process (get position of window 1) succeed in the same session. CGEvent mouse posts do reach it.
CGEvent.keyboardSetUnicodeString is ignored by the Simulator. It reads the virtual key code instead and maps it through the device's active layout, so typing has to go through a US keycode table, and the device has to be forced to US English first. The setup script writes AppleLanguages, AppleLocale, and AppleKeyboards either way, for a reason that outlived the typing: without it this machine's simulators come up QWERTZ and the software keyboard in the shot is the wrong one.
Re-read the window geometry before every single action. Activating the Simulator can move its window to another display, after which every click lands somewhere else while still reporting success.
None of that mattered, because of the fourth thing.
Fingertips ships a custom keyboard extension. Three of the eighteen cards are shots of that keyboard raised in Messages. Four of the others need text already sitting in a search field or a palette.
One capture run needs both states. The toggle can be flipped from a script, by posting ⇧⌘K at the Simulator, but it is a preference of the Simulator app rather than of the device, and the note I wrote the day I learned this ends with "you will flip it twice before you notice".
So the choice was: a script that flips a Simulator-wide switch between its own steps, or no typing at all.
Every state a screenshot needs is reachable from a DEBUG-only launch argument. Twelve of them, and here is the whole surface, verbatim from the binary:
The application side is one function. It takes closures rather than reaching into the view layer, because the iPhone, the iPad and the Mac each open a different container for the same state:
A capture step is then one line, and it is readable a year later:
The cost is 345 lines of #if DEBUG code in the app (259 in DemoSeed.swift, 21 in DemoFlag.swift, a 65-line block in the Mac AppDelegate), plus short blocks, most of them one line, in seven more files. That is real, it is in the shipping source tree, and I would pay it again.
The Mac made it non-optional rather than merely nicer. Synthetic clicks and keystrokes do not reach the Mac app at all, and a MenuBarExtra popover cannot be opened by UI scripting under any circumstances. There is no manual fallback there either: the palette shot needs the panel pinned to a known display, over a known backdrop, with a known query already typed in it. Every part of that is a launch argument or it does not happen.
If you are tempted to trust Mac UI scripting because the accessibility tree reads perfectly: it does, and it is a trap. Eight scripted clicks once produced a confident, detailed, entirely false bug report for me, and not one of them ever reached the app. Reading works, and so does moving a window: the capture script places the browsing window through System Events. A click or a keystroke is discarded without a word.
The launch arguments made the states reachable. They did not make the pipeline safe. These are the four that shipped a wrong artefact or nearly did.
1. A self-check put three of its own rows into an App Store screenshot.
2. The seed wipes the library, and on the Mac that library belongs to a person. -demoSeed starts by deleting every item. On a simulator, that is free. On the Mac, the app shares one store with whoever is using it, and the deletion would sync to every device on the account. So the demo mode opens a separate store file in the same app group with cloudKitDatabase: .none.
That was not enough. The cache projection still writes the app group's snippets.plist, which is what the palette, the keyboard extension, and the widget read, and that file is shared. So the capture script backs it up and restores it on exit, including on failure.
And then the backup defeated itself. On the second run, the script "backed up" the demo projection over the real one, and the restore at the end put the demo library into the menu bar. Nothing was lost, because the plist is derived, but the palette showed 21 fictional snippets until the real app was reopened. One guard fixed it, and it is the one I would copy into any capture script that touches shared state:
The cleanup trap also reopens the real app afterwards, which is the authoritative fix rather than the careful one: the projection is derived from a store the demo never touches, so the real app rebuilds it correctly at launch no matter what state the file was left in.
3. A flag whose entire job is suppressing output survived past the capture run. The keyboard extension is a separate process with its own launch and no arguments of its own, and it is the one drawing a build stamp and performance timings into the corner of every screenshot. The app group is the only thing the two share, so a demoCapture flag travels through it.
Clearing that flag on exit does not run, because a capture run ends by killing the process. So it survived into every later normal launch and went on hiding the keyboard's diagnostics, which is exactly the output the next capture run depends on. Nothing on screen could have told me, because the flag's only visible effect is the absence of something.
The fix is to clear it on the launches that do not ask for it, which is the only moment guaranteed to happen:
4. screencapture was given a rectangle wider than the thing it was capturing. The Mac palette card is shot over a real document, so it reads as "wherever you were typing" rather than a panel floating on an empty desktop. The first attempt used a rect sized generously around the backdrop and caught two private chat windows sitting behind it. It is now given the backdrop window's own rect, read back through System Events, and the run aborts if that geometry cannot be confirmed:
Never pass screencapture the whole screen on a machine you also work on.
There is a smaller one worth a line, because it wasted an hour. The Mac palette prints the rect it pinned itself to, and that print went to stdout. The capture script redirects stdout to a log file, which makes it block-buffered, so the rect only appeared after the process was killed, which is to say never. It goes to stderr now.
Everything above is inside #if DEBUG. The obvious way to prove that to yourself, and the thing I was going to publish as this article's runnable check, is to look for the strings in the shipped binary:
Against the Release build in /Applications, that returns nothing. Clean.
Against the Debug build, the one with all twelve arguments compiled in, it also returns nothing. Not one hit. Not even the substring demo.
Xcode 26 builds a Debug app with a debug dylib unless told not to (ENABLE_DEBUG_DYLIB, which this project never mentions and which reads back YES): the main executable is a 60,000-byte launcher stub and every line of your code lives in Fingertips.debug.dylib next to it. 33,336,320 bytes of it. The Release build has no dylib and puts everything in the 17,052,512-byte executable. So the command reads the right file in Release, reads a stub in Debug, and reports the same clean answer for both.
A check that cannot fail is not a check. The one that works walks the bundle:
Measured, just now, on this machine, against the two Mac builds:
Run it against your own app before you believe any claim you are making about what is or is not in your shipping binary. Then run it against a build you know is dirty, and only trust it if it comes back dirty.
This is the second time in this series the same shape has bitten me. The last one was a
One more, because it will cost you an afternoon if you set up a diff-based check.
I regenerated all 18 cards from unchanged raws and unchanged source, then compared the two runs:
Every card, different. And every card pixel-identical: 870,123 bytes against 870,076 for the iPhone keyboard card. Chrome's headless PNG encoder is not byte-deterministic, so diff and md5 both say the pipeline is unstable when the pixels have not moved.
Compare pixels, not files:
18 identical, 0 differing, which is the answer diff had already given me backwards.
The title of this article in my plan was "Zero Clicks". That was not true, and here is the exact remainder:
Full Access for the keyboard, once per simulator. It is behind a system alert and no script can grant it. The keyboard itself is installed without touching Settings, by writing AppleKeyboards in the simulator's global preferences, and the device is rebooted straight after, which is not optional: a running SpringBoard notices neither the new keyboard nor the language change, and without the reboot the switcher lists only English and Emoji and the keyboard card comes out silently wrong.
One card of the eighteen was shot by hand. The iPad side-window card, Fingertips beside the app you are writing in, has no scripted route. The simulator runs apps full screen, iPadOS 26 hands back a "< Messages" chevron instead of a second window, and dragging the corner does nothing. A real iPad does it in two minutes, and devicectl does pass launch arguments through, which is the part people assume it does not:
So: 17 of 18 scripted, one system alert per simulator, one card by hand. That is the honest number, and it is still the difference between a re-run and a lost day.
If a chore will happen more than twice under deadline pressure, the version you can re-run beats the version that looks finished. Screenshots always happen more than twice, because the copy changes, the icon changes, the trial badge turns out to be in frame, or the build gets rejected and the shots were taken from it.
But the sharper lesson is the one underneath both of this article's failures, and it transfers off Apple's platforms entirely. A tool that reports on your work has to be tested against a case it should fail. The strings check passed a build stuffed with debug code. The diff check failed a pipeline that had not changed a pixel. Both were confident. Both were about work I could not otherwise see, which is exactly why I had written them.
Before you trust a checker, break something on purpose and make sure it notices.
Fingertips is a snippet library for iPhone, iPad and Mac, built for RevenueCat's Shipaton 2026. It is on the App Store and at usefingertips.com. Earlier in this series: an entitlement check that turned an unreachable RevenueCat into an unlimited licence, why you cannot give a hackathon judge a free unlock before you submit, an on-device model that ignored its search tool and made up my data, an in-app purchase Apple could not find, the best paragraph in my review notes, which I deleted four days later, and 16 lessons from testing iOS and macOS features on real hardware.
Here is the set, measured from fingertips/marketing/screenshots/final/:
| Platform | Cards | Size |
|---|---|---|
| iPhone 6.9" | 6 | 1290x2796 |
| iPad 13" | 7 | 2048x2732 |
| Mac | 5 | 2880x1800 |
18 cards, plus three contact sheets. The pipeline that produces them is 1,133 lines across eight files, and the compose step alone regenerated all 18 cards in 64.67 seconds while I was writing this paragraph.
I want to be exact about what "reproducible" bought, because it is the only reason the number matters: a copy change is a re-run, not a day of clicking. Git says the finished set has been regenerated twice since it was first built: once for a price card Apple caught in review, and once when the Mac's paste feature was deleted after a failed 2.4.5 appeal and the headline card's promise stopped being true ("The snippet lands exactly where your cursor was" became "The snippet is copied and you are back in the app you were already in, ready for Command-V").
The plan that did not survive contact
The obvious approach is to drive the simulator. Tap through to the state, take the shot, move on. I wrote a small tool for it,
simdrive.swift, 237 lines, and it works: it reads the Simulator's accessibility tree to find the device screen, converts device pixels to screen points, and posts real events.Three things had to be learned to get that far, and they are worth having before you start:
System Events click never reaches the Simulator. It fails with Connection is invalid. (-609), while reads through the same process (get position of window 1) succeed in the same session. CGEvent mouse posts do reach it.
CGEvent.keyboardSetUnicodeString is ignored by the Simulator. It reads the virtual key code instead and maps it through the device's active layout, so typing has to go through a US keycode table, and the device has to be forced to US English first. The setup script writes AppleLanguages, AppleLocale, and AppleKeyboards either way, for a reason that outlived the typing: without it this machine's simulators come up QWERTZ and the software keyboard in the shot is the wrong one.
Re-read the window geometry before every single action. Activating the Simulator can move its window to another display, after which every click lands somewhere else while still reporting success.
None of that mattered, because of the fourth thing.
The trade-off, not a setting
Fingertips ships a custom keyboard extension. Three of the eighteen cards are shots of that keyboard raised in Messages. Four of the others need text already sitting in a search field or a palette.
- A CGEvent keystroke only reaches the iOS Simulator while the Mac's hardware keyboard is connected to it, which is a toggle in the Simulator's own I/O menu.
- A custom keyboard extension is only reachable while it is disconnected. With hardware input on, the software keyboard does not come up, so there is nothing to switch away from and no globe key to hold.
One capture run needs both states. The toggle can be flipped from a script, by posting ⇧⌘K at the Simulator, but it is a preference of the Simulator app rather than of the device, and the note I wrote the day I learned this ends with "you will flip it twice before you notice".
So the choice was: a script that flips a Simulator-wide switch between its own steps, or no typing at all.
Launch arguments, not typing
Every state a screenshot needs is reachable from a DEBUG-only launch argument. Twelve of them, and here is the whole surface, verbatim from the binary:
Code:
-demoSeed -demoFresh -demoPurchased -demoSearch -demoSelect
-demoFillIn -demoBlanks -demoShow -demoShowCode -demoWindow
-demoPalette -demoQuery
The application side is one function. It takes closures rather than reaching into the view layer, because the iPhone, the iPad and the Mac each open a different container for the same state:
Code:
/// The state a screenshot wants the app to open in.
///
/// Launch arguments rather than synthetic taps, and that is not laziness: a
/// CGEvent keystroke only reaches the Simulator while the hardware keyboard
/// is CONNECTED, and a custom keyboard extension is only reachable while it
/// is DISCONNECTED. One capture run needs both, so anything that had to be
/// typed could not sit in the same script as the keyboard shots.
@MainActor
static func applyState(_ context: ModelContext,
search: (String) -> Void,
select: (Item) -> Void,
fillIn: (Item) -> Void,
show: (Item) -> Void) {
if let query = argument("-demoSearch") { search(query) }
if let title = argument("-demoSelect"), let item = item(titled: title, in: context) {
select(item)
}
if let title = argument("-demoFillIn"), let item = item(titled: title, in: context) {
fillIn(item)
}
if let title = argument("-demoShow"), let item = item(titled: title, in: context) {
show(item)
}
}
A capture step is then one line, and it is readable a year later:
Code:
app -demoFillIn "Meeting follow-up" -demoBlanks "name=Ana,document=Brand guidelines"
shot 03-fill-in
The cost is 345 lines of #if DEBUG code in the app (259 in DemoSeed.swift, 21 in DemoFlag.swift, a 65-line block in the Mac AppDelegate), plus short blocks, most of them one line, in seven more files. That is real, it is in the shipping source tree, and I would pay it again.
The Mac made it non-optional rather than merely nicer. Synthetic clicks and keystrokes do not reach the Mac app at all, and a MenuBarExtra popover cannot be opened by UI scripting under any circumstances. There is no manual fallback there either: the palette shot needs the panel pinned to a known display, over a known backdrop, with a known query already typed in it. Every part of that is a launch argument or it does not happen.
If you are tempted to trust Mac UI scripting because the accessibility tree reads perfectly: it does, and it is a trap. Eight scripted clicks once produced a confident, detailed, entirely false bug report for me, and not one of them ever reached the app. Reading works, and so does moving a window: the capture script places the browsing window through System Events. A click or a keystroke is discarded without a word.
Four things that went wrong anyway
The launch arguments made the states reachable. They did not make the pipeline safe. These are the four that shipped a wrong artefact or nearly did.
1. A self-check put three of its own rows into an App Store screenshot.
Trash.selfCheck() asks the model layer for a throwaway in-memory container and inserts its test rows into it, three at a time. The demo branch in Store.container came first and did not exclude it, so those three landed in the demo library and were photographed as "live", "recent," and one row in the Trash. The fix is one condition, and the comment on it is there so nobody removes it as redundant:
Code:
// `!inMemory` matters: `Trash.selfCheck()` asks for a throwaway
// container and inserts three items into it, and while this branch came
// first those three landed in the demo library and appeared in an App
// Store screenshot as "live", "recent" and one row in the Trash.
if isDemo, !inMemory, groupURL != nil {
return try ModelContainer(
for: Item.self, Folder.self, SavedSearch.self,
configurations: ModelConfiguration("FingertipsDemo",
groupContainer: .identifier(appGroup),
cloudKitDatabase: .none))
}
2. The seed wipes the library, and on the Mac that library belongs to a person. -demoSeed starts by deleting every item. On a simulator, that is free. On the Mac, the app shares one store with whoever is using it, and the deletion would sync to every device on the account. So the demo mode opens a separate store file in the same app group with cloudKitDatabase: .none.
That was not enough. The cache projection still writes the app group's snippets.plist, which is what the palette, the keyboard extension, and the widget read, and that file is shared. So the capture script backs it up and restores it on exit, including on failure.
And then the backup defeated itself. On the second run, the script "backed up" the demo projection over the real one, and the restore at the end put the demo library into the menu bar. Nothing was lost, because the plist is derived, but the palette showed 21 fictional snippets until the real app was reopened. One guard fixed it, and it is the one I would copy into any capture script that touches shared state:
Code:
# NEVER overwrite an existing backup.
if [ ! -f "$OUT/.snippets-backup.plist" ] && [ -f "$GROUP/snippets.plist" ]; then
cp "$GROUP/snippets.plist" "$OUT/.snippets-backup.plist"
fi
The cleanup trap also reopens the real app afterwards, which is the authoritative fix rather than the careful one: the projection is derived from a store the demo never touches, so the real app rebuilds it correctly at launch no matter what state the file was left in.
3. A flag whose entire job is suppressing output survived past the capture run. The keyboard extension is a separate process with its own launch and no arguments of its own, and it is the one drawing a build stamp and performance timings into the corner of every screenshot. The app group is the only thing the two share, so a demoCapture flag travels through it.
Clearing that flag on exit does not run, because a capture run ends by killing the process. So it survived into every later normal launch and went on hiding the keyboard's diagnostics, which is exactly the output the next capture run depends on. Nothing on screen could have told me, because the flag's only visible effect is the absence of something.
The fix is to clear it on the launches that do not ask for it, which is the only moment guaranteed to happen:
Code:
guard isRequested else {
if DemoFlag.isOn { DemoFlag.set(false) }
return
}
4. screencapture was given a rectangle wider than the thing it was capturing. The Mac palette card is shot over a real document, so it reads as "wherever you were typing" rather than a panel floating on an empty desktop. The first attempt used a rect sized generously around the backdrop and caught two private chat windows sitting behind it. It is now given the backdrop window's own rect, read back through System Events, and the run aborts if that geometry cannot be confirmed:
Code:
[ -n "$BACK_RECT" ] || { echo "could not place the TextEdit backdrop"; exit 1; }
Never pass screencapture the whole screen on a machine you also work on.
There is a smaller one worth a line, because it wasted an hour. The Mac palette prints the rect it pinned itself to, and that print went to stdout. The capture script redirects stdout to a log file, which makes it block-buffered, so the rect only appeared after the process was killed, which is to say never. It goes to stderr now.
The verification command that lies
Everything above is inside #if DEBUG. The obvious way to prove that to yourself, and the thing I was going to publish as this article's runnable check, is to look for the strings in the shipped binary:
Code:
strings -a Fingertips.app/Contents/MacOS/Fingertips | grep -o '\-demo[A-Za-z]*' | sort -u
Against the Release build in /Applications, that returns nothing. Clean.
Against the Debug build, the one with all twelve arguments compiled in, it also returns nothing. Not one hit. Not even the substring demo.
Xcode 26 builds a Debug app with a debug dylib unless told not to (ENABLE_DEBUG_DYLIB, which this project never mentions and which reads back YES): the main executable is a 60,000-byte launcher stub and every line of your code lives in Fingertips.debug.dylib next to it. 33,336,320 bytes of it. The Release build has no dylib and puts everything in the 17,052,512-byte executable. So the command reads the right file in Release, reads a stub in Debug, and reports the same clean answer for both.
A check that cannot fail is not a check. The one that works walks the bundle:
Code:
find Fingertips.app -type f -perm +111 -exec strings -a {} + \
| grep -o '^\-demo[A-Za-z]*$' | sort -u
Measured, just now, on this machine, against the two Mac builds:
| | executable only | whole bundle |
|---|---|---|
| Debug build | 0 | 12 |
| Release build | 0 | 0 |
Run it against your own app before you believe any claim you are making about what is or is not in your shipping binary. Then run it against a build you know is dirty, and only trust it if it comes back dirty.
This is the second time in this series the same shape has bitten me. The last one was a
grep -Ei 'AX|CGEvent' that was case-insensitive, so AX matched maxWidth and Axis.Set and returned 21 lines of ordinary SwiftUI against an article promising an empty result. Both commands reached a draft. Both were wrong in the direction that made me look right.Reproducible does not mean byte-identical
One more, because it will cost you an afternoon if you set up a diff-based check.
I regenerated all 18 cards from unchanged raws and unchanged source, then compared the two runs:
Code:
$ diff -rq before/ screenshots/final/
Files before/ipad/01-library.png and screenshots/final/ipad/01-library.png differ
... 18 of 18 differ
Every card, different. And every card pixel-identical: 870,123 bytes against 870,076 for the iPhone keyboard card. Chrome's headless PNG encoder is not byte-deterministic, so diff and md5 both say the pipeline is unstable when the pixels have not moved.
Compare pixels, not files:
Code:
from PIL import Image, ImageChops
a, b = Image.open(old).convert("RGBA"), Image.open(new).convert("RGBA")
assert ImageChops.difference(a, b).getbbox() is None
18 identical, 0 differing, which is the answer diff had already given me backwards.
What is still manual, honestly
The title of this article in my plan was "Zero Clicks". That was not true, and here is the exact remainder:
Full Access for the keyboard, once per simulator. It is behind a system alert and no script can grant it. The keyboard itself is installed without touching Settings, by writing AppleKeyboards in the simulator's global preferences, and the device is rebooted straight after, which is not optional: a running SpringBoard notices neither the new keyboard nor the language change, and without the reboot the switcher lists only English and Emoji and the keyboard card comes out silently wrong.
One card of the eighteen was shot by hand. The iPad side-window card, Fingertips beside the app you are writing in, has no scripted route. The simulator runs apps full screen, iPadOS 26 hands back a "< Messages" chevron instead of a second window, and dragging the corner does nothing. A real iPad does it in two minutes, and devicectl does pass launch arguments through, which is the part people assume it does not:
Code:
xcrun devicectl device process launch --device <id> --terminate-existing \
app.fingertips -demoSeed -demoFresh -demoPurchased
So: 17 of 18 scripted, one system alert per simulator, one card by hand. That is the honest number, and it is still the difference between a re-run and a lost day.
The rule
If a chore will happen more than twice under deadline pressure, the version you can re-run beats the version that looks finished. Screenshots always happen more than twice, because the copy changes, the icon changes, the trial badge turns out to be in frame, or the build gets rejected and the shots were taken from it.
But the sharper lesson is the one underneath both of this article's failures, and it transfers off Apple's platforms entirely. A tool that reports on your work has to be tested against a case it should fail. The strings check passed a build stuffed with debug code. The diff check failed a pipeline that had not changed a pixel. Both were confident. Both were about work I could not otherwise see, which is exactly why I had written them.
Before you trust a checker, break something on purpose and make sure it notices.
Fingertips is a snippet library for iPhone, iPad and Mac, built for RevenueCat's Shipaton 2026. It is on the App Store and at usefingertips.com. Earlier in this series: an entitlement check that turned an unreachable RevenueCat into an unlimited licence, why you cannot give a hackathon judge a free unlock before you submit, an on-device model that ignored its search tool and made up my data, an in-app purchase Apple could not find, the best paragraph in my review notes, which I deleted four days later, and 16 lessons from testing iOS and macOS features on real hardware.