# 定盘星 客户端安装(便携版 → 托管布局,之后即可自动更新) # powershell -File scripts/install-client.ps1 # 从 API 清单下载最新版安装 # powershell -File scripts/install-client.ps1 -ApiBaseUrl http://127.0.0.1:8888 # powershell -File scripts/install-client.ps1 -FromDir artifacts\client\app -Version 2026.9.17.1 # # 安装布局(%LOCALAPPDATA%\Betwin\client): # versions\<版本>\ 代码(多文件) # runtime\.playwright\ 运行时层(Playwright 驱动 + 浏览器,跨版本共享) # current.json 当前版本指针 # 每个版本目录里的 .playwright 以 junction 指向共享运行时层,避免每版重复 ~450 MB。 param( [string]$ApiBaseUrl = "", [string]$FromDir = "", [string]$Version = "", [string]$Root = "", # 指纹浏览器运行时(174 MB)默认**不在安装时下载**:客户端会在首次用到浏览器功能时提示按需下载。 # 想一次装齐(离线部署 / 批量装机)再加这个开关。 [switch]$WithRuntime, [switch]$Shortcut ) $ErrorActionPreference = "Stop" function Get-Sha256Lower([string]$Path) { return (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLowerInvariant() } if ([string]::IsNullOrWhiteSpace($Root)) { $Root = Join-Path $env:LOCALAPPDATA "Betwin\client" } $versionsDir = Join-Path $Root "versions" $runtimeDir = Join-Path $Root "runtime" $downloadsDir = Join-Path $Root "downloads" foreach ($d in @($Root, $versionsDir, $runtimeDir, $downloadsDir)) { if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } } $manifest = $null $stage = Join-Path $env:TEMP ("betwin-install-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $stage -Force | Out-Null # ★ API 基线地址要在两个分支之前统一解析出来 —— 它既要用于下载,也要写进 api-url.txt。 # 客户端的解析顺序是「环境变量 BETWIN_API_URL → exe 同目录 api-url.txt → 内置生产地址 # https://fulcrum8.com」(见 ApiBaseUrlResolver)。api-url.txt 现在是**覆盖项**: # 万一漏写,客户端也只会落到内置生产地址,不会像以前那样安静地去连本机 8888 # ——那个坑真实发生过两次(第二次是服务端内嵌的安装脚本过期,波及所有一行命令安装的机器)。 # 但该写还是要写:它让"这台机器连的是哪台服务器"在磁盘上有据可查。 # ⚠️ Release 客户端只采纳**带发布方签名**的覆盖项(见 ApiBaseUrlResolver / ApiOverrideSignature): # 这里写的这份没有 sig= 行,所以正式版会忽略它、用内置生产地址 —— 因为写的本来就是生产地址, # 行为不变。真要指向别的环境,用 `ClientReleaseTool sign-url` 生成带 sig= 的文件(见 docs/ops/linux-deploy.md)。 if ([string]::IsNullOrWhiteSpace($ApiBaseUrl)) { $fromEnv = $env:BETWIN_API_URL if (-not [string]::IsNullOrWhiteSpace($fromEnv)) { $ApiBaseUrl = $fromEnv } else { $ApiBaseUrl = "http://127.0.0.1:8888" } } $ApiBaseUrl = $ApiBaseUrl.TrimEnd('/') try { if (-not [string]::IsNullOrWhiteSpace($FromDir)) { if (-not (Test-Path (Join-Path $FromDir "Betwin.exe"))) { throw "目录里没有 Betwin.exe:$FromDir" } if ([string]::IsNullOrWhiteSpace($Version)) { # ProductVersion 带「+提交号」后缀(DIAG-1)⇒ 去掉,版本目录名必须与发布清单一致 $Version = ((Get-Item (Join-Path $FromDir "Betwin.exe")).VersionInfo.ProductVersion -split '\+')[0] if ([string]::IsNullOrWhiteSpace($Version)) { throw "无法从 exe 读取版本号,请用 -Version 指定" } } Write-Host "==> 从本地目录安装 v$Version ..." -ForegroundColor Cyan Copy-Item (Join-Path $FromDir "*") $stage -Recurse -Force } else { Write-Host "==> 从 $ApiBaseUrl 获取发布清单 ..." -ForegroundColor Cyan $envelope = Invoke-RestMethod -Uri "$ApiBaseUrl/api/client/version" -TimeoutSec 15 if (-not $envelope.success) { throw "服务器未发布客户端更新包:$($envelope.message)" } $manifest = $envelope.content $Version = $manifest.version $packageUrl = $ApiBaseUrl + $manifest.url $zipPath = Join-Path $downloadsDir "Betwin-client-$Version.zip" Write-Host (" 下载 v{0}({1:N1} MB)..." -f $Version, ($manifest.size / 1MB)) Invoke-WebRequest -Uri $packageUrl -OutFile $zipPath -TimeoutSec 600 $hash = Get-Sha256Lower $zipPath if ($hash -ne $manifest.sha256) { throw "包体校验失败:期望 $($manifest.sha256),实际 $hash" } Write-Host " SHA-256 校验通过" -ForegroundColor Green Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $stage) Remove-Item $zipPath -Force } $target = Join-Path $versionsDir $Version if (Test-Path $target) { Remove-Item $target -Recurse -Force } New-Item -ItemType Directory -Path $target -Force | Out-Null Copy-Item (Join-Path $stage "*") $target -Recurse -Force Write-Host "==> 已安装代码到 $target" # API 基线地址写进版本目录(客户端 Release 构建读 exe 同目录的 api-url.txt)。 # UTF-8 **无 BOM**:客户端按文本读首行,BOM 会跟着进 URL。 # 自动更新时 ClientUpdatePackage.CloneWithHardLinks 是整目录克隆, # 这个文件会跟着带到新版本,所以只需要在安装时写一次。 $apiUrlFile = Join-Path $target "api-url.txt" [System.IO.File]::WriteAllText($apiUrlFile, "$ApiBaseUrl`r`n", (New-Object System.Text.UTF8Encoding($false))) Write-Host "==> API 地址已写入 api-url.txt:$ApiBaseUrl" # 运行时层(Playwright 驱动 + 浏览器,跨版本共享) # 包内根目录本身就是 .playwright\(发布脚本打包时 includeBaseDirectory=$false), # 所以必须解到 $runtimeDir 而不是 $runtimeDir\.playwright: # 解错一层会得到 runtime\.playwright\.playwright\node\...,联接永远建不起来。 $runtimePlaywright = Join-Path $runtimeDir ".playwright" $runtimeDriver = Join-Path $runtimePlaywright "node\win32_x64\node.exe" $runtimeCli = Join-Path $runtimePlaywright "package\cli.js" if ($WithRuntime -and -not (Test-Path $runtimeDriver)) { if ($manifest -and $manifest.runtimeUrl) { $runtimeZip = Join-Path $downloadsDir "Betwin-runtime-$($manifest.runtimeVersion).zip" Write-Host ("==> 下载运行时层({0:N1} MB,仅首次,请耐心等待)..." -f ($manifest.runtimeSize / 1MB)) -ForegroundColor Cyan Invoke-WebRequest -Uri ($ApiBaseUrl + $manifest.runtimeUrl) -OutFile $runtimeZip -TimeoutSec 3600 if ((Get-Sha256Lower $runtimeZip) -ne $manifest.runtimeSha256) { throw "运行时包校验失败" } Remove-Item $runtimePlaywright -Recurse -Force -ErrorAction SilentlyContinue Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::ExtractToDirectory($runtimeZip, $runtimeDir) Remove-Item $runtimeZip -Force # 驱动是「node.exe + package\cli.js」两件套,缺一不可——只验 node.exe 会漏掉驱不起来的情况 if (-not (Test-Path $runtimeDriver)) { throw "运行时包内缺少 Playwright 驱动:$runtimeDriver" } if (-not (Test-Path $runtimeCli)) { throw "运行时包内缺少 Playwright 驱动脚本:$runtimeCli" } Write-Host " 运行时层就绪(驱动 + 浏览器)" -ForegroundColor Green } else { Write-Host " 提示:清单未提供运行时层,无法随装;客户端首次用到浏览器功能时会提示下载" -ForegroundColor Yellow } } else { if (Test-Path $runtimeDriver) { Write-Host "==> 运行时层已存在,跳过下载" } else { # 默认路径:安装保持轻量(84 MB)。指纹浏览器运行时由客户端在首次用到时按需下载, # 用不上浏览器功能的用户就省下这 174 MB。要一次装齐(离线部署/批量装机)加 -WithRuntime。 Write-Host "==> 跳过指纹浏览器运行时(174 MB):客户端首次用到浏览器功能时会提示按需下载" -ForegroundColor DarkGray } } # 版本目录里的 .playwright 指向共享运行时层(junction,免管理员) $link = Join-Path $target ".playwright" if (Test-Path $link) { cmd /c rmdir "$link" 2>$null | Out-Null } if (Test-Path (Join-Path $runtimePlaywright "node\win32_x64\node.exe")) { cmd /c mklink /J "$link" "$runtimePlaywright" | Out-Null Write-Host "==> 已建立运行时联接:$link → $runtimePlaywright" } # 切换当前版本(UTF-8 无 BOM 的 current.json,与客户端读写一致) $currentJson = "{`n `"version`": `"$Version`",`n `"channel`": `"stable`"`n}" [System.IO.File]::WriteAllText((Join-Path $Root "current.json"), $currentJson, (New-Object System.Text.UTF8Encoding($false))) $exe = Join-Path $target "Betwin.exe" # 稳定启动器:读 current.json 再启动对应版本目录的 exe。 # 直接给版本目录的 exe 建快捷方式是不行的——更新后 current.json 指向新目录, # 旧快捷方式会把用户永远拉回旧版本。 $launcher = Join-Path $Root "start-betwin.cmd" $launcherLines = @( '@echo off', 'setlocal', 'for /f "usebackq tokens=2 delims=:, " %%v in (`findstr /i "\"version\"" "%~dp0current.json"`) do set VER=%%v', 'set VER=%VER:"=%', 'start "" "%~dp0versions\%VER%\Betwin.exe"' ) [System.IO.File]::WriteAllLines($launcher, $launcherLines, [System.Text.ASCIIEncoding]::new()) Write-Host "" Write-Host "安装完成:v$Version" -ForegroundColor Green Write-Host "启动:$launcher(会按 current.json 启动当前版本;不要直接给 versions\<版本>\Betwin.exe 建快捷方式)" if ($Shortcut) { $desktop = [Environment]::GetFolderPath("Desktop") $lnk = Join-Path $desktop "定盘星.lnk" # 更名前的快捷方式要清掉,否则老用户桌面上会同时出现「Betwin」和「定盘星」两个图标 $legacy = Join-Path $desktop "Betwin.lnk" if (Test-Path $legacy) { Remove-Item $legacy -Force -ErrorAction SilentlyContinue Write-Host "已移除旧快捷方式:$legacy" } # 图标必须指向**稳定文件**:写成 versions\<版本>\Betwin.exe 的话,自动更新清理旧版本目录后 # 桌面图标就变成白板(快捷方式本身指向启动器,还能启动,只是图标没了)。 # 复制一份到 \Betwin.ico —— 那个位置不在版本清理范围内;客户端启动时也会自愈一次。 $icon = Join-Path $Root "Betwin.ico" $iconSource = Join-Path $target "Betwin.ico" if (Test-Path $iconSource) { Copy-Item $iconSource $icon -Force } elseif (-not (Test-Path $icon)) { Write-Host "提示:发布包里没有 Betwin.ico,快捷方式暂时沿用 exe 自带图标(升级到带图标的版本后会自动补上)" -ForegroundColor Yellow } $shell = New-Object -ComObject WScript.Shell $sc = $shell.CreateShortcut($lnk) $sc.TargetPath = $launcher $sc.WorkingDirectory = $Root $sc.WindowStyle = 7 # 最小化,避免命令行窗口闪在桌面中央 $sc.IconLocation = if (Test-Path $icon) { "$icon,0" } else { "$exe,0" } $sc.Save() Write-Host "已创建桌面快捷方式:$lnk(指向启动器,升级后依然可用)" } } finally { Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue }