safe-disable-startup.ps1 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. <#
  2. .SYNOPSIS
  3. Disable (or re-enable) Windows startup entries via the StartupApproved
  4. registry mechanism — no admin required, fully reversible.
  5. .DESCRIPTION
  6. Equivalent of Task Manager's "Disable" button: writes a 12-byte binary
  7. flag to HKCU\...\Explorer\StartupApproved\{Run,Run32,StartupFolder}
  8. so the entry is skipped at next logon. Works on HKLM entries from a
  9. non-admin context (overlay applies per-user only).
  10. For an entry to be disable-able by this script it must exist in one of:
  11. - HKCU/HKLM\...\CurrentVersion\Run (64-bit)
  12. - HKLM\...\WOW6432Node\Microsoft\...\Run (32-bit)
  13. - Startup folders (user + all-users)
  14. Services and scheduled tasks are NOT touched by this script — those
  15. need Set-Service / Disable-ScheduledTask respectively.
  16. .PARAMETER Name
  17. The Run-key value name to disable. Multiple names accepted (positional
  18. or via pipeline).
  19. .PARAMETER Enable
  20. Re-enable instead of disable (flips status byte 0x03 -> 0x02).
  21. .PARAMETER List
  22. List current state of all StartupApproved entries and exit. Ignores -Name.
  23. .PARAMETER Json
  24. Emit machine-readable JSON of the action taken.
  25. .EXAMPLE
  26. scripts/safe-disable-startup.ps1 -Name 'Adobe Creative Cloud'
  27. Disable a single entry by exact value name.
  28. .EXAMPLE
  29. scripts/safe-disable-startup.ps1 -Name 'Granola','MuseHub','CometUpdaterTask*'
  30. Disable multiple entries; wildcards expand against actual Run-key entries.
  31. .EXAMPLE
  32. scripts/safe-disable-startup.ps1 -List
  33. Show current enabled/disabled state of every known startup entry.
  34. .EXAMPLE
  35. scripts/safe-disable-startup.ps1 -Name 'Adobe Creative Cloud' -Enable
  36. Re-enable a previously-disabled entry.
  37. .NOTES
  38. Exit codes:
  39. 0 success
  40. 2 usage (no names given and not -List)
  41. 3 not found (no matching Run-key entry for the given name)
  42. 4 validation error
  43. #>
  44. [CmdletBinding(SupportsShouldProcess)]
  45. param(
  46. [Parameter(ValueFromPipeline, Position=0)][string[]]$Name,
  47. [switch]$Enable,
  48. [switch]$List,
  49. [switch]$Json
  50. )
  51. $ErrorActionPreference = 'Stop'
  52. . "$PSScriptRoot\_lib\common.ps1"
  53. # Map: registry path -> StartupApproved variant for the overlay
  54. $pathVariantMap = @(
  55. @{ Path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'; Variant = 'Run' }
  56. @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'; Variant = 'Run' }
  57. @{ Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'; Variant = 'Run32' }
  58. )
  59. function Get-RunEntries {
  60. $entries = @()
  61. foreach ($m in $pathVariantMap) {
  62. if (Test-Path $m.Path) {
  63. (Get-ItemProperty $m.Path -ErrorAction SilentlyContinue).PSObject.Properties |
  64. Where-Object { $_.Name -notmatch '^PS' } |
  65. ForEach-Object {
  66. $entries += [PSCustomObject]@{
  67. Name = $_.Name
  68. Command = $_.Value
  69. Path = $m.Path
  70. Variant = $m.Variant
  71. }
  72. }
  73. }
  74. }
  75. # Startup folder shortcuts use a separate StartupApproved variant
  76. foreach ($d in @("$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup",
  77. "$env:ALLUSERSPROFILE\Microsoft\Windows\Start Menu\Programs\StartUp")) {
  78. if (Test-Path $d) {
  79. Get-ChildItem $d -Filter *.lnk -ErrorAction SilentlyContinue | ForEach-Object {
  80. $entries += [PSCustomObject]@{
  81. Name = $_.Name # full filename, e.g. "Comet.lnk"
  82. Command = $_.FullName
  83. Path = $d
  84. Variant = 'StartupFolder'
  85. }
  86. }
  87. }
  88. }
  89. return $entries
  90. }
  91. function Get-CurrentState {
  92. param(
  93. [Parameter(Mandatory)][string]$EntryName,
  94. [Parameter(Mandatory)][ValidateSet('Run','Run32','StartupFolder')][string]$Variant
  95. )
  96. $key = "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\$Variant"
  97. if (-not (Test-Path $key)) { return 'unmanaged' }
  98. $val = (Get-ItemProperty $key -Name $EntryName -ErrorAction SilentlyContinue).$EntryName
  99. if (-not $val) { return 'unmanaged' } # No overlay = uses default (enabled)
  100. if ($val[0] -eq 0x03) { return 'disabled' }
  101. elseif ($val[0] -eq 0x02) { return 'enabled' }
  102. else { return "unknown(0x{0:X2})" -f $val[0] }
  103. }
  104. # ─────────────────────────────────────────────────────────────────────
  105. # Mode: List
  106. # ─────────────────────────────────────────────────────────────────────
  107. if ($List) {
  108. $allEntries = Get-RunEntries
  109. foreach ($e in $allEntries) {
  110. $state = Get-CurrentState -EntryName $e.Name -Variant $e.Variant
  111. $row = [PSCustomObject]@{
  112. Name = $e.Name
  113. State = $state
  114. Variant = $e.Variant
  115. Source = (Split-Path $e.Path -Leaf) + '\' + (Split-Path $e.Path -Parent | Split-Path -Leaf)
  116. Command = $e.Command -replace '"',''
  117. }
  118. if ($Json) {
  119. [Console]::Out.WriteLine(($row | ConvertTo-Json -Compress))
  120. } else {
  121. $tag = switch ($state) { 'disabled' {'[X]'} 'enabled' {'[ ]'} default {'[?]'} }
  122. [Console]::Out.WriteLine(("{0} {1,-40} {2,-7} {3}" -f $tag, $e.Name.Substring(0, [Math]::Min(40, $e.Name.Length)), $state, $e.Variant))
  123. }
  124. }
  125. exit $script:EXIT_OK
  126. }
  127. # ─────────────────────────────────────────────────────────────────────
  128. # Mode: Disable/Enable
  129. # ─────────────────────────────────────────────────────────────────────
  130. if (-not $Name) {
  131. Write-Log -Level ERROR -Message "Must provide -Name or -List. See -? for help."
  132. exit $script:EXIT_USAGE
  133. }
  134. $statusByte = if ($Enable) { [byte]0x02 } else { [byte]0x03 }
  135. $action = if ($Enable) { 'enable' } else { 'disable' }
  136. $valueBytes = ConvertTo-Bytes12 -StatusByte $statusByte
  137. $allEntries = Get-RunEntries
  138. $matched = @()
  139. foreach ($pattern in $Name) {
  140. $hits = $allEntries | Where-Object { $_.Name -like $pattern }
  141. if (-not $hits) {
  142. Write-Log -Level WARN -Message "No Run-key entries match pattern: $pattern"
  143. continue
  144. }
  145. foreach ($e in $hits) {
  146. if ($PSCmdlet.ShouldProcess("$($e.Name) (Variant=$($e.Variant))", "$action via StartupApproved\$($e.Variant)")) {
  147. try {
  148. $key = Get-StartupApprovedKey -Variant $e.Variant
  149. Set-ItemProperty -Path $key -Name $e.Name -Value $valueBytes -Type Binary -Force
  150. $matched += $e
  151. $verified = Get-CurrentState -EntryName $e.Name -Variant $e.Variant
  152. Write-Log -Level PASS -Message "${action}d: $($e.Name) [$($e.Variant)] -> verified state: $verified"
  153. if ($Json) {
  154. [Console]::Out.WriteLine((@{
  155. action = $action
  156. name = $e.Name
  157. variant = $e.Variant
  158. verified = $verified
  159. } | ConvertTo-Json -Compress))
  160. }
  161. } catch {
  162. Write-Log -Level FAIL -Message "Failed to $action $($e.Name): $_"
  163. }
  164. }
  165. }
  166. }
  167. if (-not $matched) {
  168. Write-Log -Level ERROR -Message "No matching entries acted on."
  169. exit $script:EXIT_NOT_FOUND
  170. }
  171. if (-not $Json -and -not $Quiet) {
  172. [Console]::Error.WriteLine("")
  173. [Console]::Error.WriteLine("$($matched.Count) entr$(if ($matched.Count -eq 1) {'y'} else {'ies'}) ${action}d. Effect applies at next user logon.")
  174. [Console]::Error.WriteLine("Re-run with -List to verify.")
  175. }
  176. exit $script:EXIT_OK