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(應用程式整備)的 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 能正常開啟,再交給使用者。

《相關連結》

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

這個網站採用 Akismet 服務減少垃圾留言。進一步了解 Akismet 如何處理網站訪客的留言資料。