[CmdletBinding()] param() $ErrorActionPreference = "Stop" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $baseUrl = "https://cpa.dongailin.cn/downloads" $claudeOfficialBaseUrl = "https://downloads.claude.ai/claude-code-releases" $nodeOfficialBaseUrl = "https://nodejs.org/dist" $relayBaseUrl = "https://zgo.2000521.xyz:25501/relay" $claudeRelayBaseUrl = "$relayBaseUrl/claude-code-releases" $nodeRelayBaseUrl = "$relayBaseUrl/node-releases" $authUser = "admin" $script:UseChinese = $false function Initialize-ConsoleEncoding { try { $utf8 = New-Object Text.UTF8Encoding($false) [Console]::InputEncoding = $utf8 [Console]::OutputEncoding = $utf8 $global:OutputEncoding = $utf8 if ($env:OS -eq "Windows_NT") { $chcpPath = Join-Path $env:SystemRoot "System32\chcp.com" if (Test-Path -LiteralPath $chcpPath) { & $chcpPath 65001 | Out-Null } } } catch { # Some non-interactive hosts do not expose console encoding handles. } } function Get-CurrentConsoleFontName { if ($Host.Name -ne "ConsoleHost") { return $null } try { if (-not ("DalClaudeConsole.NativeMethods" -as [type])) { Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; namespace DalClaudeConsole { [StructLayout(LayoutKind.Sequential)] public struct Coord { public short X; public short Y; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct ConsoleFontInfoEx { public uint cbSize; public uint nFont; public Coord dwFontSize; public int FontFamily; public int FontWeight; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string FaceName; } public static class NativeMethods { [DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr GetStdHandle(int nStdHandle); [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetCurrentConsoleFontEx( IntPtr hConsoleOutput, bool bMaximumWindow, ref ConsoleFontInfoEx lpConsoleCurrentFontEx ); } } "@ -ErrorAction Stop } $fontInfo = New-Object DalClaudeConsole.ConsoleFontInfoEx $fontInfo.cbSize = [Runtime.InteropServices.Marshal]::SizeOf($fontInfo) $consoleOutput = [DalClaudeConsole.NativeMethods]::GetStdHandle(-11) if ($consoleOutput -eq [IntPtr]::Zero -or -not [DalClaudeConsole.NativeMethods]::GetCurrentConsoleFontEx( $consoleOutput, $false, [ref]$fontInfo )) { return $null } return ([string]$fontInfo.FaceName).Trim() } catch { return $null } } function Test-ChineseFontAvailable { if ($env:DAL_CLAUDE_LANG -eq "en") { return $false } if ($env:DAL_CLAUDE_LANG -eq "zh") { return $true } $cultureNames = @( [Globalization.CultureInfo]::CurrentUICulture.Name, [Globalization.CultureInfo]::CurrentCulture.Name ) if (-not ($cultureNames | Where-Object { $_ -like "zh-*" })) { return $false } if (-not $env:WINDIR) { return $false } $fontDirectory = Join-Path $env:WINDIR "Fonts" $knownChineseFonts = @( "msyh.ttc", "msyhl.ttc", "msyhm.ttc", "simsun.ttc", "simhei.ttf", "Deng.ttf", "Dengb.ttf", "Dengl.ttf" ) foreach ($fontFile in $knownChineseFonts) { if (Test-Path -LiteralPath (Join-Path $fontDirectory $fontFile) -PathType Leaf) { $consoleFontName = Get-CurrentConsoleFontName if (-not $consoleFontName) { return $false } return ($consoleFontName -match "(?i)(NSimSun|SimSun|YaHei|DengXian|FangSong|KaiTi|Noto.*CJK|Source Han|Sarasa|新宋体|宋体|微软雅黑|等线|仿宋|楷体)") } } return $false } function Get-DisplayText([string]$Chinese, [string]$English) { if ($script:UseChinese) { return $Chinese } return $English } Initialize-ConsoleEncoding $script:UseChinese = Test-ChineseFontAvailable function Write-Step([string]$Message) { Write-Host "[DAL Claude]" $Message -ForegroundColor Cyan } function Start-WaitProgress([string]$Message) { if ([string]::IsNullOrWhiteSpace($Message)) { return $null } Write-Host ("[DAL Claude] {0} " -f $Message) -NoNewline -ForegroundColor Cyan Write-Host "." -NoNewline -ForegroundColor DarkGray return [PSCustomObject]@{ Stopwatch = [Diagnostics.Stopwatch]::StartNew() DotCount = 1 } } function Update-WaitProgress($State) { if (-not $State) { return } $targetDotCount = [Math]::Floor($State.Stopwatch.Elapsed.TotalSeconds) + 1 while ($State.DotCount -lt $targetDotCount) { Write-Host "." -NoNewline -ForegroundColor DarkGray $State.DotCount++ } } function Complete-WaitProgress($State, [string]$Result) { if (-not $State) { return } $State.Stopwatch.Stop() $status = switch ($Result) { "success" { Get-DisplayText "完成" "done" } "timeout" { Get-DisplayText "超时" "timed out" } default { Get-DisplayText "失败" "failed" } } $elapsedText = if ($script:UseChinese) { "{0}({1:N1} 秒)" -f $status, $State.Stopwatch.Elapsed.TotalSeconds } else { "{0} ({1:N1} s)" -f $status, $State.Stopwatch.Elapsed.TotalSeconds } $color = switch ($Result) { "success" { [ConsoleColor]::Green } "timeout" { [ConsoleColor]::Yellow } default { [ConsoleColor]::Red } } # Always finish the progress indicator with a newline. This avoids the # following installer message being drawn on the same console row. Write-Host (" {0}" -f $elapsedText) -ForegroundColor $color } function Ensure-Directory([string]$Path) { if (-not (Test-Path -LiteralPath $Path)) { New-Item -ItemType Directory -Path $Path -Force | Out-Null } } function Get-CurrentUserProfile { if ($env:USERPROFILE -and $env:USERPROFILE.Trim()) { return $env:USERPROFILE } $profilePath = [Environment]::GetFolderPath("UserProfile") if ($profilePath -and $profilePath.Trim()) { return $profilePath } throw (Get-DisplayText "无法确定当前用户目录。" "Unable to determine the current user profile directory.") } function Get-PlainTextFromSecureString([Security.SecureString]$SecureValue) { $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureValue) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } } function Get-BasicAuthHeader([string]$UserName, [string]$Password) { $bytes = [Text.Encoding]::UTF8.GetBytes("${UserName}:${Password}") return "Basic " + [Convert]::ToBase64String($bytes) } function Get-HttpStatusCodeFromError($ErrorRecord) { if (-not $ErrorRecord) { return $null } $exception = $ErrorRecord.Exception while ($exception) { if ($exception.Data -and $exception.Data.Contains("HttpStatusCode")) { return [int]$exception.Data["HttpStatusCode"] } if ($exception.Response -and $exception.Response.StatusCode) { return [int]$exception.Response.StatusCode } $exception = $exception.InnerException } return $null } function Download-File( [string]$Uri, [string]$Destination, [hashtable]$Headers = @{}, [int]$TimeoutSec = 0, [string]$DisplayName = "" ) { $request = $null $response = $null $sourceStream = $null $destinationStream = $null $downloadSucceeded = $false $showProgress = -not [string]::IsNullOrWhiteSpace($DisplayName) $progressId = 42001 $stopwatch = [Diagnostics.Stopwatch]::StartNew() try { $request = [Net.HttpWebRequest]::Create($Uri) $request.Method = "GET" $request.UserAgent = "DAL-Claude-Installer/1.0" $request.AllowAutoRedirect = $true if ($TimeoutSec -gt 0) { $request.Timeout = $TimeoutSec * 1000 $request.ReadWriteTimeout = $TimeoutSec * 1000 } foreach ($headerName in $Headers.Keys) { $request.Headers[[string]$headerName] = [string]$Headers[$headerName] } $response = [Net.HttpWebResponse]$request.GetResponse() $contentLength = [Int64]$response.ContentLength $sourceStream = $response.GetResponseStream() $destinationStream = [IO.File]::Open( $Destination, [IO.FileMode]::Create, [IO.FileAccess]::Write, [IO.FileShare]::None ) $buffer = New-Object byte[] (1024 * 1024) $totalBytes = [Int64]0 $lastProgressMilliseconds = [Int64](-1000) # Legacy Windows PowerShell miscalculates CJK character widths inside # Write-Progress. Keep the dynamic progress title ASCII-only so it does # not overlap; the surrounding installer steps remain localized. $activity = "Downloading: $DisplayName" while (($bytesRead = $sourceStream.Read($buffer, 0, $buffer.Length)) -gt 0) { $destinationStream.Write($buffer, 0, $bytesRead) $totalBytes += $bytesRead if ($showProgress -and ($stopwatch.ElapsedMilliseconds - $lastProgressMilliseconds -ge 500 -or ($contentLength -gt 0 -and $totalBytes -ge $contentLength))) { $elapsedSeconds = [Math]::Max($stopwatch.Elapsed.TotalSeconds, 0.001) $speedMb = ($totalBytes / 1MB) / $elapsedSeconds if ($contentLength -gt 0) { $percent = [Math]::Min( 100, [Math]::Floor(($totalBytes * 100.0) / $contentLength) ) $status = "{0:N1} / {1:N1} MB | {2}% | {3:N1} MB/s" -f ` ($totalBytes / 1MB), ($contentLength / 1MB), $percent, $speedMb Write-Progress ` -Id $progressId ` -Activity $activity ` -Status $status ` -PercentComplete $percent } else { $status = "{0:N1} MB | {1:N1} MB/s" -f ($totalBytes / 1MB), $speedMb Write-Progress ` -Id $progressId ` -Activity $activity ` -Status $status ` -PercentComplete -1 } $lastProgressMilliseconds = $stopwatch.ElapsedMilliseconds } } $destinationStream.Flush() if ($contentLength -ge 0 -and $totalBytes -ne $contentLength) { throw (Get-DisplayText ` "下载中断:预期 $contentLength 字节,实际收到 $totalBytes 字节。" ` "Download interrupted: expected $contentLength bytes but received $totalBytes bytes.") } $downloadSucceeded = $true if ($showProgress) { Write-Progress -Id $progressId -Activity $activity -Completed $elapsedSeconds = [Math]::Max($stopwatch.Elapsed.TotalSeconds, 0.001) $averageSpeedMb = ($totalBytes / 1MB) / $elapsedSeconds $completedMessage = Get-DisplayText ` ("下载完成:{0}({1:N1} MB,平均 {2:N1} MB/s)" -f $DisplayName, ($totalBytes / 1MB), $averageSpeedMb) ` ("Download complete: {0} ({1:N1} MB, average {2:N1} MB/s)" -f $DisplayName, ($totalBytes / 1MB), $averageSpeedMb) Write-Host $completedMessage -ForegroundColor Green } } finally { $stopwatch.Stop() if ($showProgress) { Write-Progress -Id $progressId -Activity $DisplayName -Completed -ErrorAction SilentlyContinue } if ($destinationStream) { $destinationStream.Dispose() } if ($sourceStream) { $sourceStream.Dispose() } if ($response) { $response.Dispose() } if ($request) { $request.Abort() } if (-not $downloadSucceeded -and (Test-Path -LiteralPath $Destination)) { Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue } } } function Get-Text( [string]$Uri, [int]$TimeoutSec = 0, [string]$WaitMessage = "" ) { $request = $null $response = $null $responseStream = $null $reader = $null $waitResult = "failure" $waitState = Start-WaitProgress $WaitMessage $requestStopwatch = [Diagnostics.Stopwatch]::StartNew() try { $request = [Net.HttpWebRequest]::Create($Uri) $request.Method = "GET" $request.UserAgent = "DAL-Claude-Installer/1.0" $request.AllowAutoRedirect = $true if ($TimeoutSec -gt 0) { $request.Timeout = $TimeoutSec * 1000 $request.ReadWriteTimeout = $TimeoutSec * 1000 } $asyncResponse = $request.BeginGetResponse($null, $null) while (-not $asyncResponse.AsyncWaitHandle.WaitOne(200)) { if ($TimeoutSec -gt 0 -and $requestStopwatch.Elapsed.TotalSeconds -ge $TimeoutSec) { $waitResult = "timeout" $request.Abort() throw (Get-DisplayText "网络请求超时。" "The network request timed out.") } Update-WaitProgress $waitState } $response = [Net.HttpWebResponse]$request.EndGetResponse($asyncResponse) $responseStream = $response.GetResponseStream() $reader = New-Object IO.StreamReader( $responseStream, [Text.Encoding]::UTF8, $true ) $text = $reader.ReadToEnd() $waitResult = "success" return $text.Trim() } finally { $requestStopwatch.Stop() if ($reader) { $reader.Dispose() } if ($responseStream) { $responseStream.Dispose() } if ($response) { $response.Dispose() } if ($request) { $request.Abort() } Complete-WaitProgress -State $waitState -Result $waitResult } } function Assert-Sha256([string]$Path, [string]$Expected) { $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $Expected.ToLowerInvariant()) { throw (Get-DisplayText "SHA256 校验失败:$Path" "SHA256 verification failed: $Path") } } function Get-SemanticVersionText([string]$Uri, [int]$TimeoutSec = 15) { $sourceLabel = if ($Uri.StartsWith($claudeOfficialBaseUrl, [StringComparison]::OrdinalIgnoreCase)) { Get-DisplayText "Claude 官方" "Claude official" } elseif ($Uri.StartsWith($claudeRelayBaseUrl, [StringComparison]::OrdinalIgnoreCase)) { Get-DisplayText "DAL 高速中转" "DAL high-speed relay" } elseif ($Uri.StartsWith("$baseUrl/claude-code-releases", [StringComparison]::OrdinalIgnoreCase)) { Get-DisplayText "腾讯云镜像" "Tencent Cloud mirror" } else { Get-DisplayText "远程来源" "the remote source" } $waitMessage = Get-DisplayText ` "正在查询$sourceLabel最新版本" ` "Checking the latest version from $sourceLabel" $versionText = Get-Text ` -Uri $Uri ` -TimeoutSec $TimeoutSec ` -WaitMessage $waitMessage if ($versionText -notmatch "^\d+\.\d+\.\d+$") { throw (Get-DisplayText "版本号无效:$versionText" "Invalid version number: $versionText") } return $versionText } function Test-PartialDownload( [string]$Uri, [int]$ExpectedBytes = 262144, [int]$TimeoutSec = 15 ) { $request = $null $response = $null $stream = $null $probeSucceeded = $false $waitResult = "failure" $probeStopwatch = [Diagnostics.Stopwatch]::StartNew() $sourceLabel = if ($Uri.StartsWith($claudeOfficialBaseUrl, [StringComparison]::OrdinalIgnoreCase)) { Get-DisplayText "Claude 官方" "Claude official" } elseif ($Uri.StartsWith($claudeRelayBaseUrl, [StringComparison]::OrdinalIgnoreCase)) { Get-DisplayText "DAL 高速中转" "DAL high-speed relay" } else { Get-DisplayText "当前来源" "the selected source" } $waitMessage = Get-DisplayText ` "正在探测$sourceLabel下载链路" ` "Testing the $sourceLabel download path" $waitState = Start-WaitProgress $waitMessage try { $request = [Net.HttpWebRequest]::Create($Uri) $request.Method = "GET" $request.UserAgent = "DAL-Claude-Installer/1.0" $request.AllowAutoRedirect = $true $request.Timeout = $TimeoutSec * 1000 $request.ReadWriteTimeout = $TimeoutSec * 1000 $request.AddRange([Int64]0, [Int64]($ExpectedBytes - 1)) $asyncResponse = $request.BeginGetResponse($null, $null) while (-not $asyncResponse.AsyncWaitHandle.WaitOne(200)) { if ($probeStopwatch.Elapsed.TotalSeconds -ge $TimeoutSec) { $waitResult = "timeout" $request.Abort() return $false } Update-WaitProgress $waitState } $response = [Net.HttpWebResponse]$request.EndGetResponse($asyncResponse) if ([int]$response.StatusCode -ne 206 -or $response.ContentLength -ne $ExpectedBytes) { return $false } $stream = $response.GetResponseStream() $buffer = New-Object byte[] 32768 $totalBytes = 0 while ($totalBytes -lt $ExpectedBytes) { $remaining = $ExpectedBytes - $totalBytes $readSize = [Math]::Min($buffer.Length, $remaining) $asyncRead = $stream.BeginRead($buffer, 0, $readSize, $null, $null) while (-not $asyncRead.AsyncWaitHandle.WaitOne(200)) { if ($probeStopwatch.Elapsed.TotalSeconds -ge $TimeoutSec) { $waitResult = "timeout" $request.Abort() return $false } Update-WaitProgress $waitState } $bytesRead = $stream.EndRead($asyncRead) if ($bytesRead -le 0) { return $false } $totalBytes += $bytesRead } $probeSucceeded = ($totalBytes -eq $ExpectedBytes) if ($probeSucceeded) { $waitResult = "success" } return $probeSucceeded } catch { return $false } finally { $probeStopwatch.Stop() if ($stream) { $stream.Dispose() } if ($response) { $response.Dispose() } if ($request) { $request.Abort() } Complete-WaitProgress -State $waitState -Result $waitResult } } function Get-ClaudeManifestInfo( [string]$ManifestUri, [string]$ExpectedVersion, [int]$TimeoutSec = 30 ) { $manifest = Invoke-RestMethod -Uri $ManifestUri -TimeoutSec $TimeoutSec if ([string]$manifest.version -ne $ExpectedVersion) { throw (Get-DisplayText "Claude Code 清单版本与目标版本不一致。" "The Claude Code manifest version does not match the target version.") } $windowsBuild = $manifest.platforms."win32-x64" if (-not $windowsBuild -or [string]$windowsBuild.checksum -notmatch "^[0-9a-fA-F]{64}$") { throw (Get-DisplayText "Claude Code 清单缺少 Windows x64 校验信息。" "The Claude Code manifest is missing Windows x64 verification data.") } $expectedSize = [Int64]0 if (-not [Int64]::TryParse([string]$windowsBuild.size, [ref]$expectedSize) -or $expectedSize -le 0) { throw (Get-DisplayText "Claude Code 清单中的 Windows x64 文件大小无效。" "The Windows x64 file size in the Claude Code manifest is invalid.") } return [PSCustomObject]@{ Checksum = ([string]$windowsBuild.checksum).ToLowerInvariant() Size = $expectedSize } } function Download-VerifiedClaude( [string]$SourceBaseUrl, [string]$Version, [string]$Destination ) { $manifestInfo = Get-ClaudeManifestInfo ` -ManifestUri "$SourceBaseUrl/$Version/manifest.json" ` -ExpectedVersion $Version Download-File ` -Uri "$SourceBaseUrl/$Version/win32-x64/claude.exe" ` -Destination $Destination ` -DisplayName "Claude Code $Version" $actualSize = (Get-Item -LiteralPath $Destination).Length if ($actualSize -ne $manifestInfo.Size) { throw (Get-DisplayText "Claude Code 下载文件大小不正确。" "The downloaded Claude Code file size is incorrect.") } Assert-Sha256 -Path $Destination -Expected $manifestInfo.Checksum } function Get-ClaudeSourceInfo([string]$Kind) { switch ($Kind) { "official" { return [PSCustomObject]@{ Kind = "official" BaseUrl = $claudeOfficialBaseUrl Label = Get-DisplayText "Claude 官方" "Claude official" RequiresProbe = $true } } "relay" { return [PSCustomObject]@{ Kind = "relay" BaseUrl = $claudeRelayBaseUrl Label = Get-DisplayText "DAL 高速中转" "DAL high-speed relay" RequiresProbe = $true } } "mirror" { return [PSCustomObject]@{ Kind = "mirror" BaseUrl = "$baseUrl/claude-code-releases" Label = Get-DisplayText "腾讯云镜像" "Tencent Cloud mirror" RequiresProbe = $false } } default { throw "Unknown Claude source: $Kind" } } } function Get-StandardNodeLtsInfo( [string]$SourceBaseUrl, [string]$SourceDescription ) { $nodeIndex = Invoke-RestMethod -Uri "$SourceBaseUrl/index.json" -TimeoutSec 15 $release = $nodeIndex | Where-Object { $_.lts } | Select-Object -First 1 if (-not $release -or [string]$release.version -notmatch "^v\d+\.\d+\.\d+$") { throw (Get-DisplayText ` "$SourceDescription 的 Node.js 索引中没有有效的 LTS 版本。" ` "No valid Node.js LTS release was found through $SourceDescription.") } $version = [string]$release.version $fileName = "node-$version-win-x64.zip" return [PSCustomObject]@{ Version = $version File = $fileName } } function Get-NodeChecksumFromShasums([string]$Shasums, [string]$FileName) { $escapedFileName = [regex]::Escape($FileName) $match = [regex]::Match( $Shasums, "(?mi)^([0-9a-f]{64})\s+\*?$escapedFileName\s*$" ) if (-not $match.Success) { throw (Get-DisplayText "Node.js 官方校验清单中找不到 $FileName。" "The official Node.js checksum list does not contain $FileName.") } return $match.Groups[1].Value.ToLowerInvariant() } function Download-VerifiedStandardNode( [string]$SourceBaseUrl, [string]$SourceDescription, [string]$Destination ) { $nodeInfo = Get-StandardNodeLtsInfo ` -SourceBaseUrl $SourceBaseUrl ` -SourceDescription $SourceDescription $shasums = Get-Text "$SourceBaseUrl/$($nodeInfo.Version)/SHASUMS256.txt" 15 $checksum = Get-NodeChecksumFromShasums ` -Shasums $shasums ` -FileName $nodeInfo.File Download-File ` -Uri "$SourceBaseUrl/$($nodeInfo.Version)/$($nodeInfo.File)" ` -Destination $Destination ` -DisplayName "Node.js $($nodeInfo.Version)" Assert-Sha256 -Path $Destination -Expected $checksum return $nodeInfo } function Download-VerifiedMirrorNode([string]$Destination) { $version = Get-Text "$baseUrl/node-releases/latest" 15 if ($version -notmatch "^v\d+\.\d+\.\d+$") { throw (Get-DisplayText "Node.js 镜像版本号无效。" "The Node.js mirror returned an invalid version number.") } $manifest = Invoke-RestMethod ` -Uri "$baseUrl/node-releases/$version/manifest.json" ` -TimeoutSec 30 if ([string]$manifest.checksum -notmatch "^[0-9a-fA-F]{64}$" -or [string]$manifest.file -notmatch "^node-v\d+\.\d+\.\d+-win-x64\.zip$") { throw (Get-DisplayText ` "Node.js 镜像清单缺少有效的校验信息。" ` "The Node.js mirror manifest is missing valid verification data.") } $archiveName = [string]$manifest.file Download-File ` -Uri "$baseUrl/node-releases/$version/$archiveName" ` -Destination $Destination ` -DisplayName "Node.js $version" Assert-Sha256 -Path $Destination -Expected ([string]$manifest.checksum) return [PSCustomObject]@{ Version = $version File = $archiveName } } function Get-ClaudeVersionInfo([string[]]$CandidatePaths) { $seenPaths = @{} $bestMatch = $null foreach ($candidatePath in $CandidatePaths) { if (-not $candidatePath -or -not (Test-Path -LiteralPath $candidatePath -PathType Leaf)) { continue } try { $resolvedPath = (Resolve-Path -LiteralPath $candidatePath).Path $pathKey = $resolvedPath.ToLowerInvariant() if ($seenPaths.ContainsKey($pathKey)) { continue } $seenPaths[$pathKey] = $true $versionOutput = (& $resolvedPath --version 2>$null | Out-String).Trim() $versionMatch = [regex]::Match($versionOutput, "(?