# Discord bootstrapper: IEX-compatible version. # The server must return this file as plain UTF-8 text. if ($PSVersionTable.PSVersion.Major -lt 5) { throw 'PowerShell 5.1 or newer is required.' } Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 # -------------------- CONFIGURATION -------------------- $BaseUrl = 'https://file.cyberfix.space' $ZapretArchiveUrl = "$BaseUrl/zapret.zip" # Exactly 64 hexadecimal characters. Generate with: # (Get-FileHash .\zapret.zip -Algorithm SHA256).Hash.ToLowerInvariant() $ZapretSha256 = '35f37c791be8a78ac8f89eca584631a64e334ae885e7d40791959f064d51161f' # Used when the user presses Enter without entering a menu number. $DefaultAlt = 11 $DiscordSetupUrl = 'https://discord.com/api/download?platform=win' # A path without spaces or Cyrillic characters is preferable for zapret batch files. $WorkRoot = Join-Path $env:SystemDrive 'DiscordBootstrap' # ------------------------------------------------------- function Write-Stage { param([Parameter(Mandatory)][string]$Text) Write-Host "`n==> $Text" -ForegroundColor Cyan } function Write-Info { param([Parameter(Mandatory)][string]$Text) Write-Host "[i] $Text" } function Assert-Administrator { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run the command from an elevated PowerShell or Administrator CMD window.' } } function Assert-Configuration { if ($BaseUrl -match 'example\.win') { throw 'Replace $BaseUrl in bootstrap.ps1 with your file-server URL.' } if ($ZapretSha256 -notmatch '^[0-9a-fA-F]{64}$') { throw 'Replace $ZapretSha256 with the 64-character SHA-256 hash of zapret.zip.' } } function Test-Sha256 { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$ExpectedHash ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } try { $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash return $actual.Equals($ExpectedHash, [StringComparison]::OrdinalIgnoreCase) } catch { return $false } } function Download-NewFile { param( [Parameter(Mandatory)][string]$Uri, [Parameter(Mandatory)][string]$DestinationDirectory, [Parameter(Mandatory)][string]$FilePrefix, [Parameter(Mandatory)][string]$Extension ) $name = '{0}-{1}{2}' -f $FilePrefix, ([guid]::NewGuid().ToString('N')), $Extension $path = Join-Path $DestinationDirectory $name Write-Info "Downloading: $Uri" Invoke-WebRequest -Uri $Uri -OutFile $path -UseBasicParsing return $path } function Get-VerifiedZapretArchive { param( [Parameter(Mandatory)][string]$Directory, [Parameter(Mandatory)][string]$Uri, [Parameter(Mandatory)][string]$ExpectedHash ) $hashPrefix = $ExpectedHash.Substring(0, 12).ToLowerInvariant() $cached = Get-ChildItem -LiteralPath $Directory ` -Filter "zapret-$hashPrefix-*.zip" ` -File ` -ErrorAction SilentlyContinue foreach ($file in $cached) { if (Test-Sha256 -Path $file.FullName -ExpectedHash $ExpectedHash) { Write-Info "Using verified cached archive: $($file.FullName)" return $file.FullName } } $downloaded = Download-NewFile ` -Uri $Uri ` -DestinationDirectory $Directory ` -FilePrefix "zapret-$hashPrefix" ` -Extension '.zip' if (-not (Test-Sha256 -Path $downloaded -ExpectedHash $ExpectedHash)) { throw "zapret.zip SHA-256 mismatch. Downloaded file was not executed: $downloaded" } Write-Info 'zapret archive SHA-256 is valid.' return $downloaded } function Test-ZapretDirectory { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return $false } $batchFiles = Get-ChildItem -LiteralPath $Path ` -Filter '*.bat' ` -Recurse ` -File ` -ErrorAction SilentlyContinue foreach ($batchFile in $batchFiles) { if ($batchFile.Name -notmatch '^general \(ALT\d+\)\.bat$') { continue } $winws = Join-Path $batchFile.Directory.FullName 'bin\winws.exe' if (Test-Path -LiteralPath $winws -PathType Leaf) { return $true } } return $false } function Get-OrExpandZapretRoot { param( [Parameter(Mandatory)][string]$ArchivePath, [Parameter(Mandatory)][string]$Directory, [Parameter(Mandatory)][string]$ExpectedHash ) $hashPrefix = $ExpectedHash.Substring(0, 12).ToLowerInvariant() $existingDirectories = Get-ChildItem -LiteralPath $Directory ` -Directory ` -Filter "zapret-$hashPrefix-*" ` -ErrorAction SilentlyContinue foreach ($candidate in $existingDirectories) { if (Test-ZapretDirectory -Path $candidate.FullName) { Write-Info "Using extracted zapret directory: $($candidate.FullName)" return $candidate } } $extractDirectory = Join-Path $Directory ( 'zapret-{0}-{1}' -f $hashPrefix, ([guid]::NewGuid().ToString('N')) ) New-Item -ItemType Directory -Path $extractDirectory -Force | Out-Null Expand-Archive -LiteralPath $ArchivePath -DestinationPath $extractDirectory if (-not (Test-ZapretDirectory -Path $extractDirectory)) { throw 'No usable general (ALT...).bat strategy with bin\winws.exe was found in zapret.zip.' } return Get-Item -LiteralPath $extractDirectory } function Get-ZapretStrategies { param([Parameter(Mandatory)][System.IO.DirectoryInfo]$Root) $strategies = @() $batchFiles = Get-ChildItem -LiteralPath $Root.FullName ` -Filter '*.bat' ` -Recurse ` -File ` -ErrorAction SilentlyContinue foreach ($batchFile in $batchFiles) { if ($batchFile.Name -notmatch '^general \(ALT(?\d+)\)\.bat$') { continue } $winws = Join-Path $batchFile.Directory.FullName 'bin\winws.exe' if (-not (Test-Path -LiteralPath $winws -PathType Leaf)) { continue } $strategies += [pscustomobject]@{ Alt = [int]$Matches['AltNumber'] File = $batchFile } } $strategies = @( $strategies | Sort-Object ` @{ Expression = { $_.Alt }; Ascending = $true }, @{ Expression = { $_.File.FullName }; Ascending = $true } ) if ($strategies.Count -eq 0) { throw 'No available ALT strategies were found.' } return $strategies } function Select-ZapretStrategy { param( [Parameter(Mandatory)][object[]]$Strategies, [Parameter(Mandatory)][int]$DefaultAlt ) $requestedAlt = $null if ($env:ZAPRET_ALT -and $env:ZAPRET_ALT -match '^(?:ALT)?(?\d+)$') { $requestedAlt = [int]$Matches['AltNumber'] } if ($null -ne $requestedAlt) { $requested = @($Strategies | Where-Object { $_.Alt -eq $requestedAlt }) if ($requested.Count -gt 0) { Write-Info "Strategy ALT$requestedAlt selected through ZAPRET_ALT." return $requested[0].File } Write-Warning "ALT$requestedAlt is not present in the downloaded archive." } $defaultIndex = 0 for ($index = 0; $index -lt $Strategies.Count; $index++) { if ($Strategies[$index].Alt -eq $DefaultAlt) { $defaultIndex = $index break } } Write-Host '' Write-Host 'Available zapret strategies:' -ForegroundColor Yellow for ($index = 0; $index -lt $Strategies.Count; $index++) { $strategy = $Strategies[$index] $defaultLabel = if ($index -eq $defaultIndex) { ' [default]' } else { '' } Write-Host ( ' [{0}] ALT{1}{2}' -f ($index + 1), $strategy.Alt, $defaultLabel ) } Write-Host ' [0] Cancel' while ($true) { $choice = Read-Host ( 'Select a strategy number or press Enter for ALT{0}' -f $Strategies[$defaultIndex].Alt ) if ([string]::IsNullOrWhiteSpace($choice)) { return $Strategies[$defaultIndex].File } if ($choice -eq '0' -or $choice -match '^(q|quit|exit)$') { return $null } $number = 0 if ( [int]::TryParse($choice, [ref]$number) -and $number -ge 1 -and $number -le $Strategies.Count ) { return $Strategies[$number - 1].File } Write-Warning 'Enter one of the displayed menu numbers.' } } function Get-WinwsProcesses { try { return @(Get-CimInstance Win32_Process -Filter "Name='winws.exe'" -ErrorAction Stop) } catch { return @() } } function Start-ZapretStrategy { param( [Parameter(Mandatory)][System.IO.FileInfo]$Strategy, [Parameter(Mandatory)][string]$MarkerPath ) $strategyRoot = $Strategy.Directory.FullName $expectedWinws = [IO.Path]::GetFullPath( (Join-Path $strategyRoot 'bin\winws.exe') ) $strategyId = '{0}|{1}' -f $Strategy.Name, $expectedWinws $storedStrategyId = $null if (Test-Path -LiteralPath $MarkerPath -PathType Leaf) { $storedStrategyId = Get-Content -LiteralPath $MarkerPath -Raw -ErrorAction SilentlyContinue if ($storedStrategyId) { $storedStrategyId = $storedStrategyId.Trim() } } $processes = Get-WinwsProcesses $sameExecutable = @( $processes | Where-Object { $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath).Equals( $expectedWinws, [StringComparison]::OrdinalIgnoreCase ) } ) if ( $sameExecutable.Count -gt 0 -and $storedStrategyId -eq $strategyId ) { Write-Info "$($Strategy.BaseName) is already running. A duplicate was not started." return } # Stop an old or differently configured winws process before switching ALT. foreach ($process in $processes) { Write-Info "Stopping winws.exe process (PID $($process.ProcessId))." Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue } Remove-Item -LiteralPath $MarkerPath -Force -ErrorAction SilentlyContinue Write-Info "Starting strategy: $($Strategy.FullName)" $batchCommand = '""{0}""' -f $Strategy.FullName Start-Process ` -FilePath $env:ComSpec ` -ArgumentList @('/d', '/c', $batchCommand) ` -WorkingDirectory $strategyRoot ` -WindowStyle Hidden $deadline = (Get-Date).AddSeconds(12) do { Start-Sleep -Milliseconds 500 $running = @( Get-WinwsProcesses | Where-Object { $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath).Equals( $expectedWinws, [StringComparison]::OrdinalIgnoreCase ) } ) if ($running.Count -gt 0) { Set-Content ` -LiteralPath $MarkerPath ` -Value $strategyId ` -Encoding ASCII Write-Info "$($Strategy.BaseName) is running." return } } while ((Get-Date) -lt $deadline) throw "$($Strategy.Name) was started, but the expected winws.exe process did not appear." } function Stop-DiscordProcesses { foreach ($name in @('Discord', 'DiscordCanary', 'DiscordPTB', 'Update', 'Squirrel')) { Get-Process -Name $name -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } Start-Sleep -Seconds 2 } function Get-DiscordLocalRoots { $roots = @() if ($env:LOCALAPPDATA) { $roots += Join-Path $env:LOCALAPPDATA 'Discord' } $usersRoot = Join-Path $env:SystemDrive 'Users' if (Test-Path -LiteralPath $usersRoot -PathType Container) { $profiles = Get-ChildItem -LiteralPath $usersRoot ` -Directory ` -Force ` -ErrorAction SilentlyContinue foreach ($profile in $profiles) { $candidate = Join-Path $profile.FullName 'AppData\Local\Discord' if (Test-Path -LiteralPath $candidate -PathType Container) { $roots += $candidate } } } return @( $roots | Where-Object { $_ } | ForEach-Object { [IO.Path]::GetFullPath($_) } | Sort-Object -Unique ) } function Get-DiscordRoamingRoot { param([Parameter(Mandatory)][string]$LocalRoot) # LocalRoot: # C:\Users\\AppData\Local\Discord $localAppData = Split-Path -Path $LocalRoot -Parent $appDataRoot = Split-Path -Path $localAppData -Parent return Join-Path $appDataRoot 'Roaming\discord' } function Ensure-DiscordDirectories { param([switch]$VerifyWrite) $createdVersionDirectories = @() $localRoots = @(Get-DiscordLocalRoots) if ($localRoots.Count -eq 0 -and $env:LOCALAPPDATA) { $localRoots = @(Join-Path $env:LOCALAPPDATA 'Discord') } foreach ($localRoot in $localRoots) { # Preserve existing roots. If the current user's root is missing, # New-Item creates it without deleting or renaming anything. New-Item -ItemType Directory -Path $localRoot -Force | Out-Null $roamingRoot = Get-DiscordRoamingRoot -LocalRoot $localRoot New-Item -ItemType Directory -Path $roamingRoot -Force | Out-Null $appDirectories = @( Get-ChildItem -LiteralPath $localRoot ` -Directory ` -Filter 'app-*' ` -ErrorAction SilentlyContinue ) foreach ($appDirectory in $appDirectories) { $version = $appDirectory.Name.Substring(4) if ([string]::IsNullOrWhiteSpace($version)) { continue } $roamingVersion = Join-Path $roamingRoot $version New-Item -ItemType Directory -Path $roamingVersion -Force | Out-Null $createdVersionDirectories += $roamingVersion if ($VerifyWrite) { $probe = Join-Path $roamingVersion ( '.discord-write-test-{0}.tmp' -f [guid]::NewGuid().ToString('N') ) try { [IO.File]::WriteAllText($probe, 'test') Remove-Item -LiteralPath $probe -Force } catch { throw "The directory exists but is not writable: $roamingVersion" } } } } return @($createdVersionDirectories | Sort-Object -Unique) } function Find-DiscordExecutable { $executables = @() foreach ($localRoot in @(Get-DiscordLocalRoots)) { if (-not (Test-Path -LiteralPath $localRoot -PathType Container)) { continue } $executables += Get-ChildItem -LiteralPath $localRoot ` -Filter 'Discord.exe' ` -Recurse ` -File ` -ErrorAction SilentlyContinue } return $executables | Sort-Object ` @{ Expression = { if ($_.Directory.Name -match '^app-(?.+)$') { try { return [version]$Matches['Version'] } catch { return [version]'0.0' } } return [version]'0.0' }; Descending = $true }, @{ Expression = { $_.LastWriteTime }; Descending = $true } | Select-Object -First 1 } function Find-DiscordUpdateExecutable { $candidates = @() foreach ($localRoot in @(Get-DiscordLocalRoots)) { $candidate = Join-Path $localRoot 'Update.exe' if (Test-Path -LiteralPath $candidate -PathType Leaf) { $candidates += Get-Item -LiteralPath $candidate } } return $candidates | Sort-Object LastWriteTime -Descending | Select-Object -First 1 } function Get-DiscordProcesses { return @( Get-Process -Name 'Discord' -ErrorAction SilentlyContinue ) } function Test-DiscordErrorWindow { param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Processes ) if ($null -eq $Processes -or $Processes.Count -eq 0) { return $false } foreach ($process in $Processes) { $title = $process.MainWindowTitle if ($title -and $title -match '(?i)javascript|error|ошибка') { return $true } } return $false } function Open-DiscordDirectoryGuards { param( [Parameter(Mandatory)] [string[]]$Paths ) $guards = @() foreach ($path in $Paths) { New-Item -ItemType Directory -Path $path -Force | Out-Null $guardPath = Join-Path $path '.discord-bootstrap.keep' $stream = [IO.File]::Open( $guardPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::Read ) $guards += [pscustomobject]@{ Path = $guardPath Stream = $stream } } return @($guards) } function Close-DiscordDirectoryGuards { param( [AllowEmptyCollection()] [object[]]$Guards ) foreach ($guard in @($Guards)) { if ($null -eq $guard) { continue } try { $guard.Stream.Dispose() } catch { } Remove-Item -LiteralPath $guard.Path -Force -ErrorAction SilentlyContinue } } function Test-DiscordHealthy { $processes = @(Get-DiscordProcesses) if ($processes.Count -lt 2) { return $false } if (Test-DiscordErrorWindow -Processes $processes) { return $false } return $true } function Wait-ForDiscord { param( [int]$Seconds = 25, [int]$StableSeconds = 6 ) $deadline = (Get-Date).AddSeconds($Seconds) $stableSince = $null do { # Update.exe may create a new app-* directory after launch. # Keep creating the corresponding Roaming\discord\ # directory during the entire startup window. Ensure-DiscordDirectories | Out-Null $processes = @(Get-DiscordProcesses) if (Test-DiscordErrorWindow -Processes $processes) { Write-Warning 'Discord opened an error window.' return $false } if ($processes.Count -ge 2) { if ($null -eq $stableSince) { $stableSince = Get-Date } if (((Get-Date) - $stableSince).TotalSeconds -ge $StableSeconds) { return $true } } else { $stableSince = $null } Start-Sleep -Milliseconds 250 } while ((Get-Date) -lt $deadline) return $false } function Start-InstalledDiscord { if (Test-DiscordHealthy) { Write-Info 'Discord is already running normally.' return $true } $existingProcesses = @(Get-DiscordProcesses) if ($existingProcesses.Count -gt 0) { Write-Info 'Stopping an incomplete or failed Discord process.' Stop-DiscordProcesses } $prepared = @(Ensure-DiscordDirectories -VerifyWrite) foreach ($directory in $prepared) { Write-Info "Prepared Discord data directory: $directory" } $discordExe = Find-DiscordExecutable if (-not $discordExe) { return $false } $appDirectory = $discordExe.Directory $localRoot = $appDirectory.Parent.FullName if ($appDirectory.Name -notmatch '^app-(?.+)$') { throw "Unable to determine the Discord version from: $($appDirectory.FullName)" } $version = $Matches['Version'] $roamingRoot = Get-DiscordRoamingRoot -LocalRoot $localRoot $roamingVersion = Join-Path $roamingRoot $version New-Item -ItemType Directory -Path $roamingRoot -Force | Out-Null New-Item -ItemType Directory -Path $roamingVersion -Force | Out-Null # Keep open file handles in both directories while Discord starts. # This prevents another component from deleting the newly created # directories during the startup race. $guards = @( Open-DiscordDirectoryGuards -Paths @( $roamingRoot, $roamingVersion ) ) try { Write-Info "Starting Discord directly: $($discordExe.FullName)" Write-Info "Guarding Discord data directory: $roamingVersion" Start-Process ` -FilePath $discordExe.FullName ` -WorkingDirectory $discordExe.Directory.FullName if (Wait-ForDiscord -Seconds 30 -StableSeconds 8) { Write-Info 'Discord is running normally.' return $true } Write-Warning 'Direct Discord start was not stable.' Stop-DiscordProcesses return $false } finally { Close-DiscordDirectoryGuards -Guards $guards } } function Test-DiscordInstaller { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } try { $file = Get-Item -LiteralPath $Path if ($file.Length -lt 1MB) { return $false } $signature = Get-AuthenticodeSignature -LiteralPath $Path if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { return $false } return $true } catch { return $false } } function Get-DiscordInstaller { param( [Parameter(Mandatory)][string]$Directory, [Parameter(Mandatory)][string]$Uri ) $cached = Get-ChildItem -LiteralPath $Directory ` -Filter 'DiscordSetup-*.exe' ` -File ` -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending foreach ($candidate in $cached) { if (Test-DiscordInstaller -Path $candidate.FullName) { Write-Info "Using verified cached Discord installer: $($candidate.FullName)" return $candidate.FullName } } $installer = Download-NewFile ` -Uri $Uri ` -DestinationDirectory $Directory ` -FilePrefix 'DiscordSetup' ` -Extension '.exe' if (-not (Test-DiscordInstaller -Path $installer)) { throw "The Discord installer is too small or has an invalid digital signature: $installer" } $signature = Get-AuthenticodeSignature -LiteralPath $installer Write-Info "Discord installer signer: $($signature.SignerCertificate.Subject)" return $installer } function Install-Discord { param([Parameter(Mandatory)][string]$InstallerPath) Stop-DiscordProcesses Ensure-DiscordDirectories -VerifyWrite | Out-Null Write-Info 'Running the official Discord installer.' $installerProcess = Start-Process ` -FilePath $InstallerPath ` -PassThru ` -Wait Write-Info "Installer exit code: $($installerProcess.ExitCode)" # Squirrel can continue work in a child process after the launcher exits. $deadline = (Get-Date).AddSeconds(35) do { Ensure-DiscordDirectories if (Find-DiscordExecutable) { return } Start-Sleep -Seconds 1 } while ((Get-Date) -lt $deadline) throw 'Discord installer finished, but Discord.exe was not found.' } try { Assert-Administrator Assert-Configuration Write-Stage 'Preparing working directory' New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null Write-Info "Work directory: $WorkRoot" Write-Stage 'Preparing zapret-discord-youtube' $archive = Get-VerifiedZapretArchive ` -Directory $WorkRoot ` -Uri $ZapretArchiveUrl ` -ExpectedHash $ZapretSha256 $zapretRoot = Get-OrExpandZapretRoot ` -ArchivePath $archive ` -Directory $WorkRoot ` -ExpectedHash $ZapretSha256 $strategies = @(Get-ZapretStrategies -Root $zapretRoot) $strategy = Select-ZapretStrategy ` -Strategies $strategies ` -DefaultAlt $DefaultAlt if ($null -eq $strategy) { Write-Host "`nCancelled. Nothing was changed." -ForegroundColor Yellow return } Write-Stage "Starting zapret-discord-youtube $($strategy.BaseName)" $markerPath = Join-Path $WorkRoot 'active-zapret-strategy.txt' Start-ZapretStrategy ` -Strategy $strategy ` -MarkerPath $markerPath Write-Stage 'Repairing and starting Discord' Ensure-DiscordDirectories | Out-Null $discordRootsBeforeStart = @( Get-DiscordLocalRoots | Where-Object { Test-Path -LiteralPath $_ -PathType Container } ) if (-not (Start-InstalledDiscord)) { if ($discordRootsBeforeStart.Count -gt 0) { throw @" Discord.exe exists inside a protected LocalAppData directory, but it still opens the JavaScript error window. Update.exe and DiscordSetup.exe were not started, because Squirrel cannot replace the protected Discord root directory. "@ } Write-Info 'Discord is not installed. Installation will be attempted.' $installer = Get-DiscordInstaller ` -Directory $WorkRoot ` -Uri $DiscordSetupUrl Install-Discord -InstallerPath $installer Ensure-DiscordDirectories | Out-Null if (-not (Start-InstalledDiscord)) { throw 'Discord was installed, but the Discord process did not remain running.' } } Write-Host "`nCompleted: $($strategy.BaseName) and Discord are running." -ForegroundColor Green } catch { Write-Host "`nFAILED: $($_.Exception.Message)" -ForegroundColor Red exit 1 }