Update Rules

通用小工具更新方案,供各个桌面工具复用。

# 通用小工具更新方案

本文档描述一套适合多个个人小工具复用的轻量更新系统。目标是方便维护和复制,不追求复杂的企业级安全发布流程。

## 1. 总体约定

- 更新域名:`https://update.with-haomo.online`
- 服务端目录:`/var/www/updates`
- 每个软件一个独立子目录,目录名就是工具名。
- 更新包统一使用 `.zip`。
- 更新元数据统一使用 `latest.json`。
- 完整性校验使用 SHA256。
- 不做签名校验;HTTPS + SHA256 已满足当前方便优先的需求。

URL 结构:

```text
https://update.with-haomo.online/<tool>/latest.json
https://update.with-haomo.online/<tool>/<tool>_latest.zip
https://update.with-haomo.online/<tool>/<tool>_latest.zip.sha256
https://update.with-haomo.online/<tool>/<tool>_<version>.zip
https://update.with-haomo.online/<tool>/<tool>_<version>.zip.sha256
```

示例:

```text
https://update.with-haomo.online/icon-ring-launcher/latest.json
https://update.with-haomo.online/icon-ring-launcher/icon-ring-launcher_latest.zip
https://update.with-haomo.online/icon-ring-launcher/icon-ring-launcher_20260618130003.zip
https://update.with-haomo.online/icon-ring-launcher/icon-ring-launcher_20260618130003.zip.sha256
```

## 2. 每个软件需要确定的变量

每个软件接入时,只需要固定下面这些值:

```text
TOOL_NAME=<工具目录名,例如 icon-ring-launcher>
APP_DATA_DIR=<用户数据目录,例如 %APPDATA%/IconRingLauncher>
PROJECT_FILE=<构建入口,例如 IconRingLauncher.csproj>
PUBLISH_OUTPUT=<本地发布目录,例如 publish 或 publish-next>
```

规则:

- `TOOL_NAME` 必须和服务器目录一致。
- `latest.json` 里的 `tool` 必须等于 `TOOL_NAME`。
- zip 文件名必须是 `<TOOL_NAME>_<VERSION>.zip`。
- 公开下载链接使用 `<TOOL_NAME>_latest.zip`,由发布脚本在每次发布时覆盖更新。
- `VERSION` 使用 UTC 时间戳,格式固定为 `yyyyMMddHHmmss`,例如 `20260618130003`。
- 版本号用字符串比较即可判断新旧。

## 3. latest.json 格式

`latest.json` 是客户端检查更新的唯一入口。

示例:

```json
{
  "tool": "icon-ring-launcher",
  "version": "20260618130003",
  "file": "icon-ring-launcher_20260618130003.zip",
  "sha256": "0b78c8b11fd27a491ebff9a37acb5d7ad371d440232d274ad0f60cf5f250d15d",
  "signature": "",
  "size": 134474,
  "publishedAt": "2026-06-18T13:00:05Z",
  "notes": "Initial public update package",
  "minClientVersion": ""
}
```

字段说明:

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `tool` | 是 | 工具名,必须和服务器目录一致 |
| `version` | 是 | 版本号,使用 `yyyyMMddHHmmss` |
| `file` | 是 | zip 更新包文件名 |
| `sha256` | 是 | zip 文件 SHA256 |
| `signature` | 否 | 保留字段,当前为空 |
| `size` | 否 | zip 字节数 |
| `publishedAt` | 否 | 发布时间,UTC ISO 字符串 |
| `notes` | 否 | 更新说明 |
| `minClientVersion` | 否 | 保留字段,未来可限制最低更新器版本 |

## 4. 服务端 Nginx 规则

当前服务器已经配置:

- 域名:`update.with-haomo.online`
- HTTPS:Let's Encrypt 证书
- 静态根目录:`/var/www/updates`
- 目录列表关闭
- 根路径返回 `404`
- `latest.json` 不缓存
- zip 和 sha256 长缓存

核心 Nginx 配置模板见:

```text
deploy/nginx-update.with-haomo.online.conf
```

新增软件时不需要改 Nginx,只要创建新目录:

```bash
mkdir -p /var/www/updates/<tool>
```

发布脚本会自动创建目录。

## 5. 发布脚本

Windows 项目优先使用:

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\deploy.ps1
```

常用参数:

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\deploy.ps1 `
  -ToolName "icon-ring-launcher" `
  -RemoteHost "root@120.26.109.85" `
  -RemoteRoot "/var/www/updates" `
  -ReleaseNotes "更新说明"
```

脚本行为:

1. 执行 `dotnet publish`。
2. 将发布目录打包成 `<tool>_<version>.zip`。
3. 生成 zip 的 SHA256。
4. 生成 `latest.json`。
5. 上传 zip 和 `.sha256`。
6. 上传 `latest.json.tmp`。
7. 远程复制当前 zip 为 `<tool>_latest.zip`,方便网页直接下载最新版。
8. 远程原子替换为 `latest.json`。
8. 清理旧版本,只保留最近 5 个 zip。

发布顺序很重要:

```text
先上传 zip 和 sha256
再上传 latest.json.tmp
再刷新 <tool>_latest.zip
最后 mv latest.json.tmp latest.json
```

这样客户端不会读到一个已经声明但还没上传完成的新版本。

Linux/macOS 或 Git Bash 可使用:

```bash
./deploy.sh
```

如果某个工具不是 .NET 项目,可以复制脚本后只替换构建部分,保留打包、sha256、latest.json、上传逻辑。

## 6. 客户端更新逻辑

每个软件客户端都复用同一套流程:

1. 启动时先检查是否已有 pending update。
2. 如果已有 pending update,弹窗询问用户是否现在重启并更新。
3. 正常启动软件。
4. 后台静默请求:

```text
https://update.with-haomo.online/<tool>/latest.json
```

5. 如果远程版本号大于本地版本号,下载对应 zip。
6. 计算 zip SHA256。
7. SHA256 和 `latest.json` 一致后,解压到 pending 目录。
8. 写入 pending 标记。
9. 弹窗询问用户:
   - 现在更新:立即重启软件并应用更新。
   - 下次启动更新:保留 pending 标记,下次启动时再次提示。

网络错误、404、JSON 错误、SHA256 不一致都只写日志,不影响软件正常启动。手动检查更新时,这些错误应显示给用户。

避免重复提醒的规则:

- 启动时如果已有 `pending.json`,必须先判断 `pending.version` 是否仍然大于本地版本。
- 如果 `pending.version <= 本地版本`,说明更新已经应用或本地版本不需要该 pending,必须删除 `pending.json`,不要弹窗。
- 如果已有有效 `pending.json`,启动时只提示这一个 pending,不要同时继续后台下载同一个版本。
- 手动检查更新时,如果已有有效 pending,应直接提示“更新已准备好”,不要重复下载同版本包。
- 更新成功后必须同时写入 `%APPDATA%/<AppName>/version.txt` 并删除 `pending.json`。

## 7. Windows 桌面软件的推荐实现

Windows 运行中的 exe 通常不能被覆盖,所以不要尝试“运行中直接替换”。

推荐流程:

```text
下载新版本
校验 SHA256
解压到 %APPDATA%/<AppName>/updates/<version>/payload
写入 %APPDATA%/<AppName>/updates/pending.json
弹窗询问现在重启更新,或者下次启动更新
```

pending 文件示例:

```json
{
  "version": "20260618130003",
  "extractedDirectory": "C:\\Users\\xxx\\AppData\\Roaming\\App\\updates\\20260618130003\\payload",
  "preparedAt": "2026-06-18T13:00:05Z"
}
```

替换策略:

1. 用户选择“现在更新”。
2. 启动一个 updater 脚本或 helper 进程。
3. 主程序退出。
4. updater 等待主程序退出。
5. 备份当前安装目录。
6. 将 payload 复制到安装目录。
7. 写入本地 `version.txt`。
8. 删除 pending 标记。
9. 重新启动软件。

如果用户选择“下次启动更新”,保留 `pending.json`,下次启动时再次提示。

## 8. 本地版本记录

本地版本推荐按这个优先级读取:

1. 用户数据目录:`%APPDATA%/<AppName>/version.txt`
2. 安装目录:`version.txt`
3. 程序程序集版本
4. 空字符串

发布脚本会把 `version.txt` 写入 zip 包。

更新成功后,客户端应写入:

```text
%APPDATA%/<AppName>/version.txt
```

内容就是版本号,例如:

```text
20260618130003
```

## 9. 如何新增一个软件

以新工具 `note-taker` 为例:

1. 复制 `deploy.ps1` 到 `note-taker` 项目根目录。
2. 修改默认变量:

```powershell
[string]$ToolName = "note-taker"
```

3. 如果不是 .NET 项目,替换脚本中的构建命令,但保留后面的 zip、sha256、manifest、上传逻辑。
4. 在客户端更新模块中修改:

```csharp
private const string ToolName = "note-taker";
private const string UpdateBaseUrl = "https://update.with-haomo.online";
```

5. 修改用户数据目录,例如:

```csharp
AppDirectory = Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
    "NoteTaker");
```

6. 发布:

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\deploy.ps1 -ReleaseNotes "首次发布"
```

7. 验证:

```powershell
Invoke-RestMethod https://update.with-haomo.online/note-taker/latest.json
```

8. 在主站新增公开入口页:

```text
https://www.with-haomo.online/apps/note-taker
```

该页面至少说明:

- 软件用途和工具名。
- 直接下载链接:`https://update.with-haomo.online/<tool>/<tool>_latest.zip`
- `latest.json` 地址。
- zip 更新包地址规则。
- SHA256 校验文件地址规则。
- 客户端自动更新和手动更新入口。

9. 在 `https://www.with-haomo.online/` 的 App 模块中加入该软件链接。

## 10. 发布后验证清单

每次发布后建议检查:

```powershell
$m = Invoke-RestMethod https://update.with-haomo.online/<tool>/latest.json
$zip = Join-Path $env:TEMP $m.file
Invoke-WebRequest "https://update.with-haomo.online/<tool>/$($m.file)" -OutFile $zip
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
$hash -eq $m.sha256
Remove-Item $zip
```

期望结果:

```text
True
```

还可以检查目录不可浏览:

```powershell
Invoke-WebRequest https://update.with-haomo.online/<tool>/ -SkipHttpErrorCheck
```

期望状态码:

```text
404
```

## 11. 常见问题

### latest.json 可以访问,但 zip 404

通常是发布顺序或文件名不一致。

检查:

- `latest.json` 里的 `file`
- 服务器目录里实际 zip 文件名
- Nginx 文件名规则是否匹配 `<tool>_<14位版本号>.zip`

### 客户端一直不更新

检查:

- 本地 `version.txt` 是否大于或等于远程版本
- `tool` 字段是否和客户端 `ToolName` 一致
- `sha256` 是否为 64 位十六进制
- 客户端日志:`%APPDATA%/<AppName>/updates/update.log`

### 每次启动都提示发现新版本

通常是 pending 状态没有被正确清理,或者本地版本没有写入成功。

检查:

- `%APPDATA%/<AppName>/updates/pending.json` 是否残留。
- `%APPDATA%/<AppName>/version.txt` 是否存在,内容是否大于或等于远程 `latest.json.version`。
- 更新脚本是否在成功复制文件后删除了 `pending.json`。
- 启动逻辑是否在读取 pending 时判断了 `pending.version > 本地版本`。
- 启动时已有有效 pending 的情况下,是否还继续后台检查并重复准备同一个版本。

Windows 路径包含中文时,updater PowerShell 脚本必须用带 BOM 的 UTF-8 写入,否则 Windows PowerShell 可能把中文路径读成乱码,导致安装目录替换失败,`pending.json` 继续残留,从而每次启动都提示更新。

### 更新包已下载但没有立即生效

这是 Windows 桌面软件的预期行为。

当前 exe 运行中不能可靠覆盖,所以更新包会先准备好;用户可以选择立即重启软件应用更新,也可以下次启动时再更新。

### publish 目录无法覆盖

如果软件正在运行,Windows 会锁定 exe。

处理方式:

- 从托盘退出软件后重新发布。
- 或发布到 `publish-next`,手动替换。
- 或依赖自动更新流程让下次启动替换。

## 12. 当前 IconRingLauncher 配置

当前工具名:

```text
icon-ring-launcher
```

当前更新地址:

```text
https://update.with-haomo.online/icon-ring-launcher/latest.json
```

当前服务器目录:

```text
/var/www/updates/icon-ring-launcher
```

当前客户端更新模块:

```text
Models/UpdateManifest.cs
Services/UpdateService.cs
```

当前发布脚本:

```text
deploy.ps1
deploy.sh
```

## 13. 主站 App 模块与公开说明页

主站地址:

```text
https://www.with-haomo.online/
```

主站分为三类入口:

- `Doc`:公共文档,例如 `update_rules`。
- `App`:桌面小工具,每个工具一页,说明更新地址和更新方法。
- `PWA`:已经部署在子域名上的在线应用。

App 页面约定:

```text
https://www.with-haomo.online/apps/<tool>
```

例如:

```text
https://www.with-haomo.online/apps/icon-ring-launcher
```

每个 App 页面应包含:

- 工具名:必须和更新服务里的 `<tool>` 一致。
- 直接下载:`https://update.with-haomo.online/<tool>/<tool>_latest.zip`
- 更新元数据:`https://update.with-haomo.online/<tool>/latest.json`
- 更新包规则:`https://update.with-haomo.online/<tool>/<tool>_<version>.zip`
- 校验文件规则:`https://update.with-haomo.online/<tool>/<tool>_<version>.zip.sha256`
- 用户侧更新方式:启动检查、准备更新、立即重启更新、下次启动更新、手动检查更新。

新增软件时,需要同步维护两处:

```text
/var/www/updates/<tool>               # 更新包和 latest.json
/var/www/suma-site/apps/<tool>.html   # 公开说明页
```

如果只上传了更新包,但没有维护主站 App 页面,客户端更新仍然可用;只是公开下载和说明入口不完整。