# 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 } # Transcript. 06 runs as a SYSTEM scheduled task at every logon via the # 'Organize Public Desktop' task, so without a transcript its decisions # (what got added, what got suppressed) are invisible. Append mode keeps # a history across logons; session header + PID makes individual runs # easy to find. $transcriptDir = 'C:\Logs\SFLD' if (-not (Test-Path $transcriptDir)) { try { New-Item -ItemType Directory -Path $transcriptDir -Force | Out-Null } catch {} } $transcriptPath = Join-Path $transcriptDir '06-OrganizeDesktop.log' try { Start-Transcript -Path $transcriptPath -Append -Force | Out-Null } catch {} Write-Host "" Write-Host "================================================================" Write-Host "=== 06-OrganizeDesktop session start (PID $PID, $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) ===" Write-Host "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)" Write-Host "================================================================" # ============================================================================ # Cleanup: remove 'Shopfloor Intune Sync' task after sync completes # # Monitor-IntuneProgress.ps1 registers that task as BUILTIN\Users + RunLevel # Limited (needs a user context to show the QR code / Configure-PC prompt), # and a Limited task cannot Unregister-ScheduledTask itself. So when sync # completes it just writes C:\Enrollment\sync-complete.txt as a marker and # the task stays around firing on every logon as a no-op. # # 06 runs as SYSTEM via its own sweep task, which DOES have permission to # delete any task. When we see the marker, drop the stale task so Task # Scheduler doesn't keep the dead entry forever. # ============================================================================ $syncCompleteMarker = 'C:\Enrollment\sync-complete.txt' $syncTaskName = 'Shopfloor Intune Sync' if (Test-Path -LiteralPath $syncCompleteMarker) { $syncTask = Get-ScheduledTask -TaskName $syncTaskName -ErrorAction SilentlyContinue if ($syncTask) { try { Unregister-ScheduledTask -TaskName $syncTaskName -Confirm:$false -ErrorAction Stop Write-Host "Removed stale '$syncTaskName' task (sync complete, marker present)." } catch { Write-Warning "Failed to remove '$syncTaskName' task: $_" } } } # 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 = Get-ProfileValue 'desktopApps' if ($null -ne $cfgApps -and $cfgApps.Count -gt 0) { $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: auto-apply startup items from PC profile ===" # Creates .lnk files in AllUsers Startup folder based on the profile's # startupItems. This runs as SYSTEM at every logon via the sweep scheduled # task, so any item the tech removes via Configure-PC would bounce right # back unless we remember the removal. startup-overrides.json tracks labels # the tech has explicitly suppressed; items in that list are skipped here # and re-enabling them via Configure-PC removes them from the list. $startupDir = 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup' $overridesPath = 'C:\ProgramData\GE\Shopfloor\startup-overrides.json' $suppressed = @() if (Test-Path -LiteralPath $overridesPath) { try { $overrides = Get-Content -LiteralPath $overridesPath -Raw | ConvertFrom-Json if ($overrides.suppressed) { $suppressed = @($overrides.suppressed) } } catch { Write-Warning " Failed to parse $overridesPath : $_" } } $cfgStartup = Get-ProfileValue 'startupItems' if ($null -ne $cfgStartup -and $cfgStartup.Count -gt 0) { if (-not (Test-Path $startupDir)) { New-Item -ItemType Directory -Path $startupDir -Force | Out-Null } $edgePath = @( 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe', 'C:\Program Files\Microsoft\Edge\Application\msedge.exe' ) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 foreach ($si in $cfgStartup) { if ($suppressed -contains $si.label) { Write-Host " suppressed: $($si.label) (tech disabled via Configure-PC)" continue } $lnkPath = Join-Path $startupDir "$($si.label).lnk" # Skip if already exists (idempotent - don't overwrite user changes) if (Test-Path -LiteralPath $lnkPath) { Write-Host " exists: $($si.label)" continue } switch ($si.type) { 'exe' { if (Test-Path -LiteralPath $si.target) { if (New-ShopfloorLnk -Path $lnkPath -Target $si.target) { Write-Host " added: $($si.label)" } } else { Write-Host " skip: $($si.label) - target not installed" -ForegroundColor DarkGray } } 'existing' { $src = Find-ExistingLnk $si.sourceLnk if ($src) { try { Copy-Item -LiteralPath $src -Destination $lnkPath -Force Write-Host " added: $($si.label)" } catch { Write-Warning " failed: $($si.label) - $_" } } else { Write-Host " skip: $($si.label) - source not found" -ForegroundColor DarkGray } } 'url' { $url = $null if ($si.urlKey -and $siteConfig -and $siteConfig.urls) { $url = $siteConfig.urls.$($si.urlKey) } if ($url -and $edgePath) { if (New-ShopfloorLnk -Path $lnkPath -Target $edgePath -Arguments "--new-window `"$url`"") { Write-Host " added: $($si.label)" } } else { Write-Host " skip: $($si.label) - URL or Edge not available" -ForegroundColor DarkGray } } } } } else { Write-Host " (no startup items in profile)" } Write-Host "" Write-Host "=== Phase 4: remove empty category folders ===" Remove-EmptyCategoryFolders Write-Host "" Write-Host "=== Phase 5: Edge default browser + startup tabs ===" Invoke-EdgeDefaultBrowser Write-Host "" Write-Host "=== Phase 6: register logon sweeper scheduled task ===" Register-SweepScheduledTask -ScriptPath $scriptPath Write-Host "" Write-Host "=== 06-OrganizeDesktop session end ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) ===" try { Stop-Transcript | Out-Null } catch {} exit 0