diff --git a/docs/10-lan-update.md b/docs/10-lan-update.md index e45076a..97b0897 100644 --- a/docs/10-lan-update.md +++ b/docs/10-lan-update.md @@ -253,8 +253,8 @@ HTTP 在线更新比内网共享面临更高风险,按以下层次防护: 1. **地基**:数据目录分离(第 5 节)。任何更新方案的前提。✅ 已实现。 2. **只读通知**:启动时读取 `manifest.json` 比对版本,有新版仅提示。✅ 已实现——`services/update_service.py`(`check_for_update` / 版本比较,纯逻辑可测,支持 **`http(s)://` 源 + Basic Auth** 及本地路径;manifest 用 `utf-8-sig` 解码以容忍 BOM)+ 主窗口顶部通知横幅,后台线程检查(不可达不阻塞启动),更新源 / 凭据由 `update_source` / `update_user` / `update_pass` 配置。 -3. **自动安装(PowerShell 原型)**:启动器完整流程(下载 → 校验 → `app` 目录切换 → 启动 → `app.old` 回滚)。✅ 以 `scripts/update.ps1` 实现并端到端验证(HTTP 下载 zip + SHA-256 + 解压 + `app/app.old` 切换);`scripts/build.ps1` 产出 `version.txt` + zip + 写无 BOM `manifest.json`。**此为流程验证原型。** -4. **改用 `Launcher.exe` + `~/.cmbot`(当前方向)**:🚧 将启动器改为 **Python + PyInstaller onefile** 编译的 `Launcher.exe`(复用 `services/update_service.py`),采用**便携布局**(解压任意可写目录即用,安装根 = `Launcher.exe` 所在目录),用户数据移到 **`~/.cmbot`**(`get_data_dir()` 三级回退,见第 5 节)。退休 `scripts/update.ps1` 与 `scripts/install_local.ps1`(`%LOCALAPPDATA%` 安装器)。待做:`src/launcher.py`、`build.ps1` 增产 `Launcher.exe`、安装根可写性检测、首次播种默认模板、真实环境端到端实测。 +3. **自动安装(PowerShell 原型,已退休)**:曾以 `scripts/update.ps1` + `scripts/install_local.ps1` 跑通整条流程(HTTP 下载 zip + SHA-256 + 解压 + `app/app.old` 切换),验证了设计可行。该原型已被阶段④取代并删除(git 历史可查)。 +4. **`Launcher.exe` + `~/.cmbot`(当前方向)**:✅ 已实现——`src/launcher.py`(Python + PyInstaller onefile,复用 `services/update_service.py`:下载 zip + SHA-256 + 解压 + `app/app.old` 切换 + 安装根可写性检测 + 首次播种默认模板到 `~/.cmbot`);`scripts/build.ps1` 产出 `Launcher.exe` + 便携布局 + 两个 zip(自更新载荷 / 便携安装包)+ 无 BOM `manifest.json`;`get_data_dir()` 三级回退(见第 5 节)。已用本地 HTTP server 真实端到端验证(下载→校验→切换→播种)。**待做**:在 Windows 上真实 PyInstaller 构建 + 解压到 D 盘真机端到端实测。 5. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与 `app.old` 回滚策略。⛔ 未做。 启动器(`Launcher.exe`,目标)要点: diff --git a/scripts/install_local.ps1 b/scripts/install_local.ps1 deleted file mode 100644 index f5798ac..0000000 --- a/scripts/install_local.ps1 +++ /dev/null @@ -1,166 +0,0 @@ -<# -.SYNOPSIS - Install a built CMBot release into the local launcher layout. - -.DESCRIPTION - Creates or updates this structure: - - %LOCALAPPDATA%\CMBot\ - update.ps1 - app\ - app.old\ - data\ - config\ - logs\ - output\ - staging\ - - The script copies a built release directory (release\CMBot-x.y.z by default) - into app\, copies scripts\update.ps1 to the install root, and migrates - config files into data\config without overwriting existing user config. - -.PARAMETER ReleaseDir - Built release directory to install. Defaults to release\CMBot-APP_VERSION. - -.PARAMETER InstallRoot - Local install root. Defaults to %LOCALAPPDATA%\CMBot. - -.PARAMETER LegacyDataDir - Optional old flat install/data root. When provided, config files are copied - from LegacyDataDir\config before falling back to app.old\config or release - defaults. - -.PARAMETER Force - Allows replacing an existing app.old directory. -#> -[CmdletBinding()] -param( - [string]$ReleaseDir = "", - [string]$InstallRoot = (Join-Path $env:LOCALAPPDATA "CMBot"), - [string]$LegacyDataDir = "", - [switch]$Force -) - -$ErrorActionPreference = "Stop" - -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$RootDir = Split-Path -Parent $ScriptDir -$VersionFile = Join-Path $RootDir "src\version.py" -$ReleaseRoot = Join-Path $RootDir "release" -$LauncherScript = Join-Path $ScriptDir "update.ps1" -$AppExeName = "CMBot" -$AppExe = "$AppExeName.exe" - -function Read-VersionValue { - param( - [string]$Path, - [string]$Name - ) - - $pattern = "^\s*$Name\s*=\s*`"([^`"]+)`"" - foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) { - if ($line -match $pattern) { - return $Matches[1] - } - } - throw "Cannot find $Name in $Path" -} - -function Resolve-ExistingPath { - param( - [string]$Path, - [string]$Name - ) - if (-not (Test-Path -LiteralPath $Path)) { - throw "$Name not found: $Path" - } - return (Resolve-Path -LiteralPath $Path).Path -} - -function Assert-Release { - param([string]$Path) - $exe = Join-Path $Path $AppExe - $version = Join-Path $Path "version.txt" - if (-not (Test-Path -LiteralPath $exe)) { - throw "Release missing executable: $exe" - } - if (-not (Test-Path -LiteralPath $version)) { - throw "Release missing version.txt: $version" - } -} - -function Copy-ConfigIfMissing { - param( - [string]$SourceConfigDir, - [string]$TargetConfigDir, - [string]$FileName - ) - if (-not $SourceConfigDir) { - return - } - $source = Join-Path $SourceConfigDir $FileName - $target = Join-Path $TargetConfigDir $FileName - if ((Test-Path -LiteralPath $source) -and -not (Test-Path -LiteralPath $target)) { - Copy-Item -LiteralPath $source -Destination $target - Write-Host "Migrated config: $FileName" - } -} - -if (-not $ReleaseDir) { - $appVersion = Read-VersionValue -Path $VersionFile -Name "APP_VERSION" - $ReleaseDir = Join-Path $ReleaseRoot "$AppExeName-$appVersion" -} - -$ReleaseDir = Resolve-ExistingPath -Path $ReleaseDir -Name "Release directory" -$LauncherScript = Resolve-ExistingPath -Path $LauncherScript -Name "Launcher script" -Assert-Release -Path $ReleaseDir - -$InstallRoot = [System.IO.Path]::GetFullPath($InstallRoot) -$AppDir = Join-Path $InstallRoot "app" -$OldAppDir = Join-Path $InstallRoot "app.old" -$DataDir = Join-Path $InstallRoot "data" -$ConfigDir = Join-Path $DataDir "config" -$LogsDir = Join-Path $DataDir "logs" -$OutputDir = Join-Path $DataDir "output" -$StagingDir = Join-Path $InstallRoot "staging" - -New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null -New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null -New-Item -ItemType Directory -Force -Path $LogsDir | Out-Null -New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null -New-Item -ItemType Directory -Force -Path $StagingDir | Out-Null - -if (Test-Path -LiteralPath $AppDir) { - if (Test-Path -LiteralPath $OldAppDir) { - if (-not $Force) { - throw "app.old already exists: $OldAppDir. Re-run with -Force to replace it." - } - Remove-Item -LiteralPath $OldAppDir -Recurse -Force - } - Move-Item -LiteralPath $AppDir -Destination $OldAppDir -} - -Copy-Item -LiteralPath $ReleaseDir -Destination $AppDir -Recurse -Copy-Item -LiteralPath $LauncherScript -Destination (Join-Path $InstallRoot "update.ps1") -Force - -$configSources = @() -if ($LegacyDataDir) { - $configSources += Join-Path ([System.IO.Path]::GetFullPath($LegacyDataDir)) "config" -} -if (Test-Path -LiteralPath $OldAppDir) { - $configSources += Join-Path $OldAppDir "config" -} -$configSources += Join-Path $AppDir "config" - -foreach ($sourceConfig in $configSources) { - Copy-ConfigIfMissing -SourceConfigDir $sourceConfig -TargetConfigDir $ConfigDir -FileName "app_config.json" - Copy-ConfigIfMissing -SourceConfigDir $sourceConfig -TargetConfigDir $ConfigDir -FileName "templates.json" -} - -Write-Host "Installed CMBot launcher layout:" -Write-Host " Root: $InstallRoot" -Write-Host " App: $AppDir" -Write-Host " Data: $DataDir" -Write-Host "" -Write-Host "Start with:" -Write-Host " powershell -ExecutionPolicy Bypass -File `"$InstallRoot\update.ps1`"" diff --git a/scripts/update.ps1 b/scripts/update.ps1 deleted file mode 100644 index a64c0ee..0000000 --- a/scripts/update.ps1 +++ /dev/null @@ -1,321 +0,0 @@ -<# -.SYNOPSIS - HTTP auto-update launcher for CMBot (docs/10-lan-update.md, stage 3). - -.DESCRIPTION - Run instead of launching CMBot.exe directly. On each start it: - 1. reads the local version from app\version.txt; - 2. reads update_source/update_user/update_pass from data\config\app_config.json; - 3. downloads manifest.json and, when newer, downloads the release zip; - 4. verifies SHA-256, extracts to staging\app.new, and switches app/app.old; - 5. launches app\CMBot.exe with CMBOT_DATA_DIR pointed at data\. - - Degrades safely for normal updates: network errors, malformed manifests, - failed downloads, bad hashes, or bad zips leave the existing app\ version - in place and launch it. - -.PARAMETER InstallRoot - Install directory holding Launcher.exe, app\, app.old\, data\, staging\. - Defaults to the launcher's own folder. - -.PARAMETER NoLaunch - Do everything except start the app. For testing the update flow. -#> -[CmdletBinding()] -param( - [string]$InstallRoot = $PSScriptRoot, - [switch]$NoLaunch -) - -$ErrorActionPreference = "Stop" - -$AppExe = "CMBot.exe" -$ManifestName = "manifest.json" -$VersionFileName = "version.txt" - -$AppDir = Join-Path $InstallRoot "app" -$OldAppDir = Join-Path $InstallRoot "app.old" -$DataDir = Join-Path $InstallRoot "data" -$StagingDir = Join-Path $InstallRoot "staging" -$ConfigFile = Join-Path $DataDir "config\app_config.json" -$LogFile = Join-Path $InstallRoot "launcher.log" - -function Write-Log { - param([string]$Message) - $line = "{0} {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message - Write-Host $line - try { Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8 } catch { } -} - -function Get-LocalVersion { - $versionFile = Join-Path $AppDir $VersionFileName - if (Test-Path -LiteralPath $versionFile) { - return (Get-Content -LiteralPath $versionFile -Raw).TrimStart([char]0xFEFF).Trim() - } - return "" -} - -function Get-UpdateConfig { - $result = [ordered]@{ - Source = "" - User = "" - Pass = "" - } - if (-not (Test-Path -LiteralPath $ConfigFile)) { - return $result - } - try { - $cfg = Get-Content -LiteralPath $ConfigFile -Raw | ConvertFrom-Json - $result.Source = [string]$cfg.update_source - $result.User = [string]$cfg.update_user - $result.Pass = [string]$cfg.update_pass - } catch { - Write-Log "Cannot read update config: $_" - } - return $result -} - -function ConvertTo-VersionParts { - param([string]$Text) - $parts = @(0, 0, 0) - if ($Text) { - $chunks = $Text.Trim().Split(".") - for ($i = 0; $i -lt 3 -and $i -lt $chunks.Count; $i++) { - $digits = ($chunks[$i] -replace '^(\d*).*$', '$1') - if ($digits) { $parts[$i] = [int]$digits } - } - } - return ,$parts -} - -function Test-IsNewer { - param([string]$Remote, [string]$Local) - $r = ConvertTo-VersionParts $Remote - $l = ConvertTo-VersionParts $Local - for ($i = 0; $i -lt 3; $i++) { - if ($r[$i] -gt $l[$i]) { return $true } - if ($r[$i] -lt $l[$i]) { return $false } - } - return $false -} - -function Get-AuthHeaders { - param( - [string]$User, - [string]$Pass - ) - $headers = @{} - if ($User -or $Pass) { - $bytes = [System.Text.Encoding]::UTF8.GetBytes(("{0}:{1}" -f $User, $Pass)) - $headers["Authorization"] = "Basic " + [Convert]::ToBase64String($bytes) - } - return $headers -} - -function Resolve-ManifestUrl { - param([string]$Source) - $uri = [System.Uri]$Source - if ([System.IO.Path]::GetFileName($uri.AbsolutePath).ToLowerInvariant() -eq $ManifestName) { - return $uri.AbsoluteUri - } - if (-not $Source.EndsWith("/")) { - $Source = "$Source/" - $uri = [System.Uri]$Source - } - return (New-Object -TypeName System.Uri -ArgumentList $uri, $ManifestName).AbsoluteUri -} - -function Resolve-ReleaseUrl { - param( - [string]$ManifestUrl, - [string]$ReleaseUrl - ) - $uri = $null - if ([System.Uri]::TryCreate($ReleaseUrl, [System.UriKind]::Absolute, [ref]$uri)) { - return $uri.AbsoluteUri - } - return (New-Object -TypeName System.Uri -ArgumentList ([System.Uri]$ManifestUrl), $ReleaseUrl).AbsoluteUri -} - -function Invoke-JsonGet { - param( - [string]$Url, - [hashtable]$Headers - ) - $response = Invoke-WebRequest -Uri $Url -Headers $Headers -UseBasicParsing -TimeoutSec 15 - return $response.Content | ConvertFrom-Json -} - -function Save-HttpFile { - param( - [string]$Url, - [string]$Destination, - [hashtable]$Headers - ) - Invoke-WebRequest -Uri $Url -Headers $Headers -UseBasicParsing -TimeoutSec 120 -OutFile $Destination -} - -function Remove-PathIfExists { - param([string]$Path) - if (Test-Path -LiteralPath $Path) { - Remove-Item -LiteralPath $Path -Recurse -Force - } -} - -function Get-PackageRoot { - param([string]$ExtractDir) - if (Test-Path -LiteralPath (Join-Path $ExtractDir $AppExe)) { - return $ExtractDir - } - $children = @(Get-ChildItem -LiteralPath $ExtractDir -Directory) - if ($children.Count -eq 1) { - $candidate = $children[0].FullName - if (Test-Path -LiteralPath (Join-Path $candidate $AppExe)) { - return $candidate - } - } - return "" -} - -function Assert-PackageValid { - param( - [string]$PackageRoot, - [string]$ExpectedVersion - ) - if (-not $PackageRoot -or -not (Test-Path -LiteralPath (Join-Path $PackageRoot $AppExe))) { - throw "downloaded package does not contain $AppExe" - } - $versionFile = Join-Path $PackageRoot $VersionFileName - if (-not (Test-Path -LiteralPath $versionFile)) { - throw "downloaded package does not contain $VersionFileName" - } - $actualVersion = (Get-Content -LiteralPath $versionFile -Raw).TrimStart([char]0xFEFF).Trim() - if ($actualVersion -ne $ExpectedVersion) { - throw "package version '$actualVersion' does not match manifest '$ExpectedVersion'" - } -} - -function Test-AppRunning { - $name = [System.IO.Path]::GetFileNameWithoutExtension($AppExe) - $exePath = Join-Path $AppDir $AppExe - $running = Get-Process -Name $name -ErrorAction SilentlyContinue | Where-Object { - try { $_.Path -eq $exePath } catch { $false } - } - return [bool]$running -} - -function Switch-AppDirectory { - param([string]$PackageRoot) - if (Test-AppRunning) { - throw "$AppExe is already running" - } - if (Test-Path -LiteralPath $OldAppDir) { - Remove-Item -LiteralPath $OldAppDir -Recurse -Force - } - if (Test-Path -LiteralPath $AppDir) { - Move-Item -LiteralPath $AppDir -Destination $OldAppDir - } - try { - Move-Item -LiteralPath $PackageRoot -Destination $AppDir - } catch { - if ((-not (Test-Path -LiteralPath $AppDir)) -and (Test-Path -LiteralPath $OldAppDir)) { - Move-Item -LiteralPath $OldAppDir -Destination $AppDir - } - throw - } -} - -function Invoke-Update { - param( - [string]$Source, - [string]$User, - [string]$Pass, - [string]$LocalVersion - ) - - $manifestUrl = Resolve-ManifestUrl $Source - $headers = Get-AuthHeaders -User $User -Pass $Pass - - Write-Log "Checking update manifest: $manifestUrl" - $m = Invoke-JsonGet -Url $manifestUrl -Headers $headers - - $remoteVer = [string]$m.version - if (-not $remoteVer) { - Write-Log "Manifest has no version - skip update." - return - } - if (-not (Test-IsNewer $remoteVer $LocalVersion)) { - Write-Log "Up to date (local '$LocalVersion', remote '$remoteVer')." - return - } - - $releaseField = [string]$m.url - $expectedHash = ([string]$m.sha256).Trim().ToLowerInvariant() - if (-not $releaseField -or -not $expectedHash) { - throw "manifest must contain url and sha256" - } - $releaseUrl = Resolve-ReleaseUrl -ManifestUrl $manifestUrl -ReleaseUrl $releaseField - - $zipPath = Join-Path $StagingDir ("{0}.zip" -f $remoteVer) - $extractDir = Join-Path $StagingDir "app.new" - Remove-PathIfExists -Path $zipPath - Remove-PathIfExists -Path $extractDir - - Write-Log "Downloading v$remoteVer from $releaseUrl ..." - Save-HttpFile -Url $releaseUrl -Destination $zipPath -Headers $headers - - if ($m.size) { - $actualSize = (Get-Item -LiteralPath $zipPath).Length - if ($actualSize -ne [int64]$m.size) { - throw "download size mismatch: got $actualSize, expected $($m.size)" - } - } - - $actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actualHash -ne $expectedHash) { - throw "sha256 mismatch" - } - - Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force - $packageRoot = Get-PackageRoot -ExtractDir $extractDir - Assert-PackageValid -PackageRoot $packageRoot -ExpectedVersion $remoteVer - - Switch-AppDirectory -PackageRoot $packageRoot - Remove-PathIfExists -Path $zipPath - Remove-PathIfExists -Path $extractDir - Write-Log "Installed and switched to v$remoteVer." -} - -# -- main -------------------------------------------------------------------- - -New-Item -ItemType Directory -Force -Path $StagingDir | Out-Null -New-Item -ItemType Directory -Force -Path $DataDir | Out-Null - -$local = Get-LocalVersion -$cfg = Get-UpdateConfig - -if ($cfg.Source) { - try { - Invoke-Update -Source $cfg.Source -User $cfg.User -Pass $cfg.Pass -LocalVersion $local - } catch { - Write-Log "Update skipped (degraded to local version): $_" - } -} else { - Write-Log "No update_source configured - skip update check." -} - -$current = Get-LocalVersion -$exe = Join-Path $AppDir $AppExe -if (-not (Test-Path -LiteralPath $exe)) { - Write-Log "ERROR: executable not found: $exe" - exit 1 -} - -if ($NoLaunch) { - Write-Log "NoLaunch: would start $exe v$current (CMBOT_DATA_DIR=$DataDir)" - exit 0 -} - -Write-Log "Launching v$current ..." -$env:CMBOT_DATA_DIR = $DataDir -Start-Process -FilePath $exe diff --git a/tasks.md b/tasks.md index a6fcda5..42f8155 100644 --- a/tasks.md +++ b/tasks.md @@ -935,7 +935,7 @@ - [x] `get_data_dir()` 三级回退:`CMBOT_DATA_DIR` → 打包态 `~/.cmbot` → 开发态项目根;`tests/test_file_service.py` 5 个单测 - [x] `src/launcher.py`:复用 `update_service`,下载 zip→SHA-256→解压→`app/app.old` 切换→启动;安装根可写性检测;首次把 `app\config\` 默认模板播种到 `~/.cmbot`;`update_service` 扩展(`UpdateInfo.sha256/size/min_supported`、绝对 url 解析、`make_auth_header`/`download`);`tests/test_launcher.py` 11 个单测 + 本地 HTTP server 真实端到端验证 - [x] `build.ps1` 增产 `Launcher.exe`(PyInstaller onefile,console);发布目录改为便携布局 `Launcher.exe` + `app\`;产出两个 zip——自更新载荷 `CMBot-.zip`(app\ 内容,manifest.url 指向它)与便携安装包 `CMBot--portable.zip`;`launcher.py` 日志初始化健壮化(--windowed/只读根不崩)。语法校验通过;实际 PyInstaller 构建需在 Windows 跑 -- [ ] 退休 `scripts/update.ps1` 与 `scripts/install_local.ps1` +- [x] 退休 `scripts/update.ps1` 与 `scripts/install_local.ps1`(已删除,git 历史可查) - [ ] 端到端实测(解压到 D 盘运行、自更新、回滚) ### 17.20 设置对话框(更新配置入口)