Shotlist guide
Setting up a project
Run shotlist init, add ShotlistKit to your app, host the scenes with @Shot or by hand in SwiftUI or UIKit, and get the app ready for its first capture.
Setting up a project
Setting up takes four steps:
- Run
shotlist initto create theShotlist/folder and aScreenshots.swift. - Add the ShotlistKit package to your app.
- Host the scenes in your app’s entry point.
- Make sure the app declares every locale you capture.
Then write your scenes (Writing scenes) and capture.
1. Run shotlist init
Run it in the folder that holds your Xcode project or workspace:
cd ~/Developer/MyApp
shotlist init --scenes-file MyApp/Screenshots.swift
In the Mac app, choose Set Up a Project… in the Welcome window and pick the same folder. It runs init and opens the project.
init creates:
| File | What it’s for |
|---|---|
Shotlist/shotlist.json |
The project: app, slots, locales, appearances and the sequence of scenes. See Configuration. |
Shotlist/Captions.xcstrings |
An empty String Catalog for captions, with en as its source language. |
Shotlist/Fixtures/README.md |
A note on how HTTP fixtures are laid out. See Readiness and fixtures. |
Shotlist/Templates/.gitkeep |
Keeps the folder for your own templates. |
Shotlist/.gitignore |
Leaves out Output/, where captures and composed images go. |
Screenshots.swift |
A starting point for your scenes, at the path you give with --scenes-file (default <project-dir>/Screenshots.swift). |
init never overwrites a file that already exists. It lists what it created and what it skipped. Pass --force to overwrite.
| Option | Effect |
|---|---|
--scenes-file <path> |
Where to write Screenshots.swift. Put it in your app target’s folder. |
--force |
Overwrite files that already exist. |
--project-dir <folder> |
The folder to set up, instead of the current one. |
--json |
Print the created and skipped files as JSON. |
What init detects
init looks in the folder for your Xcode project:
- If there’s an
.xcworkspace, it writes"workspace"and uses the workspace’s name as the scheme. - Otherwise, if there’s an
.xcodeproj, it writes"project", uses the project’s name as the scheme, and reads the firstPRODUCT_BUNDLE_IDENTIFIERthat isn’t a test target’s and isn’t a build-setting reference such as$(…). - If it finds neither, it writes placeholders:
MyApp.xcodeproj,MyAppandcom.example.myapp.
Check app in Shotlist/shotlist.json afterwards. The scheme in particular is often not the project’s name. The generated file looks like this:
{
"version": 1,
"app": {
"project": "MyApp.xcodeproj",
"scheme": "MyApp",
"bundleID": "com.example.myapp"
},
"slots": ["iphone-6.9", "iphone-6.3"],
"locales": ["en-GB"],
"appearances": ["light"],
"fixedDate": "2026-06-14T10:30:00Z",
"statusBar": { "time": "9:41", "battery": 100 },
"sequence": [
{ "scene": "home", "template": "caption-top" }
]
}
Add your locales, the slots you need and a timeZone now. See Configuration.
What to commit
Commit everything in Shotlist/ except Output/, which the generated .gitignore already leaves out. That includes published.json once you’ve published: it records which screenshots in App Store Connect Shotlist uploaded.
The first capture also creates .shotlist/ beside Shotlist/. It holds the build (about 1 GB of DerivedData) and scratch files, and contains a .gitignore of *, so it never shows in git status.
2. Add ShotlistKit to your app
ShotlistKit is the small library that runs inside your app during a capture. It talks to the engine over a loopback connection, builds each scene, answers HTTP requests from fixtures and decides when a scene is ready.
Every symbol in it sits inside #if DEBUG, so a Release build contains none of it. You can prove that in CI; see CI and automation.
Products
| Product | Add it when | What it adds |
|---|---|---|
| ShotlistKit | Always | Scenes, the host, readiness, holds, fixtures. No dependencies. |
| ShotlistDependencies | The app uses swift-dependencies | context.pin(&dependencies) and pinningDependencies(), which pin the date, UUIDs, clock and locale. |
| ShotlistTCA | The app uses the Composable Architecture | TCAScene, which builds a whole screen from one State value with pinned dependencies. Also links ShotlistDependencies. |
| ShotlistMacros | You want @Shot to host the scenes for you |
@Shot, which writes your App’s body so there’s no #if DEBUG in it. See SwiftUI with @Shot. |
All four come from one open-source package, ShotlistKit on GitHub, under the MIT licence. When you add it, Xcode resolves its dependencies (swift-dependencies, the Composable Architecture and swift-syntax) even if you link only ShotlistKit. It links only the products you choose, and builds swift-syntax only for ShotlistMacros.
Use a ShotlistKit release from the same minor version as your Shotlist app and CLI: 0.1.x with Shotlist 0.1. @Shot needs 0.1.1 or later. The engine checks the protocol version when your app connects, and says so if they don’t match.
ShotlistKit needs iOS 17 or later.
In Xcode
- Choose File › Add Package Dependencies…
- Enter
https://github.com/richiehopedbt/ShotlistKitand choose Up to Next Major Version from 0.1.1. - Add ShotlistKit (and ShotlistMacros, ShotlistDependencies or ShotlistTCA if you need them) to your app target.
Link the products in every configuration, as Xcode does by default. There’s no need to limit them to Debug: in Release, the code compiles to nothing.
In XcodeGen
packages:
ShotlistKit:
url: https://github.com/richiehopedbt/ShotlistKit
from: 0.1.1
targets:
MyApp:
dependencies:
# Every symbol in these is inside #if DEBUG, so Release builds link nothing from them.
- package: ShotlistKit
product: ShotlistKit
- package: ShotlistKit
product: ShotlistMacros
- package: ShotlistKit
product: ShotlistTCA
Configurations
Shotlist builds the Debug configuration by default. If you capture another configuration (set app.configuration in shotlist.json), it must define the DEBUG compilation condition (SWIFT_ACTIVE_COMPILATION_CONDITIONS includes DEBUG). Without it, ShotlistKit compiles to nothing, the app never contacts the engine, and every cell fails waiting for its hello.
3. Host the scenes
Move Screenshots.swift into your app target if init didn’t write it there, and add it to the target in Xcode.
Then install the Shotlist host as your app’s root, but only when Shotlist launched the app. Shotlist.isActive is true only then: it reads a launch argument the engine passes. Normal Debug runs from Xcode, tests and previews are unaffected.
SwiftUI with @Shot
For an app whose body is one WindowGroup, @Shot does all of this. Link ShotlistMacros as well as ShotlistKit, then write root, your app’s normal root view, instead of body:
import ShotlistMacros
import SwiftUI
@main
@Shot
struct LarderApp: App {
var root: some View {
RootView()
}
}
@Shot writes body for you: a WindowGroup showing root. When Shotlist launches the app, the window shows the Shotlist host instead, with your scenes from Screenshots.scenes. In a Release build, the body is just WindowGroup { root }, exactly as if you’d written it yourself.
- Write
root, notbody. A macro can add code to your app but can’t change code you’ve written, so@Shotwritesbodyitself. If yourAppalready has one, the build stops and says so. - Import ShotlistMacros outside
#if DEBUG.@Shothas to exist in Release builds too, where it writes the plain body. ShotlistMacros declares nothing but the macro, so Release builds still contain no Shotlist code. It imports ShotlistKit for you. - Your app’s state stays where it is. Properties such as
@State private var store = Store()work as before, androotcan use them:var root: some View { ContentView().environment(store) }. - The first build asks you to trust the macro. Xcode asks before it runs a package’s macros; choose Trust & Enable. On a CI runner, see CI and automation.
Link scenes start from root. If they need a different root, such as one with the run’s fixed date, add a shotlistRoot. Put it inside #if DEBUG when it uses ShotlistKit, as this one does:
@main
@Shot
struct LarderApp: App {
var root: some View {
RootView()
}
#if DEBUG
// Link scenes start from this root; its requests are answered from fixtures.
var shotlistRoot: some View {
RootView(clock: .fixed(Shotlist.now))
}
#endif
}
To receive the link scene’s context, make it a function instead: func shotlistRoot(_ context: ShotlistContext?) -> some View. The context is nil until the first scene.
Host the scenes yourself, as below, if your app has more than one scene, scene modifiers such as .backgroundTask, or its scenes in a type other than Screenshots.
SwiftUI, hosting the scenes yourself
In your App:
import SwiftUI
#if DEBUG
import ShotlistKit
#endif
@main
struct LarderApp: App {
var body: some Scene {
WindowGroup {
#if DEBUG
if Shotlist.isActive {
// Link scenes start from this root; its requests are answered from fixtures.
ShotlistHost(scenes: Screenshots.scenes) { RootView(clock: .fixed(Shotlist.now)) }
} else {
RootView()
}
#else
RootView()
#endif
}
}
}
The trailing closure is your app’s normal root, with whatever environment it needs. Only LinkScenes use it: they start from this root and let your app’s own URL handling navigate. Other scenes build their own root. Give the root the run’s fixed date (Shotlist.now) and anything else a link scene needs, so link scenes are as repeatable as the rest.
If the root needs the link scene’s context, for its date, locale or slot, use the form whose closure takes one. The context is nil until the first scene:
ShotlistHost(scenes: Screenshots.scenes) { context in
RootView(clock: .fixed(context?.now ?? Shotlist.now))
}
ShotlistHost also:
- turns off animations for everything it shows;
- hides scroll indicators;
- routes
AsyncImagethrough the fixtures (on iOS 27,AsyncImagedoesn’t useURLSession.shared); - shows each scene as a fresh view, so state doesn’t leak from one scene’s view tree to the next.
UIKit
In your scene delegate’s scene(_:willConnectTo:options:), after creating the window:
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
self.window = window
#if DEBUG
if Shotlist.isActive {
window.rootViewController = Shotlist.hostingController(scenes: Screenshots.scenes) { [unowned self] in
makeRootViewController()
}
window.makeKeyAndVisible()
return
}
#endif
window.rootViewController = makeRootViewController()
window.makeKeyAndVisible()
}
The closure builds your normal root view controller, which link scenes start from. Shotlist calls it afresh for each link scene, so each one starts from the app’s launch state. A form whose closure takes a ShotlistContext? is also available:
window.rootViewController = Shotlist.hostingController(scenes: Screenshots.scenes) { context in
RootTabBarController(clock: .fixed(context?.now ?? Shotlist.now))
}
For link scenes in a UIKit app, implement scene(_:openURLContexts:). Shotlist opens the link while the app is running, so a handler that only reads connectionOptions.urlContexts at launch never sees it:
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for context in URLContexts {
root?.open(context.url)
}
}
TCA
The same SwiftUI host works for a TCA app, and so does @Shot (var root: some View { AppView(store: Self.store) }). Give the root store the run’s date, so link scenes see it:
@main
struct FixtureClubApp: App {
@MainActor
static let store = Store(initialState: AppFeature.State()) {
AppFeature()
} withDependencies: { dependencies in
#if DEBUG
// Link scenes start from this store, so it sees the run's fixed date too.
if Shotlist.isActive { dependencies.date = .constant(Shotlist.now) }
#endif
}
var body: some Scene {
WindowGroup {
#if DEBUG
if Shotlist.isActive {
ShotlistHost(scenes: Screenshots.scenes) { AppView(store: Self.store) }
} else {
AppView(store: Self.store)
}
#else
AppView(store: Self.store)
#endif
}
}
}
Register your URL scheme
If you use LinkScene, your app’s URL scheme must be registered in its Info.plist (CFBundleURLTypes). ShotlistKit opens the link from inside the app with UIApplication.shared.open(_:), which only accepts a scheme the app declares. Opened from inside the app, the link raises no “Open in…?” alert.
In XcodeGen:
info:
properties:
CFBundleURLTypes:
- CFBundleURLName: app.shotlist.example.tca
CFBundleURLSchemes: [shotlist-tca]
4. Declare every locale
The app must declare every locale you capture, as it would for the App Store: a String Catalog with that language, or an .lproj folder. Shotlist launches the app with the locale as its language and region (-AppleLanguages (de-DE) and -AppleLocale de_DE for de-DE). If the app doesn’t declare that language, iOS falls back to another, and its text, dates and numbers come out in that language.
verify catches the usual case: it fails a locale whose captures are all identical to another language’s, and says the app probably doesn’t declare it. Regional variants of one language, such as en-GB and en-US, may legitimately match, so they’re not compared.
Make the root accept a starting screen
To take your app’s real root to a screen, the root needs to accept its starting state: the selected tab and the navigation path. If your root keeps those in private @State with fixed initial values, add an initialiser that sets them, or move them into a router object:
/// Owns the selected tab and each tab's navigation path. Deep links land here.
@MainActor
@Observable
final class Router {
enum Tab: Hashable { case reading, library }
enum Route: Hashable { case book(slug: String) }
var tab: Tab
var readingPath: [Route]
var libraryPath: [Route]
init(tab: Tab = .reading, readingPath: [Route] = [], libraryPath: [Route] = []) {
self.tab = tab
self.readingPath = readingPath
self.libraryPath = libraryPath
}
}
struct RootView: View {
@State private var router: Router
private let clock: AppClock
init(router: Router = Router(), clock: AppClock = .live) {
self.router = router
self.clock = clock
}
var body: some View {
@Bindable var router = router
TabView(selection: $router.tab) {
NavigationStack(path: $router.readingPath) {
ReadingView()
.navigationDestination(for: Router.Route.self, destination: destination)
}
.tabItem { Label("Reading", systemImage: "book") }
.tag(Router.Tab.reading)
// …
}
.environment(\.appClock, clock)
.onOpenURL { router.open($0) }
}
}
A scene then passes the state in: RootView(router: Router(tab: .reading, readingPath: [.book(slug: "salt-and-silver")])).
If your app already handles deep links, a LinkScene needs no change to the root at all.
Check, then capture
shotlist doctor
shotlist capture
The first capture creates the simulators, which takes longer than later runs. See Capturing.
If every cell fails waiting for a hello from the app, the host isn’t installed or Shotlist.isActive is false. See Troubleshooting.