- Delete inline Invoke-DismWithProgress inside Invoke-WindowsUpdateRepair - Keep one global helper with integer-only % parsing (handles “4.9%”, “4,9%”)
480 lines
20 KiB
PowerShell
480 lines
20 KiB
PowerShell
# ------- Helpers -------
|
|
function Invoke-Remote {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Computer,
|
|
[Parameter(Mandatory)][scriptblock]$ScriptBlock,
|
|
[Parameter()][object[]]$ArgumentList
|
|
)
|
|
try {
|
|
return Invoke-Command -ComputerName $Computer -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ErrorAction Stop
|
|
} catch {
|
|
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
Write-Warning "Implicit auth to '$Computer' failed: $($_.Exception.Message)"
|
|
$cred = Get-Credential -UserName $currentUser -Message "Enter the password for $currentUser"
|
|
return Invoke-Command -ComputerName $Computer -Credential $cred -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ErrorAction Stop
|
|
}
|
|
}
|
|
|
|
function Remove-ProvisionedAndExisting {
|
|
<#
|
|
.SYNOPSIS
|
|
Preview + remove provisioned and installed Appx packages, locally or remotely.
|
|
.PARAMETER Pattern
|
|
-Like pattern, e.g. 'Microsoft.MSPaint*'
|
|
.PARAMETER Computer
|
|
If provided, runs on remote via Invoke-Remote; otherwise runs locally.
|
|
#>
|
|
param(
|
|
[Parameter(Mandatory)][string]$Pattern,
|
|
[string]$Computer
|
|
)
|
|
|
|
# Scriptblocks reused for both local and remote
|
|
$sbPreview = {
|
|
param($p)
|
|
$prov = Get-AppxProvisionedPackage -Online |
|
|
Where-Object DisplayName -Like $p |
|
|
Select-Object @{n='Type';e={'Provisioned'}}, DisplayName, PackageName
|
|
|
|
$inst = Get-AppxPackage -AllUsers |
|
|
Where-Object Name -Like $p |
|
|
Select-Object @{n='Type';e={'Installed'}}, Name, PackageFullName
|
|
|
|
[PSCustomObject]@{ Provisioned = $prov; Installed = $inst }
|
|
}
|
|
|
|
$sbRemove = {
|
|
param($p)
|
|
$errs = @()
|
|
|
|
# Remove from provisioned image (new profiles)
|
|
try {
|
|
Get-AppxProvisionedPackage -Online |
|
|
Where-Object DisplayName -Like $p |
|
|
Remove-AppxProvisionedPackage -Online -ErrorAction Stop | Out-Null
|
|
Write-Host "Removed provisioned packages matching '$p'."
|
|
} catch { $errs += "Provisioned removal: $($_.Exception.Message)" }
|
|
|
|
# Remove from existing users
|
|
try {
|
|
$pkgs = Get-AppxPackage -AllUsers | Where-Object Name -Like $p
|
|
foreach ($pkg in $pkgs) {
|
|
try {
|
|
Remove-AppxPackage -Package $pkg.PackageFullName -AllUsers -ErrorAction Stop
|
|
Write-Host "Removed installed package: $($pkg.PackageFullName)"
|
|
} catch {
|
|
# Fallback on older systems without -AllUsers support
|
|
try {
|
|
Get-AppxPackage -AllUsers | Where-Object Name -eq $pkg.Name |
|
|
ForEach-Object { Remove-AppxPackage -Package $_.PackageFullName -ErrorAction Stop }
|
|
Write-Host "Removed per-user instances of: $($pkg.Name)"
|
|
} catch {
|
|
$errs += "Installed removal ($($pkg.Name)): $($_.Exception.Message)"
|
|
}
|
|
}
|
|
}
|
|
} catch { $errs += "Installed discovery: $($_.Exception.Message)" }
|
|
|
|
if ($errs.Count) { Write-Warning ("Errors:`n - " + ($errs -join "`n - ")) }
|
|
else { Write-Host "Removal complete." -ForegroundColor Green }
|
|
}
|
|
|
|
$isLocal = [string]::IsNullOrWhiteSpace($Computer) -or $Computer -in @('localhost','127.0.0.1','.')
|
|
|
|
# --- Preview ---
|
|
if ($isLocal) {
|
|
$preview = & $sbPreview $Pattern
|
|
} else {
|
|
$preview = Invoke-Remote -Computer $Computer -ScriptBlock $sbPreview -ArgumentList $Pattern
|
|
}
|
|
|
|
$prov = $preview.Provisioned
|
|
$inst = $preview.Installed
|
|
|
|
if (-not $prov -and -not $inst) {
|
|
if ($isLocal) { $scope = "local machine" } else { $scope = $Computer }
|
|
Write-Host "No matches for '$Pattern' on $scope." -ForegroundColor Yellow
|
|
return
|
|
}
|
|
|
|
if ($prov) {
|
|
Write-Host "`nProvisioned packages matching '$Pattern':" -ForegroundColor Cyan
|
|
$prov | Format-Table -AutoSize
|
|
} else {
|
|
Write-Host "`nNo provisioned packages matched '$Pattern'."
|
|
}
|
|
|
|
if ($inst) {
|
|
Write-Host "`nInstalled packages (existing profiles) matching '$Pattern':" -ForegroundColor Cyan
|
|
$inst | Select-Object Name, PackageFullName | Format-Table -AutoSize
|
|
} else {
|
|
Write-Host "`nNo installed packages (existing profiles) matched '$Pattern'."
|
|
}
|
|
|
|
$confirm = Read-Host "`nProceed to remove from provisioned image AND existing profiles? (Y/N)"
|
|
if ($confirm -notin @('Y','y')) { Write-Host "Cancelled."; return }
|
|
|
|
# --- Remove ---
|
|
if ($isLocal) {
|
|
& $sbRemove $Pattern
|
|
} else {
|
|
Invoke-Remote -Computer $Computer -ScriptBlock $sbRemove -ArgumentList $Pattern | Out-Host
|
|
}
|
|
}
|
|
|
|
function Invoke-WindowsUpdateRepair {
|
|
param([string]$Computer)
|
|
|
|
$isLocal = [string]::IsNullOrWhiteSpace($Computer) -or $Computer -in @('localhost','127.0.0.1','.')
|
|
|
|
if ($isLocal) {
|
|
# ==== LOCAL run (with real progress) ====
|
|
function Test-Admin {
|
|
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$p = New-Object Security.Principal.WindowsPrincipal($id)
|
|
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
}
|
|
if (-not (Test-Admin)) { throw "You must run this elevated (Administrator)." }
|
|
|
|
Write-Host "=== Step 1: DISM (ScanHealth) ===" -ForegroundColor Cyan
|
|
$e1 = Invoke-DismWithProgress -Arguments @('/Online','/Cleanup-Image','/ScanHealth')
|
|
if ($e1 -ne 0) { throw "DISM ScanHealth failed with code $e1" }
|
|
|
|
Write-Host "`n=== Step 1b: DISM (RestoreHealth) ===" -ForegroundColor Cyan
|
|
$e2 = Invoke-DismWithProgress -Arguments @('/Online','/Cleanup-Image','/RestoreHealth','/NoRestart')
|
|
if ($e2 -ne 0) { throw "DISM RestoreHealth failed with code $e2" }
|
|
|
|
Write-Host "`n=== Step 2: SFC ===" -ForegroundColor Cyan
|
|
& sfc.exe /scannow | Out-Host
|
|
|
|
Write-Host "`n=== Step 3: Reset Windows Update components ===" -ForegroundColor Cyan
|
|
$services = 'wuauserv','bits','cryptsvc','msiserver'
|
|
foreach ($s in $services) { try { Stop-Service -Name $s -Force -ErrorAction Stop } catch { } }
|
|
|
|
$sd = Join-Path $env:SystemRoot 'SoftwareDistribution'
|
|
$cat = Join-Path $env:SystemRoot 'System32\catroot2'
|
|
$ts = Get-Date -Format 'yyyyMMdd_HHmmss'
|
|
if (Test-Path $sd) { try { Rename-Item -Path $sd -NewName ("SoftwareDistribution.bak_$ts") -ErrorAction Stop } catch { } }
|
|
if (Test-Path $cat) { try { Rename-Item -Path $cat -NewName ("catroot2.bak_$ts") -ErrorAction Stop } catch { } }
|
|
foreach ($s in $services) { try { Start-Service -Name $s -ErrorAction Stop } catch { } }
|
|
|
|
Write-Host "`n=== Step 4: Trigger update scan ===" -ForegroundColor Cyan
|
|
foreach ($cmd in @(
|
|
{ & usoclient.exe StartScan },
|
|
{ & usoclient.exe StartDownload },
|
|
{ & usoclient.exe StartInstall },
|
|
{ & wuauclt.exe /detectnow },
|
|
{ & wuauclt.exe /reportnow }
|
|
)) { try { & $cmd | Out-Null } catch { } }
|
|
|
|
Write-Host "`nWindows Update repair sequence completed." -ForegroundColor Green
|
|
return
|
|
}
|
|
|
|
# ==== REMOTE run ====
|
|
$sb = {
|
|
function Test-Admin {
|
|
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$p = New-Object Security.Principal.WindowsPrincipal($id)
|
|
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
}
|
|
if (-not (Test-Admin)) { throw "You must run this elevated (Administrator)." }
|
|
|
|
Write-Host "=== Step 1: DISM (ScanHealth) ==="
|
|
& dism.exe /Online /Cleanup-Image /ScanHealth | Out-Host
|
|
|
|
Write-Host "`n=== Step 1b: DISM (RestoreHealth) ==="
|
|
& dism.exe /Online /Cleanup-Image /RestoreHealth /NoRestart | Out-Host
|
|
|
|
Write-Host "`n=== Step 2: SFC ==="
|
|
& sfc.exe /scannow | Out-Host
|
|
|
|
Write-Host "`n=== Step 3: Reset Windows Update components ==="
|
|
$services = 'wuauserv','bits','cryptsvc','msiserver'
|
|
foreach ($s in $services) { try { Stop-Service -Name $s -Force -ErrorAction Stop } catch { } }
|
|
|
|
$sd = Join-Path $env:SystemRoot 'SoftwareDistribution'
|
|
$cat = Join-Path $env:SystemRoot 'System32\catroot2'
|
|
$ts = Get-Date -Format 'yyyyMMdd_HHmmss'
|
|
if (Test-Path $sd) { try { Rename-Item -Path $sd -NewName ("SoftwareDistribution.bak_$ts") -ErrorAction Stop } catch { } }
|
|
if (Test-Path $cat) { try { Rename-Item -Path $cat -NewName ("catroot2.bak_$ts") -ErrorAction Stop } catch { } }
|
|
foreach ($s in $services) { try { Start-Service -Name $s -ErrorAction Stop } catch { } }
|
|
|
|
Write-Host "`n=== Step 4: Trigger update scan ==="
|
|
foreach ($cmd in @(
|
|
{ & usoclient.exe StartScan },
|
|
{ & usoclient.exe StartDownload },
|
|
{ & usoclient.exe StartInstall },
|
|
{ & wuauclt.exe /detectnow },
|
|
{ & wuauclt.exe /reportnow }
|
|
)) { try { & $cmd | Out-Null } catch { } }
|
|
|
|
Write-Host "`nWindows Update repair sequence completed."
|
|
}
|
|
|
|
Invoke-Remote -Computer $Computer -ScriptBlock $sb
|
|
}
|
|
|
|
function Invoke-DismWithProgress {
|
|
<#
|
|
Runs DISM with a real progress bar.
|
|
Returns: DISM exit code (0 = success)
|
|
Note: Progress display is local-only (remoting doesn't surface Write-Progress well).
|
|
#>
|
|
param(
|
|
[Parameter(Mandatory)][string[]]$Arguments
|
|
)
|
|
|
|
$ts = Get-Date -Format 'yyyyMMdd_HHmmss'
|
|
$outf = Join-Path $env:TEMP "dism_out_$ts.txt"
|
|
$errf = Join-Path $env:TEMP "dism_err_$ts.txt"
|
|
|
|
if (-not ($Arguments -match '/LogPath:')) {
|
|
$logFile = Join-Path $env:TEMP "dism_log_$ts.log"
|
|
$Arguments += "/LogPath:$logFile"
|
|
}
|
|
|
|
$p = Start-Process -FilePath dism.exe `
|
|
-ArgumentList ($Arguments -join ' ') `
|
|
-NoNewWindow `
|
|
-RedirectStandardOutput $outf `
|
|
-RedirectStandardError $errf `
|
|
-PassThru
|
|
|
|
$lastPct = -1
|
|
while (-not $p.HasExited) {
|
|
if (Test-Path $outf) {
|
|
$content = Get-Content -Path $outf -Raw -ErrorAction SilentlyContinue
|
|
if ($content) {
|
|
# Match "NN", optional decimal with . or , then %, allowing whitespace
|
|
# Examples: "4.9%", "4,9 %", "62%"
|
|
$m = [regex]::Matches($content, '(\d{1,3})(?:[.,]\d+)?\s*%')
|
|
if ($m.Count -gt 0) {
|
|
$pctText = $m[$m.Count-1].Groups[1].Value # integer part only
|
|
$pct = 0
|
|
[void][int]::TryParse($pctText, [ref]$pct)
|
|
if ($pct -gt 100) { $pct = 100 }
|
|
if ($pct -ne $lastPct) {
|
|
Write-Progress -Activity "DISM $($Arguments -join ' ')" -Status "$pct%" -PercentComplete $pct
|
|
$lastPct = $pct
|
|
}
|
|
} else {
|
|
Write-Progress -Activity "DISM $($Arguments -join ' ')" -Status "Working..." -PercentComplete 0
|
|
}
|
|
}
|
|
}
|
|
Start-Sleep -Milliseconds 400
|
|
}
|
|
|
|
Write-Progress -Activity "DISM $($Arguments -join ' ')" -Completed
|
|
|
|
$exit = $p.ExitCode
|
|
if ($exit -ne 0) {
|
|
Write-Warning "DISM exited with code $exit"
|
|
if (Test-Path $errf) { Get-Content $errf -Tail 25 | Write-Warning }
|
|
}
|
|
return $exit
|
|
}
|
|
|
|
# ------- task functions (menu sections) -------
|
|
|
|
function Do-ConnectRemote {
|
|
$computer = Read-Host "Enter computer name (e.g. USERS-LAPTOP)"
|
|
if ([string]::IsNullOrWhiteSpace($computer)) { return }
|
|
try {
|
|
Enter-PSSession -ComputerName $computer
|
|
} catch {
|
|
Write-Warning "Implicit auth to '$computer' failed: $($_.Exception.Message)"
|
|
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
$cred = Get-Credential -UserName $currentUser -Message "Enter the password for $currentUser"
|
|
Enter-PSSession -ComputerName $computer -Credential $cred
|
|
}
|
|
}
|
|
|
|
function Do-RemoveAppxMenu {
|
|
# Choose Local or Remote first
|
|
$targetType = $null
|
|
$computer = $null
|
|
do {
|
|
Clear-Host
|
|
Write-Host "=== Remove Windows Apps ===" -ForegroundColor Cyan
|
|
Write-Host " 1) Local machine"
|
|
Write-Host " 2) Remote machine"
|
|
Write-Host " 0) Back`n"
|
|
$t = Read-Host "Choose target"
|
|
switch ($t) {
|
|
'1' { $targetType = 'Local' }
|
|
'2' { $targetType = 'Remote'; $computer = Read-Host "Enter remote computer name"; if ([string]::IsNullOrWhiteSpace($computer)) { $targetType = $null } }
|
|
'0' { return }
|
|
default { Write-Host "Invalid choice." -ForegroundColor Yellow; Start-Sleep -Milliseconds 800 }
|
|
}
|
|
} until ($targetType)
|
|
|
|
if ($targetType -eq 'Local') { $scopeLabel = 'local machine' } else { $scopeLabel = $computer }
|
|
|
|
# Presets
|
|
$presets = @(
|
|
@{ Label = 'Paint 3D'; Pattern = 'Microsoft.MSPaint*' }
|
|
@{ Label = '3D Viewer'; Pattern = 'Microsoft.Microsoft3DViewer*' }
|
|
@{ Label = 'Mail & Calendar'; Pattern = 'microsoft.windowscommunicationsapps*' }
|
|
@{ Label = 'Sticky Notes'; Pattern = 'Microsoft.MicrosoftStickyNotes*' }
|
|
@{ Label = 'Solitaire Collection'; Pattern = 'Microsoft.MicrosoftSolitaireCollection*' }
|
|
@{ Label = 'Xbox (Gaming App)'; Pattern = 'Microsoft.GamingApp*' }
|
|
@{ Label = 'Xbox Overlay/Identity'; Pattern = 'Microsoft.Xbox*' }
|
|
@{ Label = 'Clipchamp'; Pattern = 'Microsoft.Clipchamp*' }
|
|
@{ Label = 'Weather'; Pattern = 'Microsoft.BingWeather*' }
|
|
@{ Label = 'News'; Pattern = 'Microsoft.BingNews*' }
|
|
)
|
|
|
|
do {
|
|
Clear-Host
|
|
Write-Host "=== Remove Apps on $scopeLabel ===" -ForegroundColor Cyan
|
|
$i = 1
|
|
foreach ($p in $presets) { Write-Host (" {0,2}) {1} (pattern: {2})" -f $i, $p.Label, $p.Pattern); $i++ }
|
|
Write-Host (" {0,2}) {1}" -f $i, 'Custom pattern...')
|
|
Write-Host " 0) Back`n"
|
|
$sub = Read-Host "Choose an option"
|
|
|
|
if ($sub -eq '0') { break }
|
|
|
|
if ($sub -as [int]) {
|
|
$idx = [int]$sub
|
|
if ($idx -ge 1 -and $idx -le $presets.Count) {
|
|
$pat = $presets[$idx-1].Pattern
|
|
if ($targetType -eq 'Local') { Remove-ProvisionedAndExisting -Pattern $pat }
|
|
else { Remove-ProvisionedAndExisting -Pattern $pat -Computer $computer }
|
|
Read-Host "`nPress Enter to continue" | Out-Null
|
|
continue
|
|
} elseif ($idx -eq ($presets.Count + 1)) {
|
|
$pat = Read-Host "Enter -Like pattern (use * wildcards, e.g. Microsoft.Todos*)"
|
|
if ($pat) {
|
|
if ($targetType -eq 'Local') { Remove-ProvisionedAndExisting -Pattern $pat }
|
|
else { Remove-ProvisionedAndExisting -Pattern $pat -Computer $computer }
|
|
}
|
|
Read-Host "`nPress Enter to continue" | Out-Null
|
|
continue
|
|
}
|
|
}
|
|
|
|
Write-Host "Invalid choice." -ForegroundColor Yellow
|
|
Start-Sleep -Milliseconds 800
|
|
} while ($true)
|
|
}
|
|
|
|
function Do-RestartServiceMenu {
|
|
do {
|
|
Clear-Host
|
|
Write-Host "=== Restart a Service ===" -ForegroundColor Cyan
|
|
Write-Host " 1) Local machine"
|
|
Write-Host " 2) Remote machine"
|
|
Write-Host " 0) Back`n"
|
|
$opt = Read-Host "Choose an option"
|
|
|
|
switch ($opt) {
|
|
'1' {
|
|
$svc = Read-Host "Enter service name (e.g., Spooler)"
|
|
if ([string]::IsNullOrWhiteSpace($svc)) { break }
|
|
try {
|
|
Restart-Service -Name $svc -Force -ErrorAction Stop
|
|
Get-Service -Name $svc | Select-Object Status, Name, DisplayName | Format-Table -AutoSize
|
|
Write-Host "Local service restarted." -ForegroundColor Green
|
|
} catch {
|
|
Write-Warning "Local restart failed: $($_.Exception.Message)"
|
|
}
|
|
Read-Host "`nPress Enter to continue" | Out-Null
|
|
}
|
|
'2' {
|
|
$computer = Read-Host "Enter remote computer name"
|
|
if ([string]::IsNullOrWhiteSpace($computer)) { break }
|
|
$svc = Read-Host "Enter service name on $computer"
|
|
if ([string]::IsNullOrWhiteSpace($svc)) { break }
|
|
|
|
try {
|
|
$res = Invoke-Remote -Computer $computer -ScriptBlock {
|
|
param($name)
|
|
$null = Get-Service -Name $name -ErrorAction Stop
|
|
Restart-Service -Name $name -Force -ErrorAction Stop
|
|
Get-Service -Name $name | Select-Object Status, Name, DisplayName
|
|
} -ArgumentList $svc
|
|
|
|
if ($res) { $res | Format-Table -AutoSize }
|
|
Write-Host "Remote service restarted on $computer." -ForegroundColor Green
|
|
} catch {
|
|
Write-Warning "Remote restart failed on $($computer): $($_.Exception.Message)"
|
|
}
|
|
Read-Host "`nPress Enter to continue" | Out-Null
|
|
}
|
|
'0' { return }
|
|
default {
|
|
Write-Host "Invalid choice." -ForegroundColor Yellow
|
|
Start-Sleep -Milliseconds 800
|
|
}
|
|
}
|
|
} while ($true)
|
|
}
|
|
|
|
function do-wingetupgrades {
|
|
winget upgrade --all --silent --accept-source-agreements --accept-package-agreements
|
|
}
|
|
|
|
function do-winupdatefix {
|
|
do {
|
|
Clear-Host
|
|
Write-Host "=== Windows Update Repair ===" -ForegroundColor Cyan
|
|
Write-Host " 1) Local machine"
|
|
Write-Host " 2) Remote machine"
|
|
Write-Host " 0) Back`n"
|
|
$t = Read-Host "Choose target"
|
|
|
|
switch ($t) {
|
|
'1' {
|
|
$go = Read-Host "This will run DISM/SFC and reset WU components on THIS machine. Proceed? (Y/N)"
|
|
if ($go -in @('Y','y')) {
|
|
try { Invoke-WindowsUpdateRepair } catch { Write-Warning $_ }
|
|
}
|
|
Read-Host "`nPress Enter to continue" | Out-Null
|
|
}
|
|
'2' {
|
|
$comp = Read-Host "Enter remote computer name"
|
|
if ([string]::IsNullOrWhiteSpace($comp)) { break }
|
|
$go = Read-Host "Run repair on '$comp'? This requires admin on that machine. Proceed? (Y/N)"
|
|
if ($go -in @('Y','y')) {
|
|
try { Invoke-WindowsUpdateRepair -Computer $comp } catch { Write-Warning $_ }
|
|
}
|
|
Read-Host "`nPress Enter to continue" | Out-Null
|
|
}
|
|
'0' { return } # leave submenu back to main menu
|
|
default {
|
|
Write-Host "Invalid choice." -ForegroundColor Yellow
|
|
Start-Sleep -Milliseconds 800
|
|
}
|
|
}
|
|
} while ($true)
|
|
}
|
|
|
|
# ------- Main Menu -------
|
|
do {
|
|
Clear-Host
|
|
Write-Host "=== SRG Admin Menu ===" -ForegroundColor Cyan
|
|
Write-Host " 1) Connect to a machine (Enter-PSSession as current user)"
|
|
Write-Host " 2) Remove Windows apps (local or remote)"
|
|
Write-Host " 3) Restart a service (submenu)"
|
|
write-Host " 4) Run Winget Upgrades"
|
|
write-Host " 5) Run Windows Update Repair"
|
|
Write-Host " 0) Quit`n"
|
|
$choice = Read-Host "Choose an option"
|
|
|
|
switch ($choice) {
|
|
'1' { Do-ConnectRemote }
|
|
'2' { Do-RemoveAppxMenu }
|
|
'3' { Do-RestartServiceMenu }
|
|
'4' { do-wingetupgrades }
|
|
'5' { do-winupdatefix }
|
|
'0' { exit }
|
|
default {
|
|
Write-Host "Invalid choice." -ForegroundColor Yellow
|
|
Start-Sleep -Milliseconds 800
|
|
}
|
|
}
|
|
} while ($true)
|