Files
pxe-server/playbook/shopfloor-setup/Shopfloor/06-OrganizeDesktop.ps1
cproudlock c06310f5bd Replace all Unicode characters with ASCII in playbook scripts
Em dashes (U+2014) and arrows (U+2192) break PowerShell 5.1 on
Windows when the file has no UTF-8 BOM -- byte 0x94 gets read as
a right double quote in Windows-1252, silently closing strings
mid-parse. This caused run-enrollment.ps1 to fail on PXE-imaged
machines with "string is missing the terminator" at line 113.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:23:11 -04:00

503 lines
20 KiB
PowerShell

# 06-OrganizeDesktop.ps1 - Single source of truth for the Public Desktop
# layout. Builds three category folders and fills them, so end users see
# a clean desktop instead of 20+ loose icons.
#
# Creates three subfolders under C:\Users\Public\Desktop:
# Office\ - Excel, Word, PowerPoint, Outlook, OneNote, etc.
# Shopfloor Tools\ - UDC, eDNC, HostExplorer, MarkZebra, PC-DMIS, Hexagon,
# WJ Shopfloor, Defect_Tracker, NTLARS, etc.
# Web Links\ - .url shortcuts (internal portals)
#
# Two phases:
# PHASE 1 (Invoke-DesktopSweep): walks everything at the desktop ROOT,
# classifies each .lnk / .url by filename / extension / .lnk target, and
# moves it into the right category folder. Unknown items are LEFT AT
# THE ROOT on purpose. eDNC.lnk and NTLARS.lnk are allowlisted to stay
# at the root - end users click them too often to bury in a folder.
#
# PHASE 2 (Add-ShopfloorToolsApps): materializes specific app shortcuts
# into Shopfloor Tools\ from known .exe paths (UDC, eDNC, NTLARS). For
# MSI-advertised shortcuts that don't have a resolvable target (WJ
# Shopfloor, Defect_Tracker), finds the existing .lnk wherever it lives
# and copies it in. This runs AFTER the sweep so it overwrites any
# stale copies the sweeper may have moved in.
#
# Also registers an "Organize Public Desktop" scheduled task that re-runs
# the sweep (phase 1) at every logon. Phase 2 doesn't re-run - the
# shortcuts it creates are stable and 06 on first run populates them.
#
# 07-TaskbarLayout.ps1 reads the result of this script and writes
# LayoutModification.xml to pin specific shortcuts to the taskbar.
$ErrorActionPreference = 'Continue'
# ============================================================================
# Admin check - writing to C:\Users\Public\Desktop\* and registering
# system-wide scheduled tasks both require elevation. Fail fast if not
# admin so we don't spam half-successful garbage across the output.
# ============================================================================
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Host ""
Write-Host "ERROR: 06-OrganizeDesktop.ps1 must run as Administrator." -ForegroundColor Red
Write-Host " Re-run from an elevated PowerShell:" -ForegroundColor Red
Write-Host " Start -> type 'powershell' -> right-click -> Run as administrator" -ForegroundColor Red
Write-Host ""
exit 1
}
# Load site config + PC profile
. "$PSScriptRoot\lib\Get-PCProfile.ps1"
$publicDesktop = 'C:\Users\Public\Desktop'
$shopfloorToolsDir = Join-Path $publicDesktop 'Shopfloor Tools'
$scriptPath = $MyInvocation.MyCommand.Path
$scriptDir = Split-Path -Parent $scriptPath
# Filenames that always stay at the desktop root regardless of classification.
# Empty right now - every shortcut lives inside a category folder for a
# clean end-user desktop. Add entries here if a future shortcut needs to
# stay pinned at root.
$keepAtRoot = @()
# ============================================================================
# Phase 1: Sweep loose shortcuts at desktop root into category folders
# ============================================================================
function Invoke-DesktopSweep {
param([string]$DesktopPath)
if (-not (Test-Path -LiteralPath $DesktopPath)) {
Write-Host "Public desktop not found at $DesktopPath - skipping sweep."
return
}
# Category definitions. Order matters: first match wins. Each category
# can classify by file Extension, file Name (regex), or .lnk Target
# (regex, resolved via WScript.Shell).
$categories = [ordered]@{
'Office' = @{
Name = @(
'^(Excel|Word|PowerPoint|Outlook|OneNote|Access|Publisher|OneDrive)(\s|$)'
)
Target = @(
'\\(EXCEL|WINWORD|POWERPNT|OUTLOOK|ONENOTE|MSACCESS|MSPUB)\.EXE$',
'\\OneDrive\.exe$'
)
}
'Shopfloor Tools' = @{
Name = @(
'^UDC',
'eDNC', '\bDNC\b', 'DncMain', 'GE DNC', 'NTLARS',
'Host\s*Explorer', 'ShopFloor', 'TN3270', 'TN5250', 'HE\s*3270', 'HE\s*5250',
'OpenText',
'Defect[_\s-]?Tracker',
'MarkZebra', 'Zebra',
'PC-?DMIS',
'Hexagon', 'CLM\s*Tools',
'Blancco',
'Keyence'
)
Target = @(
'\\UDC\.exe$',
'DncMain\.exe$', 'NTLARS\.exe$', 'DNCRemote',
'HostExplorer\.exe$', 'hostex32\.exe$', 'humicon15\.exe$',
'PC-?DMIS',
'Hexagon',
'Defect_Tracker\.application$'
)
}
'Web Links' = @{
Extension = @('.url')
}
}
# Category folders are created LAZILY - only when we're about to move
# something into them - so a Display / Wax-Trace PC without Office
# doesn't get an empty Office\ folder littering the desktop. See
# Remove-EmptyCategoryFolders at the end of the script for the
# post-sweep cleanup pass that finishes the job.
# WScript.Shell for resolving .lnk targets
$shell = $null
try { $shell = New-Object -ComObject WScript.Shell } catch {}
# Only sweep files at the desktop root, never recurse into the category
# folders themselves (that would loop things back).
$items = @(Get-ChildItem -LiteralPath $DesktopPath -File -ErrorAction SilentlyContinue)
$moved = 0
$skipped = 0
foreach ($item in $items) {
# Allowlist check: never sweep items the user wants to keep visible
if ($keepAtRoot -contains $item.Name) {
Write-Host " keep: $($item.Name) (allowlisted)"
continue
}
$category = $null
# 1. Extension match (cheapest)
foreach ($cat in $categories.Keys) {
$exts = $categories[$cat].Extension
if ($exts -and ($exts -contains $item.Extension)) {
$category = $cat
break
}
}
# 2. File name regex match
if (-not $category) {
foreach ($cat in $categories.Keys) {
$pats = $categories[$cat].Name
if (-not $pats) { continue }
foreach ($p in $pats) {
if ($item.BaseName -match $p) {
$category = $cat
break
}
}
if ($category) { break }
}
}
# 3. .lnk target resolution
if (-not $category -and $item.Extension -ieq '.lnk' -and $shell) {
try {
$sc = $shell.CreateShortcut($item.FullName)
$target = $sc.TargetPath
if ($target) {
foreach ($cat in $categories.Keys) {
$pats = $categories[$cat].Target
if (-not $pats) { continue }
foreach ($p in $pats) {
if ($target -match $p) {
$category = $cat
break
}
}
if ($category) { break }
}
}
} catch {}
}
if ($category) {
$destDir = Join-Path $DesktopPath $category
if (-not (Test-Path -LiteralPath $destDir)) {
try {
New-Item -ItemType Directory -Path $destDir -Force -ErrorAction Stop | Out-Null
} catch {
Write-Warning "Failed to create category folder '$category' : $_"
continue
}
}
$destPath = Join-Path $destDir $item.Name
try {
Move-Item -LiteralPath $item.FullName -Destination $destPath -Force -ErrorAction Stop
Write-Host " moved: $($item.Name) -> $category\"
$moved++
} catch {
Write-Warning "Failed to move $($item.Name) : $_"
}
} else {
$skipped++
}
}
if ($shell) {
try { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell) | Out-Null } catch {}
}
Write-Host "Sweep complete: $moved moved, $skipped unclassified (left at root)."
}
# ============================================================================
# Phase 2: Materialize specific app shortcuts into Shopfloor Tools\
#
# Runs AFTER the sweep. Creates fresh .lnk files for apps we can target by
# .exe path (UDC, eDNC, NTLARS) and copies existing .lnk files for apps
# that are MSI-advertised and have no resolvable target (WJ Shopfloor,
# Defect_Tracker). Overwrites whatever the sweeper may have moved in -
# this is intentional because fresh shortcuts are more reliable than
# whatever install crud might be floating around.
#
# Each app is CONDITIONAL - if its source isn't present on this PC, the
# entry is silently skipped. Safe to run on all shopfloor PC types.
# ============================================================================
function New-ShopfloorLnk {
param(
[string]$Path,
[string]$Target,
[string]$Arguments = '',
[string]$WorkingDirectory = ''
)
$wsh = New-Object -ComObject WScript.Shell
try {
$sc = $wsh.CreateShortcut($Path)
$sc.TargetPath = $Target
if ($Arguments) { $sc.Arguments = $Arguments }
if ($WorkingDirectory) { $sc.WorkingDirectory = $WorkingDirectory }
elseif ($Target) {
$parent = Split-Path -Parent $Target
if ($parent) { $sc.WorkingDirectory = $parent }
}
$sc.IconLocation = "$Target,0"
$sc.Save()
return $true
} catch {
Write-Warning "Failed to create $Path : $_"
return $false
} finally {
try { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($wsh) | Out-Null } catch {}
}
}
# Look up an existing .lnk file across the paths where installers / ppkgs
# typically drop them. Used for apps where we don't build the .lnk from
# an .exe (because the target is an MSI-advertised Darwin descriptor).
function Find-ExistingLnk {
param([string]$Filename)
$candidates = @(
(Join-Path $publicDesktop $Filename),
(Join-Path $shopfloorToolsDir $Filename),
"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\$Filename",
"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Shopfloor Tools\$Filename"
)
foreach ($c in $candidates) {
if (Test-Path -LiteralPath $c) { return $c }
}
return $null
}
function Add-ShopfloorToolsApps {
# App registry - each entry describes how to materialize one shortcut
# into Shopfloor Tools\. Folder creation is deferred until we know we
# have at least one app to put in it (lazy creation, so PC types with
# nothing installed don't get an empty Shopfloor Tools folder).
#
# Kind = 'exe' -> build a fresh .lnk from ExePath
# Kind = 'existing' -> copy an existing .lnk via Find-ExistingLnk
$cfgApps = if ($pcProfile -and $pcProfile.desktopApps) { $pcProfile.desktopApps }
elseif ($siteConfig -and $siteConfig.desktopApps) { $siteConfig.desktopApps }
else { $null }
if ($cfgApps) {
$apps = @($cfgApps | ForEach-Object {
$entry = @{ Name = $_.name; Kind = $_.kind }
if ($_.kind -eq 'exe') { $entry.ExePath = $_.exePath }
if ($_.kind -eq 'existing') { $entry.SourceName = $_.sourceName }
$entry
})
} else {
$apps = @(
@{ Name = 'UDC'; Kind = 'exe'; ExePath = 'C:\Program Files\UDC\UDC.exe' }
@{ Name = 'eDNC'; Kind = 'exe'; ExePath = 'C:\Program Files (x86)\Dnc\bin\DncMain.exe' }
@{ Name = 'NTLARS'; Kind = 'exe'; ExePath = 'C:\Program Files (x86)\Dnc\Common\NTLARS.exe' }
@{ Name = 'WJ Shopfloor'; Kind = 'existing'; SourceName = 'WJ Shopfloor.lnk' }
@{ Name = 'Defect_Tracker'; Kind = 'existing'; SourceName = 'Defect_Tracker.lnk' }
)
}
# Lazy folder creation - only create Shopfloor Tools\ the first time
# we have an app that's actually going to land in it. PC types with
# nothing installed get no empty folder.
$ensureToolsDir = {
if (-not (Test-Path -LiteralPath $shopfloorToolsDir)) {
try {
New-Item -ItemType Directory -Path $shopfloorToolsDir -Force -ErrorAction Stop | Out-Null
return $true
} catch {
Write-Warning "Failed to create $shopfloorToolsDir : $_"
return $false
}
}
return $true
}
foreach ($app in $apps) {
$dest = Join-Path $shopfloorToolsDir "$($app.Name).lnk"
switch ($app.Kind) {
'exe' {
if (Test-Path -LiteralPath $app.ExePath) {
if (-not (& $ensureToolsDir)) { break }
if (New-ShopfloorLnk -Path $dest -Target $app.ExePath) {
Write-Host " created: $($app.Name) -> $dest"
}
} else {
Write-Host " skip: $($app.Name) - target not installed ($($app.ExePath))" -ForegroundColor DarkGray
}
}
'existing' {
$src = Find-ExistingLnk $app.SourceName
if ($src) {
# Skip if the sweep already moved the source into the
# destination folder (src == dest -> "cannot overwrite
# the item with itself").
if ((Resolve-Path -LiteralPath $src -ErrorAction SilentlyContinue).Path -eq
(Join-Path $shopfloorToolsDir $app.SourceName)) {
Write-Host " exists: $($app.Name) (already in Shopfloor Tools)"
} else {
if (-not (& $ensureToolsDir)) { break }
try {
Copy-Item -LiteralPath $src -Destination $dest -Force -ErrorAction Stop
Write-Host " copied: $($app.Name) from $src"
} catch {
Write-Warning "Failed to copy $src -> $dest : $_"
}
}
} else {
Write-Host " skip: $($app.Name) - no source .lnk found" -ForegroundColor DarkGray
}
}
}
}
}
# ============================================================================
# Phase 3: Remove empty category folders
#
# If the sweep classified nothing into a given category (e.g. no Office
# on a Display PC, no Shopfloor Tools apps on a kiosk), we don't want an
# empty folder cluttering the desktop. The scheduled-task sweep also runs
# this so categories that go empty between logons self-heal.
# ============================================================================
function Remove-EmptyCategoryFolders {
foreach ($cat in @('Office', 'Shopfloor Tools', 'Web Links')) {
$dir = Join-Path $publicDesktop $cat
if (-not (Test-Path -LiteralPath $dir)) { continue }
$contents = @(Get-ChildItem -LiteralPath $dir -Force -ErrorAction SilentlyContinue)
if ($contents.Count -eq 0) {
try {
Remove-Item -LiteralPath $dir -Force -ErrorAction Stop
Write-Host " removed empty: $cat\"
} catch {
Write-Warning "Failed to remove empty $dir : $_"
}
}
}
}
# ============================================================================
# Phase 4: Edge default browser + startup tabs (delegated to 08)
#
# 08-EdgeDefaultBrowser.ps1 resolves startup-tab URLs from .url files on
# the Public Desktop (or in Web Links\ after the sweep). The .url files
# are delivered by DSC AFTER this initial shopfloor setup, so the first
# run at imaging time won't find them (falls back to hardcoded URLs,
# Plant Apps gets skipped). By calling 08 from inside 06, every SYSTEM
# scheduled-task logon re-run of 06 also re-runs 08 -- so after DSC drops
# the .url files and the next sweep files them into Web Links\, 08
# picks them up and updates the Edge policy. Self-healing.
# ============================================================================
function Invoke-EdgeDefaultBrowser {
$edgeScript = Join-Path $scriptDir '08-EdgeDefaultBrowser.ps1'
if (-not (Test-Path -LiteralPath $edgeScript)) {
Write-Host " 08-EdgeDefaultBrowser.ps1 not found - skipping"
return
}
try {
& $edgeScript
} catch {
Write-Warning "08-EdgeDefaultBrowser.ps1 failed: $_"
}
}
# ============================================================================
# Scheduled task registration (re-runs 06 at every logon as SYSTEM)
# ============================================================================
function Register-SweepScheduledTask {
param([string]$ScriptPath)
$taskName = 'Organize Public Desktop'
if (-not (Test-Path -LiteralPath $ScriptPath)) {
Write-Warning "Cannot register scheduled task - script not found at $ScriptPath"
return
}
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$ScriptPath`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
# Run as SYSTEM so the script can (a) pass its admin check at the
# top, (b) write to Public Desktop / ProgramData Start Menu / the
# Default User profile without Access Denied, and (c) register the
# next iteration of itself idempotently. Earlier versions used
# -GroupId 'BUILTIN\Users' -RunLevel Limited which fired at logon
# but the script exited immediately on the non-admin check.
$principal = New-ScheduledTaskPrincipal `
-UserId 'NT AUTHORITY\SYSTEM' `
-LogonType ServiceAccount `
-RunLevel Highest
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 5)
# -ErrorAction Stop is required because Register-ScheduledTask emits
# non-terminating errors on failure (Access Denied etc), which try/catch
# would otherwise miss and we'd falsely claim success.
Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Force `
-ErrorAction Stop | Out-Null
Write-Host "Scheduled task '$taskName' registered (runs at every logon)."
} catch {
Write-Warning "Failed to register scheduled task : $_"
}
}
# ============================================================================
# Main
# ============================================================================
Write-Host ""
Write-Host "=== Phase 0: create Office shortcuts (if Office is installed) ==="
# Delegated to 05-OfficeShortcuts.ps1. Office is installed via ppkg but
# doesn't finish streaming until AFTER the first reboot, so the first
# imaging-time run of 06 finds no Office and this no-ops. On the second
# logon (after reboot), Office is installed and 05 creates the shortcuts
# so Phase 1 below can sweep them into the Office\ folder.
$officeScript = Join-Path $scriptDir '05-OfficeShortcuts.ps1'
if (Test-Path -LiteralPath $officeScript) {
try { & $officeScript } catch { Write-Warning "05-OfficeShortcuts.ps1 failed: $_" }
} else {
Write-Host " 05-OfficeShortcuts.ps1 not found - skipping"
}
Write-Host ""
Write-Host "=== Phase 1: sweep loose shortcuts into category folders ==="
Invoke-DesktopSweep -DesktopPath $publicDesktop
Write-Host ""
Write-Host "=== Phase 2: populate Shopfloor Tools with app shortcuts ==="
Add-ShopfloorToolsApps
Write-Host ""
Write-Host "=== Phase 3: remove empty category folders ==="
Remove-EmptyCategoryFolders
Write-Host ""
Write-Host "=== Phase 4: Edge default browser + startup tabs ==="
Invoke-EdgeDefaultBrowser
Write-Host ""
Write-Host "=== Phase 5: register logon sweeper scheduled task ==="
Register-SweepScheduledTask -ScriptPath $scriptPath
exit 0