function Read-BuilderText { param([string]$Prompt, [string]$Default, [scriptblock]$Validate) while ($true) { $label = $Prompt if ($Default) { $label += ' [' + $Default + ']' } $value = (Read-Host ($label + ',输入 Q 取消')).Trim() if ($value -ieq 'q') { throw [OperationCanceledException]::new('用户取消了向导。') } if (-not $value) { $value = $Default } if ($value.Length -ge 2 -and (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'")))) { $value = $value.Substring(1,$value.Length-2) } try { Assert-ConfigValue -Value $value -Name $Prompt if ($Validate) { & $Validate $value | Out-Null } return $value } catch { Write-Host ('输入无效:' + $_.Exception.Message) -ForegroundColor Yellow } } } function Get-BuilderDownloadLinks { [PSCustomObject]@{ WindowsIso='https://massgrave.dev/genuine-installation-media' VirtioIso='https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/latest-virtio/virtio-win.iso' Chrome='https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise64.msi' VSCode='https://update.code.visualstudio.com/latest/win32-x64/stable' } } function Show-BuilderPreparationGuide { Write-Host '开始前请准备以下资源:' -ForegroundColor Cyan $resources=@( [PSCustomObject]@{'资源'='Windows 安装 ISO';'示例文件名'='Win11_25H2_Chinese_Simplified_x64.iso';'应存放路径'='data\iso\'} [PSCustomObject]@{'资源'='VirtIO ISO';'示例文件名'='virtio-win.iso';'应存放路径'='data\iso\'} [PSCustomObject]@{'资源'='Chrome Enterprise x64 MSI';'示例文件名'='googlechromestandaloneenterprise64.msi';'应存放路径'='data\custom-resources\'} [PSCustomObject]@{'资源'='VS Code x64 系统安装包';'示例文件名'='VSCodeSetup-x64-1.105.0.exe';'应存放路径'='data\custom-resources\'} ) # 按终端显示宽度补空格,避免 PowerShell 5.1 的 Format-Table 将中英文列排错位。 $displayWidth = { param([string]$Text) return ($Text -replace '[\u1100-\u115F\u2329\u232A\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF01-\uFF60\uFFE0-\uFFE6]', ' ').Length } $columns=@('资源','示例文件名','应存放路径') $widths=@{} foreach ($column in $columns) { $widths[$column]=& $displayWidth $column foreach ($resource in $resources) { $widths[$column]=[Math]::Max($widths[$column], (& $displayWidth $resource.$column)) } } $formatRow = { param([string[]]$Cells) $padded=for ($index=0; $index -lt $columns.Count; $index++) { $cell=$Cells[$index] $cell + (' ' * ($widths[$columns[$index]] - (& $displayWidth $cell))) } return '│ ' + ($padded -join ' │ ') + ' │' } $segments=@($columns | ForEach-Object { '─' * ($widths[$_] + 2) }) Write-Host '' Write-Host ('┌' + ($segments -join '┬') + '┐') Write-Host (& $formatRow -Cells $columns) -ForegroundColor Cyan Write-Host ('├' + ($segments -join '┼') + '┤') for ($index=0; $index -lt $resources.Count; $index++) { $resource=$resources[$index] $cells=@($columns | ForEach-Object { [string]$resource.$_ }) Write-Host (& $formatRow -Cells $cells) if ($index -lt $resources.Count - 1) { Write-Host ('├' + ($segments -join '┼') + '┤') } } Write-Host ('└' + ($segments -join '┴') + '┘') Write-Host '' Write-Host ('Windows ISO 或 WIM 放入 '+(Join-Path $ProjectRoot 'data\iso')+',向导会列出文件供编号选择;也可手动指定路径。') Write-Host 'VirtIO guest-tools 会自动从 ISO 提取。Chrome 和 VS Code 可选。' Write-Host 'Chrome、VS Code 验证有效厂商签名,不限制版本,下载后无需改名;VirtIO 只检查 guest-tools 文件名。' Write-Host '没有 Hyper-V 外部交换机时会询问是否创建。回车使用默认值,输入 Q 取消。' Write-Host '' } function Read-BuilderBoolean { param([string]$Prompt, [bool]$Default=$true) $defaultValue = 'Y' if (-not $Default) { $defaultValue = 'N' } $value = Read-BuilderText -Prompt ($Prompt + ' (Y/N)') -Default $defaultValue -Validate { param($answer) if ($answer -notin @('y','n','yes','no')) { throw '请输入 Y 或 N。' } } return $value -in @('y','yes') } function Read-BuilderNumber { param([string]$Prompt, [int]$Default, [int]$Minimum, [int]$Maximum) $value = Read-BuilderText -Prompt ($Prompt + " ($Minimum-$Maximum)") -Default ([string]$Default) -Validate { param($answer) $parsed = 0 if (-not [int]::TryParse($answer,[ref]$parsed) -or $parsed -lt $Minimum -or $parsed -gt $Maximum) { throw '数值超出范围。' } } return [int]$value } function Read-BuilderChoice { param([string]$Prompt, [object[]]$Items, [scriptblock]$Label, [int]$DefaultIndex=0) if (-not $Items.Count) { throw ('没有可选择的项目:' + $Prompt) } Write-Host $Prompt -ForegroundColor Cyan for ($index=0; $index -lt $Items.Count; $index++) { $description = [string]$Items[$index] if ($Label) { $description = & $Label $Items[$index] } Write-Host (' {0}. {1}' -f ($index+1),$description) } $number = Read-BuilderNumber -Prompt '选择编号' -Default ($DefaultIndex+1) -Minimum 1 -Maximum $Items.Count return $Items[$number-1] } function Read-BuilderProductKey { param([string]$ImageName) $matched=Get-PveKmsProductKey -ImageName $ImageName $defaultIndex=0 $label='当前版本无预置 KMS 密钥(不可选)' if ($matched) { $label=$matched+'(KMS密钥)' } else { $defaultIndex=1 } $items=@([PSCustomObject]@{Mode='Kms';Label=$label},[PSCustomObject]@{Mode='Custom';Label='自行填写/留空'}) while ($true) { $choice=Read-BuilderChoice -Prompt '选择产品密钥' -Items $items -DefaultIndex $defaultIndex -Label {param($item) $item.Label} if ($choice.Mode -eq 'Kms') { if (-not $matched) { Write-Host '此版本没有匹配的 KMS 安装密钥,请选择第二项。' -ForegroundColor Yellow;continue } return [PSCustomObject]@{Mode='Kms';Key=$matched;Description='使用当前版本匹配的 KMS 安装密钥'} } while ($true) { $inputValue=Read-Host '填写产品密钥,直接回车留空,输入 Q 取消(输入隐藏)' -AsSecureString $value='' if ($inputValue -is [Security.SecureString]) { $pointer=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($inputValue) try { $value=[Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer);$inputValue.Dispose() } } else { $value=[string]$inputValue } $value=$value.Trim().ToUpperInvariant() if ($value -eq 'Q') { throw [OperationCanceledException]::new('用户取消了向导。') } if (-not $value) { return [PSCustomObject]@{Mode='None';Key='';Description='留空'} } try { Assert-PveProductKey -ProductKey $value } catch { Write-Host '密钥格式应为 XXXXX-XXXXX-XXXXX-XXXXX-XXXXX,请重新填写。' -ForegroundColor Yellow;continue } return [PSCustomObject]@{Mode='Custom';Key=$value;Description='自行填写(不显示密钥)'} } } } function Read-BuilderKmsServer { $default=Get-PveDefaultKmsServer $choice=Read-BuilderChoice -Prompt '选择 KMS 地址' -Items @($default,'自定义输入/留空') if ($choice -eq $default) { return [PSCustomObject]@{Address=$default;Description=$default} } while ($true) { $value=(Read-Host '填写 KMS 地址(域名或 IP,可加 :端口),直接回车留空,输入 Q 取消').Trim() if ($value -ieq 'q') { throw [OperationCanceledException]::new('用户取消了向导。') } try { $endpoint=ConvertTo-PveKmsEndpoint -Address $value } catch { Write-Host ('输入无效:'+$_.Exception.Message) -ForegroundColor Yellow;continue } $description=$endpoint.Address if (-not $description) { $description='留空(不设置 KMS 地址)' } return [PSCustomObject]@{Address=$endpoint.Address;Description=$description} } } function Assert-BuilderFile { param([string]$Path, [string[]]$Extensions) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw '文件不存在。' } if ($Extensions -and [IO.Path]::GetExtension($Path).ToLowerInvariant() -notin $Extensions) { throw ('支持的文件类型:' + ($Extensions -join ', ')) } } function Get-BuilderSourceCandidates { param([string]$AssetsRoot, [string]$SourcePath) $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($directory in @((Join-Path $ProjectRoot 'data\iso'),(Join-Path $AssetsRoot 'iso'))) { if (-not (Test-Path -LiteralPath $directory -PathType Container)) { continue } foreach ($file in @(Get-ChildItem -LiteralPath $directory -File | Where-Object { $_.Extension.ToLowerInvariant() -in @('.iso','.wim') -and $_.Name -notlike '*virtio*' } | Sort-Object Name)) { if ($seen.Add($file.FullName)) { $label = $file.FullName if ([IO.Path]::GetFullPath($directory) -ieq [IO.Path]::GetFullPath((Join-Path $ProjectRoot 'data\iso'))) { $label = 'data\iso\' + $file.Name } [PSCustomObject]@{Path=$file.FullName;Label=$label} } } } if ($SourcePath) { try { Assert-BuilderFile -Path $SourcePath -Extensions @('.iso','.wim') $fullPath = [IO.Path]::GetFullPath($SourcePath) if ($seen.Add($fullPath)) { [PSCustomObject]@{Path=$fullPath;Label=$fullPath} } } catch {} } } function Read-BuilderInstallationSource { param([string]$AssetsRoot, [string]$SourcePath) while ($true) { $items = @(Get-BuilderSourceCandidates -AssetsRoot $AssetsRoot -SourcePath $SourcePath) $defaultIndex = 0 if ($SourcePath) { $defaultIndex = $items.Count try { $fullPath = [IO.Path]::GetFullPath($SourcePath) for ($index=0; $index -lt $items.Count; $index++) { if ($items[$index].Path -ieq $fullPath) { $defaultIndex=$index; break } } } catch {} } if (-not $items.Count) { Write-Host '未发现 Windows ISO 或 WIM,可手动指定路径,或先将文件放入 data\iso。' -ForegroundColor Yellow } $items += [PSCustomObject]@{Path=$null;Label='手动输入其他 ISO 或 install.wim 路径'} $chosen = Read-BuilderChoice -Prompt '选择 Windows 安装源(优先列出 data\iso)' -Items $items -DefaultIndex $defaultIndex -Label { param($item) $item.Label } if (-not $chosen.Path) { $path = Read-BuilderText -Prompt 'Windows ISO 或 install.wim 完整路径' -Default $SourcePath -Validate { param($candidate) Assert-BuilderFile -Path $candidate -Extensions @('.iso','.wim') } return [IO.Path]::GetFullPath($path) } try { Assert-BuilderFile -Path $chosen.Path -Extensions @('.iso','.wim'); return $chosen.Path } catch { Write-Host ('安装源不可用:' + $_.Exception.Message + ',请重新选择。') -ForegroundColor Yellow; $SourcePath=$null } } } function Get-BuilderSourceImages { param([string]$SourcePath) $source = [IO.Path]::GetFullPath($SourcePath) $mountedHere = $false try { $wim = $source if ([IO.Path]::GetExtension($source) -ieq '.iso') { $diskImage = Get-DiskImage -ImagePath $source if (-not $diskImage.Attached) { Mount-DiskImage -ImagePath $source -StorageType ISO | Out-Null $mountedHere = $true } $volumes = @(Get-DiskImage -ImagePath $source | Get-Volume | Where-Object DriveLetter) if ($volumes.Count -ne 1) { throw '无法确定 ISO 盘符。' } $wim = $volumes[0].DriveLetter + ':\sources\install.wim' if (-not (Test-Path -LiteralPath $wim -PathType Leaf)) { throw 'ISO 中没有 sources\install.wim。install.esd 请先导出为 WIM。' } } $images = @(Get-WimFileImagesInfo -WimFilePath $wim) if (-not $images.Count) { throw '安装源没有可读取的 Windows 版本。' } return $images } finally { if ($mountedHere) { Dismount-DiskImage -ImagePath $source | Out-Null } } } function Get-BuilderAdk { $root = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64' $oscdimg = Join-Path $root 'Oscdimg\oscdimg.exe' if (-not (Test-Path -LiteralPath $oscdimg -PathType Leaf)) { throw '未找到 Windows ADK 的 Deployment Tools(oscdimg.exe)。请运行 ADK 安装程序,勾选 Deployment Tools(部署工具)后重新运行向导。下载:https://learn.microsoft.com/windows-hardware/get-started/adk-install' } return [PSCustomObject]@{OscdimgPath=$oscdimg;OscdimgVersion=(Get-Item -LiteralPath $oscdimg).VersionInfo.FileVersion} } function Get-BuilderHost { param([switch]$AllowMissingExternalSwitch) Write-Host '正在检查构建环境……' -ForegroundColor Cyan try { Assert-PowerShell51 } catch { throw '请使用 Windows PowerShell 5.1(powershell.exe)运行向导。' } try { Assert-Administrator } catch { throw '当前没有管理员权限,请以管理员身份打开 Windows PowerShell 后重新运行。' } if (-not (Get-Module -ListAvailable -Name Hyper-V)) { throw '未找到 Hyper-V 管理模块。请在支持 Hyper-V 的 Windows 上启用 Hyper-V 平台和管理工具,并重启电脑。安装说明:https://learn.microsoft.com/virtualization/hyper-v-on-windows/quick-start/enable-hyper-v' } try { Import-Module Hyper-V -ErrorAction Stop } catch { throw ('无法加载 Hyper-V 管理模块,请检查 Hyper-V 管理工具是否完整安装。'+$_.Exception.Message) } $service = Get-Service -Name vmms -ErrorAction SilentlyContinue if (-not $service -or $service.Status -ne 'Running') { throw 'Hyper-V 虚拟机管理服务(vmms)未运行。请启用 Hyper-V 平台并重启电脑;已安装时请检查该服务。' } $system = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop if (-not $system.HypervisorPresent) { throw 'Hyper-V 虚拟机监控程序未运行。请确认已启用 Hyper-V、BIOS/UEFI 中的硬件虚拟化,并在启用后重启电脑。' } try { Get-VMHost -ErrorAction Stop | Out-Null $switches = @(Get-VMSwitch -ErrorAction Stop | Where-Object SwitchType -eq 'External') } catch { throw ('无法管理 Hyper-V,请检查当前账号的管理员或 Hyper-V 管理员权限,以及 vmms 服务。'+$_.Exception.Message) } $adk = Get-BuilderAdk Write-Host ('环境检查通过:Windows PowerShell 5.1、管理员权限、Hyper-V、ADK Deployment Tools(oscdimg {0})。' -f $adk.OscdimgVersion) -ForegroundColor Green if (-not $switches.Count -and -not $AllowMissingExternalSwitch) { throw '没有可用的 Hyper-V 外部交换机,请运行 Start-ImageBuilder.ps1 按提示创建。' } return [PSCustomObject]@{Switches=$switches;LogicalProcessors=[int]$system.NumberOfLogicalProcessors;MemoryGiB=[int][Math]::Floor($system.TotalPhysicalMemory/1GB);Adk=$adk} } function Initialize-BuilderExternalSwitches { param([object[]]$Switches) if($Switches.Count){return $Switches} Write-Host '未找到 Hyper-V 外部交换机。' -ForegroundColor Yellow if(-not(Read-BuilderBoolean -Prompt '是否创建一个外部交换机' -Default $false)){ throw [OperationCanceledException]::new('未创建外部交换机,向导已停止。') } $adapters=@(Get-NetAdapter -Physical -ErrorAction Stop|Where-Object Status -eq 'Up'|Sort-Object Name) if(-not$adapters.Count){throw '没有已连接的物理网卡,请连接网络后重新运行向导。'} $adapter=Read-BuilderChoice -Prompt '选择外部交换机使用的物理网卡' -Items $adapters -Label {param($item) '{0} — {1}({2})' -f $item.Name,$item.InterfaceDescription,$item.LinkSpeed} $name=Read-BuilderText -Prompt '外部交换机名称' -Default 'external' -Validate { param($candidate) if(Get-VMSwitch -Name $candidate -ErrorAction SilentlyContinue){throw '此名称已被其他交换机使用,请换一个名称。'} } Write-Host ('将在网卡“{0}”上创建“{1}”,并允许主机共享该网卡。创建时主机网络可能短暂中断。' -f $adapter.Name,$name) -ForegroundColor Yellow if(-not(Read-BuilderBoolean -Prompt '确认创建' -Default $false)){ throw [OperationCanceledException]::new('已取消创建外部交换机。') } try{ $created=New-VMSwitch -Name $name -NetAdapterName $adapter.Name -AllowManagementOS $true -ErrorAction Stop if($created.SwitchType -ne 'External'){throw '创建结果不是外部交换机。'} }catch{throw ('创建外部交换机失败:'+$_.Exception.Message+'。请检查所选网卡和 Hyper-V 网络配置。')} Write-Host ('已创建外部交换机:'+$name) -ForegroundColor Green return $created } function Assert-BuilderCapacity { param([int]$CpuCount,[long]$RamBytes,[int]$HostCpuCount,[long]$HostRamBytes) if($CpuCount -lt 1 -or $CpuCount -gt $HostCpuCount -or $RamBytes -lt 2GB -or $RamBytes -gt $HostRamBytes){throw 'Configured CPU or memory exceeds this build host.'} } function Get-BuilderToolStatus { param($Settings) if (-not (Test-Path -LiteralPath $Settings.QemuManifestPath -PathType Leaf)) { return $false } try { $tool = Get-Content -Raw -Encoding UTF8 -LiteralPath $Settings.QemuManifestPath | ConvertFrom-Json $project = Get-BuildDependencies return $tool.Version -eq $project.Qemu.Version -and $tool.PublishedHashVerified -and (Test-Path -LiteralPath $tool.ExecutablePath -PathType Leaf) -and (Get-FileHash -Algorithm SHA256 -LiteralPath $tool.ExecutablePath).Hash -eq $project.Qemu.ExecutableSHA256 } catch { return $false } } function Assert-BuilderOutput { param([string]$Path) $full = [IO.Path]::GetFullPath($Path) if ([IO.Path]::GetExtension($full) -ine '.qcow2') { throw '输出文件必须以 .qcow2 结尾。' } if (Test-Path -LiteralPath $full) { throw '输出已经存在,请选择新文件名。' } if (Test-Path -LiteralPath ([IO.Path]::ChangeExtension($full,'.vhdx'))) { throw '对应的临时 VHDX 已经存在,请选择新文件名。' } if (Test-Path -LiteralPath ([IO.Path]::ChangeExtension($full,'.raw'))) { throw '对应的临时 RAW 已经存在,请选择新文件名。' } } function Assert-BuilderDiskSpace { param([string]$OutputPath, [int]$DiskGiB) $root = [IO.Path]::GetPathRoot([IO.Path]::GetFullPath($OutputPath)) if ($root -notmatch '^[A-Za-z]:\\$') { throw '请使用本机磁盘上的输出路径。' } $drive = Get-PSDrive -Name $root.Substring(0,1) -PSProvider FileSystem $required = ([long]$DiskGiB*2+20)*1GB if ($drive.Free -lt $required) { throw ('空间不足:建议至少 {0} GiB 可用空间(系统盘、转换和启动验收副本)。' -f ($required/1GB)) } } function Initialize-BuilderProject { param([string]$AssetsRoot) & (Join-Path $ProjectRoot 'Initialize-Project.ps1') -AssetsRoot $AssetsRoot | Out-Null } function New-BuilderConfiguration { param([hashtable]$Parameters) & (Join-Path $ProjectRoot 'New-ImageConfig.ps1') @Parameters } function Invoke-BuilderAction { param([string]$Action, [string]$ConfigPath) if ($Action -eq '预检') { & (Join-Path $ProjectRoot 'Build-Image.ps1') -ConfigPath $ConfigPath -WhatIf } elseif ($Action -eq '开始构建') { & (Join-Path $ProjectRoot 'Build-Image.ps1') -ConfigPath $ConfigPath } } function Initialize-BuilderTools { param($Settings) if (Get-BuilderToolStatus -Settings $Settings) { return } Write-Host '尚未准备或未通过校验的固定版本 qemu-img。' -ForegroundColor Yellow if (-not (Read-BuilderBoolean -Prompt '是否立刻下载 QEMU 与 7-Zip?' -Default $true)) { throw '请先运行 Install-QemuImageTool.ps1 准备工具。' } & (Join-Path $ProjectRoot 'Install-QemuImageTool.ps1') | Out-Host } function Initialize-BuilderPackages { param($Settings, [string]$VirtioIsoPath, [switch]$SkipChrome, [switch]$SkipVSCode) foreach ($package in @(Get-SelectedImagePackages -SkipChrome:$SkipChrome -SkipVSCode:$SkipVSCode)) { if($package.File -eq 'virtio-win-guest-tools.exe'){ Initialize-VirtioGuestTools -Settings $Settings -VirtioIsoPath $VirtioIsoPath|Out-Null continue } $destination = Join-Path $Settings.AssetsRoot ('custom-resources\' + $package.File) $source = Find-ImagePackageInstaller -Package $package -Directories @((Join-Path $ProjectRoot 'data\custom-resources'),(Join-Path $Settings.AssetsRoot 'custom-resources')) if ($source) { if ([IO.Path]::GetFullPath($source) -ine [IO.Path]::GetFullPath($destination)) { Copy-Item -LiteralPath $source -Destination $destination -Force } continue } Write-Host ('需要 {0} 安装包,验证厂商签名,无需改名。' -f $package.Name) -ForegroundColor Yellow $links=Get-BuilderDownloadLinks if($package.File -eq 'chrome-enterprise64.msi'){Write-Host ('Chrome Enterprise x64 MSI 下载:'+$links.Chrome)} elseif($package.File -like 'VSCodeSetup-*'){Write-Host ('VS Code x64 系统安装包下载:'+$links.VSCode)} $source = Read-BuilderText -Prompt '安装包完整路径(可选择 data\custom-resources 中的文件)' -Validate { param($candidate) Assert-ImagePackage -Path $candidate -Package $package } if ([IO.Path]::GetFullPath($source) -ine [IO.Path]::GetFullPath($destination)) { Copy-Item -LiteralPath $source -Destination $destination -Force } } } function Invoke-ImageBuilderWizard { param([string]$AssetsRoot, [string]$SourcePath, [switch]$ConfigureOnly) Write-Host 'Windows / Proxmox 镜像构建向导' -ForegroundColor Cyan Write-Host '各步骤可输入 Q 取消;不会覆盖已有成品。请使用管理员 Windows PowerShell 5.1。' $hostInfo = Get-BuilderHost -AllowMissingExternalSwitch $hostInfo.Switches=@(Initialize-BuilderExternalSwitches -Switches $hostInfo.Switches) $defaultAssets = Join-Path $ProjectRoot 'data' if (Test-Path -LiteralPath (Join-Path $ProjectRoot 'local\settings.json')) { $defaultAssets = (Get-ProjectSettings).AssetsRoot } if ($AssetsRoot) { $defaultAssets = $AssetsRoot } $assets = Read-BuilderText -Prompt '资源目录' -Default $defaultAssets -Validate { param($path) [IO.Path]::GetFullPath($path) | Out-Null if (Test-Path -LiteralPath $path -PathType Leaf) { throw '资源目录不能是文件。' } } Initialize-BuilderProject -AssetsRoot $assets $settings = Get-ProjectSettings Import-Module (Join-Path $settings.UpstreamRoot 'WinImageBuilder.psm1') -Force while ($true) { $source = Read-BuilderInstallationSource -AssetsRoot $settings.AssetsRoot -SourcePath $SourcePath try { $images = @(Get-BuilderSourceImages -SourcePath $source); break } catch { Write-Host ('无法读取安装源:' + $_.Exception.Message) -ForegroundColor Yellow; $SourcePath = $null } } $preferred = 0 for ($index=0; $index -lt $images.Count; $index++) { if ($images[$index].ImageName -eq 'Windows 11 Pro for Workstations') { $preferred = $index; break } } $image = Read-BuilderChoice -Prompt '选择 Windows 版本' -Items $images -DefaultIndex $preferred -Label { param($item) '{0}(索引 {1},版本 {2})' -f $item.ImageName,$item.ImageIndex,$item.ImageVersion } $keySelection=Read-BuilderProductKey -ImageName $image.ImageName $kmsSelection=Read-BuilderKmsServer $servicingIso=$source if([IO.Path]::GetExtension($source) -ieq '.wim'){ $defaultIso=Find-WindowsInstallerIso -ProjectRoot $ProjectRoot -AssetsRoot $settings.AssetsRoot $servicingIso=Read-BuilderText -Prompt '提供匹配的 Windows 安装 ISO(用于 Windows PE 离线 servicing)' -Default $defaultIso -Validate {param($path) Assert-BuilderFile -Path $path -Extensions @('.iso')} } $defaultVirtio=Find-VirtioInstallerIso -ProjectRoot $ProjectRoot -AssetsRoot $settings.AssetsRoot if(-not$defaultVirtio){$defaultVirtio=Join-Path $settings.AssetsRoot 'iso\virtio-win.iso'} while($true){ $virtio = Read-BuilderText -Prompt 'VirtIO ISO 路径(支持 data\iso,自动提取 guest-tools)' -Default $defaultVirtio -Validate { param($path) Assert-BuilderFile -Path $path -Extensions @('.iso') } try{ Initialize-VirtioGuestTools -Settings $settings -VirtioIsoPath $virtio -RefreshFromIso|Out-Null Write-Host '已从 ISO 提取 virtio-win-guest-tools.exe,并确认文件名。' -ForegroundColor Green break }catch{Write-Host ('VirtIO 资源准备失败:'+$_.Exception.Message+'。请选择包含 virtio-win-guest-tools.exe 的 ISO。') -ForegroundColor Yellow;$defaultVirtio=$null} } $preferredSwitch = 0 for ($index=0; $index -lt $hostInfo.Switches.Count; $index++) { if ($hostInfo.Switches[$index].Name -eq 'external') { $preferredSwitch = $index; break } } $switch = Read-BuilderChoice -Prompt '选择 Hyper-V 外部交换机' -Items $hostInfo.Switches -DefaultIndex $preferredSwitch -Label { param($item) $item.Name } $defaultOutput = Join-Path $settings.AssetsRoot ('output\' + (Get-DefaultImageFileName -ImageName $image.ImageName)) $output = Read-BuilderText -Prompt '新的 QCOW2 输出路径' -Default $defaultOutput -Validate { param($path) Assert-BuilderOutput -Path $path } $cpu = Read-BuilderNumber -Prompt 'CPU 核数' -Default ([Math]::Min(4,$hostInfo.LogicalProcessors)) -Minimum 1 -Maximum $hostInfo.LogicalProcessors $maxRam = [Math]::Max(2,[Math]::Floor($hostInfo.MemoryGiB*0.75)) $ram = Read-BuilderNumber -Prompt '内存 GiB' -Default ([Math]::Min(8,$maxRam)) -Minimum 2 -Maximum $maxRam $disk = Read-BuilderNumber -Prompt '系统盘 GiB' -Default 128 -Minimum 64 -Maximum 65536 while ($true) { try { Assert-BuilderDiskSpace -OutputPath $output -DiskGiB $disk; break } catch { Write-Host $_.Exception.Message -ForegroundColor Yellow; $output = Read-BuilderText -Prompt '空间足够的新的输出路径' -Validate { param($path) Assert-BuilderOutput -Path $path } } } Write-Host '查看全部 Windows 时区(PowerShell):Get-TimeZone -ListAvailable | Format-Table Id, DisplayName -AutoSize' Write-Host '填写列表中的 Id 列,例如纽约时间:Eastern Standard Time(自动处理夏令时);也可运行 tzutil /l 查看。' $timeZone = Read-BuilderText -Prompt 'Windows 时区 ID' -Default 'Eastern Standard Time' -Validate { param($id) [TimeZoneInfo]::FindSystemTimeZoneById($id) | Out-Null } $updates = Read-BuilderBoolean -Prompt '安装 Windows Update(会增加构建时间)' -Default $false $chrome = Read-BuilderBoolean -Prompt '安装 Chrome' -Default $true $vscode = Read-BuilderBoolean -Prompt '安装 VS Code' -Default $true Initialize-BuilderPackages -Settings $settings -VirtioIsoPath $virtio -SkipChrome:(-not $chrome) -SkipVSCode:(-not $vscode) Write-Host '' Write-Host '配置摘要' -ForegroundColor Cyan [PSCustomObject]@{安装源=$source;Windows版本=$image.ImageName;产品密钥=$keySelection.Description;KMS地址=$kmsSelection.Description;输出=$output;外部交换机=$switch.Name;CPU=$cpu;内存GiB=$ram;系统盘GiB=$disk;时区=$timeZone;WindowsUpdate=$updates;Chrome=$chrome;VSCode=$vscode} | Format-List | Out-Host Write-Host 'UEFI / Secure Boot;VirtIO、QGA、Cloudbase-Init 必装。' Write-Host '内置 Administrator,PVE 注入密码后禁用额外 Admin;临时构建密码自动生成且不显示。' Write-Host 'RDP 开启且保留 NLA;客户机三类防火墙关闭;Defender 设置不改动。' $action = '仅保存配置' if (-not $ConfigureOnly) { $action = Read-BuilderChoice -Prompt '下一步' -Items @('开始构建','预检','仅保存配置') } if ($action -ne '仅保存配置') { Initialize-BuilderTools -Settings $settings } $configurationId=(Get-Date -Format 'yyyyMMdd-HHmmss')+'-'+[guid]::NewGuid().ToString('N').Substring(0,8) $configurationPath=Join-Path $ProjectRoot ('local\configs\image-'+$configurationId+'.ini') $parameters = @{WimPath=$source;ServicingIsoPath=$servicingIso;ImageName=$image.ImageName;OutputPath=$output;VirtioIsoPath=$virtio;SwitchName=$switch.Name;CpuCount=$cpu;RamGiB=$ram;DiskGiB=$disk;TimeZone=$timeZone;InstallUpdates=$updates;SkipChrome=(-not $chrome);SkipVSCode=(-not $vscode);ConfigPath=$configurationPath;ProductKey=$keySelection.Key;ProductKeyMode=$keySelection.Mode;KmsServer=$kmsSelection.Address} $result = New-BuilderConfiguration -Parameters $parameters Write-Host ('已生成配置:' + $result.ConfigPath) -ForegroundColor Green if ($action -ne '仅保存配置') { Invoke-BuilderAction -Action $action -ConfigPath $result.ConfigPath | Out-Host } $result | Add-Member -NotePropertyName Action -NotePropertyValue $action -PassThru }