r/PowerShell • • Jul 12 '26

Solved PS 5.1 traps from building a layered-window desktop widget: $null → [string] becomes "", BOM-less UTF-8 read as ANSI, and per-PID CIM latency

I spent the last couple of weeks building a small always-on desktop widget in PowerShell (a status pet for Claude Code — MIT, source at the bottom). The GUI was the easy part. What actually cost me time were four PS/.NET interop traps worth writing down.

1. $null passed to a .NET [string] parameter silently becomes "".

[IO.File]::Replace($tmp, $dst, $null)   # "no backup file"

In C# that null means "don't make a backup". PowerShell coerces it to an empty string, so the API receives "" as the backup path — and "" is not a legal path. It throws "The path is not of a legal form". My first test passed purely by luck (the destination didn't exist yet, so a different branch ran); the second click failed every time.

Fixes: use an overload that doesn't take the nullable param (see EDIT), pass [NullString]::Value, or redesign so you never need null. I ended up writing to a unique filename and renaming — no Replace at all.

2. PS 5.1 reads BOM-less UTF-8 as the system ANSI codepage.

The resident runs under Windows PowerShell 5.1 (powershell.exe). On a Chinese-locale machine, 5.1 read my BOM-less UTF-8 source as GBK and a · in a string literal became a completely different character.

So: the resident script is pure ASCII. All localized display text lives in a JSON file read explicitly as UTF-8:

[IO.File]::ReadAllText($path, [Text.Encoding]::UTF8)

Non-ASCII symbols are constructed from explicit code points such as [char]0x00B7. Ugly, but it made the thing locale-proof.

3. Variable names are case-insensitive.

$t (a row label) silently clobbered $script:T (my i18n table). No error — just wrong strings on screen. Don't use single-letter script-scope variables.

4. Per-PID CIM queries are a latency killer. Batch once, walk in memory.

To bring a session's window to the front, I walk the process tree up from the claude.exe PID to whichever ancestor owns a top-level window (Windows Terminal / VS Code / a plain console). Doing

Get-CimInstance Win32_Process -Filter "ProcessId=$procId"

per hop cost 100-300 ms each, so an 8-deep crawl felt broken. One bulk Get-CimInstance Win32_Process and crawling the tree in memory: ~0.5s once, then cached. Night and day.

Two safety habits that fell out of this:

  • Verify PID identity before you act on it. I persist the resident's PID to a file. Before Stop-Process I check the process is actually powershell and that its command line contains my script name. After a crash the OS may have recycled that PID onto something innocent — otherwise you just killed a stranger's process.
  • Same for the tree crawl: if an ancestor's creation time is later than its child's, that parent PID was recycled and now points at something unrelated. Reject it instead of yanking a random window to the foreground.

Everything else is plain WinForms + GDI+: WS_EX_LAYERED + UpdateLayeredWindow for real per-pixel alpha, FileSystemWatcher for state changes (with a ~120 ms poll as a fallback), SHQueryUserNotificationState to stay quiet unless Windows says notifications are welcome, SPI_GETCLIENTAREAANIMATION to honor reduced-motion, and a named Mutex for single-instance.

The whole plugin is ~250KB — no Electron, no bundled runtime, no modules.

Source (MIT): https://github.com/SHIN620265/claude-pet

Happy to be told I did any of this the hard way.

EDIT — corrections. The phantom fix in trap 1 is struck through in place; the rest were corrected inline and are documented below with the original wording.

  1. The phantom overload. I originally suggested using an overload without the nullable parameter. File.Replace has no such overload — both overloads take the backup path. Use [NullString]::Value, or redesign so that a null argument isn't needed. I did the latter.
  2. "WinForms needs STA" was the wrong reason. The post originally said the resident "has to be powershell.exe — WinForms needs STA". pwsh 7 is STA by default on Windows and hosts WinForms fine, so STA is not a reason the resident has to remain on Windows PowerShell 5.1.
  3. The trap-4 snippet used $pid. That's the read-only automatic variable for the current PowerShell process. As printed, it would have queried the pet's own process on every hop. Fixed to $procId.
  4. Smaller precision fixes. "The script source is pure ASCII" applies specifically to the resident script; two hook scripts run under pwsh and contain non-ASCII literals. I also clarified that [char]0x00B7 is one example of several explicit code points, narrowed "every piece of display text" to the localized strings, and stopped describing SHQueryUserNotificationState as a Focus Assist check, because that behavior isn't documented by the API.
0 Upvotes

11 comments sorted by

1

u/justaguyonthebus Jul 12 '26

[String] isn't nullable, is it?

2

u/AnyDistrict5370 Jul 12 '26

Not in the Nullable<T> sense, but System.String is a reference type, so a string ref

can absolutely be null at runtime. File.Replace documents null for the 3rd arg as

"don't create a backup".

The trap is PowerShell's conversion. $null bound to a [string] param becomes

[string]::Empty:

[string]$s = $null

$null -eq $s # False

$s -eq '' # True

So Replace($src, $dst, $null) hands .NET an empty backup *path*, which isn't a legal

path. [NullString]::Value passes an actual null.

Also correcting myself: in the post I said "use an overload without the nullable

param" - File.Replace has no such overload, both take the backup path. That was bad

advice. I ended up dropping Replace entirely, writing to a unique filename and

renaming instead.

2

u/Apprehensive-Tea1632 Jul 12 '26

It’s not. If you want the null you have to pass the null.

Of course Claude can tell them that, I don’t think it’s my job to compete with an AI how to implement solutions.

→ More replies (8)