module BranchIOCLI::Helper::IOSHelper

Constants

ASSOCIATED_DOMAINS
CODE_SIGN_ENTITLEMENTS
DEVELOPMENT_TEAM
PRODUCT_BUNDLE_IDENTIFIER
RELEASE_CONFIGURATION

Public Instance Methods

add_custom_build_setting() click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 37
def add_custom_build_setting
  return unless config.setting

  config.target.build_configurations.each do |c|
    key = uses_test_key?(c) ? config.keys[:test] : config.keys[:live]
    # Reuse the same key if both not present
    key ||= uses_test_key?(c) ? config.keys[:live] : config.keys[:test]
    c.build_settings[config.setting] = key
  end
end
add_keys_to_info_plist(keys) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 48
def add_keys_to_info_plist(keys)
  if has_multiple_info_plists?
    config.xcodeproj.build_configurations.each do |c|
      update_info_plist_setting c.name do |info_plist|
        if keys.count > 1 && !config.setting
          # Use test key in debug configs and live key in release configs
          info_plist["branch_key"] = c.debug? ? keys[:test] : keys[:live]
        elsif config.setting
          info_plist["branch_key"] = "$(#{config.setting})"
        else
          info_plist["branch_key"] = keys[:live] ? keys[:live] : keys[:test]
        end
      end
    end
  else
    update_info_plist_setting RELEASE_CONFIGURATION do |info_plist|
      # add/overwrite Branch key(s)
      if keys.count > 1 && !config.setting
        info_plist["branch_key"] = keys
      elsif config.setting
        info_plist["branch_key"] = "$(#{config.setting})"
      elsif keys[:live]
        info_plist["branch_key"] = keys[:live]
      else
        info_plist["branch_key"] = keys[:test]
      end
    end
  end
end
app_ids_from_aasa_file(domain) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 239
def app_ids_from_aasa_file(domain)
  data = contents_of_aasa_file domain
  # errors reported in the method above
  return nil if data.nil?

  # raises
  file = JSON.parse data

  applinks = file[APPLINKS]
  @errors << "[#{domain}] No #{APPLINKS} found in AASA file" and return if applinks.nil?

  details = applinks["details"]
  @errors << "[#{domain}] No details found for #{APPLINKS} in AASA file" and return if details.nil?

  identifiers = details.map { |d| d["appID"] }.uniq
  @errors << "[#{domain}] No appID found in AASA file" and return if identifiers.count <= 0
  identifiers
rescue JSON::ParserError => e
  @errors << "[#{domain}] Failed to parse AASA file: #{e.message}"
  nil
end
branch_apps_from_project(configurations) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 498
def branch_apps_from_project(configurations)
  branch_keys_from_project(configurations).map { |key| BranchApp[key] }
end
branch_keys_from_project(configurations) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 478
def branch_keys_from_project(configurations)
  configurations.map do |c|
    path = info_plist_path(c)
    info_plist = info_plist(path).symbolize_keys
    branch_key = info_plist[:branch_key]
    if branch_key.blank?
      say "branch_key not found in Info.plist. ❌"
      return []
    end

    if branch_key.kind_of?(Hash)
      keys = branch_key.values
    else
      keys = [branch_key]
    end

    keys.map { |key| config.target.expand_build_settings key, c }
  end.compact.flatten.uniq
end
config() click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 22
def config
  Configuration::Configuration.current
end
contents_of_aasa_file(domain) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 265
def contents_of_aasa_file(domain)
  @aasa_files ||= {}
  return @aasa_files[domain] if @aasa_files[domain]

  uris = [
    URI("https://#{domain}/.well-known/apple-app-site-association"),
    URI("https://#{domain}/apple-app-site-association")
    # URI("http://#{domain}/.well-known/apple-app-site-association"),
    # URI("http://#{domain}/apple-app-site-association")
  ]

  data = nil

  uris.each do |uri|
    break unless data.nil?

    Net::HTTP.start uri.host, uri.port, use_ssl: uri.scheme == "https" do |http|
      request = Net::HTTP::Get.new uri
      spinner = TTY::Spinner.new "[:spinner] GET #{uri}.", format: :flip
      spinner.auto_spin
      response = http.request request

      # Better to use Net::HTTPRedirection and Net::HTTPSuccess here, but
      # having difficulty with the unit tests.
      if (300..399).cover?(response.code.to_i)
        spinner.error "#{response.code} #{response.message}"
        say "#{uri} cannot result in a redirect. Ignoring."
        next
      elsif response.code.to_i != 200
        # Try the next URI.
        spinner.error "#{response.code} #{response.message}"
        say "Could not retrieve #{uri}. Ignoring."
        next
      end

      spinner.success "#{response.code} #{response.message}"

      content_type = response["Content-type"]
      @errors << "[#{domain}] AASA Response does not contain a Content-type header" and next if content_type.nil?

      case content_type
      when %r{application/pkcs7-mime}
        # Verify/decrypt PKCS7 (non-Branch domains)
        cert_store = OpenSSL::X509::Store.new
        signature = OpenSSL::PKCS7.new response.body
        # raises
        signature.verify nil, cert_store, nil, OpenSSL::PKCS7::NOVERIFY
        data = signature.data
      else
        @errors << "[#{domain}] Unsigned AASA files must be served via HTTPS" and next if uri.scheme == "http"
        data = response.body
      end
    end
  end

  @errors << "[#{domain}] Failed to retrieve AASA file" and return nil if data.nil?

  @aasa_files[domain] = data
  data
rescue IOError, SocketError => e
  @errors << "[#{domain}] Socket error: #{e.message}"
  nil
rescue OpenSSL::PKCS7::PKCS7Error => e
  @errors << "[#{domain}] Failed to verify signed AASA file: #{e.message}"
  nil
end
domains_from_project(configuration = RELEASE_CONFIGURATION) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 414
def domains_from_project(configuration = RELEASE_CONFIGURATION)
  project = config.xcodeproj
  target = config.target

  relative_entitlements_path = target.expanded_build_setting CODE_SIGN_ENTITLEMENTS, configuration
  return [] if relative_entitlements_path.nil?

  project_parent = File.dirname project.path
  entitlements_path = File.expand_path relative_entitlements_path, project_parent

  # Raises
  entitlements = File.open(entitlements_path) { |f| Plist.parse_xml f }
  raise "Failed to parse entitlements file #{entitlements_path}" if entitlements.nil?

  associated_domains = entitlements[ASSOCIATED_DOMAINS]
  return [] if associated_domains.nil?

  associated_domains.select { |d| d =~ /^applinks:/ }.map { |d| d.sub(/^applinks:/, "") }
end
ensure_uri_scheme_in_info_plist() click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 89
def ensure_uri_scheme_in_info_plist
  uri_scheme = config.uri_scheme

  # No URI scheme specified. Do nothing.
  return if uri_scheme.nil?

  config.xcodeproj.build_configurations.each do |c|
    update_info_plist_setting c.name do |info_plist|
      url_types = info_plist["CFBundleURLTypes"] || []
      uri_schemes = url_types.inject([]) { |schemes, t| schemes + t["CFBundleURLSchemes"] }

      # Already present. Don't mess with the identifier.
      next if uri_schemes.include? uri_scheme

      # Not found. Add. Don't worry about the CFBundleURLName (reverse-DNS identifier)
      # TODO: Should we prompt here to add or let them change the Dashboard? If there's already
      # a URI scheme in the app, seems likely they'd want to use it. They may have just made
      # a typo at the CLI or in the Dashboard.
      url_types << {
        "CFBundleURLSchemes" => [uri_scheme]
      }
      info_plist["CFBundleURLTypes"] = url_types
    end
  end
end
has_multiple_info_plists?() click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 26
def has_multiple_info_plists?
  config.xcodeproj.build_configurations.inject([]) do |files, c|
    files + [config.target.expanded_build_setting("INFOPLIST_FILE", c.name)]
  end.uniq.count > 1
end
info_plist(path) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 126
def info_plist(path)
  # try to open and parse the Info.plist (raises)
  info_plist = File.open(path) { |f| Plist.parse_xml f }
  raise "Failed to parse #{path}" if info_plist.nil?
  info_plist
end
info_plist_path(configuration) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 115
def info_plist_path(configuration)
  # find the Info.plist paths for this configuration
  info_plist_path = config.target.expanded_build_setting "INFOPLIST_FILE", configuration

  raise "Info.plist not found for configuration #{configuration}" if info_plist_path.nil?

  project_parent = File.dirname config.xcodeproj_path

  File.expand_path info_plist_path, project_parent
end
project_valid?(configuration) click to toggle source

Validates Branch-related settings in a project (keys, domains, URI schemes)

# File lib/branch_io_cli/helper/ios_helper.rb, line 435
def project_valid?(configuration)
  @errors = []

  info_plist_path = info_plist_path(configuration)
  info_plist = info_plist(info_plist_path).symbolize_keys
  branch_key = info_plist[:branch_key]

  if branch_key.blank?
    say "branch_key not found in Info.plist. ❌"
    return false
  end

  if branch_key.kind_of?(Hash)
    branch_keys = branch_key.map { |k, v| v }
  else
    branch_keys = [branch_key]
  end

  branch_keys = branch_keys.map { |key| config.target.expand_build_settings key, configuration }

  # Retrieve app data from Branch API for all keys in the Info.plist
  apps = branch_keys.map { |k| BranchApp[k] }.compact.uniq
  invalid_keys = apps.reject(&:valid?).map(&:key)

  valid = invalid_keys.empty?
  say "Invalid Branch key(s) in Info.plist for #{configuration} configuration: #{invalid_keys}. ❌" unless valid

  # Get domains and URI schemes loaded from API
  domains_from_api = domains apps

  # Make sure all domains and URI schemes are present in the project.
  domains = domains_from_project(configuration)
  missing_domains = domains_from_api - domains
  unless missing_domains.empty?
    valid = false
    missing_domains.each do |domain|
      say "[#{domain}] Domain from Dashboard missing from #{configuration} configuration. ❌"
    end
  end

  valid
end
report_app_id_mismatch(domain, app_id, identifiers) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 351
def report_app_id_mismatch(domain, app_id, identifiers)
  error_string = "[#{domain}] appID mismatch. Project #{reportable_app_id app_id}\n"
  if identifiers.count <= 20
    error_string << " Apps from AASA:\n"
    identifiers.each do |identifier|
      reportable = reportable_app_id identifier
      error_string << "  #{reportable}\n"
    end
  else
    error_string << " Please check your settings in the Branch Dashboard (https://dashboard.branch.io)"
  end

  @errors << error_string
end
reportable_app_id(identifier) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 193
def reportable_app_id(identifier)
  team, bundle = team_and_bundle_from_app_id identifier
  "Signing team: #{team.inspect}, Bundle identifier: #{bundle.inspect}"
end
reset_aasa_cache() click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 261
def reset_aasa_cache
  @aasa_files = {}
end
target_from_project(project, target_name) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 399
def target_from_project(project, target_name)
  if target_name
    target = project.targets.find { |t| t.name == target_name }
    raise "Target #{target} not found" if target.nil?
  elsif config.respond_to?(:scheme) && project.targets.map(&:name).include?(config.scheme)
    # Return a target with the same name as the scheme, if there is one.
    target = project.targets.find { |t| t.name == config.scheme }
  else
    # find the first application target
    target = project.targets.find { |t| t.name == File.basename(project.path, '.xcodeproj') } ||
             project.targets.select { |t| !t.extension_target_type? && !t.test_target_type? }.first
  end
  target
end
team_and_bundle_from_app_id(identifier) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 188
def team_and_bundle_from_app_id(identifier)
  matches = /^(.*?)\.(.*)$/.match identifier
  matches[1, 2]
end
update_info_plist_setting(configuration = RELEASE_CONFIGURATION) { |info_plist| ... } click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 133
def update_info_plist_setting(configuration = RELEASE_CONFIGURATION, &b)
  info_plist_path = info_plist_path(configuration)
  info_plist = info_plist(info_plist_path)
  yield info_plist

  Plist::Emit.save_plist info_plist, info_plist_path
  add_change info_plist_path
end
update_team_and_bundle_ids(team, bundle) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 386
def update_team_and_bundle_ids(team, bundle)
  target = config.target

  target.build_configuration_list.set_setting PRODUCT_BUNDLE_IDENTIFIER, bundle
  target.build_configuration_list.set_setting DEVELOPMENT_TEAM, team

  # also update the team in the first test target
  target = project.targets.find(&:test_target_type?)
  return if target.nil?

  target.build_configuration_list.set_setting DEVELOPMENT_TEAM, team
end
update_team_and_bundle_ids_from_aasa_file(domain) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 198
def update_team_and_bundle_ids_from_aasa_file(domain)
  # raises
  identifiers = app_ids_from_aasa_file domain
  raise "Multiple appIDs found in AASA file" if identifiers.count > 1

  identifier = identifiers[0]
  team, bundle = team_and_bundle_from_app_id identifier

  update_team_and_bundle_ids team, bundle
  add_change config.xcodeproj_path
end
uri_schemes_from_project(configurations) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 502
def uri_schemes_from_project(configurations)
  schemes = configurations.map do |c|
    path = info_plist_path(c)
    info_plist = info_plist(path)
    url_types = info_plist["CFBundleURLTypes"] || []
    url_types.map { |t| t["CFBundleURLSchemes"] }
  end

  schemes.compact.flatten.uniq
end
uses_test_key?(build_configuration) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 32
def uses_test_key?(build_configuration)
  return build_configuration.debug? unless config.setting && config.test_configurations
  config.test_configurations.include? build_configuration.name
end
validate_project_domains(expected, configuration = RELEASE_CONFIGURATION) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 366
def validate_project_domains(expected, configuration = RELEASE_CONFIGURATION)
  @errors = []
  project_domains = domains_from_project configuration
  valid = expected.count == project_domains.count
  if valid
    sorted = expected.sort
    project_domains.sort.each_with_index do |domain, index|
      valid = false and break unless sorted[index] == domain
    end
  end

  unless valid
    @errors << "Project domains do not match :domains parameter"
    @errors << "Project domains: #{project_domains}"
    @errors << ":domains parameter: #{expected}"
  end

  valid
end
validate_team_and_bundle_ids(domain, configuration) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 332
def validate_team_and_bundle_ids(domain, configuration)
  target = config.target

  product_bundle_identifier = target.expanded_build_setting PRODUCT_BUNDLE_IDENTIFIER, configuration
  development_team = target.expanded_build_setting DEVELOPMENT_TEAM, configuration

  identifiers = app_ids_from_aasa_file domain
  return false if identifiers.nil?

  app_id = "#{development_team}.#{product_bundle_identifier}"
  match_found = identifiers.include? app_id

  unless match_found
    report_app_id_mismatch domain, app_id, identifiers
  end

  match_found
end
validate_team_and_bundle_ids_from_aasa_files(domains = [], remove_existing = false, configuration = RELEASE_CONFIGURATION) click to toggle source
# File lib/branch_io_cli/helper/ios_helper.rb, line 210
def validate_team_and_bundle_ids_from_aasa_files(domains = [], remove_existing = false, configuration = RELEASE_CONFIGURATION)
  @errors = []
  valid = true

  # Include any domains already in the project.
  # Raises. Returns a non-nil array of strings.
  if remove_existing
    # Don't validate domains to be removed (#16)
    all_domains = domains
  else
    all_domains = (domains + domains_from_project(configuration)).uniq
  end

  if all_domains.empty?
    # Cannot get here from SetupBranchAction, since the domains passed in will never be empty.
    # If called from ValidateUniversalLinksAction, this is a failure, possibly caused by
    # failure to add applinks:.
    @errors << "No Universal Link domains in project. Be sure each Universal Link domain is prefixed with applinks:."
    return false
  end

  all_domains.each do |domain|
    domain_valid = validate_team_and_bundle_ids domain, configuration
    valid &&= domain_valid
    say "Valid Universal Link configuration for #{domain} ✅" if domain_valid
  end
  valid
end