# 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=["''](?[^"'']*uv-(?\d+\.\d+\.\d+)-py3-none-' + $platform + '\.whl#sha256=(?[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 } }