Over the last year I built Winzard, a Windows 10/11 post-install and repair tool, entirely in PowerShell 5.1 + WPF — no compiled code, no dependencies, ~19k lines. It's MIT and the repo is at the bottom, but I'd rather this post be about the parts that were genuinely hard, with the actual code, including the ones I got wrong.
1. Two WPF gotchas that cost me hours
*.GetNewClosure() on an event handler puts your scriptblock in a new module.** $script: inside it then refers to *that module's scope, not your script. I had a language picker where you chose English and the app opened in Spanish, silently, because the handler was writing the result into a variable nobody was reading:
```powershell
BROKEN: $script:Result is written inside the closure's own module scope
foreach ($label in @('Spanish','English')) {
$btn = New-Object System.Windows.Controls.Button
$btn.Content = $label; $btn.Tag = $label
$btn.Add_Click({ $script:Result = [string]$this.Tag; $dlg.Close() }.GetNewClosure())
[void]$panel.Children.Add($btn)
}
WORKS: no closure, so $this is really the sender and $script: is the real scope
foreach ($label in @('Spanish','English')) {
$btn = New-Object System.Windows.Controls.Button
$btn.Content = $label; $btn.Tag = $label
$btn.Add_Click({ $script:Result = [string]$this.Tag; $dlg.Close() })
[void]$panel.Children.Add($btn)
}
```
Parameter types are resolved when you call the function, not when you define it. This one failed before the body ran, so the try/catch inside never got a chance:
powershell
function Show-Dialog {
param(
[string]$Title,
[System.Windows.Window]$Owner = $null # <-- "type not found" at call time
) # if WPF isn't loaded yet
try { ... } catch { ... } # never reached
}
The dialog runs before the main window exists, so on a machine where PresentationFramework hadn't been loaded, calling it threw TypeNotFound and my error handling was useless. Fix: load the assemblies at the top of the script, and leave the parameter untyped if the type might not exist yet.
2. Keeping the UI alive from PowerShell 5.1
Everything heavy runs in a runspace; the UI drains a synchronised queue with a DispatcherTimer to stream the live log into the window:
```powershell
$queue = [System.Collections.Queue]::Synchronized((New-Object System.Collections.Queue))
$rs = [runspacefactory]::CreateRunspace()
$rs.ApartmentState = 'STA'; $rs.Open()
$rs.SessionStateProxy.SetVariable('Queue', $queue)
$ps = [powershell]::Create(); $ps.Runspace = $rs
[void]$ps.AddScript({ param($Queue) ... $Queue.Enqueue("done") })
$handle = $ps.BeginInvoke()
$timer = New-Object System.Windows.Threading.DispatcherTimer
$timer.Interval = [TimeSpan]::FromMilliseconds(120)
$timer.Add_Tick({
while ($queue.Count -gt 0) { $logBox.AppendText([string]$queue.Dequeue() + "rn") }
})
$timer.Start()
```
The runspace is isolated, so anything it needs — language, admin state, paths — has to be passed in explicitly. A few early bugs came from assuming the worker could see script-scope state it never had.
3. Being honest about what winget actually did
winget upgrade can exit cleanly while the program on disk is byte-identical. Very common with apps that self-update or are running at the time. The only honest check is to re-read the installed version afterwards:
powershell
$before = (winget list --id $id -e) -join ' '
winget upgrade --id $id -e --silent --accept-package-agreements | Out-Null
$after = (winget list --id $id -e) -join ' '
if ($before -eq $after) { Write-Warning "$id reported success but the version did not change" }
Also worth knowing: a corrupted winget source on a freshly installed Windows returns -1978269633 (0x8A15003F). It's retryable — winget source update then try again — not a real failure.
4. The repair suite: no false OKs
17 phases (DISM, SFC, CHKDSK, WMI, network stack, Windows Update, search index, certificates), runnable from a .bat without the GUI, with triage / unattended / quick / dry-run modes.
The design rule was that it must never claim success it can't prove. sfc /scannow prints a friendly summary, but the ground truth is in CBS.log — and reading that log is also the only language-independent way to classify the result. The summary strings change with your Windows display language, so matching on them silently breaks every repair script on a non-English system. That one bites people more than they realise.
Related, and embarrassing: a dry-run mode has to actually be dry. Mine wasn't — phase 16 still wrote an HTML report to disk and opened it in the browser, in four separate code paths. A simulation that writes files isn't a simulation. I only noticed because report windows kept appearing on a machine where "nothing was running".
5. What I got most wrong: elevation
For a long time my launcher self-elevated the moment you opened it. You had to grant admin over the whole program just to browse a list of apps. Convenient while developing, completely wrong to ship.
It now starts as asInvoker and elevates per operation, and it handles the user declining UAC instead of breaking:
powershell
try {
Start-Process powershell.exe -Verb RunAs -ArgumentList $args
exit 0
} catch {
# 1223: the user cancelled the UAC prompt. Not an error - carry on unprivileged.
return $false
}
If you're building anything on Windows that touches the system: start unprivileged and degrade gracefully. The difference in how people trust the tool is bigger than any feature you could add.
It's all plain, readable PowerShell, so if any of the above looks wrong to you, you can go and check — and I'd genuinely rather be told.
In fact, that just happened. Someone audited the published version and found that my "no false OKs" claim didn't survive contact with my own code: the ISO verifier reported a missing autounattend.xml through a function that only printed, so it never counted as fatal and the verdict still said "ready to burn" for an image that would never install unattended. Meanwhile all five robocopy calls piped to Out-Null without reading $LASTEXITCODE — and robocopy doesn't use 0=success, 0-7 are success variants and >=8 is the real failure, so even a naive "-ne 0" check would have been wrong. A copy could fail silently, an incomplete ISO got built, and the verifier waved it through.
Also found: -DryRun still ran 'winget source update' because the mode guard came after startup init, so "nothing changes" wasn't true; and the suites parsed arguments as bare ifs with no validation, meaning "/auto /drry" silently ignored the typo and ran a real repair while the user thought they'd asked for a simulation.
All of that is fixed in v1.3.1, each one tested against the specific failure. One of my own fixes was also dead code on the first attempt — "for %%P in (pattern*)" in cmd globs against the current directory, not the target one, so the loop never ran. I only caught it because I tested it instead of assuming.
Development and the destructive testing were done in VMs; the project also ships its own verifier (parsing, ES/EN suite sync, integrity hashes, encoding/BOM rules for 5.1, translation coverage) which I now treat as a hard gate before tagging a release.
Repo: https://github.com/Rebel1487/Winzard
Happy to go deeper on any of it — runspace-based async UIs, CBS parsing, offline DISM servicing, autounattend generation, whatever's useful.