Loading...
Loading...
Automate multi-locale marketing screenshot capture for SwiftUI iOS apps with in-app capture system, demo data seeding, and element rendering.
npx skill4agent add aradotso/marketing-skills ios-marketing-capture-automationSkill by ara.so — Marketing Skills collection.
#if DEBUGImageRenderer-AppleLanguagesTabViewNavigationStackNavigationSplitViewnpx skills add ParthJadhav/ios-marketing-capturenpx skills add ParthJadhav/ios-marketing-capture -gnpx skills add ParthJadhav/ios-marketing-capture -a claude-codegit clone https://github.com/ParthJadhav/ios-marketing-capture ~/.claude/skills/ios-marketing-captureImageRenderer@ObservableImageRendererUIWindow.drawHierarchyxcodebuild buildsimctl launchImageRendererCaptureStepstruct 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
}YourApp/
├── Debug/
│ └── MarketingCapture.swift # Capture system (DEBUG-only)
├── ContentView.swift # Modified — DEBUG hook
scripts/
└── capture-marketing.sh # Build + locale loopmarketing/
en/
01-home.png
02-detail.png
elements/
card-item.png
widget-small.png
de/
01-home.png
...#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@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
)
]
}@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() }
)@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
)#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()
}
#endifstruct 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
}
}#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#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#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#!/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/"for activity in Activity<PulseAttributes>.activities {
await activity.end(nil, dismissalPolicy: .immediate)
}fullScreenCover.fullScreenCover(isPresented: $showTimer) {
TimerView(onDismiss: { showTimer = false })
}popToRoot()push()membershipExceptionsmembershipExceptions// In pbxproj or via Xcode:
// MarketingCapture.swift → Target Membership → Widget ❌ImageRendererProgressView.circularProgressView()
.progressViewStyle(.circular).containerBackgroundWidgetView(entry: entry)
.background(Color.white)"($LOCALE)"-AppleLanguages "(de)" # ✅
-AppleLanguages "de" # ❌.animation(nil)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()
}
)@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()
}#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)
}
}
}
#endifMARKETING_CAPTURE=1xcrun simctl launch ... -MARKETING_CAPTURE 1-AppleLanguages-AppleLanguages "(de)" # ✅ CorrectWidgetView(entry: entry)
.padding(16) // Add thissettle: .seconds(0.8) // Increase if neededcleanup: {
Task {
for activity in Activity<Attrs>.activities {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
}content()
.frame(width: 380) // Must be explicit| Property | Type | Purpose |
|---|---|---|
| | Filename without extension (e.g. "01-home") |
| | Put app in correct state |
| | Wait time for animations |
| | Tear down before next step |
| Variable | Example | Purpose |
|---|---|---|
| | App target name |
| | Xcode scheme |
| | Simulator model |
| | iOS runtime |
| | Locale codes |
MARKETING_CAPTURE=1 # Enable capture modenpx skills add ParthJadhav/app-store-screenshotsComposite my marketing screenshots into App Store pages with device frames#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
)
]
}
#endifchmod +x scripts/capture-marketing.sh
./scripts/capture-marketing.shmarketing/
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]drawHierarchyImageRenderersimctl launch-AppleLanguages