Private/New-TargetedDocumentation.ps1
|
function New-TargetedDocumentation { # Returns the EXPECTED capture path (a truthy string) on success — the caller passes it to # Wait-ForDocumentationAndProcess -ExpectedJsonPath so the wait can never be satisfied by a # stale capture from a previous run. Failure paths return $false. [CmdletBinding()] param( [string]$ProjectPath, [string]$ProjectName, [object]$AppInfo, # Which test/capture backend drives the generated script. 'Sandbox' builds + launches the .wsb; # 'HyperV' generates the (backend-aware) script but leaves execution to the Hyper-V provider. [ValidateSet('Sandbox', 'HyperV')] [string]$Backend = 'Sandbox', # Tests only: prepare everything (incl. clearing stale captures) but do not launch the sandbox. [switch]$SkipLaunch ) try { Write-Verbose "Creating targeted documentation configuration..." # Create unique timestamp for this documentation session; the sandbox script writes # Documentation\InstallationChanges_<timestamp>.json with this exact name. $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" $expectedJson = Join-Path $ProjectPath "Documentation\InstallationChanges_$timestamp.json" # Detect installer scope from YAML (user vs machine) to target the right registry paths $installerScope = 'unknown' $yamlFiles = Get-ChildItem -Path (Join-Path $ProjectPath 'Files') -Filter '*.yaml' -File -ErrorAction SilentlyContinue if ($yamlFiles) { $yamlRaw = Get-Content $yamlFiles[0].FullName -Raw -ErrorAction SilentlyContinue if ($yamlRaw -match '(?m)^\s*Scope:\s*(.+)') { $scopeVal = $matches[1].Trim().ToLower() if ($scopeVal -eq 'user') { $installerScope = 'user' } elseif ($scopeVal -eq 'machine') { $installerScope = 'machine' } } } Write-Verbose "Installer scope detected: $installerScope" # Define paths $supportFilesFolder = Join-Path $ProjectPath "SupportFiles" $sandboxConfigFile = Join-Path $ProjectPath "${ProjectName}_TargetedDocumentation.wsb" # Ensure SupportFiles folder exists if (-not (Test-Path $supportFilesFolder)) { New-Item -ItemType Directory -Path $supportFilesFolder -Force | Out-Null } # Create the documentation script content $documentationScript = @' # Targeted PSADT Installation Documentation Script # Auto-generated by CreateAppProject.ps1 $projectName = '__PROJECTNAME__' $timestamp = '__TIMESTAMP__' $installerScope = '__SCOPE__' $backend = '__BACKEND__' # Under Hyper-V this runs via PowerShell Direct (session 0, no interactive host): silence the progress # bar so Write-Progress can't error, and the Sandbox-only init/teardown timing below is skipped. if ($backend -eq 'HyperV') { $ProgressPreference = 'SilentlyContinue' } $outputPath = 'C:\PSADT\Documentation' $logPath = 'C:\PSADT\SupportFiles\Targeted_Documentation_Log___TIMESTAMP__.txt' Write-Host "======================================" Write-Host "PSADT Installation Documentation (Targeted)" Write-Host "Project: $projectName" Write-Host "Timestamp: $timestamp" Write-Host "======================================" # The settle wait is a Windows Sandbox artifact (services still coming up at logon). Under Hyper-V # the provider already gated readiness (heartbeat + PS Direct) before this script runs, so skip it. if ($backend -eq 'Sandbox') { Write-Host "`nWaiting for Windows Sandbox to fully initialize..." -ForegroundColor Yellow Write-Host "This ensures all system services are ready before documentation begins." -ForegroundColor Gray if ($installerScope -eq 'machine') { # MACHINE-scope captures: proceed as soon as the guest is actually ready — desktop up, the # service control manager answering, and the user-profile churn settled (two consecutive # samples) — CAPPED at the old 60 s, so the worst case is identical to the fixed wait. # USER/UNKNOWN scope keeps the fixed 60 s below: those captures diff LOCALAPPDATA/APPDATA/ # USERPROFILE, exactly where the fresh WDAG profile's first-logon churn lands, and an early # PRE scan would push that churn into the install diff as noise. Write-Host "Probing readiness (machine scope; up to 60 seconds)..." -ForegroundColor Cyan # Machine scope still scans LOCALAPPDATA/APPDATA (installers can write there even machine-wide), # so the churn condition watches BOTH profile roots at the snapshot's own depth ballpark and # demands TWO consecutive stable comparisons — the desktop being up alone proves nothing about # first-logon profile churn. Earliest exit ~9 s; the 60 s cap keeps the worst case identical. $settleDeadline = (Get-Date).AddSeconds(60) $prevCount = -1 $stableRuns = 0 do { $ok = $false try { $explorerUp = [bool](Get-Process -Name explorer -ErrorAction SilentlyContinue) $count = @(Get-ChildItem -Path $env:LOCALAPPDATA -Recurse -Depth 2 -ErrorAction SilentlyContinue).Count + @(Get-ChildItem -Path $env:APPDATA -Recurse -Depth 2 -ErrorAction SilentlyContinue).Count if ($prevCount -ge 0 -and [Math]::Abs($count - $prevCount) -le 2) { $stableRuns++ } else { $stableRuns = 0 } $prevCount = $count $ok = $explorerUp -and ($stableRuns -ge 2) } catch { $ok = $false } if ($ok) { break } Start-Sleep -Seconds 3 } until ((Get-Date) -gt $settleDeadline) } else { Write-Host "Please wait 60 seconds..." -ForegroundColor Cyan for ($i = 60; $i -gt 0; $i--) { Write-Progress -Activity "Initializing Windows Sandbox" -Status "Waiting for system to stabilize..." -SecondsRemaining $i -PercentComplete ((60 - $i) / 60 * 100) Start-Sleep -Seconds 1 } Write-Progress -Activity "Initializing Windows Sandbox" -Completed } Write-Host "Sandbox initialization complete!" -ForegroundColor Green } # Create documentation output folder New-Item -Path $outputPath -ItemType Directory -Force | Out-Null # Initialize logging function function Write-Log { param([string]$Message, [string]$Level = "INFO") $logEntry = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message" Write-Host $logEntry Add-Content -Path $logPath -Value $logEntry -Encoding UTF8 } # Start logging Write-Log "========================================" "INFO" Write-Log "PSADT Installation Documentation Started (Targeted)" "INFO" Write-Log "Project: $projectName" "INFO" Write-Log "Timestamp: $timestamp" "INFO" Write-Log "Output Path: $outputPath" "INFO" Write-Log "Log Path: $logPath" "INFO" Write-Log "Windows Sandbox initialization wait started (60 seconds)" "INFO" Write-Log "Waiting for system services to stabilize before baseline capture" "INFO" Write-Log "Windows Sandbox initialization completed successfully" "INFO" Write-Log "========================================" "INFO" # Function to export data with error handling and proper formatting function Export-DataSafely { param([string]$FilePath, [object]$Data, [string]$Description) try { $itemCount = if ($Data -is [array]) { $Data.Count } else { 1 } Write-Log "Attempting to export $Description ($itemCount items) to: $FilePath" "INFO" # Use Out-String with width to prevent truncation, then save to file $Data | Out-String -Width 4096 | Set-Content -Path $FilePath -Encoding UTF8 -Force Write-Host "Exported: $Description" -ForegroundColor Green Write-Log "Successfully exported $Description ($itemCount items)" "SUCCESS" } catch { Write-Host "Failed to export $Description - $($_.Exception.Message)" -ForegroundColor Red Write-Log "Failed to export $Description - $($_.Exception.Message)" "ERROR" } } # Function to scan targeted directories function Get-TargetedDirectorySnapshot { param([string]$Description, [string[]]$Paths, [int]$Depth = 3) Write-Host "Scanning $Description..." -ForegroundColor Gray Write-Log "Beginning $Description scan with depth $Depth" "INFO" # List[object], not @()+= : the array re-allocation is quadratic over tens of thousands of files. $results = New-Object System.Collections.Generic.List[object] foreach ($path in $Paths) { if (Test-Path $path) { try { Write-Log "Scanning path: $path" "INFO" $items = @(Get-ChildItem -Path $path -Recurse -Depth $Depth -ErrorAction SilentlyContinue | Select-Object FullName, Name, Length, CreationTime, LastWriteTime, Attributes) foreach ($it in $items) { $results.Add($it) } Write-Log "Found $($items.Count) items in $path" "INFO" } catch { Write-Log "Error scanning $path - $($_.Exception.Message)" "WARNING" } } else { Write-Log "Path not found: $path" "INFO" } } Write-Log "Total items found in $Description - $($results.Count)" "SUCCESS" return $results.ToArray() } # Function to scan targeted registry locations function Get-TargetedRegistrySnapshot { param([string]$Description, [string[]]$RegistryPaths) Write-Host "Scanning $Description..." -ForegroundColor Gray Write-Log "Beginning $Description scan" "INFO" # List[object], not @()+= : quadratic re-allocation over thousands of registry keys. $results = New-Object System.Collections.Generic.List[object] foreach ($regPath in $RegistryPaths) { try { Write-Log "Scanning registry path: $regPath" "INFO" if (Test-Path $regPath) { # Get all subkeys and their properties $keys = Get-ChildItem -Path $regPath -Recurse -ErrorAction SilentlyContinue foreach ($key in $keys) { try { $properties = Get-ItemProperty -Path $key.PSPath -ErrorAction SilentlyContinue if ($properties) { # Create detailed registry entry with full paths and values $propList = $properties.PSObject.Properties | Where-Object { $_.Name -notlike "PS*" } $propString = "" foreach ($prop in $propList) { $value = if ($prop.Value -is [array]) { $prop.Value -join ", " } else { $prop.Value } $propString += "`n $($prop.Name) = $value" } $results.Add([PSCustomObject]@{ FullPath = $key.Name KeyName = $key.PSChildName Properties = $propString Summary = "Key: $($key.PSChildName) | Properties: $($propList.Count)" }) } } catch { Write-Log "Error reading properties from $($key.PSPath) - $($_.Exception.Message)" "WARNING" } } Write-Log "Found $($keys.Count) registry keys in $regPath" "INFO" } else { Write-Log "Registry path not found: $regPath" "INFO" } } catch { Write-Log "Error scanning registry path $regPath - $($_.Exception.Message)" "WARNING" } } Write-Log "Total registry entries found in $Description - $($results.Count)" "SUCCESS" return $results.ToArray() } # --- Declared dependencies: install BEFORE the baseline snapshot --------------------------------- # Intune installs app dependencies FIRST on a real device (mobileAppDependency), so the test bench must # too. Critically this runs BEFORE the pre-install baseline: if the dependency went in afterwards, its own # files / registry keys / services would show up in the diff as changes made by THIS app — poisoning the # generated detection rule, uninstall logic and processes-to-close. if (Test-Path -LiteralPath 'C:\PSADT\Sandbox\InstallDependencies.ps1') { Write-Host "`nInstalling declared dependencies (before the baseline)..." -ForegroundColor Yellow Write-Log "Installing declared dependencies BEFORE the baseline capture" "INFO" try { & 'C:\PSADT\Sandbox\InstallDependencies.ps1' Write-Log "Declared dependencies installed" "SUCCESS" } catch { Write-Host "Dependency install FAILED - the capture may be unreliable." -ForegroundColor Red Write-Log "Dependency install FAILED: $($_.Exception.Message)" "ERROR" } } Write-Host "`nCapturing PRE-INSTALLATION baseline..." -ForegroundColor Yellow Write-Log "Starting PRE-INSTALLATION baseline capture" "INFO" # Set registry and directory targets based on installer scope if ($installerScope -eq 'user') { Write-Host "Scope: USER — targeting HKCU registry and user profile directories" -ForegroundColor Cyan Write-Log "Installer scope: USER — monitoring HKCU paths and user profile directories" "INFO" $targetDirectories = @( "${env:LOCALAPPDATA}", "${env:APPDATA}", "${env:USERPROFILE}", "${env:ProgramFiles}", "${env:ProgramFiles(x86)}" ) $targetRegistryPaths = @( "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths", "HKCU:\SOFTWARE\Classes\Applications", # Some user-scope installers also write to HKLM "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths", "HKLM:\SOFTWARE\Classes\Applications" ) } elseif ($installerScope -eq 'machine') { Write-Host "Scope: MACHINE — targeting HKLM registry and system directories" -ForegroundColor Cyan Write-Log "Installer scope: MACHINE — monitoring HKLM paths and system directories" "INFO" $targetDirectories = @( "${env:ProgramFiles}", "${env:ProgramFiles(x86)}", "${env:ProgramData}", "${env:LOCALAPPDATA}", "${env:APPDATA}" ) $targetRegistryPaths = @( "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths", "HKLM:\SOFTWARE\Classes\Applications", "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" ) } else { Write-Host "Scope: UNKNOWN — checking both HKCU and HKLM (no scope found in manifest)" -ForegroundColor Yellow Write-Log "Installer scope: UNKNOWN — monitoring all HKCU + HKLM paths and all directories" "INFO" $targetDirectories = @( "${env:ProgramFiles}", "${env:ProgramFiles(x86)}", "${env:ProgramData}", "${env:LOCALAPPDATA}", "${env:APPDATA}", "${env:USERPROFILE}" ) $targetRegistryPaths = @( "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths", "HKLM:\SOFTWARE\Classes\Applications", "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths", "HKCU:\SOFTWARE\Classes\Applications" ) } # Capture baseline file system state (no export - keep in memory only) $preFiles = Get-TargetedDirectorySnapshot -Description "Pre-installation files" -Paths $targetDirectories -Depth 3 Write-Log "Pre-installation files captured: $($preFiles.Count) files" "INFO" # Capture baseline registry state (no export - keep in memory only) $preRegistry = Get-TargetedRegistrySnapshot -Description "Pre-installation registry" -RegistryPaths $targetRegistryPaths Write-Log "Pre-installation registry captured: $($preRegistry.Count) keys" "INFO" # Capture baseline services (keep in memory only) Write-Host "Capturing baseline services..." -ForegroundColor Gray Write-Log "Beginning baseline services scan" "INFO" $preServices = Get-Service | Select-Object Name, DisplayName, Status, StartType Write-Log "Pre-installation services captured: $($preServices.Count) services" "INFO" # NOTE: deliberately NO WMI product-class baseline here — enumerating the installer product class # makes Windows Installer run a consistency check/reconfigure of EVERY registered MSI (minutes of # wall-clock, repair side effects, event-log spam). New programs are derived from the Uninstall # registry diff instead (see the NewPrograms derivation block below). Write-Host "`nStarting PSADT installation..." -ForegroundColor Yellow Write-Log "Starting PSADT installation phase" "INFO" Write-Host "===============================================" -ForegroundColor Cyan # Change to PSADT directory Set-Location C:\PSADT Write-Log "Changed directory to C:\PSADT" "INFO" Write-Host "`nThe installation will now begin automatically (silent mode)..." -ForegroundColor Yellow # Run the PSADT installation silently Write-Host "Running: .\Invoke-AppDeployToolkit.ps1 -DeployMode Silent" -ForegroundColor White Write-Log "Launching PSADT installation: .\Invoke-AppDeployToolkit.ps1 -DeployMode Silent" "INFO" # Start installation silently and wait for completion Write-Host "Starting installation..." -ForegroundColor Gray Write-Host "Waiting for installation to complete (this may take several minutes)..." -ForegroundColor Yellow $installProcess = Start-Process -FilePath "powershell.exe" -ArgumentList "-ExecutionPolicy", "Bypass", "-File", ".\Invoke-AppDeployToolkit.ps1", "-DeployMode", "Silent" -PassThru -Wait Write-Host "Installation process completed with exit code: $($installProcess.ExitCode)" -ForegroundColor Green Write-Log "PSADT installation process completed with exit code: $($installProcess.ExitCode)" "INFO" Write-Host "`nWaiting for installation to fully finalize..." -ForegroundColor Yellow Write-Host "Allowing time for background processes, registry updates, and file operations to complete." -ForegroundColor Gray # The fixed 30 s stays for EVERY installer type — deliberately. An msiexec-quiescence early exit was # built and then REVERTED by adversarial review: late FILE drops from detached children (MSI async EXE # custom actions, NSIS/Inno spawns) have NO rescan backstop — the late-writer loop below rescans # REGISTRY only — so any early exit here can permanently lose files from the diff that feeds the # generated uninstall logic and processes-to-close. Do not shorten this wait without adding a # file-side rescan. Write-Host "Please wait 30 seconds..." -ForegroundColor Cyan for ($i = 30; $i -gt 0; $i--) { Write-Progress -Activity "Finalizing Installation" -Status "Waiting for background processes and registry updates to complete..." -SecondsRemaining $i -PercentComplete ((30 - $i) / 30 * 100) Start-Sleep -Seconds 1 } Write-Progress -Activity "Finalizing Installation" -Completed Write-Log "Installation finalization wait completed (30 seconds)" "INFO" Write-Host "Installation finalization complete!" -ForegroundColor Green Write-Host "`nCapturing POST-INSTALLATION state..." -ForegroundColor Yellow Write-Log "Starting POST-INSTALLATION state capture" "INFO" # Capture post-installation file system state $postFiles = Get-TargetedDirectorySnapshot -Description "Post-installation files" -Paths $targetDirectories -Depth 3 Write-Log "Post-installation files captured: $($postFiles.Count) files" "INFO" # Capture post-installation registry state $postRegistry = Get-TargetedRegistrySnapshot -Description "Post-installation registry" -RegistryPaths $targetRegistryPaths Write-Log "Post-installation registry captured: $($postRegistry.Count) keys" "INFO" # Capture post-installation services Write-Host "Scanning post-installation services..." -ForegroundColor Gray Write-Log "Beginning post-installation services scan" "INFO" $postServices = Get-Service | Select-Object Name, DisplayName, Status, StartType Write-Log "Post-installation services captured: $($postServices.Count) services" "INFO" Write-Host "`nAnalyzing changes..." -ForegroundColor Yellow Write-Log "Starting change analysis" "INFO" # Compare file system changes try { Write-Log "Comparing file system changes" "INFO" $newFiles = Compare-Object -ReferenceObject $preFiles.FullName -DifferenceObject $postFiles.FullName | Where-Object { $_.SideIndicator -eq '=>' } | Select-Object @{Name='NewFile';Expression={$_.InputObject}} Write-Log "Found $($newFiles.Count) new files" "INFO" $modifiedFiles = Compare-Object -ReferenceObject $preFiles -DifferenceObject $postFiles -Property FullName, LastWriteTime | Where-Object { $_.SideIndicator -eq '=>' -and $_.FullName -in $preFiles.FullName } | Select-Object @{Name='ModifiedFile';Expression={$_.FullName}}, LastWriteTime Write-Log "Found $($modifiedFiles.Count) modified files" "INFO" } catch { Write-Host "Warning: Could not compare file changes" -ForegroundColor Yellow Write-Log "Warning: Could not compare file changes: $($_.Exception.Message)" "WARNING" } # Compare registry changes with retry logic try { Write-Log "Comparing registry changes" "INFO" Write-Host "Registry comparison: PRE entries = $($preRegistry.Count), POST entries = $($postRegistry.Count)" -ForegroundColor Gray # Build a HashSet of pre-install FullPath values for reliable set-difference detection $preFullPathSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($preEntry in $preRegistry) { if ($preEntry.FullPath) { [void]$preFullPathSet.Add($preEntry.FullPath) } } function Get-NewRegEntries { param($Snapshot) @($Snapshot | Where-Object { $_.FullPath -and -not $preFullPathSet.Contains($_.FullPath) } | ForEach-Object { [PSCustomObject]@{ NewRegistryEntry = $_.FullPath KeyName = $_.KeyName Properties = $_.Properties Summary = $_.Summary } }) } $newRegEntries = @(Get-NewRegEntries -Snapshot $postRegistry) if ($newRegEntries.Count -eq 0) { # An empty diff usually means a delayed writer (or a portable app). The old loop slept a blind # 30 s and re-scanned ALL 6-8 hives, up to three times (90 s + 3 full scans, every time). Same # 90 s TOTAL BUDGET, but: poll every 5 s against ONLY the Uninstall hives (the "did anything # land" signal — cheap), and when the budget ends or the probe trips, do ONE full-hive rescan + # full diff — mandatory, because an app whose only late writes are App Paths / Classes would # never trip the Uninstall probe, and today's loop caught those on its full-scan retries. Write-Host "No registry changes detected — polling the Uninstall hives for late writers (up to 90 s)..." -ForegroundColor Yellow Write-Log "Registry diff empty - polling Uninstall hives every 5 s (90 s budget) for delayed writes" "INFO" $uninstallHives = @($targetRegistryPaths | Where-Object { $_ -like '*CurrentVersion\Uninstall*' }) $retryStart = Get-Date $retryBudget = $retryStart.AddSeconds(90) do { Start-Sleep -Seconds 5 $probe = @(Get-TargetedRegistrySnapshot -Description "Uninstall-hive probe" -RegistryPaths $uninstallHives) $probeNew = @($probe | Where-Object { $_.FullPath -and -not $preFullPathSet.Contains($_.FullPath) }) if ($probeNew.Count -gt 0) { Write-Log "Uninstall-hive probe detected $($probeNew.Count) new key(s) - running the full rescan" "INFO" break } } until ((Get-Date) -gt $retryBudget) # The final rescan is a ONE-SHOT diff — never before t=30 s into the budget. The old loop's # earliest full rescan was its first 30 s retry; a probe hit at t=6 s must not pull the single # rescan earlier than that, or writes landing between the early hit and t=30 (which the old # code caught) would be permanently lost. $elapsed = ((Get-Date) - $retryStart).TotalSeconds if ($elapsed -lt 30) { Start-Sleep -Seconds ([int][Math]::Ceiling(30 - $elapsed)) } # Final full-hive rescan + full diff (runs on probe hit AND on budget exhaustion). Write-Host "Re-scanning all registry hives (final)..." -ForegroundColor Cyan $postRegistry = Get-TargetedRegistrySnapshot -Description "Post-installation registry (final)" -RegistryPaths $targetRegistryPaths Write-Log "Registry re-scan completed: $($postRegistry.Count) keys" "INFO" $newRegEntries = @(Get-NewRegEntries -Snapshot $postRegistry) } if ($newRegEntries.Count -gt 0) { Write-Host "Found $($newRegEntries.Count) new registry entries" -ForegroundColor Green Write-Log "Found $($newRegEntries.Count) new registry entries" "INFO" } else { Write-Host "No registry changes detected after the 90 s late-writer budget" -ForegroundColor Yellow Write-Log "No registry changes detected after the 90 s late-writer budget" "WARNING" } } catch { Write-Host "Warning: Could not compare registry changes" -ForegroundColor Yellow Write-Log "Warning: Could not compare registry changes: $($_.Exception.Message)" "WARNING" } # Compare services try { Write-Log "Comparing service changes" "INFO" $newServices = Compare-Object -ReferenceObject $preServices.Name -DifferenceObject $postServices.Name | Where-Object { $_.SideIndicator -eq '=>' } | Select-Object @{Name='NewService';Expression={$_.InputObject}} Write-Log "Found $($newServices.Count) new services" "INFO" } catch { Write-Host "Warning: Could not compare service changes" -ForegroundColor Yellow Write-Log "Warning: Could not compare service changes: $($_.Exception.Message)" "WARNING" } # Create JSON output for automation (ONLY output besides log) Write-Host "`nGenerating JSON documentation for automation..." -ForegroundColor Cyan Write-Log "Creating JSON documentation for new files and registry keys" "INFO" # Prepare JSON data structure $jsonData = @{ InstallationInfo = @{ ProjectName = $projectName Timestamp = $timestamp DocumentationDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" } NewFiles = @() ModifiedFiles = @() NewRegistryKeys = @() NewServices = @() NewPrograms = @() } # Process new files for JSON if ($newFiles -and $newFiles.Count -gt 0) { foreach ($file in $newFiles) { $fileInfo = @{ Path = $file.NewFile Size = if (Test-Path $file.NewFile) { (Get-Item $file.NewFile -ErrorAction SilentlyContinue).Length } else { $null } CreatedDate = if (Test-Path $file.NewFile) { (Get-Item $file.NewFile -ErrorAction SilentlyContinue).CreationTime.ToString("yyyy-MM-dd HH:mm:ss") } else { $null } Type = if (Test-Path $file.NewFile) { if ((Get-Item $file.NewFile -ErrorAction SilentlyContinue).PSIsContainer) { "Directory" } else { "File" } } else { "Unknown" } } $jsonData.NewFiles += $fileInfo } } # Process modified files for JSON if ($modifiedFiles -and $modifiedFiles.Count -gt 0) { foreach ($file in $modifiedFiles) { $fileInfo = @{ Path = $file.ModifiedFile Size = if (Test-Path $file.ModifiedFile) { (Get-Item $file.ModifiedFile -ErrorAction SilentlyContinue).Length } else { $null } ModifiedDate = if (Test-Path $file.ModifiedFile) { (Get-Item $file.ModifiedFile -ErrorAction SilentlyContinue).LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss") } else { $null } } $jsonData.ModifiedFiles += $fileInfo } } # Process new registry entries for JSON if ($newRegEntries -and $newRegEntries.Count -gt 0) { Write-Log "Processing $($newRegEntries.Count) new registry entries for JSON" "INFO" foreach ($regEntry in $newRegEntries) { # Get the full registry information for this path $regPath = $regEntry.NewRegistryEntry Write-Log "Processing registry path for JSON: $regPath" "INFO" try { $regItem = Get-Item -Path "Registry::$regPath" -ErrorAction SilentlyContinue if ($regItem) { $regInfo = @{ Path = $regPath KeyName = $regEntry.KeyName ValueCount = $regItem.ValueCount SubKeyCount = $regItem.SubKeyCount Values = @{} Properties = $regEntry.Properties } # Get all values in this registry key try { $regValues = Get-ItemProperty -Path "Registry::$regPath" -ErrorAction SilentlyContinue if ($regValues) { $regValues.PSObject.Properties | ForEach-Object { if ($_.Name -notin @('PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider')) { $regInfo.Values[$_.Name] = $_.Value } } } } catch { # Registry key might not have values or access denied } $jsonData.NewRegistryKeys += $regInfo } } catch { # Fallback for inaccessible registry keys - still include basic info Write-Log "Registry key not accessible, using fallback data: $regPath" "WARNING" $regInfo = @{ Path = $regPath KeyName = $regEntry.KeyName ValueCount = "Unknown" SubKeyCount = "Unknown" Values = @{} Properties = $regEntry.Properties Note = "Access denied or key not accessible" } $jsonData.NewRegistryKeys += $regInfo } } } else { Write-Log "No new registry entries found for JSON processing" "INFO" Write-Host "No new registry entries detected" -ForegroundColor Yellow } # Process new services for JSON if ($newServices -and $newServices.Count -gt 0) { foreach ($service in $newServices) { $serviceInfo = @{ Name = $service.NewService DisplayName = (Get-Service -Name $service.NewService -ErrorAction SilentlyContinue).DisplayName Status = (Get-Service -Name $service.NewService -ErrorAction SilentlyContinue).Status StartType = (Get-Service -Name $service.NewService -ErrorAction SilentlyContinue).StartType } $jsonData.NewServices += $serviceInfo } } # BEGIN NewPrograms derivation (from Uninstall registry diff — replaces the WMI product-class scan) # Derived after the NewRegistryKeys loop has settled. Keeps the legacy Name/Path keys for schema # compatibility; the other fields are additive. Note: the recursive registry snapshot can include # SUBKEYS of a new Uninstall entry — any subkey with its own DisplayName yields an extra row, which # the DisplayName|DisplayVersion dedupe below tolerates. $seenPrograms = @{} foreach ($regKey in $jsonData.NewRegistryKeys) { if ($regKey.Path -match '\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\' -and $regKey.Values -and $regKey.Values['DisplayName']) { $dedupeKey = "$($regKey.Values['DisplayName'])|$($regKey.Values['DisplayVersion'])" if (-not $seenPrograms.ContainsKey($dedupeKey)) { $seenPrograms[$dedupeKey] = $true $jsonData.NewPrograms += @{ Name = $regKey.Values['DisplayName'] DisplayName = $regKey.Values['DisplayName'] DisplayVersion = $regKey.Values['DisplayVersion'] Publisher = $regKey.Values['Publisher'] UninstallString = $regKey.Values['UninstallString'] Path = $regKey.Values['InstallLocation'] Source = 'UninstallRegistry' } } } } Write-Log "Derived $($jsonData.NewPrograms.Count) new programs from Uninstall registry diff" "INFO" # END NewPrograms derivation # --- Capture the installed application's icon (fills in when winget has no icon, and for manual apps) --- # Sources, in priority order: each new ARP 'DisplayIcon' (already captured in NewRegistryKeys above), then # the largest .exe under each new InstallLocation. Extracted at up to 256x256 as a REAL PNG with the alpha # channel intact, then written to Sandbox\Logs\ so it rides back to the host for BOTH backends (Sandbox maps # it live; the Hyper-V provider already copies Sandbox\Logs\*). The host side (finalize) decides whether to # promote it, honoring any winget/manual icon already applied. # # SECURITY: every value used here (DisplayIcon strings, InstallLocation) is RUNTIME registry data. It is only # ever passed as an ARGUMENT to Get-Item / the icon APIs — never spliced into generated code — so there is no # script-injection surface even though this runs as the capture user. try { Write-Host "`nExtracting the installed application icon..." -ForegroundColor Cyan Write-Log "Starting installed-app icon extraction" "INFO" $iconOutDir = 'C:\PSADT\Sandbox\Logs' if (-not (Test-Path $iconOutDir)) { New-Item -ItemType Directory -Path $iconOutDir -Force | Out-Null } $iconOut = Join-Path $iconOutDir 'AppIcon_Captured.png' $iconMinSize = 48 # skip-low-res gate: never upscale a frame this small or smaller Add-Type -AssemblyName System.Drawing -ErrorAction SilentlyContinue if (-not ('Win32IconApi.Native' -as [type])) { Add-Type -Namespace Win32IconApi -Name Native -MemberDefinition @" [System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] public static extern uint PrivateExtractIcons(string szFileName, int nIconIndex, int cxIcon, int cyIcon, System.IntPtr[] phicon, uint[] piconid, uint nIcons, uint flags); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool DestroyIcon(System.IntPtr hIcon); "@ -ErrorAction SilentlyContinue } # Mirror of ConvertFrom-Win32ToolkitDisplayIcon (kept 5.1-safe & self-contained for the guest). function ConvertFrom-DisplayIcon { param([string]$Value) if (-not $Value) { return $null } $v = $Value.Trim() if (-not $v) { return $null } $idx = 0 if ($v.StartsWith('"')) { $end = $v.IndexOf('"', 1) if ($end -lt 0) { return $null } $p = $v.Substring(1, $end - 1) $rest = $v.Substring($end + 1).Trim() if ($rest -match '^,\s*(-?\d+)') { $idx = [int]$matches[1] } } else { if ($v -match '^(.*),\s*(-?\d+)\s*$') { $p = $matches[1].Trim(); $idx = [int]$matches[2] } else { $p = $v } } if (-not $p) { return $null } $p = [Environment]::ExpandEnvironmentVariables($p) return New-Object PSObject -Property @{ Path = $p; Index = $idx } } function Save-IconViaApi { param([string]$Path, [int]$Index, [string]$OutFile, [int]$Size) if (-not ('Win32IconApi.Native' -as [type])) { return $false } $phicon = New-Object 'System.IntPtr[]' 1 # 'uint[]' is NOT a valid type name on the guest's Windows PowerShell 5.1 ('uint' became an # accelerator only in PS 6+). Use the full CLR name so PrivateExtractIcons' piconid marshals. $piconid = New-Object 'System.UInt32[]' 1 $n = [Win32IconApi.Native]::PrivateExtractIcons($Path, $Index, $Size, $Size, $phicon, $piconid, 1, 0) if ($n -lt 1 -or $phicon[0] -eq [System.IntPtr]::Zero) { return $false } $hicon = $phicon[0] try { $bmp = New-Object System.Drawing.Bitmap($Size, $Size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) try { $g = [System.Drawing.Graphics]::FromImage($bmp) try { $g.Clear([System.Drawing.Color]::Transparent) $ico = [System.Drawing.Icon]::FromHandle($hicon) try { $g.DrawIcon($ico, (New-Object System.Drawing.Rectangle(0, 0, $Size, $Size))) } finally { $ico.Dispose() } } finally { $g.Dispose() } $bmp.Save($OutFile, [System.Drawing.Imaging.ImageFormat]::Png) return $true } finally { $bmp.Dispose() } } finally { [void][Win32IconApi.Native]::DestroyIcon($hicon) } } function Save-AppIcon { param([string]$Path, [int]$Index, [string]$OutFile, [int]$MinSize) if (-not $Path) { return $false } if (-not (Test-Path -LiteralPath $Path)) { return $false } $ext = [System.IO.Path]::GetExtension($Path) if ($ext) { $ext = $ext.ToLower() } # .ico file: pick the largest frame; copy an embedded PNG frame byte-for-byte (best quality). if ($ext -eq '.ico') { try { $b = [System.IO.File]::ReadAllBytes($Path) if ($b.Length -lt 6 -or $b[0] -ne 0 -or $b[1] -ne 0 -or $b[2] -ne 1 -or $b[3] -ne 0) { return $false } $count = [System.BitConverter]::ToUInt16($b, 4) $bestSize = -1; $bestOff = 0; $bestLen = 0 for ($i = 0; $i -lt $count; $i++) { $e = 6 + ($i * 16) if ($e + 16 -gt $b.Length) { break } $w = $b[$e]; if ($w -eq 0) { $w = 256 } $h = $b[$e + 1]; if ($h -eq 0) { $h = 256 } $size = [Math]::Max($w, $h) if ($size -gt $bestSize) { $bestSize = $size $bestLen = [System.BitConverter]::ToInt32($b, $e + 8) $bestOff = [System.BitConverter]::ToInt32($b, $e + 12) } } if ($bestSize -lt $MinSize) { return $false } if ($bestOff -gt 0 -and $bestLen -gt 8 -and ($bestOff + $bestLen) -le $b.Length -and $b[$bestOff] -eq 0x89 -and $b[$bestOff + 1] -eq 0x50 -and $b[$bestOff + 2] -eq 0x4E -and $b[$bestOff + 3] -eq 0x47) { $frame = New-Object 'byte[]' $bestLen [System.Array]::Copy($b, $bestOff, $frame, 0, $bestLen) [System.IO.File]::WriteAllBytes($OutFile, $frame) return $true } return (Save-IconViaApi -Path $Path -Index 0 -OutFile $OutFile -Size ([Math]::Min($bestSize, 256))) } catch { return $false } } # A raster image used directly as the DisplayIcon: gate on real dimensions, re-encode PNG. if ($ext -eq '.png' -or $ext -eq '.jpg' -or $ext -eq '.jpeg' -or $ext -eq '.bmp' -or $ext -eq '.gif') { try { $img = [System.Drawing.Image]::FromFile($Path) try { if ([Math]::Max($img.Width, $img.Height) -lt $MinSize) { return $false } $img.Save($OutFile, [System.Drawing.Imaging.ImageFormat]::Png) return $true } finally { $img.Dispose() } } catch { return $false } } # PE (.exe/.dll) or unknown: extract the embedded icon at up to 256x256 (best-effort; the exact # low-res gate applies to .ico/raster sources where the native frame size is known cheaply). return (Save-IconViaApi -Path $Path -Index $Index -OutFile $OutFile -Size 256) } # Build the candidate list from the new Uninstall keys: DisplayIcon values first, InstallLocations fallback. $iconCandidates = @() $exeLocations = @() foreach ($rk in $jsonData.NewRegistryKeys) { if (-not $rk.Path) { continue } if ($rk.Path -notmatch '\\Uninstall\\') { continue } $vals = $rk.Values if (-not $vals) { continue } $di = [string]$vals['DisplayIcon'] $il = [string]$vals['InstallLocation'] if ($di) { $parsed = ConvertFrom-DisplayIcon -Value $di if ($parsed) { $iconCandidates += $parsed } } if ($il) { $loc = [Environment]::ExpandEnvironmentVariables($il).Trim() if ($loc) { $exeLocations += $loc } } } $iconDone = $false foreach ($c in $iconCandidates) { if (Save-AppIcon -Path $c.Path -Index $c.Index -OutFile $iconOut -MinSize $iconMinSize) { $iconDone = $true; break } } if (-not $iconDone) { # Fallback: the largest .exe under each InstallLocation (skip system roots — never recurse those). $systemRoots = @() foreach ($r in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramData, $env:SystemRoot, $env:USERPROFILE, 'C:\')) { if ($r) { $systemRoots += $r.TrimEnd('\').ToLower() } } foreach ($loc in $exeLocations) { if (-not (Test-Path -LiteralPath $loc)) { continue } $full = '' try { $full = [System.IO.Path]::GetFullPath($loc).TrimEnd('\').ToLower() } catch { $full = '' } if ($full -and ($systemRoots -contains $full)) { continue } $exe = Get-ChildItem -LiteralPath $loc -Filter *.exe -Recurse -Depth 2 -ErrorAction SilentlyContinue | Sort-Object Length -Descending | Select-Object -First 1 if ($exe -and (Save-AppIcon -Path $exe.FullName -Index 0 -OutFile $iconOut -MinSize $iconMinSize)) { $iconDone = $true; break } } } if ($iconDone) { Write-Host "Captured application icon -> $iconOut" -ForegroundColor Green Write-Log "Captured application icon to $iconOut" "SUCCESS" } else { Write-Host "No suitable application icon found to capture." -ForegroundColor Yellow Write-Log "No suitable application icon found to capture" "INFO" } } catch { Write-Host "Icon extraction failed: $($_.Exception.Message)" -ForegroundColor Yellow Write-Log "Icon extraction failed: $($_.Exception.Message)" "WARNING" } # Export JSON data try { $jsonOutput = $jsonData | ConvertTo-Json -Depth 10 -Compress:$false $jsonFilePath = "$outputPath\InstallationChanges_$timestamp.json" Set-Content -Path $jsonFilePath -Value $jsonOutput -Encoding UTF8 Write-Host "JSON documentation exported: InstallationChanges_$timestamp.json" -ForegroundColor Green Write-Log "JSON documentation exported successfully to: $jsonFilePath" "SUCCESS" } catch { Write-Host "Warning: Could not export JSON documentation" -ForegroundColor Yellow Write-Log "Warning: Could not export JSON documentation: $($_.Exception.Message)" "WARNING" } # Copy log file to documentation output folder try { Copy-Item -Path $logPath -Destination "$outputPath\Targeted_Documentation_Log_$timestamp.txt" -Force Write-Log "Log file copied to documentation output folder" "SUCCESS" } catch { Write-Log "Failed to copy log file to documentation folder: $($_.Exception.Message)" "WARNING" } # Copy PSADT / MSI install logs back to the project (survives sandbox teardown) try { if (Test-Path 'C:\PSADT\Sandbox\CollectLogs.ps1') { & 'C:\PSADT\Sandbox\CollectLogs.ps1' Write-Log "Install logs collected to Sandbox\Logs" "SUCCESS" } else { Write-Log "CollectLogs.ps1 not found in Sandbox folder" "WARNING" } } catch { Write-Log "Failed to collect install logs: $($_.Exception.Message)" "WARNING" } Write-Host "`n======================================" Write-Host "Targeted Documentation completed successfully!" -ForegroundColor Green Write-Log "========================================" "SUCCESS" Write-Log "PSADT Installation Documentation Completed (Targeted)" "SUCCESS" Write-Log "All documentation files saved to: $outputPath" "SUCCESS" Write-Log "Log file saved to: $logPath" "SUCCESS" Write-Log "========================================" "SUCCESS" Write-Host "======================================" Write-Host "Output folder: $outputPath" -ForegroundColor Cyan Write-Host "`nGenerated Files:" -ForegroundColor Yellow Write-Host "• JSON format for automation and uninstall/requirement script generation" -ForegroundColor White Write-Host "• Complete execution log with detailed change analysis" -ForegroundColor White Write-Host "`nNext steps:" -ForegroundColor Yellow Write-Host "1. Copy the Documentation folder to your host system" -ForegroundColor Green Write-Host "2. Use InstallationChanges JSON for automated uninstall and requirement script generation" -ForegroundColor Green Write-Host "3. Review detailed log for installation analysis" -ForegroundColor Green Write-Log "Documentation process completed successfully" "SUCCESS" # The auto-close countdown + Stop-Computer are Windows Sandbox teardown. Under Hyper-V the host # reverts the checkpoint AFTER the provider copies the capture back — the guest must NOT shut down # (that would kill the PowerShell Direct session mid-copy) and there is no sandbox client to disconnect. # The duration is mode-dependent (host decides): 30 s WATCHED — the operator's Ctrl+C window to keep the # sandbox and inspect the captured install; 5 s UNATTENDED — just enough for the VSMB mapped-folder # write-back to flush, so a chained test's single-instance guard clears quickly. if ($backend -eq 'Sandbox') { # Quoted-cast placeholder so the raw template itself stays parseable (5.1-safe). $autoClose = [int]'__AUTOCLOSE__' Write-Host "`n======================================" Write-Host "AUTO-CLOSING SANDBOX IN $autoClose SECONDS" -ForegroundColor Red Write-Host "======================================" Write-Host "The Windows Sandbox will automatically close to clean up resources." -ForegroundColor Yellow Write-Host "All documentation files have been saved to the mapped folder." -ForegroundColor Green Write-Host "`nPress Ctrl+C to cancel auto-close..." -ForegroundColor Gray for ($i = $autoClose; $i -gt 0; $i--) { Write-Progress -Activity "Auto-closing Windows Sandbox" -Status "Sandbox will close automatically to free resources..." -SecondsRemaining $i -PercentComplete ((($autoClose - $i) / $autoClose) * 100) Start-Sleep -Seconds 1 } Write-Progress -Activity "Auto-closing Windows Sandbox" -Completed Write-Host "`nClosing Windows Sandbox..." -ForegroundColor Red Write-Log "Sandbox auto-close initiated after $autoClose-second countdown" "INFO" # Gracefully shut down the sandbox so the host client can disconnect cleanly Stop-Computer } '@ # Replace placeholders with actual values. .Replace(), NOT -replace: the regex operator treats # the replacement as a .NET SUBSTITUTION string, so a project name containing $_ / $& would # splice template text into itself. Values landing inside '...' assignments are single-quote # escaped so an apostrophe in a project name cannot break the generated script's parse. $documentationScript = $documentationScript.Replace('__PROJECTNAME__', ($ProjectName -replace "'", "''")) $documentationScript = $documentationScript.Replace('__TIMESTAMP__', [string]$timestamp) $documentationScript = $documentationScript.Replace('__SCOPE__', [string]$installerScope) $documentationScript = $documentationScript.Replace('__BACKEND__', [string]$Backend) # Auto-close: 30 s watched (the operator's Ctrl+C inspect window) / 5 s unattended (VSMB flush # only). Mode comes from the SHARED resolver so the -Unattended/config/non-interactive-host # precedence cannot drift from the test scenarios. Fail-safe to 30: a resolver hiccup must never # leave the placeholder empty or shorten the operator's window. $autoCloseSeconds = 30 try { if ((Get-Win32ToolkitTestMode -Backend Sandbox) -eq 'Unattended') { $autoCloseSeconds = 5 } } catch { $autoCloseSeconds = 30 } $documentationScript = $documentationScript.Replace('__AUTOCLOSE__', [string]$autoCloseSeconds) # Save the documentation script inside the PSADT project's SupportFiles folder. Write UTF-8 WITH a # BOM: the guest runs it under Windows PowerShell 5.1, which decodes a BOM-less file as ANSI and # mojibakes non-ASCII (matches New-UpdateAssertionScript). PS7 `Set-Content -Encoding UTF8` is BOM-less. $docScriptPath = Join-Path $supportFilesFolder "TargetedDocumentationScript.ps1" [System.IO.File]::WriteAllText($docScriptPath, $documentationScript, (New-Object System.Text.UTF8Encoding($true))) Write-Verbose "Targeted documentation script created: $docScriptPath" # Create the log collector used inside the sandbox (Sandbox\CollectLogs.ps1), # so the documentation run's PSADT/MSI install logs are copied back to the project. $null = New-LogCollectorScript -ProjectPath $ProjectPath Write-Verbose "Log collector created for documentation sandbox" # Stage declared dependencies into Sandbox\Dependencies\ so the guest can install them BEFORE the # baseline. Without this the capture runs on a machine missing the app's runtime, and the detection # / uninstall rules would be derived from a broken install. No-op when none are declared. $null = Initialize-Win32ToolkitDependencyStaging -ProjectPath $ProjectPath # Create the sandbox configuration (Sandbox backend only — Hyper-V runs the same generated script # over PowerShell Direct with no .wsb). Built via the shared .wsb builder (test-backend seam, Phase 0). if ($Backend -eq 'Sandbox') { $sandboxConfigContent = New-Win32ToolkitSandboxConfig ` -Mount @{ HostPath = $ProjectPath; GuestPath = 'C:\PSADT'; ReadOnly = $false } ` -LogonCommandXml 'powershell.exe -NoExit -ExecutionPolicy Bypass -File C:\PSADT\SupportFiles\TargetedDocumentationScript.ps1' # Write the sandbox configuration to the file $sandboxConfigContent | Set-Content -Path $sandboxConfigFile -Encoding UTF8 Write-Verbose "Targeted documentation sandbox configuration created!" } # Clear stale captures from previous runs BEFORE launching, so any capture file present after # this point postdates this launch (re-finalize previously picked up the old JSON instantly). # Only the two generated patterns are removed — user notes in Documentation\ survive. $docFolder = Join-Path $ProjectPath 'Documentation' if (Test-Path -LiteralPath $docFolder) { $stale = @(Get-ChildItem -Path $docFolder -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -like 'InstallationChanges_*.json' -or $_.Name -like 'Targeted_Documentation_Log_*.txt' }) foreach ($f in $stale) { try { Remove-Item -LiteralPath $f.FullName -Force } catch { Write-Warning "Could not remove stale capture '$($f.Name)': $($_.Exception.Message)" } } if ($stale.Count -gt 0) { Write-Verbose "Cleared $($stale.Count) stale capture file(s) from Documentation\" } } # Also clear a stale captured icon, for the same reason: if THIS run extracts no icon, the finalize # reconcile must not promote the previous run's leftover AppIcon_Captured.png. $staleIcon = Join-Path $ProjectPath 'Sandbox\Logs\AppIcon_Captured.png' if (Test-Path -LiteralPath $staleIcon) { try { Remove-Item -LiteralPath $staleIcon -Force } catch { Write-Warning "Could not remove stale captured icon: $($_.Exception.Message)" } } # Hyper-V: the generated script + log collector are prepared and stale captures cleared. The # Hyper-V provider copies the project into the VM, runs the script over PowerShell Direct, and # copies the capture back — no .wsb build, feature probe, or WindowsSandbox.exe launch. if ($Backend -eq 'HyperV') { Write-Host '✓ Documentation prepared for the Hyper-V backend (the provider runs it in the VM).' -ForegroundColor Green return $expectedJson } # Check if Windows Sandbox is available. The DISM probe costs 2-10 s, and the answer cannot change # while this process runs (enabling the feature needs a reboot) — so probe ONCE per process and # reuse the verdict. Deliberately a cache, not a cheaper Get-Command check: the probe's value is # the feature-DISABLED advisory (WindowsSandbox.exe can exist with the feature off). if ($null -eq $script:SandboxFeatureEnabled) { try { $sandboxFeature = Get-WindowsOptionalFeature -Online -FeatureName "Containers-DisposableClientVM" -ErrorAction SilentlyContinue $script:SandboxFeatureEnabled = if ($sandboxFeature) { $sandboxFeature.State -eq 'Enabled' } else { $null } if ($null -eq $script:SandboxFeatureEnabled) { Write-Warning "Unable to check Windows Sandbox feature status. Windows Sandbox may not be available on this system." $script:SandboxFeatureEnabled = $true # unknown -> don't re-probe and don't nag; launch failure still warns } } catch { Write-Warning "Unable to check Windows Sandbox feature status. Windows Sandbox may not be available on this system." $script:SandboxFeatureEnabled = $true } } if ($script:SandboxFeatureEnabled -eq $false) { Write-Warning "Windows Sandbox feature is not enabled. Please enable it in Windows Features." Write-Warning "To enable: Control Panel > Programs > Turn Windows features on or off > Windows Sandbox" } Write-Host "`nTargeted Documentation Setup Complete!" -ForegroundColor Cyan Write-Host "===============================================" -ForegroundColor Cyan Write-Host "Files created:" -ForegroundColor White Write-Host "• Sandbox Config: $sandboxConfigFile" -ForegroundColor Green Write-Host "• Documentation Script: $docScriptPath" -ForegroundColor Green # Launch Windows Sandbox automatically Write-Host "`nLaunching Windows Sandbox for targeted documentation..." -ForegroundColor Yellow Write-Host "===============================================" -ForegroundColor Cyan Write-Host "The sandbox will:" -ForegroundColor White Write-Host "• Scan targeted directories (Program Files, ProgramData, AppData)" -ForegroundColor Green Write-Host "• Monitor key registry locations (Uninstall, App Paths)" -ForegroundColor Green Write-Host "• Capture services baseline (programs derived from the registry diff)" -ForegroundColor Green Write-Host "• Run your PSADT installation" -ForegroundColor Green Write-Host "• Compare before/after states to identify changes" -ForegroundColor Green Write-Host "• Generate detailed change reports" -ForegroundColor Green Write-Host "• Much faster than full system scans (2-5 minutes vs 10-30!)" -ForegroundColor Green Write-Host "===============================================" -ForegroundColor Cyan if ($SkipLaunch) { Write-Host '(Sandbox launch skipped — test mode.)' -ForegroundColor DarkYellow return $expectedJson } # Stale captures were just cleared, so nothing will satisfy the wait until a capture from THIS # run appears — if the operator doesn't launch manually, the wait times out (30 min). if ((Invoke-Win32ToolkitTestRun -Backend Sandbox -SandboxConfigPath $sandboxConfigFile).Launched) { Write-Host "✓ Windows Sandbox launched successfully!" -ForegroundColor Green Write-Host "`nThe targeted documentation will be available in the sandbox at:" -ForegroundColor Cyan Write-Host "C:\PSADT\Documentation" -ForegroundColor White } else { Write-Warning "The sandbox did NOT auto-launch — start it manually by double-clicking:" Write-Warning " $sandboxConfigFile" Write-Warning 'Otherwise the documentation wait will time out after 30 minutes.' } return $expectedJson } catch { Write-Warning "Failed to create targeted documentation: $($_.Exception.Message)" return $false } } |