-
Notifications
You must be signed in to change notification settings - Fork 36
/
ideviceinstaller.rb
374 lines (308 loc) · 11.2 KB
/
ideviceinstaller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
require "tmpdir"
require "fileutils"
require "run_loop"
require "csv"
module Calabash
# A model of the an .ipa - a application binary for iOS devices.
class IPA
# The path to this .ipa.
# @!attribute [r] path
# @return [String] A path to this .ipa.
attr_reader :path
# The bundle identifier of this ipa.
# @!attribute [r] bundle_identifier
# @return [String] The bundle identifier of this ipa; obtained by inspecting
# the app's Info.plist.
attr_reader :bundle_identifier
# Create a new ipa instance.
# @param [String] path_to_ipa The path the .ipa file.
# @return [Calabash::IPA] A new ipa instance.
# @raise [RuntimeError] If the file does not exist.
# @raise [RuntimeError] If the file does not end in .ipa.
def initialize(path_to_ipa)
unless File.exist? path_to_ipa
raise "Expected an ipa at '#{path_to_ipa}'"
end
unless path_to_ipa.end_with?('.ipa')
raise "Expected '#{path_to_ipa}' to be"
end
@path = path_to_ipa
end
# @!visibility private
def to_s
"#<IPA: #{bundle_identifier}: '#{path}'>"
end
# The bundle identifier of this ipa.
# @return [String] A string representation of this ipa's CFBundleIdentifier
# @raise [RuntimeError] If ipa does not expand into a Payload/<app name>.app
# directory.
# @raise [RuntimeError] If an Info.plist does exist in the .app.
def bundle_identifier
unless File.exist?(bundle_dir)
raise "Expected a '#{File.basename(path).split('.').first}.app'\nat path '#{payload_dir}'"
end
@bundle_identifier ||= lambda {
info_plist_path = File.join(bundle_dir, 'Info.plist')
unless File.exist? info_plist_path
raise "Expected an 'Info.plist' at '#{bundle_dir}'"
end
pbuddy = RunLoop::PlistBuddy.new
pbuddy.plist_read('CFBundleIdentifier', info_plist_path)
}.call
end
private
def tmpdir
@tmpdir ||= Dir.mktmpdir
end
def payload_dir
@payload_dir ||= lambda {
FileUtils.cp(path, tmpdir)
zip_path = File.join(tmpdir, File.basename(path))
Dir.chdir(tmpdir) do
system('unzip', *['-q', zip_path])
end
File.join(tmpdir, 'Payload')
}.call
end
def bundle_dir
@bundle_dir ||= lambda {
Dir.glob(File.join(payload_dir, '*')).detect {|f| File.directory?(f) && f.end_with?('.app')}
}.call
end
end
# A wrapper around the ideviceinstaller tool.
#
# @note libimobiledevice, ideviceinstaller, and homebrew are third-party
# tools. Please don't report problems with these tools on the Calabash
# support channels. We don't maintain them, we just use them.
#
# @see http://www.libimobiledevice.org/
# @see https://github.com/libimobiledevice/libimobiledevice
# @see https://github.com/libimobiledevice/ideviceinstaller
# @see http://brew.sh/
class IDeviceInstaller
# The default Retriable and Timeout options. ideviceinstaller is a good
# tool. Experience has shown that it takes no more than 2 tries to
# install an ipa. You can override these defaults by passing arguments
# to the initializer.
DEFAULT_RETRYABLE_OPTIONS =
{
:tries => 2,
:interval => 1,
:timeout => 10
}
# Raised when the ideviceinstaller binary cannot be found.
class BinaryNotFound < RuntimeError; end
# Raised when a IPA instance cannot be created.
class CannotCreateIPA < RuntimeError; end
# Raised when the specified device cannot be found.
class DeviceNotFound < RuntimeError; end
# Raised when there is a problem installing an ipa on the target device.
class InstallError < RuntimeError; end
# Raised when there is a problem uninstalling an ipa on the target device.
class UninstallError < RuntimeError; end
# Raised when the command line invocation of ideviceinstaller fails.
class InvocationError < RuntimeError; end
# The path to the ideviceinstaller binary.
# @!attribute [r] binary
# @return [String] A path to the ideviceinstaller binary.
attr_reader :binary
# The ipa to install.
# @!attribute [r] ipa
# @return [Calabash::IPA] The ipa to install.
attr_reader :ipa
# The udid of the device to install the ipa on.
# @!attribute [r] udid
# @return [String] The udid of the device to install the ipa on.
attr_reader :udid
# The number of times to try any ideviceinstaller command. This is an
# option for Retriable.retriable.
# @!attribute [r] tries
# @return [Numeric] The number of times to try any ideviceinstaller command.
attr_reader :tries
# How long to wait before retrying a failed ideviceinstaller command. This
# is an option for Retriable.retriable.
# @!attribute [r] interval
# @return [Numeric] How long to wait before retrying a failed
# ideviceinstaller command.
attr_reader :interval
# How long to wait for any ideviceinstaller command to complete before
# timing out. This is an option to Timeout.timeout.
# @!attribute [r] timeout
# @return [Numeric] How long to wait for any ideviceinstaller command to
# complete before timing out.
attr_reader :timeout
# Create an instance of an installer.
#
# The target device _must_ be connected via USB to the host machine.
#
# @param [String] path_to_ipa The path to ipa to install.
# @param [String] udid_or_name The udid or name of the device to install
# the ipa on.
# @param [Hash] options Options to control the behavior of the installer.
#
# @option options [Numeric] :tries (2) The number of times to retry a failed
# ideviceinstaller command.
# @option options [Numeric] :interval (1.0) How long to wait before retrying
# a failed ideviceinstaller command.
# @option options [Numeric] :timeout (10) How long to wait for any
# ideviceinstaller command to complete before timing out.
# @option options [String] :path_to_binary (/usr/local/bin/ideviceinstaller)
# The full path the ideviceinstaller library.
#
# @return [Calabash::IDeviceInstaller] A new instance of IDeviceInstaller.
#
# @raise [BinaryNotFound] If no ideviceinstaller binary can be found on the
# system.
# @raise [CannotCreateIPA] If an IPA instance cannot be created from the
# `path_to_ipa`.
# @raise [DeviceNotFound] If the device specified by `udid_or_name` cannot
# be found.
def initialize(path_to_ipa, udid, options={})
merged_opts = DEFAULT_RETRYABLE_OPTIONS.merge(options)
@binary = IDeviceInstaller.expect_binary(merged_opts[:path_to_binary])
begin
@ipa = Calabash::IPA.new(path_to_ipa)
rescue RuntimeError => e
raise CannotCreateIPA, e
end
@udid = udid
@tries = merged_opts[:tries]
@interval = merged_opts[:interval]
@timeout = merged_opts[:timeout]
end
# @!visibility private
def to_s
"#<Installer: #{binary} #{ipa.path} #{udid}>"
end
def app_installed?
command = [binary, "--udid", udid, "--list-apps"]
hash = execute_ideviceinstaller_cmd(command)
out = hash[:out].gsub(/\"/, "")
csv = CSV.new(out, {headers: true}).to_a.map do |row|
row.to_hash
end
csv.detect do |row|
row["CFBundleIdentifier"] == ipa.bundle_identifier
end
end
# Install the ipa on the target device.
#
# @note IMPORTANT If the app is already installed, uninstall it first.
# If you don't want to reinstall, use `ensure_app_installed` instead of
# this method.
#
# @note The 'ideviceinstaller --install' command does not overwrite an app
# if it is already installed on a device.
#
# @see Calabash::IDeviceInstaller#ensure_app_installed
#
# @return [Boolean] Return true if the app was installed.
# @raise [InstallError] If the app was not installed.
# @raise [UninstallError] If the app was not uninstalled.
def install_app
uninstall_app if app_installed?
args = [binary, "--udid", udid, "--install", ipa.path]
result = execute_ideviceinstaller_cmd(args)
if result[:exit_status] != 0
raise InstallError, %Q[
Could not install '#{ipa}' on '#{udid}':
#{result[:out]}
]
end
true
end
# Ensure the ipa has been installed on the device. If the app is already
# installed, do nothing. Otherwise, install the app.
#
# @return [Boolean] Return true if the app was installed.
# @raise [InstallError] If the app was not installed.
def ensure_app_installed
return true if app_installed?
install_app
end
# Uninstall the ipa from the target_device.
#
# @return [Boolean] Return true if the app was uninstalled.
# @raise [UninstallError] If the app was not uninstalled.
def uninstall_app
return true unless app_installed?
command = [binary, "--udid", udid, "--uninstall", ipa.bundle_identifier]
execute_ideviceinstaller_cmd(command)
if app_installed?
raise UninstallError, "Could not uninstall '#{ipa}' on '#{udid}'"
end
true
end
private
def self.homebrew_binary
'/usr/local/bin/ideviceinstaller'
end
def self.binary_in_path
which = self.shell_out('which ideviceinstaller')
if which.nil? || which.empty?
nil
else
which
end
end
def self.shell_out(cmd)
`#{cmd}`.strip
end
def self.select_binary(user_supplied=nil)
user_supplied || self.binary_in_path || self.homebrew_binary
end
def self.expect_binary(user_supplied=nil)
binary = self.select_binary(user_supplied)
unless File.exist?(binary)
if user_supplied
raise BinaryNotFound, "Expected binary at '#{user_supplied}'"
else
raise BinaryNotFound, %Q[
Expected binary to be in $PATH or '/usr/local/bin/ideviceinstaller'
You must install ideviceinstaller to use this class.
We recommend installing ideviceinstaller with homebrew.
]
end
end
binary
end
# Expensive! Avoid calling this more than 1x. This cannot be memoized
# into a class variable or class instance variable because the state will
# change when devices attached/detached or join/leave the network.
def self.available_devices
physical_devices
end
def retriable_intervals
Array.new(tries, interval)
end
def execute_ideviceinstaller_cmd(command)
result = {}
on = [InvocationError]
on_retry = Proc.new do |_, try, elapsed_time, next_interval|
puts "INFO: ideviceinstaller: attempt #{try} failed in '#{elapsed_time}'; will retry in '#{next_interval}'"
end
options =
{
intervals: retriable_intervals,
on_retry: on_retry,
on: on
}
require "retriable"
::Retriable.retriable(options) do
options = {:timeout => 120, :log_cmd => true}
result = RunLoop::Shell.run_shell_command(command, options)
exit_status = result[:exit_status]
if exit_status != 0
raise InvocationError, %Q[
Could not execute:
#{command.join(" ")}
Command generated this output:
#{result[:out]}
]
end
end
result
end
end
end