Integrate the Android client with native desktop pairing, remote terminal and SSH/SFTP sessions, keyboard controls, file reading and Agent conversations. Preserve authenticated connection ownership and per-device input permissions. Validation: all ten required GitHub checks passed, including five native test platforms and both macOS release workspace checks.
237 lines
11 KiB
PowerShell
237 lines
11 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[ValidatePattern('^[0-9A-Za-z][0-9A-Za-z.-]*$')]
|
|
[string] $Version = 'unreleased',
|
|
|
|
# GPUI 调试构建会从编译机源码目录加载 HLSL,不能作为可分发的便携包。
|
|
[ValidateSet('release')]
|
|
[string] $Configuration = 'release',
|
|
|
|
[ValidateSet('NebulaTerminal', 'Pebrel')]
|
|
[string] $PackageBrand = 'Pebrel',
|
|
|
|
[ValidateSet('x64', 'arm64')]
|
|
[string] $Architecture = 'x64',
|
|
|
|
[switch] $SkipBuild,
|
|
# 与 -SkipBuild 联用:跳过「exe 必须比源码新」的陈旧检查。仅用于脚本
|
|
# 自测;发布产物一律走全新构建。
|
|
[switch] $AllowStale,
|
|
[switch] $Force,
|
|
[string] $OutputDirectory,
|
|
|
|
# Cargo target directory to build into and package from. Defaults to the
|
|
# repo's own `target/`.
|
|
#
|
|
# Windows refuses to overwrite a running exe, so packaging while a Pebrel
|
|
# instance is live out of `target/release/pebrel.exe` dies with a bare
|
|
# "拒绝访问 (os error 5)" that names the linker, not the real cause. Point
|
|
# this at a separate directory to build a package without touching — let
|
|
# alone killing — the instance the user is working in.
|
|
[string] $TargetDirectory
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
Set-StrictMode -Version Latest
|
|
|
|
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
|
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
|
|
$OutputDirectory = Join-Path $repo 'dist'
|
|
} elseif (-not [System.IO.Path]::IsPathRooted($OutputDirectory)) {
|
|
$OutputDirectory = Join-Path $repo $OutputDirectory
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($TargetDirectory)) {
|
|
$TargetDirectory = Join-Path $repo 'target'
|
|
} elseif (-not [System.IO.Path]::IsPathRooted($TargetDirectory)) {
|
|
$TargetDirectory = Join-Path $repo $TargetDirectory
|
|
}
|
|
$cargoTargetRoot = [System.IO.Path]::GetFullPath($TargetDirectory)
|
|
$outputRoot = [System.IO.Path]::GetFullPath($OutputDirectory)
|
|
$targetRoot = Join-Path $cargoTargetRoot $Configuration
|
|
$stage = Join-Path $outputRoot ".stage-$Version-$PID"
|
|
$zipPath = Join-Path $outputRoot "$PackageBrand-v$Version-windows-$Architecture.zip"
|
|
$temporaryZip = Join-Path $outputRoot ".$PackageBrand-v$Version-windows-$Architecture-$PID.tmp.zip"
|
|
|
|
$manifest = [ordered]@{
|
|
'pebrel.exe' = Join-Path $targetRoot 'pebrel.exe'
|
|
'README.md' = Join-Path $repo 'README.md'
|
|
'README.zh-CN.md' = Join-Path $repo 'README.zh-CN.md'
|
|
'runtime/pebrel-hook.exe' = Join-Path $targetRoot 'pebrel-hook.exe'
|
|
'runtime/conpty.dll' = Join-Path $targetRoot 'conpty.dll'
|
|
'runtime/OpenConsole.exe' = Join-Path $targetRoot 'OpenConsole.exe'
|
|
# 1.1.0 起 zip 不再附带 20MB 字体副本:pebrel.exe 内嵌同一份字节,
|
|
# 「安装字体」提示会把它落盘(font_install::ensure_bundled_font_on_disk)。
|
|
# 安装包仍带 ttf——Inno 的 FontInstall 任务需要真实文件。
|
|
'docs/CHANGELOG.md' = Join-Path $repo 'CHANGELOG.md'
|
|
'docs/INSTALL.md' = Join-Path $repo 'INSTALL.md'
|
|
'docs/lua-configuration.md' = Join-Path $repo 'docs\lua-configuration.md'
|
|
'docs/runtime-control-api.md' = Join-Path $repo 'docs\runtime-control-api.md'
|
|
'docs/runtime-api-v1.schema.json' = Join-Path $repo 'docs\runtime-api-v1.schema.json'
|
|
'skills/pebrel-runtime/SKILL.md' = Join-Path $repo 'docs\skills\pebrel-runtime\SKILL.md'
|
|
'skills/pebrel-runtime/agents/openai.yaml' = Join-Path $repo 'docs\skills\pebrel-runtime\agents\openai.yaml'
|
|
'licenses/LICENSE' = Join-Path $repo 'LICENSE'
|
|
'licenses/LICENSE-LUA' = Join-Path $repo 'licenses\LICENSE-LUA'
|
|
'licenses/LICENSE-MLUA' = Join-Path $repo 'licenses\LICENSE-MLUA'
|
|
'licenses/THIRD-PARTY-NOTICES' = Join-Path $repo 'THIRD-PARTY-NOTICES'
|
|
}
|
|
|
|
function Assert-Manifest([string] $Root, [string[]] $Expected) {
|
|
$rootPrefix = [System.IO.Path]::GetFullPath($Root).TrimEnd('\') + '\'
|
|
$actual = @(Get-ChildItem -LiteralPath $Root -Recurse -File | ForEach-Object {
|
|
$fullPath = [System.IO.Path]::GetFullPath($_.FullName)
|
|
if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
throw "Package entry escaped the staging directory: $fullPath"
|
|
}
|
|
$fullPath.Substring($rootPrefix.Length).Replace('\', '/')
|
|
} | Sort-Object)
|
|
$difference = @(Compare-Object -ReferenceObject @($Expected | Sort-Object) -DifferenceObject $actual)
|
|
if ($difference.Count -ne 0) {
|
|
throw "Package manifest differs from the required layout:`n$($difference | Out-String)"
|
|
}
|
|
}
|
|
|
|
function Remove-StageSafely([string] $Path) {
|
|
if (-not (Test-Path -LiteralPath $Path)) {
|
|
return
|
|
}
|
|
$resolved = (Resolve-Path -LiteralPath $Path).Path
|
|
$prefix = $outputRoot.TrimEnd('\') + '\.stage-'
|
|
if (-not $resolved.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
throw "Refusing to remove path outside the package staging boundary: $resolved"
|
|
}
|
|
Remove-Item -LiteralPath $resolved -Recurse -Force
|
|
}
|
|
|
|
function Get-NewestSourceTime([string[]] $Roots) {
|
|
$newest = [datetime]::MinValue
|
|
foreach ($root in $Roots) {
|
|
if (-not (Test-Path -LiteralPath $root)) { continue }
|
|
if (Test-Path -LiteralPath $root -PathType Leaf) {
|
|
$times = @((Get-Item -LiteralPath $root).LastWriteTime)
|
|
} else {
|
|
$times = Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.Extension -in '.rs', '.toml' } |
|
|
ForEach-Object { $_.LastWriteTime }
|
|
}
|
|
foreach ($time in @($times)) {
|
|
if ($time -gt $newest) { $newest = $time }
|
|
}
|
|
}
|
|
$newest
|
|
}
|
|
|
|
function Assert-FreshBinaries {
|
|
# 判据:打包的二进制不得早于其源码的最新改动(2026-08-18 事故:修复
|
|
# 已写进源码,包里却是前一晚的 exe)。cargo 对未变更目标不重链接,
|
|
# 所以不能拿「本次运行开始时刻」当基准。conpty.dll / OpenConsole.exe
|
|
# 是预编译供给件,不参与判定。
|
|
if ($AllowStale) { return }
|
|
$memberDirs = @(
|
|
'nebula_app', 'nebula_terminal', 'nebula_config', 'nebula_config_derive',
|
|
'nebula-completions', 'nebula_gpui', 'nebula_settings', 'nebula_split'
|
|
) | ForEach-Object { Join-Path $repo $_ }
|
|
$appSources = $memberDirs + @(
|
|
(Join-Path $repo 'Cargo.toml'),
|
|
(Join-Path $repo '..\gpui-component-fork\crates')
|
|
)
|
|
$checks = @(
|
|
@{ Binary = $manifest['pebrel.exe']; Newest = Get-NewestSourceTime $appSources },
|
|
@{ Binary = $manifest['runtime/pebrel-hook.exe']; Newest = Get-NewestSourceTime @(
|
|
(Join-Path $repo 'nebula_hook'), (Join-Path $repo 'Cargo.toml')) }
|
|
)
|
|
foreach ($check in $checks) {
|
|
$item = Get-Item -LiteralPath $check.Binary
|
|
if ($item.LastWriteTime -lt $check.Newest) {
|
|
throw "Stale binary: $($check.Binary) ($($item.LastWriteTime)) is older than the newest source change ($($check.Newest)). Rebuild before packaging, or pass -AllowStale if you really mean it."
|
|
}
|
|
}
|
|
}
|
|
|
|
if (-not $SkipBuild) {
|
|
& (Join-Path $PSScriptRoot 'build-windows-product.ps1') -Configuration $Configuration -TargetDirectory $cargoTargetRoot
|
|
}
|
|
|
|
$missing = @($manifest.GetEnumerator() | Where-Object {
|
|
-not (Test-Path -LiteralPath $_.Value -PathType Leaf)
|
|
} | ForEach-Object { "$($_.Key) <- $($_.Value)" })
|
|
if ($missing.Count -ne 0) {
|
|
throw "Required package files are missing:`n$($missing -join "`n")"
|
|
}
|
|
|
|
$packagedExe = $manifest['pebrel.exe']
|
|
. (Join-Path $PSScriptRoot 'windows-package-architecture.ps1')
|
|
Assert-WindowsPackageArchitecture -Root $targetRoot -Architecture $Architecture
|
|
Assert-FreshBinaries
|
|
if ($PackageBrand -eq 'Pebrel' -and (Get-Item -LiteralPath $packagedExe).VersionInfo.ProductName -ne 'Pebrel') {
|
|
throw 'Pebrel packages require a freshly built Pebrel executable, not renamed Nebula binaries.'
|
|
}
|
|
$helpText = & $packagedExe --help 2>&1 | Out-String
|
|
if ($helpText -notmatch '--gpui') {
|
|
throw "pebrel.exe at $packagedExe is the legacy shell (no --gpui in --help). Rebuild with --features gpui-shell; do not package a workspace-default binary."
|
|
}
|
|
if ($Version -ne 'unreleased') {
|
|
$versionText = & $packagedExe --version 2>&1 | Out-String
|
|
if ($versionText -notmatch [regex]::Escape($Version)) {
|
|
throw "pebrel.exe reports `"$($versionText.Trim())`" but the package version is $Version. The staged exe does not match this release."
|
|
}
|
|
}
|
|
|
|
New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
|
|
if (Test-Path -LiteralPath $zipPath) {
|
|
if (-not $Force) {
|
|
throw "Package already exists: $zipPath (pass -Force to replace it)"
|
|
}
|
|
Remove-Item -LiteralPath $zipPath -Force
|
|
}
|
|
if (Test-Path -LiteralPath $temporaryZip) {
|
|
Remove-Item -LiteralPath $temporaryZip -Force
|
|
}
|
|
|
|
try {
|
|
Remove-StageSafely $stage
|
|
New-Item -ItemType Directory -Path $stage | Out-Null
|
|
foreach ($entry in $manifest.GetEnumerator()) {
|
|
$destination = Join-Path $stage $entry.Key.Replace('/', '\')
|
|
$parent = Split-Path -Parent $destination
|
|
New-Item -ItemType Directory -Path $parent -Force | Out-Null
|
|
Copy-Item -LiteralPath $entry.Value -Destination $destination
|
|
}
|
|
|
|
Assert-Manifest $stage @($manifest.Keys)
|
|
Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $temporaryZip -CompressionLevel Optimal
|
|
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
$archive = [System.IO.Compression.ZipFile]::OpenRead($temporaryZip)
|
|
try {
|
|
$zipManifest = @($archive.Entries |
|
|
Where-Object { -not $_.FullName.EndsWith('/') } |
|
|
ForEach-Object { $_.FullName.Replace('\', '/') } |
|
|
Sort-Object)
|
|
$difference = @(
|
|
Compare-Object -ReferenceObject @($manifest.Keys | Sort-Object) -DifferenceObject $zipManifest
|
|
)
|
|
if ($difference.Count -ne 0) {
|
|
throw "ZIP manifest differs from the required layout:`n$($difference | Out-String)"
|
|
}
|
|
$unpackedSize = ($archive.Entries | Measure-Object -Property Length -Sum).Sum
|
|
} finally {
|
|
$archive.Dispose()
|
|
}
|
|
|
|
Move-Item -LiteralPath $temporaryZip -Destination $zipPath
|
|
$zip = Get-Item -LiteralPath $zipPath
|
|
$sha256 = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash
|
|
[PSCustomObject]@{
|
|
Path = $zip.FullName
|
|
Files = $manifest.Count
|
|
Size = $zip.Length
|
|
UnpackedSize = $unpackedSize
|
|
SHA256 = $sha256
|
|
} | Format-List
|
|
} finally {
|
|
Remove-StageSafely $stage
|
|
if (Test-Path -LiteralPath $temporaryZip) {
|
|
Remove-Item -LiteralPath $temporaryZip -Force
|
|
}
|
|
}
|