3 Commits

Author SHA1 Message Date
cproudlock
2ec097069c fix(shopfloor): S: mapper review fixes - headless task, drift guard, pre-flight, re-seed
Independent review in the win11 VM found four things worth fixing before
the fleet sync:

- The task action ran powershell -WindowStyle Hidden, which on Win11
  (Windows Terminal as console host) flashes a window and steals
  foreground for ~0.5 s on every fire. Action is now
  conhost.exe --headless powershell.exe ..., verified silent.
- If the share copy of Map-SfldShare.ps1 changes without the manifest
  DetectionValue being bumped, the engine calls the registrar every
  cycle. The registrar now takes an idempotent fast path (staged hash
  matches, task present with our action -> re-stamp and exit, no task
  churn) and logs one WARN per hash when -ExpectedHash disagrees with
  the share file.
- Replacing a wrong or unreadable mapping now pre-flights the new
  mapping on a spare letter and keeps the old S: when that fails
  (except on 1219, where the old session is what blocks the new one).
- Credential Manager is re-seeded whenever the HKLM credential changes
  (password rotation) and after every repair, instead of once per
  profile. Set-State no longer wipes the stored hash on suspect writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011boHjE3EeexwszdiKDZ7iX
2026-09-04 13:37:37 -04:00
cproudlock
ca9b0de89c 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
2026-09-04 12:51:23 -04:00
cproudlock
01cf99da01 fix(shopfloor): self-healing S: drive mapper to stop mid-shift drive loss
S: was mapped once per logon (HKLM\Run -> Map-SfldShare.ps1) with no
re-check, so any SMB session break the redirector could not silently
re-authenticate left S: dead until the user logged out and back in.

Replace the Run entry with a scheduled task (BUILTIN\Users, Limited)
that fires at logon, every 3 min, on unlock/console-connect, and on
network-connect. The mapper now probes S: with a real timed directory
read (not net use output parsing, which reports a dead drive as
healthy), only repairs after two consecutive failed probes with TCP 445
reachable, and never touches a working drive. Credentials go through
New-SmbMapping / CredWrite instead of the net use command line, and a
Domain Password credential is seeded so Windows' own reconnect and UNC
access share one identity (kills the 1219 conflict class).

Register-MapSfldShare stamps HKLM\SOFTWARE\GE\SfldShare\MapperHash with
the staged script's SHA256 after success, so GE-Enforce can (re)register
it fleet-wide via a common manifest entry and re-fire on version bumps.

Verified in the win11 VM: healthy no-op with an open handle held across
runs, repair of deleted / wrong-target / present-but-unreadable drives,
unaided recovery after a NIC bounce and on the 3-min interval, and the
full SYSTEM-from-share registrar path flipping detection to installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011boHjE3EeexwszdiKDZ7iX
2026-09-04 12:18:33 -04:00
4 changed files with 616 additions and 127 deletions

View File

@@ -419,10 +419,14 @@ if ($noEnforceTypes -contains $pcType) {
Write-Warning "Register-GEEnforce.ps1 not found - no ongoing enforcement will run on this PC" 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 # Register the 'GE Map SFLD Share' task: keeps S: alive for every account
# vendor 'SFLD - Consume Credentials' task is principal-restricted and # in BUILTIN\Users (logon + every 3 min + unlock/console connect + network
# does not fire for the ShopFloor end-user, so this parallel task fills # up), with a real read probe and a confirm-then-repair path. The vendor
# the gap. Cross-PC-type because every shopfloor account needs S:. # '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 # Display PCs skipped: kiosk user has no SFLD creds, S: map would fail
# every logon. Self-contained Display has no share dependency. # every logon. Self-contained Display has no share dependency.
$registerMapShare = Join-Path $setupDir 'Shopfloor\Register-MapSfldShare.ps1' $registerMapShare = Join-Path $setupDir 'Shopfloor\Register-MapSfldShare.ps1'

View File

@@ -1,67 +1,33 @@
# Register-MapSfldShare.ps1 - Stage Map-SfldShare.ps1 + register an # Register-MapSfldShare.ps1 - stage Map-SfldShare.ps1 and register the
# HKLM\Run entry that maps S: for any interactive user (SupportUser, # 'GE Map SFLD Share' scheduled task that keeps S: alive for every
# ShopFloor, any future end-user accounts). # interactive BUILTIN\Users member (ShopFloor, SupportUser, ...).
# #
# Why HKLM\Run instead of a scheduled task: Run fires at Explorer # Replaces the HKLM\Run entry of the same name. Run fired once per logon and
# startup in the logged-in user's interactive session with their full # had no way to repair S: mid-shift; a task can fire at logon, on an
# token + HKCU mounted. No principal/LogonType/group-SID plumbing, no # interval, on unlock / console connect and when the network comes up.
# "task fires in session 0 but drive not visible to Explorer" class of # Principal is the Users GROUP with RunLevel Limited, so it runs in each
# bugs. Works for every BUILTIN\Users member with no extra logic. # logged-on member's own session with a non-elevated token: the mapping
# # lands where Explorer and the apps look for it.
# Why not the vendor's ConsumeCredentials.ps1: it calls
# New-StoredCredential -Persist LocalMachine (needs admin) before net param(
# use. ShopFloor is non-admin, so the cred-store fails and net use has [int]$IntervalMinutes = 3,
# no auth. Our Map-SfldShare.ps1 reads HKLM creds directly and passes [string]$InstallRoot = 'C:\Program Files\GE\SfldShare',
# them inline to net use /user: -- no Credential Manager needed. # 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' $ErrorActionPreference = 'Continue'
# PC types that are self-contained and must NOT map S:. A Display kiosk is $taskName = 'GE Map SFLD Share'
# 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'
$runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' $runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
$runValue = 'GE Map SFLD Share' $runValue = 'GE Map SFLD Share'
$legacyTask = 'GE Shopfloor Map S: Drive' $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 } if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null }
function Write-RegLog { function Write-RegLog {
param([string]$Message) param([string]$Message)
$line = '[{0}] [INFO] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $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-Host $line
} }
Write-RegLog '=== Register-MapSfldShare start ===' # 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
# Stage our Map-SfldShare.ps1 to a persistent location # site - see the 579C144 note in the previous version.
if (-not (Test-Path $installRoot)) { $selfContainedTypes = @('Display', 'gea-shopfloor-display')
New-Item -Path $installRoot -ItemType Directory -Force | Out-Null $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' $src = Join-Path $PSScriptRoot 'lib\Map-SfldShare.ps1'
if (Test-Path $src) { if (-not (Test-Path $src)) { Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register"; exit 1 }
Copy-Item -Path $src -Destination $mapScript -Force $srcHash = (Get-FileHash -Path $src -Algorithm SHA256).Hash
Write-RegLog "Staged $src -> $mapScript" $stampKey = 'HKLM:\SOFTWARE\GE\SfldShare'
} else {
Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register"
exit 1
}
# Remove the legacy scheduled task if it exists (left behind by older # Manifest / share drift guard. The GE-Enforce entry detects on
# imaging runs that used the scheduled-task approach). # MapperHash == DetectionValue, so a share edit without a DetectionValue bump
if (Get-ScheduledTask -TaskName $legacyTask -ErrorAction SilentlyContinue) { # makes the engine call this every cycle. Say so once per hash, and take the
try { # fast path below so that loop costs nothing and never churns the task.
Unregister-ScheduledTask -TaskName $legacyTask -Confirm:$false -ErrorAction Stop if ($ExpectedHash -and ($ExpectedHash -ne $srcHash)) {
Write-RegLog "Removed legacy scheduled task '$legacyTask'" $warned = (Get-ItemProperty -Path $stampKey -Name DriftWarnedHash -EA SilentlyContinue).DriftWarnedHash
} catch { if ($warned -ne $srcHash) {
Write-RegLog "Failed to remove legacy task '$legacyTask': $_" 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 # Fast path: staged copy already matches the share and the task exists with
# interactive user in that user's session. # 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 { try {
$command = '"{0}" -NoProfile -ExecutionPolicy Bypass -File "{1}"' -f ` # conhost --headless, not powershell -WindowStyle Hidden: on Win11 the
"$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe", $mapScript # 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)) { $ns = 'Root/Microsoft/Windows/TaskScheduler'
New-Item -Path $runKey -Force | Out-Null $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 { } catch {
Write-RegLog "FAILED to register Run key: $_" Write-RegLog "FAILED to register task: $_"
exit 1 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 ===' Write-RegLog '=== Register-MapSfldShare end ==='
exit 0 exit 0

View File

@@ -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 # Rules (the drive is never touched while it works):
# mapping lands in the logged-in user's session. Reads username/password # healthy -> exit, no changes.
# directly from HKLM:\SOFTWARE\GE\SFLD\Credentials\* and passes them # no live mapping -> map now (nothing to interrupt).
# inline to net use -- no Windows Credential Manager involvement. # 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 # Creds: HKLM:\SOFTWARE\GE\SFLD\Credentials\* entries that carry DriveLetter +
# New-StoredCredential -Persist LocalMachine which requires admin. # ShareName (written by the vendor SetupCredentials package). The password is
# ShopFloor is a non-admin user, so the cred-store step fails silently # never put on a command line: New-SmbMapping runs in-process and the
# and the subsequent net use (which relies on those stored creds) has # Credential Manager seed uses CredWrite via P/Invoke. The seed is what lets
# no authentication. Direct net use /user: bypasses the issue entirely. # 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' $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' $logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) { try {
try { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } catch { $logDir = $env:TEMP } 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' $logFile = Join-Path $logDir 'map-share.log'
function Write-MapLog { function Write-MapLog {
param([string]$Message) param([string]$Message, [string]$Level = 'INFO')
$line = '[{0}] [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $env:USERNAME, $Message $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 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' $credsBase = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
if (-not (Test-Path $credsBase)) { if (-not (Test-Path $credsBase)) { Write-MapLog 'No HKLM SFLD credentials yet - exiting'; exit 0 }
Write-MapLog 'No HKLM SFLD credentials yet - exiting'
exit 0
}
$entries = @()
foreach ($entry in (Get-ChildItem -Path $credsBase -ErrorAction SilentlyContinue)) { foreach ($entry in (Get-ChildItem -Path $credsBase -ErrorAction SilentlyContinue)) {
$p = Get-ItemProperty -Path $entry.PSPath -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 } if (-not $p -or -not $p.TargetHost -or -not $p.Username -or -not $p.Password) { continue }
$drive = [string]$p.DriveLetter; $share = [string]$p.ShareName
$drive = $null
$share = $null
try { $drive = $p.DriveLetter } catch {}
try { $share = $p.ShareName } catch {}
if ([string]::IsNullOrWhiteSpace($drive) -or [string]::IsNullOrWhiteSpace($share)) { continue } 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(':') + ':' $exit = 0
$share = $share.TrimStart('\') foreach ($e in $entries) {
$uncPath = "\\$($p.TargetHost)\$share" $letter = $e.Drive.TrimEnd(':')
$st = Get-State -Letter $letter
# Skip if already mapped to the right target $mapping = Get-SmbMapping -LocalPath $e.Drive -ErrorAction SilentlyContinue
$existing = & net use $drive 2>&1 $present = [bool]$mapping
if ($LASTEXITCODE -eq 0 -and ($existing -join "`n") -match [regex]::Escape($uncPath)) { $target = if ($mapping) { [string]$mapping.RemotePath } else { '' }
Write-MapLog "$drive already mapped to $uncPath - skipping" $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 continue
} }
& net use $drive /delete /y 2>$null | Out-Null # --- not healthy --------------------------------------------------
$out = & net use $drive $uncPath /user:$($p.Username) $($p.Password) /persistent:yes 2>&1 if (-not $present) {
if ($LASTEXITCODE -eq 0) { Write-MapLog "$($e.Drive) has no live mapping - mapping now"
Write-MapLog "Mapped $drive -> $uncPath" } elseif ($wrong) {
$st.FailCount++
Write-MapLog "$($e.Drive) points at '$target' not '$($e.Unc)' ($($st.FailCount)/$ConfirmFailures)" 'WARN'
} else { } 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 ===' $mutex.ReleaseMutex() | Out-Null
exit 0 exit $exit

View 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