spaceship 0.7.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
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