Пошаговые руководства, шпаргалки, полезные ссылки...
БлогФорумАвторы
Полезные Online-сервисы
Перечень Бесплатного ПО
Подписка на RSS-канал
Идея подсмотрена на Российской ежегодной конференции системных администраторов Apple.
Реализация в целом похожа, в представленном варианте используется cfgutil, который запускает скрипт AppleScript с логикой обработки подключенных Mac, iPhone или iPad. Последний доступный образ восстановления при необходимости скачивается автоматически. Так же выполняется логирование для понимания процесса. Для перевода Mac в DFU при условии правильного подключения используется macvdmtool. iPhone и iPad, само собой, переводим в DFU в ручном режиме.
Скрипт Restore.scpt разместим здесь /Library/Application Support/Custom/RestoreDevices/
Restore.scpt
/Library/Application Support/Custom/RestoreDevices/
-- В скрипте используется утилита jq, которая встроена в macOS начиная с Sequoia. -- При использовании более ранних версий, jq нужно установить дополнительно. -- Перед использованием с AppStore установить Apple Configurator 2, и выполнить установку средств автоматизации из меню. --Объявляем глобальную переменную для работы в обработчиках global LogPath set LogPath to "/tmp/DevsRestore/logs/" -- Получаем список подключенных устройств с помощью cfgutil set cfgutilResult to my cfgutilDevList() -- Получаем список подключенных устройств с помощью system_profiler set SPResult to my systemprofilerDevList() -- Объединяем списки для получения требуемой инф. в одной списке set MergedList to my MergeLists(cfgutilResult, SPResult) --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 & ".log" --Логируем найденное устройство my WriteLog("Найдено устройство:" & space & DeviceType & space & "серийный номер:" & space & SerialNumber & "," & space ¬ & "ECID:" & space & ECID & space & "в порте:" & space & LocationID, LogFileName) -- Выполняем подготовку -- Формируем ссылку для получения доступных IPSW set ChooseUrl to "https://api.ipsw.me/v4/device/" & DeviceType & "?type=ipsw" --Логируем my WriteLog("Получаем информацию о последней версии ОС по ссылке:" & space & ChooseUrl, LogFileName) -- Получаем информацию о последней доступной версии ОС set BaseJSON to my GetJSON(ChooseUrl, LogFileName) -- Парсим JSON, если он был получен if BaseJSON is false then return -- Получаем контрольную сумму из JSON set CloudCheckSum to do shell script "echo" & space & quoted form of BaseJSON & space & "| jq -r '.sha1sum'" -- Получаем ссылку для скачивания "образа" IPSW из JSON set IPSWUrl to do shell script "echo" & space & quoted form of BaseJSON & space & "| jq -r '.url'" -- Получаем имя файла из JSON set CloudIPSWName to do shell script "basename" & space & quoted form of IPSWUrl -- Указываем родительский каталог для сохранения "образов" IPSW set IPSWRootPath to (POSIX path of "/Library/Application Support/Custom/RestoreDevices/IPSWs/") -- Формируем путь до файла "образа" IPSW set CurrentIPSWPath to (IPSWRootPath & DeviceType & "/" & CloudIPSWName) -- Если файл существует, сверяем контрольные суммы. -- Если файла нет -- скачиваем. Скачивание файла может занимать длительное время. set SelectIPSWStatus to my SelectIPSW(CloudCheckSum, CurrentIPSWPath, IPSWRootPath, DeviceType, IPSWUrl, LogFileName) -- При возникновении ошибки процесс останавливается if SelectIPSWStatus is not true then return -- запускаем восстановление my Restore(LocationID, ECID, DeviceClass, CurrentIPSWPath, LogFileName) end repeat --=================== --Обработчики --=================== -- Проверить или скачать IPSW --=================== on SelectIPSW(TrustedCheckSum, thisFile, RootFolder, SubFolder, thisURL, LogFileName) -- Создаём каталоги для загрузки IPSW, если необходимо my CreateFolder(RootFolder, SubFolder, LogFileName) -- Проверяем контрольную сумму существующего файла set StatusCheckSum to my CheckSum(TrustedCheckSum, thisFile, LogFileName) if StatusCheckSum is true then return true else -- Скачиваем set GetIPSWStatus to my GetIPSW(thisFile, thisURL, TrustedCheckSum, LogFileName) if GetIPSWStatus is true then return true else return false end if end if end SelectIPSW --=================== -- Получить JSON --=================== on GetJSON(thisURL, LogFileName) -- Запрос всех доступных IPSW для подключенной модели set thisJSON to do shell script "curl" & space & thisURL & space & "| jq '.firmwares'" -- Если возврат "null", то нет доступных IPSW if thisJSON is "null" then my WriteLog("Нет доступных IPSW. Остановка процесса.", LogFileName) return false else -- JSON получен, сортируем элементы массива по дате релиза и берём последний set thisJSON to do shell script "echo" & space & quoted form of thisJSON & space & "| jq -r 'sort_by(.releasedate) | last'" my WriteLog("Ответ сервера в формате JSON" & linefeed & thisJSON, LogFileName) return thisJSON end if end GetJSON --=================== -- Скачать IPSW --=================== on GetIPSW(thisFile, thisURL, TrustedCheckSum, LogFileName) -- Поиск параллельных процессов скачивания IPSW образов set UserName to short user name of (system info) set PSShellCommand to "ps -u" & space & UserName & space & "| grep '[c]url' | grep" & space & quoted form of thisURL & space & "> /dev/null 2>&1; echo $?" set CurrentDownloads to (do shell script PSShellCommand) as integer -- Если процесс curl был найден, возврат 0 if CurrentDownloads is 0 then -- Устанавливаем счётчик повторных запусков на 0 set Counter to 0 -- Повторяем цикл 5 раз repeat while Counter < 6 my WriteLog("Обнаружено параллельное скачивание файла по ссылке" & space & thisURL & linefeed & "Проверим ещё раз через 10 минут.", LogFileName) -- пауза в секундах delay 600 -- Добавляем к счётчику 1 выполненный проход set Counter to Counter + 1 -- Проверяем ещё раз set CurrentDownloads to (do shell script PSShellCommand) as integer -- Если очередная проверка не обнаружила параллельный процесс, считаем что он завершён if CurrentDownloads is 1 then -- Процесс завершён, можно продолжать my WriteLog("Параллельное скачивание завершено. Повторно выполним проверку контрольной суммы.", LogFileName) set StatusCheckSum to my CheckSum(TrustedCheckSum, thisFile, LogFileName) if StatusCheckSum is true then return true else my WriteLog("Возможно, параллельный процесс загрузки был завершён некорректно", LogFileName) my WriteLog("Попробуйте в ручную загрузить файл по ссылке" & linefeed & thisURL, LogFileName) my WriteLog("Остановка процесса.", LogFileName) return false end if exit repeat end if -- При достижении счётчика, останавливаем процесс if Counter is 5 then my WriteLog("За последний час процесс загрузки образа не был завершён или есть зависший процесс curl.", LogFileName) my WriteLog("Остановка процесса.", LogFileName) return false end if end repeat -- Если процесс curl не был найден, возврат 1 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("Запускаем скачивание:" & linefeed & curlShellCommand, LogFileName) try do shell script curlShellCommand my WriteLog("Файл скачен", LogFileName) return true on error errormsg number errNumber my WriteLog("Проблема скачивания файла. Curl вернул ошибку:" & space & errNumber, LogFileName) my WriteLog("Коды возврата можно посмотреть в \"man curl\" в разделе \"EXIT CODES\"", LogFileName) my WriteLog("Остановка процесса.", LogFileName) return false end try end if end GetIPSW --=================== --=================== -- Записать лог on WriteLog(message, logFile) --do shell script "mkdir -p" & space & quoted form of POSIX path of (do shell script "dirname" & space & quoted form of LogPath) do shell script "mkdir -p" & space & LogPath set timeStamp to (do shell script "date +'%Y-%m-%d %H:%M:%S'") set logMessage to timeStamp & space & "-" & space & message & linefeed do shell script "echo" & space & quoted form of logMessage & space & ">>" & space & quoted form of (LogPath & logFile) end WriteLog --=================== --=================== --Создать каталог для хранения IPSW этой модели on CreateFolder(RootFolder, SubFolder, LogFileName) -- Формируем полный путь к каталогу set targetFolder to RootFolder & SubFolder -- Проверяем существование каталога try -- Классические методы проверки существования каталога или файла используя вызов "System Events" -- не могут быть использованы, т.к. задача запускает от RootFolder, без GUI. do shell script "test -d" & space & quoted form of targetFolder my WriteLog("Каталог для IPSW файла уже существует:" & space & targetFolder, LogFileName) on error -- Создать каталог do shell script "mkdir -p" & space & quoted form of targetFolder my WriteLog("Создан каталог для сохранения IPSW файла:" & space & targetFolder, LogFileName) end try end CreateFolder --=================== --=================== --Сверка контрольной суммы on CheckSum(TrustedCheckSum, thisFile, LogFileName) -- Проверяем существование файла try -- Классические методы проверки существования каталога или файла используя вызов "System Events" -- не могут быть использованы, т.к. задача запускает от root, без GUI. do shell script "test -f" & space & quoted form of thisFile on error return false end try -- Файл существует, продолжаем проверку my WriteLog("Начинаем проверку контрольной суммы файла" & space & thisFile, LogFileName) -- Вычисляем контрольную сумму set thisCheckSum to word 1 of (do shell script "shasum -a 1" & space & quoted form of thisFile) if TrustedCheckSum is equal to thisCheckSum then my WriteLog("Контрольные суммы совпадают", LogFileName) return true else my WriteLog("Контрольные суммы не совпадают:" & space & TrustedCheckSum & space & "≠" & space & thisCheckSum, LogFileName) return false end if end CheckSum --=================== --=================== --Перезагрузка в DFU on RebootToDFU(LocationID, DeviceCalss, LogFileName) -- Список DFU портов. -- Обычно DFU порт хоста имеет идентификатор "0x100000", но зная Apple, в других модeлях это может быть изменено. set DFUPortList to {"0x100000"} set macvdmtool to "/usr/local/bin/macvdmtool" set macvdmtoolShellCommand to macvdmtool & space & "dfu" -- Проверяем существование macvdmtool try -- Классические методы проверки существования каталога или файла используя вызов "System Events" -- не могут быть использованы, т.к. задача запускает от root, без GUI. do shell script "test -f" & space & macvdmtool -- Если подключенный Mac подключен в DFU порт хоста if LocationID is in DFUPortList then my WriteLog("Попытка перезагрузить подключенный" & space & DeviceCalss & space & "в DFU режим" & linefeed & macvdmtoolShellCommand, LogFileName) try set macvdmtoolresult to do shell script macvdmtoolShellCommand my WriteLog("Лог утилиты macvdmtool:" & linefeed & macvdmtoolresult, LogFileName) my WriteLog("Mac" & space & DeviceCalss & space & "перезагружен в DFU режим", LogFileName) on error errormsg my WriteLog("Этот" & space & DeviceCalss & space & "не может быть переведён в DFU режим." & linefeed ¬ & "Убедитесь что это Mac с процессором Apple, кабель подключен в DFU порт и не используется кабель Thunderbol 3/4.", LogFileName) my WriteLog("Лог утилиты macvdmtool:" & linefeed & errormsg, LogFileName) my WriteLog("Остановка процесса.", LogFileName) return false end try end if on error my WriteLog("Утилита macvdmtool отсутствует. Остановка процесса.", LogFileName) return false end try end RebootToDFU --=================== --=================== --Восстановление с DFU on Restore(LocationID, ECID, DeviceCalss, PathToIPSW, LogFileName) if (DeviceCalss contains "Mac") then -- Выполняем попытку перезагрузки в DFU режим set DFUStatus to my RebootToDFU(LocationID, DeviceCalss, LogFileName) if DFUStatus is false then return end if -- Запуск восстановления в фоновом режиме set cfgutilShellCommand to "/usr/local/bin/cfgutil --progress --verbose -e" & space & ECID & space & "restore -I" & space & quoted form of PathToIPSW & space & ">>" & space & LogPath & ECID & ".log 2>&1 &" WriteLog("Запуск процесса восстановления:" & linefeed & cfgutilShellCommand, LogFileName) WriteLog("Лог работы cfgutil:" & linefeed, LogFileName) do shell script cfgutilShellCommand end Restore --=================== --=================== --Поиск подключенных устройств с помощью cfgutil on cfgutilDevList() -- Получаем инф. о подключенных устройствах в формате JSON set BaseJSON to do shell script "/usr/local/bin/cfgutil --format JSON list | jq" -- Парсим JSON, выбирая нужные данные и сохраняем контент как CVS, разделитель ";" set ParsedCSV to do shell script "echo" & space & quoted form of BaseJSON & space & "| jq -r '.Output | to_entries[] | [.value.locationID, .value.ECID, .value.deviceType] | join(\";\")'" -- Разбиваем на строки set LineCSV to paragraphs of ParsedCSV -- Пустой итоговый список set ResultList to {} -- Цикл обработки строк repeat with thisDev in LineCSV -- Используем разделитель ";" для работы с CSV 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 -- Модифицируем полученные данные приводя в нужный вид -- при использовании вывода инф. о девайсах в не текстовом формате, то используется целочисленное значение Location ID. -- Конвертируем в HEX 1048576 -> 0x100000 set LocationID to "0x" & (do shell script "printf '%X' " & LocationID) -- Выполняем проверку существования log-файла с именем ECID устройства, -- Если лог есть, то считаем, что запускать повторно процедуру восстановления не нужно. Не добавляем в список результатов. try do shell script "test -f" & space & quoted form of (LogPath & ECID & ".log") on error -- Добавляем в результат set end of ResultList to {DeviceType, ECID, LocationID} end try end repeat return ResultList --Пример вывода --{{"iPhone8,4", "0x00480000000E4", "0x8320000"}, {"Macmini9,1", "0x928200000001E", "0x100000"}, {"iPhone7,2", "0x19610000000026", "0x8310000"}} end cfgutilDevList --=================== --=================== --Поиск подключенных устройств с помощью system_profiler on systemprofilerDevList() --Ищем среди подключенных USB устройств имена в которых содержится Mac, DFU или Recovery. -- Отображаем найденные строки, а так же 5 и 8 строку для отображения Serial Number и -- Location. Location необходим в качестве ключа для объединения со списком cfgutilDevList() set SPUSBInfo to do shell script "system_profiler SPUSBDataType | awk '{ lines[NR] = $0 } /Mac|DFU|Recovery/ { mac_dfu_recovery_line = NR mac_dfu_recovery_found = 1 } /Serial Number:/ && mac_dfu_recovery_found && (NR == mac_dfu_recovery_line + 5) { serial_number_line = NR } /Location ID:/ && mac_dfu_recovery_found && (NR == mac_dfu_recovery_line + 8) { if ( mac_dfu_recovery_found && serial_number_line && NR == mac_dfu_recovery_line + 8) { sub(/^ +/, \"\", lines[mac_dfu_recovery_line]) sub(/^ +/, \"\", lines[serial_number_line]) sub(/^ +/, \"\", $0) print lines[mac_dfu_recovery_line] print lines[serial_number_line] print $0 mac_dfu_recovery_found = 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 "echo" & space & Serial & space & "| awk ' /Serial Number:/ { value = substr($0, index($0, \"Serial Number:\") + 15); if (value ~ /^[0-9A-Za-z]{6,}/) { print value; found = 1; } } /SRNM:/ { split($0, arr, /SRNM:\\[|\\]/); if (length(arr) > 1) { print arr[2]; found = 1; } next; } END { if (found == 0) print \"Serial Number not found\"; } '" (* Меняем формат Location, т.к. у system_profiler и cfgutil они отличаются Пример 0x00100000 --> 0x100000 0x08310000 --> 0x8310000 Поэтому конвертируем 0x08310000 в 8310000 и добавляем 0x спереди, чтобы значение соответствовало формату cfgutil *) --Вырезаем значение из строки set LocationID to text 14 thru 23 of LocationID set LocationID to "0x" & (do shell script "printf '%X' " & LocationID) -- Добавляем инф о каждом устройстве в подсписке в список set end of ResultList to {Model, Serial, LocationID} end repeat -- Выводим итоговый список return ResultList --Пример вывода --{{"Mac mini", "H2000000Q6NY", "0x100000"}, {"Apple Mobile Device (Recovery Mode)", "DX6000000TVL", "0x8320000"}, {"Apple Mobile Device (Recovery Mode)", "F170000005MR", "0x8310000"}} end systemprofilerDevList --=================== -- Обработчик для объединения списков --https://www.macscripter.net/t/merge-2-arrays-by-key/76457/10 --=================== on MergeLists(List1, List2) -- Создаём пустой итоговый список set ResultList to {} -- Проходим по первому списку repeat with subList1 in List1 -- Извлекаем значения подсписков из первого списка "List1" в подсписок "subList1" set {Value01, Value02, ID01} to subList1 -- Проходим по второму списку repeat with subList2 in List2 -- Извлекаем значения подсписков из второго списка "List2" в подсписок "subList2" 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 --===================
Создадим Launch Daemon
sudo nano /Library/LaunchDaemons/com.custom.restoredevices.plist
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key> <string>custom.restoredevices</string> <key>ProgramArguments</key> <array> <string>/usr/local/bin/cfgutil</string> <string>exec</string> <string>--on-attach</string> <string>osascript '/Library/Application Support/Custom/RestoreDevices/Restore.scpt'</string> </array> <key>RunAtLoad</key> <true/> <key>StandardErrorPath</key> <string>/tmp/custom.restoredevices.stderr</string> <key>StandardOutPath</key> <string>/tmp/custom.restoredevices.stdout</string> </dict> </plist>
Убедимся, что plist не содержит ошибок
plutil -lint /Library/LaunchDaemons/com.custom.restoredevices.plist
Загрузим и запустим демон
sudo launchctl bootstrap system/ /Library/LaunchDaemons/com.custom.restoredevices.plist
Убедимся, что демон запущен
sudo launchctl list | grep restoredev
Найдём процесс
ps aux | grep '[c]fgutil'
Launch Daemon работает независимо от входа пользователя и будет готов к работе сразу после загрузки macOS. Если что-то пошло не так, то необработанные ошибки будут сохранятся в файле custom.restoredevices.stderr, прочие информационные сообщения /tmp/custom.restoredevices.stdout
custom.restoredevices.stderr
/tmp/custom.restoredevices.stdout
При ненадёжном или медленном интернет соединении образы восстановления лучше скачать заблаговременно.
Процесс восстановления логируется созданным обработчиком WriteLog(message, logFile), на выходе мы получаем примерно такие логи:
Восстановление iPhone
Восстановление Mac
Администраторы, которые уже погружались в автоматизацию Apple Configurator могут сказать, что лепнина из двух обработчиков systemprofilerDevList() и cfgutilDevList(), а затем ещё и объединение результатов обработчиком MergeLists() лишнее и ненужное звено, потому что производитель уже предоставил встроенные инструменты, получить информацию о подключенных устройствах можно так:
set DevicesList to my DevicesList() on DevicesList() try tell script "Configuration Utility" -- Вызываем необходимые свойства и присваиваем значения в переменные copy CNFGvaluesOfSpecifiedProperties("all", {"deviceType", "ECID", "locationID", "serialNumber", "bootedState"}, false) to ¬ {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 "0x" & (do shell script "printf '%X' " & LocationID) -- Добавляем подсписки с значениями устройства в список set end of ResultList to {DeviceType, ECID, LocationID, SerialNumber, BootedState} end repeat return ResultList -- Пример вывода -- {{"iPhone8,4", "0x0048FC93999E4", "0x8310000", missing value, "Recovery"}, {"Macmini9,1", "0x9282C1440801E", "0x100000", missing value, "DFU"}, {"iPhone7,2", "0x19610E38E81026", "0x8320000", "F17R30000000", "Booted"}} on error return false end try
Мне не понравился нюанс, который не позволяет получить серийный номер подключенных Mac, а так же мобильных устройств в режиме DFU. Если этот момент вам неважен, то проще использовать обработчик CNFGvaluesOfSpecifiedProperties(), доступный из коробки.
Получить справку о наборе встроенных обработчиков для управления автоматизацией можно так:
tell script "Configuration Utility" to show help
Подводя итог, можно сказать, что подобная автоматизация может использоваться автономно, например, на стоящем на столе ИТ отдела Mac Mini 2020 года без монитора и периферии с торчащими Type-C кабелями для подключения как одиночных устройств, так и множества.
P.S. В моём ведении нет сотен Mac, iPhone или iPad, которые нужно перезаливать перед выдачей, только несколько личных старых iPhone и Mac Mini которые можно перезаливать каждый день. Попробуйте этот вариант и сообщите в комментариях как это работает на множестве подключенных устройств.
Проверено на следующих конфигурациях:
Автор первичной редакции: Виталий Якоб Время публикации: 05.12.2024 16:57