349 lines
11 KiB
Ruby
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
|