Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

Notable changes to WinButler, by internal milestone. The current shipping version is **1.0.1**.

## v1.0.2 — 2026-07-29 · Ghost-device cleanup + Activision/CoD fix

- **Ghost-device removal on the System Tools page**: "List ghost devices" (read-only) surfaces
non-present PnP device nodes via `pnputil.exe`. "Remove ghost devices" (Advanced) is
**permanent and cannot be undone** — to avoid touching live hardware that can also show as
"disconnected" (disks, GPU-integrated controllers, VSS snapshots, virtual/software device
stubs — all observed on real hardware during development), it only ever removes an
allow-listed shape of device (USB/HID devices by vendor+product ID, Bluetooth, audio
endpoints), explicitly excluding USB root hubs. The removal action runs an embedded PowerShell
script entirely in memory (`-EncodedCommand`, never written to disk) — see
`Services/EmbeddedScript.cs`. Credit to the original "remove ghost devices natively with
PowerShell" concept from theorypc.ca (2017) — see README Acknowledgements.
- **Script-backed System Tools actions are now data-driven**: they're declared in
`Scripts/scripts.json` and auto-register, so adding one is a drop-in — write a `.ps1`, add an
entry, no code change (see `Scripts/README.md`). The manifest only ever *names* a script embedded
in the binary plus a bare-identifier mode; it can't carry a command line, and it's loaded outside
the definitions merge path so a future remote-definitions rollout could never reach it. Built-in
Windows-tool actions (DISM, SFC, WMI reset, …) stay defined in code for the same reason.
- **Fixed a data-loss bug in the Activision/Call of Duty cleanup rule**: the old
`activision-crashes` entry treated every immediate child of `%LocalAppData%\Activision` —
including all of `Call of Duty`, which can hold `Call of Duty\players` (real user settings) —
as permanently-deletable junk. Replaced with two narrower entries: one scoped to
`Call of Duty` itself (now Recycle-Bin risk, not permanent, and excludes `players` via a new
`exclude` field on known-location rules), and one scoped to the bootstrapper's crash-reports
folder specifically.

## v1.0.1 — 2026-07-18 · Program Files installer

- **The installer is now a per-machine MSI** (`WinButler-win.msi`) that installs to
Expand Down
5 changes: 5 additions & 0 deletions Data/definitions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ deletion. Partial loads are never accepted — validate your JSON before committ
"mode": "children", // children | files | self
"pattern": "*.dmp", // files mode only: wildcard filter
"recursive": true, // files mode only: recurse subdirs
"exclude": ["players"], // children mode only: child names to always skip
"allDrives": false, // path is relative to every fixed drive root
"risk": "safe", // safe | caution | risky
"displayName": "Discord cache",
Expand All @@ -50,6 +51,10 @@ deletion. Partial loads are never accepted — validate your JSON before committ
```

- **`mode: children`** — every immediate child of `path` is a delete target; the directory itself survives.
Optionally set `exclude` (child *names*, case-insensitive, not full paths) to skip specific children
even though they'd otherwise match — use this when a folder mixes junk with data that must never be
offered (e.g. a game's crash-report folder that also holds a `players` settings subfolder). Ignored
outside `children` mode.
- **`mode: files`** — files under `path` matching `pattern` (optionally `recursive`) are targets.
- **`mode: self`** — `path` itself is the target (a specific junk folder or file).
- **`risk`** drives deletion policy: `safe` → deleted permanently; `caution`/`risky` → sent to the Recycle Bin and never auto-selected. Use `risky` for anything a user might miss (local edit history, package stores that are slow to rebuild).
Expand Down
3 changes: 2 additions & 1 deletion Data/definitions/games.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
{ "id": "arma3-reports", "path": "%LocalAppData%\\Arma 3", "mode": "files", "pattern": "*.rpt", "recursive": false, "risk": "safe", "displayName": "Arma 3 report logs", "description": "Arma 3 .rpt session logs", "group": "Games" },
{ "id": "arma3-dumps", "path": "%LocalAppData%\\Arma 3", "mode": "files", "pattern": "*.mdmp", "recursive": false, "risk": "safe", "displayName": "Arma 3 crash dumps", "description": "Arma 3 minidumps", "group": "Games" },
{ "id": "cod-mw-archive", "path": "%Documents%\\Call of Duty Modern Warfare\\archive", "mode": "children", "risk": "safe", "displayName": "CoD MW crash archive", "description": "Call of Duty crash archives", "group": "Games" },
{ "id": "activision-crashes", "path": "%LocalAppData%\\Activision", "mode": "children", "risk": "safe", "displayName": "Activision crash reports","description": "Activision crash-report folders", "group": "Games" },
{ "id": "activision-cod-crashes", "path": "%LocalAppData%\\Activision\\Call of Duty", "mode": "children", "exclude": ["players"], "risk": "caution", "displayName": "Call of Duty crash/report data", "description": "CoD crash/report folders under Activision (Recycle Bin, not permanent — the players settings folder is excluded, but the full junk-folder set under Call of Duty hasn't been verified against a real install)", "group": "Games" },
{ "id": "activision-bootstrapper-crashes","path": "%LocalAppData%\\Activision\\bootstrapper\\crash_reports","mode": "children", "risk": "safe", "displayName": "Activision bootstrapper crashes", "description": "Activision launcher bootstrapper crash reports", "group": "Games" },
{ "id": "bf2042-crashdumps", "path": "%Documents%\\Battlefield 2042\\CrashDumps", "mode": "children", "risk": "safe", "displayName": "Battlefield 2042 crash dumps","description": "BF2042 crash dumps", "group": "Games" },
{ "id": "bf6-crashdumps", "path": "%Documents%\\Battlefield 6\\CrashDumps", "mode": "children", "risk": "safe", "displayName": "Battlefield 6 crash dumps","description": "BF6 crash dumps", "group": "Games" },
{ "id": "bf4-twinkle", "path": "%Documents%\\Battlefield 4\\twinkle", "mode": "children", "risk": "safe", "displayName": "Battlefield 4 web assets","description": "In-game browser assets (~200 MB)", "group": "Games" },
Expand Down
5 changes: 5 additions & 0 deletions Models/RuleDefinitions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ public sealed class KnownLocationEntry
/// <summary><c>files</c> mode only: recurse into subdirectories (junctions are not followed).</summary>
public bool Recursive { get; set; }

/// <summary><c>children</c> mode only: child names (case-insensitive, not full paths) to skip
/// even though they'd otherwise match — an additional per-rule carve-out alongside the
/// deny-list, for a folder that mixes junk with data that must never be offered.</summary>
public List<string>? Exclude { get; set; }

/// <summary>When true, <see cref="Path"/> is resolved against every fixed drive root.</summary>
public bool AllDrives { get; set; }

Expand Down
44 changes: 44 additions & 0 deletions Models/ScriptActionEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Collections.Generic;

namespace WinButler.Models;

/// <summary>The parsed <c>Scripts/scripts.json</c> manifest — the script-backed half of the System
/// Tools catalog. See <c>Scripts/README.md</c> for the field reference.</summary>
public sealed class ScriptActionManifest
{
public List<ScriptActionEntry> Actions { get; set; } = new();
}

/// <summary>
/// One script-backed <see cref="SystemAction"/> declared in <c>Scripts/scripts.json</c>. This is
/// metadata plus a <em>reference</em> to an embedded script — deliberately never a command line, an
/// executable name, or raw PowerShell. <see cref="Script"/> must resolve to a <c>.ps1</c> embedded
/// from <c>Scripts/</c> and <see cref="Mode"/> must be a bare identifier, so the set of things this
/// manifest can execute is fixed at compile time. See <see cref="SystemCommand"/>'s note on why
/// executable commands are never data-driven.
/// </summary>
public sealed class ScriptActionEntry
{
/// <summary>Unique id within the manifest; also the key tests and logs refer to.</summary>
public string Id { get; set; } = "";

public string Name { get; set; } = "";
public string Description { get; set; } = "";

/// <summary>Extra caution shown in the confirm modal. Required unless <see cref="IsReadOnly"/>.</summary>
public string Warning { get; set; } = "";

/// <summary>File name of an embedded <c>Scripts/*.ps1</c> (e.g. "RemoveGhostDevices.ps1").</summary>
public string Script { get; set; } = "";

/// <summary>Optional bare identifier passed to the script as <c>$Mode</c>, letting one script
/// back several actions (e.g. a read-only "List" preview and the real "Remove").</summary>
public string? Mode { get; set; }

/// <summary>Read-only actions change nothing, so they run for real even in dry-run and never
/// prompt for confirmation.</summary>
public bool IsReadOnly { get; set; }

/// <summary>Groups the action under the UI's "Advanced" divider with the strongest warnings.</summary>
public bool IsAdvanced { get; set; }
}
7 changes: 5 additions & 2 deletions Models/SystemAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ namespace WinButler.Models;
/// <summary>One external command to run (file + arguments), the unit a <see cref="SystemAction"/>
/// executes. These are defined in code, never in the editable definitions JSON — executable
/// commands must not be data-driven.</summary>
public sealed record SystemCommand(string FileName, string Arguments)
public sealed record SystemCommand(string FileName, string Arguments, string? DisplayOverride = null)
{
public string Display => string.IsNullOrEmpty(Arguments) ? FileName : $"{FileName} {Arguments}";
/// <summary>What the dry-run preview and the runner's "> ..." line show. Defaults to
/// "FileName Arguments"; set <see cref="DisplayOverride"/> when Arguments isn't human-readable
/// (e.g. a base64 -EncodedCommand payload — see <see cref="Services.EmbeddedScript"/>).</summary>
public string Display => DisplayOverride ?? (string.IsNullOrEmpty(Arguments) ? FileName : $"{FileName} {Arguments}");
}

/// <summary>
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,16 @@ began life as a study of [FocusedWolf](https://www.reddit.com/user/FocusedWolf/)
comprehensive Windows cleanup batch script, which WinButler absorbed into its native,
rule-driven (and dry-run-guarded) form. Thank you!

The System Tools page's ghost-device removal is credited to the "remove ghost devices
natively with PowerShell" concept originally published at
[theorypc.ca](https://web.archive.org/web/2020/https://theorypc.ca/2017/06/28/remove-ghost-devices-natively-with-powershell/)
(2017) by TrententTye / Alexander Boersch — the live page now returns a 403, hence the
Wayback Machine link. An unofficial third-party fork with additional flags exists at
[github.com/istvans/scripts](https://github.com/istvans/scripts) (not the source of
WinButler's implementation). WinButler's version is a from-scratch reimplementation built
on `pnputil.exe`'s native device-management flags rather than the original's SetupAPI/CfgMgr32
P/Invoke approach, which predates those flags.

## License

MIT — see [LICENSE](LICENSE).
85 changes: 85 additions & 0 deletions Scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# WinButler scripts

PowerShell backing the System Tools page's script-based actions. Every `.ps1` here and
`scripts.json` are **embedded in the assembly** (`WinButler.csproj`) and run in memory — see
"Never written to disk" below.

## Adding a script

Two steps, no code change:

1. Drop `YourScript.ps1` in this folder.
2. Add an entry to `scripts.json`.

It auto-registers on the System Tools page next build (`Services/ScriptCatalog.cs`).

```json
{
"id": "my-action", // unique in this file; the key logs and tests use
"name": "Do the thing", // button row title
"description": "What it does.", // button row subtitle
"warning": "Why it's risky.", // shown in the confirm modal — REQUIRED unless isReadOnly
"script": "YourScript.ps1", // must be a .ps1 embedded from this folder
"mode": "Remove", // optional; assigned to $Mode before the script body
"isReadOnly": false, // true → runs even in dry-run, never prompts (changes nothing)
"isAdvanced": true // true → grouped under the "Advanced" divider
}
```

| Field | Rule |
|-------|------|
| `id` | Required, unique within this file. |
| `name`, `description` | Required, non-empty. |
| `warning` | **Required unless `isReadOnly`.** A destructive action must state its own risk — the confirm modal shows this. |
| `script` | Required. Must match `^[A-Za-z0-9._-]+\.ps1$` **and** resolve to a `.ps1` embedded from this folder. |
| `mode` | Optional. Must be a bare identifier (`^[A-Za-z][A-Za-z0-9]*$`). |
| `isReadOnly`, `isAdvanced` | Optional, default `false`. |

**`isReadOnly` means "changes nothing"**, not "is quick" — it makes the action bypass both the
dry-run guard and the confirm modal. Only set it on an action that genuinely cannot mutate anything.

### One script, several actions

Use `mode` to back several actions with one script — `RemoveGhostDevices.ps1` does this, exposing a
read-only `List` preview and the real `Remove`. Because both run the *same* classification code,
the preview cannot drift out of sync with what the destructive action actually does:

```powershell
if (-not $Mode) { $Mode = 'Remove' } # default when no mode is declared
```

## Never put commands in this JSON

`scripts.json` carries **metadata plus a reference to a script**. It must never contain a command
line, an executable name, or raw PowerShell. Two reasons, both load-bearing:

- **WinButler always runs elevated** (`requireAdministrator`). Anything expressible in data becomes
something that runs as administrator.
- Rule definitions under `Data/definitions/` can, by design, be overlaid at runtime from a remote
URL (`Services/Definitions/RemoteDefinitionSource.cs`, merged via `DefinitionsProvider.AddSource`
— currently unused, but the plumbing exists and is public). This manifest is deliberately loaded
by `ScriptCatalog` from its own embedded resource, **outside** that merge path, so it can never
be reached that way.

The validation above is what keeps that true: `script` must name something already compiled into
the binary, and `mode` is restricted to letters and digits so it cannot escape the `$Mode = '…'`
assignment it is interpolated into. The result is that this file can only ever select among scripts
that shipped with the app — it can never introduce new executable content.

See `Models/SystemAction.cs` for the same rule applied to the built-in Windows-tool actions (DISM,
SFC, `wevtutil`, …), which stay defined in C# for exactly this reason.

## Never written to disk

`Services/EmbeddedScript.cs` runs these via `powershell.exe -NoProfile -EncodedCommand <base64>`,
reading the script straight out of the assembly. It is never extracted to a temp file or to
`%APPDATA%`. Those locations are user-writable, so an unprivileged process could overwrite the
script between write and execute and have WinButler run it as administrator.

## Fail-closed

If `scripts.json` is missing, malformed, or **any** entry fails validation, the whole manifest is
rejected: zero script actions register and the error goes to `%APPDATA%\WinButler\logs\winbutler.log`.
It is all-or-nothing on purpose — a partial load could register a destructive action while dropping
the read-only preview that makes it safe to use. Built-in C# actions are unaffected, so the System
Tools page still works.
62 changes: 62 additions & 0 deletions Scripts/RemoveGhostDevices.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<#
Removes "ghost" (non-present) PnP devices -- device nodes Windows keeps around after the
underlying hardware is gone (uninstalled, unplugged, swapped) but never cleans up itself.

Concept credited to the "remove ghost devices natively with PowerShell" technique originally
published at theorypc.ca (2017) by TrententTye / Alexander Boersch. The live page now returns
HTTP 403; archived copy via the Wayback Machine. An unofficial third-party fork with
additional flags exists at github.com/istvans/scripts -- not the source of this script.

This is WinButler's own reimplementation: the original technique P/Invoked SetupAPI/CfgMgr32
directly because no built-in tool exposed ghost-device removal in 2017; pnputil.exe's
/enum-devices and /remove-device flags now do, so this uses those instead.

THIS IS DESTRUCTIVE AND HAS NO UNDO. pnputil's "/enum-devices /disconnected" reports every
PnP node Windows currently considers non-present -- verified against a real machine, that set
includes not just dead/removed peripherals but also live, currently-installed components that
are non-present for unrelated reasons: disk-drive PnP nodes for real mounted disks, a GPU's
integrated USB-C/HD-audio controller nodes, an integrated GPU sidelined by hybrid-graphics
switching, Volume Shadow Copy snapshot entries, and internal software/virtual device stubs
(MIDI service test loopbacks, Virtual HID Framework nodes). Removing any of those can
destabilize a running system. So this only ever removes an ALLOW-listed shape of instance ID
-- genuinely pluggable peripherals identified by vendor/product ID (USB\VID_*, HID\VID_*),
Bluetooth devices (BTH\*), and audio-endpoint stubs (SWD\MMDEVAPI\*) -- and explicitly still
skips USB root hubs even though they're USB\-rooted. Everything else found is left alone and
reported as skipped, never removed.
#>

# $Mode may already be set by a prelude the caller prepends (EmbeddedScript.RunCommand's
# `prelude` param) — "List" previews the exact same classification below without removing
# anything, so the read-only action is always an accurate preview of what "Remove" will do.
if (-not $Mode) { $Mode = 'Remove' }

function Test-SafeToRemove([string]$InstanceId) {
if ($InstanceId -like 'USB\ROOT_HUB*') { return $false }
if ($InstanceId -like 'USB\VID_*') { return $true }
if ($InstanceId -like 'HID\VID_*') { return $true }
if ($InstanceId -like 'BTH\*') { return $true }
if ($InstanceId -like 'SWD\MMDEVAPI\*') { return $true }
return $false
}

$raw = & pnputil.exe /enum-devices /disconnected
$ids = $raw | Select-String '^Instance ID:\s*(.+)$' |
ForEach-Object { $_.Matches[0].Groups[1].Value.Trim() }

if (-not $ids) {
Write-Output "No ghost devices found."
exit 0
}

foreach ($id in $ids) {
if (Test-SafeToRemove $id) {
if ($Mode -eq 'List') {
Write-Output "Ghost (would remove): $id"
} else {
Write-Output "Removing: $id"
& pnputil.exe /remove-device "$id"
}
} else {
Write-Output "Ghost (kept — not a recognised removable peripheral): $id"
}
}
Loading
Loading