require 'deliver/password_manager'
require 'open-uri'
require 'openssl'
require 'capybara'
require 'capybara/poltergeist'
module Sigh
class DeveloperCenter
# This error occurs only if there is something wrong with the given login data
class DeveloperCenterLoginError < StandardError
end
# This error can occur for many reaons. It is
# usually raised when a UI element could not be found
class DeveloperCenterGeneralError < StandardError
end
# Types of certificates
APPSTORE = "AppStore"
ADHOC = "AdHoc"
DEVELOPMENT = "Development"
include Capybara::DSL
DEVELOPER_CENTER_URL = "https://developer.apple.com/devcenter/ios/index.action"
PROFILES_URL = "https://developer.apple.com/account/ios/profile/profileList.action?type=production"
PROFILES_URL_DEV = "https://developer.apple.com/account/ios/profile/profileList.action?type=limited"
def initialize
FileUtils.mkdir_p TMP_FOLDER
DependencyChecker.check_dependencies
Capybara.run_server = false
Capybara.default_driver = :poltergeist
Capybara.javascript_driver = :poltergeist
Capybara.current_driver = :poltergeist
Capybara.app_host = DEVELOPER_CENTER_URL
# Since Apple has some SSL errors, we have to configure the client properly:
# https://github.com/ariya/phantomjs/issues/11239
Capybara.register_driver :poltergeist do |a|
conf = ['--debug=no', '--ignore-ssl-errors=yes', '--ssl-protocol=TLSv1']
Capybara::Poltergeist::Driver.new(a, {
phantomjs_options: conf,
phantomjs_logger: File.open("#{TMP_FOLDER}/poltergeist_log.txt", "a"),
js_errors: false
})
end
page.driver.headers = { "Accept-Language" => "en" }
self.login
end
# Loggs in a user with the given login data on the Dev Center Frontend.
# You don't need to pass a username and password. It will
# Automatically be fetched using the {Deliver::PasswordManager}.
# This method will also automatically be called when triggering other
# actions like {#open_app_page}
# @param user (String) (optional) The username/email address
# @param password (String) (optional) The password
# @return (bool) true if everything worked fine
# @raise [DeveloperCenterGeneralError] General error while executing
# this action
# @raise [DeveloperCenterLoginError] Login data is wrong
def login(user = nil, password = nil)
begin
Helper.log.info "Login into iOS Developer Center"
user ||= Deliver::PasswordManager.shared_manager.username
password ||= Deliver::PasswordManager.shared_manager.password
result = visit PROFILES_URL
raise "Could not open Developer Center" unless result['status'] == 'success'
wait_for_elements(".button.blue").first.click
(wait_for_elements('#accountpassword') rescue nil) # when the user is already logged in, this will raise an exception
if page.has_content?"My Apps"
# Already logged in
return true
end
fill_in "accountname", with: user
fill_in "accountpassword", with: password
begin
all(".button.large.blue.signin-button").first.click
wait_for_elements('.ios.profiles.gridList')
visit PROFILES_URL # again, since after the login, the dev center loses the production GET value
rescue Exception => ex
Helper.log.debug ex
raise DeveloperCenterLoginError.new("Error logging in user #{user} with the given password. Make sure you entered them correctly.")
end
Helper.log.info "Login successful"
true
rescue Exception => ex
error_occured(ex)
end
end
def run(app_identifier, type, cert_name = nil)
cert = maintain_app_certificate(app_identifier, type)
cert_name ||= "#{type}_#{app_identifier}.mobileprovision" # default name
cert_name += '.mobileprovision' unless cert_name.include?'mobileprovision'
output_path = TMP_FOLDER + cert_name
File.write(output_path, cert)
return output_path
end
def maintain_app_certificate(app_identifier, type)
begin
if type == DEVELOPMENT
visit PROFILES_URL_DEV
else
visit PROFILES_URL
end
@list_certs_url = page.html.match(/var profileDataURL = "(.*)"/)[1]
# list_certs_url will look like this: "https://developer.apple.com/services-account/..../account/ios/profile/listProvisioningProfiles.action?content-type=application/x-www-form-urlencoded&accept=application/json&requestId=id&userLocale=en_US&teamId=xy&includeInactiveProfiles=true&onlyCountLists=true"
Helper.log.info "Fetching all available provisioning profiles..."
certs = post_ajax(@list_certs_url)
Helper.log.info "Checking if profile is available. (#{certs['provisioningProfiles'].count} profiles found)"
certs['provisioningProfiles'].each do |current_cert|
next if type == DEVELOPMENT and current_cert['type'] != "iOS Development"
next if type != DEVELOPMENT and current_cert['type'] != 'iOS Distribution'
details = profile_details(current_cert['provisioningProfileId'])
if details['provisioningProfile']['appId']['identifier'] == app_identifier
if type == APPSTORE and details['provisioningProfile']['deviceCount'] > 0
next # that's an Ad Hoc profile. I didn't find a better way to detect if it's one ... skipping it
end
if type != APPSTORE and details['provisioningProfile']['deviceCount'] == 0
next # that's an App Store profile ... skipping it
end
# We found the correct certificate
if current_cert['status'] == 'Active'
return download_profile(details['provisioningProfile']['provisioningProfileId']) # this one is already finished. Just download it.
elsif ['Expired', 'Invalid'].include?current_cert['status']
renew_profile(current_cert['provisioningProfileId'], type) # This one needs to be renewed
return maintain_app_certificate(app_identifier, type) # recursive
end
break
end
end
Helper.log.info "Could not find existing profile. Trying to create a new one."
# Certificate does not exist yet, we need to create a new one
create_profile(app_identifier, type)
# After creating the profile, we need to download it
return maintain_app_certificate(app_identifier, type) # recursive
rescue Exception => ex
error_occured(ex)
end
end
def create_profile(app_identifier, type)
Helper.log.info "Creating new profile for app '#{app_identifier}' for type '#{type}'.".yellow
certificate = code_signing_certificate(type)
create_url = "https://developer.apple.com/account/ios/profile/profileCreate.action"
visit create_url
# 1) Select the profile type (AppStore, Adhoc)
wait_for_elements('#type-production')
value = 'store'
value = 'limited' if type == DEVELOPMENT
value = 'adhoc' if type == ADHOC
first(:xpath, "//input[@type='radio' and @value='#{value}']").click
click_next
# 2) Select the App ID
while not page.has_content?"Select App ID" do sleep 1 end
# example:
first(:xpath, "//option[contains(text(), '.#{app_identifier})')]").select_option
click_next
# 3) Select the certificate
while not page.has_content?"Select certificates" do sleep 1 end
sleep 3
Helper.log.info "Using certificate ID '#{certificate['certificateId']}' from '#{certificate['ownerName']}'"
# example: (production)
id = certificate["certificateId"]
certs = all(:xpath, "//input[@type='radio' and @value='[#{id}]']") if type != DEVELOPMENT # production uses radio and has a [] around the value
certs = all(:xpath, "//input[@type='checkbox' and @value='#{id}']") if type == DEVELOPMENT # development uses a checkbox and has no [] around the value
if certs.count != 1
Helper.log.info "Looking for certificate: #{certificate}. Found: #{certs.count}"
raise "Could not find certificate in the list of available certificates."
end
certs.first.click
click_next
if type != APPSTORE
# 4) Devices selection
wait_for_elements('.selectAll.column')
sleep 3
first(:xpath, "//div[@class='selectAll column']/input").click # select all the devices
click_next
end
# 5) Choose a profile name
wait_for_elements('.distributionType')
profile_name = [app_identifier, type].join(' ')
fill_in "provisioningProfileName", with: profile_name
click_next
wait_for_elements('.row-details')
end
def renew_profile(profile_id, type)
certificate = code_signing_certificate type
details_url = "https://developer.apple.com/account/ios/profile/profileEdit.action?type=&provisioningProfileId=#{profile_id}"
Helper.log.info "Renewing provisioning profile '#{profile_id}' using URL '#{details_url}'"
visit details_url
Helper.log.info "Using certificate ID '#{certificate['certificateId']}' from '#{certificate['ownerName']}'"
wait_for_elements('.selectCertificates')
certs = all(:xpath, "//input[@type='radio' and @value='#{certificate["certificateId"]}']")
if certs.count == 1
certs.first.click
click_next
wait_for_elements('.row-details')
click_on "Done"
else
Helper.log.info "Looking for certificate: #{certificate}. Found: #{certs}"
raise "Could not find certificate in the list of available certificates."
end
end
def download_profile(profile_id)
download_cert_url = "/account/ios/profile/profileContentDownload.action?displayId=#{profile_id}"
return download_file(download_cert_url)
end
private
def profile_details(profile_id)
# We need to build the URL to get the App ID for a specific certificate
current_profile_url = @list_certs_url.gsub('listProvisioningProfiles', 'getProvisioningProfile')
current_profile_url += "&provisioningProfileId=#{profile_id}"
# Helper.log.debug "Fetching URL: '#{current_profile_url}'"
result = post_ajax(current_profile_url)
# Example response, see bottom of file
if result['resultCode'] == 0
return result
else
raise "Error fetching details for provisioning profile '#{profile_id}'".red
end
end
# Returns a hash, that contains information about the iOS certificate
# @example
# {"certRequestId"=>"B23Q2P396B",
# "name"=>"SunApps GmbH",
# "statusString"=>"Issued",
# "expirationDate"=>"2015-11-25T22:45:50Z",
# "expirationDateString"=>"Nov 25, 2015",
# "ownerType"=>"team",
# "ownerName"=>"SunApps GmbH",
# "ownerId"=>"....",
# "canDownload"=>true,
# "canRevoke"=>true,
# "certificateId"=>"....",
# "certificateStatusCode"=>0,
# "certRequestStatusCode"=>4,
# "certificateTypeDisplayId"=>"...",
# "serialNum"=>"....",
# "typeString"=>"iOS Distribution"},
def code_signing_certificate(type)
certs_url = "https://developer.apple.com/account/ios/certificate/certificateList.action?type="
certs_url << "distribution" if type != DEVELOPMENT
certs_url << "development" if type == DEVELOPMENT
visit certs_url
certificateDataURL = page.html.match(/var certificateDataURL = "(.*)"/)[1]
certificateRequestTypes = page.html.match(/var certificateRequestTypes = "(.*)"/)[1]
certificateStatuses = page.html.match(/var certificateStatuses = "(.*)"/)[1]
url = [certificateDataURL, certificateRequestTypes, certificateStatuses].join('')
# https://developer.apple.com/services-account/.../account/ios/certificate/listCertRequests.action?content-type=application/x-www-form-urlencoded&accept=application/json&requestId=...&userLocale=en_US&teamId=...&types=...&status=4&certificateStatus=0&type=distribution
certs = post_ajax(url)['certRequests']
certs.each do |current_cert|
if type != DEVELOPMENT and current_cert['typeString'] == 'iOS Distribution'
# The other profiles are push profiles
# We only care about the distribution profile
return current_cert # mostly we only care about the 'certificateId'
elsif type == DEVELOPMENT and current_cert['typeString'] == 'iOS Development'
return current_cert # mostly we only care about the 'certificateId'
end
end
raise "Could not find a Certificate. Please open #{current_url} and make sure you have a signing profile created.".red
end
# Download a file from the dev center, by using a HTTP client. This will return the content of the file
def download_file(url)
Helper.log.info "Downloading profile..."
host = Capybara.current_session.current_host
url = [host, url].join('')
myacinfo = page.driver.cookies['myacinfo'].value # some Apple magic, which is required for the profile download
data = open(url, {'Cookie' => "myacinfo=#{myacinfo}"}).read
raise "Something went wrong when downloading the file from the Dev Center" unless data
Helper.log.info "Successfully downloaded provisioning profile"
return data
end
def post_ajax(url)
JSON.parse(page.evaluate_script("$.ajax({type: 'POST', url: '#{url}', async: false})")['responseText'])
end
def click_next
wait_for_elements('.button.small.blue.right.submit').last.click
end
def error_occured(ex)
snap
raise ex # re-raise the error after saving the snapshot
end
def snap
path = "Error#{Time.now.to_i}.png"
save_screenshot(path, :full => true)
system("open '#{path}'")
end
def wait_for_elements(name)
counter = 0
results = all(name)
while results.count == 0
# Helper.log.debug "Waiting for #{name}"
sleep 0.2
results = all(name)
counter += 1
if counter > 100
Helper.log.debug page.html
Helper.log.debug caller
raise DeveloperCenterGeneralError.new("Couldn't find element '#{name}' after waiting for quite some time")
end
end
return results
end
end
end
# Example response 1)
# => {"resultCode"=>0,
# "protocolVersion"=>"....",
# "isAdmin"=>true,
# "isMember"=>false,
# "isAgent"=>true,
# "pageNumber"=>nil,
# "pageSize"=>nil,
# "totalRecords"=>nil,
# "provisioningProfile"=>
# {"provisioningProfileId"=>"....",
# "name"=>"Gut Altentann Development",
# "status"=>"Active",
# "type"=>"iOS Development",
# "distributionMethod"=>"limited",
# "proProPlatform"=>"ios",
# "version"=>"ProvisioningProfilev1",
# "dateExpire"=>"2015-02-22",
# "managingApp"=>nil,
# "appId"=>
# {"appIdId"=>".....",
# "name"=>"SunApps",
# "appIdPlatform"=>"ios",
# "prefix"=>"....",
# "identifier"=>"net.sunapps.123",
# "isWildCard"=>true,
# "isDuplicate"=>false,
# "features"=>
# {"push"=>false,
# "inAppPurchase"=>false,
# "gameCenter"=>false,
# "passbook"=>false,
# "dataProtection"=>"",
# "homeKit"=>false,
# "cloudKitVersion"=>1,
# "iCloud"=>false,
# "LPLF93JG7M"=>false,
# "WC421J6T7P"=>false},
# "enabledFeatures"=>[],
# "isDevPushEnabled"=>false,
# "isProdPushEnabled"=>false,
# "associatedApplicationGroupsCount"=>nil,
# "associatedCloudContainersCount"=>nil,
# "associatedIdentifiersCount"=>nil},
# "appIdId"=>".....",
# "deviceCount"=>8,
# "certificateCount"=>1,
# "UUID"=>"F670D427-2D0E-4782-8171-....."}}