ios-marketing-capture-automation

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

iOS Marketing Capture Automation

iOS营销截图自动化

Skill by ara.so — Marketing Skills collection.
ara.so提供的技能 — 营销技能合集。

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
    ImageRenderer
    at 3x with transparency
  • Loops every locale automatically — one build, N relaunches with
    -AppleLanguages
  • Works with any SwiftUI navigation:
    TabView
    ,
    NavigationStack
    ,
    NavigationSplitView
本技能通过构建应用内捕获系统,帮助你实现SwiftUI iOS应用营销截图的自动化捕获,具体包括:
  • 添加由
    #if DEBUG
    管控的捕获系统,对生产环境无任何影响
  • 填充确定性演示数据,让截图内容饱满、美观
  • 通过基于步骤的协调器以编程方式导航至每个屏幕
  • 捕获包含状态栏、安全区域和弹出表单的完整窗口
  • 通过
    ImageRenderer
    以3倍分辨率带透明效果渲染独立元素(卡片、小组件、图表)
  • 自动遍历所有语言环境 — 一次构建,多次重启并使用
    -AppleLanguages
    参数切换语言
  • 兼容所有SwiftUI导航方式:
    TabView
    NavigationStack
    NavigationSplitView

Installation

安装方法

Using npx skills (recommended)

使用npx skills(推荐)

bash
npx skills add ParthJadhav/ios-marketing-capture
Global install (available across all projects):
bash
npx skills add ParthJadhav/ios-marketing-capture -g
Agent-specific install:
bash
npx skills add ParthJadhav/ios-marketing-capture -a claude-code
bash
npx skills add ParthJadhav/ios-marketing-capture
全局安装(可在所有项目中使用):
bash
npx skills add ParthJadhav/ios-marketing-capture -g
针对特定Agent安装:
bash
npx skills add ParthJadhav/ios-marketing-capture -a claude-code

Manual installation

手动安装

bash
git clone https://github.com/ParthJadhav/ios-marketing-capture ~/.claude/skills/ios-marketing-capture
bash
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
开始前,请确认满足以下条件:
  • ✅ Xcode 16+(支持同步文件夹组)
  • ✅ iOS 17+部署目标(支持
    ImageRenderer
    @Observable
  • ✅ 与目标iOS版本匹配的模拟器运行时
  • ✅ Python 3(用于shell脚本中的JSON解析)
  • ✅ 基于SwiftUI且已定义导航结构的应用

Core Concepts

核心概念

In-App Capture (Not XCUITest)

应用内捕获(非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 build
    once, then
    simctl launch
    per locale
  • Element renders require it
    ImageRenderer
    must run inside app process
本方案采用应用内捕获而非XCUITest/Fastlane,原因如下:
  • 无需测试目标 — 许多项目没有测试目标,添加测试目标需要修改pbxproj,易出错
  • 直接访问 — 可直接访问ViewModels、SwiftData、
    ImageRenderer
    UIWindow.drawHierarchy
  • 速度更快 — 只需执行一次
    xcodebuild build
    ,然后针对每个语言环境执行
    simctl launch
  • 元素渲染必须依赖此方式
    ImageRenderer
    必须在应用进程内运行

Step-Based Coordinator

基于步骤的协调器

Each screenshot is a self-contained
CaptureStep
:
swift
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
}
每个截图任务都是一个独立的
CaptureStep
swift
struct CaptureStep {
    let name: String                         // "01-home"
    let navigate: @MainActor () -> Void      // 将应用置于正确状态
    let settle: Duration                     // 等待动画完成的时间
    let cleanup: (@MainActor () -> Void)?    // 进入下一步前的清理操作
}

Implementation Pattern

实现模式

1. Gather Requirements

1. 收集需求

When user asks to capture screenshots, collect:
  1. Screens to capture — exact tab names or navigation paths
  2. Elements to render — cards, widgets, charts to isolate
  3. Locales — explicit list or "all locales in xcstrings"
  4. Device — simulator model (e.g. "iPhone 17")
  5. Appearance — light, dark, or both
  6. Seed data requirements — what demo data needs to populate
当用户要求捕获截图时,需收集以下信息:
  1. 需捕获的屏幕 — 具体的标签页名称或导航路径
  2. 需渲染的元素 — 需独立提取的卡片、小组件、图表
  3. 语言环境 — 明确的列表或“xcstrings中的所有语言”
  4. 设备 — 模拟器型号(如"iPhone 17")
  5. 外观 — 浅色、深色或两者都要
  6. 演示数据需求 — 需要填充哪些演示数据

2. Generated File Structure

2. 生成的文件结构

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
        ...
创建如下结构:
YourApp/
├── Debug/
│   └── MarketingCapture.swift      // 捕获系统(仅DEBUG模式)
├── ContentView.swift               // 已修改 — 添加DEBUG钩子
scripts/
└── capture-marketing.sh            // 构建 + 语言环境循环脚本
输出文件将保存至:
marketing/
    en/
        01-home.png
        02-detail.png
        elements/
            card-item.png
            widget-small.png
    de/
        01-home.png
        ...

3. Core Capture System Code

3. 核心捕获系统代码

swift
#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
swift
#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

4. 导航模式示例

TabView Navigation

TabView导航

swift
@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
        )
    ]
}
swift
@Observable
final class AppCoordinator {
    var selectedTab = 0
    
    func setTab(_ index: Int) {
        selectedTab = index
    }
}

// 在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

带Router的NavigationStack

swift
@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() }
)
swift
@Observable
final class Router {
    var path: [Route] = []
    
    func push(_ route: Route) {
        path.append(route)
    }
    
    func popToRoot() {
        path.removeAll()
    }
}

// 步骤:
CaptureStep(
    name: "03-detail",
    navigate: {
        router.popToRoot()
        router.push(.coffeeDetail(coffee))
    },
    settle: .seconds(0.8),
    cleanup: { router.popToRoot() }
)

NavigationSplitView

NavigationSplitView

swift
@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
)
swift
@Observable
final class SplitCoordinator {
    var sidebarSelection: SidebarItem?
    var detailSelection: DetailItem?
}

// 步骤:
CaptureStep(
    name: "04-settings",
    navigate: {
        coordinator.sidebarSelection = .settings
        coordinator.detailSelection = nil
    },
    settle: .seconds(0.6),
    cleanup: nil
)

5. Demo Data Seeding

5. 演示数据填充

swift
#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
swift
#if DEBUG
@MainActor
func seedMarketingData(modelContext: ModelContext) {
    // 清除现有数据
    try? modelContext.delete(model: Coffee.self)
    
    // 填充确定性数据
    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

6. 与ContentView集成

swift
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
    }
}
swift
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

7. 元素渲染示例

Widget Rendering

小组件渲染

swift
#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
swift
#if DEBUG
@MainActor
func renderWidgets() {
    let entry = PulseWidgetEntry(
        date: Date(),
        coffee: seedCoffee,
        recentBrew: seedBrew
    )
    
    // 小型小组件
    MarketingElementHarness.renderElement(
        name: "widget-pulse-small",
        width: 158,
        cornerRadius: 16,
        background: .white
    ) {
        PulseWidgetSmallView(entry: entry)
            .padding(16) // 手动添加WidgetKit的内边距
    }
    
    // 中型小组件
    MarketingElementHarness.renderElement(
        name: "widget-pulse-medium",
        width: 338,
        cornerRadius: 16,
        background: .white
    ) {
        PulseWidgetMediumView(entry: entry)
            .padding(16)
    }
}
#endif

Card Rendering

卡片渲染

swift
#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
swift
#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

图表渲染

swift
#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
swift
#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

8. 构建与启动脚本

bash
#!/bin/bash
bash
#!/bin/bash

scripts/capture-marketing.sh

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"
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 "❌ 未找到模拟器 '$DEVICE ($IOS_VERSION)'" exit 1 fi
echo "📱 使用模拟器: $SIMULATOR_ID"

Boot simulator

启动模拟器

xcrun simctl boot "$SIMULATOR_ID" 2>/dev/null || true sleep 2
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"
echo "🔨 正在构建..." 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"
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
for LOCALE in "${LOCALES[@]}"; do echo "" echo "📸 正在捕获$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/"
undefined
CONTAINER=$(xcrun simctl get_app_container "$SIMULATOR_ID" "$BUNDLE_ID" data) cp -R "$CONTAINER/Documents/../../tmp/marketing" ./marketing/
echo "" echo "✅ 所有语言环境捕获完成 → ./marketing/"
undefined

Critical Gotchas (Baked Into Skill)

关键注意事项(已集成到技能中)

1. Live Activities Persist Across Launches

1. Live Activities在多次启动间会保留

Problem: Next locale crashes on stale SwiftData references.
Solution: End all Live Activities in cleanup:
swift
for activity in Activity<PulseAttributes>.activities {
    await activity.end(nil, dismissalPolicy: .immediate)
}
问题: 切换到下一个语言环境时,陈旧的SwiftData引用会导致崩溃。
解决方案: 在清理操作中结束所有Live Activities:
swift
for activity in Activity<PulseAttributes>.activities {
    await activity.end(nil, dismissalPolicy: .immediate)
}

2. Re-Seeding Per Locale

2. 按语言环境重新填充数据

Problem: CloudKit sync churn causes crashes.
Solution: Seed once at launch, not per step.
问题: CloudKit同步会导致数据混乱,引发崩溃。
解决方案: 在启动时填充一次数据,而非每个步骤都填充。

3. ViewModels Setup Before Seed

3. 在填充数据前设置ViewModels

Problem: VMs hold stale empty snapshots.
Solution: Seed data → wait 1s → instantiate VMs → capture.
问题: ViewModels会保留陈旧的空快照。
解决方案: 填充数据 → 等待1秒 → 实例化ViewModels → 捕获截图。

4. Setting Trigger Binding to nil

4. 将触发绑定设置为nil

Problem: Doesn't dismiss
fullScreenCover
— captures wrong sheet.
Solution: Use dedicated dismiss closure:
swift
.fullScreenCover(isPresented: $showTimer) {
    TimerView(onDismiss: { showTimer = false })
}
问题: 无法关闭
fullScreenCover
— 捕获到错误的表单。
解决方案: 使用专门的关闭闭包:
swift
.fullScreenCover(isPresented: $showTimer) {
    TimerView(onDismiss: { showTimer = false })
}

5. NavigationPath Can't Be Popped Externally

5. NavigationPath无法从外部弹出

Problem: Pushing over existing path captures wrong stack.
Solution: Always
popToRoot()
before
push()
in navigate block.
问题: 在现有路径上推送新页面会捕获到错误的导航栈。
解决方案: 在导航块中,执行
push()
前始终先执行
popToRoot()

6.
membershipExceptions
Is an INCLUSION List

6.
membershipExceptions
是包含列表

Problem: Widget target membership goes backwards — widget renders fail.
Solution: Set
membershipExceptions
for DEBUG files to exclude widget target:
swift
// In pbxproj or via Xcode:
// MarketingCapture.swift → Target Membership → Widget ❌
问题: 小组件目标成员关系设置错误 — 小组件渲染失败。
解决方案: 为DEBUG文件设置
membershipExceptions
,排除小组件目标:
swift
// 在pbxproj或通过Xcode设置:
// MarketingCapture.swift → 目标成员关系 → 小组件 ❌

7.
ImageRenderer
+
ProgressView

7.
ImageRenderer
+
ProgressView

Problem: Renders as prohibited symbol without explicit style.
Solution: Force
.circular
style:
swift
ProgressView()
    .progressViewStyle(.circular)
问题: 未设置显式样式时,会渲染为禁止符号。
解决方案: 强制使用
.circular
样式:
swift
ProgressView()
    .progressViewStyle(.circular)

8.
.containerBackground
Outside WidgetKit

8. 在WidgetKit外使用
.containerBackground

Problem: No-op — widget renders have no background.
Solution: Manually add background in render harness:
swift
WidgetView(entry: entry)
    .background(Color.white)
问题: 无效果 — 小组件渲染没有背景。
解决方案: 在渲染工具中手动添加背景:
swift
WidgetView(entry: entry)
    .background(Color.white)

9. iPhone 8 Plus Gone on iOS 18+

9. iOS 18+不再支持iPhone 8 Plus

Problem: Legacy 6.5" simulator unavailable.
Solution: Use iPhone 17 Pro Max or latest available large device.
问题: 旧版6.5"模拟器不可用。
解决方案: 使用iPhone 17 Pro Max或最新的大屏设备。

10. Locale Launch Argument Format

10. 语言环境启动参数格式

Problem: Locale ignored if parens missing.
Solution: Always use
"($LOCALE)"
format:
bash
-AppleLanguages "(de)"  # ✅
-AppleLanguages "de"    # ❌
问题: 缺少括号时,语言环境设置会被忽略。
解决方案: 始终使用
"($LOCALE)"
格式:
bash
-AppleLanguages "(de)"  # ✅ 正确
-AppleLanguages "de"    # ❌ 错误

11. SwiftUI Animations in ImageRenderer

11. ImageRenderer中的SwiftUI动画

Problem: Captures frame 0, not animated state.
Solution: Render non-animated state or use explicit
.animation(nil)
.
问题: 捕获的是第0帧,而非动画状态。
解决方案: 渲染非动画状态或使用显式的
.animation(nil)

Common Workflows

常见工作流

Capturing Timer Mid-Countdown

捕获倒计时中的计时器

swift
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()
    }
)
swift
CaptureStep(
    name: "05-timer-active",
    navigate: {
        router.popToRoot()
        coordinator.startTimer(seconds: 180)
        // 计时器视图订阅coordinator.activeTimer
    },
    settle: .seconds(1.5), // 等待计时器动画加载完成
    cleanup: {
        coordinator.stopTimer()
        router.popToRoot()
    }
)

Capturing Presented Sheet

捕获弹出的表单

swift
@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()
}
swift
@State private var showSheet = false

// 步骤:
CaptureStep(
    name: "06-add-coffee",
    navigate: {
        showSheet = true
    },
    settle: .seconds(0.8),
    cleanup: {
        showSheet = false
    }
)

// 视图:
.sheet(isPresented: $showSheet) {
    AddCoffeeView()
}

Rendering All Widgets

渲染所有小组件

swift
#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
swift
#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.
bash
xcrun simctl launch ... -MARKETING_CAPTURE 1
检查: 是否在启动参数中设置了
MARKETING_CAPTURE=1
环境变量。
bash
xcrun simctl launch ... -MARKETING_CAPTURE 1

Locale not applied

语言环境未生效

Check: Parens in
-AppleLanguages
:
bash
-AppleLanguages "(de)"  # ✅ Correct
检查:
-AppleLanguages
参数是否包含括号:
bash
-AppleLanguages "(de)"  # ✅ 正确

Widget renders blank

小组件渲染为空

Check: Manual padding added (WidgetKit adds 16pt automatically):
swift
WidgetView(entry: entry)
    .padding(16)  // Add this
检查: 是否添加了手动内边距(WidgetKit会自动添加16pt内边距):
swift
WidgetView(entry: entry)
    .padding(16)  // 添加此行

Screenshot captures wrong screen

截图捕获到错误的屏幕

Check: Settle duration long enough for animations:
swift
settle: .seconds(0.8)  // Increase if needed
检查: 等待动画完成的时间是否足够长:
swift
settle: .seconds(0.8)  // 如有需要,增加时长

SwiftData crash on second locale

切换到第二个语言环境时SwiftData崩溃

Check: Live Activities ended in cleanup:
swift
cleanup: {
    Task {
        for activity in Activity<Attrs>.activities {
            await activity.end(nil, dismissalPolicy: .immediate)
        }
    }
}
检查: 是否在清理操作中结束了Live Activities:
swift
cleanup: {
    Task {
        for activity in Activity<Attrs>.activities {
            await activity.end(nil, dismissalPolicy: .immediate)
        }
    }
}

Element renders with wrong size

元素渲染尺寸错误

Check: Frame width explicitly set:
swift
content()
    .frame(width: 380)  // Must be explicit
检查: 是否显式设置了帧宽度:
swift
content()
    .frame(width: 380)  // 必须显式设置

Configuration Reference

配置参考

Capture Step Properties

捕获步骤属性

PropertyTypePurpose
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
属性类型用途
name
String
不带扩展名的文件名(如"01-home")
navigate
@MainActor () -> Void
将应用置于正确状态
settle
Duration
等待动画完成的时间
cleanup
(@MainActor () -> Void)?
进入下一步前的清理操作

Script Variables

脚本变量

VariableExamplePurpose
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
变量示例用途
APP_NAME
"YourApp"
应用目标名称
SCHEME
"YourApp"
Xcode方案
DEVICE
"iPhone 17"
模拟器型号
IOS_VERSION
"18.2"
iOS运行时版本
LOCALES
("en" "de" "es")
语言代码列表

Environment Variables

环境变量

Set in launch args or script:
bash
MARKETING_CAPTURE=1        # Enable capture mode
在启动参数或脚本中设置:
bash
MARKETING_CAPTURE=1        // 启用捕获模式

Post-Processing

后期处理

Use app-store-screenshots to composite captured PNGs into Apple-style marketing pages with device mockups, headlines, and gradients.
bash
npx skills add ParthJadhav/app-store-screenshots
Then prompt:
Composite my marketing screenshots into App Store pages with device frames
使用app-store-screenshots将捕获的PNG图片合成为带有设备模型、标题和渐变效果的苹果风格营销页面。
bash
npx skills add ParthJadhav/app-store-screenshots
然后输入提示:
将我的营销截图合成为带有设备框架的App Store页面

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)
适合使用场景:
  • 为App Store捕获营销截图
  • 渲染独立组件(卡片、小组件、图表)
  • 生成多语言资源
  • 基于SwiftUI的iOS应用且已定义导航结构
  • 需要完全控制演示数据和应用状态
不适合使用场景:
  • 基于UIKit的应用(需要不同的捕获方法)
  • XCUITest基础设施已正常运行
  • 单一语言环境,手动捕获即可满足需求
  • 应用使用Web视图或外部内容(无法填充演示数据)

Example: Complete Coffee App Capture

示例:完整咖啡应用捕获

swift
#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:
bash
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]
swift
#if DEBUG
@MainActor
func makeMarketingSteps(
    coordinator: AppCoordinator,
    router: Router,
    viewModel: HomeViewModel
) -> [CaptureStep] {
    let coffees = viewModel.coffees
    
    return [
        // 标签页1: 首页
        CaptureStep(
            name: "01-home",
            navigate: { coordinator.setTab(0) },
            settle: .seconds(0.5),
            cleanup: nil
        ),
        
        // 标签页2: 货架
        CaptureStep(
            name: "02-shelf",
            navigate: { coordinator.setTab(1) },
            settle: .seconds(0.5),
            cleanup: nil
        ),
        
        // 详情: 第一款咖啡
        CaptureStep(
            name: "03-detail",
            navigate: {
                coordinator.setTab(0)
                router.popToRoot()
                router.push(.coffeeDetail(coffees[0]))
            },
            settle: .seconds(0.8),
            cleanup: { router.popToRoot() }
        ),
        
        // 计时器: 正在冲泡
        CaptureStep(
            name: "04-timer",
            navigate: {
                coordinator.setTab(0)
                router.popToRoot()
                coordinator.startTimer(seconds: 180)
            },
            settle: .seconds(1.2),
            cleanup: {
                coordinator.stopTimer()
                router.popToRoot()
            }
        ),
        
        // 设置
        CaptureStep(
            name: "05-settings",
            navigate: { coordinator.setTab(2) },
            settle: .seconds(0.5),
            cleanup: nil
        ),
        
        // 元素: 卡片
        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
运行:
bash
chmod +x scripts/capture-marketing.sh
./scripts/capture-marketing.sh
输出:
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/
        [相同结构]
    es/
        [相同结构]
    fr/
        [相同结构]
    ja/
        [相同结构]

Summary

总结

This skill automates iOS marketing screenshot capture by:
  1. Gathering requirements — screens, elements, locales, device, appearance
  2. Generating capture system — DEBUG-gated coordinator + steps
  3. Seeding demo data — deterministic, locale-agnostic
  4. Navigating programmatically — TabView, NavigationStack, or SplitView
  5. Capturing window
    drawHierarchy
    for full screenshots
  6. Rendering elements
    ImageRenderer
    for isolated components
  7. Looping locales
    simctl launch
    with
    -AppleLanguages
The result: one build, N launches, complete multi-locale marketing assets ready for App Store or further compositing.
本技能通过以下步骤实现iOS营销截图自动化:
  1. 收集需求 — 屏幕、元素、语言环境、设备、外观
  2. 生成捕获系统 — 由DEBUG管控的协调器+步骤
  3. 填充演示数据 — 确定性、与语言环境无关
  4. 编程式导航 — TabView、NavigationStack或SplitView
  5. 捕获窗口 — 使用
    drawHierarchy
    捕获完整截图
  6. 渲染元素 — 使用
    ImageRenderer
    渲染独立组件
  7. 遍历语言环境 — 使用
    simctl launch
    -AppleLanguages
    参数
最终结果:一次构建,多次启动,即可获得完整的多语言营销资源,直接用于App Store或进一步合成。