Files

436 lines
29 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}