diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c64ad6..40ccd00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,15 @@ client has no name for the AppBar control type of Explorer's command bar, or for SemanticZoom. Such controls now have the role `Unknown`, and `seat_element` acts on them as on any other control. A text control that provides no document range or selection is read without them. +- **The uninstaller lists what it leaves.** After removing `anode.exe`, it pointed at `anode setup + --undo` to turn child sessions off, and named only the logs. It now ends with what really stays on + the machine, each with how to remove it without Anode: logs and settings (a packaged app's too), a + Remote Desktop rendering preference not yet restored, the seat's browser and Android Studio + profiles, still signed in, the copies of agent client settings saved before Anode changed them, + child sessions, the Remote Desktop host, and the `--fps 60` and `--gpu` values. It says when + another Anode on the machine shares them. The security page shows how to turn child sessions off + and restore the rendering preference without `anode.exe`, and a rendering restore that fails + during removal is reported. ## 0.11.1 — 2026-10-02 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index c19adb8..2da413f 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -193,7 +193,9 @@ started, in that app's `%LOCALAPPDATA%\Packages\\LocalCache\Local\Anode`. T `%LOCALAPPDATA%\AnodeEdge`, and Android Studio's seat profile, `%LOCALAPPDATA%\AnodeAndroidStudio`, also remain, still signed in to any site or account you signed in to there; delete them to sign those out. To turn child sessions off, do it during removal (answer yes, or pass -`-UndoSetup`): once `anode.exe` is gone, `anode setup --undo` is no longer available. +`-UndoSetup`): once `anode.exe` is gone, `anode setup --undo` is no longer available, and +[a PowerShell command](SECURITY.md#turning-child-sessions-off-without-anode) does it instead. The +uninstaller ends by listing what stays on this machine and how to remove each without Anode. For portable, Scoop and plugin installs, or to remove Anode by hand: diff --git a/docs/SECURITY.md b/docs/SECURITY.md index a6ed605..db1fd56 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -52,6 +52,19 @@ Remove-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Servi Leave `bEnumerateHWBeforeSW` alone if your organization's Group Policy sets it. +### Turning child sessions off without Anode + +`anode setup --undo` needs `anode.exe`. After an uninstall that left child sessions on, run this +from an administrator PowerShell while no Anode runs. It prints `True` once they are off; `False` +usually means the PowerShell wasn't run as administrator. `setup --undo` signs out the seat first, +which this doesn't, so if a seat may still be signed in, for example after Anode was ended by +force, restart Windows first. + +```powershell +Add-Type -Namespace Wts -Name ChildSessions -MemberDefinition '[DllImport("wtsapi32.dll")] public static extern bool WTSEnableChildSessions(bool enable);' +[Wts.ChildSessions]::WTSEnableChildSessions($false) +``` + ## Per-user background rendering Before creating its RDP viewer, the daemon sets the DWORD @@ -67,6 +80,24 @@ inside such an app it refuses, because the app may keep its file and registry ch The next daemon startup configures rendering again unless `--no-background-rendering` is supplied. A configured value alone does not prove that capture works. +Without `anode.exe`, for example after an uninstall, set the value back from the backup in your own +PowerShell, naming the backup the uninstaller listed. Like `anode rendering --restore`, it changes +the value, and deletes the backup, only while the value is still Anode's `2`. Anything else means +something changed it later, so the backup is out of date: delete it yourself. A wrong path changes +nothing. If the uninstaller listed several backups, each saved by a different app's Anode, use the +one with the earlier value you want and delete the others. + +```powershell +$backup = "$env:LOCALAPPDATA\Anode\rdp-rendering-backup.json" +$key = 'HKCU:\Software\Microsoft\Terminal Server Client' +$saved = Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json +if ($saved -and (Get-ItemProperty -LiteralPath $key).RemoteDesktop_SuppressWhenMinimized -eq 2) { + if ($null -eq $saved.PreviousValue) { Remove-ItemProperty -LiteralPath $key -Name RemoteDesktop_SuppressWhenMinimized } + else { Set-ItemProperty -LiteralPath $key -Name RemoteDesktop_SuppressWhenMinimized -Value $saved.PreviousValue -Type DWord } + Remove-Item -LiteralPath $backup +} +``` + ## Desktop inspection Window enumeration and UI Automation execute inside a worker that first verifies diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 index da63ec1..4fecea3 100644 --- a/scripts/test-install.ps1 +++ b/scripts/test-install.ps1 @@ -203,10 +203,19 @@ Write-Output 'connector-output-ok' # Uninstall with no agent CLI visible, so no real client configuration is read or changed, and # -Quiet, so nothing asks, quits Anode or undoes machine setup. function Get-Command { param([string]$Name, [string]$ErrorAction) if ($Name -notin @('codex', 'claude')) { Microsoft.PowerShell.Core\Get-Command @PSBoundParameters } } + # A stand-in %LOCALAPPDATA% holds leftovers the closing message must name, with how to remove them without anode.exe. + $appData = Join-Path $workspace 'LocalAppData' + $packagedState = Join-Path $appData 'Packages\Test.App_x\LocalCache\Local\Anode' + foreach ($folder in @((Join-Path $appData 'Anode'), $packagedState, (Join-Path $appData 'AnodeChrome'))) { $null = [IO.Directory]::CreateDirectory($folder) } + # The uninstaller lists packaged folders with Get-ChildItem, which spells out an 8.3 TEMP such as RUNNER~1; expect the same. + $packagedState = Join-Path (Get-ChildItem -LiteralPath (Join-Path $appData 'Packages') -Directory).FullName 'LocalCache\Local\Anode' + Set-Content -LiteralPath (Join-Path $packagedState 'rdp-rendering-backup.json') -Value '{"PreviousValue":null}' + $originalAppData = $env:LOCALAPPDATA try { + $env:LOCALAPPDATA = $appData $uninstallOutput = @(& (Join-Path $installation 'uninstall.ps1') -Quiet 6>&1 | ForEach-Object { "$_" }) -join "`n" $uninstallCode = $LASTEXITCODE - } finally { Remove-Item -LiteralPath Function:\Get-Command } + } finally { Remove-Item -LiteralPath Function:\Get-Command; $env:LOCALAPPDATA = $originalAppData } Write-Host $uninstallOutput Assert ($uninstallCode -eq 0 -and $uninstallOutput.Contains('Anode is uninstalled.')) "Uninstall failed: $uninstallOutput" Assert (-not (Test-Path -LiteralPath $exe) -and -not (Test-Path -LiteralPath (Join-Path $installation 'docs')) -and @@ -214,6 +223,12 @@ Write-Output 'connector-output-ok' -not (Test-Path -LiteralPath (Join-Path $installation '.anode-install.json'))) 'Uninstall left installed files.' Assert ((Get-Content -LiteralPath $sentinel -Raw).Trim() -eq 'keep this' -and $uninstallOutput.Contains('you added')) 'Uninstall removed a file the user added.' Assert (-not (Test-Path -LiteralPath (Join-Path $startMenu 'Anode.lnk')) -and -not (Test-Path -LiteralPath $registration)) 'Uninstall left its shortcut or Installed apps entry.' + # The backup's advice depends on this machine's own rendering value, so only its path is checked. + Assert ($uninstallOutput.Contains("Logs and saved settings in $(Join-Path $appData 'Anode'):") -and $uninstallOutput.Contains("Logs and saved settings in ${packagedState}:") -and + $uninstallOutput.Contains((Join-Path $packagedState 'rdp-rendering-backup.json')) -and + $uninstallOutput.Contains("still signed in to any site or account you signed in to there: $(Join-Path $appData 'AnodeChrome'). ") -and + -not $uninstallOutput.Contains('setup --undo')) "The closing message does not list what stays and how to remove it without anode.exe: $uninstallOutput" + Write-Host '[ok] uninstall lists what stays, with how to remove each without anode.exe' $stray = Join-Path $workspace 'not installed' $null = New-Item -ItemType Directory -Path $stray Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'uninstall.ps1') -Destination $stray diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index e3dbc75..33ae65d 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -6,8 +6,9 @@ Windows Settings runs this from Installed apps. It removes the files install.ps1 copied, its PATH entry, Start menu shortcut and Installed apps entry, and unregisters Codex and Claude Code servers that run this installation's anode.exe, with their unmodified anode-desktop skill. - Files you added to the folder, logs in %LOCALAPPDATA%\Anode and machine setup stay. It asks - before quitting a running Anode or undoing machine setup; -Quiet never asks and does neither. + Files you added to the folder, logs in %LOCALAPPDATA%\Anode and machine setup stay; it ends by + listing what stays and how to remove each without Anode. It asks before quitting a running Anode + or undoing machine setup; -Quiet never asks and does neither. .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File "$env:LOCALAPPDATA\Programs\Anode\uninstall.ps1" #> @@ -22,7 +23,7 @@ $ErrorActionPreference = 'Stop' $InstallDirectory = $PSScriptRoot.TrimEnd('\') $exe = Join-Path $InstallDirectory 'anode.exe' $markerPath = Join-Path $InstallDirectory '.anode-install.json' -$setupGuide = 'https://github.com/skulitom/Anode/blob/main/docs/SECURITY.md#what-anode-setup-changes' +$securityGuide = 'https://github.com/skulitom/Anode/blob/main/docs/SECURITY.md' $interactive = -not $Quiet -and [Environment]::UserInteractive -and -not [Console]::IsInputRedirected function Ask([string]$Question) { $interactive -and ((Read-Host "$Question [y/N]") -match '^\s*y(es)?\s*$') } # Settings opens a console for this script; keep it open long enough to read the outcome. @@ -34,6 +35,82 @@ function Get-Running { @(Get-CimInstance Win32_Process -Filter "Name = 'anode.exe'" | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($InstallDirectory + '\', [StringComparison]::OrdinalIgnoreCase) }) } +# What stays on this machine once anode.exe is gone, each with how to remove it without Anode. Read after +# removal, so it names only what is really left; a probe that fails is skipped rather than failing the removal. +function Get-Leftovers { + function Read-Value([string]$Key, [string]$Name) { try { (Get-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction Stop).$Name } catch { $null } } + $local = $env:LOCALAPPDATA + if ($local) { + # Where 'anode rendering --restore' looks: Anode's own folder, then each packaged app's. + $folders = @(Join-Path $local 'Anode') + @(Get-ChildItem -LiteralPath (Join-Path $local 'Packages') -Directory -ErrorAction SilentlyContinue | + ForEach-Object { Join-Path $_.FullName 'LocalCache\Local\Anode' }) + $rendering = Read-Value 'HKCU:\Software\Microsoft\Terminal Server Client' 'RemoteDesktop_SuppressWhenMinimized' + foreach ($folder in @($folders | Where-Object { Test-Path -LiteralPath $_ -PathType Container })) { + $backup = Join-Path $folder 'rdp-rendering-backup.json' + if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) { } + elseif ($rendering -eq 2) { + $saved = try { Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json } catch { $null } + $value = if (-not $saved -or 'PreviousValue' -notin $saved.PSObject.Properties.Name) { 'unreadable' } + elseif ($null -eq $saved.PreviousValue) { 'none (the value was absent)' } else { $saved.PreviousValue } + "The Remote Desktop rendering preference Anode changed is not restored. Its backup, $backup, holds the earlier " + + "value: $value. Set it back by hand before deleting that folder: $securityGuide#per-user-background-rendering" + } else { + "$backup is out of date: the Remote Desktop rendering preference no longer has the value Anode set, so leave the preference as it is." + } + "Logs and saved settings in ${folder}: delete the folder when you no longer need them." + } + $profiles = @('AnodeChrome', 'AnodeEdge', 'AnodeAndroidStudio' | ForEach-Object { Join-Path $local $_ } | Where-Object { Test-Path -LiteralPath $_ -PathType Container }) + if ($profiles.Count) { + "The seat's browser and Android Studio profiles, still signed in to any site or account you signed in to there: " + + "$($profiles -join ', '). Delete them to sign those out." + } + } + # The copies connect-agents.ps1 made before changing a client's settings, this removal's included. + $copies = @(try { + foreach ($config in @((Join-Path $(if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $env:USERPROFILE '.codex' }) 'config.toml'), + (Join-Path $(if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { $env:USERPROFILE }) '.claude.json'))) { + Get-ChildItem -LiteralPath (Split-Path -Parent $config) -Filter ((Split-Path -Leaf $config) + '.anode-backup-*') -File -ErrorAction SilentlyContinue | + ForEach-Object { $_.FullName } + } + } catch { }) + if ($copies.Count) { + "Copies of agent client settings, saved before Anode changed them, which can hold other servers' settings and tokens: " + + "$($copies -join ', '). Delete them once those clients work as you want." + } + $childSessions = $null + try { + if (-not ('AnodeUninstall.ChildSessions' -as [type])) { + Add-Type -Namespace AnodeUninstall -Name ChildSessions -MemberDefinition '[DllImport("wtsapi32.dll")] public static extern bool WTSIsChildSessionsEnabled(out bool enabled);' + } + $enabled = $false + if ([AnodeUninstall.ChildSessions]::WTSIsChildSessionsEnabled([ref]$enabled)) { $childSessions = $enabled } + } catch { } + if ($childSessions -ne $false) { + $state = if ($childSessions) { 'Windows child sessions are on.' } else { "If you ran 'anode setup', Windows child sessions are on." } + "$state To turn them off, with no Anode running, run the commands in $securityGuide#turning-child-sessions-off-without-anode " + + 'from an administrator PowerShell.' + } + $terminalServer = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' + if ((Read-Value $terminalServer 'fDenyTSConnections') -eq 0) { + "The Remote Desktop host is on, and nothing in Anode turns it off. If it was off before 'anode setup' and nothing else " + + 'needs it, turn it off in Settings > System > Remote Desktop.' + } + $tuning = @() + if ((Read-Value "$terminalServer\WinStations" 'DWMFRAMEINTERVAL') -eq 15) { $tuning += "the 60 fps frame cap (DWMFRAMEINTERVAL, from --fps 60)" } + if ((Read-Value 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services' 'bEnumerateHWBeforeSW') -eq 1) { + $tuning += 'the hardware graphics setting (bEnumerateHWBeforeSW, from --gpu)' + } + if ($tuning.Count) { + "Remote Desktop settings 'anode setup' can make are set: $($tuning -join ' and '). Unless your organization set them, " + + "remove them as $securityGuide#what-anode-setup-changes shows." + } +} +# Another Anode on this machine (a portable, Scoop or plugin copy) shares that log folder, the seat profiles and machine setup. +function Get-OtherAnode { + $paths = @(Get-Process -Name anode -ErrorAction SilentlyContinue | ForEach-Object { try { $_.Path } catch { } }) + + @(Get-Command -Name anode.exe -ErrorAction SilentlyContinue | ForEach-Object { $_.Source }) + @($paths | Where-Object { $_ -and -not $_.StartsWith($InstallDirectory + '\', [StringComparison]::OrdinalIgnoreCase) })[0] +} try { if (-not (Test-Path -LiteralPath $markerPath -PathType Leaf)) { @@ -77,6 +154,7 @@ try { Write-Warning 'Another Anode is running, so machine setup was left on; undoing it would sign out that seat.' } else { & $exe rendering --restore | Out-Host + if ($LASTEXITCODE -ne 0) { Write-Warning 'The Remote Desktop rendering preference was not restored; see the message above.' } & $exe setup --undo | Out-Host if ($LASTEXITCODE -ne 0) { $undo = $false; Write-Warning 'Child sessions were not turned off; see the messages above.' } } @@ -138,8 +216,13 @@ try { else { Remove-Item -LiteralPath $InstallDirectory } Write-Host "`nAnode is uninstalled." -ForegroundColor Green - Write-Host "Logs and saved settings remain in $(Join-Path $env:LOCALAPPDATA 'Anode'); delete that folder if you no longer need them." - if (-not $undo) { Write-Host "If you ran 'anode setup', child sessions stay on; to turn them off, see $setupGuide" } + $leftovers = @(try { Get-Leftovers } catch { "Could not check what stays on this machine ($($_.Exception.Message)); see $securityGuide." }) + if ($leftovers.Count) { + $other = try { Get-OtherAnode } catch { $null } + Write-Host $(if ($other) { "These stay on this machine. Another Anode, $other, still uses the logs, profiles and machine settings among them, so keep those while you use it. Without Anode, each goes like this:" } + else { 'These stay on this machine. anode.exe is gone, so each says how to remove it without Anode:' }) + foreach ($line in $leftovers) { Write-Host "- $line" } + } Finish 0 } catch { Write-Host $_.Exception.Message -ForegroundColor Red