#!powershell #Requires -Version 5.1 param( [Parameter(Position = 0)][string]$Action, [Alias('h')][switch]$Help ) # 下载与安装器等待逻辑参考 NetVPN;此脚本只分发 CloudNet 桌面安装包。 $ErrorActionPreference = 'Stop' function _usage { Write-Host @' CloudNet online installer for Windows 10/11 x64 (PowerShell 5.1+). Run in an Administrator PowerShell: irm https://pkgs.cloudnet.world/stable/installer.ps1 | iex & ([scriptblock]::Create((irm https://pkgs.cloudnet.world/stable/installer.ps1))) install & ([scriptblock]::Create((irm https://pkgs.cloudnet.world/stable/installer.ps1))) restart & ([scriptblock]::Create((irm https://pkgs.cloudnet.world/stable/installer.ps1))) -h Actions: install (default), restart, help/-h. An explicit action overrides CLOUDNET_ACTION. Example: $env:CLOUDNET_ACTION='restart'; irm https://pkgs.cloudnet.world/stable/installer.ps1 | iex Remove-Item Env:CLOUDNET_ACTION Installation requires release 1.36.2.23 or later and keeps the setup wizard. install adds Windows Defender exclusions for CloudNet service data, download cache and installation directories. If unavailable or rejected, it warns and continues. These directory exclusions persist after installation or cancellation. restart only reopens the current session's desktop GUI, not the service or OS. restart and help do not change Windows Defender settings. The installer never reboots Windows. Exit 3010 requires manual attention. Service installation and network login remain available in the desktop app. '@ } function Resolve-InstallerAction([string]$Value, [bool]$Explicit, [bool]$ShowHelp) { if ($ShowHelp) { return 'help' } if (!$Explicit) { $Value = $env:CLOUDNET_ACTION } $Value = "$Value".Trim().ToLowerInvariant() if (!$Value) { return 'install' } if ($Value -in @('help', '-h', '--help')) { return 'help' } if ($Value -notin @('install', 'restart')) { throw "Unsupported action: $Value. Use -h for help." } return $Value } function ConvertTo-ReleaseVersion([string]$Value) { $match = [regex]::Match($Value.Trim(), '^(\d+)\.(\d+)\.(\d+)(?:[.\-+](\d+))?$') if (!$match.Success) { throw "Invalid CloudNet version: $Value" } $parts = @($match.Groups[1].Value, $match.Groups[2].Value, $match.Groups[3].Value, '0') if ($match.Groups[4].Success) { $parts[3] = $match.Groups[4].Value } return [version]($parts -join '.') } function Select-InstallerRecord([string]$Content) { $settings = New-Object System.Xml.XmlReaderSettings $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit $settings.XmlResolver = $null $settings.MaxCharactersInDocument = 65536 $text = New-Object System.IO.StringReader($Content) $reader = $null try { $reader = [System.Xml.XmlReader]::Create($text, $settings) $xml = New-Object System.Xml.XmlDocument $xml.XmlResolver = $null $xml.Load($reader) } finally { if ($null -ne $reader) { $reader.Dispose() } $text.Dispose() } $namespace = 'http://www.andymatuschak.org/xml-namespaces/sparkle' $records = @() foreach ($enclosure in @($xml.SelectNodes('/rss/channel/item/enclosure'))) { if ($enclosure.GetAttribute('os', $namespace) -cne 'windows') { continue } $version = ConvertTo-ReleaseVersion $enclosure.GetAttribute('version', $namespace) $url = $enclosure.GetAttribute('url') $expectedUrl = "https://pkgs.cloudnet.world/stable/windows/CloudNet_v$version.exe" if ($url -cne $expectedUrl) { throw 'Unexpected Windows package URL in appcast.' } $length = $enclosure.GetAttribute('length') $size = [long]0 if ($length -notmatch '^[1-9][0-9]*$' -or ![long]::TryParse($length, [ref]$size)) { throw 'Invalid Windows package length in appcast.' } if (@($records | Where-Object { $_.Version -eq $version }).Count) { throw 'Duplicate Windows release in appcast.' } $records += [pscustomobject]@{Version = $version; Url = $url; SizeBytes = $size; Sha256 = $null} } if (!$records.Count) { throw 'No Windows installer was found in appcast.' } $selected = $records | Sort-Object Version -Descending | Select-Object -First 1 if ($selected.Version -lt [version]'1.36.2.23') { throw 'Online installation is not ready: release 1.36.2.23 or later is required.' } return $selected } function ConvertTo-Checksum([string]$Content) { $value = $Content.TrimStart([char]0xFEFF).Trim() if ($value -notmatch '^[a-fA-F0-9]{64}$') { throw 'Invalid package SHA-256 file.' } return $value.ToLowerInvariant() } function New-InstallerHttpClient { Add-Type -AssemblyName System.Net.Http $handler = New-Object System.Net.Http.HttpClientHandler # 元数据与包均限于固定 HTTPS 地址,拒绝跟随到不受检查的下载站。 $handler.AllowAutoRedirect = $false $client = New-Object System.Net.Http.HttpClient($handler) $client.Timeout = [Threading.Timeout]::InfiniteTimeSpan [void]$client.DefaultRequestHeaders.UserAgent.ParseAdd('CloudNet-Installer/1') return $client } function Wait-InstallerTask($Task, [Threading.CancellationToken]$Token, [int]$Milliseconds) { # .NET Framework 的响应流可能不及时响应 ReadAsync 的取消 token;另设等待上限。 try { $handles = [Threading.WaitHandle[]]@(([IAsyncResult]$Task).AsyncWaitHandle, $Token.WaitHandle) if ([Threading.WaitHandle]::WaitAny($handles, $Milliseconds) -ne 0) { throw (New-Object System.TimeoutException('Installer HTTP request timed out.')) } return $Task.GetAwaiter().GetResult() } finally { if ($Task.IsCompleted) { $Task.Dispose() } } } function Get-InstallerText($Client, [string]$Uri, [int]$MaximumBytes, [bool]$Refresh = $false, [int]$TimeoutMilliseconds = 30000) { if ($Refresh) { $Uri += '?refresh=' + [guid]::NewGuid().ToString('N') } $request = New-Object System.Net.Http.HttpRequestMessage([System.Net.Http.HttpMethod]::Get, $Uri) [void]$request.Headers.TryAddWithoutValidation('Cache-Control', 'no-cache') $deadline = New-Object System.Threading.CancellationTokenSource $deadline.CancelAfter($TimeoutMilliseconds) $response = $null $stream = $null $bytes = New-Object System.IO.MemoryStream try { $response = Wait-InstallerTask ($Client.SendAsync($request, [Net.Http.HttpCompletionOption]::ResponseHeadersRead, $deadline.Token)) $deadline.Token $TimeoutMilliseconds if ([int]$response.StatusCode -ne 200) { throw "Metadata request failed: HTTP $([int]$response.StatusCode)." } $stream = Wait-InstallerTask ($response.Content.ReadAsStreamAsync()) $deadline.Token $TimeoutMilliseconds $buffer = New-Object byte[] 4096 while (($count = Wait-InstallerTask ($stream.ReadAsync($buffer, 0, $buffer.Length, $deadline.Token)) $deadline.Token $TimeoutMilliseconds) -gt 0) { if ($bytes.Length + $count -gt $MaximumBytes) { throw 'Installer metadata is too large.' } $bytes.Write($buffer, 0, $count) } return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes.ToArray()) } finally { $bytes.Dispose() if ($null -ne $stream) { $stream.Dispose() } if ($null -ne $response) { $response.Dispose() } $request.Dispose() $deadline.Dispose() } } function Get-InstallerRecord($Client, [bool]$Refresh = $false) { $record = Select-InstallerRecord (Get-InstallerText $Client 'https://pkgs.cloudnet.world/stable/appcast.xml' 65536 $Refresh) $record.Sha256 = ConvertTo-Checksum (Get-InstallerText $Client ($record.Url + '.sum') 1024 $Refresh) return $record } function Test-InstallerArtifact([string]$Path, $Record) { if (!(Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } if ((Get-Item -LiteralPath $Path).Length -ne $Record.SizeBytes) { return $false } return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $Record.Sha256 } function Get-ArtifactDownloadUri($Record) { return $Record.Url + '?sha256=' + $Record.Sha256 } function Show-InstallerDownloadProgress($Record, [long]$Downloaded, [long]$Offset, [Diagnostics.Stopwatch]$Watch) { $percent = [int][Math]::Min(100, [Math]::Floor($Downloaded * 100.0 / $Record.SizeBytes)) $seconds = [Math]::Max(1.0, $Watch.Elapsed.TotalSeconds) $speed = [Math]::Max(0.0, ($Downloaded - $Offset) / $seconds / 1MB) $status = '{0} / {1} bytes; {2:N1} MiB/s' -f $Downloaded, $Record.SizeBytes, $speed Write-Progress -Id 1 -Activity 'Downloading CloudNet installer' -Status $status -PercentComplete $percent -ErrorAction SilentlyContinue } function Invoke-RangeDownloadOnce($Client, $Record, [string]$PartPath, $RoundDeadline, [int]$IdleMilliseconds = 30000) { $offset = [long]0 if (Test-Path -LiteralPath $PartPath -PathType Leaf) { $offset = (Get-Item -LiteralPath $PartPath).Length } $request = New-Object System.Net.Http.HttpRequestMessage([Net.Http.HttpMethod]::Get, (Get-ArtifactDownloadUri $Record)) [void]$request.Headers.TryAddWithoutValidation('Accept-Encoding', 'identity') if ($offset -gt 0) { [void]$request.Headers.TryAddWithoutValidation('Range', "bytes=$offset-") } $idle = [Threading.CancellationTokenSource]::CreateLinkedTokenSource($RoundDeadline.Token) $response = $null $inputStream = $null $outputStream = $null $progressWatch = $null try { $idle.CancelAfter($IdleMilliseconds) $response = Wait-InstallerTask ($Client.SendAsync($request, [Net.Http.HttpCompletionOption]::ResponseHeadersRead, $idle.Token)) $RoundDeadline.Token $IdleMilliseconds $status = [int]$response.StatusCode if ($status -eq 416 -or ($offset -gt 0 -and $status -eq 200)) { return 'Restart' } if ($status -in @(408, 429) -or $status -ge 500) { return 'Retry' } if ($status -notin @(200, 206)) { throw "HTTP $status while downloading the installer." } if ($response.Content.Headers.ContentEncoding.Count) { return 'Mismatch' } $remaining = $Record.SizeBytes - $offset $length = $response.Content.Headers.ContentLength if ($null -ne $length -and [long]$length -ne $remaining) { return 'Mismatch' } if ($status -eq 206) { $range = $response.Content.Headers.ContentRange if ($null -eq $range -or $range.Unit -ne 'bytes' -or !$range.HasLength -or !$range.HasRange -or $range.From -ne $offset -or $range.To -ne ($Record.SizeBytes - 1) -or $range.Length -ne $Record.SizeBytes) { return 'Mismatch' } } $inputStream = Wait-InstallerTask ($response.Content.ReadAsStreamAsync()) $RoundDeadline.Token $IdleMilliseconds $outputStream = New-Object System.IO.FileStream($PartPath, [IO.FileMode]::Append, [IO.FileAccess]::Write, [IO.FileShare]::None) $buffer = New-Object byte[] 65536 $progressWatch = [Diagnostics.Stopwatch]::StartNew() $lastProgress = [long]0 Show-InstallerDownloadProgress $Record $offset $offset $progressWatch while ($true) { $idle.CancelAfter($IdleMilliseconds) $count = Wait-InstallerTask ($inputStream.ReadAsync($buffer, 0, $buffer.Length, $idle.Token)) $RoundDeadline.Token $IdleMilliseconds if (!$count) { break } if ($outputStream.Length + $count -gt $Record.SizeBytes) { return 'Mismatch' } $outputStream.Write($buffer, 0, $count) # 更新限频,避免每个数据块都刷新控制台而降低下载速度。 if ($progressWatch.ElapsedMilliseconds - $lastProgress -ge 200) { Show-InstallerDownloadProgress $Record $outputStream.Length $offset $progressWatch $lastProgress = $progressWatch.ElapsedMilliseconds } } Show-InstallerDownloadProgress $Record $outputStream.Length $offset $progressWatch if ($outputStream.Length -lt $Record.SizeBytes) { return 'Retry' } return 'Complete' } catch { if ($RoundDeadline.IsCancellationRequested) { throw 'Installer download exceeded the round time limit.' } $errorType = $_.Exception.GetBaseException() if ($idle.IsCancellationRequested -or $errorType -is [Net.Http.HttpRequestException] -or $errorType -is [IO.IOException] -or $errorType -is [OperationCanceledException] -or $errorType -is [TimeoutException]) { return 'Retry' } throw } finally { if ($null -ne $progressWatch) { $progressWatch.Stop() Write-Progress -Id 1 -Activity 'Downloading CloudNet installer' -Completed -ErrorAction SilentlyContinue } if ($null -ne $outputStream) { $outputStream.Dispose() } if ($null -ne $inputStream) { $inputStream.Dispose() } if ($null -ne $response) { $response.Dispose() } $request.Dispose() $idle.Dispose() } } function Invoke-ResumableDownload($Client, $Record, [string]$PartPath, [int]$RetryDelaySeconds = 2, [int]$IdleMilliseconds = 30000, [int]$RoundMilliseconds = 600000) { $deadline = New-Object System.Threading.CancellationTokenSource $deadline.CancelAfter($RoundMilliseconds) try { for ($attempt = 1; $attempt -le 3; $attempt++) { if ($deadline.IsCancellationRequested) { throw 'Installer download exceeded the round time limit.' } if (Test-Path -LiteralPath $PartPath -PathType Leaf) { $size = (Get-Item -LiteralPath $PartPath).Length if ($size -eq $Record.SizeBytes -and (Test-InstallerArtifact $PartPath $Record)) { Write-Host '==>> Using the completed partial download; size and SHA-256 verified.' return $true } if ($size -ge $Record.SizeBytes) { Remove-Item -LiteralPath $PartPath -Force } } Write-Host "==>> Download attempt $attempt/3..." $outcome = Invoke-RangeDownloadOnce $Client $Record $PartPath $deadline $IdleMilliseconds if ($outcome -eq 'Complete' -and (Test-InstallerArtifact $PartPath $Record)) { return $true } if ($outcome -in @('Complete', 'Mismatch', 'Restart')) { Remove-Item -LiteralPath $PartPath -Force -ErrorAction SilentlyContinue if ($outcome -ne 'Restart') { return $false } } if ($attempt -lt 3 -and $deadline.Token.WaitHandle.WaitOne($RetryDelaySeconds * 1000)) { throw 'Installer download exceeded the round time limit.' } } throw 'Download failed after three attempts. Run the command again to resume.' } finally { $deadline.Dispose() } } function Get-InstallationRegistration { $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64) $key = $null try { $key = $base.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\7B3E5A1F-8F6D-4C5E-9C7A-9B7D8E6F5A2C_is1') if ($null -eq $key) { return $null } return [pscustomobject]@{Directory = [string]$key.GetValue('InstallLocation'); Version = [string]$key.GetValue('DisplayVersion')} } finally { if ($null -ne $key) { $key.Dispose() } $base.Dispose() } } function Resolve-InstalledApplication($Registration) { if ($null -eq $Registration) { return $null } if (!$Registration.Directory -or ![IO.Path]::IsPathRooted($Registration.Directory)) { throw 'Invalid CloudNet installation directory.' } $path = [IO.Path]::GetFullPath((Join-Path $Registration.Directory 'CloudNet.exe')) $version = ConvertTo-ReleaseVersion $Registration.Version $exists = Test-Path -LiteralPath $path -PathType Leaf $fileVersion = $null if ($exists) { $fileVersion = ConvertTo-ReleaseVersion (Get-Item -LiteralPath $path).VersionInfo.ProductVersion } # 使用两处版本的较大值阻止降级;不一致的安装仍可用相同或更新包修复。 $highest = $version if ($null -ne $fileVersion -and $fileVersion -gt $highest) { $highest = $fileVersion } return [pscustomobject]@{Path = $path; Version = $highest; Valid = ($exists -and $version -eq $fileVersion)} } function Get-InstalledApplication { return Resolve-InstalledApplication (Get-InstallationRegistration) } function Add-InstallerDefenderExclusions([string]$CacheDir, $Installed) { try { if (!(Get-Command Add-MpPreference -ErrorAction SilentlyContinue)) { Write-Warning 'Windows Defender is unavailable; directory exclusions were not added.' -WarningAction Continue return } if ([string]::IsNullOrWhiteSpace($env:ProgramData)) { throw 'ProgramData is unavailable.' } if ($null -ne $Installed) { $installDir = Split-Path -Parent $Installed.Path } else { # 32 位 PowerShell 下仍使用 64 位安装目录,与 Inno Setup 的 autopf64 保持一致。 $programFiles = $env:ProgramW6432 if ([string]::IsNullOrWhiteSpace($programFiles)) { $programFiles = $env:ProgramFiles } if ([string]::IsNullOrWhiteSpace($programFiles)) { throw 'Program Files is unavailable.' } $installDir = Join-Path $programFiles 'CloudNet' } # CloudNet 核心状态/日志位于 ProgramData,不能照搬 NetVPN 的 APPDATA 目录。 $paths = @((Join-Path $env:ProgramData 'Cloudnet'), $CacheDir, $installDir) $exclusions = @() foreach ($path in $paths) { if ([string]::IsNullOrWhiteSpace($path) -or ![IO.Path]::IsPathRooted($path)) { throw 'Invalid exclusion directory.' } $directory = [IO.Path]::GetFullPath($path).TrimEnd('\') if ($directory -ieq [IO.Path]::GetPathRoot($directory).TrimEnd('\')) { throw 'Refusing to exclude a drive root.' } if ($exclusions -notcontains $directory) { $exclusions += $directory } } # 使用追加接口保留已有规则;不关闭实时保护,也不排除整个用户或临时目录。 Add-MpPreference -ExclusionPath $exclusions -ErrorAction Stop | Out-Null Write-Host '==>> Windows Defender exclusions added for CloudNet directories.' } catch { Write-Warning "Could not add Windows Defender exclusions; continuing installation. $($_.Exception.Message)" -WarningAction Continue } } function Get-InstallDecision($Installed, $Record, [string]$ReceiptPath) { if ($null -ne $Installed -and $Installed.Version -gt $Record.Version) { throw "Refusing to downgrade CloudNet $($Installed.Version) to $($Record.Version)." } if ($null -ne $Installed -and $Installed.Valid -and $Installed.Version -eq $Record.Version -and (Test-Path -LiteralPath $ReceiptPath -PathType Leaf)) { try { $receipt = Get-Content -LiteralPath $ReceiptPath -Raw -Encoding UTF8 | ConvertFrom-Json if ($receipt.Version -eq $Record.Version.ToString() -and $receipt.Path -ieq $Installed.Path -and $receipt.Sha256 -ceq $Record.Sha256 -and $receipt.AppSha256 -ceq (Get-FileHash -LiteralPath $Installed.Path -Algorithm SHA256).Hash.ToLowerInvariant()) { return 'Skip' } } catch { Write-Host '==>> Ignoring an invalid previous installation record.' } } return 'Install' } function Set-InstallationReceipt([string]$Path, $Installed, $Record) { $temporary = "$Path.$PID.tmp" try { $receipt = [ordered]@{Version = $Record.Version.ToString(); Path = $Installed.Path; Sha256 = $Record.Sha256; AppSha256 = (Get-FileHash -LiteralPath $Installed.Path -Algorithm SHA256).Hash.ToLowerInvariant()} [IO.File]::WriteAllText($temporary, ($receipt | ConvertTo-Json), (New-Object Text.UTF8Encoding($false))) Move-Item -LiteralPath $temporary -Destination $Path -Force } finally { Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue } } function Open-InstallerLock([string]$Path) { try { return New-Object IO.FileStream($Path, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { throw 'Another online installer is running, or its cache cannot be opened.' } } function Clear-InstallerCache([string]$Path) { Get-ChildItem -LiteralPath $Path -File | Where-Object { $_.Name -match '^[a-f0-9]{64}\.exe(?:\.part)?$' } | Remove-Item -Force } function Get-VerifiedInstallerArtifact($Client, [string]$CacheDir, [string]$ReceiptPath) { for ($round = 1; $round -le 2; $round++) { $record = Get-InstallerRecord $Client ($round -gt 1) if ((Get-InstallDecision (Get-InstalledApplication) $record $ReceiptPath) -eq 'Skip') { return [pscustomobject]@{AlreadyInstalled = $true; Record = $record; Path = $null} } $ready = Join-Path $CacheDir ($record.Sha256 + '.exe') $part = $ready + '.part' if ($round -gt 1) { Remove-Item -LiteralPath $ready, $part -Force -ErrorAction SilentlyContinue } if (Test-InstallerArtifact $ready $record) { Write-Host '==>> Using the cached installer; size and SHA-256 verified.' return [pscustomobject]@{AlreadyInstalled = $false; Record = $record; Path = $ready} } Remove-Item -LiteralPath $ready -Force -ErrorAction SilentlyContinue if (Invoke-ResumableDownload $Client $record $part) { Move-Item -LiteralPath $part -Destination $ready -Force return [pscustomobject]@{AlreadyInstalled = $false; Record = $record; Path = $ready} } Write-Host '==>> Integrity check failed; refreshing metadata and retrying from zero.' } throw 'Package size or SHA-256 verification failed twice. Installation was stopped.' } function Invoke-InstallerProcess([string]$Path) { $process = $null try { $start = New-Object Diagnostics.ProcessStartInfo $start.FileName = $Path $start.Arguments = '/NORESTART /RESTARTEXITCODE=3010' $start.UseShellExecute = $false # PS 5.1 的 Start-Process 可能丢失快速退出进程的句柄及退出码。 $process = [Diagnostics.Process]::Start($start) # Start-Process -Wait 会等待常驻子进程;这里只等待安装器本身。 $process.WaitForExit() return [int]$process.ExitCode } finally { if ($null -ne $process) { $process.Dispose() } } } function Complete-InstallerRun([int]$ExitCode, $Record, [string]$ReceiptPath, [string]$CacheDir) { if ($ExitCode -eq 3010) { Write-Warning 'Setup requires a manual Windows restart. No reboot was initiated; installation is not marked complete.' return 3010 } if ($ExitCode -eq 2) { throw 'Installation was cancelled. No successful installation was recorded.' } if ($ExitCode -ne 0) { throw "CloudNet installer exited with code $ExitCode." } $installed = Get-InstalledApplication if ($null -eq $installed -or !$installed.Valid -or $installed.Version -ne $Record.Version) { throw 'Setup exited successfully, but the installed version could not be verified.' } # 安装向导可能选择新的自定义目录,成功后按注册信息补充真实目录。 Add-InstallerDefenderExclusions $CacheDir $installed Set-InstallationReceipt $ReceiptPath $installed $Record Clear-InstallerCache $CacheDir Write-Host '>> CloudNet installation complete. Use the desktop app for service installation and network login.' return 0 } function Stop-DesktopProcesses([string]$Path, [int]$SessionId, [object[]]$Processes) { foreach ($process in $Processes) { try { if ($process.HasExited -or $process.SessionId -ne $SessionId) { continue } [void]$process.Handle if (!$process.Path) { throw 'Cannot verify the path of a running CloudNet process.' } if ($process.Path -ine $Path) { continue } $watch = [Diagnostics.Stopwatch]::StartNew() if ($process.CloseMainWindow() -and $process.WaitForExit(3000)) { continue } if (!$process.HasExited) { $process.Kill() } if (!$process.WaitForExit([Math]::Max(1, 10000 - [int]$watch.ElapsedMilliseconds))) { throw 'CloudNet GUI did not exit within 10 seconds.' } } catch { if (!$process.HasExited) { throw } } finally { $process.Dispose() } } } function Restart-DesktopApplication { $installed = Get-InstalledApplication if ($null -eq $installed -or !(Test-Path -LiteralPath $installed.Path -PathType Leaf)) { throw 'CloudNet desktop is not installed.' } $sessionId = (Get-Process -Id $PID).SessionId $processes = @(Get-Process -Name CloudNet -ErrorAction SilentlyContinue) Stop-DesktopProcesses $installed.Path $sessionId $processes Start-Process -FilePath $installed.Path -WorkingDirectory (Split-Path -Parent $installed.Path) Write-Host '>> CloudNet desktop reopened. Network services and Windows were not restarted.' } function Test-Administrator { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() try { return (New-Object Security.Principal.WindowsPrincipal($identity)).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) } finally { $identity.Dispose() } } function Assert-SupportedPlatform { $architecture = $env:PROCESSOR_ARCHITECTURE if ($env:PROCESSOR_ARCHITEW6432) { $architecture = $env:PROCESSOR_ARCHITEW6432 } if ($env:OS -ne 'Windows_NT' -or [Environment]::OSVersion.Version.Major -lt 10 -or $architecture -ne 'AMD64') { throw 'This installer requires Windows 10/11 x64.' } } function Invoke-ElevatedInstaller([string]$ScriptPath, [string]$Operation) { if (!$ScriptPath -or !(Test-Path -LiteralPath $ScriptPath -PathType Leaf)) { throw 'Open PowerShell as Administrator, then run: irm https://pkgs.cloudnet.world/stable/installer.ps1 | iex' } $process = $null try { $arguments = '-NoProfile -ExecutionPolicy Bypass -File "' + $ScriptPath + '" -Action ' + $Operation $start = New-Object Diagnostics.ProcessStartInfo $start.FileName = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" $start.Arguments = $arguments $start.UseShellExecute = $true $start.Verb = 'runas' $process = [Diagnostics.Process]::Start($start) $process.WaitForExit() return [int]$process.ExitCode } finally { if ($null -ne $process) { $process.Dispose() } } } function _main([string]$Operation, [bool]$Explicit, [bool]$ShowHelp, [string]$ScriptPath) { $operation = Resolve-InstallerAction $Operation $Explicit $ShowHelp if ($operation -eq 'help') { _usage; return 0 } Assert-SupportedPlatform if (!(Test-Administrator)) { return Invoke-ElevatedInstaller $ScriptPath $operation } if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { throw 'LOCALAPPDATA is unavailable.' } $cacheDir = Join-Path $env:LOCALAPPDATA 'CloudNet\InstallerCache' New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null $receiptPath = Join-Path $cacheDir 'installed.json' $lock = $null $client = $null $originalTls = [Net.ServicePointManager]::SecurityProtocol try { # 锁文件保留固定 inode,只有句柄持有期间才锁定;不删除其他进程可能已持有的锁。 $lock = Open-InstallerLock (Join-Path $cacheDir 'installer.lock') if ($operation -eq 'restart') { Restart-DesktopApplication; return 0 } Add-InstallerDefenderExclusions $cacheDir (Get-InstalledApplication) [Net.ServicePointManager]::SecurityProtocol = $originalTls -bor [Net.SecurityProtocolType]::Tls12 $client = New-InstallerHttpClient $artifact = Get-VerifiedInstallerArtifact $client $cacheDir $receiptPath if ($artifact.AlreadyInstalled) { Clear-InstallerCache $cacheDir Write-Host '>> CloudNet is already the latest verified installation.' return 0 } # 下载期间也可能通过其他途径安装了新版,再次检查,避免随后降级。 [void](Get-InstallDecision (Get-InstalledApplication) $artifact.Record $receiptPath) if (!(Test-InstallerArtifact $artifact.Path $artifact.Record)) { throw 'Installer changed after verification.' } Write-Host "==>> Installing CloudNet $($artifact.Record.Version)..." $exitCode = Invoke-InstallerProcess $artifact.Path return Complete-InstallerRun $exitCode $artifact.Record $receiptPath $cacheDir } finally { if ($null -ne $client) { $client.Dispose() } if ($null -ne $lock) { $lock.Dispose() } [Net.ServicePointManager]::SecurityProtocol = $originalTls } } # 点加载仅供隔离测试读取函数,不发起安装、下载或管理员请求。 if ($MyInvocation.InvocationName -ne '.') { $filePath = $MyInvocation.MyCommand.Path try { $result = _main $Action $PSBoundParameters.ContainsKey('Action') $Help.IsPresent $filePath $global:LASTEXITCODE = $result if ($filePath) { exit $result } } catch { $global:LASTEXITCODE = 1 Write-Error $_ -ErrorAction Continue if ($filePath) { exit 1 } } }