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 04:40] – удалено - внешнее изменение (Дата неизвестна) 127.0.0.1 | apple-mac-os:macos-sequoia:automated-os-deployment-to-apple-mac-and-iphone-or-ipad-mobile-devices [24.09.2025 19:18] (текущий) – Виталий Якоб | ||
|---|---|---|---|
| Строка 1: | Строка 1: | ||
| + | ===== Автоматизированное развёртывание OC на Apple Mac и мобильные устройства iPhone или iPad ===== | ||
| + | Идея подсмотрена на Российской ежегодной [[https:// | ||
| + | |||
| + | Реализация в целом похожа, | ||
| + | |||
| + | \\ | ||
| + | ==== Основная логика ==== | ||
| + | |||
| + | Скрипт '' | ||
| + | |||
| + | <code AppleScript> | ||
| + | -- В скрипте используется утилита jq, которая встроена в macOS начиная с Sequoia. | ||
| + | -- При использовании более ранних вресий, | ||
| + | -- Перед использованием с AppStore установить Apple Configurator 2, и выполнить установку средств автоматизации из меню. | ||
| + | |||
| + | --Объявляем глобальную переменную для работы в обработчиках | ||
| + | global LogPath | ||
| + | set LogPath to "/ | ||
| + | |||
| + | -- Определение версии ОС для правильной работы с system_profiler, | ||
| + | -- В дальнейшем могут быть другие изменения, | ||
| + | set OSVer to system version of (system info) | ||
| + | global usbDataType | ||
| + | |||
| + | if OSVer is greater than or equal to 26 then | ||
| + | set usbDataType to " | ||
| + | else | ||
| + | set usbDataType to " | ||
| + | end if | ||
| + | |||
| + | |||
| + | -- Получаем список подключенных устройств с помощью 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 устройсвах, | ||
| + | --Так как формат system_profiler изменился начиная с macOS 26, учитываем разницу. | ||
| + | --Location необходим в качестве ключа для объединения со списком cfgutilDevList() | ||
| + | set SPUSBInfo to do shell script " | ||
| + | .. | objects | ||
| + | | select(._name? | ||
| + | | [ | ||
| + | ._name, | ||
| + | (.USBDeviceKeySerialNumber // .serial_num // \" | ||
| + | (.USBKeyLocationID // .location_id // \" | ||
| + | ] | ||
| + | | .[] | ||
| + | '" | ||
| + | | ||
| + | -- Разделяем вывод на строки | ||
| + | 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 | ||
| + | | ||
| + | -- Модифицируем полученные данные приводя в нужный вид | ||
| + | -- Серийный номер подключенный 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 спереди, | ||
| + | *) | ||
| + | --Вырезаем значение из строки | ||
| + | --До macOS 26, в Location ID содержал номер порта, " | ||
| + | set LocationID to word 1 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) | ||
| + | | Apple macOS Tahoe (26.0) | ||
| + | |||
| + | ---- | ||
| + | {{: | ||
| + | {{tag> | ||
| + | |||
| + | ~~DISCUSSION~~ | ||