iOS Simulator Screenshots #23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: iOS Simulator Screenshots | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| device: | |
| description: "Simulator device target (includes screen size + target resolution; use auto to pick first available iPhone/iPad)" | |
| required: true | |
| default: "auto" | |
| type: choice | |
| options: | |
| - auto | |
| - iPhone 11 (6.1-inch, 828x1792) | |
| - iPhone 11 Pro (5.8-inch, 1125x2436) | |
| - iPhone 11 Pro Max (6.5-inch, 1242x2688) | |
| - iPhone 12 mini (5.4-inch, 1080x2340) | |
| - iPhone 12 (6.1-inch, 1170x2532) | |
| - iPhone 12 Pro (6.1-inch, 1170x2532) | |
| - iPhone 12 Pro Max (6.7-inch, 1284x2778) | |
| - iPhone 13 mini (5.4-inch, 1080x2340) | |
| - iPhone 13 (6.1-inch, 1170x2532) | |
| - iPhone 13 Pro (6.1-inch, 1170x2532) | |
| - iPhone 13 Pro Max (6.7-inch, 1284x2778) | |
| - iPhone 14 (6.1-inch, 1170x2532) | |
| - iPhone 14 Plus (6.7-inch, 1284x2778) | |
| - iPhone 14 Pro (6.1-inch, 1179x2556) | |
| - iPhone 14 Pro Max (6.7-inch, 1290x2796) | |
| - iPhone 15 (6.1-inch, 1179x2556) | |
| - iPhone 15 Plus (6.7-inch, 1290x2796) | |
| - iPhone 15 Pro (6.1-inch, 1179x2556) | |
| - iPhone 15 Pro Max (6.7-inch, 1290x2796) | |
| - iPhone 16e (6.1-inch, 1170x2532) | |
| - iPhone 16 (6.1-inch, 1179x2556) | |
| - iPhone 16 Plus (6.7-inch, 1290x2796) | |
| - iPhone 16 Pro (6.3-inch, 1206x2622) | |
| - iPhone 16 Pro Max (6.9-inch, 1320x2868) | |
| - iPad (10th generation) (10.9-inch, 1640x2360) | |
| - iPad mini (6th generation) (8.3-inch, 1488x2266) | |
| - iPad Air (5th generation) (10.9-inch, 1640x2360) | |
| - iPad Pro (11-inch) (4th generation) (11-inch, 1668x2388) | |
| - iPad Pro (12.9-inch) (6th generation) (12.9-inch, 2048x2732) | |
| startup_wait_seconds: | |
| description: "Seconds to wait after first app launch" | |
| required: true | |
| default: "8" | |
| type: string | |
| per_page_wait_seconds: | |
| description: "Seconds to wait after each deep link" | |
| required: true | |
| default: "3" | |
| type: string | |
| deep_links: | |
| description: "Deep links separated by comma or newline" | |
| required: false | |
| type: string | |
| default: "eclipsetimer://landing,eclipsetimer://timer,eclipsetimer://notifications,eclipsetimer://locations,eclipsetimer://eclipse/2026-total,eclipsetimer://eclipse/2026-total/preview,eclipsetimer://eclipse/2027-total,eclipsetimer://eclipse/2027-total/preview" | |
| concurrency: | |
| group: ios-screenshots-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| screenshots: | |
| name: Capture iOS Simulator Screenshots | |
| runs-on: [self-hosted, macOS, eclipse-timer] | |
| timeout-minutes: 90 | |
| permissions: | |
| contents: write | |
| env: | |
| BUNDLE_ID: com.lallimaven.eclipse-timer | |
| SENTRY_DISABLE_AUTO_UPLOAD: "true" | |
| SENTRY_DISABLE_XCODE_DEBUG_UPLOAD: "true" | |
| SENTRY_ALLOW_FAILURE: "true" | |
| SENTRY_CLI_EXECUTABLE: "/usr/bin/true" | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Install pnpm | |
| uses: pnpm/action-setup@v4 | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 20 | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile --prefer-offline | |
| - name: Setup EAS CLI | |
| uses: expo/expo-github-action@v8 | |
| with: | |
| eas-version: latest | |
| token: ${{ secrets.EXPO_TOKEN }} | |
| packager: pnpm | |
| - name: Build iOS simulator app | |
| working-directory: apps/mobile | |
| env: | |
| EAS_LOCAL_BUILD_ARTIFACTS_DIR: ${{ github.workspace }}/artifacts/raw | |
| # Prevent runtime permission prompt from obscuring simulator screenshots. | |
| EXPO_PUBLIC_SKIP_NOTIFICATION_PERMISSION_PROMPT: "true" | |
| run: | | |
| set -euo pipefail | |
| pnpm exec eas build --platform ios --profile screenshots --local --non-interactive | |
| - name: Resolve simulator app artifact | |
| id: resolve_app | |
| run: | | |
| set -euo pipefail | |
| artifacts_dir="$GITHUB_WORKSPACE/artifacts/raw" | |
| extracted_dir="$GITHUB_WORKSPACE/artifacts/simulator-app" | |
| mkdir -p "$extracted_dir" | |
| app_path="$(find "$artifacts_dir" -type d -name '*.app' | head -n 1 || true)" | |
| if [ -z "$app_path" ]; then | |
| archive_path="$(find "$artifacts_dir" -type f \( -name '*.tar.gz' -o -name '*.zip' \) | head -n 1 || true)" | |
| if [ -z "$archive_path" ]; then | |
| echo "Could not find simulator .app or archive in $artifacts_dir." | |
| find "$artifacts_dir" -maxdepth 2 -print || true | |
| exit 1 | |
| fi | |
| case "$archive_path" in | |
| *.tar.gz) tar -xzf "$archive_path" -C "$extracted_dir" ;; | |
| *.zip) ditto -xk "$archive_path" "$extracted_dir" ;; | |
| *) | |
| echo "Unsupported archive format: $archive_path" | |
| exit 1 | |
| ;; | |
| esac | |
| app_path="$(find "$extracted_dir" -type d -name '*.app' | head -n 1 || true)" | |
| fi | |
| if [ -z "$app_path" ]; then | |
| echo "Simulator .app bundle not found after extraction." | |
| find "$extracted_dir" -maxdepth 4 -print || true | |
| exit 1 | |
| fi | |
| echo "Using app bundle: $app_path" | |
| { | |
| echo "app_path<<EOF" | |
| echo "$app_path" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Boot simulator and capture screenshots | |
| id: capture | |
| env: | |
| DEVICE_LABEL: ${{ github.event.inputs.device }} | |
| STARTUP_WAIT_SECONDS: ${{ github.event.inputs.startup_wait_seconds }} | |
| PER_PAGE_WAIT_SECONDS: ${{ github.event.inputs.per_page_wait_seconds }} | |
| APP_PATH: ${{ steps.resolve_app.outputs.app_path }} | |
| run: | | |
| set -euo pipefail | |
| DEVICE_NAME="$(printf '%s' "$DEVICE_LABEL" | sed -E 's/[[:space:]]+\([^()]+\)$//')" | |
| if [[ ! "$STARTUP_WAIT_SECONDS" =~ ^[0-9]+$ ]]; then | |
| echo "startup_wait_seconds must be an integer." | |
| exit 1 | |
| fi | |
| if [[ ! "$PER_PAGE_WAIT_SECONDS" =~ ^[0-9]+$ ]]; then | |
| echo "per_page_wait_seconds must be an integer." | |
| exit 1 | |
| fi | |
| latest_ios_runtime_id() { | |
| xcrun simctl list runtimes -j | node -e ' | |
| const fs = require("fs"); | |
| const raw = fs.readFileSync(0, "utf8"); | |
| const data = JSON.parse(raw); | |
| const runtimes = Array.isArray(data.runtimes) ? data.runtimes : []; | |
| const ios = runtimes | |
| .filter((runtime) => { | |
| if (runtime && runtime.isAvailable === false) return false; | |
| const platform = String(runtime?.platform || ""); | |
| const name = String(runtime?.name || ""); | |
| return platform === "iOS" || /^iOS /.test(name); | |
| }) | |
| .map((runtime) => { | |
| const rawVersion = String(runtime?.version || "") | |
| .replace(/[^0-9.]/g, "") | |
| .split(".") | |
| .filter(Boolean) | |
| .map((part) => Number(part)); | |
| return { | |
| identifier: String(runtime?.identifier || ""), | |
| versionParts: [ | |
| Number.isFinite(rawVersion[0]) ? rawVersion[0] : 0, | |
| Number.isFinite(rawVersion[1]) ? rawVersion[1] : 0, | |
| Number.isFinite(rawVersion[2]) ? rawVersion[2] : 0 | |
| ] | |
| }; | |
| }) | |
| .filter((runtime) => runtime.identifier.length > 0) | |
| .sort((left, right) => { | |
| return ( | |
| right.versionParts[0] - left.versionParts[0] || | |
| right.versionParts[1] - left.versionParts[1] || | |
| right.versionParts[2] - left.versionParts[2] | |
| ); | |
| }); | |
| if (ios.length > 0) { | |
| process.stdout.write(ios[0].identifier); | |
| } | |
| ' | |
| } | |
| device_type_id_for_name() { | |
| local requested_name="$1" | |
| xcrun simctl list devicetypes -j | node -e ' | |
| const fs = require("fs"); | |
| const requested = process.argv[1]; | |
| const raw = fs.readFileSync(0, "utf8"); | |
| const data = JSON.parse(raw); | |
| const deviceTypes = Array.isArray(data.devicetypes) ? data.devicetypes : []; | |
| const match = deviceTypes.find((entry) => String(entry?.name || "") === requested); | |
| if (match?.identifier) { | |
| process.stdout.write(String(match.identifier)); | |
| } | |
| ' "$requested_name" | |
| } | |
| ensure_requested_simulator() { | |
| local requested_label="$1" | |
| local requested_name="$2" | |
| [ "$requested_name" = "auto" ] && return 0 | |
| local current_devices | |
| current_devices="$(xcrun simctl list devices available)" | |
| local requested_line | |
| requested_line="$(printf '%s\n' "$current_devices" | grep -F " $requested_name (" | head -n 1 || true)" | |
| [ -n "$requested_line" ] && return 0 | |
| echo "Requested simulator '$requested_label' is not currently available. Attempting to provision it." | |
| local device_type_id | |
| device_type_id="$(device_type_id_for_name "$requested_name")" | |
| if [ -z "$device_type_id" ]; then | |
| echo "Device type '$requested_name' is not available in the installed Xcode toolchain." | |
| return 1 | |
| fi | |
| local runtime_id | |
| runtime_id="$(latest_ios_runtime_id)" | |
| if [ -z "$runtime_id" ]; then | |
| echo "No iOS simulator runtime installed. Downloading iOS platform components via xcodebuild..." | |
| xcodebuild -downloadPlatform iOS | |
| runtime_id="$(latest_ios_runtime_id)" | |
| fi | |
| if [ -z "$runtime_id" ]; then | |
| echo "Unable to resolve an iOS simulator runtime after download attempt." | |
| return 1 | |
| fi | |
| echo "Creating simulator '$requested_name' with runtime '$runtime_id'." | |
| local created_udid | |
| created_udid="$(xcrun simctl create "$requested_name" "$device_type_id" "$runtime_id" || true)" | |
| if [ -z "$created_udid" ]; then | |
| echo "Failed to create simulator '$requested_name' using runtime '$runtime_id'." | |
| return 1 | |
| fi | |
| echo "Created simulator '$requested_name' ($created_udid)." | |
| return 0 | |
| } | |
| if ! ensure_requested_simulator "$DEVICE_LABEL" "$DEVICE_NAME"; then | |
| echo "Could not provision requested simulator '$DEVICE_LABEL'." | |
| echo "Available iOS phone/tablet simulators:" | |
| xcrun simctl list devices available | grep -E '^[[:space:]]+(iPhone|iPad) .*\([0-9A-F-]+\) \((Shutdown|Booted)\)' || true | |
| exit 1 | |
| fi | |
| available_devices="$(xcrun simctl list devices available)" | |
| device_line="" | |
| udid="" | |
| if [ "$DEVICE_NAME" != "auto" ]; then | |
| device_line="$(printf '%s\n' "$available_devices" | grep -F " $DEVICE_NAME (" | head -n 1 || true)" | |
| udid="$(printf '%s' "$device_line" | sed -E 's/.*\(([0-9A-F-]+)\).*/\1/' || true)" | |
| fi | |
| if [ -z "$udid" ]; then | |
| if [ "$DEVICE_LABEL" != "auto" ]; then | |
| echo "Requested simulator '$DEVICE_LABEL' is unavailable after provisioning." | |
| echo "Available iOS phone/tablet simulators:" | |
| printf '%s\n' "$available_devices" | grep -E '^[[:space:]]+(iPhone|iPad) .*\([0-9A-F-]+\) \((Shutdown|Booted)\)' || true | |
| exit 1 | |
| fi | |
| device_line="$(printf '%s\n' "$available_devices" | grep -E '^[[:space:]]+(iPhone|iPad) .*\([0-9A-F-]+\) \((Shutdown|Booted)\)' | head -n 1 || true)" | |
| udid="$(printf '%s' "$device_line" | sed -E 's/.*\(([0-9A-F-]+)\).*/\1/' || true)" | |
| fi | |
| if [ -z "$udid" ]; then | |
| echo "No available iOS phone/tablet simulator found." | |
| echo "Available devices:" | |
| printf '%s\n' "$available_devices" | |
| exit 1 | |
| fi | |
| resolved_name="$(printf '%s' "$device_line" | sed -E 's/^[[:space:]]*([^()]+) \([0-9A-F-]+\).*/\1/' | sed -E 's/[[:space:]]+$//')" | |
| echo "Using simulator: $resolved_name ($udid)" | |
| { | |
| echo "udid=$udid" | |
| } >> "$GITHUB_OUTPUT" | |
| shots_dir="$GITHUB_WORKSPACE/artifacts/screenshots" | |
| mkdir -p "$shots_dir" | |
| printf "index,url,file\n" > "$shots_dir/manifest.csv" | |
| xcrun simctl boot "$udid" || true | |
| xcrun simctl bootstatus "$udid" -b | |
| # Pre-approve custom URL scheme to avoid "Open in \"Eclipse Timer\"?" prompts. | |
| # Preferred path: write approval inside simulated device user defaults. | |
| # Fallback path: write directly to the simulator schemeapproval plist. | |
| approve_scheme() { | |
| local source_app="$1" | |
| local scheme_key="$source_app-->eclipsetimer" | |
| local reverse_key="$source_app-->$BUNDLE_ID" | |
| local plist="$HOME/Library/Developer/CoreSimulator/Devices/$udid/data/Library/Preferences/com.apple.launchservices.schemeapproval.plist" | |
| xcrun simctl spawn "$udid" defaults write com.apple.launchservices.schemeapproval "$scheme_key" -string "$BUNDLE_ID" || true | |
| xcrun simctl spawn "$udid" defaults write com.apple.launchservices.schemeapproval "$reverse_key" -string "eclipsetimer://" || true | |
| /usr/libexec/PlistBuddy -c "Add :$scheme_key string $BUNDLE_ID" "$plist" >/dev/null 2>&1 || \ | |
| /usr/libexec/PlistBuddy -c "Set :$scheme_key $BUNDLE_ID" "$plist" >/dev/null 2>&1 || true | |
| /usr/libexec/PlistBuddy -c "Add :$reverse_key string eclipsetimer://" "$plist" >/dev/null 2>&1 || \ | |
| /usr/libexec/PlistBuddy -c "Set :$reverse_key eclipsetimer://" "$plist" >/dev/null 2>&1 || true | |
| } | |
| approve_scheme "com.apple.CoreSimulator.CoreSimulatorBridge" | |
| approve_scheme "com.apple.mobilesafari" | |
| xcrun simctl uninstall "$udid" "$BUNDLE_ID" || true | |
| xcrun simctl install "$udid" "$APP_PATH" | |
| ensure_notification_permission() { | |
| local permission_state | |
| permission_state="$(xcrun simctl privacy "$udid" get notifications "$BUNDLE_ID" 2>/dev/null || true)" | |
| if [[ "$permission_state" == *"granted"* ]]; then | |
| return 0 | |
| fi | |
| xcrun simctl privacy "$udid" grant notifications "$BUNDLE_ID" || true | |
| } | |
| # Avoid notification permission popups appearing in captured screenshots. | |
| ensure_notification_permission | |
| xcrun simctl launch "$udid" "$BUNDLE_ID" | |
| # Some iOS runtimes only persist the permission after first launch. | |
| ensure_notification_permission | |
| sleep "$STARTUP_WAIT_SECONDS" | |
| xcrun simctl io "$udid" screenshot "$shots_dir/00-launch.png" | |
| printf "00,launch,00-launch.png\n" >> "$shots_dir/manifest.csv" | |
| DEEP_LINKS_INPUT="$(cat <<'EOF' | |
| ${{ github.event.inputs.deep_links }} | |
| EOF | |
| )" | |
| deep_links="$( | |
| printf '%s\n' "$DEEP_LINKS_INPUT" \ | |
| | tr -d '\r' \ | |
| | tr ',' '\n' \ | |
| | sed -E 's#([A-Za-z][A-Za-z0-9+.-]*://)#\n\1#g' \ | |
| | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' \ | |
| | sed '/^[[:space:]]*$/d' | |
| )" | |
| if [ -n "$deep_links" ]; then | |
| index=1 | |
| while IFS= read -r url; do | |
| [ -z "$url" ] && continue | |
| slug="$(printf '%s' "$url" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://##; s#[^A-Za-z0-9._-]+#-#g; s#-+#-#g; s#^-##; s#-$##')" | |
| if [ -z "$slug" ]; then | |
| slug="screen-$index" | |
| fi | |
| filename="$(printf '%02d' "$index")-$slug.png" | |
| if xcrun simctl openurl "$udid" "$url"; then | |
| sleep "$PER_PAGE_WAIT_SECONDS" | |
| xcrun simctl io "$udid" screenshot "$shots_dir/$filename" | |
| printf "%02d,%s,%s\n" "$index" "$url" "$filename" >> "$shots_dir/manifest.csv" | |
| else | |
| echo "::warning::Failed to open deep link: $url" | |
| fi | |
| index=$((index + 1)) | |
| done <<< "$deep_links" | |
| fi | |
| - name: Shutdown simulator | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| udid="${{ steps.capture.outputs.udid }}" | |
| if [ -n "$udid" ]; then | |
| xcrun simctl shutdown "$udid" || true | |
| fi | |
| - name: Package screenshots archive | |
| id: package_archive | |
| run: | | |
| set -euo pipefail | |
| screenshots_dir="$GITHUB_WORKSPACE/artifacts/screenshots" | |
| release_dir="$GITHUB_WORKSPACE/artifacts/release" | |
| archive_name="ios-simulator-screenshots-${GITHUB_RUN_ID}.zip" | |
| archive_path="$release_dir/$archive_name" | |
| if [ ! -d "$screenshots_dir" ]; then | |
| echo "Screenshots directory not found: $screenshots_dir" | |
| exit 1 | |
| fi | |
| mkdir -p "$release_dir" | |
| ditto -c -k --sequesterRsrc --keepParent "$screenshots_dir" "$archive_path" | |
| { | |
| echo "archive_name=$archive_name" | |
| echo "archive_path=$archive_path" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Upload screenshots artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ios-simulator-screenshots-${{ github.run_id }} | |
| path: ${{ steps.package_archive.outputs.archive_path }} | |
| if-no-files-found: error | |
| - name: Resolve latest GitHub release | |
| id: latest_release | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| try { | |
| const { data: release } = await github.rest.repos.getLatestRelease({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| core.info(`Using latest release: ${release.tag_name} (id: ${release.id})`); | |
| core.setOutput("release_id", String(release.id)); | |
| core.setOutput("tag_name", release.tag_name); | |
| } catch (error) { | |
| core.setFailed(`Unable to resolve latest release: ${error.message}`); | |
| } | |
| - name: Attach screenshots to latest release | |
| uses: actions/github-script@v7 | |
| env: | |
| RELEASE_ID: ${{ steps.latest_release.outputs.release_id }} | |
| RELEASE_TAG_NAME: ${{ steps.latest_release.outputs.tag_name }} | |
| SCREENSHOTS_ARCHIVE_NAME: ${{ steps.package_archive.outputs.archive_name }} | |
| SCREENSHOTS_ARCHIVE_PATH: ${{ steps.package_archive.outputs.archive_path }} | |
| with: | |
| script: | | |
| const fs = require("fs"); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const releaseId = Number(process.env.RELEASE_ID); | |
| const releaseTag = process.env.RELEASE_TAG_NAME || "unknown"; | |
| const archiveName = process.env.SCREENSHOTS_ARCHIVE_NAME || ""; | |
| const archivePath = process.env.SCREENSHOTS_ARCHIVE_PATH || ""; | |
| if (!Number.isFinite(releaseId) || releaseId <= 0) { | |
| core.setFailed(`Invalid release id: ${process.env.RELEASE_ID}`); | |
| return; | |
| } | |
| if (!archiveName || !archivePath) { | |
| core.setFailed("Screenshot archive metadata is missing."); | |
| return; | |
| } | |
| if (!fs.existsSync(archivePath)) { | |
| core.setFailed(`Screenshots archive not found: ${archivePath}`); | |
| return; | |
| } | |
| const existingAssets = await github.paginate(github.rest.repos.listReleaseAssets, { | |
| owner, | |
| repo, | |
| release_id: releaseId, | |
| per_page: 100, | |
| }); | |
| const existingAssetsByName = new Map(existingAssets.map((asset) => [asset.name, asset])); | |
| const previous = existingAssetsByName.get(archiveName); | |
| if (previous) { | |
| await github.rest.repos.deleteReleaseAsset({ | |
| owner, | |
| repo, | |
| asset_id: previous.id, | |
| }); | |
| core.info(`Deleted existing asset: ${archiveName}`); | |
| } | |
| const data = fs.readFileSync(archivePath); | |
| await github.rest.repos.uploadReleaseAsset({ | |
| owner, | |
| repo, | |
| release_id: releaseId, | |
| name: archiveName, | |
| data, | |
| }); | |
| core.info(`Uploaded ${archiveName} to release ${releaseTag}`); |