Skip to content

Windows 11 (nex-pc dual-boot)

Date: 2026-08-07 Hostname: DESKTOP-BO661M2 IP address: 192.168.0.100 (Ethernet, same lease as Nobara - see below) Tailscale IP: 100.80.75.55 (nex-windows) User: Nex SSH access from LXC 109: ssh winpc


Overview

Property Value
OS Windows 11 Pro (build 26100)
Hardware shared with Nobara - Ryzen 7 3700X, RTX 2060 SUPER, 32 GB
System disk nvme1n1p2 (465 GB NTFS), mounted as /mnt/nvme when Nobara is running
SSH in-box OpenSSH 9.5p1 (client + server)
Shell PowerShell 7.4.6, Windows Terminal
Remote access Tailscale node nex-windows

This is the same physical machine as the Nobara PC, dual-booting from a second NVMe. It takes the same DHCP lease, so 192.168.0.100 is Nobara or Windows depending on what is booted - never both. The boot layout and its repair history are documented in Nobara PC → Dual-boot with Windows 11.

The SSH config on LXC 109 therefore has two aliases pointing at one address:

Host nobara      # expect Linux
Host winpc       # expect Windows

Both machines' host keys live side by side in known_hosts, so switching between them does not trigger the REMOTE HOST IDENTIFICATION HAS CHANGED warning. OpenSSH accepts any key that matches one of the recorded entries for a host.


OpenSSH server

Windows has shipped OpenSSH since Windows 10 1809. No PuTTY, no third-party client: the config file format is identical to Linux, so a config is portable between the two sides of the dual-boot.

Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Set-Service ssh-agent -StartupType Automatic ; Start-Service ssh-agent
Set-Service sshd      -StartupType Automatic ; Start-Service sshd

The ~~~~0.0.1.0 suffix is not a version number. Windows capability names follow Name~Publisher~Architecture~Language~Version, and optional features always carry the placeholder 0.0.1.0. The actual OpenSSH version is whatever the Windows build ships (9.5p1 here) and is serviced through Windows Update.

Five traps, each of which fails silently or misleadingly

1. ssh-agent ships Disabled. Start-Service ssh-agent fails with a generic "cannot start service" until the startup type is changed. Set -StartupType Automatic first.

2. The firewall rule is Private-profile only, and a fresh install classifies the LAN as Public. The capability creates OpenSSH-Server-In-TCP enabled and allowing, so everything looks correct, while connections time out rather than being refused - the signature of a dropped packet, not a missing listener.

Get-NetConnectionProfile | Format-Table Name, InterfaceAlias, NetworkCategory
Set-NetConnectionProfile -InterfaceAlias "Ethernet" -NetworkCategory Private

A home LAN should be Private anyway. The alternative, Set-NetFirewallRule -Name OpenSSH-Server-In-TCP -Profile Any, punches the rule into the Public profile instead and is the worse choice.

3. For members of the Administrators group, sshd ignores ~/.ssh/authorized_keys. It reads C:\ProgramData\ssh\administrators_authorized_keys, and only if the ACL grants nobody beyond Administrators and SYSTEM. Wrong permissions are ignored without a word in the client output.

Add-Content -Path C:\ProgramData\ssh\administrators_authorized_keys -Value 'ssh-ed25519 AAAA...your_public_key_here comment'
icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"

4. The default remote shell is cmd.exe. Any command containing ; or $ arrives mangled. Point DefaultShell at PowerShell 7; it applies to new connections with no service restart.

New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell `
  -Value "C:\Program Files\PowerShell\7\pwsh.exe" -PropertyType String -Force

5. The SSH session is fully elevated, and that is not an SSH property. This entry previously claimed the opposite - that admin work had to be done from an interactive elevated window, and that remote automation therefore could not silently change machine-wide state. Measured on 2026-08-19, that is wrong here:

IsInRole Administrator        : True
HKLM write / delete           : succeeds
Get-WindowsCapability -Online : works
EnableLUA                     : 0

The cause is the last line. UAC is switched off on this machine, so every process an administrator starts receives a full administrator token with no filtering and no consent prompt - an SSH session included. On a box with EnableLUA=1 (the Windows default) the original claim would hold: sshd hands the user a filtered token and machine-wide operations fail. So the behaviour depends entirely on that one value, and it is worth checking rather than assuming in either direction:

[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
  [Security.Principal.WindowsBuiltInRole]::Administrator)

EnableLUA=0 is a real weakening and it was not a deliberate decision that got written down anywhere - it deserves to be turned back on, which needs a reboot. Until then, treat anything reached over ssh winpc as running with full administrator rights.

Verifying the host key

The dual-boot means the same IP answers with a different host key depending on the OS, which is exactly what a man-in-the-middle looks like. Compare before trusting - scan from the client, print from the server:

ssh-keyscan -t ed25519,rsa 192.168.0.100 | ssh-keygen -lf -        # on the client
Get-ChildItem C:\ProgramData\ssh\ssh_host_*_key.pub | ForEach-Object {
  & "$env:SystemRoot\System32\OpenSSH\ssh-keygen.exe" -l -f $_.FullName
}

OpenSSH client

The Server capability does not add C:\Windows\System32\OpenSSH to PATH - only the Client capability does, and shells opened before the install keep the stale PATH. This produces a confusing sequence where ssh-keygen works in one window and not in another. Full paths always work; a new window picks up the change.

Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0    # elevated
ssh-keygen -t ed25519 -C "nex-pc-windows"                        # normal window
ssh-add $env:USERPROFILE\.ssh\id_ed25519

Use a passphrase. The Windows ssh-agent stores loaded keys in the user's credential store and reloads them after a reboot, so a passphrase is entered once on the machine and never again. An empty passphrase buys nothing here.

An empty agent is the single most misleading failure mode: with a passphrase-protected key and nothing loaded, every host answers Permission denied (publickey) - identical to a key that was never installed. Check ssh-add -l before debugging the server side.

Config

C:\Users\<user>\.ssh\config, same syntax as Linux. A file written into ~/.ssh by a remote process inherits ACLs the client rejects with Bad owner or permissions; reset with:

icacls "$env:USERPROFILE\.ssh\config" /inheritance:r /grant:r "${env:USERNAME}:F"
Host *
    IdentityFile C:\Users\<user>\.ssh\id_ed25519
    ServerAliveInterval 30
    ServerAliveCountMax 4

Host proxmox
    HostName 192.168.0.109
    User root
# ... one block per host

The ServerAlive* pair is deliberate: this machine reaches the LAN over the RE605X wireless backhaul, where idle sessions have been observed to stall.

Reachable hosts

The public key is in ~/.ssh/authorized_keys on: claude-mgmt, proxmox, docker-host, adguard, komodo, karakeep, n8n, scraper, agentos, plus the Hetzner VPS and Home Assistant (both special, below).


Home Assistant is special, twice over

Cipher negotiation fails. The Advanced SSH & Web Terminal addon runs OpenSSH 10.3p1 against the Windows client's 9.5p1. They agree on umac-128-etm@openssh.com and the connection dies mid-handshake:

ssh_dispatch_run_fatal: Connection to 192.168.0.202 port 22: message authentication code incorrect

The same client reaches every other host fine, and the same server accepts every other client fine, which makes this look like a network fault rather than an algorithm mismatch. Pinning a MAC both implementations agree on fixes it:

Host haos
    HostName 192.168.0.202
    User hassio
    MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com

Keys do not go in authorized_keys. The addon's sshd honours /etc/ssh/authorized_keys, not ~/.ssh/authorized_keys, and /etc/s6-overlay/s6-rc.d/init-ssh/run rebuilds that file from the addon's own options on every start:

if bashio::config.has_value 'ssh.authorized_keys'; then
    while read -r key; do
        echo "${key}" >> "${SSH_AUTHORIZED_KEYS_PATH}"
    done <<< "$(bashio::config 'ssh.authorized_keys')"
fi

Editing the file directly therefore survives only until the next addon restart. Add keys in Settings → Add-ons → Advanced SSH & Web Terminal → Configuration. The option is nested under ssh: and the form hides unused optional options, so use the three-dot menu → Edit in YAML:

ssh:
  username: hassio
  authorized_keys:
    - ssh-ed25519 AAAA...key_one_here comment
    - ssh-ed25519 AAAA...key_two_here comment

Saving restarts the addon by itself.


Hetzner VPS

The VPS is the only host reachable from the public internet. Its posture was already PasswordAuthentication no, PermitRootLogin without-password, fail2ban active, and ufw rate-limiting (LIMIT) port 22.

Two options were weighed for adding this workstation's key:

Unrestricted matches the existing keys; works from any network, including without Tailscale
from="100.64.0.0/10" prefix key is only accepted from tailnet source addresses, so a leaked private key is useless from the open internet - at the cost of depending on Tailscale being up

Unrestricted was chosen, consistent with the keys already present. The restriction remains a one-word change to the authorized_keys line if that trade is ever revisited. Worth noting that this key is passphrase-protected, unlike the non-interactive automation key that predates it.


DNS

A fresh Windows install carried static DNS servers (Quad9), so no *.lan name resolved while every IP-based connection worked. Point it at AdGuard instead:

Set-DnsClientServerAddress -InterfaceAlias "Ethernet" -ServerAddresses 192.168.0.111
Clear-DnsClientCache

Set-DnsClientServerAddress -InterfaceAlias "Ethernet" -ResetServerAddresses hands control back to DHCP.

.lan names resolve to the Caddy reverse proxy and are served over HTTPS with a mkcert CA - see the next section for making that CA trusted. Until it is, browsers warn; direct IP:port access sidesteps it (Home Assistant, for example, at 192.168.0.202:8123).


HTTPS for .lan services

Every .lan name resolves to Caddy (192.168.0.208), which serves a certificate signed by the mkcert CA that lives on LXC 110. Nothing outside the LAN issued it, so a fresh Windows install does not trust it and the browser says so.

Read the error before doing anything

The three failures look similar in the address bar and have nothing to do with each other:

What the browser shows Firefox code Cause Fix
Warning page, "Potential Security Risk Ahead" SEC_ERROR_UNKNOWN_ISSUER The mkcert CA is not trusted on this machine Install the CA, below
Warning page, "does not apply to the name" SSL_ERROR_BAD_CERT_DOMAIN The domain is not in the cert's SAN list Regenerate the cert on LXC 110 with the domain added (caddy → Regenerating the cert)
No warning page, "Not secure" beside the URL - The address is http://, not https:// Type the https:// scheme, or fix the bookmark

The last one is easy to mistake for a certificate problem. Caddy deliberately serves the same handlers on port 80 for devices that cannot hold the CA, so http://jellyfin.lan works and is honestly labelled insecure.

Two trust stores, not one

Windows keeps one store; Firefox keeps its own (NSS) and consults the Windows store only when security.enterprise_roots.enabled is set. mkcert -install cannot bridge that gap here - mkcert supports the Firefox store on macOS and Linux only. So Edge and Chrome can be working perfectly while Firefox still refuses, which is exactly what it looks like when the CA install "did not take".

The script

scripts/install-lan-ca-windows.ps1 in this repo does all of it. Elevated PowerShell, Firefox closed:

.\install-lan-ca-windows.ps1 -Fetch

-Fetch pulls rootCA.pem off the Caddy LXC over the existing SSH access. Without it, pass -CertPath to a copy already on disk. The script:

  1. imports the CA into LocalMachine\Root - covers Edge, Chrome, curl.exe and .NET
  2. copies the CA into %LOCALAPPDATA%\Mozilla\Certificates and merges a distribution\policies.json next to firefox.exe setting Certificates.Install plus Certificates.ImportEnterpriseRoots - covers Firefox
  3. warns if DNS is not pointing at AdGuard (192.168.0.111), then flushes the resolver cache
  4. fetches https://homepage.lan and reports whether the chain validated

It is idempotent and backs up an existing policies.json to policies.json.bak.

Doing it by hand

# 1. get the CA
ssh root@192.168.0.109 "pct pull 110 /etc/caddy/certs/rootCA.pem /tmp/rootCA.pem"
scp root@192.168.0.109:/tmp/rootCA.pem $env:USERPROFILE\Downloads\mkcert-rootCA.pem

# 2. Windows store (elevated) - Chrome, Edge, curl.exe
Import-Certificate -FilePath $env:USERPROFILE\Downloads\mkcert-rootCA.pem `
  -CertStoreLocation Cert:\LocalMachine\Root

# 3. verify it landed
Get-ChildItem Cert:\LocalMachine\Root | Where-Object Subject -like '*mkcert*'

For Firefox, either flip Settings → Privacy & Security → Certificates → "Allow Firefox to automatically trust third-party root certificates you install" (this is security.enterprise_roots.enabled, so step 2 then suffices), or import the file directly: View Certificates → Authorities → Import → mkcert-rootCA.pem → tick "Trust this CA to identify websites". Either way Firefox reads the change at startup only, so restart it fully - closing the window is not enough if it is still in the tray.

Verifying

Resolve-DnsName homepage.lan          # must answer 192.168.0.208
curl.exe -sI https://homepage.lan     # Schannel, so this proves the Windows store
Invoke-WebRequest https://homepage.lan -UseBasicParsing | Select-Object StatusCode

curl.exe succeeding while Firefox still warns is the normal intermediate state, and means only the Firefox half is outstanding.

Expiry

The current server cert runs to 2028-10-19. New .lan domains added after that cert was issued produce SSL_ERROR_BAD_CERT_DOMAIN on every device until the cert is regenerated - the CA in the Windows store does not need touching for that, only the server cert on LXC 110.


Terminal

Windows Terminal was not present on this install and is worth adding - not as a PowerShell replacement but as the window PowerShell runs in, the same relation as Konsole to bash. It brings tabs and split panes, true colour and correct UTF-8 box-drawing (so htop and ncdu over SSH are legible instead of shredded), GPU-accelerated scrolling, and searchable scrollback.

winget install --id Microsoft.WindowsTerminal

Tailscale

Windows is its own tailnet node, nex-windows (100.80.75.55), separate from Nobara's nex-pc (100.109.197.79). Because the two operating systems can never run at once, this pair cannot produce the UDP 41641 port collision that has bitten other nodes behind this router.

winget install --id tailscale.tailscale

File Explorer crashed on every nav-pane right-click

Right-clicking anything in File Explorer's left navigation pane - Desktop, Downloads, Pictures, This PC, Local Disk C, Local Disk D, Network - crashed and restarted Explorer. It began while Seelen UI was installed and survived its removal untouched, which is what ruled Seelen out.

The event log names the victim, not the culprit

Application, Event ID 1000
  Explorer.EXE 10.0.26100.1591
  SHELL32.dll  10.0.26100.1591   c0000005   offset 0x5200f
  ntdll.dll    10.0.26100.1591   c015000f   offset 0x4f63c

Six of these in eleven minutes, and always the same offset in SHELL32. That repetition is the useful part. SHELL32 is what loads shell extensions, so a crash at a fixed address inside it means an extension corrupted the state SHELL32 then walked into. The faulting-module field will keep pointing at SHELL32 and never name the extension, so the event log cannot finish this investigation on its own.

Finding the extension

Enumerate the context menu handlers for the object classes the nav pane actually contains, and resolve each entry's CLSID to the DLL behind it:

foreach ($base in 'Folder','Drive','AllFilesystemObjects','*') {
  $p = "HKLM:\SOFTWARE\Classes\$base\shellex\ContextMenuHandlers"
  Get-ChildItem -LiteralPath $p | ForEach-Object {
    $guid = (Get-ItemProperty -LiteralPath $_.PSPath -Name '(default)').'(default)'
    $dll  = (Get-ItemProperty -LiteralPath "HKLM:\SOFTWARE\Classes\CLSID\$guid\InprocServer32" -Name '(default)').'(default)'
    "{0,-22} {1,-32} {2}" -f $base, $_.PSChildName, $dll
  }
}

-LiteralPath is not optional. The * class is a key whose name is literally an asterisk; without -LiteralPath PowerShell reads it as a wildcard and walks the whole of HKCR, which runs for minutes and returns nothing useful.

Two non-Microsoft handlers were registered under Folder and Drive: Open-Shell's StartMenuHelper64.dll (unsigned, but current, dated 2026-05-12) and Spybot's SDECon64.dll. The file versions settled it:

File Version Dated
SDECon64.dll 2.8.67.113 2021-12-21
SDECon32.dll 2.9.82.115 2023-01-23
installed Spybot 2.9.85.5 2024-12-29

A partial update had left the 64-bit half four years behind the product it belonged to, and 64-bit Explorer loads precisely that half. Both the SDECon32 and SDECon64 keys pointed at the same CLSID, {44176360-2BBF-4EC1-93CE-384B8681A0BC}, so the stale DLL was being loaded twice per menu. Spybot's own forums carry several threads describing exactly this, down to the detail of right-clicking drives in the left pane.

What was done

Spybot was uninstalled rather than having its extension unregistered. The free edition has no real-time protection at all, so its three resident processes (103 MB) and two automatic services were guarding nothing, while Defender was fully live (RealTimeProtectionEnabled: True, signatures 0 days old). Zero Explorer crashes since.

The uninstaller was not thorough. It removed the DLLs and the CLSID registration but left six ContextMenuHandlers\SDECon32|SDECon64 keys behind, pointing at a CLSID that no longer resolves. That is harmless - the shell fails the lookup and moves on - so an incomplete uninstall is not evidence that the fix failed. They were deleted afterwards for tidiness, not as part of the repair.

A backup that would not have worked

The six keys were exported before deletion, each with reg export, appended into one .reg file. That file cannot be imported: every export writes its own Windows Registry Editor Version 5.00 line and regedit stops at the second one. It was rewritten with a single header and then actually imported - all six keys came back - before being deleted again. A rollback file nobody has restored from is a guess, not a rollback.

One more trap in that test: & reg import $file 2>&1 under $ErrorActionPreference='Stop' throws, because reg writes to stderr even on success. The import completes; the rest of the script does not.

What was left alone

The hosts file, by decision: 476 KB and 16 314 lines, of which lines 676 to the end are Spybot immunization last written on 2024-12-29 and now permanently frozen. Lines 1-675 hold 651 unrelated entries blocking Adobe telemetry, so the file must never be emptied wholesale. DNS on this machine points at AdGuard (192.168.0.111), which makes the entire blocklist redundant in any case.

Seelen UI

Removed on 2026-08-19

Seelen UI is no longer installed on this machine. This section is kept as a record of the diagnosis and of why it was dropped; see Why it was removed at the end. Everything below describes how it was set up.

Seelen UI 2.8.2 replaced the Windows shell on this machine: its own taskbar, dock, app menu and tiling window manager. It came from the Microsoft Store as Seelen.SeelenUI_p6yyn03m1894e, so it also updated itself from there.

Every widget it draws is a separate WebView2 instance. That is the architecture, and it is also the source of the only real trouble this machine has had with it.

The widget that stopped responding

On 2026-08-19 the shell came apart a piece at a time. Clicking the tray, the settings, the quick settings or a context menu produced:

The widget 'XY' stopped responding too many times. You can try restarting the app.

The taskbar and the dock kept working, which made it read as a random partial failure. The log said otherwise:

[2026-08-19][07:28:19][ERROR][tauri_runtime_wry] failed to create webview: WebView2 error:
  WindowsError(Error { code: HRESULT(0x80010108), message: "The object invoked has disconnected from its clients." })
[2026-08-19][07:29:24][ERROR][seelen_ui::widgets::loader] Liveness prove failed for @seelen/system-tray too many times, giving up.

0x80010108 is RPC_E_DISCONNECTED. Lining those timestamps up against the WebView2 install directory explains all of it:

Time Event
07:13:09 seelen-ui.exe starts, spawning WebView2 processes from 151.0.4129.78
07:14:30 Edge Update creates ...\EdgeWebView\Application\151.0.4129.93
07:24:47 the installer finishes and the old version's directory goes away
07:28:03 first Liveness prove failed; nine widgets give up over the next ninety seconds

The Evergreen WebView2 runtime updated underneath a process that was already running. Widgets that already held a webview carried on, which is why the taskbar looked healthy. Anything that had to create a new one was asking for a runtime that no longer existed on disk. Nine gave up: tooltip, context-menu (two instances), user-menu, settings, notifications, quick-settings, keyboard-selector, system-tray.

A process in this state never recovers on its own. Only a restart fixes it, and the same thing will happen at the next runtime update - this is design, not a bug that gets patched.

Diagnosing it without reading the log

Compare the registered runtime version against what Seelen's children are actually running:

(Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}').pv
Get-CimInstance Win32_Process -Filter "Name='msedgewebview2.exe' AND ParentProcessId=$((Get-Process seelen-ui).Id)" |
  Select-Object ProcessId, ExecutablePath

Two things about that second command. The GUID is the WebView2 Runtime's, not the Edge browser's. And it returns exactly one row, not the eleven msedgewebview2.exe processes visible in Task Manager: only the browser process is a direct child of seelen-ui.exe, the renderers are children of it. If that one path does not carry the version from the first command, the shell is already broken and has no way to tell you.

The fix that was rejected

There is a one-line way to stop this permanently:

reg add "HKLM\SOFTWARE\Policies\Microsoft\EdgeUpdate" /v "Update{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" /t REG_DWORD /d 0 /f

It works, and it was not used. WebView2 is not Seelen's private dependency: the new Outlook, Teams and a long tail of Store apps render web content with the same runtime, some of it from the open internet. Freezing the runtime freezes their engine's security patches too. That is a bad trade on a machine in daily use, to spare a three-second restart every few weeks.

The guard

scripts/seelen-webview-guard.ps1 in this repo, deployed to C:\Users\Nex\seelen-webview-guard.ps1 and driven by a scheduled task named Seelen WebView2 guard. It watches the exact condition that breaks the shell - Seelen's own webview child running from a directory other than the registered runtime - and restarts Seelen when it sees it. Nothing is grepped, nothing is guessed.

The task does not call PowerShell directly. It runs scripts/seelen-webview-guard.vbs, a small launcher whose only job is to start PowerShell with no window; the reason is the third trap below.

$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name

$action = New-ScheduledTaskAction -Execute 'wscript.exe' `
  -Argument '"C:\Users\Nex\seelen-webview-guard.vbs"'
$t1 = New-ScheduledTaskTrigger -Once -At (Get-Date).Date -RepetitionInterval (New-TimeSpan -Minutes 5)
$t2 = New-ScheduledTaskTrigger -AtLogOn -User $me

Register-ScheduledTask -TaskName 'Seelen WebView2 guard' -Action $action -Trigger @($t1,$t2) `
  -Principal (New-ScheduledTaskPrincipal -UserId $me -LogonType Interactive -RunLevel Limited) `
  -Settings (New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
              -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -MultipleInstances IgnoreNew)

Three traps:

$env:USERDOMAIN is WORKGROUP on a machine that is not domain-joined, and Register-ScheduledTask rejects WORKGROUP\nex with No mapping between account names and security IDs was done - an error that reads like a permissions problem rather than a bad string. [System.Security.Principal.WindowsIdentity]::GetCurrent().Name returns DESKTOP-BO661M2\Nex, which it accepts.

-LogonType Interactive is not optional. Relaunching an MSIX app means Start-Process explorer.exe 'shell:AppsFolder\<PackageFamilyName>!App', and that needs a desktop session to appear on. A task running as SYSTEM or with a stored password would kill Seelen and never bring it back - the worst possible outcome for a script whose job is keeping the shell alive. -RunLevel Limited is deliberate too: none of this needs administrator.

powershell.exe -WindowStyle Hidden still flashes a console window. The console host is created before PowerShell parses its own parameters, so the window exists for a few hundred milliseconds no matter what the flag says. On a five-minute schedule that is a blink on the desktop twelve times an hour, and it can take focus off whatever is in front of it - which is how this was noticed at all, from the desk rather than from a log. wscript.exe has no console of its own, so a .vbs doing CreateObject("WScript.Shell").Run cmd, 0, True starts PowerShell hidden and waits for it, keeping the task's runtime and its execution time limit meaningful.

The -AtLogOn trigger exists because the repeating trigger alone is not dependable across a reboot. IgnoreNew keeps a slow run from stacking, and the script writes a seelen-webview-guard.last stamp so a mismatch that somehow refuses to clear cannot turn into a restart every five minutes.

Verifying it

The first run was a real one - the shell was still broken at the time, so the guard had actual work to do:

task last run     : 08/19/2026 07:46:35  result=0
seelen pid        : 9428 -> 8064  (started 07:46:38)
webview child     : 6192  ...\EdgeWebView\Application\151.0.4129.93\msedgewebview2.exe

%LOCALAPPDATA%\seelen-webview-guard.log:

2026-08-19T07:46:37 restart: bejegyzett runtime=151.0.4129.93, futo=...\151.0.4129.78\msedgewebview2.exe
2026-08-19T07:46:40 ujrainditva

Then it was run a second time, which is the half that actually matters: pid 8064 stayed 8064 and the log stayed two lines. A guard that restarts things is only safe once you have watched it decline to.

Moving the task onto the .vbs launcher needed one more proof, because wscript.exe returns 0 whether or not the script it launched did anything. A task result of 0 says nothing about the PowerShell underneath. So the guard was swapped for a one-line stand-in that writes a file unconditionally, the task was fired, and the stand-in reported back:

ran at 2026-08-19T10:13:31 as desktop-bo661m2\nex session 1

session 1 is the interactive desktop session, which is the property the MSIX relaunch depends on. The real script was then restored and its SHA256 compared against the copy in this repo to prove the swap left nothing behind.

Three log lines that are not evidence

Every Seelen start writes these, including the 2026-08-11 start that predates all of the above:

[ERROR][seelen_ui::error] WMI(HResultError { hres: -2147217396 })
[ERROR][seelen_ui::error] Windows(Error { code: HRESULT(0x80040154), message: "Class not registered" })

And telemetry.seelen.io fails DNS resolution on every run, because AdGuard blocks it. Neither has anything to do with the widget failure, and both are easy to seize on when hunting for a cause.

What it costs, and why it stays

Measured on 2026-08-19 with the shell idle:

seelen-ui.exe 152 MB
msedgewebview2.exe x11 926 MB
slu-service.exe 4 MB
total 1 082 MB

Plus 82 CPU-seconds over 26.3 minutes of uptime, or roughly 5.2% of one core, sustained, doing nothing - a good part of it the per-second window preview capture that the log shows on every single second.

That is a lot for a shell, and it stays anyway. This machine is the games half of the dual boot; the daily desktop is Nobara. 1 GB out of 32 GB and 5% of one core buy a desktop that is pleasant to be in for the few hours a week it runs, and the failure that prompted all of this is now handled in fifteen lines of PowerShell.

If that ever changes - if real work happens on the Windows side - the durable replacement is komorebi plus YASB: a tiling window manager and a status bar with no browser engine anywhere in the loop, so no runtime update can reach them. The cost is TOML and JSON configuration and whkd for the key bindings, with no settings GUI at all. GlazeWM is the obvious third option and is not recommended here: as of 2026-08-19 it carries 400 open issues with no commit since 2026-06-20, and Zebar, its companion bar, has not been touched since 2026-03-31.

Why it was removed

The guard worked. It was also, within four hours, clearly fixing one instance of a class of problem rather than the problem.

At 11:05:15 the same day, @seelen/wallpaper-manager began failing, and the shell showed the same "stopped responding too many times" dialog. This time it was not the WebView2 update:

Morning failure Midday failure
HRESULT 0x80010108 RPC_E_DISCONNECTED 0x8007139F ERROR_INVALID_STATE
Registered runtime vs. Seelen's child .93 vs .78 - mismatch .93 vs .93 - identical
Widgets lost nine one
Guard's verdict restart do nothing

The guard's verdict was correct both times. There was no runtime mismatch at 11:05, so restarting Seelen was not its call to make. The trigger was mundane: a window closed (Removing: Window(0xb0788)), and the wallpaper manager's webview never came back. Six liveness attempts over forty seconds, then giving up.

What settled it was what happened next. The automatic wallpaper rotation kept firing on its five-minute timer against a widget that no longer existed, and the log went from 785 KB to 937 KB in twenty seconds - 7 602 bytes per second, 2 173 copies of the same error, with no upper bound and no self-recovery. CPU stayed low (1.7% of one core), so nothing was on fire; it was simply going to grind on until someone restarted the shell by hand.

That is the argument against the whole arrangement. 0x8007139F is a known WebView2 failure with several distinct causes reported upstream, and Tauri has its own long-running issue for it. Guarding against each new symptom means writing a new detector every time, and the only detector that would have caught this one is log-grepping for giving up - which cannot distinguish a widget that failed once from a widget that fails on every start, so it risks a restart loop on the desktop it is supposed to protect.

Weighed against 1 GB of RAM and 5% of a core for a shell on a machine that exists to run games, that was not a trade worth defending. Windows 11's own shell went back on.

What removing it touched

Unregister-ScheduledTask -TaskName 'Seelen WebView2 guard' -Confirm:$false
Get-Process seelen-ui, slu-service | Stop-Process -Force
Get-AppxPackage Seelen.SeelenUI | Remove-AppxPackage
Unregister-ScheduledTask -TaskName 'Seelen UI Service' -TaskPath '\Seelen\' -Confirm:$false
Remove-Item "$env:APPDATA\com.seelen.seelen-ui", "$env:LOCALAPPDATA\com.seelen.seelen-ui" -Recurse -Force
Stop-Process -Name explorer -Force        # rebuilds the native taskbar

Two things are worth knowing before doing this on a machine you are sitting at.

slu-service.exe is what hides the native taskbar, through HideNativeTaskbar IPC calls. Removing the package while it is hidden can leave a desktop with no taskbar at all, so the order matters: stop the processes first, uninstall second, and restart explorer.exe at the end to rebuild Shell_TrayWnd from scratch. If that is ever missed, Ctrl+Shift+Esc → Run new task → explorer.exe is the way back.

A second scheduled task survives the uninstall. \Seelen\Seelen UI Service is registered outside the MSIX package and Remove-AppxPackage leaves it behind, along with its \Seelen\ folder in the Task Scheduler tree.

Removed: the 2.8.2.0 package, a 38.1 MB local data directory, 3 KB of settings, two scheduled tasks and four guard files. explorer.exe restarted cleanly (pid 9712 to 10656).

One thing SSH cannot answer: whether the taskbar is actually on screen. FindWindow('Shell_TrayWnd', ...) from an SSH session returns a null handle regardless, because that session cannot see the interactive desktop's windows - the same trap as reading monitor geometry remotely, where [System.Windows.Forms.Screen]::AllScreens reports a phantom 1024x768. Neither is evidence of anything. Only a human looking at the screen can confirm it.