diff --git a/apps/ios/UITests/SnapshotHelper.swift b/apps/ios/UITests/SnapshotHelper.swift index 015cfeaa5532..6dec13020bed 100644 --- a/apps/ios/UITests/SnapshotHelper.swift +++ b/apps/ios/UITests/SnapshotHelper.swift @@ -15,13 +15,12 @@ import Foundation import XCTest -var deviceLanguage = "" -var locale = "" - +@MainActor func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations) } +@MainActor func snapshot(_ name: String, waitForLoadingIndicator: Bool) { if waitForLoadingIndicator { Snapshot.snapshot(name) @@ -33,6 +32,7 @@ func snapshot(_ name: String, waitForLoadingIndicator: Bool) { /// - Parameters: /// - name: The name of the snapshot /// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait. +@MainActor func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { Snapshot.snapshot(name, timeWaitingForIdle: timeout) } @@ -52,6 +52,7 @@ enum SnapshotError: Error, CustomDebugStringConvertible { } @objcMembers +@MainActor open class Snapshot: NSObject { static var app: XCUIApplication? static var waitForAnimations = true @@ -59,6 +60,8 @@ open class Snapshot: NSObject { static var screenshotsDirectory: URL? { return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true) } + static var deviceLanguage = "" + static var currentLocale = "" open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { @@ -103,17 +106,17 @@ open class Snapshot: NSObject { do { let trimCharacterSet = CharacterSet.whitespacesAndNewlines - locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) } catch { NSLog("Couldn't detect/set locale...") } - if locale.isEmpty && !deviceLanguage.isEmpty { - locale = Locale(identifier: deviceLanguage).identifier + if currentLocale.isEmpty && !deviceLanguage.isEmpty { + currentLocale = Locale(identifier: deviceLanguage).identifier } - if !locale.isEmpty { - app.launchArguments += ["-AppleLocale", "\"\(locale)\""] + if !currentLocale.isEmpty { + app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""] } } @@ -165,7 +168,7 @@ open class Snapshot: NSObject { } let screenshot = XCUIScreen.main.screenshot() - #if os(iOS) + #if os(iOS) && !targetEnvironment(macCatalyst) let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image #else let image = screenshot.image @@ -181,7 +184,7 @@ open class Snapshot: NSObject { let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png") #if swift(<5.0) - UIImagePNGRepresentation(image)?.write(to: path, options: .atomic) + try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic) #else try image.pngData()?.write(to: path, options: .atomic) #endif @@ -281,6 +284,7 @@ private extension XCUIElementQuery { return self.containing(isNetworkLoadingIndicator) } + @MainActor var deviceStatusBars: XCUIElementQuery { guard let app = Snapshot.app else { fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") @@ -306,4 +310,4 @@ private extension CGFloat { // Please don't remove the lines below // They are used to detect outdated configuration files -// SnapshotHelperVersion [1.27] +// SnapshotHelperVersion [1.30] diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index 37acb4e40648..c4cb48e73a84 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -10,7 +10,24 @@ default_platform(:ios) APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app" DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE = "openclaw-app-store-connect-key" -DEFAULT_SNAPSHOT_DEVICES = ["iPhone 16 Pro Max", "iPad Pro 13-inch (M4)"].freeze +DEFAULT_SNAPSHOT_DEVICE_FAMILIES = [ + { + label: "iPhone", + patterns: [ + /\AiPhone .* Pro Max\z/, + /\AiPhone .* Plus\z/, + /\AiPhone .*\z/ + ] + }, + { + label: "13-inch iPad", + patterns: [ + /\AiPad Pro 13-inch/, + /\AiPad Air 13-inch/, + /\AiPad .*13-inch/ + ] + } +].freeze DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)" WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode" WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41" @@ -77,11 +94,23 @@ end def snapshot_devices raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip - return DEFAULT_SNAPSHOT_DEVICES if raw.empty? + return default_snapshot_devices if raw.empty? raw.split(",").map(&:strip).reject(&:empty?) end +def default_snapshot_devices + names = available_simulator_devices.map { |device| device["name"].to_s }.reject(&:empty?).uniq + + DEFAULT_SNAPSHOT_DEVICE_FAMILIES.map do |family| + match = family.fetch(:patterns).filter_map do |pattern| + names.find { |name| name.match?(pattern) } + end.first + UI.user_error!("No available #{family.fetch(:label)} simulator found for App Store screenshots.") if match.nil? + match + end +end + def watch_snapshot_device raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw @@ -113,6 +142,51 @@ def resolve_simulator_device(name) fallback end +def install_ready_for_review_edit_state_lookup! + require "spaceship" + + app_class = Spaceship::ConnectAPI::App + app_class.class_eval do + unless method_defined?(:openclaw_get_edit_app_store_version_without_ready_for_review) + alias_method :openclaw_get_edit_app_store_version_without_ready_for_review, :get_edit_app_store_version + end + + unless method_defined?(:openclaw_fetch_edit_app_info_without_ready_for_review) + alias_method :openclaw_fetch_edit_app_info_without_ready_for_review, :fetch_edit_app_info + end + + def get_edit_app_store_version(client: nil, platform: nil, includes: Spaceship::ConnectAPI::AppStoreVersion::ESSENTIAL_INCLUDES) + version = openclaw_get_edit_app_store_version_without_ready_for_review(client: client, platform: platform, includes: includes) + return version if version + + # First public releases can leave the only version in READY_FOR_REVIEW. + # Fastlane 2.236.1 excludes that state and then tries to create an illegal + # second version; use the existing review-ready version as the edit target. + client ||= Spaceship::ConnectAPI + platform ||= Spaceship::ConnectAPI::Platform::IOS + filter = { + appVersionState: Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_REVIEW, + platform: platform + } + + get_app_store_versions(client: client, filter: filter, includes: includes) + .sort_by { |candidate| Gem::Version.new(candidate.version_string) } + .last + end + + def fetch_edit_app_info(client: nil, includes: Spaceship::ConnectAPI::AppInfo::ESSENTIAL_INCLUDES) + app_info = openclaw_fetch_edit_app_info_without_ready_for_review(client: client, includes: includes) + return app_info if app_info + + client ||= Spaceship::ConnectAPI + client + .get_app_infos(app_id: id, includes: includes) + .to_models + .find { |candidate| candidate.state == Spaceship::ConnectAPI::AppInfo::State::READY_FOR_REVIEW } + end + end +end + def bundle_identifier_for_product(product_path) info_plist_path = File.join(product_path, "Info.plist") UI.user_error!("Expected Info.plist at #{info_plist_path}.") unless File.exist?(info_plist_path) @@ -961,6 +1035,7 @@ platform :ios do desc "Upload App Store metadata (and optionally screenshots)" lane :metadata do + install_ready_for_review_edit_state_lookup! sync_ios_versioning! version_metadata = read_ios_version_metadata api_key = app_store_connect_api_key_config diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md index 30abf9daddbe..c94b815c92ba 100644 --- a/apps/ios/fastlane/SETUP.md +++ b/apps/ios/fastlane/SETUP.md @@ -104,7 +104,7 @@ Generate deterministic App Store screenshots: pnpm ios:screenshots ``` -The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it captures the tab set on `iPhone 16 Pro Max` and `iPad Pro 13-inch (M4)`; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally. +The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it chooses one available large iPhone simulator and one available 13-inch iPad simulator from the installed Xcode runtime; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally. Upload to App Store Connect: diff --git a/apps/ios/fastlane/Snapfile b/apps/ios/fastlane/Snapfile index 3cb6236c9762..a748161bbffb 100644 --- a/apps/ios/fastlane/Snapfile +++ b/apps/ios/fastlane/Snapfile @@ -2,10 +2,9 @@ project("OpenClaw.xcodeproj") scheme("OpenClawUITests") configuration("Debug") -devices([ - "iPhone 16 Pro Max", - "iPad Pro 13-inch (M4)", -]) +# The Fastfile screenshot lane resolves concrete device names from the installed +# Xcode simulators. Fastlane validates Snapfile devices before lane overrides, so +# this file intentionally does not hardcode simulator model names. languages([ "en-US",