feat(installer,web): one canonical bundle per target; robust in-place upgrades; update-check feedback (#142)
Co-authored-by: Yaowei Zheng <hiyouga@buaa.edu.cn> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+101
-41
@@ -7,6 +7,15 @@
|
||||
# $env:PENGUIN_INSTALL_DIR = "<dir>" install dir; default $env:USERPROFILE\.penguin
|
||||
# $env:PENGUIN_ARCHIVE = "<file>" install a local Release zip without network access (same as -ArchivePath)
|
||||
#
|
||||
# Each Release attaches exactly one Windows artifact: penguin-win32-x64.zip, a shallow installer
|
||||
# bundle holding install.cmd, this script, the program payload (payload.zip) and the payload's
|
||||
# checksum. Online installs download that bundle and verify it against its published .sha256;
|
||||
# offline installs transfer the same single file, extract it once (the outer layer is flat, so
|
||||
# no deep paths are created) and double-click install.cmd. Both paths verify the payload
|
||||
# checksum sealed inside the bundle, then expand the payload straight into the short staging
|
||||
# directory. Releases up to v0.1.5 shipped the program tree directly (top-level penguin\);
|
||||
# such zips are still accepted, from -Version pins and -ArchivePath files alike.
|
||||
#
|
||||
# There is no -Universal on Windows: where the zip is unsuitable, install Node.js >= 24 and run
|
||||
# `npm install -g @prismshadow/penguin-cli` instead.
|
||||
#
|
||||
@@ -25,6 +34,7 @@ $ProgressPreference = "SilentlyContinue" # Invoke-WebRequest progress rendering
|
||||
|
||||
$Repo = "https://github.com/Prism-Shadow/penguin-harness"
|
||||
$Asset = "penguin-win32-x64.zip"
|
||||
$PayloadName = "payload.zip"
|
||||
|
||||
function Fail([string]$Message) {
|
||||
# `throw` rather than `exit`: the penguin.ooo forwarder runs this installer as an in-memory
|
||||
@@ -33,6 +43,30 @@ function Fail([string]$Message) {
|
||||
throw "error: $Message"
|
||||
}
|
||||
|
||||
# Lists a zip's top-level entry names without extracting (PS 5.1-safe; Expand-Archive itself
|
||||
# uses the same .NET type). Used to tell an installer bundle (contains payload.zip) from a
|
||||
# program archive (payload.zip itself, or a pre-0.1.6 release zip with a top-level penguin\).
|
||||
function Get-ZipEntryNames([string]$ZipPath) {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$Zip = [IO.Compression.ZipFile]::OpenRead($ZipPath)
|
||||
try {
|
||||
return @($Zip.Entries | ForEach-Object { $_.FullName })
|
||||
} finally {
|
||||
$Zip.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Verifies a file against a sha256sum-format checksum file (`<hex> <filename>`; the first
|
||||
# token is the hash). Checksums are never optional: every install path either downloads the
|
||||
# published .sha256 or reads the one sealed inside the bundle.
|
||||
function Assert-Sha256([string]$FilePath, [string]$ShaPath, [string]$Label) {
|
||||
$Expected = ((Get-Content -LiteralPath $ShaPath -Raw).Trim() -split "\s+")[0]
|
||||
if (-not $Expected) { Fail "checksum file is empty or malformed: $ShaPath" }
|
||||
$Actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $FilePath).Hash
|
||||
if ($Actual -ine $Expected) { Fail "checksum mismatch for $([IO.Path]::GetFileName($FilePath))." }
|
||||
Write-Host "$Label checksum OK."
|
||||
}
|
||||
|
||||
function Restore-PreviousInstall(
|
||||
[string]$InstallDir,
|
||||
[string]$OldDir,
|
||||
@@ -63,11 +97,11 @@ if (-not $InstallDir) {
|
||||
if (-not $ArchivePath) {
|
||||
$ArchivePath = if ($env:PENGUIN_ARCHIVE) { $env:PENGUIN_ARCHIVE } else { "" }
|
||||
}
|
||||
# An extracted offline bundle keeps this script, the Windows zip and its checksum together.
|
||||
# `$PSScriptRoot` is empty for the documented `irm ... | iex` path, so online installs do not
|
||||
# accidentally pick up an unrelated archive from the caller's current directory.
|
||||
# An extracted installer bundle keeps install.cmd, this script, payload.zip and its checksum
|
||||
# together. `$PSScriptRoot` is empty for the documented `irm ... | iex` path, so online installs
|
||||
# do not accidentally pick up an unrelated archive from the caller's current directory.
|
||||
if (-not $ArchivePath -and $PSScriptRoot) {
|
||||
$SiblingArchive = Join-Path $PSScriptRoot $Asset
|
||||
$SiblingArchive = Join-Path $PSScriptRoot $PayloadName
|
||||
if (Test-Path -LiteralPath $SiblingArchive -PathType Leaf) { $ArchivePath = $SiblingArchive }
|
||||
}
|
||||
if ($ArchivePath -and $Version) {
|
||||
@@ -114,6 +148,7 @@ try {
|
||||
$ArchiveName = [IO.Path]::GetFileName($ZipPath)
|
||||
Write-Host "Using local archive $ZipPath ..."
|
||||
} else {
|
||||
# Online: download the canonical bundle; the published checksum is mandatory.
|
||||
Write-Host "Downloading $BaseUrl/$Asset ..."
|
||||
$ZipPath = Join-Path $Tmp $Asset
|
||||
try {
|
||||
@@ -122,11 +157,42 @@ try {
|
||||
Fail "download failed. Check the version tag and your network, then retry. ($($_.Exception.Message))"
|
||||
}
|
||||
$ArchiveName = $Asset
|
||||
$ShaPath = Join-Path $Tmp "$Asset.sha256"
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/$Asset.sha256" -OutFile $ShaPath -UseBasicParsing
|
||||
} catch {
|
||||
Fail "checksum download failed. Check the version tag and your network, then retry. ($($_.Exception.Message))"
|
||||
}
|
||||
Assert-Sha256 $ZipPath $ShaPath "Bundle"
|
||||
}
|
||||
|
||||
# --- SHA256 verify: mandatory offline; online keeps the existing warn-and-skip fallback. ---
|
||||
$HaveSha = $true
|
||||
if ($UsingLocalArchive) {
|
||||
# --- Resolve the program payload. An installer bundle (contains payload.zip) is opened flat
|
||||
# and its sealed payload checksum verified; a program archive (payload.zip itself, or a
|
||||
# pre-0.1.6 release zip with a top-level penguin\) is used directly. ---
|
||||
$ArchiveShape = if ((Get-ZipEntryNames $ZipPath) -contains $PayloadName) { "bundle" } else { "program" }
|
||||
if ($ArchiveShape -eq "bundle") {
|
||||
# A local bundle is self-verifying through its sealed payload checksum; when the published
|
||||
# outer .sha256 was transferred alongside it, verify that layer too.
|
||||
if ($UsingLocalArchive -and (Test-Path -LiteralPath "$ZipPath.sha256" -PathType Leaf)) {
|
||||
Assert-Sha256 $ZipPath "$ZipPath.sha256" "Bundle"
|
||||
}
|
||||
$BundleDir = Join-Path $Tmp "bundle"
|
||||
New-Item -ItemType Directory -Path $BundleDir | Out-Null
|
||||
Write-Host "Opening installer bundle ..."
|
||||
# The outer layer is flat (four files), so this extraction never creates deep paths.
|
||||
Expand-Archive -LiteralPath $ZipPath -DestinationPath $BundleDir -Force
|
||||
$ZipPath = Join-Path $BundleDir $PayloadName
|
||||
if (-not (Test-Path -LiteralPath $ZipPath -PathType Leaf)) {
|
||||
Fail "unexpected bundle layout: $PayloadName missing."
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath "$ZipPath.sha256" -PathType Leaf)) {
|
||||
Fail "unexpected bundle layout: $PayloadName.sha256 missing."
|
||||
}
|
||||
Assert-Sha256 $ZipPath "$ZipPath.sha256" "Payload"
|
||||
} elseif ($UsingLocalArchive) {
|
||||
# Program archive from disk: an adjacent checksum is required — its own, or the canonical
|
||||
# asset checksum next to a renamed legacy file. (An online download was already verified
|
||||
# against the published .sha256 above.)
|
||||
$ShaPath = "$ZipPath.sha256"
|
||||
if (-not (Test-Path -LiteralPath $ShaPath -PathType Leaf) -and
|
||||
$ArchiveName -ine $Asset) {
|
||||
@@ -138,24 +204,7 @@ try {
|
||||
if (-not (Test-Path -LiteralPath $ShaPath -PathType Leaf)) {
|
||||
Fail "offline checksum file not found: $ShaPath"
|
||||
}
|
||||
} else {
|
||||
$ShaPath = Join-Path $Tmp "$Asset.sha256"
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/$Asset.sha256" -OutFile $ShaPath -UseBasicParsing
|
||||
} catch {
|
||||
$HaveSha = $false
|
||||
Write-Host "warning: checksum file not available; skipping verification."
|
||||
}
|
||||
}
|
||||
if ($HaveSha) {
|
||||
# The .sha256 file is `<hex> <filename>` (sha256sum format); the first token is the hash.
|
||||
$Expected = ((Get-Content -LiteralPath $ShaPath -Raw).Trim() -split "\s+")[0]
|
||||
$Actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $ZipPath).Hash
|
||||
if ($Expected -and ($Actual -ieq $Expected)) {
|
||||
Write-Host "Checksum OK."
|
||||
} else {
|
||||
Fail "checksum mismatch for $Asset."
|
||||
}
|
||||
Assert-Sha256 $ZipPath $ShaPath "Payload"
|
||||
}
|
||||
|
||||
# --- Extract into staging, then swap by same-volume renames. Keep the previous dirs in .old.$PID
|
||||
@@ -187,7 +236,8 @@ try {
|
||||
if ([string]$TargetProperty.Value -ine "win32-x64") {
|
||||
Fail "package target mismatch: expected win32-x64, found $($TargetProperty.Value)."
|
||||
}
|
||||
} elseif ($UsingLocalArchive -and $ArchiveName -ine $Asset) {
|
||||
} elseif ($UsingLocalArchive -and $ArchiveShape -eq "program" -and
|
||||
$ArchiveName -ine $Asset -and $ArchiveName -ine $PayloadName) {
|
||||
Fail "a renamed local archive must contain package-manifest.json; use the original filename for legacy packages."
|
||||
}
|
||||
|
||||
@@ -211,7 +261,13 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Launcher shims: shipped in the zip; (re)generate only when missing. ---
|
||||
# --- Launcher shim: shipped in the payload; (re)generated only when missing. There is
|
||||
# deliberately no penguin.ps1 launcher — PowerShell would prefer it over penguin.cmd on
|
||||
# PATH, and client Windows defaults to the Restricted execution policy, so a .ps1
|
||||
# launcher makes the plain `penguin` command fail with "running scripts is disabled".
|
||||
# Batch files are policy-exempt and both PowerShell and cmd.exe resolve penguin.cmd.
|
||||
# (bin\ is swapped wholesale above, so upgrading also removes the penguin.ps1 that
|
||||
# pre-0.1.6 payloads shipped.) ---
|
||||
$CmdShim = Join-Path $InstallDir "bin\penguin.cmd"
|
||||
if (-not (Test-Path -LiteralPath $CmdShim)) {
|
||||
@(
|
||||
@@ -228,19 +284,6 @@ try {
|
||||
'exit /b %ERRORLEVEL%'
|
||||
) | Set-Content -LiteralPath $CmdShim -Encoding ascii
|
||||
}
|
||||
$Ps1Shim = Join-Path $InstallDir "bin\penguin.ps1"
|
||||
if (-not (Test-Path -LiteralPath $Ps1Shim)) {
|
||||
@(
|
||||
'$dir = Split-Path -Parent $PSScriptRoot'
|
||||
'if (-not $env:PENGUIN_WEB_DIST) { $env:PENGUIN_WEB_DIST = Join-Path $dir "web" }'
|
||||
'$sh = Join-Path $dir "git\usr\bin\sh.exe"'
|
||||
'if (Test-Path $sh) { $env:PENGUIN_BUNDLED_SHELL = $sh }'
|
||||
'$node = Join-Path $dir "node\node.exe"'
|
||||
'if (-not (Test-Path $node)) { $node = "node" }'
|
||||
'& $node (Join-Path $dir "lib\dist\index.js") @args'
|
||||
'exit $LASTEXITCODE'
|
||||
) | Set-Content -LiteralPath $Ps1Shim -Encoding ascii
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $CmdShim)) { Fail "install incomplete: $CmdShim missing." }
|
||||
|
||||
# Verify from the final path before deleting the backup. Keep stderr visible so platform
|
||||
@@ -310,7 +353,24 @@ if ($env:OS -eq "Windows_NT") {
|
||||
if (-not $OnPath) {
|
||||
$NewPath = if ($RawPath -and -not $RawPath.EndsWith(";")) { "$RawPath;$BinDir" } else { "$RawPath$BinDir" }
|
||||
$EnvKey.SetValue("Path", $NewPath, $Kind)
|
||||
$PathUpdateMessage = "note: installation succeeded and $BinDir was appended to your user Path. Restart your terminal so 'penguin' is found."
|
||||
# A raw registry write does not broadcast WM_SETTINGCHANGE, so Explorer — and every
|
||||
# terminal window launched from it afterwards — would keep the stale Path until the next
|
||||
# logon. Broadcast it the way [Environment]::SetEnvironmentVariable would; best-effort,
|
||||
# a failure only means new terminals need a fresh logon to see the Path.
|
||||
try {
|
||||
if (-not ("PenguinInstaller.NativeMethods" -as [type])) {
|
||||
Add-Type -Namespace PenguinInstaller -Name NativeMethods -MemberDefinition @'
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
|
||||
public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult);
|
||||
'@
|
||||
}
|
||||
$BroadcastResult = [UIntPtr]::Zero
|
||||
# HWND_BROADCAST (0xffff), WM_SETTINGCHANGE (0x1a), SMTO_ABORTIFHUNG (0x2), 5s timeout.
|
||||
[PenguinInstaller.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero,
|
||||
"Environment", 2, 5000, [ref]$BroadcastResult) | Out-Null
|
||||
} catch {
|
||||
}
|
||||
$PathUpdateMessage = "note: installation succeeded and $BinDir was appended to your user Path. Open a new terminal window so 'penguin' is found (a new tab of an already-running terminal keeps the old Path)."
|
||||
}
|
||||
} finally {
|
||||
$EnvKey.Close()
|
||||
|
||||
Reference in New Issue
Block a user