diff --git a/Optimum.Bootstrap.Core/Prerequisites/PrerequisiteScanner.cs b/Optimum.Bootstrap.Core/Prerequisites/PrerequisiteScanner.cs index aeaeb015..8ccf1199 100644 --- a/Optimum.Bootstrap.Core/Prerequisites/PrerequisiteScanner.cs +++ b/Optimum.Bootstrap.Core/Prerequisites/PrerequisiteScanner.cs @@ -160,7 +160,10 @@ private PrerequisiteResult DetectIlspycmd(PrerequisiteDefinition def) private PrerequisiteResult DetectInnoextract(PrerequisiteDefinition def) { - string? path = CommandSearch.Which(probe, "innoextract"); + string binaryName = probe.Os == OsKind.Windows ? "innoextract.exe" : "innoextract"; + string? path = CommandSearch.Which(probe, "innoextract") + ?? ExecutableOrNull(Path.Combine(repoRoot, ".tools", binaryName)) + ?? ExecutableOrNull(Path.Combine(probe.HomeDirectory, ".tools", binaryName)); if (path is null) return Missing(def, AcquisitionKind.DownloadPage, null, "https://github.com/crazy-max/innoextract/releases"); diff --git a/scripts/_hostcaps.ps1 b/scripts/_hostcaps.ps1 index a08823de..2fbf82cd 100644 --- a/scripts/_hostcaps.ps1 +++ b/scripts/_hostcaps.ps1 @@ -1,4 +1,4 @@ -<# +<# Shared host-capability detection for the packaging scripts. Dot-source it: . "$PSScriptRoot/_hostcaps.ps1" @@ -20,14 +20,23 @@ function Get-HostOS { function Test-Cmd { param([string]$Name) [bool](Get-Command $Name -ErrorAction SilentlyContinue) } -function Get-InnoextractVersion { +function Get-InnoextractPath { + $repoRoot = Split-Path -Parent $PSScriptRoot + $toolsBinary = Join-Path (Join-Path $repoRoot '.tools') ($(if ($IsWindows -or ($env:OS -eq 'Windows_NT')) { 'innoextract.exe' } else { 'innoextract' })) + if (Test-Path $toolsBinary) { return $toolsBinary } $command = Get-Command innoextract -ErrorAction SilentlyContinue - if (-not $command) { return $null } + if ($command) { return $command.Path } + return $null +} + +function Get-InnoextractVersion { + $commandPath = Get-InnoextractPath + if (-not $commandPath) { return $null } $previousEap = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { - $output = @(& $command.Path --version 2>&1) + $output = @(& $commandPath --version 2>&1) $exitCode = $LASTEXITCODE } catch { return $null diff --git a/scripts/bootstrap.ps1 b/scripts/bootstrap.ps1 index 9ab87845..a19365b0 100644 --- a/scripts/bootstrap.ps1 +++ b/scripts/bootstrap.ps1 @@ -526,7 +526,7 @@ try { # a) .vanilla/win-x64/vintagestory already has Vintagestory.exe -> skip # b) -ClientArchive '__skip__' -> the caller (install-windows.ps1) already # placed .vanilla via junction; verify it exists and move on - # c) Normal: download the installer and extract with innounp + # c) Normal: download the installer and extract with innoextract $skipDownload = ($ClientArchive -eq '__skip__') $freshExtract = $false @@ -566,7 +566,7 @@ try { # succeeded, so an interrupted download leaves nothing rather than a short file # at the cache path. Without this, stopping the bootstrap during the ~570 MB # download poisons the cache: the next run finds the file, reports "Using - # cached", and fails in innounp instead. + # cached", and fails in innoextract instead. $partial = "$ClientArchive.partial" Remove-Item -Force -ErrorAction SilentlyContinue $partial @@ -581,66 +581,138 @@ try { Write-Host "Using cached $ClientArchive" } - # Extract using innounp (download if missing). Supports InnoSetup 6.x. + # Extract using innoextract >= 1.11 (download crazy-max fork if missing). + # Supports Inno Setup 6.4.3 through 7.1.0. $toolsDir = Join-Path $repoRoot '.tools' - $innounp = Join-Path $toolsDir 'innounp.exe' - if (-not (Test-Path $innounp)) { - New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null - $innounpZip = Join-Path $toolsDir 'innounp-2.zip' - Write-Host "Downloading innounp" - - # Same reasoning as the client archive above: small enough that the window is - # narrow, and a short zip here fails in Expand-Archive on every later run. - $innounpPartial = "$innounpZip.partial" - Remove-Item -Force -ErrorAction SilentlyContinue $innounpPartial - - Invoke-NativeStep { curl.exe -L --fail --silent -o $innounpPartial "https://github.com/jrathlev/InnoUnpacker-Windows-GUI/releases/download/ui_2_2_9/innounp-2.zip" } - if ($LASTEXITCODE -ne 0) { - Remove-Item -Force -ErrorAction SilentlyContinue $innounpPartial - throw "Download failed: innounp-2.zip" + New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null + $isWin = ($env:OS -eq 'Windows_NT') -or (($null -ne $IsWindows) -and $IsWindows) + $isMac = ($null -ne $IsMacOS) -and $IsMacOS + $exeSuffix = if ($isWin) { '.exe' } else { '' } + $localInno = Join-Path $toolsDir "innoextract$exeSuffix" + + # Check if local .tools/innoextract exists, or if system innoextract is >= 1.11 + $innoextract = $null + if (Test-Path $localInno) { + $innoextract = $localInno + } else { + $sysCmd = Get-Command innoextract -ErrorAction SilentlyContinue + if ($sysCmd) { + $prevEap = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $verOut = @(& $sysCmd.Path --version 2>&1) -join [Environment]::NewLine + if ($verOut -match '(?im)^\s*innoextract\s+(\d+)\.(\d+)') { + $verMajor = [int]$Matches[1] + $verMinor = [int]$Matches[2] + if ($verMajor -gt 1 -or ($verMajor -eq 1 -and $verMinor -ge 11)) { + $innoextract = $sysCmd.Path + } + } + } catch { } finally { + $ErrorActionPreference = $prevEap + } } + } - Move-Item -Force $innounpPartial $innounpZip - Expand-Archive -Path $innounpZip -DestinationPath $toolsDir -Force - $found = Get-ChildItem -Path $toolsDir -Recurse -Filter 'innounp.exe' | Select-Object -First 1 - if ($found -and $found.FullName -ne $innounp) { - Copy-Item -Force $found.FullName $innounp + if (-not $innoextract) { + Write-Host "Downloading innoextract 1.13.0 (crazy-max fork)" + $arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { + 'arm64' + } else { + 'amd64' } - Remove-Item -Force $innounpZip -ErrorAction SilentlyContinue - } - $extractTarget = $winVanillaDir - New-Item -ItemType Directory -Force -Path $extractTarget | Out-Null - Write-Host "Extracting with innounp to $extractTarget" - Get-Process -Name 'innounp' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue - $innounpProc = Start-Process -FilePath $innounp -ArgumentList "-x -d`"$extractTarget`" -c`"{app}`" `"$ClientArchive`"" -NoNewWindow -PassThru - $exited = $innounpProc.WaitForExit(300000) - if (-not $exited) { - $innounpProc.Kill() - Get-Process -Name 'innounp' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue - throw "innounp timed out after 5 minutes. Kill any innounp.exe in Task Manager and retry." - } - $innounpExitCode = $innounpProc.ExitCode - $appDir = Join-Path $extractTarget '{app}' - if (Test-Path $appDir) { - Get-ChildItem -Path $appDir | Move-Item -Destination $extractTarget -Force - Remove-Item -Force $appDir - } - if ($innounpExitCode -ne 0 -and -not (Test-Path (Join-Path $extractTarget 'Vintagestory.exe'))) { - throw "innounp failed (exit $innounpExitCode)." + $assetName = if ($isWin) { + 'innoextract-windows-amd64.exe' + } elseif ($isMac) { + "innoextract-darwin-$arch" + } else { + "innoextract-linux-$arch" + } + + $expectedHashes = @{ + 'innoextract-windows-amd64.exe' = '5700fb1e82e6812bb341b964470537161e07a127d29eecfe176f5198cf215a59' + 'innoextract-linux-amd64' = 'c898db2ecc282ff8d943e9868d03dd6f1fc6069ddd40430341b211f93826b360' + 'innoextract-linux-arm64' = 'e3852c6225f1b6043025fbfb459d6f8c38c5418f6c3a18720f525a0161d37969' + 'innoextract-darwin-amd64' = '55a384d8f077508b04c888a9f68a40c5fb4345565586632124d80b6efec7629d' + 'innoextract-darwin-arm64' = '6b8f11062e4feb153141e85d2f2cd70fc8f1d6fc4354d59e81933308536d3164' + } + + $innoUrl = "https://github.com/crazy-max/innoextract/releases/download/v1.13.0/$assetName" + $innoPartial = "$localInno.partial" + Remove-Item -Force -ErrorAction SilentlyContinue $innoPartial + + if (Get-Command curl.exe -ErrorAction SilentlyContinue) { + Invoke-NativeStep { curl.exe -L --fail --silent -o $innoPartial $innoUrl } + } elseif (Get-Command curl -ErrorAction SilentlyContinue) { + Invoke-NativeStep { curl -L --fail --silent -o $innoPartial $innoUrl } + } else { + Invoke-WebRequest -Uri $innoUrl -OutFile $innoPartial + } + + if (-not (Test-Path $innoPartial) -or (Get-Item $innoPartial).Length -eq 0) { + Remove-Item -Force -ErrorAction SilentlyContinue $innoPartial + throw "Failed to download innoextract from $innoUrl" + } + + $expected = $expectedHashes[$assetName] + if ($expected) { + $actual = (Get-FileHash -Path $innoPartial -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + Remove-Item -Force -ErrorAction SilentlyContinue $innoPartial + throw "SHA-256 verification failed for $assetName: expected $expected, got $actual" + } + } + + Move-Item -Force $innoPartial $localInno + if (-not $isWin) { + Invoke-NativeStep { chmod +x $localInno } + } + $innoextract = $localInno } - if ($innounpExitCode -ne 0) { - Write-Warning "innounp exited with code $innounpExitCode after extracting Vintagestory.exe; continuing." + + # Inspect installer with innoextract + $info = @(Invoke-NativeStep { & $innoextract --info $ClientArchive 2>&1 }) + $infoExitCode = $LASTEXITCODE + if ($infoExitCode -ne 0 -or (($info -join "`n") -notmatch '(?i)setup data version')) { + throw "innoextract could not inspect the installer '$ClientArchive'. The installer uses an unsupported Inno Setup format." } - if (-not (Test-Path (Join-Path $extractTarget 'Vintagestory.exe'))) { - throw "Extraction failed: Vintagestory.exe not found" + + # Extract into a clean staging folder beside the target + $stageParent = Split-Path -Parent $winVanillaDir + New-Item -ItemType Directory -Force -Path $stageParent | Out-Null + $extractRoot = Join-Path $stageParent ".innoextract-stage-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Force -Path $extractRoot | Out-Null + + try { + Write-Host "Extracting with innoextract to $winVanillaDir" + Invoke-NativeStep { & $innoextract --silent --extract --output-dir $extractRoot $ClientArchive } + if ($LASTEXITCODE -ne 0) { + throw "innoextract failed (exit $LASTEXITCODE) for $ClientArchive" + } + + $sourceRoot = Join-Path $extractRoot 'app' + if (-not (Test-Path (Join-Path $sourceRoot 'Vintagestory.exe'))) { + $sourceRoot = $extractRoot + } + if (-not (Test-Path (Join-Path $sourceRoot 'Vintagestory.exe'))) { + throw "Extraction failed: Vintagestory.exe not found in $ClientArchive payload" + } + + New-Item -ItemType Directory -Force -Path $winVanillaDir | Out-Null + Get-ChildItem -Path $sourceRoot -Force | ForEach-Object { + Move-Item -Path $_.FullName -Destination $winVanillaDir -Force + } + } finally { + if (Test-Path $extractRoot) { Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue } } + $freshExtract = $true Write-Host "Extraction complete." } # Validate the vanilla tree before building against it. A tolerated - # partial innounp extraction (nonzero exit above) or a stale .vanilla + # partial innoextract extraction (nonzero exit above) or a stale .vanilla # cache left by an older failed run carries zero-byte or truncated # assets that only surface later, in-game, as opaque GL crashes # ("blur.vsh ... unexpected $end at "). Catch them here instead. @@ -661,7 +733,7 @@ try { # Wipe the poisoned extraction so the next run re-extracts # instead of reusing it via the "Using existing" fast path. Remove-Item -Recurse -Force $winVanillaDir -ErrorAction SilentlyContinue - throw "innounp produced $($corrupt.Count) empty/truncated file(s); the extraction was discarded. Re-run to retry.`n $names" + throw "innoextract produced $($corrupt.Count) empty/truncated file(s); the extraction was discarded. Re-run to retry.`n $names" } throw "Vanilla client files are corrupt ($($corrupt.Count) empty/truncated file(s)):`n $names`nIf $winVanillaDir is Optimum's own cache, delete it and retry; if it points at your Vintage Story install, repair or reinstall Vintage Story $Version first." } diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index c4609a37..0acb504d 100644 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -191,6 +191,48 @@ extract_archive() { python3 -c "import zipfile,sys; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])" "$archive" "$dest" fi ;; + *.exe) + local innoextract_bin="" + if [[ -x "$repo_root/.tools/innoextract" ]]; then + innoextract_bin="$repo_root/.tools/innoextract" + elif command -v innoextract >/dev/null 2>&1; then + local ver + ver="$(innoextract --version 2>/dev/null | sed -n 's/^innoextract \([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1.\2/p' | head -n 1 || true)" + if [[ -n "$ver" ]] && awk -v v="$ver" 'BEGIN { exit (v >= 1.11 ? 0 : 1) }'; then + innoextract_bin="$(command -v innoextract)" + fi + fi + if [[ -z "$innoextract_bin" ]]; then + echo "Downloading innoextract 1.13.0 (crazy-max fork)..." >&2 + mkdir -p "$repo_root/.tools" + local os_type="$(uname -s)" + local machine="$(uname -m)" + local arch="linux-amd64" + if [[ "$os_type" == "Darwin" ]]; then + if [[ "$machine" == "arm64" ]]; then arch="darwin-arm64"; else arch="darwin-amd64"; fi + elif [[ "$machine" == "aarch64" || "$machine" == "arm64" ]]; then + arch="linux-arm64" + fi + local inno_url="https://github.com/crazy-max/innoextract/releases/download/v1.13.0/innoextract-$arch" + curl -sSL -o "$repo_root/.tools/innoextract" "$inno_url" || { + echo "Failed to download innoextract from $inno_url" >&2 + exit 1 + } + chmod +x "$repo_root/.tools/innoextract" + innoextract_bin="$repo_root/.tools/innoextract" + fi + local stage_dir="$dest/.innoextract-stage-$RANDOM" + rm -rf "$stage_dir" + mkdir -p "$stage_dir" + "$innoextract_bin" --silent --extract --output-dir "$stage_dir" "$archive" + local app_dir="$stage_dir/app" + if [[ ! -d "$app_dir" ]]; then + app_dir="$stage_dir" + fi + mkdir -p "$dest/vintagestory" + cp -a "$app_dir"/* "$dest/vintagestory/" + rm -rf "$stage_dir" + ;; *) echo "Unsupported archive: $archive" >&2; exit 1 ;; esac diff --git a/scripts/package.ps1 b/scripts/package.ps1 index 2e2258d6..cd71cc2e 100644 --- a/scripts/package.ps1 +++ b/scripts/package.ps1 @@ -134,7 +134,10 @@ function Resolve-WindowsVanilla { Write-Host "Using cached $InstallerPath" } - $innoextract = (Get-Command innoextract -ErrorAction Stop).Path + $innoextract = Get-InnoextractPath + if (-not $innoextract) { + throw "innoextract not found. Install a current release from https://github.com/crazy-max/innoextract/releases." + } $info = @(Invoke-NativeStep { & $innoextract --info $InstallerPath 2>&1 }) $infoExitCode = $LASTEXITCODE if ($infoExitCode -ne 0 -or (($info -join "`n") -notmatch '(?i)setup data version')) {