diff --git a/playbook/shopfloor-setup/common/scripts/Repair-AppShortcuts.ps1 b/playbook/shopfloor-setup/common/scripts/Repair-AppShortcuts.ps1 new file mode 100644 index 0000000..95c278c --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Repair-AppShortcuts.ps1 @@ -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}\.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