Как мы знаем, управление симулятором можно осуществить из терминала, используя 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()
}
Интеграция с другими приложениями
Во время теста удалить/установить приложение для тестирования интеграции.
Мини демо приложение
Видео
Спасибо, что прочитали.