209 lines
13 KiB
PowerShell
209 lines
13 KiB
PowerShell
# Download settings are process-local; never edit global pip/npm/uv settings.
|
||
# Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly.
|
||
|
||
function Get-DownloadText {
|
||
param([string]$English, [string]$Chinese)
|
||
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') { return $Chinese }
|
||
return $English
|
||
}
|
||
|
||
function Invoke-DownloadFile {
|
||
param(
|
||
[Parameter(Mandatory=$true)][string]$Uri,
|
||
[Parameter(Mandatory=$true)][string]$OutFile,
|
||
[int]$TimeoutSec = $ArtemisDownloads.timeout_seconds
|
||
)
|
||
$script:ArtemisDownloadError = ''
|
||
Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue
|
||
if (Get-Command curl.exe -ErrorAction SilentlyContinue) {
|
||
$previousErrors = $ErrorActionPreference
|
||
try {
|
||
# Native stderr is an ErrorRecord in PowerShell 5.1. Inspect the code,
|
||
# and log only HTTP status/exit code, never a URL containing credentials.
|
||
$ErrorActionPreference = 'Continue'
|
||
$status = & curl.exe -f -sSL --connect-timeout 5 --max-time $TimeoutSec --proto '=https' --proto-redir '=https' --write-out '%{http_code}' -o $OutFile $Uri 2>$null
|
||
$curlCode = $LASTEXITCODE
|
||
if ($curlCode -eq 0 -and (Test-Path -LiteralPath $OutFile) -and (Get-Item -LiteralPath $OutFile).Length -gt 0) { return $true }
|
||
$script:ArtemisDownloadError = if ($curlCode -eq 22) { "HTTP $status" }
|
||
elseif ($curlCode -eq 28) { Get-DownloadText "Download timed out after $TimeoutSec seconds" "下载超时($TimeoutSec 秒)" }
|
||
else { "curl exit $curlCode" }
|
||
# Do not repeat an HTTP rejection or a full timeout with a second client.
|
||
if ($curlCode -in @(22, 28)) { return $false }
|
||
} catch {
|
||
$script:ArtemisDownloadError = 'curl failed'
|
||
} finally {
|
||
$ErrorActionPreference = $previousErrors
|
||
if ($script:ArtemisDownloadError) { Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue }
|
||
}
|
||
}
|
||
$previousProgress = $ProgressPreference
|
||
try {
|
||
$ProgressPreference = 'SilentlyContinue'
|
||
Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
|
||
if ((Test-Path -LiteralPath $OutFile) -and (Get-Item -LiteralPath $OutFile).Length -gt 0) {
|
||
$script:ArtemisDownloadError = ''
|
||
return $true
|
||
}
|
||
$script:ArtemisDownloadError = Get-DownloadText 'Empty download' '下载内容为空'
|
||
} catch {
|
||
$response = $_.Exception.Response
|
||
$script:ArtemisDownloadError = if ($response) { "HTTP $([int]$response.StatusCode)" }
|
||
else { Get-DownloadText 'Network/TLS error or download timeout' '网络、TLS 连接失败或下载超时' }
|
||
} finally {
|
||
$ProgressPreference = $previousProgress
|
||
if ($script:ArtemisDownloadError) { Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue }
|
||
}
|
||
return $false
|
||
}
|
||
|
||
function Get-ArtemisFileSha256 {
|
||
param([Parameter(Mandatory=$true)][string]$Path)
|
||
# Do not depend on Get-FileHash module autoloading: PowerShell 7 can pass its
|
||
# own module search path to the Windows PowerShell 5.1 launcher.
|
||
$hasher = [Security.Cryptography.SHA256]::Create()
|
||
$stream = [IO.File]::OpenRead($Path)
|
||
try { return [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() }
|
||
finally { $stream.Dispose(); $hasher.Dispose() }
|
||
}
|
||
|
||
function Invoke-ArtemisVerifiedDownload {
|
||
param(
|
||
[Parameter(Mandatory=$true)][string[]]$Urls,
|
||
[Parameter(Mandatory=$true)][string]$OutFile,
|
||
[Parameter(Mandatory=$true)][string]$Sha256
|
||
)
|
||
$sources = @($Urls | Select-Object -Unique)
|
||
for ($index = 0; $index -lt $sources.Count; $index++) {
|
||
$sourceHost = ([Uri]$sources[$index]).Host
|
||
Write-Host (Get-DownloadText " [DOWNLOAD $($index + 1)/$($sources.Count)] scrcpy: $sourceHost" " [下载 $($index + 1)/$($sources.Count)] scrcpy:$sourceHost") -ForegroundColor Cyan
|
||
if (Invoke-DownloadFile -Uri $sources[$index] -OutFile $OutFile) {
|
||
$actualHash = Get-ArtemisFileSha256 -Path $OutFile
|
||
if ($actualHash -eq $Sha256) { return $true }
|
||
$script:ArtemisDownloadError = Get-DownloadText 'Official SHA256 verification failed; archive rejected' '官方 SHA256 校验失败,已拒绝此安装包'
|
||
Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue
|
||
}
|
||
Write-Host (Get-DownloadText " [WARN] $sourceHost`: $script:ArtemisDownloadError" " [提示] $sourceHost 下载失败:$script:ArtemisDownloadError") -ForegroundColor Yellow
|
||
}
|
||
return $false
|
||
}
|
||
|
||
function Initialize-ArtemisDownloads {
|
||
param([string]$ProjectRoot, [string]$Mode)
|
||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||
$path = Join-Path $ProjectRoot 'config\downloads.json'
|
||
$config = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
||
if (-not $Mode) { $Mode = $config.mode }
|
||
if ($Mode -notin @('Official', 'China')) { throw 'Download mode must be Official or China.' }
|
||
$timeout = 0
|
||
if (-not [int]::TryParse([string]$config.timeout_seconds, [ref]$timeout) -or $timeout -lt 10 -or $timeout -gt 3600) {
|
||
throw 'config/downloads.json: timeout_seconds must be between 10 and 3600.'
|
||
}
|
||
$values = @{
|
||
python_index = ''; python_download_mirror = ''; npm_registry = ''
|
||
node_mirror = 'https://nodejs.org/dist'
|
||
adb_url = 'https://dl.google.com/android/repository/platform-tools-latest-windows.zip'
|
||
scrcpy_url = 'https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip'
|
||
uv_installer_url = 'https://astral.sh/uv/install.ps1'
|
||
}
|
||
foreach ($name in @($values.Keys)) {
|
||
if ($Mode -eq 'China' -and $config.china.$name) { $values[$name] = [string]$config.china.$name }
|
||
if ($config.overrides.$name) { $values[$name] = [string]$config.overrides.$name }
|
||
if ($values[$name]) {
|
||
$uri = $null
|
||
if (-not [Uri]::TryCreate($values[$name], [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne 'https') {
|
||
throw "config/downloads.json: $name must be an HTTPS URL."
|
||
}
|
||
}
|
||
}
|
||
$fallbacks = @('https://downloads.sourceforge.net/project/scrcpy.mirror/v4.1/scrcpy-win64-v4.1.zip')
|
||
if ($Mode -eq 'China' -and $null -ne $config.china.scrcpy_fallback_urls) { $fallbacks = $config.china.scrcpy_fallback_urls }
|
||
if ($null -ne $config.overrides.scrcpy_fallback_urls) { $fallbacks = $config.overrides.scrcpy_fallback_urls }
|
||
if ($fallbacks -isnot [Array]) { throw 'config/downloads.json: scrcpy_fallback_urls must be an array of HTTPS URLs.' }
|
||
foreach ($url in $fallbacks) {
|
||
$uri = $null
|
||
if ($url -isnot [string] -or -not [Uri]::TryCreate($url, [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne 'https') {
|
||
throw 'config/downloads.json: scrcpy_fallback_urls must contain only HTTPS URLs.'
|
||
}
|
||
}
|
||
$values.scrcpy_urls = @(@($values.scrcpy_url) + @($fallbacks) | Select-Object -Unique)
|
||
if ($Mode -eq 'China') {
|
||
# China mode must never silently fall back to a direct GitHub download.
|
||
$values.scrcpy_urls = @($values.scrcpy_urls | Where-Object { ([Uri]$_).Host -notin @('github.com', 'www.github.com') })
|
||
}
|
||
# Explicit caller environment settings take priority over the selected preset.
|
||
if ($env:UV_DEFAULT_INDEX) { $values.python_index = $env:UV_DEFAULT_INDEX }
|
||
elseif ($env:UV_INDEX_URL) { $values.python_index = $env:UV_INDEX_URL }
|
||
if ($env:UV_PYTHON_INSTALL_MIRROR) { $values.python_download_mirror = $env:UV_PYTHON_INSTALL_MIRROR }
|
||
if ($env:npm_config_registry) { $values.npm_registry = $env:npm_config_registry }
|
||
if ($values.python_download_mirror) { $env:UV_PYTHON_INSTALL_MIRROR = $values.python_download_mirror }
|
||
if ($values.npm_registry) { $env:npm_config_registry = $values.npm_registry }
|
||
$env:UV_HTTP_TIMEOUT = [string]$timeout
|
||
$env:npm_config_fetch_timeout = [string]($timeout * 1000)
|
||
$values.mode = $Mode
|
||
$values.timeout_seconds = $timeout
|
||
# Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally.
|
||
$values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db'
|
||
$script:ArtemisDownloads = [pscustomobject]$values
|
||
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN' -and $Mode -eq 'China') {
|
||
Write-Host " [镜像] 使用国内下载源(config/downloads.json)。" -ForegroundColor Cyan
|
||
} else {
|
||
Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan
|
||
}
|
||
}
|
||
|
||
function Install-ArtemisUvFromIndex {
|
||
param([string]$IndexUrl, [string]$InstallDir = (Join-Path $env:USERPROFILE '.local\bin'))
|
||
# A uv wheel contains the standalone executable; no preinstalled Python is needed.
|
||
$work = Join-Path ([IO.Path]::GetTempPath()) ('artemis-uv-' + [guid]::NewGuid().ToString('N'))
|
||
New-Item -ItemType Directory -Path $work -ErrorAction Stop | Out-Null
|
||
try {
|
||
$index = $IndexUrl.TrimEnd('/') + '/uv/'
|
||
$page = Join-Path $work 'index.html'
|
||
if (-not (Invoke-DownloadFile -Uri $index -OutFile $page)) { throw 'Could not download the uv package index.' }
|
||
$platform = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { 'win_arm64' } else { 'win_amd64' }
|
||
$pattern = 'href=["''](?<link>[^"'']*uv-(?<version>\d+\.\d+\.\d+)-py3-none-' + $platform + '\.whl#sha256=(?<hash>[a-fA-F0-9]{64}))["'']'
|
||
$candidates = [regex]::Matches((Get-Content -LiteralPath $page -Raw), $pattern)
|
||
$candidate = $candidates | Sort-Object { [version]$_.Groups['version'].Value } -Descending | Select-Object -First 1
|
||
if (-not $candidate) { throw "No stable uv wheel for $platform was found on the selected index." }
|
||
$url = [Uri]::new([Uri]$index, [System.Net.WebUtility]::HtmlDecode($candidate.Groups['link'].Value))
|
||
if ($url.Scheme -ne 'https') { throw 'The uv download must use HTTPS.' }
|
||
$zip = Join-Path $work 'uv.zip'
|
||
if (-not (Invoke-DownloadFile -Uri $url.AbsoluteUri.Split('#')[0] -OutFile $zip)) { throw 'Could not download uv from the selected index.' }
|
||
$digest = Get-ArtemisFileSha256 -Path $zip
|
||
if ($digest -ne $candidate.Groups['hash'].Value) {
|
||
throw 'The downloaded uv package failed SHA256 verification.'
|
||
}
|
||
$extract = Join-Path $work 'wheel'
|
||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||
[IO.Compression.ZipFile]::ExtractToDirectory($zip, $extract)
|
||
$executable = Get-ChildItem -LiteralPath $extract -Filter uv.exe -Recurse -File | Select-Object -First 1
|
||
if (-not $executable) { throw 'The uv package did not contain uv.exe.' }
|
||
New-Item -ItemType Directory -Path $InstallDir -Force -ErrorAction Stop | Out-Null
|
||
Copy-Item -LiteralPath $executable.FullName -Destination (Join-Path $InstallDir 'uv.exe') -Force -ErrorAction Stop
|
||
$uvx = Join-Path $executable.DirectoryName 'uvx.exe'
|
||
if (Test-Path -LiteralPath $uvx) { Copy-Item -LiteralPath $uvx -Destination $InstallDir -Force -ErrorAction Stop }
|
||
$env:PATH = "$InstallDir;$env:PATH"
|
||
} finally {
|
||
$resolved = (Resolve-Path -LiteralPath $work).Path
|
||
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
|
||
if ($resolved.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -and (Split-Path $resolved -Leaf) -like 'artemis-uv-*') {
|
||
Remove-Item -LiteralPath $resolved -Recurse -Force -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
}
|
||
|
||
function Install-ArtemisLockedDependencies {
|
||
param([string]$PythonExe, [string]$IndexUrl)
|
||
# uv sync uses the original artifact URLs in uv.lock. Export exact versions and
|
||
# hashes, then let uv pip fetch those same artifacts through the chosen index.
|
||
$requirements = Join-Path ([IO.Path]::GetTempPath()) ('artemis-requirements-' + [guid]::NewGuid().ToString('N') + '.txt')
|
||
try {
|
||
& uv export --frozen --no-dev --format requirements-txt --output-file $requirements 1>$null
|
||
if ($LASTEXITCODE -ne 0) { throw 'Could not export the locked dependencies.' }
|
||
& uv pip install --python $PythonExe --index-url $IndexUrl --requirements $requirements | Out-Host
|
||
if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed. Check mirror availability or select another source in config/downloads.json.' }
|
||
} finally {
|
||
Remove-Item -LiteralPath $requirements -Force -ErrorAction SilentlyContinue
|
||
}
|
||
}
|