#requires -Version 5.1 $ErrorActionPreference = 'Stop' $script:ApiBaseUrl = 'https://dw.manticore.date' $script:SessionToken = $null if ($env:OS -ne 'Windows_NT') { throw 'NSK Software Installer currently supports Windows 10/11 only.' } if ($PSVersionTable.PSVersion.Major -lt 5) { throw 'PowerShell 5.1 or later is required.' } try { $utf8Encoding = New-Object -TypeName System.Text.UTF8Encoding -ArgumentList $false [Console]::OutputEncoding = $utf8Encoding [Console]::InputEncoding = $utf8Encoding $OutputEncoding = $utf8Encoding } catch { # Keep the installer usable in hosts that do not expose console encoding setters. } if ($PSVersionTable.PSVersion.Major -eq 5) { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } function Write-Header { Clear-Host $bannerArt = @( ' /$$ /$$ /$$$$$$ /$$ /$$ /$$$$$$ /$$ /$$ /$$$$$$ /$$$$$$$$ /$$$$$$ /$$ /$$ /$$$$$$$$ /$$$$$$$' '| $$$ | $$ /$$__ $$| $$ /$$/ |_ $$_/| $$$ | $$ /$$__ $$|__ $$__//$$__ $$| $$ | $$ | $$_____/| $$__ $$' '| $$$$| $$| $$ \__/| $$ /$$/ | $$ | $$$$| $$| $$ \__/ | $$ | $$ \ $$| $$ | $$ | $$ | $$ \ $$' '| $$ $$ $$| $$$$$$ | $$$$$/ | $$ | $$ $$ $$| $$$$$$ | $$ | $$$$$$$$| $$ | $$ | $$$$$ | $$$$$$$/' '| $$ $$$$ \____ $$| $$ $$ | $$ | $$ $$$$ \____ $$ | $$ | $$__ $$| $$ | $$ | $$__/ | $$__ $$' '| $$\ $$$ /$$ \ $$| $$\ $$ | $$ | $$\ $$$ /$$ \ $$ | $$ | $$ | $$| $$ | $$ | $$ | $$ \ $$' '| $$ \ $$| $$$$$$/| $$ \ $$ /$$$$$$| $$ \ $$| $$$$$$/ | $$ | $$ | $$| $$$$$$$$| $$$$$$$$| $$$$$$$$| $$ | $$' '|__/ \__/ \______/ |__/ \__/ |______/|__/ \__/ \______/ |__/ |__/ |__/|________/|________/|________/|__/ |__/' '' '' '' ) try { if ([Console]::BufferWidth -lt 126) { [Console]::BufferWidth = 126 } } catch { # Some embedded PowerShell hosts do not let scripts resize the console buffer. } foreach ($artLine in $bannerArt) { Write-Host $artLine -ForegroundColor Green } } function Read-HiddenPassword { $securePassword = New-Object System.Security.SecureString try { Write-Host 'password: ' -NoNewline while ($true) { $key = [Console]::ReadKey($true) if ($key.Key -eq [ConsoleKey]::Enter) { Write-Host '' $securePassword.MakeReadOnly() return $securePassword } if ($key.Key -eq [ConsoleKey]::Backspace) { if ($securePassword.Length -gt 0) { $securePassword.RemoveAt($securePassword.Length - 1) } continue } if ($key.KeyChar -ne [char]0 -and -not [char]::IsControl($key.KeyChar)) { $securePassword.AppendChar($key.KeyChar) } } } catch { $securePassword.Dispose() throw 'Не удалось безопасно прочитать пароль в текущем терминале.' } } function Get-InstallerSession { while ($true) { Write-Header $username = (Read-Host 'username').Trim() $securePassword = Read-HiddenPassword $passwordPointer = [IntPtr]::Zero $plainPassword = $null try { $passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword) $plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordPointer) $payload = @{ username = $username; password = $plainPassword } | ConvertTo-Json -Compress $response = Invoke-RestMethod -Uri "$script:ApiBaseUrl/api/v1/session" -Method Post -ContentType 'application/json' -Body $payload -TimeoutSec 20 if (-not $response.token) { throw 'Сервер не выдал токен сессии.' } $script:SessionToken = [string]$response.token return } catch { $message = $_.ErrorDetails.Message if (-not $message) { $message = $_.Exception.Message } Write-Host "Не удалось войти: $message" -ForegroundColor Red Write-Host 'Проверьте логин, пароль и подключение к интернету. После нескольких ошибок будет короткая пауза.' -ForegroundColor Gray Start-Sleep -Seconds 2 } finally { if ($passwordPointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPointer) } $plainPassword = $null if ($securePassword) { $securePassword.Dispose() } } } } function Receive-InstallerDownload { param( [Parameter(Mandatory)] [string] $Uri, [Parameter(Mandatory)] [string] $OutFile ) $request = [Net.HttpWebRequest]::Create($Uri) $request.Method = 'GET' $request.Timeout = 180000 $request.ReadWriteTimeout = 180000 $request.Headers['Authorization'] = "Bearer $script:SessionToken" $response = $null $source = $null $destination = $null try { $response = $request.GetResponse() $source = $response.GetResponseStream() $destination = [IO.File]::Open($OutFile, [IO.FileMode]::Create, [IO.FileAccess]::Write, [IO.FileShare]::None) $contentLength = [long]$response.ContentLength $totalRead = [long]0 $buffer = New-Object byte[] 65536 $spinner = @('|', '/', '-', '\') $frame = 0 Write-Progress -Id 1 -Activity 'NSK Software Installer' -Status 'Загрузка Figma Bridge' -PercentComplete 60 while (($read = $source.Read($buffer, 0, $buffer.Length)) -gt 0) { $destination.Write($buffer, 0, $read) $totalRead += $read if ($contentLength -gt 0) { $fraction = [Math]::Min(1.0, ($totalRead / [double]$contentLength)) $percent = 60 + [int][Math]::Floor(17 * $fraction) $status = 'Загрузка Figma Bridge: {0:N0} из {1:N0} КБ' -f ([Math]::Ceiling($totalRead / 1KB)), ([Math]::Ceiling($contentLength / 1KB)) Write-Progress -Id 1 -Activity 'NSK Software Installer' -Status $status -PercentComplete $percent } else { $status = 'Загрузка Figma Bridge: {0:N0} КБ {1}' -f ([Math]::Ceiling($totalRead / 1KB)), $spinner[$frame] Write-Progress -Id 1 -Activity 'NSK Software Installer' -Status $status -PercentComplete 60 $frame = ($frame + 1) % $spinner.Count } } return $totalRead } finally { if ($destination) { $destination.Dispose() } if ($source) { $source.Dispose() } if ($response) { $response.Dispose() } } } function Invoke-InstallerApi { param( [Parameter(Mandatory)] [string] $Path, [ValidateSet('Get', 'Post')] [string] $Method = 'Get', [object] $Body, [switch] $Download, [string] $OutFile ) $headers = @{ Authorization = "Bearer $script:SessionToken" } $arguments = @{ Uri = "$script:ApiBaseUrl$Path" Method = $Method Headers = $headers TimeoutSec = 180 ErrorAction = 'Stop' } if ($null -ne $Body) { $arguments.ContentType = 'application/json' $arguments.Body = $Body | ConvertTo-Json -Depth 8 -Compress } if ($Download) { Receive-InstallerDownload -Uri $arguments.Uri -OutFile $OutFile | Out-Null return } return Invoke-RestMethod @arguments } function Get-CurrentPlatform { $architecture = $env:PROCESSOR_ARCHITEW6432 if (-not $architecture) { $architecture = $env:PROCESSOR_ARCHITECTURE } $architecture = ([string]$architecture).ToUpperInvariant() if ($architecture -eq 'ARM64') { return 'windows-arm64' } if ([Environment]::Is64BitOperatingSystem) { return 'windows-x64' } return 'windows-x86' } function Write-MenuDescription { param([Parameter(Mandatory)] [string] $Text) $consoleWidth = 100 try { $consoleWidth = [Console]::WindowWidth } catch { } if ($consoleWidth -lt 50) { $consoleWidth = 50 } $lineWidth = [Math]::Max(24, $consoleWidth - 7) $words = @($Text -split '\s+' | Where-Object { $_ }) $line = '' foreach ($word in $words) { if ($line.Length -gt 0 -and ($line.Length + $word.Length + 1) -gt $lineWidth) { Write-Host (" {0}" -f $line) -ForegroundColor DarkGray $line = '' } if ($line.Length -gt 0) { $line += ' ' } $line += $word } if ($line.Length -gt 0) { Write-Host (" {0}" -f $line) -ForegroundColor DarkGray } } function New-MenuResult { param( [object[]] $SelectedApps = @(), [bool] $ConfigureSshForward = $false, [bool] $Cancelled = $false ) return [pscustomobject]@{ Apps = @($SelectedApps) ConfigureSshForward = $ConfigureSshForward Cancelled = $Cancelled } } function Get-MenuSelection { param([Parameter(Mandatory)] [array] $Apps) $hasSshOption = @($Apps | Where-Object { $_.sshForward -and $_.sshForward.localPort -eq 3055 }).Count -gt 0 if ($Apps.Count -eq 0) { return New-MenuResult } if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) { Write-Host 'Доступные программы:' for ($index = 0; $index -lt $Apps.Count; $index++) { Write-Host (" {0}. {1} ({2})" -f ($index + 1), $Apps[$index].name, $Apps[$index].version) } if ($hasSshOption) { Write-Host ' T. Настроить офисный SSH-туннель для Figma Bridge (необязательно)' } $raw = Read-Host 'Номера через запятую; T — SSH-туннель; Enter — выход' $chosen = @() $configureSsh = $false foreach ($piece in ($raw -split '[,\s]+')) { $number = 0 if ($hasSshOption -and $piece.Trim() -match '^[Tt]$') { $configureSsh = $true } elseif ([int]::TryParse($piece.Trim(), [ref]$number) -and $number -ge 1 -and $number -le $Apps.Count) { $chosen += $Apps[$number - 1] } } return New-MenuResult -SelectedApps $chosen -ConfigureSshForward $configureSsh } $marked = [bool[]]::new($Apps.Count) $cursor = 0 $menuItemCount = $Apps.Count + [int]$hasSshOption $message = '' $configureSsh = $false while ($true) { Write-Header Write-Host 'Добро пожаловать. Выберите приложения и нажмите Enter.' -ForegroundColor White $controls = '↑/↓ выбор Space отметить A все N снять все Enter продолжить Q выход' if ($hasSshOption) { $controls = '↑/↓ выбор Space отметить A все N снять все T SSH Enter продолжить Q выход' } Write-Host $controls -ForegroundColor Gray if ($message) { Write-Host $message -ForegroundColor Yellow } Write-Host '' for ($index = 0; $index -lt $Apps.Count; $index++) { $pointer = if ($index -eq $cursor) { '>' } else { ' ' } $check = if ($marked[$index]) { '[x]' } else { '[ ]' } $color = if ($index -eq $cursor) { 'Cyan' } else { 'White' } Write-Host (" {0} {1} {2} v{3}" -f $pointer, $check, $Apps[$index].name, $Apps[$index].version) -ForegroundColor $color Write-MenuDescription -Text ([string]$Apps[$index].description) } if ($hasSshOption) { $pointer = if ($cursor -eq $Apps.Count) { '>' } else { ' ' } $check = if ($configureSsh) { '[x]' } else { '[ ]' } $color = if ($cursor -eq $Apps.Count) { 'Cyan' } else { 'White' } Write-Host (" {0} {1} Настроить офисный SSH-туннель для Figma Bridge (порт 3055)" -f $pointer, $check) -ForegroundColor $color } $selectedCount = @($marked | Where-Object { $_ }).Count $selectionLabel = "Выбрано программ: $selectedCount" if ($hasSshOption) { $selectionLabel += if ($configureSsh) { ' | SSH-туннель: да' } else { ' | SSH-туннель: нет' } } Write-Host "`n$selectionLabel" -ForegroundColor DarkGray $message = '' $key = [Console]::ReadKey($true) switch ($key.Key) { 'UpArrow' { if ($cursor -gt 0) { $cursor-- } else { $cursor = $menuItemCount - 1 } } 'DownArrow' { if ($cursor -lt ($menuItemCount - 1)) { $cursor++ } else { $cursor = 0 } } 'Spacebar' { if ($cursor -lt $Apps.Count) { $marked[$cursor] = -not $marked[$cursor] } elseif ($hasSshOption) { $configureSsh = -not $configureSsh } } 'Enter' { $chosen = @() for ($index = 0; $index -lt $Apps.Count; $index++) { if ($marked[$index]) { $chosen += $Apps[$index] } } if ($chosen.Count -eq 0 -and -not $configureSsh) { $message = 'Ничего не выбрано. Отметьте приложение или нажмите Q для выхода.' continue } return New-MenuResult -SelectedApps $chosen -ConfigureSshForward $configureSsh } 'Escape' { return New-MenuResult -Cancelled $true } default { switch ([char]::ToUpperInvariant($key.KeyChar)) { 'Q' { return New-MenuResult -Cancelled $true } 'A' { for ($index = 0; $index -lt $Apps.Count; $index++) { $marked[$index] = $true } } 'N' { for ($index = 0; $index -lt $Apps.Count; $index++) { $marked[$index] = $false } $configureSsh = $false } 'T' { if ($hasSshOption) { $configureSsh = -not $configureSsh } } } } } } } function Send-InstallEvent { param( [Parameter(Mandatory)] [string] $AppId, [Parameter(Mandatory)] [string] $Version, [Parameter(Mandatory)] [ValidateSet('started', 'succeeded', 'failed')] [string] $Status, [string] $Detail ) try { $event = @{ appId = $AppId version = $Version status = $Status device = $env:COMPUTERNAME detail = $Detail } Invoke-InstallerApi -Path '/api/v1/events' -Method Post -Body $event | Out-Null } catch { # Logging is best-effort; report this once in the final summary instead of # injecting a warning into the live install progress display. $script:EventLoggingFailed = $true } } function Get-InstalledAppByName { param([Parameter(Mandatory)] [string] $DisplayName) $paths = @( 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' ) foreach ($path in $paths) { $items = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue if ($items | Where-Object { $_.DisplayName -eq $DisplayName }) { return $true } } return $false } function Invoke-WingetPackage { param( [Parameter(Mandatory)] [string] $PackageId, [string] $DisplayName, [string] $Version, [string] $Name = $PackageId ) if ($PackageId -notmatch '^[A-Za-z0-9][A-Za-z0-9.-]{0,100}$') { throw 'В каталоге указан некорректный идентификатор WinGet.' } $wingetCommand = Get-Command winget.exe -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $wingetCommand) { throw 'Не найден WinGet. Установите или обновите Microsoft App Installer из Microsoft Store и запустите NSK Installer снова.' } $installed = $false if ($DisplayName) { $installed = Get-InstalledAppByName -DisplayName $DisplayName } $action = if ($installed) { 'upgrade' } else { 'install' } $wingetArgs = @( $action, '--id', $PackageId, '--exact', '--source', 'winget', '--silent', '--accept-source-agreements', '--accept-package-agreements', '--disable-interactivity' ) if ($Version) { if ($Version -notmatch '^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$') { throw 'В каталоге указана некорректная версия WinGet.' } $wingetArgs += @('--version', $Version) } $wingetPath = [string]$wingetCommand.Source if (-not $wingetPath) { $wingetPath = [string]$wingetCommand.Path } if (-not $wingetPath) { throw 'Не удалось определить путь к WinGet.' } $logDirectory = Join-Path ([IO.Path]::GetTempPath()) ('nsk-winget-' + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null $stdoutPath = Join-Path $logDirectory 'stdout.log' $stderrPath = Join-Path $logDirectory 'stderr.log' $process = $null try { # The arguments are fixed switches plus validated package/version values, # so joining them is safe for Start-Process on Windows PowerShell 5.1. $argumentLine = $wingetArgs -join ' ' Write-Progress -Id 1 -Activity 'NSK Software Installer' -Status "Запуск WinGet: $Name" -PercentComplete 15 $process = Start-Process -FilePath $wingetPath -ArgumentList $argumentLine -PassThru -NoNewWindow -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath $spinner = @('|', '/', '-', '\') $frame = 0 $startedAt = Get-Date while ($true) { $process.Refresh() if ($process.HasExited) { break } $elapsed = [int]((Get-Date) - $startedAt).TotalSeconds Write-Progress -Id 1 -Activity 'NSK Software Installer' -Status ("WinGet: {0} {1} ({2} сек.)" -f $Name, $spinner[$frame], $elapsed) -PercentComplete 20 $frame = ($frame + 1) % $spinner.Count Start-Sleep -Milliseconds 250 } $process.WaitForExit() $wingetExitCode = $process.ExitCode if ($action -eq 'upgrade' -and $wingetExitCode -eq -1978335189) { Write-Progress -Id 1 -Activity 'NSK Software Installer' -Status 'Figma уже актуальна; готовлю Figma Bridge' -PercentComplete 50 return } if ($wingetExitCode -ne 0) { $diagnostics = @() foreach ($logPath in @($stderrPath, $stdoutPath)) { if (Test-Path -LiteralPath $logPath -PathType Leaf) { $diagnostics += @(Get-Content -LiteralPath $logPath -Tail 8 -ErrorAction SilentlyContinue) } } $ansiPattern = ([string][char]27) + '\[[0-?]*[ -/]*[@-~]' $diagnostics = @($diagnostics | ForEach-Object { $line = [string]$_ $line = $line -replace $ansiPattern, '' $line = $line -replace '\s+', ' ' $line.Trim() } | Where-Object { $_ } | Select-Object -Last 3) $detail = if ($diagnostics.Count -gt 0) { "`n" + ($diagnostics -join "`n") } else { '' } throw "WinGet для $Name завершился с кодом $wingetExitCode.$detail" } Write-Progress -Id 1 -Activity 'NSK Software Installer' -Status 'WinGet завершил работу; проверяю пакет Figma Bridge' -PercentComplete 50 } finally { if ($process) { $process.Dispose() } Remove-Item -LiteralPath $logDirectory -Recurse -Force -ErrorAction SilentlyContinue } } function Install-FigmaBridgePackage { param([Parameter(Mandatory)] [psobject] $App) Invoke-WingetPackage -PackageId 'Figma.Figma' -DisplayName 'Figma' -Name 'Figma Desktop' $asset = $App.assets | Where-Object { $_.fileName -match '^figma-bridge-plugin-[0-9.]+\.zip$' } | Select-Object -First 1 if (-not $asset -or $asset.sha256 -notmatch '^[A-Fa-f0-9]{64}$') { throw 'В каталоге нет проверенного архива Figma Bridge.' } $tempDirectory = Join-Path ([IO.Path]::GetTempPath()) ('nsk-installer-' + [guid]::NewGuid().ToString('N')) $archivePath = Join-Path $tempDirectory ([string]$asset.fileName) New-Item -ItemType Directory -Path $tempDirectory -Force | Out-Null try { Write-Progress -Id 1 -Activity 'NSK Software Installer' -Status 'Загрузка Figma Bridge' -PercentComplete 60 Invoke-InstallerApi -Path $asset.downloadPath -Download -OutFile $archivePath Write-Progress -Id 1 -Activity 'NSK Software Installer' -Status 'Проверка SHA-256 архива' -PercentComplete 78 $actualHash = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualHash -ne ([string]$asset.sha256).ToLowerInvariant()) { throw 'SHA-256 архива Figma Bridge не совпал с каталогом. Архив не будет распакован.' } Write-Progress -Id 1 -Activity 'NSK Software Installer' -Status 'Установка плагина Figma Bridge' -PercentComplete 88 $targetDirectory = Join-Path $env:LOCALAPPDATA ("NSK\FigmaBridge\v{0}" -f $asset.version) New-Item -ItemType Directory -Path $targetDirectory -Force | Out-Null Expand-Archive -Path $archivePath -DestinationPath $targetDirectory -Force $manifestPath = Join-Path $targetDirectory 'manifest.json' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { throw 'В проверенном архиве не найден manifest.json.' } $script:InstalledManifestPath = $manifestPath $script:InstalledManifestDirectory = $targetDirectory } finally { Remove-Item -LiteralPath $tempDirectory -Recurse -Force -ErrorAction SilentlyContinue } Write-Progress -Id 1 -Activity 'NSK Software Installer' -Status 'Figma Desktop и пакет Figma Bridge подготовлены' -PercentComplete 100 -Completed } function Add-FigmaSshForward { param([Parameter(Mandatory)] [int] $LocalPort) $sshConfigPath = Join-Path $HOME '.ssh\config' if (-not (Test-Path -LiteralPath $sshConfigPath -PathType Leaf)) { Write-Host 'Файл ~/.ssh/config не найден; SSH-перенаправление не изменено.' -ForegroundColor DarkYellow return $false } $lines = [Collections.Generic.List[string]]::new() foreach ($line in (Get-Content -LiteralPath $sshConfigPath)) { $lines.Add([string]$line) } $hostBlocks = @() for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^\s*Host\s+(.+?)\s*$') { foreach ($alias in ($Matches[1] -split '\s+')) { if ($alias -notmatch '[*?]') { $hostBlocks += [pscustomobject]@{ Alias = $alias; Start = $i } } } } } $hostBlocks = @($hostBlocks | Sort-Object Alias -Unique) if ($hostBlocks.Count -eq 0) { Write-Host 'В ~/.ssh/config нет конкретного SSH-алиаса; пропускаю настройку туннеля.' -ForegroundColor DarkYellow return $false } Write-Host "`nSSH-алиасы для офисного Dev-сервера:" -ForegroundColor Cyan for ($i = 0; $i -lt $hostBlocks.Count; $i++) { Write-Host (" {0}. {1}" -f ($i + 1), $hostBlocks[$i].Alias) } $choice = Read-Host 'Введите номер алиаса (Enter — отмена; конфиг останется без изменений)' if ([string]::IsNullOrWhiteSpace($choice)) { return $false } $number = 0 if (-not [int]::TryParse($choice, [ref]$number) -or $number -lt 1 -or $number -gt $hostBlocks.Count) { Write-Host 'Номер не распознан; файл SSH не менялся.' -ForegroundColor DarkYellow return $false } $alias = $hostBlocks[$number - 1].Alias $blockStart = $hostBlocks[$number - 1].Start $blockEnd = $lines.Count for ($i = $blockStart + 1; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^\s*Host\s+') { $blockEnd = $i; break } } $forwardLine = " LocalForward 127.0.0.1:$LocalPort 127.0.0.1:$LocalPort" for ($i = $blockStart + 1; $i -lt $blockEnd; $i++) { if ($lines[$i] -match ("^\s*LocalForward\s+127\.0\.0\.1:{0}\s+127\.0\.0\.1:{0}\s*$" -f $LocalPort)) { Write-Host "SSH forwarding уже настроен для '$alias'." -ForegroundColor Green return $true } } $backupPath = "$sshConfigPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')" Copy-Item -LiteralPath $sshConfigPath -Destination $backupPath $lines.Insert($blockEnd, $forwardLine) $encoding = New-Object System.Text.UTF8Encoding($false) [IO.File]::WriteAllLines($sshConfigPath, $lines.ToArray(), $encoding) Write-Host "Добавлен локальный SSH-туннель для '$alias'; резервная копия: $backupPath" -ForegroundColor Green return $true } function Install-App { param([Parameter(Mandatory)] [psobject] $App) $script:InstalledManifestPath = $null $script:InstalledManifestDirectory = $null Send-InstallEvent -AppId $App.id -Version $App.version -Status 'started' try { switch ([string]$App.handler) { 'winget' { if ([string]::IsNullOrWhiteSpace([string]$App.wingetId)) { throw "Для пакета '$($App.id)' не задан идентификатор WinGet." } Invoke-WingetPackage -PackageId ([string]$App.wingetId) -DisplayName ([string]$App.detectDisplayName) -Version ([string]$App.wingetVersion) -Name ([string]$App.name) } 'figma-bridge' { Install-FigmaBridgePackage -App $App } default { throw "Для пакета '$($App.id)' нет встроенного обработчика '$($App.handler)'." } } Send-InstallEvent -AppId $App.id -Version $App.version -Status 'succeeded' if ($script:InstalledManifestPath) { try { Start-Process explorer.exe -ArgumentList ('"' + $script:InstalledManifestDirectory + '"') | Out-Null } catch { } } } catch { Send-InstallEvent -AppId $App.id -Version $App.version -Status 'failed' -Detail $_.Exception.Message throw } } try { Get-InstallerSession $catalog = Invoke-InstallerApi -Path '/api/v1/catalog' $platform = Get-CurrentPlatform $apps = @($catalog.apps | Where-Object { $_.platforms -contains $platform }) if ($apps.Count -eq 0) { Write-Header Write-Host "В каталоге пока нет приложений для $platform." -ForegroundColor Yellow return } $selection = Get-MenuSelection -Apps $apps $selectedApps = @($selection.Apps) $configureSsh = [bool]$selection.ConfigureSshForward if ($selection.Cancelled -or ($selectedApps.Count -eq 0 -and -not $configureSsh)) { Write-Host 'Ничего не выбрано. Установка отменена.' -ForegroundColor Gray return } $script:EventLoggingFailed = $false $completed = 0 $failed = 0 $skipped = 0 $sshConfigured = $false $successfulApps = @() $failedTasks = @() $totalTasks = $selectedApps.Count + [int]$configureSsh Write-Header Write-Host 'ВЫПОЛНЕНИЕ ВЫБОРА' -ForegroundColor Cyan Write-Host "Задач: $totalTasks" -ForegroundColor Gray foreach ($app in $selectedApps) { Write-Host " • $($app.name)" -ForegroundColor Gray } if ($configureSsh) { Write-Host ' • Офисный SSH-туннель (порт 3055)' -ForegroundColor Gray } foreach ($app in $selectedApps) { try { Install-App -App $app $completed++ $successfulApps += [pscustomobject]@{ App = $app ManifestPath = $script:InstalledManifestPath } } catch { $failed++ $failedTasks += [pscustomobject]@{ Name = [string]$app.name; Error = [string]$_.Exception.Message } } finally { Write-Progress -Id 1 -Activity 'NSK Software Installer' -Completed } } if ($configureSsh) { Write-Header Write-Host 'НАСТРОЙКА ОФИСНОГО SSH-ТУННЕЛЯ' -ForegroundColor Cyan $sshApp = $apps | Where-Object { $_.sshForward -and $_.sshForward.localPort -eq 3055 } | Select-Object -First 1 if (-not $sshApp) { $failed++ $failedTasks += [pscustomobject]@{ Name = 'SSH-туннель'; Error = 'В каталоге отсутствуют параметры туннеля.' } } else { Write-Host 'Эта настройка была выбрана отдельно в меню. Будет изменен только выбранный SSH-алиас.' -ForegroundColor Gray try { if (Add-FigmaSshForward -LocalPort ([int]$sshApp.sshForward.localPort)) { $completed++ $sshConfigured = $true } else { $skipped++ } } catch { $failed++ $failedTasks += [pscustomobject]@{ Name = 'SSH-туннель'; Error = [string]$_.Exception.Message } } } } Write-Progress -Id 1 -Activity 'NSK Software Installer' -Completed Write-Header Write-Host 'ИТОГ' -ForegroundColor Cyan foreach ($result in $successfulApps) { $app = $result.App Write-Host "[OK] $($app.name)" -ForegroundColor Green if ($result.ManifestPath) { Write-Host " Manifest: $($result.ManifestPath)" -ForegroundColor DarkGray } foreach ($step in $app.manualSteps) { Write-Host " • $step" -ForegroundColor Gray } } if ($configureSsh -and $sshConfigured) { Write-Host '[OK] Офисный SSH-туннель на порту 3055' -ForegroundColor Green } foreach ($task in $failedTasks) { Write-Host "[ОШИБКА] $($task.Name): $($task.Error)" -ForegroundColor Red } if ($configureSsh -and $skipped -gt 0) { Write-Host '[ПРОПУЩЕНО] SSH-туннель не был настроен.' -ForegroundColor Yellow } Write-Host "`nЗавершено: $completed из $totalTasks; ошибок: $failed." -ForegroundColor $(if ($failed -eq 0) { 'Green' } else { 'Yellow' }) if ($script:EventLoggingFailed) { Write-Host 'Не удалось отправить журнал установки; сами операции от этого не меняются.' -ForegroundColor DarkYellow } [void](Read-Host 'Нажмите Enter для выхода') } catch { Write-Progress -Id 1 -Activity 'NSK Software Installer' -Completed Write-Host "Ошибка NSK Software Installer: $($_.Exception.Message)" -ForegroundColor Red if (-not [Console]::IsInputRedirected) { [void](Read-Host 'Нажмите Enter для выхода') } return }