param( [uri]$ReleaseUri = 'https://xoderony.github.io/downloads/releases/expriverse-demo-latest.json', [string]$InstallRoot, [switch]$NoLaunch, [switch]$NoShortcut, [switch]$AllowLoopbackHttp ) # Keep functions and state local even when this script is invoked through irm | iex. & { param($ReleaseUri, $InstallRoot, $NoLaunch, $NoShortcut, $AllowLoopbackHttp) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest function Read-Choice { param([string]$Prompt) do { $answer = (Read-Host ($Prompt + ' [Y/N,回车为 N]')).Trim() } while ($answer -notmatch '^(?i:y|yes|n|no|是|否)?$') return $answer -match '^(?i:y|yes|是)$' } function Assert-NoDirectoryLinks { param([string]$Path) $current = [IO.Path]::GetFullPath($Path) while (-not [string]::IsNullOrEmpty($current)) { if (Test-Path -LiteralPath $current) { $item = Get-Item -LiteralPath $current -Force if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw ('安装路径包含文件、链接或目录联接,请选择普通本地文件夹:' + $current) } } $current = [IO.Path]::GetDirectoryName($current.TrimEnd('\')) } } function Get-InstallRoot { param([string]$Requested) $defaultRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'Expriverse' $interactivePath = [string]::IsNullOrWhiteSpace($Requested) if ($interactivePath) { $Requested = Read-Host ('安装到哪个父目录?直接回车使用 ' + $defaultRoot) if ([string]::IsNullOrWhiteSpace($Requested)) { $Requested = $defaultRoot } } $Requested = $Requested.Trim().Trim('"') # Explicit paths (including the updater's environment value) are literal. if ($interactivePath) { $Requested = [Environment]::ExpandEnvironmentVariables($Requested) } if ($Requested.Contains('%')) { throw '安装路径不能包含 % 符号:Windows CMD 可能将其展开为环境变量。请选择其他目录。' } if ($Requested -notmatch '^[A-Za-z]:[\\/]') { throw '请输入完整的本地目录,例如 D:\游戏\Expriverse。' } $full = [IO.Path]::GetFullPath($Requested).TrimEnd('\') if ($full -match '^[A-Za-z]:$') { $full += '\' } Assert-NoDirectoryLinks $full return $full } function Assert-DownloadUri { param([uri]$Uri, [ValidateSet('Manifest', 'Archive')][string]$Kind, [bool]$AllowHttp, [bool]$IsRedirect = $false) if (-not $Uri.IsAbsoluteUri -or -not [string]::IsNullOrEmpty($Uri.UserInfo) -or -not [string]::IsNullOrEmpty($Uri.Fragment)) { throw '下载地址无效。' } if ($AllowHttp -and $Uri.Scheme -ceq 'http' -and $Uri.IsLoopback) { # This opt-in is for isolated local QA only, never an HTTP production fallback. if ($IsRedirect) { throw '本机测试下载不接受重定向。' } return } if ($Uri.Scheme -cne 'https' -or $Uri.Port -ne 443) { throw '下载只允许受信任的 HTTPS 地址。' } if ($Kind -ceq 'Manifest') { if ($Uri.DnsSafeHost -ine 'xoderony.github.io' -or $Uri.AbsolutePath -cnotmatch '^/downloads/releases/expriverse-demo-[a-z0-9-]{1,48}\.json$' -or -not [string]::IsNullOrEmpty($Uri.Query) -or $IsRedirect) { throw '版本信息必须直接来自 xoderony.github.io 的 downloads/releases 目录。' } return } $releasePath = '/Xoderony/Xoderony.github.io/releases/download/demo-v0.1.0/Expriverse-win-x64.zip' if ($Uri.DnsSafeHost -ieq 'github.com' -and $Uri.AbsolutePath -ceq $releasePath -and [string]::IsNullOrEmpty($Uri.Query)) { return } # Only a validated redirect from the pinned GitHub asset can reach this host. if ($IsRedirect -and $Uri.DnsSafeHost -ieq 'release-assets.githubusercontent.com' -and $Uri.AbsolutePath.StartsWith('/github-production-release-asset/', [StringComparison]::Ordinal)) { return } throw '安装包地址或重定向不在允许的 GitHub Release 范围内。' } function Open-Download { param([uri]$Uri, [ValidateSet('Manifest', 'Archive')][string]$Kind, [bool]$AllowHttp = $false) Add-Type -AssemblyName System.Net.Http $handler = [Net.Http.HttpClientHandler]::new() $client = $null $response = $null try { $handler.AllowAutoRedirect = $false $handler.UseCookies = $false $handler.UseDefaultCredentials = $false # Per-client TLS, no certificate bypass or process/machine security changes. if ($null -eq $handler.PSObject.Properties['SslProtocols']) { throw '请更新 Windows 的 .NET Framework 至 4.7.2 或更高版本,或使用 PowerShell 7。' } $handler.SslProtocols = [Security.Authentication.SslProtocols]::Tls12 $client = [Net.Http.HttpClient]::new($handler) $client.Timeout = [TimeSpan]::FromSeconds(30) $client.DefaultRequestHeaders.UserAgent.ParseAdd('Expriverse-Installer/2') $current = $Uri $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) for ($hop = 0; $hop -le 5; $hop++) { Assert-DownloadUri $current $Kind $AllowHttp ($hop -gt 0) if (-not $seen.Add($current.AbsoluteUri)) { throw '下载地址出现循环重定向。' } $response = $client.GetAsync($current, [Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() $status = [int]$response.StatusCode if ($status -eq 200) { $stream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() return [pscustomobject]@{ Response = $response; Client = $client; Stream = $stream; ContentLength = $response.Content.Headers.ContentLength } } if ($status -in @(301, 302, 303, 307, 308) -and $Kind -ceq 'Archive' -and $hop -lt 5) { $location = $response.Headers.Location if ($null -eq $location) { throw '安装包重定向缺少目标地址。' } $next = [uri]::new($current, $location.OriginalString) Assert-DownloadUri $next $Kind $AllowHttp $true $response.Dispose() $response = $null $current = $next continue } throw ('下载失败:HTTP ' + $status + ',或重定向超过允许次数。') } throw '下载重定向超过允许次数。' } catch { if ($null -ne $response) { $response.Dispose() } if ($null -ne $client) { $client.Dispose() } else { $handler.Dispose() } throw } } function Read-ResponseChunk { param([IO.Stream]$Stream, [byte[]]$Buffer) $readTask = $Stream.ReadAsync($Buffer, 0, $Buffer.Length) if (-not $readTask.Wait(120000)) { throw '读取下载内容超时,请重试。' } return $readTask.GetAwaiter().GetResult() } function Close-Download { param($Download) try { $Download.Stream.Dispose(); $Download.Response.Dispose() } finally { $Download.Client.Dispose() } } function Read-Release { param([uri]$Uri, [bool]$AllowHttp) Assert-DownloadUri $Uri 'Manifest' $AllowHttp $download = Open-Download $Uri 'Manifest' $AllowHttp $memory = [IO.MemoryStream]::new() try { $buffer = New-Object byte[] 4096 while (($read = Read-ResponseChunk $download.Stream $buffer) -gt 0) { if ($memory.Length + $read -gt 65536) { throw '版本信息文件过大。' } $memory.Write($buffer, 0, $read) } $text = [Text.UTF8Encoding]::new($false, $true).GetString($memory.ToArray()).TrimStart([char]0xfeff) $release = $text | ConvertFrom-Json } finally { $memory.Dispose(); Close-Download $download } if ($null -eq $release -or $release -is [array]) { throw '版本信息格式无效。' } foreach ($name in @('schemaVersion', 'id', 'title', 'file', 'bytes', 'expandedBytes', 'sha256', 'launcher', 'layout', 'executable')) { if ($null -eq $release.PSObject.Properties[$name]) { throw ('版本信息缺少字段:' + $name) } } if ($release.schemaVersion -ne 2 -or $release.id -isnot [string] -or $release.id -cnotmatch '^expriverse-demo-[a-z0-9-]{1,48}$' -or $release.title -isnot [string] -or [string]::IsNullOrWhiteSpace($release.title) -or $release.title.Length -gt 200 -or $release.title -match '[\x00-\x1f\x7f]' -or $release.sha256 -isnot [string] -or $release.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or [string]$release.bytes -notmatch '^[1-9][0-9]*$' -or [string]$release.expandedBytes -notmatch '^[1-9][0-9]*$' -or $release.file -isnot [string] -or $release.launcher -cne 'Start-Expriverse.cmd' -or $release.layout -cne 'flat' -or $release.executable -cne 'Expriverse.Windows.exe') { throw '版本信息格式无效,未开始安装。' } if ([long]$release.bytes -gt 4GB -or [long]$release.expandedBytes -gt 20GB) { throw '版本信息中的安装包大小超出允许范围。' } $archiveUri = [uri]::new($Uri, $release.file) Assert-DownloadUri $archiveUri 'Archive' $AllowHttp if (($Uri.Scheme -eq 'http' -or $archiveUri.Scheme -eq 'http') -and ($Uri.Scheme -ne 'http' -or $archiveUri.Scheme -ne 'http' -or $archiveUri.Authority -ine $Uri.Authority)) { throw '本机测试安装包必须与测试版本信息同源。' } return @{ Metadata = $release; ArchiveUri = $archiveUri } } function Save-Archive { param([uri]$Uri, [string]$Destination, [long]$ExpectedBytes, [bool]$AllowHttp = $false) $download = Open-Download $Uri 'Archive' $AllowHttp $outputStream = $null try { if ($null -ne $download.ContentLength -and $download.ContentLength -ne $ExpectedBytes) { throw '服务器返回的安装包大小与版本信息不符。' } $outputStream = [IO.File]::Open($Destination, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) $buffer = New-Object byte[] 131072 [long]$received = 0 $lastProgress = [DateTime]::MinValue while (($read = Read-ResponseChunk $download.Stream $buffer) -gt 0) { if ($read -gt ($ExpectedBytes - $received)) { throw '下载内容超过预期大小,已停止。' } $received += $read $outputStream.Write($buffer, 0, $read) if (([DateTime]::UtcNow - $lastProgress).TotalMilliseconds -ge 250) { Write-Progress -Activity '正在下载 Expriverse' -Status ('{0:N1} / {1:N1} MiB' -f ($received / 1MB), ($ExpectedBytes / 1MB)) -PercentComplete ([int](100 * $received / $ExpectedBytes)) $lastProgress = [DateTime]::UtcNow } } if ($received -ne $ExpectedBytes) { throw '安装包下载不完整,请重试。' } } finally { if ($null -ne $outputStream) { $outputStream.Dispose() } Close-Download $download Write-Progress -Activity '正在下载 Expriverse' -Completed } } function Expand-SafeArchive { param([string]$ArchivePath, [string]$Destination, [long]$ExpectedExpandedBytes) Add-Type -AssemblyName System.IO.Compression.FileSystem if ($ExpectedExpandedBytes -le 0 -or (Test-Path -LiteralPath $Destination)) { throw '解压目标必须是新目录,且解压大小必须有效。' } Assert-NoDirectoryLinks ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Destination))) $root = [IO.Path]::GetFullPath($Destination).TrimEnd('\') + '\' $archive = [IO.Compression.ZipFile]::OpenRead($ArchivePath) try { if ($archive.Entries.Count -gt 100000) { throw '安装包文件数量过多。' } $names = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $files = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $directories = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $validated = [Collections.Generic.List[object]]::new() [long]$total = 0 foreach ($entry in $archive.Entries) { $name = $entry.FullName.Replace('/', '\') if ([string]::IsNullOrEmpty($name) -or [IO.Path]::IsPathRooted($name) -or $name.Contains(':')) { throw '安装包包含无效或绝对路径。' } $isDirectory = $name.EndsWith('\') foreach ($part in $name.TrimEnd('\').Split('\')) { if ([string]::IsNullOrEmpty($part) -or $part -in @('.', '..') -or $part.EndsWith('.') -or $part.EndsWith(' ') -or $part.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0 -or $part -match '^(?i:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)') { throw '安装包包含不安全的文件路径。' } } $target = [IO.Path]::GetFullPath([IO.Path]::Combine($root, $name)).TrimEnd('\') if (-not $target.StartsWith($root, [StringComparison]::OrdinalIgnoreCase) -or -not $names.Add($target)) { throw '安装包包含越界或重复路径。' } $unixType = ($entry.ExternalAttributes -shr 16) -band 0xf000 if (($entry.ExternalAttributes -band 0x400) -ne 0 -or ($isDirectory -and $unixType -notin @(0, 0x4000)) -or (-not $isDirectory -and $unixType -notin @(0, 0x8000))) { throw '安装包不得包含链接、目录联接或特殊文件。' } if ($isDirectory -and $entry.Length -ne 0) { throw '安装包目录条目不能包含数据。' } if ($isDirectory) { if ($files.Contains($target)) { throw '安装包包含文件与目录冲突。' } [void]$directories.Add($target) } else { if ($directories.Contains($target)) { throw '安装包包含文件与目录冲突。' } [void]$files.Add($target) } $ancestor = [IO.Path]::GetDirectoryName($target) while ($ancestor.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) { if ($files.Contains($ancestor)) { throw '安装包包含文件与子目录冲突。' } [void]$directories.Add($ancestor) $ancestor = [IO.Path]::GetDirectoryName($ancestor) } if ($entry.Length -gt ($ExpectedExpandedBytes - $total)) { throw '解压后大小超过版本信息,已停止。' } $total += $entry.Length $validated.Add([pscustomobject]@{ Entry = $entry; Target = $target; IsDirectory = $isDirectory }) } if ($total -ne $ExpectedExpandedBytes) { throw '解压后大小与版本信息不符。' } # Preflight every path before creating files, then bound the actual decoded bytes too. [IO.Directory]::CreateDirectory($Destination) | Out-Null $buffer = New-Object byte[] 131072 [long]$actualTotal = 0 foreach ($item in $validated) { if ($item.IsDirectory) { [IO.Directory]::CreateDirectory($item.Target) | Out-Null; continue } [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($item.Target)) | Out-Null $inputStream = $item.Entry.Open() $outputStream = $null try { $outputStream = [IO.File]::Open($item.Target, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) [long]$actualEntry = 0 while (($read = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { if ($read -gt ($item.Entry.Length - $actualEntry) -or $read -gt ($ExpectedExpandedBytes - $actualTotal)) { throw '安装包实际解压数据超过声明大小,已停止。' } $actualEntry += $read $actualTotal += $read $outputStream.Write($buffer, 0, $read) } if ($actualEntry -ne $item.Entry.Length) { throw '安装包文件解压不完整。' } } finally { if ($null -ne $outputStream) { $outputStream.Dispose() } $inputStream.Dispose() } } if ($actualTotal -ne $ExpectedExpandedBytes) { throw '安装包实际解压大小不符。' } } finally { $archive.Dispose() } } function Remove-OwnedWorkDirectory { param([string]$Path, [string]$Parent, [string]$Token) $expected = [IO.Path]::GetFullPath((Join-Path $Parent ('.expriverse-install-' + $Token))) if ($Token -notmatch '^[a-f0-9]{32}$' -or -not [string]::Equals([IO.Path]::GetFullPath($Path), $expected, [StringComparison]::OrdinalIgnoreCase)) { throw '临时目录所有权不匹配,拒绝清理。' } if (-not (Test-Path -LiteralPath $Path)) { return } Assert-NoDirectoryLinks $Path $marker = Join-Path $Path '.expriverse-owner' if (-not (Test-Path -LiteralPath $marker -PathType Leaf) -or ((Get-Item -LiteralPath $marker -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or [IO.File]::ReadAllText($marker) -cne $Token) { throw '临时目录所有权标记不匹配,拒绝清理。' } $pending = [Collections.Generic.Stack[string]]::new() $pending.Push($Path) while ($pending.Count -gt 0) { foreach ($item in Get-ChildItem -LiteralPath $pending.Pop() -Force) { if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw '临时目录包含链接,拒绝清理。' } if ($item.PSIsContainer) { $pending.Push($item.FullName) } } } Remove-Item -LiteralPath $expected -Recurse -Force } function Get-GameWrappers { # These audited ASCII wrappers are generated locally; no bundled script is executed. $launcher = @' @echo off setlocal DisableDelayedExpansion cd /d "%~dp0" if not exist "%~dp0Expriverse.Windows.exe" ( echo Expriverse.Windows.exe is missing. Run the installer again. pause exit /b 1 ) start "" /d "%~dp0" "%~dp0Expriverse.Windows.exe" if errorlevel 1 ( echo Could not start Expriverse. Check that Steam is running. pause exit /b 1 ) exit /b 0 '@ $updater = @' @echo off setlocal DisableDelayedExpansion set "EXPRIVERSE_INSTALL_ROOT=%~dp0.." "%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -Command "$ErrorActionPreference='Stop'; try { Add-Type -AssemblyName System.Net.Http; $h=[Net.Http.HttpClientHandler]::new(); $h.AllowAutoRedirect=$false; $h.UseCookies=$false; $h.UseDefaultCredentials=$false; $h.SslProtocols=[Security.Authentication.SslProtocols]::Tls12; $c=[Net.Http.HttpClient]::new($h); $c.Timeout=[TimeSpan]::FromSeconds(120); try { $r=$c.GetAsync('https://xoderony.github.io/downloads/install-expriverse.txt',[Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult(); try { if ([int]$r.StatusCode -ne 200) { throw 'Bootstrap download failed or redirected.' }; $s=$r.Content.ReadAsStreamAsync().GetAwaiter().GetResult(); $m=[IO.MemoryStream]::new(); try { $b=New-Object byte[] 4096; while ($true) { $t=$s.ReadAsync($b,0,$b.Length); if (-not $t.Wait(120000)) { throw 'Download timed out.' }; $n=$t.GetAwaiter().GetResult(); if ($n -eq 0) { break }; if ($m.Length+$n -gt 262144) { throw 'Bootstrap is too large.' }; $m.Write($b,0,$n) }; $src=[Text.UTF8Encoding]::new($false,$true).GetString($m.ToArray()).TrimStart([char]0xfeff) } finally { $m.Dispose(); $s.Dispose() } } finally { $r.Dispose() } } finally { $c.Dispose() }; & ([scriptblock]::Create($src)) -InstallRoot $env:EXPRIVERSE_INSTALL_ROOT; if (-not $?) { exit 1 } } catch { Write-Host $_.Exception.Message -ForegroundColor Red; exit 1 }" if errorlevel 1 ( echo Update did not complete. See the message above. pause exit /b 1 ) pause exit /b 0 '@ return @{ 'Start-Expriverse.cmd' = ($launcher.Replace("`r`n", "`n").Replace("`n", "`r`n") + "`r`n") 'Update-Expriverse.cmd' = ($updater.Replace("`r`n", "`n").Replace("`n", "`r`n") + "`r`n") } } function New-GameWrappers { param([string]$Directory) $wrappers = Get-GameWrappers foreach ($name in $wrappers.Keys) { $target = Join-Path $Directory $name if (Test-Path -LiteralPath $target) { throw ('安装包不应包含由安装器生成的文件:' + $name) } [IO.File]::WriteAllText($target, $wrappers[$name], [Text.Encoding]::ASCII) } } function Assert-InstalledFiles { param([string]$Directory) if (-not (Test-Path -LiteralPath (Join-Path $Directory 'Expriverse.Windows.exe') -PathType Leaf)) { throw '已有游戏缺少主程序,请检查安装目录。' } $wrappers = Get-GameWrappers foreach ($name in $wrappers.Keys) { $path = Join-Path $Directory $name if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or [IO.File]::ReadAllText($path) -cne $wrappers[$name]) { throw ('已有游戏的启动或更新入口缺失或已修改,未执行:' + $name) } } } function Test-GamePrerequisites { $systemDirectory = [Environment]::GetFolderPath('System') foreach ($library in @('VCRUNTIME140.dll', 'MSVCP140.dll', 'VCRUNTIME140_1.dll')) { if (-not (Test-Path -LiteralPath (Join-Path $systemDirectory $library) -PathType Leaf)) { Write-Host '游戏已安装,但还缺少 Microsoft Visual C++ x64 运行库。' -ForegroundColor Yellow Write-Host '请先下载并安装微软运行库,然后使用游戏启动器;本次跳过自动启动。' Write-Host 'https://aka.ms/vc14/vc_redist.x64.exe' return $false } } return $true } function New-GameShortcut { param([string]$Directory, [string]$Version, [string]$DesktopDirectory) $shortcutPath = Join-Path $DesktopDirectory 'Expriverse.lnk' if (Test-Path -LiteralPath $shortcutPath) { Write-Host ('快捷方式已存在,未覆盖:' + $shortcutPath); return } $shell = New-Object -ComObject WScript.Shell try { $shortcut = $shell.CreateShortcut($shortcutPath) try { $shortcut.TargetPath = Join-Path $Directory 'Start-Expriverse.cmd' $shortcut.WorkingDirectory = $Directory $shortcut.Description = 'Expriverse 多人 Demo' $shortcut.Save() } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($shortcut) } } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($shell) } Write-Host '已创建桌面快捷方式。' } function Show-InstalledGame { param([string]$Directory, [string]$Version, [bool]$SkipLaunch, [bool]$SkipShortcut) $launcher = Join-Path $Directory 'Start-Expriverse.cmd' if (-not (Test-Path -LiteralPath $launcher -PathType Leaf)) { throw '已有目录缺少 Start-Expriverse.cmd,请换一个安装父目录。' } Write-Host '' Write-Host ('游戏目录:' + $Directory) -ForegroundColor Green Write-Host '文件会保留;以后双击 Start-Expriverse.cmd 即可运行,不需要保持安装终端打开。' $prerequisitesReady = Test-GamePrerequisites if (-not $SkipShortcut -and (Read-Choice '创建桌面快捷方式?')) { try { New-GameShortcut $Directory $Version ([Environment]::GetFolderPath('DesktopDirectory')) } catch { Write-Warning ('游戏已保留,但快捷方式未创建:' + $_.Exception.Message) } } if ($prerequisitesReady -and -not $SkipLaunch -and (Read-Choice '请先打开 Steam 并登录。现在启动游戏?')) { try { Start-Process -FilePath $launcher -WorkingDirectory $Directory | Out-Null } catch { Write-Warning ('自动启动失败,请在游戏目录双击启动器:' + $_.Exception.Message) } } } function Assert-SafeTree { param([string]$Path) Assert-NoDirectoryLinks $Path $pending = [Collections.Generic.Stack[string]]::new() $pending.Push($Path) while ($pending.Count -gt 0) { foreach ($item in Get-ChildItem -LiteralPath $pending.Pop() -Force) { if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw '游戏目录包含链接,不能更新。' } if ($item.PSIsContainer) { $pending.Push($item.FullName) } } } } function Read-InstalledRelease { param([string]$Directory) if (-not (Test-Path -LiteralPath $Directory)) { return $null } Assert-SafeTree $Directory $marker = Join-Path $Directory '.expriverse-release.json' if (-not (Test-Path -LiteralPath $marker -PathType Leaf)) { throw '目标目录不是新版安装器管理的游戏,拒绝替换。请选择空的安装位置。' } if ((Get-Item -LiteralPath $marker -Force).Length -gt 65536) { throw '已有安装的所有权标记过大。' } $installed = [IO.File]::ReadAllText($marker) | ConvertFrom-Json if ($null -eq $installed -or $installed -is [array]) { throw '已有安装的所有权标记无效。' } foreach ($name in @('installFormat', 'id', 'sha256', 'bytes', 'expandedBytes', 'launcher')) { if ($null -eq $installed.PSObject.Properties[$name]) { throw '目标目录不是新版安装器管理的游戏,拒绝替换。' } } if ($installed.installFormat -ne 2 -or $installed.id -isnot [string] -or $installed.id -cnotmatch '^expriverse-demo-[a-z0-9-]{1,48}$' -or $installed.sha256 -isnot [string] -or $installed.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or [string]$installed.bytes -notmatch '^[1-9][0-9]*$' -or [string]$installed.expandedBytes -notmatch '^[1-9][0-9]*$' -or $installed.launcher -cne 'Start-Expriverse.cmd') { throw '目标目录不是新版安装器管理的游戏,拒绝替换。' } foreach ($name in @('local', 'roaming', 'Logs', 'cache')) { $path = Join-Path $Directory $name if ((Test-Path -LiteralPath $path) -and -not (Test-Path -LiteralPath $path -PathType Container)) { throw ('已有状态路径不是普通目录,拒绝替换:' + $name) } } return $installed } function Assert-GameStopped { param([string]$Directory) $executable = Join-Path $Directory 'Expriverse.Windows.exe' foreach ($process in Get-Process -Name 'Expriverse.Windows' -ErrorAction SilentlyContinue) { if ([string]::IsNullOrEmpty($process.Path)) { throw '无法核实正在运行的游戏位置,请退出游戏后重试。' } if ([string]::Equals($process.Path, $executable, [StringComparison]::OrdinalIgnoreCase)) { throw '请先退出此目录中的游戏,再运行更新。' } } if (Test-Path -LiteralPath $executable -PathType Leaf) { try { $handle = [IO.File]::Open($executable, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) $handle.Dispose() } catch { throw '游戏文件正在使用,请退出游戏后重试。' } } } function Move-GameDirectory { param([string]$Source, [string]$Destination) [IO.Directory]::Move($Source, $Destination) } function Install-Expriverse { param([uri]$Uri, [string]$RequestedRoot, [bool]$SkipLaunch, [bool]$SkipShortcut, [bool]$AllowHttp) if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitOperatingSystem -or -not [Environment]::Is64BitProcess) { throw '请在 64 位 Windows 的 Windows PowerShell 或 PowerShell 中运行。' } Write-Host 'Expriverse 安装 / 更新' -ForegroundColor Cyan $download = Read-Release $Uri $AllowHttp $release = $download.Metadata $parent = Get-InstallRoot $RequestedRoot $directory = Join-Path $parent 'game' $installed = Read-InstalledRelease $directory $sameVersion = $null -ne $installed -and $installed.id -ceq $release.id if ($sameVersion -and ($installed.sha256 -ine $release.sha256 -or [string]$installed.bytes -cne [string]$release.bytes -or [string]$installed.expandedBytes -cne [string]$release.expandedBytes)) { throw '同一版本编号的安装包信息发生变化,拒绝跳过或替换。请联系发布者使用新的版本编号。' } if (-not $sameVersion) { if ($null -ne $installed) { Assert-GameStopped $directory } Write-Host ('目标版本:' + $release.id + ';游戏目录:' + $directory) Write-Host ('下载 {0:N1} MiB,解压 {1:N1} MiB;更新时还需保留旧版和设置的空间。' -f ([long]$release.bytes / 1MB), ([long]$release.expandedBytes / 1MB)) Write-Host '更新只保留 local、roaming 和 Logs;其他程序目录文件会被替换,cache 缓存重新生成。请保持游戏关闭。' if (-not (Read-Choice '确认下载并安装?')) { Write-Host '已取消,未修改安装文件。'; return } } $token = [guid]::NewGuid().ToString('N') $work = Join-Path $parent ('.expriverse-install-' + $token) $backup = Join-Path $work 'previous' $created = $false $preserveWork = $false $committed = $false $lock = $null try { Assert-NoDirectoryLinks $parent [IO.Directory]::CreateDirectory($parent) | Out-Null $lockPath = Join-Path $parent '.expriverse-update.lock' if ((Test-Path -LiteralPath $lockPath) -and ((Get-Item -LiteralPath $lockPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw '更新锁不能是链接。' } try { $lock = [IO.File]::Open($lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { throw '另一个安装或更新正在使用此目录,请等待它结束。' } foreach ($pending in Get-ChildItem -LiteralPath $parent -Directory -Filter '.expriverse-install-*' -Force) { if (Test-Path -LiteralPath (Join-Path $pending.FullName 'previous')) { throw ('发现上次中断保留的旧版,请先检查并恢复:' + $pending.FullName) } } $current = Read-InstalledRelease $directory if (($null -eq $installed) -ne ($null -eq $current) -or ($null -ne $current -and ($current.id -cne $installed.id -or $current.sha256 -ine $installed.sha256))) { throw '安装状态已改变,请重新运行。' } if ($sameVersion) { Assert-InstalledFiles $directory Write-Host ('已是最新版本:' + $release.id) -ForegroundColor Green Show-InstalledGame $directory $release.id $SkipLaunch $SkipShortcut return } if (Test-Path -LiteralPath $work) { throw '临时目录已存在,请重新运行。' } [IO.Directory]::CreateDirectory($work) | Out-Null $created = $true [IO.File]::WriteAllText((Join-Path $work '.expriverse-owner'), $token, [Text.Encoding]::ASCII) $archivePath = Join-Path $work 'package.zip' Save-Archive $download.ArchiveUri $archivePath ([long]$release.bytes) $AllowHttp if ((Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash -ine $release.sha256) { throw 'SHA-256 校验失败,未替换游戏。' } $payload = Join-Path $work 'game' Expand-SafeArchive $archivePath $payload ([long]$release.expandedBytes) if (-not (Test-Path -LiteralPath (Join-Path $payload 'Expriverse.Windows.exe') -PathType Leaf)) { throw '安装包缺少根目录主程序:Expriverse.Windows.exe' } foreach ($name in @('local', 'roaming', 'Logs', 'cache', '.expriverse-release.json', '.expriverse-owner')) { if (Test-Path -LiteralPath (Join-Path $payload $name)) { throw ('安装包不应包含用户状态、缓存或所有权标记:' + $name) } } New-GameWrappers $payload $release | Add-Member -NotePropertyName installFormat -NotePropertyValue 2 -Force [IO.File]::WriteAllText((Join-Path $payload '.expriverse-release.json'), ($release | ConvertTo-Json), [Text.UTF8Encoding]::new($true)) Assert-NoDirectoryLinks $parent try { if ($null -ne $installed) { Assert-SafeTree $directory Assert-GameStopped $directory Move-GameDirectory $directory $backup foreach ($name in @('local', 'roaming', 'Logs')) { $state = Join-Path $backup $name if (Test-Path -LiteralPath $state) { Copy-Item -LiteralPath $state -Destination (Join-Path $payload $name) -Recurse -Force } } } foreach ($name in @('local', 'roaming', 'Logs', 'cache')) { [IO.Directory]::CreateDirectory((Join-Path $payload $name)) | Out-Null } Move-GameDirectory $payload $directory $committed = $true } catch { if (Test-Path -LiteralPath $backup) { try { Move-GameDirectory $backup $directory } catch { $preserveWork = $true; throw ('恢复旧版失败,完整旧版保留在 ' + $backup + '。请勿删除此目录。原因:' + $_.Exception.Message) } } throw } Write-Host ('安装 / 更新完成:' + $release.id) -ForegroundColor Green } finally { if (-not $committed -and (Test-Path -LiteralPath $backup)) { $preserveWork = $true Write-Warning ('更新中断,完整旧版保留在:' + $backup) } if ($created -and -not $preserveWork) { try { Remove-OwnedWorkDirectory $work $parent $token } catch { Write-Warning ('临时目录未完全清理:' + $work + ';' + $_.Exception.Message) } } if ($null -ne $lock) { $lock.Dispose() } } Show-InstalledGame $directory $release.id $SkipLaunch $SkipShortcut } try { Install-Expriverse $ReleaseUri $InstallRoot ([bool]$NoLaunch) ([bool]$NoShortcut) ([bool]$AllowLoopbackHttp) } catch { Write-Host ('安装未完成:' + $_.Exception.Message) -ForegroundColor Red; throw } } $ReleaseUri $InstallRoot $NoLaunch $NoShortcut $AllowLoopbackHttp