xianyufaka/auto_updater.py
2026-04-15 22:56:44 +08:00

1020 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
自动热更新模块
支持在不重新下载整个exe/容器的情况下,自动更新少量修改的文件。
更新机制:
1. 从远程服务器获取更新清单包含文件列表、版本、MD5哈希
2. 比较本地文件与远程文件的哈希值
3. 只下载有变化的文件
4. 备份旧文件,下载新文件
5. 需要时重启应用
支持更新的文件类型:
- Python 源文件 (.py)
- 前端文件 (.js, .ts, .tsx, .jsx, .vue, .css, .html, .txt, .json)
- 配置文件 (.yml, .json)
- 静态资源(图片、字体等)
"""
import os
import sys
import json
import hashlib
import shutil
import tempfile
import asyncio
import aiohttp
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional, Tuple, Any
from loguru import logger
from dataclasses import dataclass, asdict
from enum import Enum
class UpdateStatus(Enum):
"""更新状态"""
IDLE = "idle" # 空闲
CHECKING = "checking" # 检查中
DOWNLOADING = "downloading" # 下载中
INSTALLING = "installing" # 安装中
COMPLETED = "completed" # 完成
FAILED = "failed" # 失败
RESTART_REQUIRED = "restart_required" # 需要重启
@dataclass
class FileUpdate:
"""文件更新信息"""
path: str # 相对路径
md5: str # MD5哈希
size: int # 文件大小
download_url: str # 下载URL
version: str # 文件版本
requires_restart: bool # 是否需要重启
description: str = "" # 更新说明
@dataclass
class DeletedFile:
"""待删除文件信息"""
path: str
requires_restart: bool
description: str = ""
@dataclass
class UpdateManifest:
"""更新清单"""
version: str # 版本号
release_date: str # 发布日期
description: str # 版本说明
files: List[FileUpdate] # 文件列表
deleted_files: List[DeletedFile] = None # 待删除文件列表
min_version: str = "" # 最低兼容版本
changelog: List[str] = None # 更新日志
@dataclass
class UpdateProgress:
"""更新进度"""
status: UpdateStatus
current_file: str = ""
current_index: int = 0
total_files: int = 0
downloaded_bytes: int = 0
total_bytes: int = 0
message: str = ""
error: str = ""
class AutoUpdater:
"""自动更新器"""
# 默认 GitHub 更新源配置
DEFAULT_GITHUB_API_BASE = "https://api.github.com"
DEFAULT_GITHUB_RAW_BASE = "https://raw.githubusercontent.com"
DEFAULT_GITHUB_OWNER = "GuDong2003"
DEFAULT_GITHUB_REPO = "xianyu-auto-reply-fix"
# 可热更新的静态文件类型(通常不需要重启)
HOT_UPDATABLE_EXTENSIONS = {
'.css', '.eot', '.gif', '.html', '.ico', '.jpeg', '.jpg',
'.js', '.json', '.jsx', '.map', '.otf', '.png', '.svg',
'.ts', '.tsx', '.ttf', '.vue',
'.txt', '.webp', '.woff', '.woff2', '.yml', '.yaml',
}
# 需要重启的文件类型
RESTART_REQUIRED_EXTENSIONS = {'.py', '.pyd', '.so', '.dll', '.exe'}
# 不允许更新的文件/目录
EXCLUDED_PATHS = {
'data/',
'logs/',
'browser_data/',
'uploads/',
'__pycache__/',
'.git/',
'.github/',
'.claude/',
'.ace-tool/',
'.pytest_cache/',
'build/',
'nginx/',
'dist/',
'node_modules/',
'qr_screenshots/',
'trajectory_history/',
'update_backup/',
'venv/',
'global_config.yml', # 用户配置文件不更新
'update_files.json',
'Dockerfile',
'Dockerfile-cn',
'docker-compose.yml',
'docker-compose-cn.yml',
}
def __init__(self,
app_dir: Optional[str] = None,
update_server: Optional[str] = None,
github_owner: Optional[str] = None,
github_repo: Optional[str] = None,
github_token: Optional[str] = None,
current_version: str = "1.0.0"):
"""
初始化更新器
Args:
app_dir: 应用目录,默认为当前工作目录
update_server: 兼容旧参数,保留但不再作为默认更新源
github_owner: GitHub 仓库 owner
github_repo: GitHub 仓库 repo
github_token: GitHub API Token可选用于提升限额
current_version: 当前版本号
"""
self.app_dir = Path(app_dir) if app_dir else Path.cwd()
self.github_owner = github_owner or os.getenv("UPDATE_GITHUB_OWNER", self.DEFAULT_GITHUB_OWNER)
self.github_repo = github_repo or os.getenv("UPDATE_GITHUB_REPO", self.DEFAULT_GITHUB_REPO)
self.github_token = (github_token or os.getenv("UPDATE_GITHUB_TOKEN", "")).strip()
self.update_server = update_server or f"{self.github_owner}/{self.github_repo}"
self.current_version = current_version
self.backup_dir = self.app_dir / "update_backup"
# 更新状态
self.progress = UpdateProgress(status=UpdateStatus.IDLE)
self._update_callbacks: List[callable] = []
# 确保备份目录存在
self.backup_dir.mkdir(parents=True, exist_ok=True)
logger.info(
"自动更新器初始化: "
f"app_dir={self.app_dir}, repo={self.github_owner}/{self.github_repo}, version={self.current_version}"
)
def add_progress_callback(self, callback: callable):
"""添加进度回调"""
self._update_callbacks.append(callback)
def _notify_progress(self):
"""通知进度更新"""
for callback in self._update_callbacks:
try:
callback(self.progress)
except Exception as e:
logger.error(f"进度回调执行失败: {e}")
def _update_progress(self, **kwargs):
"""更新进度"""
for key, value in kwargs.items():
if hasattr(self.progress, key):
setattr(self.progress, key, value)
self._notify_progress()
def _calculate_file_md5(self, file_path: Path) -> str:
"""计算文件MD5"""
if not file_path.exists():
return ""
md5_hash = hashlib.md5()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
md5_hash.update(chunk)
return md5_hash.hexdigest()
def _is_excluded(self, path: str) -> bool:
"""检查路径是否被排除"""
path_lower = path.lower().replace('\\', '/')
for excluded in self.EXCLUDED_PATHS:
if path_lower.startswith(excluded.lower()) or excluded.lower() in path_lower:
return True
return False
def _needs_restart(self, file_path: str) -> bool:
"""检查文件更新是否需要重启"""
ext = Path(file_path).suffix.lower()
return ext in self.RESTART_REQUIRED_EXTENSIONS
def refresh_current_version(self) -> str:
"""从本地版本文件刷新当前版本号,避免长生命周期进程读到旧版本"""
version = self.current_version or "1.0.0"
version_file = self.app_dir / "static" / "version.txt"
try:
if version_file.exists():
file_version = version_file.read_text(encoding="utf-8").strip()
if file_version:
version = file_version
except Exception as e:
logger.warning(f"读取本地版本文件失败,继续使用缓存版本: {e}")
return self.current_version
if version != self.current_version:
logger.info(f"检测到本地版本变更: {self.current_version} -> {version}")
self.current_version = version
return self.current_version
def _build_request_headers(self, accept_json: bool = True) -> Dict[str, str]:
"""构建 GitHub 请求头"""
headers = {
"User-Agent": f"XianyuAutoReplyUpdater/{self.current_version}",
}
if accept_json:
headers["Accept"] = "application/vnd.github+json"
if self.github_token:
headers["Authorization"] = f"Bearer {self.github_token}"
return headers
def _build_latest_release_url(self) -> str:
"""构建 GitHub 最新 release API 地址"""
return (
f"{self.DEFAULT_GITHUB_API_BASE}/repos/"
f"{self.github_owner}/{self.github_repo}/releases/latest"
)
def _build_raw_file_url(self, tag: str, relative_path: str) -> str:
"""构建 GitHub raw 文件地址"""
relative_path = relative_path.replace("\\", "/").lstrip("/")
return (
f"{self.DEFAULT_GITHUB_RAW_BASE}/"
f"{self.github_owner}/{self.github_repo}/{tag}/{relative_path}"
)
def _extract_changelog(self, release_data: Dict[str, Any]) -> List[str]:
"""从 release body 中提取简易更新日志"""
body = (release_data.get("body") or "").strip()
if not body:
return []
changelog = []
for raw_line in body.splitlines():
line = raw_line.strip()
if not line:
continue
if line.startswith(("- ", "* ", "+ ")):
line = line[2:].strip()
changelog.append(line)
return changelog
def _find_asset_download_url(self, release_data: Dict[str, Any], asset_name: str) -> Optional[str]:
"""查找指定 release asset 的浏览器下载地址"""
for asset in release_data.get("assets", []) or []:
if asset.get("name") == asset_name:
return asset.get("browser_download_url")
return None
async def check_for_updates(self) -> Optional[UpdateManifest]:
"""
检查是否有可用更新
Returns:
UpdateManifest: 更新清单如果没有更新则返回None
"""
self.refresh_current_version()
self._update_progress(status=UpdateStatus.CHECKING, message="正在检查更新...")
try:
async with aiohttp.ClientSession() as session:
release_url = self._build_latest_release_url()
async with session.get(
release_url,
headers=self._build_request_headers(),
timeout=aiohttp.ClientTimeout(total=30)
) as response:
if response.status != 200:
logger.warning(f"获取 GitHub Release 失败: HTTP {response.status}")
self._update_progress(status=UpdateStatus.IDLE, message="检查更新失败")
return None
release_data = await response.json()
release_tag = (release_data.get("tag_name") or "").strip()
if not release_tag:
logger.warning("GitHub Release 缺少 tag_name无法检查更新")
self._update_progress(status=UpdateStatus.IDLE, message="检查更新失败")
return None
manifest_url = self._find_asset_download_url(release_data, "update_files.json")
if not manifest_url:
manifest_url = self._build_raw_file_url(release_tag, "update_files.json")
async with session.get(
manifest_url,
headers=self._build_request_headers(accept_json=False),
timeout=aiohttp.ClientTimeout(total=30)
) as response:
if response.status != 200:
logger.warning(f"获取更新清单失败: HTTP {response.status}")
self._update_progress(status=UpdateStatus.IDLE, message="检查更新失败")
return None
manifest_text = await response.text()
try:
manifest_data = json.loads(manifest_text)
except json.JSONDecodeError as exc:
logger.warning(f"解析更新清单失败: {exc}")
self._update_progress(status=UpdateStatus.IDLE, message="检查更新失败")
return None
manifest_version = manifest_data.get("version") or release_tag
changelog = manifest_data.get("changelog") or self._extract_changelog(release_data)
files = []
for file_info in manifest_data.get("files", []):
file_path = file_info["path"].replace("\\", "/")
files.append(FileUpdate(
path=file_path,
md5=file_info.get("md5", ""),
size=file_info.get("size", 0),
download_url=self._build_raw_file_url(release_tag, file_path),
version=file_info.get("version", manifest_version),
requires_restart=file_info.get("requires_restart", self._needs_restart(file_path)),
description=file_info.get("description", "")
))
deleted_files = []
for file_info in manifest_data.get("deleted_files", []):
file_path = file_info["path"].replace("\\", "/")
deleted_files.append(DeletedFile(
path=file_path,
requires_restart=file_info.get("requires_restart", self._needs_restart(file_path)),
description=file_info.get("description", "")
))
manifest = UpdateManifest(
version=manifest_version,
release_date=manifest_data.get("release_date") or release_data.get("published_at", ""),
description=manifest_data.get("description") or release_data.get("name") or f"版本 {manifest_version} 更新",
files=files,
deleted_files=deleted_files,
min_version=manifest_data.get("min_version", ""),
changelog=changelog
)
logger.info(
f"发现发布版本: {manifest.version}, 共 {len(files)} 个文件可用于比对更新, "
f"{len(deleted_files)} 个文件待删除"
)
self._update_progress(status=UpdateStatus.IDLE, message=f"已获取版本 {manifest.version} 的更新清单")
return manifest
except asyncio.TimeoutError:
logger.error("检查更新超时")
self._update_progress(status=UpdateStatus.FAILED, error="检查更新超时")
return None
except Exception as e:
logger.error(f"检查更新失败: {e}")
self._update_progress(status=UpdateStatus.FAILED, error=str(e))
return None
async def get_files_to_update(self, manifest: UpdateManifest) -> List[FileUpdate]:
"""
获取需要更新的文件列表(排除已是最新的文件)
Args:
manifest: 更新清单
Returns:
需要更新的文件列表
"""
files_to_update = []
excluded_count = 0
up_to_date_count = 0
for file_update in manifest.files:
# 跳过被排除的文件
if self._is_excluded(file_update.path):
logger.debug(f"跳过排除的文件: {file_update.path}")
excluded_count += 1
continue
local_path = self.app_dir / file_update.path
# 如果服务端没有提供MD5则始终更新该文件
if not file_update.md5 or not file_update.md5.strip():
files_to_update.append(file_update)
logger.debug(f"需要更新无MD5校验: {file_update.path}")
continue
local_md5 = self._calculate_file_md5(local_path)
# 如果本地文件不存在或MD5不匹配则需要更新
if local_md5 != file_update.md5:
files_to_update.append(file_update)
logger.debug(f"需要更新: {file_update.path} (本地MD5: {local_md5}, 远程MD5: {file_update.md5})")
else:
up_to_date_count += 1
logger.info(
f"更新检查完成: 共 {len(manifest.files)} 个文件, 需要更新 {len(files_to_update)} 个, "
f"已是最新 {up_to_date_count} 个, 排除 {excluded_count}"
)
return files_to_update
async def get_files_to_delete(self, manifest: UpdateManifest) -> List[DeletedFile]:
"""获取本地实际存在且允许删除的旧文件列表"""
files_to_delete = []
for deleted_file in manifest.deleted_files or []:
if self._is_excluded(deleted_file.path):
logger.debug(f"跳过排除的删除路径: {deleted_file.path}")
continue
local_path = self.app_dir / deleted_file.path
if local_path.exists() and local_path.is_file():
files_to_delete.append(deleted_file)
logger.debug(f"需要删除旧文件: {deleted_file.path}")
return files_to_delete
# 非关键文件MD5校验失败时可以继续更新仅警告不报错
NON_CRITICAL_FILES = {'version.txt', 'update_log.txt', 'changelog.txt'}
async def download_file(self, file_update: FileUpdate, session: aiohttp.ClientSession) -> Optional[bytes]:
"""
下载单个文件
Args:
file_update: 文件更新信息
session: aiohttp会话
Returns:
文件内容失败返回None
"""
try:
async with session.get(file_update.download_url, timeout=aiohttp.ClientTimeout(total=60)) as response:
if response.status != 200:
logger.error(f"下载文件失败: {file_update.path}, HTTP {response.status}")
return None
content = await response.read()
# 验证MD5如果服务端提供了MD5值
if file_update.md5 and file_update.md5.strip():
downloaded_md5 = hashlib.md5(content).hexdigest()
if downloaded_md5 != file_update.md5:
# 检查是否为非关键文件
file_name = Path(file_update.path).name
if file_name in self.NON_CRITICAL_FILES:
logger.warning(f"非关键文件MD5不匹配忽略: {file_update.path}, 期望: {file_update.md5}, 实际: {downloaded_md5}")
# 非关键文件,继续更新
else:
logger.error(f"文件MD5校验失败: {file_update.path}, 期望: {file_update.md5}, 实际: {downloaded_md5}")
return None
else:
logger.debug(f"文件MD5校验通过: {file_update.path}")
else:
logger.debug(f"跳过MD5校验服务端未提供: {file_update.path}")
return content
except Exception as e:
logger.error(f"下载文件异常: {file_update.path}, {e}")
return None
def _backup_file(self, file_path: Path) -> bool:
"""
备份文件
Args:
file_path: 要备份的文件路径
Returns:
是否成功
"""
if not file_path.exists():
return True
try:
relative_path = file_path.relative_to(self.app_dir)
backup_path = self.backup_dir / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}" / relative_path
backup_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(file_path, backup_path)
logger.debug(f"备份文件: {file_path} -> {backup_path}")
return True
except Exception as e:
logger.error(f"备份文件失败: {file_path}, {e}")
return False
def _cleanup_empty_parent_dirs(self, directory: Path):
"""清理因删除文件产生的空目录"""
current = directory
while current != self.app_dir and current != current.parent:
try:
current.rmdir()
except OSError:
break
current = current.parent
async def apply_updates(self, files_to_update: List[FileUpdate]) -> Tuple[bool, List[str], bool]:
"""
应用更新
Args:
files_to_update: 需要更新的文件列表
Returns:
(是否成功, 更新的文件列表, 是否需要重启)
"""
if not files_to_update:
return True, [], False
updated_files = []
needs_restart = False
total_size = sum(f.size for f in files_to_update)
downloaded_size = 0
self._update_progress(
status=UpdateStatus.DOWNLOADING,
total_files=len(files_to_update),
total_bytes=total_size,
message=f"正在下载 {len(files_to_update)} 个文件..."
)
async with aiohttp.ClientSession() as session:
for index, file_update in enumerate(files_to_update):
self._update_progress(
current_file=file_update.path,
current_index=index + 1,
message=f"正在下载: {file_update.path}"
)
# 下载文件
content = await self.download_file(file_update, session)
if content is None:
self._update_progress(
status=UpdateStatus.FAILED,
error=f"下载文件失败: {file_update.path}"
)
return False, updated_files, needs_restart
downloaded_size += len(content)
self._update_progress(downloaded_bytes=downloaded_size)
# 备份并安装
local_path = self.app_dir / file_update.path
# 备份旧文件
if not self._backup_file(local_path):
logger.warning(f"备份失败,继续更新: {file_update.path}")
# 确保目录存在
local_path.parent.mkdir(parents=True, exist_ok=True)
# 写入新文件
try:
self._update_progress(
status=UpdateStatus.INSTALLING,
message=f"正在安装: {file_update.path}"
)
with open(local_path, 'wb') as f:
f.write(content)
updated_files.append(file_update.path)
if file_update.requires_restart:
needs_restart = True
logger.info(f"更新文件成功: {file_update.path}")
except Exception as e:
logger.error(f"写入文件失败: {file_update.path}, {e}")
self._update_progress(
status=UpdateStatus.FAILED,
error=f"安装文件失败: {file_update.path}"
)
return False, updated_files, needs_restart
# 更新完成
if needs_restart:
self._update_progress(
status=UpdateStatus.RESTART_REQUIRED,
message=f"更新完成,共更新 {len(updated_files)} 个文件,需要重启应用"
)
else:
self._update_progress(
status=UpdateStatus.COMPLETED,
message=f"更新完成,共更新 {len(updated_files)} 个文件"
)
return True, updated_files, needs_restart
async def apply_deletions(self, files_to_delete: List[DeletedFile]) -> Tuple[bool, List[str], bool]:
"""
删除 manifest 中声明的旧文件
Args:
files_to_delete: 待删除文件列表
Returns:
(是否成功, 删除的文件列表, 是否需要重启)
"""
if not files_to_delete:
return True, [], False
deleted_paths = []
needs_restart = False
self._update_progress(
status=UpdateStatus.INSTALLING,
total_files=len(files_to_delete),
message=f"正在清理 {len(files_to_delete)} 个旧文件..."
)
for index, deleted_file in enumerate(files_to_delete):
self._update_progress(
current_file=deleted_file.path,
current_index=index + 1,
message=f"正在删除旧文件: {deleted_file.path}"
)
local_path = self.app_dir / deleted_file.path
if not local_path.exists():
continue
if not self._backup_file(local_path):
logger.warning(f"删除前备份失败,继续删除: {deleted_file.path}")
try:
local_path.unlink()
deleted_paths.append(deleted_file.path)
if deleted_file.requires_restart:
needs_restart = True
self._cleanup_empty_parent_dirs(local_path.parent)
logger.info(f"删除旧文件成功: {deleted_file.path}")
except Exception as e:
logger.error(f"删除旧文件失败: {deleted_file.path}, {e}")
self._update_progress(
status=UpdateStatus.FAILED,
error=f"删除旧文件失败: {deleted_file.path}"
)
return False, deleted_paths, needs_restart
return True, deleted_paths, needs_restart
async def perform_update(self, manifest: Optional[UpdateManifest] = None) -> Dict[str, Any]:
"""
执行完整的更新流程
Args:
manifest: 更新清单如果为None则自动检查
Returns:
更新结果
"""
result = {
"success": False,
"message": "",
"updated_files": [],
"deleted_files": [],
"needs_restart": False,
"new_version": ""
}
try:
self.refresh_current_version()
# 检查更新
if manifest is None:
manifest = await self.check_for_updates()
if manifest is None:
result["message"] = "没有可用更新"
result["success"] = True
return result
result["new_version"] = manifest.version
# 获取需要更新的文件
files_to_update = await self.get_files_to_update(manifest)
files_to_delete = await self.get_files_to_delete(manifest)
if not files_to_update and not files_to_delete:
result["message"] = "所有文件已是最新"
result["success"] = True
return result
logger.info(
f"开始更新到版本 {manifest.version}: {len(files_to_update)} 个文件更新, "
f"{len(files_to_delete)} 个文件待删除"
)
# 应用更新
success, updated_files, needs_restart = await self.apply_updates(files_to_update)
deleted_files: List[str] = []
if success:
success, deleted_files, delete_restart = await self.apply_deletions(files_to_delete)
needs_restart = needs_restart or delete_restart
result["success"] = success
result["updated_files"] = updated_files
result["deleted_files"] = deleted_files
result["needs_restart"] = needs_restart
if success:
message_parts = []
if updated_files:
message_parts.append(f"更新 {len(updated_files)} 个文件")
if deleted_files:
message_parts.append(f"删除 {len(deleted_files)} 个旧文件")
result["message"] = f"成功{''.join(message_parts) if message_parts else '处理变更'}到版本 {manifest.version}"
if needs_restart:
result["message"] += ",需要重启应用生效"
# 更新成功后,保存文件哈希清单(用于以后对比)
self.save_file_hashes(manifest.version, updated_files, deleted_files)
else:
result["message"] = "更新过程中出现错误"
return result
except Exception as e:
logger.error(f"更新失败: {e}")
result["message"] = f"更新失败: {str(e)}"
self._update_progress(status=UpdateStatus.FAILED, error=str(e))
return result
def get_local_file_hashes(self, file_patterns: List[str] = None) -> Dict[str, str]:
"""
获取本地文件的MD5哈希值
Args:
file_patterns: 文件模式列表,默认为常见的可更新文件
Returns:
{文件路径: MD5哈希}
"""
if file_patterns is None:
try:
from generate_update_manifest import collect_updatable_files
file_hashes = {}
for relative_path in collect_updatable_files(self.app_dir):
if self._is_excluded(relative_path):
continue
file_path = self.app_dir / relative_path
file_hashes[relative_path] = self._calculate_file_md5(file_path)
return file_hashes
except Exception as e:
logger.debug(f"使用 manifest 扫描规则获取本地哈希失败,回退到后缀模式: {e}")
file_patterns = [
'*.py', '*.js', '*.ts', '*.tsx', '*.jsx', '*.vue',
'*.css', '*.html', '*.txt', '*.json', '*.map',
'*.yml', '*.yaml', '*.png', '*.jpg', '*.jpeg',
'*.gif', '*.svg', '*.ico', '*.webp', '*.woff',
'*.woff2', '*.eot', '*.otf', '*.ttf',
]
file_hashes = {}
for pattern in file_patterns:
for file_path in self.app_dir.rglob(pattern):
try:
relative_path = str(file_path.relative_to(self.app_dir)).replace('\\', '/')
# 跳过排除的路径
if self._is_excluded(relative_path):
continue
file_hashes[relative_path] = self._calculate_file_md5(file_path)
except Exception as e:
logger.debug(f"计算文件哈希失败: {file_path}, {e}")
return file_hashes
def cleanup_old_backups(self, keep_days: int = 7):
"""
清理旧的备份文件
Args:
keep_days: 保留天数
"""
try:
cutoff_time = datetime.now().timestamp() - (keep_days * 24 * 60 * 60)
for backup_dir in self.backup_dir.iterdir():
if backup_dir.is_dir():
if backup_dir.stat().st_mtime < cutoff_time:
shutil.rmtree(backup_dir)
logger.info(f"清理旧备份: {backup_dir}")
except Exception as e:
logger.error(f"清理备份失败: {e}")
def save_file_hashes(
self,
version: str,
updated_files: List[str] = None,
deleted_files: List[str] = None,
):
"""
保存文件哈希清单到本地
更新完成后调用此方法记录所有文件的MD5哈希值
方便以后对比哪些文件发生了变化。
Args:
version: 当前版本号
updated_files: 本次更新的文件列表(可选)
"""
try:
hash_file = self.app_dir / "data" / "file_hashes.json"
hash_file.parent.mkdir(parents=True, exist_ok=True)
# 获取所有可更新文件的哈希
all_hashes = self.get_local_file_hashes()
# 构建哈希清单
manifest = {
"version": version,
"updated_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
"total_files": len(all_hashes),
"files": all_hashes
}
# 如果有本次更新的文件列表,单独记录
if updated_files:
manifest["last_updated_files"] = updated_files
manifest["last_updated_count"] = len(updated_files)
if deleted_files:
manifest["last_deleted_files"] = deleted_files
manifest["last_deleted_count"] = len(deleted_files)
# 保存到文件
with open(hash_file, 'w', encoding='utf-8') as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
logger.info(f"已保存文件哈希清单: {hash_file}, 共 {len(all_hashes)} 个文件")
except Exception as e:
logger.error(f"保存文件哈希清单失败: {e}")
def load_file_hashes(self) -> Optional[Dict[str, Any]]:
"""
加载本地保存的文件哈希清单
Returns:
哈希清单字典如果不存在则返回None
"""
try:
hash_file = self.app_dir / "data" / "file_hashes.json"
if not hash_file.exists():
return None
with open(hash_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"加载文件哈希清单失败: {e}")
return None
def compare_file_hashes(self) -> Dict[str, Any]:
"""
比较当前文件与保存的哈希清单
Returns:
比较结果,包含变化的文件列表
"""
result = {
"has_changes": False,
"saved_version": None,
"changed_files": [],
"new_files": [],
"deleted_files": [],
"unchanged_files": []
}
try:
saved_manifest = self.load_file_hashes()
if saved_manifest is None:
result["message"] = "没有保存的哈希清单,无法比较"
return result
result["saved_version"] = saved_manifest.get("version")
saved_hashes = saved_manifest.get("files", {})
# 获取当前文件哈希
current_hashes = self.get_local_file_hashes()
# 比较文件
all_files = set(saved_hashes.keys()) | set(current_hashes.keys())
for file_path in all_files:
saved_md5 = saved_hashes.get(file_path)
current_md5 = current_hashes.get(file_path)
if saved_md5 is None:
# 新增的文件
result["new_files"].append(file_path)
elif current_md5 is None:
# 删除的文件
result["deleted_files"].append(file_path)
elif saved_md5 != current_md5:
# 修改的文件
result["changed_files"].append({
"path": file_path,
"old_md5": saved_md5,
"new_md5": current_md5
})
else:
# 未变化的文件
result["unchanged_files"].append(file_path)
result["has_changes"] = bool(result["changed_files"] or result["new_files"] or result["deleted_files"])
result["message"] = f"比较完成: {len(result['changed_files'])} 个文件修改, {len(result['new_files'])} 个新增, {len(result['deleted_files'])} 个删除"
except Exception as e:
logger.error(f"比较文件哈希失败: {e}")
result["message"] = f"比较失败: {str(e)}"
return result
# 全局更新器实例
_updater: Optional[AutoUpdater] = None
def get_updater() -> AutoUpdater:
"""获取全局更新器实例"""
global _updater
if _updater is None:
# 尝试从版本文件读取当前版本
version = "1.0.0"
try:
version_file = Path(__file__).parent / "static" / "version.txt"
if version_file.exists():
version = version_file.read_text().strip()
except:
pass
_updater = AutoUpdater(current_version=version)
return _updater
def init_updater(app_dir: str = None, update_server: str = None, current_version: str = None) -> AutoUpdater:
"""
初始化全局更新器
Args:
app_dir: 应用目录
update_server: 更新服务器地址
current_version: 当前版本号
"""
global _updater
if current_version is None:
try:
version_file = Path(app_dir or ".") / "static" / "version.txt"
if version_file.exists():
current_version = version_file.read_text().strip()
else:
current_version = "1.0.0"
except:
current_version = "1.0.0"
_updater = AutoUpdater(
app_dir=app_dir,
update_server=update_server,
current_version=current_version
)
return _updater