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>
503 lines
20 KiB
PowerShell
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
|