Files
pxe-server/playbook/shopfloor-setup/common/scripts/Repair-AppShortcuts.ps1
cproudlock ca9b0de89c feat(shopfloor): Repair-AppShortcuts.ps1 for Defect Tracker pins after MSI upgrade
An MSI major upgrade only fixes the shortcuts it owns. The copies we
place (Shopfloor Tools folder, per-user taskbar pin) are advertised
shortcuts carrying the old ProductCode, so they stop resolving on
locked-down bays where the operator cannot unpin. Delete the stale copy
and write a plain shortcut to the real exe, read back and verified;
MSI-owned advertised shortcuts are left for MSI self-repair.

Wired on the SFLD share as a common manifest entry (Type=PS1, Always,
same PCTypes as the WJF Defect Tracker install, ordered after it).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011boHjE3EeexwszdiKDZ7iX
2026-09-04 12:51:23 -04:00

187 lines
7.5 KiB
PowerShell

# Repair-AppShortcuts.ps1
#
# Repoint every shortcut for one app after its install location moves.
#
# WHY THIS EXISTS
#
# An MSI major upgrade fixes only the shortcuts the MSI itself owns. The
# copies we place - the Shopfloor Tools folder, and above all the per-user
# taskbar pin - keep pointing at the old install and the pin dies. Shopfloor
# bays are locked down so an operator cannot right-click and unpin it, and
# 07-TaskbarLayout cannot help: LayoutModification.xml is read only on the
# FIRST logon of a NEW profile. Existing profiles never re-read it.
#
# THE TRAP THIS SCRIPT EXISTS TO AVOID (verified in the win11 VM, 2026-09-04)
#
# The Defect Tracker shortcuts are MSI ADVERTISED (Darwin) shortcuts. Their
# Shortcut table Target is a Feature name, not a path, so Windows resolves
# them through the installer at click time. Three consequences:
#
# 1. WScript.Shell reports their TargetPath as
# C:\Windows\Installer\{ProductCode}\<stub>.exe, not the real exe.
# 2. Setting .TargetPath and calling .Save() REPORTS SUCCESS AND CHANGES
# NOTHING. WorkingDirectory and IconLocation do change, so the write
# looks like it worked. This is why the shortcut cannot be repaired
# in place, and why every write here is read back and verified.
# 3. A major upgrade issues a NEW ProductCode. An advertised shortcut
# copied out to a pin still names the OLD ProductCode, which no longer
# exists, so the pin cannot resolve at all. That, not the folder move
# on its own, is what kills a pinned copy.
#
# The fix for an advertised copy is to DELETE it and write a fresh plain
# shortcut to the real exe. Verified to work and to launch.
#
# WHAT IT LEAVES ALONE, ON PURPOSE
#
# The two locations the MSI owns (All Users Start Menu, Public Desktop) keep
# their advertised shortcuts when they are still advertised. Those resolve
# through MSI and self-repair, and converting them to plain shortcuts would
# throw that away and make the next move need this script again. The MSI
# upgrade is what fixes those. We only rewrite a copy the MSI does not
# manage, or an MSI-owned shortcut that has already been flattened to a
# plain stale path. -Force overrides.
#
# It does not restart Explorer. A replaced pin resolves on the user's next
# logon and shopfloor bays log off nightly. Killing Explorer mid-shift to
# save one logon is not a trade worth making.
#
# Usage (Type=PS1 manifest entry, DetectionMethod=Always):
# powershell -ExecutionPolicy Bypass -File Repair-AppShortcuts.ps1
# -ShortcutName Defect_Tracker
# -ExePath 'C:\Program Files (x86)\WJF_Defect_Tracker\Defect_Tracker.exe'
# -StalePathPattern 'WJF_Defect_Tracker'
param(
[Parameter(Mandatory=$true)][string]$ShortcutName,
[Parameter(Mandatory=$true)][string]$ExePath,
# Any shortcut whose stored target matches this is claimed even if its
# filename does not match ShortcutName. Catches a pin someone renamed.
[string]$StalePathPattern,
# Rewrite MSI-owned advertised shortcuts too, losing MSI self-repair.
[switch]$Force,
[switch]$WhatIfOnly
)
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\Shopfloor'
New-Item -ItemType Directory -Path $logDir -Force -EA SilentlyContinue | Out-Null
$log = Join-Path $logDir ('repair-shortcuts-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
function Log($m){ "$([DateTime]::Now.ToString('s')) $m" | Tee-Object -FilePath $log -Append | Out-Null }
Log "=== Repair shortcuts: $ShortcutName -> $ExePath ==="
# Nothing to point at means the app is not on this bay, or the manifest ran us
# before the MSI. Rewriting to a path that does not exist turns a stale
# shortcut into a broken one, so leave it and let the next cycle retry.
if (-not (Test-Path -LiteralPath $ExePath -PathType Leaf)) {
Log "SKIP: target does not exist: $ExePath"
exit 0
}
$workDir = Split-Path -Parent $ExePath
$leaf = "$ShortcutName.lnk"
# An advertised shortcut always reports its target inside the installer cache.
$AdvertisedTarget = '^[A-Za-z]:\\Windows\\Installer\\\{'
# Locations the MSI creates and maintains itself.
$msiOwnedDirs = @(
'C:\ProgramData\Microsoft\Windows\Start Menu\Programs'
'C:\Users\Public\Desktop'
)
# Copies we place. The MSI does not know about these and will never fix them.
$ourDirs = @(
'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Shopfloor Tools'
'C:\Users\Public\Desktop\Shopfloor Tools'
)
foreach ($profileDir in (Get-ChildItem 'C:\Users' -Directory -EA SilentlyContinue)) {
$quick = Join-Path $profileDir.FullName 'AppData\Roaming\Microsoft\Internet Explorer\Quick Launch\User Pinned'
$ourDirs += (Join-Path $quick 'TaskBar')
$ourDirs += (Join-Path $quick 'StartMenu')
}
$wsh = New-Object -ComObject WScript.Shell
$checked = 0; $fixed = 0; $failed = 0; $skipped = 0
function Get-Target([string]$Path) {
try { return $wsh.CreateShortcut($Path).TargetPath } catch { return $null }
}
function Repair-Lnk([string]$Path, [bool]$MsiOwned) {
$script:checked++
$current = Get-Target $Path
if ($null -eq $current) {
Log " READ FAIL $Path"
$script:failed++
return
}
$advertised = $current -match $AdvertisedTarget
if ($current -eq $ExePath) { Log " ok $Path"; return }
# An MSI-owned shortcut that is still advertised resolves through the
# installer and the upgrade will correct it. Flattening it would cost us
# self-repair for no gain.
if ($advertised -and $MsiOwned -and -not $Force) {
Log " skip (MSI-owned, still advertised, upgrade will fix) $Path"
$script:skipped++
return
}
if ($WhatIfOnly) {
Log " WOULD FIX $Path : '$current' becomes '$ExePath'"
$script:fixed++
return
}
# Delete first. An advertised shortcut cannot be repointed in place: the
# Save() succeeds and the target stays exactly as it was.
try {
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
$sc = $wsh.CreateShortcut($Path)
$sc.TargetPath = $ExePath
$sc.WorkingDirectory = $workDir
$sc.IconLocation = "$ExePath,0"
$sc.Save()
} catch {
Log " WRITE FAIL $Path : $($_.Exception.Message)"
$script:failed++
return
}
# Read back. This is the whole point: a write that silently did nothing
# must be reported as a failure, not counted as a fix.
$after = Get-Target $Path
if ($after -eq $ExePath) {
Log " FIXED $Path : '$current' becomes '$ExePath'"
$script:fixed++
} else {
Log " VERIFY FAIL $Path : wrote '$ExePath', reads back '$after'"
$script:failed++
}
}
function Test-Claimed([System.IO.FileInfo]$File) {
if ($File.Name -ieq $leaf) { return $true }
if (-not $StalePathPattern) { return $false }
$t = Get-Target $File.FullName
return ($t -and $t -match [regex]::Escape($StalePathPattern))
}
foreach ($pair in @(@{ Dirs = $msiOwnedDirs; Msi = $true }, @{ Dirs = $ourDirs; Msi = $false })) {
foreach ($dir in $pair.Dirs) {
if (-not (Test-Path -LiteralPath $dir -PathType Container)) { continue }
foreach ($f in (Get-ChildItem -LiteralPath $dir -Filter '*.lnk' -File -EA SilentlyContinue)) {
if (Test-Claimed $f) { Repair-Lnk $f.FullName $pair.Msi }
}
}
}
try { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($wsh) | Out-Null } catch {}
Log "=== done: $checked checked, $fixed fixed, $skipped skipped, $failed failed ==="
if ($failed -gt 0) { exit 1 }
exit 0