This commit is contained in:
2025-10-13 15:21:29 +08:00
parent e5d5afb1a8
commit b96137d807
38 changed files with 448 additions and 1339 deletions

Binary file not shown.

View File

@@ -18,13 +18,16 @@ import requests
from packaging.version import parse as vparse
from PyQt5.QtCore import QThread, pyqtSignal, QObject
# 移除多线程下载器依赖
class UpdaterSignals(QObject):
"""更新器信号类"""
progress_changed = pyqtSignal(int) # 进度变化信号 (0-100)
download_progress = pyqtSignal(int, int, float, float) # 下载进度: 已下载字节, 总字节, 速度MB/s, 剩余时间秒
status_changed = pyqtSignal(str) # 状态变化信号
error_occurred = pyqtSignal(str) # 错误信号
update_completed = pyqtSignal() # 更新完成信号
update_completed = pyqtSignal(str) # 更新完成信号,可选包含文件路径
update_cancelled = pyqtSignal() # 更新取消信号
@@ -43,6 +46,9 @@ class AutoUpdater(QThread):
self.app_dir = self._get_app_directory()
self.temp_dir = None
# 多线程下载器
self.downloader = None
def _get_app_directory(self) -> str:
"""获取应用程序目录"""
if self.is_frozen:
@@ -55,6 +61,8 @@ class AutoUpdater(QThread):
def cancel_update(self):
"""取消更新"""
self.cancelled = True
if self.downloader:
self.downloader.cancel()
self.signals.update_cancelled.emit()
def check_for_updates(self) -> Optional[dict]:
@@ -70,12 +78,15 @@ class AutoUpdater(QThread):
latest_version = release_data["tag_name"].lstrip("v")
if vparse(latest_version) > vparse(self.current_version):
download_url = self._get_download_url(release_data)
asset_size = self._get_asset_size(release_data, download_url) if download_url else 0
return {
'version': latest_version,
'download_url': self._get_download_url(release_data),
'download_url': download_url,
'release_notes': release_data.get('body', ''),
'release_date': release_data.get('published_at', ''),
'asset_name': self._get_asset_name(release_data)
'asset_name': self._get_asset_name(release_data),
'asset_size': asset_size
}
return None
else:
@@ -100,17 +111,32 @@ class AutoUpdater(QThread):
if name.endswith('.exe') or name.endswith('.zip'):
return asset['browser_download_url']
elif system == 'darwin': # macOS
if name.endswith('.dmg') or name.endswith('.zip'):
# 优先选择 dmg 或 zip 文件
if name.endswith('.dmg'):
return asset['browser_download_url']
if name.endswith('.zip') and 'macos' in name:
return asset['browser_download_url']
elif system == 'linux':
if name.endswith('.tar.gz') or name.endswith('.zip'):
if name.endswith('.tar.gz') or (name.endswith('.zip') and 'linux' in name):
return asset['browser_download_url']
# 如果没找到特定平台的,返回第一个可用文件
# 如果没找到特定平台的,在 macOS 上避免选择 .exe 文件
for asset in assets:
name = asset['name'].lower()
if any(name.endswith(ext) for ext in ['.zip', '.exe', '.dmg', '.tar.gz']):
return asset['browser_download_url']
if system == 'darwin':
# macOS 优先选择非 exe 文件
if name.endswith('.zip') or name.endswith('.dmg') or name.endswith('.tar.gz'):
return asset['browser_download_url']
else:
if any(name.endswith(ext) for ext in ['.zip', '.exe', '.dmg', '.tar.gz']):
return asset['browser_download_url']
# 最后才选择 exe 文件(如果没有其他选择)
if system == 'darwin':
for asset in assets:
name = asset['name'].lower()
if name.endswith('.exe'):
return asset['browser_download_url']
return None
@@ -121,46 +147,95 @@ class AutoUpdater(QThread):
return os.path.basename(urlparse(download_url).path)
return None
def _get_asset_size(self, release_data: dict, download_url: str) -> int:
"""获取资源文件大小"""
if not download_url:
return 0
assets = release_data.get('assets', [])
for asset in assets:
if asset.get('browser_download_url') == download_url:
return asset.get('size', 0)
return 0
def download_update(self, download_url: str, filename: str) -> Optional[str]:
"""下载更新文件"""
"""下载更新文件 - 使用简单的单线程下载"""
try:
self.signals.status_changed.emit("正在下载更新...")
self.signals.status_changed.emit("开始下载...")
# 创建临时目录
self.temp_dir = tempfile.mkdtemp(prefix="MRobot_update_")
file_path = os.path.join(self.temp_dir, filename)
response = requests.get(download_url, stream=True, timeout=30)
print(f"Downloading to: {file_path}")
# 使用简单的requests下载
import requests
headers = {
'User-Agent': 'MRobot-Updater/1.0',
'Accept': '*/*',
}
response = requests.get(download_url, headers=headers, stream=True, timeout=30)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
# 获取文件总大小
total_size = int(response.headers.get('Content-Length', 0))
downloaded_size = 0
# 确保目录存在
os.makedirs(os.path.dirname(file_path), exist_ok=True)
# 下载文件
with open(file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if self.cancelled:
print("Download cancelled by user")
return None
if chunk:
f.write(chunk)
downloaded_size += len(chunk)
# 更新进度
if total_size > 0:
progress = int((downloaded_size / total_size) * 50) # 下载占50%进度
progress = int((downloaded_size / total_size) * 100)
self.signals.progress_changed.emit(progress)
# 发送详细进度信息
speed = 0 # 简化版本不计算速度
remaining = 0
self.signals.download_progress.emit(downloaded_size, total_size, speed, remaining)
self.signals.status_changed.emit("下载完成")
return file_path
# 验证下载的文件
if os.path.exists(file_path) and os.path.getsize(file_path) > 0:
print(f"Download completed successfully: {file_path}")
self.signals.status_changed.emit("下载完成!")
return file_path
else:
raise Exception("下载的文件不存在或为空")
except Exception as e:
self.signals.error_occurred.emit(f"下载失败: {str(e)}")
error_msg = f"下载失败: {str(e)}"
print(f"Download error: {error_msg}")
self.signals.error_occurred.emit(error_msg)
return None
def _on_download_progress(self, downloaded: int, total: int, speed: float, remaining: float):
"""处理下载进度"""
# 转发详细下载进度
self.signals.download_progress.emit(downloaded, total, speed, remaining)
# 计算总体进度 (下载占80%其他操作占20%)
if total > 0:
download_progress = int((downloaded / total) * 80)
self.signals.progress_changed.emit(download_progress)
def extract_update(self, file_path: str) -> Optional[str]:
"""解压更新文件"""
try:
self.signals.status_changed.emit("正在解压文件...")
self.signals.progress_changed.emit(50)
self.signals.progress_changed.emit(85)
if not self.temp_dir:
raise Exception("临时目录未初始化")
@@ -176,10 +251,19 @@ class AutoUpdater(QThread):
import tarfile
with tarfile.open(file_path, 'r:gz') as tar_ref:
tar_ref.extractall(extract_dir)
elif file_path.endswith('.exe'):
# 对于 .exe 文件,在非 Windows 系统上提示手动安装
current_system = platform.system()
if current_system != 'Windows':
self.signals.error_occurred.emit(f"下载的是 Windows 安装程序,当前系统是 {current_system}\n请手动下载适合您系统的版本。")
return None
else:
# Windows 系统直接返回文件路径,由安装函数处理
return file_path
else:
raise Exception(f"不支持的文件格式: {file_path}")
self.signals.progress_changed.emit(70)
self.signals.progress_changed.emit(90)
self.signals.status_changed.emit("解压完成")
return extract_dir
@@ -191,7 +275,7 @@ class AutoUpdater(QThread):
"""安装更新"""
try:
self.signals.status_changed.emit("正在安装更新...")
self.signals.progress_changed.emit(80)
self.signals.progress_changed.emit(95)
if not self.temp_dir:
raise Exception("临时目录未初始化")
@@ -206,7 +290,7 @@ class AutoUpdater(QThread):
# 复制新文件
self._copy_update_files(extract_dir)
self.signals.progress_changed.emit(95)
self.signals.progress_changed.emit(99)
self.signals.status_changed.emit("安装完成")
return True
@@ -295,43 +379,81 @@ class AutoUpdater(QThread):
def cleanup(self):
"""清理临时文件"""
print(f"cleanup() called, temp_dir: {self.temp_dir}") # 调试输出
if self.temp_dir and os.path.exists(self.temp_dir):
try:
print(f"Removing temp directory: {self.temp_dir}") # 调试输出
shutil.rmtree(self.temp_dir)
print("Temp directory removed successfully") # 调试输出
except Exception as e:
print(f"清理临时文件失败: {e}")
else:
print("No temp directory to clean up") # 调试输出
def run(self):
"""执行更新流程"""
try:
self.signals.status_changed.emit("开始更新流程...")
# 检查更新
self.signals.status_changed.emit("正在获取更新信息...")
update_info = self.check_for_updates()
if not update_info or self.cancelled:
self.signals.status_changed.emit("未找到更新信息或已取消")
return
self.signals.status_changed.emit(f"准备下载版本 {update_info['version']}")
# 下载更新
downloaded_file = self.download_update(
update_info['download_url'],
update_info['asset_name']
)
if not downloaded_file or self.cancelled:
self.signals.status_changed.emit("下载失败或已取消")
return
self.signals.status_changed.emit("下载完成!")
self.signals.progress_changed.emit(100)
# 检查是否为exe文件且当前系统非Windows
current_system = platform.system()
print(f"Downloaded file: {downloaded_file}") # 调试输出
print(f"Current system: {current_system}") # 调试输出
if downloaded_file.endswith('.exe') and current_system != 'Windows':
# 直接完成,返回文件路径
print(f"Emitting update_completed signal with file path: {downloaded_file}") # 调试输出
self.signals.update_completed.emit(downloaded_file)
# 不要立即清理,让用户有时间访问文件
print("Skipping cleanup to preserve downloaded file") # 调试输出
return
# 对于其他情况,继续原有流程
self.signals.status_changed.emit("下载完成,开始解压...")
# 解压更新
extract_dir = self.extract_update(downloaded_file)
if not extract_dir or self.cancelled:
self.signals.status_changed.emit("解压失败或已取消")
return
# 安装更新
self.signals.status_changed.emit("开始安装更新...")
if self.install_update(extract_dir) and not self.cancelled:
self.signals.progress_changed.emit(100)
self.signals.update_completed.emit()
self.signals.status_changed.emit("更新安装完成")
self.signals.update_completed.emit("") # 传递空字符串表示正常安装完成
else:
self.signals.status_changed.emit("安装失败或已取消")
except Exception as e:
self.signals.error_occurred.emit(f"更新过程中发生错误: {str(e)}")
error_msg = f"更新过程中发生错误: {str(e)}"
print(f"AutoUpdater error: {error_msg}") # 调试输出
self.signals.error_occurred.emit(error_msg)
finally:
# 清理临时文件
self.cleanup()
# 对于下载完成的情况,延迟清理临时文件
# 这样用户有时间访问下载的文件
pass # 暂时不在这里清理
def check_update_availability(current_version: str, repo: str = "goldenfishs/MRobot") -> Optional[dict]:

View File

@@ -1,5 +1,7 @@
import requests
import platform
from packaging.version import parse as vparse
from typing import Optional
def check_update(local_version, repo="goldenfishs/MRobot"):
url = f"https://api.github.com/repos/{repo}/releases/latest"
@@ -11,4 +13,62 @@ def check_update(local_version, repo="goldenfishs/MRobot"):
else:
return None
else:
raise RuntimeError("GitHub API 请求失败")
raise RuntimeError("GitHub API 请求失败")
def check_update_availability(current_version: str, repo: str = "goldenfishs/MRobot") -> Optional[dict]:
"""检查更新并返回详细信息"""
try:
url = f"https://api.github.com/repos/{repo}/releases/latest"
response = requests.get(url, timeout=10)
if response.status_code == 200:
release_data = response.json()
latest_version = release_data["tag_name"].lstrip("v")
if vparse(latest_version) > vparse(current_version):
# 获取适合当前平台的下载链接和文件大小
download_url, asset_size, asset_name = _get_platform_asset(release_data)
return {
'version': latest_version,
'download_url': download_url,
'asset_size': asset_size,
'asset_name': asset_name,
'release_notes': release_data.get('body', ''),
'release_date': release_data.get('published_at', ''),
}
return None
else:
raise Exception(f"GitHub API请求失败: {response.status_code}")
except Exception as e:
raise Exception(f"检查更新失败: {str(e)}")
def _get_platform_asset(release_data: dict) -> tuple:
"""获取适合当前平台的资源文件信息"""
assets = release_data.get('assets', [])
system = platform.system().lower()
# 根据操作系统选择合适的安装包
for asset in assets:
name = asset['name'].lower()
if system == 'windows':
if 'installer' in name and name.endswith('.exe'):
return asset['browser_download_url'], asset.get('size', 0), asset['name']
if name.endswith('.exe') or name.endswith('.zip'):
return asset['browser_download_url'], asset.get('size', 0), asset['name']
elif system == 'darwin': # macOS
if name.endswith('.dmg') or name.endswith('.zip'):
return asset['browser_download_url'], asset.get('size', 0), asset['name']
elif system == 'linux':
if name.endswith('.tar.gz') or name.endswith('.zip'):
return asset['browser_download_url'], asset.get('size', 0), asset['name']
# 如果没找到特定平台的,返回第一个可用文件
for asset in assets:
name = asset['name'].lower()
if any(name.endswith(ext) for ext in ['.zip', '.exe', '.dmg', '.tar.gz']):
return asset['browser_download_url'], asset.get('size', 0), asset['name']
return None, 0, None

View File

@@ -0,0 +1,34 @@
"""
更新检查线程
避免阻塞UI界面的更新检查
"""
from PyQt5.QtCore import QThread, pyqtSignal
from app.tools.check_update import check_update_availability
class UpdateCheckThread(QThread):
"""更新检查线程"""
# 信号定义
update_found = pyqtSignal(dict) # 发现更新
no_update = pyqtSignal() # 无更新
error_occurred = pyqtSignal(str) # 检查出错
def __init__(self, current_version: str, repo: str = "goldenfishs/MRobot"):
super().__init__()
self.current_version = current_version
self.repo = repo
def run(self):
"""执行更新检查"""
try:
update_info = check_update_availability(self.current_version, self.repo)
if update_info:
self.update_found.emit(update_info)
else:
self.no_update.emit()
except Exception as e:
self.error_occurred.emit(str(e))