# 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}\.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