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"
}
# 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'

View File

@@ -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

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
# 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

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