diff --git a/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 b/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 new file mode 100644 index 00000000..c4c7e2b6 --- /dev/null +++ b/.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1 @@ -0,0 +1,569 @@ +# WinToolkit CI/CD V4.2.0 +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } +<# +Regression coverage for issue #189 without deleting Windows profiles. +Loads individual function definitions from the AST, never the tool or toolkit scripts. +Deletion commands are mocked; folder removal tests use empty TestDrive directories. +#> +param([string]$CompiledScriptPath) + +Describe 'Issue #189 safe regressions' { + BeforeAll { + $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path + $toolPath = Join-Path $script:RepoRoot 'tools\WinDeleteUserProfiles.ps1' + $localizationPath = Join-Path $script:RepoRoot 'wintoolkit-modules\10-Module.Localization.ps1' + $loggingPath = Join-Path $script:RepoRoot 'wintoolkit-modules\30-Module.Logging.ps1' + $processesPath = Join-Path $script:RepoRoot 'wintoolkit-modules\60-Module.Processes.ps1' + $uiPath = Join-Path $script:RepoRoot 'wintoolkit-modules\20-Module.UI.ps1' + + $definitions = @( + @{ Path = $toolPath; Name = 'New-ProfileRemovalSessionState' }, + @{ Path = $toolPath; Name = 'Receive-ProfileRemovalResult' }, + @{ Path = $toolPath; Name = 'Close-ProfileRemovalPowerShell' }, + @{ Path = $toolPath; Name = 'Remove-ProfileRegistryEntries' }, + @{ Path = $toolPath; Name = 'Remove-ResidualUserFolders' }, + @{ Path = $localizationPath; Name = 'Get-SourceTextLoc' }, + @{ Path = $loggingPath; Name = 'Write-ToolkitLog' }, + @{ Path = $processesPath; Name = 'Invoke-ExternalCommandWithLog' }, + @{ Path = $processesPath; Name = 'Remove-ItemSafely' }, + @{ Path = $uiPath; Name = 'Get-SpinnerChar' }, + @{ Path = $uiPath; Name = 'Clear-ProgressLine' }, + @{ Path = $uiPath; Name = 'Write-ProgressUpdate' } + ) + foreach ($definition in $definitions) { + $path = if ($CompiledScriptPath) { $CompiledScriptPath } else { $definition.Path } + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errors) + if ($errors) { throw "Invalid PowerShell syntax in $path" } + $function = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $definition.Name + }, $true) + if (-not $function) { throw "Missing helper $($definition.Name) in $path" } + . ([scriptblock]::Create($function.Extent.Text)) + } + + $workerSourcePath = if ($CompiledScriptPath) { $CompiledScriptPath } else { $toolPath } + $toolAst = [System.Management.Automation.Language.Parser]::ParseFile($workerSourcePath, [ref]$null, [ref]$null) + $batchFunction = $toolAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Invoke-ProfileRemovalBatch' + }, $true) + $workerAssignment = $batchFunction.Body.Find({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and $node.Left.Extent.Text -eq '$scriptBlock' + }, $true) + $workerExpression = $workerAssignment.Right.Find({ + param($node) + $node -is [System.Management.Automation.Language.ScriptBlockExpressionAst] + }, $true) + $script:ProfileRemovalWorker = $workerExpression.ScriptBlock.GetScriptBlock() + $script:ProfileRemovalBatchDefinition = $batchFunction.Extent.Text + $script:ProfileRemovalWorkerOffset = $workerAssignment.Extent.StartOffset - $batchFunction.Extent.StartOffset + $script:ProfileRemovalWorkerLength = $workerAssignment.Extent.EndOffset - $workerAssignment.Extent.StartOffset + $script:ProfileBatchResultReceiver = (Get-Command Receive-ProfileRemovalResult).ScriptBlock + + Import-LocalizedData -BindingVariable italianData -BaseDirectory (Join-Path $script:RepoRoot 'languages') -FileName 'WinToolkit.psd1' -UICulture 'it-IT' + Import-LocalizedData -BindingVariable englishData -BaseDirectory (Join-Path $script:RepoRoot 'languages') -FileName 'WinToolkit.psd1' -UICulture 'en-US' + $script:ItalianData = $italianData + $script:EnglishData = $englishData + $script:SavedGlobals = @{} + foreach ($name in @( + 'SourceTextLanguageData', 'SourceTextDefaultLanguageData', 'SourceTextKeyAliases', + 'CurrentLogFile', 'CurrentToolName', 'Spinners', 'GuiSessionActive' + )) { + $variable = Get-Variable -Name $name -Scope Global -ErrorAction SilentlyContinue + $script:SavedGlobals[$name] = @{ Exists = $null -ne $variable; Value = if ($variable) { $variable.Value } else { $null } } + } + + function Invoke-ProfileWorkerProbe { + param([scriptblock]$Probe, [int]$Count = 1) + + $sessionState = New-ProfileRemovalSessionState + $pool = [RunspaceFactory]::CreateRunspacePool(1, 2, $sessionState, $Host) + $jobs = @() + try { + $pool.Open() + for ($index = 0; $index -lt $Count; $index++) { + $powerShell = [PowerShell]::Create() + $powerShell.RunspacePool = $pool + [void]$powerShell.AddScript($Probe.ToString()) + $jobs += @{ PowerShell = $powerShell; Handle = $powerShell.BeginInvoke() } + } + foreach ($job in $jobs) { + $job.PowerShell.EndInvoke($job.Handle) + } + } + finally { + foreach ($job in $jobs) { $job.PowerShell.Dispose() } + $pool.Close() + $pool.Dispose() + } + } + } + + BeforeEach { + $Global:SourceTextLanguageData = $script:ItalianData.Clone() + $Global:SourceTextDefaultLanguageData = $script:EnglishData.Clone() + $Global:SourceTextKeyAliases = @{} + $Global:CurrentLogFile = Join-Path $TestDrive 'profile-worker.log' + $Global:CurrentToolName = 'Issue189Probe' + $Global:Spinners = '|/-\'.ToCharArray() + $Global:GuiSessionActive = $false + Set-Content -LiteralPath $Global:CurrentLogFile -Value '' -Encoding UTF8 + } + + AfterAll { + foreach ($name in $script:SavedGlobals.Keys) { + $saved = $script:SavedGlobals[$name] + if ($saved.Exists) { + Set-Variable -Name $name -Scope Global -Value $saved.Value + } + else { + Remove-Variable -Name $name -Scope Global -ErrorAction SilentlyContinue + } + } + } + + Context 'WinDeleteUserProfiles worker initialization without deletion' { + It 'provides localization, logging, and every worker helper to pooled runspaces' { + $results = @(Invoke-ProfileWorkerProbe -Count 4 -Probe { + $message = Get-SourceTextLoc 'toolText.registeredProfilesRemoved0' -Args @(0) + Write-ToolkitLog -Level INFO -Message $message -Context @{ Tool = $Global:CurrentToolName } + [pscustomobject]@{ + Message = $message + LogPath = $Global:CurrentLogFile + ToolName = $Global:CurrentToolName + Helpers = @( + 'Get-SourceTextLoc', 'Write-ToolkitLog', 'Invoke-ExternalCommandWithLog', + 'Remove-ItemSafely', 'Remove-ProfileRegistryEntries', 'Get-SpinnerChar', + 'Clear-ProgressLine', 'Write-ProgressUpdate' + ) | ForEach-Object { (Get-Command -Name $_ -CommandType Function -ErrorAction Stop).Name } + } + }) + + $results.Count | Should -Be 4 + foreach ($result in $results) { + $result.Message | Should -Be 'Profili registrati rimossi: 0' + $result.LogPath | Should -Be $Global:CurrentLogFile + $result.ToolName | Should -Be 'Issue189Probe' + $result.Helpers.Count | Should -Be 8 + } + $logLines = @(Get-Content -LiteralPath $Global:CurrentLogFile | Where-Object { $_ -match '\[INFO\]' }) + $logLines.Count | Should -Be 4 + $logLines | Should -Match 'Profili registrati rimossi: 0' + $logLines | Should -Match 'Issue189Probe' + } + + It 'keeps worker progress helpers silent while the parent owns the console' { + $result = Invoke-ProfileWorkerProbe -Probe { + Clear-ProgressLine + Write-ProgressUpdate -Activity 'Probe' -Percent 50 + [pscustomobject]@{ + Quiet = $Global:GuiSessionActive + ClearDefinition = (Get-Command Clear-ProgressLine).Definition + ProgressDefinition = (Get-Command Write-ProgressUpdate).Definition + } + } + $result.Quiet | Should -BeTrue + $result.ClearDefinition | Should -BeNullOrEmpty + $result.ProgressDefinition | Should -BeNullOrEmpty + $Global:GuiSessionActive | Should -BeFalse + } + + It 'passes aliases and the English fallback to workers' { + $Global:SourceTextKeyAliases['test.alias'] = 'toolText.registeredProfilesRemoved0' + $Global:SourceTextDefaultLanguageData['test.fallback'] = 'Fallback: {0}' + $result = Invoke-ProfileWorkerProbe -Probe { + [pscustomobject]@{ + Alias = Get-SourceTextLoc 'test.alias' -Args @(0) + Fallback = Get-SourceTextLoc 'test.fallback' -Args @(0) + } + } + $result.Alias | Should -Be 'Profili registrati rimossi: 0' + $result.Fallback | Should -Be 'Fallback: 0' + } + + It 'fails initialization when a required helper is unavailable' { + Mock Get-Command { throw 'Required helper unavailable' } -ParameterFilter { $Name -eq 'Remove-ItemSafely' } + { New-ProfileRemovalSessionState } | Should -Throw '*Required helper unavailable*' + } + } + + Context 'Profile result collection without deletion' { + It 'records a worker exception as one failed profile with its identity' { + $powerShell = [PowerShell]::Create() + [void]$powerShell.AddScript("throw 'Synthetic runspace failure'") + $job = [pscustomobject]@{ + PowerShell = $powerShell + Handle = $powerShell.BeginInvoke() + Profile = [pscustomobject]@{ LocalPath = 'C:\Users\Issue189Synthetic'; SID = 'S-1-5-21-189-1' } + } + $results = @(Receive-ProfileRemovalResult -Job $job) + + $results.Count | Should -Be 1 + $results[0].Type | Should -Be 'Profile' + $results[0].UserName | Should -Be 'Issue189Synthetic' + $results[0].Path | Should -Be $job.Profile.LocalPath + $results[0].Sid | Should -Be $job.Profile.SID + $results[0].Success | Should -BeFalse + @($results | Where-Object { -not $_.Success }).Count | Should -Be 1 + Get-Content -LiteralPath $Global:CurrentLogFile -Raw | Should -Match '\[ERROR\].*Synthetic runspace failure' + } + + It 'preserves a structured worker result without adding other output' -ForEach @( + @{ Success = $true }, @{ Success = $false } + ) { + $powerShell = [PowerShell]::Create() + [void]$powerShell.AddScript('param($success) [pscustomobject]@{ Type = "Profile"; Success = $success }').AddArgument($Success) + $job = [pscustomobject]@{ PowerShell = $powerShell; Handle = $powerShell.BeginInvoke(); Profile = $null } + $results = @(Receive-ProfileRemovalResult -Job $job) + $results.Count | Should -Be 1 + $results[0].Type | Should -Be 'Profile' + $results[0].Success | Should -Be $Success + } + } + + Context 'Folder removal failures with mocked Windows operations' { + BeforeEach { + $script:ProbeFolderPath = Join-Path $TestDrive 'Issue189[Probe]' + [void][System.IO.Directory]::CreateDirectory($script:ProbeFolderPath) + $script:ProbeProfile = [pscustomobject]@{ + LocalPath = $script:ProbeFolderPath + SID = 'S-1-5-21-189-1' + } + $script:RemovalAttempts = 0 + Mock Remove-CimInstance { throw 'Synthetic CIM removal failure' } + Mock Remove-ProfileRegistryEntries { $true } + Mock Invoke-ExternalCommandWithLog { [pscustomobject]@{ Success = $true; ExitCode = 0 } } + Mock Remove-ItemSafely { $false } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $env:TEMP 'EmptyFolder') } + Mock Clear-ProgressLine {} + Mock Write-ProgressUpdate {} + } + + It 'recovers with ACL reset when the first literal folder removal fails' { + Mock Remove-Item { + $script:RemovalAttempts++ + if ($script:RemovalAttempts -eq 1) { throw 'Synthetic access denied' } + if ($LiteralPath -ne $script:ProbeFolderPath) { throw 'Unexpected deletion target' } + [System.IO.Directory]::Delete($LiteralPath) + } + + $result = & $script:ProfileRemovalWorker $script:ProbeProfile + + $result.Success | Should -BeTrue + [System.IO.Directory]::Exists($script:ProbeFolderPath) | Should -BeFalse + Should -Invoke Remove-Item -Times 2 -Exactly -ParameterFilter { + $LiteralPath -eq $script:ProbeFolderPath -and $Recurse -and $Force -and $ErrorAction -eq 'Stop' + } + Should -Invoke Invoke-ExternalCommandWithLog -Times 1 -Exactly -ParameterFilter { $Command -eq 'takeown.exe' } + Should -Invoke Invoke-ExternalCommandWithLog -Times 1 -Exactly -ParameterFilter { + $Command -eq 'robocopy.exe' -and $Arguments -contains '/XJ' + } + Should -Invoke Invoke-ExternalCommandWithLog -Times 1 -Exactly -ParameterFilter { + $Command -eq 'icacls.exe' -and $Arguments -contains '*S-1-5-32-544:F' + } + Should -Invoke Remove-ProfileRegistryEntries -Times 1 -Exactly -ParameterFilter { $Sid -eq $script:ProbeProfile.SID } + Should -Invoke Remove-ItemSafely -Times 0 -Exactly + } + + It 'reports failure and preserves the registry when both folder removal attempts fail' { + Mock Remove-Item { throw 'Synthetic access denied' } + + $result = & $script:ProfileRemovalWorker $script:ProbeProfile + + $result.Success | Should -BeFalse + [System.IO.Directory]::Exists($script:ProbeFolderPath) | Should -BeTrue + Should -Invoke Remove-Item -Times 2 -Exactly + Should -Invoke Remove-ProfileRegistryEntries -Times 0 -Exactly + Get-Content -LiteralPath $Global:CurrentLogFile -Raw | Should -Match '\[ERROR\].*Synthetic access denied' + } + + It 'checks the remaining registry entry even when CIM reported success' { + Mock Remove-CimInstance {} + Mock Remove-ProfileRegistryEntries { $false } + Mock Remove-Item { + if ($LiteralPath -ne $script:ProbeFolderPath) { throw 'Unexpected deletion target' } + [System.IO.Directory]::Delete($LiteralPath) + } + + $result = & $script:ProfileRemovalWorker $script:ProbeProfile + + $result.Success | Should -BeFalse + [System.IO.Directory]::Exists($script:ProbeFolderPath) | Should -BeFalse + Should -Invoke Remove-ProfileRegistryEntries -Times 1 -Exactly + } + + It 'retries a residual folder using its literal path after ACL reset' { + Mock Remove-Item { + $script:RemovalAttempts++ + if ($script:RemovalAttempts -eq 1) { throw 'Synthetic access denied' } + if ($LiteralPath -ne $script:ProbeFolderPath) { throw 'Unexpected deletion target' } + [System.IO.Directory]::Delete($LiteralPath) + } + $folder = [pscustomobject]@{ Name = 'Issue189[Probe]'; Path = $script:ProbeFolderPath } + + $result = Remove-ResidualUserFolders -Folders @($folder) + + $result.Success | Should -BeTrue + Should -Invoke Remove-Item -Times 2 -Exactly -ParameterFilter { + $LiteralPath -eq $script:ProbeFolderPath -and $Recurse -and $Force -and $ErrorAction -eq 'Stop' + } + Should -Invoke Invoke-ExternalCommandWithLog -Times 1 -Exactly -ParameterFilter { + $Command -eq 'icacls.exe' -and $Arguments -contains '*S-1-5-32-544:F' + } + } + + It 'reports a residual folder failure when ACL recovery cannot remove it' { + Mock Remove-Item { throw 'Synthetic access denied' } + $folder = [pscustomobject]@{ Name = 'Issue189[Probe]'; Path = $script:ProbeFolderPath } + + $result = Remove-ResidualUserFolders -Folders @($folder) + + $result.Success | Should -BeFalse + [System.IO.Directory]::Exists($script:ProbeFolderPath) | Should -BeTrue + Should -Invoke Remove-Item -Times 2 -Exactly + Get-Content -LiteralPath $Global:CurrentLogFile -Raw | Should -Match '\[ERROR\].*Synthetic access denied' + } + } + + Context 'Localized arguments with zero or empty values' { + It 'formats the argument' -ForEach @( + @{ Name = 'zero'; Value = 0; Expected = 'Value: 0' }, + @{ Name = 'false'; Value = $false; Expected = 'Value: False' }, + @{ Name = 'empty string'; Value = ''; Expected = 'Value: ' }, + @{ Name = 'null'; Value = $null; Expected = 'Value: ' }, + @{ Name = 'nonzero'; Value = 2; Expected = 'Value: 2' } + ) { + $Global:SourceTextLanguageData['test.argument'] = 'Value: {0}' + Get-SourceTextLoc 'test.argument' -Args @($Value) | Should -Be $Expected + } + + It 'leaves the template intact when no arguments are supplied' { + $Global:SourceTextLanguageData['test.argument'] = 'Value: {0}' + Get-SourceTextLoc 'test.argument' | Should -Be 'Value: {0}' + } + + It 'prints both Italian zero counts in the cleanup summary' { + Get-SourceTextLoc 'toolText.registeredProfilesRemoved0' -Args @(0) | Should -Be 'Profili registrati rimossi: 0' + Get-SourceTextLoc 'toolText.residualFoldersRemoved0' -Args @(0) | Should -Be 'Cartelle residue rimosse: 0' + } + } + + Context 'Bounded profile scheduling with synthetic workers' { + BeforeAll { + $syntheticWorker = @' +$scriptBlock = { + param($ProfileItem) + Start-Sleep -Milliseconds $ProfileItem.DelayMilliseconds + if ($ProfileItem.Fail) { throw 'Synthetic bounded-worker failure' } + [pscustomobject]@{ + Type = 'Profile' + UserName = $ProfileItem.Name + Success = $true + Duration = [TimeSpan]::Zero + } +} +'@ + $probeSource = $script:ProfileRemovalBatchDefinition.Remove( + $script:ProfileRemovalWorkerOffset, $script:ProfileRemovalWorkerLength + ).Insert($script:ProfileRemovalWorkerOffset, $syntheticWorker) + $probeSource = $probeSource.Replace('$ps = [PowerShell]::Create()', '$ps = New-TrackedProfilePowerShell') + $probeSource = $probeSource.Replace('$handle = $ps.BeginInvoke()', '$handle = Invoke-ProfileProbeBeginInvoke -PowerShell $ps') + if ($probeSource -match '\b(Remove-CimInstance|Remove-Item|Invoke-ExternalCommandWithLog)\b') { + throw 'A destructive command remained in the scheduling probe.' + } + . ([scriptblock]::Create($probeSource)) + + function New-TrackedProfilePowerShell { + $outstanding = $script:BatchCreatedPowerShells.Count - $script:BatchCollectedCount + 1 + $script:BatchPeakOutstanding = [Math]::Max($script:BatchPeakOutstanding, $outstanding) + $powerShell = [PowerShell]::Create() + $script:BatchCreatedPowerShells.Add($powerShell) + return $powerShell + } + + function Invoke-ProfileProbeBeginInvoke { + param($PowerShell) + $PowerShell.BeginInvoke() + } + + function Assert-ProfileProbePowerShellDisposed { + param($PowerShell) + $disposed = $false + try { [void]$PowerShell.AddScript('$null') } + catch { $disposed = $_.Exception.InnerException -is [System.ObjectDisposedException] } + $disposed | Should -BeTrue + } + } + + BeforeEach { + $script:BatchCreatedPowerShells = [System.Collections.Generic.List[object]]::new() + $script:BatchCollectedCount = 0 + $script:BatchPeakOutstanding = 0 + $script:BatchReceivedNames = [System.Collections.Generic.List[string]]::new() + $script:BatchProgress = [System.Collections.Generic.List[int]]::new() + Mock New-ProfileRemovalSessionState { [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() } + Mock Write-ProgressUpdate { $script:BatchProgress.Add($Percent) } + Mock Clear-ProgressLine {} + Mock Receive-ProfileRemovalResult { + $script:BatchReceivedNames.Add([System.IO.Path]::GetFileName($Job.Profile.LocalPath)) + $result = & $script:ProfileBatchResultReceiver -Job $Job + $script:BatchCollectedCount++ + $result + } + } + + AfterEach { + foreach ($powerShell in $script:BatchCreatedPowerShells) { $powerShell.Dispose() } + } + + It 'limits profiles to outstanding jobs and preserves every result' -ForEach @( + @{ Count = 3; Threads = 1 }, + @{ Count = 32; Threads = 2 }, + @{ Count = 256; Threads = 4 } + ) { + $maxThreadsEffective = $Threads + $profiles = @(for ($index = 0; $index -lt $Count; $index++) { + [pscustomobject]@{ + Name = "Synthetic$index" + LocalPath = "C:\Users\Synthetic$index" + SID = "S-1-5-21-189-$index" + DelayMilliseconds = 1 + Fail = $false + } + }) + + $results = @(Invoke-ProfileRemovalBatch -Profiles $profiles) + + $script:BatchPeakOutstanding | Should -BeLessOrEqual $Threads + $script:BatchCreatedPowerShells.Count | Should -Be $Count + $script:BatchCollectedCount | Should -Be $Count + $results.Count | Should -Be $Count + @($results | Where-Object { -not $_.Success }).Count | Should -Be 0 + for ($index = 0; $index -lt $Count; $index++) { + $results[$index].UserName | Should -Be $profiles[$index].Name + } + $script:BatchProgress[-1] | Should -Be 100 + foreach ($powerShell in $script:BatchCreatedPowerShells) { + Assert-ProfileProbePowerShellDisposed -PowerShell $powerShell + } + } + + It 'returns input order when a later worker finishes first' { + $maxThreadsEffective = 2 + $profiles = @( + [pscustomobject]@{ Name = 'Slow'; LocalPath = 'C:\Users\Slow'; SID = 'slow'; DelayMilliseconds = 250; Fail = $false }, + [pscustomobject]@{ Name = 'Fast'; LocalPath = 'C:\Users\Fast'; SID = 'fast'; DelayMilliseconds = 1; Fail = $false } + ) + + $results = @(Invoke-ProfileRemovalBatch -Profiles $profiles) + + $script:BatchReceivedNames[0] | Should -Be 'Fast' + $results[0].UserName | Should -Be 'Slow' + $results[1].UserName | Should -Be 'Fast' + } + + It 'retains one failed result while scheduling replacements' { + $maxThreadsEffective = 1 + $profiles = @( + [pscustomobject]@{ Name = 'Failed'; LocalPath = 'C:\Users\Failed'; SID = 'failed'; DelayMilliseconds = 1; Fail = $true }, + [pscustomobject]@{ Name = 'Next'; LocalPath = 'C:\Users\Next'; SID = 'next'; DelayMilliseconds = 1; Fail = $false } + ) + + $results = @(Invoke-ProfileRemovalBatch -Profiles $profiles) + + $script:BatchPeakOutstanding | Should -Be 1 + $results.Count | Should -Be 2 + $results[0].UserName | Should -Be 'Failed' + $results[0].Success | Should -BeFalse + $results[1].UserName | Should -Be 'Next' + $results[1].Success | Should -BeTrue + $script:BatchCollectedCount | Should -Be 2 + } + + It 'disposes every outstanding instance when progress reporting fails' { + $maxThreadsEffective = 2 + $profiles = @( + [pscustomobject]@{ Name = 'First'; LocalPath = 'C:\Users\First'; SID = 'first'; DelayMilliseconds = 1000; Fail = $false }, + [pscustomobject]@{ Name = 'Second'; LocalPath = 'C:\Users\Second'; SID = 'second'; DelayMilliseconds = 1000; Fail = $false } + ) + Mock Write-ProgressUpdate { throw 'Synthetic progress failure' } + + { Invoke-ProfileRemovalBatch -Profiles $profiles } | Should -Throw '*Synthetic progress failure*' + + $script:BatchCreatedPowerShells.Count | Should -Be 2 + foreach ($powerShell in $script:BatchCreatedPowerShells) { + Assert-ProfileProbePowerShellDisposed -PowerShell $powerShell + } + } + + It 'disposes an instance when startup fails before job registration' { + $maxThreadsEffective = 1 + $profiles = @([pscustomobject]@{ + Name = 'Startup'; LocalPath = 'C:\Users\Startup'; SID = 'startup'; DelayMilliseconds = 1; Fail = $false + }) + Mock Invoke-ProfileProbeBeginInvoke { throw 'Synthetic startup failure' } + + { Invoke-ProfileRemovalBatch -Profiles $profiles } | Should -Throw '*Synthetic startup failure*' + + $script:BatchCreatedPowerShells.Count | Should -Be 1 + Assert-ProfileProbePowerShellDisposed -PowerShell $script:BatchCreatedPowerShells[0] + } + + It 'disposes remaining instances when collection fails after one instance was disposed' { + $maxThreadsEffective = 2 + $profiles = @( + [pscustomobject]@{ Name = 'Collected'; LocalPath = 'C:\Users\Collected'; SID = 'collected'; DelayMilliseconds = 1; Fail = $false }, + [pscustomobject]@{ Name = 'Remaining'; LocalPath = 'C:\Users\Remaining'; SID = 'remaining'; DelayMilliseconds = 1000; Fail = $false } + ) + Mock Receive-ProfileRemovalResult { + & $script:ProfileBatchResultReceiver -Job $Job | Out-Null + $script:BatchCollectedCount++ + throw 'Synthetic collection failure' + } + + { Invoke-ProfileRemovalBatch -Profiles $profiles } | Should -Throw '*Synthetic collection failure*' + + $script:BatchCollectedCount | Should -Be 1 + foreach ($powerShell in $script:BatchCreatedPowerShells) { + Assert-ProfileProbePowerShellDisposed -PowerShell $powerShell + } + } + + It 'still disposes an instance when stopping it fails' { + $powerShell = [pscustomobject]@{ Disposed = $false } + $powerShell | Add-Member -MemberType ScriptMethod -Name Stop -Value { throw 'Synthetic stop failure' } + $powerShell | Add-Member -MemberType ScriptMethod -Name Dispose -Value { $this.Disposed = $true } + Mock Write-Warning {} + + { Close-ProfileRemovalPowerShell -PowerShell $powerShell } | Should -Not -Throw + + $powerShell.Disposed | Should -BeTrue + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { + $Message -like '*Synthetic stop failure*' -and $WarningAction -eq 'Continue' + } + } + + It 'continues cleanup after a disposal failure and reports the failure' { + $failedPowerShell = [pscustomobject]@{} + $failedPowerShell | Add-Member -MemberType ScriptMethod -Name Stop -Value {} + $failedPowerShell | Add-Member -MemberType ScriptMethod -Name Dispose -Value { throw 'Synthetic dispose failure' } + $nextPowerShell = [PowerShell]::Create() + $script:BatchCreatedPowerShells.Add($nextPowerShell) + Mock Write-Warning {} + + { + foreach ($powerShell in @($failedPowerShell, $nextPowerShell)) { + Close-ProfileRemovalPowerShell -PowerShell $powerShell + } + } | Should -Not -Throw + + Assert-ProfileProbePowerShellDisposed -PowerShell $nextPowerShell + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { + $Message -like '*Synthetic dispose failure*' -and $WarningAction -eq 'Continue' + } + } + } +} diff --git a/.github/workflows/_reusable-lint-test.yml b/.github/workflows/_reusable-lint-test.yml index 3695c765..7d35bad4 100644 --- a/.github/workflows/_reusable-lint-test.yml +++ b/.github/workflows/_reusable-lint-test.yml @@ -289,7 +289,8 @@ jobs: # loading them there is not a missing test, it is a suite that cannot run. $requiredFolders = @( @{ Path = '.github/tests/CI-Smoke.Tests.ps1'; Root = 'wintoolkit-modules' }, - @{ Path = '.github/tests/Unit/Tools.Contract.Tests.ps1'; Root = 'tools' } + @{ Path = '.github/tests/Unit/Tools.Contract.Tests.ps1'; Root = 'tools' }, + @{ Path = '.github/tests/Unit/WinDeleteUserProfiles.Tests.ps1'; Root = 'tools' } ) $suitePaths = @( $requiredFolders | diff --git a/WinToolkit.ps1 b/WinToolkit.ps1 index 136f1c95..9a8db596 100644 --- a/WinToolkit.ps1 +++ b/WinToolkit.ps1 @@ -238,7 +238,7 @@ function Get-SourceTextLoc { } } if ($null -eq $value) { $value = $Key } - if ($Arguments -and $Arguments.Count -gt 0) { return [string]::Format($value, $Arguments) } + if ($null -ne $Arguments -and $Arguments.Count -gt 0) { return [string]::Format($value, $Arguments) } return $value } function Format-SourceText { @@ -3916,6 +3916,7 @@ function DisableBitlocker { Write-ToolkitLog -Level INFO -Message (Get-SourceTextLoc 'toolText.disablebitlockerSessionEnded') } } +function WinDeleteUserProfiles { function WinDeleteUserProfiles { [CmdletBinding()] param( @@ -3961,7 +3962,7 @@ function WinDeleteUserProfiles { function New-ProtectedNameSet { $excluded = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $protectedProfileNames | ForEach-Object { [void]$excluded.Add($_) } - return ,$excluded + return , $excluded } function Remove-ProfileRegistryEntries { param( @@ -3970,9 +3971,13 @@ function WinDeleteUserProfiles { [Parameter(Mandatory = $true)] [string]$UserName ) - if ([string]::IsNullOrWhiteSpace($Sid) -or $Sid -eq 'NULL') { return $false } + if ([string]::IsNullOrWhiteSpace($Sid) -or $Sid -eq 'NULL') { + return $false + } $profileRegKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\' + $Sid - if (-not (Test-Path -LiteralPath $profileRegKey)) { return $false } + if (-not (Test-Path -LiteralPath $profileRegKey)) { + return $true + } try { Remove-Item -LiteralPath $profileRegKey -Recurse -Force -ErrorAction Stop Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.registryEntryRemovedForSid01' -Args @($UserName, $Sid)) @@ -3990,22 +3995,22 @@ function WinDeleteUserProfiles { } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.cimProfileDetectionFailed0' -Args @($($_.Exception.Message))) - return ,$pathSet + return , $pathSet } $cimProfiles | - Where-Object { - $_.LocalPath -and - $_.LocalPath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase) - } | - ForEach-Object { - try { - [void]$pathSet.Add([System.IO.Path]::GetFullPath($_.LocalPath).TrimEnd('\')) - } - catch { - Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.failedToNormalizeRegisteredProfileLocalpath0' -Args @($($_.LocalPath))) - } + Where-Object { + $_.LocalPath -and + $_.LocalPath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase) + } | + ForEach-Object { + try { + [void]$pathSet.Add([System.IO.Path]::GetFullPath($_.LocalPath).TrimEnd('\')) + } + catch { + Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.failedToNormalizeRegisteredProfileLocalpath0' -Args @($($_.LocalPath))) } - return ,$pathSet + } + return , $pathSet } function Get-RemovableUserProfiles { $excluded = New-ProtectedNameSet @@ -4025,24 +4030,107 @@ function WinDeleteUserProfiles { $_.LocalPath -and $_.LocalPath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase) } - foreach ($profile in $profiles) { - $profileName = [System.IO.Path]::GetFileName($profile.LocalPath) + foreach ($profileItem in $profiles) { + $profileName = [System.IO.Path]::GetFileName($profileItem.LocalPath) if ($excluded.Contains($profileName)) { - Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfile01' -Args @($profileName, $($profile.LocalPath))) + Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfile01' -Args @($profileName, $($profileItem.LocalPath))) continue } - if ($profile.SID -eq $currentUserSid) { - Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfileBecauseSidMatchesCurrentUser0' -Args @($profileName, $profile.SID)) + if ($profileItem.SID -eq $currentUserSid) { + Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfileBecauseSidMatchesCurrentUser0' -Args @($profileName, $profileItem.SID)) continue } - if ($minimumLastUseDate -and $profile.LastUseTime) { - $lastUse = $profile.LastUseTime + if ($minimumLastUseDate -and $profileItem.LastUseTime) { + $lastUse = $profileItem.LastUseTime if ($lastUse -gt $minimumLastUseDate) { Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.profileExcludedDueToTimeThreshold0LastUse1' -Args @($profileName, $lastUse)) continue } } - $profile + $profileItem + } + } + function New-ProfileRemovalSessionState { + $sessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + foreach ($functionName in @( + 'Get-SourceTextLoc', + 'Write-ToolkitLog', + 'Invoke-ExternalCommandWithLog', + 'Remove-ItemSafely', + 'Remove-ProfileRegistryEntries', + 'Get-SpinnerChar' + )) { + $command = Get-Command -Name $functionName -CommandType Function -ErrorAction Stop + $sessionState.Commands.Add( + [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new($functionName, $command.Definition) + ) + } + foreach ($functionName in @('Clear-ProgressLine', 'Write-ProgressUpdate')) { + $sessionState.Commands.Add( + [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new($functionName, '') + ) + } + foreach ($variableName in @( + 'SourceTextLanguageData', + 'SourceTextDefaultLanguageData', + 'SourceTextKeyAliases', + 'CurrentLogFile', + 'CurrentToolName', + 'Spinners' + )) { + $value = Get-Variable -Name $variableName -Scope Global -ValueOnly -ErrorAction Stop + $sessionState.Variables.Add( + [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new($variableName, $value, '') + ) + } + $sessionState.Variables.Add( + [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new('GuiSessionActive', $true, '') + ) + return $sessionState + } + function Receive-ProfileRemovalResult { + param( + [Parameter(Mandatory = $true)] + [object]$Job + ) + try { + $Job.PowerShell.EndInvoke($Job.Handle) + } + catch { + Write-ToolkitLog -Level 'ERROR' -Message (Get-SourceTextLoc 'toolText.runspaceError0' -Args @($($_.Exception.Message))) + [PSCustomObject]@{ + Type = 'Profile' + UserName = [System.IO.Path]::GetFileName($Job.Profile.LocalPath) + Path = $Job.Profile.LocalPath + Sid = $Job.Profile.SID + Success = $false + Duration = [TimeSpan]::Zero + } + } + finally { + $Job.PowerShell.Commands.Clear() + $Job.PowerShell.Dispose() + } + } + function Close-ProfileRemovalPowerShell { + param( + [Parameter(Mandatory = $true)] + [object]$PowerShell + ) + try { + $PowerShell.Stop() + } + catch [System.ObjectDisposedException] {} + catch { + Write-Warning -Message $_.Exception.Message -WarningAction Continue + } + finally { + try { + $PowerShell.Dispose() + } + catch { + Write-Warning -Message $_.Exception.Message -WarningAction Continue + } } } function Invoke-ProfileRemovalBatch { @@ -4050,22 +4138,21 @@ function WinDeleteUserProfiles { [Parameter(Mandatory = $true)] [array]$Profiles ) - $pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreadsEffective) - $pool.Open() + $sessionState = New-ProfileRemovalSessionState + $pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreadsEffective, $sessionState, $Host) $jobs = [System.Collections.Generic.List[object]]::new() + $pendingPowerShell = $null $scriptBlock = { - param($Profile) + param($ProfileItem) $ErrorActionPreference = 'Stop' - $userPath = $Profile.LocalPath + $userPath = $ProfileItem.LocalPath $userName = [System.IO.Path]::GetFileName($userPath) - $userSid = $Profile.SID + $userSid = $ProfileItem.SID $start = Get-Date Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.startResidualFolder01' -Args @($userName, $userPath)) - $cimSuccess = $false try { - Remove-CimInstance -InputObject $Profile -ErrorAction Stop -Confirm:$false + Remove-CimInstance -InputObject $ProfileItem -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.cimProfileRemoved0' -Args @($userName)) - $cimSuccess = $true } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.cimRemoveFailed01' -Args @($userName, $($_.Exception.Message))) @@ -4077,17 +4164,17 @@ function WinDeleteUserProfiles { New-Item -ItemType Directory -Path $tempEmpty | Out-Null } Invoke-ExternalCommandWithLog -Command 'robocopy.exe' ` - -Arguments @("`"$tempEmpty`"", "`"$userPath`"", '/MIR', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP') ` + -Arguments @("`"$tempEmpty`"", "`"$userPath`"", '/MIR', '/XJ', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP') ` -LogContextKey "ProfileCleanup-Robocopy-$userName" | Out-Null - Remove-ItemSafely -Path $userPath -Recurse + Remove-Item -LiteralPath $userPath -Recurse -Force -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.folderRemoved0' -Args @($userName)) } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.standardFolderCleanupFailed01' -Args @($userName, $($_.Exception.Message))) try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$userPath`"", '/R', '/D', 'Y') -LogContextKey "ProfileCleanup-TakeOwn-$userName" | Out-Null - Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$userPath`"", '/grant', 'Administrators:F', '/T', '/C') -LogContextKey "ProfileCleanup-Icacls-$userName" | Out-Null - Remove-ItemSafely -Path $userPath -Recurse + Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$userPath`"", '/grant', '*S-1-5-32-544:F', '/T', '/C') -LogContextKey "ProfileCleanup-Icacls-$userName" | Out-Null + Remove-Item -LiteralPath $userPath -Recurse -Force -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.folderRemovedAfterAclReset0' -Args @($userName)) } catch { @@ -4096,10 +4183,11 @@ function WinDeleteUserProfiles { } } $folderGone = -not [System.IO.Directory]::Exists($userPath) - if (-not $cimSuccess -and $folderGone -and $userSid) { - Remove-ProfileRegistryEntries -Sid $userSid -UserName $userName + $registrySuccess = $false + if ($folderGone -and $userSid) { + $registrySuccess = Remove-ProfileRegistryEntries -Sid $userSid -UserName $userName } - $success = $folderGone + $success = $folderGone -and $registrySuccess $duration = New-TimeSpan -Start $start -End (Get-Date) if ($success) { Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.completedProfile01' -Args @($userName, $duration.ToString())) @@ -4117,48 +4205,66 @@ function WinDeleteUserProfiles { } } try { - foreach ($profile in $Profiles) { - $ps = [PowerShell]::Create() - $ps.RunspacePool = $pool - [void]$ps.AddScript($scriptBlock, $true). - AddArgument($profile) - $handle = $ps.BeginInvoke() - $jobs.Add([PSCustomObject]@{ - PowerShell = $ps - Handle = $handle - }) - } - $total = $jobs.Count + $pool.Open() + $total = $Profiles.Count + $results = [object[]]::new($total) + $nextProfileIndex = 0 + $completed = 0 $lastPercent = -1 do { - $completed = ($jobs | Where-Object { $_.Handle.IsCompleted }).Count + for ($jobIndex = $jobs.Count - 1; $jobIndex -ge 0; $jobIndex--) { + $job = $jobs[$jobIndex] + if (-not $job.Handle.IsCompleted) { continue } + $results[$job.Index] = Receive-ProfileRemovalResult -Job $job + $jobs.RemoveAt($jobIndex) + $completed++ + } + while ($nextProfileIndex -lt $total -and $jobs.Count -lt $maxThreadsEffective) { + $profileItem = $Profiles[$nextProfileIndex] + $ps = [PowerShell]::Create() + $pendingPowerShell = $ps + $ps.RunspacePool = $pool + [void]$ps.AddScript($scriptBlock, $true). + AddArgument($profileItem) + $handle = $ps.BeginInvoke() + $jobs.Add([PSCustomObject]@{ + PowerShell = $ps + Handle = $handle + Profile = $profileItem + Index = $nextProfileIndex + }) + $pendingPowerShell = $null + $nextProfileIndex++ + } $percent = if ($total -gt 0) { [math]::Floor(($completed / $total) * 100) } else { 100 } if ($percent -ne $lastPercent) { $lastPercent = $percent Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingRegisteredProfiles') -Status (Get-SourceTextLoc 'toolText.extra.01Completed' -Args @($completed, $total)) -Percent $percent -Icon '🗑️' } - Start-Sleep -Milliseconds 500 + if ($jobs.Count -gt 0) { + $waitHandles = [System.Threading.WaitHandle[]]@($jobs | ForEach-Object { $_.Handle.AsyncWaitHandle }) + [void][System.Threading.WaitHandle]::WaitAny($waitHandles, 500) + } } while ($completed -lt $total) Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingRegisteredProfiles') -Status (Get-SourceTextLoc 'uiText.completed') -Percent 100 -Icon '✅' Clear-ProgressLine - $results = foreach ($job in $jobs) { - try { - $job.PowerShell.EndInvoke($job.Handle) - } - catch { - Write-ToolkitLog -Level 'ERROR' -Message (Get-SourceTextLoc 'toolText.runspaceError0' -Args @($($_.Exception.Message))) - } - finally { - $job.PowerShell.Commands.Clear() - $job.PowerShell.Dispose() - } - } return $results } finally { - if ($pool) { - $pool.Close() - $pool.Dispose() + try { + if ($pendingPowerShell) { + Close-ProfileRemovalPowerShell -PowerShell $pendingPowerShell + } + foreach ($job in $jobs) { + Close-ProfileRemovalPowerShell -PowerShell $job.PowerShell + } + $jobs.Clear() + } + finally { + if ($pool) { + try { $pool.Close() } + finally { $pool.Dispose() } + } } } } @@ -4168,9 +4274,9 @@ function WinDeleteUserProfiles { Write-StyledMessage -Type 'Info' -Text ("🔎 " + (Get-SourceTextLoc 'toolText.checkResidualFoldersInTheUsersDirectory')) Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.checkResidualFoldersInTheUsersDirectory') $folders = Get-ChildItem -Path $usersRoot -Directory -Force | - Where-Object { - -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) - } + Where-Object { + -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) + } $profileListKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' $registeredSids = @() try { @@ -4228,8 +4334,8 @@ function WinDeleteUserProfiles { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.standardResidualFolderRemovalFailed01' -Args @($folderPath, $($_.Exception.Message))) try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$folderPath`"", '/R', '/D', 'Y') -LogContextKey "ResidualTakeOwn-$($folder.Name)" | Out-Null - Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$folderPath`"", '/grant', 'Administrators:F', '/T', '/C') -LogContextKey "ResidualIcacls-$($folder.Name)" | Out-Null - Remove-ItemSafely -Path $folderPath -Recurse + Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$folderPath`"", '/grant', '*S-1-5-32-544:F', '/T', '/C') -LogContextKey "ResidualIcacls-$($folder.Name)" | Out-Null + Remove-Item -LiteralPath $folderPath -Recurse -Force -ErrorAction Stop -Confirm:$false $success = -not [System.IO.Directory]::Exists($folderPath) } catch { @@ -4245,12 +4351,12 @@ function WinDeleteUserProfiles { Write-ToolkitLog -Level 'ERROR' -Message (Get-SourceTextLoc 'toolText.failedResidualFolder0' -Args @($($folder.Name))) } $results.Add([PSCustomObject]@{ - Type = 'ResidualFolder' - UserName = $folder.Name - Path = $folder.Path - Success = $success - Duration = $duration - }) | Out-Null + Type = 'ResidualFolder' + UserName = $folder.Name + Path = $folder.Path + Success = $success + Duration = $duration + }) | Out-Null } Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingResidualFoldersInCUsers') -Status (Get-SourceTextLoc 'uiText.completed') -Percent 100 -Icon '✅' Clear-ProgressLine @@ -4282,11 +4388,11 @@ function WinDeleteUserProfiles { Write-StyledMessage -Type 'Warning' -Text (Get-SourceTextLoc 'toolText.registeredProfilesSelectedForAutomaticRemoval') Write-Host '' $targets | - Select-Object @{Name='User'; Expression={ [System.IO.Path]::GetFileName($_.LocalPath) }}, - @{Name='Loaded'; Expression={ $_.Loaded }}, - @{Name='LastUseTime'; Expression={ $_.LastUseTime }}, - @{Name='Path'; Expression={ $_.LocalPath }} | - Format-Table -AutoSize + Select-Object @{Name = 'User'; Expression = { [System.IO.Path]::GetFileName($_.LocalPath) } }, + @{Name = 'Loaded'; Expression = { $_.Loaded } }, + @{Name = 'LastUseTime'; Expression = { $_.LastUseTime } }, + @{Name = 'Path'; Expression = { $_.LocalPath } } | + Format-Table -AutoSize Write-Host '' Write-StyledMessage -Type 'Info' -Text ("🚀 " + (Get-SourceTextLoc 'toolText.startAutomaticRemovalOf0RegisteredProfiles' -Args @($targets.Count))) Write-Host '' @@ -4341,6 +4447,7 @@ function WinDeleteUserProfiles { Write-ToolkitLog -Level INFO -Message (Get-SourceTextLoc 'toolText.winDeleteUserProfilesSessionEnded') } } +} function Install-Office { [CmdletBinding()] param( diff --git a/tools/WinDeleteUserProfiles.ps1 b/tools/WinDeleteUserProfiles.ps1 index 5ef7347f..9b9d462b 100644 --- a/tools/WinDeleteUserProfiles.ps1 +++ b/tools/WinDeleteUserProfiles.ps1 @@ -1,3 +1,4 @@ +# Coordinate registered-profile and residual-folder cleanup with shared protections and result reporting. function WinDeleteUserProfiles { <# .SYNOPSIS @@ -55,9 +56,11 @@ function WinDeleteUserProfiles { ) $script:ToolName = 'WinDeleteUserProfiles' + # Keep a trailing separator so root checks cannot match sibling paths with the same prefix. $usersRoot = [System.IO.Path]::GetFullPath($UsersRoot.TrimEnd('\') + '\') $currentUser = $env:USERNAME $computerName = $env:COMPUTERNAME + # Resolve the current profile by SID because its folder name can differ from the account name. $currentUserSid = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value $currentUserProfilePath = (Get-CimInstance -ClassName Win32_UserProfile -ErrorAction SilentlyContinue | Where-Object { $_.SID -eq $currentUserSid } | Select-Object -First 1 -ExpandProperty LocalPath -ErrorAction SilentlyContinue) if ($currentUserProfilePath -and $currentUserProfilePath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase)) { @@ -68,8 +71,10 @@ function WinDeleteUserProfiles { } $minimumLastUseDate = if ($MinimumProfileAgeDays -gt 0) { (Get-Date).AddDays(-$MinimumProfileAgeDays) } else { $null } $rebootRecommended = $false + # Cap simultaneous CIM operations and the resources held by the worker batch. $maxThreadsEffective = [Math]::Min($MaxThreads, 4) + # Preserve shared/system folders and both names that can identify the current user's profile. $protectedProfileNames = @( 'Public', 'Pubblica', @@ -84,12 +89,15 @@ function WinDeleteUserProfiles { $currentUserFolder ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + # Apply the same case-insensitive name exclusions to registered profiles and residual folders. function New-ProtectedNameSet { $excluded = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $protectedProfileNames | ForEach-Object { [void]$excluded.Add($_) } - return ,$excluded + # The unary comma returns the set itself instead of enumerating its entries into the pipeline. + return , $excluded } + # Remove leftover ProfileList state after the profile directory is gone, reporting registry failure separately. function Remove-ProfileRegistryEntries { param( [Parameter(Mandatory = $true)] @@ -99,23 +107,32 @@ function WinDeleteUserProfiles { [string]$UserName ) - if ([string]::IsNullOrWhiteSpace($Sid) -or $Sid -eq 'NULL') { return $false } + # Never construct a destructive registry path from a missing or placeholder SID. + if ([string]::IsNullOrWhiteSpace($Sid) -or $Sid -eq 'NULL') { + return $false + } $profileRegKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\' + $Sid - if (-not (Test-Path -LiteralPath $profileRegKey)) { return $false } + # CIM removal can already delete this key; an absent entry counts as successful cleanup. + if (-not (Test-Path -LiteralPath $profileRegKey)) { + return $true + } try { Remove-Item -LiteralPath $profileRegKey -Recurse -Force -ErrorAction Stop Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.registryEntryRemovedForSid01' -Args @($UserName, $Sid)) + return $true } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.failedToRemoveRegistryEntryForSid01' -Args @($UserName, $($_.Exception.Message))) + return $false } } + # Collect normalized registered paths so the residual scan preserves folders still associated with CIM profiles. function Get-RegisteredProfilePathSet { $pathSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) @@ -124,26 +141,28 @@ function WinDeleteUserProfiles { } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.cimProfileDetectionFailed0' -Args @($($_.Exception.Message))) - return ,$pathSet + + return , $pathSet } $cimProfiles | - Where-Object { - $_.LocalPath -and - $_.LocalPath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase) - } | - ForEach-Object { - try { - [void]$pathSet.Add([System.IO.Path]::GetFullPath($_.LocalPath).TrimEnd('\')) - } - catch { - Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.failedToNormalizeRegisteredProfileLocalpath0' -Args @($($_.LocalPath))) - } + Where-Object { + $_.LocalPath -and + $_.LocalPath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase) + } | + ForEach-Object { + try { + [void]$pathSet.Add([System.IO.Path]::GetFullPath($_.LocalPath).TrimEnd('\')) + } + catch { + Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.failedToNormalizeRegisteredProfileLocalpath0' -Args @($($_.LocalPath))) } + } - return ,$pathSet + return , $pathSet } + # Select unloaded, non-special profiles under the configured root before scheduling destructive work. function Get-RemovableUserProfiles { $excluded = New-ProtectedNameSet @@ -159,6 +178,7 @@ function WinDeleteUserProfiles { return } + # Active sessions and Windows-managed special profiles must keep their profile data. $profiles = $profiles | Where-Object { -not $_.Special -and -not $_.Loaded -and @@ -166,59 +186,167 @@ function WinDeleteUserProfiles { $_.LocalPath.StartsWith($usersRoot, [System.StringComparison]::OrdinalIgnoreCase) } - foreach ($profile in $profiles) { - $profileName = [System.IO.Path]::GetFileName($profile.LocalPath) + foreach ($profileItem in $profiles) { + $profileName = [System.IO.Path]::GetFileName($profileItem.LocalPath) if ($excluded.Contains($profileName)) { - Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfile01' -Args @($profileName, $($profile.LocalPath))) + Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfile01' -Args @($profileName, $($profileItem.LocalPath))) continue } - if ($profile.SID -eq $currentUserSid) { - Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfileBecauseSidMatchesCurrentUser0' -Args @($profileName, $profile.SID)) + # Protect the current account independently of folder-name exclusions. + if ($profileItem.SID -eq $currentUserSid) { + Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.excludedProfileBecauseSidMatchesCurrentUser0' -Args @($profileName, $profileItem.SID)) continue } - if ($minimumLastUseDate -and $profile.LastUseTime) { - $lastUse = $profile.LastUseTime + # Apply the optional age cutoff only when Windows provides a last-use timestamp. + if ($minimumLastUseDate -and $profileItem.LastUseTime) { + $lastUse = $profileItem.LastUseTime if ($lastUse -gt $minimumLastUseDate) { Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.profileExcludedDueToTimeThreshold0LastUse1' -Args @($profileName, $lastUse)) continue } } - $profile + $profileItem + } + } + + # Supply worker dependencies explicitly: runspaces do not inherit toolkit functions or language/log context. + function New-ProfileRemovalSessionState { + $sessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + + foreach ($functionName in @( + 'Get-SourceTextLoc', + 'Write-ToolkitLog', + 'Invoke-ExternalCommandWithLog', + 'Remove-ItemSafely', + 'Remove-ProfileRegistryEntries', + 'Get-SpinnerChar' + )) { + $command = Get-Command -Name $functionName -CommandType Function -ErrorAction Stop + $sessionState.Commands.Add( + [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new($functionName, $command.Definition) + ) + } + + # Only the parent runspace may update the shared console progress line. + foreach ($functionName in @('Clear-ProgressLine', 'Write-ProgressUpdate')) { + $sessionState.Commands.Add( + [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new($functionName, '') + ) + } + + foreach ($variableName in @( + 'SourceTextLanguageData', + 'SourceTextDefaultLanguageData', + 'SourceTextKeyAliases', + 'CurrentLogFile', + 'CurrentToolName', + 'Spinners' + )) { + $value = Get-Variable -Name $variableName -Scope Global -ValueOnly -ErrorAction Stop + $sessionState.Variables.Add( + [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new($variableName, $value, '') + ) + } + # Shared helpers must leave console rendering to the parent even inside worker sessions. + $sessionState.Variables.Add( + [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new('GuiSessionActive', $true, '') + ) + + return $sessionState + } + + # Collect a finished worker's result and release its pipeline before scheduling replacement work. + function Receive-ProfileRemovalResult { + param( + [Parameter(Mandatory = $true)] + [object]$Job + ) + + try { + $Job.PowerShell.EndInvoke($Job.Handle) + } + catch { + Write-ToolkitLog -Level 'ERROR' -Message (Get-SourceTextLoc 'toolText.runspaceError0' -Args @($($_.Exception.Message))) + # Preserve identity in a failed result so worker exceptions remain visible in summary counts. + [PSCustomObject]@{ + Type = 'Profile' + UserName = [System.IO.Path]::GetFileName($Job.Profile.LocalPath) + Path = $Job.Profile.LocalPath + Sid = $Job.Profile.SID + Success = $false + Duration = [TimeSpan]::Zero + } + } + finally { + # Release command references and pipeline resources as soon as collection finishes. + $Job.PowerShell.Commands.Clear() + $Job.PowerShell.Dispose() + } + } + + # Clean up workers abandoned by a batch failure while keeping individual cleanup errors non-terminating. + function Close-ProfileRemovalPowerShell { + param( + [Parameter(Mandatory = $true)] + [object]$PowerShell + ) + + try { + $PowerShell.Stop() + } + # Collection may have disposed the instance before a later batch operation failed. + catch [System.ObjectDisposedException] {} + catch { + # Explicit Continue prevents the caller's warning preference from interrupting cleanup. + Write-Warning -Message $_.Exception.Message -WarningAction Continue + } + finally { + # Dispose must still run when Stop fails. + try { + $PowerShell.Dispose() + } + catch { + Write-Warning -Message $_.Exception.Message -WarningAction Continue + } } } + # Bound the number of live pipelines while returning one result per profile in the original input order. function Invoke-ProfileRemovalBatch { param( [Parameter(Mandatory = $true)] [array]$Profiles ) - $pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreadsEffective) - $pool.Open() + $sessionState = New-ProfileRemovalSessionState + $pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreadsEffective, $sessionState, $Host) + # Retain only outstanding jobs so pipeline memory stays bounded by the concurrency limit. $jobs = [System.Collections.Generic.List[object]]::new() + # Keep ownership of a new instance until startup succeeds and it joins the tracked job list. + $pendingPowerShell = $null $scriptBlock = { - param($Profile) + param($ProfileItem) + # Terminating errors route failed deletion steps through their fallback cleanup paths. $ErrorActionPreference = 'Stop' - $userPath = $Profile.LocalPath + $userPath = $ProfileItem.LocalPath $userName = [System.IO.Path]::GetFileName($userPath) - $userSid = $Profile.SID + $userSid = $ProfileItem.SID $start = Get-Date Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.startResidualFolder01' -Args @($userName, $userPath)) - $cimSuccess = $false + # Let Windows remove the registered profile first; filesystem cleanup handles any leftovers. try { - Remove-CimInstance -InputObject $Profile -ErrorAction Stop -Confirm:$false + Remove-CimInstance -InputObject $ProfileItem -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.cimProfileRemoved0' -Args @($userName)) - $cimSuccess = $true } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.cimRemoveFailed01' -Args @($userName, $($_.Exception.Message))) @@ -226,6 +354,7 @@ function WinDeleteUserProfiles { if ([System.IO.Directory]::Exists($userPath)) { try { + # An empty source lets /MIR remove leftover contents; /XJ excludes junctions. $tempEmpty = Join-Path $env:TEMP "EmptyFolder" if (-not (Test-Path $tempEmpty)) { @@ -233,20 +362,22 @@ function WinDeleteUserProfiles { } Invoke-ExternalCommandWithLog -Command 'robocopy.exe' ` - -Arguments @("`"$tempEmpty`"", "`"$userPath`"", '/MIR', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP') ` + -Arguments @("`"$tempEmpty`"", "`"$userPath`"", '/MIR', '/XJ', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP') ` -LogContextKey "ProfileCleanup-Robocopy-$userName" | Out-Null - Remove-ItemSafely -Path $userPath -Recurse + Remove-Item -LiteralPath $userPath -Recurse -Force -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.folderRemoved0' -Args @($userName)) } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.standardFolderCleanupFailed01' -Args @($userName, $($_.Exception.Message))) + # Repair ownership and access if permissions prevented standard folder removal. try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$userPath`"", '/R', '/D', 'Y') -LogContextKey "ProfileCleanup-TakeOwn-$userName" | Out-Null - Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$userPath`"", '/grant', 'Administrators:F', '/T', '/C') -LogContextKey "ProfileCleanup-Icacls-$userName" | Out-Null - Remove-ItemSafely -Path $userPath -Recurse + # S-1-5-32-544 - Fixed SID across Windows languages; avoids depending on localized group names. + Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$userPath`"", '/grant', '*S-1-5-32-544:F', '/T', '/C') -LogContextKey "ProfileCleanup-Icacls-$userName" | Out-Null + Remove-Item -LiteralPath $userPath -Recurse -Force -ErrorAction Stop -Confirm:$false Write-ToolkitLog -Level 'SUCCESS' -Message (Get-SourceTextLoc 'toolText.folderRemovedAfterAclReset0' -Args @($userName)) } catch { @@ -255,12 +386,14 @@ function WinDeleteUserProfiles { } } + # Remove any remaining registry entry only after the directory is gone; require both outcomes for success. $folderGone = -not [System.IO.Directory]::Exists($userPath) - if (-not $cimSuccess -and $folderGone -and $userSid) { - Remove-ProfileRegistryEntries -Sid $userSid -UserName $userName + $registrySuccess = $false + if ($folderGone -and $userSid) { + $registrySuccess = Remove-ProfileRegistryEntries -Sid $userSid -UserName $userName } - $success = $folderGone + $success = $folderGone -and $registrySuccess $duration = New-TimeSpan -Start $start -End (Get-Date) if ($success) { @@ -281,74 +414,105 @@ function WinDeleteUserProfiles { } try { - foreach ($profile in $Profiles) { - $ps = [PowerShell]::Create() - $ps.RunspacePool = $pool - - [void]$ps.AddScript($scriptBlock, $true). - AddArgument($profile) - - $handle = $ps.BeginInvoke() - - $jobs.Add([PSCustomObject]@{ - PowerShell = $ps - Handle = $handle - }) - } - - $total = $jobs.Count + $pool.Open() + $total = $Profiles.Count + # Workers finish out of order, so store each result at its original profile index. + $results = [object[]]::new($total) + $nextProfileIndex = 0 + $completed = 0 $lastPercent = -1 do { - $completed = ($jobs | Where-Object { $_.Handle.IsCompleted }).Count + # Walk backwards so removing completed jobs cannot shift an unvisited entry. + for ($jobIndex = $jobs.Count - 1; $jobIndex -ge 0; $jobIndex--) { + $job = $jobs[$jobIndex] + if (-not $job.Handle.IsCompleted) { continue } + + $results[$job.Index] = Receive-ProfileRemovalResult -Job $job + $jobs.RemoveAt($jobIndex) + $completed++ + } + + # Create replacements only after collection frees a slot, bounding queued and running instances. + while ($nextProfileIndex -lt $total -and $jobs.Count -lt $maxThreadsEffective) { + $profileItem = $Profiles[$nextProfileIndex] + $ps = [PowerShell]::Create() + $pendingPowerShell = $ps + $ps.RunspacePool = $pool + + [void]$ps.AddScript($scriptBlock, $true). + AddArgument($profileItem) + + $handle = $ps.BeginInvoke() + + $jobs.Add([PSCustomObject]@{ + PowerShell = $ps + Handle = $handle + Profile = $profileItem + Index = $nextProfileIndex + }) + # Transfer cleanup ownership only after the new job has been registered successfully. + $pendingPowerShell = $null + $nextProfileIndex++ + } + $percent = if ($total -gt 0) { [math]::Floor(($completed / $total) * 100) } else { 100 } + # Update the console only when the whole-number percentage changes. if ($percent -ne $lastPercent) { $lastPercent = $percent Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingRegisteredProfiles') -Status (Get-SourceTextLoc 'toolText.extra.01Completed' -Args @($completed, $total)) -Percent $percent -Icon '🗑️' } - Start-Sleep -Milliseconds 500 + if ($jobs.Count -gt 0) { + # Wait for any worker or a short timeout instead of continuously polling completion. + $waitHandles = [System.Threading.WaitHandle[]]@($jobs | ForEach-Object { $_.Handle.AsyncWaitHandle }) + [void][System.Threading.WaitHandle]::WaitAny($waitHandles, 500) + } } while ($completed -lt $total) Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingRegisteredProfiles') -Status (Get-SourceTextLoc 'uiText.completed') -Percent 100 -Icon '✅' Clear-ProgressLine - $results = foreach ($job in $jobs) { - try { - $job.PowerShell.EndInvoke($job.Handle) - } - catch { - Write-ToolkitLog -Level 'ERROR' -Message (Get-SourceTextLoc 'toolText.runspaceError0' -Args @($($_.Exception.Message))) - } - finally { - $job.PowerShell.Commands.Clear() - $job.PowerShell.Dispose() - } - } - return $results } finally { - if ($pool) { - $pool.Close() - $pool.Dispose() + # Failure paths can bypass collection, leaving pending or tracked instances for this cleanup. + try { + if ($pendingPowerShell) { + Close-ProfileRemovalPowerShell -PowerShell $pendingPowerShell + } + foreach ($job in $jobs) { + Close-ProfileRemovalPowerShell -PowerShell $job.PowerShell + } + $jobs.Clear() + } + finally { + # Release the pool even after instance cleanup fails, and dispose it even if Close fails. + if ($pool) { + try { $pool.Close() } + finally { $pool.Dispose() } + } } } } + # Select leftover directories only after protected-name, CIM-path, registered-SID, and link exclusions. function Get-ResidualUserFolders { $excluded = New-ProtectedNameSet + # Refresh registration after profile removal so this scan reflects the state Windows now reports. $registeredProfilePaths = Get-RegisteredProfilePathSet Write-StyledMessage -Type 'Info' -Text ("🔎 " + (Get-SourceTextLoc 'toolText.checkResidualFoldersInTheUsersDirectory')) Write-ToolkitLog -Level 'INFO' -Message (Get-SourceTextLoc 'toolText.checkResidualFoldersInTheUsersDirectory') + # Exclude reparse points so residual cleanup does not select links into other locations. $folders = Get-ChildItem -Path $usersRoot -Directory -Force | - Where-Object { - -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) - } + Where-Object { + -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) + } + # SID-named folders need a registry exclusion in addition to the CIM path exclusion. $profileListKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' $registeredSids = @() try { @@ -377,6 +541,7 @@ function WinDeleteUserProfiles { continue } + # Keep the link exclusion at candidate selection as well as during initial enumeration. if ($folder.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.residualFolderExcludedBecauseReparsePointSymlink01' -Args @($folderName, $folderPath)) continue @@ -389,6 +554,7 @@ function WinDeleteUserProfiles { } } + # Remove selected orphan folders separately, recording each outcome and retrying after ACL recovery if needed. function Remove-ResidualUserFolders { param( [Parameter(Mandatory = $true)] @@ -413,16 +579,20 @@ function WinDeleteUserProfiles { $folderPath = $folder.Path try { + # Literal paths preserve folder names containing wildcard characters during deletion. Remove-Item -LiteralPath $folderPath -Force -Recurse -ErrorAction Stop -Confirm:$false + # A completed command is insufficient; verify that the directory is actually gone. $success = -not [System.IO.Directory]::Exists($folderPath) } catch { Write-ToolkitLog -Level 'WARNING' -Message (Get-SourceTextLoc 'toolText.standardResidualFolderRemovalFailed01' -Args @($folderPath, $($_.Exception.Message))) + # Retry with ownership and access repaired when normal filesystem deletion fails. try { Invoke-ExternalCommandWithLog -Command 'takeown.exe' -Arguments @('/F', "`"$folderPath`"", '/R', '/D', 'Y') -LogContextKey "ResidualTakeOwn-$($folder.Name)" | Out-Null - Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$folderPath`"", '/grant', 'Administrators:F', '/T', '/C') -LogContextKey "ResidualIcacls-$($folder.Name)" | Out-Null - Remove-ItemSafely -Path $folderPath -Recurse + # S-1-5-32-544 - Fixed SID across Windows languages; avoids depending on localized group names. + Invoke-ExternalCommandWithLog -Command 'icacls.exe' -Arguments @("`"$folderPath`"", '/grant', '*S-1-5-32-544:F', '/T', '/C') -LogContextKey "ResidualIcacls-$($folder.Name)" | Out-Null + Remove-Item -LiteralPath $folderPath -Recurse -Force -ErrorAction Stop -Confirm:$false $success = -not [System.IO.Directory]::Exists($folderPath) } catch { @@ -441,12 +611,12 @@ function WinDeleteUserProfiles { } $results.Add([PSCustomObject]@{ - Type = 'ResidualFolder' - UserName = $folder.Name - Path = $folder.Path - Success = $success - Duration = $duration - }) | Out-Null + Type = 'ResidualFolder' + UserName = $folder.Name + Path = $folder.Path + Success = $success + Duration = $duration + }) | Out-Null } Write-ProgressUpdate -Activity (Get-SourceTextLoc 'toolText.extra.removingResidualFoldersInCUsers') -Status (Get-SourceTextLoc 'uiText.completed') -Percent 100 -Icon '✅' @@ -455,9 +625,11 @@ function WinDeleteUserProfiles { return $results } + # Both cleanup phases share the toolkit's progress, localization, and log context. Start-ToolkitSession -ToolName $script:ToolName -SubTitle (Get-SourceTextLoc 'script.WinDeleteUserProfiles') try { + # Validate the configured directory before either cleanup phase can delete anything. if (-not (Test-Path -LiteralPath $usersRoot -PathType Container)) { throw (Get-SourceTextLoc 'toolText.extra.profilePathDoesNotExist0' -Args @($usersRoot)) } @@ -490,11 +662,11 @@ function WinDeleteUserProfiles { Write-Host '' $targets | - Select-Object @{Name='User'; Expression={ [System.IO.Path]::GetFileName($_.LocalPath) }}, - @{Name='Loaded'; Expression={ $_.Loaded }}, - @{Name='LastUseTime'; Expression={ $_.LastUseTime }}, - @{Name='Path'; Expression={ $_.LocalPath }} | - Format-Table -AutoSize + Select-Object @{Name = 'User'; Expression = { [System.IO.Path]::GetFileName($_.LocalPath) } }, + @{Name = 'Loaded'; Expression = { $_.Loaded } }, + @{Name = 'LastUseTime'; Expression = { $_.LastUseTime } }, + @{Name = 'Path'; Expression = { $_.LocalPath } } | + Format-Table -AutoSize Write-Host '' Write-StyledMessage -Type 'Info' -Text ("🚀 " + (Get-SourceTextLoc 'toolText.startAutomaticRemovalOf0RegisteredProfiles' -Args @($targets.Count))) @@ -507,6 +679,7 @@ function WinDeleteUserProfiles { Write-StyledMessage -Type 'Success' -Text ((Get-SourceTextLoc 'toolText.noRemovableRegisteredProfilesFound')) } + # Scan leftovers after registered-profile removal using the updated registration state. if (-not $SkipResidualFolderCleanup) { $residualFolders = @(Get-ResidualUserFolders) @@ -529,11 +702,13 @@ function WinDeleteUserProfiles { Write-StyledMessage -Type 'Warning' -Text (Get-SourceTextLoc 'toolText.residualFolderCleanupSkippedForSkipresidualfoldercleanupParameter') } + # Report successes separately for each phase, but base restart advice on failures from either phase. $allResults = @($profileResults) + @($residualResults) $failedCount = @($allResults | Where-Object { -not $_.Success }).Count $profileSuccessCount = @($profileResults | Where-Object { $_.Success }).Count $residualSuccessCount = @($residualResults | Where-Object { $_.Success }).Count + # Open sessions or handles may prevent cleanup; recommend a restart only when items failed. if ($failedCount -gt 0) { $rebootRecommended = $true Write-StyledMessage -Type 'Warning' -Text ((Get-SourceTextLoc 'toolText.extra.restartRecommendedAfterProfileCleanup')) @@ -547,14 +722,17 @@ function WinDeleteUserProfiles { } if ($rebootRecommended) { + # Forward suppression so a toolkit batch can manage its final restart centrally. Invoke-ToolkitReboot -Message (Get-SourceTextLoc 'toolText.extra.restartRecommendedAfterProfileCleanup') -Seconds $CountdownSeconds -SuppressIndividualReboot:$SuppressIndividualReboot } } catch { Write-ToolkitError -Record $_ -ToolName $script:ToolName + # Preserve the original exception so the caller can detect an unsuccessful run. throw } finally { + # Report that the session ended even when a cleanup phase throws. Write-StyledMessage -Type 'Info' -Text ("♻️ " + (Get-SourceTextLoc 'toolText.winDeleteUserProfilesSessionEnded')) Write-ToolkitLog -Level INFO -Message (Get-SourceTextLoc 'toolText.winDeleteUserProfilesSessionEnded') } diff --git a/wintoolkit-modules/10-Module.Localization.ps1 b/wintoolkit-modules/10-Module.Localization.ps1 index a9d14599..cf385206 100644 --- a/wintoolkit-modules/10-Module.Localization.ps1 +++ b/wintoolkit-modules/10-Module.Localization.ps1 @@ -126,7 +126,7 @@ function Get-SourceTextLoc { } if ($null -eq $value) { $value = $Key } - if ($Arguments -and $Arguments.Count -gt 0) { return [string]::Format($value, $Arguments) } + if ($null -ne $Arguments -and $Arguments.Count -gt 0) { return [string]::Format($value, $Arguments) } return $value }