ios-marketing-capture-automation
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseiOS Marketing Capture Automation
iOS营销截图自动化
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 -gated capture system with zero production footprint
#if DEBUG - 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 at 3x with transparency
ImageRenderer - Loops every locale automatically — one build, N relaunches with
-AppleLanguages - Works with any SwiftUI navigation: ,
TabView,NavigationStackNavigationSplitView
本技能通过构建应用内捕获系统,帮助你实现SwiftUI iOS应用营销截图的自动化捕获,具体包括:
- 添加由管控的捕获系统,对生产环境无任何影响
#if DEBUG - 填充确定性演示数据,让截图内容饱满、美观
- 通过基于步骤的协调器以编程方式导航至每个屏幕
- 捕获包含状态栏、安全区域和弹出表单的完整窗口
- 通过以3倍分辨率带透明效果渲染独立元素(卡片、小组件、图表)
ImageRenderer - 自动遍历所有语言环境 — 一次构建,多次重启并使用参数切换语言
-AppleLanguages - 兼容所有SwiftUI导航方式:、
TabView、NavigationStackNavigationSplitView
Installation
安装方法
Using npx skills (recommended)
使用npx skills(推荐)
bash
npx skills add ParthJadhav/ios-marketing-captureGlobal install (available across all projects):
bash
npx skills add ParthJadhav/ios-marketing-capture -gAgent-specific install:
bash
npx skills add ParthJadhav/ios-marketing-capture -a claude-codebash
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-codeManual installation
手动安装
bash
git clone https://github.com/ParthJadhav/ios-marketing-capture ~/.claude/skills/ios-marketing-capturebash
git clone https://github.com/ParthJadhav/ios-marketing-capture ~/.claude/skills/ios-marketing-captureRequirements 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, ,
ImageRendererUIWindow.drawHierarchy - Faster — once, then
xcodebuild buildper localesimctl launch - Element renders require it — must run inside app process
ImageRenderer
本方案采用应用内捕获而非XCUITest/Fastlane,原因如下:
- 无需测试目标 — 许多项目没有测试目标,添加测试目标需要修改pbxproj,易出错
- 直接访问 — 可直接访问ViewModels、SwiftData、、
ImageRendererUIWindow.drawHierarchy - 速度更快 — 只需执行一次,然后针对每个语言环境执行
xcodebuild buildsimctl launch - 元素渲染必须依赖此方式 — 必须在应用进程内运行
ImageRenderer
Step-Based Coordinator
基于步骤的协调器
Each screenshot is a self-contained :
CaptureStepswift
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
}每个截图任务都是一个独立的:
CaptureStepswift
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:
- 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
当用户要求捕获截图时,需收集以下信息:
- 需捕获的屏幕 — 具体的标签页名称或导航路径
- 需渲染的元素 — 需独立提取的卡片、小组件、图表
- 语言环境 — 明确的列表或“xcstrings中的所有语言”
- 设备 — 模拟器型号(如"iPhone 17")
- 外观 — 浅色、深色或两者都要
- 演示数据需求 — 需要填充哪些演示数据
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 loopOutput 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)
}
}
#endifswift
#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)
}
}
#endif4. 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()
}
#endifswift
#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()
}
#endif6. 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)
}
}
#endifswift
#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)
}
}
#endifCard 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)
}
}
}
#endifswift
#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)
}
}
}
#endifChart 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)
}
}
#endifswift
#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)
}
}
#endif8. Build and Launch Script
8. 构建与启动脚本
bash
#!/bin/bashbash
#!/bin/bashscripts/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 '[]')
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 '[]')
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
-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
-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 8done
for LOCALE in "${LOCALES[@]}"; do
echo ""
echo "📸 正在捕获$LOCALE语言环境..."
xcrun simctl launch \
--terminate-running-process \
"$SIMULATOR_ID" \
"$BUNDLE_ID" \
-AppleLanguages "($LOCALE)" \
-MARKETING_CAPTURE 1
sleep 8done
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/"
undefinedCONTAINER=$(xcrun simctl get_app_container "$SIMULATOR_ID" "$BUNDLE_ID" data)
cp -R "$CONTAINER/Documents/../../tmp/marketing" ./marketing/
echo ""
echo "✅ 所有语言环境捕获完成 → ./marketing/"
undefinedCritical 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 — captures wrong sheet.
fullScreenCoverSolution: 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 before in navigate block.
popToRoot()push()问题: 在现有路径上推送新页面会捕获到错误的导航栈。
解决方案: 在导航块中,执行前始终先执行。
push()popToRoot()6. membershipExceptions
Is an INCLUSION List
membershipExceptions6. membershipExceptions
是包含列表
membershipExceptionsProblem: Widget target membership goes backwards — widget renders fail.
Solution: Set for DEBUG files to exclude widget target:
membershipExceptionsswift
// In pbxproj or via Xcode:
// MarketingCapture.swift → Target Membership → Widget ❌问题: 小组件目标成员关系设置错误 — 小组件渲染失败。
解决方案: 为DEBUG文件设置,排除小组件目标:
membershipExceptionsswift
// 在pbxproj或通过Xcode设置:
// MarketingCapture.swift → 目标成员关系 → 小组件 ❌7. ImageRenderer
+ ProgressView
ImageRendererProgressView7. ImageRenderer
+ ProgressView
ImageRendererProgressViewProblem: Renders as prohibited symbol without explicit style.
Solution: Force style:
.circularswift
ProgressView()
.progressViewStyle(.circular)问题: 未设置显式样式时,会渲染为禁止符号。
解决方案: 强制使用样式:
.circularswift
ProgressView()
.progressViewStyle(.circular)8. .containerBackground
Outside WidgetKit
.containerBackground8. 在WidgetKit外使用.containerBackground
.containerBackgroundProblem: 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 format:
"($LOCALE)"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)
}
}
}
#endifswift
#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)
}
}
}
#endifTroubleshooting
故障排除
Capture exits immediately
捕获立即退出
Check: env var is set in launch args.
MARKETING_CAPTURE=1bash
xcrun simctl launch ... -MARKETING_CAPTURE 1检查: 是否在启动参数中设置了环境变量。
MARKETING_CAPTURE=1bash
xcrun simctl launch ... -MARKETING_CAPTURE 1Locale not applied
语言环境未生效
Check: Parens in :
-AppleLanguagesbash
-AppleLanguages "(de)" # ✅ Correct检查: 参数是否包含括号:
-AppleLanguagesbash
-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
捕获步骤属性
| Property | Type | Purpose |
|---|---|---|
| | Filename without extension (e.g. "01-home") |
| | Put app in correct state |
| | Wait time for animations |
| | Tear down before next step |
| 属性 | 类型 | 用途 |
|---|---|---|
| | 不带扩展名的文件名(如"01-home") |
| | 将应用置于正确状态 |
| | 等待动画完成的时间 |
| | 进入下一步前的清理操作 |
Script Variables
脚本变量
| Variable | Example | Purpose |
|---|---|---|
| | App target name |
| | Xcode scheme |
| | Simulator model |
| | iOS runtime |
| | Locale codes |
| 变量 | 示例 | 用途 |
|---|---|---|
| | 应用目标名称 |
| | Xcode方案 |
| | 模拟器型号 |
| | iOS运行时版本 |
| | 语言代码列表 |
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-screenshotsThen 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
)
]
}
#endifRun:
bash
chmod +x scripts/capture-marketing.sh
./scripts/capture-marketing.shOutput:
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:
- 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 — for full screenshots
drawHierarchy - Rendering elements — for isolated components
ImageRenderer - Looping locales — with
simctl launch-AppleLanguages
The result: one build, N launches, complete multi-locale marketing assets ready for App Store or further compositing.
本技能通过以下步骤实现iOS营销截图自动化:
- 收集需求 — 屏幕、元素、语言环境、设备、外观
- 生成捕获系统 — 由DEBUG管控的协调器+步骤
- 填充演示数据 — 确定性、与语言环境无关
- 编程式导航 — TabView、NavigationStack或SplitView
- 捕获窗口 — 使用捕获完整截图
drawHierarchy - 渲染元素 — 使用渲染独立组件
ImageRenderer - 遍历语言环境 — 使用和
simctl launch参数-AppleLanguages
最终结果:一次构建,多次启动,即可获得完整的多语言营销资源,直接用于App Store或进一步合成。