Production-grade Windows PE builder with PowerShell 7 integration. Features: - PowerShell 7 integrated into WinPE environment - Automatic script launcher for external media - Configurable startup and fallback scripts - Custom console colors - Maximized console window on boot - PowerShell module integration - Windows ADK auto-installation - Defender optimization for faster builds Project Structure: - Build-PE.ps1: Main build orchestrator - config.json: Configuration file - lib/: Build modules (Dependencies, WinPEBuilder, PowerShell7, ModuleManager, DefenderOptimization) - resources/: Runtime scripts (Invoke-ScriptLauncher, Invoke-ScriptMenu, Driver-Manager) - CLAUDE.md: Complete project documentation - README.md: Quick start guide Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2166 lines
78 KiB
PowerShell
2166 lines
78 KiB
PowerShell
#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\<MODEL> 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
|