Compare commits
3 Commits
pxe-webapp
...
fix/sfld-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ec097069c | ||
|
|
ca9b0de89c | ||
|
|
01cf99da01 |
@@ -419,10 +419,14 @@ if ($noEnforceTypes -contains $pcType) {
|
||||
Write-Warning "Register-GEEnforce.ps1 not found - no ongoing enforcement will run on this PC"
|
||||
}
|
||||
|
||||
# Map S: drive on user logon for every account in BUILTIN\Users. The
|
||||
# vendor 'SFLD - Consume Credentials' task is principal-restricted and
|
||||
# does not fire for the ShopFloor end-user, so this parallel task fills
|
||||
# the gap. Cross-PC-type because every shopfloor account needs S:.
|
||||
# Register the 'GE Map SFLD Share' task: keeps S: alive for every account
|
||||
# in BUILTIN\Users (logon + every 3 min + unlock/console connect + network
|
||||
# up), with a real read probe and a confirm-then-repair path. The vendor
|
||||
# 'SFLD - Consume Credentials' task DOES fire for ShopFloor (principal is
|
||||
# the Users group) but maps S: once per logon only; nothing re-maps it
|
||||
# mid-shift, which is why S: stayed dead until re-logon. Our task is
|
||||
# idempotent next to it (healthy S: is never touched). GE-Enforce also
|
||||
# (re)registers it fleet-wide via the common manifest entry.
|
||||
# Display PCs skipped: kiosk user has no SFLD creds, S: map would fail
|
||||
# every logon. Self-contained Display has no share dependency.
|
||||
$registerMapShare = Join-Path $setupDir 'Shopfloor\Register-MapSfldShare.ps1'
|
||||
|
||||
@@ -1,67 +1,33 @@
|
||||
# Register-MapSfldShare.ps1 - Stage Map-SfldShare.ps1 + register an
|
||||
# HKLM\Run entry that maps S: for any interactive user (SupportUser,
|
||||
# ShopFloor, any future end-user accounts).
|
||||
# Register-MapSfldShare.ps1 - stage Map-SfldShare.ps1 and register the
|
||||
# 'GE Map SFLD Share' scheduled task that keeps S: alive for every
|
||||
# interactive BUILTIN\Users member (ShopFloor, SupportUser, ...).
|
||||
#
|
||||
# Why HKLM\Run instead of a scheduled task: Run fires at Explorer
|
||||
# startup in the logged-in user's interactive session with their full
|
||||
# token + HKCU mounted. No principal/LogonType/group-SID plumbing, no
|
||||
# "task fires in session 0 but drive not visible to Explorer" class of
|
||||
# bugs. Works for every BUILTIN\Users member with no extra logic.
|
||||
#
|
||||
# Why not the vendor's ConsumeCredentials.ps1: it calls
|
||||
# New-StoredCredential -Persist LocalMachine (needs admin) before net
|
||||
# use. ShopFloor is non-admin, so the cred-store fails and net use has
|
||||
# no auth. Our Map-SfldShare.ps1 reads HKLM creds directly and passes
|
||||
# them inline to net use /user: -- no Credential Manager needed.
|
||||
# Replaces the HKLM\Run entry of the same name. Run fired once per logon and
|
||||
# had no way to repair S: mid-shift; a task can fire at logon, on an
|
||||
# interval, on unlock / console connect and when the network comes up.
|
||||
# Principal is the Users GROUP with RunLevel Limited, so it runs in each
|
||||
# logged-on member's own session with a non-elevated token: the mapping
|
||||
# lands where Explorer and the apps look for it.
|
||||
|
||||
param(
|
||||
[int]$IntervalMinutes = 3,
|
||||
[string]$InstallRoot = 'C:\Program Files\GE\SfldShare',
|
||||
# SHA256 the manifest entry detects on. When it differs from the share
|
||||
# file the manifest was not bumped with the script; warn loudly once.
|
||||
[string]$ExpectedHash = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# PC types that are self-contained and must NOT map S:. A Display kiosk is
|
||||
# Entra-joined with local accounts and no SFLD credentials, so the mapping can
|
||||
# only ever fail - once per logon, forever.
|
||||
#
|
||||
# Run-ShopfloorSetup.ps1 already gates the CALL to this script on the same list,
|
||||
# but on 579C144 (2026-08-06) the Run entry was registered on a Display anyway:
|
||||
#
|
||||
# [15:07:55] Set HKLM:\...\Run\GE Map SFLD Share = ...Map-SfldShare.ps1
|
||||
#
|
||||
# with no "Skipping S: drive logon mapper" in the log, so something in the
|
||||
# finalization phase reaches this script past the call-site gate. Gating here as
|
||||
# well makes the outcome correct regardless of who invokes it.
|
||||
$selfContainedTypes = @('Display', 'gea-shopfloor-display')
|
||||
$pcTypeFile = 'C:\Enrollment\pc-type.txt'
|
||||
$pcType = ''
|
||||
if (Test-Path -LiteralPath $pcTypeFile) {
|
||||
$pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction SilentlyContinue)
|
||||
if ($pcType) { $pcType = $pcType.Trim() }
|
||||
}
|
||||
if ($pcType -and ($selfContainedTypes -contains $pcType)) {
|
||||
$d = 'C:\Logs\SFLD'
|
||||
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force -EA SilentlyContinue | Out-Null }
|
||||
Add-Content -Path (Join-Path $d 'register-mapshare.log') -EA SilentlyContinue `
|
||||
-Value ("[{0}] [INFO] PC type '{1}' is self-contained - not registering the S: mapper." -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $pcType)
|
||||
Write-Host "PC type '$pcType' is self-contained - skipping S: drive mapper."
|
||||
# Remove a stale entry from an earlier image or an earlier code path.
|
||||
try {
|
||||
$runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
|
||||
if ((Get-ItemProperty -Path $runKey -Name 'GE Map SFLD Share' -EA SilentlyContinue)) {
|
||||
Remove-ItemProperty -Path $runKey -Name 'GE Map SFLD Share' -Force -EA Stop
|
||||
Write-Host " removed stale 'GE Map SFLD Share' Run entry."
|
||||
}
|
||||
} catch { }
|
||||
return
|
||||
}
|
||||
|
||||
$installRoot = 'C:\Program Files\GE\SfldShare'
|
||||
$mapScript = Join-Path $installRoot 'Map-SfldShare.ps1'
|
||||
$logDir = 'C:\Logs\SFLD'
|
||||
$logFile = Join-Path $logDir 'register-mapshare.log'
|
||||
$taskName = 'GE Map SFLD Share'
|
||||
$runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
|
||||
$runValue = 'GE Map SFLD Share'
|
||||
$legacyTask = 'GE Shopfloor Map S: Drive'
|
||||
$mapScript = Join-Path $InstallRoot 'Map-SfldShare.ps1'
|
||||
$logDir = 'C:\Logs\SFLD'
|
||||
$logFile = Join-Path $logDir 'register-mapshare.log'
|
||||
|
||||
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null }
|
||||
|
||||
function Write-RegLog {
|
||||
param([string]$Message)
|
||||
$line = '[{0}] [INFO] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
|
||||
@@ -69,47 +35,143 @@ function Write-RegLog {
|
||||
Write-Host $line
|
||||
}
|
||||
|
||||
Write-RegLog '=== Register-MapSfldShare start ==='
|
||||
|
||||
# Stage our Map-SfldShare.ps1 to a persistent location
|
||||
if (-not (Test-Path $installRoot)) {
|
||||
New-Item -Path $installRoot -ItemType Directory -Force | Out-Null
|
||||
# Self-contained PC types never get S: (Display kiosk: local accounts, no
|
||||
# SFLD creds, mapping could only fail). Gate here as well as at the call
|
||||
# site - see the 579C144 note in the previous version.
|
||||
$selfContainedTypes = @('Display', 'gea-shopfloor-display')
|
||||
$pcType = ''
|
||||
if (Test-Path -LiteralPath 'C:\Enrollment\pc-type.txt') {
|
||||
$pcType = (Get-Content -LiteralPath 'C:\Enrollment\pc-type.txt' -First 1 -EA SilentlyContinue)
|
||||
if ($pcType) { $pcType = $pcType.Trim() }
|
||||
}
|
||||
if ($pcType -and ($selfContainedTypes -contains $pcType)) {
|
||||
Write-RegLog "PC type '$pcType' is self-contained - not registering the S: mapper."
|
||||
Remove-ItemProperty -Path $runKey -Name $runValue -Force -EA SilentlyContinue
|
||||
if (Get-ScheduledTask -TaskName $taskName -EA SilentlyContinue) { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -EA SilentlyContinue }
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- stage the script ----------------------------------------------------
|
||||
$src = Join-Path $PSScriptRoot 'lib\Map-SfldShare.ps1'
|
||||
if (Test-Path $src) {
|
||||
Copy-Item -Path $src -Destination $mapScript -Force
|
||||
Write-RegLog "Staged $src -> $mapScript"
|
||||
} else {
|
||||
Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register"
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path $src)) { Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register"; exit 1 }
|
||||
$srcHash = (Get-FileHash -Path $src -Algorithm SHA256).Hash
|
||||
$stampKey = 'HKLM:\SOFTWARE\GE\SfldShare'
|
||||
|
||||
# Remove the legacy scheduled task if it exists (left behind by older
|
||||
# imaging runs that used the scheduled-task approach).
|
||||
if (Get-ScheduledTask -TaskName $legacyTask -ErrorAction SilentlyContinue) {
|
||||
try {
|
||||
Unregister-ScheduledTask -TaskName $legacyTask -Confirm:$false -ErrorAction Stop
|
||||
Write-RegLog "Removed legacy scheduled task '$legacyTask'"
|
||||
} catch {
|
||||
Write-RegLog "Failed to remove legacy task '$legacyTask': $_"
|
||||
# Manifest / share drift guard. The GE-Enforce entry detects on
|
||||
# MapperHash == DetectionValue, so a share edit without a DetectionValue bump
|
||||
# makes the engine call this every cycle. Say so once per hash, and take the
|
||||
# fast path below so that loop costs nothing and never churns the task.
|
||||
if ($ExpectedHash -and ($ExpectedHash -ne $srcHash)) {
|
||||
$warned = (Get-ItemProperty -Path $stampKey -Name DriftWarnedHash -EA SilentlyContinue).DriftWarnedHash
|
||||
if ($warned -ne $srcHash) {
|
||||
Write-RegLog "WARN: share Map-SfldShare.ps1 is $srcHash but the manifest DetectionValue is $ExpectedHash - bump DetectionValue in common/manifest.json or the engine re-runs this every cycle"
|
||||
if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null }
|
||||
Set-ItemProperty -Path $stampKey -Name DriftWarnedHash -Value $srcHash -Type String -EA SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# Register HKLM\Run entry. Runs at Explorer startup for every
|
||||
# interactive user in that user's session.
|
||||
# Fast path: staged copy already matches the share and the task exists with
|
||||
# our action -> nothing to (re)do. Re-stamp and leave (no Register -Force,
|
||||
# so a live task is never torn down for no reason).
|
||||
$taskNow = Get-ScheduledTask -TaskName $taskName -EA SilentlyContinue
|
||||
if ($taskNow -and (Test-Path $mapScript) -and
|
||||
((Get-FileHash -Path $mapScript -Algorithm SHA256).Hash -eq $srcHash) -and
|
||||
($taskNow.Actions[0].Execute -like '*conhost.exe') -and
|
||||
($taskNow.Actions[0].Arguments -like "*$mapScript*")) {
|
||||
if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null }
|
||||
Set-ItemProperty -Path $stampKey -Name MapperHash -Value $srcHash -Type String -EA SilentlyContinue
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-RegLog '=== Register-MapSfldShare start ==='
|
||||
if (-not (Test-Path $InstallRoot)) { New-Item -Path $InstallRoot -ItemType Directory -Force | Out-Null }
|
||||
Copy-Item -Path $src -Destination $mapScript -Force
|
||||
Write-RegLog "Staged $src -> $mapScript"
|
||||
|
||||
# --- retire the older mechanisms -----------------------------------------
|
||||
if (Get-ItemProperty -Path $runKey -Name $runValue -EA SilentlyContinue) {
|
||||
Remove-ItemProperty -Path $runKey -Name $runValue -Force -EA SilentlyContinue
|
||||
Write-RegLog "Removed HKLM\Run '$runValue' (superseded by the scheduled task)"
|
||||
}
|
||||
if (Get-ScheduledTask -TaskName $legacyTask -EA SilentlyContinue) {
|
||||
Unregister-ScheduledTask -TaskName $legacyTask -Confirm:$false -EA SilentlyContinue
|
||||
Write-RegLog "Removed legacy task '$legacyTask'"
|
||||
}
|
||||
|
||||
# --- task ------------------------------------------------------------------
|
||||
try {
|
||||
$command = '"{0}" -NoProfile -ExecutionPolicy Bypass -File "{1}"' -f `
|
||||
"$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe", $mapScript
|
||||
# conhost --headless, not powershell -WindowStyle Hidden: on Win11 the
|
||||
# default console host is Windows Terminal, which ignores the hosted
|
||||
# app's Hidden style and flashes a window + steals foreground for
|
||||
# ~0.5 s on every fire (measured in the VM). Headless conhost shows
|
||||
# nothing and moves focus nowhere.
|
||||
$ps = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
$action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\conhost.exe" `
|
||||
-Argument ('--headless "{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}"' -f $ps, $mapScript)
|
||||
|
||||
if (-not (Test-Path $runKey)) {
|
||||
New-Item -Path $runKey -Force | Out-Null
|
||||
$ns = 'Root/Microsoft/Windows/TaskScheduler'
|
||||
$triggers = New-Object System.Collections.ArrayList
|
||||
|
||||
# 1. logon (any user)
|
||||
[void]$triggers.Add((New-ScheduledTaskTrigger -AtLogOn))
|
||||
|
||||
# 2. interval, forever
|
||||
$rep = New-ScheduledTaskTrigger -Once -At (Get-Date).Date -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
|
||||
[void]$triggers.Add($rep)
|
||||
|
||||
# 3. console connect (1) + session unlock (8)
|
||||
$ssCls = Get-CimClass -ClassName MSFT_TaskSessionStateChangeTrigger -Namespace $ns
|
||||
foreach ($state in 1, 8) {
|
||||
$t = New-CimInstance -CimClass $ssCls -ClientOnly
|
||||
$t.StateChange = $state
|
||||
$t.Delay = 'PT10S'
|
||||
[void]$triggers.Add($t)
|
||||
}
|
||||
New-ItemProperty -Path $runKey -Name $runValue -Value $command -PropertyType String -Force | Out-Null
|
||||
Write-RegLog "Set $runKey\$runValue = $command"
|
||||
|
||||
# 4. network profile connected (NetworkProfile/Operational 10000) - wifi
|
||||
# roam / cable back / VPN up. Short delay so DNS is usable.
|
||||
$evCls = Get-CimClass -ClassName MSFT_TaskEventTrigger -Namespace $ns
|
||||
$ev = New-CimInstance -CimClass $evCls -ClientOnly
|
||||
$ev.Subscription = '<QueryList><Query Id="0" Path="Microsoft-Windows-NetworkProfile/Operational"><Select Path="Microsoft-Windows-NetworkProfile/Operational">*[System[(EventID=10000)]]</Select></Query></QueryList>'
|
||||
$ev.Delay = 'PT15S'
|
||||
[void]$triggers.Add($ev)
|
||||
|
||||
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited
|
||||
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-MultipleInstances IgnoreNew `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Minutes 5) `
|
||||
-StartWhenAvailable `
|
||||
-AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries `
|
||||
-Hidden
|
||||
|
||||
Register-ScheduledTask -TaskName $taskName `
|
||||
-Action $action -Trigger $triggers.ToArray() -Principal $principal -Settings $settings `
|
||||
-Force -ErrorAction Stop | Out-Null
|
||||
|
||||
Write-RegLog "Registered task '$taskName' (BUILTIN\Users, Limited; logon + every $IntervalMinutes min + unlock/connect + network-up)"
|
||||
} catch {
|
||||
Write-RegLog "FAILED to register Run key: $_"
|
||||
Write-RegLog "FAILED to register task: $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- stamp -----------------------------------------------------------------
|
||||
# Written only after staging + registration both succeeded. The GE-Enforce
|
||||
# manifest entry detects on MapperHash == SHA256 of Map-SfldShare.ps1, so a
|
||||
# new script version (new hash) or a failed registration (no stamp) re-fires
|
||||
# this registrar on the next cycle.
|
||||
try {
|
||||
$hash = (Get-FileHash -Path $mapScript -Algorithm SHA256).Hash
|
||||
$stampKey = 'HKLM:\SOFTWARE\GE\SfldShare'
|
||||
if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null }
|
||||
Set-ItemProperty -Path $stampKey -Name MapperHash -Value $hash -Type String
|
||||
Set-ItemProperty -Path $stampKey -Name RegisteredAt -Value (Get-Date -Format 's') -Type String
|
||||
Set-ItemProperty -Path $stampKey -Name TaskName -Value $taskName -Type String
|
||||
Write-RegLog "Stamped $stampKey MapperHash=$hash"
|
||||
} catch {
|
||||
Write-RegLog "Stamp write failed (registrar will re-run next cycle): $_"
|
||||
}
|
||||
|
||||
Write-RegLog '=== Register-MapSfldShare end ==='
|
||||
exit 0
|
||||
|
||||
@@ -1,68 +1,305 @@
|
||||
# Map-SfldShare.ps1 - Map S: drive on user logon using SFLD creds from HKLM.
|
||||
# Map-SfldShare.ps1 - keep the SFLD share drive (S:) alive in the interactive
|
||||
# user's session. Run by the 'GE Map SFLD Share' scheduled task as the
|
||||
# logged-on user (BUILTIN\Users, Limited): at logon, every few minutes, on
|
||||
# unlock / console connect, and when a network profile connects.
|
||||
#
|
||||
# Runs as the interactive user (BUILTIN\Users, Limited) so the drive
|
||||
# mapping lands in the logged-in user's session. Reads username/password
|
||||
# directly from HKLM:\SOFTWARE\GE\SFLD\Credentials\* and passes them
|
||||
# inline to net use -- no Windows Credential Manager involvement.
|
||||
# Rules (the drive is never touched while it works):
|
||||
# healthy -> exit, no changes.
|
||||
# no live mapping -> map now (nothing to interrupt).
|
||||
# mapped but unreadable -> repair only after $ConfirmFailures consecutive
|
||||
# failed probes, and only if TCP 445 answers.
|
||||
# mapped to wrong target -> same confirm-then-repair path.
|
||||
#
|
||||
# Why not the vendor's ConsumeCredentials.ps1: it calls
|
||||
# New-StoredCredential -Persist LocalMachine which requires admin.
|
||||
# ShopFloor is a non-admin user, so the cred-store step fails silently
|
||||
# and the subsequent net use (which relies on those stored creds) has
|
||||
# no authentication. Direct net use /user: bypasses the issue entirely.
|
||||
# Creds: HKLM:\SOFTWARE\GE\SFLD\Credentials\* entries that carry DriveLetter +
|
||||
# ShareName (written by the vendor SetupCredentials package). The password is
|
||||
# never put on a command line: New-SmbMapping runs in-process and the
|
||||
# Credential Manager seed uses CredWrite via P/Invoke. The seed is what lets
|
||||
# Windows' own logon-time restore and the redirector's transparent reconnect
|
||||
# authenticate, so both stop producing red-X drives.
|
||||
|
||||
param(
|
||||
[int]$ConfirmFailures = 2,
|
||||
[int]$ProbeTimeoutSec = 8,
|
||||
[switch]$Now # repair on first failed probe (manual / logon use)
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# --- single instance per session ---------------------------------------
|
||||
$mutex = New-Object System.Threading.Mutex($false, 'Local\GE-MapSfldShare')
|
||||
if (-not $mutex.WaitOne(0)) { exit 0 }
|
||||
|
||||
# --- logging -----------------------------------------------------------
|
||||
$logDir = 'C:\Logs\SFLD'
|
||||
if (-not (Test-Path $logDir)) {
|
||||
try { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } catch { $logDir = $env:TEMP }
|
||||
try {
|
||||
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force -EA Stop | Out-Null }
|
||||
$probeFile = Join-Path $logDir ('.w-' + [guid]::NewGuid().ToString('N'))
|
||||
[IO.File]::WriteAllText($probeFile, 'x'); Remove-Item $probeFile -Force -EA SilentlyContinue
|
||||
} catch {
|
||||
$logDir = Join-Path $env:LOCALAPPDATA 'GE\SfldShare'
|
||||
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force -EA SilentlyContinue | Out-Null }
|
||||
}
|
||||
$logFile = Join-Path $logDir 'map-share.log'
|
||||
|
||||
function Write-MapLog {
|
||||
param([string]$Message)
|
||||
$line = '[{0}] [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $env:USERNAME, $Message
|
||||
param([string]$Message, [string]$Level = 'INFO')
|
||||
$line = '[{0}] [{1}] [{2}] {3}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $env:USERNAME, $Level, $Message
|
||||
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
|
||||
Write-Host $line
|
||||
}
|
||||
|
||||
Write-MapLog '=== Map-SfldShare start ==='
|
||||
# --- per-user state (fail counter, last state) --------------------------
|
||||
$stateRoot = 'HKCU:\SOFTWARE\GE\SfldShare'
|
||||
function Get-State {
|
||||
param([string]$Letter)
|
||||
$k = Join-Path $stateRoot $Letter
|
||||
if (-not (Test-Path $k)) { New-Item -Path $k -Force | Out-Null }
|
||||
$p = Get-ItemProperty -Path $k -EA SilentlyContinue
|
||||
return @{
|
||||
Key = $k
|
||||
FailCount = [int]($p.FailCount)
|
||||
LastState = [string]$p.LastState
|
||||
CredHash = [string]$p.CredHash # SHA256(user + password) last seeded
|
||||
}
|
||||
}
|
||||
function Set-State {
|
||||
param([string]$Key, [int]$FailCount, [string]$LastState, [string]$CredHash = '')
|
||||
Set-ItemProperty -Path $Key -Name FailCount -Value $FailCount -Type DWord -EA SilentlyContinue
|
||||
Set-ItemProperty -Path $Key -Name LastState -Value $LastState -Type String -EA SilentlyContinue
|
||||
Set-ItemProperty -Path $Key -Name LastRun -Value (Get-Date -Format 's') -Type String -EA SilentlyContinue
|
||||
# only overwrite the seeded-credential hash when the caller supplies one
|
||||
if ($CredHash) { Set-ItemProperty -Path $Key -Name CredHash -Value $CredHash -Type String -EA SilentlyContinue }
|
||||
}
|
||||
function Get-CredHash {
|
||||
param([string]$User, [string]$Password)
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
$b = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes("$User`n$Password"))
|
||||
$sha.Dispose()
|
||||
return ([BitConverter]::ToString($b) -replace '-', '')
|
||||
}
|
||||
|
||||
# --- probes -------------------------------------------------------------
|
||||
function Test-Tcp445 {
|
||||
param([string]$HostName, [int]$TimeoutMs = 3000)
|
||||
try {
|
||||
$c = New-Object System.Net.Sockets.TcpClient
|
||||
$ar = $c.BeginConnect($HostName, 445, $null, $null)
|
||||
$ok = $ar.AsyncWaitHandle.WaitOne($TimeoutMs)
|
||||
if ($ok) { $c.EndConnect($ar) }
|
||||
$c.Close()
|
||||
return $ok
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
# Real read of the drive root in a runspace with a hard timeout. Test-Path on
|
||||
# a dead redirector connection can block for 30 s or more.
|
||||
function Test-DriveReadable {
|
||||
param([string]$Drive, [int]$TimeoutSec)
|
||||
$ps = [powershell]::Create()
|
||||
$null = $ps.AddScript({
|
||||
param($d)
|
||||
try {
|
||||
$e = [System.IO.Directory]::EnumerateFileSystemEntries("$d\").GetEnumerator()
|
||||
$null = $e.MoveNext()
|
||||
'OK'
|
||||
} catch { 'ERR: ' + $_.Exception.GetType().Name + ': ' + $_.Exception.Message }
|
||||
}).AddArgument($Drive)
|
||||
$h = $ps.BeginInvoke()
|
||||
if ($h.AsyncWaitHandle.WaitOne($TimeoutSec * 1000)) {
|
||||
$r = $ps.EndInvoke($h); $ps.Dispose()
|
||||
return [string]$r[0]
|
||||
}
|
||||
try { $ps.Stop() } catch {}
|
||||
$ps.Dispose()
|
||||
return 'TIMEOUT'
|
||||
}
|
||||
|
||||
# --- credential manager seed (CredWrite, no command line) ---------------
|
||||
$script:credTypeLoaded = $false
|
||||
function Set-StoredDomainCredential {
|
||||
param([string]$Target, [string]$User, [string]$Password)
|
||||
if (-not $script:credTypeLoaded) {
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class GeCredStore {
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
public struct CREDENTIAL {
|
||||
public uint Flags; public uint Type; public string TargetName; public string Comment;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
||||
public uint CredentialBlobSize; public IntPtr CredentialBlob; public uint Persist;
|
||||
public uint AttributeCount; public IntPtr Attributes; public string TargetAlias; public string UserName;
|
||||
}
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
static extern bool CredWrite([In] ref CREDENTIAL cred, uint flags);
|
||||
public static int Write(string target, string user, string password) {
|
||||
byte[] blob = System.Text.Encoding.Unicode.GetBytes(password);
|
||||
CREDENTIAL c = new CREDENTIAL();
|
||||
c.Type = 2; // CRED_TYPE_DOMAIN_PASSWORD
|
||||
c.Persist = 2; // CRED_PERSIST_LOCAL_MACHINE
|
||||
c.TargetName = target; c.UserName = user; c.Comment = "GE SFLD share";
|
||||
c.CredentialBlobSize = (uint)blob.Length;
|
||||
c.CredentialBlob = Marshal.AllocHGlobal(blob.Length);
|
||||
Marshal.Copy(blob, 0, c.CredentialBlob, blob.Length);
|
||||
bool ok = CredWrite(ref c, 0);
|
||||
int err = ok ? 0 : Marshal.GetLastWin32Error();
|
||||
Marshal.FreeHGlobal(c.CredentialBlob);
|
||||
return err;
|
||||
}
|
||||
}
|
||||
'@ -ErrorAction Stop
|
||||
$script:credTypeLoaded = $true
|
||||
}
|
||||
return [GeCredStore]::Write($Target, $User, $Password)
|
||||
}
|
||||
|
||||
# --- mapping ------------------------------------------------------------
|
||||
function New-Mapping {
|
||||
param([string]$Drive, [string]$Unc, [string]$User, [string]$Password, [string]$HostName, [bool]$PreFlight = $false)
|
||||
$letter = $Drive.TrimEnd(':')
|
||||
|
||||
# When a mapping (wrong or unreadable) is being replaced, prove the new
|
||||
# one can be made BEFORE deleting the old: map the target to a spare
|
||||
# letter, then drop it. A failed pre-flight leaves the old mapping in
|
||||
# place. 1219 is the exception - it means the old mapping's own session
|
||||
# is what blocks the new identity, so deleting it is the only way.
|
||||
if ($PreFlight) {
|
||||
$used = @((Get-PSDrive -PSProvider FileSystem -EA SilentlyContinue).Name) + @((Get-SmbMapping -EA SilentlyContinue).LocalPath | ForEach-Object { $_.TrimEnd(':') })
|
||||
$spare = $null
|
||||
foreach ($c in [char[]]'ZYXVUTQPONMLKJIHG') { if ($used -notcontains [string]$c) { $spare = "${c}:"; break } }
|
||||
if ($spare) {
|
||||
try {
|
||||
New-SmbMapping -LocalPath $spare -RemotePath $Unc -UserName $User -Password $Password -Persistent $false -ErrorAction Stop | Out-Null
|
||||
$ok = Test-DriveReadable -Drive $spare -TimeoutSec 8
|
||||
& net use $spare /delete /y 2>&1 | Out-Null
|
||||
if ($ok -ne 'OK') { return "pre-flight via $spare mapped but unreadable ($ok) - old $Drive left in place" }
|
||||
} catch {
|
||||
$m = $_.Exception.Message
|
||||
if ($m -notmatch '1219|more than one user name|Multiple connections') {
|
||||
return "pre-flight via $spare failed: $m - old $Drive left in place"
|
||||
}
|
||||
Write-MapLog "pre-flight hit 1219 - the existing $Drive session blocks the new identity; replacing it" 'WARN'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& net use $Drive /delete /y 2>&1 | Out-Null
|
||||
Remove-Item -Path "HKCU:\Network\$letter" -Recurse -Force -EA SilentlyContinue
|
||||
try {
|
||||
New-SmbMapping -LocalPath $Drive -RemotePath $Unc -UserName $User -Password $Password -Persistent $true -ErrorAction Stop | Out-Null
|
||||
return $null
|
||||
} catch {
|
||||
$msg = $_.Exception.Message
|
||||
if ($msg -match '1219|more than one user name|Multiple connections') {
|
||||
# stale session to the same host under another identity pins the
|
||||
# credential; the hidden IPC$ connection is the pin.
|
||||
Write-MapLog "1219 credential conflict on $HostName - dropping IPC`$ and retrying" 'WARN'
|
||||
& net use "\\$HostName\IPC`$" /delete /y 2>&1 | Out-Null
|
||||
try {
|
||||
New-SmbMapping -LocalPath $Drive -RemotePath $Unc -UserName $User -Password $Password -Persistent $true -ErrorAction Stop | Out-Null
|
||||
return $null
|
||||
} catch { return $_.Exception.Message }
|
||||
}
|
||||
return $msg
|
||||
}
|
||||
}
|
||||
|
||||
# --- main ---------------------------------------------------------------
|
||||
$credsBase = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
|
||||
if (-not (Test-Path $credsBase)) {
|
||||
Write-MapLog 'No HKLM SFLD credentials yet - exiting'
|
||||
exit 0
|
||||
}
|
||||
if (-not (Test-Path $credsBase)) { Write-MapLog 'No HKLM SFLD credentials yet - exiting'; exit 0 }
|
||||
|
||||
$entries = @()
|
||||
foreach ($entry in (Get-ChildItem -Path $credsBase -ErrorAction SilentlyContinue)) {
|
||||
$p = Get-ItemProperty -Path $entry.PSPath -ErrorAction SilentlyContinue
|
||||
if (-not $p -or -not $p.TargetHost -or -not $p.Username -or -not $p.Password) { continue }
|
||||
|
||||
$drive = $null
|
||||
$share = $null
|
||||
try { $drive = $p.DriveLetter } catch {}
|
||||
try { $share = $p.ShareName } catch {}
|
||||
|
||||
$drive = [string]$p.DriveLetter; $share = [string]$p.ShareName
|
||||
if ([string]::IsNullOrWhiteSpace($drive) -or [string]::IsNullOrWhiteSpace($share)) { continue }
|
||||
$entries += [pscustomobject]@{
|
||||
Drive = $drive.Trim().TrimEnd(':').ToUpper() + ':'
|
||||
Unc = '\\{0}\{1}' -f $p.TargetHost, $share.TrimStart('\').TrimEnd('\')
|
||||
Host = [string]$p.TargetHost
|
||||
User = [string]$p.Username
|
||||
Pass = [string]$p.Password
|
||||
}
|
||||
}
|
||||
if ($entries.Count -eq 0) { exit 0 }
|
||||
|
||||
$drive = $drive.TrimEnd(':') + ':'
|
||||
$share = $share.TrimStart('\')
|
||||
$uncPath = "\\$($p.TargetHost)\$share"
|
||||
$exit = 0
|
||||
foreach ($e in $entries) {
|
||||
$letter = $e.Drive.TrimEnd(':')
|
||||
$st = Get-State -Letter $letter
|
||||
|
||||
# Skip if already mapped to the right target
|
||||
$existing = & net use $drive 2>&1
|
||||
if ($LASTEXITCODE -eq 0 -and ($existing -join "`n") -match [regex]::Escape($uncPath)) {
|
||||
Write-MapLog "$drive already mapped to $uncPath - skipping"
|
||||
$mapping = Get-SmbMapping -LocalPath $e.Drive -ErrorAction SilentlyContinue
|
||||
$present = [bool]$mapping
|
||||
$target = if ($mapping) { [string]$mapping.RemotePath } else { '' }
|
||||
$wrong = $present -and ($target.TrimEnd('\') -ne $e.Unc)
|
||||
|
||||
# Seed Credential Manager whenever the HKLM credential differs from what
|
||||
# this profile last seeded (first run, or a password rotation), and again
|
||||
# after every repair. Keeps Windows' own logon-time restore authenticating.
|
||||
$credHash = Get-CredHash -User $e.User -Password $e.Pass
|
||||
if ($st.CredHash -ne $credHash) {
|
||||
$rc = Set-StoredDomainCredential -Target $e.Host -User $e.User -Password $e.Pass
|
||||
if ($rc -eq 0) {
|
||||
Write-MapLog "Seeded Credential Manager for $($e.Host) as $($e.User)$(if ($st.CredHash) { ' (credential changed)' })"
|
||||
Set-State -Key $st.Key -FailCount $st.FailCount -LastState $st.LastState -CredHash $credHash
|
||||
} else { Write-MapLog "CredWrite for $($e.Host) failed (win32 $rc)" 'WARN' }
|
||||
}
|
||||
|
||||
$probe = 'SKIPPED'
|
||||
if ($present -and -not $wrong) { $probe = Test-DriveReadable -Drive $e.Drive -TimeoutSec $ProbeTimeoutSec }
|
||||
|
||||
if ($present -and -not $wrong -and $probe -eq 'OK') {
|
||||
if ($st.LastState -ne 'healthy') { Write-MapLog "$($e.Drive) healthy -> $($e.Unc)" }
|
||||
Set-State -Key $st.Key -FailCount 0 -LastState 'healthy'
|
||||
continue
|
||||
}
|
||||
|
||||
& net use $drive /delete /y 2>$null | Out-Null
|
||||
$out = & net use $drive $uncPath /user:$($p.Username) $($p.Password) /persistent:yes 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-MapLog "Mapped $drive -> $uncPath"
|
||||
# --- not healthy --------------------------------------------------
|
||||
if (-not $present) {
|
||||
Write-MapLog "$($e.Drive) has no live mapping - mapping now"
|
||||
} elseif ($wrong) {
|
||||
$st.FailCount++
|
||||
Write-MapLog "$($e.Drive) points at '$target' not '$($e.Unc)' ($($st.FailCount)/$ConfirmFailures)" 'WARN'
|
||||
} else {
|
||||
Write-MapLog "FAILED $drive -> $uncPath (exit $LASTEXITCODE): $out"
|
||||
$st.FailCount++
|
||||
Write-MapLog "$($e.Drive) mapped (status $($mapping.Status)) but probe failed: $probe ($($st.FailCount)/$ConfirmFailures)" 'WARN'
|
||||
}
|
||||
|
||||
if ($present -and -not $Now -and $st.FailCount -lt $ConfirmFailures) {
|
||||
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'suspect'
|
||||
continue
|
||||
}
|
||||
|
||||
if (-not (Test-Tcp445 -HostName $e.Host)) {
|
||||
Write-MapLog "$($e.Host):445 unreachable - not repairing $($e.Drive) this run" 'WARN'
|
||||
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'unreachable'
|
||||
$exit = 2
|
||||
continue
|
||||
}
|
||||
|
||||
Write-MapLog "Repairing $($e.Drive) -> $($e.Unc)$(if ($present) { ' (pre-flight first, existing mapping kept on failure)' })"
|
||||
$err = New-Mapping -Drive $e.Drive -Unc $e.Unc -User $e.User -Password $e.Pass -HostName $e.Host -PreFlight $present
|
||||
if ($err) {
|
||||
Write-MapLog "Repair FAILED $($e.Drive): $err" 'ERROR'
|
||||
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed'
|
||||
$exit = 1
|
||||
continue
|
||||
}
|
||||
$verify = Test-DriveReadable -Drive $e.Drive -TimeoutSec $ProbeTimeoutSec
|
||||
if ($verify -eq 'OK') {
|
||||
Write-MapLog "Repaired $($e.Drive) -> $($e.Unc) (verified readable)"
|
||||
# re-seed after every repair so the stored credential matches what
|
||||
# just proved to work
|
||||
$rc = Set-StoredDomainCredential -Target $e.Host -User $e.User -Password $e.Pass
|
||||
if ($rc -ne 0) { Write-MapLog "CredWrite after repair failed (win32 $rc)" 'WARN' }
|
||||
Set-State -Key $st.Key -FailCount 0 -LastState 'healthy' -CredHash $credHash
|
||||
} else {
|
||||
Write-MapLog "Mapped $($e.Drive) but verify failed: $verify" 'ERROR'
|
||||
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed'
|
||||
$exit = 1
|
||||
}
|
||||
}
|
||||
|
||||
Write-MapLog '=== Map-SfldShare end ==='
|
||||
exit 0
|
||||
$mutex.ReleaseMutex() | Out-Null
|
||||
exit $exit
|
||||
|
||||
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