#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 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) { $arguments.OutFile = $OutFile $arguments.UseBasicParsing = $true Invoke-WebRequest @arguments | 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 Get-MenuSelection { param([Parameter(Mandatory)] [array] $Apps) if ($Apps.Count -eq 0) { return @() } 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) } $raw = Read-Host 'Введите номера через запятую для установки (Enter — выход)' $chosen = @() foreach ($piece in ($raw -split ',')) { $number = 0 if ([int]::TryParse($piece.Trim(), [ref]$number) -and $number -ge 1 -and $number -le $Apps.Count) { $chosen += $Apps[$number - 1] } } return $chosen } $marked = [bool[]]::new($Apps.Count) $cursor = 0 while ($true) { Write-Header Write-Host 'Добро пожаловать. Отметьте приложения и нажмите Enter для установки.' -ForegroundColor White Write-Host '↑/↓ — выбрать Space — отметить Enter — установить Q — выход' -ForegroundColor Gray 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,-36} v{3}" -f $pointer, $check, $Apps[$index].name, $Apps[$index].version) -ForegroundColor $color Write-Host (" {0}" -f $Apps[$index].description) -ForegroundColor DarkGray } Write-Host '' $key = [Console]::ReadKey($true) switch ($key.Key) { 'UpArrow' { if ($cursor -gt 0) { $cursor-- } } 'DownArrow' { if ($cursor -lt ($Apps.Count - 1)) { $cursor++ } } 'Spacebar' { $marked[$cursor] = -not $marked[$cursor] } 'Enter' { $chosen = @() for ($index = 0; $index -lt $Apps.Count; $index++) { if ($marked[$index]) { $chosen += $Apps[$index] } } return $chosen } 'Escape' { return @() } default { if ($key.KeyChar -eq 'q' -or $key.KeyChar -eq 'Q') { return @() } if ($key.KeyChar -eq 'i' -or $key.KeyChar -eq 'I') { $chosen = @() for ($index = 0; $index -lt $Apps.Count; $index++) { if ($marked[$index]) { $chosen += $Apps[$index] } } return $chosen } } } } } 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 { Write-Host 'Не удалось отправить журнал установки; локальная установка продолжится.' -ForegroundColor DarkYellow } } 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.' } if (-not (Get-Command winget.exe -ErrorAction SilentlyContinue)) { throw 'Не найден WinGet. Установите или обновите Microsoft App Installer из Microsoft Store и запустите NSK Installer снова.' } $installed = $false if ($DisplayName) { $installed = Get-InstalledAppByName -DisplayName $DisplayName } $action = if ($installed) { 'upgrade' } else { 'install' } Write-Progress -Activity 'NSK Software Installer' -Status "Установка $Name через WinGet" -PercentComplete 10 Write-Host "`n[$action] $Name — $PackageId" -ForegroundColor Cyan $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) } & winget.exe @wingetArgs $wingetExitCode = $LASTEXITCODE if ($action -eq 'upgrade' -and $wingetExitCode -eq -1978335189) { Write-Host 'WinGet не нашёл применимое обновление. Текущая Figma остаётся установленной; если версия кажется старой, проверьте установку вручную.' -ForegroundColor DarkYellow return } if ($wingetExitCode -ne 0) { throw "WinGet для $Name завершился с кодом $wingetExitCode." } } 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 { $percent = 55 Write-Progress -Activity 'NSK Software Installer' -Status 'Загрузка Figma Bridge' -PercentComplete $percent Invoke-InstallerApi -Path $asset.downloadPath -Download -OutFile $archivePath $actualHash = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualHash -ne ([string]$asset.sha256).ToLowerInvariant()) { throw 'SHA-256 архива Figma Bridge не совпал с каталогом. Архив не будет распакован.' } $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 -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 } $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 } 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 } $number = 0 if (-not [int]::TryParse($choice, [ref]$number) -or $number -lt 1 -or $number -gt $hostBlocks.Count) { Write-Host 'Номер не распознан; файл SSH не менялся.' -ForegroundColor DarkYellow return } $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 } } $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 } function Install-App { param([Parameter(Mandatory)] [psobject] $App) 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 if ($App.sshForward -and $App.sshForward.localPort -eq 3055) { Add-FigmaSshForward -LocalPort 3055 } } default { throw "Для пакета '$($App.id)' нет встроенного обработчика '$($App.handler)'." } } Send-InstallEvent -AppId $App.id -Version $App.version -Status 'succeeded' Write-Host "`nГотово: $($App.name)" -ForegroundColor Green if ($script:InstalledManifestPath) { Write-Host "Папка плагина открывается. Manifest: $script:InstalledManifestPath" -ForegroundColor Cyan Start-Process explorer.exe -ArgumentList ('"' + $script:InstalledManifestDirectory + '"') } foreach ($step in $App.manualSteps) { Write-Host " • $step" -ForegroundColor Gray } } 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) if ($selection.Count -eq 0) { Write-Host 'Ничего не выбрано. Установка отменена.' -ForegroundColor Gray return } $completed = 0 foreach ($app in $selection) { try { Install-App -App $app $completed++ } catch { Write-Host "Ошибка установки $($app.name): $($_.Exception.Message)" -ForegroundColor Red } } Write-Progress -Activity 'NSK Software Installer' -Completed Write-Host "`nУстановлено успешно: $completed из $($selection.Count). Нажмите Enter для выхода." -ForegroundColor Green [void](Read-Host) } catch { Write-Progress -Activity 'NSK Software Installer' -Completed Write-Host "Ошибка NSK Software Installer: $($_.Exception.Message)" -ForegroundColor Red if (-not [Console]::IsInputRedirected) { [void](Read-Host 'Нажмите Enter для выхода') } return }