diff --git a/bin/codes b/bin/codes index ba090bf..84963c8 100755 --- a/bin/codes +++ b/bin/codes @@ -1,17 +1,14 @@ #!/usr/bin/env ruby - $:.push File.expand_path('../../lib', __FILE__) -# require 'codes' -# require 'commander' -# require 'credentials_manager/appfile_config' +require 'codes' +require 'commander' +require 'credentials_manager/appfile_config' +require 'credentials_manager/appfile_config' require 'fastlane_core' # HighLine.track_eof = false -puts "This tool is currently not working due to the new iTunes Connect interface".red -raise "Error" - class CodesApplication include Commander::Methods @@ -27,13 +24,13 @@ class CodesApplication c.description = 'Download [num] new promo codes from iTunes Connect' c.action do |args, options| - username options + username = username(options) number_of_codes = count(args) apple_id = apple_id(options) - app_identifier = bundle_id(options, apple_id) + app_identifier = bundle_id(options) format = download_format options - Codes::CodesRunner.download(number_of_codes: number_of_codes, app_identifier: app_identifier, apple_id: apple_id, output_file_path: options.output_file, format: format, country: options.country) + Codes::CodesRunner.download(username: username, number_of_codes: number_of_codes, app_identifier: app_identifier, apple_id: apple_id, output_file_path: options.output_file, format: format) end end @@ -42,11 +39,11 @@ class CodesApplication c.description = 'Display remaining number of promo codes from iTunes Connect' c.action do |args, options| - username options + username = username(options) apple_id = apple_id(options) app_identifier = bundle_id(options, apple_id) - Codes::CodesRunner.display(app_identifier: app_identifier, apple_id: apple_id, output_file_path: options.output_file, country: options.country) + Codes::CodesRunner.display(username: username, app_identifier: app_identifier, apple_id: apple_id, output_file_path: options.output_file_path) end end default_command :download @@ -84,8 +81,7 @@ class CodesApplication user = options.username user ||= ENV['CODES_USERNAME'] user ||= CredentialsManager::AppfileConfig.try_fetch_value(:apple_id) - - CredentialsManager::PasswordManager.shared_manager(user) if user + user = CredentialsManager::AccountManager.user unless user user end @@ -100,22 +96,19 @@ class CodesApplication format end - def bundle_id(options, apple_id) + def bundle_id(options) app_identifier = options.app_identifier - app_identifier ||= ENV['CODES_APP_IDENTIFIER'] - app_identifier ||= CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier) - app_identifier ||= (FastlaneCore::ItunesSearchApi.fetch(apple_id)['bundleId'] rescue nil) if apple_id - app_identifier ||= ask('App Identifier (Bundle ID, e.g. com.krausefx.app): ') - app_identifier + app_identifier || ENV['CODES_APP_IDENTIFIER'] end def apple_id(options) apple_id = options.apple_id - apple_id ||= ENV['CODES_APP_ID'] - apple_id + apple_id || ENV['CODES_APP_ID'] end end +UI = FastlaneCore::UI + begin FastlaneCore::UpdateChecker.start_looking_for_update('codes') CodesApplication.new.run diff --git a/codes.gemspec b/codes.gemspec index 883e3cc..064a4fd 100644 --- a/codes.gemspec +++ b/codes.gemspec @@ -22,12 +22,8 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] - spec.add_dependency 'fastlane_core', '>= 0.16.0', '< 1.0.0' # all shared code and dependencies - - # Frontend Scripting - spec.add_dependency 'phantomjs', '~> 1.9.8' # dependency for poltergeist - spec.add_dependency 'capybara', '~> 2.4.3' # for controlling iTC - spec.add_dependency 'poltergeist', '~> 1.5.1' # headless Javascript browser for controlling iTC + spec.add_dependency 'spaceship', '>= 0.23.0', '< 1.0.0' # all shared code and dependencies + spec.add_dependency 'fastlane_core', '>= 0.37.0', '< 1.0.0' # all shared code and dependencies # Development only spec.add_development_dependency 'bundler' diff --git a/lib/codes.rb b/lib/codes.rb index e575423..9322c42 100644 --- a/lib/codes.rb +++ b/lib/codes.rb @@ -1,12 +1,10 @@ require 'codes/version' -require 'codes/dependency_checker' require 'codes/codes_runner' require 'codes/itunes_connect' require 'fastlane_core' +require 'spaceship' module Codes Helper = FastlaneCore::Helper - - DependencyChecker.check_dependencies end diff --git a/lib/codes/dependency_checker.rb b/lib/codes/dependency_checker.rb deleted file mode 100644 index 68d5355..0000000 --- a/lib/codes/dependency_checker.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Codes - class DependencyChecker - def self.check_dependencies - true # we don't have any dependencies currently, method is implemented for consistency with other fastlane tools - end - end -end diff --git a/lib/codes/itunes_connect.rb b/lib/codes/itunes_connect.rb index 297e07d..9c9b882 100644 --- a/lib/codes/itunes_connect.rb +++ b/lib/codes/itunes_connect.rb @@ -1,68 +1,43 @@ -require 'fastlane_core/itunes_connect/itunes_connect' - module Codes - class ItunesConnect < FastlaneCore::ItunesConnect - PROMO_URL = 'https://itunesconnect.apple.com/WebObjects/iTunesConnect.woa/wa/LCAppPage/viewPromoCodes?adamId=[[app_id]]&platform=[[platform]]' - CODE_URL = 'https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/redeemLandingPage?code=[[code]]' + class ItunesConnect - # rubocop:disable Metrics/AbcSize def download(args) number_of_codes = args[:number_of_codes] code_or_codes = number_of_codes == 1 ? 'code' : 'codes' - Helper.log.info "Downloading #{number_of_codes} promo #{code_or_codes}..." + UI.message "Downloading #{number_of_codes} promo #{code_or_codes}..." fetch_app_data args # Use Pathname because it correctly handles the distinction between relative paths vs. absolute paths output_file_path = Pathname.new(args[:output_file_path]) if args[:output_file_path] - output_file_path ||= Pathname.new(File.join(Dir.getwd, "#{@app_identifier || @app_id}_codes.txt")) - fail 'Insufficient permissions to write to output file'.red if File.exist?(output_file_path) && !File.writable?(output_file_path) - visit PROMO_URL.gsub('[[app_id]]', @app_id.to_s).gsub('[[platform]]', @platform) - - begin - text_fields = wait_for_elements('input[type=text]') - rescue - raise "Could not open details page for app #{@app_identifier}. Are you sure you are using the correct apple account and have access to this app?".red - end - fail 'There should only be a single text input field to specify the number of codes'.red unless text_fields.count == 1 - - text_fields.first.set(number_of_codes.to_s) - click_next - - # are there any errors ? - errors = [] - begin - errors = wait_for_elements('div[id=LCPurpleSoftwarePageWrapperErrorMessage]') - rescue - end - fail errors.first.text.red unless errors.count == 0 - - Helper.log.debug 'Accepting the App Store Volume Custom Code Agreement' - wait_for_elements('input[type=checkbox]').first.click - click_next + output_file_path ||= Pathname.new(File.join(Dir.getwd, "#{@app.apple_id}_codes.txt")) + User.error('Insufficient permissions to write to output file') if File.exist?(output_file_path) && !File.writable?(output_file_path) - # the find(:xpath, "..") gets the parent element of the previous expression - download_url = wait_for_elements("div[class='large-blue-rect-button']").first.find(:xpath, '..')['href'] + promocodes = @app.live_version.generate_promocodes!(number_of_codes) - codes, request_date = download_codes(download_url) + request_date = Time.at(promocodes.effective_date / 1000) + codes = promocodes.codes format = args[:format] - codes = download_format(codes, format, request_date, app) if format + if format + output = download_format(codes, format, request_date, app) + else + output = codes.join("\n") + end - bytes_written = File.write(output_file_path.to_s, codes, mode: 'a+') - Helper.log.warn 'Could not write your codes to the codes.txt file, but you can still access them from iTunes Connect later' if bytes_written == 0 - Helper.log.info "Added generated codes to '#{output_file_path}'".green unless bytes_written == 0 + bytes_written = File.write(output_file_path.to_s, output, mode: 'a+') + UI.important 'Could not write your codes to the codes.txt file, but you can still access them from iTunes Connect later' if bytes_written == 0 + UI.success "Added generated codes to '#{output_file_path}'" unless bytes_written == 0 - Helper.log.info "Your codes (requested #{request_date}) were successfully downloaded:".green - puts codes + UI.success "Your codes (requested #{request_date}) were successfully downloaded:" + puts output end - # rubocop:enable Metrics/AbcSize def download_format(codes, format, request_date, app) format = format.gsub(/%([a-z])/, '%{\\1}') # %c => %{c} - codes = codes.split("\n").map do |code| + lines = codes.map do |code| format % { c: code, b: app['bundleId'], @@ -73,11 +48,7 @@ def download_format(codes, format, request_date, app) u: CODE_URL.gsub('[[code]]', code) } end - codes.join("\n") + "\n" - end - - def app_platform(app) - app['kind'] == 'mac-software' ? 'osx' : 'ios' + lines.join("\n") + "\n" end def display(args) @@ -87,64 +58,25 @@ def display(args) # Use Pathname because it correctly handles the distinction between relative paths vs. absolute paths output_file_path = Pathname.new(args[:output_file_path]) if args[:output_file_path] - output_file_path ||= Pathname.new(File.join(Dir.getwd, "#{@app_identifier || @app_id}_codes_info.txt")) + output_file_path ||= Pathname.new(File.join(Dir.getwd, "#{@app.apple_id}_codes_info.txt")) fail 'Insufficient permissions to write to output file'.red if File.exist?(output_file_path) && !File.writable?(output_file_path) - visit PROMO_URL.gsub('[[app_id]]', @app_id.to_s).gsub('[[platform]]', @platform) - begin - text_fields = wait_for_elements('input[type=text]') - rescue - raise "Could not open details page for app #{app_identifier}. Are you sure you are using the correct apple account and have access to this app?".red - end - fail 'There should only be a single text input field to specify the number of codes'.red unless text_fields.count == 1 + app_promocodes = @app.promocodes.first - remaining_divs = wait_for_elements('div#codes_0') - fail 'There should only be a single text div containing the number of remaining codes'.red unless remaining_divs.count == 1 - remaining = remaining_divs.first.text.split(' ')[0] + remaining = app_promocodes.maximum_number_of_codes - app_promocodes.number_of_codes bytes_written = File.write(output_file_path.to_s, remaining, mode: 'a+') - Helper.log.warn 'Could not write your codes to the codes_info.txt file, but you can still access them from iTunes Connect later' if bytes_written == 0 - Helper.log.info "Added information of quantity of remaining codes to '#{output_file_path}'".green unless bytes_written == 0 + UI.important 'Could not write your codes to the codes_info.txt file, but you can still access them from iTunes Connect later' if bytes_written == 0 + UI.success "Added information of quantity of remaining codes to '#{output_file_path}'" unless bytes_written == 0 puts remaining end - def click_next - wait_for_elements('input.continueActionButton').first.click - end - - def download_codes(url) - host = Capybara.current_session.current_host - url = URI.join(host, url) - Helper.log.debug "Downloading promo code file from #{url}" - - cookie_string = '' - page.driver.cookies.each do |key, cookie| - cookie_string << "#{cookie.name}=#{cookie.value};" - end - - page = open(url, 'Cookie' => cookie_string) - request_date = page.metas['content-disposition'][0].gsub(/.*filename=.*_(.*).txt/, '\\1') - codes = page.read - - [codes, request_date] - end - private def fetch_app_data(args) - @country = args[:country] - @app_identifier = args[:app_identifier] - - @app_id = args[:apple_id] - @app_id ||= (FastlaneCore::ItunesSearchApi.fetch_by_identifier(@app_identifier, @country)['trackId'] rescue nil) - - if @app_id.to_i == 0 || @app_identifier.to_s.length == 0 - fail "Could not find app using the following information: #{args}. Maybe the app is not in the store. Pass the Apple ID of the app as well!".red - end - - app = FastlaneCore::ItunesSearchApi.fetch(@app_id, @country) - @platform = app_platform app + Spaceship::Tunes.login(args[:username]) + @app = Spaceship::Tunes::Application.find(args[:apple_id] || args[:app_identifier] ) end end end diff --git a/lib/codes/itunes_connect/itunes_connect.rb b/lib/codes/itunes_connect/itunes_connect.rb deleted file mode 100644 index 444693e..0000000 --- a/lib/codes/itunes_connect/itunes_connect.rb +++ /dev/null @@ -1,64 +0,0 @@ -require 'capybara' -require 'capybara/poltergeist' -require 'credentials_manager/password_manager' -require 'phantomjs/poltergeist' # this will download and store phantomjs - -require 'fastlane_core/itunes_connect/itunes_connect_helper.rb' -require 'fastlane_core/itunes_connect/itunes_connect_login.rb' -require 'fastlane_core/itunes_connect/itunes_connect_apple_id.rb' - -module Codes - # Everything that can't be achived using the {Codes::ItunesTransporter} - # will be scripted using the iTunesConnect frontend. - # - # Every method you call here, might take a time - class ItunesConnect - # This error occurs only if there is something wrong with the given login data - class ItunesConnectLoginError < StandardError - end - - # This error can occur for many reaons. It is - # usually raised when an UI element could not be found - class ItunesConnectGeneralError < StandardError - end - - include Capybara::DSL - - ITUNESCONNECT_URL = "https://itunesconnect.apple.com/" - APP_DETAILS_URL = "https://itunesconnect.apple.com/WebObjects/iTunesConnect.woa/ra/ng/app/[[app_id]]" - - BUTTON_STRING_NEW_VERSION = "New Version" - BUTTON_STRING_SUBMIT_FOR_REVIEW = "Submit for Review" - - WAITING_FOR_REVIEW = "Waiting For Review" - - def initialize - super - - return if Helper.is_test? - - Capybara.run_server = false - Capybara.default_driver = :poltergeist - Capybara.javascript_driver = :poltergeist - Capybara.current_driver = :poltergeist - Capybara.app_host = ITUNESCONNECT_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: Phantomjs.path, - phantomjs_options: conf, - phantomjs_logger: File.open("/tmp/poltergeist_log.txt", "a"), - js_errors: false, - timeout: 90 - }) - end - - page.driver.headers = { "Accept-Language" => "en" } - - login - end - end -end diff --git a/lib/codes/itunes_connect/itunes_connect_helper.rb b/lib/codes/itunes_connect/itunes_connect_helper.rb deleted file mode 100644 index 9148df5..0000000 --- a/lib/codes/itunes_connect/itunes_connect_helper.rb +++ /dev/null @@ -1,85 +0,0 @@ -module Codes - class ItunesConnect - # All the private helpers - - private - - # Opens the app details page of the given app. - # @param app (Deliver::App) the app that should be opened - # @return (bool) true if everything worked fine - # @raise [ItunesConnectGeneralError] General error while executing - # this action - # @raise [ItunesConnectLoginError] Login data is wrong - def open_app_page(app) - verify_app(app) - - Helper.log.info "Opening detail page for app #{app}" - - visit APP_DETAILS_URL.gsub("[[app_id]]", app.apple_id.to_s) - - wait_for_elements('.page-subnav') - sleep 5 - - if current_url.include? "wa/defaultError" # app could not be found - raise "Could not open app details for app '#{app}'. Make sure you're using the correct Apple ID and the correct Apple developer account (#{CredentialsManager::PasswordManager.shared_manager.username}).".red - end - - true - rescue => ex - error_occured(ex) - end - - def verify_app(app) - raise ItunesConnectGeneralError.new("No valid Deliver::App given") unless app.kind_of? Deliver::App - raise ItunesConnectGeneralError.new("App is missing information (apple_id not given)") unless (app.apple_id || '').to_s.length > 5 - end - - def error_occured(ex) - snap - raise ex # re-raise the error after saving the snapshot - end - - def snap - path = File.expand_path("Error#{Time.now.to_i}.png") - # rubocop:disable Lint/Debugger - save_screenshot(path, full: true) - # rubocop:enable Lint/Debugger - system("open '#{path}'") unless ENV['SIGH_DISABLE_OPEN_ERROR'] - end - - # Since Apple takes for ages, after the upload is properly processed, we have to wait here - def wait_for_preprocessing - started = Time.now - - # Wait, while iTunesConnect is processing the uploaded file - while page.has_content? "Uploaded" - # iTunesConnect is super slow... so we have to wait... - Helper.log.info("Sorry, we have to wait for iTunesConnect, since it's still processing the uploaded ipa file\n" \ - "If this takes longer than 45 minutes, you have to re-upload the ipa file again.\n" \ - "You can always open the browser page yourself: '#{current_url}'\n" \ - "Passed time: ~#{((Time.now - started) / 60.0).to_i} minute(s)") - sleep 30 - visit current_url - sleep 30 - end - 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 caller - raise ItunesConnectGeneralError.new("Couldn't find element '#{name}' after waiting for quite some time") - end - end - return results - end - end -end diff --git a/lib/codes/itunes_connect/itunes_connect_login.rb b/lib/codes/itunes_connect/itunes_connect_login.rb deleted file mode 100644 index a07b020..0000000 --- a/lib/codes/itunes_connect/itunes_connect_login.rb +++ /dev/null @@ -1,65 +0,0 @@ -module Codes - # Login code - class ItunesConnect - # Loggs in a user with the given login data on the iTC Frontend. - # You don't need to pass a username and password. It will - # Automatically be fetched using the {CredentialsManager::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 [ItunesConnectGeneralError] General error while executing - # this action - # @raise [ItunesConnectLoginError] Login data is wrong - def login(user = nil, password = nil) - Helper.log.info "Logging into iTunesConnect" - - user ||= CredentialsManager::PasswordManager.shared_manager.username - password ||= CredentialsManager::PasswordManager.shared_manager.password - - result = visit ITUNESCONNECT_URL - raise "Could not open iTunesConnect" unless result['status'] == 'success' - - sleep 3 - - if page.has_content? "My Apps" - # Already logged in - return true - end - - begin - wait_for_elements('#accountpassword') - rescue - # when the user is already logged in, this will raise an exception - end - - fill_in "accountname", with: user - fill_in "accountpassword", with: password - - begin - page.evaluate_script "appleConnectForm.submit()" - sleep 7 - - if page.has_content? "My Apps" - # Everything looks good - else - visit current_url # iTC sometimes is super buggy, try reloading the site - sleep 3 - unless page.has_content? "My Apps" - raise ItunesConnectLoginError.new("Looks like your login data was correct, but you do not have access to the apps.".red) - end - end - rescue => ex - Helper.log.debug(ex) - raise ItunesConnectLoginError.new("Error logging in user #{user} with the given password. Make sure you entered them correctly.".red) - end - - Helper.log.info "Successfully logged into iTunesConnect" - - true - rescue => ex - error_occured(ex) - end - end -end diff --git a/lib/codes/version.rb b/lib/codes/version.rb index 3375942..20541c2 100644 --- a/lib/codes/version.rb +++ b/lib/codes/version.rb @@ -1,3 +1,3 @@ module Codes - VERSION = '0.4.2' + VERSION = '0.5.0'.freeze end