mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 06:29:34 +00:00
1026 lines
33 KiB
Ruby
1026 lines
33 KiB
Ruby
require "shellwords"
|
|
require "open3"
|
|
require "json"
|
|
require "fileutils"
|
|
require "tmpdir"
|
|
require "tempfile"
|
|
require "cgi"
|
|
|
|
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_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
|
|
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
|
|
WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41"
|
|
SNAPSHOT_STATUS_BAR_ARGUMENTS = "--time 09:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100".freeze
|
|
REQUIRED_SCREENSHOT_FAMILIES = {
|
|
"iPhone" => /iPhone/,
|
|
"13-inch iPad" => /iPad (Air|Pro) 13-inch/
|
|
}.freeze
|
|
PUBLIC_METADATA_FILENAMES = [
|
|
"description.txt",
|
|
"keywords.txt",
|
|
"marketing_url.txt",
|
|
"name.txt",
|
|
"privacy_url.txt",
|
|
"promotional_text.txt",
|
|
"release_notes.txt",
|
|
"subtitle.txt",
|
|
"support_url.txt"
|
|
].freeze
|
|
|
|
def load_env_file(path)
|
|
return unless File.exist?(path)
|
|
|
|
File.foreach(path) do |line|
|
|
stripped = line.strip
|
|
next if stripped.empty? || stripped.start_with?("#")
|
|
|
|
key, value = stripped.split("=", 2)
|
|
next if key.nil? || key.empty? || value.nil?
|
|
|
|
ENV[key] = value if ENV[key].nil? || ENV[key].strip.empty?
|
|
end
|
|
end
|
|
|
|
def env_present?(value)
|
|
!value.nil? && !value.strip.empty?
|
|
end
|
|
|
|
def clear_empty_env_var(key)
|
|
return unless ENV.key?(key)
|
|
ENV.delete(key) unless env_present?(ENV[key])
|
|
end
|
|
|
|
def screenshot_upload_requested?
|
|
ENV["DELIVER_SCREENSHOTS"] == "1"
|
|
end
|
|
|
|
def release_notes_upload_requested?
|
|
ENV["DELIVER_RELEASE_NOTES"] == "1"
|
|
end
|
|
|
|
def screenshot_paths
|
|
Dir[File.join(__dir__, "screenshots", "**", "*.png")]
|
|
end
|
|
|
|
def validate_required_screenshots!(paths)
|
|
missing_families = REQUIRED_SCREENSHOT_FAMILIES.filter_map do |name, pattern|
|
|
name unless paths.any? { |path| File.basename(path).match?(pattern) }
|
|
end
|
|
return if missing_families.empty?
|
|
|
|
UI.user_error!("DELIVER_SCREENSHOTS=1 but screenshots are missing for: #{missing_families.join(', ')}.")
|
|
end
|
|
|
|
def snapshot_devices
|
|
raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip
|
|
return DEFAULT_SNAPSHOT_DEVICES if raw.empty?
|
|
|
|
raw.split(",").map(&:strip).reject(&:empty?)
|
|
end
|
|
|
|
def watch_snapshot_device
|
|
raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip
|
|
raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw
|
|
end
|
|
|
|
def available_simulator_devices
|
|
stdout, stderr, status = Open3.capture3("xcrun", "simctl", "list", "devices", "available", "--json")
|
|
unless status.success?
|
|
detail = stderr.to_s.strip
|
|
detail = stdout.to_s.strip if detail.empty?
|
|
UI.user_error!("Failed to list simulator devices: #{detail}")
|
|
end
|
|
|
|
JSON.parse(stdout).fetch("devices").values.flatten
|
|
rescue JSON::ParserError => e
|
|
UI.user_error!("Invalid JSON from simctl device list: #{e.message}")
|
|
end
|
|
|
|
def resolve_simulator_device(name)
|
|
devices = available_simulator_devices
|
|
exact = devices.find { |device| device["name"] == name }
|
|
return exact if exact
|
|
|
|
watch_devices = devices.select { |device| device["name"].to_s.include?("Apple Watch") }
|
|
fallback = watch_devices.find { |device| device["name"].to_s.include?("Ultra") } || watch_devices.first
|
|
UI.user_error!("No available Apple Watch simulators found.") unless fallback
|
|
|
|
UI.important("Apple Watch simulator '#{name}' was not found; using '#{fallback.fetch("name")}'.")
|
|
fallback
|
|
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)
|
|
|
|
stdout, stderr, status = Open3.capture3(
|
|
"/usr/libexec/PlistBuddy",
|
|
"-c",
|
|
"Print:CFBundleIdentifier",
|
|
info_plist_path
|
|
)
|
|
unless status.success?
|
|
detail = stderr.to_s.strip
|
|
detail = stdout.to_s.strip if detail.empty?
|
|
UI.user_error!("Failed to read bundle identifier from #{info_plist_path}: #{detail}")
|
|
end
|
|
|
|
bundle_identifier = stdout.to_s.strip
|
|
UI.user_error!("Missing bundle identifier in #{info_plist_path}.") if bundle_identifier.empty?
|
|
bundle_identifier
|
|
end
|
|
|
|
def write_watch_screenshot_mode_defaults(udid, bundle_identifiers)
|
|
bundle_identifiers.each do |bundle_identifier|
|
|
sh(
|
|
shell_join([
|
|
"xcrun",
|
|
"simctl",
|
|
"spawn",
|
|
udid,
|
|
"defaults",
|
|
"write",
|
|
bundle_identifier,
|
|
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY,
|
|
"-bool",
|
|
"YES",
|
|
])
|
|
)
|
|
end
|
|
end
|
|
|
|
def clear_watch_screenshot_mode_defaults(udid, bundle_identifiers)
|
|
bundle_identifiers.each do |bundle_identifier|
|
|
sh(
|
|
"#{shell_join([
|
|
"xcrun",
|
|
"simctl",
|
|
"spawn",
|
|
udid,
|
|
"defaults",
|
|
"delete",
|
|
bundle_identifier,
|
|
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY,
|
|
])} >/dev/null 2>&1 || true"
|
|
)
|
|
end
|
|
end
|
|
|
|
def status_bar_unsupported?(detail)
|
|
detail.include?("Status bar overrides not supported on this platform") ||
|
|
detail.include?("Operation not supported")
|
|
end
|
|
|
|
def set_watch_status_bar_override(udid)
|
|
stdout, stderr, status = Open3.capture3(
|
|
"xcrun",
|
|
"simctl",
|
|
"status_bar",
|
|
udid,
|
|
"override",
|
|
"--time",
|
|
WATCH_SNAPSHOT_STATUS_BAR_TIME
|
|
)
|
|
return true if status.success?
|
|
|
|
detail = stderr.to_s.strip
|
|
detail = stdout.to_s.strip if detail.empty?
|
|
if status_bar_unsupported?(detail)
|
|
UI.important("watchOS simulator status bar overrides are not supported; Watch screenshot clock will use simulator time.")
|
|
return false
|
|
end
|
|
|
|
UI.user_error!("Failed to override Watch simulator status bar: #{detail}")
|
|
end
|
|
|
|
def clear_watch_status_bar_override(udid)
|
|
stdout, stderr, status = Open3.capture3("xcrun", "simctl", "status_bar", udid, "clear")
|
|
return if status.success?
|
|
|
|
detail = stderr.to_s.strip
|
|
detail = stdout.to_s.strip if detail.empty?
|
|
UI.user_error!("Failed to clear Watch simulator status bar override: #{detail}") unless status_bar_unsupported?(detail)
|
|
end
|
|
|
|
def normalize_watch_screenshot_status_bar(path)
|
|
script = <<~SWIFT
|
|
import AppKit
|
|
import Foundation
|
|
|
|
let path = CommandLine.arguments[1]
|
|
let timeText = CommandLine.arguments[2]
|
|
|
|
guard let source = NSImage(contentsOfFile: path),
|
|
let cgImage = source.cgImage(forProposedRect: nil, context: nil, hints: nil)
|
|
else {
|
|
fputs("Failed to load screenshot at \\(path)\\n", stderr)
|
|
exit(2)
|
|
}
|
|
|
|
let width = CGFloat(cgImage.width)
|
|
let height = CGFloat(cgImage.height)
|
|
guard let bitmap = NSBitmapImageRep(
|
|
bitmapDataPlanes: nil,
|
|
pixelsWide: Int(width),
|
|
pixelsHigh: Int(height),
|
|
bitsPerSample: 8,
|
|
samplesPerPixel: 4,
|
|
hasAlpha: true,
|
|
isPlanar: false,
|
|
colorSpaceName: .deviceRGB,
|
|
bytesPerRow: 0,
|
|
bitsPerPixel: 0),
|
|
let graphicsContext = NSGraphicsContext(bitmapImageRep: bitmap)
|
|
else {
|
|
fputs("Failed to create normalized screenshot bitmap at \\(path)\\n", stderr)
|
|
exit(3)
|
|
}
|
|
|
|
bitmap.size = NSSize(width: width, height: height)
|
|
NSGraphicsContext.saveGraphicsState()
|
|
NSGraphicsContext.current = graphicsContext
|
|
source.draw(
|
|
in: NSRect(x: 0, y: 0, width: width, height: height),
|
|
from: NSRect(x: 0, y: 0, width: width, height: height),
|
|
operation: .copy,
|
|
fraction: 1.0)
|
|
|
|
NSColor.black.setFill()
|
|
NSBezierPath(rect: NSRect(x: width - 146, y: height - 92, width: 124, height: 70)).fill()
|
|
|
|
let paragraphStyle = NSMutableParagraphStyle()
|
|
paragraphStyle.alignment = .right
|
|
let attributes: [NSAttributedString.Key: Any] = [
|
|
.font: NSFont.monospacedDigitSystemFont(ofSize: 34, weight: .semibold),
|
|
.foregroundColor: NSColor.white,
|
|
.paragraphStyle: paragraphStyle,
|
|
]
|
|
timeText.draw(
|
|
in: NSRect(x: width - 134, y: height - 82, width: 102, height: 44),
|
|
withAttributes: attributes)
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
|
|
guard let png = bitmap.representation(using: .png, properties: [:])
|
|
else {
|
|
fputs("Failed to encode normalized screenshot at \\(path)\\n", stderr)
|
|
exit(4)
|
|
}
|
|
|
|
try png.write(to: URL(fileURLWithPath: path))
|
|
SWIFT
|
|
|
|
Tempfile.create(["openclaw-watch-status-bar", ".swift"]) do |file|
|
|
file.write(script)
|
|
file.flush
|
|
sh(shell_join(["xcrun", "swift", file.path, path, "9:41"]))
|
|
end
|
|
end
|
|
|
|
def capture_watch_screenshot
|
|
device = resolve_simulator_device(watch_snapshot_device)
|
|
device_name = device.fetch("name")
|
|
udid = device.fetch("udid")
|
|
output_dir = File.join(ios_root, "fastlane", "screenshots", "en-US")
|
|
output_path = File.join(output_dir, "#{device_name}-01-now-face.png")
|
|
derived_data_path = File.join(ios_root, "build", "WatchScreenshotDerivedData")
|
|
app_path = File.join(derived_data_path, "Build", "Products", "Debug-watchsimulator", "OpenClawWatchApp.app")
|
|
|
|
FileUtils.mkdir_p(output_dir)
|
|
Dir[File.join(output_dir, "Apple Watch*-*.png")].each { |path| FileUtils.rm_f(path) }
|
|
FileUtils.rm_rf(derived_data_path)
|
|
|
|
sh(
|
|
xcodebuild_shell_join([
|
|
"xcodebuild",
|
|
"-project",
|
|
File.join(ios_root, "OpenClaw.xcodeproj"),
|
|
"-scheme",
|
|
"OpenClawWatchApp",
|
|
"-configuration",
|
|
"Debug",
|
|
"-destination",
|
|
"platform=watchOS Simulator,id=#{udid}",
|
|
"-derivedDataPath",
|
|
derived_data_path,
|
|
"build",
|
|
])
|
|
)
|
|
|
|
UI.user_error!("Watch screenshot build did not produce #{app_path}.") unless File.exist?(app_path)
|
|
extension_path = File.join(app_path, "PlugIns", "OpenClawWatchExtension.appex")
|
|
watch_app_identifier = bundle_identifier_for_product(app_path)
|
|
watch_extension_identifier = bundle_identifier_for_product(extension_path)
|
|
screenshot_mode_bundle_identifiers = [watch_app_identifier, watch_extension_identifier]
|
|
|
|
sh("#{shell_join(["xcrun", "simctl", "boot", udid])} >/dev/null 2>&1 || true")
|
|
sh(shell_join(["xcrun", "simctl", "bootstatus", udid, "-b"]))
|
|
sh("#{shell_join(["xcrun", "simctl", "uninstall", udid, watch_app_identifier])} >/dev/null 2>&1 || true")
|
|
status_bar_overridden = false
|
|
begin
|
|
sh(shell_join(["xcrun", "simctl", "install", udid, app_path]))
|
|
write_watch_screenshot_mode_defaults(udid, screenshot_mode_bundle_identifiers)
|
|
status_bar_overridden = set_watch_status_bar_override(udid)
|
|
sh(
|
|
"SIMCTL_CHILD_OPENCLAW_WATCH_SCREENSHOT_MODE=1 #{shell_join([
|
|
"xcrun",
|
|
"simctl",
|
|
"launch",
|
|
udid,
|
|
watch_app_identifier,
|
|
"--openclaw-watch-screenshot-mode",
|
|
])}"
|
|
)
|
|
sleep(3)
|
|
sh(shell_join(["xcrun", "simctl", "io", udid, "screenshot", output_path]))
|
|
normalize_watch_screenshot_status_bar(output_path)
|
|
ensure
|
|
clear_watch_status_bar_override(udid) if status_bar_overridden
|
|
clear_watch_screenshot_mode_defaults(udid, screenshot_mode_bundle_identifiers)
|
|
end
|
|
|
|
UI.success("Captured Apple Watch screenshot: #{output_path}")
|
|
output_path
|
|
end
|
|
|
|
def maybe_decode_hex_keychain_secret(value)
|
|
return value unless env_present?(value)
|
|
|
|
candidate = value.strip
|
|
return candidate unless candidate.match?(/\A[0-9a-fA-F]+\z/) && candidate.length.even?
|
|
|
|
begin
|
|
decoded = [candidate].pack("H*")
|
|
return candidate unless decoded.valid_encoding?
|
|
|
|
# `security find-generic-password -w` can return hex when the stored secret
|
|
# includes newlines/non-printable bytes (like PEM files).
|
|
beginPemMarker = %w[BEGIN PRIVATE KEY].join(" ") # pragma: allowlist secret
|
|
endPemMarker = %w[END PRIVATE KEY].join(" ")
|
|
if decoded.include?(beginPemMarker) || decoded.include?(endPemMarker)
|
|
UI.message("Decoded hex-encoded App Store Connect key content from Keychain.")
|
|
return decoded
|
|
end
|
|
rescue StandardError
|
|
return candidate
|
|
end
|
|
|
|
candidate
|
|
end
|
|
|
|
def read_app_store_connect_key_content_from_keychain
|
|
service = ENV["APP_STORE_CONNECT_KEYCHAIN_SERVICE"]
|
|
service = DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE unless env_present?(service)
|
|
|
|
account = ENV["APP_STORE_CONNECT_KEYCHAIN_ACCOUNT"]
|
|
account = ENV["USER"] unless env_present?(account)
|
|
account = ENV["LOGNAME"] unless env_present?(account)
|
|
return nil unless env_present?(account)
|
|
|
|
begin
|
|
stdout, _stderr, status = Open3.capture3(
|
|
"security",
|
|
"find-generic-password",
|
|
"-s",
|
|
service,
|
|
"-a",
|
|
account,
|
|
"-w"
|
|
)
|
|
|
|
return nil unless status.success?
|
|
|
|
key_content = stdout.to_s.strip
|
|
key_content = maybe_decode_hex_keychain_secret(key_content)
|
|
return nil unless env_present?(key_content)
|
|
|
|
UI.message("Loaded App Store Connect key content from Keychain service '#{service}' (account '#{account}').")
|
|
key_content
|
|
rescue Errno::ENOENT
|
|
nil
|
|
end
|
|
end
|
|
|
|
def repo_root
|
|
File.expand_path("../../..", __dir__)
|
|
end
|
|
|
|
def ios_root
|
|
File.expand_path("..", __dir__)
|
|
end
|
|
|
|
def preserve_file(path)
|
|
existed = File.exist?(path)
|
|
contents = existed ? File.binread(path) : nil
|
|
|
|
yield
|
|
ensure
|
|
if existed
|
|
File.binwrite(path, contents)
|
|
else
|
|
FileUtils.rm_f(path)
|
|
end
|
|
end
|
|
|
|
def preserve_local_signing
|
|
preserve_file(File.join(ios_root, ".local-signing.xcconfig")) do
|
|
yield
|
|
end
|
|
end
|
|
|
|
def app_store_signing_manifest
|
|
JSON.parse(File.read(File.join(ios_root, "Config", "AppStoreSigning.json")))
|
|
end
|
|
|
|
def app_store_signing_targets
|
|
app_store_signing_manifest.fetch("targets")
|
|
end
|
|
|
|
def app_store_bundle_identifiers
|
|
app_store_signing_targets.map { |target| target.fetch("bundleId") }
|
|
end
|
|
|
|
def app_store_provisioning_profiles
|
|
app_store_signing_targets.each_with_object({}) do |target, profiles|
|
|
profiles[target.fetch("bundleId")] = target.fetch("profileName")
|
|
end
|
|
end
|
|
|
|
def xml_string(value)
|
|
CGI.escapeHTML(value.to_s)
|
|
end
|
|
|
|
def write_app_store_export_options(path)
|
|
manifest = app_store_signing_manifest
|
|
profile_entries = app_store_provisioning_profiles.map do |bundle_id, profile_name|
|
|
" <key>#{xml_string(bundle_id)}</key>\n <string>#{xml_string(profile_name)}</string>"
|
|
end.join("\n")
|
|
|
|
File.write(path, <<~PLIST)
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>method</key>
|
|
<string>app-store-connect</string>
|
|
<key>signingStyle</key>
|
|
<string>manual</string>
|
|
<key>signingCertificate</key>
|
|
<string>Apple Distribution</string>
|
|
<key>teamID</key>
|
|
<string>#{xml_string(manifest.fetch("teamId"))}</string>
|
|
<key>provisioningProfiles</key>
|
|
<dict>
|
|
#{profile_entries}
|
|
</dict>
|
|
<key>destination</key>
|
|
<string>export</string>
|
|
<key>stripSwiftSymbols</key>
|
|
<true/>
|
|
<key>manageAppVersionAndBuildNumber</key>
|
|
<false/>
|
|
</dict>
|
|
</plist>
|
|
PLIST
|
|
end
|
|
|
|
def produce_services_for_target(target)
|
|
services = {}
|
|
if target.fetch("capabilities").include?("PUSH_NOTIFICATIONS")
|
|
services[:push_notification] = "on"
|
|
end
|
|
services
|
|
end
|
|
|
|
def ensure_release_bundle_ids!
|
|
manifest = app_store_signing_manifest
|
|
app_store_signing_targets.each do |target|
|
|
options = {
|
|
app_identifier: target.fetch("bundleId"),
|
|
app_name: target.fetch("displayName"),
|
|
skip_itc: true,
|
|
team_id: manifest.fetch("teamId")
|
|
}
|
|
services = produce_services_for_target(target)
|
|
options[:enable_services] = services unless services.empty?
|
|
produce(**options)
|
|
unless services.empty?
|
|
modify_services(
|
|
app_identifier: target.fetch("bundleId"),
|
|
services: services,
|
|
team_id: manifest.fetch("teamId")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
def app_store_match_options(readonly:, target:, api_key:)
|
|
manifest = app_store_signing_manifest
|
|
options = {
|
|
type: manifest.fetch("profileType"),
|
|
app_identifier: target.fetch("bundleId"),
|
|
profile_name: target.fetch("profileName"),
|
|
git_url: manifest.fetch("signingRepo"),
|
|
git_branch: manifest.fetch("signingBranch"),
|
|
platform: "ios",
|
|
team_id: manifest.fetch("teamId"),
|
|
readonly: readonly
|
|
}
|
|
options[:api_key] = api_key if api_key
|
|
options
|
|
end
|
|
|
|
def validate_match_profile_mapping!(target)
|
|
bundle_id = target.fetch("bundleId")
|
|
expected_profile_name = target.fetch("profileName")
|
|
actual = lane_context[SharedValues::MATCH_PROVISIONING_PROFILE_MAPPING] || {}
|
|
actual_profile_name = actual[bundle_id]
|
|
return if actual_profile_name == expected_profile_name
|
|
|
|
UI.user_error!(
|
|
"Fastlane match did not resolve the pinned App Store profile for #{bundle_id}: expected #{expected_profile_name}, got #{actual_profile_name || "no match output"}"
|
|
)
|
|
end
|
|
|
|
def match_profile_env_key(target, suffix)
|
|
["sigh", target.fetch("bundleId"), app_store_signing_manifest.fetch("profileType"), suffix].join("_")
|
|
end
|
|
|
|
def profile_plist_value(profile_path, key_path)
|
|
Tempfile.create(["openclaw-profile", ".plist"]) do |file|
|
|
stdout, stderr, status = Open3.capture3("security", "cms", "-D", "-i", profile_path)
|
|
unless status.success?
|
|
detail = stderr.to_s.strip
|
|
detail = stdout.to_s.strip if detail.empty?
|
|
UI.user_error!("Failed to decode provisioning profile #{profile_path}: #{detail}")
|
|
end
|
|
|
|
file.write(stdout)
|
|
file.flush
|
|
value, _plist_stderr, plist_status = Open3.capture3("/usr/libexec/PlistBuddy", "-c", "Print:#{key_path}", file.path)
|
|
return nil unless plist_status.success?
|
|
|
|
value.to_s.strip
|
|
end
|
|
end
|
|
|
|
def validate_match_profile_capabilities!(target)
|
|
capabilities = target.fetch("capabilities")
|
|
return if capabilities.empty?
|
|
|
|
profile_path = ENV[match_profile_env_key(target, "profile-path")]
|
|
UI.user_error!("Fastlane match did not expose an installed profile path for #{target.fetch("bundleId")}.") unless env_present?(profile_path)
|
|
|
|
if capabilities.include?("PUSH_NOTIFICATIONS")
|
|
aps_environment = profile_plist_value(profile_path, "Entitlements:aps-environment")
|
|
if aps_environment != "production"
|
|
UI.user_error!(
|
|
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing production push entitlement; expected aps-environment=production, got #{aps_environment || "missing"}."
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
def sync_app_store_signing!(readonly:)
|
|
api_key = readonly ? nil : app_store_connect_api_key_config
|
|
app_store_signing_targets.each do |target|
|
|
match(**app_store_match_options(readonly: readonly, target: target, api_key: api_key))
|
|
validate_match_profile_mapping!(target)
|
|
validate_match_profile_capabilities!(target)
|
|
end
|
|
end
|
|
|
|
def release_signing_check!
|
|
sync_app_store_signing!(readonly: true)
|
|
end
|
|
|
|
def release_notes_path
|
|
File.join(__dir__, "metadata", "en-US", "release_notes.txt")
|
|
end
|
|
|
|
def release_notes_metadata_path
|
|
source = release_notes_path
|
|
UI.user_error!("Missing release notes at #{source}. Run `pnpm ios:version:sync`.") unless File.exist?(source)
|
|
|
|
temp_root = Dir.mktmpdir("openclaw-release-notes")
|
|
target_dir = File.join(temp_root, "en-US")
|
|
FileUtils.mkdir_p(target_dir)
|
|
FileUtils.cp(source, File.join(target_dir, "release_notes.txt"))
|
|
temp_root
|
|
end
|
|
|
|
def public_metadata_path
|
|
source = File.join(__dir__, "metadata")
|
|
temp_root = Dir.mktmpdir("openclaw-app-store-metadata")
|
|
Dir.children(source).each do |entry|
|
|
source_entry = File.join(source, entry)
|
|
next unless File.directory?(source_entry)
|
|
next unless PUBLIC_METADATA_FILENAMES.any? { |filename| File.exist?(File.join(source_entry, filename)) }
|
|
|
|
FileUtils.cp_r(source_entry, File.join(temp_root, entry))
|
|
end
|
|
temp_root
|
|
end
|
|
|
|
def read_ios_version_metadata
|
|
script_path = File.join(repo_root, "scripts", "ios-version.ts")
|
|
stdout, stderr, status = Open3.capture3(
|
|
"node",
|
|
"--import",
|
|
"tsx",
|
|
script_path,
|
|
"--json",
|
|
chdir: repo_root
|
|
)
|
|
|
|
unless status.success?
|
|
detail = stderr.to_s.strip
|
|
detail = stdout.to_s.strip if detail.empty?
|
|
UI.user_error!("Failed to read iOS version metadata: #{detail}")
|
|
end
|
|
|
|
parsed = JSON.parse(stdout)
|
|
version = parsed["canonicalVersion"].to_s.strip
|
|
short_version = parsed["marketingVersion"].to_s.strip
|
|
if !env_present?(version) || !env_present?(short_version)
|
|
UI.user_error!("iOS version helper returned incomplete metadata.")
|
|
end
|
|
|
|
{
|
|
short_version: short_version,
|
|
version: version
|
|
}
|
|
rescue JSON::ParserError => e
|
|
UI.user_error!("Invalid JSON from iOS version helper: #{e.message}")
|
|
end
|
|
|
|
def sync_ios_versioning!
|
|
script_path = File.join(repo_root, "scripts", "ios-sync-versioning.ts")
|
|
stdout, stderr, status = Open3.capture3(
|
|
"node",
|
|
"--import",
|
|
"tsx",
|
|
script_path,
|
|
"--check",
|
|
chdir: repo_root
|
|
)
|
|
return if status.success?
|
|
|
|
detail = stderr.to_s.strip
|
|
detail = stdout.to_s.strip if detail.empty?
|
|
UI.user_error!("iOS versioning artifacts are stale. Run `pnpm ios:version:sync`.\n#{detail}")
|
|
end
|
|
|
|
def shell_join(parts)
|
|
Shellwords.join(parts.compact)
|
|
end
|
|
|
|
def xcodebuild_shell_join(parts)
|
|
xcode_path = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin"
|
|
shell_join(["env", "PATH=#{xcode_path}", *parts])
|
|
end
|
|
|
|
def resolve_release_build_number(api_key:, short_version:)
|
|
explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
|
|
if env_present?(explicit)
|
|
UI.user_error!("Invalid iOS release build number '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
|
|
UI.message("Using explicit iOS release build number #{explicit}.")
|
|
return explicit
|
|
end
|
|
|
|
latest_build = latest_testflight_build_number(
|
|
api_key: api_key,
|
|
app_identifier: APP_STORE_APP_IDENTIFIER,
|
|
version: short_version,
|
|
initial_build_number: 0
|
|
)
|
|
next_build = latest_build.to_i + 1
|
|
UI.message("Resolved iOS release build number #{next_build} for #{short_version} (latest App Store Connect build: #{latest_build}).")
|
|
next_build.to_s
|
|
end
|
|
|
|
def release_build_number_needs_app_store_connect_auth?
|
|
explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
|
|
!env_present?(explicit)
|
|
end
|
|
|
|
def prepare_app_store_release!(version:, build_number:)
|
|
script_path = File.join(repo_root, "scripts", "ios-release-prepare.sh")
|
|
UI.message("Preparing iOS App Store release #{version} (build #{build_number}).")
|
|
sh(shell_join(["bash", script_path, "--build-number", build_number]))
|
|
|
|
release_xcconfig = File.join(ios_root, "build", "AppStoreRelease.xcconfig")
|
|
UI.user_error!("Missing App Store release xcconfig at #{release_xcconfig}.") unless File.exist?(release_xcconfig)
|
|
|
|
ENV["XCODE_XCCONFIG_FILE"] = release_xcconfig
|
|
release_xcconfig
|
|
end
|
|
|
|
def build_app_store_release(context)
|
|
version = context[:version]
|
|
project_path = File.join(ios_root, "OpenClaw.xcodeproj")
|
|
output_directory = File.join(ios_root, "build", "app-store")
|
|
archive_path = File.join(output_directory, "OpenClaw-#{version}.xcarchive")
|
|
export_options_path = File.join(output_directory, "ExportOptions.plist")
|
|
output_name = "OpenClaw-#{version}.ipa"
|
|
expected_ipa_path = File.join(output_directory, output_name)
|
|
|
|
FileUtils.mkdir_p(output_directory)
|
|
FileUtils.rm_rf(archive_path)
|
|
Dir[File.join(output_directory, "*.ipa")].each { |path| FileUtils.rm_f(path) }
|
|
write_app_store_export_options(export_options_path)
|
|
|
|
sh(
|
|
xcodebuild_shell_join([
|
|
"xcodebuild",
|
|
"-project",
|
|
project_path,
|
|
"-scheme",
|
|
"OpenClaw",
|
|
"-configuration",
|
|
"Release",
|
|
"-destination",
|
|
"generic/platform=iOS",
|
|
"-archivePath",
|
|
archive_path,
|
|
"clean",
|
|
"archive",
|
|
])
|
|
)
|
|
|
|
sh(
|
|
xcodebuild_shell_join([
|
|
"xcodebuild",
|
|
"-exportArchive",
|
|
"-archivePath",
|
|
archive_path,
|
|
"-exportPath",
|
|
output_directory,
|
|
"-exportOptionsPlist",
|
|
export_options_path,
|
|
])
|
|
)
|
|
|
|
exported_ipas = Dir[File.join(output_directory, "*.ipa")]
|
|
UI.user_error!("xcodebuild export did not produce an IPA in #{output_directory}.") if exported_ipas.empty?
|
|
UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1
|
|
exported_ipa = exported_ipas.first
|
|
FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path
|
|
|
|
{
|
|
archive_path: archive_path,
|
|
build_number: context[:build_number],
|
|
ipa_path: expected_ipa_path,
|
|
short_version: context[:short_version],
|
|
version: version
|
|
}
|
|
end
|
|
|
|
def app_store_connect_api_key_config
|
|
load_env_file(File.join(__dir__, ".env"))
|
|
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
|
|
clear_empty_env_var("APP_STORE_CONNECT_KEY_PATH")
|
|
clear_empty_env_var("APP_STORE_CONNECT_KEY_CONTENT")
|
|
|
|
api_key = nil
|
|
|
|
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
|
if env_present?(key_path)
|
|
api_key = app_store_connect_api_key(path: key_path)
|
|
else
|
|
p8_path = ENV["APP_STORE_CONNECT_KEY_PATH"]
|
|
if env_present?(p8_path)
|
|
key_id = ENV["APP_STORE_CONNECT_KEY_ID"]
|
|
issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"]
|
|
UI.user_error!("Missing APP_STORE_CONNECT_KEY_ID or APP_STORE_CONNECT_ISSUER_ID for APP_STORE_CONNECT_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| !env_present?(v) }
|
|
|
|
api_key = app_store_connect_api_key(
|
|
key_id: key_id,
|
|
issuer_id: issuer_id,
|
|
key_filepath: p8_path
|
|
)
|
|
else
|
|
key_id = ENV["APP_STORE_CONNECT_KEY_ID"]
|
|
issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"]
|
|
key_content = ENV["APP_STORE_CONNECT_KEY_CONTENT"]
|
|
key_content = read_app_store_connect_key_content_from_keychain unless env_present?(key_content)
|
|
|
|
UI.user_error!(
|
|
"Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json), APP_STORE_CONNECT_KEY_PATH (p8), or APP_STORE_CONNECT_KEY_ID/APP_STORE_CONNECT_ISSUER_ID with APP_STORE_CONNECT_KEY_CONTENT (or Keychain via APP_STORE_CONNECT_KEYCHAIN_SERVICE/APP_STORE_CONNECT_KEYCHAIN_ACCOUNT)."
|
|
) if [key_id, issuer_id, key_content].any? { |v| !env_present?(v) }
|
|
|
|
is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true
|
|
|
|
api_key = app_store_connect_api_key(
|
|
key_id: key_id,
|
|
issuer_id: issuer_id,
|
|
key_content: key_content,
|
|
is_key_content_base64: is_base64
|
|
)
|
|
end
|
|
end
|
|
|
|
api_key
|
|
end
|
|
|
|
platform :ios do
|
|
private_lane :prepare_app_store_context do |options|
|
|
require_api_key = options[:require_api_key] == true
|
|
needs_api_key = require_api_key || release_build_number_needs_app_store_connect_auth?
|
|
api_key = needs_api_key ? app_store_connect_api_key_config : nil
|
|
sync_ios_versioning!
|
|
version_metadata = read_ios_version_metadata
|
|
version = version_metadata[:version]
|
|
short_version = version_metadata[:short_version]
|
|
build_number = resolve_release_build_number(api_key: api_key, short_version: short_version)
|
|
release_xcconfig = prepare_app_store_release!(version: version, build_number: build_number)
|
|
|
|
{
|
|
api_key: api_key,
|
|
build_number: build_number,
|
|
release_xcconfig: release_xcconfig,
|
|
short_version: short_version,
|
|
version: version
|
|
}
|
|
end
|
|
|
|
desc "Print the App Store signing plan"
|
|
lane :signing_plan do
|
|
sh(shell_join(["node", File.join(repo_root, "scripts", "ios-release-signing.mjs"), "--mode", "plan"]))
|
|
end
|
|
|
|
desc "Check local App Store signing assets through Fastlane match"
|
|
lane :signing_check do
|
|
sync_app_store_signing!(readonly: true)
|
|
UI.success("Fastlane match App Store signing assets are available locally.")
|
|
end
|
|
|
|
desc "Create Developer Portal bundle IDs/services and sync App Store signing assets"
|
|
lane :signing_setup do
|
|
ensure_release_bundle_ids!
|
|
sync_app_store_signing!(readonly: false)
|
|
UI.success("Fastlane App Store signing setup is complete.")
|
|
end
|
|
|
|
desc "Pull encrypted App Store signing assets from the shared Fastlane match repo"
|
|
lane :signing_sync_pull do
|
|
sync_app_store_signing!(readonly: true)
|
|
UI.success("Pulled Fastlane match App Store signing assets.")
|
|
end
|
|
|
|
desc "Create or refresh encrypted App Store signing assets in the shared Fastlane match repo"
|
|
lane :signing_sync_push do
|
|
ensure_release_bundle_ids!
|
|
sync_app_store_signing!(readonly: false)
|
|
UI.success("Pushed Fastlane match App Store signing assets.")
|
|
end
|
|
|
|
desc "Build an App Store distribution archive locally without uploading"
|
|
lane :app_store_archive do
|
|
context = prepare_app_store_context(require_api_key: false)
|
|
build = build_app_store_release(context)
|
|
UI.success("Built iOS App Store archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
|
build
|
|
ensure
|
|
ENV.delete("XCODE_XCCONFIG_FILE")
|
|
end
|
|
|
|
desc "Build + upload an App Store distribution build to App Store Connect"
|
|
lane :app_store do
|
|
context = prepare_app_store_context(require_api_key: true)
|
|
build = build_app_store_release(context)
|
|
|
|
upload_to_testflight(
|
|
api_key: context[:api_key],
|
|
ipa: build[:ipa_path],
|
|
skip_waiting_for_build_processing: true,
|
|
uses_non_exempt_encryption: false
|
|
)
|
|
|
|
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
|
ensure
|
|
ENV.delete("XCODE_XCCONFIG_FILE")
|
|
end
|
|
|
|
desc "Generate screenshots, update App Store version metadata, then upload an App Store build"
|
|
lane :release_upload do
|
|
release_signing_check!
|
|
preserve_local_signing do
|
|
screenshots
|
|
end
|
|
ENV["DELIVER_SCREENSHOTS"] = "1"
|
|
ENV["DELIVER_RELEASE_NOTES"] = "1"
|
|
metadata
|
|
|
|
context = prepare_app_store_context(require_api_key: true)
|
|
build = build_app_store_release(context)
|
|
|
|
upload_to_testflight(
|
|
api_key: context[:api_key],
|
|
ipa: build[:ipa_path],
|
|
skip_waiting_for_build_processing: true,
|
|
uses_non_exempt_encryption: false
|
|
)
|
|
|
|
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
|
UI.important("App Review submission remains manual in App Store Connect.")
|
|
ensure
|
|
ENV.delete("XCODE_XCCONFIG_FILE")
|
|
end
|
|
|
|
desc "Upload App Store metadata (and optionally screenshots)"
|
|
lane :metadata do
|
|
sync_ios_versioning!
|
|
version_metadata = read_ios_version_metadata
|
|
api_key = app_store_connect_api_key_config
|
|
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
|
|
app_identifier = ENV["APP_STORE_CONNECT_APP_IDENTIFIER"]
|
|
app_id = ENV["APP_STORE_CONNECT_APP_ID"]
|
|
app_identifier = nil unless env_present?(app_identifier)
|
|
app_id = nil unless env_present?(app_id)
|
|
|
|
if screenshot_upload_requested?
|
|
paths = screenshot_paths
|
|
if paths.empty?
|
|
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
|
|
end
|
|
validate_required_screenshots!(paths)
|
|
end
|
|
|
|
metadata_path = public_metadata_path
|
|
skip_metadata = ENV["DELIVER_METADATA"] != "1"
|
|
if release_notes_upload_requested? && skip_metadata
|
|
metadata_path = release_notes_metadata_path
|
|
skip_metadata = false
|
|
end
|
|
|
|
deliver_options = {
|
|
api_key: api_key,
|
|
force: true,
|
|
app_version: version_metadata[:short_version],
|
|
copyright: "2026 OpenClaw",
|
|
primary_category: "PRODUCTIVITY",
|
|
secondary_category: "UTILITIES",
|
|
metadata_path: metadata_path,
|
|
skip_screenshots: !screenshot_upload_requested?,
|
|
skip_metadata: skip_metadata,
|
|
skip_binary_upload: true,
|
|
overwrite_screenshots: screenshot_upload_requested?,
|
|
skip_app_version_update: false,
|
|
submit_for_review: false,
|
|
run_precheck_before_submit: false
|
|
}
|
|
deliver_options[:app_identifier] = app_identifier if app_identifier
|
|
if app_id && app_identifier.nil?
|
|
# `deliver` prefers app_identifier from Appfile unless explicitly blanked.
|
|
deliver_options[:app_identifier] = ""
|
|
deliver_options[:app] = app_id
|
|
end
|
|
|
|
deliver(**deliver_options)
|
|
end
|
|
|
|
desc "Generate deterministic iOS screenshots for App Store metadata"
|
|
lane :screenshots do
|
|
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
|
|
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
|
|
sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))
|
|
|
|
capture_ios_screenshots(
|
|
project: File.join(ios_root, "OpenClaw.xcodeproj"),
|
|
scheme: "OpenClawUITests",
|
|
configuration: "Debug",
|
|
devices: snapshot_devices,
|
|
languages: ["en-US"],
|
|
launch_arguments: ["--openclaw-screenshot-mode"],
|
|
output_directory: File.join(ios_root, "fastlane", "screenshots"),
|
|
clear_previous_screenshots: true,
|
|
reinstall_app: true,
|
|
concurrent_simulators: false,
|
|
override_status_bar: true,
|
|
override_status_bar_arguments: SNAPSHOT_STATUS_BAR_ARGUMENTS,
|
|
skip_open_summary: true,
|
|
xcargs: "-allowProvisioningUpdates"
|
|
)
|
|
|
|
watch_screenshot
|
|
end
|
|
|
|
desc "Generate deterministic Apple Watch screenshot for App Store metadata"
|
|
lane :watch_screenshot do
|
|
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
|
|
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
|
|
sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))
|
|
capture_watch_screenshot
|
|
end
|
|
|
|
desc "Validate App Store Connect API auth"
|
|
lane :auth_check do
|
|
app_store_connect_api_key_config
|
|
UI.success("App Store Connect API auth loaded successfully.")
|
|
end
|
|
end
|