Как мы знаем, управление симулятором можно осуществить из терминала, используя 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 уровнем доступа из симулятора.
Где это может пригодиться:
-
Этим активно пользуются 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.
-
Если вы работаете с базой данных, то можно создать файл базы данных через 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)
и обнаруживаем, что новый симулятор действительно запустился.
Итоги
Внимательный читатель может спросить, а зачем нам это вообще может понадобиться, управлять симулятором из симулятора ? Я придумал теоретический список, где нам это может пригодиться:
-
Управление симулятором во время тестов
Например, во время UI теста симулятор завис (главный поток залочился) или какие-то другие редкие системные причины из-за которых симулятор стал ввести себя некорректно, можно его попробовать перезапустить прям из симулятора (да, перезапустить самого себя и это работает).
-
Симулировать 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() }
-
Послать пуш во время 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() }
-
Интеграция с другими приложениями
Во время теста удалить/установить приложение для тестирования интеграции.
Мини демо приложение
Видео
Спасибо, что прочитали.
ссылка на оригинал статьи https://habr.com/ru/articles/868846/
Добавить комментарий