Building apps with an agentic workflow is fast. Standing up a whole new app, from idea to something you can ship, now takes days instead of months. But every new app still needs App Store screenshots, and that’s a slow step that has nothing to do with code.
The usual ritual is manual and fragile. Launch the app, arrange believable data, take a screenshot, drop it into Figma, draw a window frame and a gradient, add a headline, export, upload. Every time the UI changes, you redo all of it by hand. For every new app you ship, that ritual is the bottleneck in an otherwise quick launch. So I moved it into the repo.

Why the Usual Tools Don’t Fit
I looked at the obvious tools first. fastlane’s frameit assumes you already have a framed window and can’t draw the chrome. macshot is GUI-only. ImageMagick scripting works but is painful, and it fights you on Czech diacritics (á č ř ž ě), which is a real problem when the whole listing is in Czech. A Node and Puppeteer stack would work but means dragging a whole JS toolchain into a Swift project.
Then the obvious thing clicked: the app already renders Czech text perfectly with CoreText. If I can render an arbitrary SwiftUI view to a PNG at exact App Store dimensions, then the marketing frame, the window chrome, the gradient, and the captions are just more SwiftUI. Drawn by the same engine as the app, kept in the same repo, under the same version control.
The whole problem reduces to one capability: render a SwiftUI view to a PNG on disk at a fixed pixel size, deterministically, from a test. Once you have that, “make marketing screenshots” stops being a design-tool problem.
One Command, Seven Seconds
Each screenshot is a @Test. Generating the entire store-ready set is:
swift test --filter ScreenshotTests
About seven seconds later, AppStore/Screenshots/*.png is freshly regenerated. No GUI, no Figma, no manual capture anywhere in the loop. The pipeline takes the real shipping SwiftUI screen and feeds it fictional demo data. It wraps that in a marketing frame (headline, faux-macOS window, indigo gradient), hosts it offscreen in an NSWindow, and captures it at 2x backing scale. The result is a 2880x1800 PNG committed to git.
All the test-only code lives in one small support module that depends only on the app’s domain layer and never touches shipping code: a renderer, the marketing frame, a faux sidebar, the Czech captions, and the demo data. Each screen gets one *ScreenshotTests.swift test, and the shared suffix is what lets --filter ScreenshotTests run exactly this set and nothing else.
How the Renderer Works
This is the heart of it. The key function:
@discardableResult
public static func render(
_ view: some View,
fileName: String,
size: CGSize,
colorScheme: ColorScheme = .light,
settleSeconds: TimeInterval = 0.35
) -> URL
Four things happen. First, the environment gets forced: the view is pinned to the exact logical size and given .environment(\.locale, Locale(identifier: "cs_CZ")) so dates and currency format in Czech. Second, it’s wrapped in an NSHostingController inside a borderless NSWindow and made visible offscreen. That “visible” part is what makes the SwiftUI lifecycle actually run: layout, Chart rendering, and so on.
Third, and this is the subtle part, the run loop gets spun manually to let layout settle. SwiftUI layout doesn’t complete synchronously, so:
private static func spinRunLoop(seconds: TimeInterval) {
let deadline = Date().addingTimeInterval(seconds)
while Date() < deadline {
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
}
}
Fourth, it captures: the hosting view hands back a bitmap via cacheDisplay(in:to:). At 2x backing scale, a 1440x900 pt frame produces a 2880x1800 px PNG, written straight into the repo. This offscreen-capture step is the same mechanism that pointfreeco’s swift-snapshot-testing uses for its macOS .image strategy, which is why it’s the one external dependency here. The framing and rendering on top are all custom.
That’s the entire engine. Everything else comes down to which view you hand it.
Feeding Real Views Real-Looking Data
The goal is to render the actual shipping view, not a mock. There are two patterns depending on how a screen gets its data.
When a view is driven by bindings and inputs, you just supply them. The examples here come from Cifra, a macOS invoicing app, where the invoice editor is the hero shot. InvoiceEditorView takes everything as bindings, so the test builds a small host that owns @State for the invoice number, contact, dates, currency, and line items, then passes it all into the real view. The result is the genuine shipping editor in a believable loaded state.
When a view owns a private async .task that loads from a repository, that async work never settles inside the offscreen host (more on that below). So instead of fighting it, I reconstruct the loaded layout using the app’s real sub-views and strings, fed with demo rows. The invoices list rebuilds the same Table with the same columns and about 14 demo rows, a realistic mix of paid, unpaid, and overdue, so it looks exactly like the real one.
The demo data is a single source of fictional truth, with a fixed “today” so overdue states stay deterministic across runs. No real people or IČO numbers, everything plausible but invented.
The Hard Part: Async Preview Panes
The invoice and expense editors have a live PDF preview pane that renders asynchronously. Inside the snapshot host, that pane never finishes. It just stays a spinner.
The fix is my favorite part of the system. Rather than make the async pane settle, I generate the real PDF directly through the app’s shipping PDF service, then composite it over the editor’s preview region.
let pdf = try? LiveInvoicePDFService().generate(DemoData.invoicePDFRequest()).data
The renderer rasterizes the PDF’s first page to an NSImage, and the host overlays it into exactly the region the live pane occupies. The payoff: the screenshot shows the real editor and the real, fully rendered invoice PDF (with QR payment code) side by side, both produced by shipping code.
Captions Are Code, and Claims Must Be True
The text on a screenshot isn’t decoration. The App Store OCR-indexes it, so each headline carries a real search keyword (faktura, výdaje, cizí měna, DPH, hlášení, iCloud, ARES). Because all the strings live alone in ScreenshotCaptions.swift, they can be run through a native-Czech review for grammar, declension, and tone, then edited in place without ever touching layout code. CoreText handles every diacritic with the system font, no special font wiring. That’s exactly the pain point that kills the ImageMagick and frameit routes.
Two rules govern the copy. Headlines stay short (around five words), sublines a little longer, benefit-led and in natural Czech. And no caption ever advertises a feature the app doesn’t ship. Every claim is verified against the codebase before it’s written, because the listing was built from a code-level feature inventory in the first place.
Uploading Without Leaving the Terminal
Generating the PNGs is only half the release step. The other half is getting them onto App Store Connect, and that used to mean the web uploader. I do it from the terminal now with the asc CLI, an App Store Connect command-line tool:
asc screenshots validate --path AppStore/Screenshots --device-type APP_DESKTOP
asc screenshots upload --app <app-id> --version 1.0 \
--path AppStore/Screenshots --device-type APP_DESKTOP
App Store Connect orders Mac screenshots by filename, which is why the files are prefixed 01- through 09-. The numeric prefix is the display order.
The nice part for an agentic workflow: asc is JSON-first with no interactive prompts, and it ships agent skills of its own. So the whole step, regenerate the screenshots with swift test, validate, then upload, is something an agent can drive end to end. The screenshots never leave the repo until a single command pushes them live.
Gotchas That Cost Real Time
A few things bit me, and they’re the parts most worth knowing if you build something similar:
- Async
.tasknever settles offscreen. Anything that loads asynchronously renders empty or spinning. Render from an already-loaded state instead, either by injecting final bindings or reconstructing the layout with demo rows. - There’s no real window chrome offscreen.
.searchablefields andToolbarItembuttons don’t render, which is why the frame draws its own chrome. Don’t design a shot that depends on the real toolbar. - Demo data has to be valid. A fictional EUR IBAN with wrong mod-97 check digits silently failed QR and PDF generation, and the error was swallowed by
try?, leaving a spinner. When a pane is blank, stop swallowing the error before anything else.
Why It Works
Everything lives together, so a UI change and its screenshot update land in the same commit. The output is deterministic, and it renders the real product instead of a mockup that lies about the UI. The agentic workflow made the code fast to write. Moving screenshots into the same workflow made them just as fast to ship. The last manual step in my release became one more thing the repo automates for me.
Takeaways
- Find the manual step that survived your automation. When development gets fast, the slow part is whatever still lives outside the repo. For app releases, that’s often the store screenshots.
- Reframe the problem until it’s one capability. “Make marketing screenshots” was a design-tool problem. “Render a SwiftUI view to a PNG from a test” was a small engineering one.
- Render the real views, not mockups. Feeding the actual shipping screens with fictional demo data means the screenshots can’t drift from the UI they’re supposed to show.
- Keep the words separate from the layout. Isolating captions made them reviewable and language-checkable on their own, and let CoreText solve the diacritics that break image-scripting tools.