diff --git a/CHANGELOG.md b/CHANGELOG.md index dc60da8539..15298a8e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ ### Features +- **scoop-forcekill:** Support killing running applications and services ([#6603](https://github.com/ScoopInstaller/Scoop/pull/6603)) - **autoupdate:** GitHub predefined hashes support ([#6416](https://github.com/ScoopInstaller/Scoop/issues/6416), [#6435](https://github.com/ScoopInstaller/Scoop/issues/6435)) ### Bug Fixes diff --git a/lib/install.ps1 b/lib/install.ps1 index 78acdd252b..9eb9320358 100644 --- a/lib/install.ps1 +++ b/lib/install.ps1 @@ -533,23 +533,314 @@ function persist_permission($manifest, $global) { } } -# test if there are running processes -function test_running_process($app, $global) { - $processdir = appdir $app $global | Convert-Path - $running_processes = Get-Process | Where-Object { $_.Path -like "$processdir\*" } | Out-String - - if ($running_processes) { - if (get_config IGNORE_RUNNING_PROCESSES) { - warn "The following instances of `"$app`" are still running. Scoop is configured to ignore this condition." - Write-Host $running_processes - return $false +function New-TrackedProcessEntry($proc) { + $mainWindowHandle = [IntPtr]::Zero + if ($proc.PSObject.Properties.Match('MainWindowHandle').Count -gt 0) { + $mainWindowHandle = $proc.MainWindowHandle + } + + return [PSCustomObject]@{ + Id = $proc.Id + Name = $proc.Name + AppName = if ($proc.PSObject.Properties.Match('AppName').Count -gt 0) { $proc.AppName } else { $null } + Path = if ($proc.PSObject.Properties.Match('Path').Count -gt 0) { $proc.Path } else { $null } + ApplicationType = if ($proc.PSObject.Properties.Match('ApplicationType').Count -gt 0) { $proc.ApplicationType } else { 'Unknown' } + ServiceName = if ($proc.PSObject.Properties.Match('ServiceName').Count -gt 0) { $proc.ServiceName } else { $null } + Restartable = if ($proc.PSObject.Properties.Match('Restartable').Count -gt 0) { $proc.Restartable } else { $false } + MainWindowHandle = $mainWindowHandle + } +} + +function Merge-TrackedProcesses($primary_processes, $fallback_processes) { + $merged = @() + $process_index = @{} + + foreach ($proc in @($primary_processes)) { + $entry = New-TrackedProcessEntry $proc + $merged += $entry + $process_index[[string]$entry.Id] = $entry + } + + foreach ($proc in @($fallback_processes)) { + $entry = New-TrackedProcessEntry $proc + $key = [string]$entry.Id + + if ($process_index.ContainsKey($key)) { + $existing = $process_index[$key] + + if ((-not $existing.Path) -and ($entry.Path)) { + $existing.Path = $entry.Path + } + + if ((-not $existing.Name) -and ($entry.Name)) { + $existing.Name = $entry.Name + } + + if ((-not $existing.AppName) -and ($entry.AppName)) { + $existing.AppName = $entry.AppName + } + + if (((-not $existing.ApplicationType) -or ($existing.ApplicationType -eq 'Unknown')) -and ($entry.ApplicationType)) { + $existing.ApplicationType = $entry.ApplicationType + } + + if ((-not $existing.ServiceName) -and ($entry.ServiceName)) { + $existing.ServiceName = $entry.ServiceName + } + + if ((-not $existing.Restartable) -and ($entry.Restartable)) { + $existing.Restartable = $entry.Restartable + } + + if (([Int64]$existing.MainWindowHandle -eq 0) -and ([Int64]$entry.MainWindowHandle -ne 0)) { + $existing.MainWindowHandle = $entry.MainWindowHandle + } } else { - error "The following instances of `"$app`" are still running. Close them and try again." - Write-Host $running_processes - return $true + $merged += $entry + $process_index[$key] = $entry } + } + + return @($merged) +} + +function Write-TrackedProcessDetails($processes) { + foreach ($proc in @($processes)) { + $path_info = if ($proc.Path) { " ($($proc.Path))" } else { '' } + Write-Host " - $($proc.Name) [$($proc.ApplicationType)] (PID $($proc.Id))$path_info" + } +} + +function Write-ServiceDetails($service_processes, $service_names) { + $shown_services = @{} + + foreach ($proc in @($service_processes)) { + $service_name = if ($proc.ServiceName) { $proc.ServiceName } else { $proc.Name } + $path_info = if ($proc.Path) { " ($($proc.Path))" } else { '' } + + $shown_services[$service_name] = $true + Write-Host " - $service_name [Service] (PID $($proc.Id))$path_info" + } + + foreach ($service_name in @($service_names)) { + if (-not $shown_services.ContainsKey($service_name)) { + Write-Host " - $service_name [Service]" + } + } +} + +function Test-TrackedProcessHasVisibleWindow($proc) { + $has_window_handle = $false + if ($null -ne $proc.MainWindowHandle) { + try { + $has_window_handle = ([Int64]$proc.MainWindowHandle) -ne 0 + } catch { + $has_window_handle = $false + } + } + + return ($has_window_handle -or ($proc.ApplicationType -eq 'MainWindow') -or ($proc.ApplicationType -eq 'OtherWindow')) +} + +# check if there are running processes locking app files +function check_running_process($app, $global) { + if (get_config NO_JUNCTION) { + $version = Select-CurrentVersion -App $app -Global:$global } else { - return $false + $version = 'current' + } + + $json = install_info $app $version $global + $forcekill = $false + $forcekill_services = @() + + if ($json) { + if ($json.forcekill) { $forcekill = $true } + if ($json.forcekill_services) { $forcekill_services = @($json.forcekill_services) } + } + + $processdir = if (get_config NO_JUNCTION) { versiondir $app $version $global } else { versiondir $app 'current' $global } + $processdir = $processdir | Convert-Path + + $locking_processes = @(Get-LockingProcesses $processdir) + $path_processes = @(Get-RunningProcessesInDir $processdir) + $tracked_processes = @(Merge-TrackedProcesses $locking_processes $path_processes) + + # Filter out Explorer and Critical system processes (should never be killed). + # Keep service entries so service-only blockers remain visible to the caller. + $running_processes = @($tracked_processes | Where-Object { + $_.ApplicationType -notin @('Explorer', 'Critical') + }) + + $servicesToStop = @() + if ($forcekill) { + $rm_services = @($running_processes | Where-Object { + $_.ApplicationType -eq 'Service' -and $_.ServiceName + } | ForEach-Object { $_.ServiceName }) + + $servicesToStop = @($rm_services) + + $wmi_dir = $processdir -replace '\\', '\\' -replace '_', '[_]' -replace "'", "''" + $filter = "PathName LIKE '%$wmi_dir%'" + $foundServices = @(Get-CimInstance -ClassName Win32_Service -Filter $filter -ErrorAction SilentlyContinue) + if ($foundServices.Count -gt 0) { + $servicesToStop += @($foundServices | ForEach-Object { $_.Name }) + } + + if ($forcekill_services) { + $servicesToStop += $forcekill_services + } + + if ($servicesToStop.Count -gt 0) { + $servicesToStop = @($servicesToStop | Select-Object -Unique) + } + } + + $blocked = $false + if ($running_processes.Count -gt 0 -and !$forcekill -and !(get_config IGNORE_RUNNING_PROCESSES)) { + $blocked = $true + } + + return @{ + Blocked = $blocked + Forcekill = $forcekill + App = $app + RunningProcesses = @($running_processes) + ServicesToStop = @($servicesToStop) + ProcessDir = $processdir + } +} + +function stop_running_process($test_result) { + $forcekill = $test_result.Forcekill + $app = $test_result.App + $running_processes = $test_result.RunningProcesses + $servicesToStop = $test_result.ServicesToStop + $processdir = $test_result.ProcessDir + + $stopped_services = @() + $stopped_processes = @() + $blocked = $test_result.Blocked + $killable_processes = @($running_processes | Where-Object { $_.ApplicationType -ne 'Service' }) + $service_processes = @($running_processes | Where-Object { $_.ApplicationType -eq 'Service' }) + $running_services_to_stop = @() + + foreach ($svc in @($servicesToStop)) { + $service = Get-Service -Name $svc -ErrorAction SilentlyContinue + if ($service -and ($service.Status -eq 'Running')) { + $running_services_to_stop += $svc + } + } + + if ($running_services_to_stop.Count -gt 0) { + $running_services_to_stop = @($running_services_to_stop | Select-Object -Unique) + } + + if ($forcekill -and ($running_services_to_stop.Count -gt 0) -and (-not (is_admin))) { + warn 'Administrative privileges are required to stop the associated services. Aborting forcekill.' + Write-ServiceDetails $service_processes $running_services_to_stop + $blocked = $true + } else { + $has_running_entries = ($killable_processes.Count -gt 0) -or ($service_processes.Count -gt 0) + if ($has_running_entries) { + if ($forcekill) { + if ($killable_processes.Count -gt 0) { + warn "The following instances of `"$app`" are still running. Scoop is configured to force kill them." + Write-TrackedProcessDetails $killable_processes + } + + if ($service_processes.Count -gt 0) { + warn "The following associated services for `"$app`" are still running." + Write-ServiceDetails $service_processes $running_services_to_stop + } + + # Only restart processes that were running headlessly before forcekill. + $has_visible_window = @($killable_processes | Where-Object { + Test-TrackedProcessHasVisibleWindow $_ + }).Count -gt 0 + + if ($has_visible_window) { + warn "Some instances of `"$app`" had visible windows. They will NOT be automatically restarted." + } else { + foreach ($proc in $killable_processes) { + if ($proc.Path) { + $stopped_processes += $proc.Path + } else { + warn "Process $($proc.Name) (PID $($proc.Id)) cannot be reliably restarted because its executable path is unreadable." + } + } + if ($stopped_processes.Count -gt 0) { + $stopped_processes = @($stopped_processes | Select-Object -Unique) + } + } + + foreach ($proc in $killable_processes) { + try { + Stop-Process -Id $proc.Id -Force -ErrorAction Stop + } catch { + warn "Failed to stop process $($proc.Name) (PID $($proc.Id)): $($_.Exception.Message)" + } + } + + $still_locking = @((Merge-TrackedProcesses @(Get-LockingProcesses $processdir) @(Get-RunningProcessesInDir $processdir)) | Where-Object { + $_.ApplicationType -notin @('Explorer', 'Critical', 'Service') + }) + if ($still_locking.Count -gt 0) { + warn "Some instances of `"$app`" could not be stopped." + Write-TrackedProcessDetails $still_locking + $blocked = $true + } + + } elseif (get_config IGNORE_RUNNING_PROCESSES) { + if ($killable_processes.Count -gt 0) { + warn "The following instances of `"$app`" are still running. Scoop is configured to ignore this condition." + Write-TrackedProcessDetails $killable_processes + } + + if ($service_processes.Count -gt 0) { + warn "The following associated services for `"$app`" are still running. Scoop is configured to ignore this condition." + Write-ServiceDetails $service_processes $running_services_to_stop + } + } else { + if ($killable_processes.Count -gt 0) { + error "The following instances of `"$app`" are still running. Close them and try again." + Write-TrackedProcessDetails $killable_processes + } + + if ($service_processes.Count -gt 0) { + error "The following associated services for `"$app`" are still running. Stop them and try again." + Write-ServiceDetails $service_processes $running_services_to_stop + } + + $blocked = $true + } + } + + if ($forcekill -and ($running_services_to_stop.Count -gt 0)) { + foreach ($svc in $running_services_to_stop) { + $service = Get-Service -Name $svc -ErrorAction SilentlyContinue + if ($service -and ($service.Status -eq 'Running')) { + warn "Stopping service '$svc' associated with '$app'..." + try { + Stop-Service -Name $svc -Force -ErrorAction Stop + $stopped_services += $svc + } catch { + warn "Failed to stop service '$svc': $($_.Exception.Message)" + } + + if ((Get-Service -Name $svc -ErrorAction SilentlyContinue).Status -eq 'Running') { + warn "Service '$svc' is still running." + $blocked = $true + } + } + } + } + } + + return @{ + Blocked = $blocked + ServicesToRestart = @($stopped_services) + ProcessesToRestart = @($stopped_processes) } } diff --git a/lib/restart_manager.ps1 b/lib/restart_manager.ps1 new file mode 100644 index 0000000000..51cf2e44a3 --- /dev/null +++ b/lib/restart_manager.ps1 @@ -0,0 +1,170 @@ +# Restart Manager API wrapper for detecting file-locking processes +# Uses Windows Restart Manager (rstrtmgr.dll) via P/Invoke + +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +public class RestartManager { + [StructLayout(LayoutKind.Sequential)] + public struct RM_UNIQUE_PROCESS { + public int dwProcessId; + public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime; + } + + public const int RmUnknownApp = 0; + public const int RmMainWindow = 1; + public const int RmOtherWindow = 2; + public const int RmService = 3; + public const int RmExplorer = 4; + public const int RmConsole = 5; + public const int RmCritical = 1000; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct RM_PROCESS_INFO { + public RM_UNIQUE_PROCESS Process; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string strAppName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string strServiceShortName; + public int ApplicationType; + public uint AppStatus; + public uint TSSessionId; + [MarshalAs(UnmanagedType.Bool)] + public bool bRestartable; + } + + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmStartSession(out uint pSessionHandle, int dwSessionFlags, string strSessionKey); + + [DllImport("rstrtmgr.dll")] + public static extern int RmEndSession(uint pSessionHandle); + + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFilenames, + uint nApplications, RM_UNIQUE_PROCESS[] rgApplications, uint nServices, string[] rgsServiceNames); + + [DllImport("rstrtmgr.dll")] + public static extern int RmGetList(uint pSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, + [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons); + + public static List GetLockingProcesses(string[] filePaths) { + var result = new List(); + uint sessionHandle; + string sessionKey = Guid.NewGuid().ToString(); + + int rv = RmStartSession(out sessionHandle, 0, sessionKey); + if (rv != 0) return result; + + try { + rv = RmRegisterResources(sessionHandle, (uint)filePaths.Length, filePaths, 0, null, 0, null); + if (rv != 0) return result; + + uint pnProcInfoNeeded = 0; + uint pnProcInfo = 0; + uint rebootReasons = 0; + + rv = RmGetList(sessionHandle, out pnProcInfoNeeded, ref pnProcInfo, null, ref rebootReasons); + if (rv == 234 && pnProcInfoNeeded > 0) { // ERROR_MORE_DATA + var processInfo = new RM_PROCESS_INFO[pnProcInfoNeeded]; + pnProcInfo = pnProcInfoNeeded; + rv = RmGetList(sessionHandle, out pnProcInfoNeeded, ref pnProcInfo, processInfo, ref rebootReasons); + if (rv == 0) { + for (int i = 0; i < pnProcInfo; i++) { + result.Add(processInfo[i]); + } + } + } + } finally { + RmEndSession(sessionHandle); + } + return result; + } +} +'@ -ErrorAction SilentlyContinue + +$rmAppTypeNames = @{ + 0 = 'Unknown' + 1 = 'MainWindow' + 2 = 'OtherWindow' + 3 = 'Service' + 4 = 'Explorer' + 5 = 'Console' + 1000 = 'Critical' +} + +function Get-SafeProcessPath($procObj) { + $procPath = $null + if ($procObj) { + try { + $procPath = $procObj.Path + } catch { + $procPath = $null + } + } + + return $procPath +} + +function Get-LockingProcesses($appDir) { + $files = @(Get-ChildItem -Path $appDir -Recurse -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName) + + if ($files.Count -eq 0) { return @() } + + $rmResults = [RestartManager]::GetLockingProcesses([string[]]$files) + + if ($rmResults.Count -eq 0) { return @() } + + $output = @() + $seenPids = @{} + foreach ($rm in $rmResults) { + $procId = $rm.Process.dwProcessId + if ($seenPids.ContainsKey($procId)) { continue } + $seenPids[$procId] = $true + + $procObj = Get-Process -Id $procId -ErrorAction SilentlyContinue + $procPath = Get-SafeProcessPath $procObj + + $appType = $rmAppTypeNames[[int]$rm.ApplicationType] + if (-not $appType) { $appType = 'Unknown' } + + $output += [PSCustomObject]@{ + Id = $procId + Name = if ($procObj) { $procObj.Name } else { $rm.strAppName } + AppName = $rm.strAppName + Path = $procPath + ApplicationType = $appType + ServiceName = $rm.strServiceShortName + Restartable = $rm.bRestartable + MainWindowHandle = if ($procObj) { $procObj.MainWindowHandle } else { [IntPtr]::Zero } + } + } + + return @($output) +} + +function Get-RunningProcessesInDir($appDir) { + $pathPattern = "$appDir\*" + $output = @() + + $allProcesses = @(Get-Process -ErrorAction SilentlyContinue) + foreach ($procObj in $allProcesses) { + $procPath = Get-SafeProcessPath $procObj + if ($procPath -like $pathPattern) { + $output += [PSCustomObject]@{ + Id = $procObj.Id + Name = $procObj.Name + AppName = $procObj.Name + Path = $procPath + ApplicationType = 'Unknown' + ServiceName = $null + Restartable = $false + MainWindowHandle = $procObj.MainWindowHandle + } + } + } + + return @($output) +} diff --git a/libexec/scoop-forcekill.ps1 b/libexec/scoop-forcekill.ps1 new file mode 100644 index 0000000000..34fbe9a9b0 --- /dev/null +++ b/libexec/scoop-forcekill.ps1 @@ -0,0 +1,88 @@ +# Usage: scoop forcekill +# Summary: Force close apps before updating +# Help: To mark a user-scoped app to force close: +# scoop forcekill +# +# To mark a global app to force close: +# scoop forcekill -g +# +# To explicitly define services to stop: +# scoop forcekill --service +# +# Options: +# -g, --global Force close globally installed apps +# -s, --service Services to stop (comma separated) + +. "$PSScriptRoot\..\lib\getopt.ps1" +. "$PSScriptRoot\..\lib\json.ps1" +. "$PSScriptRoot\..\lib\manifest.ps1" +. "$PSScriptRoot\..\lib\versions.ps1" +. "$PSScriptRoot\..\lib\core.ps1" +. "$PSScriptRoot\..\lib\install.ps1" + +$opt, $apps, $err = getopt $args 'gs:' 'global', 'service=' +if ($err) { "scoop forcekill: $err"; exit 1 } + +$exitcode = 0 + +$global = $opt.g -or $opt.global + +if (!$apps) { + my_usage + exit 1 +} + +if ($global -and !(is_admin)) { + error 'You need admin rights to mark a global app for forcekill.' + exit 1 +} + +foreach ($app in $apps) { + if (!(installed $app $global)) { + if ($global) { + error "'$app' is not installed globally." + } else { + error "'$app' is not installed." + } + $exitcode = 1 + continue + } + + if (get_config NO_JUNCTION) { + $version = Select-CurrentVersion -App $app -Global:$global + } else { + $version = 'current' + } + $dir = versiondir $app $version $global + $json = install_info $app $version $global + if (!$json) { + error "Failed to configure forcekill for '$app'." + $exitcode = 1 + continue + } + $install = @{} + $json.PSObject.Properties | ForEach-Object { $install[$_.Name] = $_.Value } + + $install.forcekill = $true + + if ($opt.service) { + $services = $opt.service -split ',' | ForEach-Object { $_.Trim() } + $valid_services = @() + foreach ($svc in $services) { + if (Get-Service -Name $svc -ErrorAction SilentlyContinue) { + $valid_services += $svc + } else { + warn "Could not find service '$svc'. Skipping." + $exitcode = 1 + } + } + if ($valid_services.Count -gt 0) { + $install.forcekill_services = @($valid_services | Select-Object -Unique) + } + } + + save_install_info $install $dir + success "$app is now marked to force close on update." +} + +exit $exitcode diff --git a/libexec/scoop-reset.ps1 b/libexec/scoop-reset.ps1 index 1dd000a373..3183244307 100644 --- a/libexec/scoop-reset.ps1 +++ b/libexec/scoop-reset.ps1 @@ -10,18 +10,19 @@ . "$PSScriptRoot\..\lib\manifest.ps1" # 'Select-CurrentVersion' (indirectly) . "$PSScriptRoot\..\lib\system.ps1" # 'env_add_path' (indirectly) . "$PSScriptRoot\..\lib\install.ps1" +. "$PSScriptRoot\..\lib\restart_manager.ps1" . "$PSScriptRoot\..\lib\versions.ps1" # 'Select-CurrentVersion' . "$PSScriptRoot\..\lib\shortcuts.ps1" $opt, $apps, $err = getopt $args 'a' 'all' -if($err) { error "scoop reset: $err"; exit 1 } +if ($err) { error "scoop reset: $err"; exit 1 } $all = $opt.a -or $opt.all -if(!$apps -and !$all) { error ' missing'; my_usage; exit 1 } +if (!$apps -and !$all) { error ' missing'; my_usage; exit 1 } -if($apps -eq '*' -or $all) { - $local = installed_apps $false | ForEach-Object { ,@($_, $false) } - $global = installed_apps $true | ForEach-Object { ,@($_, $true) } +if ($apps -eq '*' -or $all) { + $local = installed_apps $false | ForEach-Object { , @($_, $false) } + $global = installed_apps $true | ForEach-Object { , @($_, $true) } $apps = @($local) + @($global) } @@ -30,17 +31,17 @@ $apps | ForEach-Object { $app, $bucket, $version = parse_app $app - if(($global -eq $null) -and (installed $app $true)) { + if (($global -eq $null) -and (installed $app $true)) { # set global flag when running reset command on specific app $global = $true } - if($app -eq 'scoop') { + if ($app -eq 'scoop') { # skip scoop return } - if(!(installed $app)) { + if (!(installed $app)) { error "'$app' isn't installed" return } @@ -57,19 +58,21 @@ $apps | ForEach-Object { return } - if($global -and !(is_admin)) { + if ($global -and !(is_admin)) { warn "'$app' ($version) is a global app. You need admin rights to reset it. Skipping." return } - write-host "Resetting $app ($version)." + Write-Host "Resetting $app ($version)." $dir = Convert-Path (versiondir $app $version $global) $original_dir = $dir $persist_dir = persistdir $app $global #region Workaround for #2952 - if (test_running_process $app $global) { + $running_ret = check_running_process $app $global + $stop_ret = stop_running_process $running_ret + if ($stop_ret.Blocked) { return } #endregion Workaround for #2952 @@ -85,10 +88,43 @@ $apps | ForEach-Object { env_rm $manifest $global $architecture env_add_path $manifest $dir $global $architecture env_set $manifest $global $architecture - # unlink all potential old link before re-persisting unlink_persist_data $manifest $original_dir persist_data $manifest $original_dir $persist_dir persist_permission $manifest $global + + $stopped_services = $stop_ret.ServicesToRestart + $stopped_processes = $stop_ret.ProcessesToRestart + + if ($stopped_services.Count -gt 0) { + foreach ($svc in $stopped_services) { + warn "Restarting service '$svc' associated with '$app'..." + try { + Start-Service -Name $svc -ErrorAction Stop + } catch { + warn "Failed to restart service '$svc': $($_.Exception.Message)" + } + } + } + + if ($stopped_processes.Count -gt 0) { + $new_version = current_version $app $global + $new_processdir = versiondir $app $new_version $global | Convert-Path + foreach ($proc in $stopped_processes) { + if ($proc.StartsWith($original_dir)) { + $proc = $proc.Replace($original_dir, $new_processdir) + } + warn "Restarting process '$(Split-Path $proc -Leaf)' associated with '$app'..." + if (Test-Path $proc) { + try { + Start-Process -FilePath $proc -ErrorAction Stop + } catch { + warn "Failed to restart process '$(Split-Path $proc -Leaf)': $($_.Exception.Message)" + } + } else { + warn "Process executable no longer exists at '$proc'." + } + } + } } exit 0 diff --git a/libexec/scoop-unforcekill.ps1 b/libexec/scoop-unforcekill.ps1 new file mode 100644 index 0000000000..d82b728a4c --- /dev/null +++ b/libexec/scoop-unforcekill.ps1 @@ -0,0 +1,68 @@ +# Usage: scoop unforcekill +# Summary: Remove force close setting from an app +# Help: To unmark a user-scoped app: +# scoop unforcekill +# +# To unmark a global app: +# scoop unforcekill -g +# +# Options: +# -g, --global Unmark globally installed apps + +. "$PSScriptRoot\..\lib\getopt.ps1" +. "$PSScriptRoot\..\lib\json.ps1" +. "$PSScriptRoot\..\lib\manifest.ps1" +. "$PSScriptRoot\..\lib\versions.ps1" +. "$PSScriptRoot\..\lib\core.ps1" +. "$PSScriptRoot\..\lib\install.ps1" + +$opt, $apps, $err = getopt $args 'g' 'global' +if ($err) { "scoop unforcekill: $err"; exit 1 } + +$global = $opt.g -or $opt.global + +if (!$apps) { + my_usage + exit 1 +} + +if ($global -and !(is_admin)) { + error 'You need admin rights to unmark a global app.' + exit 1 +} + +foreach ($app in $apps) { + if (!(installed $app $global)) { + if ($global) { + error "'$app' is not installed globally." + } else { + error "'$app' is not installed." + } + continue + } + + if (get_config NO_JUNCTION) { + $version = Select-CurrentVersion -App $app -Global:$global + } else { + $version = 'current' + } + $dir = versiondir $app $version $global + $json = install_info $app $version $global + if (!$json) { + error "Failed to unmark forcekill for '$app'." + continue + } + $install = @{} + $json | Get-Member -MemberType Properties | ForEach-Object { $install.Add($_.Name, $json.($_.Name)) } + + if (!$install.forcekill -and !$install.forcekill_services) { + info "'$app' is not marked for forcekill." + continue + } + $install.Remove('forcekill') + $install.Remove('forcekill_services') + save_install_info $install $dir + success "$app is no longer marked to force close on update." +} + +exit $exitcode diff --git a/libexec/scoop-uninstall.ps1 b/libexec/scoop-uninstall.ps1 index 7beb0d260e..f951da5296 100644 --- a/libexec/scoop-uninstall.ps1 +++ b/libexec/scoop-uninstall.ps1 @@ -10,6 +10,7 @@ . "$PSScriptRoot\..\lib\manifest.ps1" # 'Get-Manifest' 'Select-CurrentVersion' (indirectly) . "$PSScriptRoot\..\lib\system.ps1" . "$PSScriptRoot\..\lib\install.ps1" +. "$PSScriptRoot\..\lib\restart_manager.ps1" . "$PSScriptRoot\..\lib\download.ps1" # url_filename . "$PSScriptRoot\..\lib\shortcuts.ps1" . "$PSScriptRoot\..\lib\psmodules.ps1" @@ -64,7 +65,9 @@ if (!$apps) { exit 0 } Invoke-HookScript -HookType 'pre_uninstall' -Manifest $manifest -Arch $architecture #region Workaround for #2952 - if (test_running_process $app $global) { + $running_ret = check_running_process $app $global + $stop_ret = stop_running_process $running_ret + if ($stop_ret.Blocked) { continue } #endregion Workaround for #2952 diff --git a/libexec/scoop-update.ps1 b/libexec/scoop-update.ps1 index bc590a13f6..e6f02fc9d7 100644 --- a/libexec/scoop-update.ps1 +++ b/libexec/scoop-update.ps1 @@ -24,6 +24,7 @@ . "$PSScriptRoot\..\lib\versions.ps1" . "$PSScriptRoot\..\lib\depends.ps1" . "$PSScriptRoot\..\lib\install.ps1" +. "$PSScriptRoot\..\lib\restart_manager.ps1" . "$PSScriptRoot\..\lib\download.ps1" if (get_config USE_SQLITE_CACHE) { . "$PSScriptRoot\..\lib\database.ps1" @@ -63,7 +64,7 @@ $show_update_log = get_config SHOW_UPDATE_LOG $true function Sync-Scoop { [CmdletBinding()] - Param ( + param ( [Switch]$Log ) # Test if Scoop Core is hold @@ -153,7 +154,7 @@ function Sync-Scoop { } function Sync-Bucket { - Param ( + param ( [Switch]$Log ) Write-Host 'Updating Buckets...' @@ -293,10 +294,25 @@ function update($app, $global, $quiet = $false, $independent, $suggested, $use_c Write-Host "Updating '$app' ($old_version -> $version)" + $originalAppName = $app + #region Workaround for #2952 - if (test_running_process $app $global) { + $running_ret = check_running_process $app $global + $stop_ret = stop_running_process $running_ret + $stopped_services = @() + $stopped_processes = @() + + if ($stop_ret.Blocked) { Write-Host 'Running process detected, skip updating.' return + } else { + if ($stop_ret.ServicesToRestart) { + $stopped_services = $stop_ret.ServicesToRestart + } + if ($stop_ret.ProcessesToRestart) { + $stopped_processes = $stop_ret.ProcessesToRestart + $old_processdir = $running_ret.ProcessDir + } } #endregion Workaround for #2952 @@ -357,7 +373,7 @@ function update($app, $global, $quiet = $false, $independent, $suggested, $use_c Move-Item "$dir" "$dir/../_$version.old" } else { $i = 1 - While (Test-Path "$dir/../_$version.old($i)") { + while (Test-Path "$dir/../_$version.old($i)") { $i++ } Move-Item "$dir" "$dir/../_$version.old($i)" @@ -383,6 +399,37 @@ function update($app, $global, $quiet = $false, $independent, $suggested, $use_c ensure_none_failed $apps $apps.Where({ !(installed $_) }) + $app | ForEach-Object { install_app $_ $architecture $global $suggested $use_cache $check_hash } } + + if ($stopped_services.Count -gt 0) { + foreach ($svc in $stopped_services) { + warn "Restarting service '$svc' associated with '$originalAppName'..." + try { + Start-Service -Name $svc -ErrorAction Stop + } catch { + warn "Failed to restart service '$svc': $($_.Exception.Message)" + } + } + } + + if ($stopped_processes.Count -gt 0) { + $new_version = current_version $originalAppName $global + $new_processdir = versiondir $originalAppName $new_version $global | Convert-Path + foreach ($proc in $stopped_processes) { + if ($proc.StartsWith($old_processdir)) { + $proc = $proc.Replace($old_processdir, $new_processdir) + } + warn "Restarting process '$(Split-Path $proc -Leaf)' associated with '$originalAppName'..." + if (Test-Path $proc) { + try { + Start-Process -FilePath $proc -ErrorAction Stop + } catch { + warn "Failed to restart process '$(Split-Path $proc -Leaf)': $($_.Exception.Message)" + } + } else { + warn "Process executable no longer exists at '$proc'." + } + } + } } if (-not ($apps -or $all)) { diff --git a/test/Scoop-Install.Tests.ps1 b/test/Scoop-Install.Tests.ps1 index 3e564d1964..c0c7e6367a 100644 --- a/test/Scoop-Install.Tests.ps1 +++ b/test/Scoop-Install.Tests.ps1 @@ -4,6 +4,7 @@ BeforeAll { . "$PSScriptRoot\..\lib\system.ps1" . "$PSScriptRoot\..\lib\manifest.ps1" . "$PSScriptRoot\..\lib\install.ps1" + . "$PSScriptRoot\..\lib\restart_manager.ps1" } Describe 'appname_from_url' -Tag 'Scoop' { @@ -100,3 +101,251 @@ Describe 'persist_def' -Tag 'Scoop' { $target | Should -Be 'foo' } } + +Describe 'check_running_process' -Tag 'Scoop', 'Windows' { + BeforeAll { + $global = $false + } + + It 'returns correct hashtable when forcekill is enabled with RM-detected service' { + Mock install_info { return @{ forcekill = $true; forcekill_services = @('manual_svc') } } + Mock get_config { return $false } + Mock versiondir { return 'C:\test\app\current' } + Mock Convert-Path { return 'C:\test\app\current' } + Mock Get-RunningProcessesInDir { return @() } + Mock Get-CimInstance { return @() } + Mock Get-LockingProcesses { + return @( + [PSCustomObject]@{ Id = 123; Name = 'app'; AppName = 'app'; Path = 'C:\test\app\current\app.exe'; ApplicationType = 'Console'; ServiceName = ''; Restartable = $false; MainWindowHandle = [IntPtr]::Zero }, + [PSCustomObject]@{ Id = 456; Name = 'app_svc'; AppName = 'app_svc'; Path = 'C:\test\app\current\app.exe'; ApplicationType = 'Service'; ServiceName = 'rm_svc'; Restartable = $true; MainWindowHandle = [IntPtr]::Zero } + ) + } + + $result = check_running_process 'testapp' $global + $result.Blocked | Should -BeFalse + $result.Forcekill | Should -BeTrue + $result.ServicesToStop.Count | Should -Be 2 + $result.ServicesToStop | Should -Contain 'rm_svc' + $result.ServicesToStop | Should -Contain 'manual_svc' + $result.RunningProcesses.Count | Should -Be 2 + } + + It 'blocks when processes are running and forcekill is disabled' { + Mock install_info { return $null } + Mock get_config { return $false } + Mock versiondir { return 'C:\test\app\current' } + Mock Convert-Path { return 'C:\test\app\current' } + Mock Get-RunningProcessesInDir { return @() } + Mock Get-LockingProcesses { + return @( + [PSCustomObject]@{ Id = 123; Name = 'app'; AppName = 'app'; Path = 'C:\test\app\current\app.exe'; ApplicationType = 'Console'; ServiceName = ''; Restartable = $false; MainWindowHandle = [IntPtr]::Zero } + ) + } + + $result = check_running_process 'testapp' $global + $result.Blocked | Should -BeTrue + $result.Forcekill | Should -BeFalse + $result.RunningProcesses.Count | Should -Be 1 + } + + It 'filters out Explorer and Critical processes' { + Mock install_info { return $null } + Mock get_config { return $false } + Mock versiondir { return 'C:\test\app\current' } + Mock Convert-Path { return 'C:\test\app\current' } + Mock Get-RunningProcessesInDir { return @() } + Mock Get-LockingProcesses { + return @( + [PSCustomObject]@{ Id = 100; Name = 'app'; AppName = 'app'; Path = 'C:\test\app\current\app.exe'; ApplicationType = 'Console'; ServiceName = ''; Restartable = $false; MainWindowHandle = [IntPtr]::Zero }, + [PSCustomObject]@{ Id = 200; Name = 'explorer'; AppName = 'Windows Explorer'; Path = 'C:\WINDOWS\Explorer.EXE'; ApplicationType = 'Explorer'; ServiceName = ''; Restartable = $true; MainWindowHandle = [IntPtr]::Zero }, + [PSCustomObject]@{ Id = 300; Name = 'csrss'; AppName = 'csrss'; Path = 'C:\WINDOWS\system32\csrss.exe'; ApplicationType = 'Critical'; ServiceName = ''; Restartable = $false; MainWindowHandle = [IntPtr]::Zero } + ) + } + + $result = check_running_process 'testapp' $global + $result.RunningProcesses.Count | Should -Be 1 + $result.RunningProcesses[0].Id | Should -Be 100 + } + + It 'falls back to executable-path scan when Restart Manager misses a process' { + Mock install_info { return $null } + Mock get_config { return $false } + Mock versiondir { return 'C:\test\app\current' } + Mock Convert-Path { return 'C:\test\app\current' } + Mock Get-LockingProcesses { return @() } + Mock Get-RunningProcessesInDir { + return @( + [PSCustomObject]@{ Id = 321; Name = 'fallback_app'; AppName = 'fallback_app'; Path = 'C:\test\app\current\fallback.exe'; ApplicationType = 'Unknown'; ServiceName = $null; Restartable = $false; MainWindowHandle = [IntPtr]::Zero } + ) + } + + $result = check_running_process 'testapp' $global + $result.Blocked | Should -BeTrue + $result.RunningProcesses.Count | Should -Be 1 + $result.RunningProcesses[0].Id | Should -Be 321 + } + + It 'includes WMI-discovered services when forcekill is enabled' { + Mock install_info { return @{ forcekill = $true } } + Mock get_config { return $false } + Mock versiondir { return 'C:\test\app\current' } + Mock Convert-Path { return 'C:\test\app\current' } + Mock Get-LockingProcesses { return @() } + Mock Get-RunningProcessesInDir { return @() } + Mock Get-CimInstance { return @([PSCustomObject]@{ Name = 'wmi_svc' }) } + + $result = check_running_process 'testapp' $global + $result.Blocked | Should -BeFalse + $result.Forcekill | Should -BeTrue + $result.ServicesToStop.Count | Should -Be 1 + $result.ServicesToStop[0] | Should -Be 'wmi_svc' + } +} + +Describe 'stop_running_process' -Tag 'Scoop', 'Windows' { + BeforeAll { + Mock warn {} + Mock error {} + Mock Write-Host {} + } + + It 'kills background process and adds to returned restart list' { + $test_result = @{ + Blocked = $false + Forcekill = $true + App = 'testapp' + RunningProcesses = @( + [PSCustomObject]@{ Path = 'C:\test\app\current\app.exe'; Id = 123; Name = 'app'; ApplicationType = 'Console'; MainWindowHandle = [IntPtr]::Zero } + ) + ServicesToStop = @() + ProcessDir = 'C:\test\app\current' + } + + Mock Stop-Process {} + Mock Get-LockingProcesses { return @() } # No processes still locking after kill + Mock Get-RunningProcessesInDir { return @() } + + $result = stop_running_process $test_result + + $result.Blocked | Should -BeFalse + $result.ProcessesToRestart.Count | Should -Be 1 + $result.ProcessesToRestart[0] | Should -Be 'C:\test\app\current\app.exe' + + Should -Invoke -CommandName Stop-Process -Times 1 -ParameterFilter { $Id -eq 123 } + } + + It 'does NOT restart processes that had visible windows' { + $test_result = @{ + Blocked = $false + Forcekill = $true + App = 'testapp' + RunningProcesses = @( + [PSCustomObject]@{ Path = 'C:\test\app\current\app.exe'; Id = 124; Name = 'app'; ApplicationType = 'Console'; MainWindowHandle = [IntPtr]1 } + ) + ServicesToStop = @() + ProcessDir = 'C:\test\app\current' + } + + Mock Stop-Process {} + Mock Get-LockingProcesses { return @() } + Mock Get-RunningProcessesInDir { return @() } + + $result = stop_running_process $test_result + + $result.Blocked | Should -BeFalse + $result.ProcessesToRestart.Count | Should -Be 0 + Should -Invoke -CommandName Stop-Process -Times 1 -ParameterFilter { $Id -eq 124 } + } + + It 'does not try to kill Service-type processes (handled separately)' { + $test_result = @{ + Blocked = $false + Forcekill = $true + App = 'testapp' + RunningProcesses = @( + [PSCustomObject]@{ Path = 'C:\test\app\current\svc.exe'; Id = 500; Name = 'app_svc'; ApplicationType = 'Service'; MainWindowHandle = [IntPtr]::Zero } + ) + ServicesToStop = @('test_svc') + ProcessDir = 'C:\test\app\current' + } + + $Script:mock_svc_status = 'Running' + Mock is_admin { return $true } + Mock Stop-Process {} + Mock Get-LockingProcesses { return @() } + Mock Get-Service { return [PSCustomObject]@{ Status = $Script:mock_svc_status } } -ParameterFilter { $Name -eq 'test_svc' } + Mock Stop-Service { $Script:mock_svc_status = 'Stopped' } + + $result = stop_running_process $test_result + + # Service-type processes should not be killed via Stop-Process + Should -Invoke -CommandName Stop-Process -Times 0 + # But the service should be stopped via Stop-Service + Should -Invoke -CommandName Stop-Service -Times 1 -ParameterFilter { $Name -eq 'test_svc' } + $result.ServicesToRestart.Count | Should -Be 1 + $result.ServicesToRestart[0] | Should -Be 'test_svc' + } + + It 'stops services when admin and adds them to restart list' { + $test_result = @{ + Blocked = $false + Forcekill = $true + App = 'testapp' + RunningProcesses = @() + ServicesToStop = @('test_svc') + ProcessDir = 'C:\test\app\current' + } + + $Script:mock_svc_status = 'Running' + Mock is_admin { return $true } + Mock Get-Service { return [PSCustomObject]@{ Status = $Script:mock_svc_status } } -ParameterFilter { $Name -eq 'test_svc' } + Mock Stop-Service { $Script:mock_svc_status = 'Stopped' } + + $result = stop_running_process $test_result + + $result.Blocked | Should -BeFalse + $result.ServicesToRestart.Count | Should -Be 1 + $result.ServicesToRestart[0] | Should -Be 'test_svc' + + Should -Invoke -CommandName Stop-Service -Times 1 -ParameterFilter { $Name -eq 'test_svc' } + } + + It 'blocks service forcekill if not admin' { + $test_result = @{ + Blocked = $false + Forcekill = $true + App = 'testapp' + RunningProcesses = @() + ServicesToStop = @('test_svc') + ProcessDir = 'C:\test\app\current' + } + + Mock is_admin { return $false } + Mock Get-Service { return [PSCustomObject]@{ Status = 'Running' } } -ParameterFilter { $Name -eq 'test_svc' } + + $result = stop_running_process $test_result + + $result.Blocked | Should -BeTrue + } + + It 'reports service-only blockers when forcekill is disabled' { + $test_result = @{ + Blocked = $true + Forcekill = $false + App = 'testapp' + RunningProcesses = @( + [PSCustomObject]@{ Path = 'C:\test\app\current\svc.exe'; Id = 700; Name = 'svc_host'; ApplicationType = 'Service'; ServiceName = 'test_svc'; MainWindowHandle = [IntPtr]::Zero } + ) + ServicesToStop = @() + ProcessDir = 'C:\test\app\current' + } + + Mock get_config { return $false } + + $result = stop_running_process $test_result + + $result.Blocked | Should -BeTrue + Should -Invoke -CommandName error -Times 1 + } +} +