Windows 11 更新至 26H2 後,無法開啟剪取工具、小畫家、小算盤、記事本等 App

  最近把公司一批 Windows 11 從 22H2 用 ISO 就地升級到 26H2,升級本身都很順利,但有幾台升級完後,使用者回報記事本、小畫家、計算機、螢幕擷取工具都打不開,部分電腦連 App 都無法安裝或移除。

  不是每一台都會發生,網路上也有人在 24H2、25H2 更新後遇到類似狀況。這次一樣是搭配 Claude Code 分析事件記錄,最後發現問題出在 Windows 登入時的「應用程式整備」卡住,把過程簡單記錄一下。

【症狀】

  • 升級方式:Windows 11 22H2 → 26H2,ISO 就地升級(保留檔案與應用程式)
  • 無法開啟:記事本、小畫家、計算機、螢幕擷取工具(Microsoft Store 也會轉圈)
  • 事件記錄:TWinUI 5961,啟用階段 Deployment 逾時
  • 手動 Add-AppxPackage -Register 也會卡住,最後出現 Failed to reach state DeQueued

【原因:AppReadiness 卡在移除舊套件】

  升級後使用者第一次登入時,Windows 會在背景執行 AppReadiness(應用程式整備)de ART:UserLogon 工作,先把 22H2 留在使用者設定檔裡的舊版 App 移除,再註冊新版 App。這個工作只要卡住,後面所有 App 的註冊都會一直排隊,App 自然就打不開。

  這次有問題的電腦,卡的位置都很一致:

  1. 先卡在移除 Microsoft.XboxGameCallableUI,一卡就是幾十分鐘到幾小時
  2. 重開機後這個過了,接著出現找不到 WindowsAppRuntime 的錯誤
STUCK  uninstall Microsoft.XboxGameCallableUI_cw5n1h2txyewy
FAIL   uninstall Microsoft.WindowsAppRuntime.1.4_8wekyb3d8bbwe

【解法:多重開幾次】

  我們升級完剛好 WSUS 也推了 26H2 的累積更新及 .NET 累積更新,但從記錄來看,真正有效的應該是多次重開機:每次開機登入,AppReadiness 會重跑一次,上次卡住的套件通常就過了,再往下處理下一個。目前大多數電腦重開約 3 次就恢復正常。

  理論上「登出再登入」也會重跑這個工作,或許也能解決,不過我還沒實測過。另外要注意,Windows 預設開啟「快速啟動」,按「關機」不等於完整重開,要選「重新啟動」。

【快速查看 AppReadiness 狀態】

  為了知道每次重開到底卡在哪、解決了哪個,寫了一支唯讀的 PowerShell,會依每次開機列出 AppReadiness 的處理結果,用來判斷還需不需要再重開。存成 Show-AppReadinessLog.ps1:

# =======================================================================
# Show-AppReadinessLog.ps1
# Read-only report of the per-user "App Readiness" logon tasks (ART:UserLogon /
# ART:UserFirstLogon) grouped by boot, so you can see on each restart:
#   - which package removals/installs finished (OK),
#   - which one was stuck / aborted (FAIL with long duration),
#   - what was still pending when the task was cancelled,
#   - which previously stuck package got resolved in a later boot.
# Also lists AppX deployment operations that started but never finished in the
# latest boot (best effort, correlated by ActivityId).
#
# Usage on the affected PC:
#   Right-click the file -> "Run with PowerShell". It asks for admin rights (UAC) if
#   needed, shows the report and waits for Enter before closing.
#   Command line:
#     powershell -ExecutionPolicy Bypass -File .\Show-AppReadinessLog.ps1 -Days 5
#   add -ShowAll to also list every package operation that finished OK
#   save to a file (no pause):
#     powershell -ExecutionPolicy Bypass -File .\Show-AppReadinessLog.ps1 -NoPause *> $env:USERPROFILE\Desktop\AppReadiness.txt
# Offline (folder containing copied .evtx files: System, AppReadiness Admin/Operational,
# AppXDeploymentServer Operational):
#   .\Show-AppReadinessLog.ps1 -LogFolder D:\evidence\PC01
#
# This script only reads event logs. It changes nothing.
# =======================================================================
param(
  [int]$Days = 3,
  [string]$LogFolder = '',
  [int]$StuckSeconds = 300,
  [switch]$ShowAll,
  [switch]$NoPause
)

# Keep the window open at the end (for right-click "Run with PowerShell").
function Wait-Close {
  if (-not $NoPause) { Write-Host ''; Read-Host 'Done. Press Enter to close' | Out-Null }
}
trap { Write-Host "ERROR: $_" -ForegroundColor Red; Wait-Close; break }

# Event logs need admin rights: relaunch elevated if needed (live mode only).
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin -and -not $LogFolder) {
  Write-Host 'Not running as administrator. Relaunching elevated (please accept the UAC prompt)...'
  $relaunch = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"", '-Days', $Days, '-StuckSeconds', $StuckSeconds)
  if ($ShowAll) { $relaunch += '-ShowAll' }
  try { Start-Process powershell.exe -Verb RunAs -ArgumentList $relaunch -ErrorAction Stop }
  catch { Write-Host "Elevation cancelled or failed: $($_.Exception.Message)" -ForegroundColor Red; Wait-Close }
  return
}

# Wider console so long lines do not wrap (ignored where not supported).
try { $raw = $Host.UI.RawUI; if ($raw.BufferSize.Width -lt 220) { $raw.BufferSize = New-Object Management.Automation.Host.Size(220, [Math]::Max($raw.BufferSize.Height, 3000)) } } catch { }

$start = (Get-Date).AddDays(-$Days)

function Get-Ev([string]$LogName, [string]$File, [hashtable]$Extra) {
  $f = @{ StartTime = $start }
  if ($Extra) { foreach ($k in $Extra.Keys) { $f[$k] = $Extra[$k] } }
  if ($LogFolder) { $f['Path'] = (Join-Path $LogFolder $File) } else { $f['LogName'] = $LogName }
  try { Get-WinEvent -FilterHashtable $f -ErrorAction Stop } catch { @() }
}

function Get-UserName([string]$Sid) {
  try { ([System.Security.Principal.SecurityIdentifier]$Sid).Translate([System.Security.Principal.NTAccount]).Value } catch { $Sid }
}

# Quoted values in the message, excluding the user SID (word order differs by OS language).
function Get-Quoted([string]$Msg) {
  $r = @(); foreach ($m in [regex]::Matches($Msg, "'([^']*)'")) { $v = $m.Groups[1].Value; if ($v -notmatch '^S-1-5-') { $r += $v } }; return ,$r
}

# "remove::X" / "removePackage:X" -> "remove X" so duplicates collapse.
function Get-Item2([string]$Raw) {
  $m = [regex]::Match($Raw, '^(install|remove|register|update|uninstall)[A-Za-z]*:{1,2}(.+)$')
  if ($m.Success) { '{0} {1}' -f $m.Groups[1].Value, $m.Groups[2].Value } else { $Raw }
}

function Get-Secs([string]$Msg) {
  $m = [regex]::Match($Msg, '\(([\d\.]+)\s*[^\)]*\)\s*$')
  if ($m.Success) { [double]$m.Groups[1].Value } else { $null }
}

function Get-Op([string]$Msg, [string]$Pkg) {
  $m = [regex]::Match($Msg, [regex]::Escape("'$Pkg'") + '\s*([A-Za-z]+)')
  if ($m.Success) { $m.Groups[1].Value } else { '' }
}

# ---- boots -------------------------------------------------------------
$boots = @(Get-Ev 'System' 'System.evtx' @{ ProviderName = 'Microsoft-Windows-Kernel-General'; Id = 12 } | Sort-Object TimeCreated)
$restarts = @(Get-Ev 'System' 'System.evtx' @{ ProviderName = 'User32'; Id = 1074 })
$powerLoss = @(Get-Ev 'System' 'System.evtx' @{ ProviderName = 'Microsoft-Windows-Kernel-Power'; Id = 41 })
if ($boots.Count -eq 0) { Write-Host "No boot events found in the last $Days days."; Wait-Close; return }

# ---- App Readiness events ---------------------------------------------
$ids = 213,214,218,220,225,240,313
$ar = @()
$ar += Get-Ev 'Microsoft-Windows-AppReadiness/Admin' 'Microsoft-Windows-AppReadiness%4Admin.evtx' @{ Id = $ids }
$ar += Get-Ev 'Microsoft-Windows-AppReadiness/Operational' 'Microsoft-Windows-AppReadiness%4Operational.evtx' @{ Id = $ids }
$seen = @{}
$ar = @($ar | Sort-Object TimeCreated, RecordId | Where-Object {
  $k = '{0:o}|{1}|{2}' -f $_.TimeCreated, $_.Id, $_.Message
  if ($seen.ContainsKey($k)) { $false } else { $seen[$k] = 1; $true }
})

$stuckEver = @{}   # pkg -> "boot n"
$resolved  = @()

for ($i = 0; $i -lt $boots.Count; $i++) {
  $bStart = $boots[$i].TimeCreated
  $bEnd = if ($i + 1 -lt $boots.Count) { $boots[$i + 1].TimeCreated } else { Get-Date }
  $isLast = ($i + 1 -ge $boots.Count)
  Write-Host ''
  Write-Host ('=' * 90)
  Write-Host ("BOOT #{0}  started {1:yyyy-MM-dd HH:mm:ss}  ->  {2}" -f ($i + 1), $bStart, $(if ($isLast) { 'now (current boot)' } else { '{0:yyyy-MM-dd HH:mm:ss}' -f $bEnd }))

  # how this boot ended
  foreach ($r in ($restarts | Where-Object { $_.TimeCreated -ge $bStart -and $_.TimeCreated -lt $bEnd } | Sort-Object TimeCreated)) {
    $p = $r.Properties
    $proc = Split-Path -Leaf ([string]$p[0].Value)
    $usr = if ($p.Count -ge 7) { [string]$p[6].Value } else { '' }
    Write-Host ("  Restart/shutdown requested {0:HH:mm:ss} by {1} ({2}) type: {3}" -f $r.TimeCreated, $proc, $usr, [string]$p[4].Value)
  }
  if (-not $isLast) {
    foreach ($pl in ($powerLoss | Where-Object { $_.TimeCreated -ge $bEnd -and $_.TimeCreated -lt $bEnd.AddMinutes(5) })) {
      $x = [xml]$pl.ToXml(); $d = @{}; foreach ($n in $x.Event.EventData.Data) { $d[$n.Name] = $n.'#text' }
      $how = if ($d['LongPowerButtonPressDetected'] -eq 'true') { 'FORCED POWER OFF (long power-button press)' } else { 'unexpected power loss / hang' }
      $pt = ''
      if ($d['PowerButtonTimestamp'] -and $d['PowerButtonTimestamp'] -ne '0') { $pt = ' at {0:HH:mm:ss}' -f [datetime]::FromFileTime([int64]$d['PowerButtonTimestamp']) }
      Write-Host ("  !! This boot ended by {0}{1}" -f $how, $pt)
    }
  }

  $evs = @($ar | Where-Object { $_.TimeCreated -ge $bStart -and $_.TimeCreated -lt $bEnd })
  $sids = @($evs | ForEach-Object { $m = [regex]::Match($_.Message, 'S-1-5-21-[\d-]+'); if ($m.Success) { $m.Value } } | Select-Object -Unique)
  if ($sids.Count -eq 0) { Write-Host '  (no user App Readiness activity in this boot)'; continue }

  foreach ($sid in $sids) {
    Write-Host ''
    Write-Host ("  USER {0}" -f (Get-UserName $sid))
    $running = $false; $lastAct = $null; $pending = @(); $okCount = 0
    foreach ($e in ($evs | Where-Object { $_.Message -match [regex]::Escape($sid) })) {
      $q = Get-Quoted $e.Message
      $t = '{0:HH:mm:ss}' -f $e.TimeCreated
      switch ($e.Id) {
        240 { if ($q.Count -ge 1 -and $q[0] -match '^ART:User') { Write-Host ("    START  {0}  {1}" -f $t, $q[0]); $running = $true; $pending = @() } }
        213 {
          $pkg = if ($q.Count -ge 1) { $q[0] } else { '?' }; $s = Get-Secs $e.Message; $op = Get-Op $e.Message $pkg
          $okCount++; $lastAct = $e.TimeCreated
          $tag = if ($s -ne $null -and $s -ge $StuckSeconds) { 'OK-SLOW' } else { 'OK' }
          if ($ShowAll -or $tag -eq 'OK-SLOW' -or $stuckEver.ContainsKey($pkg)) { Write-Host ("    {0,-7}{1}  {2,-9} {3}  ({4:N1}s)" -f $tag, $t, $op, $pkg, $s) }
          if ($stuckEver.ContainsKey($pkg)) { $resolved += ("{0}  stuck in {1}, resolved in BOOT #{2} at {3}" -f $pkg, $stuckEver[$pkg], ($i + 1), $t); $stuckEver.Remove($pkg) }
        }
        214 {
          $pkg = if ($q.Count -ge 1) { $q[0] } else { '?' }; $err = if ($q.Count -ge 2) { $q[1] } else { '' }
          $s = Get-Secs $e.Message; $op = Get-Op $e.Message $pkg; $lastAct = $e.TimeCreated
          $tag = if ($s -ne $null -and $s -ge $StuckSeconds) { 'STUCK' } else { 'FAIL' }
          Write-Host ("    {0,-7}{1}  {2,-9} {3}  ({4:N1}s)  error: {5}" -f $tag, $t, $op, $pkg, $s, $err)
          if ($tag -eq 'STUCK') { $stuckEver[$pkg] = "BOOT #$($i + 1)" }
        }
        225 {
          $task = @($q | Where-Object { $_ -match '^ART:' })
          if ($task.Count -gt 0) { Write-Host ("    CANCEL {0}  {1} cancelled (shutdown / sign-out)" -f $t, $task[0]); $running = $false }
        }
        313 { $it = @($q | Where-Object { $_ -notmatch '^ART:' }); if ($it.Count -gt 0) { $pending += (Get-Item2 $it[0]) } }
        220 {
          if ($q.Count -ge 1 -and $q[0] -match '^ART:User') {
            Write-Host ("    DONE   {0}  {1} finished OK ({2:N1}s)" -f $t, $q[0], (Get-Secs $e.Message)); $running = $false
          }
        }
      }
    }
    if ($ShowAll) { Write-Host ("    ({0} package operations finished OK in this boot)" -f $okCount) }
    else { Write-Host ("    ({0} package operations finished OK in this boot; fast OK lines hidden, use -ShowAll)" -f $okCount) }
    if ($pending.Count -gt 0) {
      Write-Host '    Still pending when cancelled:'
      $pending | Select-Object -Unique | ForEach-Object { Write-Host ("      - {0}" -f $_) }
    }
    if ($running) {
      $since = if ($lastAct) { $lastAct } else { $bStart }
      $mins = [int](($bEnd - $since).TotalMinutes)
      Write-Host ("    RUNNING / NO END in this boot. Last finished item at {0:HH:mm:ss} ({1} min before boot end/now)." -f $since, $mins)
      Write-Host  '    -> The item right after the last finished one is most likely the stuck one (see AppX section below for the current boot).'
    }
  }
}

Write-Host ''
Write-Host ('=' * 90)
Write-Host 'PREVIOUSLY STUCK PACKAGES THAT LATER SUCCEEDED'
if ($resolved.Count -gt 0) { $resolved | ForEach-Object { Write-Host "  $_" } } else { Write-Host '  (none)' }
Write-Host 'STUCK PACKAGES NOT YET SEEN SUCCEEDING'
if ($stuckEver.Count -gt 0) { $stuckEver.GetEnumerator() | ForEach-Object { Write-Host ("  {0}  (stuck in {1})" -f $_.Key, $_.Value) } } else { Write-Host '  (none)' }

# ---- AppX operations started but not finished in the latest boot ---------
Write-Host ''
Write-Host ('=' * 90)
$lb = $boots[-1].TimeCreated
Write-Host ("APPX OPERATIONS STARTED BUT NOT FINISHED SINCE LATEST BOOT ({0:yyyy-MM-dd HH:mm:ss}) - best effort" -f $lb)
$ax = @(Get-Ev 'Microsoft-Windows-AppXDeploymentServer/Operational' 'Microsoft-Windows-AppXDeploymentServer%4Operational.evtx' @{ Id = 603,613,400,401,404 } | Where-Object { $_.TimeCreated -ge $lb })
$ended = @{}; foreach ($e in ($ax | Where-Object { $_.Id -ne 603 -and $_.ActivityId })) { $ended[[string]$e.ActivityId] = 1 }
# Only package Add/Register/Remove/Stage/Update style operations; helper operations
# such as SetDefaultVolume / StageInContainer never log an end event and are skipped.
$open = @($ax | Where-Object {
  $_.Id -eq 603 -and $_.ActivityId -and -not $ended.ContainsKey([string]$_.ActivityId) -and
  $_.Message -match '\b(Add|Register|Remove|Stage|Update|Deprovision)' -and
  $_.Message -notmatch 'SetDefaultVolume|StageInContainer|InContainer'
} | Sort-Object TimeCreated)
if ($open.Count -eq 0) { Write-Host '  (none)' } else {
  foreach ($e in $open) {
    $m = ($e.Message -replace '\s+', ' '); if ($m.Length -gt 200) { $m = $m.Substring(0, 200) }
    Write-Host ("  {0:HH:mm:ss}  {1}" -f $e.TimeCreated, $m)
  }
}

Wait-Close

  在檔案上按右鍵「以 PowerShell 執行」即可,沒有系統管理員權限時會自動跳 UAC。如果出現「已停用指令碼執行」,改用指令執行:

powershell -ExecutionPolicy Bypass -File .\Show-AppReadinessLog.ps1

  結果判讀:

  • STUCK:這次開機卡住的套件(處理超過 300 秒後被中止)
  • FAIL:失敗但沒有卡住,通常下次會再試
  • DONE:該使用者的整理工作已完整跑完,App 通常就恢復了
  • RUNNING / NO END:目前還在跑,或正卡著
  • 最下方會列出「之前卡住、後來成功」及「卡住後還沒成功過」的套件

  簡單說,最後一次開機有看到 DONE 就不用再重開;還是 STUCK 或 RUNNING,就再重開一次。

【結論】

  這次問題不是升級失敗,而是升級後第一次登入時 AppReadiness 移除舊版 22H2 套件卡住。遇到內建 App 打不開時,不用急著重設 App 或重裝 Windows App Runtime,先確認更新都裝完,再多重開幾次,搭配上面的腳本看 AppReadiness 有沒有跑完,大部分就能解決。

  批次升級的話,也建議升級完先由 IT 重開到更新都裝好、App 能正常開啟,再交給使用者。

《相關連結》

Dejar una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tu comentario.