diff --git a/.gitignore b/.gitignore index b40428f..450e39e 100644 --- a/.gitignore +++ b/.gitignore @@ -180,3 +180,11 @@ vol.2.7z vol.3.7z vol.4.7z log/ +__pycache__/ +*.py[cod] +venv/ +.venv/ +Thumbs.db +.DS_Store +STRUCTURE.md +source/STRUCTURE.md diff --git a/PRIVACY.md b/PRIVACY.md index 8e1af27..30d21ea 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -21,6 +21,10 @@ - 游戏安装路径:用于识别已安装的游戏和安装补丁 - 文件哈希值:用于验证文件完整性 +### 2.4 离线模式和本地文件 +- **离线模式**:本应用提供离线模式。在离线模式下,应用不会进行任何网络连接,包括检查更新、获取云端配置或进行任何网络相关的测试,安装过程将只使用本地文件。 +- **本地文件使用**:为了支持离线安装,本应用会扫描其所在目录下的压缩包,以查找用于安装的补丁压缩包。此文件扫描操作仅限于应用所在的文件夹,不会访问或修改您系统中的其他文件。 + ## 3. 信息使用 我们收集的信息仅用于以下目的: @@ -88,4 +92,4 @@ 本隐私政策可能会根据应用功能的变化而更新。请定期查看最新版本。 -最后更新日期:2025年8月4日 \ No newline at end of file +最后更新日期:2025年8月15日 \ No newline at end of file diff --git a/source/Main.py b/source/Main.py index 353eae0..ccfd201 100644 --- a/source/Main.py +++ b/source/Main.py @@ -1,22 +1,58 @@ import sys import os import datetime +import traceback from PySide6.QtWidgets import QApplication, QMessageBox from main_window import MainWindow -from core.privacy_manager import PrivacyManager -from utils.logger import setup_logger -from data.config import LOG_FILE, APP_NAME +from core.managers.privacy_manager import PrivacyManager +from utils.logger import setup_logger, cleanup_old_logs, log_uncaught_exceptions +from config.config import LOG_FILE, APP_NAME, LOG_RETENTION_DAYS from utils import load_config +def excepthook(exc_type, exc_value, exc_traceback): + """全局异常处理钩子,将未捕获的异常记录到日志并显示错误对话框""" + # 记录异常到日志 + if hasattr(sys, '_excepthook'): + sys._excepthook(exc_type, exc_value, exc_traceback) + else: + log_uncaught_exceptions(exc_type, exc_value, exc_traceback) + + # 将异常格式化为易读的形式 + exception_text = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback)) + + # 创建错误对话框 + msg = f"程序遇到未处理的异常:\n\n{str(exc_value)}\n\n详细错误已记录到日志文件。" + try: + # 尝试使用QMessageBox显示错误 + app = QApplication.instance() + if app: + QMessageBox.critical(None, f"错误 - {APP_NAME}", msg) + except Exception: + # 如果QMessageBox失败,则使用标准输出 + print(f"严重错误: {msg}") + print(f"详细错误: {exception_text}") + if __name__ == "__main__": # 设置主日志 logger = setup_logger("main") logger.info("应用启动") + # 设置全局异常处理钩子 + sys._excepthook = sys.excepthook + sys.excepthook = excepthook + + # 记录程序启动信息 + logger.debug(f"Python版本: {sys.version}") + logger.debug(f"运行平台: {sys.platform}") + # 检查配置中是否启用了调试模式 config = load_config() debug_mode = config.get("debug_mode", False) + # 在应用启动时清理过期的日志文件 + cleanup_old_logs(LOG_RETENTION_DAYS) + logger.debug(f"已执行日志清理,保留最近{LOG_RETENTION_DAYS}天的日志") + # 如果调试模式已启用,确保立即创建主日志文件 if debug_mode: try: @@ -24,18 +60,17 @@ if __name__ == "__main__": 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}") + logger.debug(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.debug(f"--- 新调试会话开始于 {os.path.basename(LOG_FILE)} ---") + logger.debug(f"--- 应用版本: {APP_NAME} ---") + current_time = datetime.datetime.now() + formatted_date = current_time.strftime("%Y-%m-%d") + formatted_time = current_time.strftime("%H:%M:%S") + logger.debug(f"--- 日期: {formatted_date} 时间: {formatted_time} ---") - logger.info(f"调试模式已启用,日志文件路径: {os.path.abspath(LOG_FILE)}") + logger.debug(f"调试模式已启用,日志文件路径: {os.path.abspath(LOG_FILE)}") except Exception as e: logger.error(f"创建日志文件失败: {e}") @@ -45,6 +80,7 @@ if __name__ == "__main__": privacy_manager = PrivacyManager() except Exception as e: logger.error(f"初始化隐私协议管理器失败: {e}") + logger.error(f"错误详情: {traceback.format_exc()}") QMessageBox.critical( None, "隐私协议加载错误", @@ -59,4 +95,4 @@ if __name__ == "__main__": logger.info("隐私协议已同意,启动主程序") window = MainWindow() window.show() - sys.exit(app.exec()) \ No newline at end of file + sys.exit(app.exec()) \ No newline at end of file diff --git a/source/assets/__init__.py b/source/assets/__init__.py new file mode 100644 index 0000000..971d3a7 --- /dev/null +++ b/source/assets/__init__.py @@ -0,0 +1,7 @@ +# Assets package initialization +""" +包含应用程序使用的静态资源文件: +- fonts: 字体文件 +- images: 图片资源 +- resources: 其他资源文件 +""" \ No newline at end of file diff --git a/source/fonts/SmileySans-Oblique.ttf b/source/assets/fonts/SmileySans-Oblique.ttf similarity index 100% rename from source/fonts/SmileySans-Oblique.ttf rename to source/assets/fonts/SmileySans-Oblique.ttf diff --git a/source/IMG/After/voaf_ga01.jpg b/source/assets/images/After/voaf_ga01.jpg similarity index 100% rename from source/IMG/After/voaf_ga01.jpg rename to source/assets/images/After/voaf_ga01.jpg diff --git a/source/IMG/After/voaf_ga02.jpg b/source/assets/images/After/voaf_ga02.jpg similarity index 100% rename from source/IMG/After/voaf_ga02.jpg rename to source/assets/images/After/voaf_ga02.jpg diff --git a/source/IMG/BG/bg1.jpg b/source/assets/images/BG/bg1.jpg similarity index 100% rename from source/IMG/BG/bg1.jpg rename to source/assets/images/BG/bg1.jpg diff --git a/source/IMG/BG/bg2.jpg b/source/assets/images/BG/bg2.jpg similarity index 100% rename from source/IMG/BG/bg2.jpg rename to source/assets/images/BG/bg2.jpg diff --git a/source/IMG/BG/bg3.jpg b/source/assets/images/BG/bg3.jpg similarity index 100% rename from source/IMG/BG/bg3.jpg rename to source/assets/images/BG/bg3.jpg diff --git a/source/IMG/BG/bg4.jpg b/source/assets/images/BG/bg4.jpg similarity index 100% rename from source/IMG/BG/bg4.jpg rename to source/assets/images/BG/bg4.jpg diff --git a/source/IMG/BG/menubg.jpg b/source/assets/images/BG/menubg.jpg similarity index 100% rename from source/IMG/BG/menubg.jpg rename to source/assets/images/BG/menubg.jpg diff --git a/source/IMG/BG/title_bg1.png b/source/assets/images/BG/title_bg1.png similarity index 100% rename from source/IMG/BG/title_bg1.png rename to source/assets/images/BG/title_bg1.png diff --git a/source/IMG/BG/title_bg2.png b/source/assets/images/BG/title_bg2.png similarity index 100% rename from source/IMG/BG/title_bg2.png rename to source/assets/images/BG/title_bg2.png diff --git a/source/IMG/BTN/Button.png b/source/assets/images/BTN/Button.png similarity index 100% rename from source/IMG/BTN/Button.png rename to source/assets/images/BTN/Button.png diff --git a/source/IMG/BTN/exit.bmp b/source/assets/images/BTN/exit.bmp similarity index 100% rename from source/IMG/BTN/exit.bmp rename to source/assets/images/BTN/exit.bmp diff --git a/source/IMG/BTN/start_install.bmp b/source/assets/images/BTN/start_install.bmp similarity index 100% rename from source/IMG/BTN/start_install.bmp rename to source/assets/images/BTN/start_install.bmp diff --git a/source/IMG/ICO/cloudflare_logo_icon.ico b/source/assets/images/ICO/cloudflare_logo_icon.ico similarity index 100% rename from source/IMG/ICO/cloudflare_logo_icon.ico rename to source/assets/images/ICO/cloudflare_logo_icon.ico diff --git a/source/IMG/ICO/icon.ico b/source/assets/images/ICO/icon.ico similarity index 100% rename from source/IMG/ICO/icon.ico rename to source/assets/images/ICO/icon.ico diff --git a/source/IMG/ICO/icon.png b/source/assets/images/ICO/icon.png similarity index 100% rename from source/IMG/ICO/icon.png rename to source/assets/images/ICO/icon.png diff --git a/source/IMG/LOGO/gl_head_logo_jp.png b/source/assets/images/LOGO/gl_head_logo_jp.png similarity index 100% rename from source/IMG/LOGO/gl_head_logo_jp.png rename to source/assets/images/LOGO/gl_head_logo_jp.png diff --git a/source/IMG/LOGO/vo01_logo.png b/source/assets/images/LOGO/vo01_logo.png similarity index 100% rename from source/IMG/LOGO/vo01_logo.png rename to source/assets/images/LOGO/vo01_logo.png diff --git a/source/IMG/LOGO/vo02_logo.png b/source/assets/images/LOGO/vo02_logo.png similarity index 100% rename from source/IMG/LOGO/vo02_logo.png rename to source/assets/images/LOGO/vo02_logo.png diff --git a/source/IMG/LOGO/vo03_logo.png b/source/assets/images/LOGO/vo03_logo.png similarity index 100% rename from source/IMG/LOGO/vo03_logo.png rename to source/assets/images/LOGO/vo03_logo.png diff --git a/source/IMG/LOGO/vo04_logo.png b/source/assets/images/LOGO/vo04_logo.png similarity index 100% rename from source/IMG/LOGO/vo04_logo.png rename to source/assets/images/LOGO/vo04_logo.png diff --git a/source/IMG/LOGO/voaf_logo.png b/source/assets/images/LOGO/voaf_logo.png similarity index 100% rename from source/IMG/LOGO/voaf_logo.png rename to source/assets/images/LOGO/voaf_logo.png diff --git a/source/IMG/vol4/vo04_ga01.jpg b/source/assets/images/vol4/vo04_ga01.jpg similarity index 100% rename from source/IMG/vol4/vo04_ga01.jpg rename to source/assets/images/vol4/vo04_ga01.jpg diff --git a/source/IMG/vol4/vo04_ga05.jpg b/source/assets/images/vol4/vo04_ga05.jpg similarity index 100% rename from source/IMG/vol4/vo04_ga05.jpg rename to source/assets/images/vol4/vo04_ga05.jpg diff --git a/source/IMG/vol4/vo04_ga06.jpg b/source/assets/images/vol4/vo04_ga06.jpg similarity index 100% rename from source/IMG/vol4/vo04_ga06.jpg rename to source/assets/images/vol4/vo04_ga06.jpg diff --git a/source/IMG/vol4/vo04_ga07.jpg b/source/assets/images/vol4/vo04_ga07.jpg similarity index 100% rename from source/IMG/vol4/vo04_ga07.jpg rename to source/assets/images/vol4/vo04_ga07.jpg diff --git a/source/ui_manager.py b/source/config/__init__.py similarity index 100% rename from source/ui_manager.py rename to source/config/__init__.py diff --git a/source/data/config.py b/source/config/config.py similarity index 79% rename from source/data/config.py rename to source/config/config.py index c0ae378..645ef2b 100644 --- a/source/data/config.py +++ b/source/config/config.py @@ -4,7 +4,7 @@ import datetime # 配置信息 app_data = { - "APP_VERSION": "1.3.2", + "APP_VERSION": "1.4.0", "APP_NAME": "FRAISEMOE Addons Installer NEXT", "TEMP": "TEMP", "CACHE": "FRAISEMOE", @@ -46,29 +46,51 @@ app_data = { }, } -# Base64解码 -def decode_base64(encoded_str): - return base64.b64decode(encoded_str).decode("utf-8") +def decode_base64(b64str): + """解码base64字符串""" + try: + return base64.b64decode(b64str).decode('utf-8') + except: + return b64str + +# 确保缓存目录存在 +def ensure_cache_dirs(): + os.makedirs(CACHE, exist_ok=True) + os.makedirs(PLUGIN, exist_ok=True) # 全局变量 -APP_VERSION = app_data["APP_VERSION"] APP_NAME = app_data["APP_NAME"] +APP_VERSION = app_data["APP_VERSION"] # 从app_data中获取,不再重复定义 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_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "log") +LOG_LEVEL = "DEBUG" # 可选值: DEBUG, INFO, WARNING, ERROR, CRITICAL + +# 日志文件大小和轮转配置(新增) +LOG_MAX_SIZE = 10 * 1024 * 1024 # 10MB +LOG_BACKUP_COUNT = 3 # 保留3个备份文件 +LOG_RETENTION_DAYS = 7 # 日志保留7天 + # 将log文件放在程序根目录下的log文件夹中,使用日期+时间戳格式命名 root_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) log_dir = os.path.join(root_dir, "log") +os.makedirs(log_dir, exist_ok=True) 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 + +# 资源哈希值 +GAME_INFO = app_data["game_info"] PLUGIN_HASH = { "NEKOPARA Vol.1": GAME_INFO["NEKOPARA Vol.1"]["hash"], "NEKOPARA Vol.2": GAME_INFO["NEKOPARA Vol.2"]["hash"], diff --git a/source/data/privacy_policy.py b/source/config/privacy_policy.py similarity index 88% rename from source/data/privacy_policy.py rename to source/config/privacy_policy.py index 1d45dc4..daeed73 100644 --- a/source/data/privacy_policy.py +++ b/source/config/privacy_policy.py @@ -20,6 +20,7 @@ PRIVACY_POLICY_BRIEF = """ - **系统信息**:程序版本号。 - **网络信息**:IP 地址、ISP、地理位置(用于使用统计)、下载统计、IPv6 连接测试(通过访问 testipv6.cn)、IPv6 地址获取(通过 ipw.cn)。 - **文件信息**:游戏安装路径、文件哈希值。 +- **离线模式**:在离线模式下,本应用不会进行任何网络活动,仅使用本地文件进行安装。为实现此功能,应用会扫描其所在目录下的压缩包文件。 ## 系统修改 - 使用 Cloudflare 加速时会临时修改系统 hosts 文件。 @@ -43,6 +44,7 @@ This application collects and processes the following information: - **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. +- **Offline Mode**: In offline mode, the application will not perform any network activities and will only use local files for installation. To achieve this, the application scans for compressed files in its directory. ## System Modifications - Temporarily modifies system hosts file when using Cloudflare acceleration. @@ -57,7 +59,7 @@ The complete privacy policy can be found in the program's GitHub repository. """ # 默认隐私协议版本 - 本地版本的日期 -PRIVACY_POLICY_VERSION = "2025.08.04" +PRIVACY_POLICY_VERSION = "2025.08.15" def get_local_privacy_policy(): """获取本地打包的隐私协议文件 @@ -87,7 +89,7 @@ def get_local_privacy_policy(): try: date_obj = datetime.strptime(date_str, '%Y年%m月%d日') date_version = date_obj.strftime('%Y.%m.%d') - logger.info(f"成功读取本地隐私协议文件: {path}, 版本: {date_version}") + logger.debug(f"成功读取本地隐私协议文件: {path}, 版本: {date_version}") return content, date_version, "" except ValueError: logger.error(f"本地隐私协议日期格式解析错误: {path}") diff --git a/source/core/__init__.py b/source/core/__init__.py index 0c0afa1..cd178d4 100644 --- a/source/core/__init__.py +++ b/source/core/__init__.py @@ -1,15 +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 .managers.ui_manager import UIManager +from .managers.download_managers 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_managers import DownloadTaskManager +from .managers.patch_detector import PatchDetector +from .managers.animations import MultiStageAnimations +from .handlers.extraction_handler import ExtractionHandler __all__ = [ 'MultiStageAnimations', @@ -23,5 +24,5 @@ __all__ = [ 'PrivacyManager', 'CloudflareOptimizer', 'DownloadTaskManager', - 'ExtractionHandler' + 'PatchDetector', ] \ No newline at end of file diff --git a/source/core/debug_manager.py b/source/core/debug_manager.py deleted file mode 100644 index cc2a924..0000000 --- a/source/core/debug_manager.py +++ /dev/null @@ -1,149 +0,0 @@ -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 \ No newline at end of file diff --git a/source/core/extraction_handler.py b/source/core/extraction_handler.py deleted file mode 100644 index f0d00e8..0000000 --- a/source/core/extraction_handler.py +++ /dev/null @@ -1,89 +0,0 @@ -import os -from PySide6 import QtWidgets -from PySide6.QtWidgets import QMessageBox - - -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 "" - - def start_extraction(self, _7z_path, game_folder, plugin_path, game_version): - """开始解压任务 - - Args: - _7z_path: 7z文件路径 - game_folder: 游戏文件夹路径 - plugin_path: 插件路径 - game_version: 游戏版本名称 - """ - # 检查是否处于离线模式 - is_offline = False - if hasattr(self.main_window, 'offline_mode_manager'): - is_offline = self.main_window.offline_mode_manager.is_in_offline_mode() - - # 显示解压中的消息窗口 - self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window( - check_type="offline_extraction" if is_offline else "extraction", - is_offline=is_offline - ) - - # 创建并启动解压线程 - self.main_window.extraction_thread = self.main_window.create_extraction_thread( - _7z_path, game_folder, plugin_path, game_version - ) - self.main_window.extraction_thread.finished.connect(self.on_extraction_finished) - self.main_window.extraction_thread.start() - - def on_extraction_finished(self, success, error_message, game_version): - """解压完成后的处理 - - Args: - success: 是否解压成功 - error_message: 错误信息 - game_version: 游戏版本 - """ - # 关闭哈希检查窗口 - if self.main_window.hash_msg_box and self.main_window.hash_msg_box.isVisible(): - self.main_window.hash_msg_box.close() - self.main_window.hash_msg_box = 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) - else: - # 更新安装状态 - self.main_window.installed_status[game_version] = True - # 通知DownloadManager继续下一个下载任务 - self.main_window.download_manager.on_extraction_finished(True) \ No newline at end of file diff --git a/source/core/handlers/__init__.py b/source/core/handlers/__init__.py new file mode 100644 index 0000000..e9cd88a --- /dev/null +++ b/source/core/handlers/__init__.py @@ -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', +] \ No newline at end of file diff --git a/source/core/handlers/extraction_handler.py b/source/core/handlers/extraction_handler.py new file mode 100644 index 0000000..3c36520 --- /dev/null +++ b/source/core/handlers/extraction_handler.py @@ -0,0 +1,267 @@ +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 +from workers.extraction_thread import ExtractionThread + +# 初始化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 = ExtractionThread( + _7z_path, game_folder, plugin_path, game_version, self.main_window, 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: + # 用户选择停止,保持窗口启用状态 + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + # 通知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 config.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 config.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.debug(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) \ No newline at end of file diff --git a/source/handlers/patch_toggle_handler.py b/source/core/handlers/patch_toggle_handler.py similarity index 86% rename from source/handlers/patch_toggle_handler.py rename to source/core/handlers/patch_toggle_handler.py index b22761f..eea2c9e 100644 --- a/source/handlers/patch_toggle_handler.py +++ b/source/core/handlers/patch_toggle_handler.py @@ -3,7 +3,7 @@ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QLabel, QListWidget, QPushButton, QHBoxLayout, QAbstractItemView, QRadioButton, QButtonGroup, QFileDialog, QMessageBox ) -from PySide6.QtCore import QObject +from PySide6.QtCore import QObject, Signal, QThread from PySide6.QtGui import QFont from utils import msgbox_frame from utils.logger import setup_logger @@ -11,6 +11,20 @@ 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): """ 处理补丁启用/禁用功能的类 @@ -28,32 +42,59 @@ class PatchToggleHandler(QObject): 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): """ 处理禁/启用补丁按钮点击事件 打开文件选择对话框选择游戏目录,然后禁用或启用对应游戏的补丁 """ - # 获取游戏目录 - debug_mode = self.debug_manager._is_debug_mode() + selected_folder = QFileDialog.getExistingDirectory(self.main_window, "选择游戏上级目录", "") - # 提示用户选择目录 - file_dialog_info = "选择游戏上级目录" if debug_mode else "选择游戏目录" - selected_folder = QFileDialog.getExistingDirectory(self.main_window, file_dialog_info, "") + if not selected_folder: + return + + self.main_window.show_loading_dialog("正在识别游戏目录并检查补丁状态...") - if not selected_folder or selected_folder == "": - return # 用户取消了选择 + 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 debug_mode: - logger.debug(f"DEBUG: 禁/启用功能 - 用户选择了目录: {selected_folder}") + 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) - # 首先尝试将选择的目录视为上级目录,使用增强的目录识别功能 - game_dirs = self.game_detector.identify_game_directories_improved(selected_folder) + if not selected_games: + return - if game_dirs and len(game_dirs) > 0: - self._handle_multiple_games(game_dirs, debug_mode) - else: - self._handle_single_game(selected_folder, debug_mode) + 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, self.debug_manager._is_debug_mode) def _handle_multiple_games(self, game_dirs, debug_mode): """ diff --git a/source/handlers/uninstall_handler.py b/source/core/handlers/uninstall_handler.py similarity index 51% rename from source/handlers/uninstall_handler.py rename to source/core/handlers/uninstall_handler.py index c3669c1..aacc965 100644 --- a/source/handlers/uninstall_handler.py +++ b/source/core/handlers/uninstall_handler.py @@ -3,7 +3,7 @@ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QLabel, QListWidget, QPushButton, QHBoxLayout, QAbstractItemView, QFileDialog, QMessageBox ) -from PySide6.QtCore import QObject +from PySide6.QtCore import QObject, Signal, QThread from PySide6.QtGui import QFont from utils import msgbox_frame from utils.logger import setup_logger @@ -11,6 +11,20 @@ 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): """ 处理补丁卸载功能的类 @@ -28,6 +42,12 @@ class UninstallHandler(QObject): 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): """ @@ -37,23 +57,84 @@ class UninstallHandler(QObject): # 获取游戏目录 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}") + logger.debug(f"卸载功能 - 用户选择了目录: {selected_folder}") - # 首先尝试将选择的目录视为上级目录,使用增强的目录识别功能 - game_dirs = self.game_detector.identify_game_directories_improved(selected_folder) - - if game_dirs and len(game_dirs) > 0: - self._handle_multiple_games(game_dirs, debug_mode) + # 使用UI管理器显示加载对话框 + if hasattr(self.main_window, 'ui_manager'): + self.main_window.ui_manager.show_loading_dialog("正在识别游戏目录...") else: - self._handle_single_game(selected_folder, debug_mode) + logger.warning("无法访问UI管理器,无法显示加载对话框") + + 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): + """游戏识别完成后的回调""" + # 使用UI管理器隐藏加载对话框 + if hasattr(self.main_window, 'ui_manager'): + self.main_window.ui_manager.hide_loading_dialog() + else: + logger.warning("无法访问UI管理器,无法隐藏加载对话框") + + 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): """ @@ -67,15 +148,26 @@ class UninstallHandler(QObject): logger.debug(f"DEBUG: 卸载功能 - 在上级目录中找到以下游戏: {list(game_dirs.keys())}") # 查找已安装补丁的游戏,只处理那些已安装补丁的游戏 + logger.info("检查哪些游戏已安装补丁") games_with_patch = {} for game_version, game_dir in game_dirs.items(): - if self.patch_manager.check_patch_installed(game_dir, game_version): + 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} 已安装补丁") + 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}", @@ -85,16 +177,31 @@ class UninstallHandler(QObject): 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"卸载功能 - 用户选择了以下游戏: {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}", @@ -104,10 +211,26 @@ class UninstallHandler(QObject): ) 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): @@ -122,15 +245,28 @@ class UninstallHandler(QObject): 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}") # 检查是否已安装补丁 - if self.patch_manager.check_patch_installed(selected_folder, 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}", @@ -140,11 +276,37 @@ class UninstallHandler(QObject): ) 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, @@ -154,8 +316,9 @@ class UninstallHandler(QObject): ) else: # 两种方式都未识别到游戏 + logger.info("无法识别游戏") if debug_mode: - logger.debug(f"DEBUG: 卸载功能 - 无法识别游戏") + logger.debug(f"DEBUG: 卸载功能 - 无法识别游戏,显示错误消息") msg_box = msgbox_frame( f"错误 - {self.app_name}", @@ -182,7 +345,7 @@ class UninstallHandler(QObject): # 添加"已安装补丁的游戏"标签 already_installed_label = QLabel("已安装补丁的游戏:", dialog) - already_installed_label.setFont(QFont(already_installed_label.font().family(), already_installed_label.font().pointSize(), QFont.Bold)) + already_installed_label.setFont(QFont(already_installed_label.font().family(), already_installed_label.font().pointSize(), QFont.Weight.Bold)) layout.addWidget(already_installed_label) # 添加已安装游戏列表(可选,这里使用静态标签替代,保持一致性) @@ -195,7 +358,7 @@ class UninstallHandler(QObject): # 添加"请选择要卸载补丁的游戏"标签 info_label = QLabel("请选择要卸载补丁的游戏:", dialog) - info_label.setFont(QFont(info_label.font().family(), info_label.font().pointSize(), QFont.Bold)) + info_label.setFont(QFont(info_label.font().family(), info_label.font().pointSize(), QFont.Weight.Bold)) layout.addWidget(info_label) # 添加列表控件,只显示已安装补丁的游戏 diff --git a/source/core/managers/__init__.py b/source/core/managers/__init__.py new file mode 100644 index 0000000..5bfa374 --- /dev/null +++ b/source/core/managers/__init__.py @@ -0,0 +1,28 @@ +# Managers package initialization +from .ui_manager import UIManager +from .download_managers 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_managers import DownloadTaskManager +from .patch_detector import PatchDetector +from .animations import MultiStageAnimations + +__all__ = [ + 'UIManager', + 'DownloadManager', + 'DebugManager', + 'WindowManager', + 'GameDetector', + 'PatchManager', + 'ConfigManager', + 'PrivacyManager', + 'CloudflareOptimizer', + 'DownloadTaskManager', + 'PatchDetector', + 'MultiStageAnimations', +] \ No newline at end of file diff --git a/source/core/animations.py b/source/core/managers/animations.py similarity index 98% rename from source/core/animations.py rename to source/core/managers/animations.py index 9dd6b8c..190a625 100644 --- a/source/core/animations.py +++ b/source/core/managers/animations.py @@ -176,7 +176,7 @@ class MultiStageAnimations(QObject): widget.setGraphicsEffect(effect) widget.move(-widget.width(), item["end_pos"].y()) widget.show() - print("初始化支持栏动画") + # 初始化支持栏动画,这是内部处理,不需要日志输出 # 初始化菜单元素(底部外) for item in self.menu_widgets: @@ -188,7 +188,7 @@ class MultiStageAnimations(QObject): widget.show() # 禁用所有按钮,直到动画完成 - self.ui.start_install_btn.setEnabled(False) + self.ui.start_install_btn.setEnabled(False) # 动画期间禁用 self.ui.uninstall_btn.setEnabled(False) self.ui.exit_btn.setEnabled(False) @@ -358,7 +358,7 @@ class MultiStageAnimations(QObject): self.clear_animations() # 确保按钮在动画开始时被禁用 - self.ui.start_install_btn.setEnabled(False) + self.ui.start_install_btn.setEnabled(False) # 动画期间禁用 self.ui.uninstall_btn.setEnabled(False) self.ui.exit_btn.setEnabled(False) diff --git a/source/core/cloudflare_optimizer.py b/source/core/managers/cloudflare_optimizer.py similarity index 89% rename from source/core/cloudflare_optimizer.py rename to source/core/managers/cloudflare_optimizer.py index 39b6323..3ede98f 100644 --- a/source/core/cloudflare_optimizer.py +++ b/source/core/managers/cloudflare_optimizer.py @@ -75,35 +75,25 @@ class CloudflareOptimizer: # 解析域名 hostname = urlparse(url).hostname - # 检查hosts文件中是否已有该域名的IP记录 - existing_ips = self.hosts_manager.get_hostname_entries(hostname) if hostname else [] - # 判断是否继续优选的逻辑 - if existing_ips and self.has_optimized_in_session: - # 如果本次会话中已执行过优选且hosts中存在记录,则跳过优选过程 - logger.info(f"发现hosts文件中已有域名 {hostname} 的优选IP记录且本次会话已优选过,跳过优选过程") + if self.has_optimized_in_session: + # 如果本次会话中已执行过优选,则跳过优选过程 + logger.info("本次会话已执行过优选,跳过优选过程") # 设置标记为已优选完成 self.optimization_done = True self.countdown_finished = True - # 尝试获取现有的IPv4和IPv6地址 - ipv4_entries = [ip for ip in existing_ips if ':' not in ip] # IPv4地址不含冒号 - ipv6_entries = [ip for ip in existing_ips if ':' in ip] # IPv6地址包含冒号 - - if ipv4_entries: - self.optimized_ip = ipv4_entries[0] - if ipv6_entries: - self.optimized_ipv6 = ipv6_entries[0] - - logger.info(f"使用已存在的优选IP - IPv4: {self.optimized_ip}, IPv6: {self.optimized_ipv6}") return True else: - # 如果本次会话尚未优选过,或hosts中没有记录,则显示优选窗口 - if existing_ips: - logger.info(f"发现hosts文件中已有域名 {hostname} 的优选IP记录,但本次会话尚未优选过") - # 清理已有的hosts记录,准备重新优选 - self.hosts_manager.clean_hostname_entries(hostname) + # 如果本次会话尚未优选过,则清理可能存在的旧记录 + 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 @@ -122,7 +112,7 @@ class CloudflareOptimizer: ipv6_warning.setIcon(QtWidgets.QMessageBox.Icon.Warning) # 设置图标 - icon_path = resource_path(os.path.join("IMG", "ICO", "icon.png")) + icon_path = resource_path(os.path.join("assets", "images", "ICO", "icon.png")) if os.path.exists(icon_path): pixmap = QPixmap(icon_path) if not pixmap.isNull(): @@ -160,7 +150,7 @@ class CloudflareOptimizer: optimization_msg ) # 设置Cloudflare图标 - cf_icon_path = resource_path("IMG/ICO/cloudflare_logo_icon.ico") + cf_icon_path = resource_path("assets/images/ICO/cloudflare_logo_icon.ico") if os.path.exists(cf_icon_path): cf_pixmap = QPixmap(cf_icon_path) if not cf_pixmap.isNull(): @@ -209,7 +199,8 @@ class CloudflareOptimizer: # 恢复主窗口状态 self.main_window.setEnabled(True) - self.main_window.ui.start_install_text.setText("开始安装") + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) # 显示取消消息 QtWidgets.QMessageBox.information( @@ -282,6 +273,9 @@ class CloudflareOptimizer: 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) @@ -339,7 +333,8 @@ class CloudflareOptimizer: if msg_box.clickedButton() == cancel_button: # 恢复主窗口状态 self.main_window.setEnabled(True) - self.main_window.ui.start_install_text.setText("开始安装") + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) return False # 用户点击了继续,重新禁用主窗口 @@ -376,9 +371,6 @@ class CloudflareOptimizer: from utils import save_config save_config(self.main_window.config) - # 记录本次会话已执行过优选 - self.has_optimized_in_session = True - if success: msg_box = QtWidgets.QMessageBox(self.main_window) msg_box.setWindowTitle(f"成功 - {self.main_window.APP_NAME}") @@ -413,7 +405,8 @@ class CloudflareOptimizer: if msg_box.clickedButton() == cancel_button: # 恢复主窗口状态 self.main_window.setEnabled(True) - self.main_window.ui.start_install_text.setText("开始安装") + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) return False else: QtWidgets.QMessageBox.critical( @@ -422,7 +415,8 @@ class CloudflareOptimizer: "\n修改hosts文件失败,请检查程序是否以管理员权限运行。\n" ) # 恢复主窗口状态 - self.main_window.ui.start_install_text.setText("开始安装") + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) return False # 用户点击了继续,重新禁用主窗口 diff --git a/source/core/config_manager.py b/source/core/managers/config_manager.py similarity index 80% rename from source/core/config_manager.py rename to source/core/managers/config_manager.py index a5647ff..4999d54 100644 --- a/source/core/config_manager.py +++ b/source/core/managers/config_manager.py @@ -210,4 +210,54 @@ class ConfigManager: Returns: str: 错误信息 """ - return self.last_error_message \ No newline at end of file + return self.last_error_message + + def toggle_disable_pre_hash_check(self, main_window, checked): + """切换禁用安装前哈希预检查的状态 + + Args: + main_window: 主窗口实例 + checked: 是否禁用安装前哈希预检查 + + Returns: + bool: 操作是否成功 + """ + try: + # 更新配置 + if hasattr(main_window, 'config'): + main_window.config['disable_pre_hash_check'] = checked + + # 保存配置到文件 + if hasattr(main_window, 'save_config'): + main_window.save_config(main_window.config) + + # 显示成功提示 + status = "禁用" if checked else "启用" + from utils import msgbox_frame + msg_box = msgbox_frame( + f"设置已更新 - {self.app_name}", + f"\n已{status}安装前哈希预检查。\n\n{'安装时将跳过哈希预检查' if checked else '安装时将进行哈希预检查'}。\n", + QMessageBox.StandardButton.Ok, + ) + msg_box.exec() + return True + else: + # 如果配置不可用,显示错误 + from utils import msgbox_frame + msg_box = msgbox_frame( + f"错误 - {self.app_name}", + "\n配置管理器不可用,无法更新设置。\n", + QMessageBox.StandardButton.Ok, + ) + msg_box.exec() + return False + except Exception as e: + # 如果发生异常,显示错误 + from utils import msgbox_frame + msg_box = msgbox_frame( + f"错误 - {self.app_name}", + f"\n更新设置时发生异常:\n\n{str(e)}\n", + QMessageBox.StandardButton.Ok, + ) + msg_box.exec() + return False \ No newline at end of file diff --git a/source/core/managers/debug_manager.py b/source/core/managers/debug_manager.py new file mode 100644 index 0000000..5243b02 --- /dev/null +++ b/source/core/managers/debug_manager.py @@ -0,0 +1,276 @@ +import os +import sys +from PySide6 import QtWidgets +from config.config import LOG_FILE +from utils.logger import setup_logger +from utils import Logger +import datetime +from config.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 config.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.debug(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.debug(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.debug(f"已创建日志文件: {os.path.abspath(LOG_FILE)}") + + # 保存原始的 stdout 并创建Logger实例 + self.original_stdout = sys.stdout + self.logger = Logger(LOG_FILE, self.original_stdout) + + logger.debug(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.debug("--- Debug mode disabled ---") + # 恢复stdout到原始状态 + if hasattr(self, 'original_stdout') and self.original_stdout: + sys.stdout = self.original_stdout + # 关闭日志文件 + if hasattr(self.logger, 'close'): + self.logger.close() + self.logger = None + + def open_log_file(self): + """打开当前日志文件""" + try: + # 检查日志文件是否存在 + if os.path.exists(LOG_FILE): + # 获取日志文件大小 + file_size = os.path.getsize(LOG_FILE) + if file_size == 0: + from utils import msgbox_frame + msg_box = msgbox_frame( + f"提示 - {APP_NAME}", + f"\n当前日志文件 {os.path.basename(LOG_FILE)} 存在但为空。\n\n日志文件位置:{os.path.abspath(LOG_FILE)}", + QtWidgets.QMessageBox.StandardButton.Ok + ) + msg_box.exec() + return + + # 根据文件大小决定是使用文本查看器还是直接打开 + if file_size > 1024 * 1024: # 大于1MB + # 文件较大,显示警告 + from utils import msgbox_frame + msg_box = msgbox_frame( + f"警告 - {APP_NAME}", + f"\n日志文件较大 ({file_size / 1024 / 1024:.2f} MB),是否仍要打开?\n\n日志文件位置:{os.path.abspath(LOG_FILE)}", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No + ) + if msg_box.exec() != QtWidgets.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: + # 文件不存在,显示信息和搜索其他日志文件 + 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) + from utils import msgbox_frame + msg_box = msgbox_frame( + f"信息 - {APP_NAME}", + f"\n日志文件夹不存在,已创建新的日志文件夹:\n{log_dir}\n\n请在启用调试模式后重试。", + QtWidgets.QMessageBox.StandardButton.Ok + ) + msg_box.exec() + return + except Exception as e: + from utils import msgbox_frame + msg_box = msgbox_frame( + f"错误 - {APP_NAME}", + f"\n创建日志文件夹失败:\n\n{str(e)}", + QtWidgets.QMessageBox.StandardButton.Ok + ) + 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: + from utils import msgbox_frame + msg_box = msgbox_frame( + f"错误 - {APP_NAME}", + f"\n无法读取日志文件夹:\n\n{str(e)}", + QtWidgets.QMessageBox.StandardButton.Ok + ) + 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 = "时间未知" + + from utils import msgbox_frame + msg_box = msgbox_frame( + f"信息 - {APP_NAME}", + f"\n当前日志文件 {os.path.basename(LOG_FILE)} 不存在。\n\n" + f"发现最新的日志文件: {os.path.basename(latest_log)}\n" + f"({date_info} {time_info})\n\n" + f"是否打开此文件?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No + ) + + if msg_box.exec() == QtWidgets.QMessageBox.StandardButton.Yes: + if os.name == 'nt': # Windows + os.startfile(latest_log) + else: # macOS 和 Linux + import subprocess + subprocess.call(['xdg-open', latest_log]) + return + + # 如果没有找到任何日志文件 + from utils import msgbox_frame + msg_box = msgbox_frame( + f"信息 - {APP_NAME}", + f"\n没有找到有效的日志文件。\n\n" + f"预期的日志文件夹:{log_dir}\n\n" + f"请确认调试模式已启用,并执行一些操作后再查看日志。", + QtWidgets.QMessageBox.StandardButton.Ok + ) + msg_box.exec() + + except Exception as e: + from utils import msgbox_frame + msg_box = msgbox_frame( + f"错误 - {APP_NAME}", + f"\n处理日志文件时出错:\n\n{str(e)}\n\n文件位置:{os.path.abspath(LOG_FILE)}", + QtWidgets.QMessageBox.StandardButton.Ok + ) + msg_box.exec() \ No newline at end of file diff --git a/source/core/managers/download_managers/__init__.py b/source/core/managers/download_managers/__init__.py new file mode 100644 index 0000000..021b971 --- /dev/null +++ b/source/core/managers/download_managers/__init__.py @@ -0,0 +1,12 @@ +""" +下载管理器模块 +包含下载相关的管理器类 +""" + +from .download_manager import DownloadManager +from .download_task_manager import DownloadTaskManager + +__all__ = [ + 'DownloadManager', + 'DownloadTaskManager', +] \ No newline at end of file diff --git a/source/core/download_manager.py b/source/core/managers/download_managers/download_manager.py similarity index 56% rename from source/core/download_manager.py rename to source/core/managers/download_managers/download_manager.py index 0fdfe82..d04f8d7 100644 --- a/source/core/download_manager.py +++ b/source/core/managers/download_managers/download_manager.py @@ -6,17 +6,22 @@ from urllib.parse import urlparse import re from PySide6 import QtWidgets, QtCore -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QIcon, QPixmap, QFont from PySide6.QtWidgets import QPushButton, QDialog, QHBoxLayout from utils import msgbox_frame, HostsManager, resource_path -from data.config import APP_NAME, PLUGIN, GAME_INFO, UA, CONFIG_URL, DOWNLOAD_THREADS, DEFAULT_DOWNLOAD_THREAD_LEVEL +from config.config import APP_NAME, PLUGIN, GAME_INFO, UA, CONFIG_URL, DOWNLOAD_THREADS, DEFAULT_DOWNLOAD_THREAD_LEVEL from workers import IpOptimizerThread -from core.cloudflare_optimizer import CloudflareOptimizer -from core.download_task_manager import DownloadTaskManager -from core.extraction_handler import ExtractionHandler +from core.managers.cloudflare_optimizer import CloudflareOptimizer +from .download_task_manager import DownloadTaskManager +from core.handlers.extraction_handler import ExtractionHandler from utils.logger import setup_logger +from utils.url_censor import censor_url +from utils.helpers import ( + HashManager, AdminPrivileges, msgbox_frame, HostsManager +) +from workers.download import DownloadThread, ProgressWindow # 初始化logger logger = setup_logger("download_manager") @@ -41,6 +46,12 @@ class DownloadManager: self.download_task_manager = DownloadTaskManager(main_window, self.download_thread_level) self.extraction_handler = ExtractionHandler(main_window) + self.extraction_thread = None + self.progress_window = None + + # 调试管理器 + self.debug_manager = getattr(main_window, 'debug_manager', None) + def file_dialog(self): """显示文件夹选择对话框,选择游戏安装目录""" self.selected_folder = QtWidgets.QFileDialog.getExistingDirectory( @@ -52,7 +63,8 @@ class DownloadManager: ) return - self.main_window.ui.start_install_text.setText("正在安装") + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_INSTALLING) self.main_window.setEnabled(False) @@ -109,13 +121,40 @@ class DownloadManager: logger.debug(f"DEBUG: Parsed JSON data: {json.dumps(safe_config, indent=2)}") urls = {} + missing_urls = [] + + # 检查每个游戏版本的URL for i in range(4): key = f"vol.{i+1}.data" if key in config_data and "url" in config_data[key]: urls[f"vol{i+1}"] = config_data[key]["url"] + else: + missing_urls.append(f"NEKOPARA Vol.{i+1}") + if self.is_debug_mode(): + logger.warning(f"DEBUG: 未找到 NEKOPARA Vol.{i+1} 的下载URL") + # 检查After的URL if "after.data" in config_data and "url" in config_data["after.data"]: urls["after"] = config_data["after.data"]["url"] + else: + missing_urls.append("NEKOPARA After") + if self.is_debug_mode(): + logger.warning(f"DEBUG: 未找到 NEKOPARA After 的下载URL") + + # 如果有缺失的URL,记录详细信息 + if missing_urls: + if self.is_debug_mode(): + logger.warning(f"DEBUG: 以下游戏版本缺少下载URL: {', '.join(missing_urls)}") + logger.warning(f"DEBUG: 当前云端配置中的键: {list(config_data.keys())}") + + # 检查每个游戏数据是否包含url键 + for i in range(4): + key = f"vol.{i+1}.data" + if key in config_data: + logger.warning(f"DEBUG: {key} 内容: {list(config_data[key].keys())}") + + if "after.data" in config_data: + logger.warning(f"DEBUG: after.data 内容: {list(config_data['after.data'].keys())}") if len(urls) != 5: missing_keys_map = { @@ -128,7 +167,17 @@ class DownloadManager: missing_simple_keys = all_keys - extracted_keys missing_original_keys = [missing_keys_map[k] for k in missing_simple_keys] - raise ValueError(f"配置文件缺少必要的键: {', '.join(missing_original_keys)}") + + # 记录详细的缺失信息 + if self.is_debug_mode(): + logger.warning(f"DEBUG: 缺失的URL键: {missing_original_keys}") + + # 如果所有URL都缺失,可能是云端配置问题 + if len(urls) == 0: + raise ValueError(f"配置文件缺少所有下载URL键: {', '.join(missing_original_keys)}") + + # 否则只是部分缺失,可以继续使用已有的URL + logger.warning(f"配置文件缺少部分键: {', '.join(missing_original_keys)}") if self.is_debug_mode(): # 创建安全版本的URL字典用于调试输出 @@ -201,44 +250,65 @@ class DownloadManager: return safe_config def download_action(self): - """开始下载流程""" - self.main_window.download_queue_history = [] - - # 清除游戏检测器的目录缓存,确保获取最新的目录状态 - if hasattr(self.main_window, 'game_detector') and hasattr(self.main_window.game_detector, 'clear_directory_cache'): - self.main_window.game_detector.clear_directory_cache() - if self.is_debug_mode(): - logger.debug("DEBUG: 已清除游戏目录缓存,确保获取最新状态") - - game_dirs = self.main_window.game_detector.identify_game_directories_improved(self.selected_folder) - - debug_mode = self.is_debug_mode() - if debug_mode: - logger.debug(f"DEBUG: 开始下载流程, 识别到 {len(game_dirs)} 个游戏目录") - - if not game_dirs: - if debug_mode: - logger.warning("DEBUG: 未识别到任何游戏目录,设置目录未找到错误") - self.main_window.last_error_message = "directory_not_found" + """下载操作的主入口点""" + if not self.selected_folder: QtWidgets.QMessageBox.warning( - self.main_window, - f"目录错误 - {APP_NAME}", - "\n未能识别到任何游戏目录。\n\n请确认您选择的是游戏的上级目录,并且该目录中包含NEKOPARA系列游戏文件夹。\n" + self.main_window, f"通知 - {APP_NAME}", "\n未选择任何目录,请重新选择\n" ) - self.main_window.setEnabled(True) - self.main_window.ui.start_install_text.setText("开始安装") return - self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="pre", is_offline=False) + # 识别游戏目录 + game_dirs = self.main_window.game_detector.identify_game_directories_improved(self.selected_folder) + + if not game_dirs: + QtWidgets.QMessageBox.warning( + self.main_window, f"通知 - {APP_NAME}", "\n未在选择的目录中找到支持的游戏\n" + ) + self.main_window.setEnabled(True) + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + return + + # 检查是否禁用了安装前哈希预检查 + config = getattr(self.main_window, 'config', {}) + disable_pre_hash = False + if isinstance(config, dict): + disable_pre_hash = config.get("disable_pre_hash_check", False) + + debug_mode = self.is_debug_mode() + + if disable_pre_hash: + if debug_mode: + logger.debug("DEBUG: 哈希预检查已被用户禁用,跳过预检查") + # 直接跳过哈希预检查,进入安装流程 + # 创建一个空的安装状态字典,所有游戏都标记为未安装 + updated_status = {} + for game in game_dirs.keys(): + updated_status[game] = False + + # 直接调用预检查完成的处理方法 + self.on_pre_hash_finished_with_dirs(updated_status, game_dirs) + else: + # 关闭可能存在的哈希校验窗口 + self.main_window.close_hash_msg_box() + + # 显示文件检验窗口 + self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window( + check_type="pre", + auto_close=True, # 添加自动关闭参数 + close_delay=1000 # 1秒后自动关闭 + ) + + # 获取安装路径 + install_paths = self.get_install_paths() + + # 创建并启动哈希线程进行预检查 + self.main_window.hash_thread = self.main_window.patch_detector.create_hash_thread("pre", install_paths) + self.main_window.hash_thread.pre_finished.connect( + lambda updated_status: self.on_pre_hash_finished_with_dirs(updated_status, game_dirs) + ) + self.main_window.hash_thread.start() - install_paths = self.get_install_paths() - - self.main_window.hash_thread = self.main_window.create_hash_thread("pre", install_paths) - self.main_window.hash_thread.pre_finished.connect( - lambda updated_status: self.on_pre_hash_finished_with_dirs(updated_status, game_dirs) - ) - self.main_window.hash_thread.start() - def on_pre_hash_finished_with_dirs(self, updated_status, game_dirs): """优化的哈希预检查完成处理,带有游戏目录信息 @@ -247,44 +317,16 @@ class DownloadManager: 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.close_hash_msg_box() debug_mode = self.is_debug_mode() self.main_window.setEnabled(True) - installable_games = [] - already_installed_games = [] - disabled_patch_games = [] # 存储检测到禁用补丁的游戏 - - for game_version, game_dir in game_dirs.items(): - # 首先通过文件检查确认补丁是否已安装 - is_patch_installed = self.main_window.patch_manager.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.main_window.patch_manager.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) + # 使用patch_detector检测可安装的游戏 + already_installed_games, installable_games, disabled_patch_games = self.main_window.patch_detector.detect_installable_games(game_dirs) status_message = "" if already_installed_games: @@ -328,108 +370,193 @@ class DownloadManager: already_installed_games.append(game_version) else: if debug_mode: - logger.info(f"DEBUG: 用户选择不启用被禁用的补丁,这些游戏将被添加到可安装列表") + logger.debug(f"用户选择不启用被禁用的补丁,这些游戏将被添加到可安装列表") # 用户选择不启用,将这些游戏视为可以安装补丁 installable_games.extend(disabled_patch_games) - - # 更新status_message - if already_installed_games: - status_message = f"已安装补丁的游戏:\n{chr(10).join(already_installed_games)}\n\n" + + # 如果有可安装的游戏,显示选择对话框 + if installable_games: + # 创建游戏选择对话框 + dialog = QtWidgets.QDialog(self.main_window) + dialog.setWindowTitle(f"选择要安装的游戏 - {APP_NAME}") + dialog.setMinimumWidth(400) + dialog.setMinimumHeight(300) - if not installable_games: + layout = QtWidgets.QVBoxLayout() + + # 添加说明标签 + label = QtWidgets.QLabel("请选择要安装的游戏:") + layout.addWidget(label) + + # 添加已安装游戏的状态提示 + if already_installed_games: + installed_label = QtWidgets.QLabel(status_message) + installed_label.setStyleSheet("color: green;") + layout.addWidget(installed_label) + + # 创建列表控件 + list_widget = QtWidgets.QListWidget() + list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.MultiSelection) + + # 添加可安装的游戏 + for game in installable_games: + item = QtWidgets.QListWidgetItem(game) + item.setSelected(True) # 默认全选 + list_widget.addItem(item) + + layout.addWidget(list_widget) + + # 添加全选/取消全选按钮 + select_all_layout = QtWidgets.QHBoxLayout() + select_all_button = QtWidgets.QPushButton("全选") + deselect_all_button = QtWidgets.QPushButton("取消全选") + select_all_layout.addWidget(select_all_button) + select_all_layout.addWidget(deselect_all_button) + select_all_layout.addStretch() # 添加弹性空间,将按钮左对齐 + layout.addLayout(select_all_layout) + + # 添加主要操作按钮 + button_layout = QtWidgets.QHBoxLayout() + ok_button = QtWidgets.QPushButton("确定") + cancel_button = QtWidgets.QPushButton("取消") + button_layout.addWidget(ok_button) + button_layout.addWidget(cancel_button) + layout.addLayout(button_layout) + + dialog.setLayout(layout) + + # 全选功能的实现 + def select_all_items(): + """选择所有游戏项目""" + for i in range(list_widget.count()): + item = list_widget.item(i) + item.setSelected(True) + + def deselect_all_items(): + """取消选择所有游戏项目""" + for i in range(list_widget.count()): + item = list_widget.item(i) + item.setSelected(False) + + # 连接按钮信号 + select_all_button.clicked.connect(select_all_items) + deselect_all_button.clicked.connect(deselect_all_items) + ok_button.clicked.connect(dialog.accept) + cancel_button.clicked.connect(dialog.reject) + + # 显示对话框 + if dialog.exec() == QtWidgets.QDialog.DialogCode.Accepted: + selected_games = [item.text() for item in list_widget.selectedItems()] + if debug_mode: + logger.debug(f"DEBUG: 用户选择了以下游戏进行安装: {selected_games}") + + selected_game_dirs = {game: game_dirs[game] for game in selected_games if game in game_dirs} + + self.main_window.setEnabled(False) + + # 检查是否处于离线模式 + 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() + + if is_offline_mode: + if debug_mode: + logger.debug("使用离线模式,跳过网络配置获取") + self._fill_offline_download_queue(selected_game_dirs) + else: + # 在线模式下,重新获取云端配置 + if hasattr(self.main_window, 'fetch_cloud_config'): + if debug_mode: + logger.debug("重新获取云端配置以确保URL最新") + # 重新获取云端配置并继续下载流程 + from workers.config_fetch_thread import ConfigFetchThread + self.main_window.config_manager.fetch_cloud_config( + ConfigFetchThread, + lambda data, error: self._continue_download_after_config_fetch(data, error, selected_game_dirs) + ) + else: + # 如果无法重新获取配置,使用当前配置 + config = self.get_download_url() + self._continue_download_with_config(config, selected_game_dirs) + else: + if debug_mode: + logger.debug("DEBUG: 用户取消了游戏选择") + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + else: + # 如果没有可安装的游戏,显示提示 + if already_installed_games: + msg = f"所有游戏已安装补丁,无需重复安装。\n\n已安装的游戏:\n{chr(10).join(already_installed_games)}" + else: + msg = "未检测到可安装的游戏。" + QtWidgets.QMessageBox.information( - self.main_window, - f"信息 - {APP_NAME}", - f"\n所有检测到的游戏都已安装补丁。\n\n{status_message}" + self.main_window, + f"通知 - {APP_NAME}", + msg + ) + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + + def _continue_download_after_config_fetch(self, data, error, selected_game_dirs): + """云端配置获取完成后继续下载流程 + + Args: + data: 获取到的配置数据 + error: 错误信息 + selected_game_dirs: 选择的游戏目录 + """ + debug_mode = self.is_debug_mode() + + if error: + if debug_mode: + logger.error(f"DEBUG: 重新获取云端配置失败: {error}") + # 使用当前配置 + config = self.get_download_url() + else: + # 使用新获取的配置 + self.main_window.cloud_config = data + config = self.get_download_url() + + self._continue_download_with_config(config, selected_game_dirs) + + def _continue_download_with_config(self, config, selected_game_dirs): + """使用配置继续下载流程 + + Args: + config: 下载配置 + selected_game_dirs: 选择的游戏目录 + """ + debug_mode = self.is_debug_mode() + + if not config: + QtWidgets.QMessageBox.critical( + self.main_window, f"错误 - {APP_NAME}", "\n网络状态异常或服务器故障,请重试\n" ) self.main_window.setEnabled(True) - self.main_window.ui.start_install_text.setText("开始安装") + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) return - - dialog = QtWidgets.QDialog(self.main_window) - dialog.setWindowTitle("选择要安装的游戏") - dialog.resize(400, 300) - - layout = QtWidgets.QVBoxLayout(dialog) - - if already_installed_games: - already_installed_label = QtWidgets.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) - - already_installed_list = QtWidgets.QLabel(chr(10).join(already_installed_games), dialog) - layout.addWidget(already_installed_list) - - layout.addSpacing(10) - - info_label = QtWidgets.QLabel("请选择你需要安装补丁的游戏:", dialog) - info_label.setFont(QFont(info_label.font().family(), info_label.font().pointSize(), QFont.Weight.Bold)) - layout.addWidget(info_label) - - list_widget = QtWidgets.QListWidget(dialog) - list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) - for game in installable_games: - 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() == []: - self.main_window.setEnabled(True) - self.main_window.ui.start_install_text.setText("开始安装") - return - - selected_games = [item.text() for item in list_widget.selectedItems()] - if debug_mode: - logger.debug(f"DEBUG: 用户选择了以下游戏进行安装: {selected_games}") - - selected_game_dirs = {game: game_dirs[game] for game in selected_games if game in game_dirs} - - self.main_window.setEnabled(False) + self._fill_download_queue(config, selected_game_dirs) + + if not self.download_queue: + # 所有下载任务都已完成,进行后检查 + if debug_mode: + logger.debug("DEBUG: 所有下载任务完成,进行后检查") + # 使用patch_detector进行安装后哈希比较 + self.main_window.patch_detector.after_hash_compare() + return + # 检查是否处于离线模式 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() - if is_offline_mode: - if debug_mode: - logger.info("DEBUG: 使用离线模式,跳过网络配置获取") - self._fill_offline_download_queue(selected_game_dirs) - else: - config = self.get_download_url() - if not config: - QtWidgets.QMessageBox.critical( - self.main_window, f"错误 - {APP_NAME}", "\n网络状态异常或服务器故障,请重试\n" - ) - self.main_window.setEnabled(True) - self.main_window.ui.start_install_text.setText("开始安装") - return - - self._fill_download_queue(config, selected_game_dirs) - - if not self.download_queue: - self.main_window.after_hash_compare() - return - # 如果是离线模式,直接开始下一个下载任务 if is_offline_mode: if debug_mode: - logger.info("DEBUG: 离线模式,跳过Cloudflare优化") + logger.debug("离线模式,跳过Cloudflare优化") self.next_download_task() else: self._show_cloudflare_option() @@ -544,37 +671,25 @@ class DownloadManager: """显示Cloudflare加速选择对话框""" if self.download_queue: first_url = self.download_queue[0][0] - hostname = urlparse(first_url).hostname - if hostname: - existing_ips = self.cloudflare_optimizer.hosts_manager.get_hostname_entries(hostname) + # 直接检查是否本次会话已执行过优选 + if self.cloudflare_optimizer.has_optimized_in_session: + logger.info("本次会话已执行过优选,跳过询问直接使用") + + self.cloudflare_optimizer.optimization_done = True + self.cloudflare_optimizer.countdown_finished = True + + self.main_window.current_url = first_url + self.next_download_task() + return - if existing_ips: - logger.info(f"发现hosts文件中已有域名 {hostname} 的优选IP记录,跳过询问直接使用") - - self.cloudflare_optimizer.optimization_done = True - self.cloudflare_optimizer.countdown_finished = True - - ipv4_entries = [ip for ip in existing_ips if ':' not in ip] - ipv6_entries = [ip for ip in existing_ips if ':' in ip] - - if ipv4_entries: - self.cloudflare_optimizer.optimized_ip = ipv4_entries[0] - if ipv6_entries: - self.cloudflare_optimizer.optimized_ipv6 = ipv6_entries[0] - - self.main_window.current_url = first_url - - self.next_download_task() - return - self.main_window.setEnabled(True) msg_box = QtWidgets.QMessageBox(self.main_window) msg_box.setWindowTitle(f"下载优化 - {APP_NAME}") msg_box.setText("是否愿意通过Cloudflare加速来优化下载速度?\n\n这将临时修改系统的hosts文件,并需要管理员权限。\n如您的杀毒软件提醒有软件正在修改hosts文件,请注意放行。") - cf_icon_path = resource_path("IMG/ICO/cloudflare_logo_icon.ico") + cf_icon_path = resource_path("assets/images/ICO/cloudflare_logo_icon.ico") if os.path.exists(cf_icon_path): cf_pixmap = QPixmap(cf_icon_path) if not cf_pixmap.isNull(): @@ -593,7 +708,8 @@ class DownloadManager: clicked_button = msg_box.clickedButton() if clicked_button == cancel_button: self.main_window.setEnabled(True) - self.main_window.ui.start_install_text.setText("开始安装") + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) self.download_queue.clear() return @@ -619,7 +735,12 @@ class DownloadManager: def next_download_task(self): """处理下载队列中的下一个任务""" if not self.download_queue: - self.main_window.after_hash_compare() + # 所有下载任务都已完成,进行后检查 + debug_mode = self.is_debug_mode() + if debug_mode: + logger.debug("DEBUG: 所有下载任务完成,进行后检查") + # 使用patch_detector进行安装后哈希比较 + self.main_window.patch_detector.after_hash_compare() return if self.download_task_manager.current_download_thread and self.download_task_manager.current_download_thread.isRunning(): @@ -696,10 +817,9 @@ class DownloadManager: if hash_valid: if debug_mode: - logger.info(f"DEBUG: 成功复制并验证补丁文件 {_7z_path}") + logger.debug(f"成功复制并验证补丁文件 {_7z_path}") # 直接进入解压阶段 - self.main_window.extraction_handler.start_extraction(_7z_path, game_folder, plugin_path, game_version) - self.main_window.extraction_handler.extraction_finished.connect(self.on_extraction_finished) + self.extraction_handler.start_extraction(_7z_path, game_folder, plugin_path, game_version) else: if debug_mode: logger.warning(f"DEBUG: 补丁文件哈希验证失败") @@ -735,21 +855,18 @@ class DownloadManager: self.download_task_manager.start_download(url, _7z_path, game_version, game_folder, plugin_path) def on_download_finished(self, success, error, url, game_folder, game_version, _7z_path, plugin_path): - """下载完成后的处理 + """下载完成后的回调函数 Args: success: 是否下载成功 error: 错误信息 url: 下载URL game_folder: 游戏文件夹路径 - game_version: 游戏版本名称 + game_version: 游戏版本 _7z_path: 7z文件保存路径 - plugin_path: 插件路径 + plugin_path: 插件保存路径 """ - if self.main_window.progress_window and self.main_window.progress_window.isVisible(): - self.main_window.progress_window.reject() - self.main_window.progress_window = None - + # 如果下载失败,显示错误并询问是否重试 if not success: logger.error(f"--- Download Failed: {game_version} ---") logger.error(error) @@ -803,9 +920,21 @@ class DownloadManager: else: self.on_download_stopped() return - + + # 下载成功后,直接进入解压阶段 + debug_mode = self.is_debug_mode() + + # 关闭进度窗口 + if hasattr(self.main_window, 'progress_window') and self.main_window.progress_window: + if self.main_window.progress_window.isVisible(): + self.main_window.progress_window.accept() + self.main_window.progress_window = None + + if debug_mode: + logger.debug(f"DEBUG: 下载完成,直接进入解压阶段") + + # 直接进入解压阶段 self.extraction_handler.start_extraction(_7z_path, game_folder, plugin_path, game_version) - self.extraction_handler.extraction_finished.connect(self.on_extraction_finished) def on_extraction_finished(self, continue_download): """解压完成后的回调,决定是否继续下载队列 @@ -835,7 +964,8 @@ class DownloadManager: logger.info("下载已全部停止。") self.main_window.setEnabled(True) - self.main_window.ui.start_install_text.setText("开始安装") + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) QtWidgets.QMessageBox.information( self.main_window, @@ -853,4 +983,206 @@ class DownloadManager: def show_download_thread_settings(self): """显示下载线程设置对话框""" - return self.download_task_manager.show_download_thread_settings() \ No newline at end of file + return self.download_task_manager.show_download_thread_settings() + + def direct_download_action(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 not self.selected_folder: + QtWidgets.QMessageBox.warning( + self.main_window, f"通知 - {APP_NAME}", "\n未选择任何目录,请重新选择\n" + ) + return + + # 识别游戏目录 + game_dirs = self.main_window.game_detector.identify_game_directories_improved(self.selected_folder) + + if not game_dirs: + QtWidgets.QMessageBox.warning( + self.main_window, f"通知 - {APP_NAME}", "\n未在选择的目录中找到支持的游戏\n" + ) + self.main_window.setEnabled(True) + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + return + + # 过滤出存在的游戏目录 + selected_game_dirs = {game: game_dirs[game] for game in games_to_download if game in game_dirs} + + if not selected_game_dirs: + QtWidgets.QMessageBox.warning( + self.main_window, f"通知 - {APP_NAME}", "\n未找到指定游戏的安装目录\n" + ) + self.main_window.setEnabled(True) + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + return + + self.main_window.setEnabled(False) + + # 获取下载配置 + config = self.get_download_url() + if not config: + QtWidgets.QMessageBox.critical( + self.main_window, f"错误 - {APP_NAME}", "\n网络状态异常或服务器故障,请重试\n" + ) + self.main_window.setEnabled(True) + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + return + + # 填充下载队列 + self._fill_direct_download_queue(config, selected_game_dirs) + + if not self.download_queue: + # 所有下载任务都已完成,进行后检查 + if debug_mode: + logger.debug("DEBUG: 所有下载任务完成,进行后检查") + # 使用patch_detector进行安装后哈希比较 + self.main_window.patch_detector.after_hash_compare() + return + + # 显示Cloudflare优化选项 + self._show_cloudflare_option() + + def _fill_direct_download_queue(self, config, game_dirs): + """直接填充下载队列,不检查补丁是否已安装 + + 兼容两种配置格式: + 1) 扁平格式: {"vol1": url, "vol2": url, ..., "after": url} + 2) 原始JSON格式: {"vol.1.data": {"url": url}, ..., "after.data": {"url": url}} + + Args: + config: 包含下载URL的配置字典 + game_dirs: 包含游戏文件夹路径的字典 + """ + self.download_queue.clear() + + if not hasattr(self.main_window, 'download_queue_history'): + self.main_window.download_queue_history = [] + + debug_mode = self.is_debug_mode() + if debug_mode: + logger.debug(f"DEBUG: 直接填充下载队列, 游戏目录: {game_dirs}") + + # 记录要下载的游戏,用于历史记录 + games_to_download = list(game_dirs.keys()) + self.main_window.download_queue_history = games_to_download + + def _extract_url(cfg, simple_key, nested_key): + """从配置中提取URL,优先扁平键,其次原始JSON嵌套键""" + try: + if isinstance(cfg, dict): + # 扁平格式: {"vol1": url} 或 {"after": url} + val = cfg.get(simple_key) + if isinstance(val, str) and val: + return val + # 原始格式: {"vol.1.data": {"url": url}} 或 {"after.data": {"url": url}} + nested = cfg.get(nested_key) + if isinstance(nested, dict) and isinstance(nested.get("url"), str) and nested.get("url"): + return nested.get("url") + except Exception: + pass + return None + + # Vol.1-4 + for i in range(1, 5): + game_version = f"NEKOPARA Vol.{i}" + if game_version in game_dirs: + url = _extract_url(config, f"vol{i}", f"vol.{i}.data") + if url: + game_folder = game_dirs[game_version] + if debug_mode: + logger.debug(f"DEBUG: 添加下载任务 {game_version}: {game_folder}") + + _7z_path = os.path.join(PLUGIN, f"vol.{i}.7z") + plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"]) + self.download_queue.append((url, game_folder, game_version, _7z_path, plugin_path)) + elif debug_mode: + logger.warning(f"DEBUG: 未找到 {game_version} 的下载URL") + + # After + game_version = "NEKOPARA After" + if game_version in game_dirs: + url = _extract_url(config, "after", "after.data") + if url: + game_folder = game_dirs[game_version] + if debug_mode: + logger.debug(f"DEBUG: 添加下载任务 {game_version}: {game_folder}") + + _7z_path = os.path.join(PLUGIN, "after.7z") + plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"]) + self.download_queue.append((url, game_folder, game_version, _7z_path, plugin_path)) + elif debug_mode: + logger.warning(f"DEBUG: 未找到 {game_version} 的下载URL") + + def graceful_stop_threads(self, threads_dict, timeout_ms=2000): + """优雅地停止一组线程. + + Args: + threads_dict (dict): 线程名字和线程对象的字典. + timeout_ms (int): 等待线程自然结束的超时时间. + """ + for name, thread_obj in threads_dict.items(): + if not thread_obj or not hasattr(thread_obj, 'isRunning') or not thread_obj.isRunning(): + continue + + try: + if hasattr(thread_obj, 'requestInterruption'): + thread_obj.requestInterruption() + + if thread_obj.wait(timeout_ms): + if self.debug_manager: + self.debug_manager.log_debug(f"线程 {name} 已优雅停止.") + else: + if self.debug_manager: + self.debug_manager.log_warning(f"线程 {name} 超时,强制终止.") + thread_obj.terminate() + thread_obj.wait(1000) # a short wait after termination + except Exception as e: + if self.debug_manager: + self.debug_manager.log_error(f"停止线程 {name} 时发生错误: {e}") + + def on_game_directories_identified(self, game_dirs): + """当游戏目录识别完成后的回调. + + Args: + game_dirs: 识别到的游戏目录 + """ + self.main_window.ui_manager.hide_loading_dialog() + + if not game_dirs: + self.main_window.setEnabled(True) + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + from PySide6.QtWidgets import QMessageBox + from config.config import APP_NAME + QMessageBox.warning( + self.main_window, + f"目录错误 - {APP_NAME}", + "\n未能识别到任何游戏目录。\n\n请确认您选择的是游戏的上级目录,并且该目录中包含NEKOPARA系列游戏文件夹。\n" + ) + return + + self.main_window.ui_manager.show_loading_dialog("正在检查补丁状态...") + + install_paths = self.get_install_paths() + + # 使用异步方式进行哈希预检查 + self.main_window.pre_hash_thread = self.main_window.patch_detector.create_hash_thread("pre", install_paths) + self.main_window.pre_hash_thread.pre_finished.connect( + lambda updated_status: self.on_pre_hash_finished_with_dirs(updated_status, game_dirs) + ) + # 在线程自然结束时清理引用 + try: + self.main_window.pre_hash_thread.finished.connect(lambda: setattr(self.main_window, 'pre_hash_thread', None)) + except Exception: + pass + self.main_window.pre_hash_thread.start() \ No newline at end of file diff --git a/source/core/download_task_manager.py b/source/core/managers/download_managers/download_task_manager.py similarity index 87% rename from source/core/download_task_manager.py rename to source/core/managers/download_managers/download_task_manager.py index 72ad5ee..0260f23 100644 --- a/source/core/download_task_manager.py +++ b/source/core/managers/download_managers/download_task_manager.py @@ -3,7 +3,9 @@ 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 +from config.config import DOWNLOAD_THREADS +from workers.download import DownloadThread +from utils.logger import setup_logger class DownloadTaskManager: @@ -34,7 +36,7 @@ class DownloadTaskManager: # 按钮在file_dialog中已设置为禁用状态 # 创建并连接下载线程 - self.current_download_thread = self.main_window.create_download_thread(url, _7z_path, game_version) + self.current_download_thread = DownloadThread(url, _7z_path, game_version, self.main_window) 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( @@ -52,7 +54,7 @@ class DownloadTaskManager: 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.main_window.progress_window.pause_resume_button.clicked.connect(self._on_pause_resume_clicked) # 启动线程和显示进度窗口 self.current_download_thread.start() @@ -60,6 +62,8 @@ class DownloadTaskManager: def toggle_download_pause(self): """切换下载的暂停/恢复状态""" + logger = setup_logger("download_task_manager") + logger.debug("执行暂停/恢复下载操作") if not self.current_download_thread: return @@ -111,6 +115,8 @@ class DownloadTaskManager: def show_download_thread_settings(self): """显示下载线程设置对话框""" + logger = setup_logger("download_task_manager") + logger.info("用户打开下载线程数设置对话框") # 创建对话框 dialog = QDialog(self.main_window) dialog.setWindowTitle(f"下载线程设置 - {self.APP_NAME}") @@ -178,6 +184,7 @@ class DownloadTaskManager: break if selected_level: + old_level = self.download_thread_level # 为极速和狂暴模式显示警告 if selected_level in ["extreme", "insane"]: warning_result = QtWidgets.QMessageBox.warning( @@ -193,6 +200,9 @@ class DownloadTaskManager: success = self.set_download_thread_level(selected_level) + logger.info(f"用户修改下载线程数设置: {old_level} -> {selected_level}") + logger.debug(f"对应线程数: {DOWNLOAD_THREADS[old_level]} -> {DOWNLOAD_THREADS[selected_level]}") + if success: # 显示设置成功消息 thread_count = DOWNLOAD_THREADS[selected_level] @@ -214,8 +224,19 @@ class DownloadTaskManager: def stop_download(self): """停止当前下载线程""" + logger = setup_logger("download_task_manager") + logger.info("用户点击停止下载按钮") 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 \ No newline at end of file + return False + + def _on_pause_resume_clicked(self): + """处理暂停/恢复按钮点击""" + logger = setup_logger("download_task_manager") + logger.info("用户点击暂停/恢复下载按钮") + self.toggle_download_pause() + + def toggle_download_pause(self): + """切换下载暂停/恢复状态""" \ No newline at end of file diff --git a/source/core/game_detector.py b/source/core/managers/game_detector.py similarity index 94% rename from source/core/game_detector.py rename to source/core/managers/game_detector.py index 21b9e7b..896e0ba 100644 --- a/source/core/game_detector.py +++ b/source/core/managers/game_detector.py @@ -1,7 +1,21 @@ +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: """游戏检测器,用于识别游戏目录和版本""" @@ -16,6 +30,17 @@ class GameDetector: 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): """检查是否处于调试模式 diff --git a/source/core/ipv6_manager.py b/source/core/managers/ipv6_manager.py similarity index 99% rename from source/core/ipv6_manager.py rename to source/core/managers/ipv6_manager.py index d6e7272..2a6ab85 100644 --- a/source/core/ipv6_manager.py +++ b/source/core/managers/ipv6_manager.py @@ -8,7 +8,7 @@ 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 config.config import APP_NAME from utils import msgbox_frame diff --git a/source/core/managers/offline_mode_manager.py b/source/core/managers/offline_mode_manager.py new file mode 100644 index 0000000..b1ea2e3 --- /dev/null +++ b/source/core/managers/offline_mode_manager.py @@ -0,0 +1,1051 @@ +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 config.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: + # 获取软件所在目录 - 直接使用最简单的方式 + try: + import sys + + if getattr(sys, 'frozen', False): + # 如果是PyInstaller打包的环境,使用可执行文件所在目录 + directory = os.path.dirname(sys.executable) + else: + # 直接取当前工作目录 + directory = os.getcwd() + + # 对于开发环境的特殊处理: + # 如果当前目录路径中包含'source',则可能是在开发模式下从source目录运行 + # 尝试找到项目根目录 + if 'source' in directory: + # 尝试向上一级查找补丁文件 + parent_dir = os.path.dirname(directory) + # 看看父目录是否存在补丁文件 + potential_patches = ["vol.1.7z", "vol.2.7z", "vol.3.7z", "vol.4.7z", "after.7z"] + for patch_file in potential_patches: + if os.path.exists(os.path.join(parent_dir, patch_file)): + # 如果在父目录找到了补丁文件,使用父目录作为扫描目录 + directory = parent_dir + break + + if self._is_debug_mode(): + logger.debug(f"DEBUG: 使用目录 {directory} 扫描离线补丁文件") + current_dir = os.getcwd() + logger.debug(f"DEBUG: 当前工作目录: {current_dir}") + logger.debug(f"DEBUG: 是否为打包环境: {getattr(sys, 'frozen', False)}") + if getattr(sys, 'frozen', False): + logger.debug(f"DEBUG: 可执行文件路径: {sys.executable}") + except Exception as e: + # 如果出现异常,使用当前工作目录 + directory = os.getcwd() + if self._is_debug_mode(): + logger.debug(f"DEBUG: 路径计算出错,使用工作目录: {directory}, 错误: {e}") + + debug_mode = self._is_debug_mode() + + # 记录扫描操作 + logger.debug(f"扫描离线补丁文件,目录: {directory}") + + # 要查找的补丁文件名 + patch_files = ["vol.1.7z", "vol.2.7z", "vol.3.7z", "vol.4.7z", "after.7z"] + + # 只在指定目录中查找,不查找父目录和其他目录 + search_dirs = [directory] + + if debug_mode: + logger.debug(f"DEBUG: 将在以下目录中查找补丁文件: {search_dirs}") + + found_patches = {} + + # 扫描目录查找补丁文件 + try: + # 搜索指定目录 + search_dir = directory + if debug_mode: + logger.debug(f"DEBUG: 正在搜索目录: {search_dir}") + + if not os.path.exists(search_dir): + if debug_mode: + logger.debug(f"DEBUG: 目录不存在,跳过: {search_dir}") + else: + for file in os.listdir(search_dir): + if file.lower() in patch_files: + file_path = os.path.join(search_dir, 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}") + except Exception as e: + logger.error(f"扫描目录时出错: {str(e)}") + + self.offline_patches = found_patches + + # 记录扫描结果 + if found_patches: + logger.info(f"共找到 {len(found_patches)} 个离线补丁文件: {list(found_patches.keys())}") + else: + logger.debug("未找到任何离线补丁文件") + + 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 config.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, 'sync_work_mode_menu_state'): + ui_manager.sync_work_mode_menu_state() + elif hasattr(ui_manager, 'online_mode_action') and hasattr(ui_manager, 'offline_mode_action'): + # 兼容旧版本的直接设置方式 + if ui_manager.online_mode_action and ui_manager.offline_mode_action: + ui_manager.online_mode_action.setChecked(not enabled) + ui_manager.offline_mode_action.setChecked(enabled) + + # 记录离线模式状态变化 + logger.debug(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 config.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 config.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 config.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"]: + # 记录校验失败信息 + logger.error(f"===== {game_version} 哈希校验失败 =====") + logger.error(f"校验失败消息: {result.get('message', '无错误消息')}") + + # 校验失败,删除已解压的文件并提示重新安装 + 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 config.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"])) + logger.info(f"找到安装路径: {install_path}") + + # 记录安装路径的文件信息 + if os.path.exists(install_path): + file_size = os.path.getsize(install_path) + logger.info(f"文件存在,大小: {file_size} 字节") + + # 检查是否为NEKOPARA After,记录签名文件信息 + if game_version == "NEKOPARA After": + sig_path = f"{install_path}.sig" + if os.path.exists(sig_path): + sig_size = os.path.getsize(sig_path) + logger.info(f"签名文件存在: {sig_path}, 大小: {sig_size} 字节") + else: + logger.info(f"签名文件不存在: {sig_path}") + else: + logger.warning(f"文件不存在: {install_path}") + else: + logger.warning(f"未找到 {game_version} 的安装路径") + + # 如果找到安装路径,尝试删除已解压的文件 + if install_path and os.path.exists(install_path): + try: + os.remove(install_path) + logger.debug(f"已删除校验失败的文件: {install_path}") + + # 检查是否为NEKOPARA After,同时删除签名文件 + if game_version == "NEKOPARA After": + sig_path = f"{install_path}.sig" + if os.path.exists(sig_path): + os.remove(sig_path) + logger.debug(f"已删除签名文件: {sig_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 + logger.info(f"===== {game_version} 哈希校验通过 =====") + + # 添加到已安装游戏列表 + 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.debug(f"开始离线安装流程,安装游戏数量: {len(install_tasks)}") + self.process_next_offline_install_task(install_tasks) + return True + else: + if debug_mode: + logger.warning("DEBUG: 没有可安装的游戏,安装流程结束") + + # 如果没有找到任何可安装的游戏,显示一般消息 + msgbox_frame( + f"离线安装信息 - {self.app_name}", + "\n没有可安装的游戏或未找到对应的离线补丁文件。\n", + QMessageBox.StandardButton.Ok + ).exec() + self.main_window.setEnabled(True) + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + + 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.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 = "" + + # 在安装完成后询问用户是否切换到在线模式 + self._show_missing_patches_dialog(installed_msg) + else: + # 恢复UI状态 + self.main_window.setEnabled(True) + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + + 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 + + # 启动解压线程 + from workers.extraction_thread import ExtractionThread + self.extraction_thread = ExtractionThread( + patch_file, game_folder, plugin_path, game_version, self.main_window + ) + + # 连接进度更新到窗口控件 + 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) + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) \ No newline at end of file diff --git a/source/core/managers/patch_detector.py b/source/core/managers/patch_detector.py new file mode 100644 index 0000000..3d0ddd2 --- /dev/null +++ b/source/core/managers/patch_detector.py @@ -0,0 +1,378 @@ +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 config.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 config.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.debug(f"{game_version} 已安装补丁,不需要再次安装") + logger.debug(f"文件检查结果: {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.debug(f"{game_version} 存在被禁用的补丁: {disabled_path}") + disabled_patch_games.append(game_version) + else: + if debug_mode: + logger.debug(f"{game_version} 未安装补丁,可以安装") + logger.debug(f"文件检查结果: {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) + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + 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", + ) + + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + 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(): + if hasattr(self.main_window, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + return + + selected_games = [item.text() for item in list_widget.selectedItems()] + + self.main_window.offline_mode_manager.install_offline_patches(selected_games) \ No newline at end of file diff --git a/source/core/patch_manager.py b/source/core/managers/patch_manager.py similarity index 68% rename from source/core/patch_manager.py rename to source/core/managers/patch_manager.py index 692f87e..00ec5c6 100644 --- a/source/core/patch_manager.py +++ b/source/core/managers/patch_manager.py @@ -3,23 +3,36 @@ import shutil import traceback from PySide6.QtWidgets import QMessageBox from utils.logger import setup_logger +from config.config import APP_NAME +from utils import msgbox_frame class PatchManager: """补丁管理器,用于处理补丁的安装和卸载""" - def __init__(self, app_name, game_info, debug_manager=None): + 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): """检查是否处于调试模式 @@ -73,17 +86,24 @@ class PatchManager: debug_mode = self._is_debug_mode() if debug_mode: - self.logger.debug(f"开始卸载 {game_version} 补丁,目录: {game_dir}") + 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无法识别游戏版本: {game_version}\n", + f"\n{error_msg}\n", QMessageBox.StandardButton.Ok, ) - return False if not silent else {"success": False, "message": f"无法识别游戏版本: {game_version}", "files_removed": 0} + return False if not silent else {"success": False, "message": error_msg, "files_removed": 0} try: files_removed = 0 @@ -92,6 +112,9 @@ class PatchManager: 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, @@ -102,7 +125,7 @@ class PatchManager: ] if debug_mode: - self.logger.debug(f"查找以下可能的补丁文件路径: {patch_files_to_check}") + self.logger.debug(f"DEBUG: 查找以下可能的补丁文件路径: {patch_files_to_check}") # 查找并删除补丁文件,包括启用和禁用的 patch_file_found = False @@ -110,44 +133,68 @@ class PatchManager: # 检查常规补丁文件 if os.path.exists(patch_path): patch_file_found = True + if debug_mode: + self.logger.debug(f"DEBUG: 找到补丁文件: {patch_path},准备删除") + self.logger.debug(f"删除补丁文件: {patch_path}") + os.remove(patch_path) files_removed += 1 if debug_mode: - self.logger.debug(f"已删除补丁文件: {patch_path}") + 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.debug(f"删除被禁用的补丁文件: {disabled_path}") + os.remove(disabled_path) files_removed += 1 if debug_mode: - self.logger.debug(f"已删除被禁用的补丁文件: {disabled_path}") + self.logger.debug(f"DEBUG: 已删除被禁用的补丁文件: {disabled_path}") - if not patch_file_found and debug_mode: - self.logger.debug(f"未找到补丁文件,检查了以下路径: {patch_files_to_check}") - self.logger.debug(f"也检查了禁用的补丁文件(.fain后缀)") + 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.debug(f"删除签名文件: {sig_file_path}") + os.remove(sig_file_path) files_removed += 1 if debug_mode: - self.logger.debug(f"已删除签名文件: {sig_file_path}") + 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.debug(f"删除被禁用补丁的签名文件: {disabled_sig_path}") + os.remove(disabled_sig_path) files_removed += 1 if debug_mode: - self.logger.debug(f"已删除被禁用补丁的签名文件: {disabled_sig_path}") + 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"), @@ -156,12 +203,20 @@ class PatchManager: 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.debug(f"删除补丁文件夹: {patch_folder}") + + import shutil shutil.rmtree(patch_folder) files_removed += 1 if debug_mode: - self.logger.debug(f"已删除补丁文件夹: {patch_folder}") + 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"] @@ -169,12 +224,20 @@ class PatchManager: 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.debug(f"删除game/patch文件夹: {game_patch_folder}") + + import shutil shutil.rmtree(game_patch_folder) files_removed += 1 if debug_mode: - self.logger.debug(f"已删除game/patch文件夹: {game_patch_folder}") + 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"] @@ -185,55 +248,82 @@ class PatchManager: 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.debug(f"删除配置文件: {config_path}") + os.remove(config_path) files_removed += 1 if debug_mode: - self.logger.debug(f"已删除配置文件: {config_path}") + 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.debug(f"删除脚本文件: {script_path}") + os.remove(script_path) files_removed += 1 if debug_mode: - self.logger.debug(f"已删除脚本文件: {script_path}") + 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}", - f"\n{game_version} 补丁卸载成功!\n共删除 {files_removed} 个文件/文件夹。\n", + 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}", - f"\n未找到 {game_version} 的补丁文件,可能未安装补丁或已被移除。\n", + 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"卸载错误 - {str(e)}") - import traceback - self.logger.debug(f"错误详情:\n{traceback.format_exc()}") + self.logger.debug(f"DEBUG: 显示卸载失败消息") QMessageBox.critical( None, @@ -261,16 +351,38 @@ class PatchManager: 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.debug(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"], @@ -280,8 +392,15 @@ class PatchManager: 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, @@ -291,7 +410,12 @@ class PatchManager: except Exception as e: if debug_mode: - self.logger.debug(f"卸载 {version} 时出错: {str(e)}") + 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, @@ -299,7 +423,12 @@ class PatchManager: "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): @@ -310,6 +439,11 @@ class PatchManager: 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" # 如果有详细结果,添加到消息中 @@ -317,11 +451,24 @@ class PatchManager: 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, @@ -331,7 +478,7 @@ class PatchManager: ) def check_patch_installed(self, game_dir, game_version): - """检查游戏是否已安装补丁 + """检查游戏是否已安装补丁(调用patch_detector) Args: game_dir: 游戏目录路径 @@ -340,6 +487,10 @@ class PatchManager: 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: @@ -425,7 +576,7 @@ class PatchManager: return False def check_patch_disabled(self, game_dir, game_version): - """检查游戏的补丁是否已被禁用 + """检查游戏的补丁是否已被禁用(调用patch_detector) Args: game_dir: 游戏目录路径 @@ -435,6 +586,10 @@ class PatchManager: 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: @@ -743,4 +898,86 @@ class PatchManager: 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 ) \ No newline at end of file diff --git a/source/core/privacy_manager.py b/source/core/managers/privacy_manager.py similarity index 91% rename from source/core/privacy_manager.py rename to source/core/managers/privacy_manager.py index 2d16115..7db8dcf 100644 --- a/source/core/privacy_manager.py +++ b/source/core/managers/privacy_manager.py @@ -6,8 +6,8 @@ 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 config.privacy_policy import PRIVACY_POLICY_BRIEF, get_local_privacy_policy, PRIVACY_POLICY_VERSION +from config.config import CACHE, APP_NAME, APP_VERSION from utils import msgbox_frame from utils.logger import setup_logger @@ -18,20 +18,20 @@ class PrivacyManager: """初始化隐私协议管理器""" # 初始化日志 self.logger = setup_logger("privacy_manager") - self.logger.info("正在初始化隐私协议管理器") + self.logger.debug("正在初始化隐私协议管理器") # 确保缓存目录存在 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.logger.debug("读取本地隐私协议文件") 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.logger.debug(f"隐私协议版本: {self.current_privacy_version}") # 检查隐私协议版本和用户同意状态 self.privacy_accepted = self._check_privacy_acceptance() @@ -66,9 +66,9 @@ class PrivacyManager: 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}") + self.logger.debug(f"存储的隐私协议版本: {stored_privacy_version}, 当前版本: {self.current_privacy_version}") + self.logger.debug(f"存储的应用版本: {stored_app_version}, 当前版本: {APP_VERSION}") + self.logger.debug(f"隐私协议接受状态: {privacy_accepted}") # 如果隐私协议版本变更,需要重新同意 if stored_privacy_version != self.current_privacy_version: @@ -125,7 +125,7 @@ class PrivacyManager: """ # 如果用户已经同意了隐私协议,直接返回True不显示对话框 if self.privacy_accepted: - self.logger.info("用户已同意当前版本的隐私协议,无需再次显示") + self.logger.debug("用户已同意当前版本的隐私协议,无需再次显示") return True self.logger.info("首次运行或隐私协议版本变更,显示隐私对话框") diff --git a/source/core/managers/ui_manager.py b/source/core/managers/ui_manager.py new file mode 100644 index 0000000..f913030 --- /dev/null +++ b/source/core/managers/ui_manager.py @@ -0,0 +1,417 @@ +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QMessageBox +import os +import logging +import subprocess + +from utils import load_base64_image, resource_path +from config.config import APP_NAME, APP_VERSION +from ui.components import FontStyleManager, DialogFactory, ExternalLinksHandler, MenuBuilder + +logger = logging.getLogger(__name__) + +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) + + # 获取主窗口的IPv6Manager实例 + self.ipv6_manager = getattr(main_window, 'ipv6_manager', None) + + # 初始化UI组件 + self.font_style_manager = FontStyleManager() + self.dialog_factory = DialogFactory(main_window) + self.external_links_handler = ExternalLinksHandler(main_window, self.dialog_factory) + self.menu_builder = MenuBuilder(main_window, self.font_style_manager, self.external_links_handler, self.dialog_factory) + + # 保留一些快捷访问属性以保持兼容性 + self.debug_action = None + self.disable_auto_restore_action = None + self.disable_pre_hash_action = None + + def setup_ui(self): + """设置UI元素,包括窗口图标、标题和菜单""" + # 设置窗口图标 + icon_path = resource_path(os.path.join("assets", "images", "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.menu_builder.setup_all_menus() + + # 保持对一些重要UI元素的引用以确保兼容性 + self.debug_action = self.menu_builder.debug_action + self.disable_auto_restore_action = self.menu_builder.disable_auto_restore_action + self.disable_pre_hash_action = self.menu_builder.disable_pre_hash_action + + # 保存对工作模式菜单项的引用,确保能正确同步状态 + self.online_mode_action = self.menu_builder.online_mode_action + self.offline_mode_action = self.menu_builder.offline_mode_action + + # 在菜单创建完成后,强制同步一次工作模式状态 + self.sync_work_mode_menu_state() + + # 为了向后兼容性,添加委托方法 + def create_progress_window(self, title, initial_text="准备中..."): + """创建进度窗口(委托给dialog_factory)""" + return self.dialog_factory.create_progress_window(title, initial_text) + + def show_loading_dialog(self, message): + """显示加载对话框(委托给dialog_factory)""" + return self.dialog_factory.show_loading_dialog(message) + + def hide_loading_dialog(self): + """隐藏加载对话框(委托给dialog_factory)""" + return self.dialog_factory.hide_loading_dialog() + + def _create_message_box(self, title, message, buttons=QMessageBox.StandardButton.Ok): + """创建消息框(委托给dialog_factory)""" + return self.dialog_factory.create_message_box(title, message, buttons) + + def show_menu(self, menu, button): + """显示菜单(委托给menu_builder)""" + return self.menu_builder.show_menu(menu, button) + + def sync_work_mode_menu_state(self): + """同步工作模式菜单状态,确保菜单选择状态与实际工作模式一致""" + try: + # 检查是否有离线模式管理器和菜单项 + if not hasattr(self.main_window, 'offline_mode_manager') or not self.main_window.offline_mode_manager: + return + + if not hasattr(self, 'online_mode_action') or not hasattr(self, 'offline_mode_action'): + return + + if not self.online_mode_action or not self.offline_mode_action: + return + + # 获取当前离线模式状态 + is_offline_mode = self.main_window.offline_mode_manager.is_in_offline_mode() + + # 同步菜单选择状态 + self.online_mode_action.setChecked(not is_offline_mode) + self.offline_mode_action.setChecked(is_offline_mode) + + # 记录同步操作(仅在调试模式下) + if hasattr(self.main_window, 'config') and self.main_window.config.get('debug_mode', False): + from utils.logger import setup_logger + logger = setup_logger("ui_manager") + logger.debug(f"已同步工作模式菜单状态: 离线模式={is_offline_mode}") + + except Exception as e: + # 静默处理异常,避免影响程序正常运行 + if hasattr(self.main_window, 'config') and self.main_window.config.get('debug_mode', False): + from utils.logger import setup_logger + logger = setup_logger("ui_manager") + logger.debug(f"同步工作模式菜单状态时出错: {e}") + + + + + + + + + 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_download_thread_settings(self): + """显示下载线程设置对话框""" + if hasattr(self.main_window, 'download_manager'): + self.main_window.download_manager.show_download_thread_settings() + else: + # 如果下载管理器不可用,显示错误信息 + self.dialog_factory.show_simple_message("错误", "\n下载管理器未初始化,无法修改下载线程设置。\n", "error") + + + + 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 _handle_pre_hash_toggle(self, checked): + """处理禁用安装前哈希预检查的切换 + + Args: + checked: 是否禁用安装前哈希预检查 + """ + if hasattr(self.main_window, 'config_manager'): + success = self.main_window.config_manager.toggle_disable_pre_hash_check(self.main_window, checked) + if not success: + # 如果操作失败,恢复复选框状态 + self.disable_pre_hash_action.setChecked(not checked) + else: + # 如果配置管理器不可用,恢复复选框状态并显示错误 + self.disable_pre_hash_action.setChecked(not checked) + self._create_message_box("错误", "\n配置管理器未初始化。\n").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, 'window_manager'): + self.main_window.window_manager.change_window_state(self.main_window.window_manager.STATE_READY) + + # 清除版本警告标志 + 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() + + + \ No newline at end of file diff --git a/source/core/window_manager.py b/source/core/managers/window_manager.py similarity index 75% rename from source/core/window_manager.py rename to source/core/managers/window_manager.py index ae55bc9..d754877 100644 --- a/source/core/window_manager.py +++ b/source/core/managers/window_manager.py @@ -23,7 +23,58 @@ class WindowManager: # 设置圆角窗口 self.setRoundedCorners() - + + # 初始化状态管理 + self._setup_window_state() + + def _setup_window_state(self): + """初始化窗口状态管理.""" + self.STATE_INITIALIZING = "initializing" + self.STATE_READY = "ready" + self.STATE_DOWNLOADING = "downloading" + self.STATE_EXTRACTING = "extracting" + self.STATE_VERIFYING = "verifying" + self.STATE_INSTALLING = "installing" + self.STATE_COMPLETED = "completed" + self.STATE_ERROR = "error" + + self.current_state = self.STATE_INITIALIZING + + def change_window_state(self, new_state, error_message=None): + """更改窗口状态并更新UI. + + Args: + new_state (str): 新的状态. + error_message (str, optional): 错误信息. Defaults to None. + """ + if new_state == self.current_state: + return + + self.current_state = new_state + self._update_ui_for_state(new_state, error_message) + + def _update_ui_for_state(self, state, error_message=None): + """根据当前状态更新UI组件.""" + is_offline = self.window.offline_mode_manager.is_in_offline_mode() + config_valid = self.window.config_valid + + button_enabled = False + button_text = "!无法安装!" + + if state == self.STATE_READY: + if is_offline or config_valid: + button_enabled = True + button_text = "开始安装" + elif state in [self.STATE_DOWNLOADING, self.STATE_EXTRACTING, self.STATE_VERIFYING, self.STATE_INSTALLING]: + button_text = "正在安装" + elif state == self.STATE_COMPLETED: + button_enabled = True + button_text = "安装完成" # Or back to "开始安装" + + self.window.ui.start_install_btn.setEnabled(button_enabled) + self.window.ui.start_install_text.setText(button_text) + self.window.install_button_enabled = button_enabled + def setRoundedCorners(self): """设置窗口圆角""" # 实现圆角窗口 diff --git a/source/core/offline_mode_manager.py b/source/core/offline_mode_manager.py deleted file mode 100644 index 39f9709..0000000 --- a/source/core/offline_mode_manager.py +++ /dev/null @@ -1,702 +0,0 @@ -import os -import hashlib -import shutil -import tempfile -import py7zr -import traceback -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 - - 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() - if debug_mode: - logger.debug(f"DEBUG: 扫描离线补丁文件,目录: {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 - if debug_mode: - logger.debug(f"DEBUG: 找到离线补丁文件: {patch_name} 路径: {file_path}") - - self.offline_patches = found_patches - 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() - 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) - - 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): - """验证补丁文件的哈希值 - - Args: - game_version: 游戏版本名称 - file_path: 补丁压缩包文件路径 - - Returns: - bool: 哈希值是否匹配 - """ - # 获取预期的哈希值 - expected_hash = None - - if "Vol.1" in game_version: - expected_hash = PLUGIN_HASH.get("vol1", "") - elif "Vol.2" in game_version: - expected_hash = PLUGIN_HASH.get("vol2", "") - elif "Vol.3" in game_version: - expected_hash = PLUGIN_HASH.get("vol3", "") - elif "Vol.4" in game_version: - expected_hash = PLUGIN_HASH.get("vol4", "") - elif "After" in game_version: - expected_hash = PLUGIN_HASH.get("after", "") - - 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): - if debug_mode: - logger.warning(f"DEBUG: 补丁文件不存在: {file_path}") - return False - - # 检查文件大小 - file_size = os.path.getsize(file_path) - if debug_mode: - logger.debug(f"DEBUG: 补丁文件大小: {file_size} 字节") - - if file_size == 0: - if debug_mode: - logger.warning(f"DEBUG: 补丁文件大小为0,无效文件") - return False - - # 创建临时目录用于解压文件 - with tempfile.TemporaryDirectory() as temp_dir: - if debug_mode: - logger.debug(f"DEBUG: 创建临时目录: {temp_dir}") - - # 解压补丁文件 - try: - if debug_mode: - logger.debug(f"DEBUG: 开始解压文件: {file_path}") - - with py7zr.SevenZipFile(file_path, mode="r") as archive: - # 获取压缩包内文件列表 - file_list = archive.getnames() - if debug_mode: - logger.debug(f"DEBUG: 压缩包内文件列表: {file_list}") - - # 解压所有文件 - archive.extractall(path=temp_dir) - - if debug_mode: - logger.debug(f"DEBUG: 解压完成") - # 列出解压后的文件 - extracted_files = [] - for root, dirs, files in os.walk(temp_dir): - for file in files: - extracted_files.append(os.path.join(root, file)) - logger.debug(f"DEBUG: 解压后的文件列表: {extracted_files}") - 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()}") - return False - - # 获取补丁文件路径 - patch_file = None - if "Vol.1" in game_version: - patch_file = os.path.join(temp_dir, "vol.1", "adultsonly.xp3") - elif "Vol.2" in game_version: - patch_file = os.path.join(temp_dir, "vol.2", "adultsonly.xp3") - elif "Vol.3" in game_version: - patch_file = os.path.join(temp_dir, "vol.3", "update00.int") - elif "Vol.4" in game_version: - patch_file = os.path.join(temp_dir, "vol.4", "vol4adult.xp3") - elif "After" in game_version: - patch_file = os.path.join(temp_dir, "after", "afteradult.xp3") - - if not patch_file or not os.path.exists(patch_file): - if debug_mode: - logger.warning(f"DEBUG: 未找到解压后的补丁文件: {patch_file}") - # 尝试查找可能的替代文件 - alternative_files = [] - for root, dirs, files in os.walk(temp_dir): - for file in files: - if file.endswith('.xp3') or file.endswith('.int'): - alternative_files.append(os.path.join(root, file)) - if alternative_files: - logger.debug(f"DEBUG: 找到可能的替代文件: {alternative_files}") - - # 检查解压目录结构 - logger.debug(f"DEBUG: 检查解压目录结构:") - for root, dirs, files in os.walk(temp_dir): - logger.debug(f"DEBUG: 目录: {root}") - logger.debug(f"DEBUG: 子目录: {dirs}") - logger.debug(f"DEBUG: 文件: {files}") - 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 '失败'}") - logger.debug(f"DEBUG: 预期哈希值: {expected_hash}") - logger.debug(f"DEBUG: 实际哈希值: {file_hash}") - - return result - except Exception as e: - if debug_mode: - logger.error(f"DEBUG: 计算补丁文件哈希值失败: {e}") - logger.error(f"DEBUG: 错误类型: {type(e).__name__}") - return False - 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()}") - return False - - 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 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.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="pre", is_offline=True) - - # 获取安装路径 - install_paths = self.main_window.download_manager.get_install_paths() - - # 创建并启动哈希线程进行预检查 - self.main_window.hash_thread = self.main_window.create_hash_thread("pre", install_paths) - self.main_window.hash_thread.pre_finished.connect( - lambda updated_status: self.on_offline_pre_hash_finished(updated_status, game_dirs, selected_games) - ) - self.main_window.hash_thread.start() - - return True - - def on_offline_pre_hash_finished(self, updated_status, game_dirs, selected_games): - """离线模式下的哈希预检查完成处理 - - Args: - updated_status: 更新后的安装状态 - game_dirs: 识别到的游戏目录 - selected_games: 用户选择安装的游戏列表 - """ - debug_mode = self._is_debug_mode() - - # 更新安装状态 - 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) - - # 过滤出需要安装的游戏 - installable_games = [] - for game_version in selected_games: - if game_version in game_dirs and not self.main_window.installed_status.get(game_version, False): - # 检查是否有对应的离线补丁 - if self.get_offline_patch_path(game_version): - installable_games.append(game_version) - elif debug_mode: - logger.warning(f"DEBUG: 未找到 {game_version} 的离线补丁文件,跳过") - - if not installable_games: - if debug_mode: - logger.info("DEBUG: 没有需要安装的游戏或未找到对应的离线补丁") - msgbox_frame( - f"离线安装信息 - {self.app_name}", - "\n没有需要安装的游戏或未找到对应的离线补丁文件。\n", - QMessageBox.StandardButton.Ok - ).exec() - self.main_window.ui.start_install_text.setText("开始安装") - return - - # 开始安装流程 - if debug_mode: - logger.info(f"DEBUG: 开始离线安装流程,安装游戏: {installable_games}") - - # 创建安装任务列表 - install_tasks = [] - for game_version in installable_games: - # 获取离线补丁文件路径 - patch_file = self.get_offline_patch_path(game_version) - if not patch_file: - continue - - # 获取游戏目录 - game_folder = game_dirs.get(game_version) - if not game_folder: - 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: - continue - - # 添加到安装任务列表 - install_tasks.append((patch_file, game_folder, game_version, _7z_path, plugin_path)) - - # 开始执行第一个安装任务 - if install_tasks: - self.process_next_offline_install_task(install_tasks) - else: - self.main_window.ui.start_install_text.setText("开始安装") - - 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: 所有离线安装任务完成,进行后检查") - self.main_window.after_hash_compare() - 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}") - - # 确保目标目录存在 - os.makedirs(os.path.dirname(_7z_path), exist_ok=True) - - try: - # 复制补丁文件到缓存目录 - shutil.copy2(patch_file, _7z_path) - - if debug_mode: - logger.debug(f"DEBUG: 已复制补丁文件到缓存目录: {_7z_path}") - logger.debug(f"DEBUG: 开始验证补丁文件哈希值") - - # 获取预期的哈希值 - expected_hash = None - if "Vol.1" in game_version: - expected_hash = PLUGIN_HASH.get("vol1", "") - elif "Vol.2" in game_version: - expected_hash = PLUGIN_HASH.get("vol2", "") - elif "Vol.3" in game_version: - expected_hash = PLUGIN_HASH.get("vol3", "") - elif "Vol.4" in game_version: - expected_hash = PLUGIN_HASH.get("vol4", "") - elif "After" in game_version: - expected_hash = PLUGIN_HASH.get("after", "") - - if debug_mode and expected_hash: - logger.debug(f"DEBUG: 预期哈希值: {expected_hash}") - - # 显示哈希验证窗口 - 使用离线特定消息 - self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="offline_verify", is_offline=True) - - # 验证补丁文件哈希 - hash_valid = self.verify_patch_hash(game_version, _7z_path) - - # 关闭哈希验证窗口 - if self.main_window.hash_msg_box and self.main_window.hash_msg_box.isVisible(): - self.main_window.hash_msg_box.close() - self.main_window.hash_msg_box = None - - if hash_valid: - if debug_mode: - logger.info(f"DEBUG: 补丁文件哈希验证成功,开始解压") - - # 显示解压窗口 - 使用离线特定消息 - self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="offline_extraction", is_offline=True) - - try: - # 创建解压线程 - extraction_thread = self.main_window.create_extraction_thread( - _7z_path, game_folder, plugin_path, game_version - ) - - # 正确连接信号 - extraction_thread.finished.connect( - lambda success, error, game_ver: self.on_extraction_thread_finished( - success, error, game_ver, install_tasks - ) - ) - - # 启动解压线程 - extraction_thread.start() - except Exception as e: - if debug_mode: - logger.error(f"DEBUG: 创建或启动解压线程失败: {e}") - - # 关闭解压窗口 - if self.main_window.hash_msg_box and self.main_window.hash_msg_box.isVisible(): - self.main_window.hash_msg_box.close() - self.main_window.hash_msg_box = 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) - else: - if debug_mode: - logger.warning(f"DEBUG: 补丁文件哈希验证失败") - - # 显示错误消息 - 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) - except Exception as e: - if debug_mode: - logger.error(f"DEBUG: 离线安装任务处理失败: {e}") - - # 显示错误消息 - 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 on_extraction_thread_finished(self, success, error_message, game_version, remaining_tasks): - """解压线程完成后的处理 - - Args: - success: 是否解压成功 - error_message: 错误信息 - game_version: 游戏版本 - remaining_tasks: 剩余的安装任务列表 - """ - debug_mode = self._is_debug_mode() - - # 关闭解压窗口 - if self.main_window.hash_msg_box and self.main_window.hash_msg_box.isVisible(): - self.main_window.hash_msg_box.close() - self.main_window.hash_msg_box = None - - if debug_mode: - logger.debug(f"DEBUG: 离线解压完成,状态: {'成功' if success else '失败'}") - if not success: - logger.error(f"DEBUG: 错误信息: {error_message}") - - if not success: - # 显示错误消息 - msgbox_frame( - f"解压失败 - {self.app_name}", - f"\n{game_version} 的补丁解压失败。\n\n错误信息: {error_message}\n\n跳过此游戏的安装。\n", - QMessageBox.StandardButton.Ok - ).exec() - - # 更新安装状态 - self.main_window.installed_status[game_version] = False - else: - # 更新安装状态 - self.main_window.installed_status[game_version] = True - - # 处理下一个任务 - self.process_next_offline_install_task(remaining_tasks) - - def on_offline_extraction_finished(self, remaining_tasks): - """离线模式下的解压完成处理(旧方法,保留兼容性) - - Args: - remaining_tasks: 剩余的安装任务列表 - """ - debug_mode = self._is_debug_mode() - - if debug_mode: - logger.debug("DEBUG: 离线解压完成,继续处理下一个任务") - - # 处理下一个任务 - self.process_next_offline_install_task(remaining_tasks) \ No newline at end of file diff --git a/source/core/ui_manager.py b/source/core/ui_manager.py deleted file mode 100644 index dd780de..0000000 --- a/source/core/ui_manager.py +++ /dev/null @@ -1,967 +0,0 @@ -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() - - 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""" -

{APP_NAME} v{APP_VERSION}

-

GitHub: https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT

-

原作: Yanam1Anna

-

此应用根据 GPL-3.0 许可证 授权。

-
-

感谢:

-

- HTony03:对原项目部分源码的重构、逻辑优化和功能实现提供了支持。

-

- 钨鸮:对于云端资源存储提供了支持。

-

- XIU2/CloudflareSpeedTest:提供了 IP 优选功能的核心支持。

-

- hosxy/aria2-fast:提供了修改版aria2c,提高了下载速度和性能。

- """ - 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, '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() \ No newline at end of file diff --git a/source/handlers/__init__.py b/source/handlers/__init__.py deleted file mode 100644 index f0bd89c..0000000 --- a/source/handlers/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -handlers包,包含各种处理程序,用于分离主窗口的功能 -""" - -from .patch_toggle_handler import PatchToggleHandler -from .uninstall_handler import UninstallHandler - -__all__ = ['PatchToggleHandler', 'UninstallHandler'] \ No newline at end of file diff --git a/source/main_window.py b/source/main_window.py index a5d7e7c..ca37dd3 100644 --- a/source/main_window.py +++ b/source/main_window.py @@ -4,34 +4,36 @@ import subprocess import shutil import json import webbrowser +import traceback from PySide6 import QtWidgets from PySide6.QtCore import QTimer, Qt, QPoint, QRect, QSize from PySide6.QtWidgets import QMainWindow, QMessageBox, QGraphicsOpacityEffect, QGraphicsColorizeEffect from PySide6.QtGui import QPalette, QColor, QPainterPath, QRegion, QFont -from PySide6.QtGui import QAction # Added for menu actions +from PySide6.QtGui import QAction from ui.Ui_install import Ui_MainWindows -from data.config import ( +from config.config import ( APP_NAME, PLUGIN, GAME_INFO, BLOCK_SIZE, PLUGIN_HASH, UA, CONFIG_URL, LOG_FILE, - DOWNLOAD_THREADS, DEFAULT_DOWNLOAD_THREAD_LEVEL, APP_VERSION # 添加APP_VERSION导入 + DOWNLOAD_THREADS, DEFAULT_DOWNLOAD_THREAD_LEVEL, APP_VERSION ) from utils import ( load_config, save_config, HashManager, AdminPrivileges, msgbox_frame, load_image_from_file ) from workers import ( - DownloadThread, ProgressWindow, IpOptimizerThread, - HashThread, ExtractionThread, ConfigFetchThread + IpOptimizerThread, + HashThread, ConfigFetchThread ) from core import ( MultiStageAnimations, UIManager, DownloadManager, DebugManager, - WindowManager, GameDetector, PatchManager, ConfigManager + WindowManager, GameDetector, PatchManager, ConfigManager, PatchDetector ) -from core.ipv6_manager import IPv6Manager -from handlers import PatchToggleHandler, UninstallHandler +from core.managers.ipv6_manager import IPv6Manager +from core.handlers import PatchToggleHandler, UninstallHandler from utils.logger import setup_logger + # 初始化logger logger = setup_logger("main_window") @@ -39,153 +41,117 @@ class MainWindow(QMainWindow): def __init__(self): super().__init__() - # 设置窗口为无边框 - self.setWindowFlags(Qt.WindowType.FramelessWindowHint) - # 设置窗口背景透明 - self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self._setup_window_properties() + self._init_ui() + self._init_config_and_tools() + self._init_managers() + self._connect_signals() + self._setup_environment() - # 调整窗口大小以适应背景图片比例 (1280x720) + self.download_manager.hosts_manager.backup() + self._setup_debug_mode() + + self.check_and_set_offline_mode() + self.fetch_cloud_config() + self.start_animations() + + def _setup_window_properties(self): + """设置窗口的基本属性,如无边框、透明背景和大小.""" + self.setWindowFlags(Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.resize(1280, 720) - # 设置固定尺寸范围 self.setMinimumSize(QSize(1024, 576)) self.setMaximumSize(QSize(1280, 720)) - - # 初始化UI (从Ui_install.py导入) + + def _init_ui(self): + """初始化UI组件.""" self.ui = Ui_MainWindows() self.ui.setupUi(self) - - # 初始化配置 + + def _init_config_and_tools(self): + """加载配置并初始化核心工具.""" self.config = load_config() - - # 初始化工具类 self.hash_manager = HashManager(BLOCK_SIZE) self.admin_privileges = AdminPrivileges() - - # 初始化各种管理器 - 调整初始化顺序,避免循环依赖 - # 1. 首先创建必要的基础管理器 - self.animator = MultiStageAnimations(self.ui, self) - self.window_manager = WindowManager(self) - self.debug_manager = DebugManager(self) - - # 2. 初始化IPv6Manager(应在UIManager之前) - self.ipv6_manager = IPv6Manager(self) - - # 3. 创建UIManager(依赖IPv6Manager) - self.ui_manager = UIManager(self) - - # 4. 为debug_manager设置ui_manager引用 - self.debug_manager.set_ui_manager(self.ui_manager) - - # 5. 初始化其他管理器 - self.config_manager = ConfigManager(APP_NAME, CONFIG_URL, UA, self.debug_manager) - self.game_detector = GameDetector(GAME_INFO, self.debug_manager) - self.patch_manager = PatchManager(APP_NAME, GAME_INFO, self.debug_manager) - - # 6. 初始化离线模式管理器 - from core.offline_mode_manager import OfflineModeManager - self.offline_mode_manager = OfflineModeManager(self) - - # 7. 初始化下载管理器 - 放在最后,因为它可能依赖于其他管理器 - self.download_manager = DownloadManager(self) - - # 8. 初始化功能处理程序 - self.uninstall_handler = UninstallHandler(self) - self.patch_toggle_handler = PatchToggleHandler(self) - - # 加载用户下载线程设置 - if "download_thread_level" in self.config and self.config["download_thread_level"] in DOWNLOAD_THREADS: - self.download_manager.download_thread_level = self.config["download_thread_level"] + self.patch_detector = PatchDetector(self) # 初始化状态变量 self.cloud_config = None - self.config_valid = False # 添加配置有效标志 - self.patch_manager.initialize_status() - self.installed_status = self.patch_manager.get_status() # 获取初始化后的状态 - self.hash_msg_box = None - self.last_error_message = "" # 添加错误信息记录 - self.version_warning = False # 添加版本警告标志 - self.install_button_enabled = True # 默认启用安装按钮 + self.config_valid = False + self.last_error_message = "" + self.version_warning = False + self.install_button_enabled = True self.progress_window = None + self.pre_hash_thread = None + self.hash_thread = None + self.installed_status = {} + self.hash_msg_box = None - # 设置关闭按钮事件连接 + # 资源验证已移除,素材通过正常加载流程使用 + + def _init_managers(self): + """初始化所有管理器.""" + self.animator = MultiStageAnimations(self.ui, self) + self.window_manager = WindowManager(self) + self.debug_manager = DebugManager(self) + self.ipv6_manager = IPv6Manager(self) + self.ui_manager = UIManager(self) + self.debug_manager.set_ui_manager(self.ui_manager) + self.config_manager = ConfigManager(APP_NAME, CONFIG_URL, UA, self.debug_manager) + self.game_detector = GameDetector(GAME_INFO, self.debug_manager) + self.patch_manager = PatchManager(APP_NAME, GAME_INFO, self.debug_manager, self) + self.patch_manager.set_patch_detector(self.patch_detector) + from core.managers.offline_mode_manager import OfflineModeManager + self.offline_mode_manager = OfflineModeManager(self) + self.download_manager = DownloadManager(self) + self.uninstall_handler = UninstallHandler(self) + self.patch_toggle_handler = PatchToggleHandler(self) + + # Load user's download thread setting + if "download_thread_level" in self.config and self.config["download_thread_level"] in DOWNLOAD_THREADS: + self.download_manager.download_thread_level = self.config["download_thread_level"] + + def _connect_signals(self): + """连接UI组件的信号到相应的槽函数.""" if hasattr(self.ui, 'close_btn'): - self.ui.close_btn.clicked.connect(self.close) - + self.ui.close_btn.clicked.connect(self._on_close_clicked) if hasattr(self.ui, 'minimize_btn'): - self.ui.minimize_btn.clicked.connect(self.showMinimized) + self.ui.minimize_btn.clicked.connect(self._on_minimize_clicked) - # 创建缓存目录 + self.ui.start_install_btn.clicked.connect(self.handle_install_button_click) + self.ui.uninstall_btn.clicked.connect(self.uninstall_handler.handle_uninstall_button_click) + self.ui.toggle_patch_btn.clicked.connect(self.patch_toggle_handler.handle_toggle_patch_button_click) + self.ui.exit_btn.clicked.connect(self.shutdown_app) + + def _setup_environment(self): + """准备应用运行所需的环境,如创建缓存目录和检查权限.""" if not os.path.exists(PLUGIN): try: os.makedirs(PLUGIN) except OSError as e: - QtWidgets.QMessageBox.critical( - self, - f"错误 - {APP_NAME}", - f"\n无法创建缓存位置\n\n使用管理员身份运行或检查文件读写权限\n\n【错误信息】:{e}\n", - ) + QtWidgets.QMessageBox.critical(self, f"错误 - {APP_NAME}", f"无法创建缓存位置: {e}") sys.exit(1) - # 连接信号 - 绑定到新按钮 - self.ui.start_install_btn.clicked.connect(self.handle_install_button_click) - self.ui.uninstall_btn.clicked.connect(self.uninstall_handler.handle_uninstall_button_click) # 使用卸载处理程序 - self.ui.toggle_patch_btn.clicked.connect(self.patch_toggle_handler.handle_toggle_patch_button_click) # 使用补丁切换处理程序 - self.ui.exit_btn.clicked.connect(self.shutdown_app) - - # 初始化按钮状态标记 - self.install_button_enabled = False - self.last_error_message = "" - - # 检查管理员权限和进程 try: - # 检查管理员权限 self.admin_privileges.request_admin_privileges() - # 检查并终止相关进程 self.admin_privileges.check_and_terminate_processes() - except KeyboardInterrupt: - logger.warning("权限检查或进程检查被用户中断") - QtWidgets.QMessageBox.warning( - self, - f"警告 - {APP_NAME}", - "\n操作被中断,请重新启动应用。\n" - ) - sys.exit(1) except Exception as e: - logger.error(f"权限检查或进程检查时发生错误: {e}") - QtWidgets.QMessageBox.critical( - self, - f"错误 - {APP_NAME}", - f"\n权限检查或进程检查时发生错误,请重新启动应用。\n\n【错误信息】:{e}\n" - ) + logger.error(f"权限或进程检查失败: {e}") + QtWidgets.QMessageBox.critical(self, f"错误 - {APP_NAME}", f"权限检查失败: {e}") sys.exit(1) - - # 备份hosts文件 - self.download_manager.hosts_manager.backup() - - # 根据初始配置决定是否开启Debug模式 - if "debug_mode" in self.config and self.config["debug_mode"]: - # 先启用日志系统 + + def _setup_debug_mode(self): + """根据配置设置调试模式.""" + if self.config.get("debug_mode"): self.debug_manager.start_logging() - logger.info("通过配置启动调试模式") - # 检查UI设置 - if hasattr(self.ui_manager, 'debug_action') and self.ui_manager.debug_action: - if self.ui_manager.debug_action.isChecked(): - # 如果通过UI启用了调试模式,确保日志系统已启动 - if not self.debug_manager.logger: - self.debug_manager.start_logging() - logger.info("通过UI启动调试模式") + logger.debug("通过配置启动调试模式") + + if hasattr(self.ui_manager, 'debug_action') and self.ui_manager.debug_action and self.ui_manager.debug_action.isChecked(): + if not self.debug_manager.logger: + self.debug_manager.start_logging() + logger.debug("通过UI启动调试模式") - # 设置UI,包括窗口图标和菜单 self.ui_manager.setup_ui() - - # 检查是否有离线补丁文件,如果有则自动切换到离线模式 - self.check_and_set_offline_mode() - - # 获取云端配置 - self.fetch_cloud_config() - - # 启动动画 - self.start_animations() # 窗口事件处理 - 委托给WindowManager def mousePressEvent(self, event): @@ -225,9 +191,9 @@ class MainWindow(QMainWindow): self.animation_in_progress = False # 启用所有菜单按钮 - self.ui.start_install_btn.setEnabled(True) + # 按钮状态由WindowManager统一管理 self.ui.uninstall_btn.setEnabled(True) - self.ui.toggle_patch_btn.setEnabled(True) # 启用禁/启用补丁按钮 + self.ui.toggle_patch_btn.setEnabled(True) self.ui.exit_btn.setEnabled(True) # 检查是否处于离线模式 @@ -235,35 +201,31 @@ class MainWindow(QMainWindow): if hasattr(self, 'offline_mode_manager'): is_offline_mode = self.offline_mode_manager.is_in_offline_mode() - # 如果是离线模式,始终启用开始安装按钮 - if is_offline_mode: - self.set_start_button_enabled(True) - # 否则,只有在配置有效时才启用开始安装按钮 - elif self.config_valid: - self.set_start_button_enabled(True) + # 根据离线模式和配置状态设置按钮 + if is_offline_mode or self.config_valid: + self.window_manager.change_window_state(self.window_manager.STATE_READY) else: - self.set_start_button_enabled(False) + self.window_manager.change_window_state(self.window_manager.STATE_ERROR) + + # 确保工作模式菜单状态与实际状态同步 + if hasattr(self, 'ui_manager') and hasattr(self.ui_manager, 'sync_work_mode_menu_state'): + self.ui_manager.sync_work_mode_menu_state() def set_start_button_enabled(self, enabled, installing=False): - """[已弃用] 设置按钮启用状态的旧方法,保留以兼容旧代码 + """[过渡方法] 设置按钮状态,将调用委托给WindowManager - 现在推荐使用主窗口的setEnabled方法和直接设置按钮文本 + 这个方法将逐步被淘汰,请使用 self.window_manager.change_window_state() Args: enabled: 是否启用按钮 installing: 是否正在安装中 """ - # 直接设置按钮文本,不改变窗口启用状态 if installing: - self.ui.start_install_text.setText("正在安装") - self.install_button_enabled = False + self.window_manager.change_window_state(self.window_manager.STATE_INSTALLING) + elif enabled: + self.window_manager.change_window_state(self.window_manager.STATE_READY) else: - if enabled: - self.ui.start_install_text.setText("开始安装") - else: - self.ui.start_install_text.setText("!无法安装!") - - self.install_button_enabled = enabled + self.window_manager.change_window_state(self.window_manager.STATE_ERROR) def fetch_cloud_config(self): """获取云端配置(异步方式)""" @@ -282,6 +244,11 @@ class MainWindow(QMainWindow): # 处理返回结果 result = self.config_manager.on_config_fetched(data, error_message) + # 先同步状态 + self.cloud_config = self.config_manager.get_cloud_config() + self.config_valid = self.config_manager.is_config_valid() + self.last_error_message = self.config_manager.get_last_error() + # 根据返回的操作执行相应动作 if result and "action" in result: if result["action"] == "exit": @@ -289,21 +256,16 @@ class MainWindow(QMainWindow): self.shutdown_app(force_exit=True) elif result["action"] == "disable_button": # 禁用开始安装按钮 - self.set_start_button_enabled(False) + self.window_manager.change_window_state(self.window_manager.STATE_ERROR) elif result["action"] == "enable_button": # 启用开始安装按钮 - self.set_start_button_enabled(True) + self.window_manager.change_window_state(self.window_manager.STATE_READY) # 检查是否需要记录版本警告 if "version_warning" in result and result["version_warning"]: self.version_warning = True else: self.version_warning = False - # 同步状态 - self.cloud_config = self.config_manager.get_cloud_config() - self.config_valid = self.config_manager.is_config_valid() - self.last_error_message = self.config_manager.get_last_error() - # 重新启用窗口,恢复用户交互 self.setEnabled(True) @@ -318,170 +280,11 @@ class MainWindow(QMainWindow): def save_config(self, config): """保存配置的便捷方法""" self.config_manager.save_config(config) - - def create_download_thread(self, url, _7z_path, game_version): - """创建下载线程 - - Args: - url: 下载URL - _7z_path: 7z文件保存路径 - game_version: 游戏版本名称 - - Returns: - DownloadThread: 下载线程实例 - """ - from workers import DownloadThread - return DownloadThread(url, _7z_path, game_version, parent=self) - - def create_progress_window(self): - """创建下载进度窗口 - - Returns: - ProgressWindow: 进度窗口实例 - """ - return ProgressWindow(self) - - def create_hash_thread(self, mode, install_paths): - """创建哈希检查线程 - - Args: - mode: 检查模式,"pre"或"after" - install_paths: 安装路径字典 - - Returns: - HashThread: 哈希检查线程实例 - """ - return HashThread(mode, install_paths, PLUGIN_HASH, self.installed_status, self) - - def create_extraction_thread(self, _7z_path, game_folder, plugin_path, game_version): - """创建解压线程 - - Args: - _7z_path: 7z文件路径 - game_folder: 游戏文件夹路径 - plugin_path: 插件路径 - game_version: 游戏版本 - - Returns: - ExtractionThread: 解压线程实例 - """ - return ExtractionThread(_7z_path, game_folder, plugin_path, game_version, self) - - def after_hash_compare(self): - """进行安装后哈希比较""" - # 禁用窗口已在安装流程开始时完成 - - # 检查是否处于离线模式 - is_offline = False - if hasattr(self, 'offline_mode_manager'): - is_offline = self.offline_mode_manager.is_in_offline_mode() - - self.hash_msg_box = self.hash_manager.hash_pop_window(check_type="after", is_offline=is_offline) - - install_paths = self.download_manager.get_install_paths() - - self.hash_thread = self.create_hash_thread("after", install_paths) - self.hash_thread.after_finished.connect(self.on_after_hash_finished) - self.hash_thread.start() - - def on_after_hash_finished(self, result): - """哈希比较完成后的处理 - - Args: - result: 哈希比较结果 - """ - # 确保哈希检查窗口关闭,无论是否还在显示 - if self.hash_msg_box: - try: - if self.hash_msg_box.isVisible(): - self.hash_msg_box.close() - else: - # 如果窗口已经不可见但没有关闭,也要尝试关闭 - self.hash_msg_box.close() - except: - pass # 忽略任何关闭窗口时的错误 - self.hash_msg_box = None - - if not result["passed"]: - # 启用窗口以显示错误消息 - self.setEnabled(True) - - game = result.get("game", "未知游戏") - message = result.get("message", "发生未知错误。") - msg_box = msgbox_frame( - f"文件校验失败 - {APP_NAME}", - message, - QMessageBox.StandardButton.Ok, - ) - msg_box.exec() - - # 恢复窗口状态 - self.setEnabled(True) - self.ui.start_install_text.setText("开始安装") - - # 添加短暂延迟确保UI更新 - QTimer.singleShot(100, self.show_result) def show_result(self): - """显示安装结果,区分不同情况""" - # 获取当前安装状态 - installed_versions = [] # 成功安装的版本 - skipped_versions = [] # 已有补丁跳过的版本 - failed_versions = [] # 安装失败的版本 - not_found_versions = [] # 未找到的版本 + """显示安装结果,调用patch_manager的show_result方法""" + self.patch_manager.show_result() - # 获取所有游戏版本路径 - install_paths = self.download_manager.get_install_paths() if hasattr(self.download_manager, "get_install_paths") else {} - - for game_version, is_installed in self.installed_status.items(): - # 只处理install_paths中存在的游戏版本 - if game_version in install_paths: - path = install_paths[game_version] - - # 检查游戏是否存在但未通过本次安装补丁 - if is_installed: - # 游戏已安装补丁 - if hasattr(self, 'download_queue_history') and game_version not in self.download_queue_history: - # 已有补丁,被跳过下载 - skipped_versions.append(game_version) - else: - # 本次成功安装 - installed_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, - f"安装完成 - {APP_NAME}", - result_text - ) - def closeEvent(self, event): """窗口关闭事件处理 @@ -491,50 +294,38 @@ class MainWindow(QMainWindow): self.shutdown_app(event) def shutdown_app(self, event=None, force_exit=False): - """关闭应用程序 + """关闭应用程序""" + logger = setup_logger("main_window") + logger.info("用户点击退出按钮") + logger.debug("开始关闭应用程序") - Args: - event: 关闭事件,如果是从closeEvent调用的 - force_exit: 是否强制退出 - """ - # 检查是否有动画或任务正在进行 if hasattr(self, 'animation_in_progress') and self.animation_in_progress and not force_exit: - # 如果动画正在进行,阻止退出 if event: event.ignore() return - - # 检查是否有下载任务正在进行 - if hasattr(self.download_manager, 'current_download_thread') and \ - self.download_manager.current_download_thread and \ - self.download_manager.current_download_thread.isRunning() and not force_exit: - # 询问用户是否确认退出 - reply = QMessageBox.question( - self, - f"确认退出 - {APP_NAME}", - "\n下载任务正在进行中,确定要退出吗?\n", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No - ) - if reply == QMessageBox.StandardButton.No: - if event: - event.ignore() - return - - # 恢复hosts文件(如果未禁用自动还原) - self.download_manager.hosts_manager.restore() + + threads_to_stop = { + 'pre_hash': getattr(self, 'pre_hash_thread', None), + 'hash': getattr(self, 'hash_thread', None), + 'offline_hash': getattr(self.offline_mode_manager, 'hash_thread', None), + 'extraction': getattr(self.offline_mode_manager, 'extraction_thread', None), + 'config_fetch': getattr(self.config_manager, 'config_fetch_thread', None), + 'game_detector': getattr(self.game_detector, 'detection_thread', None), + 'patch_check': getattr(self.patch_detector, 'patch_check_thread', None) + } - # 额外检查并清理hosts文件中的残留记录(如果未禁用自动还原) - self.download_manager.hosts_manager.check_and_clean_all_entries() - - # 停止日志记录 + # Add current download thread if it's running + if hasattr(self.download_manager, 'current_download_thread') and self.download_manager.current_download_thread: + threads_to_stop['download'] = self.download_manager.current_download_thread + + self.download_manager.graceful_stop_threads(threads_to_stop) + + self.debug_manager.stop_logging() if not force_exit: reply = QMessageBox.question( - self, - f"确认退出 - {APP_NAME}", - "\n确定要退出吗?\n", + self, f"确认退出 - {APP_NAME}", "\n确定要退出吗?\n", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) @@ -542,8 +333,15 @@ class MainWindow(QMainWindow): if event: event.ignore() return + + # 用户确认退出后,再执行hosts相关操作 + self.download_manager.hosts_manager.restore() + self.download_manager.hosts_manager.check_and_clean_all_entries() + else: + # 强制退出时,也需执行hosts相关操作 + self.download_manager.hosts_manager.restore() + self.download_manager.hosts_manager.check_and_clean_all_entries() - # 退出应用程序 if event: event.accept() else: @@ -553,6 +351,9 @@ class MainWindow(QMainWindow): """处理安装按钮点击事件 根据按钮当前状态决定是显示错误还是执行安装 """ + logger = setup_logger("main_window") + logger.info("用户点击开始安装按钮") + logger.debug("开始处理安装按钮点击事件") # 检查是否处于离线模式 is_offline_mode = False if hasattr(self, 'offline_mode_manager'): @@ -612,103 +413,20 @@ class MainWindow(QMainWindow): # 重试获取配置 self.fetch_cloud_config() else: - # 按钮处于"开始安装"状态,正常执行安装流程 - # 检查是否处于离线模式 - if is_offline_mode: - # 如果是离线模式,使用离线安装流程 - # 先选择游戏目录 + if self.offline_mode_manager.is_in_offline_mode(): self.selected_folder = QtWidgets.QFileDialog.getExistingDirectory( self, f"选择游戏所在【上级目录】 {APP_NAME}" ) if not self.selected_folder: - QtWidgets.QMessageBox.warning( - self, f"通知 - {APP_NAME}", "\n未选择任何目录,请重新选择\n" - ) + QtWidgets.QMessageBox.warning(self, f"通知 - {APP_NAME}", "\n未选择任何目录,请重新选择\n") return - # 保存选择的目录到下载管理器 self.download_manager.selected_folder = self.selected_folder - - # 设置按钮状态 - self.ui.start_install_text.setText("正在安装") + self.ui_manager.show_loading_dialog("正在识别游戏目录...") self.setEnabled(False) - - # 清除游戏检测器的目录缓存 - if hasattr(self, 'game_detector') and hasattr(self.game_detector, 'clear_directory_cache'): - self.game_detector.clear_directory_cache() - - # 识别游戏目录 - game_dirs = self.game_detector.identify_game_directories_improved(self.selected_folder) - - if not game_dirs: - self.last_error_message = "directory_not_found" - QtWidgets.QMessageBox.warning( - self, - f"目录错误 - {APP_NAME}", - "\n未能识别到任何游戏目录。\n\n请确认您选择的是游戏的上级目录,并且该目录中包含NEKOPARA系列游戏文件夹。\n" - ) - self.setEnabled(True) - self.ui.start_install_text.setText("开始安装") - return - - # 显示游戏选择对话框 - dialog = QtWidgets.QDialog(self) - dialog.setWindowTitle("选择要安装的游戏") - dialog.resize(400, 300) - - layout = QtWidgets.QVBoxLayout(dialog) - - # 添加"选择要安装的游戏"标签 - title_label = QtWidgets.QLabel("选择要安装的游戏", dialog) - title_label.setFont(QFont(title_label.font().family(), title_label.font().pointSize(), QFont.Bold)) - layout.addWidget(title_label) - - # 添加游戏列表控件 - list_widget = QtWidgets.QListWidget(dialog) - list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) # 允许多选 - for game_version in game_dirs.keys(): - list_widget.addItem(game_version) - # 默认选中所有项目 - list_widget.item(list_widget.count() - 1).setSelected(True) - layout.addWidget(list_widget) - - # 添加全选按钮 - select_all_btn = QtWidgets.QPushButton("全选", dialog) - select_all_btn.clicked.connect(lambda: list_widget.selectAll()) - layout.addWidget(select_all_btn) - - # 添加确定和取消按钮 - buttons_layout = QtWidgets.QHBoxLayout() - ok_button = QtWidgets.QPushButton("确定", dialog) - cancel_button = QtWidgets.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) - - # 显示对话框 - if dialog.exec() == QtWidgets.QDialog.DialogCode.Accepted: - # 获取选择的游戏 - selected_games = [item.text() for item in list_widget.selectedItems()] - - if selected_games: - # 使用离线模式管理器进行安装 - self.offline_mode_manager.install_offline_patches(selected_games) - else: - QtWidgets.QMessageBox.information( - self, - f"通知 - {APP_NAME}", - "\n未选择任何游戏,安装已取消。\n" - ) - self.setEnabled(True) - self.ui.start_install_text.setText("开始安装") - else: - # 用户取消了选择 - self.setEnabled(True) - self.ui.start_install_text.setText("开始安装") + + # 异步识别游戏目录 + self.game_detector.identify_game_directories_async(self.selected_folder, self.on_game_directories_identified) else: # 在线模式下,检查版本是否过低 if hasattr(self, 'version_warning') and self.version_warning: @@ -723,6 +441,42 @@ class MainWindow(QMainWindow): # 版本正常,使用原有的下载流程 self.download_manager.file_dialog() + def on_game_directories_identified(self, game_dirs): + self.ui_manager.hide_loading_dialog() + + if not game_dirs: + self.setEnabled(True) + self.window_manager.change_window_state(self.window_manager.STATE_READY) + QtWidgets.QMessageBox.warning( + self, + f"目录错误 - {APP_NAME}", + "\n未能识别到任何游戏目录。\n\n请确认您选择的是游戏的上级目录,并且该目录中包含NEKOPARA系列游戏文件夹。\n" + ) + return + + self.ui_manager.show_loading_dialog("正在检查补丁状态...") + + install_paths = self.download_manager.get_install_paths() + + # 使用异步方式进行哈希预检查 + 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) + ) + # 在线程自然结束时清理引用 + 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): + self.ui_manager.hide_loading_dialog() + self.setEnabled(True) + self.patch_detector.on_offline_pre_hash_finished(updated_status, game_dirs) + + + def check_and_set_offline_mode(self): """检查是否有离线补丁文件,如果有则自动启用离线模式 @@ -732,9 +486,32 @@ class MainWindow(QMainWindow): try: # 初始化离线模式管理器 if not hasattr(self, 'offline_mode_manager') or self.offline_mode_manager is None: - from core.offline_mode_manager import OfflineModeManager + from core.managers.offline_mode_manager import OfflineModeManager self.offline_mode_manager = OfflineModeManager(self) + # 在调试模式下记录当前执行路径 + is_debug_mode = self.config.get('debug_mode', False) if hasattr(self, 'config') else False + if is_debug_mode: + import os + import sys + current_dir = os.getcwd() + logger.debug(f"DEBUG: 当前工作目录: {current_dir}") + logger.debug(f"DEBUG: 是否为打包环境: {getattr(sys, 'frozen', False)}") + if getattr(sys, 'frozen', False): + logger.debug(f"DEBUG: 可执行文件路径: {sys.executable}") + + # 尝试列出当前目录中的文件(调试用) + try: + files = os.listdir(current_dir) + logger.debug(f"DEBUG: 当前目录文件列表: {files}") + + # 检查上级目录 + parent_dir = os.path.dirname(current_dir) + parent_files = os.listdir(parent_dir) + logger.debug(f"DEBUG: 上级目录 {parent_dir} 文件列表: {parent_files}") + except Exception as e: + logger.debug(f"DEBUG: 列出目录文件时出错: {str(e)}") + # 扫描离线补丁文件 self.offline_mode_manager.scan_for_offline_patches() @@ -745,8 +522,20 @@ class MainWindow(QMainWindow): # 启用开始安装按钮 self.set_start_button_enabled(True) - logger.debug(f"DEBUG: 已自动切换到离线模式,找到离线补丁文件: {list(self.offline_mode_manager.offline_patches.keys())}") + # 记录日志 + found_patches = list(self.offline_mode_manager.offline_patches.keys()) + logger.debug(f"DEBUG: 已自动切换到离线模式,找到离线补丁文件: {found_patches}") + logger.info(f"发现离线补丁文件: {found_patches},将自动切换到离线模式") logger.debug(f"DEBUG: 离线模式下启用开始安装按钮") + + # 显示提示弹窗 + from PySide6.QtWidgets import QMessageBox + QMessageBox.information( + self, + f"离线模式提示 - {APP_NAME}", + f"已找到本地补丁,将主动转为离线模式。\n\n检测到的补丁文件: {', '.join(found_patches)}" + ) + return True else: # 如果没有找到离线补丁文件,禁用离线模式 @@ -769,8 +558,60 @@ class MainWindow(QMainWindow): self.set_start_button_enabled(False) logger.error(f"错误: 检查离线模式时发生异常: {e}") + logger.error(f"错误详情: {traceback.format_exc()}") return False + def close_hash_msg_box(self): + """关闭哈希校验窗口,确保在创建新窗口前关闭旧窗口""" + if hasattr(self, 'hash_msg_box') and self.hash_msg_box: + try: + if self.hash_msg_box.isVisible(): + self.hash_msg_box.close() + QtWidgets.QApplication.processEvents() # 确保UI更新,窗口真正关闭 + except Exception as e: + logger.error(f"关闭哈希校验窗口时发生错误: {e}") + self.hash_msg_box = None + def create_progress_window(self, title="下载进度", initial_text="准备中..."): + """创建一个用于显示下载进度的窗口 + + Args: + title (str): 窗口标题,默认为"下载进度" + initial_text (str): 初始状态文本,默认为"准备中..." + + Returns: + 进度窗口实例 + """ + return self.ui_manager.create_progress_window(title, initial_text) - \ No newline at end of file + def create_extraction_progress_window(self): + """创建一个用于显示解压进度的窗口 + + Returns: + 解压进度窗口实例 + """ + return self.ui_manager.create_progress_window("解压进度", "正在准备解压...") + + def show_loading_dialog(self, message): + """显示加载对话框 + + Args: + message: 要显示的消息 + """ + self.ui_manager.show_loading_dialog(message) + + def _on_close_clicked(self): + """处理关闭按钮点击""" + logger = setup_logger("main_window") + logger.info("用户点击关闭按钮") + self.close() + + def _on_minimize_clicked(self): + """处理最小化按钮点击""" + logger = setup_logger("main_window") + logger.info("用户点击最小化按钮") + self.showMinimized() + + def hide_loading_dialog(self): + """隐藏加载对话框""" + self.ui_manager.hide_loading_dialog() \ No newline at end of file diff --git a/source/ui/Ui_install.py b/source/ui/Ui_install.py index 918f6a7..626b0b1 100644 --- a/source/ui/Ui_install.py +++ b/source/ui/Ui_install.py @@ -11,10 +11,14 @@ from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient, from PySide6.QtWidgets import (QApplication, QLabel, QMainWindow, QMenu, QMenuBar, QPushButton, QSizePolicy, QWidget, QHBoxLayout) import os +import logging + +# 初始化日志记录器 +logger = logging.getLogger(__name__) # 导入配置常量 -from data.config import APP_NAME, APP_VERSION -from utils import load_image_from_file +from config.config import APP_NAME, APP_VERSION +from utils import load_image_from_file, resource_path class Ui_MainWindows(object): def setupUi(self, MainWindows): @@ -38,8 +42,21 @@ class Ui_MainWindows(object): MainWindows.setDockNestingEnabled(False) # 加载自定义字体 - font_id = QFontDatabase.addApplicationFont(os.path.join(os.path.dirname(os.path.dirname(__file__)), "fonts", "SmileySans-Oblique.ttf")) - font_family = QFontDatabase.applicationFontFamilies(font_id)[0] if font_id != -1 else "Arial" + font_path = resource_path(os.path.join("assets", "fonts", "SmileySans-Oblique.ttf")) + logger.info(f"尝试加载字体文件: {font_path}") + font_id = QFontDatabase.addApplicationFont(font_path) + if font_id != -1: + font_families = QFontDatabase.applicationFontFamilies(font_id) + if font_families: + font_family = font_families[0] + logger.info(f"成功加载字体: {font_family} 从 {font_path}") + else: + logger.warning(f"字体加载成功但无法获取字体族: {font_path}") + font_family = "Arial" + else: + logger.error(f"字体加载失败: {font_path}") + font_family = "Arial" + self.custom_font = QFont(font_family, 16) # 创建字体对象,大小为16 self.custom_font.setWeight(QFont.Weight.Medium) # 设置为中等粗细,不要太粗 @@ -299,8 +316,11 @@ class Ui_MainWindows(object): self.loadbg.setObjectName(u"loadbg") self.loadbg.setGeometry(QRect(0, 0, 1280, 655)) # 加载背景图并允许拉伸 - bg_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "BG", "bg1.jpg") + bg_path = resource_path(os.path.join("assets", "images", "BG", "bg1.jpg")) + logger.info(f"加载背景图: {bg_path}") bg_pixmap = QPixmap(bg_path) + if bg_pixmap.isNull(): + logger.error(f"背景图加载失败: {bg_path}") self.loadbg.setPixmap(bg_pixmap) self.loadbg.setScaledContents(True) @@ -308,7 +328,8 @@ class Ui_MainWindows(object): self.vol1bg.setObjectName(u"vol1bg") self.vol1bg.setGeometry(QRect(0, 150, 93, 64)) # 直接加载图片文件 - vol1_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "LOGO", "vo01_logo.png") + vol1_path = resource_path(os.path.join("assets", "images", "LOGO", "vo01_logo.png")) + logger.info(f"加载LOGO图: {vol1_path}") self.vol1bg.setPixmap(QPixmap(vol1_path)) self.vol1bg.setScaledContents(True) @@ -316,7 +337,7 @@ class Ui_MainWindows(object): self.vol2bg.setObjectName(u"vol2bg") self.vol2bg.setGeometry(QRect(0, 210, 93, 64)) # 直接加载图片文件 - vol2_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "LOGO", "vo02_logo.png") + vol2_path = resource_path(os.path.join("assets", "images", "LOGO", "vo02_logo.png")) self.vol2bg.setPixmap(QPixmap(vol2_path)) self.vol2bg.setScaledContents(True) @@ -324,7 +345,7 @@ class Ui_MainWindows(object): self.vol3bg.setObjectName(u"vol3bg") self.vol3bg.setGeometry(QRect(0, 270, 93, 64)) # 直接加载图片文件 - vol3_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "LOGO", "vo03_logo.png") + vol3_path = resource_path(os.path.join("assets", "images", "LOGO", "vo03_logo.png")) self.vol3bg.setPixmap(QPixmap(vol3_path)) self.vol3bg.setScaledContents(True) @@ -332,7 +353,7 @@ class Ui_MainWindows(object): self.vol4bg.setObjectName(u"vol4bg") self.vol4bg.setGeometry(QRect(0, 330, 93, 64)) # 直接加载图片文件 - vol4_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "LOGO", "vo04_logo.png") + vol4_path = resource_path(os.path.join("assets", "images", "LOGO", "vo04_logo.png")) self.vol4bg.setPixmap(QPixmap(vol4_path)) self.vol4bg.setScaledContents(True) @@ -340,7 +361,7 @@ class Ui_MainWindows(object): self.afterbg.setObjectName(u"afterbg") self.afterbg.setGeometry(QRect(0, 390, 93, 64)) # 直接加载图片文件 - after_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "LOGO", "voaf_logo.png") + after_path = resource_path(os.path.join("assets", "images", "LOGO", "voaf_logo.png")) self.afterbg.setPixmap(QPixmap(after_path)) self.afterbg.setScaledContents(True) @@ -349,8 +370,12 @@ class Ui_MainWindows(object): self.Mainbg.setObjectName(u"Mainbg") self.Mainbg.setGeometry(QRect(0, 0, 1280, 655)) # 允许拉伸以填满整个区域 - main_bg_pixmap = load_image_from_file(os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "BG", "title_bg1.png")) - + main_bg_path = resource_path(os.path.join("assets", "images", "BG", "title_bg1.png")) + logger.info(f"加载主背景图: {main_bg_path}") + main_bg_pixmap = QPixmap(main_bg_path) + if main_bg_pixmap.isNull(): + logger.error(f"主背景图加载失败: {main_bg_path}") + # 如果加载的图片不是空的,则设置,并允许拉伸填满 if not main_bg_pixmap.isNull(): self.Mainbg.setPixmap(main_bg_pixmap) @@ -358,7 +383,11 @@ class Ui_MainWindows(object): self.Mainbg.setAlignment(Qt.AlignmentFlag.AlignCenter) # 使用新的按钮图片 - button_pixmap = load_image_from_file(os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "BTN", "Button.png")) + button_path = resource_path(os.path.join("assets", "images", "BTN", "Button.png")) + logger.info(f"加载按钮图片: {button_path}") + button_pixmap = QPixmap(button_path) + if button_pixmap.isNull(): + logger.error(f"按钮图片加载失败: {button_path}") # 创建文本标签布局的按钮 # 开始安装按钮 - 基于背景图片和标签组合 diff --git a/source/ui/components/__init__.py b/source/ui/components/__init__.py new file mode 100644 index 0000000..faefaf8 --- /dev/null +++ b/source/ui/components/__init__.py @@ -0,0 +1,16 @@ +""" +UI组件模块 +提供各种UI组件类用于构建应用程序界面 +""" + +from .font_style_manager import FontStyleManager +from .dialog_factory import DialogFactory +from .external_links_handler import ExternalLinksHandler +from .menu_builder import MenuBuilder + +__all__ = [ + 'FontStyleManager', + 'DialogFactory', + 'ExternalLinksHandler', + 'MenuBuilder' +] \ No newline at end of file diff --git a/source/ui/components/dialog_factory.py b/source/ui/components/dialog_factory.py new file mode 100644 index 0000000..1f23ca4 --- /dev/null +++ b/source/ui/components/dialog_factory.py @@ -0,0 +1,147 @@ +""" +对话框工厂 +负责创建和管理各种类型的对话框 +""" + +from PySide6.QtWidgets import QMessageBox, QDialog, QVBoxLayout, QProgressBar, QLabel, QApplication +from PySide6.QtCore import Qt + +from utils import msgbox_frame +from config.config import APP_NAME +from workers.download import ProgressWindow + + +class DialogFactory: + """对话框工厂类""" + + def __init__(self, main_window): + """初始化对话框工厂 + + Args: + main_window: 主窗口实例 + """ + self.main_window = main_window + self.loading_dialog = None + + 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 create_progress_window(self, title, initial_text="准备中..."): + """创建并返回一个通用的进度窗口 + + Args: + title (str): 窗口标题 + initial_text (str): 初始状态文本 + + Returns: + QDialog: 配置好的进度窗口实例 + """ + # 如果是下载进度窗口,使用专用的ProgressWindow类 + if "下载" in title: + return ProgressWindow(self.main_window) + + # 其他情况使用基本的进度窗口 + progress_window = QDialog(self.main_window) + progress_window.setWindowTitle(f"{title} - {APP_NAME}") + progress_window.setFixedSize(400, 150) + + layout = QVBoxLayout() + + progress_bar = QProgressBar() + progress_bar.setRange(0, 100) + progress_bar.setValue(0) + layout.addWidget(progress_bar) + + status_label = QLabel(initial_text) + layout.addWidget(status_label) + + progress_window.setLayout(layout) + # 将控件附加到窗口对象上,以便外部访问 + progress_window.progress_bar = progress_bar + progress_window.status_label = status_label + + return progress_window + + def show_loading_dialog(self, message): + """显示或更新加载对话框 + + Args: + message: 要显示的加载消息 + """ + if not self.loading_dialog: + self.loading_dialog = QDialog(self.main_window) + self.loading_dialog.setWindowTitle(f"请稍候 - {APP_NAME}") + self.loading_dialog.setFixedSize(300, 100) + self.loading_dialog.setModal(True) + layout = QVBoxLayout() + loading_label = QLabel(message) + loading_label.setAlignment(Qt.AlignCenter) + layout.addWidget(loading_label) + self.loading_dialog.setLayout(layout) + # 将label附加到dialog,方便后续更新 + self.loading_dialog.loading_label = loading_label + else: + self.loading_dialog.loading_label.setText(message) + + self.loading_dialog.show() + # 强制UI更新 + QApplication.processEvents() + + def hide_loading_dialog(self): + """隐藏并销毁加载对话框""" + if self.loading_dialog: + self.loading_dialog.hide() + self.loading_dialog = None + + def show_simple_message(self, title, message, message_type="info"): + """显示简单的消息提示 + + Args: + title: 标题 + message: 消息内容 + message_type: 消息类型,可选 "info", "warning", "error", "question" + """ + if message_type == "question": + buttons = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + else: + buttons = QMessageBox.StandardButton.Ok + + msg_box = self.create_message_box(title, message, buttons) + + if message_type == "question": + return msg_box.exec() + else: + msg_box.exec() + return None + + def show_confirmation_dialog(self, title, message): + """显示确认对话框 + + Args: + title: 标题 + message: 消息内容 + + Returns: + bool: 用户是否选择了确认 + """ + msg_box = self.create_message_box( + title, + message, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + return msg_box.exec() == QMessageBox.StandardButton.Yes \ No newline at end of file diff --git a/source/ui/components/external_links_handler.py b/source/ui/components/external_links_handler.py new file mode 100644 index 0000000..60ee623 --- /dev/null +++ b/source/ui/components/external_links_handler.py @@ -0,0 +1,145 @@ +""" +外部链接处理器 +负责处理所有外部链接打开和关于信息显示 +""" + +import webbrowser +import locale +import sys +import subprocess +import os +from PySide6.QtWidgets import QMessageBox +from PySide6.QtCore import Qt + +from config.config import APP_NAME, APP_VERSION +from utils import msgbox_frame + + +class ExternalLinksHandler: + """外部链接处理器类""" + + def __init__(self, main_window, dialog_factory=None): + """初始化外部链接处理器 + + Args: + main_window: 主窗口实例 + dialog_factory: 对话框工厂实例 + """ + self.main_window = main_window + self.dialog_factory = dialog_factory + + 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): + """打开常见问题页面""" + # 根据系统语言选择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 show_about_dialog(self): + """显示关于对话框""" + about_text = f""" +

{APP_NAME} v{APP_VERSION}

+

GitHub: https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT

+

原作: Yanam1Anna

+

此应用根据 GPL-3.0 许可证 授权。

+
+

感谢:

+

- HTony03:对原项目部分源码的重构、逻辑优化和功能实现提供了支持。

+

- 钨鸮:对于云端资源存储提供了支持。

+

- XIU2/CloudflareSpeedTest:提供了 IP 优选功能的核心支持。

+

- hosxy/aria2-fast:提供了修改版aria2c,提高了下载速度和性能。

+ """ + msg_box = msgbox_frame( + f"关于 - {APP_NAME}", + about_text, + QMessageBox.StandardButton.Ok, + ) + msg_box.setTextFormat(Qt.TextFormat.RichText) + msg_box.exec() + + def revoke_privacy_agreement(self): + """撤回隐私协议同意,并重启软件""" + # 创建确认对话框 + if self.dialog_factory: + response = self.dialog_factory.show_confirmation_dialog( + "确认操作", + "\n您确定要撤回隐私协议同意吗?\n\n撤回后软件将立即重启,您需要重新阅读并同意隐私协议。\n" + ) + else: + msg_box = msgbox_frame( + f"确认操作 - {APP_NAME}", + "\n您确定要撤回隐私协议同意吗?\n\n撤回后软件将立即重启,您需要重新阅读并同意隐私协议。\n", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + response = msg_box.exec() == QMessageBox.StandardButton.Yes + + if response: + try: + from core.managers.privacy_manager import PrivacyManager + + privacy_manager = PrivacyManager() + if privacy_manager.reset_privacy_agreement(): + # 显示重启提示 + if self.dialog_factory: + self.dialog_factory.show_simple_message( + "操作成功", + "\n已成功撤回隐私协议同意。\n\n软件将立即重启。\n" + ) + else: + restart_msg = msgbox_frame( + f"操作成功 - {APP_NAME}", + "\n已成功撤回隐私协议同意。\n\n软件将立即重启。\n", + QMessageBox.StandardButton.Ok + ) + restart_msg.exec() + + # 重启应用程序 + python_executable = sys.executable + script_path = os.path.abspath(sys.argv[0]) + subprocess.Popen([python_executable, script_path]) + sys.exit(0) + else: + if self.dialog_factory: + self.dialog_factory.show_simple_message( + "操作失败", + "\n撤回隐私协议同意失败。\n\n请检查应用权限或稍后再试。\n", + "error" + ) + else: + msgbox_frame( + f"操作失败 - {APP_NAME}", + "\n撤回隐私协议同意失败。\n\n请检查应用权限或稍后再试。\n", + QMessageBox.StandardButton.Ok + ).exec() + except Exception as e: + error_message = f"\n撤回隐私协议同意时发生错误:\n\n{str(e)}\n" + if self.dialog_factory: + self.dialog_factory.show_simple_message("错误", error_message, "error") + else: + msgbox_frame( + f"错误 - {APP_NAME}", + error_message, + QMessageBox.StandardButton.Ok + ).exec() \ No newline at end of file diff --git a/source/ui/components/font_style_manager.py b/source/ui/components/font_style_manager.py new file mode 100644 index 0000000..fdb87ad --- /dev/null +++ b/source/ui/components/font_style_manager.py @@ -0,0 +1,147 @@ +""" +字体和样式管理器 +负责管理应用程序的字体加载和UI样式 +""" + +import os +import logging +import traceback +from PySide6.QtGui import QFont, QFontDatabase + +from utils import resource_path + +logger = logging.getLogger(__name__) + + +class FontStyleManager: + """字体和样式管理器""" + + def __init__(self): + """初始化字体样式管理器""" + self._cached_font = None + self._font_family = "Arial" # 默认字体族 + self._load_custom_font() + + def _load_custom_font(self): + """加载自定义字体""" + try: + # 使用resource_path查找字体文件 + font_path = resource_path(os.path.join("assets", "fonts", "SmileySans-Oblique.ttf")) + + # 详细记录字体加载过程 + if os.path.exists(font_path): + logger.info(f"尝试加载字体文件: {font_path}") + font_id = QFontDatabase.addApplicationFont(font_path) + + if font_id != -1: + font_families = QFontDatabase.applicationFontFamilies(font_id) + if font_families: + self._font_family = font_families[0] + logger.info(f"成功加载字体: {self._font_family} 从 {font_path}") + else: + logger.warning(f"字体加载成功但无法获取字体族: {font_path}") + else: + logger.warning(f"字体加载失败: {font_path} (返回ID: {font_id})") + self._check_font_file_issues(font_path) + else: + logger.error(f"找不到字体文件: {font_path}") + self._list_font_directory(font_path) + + except Exception as e: + logger.error(f"加载字体过程中发生异常: {e}") + logger.error(f"异常详情: {traceback.format_exc()}") + + def _check_font_file_issues(self, font_path): + """检查字体文件的问题""" + try: + file_size = os.path.getsize(font_path) + logger.debug(f"字体文件大小: {file_size} 字节") + if file_size == 0: + logger.error(f"字体文件大小为0字节: {font_path}") + + # 尝试打开文件测试可读性 + with open(font_path, 'rb') as f: + f.read(10) # 只读取前几个字节测试可访问性 + logger.debug(f"字体文件可以正常打开和读取") + except Exception as file_error: + logger.error(f"字体文件访问错误: {file_error}") + + def _list_font_directory(self, font_path): + """列出字体目录下的文件""" + try: + fonts_dir = os.path.dirname(font_path) + if os.path.exists(fonts_dir): + files = os.listdir(fonts_dir) + logger.debug(f"字体目录 {fonts_dir} 中的文件: {files}") + else: + logger.debug(f"字体目录不存在: {fonts_dir}") + except Exception as dir_error: + logger.error(f"无法列出字体目录内容: {dir_error}") + + def get_menu_font(self, size=14, bold=True): + """获取菜单字体 + + Args: + size: 字体大小,默认14 + bold: 是否加粗,默认True + + Returns: + QFont: 配置好的菜单字体 + """ + if self._cached_font is None or self._cached_font.pointSize() != size: + self._cached_font = QFont(self._font_family, size) + self._cached_font.setBold(bold) + return self._cached_font + + def get_menu_style(self, font_family=None): + """获取统一的菜单样式 + + Args: + font_family: 字体族,如果不提供则使用默认 + + Returns: + str: CSS样式字符串 + """ + if font_family is None: + font_family = 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; + }} + """ + + @property + def font_family(self): + """获取当前字体族""" + return self._font_family \ No newline at end of file diff --git a/source/ui/components/menu_builder.py b/source/ui/components/menu_builder.py new file mode 100644 index 0000000..11bf09d --- /dev/null +++ b/source/ui/components/menu_builder.py @@ -0,0 +1,502 @@ +""" +菜单构建器 +负责构建和管理应用程序的各种菜单 +""" + +from PySide6.QtGui import QAction, QActionGroup, QCursor +from PySide6.QtWidgets import QMenu, QPushButton +from PySide6.QtCore import Qt, QRect + +from config.config import APP_NAME, APP_VERSION + + +class MenuBuilder: + """菜单构建器类""" + + def __init__(self, main_window, font_style_manager, external_links_handler, dialog_factory): + """初始化菜单构建器 + + Args: + main_window: 主窗口实例 + font_style_manager: 字体样式管理器 + external_links_handler: 外部链接处理器 + dialog_factory: 对话框工厂 + """ + self.main_window = main_window + self.ui = getattr(main_window, 'ui', None) + self.font_style_manager = font_style_manager + self.external_links_handler = external_links_handler + self.dialog_factory = dialog_factory + + # 菜单引用 + self.dev_menu = None + self.privacy_menu = None + self.about_menu = None + self.about_btn = None + + # 工作模式相关 + self.work_mode_menu = None + self.online_mode_action = None + self.offline_mode_action = None + + # 开发者选项相关 + self.debug_submenu = None + self.hosts_submenu = None + self.ipv6_submenu = None + self.hash_settings_menu = None + self.download_settings_menu = None + + # 各种action引用 + self.debug_action = None + self.open_log_action = None + self.ipv6_action = None + self.ipv6_test_action = None + self.disable_auto_restore_action = None + self.disable_pre_hash_action = None + + def setup_all_menus(self): + """设置所有菜单""" + 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.font_style_manager.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.font_style_manager.get_menu_font() + + # 创建菜单项 + faq_action = QAction("常见问题", self.main_window) + faq_action.triggered.connect(self.external_links_handler.open_faq_page) + faq_action.setFont(menu_font) + + report_issue_action = QAction("提交错误", self.main_window) + report_issue_action.triggered.connect(self.external_links_handler.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) + + def setup_about_menu(self): + """设置"关于"菜单""" + # 获取菜单字体 + menu_font = self.font_style_manager.get_menu_font() + + # 创建关于菜单 + self.about_menu = QMenu("关于", self.main_window) + self.about_menu.setFont(menu_font) + + # 设置菜单样式 + menu_style = self.font_style_manager.get_menu_style() + 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.external_links_handler.show_about_dialog) + + project_home_action = QAction("Github项目主页", self.main_window) + project_home_action.setFont(menu_font) + project_home_action.triggered.connect(self.external_links_handler.open_project_home_page) + + qq_group_action = QAction("加入QQ群", self.main_window) + qq_group_action.setFont(menu_font) + qq_group_action.triggered.connect(self.external_links_handler.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.font_style_manager.get_menu_font() + + # 创建隐私协议子菜单 + self.privacy_menu = QMenu("隐私协议", self.main_window) + self.privacy_menu.setFont(menu_font) + + # 设置样式 + menu_style = self.font_style_manager.get_menu_style() + 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.external_links_handler.open_privacy_policy) + + revoke_privacy_action = QAction("撤回隐私协议", self.main_window) + revoke_privacy_action.setFont(menu_font) + revoke_privacy_action.triggered.connect(self.external_links_handler.revoke_privacy_agreement) + + # 添加到子菜单 + self.privacy_menu.addAction(view_privacy_action) + self.privacy_menu.addAction(revoke_privacy_action) + + def setup_settings_menu(self): + """设置"设置"菜单""" + if not self.ui or not hasattr(self.ui, 'menu'): + return + + # 获取菜单字体 + menu_font = self.font_style_manager.get_menu_font() + menu_style = self.font_style_manager.get_menu_style() + + # 创建各个子菜单 + self._create_work_mode_menu(menu_font, menu_style) + self._create_download_settings_menu(menu_font, menu_style) + self._create_developer_options_menu(menu_font, menu_style) + + # 添加到主菜单 + 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) + + def _create_work_mode_menu(self, menu_font, menu_style): + """创建工作模式子菜单""" + self.work_mode_menu = QMenu("工作模式", self.main_window) + self.work_mode_menu.setFont(menu_font) + self.work_mode_menu.setStyleSheet(menu_style) + + # 获取当前离线模式状态 + 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) + + # 连接切换事件(这里需要在ui_manager中处理) + self.online_mode_action.triggered.connect(lambda: self._handle_mode_switch("online")) + self.offline_mode_action.triggered.connect(lambda: self._handle_mode_switch("offline")) + + # 添加到工作模式子菜单 + self.work_mode_menu.addAction(self.online_mode_action) + self.work_mode_menu.addAction(self.offline_mode_action) + + def _create_download_settings_menu(self, menu_font, menu_style): + """创建下载设置子菜单""" + self.download_settings_menu = QMenu("下载设置", self.main_window) + self.download_settings_menu.setFont(menu_font) + self.download_settings_menu.setStyleSheet(menu_style) + + # "修改下载源"按钮 + switch_source_action = QAction("修改下载源", self.main_window) + switch_source_action.setFont(menu_font) + switch_source_action.setEnabled(True) + switch_source_action.triggered.connect( + lambda: self.dialog_factory.show_simple_message("提示", "\n该功能正在开发中,敬请期待!\n") + ) + + # 添加下载线程设置选项 + thread_settings_action = QAction("下载线程设置", self.main_window) + thread_settings_action.setFont(menu_font) + thread_settings_action.triggered.connect(self._handle_download_thread_settings) + + # 添加到下载设置子菜单 + self.download_settings_menu.addAction(switch_source_action) + self.download_settings_menu.addAction(thread_settings_action) + + def _create_developer_options_menu(self, menu_font, menu_style): + """创建开发者选项子菜单""" + self.dev_menu = QMenu("开发者选项", self.main_window) + self.dev_menu.setFont(menu_font) + self.dev_menu.setStyleSheet(menu_style) + + # 创建各个子菜单 + self._create_debug_submenu(menu_font, menu_style) + self._create_hosts_submenu(menu_font, menu_style) + self._create_ipv6_submenu(menu_font, menu_style) + self._create_hash_settings_submenu(menu_font, menu_style) + + # 添加到开发者选项菜单 + self.dev_menu.addMenu(self.debug_submenu) + self.dev_menu.addMenu(self.hosts_submenu) + self.dev_menu.addMenu(self.ipv6_submenu) + self.dev_menu.addMenu(self.hash_settings_menu) + + def _create_debug_submenu(self, menu_font, menu_style): + """创建Debug子菜单""" + self.debug_submenu = QMenu("Debug模式", self.main_window) + self.debug_submenu.setFont(menu_font) + self.debug_submenu.setStyleSheet(menu_style) + + # 创建Debug开关选项 + self.debug_action = QAction("Debug开关", self.main_window, checkable=True) + self.debug_action.setFont(menu_font) + + # 获取debug模式状态 + 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) + self.open_log_action.setEnabled(debug_mode) + + # 连接打开log文件的事件 + if hasattr(self.main_window, 'debug_manager'): + self.open_log_action.triggered.connect(self.main_window.debug_manager.open_log_file) + else: + self.open_log_action.triggered.connect( + lambda: self.dialog_factory.show_simple_message("错误", "\n调试管理器未初始化。\n", "error") + ) + + # 添加到Debug子菜单 + self.debug_submenu.addAction(self.debug_action) + self.debug_submenu.addAction(self.open_log_action) + + def _create_hosts_submenu(self, menu_font, menu_style): + """创建hosts文件选项子菜单""" + self.hosts_submenu = QMenu("hosts文件选项", self.main_window) + self.hosts_submenu.setFont(menu_font) + self.hosts_submenu.setStyleSheet(menu_style) + + # 添加hosts子选项 + restore_hosts_action = QAction("还原软件备份的hosts文件", self.main_window) + restore_hosts_action.setFont(menu_font) + restore_hosts_action.triggered.connect(self._handle_restore_hosts_backup) + + clean_hosts_action = QAction("手动删除软件添加的hosts条目", self.main_window) + clean_hosts_action.setFont(menu_font) + clean_hosts_action.triggered.connect(self._handle_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._handle_toggle_disable_auto_restore_hosts) + + # 添加打开hosts文件选项 + open_hosts_action = QAction("打开hosts文件", self.main_window) + open_hosts_action.setFont(menu_font) + open_hosts_action.triggered.connect(self._handle_open_hosts_file) + + # 添加到hosts子菜单 + self.hosts_submenu.addAction(self.disable_auto_restore_action) + self.hosts_submenu.addAction(restore_hosts_action) + self.hosts_submenu.addAction(clean_hosts_action) + self.hosts_submenu.addAction(open_hosts_action) + + def _create_ipv6_submenu(self, menu_font, menu_style): + """创建IPv6支持子菜单""" + self.ipv6_submenu = QMenu("IPv6支持", self.main_window) + self.ipv6_submenu.setFont(menu_font) + self.ipv6_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) + + # 获取IPv6Manager实例 + ipv6_manager = getattr(self.main_window, 'ipv6_manager', None) + if ipv6_manager: + self.ipv6_test_action.triggered.connect(ipv6_manager.show_ipv6_details) + else: + self.ipv6_test_action.triggered.connect( + lambda: self.dialog_factory.show_simple_message("错误", "\nIPv6管理器尚未初始化,请稍后再试。\n", "error") + ) + + # 检查配置中是否已启用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) + + def _create_hash_settings_submenu(self, menu_font, menu_style): + """创建哈希校验设置子菜单""" + self.hash_settings_menu = QMenu("哈希校验设置", self.main_window) + self.hash_settings_menu.setFont(menu_font) + self.hash_settings_menu.setStyleSheet(menu_style) + + # 添加禁用安装前哈希预检查选项 + self.disable_pre_hash_action = QAction("禁用安装前哈希预检查", self.main_window, checkable=True) + self.disable_pre_hash_action.setFont(menu_font) + + # 从配置中读取当前状态 + config = getattr(self.main_window, 'config', {}) + disable_pre_hash = False + if isinstance(config, dict): + disable_pre_hash = config.get("disable_pre_hash_check", False) + + self.disable_pre_hash_action.setChecked(disable_pre_hash) + self.disable_pre_hash_action.triggered.connect(lambda checked: self._handle_pre_hash_toggle(checked)) + + # 添加到哈希校验设置子菜单 + self.hash_settings_menu.addAction(self.disable_pre_hash_action) + + def show_menu(self, menu, button): + """显示菜单 + + Args: + menu: 要显示的菜单 + button: 触发菜单的按钮 + """ + # 检查Ui_install中是否定义了show_menu方法 + if hasattr(self.ui, 'show_menu'): + self.ui.show_menu(menu, button) + else: + # 否则,使用默认的弹出方法 + global_pos = button.mapToGlobal(button.rect().bottomLeft()) + menu.popup(global_pos) + + # 以下方法需要委托给ui_manager处理 + def _handle_mode_switch(self, mode): + """处理工作模式切换""" + # 这个方法需要在ui_manager中实现 + if hasattr(self.main_window, 'ui_manager') and hasattr(self.main_window.ui_manager, 'switch_work_mode'): + self.main_window.ui_manager.switch_work_mode(mode) + else: + self.dialog_factory.show_simple_message("错误", "\n工作模式切换功能不可用。\n", "error") + + def _handle_download_thread_settings(self): + """处理下载线程设置""" + if hasattr(self.main_window, 'download_manager'): + self.main_window.download_manager.show_download_thread_settings() + else: + self.dialog_factory.show_simple_message("错误", "\n下载管理器未初始化,无法修改下载线程设置。\n", "error") + + def _handle_ipv6_toggle(self, enabled): + """处理IPv6支持切换""" + if hasattr(self.main_window, 'ui_manager') and hasattr(self.main_window.ui_manager, '_handle_ipv6_toggle'): + self.main_window.ui_manager._handle_ipv6_toggle(enabled) + else: + self.dialog_factory.show_simple_message("错误", "\nIPv6管理功能不可用。\n", "error") + + def _handle_pre_hash_toggle(self, checked): + """处理禁用安装前哈希预检查的切换""" + if hasattr(self.main_window, 'ui_manager') and hasattr(self.main_window.ui_manager, '_handle_pre_hash_toggle'): + self.main_window.ui_manager._handle_pre_hash_toggle(checked) + else: + self.dialog_factory.show_simple_message("错误", "\n哈希检查设置功能不可用。\n", "error") + + def _handle_restore_hosts_backup(self): + """处理还原hosts备份""" + if hasattr(self.main_window, 'ui_manager') and hasattr(self.main_window.ui_manager, 'restore_hosts_backup'): + self.main_window.ui_manager.restore_hosts_backup() + else: + self.dialog_factory.show_simple_message("错误", "\nhosts管理功能不可用。\n", "error") + + def _handle_clean_hosts_entries(self): + """处理清理hosts条目""" + if hasattr(self.main_window, 'ui_manager') and hasattr(self.main_window.ui_manager, 'clean_hosts_entries'): + self.main_window.ui_manager.clean_hosts_entries() + else: + self.dialog_factory.show_simple_message("错误", "\nhosts管理功能不可用。\n", "error") + + def _handle_toggle_disable_auto_restore_hosts(self, checked): + """处理切换禁用自动还原hosts""" + if hasattr(self.main_window, 'ui_manager') and hasattr(self.main_window.ui_manager, 'toggle_disable_auto_restore_hosts'): + self.main_window.ui_manager.toggle_disable_auto_restore_hosts(checked) + else: + self.dialog_factory.show_simple_message("错误", "\nhosts管理功能不可用。\n", "error") + + def _handle_open_hosts_file(self): + """处理打开hosts文件""" + if hasattr(self.main_window, 'ui_manager') and hasattr(self.main_window.ui_manager, 'open_hosts_file'): + self.main_window.ui_manager.open_hosts_file() + else: + self.dialog_factory.show_simple_message("错误", "\nhosts管理功能不可用。\n", "error") \ No newline at end of file diff --git a/source/utils/helpers.py b/source/utils/helpers.py index 430d042..a829546 100644 --- a/source/utils/helpers.py +++ b/source/utils/helpers.py @@ -9,31 +9,140 @@ import psutil from PySide6 import QtCore, QtWidgets import re from PySide6.QtGui import QIcon, QPixmap -from data.config import APP_NAME, CONFIG_FILE +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QProgressBar +from config.config import APP_NAME, CONFIG_FILE from utils.logger import setup_logger +import datetime +import traceback +import subprocess +from pathlib import Path # 初始化logger logger = setup_logger("helpers") -def resource_path(relative_path): - """获取资源的绝对路径,适用于开发环境和Nuitka打包环境""" - if getattr(sys, 'frozen', False): - # Nuitka/PyInstaller创建的临时文件夹,并将路径存储在_MEIPASS中或与可执行文件同目录 - if hasattr(sys, '_MEIPASS'): - base_path = sys._MEIPASS +class ProgressHashVerifyDialog(QDialog): + """带进度条的哈希验证对话框""" + + def __init__(self, title, message, parent=None): + """初始化对话框 + + Args: + title: 对话框标题 + message: 对话框消息 + parent: 父窗口 + """ + super().__init__(parent) + self.setWindowTitle(title) + self.setMinimumWidth(400) + self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + + # 创建布局 + layout = QVBoxLayout(self) + + # 添加消息标签 + self.message_label = QLabel(message) + self.message_label.setAlignment(Qt.AlignCenter) + layout.addWidget(self.message_label) + + # 添加进度条 + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + layout.addWidget(self.progress_bar) + + # 添加状态标签 + self.status_label = QLabel("正在准备...") + self.status_label.setAlignment(Qt.AlignCenter) + layout.addWidget(self.status_label) + + # 添加取消按钮 + button_layout = QHBoxLayout() + self.cancel_button = QPushButton("取消") + self.cancel_button.clicked.connect(self.reject) + button_layout.addStretch() + button_layout.addWidget(self.cancel_button) + layout.addLayout(button_layout) + + def update_progress(self, value): + """更新进度条 + + Args: + value: 进度值 (0-100) + """ + self.progress_bar.setValue(value) + + # 更新状态文本 + if value < 10: + self.status_label.setText("正在准备...") + elif value < 50: + self.status_label.setText("正在解压文件...") + elif value < 70: + self.status_label.setText("正在查找补丁文件...") + elif value < 95: + self.status_label.setText("正在计算哈希值...") else: - base_path = os.path.dirname(sys.executable) - else: - # 在开发环境中运行 - base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + self.status_label.setText("正在验证哈希值...") + + def set_message(self, message): + """设置消息文本 + + Args: + message: 消息文本 + """ + self.message_label.setText(message) + + def set_status(self, status): + """设置状态文本 + + Args: + status: 状态文本 + """ + self.status_label.setText(status) + +def resource_path(relative_path): + """获取资源的绝对路径,适用于开发环境和打包环境""" + try: + if getattr(sys, 'frozen', False): + # 打包环境 - 可执行文件所在目录 + if hasattr(sys, '_MEIPASS'): + # PyInstaller打包的临时目录 + base_path = sys._MEIPASS + else: + # 其他打包方式,直接使用可执行文件目录 + base_path = os.path.dirname(sys.executable) + + # 对于离线补丁文件,需要在可执行文件所在目录查找 + if relative_path.lower() in ["vol.1.7z", "vol.2.7z", "vol.3.7z", "vol.4.7z", "after.7z"]: + exe_dir = os.path.dirname(sys.executable) + patch_path = os.path.join(exe_dir, relative_path) + if os.path.exists(patch_path): + logger.debug(f"找到离线补丁文件: {patch_path}") + return patch_path + else: + # 在开发环境中运行 + base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) # 处理特殊的可执行文件和数据文件路径 if relative_path in ("aria2c-fast_x64.exe", "cfst.exe"): - return os.path.join(base_path, 'bin', relative_path) + result_path = os.path.join(base_path, 'bin', relative_path) elif relative_path in ("ip.txt", "ipv6.txt"): - return os.path.join(base_path, 'data', relative_path) + result_path = os.path.join(base_path, 'data', relative_path) + else: + # 标准资源路径 + result_path = os.path.join(base_path, relative_path) + + # 记录资源路径并验证是否存在 + if not os.path.exists(result_path) and relative_path: # 只在非空路径时检查 + logger.warning(f"资源文件不存在: {result_path}") + elif relative_path: # 避免记录空路径 + logger.debug(f"已找到资源文件: {result_path}") - return os.path.join(base_path, relative_path) + return result_path + except Exception as e: + logger.error(f"资源路径解析错误 ({relative_path}): {e}") + # 出错时仍返回一个基本路径 + return os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", relative_path) def load_base64_image(base64_str): pixmap = QPixmap() @@ -49,9 +158,57 @@ def load_image_from_file(file_path): Returns: QPixmap: 加载的图像 """ - if os.path.exists(file_path): - return QPixmap(file_path) - return QPixmap() + try: + if os.path.exists(file_path): + logger.info(f"加载图片: {file_path}") + pixmap = QPixmap(file_path) + + if pixmap.isNull(): + logger.error(f"图片加载失败(pixmap为空): {file_path}") + + # 检查文件大小和是否可读 + try: + file_size = os.path.getsize(file_path) + logger.debug(f"图片文件大小: {file_size} 字节") + if file_size == 0: + logger.error(f"图片文件大小为0字节: {file_path}") + + # 尝试打开文件测试可读性 + with open(file_path, 'rb') as f: + # 只读取前几个字节测试可访问性 + f.read(10) + logger.debug(f"图片文件可以正常打开和读取") + + # 检查文件扩展名是否正确 + ext = os.path.splitext(file_path)[1].lower() + if ext not in ['.png', '.jpg', '.jpeg', '.bmp', '.ico']: + logger.warning(f"图片文件扩展名可能不受支持: {ext}") + + except Exception as file_error: + logger.error(f"图片文件访问错误: {file_error}") + + return QPixmap() + else: + logger.debug(f"图片加载成功: {file_path}, 大小: {pixmap.width()}x{pixmap.height()}") + return pixmap + else: + logger.warning(f"图片文件不存在: {file_path}") + # 尝试列出父目录下的文件 + try: + parent_dir = os.path.dirname(file_path) + if os.path.exists(parent_dir): + files = os.listdir(parent_dir) + logger.debug(f"目录 {parent_dir} 中的文件: {files}") + else: + logger.debug(f"目录不存在: {parent_dir}") + except Exception as dir_error: + logger.error(f"无法列出目录内容: {dir_error}") + + return QPixmap() + except Exception as e: + logger.error(f"加载图片时发生异常: {e}") + logger.error(f"异常详情: {traceback.format_exc()}") + return QPixmap() def msgbox_frame(title, text, buttons=QtWidgets.QMessageBox.StandardButton.NoButton): msg_box = QtWidgets.QMessageBox() @@ -59,7 +216,7 @@ def msgbox_frame(title, text, buttons=QtWidgets.QMessageBox.StandardButton.NoBut msg_box.setWindowModality(QtCore.Qt.WindowModality.WindowModal) # 直接加载图标文件 - icon_path = resource_path(os.path.join("IMG", "ICO", "icon.png")) + icon_path = resource_path(os.path.join("assets", "images", "ICO", "icon.png")) if os.path.exists(icon_path): pixmap = QPixmap(icon_path) if not pixmap.isNull(): @@ -116,12 +273,14 @@ class HashManager: logger.error(f"Error calculating hash for {file_path}: {e}") return results - def hash_pop_window(self, check_type="default", is_offline=False): + def hash_pop_window(self, check_type="default", is_offline=False, auto_close=False, close_delay=500): """显示文件检验窗口 Args: check_type: 检查类型,可以是 'pre'(预检查), 'after'(后检查), 'extraction'(解压后检查), 'offline_extraction'(离线解压), 'offline_verify'(离线验证) is_offline: 是否处于离线模式 + auto_close: 是否自动关闭窗口 + close_delay: 自动关闭延迟(毫秒) Returns: QMessageBox: 消息框实例 @@ -138,6 +297,8 @@ class HashManager: message = "\n正在验证本地补丁压缩文件完整性...\n" elif check_type == "offline_extraction": message = "\n正在解压安装补丁文件...\n" + elif check_type == "offline_installation": + message = "\n正在安装补丁文件...\n" else: message = "\n正在处理离线补丁文件...\n" else: @@ -148,10 +309,27 @@ class HashManager: message = "\n正在检验本地文件完整性...\n" elif check_type == "extraction": message = "\n正在验证下载的解压文件完整性...\n" + elif check_type == "post": + message = "\n正在检验补丁文件完整性...\n" + # 创建新的消息框 msg_box = msgbox_frame(f"通知 - {APP_NAME}", message) + + # 使用open()而不是exec(),避免阻塞UI线程 msg_box.open() + + # 处理事件循环,确保窗口显示 QtWidgets.QApplication.processEvents() + + # 如果设置了自动关闭,添加定时器 + if auto_close: + timer = QtCore.QTimer() + timer.setSingleShot(True) + timer.timeout.connect(msg_box.close) + timer.start(close_delay) + # 保存定时器引用,防止被垃圾回收 + msg_box.close_timer = timer + return msg_box def cfg_pre_hash_compare(self, install_paths, plugin_hash, installed_status): @@ -160,7 +338,7 @@ class HashManager: # 尝试检测是否处于调试模式 try: - from data.config import CACHE + from config.config import CACHE debug_file = os.path.join(os.path.dirname(CACHE), "debug_mode.txt") debug_mode = os.path.exists(debug_file) except: @@ -210,7 +388,7 @@ class HashManager: # 尝试检测是否处于调试模式 try: - from data.config import CACHE + from config.config import CACHE debug_file = os.path.join(os.path.dirname(CACHE), "debug_mode.txt") debug_mode = os.path.exists(debug_file) except: @@ -436,7 +614,7 @@ class HostsManager: self.original_content = f.read() with open(self.backup_path, 'w', encoding='utf-8') as f: f.write(self.original_content) - logger.info(f"Hosts文件已备份到: {self.backup_path}") + logger.debug(f"Hosts文件已备份到: {self.backup_path}") return True except IOError as e: logger.error(f"备份hosts文件失败: {e}") @@ -567,14 +745,17 @@ class HostsManager: self.auto_restore_disabled = auto_restore_disabled return auto_restore_disabled - def check_and_clean_all_entries(self): + def check_and_clean_all_entries(self, force_clean=False): """检查并清理所有由本应用程序添加的hosts记录 + Args: + force_clean: 是否强制清理,即使禁用了自动还原 + Returns: bool: 清理是否成功 """ - # 如果禁用了自动还原,则不执行清理操作 - if self.is_auto_restore_disabled(): + # 如果禁用了自动还原,且不是强制清理,则不执行清理操作 + if self.is_auto_restore_disabled() and not force_clean: logger.info("已禁用自动还原hosts,跳过清理操作") return True diff --git a/source/utils/logger.py b/source/utils/logger.py index bceccd8..5cb779c 100644 --- a/source/utils/logger.py +++ b/source/utils/logger.py @@ -1,7 +1,14 @@ -from .url_censor import censor_url -import logging import os -from data.config import CACHE +import logging +import datetime +import sys +import glob +import time +import traceback +from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler +from config.config import LOG_DIR, LOG_FILE, LOG_LEVEL, LOG_MAX_SIZE, LOG_BACKUP_COUNT, LOG_RETENTION_DAYS + +from .url_censor import censor_url class URLCensorFormatter(logging.Formatter): """自定义的日志格式化器,用于隐藏日志消息中的URL""" @@ -44,7 +51,7 @@ class Logger: except Exception as e: # 发生错误时记录到控制台 self.terminal.write(f"Error writing to log: {e}\n") - + def flush(self): try: self.terminal.flush() @@ -62,65 +69,120 @@ class Logger: except Exception: pass +# 增加异常钩子,确保未捕获的异常也会记录到日志文件中 +def log_uncaught_exceptions(exc_type, exc_value, exc_traceback): + """处理未捕获的异常,记录到日志中""" + if issubclass(exc_type, KeyboardInterrupt): + # 对于键盘中断,使用默认处理 + sys.__excepthook__(exc_type, exc_value, exc_traceback) + return + + # 获取主日志记录器 + logger = logging.getLogger('main') + + # 格式化异常信息 + lines = traceback.format_exception(exc_type, exc_value, exc_traceback) + error_message = '未捕获的异常:\n' + ''.join(lines) + + # 记录到日志中 + logger.critical(error_message) + + # 同时也显示在控制台 + sys.__excepthook__(exc_type, exc_value, exc_traceback) + +# 设置全局异常处理器 +sys.excepthook = log_uncaught_exceptions + +def cleanup_old_logs(retention_days=7): + """清理超过指定天数的旧日志文件 + + Args: + retention_days: 日志保留天数,默认7天 + """ + try: + now = time.time() + cutoff = now - (retention_days * 86400) # 86400秒 = 1天 + + # 获取所有日志文件 + log_files = glob.glob(os.path.join(LOG_DIR, "log-*.txt")) + + for log_file in log_files: + # 检查文件修改时间 + if os.path.getmtime(log_file) < cutoff: + try: + os.remove(log_file) + print(f"已删除过期日志: {log_file}") + except Exception as e: + print(f"删除日志文件失败 {log_file}: {e}") + except Exception as e: + print(f"清理旧日志文件时出错: {e}") + def setup_logger(name): """设置并返回一个命名的logger + 使用统一的日志文件,添加日志轮转功能,实现自动清理过期日志 + Args: name: logger的名称 - + Returns: logging.Logger: 配置好的logger对象 """ - # 导入LOG_FILE - from data.config import LOG_FILE - # 创建logger logger = logging.getLogger(name) # 避免重复添加处理器 if logger.hasHandlers(): return logger - - logger.setLevel(logging.DEBUG) + + # 根据配置设置日志级别 + log_level = getattr(logging, LOG_LEVEL.upper(), logging.DEBUG) + logger.setLevel(log_level) # 确保日志目录存在 - log_dir = os.path.join(CACHE, "logs") - os.makedirs(log_dir, exist_ok=True) - log_file = os.path.join(log_dir, f"{name}.log") + os.makedirs(LOG_DIR, exist_ok=True) - # 创建文件处理器 - 模块日志 - file_handler = logging.FileHandler(log_file, encoding="utf-8") - file_handler.setLevel(logging.DEBUG) + # 清理过期日志文件 + cleanup_old_logs(LOG_RETENTION_DAYS) - # 创建主日志文件处理器 - 所有日志合并到主LOG_FILE + # 创建主日志文件的轮转处理器 try: # 确保主日志文件目录存在 log_file_dir = os.path.dirname(LOG_FILE) if log_file_dir and not os.path.exists(log_file_dir): os.makedirs(log_file_dir, exist_ok=True) print(f"已创建主日志目录: {log_file_dir}") - - main_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8", mode="w") - main_file_handler.setLevel(logging.DEBUG) + + # 使用RotatingFileHandler实现日志轮转 + main_file_handler = RotatingFileHandler( + LOG_FILE, + maxBytes=LOG_MAX_SIZE, + backupCount=LOG_BACKUP_COUNT, + encoding="utf-8" + ) + main_file_handler.setLevel(log_level) except (IOError, OSError) as e: print(f"无法创建主日志文件处理器: {e}") main_file_handler = None # 创建控制台处理器 console_handler = logging.StreamHandler() - console_handler.setLevel(logging.INFO) + console_handler.setLevel(logging.INFO) # 控制台只显示INFO以上级别 - # 创建格式器并添加到处理器 - formatter = URLCensorFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') - file_handler.setFormatter(formatter) + # 创建更详细的格式器,包括模块名、文件名和行号 + formatter = URLCensorFormatter('%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s') + + # 设置处理器的格式化器 console_handler.setFormatter(formatter) if main_file_handler: main_file_handler.setFormatter(formatter) # 添加处理器到logger - logger.addHandler(file_handler) logger.addHandler(console_handler) if main_file_handler: logger.addHandler(main_file_handler) + # 确保异常可以被正确记录 + logger.propagate = True + return logger \ No newline at end of file diff --git a/source/utils/url_censor.py b/source/utils/url_censor.py index 3116d22..70fa7b8 100644 --- a/source/utils/url_censor.py +++ b/source/utils/url_censor.py @@ -16,7 +16,7 @@ def censor_url(text): return text # 直接返回原始文本,不做任何隐藏 # 以下是原始代码,现在被注释掉 - ''' + r''' # 匹配URL并替换为固定文本 url_pattern = re.compile(r'https?://[^\s/$.?#].[^\s]*') censored = url_pattern.sub('***URL protection***', text) diff --git a/source/workers/config_fetch_thread.py b/source/workers/config_fetch_thread.py index 6b42205..8e97800 100644 --- a/source/workers/config_fetch_thread.py +++ b/source/workers/config_fetch_thread.py @@ -22,7 +22,7 @@ class ConfigFetchThread(QThread): def run(self): try: if self.debug_mode: - logger.info("--- Starting to fetch cloud config ---") + logger.debug("--- Starting to fetch cloud config ---") # 完全隐藏URL logger.debug(f"DEBUG: Requesting URL: ***URL protection***") logger.debug(f"DEBUG: Using Headers: {self.headers}") @@ -72,7 +72,7 @@ class ConfigFetchThread(QThread): self.finished.emit(None, error_msg) finally: if self.debug_mode: - logger.info("--- Finished fetching cloud config ---") + logger.debug("--- Finished fetching cloud config ---") def _create_safe_config_for_logging(self, config_data): """创建用于日志记录的安全配置副本,隐藏敏感URL diff --git a/source/workers/download.py b/source/workers/download.py index 4eada7c..51a4b99 100644 --- a/source/workers/download.py +++ b/source/workers/download.py @@ -7,7 +7,7 @@ from PySide6 import QtCore, QtWidgets from PySide6.QtCore import (Qt, Signal, QThread, QTimer) from PySide6.QtWidgets import (QLabel, QProgressBar, QVBoxLayout, QDialog, QHBoxLayout) from utils import resource_path -from data.config import APP_NAME, UA +from config.config import APP_NAME, UA import signal import ctypes import time diff --git a/source/workers/extraction_thread.py b/source/workers/extraction_thread.py index 42d5f58..f46bba3 100644 --- a/source/workers/extraction_thread.py +++ b/source/workers/extraction_thread.py @@ -1,31 +1,351 @@ import os import shutil import py7zr +import tempfile +import traceback from PySide6.QtCore import QThread, Signal -from data.config import PLUGIN, GAME_INFO +from config.config import PLUGIN, GAME_INFO +import time # 用于时间计算 +import threading +import queue +from concurrent.futures import TimeoutError class ExtractionThread(QThread): finished = Signal(bool, str, str) # success, error_message, game_version + progress = Signal(int, str) # 添加进度信号,传递进度百分比和状态信息 - def __init__(self, _7z_path, game_folder, plugin_path, game_version, parent=None): + def __init__(self, _7z_path, game_folder, plugin_path, game_version, parent=None, extracted_path=None): super().__init__(parent) self._7z_path = _7z_path self.game_folder = game_folder self.plugin_path = plugin_path self.game_version = game_version + self.extracted_path = extracted_path # 添加已解压文件路径参数 def run(self): try: - with py7zr.SevenZipFile(self._7z_path, mode="r") as archive: - archive.extractall(path=PLUGIN) - + # 确保游戏目录存在 os.makedirs(self.game_folder, exist_ok=True) - shutil.copy(self.plugin_path, self.game_folder) + + def update_progress(percent: int, message: str): + try: + self.progress.emit(percent, message) + except Exception: + pass - if self.game_version == "NEKOPARA After": - sig_path = os.path.join(PLUGIN, GAME_INFO[self.game_version]["sig_path"]) - shutil.copy(sig_path, self.game_folder) + # 记录调试信息 + from utils.logger import setup_logger + debug_logger = setup_logger("extraction_thread") + debug_logger.info(f"====== 开始处理 {self.game_version} 补丁文件 ======") + debug_logger.info(f"压缩包路径: {self._7z_path}") + debug_logger.info(f"游戏目录: {self.game_folder}") + debug_logger.info(f"插件路径: {self.plugin_path}") + + 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): + 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) + + update_progress(60, f"正在完成 {self.game_version} 的补丁安装...") + + # 对于NEKOPARA After,还需要复制签名文件 + if self.game_version == "NEKOPARA After": + try: + update_progress(70, f"正在处理 {self.game_version} 的签名文件...") + # 从已解压文件的目录中获取签名文件 + extracted_dir = os.path.dirname(self.extracted_path) + sig_filename = os.path.basename(GAME_INFO[self.game_version]["sig_path"]) + sig_path = os.path.join(extracted_dir, sig_filename) + + # 尝试多种可能的签名文件路径 + if not os.path.exists(sig_path): + # 尝试在同级目录查找 + sig_path = os.path.join(os.path.dirname(extracted_dir), sig_filename) + + # 如果签名文件存在,则复制它 + if os.path.exists(sig_path): + target_sig = os.path.join(self.game_folder, sig_filename) + shutil.copy(sig_path, target_sig) + update_progress(80, f"签名文件复制完成") + else: + # 如果签名文件不存在,则使用原始路径 + sig_path = os.path.join(PLUGIN, GAME_INFO[self.game_version]["sig_path"]) + if os.path.exists(sig_path): + target_sig = os.path.join(self.game_folder, os.path.basename(sig_path)) + shutil.copy(sig_path, target_sig) + update_progress(80, f"使用内置签名文件完成") + else: + update_progress(80, f"未找到签名文件,继续安装主补丁文件") + except Exception as sig_err: + # 签名文件处理失败时记录错误但不中断主流程 + update_progress(80, f"签名文件处理失败: {str(sig_err)}") + + update_progress(100, f"{self.game_version} 补丁文件处理完成") + self.finished.emit(True, "", self.game_version) + return + + # 否则解压源压缩包到临时目录,再复制目标文件 + update_progress(10, f"正在打开 {self.game_version} 的补丁压缩包...") + + with py7zr.SevenZipFile(self._7z_path, mode="r") as archive: + # 获取压缩包内的文件列表 + file_list = archive.getnames() - self.finished.emit(True, "", self.game_version) + # 详细记录压缩包中的所有文件 + debug_logger.debug(f"压缩包内容分析:") + debug_logger.debug(f"- 文件总数: {len(file_list)}") + for i, f in enumerate(file_list): + debug_logger.debug(f" {i+1}. {f} - 类型: {'文件夹' if f.endswith('/') or f.endswith('\\') else '文件'}") + + update_progress(20, f"正在分析 {self.game_version} 的补丁文件...") + + update_progress(30, f"正在解压 {self.game_version} 的补丁文件...\n(在此过程中可能会卡顿或无响应,请不要关闭软件)") + + with tempfile.TemporaryDirectory() as temp_dir: + # 查找主补丁文件和签名文件 + target_filename = os.path.basename(self.plugin_path) + # 只有NEKOPARA After版本才需要查找签名文件 + if self.game_version == "NEKOPARA After": + sig_filename = target_filename + ".sig" # 签名文件名 + debug_logger.debug(f"查找主补丁文件: {target_filename}") + debug_logger.debug(f"查找签名文件: {sig_filename}") + else: + sig_filename = None + debug_logger.debug(f"查找主补丁文件: {target_filename}") + debug_logger.debug(f"{self.game_version} 不需要签名文件") + + target_file_in_archive = None + sig_file_in_archive = None + + # 对于NEKOPARA After,增加特殊处理 + if self.game_version == "NEKOPARA After": + # 增加专门的检查,同时识别主补丁和签名文件 + debug_logger.debug("执行NEKOPARA After特殊补丁文件识别") + + # 查找主补丁和签名文件 + for file_path in file_list: + basename = os.path.basename(file_path) + + # 查找主补丁文件 + if basename == "afteradult.xp3" and not basename.endswith('.sig'): + target_file_in_archive = file_path + debug_logger.debug(f"找到精确匹配的After主补丁文件: {target_file_in_archive}") + + # 查找签名文件 + elif basename == "afteradult.xp3.sig" or basename.endswith('.sig'): + sig_file_in_archive = file_path + debug_logger.debug(f"找到After签名文件: {sig_file_in_archive}") + + # 如果没找到主补丁文件,寻找可能的替代文件 + if not target_file_in_archive: + for file_path in file_list: + if "afteradult.xp3" in file_path and not file_path.endswith('.sig'): + target_file_in_archive = file_path + debug_logger.debug(f"找到备选After主补丁文件: {target_file_in_archive}") + break + else: + # 标准处理逻辑 + for file_path in file_list: + basename = os.path.basename(file_path) + + # 查找主补丁文件 + if basename == target_filename and not basename.endswith('.sig'): + target_file_in_archive = file_path + debug_logger.debug(f"在压缩包中找到主补丁文件: {target_file_in_archive}") + + # 查找签名文件 + elif basename == sig_filename: + sig_file_in_archive = file_path + debug_logger.debug(f"在压缩包中找到签名文件: {sig_file_in_archive}") + + # 如果没有找到精确匹配的主补丁文件,使用更宽松的搜索 + if not target_file_in_archive: + debug_logger.warning(f"没有找到精确匹配的主补丁文件,尝试更宽松的搜索") + for file_path in file_list: + if target_filename in file_path and not file_path.endswith('.sig'): + target_file_in_archive = file_path + debug_logger.info(f"在压缩包中找到可能的主补丁文件: {target_file_in_archive}") + break + + # 如果找不到主补丁文件,使用回退方案:提取全部内容 + if not target_file_in_archive: + debug_logger.warning(f"未能识别正确的主补丁文件,将提取所有文件并尝试查找") + + # 提取所有文件到临时目录 + update_progress(30, f"正在解压所有文件...") + archive.extractall(path=temp_dir) + debug_logger.debug(f"已提取所有文件到临时目录") + + # 在提取的文件中查找主补丁文件和签名文件 + found_main = False + found_sig = False + + for root, dirs, files in os.walk(temp_dir): + for file in files: + # 查找主补丁文件 + if file == target_filename and not file.endswith('.sig'): + extracted_file_path = os.path.join(root, file) + file_size = os.path.getsize(extracted_file_path) + debug_logger.debug(f"在提取的文件中找到主补丁文件: {extracted_file_path}, 大小: {file_size} 字节") + + # 复制到目标位置 + target_path = os.path.join(self.game_folder, target_filename) + shutil.copy2(extracted_file_path, target_path) + debug_logger.debug(f"已复制主补丁文件到: {target_path}") + found_main = True + + # 查找签名文件 + elif file == sig_filename or file.endswith('.sig'): + extracted_sig_path = os.path.join(root, file) + sig_size = os.path.getsize(extracted_sig_path) + debug_logger.debug(f"在提取的文件中找到签名文件: {extracted_sig_path}, 大小: {sig_size} 字节") + + # 复制到目标位置 + sig_target = os.path.join(self.game_folder, sig_filename) + shutil.copy2(extracted_sig_path, sig_target) + debug_logger.debug(f"已复制签名文件到: {sig_target}") + found_sig = True + + # 如果两个文件都找到,可以停止遍历 + if found_main and found_sig: + debug_logger.debug("已找到所有需要的文件,停止遍历") + break + + if found_main and found_sig: + break + + if not found_main: + debug_logger.error(f"无法找到主补丁文件,安装失败") + raise FileNotFoundError(f"在压缩包中未找到主补丁文件 {target_filename}") + + # 只有NEKOPARA After版本才需要处理签名文件 + if self.game_version == "NEKOPARA After": + # 签名文件没找到不影响主流程,但记录警告 + if not found_sig: + debug_logger.warning(f"未找到签名文件 {sig_filename},但继续安装主补丁文件") + else: + debug_logger.info(f"{self.game_version} 不需要签名文件,跳过签名文件处理") + else: + # 准备要解压的文件列表 + files_to_extract = [target_file_in_archive] + # 只有NEKOPARA After版本才需要解压签名文件 + if self.game_version == "NEKOPARA After" and sig_file_in_archive: + files_to_extract.append(sig_file_in_archive) + debug_logger.debug(f"将同时解压主补丁文件和签名文件: {files_to_extract}") + else: + debug_logger.debug(f"将仅解压主补丁文件: {files_to_extract}") + + # 解压选定的文件到临时目录 + debug_logger.debug(f"开始解压选定文件到临时目录: {temp_dir}") + + # 设置解压超时时间(秒) + extract_timeout = 180 # 3分钟超时 + debug_logger.debug(f"设置解压超时: {extract_timeout}秒") + + # 创建子线程执行解压 + import threading + import queue + + extract_result = queue.Queue() + + def extract_files(): + try: + archive.extract(path=temp_dir, targets=files_to_extract) + extract_result.put(("success", None)) + except Exception as e: + extract_result.put(("error", e)) + + extract_thread = threading.Thread(target=extract_files) + extract_thread.daemon = True + extract_thread.start() + + # 每5秒更新一次进度,最多等待设定的超时时间 + total_waited = 0 + while extract_thread.is_alive() and total_waited < extract_timeout: + update_progress(30 + int(30 * total_waited / extract_timeout), + f"正在解压文件...已等待{total_waited}秒") + extract_thread.join(5) # 等待5秒 + total_waited += 5 + + # 检查是否超时 + if extract_thread.is_alive(): + debug_logger.error(f"解压超时(超过{extract_timeout}秒)") + raise TimeoutError(f"解压超时(超过{extract_timeout}秒),请检查补丁文件是否完整") + + # 检查解压结果 + if not extract_result.empty(): + status, error = extract_result.get() + if status == "error": + debug_logger.error(f"解压错误: {error}") + raise error + + debug_logger.debug(f"文件解压完成") + + update_progress(60, f"正在复制 {self.game_version} 的补丁文件...") + + # 复制主补丁文件到游戏目录 + extracted_file_path = os.path.join(temp_dir, target_file_in_archive) + + # 检查解压后的文件是否存在及其大小 + if os.path.exists(extracted_file_path): + file_size = os.path.getsize(extracted_file_path) + debug_logger.debug(f"解压后的主补丁文件存在: {extracted_file_path}, 大小: {file_size} 字节") + else: + debug_logger.error(f"解压后的主补丁文件不存在: {extracted_file_path}") + raise FileNotFoundError(f"解压后的文件不存在: {extracted_file_path}") + + # 构建目标路径并复制 + target_path = os.path.join(self.game_folder, target_filename) + debug_logger.debug(f"复制主补丁文件: {extracted_file_path} 到 {target_path}") + shutil.copy2(extracted_file_path, target_path) + + # 验证主补丁文件是否成功复制 + if os.path.exists(target_path): + target_size = os.path.getsize(target_path) + debug_logger.debug(f"主补丁文件成功复制: {target_path}, 大小: {target_size} 字节") + else: + debug_logger.error(f"主补丁文件复制失败: {target_path}") + raise FileNotFoundError(f"目标文件复制失败: {target_path}") + + # 只有NEKOPARA After版本才需要处理签名文件 + if self.game_version == "NEKOPARA After": + # 如果有找到签名文件,也复制它 + if sig_file_in_archive: + update_progress(80, f"正在复制签名文件...") + extracted_sig_path = os.path.join(temp_dir, sig_file_in_archive) + + if os.path.exists(extracted_sig_path): + sig_size = os.path.getsize(extracted_sig_path) + debug_logger.debug(f"解压后的签名文件存在: {extracted_sig_path}, 大小: {sig_size} 字节") + + # 复制签名文件到游戏目录 + sig_target = os.path.join(self.game_folder, sig_filename) + shutil.copy2(extracted_sig_path, sig_target) + debug_logger.debug(f"签名文件成功复制: {sig_target}") + else: + debug_logger.warning(f"解压后的签名文件不存在: {extracted_sig_path}") + else: + debug_logger.warning(f"压缩包中没有找到签名文件,但继续安装主补丁文件") + else: + debug_logger.info(f"{self.game_version} 不需要签名文件,跳过签名文件处理") + + update_progress(100, f"{self.game_version} 补丁文件解压完成") + self.finished.emit(True, "", self.game_version) except (py7zr.Bad7zFile, FileNotFoundError, Exception) as e: - self.finished.emit(False, f"\n文件操作失败,请重试\n\n【错误信息】:{e}\n", self.game_version) \ No newline at end of file + try: + self.progress.emit(100, f"处理 {self.game_version} 的补丁文件失败") + except Exception: + pass + self.finished.emit(False, f"\n文件操作失败,请重试\n\n【错误信息】:{e}\n", self.game_version) + + \ No newline at end of file diff --git a/source/workers/hash_thread.py b/source/workers/hash_thread.py index 008c6b7..665ff34 100644 --- a/source/workers/hash_thread.py +++ b/source/workers/hash_thread.py @@ -1,28 +1,545 @@ +import os +import hashlib +import py7zr +import tempfile +import traceback +import time # Added for time.time() from PySide6.QtCore import QThread, Signal -from utils import HashManager -from data.config import BLOCK_SIZE +from PySide6.QtWidgets import QApplication +from utils.logger import setup_logger + +# 初始化logger +logger = setup_logger("hash_thread") class HashThread(QThread): pre_finished = Signal(dict) after_finished = Signal(dict) - - def __init__(self, mode, install_paths, plugin_hash, installed_status, parent=None): - super().__init__(parent) + + def __init__(self, mode, install_paths, plugin_hash, installed_status, main_window=None): + """初始化哈希检查线程 + + Args: + mode: 检查模式,"pre"或"after" + install_paths: 安装路径字典 + plugin_hash: 插件哈希值字典 + installed_status: 安装状态字典 + main_window: 主窗口实例,用于访问UI和状态 + """ + super().__init__() self.mode = mode self.install_paths = install_paths self.plugin_hash = plugin_hash - self.installed_status = installed_status - # 每个线程都应该有自己的HashManager实例 - self.hash_manager = HashManager(BLOCK_SIZE) - + self.installed_status = installed_status.copy() + self.main_window = main_window + def run(self): + """运行线程""" + debug_mode = False + + # 设置超时限制(分钟) + timeout_minutes = 10 + max_execution_time = timeout_minutes * 60 # 转换为秒 + start_execution_time = time.time() + + # 尝试检测是否处于调试模式 + if self.main_window and hasattr(self.main_window, 'debug_manager'): + debug_mode = self.main_window.debug_manager._is_debug_mode() + + if debug_mode: + logger.debug(f"DEBUG: 设置哈希计算超时时间: {timeout_minutes} 分钟") + + # 在各个关键步骤添加超时检测 + def check_timeout(): + elapsed = time.time() - start_execution_time + if elapsed > max_execution_time: + if debug_mode: + logger.error(f"DEBUG: 哈希计算超时,已执行 {elapsed:.1f} 秒,超过限制的 {max_execution_time} 秒") + return True + return False + if self.mode == "pre": - updated_status = self.hash_manager.cfg_pre_hash_compare( - self.install_paths, self.plugin_hash, self.installed_status - ) - self.pre_finished.emit(updated_status) + 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: + logger.debug(f"DEBUG: 哈希预检查 - {game_version} 补丁文件不存在: {install_path}") + continue + + try: + expected_hash = self.plugin_hash.get(game_version, "") + if not expected_hash: + if debug_mode: + logger.debug(f"DEBUG: 哈希预检查 - {game_version} 没有预期哈希值,跳过哈希检查") + # 当没有预期哈希值时,保持当前状态不变 + continue + + # 分块读取,避免大文件一次性读取内存 + hash_obj = hashlib.sha256() + with open(install_path, "rb") as f: + while True: + if self.isInterruptionRequested(): + break + # 检查超时 + if check_timeout(): + logger.error(f"哈希计算超时,强制终止") + result["passed"] = False + result["game"] = game_version + result["message"] = f"\n{game_version} 哈希计算超时,已超过 {timeout_minutes} 分钟。\n\n请考虑跳过哈希校验或稍后再试。\n" + 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}") + logger.debug(f"DEBUG: 文件路径: {install_path}") + logger.debug(f"DEBUG: 预期哈希值: {expected_hash}") + logger.debug(f"DEBUG: 实际哈希值: {file_hash}") + logger.debug(f"DEBUG: 哈希匹配: {file_hash == expected_hash}") + + if file_hash == expected_hash: + status_copy[game_version] = True + if debug_mode: + logger.debug(f"DEBUG: 哈希预检查 - {game_version} 哈希匹配成功") + else: + status_copy[game_version] = False + if debug_mode: + logger.debug(f"DEBUG: 哈希预检查 - {game_version} 哈希不匹配") + except Exception as e: + status_copy[game_version] = False + if debug_mode: + logger.debug(f"DEBUG: 哈希预检查异常 - {game_version}: {str(e)}") + + self.pre_finished.emit(status_copy) + elif self.mode == "after": - result = self.hash_manager.cfg_after_hash_compare( - self.install_paths, self.plugin_hash, self.installed_status - ) - self.after_finished.emit(result) \ No newline at end of file + 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}") + continue + + # 设置当前处理的游戏版本 + result["game"] = game_version + + try: + expected_hash = self.plugin_hash.get(game_version, "") + if not expected_hash: + if debug_mode: + logger.debug(f"DEBUG: 哈希后检查 - {game_version} 没有预期哈希值,跳过哈希检查") + # 当没有预期哈希值时,跳过检查 + continue + + # 检查文件存在和可读性 + if not os.path.exists(install_path): + logger.error(f"哈希校验失败 - 文件不存在: {install_path}") + result["passed"] = False + result["game"] = game_version + result["message"] = f"\n{game_version} 安装后的文件不存在,无法校验。\n\n文件路径: {install_path}\n" + break + + # 记录文件大小信息 + file_size = os.path.getsize(install_path) + logger.info(f"开始校验 {game_version} 补丁文件") + logger.debug(f"文件路径: {install_path}, 文件大小: {file_size} 字节") + + # 增加块大小,提高大文件处理性能 + # 文件越大,块越大,最大256MB + chunk_size = min(256 * 1024 * 1024, max(16 * 1024 * 1024, file_size // 20)) + logger.debug(f"使用块大小: {chunk_size // (1024 * 1024)}MB") + + # 分块读取,避免大文件一次性读取内存 + hash_obj = hashlib.sha256() + bytes_read = 0 + start_time = time.time() + last_progress_time = start_time + with open(install_path, "rb") as f: + while True: + if self.isInterruptionRequested(): + break + # 检查超时 + if check_timeout(): + logger.error(f"哈希计算超时,强制终止") + result["passed"] = False + result["game"] = game_version + result["message"] = f"\n{game_version} 哈希计算超时,已超过 {timeout_minutes} 分钟。\n\n请考虑跳过哈希校验或稍后再试。\n" + break + chunk = f.read(chunk_size) + if not chunk: + break + bytes_read += len(chunk) + hash_obj.update(chunk) + + # 每秒更新一次进度 + current_time = time.time() + if current_time - last_progress_time >= 1.0: + progress = bytes_read / file_size * 100 + elapsed = current_time - start_time + speed = bytes_read / (elapsed if elapsed > 0 else 1) / (1024 * 1024) # MB/s + logger.debug(f"哈希计算进度: {progress:.1f}% - 已处理: {bytes_read/(1024*1024):.1f}MB/{file_size/(1024*1024):.1f}MB - 速度: {speed:.1f}MB/s") + last_progress_time = current_time + + # 计算最终的哈希值 + file_hash = hash_obj.hexdigest() + + # 记录总用时 + total_time = time.time() - start_time + logger.debug(f"哈希计算完成,耗时: {total_time:.1f}秒,平均速度: {file_size/(total_time*1024*1024):.1f}MB/s") + + # 记录哈希比较结果 + is_valid = file_hash == expected_hash + logger.info(f"{game_version} 哈希校验{'通过' if is_valid else '失败'}") + logger.debug(f"哈希校验详情 - {game_version}:") + logger.debug(f" 文件: {install_path}") + logger.debug(f" 读取字节数: {bytes_read} / {file_size}") + logger.debug(f" 预期哈希: {expected_hash}") + logger.debug(f" 实际哈希: {file_hash}") + + if debug_mode: + logger.debug(f"DEBUG: 哈希后检查 - {game_version}") + logger.debug(f"DEBUG: 文件路径: {install_path}") + logger.debug(f"DEBUG: 预期哈希值: {expected_hash}") + logger.debug(f"DEBUG: 实际哈希值: {file_hash}") + logger.debug(f"DEBUG: 哈希匹配: {file_hash == expected_hash}") + + if file_hash != expected_hash: + result["passed"] = False + result["game"] = game_version + result["message"] = f"\n{game_version} 安装后的文件校验失败。\n\n文件可能已损坏或被篡改,请重新安装。\n预期哈希: {expected_hash[:10]}...\n实际哈希: {file_hash[:10]}...\n" + if debug_mode: + logger.debug(f"DEBUG: 哈希后检查 - {game_version} 哈希不匹配") + break + elif debug_mode: + logger.debug(f"DEBUG: 哈希后检查 - {game_version} 哈希匹配成功") + except Exception as e: + result["passed"] = False + result["game"] = game_version + result["message"] = f"\n{game_version} 安装后的文件校验过程中发生错误。\n\n错误信息: {str(e)}\n" + if debug_mode: + logger.debug(f"DEBUG: 哈希后检查异常 - {game_version}: {str(e)}") + break + + self.after_finished.emit(result) + + +class OfflineHashVerifyThread(QThread): + """离线模式下验证补丁文件哈希的线程,支持进度更新""" + + progress = Signal(int) # 进度信号,0-100 + finished = Signal(bool, str, str) # 完成信号,(成功/失败, 错误信息, 解压后的补丁文件路径) + + def __init__(self, game_version, file_path, plugin_hash, main_window=None): + super().__init__() + self.game_version = game_version + self.file_path = file_path + self.plugin_hash = plugin_hash + self.main_window = main_window + self.extracted_patch_path = None # 添加解压后的补丁文件路径 + + # 获取预期的哈希值 + self.expected_hash = None + + # 直接使用完整游戏名称作为键 + self.expected_hash = self.plugin_hash.get(game_version, "") + + # 设置调试模式标志 + self.debug_mode = False + if main_window and hasattr(main_window, 'debug_manager'): + self.debug_mode = main_window.debug_manager._is_debug_mode() + + def run(self): + """运行线程""" + debug_mode = False + + # 设置超时限制(分钟) + timeout_minutes = 10 + max_execution_time = timeout_minutes * 60 # 转换为秒 + start_execution_time = time.time() + + # 尝试检测是否处于调试模式 + if self.main_window and hasattr(self.main_window, 'debug_manager'): + debug_mode = self.main_window.debug_manager._is_debug_mode() + + # 检查超时的函数 + def check_timeout(): + elapsed = time.time() - start_execution_time + if elapsed > max_execution_time: + if debug_mode: + logger.debug(f"DEBUG: 哈希校验超时,已运行 {elapsed:.1f} 秒") + return True + return False + + # 获取预期的哈希值 + expected_hash = self.plugin_hash.get(self.game_version, "") + + if not expected_hash: + logger.warning(f"DEBUG: 未找到 {self.game_version} 的预期哈希值") + self.progress.emit(100) + self.finished.emit(False, f"未找到 {self.game_version} 的预期哈希值", "") + return + + if debug_mode: + logger.debug(f"DEBUG: 开始验证补丁文件: {self.file_path}") + logger.debug(f"DEBUG: 游戏版本: {self.game_version}") + logger.debug(f"DEBUG: 预期哈希值: {expected_hash}") + + try: + # 检查文件是否存在 + if not os.path.exists(self.file_path): + if debug_mode: + logger.warning(f"DEBUG: 补丁文件不存在: {self.file_path}") + self.progress.emit(100) + self.finished.emit(False, f"补丁文件不存在: {self.file_path}", "") + return + + # 检查文件大小 + file_size = os.path.getsize(self.file_path) + if debug_mode: + logger.debug(f"DEBUG: 补丁文件大小: {file_size} 字节") + + if file_size == 0: + if debug_mode: + logger.warning(f"DEBUG: 补丁文件大小为0,无效文件") + self.progress.emit(100) + self.finished.emit(False, "补丁文件大小为0,无效文件", "") + return + + # 创建临时目录用于解压文件 + with tempfile.TemporaryDirectory() as temp_dir: + if debug_mode: + logger.debug(f"DEBUG: 创建临时目录: {temp_dir}") + + # 发送进度信号 - 10% + self.progress.emit(10) + + # 解压补丁文件 + try: + if debug_mode: + logger.debug(f"DEBUG: 开始解压文件: {self.file_path}") + + # 确定目标文件名 + target_filename = None + if "Vol.1" in self.game_version: + target_filename = "adultsonly.xp3" + elif "Vol.2" in self.game_version: + target_filename = "adultsonly.xp3" + elif "Vol.3" in self.game_version: + target_filename = "update00.int" + elif "Vol.4" in self.game_version: + target_filename = "vol4adult.xp3" + elif "After" in self.game_version: + target_filename = "afteradult.xp3" + + if not target_filename: + if debug_mode: + logger.warning(f"DEBUG: 未知的游戏版本: {self.game_version}") + self.progress.emit(100) + self.finished.emit(False, f"未知的游戏版本: {self.game_version}", "") + return + + with py7zr.SevenZipFile(self.file_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}") + # 尝试查找可能的替代文件 + alternative_files = [] + for file_path in file_list: + if file_path.endswith('.xp3') or file_path.endswith('.int'): + alternative_files.append(file_path) + + if alternative_files: + if debug_mode: + logger.debug(f"DEBUG: 找到可能的替代文件: {alternative_files}") + target_file_in_archive = alternative_files[0] + else: + # 如果找不到任何替代文件,解压全部文件 + if debug_mode: + logger.debug(f"DEBUG: 未找到任何替代文件,解压全部文件") + archive.extractall(path=temp_dir) + + # 尝试在解压后的目录中查找目标文件 + for root, dirs, files in os.walk(temp_dir): + for file in files: + if file.endswith('.xp3') or file.endswith('.int'): + patch_file = os.path.join(root, file) + if debug_mode: + logger.debug(f"DEBUG: 找到可能的补丁文件: {patch_file}") + break + if patch_file: + break + + if not patch_file: + if debug_mode: + logger.warning(f"DEBUG: 未找到解压后的补丁文件") + self.progress.emit(100) + self.finished.emit(False, "未找到解压后的补丁文件", "") + return + else: + # 只解压目标文件 + if debug_mode: + logger.debug(f"DEBUG: 解压目标文件: {target_file_in_archive}") + archive.extract(path=temp_dir, targets=[target_file_in_archive]) + patch_file = os.path.join(temp_dir, target_file_in_archive) + + # 发送进度信号 - 50% + self.progress.emit(50) + + # 如果还没有设置patch_file,尝试查找 + if not 'patch_file' in locals(): + if "Vol.1" in self.game_version: + patch_file = os.path.join(temp_dir, "vol.1", "adultsonly.xp3") + elif "Vol.2" in self.game_version: + patch_file = os.path.join(temp_dir, "vol.2", "adultsonly.xp3") + elif "Vol.3" in self.game_version: + patch_file = os.path.join(temp_dir, "vol.3", "update00.int") + elif "Vol.4" in self.game_version: + patch_file = os.path.join(temp_dir, "vol.4", "vol4adult.xp3") + elif "After" in self.game_version: + patch_file = os.path.join(temp_dir, "after", "afteradult.xp3") + + if not os.path.exists(patch_file): + if debug_mode: + logger.warning(f"DEBUG: 未找到解压后的补丁文件: {patch_file}") + # 尝试查找可能的替代文件 + alternative_files = [] + for root, dirs, files in os.walk(temp_dir): + for file in files: + if file.endswith('.xp3') or file.endswith('.int'): + alternative_files.append(os.path.join(root, file)) + if alternative_files: + logger.debug(f"DEBUG: 找到可能的替代文件: {alternative_files}") + patch_file = alternative_files[0] + else: + # 检查解压目录结构 + logger.debug(f"DEBUG: 检查解压目录结构:") + for root, dirs, files in os.walk(temp_dir): + logger.debug(f"DEBUG: 目录: {root}") + logger.debug(f"DEBUG: 子目录: {dirs}") + logger.debug(f"DEBUG: 文件: {files}") + + if not os.path.exists(patch_file): + self.progress.emit(100) + self.finished.emit(False, f"未找到解压后的补丁文件", "") + return + + # 发送进度信号 - 70% + self.progress.emit(70) + + if debug_mode: + logger.debug(f"DEBUG: 找到解压后的补丁文件: {patch_file}") + + # 计算补丁文件哈希值 + try: + # 读取文件内容并计算哈希值,同时更新进度 + file_size = os.path.getsize(patch_file) + + # 根据文件大小动态调整块大小 + # 文件越大,块越大,最大256MB + chunk_size = min(256 * 1024 * 1024, max(16 * 1024 * 1024, file_size // 20)) + if debug_mode: + logger.debug(f"DEBUG: 文件大小: {file_size} 字节, 使用块大小: {chunk_size // (1024 * 1024)}MB") + + hash_obj = hashlib.sha256() + + with open(patch_file, "rb") as f: + bytes_read = 0 + start_time = time.time() + last_progress_time = start_time + + while True: + if self.isInterruptionRequested(): + break + # 检查超时 + if check_timeout(): + logger.error(f"哈希计算超时,强制终止") + self.progress.emit(100) + self.finished.emit( + False, + f"{self.game_version} 哈希计算超时,已超过 {timeout_minutes} 分钟。请考虑跳过哈希校验或稍后再试。", + "" + ) + return + chunk = f.read(chunk_size) + if not chunk: + break + hash_obj.update(chunk) + bytes_read += len(chunk) + + # 计算进度 (70-95%) + progress = 70 + int(25 * bytes_read / file_size) + self.progress.emit(min(95, progress)) + + # 每秒更新一次日志进度 + current_time = time.time() + if debug_mode and current_time - last_progress_time >= 1.0: + elapsed = current_time - start_time + speed = bytes_read / (elapsed if elapsed > 0 else 1) / (1024 * 1024) # MB/s + percent = bytes_read / file_size * 100 + logger.debug(f"DEBUG: 哈希计算进度 - {percent:.1f}% - 已处理: {bytes_read/(1024*1024):.1f}MB/{file_size/(1024*1024):.1f}MB - 速度: {speed:.1f}MB/s") + last_progress_time = current_time + + # 记录总用时 + if debug_mode: + total_time = time.time() - start_time + logger.debug(f"DEBUG: 哈希计算完成,耗时: {total_time:.1f}秒,平均速度: {file_size/(total_time*1024*1024):.1f}MB/s") + + file_hash = hash_obj.hexdigest() + + # 比较哈希值 + result = file_hash.lower() == expected_hash.lower() + + # 发送进度信号 - 100% + self.progress.emit(100) + + if debug_mode: + logger.debug(f"DEBUG: 补丁文件 {patch_file} 哈希值验证: {'成功' if result else '失败'}") + logger.debug(f"DEBUG: 预期哈希值: {expected_hash}") + logger.debug(f"DEBUG: 实际哈希值: {file_hash}") + + # 将验证结果和解压后的文件路径传递回去 + # 注意:由于使用了临时目录,此路径在函数返回后将不再有效 + # 但这里返回的路径只是用于标识验证成功,实际安装时会重新解压 + self.finished.emit(result, "" if result else "补丁文件哈希验证失败,文件可能已损坏或被篡改", patch_file if result else "") + except Exception as e: + if debug_mode: + logger.error(f"DEBUG: 计算补丁文件哈希值失败: {e}") + logger.error(f"DEBUG: 错误类型: {type(e).__name__}") + self.progress.emit(100) + 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()}") + self.progress.emit(100) + 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()}" ) + self.progress.emit(100) + self.finished.emit(False, f"验证补丁哈希值失败: {str(e)}", "") \ No newline at end of file