Made by ErneyWhite — GitHub
# powertoy.ps1 — автоматический установщик программ для Windows
# Запуск: irm https://powertoy.erney.monster | iex
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# TLS 1.2 для всех HTTPS-запросов (PS 5.1 по умолчанию использует SSL3/TLS1.0)
try {
[System.Net.ServicePointManager]::SecurityProtocol = `
[System.Net.ServicePointManager]::SecurityProtocol -bor `
[System.Net.SecurityProtocolType]::Tls12
} catch {}
$ErrorActionPreference = 'Stop'
$logPath = Join-Path $env:TEMP 'powertoy.log'
# --- Лог -------------------------------------------------------------------
function Write-PtLog {
param([string]$Message, [string]$Level = 'INFO')
$line = "[{0}] [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
try { Add-Content -Path $logPath -Value $line -Encoding UTF8 } catch {}
}
# --- Утилиты ---------------------------------------------------------------
function Read-YesNo {
param([Parameter(Mandatory)] [string]$Prompt)
while ($true) {
$resp = Read-Host "$Prompt (y/н, n/т)"
switch -Regex ($resp) {
'^(y|н)$' { return $true }
'^(n|т)$' { return $false }
default { Write-Host 'Неверный ответ. Введите y/н или n/т.' -ForegroundColor Yellow }
}
}
}
function Test-IsAdmin {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
return ([Security.Principal.WindowsPrincipal]$id).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator
)
}
function Write-CenteredMessage {
param(
[Parameter(Mandatory)] [string]$Message,
[Parameter(Mandatory)] [int]$LineNumber,
[string]$ForegroundColor
)
$origFg = [console]::ForegroundColor
if ($ForegroundColor) { [console]::ForegroundColor = $ForegroundColor }
$width = [console]::WindowWidth
$padding = ' ' * [math]::Max(0, [math]::Floor(($width - $Message.Length) / 2))
[console]::SetCursorPosition(0, $LineNumber)
[console]::WriteLine("$padding$Message")
[console]::ForegroundColor = $origFg
}
function Resize-ConsoleWindow {
# Подгоняем окно консоли под размер меню, чтобы не прокручивать.
# 9 категорий + 31 программа + подсказки ≈ 60 строк.
param(
[int]$MinHeight = 60,
[int]$MinWidth = 100
)
try {
$ui = (Get-Host).UI.RawUI
if (-not $ui) { return }
$maxSize = $ui.MaxWindowSize
if (-not $maxSize) { return }
$targetHeight = [math]::Min($MinHeight, [int]$maxSize.Height)
$targetWidth = [math]::Min($MinWidth, [int]$maxSize.Width)
# BufferSize должен быть >= WindowSize по обоим измерениям.
$buffer = $ui.BufferSize
$bufferChanged = $false
if ($buffer.Width -lt $targetWidth) { $buffer.Width = $targetWidth; $bufferChanged = $true }
if ($buffer.Height -lt ($targetHeight + 200)) { $buffer.Height = $targetHeight + 200; $bufferChanged = $true }
if ($bufferChanged) { $ui.BufferSize = $buffer }
$window = $ui.WindowSize
$windowChanged = $false
if ($window.Width -lt $targetWidth) { $window.Width = $targetWidth; $windowChanged = $true }
if ($window.Height -lt $targetHeight) { $window.Height = $targetHeight; $windowChanged = $true }
if ($windowChanged) { $ui.WindowSize = $window }
} catch {
# ISE/VSCode/Windows Terminal могут не позволять — игнорируем.
}
}
function Get-SevenZipPath {
$candidates = @(
Join-Path $env:ProgramFiles '7-Zip\7z.exe'
Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe'
) | Where-Object { $_ -and (Test-Path -LiteralPath $_) }
return $candidates | Select-Object -First 1
}
function Install-SevenZip {
$url = 'https://www.7-zip.org/a/7z2601-x64.exe'
$tmp = Join-Path $env:TEMP '7z2601-x64.exe'
Write-Host 'Скачивание установщика 7-Zip...' -ForegroundColor Cyan
Start-BitsTransfer -Source $url -Destination $tmp -ErrorAction Stop
Write-Host 'Установка 7-Zip...' -ForegroundColor Cyan
Start-Process -FilePath $tmp -ArgumentList '/S' -Wait -ErrorAction Stop
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
$path = Get-SevenZipPath
if (-not $path) { throw '7-Zip не установлен. Проверьте корректность установки.' }
Write-Host '7-Zip успешно установлен!' -ForegroundColor Green
return $path
}
# --- Прогресс-бар скачивания -----------------------------------------------
function Write-DownloadProgress {
param([Parameter(Mandatory)] [long]$Received, [Parameter(Mandatory)] [long]$Total)
$width = [Console]::WindowWidth - 1
if ($Total -le 0) {
$receivedMb = [math]::Round($Received / 1MB, 1)
$line = " Скачано: $receivedMb MB"
} else {
$pct = [math]::Min(100, [int](($Received / $Total) * 100))
$barWidth = 30
$filled = [int]([math]::Floor($pct * $barWidth / 100))
$bar = ('█' * $filled) + ('░' * ($barWidth - $filled))
$receivedMb = [math]::Round($Received / 1MB, 1)
$totalMb = [math]::Round($Total / 1MB, 1)
$line = " [$bar] {0,3}% {1,5} / {2,5} MB" -f $pct, $receivedMb, $totalMb
}
if ($line.Length -lt $width) { $line = $line.PadRight($width) }
[Console]::Write("`r$line")
}
function Invoke-DownloadWithProgress {
param(
[Parameter(Mandatory)] [string]$Url,
[Parameter(Mandatory)] [string]$Destination,
[Parameter(Mandatory)] [string]$DisplayName
)
Write-Host "Скачивание $DisplayName..." -ForegroundColor Cyan
Write-PtLog "Download start: $DisplayName ($Url)"
$job = Start-BitsTransfer -Source $Url -Destination $Destination `
-Asynchronous -DisplayName $DisplayName -ErrorAction Stop
try {
while ($job.JobState -ne 'Transferred' -and $job.JobState -ne 'Error') {
if ($job.BytesTotal -gt 0) {
Write-DownloadProgress -Received $job.BytesTransferred -Total $job.BytesTotal
}
Start-Sleep -Milliseconds 200
}
if ($job.JobState -eq 'Transferred') {
Write-DownloadProgress -Received $job.BytesTotal -Total $job.BytesTotal
[Console]::WriteLine()
Complete-BitsTransfer -BitsJob $job
Write-PtLog "Download done: $DisplayName ($($job.BytesTotal) bytes)"
} else {
$errMsg = $job.ErrorDescription
Remove-BitsTransfer -BitsJob $job -ErrorAction SilentlyContinue
Write-PtLog "Download failed: $DisplayName — $errMsg" 'ERROR'
throw "Ошибка BITS: $errMsg"
}
} catch {
if ($job -and $job.JobState -ne 'Transferred') {
Remove-BitsTransfer -BitsJob $job -ErrorAction SilentlyContinue
}
throw
}
}
function Get-CachedOrDownload {
param(
[Parameter(Mandatory)] [string]$Url,
[Parameter(Mandatory)] [string]$Destination,
[Parameter(Mandatory)] [string]$DisplayName
)
if (Test-Path -LiteralPath $Destination) {
$size = (Get-Item -LiteralPath $Destination).Length
if ($size -gt 0) {
$sizeMb = [math]::Round($size / 1MB, 1)
Write-Host "Используется кешированная версия $DisplayName ($sizeMb MB)" -ForegroundColor DarkGreen
Write-PtLog "Cache hit: $DisplayName ($size bytes)"
return
}
Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue
}
Invoke-DownloadWithProgress -Url $Url -Destination $Destination -DisplayName $DisplayName
}
# --- Установленные программы (для отметки в меню) -------------------------
$script:installedNamesCache = $null
function Get-InstalledProgramNames {
if ($null -ne $script:installedNamesCache) { return $script:installedNamesCache }
$set = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
# 1. Реестр Uninstall (HKLM, HKLM\WOW6432Node, HKCU, HKCU\WOW6432Node)
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKCU:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.DisplayName) { [void]$set.Add($_.DisplayName) }
# Имя ключа реестра тоже бывает информативным (например "1Password" или GUID)
if ($_.PSChildName) { [void]$set.Add($_.PSChildName) }
}
# 2. AppX-пакеты (Microsoft Store / Modern apps — например 1Password 8 ставится как AppX)
try {
Get-AppxPackage -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.Name) { [void]$set.Add($_.Name) }
if ($_.PackageFamilyName) { [void]$set.Add($_.PackageFamilyName) }
}
} catch {}
$script:installedNamesCache = @($set)
return $script:installedNamesCache
}
function Test-IsProgramInstalled {
param([Parameter(Mandatory)] $Program)
# Базовое имя — отрезаем версию и пометки в скобках.
$base = ($Program.Name -replace '\s*\([^)]*\)\s*', ' ').Trim()
# Подстроки для поиска: базовое имя + пользовательские DetectNames из JSON
$needles = New-Object System.Collections.Generic.List[string]
if ($base) { $needles.Add($base) }
if ($Program.DetectNames) {
foreach ($n in $Program.DetectNames) {
if ($n) { $needles.Add($n) }
}
}
if ($needles.Count -eq 0) { return $false }
foreach ($name in (Get-InstalledProgramNames)) {
foreach ($needle in $needles) {
if ($name -like "*$needle*") { return $true }
}
}
return $false
}
# --- Winget --------------------------------------------------------------
$script:wingetAvailable = $null
function Test-WingetAvailable {
if ($null -ne $script:wingetAvailable) { return $script:wingetAvailable }
$script:wingetAvailable = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
return $script:wingetAvailable
}
function Install-WithWinget {
param([Parameter(Mandatory)] [string]$WingetId, [Parameter(Mandatory)] [string]$DisplayName)
Write-Host "Установка $DisplayName через winget ($WingetId)..." -ForegroundColor Cyan
Write-PtLog "Winget install: $WingetId"
$proc = Start-Process -FilePath 'winget' -ArgumentList @(
'install', '--id', $WingetId,
'--silent',
'--accept-source-agreements',
'--accept-package-agreements',
'--disable-interactivity'
) -Wait -PassThru -NoNewWindow
if ($proc.ExitCode -eq 0) {
Write-PtLog "Winget success: $DisplayName"
return $true
}
Write-PtLog "Winget failed (exit $($proc.ExitCode)): $DisplayName" 'WARN'
return $false
}
# --- Проверка прав администратора ------------------------------------------
if (-not (Test-IsAdmin)) {
Write-Host 'Скрипт требует запуска от имени администратора.' -ForegroundColor Red
if (Read-YesNo 'Хотите запустить скрипт от имени администратора?') {
Start-Process -FilePath PowerShell `
-ArgumentList '-Command', 'irm https://powertoy.erney.monster | iex' `
-Verb RunAs
Exit
} else {
Write-Host 'Скрипт требует прав администратора для корректной работы.' -ForegroundColor Red
Read-Host 'Нажмите Enter для выхода'
Exit
}
}
Write-PtLog '=== Session start ==='
# Расширяем окно консоли, чтобы меню помещалось без прокрутки.
Resize-ConsoleWindow -MinHeight 60 -MinWidth 100
# --- Приветствие ----------------------------------------------------------
Clear-Host
$windowWidth = [console]::WindowWidth
$windowHeight = [console]::WindowHeight
$centerLine = [math]::Floor($windowHeight / 2)
Write-CenteredMessage -Message 'Автоматический установщик программ для Windows' `
-LineNumber ($centerLine - 10) -ForegroundColor DarkRed
$enot = @(
'░░░░░░░░░░░░░░░▄▄▄▄▄▄▄▄░░░░░░░░░░░░░░',
'░▄█▀███▄▄████████████████████▄▄███▀█░',
'░█░░▀████████████████████████████░░█░',
'░░█▄░░▀████████████████████████░░░▄▀░',
'░░░▀█▄▄████▀▀▀░░░░██░░░▀▀▀█████▄▄█▀░░',
'░░░▄███▀▀░░░░░░░░░██░░░░░░░░░▀███▄░░░',
'░░▄██▀░░░░░▄▄▄██▄▄██░▄██▄▄▄░░░░░▀██▄░',
'▄██▀░░░▄▄▄███▄██████████▄███▄▄▄░░░▀█▄',
'▀██▄▄██████████▀░███▀▀▀█████████▄▄▄█▀',
'░░▀██████████▀░░░███░░░▀███████████▀░',
'░░░░▀▀▀██████░░░█████▄░░▀██████▀▀░░░░',
'░░░░░░░░░▀▀▀▀▄░░█████▀░▄█▀▀▀░░░░░░░░░',
'░░░░░░░░░░░░░░▀▀▄▄▄▄▄▀▀░░░░░░░░░░░░░░'
)
$enotStartLine = $centerLine - [math]::Floor($enot.Length / 2)
for ($i = 0; $i -lt $enot.Length; $i++) {
[console]::SetCursorPosition(0, $enotStartLine + $i)
$padding = ' ' * [math]::Max(0, [math]::Floor(($windowWidth - $enot[$i].Length) / 2))
[console]::WriteLine("$padding$($enot[$i])")
}
$origFg = [console]::ForegroundColor
[console]::ForegroundColor = 'DarkRed'
$signature = 'made by ErneyWhite'
$padding = ' ' * [math]::Max(0, [math]::Floor(($windowWidth - $signature.Length) / 2))
[console]::SetCursorPosition(0, $windowHeight - 1)
[console]::WriteLine("$padding$signature")
[console]::ForegroundColor = $origFg
Start-Sleep -Seconds 3
Clear-Host
# --- 7-Zip -----------------------------------------------------------------
$sevenZipPath = Get-SevenZipPath
if (-not $sevenZipPath) {
Write-Host '7-Zip не найден. Он рекомендуется для установки программ из архивов.' -ForegroundColor Yellow
if (Read-YesNo 'Хотите установить 7-Zip?') {
try { $sevenZipPath = Install-SevenZip }
catch {
Write-Host "Ошибка установки 7-Zip: $_" -ForegroundColor Red
Read-Host 'Нажмите Enter для выхода'
Exit
}
} else {
Write-Host 'Установка 7-Zip пропущена. Программы типа archive не сработают.' -ForegroundColor Yellow
if (-not (Read-YesNo 'Продолжить без 7-Zip?')) { Exit }
}
}
# --- Список программ ------------------------------------------------------
$programsUrl = 'https://powertoy.erney.monster/programs.json'
try {
Write-Host 'Загрузка списка программ...' -ForegroundColor Cyan
# Грузим сырыми байтами и руками декодируем как UTF-8 — иначе PS 5.1
# без charset в Content-Type использует ISO-8859-1 и ломает кириллицу.
$resp = Invoke-WebRequest -Uri $programsUrl -UseBasicParsing
$bytes = $resp.RawContentStream.ToArray()
# Срезаем UTF-8 BOM, если он есть, чтобы ConvertFrom-Json в PS 5.1 не споткнулся.
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
$jsonText = [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
} else {
$jsonText = [System.Text.Encoding]::UTF8.GetString($bytes)
}
$programs = $jsonText | ConvertFrom-Json
} catch {
Write-Host "Не удалось загрузить список программ с $programsUrl" -ForegroundColor Red
Write-Host "Ошибка: $_" -ForegroundColor Red
Write-PtLog "Failed to fetch programs.json: $_" 'ERROR'
Read-Host 'Нажмите Enter для выхода'
Exit
}
if (-not $programs -or $programs.Count -eq 0) {
Write-Host 'Список программ пуст или повреждён.' -ForegroundColor Red
Read-Host 'Нажмите Enter для выхода'
Exit
}
Write-PtLog "Loaded $($programs.Count) programs from JSON"
# --- Установка ------------------------------------------------------------
$downloadPath = Join-Path $env:TEMP 'Installers'
if (-not (Test-Path -LiteralPath $downloadPath)) {
New-Item -ItemType Directory -Path $downloadPath | Out-Null
}
function Expand-NestedArchive {
param(
[Parameter(Mandatory)] [string]$ArchivePath,
[Parameter(Mandatory)] [string]$ExtractPath
)
if (-not $sevenZipPath) { throw '7-Zip не установлен — распаковка архива невозможна.' }
Write-Host "Разархивирование $ArchivePath..." -ForegroundColor Cyan
Start-Process -FilePath $sevenZipPath `
-ArgumentList "x -o`"$ExtractPath`" `"$ArchivePath`" -y" -Wait
$nested = Get-ChildItem -LiteralPath $ExtractPath -Recurse -Include *.zip, *.rar -File
foreach ($n in $nested) {
$nestedExtract = Join-Path $n.DirectoryName ($n.BaseName -replace ' ', '_')
if (Test-Path -LiteralPath $nestedExtract) {
Remove-Item -LiteralPath $nestedExtract -Recurse -Force
}
New-Item -ItemType Directory -Path $nestedExtract | Out-Null
Expand-NestedArchive -ArchivePath $n.FullName -ExtractPath $nestedExtract
}
}
function Install-IsoProgram {
param([Parameter(Mandatory)] $Program)
$isoPath = Join-Path $downloadPath $Program.Installer
Get-CachedOrDownload -Url $Program.Url -Destination $isoPath -DisplayName "ISO $($Program.Name)"
if ($Program.LicenseKey) {
Set-Clipboard -Value $Program.LicenseKey
Write-Host 'Лицензионный ключ скопирован в буфер обмена.' -ForegroundColor Green
}
Write-Host 'Монтирование ISO-образа...' -ForegroundColor Cyan
$mount = Mount-DiskImage -ImagePath $isoPath -PassThru
$driveLetter = ($mount | Get-Volume).DriveLetter
if ($driveLetter) {
Write-Host "ISO-образ смонтирован на диске ${driveLetter}:" -ForegroundColor Green
} else {
Write-Host 'ISO-образ смонтирован (буква диска не определена).' -ForegroundColor Yellow
}
Read-Host 'Нажмите Enter для размонтирования ISO-образа'
Write-Host 'Размонтирование ISO-образа...' -ForegroundColor Cyan
Dismount-DiskImage -ImagePath $isoPath | Out-Null
Write-Host 'ISO-образ размонтирован.' -ForegroundColor Green
}
function Install-ScriptProgram {
param([Parameter(Mandatory)] $Program)
$scriptPath = Join-Path $downloadPath $Program.Installer
Get-CachedOrDownload -Url $Program.Url -Destination $scriptPath -DisplayName "скрипт $($Program.Name)"
Write-Host "Выполнение скрипта $($Program.Name)..." -ForegroundColor Cyan
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $scriptPath
Write-Host "$($Program.Name) выполнен." -ForegroundColor Green
}
function Install-SelectedProgram {
param([Parameter(Mandatory)] [int]$Index)
if ($Index -lt 1 -or $Index -gt $programs.Count) {
Write-Host "Неверный выбор: $Index" -ForegroundColor Yellow
return
}
$program = $programs[$Index - 1]
Write-PtLog "Install start: [$Index] $($program.Name)"
try {
# Сначала пробуем winget, если поле задано и winget доступен
if ($program.WingetId -and (Test-WingetAvailable)) {
if (Install-WithWinget -WingetId $program.WingetId -DisplayName $program.Name) {
Write-Host "$($program.Name) установлен через winget." -ForegroundColor Green
return
}
Write-Host 'Winget не сработал, пробую прямую установку...' -ForegroundColor Yellow
}
switch ($program.Type) {
'ISO' { Install-IsoProgram -Program $program; return }
'Script' { Install-ScriptProgram -Program $program; return }
}
$downloadName = if ($program.Zip) { $program.Zip } else { $program.Installer }
$downloadFile = Join-Path $downloadPath $downloadName
$extractPath = Join-Path $downloadPath ($program.Name -replace ' ', '_')
Get-CachedOrDownload -Url $program.Url -Destination $downloadFile -DisplayName $program.Name
if ($program.Zip) {
if (Test-Path -LiteralPath $extractPath) {
Remove-Item -LiteralPath $extractPath -Recurse -Force
}
New-Item -ItemType Directory -Path $extractPath | Out-Null
Expand-NestedArchive -ArchivePath $downloadFile -ExtractPath $extractPath
$installerPath = Get-ChildItem -LiteralPath $extractPath -Recurse -Include *.msi, *.exe -File |
Select-Object -First 1 -ExpandProperty FullName
if (-not $installerPath) {
Write-Host 'Установочный файл не найден в архиве.' -ForegroundColor Red
return
}
} else {
$installerPath = $downloadFile
}
if ($installerPath -like '*.msi') {
Write-Host "Установка MSI-пакета $($program.Name)..." -ForegroundColor Cyan
$msiArgs = "/i `"$installerPath`""
if ($program.Args) { $msiArgs += " $($program.Args)" }
Start-Process -FilePath 'msiexec.exe' -ArgumentList $msiArgs -Wait
} else {
Write-Host "Установка $($program.Name)..." -ForegroundColor Cyan
if ($program.Args) {
Start-Process -FilePath $installerPath -ArgumentList $program.Args -Wait
} else {
Start-Process -FilePath $installerPath -Wait
}
}
Write-Host "$($program.Name) успешно установлен." -ForegroundColor Green
Write-PtLog "Install done: $($program.Name)"
} catch {
Write-Host "Ошибка при установке $($program.Name): $_" -ForegroundColor Red
Write-PtLog "Install failed: $($program.Name) — $_" 'ERROR'
}
}
# --- Меню ------------------------------------------------------------------
function Get-FilteredIndices {
param([string]$Filter)
if ([string]::IsNullOrWhiteSpace($Filter)) {
return @(1..$programs.Count)
}
$result = @()
for ($i = 0; $i -lt $programs.Count; $i++) {
if ($programs[$i].Name -like "*$Filter*") { $result += ($i + 1) }
}
return $result
}
function Show-Menu {
param([string]$Filter = '')
Clear-Host
if ($Filter) {
Write-Host "Поиск: '$Filter' (введите / чтобы сбросить)" -ForegroundColor Yellow
Write-Host
}
$visible = Get-FilteredIndices -Filter $Filter
if ($visible.Count -eq 0) {
Write-Host 'Ничего не найдено.' -ForegroundColor Yellow
Write-Host
return
}
# Группируем по Category, сохраняя порядок появления категории в списке
$groups = [ordered]@{}
foreach ($idx in $visible) {
$cat = $programs[$idx - 1].Category
if (-not $cat) { $cat = 'Прочее' }
if (-not $groups.Contains($cat)) { $groups[$cat] = @() }
$groups[$cat] += $idx
}
foreach ($cat in $groups.Keys) {
Write-Host "── $cat ──" -ForegroundColor Cyan
foreach ($idx in $groups[$cat]) {
$p = $programs[$idx - 1]
$num = "[$idx]".PadLeft(5)
$installed = Test-IsProgramInstalled -Program $p
if ($installed) {
Write-Host (" {0} {1}" -f $num, $p.Name) -NoNewline
Write-Host ' ✓' -ForegroundColor Green
} else {
Write-Host (" {0} {1}" -f $num, $p.Name)
}
}
Write-Host
}
}
function Show-Hints {
Write-Host '─────────────────────────────────────────────' -ForegroundColor DarkGray
Write-Host ' Введите номер(а) через пробел: 1 5 17 ' -ForegroundColor DarkGray
Write-Host ' Поиск: /текст Сброс поиска: / ' -ForegroundColor DarkGray
Write-Host ' Выход: q ' -ForegroundColor DarkGray
Write-Host '─────────────────────────────────────────────' -ForegroundColor DarkGray
}
# --- Основной цикл ---------------------------------------------------------
$filter = ''
try {
while ($true) {
Show-Menu -Filter $filter
Show-Hints
$userInput = Read-Host 'Ваш выбор'
$userInput = $userInput.Trim()
if ([string]::IsNullOrWhiteSpace($userInput)) { continue }
if ($userInput -match '^(q|й)$') {
Write-Host 'Выход из программы.' -ForegroundColor Green
break
}
if ($userInput.StartsWith('/')) {
$filter = $userInput.Substring(1).Trim()
if ($filter) { Write-PtLog "Search filter set: '$filter'" }
else { Write-PtLog 'Search filter cleared' }
continue
}
# Парсинг множественного выбора через пробел
$indices = @()
$bad = $null
foreach ($token in ($userInput -split '\s+')) {
if ([string]::IsNullOrWhiteSpace($token)) { continue }
$n = 0
if ([int]::TryParse($token, [ref]$n) -and $n -ge 1 -and $n -le $programs.Count) {
if ($indices -notcontains $n) { $indices += $n }
} else {
$bad = $token
break
}
}
if ($bad) {
Write-Host "Неверный пункт: $bad" -ForegroundColor Red
Read-Host 'Нажмите Enter'
continue
}
if ($indices.Count -eq 0) { continue }
if ($indices.Count -gt 1) {
Write-Host "Будут установлены $($indices.Count) программ(ы)." -ForegroundColor Cyan
}
foreach ($idx in $indices) {
Install-SelectedProgram -Index $idx
Write-Host
}
# Сбрасываем кеш установленных, чтобы при возврате в меню обновились ✓
$script:installedNamesCache = $null
Read-Host 'Нажмите Enter для возврата в меню'
}
}
finally {
if (Test-Path -LiteralPath $downloadPath) {
Remove-Item -LiteralPath $downloadPath -Recurse -Force -ErrorAction SilentlyContinue
}
Write-PtLog '=== Session end ==='
Write-Host 'Все выбранные программы установлены.' -ForegroundColor Green
Write-Host "Лог сессии: $logPath" -ForegroundColor DarkGray
}