run_loop 1.5.1 → 1.5.2

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.
@@ -0,0 +1,544 @@
1
+ module RunLoop
2
+ module LifeCycle
3
+
4
+ class CoreSimulator
5
+
6
+ require 'securerandom'
7
+
8
+ # @!visibility private
9
+ METADATA_PLIST = '.com.apple.mobile_container_manager.metadata.plist'
10
+
11
+ # @!visibility private
12
+ CORE_SIMULATOR_DEVICE_DIR = File.expand_path('~/Library/Developer/CoreSimulator/Devices')
13
+
14
+ # @!visibility private
15
+ # Pattern.
16
+ # [ '< process name >', < send term first > ]
17
+ MANAGED_PROCESSES =
18
+ [
19
+ # This process is a daemon, and requires 'KILL' to terminate.
20
+ # Killing the process is fast, but it takes a long time to
21
+ # restart.
22
+ # ['com.apple.CoreSimulator.CoreSimulatorService', false],
23
+
24
+ # Probably do not need to quit this, but it is tempting to do so.
25
+ #['com.apple.CoreSimulator.SimVerificationService', false],
26
+
27
+ # Started by Xamarin Studio, this is the parent process of the
28
+ # processes launched by Xamarin's interaction with
29
+ # CoreSimulatorBridge.
30
+ ['csproxy', true],
31
+
32
+ # Yes.
33
+ ['SimulatorBridge', true],
34
+ ['configd_sim', true],
35
+ ['launchd_sim', true],
36
+
37
+ # Does not always appear.
38
+ ['CoreSimulatorBridge', true],
39
+
40
+ # Xcode 7
41
+ ['ids_simd', true]
42
+ ]
43
+
44
+ # @!visibility private
45
+ # How long to wait after the simulator has launched.
46
+ SIM_POST_LAUNCH_WAIT = RunLoop::Environment.sim_post_launch_wait || 1.0
47
+
48
+ # @!visibility private
49
+ # How long to wait for for a device to reach a state.
50
+ WAIT_FOR_DEVICE_STATE_OPTS =
51
+ {
52
+ interval: 0.1,
53
+ timeout: 5
54
+ }
55
+
56
+ # @!visibility private
57
+ # How long to wait for the CoreSimulator processes to start.
58
+ WAIT_FOR_SIMULATOR_PROCESSES_OPTS =
59
+ {
60
+ timeout: 5,
61
+ raise_on_timeout: true
62
+ }
63
+
64
+ attr_reader :app
65
+ attr_reader :device
66
+ attr_reader :sim_control
67
+ attr_reader :pbuddy
68
+
69
+ # @param [RunLoop::App] app The application.
70
+ # @param [RunLoop::Device] device The device.
71
+ def initialize(app, device, sim_control=RunLoop::SimControl.new)
72
+ @app = app
73
+ @device = device
74
+ @sim_control = sim_control
75
+
76
+ # In order to manage the app on the device, we need to manage the
77
+ # CoreSimulator processes.
78
+ RunLoop::SimControl.terminate_all_sims
79
+ terminate_core_simulator_processes
80
+ end
81
+
82
+ # Launch simulator without specifying an app.
83
+ def launch_simulator
84
+ sim_path = sim_control.send(:sim_app_path)
85
+ args = ['open', '-g', '-a', sim_path, '--args', '-CurrentDeviceUDID', device.udid]
86
+
87
+ RunLoop.log_debug("Launching #{device} with:")
88
+ RunLoop.log_unix_cmd("xcrun #{args.join(' ')}")
89
+
90
+ start_time = Time.now
91
+
92
+ pid = spawn('xcrun', *args)
93
+ Process.detach(pid)
94
+
95
+ sim_name = sim_control.send(:sim_name)
96
+
97
+ RunLoop::ProcessWaiter.new(sim_name, WAIT_FOR_SIMULATOR_PROCESSES_OPTS).wait_for_any
98
+
99
+ device.simulator_wait_for_stable_state
100
+
101
+ elapsed = Time.now - start_time
102
+ RunLoop.log_debug("Took #{elapsed} seconds to launch the simulator")
103
+
104
+ true
105
+ end
106
+
107
+ # @!visibility private
108
+ def pbuddy
109
+ @pbuddy ||= RunLoop::PlistBuddy.new
110
+ end
111
+
112
+ # @!visibility private
113
+ def sdk_gte_8?
114
+ device.version >= RunLoop::Version.new('8.0')
115
+ end
116
+
117
+ # The data directory for the the device.
118
+ #
119
+ # ~/Library/Developer/CoreSimulator/Devices/<UDID>/data
120
+ def device_data_dir
121
+ @device_data_dir ||= File.join(CORE_SIMULATOR_DEVICE_DIR, device.udid, 'data')
122
+ end
123
+
124
+ # The applications directory for the device.
125
+ #
126
+ # ~/Library/Developer/CoreSimulator/Devices/<UDID>/Containers/Bundle/Application
127
+ def device_applications_dir
128
+ @device_app_dir ||= lambda do
129
+ if sdk_gte_8?
130
+ File.join(device_data_dir, 'Containers', 'Bundle', 'Application')
131
+ else
132
+ File.join(device_data_dir, 'Applications')
133
+ end
134
+ end.call
135
+ end
136
+
137
+ # The sandbox directory for the app.
138
+ #
139
+ # ~/Library/Developer/CoreSimulator/Devices/<UDID>/Containers/Data/Application
140
+ #
141
+ # Contains Library, Documents, and tmp directories.
142
+ def app_sandbox_dir
143
+ app_install_dir = installed_app_bundle_dir
144
+ return nil if app_install_dir.nil?
145
+ if sdk_gte_8?
146
+ app_sandbox_dir_sdk_gte_8
147
+ else
148
+ app_install_dir
149
+ end
150
+ end
151
+
152
+ # The Library directory in the sandbox.
153
+ def app_library_dir
154
+ base_dir = app_sandbox_dir
155
+ if base_dir.nil?
156
+ nil
157
+ else
158
+ File.join(base_dir, 'Library')
159
+ end
160
+ end
161
+
162
+ # The Library/Preferences directory in the sandbox.
163
+ def app_library_preferences_dir
164
+ base_dir = app_library_dir
165
+ if base_dir.nil?
166
+ nil
167
+ else
168
+ File.join(base_dir, 'Preferences')
169
+ end
170
+ end
171
+
172
+ # The Documents directory in the sandbox.
173
+ def app_documents_dir
174
+ base_dir = app_sandbox_dir
175
+ if base_dir.nil?
176
+ nil
177
+ else
178
+ File.join(base_dir, 'Documents')
179
+ end
180
+ end
181
+
182
+ # The tmp directory in the sandbox.
183
+ def app_tmp_dir
184
+ base_dir = app_sandbox_dir
185
+ if base_dir.nil?
186
+ nil
187
+ else
188
+ File.join(base_dir, 'tmp')
189
+ end
190
+ end
191
+
192
+ # Is this app installed?
193
+ def app_is_installed?
194
+ !installed_app_bundle_dir.nil?
195
+ end
196
+
197
+ # The sha1 of the installed app.
198
+ def installed_app_sha1
199
+ installed_bundle = installed_app_bundle_dir
200
+ if installed_bundle
201
+ RunLoop::Directory.directory_digest(installed_bundle)
202
+ else
203
+ nil
204
+ end
205
+ end
206
+
207
+ # Is the app that is install the same as the one we have in hand?
208
+ def same_sha1_as_installed?
209
+ app.sha1 == installed_app_sha1
210
+ end
211
+
212
+ # @!visibility private
213
+ #
214
+ # Returns the path to the installed app bundle directory (.app).
215
+ #
216
+ # If this method returns nil, the app is not installed.
217
+ def installed_app_bundle_dir
218
+ sim_app_dir = device_applications_dir
219
+ return nil if !File.exist?(sim_app_dir)
220
+ Dir.glob("#{sim_app_dir}/**/*.app").find do |path|
221
+ RunLoop::App.new(path).bundle_identifier == app.bundle_identifier
222
+ end
223
+ end
224
+
225
+ # Uninstall the app on the device.
226
+ def uninstall
227
+ installed_app_bundle = installed_app_bundle_dir
228
+ if installed_app_bundle
229
+ uninstall_app_and_sandbox(installed_app_bundle)
230
+ :uninstalled
231
+ else
232
+ RunLoop.log_debug('App was not installed. Nothing to do')
233
+ :not_installed
234
+ end
235
+ end
236
+
237
+ # Install the app on the device.
238
+ def install
239
+ installed_app_bundle = installed_app_bundle_dir
240
+
241
+ # App is not installed.
242
+ return install_new_app if installed_app_bundle.nil?
243
+
244
+ # App is installed but sha1 is different.
245
+ if !same_sha1_as_installed?
246
+ return reinstall_existing_app_and_clear_sandbox(installed_app_bundle)
247
+ end
248
+
249
+ RunLoop.log_debug('The installed app is the same as the app we are trying to install; skipping installation')
250
+ installed_app_bundle
251
+ end
252
+
253
+ # @!visibility private
254
+ #
255
+ # 1. Does nothing if the app is not installed.
256
+ # 2. Does nothing if the app the same as the app that is installed
257
+ # 3. Installs app if it is different from the installed app
258
+ #
259
+ # TODO needs unit tests and a better name?
260
+ def ensure_app_same
261
+ installed_app_bundle = installed_app_bundle_dir
262
+
263
+ if !installed_app_bundle
264
+ RunLoop.log_debug("App: #{app} is not installed")
265
+ return true
266
+ end
267
+
268
+ installed_sha = installed_app_sha1
269
+ app_sha = app.sha1
270
+
271
+ if installed_sha == app_sha
272
+ RunLoop.log_debug("Installed app is the same as #{app}")
273
+ return true
274
+ end
275
+
276
+ RunLoop.log_debug("The app you are trying to launch is not the same as the app that is installed.")
277
+ RunLoop.log_debug(" Installed app SHA: #{installed_sha}")
278
+ RunLoop.log_debug(" App to launch SHA: #{app_sha}")
279
+ RunLoop.log_debug("Will install #{app}")
280
+
281
+
282
+ FileUtils.rm_rf installed_app_bundle
283
+ RunLoop.log_debug('Deleted the existing app')
284
+
285
+ directory = File.expand_path(File.join(installed_app_bundle, '..'))
286
+ bundle_name = File.basename(app.path)
287
+ target = File.join(directory, bundle_name)
288
+
289
+ args = ['ditto', app.path, target]
290
+ RunLoop::Xcrun.new.exec(args, log_cmd: true)
291
+
292
+ RunLoop.log_debug("Installed #{app} on CoreSimulator #{device.udid}")
293
+
294
+ true
295
+ end
296
+
297
+ # Reset app sandbox.
298
+ def reset_app_sandbox
299
+ return true if !app_is_installed?
300
+
301
+ wait_for_device_state('Shutdown')
302
+
303
+ reset_app_sandbox_internal
304
+ end
305
+
306
+ private
307
+
308
+ def generate_uuid
309
+ SecureRandom.uuid.upcase!
310
+ end
311
+
312
+ def existing_app_container_uuids
313
+ if File.exist?(device_applications_dir)
314
+ Dir.entries(device_applications_dir)
315
+ else
316
+ []
317
+ end
318
+ end
319
+
320
+ def generate_unique_uuid(existing, timeout=1.0)
321
+ begin
322
+ Timeout::timeout(timeout, Timeout::Error) do
323
+ uuid = generate_uuid
324
+ loop do
325
+ break if !existing.include?(uuid)
326
+ uuid = generate_uuid
327
+ end
328
+ uuid
329
+ end
330
+ rescue Timeout::Error => _
331
+ raise RuntimeError,
332
+ "Expected to be able to generate a unique uuid in #{timeout} seconds"
333
+ end
334
+ end
335
+
336
+ def install_new_app
337
+ wait_for_device_state('Shutdown')
338
+
339
+ existing = existing_app_container_uuids
340
+ udid = generate_unique_uuid(existing)
341
+ directory = File.join(device_applications_dir, udid)
342
+
343
+ bundle_name = File.basename(app.path)
344
+ target = File.join(directory, bundle_name)
345
+
346
+ args = ['ditto', app.path, target]
347
+ RunLoop::Xcrun.new.exec(args, log_cmd: true)
348
+ target
349
+ end
350
+
351
+ def reinstall_existing_app_and_clear_sandbox(installed_app_bundle)
352
+ wait_for_device_state('Shutdown')
353
+
354
+ reset_app_sandbox_internal
355
+
356
+ if File.exist?(installed_app_bundle)
357
+ FileUtils.rm_rf(installed_app_bundle)
358
+ RunLoop.log_debug("Deleted app bundle: #{installed_app_bundle}")
359
+ end
360
+
361
+ directory = File.dirname(installed_app_bundle)
362
+ bundle_name = File.basename(app.path)
363
+ target = File.join(directory, bundle_name)
364
+
365
+ args = ['ditto', app.path, target]
366
+ RunLoop::Xcrun.new.exec(args, log_cmd: true)
367
+ installed_app_bundle
368
+ end
369
+
370
+ def uninstall_app_and_sandbox(installed_app_bundle)
371
+ wait_for_device_state('Shutdown')
372
+
373
+ if sdk_gte_8?
374
+ # Must delete the sandbox first.
375
+ directory = app_sandbox_dir
376
+ if File.exist?(directory)
377
+ FileUtils.rm_rf(directory)
378
+ RunLoop.log_debug("Deleted app sandbox: #{directory}")
379
+ end
380
+
381
+ directory = File.dirname(installed_app_bundle)
382
+ if File.exist?(directory)
383
+ FileUtils.rm_rf(directory)
384
+ RunLoop.log_debug("Deleted app container: #{directory}")
385
+ end
386
+ else
387
+ # Sandbox _is_ in the container.
388
+ directory = File.dirname(installed_app_bundle)
389
+ if File.exist?(directory)
390
+ FileUtils.rm_rf(directory)
391
+ RunLoop.log_debug("Deleted app container: #{directory}")
392
+ end
393
+ end
394
+ end
395
+
396
+ # @!visibility private
397
+ def app_sandbox_dir_sdk_gte_8
398
+ containers_data_dir = File.join(device_data_dir, 'Containers', 'Data', 'Application')
399
+ apps = Dir.glob("#{containers_data_dir}/**/#{METADATA_PLIST}")
400
+ match = apps.find do |metadata_plist|
401
+ pbuddy.plist_read('MCMMetadataIdentifier', metadata_plist) == app.bundle_identifier
402
+ end
403
+ if match
404
+ File.dirname(match)
405
+ else
406
+ nil
407
+ end
408
+ end
409
+
410
+ # @!visibility private
411
+ def terminate_core_simulator_processes
412
+ MANAGED_PROCESSES.each do |pair|
413
+ name = pair[0]
414
+ send_term = pair[1]
415
+ pids = RunLoop::ProcessWaiter.new(name).pids
416
+ pids.each do |pid|
417
+
418
+ if send_term
419
+ term = RunLoop::ProcessTerminator.new(pid, 'TERM', name)
420
+ killed = term.kill_process
421
+ else
422
+ killed = false
423
+ end
424
+
425
+ unless killed
426
+ term = RunLoop::ProcessTerminator.new(pid, 'KILL', name)
427
+ term.kill_process
428
+ end
429
+ end
430
+ end
431
+ end
432
+
433
+ # @!visibility private
434
+ def wait_for_device_state(target_state)
435
+ now = Time.now
436
+ timeout = WAIT_FOR_DEVICE_STATE_OPTS[:timeout]
437
+ poll_until = now + timeout
438
+ delay = WAIT_FOR_DEVICE_STATE_OPTS[:interval]
439
+ in_state = false
440
+ while Time.now < poll_until
441
+ in_state = device.update_simulator_state == target_state
442
+ break if in_state
443
+ sleep delay
444
+ end
445
+
446
+ elapsed = Time.now - now
447
+ RunLoop.log_debug("Waited for #{elapsed} seconds for device to have state: '#{target_state}'.")
448
+
449
+ unless in_state
450
+ raise "Expected '#{target_state} but found '#{device.state}' after waiting."
451
+ end
452
+ in_state
453
+ end
454
+
455
+ # @!visibility private
456
+ def reset_app_sandbox_internal_shared
457
+ [app_documents_dir, app_tmp_dir].each do |dir|
458
+ FileUtils.rm_rf dir
459
+ FileUtils.mkdir dir
460
+ end
461
+ end
462
+
463
+ # @!visibility private
464
+ def reset_app_sandbox_internal_sdk_gte_8
465
+ lib_dir = app_library_dir
466
+ RunLoop::Directory.recursive_glob_for_entries(lib_dir).each do |entry|
467
+ if entry.include?('Preferences')
468
+ # nop
469
+ else
470
+ if File.exist?(entry)
471
+ FileUtils.rm_rf(entry)
472
+ end
473
+ end
474
+ end
475
+
476
+ prefs_dir = app_library_preferences_dir
477
+ protected = ['com.apple.UIAutomation.plist',
478
+ 'com.apple.UIAutomationPlugIn.plist']
479
+ RunLoop::Directory.recursive_glob_for_entries(prefs_dir).each do |entry|
480
+ unless protected.include?(File.basename(entry))
481
+ if File.exist?(entry)
482
+ FileUtils.rm_rf entry
483
+ end
484
+ end
485
+ end
486
+ end
487
+
488
+ # @!visibility private
489
+ def reset_app_sandbox_internal_sdk_lt_8
490
+ prefs_dir = app_library_preferences_dir
491
+ RunLoop::Directory.recursive_glob_for_entries(prefs_dir).each do |entry|
492
+ if entry.end_with?('.GlobalPreferences.plist') ||
493
+ entry.end_with?('com.apple.PeoplePicker.plist')
494
+ # nop
495
+ else
496
+ if File.exist?(entry)
497
+ FileUtils.rm_rf entry
498
+ end
499
+ end
500
+ end
501
+
502
+ # app preferences lives in device Library/Preferences
503
+ device_prefs_dir = File.join(app_sandbox_dir, 'Library', 'Preferences')
504
+ app_prefs_plist = File.join(device_prefs_dir, "#{app.bundle_identifier}.plist")
505
+ if File.exist?(app_prefs_plist)
506
+ FileUtils.rm_rf(app_prefs_plist)
507
+ end
508
+ end
509
+
510
+ # @!visibility private
511
+ def reset_app_sandbox_internal
512
+ reset_app_sandbox_internal_shared
513
+
514
+ if sdk_gte_8?
515
+ reset_app_sandbox_internal_sdk_gte_8
516
+ else
517
+ reset_app_sandbox_internal_sdk_lt_8
518
+ end
519
+ end
520
+
521
+ # @!visibility private
522
+ # For testing.
523
+ def launch
524
+
525
+ install
526
+ launch_simulator
527
+
528
+ args = ['simctl', 'launch', device.udid, app.bundle_identifier]
529
+ hash = RunLoop::Xcrun.new.exec(args, log_cmd: true, timeout: 20)
530
+
531
+ exit_status = hash[:exit_status]
532
+
533
+ if exit_status != 0
534
+ err = hash[:err]
535
+ RunLoop.log_error(err)
536
+ raise RuntimeError, "Could not launch #{app.bundle_identifier} on #{device}"
537
+ end
538
+
539
+ RunLoop::ProcessWaiter.new(app.executable_name, WAIT_FOR_APP_LAUNCH_OPTS).wait_for_any
540
+ true
541
+ end
542
+ end
543
+ end
544
+ end