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
This commit is contained in:
186
playbook/shopfloor-setup/common/scripts/Repair-AppShortcuts.ps1
Normal file
186
playbook/shopfloor-setup/common/scripts/Repair-AppShortcuts.ps1
Normal file
@@ -0,0 +1,186 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user