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
187 lines
7.5 KiB
PowerShell
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
|