Files
2026-07-13 13:12:00 +08:00

349 lines
11 KiB
Ruby

require "fileutils"
require "net/http"
require "socket"
require "tmpdir"
require "shellwords"
def omnigent_available_port
loop do
server = TCPServer.new("127.0.0.1", 0)
port = server.addr[1]
server.close
return port unless port == 6767
end
end
def omnigent_start_process(label, env, command, chdir:, log_path:)
FileUtils.mkdir_p(File.dirname(log_path))
UI.message("Starting #{label} on a dedicated screenshot port")
pid = Process.spawn(
env,
*command,
chdir: chdir,
out: [log_path, "w"],
err: [:child, :out],
pgroup: true
)
Process.detach(pid)
pid
end
def omnigent_run_command(label, command, chdir:)
UI.message(label)
UI.command(command.join(" "))
success = system(*command, chdir: chdir)
UI.user_error!("#{label} failed") unless success
end
def omnigent_fetch_env(*names)
names.each do |name|
value = ENV[name]
return value unless value.to_s.strip.empty?
end
UI.user_error!("Set one of: #{names.join(", ")}")
end
def omnigent_stop_process(pid)
return if pid.nil?
Process.kill("TERM", -pid)
deadline = Time.now + 10
while Time.now < deadline
begin
Process.kill(0, pid)
rescue Errno::ESRCH
return
end
sleep 0.2
end
Process.kill("KILL", -pid)
rescue Errno::ESRCH
nil
end
def omnigent_wait_for_http(url, timeout_seconds:, log_path:)
uri = URI(url)
deadline = Time.now + timeout_seconds
last_error = nil
while Time.now < deadline
begin
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) do |http|
http.get(uri.request_uri)
end
return if response.code.to_i < 500
rescue StandardError => e
last_error = e
end
sleep 1
end
if File.exist?(log_path)
UI.error("Last server log lines:\n#{File.readlines(log_path).last(80).join}")
end
UI.user_error!("Timed out waiting for #{url}: #{last_error}")
end
default_platform(:ios)
# Builds are signed with the team's distribution cert via Xcode automatic
# signing (-allowProvisioningUpdates). Upload + provisioning use an App Store
# Connect API key supplied through env vars — see fastlane/.env.example.
# Two distributions ship from the same target and sources. Each is a distinct
# app record (distinct bundle ID) with its own TestFlight build counter, so both
# can be installed side by side. The bundle ID and display name are injected as
# xcodebuild setting overrides at archive time — nothing in the repo changes.
# - public: the App Store app.
# - internal: a private (custom/unlisted App Store) build for Databricks.
FLAVORS = {
public: {
bundle_id: "ai.omnigent.ios",
display_name: "Omnigent",
},
internal: {
bundle_id: "ai.omnigent.ios.internal",
display_name: "Omnigent (Databricks)",
},
}.freeze
platform :ios do
desc "Run the OmnigentTests unit tests"
lane :tests do
run_tests(
scheme: "Omnigent",
configuration: "Debug",
destination: "platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.5",
skip_detect_devices: true,
skip_testing: ["OmnigentUITests"]
)
end
desc "Generate App Store screenshots with fastlane snapshot"
lane :screenshots do
repo_root = File.expand_path("../../..", __dir__)
web_dir = File.join(repo_root, "web")
static_index = File.join(repo_root, "omnigent/server/static/web-ui/index.html")
unless File.executable?(File.join(web_dir, "node_modules/.bin/tsc"))
omnigent_run_command(
"Installing web UI dependencies",
["npm", "--prefix", web_dir, "install", "--package-lock=false", "--no-audit", "--no-fund"],
chdir: repo_root
)
end
omnigent_run_command(
"Building current web UI for screenshots",
["npm", "--prefix", web_dir, "run", "build"],
chdir: repo_root
)
UI.user_error!("Web UI build did not produce #{static_index}") unless File.file?(static_index)
port = ENV.fetch("OMNIGENT_SCREENSHOT_SERVER_PORT", omnigent_available_port.to_s)
UI.user_error!("OMNIGENT_SCREENSHOT_SERVER_PORT must not be 6767") if port.to_s == "6767"
screenshot_root = File.join(Dir.tmpdir, "omnigent-ios-screenshots-#{port}")
data_dir = File.join(screenshot_root, "data")
home_dir = File.join(screenshot_root, "home")
log_dir = File.join(screenshot_root, "logs")
server_log = File.join(log_dir, "omnigent-server.log")
server_url = "http://127.0.0.1:#{port}"
agent_path = File.join(repo_root, "examples/kimi_hello.yaml")
snapshot_cache = File.expand_path("~/Library/Caches/tools.fastlane/screenshots")
FileUtils.mkdir_p([data_dir, home_dir, log_dir])
command = [
"uv",
"run",
"--frozen",
"--no-sync",
"--project",
repo_root,
"omnigent",
"server",
"--host",
"127.0.0.1",
"--port",
port.to_s,
"--database-uri",
"sqlite:///#{File.join(data_dir, "chat.db")}",
"--artifact-location",
File.join(data_dir, "artifacts"),
"--no-open",
]
command += ["--agent", agent_path] if File.file?(agent_path)
server_pid = nil
begin
server_pid = omnigent_start_process(
"Omnigent server",
{
"HOME" => home_dir,
"OMNIGENT_DATA_DIR" => data_dir,
"OMNIGENT_NO_UPDATE_CHECK" => "1",
},
command,
chdir: repo_root,
log_path: server_log
)
omnigent_wait_for_http("#{server_url}/", timeout_seconds: 60, log_path: server_log)
FileUtils.rm_rf(snapshot_cache)
ENV["OMNIGENT_SCREENSHOT_APP_URL"] = server_url
capture_ios_screenshots(
only_testing: ["OmnigentUITests/OmnigentUITests/testLocalServerSnapshot"],
launch_arguments: ["--omnigent-server-url #{server_url}"],
test_target_name: "Omnigent",
result_bundle: true,
number_of_retries: 0,
stop_after_first_error: true,
xcodebuild_formatter: ""
)
ensure
omnigent_stop_process(server_pid)
end
end
desc "Build the public app and upload it to TestFlight"
lane :beta do
beta_flavor(flavor: :public)
end
desc "Build the internal (Databricks) app and upload it to TestFlight"
lane :beta_internal do
beta_flavor(flavor: :internal)
end
desc "Build the public app and upload it to App Store Connect (no submission)"
lane :release do
release_flavor(flavor: :public)
end
desc "Build the internal (Databricks) app and upload it to App Store Connect (no submission)"
lane :release_internal do
release_flavor(flavor: :internal)
end
desc "Prepare the public App Store version using an already-uploaded build"
lane :prod do
prod_flavor(flavor: :public)
end
desc "Prepare the internal (Databricks) App Store version using an already-uploaded build"
lane :prod_internal do
prod_flavor(flavor: :internal)
end
# --- helpers ---
desc "Upload screenshots + metadata for a flavor against an already-uploaded build"
private_lane :prod_flavor do |options|
flavor = FLAVORS.fetch(options[:flavor])
load_asc_api_key
bundle_id = flavor[:bundle_id]
build_number = ENV["OMNIGENT_PROD_BUILD_NUMBER"] ||
latest_testflight_build_number(app_identifier: bundle_id, initial_build_number: 0).to_s
screenshots_path = File.join(Dir.tmpdir, "omnigent-ios-app-store-screenshots")
screenshots_locale_path = File.join(screenshots_path, "en-US")
screenshot_files = Dir[File.join(__dir__, "screenshots/en-US", "*.png")]
UI.user_error!("Generate screenshots first: bundle exec fastlane screenshots") if screenshot_files.empty?
FileUtils.rm_rf(screenshots_path)
FileUtils.mkdir_p(screenshots_locale_path)
FileUtils.cp(screenshot_files, screenshots_locale_path)
upload_to_app_store(
app_identifier: bundle_id,
app_version: ENV.fetch("OMNIGENT_PROD_APP_VERSION", "0.1.0"),
build_number: build_number,
skip_binary_upload: true,
skip_metadata: false,
screenshots_path: screenshots_path,
overwrite_screenshots: true,
submit_for_review: false,
automatic_release: false,
force: true,
precheck_include_in_app_purchases: false
)
end
desc "Archive a flavor and upload it to TestFlight"
private_lane :beta_flavor do |options|
flavor = FLAVORS.fetch(options[:flavor])
load_asc_api_key
build(flavor: flavor, build_number: next_build_number(bundle_id: flavor[:bundle_id]))
upload_to_testflight(
app_identifier: flavor[:bundle_id],
skip_waiting_for_build_processing: true
)
end
desc "Archive a flavor and upload it to App Store Connect (no submission)"
private_lane :release_flavor do |options|
# NB: this uploads a fresh, uniquely-numbered binary. The more common App
# Store flow is to *promote* an already-tested TestFlight build instead of
# uploading a new one — if you adopt that, replace the build/upload below
# with a submission of the chosen TestFlight build. App Store metadata and
# screenshots are still a follow-up; this lane uploads but does not submit.
flavor = FLAVORS.fetch(options[:flavor])
load_asc_api_key
build(flavor: flavor, build_number: next_build_number(bundle_id: flavor[:bundle_id]))
upload_to_app_store(
app_identifier: flavor[:bundle_id],
submit_for_review: false,
skip_metadata: true,
skip_screenshots: true,
precheck_include_in_app_purchases: false
)
end
desc "Archive the Release configuration into ./build for the given flavor"
private_lane :build do |options|
# Inject the build number as an xcodebuild setting override rather than
# mutating tracked files. CFBundleVersion is $(CURRENT_PROJECT_VERSION) in
# the Info.plists, so overriding CURRENT_PROJECT_VERSION here flows into the
# archived binary — and nothing in the repo changes (no agvtool, no churn).
# The bundle ID and display name are overridden the same way, so each flavor
# comes out of the one target without a second Xcode configuration.
flavor = options[:flavor]
xcargs = ["-allowProvisioningUpdates"]
xcargs << "CURRENT_PROJECT_VERSION=#{options[:build_number]}" if options[:build_number]
if flavor
xcargs << "PRODUCT_BUNDLE_IDENTIFIER=#{flavor[:bundle_id].shellescape}"
xcargs << "APP_DISPLAY_NAME=#{flavor[:display_name].shellescape}"
end
build_app(
scheme: "Omnigent",
configuration: "Release",
export_method: "app-store",
xcargs: xcargs.join(" "),
output_directory: "./build",
clean: true
)
end
desc "Next build number: one past the highest already on App Store Connect"
private_lane :next_build_number do |options|
# TestFlight sees every build (App Store builds pass through it too), so the
# latest TestFlight build number is a monotonic counter for the whole app.
# Each flavor is a separate app record, so pass its bundle ID to count the
# right one. Requires the ASC API key to be loaded first.
latest_testflight_build_number(
app_identifier: options[:bundle_id],
initial_build_number: 0
) + 1
end
desc "Load the App Store Connect API key from env vars into the session"
private_lane :load_asc_api_key do
key_filepath = File.expand_path(omnigent_fetch_env("ASC_KEY_PATH", "APPLE_API_KEY"))
UI.user_error!("App Store Connect API key file not found: #{key_filepath}") unless File.file?(key_filepath)
app_store_connect_api_key(
key_id: omnigent_fetch_env("ASC_KEY_ID", "APPLE_API_KEY_ID"),
issuer_id: omnigent_fetch_env("ASC_ISSUER_ID", "APPLE_API_ISSUER"),
key_filepath: key_filepath,
in_house: false
)
end
end