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

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

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

Видео


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

Комментарии (0)