apple-mac-os:macos-sequoia:automated-os-deployment-to-apple-mac-and-iphone-or-ipad-mobile-devices
Различия
Показаны различия между двумя версиями страницы.
Предыдущая версия справа и слеваПредыдущая версияСледующая версия | Предыдущая версия | ||
apple-mac-os:macos-sequoia:automated-os-deployment-to-apple-mac-and-iphone-or-ipad-mobile-devices [10.12.2024 07:40] – удалено - внешнее изменение (Дата неизвестна) 127.0.0.1 | apple-mac-os:macos-sequoia:automated-os-deployment-to-apple-mac-and-iphone-or-ipad-mobile-devices [10.12.2024 07:45] (текущий) – ↷ Операцией перемещения обновлены ссылки Алексей Максимов | ||
---|---|---|---|
Строка 1: | Строка 1: | ||
+ | ===== Автоматизированное развёртывание OC на Apple Mac и мобильные устройства iPhone или iPad ===== | ||
+ | Идея подсмотрена на Российской ежегодной [[https:// | ||
+ | |||
+ | Реализация в целом похожа, | ||
+ | |||
+ | \\ | ||
+ | ==== Основная логика ==== | ||
+ | |||
+ | Скрипт '' | ||
+ | |||
+ | <code AppleScript> | ||
+ | -- В скрипте используется утилита jq, которая встроена в macOS начиная с Sequoia. | ||
+ | -- При использовании более ранних версий, | ||
+ | -- Перед использованием с AppStore установить Apple Configurator 2, и выполнить установку средств автоматизации из меню. | ||
+ | |||
+ | --Объявляем глобальную переменную для работы в обработчиках | ||
+ | global LogPath | ||
+ | set LogPath to "/ | ||
+ | |||
+ | |||
+ | -- Получаем список подключенных устройств с помощью cfgutil | ||
+ | set cfgutilResult to my cfgutilDevList() | ||
+ | -- Получаем список подключенных устройств с помощью system_profiler | ||
+ | set SPResult to my systemprofilerDevList() | ||
+ | -- Объединяем списки для получения требуемой инф. в одной списке | ||
+ | set MergedList to my MergeLists(cfgutilResult, | ||
+ | --return MergedList | ||
+ | |||
+ | -- Проходим по каждому устройству в подписке списка | ||
+ | repeat with i from 1 to count MergedList | ||
+ | -- Берём первое устройство | ||
+ | set thisDevice to item i of MergedList | ||
+ | -- Записываем атрибуты устройства | ||
+ | set DeviceType to item 1 of thisDevice | ||
+ | set DeviceClass to item 2 of thisDevice | ||
+ | set SerialNumber to item 3 of thisDevice | ||
+ | set ECID to item 4 of thisDevice | ||
+ | set LocationID to item 5 of thisDevice | ||
+ | -- Формируем имя Log-файла | ||
+ | set LogFileName to ECID & " | ||
+ | | ||
+ | --Логируем найденное устройство | ||
+ | my WriteLog(" | ||
+ | & " | ||
+ | | ||
+ | -- Выполняем подготовку | ||
+ | -- Формируем ссылку для получения доступных IPSW | ||
+ | set ChooseUrl to " | ||
+ | --Логируем | ||
+ | my WriteLog(" | ||
+ | -- Получаем информацию о последней доступной версии ОС | ||
+ | set BaseJSON to my GetJSON(ChooseUrl, | ||
+ | -- Парсим JSON, если он был получен | ||
+ | if BaseJSON is false then return | ||
+ | -- Получаем контрольную сумму из JSON | ||
+ | set CloudCheckSum to do shell script " | ||
+ | -- Получаем ссылку для скачивания " | ||
+ | set IPSWUrl to do shell script " | ||
+ | -- Получаем имя файла из JSON | ||
+ | set CloudIPSWName to do shell script " | ||
+ | -- Указываем родительский каталог для сохранения " | ||
+ | set IPSWRootPath to (POSIX path of "/ | ||
+ | -- Формируем путь до файла " | ||
+ | set CurrentIPSWPath to (IPSWRootPath & DeviceType & "/" | ||
+ | -- Если файл существует, | ||
+ | -- Если файла нет -- скачиваем. Скачивание файла может занимать длительное время. | ||
+ | set SelectIPSWStatus to my SelectIPSW(CloudCheckSum, | ||
+ | -- При возникновении ошибки процесс останавливается | ||
+ | if SelectIPSWStatus is not true then return | ||
+ | -- запускаем восстановление | ||
+ | my Restore(LocationID, | ||
+ | | ||
+ | end repeat | ||
+ | |||
+ | |||
+ | |||
+ | --=================== | ||
+ | --Обработчики | ||
+ | --=================== | ||
+ | |||
+ | -- Проверить или скачать IPSW | ||
+ | --=================== | ||
+ | on SelectIPSW(TrustedCheckSum, | ||
+ | -- Создаём каталоги для загрузки IPSW, если необходимо | ||
+ | my CreateFolder(RootFolder, | ||
+ | -- Проверяем контрольную сумму существующего файла | ||
+ | set StatusCheckSum to my CheckSum(TrustedCheckSum, | ||
+ | if StatusCheckSum is true then | ||
+ | return true | ||
+ | else | ||
+ | -- Скачиваем | ||
+ | set GetIPSWStatus to my GetIPSW(thisFile, | ||
+ | if GetIPSWStatus is true then | ||
+ | return true | ||
+ | else | ||
+ | return false | ||
+ | end if | ||
+ | end if | ||
+ | end SelectIPSW | ||
+ | --=================== | ||
+ | |||
+ | |||
+ | |||
+ | -- Получить JSON | ||
+ | --=================== | ||
+ | on GetJSON(thisURL, | ||
+ | -- Запрос всех доступных IPSW для подключенной модели | ||
+ | set thisJSON to do shell script " | ||
+ | -- Если возврат " | ||
+ | if thisJSON is " | ||
+ | my WriteLog(" | ||
+ | return false | ||
+ | else | ||
+ | -- JSON получен, | ||
+ | set thisJSON to do shell script " | ||
+ | my WriteLog(" | ||
+ | | ||
+ | return thisJSON | ||
+ | | ||
+ | end if | ||
+ | | ||
+ | end GetJSON | ||
+ | --=================== | ||
+ | |||
+ | |||
+ | -- Скачать IPSW | ||
+ | --=================== | ||
+ | on GetIPSW(thisFile, | ||
+ | -- Поиск параллельных процессов скачивания IPSW образов | ||
+ | set UserName to short user name of (system info) | ||
+ | set PSShellCommand to "ps -u" & space & UserName & space & "| grep ' | ||
+ | set CurrentDownloads to (do shell script PSShellCommand) as integer | ||
+ | | ||
+ | -- Если процесс curl был найден, | ||
+ | if CurrentDownloads is 0 then | ||
+ | -- Устанавливаем счётчик повторных запусков на 0 | ||
+ | set Counter to 0 | ||
+ | -- Повторяем цикл 5 раз | ||
+ | repeat while Counter < 6 | ||
+ | my WriteLog(" | ||
+ | -- пауза в секундах | ||
+ | delay 600 | ||
+ | -- Добавляем к счётчику 1 выполненный проход | ||
+ | set Counter to Counter + 1 | ||
+ | -- Проверяем ещё раз | ||
+ | set CurrentDownloads to (do shell script PSShellCommand) as integer | ||
+ | -- Если очередная проверка не обнаружила параллельный процесс, | ||
+ | if CurrentDownloads is 1 then | ||
+ | -- Процесс завершён, | ||
+ | my WriteLog(" | ||
+ | set StatusCheckSum to my CheckSum(TrustedCheckSum, | ||
+ | if StatusCheckSum is true then | ||
+ | return true | ||
+ | else | ||
+ | my WriteLog(" | ||
+ | my WriteLog(" | ||
+ | my WriteLog(" | ||
+ | return false | ||
+ | end if | ||
+ | exit repeat | ||
+ | end if | ||
+ | -- При достижении счётчика, | ||
+ | if Counter is 5 then | ||
+ | my WriteLog(" | ||
+ | my WriteLog(" | ||
+ | return false | ||
+ | end if | ||
+ | | ||
+ | end repeat | ||
+ | | ||
+ | -- Если процесс curl не был найден, | ||
+ | else if CurrentDownloads is 1 then | ||
+ | set curlShellCommand to "curl -L --retry 5 --retry-delay 10 --retry-all-errors -J -o" & space & quoted form of thisFile & space & thisURL | ||
+ | my WriteLog(" | ||
+ | try | ||
+ | do shell script curlShellCommand | ||
+ | my WriteLog(" | ||
+ | return true | ||
+ | on error errormsg number errNumber | ||
+ | my WriteLog(" | ||
+ | my WriteLog(" | ||
+ | my WriteLog(" | ||
+ | return false | ||
+ | end try | ||
+ | end if | ||
+ | end GetIPSW | ||
+ | --=================== | ||
+ | |||
+ | |||
+ | --=================== | ||
+ | -- Записать лог | ||
+ | on WriteLog(message, | ||
+ | | ||
+ | --do shell script "mkdir -p" & space & quoted form of POSIX path of (do shell script " | ||
+ | do shell script "mkdir -p" & space & LogPath | ||
+ | set timeStamp to (do shell script "date +' | ||
+ | set logMessage to timeStamp & space & " | ||
+ | do shell script " | ||
+ | | ||
+ | end WriteLog | ||
+ | --=================== | ||
+ | |||
+ | |||
+ | --=================== | ||
+ | --Создать каталог для хранения IPSW этой модели | ||
+ | on CreateFolder(RootFolder, | ||
+ | | ||
+ | -- Формируем полный путь к каталогу | ||
+ | set targetFolder to RootFolder & SubFolder | ||
+ | -- Проверяем существование каталога | ||
+ | try | ||
+ | -- Классические методы проверки существования каталога или файла используя вызов " | ||
+ | -- не могут быть использованы, | ||
+ | do shell script "test -d" & space & quoted form of targetFolder | ||
+ | my WriteLog(" | ||
+ | on error | ||
+ | -- Создать каталог | ||
+ | do shell script "mkdir -p" & space & quoted form of targetFolder | ||
+ | my WriteLog(" | ||
+ | end try | ||
+ | | ||
+ | end CreateFolder --=================== | ||
+ | |||
+ | |||
+ | --=================== | ||
+ | --Сверка контрольной суммы | ||
+ | on CheckSum(TrustedCheckSum, | ||
+ | | ||
+ | -- Проверяем существование файла | ||
+ | try | ||
+ | -- Классические методы проверки существования каталога или файла используя вызов " | ||
+ | -- не могут быть использованы, | ||
+ | do shell script "test -f" & space & quoted form of thisFile | ||
+ | on error | ||
+ | return false | ||
+ | end try | ||
+ | | ||
+ | -- Файл существует, | ||
+ | my WriteLog(" | ||
+ | | ||
+ | -- Вычисляем контрольную сумму | ||
+ | set thisCheckSum to word 1 of (do shell script " | ||
+ | if TrustedCheckSum is equal to thisCheckSum then | ||
+ | my WriteLog(" | ||
+ | return true | ||
+ | else | ||
+ | my WriteLog(" | ||
+ | return false | ||
+ | end if | ||
+ | | ||
+ | end CheckSum | ||
+ | --=================== | ||
+ | |||
+ | |||
+ | --=================== | ||
+ | --Перезагрузка в DFU | ||
+ | on RebootToDFU(LocationID, | ||
+ | | ||
+ | -- Список DFU портов. | ||
+ | -- Обычно DFU порт хоста имеет идентификатор " | ||
+ | set DFUPortList to {" | ||
+ | set macvdmtool to "/ | ||
+ | set macvdmtoolShellCommand to macvdmtool & space & " | ||
+ | -- Проверяем существование macvdmtool | ||
+ | try | ||
+ | -- Классические методы проверки существования каталога или файла используя вызов " | ||
+ | -- не могут быть использованы, | ||
+ | do shell script "test -f" & space & macvdmtool | ||
+ | | ||
+ | -- Если подключенный Mac подключен в DFU порт хоста | ||
+ | if LocationID is in DFUPortList then | ||
+ | my WriteLog(" | ||
+ | try | ||
+ | set macvdmtoolresult to do shell script macvdmtoolShellCommand | ||
+ | my WriteLog(" | ||
+ | my WriteLog(" | ||
+ | on error errormsg | ||
+ | my WriteLog(" | ||
+ | & " | ||
+ | my WriteLog(" | ||
+ | my WriteLog(" | ||
+ | | ||
+ | return false | ||
+ | | ||
+ | end try | ||
+ | end if | ||
+ | on error | ||
+ | my WriteLog(" | ||
+ | | ||
+ | return false | ||
+ | | ||
+ | end try | ||
+ | | ||
+ | end RebootToDFU | ||
+ | --=================== | ||
+ | |||
+ | |||
+ | --=================== | ||
+ | --Восстановление с DFU | ||
+ | on Restore(LocationID, | ||
+ | | ||
+ | if (DeviceCalss contains " | ||
+ | -- Выполняем попытку перезагрузки в DFU режим | ||
+ | set DFUStatus to my RebootToDFU(LocationID, | ||
+ | if DFUStatus is false then return | ||
+ | end if | ||
+ | -- Запуск восстановления в фоновом режиме | ||
+ | set cfgutilShellCommand to "/ | ||
+ | WriteLog(" | ||
+ | WriteLog(" | ||
+ | do shell script cfgutilShellCommand | ||
+ | | ||
+ | end Restore | ||
+ | --=================== | ||
+ | |||
+ | --=================== | ||
+ | --Поиск подключенных устройств с помощью cfgutil | ||
+ | on cfgutilDevList() | ||
+ | | ||
+ | -- Получаем инф. о подключенных устройствах в формате JSON | ||
+ | set BaseJSON to do shell script "/ | ||
+ | -- Парсим JSON, выбирая нужные данные и сохраняем контент как CVS, разделитель ";" | ||
+ | set ParsedCSV to do shell script " | ||
+ | -- Разбиваем на строки | ||
+ | set LineCSV to paragraphs of ParsedCSV | ||
+ | | ||
+ | -- Пустой итоговый список | ||
+ | set ResultList to {} | ||
+ | | ||
+ | -- Цикл обработки строк | ||
+ | repeat with thisDev in LineCSV | ||
+ | -- Используем разделитель ";" | ||
+ | set text item delimiters to ";" | ||
+ | -- Развиваем строку на части разделителем установленным разделителем | ||
+ | set CSVParts to text items of thisDev | ||
+ | -- Сбрасываем разделитель | ||
+ | set text item delimiters to "" | ||
+ | | ||
+ | -- Извлекаем значения | ||
+ | -- Первая часть Location ID | ||
+ | set LocationID to item 1 of CSVParts | ||
+ | -- Вторая часть ECID | ||
+ | set ECID to item 2 of CSVParts | ||
+ | -- Третья часть DeviceType | ||
+ | set DeviceType to item 3 of CSVParts | ||
+ | | ||
+ | -- Модифицируем полученные данные приводя в нужный вид | ||
+ | -- при использовании вывода инф. о девайсах в не текстовом формате, | ||
+ | -- Конвертируем в HEX 1048576 -> 0x100000 | ||
+ | set LocationID to " | ||
+ | | ||
+ | -- Выполняем проверку существования log-файла с именем ECID устройства, | ||
+ | -- Если лог есть, то считаем, | ||
+ | try | ||
+ | do shell script "test -f" & space & quoted form of (LogPath & ECID & " | ||
+ | on error | ||
+ | -- Добавляем в результат | ||
+ | set end of ResultList to {DeviceType, | ||
+ | end try | ||
+ | end repeat | ||
+ | | ||
+ | return ResultList | ||
+ | | ||
+ | --Пример вывода | ||
+ | --{{" | ||
+ | | ||
+ | end cfgutilDevList | ||
+ | --=================== | ||
+ | |||
+ | --=================== | ||
+ | --Поиск подключенных устройств с помощью system_profiler | ||
+ | on systemprofilerDevList() | ||
+ | --Ищем среди подключенных USB устройств имена в которых содержится Mac, DFU или Recovery. | ||
+ | -- Отображаем найденные строки, | ||
+ | -- Location. Location необходим в качестве ключа для объединения со списком cfgutilDevList() | ||
+ | set SPUSBInfo to do shell script " | ||
+ | lines[NR] = $0 | ||
+ | } | ||
+ | / | ||
+ | mac_dfu_recovery_line = NR | ||
+ | | ||
+ | } | ||
+ | /Serial Number:/ && | ||
+ | serial_number_line = NR | ||
+ | } | ||
+ | /Location ID:/ && | ||
+ | if ( mac_dfu_recovery_found && serial_number_line && NR == mac_dfu_recovery_line + 8) { | ||
+ | sub(/^ +/, \" | ||
+ | sub(/^ +/, \" | ||
+ | sub(/^ +/, \" | ||
+ | | ||
+ | print lines[mac_dfu_recovery_line] | ||
+ | print lines[serial_number_line] | ||
+ | print $0 | ||
+ | | ||
+ | | ||
+ | serial_number_line = 0 | ||
+ | } | ||
+ | }'" | ||
+ | | ||
+ | -- Разделяем вывод на строки | ||
+ | set SPUSBInfoLines to paragraphs of SPUSBInfo | ||
+ | | ||
+ | -- Пустой итоговый список | ||
+ | set ResultList to {} | ||
+ | | ||
+ | -- Извлекаем значения проходя по каждой строке с шагом 3 | ||
+ | repeat with i from 1 to count of SPUSBInfoLines by 3 | ||
+ | set Model to item i of SPUSBInfoLines | ||
+ | set Serial to item (i + 1) of SPUSBInfoLines | ||
+ | set LocationID to item (i + 2) of SPUSBInfoLines | ||
+ | | ||
+ | -- Модифицируем полученные данные приводя в нужный вид | ||
+ | --Последний символ строки ":", | ||
+ | set Model to text 1 thru -2 of Model | ||
+ | -- Серийный номер подключенный Mac отображается сразу, у iOS строка содержит множество другой информации | ||
+ | -- Mac загруженный в DFU режиме не отображает серийный номер. | ||
+ | set Serial to do shell script " | ||
+ | / | ||
+ | value = substr($0, index($0, \" | ||
+ | if (value ~ / | ||
+ | print value; | ||
+ | found = 1; | ||
+ | } | ||
+ | } | ||
+ | /SRNM:/ { | ||
+ | split($0, arr, / | ||
+ | if (length(arr) > 1) { | ||
+ | print arr[2]; | ||
+ | found = 1; | ||
+ | } | ||
+ | next; | ||
+ | } | ||
+ | END { | ||
+ | if (found == 0) print \" | ||
+ | } | ||
+ | '" | ||
+ | | ||
+ | (* | ||
+ | Меняем формат Location, т.к. у system_profiler и cfgutil они отличаются | ||
+ | Пример | ||
+ | 0x00100000 --> 0x100000 | ||
+ | 0x08310000 --> 0x8310000 | ||
+ | Поэтому конвертируем 0x08310000 в 8310000 и добавляем 0x спереди, | ||
+ | *) | ||
+ | --Вырезаем значение из строки | ||
+ | set LocationID to text 14 thru 23 of LocationID | ||
+ | set LocationID to " | ||
+ | | ||
+ | -- Добавляем инф о каждом устройстве в подсписке в список | ||
+ | set end of ResultList to {Model, Serial, LocationID} | ||
+ | end repeat | ||
+ | | ||
+ | -- Выводим итоговый список | ||
+ | return ResultList | ||
+ | | ||
+ | --Пример вывода | ||
+ | --{{" | ||
+ | | ||
+ | end systemprofilerDevList | ||
+ | --=================== | ||
+ | |||
+ | -- Обработчик для объединения списков | ||
+ | --https:// | ||
+ | --=================== | ||
+ | on MergeLists(List1, | ||
+ | -- Создаём пустой итоговый список | ||
+ | set ResultList to {} | ||
+ | | ||
+ | -- Проходим по первому списку | ||
+ | repeat with subList1 in List1 | ||
+ | -- Извлекаем значения подсписков из первого списка " | ||
+ | set {Value01, Value02, ID01} to subList1 | ||
+ | | ||
+ | -- Проходим по второму списку | ||
+ | repeat with subList2 in List2 | ||
+ | -- Извлекаем значения подсписков из второго списка " | ||
+ | set {Value11, Value12, ID11} to subList2 | ||
+ | --Если ключевые значения равны, создаём итоговый новый список | ||
+ | if ID01 is equal to ID11 then | ||
+ | set end of ResultList to {Value01, Value11, Value12, Value02, ID01} | ||
+ | exit repeat | ||
+ | end if | ||
+ | end repeat | ||
+ | end repeat | ||
+ | | ||
+ | -- Выводим итоговый список | ||
+ | return ResultList | ||
+ | | ||
+ | end MergeLists | ||
+ | --=================== | ||
+ | </ | ||
+ | |||
+ | |||
+ | \\ | ||
+ | ==== Создание LaunchDaemon ==== | ||
+ | |||
+ | Создадим Launch Daemon | ||
+ | |||
+ | < | ||
+ | |||
+ | <code XML> | ||
+ | <?xml version=" | ||
+ | < | ||
+ | <plist version=" | ||
+ | < | ||
+ | < | ||
+ | < | ||
+ | < | ||
+ | < | ||
+ | < | ||
+ | < | ||
+ | < | ||
+ | < | ||
+ | </ | ||
+ | < | ||
+ | < | ||
+ | < | ||
+ | < | ||
+ | < | ||
+ | < | ||
+ | </ | ||
+ | </ | ||
+ | </ | ||
+ | |||
+ | Убедимся, | ||
+ | |||
+ | < | ||
+ | |||
+ | |||
+ | Загрузим и запустим демон | ||
+ | |||
+ | < | ||
+ | |||
+ | |||
+ | Убедимся, | ||
+ | |||
+ | < | ||
+ | |||
+ | |||
+ | Найдём процесс | ||
+ | |||
+ | < | ||
+ | |||
+ | |||
+ | |||
+ | Launch Daemon работает независимо от входа пользователя и будет готов к работе сразу после загрузки macOS. | ||
+ | Если что-то пошло не так, то необработанные ошибки будут сохранятся в файле '' | ||
+ | |||
+ | При ненадёжном или медленном интернет соединении образы восстановления лучше скачать заблаговременно. | ||
+ | |||
+ | \\ | ||
+ | ==== Пример логирования процесса ==== | ||
+ | |||
+ | Процесс восстановления логируется созданным обработчиком WriteLog(message, | ||
+ | |||
+ | Восстановление iPhone | ||
+ | |||
+ | {{ apple-mac-os: | ||
+ | |||
+ | Восстановление Mac | ||
+ | |||
+ | {{ apple-mac-os: | ||
+ | |||
+ | \\ | ||
+ | ==== Другой вариант получения списка подключенных устройств ==== | ||
+ | |||
+ | Администраторы, | ||
+ | |||
+ | <code AppleScript> | ||
+ | set DevicesList to my DevicesList() | ||
+ | |||
+ | on DevicesList() | ||
+ | | ||
+ | try | ||
+ | tell script " | ||
+ | -- Вызываем необходимые свойства и присваиваем значения в переменные | ||
+ | copy CNFGvaluesOfSpecifiedProperties(" | ||
+ | {deviceCount ¬ | ||
+ | , propertyTitles ¬ | ||
+ | , DeviceTypes ¬ | ||
+ | , deviceECIDs ¬ | ||
+ | , LocationIDs ¬ | ||
+ | , serialNumbers ¬ | ||
+ | , BootedStates} | ||
+ | end tell | ||
+ | | ||
+ | -- Создаём пустой список | ||
+ | set ResultList to {} | ||
+ | --Получаем значения атрибутов для каждого устройства | ||
+ | repeat with i from 1 to the deviceCount | ||
+ | set DeviceType to item i of DeviceTypes | ||
+ | set ECID to item i of deviceECIDs | ||
+ | set LocationID to item i of LocationIDs | ||
+ | set SerialNumber to item i of serialNumbers -- серийные номера отображаются нас устройствах в статусе Booted. Серийные номер Mac не отображаются вообще. | ||
+ | set BootedState to item i of BootedStates -- значения Booted, Recovery или DFU | ||
+ | | ||
+ | -- Изменим формат Location ID integer на hex | ||
+ | set LocationID to " | ||
+ | -- Добавляем подсписки с значениями устройства в список | ||
+ | set end of ResultList to {DeviceType, | ||
+ | end repeat | ||
+ | | ||
+ | return ResultList | ||
+ | | ||
+ | -- Пример вывода | ||
+ | -- {{" | ||
+ | | ||
+ | on error | ||
+ | return false | ||
+ | end try | ||
+ | </ | ||
+ | |||
+ | Мне не понравился нюанс, который не позволяет получить серийный номер подключенных Mac, а так же мобильных устройств в режиме DFU. Если этот момент вам неважен, | ||
+ | |||
+ | Получить справку о наборе встроенных обработчиков для управления автоматизацией можно так: | ||
+ | |||
+ | < | ||
+ | |||
+ | Подводя итог, можно сказать, | ||
+ | |||
+ | |||
+ | P.S. | ||
+ | В моём ведении нет сотен Mac, iPhone или iPad, которые нужно перезаливать перед выдачей, | ||
+ | |||
+ | |||
+ | ---- | ||
+ | Проверено на следующих конфигурациях: | ||
+ | ^ Версия ОС ^ | ||
+ | | Apple macOS Sequoia (15.0) | ||
+ | |||
+ | ---- | ||
+ | {{: | ||
+ | {{tag> | ||
+ | |||
+ | ~~DISCUSSION~~ |