#Requires -RunAsAdministrator #Requires -Version 5.1 <# .SYNOPSIS Windows Driver Import/Export Manager with Terminal GUI .DESCRIPTION Interactive terminal-based GUI for managing Windows driver backups and restoration. Combines Export-Drivers and Import-Drivers functionality into a unified interface. Can be run in three modes: 1. GUI Mode (default) - Interactive terminal interface 2. Export Mode (-Export) - Headless driver export 3. Import Mode (-Import) - Headless driver import .PARAMETER Mode Operation mode: GUI (default), Export, or Import .PARAMETER TargetDrive Target drive letter for export (e.g., "D:") or "Auto" to use first available removable drive .PARAMETER SourceDrive Source drive letter for import (e.g., "D:") or "Auto" to search all drives .PARAMETER Directory Directory name for driver storage (default: ".drivers") .PARAMETER Force Skip confirmation prompts in headless mode .PARAMETER Quiet Suppress non-essential output in headless mode .EXAMPLE Driver-Manager.ps1 Launches interactive GUI mode .EXAMPLE Driver-Manager.ps1 -Mode Export -TargetDrive "E:" -Force Export drivers to E:\.drivers\ without prompts .EXAMPLE Driver-Manager.ps1 -Mode Export -TargetDrive Auto Export drivers to first removable drive automatically .EXAMPLE Driver-Manager.ps1 -Mode Import -SourceDrive Auto -Force Import drivers from auto-detected source without prompts .EXAMPLE Driver-Manager.ps1 -Mode Import -SourceDrive "D:" -Quiet Import drivers from D: with minimal output .NOTES Author: Seton (based on original Export/Import scripts) Requires: Microsoft.PowerShell.ConsoleGuiTools module (GUI mode only) #> [CmdletBinding()] param( [Parameter(Position = 0)] [ValidateSet('GUI', 'Export', 'Import')] [string]$Mode = 'GUI', [Parameter()] [string]$TargetDrive, [Parameter()] [string]$SourceDrive, [Parameter()] [string]$Directory = '.drivers', [Parameter()] [switch]$Force, [Parameter()] [switch]$Quiet ) #region PowerShell 7 Check and Auto-Relaunch # Only enforce PowerShell 7 for GUI mode (headless modes work fine on PS 5.1+) if ($Mode -eq 'GUI' -and $PSVersionTable.PSVersion.Major -lt 7) { Write-Host "PowerShell 7+ is required for optimal compatibility." -ForegroundColor Yellow Write-Host "Current version: PowerShell $($PSVersionTable.PSVersion)" -ForegroundColor Gray Write-Host "" # Try to find PowerShell 7 $pwshPath = $null $pwshLocations = @( (Get-Command pwsh -ErrorAction SilentlyContinue).Source, "C:\Program Files\PowerShell\7\pwsh.exe", "$env:ProgramFiles\PowerShell\7\pwsh.exe", "${env:ProgramFiles(x86)}\PowerShell\7\pwsh.exe", "$env:LOCALAPPDATA\Microsoft\PowerShell\7\pwsh.exe" ) foreach ($location in $pwshLocations) { if ($location -and (Test-Path $location)) { $pwshPath = $location Write-Host "Found PowerShell 7 at: $pwshPath" -ForegroundColor Green break } } if ($pwshPath) { # Relaunch in PowerShell 7 Write-Host "Relaunching in PowerShell 7..." -ForegroundColor Cyan Write-Host "" if ($PSCommandPath) { # Running from a file - can relaunch directly $arguments = @('-NoProfile', '-File', $PSCommandPath) # Pass through all parameters if ($Mode) { $arguments += @('-Mode', $Mode) } if ($TargetDrive) { $arguments += @('-TargetDrive', $TargetDrive) } if ($SourceDrive) { $arguments += @('-SourceDrive', $SourceDrive) } if ($Directory) { $arguments += @('-Directory', $Directory) } if ($Force) { $arguments += '-Force' } if ($Quiet) { $arguments += '-Quiet' } Start-Process -FilePath $pwshPath -ArgumentList $arguments -NoNewWindow -Wait exit } else { # Running via irm | iex - can't easily relaunch Write-Host "Please rerun this script using PowerShell 7:" -ForegroundColor Yellow Write-Host " pwsh -Command `"irm YOUR_URL | iex`"" -ForegroundColor White Write-Host "" Write-Host "Or install as a file and run it." -ForegroundColor Gray exit 1 } } else { # PowerShell 7 not found - offer to install Write-Host "PowerShell 7 not found. Installing automatically in 3 seconds..." -ForegroundColor Yellow Write-Host "(Press Ctrl+C to cancel and install manually)" -ForegroundColor Gray Write-Host "" # Countdown for ($i = 3; $i -gt 0; $i--) { Write-Host " Installing in $i..." -ForegroundColor Cyan -NoNewline Start-Sleep -Seconds 1 Write-Host "`r" -NoNewline } Write-Host "" try { # Try winget first (fastest) Write-Host "Attempting installation via winget..." -ForegroundColor Yellow $wingetResult = & winget install Microsoft.PowerShell --silent --accept-source-agreements --accept-package-agreements 2>&1 if ($LASTEXITCODE -eq 0) { Write-Host "PowerShell 7 installed successfully via winget!" -ForegroundColor Green Write-Host "" Write-Host "Please restart this script to use PowerShell 7." -ForegroundColor Yellow Write-Host "The script will auto-relaunch in PowerShell 7 next time." -ForegroundColor Gray exit 0 } else { throw "winget installation failed" } } catch { # Fallback to MSI installer Write-Host "winget not available, using MSI installer..." -ForegroundColor Yellow try { iex "& { $(irm https://aka.ms/install-powershell.ps1) } -UseMSI -Quiet" Write-Host "PowerShell 7 installed successfully!" -ForegroundColor Green Write-Host "" Write-Host "Please restart this script to use PowerShell 7." -ForegroundColor Yellow exit 0 } catch { Write-Host "Automatic installation failed: $_" -ForegroundColor Red Write-Host "" -ForegroundColor Red Write-Host "Please install PowerShell 7 manually:" -ForegroundColor Yellow Write-Host " winget install Microsoft.PowerShell" -ForegroundColor White Write-Host " or download from: https://aka.ms/powershell-release?tag=stable" -ForegroundColor White exit 1 } } } } #endregion #region Debug Logging $script:DebugLogPath = Join-Path $env:TEMP "Driver-Manager-Debug.log" function Write-DebugLog { param([string]$Message) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" $logMessage = "[$timestamp] $Message" # Always write to log file Add-Content -Path $script:DebugLogPath -Value $logMessage -Force # Also write to verbose stream Write-Verbose $logMessage } # Initialize log Write-DebugLog "=== Driver Manager Started ===" Write-DebugLog "Mode: $Mode" Write-DebugLog "Debug log: $script:DebugLogPath" #endregion #region Windows PE and Offline Windows Detection # Script-level variables for offline Windows support $script:IsWindowsPE = $false $script:OfflineWindowsPath = $null $script:OfflineWindowsDrive = $null $script:PEDriveLetter = $null function Test-WindowsPE { <# .SYNOPSIS Detect if running in Windows PE environment #> try { # Primary check: MiniNT registry key exists in WinPE if (Test-Path "HKLM:\SYSTEM\CurrentControlSet\Control\MiniNT") { Write-DebugLog "Windows PE detected: MiniNT registry key exists" return $true } # Secondary check: PE typically runs from X: drive if (Test-Path "X:\Windows\System32" -ErrorAction SilentlyContinue) { Write-DebugLog "Windows PE detected: X:\Windows\System32 exists" return $true } Write-DebugLog "Not running in Windows PE" return $false } catch { Write-DebugLog "Error detecting Windows PE: $_" return $false } } function Get-OfflineWindowsInstallations { <# .SYNOPSIS Find offline Windows installations on available drives .DESCRIPTION Searches all available drives for Windows\System32 directories and returns information about discovered installations. Excludes the Windows PE drive (typically X:) #> $installations = @() Write-DebugLog "Searching for offline Windows installations..." # Get all fixed drives $drives = Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Root -match '^[A-Z]:\\$' -and (Test-Path $_.Root -ErrorAction SilentlyContinue) } foreach ($drive in $drives) { $driveLetter = $drive.Root # Skip X: drive (Windows PE boot drive) if ($driveLetter -eq "X:\") { Write-DebugLog "Skipping PE drive: $driveLetter" continue } $windowsPath = Join-Path $driveLetter "Windows" $system32Path = Join-Path $windowsPath "System32" Write-DebugLog "Checking drive $driveLetter for Windows installation" # Check if this drive has a Windows installation # Also verify it's not the PE environment by checking for specific PE files if ((Test-Path $system32Path -ErrorAction SilentlyContinue)) { # Additional check: PE usually has winpeshl.exe in System32 $winpeshlPath = Join-Path $system32Path "winpeshl.exe" if (Test-Path $winpeshlPath -ErrorAction SilentlyContinue) { Write-DebugLog "Skipping PE image at $windowsPath (winpeshl.exe detected)" continue } Write-DebugLog "Found Windows installation at $windowsPath" # Try to get more info about the installation $installInfo = @{ Drive = $driveLetter WindowsPath = $windowsPath System32Path = $system32Path DisplayName = $driveLetter.TrimEnd('\') } # Try to read version info from registry hive (if accessible) $regPath = Join-Path $windowsPath "System32\config\SOFTWARE" if (Test-Path $regPath -ErrorAction SilentlyContinue) { try { # Try to get Windows version info $installInfo.DisplayName = "$($driveLetter.TrimEnd('\')) - Windows Installation" Write-DebugLog "Windows installation verified at $windowsPath" } catch { Write-DebugLog "Could not read version info for $windowsPath" } } $installations += [PSCustomObject]$installInfo } } Write-DebugLog "Found $($installations.Count) Windows installation(s)" return $installations } function Get-OfflineWindowsModel { <# .SYNOPSIS Get computer model from offline Windows registry or BIOS .PARAMETER WindowsPath Path to offline Windows installation (e.g., "D:\Windows") #> param( [Parameter(Mandatory = $true)] [string]$WindowsPath ) # First, try getting model from the current system's BIOS # In PE, the hardware is the same, so this should work Write-DebugLog "Attempting to get model from system BIOS (physical hardware)" try { # Try WMI first - this reads from actual hardware $biosInfo = Get-CimInstance -ClassName Win32_ComputerSystemProduct -ErrorAction SilentlyContinue if ($biosInfo -and $biosInfo.Name) { $model = $biosInfo.Name Write-DebugLog "Got model from BIOS: $model" return $model } } catch { Write-DebugLog "Could not get model from BIOS via WMI: $_" } # Try wmic if CIM fails try { $wmicResult = & wmic csproduct get name 2>&1 | Select-Object -Skip 1 | Where-Object { $_.Trim() -ne "" } if ($wmicResult -and $wmicResult.Trim()) { $model = $wmicResult.Trim() Write-DebugLog "Got model from wmic: $model" return $model } } catch { Write-DebugLog "Could not get model from wmic: $_" } # Try reading from offline Windows registry Write-DebugLog "Attempting to get model from offline Windows registry: $WindowsPath" try { # Path to SYSTEM registry hive $systemHivePath = Join-Path $WindowsPath "System32\config\SYSTEM" if (-not (Test-Path $systemHivePath)) { Write-DebugLog "SYSTEM hive not found at: $systemHivePath" return "Offline Windows" } Write-DebugLog "SYSTEM hive found at: $systemHivePath" # Mount the registry hive temporarily $mountPoint = "HKLM\TempOfflineSystem" Write-DebugLog "Mounting registry hive to $mountPoint" # Load the hive using reg.exe - don't use extra quotes $loadCmd = "reg.exe load $mountPoint `"$systemHivePath`"" Write-DebugLog "Running command: $loadCmd" $result = cmd.exe /c $loadCmd 2>&1 Write-DebugLog "Reg load result: $result" Write-DebugLog "Reg load exit code: $LASTEXITCODE" if ($LASTEXITCODE -ne 0) { Write-DebugLog "Failed to load registry hive, trying without quotes" # Try without quotes if path has no spaces if ($systemHivePath -notmatch '\s') { $result = & reg.exe load $mountPoint $systemHivePath 2>&1 Write-DebugLog "Second attempt result: $result" Write-DebugLog "Second attempt exit code: $LASTEXITCODE" } if ($LASTEXITCODE -ne 0) { Write-DebugLog "Failed to load registry hive after retry" return "Offline Windows" } } try { # Wait a moment for registry to be available Start-Sleep -Milliseconds 200 # First, find which control set is current $selectPath = "HKLM:\TempOfflineSystem\Select" $currentControlSet = "ControlSet001" if (Test-Path $selectPath) { $selectInfo = Get-ItemProperty -Path $selectPath -ErrorAction SilentlyContinue if ($selectInfo.Current) { $currentControlSet = "ControlSet" + ([string]$selectInfo.Current).PadLeft(3, '0') Write-DebugLog "Current control set: $currentControlSet" } } # Try multiple locations for system information $pathsToTry = @( "HKLM:\TempOfflineSystem\$currentControlSet\Control\SystemInformation", "HKLM:\TempOfflineSystem\ControlSet001\Control\SystemInformation", "HKLM:\TempOfflineSystem\$currentControlSet\Hardware Abstraction Layer\ComputerName" ) foreach ($keyPath in $pathsToTry) { Write-DebugLog "Trying path: $keyPath" if (Test-Path $keyPath) { Write-DebugLog "Path exists!" $systemInfo = Get-ItemProperty -Path $keyPath -ErrorAction SilentlyContinue if ($systemInfo) { Write-DebugLog "Got properties: $($systemInfo.PSObject.Properties.Name -join ', ')" if ($systemInfo.SystemManufacturer -and $systemInfo.SystemProductName) { $model = "$($systemInfo.SystemManufacturer) $($systemInfo.SystemProductName)" Write-DebugLog "Found model: $model" return $model } elseif ($systemInfo.SystemProductName) { Write-DebugLog "Found model: $($systemInfo.SystemProductName)" return $systemInfo.SystemProductName } } } else { Write-DebugLog "Path does not exist" } } Write-DebugLog "No model information found in any location" return "Offline Windows" } finally { # Always unload the hive Write-DebugLog "Unloading registry hive" $unloadResult = & reg.exe unload $mountPoint 2>&1 Write-DebugLog "Unload result: $unloadResult" # Sometimes unload needs a moment Start-Sleep -Milliseconds 500 } } catch { Write-DebugLog "Error reading offline model: $_" Write-DebugLog "Error details: $($_.Exception.Message)" Write-DebugLog "Stack trace: $($_.ScriptStackTrace)" # Try to unload if we loaded it try { & reg.exe unload "HKLM\TempOfflineSystem" 2>&1 | Out-Null } catch { } return "Offline Windows" } } function Show-WindowsInstallationSelector { <# .SYNOPSIS Show dialog to select offline Windows installation .PARAMETER Installations Array of discovered Windows installations #> param( [Parameter(Mandatory = $true)] [array]$Installations ) Write-DebugLog "Showing Windows installation selector dialog" # If only one installation, auto-select it if ($Installations.Count -eq 1) { Write-DebugLog "Only one installation found, auto-selecting" return $Installations[0] } # Create selection dialog $dialog = [Terminal.Gui.Dialog]::new() $dialog.Title = "Select Windows Installation" $dialog.Width = 70 $dialog.Height = 18 # Instructions label $lblInstructions = [Terminal.Gui.Label]::new() $lblInstructions.X = 1 $lblInstructions.Y = 1 $lblInstructions.Width = [Terminal.Gui.Dim]::Fill() - 2 $lblInstructions.Height = 2 $lblInstructions.Text = "Multiple Windows installations detected. Select one:" $dialog.Add($lblInstructions) # Create list view for installations $listView = [Terminal.Gui.ListView]::new() $listView.X = 1 $listView.Y = [Terminal.Gui.Pos]::Bottom($lblInstructions) $listView.Width = [Terminal.Gui.Dim]::Fill() - 2 $listView.Height = [Terminal.Gui.Dim]::Fill() - 4 # Build display items $displayItems = $Installations | ForEach-Object { "$($_.DisplayName) ($($_.WindowsPath))" } $listView.SetSource($displayItems) $dialog.Add($listView) # Store installations in script scope for button handler $script:TempInstallations = $Installations $script:SelectedInstallation = $null # OK button $btnOK = [Terminal.Gui.Button]::new() $btnOK.Text = "OK" $btnOK.X = [Terminal.Gui.Pos]::Center() - 10 $btnOK.Y = [Terminal.Gui.Pos]::Bottom($listView) + 1 $btnOK.Width = 10 $btnOK.Height = 1 $btnOK.IsDefault = $true $btnOK.add_Clicked({ $selectedIndex = $listView.SelectedItem if ($selectedIndex -ge 0 -and $selectedIndex -lt $script:TempInstallations.Count) { $script:SelectedInstallation = $script:TempInstallations[$selectedIndex] Write-DebugLog "Selected installation: $($script:SelectedInstallation.WindowsPath)" [Terminal.Gui.Application]::RequestStop() } }) $dialog.AddButton($btnOK) # Cancel button $btnCancel = [Terminal.Gui.Button]::new() $btnCancel.Text = "Cancel" $btnCancel.X = [Terminal.Gui.Pos]::Center() + 2 $btnCancel.Y = [Terminal.Gui.Pos]::Bottom($listView) + 1 $btnCancel.Width = 10 $btnCancel.Height = 1 $btnCancel.add_Clicked({ $script:SelectedInstallation = $null [Terminal.Gui.Application]::RequestStop() }) $dialog.AddButton($btnCancel) # Show dialog [Terminal.Gui.Application]::Run($dialog) # Clean up temp variable $result = $script:SelectedInstallation $script:TempInstallations = $null $script:SelectedInstallation = $null return $result } #endregion #region Terminal.Gui Initialization function Initialize-TerminalGui { <# .SYNOPSIS Initialize Terminal.Gui framework #> try { $dllPath = $null # Try to find Terminal.Gui.dll in multiple locations $searchPaths = @() # Only add script directory paths if PSScriptRoot is available (not when run via irm | iex) if ($PSScriptRoot) { $searchPaths += (Join-Path $PSScriptRoot "Terminal.Gui.dll") $searchPaths += (Join-Path $PSScriptRoot "lib\Terminal.Gui.dll") } # Module path (for installed module) $modulePath = Get-Module -ListAvailable -Name Microsoft.PowerShell.ConsoleGuiTools | Select-Object -First 1 -ExpandProperty ModuleBase if ($modulePath) { $searchPaths += (Join-Path $modulePath "Terminal.Gui.dll") } # Find first valid DLL path foreach ($path in $searchPaths) { if ($path -and (Test-Path $path)) { $dllPath = $path Write-Verbose "Found Terminal.Gui.dll at: $dllPath" break } } # If DLL not found, try to import or install module if (-not $dllPath) { Write-Host "Microsoft.PowerShell.ConsoleGuiTools module is required for GUI mode." -ForegroundColor Yellow Write-Host "" try { Import-Module Microsoft.PowerShell.ConsoleGuiTools -ErrorAction Stop $module = (Get-Module Microsoft.PowerShell.ConsoleGuiTools).ModuleBase $dllPath = Join-Path $module "Terminal.Gui.dll" Write-Host "Module loaded successfully!" -ForegroundColor Green } catch { # Module not installed - offer to install it Write-Host "Module not found. Installing automatically in 3 seconds..." -ForegroundColor Yellow Write-Host "(You can still use headless modes: -Mode Export or -Mode Import)" -ForegroundColor Gray Write-Host "" # Countdown for ($i = 3; $i -gt 0; $i--) { Write-Host " Installing in $i..." -ForegroundColor Cyan -NoNewline Start-Sleep -Seconds 1 Write-Host "`r" -NoNewline } Write-Host "" try { Write-Host "Installing Microsoft.PowerShell.ConsoleGuiTools module..." -ForegroundColor Yellow Install-Module Microsoft.PowerShell.ConsoleGuiTools -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop Write-Host "Module installed successfully!" -ForegroundColor Green Write-Host "" # Import the newly installed module Import-Module Microsoft.PowerShell.ConsoleGuiTools -ErrorAction Stop $module = (Get-Module Microsoft.PowerShell.ConsoleGuiTools).ModuleBase $dllPath = Join-Path $module "Terminal.Gui.dll" } catch { Write-Host "Failed to install module: $_" -ForegroundColor Red Write-Host "" -ForegroundColor Red Write-Host "You can install it manually with:" -ForegroundColor Yellow Write-Host " Install-Module Microsoft.PowerShell.ConsoleGuiTools -Scope CurrentUser" -ForegroundColor White Write-Host "" -ForegroundColor Yellow Write-Host "Or use headless modes without the module:" -ForegroundColor Cyan Write-Host " .\Driver-Manager.ps1 -Mode Export -TargetDrive Auto -Force" -ForegroundColor White Write-Host " .\Driver-Manager.ps1 -Mode Import -Force" -ForegroundColor White exit 1 } } } # Verify DLL exists if (-not (Test-Path $dllPath)) { throw "Terminal.Gui.dll not found at: $dllPath" } # Load the DLL Add-Type -Path $dllPath # Initialize application [Terminal.Gui.Application]::Init() return $true } catch { Write-Host "Failed to initialize Terminal.Gui: $_" -ForegroundColor Red Write-Host "" -ForegroundColor Red Write-Host "You can still use headless modes:" -ForegroundColor Cyan Write-Host " .\Driver-Manager.ps1 -Mode Export -TargetDrive Auto -Force" -ForegroundColor White Write-Host " .\Driver-Manager.ps1 -Mode Import -Force" -ForegroundColor White return $false } } #endregion #region Driver Export Functions (from Export-Drivers.ps1) function Select-ExportTargetDisk { <# .SYNOPSIS Select a target disk for driver export from available volumes. .DESCRIPTION This function lists all available volumes and prompts the user to select a drive letter, then returns the selected drive letter with a colon appended if not already present. Prioritizes external/removable drives for faster scanning. In PE mode, filters out the PE drive and the offline Windows installation drive. .PARAMETER IncludeInternal Include internal fixed drives in the list. Default is to show only removable/external drives first. .EXAMPLE $ExternalDrive = Select-ExportTargetDisk #> [CmdletBinding()] [OutputType([string])] param ( [switch]$IncludeInternal ) $Volumes = (Get-Volume | Select-Object -Property DriveLetter, FileSystemLabel, FileSystemType, Size, SizeRemaining, DriveType) # Filter out volumes without drive letters $Volumes = $Volumes | Where-Object { $_.DriveLetter } # Build list of drives to exclude $excludeDrives = @() # In PE mode, exclude PE drive (typically X:) and offline Windows drive if ($script:IsWindowsPE) { # Exclude PE drive (typically X:) if (Test-Path "X:\Windows\System32") { $excludeDrives += 'X' Write-DebugLog "Excluding PE drive: X:" } # Exclude the drive containing offline Windows installation if ($script:OfflineWindowsDrive) { $offlineDriveLetter = $script:OfflineWindowsDrive.TrimEnd(':', '\') $excludeDrives += $offlineDriveLetter Write-DebugLog "Excluding offline Windows drive: $offlineDriveLetter" } } # Filter out excluded drives if ($excludeDrives.Count -gt 0) { $Volumes = $Volumes | Where-Object { $_.DriveLetter -notin $excludeDrives } } # If not including internal, filter to only Removable and external drives # In PE mode, be more permissive since USB drives may appear as Fixed if (-not $IncludeInternal) { if ($script:IsWindowsPE) { # In PE: Show all remaining drives (already filtered out X: and Windows drive) # Just exclude common system drives $Volumes = $Volumes | Where-Object { $_.DriveLetter -notin @('C') } } else { # Normal Windows: Filter to removable/network only $Volumes = $Volumes | Where-Object { $_.DriveType -in @('Removable', 'Network') -or ($_.DriveType -eq 'Fixed' -and $_.DriveLetter -notin @('C', 'D')) } } } return $Volumes } function Initialize-ExportPath { <# .SYNOPSIS Validate that the selected export path exists and create it if it does not. .DESCRIPTION This function validates the export path for driver export, ensuring that it exists or creating it if necessary. If the directory already exists, it prompts the user for confirmation to overwrite it. .PARAMETER ExternalDrive The drive letter of the external drive where drivers will be exported. Provided by Select-ExportTargetDisk. .PARAMETER Directory The directory where drivers will be exported. In my case, this is ".drivers". .PARAMETER Model The model name of the machine, used to find and/or create a subdirectory to use for the driver export. .EXAMPLE $DriverPath = Initialize-ExportPath -ExternalDrive "D:" -Directory ".drivers" -Model "OptiPlex 9020" #> [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory = $true)] [string]$ExternalDrive, [string]$Directory, [string]$Model, [switch]$AutoConfirm ) $AssembledPath = "$($ExternalDrive)\$($Directory)\$($Model)" # Try to resolve the exact path first if (Test-Path $AssembledPath) { $ResolvedPath = (Resolve-Path $AssembledPath).Path } else { # Try to find a near match containing the model name $PathMatches = Get-ChildItem -Path "$($ExternalDrive)\$($Directory)" -Directory -Filter "*$($Model)*" -ErrorAction SilentlyContinue # use the first match if found if ($PathMatches) { $ResolvedPath = $PathMatches[0].FullName } else { # otherwise use the exact model name $ResolvedPath = $AssembledPath } } # if directory already exists, prompt for confirmation to overwrite if (Test-Path $ResolvedPath 2> $null) { if (-not $AutoConfirm) { # Return special value to indicate confirmation needed return @{ Path = $ResolvedPath NeedsConfirmation = $true } } # Auto-confirmed, remove existing try { Remove-Item $ResolvedPath -Recurse -Force -ErrorAction Stop | Out-Null } catch { throw "An error occurred while removing the existing directory: $($_.Exception.Message)" } } try { # attempt to create the directory if it does not exist or if we've removed it if (-not (Test-Path -Path $ResolvedPath)) { New-Item -ItemType Directory -Path $ResolvedPath -Force -ErrorAction Stop | Out-Null } } catch { throw "An error occurred while creating the path: $($_.Exception.Message)" } return @{ Path = $ResolvedPath NeedsConfirmation = $false } } function Invoke-DriverExport { <# .SYNOPSIS Export drivers from the online or offline Windows image .PARAMETER DriverPath Destination path for exported drivers .PARAMETER ProgressCallback Scriptblock to call for progress updates #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$DriverPath, [scriptblock]$ProgressCallback ) try { if ($ProgressCallback) { & $ProgressCallback -Status "Exporting drivers..." -Percent 0 } # Export drivers - check if we're in offline mode or online mode if ($script:IsWindowsPE -and $script:OfflineWindowsPath) { # Offline mode: Export from offline Windows installation Write-DebugLog "Exporting drivers from offline Windows: $($script:OfflineWindowsPath)" Write-DebugLog "Export destination: $DriverPath" # Verify the offline Windows path exists if (-not (Test-Path $script:OfflineWindowsPath)) { throw "Offline Windows path does not exist: $($script:OfflineWindowsPath)" } # IMPORTANT: Export-WindowsDriver -Path expects the DRIVE ROOT, not the Windows folder # Example: If Windows is at D:\Windows, pass "D:\" to -Path $driveLetter = $script:OfflineWindowsDrive if (-not $driveLetter) { # Extract drive from Windows path (e.g., "D:\Windows" -> "D:\") $driveLetter = (Split-Path $script:OfflineWindowsPath -Qualifier) + "\" } Write-DebugLog "Using drive root for DISM: $driveLetter" # Verify the drive root exists if (-not (Test-Path $driveLetter)) { throw "Drive root does not exist: $driveLetter" } # Use DISM to export drivers from offline image Write-DebugLog "Running: Export-WindowsDriver -Path '$driveLetter' -Destination '$DriverPath'" $result = Export-WindowsDriver -Path $driveLetter -Destination $DriverPath -ErrorAction Stop -Verbose 4>&1 Write-DebugLog "Export result: $result" } else { # Online mode: Export from running system Write-DebugLog "Exporting drivers from online system" Export-WindowsDriver -Online -Destination $DriverPath -ErrorAction Stop } if ($ProgressCallback) { & $ProgressCallback -Status "Export complete!" -Percent 100 } return $true } catch { $errorMsg = "Driver export failed: $($_.Exception.Message)" Write-DebugLog "ERROR: $errorMsg" Write-DebugLog "Full error: $_" throw $errorMsg } } #endregion #region Driver Import Functions (from Import-Drivers.ps1) function Get-DriverFiles { <# .SYNOPSIS Search for driver files for a machine's model in a .drivers directory at the root of mounted drives. .INPUTS $Model: the machine's Win32_ComputerSystem.Model identifier, e.g. '21G2002CUS' or 'Dell Pro 16 PC16250' This is the directory that the function will search for in the .drivers directory. .OUTPUTS $DriverFiles: .inf children of located system model driver directory, if found $false, if not found. .DESCRIPTION Searches for .drivers\MachineModel (e.g. .drivers\21G2002CUS) directory at the root of all system drives. Returns objects pertaining to the driver files found in the model's directory, and the discovered .drivers directory itself. #> param ( [Parameter(Mandatory = $true)] [string]$Model ) # Search for a .drivers directory at the root of all filesystem drives $DriverDirectoryRoot = Get-PSDrive -PSProvider FileSystem | Where-Object { Test-Path "$($_.Root).drivers" } | Select-Object -ExpandProperty Root -First 1 if ($DriverDirectoryRoot) { $DriverDirectoryRoot = "$DriverDirectoryRoot.drivers" $Directories = Get-ChildItem -Path $DriverDirectoryRoot -Directory # Try exact match (case-insensitive) $DriverDirectory = $Directories | Where-Object { $_.Name -ieq $Model } | Select-Object -First 1 # If not found, try wildcard match (case-insensitive) if (-not $DriverDirectory) { $DriverDirectory = $Directories | Where-Object { $_.Name -ilike "*$($Model)*" } | Select-Object -First 1 } if ($DriverDirectory) { # Get all .inf files in the driver directory and its subdirectories $DriverFiles = Get-ChildItem -Path $DriverDirectory.FullName -Recurse -Include "*.inf" if ($DriverFiles) { return @{ Files = $DriverFiles Directory = $DriverDirectory Success = $true } } else { return @{ Files = $null Directory = $DriverDirectory Success = $false Error = "No driver files (.inf) found in $($DriverDirectory.FullName)" } } } else { return @{ Files = $null Directory = $null Success = $false Error = "No directory for device model '$($Model)' found at $($DriverDirectoryRoot)" } } } else { return @{ Files = $null Directory = $null Success = $false Error = "No '.drivers' directory found at the root of drives on this system" } } } function Install-Drivers { <# .SYNOPSIS Install an array of .infs with pnputil. .INPUTS Object[] $DriverFiles: Output from Get-DriverFiles - an array of System.IO.FileSystemInfo objects pointing to driver .inf files to be installed. .OUTPUTS Hashtable with SuccessCount and FailureCount #> param ( [Parameter(Mandatory = $true)] [Object[]]$DriverFiles, [scriptblock]$ProgressCallback ) $TotalDrivers = $DriverFiles.Count $CurrentDriver, $SuccessCount, $FailureCount = 0, 0, 0 foreach ($DriverFile in $DriverFiles) { $CurrentDriver++ $Percent = [math]::Round(($CurrentDriver / $TotalDrivers) * 100) if ($ProgressCallback) { & $ProgressCallback -Status "Installing driver $CurrentDriver of $TotalDrivers" -Percent $Percent -CurrentFile $DriverFile.Name } try { # cannot use Add-WindowsDriver here as it does not support online installation $Result = & pnputil.exe /add-driver "$($DriverFile.FullName)" /install 2>&1 if ($Result -match "Failed") { $FailureCount++ } else { $SuccessCount++ } } catch { $FailureCount++ } } return @{ SuccessCount = $SuccessCount FailureCount = $FailureCount TotalDrivers = $TotalDrivers } } #endregion #region GUI Application function Show-DriverManagerGui { <# .SYNOPSIS Main Terminal.Gui application window #> Write-DebugLog "Show-DriverManagerGui started" # Get system information # If in offline mode, get model from offline Windows; otherwise from current system if ($script:IsWindowsPE -and $script:OfflineWindowsPath) { # For offline mode, try to get the actual model from the offline Windows registry Write-DebugLog "Offline mode: Working with $($script:OfflineWindowsPath)" $Model = Get-OfflineWindowsModel -WindowsPath $script:OfflineWindowsPath $Manufacturer = "Offline" if ($Model -ne "Offline Windows") { Write-DebugLog "Retrieved offline model: $Model" } else { Write-DebugLog "Could not retrieve model from offline Windows, using default" } } else { # Wrap in try-catch for PE environment where CIM queries may fail try { $Model = (Get-CimInstance -Class Win32_ComputerSystem -ErrorAction Stop).Model $Manufacturer = (Get-CimInstance -Class Win32_ComputerSystem -ErrorAction Stop).Manufacturer Write-DebugLog "System: $Manufacturer $Model" } catch { Write-DebugLog "Failed to get CIM instance (PE environment?): $_" $Model = "Unknown" $Manufacturer = "Unknown" } } # Create main window $MainWindow = [Terminal.Gui.Window]::new() $MainWindow.Title = "Windows Driver Manager" $MainWindow.X = 0 $MainWindow.Y = 1 $MainWindow.Width = [Terminal.Gui.Dim]::Fill() $MainWindow.Height = [Terminal.Gui.Dim]::Fill() # Create menu bar - check if Top is available first if ([Terminal.Gui.Application]::Top) { $MenuBar = [Terminal.Gui.MenuBar]::new() $MenuBar.Menus = @( [Terminal.Gui.MenuBarItem]::new("_File", @( [Terminal.Gui.MenuItem]::new("_Quit", "", { [Terminal.Gui.Application]::RequestStop() }) )), [Terminal.Gui.MenuBarItem]::new("_Help", @( [Terminal.Gui.MenuItem]::new("_About", "", { $aboutText = "Windows Driver Manager v1.0`n`n" if ($script:IsWindowsPE) { $aboutText += "Mode: Windows PE (Offline)`n" $aboutText += "Target: $($script:OfflineWindowsPath)`n`n" } else { $aboutText += "System: $Manufacturer $Model`n`n" } $aboutText += "Backup and restore Windows drivers`n" + "using an intuitive terminal interface.`n`n" + "Created by Seton" [Terminal.Gui.MessageBox]::Query(50, 12, "About", $aboutText, "OK") }) )) ) [Terminal.Gui.Application]::Top.Add($MenuBar) } else { Write-DebugLog "Warning: Terminal.Gui.Application.Top is null, skipping menu bar" } # Logo/Title with border $lblTitle = [Terminal.Gui.Label]::new() $lblTitle.X = 0 $lblTitle.Y = 1 $lblTitle.Width = [Terminal.Gui.Dim]::Fill() $lblTitle.Height = 5 $lblTitle.TextAlignment = [Terminal.Gui.TextAlignment]::Centered if ($script:IsWindowsPE) { $lblTitle.Text = @" ╔════════════════════════════════════════════════╗ ║ Windows Driver Manager v1.0 - PE Mode ║ ║ Import Disabled - Export Only ║ ╚════════════════════════════════════════════════╝ "@ } else { $lblTitle.Text = @" ╔════════════════════════════════════════════════╗ ║ Windows Driver Manager v1.0 ║ ╚════════════════════════════════════════════════╝ "@ } $MainWindow.Add($lblTitle) # System info $lblSysInfo = [Terminal.Gui.Label]::new() $lblSysInfo.X = 0 $lblSysInfo.Y = [Terminal.Gui.Pos]::Bottom($lblTitle) $lblSysInfo.Width = [Terminal.Gui.Dim]::Fill() $lblSysInfo.Height = 2 $lblSysInfo.TextAlignment = [Terminal.Gui.TextAlignment]::Centered if ($script:IsWindowsPE -and $script:OfflineWindowsPath) { $lblSysInfo.Text = "Model: $Model`nOffline Windows: $($script:OfflineWindowsPath)" } else { $lblSysInfo.Text = "System: $Manufacturer $Model" } $MainWindow.Add($lblSysInfo) # Set current Y position for buttons $currentY = [Terminal.Gui.Pos]::Bottom($lblSysInfo) # Buttons - fixed widths for consistent centering $buttonWidth = 20 $buttonGap = 2 $totalButtonWidth = ($buttonWidth * 2) + $buttonGap # Calculate starting X position (assuming 80 char wide terminal) $terminalWidth = 80 $buttonsStartX = [int][Math]::Floor(($terminalWidth - $totalButtonWidth) / 2) # Export button $btnExport = [Terminal.Gui.Button]::new() $btnExport.Text = "Export Drivers" $btnExport.X = $buttonsStartX $btnExport.Y = $currentY + 2 $btnExport.Width = $buttonWidth $btnExport.Height = 1 $btnExport.add_Clicked({ Show-ExportDialog -Model $Model }) $MainWindow.Add($btnExport) # Import button $btnImport = [Terminal.Gui.Button]::new() $btnImport.Text = "Import Drivers" $btnImport.X = $buttonsStartX + $buttonWidth + $buttonGap $btnImport.Y = $currentY + 2 $btnImport.Width = $buttonWidth $btnImport.Height = 1 # Disable import button in offline/PE mode (just gray out, keep same text) if ($script:IsWindowsPE) { $btnImport.Enabled = $false Write-DebugLog "Import button disabled in PE mode" } else { $btnImport.add_Clicked({ Show-ImportDialog -Model $Model -Manufacturer $Manufacturer }) } $MainWindow.Add($btnImport) # Instructions $lblInstructions = [Terminal.Gui.Label]::new() $lblInstructions.X = 0 $lblInstructions.Y = [Terminal.Gui.Pos]::Bottom($btnExport) + 3 $lblInstructions.Width = [Terminal.Gui.Dim]::Fill() $lblInstructions.Height = 5 $lblInstructions.TextAlignment = [Terminal.Gui.TextAlignment]::Centered if ($script:IsWindowsPE) { $lblInstructions.Text = @" Export: Backup all drivers from offline Windows to external drive Import: Not available in PE mode (offline Windows) Press Ctrl+Q to quit "@ } else { $lblInstructions.Text = @" Export: Backup all drivers from this system to external drive Import: Restore drivers from external drive to this system Press Ctrl+Q to quit "@ } $MainWindow.Add($lblInstructions) # Add main window to application [Terminal.Gui.Application]::Top.Add($MainWindow) # Run the application [Terminal.Gui.Application]::Run() } function Show-ExportDialog { param( [string]$Model, [switch]$ShowInternal ) # Get available volumes (external only by default) $Volumes = Select-ExportTargetDisk -IncludeInternal:$ShowInternal # Check if we have volumes if (-not $Volumes -or $Volumes.Count -eq 0) { if (-not $ShowInternal) { # No external drives found, offer to show internal $result = [Terminal.Gui.MessageBox]::Query("No External Drives Found", "No external/removable drives found.`nWould you like to view internal drives?", @("Yes", "No")) if ($result -eq 0) { Show-ExportDialog -Model $Model -ShowInternal return } } else { [Terminal.Gui.MessageBox]::ErrorQuery("No Volumes Found", "No available volumes found for export.", "OK") } return } # Create dialog $Dialog = [Terminal.Gui.Dialog]::new() $Dialog.Title = "Export Drivers" + $(if ($ShowInternal) { " - All Drives" } else { " - External Drives" }) $Dialog.Width = 80 $Dialog.Height = 22 # Instructions $lblInstr = [Terminal.Gui.Label]::new() $lblInstr.X = 1 $lblInstr.Y = 1 $lblInstr.Width = [Terminal.Gui.Dim]::Fill(2) $lblInstr.Height = 2 $lblInstr.Text = "Select destination drive for driver export:`n[*] = Existing .drivers folder found" $Dialog.Add($lblInstr) # ListView for volumes $lvVolumes = [Terminal.Gui.ListView]::new() $lvVolumes.X = 1 $lvVolumes.Y = [Terminal.Gui.Pos]::Bottom($lblInstr) + 1 $lvVolumes.Width = [Terminal.Gui.Dim]::Fill(2) $lvVolumes.Height = 8 # Format volume list with indicator for existing .drivers folder $volumeList = $Volumes | ForEach-Object { $label = if ($_.FileSystemLabel) { $_.FileSystemLabel } else { "(No Label)" } $sizeMB = [math]::Round($_.Size / 1MB, 0) $hasDrivers = Test-Path "$($_.DriveLetter):\.drivers" $indicator = if ($hasDrivers) { "[*] " } else { " " } "$indicator$($_.DriveLetter): - $label - $($_.FileSystemType) - $sizeMB MB" } $lvVolumes.SetSource($volumeList) $Dialog.Add($lvVolumes) # Store volumes in script scope for button closure $script:ExportVolumes = $Volumes $script:ExportModel = $Model # OK button $btnOK = [Terminal.Gui.Button]::new() $btnOK.Text = "Export" $btnOK.IsDefault = $true $btnOK.add_Clicked({ $selectedIndex = $lvVolumes.SelectedItem if ($selectedIndex -ge 0 -and $selectedIndex -lt $script:ExportVolumes.Count) { $selectedVolume = $script:ExportVolumes[$selectedIndex] $driveLetter = "$($selectedVolume.DriveLetter):" [Terminal.Gui.Application]::RequestStop() Start-DriverExportProcess -DriveLetter $driveLetter -Model $script:ExportModel } }) $Dialog.AddButton($btnOK) # Show All Drives button (if currently showing external only) if (-not $ShowInternal) { $btnShowAll = [Terminal.Gui.Button]::new() $btnShowAll.Text = "Show All Drives" $btnShowAll.add_Clicked({ [Terminal.Gui.Application]::RequestStop() Show-ExportDialog -Model $script:ExportModel -ShowInternal }) $Dialog.AddButton($btnShowAll) } # Cancel button $btnCancel = [Terminal.Gui.Button]::new() $btnCancel.Text = "Cancel" $btnCancel.add_Clicked({ [Terminal.Gui.Application]::RequestStop() }) $Dialog.AddButton($btnCancel) [Terminal.Gui.Application]::Run($Dialog) } function Start-DriverExportProcess { param( [string]$DriveLetter, [string]$Model ) Write-DebugLog "Start-DriverExportProcess called: Drive=$DriveLetter, Model=$Model" # Initialize export path Write-DebugLog "Calling Initialize-ExportPath..." $pathResult = Initialize-ExportPath -ExternalDrive $DriveLetter -Directory ".drivers" -Model $Model Write-DebugLog "Initialize-ExportPath returned: Path=$($pathResult.Path), NeedsConfirmation=$($pathResult.NeedsConfirmation)" if ($pathResult.NeedsConfirmation) { Write-DebugLog "Showing overwrite confirmation dialog" $overwrite = [Terminal.Gui.MessageBox]::Query("Confirm Overwrite", "Directory '$($pathResult.Path)' already exists.`nDo you want to overwrite it?", @("Yes", "No")) if ($overwrite -eq 0) { Write-DebugLog "User confirmed overwrite" # User confirmed, retry with auto-confirm $pathResult = Initialize-ExportPath -ExternalDrive $DriveLetter -Directory ".drivers" -Model $Model -AutoConfirm } else { Write-DebugLog "User cancelled overwrite, aborting export" return } } $exportPath = $pathResult.Path Write-DebugLog "Final export path: $exportPath" # Show progress dialog Write-DebugLog "Creating progress dialog..." $progressDialog = [Terminal.Gui.Dialog]::new() $progressDialog.Title = "Exporting Drivers" $progressDialog.Width = 70 $progressDialog.Height = 12 $lblStatus = [Terminal.Gui.Label]::new() $lblStatus.X = 1 $lblStatus.Y = 1 $lblStatus.Width = [Terminal.Gui.Dim]::Fill(2) $lblStatus.Height = 2 $lblStatus.Text = "Preparing to export drivers..." $progressDialog.Add($lblStatus) $progressBar = [Terminal.Gui.ProgressBar]::new() $progressBar.X = 1 $progressBar.Y = [Terminal.Gui.Pos]::Bottom($lblStatus) + 1 $progressBar.Width = [Terminal.Gui.Dim]::Fill(2) $progressBar.Height = 1 $progressBar.Fraction = [float]0.0 $progressDialog.Add($progressBar) # Close button (disabled initially) $btnClose = [Terminal.Gui.Button]::new() $btnClose.Text = "Close" $btnClose.Enabled = $false $btnClose.add_Clicked({ [Terminal.Gui.Application]::RequestStop() }) $progressDialog.AddButton($btnClose) # Determine if we're in PE mode with offline Windows $isOfflineMode = ($script:IsWindowsPE -and $script:OfflineWindowsPath) $offlineDrive = if ($isOfflineMode) { if ($script:OfflineWindowsDrive) { $script:OfflineWindowsDrive } else { (Split-Path $script:OfflineWindowsPath -Qualifier) + "\" } } else { $null } Write-DebugLog "Export mode: $(if ($isOfflineMode) { 'Offline (PE)' } else { 'Online' })" if ($isOfflineMode) { Write-DebugLog "Offline Windows drive: $offlineDrive" } # Don't enumerate beforehand - do it in the background job to avoid UI freeze Write-DebugLog "Starting ThreadJob for export to: $exportPath (enumeration will happen in background)" # Create progress tracking file for total count $countFile = Join-Path $env:TEMP "driver-export-count-$(Get-Random).json" # Start export in background $exportJob = Start-ThreadJob -ScriptBlock { param($Path, $LogPath, $IsOfflineMode, $OfflineDrive, $CountFile) # Create inner logging function function Write-JobLog { param([string]$Message) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" Add-Content -Path $LogPath -Value "[$timestamp] [JOB] $Message" -Force } Write-JobLog "ThreadJob started, exporting to: $Path" Write-JobLog "Mode: $(if ($IsOfflineMode) { "Offline from $OfflineDrive" } else { 'Online' })" try { # Create destination directory if it doesn't exist if (-not (Test-Path $Path)) { New-Item -ItemType Directory -Path $Path -Force | Out-Null Write-JobLog "Created directory: $Path" } # Enumerate drivers to get total count (this is slow but necessary) Write-JobLog "Enumerating drivers..." if ($IsOfflineMode) { $driverList = Get-WindowsDriver -Path $OfflineDrive -ErrorAction Stop } else { $driverList = Get-WindowsDriver -Online -ErrorAction Stop } $totalDrivers = @($driverList).Count Write-JobLog "Found $totalDrivers drivers to enumerate and export" # Write total count to file so UI can switch to determinate progress @{ Total = $totalDrivers } | ConvertTo-Json | Set-Content -Path $CountFile -Force # Export all drivers using DISM (automatically creates organized folders per driver) Write-JobLog "Starting bulk export with Export-WindowsDriver..." if ($IsOfflineMode) { Write-JobLog "Running: Export-WindowsDriver -Path '$OfflineDrive' -Destination '$Path'" $exportResult = Export-WindowsDriver -Path $OfflineDrive -Destination $Path -ErrorAction Stop } else { Write-JobLog "Running: Export-WindowsDriver -Online -Destination '$Path'" $exportResult = Export-WindowsDriver -Online -Destination $Path -ErrorAction Stop } Write-JobLog "Export-WindowsDriver completed" # Count exported drivers by counting .inf files in subdirectories $exportedDrivers = Get-ChildItem -Path $Path -Filter "*.inf" -Recurse -ErrorAction SilentlyContinue $driverCount = @($exportedDrivers).Count Write-JobLog "Found $driverCount exported driver .inf files" # Clean up count file Remove-Item -Path $CountFile -Force -ErrorAction SilentlyContinue if ($driverCount -gt 0) { Write-JobLog "Export completed successfully: $driverCount driver(s)" return @{ Success = $true Error = $null SuccessCount = $driverCount FailureCount = 0 } } else { Write-JobLog "Export completed but no drivers were found" return @{ Success = $false Error = "No drivers were exported" SuccessCount = 0 FailureCount = 0 } } } catch { Write-JobLog "Export failed: $($_.Exception.Message)" Write-JobLog "Full error: $_" Remove-Item -Path $CountFile -Force -ErrorAction SilentlyContinue return @{ Success = $false Error = $_.Exception.Message SuccessCount = 0 FailureCount = 0 } } } -ArgumentList $exportPath, $script:DebugLogPath, $isOfflineMode, $offlineDrive, $countFile Write-DebugLog "ThreadJob started, ID: $($exportJob.Id), State: $($exportJob.State)" # Store job and UI elements in script scope for timeout access $script:CurrentExportJob = $exportJob $script:CurrentProgressBar = $progressBar $script:CurrentStatusLabel = $lblStatus $script:CurrentCloseButton = $btnClose $script:CurrentExportPath = $exportPath $script:CurrentCountFile = $countFile $script:CurrentExportTotalDrivers = 0 # Will be updated once count file is written # Create a timeout to poll the job (Terminal.Gui's built-in timeout mechanism) Write-DebugLog "Creating timeout for job monitoring..." $pollAction = { if ($script:CurrentExportJob.State -ne 'Running') { Write-DebugLog "Job completed with state: $($script:CurrentExportJob.State)" $result = Receive-Job -Job $script:CurrentExportJob -Wait Write-DebugLog "Job result: Success=$($result.Success), Error=$($result.Error), Exported=$($result.SuccessCount), Failed=$($result.FailureCount)" if ($result.Success) { $script:CurrentProgressBar.Fraction = [float]1.0 $script:CurrentStatusLabel.Text = "Export completed successfully! Exported $($result.SuccessCount) driver(s)." } else { $script:CurrentStatusLabel.Text = "Export failed: $($result.Error)" } $script:CurrentCloseButton.Enabled = $true # Return false to stop polling return $false } else { # Job still running - check if we have the total count yet try { # Try to read total count from file (written by background job after enumeration) if (($script:CurrentExportTotalDrivers -eq 0) -and (Test-Path $script:CurrentCountFile)) { $countData = Get-Content -Path $script:CurrentCountFile -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json if ($countData -and $countData.Total) { $script:CurrentExportTotalDrivers = $countData.Total Write-DebugLog "Got total driver count from background job: $($script:CurrentExportTotalDrivers)" } } # Count how many driver folders have been created $exportedFolders = Get-ChildItem -Path $script:CurrentExportPath -Directory -ErrorAction SilentlyContinue $currentCount = @($exportedFolders).Count if ($script:CurrentExportTotalDrivers -gt 0) { # We know the total - show determinate progress $fraction = [Math]::Min(1.0, [float]$currentCount / [float]$script:CurrentExportTotalDrivers) $script:CurrentProgressBar.Fraction = [float]$fraction $percent = [Math]::Round($fraction * 100, 0) $script:CurrentStatusLabel.Text = "Exporting drivers... ($currentCount / $($script:CurrentExportTotalDrivers)) - $percent%" } else { # Still analyzing - show indeterminate progress $script:CurrentProgressBar.Fraction = [float]0.5 if ($currentCount -gt 0) { $script:CurrentStatusLabel.Text = "Exporting drivers... ($currentCount so far)" } else { $script:CurrentStatusLabel.Text = "Analyzing installed drivers... Please wait..." } } } catch { # If we can't check the folder, show generic message $script:CurrentProgressBar.Fraction = [float]0.5 $script:CurrentStatusLabel.Text = "Exporting drivers... Please wait..." } # Return true to continue polling return $true } } # Add timeout that fires every 500ms $timeout = [Terminal.Gui.Application]::MainLoop.AddTimeout([TimeSpan]::FromMilliseconds(500), $pollAction) Write-DebugLog "Timeout added" # Show dialog Write-DebugLog "Calling Application.Run() for progress dialog..." [Terminal.Gui.Application]::Run($progressDialog) Write-DebugLog "Application.Run() returned" Write-DebugLog "Start-DriverExportProcess completed" } function Show-ImportDialog { param( [string]$Model, [string]$Manufacturer ) # Search for drivers $driverResult = Get-DriverFiles -Model $Model if (-not $driverResult.Success) { [Terminal.Gui.MessageBox]::ErrorQuery("Driver Search Failed", $driverResult.Error, "OK") return } # Show confirmation dialog $driverCount = $driverResult.Files.Count $driverPath = $driverResult.Directory.FullName $confirm = [Terminal.Gui.MessageBox]::Query("Confirm Import", "Found $driverCount driver(s) in:`n$driverPath`n`nProceed with installation?", @("Yes", "No")) if ($confirm -ne 0) { return } # Start import process Start-DriverImportProcess -DriverFiles $driverResult.Files -DriverDirectory $driverResult.Directory } function Start-DriverImportProcess { param( [Object[]]$DriverFiles, [Object]$DriverDirectory ) # Show progress dialog $progressDialog = [Terminal.Gui.Dialog]::new() $progressDialog.Title = "Installing Drivers" $progressDialog.Width = 70 $progressDialog.Height = 14 $lblStatus = [Terminal.Gui.Label]::new() $lblStatus.X = 1 $lblStatus.Y = 1 $lblStatus.Width = [Terminal.Gui.Dim]::Fill(2) $lblStatus.Height = 3 $lblStatus.Text = "Installing drivers..." $progressDialog.Add($lblStatus) $progressBar = [Terminal.Gui.ProgressBar]::new() $progressBar.X = 1 $progressBar.Y = [Terminal.Gui.Pos]::Bottom($lblStatus) + 1 $progressBar.Width = [Terminal.Gui.Dim]::Fill(2) $progressBar.Height = 1 $progressBar.Fraction = [float]0.0 $progressDialog.Add($progressBar) $lblDetails = [Terminal.Gui.Label]::new() $lblDetails.X = 1 $lblDetails.Y = [Terminal.Gui.Pos]::Bottom($progressBar) + 1 $lblDetails.Width = [Terminal.Gui.Dim]::Fill(2) $lblDetails.Height = 2 $lblDetails.Text = "" $progressDialog.Add($lblDetails) # Close button $btnClose = [Terminal.Gui.Button]::new() $btnClose.Text = "Close" $btnClose.Enabled = $false $btnClose.add_Clicked({ [Terminal.Gui.Application]::RequestStop() }) $progressDialog.AddButton($btnClose) # Create temp file for progress tracking $progressFile = Join-Path $env:TEMP "driver-import-progress-$(Get-Random).json" # Start installation in background $importJob = Start-ThreadJob -ScriptBlock { param($Files, $ProgressFile) $TotalDrivers = $Files.Count $CurrentDriver, $SuccessCount, $FailureCount = 0, 0, 0 foreach ($DriverFile in $Files) { $CurrentDriver++ # Write current progress to file $progressData = @{ Current = $CurrentDriver Total = $TotalDrivers Success = $SuccessCount Failed = $FailureCount CurrentFile = $DriverFile.Name } $progressData | ConvertTo-Json | Set-Content -Path $ProgressFile -Force try { $Result = & pnputil.exe /add-driver "$($DriverFile.FullName)" /install 2>&1 if ($Result -match "Failed") { $FailureCount++ } else { $SuccessCount++ } } catch { $FailureCount++ } } # Clean up progress file Remove-Item -Path $ProgressFile -Force -ErrorAction SilentlyContinue return @{ SuccessCount = $SuccessCount FailureCount = $FailureCount TotalDrivers = $TotalDrivers } } -ArgumentList @(,$DriverFiles), $progressFile # Store job and UI elements in script scope for timeout access $script:CurrentImportJob = $importJob $script:CurrentImportProgressBar = $progressBar $script:CurrentImportStatusLabel = $lblStatus $script:CurrentImportDetailsLabel = $lblDetails $script:CurrentImportCloseButton = $btnClose $script:CurrentImportTotalDrivers = $DriverFiles.Count $script:CurrentImportProgressFile = $progressFile # Create a timeout to poll the job (Terminal.Gui's built-in timeout mechanism) $pollAction = { if ($script:CurrentImportJob.State -ne 'Running') { $result = Receive-Job -Job $script:CurrentImportJob -Wait $script:CurrentImportProgressBar.Fraction = [float]1.0 $script:CurrentImportStatusLabel.Text = "Installation complete!" $script:CurrentImportDetailsLabel.Text = "Success: $($result.SuccessCount) | Failed: $($result.FailureCount) | Total: $($result.TotalDrivers)" $script:CurrentImportCloseButton.Enabled = $true # Clean up progress file if it still exists if (Test-Path $script:CurrentImportProgressFile) { Remove-Item -Path $script:CurrentImportProgressFile -Force -ErrorAction SilentlyContinue } # Return false to stop polling return $false } else { # Read progress from file if (Test-Path $script:CurrentImportProgressFile) { try { $progress = Get-Content -Path $script:CurrentImportProgressFile -Raw | ConvertFrom-Json $fraction = [float]$progress.Current / [float]$progress.Total $script:CurrentImportProgressBar.Fraction = $fraction $script:CurrentImportStatusLabel.Text = "Installing drivers... ($($progress.Current) / $($progress.Total))" $script:CurrentImportDetailsLabel.Text = "Success: $($progress.Success) | Failed: $($progress.Failed)`nCurrent: $($progress.CurrentFile)" } catch { # If we can't read progress, fall back to indeterminate $script:CurrentImportProgressBar.Fraction = [float]0.5 $script:CurrentImportStatusLabel.Text = "Installing drivers..." $script:CurrentImportDetailsLabel.Text = "Processing $($script:CurrentImportTotalDrivers) driver(s)..." } } else { $script:CurrentImportProgressBar.Fraction = [float]0.5 $script:CurrentImportStatusLabel.Text = "Installing drivers..." $script:CurrentImportDetailsLabel.Text = "Processing $($script:CurrentImportTotalDrivers) driver(s)..." } # Return true to continue polling return $true } } # Add timeout that fires every 500ms $timeout = [Terminal.Gui.Application]::MainLoop.AddTimeout([TimeSpan]::FromMilliseconds(500), $pollAction) # Show dialog [Terminal.Gui.Application]::Run($progressDialog) } #endregion #region Headless Mode Functions function Invoke-HeadlessExport { <# .SYNOPSIS Perform headless driver export #> param( [string]$TargetDrive, [string]$Directory, [switch]$Force, [switch]$Quiet ) # Get model - handle PE environment where CIM may fail if ($script:IsWindowsPE -and $script:OfflineWindowsPath) { $Model = Get-OfflineWindowsModel -WindowsPath $script:OfflineWindowsPath if ($Model -eq "Offline Windows") { Write-Host "Warning: Could not detect model from offline Windows" -ForegroundColor Yellow } } else { try { $Model = (Get-CimInstance -Class Win32_ComputerSystem -ErrorAction Stop).Model } catch { Write-Host "Warning: Failed to get system model, using 'Unknown'" -ForegroundColor Yellow $Model = "Unknown" } } if (-not $Quiet) { Write-Host "========================================" -ForegroundColor Cyan Write-Host " Driver Export - Headless Mode" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan if ($script:IsWindowsPE) { Write-Host "Mode: Windows PE (Offline)" -ForegroundColor Yellow Write-Host "Target: $($script:OfflineWindowsPath)" -ForegroundColor White } else { Write-Host "System Model: $Model" -ForegroundColor White } Write-Host "" } # Determine target drive if ($TargetDrive -eq "Auto") { if (-not $Quiet) { Write-Host "Auto-detecting removable drive..." -ForegroundColor Yellow } $removableDrive = Get-Volume | Where-Object { $_.DriveType -eq 'Removable' -and $_.DriveLetter } | Select-Object -First 1 if (-not $removableDrive) { Write-Host "ERROR: No removable drive found" -ForegroundColor Red return 1 } $TargetDrive = "$($removableDrive.DriveLetter):" if (-not $Quiet) { Write-Host "Found: $TargetDrive" -ForegroundColor Green } } # Ensure drive letter has colon if ($TargetDrive -notlike "*:") { $TargetDrive += ":" } # Validate target drive exists if (-not (Test-Path $TargetDrive)) { Write-Host "ERROR: Target drive '$TargetDrive' not found" -ForegroundColor Red return 1 } if (-not $Quiet) { Write-Host "Target Drive: $TargetDrive" -ForegroundColor White Write-Host "Export Path: $TargetDrive\$Directory\$Model" -ForegroundColor White Write-Host "" } # Initialize export path try { $pathResult = Initialize-ExportPath -ExternalDrive $TargetDrive -Directory $Directory -Model $Model if ($pathResult.NeedsConfirmation) { if ($Force) { if (-not $Quiet) { Write-Host "Overwriting existing directory (forced)..." -ForegroundColor Yellow } $pathResult = Initialize-ExportPath -ExternalDrive $TargetDrive -Directory $Directory -Model $Model -AutoConfirm } else { Write-Host "Directory already exists: $($pathResult.Path)" -ForegroundColor Yellow $confirm = Read-Host "Overwrite? (Y/N)" if ($confirm -eq 'Y' -or $confirm -eq 'y') { $pathResult = Initialize-ExportPath -ExternalDrive $TargetDrive -Directory $Directory -Model $Model -AutoConfirm } else { Write-Host "Export cancelled by user" -ForegroundColor Yellow return 0 } } } $exportPath = $pathResult.Path if (-not $Quiet) { Write-Host "Exporting drivers from Windows image..." -ForegroundColor Yellow Write-Host "This may take several minutes..." -ForegroundColor Gray } # Create transcript $transcriptPath = "$env:TEMP\driver-export-$Model-$(Get-Date -UFormat %s).log" Start-Transcript -Path $transcriptPath | Out-Null # Perform export - check if we're in offline mode or online mode if ($script:IsWindowsPE -and $script:OfflineWindowsPath) { # Offline mode: Export from offline Windows installation # IMPORTANT: Export-WindowsDriver -Path expects the DRIVE ROOT, not the Windows folder $driveLetter = $script:OfflineWindowsDrive if (-not $driveLetter) { # Extract drive from Windows path (e.g., "D:\Windows" -> "D:\") $driveLetter = (Split-Path $script:OfflineWindowsPath -Qualifier) + "\" } if (-not $Quiet) { Write-Host "Exporting from offline Windows at: $driveLetter" -ForegroundColor Cyan } Export-WindowsDriver -Path $driveLetter -Destination $exportPath -ErrorAction Stop } else { # Online mode: Export from running system Export-WindowsDriver -Online -Destination $exportPath -ErrorAction Stop } Stop-Transcript | Out-Null # Move transcript to export directory Move-Item -Path $transcriptPath -Destination $exportPath -Force if (-not $Quiet) { Write-Host "" Write-Host "Export completed successfully!" -ForegroundColor Green Write-Host "Drivers exported to: $exportPath" -ForegroundColor Green Write-Host "Log file: $exportPath\$(Split-Path $transcriptPath -Leaf)" -ForegroundColor Gray } return 0 } catch { Write-Host "ERROR: Export failed - $($_.Exception.Message)" -ForegroundColor Red return 1 } } function Invoke-HeadlessImport { <# .SYNOPSIS Perform headless driver import #> param( [string]$SourceDrive, [string]$Directory, [switch]$Force, [switch]$Quiet ) # Get system information - handle PE environment where CIM may fail try { $Model = (Get-CimInstance -Class Win32_ComputerSystem -ErrorAction Stop).Model $Manufacturer = (Get-CimInstance -Class Win32_ComputerSystem -ErrorAction Stop).Manufacturer $SerialNumber = (Get-CimInstance -Class Win32_BIOS -ErrorAction Stop).SerialNumber } catch { Write-Host "Warning: Failed to get system information, using defaults" -ForegroundColor Yellow $Model = "Unknown" $Manufacturer = "Unknown" $SerialNumber = "Unknown" } if (-not $Quiet) { Write-Host "========================================" -ForegroundColor Cyan Write-Host " Driver Import - Headless Mode" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan Write-Host "System: $Manufacturer $Model" -ForegroundColor White Write-Host "Serial: $SerialNumber" -ForegroundColor White Write-Host "" } # Search for drivers if (-not $Quiet) { Write-Host "Searching for driver directory..." -ForegroundColor Yellow } $driverResult = Get-DriverFiles -Model $Model if (-not $driverResult.Success) { Write-Host "ERROR: $($driverResult.Error)" -ForegroundColor Red return 1 } $driverCount = $driverResult.Files.Count $driverPath = $driverResult.Directory.FullName if (-not $Quiet) { Write-Host "Found: $driverPath" -ForegroundColor Green Write-Host "Driver count: $driverCount" -ForegroundColor White Write-Host "" } # Confirm if not forced if (-not $Force) { Write-Host "Ready to install $driverCount driver(s)" -ForegroundColor Yellow $confirm = Read-Host "Proceed? (Y/N)" if ($confirm -ne 'Y' -and $confirm -ne 'y') { Write-Host "Import cancelled by user" -ForegroundColor Yellow return 0 } } # Create transcript $transcriptPath = "$env:TEMP\driver-import-$Manufacturer-$Model-$SerialNumber-$(Get-Date -UFormat %s).log" Start-Transcript -Path $transcriptPath | Out-Null if (-not $Quiet) { Write-Host "Installing drivers..." -ForegroundColor Yellow Write-Host "This may take several minutes..." -ForegroundColor Gray Write-Host "" } # Install drivers $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $progressCallback = if (-not $Quiet) { { param($Status, $Percent, $CurrentFile) Write-Host "[$Percent%] $Status" -ForegroundColor Cyan if ($CurrentFile) { Write-Host " Current: $CurrentFile" -ForegroundColor Gray } } } else { $null } $result = Install-Drivers -DriverFiles $driverResult.Files -ProgressCallback $progressCallback $stopwatch.Stop() Stop-Transcript | Out-Null # Move transcript to driver directory Move-Item -Path $transcriptPath -Destination $driverPath -Force if (-not $Quiet) { Write-Host "" Write-Host "========================================" -ForegroundColor Cyan Write-Host " Import Complete" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan Write-Host "Duration: $($stopwatch.Elapsed.TotalSeconds) seconds" -ForegroundColor White Write-Host "Total drivers: $($result.TotalDrivers)" -ForegroundColor White Write-Host "Successful: $($result.SuccessCount)" -ForegroundColor Green Write-Host "Failed: $($result.FailureCount)" -ForegroundColor $(if ($result.FailureCount -gt 0) { 'Red' } else { 'Gray' }) Write-Host "Log file: $driverPath\$(Split-Path $transcriptPath -Leaf)" -ForegroundColor Gray Write-Host "" } if ($result.SuccessCount -gt 0) { return 0 } else { return 1 } } #endregion #region Main Entry Point # Show debug log location Write-Host "Debug log: $script:DebugLogPath" -ForegroundColor Gray Write-Host "" # Detect Windows PE environment $script:IsWindowsPE = Test-WindowsPE if ($script:IsWindowsPE) { Write-Host "Windows PE environment detected" -ForegroundColor Yellow Write-DebugLog "Windows PE environment detected" # Get offline Windows installations $offlineInstallations = Get-OfflineWindowsInstallations if ($offlineInstallations.Count -eq 0) { Write-Host "ERROR: No offline Windows installations found" -ForegroundColor Red Write-Host "Please ensure a Windows installation is available on a connected drive" -ForegroundColor Yellow Write-DebugLog "No offline Windows installations found" exit 1 } Write-Host "Found $($offlineInstallations.Count) offline Windows installation(s)" -ForegroundColor Green Write-DebugLog "Found $($offlineInstallations.Count) offline Windows installation(s)" # For GUI mode, show selection dialog if ($Mode -eq 'GUI') { # Initialize Terminal.Gui first for the selection dialog if (-not (Initialize-TerminalGui)) { exit 1 } # Show selection dialog $selectedInstallation = Show-WindowsInstallationSelector -Installations $offlineInstallations if (-not $selectedInstallation) { Write-Host "No Windows installation selected. Exiting." -ForegroundColor Yellow [Terminal.Gui.Application]::Shutdown() exit 0 } # Store selected installation info $script:OfflineWindowsPath = $selectedInstallation.WindowsPath $script:OfflineWindowsDrive = $selectedInstallation.Drive Write-Host "Selected: $($selectedInstallation.WindowsPath)" -ForegroundColor Green Write-DebugLog "Selected offline Windows: $($script:OfflineWindowsPath)" } else { # For headless modes, auto-select the first (or only) installation $selectedInstallation = $offlineInstallations[0] $script:OfflineWindowsPath = $selectedInstallation.WindowsPath $script:OfflineWindowsDrive = $selectedInstallation.Drive Write-Host "Auto-selected: $($selectedInstallation.WindowsPath)" -ForegroundColor Green Write-DebugLog "Auto-selected offline Windows: $($script:OfflineWindowsPath)" } # Store PE drive letter if (Test-Path "X:\Windows\System32") { $script:PEDriveLetter = "X:" Write-DebugLog "PE drive: X:" } Write-Host "" } # Determine operation mode if ($Mode -eq 'Export') { # Headless export mode if (-not $TargetDrive) { Write-Host "ERROR: -TargetDrive is required for Export mode" -ForegroundColor Red Write-Host "Use -TargetDrive 'Auto' for automatic detection or specify drive letter (e.g., 'D:')" -ForegroundColor Yellow exit 1 } $exitCode = Invoke-HeadlessExport -TargetDrive $TargetDrive -Directory $Directory -Force:$Force -Quiet:$Quiet exit $exitCode } elseif ($Mode -eq 'Import') { # Headless import mode $exitCode = Invoke-HeadlessImport -SourceDrive $SourceDrive -Directory $Directory -Force:$Force -Quiet:$Quiet exit $exitCode } else { # GUI mode (default) # Initialize Terminal.Gui if not already initialized (PE mode initializes it earlier) if (-not $script:IsWindowsPE) { if (-not (Initialize-TerminalGui)) { exit 1 } } try { # Show main GUI Show-DriverManagerGui } finally { # Shutdown Terminal.Gui [Terminal.Gui.Application]::Shutdown() } } #endregion