69 KiB
应用自动更新系统开发文档
概述
本文档详细说明美家卡智影应用自动更新系统的完整实现方案,采用自建更新服务器 + 七牛云存储的架构,解决国内访问 GitHub 不稳定的问题。
架构特点:
- 轻量云账号 + 全本地业务数据
- 七牛云存储更新包,稳定访问
- FastAPI 提供更新检查和下载接口
- Tauri 应用通过 API 获取更新并安装
- 支持强制更新、下载统计等功能
一、架构设计
1.1 整体架构
┌─────────────────────────────────────────────────────────────────┐
│ 桌面应用 │
│ ┌──────────────────────┐ ┌──────────────────────────┐ │
│ │ React 前端 │────────▶│ Rust 后端 │ │
│ │ - 更新对话框 │ IPC │ - 检查/下载/安装 │ │
│ └──────────────────────┘ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ │
│ HTTP │
▼ │
┌─────────────────────────────────────────────────────────────────┐
│ 后端服务层 │
│ ┌──────────────────────┐ ┌──────────────────────────┐ │
│ │ FastAPI 后端 │────────▶│ PostgreSQL 数据库 │ │
│ │ - 更新 API │ │ - 版本/包/下载日志 │ │
│ └──────────────────────┘ └──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ 七牛云存储 │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
1.2 更新流程
应用启动 → 检查更新 → 显示对话框 → 下载安装包 → 安装 → 重启应用
二、数据库设计
2.1 数据表结构
2.1.1 app_releases - 版本发布记录
| 字段 | 类型 | 说明 | 约束 |
|---|---|---|---|
| id | SERIAL | 主键 | PRIMARY KEY |
| version | VARCHAR(20) | 版本号 (语义化版本) | NOT NULL, UNIQUE |
| release_date | TIMESTAMP | 发布时间 | NOT NULL |
| notes | TEXT | 更新说明 | NOT NULL |
| mandatory | BOOLEAN | 是否强制更新 | DEFAULT FALSE |
| created_at | TIMESTAMP | 创建时间 | DEFAULT NOW() |
示例数据:
INSERT INTO app_releases (version, release_date, notes, mandatory) VALUES
('0.1.0', '2026-04-01 10:00:00', '初始版本发布', FALSE),
('0.1.1', '2026-04-14 10:00:00', '新功能:视频字幕烧录\n修复:导出问题', FALSE),
('0.2.0', '2026-04-20 10:00:00', '新增:批量导出功能\n优化:性能提升 30%', FALSE);
2.1.2 mjk_app_release_packages - 平台包信息
| 字段 | 类型 | 说明 | 约束 |
|---|---|---|---|
| id | SERIAL | 主键 | PRIMARY KEY |
| release_id | INTEGER | 关联版本发布 | FOREIGN KEY → app_releases.id |
| platform | VARCHAR(20) | 操作系统平台 | NOT NULL |
| architecture | VARCHAR(20) | CPU 架构 | NOT NULL |
| filename | VARCHAR(255) | 文件名 | NOT NULL |
| file_url | VARCHAR(500) | 七牛云下载 URL | NOT NULL |
| file_size | BIGINT | 文件大小(字节) | NOT NULL |
| file_hash | VARCHAR(64) | SHA256 哈希 | NOT NULL |
| download_count | INTEGER | 下载次数 | DEFAULT 0 |
| created_at | TIMESTAMP | 创建时间 | DEFAULT NOW() |
平台枚举值:
darwin: macOSwindows: Windowslinux: Linux
架构枚举值:
x86_64: 64位 Intel/AMDarm64: ARM64 (Apple Silicon)
示例数据:
INSERT INTO mjk_app_release_packages (release_id, platform, architecture, filename, file_url, file_size, file_hash) VALUES
(2, 'darwin', 'x86_64', 'meijiaka_0.1.1_darwin_x86_64.dmg',
'https://cdn.meijiaka.com/releases/meijiaka_0.1.1_darwin_x86_64.dmg',
102400000, 'sha256:abc123...'),
(2, 'windows', 'x86_64', 'meijiaka_0.1.1_windows_x86_64-setup.exe',
'https://cdn.meijiaka.com/releases/meijiaka_0.1.1_windows_x86_64-setup.exe',
115343360, 'sha256:def456...'),
(2, 'linux', 'amd64', 'meijiaka_0.1.1_linux_amd64.AppImage',
'https://cdn.meijiaka.com/releases/meijiaka_0.1.1_linux_amd64.AppImage',
104857600, 'sha256:ghi789...');
2.1.3 update_downloads - 更新下载日志
| 字段 | 类型 | 说明 | 约束 | |------| | | | | id | SERIAL | 主键 | PRIMARY KEY | | release_id | INTEGER | 关联版本发布 | FOREIGN KEY → app_releases.id | | platform | VARCHAR(20) | 下载平台 | NOT NULL | | app_version | VARCHAR(20) | 应用当前版本 | NOT NULL | | user_id | INTEGER | 用户 ID (可选) | FOREIGN KEY → users.id | | download_at | TIMESTAMP | 下载时间 | DEFAULT NOW() |
2.2 索引设计
-- 快速查询最新版本
CREATE INDEX idx_releases_release_date ON app_releases(release_date DESC);
-- 平台包复合索引
CREATE INDEX idx_packages_platform_arch ON mjk_app_release_packages(platform, architecture);
-- 下载统计
CREATE INDEX idx_downloads_release_id ON update_downloads(release_id);
2.3 初始化脚本
文件位置:python-api/scripts/init_update_tables.sql
-- 创建版本发布记录表
CREATE TABLE IF NOT EXISTS app_releases (
id SERIAL PRIMARY KEY,
version VARCHAR(20) NOT NULL UNIQUE,
release_date TIMESTAMP NOT NULL,
notes TEXT NOT NULL,
mandatory BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
-- 创建平台包信息表
CREATE TABLE IF NOT EXISTS mjk_app_release_packages (
id SERIAL PRIMARY KEY,
release_id INTEGER NOT NULL REFERENCES app_releases(id) ON DELETE CASCADE,
platform VARCHAR(20) NOT NULL,
architecture VARCHAR(20) NOT NULL,
filename VARCHAR(255) NOT NULL,
file_url VARCHAR(500) NOT NULL,
file_size BIGINT NOT NULL,
file_hash VARCHAR(64) NOT NULL,
download_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
-- 创建更新下载日志表
CREATE TABLE IF NOT EXISTS update_downloads (
id SERIAL PRIMARY KEY,
release_id INTEGER NOT NULL REFERENCES app_releases(id) ON DELETE CASCADE,
platform VARCHAR(20) NOT NULL,
app_version VARCHAR(20) NOT NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
download_at TIMESTAMP DEFAULT NOW()
);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_releases_version ON app_releases(version);
CREATE INDEX IF NOT EXISTS idx_releases_release_date ON app_releases(release_date DESC);
CREATE INDEX IF NOT EXISTS idx_packages_platform_arch ON mjk_app_release_packages(platform, architecture);
CREATE INDEX IF NOT EXISTS idx_packages_release_id ON mjk_app_release_packages(release_id);
CREATE INDEX IF NOT EXISTS idx_downloads_release_id ON update_downloads(release_id);
CREATE INDEX IF NOT EXISTS idx_downloads_download_at ON update_downloads(download_at);
-- 插入初始版本(可选)
INSERT INTO app_releases (version, release_date, notes, mandatory)
VALUES ('0.1.0', '2026-04-01 10:00:00', '初始版本发布', FALSE)
ON CONFLICT (version) DO NOTHING;
三、后端 API 设计
3.1 API 端点列表
| 方法 | 路径 | 说明 | 认证 |
|---|---|---|---|
| POST | /api/v1/update/check |
检查应用更新 | 无需认证 |
| GET | /api/v1/update/download |
统一下载入口:自动匹配最新版本和平台安装包 | 无需认证 |
| POST | /api/v1/update/releases |
创建新版本发布 | 需要管理员认证 |
| GET | /api/v1/update/releases |
获取所有版本列表 | 需要管理员认证 |
| DELETE | /api/v1/update/releases/{version} |
删除版本发布 | 需要管理员认证 |
说明:
/download优先根据User-Agent识别平台(darwin / windows / linux)和架构(x86_64 / aarch64),也支持通过查询参数显式指定,例如?target=darwin&arch=aarch64。返回 302 重定向到七牛云上的对应安装包。
3.2 数据模型
3.2.1 SQLAlchemy 模型
文件位置:python-api/app/models/update.py
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Boolean, Integer, String, Text, BigInteger, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
class AppRelease(Base):
"""应用版本发布记录"""
__tablename__ = "app_releases"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
version: Mapped[str] = mapped_column(String(20), unique=True, index=True)
release_date: Mapped[datetime] = mapped_column(DateTime, nullable=False)
notes: Mapped[str] = mapped_column(Text, nullable=False)
mandatory: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# 关系
packages: Mapped[list["ReleasePackage"]] = relationship(
"ReleasePackage",
back_populates="release",
cascade="all, delete-orphan"
)
downloads: Mapped[list["UpdateDownload"]] = relationship(
"UpdateDownload",
back_populates="release",
cascade="all, delete-orphan"
)
class ReleasePackage(Base):
"""平台包信息"""
__tablename__ = "mjk_app_release_packages"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
release_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("app_releases.id", ondelete="CASCADE"),
nullable=False
)
platform: Mapped[str] = mapped_column(String(20), nullable=False)
architecture: Mapped[str] = mapped_column(String(20), nullable=False)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
file_url: Mapped[str] = mapped_column(String(500), nullable=False)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
file_hash: Mapped[str] = mapped_column(String(64), nullable=False)
download_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# 关系
release: Mapped["AppRelease"] = relationship("AppRelease", back_populates="packages")
class UpdateDownload(Base):
"""更新下载日志"""
__tablename__ = "update_downloads"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
release_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("app_releases.id", ondelete="CASCADE"),
nullable=False
)
platform: Mapped[str] = mapped_column(String(20), nullable=False)
app_version: Mapped[str] = mapped_column(String(20), nullable=False)
user_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="SET NULL")
)
download_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# 关系
release: Mapped["AppRelease"] = relationship("AppRelease", back_populates="downloads")
3.2.2 Pydantic Schemas
文件位置:python-api/app/schemas/update.py
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class UpdateCheckRequest(BaseModel):
"""更新检查请求"""
current_version: str = Field(..., description="当前应用版本")
platform: str = Field(..., description="操作系统平台: darwin, windows, linux")
architecture: Optional[str] = Field(None, description="CPU架构: x86_64, arm64")
class PackageInfo(BaseModel):
"""包信息"""
platform: str
architecture: str
file_url: str
file_size: int
file_hash: str
class UpdateInfoResponse(BaseModel):
"""更新信息响应"""
version: str
release_date: datetime
notes: str
mandatory: bool
packages: list[PackageInfo]
class PackageCreate(BaseModel):
"""包创建信息"""
platform: str
architecture: str
file_url: str
file_size: int
file_hash: str
class ReleaseCreate(BaseModel):
"""版本发布创建请求"""
version: str
notes: str
mandatory: bool = False
packages: list[PackageCreate]
class ReleaseResponse(BaseModel):
"""版本发布响应"""
id: int
version: str
release_date: datetime
notes: str
mandatory: bool
created_at: datetime
packages: list[PackageInfo]
class DownloadResponse(BaseModel):
"""下载信息响应"""
download_url: str
file_size: int
file_hash: str
3.3 API 路由实现
文件位置:python-api/app/api/v1/update.py
from fastapi import APIRouter, HTTPException, Depends, Query, Request, status
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from ...deps import get_db
from ...models.update import AppRelease, ReleasePackage, UpdateDownload
from ...schemas.update import (
UpdateCheckRequest,
UpdateInfoResponse,
ReleaseCreate,
ReleaseResponse,
DownloadResponse,
PackageInfo,
PackageCreate
)
router = APIRouter()
@router.post("/check", response_model=UpdateInfoResponse, status_code=status.HTTP_200_OK)
async def check_update(
request: UpdateCheckRequest,
db: AsyncSession = Depends(get_db)
):
"""
检查应用更新
Args:
request: 包含当前版本、平台信息的请求
Returns:
最新的版本信息,如果已是最新版本则返回 204 No Content
"""
# 查询最新版本
result = await db.execute(
select(AppRelease)
.order_by(AppRelease.release_date.desc())
.limit(1)
)
latest_release = result.scalar_one_or_none()
if not latest_release:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No releases found"
)
# 如果已是最新版本
if latest_release.version == request.current_version:
raise HTTPException(
status_code=status.HTTP_204_NO_CONTENT,
detail="Already up to date"
)
# 确定架构(如果未提供)
arch = request.architecture or _get_default_architecture(request.platform)
# 查询对应平台的包
result = await db.execute(
select(ReleasePackage).where(
and_(
ReleasePackage.release_id == latest_release.id,
ReleasePackage.platform == request.platform,
ReleasePackage.architecture == arch
)
)
)
packages = result.scalars().all()
if not packages:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No package found for platform {request.platform} {arch}"
)
# 构建响应
return UpdateInfoResponse(
version=latest_release.version,
release_date=latest_release.release_date,
notes=latest_release.notes,
mandatory=latest_release.mandatory,
packages=[
PackageInfo(
platform=p.platform,
architecture=p.architecture,
file_url=p.file_url,
file_size=p.file_size,
file_hash=p.file_hash
)
for p in packages
]
)
@router.get("/download")
async def download_latest(
request: Request,
target: str | None = Query(None, description="平台:darwin / windows / linux"),
arch: str | None = Query(None, description="架构:x86_64 / aarch64 / i686"),
db: AsyncSession = Depends(get_db),
):
"""
统一下载入口:自动匹配最新版本和当前环境安装包。
优先级:
1. 查询参数 `target` + `arch`(最可靠,推荐应用内使用)
2. `User-Agent` 解析(兜底,适合网页/文档中的固定链接)
匹配规则:
- 返回 302 重定向到七牛云上的安装包地址
- 优先返回用户安装包(`.dmg` / `.exe` / `.msi` / `.AppImage`)
- 其次返回 updater 用的 `.app.tar.gz`
- 同一平台若找不到精确架构,会兜底返回同平台的其他架构包
"""
# 1. 确定平台与架构
if target and arch:
platform = target.lower()
architecture = arch.lower()
else:
parsed = _parse_user_agent(request.headers.get("user-agent"))
if not parsed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法识别您的操作系统,请通过官网或应用商店下载对应版本",
)
platform, architecture = parsed
# 2. 查询最新版本
result = await db.execute(
select(AppRelease).order_by(AppRelease.release_date.desc()).limit(1)
)
latest: AppRelease | None = result.scalar_one_or_none()
if not latest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="暂无可用下载",
)
# 3. 查询该平台所有包(macOS 常用 universal 包会同时写入 x86_64/aarch64)
result = await db.execute(
select(ReleasePackage).where(
ReleasePackage.release_id == latest.id,
ReleasePackage.platform == platform,
)
)
platform_pkgs = list(result.scalars().all())
if not platform_pkgs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"版本 {latest.version} 暂无可用的 {platform} 安装包",
)
# 4. 优先选择用户安装包,而不是 updater 用的 .app.tar.gz
def _install_pkg_priority(pkg: ReleasePackage) -> int:
name = pkg.filename.lower()
if name.endswith(".dmg"):
return 1
if name.endswith(".exe"):
return 2
if name.endswith(".msi"):
return 3
if name.endswith(".appimage"):
return 4
if name.endswith(".app.tar.gz"):
return 10
return 5
exact_arch_pkgs = [p for p in platform_pkgs if p.architecture == architecture]
candidate_pkgs = exact_arch_pkgs or platform_pkgs
package = min(candidate_pkgs, key=_install_pkg_priority)
return RedirectResponse(url=package.file_url)
def _parse_user_agent(user_agent: str | None) -> tuple[str, str] | None:
"""从 User-Agent 解析 Tauri 平台标识和架构。"""
if not user_agent:
return None
ua = user_agent.lower()
if "windows" in ua:
platform = "windows"
arch = "aarch64" if "arm64" in ua or "aarch64" in ua else "x86_64"
return platform, arch
if "macintosh" in ua or "mac os x" in ua:
platform = "darwin"
if "arm64" in ua or "aarch64" in ua:
arch = "aarch64"
elif "intel" in ua:
arch = "x86_64"
else:
arch = "aarch64"
return platform, arch
if "linux" in ua:
platform = "linux"
arch = "aarch64" if "aarch64" in ua or "arm64" in ua else "x86_64"
return platform, arch
return None
@router.post("/releases", response_model=ReleaseResponse)
async def create_release(
release: ReleaseCreate,
db: AsyncSession = Depends(get_db)
):
"""
创建新版本发布
Args:
release: 版本发布信息
Returns:
创建的版本发布信息
"""
# 检查版本是否已存在
result = await db.execute(
select(AppRelease).where(AppRelease.version == release.version)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Version already exists"
)
# 创建发布记录
new_release = AppRelease(
version=release.version,
release_date=datetime.utcnow(),
notes=release.notes,
mandatory=release.mandatory
)
db.add(new_release)
await db.flush() # 获取 ID
# 创建包记录
for pkg in release.packages:
package = ReleasePackage(
release_id=new_release.id,
platform=pkg.platform,
architecture=pkg.architecture,
filename=pkg.file_url.split("/")[-1],
file_url=pkg.file_url,
file_size=pkg.file_size,
file_hash=pkg.file_hash
)
db.add(package)
await db.commit()
# 构建响应
return ReleaseResponse(
id=new_release.id,
version=new_release.version,
release_date=new_release.release_date,
notes=new_release.notes,
mandatory=new_release.mandatory,
created_at=new_release.created_at,
packages=[
PackageInfo(
platform=pkg.platform,
architecture=pkg.architecture,
file_url=pkg.file_url,
file_size=pkg.file_size,
file_hash=pkg.file_hash
)
for pkg in release.packages
]
)
@router.get("/releases", response_model=list[ReleaseResponse])
async def list_releases(
db: AsyncSession = Depends(get_db)
):
"""
获取所有版本发布列表
Returns:
所有版本发布信息列表
"""
result = await db.execute(
select(AppRelease).order_by(AppRelease.release_date.desc())
)
releases = result.scalars().all()
responses = []
for release in releases:
# 获取包信息
result = await db.execute(
select(ReleasePackage).where(
ReleasePackage.release_id == release.id
)
)
packages = result.scalars().all()
responses.append(ReleaseResponse(
id=release.id,
version=release.version,
release_date=release.release_date,
notes=release.notes,
mandatory=release.mandatory,
created_at=release.created_at,
packages=[
PackageInfo(
platform=p.platform,
architecture=p.architecture,
file_url=p.file_url,
file_size=p.file_size,
file_hash=p.file_hash
)
for p in packages
]
))
return responses
@router.delete("/releases/{version}")
async def delete_release(
version: str,
db: AsyncSession = Depends(get_db)
):
"""
删除版本发布
Args:
version: 要删除的版本号
Returns:
操作结果
"""
result = await db.execute(
select(AppRelease).where(AppRelease.version == version)
)
release = result.scalar_one_or_none()
if not release:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Release not found"
)
await db.delete(release)
await db.commit()
return {"status": "success", "message": f"Release {version} deleted"}
# 辅助函数
def _get_default_architecture(platform: str) -> str:
"""获取系统架构默认值"""
if platform == "darwin":
return "universal" # macOS 默认 universal
elif platform == "linux":
return "amd64" # Linux 默认 amd64
else:
return "x86_64" # Windows 默认 x86_64
3.4 注册路由
在 python-api/app/main.py 中注册:
from app.api.v1.update import router as update_router
app.include_router(update_router, prefix="/api/v1/update", tags=["更新"])
四、Rust 后端更新逻辑
4.1 模块结构
tauri-app/src-tauri/src/
├── updater.rs # 更新模块主文件
├── lib.rs # 命令注册
└── Cargo.toml # 依赖配置
4.2 依赖配置
在 tauri-app/src-tauri/Cargo.toml 中添加:
[dependencies]
# 现有依赖...
sha2 = "0.10" # SHA256 哈希计算
tokio = { version = "1", features = ["fs", "io-util"] } # 异步 I/O
dirs = "5" # 获取系统目录路径
4.3 更新模块实现
文件位置:tauri-app/src-tauri/src/updater.rs
use tauri::{AppHandle, Emitter};
use reqwest::{self, Client};
use std::fs::{self, File};
use std::io::Write;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncReadExt;
use sha2::{Sha256, Digest};
// ============ 数据结构 ============
#[derive(Debug, Serialize, Deserialize)]
pub struct PackageInfo {
pub platform: String,
pub architecture: String,
pub file_url: String,
pub file_size: u64,
pub file_hash: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateInfo {
pub version: String,
pub release_date: String,
pub notes: String,
pub mandatory: bool,
pub packages: Vec<PackageInfo>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DownloadProgress {
pub downloaded: u64,
pub total: u64,
pub percentage: f32,
}
// ============ Tauri 命令 ============
/// 检查应用更新
#[tauri::command]
pub async fn check_update(
app: AppHandle,
current_version: String,
) -> Result<Option<UpdateInfo>, String> {
// 获取平台信息
let platform = std::env::consts::OS;
let platform_name = match platform {
"macos" => "darwin",
"windows" => "windows",
"linux" => "linux",
_ => return Err(format!("Unsupported platform: {}", platform)),
};
// 获取架构信息
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
return Err("Unsupported architecture".to_string());
};
// 调用 FastAPI 检查更新
let client = Client::new();
let api_url = "http://localhost:8080"; // 从配置读取
let url = format!(
"{}/api/v1/update/check",
api_url
);
let response = client
.post(&url)
.json(&serde_json::json!({
"current_version": current_version,
"platform": platform_name,
"architecture": arch
}))
.send()
.await
.map_err(|e| format!("Failed to check update: {}", e))?;
if response.status() == 204 {
return Ok(None); // 已是最新版本
}
let update_info: UpdateInfo = response
.json()
.await
.map_err(|e| format!("Failed to parse update info: {}", e))?;
Ok(Some(update_info))
}
/// 下载更新包
#[tauri::command]
pub async fn download_update(
app: AppHandle,
url: String,
file_hash: String,
expected_size: u64,
) -> Result<String, String> {
// 创建更新目录
let cache_dir = app.path().app_cache_dir()
.map_err(|e| format!("Failed to get cache dir: {}", e))?;
let updates_dir = cache_dir.join("updates");
fs::create_dir_all(&updates_dir)
.map_err(|e| format!("Failed to create updates dir: {}", e))?;
// 从 URL 提取文件名
let filename = url.split('/').last()
.unwrap_or("update.pkg")
.to_string();
let save_path = updates_dir.join(&filename);
// 检查是否已下载并验证
if save_path.exists() {
if verify_file_hash(&save_path, &file_hash).await {
return Ok(save_path.to_string_lossy().to_string());
}
// 哈希不匹配,删除重新下载
fs::remove_file(&save_path)?;
}
// 发起下载请求
let client = Client::new();
let mut response = client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to download: {}", e))?;
let total_size = expected_size;
let mut downloaded = 0u64;
let mut file = File::create(&save_path)
.map_err(|e| format!("Failed to create file: {}", e))?;
// 流式下载并报告进度
while let Some(chunk_result) = response.chunk().await {
let chunk = chunk_result
.map_err(|e| format!("Download error: {}", e))?;
file.write_all(&chunk)
.map_err(|e| format!("Write error: {}", e))?;
downloaded += chunk.len() as u64;
// 发送进度事件
let percentage = if total_size > 0 {
(downloaded as f32 / total_size as f32) * 100.0
} else {
0.0
};
let _ = app.emit("download-progress", DownloadProgress {
downloaded,
total: total_size,
percentage,
});
}
// 验证文件哈希
if !verify_file_hash(&save_path, &file_hash).await {
fs::remove_file(&save_path)?;
return Err("File hash verification failed".to_string());
}
Ok(save_path.to_string_lossy().to_string())
}
/// 安装更新包
#[tauri::command]
pub async fn install_update(
app: AppHandle,
package_path: String,
) -> Result<(), String> {
let package_path = PathBuf::from(package_path);
#[cfg(target_os = "macos")]
{
install_macos(app, package_path).await?;
}
#[cfg(target_os = "windows")]
{
install_windows(app, package_path).await?;
}
#[cfg(target_os = "linux")]
{
install_linux(app, package_path).await?;
}
Ok(())
}
// ============ 平台特定实现 ============
#[cfg(target_os = "macos")]
async fn install_macos(app: AppHandle, package_path: PathBuf) -> Result<(), String> {
use std::process::Command;
use std::thread;
use std::time::Duration;
// macOS: 使用 hdiutil 挂载 dmg 并复制应用到 ~/Applications
let mount_dir = PathBuf::from("/tmp/meijiaka_mount");
// 确保挂载目录不存在
if mount_dir.exists() {
let _ = Command::new("hdiutil")
.args(["detach", mount_dir.to_str().unwrap(), "-force"])
.status();
}
// 挂载 DMG
let output = Command::new("hdiutil")
.args([
"attach",
"-readonly",
"-nobrowse",
"-mountpoint",
mount_dir.to_str().unwrap(),
package_path.to_str().unwrap(),
])
.output()
.map_err(|e| format!("Failed to mount DMG: {}", e))?;
if !output.status.success() {
return Err(format!("Failed to mount DMG: {}",
String::from_utf8_loss(output.stderr.as_slice())));
}
// 查找应用文件
let app_path = find_app_in_dmg(&mount_dir)?;
let app_name = app_path.file_name()
.ok_or("Invalid app path")?
.to_string_lossy()
.to_string();
// 用户目录 Applications
let dest_dir = dirs::home_dir()
.ok_or("Failed to get home directory")?
.join("Applications");
fs::create_dir_all(&dest_dir)
.map_err(|e| format!("Failed to create Applications dir: {}", e))?;
let dest_path = dest_dir.join(&app_name);
// 删除旧版本
if dest_path.exists() {
let _ = Command::new("rm")
.args(["-rf", dest_path.to_str().unwrap()])
.status();
}
// 复制应用
let output = Command::new("cp")
.args(["-R", app_path.to_str().unwrap(), dest_dir.to_str().unwrap()])
.output()
.map_err(|e| format!("Failed to copy app: {}", e))?;
if !output.status.success() {
return Err("Failed to copy app".to_string());
}
// 卸载 DMG
let _ = Command::new("hdiutil")
.args(["detach", mount_dir.to_str().unwrap(), "-force"])
.status();
// 延迟启动新版本
thread::spawn(move || {
thread::sleep(Duration::from_secs(2));
let _ = Command::new("open")
.args(["-a", &app_name])
.status();
});
// 退出当前应用
app.exit(0);
Ok(())
}
#[cfg(target_os = "macos")]
fn find_app_in_dmg(mount_dir: &PathBuf) -> Result<PathBuf, String> {
use std::fs;
let entries = fs::read_dir(mount_dir)
.map_err(|e| format!("Failed to read mount dir: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
// 查找 .app 目录
if path.extension().and_then(|s| s.to_str()) == Some("app") {
return Ok(path);
}
// 递归查找
if path.is_dir() {
if let Ok(app_path) = find_app_in_dmg(&path) {
return Ok(app_path);
}
}
}
Err("App not found in DMG".to_string())
}
#[cfg(target_os = "windows")]
async fn install_windows(app: AppHandle, package_path: PathBuf) -> Result<(), String> {
use std::process::Command;
use std::thread;
use std::time::Duration;
// Windows: 使用 NSIS 安装包
let _ = Command::new(package_path.to_str().unwrap())
.args(["/S"]) // 静默安装
.spawn()
.map_err(|e| format!("Failed to start installer: {}", e))?;
// 延迟退出,让安装程序完成
thread::sleep(Duration::from_secs(2));
app.exit(0);
Ok(())
}
#[cfg(target_os = "linux")]
async fn install_linux(app: AppHandle, package_path: PathBuf) -> Result<(), String> {
use std::process::Command;
use std::thread;
use std::time::Duration;
// Linux: 提取 AppImage 并设置执行权限
let home_dir = dirs::home_dir()
.ok_or("Failed to get home directory")?;
let app_dir = home_dir.join("Applications");
fs::create_dir_all(&app_dir)?;
let filename = package_path.file_name()
.ok_or("Invalid package path")?;
let dest_path = app_dir.join(filename);
// 复制文件
fs::copy(&package_path, &dest_path)?;
// 设置执行权限
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&dest_path)?.permissions();
perms.set_mode(perms.mode() | 0o111);
fs::set_permissions(&dest_path, perms)?;
}
// 启动新版本
thread::spawn(move || {
thread::sleep(Duration::from_secs(2));
let _ = Command::new(dest_path.to_str().unwrap())
.status();
});
app.exit(0);
Ok(())
}
// ============ 工具函数 ============
/// 验证文件 SHA256 哈希
async fn verify_file_hash(file_path: &PathBuf, expected_hash: &str) -> bool {
use tokio::fs;
let file = fs::File::open(file_path).await;
if file.is_err() {
return false;
}
let mut file = file.unwrap();
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let n = file.read(&mut buffer).await.unwrap_or(0);
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
let actual_hash = format!("sha256:{:x}", hasher.finalize());
actual_hash == expected_hash
}
注意:上述示例代码混合使用了同步和异步 I/O。在生产环境中,建议统一使用异步 I/O 以获得更好的性能。统一后的代码需要使用 tokio::fs 替代 std::fs,并使用 tokio::process::Command 替代 std::process::Command。
4.4 注册命令
在 tauri-app/src-tauri/src/lib.rs 中注册命令:
mod updater;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
// ... 其他命令
updater::check_update,
updater::download_update,
updater::install_update
])
.setup(|app| {
#[cfg(debug_assertions)]
{
let window = app.get_webview_window("main").unwrap();
window.open_devtools();
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
五、前端实现
5.1 更新状态管理
文件位置:tauri-app/src/store/updateStore.ts
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
interface UpdatePackage {
platform: string;
architecture: string;
file_url: string;
file_size: number;
file_hash: string;
}
interface UpdateInfo {
version: string;
release_date: string;
notes: string;
mandatory: boolean;
packages: UpdatePackage[];
}
interface DownloadProgress {
downloaded: number;
total: number;
percentage: number;
}
interface UpdateState {
updateAvailable: boolean;
updateInfo: UpdateInfo | null;
downloadProgress: DownloadProgress;
downloading: boolean;
checking: boolean;
installing: boolean;
downloadedPath: string | null;
checkUpdate: (currentVersion: string) => Promise<void>;
downloadUpdate: () => Promise<void>;
installUpdate: () => Promise<void>;
dismissUpdate: () => void;
}
export const useUpdateStore = create<UpdateState>((set, get) => ({
updateAvailable: false,
updateInfo: null,
downloadProgress: { downloaded: 0, total: 0, percentage: 0 },
downloading: false,
checking: false,
installing: false,
downloadedPath: null,
checkUpdate: async (currentVersion: string) => {
set({ checking: true });
try {
const updateInfo = await invoke<UpdateInfo | null>('check_update', {
currentVersion,
});
if (updateInfo) {
set({ updateAvailable: true, updateInfo });
} else {
set({ updateAvailable: false, updateInfo: null });
}
} catch (error) {
console.error('Check update failed:', error);
set({ updateAvailable: false, updateInfo: null });
} finally {
set({ checking: false });
}
},
downloadUpdate: async () => {
const { updateInfo } = get();
if (!updateInfo) {
throw new Error('No update available');
}
// 获取当前平台的包
const platform = process.platform === 'darwin' ? 'darwin' :
process.platform === 'win32' ? 'windows' : 'linux';
const arch = process.arch === 'arm64' ? 'arm64' : 'x86_64';
const pkg = updateInfo.packages.find(
p => p.platform === platform && p.architecture === arch
);
if (!pkg) {
throw new Error(`No package found for ${platform} ${arch}`);
}
set({ downloading: true, downloadProgress: { downloaded: 0, total: pkg.file_size, percentage: 0 } });
try {
// 监听下载进度
const unlisten = await listen<DownloadProgress>('download-progress', (event) => {
set({ downloadProgress: event.payload });
});
const downloadedPath = await invoke<string>('download_update', {
url: pkg.file_url,
fileHash: pkg.file_hash,
expectedSize: pkg.file_size,
});
await unlisten();
set({ downloadedPath });
} catch (error) {
console.error('Download failed:', error);
throw error;
} finally {
set({ downloading: false });
}
},
installUpdate: async () => {
const { downloadedPath } = get();
if (!downloadedPath) {
throw new Error('No downloaded package');
}
set({ installing: true });
try {
await invoke('install_update', {
packagePath: downloadedPath,
});
// 安装后应用会重启,这里不会执行
} catch (error) {
console.error('Install failed:', error);
throw error;
} finally {
set({ installing: false });
}
},
dismissUpdate: () => {
set({ updateAvailable: false, updateInfo: null });
},
}));
5.2 更新对话框组件
文件位置:tauri-app/src/components/UpdateDialog.tsx
import { useEffect } from 'react';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { useUpdateStore } from '@/store/updateStore';
export function UpdateDialog() {
const {
updateAvailable,
updateInfo,
downloadProgress,
downloading,
checking,
installing,
checkUpdate,
downloadUpdate,
installUpdate,
dismissUpdate,
} = useUpdateStore();
// 当前应用版本(从 package.json 或构建时注入)
const CURRENT_VERSION = import.meta.env.VITE_APP_VERSION || '0.1.0';
useEffect(() => {
// 启动时检查更新
checkUpdate(CURRENT_VERSION);
}, []);
if (!updateAvailable) return null;
const isMandatory = updateInfo?.mandatory;
return (
<Dialog open={updateAvailable} onOpenChange={(open) => !open && !isMandatory && dismissUpdate()}>
<DialogContent className="sm:max-w-md">
<DialogTitle className="text-xl font-bold">
{checking ? '检查更新...' :
downloading ? '下载更新中...' :
installing ? '安装更新...' :
`发现新版本 ${updateInfo?.version}`}
</DialogTitle>
<div className="space-y-4">
{!checking && updateInfo && (
<>
<div className="text-sm text-muted-foreground whitespace-pre-line">
{updateInfo.notes}
</div>
{downloading && (
<div className="space-y-2">
<Progress value={downloadProgress.percentage} />
<div className="text-xs text-muted-foreground text-center">
{Math.round(downloadProgress.percentage)}%
({(downloadProgress.downloaded / 1024 / 1024).toFixed(1)} MB /
{(downloadProgress.total / 1024 / 1024).toFixed(1)} MB)
</div>
</div>
)}
<div className="flex justify-end gap-2">
{!downloading && !installing && (
<>
{!isMandatory && (
<Button variant="outline" onClick={dismissUpdate}>
稍后
</Button>
)}
<Button onClick={downloadUpdate}>
立即更新
</Button>
</>
)}
{!checking && !downloading && installing && (
<Button onClick={installUpdate} disabled>
安装中...
</Button>
)}
{downloading && (
<Button variant="outline" disabled>
取消
</Button>
)}
</div>
{isMandatory && !installing && !downloading && (
<div className="text-sm text-orange-500 text-center">
此版本为强制更新,必须安装后才能继续使用
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
5.3 设置页面集成
在设置页面添加手动检查更新按钮:
import { useUpdateStore } from '@/store/updateStore';
import { Button } from '@/components/ui/button';
function SettingsPage() {
const { checkUpdate, updateAvailable, updateInfo } = useUpdateStore();
const CURRENT_VERSION = import.meta.env.VITE_APP_VERSION || '0.1.0';
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">应用更新</h3>
<p className="text-sm text-muted-foreground">
当前版本:{CURRENT_VERSION}
</p>
</div>
<Button onClick={() => checkUpdate(CURRENT_VERSION)}>
检查更新
</Button>
</div>
{updateAvailable && updateInfo && (
<div className="rounded-lg border p-4">
<h4 className="font-semibold">发现新版本 {updateInfo.version}</h4>
<p className="text-sm text-muted-foreground mt-2">
{updateInfo.notes}
</p>
</div>
)}
</div>
);
}
六、七牛云集成
6.1 七牛云配置
在 python-api/.env 中添加:
# 七牛云配置
QINIU_ACCESS_KEY=your-access-key
QINIU_SECRET_KEY=your-secret-key
QINIU_BUCKET_NAME=meijiaka-releases
QINIU_BUCKET_DOMAIN=cdn.meijiaka.com
6.2 七牛云服务封装
文件位置:python-api/app/services/qiniu_service.py
import os
import hashlib
from typing import Optional
from qiniu import Auth, BucketManager
class QiniuService:
"""七牛云服务封装"""
def __init__(self):
access_key = os.getenv('QINIU_ACCESS_KEY')
secret_key = os.getenv('QINIU_SECRET_KEY')
self.bucket_name = os.getenv('QINIU_BUCKET_NAME')
self.domain = os.getenv('QINIU_BUCKET_DOMAIN')
self.auth = Auth(access_key, secret_key)
self.bucket = BucketManager(self.auth)
def get_file_url(self, key: str) -> str:
"""获取文件访问 URL"""
return f"https://{self.domain}/{key}"
def upload_file(self, local_path: str, key: str) -> dict:
"""上传文件到七牛云"""
from qiniu import put_file_v2, etag
token = self.auth.upload_token(self.bucket_name, key, 3600)
ret, info = put_file_v2(token, key, local_path, version='v2')
if ret is None:
raise Exception(f"上传失败: {info}")
return {
"key": ret['key'],
"hash": ret['hash'],
"url": self.get_file_url(key)
}
def get_file_info(self, key: str) -> dict:
"""获取文件信息"""
ret, info = self.bucket.stat(self.bucket_name, key)
if ret is None:
raise Exception(f"获取文件信息失败: {info}")
return ret
def get_file_hash(self, local_path: str) -> str:
"""计算本地文件 SHA256 哈希"""
sha256_hash = hashlib.sha256()
with open(local_path, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return f"sha256:{sha256_hash.hexdigest()}"
# 全局单例
_qiniu_service: Optional[QiniuService] = None
def get_qiniu_service() -> QiniuService:
global _qiniu_service
if _qiniu_service is None:
_qiniu_service = QiniuService()
return _qiniu_service
七、部署流程
7.1 构建流程
# 1. 构建 Tauri 应用
cd tauri-app
npm run tauri build
# 2. 构建产物位置
# macOS: src-tauri/target/release/bundle/dmg/
# Windows: src-tauri/target/release/bundle/nsis/
# Linux: src-tauri/target/release/bundle/appimage/
7.2 上传到七牛云
文件位置:python-api/scripts/upload_release.py
#!/usr/bin/env python3
"""
上传版本发布包到七牛云并创建版本发布记录
"""
import os
import sys
import argparse
import subprocess
from pathlib import Path
# 添加项目路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.services.qiniu_service import get_qiniu_service
import httpx
def upload_package(local_path: str, version: str) -> dict:
"""上传单个安装包"""
qiniu = get_qiniu_service()
filename = Path(local_path).name
key = f"releases/{version}/{filename}"
# 计算哈希
file_hash = qiniu.get_file_hash(local_path)
# 上传
print(f"上传 {filename}...")
result = qiniu.upload_file(local_path, key)
# 获取文件信息
file_info = qiniu.get_file_info(key)
return {
"platform": filename.split('_')[2],
"architecture": filename.split('_')[3].split('.')[0],
"file_url": result['url'],
"file_size": file_info['fsize'],
"file_hash": file_hash
}
def create_release(version: str, notes: str, packages: list, mandatory: bool = False):
"""创建版本发布"""
api_url = "http://localhost:8080/api/v1/update/releases"
response = httpx.post(api_url, json={
"version": version,
"notes": notes,
"mandatory": mandatory,
"packages": packages
})
if response.status_code == 200:
print(f"版本 {version} 发布成功!")
return response.json()
else:
print(f"创建版本发布失败: {response.text}")
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="上传版本发布包")
parser.add_argument("--version", required=True, help="版本号 (如: 0.1.1)")
parser.add_argument("--notes", required=True, help="更新说明")
parser.add_argument("--mandatory", action="store_true", help="强制更新")
parser.add_argument("--path", required=True, help="构建产物目录")
args = parser.parse_args()
build_dir = Path(args.path)
# 查找所有安装包
packages = []
for file in build_dir.rglob("*"):
if file.suffix in ['.dmg', '.exe', '.AppImage']:
pkg_info = upload_package(str(file), args.version)
packages.append(pkg_info)
# 创建版本发布
create_release(args.version, args.notes, packages, args.mandatory)
if __name__ == "__main__":
main()
使用方式:
cd python-api
python scripts/upload_release.py \
--version 0.1.1 \
--notes "新功能:视频字幕烧录\n修复:导出问题" \
--path ../tauri-app/src-tauri/target/release/bundle
7.3 GitHub Actions 自动化(多平台)
文件位置:.github/workflows/release.yml
name: Release
on:
push:
tags:
- 'v*'
jobs:
# macOS 构建
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
target: x86_64-apple-darwin
- name: Install Tauri CLI
run: cargo install tauri-cli --version "^2.0"
- name: Install Python dependencies
run: |
cd python-api
python -m venv venv
source venv/bin/activate
pip install -e ".[dev]"
pip install qiniu httpx
- name: Install Node dependencies
run: |
cd tauri-app
npm install
- name: Build Tauri app (macOS)
run: |
cd tauri-app
npm run tauri build --target x86_64-apple-darwin
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: macos-bundle
path: tauri-app/src-tauri/target/x86_64-apple-darwin/release/bundle/
retention-days: 1
# Windows 构建
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
target: x86_64-pc-windows-msvc
- name: Install Tauri CLI
run: cargo install tauri-cli --version "^2.0"
- name: Install Python dependencies
run: |
cd python-api
python -m venv venv
venv\Scripts\pip install -e ".[dev]"
venv\Scripts\pip install qiniu httpx
- name: Install Node dependencies
run: |
cd tauri-app
npm install
- name: Build Tauri app (Windows)
)
run: |
cd tauri-app
npm run tauri build --target x86_64-pc-windows-msvc
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: windows-bundle
path: tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/
retention-days: 1
# Linux 构建
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
target: x86_64-unknown-linux-gnu
- name: Install Tauri CLI
run: cargo install tauri-cli --version "^2.0"
- name: Install Python dependencies
run: |
cd python-api
python -m venv venv
source venv/bin/activate
pip install -e ".[dev]"
pip install qiniu httpx
- name: Install Node dependencies
run: |
cd tauri-app
npm install
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev
- name: Build Tauri app (Linux)
run: |
cd tauri-app
npm run tauri build --target x86_64-unknown-linux-gnu
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: linux-bundle
path: tauri-app/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/
retention-days: 1
# 统一发布
release:
needs: [build-macos, build-windows, build-linux]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: macos-bundle
path: bundle/macos
- name: Download Windows artifacts
uses: actions/download-artifact@v4
with:
name: windows-bundle
path: bundle/windows
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
name: linux-bundle
path: bundle/linux
- name: Install Python dependencies
run: |
cd python-api
python -m venv venv
source venv/bin/activate
pip install -e ".[dev]"
pip install qiniu httpx
- name: Upload to Qiniu and create release
env:
QINIU_ACCESS_KEY: ${{ secrets.QINIU_ACCESS_KEY }}
QINIU_SECRET_KEY: ${{ secrets.QINIU_SECRET_KEY }}
QINIU_BUCKET_NAME: ${{ secrets.QINIU_BUCKET_NAME }}
QINIU_BUCKET_DOMAIN: ${{ secrets.QINIU_BUCKET_DOMAIN }}
run: |
VERSION=${GITHUB_REF#refs/tags/v}
cd python-api
source venv/bin/activate
# 上传所有平台的包
python scripts/upload_release.py \
--version $VERSION \
--notes "Release $VERSION" \
--path ../bundle
八、配置总结
8.1 功能配置
| 配置项 | 选择 |
|---|---|
| OSS/COS 服务 | 七牛云 |
| 更新触发方式 | 启动时自动检查,需用户确认再下载 |
| 灰度发布 | 不需要 |
| 强制更新 | 需要 |
| 版本兼容性检查 | 不需要 |
| 更新检查频率 | 仅启动时 + 手动按钮 |
| 下载日志统计 | 需要 |
| macOS 安装位置 | ~/Applications(无需管理员) |
| 更新 UI 风格 | shadcn/ui 对话框 |
| 构建部署自动化 | 需要 |
8.2 环境变量
后端 (.env):
# 七牛云配置
QINIU_ACCESS_KEY=your-access-key
QINIU_SECRET_KEY=your-secret-key
QINIU_BUCKET_NAME=meijiaka-releases
QINIU_BUCKET_DOMAIN=cdn.meijiaka.com
# FastAPI 配置
DATABASE_URL=postgresql+asyncpg://...
API_BASE_URL=http://localhost:8080
前端:
# 应用版本(在构建时注入)
VITE_APP_VERSION=0.1.0
8.3 文件清单
python-api/
├── app/
│ ├── models/
│ │ └── update.py # 数据模型
│ ├── schemas/
│ │ └── update.py # Pydantic schemas
│ ├── api/
│ │ └── v1/
│ │ └── update.py # API 路由
│ ├── services/
│ │ └── qiniu_service.py # 七牛云服务
│ └── main.py # 注册路由
├── scripts/
│ ├── init_update_tables.sql # 数据库初始化
│ └── upload_release.py # 上传和发布脚本
└── .env # 环境变量
tauri-app/
├── src/
│ ├── components/
│ │ └── UpdateDialog.tsx # 更新对话框
│ ├── store/
│ │ └── updateStore.ts # 更新状态管理
│ └── pages/
│ └── Settings.tsx # 设置页面集成
└── src-tauri/
├── src/
│ ├── updater.rs # Rust 更新模块
│ └── lib.rs # 命令注册
└── Cargo.toml # 依赖配置
.github/
└── workflows/
└── release.yml # 自动化发布
九、测试指南
9.1 本地测试
- 初始化数据库:
cd python-api
psql -h localhost -U postgres -d meijiaka -f scripts/init_update_tables.sql
- 创建测试版本:
curl -X POST http://localhost:8080/api/v1/update/releases \
-H "Content-Type: application/json" \
-d '{
"version": "0.1.1",
"notes": "测试版本",
"mandatory": false,
"packages": [
{
"platform": "darwin",
"architecture": "x86_64",
"file_url": "https://cdn.meijiaka.com/releases/test.dmg",
"file_size": 102400000,
"file_hash": "sha256:test123"
}
]
}'
- 启动 Tauri 应用并测试更新流程
9.2 测试检查清单
- 版本检查 API 返回正确信息
- 下载进度正确显示
- 文件哈希验证工作正常
- macOS 安装到 ~/Applications
- 安装后自动重启应用
- 强制更新无法跳过
- 下载日志正确记录
- 手动检查更新按钮工作
十、常见问题
Q1: 如何处理更新失败?
A: Rust 后端会验证文件哈希,如果验证失败会删除损坏的文件并提示用户重试。建议实现重试机制,最多重试 3 次。
Q2: 如何回滚版本?
A:
- 发布上一个版本为最新
- 或标记当前版本为不可用(修改 mandatory 和 notes)
-- 回滚到上一个版本
UPDATE app_releases
SET release_date = NOW()
WHERE version = '0.1.0';
Q3: 如何处理网络中断?
A: Rust 下载器使用流式下载,支持断点续传。建议实现下载状态持久化,应用重启后继续下载。
Q4: 如何测试更新流程?
A:
- 修改前端当前版本为旧版本
- 创建测试版本发布
- 启动应用测试更新流程
- 使用七牛云测试文件(不是真实安装包)
Q5: macOS 安装需要权限吗?
A: 使用 ~/Applications 目录,用户权限即可,不需要管理员权限。
十一、错误处理与重试机制
11.1 下载重试机制
在 Rust 下载函数中添加重试逻辑:
use std::time::Duration;
#[tauri::command]
pub async fn download_update_with_retry(
app: AppHandle,
url: String,
file_hash: String,
expected_size: u64,
max_retries: u32,
) -> Result<String, String> {
let mut last_error = String::new();
for attempt in 0..max_retries {
match download_update_internal(&app, &url, &file_hash, expected_size).await {
Ok(path) => return Ok(path),
Err(e) => {
last_error = e.clone();
eprintln!("下载失败(尝试 {}/{}):{}", attempt + 1, max_retries, e);
if attempt < max_retries - 1 {
// 指数退避
let delay = Duration::from_secs(2_u64.pow(attempt));
tokio::time::sleep(delay).await;
}
}
}
}
Err(format!("下载失败,已重试 {} 次:{}", max_retries, last_error))
}
async fn download_update_internal(
app: &AppHandle,
url: &str,
file_hash: &str,
expected_size: u64,
) -> Result<String, String> {
// 原有的下载逻辑
// ...
}
11.2 安装失败回滚
macOS 安装失败时自动回滚:
#[cfg(target_os = "macos")]
async fn install_macos(app: AppHandle, package_path: PathBuf) -> Result<(), String> {
use std::process::Command;
use std::thread;
use std::time::Duration;
let mount_dir = PathBuf::from("/tmp/meijiaka_mount");
let backup_path = PathBuf::from("/tmp/meijiaka_backup");
// 1. 挂载 DMG
if mount_dir.exists() {
let _ = Command::new("hdiutil")
.args(["detach", mount_dir.to_str().unwrap(), "-force"])
.status();
}
let output = Command::new("hdiutil")
.args([
"attach",
"-readonly",
"-nobrowse",
"-mountpoint",
mount_dir.to_str().unwrap(),
package_path.to_str().unwrap(),
])
.output()
.map_err(|e| format!("Failed to mount DMG: {}", e))?;
if !output.status.success() {
return Err(format!("Failed to mount DMG: {}",
String::from_utf8_lossy(output.stderr.as_slice())));
}
// 2. 查找应用文件
let app_path = find_app_in_dmg(&mount_dir)?;
let app_name = app_path.file_name()
.ok_or("Invalid app path")?
.to_string_lossy()
.to_string();
let dest_dir = dirs::home_dir()
.ok_or("Failed to get home directory")?
.join("Applications");
fs::create_dir_all(&dest_dir)
.map_err(|e| format!("Failed to create Applications dir: {}", e))?;
let dest_path = dest_dir.join(&app_name);
// 3. 备份旧版本
let backup_exists = dest_path.exists();
if backup_exists {
fs::create_dir_all(&backup_path)?;
let _ = Command::new("cp")
.args(["-R", dest_path.to_str().unwrap(), backup_path.to_str().unwrap()])
.status();
}
// 4. 删除旧版本
if dest_path.exists() {
let _ = Command::new("rm")
.args(["-rf", dest_path.to_str().unwrap()])
.status();
}
// 5. 复制新版本
let output = Command::new("cp")
.args(["-R", app_path.to_str().unwrap(), dest_dir.to_str().unwrap()])
.output()
.map_err(|e| format!("Failed to copy app: {}", e))?;
if !output.status.success() {
// 复制失败,恢复备份
if backup_exists {
eprintln!("安装失败,正在恢复旧版本...");
let _ = Command::new("cp")
.args(["-R",
backup_path.join(&app_name).to_str().unwrap(),
dest_dir.to_str().unwrap()])
.status();
let _ = Command::new("rm")
.args(["-rf", backup_path.to_str().unwrap()])
.status();
}
return Err("Failed to copy app".to_string());
}
// 6. 验证新版本
if !dest_path.exists() {
// 验证失败,恢复备份
if backup_exists {
eprintln!("验证失败,正在恢复旧版本...");
let _ = Command::new("cp")
.args(["-R",
backup_path.join(&app_name).to_str().unwrap(),
dest_dir.to_str().unwrap()])
.status();
}
return Err("New app not found after installation".to_string());
}
// 7. 清理备份
if backup_exists {
let _ = Command::new("rm")
.args(["-rf", backup_path.to_str().unwrap()])
.status();
}
// 8. 卸载 DMG
let _ = Command::new("hdiutil")
.args(["detach", mount_dir.to_str().unwrap(), "-force"])
.status();
// 9. 延迟启动新版本
thread::spawn(move || {
thread::sleep(Duration::from_secs(2));
let _ = Command::new("open")
.args(["-a", &app_name])
.status();
});
// 10. 退出当前应用
app.exit(0);
Ok(())
}
11.3 下载状态持久化
实现下载状态保存,应用重启后继续下载:
use serde::{Deserialize, Serialize};
use std::fs;
#[derive(Debug, Serialize, Deserialize)]
struct DownloadState {
url: String,
file_hash: String,
expected_size: u64,
downloaded: u64,
save_path: String,
}
fn save_download_state(app: &AppHandle, state: &DownloadState) -> Result<(), String> {
let cache_dir = app.path().app_cache_dir()?;
let state_file = cache_dir.join("updates/download_state.json");
let json = serde_json::to_string_pretty(state)
.map_err(|e| format!("Failed to serialize state: {}", e))?;
fs::write(&state_file, json)
.map_err(|e| format!("Failed to save state: {}", e))
}
fn load_download_state(app: &AppHandle) -> Result<Option<DownloadState>, String> {
let cache_dir = app.path().app_cache_dir()?;
let state_file = cache_dir.join("updates/download_state.json");
if !state_file.exists() {
return Ok(None);
}
let content = fs::read_to_string(&state_file)
.map_err(|e| format!("Failed to read state: {}", e))?;
let state: DownloadState = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse state: {}", e))?;
Ok(Some(state))
}
十二、安全性最佳实践
12.1 强制 HTTPS
在 FastAPI 中强制使用 HTTPS:
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
# 在生产环境强制 HTTPS
if not os.getenv("DEBUG"):
app.add_middleware(HTTPSRedirectMiddleware)
12.2 API 速率限制
使用 slowapi 实现速率限制:
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
# 配置速率限制
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200/minute"],
storage_uri="redis://localhost:6379/1"
)
app.state.limiter = limiter
# 在检查更新接口添加速率限制
@router.post("/check", response_model=UpdateInfoResponse)
@limiter.limit("10/minute") # 每分钟最多 10 次检查
async def check_update(
request: UpdateCheckRequest,
db: AsyncSession = Depends(get_db)
):
# ...
12.3 文件签名验证
添加 HMAC 签名验证:
import hmac
import hashlib
from fastapi import Header, HTTPException
def verify_file_signature(
file_path: str,
expected_signature: str,
secret: str
) -> bool:
"""验证文件签名"""
with open(file_path, 'rb') as f:
file_hash = hashlib.sha256(f.read()).hexdigest()
signature = hmac.new(
secret.encode(),
file_hash.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
12.4 CORS 配置
严格配置 CORS:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=[
"tauri://localhost", # Tauri 开发环境
"http://localhost:1420", # Vite 开发服务器
"https://yourdomain.com", # 生产域名
],
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
12.5 环境变量验证
在启动时验证必需的环境变量:
import os
def validate_env_vars():
"""验证环境变量"""
required_vars = [
"DATABASE_URL",
"QINIU_ACCESS_KEY",
"QINIU_SECRET_KEY",
"QINIU_BUCKET_NAME",
"QINIU_BUCKET_DOMAIN",
]
missing = [var for var in required_vars if not os.getenv(var)]
if missing:
raise RuntimeError(f"缺少必需的环境变量: {', '.join(missing)}")
# 在应用启动时调用
validate_env_vars()
12.6 敏感信息不记录
确保日志中不包含敏感信息:
import logging
# 配置日志过滤器
class SensitiveDataFilter(logging.Filter):
"""过滤敏感数据"""
SENSITIVE_KEYWORDS = ["access_key", "secret_key", "password", "token"]
def filter(self, record):
msg = record.getMessage().lower()
if any(keyword in msg for keyword in self.SENSITIVE_KEYWORDS):
return False
return True
# 添加过滤器
logging.getLogger().addFilter(SensitiveDataFilter())