spaceship 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: cb67ea61ddcb8fc09c87de04eb400be6a4c0c9e2
4
- data.tar.gz: c62c7bc6307daf12b7dd9a890c30241e633ec02c
3
+ metadata.gz: bb6191ed6a0a0d23338cc28f0f31d3160a1f2a99
4
+ data.tar.gz: 536a34d8e0ea5fa2240599de967fb5e221dceee1
5
5
  SHA512:
6
- metadata.gz: 86c17cf18e9949250db8d312822188f2896ba4beff339fd949d876fc0e9bd4c2b017d58dc18bcade5a831682ab8f84cddcdd3d640cbf1fe4b2d22b33d7e7cb38
7
- data.tar.gz: bd449519839313194d8128f72b6f1fa74f4a89b03293f35b33b692cf5e638d1014f03ccc01db8fb1dd73db8270dc4a77c5559f52f6f8182725f60ffa442766a7
6
+ metadata.gz: 3de66c022d9654a8a56360afc24b03824a231bf90aca2e3d06981216def52bc1633de822496a89e30db3103c714a31160633c15d5da8b8bb2119f0181ec3d281
7
+ data.tar.gz: ba9fe63a3d4410d47a8956d650e08521abc3afd016977c042ff6d55616882f23cb8b36b269fee2cdb26e8d578699e6414a7ae6ffd1e9b02aa2ffa3618b8357e8
data/README.md CHANGED
@@ -149,6 +149,8 @@ I won't go into too much technical details about the various API endpoints, but
149
149
  - Managing beta testers
150
150
  - Submitting updates to review
151
151
  - Manaing app metadata
152
+ - `https://du-itc.itunesconnect.apple.com`:
153
+ - Upload icons, screenshots, trailers ...
152
154
 
153
155
  `spaceship` uses all those API points to offer this seamless experience.
154
156
 
@@ -27,9 +27,11 @@ module Spaceship
27
27
  def get(*keys)
28
28
  lookup(keys)
29
29
  end
30
- alias [] get
30
+
31
+ alias_method :[], :get
31
32
 
32
33
  def set(keys, value)
34
+ raise "'keys' must be an array, got #{keys.class} instead" unless keys.kind_of?(Array)
33
35
  last = keys.pop
34
36
  ref = lookup(keys) || @hash
35
37
  ref[last] = value
@@ -18,6 +18,9 @@ module Spaceship
18
18
  attr_reader :client
19
19
  attr_accessor :cookie
20
20
 
21
+ # The user that is currently logged in
22
+ attr_accessor :user
23
+
21
24
  # The logger in which all requests are logged
22
25
  # /tmp/spaceship[time].log by default
23
26
  attr_accessor :logger
@@ -148,6 +151,7 @@ module Spaceship
148
151
  raise NoUserCredentialsError.new, "No login data provided"
149
152
  end
150
153
 
154
+ self.user = user
151
155
  send_login_request(user, password) # different in subclasses
152
156
  end
153
157
 
@@ -186,10 +190,10 @@ module Spaceship
186
190
 
187
191
  def request(method, url_or_path = nil, params = nil, headers = {}, &block)
188
192
  if session?
189
- headers.merge!({'Cookie' => cookie})
193
+ headers.merge!({ 'Cookie' => cookie })
190
194
  headers.merge!(csrf_tokens)
191
195
  end
192
- headers.merge!({'User-Agent' => 'spaceship'})
196
+ headers.merge!({ 'User-Agent' => 'spaceship' })
193
197
 
194
198
  # Before encoding the parameters, log them
195
199
  log_request(method, url_or_path, params)
@@ -246,7 +250,7 @@ module Spaceship
246
250
 
247
251
  def encode_params(params, headers)
248
252
  params = Faraday::Utils::ParamsHash[params].to_query
249
- headers = {'Content-Type' => 'application/x-www-form-urlencoded'}.merge(headers)
253
+ headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }.merge(headers)
250
254
  return params, headers
251
255
  end
252
256
  end
@@ -0,0 +1,100 @@
1
+ module Spaceship
2
+ # This class is used to upload Digital files (Images, Videos, JSON files) onto the du-itc service.
3
+ # Its implementation is tied to the tunes module (in particular using +AppVersion+ instances)
4
+ class DUClient < Spaceship::Client #:nodoc:
5
+ #####################################################
6
+ # @!group Init and Login
7
+ #####################################################
8
+
9
+ def self.hostname
10
+ "https://du-itc.itunes.apple.com"
11
+ end
12
+
13
+ #####################################################
14
+ # @!group Images
15
+ #####################################################
16
+
17
+ def upload_screenshot(app_version, upload_file, content_provider_id, sso_token_for_image, device)
18
+ upload_file(app_version, upload_file, '/upload/image', content_provider_id, sso_token_for_image, screenshot_picture_type(device))
19
+ end
20
+
21
+ def upload_large_icon(app_version, upload_file, content_provider_id, sso_token_for_image)
22
+ upload_file(app_version, upload_file, '/upload/image', content_provider_id, sso_token_for_image, 'MZPFT.LargeApplicationIcon')
23
+ end
24
+
25
+ def upload_watch_icon(app_version, upload_file, content_provider_id, sso_token_for_image)
26
+ upload_file(app_version, upload_file, '/upload/image', content_provider_id, sso_token_for_image, 'MZPFT.GizmoAppIcon')
27
+ end
28
+
29
+ def upload_geojson(app_version, upload_file, content_provider_id, sso_token_for_image)
30
+ upload_file(app_version, upload_file, '/upload/geo-json', content_provider_id, sso_token_for_image)
31
+ end
32
+
33
+ def upload_trailer(app_version, upload_file, content_provider_id, sso_token_for_video)
34
+ upload_file(app_version, upload_file, '/upload/purple-video', content_provider_id, sso_token_for_video)
35
+ end
36
+
37
+ def upload_video_preview(app_version, upload_file, content_provider_id, sso_token_for_image)
38
+ upload_file(app_version, upload_file, '/upload/app-screenshot-image', content_provider_id, sso_token_for_image)
39
+ end
40
+
41
+ private
42
+
43
+ def upload_file(app_version, upload_file, path, content_provider_id, sso_token, du_validation_rule_set = nil)
44
+ raise "File #{upload_file.file_path} is empty" if upload_file.file_size == 0
45
+
46
+ version = app_version.version
47
+ app_id = app_version.application.apple_id
48
+ app_type = app_version.app_type
49
+
50
+ referrer = app_version.application.url
51
+
52
+ r = request(:post) do |req|
53
+ req.url "#{self.class.hostname}#{path}"
54
+ req.body = upload_file.bytes
55
+ req.headers['Accept'] = 'application/json, text/plain, */*'
56
+ req.headers['Content-Type'] = upload_file.content_type
57
+ req.headers['X-Apple-Upload-Referrer'] = referrer
58
+ req.headers['Referrer'] = referrer
59
+ req.headers['X-Apple-Upload-AppleId'] = app_id
60
+ req.headers['X-Apple-Jingle-Correlation-Key'] = "#{app_type}:AdamId=#{app_id}:Version=#{version}"
61
+ req.headers['X-Apple-Upload-itctoken'] = sso_token
62
+ req.headers['X-Apple-Upload-ContentProviderId'] = content_provider_id
63
+ req.headers['X-Original-Filename'] = upload_file.file_name
64
+ req.headers['X-Apple-Upload-Validation-RuleSets'] = du_validation_rule_set if du_validation_rule_set
65
+ req.headers['Content-Length'] = "#{upload_file.file_size}"
66
+ req.headers['Connection'] = "keep-alive"
67
+ end
68
+ parse_upload_response(r)
69
+ end
70
+
71
+ def picture_type_map
72
+ # rubocop:enable Style/ExtraSpacing
73
+ {
74
+ watch: "MZPFT.SortedN27ScreenShot",
75
+ ipad: "MZPFT.SortedTabletScreenShot",
76
+ iphone6: "MZPFT.SortedN61ScreenShot",
77
+ iphone6Plus: "MZPFT.SortedN56ScreenShot",
78
+ iphone4: "MZPFT.SortedN41ScreenShot",
79
+ iphone35: "MZPFT.SortedScreenShot"
80
+ }
81
+ end
82
+
83
+ def screenshot_picture_type(device)
84
+ device = device.to_sym
85
+ raise "Unknown picture type for device: #{device}" unless picture_type_map.key? device
86
+ picture_type_map[device]
87
+ end
88
+
89
+ def parse_upload_response(response)
90
+ content = response.body
91
+ if !content['statusCode'].nil? && content['statusCode'] != 200
92
+ error_codes = ""
93
+ error_codes = content['errorCodes'].join(',') unless content['errorCodes'].nil?
94
+ error_message = "[#{error_codes}] #{content['localizedMessage']}"
95
+ raise UnexpectedResponse.new, error_message
96
+ end
97
+ content
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,45 @@
1
+ module Spaceship
2
+ # a wrapper around the concept of file required to make uploads to DU
3
+ class UploadFile
4
+ attr_reader :file_path
5
+ attr_reader :file_name
6
+ attr_reader :file_size
7
+ attr_reader :content_type
8
+ attr_reader :bytes
9
+
10
+ class << self
11
+ def from_path(path)
12
+ raise "Image must exists at path: #{path}" unless File.exist?(path)
13
+ path = remove_alpha_channel(path) if File.extname(path).downcase == '.png'
14
+
15
+ content_type = Utilities.content_type(path)
16
+ self.new(
17
+ file_path: path,
18
+ file_name: File.basename(path),
19
+ file_size: File.size(path),
20
+ content_type: content_type,
21
+ bytes: File.read(path)
22
+ )
23
+ end
24
+
25
+ # As things like screenshots and app icon shouldn't contain the alpha channel
26
+ # This will copy the image into /tmp to remove the alpha channel there
27
+ # That's done to not edit the original image
28
+ def remove_alpha_channel(original)
29
+ path = "/tmp/#{Digest::MD5.hexdigest(original)}.png"
30
+ FileUtils.copy(original, path)
31
+ `sips -s format bmp '#{path}' &> /dev/null ` # &> /dev/null since there is warning because of the extension
32
+ `sips -s format png '#{path}'`
33
+ return path
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def initialize(args)
40
+ args.each do |k, v|
41
+ instance_variable_set("@#{k}", v) unless v.nil?
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,75 @@
1
+ require 'fastimage'
2
+ require "English"
3
+
4
+ module Spaceship
5
+ # Set of utility methods useful to work with media files
6
+ module Utilities #:nodoc:
7
+ # Identifies the content_type of a file based on its file name extension.
8
+ # Supports all formats required by DU-UTC right now (video, images and json)
9
+ # @param path (String) the path to the file
10
+ def content_type(path)
11
+ path = path.downcase
12
+ return 'image/jpeg' if path.end_with?('.jpg')
13
+ return 'image/png' if path.end_with?('.png')
14
+ return 'application/json' if path.end_with?('.geojson')
15
+ return 'video/quicktime' if path.end_with?('.mov')
16
+ return 'video/mp4' if path.end_with?('.m4v')
17
+ return 'video/mp4' if path.end_with?('.mp4')
18
+ raise "Unknown content-type for file #{path}"
19
+ end
20
+
21
+ # Identifies the resolution of a video or an image.
22
+ # Supports all video and images required by DU-UTC right now
23
+ # @param path (String) the path to the file
24
+ def resolution(path)
25
+ return FastImage.size(path) if content_type(path).start_with?("image")
26
+ return video_resolution(path) if content_type(path).start_with?("video")
27
+ raise "Cannot find resolution of file #{path}"
28
+ end
29
+
30
+ # Is the video or image in portrait mode ?
31
+ # Supports all video and images required by DU-UTC right now
32
+ # @param path (String) the path to the file
33
+ def portrait?(path)
34
+ resolution = resolution(path)
35
+ resolution[0] < resolution[1]
36
+ end
37
+
38
+ # Grabs a screenshot from the specified video at the specified timestamp using `ffmpeg`
39
+ # @param video_path (String) the path to the video file
40
+ # @param timestamp (String) the `ffmpeg` timestamp format (e.g. 00.00)
41
+ # @param dimensions (Array) the dimension of the screenshot to generate
42
+ # @return the path to the TempFile containing the generated screenshot
43
+ def grab_video_preview(video_path, timestamp, dimensions)
44
+ width, height = dimensions
45
+ require 'tempfile'
46
+ tmp = Tempfile.new(['video_preview', ".jpg"])
47
+ file = tmp.path
48
+ command = "ffmpeg -y -i \"#{video_path}\" -s #{width}x#{height} -ss \"#{timestamp}\" -vframes 1 \"#{file}\" 2>&1 >/dev/null"
49
+ # puts "COMMAND: #{command}"
50
+ `#{command}`
51
+ raise "Failed to grab screenshot at #{timestamp} from #{video_path} (using #{command})" unless $CHILD_STATUS.to_i == 0
52
+ tmp.path
53
+ end
54
+
55
+ # identifies the resolution of a video using `ffmpeg`
56
+ # @param video_path (String) the path to the video file
57
+ # @return [Array] the resolution of the video
58
+ def video_resolution(video_path)
59
+ command = "ffmpeg -i \"#{video_path}\" 2>&1"
60
+ # puts "COMMAND: #{command}"
61
+ output = `#{command}`
62
+ # Note: ffmpeg exits with 1 if no output specified
63
+ # raise "Failed to find video information from #{video_path} (using #{command})" unless $CHILD_STATUS.to_i == 0
64
+ output = output.force_encoding("BINARY")
65
+ video_infos = output.split("\n").select { |l| l =~ /Stream.*Video/ }
66
+ raise "Unable to find Stream Video information from ffmpeg output of #{command}" if video_infos.count == 0
67
+ video_info = video_infos[0]
68
+ res = video_info.match(/.* ([0-9]+)x([0-9]+),.*/)
69
+ raise "Unable to parse resolution information from #{video_info}" if res.count < 3
70
+ [res[1].to_i, res[2].to_i]
71
+ end
72
+
73
+ module_function :content_type, :grab_video_preview, :portrait?, :resolution, :video_resolution
74
+ end
75
+ end
@@ -5,8 +5,10 @@ require 'net/http'
5
5
  # Certain apple endpoints return 415 responses if a Content-Type is supplied.
6
6
  # Net::HTTP will default a content-type if none is provided by faraday
7
7
  # This monkey-patch allows us to leave out the content-type if we do not specify one.
8
- class Net::HTTPGenericRequest
9
- def supply_default_content_type
10
- return if content_type
8
+ module Net
9
+ class HTTPGenericRequest
10
+ def supply_default_content_type
11
+ return if content_type
12
+ end
11
13
  end
12
14
  end
@@ -2,7 +2,6 @@ module Spaceship
2
2
  module Portal
3
3
  # Represents an App ID from the Developer Portal
4
4
  class App < PortalBase
5
-
6
5
  # @return (String) The identifier of this app, provided by the Dev Portal
7
6
  # @example
8
7
  # "RGAWZGXSAA"
@@ -82,9 +82,9 @@ module Spaceship
82
82
 
83
83
  def ==(other)
84
84
  self.class == other.class &&
85
- self.service_id == other.service_id &&
86
- self.value == other.value &&
87
- self.service_uri == other.service_uri
85
+ self.service_id == other.service_id &&
86
+ self.value == other.value &&
87
+ self.service_uri == other.service_uri
88
88
  end
89
89
 
90
90
  #
@@ -1,6 +1,5 @@
1
1
  module Spaceship
2
2
  class PortalClient < Spaceship::Client
3
-
4
3
  #####################################################
5
4
  # @!group Init and Login
6
5
  #####################################################
@@ -274,7 +273,7 @@ module Spaceship
274
273
  end
275
274
 
276
275
  def download_certificate(certificate_id, type)
277
- {type: type, certificate_id: certificate_id}.each { |k, v| raise "#{k} must not be nil" if v.nil? }
276
+ { type: type, certificate_id: certificate_id }.each { |k, v| raise "#{k} must not be nil" if v.nil? }
278
277
 
279
278
  r = request(:post, 'https://developer.apple.com/account/ios/certificate/certificateContentDownload.action', {
280
279
  teamId: team_id,
@@ -222,7 +222,7 @@ module Spaceship
222
222
  self.type,
223
223
  app.app_id,
224
224
  certificate_parameter,
225
- devices.map(&:id) )
225
+ devices.map(&:id))
226
226
  end
227
227
 
228
228
  self.new(profile)
@@ -256,7 +256,6 @@ module Spaceship
256
256
  profile.app.bundle_id == bundle_id
257
257
  end
258
258
  end
259
-
260
259
  end
261
260
 
262
261
  # Represents a Development profile from the Dev Portal
@@ -63,6 +63,12 @@ module Spaceship
63
63
  # Push all changes that were made back to iTunes Connect
64
64
  def save!
65
65
  client.update_app_details!(application.apple_id, raw_data)
66
+ rescue Spaceship::TunesClient::ITunesConnectError => ex
67
+ if ex.to_s == "operation_failed"
68
+ # That's alright, we get this error message if nothing has changed
69
+ else
70
+ raise ex
71
+ end
66
72
  end
67
73
 
68
74
  # Custom Setters
@@ -0,0 +1,53 @@
1
+ module Spaceship
2
+ module Tunes
3
+ # Represents an image hosted on iTunes Connect. Used for icons, screenshots, etc
4
+ class AppImage < TunesBase
5
+ HOST_URL = "https://is1-ssl.mzstatic.com/image/thumb"
6
+
7
+ attr_accessor :asset_token
8
+
9
+ attr_accessor :sort_order
10
+
11
+ attr_accessor :original_file_name
12
+
13
+ attr_accessor :url
14
+
15
+ attr_mapping(
16
+ 'assetToken' => :asset_token,
17
+ 'sortOrder' => :sort_order,
18
+ 'url' => :url,
19
+ 'originalFileName' => :original_file_name
20
+ )
21
+
22
+ class << self
23
+ def factory(attrs)
24
+ self.new(attrs)
25
+ end
26
+ end
27
+
28
+ def reset!(attrs = {})
29
+ update_raw_data!(
30
+ {
31
+ asset_token: nil,
32
+ original_file_name: nil,
33
+ sort_order: nil,
34
+ url: nil
35
+ }.merge(attrs)
36
+ )
37
+ end
38
+
39
+ def setup
40
+ # Since September 2015 we don't get the url any more, so we have to manually build it
41
+ self.url = "#{HOST_URL}/#{self.asset_token}/0x0ss.jpg"
42
+ end
43
+
44
+ private
45
+
46
+ def update_raw_data!(hash)
47
+ hash.each do |k, v|
48
+ self.send("#{k}=", v)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,27 +1,16 @@
1
1
  module Spaceship
2
2
  module Tunes
3
3
  # Represents a screenshot hosted on iTunes Connect
4
- class AppScreenshot
5
-
6
- attr_accessor :thumbnail_url
7
-
8
- attr_accessor :sort_order
9
-
10
- attr_accessor :original_file_name
11
-
12
- attr_accessor :url
13
-
4
+ class AppScreenshot < Spaceship::Tunes::AppImage
14
5
  attr_accessor :device_type
15
6
 
16
7
  attr_accessor :language
17
8
 
18
- def initialize(hash)
19
- self.thumbnail_url = hash[:thumbnail_url]
20
- self.sort_order = hash[:sort_order]
21
- self.original_file_name = hash[:original_file_name]
22
- self.url = hash[:url]
23
- self.device_type = hash[:device_type]
24
- self.language = hash[:language]
9
+ class << self
10
+ # Create a new object based on a hash.
11
+ def factory(attrs)
12
+ self.new(attrs)
13
+ end
25
14
  end
26
15
  end
27
16
  end