remove-hidden-devices.ps1

<#PSScriptInfo

.VERSION 1.2.0

.GUID b7ca4884-b23d-4029-826b-34f4b5fc496b

.AUTHOR vadyaravadim

.COMPANYNAME

.COPYRIGHT

.TAGS Windows Windows10 Windows11 DeviceManager GhostDevices HiddenDevices Cleanup PnP Drivers Tweak

.LICENSEURI https://github.com/vadyaravadim/remove-hidden-devices/blob/main/LICENSE

.PROJECTURI https://github.com/vadyaravadim/remove-hidden-devices

.ICONURI

.EXTERNALMODULEDEPENDENCIES

.REQUIREDSCRIPTS

.EXTERNALSCRIPTDEPENDENCIES

.RELEASENOTES

.PRIVATEDATA

#>


<#
.SYNOPSIS
    Removes hidden / ghost devices (non-present hardware) from Device Manager on Windows 10/11.
.DESCRIPTION
    Enumerates devices whose driver is still registered but that are not
    currently present - the ghost entries left behind by unplugged USB sticks,
    headsets, dongles and old GPUs - and removes them so Device Manager reflects
    only hardware that is actually connected. Self-elevates via UAC. Zero
    external dependencies.
.PARAMETER Status
    List the hidden devices and change nothing. Does not need Administrator
    rights.
.NOTES
    Requirements: Windows 10/11. The script requests administrator rights on
    its own (UAC prompt).
.LINK
    https://github.com/vadyaravadim/remove-hidden-devices
#>

[CmdletBinding()]
param(
    [switch]$Status,
    [switch]$Elevated   # internal: set by the self-elevation relaunch
)

# Keep the self-elevated window open so the user can read the output.
function Wait-IfElevatedWindow {
    if ($Elevated) { Read-Host "Press Enter to close" | Out-Null }
}

# Without this, an unhandled error closes the self-elevated window before
# the user can read the message.
trap {
    Write-Host "ERROR: $_" -ForegroundColor Red
    Wait-IfElevatedWindow
    # Under `irm | iex` this runs inside the user's own session, where `exit`
    # would close their console - rethrow so only the piped script stops.
    if ($PSCommandPath) { exit 1 }
    break
}

# Launched via `irm <url> | iex` - no file on disk. Save the script to the
# user profile and rerun it from there (the rerun handles elevation).
if (-not $PSCommandPath) {
    # The piped text is not recoverable from inside iex ($MyInvocation there
    # holds the caller's command line, not the script body) - download the
    # script.
    try {
        $body = Invoke-RestMethod 'https://github.com/vadyaravadim/remove-hidden-devices/releases/latest/download/remove-hidden-devices.ps1' -TimeoutSec 30 -ErrorAction Stop
    } catch {
        Write-Host "ERROR: could not download the script ($($_.Exception.Message)). Check your internet connection, or save the script to a file and run it from there." -ForegroundColor Red
        return
    }
    $saved = Join-Path $env:USERPROFILE 'remove-hidden-devices.ps1'
    if ((Test-Path $saved) -and ([IO.File]::ReadAllText($saved) -cne $body)) {
        Copy-Item $saved "$saved.bak" -Force -ErrorAction Stop
        Write-Host "Existing $saved differs - previous copy kept as $saved.bak" -ForegroundColor Yellow
    }
    # UTF8Encoding($false) = no BOM: a BOM would break a later `irm | iex` of
    # the saved copy and violates the ASCII/no-BOM invariant the repo enforces.
    [IO.File]::WriteAllText($saved, $body, [Text.UTF8Encoding]::new($false))
    Write-Host "Script saved to: $saved" -ForegroundColor Cyan
    # @(): splatting a scalar string breaks powershell.exe -File switch binding on PS 5.1.
    $fwd = @(if ($Status) { '-Status' })
    powershell -NoProfile -ExecutionPolicy Bypass -File $saved @fwd
    # The rerun's exit code stays in $LASTEXITCODE for scripted callers.
    return
}

# Only now: under `irm | iex` the block above runs in the caller's own session,
# where Stop would stay behind in their console after the script is done.
$ErrorActionPreference = 'Stop'

# ---- Everything below -Status removes devices: Administrator required ----
$principal = New-Object Security.Principal.WindowsPrincipal(
    [Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $Status -and -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
    Write-Host "Not running as Administrator. Requesting elevation..." -ForegroundColor Yellow
    try {
        Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList @(
            '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"", '-Elevated')
    } catch {
        # Not always a refusal (UAC service disabled, ...) - show the real cause.
        Write-Host "ERROR: elevation failed ($($_.Exception.Message)). Run this script as Administrator." -ForegroundColor Red
        Read-Host "Press Enter to close" | Out-Null
    }
    return
}

# Read from this file's own PSScriptInfo block - the one place the version
# lives (release.yml stamps the tag into it). 0.0.0 is the committed
# placeholder: a clone or ZIP of main, not a release.
$version = [regex]::Match((Get-Content -LiteralPath $PSCommandPath -Raw), '(?m)^\.VERSION\s+(\S+)').Groups[1].Value
$version = if ($version -eq '0.0.0') { 'dev build' } else { "v$version" }

Write-Host ""
Write-Host "===================================" -ForegroundColor Cyan
Write-Host " REMOVE HIDDEN DEVICES $version" -ForegroundColor Cyan
Write-Host "===================================" -ForegroundColor Cyan
Write-Host ""

Write-Host "Scanning for hidden devices..."
# Not present = what Device Manager shows only under View > Show hidden devices.
$ghosts = @(Get-PnpDevice | Where-Object { -not $_.Present })
if (-not $ghosts) {
    Write-Host ""
    Write-Host "No hidden devices found." -ForegroundColor Green
    Wait-IfElevatedWindow
    return
}

Write-Host ""
Write-Host "Found $($ghosts.Count) hidden device(s):"
Write-Host ""
foreach ($d in $ghosts) {
    # Some ghosts carry no name; the instance ID still identifies them.
    $name = if ($d.FriendlyName) { $d.FriendlyName } else { $d.InstanceId }
    Write-Host (" -> {0}{1}" -f $name, $(if ($d.Class) { " [$($d.Class)]" }))
}
Write-Host ""

if ($Status) { Wait-IfElevatedWindow; return }

# Picked, not all-or-nothing: a ghost entry can carry settings worth keeping
# (a monitor's EDID override or color profile, a COM port number). Without a
# desktop (Server Core - ghost NICs after VM moves) there is no grid, so the
# all-or-nothing question stays as the fallback.
if (Get-Command Out-GridView -ErrorAction SilentlyContinue) {
    $targets = @($ghosts |
        ForEach-Object { [PSCustomObject]@{ Name = $(if ($_.FriendlyName) { $_.FriendlyName } else { $_.InstanceId }); Class = $_.Class; InstanceId = $_.InstanceId } } |
        Sort-Object Class, Name |
        Out-GridView -Title 'Select hidden devices to remove (Ctrl+A = all, Ctrl-click = several, Cancel = none)' -PassThru)
} else {
    Write-Host "==================================="
    $targets = if ((Read-Host "Remove these devices? (Y/N)") -match '^y') { $ghosts } else { @() }
}
if (-not $targets) {
    Write-Host ""
    Write-Host "Cancelled. No changes made." -ForegroundColor Yellow
    Wait-IfElevatedWindow
    return
}

Write-Host ""
Write-Host "Removing $($targets.Count) device(s)..."
$removed = 0
$needReboot = $false
foreach ($d in $targets) {
    pnputil /remove-device "$($d.InstanceId)"
    # 3010 = removed, and Windows needs a restart to finish (documented pnputil code).
    if ($LASTEXITCODE -in 0, 3010) { $removed++ }
    if ($LASTEXITCODE -eq 3010) { $needReboot = $true }
}

Write-Host ""
if ($removed -eq $targets.Count) {
    Write-Host "Removed $removed of $($targets.Count) device(s)." -ForegroundColor Green
} else {
    Write-Host "Removed $removed of $($targets.Count) device(s) - pnputil says why the rest failed above." -ForegroundColor Yellow
}
if ($needReboot) { Write-Host "Restart Windows to finish removing them." -ForegroundColor Yellow }
if ($removed) {
    Write-Host ""
    Write-Host "Useful? A star on GitHub helps others find it: https://github.com/vadyaravadim/remove-hidden-devices"
}
Wait-IfElevatedWindow