ios-marketing-capture-automation
Automate multi-locale marketing screenshot capture for SwiftUI iOS apps with in-app capture system, demo data seeding, and element rendering.
How do I install this agent skill?
npx skills add https://github.com/aradotso/marketing-skills --skill ios-marketing-capture-automationIs this agent skill safe to install?
- Gen Agent Trust Hubwarn
This skill automates iOS marketing screenshot capture through Swift code and shell scripts. It recommends downloading and installing code from untrusted external GitHub repositories and executes several shell commands to interact with Xcode simulators. While these operations are functional for its purpose, they involve unverifiable dependencies and system-level command execution.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
iOS Marketing Capture Automation
Skill by ara.so — Marketing Skills collection.
What This Skill Does
This skill helps you automate marketing screenshot capture for SwiftUI iOS apps by building an in-app capture system that:
- Adds a
#if DEBUG-gated capture system with zero production footprint - Seeds deterministic demo data so screenshots look populated and polished
- Navigates to each screen programmatically via step-based coordinator
- Snapshots full window including status bar, safe area, and presented sheets
- Renders isolated elements (cards, widgets, charts) via
ImageRendererat 3x with transparency - Loops every locale automatically — one build, N relaunches with
-AppleLanguages - Works with any SwiftUI navigation:
TabView,NavigationStack,NavigationSplitView
Installation
Using npx skills (recommended)
npx skills add ParthJadhav/ios-marketing-capture
Global install (available across all projects):
npx skills add ParthJadhav/ios-marketing-capture -g
Agent-specific install:
npx skills add ParthJadhav/ios-marketing-capture -a claude-code
Manual installation
git clone https://github.com/ParthJadhav/ios-marketing-capture ~/.claude/skills/ios-marketing-capture
Requirements Checklist
Before starting, verify:
- ✅ Xcode 16+ (synchronized folder groups support)
- ✅ iOS 17+ deployment target (for
ImageRenderer,@Observable) - ✅ A simulator runtime matching target iOS version
- ✅ Python 3 (for JSON parsing in shell script)
- ✅ SwiftUI-based app with defined navigation structure
Core Concepts
In-App Capture (Not XCUITest)
This approach uses in-app capture instead of XCUITest/Fastlane because:
- No test target needed — many projects lack one, adding means fragile pbxproj edits
- Direct access — ViewModels, SwiftData,
ImageRenderer,UIWindow.drawHierarchy - Faster —
xcodebuild buildonce, thensimctl launchper locale - Element renders require it —
ImageRenderermust run inside app process
Step-Based Coordinator
Each screenshot is a self-contained CaptureStep:
struct CaptureStep {
let name: String // "01-home"
let navigate: @MainActor () -> Void // put app in right state
let settle: Duration // wait for animations
let cleanup: (@MainActor () -> Void)? // tear down before next step
}
Implementation Pattern
1. Gather Requirements
When user asks to capture screenshots, collect:
- Screens to capture — exact tab names or navigation paths
- Elements to render — cards, widgets, charts to isolate
- Locales — explicit list or "all locales in xcstrings"
- Device — simulator model (e.g. "iPhone 17")
- Appearance — light, dark, or both
- Seed data requirements — what demo data needs to populate
2. Generated File Structure
Create this structure:
YourApp/
├── Debug/
│ └── MarketingCapture.swift # Capture system (DEBUG-only)
├── ContentView.swift # Modified — DEBUG hook
scripts/
└── capture-marketing.sh # Build + locale loop
Output lands in:
marketing/
en/
01-home.png
02-detail.png
elements/
card-item.png
widget-small.png
de/
01-home.png
...
3. Core Capture System Code
#if DEBUG
import SwiftUI
struct CaptureStep {
let name: String
let navigate: @MainActor () -> Void
let settle: Duration
let cleanup: (@MainActor () -> Void)?
}
@Observable
final class MarketingCaptureCoordinator {
var isCapturing = false
var currentStep = 0
private let steps: [CaptureStep]
private let outputDir: URL
private let elementsDir: URL
init(steps: [CaptureStep], locale: String) {
self.steps = steps
let base = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("marketing/\(locale)")
self.outputDir = base
self.elementsDir = base.appendingPathComponent("elements")
try? FileManager.default.createDirectory(
at: outputDir,
withIntermediateDirectories: true
)
try? FileManager.default.createDirectory(
at: elementsDir,
withIntermediateDirectories: true
)
}
@MainActor
func startCapture() async {
isCapturing = true
currentStep = 0
for step in steps {
step.navigate()
try? await Task.sleep(for: step.settle)
captureWindow(name: step.name)
step.cleanup?()
currentStep += 1
}
isCapturing = false
print("✅ Capture complete: \(outputDir.path)")
exit(0)
}
private func captureWindow(name: String) {
guard let window = UIApplication.shared
.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first?.windows.first else { return }
let renderer = UIGraphicsImageRenderer(bounds: window.bounds)
let image = renderer.image { ctx in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
let url = outputDir.appendingPathComponent("\(name).png")
try? image.pngData()?.write(to: url)
}
}
struct MarketingElementHarness {
@MainActor
static func renderElement<Content: View>(
name: String,
width: CGFloat,
cornerRadius: CGFloat,
background: Color,
@ViewBuilder content: () -> Content
) {
let renderer = ImageRenderer(content: content()
.frame(width: width)
.background(background)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
)
renderer.scale = 3.0
guard let uiImage = renderer.uiImage else { return }
let base = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.deletingLastPathComponent()
.deletingLastPathComponent()
let locale = Locale.current.language.languageCode?.identifier ?? "en"
let elementsDir = base
.appendingPathComponent("marketing/\(locale)/elements")
try? FileManager.default.createDirectory(
at: elementsDir,
withIntermediateDirectories: true
)
let url = elementsDir.appendingPathComponent("\(name).png")
try? uiImage.pngData()?.write(to: url)
}
}
#endif
4. Navigation Pattern Examples
TabView Navigation
@Observable
final class AppCoordinator {
var selectedTab = 0
func setTab(_ index: Int) {
selectedTab = index
}
}
// In MarketingCapture.swift:
func makeSteps(coordinator: AppCoordinator) -> [CaptureStep] {
[
CaptureStep(
name: "01-home",
navigate: { coordinator.setTab(0) },
settle: .seconds(0.5),
cleanup: nil
),
CaptureStep(
name: "02-shelf",
navigate: { coordinator.setTab(1) },
settle: .seconds(0.5),
cleanup: nil
)
]
}
NavigationStack with Router
@Observable
final class Router {
var path: [Route] = []
func push(_ route: Route) {
path.append(route)
}
func popToRoot() {
path.removeAll()
}
}
// Steps:
CaptureStep(
name: "03-detail",
navigate: {
router.popToRoot()
router.push(.coffeeDetail(coffee))
},
settle: .seconds(0.8),
cleanup: { router.popToRoot() }
)
NavigationSplitView
@Observable
final class SplitCoordinator {
var sidebarSelection: SidebarItem?
var detailSelection: DetailItem?
}
// Steps:
CaptureStep(
name: "04-settings",
navigate: {
coordinator.sidebarSelection = .settings
coordinator.detailSelection = nil
},
settle: .seconds(0.6),
cleanup: nil
)
5. Demo Data Seeding
#if DEBUG
@MainActor
func seedMarketingData(modelContext: ModelContext) {
// Clear existing
try? modelContext.delete(model: Coffee.self)
// Seed deterministic data
let coffees = [
Coffee(
name: "Morning Blend",
roaster: "Blue Bottle",
origin: "Ethiopia",
notes: ["Blueberry", "Chocolate", "Citrus"],
roastDate: Date().addingTimeInterval(-7 * 86400)
),
Coffee(
name: "Dark Horse",
roaster: "Stumptown",
origin: "Colombia",
notes: ["Caramel", "Nuts", "Brown Sugar"],
roastDate: Date().addingTimeInterval(-3 * 86400)
)
]
coffees.forEach { modelContext.insert($0) }
try? modelContext.save()
}
#endif
6. Integrating with ContentView
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@State private var coordinator = AppCoordinator()
#if DEBUG
@State private var captureCoordinator: MarketingCaptureCoordinator?
#endif
var body: some View {
TabView(selection: $coordinator.selectedTab) {
HomeView()
.tag(0)
.tabItem { Label("Home", systemImage: "house") }
ShelfView()
.tag(1)
.tabItem { Label("Shelf", systemImage: "books.vertical") }
}
#if DEBUG
.task {
if ProcessInfo.processInfo.environment["MARKETING_CAPTURE"] == "1" {
seedMarketingData(modelContext: modelContext)
let locale = Locale.current.language.languageCode?.identifier ?? "en"
captureCoordinator = MarketingCaptureCoordinator(
steps: makeMarketingSteps(coordinator: coordinator),
locale: locale
)
try? await Task.sleep(for: .seconds(1))
await captureCoordinator?.startCapture()
}
}
#endif
}
}
7. Element Rendering Examples
Widget Rendering
#if DEBUG
@MainActor
func renderWidgets() {
let entry = PulseWidgetEntry(
date: Date(),
coffee: seedCoffee,
recentBrew: seedBrew
)
// Small widget
MarketingElementHarness.renderElement(
name: "widget-pulse-small",
width: 158,
cornerRadius: 16,
background: .white
) {
PulseWidgetSmallView(entry: entry)
.padding(16) // WidgetKit padding manually added
}
// Medium widget
MarketingElementHarness.renderElement(
name: "widget-pulse-medium",
width: 338,
cornerRadius: 16,
background: .white
) {
PulseWidgetMediumView(entry: entry)
.padding(16)
}
}
#endif
Card Rendering
#if DEBUG
@MainActor
func renderCards(coffees: [Coffee]) {
for (index, coffee) in coffees.enumerated() {
MarketingElementHarness.renderElement(
name: "card-\(index + 1)",
width: 380,
cornerRadius: 20,
background: Color(.systemBackground)
) {
CoffeeCard(coffee: coffee)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
}
#endif
Chart Rendering
#if DEBUG
@MainActor
func renderCharts() {
MarketingElementHarness.renderElement(
name: "chart-cupping",
width: 380,
cornerRadius: 16,
background: Color(.systemBackground)
) {
CuppingRadarChart(scores: sampleScores)
.frame(height: 300)
.padding(20)
}
}
#endif
8. Build and Launch Script
#!/bin/bash
# scripts/capture-marketing.sh
set -e
APP_NAME="YourApp"
SCHEME="YourApp"
DEVICE="iPhone 17"
IOS_VERSION="18.2"
LOCALES=("en" "de" "es" "fr" "ja")
SIMULATOR_ID=$(xcrun simctl list devices available | \
grep "$DEVICE ($IOS_VERSION)" | head -1 | \
grep -o '\[.*\]' | tr -d '[]')
if [ -z "$SIMULATOR_ID" ]; then
echo "❌ Simulator '$DEVICE ($IOS_VERSION)' not found"
exit 1
fi
echo "📱 Using simulator: $SIMULATOR_ID"
# Boot simulator
xcrun simctl boot "$SIMULATOR_ID" 2>/dev/null || true
sleep 2
# Build once
echo "🔨 Building..."
xcodebuild \
-scheme "$SCHEME" \
-destination "id=$SIMULATOR_ID" \
-configuration Debug \
-derivedDataPath ./build \
build
APP_PATH=$(find ./build -name "${APP_NAME}.app" | head -1)
BUNDLE_ID=$(defaults read "$APP_PATH/Info.plist" CFBundleIdentifier)
echo "📦 Bundle ID: $BUNDLE_ID"
# Install once
xcrun simctl install "$SIMULATOR_ID" "$APP_PATH"
# Per-locale capture
for LOCALE in "${LOCALES[@]}"; do
echo ""
echo "📸 Capturing $LOCALE..."
xcrun simctl launch \
--terminate-running-process \
"$SIMULATOR_ID" \
"$BUNDLE_ID" \
-AppleLanguages "($LOCALE)" \
-MARKETING_CAPTURE 1
sleep 8
done
# Copy to project
CONTAINER=$(xcrun simctl get_app_container "$SIMULATOR_ID" "$BUNDLE_ID" data)
cp -R "$CONTAINER/Documents/../../tmp/marketing" ./marketing/
echo ""
echo "✅ All locales captured → ./marketing/"
Critical Gotchas (Baked Into Skill)
1. Live Activities Persist Across Launches
Problem: Next locale crashes on stale SwiftData references.
Solution: End all Live Activities in cleanup:
for activity in Activity<PulseAttributes>.activities {
await activity.end(nil, dismissalPolicy: .immediate)
}
2. Re-Seeding Per Locale
Problem: CloudKit sync churn causes crashes.
Solution: Seed once at launch, not per step.
3. ViewModels Setup Before Seed
Problem: VMs hold stale empty snapshots.
Solution: Seed data → wait 1s → instantiate VMs → capture.
4. Setting Trigger Binding to nil
Problem: Doesn't dismiss fullScreenCover — captures wrong sheet.
Solution: Use dedicated dismiss closure:
.fullScreenCover(isPresented: $showTimer) {
TimerView(onDismiss: { showTimer = false })
}
5. NavigationPath Can't Be Popped Externally
Problem: Pushing over existing path captures wrong stack.
Solution: Always popToRoot() before push() in navigate block.
6. membershipExceptions Is an INCLUSION List
Problem: Widget target membership goes backwards — widget renders fail.
Solution: Set membershipExceptions for DEBUG files to exclude widget target:
// In pbxproj or via Xcode:
// MarketingCapture.swift → Target Membership → Widget ❌
7. ImageRenderer + ProgressView
Problem: Renders as prohibited symbol without explicit style.
Solution: Force .circular style:
ProgressView()
.progressViewStyle(.circular)
8. .containerBackground Outside WidgetKit
Problem: No-op — widget renders have no background.
Solution: Manually add background in render harness:
WidgetView(entry: entry)
.background(Color.white)
9. iPhone 8 Plus Gone on iOS 18+
Problem: Legacy 6.5" simulator unavailable.
Solution: Use iPhone 17 Pro Max or latest available large device.
10. Locale Launch Argument Format
Problem: Locale ignored if parens missing.
Solution: Always use "($LOCALE)" format:
-AppleLanguages "(de)" # ✅
-AppleLanguages "de" # ❌
11. SwiftUI Animations in ImageRenderer
Problem: Captures frame 0, not animated state.
Solution: Render non-animated state or use explicit .animation(nil).
Common Workflows
Capturing Timer Mid-Countdown
CaptureStep(
name: "05-timer-active",
navigate: {
router.popToRoot()
coordinator.startTimer(seconds: 180)
// Timer view subscribes to coordinator.activeTimer
},
settle: .seconds(1.5), // Let timer animate in
cleanup: {
coordinator.stopTimer()
router.popToRoot()
}
)
Capturing Presented Sheet
@State private var showSheet = false
// Step:
CaptureStep(
name: "06-add-coffee",
navigate: {
showSheet = true
},
settle: .seconds(0.8),
cleanup: {
showSheet = false
}
)
// View:
.sheet(isPresented: $showSheet) {
AddCoffeeView()
}
Rendering All Widgets
#if DEBUG
@MainActor
func renderAllWidgets() {
let entries = [
("pulse-small", PulseWidgetSmallView.self, 158),
("pulse-medium", PulseWidgetMediumView.self, 338),
("pulse-large", PulseWidgetLargeView.self, 338)
]
for (name, viewType, width) in entries {
MarketingElementHarness.renderElement(
name: "widget-\(name)",
width: width,
cornerRadius: 16,
background: .white
) {
viewType.init(entry: sampleEntry)
.padding(16)
}
}
}
#endif
Troubleshooting
Capture exits immediately
Check: MARKETING_CAPTURE=1 env var is set in launch args.
xcrun simctl launch ... -MARKETING_CAPTURE 1
Locale not applied
Check: Parens in -AppleLanguages:
-AppleLanguages "(de)" # ✅ Correct
Widget renders blank
Check: Manual padding added (WidgetKit adds 16pt automatically):
WidgetView(entry: entry)
.padding(16) // Add this
Screenshot captures wrong screen
Check: Settle duration long enough for animations:
settle: .seconds(0.8) // Increase if needed
SwiftData crash on second locale
Check: Live Activities ended in cleanup:
cleanup: {
Task {
for activity in Activity<Attrs>.activities {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
}
Element renders with wrong size
Check: Frame width explicitly set:
content()
.frame(width: 380) // Must be explicit
Configuration Reference
Capture Step Properties
| Property | Type | Purpose |
|---|---|---|
name | String | Filename without extension (e.g. "01-home") |
navigate | @MainActor () -> Void | Put app in correct state |
settle | Duration | Wait time for animations |
cleanup | (@MainActor () -> Void)? | Tear down before next step |
Script Variables
| Variable | Example | Purpose |
|---|---|---|
APP_NAME | "YourApp" | App target name |
SCHEME | "YourApp" | Xcode scheme |
DEVICE | "iPhone 17" | Simulator model |
IOS_VERSION | "18.2" | iOS runtime |
LOCALES | ("en" "de" "es") | Locale codes |
Environment Variables
Set in launch args or script:
MARKETING_CAPTURE=1 # Enable capture mode
Post-Processing
Use app-store-screenshots to composite captured PNGs into Apple-style marketing pages with device mockups, headlines, and gradients.
npx skills add ParthJadhav/app-store-screenshots
Then prompt:
Composite my marketing screenshots into App Store pages with device frames
When to Use This Skill
✅ Use when:
- Capturing marketing screenshots for App Store
- Rendering isolated components (cards, widgets, charts)
- Multi-locale asset generation
- SwiftUI-based iOS app with defined navigation
- Need full control over demo data and app state
❌ Don't use when:
- UIKit-based app (requires different capture approach)
- XCUITest infrastructure already working well
- Single locale, manual capture sufficient
- App uses web views or external content (can't seed)
Example: Complete Coffee App Capture
#if DEBUG
@MainActor
func makeMarketingSteps(
coordinator: AppCoordinator,
router: Router,
viewModel: HomeViewModel
) -> [CaptureStep] {
let coffees = viewModel.coffees
return [
// Tab 1: Home
CaptureStep(
name: "01-home",
navigate: { coordinator.setTab(0) },
settle: .seconds(0.5),
cleanup: nil
),
// Tab 2: Shelf
CaptureStep(
name: "02-shelf",
navigate: { coordinator.setTab(1) },
settle: .seconds(0.5),
cleanup: nil
),
// Detail: First coffee
CaptureStep(
name: "03-detail",
navigate: {
coordinator.setTab(0)
router.popToRoot()
router.push(.coffeeDetail(coffees[0]))
},
settle: .seconds(0.8),
cleanup: { router.popToRoot() }
),
// Timer: Active brew
CaptureStep(
name: "04-timer",
navigate: {
coordinator.setTab(0)
router.popToRoot()
coordinator.startTimer(seconds: 180)
},
settle: .seconds(1.2),
cleanup: {
coordinator.stopTimer()
router.popToRoot()
}
),
// Settings
CaptureStep(
name: "05-settings",
navigate: { coordinator.setTab(2) },
settle: .seconds(0.5),
cleanup: nil
),
// Elements: Cards
CaptureStep(
name: "elements",
navigate: {
for (index, coffee) in coffees.enumerated() {
MarketingElementHarness.renderElement(
name: "card-\(index + 1)",
width: 380,
cornerRadius: 20,
background: Color(.systemBackground)
) {
CoffeeCard(coffee: coffee)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
renderAllWidgets()
},
settle: .seconds(0.1),
cleanup: nil
)
]
}
#endif
Run:
chmod +x scripts/capture-marketing.sh
./scripts/capture-marketing.sh
Output:
marketing/
en/
01-home.png
02-shelf.png
03-detail.png
04-timer.png
05-settings.png
elements/
card-1.png
card-2.png
widget-pulse-small.png
widget-pulse-medium.png
de/
[same structure]
es/
[same structure]
fr/
[same structure]
ja/
[same structure]
Summary
This skill automates iOS marketing screenshot capture by:
- Gathering requirements — screens, elements, locales, device, appearance
- Generating capture system — DEBUG-gated coordinator + steps
- Seeding demo data — deterministic, locale-agnostic
- Navigating programmatically — TabView, NavigationStack, or SplitView
- Capturing window —
drawHierarchyfor full screenshots - Rendering elements —
ImageRendererfor isolated components - Looping locales —
simctl launchwith-AppleLanguages
The result: one build, N launches, complete multi-locale marketing assets ready for App Store or further compositing.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/aradotso/marketing-skills/ios-marketing-capture-automation">View ios-marketing-capture-automation on skillZs</a>