# Monkey Server per-user bootstrap. PowerShell 5.1+ / 7. [CmdletBinding()] param( [string]$ApiBase = 'https://monkeysv.xyz', [string]$InstallRoot = '', [ValidateSet('Run', 'Diagnose', 'Preview', 'SessionCheck')] [string]$Mode = 'Run', [switch]$NoLaunch, [switch]$AllowLoopbackHttp ) & { param( [string]$ApiBase, [string]$InstallRoot, [string]$Mode, [bool]$NoLaunch, [bool]$AllowLoopbackHttp ) $ErrorActionPreference = 'Stop' $MaxManifestBytes = [int64]2 * 1024 * 1024 $MaxPackageBytes = [int64]512 * 1024 * 1024 $MaxExpandedBytes = [int64]512 * 1024 * 1024 $MaxZipEntries = 2048 $HttpTimeoutSeconds = 30 $SemVerPattern = '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' function Throw-InstallerError([string]$Message, [string]$Kind = 'Validation') { $exception = New-Object -TypeName System.Exception -ArgumentList $Message $exception.Data['Kind'] = $Kind throw $exception } function Get-ErrorKind($ErrorRecord) { if ($null -ne $ErrorRecord -and $null -ne $ErrorRecord.Exception -and $ErrorRecord.Exception.Data.Contains('Kind')) { return [string]$ErrorRecord.Exception.Data['Kind'] } return '' } function Get-Sha256Hex([string]$Path) { $hash = [Security.Cryptography.SHA256]::Create() $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) try { return ([BitConverter]::ToString($hash.ComputeHash($stream))).Replace('-', '').ToLowerInvariant() } finally { $stream.Dispose(); $hash.Dispose() } } function Get-BytesSha256Hex([byte[]]$Bytes) { $hash = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString($hash.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } finally { $hash.Dispose() } } function Get-AbsolutePath([string]$Path) { try { return [IO.Path]::GetFullPath($Path) } catch { Throw-InstallerError "InstallRoot inválido: $Path" } } function Normalize-BaseUri([string]$Value, [bool]$AllowLoopback) { try { $uri = New-Object Uri($Value) } catch { Throw-InstallerError "ApiBase não é uma URI absoluta válida." } if (-not $uri.IsAbsoluteUri -or $uri.UserInfo -or $uri.Query -or $uri.Fragment) { Throw-InstallerError 'ApiBase deve ser uma origem sem credenciais, query ou fragmento.' } if ($uri.AbsolutePath -ne '/' -and $uri.AbsolutePath -ne '') { Throw-InstallerError 'ApiBase deve apontar para a origem, sem caminho adicional.' } $hostName = $uri.DnsSafeHost.ToLowerInvariant() if ($uri.Scheme -eq 'http') { if (-not $AllowLoopback -or ($hostName -ne '127.0.0.1' -and $hostName -ne 'localhost')) { Throw-InstallerError 'HTTP só é permitido com -AllowLoopbackHttp em 127.0.0.1 ou localhost.' } } elseif ($uri.Scheme -ne 'https') { Throw-InstallerError 'ApiBase deve usar HTTPS.' } $port = '' if ($uri.Port -gt 0 -and (($uri.Scheme -eq 'https' -and $uri.Port -ne 443) -or ($uri.Scheme -eq 'http' -and $uri.Port -ne 80))) { $port = ':' + $uri.Port } return ($uri.Scheme.ToLowerInvariant() + '://' + $hostName + $port) } function Assert-SafeRelativePath([string]$Path, [bool]$Directory = $false) { if ([string]::IsNullOrWhiteSpace($Path) -or $Path.IndexOf([char]0) -ge 0 -or $Path.IndexOf('\') -ge 0 -or $Path.IndexOf(':') -ge 0) { Throw-InstallerError "Caminho ZIP/manifesto inseguro: $Path" } $clean = $Path if ($Directory) { $clean = $clean.TrimEnd('/') } if ([string]::IsNullOrWhiteSpace($clean) -or $clean.StartsWith('/') -or [IO.Path]::IsPathRooted($clean) -or $clean -match '(^|/)\.\.?(/|$)') { Throw-InstallerError "Caminho ZIP/manifesto inseguro: $Path" } foreach ($part in $clean.Split('/')) { if ([string]::IsNullOrWhiteSpace($part) -or $part.EndsWith('.') -or $part.EndsWith(' ')) { Throw-InstallerError "Segmento de caminho inseguro: $Path" } if ($part -match '^(?i:con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)') { Throw-InstallerError "Nome reservado no caminho: $Path" } } } function Get-ManifestLong($Value, [string]$Label, [int64]$Minimum = 0) { try { $number = [Int64]::Parse([string]$Value, [Globalization.NumberStyles]::Integer, [Globalization.CultureInfo]::InvariantCulture) } catch { Throw-InstallerError "$Label deve ser um inteiro." } if ($number -lt $Minimum) { Throw-InstallerError "$Label tem valor inválido." } return $number } function Assert-Hash([string]$Hash, [string]$Label) { if ($Hash -notmatch '^[0-9A-Fa-f]{64}$') { Throw-InstallerError "$Label não é SHA-256 hexadecimal." } } function Get-ManifestMap($Manifest) { $map = @{} foreach ($entry in @($Manifest.files)) { $path = [string]$entry.path $key = $path.ToUpperInvariant() if ($map.ContainsKey($key)) { Throw-InstallerError "Colisão de caminho no manifesto: $path" } $map[$key] = $entry } return $map } function Validate-Manifest($Manifest, [string]$ExpectedApi) { if ($null -eq $Manifest) { Throw-InstallerError 'Manifesto vazio.' } if ((Get-ManifestLong $Manifest.schema_version 'schema_version') -ne 1) { Throw-InstallerError 'schema_version incompatível.' } $version = [string]$Manifest.version if ($version -notmatch $SemVerPattern) { Throw-InstallerError "Versão semver inválida: $version" } if ([string]$Manifest.platform -cne 'windows' -or [string]$Manifest.architecture -cne 'x64') { Throw-InstallerError 'Manifesto não é para Windows x64.' } if ([string]$Manifest.entry_point -cne 'Zes.Supervisor.exe') { Throw-InstallerError 'entry_point inválido.' } $manifestApi = Normalize-BaseUri ([string]$Manifest.api_base) $AllowLoopbackHttp if ($manifestApi -cne $ExpectedApi) { Throw-InstallerError 'api_base do manifesto não corresponde à origem solicitada.' } try { [DateTimeOffset]::Parse([string]$Manifest.published_at, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind) | Out-Null } catch { Throw-InstallerError 'published_at não é ISO-8601 válido.' } if ($null -eq $Manifest.package) { Throw-InstallerError 'package ausente.' } $packageName = [string]$Manifest.package.file $expectedName = "monkey-server-$version-windows-x64.zip" if ($packageName -cne $expectedName) { Throw-InstallerError 'package.file não corresponde à versão.' } Assert-SafeRelativePath $packageName $false $packageSize = Get-ManifestLong $Manifest.package.size_bytes 'package.size_bytes' 1 if ($packageSize -gt $MaxPackageBytes) { Throw-InstallerError 'package.size_bytes excede o limite.' } Assert-Hash ([string]$Manifest.package.sha256) 'package.sha256' $entries = @($Manifest.files) if ($entries.Count -lt 4 -or $entries.Count -gt $MaxZipEntries) { Throw-InstallerError 'Quantidade de arquivos inválida no manifesto.' } $map = Get-ManifestMap $Manifest foreach ($required in @('Zes.Supervisor.exe', 'Zes.Renderer.exe', 'Zes.Watchdog.exe')) { if (-not $map.ContainsKey($required.ToUpperInvariant()) -or [string]$map[$required.ToUpperInvariant()].path -cne $required) { Throw-InstallerError "Arquivo obrigatório ausente: $required" } } $assetCount = 0 foreach ($entry in $entries) { $path = [string]$entry.path Assert-SafeRelativePath $path $false if ($path -ceq 'manifest.json') { Throw-InstallerError 'manifest.json é reservado pelo instalador.' } Get-ManifestLong $entry.size_bytes "size_bytes de $path" 1 | Out-Null Assert-Hash ([string]$entry.sha256) "sha256 de $path" if ($path.StartsWith('assets/', [StringComparison]::Ordinal)) { $assetCount++ } } if ($assetCount -lt 1) { Throw-InstallerError 'Manifesto precisa conter ao menos um asset real.' } return $Manifest } function Read-ManifestBytes([byte[]]$Bytes, [string]$ExpectedApi) { try { $manifest = [Text.Encoding]::UTF8.GetString($Bytes) | ConvertFrom-Json } catch { Throw-InstallerError 'Manifesto não é JSON válido.' } Validate-Manifest $manifest $ExpectedApi | Out-Null return [pscustomobject]@{ Object = $manifest; Raw = $Bytes; Sha256 = Get-BytesSha256Hex $Bytes } } function New-WebRequest([string]$Url) { $request = [Net.WebRequest]::Create($Url) if ($request -is [Net.HttpWebRequest]) { $request.Method = 'GET'; $request.Timeout = $HttpTimeoutSeconds * 1000; $request.ReadWriteTimeout = $HttpTimeoutSeconds * 1000 $request.AllowAutoRedirect = $false; $request.UserAgent = 'MonkeyServerBootstrap/1' } return $request } function Read-WebResponse([string]$Url, [string]$Destination, [int64]$MaxBytes) { $request = New-WebRequest $Url $response = $null; $inputStream = $null; $outputStream = $null try { $response = $request.GetResponse() if ($response.StatusCode -ne 200) { Throw-InstallerError "HTTP $([int]$response.StatusCode) ao baixar $Url" 'Network' } if ($response.ContentLength -gt $MaxBytes) { Throw-InstallerError 'Resposta excede o limite permitido.' } $inputStream = $response.GetResponseStream() if ($Destination) { $outputStream = [IO.File]::Open($Destination, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) } else { $outputStream = New-Object IO.MemoryStream } $buffer = New-Object byte[] 81920; $total = [int64]0 while (($read = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { $total += $read if ($total -gt $MaxBytes) { Throw-InstallerError 'Resposta excede o limite permitido.' } $outputStream.Write($buffer, 0, $read) } if ($Destination) { $outputStream.Dispose(); $outputStream = $null; return $total } $bytes = $outputStream.ToArray(); $outputStream.Dispose(); $outputStream = $null; return ,$bytes } catch [Net.WebException] { Throw-InstallerError ("Falha de rede ao acessar {0}: {1}" -f $Url, $_.Exception.Message) 'Network' } finally { if ($null -ne $inputStream) { $inputStream.Dispose() } if ($null -ne $outputStream) { $outputStream.Dispose() } if ($null -ne $response) { $response.Dispose() } } } function Write-AtomicBytes([string]$Path, [byte[]]$Bytes) { $temp = "$Path.$([Guid]::NewGuid().ToString('N')).tmp" try { [IO.File]::WriteAllBytes($temp, $Bytes); Move-Item -LiteralPath $temp -Destination $Path -Force | Out-Null } finally { if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } } } function Write-AtomicText([string]$Path, [string]$Text) { $temp = "$Path.$([Guid]::NewGuid().ToString('N')).tmp" $utf8 = New-Object Text.UTF8Encoding($false) try { [IO.File]::WriteAllText($temp, $Text, $utf8); Move-Item -LiteralPath $temp -Destination $Path -Force | Out-Null } finally { if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } } } function Assert-NoReparse([string]$Path) { $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { Throw-InstallerError "Reparse point/symlink recusado: $Path" } } function Get-RelativePath([string]$Root, [string]$Path) { $rootFull = (Get-AbsolutePath $Root).TrimEnd('\') + '\' $full = Get-AbsolutePath $Path if (-not $full.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase)) { Throw-InstallerError 'Caminho extraído saiu do staging.' } return $full.Substring($rootFull.Length).Replace('\', '/') } function Expand-SafeZip([string]$ZipPath, [string]$Destination, $Manifest) { try { Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop } catch { Throw-InstallerError 'System.IO.Compression não está disponível.' } $archive = $null; $seen = @{}; $expanded = [int64]0; $manifestMap = Get-ManifestMap $Manifest try { $archive = [IO.Compression.ZipFile]::OpenRead($ZipPath) if ($archive.Entries.Count -gt $MaxZipEntries) { Throw-InstallerError 'ZIP excede o limite de entradas.' } foreach ($entry in $archive.Entries) { $name = [string]$entry.FullName $isDirectory = $name.EndsWith('/') Assert-SafeRelativePath $name $isDirectory $key = $name.TrimEnd('/').ToUpperInvariant() if ($seen.ContainsKey($key)) { Throw-InstallerError "Colisão de caminho no ZIP: $name" } $seen[$key] = $true $attrs = [int64]$entry.ExternalAttributes if ($attrs -lt 0) { $attrs += 0x100000000 } $unixType = ($attrs -shr 16) -band 0xF000 if ($unixType -eq 0xA000 -or (($attrs -band 0x400) -ne 0)) { Throw-InstallerError "Symlink/reparse no ZIP: $name" } if ($isDirectory) { $prefix = $name.TrimEnd('/') + '/' $hasChild = $false foreach ($expected in $manifestMap.Keys) { if ($expected.StartsWith($prefix.ToUpperInvariant(), [StringComparison]::OrdinalIgnoreCase)) { $hasChild = $true; break } } if (-not $hasChild) { Throw-InstallerError "Diretório inesperado no ZIP: $name" } continue } if (-not $manifestMap.ContainsKey($key) -or [string]$manifestMap[$key].path -cne $name) { Throw-InstallerError "Arquivo inesperado no ZIP: $name" } $length = [int64]$entry.Length if ($length -lt 1 -or $length -gt $MaxExpandedBytes -or ($expanded + $length) -gt $MaxExpandedBytes) { Throw-InstallerError 'ZIP excede o limite expandido.' } $expanded += $length $target = Join-Path $Destination ($name.Replace('/', '\')) $parent = Split-Path -Parent $target New-Item -ItemType Directory -Force -Path $parent | Out-Null Assert-NoReparse $parent $source = $null; $output = $null; $written = [int64]0 try { $source = $entry.Open() $output = [IO.File]::Open($target, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) $buffer = New-Object byte[] 81920 while (($read = $source.Read($buffer, 0, $buffer.Length)) -gt 0) { $written += $read; if ($written -gt $length) { Throw-InstallerError 'ZIP produziu bytes além do tamanho declarado.' }; $output.Write($buffer, 0, $read) } if ($written -ne $length) { Throw-InstallerError "Tamanho extraído divergente: $name" } } finally { if ($null -ne $source) { $source.Dispose() }; if ($null -ne $output) { $output.Dispose() } } Assert-NoReparse $target } } catch [IO.Compression.InvalidDataException] { Throw-InstallerError 'ZIP inválido.' } finally { if ($null -ne $archive) { $archive.Dispose() } } } function Test-PeFile([string]$Path) { $stream = [IO.File]::OpenRead($Path); $reader = New-Object IO.BinaryReader($stream) try { if ($stream.Length -lt 64 -or $reader.ReadUInt16() -ne 0x5A4D) { return $false } $stream.Position = 0x3C; $offset = $reader.ReadInt32() if ($offset -lt 0 -or $offset + 4 -gt $stream.Length) { return $false } $stream.Position = $offset; return ($reader.ReadUInt32() -eq 0x00004550) } finally { $reader.Dispose(); $stream.Dispose() } } function Test-VersionContents([string]$VersionDirectory, $Manifest, [bool]$AllowManifest = $false) { Assert-NoReparse $VersionDirectory $manifestMap = Get-ManifestMap $Manifest; $actual = @{} foreach ($item in @(Get-ChildItem -LiteralPath $VersionDirectory -Recurse -Force)) { Assert-NoReparse $item.FullName if ($item.PSIsContainer) { continue } $relative = Get-RelativePath $VersionDirectory $item.FullName if ($AllowManifest -and $relative -ceq 'manifest.json') { continue } $key = $relative.ToUpperInvariant() if ($actual.ContainsKey($key)) { Throw-InstallerError "Colisão de arquivo instalado: $relative" } $actual[$key] = $relative if (-not $manifestMap.ContainsKey($key) -or [string]$manifestMap[$key].path -cne $relative) { Throw-InstallerError "Arquivo inesperado instalado: $relative" } $expected = $manifestMap[$key] if ((Get-Item -LiteralPath $item.FullName).Length -ne (Get-ManifestLong $expected.size_bytes "size_bytes de $relative")) { Throw-InstallerError "Tamanho divergente: $relative" } if ((Get-Sha256Hex $item.FullName) -cne ([string]$expected.sha256).ToLowerInvariant()) { Throw-InstallerError "Hash divergente: $relative" } } if ($actual.Count -ne $manifestMap.Count) { Throw-InstallerError 'Arquivos do diretório não correspondem ao manifesto.' } foreach ($required in @('Zes.Supervisor.exe', 'Zes.Renderer.exe', 'Zes.Watchdog.exe')) { if (-not (Test-PeFile (Join-Path $VersionDirectory $required))) { Throw-InstallerError "Executável inválido: $required" } } return $true } function Read-JsonFile([string]$Path, [string]$Label) { try { return (Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json) } catch { Throw-InstallerError "$Label não é JSON válido." } } function Read-Pointer([string]$Path) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } $pointer = Read-JsonFile $Path 'Ponteiro local' $version = [string]$pointer.version if ($version -notmatch $SemVerPattern) { Throw-InstallerError "Ponteiro inválido: $Path" } Assert-Hash ([string]$pointer.manifest_sha256) 'manifest_sha256 do ponteiro' return $pointer } function Read-PersistedManifest([string]$VersionDirectory, [string]$ExpectedApi) { $path = Join-Path $VersionDirectory 'manifest.json' if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Throw-InstallerError "Manifesto local ausente: $path" } $bytes = [IO.File]::ReadAllBytes($path); return Read-ManifestBytes $bytes $ExpectedApi } function Compare-Manifests($Left, $Right) { if ([string]$Left.Object.version -cne [string]$Right.Object.version -or [string]$Left.Object.package.sha256 -cne [string]$Right.Object.package.sha256 -or (Get-ManifestLong $Left.Object.package.size_bytes 'package.size_bytes') -ne (Get-ManifestLong $Right.Object.package.size_bytes 'package.size_bytes')) { return $false } $leftMap = Get-ManifestMap $Left.Object; $rightMap = Get-ManifestMap $Right.Object if ($leftMap.Count -ne $rightMap.Count) { return $false } foreach ($key in $leftMap.Keys) { if (-not $rightMap.ContainsKey($key) -or [string]$leftMap[$key].path -cne [string]$rightMap[$key].path -or [string]$leftMap[$key].sha256 -cne [string]$rightMap[$key].sha256 -or (Get-ManifestLong $leftMap[$key].size_bytes 'size_bytes') -ne (Get-ManifestLong $rightMap[$key].size_bytes 'size_bytes')) { return $false } } return $true } function Get-VerifiedCurrent([string]$Root, [string]$ExpectedApi) { $pointer = Read-Pointer (Join-Path $Root 'current.json') if ($null -eq $pointer) { Throw-InstallerError 'Não existe versão current confiável.' } $directory = Join-Path (Join-Path $Root 'versions') ([string]$pointer.version) if (-not (Test-Path -LiteralPath $directory -PathType Container)) { Throw-InstallerError 'Diretório current ausente.' } $local = Read-PersistedManifest $directory $ExpectedApi if ([string]$local.Object.version -cne [string]$pointer.version) { Throw-InstallerError 'current.json não corresponde ao manifesto local.' } if ([string]$local.Sha256 -cne ([string]$pointer.manifest_sha256).ToLowerInvariant()) { Throw-InstallerError 'current.json não corresponde ao hash do manifesto local.' } Test-VersionContents $directory $local.Object $true | Out-Null return [pscustomobject]@{ Pointer = $pointer; Directory = $directory; Manifest = $local } } function Assert-ConfigUsable([string]$Root) { $path = Join-Path (Join-Path $Root 'data') 'config.json' if (Test-Path -LiteralPath $path -PathType Leaf) { Assert-NoReparse $path $config = Read-JsonFile $path 'data/config.json' if ($config -is [Array] -or $null -eq $config) { Throw-InstallerError 'data/config.json deve ser um objeto JSON.' } } } function Ensure-Config([string]$Root, $Manifest) { $data = Join-Path $Root 'data'; New-Item -ItemType Directory -Force -Path $data | Out-Null $path = Join-Path $data 'config.json' if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { $config = [ordered]@{ version = 1 api_url = (Normalize-BaseUri ([string]$Manifest.api_base) $AllowLoopbackHttp) intro_enabled = $true audio_enabled = $false reduce_motion = $false background_music_enabled = $true background_music_file = '' background_music_volume = 0.03 } Write-AtomicText $path ($config | ConvertTo-Json -Depth 10) return } Assert-NoReparse $path; $config = Read-JsonFile $path 'data/config.json' if ($config -is [Array] -or $null -eq $config) { Throw-InstallerError 'data/config.json deve ser um objeto JSON.' } $api = [string]$config.api_url; $desired = Normalize-BaseUri ([string]$Manifest.api_base) $AllowLoopbackHttp if ($api -cne $desired) { $backup = "$path.bak-$([DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ'))-$([Guid]::NewGuid().ToString('N'))" Copy-Item -LiteralPath $path -Destination $backup -Force if ($null -eq $config.PSObject.Properties['api_url']) { Add-Member -InputObject $config -MemberType NoteProperty -Name api_url -Value $desired } else { $config.api_url = $desired } Write-AtomicText $path ($config | ConvertTo-Json -Depth 20) } } function Commit-Pointers([string]$Root, $OldCurrent, [string]$Version, [string]$ManifestSha256) { $currentPath = Join-Path $Root 'current.json'; $previousPath = Join-Path $Root 'previous.json' $oldCurrentBytes = if (Test-Path -LiteralPath $currentPath -PathType Leaf) { [IO.File]::ReadAllBytes($currentPath) } else { $null } $oldPreviousBytes = if (Test-Path -LiteralPath $previousPath -PathType Leaf) { [IO.File]::ReadAllBytes($previousPath) } else { $null } $current = [ordered]@{ version = $Version; manifest_sha256 = $ManifestSha256 } try { if ($null -ne $OldCurrent -and [string]$OldCurrent.version -cne $Version) { $previous = [ordered]@{ version = [string]$OldCurrent.version; manifest_sha256 = [string]$OldCurrent.manifest_sha256 } Write-AtomicText $previousPath ($previous | ConvertTo-Json -Compress) } Write-AtomicText $currentPath ($current | ConvertTo-Json -Compress) } catch { if ($null -eq $oldCurrentBytes) { Remove-Item -LiteralPath $currentPath -Force -ErrorAction SilentlyContinue } else { [IO.File]::WriteAllBytes($currentPath, $oldCurrentBytes) } if ($null -eq $oldPreviousBytes) { Remove-Item -LiteralPath $previousPath -Force -ErrorAction SilentlyContinue } else { [IO.File]::WriteAllBytes($previousPath, $oldPreviousBytes) } throw } } function New-MutexName([string]$Root) { $bytes = [Text.Encoding]::UTF8.GetBytes($Root.ToUpperInvariant()); $hash = [Security.Cryptography.SHA256]::Create() try { return 'Local\MonkeyServerInstaller-' + ([BitConverter]::ToString($hash.ComputeHash($bytes))).Replace('-', '').Substring(0, 32) } finally { $hash.Dispose() } } function Start-InstalledProcess([string]$Directory, $Manifest, [string]$DataDirectory, [string]$LaunchMode) { $entryName = if ($LaunchMode -eq 'Preview') { 'Zes.Renderer.exe' } else { [string]$Manifest.entry_point } $entry = Join-Path $Directory $entryName if (-not (Test-Path -LiteralPath $entry -PathType Leaf)) { Throw-InstallerError 'entry_point instalado ausente.' } $argument = switch ($LaunchMode) { 'Run' { '--run'; break } 'Diagnose' { '--diagnose'; break } 'Preview' { '--preview'; break } 'SessionCheck' { '--session-check'; break } } if ($LaunchMode -eq 'Run') { Write-Warning 'Run iniciará o supervisor e pode trocar a desktop visível do Windows.' } $info = New-Object Diagnostics.ProcessStartInfo $info.FileName = $entry; $info.Arguments = $argument; $info.WorkingDirectory = $Directory; $info.UseShellExecute = $false; $info.CreateNoWindow = $true $info.EnvironmentVariables['ZES_DATA_DIR'] = $DataDirectory $process = [Diagnostics.Process]::Start($info) if ($null -eq $process) { Throw-InstallerError 'Não foi possível iniciar o supervisor.' } Write-Host "Monkey Server $LaunchMode iniciado (PID=$($process.Id))." $process.Dispose() } function Invoke-MonkeyInstaller { if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitOperatingSystem -or [Environment]::OSVersion.Version.Major -lt 10 -or $env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { Throw-InstallerError 'Monkey Server requer Windows 10/11 x64.' } $root = if ([string]::IsNullOrWhiteSpace($InstallRoot)) { Join-Path $env:LOCALAPPDATA 'MonkeyServer' } else { $InstallRoot } if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA) -and [string]::IsNullOrWhiteSpace($InstallRoot)) { Throw-InstallerError 'LOCALAPPDATA não está definido.' } $root = Get-AbsolutePath $root; $api = Normalize-BaseUri $ApiBase $AllowLoopbackHttp New-Item -ItemType Directory -Force -Path $root | Out-Null; Assert-NoReparse $root $versions = Join-Path $root 'versions'; $stagingRoot = Join-Path $root 'staging'; New-Item -ItemType Directory -Force -Path $versions, $stagingRoot | Out-Null Assert-NoReparse $versions; Assert-NoReparse $stagingRoot Assert-ConfigUsable $root $mutex = New-Object Threading.Mutex($false, (New-MutexName $root)); $held = $false; $stage = $null; $packagePath = $null $oldTls = [Net.ServicePointManager]::SecurityProtocol try { try { $held = $mutex.WaitOne(0) } catch [Threading.AbandonedMutexException] { $held = $true } if (-not $held) { Throw-InstallerError 'Outra instalação do Monkey Server já está em andamento.' 'Busy' } if (($oldTls -band [Net.SecurityProtocolType]::Tls12) -eq 0) { [Net.ServicePointManager]::SecurityProtocol = $oldTls -bor [Net.SecurityProtocolType]::Tls12 } $oldCurrent = Read-Pointer (Join-Path $root 'current.json') try { $manifestUrl = $api + '/api/v1/releases/latest'; $manifestBytes = Read-WebResponse $manifestUrl $null $MaxManifestBytes $remote = Read-ManifestBytes $manifestBytes $api } catch { if ((Get-ErrorKind $_) -eq 'Network') { $fallback = Get-VerifiedCurrent $root $api Ensure-Config $root $fallback.Manifest.Object Write-Warning 'Servidor indisponível; usando a versão current previamente verificada.' if (-not $NoLaunch) { Start-InstalledProcess $fallback.Directory $fallback.Manifest.Object (Join-Path $root 'data') $Mode } return } throw } $target = Join-Path $versions ([string]$remote.Object.version); $targetExists = Test-Path -LiteralPath $target -PathType Container if ($targetExists) { $local = Read-PersistedManifest $target $api if (-not (Compare-Manifests $local $remote)) { Throw-InstallerError 'Versão imutável já existente não corresponde ao manifesto remoto.' } Test-VersionContents $target $local.Object $true | Out-Null } else { $guid = [Guid]::NewGuid().ToString('N'); $stage = Join-Path $stagingRoot ('stage-' + $guid); $packagePath = Join-Path $stagingRoot ($guid + '.zip') New-Item -ItemType Directory -Force -Path $stage | Out-Null $packageUrl = $api + '/releases/' + $remote.Object.version + '/' + $remote.Object.package.file Read-WebResponse $packageUrl $packagePath (Get-ManifestLong $remote.Object.package.size_bytes 'package.size_bytes') | Out-Null if ((Get-Item -LiteralPath $packagePath).Length -ne (Get-ManifestLong $remote.Object.package.size_bytes 'package.size_bytes')) { Throw-InstallerError 'Tamanho do download diverge do manifesto.' } if ((Get-Sha256Hex $packagePath) -cne ([string]$remote.Object.package.sha256).ToLowerInvariant()) { Throw-InstallerError 'Hash do download diverge do manifesto.' } Expand-SafeZip $packagePath $stage $remote.Object Test-VersionContents $stage $remote.Object $false | Out-Null Write-AtomicBytes (Join-Path $stage 'manifest.json') $remote.Raw Test-VersionContents $stage $remote.Object $true | Out-Null if (Test-Path -LiteralPath $target) { Throw-InstallerError 'Versão apareceu durante a instalação.' 'Busy' } [IO.Directory]::Move($stage, $target); $stage = $null; $targetExists = $true } $manifestOnDisk = Read-PersistedManifest $target $api Ensure-Config $root $remote.Object if ($null -eq $oldCurrent -or [string]$oldCurrent.version -cne [string]$remote.Object.version) { Commit-Pointers $root $oldCurrent ([string]$remote.Object.version) $manifestOnDisk.Sha256 } if (-not $NoLaunch) { Start-InstalledProcess $target $remote.Object (Join-Path $root 'data') $Mode } Write-Host "Monkey Server versão $($remote.Object.version) pronta em $target." } finally { if ($null -ne $stage -and (Test-Path -LiteralPath $stage)) { Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue } if ($null -ne $packagePath -and (Test-Path -LiteralPath $packagePath)) { Remove-Item -LiteralPath $packagePath -Force -ErrorAction SilentlyContinue } [Net.ServicePointManager]::SecurityProtocol = $oldTls if ($held) { $mutex.ReleaseMutex() } $mutex.Dispose() } } Invoke-MonkeyInstaller } -ApiBase $ApiBase -InstallRoot $InstallRoot -Mode $Mode -NoLaunch:$NoLaunch -AllowLoopbackHttp:$AllowLoopbackHttp