<# consolize one-line installer. irm https://get-consolize.cybx.dev | iex Downloads consolize.exe (the release matching -Ref) plus the setup scripts, installs them to C:\Program Files\Consolize, and offers to run the provisioning right away. Options, when piping is not enough: & ([scriptblock]::Create((irm https://get-consolize.cybx.dev))) -DownloadOnly & ([scriptblock]::Create((irm .../get.ps1))) -Ref v0.11.0 #> param( [string]$Repo = 'cybx/consolize', [string]$Ref = 'main', [string]$InstallDir = 'C:\Program Files\Consolize', # Program Files, not ProgramData: ProgramData grants BUILTIN\Users Write on # everything it contains, and a SYSTEM scheduled task runs these scripts at # every logon. A writable script path there is a handout of SYSTEM. [string]$ScriptDir = 'C:\Program Files\Consolize\setup', [switch]$DownloadOnly, # Refresh the installed scripts and binary, then stop. The scripts are a # copy taken at install time, so a fix published later does not reach a # machine until they are pulled again. [switch]$UpdateOnly, # Where the elevated window re-fetches this script from. The short domain is # a Cloudflare Worker in front of the same file, serving it with no-store, so # a fix published a minute ago is the one that runs. raw.githubusercontent # caches for minutes, which is long enough to run yesterday's code by # accident. A fork passing -Repo or -Ref gets the raw URL instead. [string]$SelfUrl = 'https://get-consolize.cybx.dev', [switch]$NoElevate ) $ErrorActionPreference = 'Stop' [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $ProgressPreference = 'SilentlyContinue' function Test-Admin { return ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } Write-Host '' Write-Host ' consolize installer' -ForegroundColor Cyan Write-Host ' turns this PC into a couch gaming console' Write-Host '' # The Windows console enables QuickEdit by default, and a click inside the # window then PAUSES whatever is running until Esc or Enter. Setup is long # enough that someone will click it, and a paused console is indistinguishable # from a crashed one. Console settings are read when a window is created, so # doing this here means the elevated window created just below is already # immune. (Same user either way: elevation does not change the hive.) function Disable-ConsoleQuickEdit { try { $key = 'HKCU:\Console' if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null } New-ItemProperty -Path $key -Name 'QuickEdit' -Value 0 -PropertyType DWord -Force | Out-Null # powershell.exe keeps its own console profile, which would win $psKey = Join-Path $key '%SystemRoot%_System32_WindowsPowerShell_v1.0_powershell.exe' if (Test-Path $psKey) { New-ItemProperty -Path $psKey -Name 'QuickEdit' -Value 0 -PropertyType DWord -Force | Out-Null } } catch { # cosmetic; never worth failing an install over } } Disable-ConsoleQuickEdit # Everything past this point writes to Program Files, services, the registry and # the shell, so elevate rather than failing halfway. Piped through iex there is # no script file to re-launch, so the elevated session re-fetches this same # script and replays the parameters, base64 encoded to dodge quoting entirely. if (-not (Test-Admin) -and -not $DownloadOnly -and -not $NoElevate) { $relaunchUrl = if ($PSBoundParameters.ContainsKey('Repo') -or $PSBoundParameters.ContainsKey('Ref')) { "https://raw.githubusercontent.com/$Repo/$Ref/get.ps1" } else { $SelfUrl } $replay = foreach ($p in $PSBoundParameters.GetEnumerator()) { if ($p.Value -is [switch]) { if ($p.Value.IsPresent) { "-$($p.Key)" } } else { "-$($p.Key) '$($p.Value -replace "'", "''")'" } } $inner = "& ([scriptblock]::Create((Invoke-RestMethod '$relaunchUrl'))) $($replay -join ' ')" $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($inner)) Write-Host 'Administrator rights are needed (Program Files, services, registry, shell).' Write-Host 'Approve the prompt: the installer continues in a new elevated window,' Write-Host 'where clicking will not pause it.' try { Start-Process powershell -Verb RunAs -ArgumentList ` '-NoExit', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', $encoded } catch { Write-Warning 'Elevation was declined. Open PowerShell as administrator and run the one-liner again:' Write-Host " irm $relaunchUrl | iex" } return } $tmp = Join-Path $env:TEMP ("consolize-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Force -Path $tmp | Out-Null # --- setup scripts always come from the source tree (small, always available) Write-Host "Downloading setup scripts ($Ref)..." $srcZip = Join-Path $tmp 'src.zip' Invoke-WebRequest "https://github.com/$Repo/archive/refs/heads/$Ref.zip" -OutFile $srcZip -UseBasicParsing -ErrorAction SilentlyContinue if (-not (Test-Path $srcZip)) { # $Ref may be a tag rather than a branch Invoke-WebRequest "https://github.com/$Repo/archive/refs/tags/$Ref.zip" -OutFile $srcZip -UseBasicParsing } Expand-Archive $srcZip -DestinationPath $tmp -Force $extracted = Get-ChildItem $tmp -Directory | Where-Object { $_.Name -like 'consolize-*' } | Select-Object -First 1 if (-not $extracted) { throw 'Could not find the extracted source folder.' } # --- consolize.exe comes from the matching release; the source tree has no binary $releaseEndpoint = if ($Ref -eq 'main') { "https://api.github.com/repos/$Repo/releases/latest" } elseif ($Ref -match '^v\d') { "https://api.github.com/repos/$Repo/releases/tags/$([uri]::EscapeDataString($Ref))" } else { $null } Write-Host $(if ($releaseEndpoint) { "Looking for a consolize.exe release matching '$Ref'..." } else { "Ref '$Ref' is not a release tag; the binary will be built from that source when the .NET SDK is available." }) $exeSource = $null if ($releaseEndpoint) { try { $rel = Invoke-RestMethod $releaseEndpoint -UseBasicParsing -Headers @{ 'User-Agent' = 'consolize-installer' } $asset = $rel.assets | Where-Object { $_.name -eq 'consolize.exe' } | Select-Object -First 1 if ($asset) { $exeSource = Join-Path $tmp 'consolize.exe' Write-Host " $($rel.tag_name)" Invoke-WebRequest $asset.browser_download_url -OutFile $exeSource -UseBasicParsing # GitHub records the digest when the release asset is uploaded. # Refuse a truncated, substituted or stale proxy response rather # than executing it as the shell on the next sign-in. $assetDigest = [string]$asset.digest $digestMatch = [regex]::Match($assetDigest, '^sha256:([0-9a-fA-F]{64})$') if (-not $digestMatch.Success) { Remove-Item $exeSource -Force -ErrorAction SilentlyContinue $exeSource = $null throw 'The release asset has no GitHub SHA-256 digest.' } $expectedHash = $digestMatch.Groups[1].Value.ToLowerInvariant() $actualHash = (Get-FileHash $exeSource -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualHash -ne $expectedHash) { Remove-Item $exeSource -Force -ErrorAction SilentlyContinue $exeSource = $null throw "consolize.exe digest mismatch (expected $expectedHash, got $actualHash)." } Write-Host " SHA-256 verified by GitHub: $actualHash" } } catch { Write-Warning "No usable matching release found ($($_.Exception.Message))." } } if (-not $exeSource) { $dotnet = Get-Command dotnet -ErrorAction SilentlyContinue if ($dotnet) { Write-Host 'Building consolize.exe from source instead...' & dotnet publish (Join-Path $extracted.FullName 'src\Consolize.SessionManager') -c Release -o (Join-Path $tmp 'publish') if ($LASTEXITCODE -eq 0) { $exeSource = Join-Path $tmp 'publish\consolize.exe' } } } if ($DownloadOnly) { Write-Host '' Write-Host "Downloaded to: $tmp" Write-Host (" setup scripts : " + (Get-ChildItem (Join-Path $extracted.FullName 'setup') -Filter *.ps1).Count + ' files') Write-Host (" consolize.exe : " + $(if ($exeSource) { $exeSource } else { 'not available' })) return } if (-not $exeSource) { throw "consolize.exe is unavailable for ref '$Ref'. No files were installed and provisioning will not be offered." } New-Item -ItemType Directory -Force -Path $ScriptDir | Out-Null Copy-Item (Join-Path $extracted.FullName 'setup\*.ps1') $ScriptDir -Force # Belt and braces against "cannot be loaded because it is not digitally signed". # Measured, so the reasoning is not overstated: a zip fetched with # Invoke-WebRequest does NOT carry the Mark of the Web, so this install path does # not create the problem. One taken from a browser does, and someone who # downloaded the repository by hand and ran the scripts from there would hit it # under RemoteSigned. Costs nothing here and removes that case. # # What this cannot fix is a machine set to AllSigned or Restricted, where an # unsigned script is refused no matter where it came from. That is why the # README gives the -ExecutionPolicy Bypass form: it is the one that works # everywhere, and it matters most for the uninstaller, the script someone runs # when they have already decided they want out. Get-ChildItem (Join-Path $ScriptDir '*.ps1') | Unblock-File -ErrorAction SilentlyContinue Write-Host "Setup scripts installed to $ScriptDir" # Earlier versions installed to ProgramData, which any user can write to. Clear # those copies out so nobody runs the stale, unsafe ones by habit. $legacyScriptDir = 'C:\ProgramData\consolize\setup' if ((Test-Path $legacyScriptDir) -and ($legacyScriptDir -ne $ScriptDir)) { Remove-Item (Join-Path $legacyScriptDir '*.ps1') -Force -ErrorAction SilentlyContinue Write-Host "Removed the old copies from $legacyScriptDir (that location is user writable)" } # The icon, as a file next to the binary rather than only as a resource inside # it. Steam's shortcut entries point at this: extracting an icon out of an exe # gives whatever small size the resource happens to carry, and the Steam library # and Big Picture both draw it larger than that. $iconSource = Join-Path $extracted.FullName 'assets\consolize.ico' if (Test-Path $iconSource) { New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null Copy-Item $iconSource (Join-Path $InstallDir 'consolize.ico') -Force Write-Host "Icon installed to $InstallDir\consolize.ico" } # The boot splash: Windows' own logo is switched off, so this is what the # machine shows on the way up. $splashSource = Join-Path $extracted.FullName 'assets\splash.png' if (Test-Path $splashSource) { $stateDir = Join-Path $env:ProgramData 'Consolize' New-Item -ItemType Directory -Force -Path $stateDir | Out-Null & icacls.exe $stateDir /inheritance:r /grant:r ` '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-32-545:(OI)(CI)RX' | Out-Null if ($LASTEXITCODE -ne 0) { throw "Could not protect $stateDir" } Copy-Item $splashSource (Join-Path $stateDir 'splash.png') -Force Write-Host "Boot splash installed to $stateDir\splash.png" } if ($exeSource) { New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null $target = Join-Path $InstallDir 'consolize.exe' # The running shell IS this file, so it cannot be overwritten in place. # Windows does allow renaming a running executable, and the open handle # follows the rename, so the shell keeps working from the old name until the # next sign-in picks up the new one. if (Test-Path $target) { $retired = Join-Path $InstallDir 'consolize.old.exe' Remove-Item $retired -Force -ErrorAction SilentlyContinue try { Rename-Item $target 'consolize.old.exe' -Force -ErrorAction Stop } catch { Write-Warning "Could not move the running consolize.exe aside: $($_.Exception.Message)" } } Copy-Item $exeSource $target -Force Write-Host "consolize.exe installed to $InstallDir" # make `consolize` work in any new shell $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') if ($machinePath -notlike "*$InstallDir*") { [Environment]::SetEnvironmentVariable('Path', "$machinePath;$InstallDir", 'Machine') Write-Host " added to PATH (new shells only)" } } Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue if ($UpdateOnly) { Write-Host '' Write-Host 'Scripts and binary updated.' -ForegroundColor Green Write-Host 'Nothing else run. To continue a setup you already answered:' Write-Host " cd '$ScriptDir'" Write-Host ' .\setup-console.ps1 -Unattended' Write-Host '' Write-Host 'Note the path: it moved out of ProgramData, which grants every user' -ForegroundColor DarkGray Write-Host 'write access, and a SYSTEM task runs these scripts at logon.' -ForegroundColor DarkGray return } Write-Host '' Write-Host 'Installed.' -ForegroundColor Green Write-Host '' Write-Host 'setup-console.ps1 runs the whole thing: apps, runtimes, quiet layer,' Write-Host 'power, startup, performance, the console account and the shell. It asks' Write-Host 'before each part, and every step is also runnable on its own from' Write-Host "$ScriptDir." Write-Host '' $run = Read-Host 'Run the full console setup now? [Y/n]' if ($run -notmatch '^[nN]') { & (Join-Path $ScriptDir 'setup-console.ps1') } else { Write-Host '' Write-Host 'When you are ready:' Write-Host " cd '$ScriptDir'" Write-Host ' .\setup-console.ps1' }