feat(core): 优化线程管理和清理机制

- 在主窗口中添加优雅的线程清理逻辑,确保在退出时安全停止所有后台线程,避免潜在的资源泄漏。
- 更新离线模式管理器和哈希线程,增强对线程引用的管理,确保在操作完成后及时清理引用。
- 改进补丁检测器,支持在离线模式下的补丁状态检查,提升用户体验和系统稳定性。
- 增强日志记录,确保在关键操作中提供详细的调试信息,便于后续排查和用户反馈。
This commit is contained in:
hyb-oyqq
2025-08-11 14:42:38 +08:00
parent f0031ed17c
commit 6a4c6ca1f1
57 changed files with 8518 additions and 237 deletions

View File

@@ -3,9 +3,9 @@ import os
import datetime
from PySide6.QtWidgets import QApplication, QMessageBox
from main_window import MainWindow
from core.privacy_manager import PrivacyManager
from core.managers.privacy_manager import PrivacyManager
from utils.logger import setup_logger
from data.config import LOG_FILE, APP_NAME
from config.config import LOG_FILE, APP_NAME
from utils import load_config
if __name__ == "__main__":

63
source/STRUCTURE.md Normal file
View File

@@ -0,0 +1,63 @@
# FRAISEMOE Addons Installer NEXT - 项目结构
## 目录结构
```
source/
├── assets/ # 所有静态资源文件
│ ├── fonts/ # 字体文件
│ ├── images/ # 图片资源
│ └── resources/ # 其他资源文件
├── bin/ # 二进制工具文件
├── config/ # 配置文件
├── core/ # 核心功能模块
│ ├── managers/ # 所有管理器类
│ └── handlers/ # 处理器类
├── data/ # 数据文件
├── ui/ # 用户界面相关
│ ├── components/ # UI组件
│ ├── windows/ # 窗口定义
│ └── views/ # 视图定义
├── utils/ # 工具类和辅助函数
├── workers/ # 后台工作线程
└── main.py # 主入口文件
```
## 文件路径映射
| 重构前 | 重构后 |
| ------ | ------ |
| source/Main.py | source/main.py |
| source/fonts/* | source/assets/fonts/* |
| source/IMG/* | source/assets/images/* |
| source/resources/* | source/assets/resources/* |
| source/data/config.py | source/config/config.py |
| source/data/privacy_policy.py | source/config/privacy_policy.py |
| source/core/animations.py | source/core/managers/animations.py |
| source/core/cloudflare_optimizer.py | source/core/managers/cloudflare_optimizer.py |
| source/core/config_manager.py | source/core/managers/config_manager.py |
| source/core/debug_manager.py | source/core/managers/debug_manager.py |
| source/core/download_manager.py | source/core/managers/download_manager.py |
| source/core/download_task_manager.py | source/core/managers/download_task_manager.py |
| source/core/extraction_handler.py | source/core/handlers/extraction_handler.py |
| source/core/game_detector.py | source/core/managers/game_detector.py |
| source/core/ipv6_manager.py | source/core/managers/ipv6_manager.py |
| source/core/offline_mode_manager.py | source/core/managers/offline_mode_manager.py |
| source/core/patch_detector.py | source/core/managers/patch_detector.py |
| source/core/patch_manager.py | source/core/managers/patch_manager.py |
| source/core/privacy_manager.py | source/core/managers/privacy_manager.py |
| source/core/ui_manager.py | source/core/managers/ui_manager.py |
| source/core/window_manager.py | source/core/managers/window_manager.py |
| source/handlers/* | source/core/handlers/* |
## 模块职责划分
1. **managers**: 负责管理应用程序的各个方面,如配置、下载、游戏检测等。
2. **handlers**: 负责处理特定的操作,如提取文件、打补丁、卸载等。
3. **assets**: 存储应用程序使用的静态资源。
4. **config**: 存储应用程序的配置信息。
5. **ui**: 负责用户界面相关的组件和视图。
6. **utils**: 提供各种实用工具函数。
7. **workers**: 负责在后台执行耗时操作的线程。
这种结构更加清晰地区分了各个模块的职责,使代码更容易维护和扩展。

View File

@@ -0,0 +1,7 @@
# Assets package initialization
"""
包含应用程序使用的静态资源文件:
- fonts: 字体文件
- images: 图片资源
- resources: 其他资源文件
"""

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

View File

@@ -0,0 +1 @@

91
source/config/config.py Normal file
View File

@@ -0,0 +1,91 @@
import os
import base64
import datetime
# 配置信息
app_data = {
"APP_VERSION": "1.3.2",
"APP_NAME": "FRAISEMOE Addons Installer NEXT",
"TEMP": "TEMP",
"CACHE": "FRAISEMOE",
"PLUGIN": "PLUGIN",
"CONFIG_URL": "aHR0cHM6Ly9hcGkuMncyLnRvcC9hcGkvb3V5YW5ncWlxaS9uZWtvcGFyYS9kb3dubG9hZF91cmwuanNvbg==",
"UA_TEMPLATE": "Mozilla/5.0 (Linux debian12 FraiseMoe2-Accept-Next) Gecko/20100101 Firefox/114.0 FraiseMoe2/{}",
"game_info": {
"NEKOPARA Vol.1": {
"exe": "nekopara_vol1.exe",
"hash": "04b48b231a7f34431431e5027fcc7b27affaa951b8169c541709156acf754f3e",
"install_path": "NEKOPARA Vol. 1/adultsonly.xp3",
"plugin_path": "vol.1/adultsonly.xp3",
},
"NEKOPARA Vol.2": {
"exe": "nekopara_vol2.exe",
"hash": "b9c00a2b113a1e768bf78400e4f9075ceb7b35349cdeca09be62eb014f0d4b42",
"install_path": "NEKOPARA Vol. 2/adultsonly.xp3",
"plugin_path": "vol.2/adultsonly.xp3",
},
"NEKOPARA Vol.3": {
"exe": "NEKOPARAvol3.exe",
"hash": "2ce7b223c84592e1ebc3b72079dee1e5e8d064ade15723328a64dee58833b9d5",
"install_path": "NEKOPARA Vol. 3/update00.int",
"plugin_path": "vol.3/update00.int",
},
"NEKOPARA Vol.4": {
"exe": "nekopara_vol4.exe",
"hash": "4a4a9ae5a75a18aacbe3ab0774d7f93f99c046afe3a777ee0363e8932b90f36a",
"install_path": "NEKOPARA Vol. 4/vol4adult.xp3",
"plugin_path": "vol.4/vol4adult.xp3",
},
"NEKOPARA After": {
"exe": "nekopara_after.exe",
"hash": "eb26ff6850096a240af8340ba21c5c3232e90f29fb8191e24b6ce701acae0aa9",
"install_path": "NEKOPARA After/afteradult.xp3",
"plugin_path": "after/afteradult.xp3",
"sig_path": "after/afteradult.xp3.sig"
},
},
}
# Base64解码
def decode_base64(encoded_str):
return base64.b64decode(encoded_str).decode("utf-8")
# 全局变量
APP_VERSION = app_data["APP_VERSION"]
APP_NAME = app_data["APP_NAME"]
TEMP = os.getenv(app_data["TEMP"]) or app_data["TEMP"]
CACHE = os.path.join(TEMP, app_data["CACHE"])
CONFIG_FILE = os.path.join(CACHE, "config.json")
# 将log文件放在程序根目录下的log文件夹中使用日期+时间戳格式命名
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
log_dir = os.path.join(root_dir, "log")
current_datetime = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
LOG_FILE = os.path.join(log_dir, f"log-{current_datetime}.txt")
PLUGIN = os.path.join(CACHE, app_data["PLUGIN"])
CONFIG_URL = decode_base64(app_data["CONFIG_URL"])
UA = app_data["UA_TEMPLATE"].format(APP_VERSION)
GAME_INFO = app_data["game_info"]
BLOCK_SIZE = 67108864
HASH_SIZE = 134217728
PLUGIN_HASH = {
"NEKOPARA Vol.1": GAME_INFO["NEKOPARA Vol.1"]["hash"],
"NEKOPARA Vol.2": GAME_INFO["NEKOPARA Vol.2"]["hash"],
"NEKOPARA Vol.3": GAME_INFO["NEKOPARA Vol.3"]["hash"],
"NEKOPARA Vol.4": GAME_INFO["NEKOPARA Vol.4"]["hash"],
"NEKOPARA After": GAME_INFO["NEKOPARA After"]["hash"]
}
PROCESS_INFO = {info["exe"]: game for game, info in GAME_INFO.items()}
# 下载线程档位设置
DOWNLOAD_THREADS = {
"low": 1, # 低速
"medium": 8, # 中速(默认)
"high": 16, # 高速
"extreme": 32, # 极速
"insane": 64 # 狂暴
}
# 默认下载线程档位
DEFAULT_DOWNLOAD_THREAD_LEVEL = "high"

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import re
import sys
from datetime import datetime
from utils.logger import setup_logger
# 初始化logger
logger = setup_logger("privacy_policy")
# 隐私协议的缩略版内容
PRIVACY_POLICY_BRIEF = """
# FRAISEMOE Addons Installer NEXT 隐私政策摘要
本应用在运行过程中会收集和处理以下信息:
## 收集的信息
- **系统信息**:程序版本号。
- **网络信息**IP 地址、ISP、地理位置用于使用统计、下载统计、IPv6 连接测试(通过访问 testipv6.cn、IPv6 地址获取(通过 ipw.cn
- **文件信息**:游戏安装路径、文件哈希值。
## 系统修改
- 使用 Cloudflare 加速时会临时修改系统 hosts 文件。
- 修改前会自动备份,程序退出时自动恢复。
## 第三方服务
- **Cloudflare 服务**:通过开源项目 CloudflareSpeedTest (CFST) 提供,用于优化下载速度。此过程会将您的 IP 提交至 Cloudflare 节点。
- **云端配置服务**:获取配置信息。服务器会记录您的 IP、ISP 及地理位置用于统计。
- **IPv6 测试服务**:应用使用 testipv6.cn 和 ipw.cn 测试和获取 IPv6 连接信息。
完整的隐私政策可在本程序的 GitHub 仓库中查看。
"""
# 隐私协议的英文版缩略版内容
PRIVACY_POLICY_BRIEF_EN = """
# FRAISEMOE Addons Installer NEXT Privacy Policy Summary
This application collects and processes the following information:
## Information Collected
- **System info**: Application version.
- **Network info**: IP address, ISP, geographic location (for usage statistics), download statistics, IPv6 connectivity test (via testipv6.cn), IPv6 address acquisition (via ipw.cn).
- **File info**: Game installation paths, file hash values.
## System Modifications
- Temporarily modifies system hosts file when using Cloudflare acceleration.
- Automatically backs up before modification and restores upon exit.
## Third-party Services
- **Cloudflare services**: Provided via the open-source project CloudflareSpeedTest (CFST) to optimize download speeds. This process submits your IP to Cloudflare nodes.
- **Cloud configuration services**: For obtaining configuration information. The server logs your IP, ISP, and location for statistical purposes.
- **IPv6 testing services**: The application uses testipv6.cn and ipw.cn to test and retrieve IPv6 connection information.
The complete privacy policy can be found in the program's GitHub repository.
"""
# 默认隐私协议版本 - 本地版本的日期
PRIVACY_POLICY_VERSION = "2025.08.04"
def get_local_privacy_policy():
"""获取本地打包的隐私协议文件
Returns:
tuple: (隐私协议内容, 版本号, 错误信息)
"""
# 尝试不同的可能路径
possible_paths = [
"PRIVACY.md", # 相对于可执行文件
os.path.join(os.path.dirname(sys.executable), "PRIVACY.md"), # 可执行文件目录
os.path.join(os.path.dirname(__file__), "PRIVACY.md"), # 当前模块目录
]
for path in possible_paths:
try:
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# 提取更新日期
date_pattern = r'最后更新日期:(\d{4}\d{1,2}月\d{1,2}日)'
match = re.search(date_pattern, content)
if match:
date_str = match.group(1)
try:
date_obj = datetime.strptime(date_str, '%Y年%m月%d')
date_version = date_obj.strftime('%Y.%m.%d')
logger.info(f"成功读取本地隐私协议文件: {path}, 版本: {date_version}")
return content, date_version, ""
except ValueError:
logger.error(f"本地隐私协议日期格式解析错误: {path}")
else:
logger.warning(f"本地隐私协议未找到更新日期: {path}")
except Exception as e:
logger.error(f"读取本地隐私协议失败 {path}: {str(e)}")
# 所有路径都尝试失败,使用默认版本
return PRIVACY_POLICY_BRIEF, PRIVACY_POLICY_VERSION, "无法读取本地隐私协议文件"

View File

@@ -1,16 +1,16 @@
from .animations import MultiStageAnimations
from .ui_manager import UIManager
from .download_manager import DownloadManager
from .debug_manager import DebugManager
from .window_manager import WindowManager
from .game_detector import GameDetector
from .patch_manager import PatchManager
from .config_manager import ConfigManager
from .privacy_manager import PrivacyManager
from .cloudflare_optimizer import CloudflareOptimizer
from .download_task_manager import DownloadTaskManager
from .extraction_handler import ExtractionHandler
from .patch_detector import PatchDetector
from .managers.ui_manager import UIManager
from .managers.download_manager import DownloadManager
from .managers.debug_manager import DebugManager
from .managers.window_manager import WindowManager
from .managers.game_detector import GameDetector
from .managers.patch_manager import PatchManager
from .managers.config_manager import ConfigManager
from .managers.privacy_manager import PrivacyManager
from .managers.cloudflare_optimizer import CloudflareOptimizer
from .managers.download_task_manager import DownloadTaskManager
from .managers.patch_detector import PatchDetector
from .managers.animations import MultiStageAnimations
from .handlers.extraction_handler import ExtractionHandler
__all__ = [
'MultiStageAnimations',

View File

@@ -0,0 +1,10 @@
# Handlers package initialization
from .extraction_handler import ExtractionHandler
from .patch_toggle_handler import PatchToggleHandler
from .uninstall_handler import UninstallHandler
__all__ = [
'ExtractionHandler',
'PatchToggleHandler',
'UninstallHandler',
]

View File

@@ -0,0 +1,265 @@
import os
import shutil
from PySide6 import QtWidgets
from PySide6.QtWidgets import QMessageBox
from PySide6.QtCore import QTimer, QCoreApplication
from utils.logger import setup_logger
# 初始化logger
logger = setup_logger("extraction_handler")
class ExtractionHandler:
"""解压处理器,负责管理解压任务和结果处理"""
def __init__(self, main_window):
"""初始化解压处理器
Args:
main_window: 主窗口实例用于访问UI和状态
"""
self.main_window = main_window
self.APP_NAME = main_window.APP_NAME if hasattr(main_window, 'APP_NAME') else ""
self.extraction_progress_window = None
def start_extraction(self, _7z_path, game_folder, plugin_path, game_version, extracted_path=None):
"""开始解压任务
Args:
_7z_path: 7z文件路径
game_folder: 游戏文件夹路径
plugin_path: 插件路径
game_version: 游戏版本名称
extracted_path: 已解压的补丁文件路径,如果提供则直接使用它而不进行解压
"""
# 检查是否处于离线模式
is_offline = False
if hasattr(self.main_window, 'offline_mode_manager'):
is_offline = self.main_window.offline_mode_manager.is_in_offline_mode()
# 创建并显示解压进度窗口,替代原来的消息框
self.extraction_progress_window = self.main_window.create_extraction_progress_window()
self.extraction_progress_window.show()
# 确保UI更新
QCoreApplication.processEvents()
# 创建并启动解压线程
self.main_window.extraction_thread = self.main_window.create_extraction_thread(
_7z_path, game_folder, plugin_path, game_version, extracted_path
)
# 连接进度信号
self.main_window.extraction_thread.progress.connect(self.update_extraction_progress)
# 连接完成信号
self.main_window.extraction_thread.finished.connect(self.on_extraction_finished_with_hash_check)
# 启动线程
self.main_window.extraction_thread.start()
def update_extraction_progress(self, progress, status_text):
"""更新解压进度
Args:
progress: 进度百分比
status_text: 状态文本
"""
if self.extraction_progress_window and hasattr(self.extraction_progress_window, 'progress_bar'):
self.extraction_progress_window.progress_bar.setValue(progress)
self.extraction_progress_window.status_label.setText(status_text)
# 确保UI更新
QCoreApplication.processEvents()
def on_extraction_finished_with_hash_check(self, success, error_message, game_version):
"""解压完成后进行哈希校验
Args:
success: 是否解压成功
error_message: 错误信息
game_version: 游戏版本
"""
# 关闭解压进度窗口
if self.extraction_progress_window:
self.extraction_progress_window.close()
self.extraction_progress_window = None
# 如果解压失败,显示错误并询问是否继续
if not success:
# 临时启用窗口以显示错误消息
self.main_window.setEnabled(True)
QtWidgets.QMessageBox.critical(self.main_window, f"错误 - {self.APP_NAME}", error_message)
self.main_window.installed_status[game_version] = False
# 询问用户是否继续其他游戏的安装
reply = QtWidgets.QMessageBox.question(
self.main_window,
f"继续安装? - {self.APP_NAME}",
f"\n{game_version} 的补丁安装失败。\n\n是否继续安装其他游戏的补丁?\n",
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
QtWidgets.QMessageBox.StandardButton.Yes
)
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
# 继续下一个,重新禁用窗口
self.main_window.setEnabled(False)
# 通知DownloadManager继续下一个下载任务
self.main_window.download_manager.on_extraction_finished(True)
else:
# 用户选择停止,保持窗口启用状态
self.main_window.ui.start_install_text.setText("开始安装")
# 通知DownloadManager停止下载队列
self.main_window.download_manager.on_extraction_finished(False)
return
# 解压成功,进行哈希校验
self._perform_hash_check(game_version)
def _perform_hash_check(self, game_version):
"""解压成功后进行哈希校验
Args:
game_version: 游戏版本
"""
# 导入所需模块
from data.config import GAME_INFO, PLUGIN_HASH
from workers.hash_thread import HashThread
# 获取安装路径
install_paths = {}
if hasattr(self.main_window, 'game_detector') and hasattr(self.main_window, 'download_manager'):
game_dirs = self.main_window.game_detector.identify_game_directories_improved(
self.main_window.download_manager.selected_folder
)
for game, info in GAME_INFO.items():
if game in game_dirs and game == game_version:
game_dir = game_dirs[game]
install_path = os.path.join(game_dir, os.path.basename(info["install_path"]))
install_paths[game] = install_path
break
if not install_paths:
# 如果找不到安装路径,直接认为安装成功
logger.warning(f"未找到 {game_version} 的安装路径,跳过哈希校验")
self.main_window.installed_status[game_version] = True
self.main_window.download_manager.on_extraction_finished(True)
return
# 关闭可能存在的哈希校验窗口
self.main_window.close_hash_msg_box()
# 显示哈希校验窗口
self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(
check_type="post",
auto_close=True, # 添加自动关闭参数
close_delay=1000 # 1秒后自动关闭
)
# 直接创建并启动哈希线程进行校验
hash_thread = HashThread(
"after",
install_paths,
PLUGIN_HASH,
self.main_window.installed_status,
self.main_window
)
hash_thread.after_finished.connect(self.on_hash_check_finished)
# 保存引用以便后续使用
self.hash_thread = hash_thread
hash_thread.start()
def on_hash_check_finished(self, result):
"""哈希校验完成后的处理
Args:
result: 校验结果,包含通过状态、游戏版本和消息
"""
# 导入所需模块
from data.config import GAME_INFO
# 关闭哈希检查窗口
self.main_window.close_hash_msg_box()
if not result["passed"]:
# 校验失败,删除已解压的文件并提示重新下载
game_version = result["game"]
error_message = result["message"]
# 临时启用窗口以显示错误消息
self.main_window.setEnabled(True)
# 获取安装路径
install_path = None
if hasattr(self.main_window, 'game_detector') and hasattr(self.main_window, 'download_manager'):
game_dirs = self.main_window.game_detector.identify_game_directories_improved(
self.main_window.download_manager.selected_folder
)
if game_version in game_dirs and game_version in GAME_INFO:
game_dir = game_dirs[game_version]
install_path = os.path.join(game_dir, os.path.basename(GAME_INFO[game_version]["install_path"]))
# 如果找到安装路径,尝试删除已解压的文件
if install_path and os.path.exists(install_path):
try:
os.remove(install_path)
logger.info(f"已删除校验失败的文件: {install_path}")
except Exception as e:
logger.error(f"删除文件失败: {e}")
# 显示错误消息并询问是否重试
reply = QtWidgets.QMessageBox.question(
self.main_window,
f"校验失败 - {self.APP_NAME}",
f"{error_message}\n\n是否重新下载并安装?",
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
QtWidgets.QMessageBox.StandardButton.Yes
)
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
# 重新下载,将游戏重新添加到下载队列
self.main_window.setEnabled(False)
self.main_window.installed_status[game_version] = False
# 获取游戏目录和下载URL
if hasattr(self.main_window, 'download_manager') and hasattr(self.main_window, 'game_detector'):
game_dirs = self.main_window.game_detector.identify_game_directories_improved(
self.main_window.download_manager.selected_folder
)
if game_version in game_dirs:
# 重新将游戏添加到下载队列
self.main_window.download_manager.download_queue.appendleft([game_version])
# 继续下一个下载任务
self.main_window.download_manager.next_download_task()
else:
# 如果找不到游戏目录,继续下一个
self.main_window.download_manager.on_extraction_finished(True)
else:
# 如果无法重新下载,继续下一个
self.main_window.download_manager.on_extraction_finished(True)
else:
# 用户选择不重试,继续下一个
self.main_window.installed_status[game_version] = False
self.main_window.download_manager.on_extraction_finished(True)
else:
# 校验通过,更新安装状态
game_version = result["game"]
self.main_window.installed_status[game_version] = True
# 通知DownloadManager继续下一个下载任务
self.main_window.download_manager.on_extraction_finished(True)
def on_extraction_finished(self, success, error_message, game_version):
"""兼容旧版本的回调函数
Args:
success: 是否解压成功
error_message: 错误信息
game_version: 游戏版本
"""
# 调用新的带哈希校验的回调函数
self.on_extraction_finished_with_hash_check(success, error_message, game_version)

View File

@@ -0,0 +1,438 @@
import os
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QListWidget, QPushButton, QHBoxLayout,
QAbstractItemView, QRadioButton, QButtonGroup, QFileDialog, QMessageBox
)
from PySide6.QtCore import QObject, Signal, QThread
from PySide6.QtGui import QFont
from utils import msgbox_frame
from utils.logger import setup_logger
# 初始化logger
logger = setup_logger("patch_toggle_handler")
class PatchToggleThread(QThread):
"""在后台线程中处理补丁切换逻辑"""
finished = Signal(object)
def __init__(self, handler, selected_folder):
super().__init__()
self.handler = handler
self.selected_folder = selected_folder
def run(self):
# 在后台线程中执行耗时操作
game_dirs = self.handler.game_detector.identify_game_directories_improved(self.selected_folder)
self.finished.emit(game_dirs)
class PatchToggleHandler(QObject):
"""
处理补丁启用/禁用功能的类
"""
def __init__(self, main_window):
"""
初始化补丁切换处理程序
Args:
main_window: 主窗口实例,用于访问其他组件
"""
super().__init__()
self.main_window = main_window
self.debug_manager = main_window.debug_manager
self.game_detector = main_window.game_detector
self.patch_manager = main_window.patch_manager
self.app_name = main_window.patch_manager.app_name
self.toggle_thread = None
def handle_toggle_patch_button_click(self):
"""
处理禁/启用补丁按钮点击事件
打开文件选择对话框选择游戏目录,然后禁用或启用对应游戏的补丁
"""
selected_folder = QFileDialog.getExistingDirectory(self.main_window, "选择游戏上级目录", "")
if not selected_folder:
return
self.main_window.show_loading_dialog("正在识别游戏目录并检查补丁状态...")
self.toggle_thread = PatchToggleThread(self, selected_folder)
self.toggle_thread.finished.connect(self.on_game_detection_finished)
self.toggle_thread.start()
def on_game_detection_finished(self, game_dirs):
"""游戏识别完成后的回调"""
self.main_window.hide_loading_dialog()
if not game_dirs:
QMessageBox.information(
self.main_window,
f"提示 - {self.app_name}",
"\n未在选择的目录中找到任何支持的游戏。\n",
)
return
games_with_patch = {}
for game_version, game_dir in game_dirs.items():
if self.patch_manager.check_patch_installed(game_dir, game_version):
is_disabled, _ = self.patch_manager.check_patch_disabled(game_dir, game_version)
status = "已禁用" if is_disabled else "已启用"
games_with_patch[game_version] = {"dir": game_dir, "status": status}
if not games_with_patch:
QMessageBox.information(
self.main_window,
f"提示 - {self.app_name}",
"\n目录中未找到已安装补丁的游戏。\n",
)
return
selected_games, operation = self._show_multi_game_dialog(games_with_patch)
if not selected_games:
return
selected_game_dirs = {game: games_with_patch[game]["dir"] for game in selected_games if game in games_with_patch}
self._execute_batch_toggle(selected_game_dirs, operation)
def _handle_multiple_games(self, game_dirs, debug_mode):
"""
处理多个游戏的补丁切换
Args:
game_dirs: 游戏目录字典
debug_mode: 是否为调试模式
"""
if debug_mode:
logger.debug(f"DEBUG: 禁/启用功能 - 在上级目录中找到以下游戏: {list(game_dirs.keys())}")
# 查找已安装补丁的游戏,只处理那些已安装补丁的游戏
games_with_patch = {}
for game_version, game_dir in game_dirs.items():
if self.patch_manager.check_patch_installed(game_dir, game_version):
# 检查补丁当前状态(是否禁用)
is_disabled, disabled_path = self.patch_manager.check_patch_disabled(game_dir, game_version)
status = "已禁用" if is_disabled else "已启用"
games_with_patch[game_version] = {
"dir": game_dir,
"disabled": is_disabled,
"status": status
}
if debug_mode:
logger.debug(f"DEBUG: 禁/启用功能 - {game_version} 已安装补丁,当前状态: {status}")
# 检查是否有已安装补丁的游戏
if not games_with_patch:
QMessageBox.information(
self.main_window,
f"提示 - {self.app_name}",
"\n未在选择的目录中找到已安装补丁的游戏。\n请确认您选择了正确的游戏目录,并且该目录中的游戏已安装过补丁。\n",
QMessageBox.StandardButton.Ok
)
return
# 显示选择对话框
selected_games, operation = self._show_multi_game_dialog(games_with_patch)
if not selected_games:
return # 用户取消了操作
# 过滤games_with_patch只保留选中的游戏
selected_game_dirs = {}
for game in selected_games:
if game in games_with_patch:
selected_game_dirs[game] = games_with_patch[game]["dir"]
# 确认操作
operation_text = "禁用" if operation == "disable" else "启用" if operation == "enable" else "切换"
game_list = '\n'.join([f"{game} ({games_with_patch[game]['status']})" for game in selected_games])
reply = QMessageBox.question(
self.main_window,
f"确认{operation_text}操作 - {self.app_name}",
f"\n确定要{operation_text}以下游戏补丁吗?\n\n{game_list}\n",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.No:
return
# 执行批量操作
self._execute_batch_toggle(selected_game_dirs, operation, debug_mode)
def _handle_single_game(self, selected_folder, debug_mode):
"""
处理单个游戏的补丁切换
Args:
selected_folder: 选择的游戏目录
debug_mode: 是否为调试模式
"""
# 未找到游戏目录,尝试将选择的目录作为游戏目录
if debug_mode:
logger.debug(f"DEBUG: 禁/启用功能 - 未在上级目录找到游戏,尝试将选择的目录视为游戏目录")
game_version = self.game_detector.identify_game_version(selected_folder)
if game_version:
if debug_mode:
logger.debug(f"DEBUG: 禁/启用功能 - 识别为游戏: {game_version}")
# 检查是否已安装补丁
if self.patch_manager.check_patch_installed(selected_folder, game_version):
# 检查补丁当前状态
is_disabled, disabled_path = self.patch_manager.check_patch_disabled(selected_folder, game_version)
current_status = "已禁用" if is_disabled else "已启用"
# 显示单游戏操作对话框
operation = self._show_single_game_dialog(game_version, current_status, is_disabled)
if not operation:
return # 用户取消了操作
# 执行操作
result = self.patch_manager.toggle_patch(selected_folder, game_version, operation=operation)
if not result["success"]:
# 操作失败的消息已在toggle_patch中显示
pass
else:
# 没有安装补丁
QMessageBox.information(
self.main_window,
f"提示 - {self.app_name}",
f"\n未在 {game_version} 中找到已安装的补丁。\n请确认该游戏已经安装过补丁。\n",
QMessageBox.StandardButton.Ok
)
else:
# 两种方式都未识别到游戏
if debug_mode:
logger.debug(f"DEBUG: 禁/启用功能 - 无法识别游戏")
msg_box = msgbox_frame(
f"错误 - {self.app_name}",
"\n所选目录不是有效的NEKOPARA游戏目录。\n请选择包含游戏可执行文件的目录或其上级目录。\n",
QMessageBox.StandardButton.Ok,
)
msg_box.exec()
def _show_multi_game_dialog(self, games_with_patch):
"""
显示多游戏选择对话框
Args:
games_with_patch: 已安装补丁的游戏信息
Returns:
tuple: (选择的游戏列表, 操作类型)
"""
dialog = QDialog(self.main_window)
dialog.setWindowTitle("选择要操作的游戏补丁")
dialog.resize(400, 400) # 增加高度以适应新增的单选按钮
layout = QVBoxLayout(dialog)
# 添加"已安装补丁的游戏"标签
already_installed_label = QLabel("已安装补丁的游戏:", dialog)
already_installed_label.setFont(QFont(already_installed_label.font().family(), already_installed_label.font().pointSize(), QFont.Bold))
layout.addWidget(already_installed_label)
# 添加游戏列表和状态
games_status_text = ""
for game, info in games_with_patch.items():
games_status_text += f"{game} ({info['status']})\n"
games_status_label = QLabel(games_status_text.strip(), dialog)
layout.addWidget(games_status_label)
# 添加一些间距
layout.addSpacing(10)
# 添加"请选择要操作的游戏"标签
info_label = QLabel("请选择要操作的游戏:", dialog)
info_label.setFont(QFont(info_label.font().family(), info_label.font().pointSize(), QFont.Bold))
layout.addWidget(info_label)
# 添加列表控件,只显示已安装补丁的游戏
list_widget = QListWidget(dialog)
list_widget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # 允许多选
for game, info in games_with_patch.items():
list_widget.addItem(f"{game} ({info['status']})")
layout.addWidget(list_widget)
# 添加全选按钮
select_all_btn = QPushButton("全选", dialog)
select_all_btn.clicked.connect(lambda: list_widget.selectAll())
layout.addWidget(select_all_btn)
# 添加操作选择单选按钮
operation_label = QLabel("请选择要执行的操作:", dialog)
operation_label.setFont(QFont(operation_label.font().family(), operation_label.font().pointSize(), QFont.Bold))
layout.addWidget(operation_label)
# 创建单选按钮组
radio_button_group = QButtonGroup(dialog)
# 添加"自动切换状态"单选按钮(默认选中)
auto_toggle_radio = QRadioButton("自动切换状态(禁用<->启用)", dialog)
auto_toggle_radio.setChecked(True)
radio_button_group.addButton(auto_toggle_radio, 0)
layout.addWidget(auto_toggle_radio)
# 添加"全部禁用"单选按钮
disable_all_radio = QRadioButton("禁用选中的补丁", dialog)
radio_button_group.addButton(disable_all_radio, 1)
layout.addWidget(disable_all_radio)
# 添加"全部启用"单选按钮
enable_all_radio = QRadioButton("启用选中的补丁", dialog)
radio_button_group.addButton(enable_all_radio, 2)
layout.addWidget(enable_all_radio)
# 添加确定和取消按钮
buttons_layout = QHBoxLayout()
ok_button = QPushButton("确定", dialog)
cancel_button = QPushButton("取消", dialog)
buttons_layout.addWidget(ok_button)
buttons_layout.addWidget(cancel_button)
layout.addLayout(buttons_layout)
# 连接按钮事件
ok_button.clicked.connect(dialog.accept)
cancel_button.clicked.connect(dialog.reject)
# 显示对话框并等待用户选择
result = dialog.exec()
if result != QDialog.DialogCode.Accepted or list_widget.selectedItems() == []:
# 用户取消或未选择任何游戏
return [], None
# 获取用户选择的游戏
selected_items = [item.text() for item in list_widget.selectedItems()]
selected_games = []
# 从选中项文本中提取游戏名称
for item in selected_items:
# 去除状态后缀 " (已启用)" 或 " (已禁用)"
game_name = item.split(" (")[0]
selected_games.append(game_name)
# 获取选中的操作类型
operation = None
if radio_button_group.checkedId() == 1: # 禁用选中的补丁
operation = "disable"
elif radio_button_group.checkedId() == 2: # 启用选中的补丁
operation = "enable"
# 否则为None表示自动切换状态
return selected_games, operation
def _show_single_game_dialog(self, game_version, current_status, is_disabled):
"""
显示单游戏操作对话框
Args:
game_version: 游戏版本
current_status: 当前补丁状态
is_disabled: 是否已禁用
Returns:
str: 操作类型,"enable""disable"或None表示取消
"""
dialog = QDialog(self.main_window)
dialog.setWindowTitle(f"{game_version} 补丁操作")
dialog.resize(300, 200)
layout = QVBoxLayout(dialog)
# 添加当前状态标签
status_label = QLabel(f"当前补丁状态: {current_status}", dialog)
status_label.setFont(QFont(status_label.font().family(), status_label.font().pointSize(), QFont.Bold))
layout.addWidget(status_label)
# 添加操作选择单选按钮
operation_label = QLabel("请选择要执行的操作:", dialog)
layout.addWidget(operation_label)
# 创建单选按钮组
radio_button_group = QButtonGroup(dialog)
# 添加可选操作
if is_disabled:
# 当前是禁用状态,显示启用选项
enable_radio = QRadioButton("启用补丁", dialog)
enable_radio.setChecked(True)
radio_button_group.addButton(enable_radio, 0)
layout.addWidget(enable_radio)
else:
# 当前是启用状态,显示禁用选项
disable_radio = QRadioButton("禁用补丁", dialog)
disable_radio.setChecked(True)
radio_button_group.addButton(disable_radio, 0)
layout.addWidget(disable_radio)
# 添加确定和取消按钮
buttons_layout = QHBoxLayout()
ok_button = QPushButton("确定", dialog)
cancel_button = QPushButton("取消", dialog)
buttons_layout.addWidget(ok_button)
buttons_layout.addWidget(cancel_button)
layout.addLayout(buttons_layout)
# 连接按钮事件
ok_button.clicked.connect(dialog.accept)
cancel_button.clicked.connect(dialog.reject)
# 显示对话框并等待用户选择
result = dialog.exec()
if result != QDialog.DialogCode.Accepted:
# 用户取消
return None
# 根据当前状态确定操作
return "enable" if is_disabled else "disable"
def _execute_batch_toggle(self, selected_game_dirs, operation, debug_mode):
"""
执行批量补丁切换操作
Args:
selected_game_dirs: 选择的游戏目录
operation: 操作类型
debug_mode: 是否为调试模式
"""
success_count = 0
fail_count = 0
results = []
for game_version, game_dir in selected_game_dirs.items():
try:
# 使用静默模式进行操作
result = self.patch_manager.toggle_patch(game_dir, game_version, operation=operation, silent=True)
if result["success"]:
success_count += 1
else:
fail_count += 1
results.append({
"version": game_version,
"success": result["success"],
"message": result["message"],
"action": result["action"]
})
except Exception as e:
if debug_mode:
logger.error(f"DEBUG: 切换 {game_version} 补丁状态时出错: {str(e)}")
fail_count += 1
results.append({
"version": game_version,
"success": False,
"message": f"操作出错: {str(e)}",
"action": "none"
})
# 显示操作结果
self.patch_manager.show_toggle_result(success_count, fail_count, results)

View File

@@ -0,0 +1,388 @@
import os
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QListWidget, QPushButton, QHBoxLayout,
QAbstractItemView, QFileDialog, QMessageBox
)
from PySide6.QtCore import QObject, Signal, QThread
from PySide6.QtGui import QFont
from utils import msgbox_frame
from utils.logger import setup_logger
# 初始化logger
logger = setup_logger("uninstall_handler")
class UninstallThread(QThread):
"""在后台线程中处理卸载逻辑"""
finished = Signal(object)
def __init__(self, handler, selected_folder):
super().__init__()
self.handler = handler
self.selected_folder = selected_folder
def run(self):
# 在后台线程中执行耗时操作
game_dirs = self.handler.game_detector.identify_game_directories_improved(self.selected_folder)
self.finished.emit(game_dirs)
class UninstallHandler(QObject):
"""
处理补丁卸载功能的类
"""
def __init__(self, main_window):
"""
初始化卸载处理程序
Args:
main_window: 主窗口实例,用于访问其他组件
"""
super().__init__()
self.main_window = main_window
self.debug_manager = main_window.debug_manager
self.game_detector = main_window.game_detector
self.patch_manager = main_window.patch_manager
self.app_name = main_window.patch_manager.app_name
self.uninstall_thread = None
# 记录初始化日志
debug_mode = self.debug_manager._is_debug_mode() if hasattr(self.debug_manager, '_is_debug_mode') else False
if debug_mode:
logger.debug("DEBUG: 卸载处理程序已初始化")
def handle_uninstall_button_click(self):
"""
处理卸载补丁按钮点击事件
打开文件选择对话框选择游戏目录,然后卸载对应游戏的补丁
"""
# 获取游戏目录
debug_mode = self.debug_manager._is_debug_mode()
logger.info("用户点击了卸载补丁按钮")
if debug_mode:
logger.debug("DEBUG: 处理卸载补丁按钮点击事件")
# 提示用户选择目录
file_dialog_info = "选择游戏上级目录" if debug_mode else "选择游戏目录"
selected_folder = QFileDialog.getExistingDirectory(self.main_window, file_dialog_info, "")
if not selected_folder or selected_folder == "":
logger.info("用户取消了目录选择")
if debug_mode:
logger.debug("DEBUG: 用户取消了目录选择,退出卸载流程")
return # 用户取消了选择
logger.info(f"用户选择了目录: {selected_folder}")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 用户选择了目录: {selected_folder}")
self.main_window.show_loading_dialog("正在识别游戏目录...")
self.uninstall_thread = UninstallThread(self, selected_folder)
self.uninstall_thread.finished.connect(self.on_game_detection_finished)
self.uninstall_thread.start()
def on_game_detection_finished(self, game_dirs):
"""游戏识别完成后的回调"""
self.main_window.hide_loading_dialog()
if not game_dirs:
QMessageBox.information(
self.main_window,
f"提示 - {self.app_name}",
"\n未在选择的目录中找到任何支持的游戏。\n",
)
return
games_with_patch = {}
for game_version, game_dir in game_dirs.items():
if self.patch_manager.check_patch_installed(game_dir, game_version):
games_with_patch[game_version] = game_dir
if not games_with_patch:
QMessageBox.information(
self.main_window,
f"提示 - {self.app_name}",
"\n目录中未找到已安装补丁的游戏。\n",
)
return
selected_games = self._show_game_selection_dialog(games_with_patch)
if not selected_games:
return
selected_game_dirs = {game: games_with_patch[game] for game in selected_games if game in games_with_patch}
game_list = '\n'.join(selected_games)
reply = QMessageBox.question(
self.main_window,
f"确认卸载 - {self.app_name}",
f"\n确定要卸载以下游戏的补丁吗?\n\n{game_list}\n",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.No:
return
success_count, fail_count, results = self.patch_manager.batch_uninstall_patches(selected_game_dirs)
self.patch_manager.show_uninstall_result(success_count, fail_count, results)
def _handle_multiple_games(self, game_dirs, debug_mode):
"""
处理多个游戏的补丁卸载
Args:
game_dirs: 游戏目录字典
debug_mode: 是否为调试模式
"""
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 在上级目录中找到以下游戏: {list(game_dirs.keys())}")
# 查找已安装补丁的游戏,只处理那些已安装补丁的游戏
logger.info("检查哪些游戏已安装补丁")
games_with_patch = {}
for game_version, game_dir in game_dirs.items():
is_installed = self.patch_manager.check_patch_installed(game_dir, game_version)
if is_installed:
games_with_patch[game_version] = game_dir
logger.info(f"游戏 {game_version} 已安装补丁")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - {game_version} 已安装补丁,目录: {game_dir}")
else:
logger.info(f"游戏 {game_version} 未安装补丁")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - {game_version} 未安装补丁,跳过")
# 检查是否有已安装补丁的游戏
if not games_with_patch:
logger.info("未找到已安装补丁的游戏")
if debug_mode:
logger.debug("DEBUG: 卸载功能 - 未找到已安装补丁的游戏,显示提示消息")
QMessageBox.information(
self.main_window,
f"提示 - {self.app_name}",
"\n未在选择的目录中找到已安装补丁的游戏。\n请确认您选择了正确的游戏目录,并且该目录中的游戏已安装过补丁。\n",
QMessageBox.StandardButton.Ok
)
return
# 显示选择对话框
logger.info("显示游戏选择对话框")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 显示游戏选择对话框,可选游戏: {list(games_with_patch.keys())}")
selected_games = self._show_game_selection_dialog(games_with_patch)
if not selected_games:
logger.info("用户未选择任何游戏或取消了选择")
if debug_mode:
logger.debug("DEBUG: 卸载功能 - 用户未选择任何游戏或取消了选择,退出卸载流程")
return # 用户取消了选择
logger.info(f"用户选择了以下游戏: {selected_games}")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 用户选择了以下游戏: {selected_games}")
# 过滤game_dirs只保留选中的游戏
selected_game_dirs = {game: games_with_patch[game] for game in selected_games if game in games_with_patch}
# 确认卸载
game_list = '\n'.join(selected_games)
logger.info("显示卸载确认对话框")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 显示卸载确认对话框,选择的游戏: {selected_games}")
reply = QMessageBox.question(
self.main_window,
f"确认卸载 - {self.app_name}",
f"\n确定要卸载以下游戏的补丁吗?\n\n{game_list}\n",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.No:
logger.info("用户取消了卸载操作")
if debug_mode:
logger.debug("DEBUG: 卸载功能 - 用户取消了卸载操作,退出卸载流程")
return
logger.info("开始批量卸载补丁")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 开始批量卸载补丁,游戏: {list(selected_game_dirs.keys())}")
# 使用批量卸载方法
success_count, fail_count, results = self.patch_manager.batch_uninstall_patches(selected_game_dirs)
logger.info(f"批量卸载完成,成功: {success_count},失败: {fail_count}")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 批量卸载完成,成功: {success_count},失败: {fail_count}")
if results:
for result in results:
status = "成功" if result["success"] else "失败"
logger.debug(f"DEBUG: 卸载结果 - {result['version']}: {status}, 消息: {result['message']}, 删除文件数: {result['files_removed']}")
self.patch_manager.show_uninstall_result(success_count, fail_count, results)
def _handle_single_game(self, selected_folder, debug_mode):
"""
处理单个游戏的补丁卸载
Args:
selected_folder: 选择的游戏目录
debug_mode: 是否为调试模式
"""
# 未找到游戏目录,尝试将选择的目录作为游戏目录
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 未在上级目录找到游戏,尝试将选择的目录视为游戏目录")
logger.info("尝试识别单个游戏版本")
game_version = self.game_detector.identify_game_version(selected_folder)
if game_version:
logger.info(f"识别为游戏: {game_version}")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 识别为游戏: {game_version}")
# 检查是否已安装补丁
logger.info(f"检查 {game_version} 是否已安装补丁")
is_installed = self.patch_manager.check_patch_installed(selected_folder, game_version)
if is_installed:
logger.info(f"{game_version} 已安装补丁")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - {game_version} 已安装补丁")
# 确认卸载
logger.info("显示卸载确认对话框")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 显示卸载确认对话框,游戏: {game_version}")
reply = QMessageBox.question(
self.main_window,
f"确认卸载 - {self.app_name}",
f"\n确定要卸载 {game_version} 的补丁吗?\n游戏目录: {selected_folder}\n",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
logger.info(f"开始卸载 {game_version} 的补丁")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 用户确认卸载 {game_version} 的补丁")
# 创建单个游戏的目录字典,使用批量卸载流程
single_game_dir = {game_version: selected_folder}
logger.info("执行批量卸载方法(单游戏)")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 执行批量卸载方法(单游戏): {game_version}")
success_count, fail_count, results = self.patch_manager.batch_uninstall_patches(single_game_dir)
logger.info(f"卸载完成,成功: {success_count},失败: {fail_count}")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 卸载完成,成功: {success_count},失败: {fail_count}")
if results:
for result in results:
status = "成功" if result["success"] else "失败"
logger.debug(f"DEBUG: 卸载结果 - {result['version']}: {status}, 消息: {result['message']}, 删除文件数: {result['files_removed']}")
self.patch_manager.show_uninstall_result(success_count, fail_count, results)
else:
logger.info("用户取消了卸载操作")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 用户取消了卸载 {game_version} 的补丁")
else:
logger.info(f"{game_version} 未安装补丁")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - {game_version} 未安装补丁,显示提示消息")
# 没有安装补丁
QMessageBox.information(
self.main_window,
f"提示 - {self.app_name}",
f"\n未在 {game_version} 中找到已安装的补丁。\n请确认该游戏已经安装过补丁。\n",
QMessageBox.StandardButton.Ok
)
else:
# 两种方式都未识别到游戏
logger.info("无法识别游戏")
if debug_mode:
logger.debug(f"DEBUG: 卸载功能 - 无法识别游戏,显示错误消息")
msg_box = msgbox_frame(
f"错误 - {self.app_name}",
"\n所选目录不是有效的NEKOPARA游戏目录。\n请选择包含游戏可执行文件的目录或其上级目录。\n",
QMessageBox.StandardButton.Ok,
)
msg_box.exec()
def _show_game_selection_dialog(self, games_with_patch):
"""
显示游戏选择对话框
Args:
games_with_patch: 已安装补丁的游戏目录字典
Returns:
list: 选择的游戏列表
"""
dialog = QDialog(self.main_window)
dialog.setWindowTitle("选择要卸载的游戏补丁")
dialog.resize(400, 300)
layout = QVBoxLayout(dialog)
# 添加"已安装补丁的游戏"标签
already_installed_label = QLabel("已安装补丁的游戏:", dialog)
already_installed_label.setFont(QFont(already_installed_label.font().family(), already_installed_label.font().pointSize(), QFont.Weight.Bold))
layout.addWidget(already_installed_label)
# 添加已安装游戏列表(可选,这里使用静态标签替代,保持一致性)
installed_games_text = ", ".join(games_with_patch.keys())
installed_games_label = QLabel(installed_games_text, dialog)
layout.addWidget(installed_games_label)
# 添加一些间距
layout.addSpacing(10)
# 添加"请选择要卸载补丁的游戏"标签
info_label = QLabel("请选择要卸载补丁的游戏:", dialog)
info_label.setFont(QFont(info_label.font().family(), info_label.font().pointSize(), QFont.Weight.Bold))
layout.addWidget(info_label)
# 添加列表控件,只显示已安装补丁的游戏
list_widget = QListWidget(dialog)
list_widget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # 允许多选
for game in games_with_patch.keys():
list_widget.addItem(game)
layout.addWidget(list_widget)
# 添加全选按钮
select_all_btn = QPushButton("全选", dialog)
select_all_btn.clicked.connect(lambda: list_widget.selectAll())
layout.addWidget(select_all_btn)
# 添加确定和取消按钮
buttons_layout = QHBoxLayout()
ok_button = QPushButton("确定", dialog)
cancel_button = QPushButton("取消", dialog)
buttons_layout.addWidget(ok_button)
buttons_layout.addWidget(cancel_button)
layout.addLayout(buttons_layout)
# 连接按钮事件
ok_button.clicked.connect(dialog.accept)
cancel_button.clicked.connect(dialog.reject)
# 显示对话框并等待用户选择
result = dialog.exec()
if result != QDialog.DialogCode.Accepted or list_widget.selectedItems() == []:
# 用户取消或未选择任何游戏
return []
# 获取用户选择的游戏
return [item.text() for item in list_widget.selectedItems()]

View File

@@ -0,0 +1,28 @@
# Managers package initialization
from .ui_manager import UIManager
from .download_manager import DownloadManager
from .debug_manager import DebugManager
from .window_manager import WindowManager
from .game_detector import GameDetector
from .patch_manager import PatchManager
from .config_manager import ConfigManager
from .privacy_manager import PrivacyManager
from .cloudflare_optimizer import CloudflareOptimizer
from .download_task_manager import DownloadTaskManager
from .patch_detector import PatchDetector
from .animations import MultiStageAnimations
__all__ = [
'UIManager',
'DownloadManager',
'DebugManager',
'WindowManager',
'GameDetector',
'PatchManager',
'ConfigManager',
'PrivacyManager',
'CloudflareOptimizer',
'DownloadTaskManager',
'PatchDetector',
'MultiStageAnimations',
]

View File

@@ -0,0 +1,374 @@
import sys
from PySide6.QtCore import (QObject, QPropertyAnimation, QParallelAnimationGroup,
QPoint, QEasingCurve, QTimer, Signal, QRect)
from PySide6.QtWidgets import QGraphicsOpacityEffect, QPushButton
from PySide6.QtGui import QColor
class MultiStageAnimations(QObject):
animation_finished = Signal()
def __init__(self, ui, parent=None):
super().__init__(parent)
self.ui = ui
self.parent = parent # 保存父窗口引用以获取当前尺寸
# 获取画布尺寸 - 动态从父窗口获取
if parent:
self.canvas_width = parent.width()
self.canvas_height = parent.height()
else:
# 默认尺寸
self.canvas_width = 1280
self.canvas_height = 720
# 动画时序配置
self.animation_config = {
"logo": {
"delay_after": 2800
},
"mainbg": {
"delay_after": 500
},
"button_click": {
"scale_duration": 100,
"scale_min": 0.95,
"scale_max": 1.0
}
}
# 第一阶段Logo动画配置根据新布局调整Y坐标
self.logo_widgets = [
{"widget": ui.vol1bg, "delay": 0, "duration": 500, "end_pos": QPoint(0, 150)},
{"widget": ui.vol2bg, "delay": 80, "duration": 500, "end_pos": QPoint(0, 210)},
{"widget": ui.vol3bg, "delay": 160, "duration": 500, "end_pos": QPoint(0, 270)},
{"widget": ui.vol4bg, "delay": 240, "duration": 500, "end_pos": QPoint(0, 330)},
{"widget": ui.afterbg, "delay": 320, "duration": 500, "end_pos": QPoint(0, 390)}
]
# 第二阶段:菜单元素,位置会在开始动画时动态计算
self.menu_widgets = [
# 移除菜单背景动画
# {"widget": ui.menubg, "end_pos": QPoint(720, 55), "duration": 600},
{"widget": ui.button_container, "end_pos": None, "duration": 600},
{"widget": ui.toggle_patch_container, "end_pos": None, "duration": 600}, # 添加禁/启用补丁按钮
{"widget": ui.uninstall_container, "end_pos": None, "duration": 600}, # 添加卸载补丁按钮
{"widget": ui.exit_container, "end_pos": None, "duration": 600}
]
self.animations = []
self.timers = []
# 设置按钮点击动画
self.setup_button_click_animations()
def setup_button_click_animations(self):
"""设置按钮点击动画"""
# 为开始安装按钮添加点击动画
self.ui.start_install_btn.pressed.connect(
lambda: self.start_button_click_animation(self.ui.button_container)
)
self.ui.start_install_btn.released.connect(
lambda: self.end_button_click_animation(self.ui.button_container)
)
# 为卸载补丁按钮添加点击动画
self.ui.uninstall_btn.pressed.connect(
lambda: self.start_button_click_animation(self.ui.uninstall_container)
)
self.ui.uninstall_btn.released.connect(
lambda: self.end_button_click_animation(self.ui.uninstall_container)
)
# 为退出按钮添加点击动画
self.ui.exit_btn.pressed.connect(
lambda: self.start_button_click_animation(self.ui.exit_container)
)
self.ui.exit_btn.released.connect(
lambda: self.end_button_click_animation(self.ui.exit_container)
)
def start_button_click_animation(self, button_container):
"""开始按钮点击动画"""
# 创建缩放动画
scale_anim = QPropertyAnimation(button_container.children()[0], b"geometry") # 只对按钮背景应用动画
scale_anim.setDuration(self.animation_config["button_click"]["scale_duration"])
# 获取当前几何形状
current_geometry = button_container.children()[0].geometry()
# 计算缩放后的几何形状(保持中心点不变)
scale_factor = self.animation_config["button_click"]["scale_min"]
width_diff = current_geometry.width() * (1 - scale_factor) / 2
height_diff = current_geometry.height() * (1 - scale_factor) / 2
new_geometry = QRect(
current_geometry.x() + width_diff,
current_geometry.y() + height_diff,
current_geometry.width() * scale_factor,
current_geometry.height() * scale_factor
)
scale_anim.setEndValue(new_geometry)
scale_anim.setEasingCurve(QEasingCurve.Type.OutQuad)
# 启动动画
scale_anim.start()
self.animations.append(scale_anim)
# 对文本标签也应用同样的动画
text_anim = QPropertyAnimation(button_container.children()[1], b"geometry")
text_anim.setDuration(self.animation_config["button_click"]["scale_duration"])
text_geometry = button_container.children()[1].geometry()
new_text_geometry = QRect(
text_geometry.x() + width_diff,
text_geometry.y() + height_diff,
text_geometry.width() * scale_factor,
text_geometry.height() * scale_factor
)
text_anim.setEndValue(new_text_geometry)
text_anim.setEasingCurve(QEasingCurve.Type.OutQuad)
text_anim.start()
self.animations.append(text_anim)
def end_button_click_animation(self, button_container):
"""结束按钮点击动画,恢复正常外观"""
# 创建恢复动画 - 对背景
scale_anim = QPropertyAnimation(button_container.children()[0], b"geometry")
scale_anim.setDuration(self.animation_config["button_click"]["scale_duration"])
# 恢复到原始大小 (10,10,191,91)
original_geometry = QRect(10, 10, 191, 91)
scale_anim.setEndValue(original_geometry)
scale_anim.setEasingCurve(QEasingCurve.Type.OutElastic)
# 启动动画
scale_anim.start()
self.animations.append(scale_anim)
# 恢复文本标签
text_anim = QPropertyAnimation(button_container.children()[1], b"geometry")
text_anim.setDuration(self.animation_config["button_click"]["scale_duration"])
# 恢复文本到原始大小 (10,7,191,91)
text_anim.setEndValue(QRect(10, 7, 191, 91))
text_anim.setEasingCurve(QEasingCurve.Type.OutElastic)
text_anim.start()
self.animations.append(text_anim)
def initialize(self):
"""初始化所有组件状态"""
# 更新画布尺寸
if self.parent:
self.canvas_width = self.parent.width()
self.canvas_height = self.parent.height()
# 设置Mainbg初始状态
effect = QGraphicsOpacityEffect(self.ui.Mainbg)
effect.setOpacity(0)
self.ui.Mainbg.setGraphicsEffect(effect)
# 初始化Logo位置移到左侧外
for item in self.logo_widgets:
widget = item["widget"]
effect = QGraphicsOpacityEffect(widget)
effect.setOpacity(0)
widget.setGraphicsEffect(effect)
widget.move(-widget.width(), item["end_pos"].y())
widget.show()
print("初始化支持栏动画")
# 初始化菜单元素(底部外)
for item in self.menu_widgets:
widget = item["widget"]
effect = QGraphicsOpacityEffect(widget)
effect.setOpacity(0)
widget.setGraphicsEffect(effect)
widget.move(widget.x(), self.canvas_height + 100)
widget.show()
# 禁用所有按钮,直到动画完成
self.ui.start_install_btn.setEnabled(False)
self.ui.uninstall_btn.setEnabled(False)
self.ui.exit_btn.setEnabled(False)
def start_logo_animations(self):
"""启动Logo动画序列"""
for item in self.logo_widgets:
timer = QTimer()
timer.setSingleShot(True)
timer.timeout.connect(
lambda w=item["widget"], d=item["duration"], pos=item["end_pos"]:
self.animate_logo(w, pos, d)
)
timer.start(item["delay"])
self.timers.append(timer)
def animate_logo(self, widget, end_pos, duration):
"""执行单个Logo动画"""
anim_group = QParallelAnimationGroup()
# 位置动画
pos_anim = QPropertyAnimation(widget, b"pos")
pos_anim.setDuration(duration)
pos_anim.setStartValue(QPoint(-widget.width(), end_pos.y()))
pos_anim.setEndValue(end_pos)
pos_anim.setEasingCurve(QEasingCurve.Type.OutBack)
# 透明度动画
opacity_anim = QPropertyAnimation(widget.graphicsEffect(), b"opacity")
opacity_anim.setDuration(duration)
opacity_anim.setStartValue(0)
opacity_anim.setEndValue(1)
anim_group.addAnimation(pos_anim)
anim_group.addAnimation(opacity_anim)
# 最后一个Logo动画完成后添加延迟
if widget == self.logo_widgets[-1]["widget"]:
anim_group.finished.connect(
lambda: QTimer.singleShot(
self.animation_config["logo"]["delay_after"],
self.start_mainbg_animation
)
)
anim_group.start()
self.animations.append(anim_group)
def start_mainbg_animation(self):
"""启动主背景淡入动画(带延迟)"""
main_anim = QPropertyAnimation(self.ui.Mainbg.graphicsEffect(), b"opacity")
main_anim.setDuration(800)
main_anim.setStartValue(0)
main_anim.setEndValue(1)
main_anim.finished.connect(
lambda: QTimer.singleShot(
self.animation_config["mainbg"]["delay_after"],
self.start_menu_animations
)
)
main_anim.start()
self.animations.append(main_anim)
def start_menu_animations(self):
"""启动菜单动画(从下往上)"""
# 更新按钮最终位置
self._update_button_positions()
# 跟踪最后一个动画用于连接finished信号
last_anim = None
for item in self.menu_widgets:
anim_group = QParallelAnimationGroup()
# 位置动画(从下往上)
pos_anim = QPropertyAnimation(item["widget"], b"pos")
pos_anim.setDuration(item["duration"])
pos_anim.setStartValue(QPoint(item["end_pos"].x(), self.canvas_height + 100))
pos_anim.setEndValue(item["end_pos"])
pos_anim.setEasingCurve(QEasingCurve.Type.OutBack)
# 透明度动画
opacity_anim = QPropertyAnimation(item["widget"].graphicsEffect(), b"opacity")
opacity_anim.setDuration(item["duration"])
opacity_anim.setStartValue(0)
opacity_anim.setEndValue(1)
anim_group.addAnimation(pos_anim)
anim_group.addAnimation(opacity_anim)
# 记录最后一个按钮的动画
if item["widget"] == self.ui.exit_container:
last_anim = anim_group
anim_group.start()
self.animations.append(anim_group)
# 在最后一个动画完成时发出信号
if last_anim:
last_anim.finished.connect(self.animation_finished.emit)
def _update_button_positions(self):
"""更新按钮最终位置"""
# 根据当前窗口大小动态计算按钮位置
if self.parent:
width = self.parent.width()
height = self.parent.height()
# 计算按钮位置
right_margin = 20 # 减小右边距,使按钮更靠右
# 开始安装按钮
if hasattr(self.ui, 'button_container'):
btn_width = self.ui.button_container.width()
x_pos = width - btn_width - right_margin
y_pos = int((height - 65) * 0.18) - 10 # 从0.28改为0.18,向上移动
# 更新动画目标位置
for item in self.menu_widgets:
if item["widget"] == self.ui.button_container:
item["end_pos"] = QPoint(x_pos, y_pos)
# 禁用补丁按钮
if hasattr(self.ui, 'toggle_patch_container'):
btn_width = self.ui.toggle_patch_container.width()
x_pos = width - btn_width - right_margin
y_pos = int((height - 65) * 0.36) - 10 # 从0.46改为0.36,向上移动
# 更新动画目标位置
for item in self.menu_widgets:
if item["widget"] == self.ui.toggle_patch_container:
item["end_pos"] = QPoint(x_pos, y_pos)
# 卸载补丁按钮
if hasattr(self.ui, 'uninstall_container'):
btn_width = self.ui.uninstall_container.width()
x_pos = width - btn_width - right_margin
y_pos = int((height - 65) * 0.54) - 10 # 从0.64改为0.54,向上移动
# 更新动画目标位置
for item in self.menu_widgets:
if item["widget"] == self.ui.uninstall_container:
item["end_pos"] = QPoint(x_pos, y_pos)
# 退出按钮
if hasattr(self.ui, 'exit_container'):
btn_width = self.ui.exit_container.width()
x_pos = width - btn_width - right_margin
y_pos = int((height - 65) * 0.72) - 10 # 从0.82改为0.72,向上移动
# 更新动画目标位置
for item in self.menu_widgets:
if item["widget"] == self.ui.exit_container:
item["end_pos"] = QPoint(x_pos, y_pos)
else:
# 默认位置
for item in self.menu_widgets:
if item["widget"] == self.ui.button_container:
item["end_pos"] = QPoint(1050, 200)
elif item["widget"] == self.ui.toggle_patch_container:
item["end_pos"] = QPoint(1050, 310)
elif item["widget"] == self.ui.uninstall_container:
item["end_pos"] = QPoint(1050, 420)
elif item["widget"] == self.ui.exit_container:
item["end_pos"] = QPoint(1050, 530)
def start_animations(self):
"""启动完整动画序列"""
self.clear_animations()
# 确保按钮在动画开始时被禁用
self.ui.start_install_btn.setEnabled(False)
self.ui.uninstall_btn.setEnabled(False)
self.ui.exit_btn.setEnabled(False)
self.start_logo_animations()
def clear_animations(self):
"""清理所有动画资源"""
for timer in self.timers:
timer.stop()
for anim in self.animations:
anim.stop()
self.timers.clear()
self.animations.clear()

View File

@@ -0,0 +1,438 @@
import os
from urllib.parse import urlparse
from PySide6 import QtWidgets
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QIcon, QPixmap
from utils import msgbox_frame, resource_path
from workers import IpOptimizerThread
from utils.logger import setup_logger
# 初始化logger
logger = setup_logger("cloudflare_optimizer")
class CloudflareOptimizer:
"""Cloudflare IP优化器负责处理IP优化和Cloudflare加速相关功能"""
def __init__(self, main_window, hosts_manager):
"""初始化Cloudflare优化器
Args:
main_window: 主窗口实例用于访问UI和状态
hosts_manager: Hosts文件管理器实例
"""
self.main_window = main_window
self.hosts_manager = hosts_manager
self.optimized_ip = None
self.optimized_ipv6 = None
self.optimization_done = False # 标记是否已执行过优选
self.countdown_finished = False # 标记倒计时是否结束
self.optimizing_msg_box = None
self.optimization_cancelled = False
self.ip_optimizer_thread = None
self.ipv6_optimizer_thread = None
self.has_optimized_in_session = False # 本次启动是否已执行过优选
def is_optimization_done(self):
"""检查是否已完成优化
Returns:
bool: 是否已完成优化
"""
return self.optimization_done
def is_countdown_finished(self):
"""检查倒计时是否已完成
Returns:
bool: 倒计时是否已完成
"""
return self.countdown_finished
def get_optimized_ip(self):
"""获取优选的IP地址
Returns:
str: 优选的IP地址如果未优选则为None
"""
return self.optimized_ip
def get_optimized_ipv6(self):
"""获取优选的IPv6地址
Returns:
str: 优选的IPv6地址如果未优选则为None
"""
return self.optimized_ipv6
def start_ip_optimization(self, url):
"""开始IP优化过程
Args:
url: 用于优化的URL
"""
# 解析域名
hostname = urlparse(url).hostname
# 判断是否继续优选的逻辑
if self.has_optimized_in_session:
# 如果本次会话中已执行过优选,则跳过优选过程
logger.info("本次会话已执行过优选,跳过优选过程")
# 设置标记为已优选完成
self.optimization_done = True
self.countdown_finished = True
return True
else:
# 如果本次会话尚未优选过,则清理可能存在的旧记录
if hostname:
# 检查hosts文件中是否已有该域名的IP记录
existing_ips = self.hosts_manager.get_hostname_entries(hostname)
if existing_ips:
logger.info(f"发现hosts文件中已有域名 {hostname} 的优选IP记录但本次会话尚未优选过")
# 清理已有的hosts记录准备重新优选
self.hosts_manager.clean_hostname_entries(hostname)
# 创建取消状态标记
self.optimization_cancelled = False
self.countdown_finished = False
# 检查是否启用了IPv6
use_ipv6 = False
if hasattr(self.main_window, 'config'):
use_ipv6 = self.main_window.config.get("ipv6_enabled", False)
# 如果启用了IPv6显示警告消息
if use_ipv6:
ipv6_warning = QtWidgets.QMessageBox(self.main_window)
ipv6_warning.setWindowTitle(f"IPv6优选警告 - {self.main_window.APP_NAME}")
ipv6_warning.setText("\nIPv6优选比IPv4耗时更长且感知不强预计耗时10分钟以上不建议使用。\n\n确定要同时执行IPv6优选吗\n")
ipv6_warning.setIcon(QtWidgets.QMessageBox.Icon.Warning)
# 设置图标
icon_path = resource_path(os.path.join("IMG", "ICO", "icon.png"))
if os.path.exists(icon_path):
pixmap = QPixmap(icon_path)
if not pixmap.isNull():
ipv6_warning.setWindowIcon(QIcon(pixmap))
yes_button = ipv6_warning.addButton("", QtWidgets.QMessageBox.ButtonRole.YesRole)
no_button = ipv6_warning.addButton("仅使用IPv4", QtWidgets.QMessageBox.ButtonRole.NoRole)
cancel_button = ipv6_warning.addButton("取消优选", QtWidgets.QMessageBox.ButtonRole.RejectRole)
ipv6_warning.setDefaultButton(no_button)
ipv6_warning.exec()
if ipv6_warning.clickedButton() == cancel_button:
# 用户取消了优选
self.optimization_cancelled = True
return
# 根据用户选择调整IPv6设置
if ipv6_warning.clickedButton() == no_button:
use_ipv6 = False
# 临时覆盖配置(不保存到文件)
if hasattr(self.main_window, 'config'):
self.main_window.config["ipv6_enabled"] = False
# 准备提示信息
optimization_msg = "\n正在优选Cloudflare IP请稍候...\n\n"
if use_ipv6:
optimization_msg += "已启用IPv6支持同时进行IPv4和IPv6优选。\n这可能需要10分钟以上请耐心等待喵~\n"
else:
optimization_msg += "这可能需要5-10分钟请耐心等待喵~\n"
# 使用Cloudflare图标创建消息框
self.optimizing_msg_box = msgbox_frame(
f"通知 - {self.main_window.APP_NAME}",
optimization_msg
)
# 设置Cloudflare图标
cf_icon_path = resource_path("IMG/ICO/cloudflare_logo_icon.ico")
if os.path.exists(cf_icon_path):
cf_pixmap = QPixmap(cf_icon_path)
if not cf_pixmap.isNull():
self.optimizing_msg_box.setWindowIcon(QIcon(cf_pixmap))
self.optimizing_msg_box.setIconPixmap(cf_pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation))
# 添加取消按钮
self.optimizing_msg_box.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Cancel)
self.optimizing_msg_box.buttonClicked.connect(self._on_optimization_dialog_clicked)
self.optimizing_msg_box.setWindowModality(Qt.WindowModality.ApplicationModal)
# 创建并启动优化线程
self.ip_optimizer_thread = IpOptimizerThread(url)
self.ip_optimizer_thread.finished.connect(self.on_ipv4_optimization_finished)
# 如果启用IPv6同时启动IPv6优化线程
if use_ipv6:
logger.info("IPv6已启用将同时优选IPv6地址")
self.ipv6_optimizer_thread = IpOptimizerThread(url, use_ipv6=True)
self.ipv6_optimizer_thread.finished.connect(self.on_ipv6_optimization_finished)
self.ipv6_optimizer_thread.start()
# 启动IPv4优化线程
self.ip_optimizer_thread.start()
# 显示消息框(非模态,不阻塞)
self.optimizing_msg_box.open()
def _on_optimization_dialog_clicked(self, button):
"""处理优化对话框按钮点击
Args:
button: 被点击的按钮
"""
if button.text() == "Cancel": # 如果是取消按钮
# 标记已取消
self.optimization_cancelled = True
# 停止优化线程
if hasattr(self, 'ip_optimizer_thread') and self.ip_optimizer_thread and self.ip_optimizer_thread.isRunning():
self.ip_optimizer_thread.stop()
if hasattr(self, 'ipv6_optimizer_thread') and self.ipv6_optimizer_thread and self.ipv6_optimizer_thread.isRunning():
self.ipv6_optimizer_thread.stop()
# 恢复主窗口状态
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
# 显示取消消息
QtWidgets.QMessageBox.information(
self.main_window,
f"已取消 - {self.main_window.APP_NAME}",
"\n已取消IP优选和安装过程。\n"
)
def on_ipv4_optimization_finished(self, ip):
"""IPv4优化完成后的处理
Args:
ip: 优选的IP地址如果失败则为空字符串
"""
# 如果已经取消,则不继续处理
if hasattr(self, 'optimization_cancelled') and self.optimization_cancelled:
return
self.optimized_ip = ip
logger.info(f"IPv4优选完成结果: {ip if ip else '未找到合适的IP'}")
# 检查是否还有IPv6优化正在运行
if hasattr(self, 'ipv6_optimizer_thread') and self.ipv6_optimizer_thread and self.ipv6_optimizer_thread.isRunning():
logger.info("等待IPv6优选完成...")
return
# 所有优选都已完成,继续处理
self.optimization_done = True
self.countdown_finished = False # 确保倒计时标志重置
# 关闭提示框
if hasattr(self, 'optimizing_msg_box') and self.optimizing_msg_box:
if self.optimizing_msg_box.isVisible():
self.optimizing_msg_box.accept()
self.optimizing_msg_box = None
# 处理优选结果
self._process_optimization_results()
def on_ipv6_optimization_finished(self, ipv6):
"""IPv6优化完成后的处理
Args:
ipv6: 优选的IPv6地址如果失败则为空字符串
"""
# 如果已经取消,则不继续处理
if hasattr(self, 'optimization_cancelled') and self.optimization_cancelled:
return
self.optimized_ipv6 = ipv6
logger.info(f"IPv6优选完成结果: {ipv6 if ipv6 else '未找到合适的IPv6'}")
# 检查IPv4优化是否已完成
if hasattr(self, 'ip_optimizer_thread') and self.ip_optimizer_thread and self.ip_optimizer_thread.isRunning():
logger.info("等待IPv4优选完成...")
return
# 所有优选都已完成,继续处理
self.optimization_done = True
self.countdown_finished = False # 确保倒计时标志重置
# 关闭提示框
if hasattr(self, 'optimizing_msg_box') and self.optimizing_msg_box:
if self.optimizing_msg_box.isVisible():
self.optimizing_msg_box.accept()
self.optimizing_msg_box = None
# 处理优选结果
self._process_optimization_results()
def _process_optimization_results(self):
"""处理优选的IP结果显示相应提示"""
# 无论优选结果如何,都标记本次会话已执行过优选
self.has_optimized_in_session = True
use_ipv6 = False
if hasattr(self.main_window, 'config'):
use_ipv6 = self.main_window.config.get("ipv6_enabled", False)
# 判断优选结果
ipv4_success = bool(self.optimized_ip)
ipv6_success = bool(self.optimized_ipv6) if use_ipv6 else False
# 临时启用窗口以显示对话框
self.main_window.setEnabled(True)
hostname = urlparse(self.main_window.current_url).hostname if hasattr(self.main_window, 'current_url') else None
if not ipv4_success and (not use_ipv6 or not ipv6_success):
# 两种IP都没有优选成功
msg_box = QtWidgets.QMessageBox(self.main_window)
msg_box.setWindowTitle(f"优选失败 - {self.main_window.APP_NAME}")
fail_message = "\n未能找到合适的Cloudflare "
if use_ipv6:
fail_message += "IPv4和IPv6地址"
else:
fail_message += "IP地址"
fail_message += ",将使用默认网络进行下载。\n\n10秒后自动继续..."
msg_box.setText(fail_message)
msg_box.setIcon(QtWidgets.QMessageBox.Icon.Warning)
ok_button = msg_box.addButton("确定 (10)", QtWidgets.QMessageBox.ButtonRole.AcceptRole)
cancel_button = msg_box.addButton("取消安装", QtWidgets.QMessageBox.ButtonRole.RejectRole)
# 创建计时器实现倒计时
countdown = 10
timer = QTimer(self.main_window)
def update_countdown():
nonlocal countdown
countdown -= 1
ok_button.setText(f"确定 ({countdown})")
if countdown <= 0:
timer.stop()
if msg_box.isVisible():
msg_box.accept()
timer.timeout.connect(update_countdown)
timer.start(1000) # 每秒更新一次
# 显示对话框并等待用户响应
result = msg_box.exec()
# 停止计时器
timer.stop()
# 如果用户点击了取消安装
if msg_box.clickedButton() == cancel_button:
# 恢复主窗口状态
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
return False
# 用户点击了继续,重新禁用主窗口
self.main_window.setEnabled(False)
# 标记倒计时已完成
self.countdown_finished = True
return True
else:
# 至少有一种IP优选成功
success_message = ""
if ipv4_success:
success_message += f"IPv4: {self.optimized_ip}\n"
if ipv6_success:
success_message += f"IPv6: {self.optimized_ipv6}\n"
if hostname:
# 先清理可能存在的旧记录(只清理一次)
self.hosts_manager.clean_hostname_entries(hostname)
success = False
# 应用优选IP到hosts文件
if ipv4_success:
success = self.hosts_manager.apply_ip(hostname, self.optimized_ip, clean=False) or success
# 如果启用IPv6并且找到了IPv6地址也应用到hosts
if ipv6_success:
success = self.hosts_manager.apply_ip(hostname, self.optimized_ipv6, clean=False) or success
# 记录此次优选操作对hosts文件进行了更新
if hasattr(self.main_window, 'config'):
self.main_window.config['last_hosts_optimized_hostname'] = hostname
from utils import save_config
save_config(self.main_window.config)
if success:
msg_box = QtWidgets.QMessageBox(self.main_window)
msg_box.setWindowTitle(f"成功 - {self.main_window.APP_NAME}")
msg_box.setText(f"\n已将优选IP应用到hosts文件\n{success_message}\n10秒后自动继续...")
msg_box.setIcon(QtWidgets.QMessageBox.Icon.Information)
ok_button = msg_box.addButton("确定 (10)", QtWidgets.QMessageBox.ButtonRole.AcceptRole)
cancel_button = msg_box.addButton("取消安装", QtWidgets.QMessageBox.ButtonRole.RejectRole)
# 创建计时器实现倒计时
countdown = 10
timer = QTimer(self.main_window)
def update_countdown():
nonlocal countdown
countdown -= 1
ok_button.setText(f"确定 ({countdown})")
if countdown <= 0:
timer.stop()
if msg_box.isVisible():
msg_box.accept()
timer.timeout.connect(update_countdown)
timer.start(1000) # 每秒更新一次
# 显示对话框并等待用户响应
result = msg_box.exec()
# 停止计时器
timer.stop()
# 如果用户点击了取消安装
if msg_box.clickedButton() == cancel_button:
# 恢复主窗口状态
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
return False
else:
QtWidgets.QMessageBox.critical(
self.main_window,
f"错误 - {self.main_window.APP_NAME}",
"\n修改hosts文件失败请检查程序是否以管理员权限运行。\n"
)
# 恢复主窗口状态
self.main_window.ui.start_install_text.setText("开始安装")
return False
# 用户点击了继续,重新禁用主窗口
self.main_window.setEnabled(False)
# 标记倒计时已完成
self.countdown_finished = True
return True
def stop_optimization(self):
"""停止正在进行的IP优化"""
if hasattr(self, 'ip_optimizer_thread') and self.ip_optimizer_thread and self.ip_optimizer_thread.isRunning():
self.ip_optimizer_thread.stop()
self.ip_optimizer_thread.wait()
if hasattr(self, 'ipv6_optimizer_thread') and self.ipv6_optimizer_thread and self.ipv6_optimizer_thread.isRunning():
self.ipv6_optimizer_thread.stop()
self.ipv6_optimizer_thread.wait()
if hasattr(self, 'optimizing_msg_box') and self.optimizing_msg_box:
if self.optimizing_msg_box.isVisible():
self.optimizing_msg_box.accept()
self.optimizing_msg_box = None

View File

@@ -0,0 +1,213 @@
import json
import webbrowser
from PySide6.QtWidgets import QMessageBox
from utils import load_config, save_config, msgbox_frame
class ConfigManager:
"""配置管理器,用于处理配置的加载、保存和获取云端配置"""
def __init__(self, app_name, config_url, ua, debug_manager=None):
"""初始化配置管理器
Args:
app_name: 应用程序名称,用于显示消息框标题
config_url: 云端配置URL
ua: User-Agent字符串
debug_manager: 调试管理器实例,用于输出调试信息
"""
self.app_name = app_name
self.config_url = config_url
self.ua = ua
self.debug_manager = debug_manager
self.cloud_config = None
self.config_valid = False
self.last_error_message = ""
def _is_debug_mode(self):
"""检查是否处于调试模式
Returns:
bool: 是否处于调试模式
"""
if hasattr(self.debug_manager, 'ui_manager') and hasattr(self.debug_manager.ui_manager, 'debug_action'):
return self.debug_manager.ui_manager.debug_action.isChecked()
return False
def load_config(self):
"""加载本地配置
Returns:
dict: 加载的配置
"""
return load_config()
def save_config(self, config):
"""保存配置
Args:
config: 要保存的配置
"""
save_config(config)
def fetch_cloud_config(self, config_fetch_thread_class, callback=None):
"""获取云端配置
Args:
config_fetch_thread_class: 用于获取云端配置的线程类
callback: 获取完成后的回调函数,接受两个参数(data, error_message)
"""
headers = {"User-Agent": self.ua}
debug_mode = self._is_debug_mode()
self.config_fetch_thread = config_fetch_thread_class(self.config_url, headers, debug_mode)
# 如果提供了回调使用它否则使用内部的on_config_fetched方法
if callback:
self.config_fetch_thread.finished.connect(callback)
else:
self.config_fetch_thread.finished.connect(self.on_config_fetched)
self.config_fetch_thread.start()
def on_config_fetched(self, data, error_message):
"""云端配置获取完成的回调处理
Args:
data: 获取到的配置数据
error_message: 错误信息,如果有
"""
debug_mode = self._is_debug_mode()
if error_message:
# 标记配置无效
self.config_valid = False
# 记录错误信息,用于按钮点击时显示
if error_message == "update_required":
self.last_error_message = "update_required"
# 检查是否处于离线模式
is_offline_mode = False
if hasattr(self.debug_manager, 'main_window') and hasattr(self.debug_manager.main_window, 'offline_mode_manager'):
is_offline_mode = self.debug_manager.main_window.offline_mode_manager.is_in_offline_mode()
if is_offline_mode:
# 离线模式下只显示提示,不禁用开始安装按钮
msg_box = msgbox_frame(
f"更新提示 - {self.app_name}",
"\n当前版本过低,请及时更新。\n在离线模式下,您仍可使用禁用/启用补丁、卸载补丁和离线安装功能。\n",
QMessageBox.StandardButton.Ok,
)
msg_box.exec()
# 移除在浏览器中打开项目主页的代码
# 离线模式下版本过低,仍然允许使用安装按钮
return {"action": "enable_button"}
else:
# 在线模式下显示强制更新提示
msg_box = msgbox_frame(
f"更新提示 - {self.app_name}",
"\n当前版本过低,请及时更新。\n如需联网下载补丁,请更新到最新版,否则无法下载。\n",
QMessageBox.StandardButton.Ok,
)
msg_box.exec()
# 移除在浏览器中打开项目主页的代码
# 在线模式下版本过低,但不直接禁用按钮,而是在点击时提示
return {"action": "enable_button", "version_warning": True}
elif "missing_keys" in error_message:
self.last_error_message = "missing_keys"
missing_versions = error_message.split(":")[1]
msg_box = msgbox_frame(
f"配置缺失 - {self.app_name}",
f'\n云端缺失下载链接,可能云服务器正在维护,不影响其他版本下载。\n当前缺失版本:"{missing_versions}"\n',
QMessageBox.StandardButton.Ok,
)
msg_box.exec()
# 对于部分缺失,仍然允许使用,因为可能只影响部分游戏版本
self.config_valid = True
return {"action": "enable_button"}
else:
# 设置网络错误标记
self.last_error_message = "network_error"
# 显示通用错误消息只在debug模式下显示详细错误
error_msg = "访问云端配置失败,请检查网络状况或稍后再试。"
if debug_mode and "详细错误:" in error_message:
msg_box = msgbox_frame(
f"错误 - {self.app_name}",
f"\n{error_message}\n",
QMessageBox.StandardButton.Ok,
)
else:
msg_box = msgbox_frame(
f"错误 - {self.app_name}",
f"\n{error_msg}\n",
QMessageBox.StandardButton.Ok,
)
msg_box.exec()
# 网络错误时仍然允许使用按钮,用户可以尝试离线模式
return {"action": "enable_button"}
else:
self.cloud_config = data
# 标记配置有效
self.config_valid = True
# 清除错误信息
self.last_error_message = ""
if debug_mode:
print("--- Cloud config fetched successfully ---")
# 创建一个数据副本隐藏敏感URL
safe_data = self._create_safe_config_for_logging(data)
print(json.dumps(safe_data, indent=2))
# 获取配置成功,允许安装
return {"action": "enable_button"}
def _create_safe_config_for_logging(self, config_data):
"""创建用于日志记录的安全配置副本隐藏敏感URL
Args:
config_data: 原始配置数据
Returns:
dict: 安全的配置数据副本
"""
if not config_data or not isinstance(config_data, dict):
return config_data
# 创建深拷贝,避免修改原始数据
import copy
safe_config = copy.deepcopy(config_data)
# 隐藏敏感URL
for key in safe_config:
if isinstance(safe_config[key], dict) and "url" in safe_config[key]:
# 完全隐藏URL
safe_config[key]["url"] = "***URL protection***"
return safe_config
def is_config_valid(self):
"""检查配置是否有效
Returns:
bool: 配置是否有效
"""
return self.config_valid
def get_cloud_config(self):
"""获取云端配置
Returns:
dict: 云端配置
"""
return self.cloud_config
def get_last_error(self):
"""获取最后一次错误信息
Returns:
str: 错误信息
"""
return self.last_error_message

View File

@@ -0,0 +1,149 @@
import os
import sys
from PySide6 import QtWidgets
from data.config import LOG_FILE
from utils.logger import setup_logger
from utils import Logger
import datetime
from data.config import APP_NAME
# 初始化logger
logger = setup_logger("debug_manager")
class DebugManager:
def __init__(self, main_window):
"""初始化调试管理器
Args:
main_window: 主窗口实例
"""
self.main_window = main_window
self.logger = None
self.original_stdout = None
self.original_stderr = None
self.ui_manager = None # 添加ui_manager属性
def set_ui_manager(self, ui_manager):
"""设置UI管理器引用
Args:
ui_manager: UI管理器实例
"""
self.ui_manager = ui_manager
def _is_debug_mode(self):
"""检查是否处于调试模式
Returns:
bool: 是否处于调试模式
"""
try:
# 首先尝试从UI管理器获取状态
if hasattr(self, 'ui_manager') and self.ui_manager and hasattr(self.ui_manager, 'debug_action') and self.ui_manager.debug_action:
return self.ui_manager.debug_action.isChecked()
# 如果UI管理器还没准备好尝试从配置中获取
if hasattr(self.main_window, 'config') and isinstance(self.main_window.config, dict):
return self.main_window.config.get('debug_mode', False)
# 如果以上都不可行返回False
return False
except Exception:
# 捕获任何异常默认返回False
return False
def toggle_debug_mode(self, checked):
"""切换调试模式
Args:
checked: 是否启用调试模式
"""
logger.info(f"Toggle debug mode: {checked}")
self.main_window.config["debug_mode"] = checked
self.main_window.save_config(self.main_window.config)
# 创建或删除debug_mode.txt标记文件
try:
from data.config import CACHE
debug_file = os.path.join(os.path.dirname(CACHE), "debug_mode.txt")
if checked:
# 确保目录存在
os.makedirs(os.path.dirname(debug_file), exist_ok=True)
# 创建标记文件
with open(debug_file, 'w', encoding='utf-8') as f:
f.write(f"Debug mode enabled at {os.path.abspath(debug_file)}\n")
logger.info(f"已创建调试模式标记文件: {debug_file}")
elif os.path.exists(debug_file):
# 删除标记文件
os.remove(debug_file)
logger.info(f"已删除调试模式标记文件: {debug_file}")
except Exception as e:
logger.warning(f"处理调试模式标记文件时发生错误: {e}")
# 更新打开log文件按钮状态
if hasattr(self, 'ui_manager') and hasattr(self.ui_manager, 'open_log_action'):
self.ui_manager.open_log_action.setEnabled(checked)
if checked:
self.start_logging()
# 如果启用了调试模式,检查是否需要强制启用离线模式
if hasattr(self.main_window, 'offline_mode_manager'):
# 检查配置中是否已设置离线模式
offline_mode_enabled = self.main_window.config.get("offline_mode", False)
# 如果配置中已设置离线模式,则在调试模式下强制启用
if offline_mode_enabled:
logger.debug("DEBUG: 调试模式下强制启用离线模式")
self.main_window.offline_mode_manager.set_offline_mode(True)
# 更新UI中的离线模式选项
if hasattr(self.ui_manager, 'offline_mode_action') and self.ui_manager.offline_mode_action:
self.ui_manager.offline_mode_action.setChecked(True)
self.ui_manager.online_mode_action.setChecked(False)
else:
self.stop_logging()
def start_logging(self):
"""启动日志记录"""
if self.logger is None:
try:
# 确保log目录存在
log_dir = os.path.dirname(LOG_FILE)
if not os.path.exists(log_dir):
os.makedirs(log_dir, exist_ok=True)
logger.info(f"已创建日志目录: {log_dir}")
# 创建新的日志文件,使用覆盖模式而不是追加模式
with open(LOG_FILE, 'w', encoding='utf-8') as f:
current_time = datetime.datetime.now()
formatted_date = current_time.strftime("%Y-%m-%d")
formatted_time = current_time.strftime("%H:%M:%S")
f.write(f"--- 新调试会话开始于 {os.path.basename(LOG_FILE)} ---\n")
f.write(f"--- 应用版本: {APP_NAME} ---\n")
f.write(f"--- 日期: {formatted_date} 时间: {formatted_time} ---\n\n")
logger.info(f"已创建日志文件: {os.path.abspath(LOG_FILE)}")
# 保存原始的 stdout 和 stderr
self.original_stdout = sys.stdout
self.original_stderr = sys.stderr
# 创建 Logger 实例
self.logger = Logger(LOG_FILE, self.original_stdout)
sys.stdout = self.logger
sys.stderr = self.logger
logger.info(f"--- Debug mode enabled (log file: {os.path.abspath(LOG_FILE)}) ---")
except (IOError, OSError) as e:
QtWidgets.QMessageBox.critical(self.main_window, "错误", f"无法创建日志文件: {e}")
self.logger = None
def stop_logging(self):
"""停止日志记录"""
if self.logger:
logger.info("--- Debug mode disabled ---")
sys.stdout = self.original_stdout
sys.stderr = self.original_stderr
self.logger.close()
self.logger = None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,221 @@
from PySide6 import QtWidgets
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QDialog, QVBoxLayout, QRadioButton, QPushButton, QLabel, QButtonGroup, QHBoxLayout
from PySide6.QtGui import QFont
from data.config import DOWNLOAD_THREADS
class DownloadTaskManager:
"""下载任务管理器,负责管理下载任务和线程设置"""
def __init__(self, main_window, download_thread_level="medium"):
"""初始化下载任务管理器
Args:
main_window: 主窗口实例用于访问UI和状态
download_thread_level: 下载线程级别,默认为"medium"
"""
self.main_window = main_window
self.APP_NAME = main_window.APP_NAME if hasattr(main_window, 'APP_NAME') else ""
self.current_download_thread = None
self.download_thread_level = download_thread_level
def start_download(self, url, _7z_path, game_version, game_folder, plugin_path):
"""启动下载线程
Args:
url: 下载URL
_7z_path: 7z文件保存路径
game_version: 游戏版本名称
game_folder: 游戏文件夹路径
plugin_path: 插件路径
"""
# 按钮在file_dialog中已设置为禁用状态
# 创建并连接下载线程
self.current_download_thread = self.main_window.create_download_thread(url, _7z_path, game_version)
self.current_download_thread.progress.connect(self.main_window.progress_window.update_progress)
self.current_download_thread.finished.connect(
lambda success, error: self.main_window.download_manager.on_download_finished(
success,
error,
url,
game_folder,
game_version,
_7z_path,
plugin_path,
)
)
# 连接停止按钮到download_manager的on_download_stopped方法
self.main_window.progress_window.stop_button.clicked.connect(self.main_window.download_manager.on_download_stopped)
# 连接暂停/恢复按钮
self.main_window.progress_window.pause_resume_button.clicked.connect(self.toggle_download_pause)
# 启动线程和显示进度窗口
self.current_download_thread.start()
self.main_window.progress_window.exec()
def toggle_download_pause(self):
"""切换下载的暂停/恢复状态"""
if not self.current_download_thread:
return
# 获取当前暂停状态
is_paused = self.current_download_thread.is_paused()
if is_paused:
# 如果已暂停,则恢复下载
success = self.current_download_thread.resume()
if success:
self.main_window.progress_window.update_pause_button_state(False)
else:
# 如果未暂停,则暂停下载
success = self.current_download_thread.pause()
if success:
self.main_window.progress_window.update_pause_button_state(True)
def get_download_thread_count(self):
"""获取当前下载线程设置对应的线程数
Returns:
int: 下载线程数
"""
# 获取当前线程级别对应的线程数
thread_count = DOWNLOAD_THREADS.get(self.download_thread_level, DOWNLOAD_THREADS["medium"])
return thread_count
def set_download_thread_level(self, level):
"""设置下载线程级别
Args:
level: 线程级别 (low, medium, high, extreme, insane)
Returns:
bool: 设置是否成功
"""
if level in DOWNLOAD_THREADS:
old_level = self.download_thread_level
self.download_thread_level = level
# 只有非极端级别才保存到配置
if level not in ["extreme", "insane"]:
if hasattr(self.main_window, 'config'):
self.main_window.config["download_thread_level"] = level
self.main_window.save_config(self.main_window.config)
return True
return False
def show_download_thread_settings(self):
"""显示下载线程设置对话框"""
# 创建对话框
dialog = QDialog(self.main_window)
dialog.setWindowTitle(f"下载线程设置 - {self.APP_NAME}")
dialog.setMinimumWidth(350)
layout = QVBoxLayout(dialog)
# 添加说明标签
info_label = QLabel("选择下载线程数量(更多线程通常可以提高下载速度):", dialog)
info_label.setWordWrap(True)
layout.addWidget(info_label)
# 创建按钮组
button_group = QButtonGroup(dialog)
# 添加线程选项
thread_options = {
"low": f"低速 - {DOWNLOAD_THREADS['low']}线程(慢慢来,不着急)",
"medium": f"中速 - {DOWNLOAD_THREADS['medium']}线程(快人半步)",
"high": f"高速 - {DOWNLOAD_THREADS['high']}线程(默认,推荐配置)",
"extreme": f"极速 - {DOWNLOAD_THREADS['extreme']}线程(如果你对你的网和电脑很自信的话)",
"insane": f"狂暴 - {DOWNLOAD_THREADS['insane']}线程(看看是带宽和性能先榨干还是牛牛先榨干)"
}
radio_buttons = {}
for level, text in thread_options.items():
radio = QRadioButton(text, dialog)
# 选中当前使用的线程级别
if level == self.download_thread_level:
radio.setChecked(True)
button_group.addButton(radio)
layout.addWidget(radio)
radio_buttons[level] = radio
layout.addSpacing(10)
# 添加按钮区域
btn_layout = QHBoxLayout()
ok_button = QPushButton("确定", dialog)
cancel_button = QPushButton("取消", dialog)
btn_layout.addWidget(ok_button)
btn_layout.addWidget(cancel_button)
layout.addLayout(btn_layout)
# 连接按钮事件
ok_button.clicked.connect(dialog.accept)
cancel_button.clicked.connect(dialog.reject)
# 显示对话框
result = dialog.exec()
# 处理结果
if result == QDialog.DialogCode.Accepted:
# 获取用户选择的线程级别
selected_level = None
for level, radio in radio_buttons.items():
if radio.isChecked():
selected_level = level
break
if selected_level:
# 为极速和狂暴模式显示警告
if selected_level in ["extreme", "insane"]:
warning_result = QtWidgets.QMessageBox.warning(
self.main_window,
f"高风险警告 - {self.APP_NAME}",
"警告过高的线程数可能导致CPU负载过高或其他恶性问题\n你确定要这么做吗?",
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
QtWidgets.QMessageBox.StandardButton.No
)
if warning_result != QtWidgets.QMessageBox.StandardButton.Yes:
return False
success = self.set_download_thread_level(selected_level)
if success:
# 显示设置成功消息
thread_count = DOWNLOAD_THREADS[selected_level]
message = f"\n已成功设置下载线程为: {thread_count}线程\n"
# 对于极速和狂暴模式,添加仅本次生效的提示
if selected_level in ["extreme", "insane"]:
message += "\n注意:极速/狂暴模式仅本次生效。软件重启后将恢复默认设置。\n"
QtWidgets.QMessageBox.information(
self.main_window,
f"设置成功 - {self.APP_NAME}",
message
)
return True
return False
def stop_download(self):
"""停止当前下载线程"""
if self.current_download_thread and self.current_download_thread.isRunning():
self.current_download_thread.stop()
self.current_download_thread.wait() # 等待线程完全终止
return True
return False

View File

@@ -0,0 +1,354 @@
from PySide6.QtCore import QThread, Signal
import os
import re
from utils.logger import setup_logger
class GameDetectionThread(QThread):
"""用于在后台线程中执行游戏目录识别的线程"""
finished = Signal(dict)
def __init__(self, detector_func, selected_folder):
super().__init__()
self.detector_func = detector_func
self.selected_folder = selected_folder
def run(self):
result = self.detector_func(self.selected_folder)
self.finished.emit(result)
class GameDetector:
"""游戏检测器,用于识别游戏目录和版本"""
def __init__(self, game_info, debug_manager=None):
"""初始化游戏检测器
Args:
game_info: 游戏信息字典,包含各版本的安装路径和可执行文件名
debug_manager: 调试管理器实例,用于输出调试信息
"""
self.game_info = game_info
self.debug_manager = debug_manager
self.directory_cache = {} # 添加目录缓存
self.logger = setup_logger("game_detector")
self.detection_thread = None
def identify_game_directories_async(self, selected_folder, callback):
"""异步识别游戏目录"""
def on_finished(game_dirs):
callback(game_dirs)
self.detection_thread = None
self.detection_thread = GameDetectionThread(self.identify_game_directories_improved, selected_folder)
self.detection_thread.finished.connect(on_finished)
self.detection_thread.start()
def _is_debug_mode(self):
"""检查是否处于调试模式
Returns:
bool: 是否处于调试模式
"""
if hasattr(self.debug_manager, 'ui_manager') and hasattr(self.debug_manager.ui_manager, 'debug_action'):
return self.debug_manager.ui_manager.debug_action.isChecked()
return False
def identify_game_version(self, game_dir):
"""识别游戏版本
Args:
game_dir: 游戏目录路径
Returns:
str: 游戏版本名称如果不是有效的游戏目录则返回None
"""
debug_mode = self._is_debug_mode()
if debug_mode:
self.logger.debug(f"尝试识别游戏版本: {game_dir}")
# 先通过目录名称进行初步推测(这将作为递归搜索的提示)
dir_name = os.path.basename(game_dir).lower()
potential_version = None
vol_num = None
# 提取卷号或判断是否是After
if "vol" in dir_name or "vol." in dir_name:
vol_match = re.search(r"vol(?:\.|\s*)?(\d+)", dir_name)
if vol_match:
vol_num = vol_match.group(1)
potential_version = f"NEKOPARA Vol.{vol_num}"
if debug_mode:
self.logger.debug(f"从目录名推测游戏版本: {potential_version}, 卷号: {vol_num}")
elif "after" in dir_name:
potential_version = "NEKOPARA After"
if debug_mode:
self.logger.debug(f"从目录名推测游戏版本: NEKOPARA After")
# 检查是否为NEKOPARA游戏目录
# 通过检查游戏可执行文件来识别游戏版本
for game_version, info in self.game_info.items():
# 尝试多种可能的可执行文件名变体
exe_variants = [
info["exe"], # 标准文件名
info["exe"] + ".nocrack", # Steam加密版本
info["exe"].replace(".exe", ""), # 无扩展名版本
info["exe"].replace("NEKOPARA", "nekopara").lower(), # 全小写变体
info["exe"].lower(), # 小写变体
info["exe"].lower() + ".nocrack", # 小写变体的Steam加密版本
]
# 对于Vol.3可能有特殊名称
if "Vol.3" in game_version:
# 增加可能的卷3特定的变体
exe_variants.extend([
"NEKOPARAVol3.exe",
"NEKOPARAVol3.exe.nocrack",
"nekoparavol3.exe",
"nekoparavol3.exe.nocrack",
"nekopara_vol3.exe",
"nekopara_vol3.exe.nocrack",
"vol3.exe",
"vol3.exe.nocrack"
])
for exe_variant in exe_variants:
exe_path = os.path.join(game_dir, exe_variant)
if os.path.exists(exe_path):
if debug_mode:
self.logger.debug(f"通过可执行文件确认游戏版本: {game_version}, 文件: {exe_variant}")
return game_version
# 如果没有直接匹配,尝试递归搜索
if potential_version:
# 从预测的版本中获取卷号或确认是否是After
is_after = "After" in potential_version
if not vol_num and not is_after:
vol_match = re.search(r"Vol\.(\d+)", potential_version)
if vol_match:
vol_num = vol_match.group(1)
# 递归搜索可执行文件
for root, dirs, files in os.walk(game_dir):
for file in files:
file_lower = file.lower()
if file.endswith('.exe') or file.endswith('.exe.nocrack'):
# 检查文件名中是否包含卷号或关键词
if ((vol_num and (f"vol{vol_num}" in file_lower or
f"vol.{vol_num}" in file_lower or
f"vol {vol_num}" in file_lower)) or
(is_after and "after" in file_lower)):
if debug_mode:
self.logger.debug(f"通过递归搜索确认游戏版本: {potential_version}, 文件: {file}")
return potential_version
# 如果仍然没有找到,基于目录名的推测返回结果
if potential_version:
if debug_mode:
self.logger.debug(f"基于目录名返回推测的游戏版本: {potential_version}")
return potential_version
if debug_mode:
self.logger.debug(f"无法识别游戏版本: {game_dir}")
return None
def identify_game_directories_improved(self, selected_folder):
"""改进的游戏目录识别,支持大小写不敏感和特殊字符处理
Args:
selected_folder: 选择的上级目录
Returns:
dict: 游戏版本到游戏目录的映射
"""
debug_mode = self._is_debug_mode()
# 检查缓存中是否已有该目录的识别结果
if selected_folder in self.directory_cache:
if debug_mode:
self.logger.debug(f"使用缓存的目录识别结果: {selected_folder}")
return self.directory_cache[selected_folder]
if debug_mode:
self.logger.debug(f"--- 开始识别目录: {selected_folder} ---")
game_paths = {}
# 获取上级目录中的所有文件夹
try:
all_dirs = [d for d in os.listdir(selected_folder) if os.path.isdir(os.path.join(selected_folder, d))]
if debug_mode:
self.logger.debug(f"找到以下子目录: {all_dirs}")
except Exception as e:
if debug_mode:
self.logger.debug(f"无法读取目录 {selected_folder}: {str(e)}")
return {}
for game, info in self.game_info.items():
expected_dir = info["install_path"].split("/")[0] # 例如 "NEKOPARA Vol. 1"
expected_exe = info["exe"] # 标准可执行文件名
if debug_mode:
self.logger.debug(f"搜索游戏 {game}, 预期目录: {expected_dir}, 预期可执行文件: {expected_exe}")
# 尝试不同的匹配方法
found_dir = None
# 1. 精确匹配
if expected_dir in all_dirs:
found_dir = expected_dir
if debug_mode:
self.logger.debug(f"精确匹配成功: {expected_dir}")
# 2. 大小写不敏感匹配
if not found_dir:
for dir_name in all_dirs:
if expected_dir.lower() == dir_name.lower():
found_dir = dir_name
if debug_mode:
self.logger.debug(f"大小写不敏感匹配成功: {dir_name}")
break
# 3. 更模糊的匹配(允许特殊字符差异)
if not found_dir:
# 准备用于模糊匹配的正则表达式模式
# 替换空格为可选空格或连字符,替换点为可选点
pattern_text = expected_dir.replace(" ", "[ -]?").replace(".", "\\.?")
pattern = re.compile(f"^{pattern_text}$", re.IGNORECASE)
for dir_name in all_dirs:
if pattern.match(dir_name):
found_dir = dir_name
if debug_mode:
self.logger.debug(f"模糊匹配成功: {dir_name} 匹配模式 {pattern_text}")
break
# 4. 如果还是没找到,尝试更宽松的匹配
if not found_dir:
vol_match = re.search(r"vol(?:\.|\s*)?(\d+)", expected_dir, re.IGNORECASE)
vol_num = None
if vol_match:
vol_num = vol_match.group(1)
if debug_mode:
self.logger.debug(f"提取卷号: {vol_num}")
is_after = "after" in expected_dir.lower()
for dir_name in all_dirs:
dir_lower = dir_name.lower()
# 对于After特殊处理
if is_after and "after" in dir_lower:
found_dir = dir_name
if debug_mode:
self.logger.debug(f"After特殊匹配成功: {dir_name}")
break
# 对于Vol特殊处理
if vol_num:
# 查找目录名中的卷号
dir_vol_match = re.search(r"vol(?:\.|\s*)?(\d+)", dir_lower)
if dir_vol_match and dir_vol_match.group(1) == vol_num:
found_dir = dir_name
if debug_mode:
self.logger.debug(f"卷号匹配成功: {dir_name} 卷号 {vol_num}")
break
# 如果找到匹配的目录验证exe文件是否存在
if found_dir:
potential_path = os.path.join(selected_folder, found_dir)
# 尝试多种可能的可执行文件名变体
# 包括Steam加密版本和其他可能的变体
exe_variants = [
expected_exe, # 标准文件名
expected_exe + ".nocrack", # Steam加密版本
expected_exe.replace(".exe", ""),# 无扩展名版本
# Vol.3的特殊变体,因为它的文件名可能不一样
expected_exe.replace("NEKOPARA", "nekopara").lower(), # 全小写变体
expected_exe.lower(), # 小写变体
expected_exe.lower() + ".nocrack", # 小写变体的Steam加密版本
]
# 对于Vol.3可能有特殊名称
if "Vol.3" in game:
# 增加可能的卷3特定的变体
exe_variants.extend([
"NEKOPARAVol3.exe",
"NEKOPARAVol3.exe.nocrack",
"nekoparavol3.exe",
"nekoparavol3.exe.nocrack",
"nekopara_vol3.exe",
"nekopara_vol3.exe.nocrack",
"vol3.exe",
"vol3.exe.nocrack"
])
exe_exists = False
found_exe = None
# 尝试所有可能的变体
for exe_variant in exe_variants:
exe_path = os.path.join(potential_path, exe_variant)
if os.path.exists(exe_path):
exe_exists = True
found_exe = exe_variant
if debug_mode:
self.logger.debug(f"验证成功,找到游戏可执行文件: {exe_variant}")
break
# 如果没有直接找到,尝试递归搜索当前目录下的所有可执行文件
if not exe_exists:
# 遍历当前目录下的所有文件和文件夹
for root, dirs, files in os.walk(potential_path):
for file in files:
file_lower = file.lower()
# 检查是否是游戏可执行文件(根据关键字)
if file.endswith('.exe') or file.endswith('.exe.nocrack'):
# 检查文件名中是否包含卷号或关键词
if "Vol." in game:
vol_match = re.search(r"Vol\.(\d+)", game)
if vol_match:
vol_num = vol_match.group(1)
if (f"vol{vol_num}" in file_lower or
f"vol.{vol_num}" in file_lower or
f"vol {vol_num}" in file_lower):
exe_path = os.path.join(root, file)
exe_exists = True
found_exe = os.path.relpath(exe_path, potential_path)
if debug_mode:
self.logger.debug(f"通过递归搜索找到游戏可执行文件: {found_exe}")
break
elif "After" in game and "after" in file_lower:
exe_path = os.path.join(root, file)
exe_exists = True
found_exe = os.path.relpath(exe_path, potential_path)
if debug_mode:
self.logger.debug(f"通过递归搜索找到After游戏可执行文件: {found_exe}")
break
if exe_exists:
break
# 如果找到了可执行文件,将该目录添加到游戏目录列表
if exe_exists:
game_paths[game] = potential_path
if debug_mode:
self.logger.debug(f"验证成功,将 {potential_path} 添加为 {game} 的目录")
else:
if debug_mode:
self.logger.debug(f"未找到任何可执行文件变体,游戏 {game}{potential_path} 未找到")
if debug_mode:
self.logger.debug(f"最终识别的游戏目录: {game_paths}")
self.logger.debug(f"--- 目录识别结束 ---")
# 将识别结果存入缓存
self.directory_cache[selected_folder] = game_paths
return game_paths
def clear_directory_cache(self):
"""清除目录缓存"""
self.directory_cache = {}
if self._is_debug_mode():
self.logger.debug("已清除目录缓存")

View File

@@ -0,0 +1,323 @@
import os
import sys
import time
import subprocess
import urllib.request
import ssl
import threading
from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QTextEdit, QProgressBar, QMessageBox
from data.config import APP_NAME
from utils import msgbox_frame
class IPv6Manager:
"""管理IPv6相关功能的类"""
def __init__(self, main_window):
"""初始化IPv6管理器
Args:
main_window: 主窗口实例,用于显示对话框和访问配置
"""
self.main_window = main_window
self.config = getattr(main_window, 'config', {})
def check_ipv6_availability(self):
"""检查IPv6是否可用
通过访问IPv6专用图片URL测试IPv6连接
Returns:
bool: IPv6是否可用
"""
import urllib.request
import time
print("开始检测IPv6可用性...")
try:
# 获取IPv6测试请求
ipv6_test_url, req, context = self._get_ipv6_test_request()
# 设置3秒超时避免长时间等待
start_time = time.time()
with urllib.request.urlopen(req, timeout=3, context=context) as response:
# 读取图片数据
image_data = response.read()
# 检查是否成功
if response.status == 200 and len(image_data) > 0:
elapsed = time.time() - start_time
print(f"IPv6测试成功! 用时: {elapsed:.2f}")
return True
else:
print(f"IPv6测试失败: 状态码 {response.status}")
return False
except Exception as e:
print(f"IPv6测试失败: {e}")
return False
def _get_ipv6_test_request(self):
"""获取IPv6测试请求
Returns:
tuple: (测试URL, 请求对象, SSL上下文)
"""
import urllib.request
import ssl
# IPv6测试URL - 这是一个只能通过IPv6访问的资源
ipv6_test_url = "https://ipv6.testipv6.cn/images-nc/knob_green.png?&testdomain=www.test-ipv6.com&testname=sites"
# 创建SSL上下文
context = ssl._create_unverified_context()
# 创建请求并添加常见的HTTP头
req = urllib.request.Request(ipv6_test_url)
req.add_header('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
req.add_header('Accept', 'image/webp,image/apng,image/*,*/*;q=0.8')
return ipv6_test_url, req, context
def get_ipv6_address(self):
"""获取公网IPv6地址
Returns:
str: IPv6地址如果失败则返回None
"""
try:
# 使用curl命令获取IPv6地址
process = subprocess.Popen(
["curl", "-6", "6.ipw.cn"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding='utf-8',
errors='replace',
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0
)
# 设置超时
timeout = 5 # 5秒超时
start_time = time.time()
while process.poll() is None and (time.time() - start_time) < timeout:
time.sleep(0.1)
# 如果进程仍在运行,则强制终止
if process.poll() is None:
process.terminate()
print("获取IPv6地址超时")
return None
stdout, stderr = process.communicate()
if process.returncode == 0 and stdout.strip():
ipv6_address = stdout.strip()
print(f"获取到IPv6地址: {ipv6_address}")
return ipv6_address
else:
print("未能获取到IPv6地址")
if stderr:
print(f"错误信息: {stderr}")
return None
except Exception as e:
print(f"获取IPv6地址失败: {e}")
return None
def show_ipv6_details(self):
"""显示IPv6连接详情"""
class SignalEmitter(QObject):
update_signal = Signal(str)
complete_signal = Signal(bool, float)
# 创建对话框
dialog = QDialog(self.main_window)
dialog.setWindowTitle(f"IPv6连接测试 - {APP_NAME}")
dialog.resize(500, 300)
# 创建布局
layout = QVBoxLayout(dialog)
# 创建状态标签
status_label = QLabel("正在测试IPv6连接...", dialog)
layout.addWidget(status_label)
# 创建进度条
progress = QProgressBar(dialog)
progress.setRange(0, 0) # 不确定进度
layout.addWidget(progress)
# 创建结果文本框
result_text = QTextEdit(dialog)
result_text.setReadOnly(True)
layout.addWidget(result_text)
# 创建关闭按钮
close_button = QPushButton("关闭", dialog)
close_button.clicked.connect(dialog.accept)
close_button.setEnabled(False) # 测试完成前禁用
layout.addWidget(close_button)
# 信号发射器
signal_emitter = SignalEmitter()
# 连接信号
signal_emitter.update_signal.connect(
lambda text: result_text.append(text)
)
def on_test_complete(success, elapsed_time):
# 停止进度条动画
progress.setRange(0, 100)
progress.setValue(100 if success else 0)
# 更新状态
if success:
status_label.setText(f"IPv6连接测试完成: 可用 (用时: {elapsed_time:.2f}秒)")
else:
status_label.setText("IPv6连接测试完成: 不可用")
# 启用关闭按钮
close_button.setEnabled(True)
signal_emitter.complete_signal.connect(on_test_complete)
# 测试函数
def test_ipv6():
try:
signal_emitter.update_signal.emit("正在测试IPv6连接请稍候...")
# 先进行标准的IPv6连接测试
signal_emitter.update_signal.emit("正在进行标准IPv6连接测试...")
# 使用IPv6测试URL
ipv6_test_url, req, context = self._get_ipv6_test_request()
ipv6_connected = False
ipv6_test_elapsed_time = 0
try:
# 设置5秒超时
start_time = time.time()
signal_emitter.update_signal.emit(f"开始连接: {ipv6_test_url}")
# 尝试下载图片
with urllib.request.urlopen(req, timeout=5, context=context) as response:
image_data = response.read()
# 计算耗时
elapsed_time = time.time() - start_time
ipv6_test_elapsed_time = elapsed_time
# 检查是否成功
if response.status == 200 and len(image_data) > 0:
ipv6_connected = True
signal_emitter.update_signal.emit(f"✓ 成功! 已下载 {len(image_data)} 字节")
signal_emitter.update_signal.emit(f"✓ 响应时间: {elapsed_time:.2f}")
else:
signal_emitter.update_signal.emit(f"✗ 失败: 状态码 {response.status}")
signal_emitter.update_signal.emit("\n结论: 您的网络不支持IPv6连接 ✗")
signal_emitter.complete_signal.emit(False, 0)
return
except Exception as e:
signal_emitter.update_signal.emit(f"✗ 连接失败: {e}")
signal_emitter.update_signal.emit("\n结论: 您的网络不支持IPv6连接 ✗")
signal_emitter.complete_signal.emit(False, 0)
return
# 如果IPv6连接测试成功再尝试获取公网IPv6地址
if ipv6_connected:
signal_emitter.update_signal.emit("\n正在获取您的公网IPv6地址...")
try:
# 使用curl命令获取IPv6地址
process = subprocess.Popen(
["curl", "-6", "6.ipw.cn"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding='utf-8',
errors='replace',
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0
)
# 设置超时
timeout = 5 # 5秒超时
start_time = time.time()
while process.poll() is None and (time.time() - start_time) < timeout:
time.sleep(0.1)
# 如果进程仍在运行,则强制终止
if process.poll() is None:
process.terminate()
signal_emitter.update_signal.emit("✗ 获取IPv6地址超时")
else:
stdout, stderr = process.communicate()
if process.returncode == 0 and stdout.strip():
ipv6_address = stdout.strip()
signal_emitter.update_signal.emit(f"✓ 获取到的IPv6地址: {ipv6_address}")
else:
signal_emitter.update_signal.emit("✗ 未能获取到IPv6地址")
if stderr:
signal_emitter.update_signal.emit(f"错误信息: {stderr}")
except Exception as e:
signal_emitter.update_signal.emit(f"✗ 获取IPv6地址失败: {e}")
# 输出最终结论
signal_emitter.update_signal.emit("\n结论: 您的网络支持IPv6连接 ✓")
signal_emitter.complete_signal.emit(True, ipv6_test_elapsed_time)
return
except Exception as e:
signal_emitter.update_signal.emit(f"测试过程中出错: {e}")
signal_emitter.complete_signal.emit(False, 0)
# 启动测试线程
threading.Thread(target=test_ipv6, daemon=True).start()
# 显示对话框
dialog.exec()
def toggle_ipv6_support(self, enabled):
"""切换IPv6支持
Args:
enabled: 是否启用IPv6支持
"""
print(f"Toggle IPv6 support: {enabled}")
# 保存设置到配置
if self.config is not None:
self.config["ipv6_enabled"] = enabled
# 直接使用utils.save_config保存配置
from utils import save_config
save_config(self.config)
# 显示设置已保存的消息
status = "启用" if enabled else "禁用"
msg_box = self._create_message_box("IPv6设置", f"\nIPv6支持已{status}。新的设置将在下一次下载时生效。\n")
msg_box.exec()
return True
def _create_message_box(self, title, message, buttons=QMessageBox.StandardButton.Ok):
"""创建统一风格的消息框
Args:
title: 消息框标题
message: 消息内容
buttons: 按钮类型,默认为确定按钮
Returns:
QMessageBox: 配置好的消息框实例
"""
msg_box = msgbox_frame(
f"{title} - {APP_NAME}",
message,
buttons,
)
return msg_box

View File

@@ -0,0 +1,992 @@
import os
import hashlib
import shutil
import tempfile
import py7zr
import traceback
from PySide6 import QtWidgets, QtCore
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QMessageBox
from data.config import PLUGIN, PLUGIN_HASH, GAME_INFO
from utils import msgbox_frame
from utils.logger import setup_logger
# 初始化logger
logger = setup_logger("offline_mode_manager")
class OfflineModeManager:
"""离线模式管理器,用于管理离线模式下的补丁安装和检测"""
def __init__(self, main_window):
"""初始化离线模式管理器
Args:
main_window: 主窗口实例用于访问UI和状态
"""
self.main_window = main_window
self.app_name = main_window.APP_NAME if hasattr(main_window, 'APP_NAME') else ""
self.offline_patches = {} # 存储离线补丁信息 {补丁名称: 文件路径}
self.is_offline_mode = False
self.installed_games = [] # 跟踪本次实际安装的游戏
# 保持对哈希线程的引用,避免运行中被销毁
self.hash_thread = None
# 解压线程与进度窗口引用避免运行中被销毁且确保UI可更新
self.extraction_thread = None
self.extraction_progress_window = None
def _is_debug_mode(self):
"""检查是否处于调试模式
Returns:
bool: 是否处于调试模式
"""
try:
if hasattr(self.main_window, 'debug_manager') and self.main_window.debug_manager:
if hasattr(self.main_window.debug_manager, '_is_debug_mode'):
# 尝试直接从debug_manager获取状态
return self.main_window.debug_manager._is_debug_mode()
elif hasattr(self.main_window, 'config'):
# 如果debug_manager还没准备好尝试从配置中获取
return self.main_window.config.get('debug_mode', False)
# 如果以上都不可行返回False
return False
except Exception:
# 捕获任何异常默认返回False
return False
def scan_for_offline_patches(self, directory=None):
"""扫描指定目录(默认为软件所在目录)查找离线补丁文件
Args:
directory: 要扫描的目录如果为None则使用软件所在目录
Returns:
dict: 找到的补丁文件 {补丁名称: 文件路径}
"""
if directory is None:
# 获取软件所在目录
directory = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
debug_mode = self._is_debug_mode()
# 无论是否为调试模式,都记录扫描操作
logger.info(f"扫描离线补丁文件,目录: {directory}")
# 要查找的补丁文件名
patch_files = ["vol.1.7z", "vol.2.7z", "vol.3.7z", "vol.4.7z", "after.7z"]
found_patches = {}
# 扫描目录中的文件
for file in os.listdir(directory):
if file.lower() in patch_files:
file_path = os.path.join(directory, file)
if os.path.isfile(file_path):
patch_name = file.lower()
found_patches[patch_name] = file_path
# 无论是否为调试模式,都记录找到的补丁文件
logger.info(f"找到离线补丁文件: {patch_name} 路径: {file_path}")
if debug_mode:
logger.debug(f"DEBUG: 找到离线补丁文件: {patch_name} 路径: {file_path}")
self.offline_patches = found_patches
# 记录扫描结果
if found_patches:
logger.info(f"共找到 {len(found_patches)} 个离线补丁文件: {list(found_patches.keys())}")
else:
logger.info("未找到任何离线补丁文件")
return found_patches
def has_offline_patches(self):
"""检查是否有可用的离线补丁文件
Returns:
bool: 是否有可用的离线补丁
"""
if not self.offline_patches:
self.scan_for_offline_patches()
return len(self.offline_patches) > 0
def set_offline_mode(self, enabled):
"""设置离线模式状态
Args:
enabled: 是否启用离线模式
Returns:
bool: 是否成功设置离线模式
"""
debug_mode = self._is_debug_mode()
if enabled:
# 检查是否有离线补丁文件
if not self.has_offline_patches() and not debug_mode:
msgbox_frame(
f"离线模式错误 - {self.app_name}",
"\n未找到任何离线补丁文件,无法启用离线模式。\n\n请将补丁文件放置在软件所在目录后再尝试。\n",
QMessageBox.StandardButton.Ok
).exec()
logger.warning("尝试启用离线模式失败:未找到任何离线补丁文件")
return False
if debug_mode:
logger.debug("DEBUG: 已启用离线模式(调试模式下允许强制启用)")
self.is_offline_mode = enabled
# 更新窗口标题
if hasattr(self.main_window, 'setWindowTitle'):
from data.config import APP_NAME, APP_VERSION
mode_indicator = "[离线模式]" if enabled else "[在线模式]"
self.main_window.setWindowTitle(f"{APP_NAME} v{APP_VERSION} {mode_indicator}")
# 同时更新UI中的标题标签
if hasattr(self.main_window, 'ui') and hasattr(self.main_window.ui, 'title_label'):
self.main_window.ui.title_label.setText(f"{APP_NAME} v{APP_VERSION} {mode_indicator}")
# 同步更新UI菜单中的模式选择状态
if hasattr(self.main_window, 'ui_manager'):
ui_manager = self.main_window.ui_manager
if hasattr(ui_manager, 'online_mode_action') and hasattr(ui_manager, 'offline_mode_action'):
ui_manager.online_mode_action.setChecked(not enabled)
ui_manager.offline_mode_action.setChecked(enabled)
# 无论是否为调试模式,都记录离线模式状态变化
logger.info(f"离线模式已{'启用' if enabled else '禁用'}")
if debug_mode:
logger.debug(f"DEBUG: 离线模式已{'启用' if enabled else '禁用'}")
return True
def get_offline_patch_path(self, game_version):
"""根据游戏版本获取对应的离线补丁文件路径
Args:
game_version: 游戏版本名称,如"NEKOPARA Vol.1"
Returns:
str: 离线补丁文件路径如果没有找到则返回None
"""
# 确保已扫描过补丁文件
if not self.offline_patches:
self.scan_for_offline_patches()
# 根据游戏版本获取对应的补丁文件名
patch_file = None
if "Vol.1" in game_version:
patch_file = "vol.1.7z"
elif "Vol.2" in game_version:
patch_file = "vol.2.7z"
elif "Vol.3" in game_version:
patch_file = "vol.3.7z"
elif "Vol.4" in game_version:
patch_file = "vol.4.7z"
elif "After" in game_version:
patch_file = "after.7z"
# 检查是否有对应的补丁文件
if patch_file and patch_file in self.offline_patches:
return self.offline_patches[patch_file]
return None
def prepare_offline_patch(self, game_version, target_path):
"""准备离线补丁文件,复制到缓存目录
Args:
game_version: 游戏版本名称
target_path: 目标路径(通常是缓存目录中的路径)
Returns:
bool: 是否成功准备补丁文件
"""
source_path = self.get_offline_patch_path(game_version)
if not source_path:
return False
debug_mode = self._is_debug_mode()
try:
# 确保目标目录存在
os.makedirs(os.path.dirname(target_path), exist_ok=True)
# 复制文件
shutil.copy2(source_path, target_path)
if debug_mode:
logger.debug(f"DEBUG: 已复制离线补丁文件 {source_path}{target_path}")
return True
except Exception as e:
if debug_mode:
logger.error(f"DEBUG: 复制离线补丁文件失败: {e}")
return False
def verify_patch_hash(self, game_version, file_path):
"""验证补丁文件的哈希值使用patch_detector模块
Args:
game_version: 游戏版本名称
file_path: 补丁压缩包文件路径
Returns:
bool: 哈希值是否匹配
"""
debug_mode = self._is_debug_mode()
if debug_mode:
logger.debug(f"DEBUG: 开始验证补丁文件哈希: {file_path}")
# 创建进度对话框
from utils.helpers import ProgressHashVerifyDialog
from data.config import PLUGIN_HASH
from workers.hash_thread import OfflineHashVerifyThread
# 创建并显示进度对话框
progress_dialog = ProgressHashVerifyDialog(
f"验证补丁文件 - {self.app_name}",
f"正在验证 {game_version} 的补丁文件完整性...",
self.main_window
)
# 创建哈希验证线程
hash_thread = OfflineHashVerifyThread(game_version, file_path, PLUGIN_HASH, self.main_window)
# 连接信号
hash_thread.progress.connect(progress_dialog.update_progress)
hash_thread.finished.connect(lambda result, error, extracted_path: self._on_hash_verify_finished(result, error, extracted_path, progress_dialog))
# 启动线程
hash_thread.start()
# 显示对话框,阻塞直到对话框关闭
result = progress_dialog.exec()
# 如果用户取消了验证,停止线程
if result == ProgressHashVerifyDialog.Rejected and hash_thread.isRunning():
if debug_mode:
logger.debug(f"DEBUG: 用户取消了哈希验证")
hash_thread.terminate()
hash_thread.wait()
return False
# 返回对话框中存储的验证结果
return hasattr(progress_dialog, 'hash_result') and progress_dialog.hash_result
def _on_hash_verify_finished(self, result, error, extracted_path, dialog):
"""哈希验证线程完成后的回调
Args:
result: 验证结果
error: 错误信息
extracted_path: 解压后的补丁文件路径,如果哈希验证成功则包含此路径
dialog: 进度对话框
"""
debug_mode = self._is_debug_mode()
# 存储结果到对话框以便在exec()返回后获取
dialog.hash_result = result
if result:
if debug_mode:
logger.debug(f"DEBUG: 哈希验证成功")
if extracted_path:
logger.debug(f"DEBUG: 解压后的补丁文件路径: {extracted_path}")
dialog.set_status("验证成功")
# 短暂延时后关闭对话框
QTimer.singleShot(500, dialog.accept)
else:
if debug_mode:
logger.debug(f"DEBUG: 哈希验证失败: {error}")
dialog.set_status(f"验证失败: {error}")
dialog.set_message("补丁文件验证失败,可能已损坏或被篡改。")
# 将取消按钮改为关闭按钮
dialog.cancel_button.setText("关闭")
# 不自动关闭,让用户查看错误信息
def _on_offline_install_hash_finished(self, result, error, extracted_path, dialog, game_version, _7z_path, game_folder, plugin_path, install_tasks):
"""离线安装哈希验证线程完成后的回调
Args:
result: 验证结果
error: 错误信息
extracted_path: 解压后的补丁文件路径
dialog: 进度对话框
game_version: 游戏版本
_7z_path: 7z文件路径
game_folder: 游戏文件夹路径
plugin_path: 插件路径
install_tasks: 剩余的安装任务列表
"""
debug_mode = self._is_debug_mode()
# 导入所需模块
from data.config import GAME_INFO, PLUGIN
# 存储结果到对话框以便在exec()返回后获取
dialog.hash_result = result
# 关闭哈希验证窗口
self.main_window.close_hash_msg_box()
if not result:
# 哈希验证失败
if debug_mode:
logger.warning(f"DEBUG: 补丁文件哈希验证失败: {error}")
# 显示错误消息
msgbox_frame(
f"哈希验证失败 - {self.app_name}",
f"\n{game_version} 的补丁文件哈希验证失败,可能已损坏或被篡改。\n\n跳过此游戏的安装。\n",
QMessageBox.StandardButton.Ok
).exec()
# 继续下一个任务
self.process_next_offline_install_task(install_tasks)
return
# 哈希验证成功,直接进行安装
if debug_mode:
logger.debug(f"DEBUG: 哈希验证成功,开始安装")
# 显示安装进度窗口
self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="offline_installation", is_offline=True)
try:
# 确保游戏目录存在
os.makedirs(game_folder, exist_ok=True)
# 根据游戏版本确定目标文件名
target_filename = None
if "Vol.1" in game_version:
target_filename = "adultsonly.xp3"
elif "Vol.2" in game_version:
target_filename = "adultsonly.xp3"
elif "Vol.3" in game_version:
target_filename = "update00.int"
elif "Vol.4" in game_version:
target_filename = "vol4adult.xp3"
elif "After" in game_version:
target_filename = "afteradult.xp3"
if not target_filename:
raise ValueError(f"未知的游戏版本: {game_version}")
# 直接解压文件到游戏目录
import py7zr
if debug_mode:
logger.debug(f"DEBUG: 直接解压文件 {_7z_path} 到游戏目录 {game_folder}")
# 解压文件
with py7zr.SevenZipFile(_7z_path, mode="r") as archive:
# 获取压缩包内的文件列表
file_list = archive.getnames()
if debug_mode:
logger.debug(f"DEBUG: 压缩包内文件列表: {file_list}")
# 解析压缩包内的文件结构
target_file_in_archive = None
for file_path in file_list:
if target_filename in file_path:
target_file_in_archive = file_path
break
if not target_file_in_archive:
if debug_mode:
logger.warning(f"DEBUG: 在压缩包中未找到目标文件 {target_filename}")
raise FileNotFoundError(f"在压缩包中未找到目标文件 {target_filename}")
# 准备解压特定文件到游戏目录
target_path = os.path.join(game_folder, target_filename)
# 创建一个临时目录用于解压单个文件
with tempfile.TemporaryDirectory() as temp_dir:
# 解压特定文件到临时目录
archive.extract(path=temp_dir, targets=[target_file_in_archive])
# 找到解压后的文件
extracted_file_path = os.path.join(temp_dir, target_file_in_archive)
# 复制到目标位置
shutil.copy2(extracted_file_path, target_path)
if debug_mode:
logger.debug(f"DEBUG: 已解压并复制文件到 {target_path}")
# 对于NEKOPARA After还需要复制签名文件
if game_version == "NEKOPARA After":
sig_filename = f"{target_filename}.sig"
sig_file_in_archive = None
# 查找签名文件
for file_path in file_list:
if sig_filename in file_path:
sig_file_in_archive = file_path
break
if sig_file_in_archive:
# 解压签名文件
archive.extract(path=temp_dir, targets=[sig_file_in_archive])
extracted_sig_path = os.path.join(temp_dir, sig_file_in_archive)
sig_target = os.path.join(game_folder, sig_filename)
shutil.copy2(extracted_sig_path, sig_target)
if debug_mode:
logger.debug(f"DEBUG: 已解压并复制签名文件到 {sig_target}")
else:
if debug_mode:
logger.warning(f"DEBUG: 未找到签名文件 {sig_filename}")
# 进行安装后的哈希校验
self._perform_hash_check(game_version, install_tasks)
except Exception as e:
if debug_mode:
logger.error(f"DEBUG: 安装补丁文件失败: {e}")
import traceback
logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}")
# 关闭安装进度窗口
self.main_window.close_hash_msg_box()
# 显示错误消息
msgbox_frame(
f"安装错误 - {self.app_name}",
f"\n{game_version} 的安装过程中发生错误: {str(e)}\n\n跳过此游戏的安装。\n",
QMessageBox.StandardButton.Ok
).exec()
# 继续下一个任务
self.process_next_offline_install_task(install_tasks)
def _perform_hash_check(self, game_version, install_tasks):
"""安装完成后进行哈希校验
Args:
game_version: 游戏版本
install_tasks: 剩余的安装任务列表
"""
debug_mode = self._is_debug_mode()
# 导入所需模块
from data.config import GAME_INFO, PLUGIN_HASH
from workers.hash_thread import HashThread
# 获取安装路径
install_paths = {}
game_dirs = self.main_window.game_detector.identify_game_directories_improved(
self.main_window.download_manager.selected_folder
)
for game, info in GAME_INFO.items():
if game in game_dirs and game == game_version:
game_dir = game_dirs[game]
install_path = os.path.join(game_dir, os.path.basename(info["install_path"]))
install_paths[game] = install_path
break
if not install_paths:
# 如果找不到安装路径,直接认为安装成功
logger.warning(f"未找到 {game_version} 的安装路径,跳过哈希校验")
self.main_window.installed_status[game_version] = True
# 添加到已安装游戏列表
if game_version not in self.installed_games:
self.installed_games.append(game_version)
# 关闭安装进度窗口
self.main_window.close_hash_msg_box()
# 继续下一个任务
self.process_next_offline_install_task(install_tasks)
return
# 关闭可能存在的哈希校验窗口,然后创建新窗口
self.main_window.close_hash_msg_box()
# 显示哈希校验窗口
self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="post", is_offline=True)
# 直接创建并启动哈希线程进行校验,而不是通过主窗口
hash_thread = HashThread(
"after",
install_paths,
PLUGIN_HASH,
self.main_window.installed_status,
self.main_window
)
hash_thread.after_finished.connect(
lambda result: self._on_hash_check_finished(result, game_version, install_tasks)
)
# 保存引用以便后续使用
self.hash_thread = hash_thread
try:
self.hash_thread.finished.connect(lambda: setattr(self, 'hash_thread', None))
except Exception:
pass
hash_thread.start()
def _on_hash_check_finished(self, result, game_version, install_tasks):
"""哈希校验完成后的处理
Args:
result: 校验结果,包含通过状态、游戏版本和消息
game_version: 游戏版本
install_tasks: 剩余的安装任务列表
"""
debug_mode = self._is_debug_mode()
# 关闭哈希检查窗口
self.main_window.close_hash_msg_box()
if not result["passed"]:
# 校验失败,删除已解压的文件并提示重新安装
error_message = result["message"]
# 获取安装路径
install_path = None
game_dirs = self.main_window.game_detector.identify_game_directories_improved(
self.main_window.download_manager.selected_folder
)
from data.config import GAME_INFO
if game_version in game_dirs and game_version in GAME_INFO:
game_dir = game_dirs[game_version]
install_path = os.path.join(game_dir, os.path.basename(GAME_INFO[game_version]["install_path"]))
# 如果找到安装路径,尝试删除已解压的文件
if install_path and os.path.exists(install_path):
try:
os.remove(install_path)
logger.info(f"已删除校验失败的文件: {install_path}")
except Exception as e:
logger.error(f"删除文件失败: {e}")
# 显示错误消息
msgbox_frame(
f"校验失败 - {self.app_name}",
f"{error_message}\n\n跳过此游戏的安装。",
QMessageBox.StandardButton.Ok
).exec()
# 更新安装状态
self.main_window.installed_status[game_version] = False
else:
# 校验通过,更新安装状态
self.main_window.installed_status[game_version] = True
# 添加到已安装游戏列表
if game_version not in self.installed_games:
self.installed_games.append(game_version)
# 显示安装成功消息
if debug_mode:
logger.debug(f"DEBUG: {game_version} 安装成功并通过哈希校验")
# 继续处理下一个任务
self.process_next_offline_install_task(install_tasks)
def _on_extraction_finished_with_hash_check(self, success, error_message, game_version, install_tasks):
"""解压完成后进行哈希校验(后台线程回调)"""
# 关闭解压进度窗口
try:
if self.extraction_progress_window and self.extraction_progress_window.isVisible():
self.extraction_progress_window.close()
except Exception:
pass
self.extraction_progress_window = None
# 清理线程引用
self.extraction_thread = None
if not success:
# 解压失败,提示并继续下一个任务
msgbox_frame(
f"安装错误 - {self.app_name}",
error_message or f"\n{game_version} 的安装过程中发生错误。\n",
QMessageBox.StandardButton.Ok
).exec()
self.process_next_offline_install_task(install_tasks)
return
# 解压成功,进入安装后哈希校验
self._perform_hash_check(game_version, install_tasks)
def on_extraction_thread_finished(self, success, error_message, game_version, install_tasks):
"""解压线程完成后的处理(兼容旧版本)
Args:
success: 是否解压成功
error_message: 错误信息
game_version: 游戏版本
install_tasks: 剩余的安装任务列表
"""
# 这个方法已不再使用,但为了兼容性,我们直接处理下一个任务
if success:
# 更新安装状态
self.main_window.installed_status[game_version] = True
# 添加到已安装游戏列表
if game_version not in self.installed_games:
self.installed_games.append(game_version)
else:
# 更新安装状态
self.main_window.installed_status[game_version] = False
# 显示错误消息
debug_mode = self._is_debug_mode()
if debug_mode:
logger.error(f"DEBUG: 解压失败: {error_message}")
# 继续下一个任务
self.process_next_offline_install_task(install_tasks)
def install_offline_patches(self, selected_games):
"""直接安装离线补丁,完全绕过下载模块
Args:
selected_games: 用户选择安装的游戏列表
Returns:
bool: 是否成功启动安装流程
"""
debug_mode = self._is_debug_mode()
if debug_mode:
logger.debug(f"DEBUG: 开始离线安装流程,选择的游戏: {selected_games}")
if not self.is_in_offline_mode():
if debug_mode:
logger.warning("DEBUG: 当前不是离线模式,无法使用离线安装")
return False
# 确保已扫描过补丁文件
if not self.offline_patches:
self.scan_for_offline_patches()
if not self.offline_patches:
if debug_mode:
logger.warning("DEBUG: 未找到任何离线补丁文件")
msgbox_frame(
f"离线安装错误 - {self.app_name}",
"\n未找到任何离线补丁文件,无法进行离线安装。\n\n请将补丁文件放置在软件所在目录后再尝试。\n",
QMessageBox.StandardButton.Ok
).exec()
return False
# 获取游戏目录
game_dirs = self.main_window.game_detector.identify_game_directories_improved(
self.main_window.download_manager.selected_folder
)
if not game_dirs:
if debug_mode:
logger.warning("DEBUG: 未识别到任何游戏目录")
return False
self.main_window.setEnabled(False)
# 重置已安装游戏列表
self.installed_games = []
# 设置到主窗口,供结果显示使用
self.main_window.download_queue_history = selected_games
# 记录未找到离线补丁文件的游戏
self.missing_offline_patches = []
# 创建安装任务列表
install_tasks = []
for game_version in selected_games:
# 获取离线补丁文件路径
patch_file = self.get_offline_patch_path(game_version)
if not patch_file:
if debug_mode:
logger.warning(f"DEBUG: 未找到 {game_version} 的离线补丁文件,跳过")
# 记录未找到离线补丁文件的游戏
self.missing_offline_patches.append(game_version)
continue
# 获取游戏目录
game_folder = game_dirs.get(game_version)
if not game_folder:
if debug_mode:
logger.warning(f"DEBUG: 未找到 {game_version} 的游戏目录,跳过")
continue
# 获取目标路径
if "Vol.1" in game_version:
_7z_path = os.path.join(PLUGIN, "vol.1.7z")
plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"])
elif "Vol.2" in game_version:
_7z_path = os.path.join(PLUGIN, "vol.2.7z")
plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"])
elif "Vol.3" in game_version:
_7z_path = os.path.join(PLUGIN, "vol.3.7z")
plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"])
elif "Vol.4" in game_version:
_7z_path = os.path.join(PLUGIN, "vol.4.7z")
plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"])
elif "After" in game_version:
_7z_path = os.path.join(PLUGIN, "after.7z")
plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"])
else:
if debug_mode:
logger.warning(f"DEBUG: {game_version} 不是支持的游戏版本,跳过")
continue
# 添加到安装任务列表
install_tasks.append((patch_file, game_folder, game_version, _7z_path, plugin_path))
# 开始执行第一个安装任务
if install_tasks:
if debug_mode:
logger.info(f"DEBUG: 开始离线安装流程,安装游戏数量: {len(install_tasks)}")
self.process_next_offline_install_task(install_tasks)
else:
if debug_mode:
logger.warning("DEBUG: 没有可安装的游戏,安装流程结束")
# 检查是否有未找到离线补丁文件的游戏
if self.missing_offline_patches:
if debug_mode:
logger.debug(f"DEBUG: 有未找到离线补丁文件的游戏: {self.missing_offline_patches}")
# 询问用户是否切换到在线模式
msg_box = msgbox_frame(
f"离线安装信息 - {self.app_name}",
f"\n本地未发现对应离线文件,是否切换为在线模式安装?\n\n以下游戏未找到对应的离线补丁文件:\n\n{chr(10).join(self.missing_offline_patches)}\n",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
result = msg_box.exec()
if result == QMessageBox.StandardButton.Yes:
if debug_mode:
logger.debug("DEBUG: 用户选择切换到在线模式")
# 切换到在线模式
if hasattr(self.main_window, 'ui_manager'):
self.main_window.ui_manager.switch_work_mode("online")
# 直接启动下载流程
self.main_window.setEnabled(True)
# 保存当前选择的游戏列表,以便在线模式使用
missing_games = self.missing_offline_patches.copy()
# 启动下载流程
QTimer.singleShot(500, lambda: self._start_online_download(missing_games))
else:
if debug_mode:
logger.debug("DEBUG: 用户选择不切换到在线模式")
# 恢复UI状态
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
else:
# 没有缺少离线补丁的游戏,显示一般消息
msgbox_frame(
f"离线安装信息 - {self.app_name}",
"\n没有可安装的游戏或未找到对应的离线补丁文件。\n",
QMessageBox.StandardButton.Ok
).exec()
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
return True
def _start_online_download(self, games_to_download):
"""启动在线下载流程
Args:
games_to_download: 要下载的游戏列表
"""
debug_mode = self._is_debug_mode()
if debug_mode:
logger.debug(f"DEBUG: 启动在线下载流程,游戏列表: {games_to_download}")
# 确保下载管理器已初始化
if hasattr(self.main_window, 'download_manager'):
# 使用直接下载方法,绕过补丁判断
self.main_window.download_manager.direct_download_action(games_to_download)
else:
if debug_mode:
logger.error("DEBUG: 下载管理器未初始化,无法启动下载流程")
# 显示错误消息
msgbox_frame(
f"错误 - {self.app_name}",
"\n下载管理器未初始化,无法启动下载流程。\n",
QMessageBox.StandardButton.Ok
).exec()
def process_next_offline_install_task(self, install_tasks):
"""处理下一个离线安装任务
Args:
install_tasks: 安装任务列表,每个任务是一个元组 (patch_file, game_folder, game_version, _7z_path, plugin_path)
"""
debug_mode = self._is_debug_mode()
if not install_tasks:
# 所有任务完成,进行后检查
if debug_mode:
logger.info("DEBUG: 所有离线安装任务完成,进行后检查")
# 使用patch_detector进行安装后哈希比较
self.main_window.patch_detector.after_hash_compare()
# 检查是否有未找到离线补丁文件的游戏
if hasattr(self, 'missing_offline_patches') and self.missing_offline_patches:
if debug_mode:
logger.debug(f"DEBUG: 有未找到离线补丁文件的游戏: {self.missing_offline_patches}")
# 先显示已安装的结果
if self.installed_games:
installed_msg = f"已成功安装以下补丁:\n\n{chr(10).join(self.installed_games)}\n\n"
else:
installed_msg = ""
# 使用QTimer延迟显示询问对话框确保安装结果窗口先显示并关闭
QTimer.singleShot(500, lambda: self._show_missing_patches_dialog(installed_msg))
else:
# 恢复UI状态
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
return
# 获取下一个任务
patch_file, game_folder, game_version, _7z_path, plugin_path = install_tasks.pop(0)
if debug_mode:
logger.debug(f"DEBUG: 处理离线安装任务: {game_version}")
logger.debug(f"DEBUG: 补丁文件: {patch_file}")
logger.debug(f"DEBUG: 游戏目录: {game_folder}")
# 使用后台线程进行解压避免阻塞UI
try:
# 确保游戏目录存在
os.makedirs(game_folder, exist_ok=True)
# 创建非阻塞的解压进度窗口
self.extraction_progress_window = self.main_window.create_extraction_progress_window()
try:
self.extraction_progress_window.show()
QtWidgets.QApplication.processEvents()
except Exception:
pass
# 启动解压线程
self.extraction_thread = self.main_window.create_extraction_thread(
patch_file, game_folder, plugin_path, game_version
)
# 连接进度更新到窗口控件
if self.extraction_thread and self.extraction_progress_window:
self.extraction_thread.progress.connect(
lambda percent, status: (
self.extraction_progress_window.progress_bar.setValue(percent),
self.extraction_progress_window.status_label.setText(status)
)
)
# 完成后进入哈希校验
self.extraction_thread.finished.connect(
lambda success, error, gv: self._on_extraction_finished_with_hash_check(success, error, gv, install_tasks)
)
# 线程结束时清理引用
try:
self.extraction_thread.finished.connect(lambda *_: setattr(self, 'extraction_thread', None))
except Exception:
pass
self.extraction_thread.start()
except Exception as e:
if debug_mode:
logger.error(f"DEBUG: 离线安装任务处理失败: {e}")
logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}")
# 关闭可能存在的解压进度窗口
try:
if self.extraction_progress_window and self.extraction_progress_window.isVisible():
self.extraction_progress_window.close()
except Exception:
pass
self.extraction_progress_window = None
# 显示错误消息
msgbox_frame(
f"安装错误 - {self.app_name}",
f"\n{game_version} 的安装过程中发生错误: {str(e)}\n\n跳过此游戏的安装。\n",
QMessageBox.StandardButton.Ok
).exec()
# 继续下一个任务
self.process_next_offline_install_task(install_tasks)
def is_offline_mode_available(self):
"""检查是否可以使用离线模式
Returns:
bool: 是否可以使用离线模式
"""
# 在调试模式下始终允许离线模式
if self._is_debug_mode():
return True
# 检查是否有离线补丁文件
return self.has_offline_patches()
def is_in_offline_mode(self):
"""检查当前是否处于离线模式
Returns:
bool: 是否处于离线模式
"""
return self.is_offline_mode
def _show_missing_patches_dialog(self, installed_msg):
"""显示缺少离线补丁文件的对话框
Args:
installed_msg: 已安装的补丁信息
"""
debug_mode = self._is_debug_mode()
# 在安装完成后询问用户是否切换到在线模式
msg_box = msgbox_frame(
f"离线安装完成 - {self.app_name}",
f"\n{installed_msg}以下游戏未找到对应的离线补丁文件:\n\n{chr(10).join(self.missing_offline_patches)}\n\n是否切换到在线模式继续安装?\n",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
result = msg_box.exec()
if result == QMessageBox.StandardButton.Yes:
if debug_mode:
logger.debug("DEBUG: 用户选择切换到在线模式")
# 切换到在线模式
if hasattr(self.main_window, 'ui_manager'):
self.main_window.ui_manager.switch_work_mode("online")
# 直接启动下载流程
self.main_window.setEnabled(True)
# 保存当前选择的游戏列表,以便在线模式使用
missing_games = self.missing_offline_patches.copy()
# 启动下载流程
QTimer.singleShot(500, lambda: self._start_online_download(missing_games))
else:
if debug_mode:
logger.debug("DEBUG: 用户选择不切换到在线模式")
# 恢复UI状态
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")

View File

@@ -0,0 +1,375 @@
import os
import hashlib
import tempfile
import py7zr
import traceback
from utils.logger import setup_logger
from PySide6.QtWidgets import QMessageBox
from PySide6.QtCore import QTimer, QThread, Signal
from data.config import PLUGIN_HASH, APP_NAME
# 初始化logger
logger = setup_logger("patch_detector")
class PatchCheckThread(QThread):
"""用于在后台线程中执行补丁检查的线程"""
finished = Signal(bool) # (is_installed)
def __init__(self, checker_func, *args):
super().__init__()
self.checker_func = checker_func
self.args = args
def run(self):
result = self.checker_func(*self.args)
self.finished.emit(result)
class PatchDetector:
"""补丁检测与校验模块,用于统一处理在线和离线模式下的补丁检测和校验"""
def __init__(self, main_window):
"""初始化补丁检测器
Args:
main_window: 主窗口实例用于访问UI和状态
"""
self.main_window = main_window
self.app_name = main_window.APP_NAME if hasattr(main_window, 'APP_NAME') else ""
self.game_info = {}
self.plugin_hash = {}
self._load_game_info()
self.patch_check_thread = None
def _load_game_info(self):
"""从配置中加载游戏信息和补丁哈希值"""
try:
from data.config import GAME_INFO, PLUGIN_HASH
self.game_info = GAME_INFO
self.plugin_hash = PLUGIN_HASH
except ImportError:
logger.error("无法加载游戏信息或补丁哈希值配置")
def _is_debug_mode(self):
"""检查是否处于调试模式
Returns:
bool: 是否处于调试模式
"""
try:
if hasattr(self.main_window, 'debug_manager') and self.main_window.debug_manager:
if hasattr(self.main_window.debug_manager, '_is_debug_mode'):
return self.main_window.debug_manager._is_debug_mode()
elif hasattr(self.main_window, 'config'):
return self.main_window.config.get('debug_mode', False)
return False
except Exception:
return False
def check_patch_installed_async(self, game_dir, game_version, callback):
"""异步检查游戏是否已安装补丁"""
def on_finished(is_installed):
callback(is_installed)
self.patch_check_thread = None
self.patch_check_thread = PatchCheckThread(self._check_patch_installed_sync, game_dir, game_version)
self.patch_check_thread.finished.connect(on_finished)
self.patch_check_thread.start()
def _check_patch_installed_sync(self, game_dir, game_version):
"""同步检查游戏是否已安装补丁(在工作线程中运行)"""
debug_mode = self._is_debug_mode()
if debug_mode:
logger.debug(f"DEBUG: 检查 {game_version} 是否已安装补丁,目录: {game_dir}")
if game_version not in self.game_info:
if debug_mode:
logger.debug(f"DEBUG: {game_version} 不在支持的游戏列表中,跳过检查")
return False
install_path_base = os.path.basename(self.game_info[game_version]["install_path"])
patch_file_path = os.path.join(game_dir, install_path_base)
# 检查补丁文件和禁用的补丁文件
if os.path.exists(patch_file_path) or os.path.exists(f"{patch_file_path}.fain"):
return True
return False
def check_patch_installed(self, game_dir, game_version):
"""检查游戏是否已安装补丁(此方法可能导致阻塞,推荐使用异步版本)"""
return self._check_patch_installed_sync(game_dir, game_version)
def check_patch_disabled(self, game_dir, game_version):
"""检查游戏的补丁是否已被禁用"""
debug_mode = self._is_debug_mode()
if game_version not in self.game_info:
return False, None
install_path_base = os.path.basename(self.game_info[game_version]["install_path"])
patch_file_path = os.path.join(game_dir, install_path_base)
disabled_path = f"{patch_file_path}.fain"
if os.path.exists(disabled_path):
if debug_mode:
logger.debug(f"找到禁用的补丁文件: {disabled_path}")
return True, disabled_path
if debug_mode:
logger.debug(f"{game_version}{game_dir} 的补丁未被禁用")
return False, None
def detect_installable_games(self, game_dirs):
"""检测可安装补丁的游戏"""
debug_mode = self._is_debug_mode()
if debug_mode:
logger.debug(f"开始检测可安装补丁的游戏,游戏目录: {game_dirs}")
already_installed_games = []
installable_games = []
disabled_patch_games = []
for game_version, game_dir in game_dirs.items():
is_patch_installed = self.check_patch_installed(game_dir, game_version)
hash_check_passed = self.main_window.installed_status.get(game_version, False)
if is_patch_installed or hash_check_passed:
if debug_mode:
logger.info(f"DEBUG: {game_version} 已安装补丁,不需要再次安装")
logger.info(f"DEBUG: 文件检查结果: {is_patch_installed}, 哈希检查结果: {hash_check_passed}")
already_installed_games.append(game_version)
self.main_window.installed_status[game_version] = True
else:
is_disabled, disabled_path = self.check_patch_disabled(game_dir, game_version)
if is_disabled:
if debug_mode:
logger.info(f"DEBUG: {game_version} 存在被禁用的补丁: {disabled_path}")
disabled_patch_games.append(game_version)
else:
if debug_mode:
logger.info(f"DEBUG: {game_version} 未安装补丁,可以安装")
logger.info(f"DEBUG: 文件检查结果: {is_patch_installed}, 哈希检查结果: {hash_check_passed}")
installable_games.append(game_version)
if debug_mode:
logger.debug(f"检测结果 - 已安装补丁: {already_installed_games}")
logger.debug(f"检测结果 - 可安装补丁: {installable_games}")
logger.debug(f"检测结果 - 禁用补丁: {disabled_patch_games}")
return already_installed_games, installable_games, disabled_patch_games
def verify_patch_hash(self, game_version, file_path):
"""验证补丁文件的哈希值"""
expected_hash = self.plugin_hash.get(game_version, "")
if not expected_hash:
logger.warning(f"DEBUG: 未找到 {game_version} 的预期哈希值")
return False
debug_mode = self._is_debug_mode()
if debug_mode:
logger.debug(f"DEBUG: 开始验证补丁文件: {file_path}")
logger.debug(f"DEBUG: 游戏版本: {game_version}")
logger.debug(f"DEBUG: 预期哈希值: {expected_hash}")
try:
if not os.path.exists(file_path) or os.path.getsize(file_path) == 0:
return False
with tempfile.TemporaryDirectory() as temp_dir:
if debug_mode:
logger.debug(f"DEBUG: 创建临时目录: {temp_dir}")
try:
with py7zr.SevenZipFile(file_path, mode="r") as archive:
archive.extractall(path=temp_dir)
except Exception as e:
if debug_mode:
logger.error(f"DEBUG: 解压补丁文件失败: {e}")
return False
patch_file = self._find_patch_file_in_temp_dir(temp_dir, game_version)
if not patch_file or not os.path.exists(patch_file):
if debug_mode:
logger.warning(f"DEBUG: 未找到解压后的补丁文件")
return False
if debug_mode:
logger.debug(f"DEBUG: 找到解压后的补丁文件: {patch_file}")
try:
with open(patch_file, "rb") as f:
file_hash = hashlib.sha256(f.read()).hexdigest()
result = file_hash.lower() == expected_hash.lower()
if debug_mode:
logger.debug(f"DEBUG: 补丁文件 {patch_file} 哈希值验证: {'成功' if result else '失败'}")
return result
except Exception as e:
if debug_mode:
logger.error(f"DEBUG: 计算补丁文件哈希值失败: {e}")
return False
except Exception as e:
if debug_mode:
logger.error(f"DEBUG: 验证补丁哈希值失败: {e}")
return False
def _find_patch_file_in_temp_dir(self, temp_dir, game_version):
"""在临时目录中查找解压后的补丁文件"""
game_patch_map = {
"Vol.1": os.path.join("vol.1", "adultsonly.xp3"),
"Vol.2": os.path.join("vol.2", "adultsonly.xp3"),
"Vol.3": os.path.join("vol.3", "update00.int"),
"Vol.4": os.path.join("vol.4", "vol4adult.xp3"),
"After": os.path.join("after", "afteradult.xp3"),
}
for version_keyword, relative_path in game_patch_map.items():
if version_keyword in game_version:
return os.path.join(temp_dir, relative_path)
# 如果没有找到,则进行通用搜索
for root, dirs, files in os.walk(temp_dir):
for file in files:
if file.endswith('.xp3') or file.endswith('.int'):
return os.path.join(root, file)
return None
def create_hash_thread(self, mode, install_paths):
from workers.hash_thread import HashThread
return HashThread(mode, install_paths, PLUGIN_HASH, self.main_window.installed_status, self.main_window)
def after_hash_compare(self):
is_offline = self.main_window.offline_mode_manager.is_in_offline_mode()
self.main_window.close_hash_msg_box()
self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="after", is_offline=is_offline)
install_paths = self.main_window.download_manager.get_install_paths()
self.main_window.hash_thread = self.create_hash_thread("after", install_paths)
self.main_window.hash_thread.after_finished.connect(self.on_after_hash_finished)
self.main_window.hash_thread.start()
def on_after_hash_finished(self, result):
self.main_window.close_hash_msg_box()
if not result["passed"]:
self.main_window.setEnabled(True)
game = result.get("game", "未知游戏")
message = result.get("message", "发生未知错误。")
QMessageBox.critical(self.main_window, f"文件校验失败 - {APP_NAME}", message)
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
QTimer.singleShot(100, self.main_window.show_result)
def on_offline_pre_hash_finished(self, updated_status, game_dirs):
self.main_window.installed_status = updated_status
if self.main_window.hash_msg_box and self.main_window.hash_msg_box.isVisible():
self.main_window.hash_msg_box.accept()
self.main_window.hash_msg_box = None
self.main_window.setEnabled(True)
already_installed_games, installable_games, disabled_patch_games = self.detect_installable_games(game_dirs)
status_message = ""
if already_installed_games:
status_message += f"已安装补丁的游戏:\n{chr(10).join(already_installed_games)}\n\n"
if disabled_patch_games:
disabled_msg = f"检测到以下游戏的补丁已被禁用:\n{chr(10).join(disabled_patch_games)}\n\n是否要启用这些补丁?"
from PySide6 import QtWidgets
reply = QtWidgets.QMessageBox.question(
self.main_window,
f"检测到禁用补丁 - {APP_NAME}",
disabled_msg,
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No
)
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
disabled_game_dirs = {game: game_dirs[game] for game in disabled_patch_games}
success_count, fail_count, results = self.main_window.patch_manager.batch_toggle_patches(
disabled_game_dirs,
operation="enable"
)
self.main_window.patch_manager.show_toggle_result(success_count, fail_count, results)
for game_version in disabled_patch_games:
self.main_window.installed_status[game_version] = True
if game_version in installable_games:
installable_games.remove(game_version)
if game_version not in already_installed_games:
already_installed_games.append(game_version)
else:
installable_games.extend(disabled_patch_games)
if disabled_patch_games:
status_message += f"禁用补丁的游戏:\n{chr(10).join(disabled_patch_games)}\n\n"
if not installable_games:
if already_installed_games:
QMessageBox.information(
self.main_window,
f"信息 - {APP_NAME}",
f"\n所有游戏已安装补丁,无需重复安装。\n\n{status_message}",
)
else:
QMessageBox.warning(
self.main_window,
f"警告 - {APP_NAME}",
"\n未检测到任何需要安装补丁的游戏。\n\n请确保游戏文件夹位于选择的目录中。\n",
)
self.main_window.ui.start_install_text.setText("开始安装")
return
from PySide6 import QtWidgets
dialog = QtWidgets.QDialog(self.main_window)
dialog.setWindowTitle(f"选择要安装的游戏 - {APP_NAME}")
dialog.setMinimumWidth(300)
layout = QtWidgets.QVBoxLayout()
label = QtWidgets.QLabel("请选择要安装补丁的游戏:")
layout.addWidget(label)
list_widget = QtWidgets.QListWidget()
list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.MultiSelection)
for game in installable_games:
item = QtWidgets.QListWidgetItem(game)
list_widget.addItem(item)
item.setSelected(True)
layout.addWidget(list_widget)
button_box = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.StandardButton.Ok |
QtWidgets.QDialogButtonBox.StandardButton.Cancel
)
button_box.accepted.connect(dialog.accept)
button_box.rejected.connect(dialog.reject)
layout.addWidget(button_box)
dialog.setLayout(layout)
result = dialog.exec()
if result != QtWidgets.QDialog.DialogCode.Accepted or not list_widget.selectedItems():
self.main_window.ui.start_install_text.setText("开始安装")
return
selected_games = [item.text() for item in list_widget.selectedItems()]
self.main_window.offline_mode_manager.install_offline_patches(selected_games)

View File

@@ -0,0 +1,983 @@
import os
import shutil
import traceback
from PySide6.QtWidgets import QMessageBox
from utils.logger import setup_logger
from data.config import APP_NAME
from utils import msgbox_frame
class PatchManager:
"""补丁管理器,用于处理补丁的安装和卸载"""
def __init__(self, app_name, game_info, debug_manager=None, main_window=None):
"""初始化补丁管理器
Args:
app_name: 应用程序名称,用于显示消息框标题
game_info: 游戏信息字典,包含各版本的安装路径和可执行文件名
debug_manager: 调试管理器实例,用于输出调试信息
main_window: 主窗口实例用于访问UI和状态
"""
self.app_name = app_name
self.game_info = game_info
self.debug_manager = debug_manager
self.main_window = main_window # 添加main_window属性
self.installed_status = {} # 游戏版本的安装状态
self.logger = setup_logger("patch_manager")
self.patch_detector = None # 将在main_window初始化后设置
def set_patch_detector(self, patch_detector):
"""设置补丁检测器实例
Args:
patch_detector: 补丁检测器实例
"""
self.patch_detector = patch_detector
def _is_debug_mode(self):
"""检查是否处于调试模式
Returns:
bool: 是否处于调试模式
"""
if hasattr(self.debug_manager, 'ui_manager') and hasattr(self.debug_manager.ui_manager, 'debug_action'):
return self.debug_manager.ui_manager.debug_action.isChecked()
return False
def initialize_status(self):
"""初始化所有游戏版本的安装状态"""
self.installed_status = {f"NEKOPARA Vol.{i}": False for i in range(1, 5)}
self.installed_status["NEKOPARA After"] = False
def update_status(self, game_version, is_installed):
"""更新游戏版本的安装状态
Args:
game_version: 游戏版本
is_installed: 是否已安装
"""
self.installed_status[game_version] = is_installed
def get_status(self, game_version=None):
"""获取游戏版本的安装状态
Args:
game_version: 游戏版本如果为None则返回所有状态
Returns:
bool或dict: 指定版本的安装状态或所有版本的安装状态
"""
if game_version:
return self.installed_status.get(game_version, False)
return self.installed_status
def uninstall_patch(self, game_dir, game_version, silent=False):
"""卸载补丁
Args:
game_dir: 游戏目录路径
game_version: 游戏版本
silent: 是否静默模式(不显示弹窗)
Returns:
bool: 卸载成功返回True失败返回False
dict: 在silent=True时返回包含卸载结果信息的字典
"""
debug_mode = self._is_debug_mode()
if debug_mode:
self.logger.debug(f"DEBUG: 开始卸载 {game_version} 补丁,目录: {game_dir}")
self.logger.info(f"开始卸载 {game_version} 补丁,目录: {game_dir}")
if game_version not in self.game_info:
error_msg = f"无法识别游戏版本: {game_version}"
if debug_mode:
self.logger.debug(f"DEBUG: 卸载失败 - {error_msg}")
self.logger.error(f"卸载失败 - {error_msg}")
if not silent:
QMessageBox.critical(
None,
f"错误 - {self.app_name}",
f"\n{error_msg}\n",
QMessageBox.StandardButton.Ok,
)
return False if not silent else {"success": False, "message": error_msg, "files_removed": 0}
try:
files_removed = 0
# 获取可能的补丁文件路径
install_path_base = os.path.basename(self.game_info[game_version]["install_path"])
patch_file_path = os.path.join(game_dir, install_path_base)
if debug_mode:
self.logger.debug(f"DEBUG: 基础补丁文件路径: {patch_file_path}")
# 尝试查找补丁文件,支持不同大小写
patch_files_to_check = [
patch_file_path,
patch_file_path.lower(),
patch_file_path.upper(),
patch_file_path.replace("_", ""),
patch_file_path.replace("_", "-"),
]
if debug_mode:
self.logger.debug(f"DEBUG: 查找以下可能的补丁文件路径: {patch_files_to_check}")
# 查找并删除补丁文件,包括启用和禁用的
patch_file_found = False
for patch_path in patch_files_to_check:
# 检查常规补丁文件
if os.path.exists(patch_path):
patch_file_found = True
if debug_mode:
self.logger.debug(f"DEBUG: 找到补丁文件: {patch_path},准备删除")
self.logger.info(f"删除补丁文件: {patch_path}")
os.remove(patch_path)
files_removed += 1
if debug_mode:
self.logger.debug(f"DEBUG: 已删除补丁文件: {patch_path}")
# 检查被禁用的补丁文件(带.fain后缀
disabled_path = f"{patch_path}.fain"
if os.path.exists(disabled_path):
patch_file_found = True
if debug_mode:
self.logger.debug(f"DEBUG: 找到被禁用的补丁文件: {disabled_path},准备删除")
self.logger.info(f"删除被禁用的补丁文件: {disabled_path}")
os.remove(disabled_path)
files_removed += 1
if debug_mode:
self.logger.debug(f"DEBUG: 已删除被禁用的补丁文件: {disabled_path}")
if not patch_file_found:
if debug_mode:
self.logger.debug(f"DEBUG: 未找到补丁文件,检查了以下路径: {patch_files_to_check}")
self.logger.debug(f"DEBUG: 也检查了禁用的补丁文件(.fain后缀")
self.logger.warning(f"未找到 {game_version} 的补丁文件")
# 检查是否有额外的签名文件 (.sig)
if game_version == "NEKOPARA After":
if debug_mode:
self.logger.debug(f"DEBUG: {game_version} 需要检查额外的签名文件")
for patch_path in patch_files_to_check:
# 检查常规签名文件
sig_file_path = f"{patch_path}.sig"
if os.path.exists(sig_file_path):
if debug_mode:
self.logger.debug(f"DEBUG: 找到签名文件: {sig_file_path},准备删除")
self.logger.info(f"删除签名文件: {sig_file_path}")
os.remove(sig_file_path)
files_removed += 1
if debug_mode:
self.logger.debug(f"DEBUG: 已删除签名文件: {sig_file_path}")
# 检查被禁用补丁的签名文件
disabled_sig_path = f"{patch_path}.fain.sig"
if os.path.exists(disabled_sig_path):
if debug_mode:
self.logger.debug(f"DEBUG: 找到被禁用补丁的签名文件: {disabled_sig_path},准备删除")
self.logger.info(f"删除被禁用补丁的签名文件: {disabled_sig_path}")
os.remove(disabled_sig_path)
files_removed += 1
if debug_mode:
self.logger.debug(f"DEBUG: 已删除被禁用补丁的签名文件: {disabled_sig_path}")
# 删除patch文件夹
if debug_mode:
self.logger.debug(f"DEBUG: 检查并删除patch文件夹")
patch_folders_to_check = [
os.path.join(game_dir, "patch"),
os.path.join(game_dir, "Patch"),
os.path.join(game_dir, "PATCH"),
]
for patch_folder in patch_folders_to_check:
if os.path.exists(patch_folder):
if debug_mode:
self.logger.debug(f"DEBUG: 找到补丁文件夹: {patch_folder},准备删除")
self.logger.info(f"删除补丁文件夹: {patch_folder}")
import shutil
shutil.rmtree(patch_folder)
files_removed += 1
if debug_mode:
self.logger.debug(f"DEBUG: 已删除补丁文件夹: {patch_folder}")
# 删除game/patch文件夹
if debug_mode:
self.logger.debug(f"DEBUG: 检查并删除game/patch文件夹")
game_folders = ["game", "Game", "GAME"]
patch_folders = ["patch", "Patch", "PATCH"]
for game_folder in game_folders:
for patch_folder in patch_folders:
game_patch_folder = os.path.join(game_dir, game_folder, patch_folder)
if os.path.exists(game_patch_folder):
if debug_mode:
self.logger.debug(f"DEBUG: 找到game/patch文件夹: {game_patch_folder},准备删除")
self.logger.info(f"删除game/patch文件夹: {game_patch_folder}")
import shutil
shutil.rmtree(game_patch_folder)
files_removed += 1
if debug_mode:
self.logger.debug(f"DEBUG: 已删除game/patch文件夹: {game_patch_folder}")
# 删除配置文件
if debug_mode:
self.logger.debug(f"DEBUG: 检查并删除配置文件和脚本文件")
config_files = ["config.json", "Config.json", "CONFIG.JSON"]
script_files = ["scripts.json", "Scripts.json", "SCRIPTS.JSON"]
for game_folder in game_folders:
game_path = os.path.join(game_dir, game_folder)
if os.path.exists(game_path):
# 删除配置文件
for config_file in config_files:
config_path = os.path.join(game_path, config_file)
if os.path.exists(config_path):
if debug_mode:
self.logger.debug(f"DEBUG: 找到配置文件: {config_path},准备删除")
self.logger.info(f"删除配置文件: {config_path}")
os.remove(config_path)
files_removed += 1
if debug_mode:
self.logger.debug(f"DEBUG: 已删除配置文件: {config_path}")
# 删除脚本文件
for script_file in script_files:
script_path = os.path.join(game_path, script_file)
if os.path.exists(script_path):
if debug_mode:
self.logger.debug(f"DEBUG: 找到脚本文件: {script_path},准备删除")
self.logger.info(f"删除脚本文件: {script_path}")
os.remove(script_path)
files_removed += 1
if debug_mode:
self.logger.debug(f"DEBUG: 已删除脚本文件: {script_path}")
# 更新安装状态
self.installed_status[game_version] = False
if debug_mode:
self.logger.debug(f"DEBUG: 已更新 {game_version} 的安装状态为未安装")
# 在非静默模式且非批量卸载模式下显示卸载成功消息
if not silent and game_version != "all":
# 显示卸载成功消息
if files_removed > 0:
success_msg = f"\n{game_version} 补丁卸载成功!\n共删除 {files_removed} 个文件/文件夹。\n"
if debug_mode:
self.logger.debug(f"DEBUG: 显示卸载成功消息: {success_msg}")
QMessageBox.information(
None,
f"卸载完成 - {self.app_name}",
success_msg,
QMessageBox.StandardButton.Ok,
)
else:
warning_msg = f"\n未找到 {game_version} 的补丁文件,可能未安装补丁或已被移除。\n"
if debug_mode:
self.logger.debug(f"DEBUG: 显示警告消息: {warning_msg}")
QMessageBox.warning(
None,
f"警告 - {self.app_name}",
warning_msg,
QMessageBox.StandardButton.Ok,
)
# 卸载成功
if debug_mode:
self.logger.debug(f"DEBUG: {game_version} 卸载完成,共删除 {files_removed} 个文件/文件夹")
self.logger.info(f"{game_version} 卸载完成,共删除 {files_removed} 个文件/文件夹")
if silent:
return {"success": True, "message": f"{game_version} 补丁卸载成功", "files_removed": files_removed}
return True
except Exception as e:
error_message = f"卸载 {game_version} 补丁时出错:{str(e)}"
if debug_mode:
self.logger.debug(f"DEBUG: {error_message}")
import traceback
self.logger.debug(f"DEBUG: 错误详情:\n{traceback.format_exc()}")
self.logger.error(error_message)
# 在非静默模式且非批量卸载模式下显示卸载失败消息
if not silent and game_version != "all":
# 显示卸载失败消息
error_message = f"\n卸载 {game_version} 补丁时出错:\n\n{str(e)}\n"
if debug_mode:
self.logger.debug(f"DEBUG: 显示卸载失败消息")
QMessageBox.critical(
None,
f"卸载失败 - {self.app_name}",
error_message,
QMessageBox.StandardButton.Ok,
)
# 卸载失败
if silent:
return {"success": False, "message": f"卸载 {game_version} 补丁时出错: {str(e)}", "files_removed": 0}
return False
def batch_uninstall_patches(self, game_dirs):
"""批量卸载多个游戏的补丁
Args:
game_dirs: 游戏版本到游戏目录的映射字典
Returns:
tuple: (成功数量, 失败数量, 详细结果列表)
"""
success_count = 0
fail_count = 0
debug_mode = self._is_debug_mode()
results = []
if debug_mode:
self.logger.debug(f"DEBUG: 开始批量卸载补丁,游戏数量: {len(game_dirs)}")
self.logger.debug(f"DEBUG: 要卸载的游戏: {list(game_dirs.keys())}")
self.logger.info(f"开始批量卸载补丁,游戏数量: {len(game_dirs)}")
self.logger.info(f"要卸载的游戏: {list(game_dirs.keys())}")
for version, path in game_dirs.items():
if debug_mode:
self.logger.debug(f"DEBUG: 处理游戏 {version},路径: {path}")
self.logger.info(f"开始卸载 {version} 的补丁")
try:
# 在批量模式下使用静默卸载
if debug_mode:
self.logger.debug(f"DEBUG: 使用静默模式卸载 {version}")
result = self.uninstall_patch(path, version, silent=True)
if isinstance(result, dict): # 使用了静默模式
if result["success"]:
success_count += 1
if debug_mode:
self.logger.debug(f"DEBUG: {version} 卸载成功,删除了 {result['files_removed']} 个文件/文件夹")
self.logger.info(f"{version} 卸载成功,删除了 {result['files_removed']} 个文件/文件夹")
else:
fail_count += 1
if debug_mode:
self.logger.debug(f"DEBUG: {version} 卸载失败,原因: {result['message']}")
self.logger.warning(f"{version} 卸载失败,原因: {result['message']}")
results.append({
"version": version,
"success": result["success"],
"message": result["message"],
"files_removed": result["files_removed"]
})
else: # 兼容旧代码,不应该执行到这里
if result:
success_count += 1
if debug_mode:
self.logger.debug(f"DEBUG: {version} 卸载成功(旧格式)")
self.logger.info(f"{version} 卸载成功(旧格式)")
else:
fail_count += 1
if debug_mode:
self.logger.debug(f"DEBUG: {version} 卸载失败(旧格式)")
self.logger.warning(f"{version} 卸载失败(旧格式)")
results.append({
"version": version,
"success": result,
"message": f"{version} 卸载{'成功' if result else '失败'}",
"files_removed": 0
})
except Exception as e:
if debug_mode:
self.logger.debug(f"DEBUG: 卸载 {version} 时出错: {str(e)}")
import traceback
self.logger.debug(f"DEBUG: 错误详情:\n{traceback.format_exc()}")
self.logger.error(f"卸载 {version} 时出错: {str(e)}")
fail_count += 1
results.append({
"version": version,
"success": False,
"message": f"卸载出错: {str(e)}",
"files_removed": 0
})
if debug_mode:
self.logger.debug(f"DEBUG: 批量卸载完成,成功: {success_count},失败: {fail_count}")
self.logger.info(f"批量卸载完成,成功: {success_count},失败: {fail_count}")
return success_count, fail_count, results
def show_uninstall_result(self, success_count, fail_count, results=None):
"""显示批量卸载结果
Args:
success_count: 成功卸载的数量
fail_count: 卸载失败的数量
results: 详细结果列表,如果提供,会显示更详细的信息
"""
debug_mode = self._is_debug_mode()
if debug_mode:
self.logger.debug(f"DEBUG: 显示卸载结果,成功: {success_count},失败: {fail_count}")
result_text = f"\n批量卸载完成!\n成功: {success_count}\n失败: {fail_count}\n"
# 如果有详细结果,添加到消息中
if results:
success_list = [r["version"] for r in results if r["success"]]
fail_list = [r["version"] for r in results if not r["success"]]
if debug_mode:
self.logger.debug(f"DEBUG: 成功卸载的游戏: {success_list}")
self.logger.debug(f"DEBUG: 卸载失败的游戏: {fail_list}")
if success_list:
result_text += f"\n【成功卸载】:\n{chr(10).join(success_list)}\n"
if fail_list:
result_text += f"\n【卸载失败】:\n{chr(10).join(fail_list)}\n"
# 记录更详细的失败原因
if debug_mode:
for r in results:
if not r["success"]:
self.logger.debug(f"DEBUG: {r['version']} 卸载失败原因: {r['message']}")
if debug_mode:
self.logger.debug(f"DEBUG: 显示卸载结果对话框")
QMessageBox.information(
None,
f"批量卸载完成 - {self.app_name}",
result_text,
QMessageBox.StandardButton.Ok,
)
def check_patch_installed(self, game_dir, game_version):
"""检查游戏是否已安装补丁调用patch_detector
Args:
game_dir: 游戏目录路径
game_version: 游戏版本
Returns:
bool: 如果已安装补丁或有被禁用的补丁文件返回True否则返回False
"""
if self.patch_detector:
return self.patch_detector.check_patch_installed(game_dir, game_version)
# 如果patch_detector未设置使用原始逻辑应该不会执行到这里
debug_mode = self._is_debug_mode()
if game_version not in self.game_info:
return False
# 获取可能的补丁文件路径
install_path_base = os.path.basename(self.game_info[game_version]["install_path"])
patch_file_path = os.path.join(game_dir, install_path_base)
# 尝试查找补丁文件,支持不同大小写
patch_files_to_check = [
patch_file_path,
patch_file_path.lower(),
patch_file_path.upper(),
patch_file_path.replace("_", ""),
patch_file_path.replace("_", "-"),
]
# 查找补丁文件
for patch_path in patch_files_to_check:
if os.path.exists(patch_path):
if debug_mode:
self.logger.debug(f"找到补丁文件: {patch_path}")
return True
# 检查是否存在被禁用的补丁文件(带.fain后缀
disabled_path = f"{patch_path}.fain"
if os.path.exists(disabled_path):
if debug_mode:
self.logger.debug(f"找到被禁用的补丁文件: {disabled_path}")
return True
# 检查是否有补丁文件夹
patch_folders_to_check = [
os.path.join(game_dir, "patch"),
os.path.join(game_dir, "Patch"),
os.path.join(game_dir, "PATCH"),
]
for patch_folder in patch_folders_to_check:
if os.path.exists(patch_folder):
if debug_mode:
self.logger.debug(f"找到补丁文件夹: {patch_folder}")
return True
# 检查game/patch文件夹
game_folders = ["game", "Game", "GAME"]
patch_folders = ["patch", "Patch", "PATCH"]
for game_folder in game_folders:
for patch_folder in patch_folders:
game_patch_folder = os.path.join(game_dir, game_folder, patch_folder)
if os.path.exists(game_patch_folder):
if debug_mode:
self.logger.debug(f"找到game/patch文件夹: {game_patch_folder}")
return True
# 检查配置文件
config_files = ["config.json", "Config.json", "CONFIG.JSON"]
script_files = ["scripts.json", "Scripts.json", "SCRIPTS.JSON"]
for game_folder in game_folders:
game_path = os.path.join(game_dir, game_folder)
if os.path.exists(game_path):
# 检查配置文件
for config_file in config_files:
config_path = os.path.join(game_path, config_file)
if os.path.exists(config_path):
if debug_mode:
self.logger.debug(f"找到配置文件: {config_path}")
return True
# 检查脚本文件
for script_file in script_files:
script_path = os.path.join(game_path, script_file)
if os.path.exists(script_path):
if debug_mode:
self.logger.debug(f"找到脚本文件: {script_path}")
return True
# 没有找到补丁文件或文件夹
if debug_mode:
self.logger.debug(f"{game_version}{game_dir} 中没有安装补丁")
return False
def check_patch_disabled(self, game_dir, game_version):
"""检查游戏的补丁是否已被禁用调用patch_detector
Args:
game_dir: 游戏目录路径
game_version: 游戏版本
Returns:
bool: 如果补丁被禁用返回True否则返回False
str: 禁用的补丁文件路径如果没有禁用返回None
"""
if self.patch_detector:
return self.patch_detector.check_patch_disabled(game_dir, game_version)
# 如果patch_detector未设置使用原始逻辑应该不会执行到这里
debug_mode = self._is_debug_mode()
if game_version not in self.game_info:
return False, None
# 获取可能的补丁文件路径
install_path_base = os.path.basename(self.game_info[game_version]["install_path"])
patch_file_path = os.path.join(game_dir, install_path_base)
# 检查是否存在禁用的补丁文件(.fain后缀
disabled_patch_files = [
f"{patch_file_path}.fain",
f"{patch_file_path.lower()}.fain",
f"{patch_file_path.upper()}.fain",
f"{patch_file_path.replace('_', '')}.fain",
f"{patch_file_path.replace('_', '-')}.fain",
]
# 检查是否有禁用的补丁文件
for disabled_path in disabled_patch_files:
if os.path.exists(disabled_path):
if debug_mode:
self.logger.debug(f"找到禁用的补丁文件: {disabled_path}")
return True, disabled_path
if debug_mode:
self.logger.debug(f"{game_version}{game_dir} 的补丁未被禁用")
return False, None
def toggle_patch(self, game_dir, game_version, operation=None, silent=False):
"""切换补丁的禁用/启用状态
Args:
game_dir: 游戏目录路径
game_version: 游戏版本
operation: 指定操作,可以是"enable""disable"或NoneNone则自动切换当前状态
silent: 是否静默模式(不显示弹窗)
Returns:
dict: 包含操作结果信息的字典
"""
debug_mode = self._is_debug_mode()
if debug_mode:
self.logger.debug(f"开始切换补丁状态 - 游戏版本: {game_version}, 游戏目录: {game_dir}, 操作: {operation}")
if game_version not in self.game_info:
if debug_mode:
self.logger.debug(f"无法识别游戏版本: {game_version}")
if not silent:
QMessageBox.critical(
None,
f"错误 - {self.app_name}",
f"\n无法识别游戏版本: {game_version}\n",
QMessageBox.StandardButton.Ok,
)
return {"success": False, "message": f"无法识别游戏版本: {game_version}", "action": "none"}
# 检查补丁是否已安装
is_patch_installed = self.check_patch_installed(game_dir, game_version)
if debug_mode:
self.logger.debug(f"补丁安装状态检查结果: {is_patch_installed}")
if not is_patch_installed:
if debug_mode:
self.logger.debug(f"{game_version} 未安装补丁,无法进行禁用/启用操作")
if not silent:
QMessageBox.warning(
None,
f"提示 - {self.app_name}",
f"\n{game_version} 未安装补丁,无法进行禁用/启用操作。\n",
QMessageBox.StandardButton.Ok,
)
return {"success": False, "message": f"{game_version} 未安装补丁", "action": "none"}
try:
# 检查当前状态
is_disabled, disabled_path = self.check_patch_disabled(game_dir, game_version)
if debug_mode:
self.logger.debug(f"补丁禁用状态检查结果 - 是否禁用: {is_disabled}, 禁用路径: {disabled_path}")
# 获取可能的补丁文件路径
install_path_base = os.path.basename(self.game_info[game_version]["install_path"])
patch_file_path = os.path.join(game_dir, install_path_base)
# 尝试查找原始补丁文件,支持不同大小写
patch_files_to_check = [
patch_file_path,
patch_file_path.lower(),
patch_file_path.upper(),
patch_file_path.replace("_", ""),
patch_file_path.replace("_", "-"),
]
if debug_mode:
self.logger.debug(f"将检查以下可能的补丁文件: {patch_files_to_check}")
# 确定操作类型
if operation:
if operation == "enable":
action_needed = is_disabled # 只有当前是禁用状态时才需要启用
elif operation == "disable":
action_needed = not is_disabled # 只有当前是启用状态时才需要禁用
else:
action_needed = True # 无效操作类型,强制进行操作
else:
action_needed = True # 未指定操作类型,始终执行切换
if debug_mode:
self.logger.debug(f"操作决策 - 操作类型: {operation}, 是否需要执行操作: {action_needed}")
if not action_needed:
# 补丁已经是目标状态,无需操作
if operation == "enable":
message = f"{game_version} 补丁已经是启用状态"
else:
message = f"{game_version} 补丁已经是禁用状态"
if debug_mode:
self.logger.debug(f"{message}, 无需操作")
if not silent:
QMessageBox.information(
None,
f"提示 - {self.app_name}",
f"\n{message}\n",
QMessageBox.StandardButton.Ok,
)
return {"success": True, "message": message, "action": "none"}
if is_disabled:
# 当前是禁用状态,需要启用
if disabled_path and os.path.exists(disabled_path):
# 从禁用文件名去掉.fain后缀
enabled_path = disabled_path[:-5] # 去掉.fain
if debug_mode:
self.logger.debug(f"正在启用补丁 - 从 {disabled_path} 重命名为 {enabled_path}")
os.rename(disabled_path, enabled_path)
if debug_mode:
self.logger.debug(f"已启用 {game_version} 的补丁,重命名文件成功")
action = "enable"
message = f"{game_version} 补丁已启用"
else:
# 未找到禁用的补丁文件,但状态是禁用
message = f"未找到禁用的补丁文件: {disabled_path}"
if debug_mode:
self.logger.debug(f"{message}")
return {"success": False, "message": message, "action": "none"}
else:
# 当前是启用状态,需要禁用
# 查找正在使用的补丁文件
active_patch_file = None
for patch_path in patch_files_to_check:
if os.path.exists(patch_path):
active_patch_file = patch_path
if debug_mode:
self.logger.debug(f"找到活跃的补丁文件: {active_patch_file}")
break
if active_patch_file:
# 给补丁文件添加.fain后缀禁用它
disabled_path = f"{active_patch_file}.fain"
if debug_mode:
self.logger.debug(f"正在禁用补丁 - 从 {active_patch_file} 重命名为 {disabled_path}")
os.rename(active_patch_file, disabled_path)
if debug_mode:
self.logger.debug(f"已禁用 {game_version} 的补丁,重命名文件成功")
action = "disable"
message = f"{game_version} 补丁已禁用"
else:
# 未找到活跃的补丁文件,但状态是启用
message = f"未找到启用的补丁文件,请检查游戏目录: {game_dir}"
if debug_mode:
self.logger.debug(f"{message}")
return {"success": False, "message": message, "action": "none"}
# 非静默模式下显示操作结果
if not silent:
QMessageBox.information(
None,
f"操作成功 - {self.app_name}",
f"\n{message}\n",
QMessageBox.StandardButton.Ok,
)
if debug_mode:
self.logger.debug(f"切换补丁状态操作完成 - 结果: 成功, 操作: {action}, 消息: {message}")
return {"success": True, "message": message, "action": action}
except Exception as e:
error_message = f"切换 {game_version} 补丁状态时出错: {str(e)}"
if debug_mode:
self.logger.debug(f"{error_message}")
import traceback
self.logger.debug(f"错误详情:\n{traceback.format_exc()}")
if not silent:
QMessageBox.critical(
None,
f"操作失败 - {self.app_name}",
f"\n{error_message}\n",
QMessageBox.StandardButton.Ok,
)
return {"success": False, "message": error_message, "action": "none"}
def batch_toggle_patches(self, game_dirs, operation=None):
"""批量切换多个游戏补丁的禁用/启用状态
Args:
game_dirs: 游戏版本到游戏目录的映射字典
operation: 指定操作,可以是"enable""disable"或NoneNone则自动切换当前状态
Returns:
tuple: (成功数量, 失败数量, 详细结果列表)
"""
success_count = 0
fail_count = 0
debug_mode = self._is_debug_mode()
results = []
if debug_mode:
self.logger.debug(f"开始批量切换补丁状态 - 操作: {operation}, 游戏数量: {len(game_dirs)}")
self.logger.debug(f"游戏列表: {list(game_dirs.keys())}")
for version, path in game_dirs.items():
try:
if debug_mode:
self.logger.debug(f"处理游戏 {version}, 目录: {path}")
# 在批量模式下使用静默操作
result = self.toggle_patch(path, version, operation=operation, silent=True)
if debug_mode:
self.logger.debug(f"游戏 {version} 操作结果: {result}")
if result["success"]:
success_count += 1
if debug_mode:
self.logger.debug(f"游戏 {version} 操作成功,操作类型: {result['action']}")
else:
fail_count += 1
if debug_mode:
self.logger.debug(f"游戏 {version} 操作失败,原因: {result['message']}")
results.append({
"version": version,
"success": result["success"],
"message": result["message"],
"action": result["action"]
})
except Exception as e:
if debug_mode:
self.logger.debug(f"切换 {version} 补丁状态时出错: {str(e)}")
import traceback
self.logger.debug(f"错误详情:\n{traceback.format_exc()}")
fail_count += 1
results.append({
"version": version,
"success": False,
"message": f"操作出错: {str(e)}",
"action": "none"
})
if debug_mode:
self.logger.debug(f"批量切换补丁状态完成 - 成功: {success_count}, 失败: {fail_count}")
return success_count, fail_count, results
def show_toggle_result(self, success_count, fail_count, results=None):
"""显示批量切换补丁状态的结果
Args:
success_count: 成功操作的数量
fail_count: 操作失败的数量
results: 详细结果列表,如果提供,会显示更详细的信息
"""
result_text = f"\n批量操作完成!\n成功: {success_count}\n失败: {fail_count}\n"
# 如果有详细结果,添加到消息中
if results:
enabled_list = [r["version"] for r in results if r["success"] and r["action"] == "enable"]
disabled_list = [r["version"] for r in results if r["success"] and r["action"] == "disable"]
skipped_list = [r["version"] for r in results if r["success"] and r["action"] == "none"]
fail_list = [r["version"] for r in results if not r["success"]]
if enabled_list:
result_text += f"\n【已启用补丁】:\n{chr(10).join(enabled_list)}\n"
if disabled_list:
result_text += f"\n【已禁用补丁】:\n{chr(10).join(disabled_list)}\n"
if skipped_list:
result_text += f"\n【无需操作】:\n{chr(10).join(skipped_list)}\n"
if fail_list:
result_text += f"\n【操作失败】:\n{chr(10).join(fail_list)}\n"
QMessageBox.information(
None,
f"批量操作完成 - {self.app_name}",
result_text,
QMessageBox.StandardButton.Ok,
)
def show_result(self):
"""显示安装结果,区分不同情况"""
# 获取当前安装状态
installed_versions = [] # 成功安装的版本
skipped_versions = [] # 已有补丁跳过的版本
failed_versions = [] # 安装失败的版本
not_found_versions = [] # 未找到的版本
# 获取所有游戏版本路径
install_paths = self.main_window.download_manager.get_install_paths() if hasattr(self.main_window.download_manager, "get_install_paths") else {}
# 检查是否处于离线模式
is_offline_mode = False
if hasattr(self.main_window, 'offline_mode_manager'):
is_offline_mode = self.main_window.offline_mode_manager.is_in_offline_mode()
# 获取本次实际安装的游戏列表
installed_games = []
# 在线模式下使用download_queue_history
if hasattr(self.main_window, 'download_queue_history') and self.main_window.download_queue_history:
installed_games = self.main_window.download_queue_history
# 离线模式下使用offline_mode_manager.installed_games
if is_offline_mode and hasattr(self.main_window.offline_mode_manager, 'installed_games'):
installed_games = self.main_window.offline_mode_manager.installed_games
debug_mode = self._is_debug_mode()
if debug_mode:
self.logger.debug(f"DEBUG: 显示安装结果,离线模式: {is_offline_mode}")
self.logger.debug(f"DEBUG: 本次安装的游戏: {installed_games}")
for game_version, is_installed in self.main_window.installed_status.items():
# 只处理install_paths中存在的游戏版本
if game_version in install_paths:
path = install_paths[game_version]
# 检查游戏是否存在但未通过本次安装补丁
if is_installed:
# 游戏已安装补丁
if game_version in installed_games:
# 本次成功安装
installed_versions.append(game_version)
else:
# 已有补丁,被跳过下载
skipped_versions.append(game_version)
else:
# 游戏未安装补丁
if os.path.exists(path):
# 游戏文件夹存在,但安装失败
failed_versions.append(game_version)
else:
# 游戏文件夹不存在
not_found_versions.append(game_version)
# 构建结果信息
result_text = f"\n安装结果:\n"
# 总数统计 - 只显示本次实际安装的数量
total_installed = len(installed_versions)
total_failed = len(failed_versions)
result_text += f"安装成功:{total_installed} 个 安装失败:{total_failed}\n\n"
# 详细列表
if installed_versions:
result_text += f"【成功安装】:\n{chr(10).join(installed_versions)}\n\n"
if failed_versions:
result_text += f"【安装失败】:\n{chr(10).join(failed_versions)}\n\n"
if not_found_versions:
# 只有在真正检测到了游戏但未安装补丁时才显示
result_text += f"【尚未安装补丁的游戏】:\n{chr(10).join(not_found_versions)}\n"
QMessageBox.information(
self.main_window,
f"安装完成 - {APP_NAME}",
result_text
)

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import json
from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QTextBrowser, QPushButton, QCheckBox, QLabel, QMessageBox
from PySide6.QtCore import Qt
from data.privacy_policy import PRIVACY_POLICY_BRIEF, get_local_privacy_policy, PRIVACY_POLICY_VERSION
from data.config import CACHE, APP_NAME, APP_VERSION
from utils import msgbox_frame
from utils.logger import setup_logger
class PrivacyManager:
"""隐私协议管理器,负责显示隐私协议对话框并处理用户选择"""
def __init__(self):
"""初始化隐私协议管理器"""
# 初始化日志
self.logger = setup_logger("privacy_manager")
self.logger.info("正在初始化隐私协议管理器")
# 确保缓存目录存在
os.makedirs(CACHE, exist_ok=True)
self.config_file = os.path.join(CACHE, "privacy_config.json")
self.privacy_config = self._load_privacy_config()
# 获取隐私协议内容和版本
self.logger.info("读取本地隐私协议文件")
self.privacy_content, self.current_privacy_version, error = get_local_privacy_policy()
if error:
self.logger.warning(f"读取本地隐私协议文件警告: {error}")
# 使用默认版本作为备用
self.current_privacy_version = PRIVACY_POLICY_VERSION
self.logger.info(f"隐私协议版本: {self.current_privacy_version}")
# 检查隐私协议版本和用户同意状态
self.privacy_accepted = self._check_privacy_acceptance()
def _load_privacy_config(self):
"""加载隐私协议配置
Returns:
dict: 隐私协议配置信息
"""
if os.path.exists(self.config_file):
try:
with open(self.config_file, "r", encoding="utf-8") as f:
config = json.load(f)
return config
except (json.JSONDecodeError, IOError) as e:
self.logger.error(f"读取隐私配置失败: {e}")
# 如果读取失败,返回空配置,强制显示隐私协议
return {"privacy_accepted": False}
return {"privacy_accepted": False}
def _check_privacy_acceptance(self):
"""检查隐私协议是否需要重新同意
如果隐私协议版本变更,则需要重新同意
Returns:
bool: 是否已有有效的隐私协议同意
"""
# 获取存储的版本信息
stored_privacy_version = self.privacy_config.get("privacy_version", "0.0.0")
stored_app_version = self.privacy_config.get("app_version", "0.0.0")
privacy_accepted = self.privacy_config.get("privacy_accepted", False)
self.logger.info(f"存储的隐私协议版本: {stored_privacy_version}, 当前版本: {self.current_privacy_version}")
self.logger.info(f"存储的应用版本: {stored_app_version}, 当前版本: {APP_VERSION}")
self.logger.info(f"隐私协议接受状态: {privacy_accepted}")
# 如果隐私协议版本变更,需要重新同意
if stored_privacy_version != self.current_privacy_version:
self.logger.info("隐私协议版本已变更,需要重新同意")
return False
# 返回当前的同意状态
return privacy_accepted
def _save_privacy_config(self, accepted):
"""保存隐私协议配置
Args:
accepted: 用户是否同意隐私协议
Returns:
bool: 配置是否保存成功
"""
try:
# 确保目录存在
os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
# 写入配置文件,包含应用版本和隐私协议版本
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump({
"privacy_accepted": accepted,
"privacy_version": self.current_privacy_version, # 保存当前隐私协议版本
"app_version": APP_VERSION # 保存当前应用版本
}, f, indent=2)
# 更新实例变量
self.privacy_accepted = accepted
self.privacy_config = {
"privacy_accepted": accepted,
"privacy_version": self.current_privacy_version,
"app_version": APP_VERSION
}
return True
except IOError as e:
self.logger.error(f"保存隐私协议配置失败: {e}")
# 显示保存失败的提示
QMessageBox.warning(
None,
f"配置保存警告 - {APP_NAME}",
f"隐私设置无法保存到配置文件,下次启动时可能需要重新确认。\n\n错误信息:{e}"
)
return False
def show_privacy_dialog(self):
"""显示隐私协议对话框
Returns:
bool: 用户是否同意隐私协议
"""
# 如果用户已经同意了隐私协议直接返回True不显示对话框
if self.privacy_accepted:
self.logger.info("用户已同意当前版本的隐私协议,无需再次显示")
return True
self.logger.info("首次运行或隐私协议版本变更,显示隐私对话框")
# 创建隐私协议对话框
dialog = QDialog()
dialog.setWindowTitle(f"隐私政策 - {APP_NAME}")
dialog.setMinimumSize(600, 400)
dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowContextHelpButtonHint)
# 创建布局
layout = QVBoxLayout(dialog)
# 添加标题和版本信息
title_label = QLabel(f"请阅读并同意以下隐私政策 (更新日期: {self.current_privacy_version})")
title_label.setStyleSheet("font-size: 14px; font-weight: bold;")
layout.addWidget(title_label)
# 添加隐私协议文本框
text_browser = QTextBrowser()
# 这里使用PRIVACY_POLICY_BRIEF而不是self.privacy_content保持UI简洁
text_browser.setMarkdown(PRIVACY_POLICY_BRIEF)
text_browser.setOpenExternalLinks(True)
layout.addWidget(text_browser)
# 添加同意选择框
checkbox = QCheckBox("我已阅读并同意上述隐私政策")
layout.addWidget(checkbox)
# 添加按钮
buttons_layout = QHBoxLayout()
agree_button = QPushButton("同意并继续")
agree_button.setEnabled(False) # 初始状态为禁用
decline_button = QPushButton("不同意并退出")
buttons_layout.addWidget(agree_button)
buttons_layout.addWidget(decline_button)
layout.addLayout(buttons_layout)
# 连接选择框状态变化 - 修复勾选后按钮不亮起的问题
def on_checkbox_state_changed(state):
self.logger.debug(f"复选框状态变更为: {state}")
agree_button.setEnabled(state == 2) # Qt.Checked 在 PySide6 中值为 2
checkbox.stateChanged.connect(on_checkbox_state_changed)
# 连接按钮点击事件
agree_button.clicked.connect(lambda: self._on_agree(dialog))
decline_button.clicked.connect(lambda: self._on_decline(dialog))
# 显示对话框
result = dialog.exec()
# 返回用户选择结果
return self.privacy_accepted
def _on_agree(self, dialog):
"""处理用户同意隐私协议
Args:
dialog: 对话框实例
"""
# 保存配置并更新状态
self._save_privacy_config(True)
dialog.accept()
def _on_decline(self, dialog):
"""处理用户拒绝隐私协议
Args:
dialog: 对话框实例
"""
# 显示拒绝信息
msg_box = msgbox_frame(
f"退出 - {APP_NAME}",
"\n您需要同意隐私政策才能使用本软件。\n软件将立即退出。\n",
QMessageBox.Ok,
)
msg_box.exec()
# 保存拒绝状态
self._save_privacy_config(False)
dialog.reject()
def is_privacy_accepted(self):
"""检查用户是否已同意隐私协议
Returns:
bool: 用户是否已同意隐私协议
"""
return self.privacy_accepted
def reset_privacy_agreement(self):
"""重置隐私协议同意状态,用于测试或重新显示隐私协议
Returns:
bool: 重置是否成功
"""
return self._save_privacy_config(False)

View File

@@ -0,0 +1,971 @@
from PySide6.QtGui import QIcon, QAction, QFont, QCursor, QActionGroup
from PySide6.QtWidgets import QMessageBox, QMainWindow, QMenu, QPushButton
from PySide6.QtCore import Qt, QRect
import webbrowser
import os
from utils import load_base64_image, msgbox_frame, resource_path
from data.config import APP_NAME, APP_VERSION, LOG_FILE
from core.ipv6_manager import IPv6Manager # 导入新的IPv6Manager类
class UIManager:
def __init__(self, main_window):
"""初始化UI管理器
Args:
main_window: 主窗口实例用于设置UI元素
"""
self.main_window = main_window
# 使用getattr获取ui属性如果不存在则为None
self.ui = getattr(main_window, 'ui', None)
self.debug_action = None
self.turbo_download_action = None
self.dev_menu = None
self.privacy_menu = None # 隐私协议菜单
self.about_menu = None # 关于菜单
self.about_btn = None # 关于按钮
# 获取主窗口的IPv6Manager实例
self.ipv6_manager = getattr(main_window, 'ipv6_manager', None)
def setup_ui(self):
"""设置UI元素包括窗口图标、标题和菜单"""
# 设置窗口图标
import os
from utils import resource_path
icon_path = resource_path(os.path.join("IMG", "ICO", "icon.png"))
if os.path.exists(icon_path):
self.main_window.setWindowIcon(QIcon(icon_path))
# 获取当前离线模式状态
is_offline_mode = False
if hasattr(self.main_window, 'offline_mode_manager'):
is_offline_mode = self.main_window.offline_mode_manager.is_in_offline_mode()
# 设置窗口标题和UI标题标签
mode_indicator = "[离线模式]" if is_offline_mode else "[在线模式]"
self.main_window.setWindowTitle(f"{APP_NAME} v{APP_VERSION} {mode_indicator}")
# 更新UI中的标题标签
if hasattr(self.main_window, 'ui') and hasattr(self.main_window.ui, 'title_label'):
self.main_window.ui.title_label.setText(f"{APP_NAME} v{APP_VERSION} {mode_indicator}")
# 创建关于按钮
self._create_about_button()
# 设置菜单
self._setup_help_menu()
self._setup_about_menu() # 新增关于菜单
self._setup_settings_menu()
def _create_about_button(self):
"""创建"关于"按钮"""
if not self.ui or not hasattr(self.ui, 'menu_area'):
return
# 获取菜单字体和样式
menu_font = self._get_menu_font()
# 创建关于按钮
self.about_btn = QPushButton("关于", self.ui.menu_area)
self.about_btn.setObjectName(u"about_btn")
# 获取帮助按钮的位置和样式
help_btn_x = 0
help_btn_width = 0
if hasattr(self.ui, 'help_btn'):
help_btn_x = self.ui.help_btn.x()
help_btn_width = self.ui.help_btn.width()
# 设置位置在"帮助"按钮右侧
self.about_btn.setGeometry(QRect(help_btn_x + help_btn_width + 20, 1, 80, 28))
self.about_btn.setFont(menu_font)
self.about_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
# 复制帮助按钮的样式
if hasattr(self.ui, 'help_btn'):
self.about_btn.setStyleSheet(self.ui.help_btn.styleSheet())
else:
# 默认样式
self.about_btn.setStyleSheet("""
QPushButton {
background-color: transparent;
color: white;
border: none;
text-align: left;
padding-left: 10px;
}
QPushButton:hover {
background-color: #F47A5B;
border-radius: 4px;
}
QPushButton:pressed {
background-color: #D25A3C;
border-radius: 4px;
}
""")
def _setup_help_menu(self):
"""设置"帮助"菜单"""
if not self.ui or not hasattr(self.ui, 'menu_2'):
return
# 获取菜单字体
menu_font = self._get_menu_font()
# 创建菜单项 - 移除"项目主页",添加"常见问题"和"提交错误"
faq_action = QAction("常见问题", self.main_window)
faq_action.triggered.connect(self.open_faq_page)
faq_action.setFont(menu_font)
report_issue_action = QAction("提交错误", self.main_window)
report_issue_action.triggered.connect(self.open_issues_page)
report_issue_action.setFont(menu_font)
# 清除现有菜单项并添加新的菜单项
self.ui.menu_2.clear()
self.ui.menu_2.addAction(faq_action)
self.ui.menu_2.addAction(report_issue_action)
# 连接按钮点击事件,如果使用按钮式菜单
if hasattr(self.ui, 'help_btn'):
# 按钮已经连接到显示菜单,不需要额外处理
pass
def _setup_about_menu(self):
"""设置"关于"菜单"""
# 获取菜单字体
menu_font = self._get_menu_font()
# 创建关于菜单
self.about_menu = QMenu("关于", self.main_window)
self.about_menu.setFont(menu_font)
# 设置菜单样式
font_family = menu_font.family()
menu_style = self._get_menu_style(font_family)
self.about_menu.setStyleSheet(menu_style)
# 创建菜单项
about_project_action = QAction("关于本项目", self.main_window)
about_project_action.setFont(menu_font)
about_project_action.triggered.connect(self.show_about_dialog)
# 添加项目主页选项(从帮助菜单移动过来)
project_home_action = QAction("Github项目主页", self.main_window)
project_home_action.setFont(menu_font)
project_home_action.triggered.connect(self.open_project_home_page)
# 添加加入QQ群选项
qq_group_action = QAction("加入QQ群", self.main_window)
qq_group_action.setFont(menu_font)
qq_group_action.triggered.connect(self.open_qq_group)
# 创建隐私协议菜单
self._setup_privacy_menu()
# 添加到关于菜单
self.about_menu.addAction(about_project_action)
self.about_menu.addAction(project_home_action)
self.about_menu.addAction(qq_group_action)
self.about_menu.addSeparator()
self.about_menu.addMenu(self.privacy_menu)
# 连接按钮点击事件
if self.about_btn:
self.about_btn.clicked.connect(lambda: self.show_menu(self.about_menu, self.about_btn))
def _setup_privacy_menu(self):
"""设置"隐私协议"菜单"""
# 获取菜单字体
menu_font = self._get_menu_font()
# 创建隐私协议子菜单
self.privacy_menu = QMenu("隐私协议", self.main_window)
self.privacy_menu.setFont(menu_font)
# 设置与其他菜单一致的样式
font_family = menu_font.family()
menu_style = self._get_menu_style(font_family)
self.privacy_menu.setStyleSheet(menu_style)
# 添加子选项
view_privacy_action = QAction("查看完整隐私协议", self.main_window)
view_privacy_action.setFont(menu_font)
view_privacy_action.triggered.connect(self.open_privacy_policy)
revoke_privacy_action = QAction("撤回隐私协议", self.main_window)
revoke_privacy_action.setFont(menu_font)
revoke_privacy_action.triggered.connect(self.revoke_privacy_agreement)
# 添加到子菜单
self.privacy_menu.addAction(view_privacy_action)
self.privacy_menu.addAction(revoke_privacy_action)
def _get_menu_style(self, font_family):
"""获取统一的菜单样式"""
return f"""
QMenu {{
background-color: #E96948;
color: white;
font-family: "{font_family}";
font-size: 14px;
font-weight: bold;
border: 1px solid #F47A5B;
padding: 8px;
border-radius: 6px;
margin-top: 2px;
}}
QMenu::item {{
padding: 6px 20px 6px 15px;
background-color: transparent;
min-width: 120px;
color: white;
font-family: "{font_family}";
font-size: 14px;
font-weight: bold;
}}
QMenu::item:selected {{
background-color: #F47A5B;
border-radius: 4px;
}}
QMenu::separator {{
height: 1px;
background-color: #F47A5B;
margin: 5px 15px;
}}
QMenu::item:checked {{
background-color: #D25A3C;
border-radius: 4px;
}}
"""
def _get_menu_font(self):
"""获取菜单字体"""
font_family = "Arial" # 默认字体族
try:
from PySide6.QtGui import QFontDatabase
# 尝试加载字体
font_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fonts", "SmileySans-Oblique.ttf")
if os.path.exists(font_path):
font_id = QFontDatabase.addApplicationFont(font_path)
if font_id != -1:
font_family = QFontDatabase.applicationFontFamilies(font_id)[0]
# 创建菜单字体
menu_font = QFont(font_family, 14)
menu_font.setBold(True)
return menu_font
except Exception as e:
print(f"加载字体失败: {e}")
# 返回默认字体
menu_font = QFont(font_family, 14)
menu_font.setBold(True)
return menu_font
def _setup_settings_menu(self):
"""设置"设置"菜单"""
if not self.ui or not hasattr(self.ui, 'menu'):
return
# 获取菜单字体
menu_font = self._get_menu_font()
font_family = menu_font.family()
# 创建工作模式子菜单
self.work_mode_menu = QMenu("工作模式", self.main_window)
self.work_mode_menu.setFont(menu_font)
self.work_mode_menu.setStyleSheet(self._get_menu_style(font_family))
# 获取当前离线模式状态
is_offline_mode = False
if hasattr(self.main_window, 'offline_mode_manager'):
is_offline_mode = self.main_window.offline_mode_manager.is_in_offline_mode()
# 创建在线模式和离线模式选项
self.online_mode_action = QAction("在线模式", self.main_window, checkable=True)
self.online_mode_action.setFont(menu_font)
self.online_mode_action.setChecked(not is_offline_mode) # 根据当前状态设置
self.offline_mode_action = QAction("离线模式", self.main_window, checkable=True)
self.offline_mode_action.setFont(menu_font)
self.offline_mode_action.setChecked(is_offline_mode) # 根据当前状态设置
# 将两个模式选项添加到同一个互斥组
mode_group = QActionGroup(self.main_window)
mode_group.addAction(self.online_mode_action)
mode_group.addAction(self.offline_mode_action)
mode_group.setExclusive(True) # 确保只能选择一个模式
# 连接切换事件
self.online_mode_action.triggered.connect(lambda: self.switch_work_mode("online"))
self.offline_mode_action.triggered.connect(lambda: self.switch_work_mode("offline"))
# 添加到工作模式子菜单
self.work_mode_menu.addAction(self.online_mode_action)
self.work_mode_menu.addAction(self.offline_mode_action)
# 创建开发者选项子菜单
self.dev_menu = QMenu("开发者选项", self.main_window)
self.dev_menu.setFont(menu_font) # 设置与UI_install.py中相同的字体
# 使用和主菜单相同的样式
menu_style = self._get_menu_style(font_family)
self.dev_menu.setStyleSheet(menu_style)
# 创建Debug子菜单
self.debug_submenu = QMenu("Debug模式", self.main_window)
self.debug_submenu.setFont(menu_font)
self.debug_submenu.setStyleSheet(menu_style)
# 创建hosts文件选项子菜单
self.hosts_submenu = QMenu("hosts文件选项", self.main_window)
self.hosts_submenu.setFont(menu_font)
self.hosts_submenu.setStyleSheet(menu_style)
# 添加IPv6支持选项
self.ipv6_action = QAction("启用IPv6支持", self.main_window, checkable=True)
self.ipv6_action.setFont(menu_font)
# 添加IPv6检测按钮用于显示详细信息
self.ipv6_test_action = QAction("测试IPv6连接", self.main_window)
self.ipv6_test_action.setFont(menu_font)
if self.ipv6_manager:
self.ipv6_test_action.triggered.connect(self.ipv6_manager.show_ipv6_details)
else:
self.ipv6_test_action.triggered.connect(self.show_ipv6_manager_not_ready)
# 创建IPv6支持子菜单
self.ipv6_submenu = QMenu("IPv6支持", self.main_window)
self.ipv6_submenu.setFont(menu_font)
self.ipv6_submenu.setStyleSheet(menu_style)
# 检查配置中是否已启用IPv6
config = getattr(self.main_window, 'config', {})
ipv6_enabled = False
if isinstance(config, dict):
ipv6_enabled = config.get("ipv6_enabled", False)
self.ipv6_action.setChecked(ipv6_enabled)
# 连接IPv6支持切换事件
self.ipv6_action.triggered.connect(self._handle_ipv6_toggle)
# 将选项添加到IPv6子菜单
self.ipv6_submenu.addAction(self.ipv6_action)
self.ipv6_submenu.addAction(self.ipv6_test_action)
# 添加hosts子选项
self.restore_hosts_action = QAction("还原软件备份的hosts文件", self.main_window)
self.restore_hosts_action.setFont(menu_font)
self.restore_hosts_action.triggered.connect(self.restore_hosts_backup)
self.clean_hosts_action = QAction("手动删除软件添加的hosts条目", self.main_window)
self.clean_hosts_action.setFont(menu_font)
self.clean_hosts_action.triggered.connect(self.clean_hosts_entries)
# 添加禁用自动还原hosts的选项
self.disable_auto_restore_action = QAction("禁用关闭/重启自动还原hosts", self.main_window, checkable=True)
self.disable_auto_restore_action.setFont(menu_font)
# 从配置中读取当前状态
config = getattr(self.main_window, 'config', {})
disable_auto_restore = False
if isinstance(config, dict):
disable_auto_restore = config.get("disable_auto_restore_hosts", False)
self.disable_auto_restore_action.setChecked(disable_auto_restore)
self.disable_auto_restore_action.triggered.connect(self.toggle_disable_auto_restore_hosts)
# 添加打开hosts文件选项
self.open_hosts_action = QAction("打开hosts文件", self.main_window)
self.open_hosts_action.setFont(menu_font)
self.open_hosts_action.triggered.connect(self.open_hosts_file)
# 添加到hosts子菜单
self.hosts_submenu.addAction(self.disable_auto_restore_action)
self.hosts_submenu.addAction(self.restore_hosts_action)
self.hosts_submenu.addAction(self.clean_hosts_action)
self.hosts_submenu.addAction(self.open_hosts_action)
# 创建Debug开关选项
self.debug_action = QAction("Debug开关", self.main_window, checkable=True)
self.debug_action.setFont(menu_font)
# 安全地获取config属性
config = getattr(self.main_window, 'config', {})
debug_mode = False
if isinstance(config, dict):
debug_mode = config.get("debug_mode", False)
self.debug_action.setChecked(debug_mode)
# 安全地连接toggle_debug_mode方法
if hasattr(self.main_window, 'toggle_debug_mode'):
self.debug_action.triggered.connect(self.main_window.toggle_debug_mode)
# 创建打开log文件选项
self.open_log_action = QAction("打开log.txt", self.main_window)
self.open_log_action.setFont(menu_font)
# 初始状态根据debug模式设置启用状态
self.open_log_action.setEnabled(debug_mode)
# 连接打开log文件的事件
self.open_log_action.triggered.connect(self.open_log_file)
# 添加到Debug子菜单
self.debug_submenu.addAction(self.debug_action)
self.debug_submenu.addAction(self.open_log_action)
# 创建下载设置子菜单
self.download_settings_menu = QMenu("下载设置", self.main_window)
self.download_settings_menu.setFont(menu_font)
self.download_settings_menu.setStyleSheet(menu_style)
# "修改下载源"按钮移至下载设置菜单
self.switch_source_action = QAction("修改下载源", self.main_window)
self.switch_source_action.setFont(menu_font)
self.switch_source_action.setEnabled(True)
self.switch_source_action.triggered.connect(self.show_under_development)
# 添加下载线程设置选项
self.thread_settings_action = QAction("下载线程设置", self.main_window)
self.thread_settings_action.setFont(menu_font)
# 连接到下载线程设置对话框
self.thread_settings_action.triggered.connect(self.show_download_thread_settings)
# 添加到下载设置子菜单
self.download_settings_menu.addAction(self.switch_source_action)
self.download_settings_menu.addAction(self.thread_settings_action)
# 添加到主菜单
self.ui.menu.addMenu(self.work_mode_menu) # 添加工作模式子菜单
self.ui.menu.addMenu(self.download_settings_menu) # 添加下载设置子菜单
self.ui.menu.addSeparator()
self.ui.menu.addMenu(self.dev_menu) # 添加开发者选项子菜单
# 添加Debug子菜单到开发者选项菜单
self.dev_menu.addMenu(self.debug_submenu)
self.dev_menu.addMenu(self.hosts_submenu) # 添加hosts文件选项子菜单
self.dev_menu.addMenu(self.ipv6_submenu) # 添加IPv6支持子菜单
def _handle_ipv6_toggle(self, enabled):
"""处理IPv6支持切换事件
Args:
enabled: 是否启用IPv6支持
"""
if not self.ipv6_manager:
# 显示错误提示
msg_box = self._create_message_box("错误", "\nIPv6管理器尚未初始化请稍后再试。\n")
msg_box.exec()
# 恢复复选框状态
self.ipv6_action.setChecked(not enabled)
return
if enabled:
# 先显示警告提示
warning_msg_box = self._create_message_box(
"警告",
"\n目前IPv6支持功能仍在测试阶段可能会发生意料之外的bug\n\n您确定需要启用吗?\n",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
response = warning_msg_box.exec()
# 如果用户选择不启用,直接返回
if response != QMessageBox.StandardButton.Yes:
# 恢复复选框状态
self.ipv6_action.setChecked(False)
return
# 显示正在校验IPv6的提示
msg_box = self._create_message_box("IPv6检测", "\n正在校验是否支持IPv6请稍候...\n")
msg_box.open() # 使用open而不是exec这样不会阻塞UI
# 处理消息队列,确保对话框显示
from PySide6.QtCore import QCoreApplication
QCoreApplication.processEvents()
# 检查IPv6是否可用
ipv6_available = self.ipv6_manager.check_ipv6_availability()
# 关闭提示对话框
msg_box.accept()
if not ipv6_available:
# 显示IPv6不可用的提示
error_msg_box = self._create_message_box(
"IPv6不可用",
"\n未检测到可用的IPv6连接无法启用IPv6支持。\n\n请确保您的网络环境支持IPv6且已正确配置。\n"
)
error_msg_box.exec()
# 恢复复选框状态
self.ipv6_action.setChecked(False)
return False
# 使用IPv6Manager处理切换
success = self.ipv6_manager.toggle_ipv6_support(enabled)
# 如果切换失败,恢复复选框状态
if not success:
self.ipv6_action.setChecked(not enabled)
def show_menu(self, menu, button):
"""显示菜单
Args:
menu: 要显示的菜单
button: 触发菜单的按钮
"""
# 检查Ui_install中是否定义了show_menu方法
if hasattr(self.ui, 'show_menu'):
# 如果存在使用UI中定义的方法
self.ui.show_menu(menu, button)
else:
# 否则,使用默认的弹出方法
global_pos = button.mapToGlobal(button.rect().bottomLeft())
menu.popup(global_pos)
def open_project_home_page(self):
"""打开项目主页"""
webbrowser.open("https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT")
def open_github_page(self):
"""打开项目GitHub页面"""
webbrowser.open("https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT")
def open_faq_page(self):
"""打开常见问题页面"""
import locale
# 根据系统语言选择FAQ页面
system_lang = locale.getdefaultlocale()[0]
if system_lang and system_lang.startswith('zh'):
webbrowser.open("https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/FAQ.md")
else:
webbrowser.open("https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/FAQ-en.md")
def open_issues_page(self):
"""打开GitHub问题页面"""
webbrowser.open("https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/issues")
def open_qq_group(self):
"""打开QQ群链接"""
webbrowser.open("https://qm.qq.com/q/g9i04i5eec")
def open_privacy_policy(self):
"""打开完整隐私协议在GitHub上"""
webbrowser.open("https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/PRIVACY.md")
def revoke_privacy_agreement(self):
"""撤回隐私协议同意,并重启软件"""
# 创建确认对话框
msg_box = self._create_message_box(
"确认操作",
"\n您确定要撤回隐私协议同意吗?\n\n撤回后软件将立即重启,您需要重新阅读并同意隐私协议。\n",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
reply = msg_box.exec()
if reply == QMessageBox.StandardButton.Yes:
# 用户确认撤回
try:
# 导入隐私管理器
from core.privacy_manager import PrivacyManager
import sys
import subprocess
import os
# 创建实例并重置隐私协议同意
privacy_manager = PrivacyManager()
if privacy_manager.reset_privacy_agreement():
# 显示重启提示
restart_msg = self._create_message_box(
"操作成功",
"\n已成功撤回隐私协议同意。\n\n软件将立即重启。\n"
)
restart_msg.exec()
# 获取当前执行的Python解释器路径和脚本路径
python_executable = sys.executable
script_path = os.path.abspath(sys.argv[0])
# 构建重启命令
restart_cmd = [python_executable, script_path]
# 启动新进程
subprocess.Popen(restart_cmd)
# 退出当前进程
sys.exit(0)
else:
# 显示失败提示
fail_msg = self._create_message_box(
"操作失败",
"\n撤回隐私协议同意失败。\n\n请检查应用权限或稍后再试。\n"
)
fail_msg.exec()
except Exception as e:
# 显示错误提示
error_msg = self._create_message_box(
"错误",
f"\n撤回隐私协议同意时发生错误:\n\n{str(e)}\n"
)
error_msg.exec()
def _create_message_box(self, title, message, buttons=QMessageBox.StandardButton.Ok):
"""创建统一风格的消息框
Args:
title: 消息框标题
message: 消息内容
buttons: 按钮类型,默认为确定按钮
Returns:
QMessageBox: 配置好的消息框实例
"""
msg_box = msgbox_frame(
f"{title} - {APP_NAME}",
message,
buttons,
)
return msg_box
def show_under_development(self):
"""显示功能正在开发中的提示"""
msg_box = self._create_message_box("提示", "\n该功能正在开发中,敬请期待!\n")
msg_box.exec()
def show_download_thread_settings(self):
"""显示下载线程设置对话框"""
if hasattr(self.main_window, 'download_manager'):
self.main_window.download_manager.show_download_thread_settings()
else:
# 如果下载管理器不可用,显示错误信息
msg_box = self._create_message_box("错误", "\n下载管理器未初始化,无法修改下载线程设置。\n")
msg_box.exec()
def open_log_file(self):
"""打开当前日志文件"""
try:
# 检查日志文件是否存在
if os.path.exists(LOG_FILE):
# 获取日志文件大小
file_size = os.path.getsize(LOG_FILE)
if file_size == 0:
msg_box = self._create_message_box("提示", f"\n当前日志文件 {os.path.basename(LOG_FILE)} 存在但为空。\n\n日志文件位置:{os.path.abspath(LOG_FILE)}")
msg_box.exec()
return
# 根据文件大小决定是使用文本查看器还是直接打开
if file_size > 1024 * 1024: # 大于1MB
# 文件较大,显示警告
msg_box = self._create_message_box(
"警告",
f"\n日志文件较大 ({file_size / 1024 / 1024:.2f} MB),是否仍要打开?\n\n日志文件位置:{os.path.abspath(LOG_FILE)}",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if msg_box.exec() != QMessageBox.StandardButton.Yes:
return
# 使用操作系统默认程序打开日志文件
if os.name == 'nt': # Windows
os.startfile(LOG_FILE)
else: # macOS 和 Linux
import subprocess
subprocess.call(['xdg-open', LOG_FILE])
else:
# 文件不存在,显示信息
# 搜索log文件夹下所有可能的日志文件
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
log_dir = os.path.join(root_dir, "log")
# 如果log文件夹不存在尝试创建它
if not os.path.exists(log_dir):
try:
os.makedirs(log_dir, exist_ok=True)
msg_box = self._create_message_box(
"信息",
f"\n日志文件夹不存在,已创建新的日志文件夹:\n{log_dir}\n\n请在启用调试模式后重试。"
)
msg_box.exec()
return
except Exception as e:
msg_box = self._create_message_box(
"错误",
f"\n创建日志文件夹失败:\n\n{str(e)}"
)
msg_box.exec()
return
# 搜索log文件夹中的日志文件
try:
log_files = [f for f in os.listdir(log_dir) if f.startswith("log-") and f.endswith(".txt")]
except Exception as e:
msg_box = self._create_message_box(
"错误",
f"\n无法读取日志文件夹:\n\n{str(e)}"
)
msg_box.exec()
return
if log_files:
# 按照修改时间排序,获取最新的日志文件
log_files.sort(key=lambda x: os.path.getmtime(os.path.join(log_dir, x)), reverse=True)
latest_log = os.path.join(log_dir, log_files[0])
# 获取最新日志文件的创建时间信息
try:
log_datetime = "-".join(os.path.basename(latest_log)[4:-4].split("-")[:2])
log_date = log_datetime.split("-")[0]
log_time = log_datetime.split("-")[1] if "-" in log_datetime else "未知时间"
date_info = f"日期: {log_date[:4]}-{log_date[4:6]}-{log_date[6:]}"
time_info = f"时间: {log_time[:2]}:{log_time[2:4]}:{log_time[4:]}"
except:
date_info = "日期未知 "
time_info = "时间未知"
msg_box = self._create_message_box(
"信息",
f"\n当前日志文件 {os.path.basename(LOG_FILE)} 不存在。\n\n"
f"发现最新的日志文件: {os.path.basename(latest_log)}\n"
f"({date_info}{time_info})\n\n"
f"是否打开此文件?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if msg_box.exec() == QMessageBox.StandardButton.Yes:
if os.name == 'nt': # Windows
os.startfile(latest_log)
else: # macOS 和 Linux
import subprocess
subprocess.call(['xdg-open', latest_log])
return
# 如果没有找到任何日志文件或用户选择不打开最新的日志文件
msg_box = self._create_message_box(
"信息",
f"\n没有找到有效的日志文件。\n\n"
f"预期的日志文件夹:{log_dir}\n\n"
f"请确认调试模式已启用,并执行一些操作后再查看日志。"
)
msg_box.exec()
except Exception as e:
msg_box = self._create_message_box("错误", f"\n处理日志文件时出错:\n\n{str(e)}\n\n文件位置:{os.path.abspath(LOG_FILE)}")
msg_box.exec()
def restore_hosts_backup(self):
"""还原软件备份的hosts文件"""
if hasattr(self.main_window, 'download_manager') and hasattr(self.main_window.download_manager, 'hosts_manager'):
try:
# 调用恢复hosts文件的方法
result = self.main_window.download_manager.hosts_manager.restore()
if result:
msg_box = self._create_message_box("成功", "\nhosts文件已成功还原为备份版本。\n")
else:
msg_box = self._create_message_box("警告", "\n还原hosts文件失败或没有找到备份文件。\n")
msg_box.exec()
except Exception as e:
msg_box = self._create_message_box("错误", f"\n还原hosts文件时发生错误\n\n{str(e)}\n")
msg_box.exec()
else:
msg_box = self._create_message_box("错误", "\n无法访问hosts管理器。\n")
msg_box.exec()
def clean_hosts_entries(self):
"""手动删除软件添加的hosts条目"""
if hasattr(self.main_window, 'download_manager') and hasattr(self.main_window.download_manager, 'hosts_manager'):
try:
# 调用清理hosts条目的方法强制清理即使禁用了自动还原
result = self.main_window.download_manager.hosts_manager.check_and_clean_all_entries(force_clean=True)
if result:
msg_box = self._create_message_box("成功", "\n已成功清理软件添加的hosts条目。\n")
else:
msg_box = self._create_message_box("提示", "\n未发现软件添加的hosts条目或清理操作失败。\n")
msg_box.exec()
except Exception as e:
msg_box = self._create_message_box("错误", f"\n清理hosts条目时发生错误\n\n{str(e)}\n")
msg_box.exec()
else:
msg_box = self._create_message_box("错误", "\n无法访问hosts管理器。\n")
msg_box.exec()
def open_hosts_file(self):
"""打开系统hosts文件"""
try:
# 获取hosts文件路径
hosts_path = os.path.join(os.environ['SystemRoot'], 'System32', 'drivers', 'etc', 'hosts')
# 检查文件是否存在
if os.path.exists(hosts_path):
# 使用操作系统默认程序打开hosts文件
if os.name == 'nt': # Windows
# 尝试以管理员权限打开记事本编辑hosts文件
try:
# 使用PowerShell以管理员身份启动记事本
subprocess.Popen(["powershell", "Start-Process", "notepad", hosts_path, "-Verb", "RunAs"])
except Exception as e:
# 如果失败,尝试直接打开
os.startfile(hosts_path)
else: # macOS 和 Linux
import subprocess
subprocess.call(['xdg-open', hosts_path])
else:
msg_box = self._create_message_box("错误", f"\nhosts文件不存在\n{hosts_path}\n")
msg_box.exec()
except Exception as e:
msg_box = self._create_message_box("错误", f"\n打开hosts文件时发生错误\n\n{str(e)}\n")
msg_box.exec()
def toggle_disable_auto_restore_hosts(self, checked):
"""切换禁用自动还原hosts的状态
Args:
checked: 是否禁用自动还原
"""
if hasattr(self.main_window, 'download_manager') and hasattr(self.main_window.download_manager, 'hosts_manager'):
try:
# 调用HostsManager的方法设置自动还原标志
result = self.main_window.download_manager.hosts_manager.set_auto_restore_disabled(checked)
if result:
# 同时更新内部配置,确保立即生效
if hasattr(self.main_window, 'config'):
self.main_window.config['disable_auto_restore_hosts'] = checked
# 显示成功提示
status = "禁用" if checked else "启用"
msg_box = self._create_message_box(
"设置已更新",
f"\n{status}关闭/重启时自动还原hosts。\n\n{'hosts将被保留' if checked else 'hosts将在关闭时自动还原'}\n"
)
msg_box.exec()
else:
# 如果设置失败,恢复复选框状态
self.disable_auto_restore_action.setChecked(not checked)
msg_box = self._create_message_box(
"设置失败",
"\n更新设置时发生错误,请稍后再试。\n"
)
msg_box.exec()
except Exception as e:
# 如果发生异常,恢复复选框状态
self.disable_auto_restore_action.setChecked(not checked)
msg_box = self._create_message_box(
"错误",
f"\n更新设置时发生异常:\n\n{str(e)}\n"
)
msg_box.exec()
else:
# 如果hosts管理器不可用恢复复选框状态
self.disable_auto_restore_action.setChecked(not checked)
msg_box = self._create_message_box(
"错误",
"\nhosts管理器不可用无法更新设置。\n"
)
msg_box.exec()
def show_about_dialog(self):
"""显示关于对话框"""
about_text = f"""
<p><b>{APP_NAME} v{APP_VERSION}</b></p>
<p>GitHub: <a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT">https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT</a></p>
<p>原作: <a href="https://github.com/Yanam1Anna">Yanam1Anna</a></p>
<p>此应用根据 <a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/LICENSE">GPL-3.0 许可证</a> 授权。</p>
<br>
<p><b>感谢:</b></p>
<p>- <a href="https://github.com/HTony03">HTony03</a>:对原项目部分源码的重构、逻辑优化和功能实现提供了支持。</p>
<p>- <a href="https://github.com/ABSIDIA">钨鸮</a>:对于云端资源存储提供了支持。</p>
<p>- <a href="https://github.com/XIU2/CloudflareSpeedTest">XIU2/CloudflareSpeedTest</a>:提供了 IP 优选功能的核心支持。</p>
<p>- <a href="https://github.com/hosxy/aria2-fast">hosxy/aria2-fast</a>提供了修改版aria2c提高了下载速度和性能。</p>
"""
msg_box = msgbox_frame(
f"关于 - {APP_NAME}",
about_text,
QMessageBox.StandardButton.Ok,
)
msg_box.setTextFormat(Qt.TextFormat.RichText) # 使用Qt.TextFormat
msg_box.exec()
def show_ipv6_manager_not_ready(self):
"""显示IPv6管理器未准备好的提示"""
msg_box = self._create_message_box("错误", "\nIPv6管理器尚未初始化请稍后再试。\n")
msg_box.exec()
def switch_work_mode(self, mode):
"""切换工作模式
Args:
mode: 要切换的模式,"online""offline"
"""
# 检查主窗口是否有离线模式管理器
if not hasattr(self.main_window, 'offline_mode_manager'):
# 如果没有离线模式管理器,创建提示
msg_box = self._create_message_box(
"错误",
"\n离线模式管理器未初始化,无法切换工作模式。\n"
)
msg_box.exec()
# 恢复选择状态
self.online_mode_action.setChecked(True)
self.offline_mode_action.setChecked(False)
return
if mode == "offline":
# 尝试切换到离线模式
success = self.main_window.offline_mode_manager.set_offline_mode(True)
if not success:
# 如果切换失败,恢复选择状态
self.online_mode_action.setChecked(True)
self.offline_mode_action.setChecked(False)
return
# 更新配置
self.main_window.config["offline_mode"] = True
self.main_window.save_config(self.main_window.config)
# 在离线模式下始终启用开始安装按钮
if hasattr(self.main_window, 'set_start_button_enabled'):
self.main_window.set_start_button_enabled(True)
# 清除版本警告标志
if hasattr(self.main_window, 'version_warning'):
self.main_window.version_warning = False
# 显示提示
msg_box = self._create_message_box(
"模式已切换",
"\n已切换到离线模式。\n\n将使用本地补丁文件进行安装,不会从网络下载补丁。\n"
)
msg_box.exec()
else:
# 切换到在线模式
self.main_window.offline_mode_manager.set_offline_mode(False)
# 更新配置
self.main_window.config["offline_mode"] = False
self.main_window.save_config(self.main_window.config)
# 重新获取云端配置
if hasattr(self.main_window, 'fetch_cloud_config'):
self.main_window.fetch_cloud_config()
# 如果当前版本过低,设置版本警告标志
if hasattr(self.main_window, 'last_error_message') and self.main_window.last_error_message == "update_required":
# 设置版本警告标志
if hasattr(self.main_window, 'version_warning'):
self.main_window.version_warning = True
# 显示提示
msg_box = self._create_message_box(
"模式已切换",
"\n已切换到在线模式。\n\n将从网络下载补丁进行安装。\n"
)
msg_box.exec()

View File

@@ -0,0 +1,149 @@
from PySide6.QtCore import Qt, QPoint, QRect, QSize
from PySide6.QtGui import QPainterPath, QRegion
class WindowManager:
"""窗口管理器类,用于处理窗口的基本行为,如拖拽、调整大小和圆角设置"""
def __init__(self, parent_window):
"""初始化窗口管理器
Args:
parent_window: 父窗口实例
"""
self.window = parent_window
self.ui = parent_window.ui
# 拖动窗口相关变量
self._drag_position = QPoint()
self._is_dragging = False
# 窗口比例
self.aspect_ratio = 16 / 9
self.updateRoundedCorners = True
# 设置圆角窗口
self.setRoundedCorners()
def setRoundedCorners(self):
"""设置窗口圆角"""
# 实现圆角窗口
path = QPainterPath()
path.addRoundedRect(self.window.rect(), 20, 20)
mask = QRegion(path.toFillPolygon().toPolygon())
self.window.setMask(mask)
# 更新resize事件时更新圆角
self.updateRoundedCorners = True
def handle_mouse_press(self, event):
"""处理鼠标按下事件
Args:
event: 鼠标事件
"""
if event.button() == Qt.MouseButton.LeftButton:
# 只有当鼠标在标题栏区域时才可以拖动
if hasattr(self.ui, 'title_bar') and self.ui.title_bar.geometry().contains(event.position().toPoint()):
self._is_dragging = True
self._drag_position = event.globalPosition().toPoint() - self.window.frameGeometry().topLeft()
event.accept()
def handle_mouse_move(self, event):
"""处理鼠标移动事件
Args:
event: 鼠标事件
"""
if event.buttons() & Qt.MouseButton.LeftButton and self._is_dragging:
self.window.move(event.globalPosition().toPoint() - self._drag_position)
event.accept()
def handle_mouse_release(self, event):
"""处理鼠标释放事件
Args:
event: 鼠标事件
"""
if event.button() == Qt.MouseButton.LeftButton:
self._is_dragging = False
event.accept()
def handle_resize(self, event):
"""当窗口大小改变时更新圆角和维持纵横比
Args:
event: 窗口大小改变事件
"""
# 计算基于当前宽度的合适高度以维持16:9比例
new_width = event.size().width()
new_height = int(new_width / self.aspect_ratio)
if new_height != event.size().height():
# 阻止变形,保持比例
self.window.resize(new_width, new_height)
# 更新主容器大小
if hasattr(self.ui, 'main_container'):
self.ui.main_container.setGeometry(0, 0, new_width, new_height)
# 更新内容容器大小
if hasattr(self.ui, 'content_container'):
self.ui.content_container.setGeometry(0, 0, new_width, new_height)
# 更新标题栏宽度和高度
if hasattr(self.ui, 'title_bar'):
self.ui.title_bar.setGeometry(0, 0, new_width, 35)
# 更新菜单区域
if hasattr(self.ui, 'menu_area'):
self.ui.menu_area.setGeometry(0, 35, new_width, 30)
# 更新内容区域大小
if hasattr(self.ui, 'inner_content'):
self.ui.inner_content.setGeometry(0, 65, new_width, new_height - 65)
# 更新背景图大小
if hasattr(self.ui, 'Mainbg'):
self.ui.Mainbg.setGeometry(0, 0, new_width, new_height - 65)
if hasattr(self.ui, 'loadbg'):
self.ui.loadbg.setGeometry(0, 0, new_width, new_height - 65)
# 调整按钮位置 - 固定在右侧
right_margin = 20 # 减小右边距,使按钮更靠右
if hasattr(self.ui, 'button_container'):
btn_width = 211 # 扩大后的容器宽度
btn_height = 111 # 扩大后的容器高度
x_pos = new_width - btn_width - right_margin
y_pos = int((new_height - 65) * 0.18) - 10 # 从0.28改为0.18,向上移动
self.ui.button_container.setGeometry(x_pos, y_pos, btn_width, btn_height)
# 添加禁/启用补丁按钮容器的位置调整
if hasattr(self.ui, 'toggle_patch_container'):
btn_width = 211 # 扩大后的容器宽度
btn_height = 111 # 扩大后的容器高度
x_pos = new_width - btn_width - right_margin
y_pos = int((new_height - 65) * 0.36) - 10 # 从0.46改为0.36,向上移动
self.ui.toggle_patch_container.setGeometry(x_pos, y_pos, btn_width, btn_height)
# 添加卸载补丁按钮容器的位置调整
if hasattr(self.ui, 'uninstall_container'):
btn_width = 211 # 扩大后的容器宽度
btn_height = 111 # 扩大后的容器高度
x_pos = new_width - btn_width - right_margin
y_pos = int((new_height - 65) * 0.54) - 10 # 从0.64改为0.54,向上移动
self.ui.uninstall_container.setGeometry(x_pos, y_pos, btn_width, btn_height)
if hasattr(self.ui, 'exit_container'):
btn_width = 211 # 扩大后的容器宽度
btn_height = 111 # 扩大后的容器高度
x_pos = new_width - btn_width - right_margin
y_pos = int((new_height - 65) * 0.72) - 10 # 从0.82改为0.72,向上移动
self.ui.exit_container.setGeometry(x_pos, y_pos, btn_width, btn_height)
# 更新圆角
if hasattr(self, 'updateRoundedCorners') and self.updateRoundedCorners:
path = QPainterPath()
path.addRoundedRect(self.window.rect(), 20, 20)
mask = QRegion(path.toFillPolygon().toPolygon())
self.window.setMask(mask)

View File

@@ -29,6 +29,11 @@ class OfflineModeManager:
self.offline_patches = {} # 存储离线补丁信息 {补丁名称: 文件路径}
self.is_offline_mode = False
self.installed_games = [] # 跟踪本次实际安装的游戏
# 保持对哈希线程的引用,避免运行中被销毁
self.hash_thread = None
# 解压线程与进度窗口引用避免运行中被销毁且确保UI可更新
self.extraction_thread = None
self.extraction_progress_window = None
def _is_debug_mode(self):
"""检查是否处于调试模式
@@ -523,6 +528,10 @@ class OfflineModeManager:
# 保存引用以便后续使用
self.hash_thread = hash_thread
try:
self.hash_thread.finished.connect(lambda: setattr(self, 'hash_thread', None))
except Exception:
pass
hash_thread.start()
def _on_hash_check_finished(self, result, game_version, install_tasks):
@@ -586,16 +595,30 @@ class OfflineModeManager:
self.process_next_offline_install_task(install_tasks)
def _on_extraction_finished_with_hash_check(self, success, error_message, game_version, install_tasks):
"""解压完成后进行哈希校验
Args:
success: 是否解压成功
error_message: 错误信息
game_version: 游戏版本
install_tasks: 剩余的安装任务列表
"""
# 这个方法已不再使用,保留为空以兼容旧版本调用
"""解压完成后进行哈希校验(后台线程回调)"""
# 关闭解压进度窗口
try:
if self.extraction_progress_window and self.extraction_progress_window.isVisible():
self.extraction_progress_window.close()
except Exception:
pass
self.extraction_progress_window = None
# 清理线程引用
self.extraction_thread = None
if not success:
# 解压失败,提示并继续下一个任务
msgbox_frame(
f"安装错误 - {self.app_name}",
error_message or f"\n{game_version} 的安装过程中发生错误。\n",
QMessageBox.StandardButton.Ok
).exec()
self.process_next_offline_install_task(install_tasks)
return
# 解压成功,进入安装后哈希校验
self._perform_hash_check(game_version, install_tasks)
def on_extraction_thread_finished(self, success, error_message, game_version, install_tasks):
"""解压线程完成后的处理(兼容旧版本)
@@ -823,14 +846,12 @@ class OfflineModeManager:
if debug_mode:
logger.debug(f"DEBUG: 有未找到离线补丁文件的游戏: {self.missing_offline_patches}")
# 先显示已安装结果
# 不再先弹出安装结果,直接询问是否联网继续
if self.installed_games:
installed_msg = f"已成功安装以下补丁:\n\n{chr(10).join(self.installed_games)}\n\n"
else:
installed_msg = ""
# 使用QTimer延迟显示询问对话框确保安装结果窗口先显示并关闭
QTimer.singleShot(500, lambda: self._show_missing_patches_dialog(installed_msg))
QTimer.singleShot(100, lambda: self._show_missing_patches_dialog(installed_msg))
else:
# 恢复UI状态
self.main_window.setEnabled(True)
@@ -846,96 +867,58 @@ class OfflineModeManager:
logger.debug(f"DEBUG: 补丁文件: {patch_file}")
logger.debug(f"DEBUG: 游戏目录: {game_folder}")
# 显示安装进度窗口
self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="offline_installation", is_offline=True)
# 使用后台线程进行解压避免阻塞UI
try:
# 确保游戏目录存在
os.makedirs(game_folder, exist_ok=True)
# 从GAME_INFO获取目标文件名
target_filename = os.path.basename(GAME_INFO[game_version]["install_path"])
if not target_filename:
raise ValueError(f"未知的游戏版本或配置错误: {game_version}")
# 直接从源7z文件解压
with py7zr.SevenZipFile(patch_file, mode="r") as archive:
file_list = archive.getnames()
target_file_in_archive = None
# 查找压缩包中的目标文件
for f_path in file_list:
if target_filename in f_path:
target_file_in_archive = f_path
break
if not target_file_in_archive:
raise FileNotFoundError(f"在压缩包 {os.path.basename(patch_file)} 中未找到目标文件 {target_filename}")
# 使用临时目录来解压单个文件
with tempfile.TemporaryDirectory() as temp_dir:
archive.extract(path=temp_dir, targets=[target_file_in_archive])
extracted_file_path = os.path.join(temp_dir, target_file_in_archive)
# 最终目标路径
target_path = os.path.join(game_folder, target_filename)
# 复制到游戏目录
shutil.copy2(extracted_file_path, target_path)
if debug_mode:
logger.debug(f"DEBUG: 已解压并复制文件到 {target_path}")
# 对于NEKOPARA After还需要处理签名文件
if game_version == "NEKOPARA After":
sig_filename = f"{target_filename}.sig"
sig_file_in_archive = None
for f_path in file_list:
if sig_filename in f_path:
sig_file_in_archive = f_path
break
if sig_file_in_archive:
# 创建非阻塞的解压进度窗口
self.extraction_progress_window = self.main_window.create_extraction_progress_window()
try:
archive.extract(path=temp_dir, targets=[sig_file_in_archive])
extracted_sig_path = os.path.join(temp_dir, sig_file_in_archive)
sig_target = os.path.join(game_folder, sig_filename)
shutil.copy2(extracted_sig_path, sig_target)
if debug_mode:
logger.debug(f"DEBUG: 已解压并复制签名文件到 {sig_target}")
except py7zr.exceptions.CrcError as sig_e:
if debug_mode:
logger.warning(f"DEBUG: 签名文件 '{sig_e.filename}' CRC校验失败已忽略此文件。")
self.extraction_progress_window.show()
QtWidgets.QApplication.processEvents()
except Exception:
pass
# 进行安装后的哈希校验
self._perform_hash_check(game_version, install_tasks)
# 启动解压线程
self.extraction_thread = self.main_window.create_extraction_thread(
patch_file, game_folder, plugin_path, game_version
)
except py7zr.exceptions.CrcError as e:
if debug_mode:
logger.error(f"DEBUG: CRC校验失败文件可能已损坏: {e}")
logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}")
# 连接进度更新到窗口控件
if self.extraction_thread and self.extraction_progress_window:
self.extraction_thread.progress.connect(
lambda percent, status: (
self.extraction_progress_window.progress_bar.setValue(percent),
self.extraction_progress_window.status_label.setText(status)
)
)
self.main_window.close_hash_msg_box()
# 完成后进入哈希校验
self.extraction_thread.finished.connect(
lambda success, error, gv: self._on_extraction_finished_with_hash_check(success, error, gv, install_tasks)
)
msgbox_frame(
f"安装错误 - {self.app_name}",
f"\n补丁文件 {os.path.basename(patch_file)} 在解压时CRC校验失败。\n"
f"这通常意味着文件已损坏,请尝试重新下载该文件。\n\n"
f"游戏: {game_version}\n"
f"错误文件: {e.filename}\n\n"
"跳过此游戏的安装。",
QMessageBox.StandardButton.Ok
).exec()
# 线程结束时清理引用
try:
self.extraction_thread.finished.connect(lambda *_: setattr(self, 'extraction_thread', None))
except Exception:
pass
self.extraction_thread.start()
self.process_next_offline_install_task(install_tasks)
except Exception as e:
if debug_mode:
logger.error(f"DEBUG: 离线安装任务处理失败: {e}")
logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}")
# 关闭安装进度窗口
self.main_window.close_hash_msg_box()
# 关闭可能存在的解压进度窗口
try:
if self.extraction_progress_window and self.extraction_progress_window.isVisible():
self.extraction_progress_window.close()
except Exception:
pass
self.extraction_progress_window = None
# 显示错误消息
msgbox_frame(
@@ -1005,3 +988,8 @@ class OfflineModeManager:
# 恢复UI状态
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
# 用户拒绝联网后,再显示本次安装结果
try:
QTimer.singleShot(100, self.main_window.show_result)
except Exception:
pass

View File

@@ -269,6 +269,18 @@ class PatchDetector:
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
# 当处于离线模式且存在缺失补丁需要联网继续时,暂不立即显示安装结果
should_defer_result = False
try:
offline_mgr = getattr(self.main_window, 'offline_mode_manager', None)
if offline_mgr and offline_mgr.is_in_offline_mode():
missing_list = getattr(offline_mgr, 'missing_offline_patches', [])
if missing_list:
should_defer_result = True
except Exception:
should_defer_result = False
if not should_defer_result:
QTimer.singleShot(100, self.main_window.show_result)
def on_offline_pre_hash_finished(self, updated_status, game_dirs):

View File

@@ -118,6 +118,9 @@ class MainWindow(QMainWindow):
self.version_warning = False # 添加版本警告标志
self.install_button_enabled = True # 默认启用安装按钮
self.progress_window = None
# 线程持有引用,避免 QThread 在运行中被销毁
self.pre_hash_thread = None
self.hash_thread = None # after 校验线程引用(由 PatchDetector 赋值)
# 设置关闭按钮事件连接
if hasattr(self.ui, 'close_btn'):
@@ -478,6 +481,60 @@ class MainWindow(QMainWindow):
event.ignore()
return
# 在退出前优雅地清理后台线程,避免 QThread 在运行中被销毁
def _graceful_stop(thread_obj, name="thread", timeout_ms=2000):
try:
if thread_obj and hasattr(thread_obj, 'isRunning') and thread_obj.isRunning():
# 首选等待自然结束
if hasattr(thread_obj, 'requestInterruption'):
try:
thread_obj.requestInterruption()
except Exception:
pass
thread_obj.wait(timeout_ms)
# 仍未退出时,最后手段终止
if thread_obj.isRunning():
try:
thread_obj.terminate()
except Exception:
pass
thread_obj.wait(1000)
except Exception:
pass
# 清理主窗口直接持有的线程
_graceful_stop(getattr(self, 'pre_hash_thread', None), 'pre_hash_thread')
_graceful_stop(getattr(self, 'hash_thread', None), 'hash_thread')
# 清理离线管理器中的线程
try:
if hasattr(self, 'offline_mode_manager') and self.offline_mode_manager:
_graceful_stop(getattr(self.offline_mode_manager, 'hash_thread', None), 'offline_hash_thread')
_graceful_stop(getattr(self.offline_mode_manager, 'extraction_thread', None), 'extraction_thread')
except Exception:
pass
# 清理配置获取线程
try:
if hasattr(self, 'config_manager') and hasattr(self.config_manager, 'config_fetch_thread'):
_graceful_stop(self.config_manager.config_fetch_thread, 'config_fetch_thread', 1000)
except Exception:
pass
# 清理游戏识别线程
try:
if hasattr(self, 'game_detector') and hasattr(self.game_detector, 'detection_thread'):
_graceful_stop(self.game_detector.detection_thread, 'detection_thread', 1000)
except Exception:
pass
# 清理补丁检查线程
try:
if hasattr(self, 'patch_detector') and hasattr(self.patch_detector, 'patch_check_thread'):
_graceful_stop(self.patch_detector.patch_check_thread, 'patch_check_thread', 1000)
except Exception:
pass
# 恢复hosts文件如果未禁用自动还原
self.download_manager.hosts_manager.restore()
@@ -640,11 +697,16 @@ class MainWindow(QMainWindow):
install_paths = self.download_manager.get_install_paths()
# 使用异步方式进行哈希预检查
hash_thread = self.patch_detector.create_hash_thread("pre", install_paths)
hash_thread.pre_finished.connect(
self.pre_hash_thread = self.patch_detector.create_hash_thread("pre", install_paths)
self.pre_hash_thread.pre_finished.connect(
lambda updated_status: self.on_pre_hash_finished(updated_status, game_dirs)
)
hash_thread.start()
# 在线程自然结束时清理引用
try:
self.pre_hash_thread.finished.connect(lambda: setattr(self, 'pre_hash_thread', None))
except Exception:
pass
self.pre_hash_thread.start()
def on_pre_hash_finished(self, updated_status, game_dirs):
"""哈希预检查完成后的回调"""

View File

@@ -1,7 +1,7 @@
import os
import shutil
import py7zr
from PySide6.QtCore import QThread, Signal, QCoreApplication
from PySide6.QtCore import QThread, Signal
from data.config import PLUGIN, GAME_INFO
class ExtractionThread(QThread):
@@ -21,24 +21,28 @@ class ExtractionThread(QThread):
# 确保游戏目录存在
os.makedirs(self.game_folder, exist_ok=True)
# 发送初始进度信号
self.progress.emit(0, f"开始处理 {self.game_version} 的补丁文件...")
# 确保UI更新
QCoreApplication.processEvents()
def update_progress(percent: int, message: str):
try:
self.progress.emit(percent, message)
except Exception:
pass
update_progress(0, f"开始处理 {self.game_version} 的补丁文件...")
# 支持外部请求中断
if self.isInterruptionRequested():
self.finished.emit(False, "操作已取消", self.game_version)
return
# 如果提供了已解压文件路径,直接使用它
if self.extracted_path and os.path.exists(self.extracted_path):
# 发送进度信号
self.progress.emit(20, f"正在复制 {self.game_version} 的补丁文件...")
QCoreApplication.processEvents()
update_progress(20, f"正在复制 {self.game_version} 的补丁文件...\n(在此过程中可能会卡顿或无响应,请不要关闭软件)")
# 直接复制已解压的文件到游戏目录
target_file = os.path.join(self.game_folder, os.path.basename(self.plugin_path))
shutil.copy(self.extracted_path, target_file)
# 发送进度信号
self.progress.emit(60, f"正在完成 {self.game_version} 的补丁安装...")
QCoreApplication.processEvents()
update_progress(60, f"正在完成 {self.game_version} 的补丁安装...")
# 对于NEKOPARA After还需要复制签名文件
if self.game_version == "NEKOPARA After":
@@ -55,29 +59,23 @@ class ExtractionThread(QThread):
sig_path = os.path.join(PLUGIN, GAME_INFO[self.game_version]["sig_path"])
shutil.copy(sig_path, self.game_folder)
# 发送完成进度信号
self.progress.emit(100, f"{self.game_version} 补丁文件处理完成")
QCoreApplication.processEvents()
else:
# 如果没有提供已解压文件路径,直接解压到游戏目录
# 获取目标文件名
update_progress(100, f"{self.game_version} 补丁文件处理完成")
self.finished.emit(True, "", self.game_version)
return
# 否则解压源压缩包到临时目录,再复制目标文件
target_filename = os.path.basename(self.plugin_path)
target_path = os.path.join(self.game_folder, target_filename)
# 发送进度信号
self.progress.emit(10, f"正在打开 {self.game_version} 的补丁压缩包...")
QCoreApplication.processEvents()
update_progress(10, f"正在打开 {self.game_version} 的补丁压缩包...")
# 使用7z解压
with py7zr.SevenZipFile(self._7z_path, mode="r") as archive:
# 获取压缩包内的文件列表
file_list = archive.getnames()
# 发送进度信号
self.progress.emit(20, f"正在分析 {self.game_version} 的补丁文件...")
QCoreApplication.processEvents()
update_progress(20, f"正在分析 {self.game_version} 的补丁文件...")
# 解析压缩包内的文件结构
# 查找压缩包内的目标文件
target_file_in_archive = None
for file_path in file_list:
if target_filename in file_path:
@@ -87,19 +85,14 @@ class ExtractionThread(QThread):
if not target_file_in_archive:
raise FileNotFoundError(f"在压缩包中未找到目标文件 {target_filename}")
# 发送进度信号
self.progress.emit(30, f"正在解压 {self.game_version} 的补丁文件...")
QCoreApplication.processEvents()
update_progress(30, f"正在解压 {self.game_version} 的补丁文件...\n(在此过程中可能会卡顿或无响应,请不要关闭软件)")
# 创建一个临时目录用于解压单个文件
import tempfile
with tempfile.TemporaryDirectory() as temp_dir:
# 解压特定文件到临时目录
archive.extract(path=temp_dir, targets=[target_file_in_archive])
# 发送进度信号
self.progress.emit(60, f"正在复制 {self.game_version} 的补丁文件...")
QCoreApplication.processEvents()
update_progress(60, f"正在复制 {self.game_version} 的补丁文件...")
# 找到解压后的文件
extracted_file_path = os.path.join(temp_dir, target_file_in_archive)
@@ -107,9 +100,7 @@ class ExtractionThread(QThread):
# 复制到目标位置
shutil.copy2(extracted_file_path, target_path)
# 发送进度信号
self.progress.emit(80, f"正在完成 {self.game_version} 的补丁安装...")
QCoreApplication.processEvents()
update_progress(80, f"正在完成 {self.game_version} 的补丁安装...")
# 对于NEKOPARA After还需要复制签名文件
if self.game_version == "NEKOPARA After":
@@ -135,12 +126,11 @@ class ExtractionThread(QThread):
sig_target = os.path.join(self.game_folder, sig_filename)
shutil.copy2(sig_path, sig_target)
# 发送完成进度信号
self.progress.emit(100, f"{self.game_version} 补丁文件解压完成")
QCoreApplication.processEvents()
update_progress(100, f"{self.game_version} 补丁文件解压完成")
self.finished.emit(True, "", self.game_version)
except (py7zr.Bad7zFile, FileNotFoundError, Exception) as e:
try:
self.progress.emit(100, f"处理 {self.game_version} 的补丁文件失败")
QCoreApplication.processEvents()
except Exception:
pass
self.finished.emit(False, f"\n文件操作失败,请重试\n\n【错误信息】:{e}\n", self.game_version)

View File

@@ -43,6 +43,8 @@ class HashThread(QThread):
status_copy = self.installed_status.copy()
for game_version, install_path in self.install_paths.items():
if self.isInterruptionRequested():
break
if not os.path.exists(install_path):
status_copy[game_version] = False
if debug_mode:
@@ -57,8 +59,17 @@ class HashThread(QThread):
# 当没有预期哈希值时,保持当前状态不变
continue
# 分块读取,避免大文件一次性读取内存
hash_obj = hashlib.sha256()
with open(install_path, "rb") as f:
file_hash = hashlib.sha256(f.read()).hexdigest()
while True:
if self.isInterruptionRequested():
break
chunk = f.read(1024 * 1024)
if not chunk:
break
hash_obj.update(chunk)
file_hash = hash_obj.hexdigest()
if debug_mode:
logger.debug(f"DEBUG: 哈希预检查 - {game_version}")
@@ -86,6 +97,8 @@ class HashThread(QThread):
result = {"passed": True, "game": "", "message": ""}
for game_version, install_path in self.install_paths.items():
if self.isInterruptionRequested():
break
if not os.path.exists(install_path):
if debug_mode:
logger.debug(f"DEBUG: 哈希后检查 - {game_version} 补丁文件不存在: {install_path}")
@@ -99,8 +112,17 @@ class HashThread(QThread):
# 当没有预期哈希值时,跳过检查
continue
# 分块读取,避免大文件一次性读取内存
hash_obj = hashlib.sha256()
with open(install_path, "rb") as f:
file_hash = hashlib.sha256(f.read()).hexdigest()
while True:
if self.isInterruptionRequested():
break
chunk = f.read(1024 * 1024)
if not chunk:
break
hash_obj.update(chunk)
file_hash = hash_obj.hexdigest()
if debug_mode:
logger.debug(f"DEBUG: 哈希后检查 - {game_version}")
@@ -167,9 +189,7 @@ class OfflineHashVerifyThread(QThread):
if not expected_hash:
logger.warning(f"DEBUG: 未找到 {self.game_version} 的预期哈希值")
# 确保发送100%进度信号以便UI更新
self.progress.emit(100)
QApplication.processEvents()
self.finished.emit(False, f"未找到 {self.game_version} 的预期哈希值", "")
return
@@ -183,9 +203,7 @@ class OfflineHashVerifyThread(QThread):
if not os.path.exists(self.file_path):
if debug_mode:
logger.warning(f"DEBUG: 补丁文件不存在: {self.file_path}")
# 确保发送100%进度信号以便UI更新
self.progress.emit(100)
QApplication.processEvents()
self.finished.emit(False, f"补丁文件不存在: {self.file_path}", "")
return
@@ -197,9 +215,7 @@ class OfflineHashVerifyThread(QThread):
if file_size == 0:
if debug_mode:
logger.warning(f"DEBUG: 补丁文件大小为0无效文件")
# 确保发送100%进度信号以便UI更新
self.progress.emit(100)
QApplication.processEvents()
self.finished.emit(False, "补丁文件大小为0无效文件", "")
return
@@ -233,7 +249,6 @@ class OfflineHashVerifyThread(QThread):
if debug_mode:
logger.warning(f"DEBUG: 未知的游戏版本: {self.game_version}")
self.progress.emit(100)
QApplication.processEvents()
self.finished.emit(False, f"未知的游戏版本: {self.game_version}", "")
return
@@ -284,7 +299,6 @@ class OfflineHashVerifyThread(QThread):
if debug_mode:
logger.warning(f"DEBUG: 未找到解压后的补丁文件")
self.progress.emit(100)
QApplication.processEvents()
self.finished.emit(False, "未找到解压后的补丁文件", "")
return
else:
@@ -331,9 +345,7 @@ class OfflineHashVerifyThread(QThread):
logger.debug(f"DEBUG: 文件: {files}")
if not os.path.exists(patch_file):
# 确保发送100%进度信号以便UI更新
self.progress.emit(100)
QApplication.processEvents()
self.finished.emit(False, f"未找到解压后的补丁文件", "")
return
@@ -352,7 +364,12 @@ class OfflineHashVerifyThread(QThread):
with open(patch_file, "rb") as f:
bytes_read = 0
while chunk := f.read(chunk_size):
while True:
if self.isInterruptionRequested():
break
chunk = f.read(chunk_size)
if not chunk:
break
hash_obj.update(chunk)
bytes_read += len(chunk)
# 计算进度 (70-95%)
@@ -366,8 +383,6 @@ class OfflineHashVerifyThread(QThread):
# 发送进度信号 - 100%
self.progress.emit(100)
# 确保UI更新
QApplication.processEvents()
if debug_mode:
logger.debug(f"DEBUG: 补丁文件 {patch_file} 哈希值验证: {'成功' if result else '失败'}")
@@ -382,26 +397,20 @@ class OfflineHashVerifyThread(QThread):
if debug_mode:
logger.error(f"DEBUG: 计算补丁文件哈希值失败: {e}")
logger.error(f"DEBUG: 错误类型: {type(e).__name__}")
# 确保发送100%进度信号以便UI更新
self.progress.emit(100)
QApplication.processEvents()
self.finished.emit(False, f"计算补丁文件哈希值失败: {str(e)}", "")
except Exception as e:
if debug_mode:
logger.error(f"DEBUG: 解压补丁文件失败: {e}")
logger.error(f"DEBUG: 错误类型: {type(e).__name__}")
logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}")
# 确保发送100%进度信号以便UI更新
self.progress.emit(100)
QApplication.processEvents()
self.finished.emit(False, f"解压补丁文件失败: {str(e)}", "")
return
except Exception as e:
if debug_mode:
logger.error(f"DEBUG: 验证补丁哈希值失败: {e}")
logger.error(f"DEBUG: 错误类型: {type(e).__name__}")
logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}")
# 确保发送100%进度信号以便UI更新
logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}" )
self.progress.emit(100)
QApplication.processEvents()
self.finished.emit(False, f"验证补丁哈希值失败: {str(e)}", "")