Skip to content

iOS Simulator Screenshots #24

iOS Simulator Screenshots

iOS Simulator Screenshots #24

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}`);