2403 lines
66 KiB
Markdown
2403 lines
66 KiB
Markdown
# 应用自动更新系统开发文档
|
||
|
||
## 概述
|
||
|
||
本文档详细说明美家卡智影应用自动更新系统的完整实现方案,采用自建更新服务器 + 七牛云存储的架构,解决国内访问 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() |
|
||
|
||
**示例数据**:
|
||
```sql
|
||
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 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`: macOS
|
||
- `windows`: Windows
|
||
- `linux`: Linux
|
||
|
||
**架构枚举值**:
|
||
- `x86_64`: 64位 Intel/AMD
|
||
- `arm64`: ARM64 (Apple Silicon)
|
||
|
||
**示例数据**:
|
||
```sql
|
||
INSERT INTO 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 索引设计
|
||
|
||
```sql
|
||
-- 快速查询最新版本
|
||
CREATE INDEX idx_releases_release_date ON app_releases(release_date DESC);
|
||
|
||
-- 平台包复合索引
|
||
CREATE INDEX idx_packages_platform_arch ON release_packages(platform, architecture);
|
||
|
||
-- 下载统计
|
||
CREATE INDEX idx_downloads_release_id ON update_downloads(release_id);
|
||
```
|
||
|
||
### 2.3 初始化脚本
|
||
|
||
文件位置:`python-api/scripts/init_update_tables.sql`
|
||
|
||
```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 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 release_packages(platform, architecture);
|
||
CREATE INDEX IF NOT EXISTS idx_packages_release_id ON 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/{version}/{platform}` | 获取下载 URL 并记录 | 无需认证 |
|
||
| POST | `/api/v1/update/releases` | 创建新版本发布 | 需要管理员认证 |
|
||
| GET | `/api/v1/update/releases` | 获取所有版本列表 | 需要管理员认证 |
|
||
| DELETE | `/api/v1/update/releases/{version}` | 删除版本发布 | 需要管理员认证 |
|
||
|
||
### 3.2 数据模型
|
||
|
||
#### 3.2.1 SQLAlchemy 模型
|
||
|
||
文件位置:`python-api/app/models/update.py`
|
||
|
||
```python
|
||
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__ = "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`
|
||
|
||
```python
|
||
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`
|
||
|
||
```python
|
||
from fastapi import APIRouter, HTTPException, Depends, status
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select, and_
|
||
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/{version}/{platform}", response_model=DownloadResponse)
|
||
async def get_download_url(
|
||
version: str,
|
||
platform: str,
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取下载 URL 并记录下载日志
|
||
|
||
Args:
|
||
version: 目标版本
|
||
platform: 平台类型
|
||
|
||
Returns:
|
||
包含下载 URL、文件大小和哈希的响应
|
||
"""
|
||
# 查询版本信息
|
||
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"
|
||
)
|
||
|
||
# 查询对应平台的包
|
||
result = await db.execute(
|
||
select(ReleasePackage).where(
|
||
and_(
|
||
ReleasePackage.release_id == release.id,
|
||
ReleasePackage.platform == platform
|
||
)
|
||
)
|
||
)
|
||
package = result.scalar_one_or_none()
|
||
|
||
if not package:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=f"Package not found for platform {platform}"
|
||
)
|
||
|
||
# 增加下载计数
|
||
package.download_count += 1
|
||
|
||
# 记录下载日志(可选,不阻塞主流程)
|
||
download_log = UpdateDownload(
|
||
release_id=release.id,
|
||
platform=platform,
|
||
app_version=version
|
||
)
|
||
db.add(download_log)
|
||
|
||
await db.commit()
|
||
|
||
return DownloadResponse(
|
||
download_url=package.file_url,
|
||
file_size=package.file_size,
|
||
file_hash=package.file_hash
|
||
)
|
||
|
||
@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` 中注册:
|
||
|
||
```python
|
||
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` 中添加:
|
||
|
||
```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`
|
||
|
||
```rust
|
||
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` 中注册命令:
|
||
|
||
```rust
|
||
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`
|
||
|
||
```typescript
|
||
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`
|
||
|
||
```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 设置页面集成
|
||
|
||
在设置页面添加手动检查更新按钮:
|
||
|
||
```tsx
|
||
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` 中添加:
|
||
|
||
```bash
|
||
# 七牛云配置
|
||
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`
|
||
|
||
```python
|
||
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 构建流程
|
||
|
||
```bash
|
||
# 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`
|
||
|
||
```python
|
||
#!/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()
|
||
```
|
||
|
||
使用方式:
|
||
```bash
|
||
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`
|
||
|
||
```yaml
|
||
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)**:
|
||
```bash
|
||
# 七牛云配置
|
||
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
|
||
```
|
||
|
||
**前端**:
|
||
```bash
|
||
# 应用版本(在构建时注入)
|
||
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 本地测试
|
||
|
||
1. **初始化数据库**:
|
||
```bash
|
||
cd python-api
|
||
psql -h localhost -U postgres -d meijiaka -f scripts/init_update_tables.sql
|
||
```
|
||
|
||
2. **创建测试版本**:
|
||
```bash
|
||
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"
|
||
}
|
||
]
|
||
}'
|
||
```
|
||
|
||
3. **启动 Tauri 应用并测试更新流程**
|
||
|
||
### 9.2 测试检查清单
|
||
|
||
- [ ] 版本检查 API 返回正确信息
|
||
- [ ] 下载进度正确显示
|
||
- [ ] 文件哈希验证工作正常
|
||
- [ ] macOS 安装到 ~/Applications
|
||
- [ ] 安装后自动重启应用
|
||
- [ ] 强制更新无法跳过
|
||
- [ ] 下载日志正确记录
|
||
- [ ] 手动检查更新按钮工作
|
||
|
||
---
|
||
|
||
## 十、常见问题
|
||
|
||
### Q1: 如何处理更新失败?
|
||
|
||
**A**: Rust 后端会验证文件哈希,如果验证失败会删除损坏的文件并提示用户重试。建议实现重试机制,最多重试 3 次。
|
||
|
||
### Q2: 如何回滚版本?
|
||
|
||
**A**:
|
||
1. 发布上一个版本为最新
|
||
2. 或标记当前版本为不可用(修改 mandatory 和 notes)
|
||
|
||
```sql
|
||
-- 回滚到上一个版本
|
||
UPDATE app_releases
|
||
SET release_date = NOW()
|
||
WHERE version = '0.1.0';
|
||
```
|
||
|
||
### Q3: 如何处理网络中断?
|
||
|
||
**A**: Rust 下载器使用流式下载,支持断点续传。建议实现下载状态持久化,应用重启后继续下载。
|
||
|
||
### Q4: 如何测试更新流程?
|
||
|
||
**A**:
|
||
1. 修改前端当前版本为旧版本
|
||
2. 创建测试版本发布
|
||
3. 启动应用测试更新流程
|
||
4. 使用七牛云测试文件(不是真实安装包)
|
||
|
||
### Q5: macOS 安装需要权限吗?
|
||
|
||
**A**: 使用 ~/Applications 目录,用户权限即可,不需要管理员权限。
|
||
|
||
---
|
||
|
||
## 十一、错误处理与重试机制
|
||
|
||
### 11.1 下载重试机制
|
||
|
||
在 Rust 下载函数中添加重试逻辑:
|
||
|
||
```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 安装失败时自动回滚:
|
||
|
||
```rust
|
||
#[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 下载状态持久化
|
||
|
||
实现下载状态保存,应用重启后继续下载:
|
||
|
||
```rust
|
||
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:
|
||
|
||
```python
|
||
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
|
||
|
||
# 在生产环境强制 HTTPS
|
||
if not os.getenv("DEBUG"):
|
||
app.add_middleware(HTTPSRedirectMiddleware)
|
||
```
|
||
|
||
### 12.2 API 速率限制
|
||
|
||
使用 slowapi 实现速率限制:
|
||
|
||
```python
|
||
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 签名验证:
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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 环境变量验证
|
||
|
||
在启动时验证必需的环境变量:
|
||
|
||
```python
|
||
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 敏感信息不记录
|
||
|
||
确保日志中不包含敏感信息:
|
||
|
||
```python
|
||
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())
|
||
```
|
||
|
||
---
|
||
|
||
## 十三、参考资料
|
||
|
||
- [Tauri 更新插件文档](https://v2.tauri.app/plugin/updater/)
|
||
- [七牛云 Python SDK](https://developer.qiniu.com/kodo/1242/python)
|
||
- [语义化版本规范](https://semver.org/lang/zh-CN/)
|
||
- [FastAPI 官方文档](https://fastapi.tiangolo.com/zh/)
|
||
- [FastAPI 安全最佳实践](https://fastapi.tiangolo.com/tutorial/security/)
|