#Requires -RunAsAdministrator <# setup-windows-with-uac.ps1 Elevated portion of the Windows provisioning. Invoked by setup-windows.bat via Start-Process -Verb RunAs, or run manually from an Administrator prompt. What this installs / configures: - OpenSSH Client capability (the ssh.exe rsync shells out to, and the System32 libcrypto.dll the release's own ssh.exe links against) - ssh-agent set to automatic + started - OpenSSH Server (sshd) capability: automatic + started + inbound TCP 22 - rsync for Windows (nuket/rsync-windows) in C:\Tools\rsync, on the machine PATH: rsync.exe plus the ssh.exe it runs, out of the release zip for this architecture (there is no ARM64 zip; ARM64 gets the x64 one, and the ssh.exe is kept only if it actually starts) - Visual Studio Community (C++ desktop workload, x86/x64 AND ARM64 build tools, Spectre libs, WDK VSIX, Win11 SDK 26100, Clang/LLVM, and - on x64 only - the v141 + Windows XP targeting toolset). VS 2022 on x64, VS 2026 on ARM64; see $VsChannel. - Windows Driver Kit 10.0.26100 - Windows Performance Toolkit - xperf, wpr and Windows Performance Analyzer (wpa.exe) - on the machine PATH Change $VsEdition below to Professional or Enterprise if needed. $VsChannel picks the Visual Studio generation and is architecture-split by default: 17 (VS 2022) on x64, where the v141 / Windows XP toolset is wanted, and 18 (VS 2026) on ARM64, where that toolset cannot exist anyway. ARCHITECTURE. Runs on x64 and on ARM64 (Windows 11 on Arm). On ARM64 the Visual Studio installer, MSVC and clang-cl are all native ARM64 and cross-compile ARM64/x64/x86 targets; what changes is called out at each step, and $HostArch below is what drives it. #> $ErrorActionPreference = 'Stop' # --------------------------------------------------------------------------- # Host architecture # # RuntimeInformation.OSArchitecture rather than PROCESSOR_ARCHITECTURE: this # script can be launched by a 32-bit or an emulated x64 PowerShell, either of # which reports the emulated architecture in the environment variable while this # API still reports the real one. Values seen here: X64, Arm64, X86. # # $IsArm64 gates: # - which rsync-windows release zip is fetched (there is no ARM64 one) # - which Visual Studio generation is driven ($VsChannel: 18 on Arm, 17 on x64) # - the Visual Studio component groups (ARM64 build tools in; the v141/XP # toolset out, because it has no ARM64-hosted compiler and Microsoft does not # ship Windows XP targeting for Arm hosts. Clang/LLVM is installed on every # architecture and is native on Arm, so it is NOT gated here) # - what the VTune and WPT steps report # --------------------------------------------------------------------------- $HostArch = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() $IsArm64 = ($HostArch -eq 'Arm64') function Write-Step([string]$Msg) { Write-Host "`n==> $Msg" -ForegroundColor Cyan } function Assert-ExitCode([int]$Code, [string]$Step) { # 0 = success, 3010 = success + reboot required if ($Code -notin @(0, 3010)) { throw "$Step failed with exit code $Code" } if ($Code -eq 3010) { Write-Host " [reboot required after $Step]" -ForegroundColor Yellow } } function Show-VsSetupLogs { # The VS Installer writes dd_*.log to the invoking user's %TEMP%. Because # this script runs elevated, that %TEMP% belongs to the elevated user and is # readable here even when it is NOT readable by the non-elevated caller. Fold # only the NEWEST installer + bootstrapper log into the transcript (the setup # engine log is where per-component / product errors actually appear) and # keep it short so the transcript stays readable. Write-Host "`n==> Collecting VS Installer logs from $env:TEMP" -ForegroundColor Cyan $recent = Get-ChildItem $env:TEMP -Filter 'dd_*.log' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -gt (Get-Date).AddMinutes(-15) } $picks = @() $picks += $recent | Where-Object { $_.Name -like 'dd_installer_*' } | Sort-Object LastWriteTime | Select-Object -Last 1 $picks += $recent | Where-Object { $_.Name -like 'dd_bootstrapper_*' } | Sort-Object LastWriteTime | Select-Object -Last 1 $picks = $picks | Where-Object { $_ } if (-not $picks) { Write-Host ' (no VS Installer logs modified in the last 15 minutes)' -ForegroundColor Yellow return } foreach ($l in $picks) { Write-Host "`n----- $($l.Name) (tail) -----" -ForegroundColor Yellow Get-Content $l.FullName -Tail 40 } } function Get-VsInstallPath { # The install path of a Visual Studio matching $script:VsChannel, or $null. # # -version is the point. A bare `vswhere -products *` returns EVERY Visual # Studio on the box, newest first, and handing that path to a bootstrapper of # a different generation is not a no-op: `vs_community.exe` (17.x) told to # `modify --installPath ` is a 17.x engine pointed at an # 18.x product. On a box that already has VS 2026 - increasingly the default - # every pass below would target the wrong install. Scope the query to the # generation this script is actually driving. if (-not (Test-Path $script:VsWhere)) { return $null } $range = "[$script:VsChannel.0,$($script:VsChannel + 1).0)" & $script:VsWhere -products '*' -version $range -property installationPath -format value | Select-Object -First 1 } function Invoke-VsModify { # Run one VS install/modify pass for a named group of components. Splitting # the install into separate passes makes it obvious WHICH group fails: each # call prints its label and exit code before Assert-ExitCode throws. # # -Optional downgrades a failure to a warning. Used for groups that are not # available on every host architecture (the v141/XP toolset on ARM64) or that # the rest of the box does not depend on, so one unavailable component cannot # cost you the toolchain. param( [string] $Label, [string[]] $Ids, [switch] $Optional ) Write-Step "Visual Studio: $Label" if (-not $Ids) { Write-Host ' (no components in this group for this architecture - skipping)' -ForegroundColor DarkGray return } $addStr = ($Ids | ForEach-Object { "--add $_" }) -join ' ' # --installPath must be quoted: it contains spaces ("C:\Program Files\..."). # Windows PowerShell 5.1's Start-Process does not quote array elements, so we # hand-build a single string. Component IDs / flags have no spaces. $common = '--includeRecommended --quiet --norestart --wait' if ($script:InstallPath) { $argString = "modify --installPath `"$script:InstallPath`" $addStr $common --force" } else { # No existing install yet -> this first pass performs the base install. $argString = "$addStr $common" } Write-Host " > $script:VsBootstrapper $argString" -ForegroundColor DarkGray $p = Start-Process -FilePath $script:VsBootstrapper -ArgumentList $argString -Wait -PassThru -NoNewWindow Write-Host " exit code: $($p.ExitCode)" if ($Optional -and $p.ExitCode -notin @(0, 3010)) { Write-Warning "Visual Studio ($Label) failed with exit code $($p.ExitCode); continuing (this group is optional)." } else { Assert-ExitCode $p.ExitCode "Visual Studio ($Label)" } # After the first (fresh) install, re-detect the install path so subsequent # passes use `modify`. if (-not $script:InstallPath) { $script:InstallPath = Get-VsInstallPath } } # --------------------------------------------------------------------------- # This runs in a separate elevated window that closes the moment it exits, so # the non-elevated caller (setup-windows.bat) can't see what happened. Mirror # all output to a log next to the script and exit with a real code so the # caller can detect success/failure and show the log. # --------------------------------------------------------------------------- $LogFile = Join-Path $PSScriptRoot 'setup-windows-uac.log' $ExitCode = 0 try { Start-Transcript -Path $LogFile -Force | Out-Null } catch {} try { Write-Step "Host architecture: $HostArch" if ($IsArm64) { # x64 emulation ("Prism") is what carries every x64-only tool this script # installs - rsync.exe, the WDK and ADK installers, BinSkim in the # non-elevated half. It is present on Windows 11 on Arm and absent on # Windows 10 on Arm (x86-only there) and on some Server images, so check # rather than assume: without it those steps install binaries that cannot # start, and the failure would otherwise surface much later. $Prism = Join-Path $env:WINDIR 'System32\xtajit64.dll' if (Test-Path $Prism) { Write-Host ' x64 emulation (Prism) present - x64-only tools will run.' -ForegroundColor Green } else { Write-Warning "x64 emulation not found ($Prism is missing). rsync.exe, the WDK/ADK installers and BinSkim have no ARM64 build and will not run on this box." } } # --------------------------------------------------------------------------- # Base tools via winget # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # OpenSSH Client # # Present by default on Windows 10 1809+ / Windows 11, but removable, and absent # from some Server images. Two things below want it: rsync does not speak ssh # itself, it execs an ssh binary, and the release's own ssh.exe links against the # libcrypto.dll this capability puts in System32. It also owns the ssh-agent # service configured next, so a missing client is why that step would fail. # # Non-fatal, like the server half below: a box that cannot have it should still # finish provisioning. # --------------------------------------------------------------------------- Write-Step 'OpenSSH Client' try { $sshc = Get-WindowsCapability -Online -Name 'OpenSSH.Client*' | Select-Object -First 1 if (-not $sshc) { Write-Warning 'OpenSSH.Client capability not offered by this Windows image - skipping.' } elseif ($sshc.State -eq 'Installed') { Write-Host " OK: $($sshc.Name) already installed" } else { Write-Host " Installing $($sshc.Name) ..." $r = Add-WindowsCapability -Online -Name $sshc.Name if ($r.RestartNeeded) { Write-Host ' [reboot required after OpenSSH Client]' -ForegroundColor Yellow } } } catch { Write-Warning "OpenSSH Client setup failed: $($_.Exception.Message)" } # --------------------------------------------------------------------------- # SSH agent # --------------------------------------------------------------------------- Write-Step 'Enabling ssh-agent' Set-Service -Name ssh-agent -StartupType Automatic if ((Get-Service ssh-agent).Status -ne 'Running') { Start-Service ssh-agent } # --------------------------------------------------------------------------- # OpenSSH Server (sshd) # # Used to reach the test VMs (VirtualBox) from the host: remote shell plus the # transport rsync rides on when seeding test data in. Ships with Windows 10 # 1809+ / Windows 11 as an on-demand capability, so no third-party install. # # The capability normally adds the "OpenSSH Server (sshd)" inbound firewall # rule; we verify and create it if missing (it is absent on some images). # # Non-fatal: a box that can't run sshd should still finish provisioning. # --------------------------------------------------------------------------- Write-Step 'OpenSSH Server (sshd)' try { $sshd = Get-WindowsCapability -Online -Name 'OpenSSH.Server*' | Select-Object -First 1 if (-not $sshd) { Write-Warning 'OpenSSH.Server capability not offered by this Windows image - skipping.' } else { if ($sshd.State -ne 'Installed') { Write-Host " Installing $($sshd.Name) ..." $r = Add-WindowsCapability -Online -Name $sshd.Name if ($r.RestartNeeded) { Write-Host ' [reboot required after OpenSSH Server]' -ForegroundColor Yellow } } else { Write-Host " OK: $($sshd.Name) already installed" } Set-Service -Name sshd -StartupType Automatic if ((Get-Service sshd).Status -ne 'Running') { Start-Service sshd } Write-Host ' sshd: Automatic + running' # Firewall: allow inbound 22 on all profiles. VirtualBox host-only and # bridged adapters are frequently classified Public, and the capability's # own rule is Private-only on some images, which is what leaves a plainly # running sshd plainly unreachable. # # OpenSSH-Server-In-TCP is the name the capability itself uses, so this # WIDENS that rule rather than adding a second one next to it. Creating # our own under a different name would leave the narrow rule in place and # the box still unreachable on a Public-classified adapter; creating one # under the same name would collide. Adopt it if present, create it if not. $ruleName = 'OpenSSH-Server-In-TCP' if (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue) { Set-NetFirewallRule -Name $ruleName -Enabled True -Profile Any Write-Host " Widened firewall rule $ruleName to all profiles" } else { New-NetFirewallRule -Name $ruleName -DisplayName 'OpenSSH SSH Server (sshd)' ` -Enabled True -Direction Inbound -Protocol TCP -Action Allow ` -LocalPort 22 -Profile Any | Out-Null Write-Host " Added firewall rule $ruleName (TCP 22, all profiles)" } } } catch { Write-Warning "OpenSSH Server setup failed: $($_.Exception.Message)" } # --------------------------------------------------------------------------- # rsync for Windows (github.com/nuket/rsync-windows) # # Windows' OpenSSH ships the transport only - no rsync - so pushing test data # from a Linux box needs an rsync.exe on the Windows side. # # The release is one zip per architecture - rsync-windows-x64.zip and # rsync-windows-x86.zip - each holding rsync.exe, the ssh.exe it runs, and the # licence texts under exactly those names. Both exes are installed, together: # rsync.exe prefers an ssh.exe in its own directory, and the release's build is # what makes a push FROM this box run at line rate. The ssh.exe Windows ships # reads its stdin 3KB at a time, which holds a send at ~17MB/s however fast the # link is. Nothing else about it differs - same ~/.ssh, same ssh-agent, same # known_hosts - and a bare `ssh` still resolves to the in-box client, which sits # ahead of C:\Tools\rsync on the machine PATH. # # That ssh.exe links against the libcrypto.dll the OpenSSH Client capability # above puts in System32: Windows' own LibreSSL, and the fast one, since it uses # AES-NI. No copy of it ships in the zip, so where the capability is missing we # unpack rsync alone rather than an ssh.exe that will not start. # # Installed to C:\Tools\rsync (NOT under "Program Files"): the remote end is # invoked as `rsync --server ...` through cmd.exe, and a path with spaces makes # the client-side --rsync-path escape hatch painful to quote. Added to the # MACHINE PATH so it resolves for every account, including the non-interactive # sshd session, which builds its environment from the machine + user registry # PATH rather than from a login shell. # # Non-fatal: a download failure only warns. # --------------------------------------------------------------------------- Write-Step 'rsync for Windows' $RsyncRepo = 'nuket/rsync-windows' # The release publishes exactly two assets - x64 and x86 - and no ARM64 one, so # on ARM64 the x64 build is the right pick: it runs under Prism, and an emulated # x64 rsync still moves data far faster than the ~17MB/s stdin cap that the whole # reason for using this build is to avoid. (The transfer is I/O-bound on the # socket, not on emulated CPU.) Selected off $HostArch rather than # Is64BitOperatingSystem, which answers "true" on ARM64 and so cannot tell the # two 64-bit cases apart. $RsyncAsset = switch ($HostArch) { 'X64' { 'rsync-windows-x64.zip' } 'Arm64' { 'rsync-windows-x64.zip' } default { 'rsync-windows-x86.zip' } } if ($IsArm64) { Write-Host ' ARM64: no native asset is published, using the x64 build under emulation.' -ForegroundColor Yellow } # The /releases/latest/download/ redirect rather than the API: unauthenticated # API calls are rate-limited to 60/hour per IP, which a provisioning run behind a # shared NAT can genuinely exhaust, and the redirect costs none of that budget. # To hold a box on a known build, pin the tag instead: # .../releases/download/v3.5.0-gABCDEF0/$RsyncAsset $RsyncUrl = "https://github.com/$RsyncRepo/releases/latest/download/$RsyncAsset" $RsyncDir = 'C:\Tools\rsync' try { New-Item -ItemType Directory -Force -Path $RsyncDir | Out-Null $RsyncExe = Join-Path $RsyncDir 'rsync.exe' # Does the release's ssh.exe have the libcrypto it needs? Decided before the # download so the answer can also gate what comes out of the zip. This is a # cheap pre-filter only - the authoritative check is running the thing, which # happens after the unpack below. $SysCrypto = Join-Path $env:WINDIR 'System32\libcrypto.dll' $WantSsh = Test-Path $SysCrypto if (-not $WantSsh) { Write-Warning "$SysCrypto is missing - the OpenSSH Client capability is not installed - and the release's ssh.exe needs it. Installing rsync.exe only; rsync will use the ssh on the PATH." } else { $v = (Get-Item $SysCrypto).VersionInfo.FileVersion if ($v -and ([version]($v -replace '[^0-9.]', '')) -lt [version]'3.8.2') { Write-Warning "$SysCrypto is LibreSSL $v; the release's ssh.exe is built against 3.8.2 (Windows OpenSSH Client 9.5). Update Windows, or expect ssh.exe not to start." } if ($IsArm64) { # On ARM64 the presence of libcrypto.dll proves less than it does on # x64: System32 holds the ARM64 build of it, and the ssh.exe in the # zip is x64. Whether an emulated x64 process can load that DLL comes # down to whether it is a plain ARM64 binary or an ARM64X one - not # something worth deciding by parsing the PE header, when running the # binary answers it outright. Unpack it, then run it (below). Write-Host ' ARM64: the x64 ssh.exe will be verified by running it, not by assuming.' -ForegroundColor Yellow } } # Download and unpack beside the targets, not over them, so an interrupted # transfer can't leave a truncated rsync.exe sitting on the PATH. The scratch # name still has to END in .zip: Windows PowerShell 5.1's Expand-Archive # refuses any other extension outright ("*.download is not a supported # archive file format"), where PowerShell 7 just reads the file. [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $tmpZip = Join-Path $RsyncDir "download-$RsyncAsset" Invoke-WebRequest -Uri $RsyncUrl -OutFile $tmpZip -UseBasicParsing Write-Host " Downloaded $RsyncAsset ($([math]::Round((Get-Item $tmpZip).Length / 1MB, 2)) MB)" # Verify against the .sha256 published beside it. Same origin, so this is an # integrity check on the transfer rather than a defence against a hostile # release - but a truncated or proxy-mangled download is the failure that # actually happens, and it fails here instead of mid-transfer later. # # -OutFile, not .Content: GitHub serves the .sha256 as # application/octet-stream, and Invoke-WebRequest hands back a byte[] rather # than a string for any non-text content type, so .Content would compare the # first BYTE against the hash and fail on every correct download. $tmpSha = "$tmpZip.sha256" Invoke-WebRequest -Uri "$RsyncUrl.sha256" -OutFile $tmpSha -UseBasicParsing $want = (((Get-Content $tmpSha -Raw) -split '\s+')[0]).Trim().ToLower() Remove-Item $tmpSha -Force -ErrorAction SilentlyContinue $got = (Get-FileHash $tmpZip -Algorithm SHA256).Hash.ToLower() if ($want -and $want -ne $got) { Remove-Item $tmpZip -Force throw "SHA-256 mismatch for ${RsyncAsset}: expected $want, got $got" } Write-Host " SHA-256 verified: $got" # Unpack to a scratch directory and move out the files we asked for, rather # than expanding straight over the install directory: the zip is the unit # that was checksummed, and this way a future release adding something to it # cannot quietly drop that something onto the machine PATH. $unpack = Join-Path $RsyncDir '.unpack' if (Test-Path $unpack) { Remove-Item -Recurse -Force $unpack } Expand-Archive -Path $tmpZip -DestinationPath $unpack -Force Remove-Item $tmpZip -Force foreach ($f in 'rsync.exe', 'ssh.exe', 'COPYING.txt', 'NOTICE-ssh.txt') { $src = Join-Path $unpack $f if (-not (Test-Path $src)) { continue } if ($f -eq 'ssh.exe' -and -not $WantSsh) { continue } Move-Item -Path $src -Destination (Join-Path $RsyncDir $f) -Force } Remove-Item -Recurse -Force $unpack # Prove the unpacked ssh.exe actually starts, and DELETE it if it does not. # # This matters more than it looks. rsync.exe prefers an ssh.exe sitting in its # own directory, so a present-but-unstartable one does not degrade to the # in-box client - it breaks rsync outright, and the error you get is a remote # shell that died rather than anything naming ssh.exe. The two ways to land # there are a missing/old System32 libcrypto.dll (x64 boxes) and an ARM64 box # whose ARM64 libcrypto cannot be loaded by this x64 binary. Removing it is # the repair in both cases: rsync then falls back to the ssh on the PATH, # which on ARM64 is the native in-box client. # # EAP back to Continue for the call: ssh -V writes its version to STDERR, and # under $ErrorActionPreference = 'Stop' a native command's stderr becomes a # terminating error, so a WORKING client would look like a broken one. # $global:LASTEXITCODE is cleared first because an exe that cannot start at # all throws without setting one, and the stale 0 from the previous native # command would otherwise read as success. $SshExe = Join-Path $RsyncDir 'ssh.exe' if ($WantSsh -and (Test-Path $SshExe)) { $prevEap = $ErrorActionPreference $ErrorActionPreference = 'Continue' $global:LASTEXITCODE = $null $sshVer = $null try { $sshVer = (& $SshExe -V 2>&1 | Select-Object -First 1) } catch { } finally { $ErrorActionPreference = $prevEap } if ($LASTEXITCODE -eq 0) { Write-Host " ssh.exe runs: $sshVer" } else { $why = if ($null -eq $LASTEXITCODE) { 'it would not start' } else { "exit $LASTEXITCODE" } Write-Warning "The release's ssh.exe does not run here ($why)$(if ($sshVer) { ": $sshVer" }). Removing it so rsync.exe falls back to the ssh on the PATH instead of failing on it." Remove-Item $SshExe -Force -ErrorAction SilentlyContinue $WantSsh = $false } } Write-Host " Installed $RsyncExe$(if ($WantSsh) { ' and the ssh.exe it runs' })" # Machine PATH (HKLM environment). Idempotent: only appends if absent. $m = [Environment]::GetEnvironmentVariable('Path', 'Machine') if (-not $m) { $m = '' } if (($m -split ';') -notcontains $RsyncDir) { $new = if ($m.Trim()) { $m.TrimEnd(';') + ';' + $RsyncDir } else { $RsyncDir } [Environment]::SetEnvironmentVariable('Path', $new, 'Machine') Write-Host " Added $RsyncDir to the machine PATH (restart shells / sshd to pick it up)." # sshd caches the environment it was started with, so an already-running # service would not see the new PATH until restarted. if ((Get-Service sshd -ErrorAction SilentlyContinue).Status -eq 'Running') { Restart-Service sshd Write-Host ' Restarted sshd so it inherits the updated machine PATH.' } } else { Write-Host " OK: $RsyncDir already in the machine PATH" } & $RsyncExe --version | Select-Object -First 1 } catch { Write-Warning "rsync install failed: $($_.Exception.Message)" Write-Warning "Download $RsyncAsset from https://github.com/$RsyncRepo/releases manually" Write-Warning "and unpack it into $RsyncDir, keeping rsync.exe and ssh.exe together." } # --------------------------------------------------------------------------- # Visual Studio 2022 Community # --------------------------------------------------------------------------- $TempDir = Join-Path $env:TEMP 'dev_install' New-Item -ItemType Directory -Force -Path $TempDir | Out-Null # Component IDs split into independent groups so each can be installed in its # own pass. The base group is the known-good set; Clang and the Windows XP # toolset are layered on afterwards so a failure clearly identifies the culprit. # Component reference: https://learn.microsoft.com/visualstudio/install/workload-component-id-vs-community $BaseComponents = @( # Core C++ desktop workload 'Microsoft.VisualStudio.Workload.NativeDesktop' # MSVC build tools. Named explicitly rather than left to --includeRecommended, # because what that pulls in depends on the host: on an ARM64 machine the # workload's recommended set is the ARM64-hosted toolchain targeting ARM64, # and the x64 cross-compiler is NOT implied. Ask for both and the box builds # every target it can, whichever architecture it is: # on x64 -> x64-hosted, targeting x86/x64 and ARM64 # on ARM64 -> ARM64-hosted, targeting ARM64 and x86/x64 # Both are native toolchains; neither cross-compile runs under emulation. 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64' 'Microsoft.VisualStudio.Component.VC.Tools.ARM64' # Spectre-mitigated MSVC runtime libs, for each target above 'Microsoft.VisualStudio.Component.VC.Runtimes.x86.x64.Spectre' 'Microsoft.VisualStudio.Component.VC.Runtimes.ARM64.Spectre' # Spectre-mitigated ATL (needed for many driver/COM projects). The x86/x64 and # ARM64 ATL libraries are separate components; a driver or COM project built # for ARM64 wants the second one, and it is not implied by the first. 'Microsoft.VisualStudio.Component.VC.ATL.Spectre' 'Microsoft.VisualStudio.Component.VC.ATL.ARM64.Spectre' # Windows 11 SDK — build number must match the WDK below 'Microsoft.VisualStudio.Component.Windows11SDK.26100' # WDK Visual Studio extension (VSIX). The silent wdksetup.exe /quiet does NOT # install this (it only prompts interactively), so it must be added here. 'Component.Microsoft.Windows.DriverKit' ) # Clang/LLVM toolset (ClangCL, used in CMakePresets.json). Two parts: the Clang # compiler itself, plus the MSBuild integration providing the "ClangCL" toolset. # # Installed on every architecture, ARM64 included, and native there - not an # emulated x64 compiler. Two things establish that: the VSIX is # productArch=neutral with no chip/machineArch restriction, so the Arm installer # offers it; and MSVC's VC\Tools\Llvm tree is partitioned by HOST architecture # (bin = x86, x64\bin = x64, ARM64\bin = ARM64) with genuine ARM64 binaries # already in ARM64\bin, which is where clang-cl.exe lands. Unlike the v141/XP # group below, nothing technical is in the way. # # This is the compiler diversity the box is after: MSVC and clang-cl over the # same sources, both native. $ClangComponents = @( 'Microsoft.VisualStudio.Component.VC.Llvm.Clang' 'Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset' ) # Windows XP targeting (v141_xp toolset, used in CMakePresets.json). The v141 # (VS2017) build tools provide the 14.16 compiler that the XP toolset wraps; # WinXP layers the XP-compatible CRT/SDK on top of it. # # NOT USED ON ARM64, and left empty there. The components are still listed in the # catalog on Arm, so this is not strictly "unavailable" - but the 14.16 toolset # predates Windows on Arm as a host and ships HostX86/HostX64 compilers only, so # the best you could get is an x86-emulated compiler, and Microsoft does not # support XP targeting from an Arm host. Nothing is lost that this box could have # used: Windows XP never ran on ARM64, so an XP-targeting build from an ARM64 # host has no purpose beyond producing x86 binaries, which the current toolset # does natively via -A Win32. On x64 the group is installed exactly as before. $XpComponents = if ($IsArm64) { @() } else { @( 'Microsoft.VisualStudio.Component.VC.v141.x86.x64' 'Microsoft.VisualStudio.Component.WinXP' ) } # Which Visual Studio generation to drive: 17 = VS 2022, 18 = VS 2026. Both have # native ARM64 installers and ARM64-hosted MSVC. This picks the bootstrapper URL, # and - just as importantly - scopes the vswhere lookup below, so a box that # already has a DIFFERENT generation installed is not mistaken for this one. # # Split by architecture on purpose: # x64 -> 17. The v141 / Windows XP targeting toolset in $XpComponents is the # reason; that group is the whole point of pinning a generation here. # ARM64 -> 18. The XP group is skipped on Arm regardless (no ARM64-hosted 14.16 # compiler), so nothing holds this back to 17, and VS 2026 brings the # newer MSVC. Together with the native ARM64 clang-cl from # $ClangComponents above, that is the compiler diversity on this box. $VsChannel = if ($IsArm64) { 18 } else { 17 } $VsEdition = 'community' # community | professional | enterprise # aka.ms path segment per generation. NOT the same word for both: VS 2022 is # published under /release/, VS 2026 under /stable/. This is not cosmetic - # https://aka.ms/vs/18/release/vs_community.exe is not a 404, it silently # redirects to Bing and returns 200 with an HTML body, so a wrong guess here # downloads a web page, names it vs_community.exe, and fails at Start-Process # with something that looks nothing like a bad URL. $VsChannelPath = if ($VsChannel -ge 18) { 'stable' } else { 'release' } # Detect an existing VS install via vswhere (ships with the VS Installer). Note # that vswhere itself lives under the 32-bit Program Files on every architecture, # ARM64 included - the VS Installer is x86-registered there by contract even # though the installer binaries themselves are native. # These are referenced by Invoke-VsModify via $script: scope. $VsWhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' $InstallPath = Get-VsInstallPath # Report any OTHER Visual Studio generations on the box. They are left alone - # the passes below only ever touch $InstallPath - but when none of them matches # $VsChannel this script is about to download and install a second, largely # redundant toolchain, and that should be a visible decision rather than a # surprise 10GB. (On ARM64, where $VsChannel is 18, an existing VS 2026 IS the # match and gets modified in place rather than duplicated.) if (Test-Path $VsWhere) { $others = & $VsWhere -products '*' -format value -property installationPath | Where-Object { $_ -and $_ -ne $InstallPath } if ($others) { Write-Step 'Other Visual Studio installations detected' foreach ($o in $others) { Write-Host " $o" -ForegroundColor Yellow } Write-Host " Not modified. This script drives VS generation $VsChannel only." -ForegroundColor Yellow Write-Host " To use one of the above instead, set `$VsChannel at the top of this step." -ForegroundColor Yellow } } Write-Step "Downloading VS $VsChannel $VsEdition bootstrapper (host: $HostArch)" # aka.ms serves the bootstrapper for the requesting machine's architecture, so on # ARM64 this is the native ARM64 installer - no --arch flag needed or offered. $VsInstallerUrl = "https://aka.ms/vs/$VsChannel/$VsChannelPath/vs_$VsEdition.exe" $VsBootstrapper = Join-Path $TempDir "vs_$VsEdition.exe" Write-Host " $VsInstallerUrl" -ForegroundColor DarkGray Invoke-WebRequest -Uri $VsInstallerUrl -OutFile $VsBootstrapper -UseBasicParsing # Prove we got an installer and not a web page. The Bing redirect described above # returns 200 with HTML, and every other aka.ms typo behaves the same way, so a # bad channel/edition combination is otherwise only discovered when the "exe" # fails to start. 'MZ' is the DOS header every PE begins with. $vsHead = [IO.File]::ReadAllBytes($VsBootstrapper) | Select-Object -First 2 if (-not ($vsHead.Count -eq 2 -and $vsHead[0] -eq 0x4D -and $vsHead[1] -eq 0x5A)) { throw "Visual Studio: $VsInstallerUrl did not return an executable (no MZ header; $((Get-Item $VsBootstrapper).Length) bytes). Check `$VsChannel / `$VsChannelPath / `$VsEdition." } Write-Host " OK: bootstrapper is a PE ($([math]::Round((Get-Item $VsBootstrapper).Length / 1MB, 2)) MB)" # Install in three sequential passes. The base set is installed first (this is # the configuration that previously worked); Clang and the XP toolset are added # afterwards. If one fails, its label pinpoints which group is responsible. # # The last two are -Optional: neither the Clang toolset nor XP targeting is # needed to build with MSVC, and on a host where one of them is simply not # offered a hard failure here would cost you the whole toolchain over a component # you can add later from the installer UI. Invoke-VsModify -Label 'base toolset + workload' -Ids $BaseComponents Invoke-VsModify -Label 'Clang / LLVM' -Ids $ClangComponents -Optional if ($IsArm64) { Write-Step 'Visual Studio: Windows XP (v141 + WinXP)' Write-Host ' Skipped on ARM64: the v141 (14.16) toolset ships x86/x64-hosted compilers only,' -ForegroundColor Yellow Write-Host ' and Windows XP targeting is not offered for Arm hosts. Build x86 with the' -ForegroundColor Yellow Write-Host ' current toolset instead (cmake -A Win32), which is native here.' -ForegroundColor Yellow } else { Invoke-VsModify -Label 'Windows XP (v141 + WinXP)' -Ids $XpComponents -Optional } # --------------------------------------------------------------------------- # Verify what actually landed, on disk. Earlier runs silently skipped the v141 # toolset and the failure only surfaced at build time, so check here and say so # loudly instead. Widened from that one check to every toolset worth naming, # because the same "installed something, but not the thing you needed" failure is # now possible per host architecture: this reports which MSVC host toolchains are # present (HostARM64 is what proves the compiler is native rather than emulated), # whether clang-cl is there, and - on x64 only - whether v141 is. # # Reporting, not throwing. A missing optional component is something to fix from # the installer UI, not a reason to fail a provisioning run that installed a # working compiler. # --------------------------------------------------------------------------- Write-Step 'Verifying the installed toolsets' $InstallPath = Get-VsInstallPath if (-not $InstallPath) { Write-Warning "No Visual Studio $VsChannel installation found after the passes above; cannot verify toolsets." } else { Write-Host " Install path: $InstallPath" # What MSVC versions landed, and which host toolchains each one carries. # HostARM64 is the directory that proves the native ARM64 compiler is here # rather than an x64 one that would run under emulation. $msvcRoot = Join-Path $InstallPath 'VC\Tools\MSVC' foreach ($v in (Get-ChildItem $msvcRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) { $hosts = Get-ChildItem (Join-Path $v.FullName 'bin') -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.Name } Write-Host " MSVC $($v.Name): $(if ($hosts) { $hosts -join ', ' } else { '(no bin dir)' })" } if ($IsArm64) { $armHost = Test-Path (Join-Path $msvcRoot '*\bin\HostARM64\ARM64\cl.exe') if ($armHost) { Write-Host ' OK: native ARM64-hosted cl.exe present.' -ForegroundColor Green } else { Write-Warning 'No HostARM64 cl.exe found - MSVC would run under x64 emulation. Add "MSVC v14x - VS 2022 C++ ARM64/ARM64EC build tools" in the installer.' } } # clang-cl, for the ClangCL toolset in CMakePresets.json. Checked per host # directory, because the Llvm tree is partitioned by HOST architecture and # only the matching one is a native compiler. # # Look for clang-cl.exe specifically, NOT for the directory. VC\Tools\Llvm\*\bin # holds clang-format.exe and clang-tidy.exe on every host whether or not the # Clang component was ever installed - those ship with the NativeDesktop # workload - so a present ARM64\bin proves nothing on its own. That is the # false positive to avoid when checking this by hand. $llvmRoot = Join-Path $InstallPath 'VC\Tools\Llvm' $clangArm = Test-Path (Join-Path $llvmRoot 'ARM64\bin\clang-cl.exe') $clangX64 = Test-Path (Join-Path $llvmRoot 'x64\bin\clang-cl.exe') $clangX86 = Test-Path (Join-Path $llvmRoot 'bin\clang-cl.exe') if ($clangArm -or $clangX64 -or $clangX86) { Write-Host " clang-cl: $(@(if ($clangArm) {'ARM64'}; if ($clangX64) {'x64'}; if ($clangX86) {'x86'}) -join ', ')" -ForegroundColor Green # On ARM64 the x64 build would still run, under emulation - so say plainly # whether the NATIVE one is the one that landed. if ($IsArm64 -and -not $clangArm) { Write-Warning 'No ARM64-hosted clang-cl - the x64 one would run under emulation. Re-run the Clang/LLVM pass, or add "C++ Clang tools for Windows" in the installer.' } } else { Write-Warning 'clang-cl not found - the ClangCL presets will fail. Add the "C++ Clang tools for Windows" component.' } # v141 / XP. Only meaningful where the toolset can exist at all; on ARM64 the # group above was deliberately skipped, so warning here would be noise about # a decision this script made on purpose two steps ago. if ($IsArm64) { Write-Host ' v141 / Windows XP toolset: n/a on ARM64 (not offered for Arm hosts).' -ForegroundColor DarkGray } else { $V141 = Get-ChildItem $msvcRoot -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -like '14.16.*' } | Select-Object -First 1 if ($V141) { Write-Host " OK: v141 toolset present ($($V141.Name))" -ForegroundColor Green } else { Write-Warning 'v141 (14.16.x) toolset NOT found - the XP build presets will fail.' Write-Warning 'Add it via Visual Studio Installer > Modify > Individual components:' Write-Warning ' - MSVC v141 - VS 2017 C++ x64/x86 build tools (v14.16)' Write-Warning ' - C++ Windows XP Support for VS 2017 (v141) tools' } } } # --------------------------------------------------------------------------- # Windows Driver Kit (WDK 10.0.26100) # Build 26100 matches the Windows 11 SDK installed above. # Provides IddCx (iddcx.h / iddcx.lib) and UMDF 2.x for Indirect Display Drivers. # linkid=2335869 -> WDK 26100.6584 (per Microsoft "Other WDK Downloads"). # --------------------------------------------------------------------------- $WdkVersion = '10.0.26100' # Both hives. The WDK installer is a 32-bit program, so on x64 it writes under # WOW6432Node - but which hive a given kit lands in has varied across kit # versions and architectures, and reading only one of them makes an installed WDK # look absent, which costs a needless multi-GB reinstall on every run. Check the # native hive too and take whichever answers. $WdkInstalledRoot = @( 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows Kits\Installed Roots' 'HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots' ) | ForEach-Object { (Get-ItemProperty $_ -ErrorAction SilentlyContinue).WdkBinRootVersioned } | Where-Object { $_ } | Select-Object -First 1 if ($WdkInstalledRoot -and $WdkInstalledRoot -match [regex]::Escape($WdkVersion)) { # Re-running wdksetup.exe for an already-present version returns exit code # 2008 (maintenance mode / nothing to do), which is not a real failure. Write-Step "WDK $WdkVersion already installed - skipping ($WdkInstalledRoot)" } else { Write-Step 'Downloading WDK installer' $WdkUrl = 'https://go.microsoft.com/fwlink/?linkid=2335869' $WdkInstaller = Join-Path $TempDir 'wdksetup.exe' Invoke-WebRequest -Uri $WdkUrl -OutFile $WdkInstaller -UseBasicParsing Write-Step 'Installing WDK' # wdksetup.exe is a 32-bit binary and runs under emulation on ARM64; the kit # it lays down does include the ARM64 target headers, libs and tools (the # signing/deployment tools under bin\arm64), so an ARM64 driver builds from # an ARM64 host. Only the installer is emulated, not the toolchain. $proc = Start-Process -FilePath $WdkInstaller -ArgumentList '/quiet /norestart' -Wait -PassThru -NoNewWindow Write-Host " WDK installer exit code: $($proc.ExitCode)" if ($proc.ExitCode -eq 2008) { # 2008 = the WDK is already present; the installer has nothing to do. Write-Host ' [WDK already installed (exit 2008) - treating as success]' -ForegroundColor Yellow } else { Assert-ExitCode $proc.ExitCode 'WDK' } } # --------------------------------------------------------------------------- # Windows Performance Toolkit: xperf, wpr, and Windows Performance Analyzer # (wpa.exe) -- ETW CPU + loader profiling and the GUI that reads the traces. # # WPA is NOT a Visual Studio component and has no relationship to VS's own # Performance Profiler (a separate, .diagsession-based tool that cannot open an # .etl). It ships in exactly two places: as an optional FEATURE of the Windows # SDK ("Windows Performance Toolkit", OptionId.WindowsPerformanceToolkit), and # in the Windows ADK, which bundles the same toolkit. Whether the SDK install # that Visual Studio performs happens to select that feature varies with the VS # and SDK version - when it does, WPT lands in # %ProgramFiles(x86)%\Windows Kits\10\Windows Performance Toolkit and the SDK # puts that directory on the machine PATH itself - so this step DETECTS first # and only falls back to installing the ADK (winget owns the versioned download # URL, which makes it the reliable source) when nothing is there. That fallback # is a large download; to install just the toolkit instead, run the standalone # SDK setup with # winsdksetup.exe /features OptionId.WindowsPerformanceToolkit /q # # There is also a newer WPA in the Microsoft Store (`winget install --id # 9N0W1B2BXGNZ --source msstore`), which updates independently of the SDK. It is # not installed here: the Store package needs an interactive, signed-in session, # which is exactly what this elevated, unattended half does not have. # # Idempotent and non-fatal - it never aborts provisioning. # --------------------------------------------------------------------------- Write-Step 'Windows Performance Toolkit (xperf / wpr / WPA)' $WptDirs = @( (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Windows Performance Toolkit'), (Join-Path $env:ProgramFiles 'Windows Kits\10\Windows Performance Toolkit'), (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Windows Performance Toolkit') ) function Find-WptDir { $script:WptDirs | Where-Object { Test-Path (Join-Path $_ 'xperf.exe') } | Select-Object -First 1 } $WptDir = Find-WptDir if ($WptDir) { Write-Host " OK: WPT already present ($WptDir)" -ForegroundColor Green } else { try { # The ADK manifest offers no ARM64 installer, so on ARM64 winget fetches # the x64 one; it runs under emulation and lays down a toolkit that does # include the ARM64 binaries. The SDK feature is the lighter route on any # architecture and is worth preferring if this fallback ever gives # trouble - see the winsdksetup.exe line in the comment above. if ($IsArm64) { Write-Host ' ARM64: the ADK installer is x64 (emulated); the toolkit it installs is ARM64.' -ForegroundColor Yellow } winget install --id Microsoft.WindowsADK --exact --silent --disable-interactivity ` --accept-source-agreements --accept-package-agreements Write-Host ' Windows ADK (includes Windows Performance Toolkit) installed.' $WptDir = Find-WptDir } catch { Write-Warning "WPT install failed: $($_.Exception.Message)" Write-Warning 'Install manually: winget install Microsoft.WindowsADK, or add the' Write-Warning 'Windows SDK "Windows Performance Toolkit" optional feature.' } } if ($WptDir) { # Report what actually landed. wpa.exe is the piece people come looking for # and it is the one that is absent if a trimmed toolkit ever shows up. foreach ($tool in 'xperf.exe', 'wpr.exe', 'wpa.exe', 'wpaexporter.exe') { $p = Join-Path $WptDir $tool if (Test-Path $p) { Write-Host " $tool $((Get-Item $p).VersionInfo.ProductVersion)" } else { Write-Warning "$tool is missing from $WptDir" } } # The WPT installer normally adds this to the machine PATH itself (and the # Start Menu gets "Windows Kits > Windows Performance Toolkit" shortcuts for # WPA and WPR). Re-assert it anyway: on the machine PATH rather than a user # one so it also resolves for the non-interactive sshd sessions this box is # driven through, which build their environment from the registry PATH. # Compared trailing-backslash-insensitively - the installer's own entry has # one, and adding a second spelling of the same directory is just noise. $m = [Environment]::GetEnvironmentVariable('Path', 'Machine') if (-not $m) { $m = '' } $have = ($m -split ';') | Where-Object { $_.TrimEnd('\') -eq $WptDir.TrimEnd('\') } if ($have) { Write-Host " OK: $WptDir already in the machine PATH" } else { $new = if ($m.Trim()) { $m.TrimEnd(';') + ';' + $WptDir } else { $WptDir } [Environment]::SetEnvironmentVariable('Path', $new, 'Machine') Write-Host " Added $WptDir to the machine PATH (restart shells to pick it up)." } } # --------------------------------------------------------------------------- # Intel VTune Profiler - reported, not installed # # Deliberately NOT automated, unlike everything above. The offline installer is # a ~750 MB download from a URL carrying a per-release GUID # (registrationcenter-download.intel.com/akdlm/IRC_NAS//intel-vtune-_offline.exe) # with no "latest" redirect behind it, so every new build means editing a # hard-coded link in here - and it is only worth having on Intel silicon, since # hardware event-based sampling reads Intel PMU counters. Not a good trade for a # script that has to keep working unattended on any box. # # So this step only reports. To install it, take the Windows offline installer # from # https://www.intel.com/content/www/us/en/developer/tools/oneapi/vtune-profiler-download.html # and run it elevated; it installs unattended with # intel-vtune-_offline.exe -a --silent --cli --eula accept # --------------------------------------------------------------------------- Write-Step 'Intel VTune Profiler (status only)' if ($IsArm64) { # Not a "not installed yet" case - there is no Windows-on-Arm build of VTune, # and there is nothing for it to sample: its whole value is reading Intel PMU # counters. Say so plainly and point at what does work here, rather than # printing a download link for a product this box cannot run. Write-Host ' n/a on ARM64: Intel ships no Windows-on-Arm build, and hardware event-based' -ForegroundColor DarkGray Write-Host ' sampling reads Intel PMU counters. Use the Windows Performance Toolkit above' -ForegroundColor DarkGray Write-Host ' (wpr / xperf to collect, wpa to analyse) for profiling on this box.' -ForegroundColor DarkGray Write-Host ' Arm also publishes Arm Performance Studio / Streamline for Arm PMU sampling.' -ForegroundColor DarkGray } else { $UninstallKeys = @( 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' ) $vtune = Get-ItemProperty $UninstallKeys -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -match 'VTune' } | Select-Object -First 1 if ($vtune) { Write-Host " Installed: $($vtune.DisplayName.Trim()) $($vtune.DisplayVersion)" -ForegroundColor Green # The oneAPI layout keeps a `latest` junction beside the versioned directory, # so this path stays right across upgrades. $VTuneCli = Join-Path $vtune.InstallLocation 'vtune\latest\bin64\vtune.exe' if (Test-Path $VTuneCli) { Write-Host " CLI: $VTuneCli" } } else { Write-Host ' Not installed.' -ForegroundColor Yellow Write-Host ' https://www.intel.com/content/www/us/en/developer/tools/oneapi/vtune-profiler-download.html' -ForegroundColor Yellow $cpu = (Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1).Manufacturer if ($cpu -and $cpu -notmatch 'Intel') { Write-Host " (This CPU reports itself as '$cpu' - VTune's hardware event-based sampling wants Intel silicon.)" -ForegroundColor Yellow } } } # --------------------------------------------------------------------------- Write-Host "`nAll done." -ForegroundColor Green Write-Host "Host architecture was $HostArch." Write-Host 'If a reboot was flagged above, restart before opening VS or building drivers.' } catch { $ExitCode = 1 Write-Host "`n==> SETUP FAILED: $($_.Exception.Message)" -ForegroundColor Red if ($_.ScriptStackTrace) { Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray } # Only fold in the VS Installer logs when a VS step actually failed; for other # steps (e.g. WDK) those logs are stale and misleading, so the message above # is what matters. if ($_.Exception.Message -match 'Visual Studio') { try { Show-VsSetupLogs } catch {} } } finally { try { Stop-Transcript | Out-Null } catch {} # This log was created by the elevated (admin) process, so by default the # non-elevated caller can't delete it (their token has Administrators marked # deny-only). Grant BUILTIN\Users Modify rights so the user account that runs # setup-windows.bat can remove the log later. S-1-5-32-545 is the well-known # Users SID, used here so this is locale-independent. try { if (Test-Path $LogFile) { $usersSid = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-545') $acl = Get-Acl -Path $LogFile $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( $usersSid, 'Modify', 'Allow') $acl.AddAccessRule($rule) Set-Acl -Path $LogFile -AclObject $acl } } catch { Write-Host " [warning] could not relax ACL on $LogFile : $($_.Exception.Message)" -ForegroundColor Yellow } } exit $ExitCode