# Tauri v2 客户端自动更新指南

> **方案版本**: v1.0（2026-04-02）
> **适用范围**: Tauri v2 + 任意更新源（阿里云 OSS / GitHub Releases / 自建服务器）
> **验证状态**: 已在 dev-manager-tauri 项目实际跑通
>
> 本文档聚焦客户端侧的自动更新功能实现，涵盖从插件安装到 UI 交互的全链路。
> 服务端构建与发布流程请参考 [tauri-cicd-guide.md](./tauri-cicd-guide.md)。

---

## 整体流程

```
客户端启动 / 用户点击「检查更新」
        │
        ▼
请求 endpoint 上的 latest.json
        │
        ▼
比较 latest.json.version vs 本地版本
        │
   ┌────┴────┐
   │ 无更新   │ 有新版本
   │ 结束    │
   │         ▼
   │    展示版本号 + 更新说明
   │    用户点击「立即更新」
   │         │
   │         ▼
   │    下载 .exe（带进度）
   │         │
   │         ▼
   │    验证签名 → 安装 → relaunch
   └─────────┘
```

---

## 一、安装依赖

### 1.1 Rust 端（src-tauri/Cargo.toml）

```toml
[dependencies]
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
```

### 1.2 前端（package.json）

```bash
pnpm add @tauri-apps/plugin-updater @tauri-apps/plugin-process
```

---

## 二、注册插件

`src-tauri/src/lib.rs` 中注册两个插件：

```rust
tauri::Builder::default()
    .plugin(tauri_plugin_updater::Builder::new().build())
    .plugin(tauri_plugin_process::init())
    // ... 其他插件和配置
```

- `tauri_plugin_updater` — 检查更新、下载、验证签名、安装
- `tauri_plugin_process` — 安装后调用 `relaunch()` 重启应用

---

## 三、配置 tauri.conf.json

```jsonc
{
  "bundle": {
    "createUpdaterArtifacts": true  // ⚠️ 必须！否则构建不生成 .exe.sig 签名文件
  },
  "plugins": {
    "updater": {
      "pubkey": "<公钥 base64>",
      "endpoints": [
        "https://<bucket>.<endpoint>/latest.json"
      ],
      "dialog": false  // false = 用代码控制更新 UI；true = 系统原生弹窗
    }
  }
}
```

### 关键说明

| 字段 | 作用 | 注意事项 |
|------|------|---------|
| `createUpdaterArtifacts` | 构建时生成 `.exe.sig` 签名文件 | Tauri v2 默认 false，不开启则签名静默跳过 |
| `pubkey` | 客户端用来验证下载文件签名 | 是**公钥文件**的 base64，不是私钥 |
| `endpoints` | `latest.json` 的 URL 列表 | 可配多个，按顺序尝试 |
| `dialog` | 是否使用原生弹窗 | 推荐 false，自定义 UI 体验更好 |

### pubkey 生成方法

```powershell
# 生成密钥对
pnpm tauri signer generate -w "$HOME\.tauri\your-app.key"

# 获取公钥 base64（填入 pubkey 字段）
$content = Get-Content "$HOME\.tauri\your-app.key.pub" -Raw
$content = $content -replace "`r`n", "`n"
$bytes = [System.Text.Encoding]::UTF8.GetBytes($content)
[Convert]::ToBase64String($bytes)
```

---

## 四、配置 Capabilities 权限

`src-tauri/capabilities/default.json` 中必须声明权限，否则前端 API 调用会被拦截：

```jsonc
{
  "identifier": "default",
  "windows": ["main"],
  "permissions": [
    "core:default",
    // ... 其他权限 ...
    {
      "identifier": "http:default",
      "allow": [
        { "url": "https://*.aliyuncs.com/**" }  // ⚠️ 放行更新源域名
      ]
    },
    "updater:default",   // ⚠️ 允许 check()、downloadAndInstall()
    "process:default"    // ⚠️ 允许 relaunch()
  ]
}
```

**三项缺一不可**：
- `updater:default` — 否则报 `updater.check not allowed`
- `process:default` — 否则 `relaunch()` 无法调用
- http allow 列表包含更新源域名 — 否则报 `Could not fetch a valid release JSON`

---

## 五、latest.json 格式

更新源需要提供一个 `latest.json`，格式如下：

```json
{
  "version": "0.1.11",
  "pub_date": "2026-04-02T05:29:44Z",
  "notes": "版本 v0.1.11 已发布",
  "platforms": {
    "windows-x86_64": {
      "signature": "<.exe.sig 文件内容>",
      "url": "https://<bucket>.<endpoint>/releases/v0.1.11/<app>_0.1.11_x64-setup.exe"
    }
  }
}
```

| 字段 | 说明 |
|------|------|
| `version` | 新版本号，updater 用它和本地版本比较 |
| `pub_date` | ISO 8601 格式，必须有 |
| `notes` | 更新说明，前端通过 `update.body` 获取 |
| `platforms` | 按平台提供下载链接和签名 |
| `signature` | `.exe.sig` 文件内容（base64），客户端用 pubkey 验证 |
| `url` | 安装包下载地址，必须可公开访问 |

### 多平台支持

```json
{
  "platforms": {
    "windows-x86_64": { "signature": "...", "url": "...setup.exe" },
    "darwin-x86_64":  { "signature": "...", "url": "...app.tar.gz" },
    "darwin-aarch64": { "signature": "...", "url": "...app.tar.gz" },
    "linux-x86_64":   { "signature": "...", "url": "...AppImage.tar.gz" }
  }
}
```

---

## 六、前端实现（React + TypeScript）

### 6.1 状态机设计

更新流程有明确的状态流转，用联合类型建模最清晰：

```typescript
type UpdateState =
  | { status: "idle" }                    // 初始状态
  | { status: "checking" }               // 正在检查
  | { status: "latest" }                 // 已是最新
  | { status: "available"; version: string; notes: string; downloadAndInstall: () => Promise<void> }
  | { status: "downloading"; progress: number }  // 下载中（0~100）
  | { status: "error"; message: string }          // 出错
```

### 6.2 完整组件参考

```typescript
import { useState, useEffect } from "react";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { getVersion } from "@tauri-apps/api/app";

function UpdateSection() {
  const [appVersion, setAppVersion] = useState("");
  const [state, setState] = useState<UpdateState>({ status: "idle" });

  useEffect(() => {
    getVersion().then(setAppVersion).catch(() => {});
  }, []);

  const handleCheck = async () => {
    setState({ status: "checking" });
    try {
      const update = await checkUpdate();
      if (!update?.available) {
        setState({ status: "latest" });
        return;
      }
      setState({
        status: "available",
        version: update.version,
        notes: update.body ?? "",
        downloadAndInstall: async () => {
          let downloaded = 0;
          let total = 0;
          await update.downloadAndInstall((event) => {
            if (event.event === "Started") {
              total = event.data.contentLength ?? 0;
              setState({ status: "downloading", progress: 0 });
            } else if (event.event === "Progress") {
              downloaded += event.data.chunkLength;
              const progress = total > 0 ? Math.round((downloaded / total) * 100) : 0;
              setState({ status: "downloading", progress });
            } else if (event.event === "Finished") {
              setState({ status: "idle" });
            }
          });
          await relaunch();
        },
      });
    } catch (e) {
      setState({ status: "error", message: String(e) });
    }
  };

  return (
    <div>
      <p>当前版本 v{appVersion}</p>

      {state.status === "latest" && <span>已是最新版本</span>}

      {state.status === "available" && (
        <>
          <span>v{state.version} 可用</span>
          <button onClick={state.downloadAndInstall}>立即更新</button>
        </>
      )}

      {state.status === "downloading" && (
        <span>下载中 {state.progress}%</span>
      )}

      {state.status === "error" && (
        <div>
          <p>{state.message}</p>
          <button onClick={() => navigator.clipboard.writeText(state.message)}>
            复制错误
          </button>
        </div>
      )}

      {state.status !== "available" && (
        <button
          onClick={handleCheck}
          disabled={state.status === "checking" || state.status === "downloading"}
        >
          {state.status === "checking" ? "检查中…" : "检查更新"}
        </button>
      )}
    </div>
  );
}
```

### 6.3 下载事件说明

`downloadAndInstall` 的回调事件顺序：

| 事件 | 数据 | 说明 |
|------|------|------|
| `Started` | `{ contentLength?: number }` | 开始下载，contentLength 可能为 0 |
| `Progress` | `{ chunkLength: number }` | 每个数据块，累加计算进度 |
| `Finished` | 无 | 下载完成，随后自动安装 |

> `downloadAndInstall` 完成后立即调用 `relaunch()` 重启应用，新版本生效。

---

## 七、版本号管理

Tauri v2 项目有**三处版本号**必须保持一致：

| 文件 | 字段 | 作用 |
|------|------|------|
| `src-tauri/tauri.conf.json` | `"version"` | updater 用来和 latest.json 比较 |
| `src-tauri/Cargo.toml` | `version` | Rust 构建元数据 |
| `package.json` | `"version"` | 前端构建元数据 |

### 发版步骤

```bash
# 1. 三处改版本号（如 0.2.0）
# 2. 提交
git add -A
git commit -m "release: v0.2.0"
# 3. 打 tag + push（PowerShell 不支持 &&，分开执行）
git tag v0.2.0
git push
git push --tags
```

> **版本号不一致的后果**：`latest.json` 中的 version 高于本地版本时才会提示更新。
> 如果 `tauri.conf.json` 的版本没改，每次启动都会提示"有新版本"即使已经更新过。

---

## 八、更新源选择

| 更新源 | 优点 | 缺点 | 适用场景 |
|--------|------|------|---------|
| **阿里云 OSS** | 国内快，CDN 加速 | 需要额外配置 OSS | 面向国内用户 |
| **GitHub Releases** | 零配置，tauri-action 自动生成 | 国内访问慢/不稳定 | 面向海外用户 |
| **自建服务器** | 完全可控 | 需要运维 | 企业内网分发 |

Tauri v2 的 `endpoints` 支持多个 URL，可以配置主备：

```json
"endpoints": [
  "https://<oss-bucket>.oss-cn-hangzhou.aliyuncs.com/latest.json",
  "https://github.com/<user>/<repo>/releases/latest/download/latest.json"
]
```

---

## 九、调试技巧

### 9.1 验证 latest.json 可访问

```powershell
curl https://<your-endpoint>/latest.json
```

- 返回 JSON → 正常
- 返回 `AccessDenied` → OSS Bucket 未设为公共读
- 返回 404 → CI/CD 未上传或路径不对

### 9.2 开发模式测试

`pnpm tauri dev` 模式下也能调用 `check()`，但注意：
- 开发模式的版本号来自 `tauri.conf.json`
- 如果本地版本号和 latest.json 相同或更高，不会触发更新

### 9.3 强制触发更新（调试用）

临时把 `tauri.conf.json` 的版本号改低（如 `0.0.1`），再运行 `pnpm tauri dev` 点检查更新即可触发。

---

## 十、踩坑清单

| # | 问题 | 现象 | 解决方案 |
|---|------|------|---------|
| 1 | 缺少 `updater:default` | `updater.check not allowed` | `capabilities/default.json` 添加 `updater:default` |
| 2 | 缺少 `process:default` | `relaunch()` 无效 | `capabilities/default.json` 添加 `process:default` |
| 3 | http 未放行更新源域名 | `Could not fetch a valid release JSON` | capabilities http allow 加更新源域名 |
| 4 | OSS Bucket 为私有 | 同上，curl 返回 `AccessDenied` | Bucket ACL 改为**公共读** |
| 5 | `createUpdaterArtifacts` 未开启 | 构建成功但无 `.exe.sig` | `tauri.conf.json` → `bundle.createUpdaterArtifacts: true` |
| 6 | pubkey 填了私钥 | `failed to decode pubkey` | 用公钥文件的 base64 |
| 7 | 版本号三处不一致 | 更新后仍提示有新版本 | `tauri.conf.json`、`Cargo.toml`、`package.json` 必须同步 |
| 8 | latest.json 缺少 `pub_date` | updater 解析失败 | 必须包含 ISO 8601 格式的日期 |
| 9 | signature 字段为空 | 签名验证失败 | 检查 CI/CD 是否正确读取了 `.exe.sig` 文件内容 |
| 10 | `dialog: true` 时无法自定义 UI | 弹出系统原生对话框 | 设为 `false`，用代码实现自定义更新界面 |

---

## 配置文件速查

新项目最小配置清单：

```
src-tauri/Cargo.toml          → 添加 tauri-plugin-updater、tauri-plugin-process 依赖
package.json                   → pnpm add @tauri-apps/plugin-updater @tauri-apps/plugin-process
src-tauri/src/lib.rs           → 注册两个插件
src-tauri/tauri.conf.json      → bundle.createUpdaterArtifacts + plugins.updater 配置
src-tauri/capabilities/*.json  → updater:default + process:default + http allow
前端组件                        → 调用 check() + downloadAndInstall() + relaunch()
```
