All skills
rshankras avatar

/screenshot-automation

@32566aa

Generates an automated App Store screenshot pipeline with UI tests for screenshot capture, device framing, localized caption overlays, and multi-size batch export. Use when user wants automated screenshots, App Store screenshot generation, or a fastlane snapshot replacement.

Use this Skill: https://skilld.dev/gh/rshankras/claude-code-apple-skills/screenshot-automation

This session only. Nothing lands on disk.

templates.md

≈19k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Screenshot Automation Code Templates

Production-ready Swift templates for automated App Store screenshot generation. All code targets iOS 16+ / macOS 13+ and uses XCUITest for capture with CoreGraphics for post-processing.

Platform Compatibility

#if canImport(UIKit)
import UIKit
typealias PlatformImage = UIImage
#elseif canImport(AppKit)
import AppKit
typealias PlatformImage = NSImage
#endif

ScreenshotPlan.swift

import Foundation

/// Configuration defining which screens to capture, on which devices, and in which locales.
///
/// Usage:
/// ```swift
/// let plan = ScreenshotPlan.default
/// for screen in plan.screens {
///     // Navigate to screen.name, then capture
/// }
/// ```
struct ScreenshotPlan: Codable, Sendable {

    /// A single screen to capture.
    struct Screen: Codable, Sendable {
        /// Display name used in output filename (e.g., "01_HomeScreen").
        let name: String

        /// Ordered steps to navigate to this screen from app launch.
        let setupSteps: [NavigationStep]

        /// Optional: override locale for this screen only.
        let localeOverride: String?

        /// Whether to capture in landscape orientation.
        let landscape: Bool

        init(
            name: String,
            setupSteps: [NavigationStep],
            localeOverride: String? = nil,
            landscape: Bool = false
        ) {
            self.name = name
            self.setupSteps = setupSteps
            self.localeOverride = localeOverride
            self.landscape = landscape
        }
    }

    /// A navigation action to reach a screen.
    enum NavigationStep: Codable, Sendable {
        case tapButton(identifier: String)
        case tapCell(identifier: String)
        case tapTabBarItem(identifier: String)
        case swipeLeft
        case swipeRight
        case typeText(identifier: String, text: String)
        case wait(seconds: TimeInterval)
        case dismissAlert
        case custom(description: String)
    }

    /// Target device specifications.
    enum Device: String, Codable, Sendable, CaseIterable {
        case iPhone6_7 = "iPhone_6.7"
        case iPhone6_5 = "iPhone_6.5"
        case iPhone5_5 = "iPhone_5.5"
        case iPad12_9 = "iPad_12.9"
        case iPad11 = "iPad_11"

        /// Simulator device name for xcodebuild destination.
        var simulatorName: String {
            switch self {
            case .iPhone6_7: return "iPhone 16 Pro Max"
            case .iPhone6_5: return "iPhone 11 Pro Max"
            case .iPhone5_5: return "iPhone 8 Plus"
            case .iPad12_9: return "iPad Pro (12.9-inch) (6th generation)"
            case .iPad11: return "iPad Air (5th generation)"
            }
        }

        /// Expected screenshot pixel dimensions (portrait).
        var screenshotSize: CGSize {
            switch self {
            case .iPhone6_7: return CGSize(width: 1290, height: 2796)
            case .iPhone6_5: return CGSize(width: 1242, height: 2688)
            case .iPhone5_5: return CGSize(width: 1242, height: 2208)
            case .iPad12_9: return CGSize(width: 2048, height: 2732)
            case .iPad11: return CGSize(width: 1668, height: 2388)
            }
        }
    }

    /// Screens to capture, in order.
    let screens: [Screen]

    /// Devices to generate screenshots for.
    let devices: [Device]

    /// Locales to generate screenshots for.
    let locales: [String]

    /// Output root directory.
    let outputDirectory: String

    /// Whether to include device frames in output.
    let includeDeviceFrames: Bool

    /// Caption overlay style.
    let captionStyle: CaptionStyle

    enum CaptionStyle: String, Codable, Sendable {
        case top
        case bottom
        case none
    }
}

// MARK: - Default Configuration

extension ScreenshotPlan {

    /// Default plan -- customize per project.
    static let `default` = ScreenshotPlan(
        screens: [
            Screen(
                name: "01_HomeScreen",
                setupSteps: [.wait(seconds: 1)]
            ),
            Screen(
                name: "02_DetailView",
                setupSteps: [
                    .tapCell(identifier: "item_cell_0"),
                    .wait(seconds: 0.5)
                ]
            ),
            Screen(
                name: "03_Search",
                setupSteps: [
                    .tapTabBarItem(identifier: "Search"),
                    .wait(seconds: 0.5)
                ]
            ),
            Screen(
                name: "04_Profile",
                setupSteps: [
                    .tapTabBarItem(identifier: "Profile"),
                    .wait(seconds: 0.5)
                ]
            ),
            Screen(
                name: "05_Settings",
                setupSteps: [
                    .tapTabBarItem(identifier: "Profile"),
                    .tapButton(identifier: "settings_button"),
                    .wait(seconds: 0.5)
                ]
            )
        ],
        devices: [.iPhone6_7, .iPhone6_5, .iPad12_9],
        locales: ["en-US"],
        outputDirectory: "screenshots",
        includeDeviceFrames: true,
        captionStyle: .top
    )
}

ScreenshotUITests.swift

import XCTest

/// UI test class that captures screenshots for all screens defined in ScreenshotPlan.
///
/// Run with:
/// ```bash
/// xcodebuild test \
///   -scheme "YourAppUITests" \
///   -destination "platform=iOS Simulator,name=iPhone 16 Pro Max" \
///   -only-testing "YourAppUITests/ScreenshotUITests"
/// ```
final class ScreenshotUITests: XCTestCase {

    private var app: XCUIApplication!
    private let helper = ScreenshotTestHelper()

    override func setUp() {
        super.setUp()
        continueAfterFailure = false

        app = XCUIApplication()

        // Enable screenshot mode via launch argument
        app.launchArguments += ["-ScreenshotMode", "YES"]

        // Disable animations for consistent captures
        app.launchArguments += ["-UIViewAnimationDuration", "0"]
        app.launchArguments += ["-CALayerAnimationDuration", "0"]
    }

    override func tearDown() {
        app = nil
        super.tearDown()
    }

    // MARK: - Screenshot Capture

    /// Captures all screens defined in ScreenshotPlan for the current locale.
    func testCaptureAllScreenshots() throws {
        let plan = ScreenshotPlan.default

        for locale in plan.locales {
            // Configure locale
            app.launchArguments += ["-AppleLanguages", "(\(locale))"]
            app.launchArguments += ["-AppleLocale", locale]
            app.launch()

            // Bypass onboarding if present
            helper.bypassOnboarding(app: app)

            // Seed sample data for attractive screenshots
            helper.seedSampleData(app: app)

            // Wait for initial load
            helper.waitForLoad(app: app)

            // Capture each screen
            for screen in plan.screens {
                try captureScreen(screen, locale: locale)

                // Return to root for next screen
                helper.returnToRoot(app: app)
            }
        }
    }

    /// Captures a single screen by executing its navigation steps.
    private func captureScreen(_ screen: ScreenshotPlan.Screen, locale: String) throws {
        // Set orientation
        if screen.landscape {
            XCUIDevice.shared.orientation = .landscapeLeft
        } else {
            XCUIDevice.shared.orientation = .portrait
        }

        // Execute navigation steps
        for step in screen.setupSteps {
            try executeStep(step)
        }

        // Allow UI to settle
        Thread.sleep(forTimeInterval: 0.3)

        // Capture and attach screenshot
        let screenshot = app.windows.firstMatch.screenshot()
        let attachment = XCTAttachment(screenshot: screenshot)
        attachment.name = "\(locale)_\(screen.name)"
        attachment.lifetime = .keepAlways
        add(attachment)
    }

    /// Executes a single navigation step.
    private func executeStep(_ step: ScreenshotPlan.NavigationStep) throws {
        switch step {
        case .tapButton(let identifier):
            let button = app.buttons[identifier]
            XCTAssertTrue(
                button.waitForExistence(timeout: 5),
                "Button '\(identifier)' not found"
            )
            button.tap()

        case .tapCell(let identifier):
            let cell = app.cells[identifier]
            XCTAssertTrue(
                cell.waitForExistence(timeout: 5),
                "Cell '\(identifier)' not found"
            )
            cell.tap()

        case .tapTabBarItem(let identifier):
            let tabBarItem = app.tabBars.buttons[identifier]
            XCTAssertTrue(
                tabBarItem.waitForExistence(timeout: 5),
                "Tab bar item '\(identifier)' not found"
            )
            tabBarItem.tap()

        case .swipeLeft:
            app.swipeLeft()

        case .swipeRight:
            app.swipeRight()

        case .typeText(let identifier, let text):
            let textField = app.textFields[identifier]
            XCTAssertTrue(
                textField.waitForExistence(timeout: 5),
                "Text field '\(identifier)' not found"
            )
            textField.tap()
            textField.typeText(text)

        case .wait(let seconds):
            Thread.sleep(forTimeInterval: seconds)

        case .dismissAlert:
            helper.dismissSystemAlerts(app: app)

        case .custom:
            // Override in subclass for custom navigation
            break
        }
    }

    // MARK: - Dark Mode Variant

    /// Captures all screens in dark mode.
    func testCaptureAllScreenshotsDarkMode() throws {
        let plan = ScreenshotPlan.default

        for locale in plan.locales {
            app.launchArguments += ["-AppleLanguages", "(\(locale))"]
            app.launchArguments += ["-AppleLocale", locale]
            app.launchArguments += ["-UIUserInterfaceStyle", "Dark"]
            app.launch()

            helper.bypassOnboarding(app: app)
            helper.seedSampleData(app: app)
            helper.waitForLoad(app: app)

            for screen in plan.screens {
                try captureScreen(
                    ScreenshotPlan.Screen(
                        name: "\(screen.name)_dark",
                        setupSteps: screen.setupSteps,
                        localeOverride: screen.localeOverride,
                        landscape: screen.landscape
                    ),
                    locale: locale
                )
                helper.returnToRoot(app: app)
            }
        }
    }
}

ScreenshotTestHelper.swift

import XCTest

/// Helper utilities for screenshot UI tests.
///
/// Handles common tasks like bypassing onboarding, seeding sample data,
/// dismissing system alerts, and waiting for content to load.
struct ScreenshotTestHelper {

    // MARK: - Locale Setup

    /// Sets the app locale via launch arguments.
    ///
    /// Call before `app.launch()`.
    func setLocale(_ locale: String, on app: XCUIApplication) {
        app.launchArguments += ["-AppleLanguages", "(\(locale))"]
        app.launchArguments += ["-AppleLocale", locale]
    }

    // MARK: - Sample Data Seeding

    /// Seeds the app with attractive sample data for screenshots.
    ///
    /// The app should check for the `-ScreenshotMode` launch argument
    /// and load pre-configured sample data when present.
    ///
    /// In your app's launch code:
    /// ```swift
    /// if ProcessInfo.processInfo.arguments.contains("-ScreenshotMode") {
    ///     DataStore.shared.loadScreenshotSampleData()
    /// }
    /// ```
    func seedSampleData(app: XCUIApplication) {
        // Data seeding is handled via launch argument "-ScreenshotMode"
        // The app itself loads sample data when this flag is present
    }

    // MARK: - Onboarding Bypass

    /// Bypasses onboarding screens if they appear.
    ///
    /// The app should check for the `-SkipOnboarding` launch argument.
    /// As a fallback, this method taps through common onboarding patterns.
    func bypassOnboarding(app: XCUIApplication) {
        // Primary: launch argument handled by the app
        // Fallback: tap through onboarding buttons
        let skipButtons = ["Skip", "Continue", "Get Started", "Next"]

        for buttonLabel in skipButtons {
            let button = app.buttons[buttonLabel]
            if button.waitForExistence(timeout: 2) {
                button.tap()
            }
        }
    }

    // MARK: - System Alert Dismissal

    /// Dismisses system permission alerts (notifications, location, etc.).
    ///
    /// Call after app launch if the app requests permissions on first launch.
    func dismissSystemAlerts(app: XCUIApplication) {
        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
        let allowButtons = ["Allow", "Allow While Using App", "OK", "Don't Allow"]

        for buttonLabel in allowButtons {
            let button = springboard.buttons[buttonLabel]
            if button.waitForExistence(timeout: 2) {
                button.tap()
            }
        }
    }

    // MARK: - Wait for Load

    /// Waits for the app's main content to finish loading.
    ///
    /// Uses a combination of activity indicator absence and element existence.
    func waitForLoad(app: XCUIApplication, timeout: TimeInterval = 10) {
        // Wait for any loading indicators to disappear
        let loadingIndicator = app.activityIndicators.firstMatch
        if loadingIndicator.exists {
            let disappeared = NSPredicate(format: "exists == false")
            let expectation = XCTNSPredicateExpectation(
                predicate: disappeared,
                object: loadingIndicator
            )
            _ = XCTWaiter.wait(for: [expectation], timeout: timeout)
        }

        // Additional settle time for animations
        Thread.sleep(forTimeInterval: 0.5)
    }

    // MARK: - Navigation Helpers

    /// Returns to the root view by tapping the back button repeatedly.
    func returnToRoot(app: XCUIApplication) {
        // Tap back buttons until we're at root
        for _ in 0..<5 {
            let backButton = app.navigationBars.buttons.element(boundBy: 0)
            if backButton.exists && backButton.isHittable {
                backButton.tap()
                Thread.sleep(forTimeInterval: 0.2)
            } else {
                break
            }
        }
    }

    // MARK: - Status Bar Configuration

    /// Configures the simulator status bar for clean screenshots.
    ///
    /// Run this before launching tests:
    /// ```bash
    /// xcrun simctl status_bar "iPhone 16 Pro Max" override \
    ///   --time "9:41" \
    ///   --batteryState charged \
    ///   --batteryLevel 100 \
    ///   --wifiBars 3 \
    ///   --cellularBars 4 \
    ///   --operatorName ""
    /// ```
    static func configureStatusBar(simulatorName: String) {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
        process.arguments = [
            "simctl", "status_bar", simulatorName, "override",
            "--time", "9:41",
            "--batteryState", "charged",
            "--batteryLevel", "100",
            "--wifiBars", "3",
            "--cellularBars", "4",
            "--operatorName", ""
        ]
        try? process.run()
        process.waitUntilExit()
    }

    // MARK: - Screenshot Naming

    /// Generates a standardized screenshot filename.
    ///
    /// Format: `{locale}_{screenshotName}` (e.g., "en-US_01_HomeScreen")
    /// Alphabetical ordering ensures correct order in App Store Connect.
    func screenshotName(locale: String, screenName: String) -> String {
        "\(locale)_\(screenName)"
    }
}

// MARK: - XCUIElement Unhittable Tap Extension

extension XCUIElement {
    /// Taps an element that XCUITest reports as not hittable.
    ///
    /// Some custom controls (e.g., custom tab bars, overlapping views, controls behind
    /// transparent overlays) don't properly report hittability. This method bypasses
    /// XCUITest's built-in hit testing by calculating the element's center coordinate
    /// and tapping it directly.
    ///
    /// Usage:
    /// ```swift
    /// let customControl = app.otherElements["pageStrip.page.1"]
    /// customControl.tapUnhittable()
    /// ```
    ///
    /// Ref: Common XCUITest workaround for custom controls.
    func tapUnhittable() {
        let center = coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
        center.tap()
    }

    /// Force-taps an element at a specific normalized offset.
    ///
    /// Useful when you need to tap a specific region of a non-hittable element,
    /// such as a custom slider track or a segmented control segment.
    ///
    /// - Parameter offset: Normalized offset (0.0-1.0) from the element's origin.
    func tapUnhittable(at offset: CGVector) {
        let point = coordinate(withNormalizedOffset: offset)
        point.tap()
    }
}

ScreenshotProcessor.swift

import Foundation
import CoreGraphics
import ImageIO

#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

/// A captured screenshot with metadata for processing.
struct CapturedScreenshot: Sendable {
    /// Filename without extension (e.g., "01_HomeScreen").
    let name: String

    /// The raw screenshot image.
    let image: PlatformImage

    /// Locale this screenshot was captured in (e.g., "en-US").
    let locale: String

    /// Device this screenshot was captured on.
    let device: ScreenshotPlan.Device
}

/// Orchestrates screenshot post-processing: captioning, device framing, and export.
///
/// Flow: Load captured images -> Add captions -> Add device frames -> Export to organized directory
///
/// Usage:
/// ```swift
/// let processor = ScreenshotProcessor(
///     outputDirectory: URL(fileURLWithPath: "screenshots"),
///     plan: .default
/// )
/// try await processor.process(capturedScreenshots)
/// ```
final class ScreenshotProcessor: Sendable {
    private let outputDirectory: URL
    private let captionOverlay: CaptionOverlay?
    private let deviceFramer: DeviceFramer?
    private let plan: ScreenshotPlan

    init(
        outputDirectory: URL,
        plan: ScreenshotPlan = .default,
        captionOverlay: CaptionOverlay? = nil,
        deviceFramer: DeviceFramer? = nil
    ) {
        self.outputDirectory = outputDirectory
        self.plan = plan

        if plan.captionStyle != .none {
            self.captionOverlay = captionOverlay ?? CaptionOverlay(style: plan.captionStyle)
        } else {
            self.captionOverlay = nil
        }

        if plan.includeDeviceFrames {
            self.deviceFramer = deviceFramer ?? DeviceFramer()
        } else {
            self.deviceFramer = nil
        }
    }

    /// Process all captured screenshots: caption, frame, and export.
    func process(_ screenshots: [CapturedScreenshot]) async throws {
        for screenshot in screenshots {
            var image = screenshot.image

            // Step 1: Add caption overlay (if configured)
            if let captionOverlay {
                let captionKey = "screenshot.\(screenshot.name)"
                let localizedCaption = localizedString(
                    key: captionKey,
                    locale: screenshot.locale
                )
                image = try captionOverlay.apply(
                    to: image,
                    text: localizedCaption,
                    canvasSize: screenshot.device.screenshotSize
                )
            }

            // Step 2: Add device frame (if configured)
            if let deviceFramer {
                image = try deviceFramer.frame(
                    image: image,
                    device: screenshot.device
                )
            }

            // Step 3: Export to organized directory
            try export(
                image: image,
                name: screenshot.name,
                locale: screenshot.locale,
                device: screenshot.device
            )
        }
    }

    // MARK: - Export

    /// Exports a processed image to the organized directory structure.
    ///
    /// Output structure: `{outputDirectory}/{locale}/{device}/{name}.png`
    private func export(
        image: PlatformImage,
        name: String,
        locale: String,
        device: ScreenshotPlan.Device
    ) throws {
        let directory = outputDirectory
            .appendingPathComponent(locale)
            .appendingPathComponent(device.rawValue)

        try FileManager.default.createDirectory(
            at: directory,
            withIntermediateDirectories: true
        )

        let fileURL = directory.appendingPathComponent("\(name).png")
        let pngData = try pngRepresentation(of: image)
        try pngData.write(to: fileURL)
    }

    /// Converts a PlatformImage to PNG data.
    private func pngRepresentation(of image: PlatformImage) throws -> Data {
        #if canImport(UIKit)
        guard let data = image.pngData() else {
            throw ScreenshotError.exportFailed("Failed to create PNG data")
        }
        return data
        #elseif canImport(AppKit)
        guard let tiffData = image.tiffRepresentation,
              let bitmap = NSBitmapImageRep(data: tiffData),
              let data = bitmap.representation(using: .png, properties: [:]) else {
            throw ScreenshotError.exportFailed("Failed to create PNG data")
        }
        return data
        #endif
    }

    /// Loads a localized string for the given key and locale.
    private func localizedString(key: String, locale: String) -> String {
        guard let bundlePath = Bundle.main.path(
            forResource: locale,
            ofType: "lproj"
        ),
        let bundle = Bundle(path: bundlePath) else {
            return NSLocalizedString(key, comment: "")
        }
        return bundle.localizedString(forKey: key, value: nil, table: nil)
    }

    // MARK: - Load from XCResult

    /// Loads captured screenshots from an xcresult bundle.
    ///
    /// Usage:
    /// ```swift
    /// let screenshots = try ScreenshotProcessor.loadFromXCResult(
    ///     at: URL(fileURLWithPath: "screenshots.xcresult"),
    ///     plan: .default
    /// )
    /// ```
    static func loadFromXCResult(
        at resultBundlePath: URL,
        plan: ScreenshotPlan
    ) throws -> [CapturedScreenshot] {
        // Extract attachments from xcresult using xcresulttool
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
        process.arguments = [
            "xcresulttool", "get",
            "--path", resultBundlePath.path,
            "--format", "json"
        ]

        let pipe = Pipe()
        process.standardOutput = pipe
        try process.run()
        process.waitUntilExit()

        let data = pipe.fileHandleForReading.readDataToEndOfFile()

        // Parse xcresult JSON to find screenshot attachments
        // Attachment names follow the convention: "{locale}_{screenshotName}"
        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            throw ScreenshotError.xcresultParseFailed
        }

        // Implementation varies based on xcresult format version
        // This provides the extraction framework -- adapt parsing to your Xcode version
        _ = json // Suppress unused warning; actual parsing depends on xcresult schema
        return []
    }
}

// MARK: - Device Framer

/// Applies device frame overlays to screenshots.
///
/// Device frame images should be placed in a `DeviceFrames/` directory
/// with naming convention: `frame_{device.rawValue}.png`
struct DeviceFramer: Sendable {
    private let framesDirectory: URL

    init(framesDirectory: URL = Bundle.main.resourceURL ?? URL(fileURLWithPath: "DeviceFrames")) {
        self.framesDirectory = framesDirectory
    }

    /// Composites the screenshot into a device frame.
    func frame(image: PlatformImage, device: ScreenshotPlan.Device) throws -> PlatformImage {
        let framePath = framesDirectory
            .appendingPathComponent("frame_\(device.rawValue).png")

        guard FileManager.default.fileExists(atPath: framePath.path),
              let frameImage = PlatformImage(contentsOfFile: framePath.path) else {
            // No frame available for this device; return original
            return image
        }

        return try compositeScreenshotInFrame(
            screenshot: image,
            frame: frameImage,
            device: device
        )
    }

    /// Composites a screenshot into the device frame using CoreGraphics.
    private func compositeScreenshotInFrame(
        screenshot: PlatformImage,
        frame: PlatformImage,
        device: ScreenshotPlan.Device
    ) throws -> PlatformImage {
        #if canImport(UIKit)
        let frameSize = frame.size
        let renderer = UIGraphicsImageRenderer(size: frameSize)
        return renderer.image { context in
            // Draw screenshot centered in frame (inset by bezel)
            let inset = bezelInset(for: device, frameSize: frameSize)
            screenshot.draw(in: inset)

            // Draw frame on top
            frame.draw(in: CGRect(origin: .zero, size: frameSize))
        }
        #elseif canImport(AppKit)
        let frameSize = frame.size
        let result = NSImage(size: frameSize)
        result.lockFocus()

        let inset = bezelInset(for: device, frameSize: frameSize)
        screenshot.draw(in: inset, from: .zero, operation: .copy, fraction: 1.0)
        frame.draw(in: NSRect(origin: .zero, size: frameSize), from: .zero, operation: .sourceOver, fraction: 1.0)

        result.unlockFocus()
        return result
        #endif
    }

    /// Returns the inset rect where the screenshot should be drawn within the frame.
    ///
    /// These values depend on your specific device frame assets.
    /// Adjust percentages to match your frame image dimensions.
    private func bezelInset(for device: ScreenshotPlan.Device, frameSize: CGSize) -> CGRect {
        // Default inset: 5% from each edge (adjust per your frame assets)
        let insetPercent: CGFloat = 0.05
        return CGRect(
            x: frameSize.width * insetPercent,
            y: frameSize.height * insetPercent,
            width: frameSize.width * (1 - 2 * insetPercent),
            height: frameSize.height * (1 - 2 * insetPercent)
        )
    }
}

// MARK: - Errors

/// Errors specific to screenshot automation.
enum ScreenshotError: Error, LocalizedError {
    case captionRenderFailed(String)
    case exportFailed(String)
    case xcresultParseFailed
    case deviceFrameNotFound(String)

    var errorDescription: String? {
        switch self {
        case .captionRenderFailed(let reason):
            return "Failed to render caption: \(reason)"
        case .exportFailed(let reason):
            return "Failed to export screenshot: \(reason)"
        case .xcresultParseFailed:
            return "Failed to parse xcresult bundle"
        case .deviceFrameNotFound(let device):
            return "Device frame not found for \(device)"
        }
    }
}

CaptionOverlay.swift

import Foundation
import CoreGraphics
import CoreText

#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

/// Renders localized marketing text captions onto screenshot images.
///
/// Supports top and bottom placement, custom fonts, colors, and gradient backgrounds.
/// Uses CoreGraphics for image compositing to produce high-quality output.
///
/// Usage:
/// ```swift
/// let overlay = CaptionOverlay(style: .top)
/// let captioned = try overlay.apply(
///     to: screenshotImage,
///     text: "Track your goals effortlessly",
///     canvasSize: CGSize(width: 1290, height: 2796)
/// )
/// ```
struct CaptionOverlay: Sendable {

    /// Caption placement relative to the screenshot.
    let style: ScreenshotPlan.CaptionStyle

    /// Font for the caption text.
    let font: CTFont

    /// Caption text color.
    let textColor: CGColor

    /// Background color behind the caption area.
    let backgroundColor: CGColor

    /// Optional gradient colors for the caption background (overrides backgroundColor).
    let gradientColors: [CGColor]?

    /// Height of the caption area as a fraction of the total canvas height.
    let captionHeightFraction: CGFloat

    /// Horizontal padding as a fraction of canvas width.
    let horizontalPaddingFraction: CGFloat

    init(
        style: ScreenshotPlan.CaptionStyle,
        fontName: String = "SF Pro Display",
        fontSize: CGFloat = 72,
        fontWeight: CGFloat = 700, // Bold
        textColor: CGColor = CGColor(red: 1, green: 1, blue: 1, alpha: 1),
        backgroundColor: CGColor = CGColor(red: 0, green: 0, blue: 0, alpha: 1),
        gradientColors: [CGColor]? = nil,
        captionHeightFraction: CGFloat = 0.2,
        horizontalPaddingFraction: CGFloat = 0.08
    ) {
        self.style = style

        // Create CTFont with the specified name and size, falling back to system font
        if let ctFont = CTFontCreateWithName(fontName as CFString, fontSize, nil) as CTFont? {
            self.font = ctFont
        } else {
            self.font = CTFontCreateWithName("Helvetica-Bold" as CFString, fontSize, nil)
        }

        self.textColor = textColor
        self.backgroundColor = backgroundColor
        self.gradientColors = gradientColors
        self.captionHeightFraction = captionHeightFraction
        self.horizontalPaddingFraction = horizontalPaddingFraction
    }

    /// Applies the caption overlay to a screenshot image.
    ///
    /// - Parameters:
    ///   - image: The source screenshot image.
    ///   - text: The localized marketing caption text.
    ///   - canvasSize: The final output size (typically the device's screenshot size).
    /// - Returns: A new image with the caption rendered on it.
    func apply(
        to image: PlatformImage,
        text: String,
        canvasSize: CGSize
    ) throws -> PlatformImage {
        let captionHeight = canvasSize.height * captionHeightFraction
        let totalHeight = canvasSize.height + captionHeight
        let totalSize = CGSize(width: canvasSize.width, height: totalHeight)

        guard let context = CGContext(
            data: nil,
            width: Int(totalSize.width),
            height: Int(totalSize.height),
            bitsPerComponent: 8,
            bytesPerRow: 0,
            space: CGColorSpaceCreateDeviceRGB(),
            bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
        ) else {
            throw ScreenshotError.captionRenderFailed("Failed to create CGContext")
        }

        // Determine layout based on style
        let screenshotRect: CGRect
        let captionRect: CGRect

        switch style {
        case .top:
            captionRect = CGRect(
                x: 0,
                y: canvasSize.height, // CoreGraphics origin is bottom-left
                width: totalSize.width,
                height: captionHeight
            )
            screenshotRect = CGRect(
                x: 0,
                y: 0,
                width: canvasSize.width,
                height: canvasSize.height
            )
        case .bottom:
            captionRect = CGRect(
                x: 0,
                y: 0,
                width: totalSize.width,
                height: captionHeight
            )
            screenshotRect = CGRect(
                x: 0,
                y: captionHeight,
                width: canvasSize.width,
                height: canvasSize.height
            )
        case .none:
            // Should not reach here, but handle gracefully
            return image
        }

        // Draw caption background
        drawCaptionBackground(in: context, rect: captionRect)

        // Draw screenshot
        #if canImport(UIKit)
        guard let cgImage = image.cgImage else {
            throw ScreenshotError.captionRenderFailed("Failed to get CGImage from UIImage")
        }
        #elseif canImport(AppKit)
        guard let cgImage = image.cgImage(
            forProposedRect: nil,
            context: nil,
            hints: nil
        ) else {
            throw ScreenshotError.captionRenderFailed("Failed to get CGImage from NSImage")
        }
        #endif

        context.draw(cgImage, in: screenshotRect)

        // Draw caption text
        drawCaptionText(text, in: context, rect: captionRect)

        // Create output image
        guard let outputCGImage = context.makeImage() else {
            throw ScreenshotError.captionRenderFailed("Failed to create output image")
        }

        #if canImport(UIKit)
        return UIImage(cgImage: outputCGImage)
        #elseif canImport(AppKit)
        return NSImage(
            cgImage: outputCGImage,
            size: NSSize(width: totalSize.width, height: totalSize.height)
        )
        #endif
    }

    // MARK: - Private Drawing

    /// Draws the caption background (solid color or gradient).
    private func drawCaptionBackground(in context: CGContext, rect: CGRect) {
        if let gradientColors, gradientColors.count >= 2 {
            // Draw gradient background
            let colorSpace = CGColorSpaceCreateDeviceRGB()
            guard let gradient = CGGradient(
                colorsSpace: colorSpace,
                colors: gradientColors as CFArray,
                locations: nil
            ) else {
                // Fallback to solid color
                context.setFillColor(backgroundColor)
                context.fill(rect)
                return
            }

            context.saveGState()
            context.clip(to: rect)
            context.drawLinearGradient(
                gradient,
                start: CGPoint(x: rect.midX, y: rect.minY),
                end: CGPoint(x: rect.midX, y: rect.maxY),
                options: []
            )
            context.restoreGState()
        } else {
            context.setFillColor(backgroundColor)
            context.fill(rect)
        }
    }

    /// Draws centered caption text within the given rect.
    private func drawCaptionText(_ text: String, in context: CGContext, rect: CGRect) {
        let horizontalPadding = rect.width * horizontalPaddingFraction

        // Create attributed string
        let attributes: [NSAttributedString.Key: Any] = [
            .font: font,
            .foregroundColor: textColor
        ]
        let attributedString = NSAttributedString(string: text, attributes: attributes)

        // Create framesetter for multi-line text layout
        let framesetter = CTFramesetterCreateWithAttributedString(attributedString)

        let textRect = CGRect(
            x: rect.origin.x + horizontalPadding,
            y: rect.origin.y,
            width: rect.width - 2 * horizontalPadding,
            height: rect.height
        )

        // Calculate text size for centering
        let suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints(
            framesetter,
            CFRangeMake(0, 0),
            nil,
            textRect.size,
            nil
        )

        // Center text vertically within caption area
        let yOffset = (rect.height - suggestedSize.height) / 2
        let centeredRect = CGRect(
            x: textRect.origin.x,
            y: rect.origin.y + yOffset,
            width: textRect.width,
            height: suggestedSize.height
        )

        let path = CGPath(rect: centeredRect, transform: nil)
        let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, nil)

        context.saveGState()
        CTFrameDraw(frame, context)
        context.restoreGState()
    }
}

ScreenshotExportScript.swift

#!/usr/bin/env swift

import Foundation

// MARK: - Screenshot Export Pipeline Script
//
// Runs the full screenshot generation pipeline:
// 1. Configure simulators (status bar, appearance)
// 2. Build the app for testing
// 3. Run UI tests on each target device
// 4. Extract screenshots from xcresult bundles
// 5. Post-process (captions, device frames)
// 6. Organize output for App Store Connect upload
//
// Usage:
//   swift ScreenshotExportScript.swift
//   swift ScreenshotExportScript.swift --locales en-US,de-DE --devices iPhone_6.7,iPad_12.9
//   swift ScreenshotExportScript.swift --skip-frames --skip-captions

// MARK: - Configuration

struct ExportConfig {
    let projectPath: String
    let scheme: String
    let uiTestTarget: String
    let outputDirectory: String
    let locales: [String]
    let devices: [DeviceSpec]
    let skipFrames: Bool
    let skipCaptions: Bool

    struct DeviceSpec {
        let name: String         // Display name for directory
        let simulatorName: String // Xcode simulator identifier
    }

    static func parseArguments() -> ExportConfig {
        let args = CommandLine.arguments
        var locales = ["en-US"]
        var devices = defaultDevices
        var skipFrames = false
        var skipCaptions = false

        for (index, arg) in args.enumerated() {
            switch arg {
            case "--locales":
                if index + 1 < args.count {
                    locales = args[index + 1].components(separatedBy: ",")
                }
            case "--devices":
                if index + 1 < args.count {
                    let deviceNames = args[index + 1].components(separatedBy: ",")
                    devices = defaultDevices.filter { deviceNames.contains($0.name) }
                }
            case "--skip-frames":
                skipFrames = true
            case "--skip-captions":
                skipCaptions = true
            default:
                break
            }
        }

        return ExportConfig(
            projectPath: ".", // Current directory
            scheme: "YourAppUITests", // TODO: Replace with actual scheme
            uiTestTarget: "YourAppUITests/ScreenshotUITests",
            outputDirectory: "screenshots",
            locales: locales,
            devices: devices,
            skipFrames: skipFrames,
            skipCaptions: skipCaptions
        )
    }

    static let defaultDevices: [DeviceSpec] = [
        DeviceSpec(name: "iPhone_6.7", simulatorName: "iPhone 16 Pro Max"),
        DeviceSpec(name: "iPhone_6.5", simulatorName: "iPhone 11 Pro Max"),
        DeviceSpec(name: "iPad_12.9", simulatorName: "iPad Pro (12.9-inch) (6th generation)")
    ]
}

// MARK: - Shell Execution

@discardableResult
func shell(_ command: String, verbose: Bool = true) throws -> String {
    if verbose {
        print("  > \(command)")
    }

    let process = Process()
    let pipe = Pipe()

    process.executableURL = URL(fileURLWithPath: "/bin/zsh")
    process.arguments = ["-c", command]
    process.standardOutput = pipe
    process.standardError = pipe

    try process.run()
    process.waitUntilExit()

    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8) ?? ""

    if process.terminationStatus != 0 {
        throw ExportError.shellCommandFailed(command: command, output: output)
    }

    return output
}

enum ExportError: Error, LocalizedError {
    case shellCommandFailed(command: String, output: String)
    case screenshotExtractionFailed(String)

    var errorDescription: String? {
        switch self {
        case .shellCommandFailed(let cmd, let output):
            return "Command failed: \(cmd)\nOutput: \(output)"
        case .screenshotExtractionFailed(let reason):
            return "Screenshot extraction failed: \(reason)"
        }
    }
}

// MARK: - Pipeline Steps

func configureSimulators(config: ExportConfig) throws {
    print("\n[1/5] Configuring simulators...")

    for device in config.devices {
        print("  Configuring \(device.simulatorName)...")

        // Boot simulator if needed
        try? shell(
            "xcrun simctl boot '\(device.simulatorName)'",
            verbose: false
        )

        // Override status bar for clean screenshots
        try shell("""
            xcrun simctl status_bar '\(device.simulatorName)' override \
              --time "9:41" \
              --batteryState charged \
              --batteryLevel 100 \
              --wifiBars 3 \
              --cellularBars 4 \
              --operatorName ""
            """)
    }
}

func buildForTesting(config: ExportConfig) throws {
    print("\n[2/5] Building for testing...")

    try shell("""
        xcodebuild build-for-testing \
          -scheme "\(config.scheme)" \
          -destination "platform=iOS Simulator,name=\(config.devices[0].simulatorName)" \
          -derivedDataPath DerivedData \
          | xcbeautify 2>/dev/null || true
        """)
}

func runTests(config: ExportConfig) throws {
    print("\n[3/5] Running screenshot tests...")

    for device in config.devices {
        print("  Capturing on \(device.simulatorName)...")

        let resultBundleName = "screenshots_\(device.name).xcresult"

        // Remove old result bundle if exists
        try? shell("rm -rf \(resultBundleName)", verbose: false)

        try shell("""
            xcodebuild test-without-building \
              -scheme "\(config.scheme)" \
              -destination "platform=iOS Simulator,name=\(device.simulatorName)" \
              -derivedDataPath DerivedData \
              -only-testing "\(config.uiTestTarget)" \
              -resultBundlePath \(resultBundleName) \
              | xcbeautify 2>/dev/null || true
            """)
    }
}

func extractScreenshots(config: ExportConfig) throws {
    print("\n[4/5] Extracting screenshots from test results...")

    let outputDir = config.outputDirectory
    try? shell("rm -rf \(outputDir)", verbose: false)

    for device in config.devices {
        let resultBundle = "screenshots_\(device.name).xcresult"

        // Extract test attachments from xcresult
        let attachmentsDir = "\(outputDir)/raw/\(device.name)"
        try shell("mkdir -p \(attachmentsDir)")

        // Use xcresulttool to list and extract attachments
        try shell("""
            xcrun xcresulttool get \
              --path \(resultBundle) \
              --format json \
              > /tmp/xcresult_\(device.name).json 2>/dev/null || true
            """)

        // Extract actual screenshot files using xcparse (if available) or manual extraction
        try? shell("""
            if command -v xcparse &> /dev/null; then
              xcparse attachments \(resultBundle) \(attachmentsDir)
            else
              echo "  Note: Install xcparse for easier extraction: brew install chargepoint/xcparse/xcparse"
              # Fallback: copy from result bundle internals
              find \(resultBundle) -name "*.png" -exec cp {} \(attachmentsDir)/ \\;
            fi
            """)

        // Organize by locale
        for locale in config.locales {
            let localeDir = "\(outputDir)/\(locale)/\(device.name)"
            try shell("mkdir -p \(localeDir)")

            // Move files matching this locale
            try? shell(
                "mv \(attachmentsDir)/\(locale)_*.png \(localeDir)/ 2>/dev/null || true",
                verbose: false
            )

            // Rename to remove locale prefix
            let files = try shell(
                "ls \(localeDir)/*.png 2>/dev/null || true",
                verbose: false
            )
            for file in files.components(separatedBy: "\n") where !file.isEmpty {
                let filename = URL(fileURLWithPath: file).lastPathComponent
                let cleaned = filename.replacingOccurrences(of: "\(locale)_", with: "")
                if cleaned != filename {
                    try? shell(
                        "mv '\(file)' '\(localeDir)/\(cleaned)'",
                        verbose: false
                    )
                }
            }
        }
    }
}

func postProcess(config: ExportConfig) throws {
    print("\n[5/5] Post-processing screenshots...")

    if config.skipCaptions && config.skipFrames {
        print("  Skipping post-processing (--skip-frames --skip-captions)")
        return
    }

    if !config.skipCaptions {
        print("  Adding caption overlays...")
        // Caption overlay is handled by ScreenshotProcessor when run as part of the app
        // For standalone script usage, invoke the compiled processor:
        print("  Note: Run the Swift post-processor for caption overlays:")
        print("    swift run ScreenshotProcessor --input \(config.outputDirectory) --captions")
    }

    if !config.skipFrames {
        print("  Adding device frames...")
        print("  Note: Run the Swift post-processor for device frames:")
        print("    swift run ScreenshotProcessor --input \(config.outputDirectory) --frames")
    }
}

func printSummary(config: ExportConfig) {
    print("\n" + String(repeating: "=", count: 60))
    print("Screenshot generation complete!")
    print(String(repeating: "=", count: 60))
    print("")
    print("Output directory: \(config.outputDirectory)/")
    print("Locales: \(config.locales.joined(separator: ", "))")
    print("Devices: \(config.devices.map(\.name).joined(separator: ", "))")
    print("")
    print("Directory structure:")

    for locale in config.locales {
        print("  \(config.outputDirectory)/\(locale)/")
        for device in config.devices {
            print("    \(device.name)/")
            print("      01_HomeScreen.png")
            print("      02_DetailView.png")
            print("      ...")
        }
    }

    print("")
    print("Next steps:")
    print("  1. Review screenshots in \(config.outputDirectory)/")
    print("  2. Upload to App Store Connect via Transporter or fastlane deliver")
    print("  3. Verify all required sizes are present")
}

// MARK: - Main

let config = ExportConfig.parseArguments()

do {
    try configureSimulators(config: config)
    try buildForTesting(config: config)
    try runTests(config: config)
    try extractScreenshots(config: config)
    try postProcess(config: config)
    printSummary(config: config)
} catch {
    print("\nError: \(error.localizedDescription)")
    exit(1)
}

ScreenshotModeController.swift

A controller the user adds to their app target (not the test target) that detects --screenshot-mode and configures the app for clean screenshot capture. This is the app-side counterpart to the UI test infrastructure.

import Foundation
import SwiftUI

#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

/// Controls app behavior during automated screenshot capture.
///
/// Add this to your App's init or main entry point:
/// ```swift
/// @main
/// struct MyApp: App {
///     init() {
///         ScreenshotModeController.shared.configureIfNeeded()
///     }
///
///     var body: some Scene {
///         WindowGroup {
///             ContentView()
///                 .onAppear {
///                     ScreenshotModeController.shared.configureWindow()
///                 }
///         }
///     }
/// }
/// ```
@MainActor
final class ScreenshotModeController {
    static let shared = ScreenshotModeController()

    /// Whether the app was launched in screenshot mode.
    let isScreenshotMode: Bool

    /// Whether onboarding should be skipped.
    let shouldSkipOnboarding: Bool

    private init() {
        let args = ProcessInfo.processInfo.arguments
        self.isScreenshotMode = args.contains("--screenshot-mode") ||
                                args.contains("-ScreenshotMode")
        self.shouldSkipOnboarding = isScreenshotMode ||
                                    args.contains("--skip-onboarding") ||
                                    args.contains("-SkipOnboarding")
    }

    /// Call early in app lifecycle (e.g., App.init or applicationDidFinishLaunching).
    /// Suppresses onboarding, disables analytics, loads sample data.
    func configureIfNeeded() {
        guard isScreenshotMode else { return }

        // Suppress first-launch behaviors
        UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding")
        UserDefaults.standard.set(true, forKey: "hasSeenWhatsNew")

        // Disable analytics and crash reporting during screenshots
        UserDefaults.standard.set(false, forKey: "analyticsEnabled")

        // Disable in-app purchase prompts
        UserDefaults.standard.set(true, forKey: "hideIAPPrompts")

        // Load sample data for attractive screenshots
        loadSampleData()

        #if canImport(AppKit)
        configureDesktopForScreenshots()
        #endif
    }

    /// Configure the main window for screenshot capture.
    /// Call in onAppear of your root view.
    func configureWindow() {
        guard isScreenshotMode else { return }

        #if canImport(AppKit)
        // Size the window to match App Store screenshot dimensions
        // Mac App Store requires 2880×1800 for Retina displays
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            guard let window = NSApp.windows.first(where: { $0.isVisible }) else { return }

            // Account for title bar height and asymmetric window borders
            // macOS windows have different border thickness top vs bottom.
            // The bottom ~3pt gets cropped, so add padding there.
            let titleBarHeight: CGFloat = 28
            let bottomBorderPadding: CGFloat = 4

            let contentWidth: CGFloat = 1440  // Half of 2880 for Retina
            let contentHeight: CGFloat = 900 - titleBarHeight + bottomBorderPadding

            window.setContentSize(NSSize(width: contentWidth, height: contentHeight))
            window.center()

            // Bring window to front
            window.makeKeyAndOrderFront(nil)
            NSApp.activate(ignoringOtherApps: true)
        }
        #endif
    }

    // MARK: - Sample Data

    /// Override this method to load your app's sample data.
    /// Called automatically when screenshot mode is detected.
    ///
    /// Example implementation:
    /// ```swift
    /// override func loadSampleData() {
    ///     let store = DataStore.shared
    ///     store.clearAll()
    ///     store.insert(SampleData.projects)
    ///     store.insert(SampleData.tasks)
    /// }
    /// ```
    func loadSampleData() {
        // Override in your app to load attractive sample data.
        // This is where you populate your data store with content
        // that makes the app look great in screenshots.
    }

    #if canImport(AppKit)
    // MARK: - macOS Desktop Configuration

    /// Configures the macOS desktop for clean screenshots.
    /// Hides the dock and sets a clean appearance.
    private func configureDesktopForScreenshots() {
        // Hide the dock during screenshots
        // The dock auto-hides when the app is in the foreground with this setting
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/usr/bin/defaults")
        process.arguments = ["write", "com.apple.dock", "autohide", "-bool", "true"]
        try? process.run()
        process.waitUntilExit()

        // Restart dock to apply
        let killDock = Process()
        killDock.executableURL = URL(fileURLWithPath: "/usr/bin/killall")
        killDock.arguments = ["Dock"]
        try? killDock.run()
        killDock.waitUntilExit()
    }
    #endif
}

// MARK: - SwiftUI Integration

/// Environment key for screenshot mode detection in views.
///
/// Usage in views:
/// ```swift
/// @Environment(\.isScreenshotMode) private var isScreenshotMode
///
/// var body: some View {
///     if !isScreenshotMode {
///         UpgradePromptBanner()
///     }
///     // ... main content
/// }
/// ```
private struct ScreenshotModeKey: EnvironmentKey {
    static let defaultValue = ScreenshotModeController.shared.isScreenshotMode
}

extension EnvironmentValues {
    var isScreenshotMode: Bool {
        get { self[ScreenshotModeKey.self] }
        set { self[ScreenshotModeKey.self] = newValue }
    }
}

sips-screenshot-process.sh

A lightweight post-processing script using macOS's built-in sips command. Zero external dependencies — no Swift compilation, no fastlane, no ImageMagick. Best for macOS app screenshots where you just need to resize and crop to App Store dimensions.

#!/bin/bash
# sips-screenshot-process.sh
# Post-process screenshots using macOS built-in sips command.
# No external dependencies required.
#
# Usage:
#   ./sips-screenshot-process.sh raw_screenshots/ processed/
#   ./sips-screenshot-process.sh raw_screenshots/ processed/ --mac-only
#   ./sips-screenshot-process.sh raw_screenshots/ processed/ --target 2880x1800

set -euo pipefail

INPUT_DIR="${1:?Usage: $0 <input_dir> <output_dir> [--mac-only] [--target WxH]}"
OUTPUT_DIR="${2:?Usage: $0 <input_dir> <output_dir> [--mac-only] [--target WxH]}"
MAC_ONLY=false
TARGET_WIDTH=""
TARGET_HEIGHT=""

# Parse optional arguments
shift 2
while [[ $# -gt 0 ]]; do
    case "$1" in
        --mac-only) MAC_ONLY=true; shift ;;
        --target)
            TARGET_WIDTH="${2%x*}"
            TARGET_HEIGHT="${2#*x}"
            shift 2
            ;;
        *) echo "Unknown option: $1"; exit 1 ;;
    esac
done

# Default App Store dimensions
declare -A SIZES
if [[ "$MAC_ONLY" == true ]]; then
    SIZES=(
        ["mac_retina"]="2880x1800"
    )
else
    SIZES=(
        ["iPhone_6.9"]="1320x2868"
        ["iPhone_6.7"]="1290x2796"
        ["iPhone_6.5"]="1284x2778"
        ["iPhone_5.5"]="1242x2208"
        ["iPad_12.9"]="2048x2732"
        ["mac_retina"]="2880x1800"
    )
fi

mkdir -p "$OUTPUT_DIR"

process_image() {
    local src="$1"
    local dst="$2"
    local target_w="$3"
    local target_h="$4"
    local filename
    filename=$(basename "$src")

    # Get current dimensions
    local cur_w cur_h
    cur_w=$(sips -g pixelWidth "$src" | tail -1 | awk '{print $2}')
    cur_h=$(sips -g pixelHeight "$src" | tail -1 | awk '{print $2}')

    cp "$src" "$dst/$filename"
    local work="$dst/$filename"

    # Step 1: Resize to target width (maintaining aspect ratio)
    if [[ "$cur_w" -ne "$target_w" ]]; then
        sips --resampleWidth "$target_w" "$work" --out "$work" > /dev/null 2>&1
    fi

    # Step 2: Crop to exact target height if needed
    local new_h
    new_h=$(sips -g pixelHeight "$work" | tail -1 | awk '{print $2}')
    if [[ "$new_h" -gt "$target_h" ]]; then
        # Crop from bottom (removes extra pixels at the bottom edge)
        # This accounts for macOS asymmetric window borders
        sips --cropToHeightWidth "$target_h" "$target_w" "$work" --out "$work" > /dev/null 2>&1
    fi

    echo "  ✓ $filename → ${target_w}×${target_h}"
}

# Process with explicit target
if [[ -n "$TARGET_WIDTH" && -n "$TARGET_HEIGHT" ]]; then
    echo "Processing screenshots to ${TARGET_WIDTH}×${TARGET_HEIGHT}..."
    for img in "$INPUT_DIR"/*.png; do
        [[ -f "$img" ]] || continue
        process_image "$img" "$OUTPUT_DIR" "$TARGET_WIDTH" "$TARGET_HEIGHT"
    done
    echo "Done. Output in $OUTPUT_DIR/"
    exit 0
fi

# Process for each App Store size
for size_name in "${!SIZES[@]}"; do
    dims="${SIZES[$size_name]}"
    w="${dims%x*}"
    h="${dims#*x}"

    size_dir="$OUTPUT_DIR/$size_name"
    mkdir -p "$size_dir"

    echo "Processing for $size_name (${w}×${h})..."
    for img in "$INPUT_DIR"/*.png; do
        [[ -f "$img" ]] || continue
        process_image "$img" "$size_dir" "$w" "$h"
    done
done

echo ""
echo "All screenshots processed."
echo "Output: $OUTPUT_DIR/"
ls -la "$OUTPUT_DIR"/

macos-screenshot-env.sh

Prepares the macOS desktop environment for clean screenshot capture. Sets clock time, hides desktop icons, configures wallpaper, and hides the dock. Uses trap to restore everything on exit — even if the script fails or is interrupted.

#!/bin/bash
# macos-screenshot-env.sh
# Prepare macOS desktop for screenshot capture with automatic cleanup.
#
# Usage:
#   ./macos-screenshot-env.sh             # Setup only (restores on exit)
#   ./macos-screenshot-env.sh --run-tests # Setup, run tests, then restore
#   ./macos-screenshot-env.sh --restore   # Force restore saved state
#
# This script saves original settings, applies screenshot-friendly configuration,
# and restores everything on exit via trap — even on Ctrl+C or failure.

set -euo pipefail

STATE_FILE="/tmp/.screenshot_env_state"

# ── Save current state ──────────────────────────────────────────────

save_state() {
    echo "Saving current desktop state..."

    local dock_autohide
    dock_autohide=$(defaults read com.apple.dock autohide 2>/dev/null || echo "0")

    local dock_size
    dock_size=$(defaults read com.apple.dock tilesize 2>/dev/null || echo "48")

    local desktop_icons
    desktop_icons=$(defaults read com.apple.finder CreateDesktop 2>/dev/null || echo "1")

    local menubar_clock
    menubar_clock=$(defaults read com.apple.menuextra.clock DateFormat 2>/dev/null || echo "")

    cat > "$STATE_FILE" <<STATEEOF
DOCK_AUTOHIDE=$dock_autohide
DOCK_SIZE=$dock_size
DESKTOP_ICONS=$desktop_icons
MENUBAR_CLOCK=$menubar_clock
STATEEOF

    echo "  State saved to $STATE_FILE"
}

# ── Restore original state ──────────────────────────────────────────

restore_state() {
    echo ""
    echo "Restoring original desktop state..."

    if [[ ! -f "$STATE_FILE" ]]; then
        echo "  No saved state found at $STATE_FILE"
        return
    fi

    source "$STATE_FILE"

    # Restore dock
    defaults write com.apple.dock autohide -bool "$DOCK_AUTOHIDE"
    defaults write com.apple.dock tilesize -int "$DOCK_SIZE"

    # Restore desktop icons
    defaults write com.apple.finder CreateDesktop -bool "$DESKTOP_ICONS"

    # Restore clock format
    if [[ -n "$MENUBAR_CLOCK" ]]; then
        defaults write com.apple.menuextra.clock DateFormat "$MENUBAR_CLOCK"
    fi

    # Restart affected services
    killall Dock 2>/dev/null || true
    killall Finder 2>/dev/null || true
    killall SystemUIServer 2>/dev/null || true

    rm -f "$STATE_FILE"
    echo "  Desktop restored to original state."
}

# ── Apply screenshot environment ────────────────────────────────────

apply_screenshot_env() {
    echo "Configuring desktop for screenshots..."

    # Hide the dock
    defaults write com.apple.dock autohide -bool true
    defaults write com.apple.dock autohide-delay -float 1000
    defaults write com.apple.dock no-bouncing -bool true

    # Hide desktop icons (Finder's "CreateDesktop" setting)
    defaults write com.apple.finder CreateDesktop -bool false

    # Set clock to canonical time (9:41 — Apple's standard)
    # Note: This changes the display format, not the actual time.
    # For the menu bar clock, we hide seconds and use a clean format.
    defaults write com.apple.menuextra.clock DateFormat -string "h:mm"

    # Restart affected services to apply changes
    killall Dock 2>/dev/null || true
    killall Finder 2>/dev/null || true
    killall SystemUIServer 2>/dev/null || true

    # Wait for services to restart
    sleep 2

    echo "  ✓ Dock hidden"
    echo "  ✓ Desktop icons hidden"
    echo "  ✓ Menu bar clock simplified"
    echo ""
    echo "Desktop is ready for screenshots."
}

# ── Trap: Always restore on exit ────────────────────────────────────

trap restore_state EXIT INT TERM

# ── Main ────────────────────────────────────────────────────────────

case "${1:-}" in
    --restore)
        restore_state
        trap - EXIT INT TERM  # Don't double-restore
        exit 0
        ;;
    --run-tests)
        save_state
        apply_screenshot_env

        echo ""
        echo "Running screenshot tests..."
        shift

        # Run the test command passed as remaining arguments,
        # or use default xcodebuild command
        if [[ $# -gt 0 ]]; then
            "$@"
        else
            echo "No test command provided. Desktop is configured."
            echo "Run your tests manually, then press Enter to restore."
            read -r
        fi
        # restore_state runs automatically via trap
        ;;
    *)
        save_state
        apply_screenshot_env

        echo ""
        echo "Desktop configured. Press Enter when done to restore, or Ctrl+C."
        read -r
        # restore_state runs automatically via trap
        ;;
esac

SampleContentGenerator.swift

Patterns for generating realistic sample content programmatically. Instead of relying on pre-seeded databases, generate attractive data at runtime during screenshot mode. Covers text, images, PDFs, and structured data.

import Foundation
import CoreGraphics

#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

/// Generates realistic sample content for screenshot capture.
///
/// Use in your ScreenshotModeController.loadSampleData() override
/// to populate the app with attractive content.
///
/// ```swift
/// override func loadSampleData() {
///     let generator = SampleContentGenerator()
///     let projects = generator.generateProjects(count: 5)
///     DataStore.shared.insert(projects)
/// }
/// ```
enum SampleContentGenerator {

    // MARK: - Text Content

    /// Realistic sample titles by app category.
    /// Use these instead of "Lorem Ipsum" for screenshots that look real.
    static let sampleTitles: [String: [String]] = [
        "productivity": [
            "Q4 Marketing Strategy",
            "Website Redesign Sprint",
            "Product Launch Checklist",
            "Team Onboarding Guide",
            "Budget Review 2025"
        ],
        "fitness": [
            "Morning Run - Central Park",
            "Upper Body Workout",
            "5K Training Plan",
            "Yoga Flow Session",
            "Weekend Hike"
        ],
        "finance": [
            "Monthly Budget",
            "Vacation Fund",
            "Emergency Savings",
            "Investment Portfolio",
            "Subscription Audit"
        ],
        "notes": [
            "Meeting Notes - Design Review",
            "Book Recommendations",
            "Recipe: Homemade Pasta",
            "Travel Packing List",
            "Gift Ideas for Mom"
        ]
    ]

    /// Returns sample titles for a given category, cycling if count exceeds available titles.
    static func titles(for category: String, count: Int) -> [String] {
        let pool = sampleTitles[category] ?? sampleTitles["productivity"]!
        return (0..<count).map { pool[$0 % pool.count] }
    }

    /// Generates a realistic paragraph of text.
    static func paragraph(sentences: Int = 3) -> String {
        let pool = [
            "Track your progress with detailed analytics and visual charts.",
            "Stay organized with smart folders that adapt to your workflow.",
            "Collaborate seamlessly with your team in real-time.",
            "Set reminders so you never miss an important deadline.",
            "Export your data in multiple formats for easy sharing.",
            "Customize the interface to match your personal style.",
            "Access your content from any device with cloud sync.",
            "Use keyboard shortcuts to work faster than ever.",
            "Get insights with weekly summary reports.",
            "Protect your data with end-to-end encryption."
        ]
        let selected = (0..<sentences).map { pool[$0 % pool.count] }
        return selected.joined(separator: " ")
    }

    // MARK: - Sample Dates

    /// Generates an array of recent dates, useful for chart data or activity feeds.
    static func recentDates(count: Int, daySpacing: Int = 1) -> [Date] {
        let calendar = Calendar.current
        let now = Date()
        return (0..<count).compactMap { index in
            calendar.date(byAdding: .day, value: -(index * daySpacing), to: now)
        }.reversed()
    }

    // MARK: - Sample Numbers

    /// Generates realistic-looking chart data with a general upward trend.
    static func trendingData(count: Int, baseValue: Double = 100, variance: Double = 20) -> [Double] {
        var values: [Double] = []
        var current = baseValue
        for i in 0..<count {
            let trend = Double(i) * (variance / Double(count)) // Gradual increase
            let noise = Double.random(in: -variance/3...variance/3)
            current = baseValue + trend + noise
            values.append(max(0, current))
        }
        return values
    }

    /// Generates percentage values that sum to 100 (useful for pie charts).
    static func percentages(count: Int) -> [Double] {
        let raw = (0..<count).map { _ in Double.random(in: 10...50) }
        let total = raw.reduce(0, +)
        return raw.map { ($0 / total) * 100 }
    }

    // MARK: - Placeholder Images

    /// Generates a solid color placeholder image.
    static func placeholderImage(
        size: CGSize,
        color: CGColor = CGColor(red: 0.9, green: 0.9, blue: 0.95, alpha: 1)
    ) -> PlatformImage? {
        guard let context = CGContext(
            data: nil,
            width: Int(size.width),
            height: Int(size.height),
            bitsPerComponent: 8,
            bytesPerRow: 0,
            space: CGColorSpaceCreateDeviceRGB(),
            bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
        ) else { return nil }

        context.setFillColor(color)
        context.fill(CGRect(origin: .zero, size: size))

        guard let cgImage = context.makeImage() else { return nil }

        #if canImport(UIKit)
        return UIImage(cgImage: cgImage)
        #elseif canImport(AppKit)
        return NSImage(cgImage: cgImage, size: size)
        #endif
    }

    /// Generates a gradient placeholder image (more visually appealing for screenshots).
    static func gradientImage(
        size: CGSize,
        colors: [CGColor] = [
            CGColor(red: 0.3, green: 0.5, blue: 0.9, alpha: 1),
            CGColor(red: 0.6, green: 0.3, blue: 0.8, alpha: 1)
        ]
    ) -> PlatformImage? {
        guard let context = CGContext(
            data: nil,
            width: Int(size.width),
            height: Int(size.height),
            bitsPerComponent: 8,
            bytesPerRow: 0,
            space: CGColorSpaceCreateDeviceRGB(),
            bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
        ) else { return nil }

        let colorSpace = CGColorSpaceCreateDeviceRGB()
        guard let gradient = CGGradient(
            colorsSpace: colorSpace,
            colors: colors as CFArray,
            locations: nil
        ) else { return nil }

        context.drawLinearGradient(
            gradient,
            start: .zero,
            end: CGPoint(x: size.width, y: size.height),
            options: []
        )

        guard let cgImage = context.makeImage() else { return nil }

        #if canImport(UIKit)
        return UIImage(cgImage: cgImage)
        #elseif canImport(AppKit)
        return NSImage(cgImage: cgImage, size: size)
        #endif
    }

    // MARK: - PDF Generation (macOS)

    #if canImport(AppKit)
    /// Generates a sample PDF document with multiple pages of varied content.
    ///
    /// Useful for document-based apps that need realistic content in screenshots.
    /// Creates pages with headings, body text, and simple shapes.
    static func generateSamplePDF(
        pages: Int = 3,
        pageSize: CGSize = CGSize(width: 612, height: 792), // US Letter
        outputURL: URL
    ) throws {
        var mediaBox = CGRect(origin: .zero, size: pageSize)

        guard let context = CGContext(outputURL as CFURL, mediaBox: &mediaBox, nil) else {
            return
        }

        let headingFont = CTFontCreateWithName("Helvetica-Bold" as CFString, 24, nil)
        let bodyFont = CTFontCreateWithName("Helvetica" as CFString, 12, nil)

        let pageTitles = [
            "Executive Summary",
            "Market Analysis",
            "Financial Projections",
            "Implementation Timeline",
            "Risk Assessment"
        ]

        let bodyText = """
        This section contains detailed analysis and supporting data for the \
        overall strategy. Key metrics indicate positive trends across all \
        measured dimensions, with particular strength in user engagement \
        and retention rates.
        """

        for pageIndex in 0..<pages {
            context.beginPage(mediaBox: &mediaBox)

            // Draw heading
            let title = pageTitles[pageIndex % pageTitles.count]
            let headingAttrs: [NSAttributedString.Key: Any] = [
                .font: headingFont,
                .foregroundColor: NSColor.black
            ]
            let headingString = NSAttributedString(string: title, attributes: headingAttrs)
            let headingLine = CTLineCreateWithAttributedString(headingString)

            context.textPosition = CGPoint(x: 72, y: pageSize.height - 72)
            CTLineDraw(headingLine, context)

            // Draw body text
            let bodyAttrs: [NSAttributedString.Key: Any] = [
                .font: bodyFont,
                .foregroundColor: NSColor.darkGray
            ]
            let bodyString = NSAttributedString(string: bodyText, attributes: bodyAttrs)
            let framesetter = CTFramesetterCreateWithAttributedString(bodyString)
            let textRect = CGRect(x: 72, y: 72, width: pageSize.width - 144, height: pageSize.height - 180)
            let path = CGPath(rect: textRect, transform: nil)
            let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, nil)
            CTFrameDraw(frame, context)

            // Draw a decorative chart placeholder on some pages
            if pageIndex % 2 == 1 {
                let chartRect = CGRect(
                    x: 72,
                    y: pageSize.height - 400,
                    width: pageSize.width - 144,
                    height: 200
                )
                context.setStrokeColor(CGColor(red: 0.7, green: 0.7, blue: 0.7, alpha: 1))
                context.setLineWidth(1)
                context.stroke(chartRect)

                // Draw simple bar chart
                let barCount = 6
                let barWidth = (chartRect.width - 20) / CGFloat(barCount)
                let colors: [CGColor] = [
                    CGColor(red: 0.3, green: 0.5, blue: 0.9, alpha: 0.8),
                    CGColor(red: 0.2, green: 0.7, blue: 0.5, alpha: 0.8)
                ]

                for i in 0..<barCount {
                    let barHeight = CGFloat.random(in: 40...180)
                    let barRect = CGRect(
                        x: chartRect.origin.x + 10 + CGFloat(i) * barWidth + 5,
                        y: chartRect.origin.y + 10,
                        width: barWidth - 10,
                        height: barHeight
                    )
                    context.setFillColor(colors[i % colors.count])
                    context.fill(barRect)
                }
            }

            context.endPage()
        }

        context.closePDF()
    }
    #endif
}

// MARK: - Sample User Profiles

extension SampleContentGenerator {
    /// Pre-built user profiles for social or collaborative app screenshots.
    struct SampleUser {
        let name: String
        let initials: String
        let role: String
        let avatarColor: CGColor
    }

    static let sampleUsers: [SampleUser] = [
        SampleUser(
            name: "Sarah Chen",
            initials: "SC",
            role: "Designer",
            avatarColor: CGColor(red: 0.9, green: 0.4, blue: 0.4, alpha: 1)
        ),
        SampleUser(
            name: "Alex Rivera",
            initials: "AR",
            role: "Developer",
            avatarColor: CGColor(red: 0.3, green: 0.6, blue: 0.9, alpha: 1)
        ),
        SampleUser(
            name: "Jordan Park",
            initials: "JP",
            role: "Manager",
            avatarColor: CGColor(red: 0.4, green: 0.8, blue: 0.5, alpha: 1)
        ),
        SampleUser(
            name: "Maya Johnson",
            initials: "MJ",
            role: "Analyst",
            avatarColor: CGColor(red: 0.8, green: 0.6, blue: 0.2, alpha: 1)
        )
    ]
}

Xcode Test Plan for Screenshots

Create a dedicated test plan to isolate screenshot tests from development tests. This prevents screenshot tests from running during normal Cmd+U test cycles.

File: ScreenshotTests.xctestplan

{
  "configurations" : [
    {
      "id" : "screenshot-config",
      "name" : "Screenshot Configuration",
      "options" : {
        "language" : "en",
        "region" : "US",
        "uiTestingScreenshotsLifetime" : "keepAlways",
        "testRepetitionMode" : "none"
      }
    }
  ],
  "defaultOptions" : {
    "codeCoverage" : false,
    "testTimeoutsEnabled" : true,
    "defaultTestExecutionTimeAllowance" : 300,
    "maximumTestExecutionTimeAllowance" : 600,
    "targetForVariableExpansion" : {
      "containerPath" : "container:YourApp.xcodeproj",
      "identifier" : "YourAppUITests",
      "name" : "YourAppUITests"
    }
  },
  "testTargets" : [
    {
      "target" : {
        "containerPath" : "container:YourApp.xcodeproj",
        "identifier" : "YourAppUITests",
        "name" : "YourAppUITests"
      },
      "selectedTests" : [
        "ScreenshotUITests"
      ]
    }
  ],
  "version" : 1
}

How to add this test plan to Xcode:

  1. Save the above JSON as ScreenshotTests.xctestplan in your project root
  2. In Xcode, go to Product → Scheme → Edit Scheme
  3. Under Test, click the + at the bottom of the test plans list
  4. Select "Add existing test plan" and choose ScreenshotTests.xctestplan
  5. Your normal test plan runs with Cmd+U; run the screenshot plan via:
    xcodebuild test \
      -scheme "YourApp" \
      -testPlan "ScreenshotTests" \
      -destination "platform=iOS Simulator,name=iPhone 16 Pro Max"

Adding locale variants as test configurations:

{
  "configurations" : [
    {
      "id" : "en-US",
      "name" : "English (US)",
      "options" : { "language" : "en", "region" : "US" }
    },
    {
      "id" : "de-DE",
      "name" : "German",
      "options" : { "language" : "de", "region" : "DE" }
    },
    {
      "id" : "ja-JP",
      "name" : "Japanese",
      "options" : { "language" : "ja", "region" : "JP" }
    }
  ]
}

Source: SKILL.md on GitHub

No alerts2mo3 checks · Risk SAFE
  • Gen Agent Trust Hub2mo

    The skill provides a suite of templates and scripts to automate Apple App Store screenshot generation. It generates Swift and Shell scripts that perform build operations and modify macOS system settings to create a clean capture environment. All identified behaviors are consistent with the skill's primary purpose as a developer automation tool.

  • Socket2mo

    No alerts

  • Snyk2mo

    Risk: LOW · No issues

Signed by skilld at 32566aa. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 3 months ago
What it can do
Reads files Edits files Runs commands
last_verified
2026-07-16
review_by
2027-06-22
os_version
iOS 27 / macOS 27
All 7 allowed tools
ReadWriteEditGlobGrepBashAskUserQuestion

README badge

README badge for rshankras/claude-code-apple-skills/screenshot-automation