Как стать автором
Обновить

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

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров1.1K

Как мы знаем, управление симулятором можно осуществить из терминала, используя 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. Интеграция с другими приложениями

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

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

Видео


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

Теги:
Хабы:
Всего голосов 3: ↑3 и ↓0+3
Комментарии0

Публикации

Истории

Работа

Swift разработчик
14 вакансий
iOS разработчик
10 вакансий

Ближайшие события

11 – 13 февраля
Epic Telegram Conference
Онлайн
27 марта
Deckhouse Conf 2025
Москва
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань