safe-disable-startup.ps1 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  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. . (Join-Path $PSScriptRoot '..\..\_lib\term.ps1')
  54. Initialize-Term
  55. # Map: registry path -> StartupApproved variant for the overlay
  56. $pathVariantMap = @(
  57. @{ Path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'; Variant = 'Run' }
  58. @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'; Variant = 'Run' }
  59. @{ Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'; Variant = 'Run32' }
  60. )
  61. function Get-RunEntries {
  62. $entries = @()
  63. foreach ($m in $pathVariantMap) {
  64. if (Test-Path $m.Path) {
  65. (Get-ItemProperty $m.Path -ErrorAction SilentlyContinue).PSObject.Properties |
  66. Where-Object { $_.Name -notmatch '^PS' } |
  67. ForEach-Object {
  68. $entries += [PSCustomObject]@{
  69. Name = $_.Name
  70. Command = $_.Value
  71. Path = $m.Path
  72. Variant = $m.Variant
  73. }
  74. }
  75. }
  76. }
  77. # Startup folder shortcuts use a separate StartupApproved variant
  78. foreach ($d in @("$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup",
  79. "$env:ALLUSERSPROFILE\Microsoft\Windows\Start Menu\Programs\StartUp")) {
  80. if (Test-Path $d) {
  81. Get-ChildItem $d -Filter *.lnk -ErrorAction SilentlyContinue | ForEach-Object {
  82. $entries += [PSCustomObject]@{
  83. Name = $_.Name # full filename, e.g. "Comet.lnk"
  84. Command = $_.FullName
  85. Path = $d
  86. Variant = 'StartupFolder'
  87. }
  88. }
  89. }
  90. }
  91. return $entries
  92. }
  93. function Get-CurrentState {
  94. param(
  95. [Parameter(Mandatory)][string]$EntryName,
  96. [Parameter(Mandatory)][ValidateSet('Run','Run32','StartupFolder')][string]$Variant
  97. )
  98. $key = "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\$Variant"
  99. if (-not (Test-Path $key)) { return 'unmanaged' }
  100. $val = (Get-ItemProperty $key -Name $EntryName -ErrorAction SilentlyContinue).$EntryName
  101. if (-not $val) { return 'unmanaged' } # No overlay = uses default (enabled)
  102. if ($val[0] -eq 0x03) { return 'disabled' }
  103. elseif ($val[0] -eq 0x02) { return 'enabled' }
  104. else { return "unknown(0x{0:X2})" -f $val[0] }
  105. }
  106. # ─────────────────────────────────────────────────────────────────────
  107. # Mode: List
  108. # ─────────────────────────────────────────────────────────────────────
  109. if ($List) {
  110. $allEntries = Get-RunEntries
  111. $rows = foreach ($e in $allEntries) {
  112. $state = Get-CurrentState -EntryName $e.Name -Variant $e.Variant
  113. [PSCustomObject]@{
  114. Name = $e.Name
  115. State = $state
  116. Variant = $e.Variant
  117. Source = (Split-Path $e.Path -Leaf) + '\' + (Split-Path $e.Path -Parent | Split-Path -Leaf)
  118. Command = $e.Command -replace '"',''
  119. }
  120. }
  121. if ($Json) {
  122. foreach ($r in $rows) {
  123. [Console]::Out.WriteLine(($r | ConvertTo-Json -Compress))
  124. }
  125. exit $script:EXIT_OK
  126. }
  127. # Group by state for the panel
  128. $enabled = $rows | Where-Object { $_.State -eq 'enabled' -or $_.State -eq 'unmanaged' }
  129. $disabled = $rows | Where-Object { $_.State -eq 'disabled' }
  130. $unknown = $rows | Where-Object { $_.State -ne 'enabled' -and $_.State -ne 'unmanaged' -and $_.State -ne 'disabled' }
  131. Write-TermLine (New-TermPanelOpen -Brand 'windows-ops' -Name 'windows-ops' -Subtitle 'safe-disable-startup' -Indicator "$($rows.Count) entries")
  132. Write-TermLine (New-TermPanelVert)
  133. $summary = "$($enabled.Count) active · $($disabled.Count) disabled"
  134. if ($unknown.Count -gt 0) { $summary += " · $($unknown.Count) unknown" }
  135. Write-TermLine (New-TermSummary -Text $summary)
  136. Write-TermLine (New-TermPanelVert)
  137. if ($enabled) {
  138. Write-TermLine (New-TermSection -State 'PASS' -Label 'active' -Count $enabled.Count)
  139. $last = $enabled[-1]
  140. foreach ($e in $enabled) {
  141. $variant = switch ($e.Variant) { 'Run' { 'HKCU/HKLM' } 'Run32' { 'WOW64' } 'StartupFolder' { 'startup folder' } default { $e.Variant } }
  142. Write-TermLine (New-TermLeaf -Name $e.Name -Meta $variant -IsLast:($e -eq $last))
  143. }
  144. Write-TermLine (New-TermPanelVert)
  145. }
  146. if ($disabled) {
  147. Write-TermLine (New-TermSection -State 'WARN' -Label 'disabled' -Count $disabled.Count)
  148. $last = $disabled[-1]
  149. foreach ($e in $disabled) {
  150. $variant = switch ($e.Variant) { 'Run' { 'HKCU/HKLM' } 'Run32' { 'WOW64' } 'StartupFolder' { 'startup folder' } default { $e.Variant } }
  151. Write-TermLine (New-TermLeaf -Name $e.Name -Meta $variant -IsLast:($e -eq $last))
  152. }
  153. Write-TermLine (New-TermPanelVert)
  154. }
  155. if ($unknown) {
  156. Write-TermLine (New-TermSection -State 'FAILING' -Label 'unknown state' -Count $unknown.Count)
  157. $last = $unknown[-1]
  158. foreach ($e in $unknown) {
  159. $variant = switch ($e.Variant) { 'Run' { 'HKCU/HKLM' } 'Run32' { 'WOW64' } 'StartupFolder' { 'startup folder' } default { $e.Variant } }
  160. Write-TermLine (New-TermLeaf -Name $e.Name -Meta $variant -Age $e.State -IsLast:($e -eq $last))
  161. Write-TermLine (New-TermAlert -Severity warning -Text 'partial/corrupt StartupApproved entry — verify with Task Manager')
  162. }
  163. Write-TermLine (New-TermPanelVert)
  164. }
  165. $hk = @(
  166. (New-TermHotkey -Key 'E' -Verb 'enable')
  167. (New-TermHotkey -Key 'D' -Verb 'disable')
  168. (New-TermHotkey -Key '?' -Verb 'help')
  169. ) | Join-TermHotkeys
  170. $hl = @(
  171. (New-TermHealth -State 'healthy' -Text "$($enabled.Count) active")
  172. ) | Join-TermHealths
  173. Write-TermLine (New-TermPanelClose -Hotkeys $hk -Healths $hl)
  174. exit $script:EXIT_OK
  175. }
  176. # ─────────────────────────────────────────────────────────────────────
  177. # Mode: Disable/Enable
  178. # ─────────────────────────────────────────────────────────────────────
  179. if (-not $Name) {
  180. Write-Log -Level ERROR -Message "Must provide -Name or -List. See -? for help."
  181. exit $script:EXIT_USAGE
  182. }
  183. $statusByte = if ($Enable) { [byte]0x02 } else { [byte]0x03 }
  184. $action = if ($Enable) { 'enable' } else { 'disable' }
  185. $valueBytes = ConvertTo-Bytes12 -StatusByte $statusByte
  186. $allEntries = Get-RunEntries
  187. $matched = @()
  188. foreach ($pattern in $Name) {
  189. $hits = $allEntries | Where-Object { $_.Name -like $pattern }
  190. if (-not $hits) {
  191. Write-Log -Level WARN -Message "No Run-key entries match pattern: $pattern"
  192. continue
  193. }
  194. foreach ($e in $hits) {
  195. if ($PSCmdlet.ShouldProcess("$($e.Name) (Variant=$($e.Variant))", "$action via StartupApproved\$($e.Variant)")) {
  196. try {
  197. $key = Get-StartupApprovedKey -Variant $e.Variant
  198. Set-ItemProperty -Path $key -Name $e.Name -Value $valueBytes -Type Binary -Force
  199. $matched += $e
  200. $verified = Get-CurrentState -EntryName $e.Name -Variant $e.Variant
  201. Write-Log -Level PASS -Message "${action}d: $($e.Name) [$($e.Variant)] -> verified state: $verified"
  202. if ($Json) {
  203. [Console]::Out.WriteLine((@{
  204. action = $action
  205. name = $e.Name
  206. variant = $e.Variant
  207. verified = $verified
  208. } | ConvertTo-Json -Compress))
  209. }
  210. } catch {
  211. Write-Log -Level FAIL -Message "Failed to $action $($e.Name): $_"
  212. }
  213. }
  214. }
  215. }
  216. if (-not $matched) {
  217. Write-Log -Level ERROR -Message "No matching entries acted on."
  218. exit $script:EXIT_NOT_FOUND
  219. }
  220. if (-not $Json -and -not $Quiet) {
  221. [Console]::Error.WriteLine("")
  222. [Console]::Error.WriteLine("$($matched.Count) entr$(if ($matched.Count -eq 1) {'y'} else {'ies'}) ${action}d. Effect applies at next user logon.")
  223. [Console]::Error.WriteLine("Re-run with -List to verify.")
  224. }
  225. exit $script:EXIT_OK