# bins.v2.ps1 — установка выбранных программ из списка installers.json # # Чем отличается от bins.ps1: # * совместимость с Windows 7 и PowerShell 2.0..5.1: все возможности # (ConvertFrom-Json, Expand-Archive, Get-FileHash, Get-Package, Unblock-File, # ZipFile, TLS 1.2) определяются в рантайме и имеют запасной путь; # * докачка сделана безопасно: проверяется ответ 206 и Content-Range, # повторная отправка при смене файла на сервере начинается с нуля, # недокачанный файл больше не удаляется и не портится; # * "уже скачан" подтверждается маркером завершения и SHA-256, а не фактом # наличия файла с таким именем; # * коды возврата инсталляторов проверяются (msiexec 1603 — это ошибка, # а не "успешно установлена"); учитываются 3010 и 1641; # * папки распаковки защищены от выхода за пределы целевого каталога; # * единые точки логирования, паузы и обработки ошибок, есть -DryRun и -SelfTest. # # ШАПКА НАРОЧНО ОФОРМЛЕНА СТРОЧНЫМИ КОММЕНТАРИЯМИ, А НЕ БЛОКОМ <# #>: # при запуске вида "irm | iex" служебные символы BOM попадают в начало # текста, и блочный комментарий перестаёт распознаваться — парсер начинает # читать его содержимое как код. Со строчными комментариями этого не происходит. # # ВАЖНО ПРО КОДИРОВКУ: # Файл обязан быть сохранён в UTF-8 С BOM. PowerShell 5.1 читает .ps1 без BOM # в системной ANSI-кодировке (windows-1251 на русской Windows), и тогда беда # не только в кракозябрах: часть байтов превращается в символы вида U+201A, # которые PowerShell считает кавычками, и файл вообще перестаёт разбираться. # # ЗАПУСК (правильный способ — файлом): # powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\bins.v2.ps1 # .\bins.v2.ps1 -JsonPath .\installers.json -DryRun # офлайн-проверка списка # .\bins.v2.ps1 -SelfTest # проверка возможностей # # ЗАПУСК ОДНОЙ СТРОКОЙ (без ручного скачивания): # $f = "$env:TEMP\bins.v2.ps1"; iwr https://share.anykey54.ru/bins.v2.ps1 -OutFile $f # powershell -NoProfile -ExecutionPolicy Bypass -File $f # либо готовым загрузчиком — он чисто ASCII и потому переживает iex: # irm https://share.anykey54.ru/bins.boot.ps1 | iex # # ПОЧЕМУ НЕ "irm | iex": # Invoke-RestMethod для text/plain без charset раскодирует ответ как Latin-1, # и служебные символы BOM в начале текста превращаются в мусор. Проверено: # мусор перед первым оператором ломает разбор всего файла — ошибки начинаются # со строки [CmdletBinding()], до кода дело не доходит. Кириллица при этом # тоже портится. Убрать BOM нельзя: без него PowerShell 5.1 читает .ps1 # в системной ANSI-кодировке и файл не разбирается вообще (225 ошибок). # Поэтому скрипт нужно запускать файлом — bins.boot.ps1 делает это сам. [CmdletBinding()] param( # Откуда брать список установщиков. [string]$JsonUrl = 'https://share.anykey54.ru/list/installers.json', # Локальный файл вместо URL (офлайн-режим). Если указан — сеть не используется. [string]$JsonPath, # Куда складывать скачанное. По умолчанию — известная папка "Загрузки"\MyInstallers. [string]$DownloadFolder, # Файл журнала. По умолчанию — install_log.txt в папке загрузок. [string]$LogFile, [int]$Retries = 3, [int]$RetryDelaySeconds = 5, # Сколько ждать инсталлятор. 0 — ждать бесконечно. [int]$InstallerTimeoutMinutes = 30, # Размер журнала, после которого он уходит в install_log.1.txt. [int]$MaxLogSizeMB = 5, # Не опрашивать Get-Package (на Windows 10 это десятки секунд). [switch]$SkipPackageCheck, # Не определять, что уже установлено: считать всё неустановленным. [switch]$SkipInstalledCheck, # Не пропускать уже установленное: выбрать всё по умолчанию. [switch]$ForceReinstall, # Удалять скачанные установщики после успешной установки. [switch]$CleanupDownloads, # Скачивать заново, даже если файл подтверждён маркером завершения. [switch]$IgnoreCache, # Показывать в меню, почему программа считается установленной. [switch]$ShowDetectReason, # Ничего не скачивать и не запускать, только показать план. [switch]$DryRun, # Отбор без меню: номера записей (1,3,5), имена программ или слово all. # Нужен для запуска по расписанию и для автотестов. [string[]]$Select, # Не ждать ПРОБЕЛ в конце (для запуска по расписанию). [switch]$NoPause, # Проверить окружение и выйти. [switch]$SelfTest ) Set-StrictMode -Version 1.0 $ErrorActionPreference = 'Stop' # Проверка, что текст скрипта прочитан в правильной кодировке. # Если файл провезли через "irm | iex" без charset=utf-8, PowerShell # раскодирует байты как Latin-1: русские строки становятся мусором, и понять # вывод невозможно. Текст ниже намеренно набран латиницей — чтобы его можно # было прочитать даже в этом случае. Строка-образец и класс символов портятся # вместе, поэтому проверка ловит именно смену кодировки. $encodingProbe = 'Проверка кодировки' if ($encodingProbe -notmatch '^[А-Яа-яЁё ]+$') { Write-Host '' Write-Host 'ERROR: the script text was decoded with a wrong codepage.' -ForegroundColor Red Write-Host 'Do not run it as: irm | iex' -ForegroundColor Yellow Write-Host 'Invoke-RestMethod decodes text/plain without charset as Latin-1,' -ForegroundColor Yellow Write-Host 'so the Russian text (and the BOM) get corrupted.' -ForegroundColor Yellow Write-Host '' Write-Host 'Run it as a file instead:' -ForegroundColor Yellow Write-Host ' $f = "$env:TEMP\bins.v2.ps1"' -ForegroundColor Yellow Write-Host ' iwr https://share.anykey54.ru/bins.v2.ps1 -OutFile $f' -ForegroundColor Yellow Write-Host ' & $f' -ForegroundColor Yellow Write-Host '' Write-Host 'Server-side fix: serve the file as' -ForegroundColor Yellow Write-Host ' Content-Type: text/plain; charset=utf-8' -ForegroundColor Yellow exit 1 } # --------------------------------------------------------------------------- # Состояние скрипта. Все переменные объявлены заранее: включён StrictMode, # обращение к неинициализированной переменной — ошибка. # --------------------------------------------------------------------------- $Script:Caps = @{} $Script:LogFile = $null $Script:LogWriter = $null $Script:MinFileLevel = 10 $Script:MinConsoleLevel = 20 $Script:NoPause = [bool]$NoPause $Script:IsInteractive = $false $Script:UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' $Script:LogLevels = @{ DEBUG = 10; INFO = 20; SUCCESS = 25; WARNING = 30; ERROR = 40 } # =========================================================================== # Возможности рантайма # =========================================================================== function Test-CommandAvailable { param([string]$Name) $command = Get-Command -Name $Name -ErrorAction SilentlyContinue return ($null -ne $command) } function Test-TypeAvailable { # Мало проверить наличие типа: сборку нужно ещё и загрузить, иначе её не # найдёт ни Type.GetType (ищет в mscorlib и вызывающей сборке), ни поиск # по имени. Проверка идёт через '-as [type]' по строке, без литерала типа: # литерал несуществующего типа — это ошибка разбора, а не $false. param( [string]$TypeName, [string]$AssemblyName ) try { if ($AssemblyName) { Add-Type -AssemblyName $AssemblyName -ErrorAction Stop } return ($null -ne ($TypeName -as [type])) } catch { return $false } } function Initialize-Capabilities { $caps = @{} $caps['PSVersion'] = $PSVersionTable.PSVersion.ToString() $caps['HasConvertFromJson'] = (Test-CommandAvailable 'ConvertFrom-Json') $caps['HasGetFileHash'] = (Test-CommandAvailable 'Get-FileHash') $caps['HasExpandArchive'] = (Test-CommandAvailable 'Expand-Archive') $caps['HasGetPackage'] = (Test-CommandAvailable 'Get-Package') $caps['HasUnblockFile'] = (Test-CommandAvailable 'Unblock-File') $caps['HasZipFileApi'] = (Test-TypeAvailable -TypeName 'System.IO.Compression.ZipFile' -AssemblyName 'System.IO.Compression.FileSystem') $caps['HasZipPackageApi'] = (Test-TypeAvailable -TypeName 'System.IO.Packaging.ZipPackage' -AssemblyName 'WindowsBase') # JavaScriptSerializer из .NET 3.5 — быстрый запасной вариант для PowerShell 2.0. $caps['HasJavaScriptSerializer'] = (Test-TypeAvailable -TypeName 'System.Web.Script.Serialization.JavaScriptSerializer' -AssemblyName 'System.Web.Extensions') $tlsNames = @() try { $tlsNames = @([Enum]::GetNames([System.Net.SecurityProtocolType])) } catch { } $caps['HasTls12'] = ($tlsNames -contains 'Tls12') $caps['TlsNames'] = $tlsNames $caps['ShellApplication'] = $false try { $shellType = [Type]::GetTypeFromProgID('Shell.Application') $caps['ShellApplication'] = ($null -ne $shellType) } catch { } return $caps } function Initialize-Tls { $wanted = @() try { $names = @([Enum]::GetNames([System.Net.SecurityProtocolType])) foreach ($candidate in @('Tls', 'Tls11', 'Tls12')) { if ($names -contains $candidate) { $wanted += $candidate } } } catch { } if ($wanted.Count -eq 0) { Write-Log 'Не удалось определить поддерживаемые версии TLS.' 'WARNING' return } try { # Объединяем, а не заменяем: часть зеркал до сих пор умеет только TLS 1.0. $protocol = [Enum]::Parse([System.Net.SecurityProtocolType], ($wanted -join ', ')) [System.Net.ServicePointManager]::SecurityProtocol = $protocol Write-Log ('TLS: включены ' + ($wanted -join ', ')) 'DEBUG' } catch { Write-Log ('Не удалось настроить TLS: ' + $_.Exception.Message) 'WARNING' } if (-not $Script:Caps['HasTls12']) { Write-Log 'В системе нет TLS 1.2 (.NET ниже 4.5 или Windows 7 без KB3140245). Часть сайтов может не открыться.' 'WARNING' } try { # Некоторые серверы отвечают на Expect: 100-continue с задержкой вплоть до таймаута. [System.Net.ServicePointManager]::Expect100Continue = $false } catch { } } function Initialize-ConsoleEncoding { try { $utf8 = New-Object System.Text.UTF8Encoding($false) [Console]::OutputEncoding = $utf8 $global:OutputEncoding = $utf8 } catch { # На урезанных консолях это может не сработать — не критично. } } # =========================================================================== # Права администратора # =========================================================================== function Test-Administrator { try { $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object System.Security.Principal.WindowsPrincipal($identity) return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } # =========================================================================== # Логирование # =========================================================================== function Get-LogLevelValue { param([string]$Level) if ($Script:LogLevels.ContainsKey($Level)) { return $Script:LogLevels[$Level] } return 20 } function Write-Log { param( [string]$Message, [string]$Level = 'INFO', [switch]$NoConsole ) $entry = '[' + (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') + '] [' + $Level + '] ' + $Message if ($null -ne $Script:LogWriter -and (Get-LogLevelValue $Level) -ge $Script:MinFileLevel) { try { $Script:LogWriter.WriteLine($entry) } catch { } } if (-not $NoConsole -and (Get-LogLevelValue $Level) -ge $Script:MinConsoleLevel) { switch ($Level) { 'ERROR' { Write-Host $entry -ForegroundColor Red } 'WARNING' { Write-Host $entry -ForegroundColor Yellow } 'SUCCESS' { Write-Host $entry -ForegroundColor Green } 'DEBUG' { Write-Host $entry -ForegroundColor DarkGray } default { Write-Host $entry } } } } function Initialize-LogFile { param( [string]$Path, [int]$MaxSizeMB = 5, [int]$MinFileLevel = 10 ) $Script:MinFileLevel = $MinFileLevel $directory = Split-Path -Path $Path -Parent if ($directory -and -not (Test-Path -LiteralPath $directory)) { New-Item -Path $directory -ItemType Directory -Force | Out-Null } # Ротация: журнал копится между запусками, поэтому ограничиваем его размер. if ($MaxSizeMB -gt 0 -and (Test-Path -LiteralPath $Path)) { $size = (Get-Item -LiteralPath $Path).Length if ($size -gt ($MaxSizeMB * 1MB)) { $archive = Join-Path $directory ` ([System.IO.Path]::GetFileNameWithoutExtension($Path) + '.1' + [System.IO.Path]::GetExtension($Path)) if (Test-Path -LiteralPath $archive) { Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue } Move-Item -LiteralPath $Path -Destination $archive -Force -ErrorAction SilentlyContinue } } $encoding = New-Object System.Text.UTF8Encoding($false) $Script:LogWriter = New-Object System.IO.StreamWriter -ArgumentList @($Path, $true, $encoding) $Script:LogWriter.AutoFlush = $true $Script:LogFile = $Path } function Close-LogFile { if ($null -ne $Script:LogWriter) { try { $Script:LogWriter.Flush() $Script:LogWriter.Close() } catch { } $Script:LogWriter = $null } } # =========================================================================== # Пауза и завершение # =========================================================================== function Wait-ForKey { param([string]$Message = 'Нажмите ПРОБЕЛ, чтобы закрыть...') if ($Script:NoPause -or -not $Script:IsInteractive) { return } Write-Host '' Write-Host $Message -NoNewline try { do { $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') } while ($key.VirtualKeyCode -ne 32) } catch { # Ввод перенаправлен или хост без RawUI — ждём Enter. try { $null = Read-Host } catch { } } Write-Host '' } function Stop-Script { param( [int]$ExitCode = 0, [string]$Message = $null, [string]$Level = 'INFO' ) if ($Message) { Write-Log $Message $Level } if ($ExitCode -ne 0) { Wait-ForKey } Close-LogFile exit $ExitCode } # =========================================================================== # Работа со значениями # =========================================================================== function Get-Value { # Достаёт значение из хэш-таблицы (запасной JSON-парсер) или из объекта # (ConvertFrom-Json). Отсутствующее поле даёт $Default, а не ошибку. param( $Object, [string]$Name, $Default = $null ) if ($null -eq $Object) { return $Default } if ($Object -is [System.Collections.IDictionary]) { if ($Object.Contains($Name)) { $value = $Object[$Name] if ($null -ne $value) { return $value } } return $Default } $property = $Object.PSObject.Properties[$Name] if ($null -ne $property -and $null -ne $property.Value) { return $property.Value } return $Default } function Get-FirstValue { # Первое непустое значение из списка имён полей — терпимость к разным схемам. param( $Object, [string[]]$Names, $Default = $null ) foreach ($name in $Names) { $value = Get-Value -Object $Object -Name $name if ($null -ne $value) { if ($value -is [string] -and $value.Trim().Length -eq 0) { continue } return $value } } return $Default } function Get-ObjectFieldNames { param($Object) if ($null -eq $Object) { return @() } if ($Object -is [System.Collections.IDictionary]) { $names = @() foreach ($key in $Object.Keys) { $names += [string]$key } return $names } $names = @() foreach ($property in $Object.PSObject.Properties) { $names += $property.Name } return $names } function Format-FileSize { param([double]$Bytes) if ($Bytes -ge 1GB) { return ('{0:N2} GB' -f ($Bytes / 1GB)) } if ($Bytes -ge 1MB) { return ('{0:N1} MB' -f ($Bytes / 1MB)) } if ($Bytes -ge 1KB) { return ('{0:N0} KB' -f ($Bytes / 1KB)) } return ('{0:N0} B' -f $Bytes) } function Get-ClampedPercent { param([double]$Value) if ($Value -lt 0) { return 0 } if ($Value -gt 100) { return 100 } return [int][Math]::Round($Value) } # =========================================================================== # Файловые утилиты # =========================================================================== function Read-TextFileAuto { # Читает текст в UTF-8/UTF-16 с BOM, а без BOM — сначала пробует строгий # UTF-8 и только потом системную ANSI. Так же ведёт себя редактор. param([string]$Path) $bytes = [System.IO.File]::ReadAllBytes($Path) if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3) } if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) { return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2) } if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) { return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2) } try { $strict = New-Object System.Text.UTF8Encoding($false, $true) return $strict.GetString($bytes) } catch { return [System.Text.Encoding]::Default.GetString($bytes) } } function Get-FileHashCompat { param([string]$Path) if ($Script:Caps['HasGetFileHash']) { $result = Get-FileHash -LiteralPath $Path -Algorithm SHA256 return $result.Hash.ToLowerInvariant() } $sha = $null $stream = $null try { $sha = [System.Security.Cryptography.SHA256]::Create() $stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) $bytes = $sha.ComputeHash($stream) $builder = New-Object System.Text.StringBuilder foreach ($byte in $bytes) { [void]$builder.Append($byte.ToString('x2')) } return $builder.ToString() } finally { if ($null -ne $stream) { $stream.Close() } if ($null -ne $sha) { $sha.Clear() } } } function Unblock-FileCompat { # Снимает метку "файл из интернета", иначе установщик получит MOTW. param([string]$Path) if ($Script:Caps['HasUnblockFile']) { try { Unblock-File -LiteralPath $Path -ErrorAction SilentlyContinue } catch { } return } try { Remove-Item -LiteralPath ($Path + ':Zone.Identifier') -Force -ErrorAction SilentlyContinue } catch { } } function Get-DownloadsFolder { # На Win7 папка "Загрузки" локализована, поэтому спрашиваем оболочку, # а к англоязычному пути откатываемся только если COM недоступен. try { $shell = New-Object -ComObject Shell.Application $folder = $shell.NameSpace('shell:Downloads') if ($null -ne $folder -and $folder.Self.Path) { return [string]$folder.Self.Path } } catch { } $profilePath = [Environment]::GetFolderPath('UserProfile') return (Join-Path $profilePath 'Downloads') } # =========================================================================== # JSON # # Разбор JSON в три слоя, от быстрого к универсальному: # 1. ConvertFrom-Json — PowerShell 3.0+ # 2. JavaScriptSerializer — .NET 3.5+ (обычно есть даже на Windows 7) # 3. собственный парсер — работает всегда, включая PowerShell 2.0 # =========================================================================== $Script:JsonText = '' $Script:JsonChars = $null $Script:JsonPos = 0 $Script:JsonLength = 0 function JsonSkipWhitespace { while ($Script:JsonPos -lt $Script:JsonLength) { $ch = $Script:JsonChars[$Script:JsonPos] if ($ch -eq ' ' -or $ch -eq "`t" -or $ch -eq "`r" -or $ch -eq "`n") { $Script:JsonPos++ } else { break } } } function JsonReadString { # Вызывается, когда текущий символ — открывающая кавычка. $Script:JsonPos++ $builder = New-Object System.Text.StringBuilder while ($Script:JsonPos -lt $Script:JsonLength) { $ch = $Script:JsonChars[$Script:JsonPos] $Script:JsonPos++ if ($ch -eq '"') { return $builder.ToString() } if ($ch -ne '\') { [void]$builder.Append($ch) continue } if ($Script:JsonPos -ge $Script:JsonLength) { break } $esc = $Script:JsonChars[$Script:JsonPos] $Script:JsonPos++ switch ($esc) { '"' { [void]$builder.Append('"') } '\' { [void]$builder.Append('\') } '/' { [void]$builder.Append('/') } 'b' { [void]$builder.Append([char]8) } 'f' { [void]$builder.Append([char]12) } 'n' { [void]$builder.Append([char]10) } 'r' { [void]$builder.Append([char]13) } 't' { [void]$builder.Append([char]9) } 'u' { if ($Script:JsonPos + 4 -gt $Script:JsonLength) { throw 'Обрыв escape-последовательности \u в JSON.' } $hex = $Script:JsonText.Substring($Script:JsonPos, 4) $Script:JsonPos += 4 [void]$builder.Append([char][Convert]::ToInt32($hex, 16)) } default { [void]$builder.Append($esc) } } } throw 'Незакрытая строка в JSON.' } function JsonReadNumber { $start = $Script:JsonPos while ($Script:JsonPos -lt $Script:JsonLength) { $ch = $Script:JsonChars[$Script:JsonPos] if (($ch -ge '0' -and $ch -le '9') -or $ch -eq '-' -or $ch -eq '+' -or $ch -eq '.' -or $ch -eq 'e' -or $ch -eq 'E') { $Script:JsonPos++ } else { break } } $raw = $Script:JsonText.Substring($start, $Script:JsonPos - $start) if ($raw.Length -eq 0) { throw ('Ожидалось значение в позиции ' + $start + '.') } $intValue = 0 if ($raw -notmatch '[.eE]') { if ([int]::TryParse($raw, [ref]$intValue)) { return $intValue } } return [double]::Parse($raw, [System.Globalization.CultureInfo]::InvariantCulture) } function JsonReadObject { $Script:JsonPos++ # { $table = @{} JsonSkipWhitespace if ($Script:JsonPos -lt $Script:JsonLength -and $Script:JsonChars[$Script:JsonPos] -eq '}') { $Script:JsonPos++ return $table } while ($true) { JsonSkipWhitespace if ($Script:JsonPos -ge $Script:JsonLength) { throw 'Незакрытый объект JSON.' } if ($Script:JsonChars[$Script:JsonPos] -ne '"') { throw ('Ожидалось имя поля в позиции ' + $Script:JsonPos + '.') } $key = JsonReadString JsonSkipWhitespace if ($Script:JsonPos -ge $Script:JsonLength -or $Script:JsonChars[$Script:JsonPos] -ne ':') { throw ('Ожидалось ":" после поля "' + $key + '".') } $Script:JsonPos++ $table[$key] = JsonReadValue JsonSkipWhitespace if ($Script:JsonPos -ge $Script:JsonLength) { throw 'Незакрытый объект JSON.' } $ch = $Script:JsonChars[$Script:JsonPos] $Script:JsonPos++ if ($ch -eq '}') { break } if ($ch -ne ',') { throw ('Ожидалась "," или "}" в позиции ' + ($Script:JsonPos - 1) + '.') } } return $table } function JsonReadArray { $Script:JsonPos++ # [ $items = @() JsonSkipWhitespace if ($Script:JsonPos -lt $Script:JsonLength -and $Script:JsonChars[$Script:JsonPos] -eq ']') { $Script:JsonPos++ return $items } while ($true) { # Запятая перед значением сохраняет вложенные массивы: без неё # PowerShell "разворачивает" массив внутрь внешнего. $items += ,(JsonReadValue) JsonSkipWhitespace if ($Script:JsonPos -ge $Script:JsonLength) { throw 'Незакрытый массив JSON.' } $ch = $Script:JsonChars[$Script:JsonPos] $Script:JsonPos++ if ($ch -eq ']') { break } if ($ch -ne ',') { throw ('Ожидалась "," или "]" в позиции ' + ($Script:JsonPos - 1) + '.') } } return $items } function JsonReadValue { JsonSkipWhitespace if ($Script:JsonPos -ge $Script:JsonLength) { throw 'Неожиданный конец JSON.' } $ch = $Script:JsonChars[$Script:JsonPos] switch ($ch) { '{' { return (JsonReadObject) } '[' { return (JsonReadArray) } '"' { return (JsonReadString) } 't' { if ($Script:JsonText.Substring($Script:JsonPos, 4) -ne 'true') { throw ('Ожидалось true в позиции ' + $Script:JsonPos + '.') } $Script:JsonPos += 4 return $true } 'f' { if ($Script:JsonText.Substring($Script:JsonPos, 5) -ne 'false') { throw ('Ожидалось false в позиции ' + $Script:JsonPos + '.') } $Script:JsonPos += 5 return $false } 'n' { if ($Script:JsonText.Substring($Script:JsonPos, 4) -ne 'null') { throw ('Ожидалось null в позиции ' + $Script:JsonPos + '.') } $Script:JsonPos += 4 return $null } default { return (JsonReadNumber) } } } function ConvertFrom-JsonFallback { param([string]$Text) if ($null -eq $Text) { throw 'Пустой JSON.' } $Script:JsonText = $Text $Script:JsonChars = $Text.ToCharArray() $Script:JsonPos = 0 $Script:JsonLength = $Text.Length if ($Script:JsonLength -gt 0 -and [int]$Text[0] -eq 0xFEFF) { $Script:JsonPos = 1 } $value = JsonReadValue JsonSkipWhitespace if ($Script:JsonPos -lt $Script:JsonLength) { throw ('Лишние данные после JSON, позиция ' + $Script:JsonPos + '.') } return $value } function ConvertFrom-JsonCompat { param( [string]$Text, [switch]$ForceFallback ) if (-not $ForceFallback) { if ($Script:Caps['HasConvertFromJson']) { return ($Text | ConvertFrom-Json) } if ($Script:Caps['HasJavaScriptSerializer']) { try { $serializer = New-Object System.Web.Script.Serialization.JavaScriptSerializer return $serializer.DeserializeObject($Text) } catch { Write-Log ('JavaScriptSerializer не справился, разбираем встроенным парсером: ' + $_.Exception.Message) 'DEBUG' } } } return (ConvertFrom-JsonFallback -Text $Text) } # =========================================================================== # Список установщиков # =========================================================================== function ConvertTo-InstallerList { # Приводит разобранный JSON к единому виду. Поля читаются по нескольким # возможным именам, поэтому список можно расширять, не ломая скрипт. param($Data) if ($null -eq $Data) { throw 'Список установщиков пуст.' } # Если список завёрнут в объект-контейнер, разворачиваем его. if ($Data -is [System.Collections.IDictionary] -or $Data -is [System.Management.Automation.PSCustomObject]) { foreach ($wrapper in @('installers', 'programs', 'items', 'list', 'apps')) { $candidate = Get-Value -Object $Data -Name $wrapper if ($null -eq $candidate -or $candidate -is [string]) { continue } Write-Log ('Список программ найден в поле "' + $wrapper + '".') 'DEBUG' $Data = $candidate break } } $result = @() $index = 0 foreach ($item in @($Data)) { $index++ $name = [string](Get-FirstValue -Object $item -Names @('name', 'title', 'displayName') -Default '') if ($name.Trim().Length -eq 0) { Write-Log ('Запись №' + $index + ' пропущена: нет поля name. Найдены поля: ' + ((Get-ObjectFieldNames -Object $item) -join ', ')) 'WARNING' continue } $urls = @() foreach ($candidate in @(Get-FirstValue -Object $item -Names @('urls', 'url') -Default @())) { if ($null -ne $candidate -and ([string]$candidate).Trim().Length -gt 0) { $urls += ([string]$candidate).Trim() } } if ($urls.Count -eq 0) { Write-Log ($name + ': пропущена, нет ни одной ссылки (urls/url).') 'WARNING' continue } $hash = [string](Get-FirstValue -Object $item -Names @('sha256', 'hash', 'checksum') -Default '') $hash = ($hash.Trim().ToLowerInvariant() -replace '[^0-9a-f]', '') if ($hash.Length -ne 64) { $hash = '' } $result += New-Object PSObject -Property @{ Name = $name.Trim() Urls = $urls Arguments = [string](Get-FirstValue -Object $item -Names @('arguments', 'args', 'installArguments', 'silentArgs') -Default '') PostExtract = [string](Get-FirstValue -Object $item -Names @('postExtract', 'postExtractFile', 'runAfterExtract') -Default '') Detect = @(Get-FirstValue -Object $item -Names @('detect', 'detectNames', 'alreadyInstalled') -Default @()) Sha256 = $hash Category = [string](Get-FirstValue -Object $item -Names @('category', 'group') -Default '') ExtractTo = [string](Get-FirstValue -Object $item -Names @('extractTo', 'targetFolder') -Default '') FileName = [string](Get-FirstValue -Object $item -Names @('fileName', 'file') -Default '') Fields = @(Get-ObjectFieldNames -Object $item) } } return $result } function Get-InstallerList { param( [string]$Url, [string]$Path, [int]$Retries = 3, [int]$RetryDelaySeconds = 5 ) if ($Path) { if (-not (Test-Path -LiteralPath $Path)) { throw ('Файл списка не найден: ' + $Path) } Write-Log ('Читаем список из файла: ' + $Path) $text = Read-TextFileAuto -Path $Path } else { Write-Log ('Загружаем список: ' + $Url) $text = Get-TextFromUrl -Url $Url -Retries $Retries -RetryDelaySeconds $RetryDelaySeconds } if ($null -eq $text -or $text.Trim().Length -eq 0) { throw 'Получен пустой список установщиков.' } $data = ConvertFrom-JsonCompat -Text $text $installers = @(ConvertTo-InstallerList -Data $data) if ($installers.Count -eq 0) { throw 'В списке нет ни одной пригодной записи.' } # Полезно при подгонке скрипта под новый список: видно, какие поля вообще есть. $allFields = @() foreach ($installer in $installers) { foreach ($field in $installer.Fields) { if ($allFields -notcontains $field) { $allFields += $field } } } Write-Log ('Поля в списке: ' + (($allFields | Sort-Object) -join ', ') + ' (записей: ' + $installers.Count + ')') 'DEBUG' return $installers } # =========================================================================== # Сеть # # Используется HttpWebRequest, а не HttpClient: он есть начиная с .NET 2.0, # то есть работает и на "голой" Windows 7 без .NET 4.5. # =========================================================================== function Get-WebResponseSafe { # GetResponse() бросает WebException на любом ответе вне 2xx, поэтому статус # приходится доставать из исключения. param($Request) $result = @{ Response = $null; StatusCode = 0; StatusText = ''; Error = $null } try { $response = $Request.GetResponse() $httpResponse = [System.Net.HttpWebResponse]$response $result['Response'] = $response $result['StatusCode'] = [int]$httpResponse.StatusCode $result['StatusText'] = [string]$httpResponse.StatusDescription } catch [System.Net.WebException] { $webError = $_.Exception if ($null -ne $webError.Response) { $httpResponse = [System.Net.HttpWebResponse]$webError.Response $result['Response'] = $httpResponse $result['StatusCode'] = [int]$httpResponse.StatusCode $result['StatusText'] = [string]$httpResponse.StatusDescription } else { $result['Error'] = $webError.Message } } return $result } function New-DownloadRequest { param( [string]$Url, [long]$From = 0, [string]$IfRange = $null, [switch]$AllowCompression ) $request = [System.Net.HttpWebRequest]::Create($Url) $request.Method = 'GET' $request.AllowAutoRedirect = $true $request.MaximumAutomaticRedirections = 10 $request.UserAgent = $Script:UserAgent $request.Accept = '*/*' $request.KeepAlive = $true $request.Timeout = 60000 # соединение и получение заголовков $request.ReadWriteTimeout = 300000 # таймаут на одно чтение из потока if ($AllowCompression) { $request.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate } # Для установщиков сжатие не запрашиваем сознательно: при сжатом ответе # смещения в Range относятся к сжатому потоку, и докачка склеит мусор. if ($From -gt 0) { if ($From -gt [int]::MaxValue) { throw 'Файл больше 2 ГБ: докачка не поддерживается.' } $request.AddRange([int]$From) if ($IfRange) { $request.Headers['If-Range'] = $IfRange } } return $request } function Get-TextFromUrl { param( [string]$Url, [int]$Retries = 3, [int]$RetryDelaySeconds = 5 ) $attempt = 0 $lastError = 'неизвестная ошибка' while ($attempt -lt $Retries) { $attempt++ $response = $null try { $request = New-DownloadRequest -Url $Url -AllowCompression $request.Accept = 'application/json, text/plain, */*' $request.Timeout = 30000 $request.ReadWriteTimeout = 60000 $answer = Get-WebResponseSafe -Request $request $response = $answer['Response'] if ($null -eq $response) { throw ('Сетевая ошибка: ' + $answer['Error']) } $status = $answer['StatusCode'] if ($status -ne 200) { throw ('HTTP ' + $status + ' ' + $answer['StatusText']) } $stream = $response.GetResponseStream() $reader = New-Object System.IO.StreamReader -ArgumentList @($stream, [System.Text.Encoding]::UTF8, $true) try { return $reader.ReadToEnd() } finally { $reader.Close() } } catch { $lastError = $_.Exception.Message Write-Log ('Попытка ' + $attempt + ' из ' + $Retries + ' не удалась: ' + $lastError) 'WARNING' if ($attempt -lt $Retries) { Start-Sleep -Seconds $RetryDelaySeconds } } finally { if ($null -ne $response) { try { ([System.Net.HttpWebResponse]$response).Close() } catch { } } } } throw ('Не удалось получить ' + $Url + ' за ' + $Retries + ' попыток. Последняя ошибка: ' + $lastError) } # =========================================================================== # Загрузка файлов # # Рядом с файлом лежит маркер <файл>.resume. Он нужен для двух вещей: # * отличить полностью скачанный файл от обрывка с тем же именем; # * помнить ETag/Last-Modified, чтобы докачка не склеила две версии файла. # =========================================================================== function Get-ResumeMarkerPath { param([string]$Path) return ($Path + '.resume') } function Read-ResumeMarker { param([string]$Path) $result = @{} $markerPath = Get-ResumeMarkerPath -Path $Path if (-not (Test-Path -LiteralPath $markerPath)) { return $result } try { foreach ($line in [System.IO.File]::ReadAllLines($markerPath)) { if ($line -match '^\s*([A-Za-z]+)\s*=\s*(.*)$') { $result[$Matches[1]] = $Matches[2] } } } catch { Write-Log ('Не удалось прочитать маркер загрузки: ' + $_.Exception.Message) 'DEBUG' } return $result } function Write-ResumeMarker { param([string]$Path, $Data) $lines = @() foreach ($key in @('Url', 'ETag', 'LastModified', 'TotalLength', 'Sha256', 'Complete')) { $value = '' if ($Data.ContainsKey($key)) { $value = [string]$Data[$key] } $lines += ($key + '=' + $value) } try { $encoding = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllLines((Get-ResumeMarkerPath -Path $Path), [string[]]$lines, $encoding) } catch { Write-Log ('Не удалось сохранить маркер загрузки: ' + $_.Exception.Message) 'DEBUG' } } function Get-FileWithResume { param( [string]$Url, [string]$Destination, [string]$ExpectedHash = $null, [int]$Retries = 3, [int]$RetryDelaySeconds = 5, [string]$Label = '', [switch]$IgnoreCache ) if (-not $Label) { $Label = Split-Path -Path $Destination -Leaf } $activity = 'Скачивание: ' + $Label $marker = Read-ResumeMarker -Path $Destination # -IgnoreCache означает "начать заново", а не "докачать остаток": # иначе уже полный файл просто подтвердился бы ответом 206 на пустой Range. if ($IgnoreCache -and (Test-Path -LiteralPath $Destination)) { Write-Log ($Label + ': -IgnoreCache, удаляем ранее скачанный файл и начинаем заново.') 'DEBUG' Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Get-ResumeMarkerPath -Path $Destination) -Force -ErrorAction SilentlyContinue $marker = @{} } # --- Шаг 1: файл уже скачан и это подтверждено маркером --- if (-not $IgnoreCache -and (Test-Path -LiteralPath $Destination) -and $marker['Complete'] -eq '1' -and $marker['Url'] -eq $Url) { $size = (Get-Item -LiteralPath $Destination).Length $expectedSize = -1 if ([string]$marker['TotalLength'] -match '^\d+$') { $expectedSize = [long]$marker['TotalLength'] } if ($expectedSize -ne $size) { Write-Log ($Label + ': размер файла (' + $size + ') не совпадает с записанным (' + $expectedSize + '), перекачиваем.') 'WARNING' } elseif ($ExpectedHash) { $actualHash = Get-FileHashCompat -Path $Destination if ($actualHash -eq $ExpectedHash) { Write-Log ($Label + ': уже скачан, SHA-256 совпал, пропускаем загрузку.') 'DEBUG' return @{ Status = 'cached'; Path = $Destination; Length = $size; Sha256 = $actualHash } } Write-Log ($Label + ': SHA-256 не совпал (' + $actualHash + '), файл перекачивается.') 'WARNING' } else { Write-Log ($Label + ': уже скачан (' + (Format-FileSize $size) + '), пропускаем загрузку.') 'DEBUG' return @{ Status = 'cached'; Path = $Destination; Length = $size; Sha256 = '' } } Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Get-ResumeMarkerPath -Path $Destination) -Force -ErrorAction SilentlyContinue $marker = @{} } $attempt = 0 $lastError = 'неизвестная ошибка' $fatal = $false while ($attempt -lt $Retries) { $attempt++ $response = $null $stream = $null $fileStream = $null try { $existing = 0L if (Test-Path -LiteralPath $Destination) { $existing = (Get-Item -LiteralPath $Destination).Length } if ($existing -gt [int]::MaxValue) { Write-Log ($Label + ': локальный файл больше 2 ГБ, начинаем загрузку заново.') 'WARNING' Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue $existing = 0 $marker = @{} } $ifRange = $null if ($existing -gt 0 -and $marker['Url'] -eq $Url) { if ($marker['ETag']) { $ifRange = [string]$marker['ETag'] } elseif ($marker['LastModified']) { $ifRange = [string]$marker['LastModified'] } } $request = New-DownloadRequest -Url $Url -From $existing -IfRange $ifRange $answer = Get-WebResponseSafe -Request $request $response = $answer['Response'] if ($null -eq $response) { throw ('Сетевая ошибка: ' + $answer['Error']) } $status = $answer['StatusCode'] if ($status -eq 416) { # Огрызок длиннее файла на сервере — файл там заменили. Write-Log ($Label + ': локальный огрызок длиннее файла на сервере, начинаем заново.') 'WARNING' Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Get-ResumeMarkerPath -Path $Destination) -Force -ErrorAction SilentlyContinue $marker = @{} continue } if ($status -eq 401 -or $status -eq 403 -or $status -eq 404) { $fatal = $true throw ('HTTP ' + $status + ' ' + $answer['StatusText'] + ' — ' + $Url) } if ($status -ne 200 -and $status -ne 206) { throw ('HTTP ' + $status + ' ' + $answer['StatusText']) } $httpResponse = [System.Net.HttpWebResponse]$response # --- Шаг 2: решаем, дописывать или начинать заново --- $append = $false $total = -1L if ($status -eq 206 -and $existing -gt 0) { $append = $true $contentRange = [string]$httpResponse.Headers['Content-Range'] if ($contentRange -and $contentRange -match '/(\d+)\s*$') { $total = [long]$Matches[1] } if ($total -le 0 -and $httpResponse.ContentLength -ge 0) { $total = $existing + [long]$httpResponse.ContentLength } Write-Log ($Label + ': продолжаем загрузку с ' + (Format-FileSize $existing) + '.') 'DEBUG' } else { if ($existing -gt 0) { # Ключевая проверка: сервер проигнорировал Range и отдал файл # целиком. Дописать его к огрызку означало бы испортить файл. Write-Log ($Label + ': сервер ответил 200 вместо 206 (докачка не поддерживается), качаем заново.') 'WARNING' } $append = $false $existing = 0 if ($httpResponse.ContentLength -ge 0) { $total = [long]$httpResponse.ContentLength } } # Запоминаем ETag до начала записи: если загрузка оборвётся, # продолжение будет запрошено именно для этой версии файла. $marker = @{ Url = $Url ETag = [string]$httpResponse.Headers['ETag'] LastModified = [string]$httpResponse.Headers['Last-Modified'] TotalLength = $total Complete = '0' } Write-ResumeMarker -Path $Destination -Data $marker # --- Шаг 3: копируем поток --- $directory = Split-Path -Path $Destination -Parent if ($directory -and -not (Test-Path -LiteralPath $directory)) { New-Item -Path $directory -ItemType Directory -Force | Out-Null } $stream = $httpResponse.GetResponseStream() $fileMode = [System.IO.FileMode]::Create if ($append) { $fileMode = [System.IO.FileMode]::Append } $fileStream = [System.IO.File]::Open($Destination, $fileMode, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) $buffer = New-Object byte[] 65536 $written = $existing $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $lastUpdate = -200 while ($true) { $read = $stream.Read($buffer, 0, $buffer.Length) if ($read -le 0) { break } $fileStream.Write($buffer, 0, $read) $written += $read if ($stopwatch.ElapsedMilliseconds -ge ($lastUpdate + 200)) { $lastUpdate = $stopwatch.ElapsedMilliseconds $seconds = $stopwatch.ElapsedMilliseconds / 1000.0 $speed = 0 if ($seconds -gt 0) { $speed = ($written - $existing) / $seconds } if ($total -gt 0) { Write-Progress -Activity $activity ` -Status ((Format-FileSize $written) + ' из ' + (Format-FileSize $total) + ' (' + (Format-FileSize $speed) + '/с)') ` -PercentComplete (Get-ClampedPercent (($written / $total) * 100)) } else { Write-Progress -Activity $activity -Status ((Format-FileSize $written) + ' скачано') } } } $fileStream.Flush() $fileStream.Close(); $fileStream = $null $stream.Close(); $stream = $null $httpResponse.Close(); $response = $null Write-Progress -Activity $activity -Completed # --- Шаг 4: проверки --- $finalLength = (Get-Item -LiteralPath $Destination).Length if ($total -gt 0 -and $finalLength -ne $total) { throw ('Файл недокачан: ' + $finalLength + ' из ' + $total + ' байт.') } $actualHash = '' if ($ExpectedHash) { $actualHash = Get-FileHashCompat -Path $Destination if ($actualHash -ne $ExpectedHash) { Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Get-ResumeMarkerPath -Path $Destination) -Force -ErrorAction SilentlyContinue $fatal = $true throw ('SHA-256 не совпал: ожидался ' + $ExpectedHash + ', получен ' + $actualHash + '. Файл удалён.') } } $marker = @{ Url = $Url ETag = [string]$marker['ETag'] LastModified = [string]$marker['LastModified'] TotalLength = $finalLength Sha256 = $actualHash Complete = '1' } Write-ResumeMarker -Path $Destination -Data $marker $resultStatus = 'downloaded' if ($append) { $resultStatus = 'resumed' } return @{ Status = $resultStatus; Path = $Destination; Length = $finalLength; Sha256 = $actualHash } } catch { $lastError = $_.Exception.Message if ($fatal) { Write-Progress -Activity $activity -Completed throw $lastError } Write-Log ($Label + ': попытка ' + $attempt + ' из ' + $Retries + ' не удалась — ' + $lastError) 'WARNING' if ($attempt -lt $Retries) { $delay = $RetryDelaySeconds * $attempt Write-Log ('Повтор через ' + $delay + ' с. Недокачанный файл сохранён для продолжения.') 'DEBUG' Start-Sleep -Seconds $delay } } finally { if ($null -ne $fileStream) { try { $fileStream.Close() } catch { } } if ($null -ne $stream) { try { $stream.Close() } catch { } } if ($null -ne $response) { try { ([System.Net.HttpWebResponse]$response).Close() } catch { } } } } Write-Progress -Activity $activity -Completed throw ($Label + ': не удалось скачать за ' + $Retries + ' попыток. Последняя ошибка: ' + $lastError) } # =========================================================================== # Определение того, что уже установлено # # Раньше на каждый установщик и на каждое слово его имени заново перебирался # весь реестр — это O(установщики × слова × записи) и минуты работы. # Теперь реестр читается один раз и склеивается в одну строку, а поиск идёт # предкомпилированными регулярными выражениями по границам слов. # =========================================================================== function Get-InstallerTokens { # Значимые слова имени программы. Служебные слова отбрасываются: иначе # "Microsoft" или "Setup" в имени дают ложное "уже установлена". param([string]$Name) $stopWords = @( 'microsoft', 'windows', 'setup', 'install', 'installer', 'runtime', 'update', 'updates', 'x64', 'x86', 'bit', 'the', 'and', 'for', 'with', 'from', 'plus', 'edition', 'version', 'free', 'standard', 'portable' ) $tokens = @() foreach ($word in ($Name -split '[^0-9A-Za-zА-Яа-яЁё\.\+]+')) { $token = $word.Trim().TrimEnd('.').ToLowerInvariant() if ($token.Length -lt 3) { continue } if ($stopWords -contains $token) { continue } if ($tokens -notcontains $token) { $tokens += $token } } return $tokens } function New-TokenPattern { param([string]$Token, $Cache) if (-not $Cache.ContainsKey($Token)) { # Границы — только буквы, без цифр: иначе "hwinfo" не найдётся # в "HWiNFO64", а таких имён в Windows большинство. $pattern = '(?