spaceship 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,399 @@
1
+ require 'faraday' # HTTP Client
2
+ require 'faraday_middleware'
3
+ require 'spaceship/ui'
4
+ require 'spaceship/helper/plist_middleware'
5
+ require 'spaceship/helper/net_http_generic_request'
6
+
7
+ if ENV['DEBUG']
8
+ require 'pry' # TODO: Remove
9
+ require 'openssl'
10
+ OpenSSL::SSL::VERIFY_PEER = OpenSSL::SSL::VERIFY_NONE
11
+ end
12
+
13
+
14
+ module Spaceship
15
+ class Client
16
+ PROTOCOL_VERSION = "QH65B2"
17
+
18
+ attr_reader :client
19
+ attr_accessor :cookie
20
+
21
+ class InvalidUserCredentialsError < StandardError; end
22
+ class UnexpectedResponse < StandardError; end
23
+
24
+ # Authenticates with Apple's web services. This method has to be called once
25
+ # to generate a valid session. The session will automatically be used from then
26
+ # on.
27
+ #
28
+ # This method will automatically use the username from the Appfile (if available)
29
+ # and fetch the password from the Keychain (if available)
30
+ #
31
+ # @param user (String) (optional): The username (usually the email address)
32
+ # @param password (String) (optional): The password
33
+ #
34
+ # @raise InvalidUserCredentialsError: raised if authentication failed
35
+ #
36
+ # @return (Spaceship::Client) The client the login method was called for
37
+ def self.login(user = nil, password = nil)
38
+ instance = self.new
39
+ if instance.login(user, password)
40
+ instance
41
+ else
42
+ raise InvalidUserCredentialsError.new
43
+ end
44
+ end
45
+
46
+ def initialize
47
+ @client = Faraday.new("https://developer.apple.com/services-account/#{PROTOCOL_VERSION}/") do |c|
48
+ c.response :json, :content_type => /\bjson$/
49
+ c.response :xml, :content_type => /\bxml$/
50
+ c.response :plist, :content_type => /\bplist$/
51
+ c.adapter Faraday.default_adapter
52
+
53
+ if ENV['DEBUG']
54
+ # for debugging:
55
+ c.response :logger
56
+ c.proxy "https://127.0.0.1:8888"
57
+ end
58
+ end
59
+ end
60
+
61
+ def api_key
62
+ page = @client.get("https://developer.apple.com/devcenter/ios/index.action").body
63
+ if page =~ %r{<a href="https://idmsa.apple.com/IDMSWebAuth/login\?.*appIdKey=(\h+)}
64
+ return $1
65
+ end
66
+ end
67
+
68
+ # Automatic paging
69
+
70
+ def page_size
71
+ @page_size ||= 500
72
+ end
73
+
74
+ # Handles the paging for you... for free
75
+ # Just pass a block and use the parameter as page number
76
+ def paging
77
+ page = 0
78
+ results = []
79
+ loop do
80
+ page += 1
81
+ current = yield(page)
82
+
83
+ results = results + current
84
+
85
+ break if ((current || []).count < page_size) # no more results
86
+ end
87
+
88
+ return results
89
+ end
90
+
91
+ # Authenticates with Apple's web services. This method has to be called once
92
+ # to generate a valid session. The session will automatically be used from then
93
+ # on.
94
+ #
95
+ # This method will automatically use the username from the Appfile (if available)
96
+ # and fetch the password from the Keychain (if available)
97
+ #
98
+ # @param user (String) (optional): The username (usually the email address)
99
+ # @param password (String) (optional): The password
100
+ #
101
+ # @raise InvalidUserCredentialsError: raised if authentication failed
102
+ #
103
+ # @return (Spaceship::Client) The client the login method was called for
104
+ def login(user = nil, password = nil)
105
+ if user.to_s.empty? or password.to_s.empty?
106
+ require 'credentials_manager'
107
+ data = CredentialsManager::PasswordManager.shared_manager(user, false)
108
+ user ||= data.username
109
+ password ||= data.password
110
+ end
111
+
112
+ if user.to_s.empty? or password.to_s.empty?
113
+ raise InvalidUserCredentialsError.new("No login data provided")
114
+ end
115
+
116
+ response = request(:post, "https://idmsa.apple.com/IDMSWebAuth/authenticate", {
117
+ appleId: user,
118
+ accountPassword: password,
119
+ appIdKey: api_key
120
+ })
121
+
122
+ if response['Set-Cookie'] =~ /myacinfo=(\w+);/
123
+ @cookie = "myacinfo=#{$1};"
124
+ return @client
125
+ else
126
+ # User Credentials are wrong
127
+ raise InvalidUserCredentialsError.new(response)
128
+ end
129
+ end
130
+
131
+ def session?
132
+ !!@cookie
133
+ end
134
+
135
+ def teams
136
+ r = request(:post, 'account/listTeams.action')
137
+ parse_response(r, 'teams')
138
+ end
139
+
140
+ def team_id
141
+ return ENV['FASTLANE_TEAM_ID'] if ENV['FASTLANE_TEAM_ID']
142
+ return @current_team_id if @current_team_id
143
+
144
+ if teams.count > 1
145
+ puts "The current user is in #{teams.count} teams. Pass a team ID or call `select_team` to choose a team. Using the first one for now."
146
+ end
147
+ @current_team_id ||= teams[0]['teamId']
148
+ end
149
+
150
+ def select_team
151
+ @current_team_id = self.UI.select_team
152
+ end
153
+
154
+ def current_team_id=(team_id)
155
+ @current_team_id = team_id
156
+ end
157
+
158
+ def apps
159
+ paging do |page_number|
160
+ r = request(:post, 'account/ios/identifiers/listAppIds.action', {
161
+ teamId: team_id,
162
+ pageNumber: page_number,
163
+ pageSize: page_size,
164
+ sort: 'name=asc'
165
+ })
166
+ parse_response(r, 'appIds')
167
+ end
168
+ end
169
+
170
+ def create_app!(type, name, bundle_id)
171
+ ident_params = case type.to_sym
172
+ when :explicit
173
+ {
174
+ type: 'explicit',
175
+ explicitIdentifier: bundle_id,
176
+ appIdentifierString: bundle_id,
177
+ push: 'on',
178
+ inAppPurchase: 'on',
179
+ gameCenter: 'on'
180
+ }
181
+ when :wildcard
182
+ {
183
+ type: 'wildcard',
184
+ wildcardIdentifier: bundle_id,
185
+ appIdentifierString: bundle_id
186
+ }
187
+ end
188
+
189
+ params = {
190
+ appIdName: name,
191
+ teamId: team_id
192
+ }
193
+
194
+ params.merge!(ident_params)
195
+
196
+ r = request(:post, 'account/ios/identifiers/addAppId.action', params)
197
+ parse_response(r, 'appId')
198
+ end
199
+
200
+ def delete_app!(app_id)
201
+ r = request(:post, 'account/ios/identifiers/deleteAppId.action', {
202
+ teamId: team_id,
203
+ appIdId: app_id
204
+ })
205
+ parse_response(r)
206
+ end
207
+
208
+ def devices
209
+ paging do |page_number|
210
+ r = request(:post, 'account/ios/device/listDevices.action', {
211
+ teamId: team_id,
212
+ pageNumber: page_number,
213
+ pageSize: page_size,
214
+ sort: 'name=asc'
215
+ })
216
+ parse_response(r, 'devices')
217
+ end
218
+ end
219
+
220
+ def create_device!(device_name, device_id)
221
+ r = request(:post) do |r|
222
+ r.url "https://developerservices2.apple.com/services/#{PROTOCOL_VERSION}/ios/addDevice.action"
223
+ r.params = {
224
+ teamId: team_id,
225
+ deviceNumber: device_id,
226
+ name: device_name
227
+ }
228
+ end
229
+
230
+ parse_response(r, 'device')
231
+ end
232
+
233
+ def certificates(types)
234
+ paging do |page_number|
235
+ r = request(:post, 'account/ios/certificate/listCertRequests.action', {
236
+ teamId: team_id,
237
+ types: types.join(','),
238
+ pageNumber: page_number,
239
+ pageSize: page_size,
240
+ sort: 'certRequestStatusCode=asc'
241
+ })
242
+ parse_response(r, 'certRequests')
243
+ end
244
+ end
245
+
246
+ def create_certificate!(type, csr, app_id = nil)
247
+ r = request(:post, 'account/ios/certificate/submitCertificateRequest.action', {
248
+ teamId: team_id,
249
+ type: type,
250
+ csrContent: csr,
251
+ appIdId: app_id #optional
252
+ })
253
+ parse_response(r, 'certRequest')
254
+ end
255
+
256
+ def download_certificate(certificate_id, type)
257
+ {type: type, certificate_id: certificate_id}.each { |k, v| raise "#{k} must not be nil" if v.nil? }
258
+
259
+ r = request(:post, 'https://developer.apple.com/account/ios/certificate/certificateContentDownload.action', {
260
+ displayId: certificate_id,
261
+ type: type
262
+ })
263
+ parse_response(r)
264
+ end
265
+
266
+ def revoke_certificate!(certificate_id, type)
267
+ r = request(:post, 'account/ios/certificate/revokeCertificate.action', {
268
+ teamId: team_id,
269
+ certificateId: certificate_id,
270
+ type: type
271
+ })
272
+ parse_response(r, 'certRequests')
273
+ end
274
+
275
+ def provisioning_profiles
276
+ r = request(:post) do |r|
277
+ r.url "https://developerservices2.apple.com/services/#{PROTOCOL_VERSION}/ios/listProvisioningProfiles.action"
278
+ r.params = {
279
+ teamId: team_id,
280
+ includeInactiveProfiles: true,
281
+ onlyCountLists: true,
282
+ }
283
+ end
284
+
285
+ parse_response(r, 'provisioningProfiles')
286
+ end
287
+
288
+ def create_provisioning_profile!(name, distribution_method, app_id, certificate_ids, device_ids)
289
+ r = request(:post, 'account/ios/profile/createProvisioningProfile.action', {
290
+ teamId: team_id,
291
+ provisioningProfileName: name,
292
+ appIdId: app_id,
293
+ distributionType: distribution_method,
294
+ certificateIds: certificate_ids,
295
+ deviceIds: device_ids
296
+ })
297
+ parse_response(r, 'provisioningProfile')
298
+ end
299
+
300
+ def download_provisioning_profile(profile_id)
301
+ r = request(:get, 'https://developer.apple.com/account/ios/profile/profileContentDownload.action', {
302
+ teamId: team_id,
303
+ displayId: profile_id
304
+ })
305
+ parse_response(r)
306
+ end
307
+
308
+ def delete_provisioning_profile!(profile_id)
309
+ r = request(:post, 'account/ios/profile/deleteProvisioningProfile.action', {
310
+ teamId: team_id,
311
+ provisioningProfileId: profile_id
312
+ })
313
+ parse_response(r)
314
+ end
315
+
316
+ def repair_provisioning_profile!(profile_id, name, distribution_method, app_id, certificate_ids, device_ids)
317
+ r = request(:post, 'account/ios/profile/regenProvisioningProfile.action', {
318
+ teamId: team_id,
319
+ provisioningProfileId: profile_id,
320
+ provisioningProfileName: name,
321
+ appIdId: app_id,
322
+ distributionType: distribution_method,
323
+ certificateIds: certificate_ids,
324
+ deviceIds: device_ids
325
+ })
326
+
327
+ parse_response(r, 'provisioningProfile')
328
+ end
329
+
330
+ private
331
+ # Is called from `parse_response` to store
332
+ def store_csrf_tokens(response)
333
+ if response and response.headers
334
+ tokens = response.headers.select { |k, v| %w[csrf csrf_ts].include?(k) }
335
+ if tokens and not tokens.empty?
336
+ @csrf_tokens = tokens
337
+ end
338
+ end
339
+ end
340
+ ##
341
+ # memoize the last csrf tokens from responses
342
+ def csrf_tokens
343
+ @csrf_tokens || {}
344
+ end
345
+
346
+ def request(method, url_or_path = nil, params = nil, headers = {}, &block)
347
+ if session?
348
+ headers.merge!({'Cookie' => cookie})
349
+ headers.merge!(csrf_tokens)
350
+ end
351
+ headers.merge!({'User-Agent' => 'spaceship'})
352
+
353
+ # form-encode the params only if there are params, and the block is not supplied.
354
+ # this is so that certain requests can be made using the block for more control
355
+ if method == :post && params && !block_given?
356
+ params, headers = encode_params(params, headers)
357
+ end
358
+
359
+ send_request(method, url_or_path, params, headers, &block)
360
+ end
361
+
362
+ # Actually sends the request to the remote server
363
+ # Automatically retries the request up to 3 times if something goes wrong
364
+ def send_request(method, url_or_path, params, headers, &block)
365
+ tries ||= 5
366
+
367
+ return @client.send(method, url_or_path, params, headers, &block)
368
+
369
+ rescue Faraday::TimeoutError => ex
370
+ unless (tries -= 1).zero?
371
+ sleep 3
372
+ retry
373
+ end
374
+
375
+ raise ex # re-raise the exception
376
+ end
377
+
378
+ def parse_response(response, expected_key = nil)
379
+ if expected_key
380
+ content = response.body[expected_key]
381
+ else
382
+ content = response.body
383
+ end
384
+
385
+ if content.nil?
386
+ raise UnexpectedResponse.new(response.body)
387
+ else
388
+ store_csrf_tokens(response)
389
+ content
390
+ end
391
+ end
392
+
393
+ def encode_params(params, headers)
394
+ params = Faraday::Utils::ParamsHash[params].to_query
395
+ headers = {'Content-Type' => 'application/x-www-form-urlencoded'}.merge(headers)
396
+ return params, headers
397
+ end
398
+ end
399
+ end
@@ -0,0 +1,100 @@
1
+ module Spaceship
2
+ # Represents a device from the Apple Developer Portal
3
+ class Device < Base
4
+ # @return (String) The ID given from the developer portal. You'll probably not need it.
5
+ # @example
6
+ # "XJXGVS46MW"
7
+ attr_accessor :id
8
+
9
+ # @return (String) The name of the device
10
+ # @example
11
+ # "Felix Krause's iPhone 6"
12
+ attr_accessor :name
13
+
14
+ # @return (String) The UDID of the device
15
+ # @example
16
+ # "4c24a7ee5caaa4847f49aaab2d87483053f53b65"
17
+ attr_accessor :udid
18
+
19
+ # @return (String) The platform of the device. This is probably always "ios"
20
+ # @example
21
+ # "ios"
22
+ attr_accessor :platform
23
+
24
+ # @return (String) Status of the device. Probably always "c"
25
+ # @example
26
+ # "c"
27
+ attr_accessor :status
28
+
29
+ attr_mapping({
30
+ 'deviceId' => :id,
31
+ 'name' => :name,
32
+ 'deviceNumber' => :udid,
33
+ 'devicePlatform' => :platform,
34
+ 'status' => :status
35
+ })
36
+
37
+ class << self
38
+ # Create a new object based on a hash.
39
+ # This is used to create a new object based on the server response.
40
+ def factory(attrs)
41
+ self.new(attrs)
42
+ end
43
+
44
+ # @return (Array) Returns all devices registered for this account
45
+ def all
46
+ client.devices.map { |device| self.factory(device) }
47
+ end
48
+
49
+ # @return (Device) Find a device based on the ID of the device. *Attention*:
50
+ # This is *not* the UDID. nil if no device was found.
51
+ def find(device_id)
52
+ all.find do |device|
53
+ device.id == device_id
54
+ end
55
+ end
56
+
57
+ # @return (Device) Find a device based on the UDID of the device. nil if no device was found.
58
+ def find_by_udid(device_udid)
59
+ all.find do |device|
60
+ device.udid == device_udid
61
+ end
62
+ end
63
+
64
+ # @return (Device) Find a device based on its name. nil if no device was found.
65
+ def find_by_name(device_name)
66
+ all.find do |device|
67
+ device.name == device_name
68
+ end
69
+ end
70
+
71
+ # Register a new device to this account
72
+ # @param name (String) (required): The name of the new device
73
+ # @param udid (String) (required): The UDID of the new device
74
+ # @example
75
+ # Spaceship.device.create!(name: "Felix Krause's iPhone 6", udid: "4c24a7ee5caaa4847f49aaab2d87483053f53b65")
76
+ # @return (Device): The newly created device
77
+ def create!(name: nil, udid: nil)
78
+ # Check whether the user has passed in a UDID and a name
79
+ unless (udid and name)
80
+ raise "You cannot create a device without a device_id (UDID) and name"
81
+ end
82
+
83
+ # Find the device by UDID, raise an exception if it already exists
84
+ if self.find_by_udid(udid)
85
+ raise "The device UDID '#{udid}' already exists on this team."
86
+ end
87
+
88
+ # Find the device by name, raise an exception if it already exists
89
+ if self.find_by_name(name)
90
+ raise "The device name '#{name}' already exists on this team, use different one."
91
+ end
92
+
93
+ device = client.create_device!(name, udid)
94
+
95
+ # Update self with the new device
96
+ self.new(device)
97
+ end
98
+ end
99
+ end
100
+ end