Управление Xcode симулятором из симулятора

от автора

Как мы знаем, управление симулятором можно осуществить из терминала, используя simctl утилиту, которая поставляется вместе с Xcode и располагается по пути:

Xcode.app/Contents/Developer/usr/bin/simctl

Если из терминала вызвать simctl, то, скорее всего, вы получите ошибку:

"command not found: simctl"

Поэтому следует использовать proxy утилиту xcrun, которая перенаправит обращение к simctl, установленную в Xcode по умолчанию (чтобы изменить Xcode по умолчанию, следует использовать xcode-select утилиту с правами root) или же можно в переменных окружения расширить PATH, чтобы окружение также смотрело и в директорию:

/Applications/Xcode.app/Contents/Developer/usr/bin/

Вызовем simctl еще раз, но используя xcrun, и убедимся, что вызов работает:

xcrun simctl --version @(#)PROGRAM:simctl  PROJECT:CoreSimulator-993.7

Используя simctl, мы можем узнать какие Xcode симулятор рантаймы у нас установлены и какие симуляторы за каждым рантаймом закреплены:

xcrun simctl list devices  == Devices == -- iOS 16.4 -- -- iOS 17.2 -- -- iOS 17.5 --     iPhone 15 (FDFE4922-31B3-45C2-920E-CB7D157438D8) (Shutdown)      iPhone 15 1 (322438F1-8B66-468E-A1DD-8285BEDB6235) (Shutdown)  -- iOS 18.2 --     iPhone 16 1 (EB373B43-A9D5-4186-9ED3-721FBBB025E6) (Booted)      iPhone 16 Pro (0457CF4E-0D34-4F10-8EE3-C9DE90CDC7F8) (Shutdown)      iPhone 16 (B67A0BE4-83D6-4423-B022-AC4F82F583FD) (Booted) 

Зная UUID симулятора, мы можем управлять симулятором с помощью всё той же simctl утилиты. Можно запускать симулятор, можно завершать работу симулятора, можно удалить симулятор, можно также создать клон симулятора. Все команды вы можете узнать, запустив команду:

xcrun simctl help

Вернемся к заголовку статьи. Теперь становится более менее понятно, что нужно сделать, чтобы управлять симулятором из симулятора. Нужно каким-то образом из симулятора запустить bash команду (xcrun simctl …) на хост машине, то есть на MacOS, где запущен симулятор.

Но предлагаю начать с простого и проверить в каком окружении запущен симулятор относительно хост машины. Создадим пустое iOS приложение и запустим его на iOS симуляторе. Все примеры кода будем выполнять внутри функции, которая вызывается iOS системой при запуске приложения:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { }

Давайте просто посмотрим, что нам выведет системное iOS API для временной директории:

let dir = FileManager.default.temporaryDirectory print(dir)

Выдаст вывод в Xcode консоль следующего вида:

file:///Users/user/Library/Developer/CoreSimulator/Devices/FDFE4922-31B3-45C2-920E-CB7D157438D8/data/Containers/Data/Application/ED9B6B5F-F62D-4452-B72D-A1A3260F19F3/tmp/

Мы также эту директорию можем открыть и на хост машине просто в Finder приложении.

Следующий эксперимент — создать файл в $HOME/Downloads юзер директории, чтобы проверить права на запись. Создадим простой пример:

 do {     try "Hello Habr!".write(to: URL(fileURLWithPath: "/Users/user/Downloads/habr-hello.txt"), atomically: true, encoding: .utf8) } catch {     print(error) }

В результате которого у нас в директории /Downloads создастся файл habr-hello.txt, иными словами мы можем, как минимум, манипулировать файлами на хост системе с write уровнем доступа из симулятора.

Где это может пригодиться:

  1. Этим активно пользуются Snapshot Testing библиотеки. Они делают снапшот экрана или вьюшки во время прохождения теста и сохраняют его на диск хост машины рядом с файлом тестов. В коде это выглядит, примерно, следующим образом:

func testSnapshotScreen() throws {     let view = UIView()     let image = UIGraphicsImageRenderer(size: view.bounds.size).image { _ in         view.drawHierarchy(in: view.bounds, afterScreenUpdates: true)     }     let jpeg = image.jpegData(compressionQuality: 1)!     let url = URL(fileURLWithPath: #filePath)         .deletingLastPathComponent()         .appendingPathComponent(#function.replacingOccurrences(of: "()", with: ""))         .appendingPathExtension(for: .jpeg)     try jpeg.write(to: url) }

После прохождения теста, рядом с Swift/ObjC файлом с тестом, появится файл снапшота testSnapshotScreen.jpeg.

  1. Если вы работаете с базой данных, то можно создать файл базы данных через MacOS Desktop полноценное приложение с определенными данными и затем эту базу данных подключать в приложение, запущенное на симуляторе.

Теперь снова перейдем к сути самой статьи.

Чтобы запустить bash команду из приложения можно использовать posix функцию system(), но, к сожалению, она доступна только для MacOS SDK, о чем нам говорят макросы вокруг нее:

__swift_unavailable("Use posix_spawn APIs or NSTask instead. (On iOS, process spawning is unavailable.)") __API_AVAILABLE(macos(10.0)) __IOS_PROHIBITED __WATCHOS_PROHIBITED __TVOS_PROHIBITED int system(const char * ) __DARWIN_ALIAS_C(system);

Более того, описание в макросе нам предлагает вместо system() функции использовать или posix_spawn() из Darwin SDK, или, знакомую многим разработчикам, NSTask (класс Process в Swift) из системной Foundation библиотеки. Если заглянуть в описание NSTask, то этот системный класс, как и system() функция доступен, к сожалению, только на MacOS. Теперь у нас вся надежда на posix_spawn(), которая, к нашей удаче, есть в iOS SDK, о чем и говорят макросы в исходниках этой функции https://github.com/apple/darwin-xnu/blob/main/libsyscall/wrappers/spawn/spawn.h :

int posix_spawn(pid_t * __restrict, const char * __restrict,     const posix_spawn_file_actions_t *,     const posix_spawnattr_t * __restrict,     char *const __argv[__restrict],     char *const __envp[__restrict]) __API_AVAILABLE(macos(10.5), ios(2.0)) __SPI_AVAILABLE(watchos(2.0), tvos(9.0), bridgeos(1.0));
  • Для продвинутых хацкеров, есть информация, что NSTask поддерживается iOS SDK, просто Apple убрал информацию об этом классе из заголовочных файлов, чтобы им воспользоваться нужно просто добавить описание этого класса в ваш проект (можно скопировать с MacOS Foundation SDK и чуть подправить).

Продолжаем. Чем дольше я смотрю на эту функцию, тем больше понимаю, что ничего не понимаю. Если посмотреть на с конвертированное в Swift язык API этой функции, то становится еще непонятнее:

public func posix_spawn(     _: UnsafeMutablePointer<pid_t>!,     _: UnsafePointer<CChar>!,      _: UnsafePointer<posix_spawn_file_actions_t?>!,      _: UnsafePointer<posix_spawnattr_t?>!,      _ __argv: UnsafePointer<UnsafeMutablePointer<CChar>?>!,      _ __envp: UnsafePointer<UnsafeMutablePointer<CChar>?>!     ) -> Int32

В общем, идем самым простым способом, идем на Github и пытаемся найти примеры использования этой функции на Swift языке и берем первый попавшийся рабочий пример. У меня получился такой код:

struct CliTool {      static func runCommand(_ command: String) throws -> Int32 {         var pid: pid_t = 0         var status = Int32(0)         let args = ["sh", "-c", command]         let envs = [String]()         try withCStrings(args) { cArgs in             try withCStrings(envs) { cEnvs in                 status = posix_spawn(&pid, "/bin/sh", nil, nil, cArgs, cEnvs)                 if status == 0 {                     if waitpid(pid, &status, 0) == -1 {                         throw RunCommandError.WaitPIDError                     }                 } else {                     throw RunCommandError.POSIXSpawnError(status)                 }             }         }         return status     }      enum RunCommandError: Error {         case WaitPIDError         case POSIXSpawnError(Int32)     } }

Теперь попробуем узнать версию Xcode на хост машине:

// Всегда вызываем на фоновом потоке DispatchQueue.global().async {     do {         try CliTool.runCommand("xcodebuild -version")     } catch {         print(error)     } }

И в консоли Xcode видим заветные:

Xcode 15.4
Build version 15F31d

Проверим, какие переменные доступны:

try CliTool.runCommand("export")

Получаем:

export OLDPWD export PWD="/" export SHLVL="1"

Попробуем узнать список файлов в $HOME директории:

try CliTool.runCommand("ls $HOME")

И получаем список из корневой директории MacOS, это не то что мы ожидаем. Нужно выставить HOME нашего MacOS юзера прежде чем запускать команду, немного правим функцию runCommand, где выставляем переменные окружения:

let iOSUserENV = [     "export LANG=en_US.UTF-8",     "export LC_ALL=en_US.UTF-8",     "export LC_CTYPE=UTF-8",     "export USER=\(FileManager.default.temporaryDirectory.pathComponents[2])", // юзера определяем динамически     "export HOME=/Users/$USER" ].joined(separator: ";") let args = ["sh", "-c", "\(iOSUserENV);\(command)"]

Теперь если повторить команду, получим желаемый результат.

Еще эксперимент, попробуем создать скрип и его запустить:

try CliTool.runCommand("cd $HOME/Downloads/;echo date > 123.sh;chmod +x ./123.sh;./123.sh")

Обнаруживаем, что хост машина нам позволяет делать практически всё что угодно где не требуется root пароль или каких специфичных параметров для окружения.

Вернемся снова к теме статьи и, наконец, попробуем отправить команду нашему симулятору. Но, сначала, проверим доступна ли нам simctl утилита из симулятора. Запускаем:

try CliTool.runCommand("xcrun simctl --version")

и получаем:

@(#)PROGRAM:simctl  PROJECT:CoreSimulator-993.7

Значит нам доступна simctl. И теперь пробуем долгожданный запуск симулятора из симулятора:

try CliTool.runCommand("xcrun simctl boot 322438F1-8B66-468E-A1DD-8285BEDB6235")

и, к сожалению, получаем ошибку:

simctl[16681:584185] Error Domain=NSPOSIXErrorDomain Code=61 «Connection refused» UserInfo={NSLocalizedDescription=Unable to lookup com.apple.CoreSimulator.CoreSimulatorService (993.7) in the bootstrap. This can happen if running with a sandbox profile. When running with a sandbox profile, /Library/Developer/PrivateFrameworks/CoreSimulator.framework/XPCServices/com.apple.CoreSimulator.CoreSimulatorService.xpc must be owned by root, not group writable, and not world writable. See . isXBSChroot(): NO, XBS_IS_CHROOTED: (null)}

По тексту ошибки становится примерно понятно, что наш юзер bash команды не совсем юзер хост машины и, к тому же, запущен в режиме песочницы. whoami показывает, что мы запускаем команды от 501 пользователя, так же он не знает ничего ни о нашем основном хост машины пользователе, ни о root пользователе. Когда я пытался переключить юзера, то получал ошибку:

sudo: you do not exist in the passwd database

К сожалению, на данном этапе мы зашли в тупик, потому что нам недостаточно прав, чтобы подавать команды симулятору из симулятора. Нам нужно как-то расширить права пользователя или каким-то образом прокинуть команды основному юзеру нашей хост машины. И немного подумав, я решил запустить локальный сервер на хост машине, который будет просто перенаправлять команды в sh. Для простоты я взял простой сервер на ruby, который уже использовал в других экспериментах:

Код сервера
# file proxy-server.rb  require 'webrick'  class MyServlet < WEBrick::HTTPServlet::AbstractServlet   def do_GET (request, response)     response.body = 'OK'     response.status = 200      case request.path       when '/shutdown'         Thread.new do           @server.shutdown         end       else         command = request.query["command"]         if command != nil           Thread.new do             puts "system(#{command})"             puts system(*%W[#{command}])           end         end     end   end end  server = WEBrick::HTTPServer.new(:Port => 59123) server.mount '/', MyServlet  trap('INT') { server.shutdown } server.start

Запускаем сервер командой:

ruby proxy-server.rb [2024-12-23 11:31:13] INFO  WEBrick 1.7.0 [2024-12-23 11:31:13] INFO  ruby 3.1.3 (2022-11-24) [arm64-darwin21] [2024-12-23 11:31:13] INFO  WEBrick::HTTPServer#start: pid=41537 port=59123

Из симулятора вызывать сервер будем через curl утилиту хост машины. Для этого добавим новую функцию:

static func runCurl(_ command: String, port: String = "59123") throws -> Int32 {     var path = "http://localhost:\(port)/?command=\(command)"     path = path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? path     path = path.replacingOccurrences(of: ";", with: "%3B")     return try Self.runCommand("curl \(path)") }

Пробуем еще раз запустить симулятор из другого симулятора:

try CliTool.runCurl("xcrun simctl boot 322438F1-8B66-468E-A1DD-8285BEDB6235")

В логах сервера видим, что команда выполнилась на хост машине:

[2024-12-14 19:34:28] INFO  WEBrick 1.7.0 [2024-12-14 19:34:28] INFO  ruby 3.1.3 (2022-11-24) [arm64-darwin21] [2024-12-14 19:34:28] INFO  WEBrick::HTTPServer#start: pid=17662 port=59123 ::1 - - [14/Dec/2024:19:37:21 +07] "GET /?command=xcrun%20simctl%20boot%20322438F1-8B66-468E-A1DD-8285BEDB6235 HTTP/1.1" 200 2 - -> /?command=xcrun%20simctl%20boot%20322438F1-8B66-468E-A1DD-8285BEDB6235 system(xcrun simctl boot 322438F1-8B66-468E-A1DD-8285BEDB6235)

и обнаруживаем, что новый симулятор действительно запустился.

Итоги

Внимательный читатель может спросить, а зачем нам это вообще может понадобиться, управлять симулятором из симулятора ? Я придумал теоретический список, где нам это может пригодиться:

  1. Управление симулятором во время тестов

Например, во время UI теста симулятор завис (главный поток залочился) или какие-то другие редкие системные причины из-за которых симулятор стал ввести себя некорректно, можно его попробовать перезапустить прям из симулятора (да, перезапустить самого себя и это работает).

  1. Симулировать app линк во время UI теста

Пример:

func testAppLinkFlow() throws {     app.launch()     let simulatorUUID = FileManager.default.temporaryDirectory.pathComponents[7]     try CliTool.runCurl("xcrun simctl openurl \(simulatorUUID) app-scheme://path/to")     verifyUI() }
  1. Послать пуш во время UI теста

func testPushFlow() throws {     app.launch()     let simulatorUUID = FileManager.default.temporaryDirectory.pathComponents[7]     try CliTool.runCurl("xcrun simctl push \(simulatorUUID) com.my.app - <<< \"{\\\"aps\\\":{\\\"alert\\\":{\\\"body\\\":\\\"Body Title\\\",\\\"title\\\":\\\"Alert Title\\\"}}}\"")     tapPush()     verifyUI() }
  1. Интеграция с другими приложениями

Во время теста удалить/установить приложение для тестирования интеграции.

Мини демо приложение

Видео


Спасибо, что прочитали.


ссылка на оригинал статьи https://habr.com/ru/articles/868846/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *