diff --git a/source/Main.py b/source/Main.py index 353eae0..c7c8301 100644 --- a/source/Main.py +++ b/source/Main.py @@ -3,9 +3,9 @@ import os import datetime from PySide6.QtWidgets import QApplication, QMessageBox from main_window import MainWindow -from core.privacy_manager import PrivacyManager +from core.managers.privacy_manager import PrivacyManager from utils.logger import setup_logger -from data.config import LOG_FILE, APP_NAME +from config.config import LOG_FILE, APP_NAME from utils import load_config if __name__ == "__main__": diff --git a/source/STRUCTURE.md b/source/STRUCTURE.md new file mode 100644 index 0000000..e516569 --- /dev/null +++ b/source/STRUCTURE.md @@ -0,0 +1,63 @@ +# FRAISEMOE Addons Installer NEXT - 项目结构 + +## 目录结构 + +``` +source/ +├── assets/ # 所有静态资源文件 +│ ├── fonts/ # 字体文件 +│ ├── images/ # 图片资源 +│ └── resources/ # 其他资源文件 +├── bin/ # 二进制工具文件 +├── config/ # 配置文件 +├── core/ # 核心功能模块 +│ ├── managers/ # 所有管理器类 +│ └── handlers/ # 处理器类 +├── data/ # 数据文件 +├── ui/ # 用户界面相关 +│ ├── components/ # UI组件 +│ ├── windows/ # 窗口定义 +│ └── views/ # 视图定义 +├── utils/ # 工具类和辅助函数 +├── workers/ # 后台工作线程 +└── main.py # 主入口文件 +``` + +## 文件路径映射 + +| 重构前 | 重构后 | +| ------ | ------ | +| source/Main.py | source/main.py | +| source/fonts/* | source/assets/fonts/* | +| source/IMG/* | source/assets/images/* | +| source/resources/* | source/assets/resources/* | +| source/data/config.py | source/config/config.py | +| source/data/privacy_policy.py | source/config/privacy_policy.py | +| source/core/animations.py | source/core/managers/animations.py | +| source/core/cloudflare_optimizer.py | source/core/managers/cloudflare_optimizer.py | +| source/core/config_manager.py | source/core/managers/config_manager.py | +| source/core/debug_manager.py | source/core/managers/debug_manager.py | +| source/core/download_manager.py | source/core/managers/download_manager.py | +| source/core/download_task_manager.py | source/core/managers/download_task_manager.py | +| source/core/extraction_handler.py | source/core/handlers/extraction_handler.py | +| source/core/game_detector.py | source/core/managers/game_detector.py | +| source/core/ipv6_manager.py | source/core/managers/ipv6_manager.py | +| source/core/offline_mode_manager.py | source/core/managers/offline_mode_manager.py | +| source/core/patch_detector.py | source/core/managers/patch_detector.py | +| source/core/patch_manager.py | source/core/managers/patch_manager.py | +| source/core/privacy_manager.py | source/core/managers/privacy_manager.py | +| source/core/ui_manager.py | source/core/managers/ui_manager.py | +| source/core/window_manager.py | source/core/managers/window_manager.py | +| source/handlers/* | source/core/handlers/* | + +## 模块职责划分 + +1. **managers**: 负责管理应用程序的各个方面,如配置、下载、游戏检测等。 +2. **handlers**: 负责处理特定的操作,如提取文件、打补丁、卸载等。 +3. **assets**: 存储应用程序使用的静态资源。 +4. **config**: 存储应用程序的配置信息。 +5. **ui**: 负责用户界面相关的组件和视图。 +6. **utils**: 提供各种实用工具函数。 +7. **workers**: 负责在后台执行耗时操作的线程。 + +这种结构更加清晰地区分了各个模块的职责,使代码更容易维护和扩展。 \ 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/assets/fonts/SmileySans-Oblique.ttf b/source/assets/fonts/SmileySans-Oblique.ttf new file mode 100644 index 0000000..c297dc6 Binary files /dev/null and b/source/assets/fonts/SmileySans-Oblique.ttf differ diff --git a/source/assets/images/After/voaf_ga01.jpg b/source/assets/images/After/voaf_ga01.jpg new file mode 100644 index 0000000..9412413 Binary files /dev/null and b/source/assets/images/After/voaf_ga01.jpg differ diff --git a/source/assets/images/After/voaf_ga02.jpg b/source/assets/images/After/voaf_ga02.jpg new file mode 100644 index 0000000..a5377d5 Binary files /dev/null and b/source/assets/images/After/voaf_ga02.jpg differ diff --git a/source/assets/images/BG/bg1.jpg b/source/assets/images/BG/bg1.jpg new file mode 100644 index 0000000..9dbe5b5 Binary files /dev/null and b/source/assets/images/BG/bg1.jpg differ diff --git a/source/assets/images/BG/bg2.jpg b/source/assets/images/BG/bg2.jpg new file mode 100644 index 0000000..e83bcf1 Binary files /dev/null and b/source/assets/images/BG/bg2.jpg differ diff --git a/source/assets/images/BG/bg3.jpg b/source/assets/images/BG/bg3.jpg new file mode 100644 index 0000000..2acb228 Binary files /dev/null and b/source/assets/images/BG/bg3.jpg differ diff --git a/source/assets/images/BG/bg4.jpg b/source/assets/images/BG/bg4.jpg new file mode 100644 index 0000000..dbed973 Binary files /dev/null and b/source/assets/images/BG/bg4.jpg differ diff --git a/source/assets/images/BG/menubg.jpg b/source/assets/images/BG/menubg.jpg new file mode 100644 index 0000000..9cde541 Binary files /dev/null and b/source/assets/images/BG/menubg.jpg differ diff --git a/source/assets/images/BG/title_bg1.png b/source/assets/images/BG/title_bg1.png new file mode 100644 index 0000000..8b39823 Binary files /dev/null and b/source/assets/images/BG/title_bg1.png differ diff --git a/source/assets/images/BG/title_bg2.png b/source/assets/images/BG/title_bg2.png new file mode 100644 index 0000000..8e2bd7f Binary files /dev/null and b/source/assets/images/BG/title_bg2.png differ diff --git a/source/assets/images/BTN/Button.png b/source/assets/images/BTN/Button.png new file mode 100644 index 0000000..671274c Binary files /dev/null and b/source/assets/images/BTN/Button.png differ diff --git a/source/assets/images/BTN/exit.bmp b/source/assets/images/BTN/exit.bmp new file mode 100644 index 0000000..93303e3 Binary files /dev/null and b/source/assets/images/BTN/exit.bmp differ diff --git a/source/assets/images/BTN/start_install.bmp b/source/assets/images/BTN/start_install.bmp new file mode 100644 index 0000000..b9b7404 Binary files /dev/null and b/source/assets/images/BTN/start_install.bmp differ diff --git a/source/assets/images/ICO/cloudflare_logo_icon.ico b/source/assets/images/ICO/cloudflare_logo_icon.ico new file mode 100644 index 0000000..0c58cfc Binary files /dev/null and b/source/assets/images/ICO/cloudflare_logo_icon.ico differ diff --git a/source/assets/images/ICO/icon.ico b/source/assets/images/ICO/icon.ico new file mode 100644 index 0000000..4bf517d Binary files /dev/null and b/source/assets/images/ICO/icon.ico differ diff --git a/source/assets/images/ICO/icon.png b/source/assets/images/ICO/icon.png new file mode 100644 index 0000000..d5ca97d Binary files /dev/null and b/source/assets/images/ICO/icon.png differ diff --git a/source/assets/images/LOGO/gl_head_logo_jp.png b/source/assets/images/LOGO/gl_head_logo_jp.png new file mode 100644 index 0000000..fe01f25 Binary files /dev/null and b/source/assets/images/LOGO/gl_head_logo_jp.png differ diff --git a/source/assets/images/LOGO/vo01_logo.png b/source/assets/images/LOGO/vo01_logo.png new file mode 100644 index 0000000..e88567b Binary files /dev/null and b/source/assets/images/LOGO/vo01_logo.png differ diff --git a/source/assets/images/LOGO/vo02_logo.png b/source/assets/images/LOGO/vo02_logo.png new file mode 100644 index 0000000..a8b948c Binary files /dev/null and b/source/assets/images/LOGO/vo02_logo.png differ diff --git a/source/assets/images/LOGO/vo03_logo.png b/source/assets/images/LOGO/vo03_logo.png new file mode 100644 index 0000000..6273e6b Binary files /dev/null and b/source/assets/images/LOGO/vo03_logo.png differ diff --git a/source/assets/images/LOGO/vo04_logo.png b/source/assets/images/LOGO/vo04_logo.png new file mode 100644 index 0000000..f110beb Binary files /dev/null and b/source/assets/images/LOGO/vo04_logo.png differ diff --git a/source/assets/images/LOGO/voaf_logo.png b/source/assets/images/LOGO/voaf_logo.png new file mode 100644 index 0000000..3470111 Binary files /dev/null and b/source/assets/images/LOGO/voaf_logo.png differ diff --git a/source/assets/images/vol4/vo04_ga01.jpg b/source/assets/images/vol4/vo04_ga01.jpg new file mode 100644 index 0000000..8bc9bf0 Binary files /dev/null and b/source/assets/images/vol4/vo04_ga01.jpg differ diff --git a/source/assets/images/vol4/vo04_ga05.jpg b/source/assets/images/vol4/vo04_ga05.jpg new file mode 100644 index 0000000..4afc4de Binary files /dev/null and b/source/assets/images/vol4/vo04_ga05.jpg differ diff --git a/source/assets/images/vol4/vo04_ga06.jpg b/source/assets/images/vol4/vo04_ga06.jpg new file mode 100644 index 0000000..be16715 Binary files /dev/null and b/source/assets/images/vol4/vo04_ga06.jpg differ diff --git a/source/assets/images/vol4/vo04_ga07.jpg b/source/assets/images/vol4/vo04_ga07.jpg new file mode 100644 index 0000000..86cf2c3 Binary files /dev/null and b/source/assets/images/vol4/vo04_ga07.jpg differ diff --git a/source/config/__init__.py b/source/config/__init__.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/source/config/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/source/config/config.py b/source/config/config.py new file mode 100644 index 0000000..c0ae378 --- /dev/null +++ b/source/config/config.py @@ -0,0 +1,91 @@ +import os +import base64 +import datetime + +# 配置信息 +app_data = { + "APP_VERSION": "1.3.2", + "APP_NAME": "FRAISEMOE Addons Installer NEXT", + "TEMP": "TEMP", + "CACHE": "FRAISEMOE", + "PLUGIN": "PLUGIN", + "CONFIG_URL": "aHR0cHM6Ly9hcGkuMncyLnRvcC9hcGkvb3V5YW5ncWlxaS9uZWtvcGFyYS9kb3dubG9hZF91cmwuanNvbg==", + "UA_TEMPLATE": "Mozilla/5.0 (Linux debian12 FraiseMoe2-Accept-Next) Gecko/20100101 Firefox/114.0 FraiseMoe2/{}", + "game_info": { + "NEKOPARA Vol.1": { + "exe": "nekopara_vol1.exe", + "hash": "04b48b231a7f34431431e5027fcc7b27affaa951b8169c541709156acf754f3e", + "install_path": "NEKOPARA Vol. 1/adultsonly.xp3", + "plugin_path": "vol.1/adultsonly.xp3", + }, + "NEKOPARA Vol.2": { + "exe": "nekopara_vol2.exe", + "hash": "b9c00a2b113a1e768bf78400e4f9075ceb7b35349cdeca09be62eb014f0d4b42", + "install_path": "NEKOPARA Vol. 2/adultsonly.xp3", + "plugin_path": "vol.2/adultsonly.xp3", + }, + "NEKOPARA Vol.3": { + "exe": "NEKOPARAvol3.exe", + "hash": "2ce7b223c84592e1ebc3b72079dee1e5e8d064ade15723328a64dee58833b9d5", + "install_path": "NEKOPARA Vol. 3/update00.int", + "plugin_path": "vol.3/update00.int", + }, + "NEKOPARA Vol.4": { + "exe": "nekopara_vol4.exe", + "hash": "4a4a9ae5a75a18aacbe3ab0774d7f93f99c046afe3a777ee0363e8932b90f36a", + "install_path": "NEKOPARA Vol. 4/vol4adult.xp3", + "plugin_path": "vol.4/vol4adult.xp3", + }, + "NEKOPARA After": { + "exe": "nekopara_after.exe", + "hash": "eb26ff6850096a240af8340ba21c5c3232e90f29fb8191e24b6ce701acae0aa9", + "install_path": "NEKOPARA After/afteradult.xp3", + "plugin_path": "after/afteradult.xp3", + "sig_path": "after/afteradult.xp3.sig" + }, + }, +} + +# Base64解码 +def decode_base64(encoded_str): + return base64.b64decode(encoded_str).decode("utf-8") + +# 全局变量 +APP_VERSION = app_data["APP_VERSION"] +APP_NAME = app_data["APP_NAME"] +TEMP = os.getenv(app_data["TEMP"]) or app_data["TEMP"] +CACHE = os.path.join(TEMP, app_data["CACHE"]) +CONFIG_FILE = os.path.join(CACHE, "config.json") + +# 将log文件放在程序根目录下的log文件夹中,使用日期+时间戳格式命名 +root_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +log_dir = os.path.join(root_dir, "log") +current_datetime = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") +LOG_FILE = os.path.join(log_dir, f"log-{current_datetime}.txt") + +PLUGIN = os.path.join(CACHE, app_data["PLUGIN"]) +CONFIG_URL = decode_base64(app_data["CONFIG_URL"]) +UA = app_data["UA_TEMPLATE"].format(APP_VERSION) +GAME_INFO = app_data["game_info"] +BLOCK_SIZE = 67108864 +HASH_SIZE = 134217728 +PLUGIN_HASH = { + "NEKOPARA Vol.1": GAME_INFO["NEKOPARA Vol.1"]["hash"], + "NEKOPARA Vol.2": GAME_INFO["NEKOPARA Vol.2"]["hash"], + "NEKOPARA Vol.3": GAME_INFO["NEKOPARA Vol.3"]["hash"], + "NEKOPARA Vol.4": GAME_INFO["NEKOPARA Vol.4"]["hash"], + "NEKOPARA After": GAME_INFO["NEKOPARA After"]["hash"] +} +PROCESS_INFO = {info["exe"]: game for game, info in GAME_INFO.items()} + +# 下载线程档位设置 +DOWNLOAD_THREADS = { + "low": 1, # 低速 + "medium": 8, # 中速(默认) + "high": 16, # 高速 + "extreme": 32, # 极速 + "insane": 64 # 狂暴 +} + +# 默认下载线程档位 +DEFAULT_DOWNLOAD_THREAD_LEVEL = "high" \ No newline at end of file diff --git a/source/config/privacy_policy.py b/source/config/privacy_policy.py new file mode 100644 index 0000000..1d45dc4 --- /dev/null +++ b/source/config/privacy_policy.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import re +import sys +from datetime import datetime +from utils.logger import setup_logger + +# 初始化logger +logger = setup_logger("privacy_policy") + +# 隐私协议的缩略版内容 +PRIVACY_POLICY_BRIEF = """ +# FRAISEMOE Addons Installer NEXT 隐私政策摘要 + +本应用在运行过程中会收集和处理以下信息: + +## 收集的信息 +- **系统信息**:程序版本号。 +- **网络信息**:IP 地址、ISP、地理位置(用于使用统计)、下载统计、IPv6 连接测试(通过访问 testipv6.cn)、IPv6 地址获取(通过 ipw.cn)。 +- **文件信息**:游戏安装路径、文件哈希值。 + +## 系统修改 +- 使用 Cloudflare 加速时会临时修改系统 hosts 文件。 +- 修改前会自动备份,程序退出时自动恢复。 + +## 第三方服务 +- **Cloudflare 服务**:通过开源项目 CloudflareSpeedTest (CFST) 提供,用于优化下载速度。此过程会将您的 IP 提交至 Cloudflare 节点。 +- **云端配置服务**:获取配置信息。服务器会记录您的 IP、ISP 及地理位置用于统计。 +- **IPv6 测试服务**:应用使用 testipv6.cn 和 ipw.cn 测试和获取 IPv6 连接信息。 + +完整的隐私政策可在本程序的 GitHub 仓库中查看。 +""" + +# 隐私协议的英文版缩略版内容 +PRIVACY_POLICY_BRIEF_EN = """ +# FRAISEMOE Addons Installer NEXT Privacy Policy Summary + +This application collects and processes the following information: + +## Information Collected +- **System info**: Application version. +- **Network info**: IP address, ISP, geographic location (for usage statistics), download statistics, IPv6 connectivity test (via testipv6.cn), IPv6 address acquisition (via ipw.cn). +- **File info**: Game installation paths, file hash values. + +## System Modifications +- Temporarily modifies system hosts file when using Cloudflare acceleration. +- Automatically backs up before modification and restores upon exit. + +## Third-party Services +- **Cloudflare services**: Provided via the open-source project CloudflareSpeedTest (CFST) to optimize download speeds. This process submits your IP to Cloudflare nodes. +- **Cloud configuration services**: For obtaining configuration information. The server logs your IP, ISP, and location for statistical purposes. +- **IPv6 testing services**: The application uses testipv6.cn and ipw.cn to test and retrieve IPv6 connection information. + +The complete privacy policy can be found in the program's GitHub repository. +""" + +# 默认隐私协议版本 - 本地版本的日期 +PRIVACY_POLICY_VERSION = "2025.08.04" + +def get_local_privacy_policy(): + """获取本地打包的隐私协议文件 + + Returns: + tuple: (隐私协议内容, 版本号, 错误信息) + """ + # 尝试不同的可能路径 + possible_paths = [ + "PRIVACY.md", # 相对于可执行文件 + os.path.join(os.path.dirname(sys.executable), "PRIVACY.md"), # 可执行文件目录 + os.path.join(os.path.dirname(__file__), "PRIVACY.md"), # 当前模块目录 + ] + + for path in possible_paths: + try: + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + # 提取更新日期 + date_pattern = r'最后更新日期:(\d{4}年\d{1,2}月\d{1,2}日)' + match = re.search(date_pattern, content) + + if match: + date_str = match.group(1) + try: + date_obj = datetime.strptime(date_str, '%Y年%m月%d日') + date_version = date_obj.strftime('%Y.%m.%d') + logger.info(f"成功读取本地隐私协议文件: {path}, 版本: {date_version}") + return content, date_version, "" + except ValueError: + logger.error(f"本地隐私协议日期格式解析错误: {path}") + else: + logger.warning(f"本地隐私协议未找到更新日期: {path}") + except Exception as e: + logger.error(f"读取本地隐私协议失败 {path}: {str(e)}") + + # 所有路径都尝试失败,使用默认版本 + return PRIVACY_POLICY_BRIEF, PRIVACY_POLICY_VERSION, "无法读取本地隐私协议文件" \ No newline at end of file diff --git a/source/core/__init__.py b/source/core/__init__.py index dfc8f81..bb4d9a3 100644 --- a/source/core/__init__.py +++ b/source/core/__init__.py @@ -1,16 +1,16 @@ -from .animations import MultiStageAnimations -from .ui_manager import UIManager -from .download_manager import DownloadManager -from .debug_manager import DebugManager -from .window_manager import WindowManager -from .game_detector import GameDetector -from .patch_manager import PatchManager -from .config_manager import ConfigManager -from .privacy_manager import PrivacyManager -from .cloudflare_optimizer import CloudflareOptimizer -from .download_task_manager import DownloadTaskManager -from .extraction_handler import ExtractionHandler -from .patch_detector import PatchDetector +from .managers.ui_manager import UIManager +from .managers.download_manager import DownloadManager +from .managers.debug_manager import DebugManager +from .managers.window_manager import WindowManager +from .managers.game_detector import GameDetector +from .managers.patch_manager import PatchManager +from .managers.config_manager import ConfigManager +from .managers.privacy_manager import PrivacyManager +from .managers.cloudflare_optimizer import CloudflareOptimizer +from .managers.download_task_manager import DownloadTaskManager +from .managers.patch_detector import PatchDetector +from .managers.animations import MultiStageAnimations +from .handlers.extraction_handler import ExtractionHandler __all__ = [ 'MultiStageAnimations', 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..dbf01be --- /dev/null +++ b/source/core/handlers/extraction_handler.py @@ -0,0 +1,265 @@ +import os +import shutil +from PySide6 import QtWidgets +from PySide6.QtWidgets import QMessageBox +from PySide6.QtCore import QTimer, QCoreApplication + +from utils.logger import setup_logger + +# 初始化logger +logger = setup_logger("extraction_handler") + +class ExtractionHandler: + """解压处理器,负责管理解压任务和结果处理""" + + def __init__(self, main_window): + """初始化解压处理器 + + Args: + main_window: 主窗口实例,用于访问UI和状态 + """ + self.main_window = main_window + self.APP_NAME = main_window.APP_NAME if hasattr(main_window, 'APP_NAME') else "" + self.extraction_progress_window = None + + def start_extraction(self, _7z_path, game_folder, plugin_path, game_version, extracted_path=None): + """开始解压任务 + + Args: + _7z_path: 7z文件路径 + game_folder: 游戏文件夹路径 + plugin_path: 插件路径 + game_version: 游戏版本名称 + extracted_path: 已解压的补丁文件路径,如果提供则直接使用它而不进行解压 + """ + # 检查是否处于离线模式 + is_offline = False + if hasattr(self.main_window, 'offline_mode_manager'): + is_offline = self.main_window.offline_mode_manager.is_in_offline_mode() + + # 创建并显示解压进度窗口,替代原来的消息框 + self.extraction_progress_window = self.main_window.create_extraction_progress_window() + self.extraction_progress_window.show() + + # 确保UI更新 + QCoreApplication.processEvents() + + # 创建并启动解压线程 + self.main_window.extraction_thread = self.main_window.create_extraction_thread( + _7z_path, game_folder, plugin_path, game_version, extracted_path + ) + + # 连接进度信号 + self.main_window.extraction_thread.progress.connect(self.update_extraction_progress) + + # 连接完成信号 + self.main_window.extraction_thread.finished.connect(self.on_extraction_finished_with_hash_check) + + # 启动线程 + self.main_window.extraction_thread.start() + + def update_extraction_progress(self, progress, status_text): + """更新解压进度 + + Args: + progress: 进度百分比 + status_text: 状态文本 + """ + if self.extraction_progress_window and hasattr(self.extraction_progress_window, 'progress_bar'): + self.extraction_progress_window.progress_bar.setValue(progress) + self.extraction_progress_window.status_label.setText(status_text) + + # 确保UI更新 + QCoreApplication.processEvents() + + def on_extraction_finished_with_hash_check(self, success, error_message, game_version): + """解压完成后进行哈希校验 + + Args: + success: 是否解压成功 + error_message: 错误信息 + game_version: 游戏版本 + """ + # 关闭解压进度窗口 + if self.extraction_progress_window: + self.extraction_progress_window.close() + self.extraction_progress_window = None + + # 如果解压失败,显示错误并询问是否继续 + if not success: + # 临时启用窗口以显示错误消息 + self.main_window.setEnabled(True) + + QtWidgets.QMessageBox.critical(self.main_window, f"错误 - {self.APP_NAME}", error_message) + self.main_window.installed_status[game_version] = False + + # 询问用户是否继续其他游戏的安装 + reply = QtWidgets.QMessageBox.question( + self.main_window, + f"继续安装? - {self.APP_NAME}", + f"\n{game_version} 的补丁安装失败。\n\n是否继续安装其他游戏的补丁?\n", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.Yes + ) + + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + # 继续下一个,重新禁用窗口 + self.main_window.setEnabled(False) + # 通知DownloadManager继续下一个下载任务 + self.main_window.download_manager.on_extraction_finished(True) + else: + # 用户选择停止,保持窗口启用状态 + self.main_window.ui.start_install_text.setText("开始安装") + # 通知DownloadManager停止下载队列 + self.main_window.download_manager.on_extraction_finished(False) + return + + # 解压成功,进行哈希校验 + self._perform_hash_check(game_version) + + def _perform_hash_check(self, game_version): + """解压成功后进行哈希校验 + + Args: + game_version: 游戏版本 + """ + # 导入所需模块 + from data.config import GAME_INFO, PLUGIN_HASH + from workers.hash_thread import HashThread + + # 获取安装路径 + install_paths = {} + if hasattr(self.main_window, 'game_detector') and hasattr(self.main_window, 'download_manager'): + game_dirs = self.main_window.game_detector.identify_game_directories_improved( + self.main_window.download_manager.selected_folder + ) + + for game, info in GAME_INFO.items(): + if game in game_dirs and game == game_version: + game_dir = game_dirs[game] + install_path = os.path.join(game_dir, os.path.basename(info["install_path"])) + install_paths[game] = install_path + break + + if not install_paths: + # 如果找不到安装路径,直接认为安装成功 + logger.warning(f"未找到 {game_version} 的安装路径,跳过哈希校验") + self.main_window.installed_status[game_version] = True + self.main_window.download_manager.on_extraction_finished(True) + return + + # 关闭可能存在的哈希校验窗口 + self.main_window.close_hash_msg_box() + + # 显示哈希校验窗口 + self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window( + check_type="post", + auto_close=True, # 添加自动关闭参数 + close_delay=1000 # 1秒后自动关闭 + ) + + # 直接创建并启动哈希线程进行校验 + hash_thread = HashThread( + "after", + install_paths, + PLUGIN_HASH, + self.main_window.installed_status, + self.main_window + ) + hash_thread.after_finished.connect(self.on_hash_check_finished) + + # 保存引用以便后续使用 + self.hash_thread = hash_thread + hash_thread.start() + + def on_hash_check_finished(self, result): + """哈希校验完成后的处理 + + Args: + result: 校验结果,包含通过状态、游戏版本和消息 + """ + # 导入所需模块 + from data.config import GAME_INFO + + # 关闭哈希检查窗口 + self.main_window.close_hash_msg_box() + + if not result["passed"]: + # 校验失败,删除已解压的文件并提示重新下载 + game_version = result["game"] + error_message = result["message"] + + # 临时启用窗口以显示错误消息 + self.main_window.setEnabled(True) + + # 获取安装路径 + install_path = None + if hasattr(self.main_window, 'game_detector') and hasattr(self.main_window, 'download_manager'): + game_dirs = self.main_window.game_detector.identify_game_directories_improved( + self.main_window.download_manager.selected_folder + ) + + if game_version in game_dirs and game_version in GAME_INFO: + game_dir = game_dirs[game_version] + install_path = os.path.join(game_dir, os.path.basename(GAME_INFO[game_version]["install_path"])) + + # 如果找到安装路径,尝试删除已解压的文件 + if install_path and os.path.exists(install_path): + try: + os.remove(install_path) + logger.info(f"已删除校验失败的文件: {install_path}") + except Exception as e: + logger.error(f"删除文件失败: {e}") + + # 显示错误消息并询问是否重试 + reply = QtWidgets.QMessageBox.question( + self.main_window, + f"校验失败 - {self.APP_NAME}", + f"{error_message}\n\n是否重新下载并安装?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.Yes + ) + + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + # 重新下载,将游戏重新添加到下载队列 + self.main_window.setEnabled(False) + self.main_window.installed_status[game_version] = False + + # 获取游戏目录和下载URL + if hasattr(self.main_window, 'download_manager') and hasattr(self.main_window, 'game_detector'): + game_dirs = self.main_window.game_detector.identify_game_directories_improved( + self.main_window.download_manager.selected_folder + ) + + if game_version in game_dirs: + # 重新将游戏添加到下载队列 + self.main_window.download_manager.download_queue.appendleft([game_version]) + # 继续下一个下载任务 + self.main_window.download_manager.next_download_task() + else: + # 如果找不到游戏目录,继续下一个 + self.main_window.download_manager.on_extraction_finished(True) + else: + # 如果无法重新下载,继续下一个 + self.main_window.download_manager.on_extraction_finished(True) + else: + # 用户选择不重试,继续下一个 + self.main_window.installed_status[game_version] = False + self.main_window.download_manager.on_extraction_finished(True) + else: + # 校验通过,更新安装状态 + game_version = result["game"] + self.main_window.installed_status[game_version] = True + # 通知DownloadManager继续下一个下载任务 + self.main_window.download_manager.on_extraction_finished(True) + + def on_extraction_finished(self, success, error_message, game_version): + """兼容旧版本的回调函数 + + Args: + success: 是否解压成功 + error_message: 错误信息 + game_version: 游戏版本 + """ + # 调用新的带哈希校验的回调函数 + self.on_extraction_finished_with_hash_check(success, error_message, game_version) \ No newline at end of file diff --git a/source/core/handlers/patch_toggle_handler.py b/source/core/handlers/patch_toggle_handler.py new file mode 100644 index 0000000..188138a --- /dev/null +++ b/source/core/handlers/patch_toggle_handler.py @@ -0,0 +1,438 @@ +import os +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QListWidget, QPushButton, QHBoxLayout, + QAbstractItemView, QRadioButton, QButtonGroup, QFileDialog, QMessageBox +) +from PySide6.QtCore import QObject, Signal, QThread +from PySide6.QtGui import QFont +from utils import msgbox_frame +from utils.logger import setup_logger + +# 初始化logger +logger = setup_logger("patch_toggle_handler") + +class PatchToggleThread(QThread): + """在后台线程中处理补丁切换逻辑""" + finished = Signal(object) + + def __init__(self, handler, selected_folder): + super().__init__() + self.handler = handler + self.selected_folder = selected_folder + + def run(self): + # 在后台线程中执行耗时操作 + game_dirs = self.handler.game_detector.identify_game_directories_improved(self.selected_folder) + self.finished.emit(game_dirs) + +class PatchToggleHandler(QObject): + """ + 处理补丁启用/禁用功能的类 + """ + def __init__(self, main_window): + """ + 初始化补丁切换处理程序 + + Args: + main_window: 主窗口实例,用于访问其他组件 + """ + super().__init__() + self.main_window = main_window + self.debug_manager = main_window.debug_manager + self.game_detector = main_window.game_detector + self.patch_manager = main_window.patch_manager + self.app_name = main_window.patch_manager.app_name + self.toggle_thread = None + + def handle_toggle_patch_button_click(self): + """ + 处理禁/启用补丁按钮点击事件 + 打开文件选择对话框选择游戏目录,然后禁用或启用对应游戏的补丁 + """ + selected_folder = QFileDialog.getExistingDirectory(self.main_window, "选择游戏上级目录", "") + + if not selected_folder: + return + + self.main_window.show_loading_dialog("正在识别游戏目录并检查补丁状态...") + + self.toggle_thread = PatchToggleThread(self, selected_folder) + self.toggle_thread.finished.connect(self.on_game_detection_finished) + self.toggle_thread.start() + + def on_game_detection_finished(self, game_dirs): + """游戏识别完成后的回调""" + self.main_window.hide_loading_dialog() + + if not game_dirs: + QMessageBox.information( + self.main_window, + f"提示 - {self.app_name}", + "\n未在选择的目录中找到任何支持的游戏。\n", + ) + return + + games_with_patch = {} + for game_version, game_dir in game_dirs.items(): + if self.patch_manager.check_patch_installed(game_dir, game_version): + is_disabled, _ = self.patch_manager.check_patch_disabled(game_dir, game_version) + status = "已禁用" if is_disabled else "已启用" + games_with_patch[game_version] = {"dir": game_dir, "status": status} + + if not games_with_patch: + QMessageBox.information( + self.main_window, + f"提示 - {self.app_name}", + "\n目录中未找到已安装补丁的游戏。\n", + ) + return + + selected_games, operation = self._show_multi_game_dialog(games_with_patch) + + if not selected_games: + return + + selected_game_dirs = {game: games_with_patch[game]["dir"] for game in selected_games if game in games_with_patch} + + self._execute_batch_toggle(selected_game_dirs, operation) + + def _handle_multiple_games(self, game_dirs, debug_mode): + """ + 处理多个游戏的补丁切换 + + Args: + game_dirs: 游戏目录字典 + debug_mode: 是否为调试模式 + """ + if debug_mode: + logger.debug(f"DEBUG: 禁/启用功能 - 在上级目录中找到以下游戏: {list(game_dirs.keys())}") + + # 查找已安装补丁的游戏,只处理那些已安装补丁的游戏 + games_with_patch = {} + for game_version, game_dir in game_dirs.items(): + if self.patch_manager.check_patch_installed(game_dir, game_version): + # 检查补丁当前状态(是否禁用) + is_disabled, disabled_path = self.patch_manager.check_patch_disabled(game_dir, game_version) + status = "已禁用" if is_disabled else "已启用" + games_with_patch[game_version] = { + "dir": game_dir, + "disabled": is_disabled, + "status": status + } + if debug_mode: + logger.debug(f"DEBUG: 禁/启用功能 - {game_version} 已安装补丁,当前状态: {status}") + + # 检查是否有已安装补丁的游戏 + if not games_with_patch: + QMessageBox.information( + self.main_window, + f"提示 - {self.app_name}", + "\n未在选择的目录中找到已安装补丁的游戏。\n请确认您选择了正确的游戏目录,并且该目录中的游戏已安装过补丁。\n", + QMessageBox.StandardButton.Ok + ) + return + + # 显示选择对话框 + selected_games, operation = self._show_multi_game_dialog(games_with_patch) + + if not selected_games: + return # 用户取消了操作 + + # 过滤games_with_patch,只保留选中的游戏 + selected_game_dirs = {} + for game in selected_games: + if game in games_with_patch: + selected_game_dirs[game] = games_with_patch[game]["dir"] + + # 确认操作 + operation_text = "禁用" if operation == "disable" else "启用" if operation == "enable" else "切换" + game_list = '\n'.join([f"{game} ({games_with_patch[game]['status']})" for game in selected_games]) + reply = QMessageBox.question( + self.main_window, + f"确认{operation_text}操作 - {self.app_name}", + f"\n确定要{operation_text}以下游戏补丁吗?\n\n{game_list}\n", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.No: + return + + # 执行批量操作 + self._execute_batch_toggle(selected_game_dirs, operation, debug_mode) + + def _handle_single_game(self, selected_folder, debug_mode): + """ + 处理单个游戏的补丁切换 + + Args: + selected_folder: 选择的游戏目录 + debug_mode: 是否为调试模式 + """ + # 未找到游戏目录,尝试将选择的目录作为游戏目录 + if debug_mode: + logger.debug(f"DEBUG: 禁/启用功能 - 未在上级目录找到游戏,尝试将选择的目录视为游戏目录") + + game_version = self.game_detector.identify_game_version(selected_folder) + + if game_version: + if debug_mode: + logger.debug(f"DEBUG: 禁/启用功能 - 识别为游戏: {game_version}") + + # 检查是否已安装补丁 + if self.patch_manager.check_patch_installed(selected_folder, game_version): + # 检查补丁当前状态 + is_disabled, disabled_path = self.patch_manager.check_patch_disabled(selected_folder, game_version) + current_status = "已禁用" if is_disabled else "已启用" + + # 显示单游戏操作对话框 + operation = self._show_single_game_dialog(game_version, current_status, is_disabled) + + if not operation: + return # 用户取消了操作 + + # 执行操作 + result = self.patch_manager.toggle_patch(selected_folder, game_version, operation=operation) + if not result["success"]: + # 操作失败的消息已在toggle_patch中显示 + pass + else: + # 没有安装补丁 + QMessageBox.information( + self.main_window, + f"提示 - {self.app_name}", + f"\n未在 {game_version} 中找到已安装的补丁。\n请确认该游戏已经安装过补丁。\n", + QMessageBox.StandardButton.Ok + ) + else: + # 两种方式都未识别到游戏 + if debug_mode: + logger.debug(f"DEBUG: 禁/启用功能 - 无法识别游戏") + + msg_box = msgbox_frame( + f"错误 - {self.app_name}", + "\n所选目录不是有效的NEKOPARA游戏目录。\n请选择包含游戏可执行文件的目录或其上级目录。\n", + QMessageBox.StandardButton.Ok, + ) + msg_box.exec() + + def _show_multi_game_dialog(self, games_with_patch): + """ + 显示多游戏选择对话框 + + Args: + games_with_patch: 已安装补丁的游戏信息 + + Returns: + tuple: (选择的游戏列表, 操作类型) + """ + dialog = QDialog(self.main_window) + dialog.setWindowTitle("选择要操作的游戏补丁") + dialog.resize(400, 400) # 增加高度以适应新增的单选按钮 + + layout = QVBoxLayout(dialog) + + # 添加"已安装补丁的游戏"标签 + already_installed_label = QLabel("已安装补丁的游戏:", dialog) + already_installed_label.setFont(QFont(already_installed_label.font().family(), already_installed_label.font().pointSize(), QFont.Bold)) + layout.addWidget(already_installed_label) + + # 添加游戏列表和状态 + games_status_text = "" + for game, info in games_with_patch.items(): + games_status_text += f"{game} ({info['status']})\n" + games_status_label = QLabel(games_status_text.strip(), dialog) + layout.addWidget(games_status_label) + + # 添加一些间距 + layout.addSpacing(10) + + # 添加"请选择要操作的游戏"标签 + info_label = QLabel("请选择要操作的游戏:", dialog) + info_label.setFont(QFont(info_label.font().family(), info_label.font().pointSize(), QFont.Bold)) + layout.addWidget(info_label) + + # 添加列表控件,只显示已安装补丁的游戏 + list_widget = QListWidget(dialog) + list_widget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # 允许多选 + for game, info in games_with_patch.items(): + list_widget.addItem(f"{game} ({info['status']})") + layout.addWidget(list_widget) + + # 添加全选按钮 + select_all_btn = QPushButton("全选", dialog) + select_all_btn.clicked.connect(lambda: list_widget.selectAll()) + layout.addWidget(select_all_btn) + + # 添加操作选择单选按钮 + operation_label = QLabel("请选择要执行的操作:", dialog) + operation_label.setFont(QFont(operation_label.font().family(), operation_label.font().pointSize(), QFont.Bold)) + layout.addWidget(operation_label) + + # 创建单选按钮组 + radio_button_group = QButtonGroup(dialog) + + # 添加"自动切换状态"单选按钮(默认选中) + auto_toggle_radio = QRadioButton("自动切换状态(禁用<->启用)", dialog) + auto_toggle_radio.setChecked(True) + radio_button_group.addButton(auto_toggle_radio, 0) + layout.addWidget(auto_toggle_radio) + + # 添加"全部禁用"单选按钮 + disable_all_radio = QRadioButton("禁用选中的补丁", dialog) + radio_button_group.addButton(disable_all_radio, 1) + layout.addWidget(disable_all_radio) + + # 添加"全部启用"单选按钮 + enable_all_radio = QRadioButton("启用选中的补丁", dialog) + radio_button_group.addButton(enable_all_radio, 2) + layout.addWidget(enable_all_radio) + + # 添加确定和取消按钮 + buttons_layout = QHBoxLayout() + ok_button = QPushButton("确定", dialog) + cancel_button = QPushButton("取消", dialog) + buttons_layout.addWidget(ok_button) + buttons_layout.addWidget(cancel_button) + layout.addLayout(buttons_layout) + + # 连接按钮事件 + ok_button.clicked.connect(dialog.accept) + cancel_button.clicked.connect(dialog.reject) + + # 显示对话框并等待用户选择 + result = dialog.exec() + + if result != QDialog.DialogCode.Accepted or list_widget.selectedItems() == []: + # 用户取消或未选择任何游戏 + return [], None + + # 获取用户选择的游戏 + selected_items = [item.text() for item in list_widget.selectedItems()] + selected_games = [] + + # 从选中项文本中提取游戏名称 + for item in selected_items: + # 去除状态后缀 " (已启用)" 或 " (已禁用)" + game_name = item.split(" (")[0] + selected_games.append(game_name) + + # 获取选中的操作类型 + operation = None + if radio_button_group.checkedId() == 1: # 禁用选中的补丁 + operation = "disable" + elif radio_button_group.checkedId() == 2: # 启用选中的补丁 + operation = "enable" + # 否则为None,表示自动切换状态 + + return selected_games, operation + + def _show_single_game_dialog(self, game_version, current_status, is_disabled): + """ + 显示单游戏操作对话框 + + Args: + game_version: 游戏版本 + current_status: 当前补丁状态 + is_disabled: 是否已禁用 + + Returns: + str: 操作类型,"enable"或"disable",或None表示取消 + """ + dialog = QDialog(self.main_window) + dialog.setWindowTitle(f"{game_version} 补丁操作") + dialog.resize(300, 200) + + layout = QVBoxLayout(dialog) + + # 添加当前状态标签 + status_label = QLabel(f"当前补丁状态: {current_status}", dialog) + status_label.setFont(QFont(status_label.font().family(), status_label.font().pointSize(), QFont.Bold)) + layout.addWidget(status_label) + + # 添加操作选择单选按钮 + operation_label = QLabel("请选择要执行的操作:", dialog) + layout.addWidget(operation_label) + + # 创建单选按钮组 + radio_button_group = QButtonGroup(dialog) + + # 添加可选操作 + if is_disabled: + # 当前是禁用状态,显示启用选项 + enable_radio = QRadioButton("启用补丁", dialog) + enable_radio.setChecked(True) + radio_button_group.addButton(enable_radio, 0) + layout.addWidget(enable_radio) + else: + # 当前是启用状态,显示禁用选项 + disable_radio = QRadioButton("禁用补丁", dialog) + disable_radio.setChecked(True) + radio_button_group.addButton(disable_radio, 0) + layout.addWidget(disable_radio) + + # 添加确定和取消按钮 + buttons_layout = QHBoxLayout() + ok_button = QPushButton("确定", dialog) + cancel_button = QPushButton("取消", dialog) + buttons_layout.addWidget(ok_button) + buttons_layout.addWidget(cancel_button) + layout.addLayout(buttons_layout) + + # 连接按钮事件 + ok_button.clicked.connect(dialog.accept) + cancel_button.clicked.connect(dialog.reject) + + # 显示对话框并等待用户选择 + result = dialog.exec() + + if result != QDialog.DialogCode.Accepted: + # 用户取消 + return None + + # 根据当前状态确定操作 + return "enable" if is_disabled else "disable" + + def _execute_batch_toggle(self, selected_game_dirs, operation, debug_mode): + """ + 执行批量补丁切换操作 + + Args: + selected_game_dirs: 选择的游戏目录 + operation: 操作类型 + debug_mode: 是否为调试模式 + """ + success_count = 0 + fail_count = 0 + results = [] + + for game_version, game_dir in selected_game_dirs.items(): + try: + # 使用静默模式进行操作 + result = self.patch_manager.toggle_patch(game_dir, game_version, operation=operation, silent=True) + + if result["success"]: + success_count += 1 + else: + fail_count += 1 + + results.append({ + "version": game_version, + "success": result["success"], + "message": result["message"], + "action": result["action"] + }) + + except Exception as e: + if debug_mode: + logger.error(f"DEBUG: 切换 {game_version} 补丁状态时出错: {str(e)}") + fail_count += 1 + results.append({ + "version": game_version, + "success": False, + "message": f"操作出错: {str(e)}", + "action": "none" + }) + + # 显示操作结果 + self.patch_manager.show_toggle_result(success_count, fail_count, results) \ No newline at end of file diff --git a/source/core/handlers/uninstall_handler.py b/source/core/handlers/uninstall_handler.py new file mode 100644 index 0000000..7171c96 --- /dev/null +++ b/source/core/handlers/uninstall_handler.py @@ -0,0 +1,388 @@ +import os +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QListWidget, QPushButton, QHBoxLayout, + QAbstractItemView, QFileDialog, QMessageBox +) +from PySide6.QtCore import QObject, Signal, QThread +from PySide6.QtGui import QFont +from utils import msgbox_frame +from utils.logger import setup_logger + +# 初始化logger +logger = setup_logger("uninstall_handler") + +class UninstallThread(QThread): + """在后台线程中处理卸载逻辑""" + finished = Signal(object) + + def __init__(self, handler, selected_folder): + super().__init__() + self.handler = handler + self.selected_folder = selected_folder + + def run(self): + # 在后台线程中执行耗时操作 + game_dirs = self.handler.game_detector.identify_game_directories_improved(self.selected_folder) + self.finished.emit(game_dirs) + +class UninstallHandler(QObject): + """ + 处理补丁卸载功能的类 + """ + def __init__(self, main_window): + """ + 初始化卸载处理程序 + + Args: + main_window: 主窗口实例,用于访问其他组件 + """ + super().__init__() + self.main_window = main_window + self.debug_manager = main_window.debug_manager + self.game_detector = main_window.game_detector + self.patch_manager = main_window.patch_manager + self.app_name = main_window.patch_manager.app_name + self.uninstall_thread = None + + # 记录初始化日志 + debug_mode = self.debug_manager._is_debug_mode() if hasattr(self.debug_manager, '_is_debug_mode') else False + if debug_mode: + logger.debug("DEBUG: 卸载处理程序已初始化") + + def handle_uninstall_button_click(self): + """ + 处理卸载补丁按钮点击事件 + 打开文件选择对话框选择游戏目录,然后卸载对应游戏的补丁 + """ + # 获取游戏目录 + debug_mode = self.debug_manager._is_debug_mode() + + logger.info("用户点击了卸载补丁按钮") + if debug_mode: + logger.debug("DEBUG: 处理卸载补丁按钮点击事件") + + # 提示用户选择目录 + file_dialog_info = "选择游戏上级目录" if debug_mode else "选择游戏目录" + selected_folder = QFileDialog.getExistingDirectory(self.main_window, file_dialog_info, "") + + if not selected_folder or selected_folder == "": + logger.info("用户取消了目录选择") + if debug_mode: + logger.debug("DEBUG: 用户取消了目录选择,退出卸载流程") + return # 用户取消了选择 + + logger.info(f"用户选择了目录: {selected_folder}") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 用户选择了目录: {selected_folder}") + + self.main_window.show_loading_dialog("正在识别游戏目录...") + + self.uninstall_thread = UninstallThread(self, selected_folder) + self.uninstall_thread.finished.connect(self.on_game_detection_finished) + self.uninstall_thread.start() + + def on_game_detection_finished(self, game_dirs): + """游戏识别完成后的回调""" + self.main_window.hide_loading_dialog() + + if not game_dirs: + QMessageBox.information( + self.main_window, + f"提示 - {self.app_name}", + "\n未在选择的目录中找到任何支持的游戏。\n", + ) + return + + games_with_patch = {} + for game_version, game_dir in game_dirs.items(): + if self.patch_manager.check_patch_installed(game_dir, game_version): + games_with_patch[game_version] = game_dir + + if not games_with_patch: + QMessageBox.information( + self.main_window, + f"提示 - {self.app_name}", + "\n目录中未找到已安装补丁的游戏。\n", + ) + return + + selected_games = self._show_game_selection_dialog(games_with_patch) + + if not selected_games: + return + + selected_game_dirs = {game: games_with_patch[game] for game in selected_games if game in games_with_patch} + + game_list = '\n'.join(selected_games) + reply = QMessageBox.question( + self.main_window, + f"确认卸载 - {self.app_name}", + f"\n确定要卸载以下游戏的补丁吗?\n\n{game_list}\n", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.No: + return + + success_count, fail_count, results = self.patch_manager.batch_uninstall_patches(selected_game_dirs) + self.patch_manager.show_uninstall_result(success_count, fail_count, results) + + def _handle_multiple_games(self, game_dirs, debug_mode): + """ + 处理多个游戏的补丁卸载 + + Args: + game_dirs: 游戏目录字典 + debug_mode: 是否为调试模式 + """ + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 在上级目录中找到以下游戏: {list(game_dirs.keys())}") + + # 查找已安装补丁的游戏,只处理那些已安装补丁的游戏 + logger.info("检查哪些游戏已安装补丁") + games_with_patch = {} + for game_version, game_dir in game_dirs.items(): + is_installed = self.patch_manager.check_patch_installed(game_dir, game_version) + if is_installed: + games_with_patch[game_version] = game_dir + logger.info(f"游戏 {game_version} 已安装补丁") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - {game_version} 已安装补丁,目录: {game_dir}") + else: + logger.info(f"游戏 {game_version} 未安装补丁") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - {game_version} 未安装补丁,跳过") + + # 检查是否有已安装补丁的游戏 + if not games_with_patch: + logger.info("未找到已安装补丁的游戏") + if debug_mode: + logger.debug("DEBUG: 卸载功能 - 未找到已安装补丁的游戏,显示提示消息") + + QMessageBox.information( + self.main_window, + f"提示 - {self.app_name}", + "\n未在选择的目录中找到已安装补丁的游戏。\n请确认您选择了正确的游戏目录,并且该目录中的游戏已安装过补丁。\n", + QMessageBox.StandardButton.Ok + ) + return + + # 显示选择对话框 + logger.info("显示游戏选择对话框") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 显示游戏选择对话框,可选游戏: {list(games_with_patch.keys())}") + + selected_games = self._show_game_selection_dialog(games_with_patch) + + if not selected_games: + logger.info("用户未选择任何游戏或取消了选择") + if debug_mode: + logger.debug("DEBUG: 卸载功能 - 用户未选择任何游戏或取消了选择,退出卸载流程") + return # 用户取消了选择 + + logger.info(f"用户选择了以下游戏: {selected_games}") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 用户选择了以下游戏: {selected_games}") + + # 过滤game_dirs,只保留选中的游戏 + selected_game_dirs = {game: games_with_patch[game] for game in selected_games if game in games_with_patch} + + # 确认卸载 + game_list = '\n'.join(selected_games) + logger.info("显示卸载确认对话框") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 显示卸载确认对话框,选择的游戏: {selected_games}") + + reply = QMessageBox.question( + self.main_window, + f"确认卸载 - {self.app_name}", + f"\n确定要卸载以下游戏的补丁吗?\n\n{game_list}\n", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.No: + logger.info("用户取消了卸载操作") + if debug_mode: + logger.debug("DEBUG: 卸载功能 - 用户取消了卸载操作,退出卸载流程") + return + + logger.info("开始批量卸载补丁") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 开始批量卸载补丁,游戏: {list(selected_game_dirs.keys())}") + + # 使用批量卸载方法 + success_count, fail_count, results = self.patch_manager.batch_uninstall_patches(selected_game_dirs) + + logger.info(f"批量卸载完成,成功: {success_count},失败: {fail_count}") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 批量卸载完成,成功: {success_count},失败: {fail_count}") + if results: + for result in results: + status = "成功" if result["success"] else "失败" + logger.debug(f"DEBUG: 卸载结果 - {result['version']}: {status}, 消息: {result['message']}, 删除文件数: {result['files_removed']}") + + self.patch_manager.show_uninstall_result(success_count, fail_count, results) + + def _handle_single_game(self, selected_folder, debug_mode): + """ + 处理单个游戏的补丁卸载 + + Args: + selected_folder: 选择的游戏目录 + debug_mode: 是否为调试模式 + """ + # 未找到游戏目录,尝试将选择的目录作为游戏目录 + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 未在上级目录找到游戏,尝试将选择的目录视为游戏目录") + + logger.info("尝试识别单个游戏版本") + game_version = self.game_detector.identify_game_version(selected_folder) + + if game_version: + logger.info(f"识别为游戏: {game_version}") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 识别为游戏: {game_version}") + + # 检查是否已安装补丁 + logger.info(f"检查 {game_version} 是否已安装补丁") + is_installed = self.patch_manager.check_patch_installed(selected_folder, game_version) + + if is_installed: + logger.info(f"{game_version} 已安装补丁") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - {game_version} 已安装补丁") + + # 确认卸载 + logger.info("显示卸载确认对话框") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 显示卸载确认对话框,游戏: {game_version}") + + reply = QMessageBox.question( + self.main_window, + f"确认卸载 - {self.app_name}", + f"\n确定要卸载 {game_version} 的补丁吗?\n游戏目录: {selected_folder}\n", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + logger.info(f"开始卸载 {game_version} 的补丁") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 用户确认卸载 {game_version} 的补丁") + + # 创建单个游戏的目录字典,使用批量卸载流程 + single_game_dir = {game_version: selected_folder} + + logger.info("执行批量卸载方法(单游戏)") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 执行批量卸载方法(单游戏): {game_version}") + + success_count, fail_count, results = self.patch_manager.batch_uninstall_patches(single_game_dir) + + logger.info(f"卸载完成,成功: {success_count},失败: {fail_count}") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 卸载完成,成功: {success_count},失败: {fail_count}") + if results: + for result in results: + status = "成功" if result["success"] else "失败" + logger.debug(f"DEBUG: 卸载结果 - {result['version']}: {status}, 消息: {result['message']}, 删除文件数: {result['files_removed']}") + + self.patch_manager.show_uninstall_result(success_count, fail_count, results) + else: + logger.info("用户取消了卸载操作") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 用户取消了卸载 {game_version} 的补丁") + else: + logger.info(f"{game_version} 未安装补丁") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - {game_version} 未安装补丁,显示提示消息") + + # 没有安装补丁 + QMessageBox.information( + self.main_window, + f"提示 - {self.app_name}", + f"\n未在 {game_version} 中找到已安装的补丁。\n请确认该游戏已经安装过补丁。\n", + QMessageBox.StandardButton.Ok + ) + else: + # 两种方式都未识别到游戏 + logger.info("无法识别游戏") + if debug_mode: + logger.debug(f"DEBUG: 卸载功能 - 无法识别游戏,显示错误消息") + + msg_box = msgbox_frame( + f"错误 - {self.app_name}", + "\n所选目录不是有效的NEKOPARA游戏目录。\n请选择包含游戏可执行文件的目录或其上级目录。\n", + QMessageBox.StandardButton.Ok, + ) + msg_box.exec() + + def _show_game_selection_dialog(self, games_with_patch): + """ + 显示游戏选择对话框 + + Args: + games_with_patch: 已安装补丁的游戏目录字典 + + Returns: + list: 选择的游戏列表 + """ + dialog = QDialog(self.main_window) + dialog.setWindowTitle("选择要卸载的游戏补丁") + dialog.resize(400, 300) + + layout = QVBoxLayout(dialog) + + # 添加"已安装补丁的游戏"标签 + already_installed_label = QLabel("已安装补丁的游戏:", dialog) + already_installed_label.setFont(QFont(already_installed_label.font().family(), already_installed_label.font().pointSize(), QFont.Weight.Bold)) + layout.addWidget(already_installed_label) + + # 添加已安装游戏列表(可选,这里使用静态标签替代,保持一致性) + installed_games_text = ", ".join(games_with_patch.keys()) + installed_games_label = QLabel(installed_games_text, dialog) + layout.addWidget(installed_games_label) + + # 添加一些间距 + layout.addSpacing(10) + + # 添加"请选择要卸载补丁的游戏"标签 + info_label = QLabel("请选择要卸载补丁的游戏:", dialog) + info_label.setFont(QFont(info_label.font().family(), info_label.font().pointSize(), QFont.Weight.Bold)) + layout.addWidget(info_label) + + # 添加列表控件,只显示已安装补丁的游戏 + list_widget = QListWidget(dialog) + list_widget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # 允许多选 + for game in games_with_patch.keys(): + list_widget.addItem(game) + layout.addWidget(list_widget) + + # 添加全选按钮 + select_all_btn = QPushButton("全选", dialog) + select_all_btn.clicked.connect(lambda: list_widget.selectAll()) + layout.addWidget(select_all_btn) + + # 添加确定和取消按钮 + buttons_layout = QHBoxLayout() + ok_button = QPushButton("确定", dialog) + cancel_button = QPushButton("取消", dialog) + buttons_layout.addWidget(ok_button) + buttons_layout.addWidget(cancel_button) + layout.addLayout(buttons_layout) + + # 连接按钮事件 + ok_button.clicked.connect(dialog.accept) + cancel_button.clicked.connect(dialog.reject) + + # 显示对话框并等待用户选择 + result = dialog.exec() + + if result != QDialog.DialogCode.Accepted or list_widget.selectedItems() == []: + # 用户取消或未选择任何游戏 + return [] + + # 获取用户选择的游戏 + return [item.text() for item in list_widget.selectedItems()] \ No newline at end of file diff --git a/source/core/managers/__init__.py b/source/core/managers/__init__.py new file mode 100644 index 0000000..70b7135 --- /dev/null +++ b/source/core/managers/__init__.py @@ -0,0 +1,28 @@ +# Managers package initialization +from .ui_manager import UIManager +from .download_manager import DownloadManager +from .debug_manager import DebugManager +from .window_manager import WindowManager +from .game_detector import GameDetector +from .patch_manager import PatchManager +from .config_manager import ConfigManager +from .privacy_manager import PrivacyManager +from .cloudflare_optimizer import CloudflareOptimizer +from .download_task_manager import DownloadTaskManager +from .patch_detector import PatchDetector +from .animations import MultiStageAnimations + +__all__ = [ + 'UIManager', + 'DownloadManager', + 'DebugManager', + 'WindowManager', + 'GameDetector', + 'PatchManager', + 'ConfigManager', + 'PrivacyManager', + 'CloudflareOptimizer', + 'DownloadTaskManager', + 'PatchDetector', + 'MultiStageAnimations', +] \ No newline at end of file diff --git a/source/core/managers/animations.py b/source/core/managers/animations.py new file mode 100644 index 0000000..9dd6b8c --- /dev/null +++ b/source/core/managers/animations.py @@ -0,0 +1,374 @@ +import sys +from PySide6.QtCore import (QObject, QPropertyAnimation, QParallelAnimationGroup, + QPoint, QEasingCurve, QTimer, Signal, QRect) +from PySide6.QtWidgets import QGraphicsOpacityEffect, QPushButton +from PySide6.QtGui import QColor + +class MultiStageAnimations(QObject): + animation_finished = Signal() + def __init__(self, ui, parent=None): + super().__init__(parent) + self.ui = ui + self.parent = parent # 保存父窗口引用以获取当前尺寸 + + # 获取画布尺寸 - 动态从父窗口获取 + if parent: + self.canvas_width = parent.width() + self.canvas_height = parent.height() + else: + # 默认尺寸 + self.canvas_width = 1280 + self.canvas_height = 720 + + # 动画时序配置 + self.animation_config = { + "logo": { + "delay_after": 2800 + }, + "mainbg": { + "delay_after": 500 + }, + "button_click": { + "scale_duration": 100, + "scale_min": 0.95, + "scale_max": 1.0 + } + } + + # 第一阶段:Logo动画配置,根据新布局调整Y坐标 + self.logo_widgets = [ + {"widget": ui.vol1bg, "delay": 0, "duration": 500, "end_pos": QPoint(0, 150)}, + {"widget": ui.vol2bg, "delay": 80, "duration": 500, "end_pos": QPoint(0, 210)}, + {"widget": ui.vol3bg, "delay": 160, "duration": 500, "end_pos": QPoint(0, 270)}, + {"widget": ui.vol4bg, "delay": 240, "duration": 500, "end_pos": QPoint(0, 330)}, + {"widget": ui.afterbg, "delay": 320, "duration": 500, "end_pos": QPoint(0, 390)} + ] + + # 第二阶段:菜单元素,位置会在开始动画时动态计算 + self.menu_widgets = [ + # 移除菜单背景动画 + # {"widget": ui.menubg, "end_pos": QPoint(720, 55), "duration": 600}, + {"widget": ui.button_container, "end_pos": None, "duration": 600}, + {"widget": ui.toggle_patch_container, "end_pos": None, "duration": 600}, # 添加禁/启用补丁按钮 + {"widget": ui.uninstall_container, "end_pos": None, "duration": 600}, # 添加卸载补丁按钮 + {"widget": ui.exit_container, "end_pos": None, "duration": 600} + ] + + self.animations = [] + self.timers = [] + + # 设置按钮点击动画 + self.setup_button_click_animations() + + def setup_button_click_animations(self): + """设置按钮点击动画""" + # 为开始安装按钮添加点击动画 + self.ui.start_install_btn.pressed.connect( + lambda: self.start_button_click_animation(self.ui.button_container) + ) + self.ui.start_install_btn.released.connect( + lambda: self.end_button_click_animation(self.ui.button_container) + ) + + # 为卸载补丁按钮添加点击动画 + self.ui.uninstall_btn.pressed.connect( + lambda: self.start_button_click_animation(self.ui.uninstall_container) + ) + self.ui.uninstall_btn.released.connect( + lambda: self.end_button_click_animation(self.ui.uninstall_container) + ) + + # 为退出按钮添加点击动画 + self.ui.exit_btn.pressed.connect( + lambda: self.start_button_click_animation(self.ui.exit_container) + ) + self.ui.exit_btn.released.connect( + lambda: self.end_button_click_animation(self.ui.exit_container) + ) + + def start_button_click_animation(self, button_container): + """开始按钮点击动画""" + # 创建缩放动画 + scale_anim = QPropertyAnimation(button_container.children()[0], b"geometry") # 只对按钮背景应用动画 + scale_anim.setDuration(self.animation_config["button_click"]["scale_duration"]) + + # 获取当前几何形状 + current_geometry = button_container.children()[0].geometry() + + # 计算缩放后的几何形状(保持中心点不变) + scale_factor = self.animation_config["button_click"]["scale_min"] + width_diff = current_geometry.width() * (1 - scale_factor) / 2 + height_diff = current_geometry.height() * (1 - scale_factor) / 2 + + new_geometry = QRect( + current_geometry.x() + width_diff, + current_geometry.y() + height_diff, + current_geometry.width() * scale_factor, + current_geometry.height() * scale_factor + ) + + scale_anim.setEndValue(new_geometry) + scale_anim.setEasingCurve(QEasingCurve.Type.OutQuad) + + # 启动动画 + scale_anim.start() + self.animations.append(scale_anim) + + # 对文本标签也应用同样的动画 + text_anim = QPropertyAnimation(button_container.children()[1], b"geometry") + text_anim.setDuration(self.animation_config["button_click"]["scale_duration"]) + text_geometry = button_container.children()[1].geometry() + + new_text_geometry = QRect( + text_geometry.x() + width_diff, + text_geometry.y() + height_diff, + text_geometry.width() * scale_factor, + text_geometry.height() * scale_factor + ) + + text_anim.setEndValue(new_text_geometry) + text_anim.setEasingCurve(QEasingCurve.Type.OutQuad) + text_anim.start() + self.animations.append(text_anim) + + def end_button_click_animation(self, button_container): + """结束按钮点击动画,恢复正常外观""" + # 创建恢复动画 - 对背景 + scale_anim = QPropertyAnimation(button_container.children()[0], b"geometry") + scale_anim.setDuration(self.animation_config["button_click"]["scale_duration"]) + + # 恢复到原始大小 (10,10,191,91) + original_geometry = QRect(10, 10, 191, 91) + scale_anim.setEndValue(original_geometry) + scale_anim.setEasingCurve(QEasingCurve.Type.OutElastic) + + # 启动动画 + scale_anim.start() + self.animations.append(scale_anim) + + # 恢复文本标签 + text_anim = QPropertyAnimation(button_container.children()[1], b"geometry") + text_anim.setDuration(self.animation_config["button_click"]["scale_duration"]) + + # 恢复文本到原始大小 (10,7,191,91) + text_anim.setEndValue(QRect(10, 7, 191, 91)) + text_anim.setEasingCurve(QEasingCurve.Type.OutElastic) + text_anim.start() + self.animations.append(text_anim) + + def initialize(self): + """初始化所有组件状态""" + # 更新画布尺寸 + if self.parent: + self.canvas_width = self.parent.width() + self.canvas_height = self.parent.height() + + # 设置Mainbg初始状态 + effect = QGraphicsOpacityEffect(self.ui.Mainbg) + effect.setOpacity(0) + self.ui.Mainbg.setGraphicsEffect(effect) + + # 初始化Logo位置(移到左侧外) + for item in self.logo_widgets: + widget = item["widget"] + effect = QGraphicsOpacityEffect(widget) + effect.setOpacity(0) + widget.setGraphicsEffect(effect) + widget.move(-widget.width(), item["end_pos"].y()) + widget.show() + print("初始化支持栏动画") + + # 初始化菜单元素(底部外) + for item in self.menu_widgets: + widget = item["widget"] + effect = QGraphicsOpacityEffect(widget) + effect.setOpacity(0) + widget.setGraphicsEffect(effect) + widget.move(widget.x(), self.canvas_height + 100) + widget.show() + + # 禁用所有按钮,直到动画完成 + self.ui.start_install_btn.setEnabled(False) + self.ui.uninstall_btn.setEnabled(False) + self.ui.exit_btn.setEnabled(False) + + def start_logo_animations(self): + """启动Logo动画序列""" + for item in self.logo_widgets: + timer = QTimer() + timer.setSingleShot(True) + timer.timeout.connect( + lambda w=item["widget"], d=item["duration"], pos=item["end_pos"]: + self.animate_logo(w, pos, d) + ) + timer.start(item["delay"]) + self.timers.append(timer) + + def animate_logo(self, widget, end_pos, duration): + """执行单个Logo动画""" + anim_group = QParallelAnimationGroup() + + # 位置动画 + pos_anim = QPropertyAnimation(widget, b"pos") + pos_anim.setDuration(duration) + pos_anim.setStartValue(QPoint(-widget.width(), end_pos.y())) + pos_anim.setEndValue(end_pos) + pos_anim.setEasingCurve(QEasingCurve.Type.OutBack) + + # 透明度动画 + opacity_anim = QPropertyAnimation(widget.graphicsEffect(), b"opacity") + opacity_anim.setDuration(duration) + opacity_anim.setStartValue(0) + opacity_anim.setEndValue(1) + + anim_group.addAnimation(pos_anim) + anim_group.addAnimation(opacity_anim) + + # 最后一个Logo动画完成后添加延迟 + if widget == self.logo_widgets[-1]["widget"]: + anim_group.finished.connect( + lambda: QTimer.singleShot( + self.animation_config["logo"]["delay_after"], + self.start_mainbg_animation + ) + ) + + anim_group.start() + self.animations.append(anim_group) + + def start_mainbg_animation(self): + """启动主背景淡入动画(带延迟)""" + main_anim = QPropertyAnimation(self.ui.Mainbg.graphicsEffect(), b"opacity") + main_anim.setDuration(800) + main_anim.setStartValue(0) + main_anim.setEndValue(1) + main_anim.finished.connect( + lambda: QTimer.singleShot( + self.animation_config["mainbg"]["delay_after"], + self.start_menu_animations + ) + ) + main_anim.start() + self.animations.append(main_anim) + def start_menu_animations(self): + """启动菜单动画(从下往上)""" + # 更新按钮最终位置 + self._update_button_positions() + + # 跟踪最后一个动画,用于连接finished信号 + last_anim = None + + for item in self.menu_widgets: + anim_group = QParallelAnimationGroup() + + # 位置动画(从下往上) + pos_anim = QPropertyAnimation(item["widget"], b"pos") + pos_anim.setDuration(item["duration"]) + pos_anim.setStartValue(QPoint(item["end_pos"].x(), self.canvas_height + 100)) + pos_anim.setEndValue(item["end_pos"]) + pos_anim.setEasingCurve(QEasingCurve.Type.OutBack) + + # 透明度动画 + opacity_anim = QPropertyAnimation(item["widget"].graphicsEffect(), b"opacity") + opacity_anim.setDuration(item["duration"]) + opacity_anim.setStartValue(0) + opacity_anim.setEndValue(1) + + anim_group.addAnimation(pos_anim) + anim_group.addAnimation(opacity_anim) + + # 记录最后一个按钮的动画 + if item["widget"] == self.ui.exit_container: + last_anim = anim_group + + anim_group.start() + self.animations.append(anim_group) + + # 在最后一个动画完成时发出信号 + if last_anim: + last_anim.finished.connect(self.animation_finished.emit) + + def _update_button_positions(self): + """更新按钮最终位置""" + # 根据当前窗口大小动态计算按钮位置 + if self.parent: + width = self.parent.width() + height = self.parent.height() + + # 计算按钮位置 + right_margin = 20 # 减小右边距,使按钮更靠右 + + # 开始安装按钮 + if hasattr(self.ui, 'button_container'): + btn_width = self.ui.button_container.width() + x_pos = width - btn_width - right_margin + y_pos = int((height - 65) * 0.18) - 10 # 从0.28改为0.18,向上移动 + + # 更新动画目标位置 + for item in self.menu_widgets: + if item["widget"] == self.ui.button_container: + item["end_pos"] = QPoint(x_pos, y_pos) + + # 禁用补丁按钮 + if hasattr(self.ui, 'toggle_patch_container'): + btn_width = self.ui.toggle_patch_container.width() + x_pos = width - btn_width - right_margin + y_pos = int((height - 65) * 0.36) - 10 # 从0.46改为0.36,向上移动 + + # 更新动画目标位置 + for item in self.menu_widgets: + if item["widget"] == self.ui.toggle_patch_container: + item["end_pos"] = QPoint(x_pos, y_pos) + + # 卸载补丁按钮 + if hasattr(self.ui, 'uninstall_container'): + btn_width = self.ui.uninstall_container.width() + x_pos = width - btn_width - right_margin + y_pos = int((height - 65) * 0.54) - 10 # 从0.64改为0.54,向上移动 + + # 更新动画目标位置 + for item in self.menu_widgets: + if item["widget"] == self.ui.uninstall_container: + item["end_pos"] = QPoint(x_pos, y_pos) + + # 退出按钮 + if hasattr(self.ui, 'exit_container'): + btn_width = self.ui.exit_container.width() + x_pos = width - btn_width - right_margin + y_pos = int((height - 65) * 0.72) - 10 # 从0.82改为0.72,向上移动 + + # 更新动画目标位置 + for item in self.menu_widgets: + if item["widget"] == self.ui.exit_container: + item["end_pos"] = QPoint(x_pos, y_pos) + else: + # 默认位置 + for item in self.menu_widgets: + if item["widget"] == self.ui.button_container: + item["end_pos"] = QPoint(1050, 200) + elif item["widget"] == self.ui.toggle_patch_container: + item["end_pos"] = QPoint(1050, 310) + elif item["widget"] == self.ui.uninstall_container: + item["end_pos"] = QPoint(1050, 420) + elif item["widget"] == self.ui.exit_container: + item["end_pos"] = QPoint(1050, 530) + + def start_animations(self): + """启动完整动画序列""" + self.clear_animations() + + # 确保按钮在动画开始时被禁用 + self.ui.start_install_btn.setEnabled(False) + self.ui.uninstall_btn.setEnabled(False) + self.ui.exit_btn.setEnabled(False) + + self.start_logo_animations() + + def clear_animations(self): + """清理所有动画资源""" + for timer in self.timers: + timer.stop() + for anim in self.animations: + anim.stop() + self.timers.clear() + self.animations.clear() \ No newline at end of file diff --git a/source/core/managers/cloudflare_optimizer.py b/source/core/managers/cloudflare_optimizer.py new file mode 100644 index 0000000..93d7c88 --- /dev/null +++ b/source/core/managers/cloudflare_optimizer.py @@ -0,0 +1,438 @@ +import os +from urllib.parse import urlparse +from PySide6 import QtWidgets +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QIcon, QPixmap + +from utils import msgbox_frame, resource_path +from workers import IpOptimizerThread +from utils.logger import setup_logger + +# 初始化logger +logger = setup_logger("cloudflare_optimizer") + + +class CloudflareOptimizer: + """Cloudflare IP优化器,负责处理IP优化和Cloudflare加速相关功能""" + + def __init__(self, main_window, hosts_manager): + """初始化Cloudflare优化器 + + Args: + main_window: 主窗口实例,用于访问UI和状态 + hosts_manager: Hosts文件管理器实例 + """ + self.main_window = main_window + self.hosts_manager = hosts_manager + self.optimized_ip = None + self.optimized_ipv6 = None + self.optimization_done = False # 标记是否已执行过优选 + self.countdown_finished = False # 标记倒计时是否结束 + self.optimizing_msg_box = None + self.optimization_cancelled = False + self.ip_optimizer_thread = None + self.ipv6_optimizer_thread = None + self.has_optimized_in_session = False # 本次启动是否已执行过优选 + + def is_optimization_done(self): + """检查是否已完成优化 + + Returns: + bool: 是否已完成优化 + """ + return self.optimization_done + + def is_countdown_finished(self): + """检查倒计时是否已完成 + + Returns: + bool: 倒计时是否已完成 + """ + return self.countdown_finished + + def get_optimized_ip(self): + """获取优选的IP地址 + + Returns: + str: 优选的IP地址,如果未优选则为None + """ + return self.optimized_ip + + def get_optimized_ipv6(self): + """获取优选的IPv6地址 + + Returns: + str: 优选的IPv6地址,如果未优选则为None + """ + return self.optimized_ipv6 + + def start_ip_optimization(self, url): + """开始IP优化过程 + + Args: + url: 用于优化的URL + """ + # 解析域名 + hostname = urlparse(url).hostname + + # 判断是否继续优选的逻辑 + if self.has_optimized_in_session: + # 如果本次会话中已执行过优选,则跳过优选过程 + logger.info("本次会话已执行过优选,跳过优选过程") + + # 设置标记为已优选完成 + self.optimization_done = True + self.countdown_finished = True + + return True + else: + # 如果本次会话尚未优选过,则清理可能存在的旧记录 + if hostname: + # 检查hosts文件中是否已有该域名的IP记录 + existing_ips = self.hosts_manager.get_hostname_entries(hostname) + if existing_ips: + logger.info(f"发现hosts文件中已有域名 {hostname} 的优选IP记录,但本次会话尚未优选过") + # 清理已有的hosts记录,准备重新优选 + self.hosts_manager.clean_hostname_entries(hostname) + + # 创建取消状态标记 + self.optimization_cancelled = False + self.countdown_finished = False + + # 检查是否启用了IPv6 + use_ipv6 = False + if hasattr(self.main_window, 'config'): + use_ipv6 = self.main_window.config.get("ipv6_enabled", False) + + # 如果启用了IPv6,显示警告消息 + if use_ipv6: + ipv6_warning = QtWidgets.QMessageBox(self.main_window) + ipv6_warning.setWindowTitle(f"IPv6优选警告 - {self.main_window.APP_NAME}") + ipv6_warning.setText("\nIPv6优选比IPv4耗时更长且感知不强(预计耗时10分钟以上),不建议使用。\n\n确定要同时执行IPv6优选吗?\n") + ipv6_warning.setIcon(QtWidgets.QMessageBox.Icon.Warning) + + # 设置图标 + icon_path = resource_path(os.path.join("IMG", "ICO", "icon.png")) + if os.path.exists(icon_path): + pixmap = QPixmap(icon_path) + if not pixmap.isNull(): + ipv6_warning.setWindowIcon(QIcon(pixmap)) + + yes_button = ipv6_warning.addButton("是", QtWidgets.QMessageBox.ButtonRole.YesRole) + no_button = ipv6_warning.addButton("否,仅使用IPv4", QtWidgets.QMessageBox.ButtonRole.NoRole) + cancel_button = ipv6_warning.addButton("取消优选", QtWidgets.QMessageBox.ButtonRole.RejectRole) + + ipv6_warning.setDefaultButton(no_button) + ipv6_warning.exec() + + if ipv6_warning.clickedButton() == cancel_button: + # 用户取消了优选 + self.optimization_cancelled = True + return + + # 根据用户选择调整IPv6设置 + if ipv6_warning.clickedButton() == no_button: + use_ipv6 = False + # 临时覆盖配置(不保存到文件) + if hasattr(self.main_window, 'config'): + self.main_window.config["ipv6_enabled"] = False + + # 准备提示信息 + optimization_msg = "\n正在优选Cloudflare IP,请稍候...\n\n" + if use_ipv6: + optimization_msg += "已启用IPv6支持,同时进行IPv4和IPv6优选。\n这可能需要10分钟以上,请耐心等待喵~\n" + else: + optimization_msg += "这可能需要5-10分钟,请耐心等待喵~\n" + + # 使用Cloudflare图标创建消息框 + self.optimizing_msg_box = msgbox_frame( + f"通知 - {self.main_window.APP_NAME}", + optimization_msg + ) + # 设置Cloudflare图标 + cf_icon_path = resource_path("IMG/ICO/cloudflare_logo_icon.ico") + if os.path.exists(cf_icon_path): + cf_pixmap = QPixmap(cf_icon_path) + if not cf_pixmap.isNull(): + self.optimizing_msg_box.setWindowIcon(QIcon(cf_pixmap)) + self.optimizing_msg_box.setIconPixmap(cf_pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation)) + + # 添加取消按钮 + self.optimizing_msg_box.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Cancel) + self.optimizing_msg_box.buttonClicked.connect(self._on_optimization_dialog_clicked) + self.optimizing_msg_box.setWindowModality(Qt.WindowModality.ApplicationModal) + + # 创建并启动优化线程 + self.ip_optimizer_thread = IpOptimizerThread(url) + self.ip_optimizer_thread.finished.connect(self.on_ipv4_optimization_finished) + + # 如果启用IPv6,同时启动IPv6优化线程 + if use_ipv6: + logger.info("IPv6已启用,将同时优选IPv6地址") + self.ipv6_optimizer_thread = IpOptimizerThread(url, use_ipv6=True) + self.ipv6_optimizer_thread.finished.connect(self.on_ipv6_optimization_finished) + self.ipv6_optimizer_thread.start() + + # 启动IPv4优化线程 + self.ip_optimizer_thread.start() + + # 显示消息框(非模态,不阻塞) + self.optimizing_msg_box.open() + + def _on_optimization_dialog_clicked(self, button): + """处理优化对话框按钮点击 + + Args: + button: 被点击的按钮 + """ + if button.text() == "Cancel": # 如果是取消按钮 + # 标记已取消 + self.optimization_cancelled = True + + # 停止优化线程 + if hasattr(self, 'ip_optimizer_thread') and self.ip_optimizer_thread and self.ip_optimizer_thread.isRunning(): + self.ip_optimizer_thread.stop() + + if hasattr(self, 'ipv6_optimizer_thread') and self.ipv6_optimizer_thread and self.ipv6_optimizer_thread.isRunning(): + self.ipv6_optimizer_thread.stop() + + # 恢复主窗口状态 + self.main_window.setEnabled(True) + self.main_window.ui.start_install_text.setText("开始安装") + + # 显示取消消息 + QtWidgets.QMessageBox.information( + self.main_window, + f"已取消 - {self.main_window.APP_NAME}", + "\n已取消IP优选和安装过程。\n" + ) + + def on_ipv4_optimization_finished(self, ip): + """IPv4优化完成后的处理 + + Args: + ip: 优选的IP地址,如果失败则为空字符串 + """ + # 如果已经取消,则不继续处理 + if hasattr(self, 'optimization_cancelled') and self.optimization_cancelled: + return + + self.optimized_ip = ip + logger.info(f"IPv4优选完成,结果: {ip if ip else '未找到合适的IP'}") + + # 检查是否还有IPv6优化正在运行 + if hasattr(self, 'ipv6_optimizer_thread') and self.ipv6_optimizer_thread and self.ipv6_optimizer_thread.isRunning(): + logger.info("等待IPv6优选完成...") + return + + # 所有优选都已完成,继续处理 + self.optimization_done = True + self.countdown_finished = False # 确保倒计时标志重置 + + # 关闭提示框 + if hasattr(self, 'optimizing_msg_box') and self.optimizing_msg_box: + if self.optimizing_msg_box.isVisible(): + self.optimizing_msg_box.accept() + self.optimizing_msg_box = None + + # 处理优选结果 + self._process_optimization_results() + + def on_ipv6_optimization_finished(self, ipv6): + """IPv6优化完成后的处理 + + Args: + ipv6: 优选的IPv6地址,如果失败则为空字符串 + """ + # 如果已经取消,则不继续处理 + if hasattr(self, 'optimization_cancelled') and self.optimization_cancelled: + return + + self.optimized_ipv6 = ipv6 + logger.info(f"IPv6优选完成,结果: {ipv6 if ipv6 else '未找到合适的IPv6'}") + + # 检查IPv4优化是否已完成 + if hasattr(self, 'ip_optimizer_thread') and self.ip_optimizer_thread and self.ip_optimizer_thread.isRunning(): + logger.info("等待IPv4优选完成...") + return + + # 所有优选都已完成,继续处理 + self.optimization_done = True + self.countdown_finished = False # 确保倒计时标志重置 + + # 关闭提示框 + if hasattr(self, 'optimizing_msg_box') and self.optimizing_msg_box: + if self.optimizing_msg_box.isVisible(): + self.optimizing_msg_box.accept() + self.optimizing_msg_box = None + + # 处理优选结果 + self._process_optimization_results() + + def _process_optimization_results(self): + """处理优选的IP结果,显示相应提示""" + # 无论优选结果如何,都标记本次会话已执行过优选 + self.has_optimized_in_session = True + + use_ipv6 = False + if hasattr(self.main_window, 'config'): + use_ipv6 = self.main_window.config.get("ipv6_enabled", False) + + # 判断优选结果 + ipv4_success = bool(self.optimized_ip) + ipv6_success = bool(self.optimized_ipv6) if use_ipv6 else False + + # 临时启用窗口以显示对话框 + self.main_window.setEnabled(True) + + hostname = urlparse(self.main_window.current_url).hostname if hasattr(self.main_window, 'current_url') else None + + if not ipv4_success and (not use_ipv6 or not ipv6_success): + # 两种IP都没有优选成功 + msg_box = QtWidgets.QMessageBox(self.main_window) + msg_box.setWindowTitle(f"优选失败 - {self.main_window.APP_NAME}") + + fail_message = "\n未能找到合适的Cloudflare " + if use_ipv6: + fail_message += "IPv4和IPv6地址" + else: + fail_message += "IP地址" + + fail_message += ",将使用默认网络进行下载。\n\n10秒后自动继续..." + + msg_box.setText(fail_message) + msg_box.setIcon(QtWidgets.QMessageBox.Icon.Warning) + ok_button = msg_box.addButton("确定 (10)", QtWidgets.QMessageBox.ButtonRole.AcceptRole) + cancel_button = msg_box.addButton("取消安装", QtWidgets.QMessageBox.ButtonRole.RejectRole) + + # 创建计时器实现倒计时 + countdown = 10 + timer = QTimer(self.main_window) + + def update_countdown(): + nonlocal countdown + countdown -= 1 + ok_button.setText(f"确定 ({countdown})") + if countdown <= 0: + timer.stop() + if msg_box.isVisible(): + msg_box.accept() + + timer.timeout.connect(update_countdown) + timer.start(1000) # 每秒更新一次 + + # 显示对话框并等待用户响应 + result = msg_box.exec() + + # 停止计时器 + timer.stop() + + # 如果用户点击了取消安装 + if msg_box.clickedButton() == cancel_button: + # 恢复主窗口状态 + self.main_window.setEnabled(True) + self.main_window.ui.start_install_text.setText("开始安装") + return False + + # 用户点击了继续,重新禁用主窗口 + self.main_window.setEnabled(False) + # 标记倒计时已完成 + self.countdown_finished = True + return True + else: + # 至少有一种IP优选成功 + success_message = "" + if ipv4_success: + success_message += f"IPv4: {self.optimized_ip}\n" + + if ipv6_success: + success_message += f"IPv6: {self.optimized_ipv6}\n" + + if hostname: + # 先清理可能存在的旧记录(只清理一次) + self.hosts_manager.clean_hostname_entries(hostname) + + success = False + + # 应用优选IP到hosts文件 + if ipv4_success: + success = self.hosts_manager.apply_ip(hostname, self.optimized_ip, clean=False) or success + + # 如果启用IPv6并且找到了IPv6地址,也应用到hosts + if ipv6_success: + success = self.hosts_manager.apply_ip(hostname, self.optimized_ipv6, clean=False) or success + + # 记录此次优选操作对hosts文件进行了更新 + if hasattr(self.main_window, 'config'): + self.main_window.config['last_hosts_optimized_hostname'] = hostname + from utils import save_config + save_config(self.main_window.config) + + if success: + msg_box = QtWidgets.QMessageBox(self.main_window) + msg_box.setWindowTitle(f"成功 - {self.main_window.APP_NAME}") + msg_box.setText(f"\n已将优选IP应用到hosts文件:\n{success_message}\n10秒后自动继续...") + msg_box.setIcon(QtWidgets.QMessageBox.Icon.Information) + ok_button = msg_box.addButton("确定 (10)", QtWidgets.QMessageBox.ButtonRole.AcceptRole) + cancel_button = msg_box.addButton("取消安装", QtWidgets.QMessageBox.ButtonRole.RejectRole) + + # 创建计时器实现倒计时 + countdown = 10 + timer = QTimer(self.main_window) + + def update_countdown(): + nonlocal countdown + countdown -= 1 + ok_button.setText(f"确定 ({countdown})") + if countdown <= 0: + timer.stop() + if msg_box.isVisible(): + msg_box.accept() + + timer.timeout.connect(update_countdown) + timer.start(1000) # 每秒更新一次 + + # 显示对话框并等待用户响应 + result = msg_box.exec() + + # 停止计时器 + timer.stop() + + # 如果用户点击了取消安装 + if msg_box.clickedButton() == cancel_button: + # 恢复主窗口状态 + self.main_window.setEnabled(True) + self.main_window.ui.start_install_text.setText("开始安装") + return False + else: + QtWidgets.QMessageBox.critical( + self.main_window, + f"错误 - {self.main_window.APP_NAME}", + "\n修改hosts文件失败,请检查程序是否以管理员权限运行。\n" + ) + # 恢复主窗口状态 + self.main_window.ui.start_install_text.setText("开始安装") + return False + + # 用户点击了继续,重新禁用主窗口 + self.main_window.setEnabled(False) + # 标记倒计时已完成 + self.countdown_finished = True + + return True + + def stop_optimization(self): + """停止正在进行的IP优化""" + if hasattr(self, 'ip_optimizer_thread') and self.ip_optimizer_thread and self.ip_optimizer_thread.isRunning(): + self.ip_optimizer_thread.stop() + self.ip_optimizer_thread.wait() + + if hasattr(self, 'ipv6_optimizer_thread') and self.ipv6_optimizer_thread and self.ipv6_optimizer_thread.isRunning(): + self.ipv6_optimizer_thread.stop() + self.ipv6_optimizer_thread.wait() + + if hasattr(self, 'optimizing_msg_box') and self.optimizing_msg_box: + if self.optimizing_msg_box.isVisible(): + self.optimizing_msg_box.accept() + self.optimizing_msg_box = None \ No newline at end of file diff --git a/source/core/managers/config_manager.py b/source/core/managers/config_manager.py new file mode 100644 index 0000000..a5647ff --- /dev/null +++ b/source/core/managers/config_manager.py @@ -0,0 +1,213 @@ +import json +import webbrowser +from PySide6.QtWidgets import QMessageBox + +from utils import load_config, save_config, msgbox_frame + +class ConfigManager: + """配置管理器,用于处理配置的加载、保存和获取云端配置""" + + def __init__(self, app_name, config_url, ua, debug_manager=None): + """初始化配置管理器 + + Args: + app_name: 应用程序名称,用于显示消息框标题 + config_url: 云端配置URL + ua: User-Agent字符串 + debug_manager: 调试管理器实例,用于输出调试信息 + """ + self.app_name = app_name + self.config_url = config_url + self.ua = ua + self.debug_manager = debug_manager + self.cloud_config = None + self.config_valid = False + self.last_error_message = "" + + def _is_debug_mode(self): + """检查是否处于调试模式 + + Returns: + bool: 是否处于调试模式 + """ + if hasattr(self.debug_manager, 'ui_manager') and hasattr(self.debug_manager.ui_manager, 'debug_action'): + return self.debug_manager.ui_manager.debug_action.isChecked() + return False + + def load_config(self): + """加载本地配置 + + Returns: + dict: 加载的配置 + """ + return load_config() + + def save_config(self, config): + """保存配置 + + Args: + config: 要保存的配置 + """ + save_config(config) + + def fetch_cloud_config(self, config_fetch_thread_class, callback=None): + """获取云端配置 + + Args: + config_fetch_thread_class: 用于获取云端配置的线程类 + callback: 获取完成后的回调函数,接受两个参数(data, error_message) + """ + headers = {"User-Agent": self.ua} + debug_mode = self._is_debug_mode() + self.config_fetch_thread = config_fetch_thread_class(self.config_url, headers, debug_mode) + + # 如果提供了回调,使用它;否则使用内部的on_config_fetched方法 + if callback: + self.config_fetch_thread.finished.connect(callback) + else: + self.config_fetch_thread.finished.connect(self.on_config_fetched) + + self.config_fetch_thread.start() + + def on_config_fetched(self, data, error_message): + """云端配置获取完成的回调处理 + + Args: + data: 获取到的配置数据 + error_message: 错误信息,如果有 + """ + debug_mode = self._is_debug_mode() + + if error_message: + # 标记配置无效 + self.config_valid = False + + # 记录错误信息,用于按钮点击时显示 + if error_message == "update_required": + self.last_error_message = "update_required" + + # 检查是否处于离线模式 + is_offline_mode = False + if hasattr(self.debug_manager, 'main_window') and hasattr(self.debug_manager.main_window, 'offline_mode_manager'): + is_offline_mode = self.debug_manager.main_window.offline_mode_manager.is_in_offline_mode() + + if is_offline_mode: + # 离线模式下只显示提示,不禁用开始安装按钮 + msg_box = msgbox_frame( + f"更新提示 - {self.app_name}", + "\n当前版本过低,请及时更新。\n在离线模式下,您仍可使用禁用/启用补丁、卸载补丁和离线安装功能。\n", + QMessageBox.StandardButton.Ok, + ) + msg_box.exec() + # 移除在浏览器中打开项目主页的代码 + # 离线模式下版本过低,仍然允许使用安装按钮 + return {"action": "enable_button"} + else: + # 在线模式下显示强制更新提示 + msg_box = msgbox_frame( + f"更新提示 - {self.app_name}", + "\n当前版本过低,请及时更新。\n如需联网下载补丁,请更新到最新版,否则无法下载。\n", + QMessageBox.StandardButton.Ok, + ) + msg_box.exec() + # 移除在浏览器中打开项目主页的代码 + # 在线模式下版本过低,但不直接禁用按钮,而是在点击时提示 + return {"action": "enable_button", "version_warning": True} + + elif "missing_keys" in error_message: + self.last_error_message = "missing_keys" + missing_versions = error_message.split(":")[1] + msg_box = msgbox_frame( + f"配置缺失 - {self.app_name}", + f'\n云端缺失下载链接,可能云服务器正在维护,不影响其他版本下载。\n当前缺失版本:"{missing_versions}"\n', + QMessageBox.StandardButton.Ok, + ) + msg_box.exec() + # 对于部分缺失,仍然允许使用,因为可能只影响部分游戏版本 + self.config_valid = True + return {"action": "enable_button"} + else: + # 设置网络错误标记 + self.last_error_message = "network_error" + + # 显示通用错误消息,只在debug模式下显示详细错误 + error_msg = "访问云端配置失败,请检查网络状况或稍后再试。" + if debug_mode and "详细错误:" in error_message: + msg_box = msgbox_frame( + f"错误 - {self.app_name}", + f"\n{error_message}\n", + QMessageBox.StandardButton.Ok, + ) + else: + msg_box = msgbox_frame( + f"错误 - {self.app_name}", + f"\n{error_msg}\n", + QMessageBox.StandardButton.Ok, + ) + msg_box.exec() + + # 网络错误时仍然允许使用按钮,用户可以尝试离线模式 + return {"action": "enable_button"} + else: + self.cloud_config = data + # 标记配置有效 + self.config_valid = True + # 清除错误信息 + self.last_error_message = "" + + if debug_mode: + print("--- Cloud config fetched successfully ---") + # 创建一个数据副本,隐藏敏感URL + safe_data = self._create_safe_config_for_logging(data) + print(json.dumps(safe_data, indent=2)) + + # 获取配置成功,允许安装 + return {"action": "enable_button"} + + def _create_safe_config_for_logging(self, config_data): + """创建用于日志记录的安全配置副本,隐藏敏感URL + + Args: + config_data: 原始配置数据 + + Returns: + dict: 安全的配置数据副本 + """ + if not config_data or not isinstance(config_data, dict): + return config_data + + # 创建深拷贝,避免修改原始数据 + import copy + safe_config = copy.deepcopy(config_data) + + # 隐藏敏感URL + for key in safe_config: + if isinstance(safe_config[key], dict) and "url" in safe_config[key]: + # 完全隐藏URL + safe_config[key]["url"] = "***URL protection***" + + return safe_config + + def is_config_valid(self): + """检查配置是否有效 + + Returns: + bool: 配置是否有效 + """ + return self.config_valid + + def get_cloud_config(self): + """获取云端配置 + + Returns: + dict: 云端配置 + """ + return self.cloud_config + + def get_last_error(self): + """获取最后一次错误信息 + + Returns: + str: 错误信息 + """ + return self.last_error_message \ 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..cc2a924 --- /dev/null +++ b/source/core/managers/debug_manager.py @@ -0,0 +1,149 @@ +import os +import sys +from PySide6 import QtWidgets +from data.config import LOG_FILE +from utils.logger import setup_logger +from utils import Logger +import datetime +from data.config import APP_NAME + +# 初始化logger +logger = setup_logger("debug_manager") + +class DebugManager: + def __init__(self, main_window): + """初始化调试管理器 + + Args: + main_window: 主窗口实例 + """ + self.main_window = main_window + self.logger = None + self.original_stdout = None + self.original_stderr = None + self.ui_manager = None # 添加ui_manager属性 + + def set_ui_manager(self, ui_manager): + """设置UI管理器引用 + + Args: + ui_manager: UI管理器实例 + """ + self.ui_manager = ui_manager + + def _is_debug_mode(self): + """检查是否处于调试模式 + + Returns: + bool: 是否处于调试模式 + """ + try: + # 首先尝试从UI管理器获取状态 + if hasattr(self, 'ui_manager') and self.ui_manager and hasattr(self.ui_manager, 'debug_action') and self.ui_manager.debug_action: + return self.ui_manager.debug_action.isChecked() + + # 如果UI管理器还没准备好,尝试从配置中获取 + if hasattr(self.main_window, 'config') and isinstance(self.main_window.config, dict): + return self.main_window.config.get('debug_mode', False) + + # 如果以上都不可行,返回False + return False + except Exception: + # 捕获任何异常,默认返回False + return False + + def toggle_debug_mode(self, checked): + """切换调试模式 + + Args: + checked: 是否启用调试模式 + """ + logger.info(f"Toggle debug mode: {checked}") + self.main_window.config["debug_mode"] = checked + self.main_window.save_config(self.main_window.config) + + # 创建或删除debug_mode.txt标记文件 + try: + from data.config import CACHE + debug_file = os.path.join(os.path.dirname(CACHE), "debug_mode.txt") + + if checked: + # 确保目录存在 + os.makedirs(os.path.dirname(debug_file), exist_ok=True) + # 创建标记文件 + with open(debug_file, 'w', encoding='utf-8') as f: + f.write(f"Debug mode enabled at {os.path.abspath(debug_file)}\n") + logger.info(f"已创建调试模式标记文件: {debug_file}") + elif os.path.exists(debug_file): + # 删除标记文件 + os.remove(debug_file) + logger.info(f"已删除调试模式标记文件: {debug_file}") + except Exception as e: + logger.warning(f"处理调试模式标记文件时发生错误: {e}") + + # 更新打开log文件按钮状态 + if hasattr(self, 'ui_manager') and hasattr(self.ui_manager, 'open_log_action'): + self.ui_manager.open_log_action.setEnabled(checked) + + if checked: + self.start_logging() + + # 如果启用了调试模式,检查是否需要强制启用离线模式 + if hasattr(self.main_window, 'offline_mode_manager'): + # 检查配置中是否已设置离线模式 + offline_mode_enabled = self.main_window.config.get("offline_mode", False) + + # 如果配置中已设置离线模式,则在调试模式下强制启用 + if offline_mode_enabled: + logger.debug("DEBUG: 调试模式下强制启用离线模式") + self.main_window.offline_mode_manager.set_offline_mode(True) + + # 更新UI中的离线模式选项 + if hasattr(self.ui_manager, 'offline_mode_action') and self.ui_manager.offline_mode_action: + self.ui_manager.offline_mode_action.setChecked(True) + self.ui_manager.online_mode_action.setChecked(False) + else: + self.stop_logging() + + def start_logging(self): + """启动日志记录""" + if self.logger is None: + try: + # 确保log目录存在 + log_dir = os.path.dirname(LOG_FILE) + if not os.path.exists(log_dir): + os.makedirs(log_dir, exist_ok=True) + logger.info(f"已创建日志目录: {log_dir}") + + # 创建新的日志文件,使用覆盖模式而不是追加模式 + with open(LOG_FILE, 'w', encoding='utf-8') as f: + current_time = datetime.datetime.now() + formatted_date = current_time.strftime("%Y-%m-%d") + formatted_time = current_time.strftime("%H:%M:%S") + f.write(f"--- 新调试会话开始于 {os.path.basename(LOG_FILE)} ---\n") + f.write(f"--- 应用版本: {APP_NAME} ---\n") + f.write(f"--- 日期: {formatted_date} 时间: {formatted_time} ---\n\n") + logger.info(f"已创建日志文件: {os.path.abspath(LOG_FILE)}") + + # 保存原始的 stdout 和 stderr + self.original_stdout = sys.stdout + self.original_stderr = sys.stderr + + # 创建 Logger 实例 + self.logger = Logger(LOG_FILE, self.original_stdout) + sys.stdout = self.logger + sys.stderr = self.logger + + logger.info(f"--- Debug mode enabled (log file: {os.path.abspath(LOG_FILE)}) ---") + except (IOError, OSError) as e: + QtWidgets.QMessageBox.critical(self.main_window, "错误", f"无法创建日志文件: {e}") + self.logger = None + + def stop_logging(self): + """停止日志记录""" + if self.logger: + logger.info("--- Debug mode disabled ---") + sys.stdout = self.original_stdout + sys.stderr = self.original_stderr + self.logger.close() + self.logger = None \ No newline at end of file diff --git a/source/core/managers/download_manager.py b/source/core/managers/download_manager.py new file mode 100644 index 0000000..efb8239 --- /dev/null +++ b/source/core/managers/download_manager.py @@ -0,0 +1,1061 @@ +import os +import requests +import json +from collections import deque +from urllib.parse import urlparse +import re + +from PySide6 import QtWidgets, QtCore +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 workers import IpOptimizerThread +from core.cloudflare_optimizer import CloudflareOptimizer +from core.download_task_manager import DownloadTaskManager +from core.extraction_handler import ExtractionHandler +from utils.logger import setup_logger + +# 初始化logger +logger = setup_logger("download_manager") + +class DownloadManager: + def __init__(self, main_window): + """初始化下载管理器 + + Args: + main_window: 主窗口实例,用于访问UI和状态 + """ + self.main_window = main_window + self.main_window.APP_NAME = APP_NAME + self.selected_folder = "" + self.download_queue = deque() + self.current_download_thread = None + self.hosts_manager = HostsManager() + + self.download_thread_level = DEFAULT_DOWNLOAD_THREAD_LEVEL + + self.cloudflare_optimizer = CloudflareOptimizer(main_window, self.hosts_manager) + self.download_task_manager = DownloadTaskManager(main_window, self.download_thread_level) + self.extraction_handler = ExtractionHandler(main_window) + + def file_dialog(self): + """显示文件夹选择对话框,选择游戏安装目录""" + self.selected_folder = QtWidgets.QFileDialog.getExistingDirectory( + self.main_window, f"选择游戏所在【上级目录】 {APP_NAME}" + ) + if not self.selected_folder: + QtWidgets.QMessageBox.warning( + self.main_window, f"通知 - {APP_NAME}", "\n未选择任何目录,请重新选择\n" + ) + return + + self.main_window.ui.start_install_text.setText("正在安装") + + self.main_window.setEnabled(False) + + self.download_action() + + def get_install_paths(self): + """获取所有游戏版本的安装路径""" + game_dirs = self.main_window.game_detector.identify_game_directories_improved(self.selected_folder) + install_paths = {} + + debug_mode = self.is_debug_mode() + + for game, info in GAME_INFO.items(): + if game in game_dirs: + game_dir = game_dirs[game] + install_path = os.path.join(game_dir, os.path.basename(info["install_path"])) + install_paths[game] = install_path + if debug_mode: + logger.debug(f"DEBUG: 使用识别到的游戏目录 {game}: {game_dir}") + logger.debug(f"DEBUG: 安装路径设置为: {install_path}") + + return install_paths + + def is_debug_mode(self): + """检查是否处于调试模式""" + if hasattr(self.main_window, 'ui_manager') and self.main_window.ui_manager: + if hasattr(self.main_window.ui_manager, 'debug_action') and self.main_window.ui_manager.debug_action: + return self.main_window.ui_manager.debug_action.isChecked() + return False + + def get_download_url(self) -> dict: + """获取所有游戏版本的下载链接 + + Returns: + dict: 包含游戏版本和下载URL的字典 + """ + try: + if self.main_window.cloud_config: + if self.is_debug_mode(): + logger.info("--- Using pre-fetched cloud config ---") + config_data = self.main_window.cloud_config + else: + headers = {"User-Agent": UA} + response = requests.get(CONFIG_URL, headers=headers, timeout=10) + response.raise_for_status() + config_data = response.json() + + if not config_data: + raise ValueError("未能获取或解析配置数据") + + if self.is_debug_mode(): + # 创建安全版本的配置数据用于调试输出 + safe_config = self._create_safe_config_for_logging(config_data) + 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 = { + f"vol{i+1}": f"vol.{i+1}.data" for i in range(4) + } + missing_keys_map["after"] = "after.data" + + extracted_keys = set(urls.keys()) + all_keys = set(missing_keys_map.keys()) + missing_simple_keys = all_keys - extracted_keys + + missing_original_keys = [missing_keys_map[k] for k in missing_simple_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字典用于调试输出 + safe_urls = {} + for key, url in urls.items(): + # 保留域名部分,隐藏路径 + import re + domain_match = re.match(r'(https?://[^/]+)/.*', url) + if domain_match: + domain = domain_match.group(1) + safe_urls[key] = f"{domain}/***隐藏URL路径***" + else: + safe_urls[key] = "***隐藏URL***" + logger.debug(f"DEBUG: Extracted URLs: {safe_urls}") + logger.info("--- Finished getting download URL successfully ---") + return urls + + except requests.exceptions.RequestException as e: + status_code = e.response.status_code if e.response is not None else "未知" + try: + error_response = e.response.json() if e.response else {} + json_title = error_response.get("title", "无错误类型") + json_message = error_response.get("message", "无附加错误信息") + except (ValueError, AttributeError): + json_title = "配置文件异常,无法解析错误类型" + json_message = "配置文件异常,无法解析错误信息" + + if self.is_debug_mode(): + logger.error(f"ERROR: Failed to get download config due to RequestException: {e}") + + QtWidgets.QMessageBox.critical( + self.main_window, + f"错误 - {APP_NAME}", + f"\n下载配置获取失败\n\n【HTTP状态】:{status_code}\n【错误类型】:{json_title}\n【错误信息】:{json_message}\n", + ) + return {} + except ValueError as e: + if self.is_debug_mode(): + logger.error(f"ERROR: Failed to parse download config due to ValueError: {e}") + + QtWidgets.QMessageBox.critical( + self.main_window, + f"错误 - {APP_NAME}", + f"\n配置文件格式异常\n\n【错误信息】:{e}\n", + ) + return {} + + def _create_safe_config_for_logging(self, config_data): + """创建用于日志记录的安全配置副本,隐藏敏感URL + + Args: + config_data: 原始配置数据 + + Returns: + dict: 安全的配置数据副本 + """ + if not config_data or not isinstance(config_data, dict): + return config_data + + # 创建深拷贝,避免修改原始数据 + import copy + safe_config = copy.deepcopy(config_data) + + # 隐藏敏感URL + for key in safe_config: + if isinstance(safe_config[key], dict) and "url" in safe_config[key]: + # 完全隐藏URL + safe_config[key]["url"] = "***URL protection***" + + return safe_config + + def download_action(self): + """下载操作的主入口点""" + 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) + self.main_window.ui.start_install_text.setText("开始安装") + return + + # 关闭可能存在的哈希校验窗口 + 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.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): + """优化的哈希预检查完成处理,带有游戏目录信息 + + Args: + updated_status: 更新后的安装状态 + game_dirs: 识别到的游戏目录 + """ + self.main_window.installed_status = updated_status + + # 关闭哈希校验窗口 + self.main_window.close_hash_msg_box() + + debug_mode = self.is_debug_mode() + + self.main_window.setEnabled(True) + + # 使用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: + 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是否要启用这些补丁?" + + 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: + # 用户选择启用补丁 + if debug_mode: + logger.debug(f"DEBUG: 用户选择启用被禁用的补丁") + + # 为每个禁用的游戏创建目录映射 + 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: + if debug_mode: + logger.info(f"DEBUG: 用户选择不启用被禁用的补丁,这些游戏将被添加到可安装列表") + # 用户选择不启用,将这些游戏视为可以安装补丁 + installable_games.extend(disabled_patch_games) + + # 如果有可安装的游戏,显示选择对话框 + if installable_games: + # 创建游戏选择对话框 + dialog = QtWidgets.QDialog(self.main_window) + dialog.setWindowTitle(f"选择要安装的游戏 - {APP_NAME}") + dialog.setMinimumWidth(400) + dialog.setMinimumHeight(300) + + 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) + + # 添加按钮 + 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) + + # 连接按钮信号 + 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.info("DEBUG: 使用离线模式,跳过网络配置获取") + self._fill_offline_download_queue(selected_game_dirs) + else: + # 在线模式下,重新获取云端配置 + if hasattr(self.main_window, 'fetch_cloud_config'): + if debug_mode: + logger.info("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: 用户取消了游戏选择") + self.main_window.ui.start_install_text.setText("开始安装") + 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}", + msg + ) + self.main_window.ui.start_install_text.setText("开始安装") + + 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("开始安装") + return + + 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: 离线模式,跳过Cloudflare优化") + self.next_download_task() + else: + self._show_cloudflare_option() + + def _fill_download_queue(self, config, game_dirs): + """填充下载队列 + + 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}") + + for i in range(1, 5): + game_version = f"NEKOPARA Vol.{i}" + if game_version in game_dirs and not self.main_window.installed_status.get(game_version, False): + url = config.get(f"vol{i}") + if not url: continue + + 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)) + self.main_window.download_queue_history.append(game_version) + + game_version = "NEKOPARA After" + if game_version in game_dirs and not self.main_window.installed_status.get(game_version, False): + url = config.get("after") + 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)) + self.main_window.download_queue_history.append(game_version) + + def _fill_offline_download_queue(self, game_dirs): + """填充离线模式下的下载队列 + + Args: + 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}") + + # 检查是否有离线模式管理器 + if not hasattr(self.main_window, 'offline_mode_manager'): + if debug_mode: + logger.warning("DEBUG: 离线模式管理器未初始化,无法使用离线模式") + return + + for i in range(1, 5): + game_version = f"NEKOPARA Vol.{i}" + if game_version in game_dirs and not self.main_window.installed_status.get(game_version, False): + # 获取离线补丁文件路径 + offline_patch_path = self.main_window.offline_mode_manager.get_offline_patch_path(game_version) + if not offline_patch_path: + if debug_mode: + logger.warning(f"DEBUG: 未找到 {game_version} 的离线补丁文件,跳过") + continue + + game_folder = game_dirs[game_version] + if debug_mode: + logger.debug(f"DEBUG: 使用识别到的游戏目录 {game_version}: {game_folder}") + logger.debug(f"DEBUG: 使用离线补丁文件: {offline_patch_path}") + + _7z_path = os.path.join(PLUGIN, f"vol.{i}.7z") + plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"]) + + # 将本地文件路径作为URL添加到下载队列 + self.download_queue.append((offline_patch_path, game_folder, game_version, _7z_path, plugin_path)) + self.main_window.download_queue_history.append(game_version) + + game_version = "NEKOPARA After" + if game_version in game_dirs and not self.main_window.installed_status.get(game_version, False): + # 获取离线补丁文件路径 + offline_patch_path = self.main_window.offline_mode_manager.get_offline_patch_path(game_version) + if offline_patch_path: + game_folder = game_dirs[game_version] + if debug_mode: + logger.debug(f"DEBUG: 使用识别到的游戏目录 {game_version}: {game_folder}") + logger.debug(f"DEBUG: 使用离线补丁文件: {offline_patch_path}") + + _7z_path = os.path.join(PLUGIN, "after.7z") + plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"]) + + # 将本地文件路径作为URL添加到下载队列 + self.download_queue.append((offline_patch_path, game_folder, game_version, _7z_path, plugin_path)) + self.main_window.download_queue_history.append(game_version) + elif debug_mode: + logger.warning(f"DEBUG: 未找到 {game_version} 的离线补丁文件,跳过") + + def _show_cloudflare_option(self): + """显示Cloudflare加速选择对话框""" + if self.download_queue: + first_url = self.download_queue[0][0] + + # 直接检查是否本次会话已执行过优选 + 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 + + 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") + if os.path.exists(cf_icon_path): + cf_pixmap = QPixmap(cf_icon_path) + if not cf_pixmap.isNull(): + msg_box.setWindowIcon(QIcon(cf_pixmap)) + msg_box.setIconPixmap(cf_pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation)) + else: + msg_box.setIcon(QtWidgets.QMessageBox.Icon.Question) + + yes_button = msg_box.addButton("是,开启加速", QtWidgets.QMessageBox.ButtonRole.YesRole) + no_button = msg_box.addButton("否,直接下载", QtWidgets.QMessageBox.ButtonRole.NoRole) + cancel_button = msg_box.addButton("取消安装", QtWidgets.QMessageBox.ButtonRole.RejectRole) + + msg_box.exec() + + clicked_button = msg_box.clickedButton() + if clicked_button == cancel_button: + self.main_window.setEnabled(True) + self.main_window.ui.start_install_text.setText("开始安装") + self.download_queue.clear() + return + + self.main_window.setEnabled(False) + + use_optimization = clicked_button == yes_button + + if use_optimization and not self.cloudflare_optimizer.is_optimization_done(): + first_url = self.download_queue[0][0] + self.main_window.current_url = first_url + self.cloudflare_optimizer.start_ip_optimization(first_url) + QtCore.QTimer.singleShot(100, self.check_optimization_status) + else: + self.next_download_task() + + def check_optimization_status(self): + """检查IP优化状态并继续下载流程""" + if self.cloudflare_optimizer.is_optimization_done() and self.cloudflare_optimizer.is_countdown_finished(): + self.next_download_task() + else: + QtCore.QTimer.singleShot(100, self.check_optimization_status) + + def next_download_task(self): + """处理下载队列中的下一个任务""" + if not self.download_queue: + # 所有下载任务都已完成,进行后检查 + 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(): + return + + url, game_folder, game_version, _7z_path, plugin_path = self.download_queue.popleft() + self.download_setting(url, game_folder, game_version, _7z_path, plugin_path) + + def download_setting(self, url, game_folder, game_version, _7z_path, plugin_path): + """准备下载特定游戏版本 + + Args: + url: 下载URL或本地文件路径 + game_folder: 游戏文件夹路径 + game_version: 游戏版本名称 + _7z_path: 7z文件保存路径 + plugin_path: 插件路径 + """ + install_paths = self.get_install_paths() + + debug_mode = self.is_debug_mode() + if debug_mode: + logger.debug(f"DEBUG: 准备下载游戏 {game_version}") + logger.debug(f"DEBUG: 游戏文件夹: {game_folder}") + + # 隐藏敏感URL + safe_url = "***URL protection***" # 完全隐藏URL + logger.debug(f"DEBUG: 下载URL: {safe_url}") + + game_exe_exists = True + + if ( + not game_exe_exists + or self.main_window.installed_status[game_version] + ): + if debug_mode: + logger.debug(f"DEBUG: 跳过下载游戏 {game_version}") + logger.debug(f"DEBUG: 游戏存在: {game_exe_exists}") + logger.debug(f"DEBUG: 已安装补丁: {self.main_window.installed_status[game_version]}") + self.main_window.installed_status[game_version] = False if not game_exe_exists else True + self.next_download_task() + 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() + + # 如果是离线模式且URL是本地文件路径 + if is_offline_mode and os.path.isfile(url): + if debug_mode: + logger.debug(f"DEBUG: 离线模式,复制本地补丁文件 {url} 到 {_7z_path}") + + try: + # 确保目标目录存在 + os.makedirs(os.path.dirname(_7z_path), exist_ok=True) + + # 复制文件 + import shutil + shutil.copy2(url, _7z_path) + + # 验证文件哈希 + hash_valid = False + if hasattr(self.main_window, 'offline_mode_manager'): + if debug_mode: + logger.debug(f"DEBUG: 开始验证补丁文件哈希: {_7z_path}") + hash_valid = self.main_window.offline_mode_manager.verify_patch_hash(game_version, _7z_path) + if debug_mode: + logger.debug(f"DEBUG: 补丁文件哈希验证结果: {'成功' if hash_valid else '失败'}") + else: + if debug_mode: + logger.warning("DEBUG: 离线模式管理器不可用,跳过哈希验证") + hash_valid = True # 如果没有离线模式管理器,假设验证成功 + + if hash_valid: + if debug_mode: + logger.info(f"DEBUG: 成功复制并验证补丁文件 {_7z_path}") + # 直接进入解压阶段 + self.extraction_handler.start_extraction(_7z_path, game_folder, plugin_path, game_version) + self.main_window.extraction_handler.extraction_finished.connect(self.on_extraction_finished) + else: + if debug_mode: + logger.warning(f"DEBUG: 补丁文件哈希验证失败") + # 显示错误消息 + QtWidgets.QMessageBox.critical( + self.main_window, + f"错误 - {APP_NAME}", + f"\n补丁文件校验失败: {game_version}\n\n文件可能已损坏或被篡改,请重新获取补丁文件。\n" + ) + # 继续下一个任务 + self.next_download_task() + except Exception as e: + if debug_mode: + logger.error(f"DEBUG: 复制补丁文件失败: {e}") + # 显示错误消息 + QtWidgets.QMessageBox.critical( + self.main_window, + f"错误 - {APP_NAME}", + f"\n复制补丁文件失败: {game_version}\n错误: {e}\n" + ) + # 继续下一个任务 + self.next_download_task() + else: + # 在线模式,正常下载 + self.main_window.progress_window = self.main_window.create_progress_window() + + self.optimized_ip = self.cloudflare_optimizer.get_optimized_ip() + if self.optimized_ip: + logger.info(f"已为 {game_version} 获取到优选IP: {self.optimized_ip}") + else: + logger.info(f"未能为 {game_version} 获取优选IP,将使用默认线路。") + + 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: 游戏版本 + _7z_path: 7z文件保存路径 + plugin_path: 插件保存路径 + """ + # 如果下载失败,显示错误并询问是否重试 + if not success: + logger.error(f"--- Download Failed: {game_version} ---") + logger.error(error) + logger.error("------------------------------------") + + self.main_window.setEnabled(True) + + # 分析错误类型 + error_type = "未知错误" + suggestion = "" + + if "SSL/TLS handshake failure" in error: + error_type = "SSL/TLS连接失败" + suggestion = "可能是由于网络连接不稳定或证书问题,建议:\n1. 检查网络连接\n2. 尝试使用其他网络\n3. 确保系统时间和日期正确\n4. 可能需要使用代理或VPN" + elif "Connection timed out" in error or "read timed out" in error: + error_type = "连接超时" + suggestion = "下载服务器响应时间过长,建议:\n1. 检查网络连接\n2. 稍后重试\n3. 使用优化网络选项" + elif "404" in error: + error_type = "文件不存在" + suggestion = "请求的文件不存在或已移除,请联系开发者" + elif "403" in error: + error_type = "访问被拒绝" + suggestion = "服务器拒绝请求,可能需要使用优化网络选项" + elif "No space left on device" in error or "空间不足" in error: + error_type = "存储空间不足" + suggestion = "请确保有足够的磁盘空间用于下载和解压文件" + + msg_box = QtWidgets.QMessageBox(self.main_window) + msg_box.setWindowTitle(f"下载失败 - {APP_NAME}") + error_message = f"\n文件获取失败: {game_version}\n错误类型: {error_type}" + + if suggestion: + error_message += f"\n\n可能的解决方案:\n{suggestion}" + + error_message += "\n\n是否重试?" + msg_box.setText(error_message) + + retry_button = msg_box.addButton("重试", QtWidgets.QMessageBox.ButtonRole.YesRole) + next_button = msg_box.addButton("下一个", QtWidgets.QMessageBox.ButtonRole.NoRole) + end_button = msg_box.addButton("结束", QtWidgets.QMessageBox.ButtonRole.RejectRole) + + msg_box.exec() + clicked_button = msg_box.clickedButton() + + if clicked_button == retry_button: + self.main_window.setEnabled(False) + self.download_setting(url, game_folder, game_version, _7z_path, plugin_path) + elif clicked_button == next_button: + self.main_window.setEnabled(False) + self.next_download_task() + 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.main_window.extraction_handler.extraction_finished.connect(self.on_extraction_finished) + + def on_extraction_finished(self, continue_download): + """解压完成后的回调,决定是否继续下载队列 + + Args: + continue_download: 是否继续下载队列中的下一个任务 + """ + if continue_download: + self.next_download_task() + else: + self.download_queue.clear() + self.main_window.show_result() + + def on_download_stopped(self): + """当用户点击停止按钮或选择结束时调用的函数""" + self.cloudflare_optimizer.stop_optimization() + + self.download_task_manager.stop_download() + + self.download_queue.clear() + + 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.reject() + self.main_window.progress_window = None + + logger.info("下载已全部停止。") + + self.main_window.setEnabled(True) + self.main_window.ui.start_install_text.setText("开始安装") + + QtWidgets.QMessageBox.information( + self.main_window, + f"已取消 - {APP_NAME}", + "\n已成功取消安装进程。\n" + ) + + def get_download_thread_count(self): + """获取当前下载线程设置对应的线程数""" + return self.download_task_manager.get_download_thread_count() + + def set_download_thread_level(self, level): + """设置下载线程级别""" + return self.download_task_manager.set_download_thread_level(level) + + def show_download_thread_settings(self): + """显示下载线程设置对话框""" + 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) + self.main_window.ui.start_install_text.setText("开始安装") + 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) + self.main_window.ui.start_install_text.setText("开始安装") + 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) + self.main_window.ui.start_install_text.setText("开始安装") + 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") \ No newline at end of file diff --git a/source/core/managers/download_task_manager.py b/source/core/managers/download_task_manager.py new file mode 100644 index 0000000..72ad5ee --- /dev/null +++ b/source/core/managers/download_task_manager.py @@ -0,0 +1,221 @@ +from PySide6 import QtWidgets +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QDialog, QVBoxLayout, QRadioButton, QPushButton, QLabel, QButtonGroup, QHBoxLayout +from PySide6.QtGui import QFont + +from data.config import DOWNLOAD_THREADS + + +class DownloadTaskManager: + """下载任务管理器,负责管理下载任务和线程设置""" + + def __init__(self, main_window, download_thread_level="medium"): + """初始化下载任务管理器 + + Args: + main_window: 主窗口实例,用于访问UI和状态 + download_thread_level: 下载线程级别,默认为"medium" + """ + self.main_window = main_window + self.APP_NAME = main_window.APP_NAME if hasattr(main_window, 'APP_NAME') else "" + self.current_download_thread = None + self.download_thread_level = download_thread_level + + def start_download(self, url, _7z_path, game_version, game_folder, plugin_path): + """启动下载线程 + + Args: + url: 下载URL + _7z_path: 7z文件保存路径 + game_version: 游戏版本名称 + game_folder: 游戏文件夹路径 + plugin_path: 插件路径 + """ + # 按钮在file_dialog中已设置为禁用状态 + + # 创建并连接下载线程 + self.current_download_thread = self.main_window.create_download_thread(url, _7z_path, game_version) + self.current_download_thread.progress.connect(self.main_window.progress_window.update_progress) + self.current_download_thread.finished.connect( + lambda success, error: self.main_window.download_manager.on_download_finished( + success, + error, + url, + game_folder, + game_version, + _7z_path, + plugin_path, + ) + ) + + # 连接停止按钮到download_manager的on_download_stopped方法 + self.main_window.progress_window.stop_button.clicked.connect(self.main_window.download_manager.on_download_stopped) + + # 连接暂停/恢复按钮 + self.main_window.progress_window.pause_resume_button.clicked.connect(self.toggle_download_pause) + + # 启动线程和显示进度窗口 + self.current_download_thread.start() + self.main_window.progress_window.exec() + + def toggle_download_pause(self): + """切换下载的暂停/恢复状态""" + if not self.current_download_thread: + return + + # 获取当前暂停状态 + is_paused = self.current_download_thread.is_paused() + + if is_paused: + # 如果已暂停,则恢复下载 + success = self.current_download_thread.resume() + if success: + self.main_window.progress_window.update_pause_button_state(False) + else: + # 如果未暂停,则暂停下载 + success = self.current_download_thread.pause() + if success: + self.main_window.progress_window.update_pause_button_state(True) + + def get_download_thread_count(self): + """获取当前下载线程设置对应的线程数 + + Returns: + int: 下载线程数 + """ + # 获取当前线程级别对应的线程数 + thread_count = DOWNLOAD_THREADS.get(self.download_thread_level, DOWNLOAD_THREADS["medium"]) + return thread_count + + def set_download_thread_level(self, level): + """设置下载线程级别 + + Args: + level: 线程级别 (low, medium, high, extreme, insane) + + Returns: + bool: 设置是否成功 + """ + if level in DOWNLOAD_THREADS: + old_level = self.download_thread_level + self.download_thread_level = level + + # 只有非极端级别才保存到配置 + if level not in ["extreme", "insane"]: + if hasattr(self.main_window, 'config'): + self.main_window.config["download_thread_level"] = level + self.main_window.save_config(self.main_window.config) + + return True + return False + + def show_download_thread_settings(self): + """显示下载线程设置对话框""" + # 创建对话框 + dialog = QDialog(self.main_window) + dialog.setWindowTitle(f"下载线程设置 - {self.APP_NAME}") + dialog.setMinimumWidth(350) + + layout = QVBoxLayout(dialog) + + # 添加说明标签 + info_label = QLabel("选择下载线程数量(更多线程通常可以提高下载速度):", dialog) + info_label.setWordWrap(True) + layout.addWidget(info_label) + + # 创建按钮组 + button_group = QButtonGroup(dialog) + + # 添加线程选项 + thread_options = { + "low": f"低速 - {DOWNLOAD_THREADS['low']}线程(慢慢来,不着急)", + "medium": f"中速 - {DOWNLOAD_THREADS['medium']}线程(快人半步)", + "high": f"高速 - {DOWNLOAD_THREADS['high']}线程(默认,推荐配置)", + "extreme": f"极速 - {DOWNLOAD_THREADS['extreme']}线程(如果你对你的网和电脑很自信的话)", + "insane": f"狂暴 - {DOWNLOAD_THREADS['insane']}线程(看看是带宽和性能先榨干还是牛牛先榨干)" + } + + radio_buttons = {} + + for level, text in thread_options.items(): + radio = QRadioButton(text, dialog) + + # 选中当前使用的线程级别 + if level == self.download_thread_level: + radio.setChecked(True) + + button_group.addButton(radio) + layout.addWidget(radio) + radio_buttons[level] = radio + + layout.addSpacing(10) + + # 添加按钮区域 + btn_layout = QHBoxLayout() + + ok_button = QPushButton("确定", dialog) + cancel_button = QPushButton("取消", dialog) + + btn_layout.addWidget(ok_button) + btn_layout.addWidget(cancel_button) + + layout.addLayout(btn_layout) + + # 连接按钮事件 + ok_button.clicked.connect(dialog.accept) + cancel_button.clicked.connect(dialog.reject) + + # 显示对话框 + result = dialog.exec() + + # 处理结果 + if result == QDialog.DialogCode.Accepted: + # 获取用户选择的线程级别 + selected_level = None + for level, radio in radio_buttons.items(): + if radio.isChecked(): + selected_level = level + break + + if selected_level: + # 为极速和狂暴模式显示警告 + if selected_level in ["extreme", "insane"]: + warning_result = QtWidgets.QMessageBox.warning( + self.main_window, + f"高风险警告 - {self.APP_NAME}", + "警告!过高的线程数可能导致CPU负载过高或其他恶性问题!\n你确定要这么做吗?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.No + ) + + if warning_result != QtWidgets.QMessageBox.StandardButton.Yes: + return False + + success = self.set_download_thread_level(selected_level) + + if success: + # 显示设置成功消息 + thread_count = DOWNLOAD_THREADS[selected_level] + message = f"\n已成功设置下载线程为: {thread_count}线程\n" + + # 对于极速和狂暴模式,添加仅本次生效的提示 + if selected_level in ["extreme", "insane"]: + message += "\n注意:极速/狂暴模式仅本次生效。软件重启后将恢复默认设置。\n" + + QtWidgets.QMessageBox.information( + self.main_window, + f"设置成功 - {self.APP_NAME}", + message + ) + + return True + + return False + + def stop_download(self): + """停止当前下载线程""" + if self.current_download_thread and self.current_download_thread.isRunning(): + self.current_download_thread.stop() + self.current_download_thread.wait() # 等待线程完全终止 + return True + return False \ No newline at end of file diff --git a/source/core/managers/game_detector.py b/source/core/managers/game_detector.py new file mode 100644 index 0000000..896e0ba --- /dev/null +++ b/source/core/managers/game_detector.py @@ -0,0 +1,354 @@ +from PySide6.QtCore import QThread, Signal +import os +import re +from utils.logger import setup_logger + +class GameDetectionThread(QThread): + """用于在后台线程中执行游戏目录识别的线程""" + finished = Signal(dict) + + def __init__(self, detector_func, selected_folder): + super().__init__() + self.detector_func = detector_func + self.selected_folder = selected_folder + + def run(self): + result = self.detector_func(self.selected_folder) + self.finished.emit(result) + +class GameDetector: + """游戏检测器,用于识别游戏目录和版本""" + + def __init__(self, game_info, debug_manager=None): + """初始化游戏检测器 + + Args: + game_info: 游戏信息字典,包含各版本的安装路径和可执行文件名 + debug_manager: 调试管理器实例,用于输出调试信息 + """ + self.game_info = game_info + self.debug_manager = debug_manager + self.directory_cache = {} # 添加目录缓存 + self.logger = setup_logger("game_detector") + self.detection_thread = None + + def identify_game_directories_async(self, selected_folder, callback): + """异步识别游戏目录""" + def on_finished(game_dirs): + callback(game_dirs) + self.detection_thread = None + + self.detection_thread = GameDetectionThread(self.identify_game_directories_improved, selected_folder) + self.detection_thread.finished.connect(on_finished) + self.detection_thread.start() + + def _is_debug_mode(self): + """检查是否处于调试模式 + + Returns: + bool: 是否处于调试模式 + """ + if hasattr(self.debug_manager, 'ui_manager') and hasattr(self.debug_manager.ui_manager, 'debug_action'): + return self.debug_manager.ui_manager.debug_action.isChecked() + return False + + def identify_game_version(self, game_dir): + """识别游戏版本 + + Args: + game_dir: 游戏目录路径 + + Returns: + str: 游戏版本名称,如果不是有效的游戏目录则返回None + """ + debug_mode = self._is_debug_mode() + + if debug_mode: + self.logger.debug(f"尝试识别游戏版本: {game_dir}") + + # 先通过目录名称进行初步推测(这将作为递归搜索的提示) + dir_name = os.path.basename(game_dir).lower() + potential_version = None + vol_num = None + + # 提取卷号或判断是否是After + if "vol" in dir_name or "vol." in dir_name: + vol_match = re.search(r"vol(?:\.|\s*)?(\d+)", dir_name) + if vol_match: + vol_num = vol_match.group(1) + potential_version = f"NEKOPARA Vol.{vol_num}" + if debug_mode: + self.logger.debug(f"从目录名推测游戏版本: {potential_version}, 卷号: {vol_num}") + elif "after" in dir_name: + potential_version = "NEKOPARA After" + if debug_mode: + self.logger.debug(f"从目录名推测游戏版本: NEKOPARA After") + + # 检查是否为NEKOPARA游戏目录 + # 通过检查游戏可执行文件来识别游戏版本 + for game_version, info in self.game_info.items(): + # 尝试多种可能的可执行文件名变体 + exe_variants = [ + info["exe"], # 标准文件名 + info["exe"] + ".nocrack", # Steam加密版本 + info["exe"].replace(".exe", ""), # 无扩展名版本 + info["exe"].replace("NEKOPARA", "nekopara").lower(), # 全小写变体 + info["exe"].lower(), # 小写变体 + info["exe"].lower() + ".nocrack", # 小写变体的Steam加密版本 + ] + + # 对于Vol.3可能有特殊名称 + if "Vol.3" in game_version: + # 增加可能的卷3特定的变体 + exe_variants.extend([ + "NEKOPARAVol3.exe", + "NEKOPARAVol3.exe.nocrack", + "nekoparavol3.exe", + "nekoparavol3.exe.nocrack", + "nekopara_vol3.exe", + "nekopara_vol3.exe.nocrack", + "vol3.exe", + "vol3.exe.nocrack" + ]) + + for exe_variant in exe_variants: + exe_path = os.path.join(game_dir, exe_variant) + if os.path.exists(exe_path): + if debug_mode: + self.logger.debug(f"通过可执行文件确认游戏版本: {game_version}, 文件: {exe_variant}") + return game_version + + # 如果没有直接匹配,尝试递归搜索 + if potential_version: + # 从预测的版本中获取卷号或确认是否是After + is_after = "After" in potential_version + if not vol_num and not is_after: + vol_match = re.search(r"Vol\.(\d+)", potential_version) + if vol_match: + vol_num = vol_match.group(1) + + # 递归搜索可执行文件 + for root, dirs, files in os.walk(game_dir): + for file in files: + file_lower = file.lower() + if file.endswith('.exe') or file.endswith('.exe.nocrack'): + # 检查文件名中是否包含卷号或关键词 + if ((vol_num and (f"vol{vol_num}" in file_lower or + f"vol.{vol_num}" in file_lower or + f"vol {vol_num}" in file_lower)) or + (is_after and "after" in file_lower)): + if debug_mode: + self.logger.debug(f"通过递归搜索确认游戏版本: {potential_version}, 文件: {file}") + return potential_version + + # 如果仍然没有找到,基于目录名的推测返回结果 + if potential_version: + if debug_mode: + self.logger.debug(f"基于目录名返回推测的游戏版本: {potential_version}") + return potential_version + + if debug_mode: + self.logger.debug(f"无法识别游戏版本: {game_dir}") + + return None + + def identify_game_directories_improved(self, selected_folder): + """改进的游戏目录识别,支持大小写不敏感和特殊字符处理 + + Args: + selected_folder: 选择的上级目录 + + Returns: + dict: 游戏版本到游戏目录的映射 + """ + debug_mode = self._is_debug_mode() + + # 检查缓存中是否已有该目录的识别结果 + if selected_folder in self.directory_cache: + if debug_mode: + self.logger.debug(f"使用缓存的目录识别结果: {selected_folder}") + return self.directory_cache[selected_folder] + + if debug_mode: + self.logger.debug(f"--- 开始识别目录: {selected_folder} ---") + + game_paths = {} + + # 获取上级目录中的所有文件夹 + try: + all_dirs = [d for d in os.listdir(selected_folder) if os.path.isdir(os.path.join(selected_folder, d))] + if debug_mode: + self.logger.debug(f"找到以下子目录: {all_dirs}") + except Exception as e: + if debug_mode: + self.logger.debug(f"无法读取目录 {selected_folder}: {str(e)}") + return {} + + for game, info in self.game_info.items(): + expected_dir = info["install_path"].split("/")[0] # 例如 "NEKOPARA Vol. 1" + expected_exe = info["exe"] # 标准可执行文件名 + + if debug_mode: + self.logger.debug(f"搜索游戏 {game}, 预期目录: {expected_dir}, 预期可执行文件: {expected_exe}") + + # 尝试不同的匹配方法 + found_dir = None + + # 1. 精确匹配 + if expected_dir in all_dirs: + found_dir = expected_dir + if debug_mode: + self.logger.debug(f"精确匹配成功: {expected_dir}") + + # 2. 大小写不敏感匹配 + if not found_dir: + for dir_name in all_dirs: + if expected_dir.lower() == dir_name.lower(): + found_dir = dir_name + if debug_mode: + self.logger.debug(f"大小写不敏感匹配成功: {dir_name}") + break + + # 3. 更模糊的匹配(允许特殊字符差异) + if not found_dir: + # 准备用于模糊匹配的正则表达式模式 + # 替换空格为可选空格或连字符,替换点为可选点 + pattern_text = expected_dir.replace(" ", "[ -]?").replace(".", "\\.?") + pattern = re.compile(f"^{pattern_text}$", re.IGNORECASE) + + for dir_name in all_dirs: + if pattern.match(dir_name): + found_dir = dir_name + if debug_mode: + self.logger.debug(f"模糊匹配成功: {dir_name} 匹配模式 {pattern_text}") + break + + # 4. 如果还是没找到,尝试更宽松的匹配 + if not found_dir: + vol_match = re.search(r"vol(?:\.|\s*)?(\d+)", expected_dir, re.IGNORECASE) + vol_num = None + if vol_match: + vol_num = vol_match.group(1) + if debug_mode: + self.logger.debug(f"提取卷号: {vol_num}") + + is_after = "after" in expected_dir.lower() + + for dir_name in all_dirs: + dir_lower = dir_name.lower() + + # 对于After特殊处理 + if is_after and "after" in dir_lower: + found_dir = dir_name + if debug_mode: + self.logger.debug(f"After特殊匹配成功: {dir_name}") + break + + # 对于Vol特殊处理 + if vol_num: + # 查找目录名中的卷号 + dir_vol_match = re.search(r"vol(?:\.|\s*)?(\d+)", dir_lower) + if dir_vol_match and dir_vol_match.group(1) == vol_num: + found_dir = dir_name + if debug_mode: + self.logger.debug(f"卷号匹配成功: {dir_name} 卷号 {vol_num}") + break + + # 如果找到匹配的目录,验证exe文件是否存在 + if found_dir: + potential_path = os.path.join(selected_folder, found_dir) + + # 尝试多种可能的可执行文件名变体 + # 包括Steam加密版本和其他可能的变体 + exe_variants = [ + expected_exe, # 标准文件名 + expected_exe + ".nocrack", # Steam加密版本 + expected_exe.replace(".exe", ""),# 无扩展名版本 + # Vol.3的特殊变体,因为它的文件名可能不一样 + expected_exe.replace("NEKOPARA", "nekopara").lower(), # 全小写变体 + expected_exe.lower(), # 小写变体 + expected_exe.lower() + ".nocrack", # 小写变体的Steam加密版本 + ] + + # 对于Vol.3可能有特殊名称 + if "Vol.3" in game: + # 增加可能的卷3特定的变体 + exe_variants.extend([ + "NEKOPARAVol3.exe", + "NEKOPARAVol3.exe.nocrack", + "nekoparavol3.exe", + "nekoparavol3.exe.nocrack", + "nekopara_vol3.exe", + "nekopara_vol3.exe.nocrack", + "vol3.exe", + "vol3.exe.nocrack" + ]) + + exe_exists = False + found_exe = None + + # 尝试所有可能的变体 + for exe_variant in exe_variants: + exe_path = os.path.join(potential_path, exe_variant) + if os.path.exists(exe_path): + exe_exists = True + found_exe = exe_variant + if debug_mode: + self.logger.debug(f"验证成功,找到游戏可执行文件: {exe_variant}") + break + + # 如果没有直接找到,尝试递归搜索当前目录下的所有可执行文件 + if not exe_exists: + # 遍历当前目录下的所有文件和文件夹 + for root, dirs, files in os.walk(potential_path): + for file in files: + file_lower = file.lower() + # 检查是否是游戏可执行文件(根据关键字) + if file.endswith('.exe') or file.endswith('.exe.nocrack'): + # 检查文件名中是否包含卷号或关键词 + if "Vol." in game: + vol_match = re.search(r"Vol\.(\d+)", game) + if vol_match: + vol_num = vol_match.group(1) + if (f"vol{vol_num}" in file_lower or + f"vol.{vol_num}" in file_lower or + f"vol {vol_num}" in file_lower): + exe_path = os.path.join(root, file) + exe_exists = True + found_exe = os.path.relpath(exe_path, potential_path) + if debug_mode: + self.logger.debug(f"通过递归搜索找到游戏可执行文件: {found_exe}") + break + elif "After" in game and "after" in file_lower: + exe_path = os.path.join(root, file) + exe_exists = True + found_exe = os.path.relpath(exe_path, potential_path) + if debug_mode: + self.logger.debug(f"通过递归搜索找到After游戏可执行文件: {found_exe}") + break + if exe_exists: + break + + # 如果找到了可执行文件,将该目录添加到游戏目录列表 + if exe_exists: + game_paths[game] = potential_path + if debug_mode: + self.logger.debug(f"验证成功,将 {potential_path} 添加为 {game} 的目录") + else: + if debug_mode: + self.logger.debug(f"未找到任何可执行文件变体,游戏 {game} 在 {potential_path} 未找到") + + if debug_mode: + self.logger.debug(f"最终识别的游戏目录: {game_paths}") + self.logger.debug(f"--- 目录识别结束 ---") + + # 将识别结果存入缓存 + self.directory_cache[selected_folder] = game_paths + + return game_paths + + def clear_directory_cache(self): + """清除目录缓存""" + self.directory_cache = {} + if self._is_debug_mode(): + self.logger.debug("已清除目录缓存") \ No newline at end of file diff --git a/source/core/managers/ipv6_manager.py b/source/core/managers/ipv6_manager.py new file mode 100644 index 0000000..d6e7272 --- /dev/null +++ b/source/core/managers/ipv6_manager.py @@ -0,0 +1,323 @@ +import os +import sys +import time +import subprocess +import urllib.request +import ssl +import threading +from PySide6.QtCore import QObject, Signal +from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QTextEdit, QProgressBar, QMessageBox + +from data.config import APP_NAME +from utils import msgbox_frame + + +class IPv6Manager: + """管理IPv6相关功能的类""" + + def __init__(self, main_window): + """初始化IPv6管理器 + + Args: + main_window: 主窗口实例,用于显示对话框和访问配置 + """ + self.main_window = main_window + self.config = getattr(main_window, 'config', {}) + + def check_ipv6_availability(self): + """检查IPv6是否可用 + + 通过访问IPv6专用图片URL测试IPv6连接 + + Returns: + bool: IPv6是否可用 + """ + import urllib.request + import time + + print("开始检测IPv6可用性...") + + try: + # 获取IPv6测试请求 + ipv6_test_url, req, context = self._get_ipv6_test_request() + + # 设置3秒超时,避免长时间等待 + start_time = time.time() + with urllib.request.urlopen(req, timeout=3, context=context) as response: + # 读取图片数据 + image_data = response.read() + + # 检查是否成功 + if response.status == 200 and len(image_data) > 0: + elapsed = time.time() - start_time + print(f"IPv6测试成功! 用时: {elapsed:.2f}秒") + return True + else: + print(f"IPv6测试失败: 状态码 {response.status}") + return False + except Exception as e: + print(f"IPv6测试失败: {e}") + return False + + def _get_ipv6_test_request(self): + """获取IPv6测试请求 + + Returns: + tuple: (测试URL, 请求对象, SSL上下文) + """ + import urllib.request + import ssl + + # IPv6测试URL - 这是一个只能通过IPv6访问的资源 + ipv6_test_url = "https://ipv6.testipv6.cn/images-nc/knob_green.png?&testdomain=www.test-ipv6.com&testname=sites" + + # 创建SSL上下文 + context = ssl._create_unverified_context() + + # 创建请求并添加常见的HTTP头 + req = urllib.request.Request(ipv6_test_url) + req.add_header('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)') + req.add_header('Accept', 'image/webp,image/apng,image/*,*/*;q=0.8') + + return ipv6_test_url, req, context + + def get_ipv6_address(self): + """获取公网IPv6地址 + + Returns: + str: IPv6地址,如果失败则返回None + """ + try: + # 使用curl命令获取IPv6地址 + process = subprocess.Popen( + ["curl", "-6", "6.ipw.cn"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding='utf-8', + errors='replace', + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0 + ) + + # 设置超时 + timeout = 5 # 5秒超时 + start_time = time.time() + while process.poll() is None and (time.time() - start_time) < timeout: + time.sleep(0.1) + + # 如果进程仍在运行,则强制终止 + if process.poll() is None: + process.terminate() + print("获取IPv6地址超时") + return None + + stdout, stderr = process.communicate() + + if process.returncode == 0 and stdout.strip(): + ipv6_address = stdout.strip() + print(f"获取到IPv6地址: {ipv6_address}") + return ipv6_address + else: + print("未能获取到IPv6地址") + if stderr: + print(f"错误信息: {stderr}") + return None + + except Exception as e: + print(f"获取IPv6地址失败: {e}") + return None + + def show_ipv6_details(self): + """显示IPv6连接详情""" + class SignalEmitter(QObject): + update_signal = Signal(str) + complete_signal = Signal(bool, float) + + # 创建对话框 + dialog = QDialog(self.main_window) + dialog.setWindowTitle(f"IPv6连接测试 - {APP_NAME}") + dialog.resize(500, 300) + + # 创建布局 + layout = QVBoxLayout(dialog) + + # 创建状态标签 + status_label = QLabel("正在测试IPv6连接...", dialog) + layout.addWidget(status_label) + + # 创建进度条 + progress = QProgressBar(dialog) + progress.setRange(0, 0) # 不确定进度 + layout.addWidget(progress) + + # 创建结果文本框 + result_text = QTextEdit(dialog) + result_text.setReadOnly(True) + layout.addWidget(result_text) + + # 创建关闭按钮 + close_button = QPushButton("关闭", dialog) + close_button.clicked.connect(dialog.accept) + close_button.setEnabled(False) # 测试完成前禁用 + layout.addWidget(close_button) + + # 信号发射器 + signal_emitter = SignalEmitter() + + # 连接信号 + signal_emitter.update_signal.connect( + lambda text: result_text.append(text) + ) + + def on_test_complete(success, elapsed_time): + # 停止进度条动画 + progress.setRange(0, 100) + progress.setValue(100 if success else 0) + + # 更新状态 + if success: + status_label.setText(f"IPv6连接测试完成: 可用 (用时: {elapsed_time:.2f}秒)") + else: + status_label.setText("IPv6连接测试完成: 不可用") + + # 启用关闭按钮 + close_button.setEnabled(True) + + signal_emitter.complete_signal.connect(on_test_complete) + + # 测试函数 + def test_ipv6(): + try: + signal_emitter.update_signal.emit("正在测试IPv6连接,请稍候...") + + # 先进行标准的IPv6连接测试 + signal_emitter.update_signal.emit("正在进行标准IPv6连接测试...") + + # 使用IPv6测试URL + ipv6_test_url, req, context = self._get_ipv6_test_request() + ipv6_connected = False + ipv6_test_elapsed_time = 0 + + try: + # 设置5秒超时 + start_time = time.time() + signal_emitter.update_signal.emit(f"开始连接: {ipv6_test_url}") + + # 尝试下载图片 + with urllib.request.urlopen(req, timeout=5, context=context) as response: + image_data = response.read() + + # 计算耗时 + elapsed_time = time.time() - start_time + ipv6_test_elapsed_time = elapsed_time + + # 检查是否成功 + if response.status == 200 and len(image_data) > 0: + ipv6_connected = True + signal_emitter.update_signal.emit(f"✓ 成功! 已下载 {len(image_data)} 字节") + signal_emitter.update_signal.emit(f"✓ 响应时间: {elapsed_time:.2f}秒") + else: + signal_emitter.update_signal.emit(f"✗ 失败: 状态码 {response.status}") + signal_emitter.update_signal.emit("\n结论: 您的网络不支持IPv6连接 ✗") + signal_emitter.complete_signal.emit(False, 0) + return + + except Exception as e: + signal_emitter.update_signal.emit(f"✗ 连接失败: {e}") + signal_emitter.update_signal.emit("\n结论: 您的网络不支持IPv6连接 ✗") + signal_emitter.complete_signal.emit(False, 0) + return + + # 如果IPv6连接测试成功,再尝试获取公网IPv6地址 + if ipv6_connected: + signal_emitter.update_signal.emit("\n正在获取您的公网IPv6地址...") + + try: + # 使用curl命令获取IPv6地址 + process = subprocess.Popen( + ["curl", "-6", "6.ipw.cn"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding='utf-8', + errors='replace', + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0 + ) + + # 设置超时 + timeout = 5 # 5秒超时 + start_time = time.time() + while process.poll() is None and (time.time() - start_time) < timeout: + time.sleep(0.1) + + # 如果进程仍在运行,则强制终止 + if process.poll() is None: + process.terminate() + signal_emitter.update_signal.emit("✗ 获取IPv6地址超时") + else: + stdout, stderr = process.communicate() + + if process.returncode == 0 and stdout.strip(): + ipv6_address = stdout.strip() + signal_emitter.update_signal.emit(f"✓ 获取到的IPv6地址: {ipv6_address}") + else: + signal_emitter.update_signal.emit("✗ 未能获取到IPv6地址") + if stderr: + signal_emitter.update_signal.emit(f"错误信息: {stderr}") + + except Exception as e: + signal_emitter.update_signal.emit(f"✗ 获取IPv6地址失败: {e}") + + # 输出最终结论 + signal_emitter.update_signal.emit("\n结论: 您的网络支持IPv6连接 ✓") + signal_emitter.complete_signal.emit(True, ipv6_test_elapsed_time) + return + + except Exception as e: + signal_emitter.update_signal.emit(f"测试过程中出错: {e}") + signal_emitter.complete_signal.emit(False, 0) + + # 启动测试线程 + threading.Thread(target=test_ipv6, daemon=True).start() + + # 显示对话框 + dialog.exec() + + def toggle_ipv6_support(self, enabled): + """切换IPv6支持 + + Args: + enabled: 是否启用IPv6支持 + """ + print(f"Toggle IPv6 support: {enabled}") + + # 保存设置到配置 + if self.config is not None: + self.config["ipv6_enabled"] = enabled + # 直接使用utils.save_config保存配置 + from utils import save_config + save_config(self.config) + + # 显示设置已保存的消息 + status = "启用" if enabled else "禁用" + msg_box = self._create_message_box("IPv6设置", f"\nIPv6支持已{status}。新的设置将在下一次下载时生效。\n") + msg_box.exec() + return True + + def _create_message_box(self, title, message, buttons=QMessageBox.StandardButton.Ok): + """创建统一风格的消息框 + + Args: + title: 消息框标题 + message: 消息内容 + buttons: 按钮类型,默认为确定按钮 + + Returns: + QMessageBox: 配置好的消息框实例 + """ + msg_box = msgbox_frame( + f"{title} - {APP_NAME}", + message, + buttons, + ) + return msg_box \ No newline at end of file diff --git a/source/core/managers/offline_mode_manager.py b/source/core/managers/offline_mode_manager.py new file mode 100644 index 0000000..a2ff652 --- /dev/null +++ b/source/core/managers/offline_mode_manager.py @@ -0,0 +1,992 @@ +import os +import hashlib +import shutil +import tempfile +import py7zr +import traceback +from PySide6 import QtWidgets, QtCore +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QMessageBox + +from data.config import PLUGIN, PLUGIN_HASH, GAME_INFO +from utils import msgbox_frame +from utils.logger import setup_logger + +# 初始化logger +logger = setup_logger("offline_mode_manager") + +class OfflineModeManager: + """离线模式管理器,用于管理离线模式下的补丁安装和检测""" + + def __init__(self, main_window): + """初始化离线模式管理器 + + Args: + main_window: 主窗口实例,用于访问UI和状态 + """ + self.main_window = main_window + self.app_name = main_window.APP_NAME if hasattr(main_window, 'APP_NAME') else "" + self.offline_patches = {} # 存储离线补丁信息 {补丁名称: 文件路径} + self.is_offline_mode = False + self.installed_games = [] # 跟踪本次实际安装的游戏 + # 保持对哈希线程的引用,避免运行中被销毁 + self.hash_thread = None + # 解压线程与进度窗口引用,避免运行中被销毁,且确保UI可更新 + self.extraction_thread = None + self.extraction_progress_window = None + + def _is_debug_mode(self): + """检查是否处于调试模式 + + Returns: + bool: 是否处于调试模式 + """ + try: + if hasattr(self.main_window, 'debug_manager') and self.main_window.debug_manager: + if hasattr(self.main_window.debug_manager, '_is_debug_mode'): + # 尝试直接从debug_manager获取状态 + return self.main_window.debug_manager._is_debug_mode() + elif hasattr(self.main_window, 'config'): + # 如果debug_manager还没准备好,尝试从配置中获取 + return self.main_window.config.get('debug_mode', False) + # 如果以上都不可行,返回False + return False + except Exception: + # 捕获任何异常,默认返回False + return False + + def scan_for_offline_patches(self, directory=None): + """扫描指定目录(默认为软件所在目录)查找离线补丁文件 + + Args: + directory: 要扫描的目录,如果为None则使用软件所在目录 + + Returns: + dict: 找到的补丁文件 {补丁名称: 文件路径} + """ + if directory is None: + # 获取软件所在目录 + directory = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + debug_mode = self._is_debug_mode() + + # 无论是否为调试模式,都记录扫描操作 + logger.info(f"扫描离线补丁文件,目录: {directory}") + + # 要查找的补丁文件名 + patch_files = ["vol.1.7z", "vol.2.7z", "vol.3.7z", "vol.4.7z", "after.7z"] + + found_patches = {} + + # 扫描目录中的文件 + for file in os.listdir(directory): + if file.lower() in patch_files: + file_path = os.path.join(directory, file) + if os.path.isfile(file_path): + patch_name = file.lower() + found_patches[patch_name] = file_path + # 无论是否为调试模式,都记录找到的补丁文件 + logger.info(f"找到离线补丁文件: {patch_name} 路径: {file_path}") + if debug_mode: + logger.debug(f"DEBUG: 找到离线补丁文件: {patch_name} 路径: {file_path}") + + self.offline_patches = found_patches + + # 记录扫描结果 + if found_patches: + logger.info(f"共找到 {len(found_patches)} 个离线补丁文件: {list(found_patches.keys())}") + else: + logger.info("未找到任何离线补丁文件") + + return found_patches + + def has_offline_patches(self): + """检查是否有可用的离线补丁文件 + + Returns: + bool: 是否有可用的离线补丁 + """ + if not self.offline_patches: + self.scan_for_offline_patches() + + return len(self.offline_patches) > 0 + + def set_offline_mode(self, enabled): + """设置离线模式状态 + + Args: + enabled: 是否启用离线模式 + + Returns: + bool: 是否成功设置离线模式 + """ + debug_mode = self._is_debug_mode() + + if enabled: + # 检查是否有离线补丁文件 + if not self.has_offline_patches() and not debug_mode: + msgbox_frame( + f"离线模式错误 - {self.app_name}", + "\n未找到任何离线补丁文件,无法启用离线模式。\n\n请将补丁文件放置在软件所在目录后再尝试。\n", + QMessageBox.StandardButton.Ok + ).exec() + logger.warning("尝试启用离线模式失败:未找到任何离线补丁文件") + return False + + if debug_mode: + logger.debug("DEBUG: 已启用离线模式(调试模式下允许强制启用)") + + self.is_offline_mode = enabled + + # 更新窗口标题 + if hasattr(self.main_window, 'setWindowTitle'): + from data.config import APP_NAME, APP_VERSION + mode_indicator = "[离线模式]" if enabled else "[在线模式]" + self.main_window.setWindowTitle(f"{APP_NAME} v{APP_VERSION} {mode_indicator}") + + # 同时更新UI中的标题标签 + if hasattr(self.main_window, 'ui') and hasattr(self.main_window.ui, 'title_label'): + self.main_window.ui.title_label.setText(f"{APP_NAME} v{APP_VERSION} {mode_indicator}") + + # 同步更新UI菜单中的模式选择状态 + if hasattr(self.main_window, 'ui_manager'): + ui_manager = self.main_window.ui_manager + if hasattr(ui_manager, 'online_mode_action') and hasattr(ui_manager, 'offline_mode_action'): + ui_manager.online_mode_action.setChecked(not enabled) + ui_manager.offline_mode_action.setChecked(enabled) + + # 无论是否为调试模式,都记录离线模式状态变化 + logger.info(f"离线模式已{'启用' if enabled else '禁用'}") + if debug_mode: + logger.debug(f"DEBUG: 离线模式已{'启用' if enabled else '禁用'}") + + return True + + def get_offline_patch_path(self, game_version): + """根据游戏版本获取对应的离线补丁文件路径 + + Args: + game_version: 游戏版本名称,如"NEKOPARA Vol.1" + + Returns: + str: 离线补丁文件路径,如果没有找到则返回None + """ + # 确保已扫描过补丁文件 + if not self.offline_patches: + self.scan_for_offline_patches() + + # 根据游戏版本获取对应的补丁文件名 + patch_file = None + + if "Vol.1" in game_version: + patch_file = "vol.1.7z" + elif "Vol.2" in game_version: + patch_file = "vol.2.7z" + elif "Vol.3" in game_version: + patch_file = "vol.3.7z" + elif "Vol.4" in game_version: + patch_file = "vol.4.7z" + elif "After" in game_version: + patch_file = "after.7z" + + # 检查是否有对应的补丁文件 + if patch_file and patch_file in self.offline_patches: + return self.offline_patches[patch_file] + + return None + + def prepare_offline_patch(self, game_version, target_path): + """准备离线补丁文件,复制到缓存目录 + + Args: + game_version: 游戏版本名称 + target_path: 目标路径(通常是缓存目录中的路径) + + Returns: + bool: 是否成功准备补丁文件 + """ + source_path = self.get_offline_patch_path(game_version) + + if not source_path: + return False + + debug_mode = self._is_debug_mode() + + try: + # 确保目标目录存在 + os.makedirs(os.path.dirname(target_path), exist_ok=True) + + # 复制文件 + shutil.copy2(source_path, target_path) + + if debug_mode: + logger.debug(f"DEBUG: 已复制离线补丁文件 {source_path} 到 {target_path}") + + return True + except Exception as e: + if debug_mode: + logger.error(f"DEBUG: 复制离线补丁文件失败: {e}") + return False + + def verify_patch_hash(self, game_version, file_path): + """验证补丁文件的哈希值,使用patch_detector模块 + + Args: + game_version: 游戏版本名称 + file_path: 补丁压缩包文件路径 + + Returns: + bool: 哈希值是否匹配 + """ + debug_mode = self._is_debug_mode() + + if debug_mode: + logger.debug(f"DEBUG: 开始验证补丁文件哈希: {file_path}") + + # 创建进度对话框 + from utils.helpers import ProgressHashVerifyDialog + from data.config import PLUGIN_HASH + from workers.hash_thread import OfflineHashVerifyThread + + # 创建并显示进度对话框 + progress_dialog = ProgressHashVerifyDialog( + f"验证补丁文件 - {self.app_name}", + f"正在验证 {game_version} 的补丁文件完整性...", + self.main_window + ) + + # 创建哈希验证线程 + hash_thread = OfflineHashVerifyThread(game_version, file_path, PLUGIN_HASH, self.main_window) + + # 连接信号 + hash_thread.progress.connect(progress_dialog.update_progress) + hash_thread.finished.connect(lambda result, error, extracted_path: self._on_hash_verify_finished(result, error, extracted_path, progress_dialog)) + + # 启动线程 + hash_thread.start() + + # 显示对话框,阻塞直到对话框关闭 + result = progress_dialog.exec() + + # 如果用户取消了验证,停止线程 + if result == ProgressHashVerifyDialog.Rejected and hash_thread.isRunning(): + if debug_mode: + logger.debug(f"DEBUG: 用户取消了哈希验证") + hash_thread.terminate() + hash_thread.wait() + return False + + # 返回对话框中存储的验证结果 + return hasattr(progress_dialog, 'hash_result') and progress_dialog.hash_result + + def _on_hash_verify_finished(self, result, error, extracted_path, dialog): + """哈希验证线程完成后的回调 + + Args: + result: 验证结果 + error: 错误信息 + extracted_path: 解压后的补丁文件路径,如果哈希验证成功则包含此路径 + dialog: 进度对话框 + """ + debug_mode = self._is_debug_mode() + + # 存储结果到对话框,以便在exec()返回后获取 + dialog.hash_result = result + + if result: + if debug_mode: + logger.debug(f"DEBUG: 哈希验证成功") + if extracted_path: + logger.debug(f"DEBUG: 解压后的补丁文件路径: {extracted_path}") + dialog.set_status("验证成功") + # 短暂延时后关闭对话框 + QTimer.singleShot(500, dialog.accept) + else: + if debug_mode: + logger.debug(f"DEBUG: 哈希验证失败: {error}") + dialog.set_status(f"验证失败: {error}") + dialog.set_message("补丁文件验证失败,可能已损坏或被篡改。") + # 将取消按钮改为关闭按钮 + dialog.cancel_button.setText("关闭") + # 不自动关闭,让用户查看错误信息 + + def _on_offline_install_hash_finished(self, result, error, extracted_path, dialog, game_version, _7z_path, game_folder, plugin_path, install_tasks): + """离线安装哈希验证线程完成后的回调 + + Args: + result: 验证结果 + error: 错误信息 + extracted_path: 解压后的补丁文件路径 + dialog: 进度对话框 + game_version: 游戏版本 + _7z_path: 7z文件路径 + game_folder: 游戏文件夹路径 + plugin_path: 插件路径 + install_tasks: 剩余的安装任务列表 + """ + debug_mode = self._is_debug_mode() + + # 导入所需模块 + from data.config import GAME_INFO, PLUGIN + + # 存储结果到对话框,以便在exec()返回后获取 + dialog.hash_result = result + + # 关闭哈希验证窗口 + self.main_window.close_hash_msg_box() + + if not result: + # 哈希验证失败 + if debug_mode: + logger.warning(f"DEBUG: 补丁文件哈希验证失败: {error}") + + # 显示错误消息 + msgbox_frame( + f"哈希验证失败 - {self.app_name}", + f"\n{game_version} 的补丁文件哈希验证失败,可能已损坏或被篡改。\n\n跳过此游戏的安装。\n", + QMessageBox.StandardButton.Ok + ).exec() + + # 继续下一个任务 + self.process_next_offline_install_task(install_tasks) + return + + # 哈希验证成功,直接进行安装 + if debug_mode: + logger.debug(f"DEBUG: 哈希验证成功,开始安装") + + # 显示安装进度窗口 + self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="offline_installation", is_offline=True) + + try: + # 确保游戏目录存在 + os.makedirs(game_folder, exist_ok=True) + + # 根据游戏版本确定目标文件名 + target_filename = None + if "Vol.1" in game_version: + target_filename = "adultsonly.xp3" + elif "Vol.2" in game_version: + target_filename = "adultsonly.xp3" + elif "Vol.3" in game_version: + target_filename = "update00.int" + elif "Vol.4" in game_version: + target_filename = "vol4adult.xp3" + elif "After" in game_version: + target_filename = "afteradult.xp3" + + if not target_filename: + raise ValueError(f"未知的游戏版本: {game_version}") + + # 直接解压文件到游戏目录 + import py7zr + + if debug_mode: + logger.debug(f"DEBUG: 直接解压文件 {_7z_path} 到游戏目录 {game_folder}") + + # 解压文件 + with py7zr.SevenZipFile(_7z_path, mode="r") as archive: + # 获取压缩包内的文件列表 + file_list = archive.getnames() + if debug_mode: + logger.debug(f"DEBUG: 压缩包内文件列表: {file_list}") + + # 解析压缩包内的文件结构 + target_file_in_archive = None + for file_path in file_list: + if target_filename in file_path: + target_file_in_archive = file_path + break + + if not target_file_in_archive: + if debug_mode: + logger.warning(f"DEBUG: 在压缩包中未找到目标文件 {target_filename}") + raise FileNotFoundError(f"在压缩包中未找到目标文件 {target_filename}") + + # 准备解压特定文件到游戏目录 + target_path = os.path.join(game_folder, target_filename) + + # 创建一个临时目录用于解压单个文件 + with tempfile.TemporaryDirectory() as temp_dir: + # 解压特定文件到临时目录 + archive.extract(path=temp_dir, targets=[target_file_in_archive]) + + # 找到解压后的文件 + extracted_file_path = os.path.join(temp_dir, target_file_in_archive) + + # 复制到目标位置 + shutil.copy2(extracted_file_path, target_path) + + if debug_mode: + logger.debug(f"DEBUG: 已解压并复制文件到 {target_path}") + + # 对于NEKOPARA After,还需要复制签名文件 + if game_version == "NEKOPARA After": + sig_filename = f"{target_filename}.sig" + sig_file_in_archive = None + + # 查找签名文件 + for file_path in file_list: + if sig_filename in file_path: + sig_file_in_archive = file_path + break + + if sig_file_in_archive: + # 解压签名文件 + archive.extract(path=temp_dir, targets=[sig_file_in_archive]) + extracted_sig_path = os.path.join(temp_dir, sig_file_in_archive) + sig_target = os.path.join(game_folder, sig_filename) + shutil.copy2(extracted_sig_path, sig_target) + + if debug_mode: + logger.debug(f"DEBUG: 已解压并复制签名文件到 {sig_target}") + else: + if debug_mode: + logger.warning(f"DEBUG: 未找到签名文件 {sig_filename}") + + # 进行安装后的哈希校验 + self._perform_hash_check(game_version, install_tasks) + + except Exception as e: + if debug_mode: + logger.error(f"DEBUG: 安装补丁文件失败: {e}") + import traceback + logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}") + + # 关闭安装进度窗口 + self.main_window.close_hash_msg_box() + + # 显示错误消息 + msgbox_frame( + f"安装错误 - {self.app_name}", + f"\n{game_version} 的安装过程中发生错误: {str(e)}\n\n跳过此游戏的安装。\n", + QMessageBox.StandardButton.Ok + ).exec() + + # 继续下一个任务 + self.process_next_offline_install_task(install_tasks) + + def _perform_hash_check(self, game_version, install_tasks): + """安装完成后进行哈希校验 + + Args: + game_version: 游戏版本 + install_tasks: 剩余的安装任务列表 + """ + debug_mode = self._is_debug_mode() + + # 导入所需模块 + from data.config import GAME_INFO, PLUGIN_HASH + from workers.hash_thread import HashThread + + # 获取安装路径 + install_paths = {} + game_dirs = self.main_window.game_detector.identify_game_directories_improved( + self.main_window.download_manager.selected_folder + ) + + for game, info in GAME_INFO.items(): + if game in game_dirs and game == game_version: + game_dir = game_dirs[game] + install_path = os.path.join(game_dir, os.path.basename(info["install_path"])) + install_paths[game] = install_path + break + + if not install_paths: + # 如果找不到安装路径,直接认为安装成功 + logger.warning(f"未找到 {game_version} 的安装路径,跳过哈希校验") + self.main_window.installed_status[game_version] = True + + # 添加到已安装游戏列表 + if game_version not in self.installed_games: + self.installed_games.append(game_version) + + # 关闭安装进度窗口 + self.main_window.close_hash_msg_box() + + # 继续下一个任务 + self.process_next_offline_install_task(install_tasks) + return + + # 关闭可能存在的哈希校验窗口,然后创建新窗口 + self.main_window.close_hash_msg_box() + + # 显示哈希校验窗口 + self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="post", is_offline=True) + + # 直接创建并启动哈希线程进行校验,而不是通过主窗口 + hash_thread = HashThread( + "after", + install_paths, + PLUGIN_HASH, + self.main_window.installed_status, + self.main_window + ) + hash_thread.after_finished.connect( + lambda result: self._on_hash_check_finished(result, game_version, install_tasks) + ) + + # 保存引用以便后续使用 + self.hash_thread = hash_thread + try: + self.hash_thread.finished.connect(lambda: setattr(self, 'hash_thread', None)) + except Exception: + pass + hash_thread.start() + + def _on_hash_check_finished(self, result, game_version, install_tasks): + """哈希校验完成后的处理 + + Args: + result: 校验结果,包含通过状态、游戏版本和消息 + game_version: 游戏版本 + install_tasks: 剩余的安装任务列表 + """ + debug_mode = self._is_debug_mode() + + # 关闭哈希检查窗口 + self.main_window.close_hash_msg_box() + + if not result["passed"]: + # 校验失败,删除已解压的文件并提示重新安装 + error_message = result["message"] + + # 获取安装路径 + install_path = None + game_dirs = self.main_window.game_detector.identify_game_directories_improved( + self.main_window.download_manager.selected_folder + ) + + from data.config import GAME_INFO + if game_version in game_dirs and game_version in GAME_INFO: + game_dir = game_dirs[game_version] + install_path = os.path.join(game_dir, os.path.basename(GAME_INFO[game_version]["install_path"])) + + # 如果找到安装路径,尝试删除已解压的文件 + if install_path and os.path.exists(install_path): + try: + os.remove(install_path) + logger.info(f"已删除校验失败的文件: {install_path}") + except Exception as e: + logger.error(f"删除文件失败: {e}") + + # 显示错误消息 + msgbox_frame( + f"校验失败 - {self.app_name}", + f"{error_message}\n\n跳过此游戏的安装。", + QMessageBox.StandardButton.Ok + ).exec() + + # 更新安装状态 + self.main_window.installed_status[game_version] = False + else: + # 校验通过,更新安装状态 + self.main_window.installed_status[game_version] = True + + # 添加到已安装游戏列表 + if game_version not in self.installed_games: + self.installed_games.append(game_version) + + # 显示安装成功消息 + if debug_mode: + logger.debug(f"DEBUG: {game_version} 安装成功并通过哈希校验") + + # 继续处理下一个任务 + self.process_next_offline_install_task(install_tasks) + + def _on_extraction_finished_with_hash_check(self, success, error_message, game_version, install_tasks): + """解压完成后进行哈希校验(后台线程回调)""" + # 关闭解压进度窗口 + try: + if self.extraction_progress_window and self.extraction_progress_window.isVisible(): + self.extraction_progress_window.close() + except Exception: + pass + self.extraction_progress_window = None + + # 清理线程引用 + self.extraction_thread = None + + if not success: + # 解压失败,提示并继续下一个任务 + msgbox_frame( + f"安装错误 - {self.app_name}", + error_message or f"\n{game_version} 的安装过程中发生错误。\n", + QMessageBox.StandardButton.Ok + ).exec() + self.process_next_offline_install_task(install_tasks) + return + + # 解压成功,进入安装后哈希校验 + self._perform_hash_check(game_version, install_tasks) + + def on_extraction_thread_finished(self, success, error_message, game_version, install_tasks): + """解压线程完成后的处理(兼容旧版本) + + Args: + success: 是否解压成功 + error_message: 错误信息 + game_version: 游戏版本 + install_tasks: 剩余的安装任务列表 + """ + # 这个方法已不再使用,但为了兼容性,我们直接处理下一个任务 + if success: + # 更新安装状态 + self.main_window.installed_status[game_version] = True + + # 添加到已安装游戏列表 + if game_version not in self.installed_games: + self.installed_games.append(game_version) + else: + # 更新安装状态 + self.main_window.installed_status[game_version] = False + + # 显示错误消息 + debug_mode = self._is_debug_mode() + if debug_mode: + logger.error(f"DEBUG: 解压失败: {error_message}") + + # 继续下一个任务 + self.process_next_offline_install_task(install_tasks) + + def install_offline_patches(self, selected_games): + """直接安装离线补丁,完全绕过下载模块 + + Args: + selected_games: 用户选择安装的游戏列表 + + Returns: + bool: 是否成功启动安装流程 + """ + debug_mode = self._is_debug_mode() + + if debug_mode: + logger.debug(f"DEBUG: 开始离线安装流程,选择的游戏: {selected_games}") + + if not self.is_in_offline_mode(): + if debug_mode: + logger.warning("DEBUG: 当前不是离线模式,无法使用离线安装") + return False + + # 确保已扫描过补丁文件 + if not self.offline_patches: + self.scan_for_offline_patches() + + if not self.offline_patches: + if debug_mode: + logger.warning("DEBUG: 未找到任何离线补丁文件") + msgbox_frame( + f"离线安装错误 - {self.app_name}", + "\n未找到任何离线补丁文件,无法进行离线安装。\n\n请将补丁文件放置在软件所在目录后再尝试。\n", + QMessageBox.StandardButton.Ok + ).exec() + return False + + # 获取游戏目录 + game_dirs = self.main_window.game_detector.identify_game_directories_improved( + self.main_window.download_manager.selected_folder + ) + + if not game_dirs: + if debug_mode: + logger.warning("DEBUG: 未识别到任何游戏目录") + return False + + self.main_window.setEnabled(False) + + # 重置已安装游戏列表 + self.installed_games = [] + + # 设置到主窗口,供结果显示使用 + self.main_window.download_queue_history = selected_games + + # 记录未找到离线补丁文件的游戏 + self.missing_offline_patches = [] + + # 创建安装任务列表 + install_tasks = [] + for game_version in selected_games: + # 获取离线补丁文件路径 + patch_file = self.get_offline_patch_path(game_version) + if not patch_file: + if debug_mode: + logger.warning(f"DEBUG: 未找到 {game_version} 的离线补丁文件,跳过") + # 记录未找到离线补丁文件的游戏 + self.missing_offline_patches.append(game_version) + continue + + # 获取游戏目录 + game_folder = game_dirs.get(game_version) + if not game_folder: + if debug_mode: + logger.warning(f"DEBUG: 未找到 {game_version} 的游戏目录,跳过") + continue + + # 获取目标路径 + if "Vol.1" in game_version: + _7z_path = os.path.join(PLUGIN, "vol.1.7z") + plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"]) + elif "Vol.2" in game_version: + _7z_path = os.path.join(PLUGIN, "vol.2.7z") + plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"]) + elif "Vol.3" in game_version: + _7z_path = os.path.join(PLUGIN, "vol.3.7z") + plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"]) + elif "Vol.4" in game_version: + _7z_path = os.path.join(PLUGIN, "vol.4.7z") + plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"]) + elif "After" in game_version: + _7z_path = os.path.join(PLUGIN, "after.7z") + plugin_path = os.path.join(PLUGIN, GAME_INFO[game_version]["plugin_path"]) + else: + if debug_mode: + logger.warning(f"DEBUG: {game_version} 不是支持的游戏版本,跳过") + continue + + # 添加到安装任务列表 + install_tasks.append((patch_file, game_folder, game_version, _7z_path, plugin_path)) + + # 开始执行第一个安装任务 + if install_tasks: + if debug_mode: + logger.info(f"DEBUG: 开始离线安装流程,安装游戏数量: {len(install_tasks)}") + self.process_next_offline_install_task(install_tasks) + else: + if debug_mode: + logger.warning("DEBUG: 没有可安装的游戏,安装流程结束") + + # 检查是否有未找到离线补丁文件的游戏 + if self.missing_offline_patches: + if debug_mode: + logger.debug(f"DEBUG: 有未找到离线补丁文件的游戏: {self.missing_offline_patches}") + + # 询问用户是否切换到在线模式 + msg_box = msgbox_frame( + f"离线安装信息 - {self.app_name}", + f"\n本地未发现对应离线文件,是否切换为在线模式安装?\n\n以下游戏未找到对应的离线补丁文件:\n\n{chr(10).join(self.missing_offline_patches)}\n", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + result = msg_box.exec() + + if result == QMessageBox.StandardButton.Yes: + if debug_mode: + logger.debug("DEBUG: 用户选择切换到在线模式") + + # 切换到在线模式 + if hasattr(self.main_window, 'ui_manager'): + self.main_window.ui_manager.switch_work_mode("online") + + # 直接启动下载流程 + self.main_window.setEnabled(True) + # 保存当前选择的游戏列表,以便在线模式使用 + missing_games = self.missing_offline_patches.copy() + # 启动下载流程 + QTimer.singleShot(500, lambda: self._start_online_download(missing_games)) + else: + if debug_mode: + logger.debug("DEBUG: 用户选择不切换到在线模式") + + # 恢复UI状态 + self.main_window.setEnabled(True) + self.main_window.ui.start_install_text.setText("开始安装") + else: + # 没有缺少离线补丁的游戏,显示一般消息 + msgbox_frame( + f"离线安装信息 - {self.app_name}", + "\n没有可安装的游戏或未找到对应的离线补丁文件。\n", + QMessageBox.StandardButton.Ok + ).exec() + self.main_window.setEnabled(True) + self.main_window.ui.start_install_text.setText("开始安装") + + return True + + def _start_online_download(self, games_to_download): + """启动在线下载流程 + + Args: + games_to_download: 要下载的游戏列表 + """ + debug_mode = self._is_debug_mode() + if debug_mode: + logger.debug(f"DEBUG: 启动在线下载流程,游戏列表: {games_to_download}") + + # 确保下载管理器已初始化 + if hasattr(self.main_window, 'download_manager'): + # 使用直接下载方法,绕过补丁判断 + self.main_window.download_manager.direct_download_action(games_to_download) + else: + if debug_mode: + logger.error("DEBUG: 下载管理器未初始化,无法启动下载流程") + # 显示错误消息 + msgbox_frame( + f"错误 - {self.app_name}", + "\n下载管理器未初始化,无法启动下载流程。\n", + QMessageBox.StandardButton.Ok + ).exec() + + def process_next_offline_install_task(self, install_tasks): + """处理下一个离线安装任务 + + Args: + install_tasks: 安装任务列表,每个任务是一个元组 (patch_file, game_folder, game_version, _7z_path, plugin_path) + """ + debug_mode = self._is_debug_mode() + + if not install_tasks: + # 所有任务完成,进行后检查 + if debug_mode: + logger.info("DEBUG: 所有离线安装任务完成,进行后检查") + + # 使用patch_detector进行安装后哈希比较 + self.main_window.patch_detector.after_hash_compare() + + # 检查是否有未找到离线补丁文件的游戏 + if hasattr(self, 'missing_offline_patches') and self.missing_offline_patches: + if debug_mode: + logger.debug(f"DEBUG: 有未找到离线补丁文件的游戏: {self.missing_offline_patches}") + + # 先显示已安装的结果 + if self.installed_games: + installed_msg = f"已成功安装以下补丁:\n\n{chr(10).join(self.installed_games)}\n\n" + else: + installed_msg = "" + + # 使用QTimer延迟显示询问对话框,确保安装结果窗口先显示并关闭 + QTimer.singleShot(500, lambda: self._show_missing_patches_dialog(installed_msg)) + else: + # 恢复UI状态 + self.main_window.setEnabled(True) + self.main_window.ui.start_install_text.setText("开始安装") + + return + + # 获取下一个任务 + patch_file, game_folder, game_version, _7z_path, plugin_path = install_tasks.pop(0) + + if debug_mode: + logger.debug(f"DEBUG: 处理离线安装任务: {game_version}") + logger.debug(f"DEBUG: 补丁文件: {patch_file}") + logger.debug(f"DEBUG: 游戏目录: {game_folder}") + + # 使用后台线程进行解压,避免阻塞UI + try: + # 确保游戏目录存在 + os.makedirs(game_folder, exist_ok=True) + + # 创建非阻塞的解压进度窗口 + self.extraction_progress_window = self.main_window.create_extraction_progress_window() + try: + self.extraction_progress_window.show() + QtWidgets.QApplication.processEvents() + except Exception: + pass + + # 启动解压线程 + self.extraction_thread = self.main_window.create_extraction_thread( + patch_file, game_folder, plugin_path, game_version + ) + + # 连接进度更新到窗口控件 + if self.extraction_thread and self.extraction_progress_window: + self.extraction_thread.progress.connect( + lambda percent, status: ( + self.extraction_progress_window.progress_bar.setValue(percent), + self.extraction_progress_window.status_label.setText(status) + ) + ) + + # 完成后进入哈希校验 + self.extraction_thread.finished.connect( + lambda success, error, gv: self._on_extraction_finished_with_hash_check(success, error, gv, install_tasks) + ) + + # 线程结束时清理引用 + try: + self.extraction_thread.finished.connect(lambda *_: setattr(self, 'extraction_thread', None)) + except Exception: + pass + + self.extraction_thread.start() + + except Exception as e: + if debug_mode: + logger.error(f"DEBUG: 离线安装任务处理失败: {e}") + logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}") + + # 关闭可能存在的解压进度窗口 + try: + if self.extraction_progress_window and self.extraction_progress_window.isVisible(): + self.extraction_progress_window.close() + except Exception: + pass + self.extraction_progress_window = None + + # 显示错误消息 + msgbox_frame( + f"安装错误 - {self.app_name}", + f"\n{game_version} 的安装过程中发生错误: {str(e)}\n\n跳过此游戏的安装。\n", + QMessageBox.StandardButton.Ok + ).exec() + + # 继续下一个任务 + self.process_next_offline_install_task(install_tasks) + + def is_offline_mode_available(self): + """检查是否可以使用离线模式 + + Returns: + bool: 是否可以使用离线模式 + """ + # 在调试模式下始终允许离线模式 + if self._is_debug_mode(): + return True + + # 检查是否有离线补丁文件 + return self.has_offline_patches() + + def is_in_offline_mode(self): + """检查当前是否处于离线模式 + + Returns: + bool: 是否处于离线模式 + """ + return self.is_offline_mode + + def _show_missing_patches_dialog(self, installed_msg): + """显示缺少离线补丁文件的对话框 + + Args: + installed_msg: 已安装的补丁信息 + """ + debug_mode = self._is_debug_mode() + + # 在安装完成后询问用户是否切换到在线模式 + msg_box = msgbox_frame( + f"离线安装完成 - {self.app_name}", + f"\n{installed_msg}以下游戏未找到对应的离线补丁文件:\n\n{chr(10).join(self.missing_offline_patches)}\n\n是否切换到在线模式继续安装?\n", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + result = msg_box.exec() + + if result == QMessageBox.StandardButton.Yes: + if debug_mode: + logger.debug("DEBUG: 用户选择切换到在线模式") + + # 切换到在线模式 + if hasattr(self.main_window, 'ui_manager'): + self.main_window.ui_manager.switch_work_mode("online") + + # 直接启动下载流程 + self.main_window.setEnabled(True) + # 保存当前选择的游戏列表,以便在线模式使用 + missing_games = self.missing_offline_patches.copy() + # 启动下载流程 + QTimer.singleShot(500, lambda: self._start_online_download(missing_games)) + else: + if debug_mode: + logger.debug("DEBUG: 用户选择不切换到在线模式") + + # 恢复UI状态 + self.main_window.setEnabled(True) + self.main_window.ui.start_install_text.setText("开始安装") \ 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..07206ed --- /dev/null +++ b/source/core/managers/patch_detector.py @@ -0,0 +1,375 @@ +import os +import hashlib +import tempfile +import py7zr +import traceback +from utils.logger import setup_logger +from PySide6.QtWidgets import QMessageBox +from PySide6.QtCore import QTimer, QThread, Signal +from data.config import PLUGIN_HASH, APP_NAME + +# 初始化logger +logger = setup_logger("patch_detector") + +class PatchCheckThread(QThread): + """用于在后台线程中执行补丁检查的线程""" + finished = Signal(bool) # (is_installed) + + def __init__(self, checker_func, *args): + super().__init__() + self.checker_func = checker_func + self.args = args + + def run(self): + result = self.checker_func(*self.args) + self.finished.emit(result) + +class PatchDetector: + """补丁检测与校验模块,用于统一处理在线和离线模式下的补丁检测和校验""" + + def __init__(self, main_window): + """初始化补丁检测器 + + Args: + main_window: 主窗口实例,用于访问UI和状态 + """ + self.main_window = main_window + self.app_name = main_window.APP_NAME if hasattr(main_window, 'APP_NAME') else "" + self.game_info = {} + self.plugin_hash = {} + self._load_game_info() + self.patch_check_thread = None + + def _load_game_info(self): + """从配置中加载游戏信息和补丁哈希值""" + try: + from data.config import GAME_INFO, PLUGIN_HASH + self.game_info = GAME_INFO + self.plugin_hash = PLUGIN_HASH + except ImportError: + logger.error("无法加载游戏信息或补丁哈希值配置") + + def _is_debug_mode(self): + """检查是否处于调试模式 + + Returns: + bool: 是否处于调试模式 + """ + try: + if hasattr(self.main_window, 'debug_manager') and self.main_window.debug_manager: + if hasattr(self.main_window.debug_manager, '_is_debug_mode'): + return self.main_window.debug_manager._is_debug_mode() + elif hasattr(self.main_window, 'config'): + return self.main_window.config.get('debug_mode', False) + return False + except Exception: + return False + + def check_patch_installed_async(self, game_dir, game_version, callback): + """异步检查游戏是否已安装补丁""" + def on_finished(is_installed): + callback(is_installed) + self.patch_check_thread = None + + self.patch_check_thread = PatchCheckThread(self._check_patch_installed_sync, game_dir, game_version) + self.patch_check_thread.finished.connect(on_finished) + self.patch_check_thread.start() + + def _check_patch_installed_sync(self, game_dir, game_version): + """同步检查游戏是否已安装补丁(在工作线程中运行)""" + debug_mode = self._is_debug_mode() + + if debug_mode: + logger.debug(f"DEBUG: 检查 {game_version} 是否已安装补丁,目录: {game_dir}") + + if game_version not in self.game_info: + if debug_mode: + logger.debug(f"DEBUG: {game_version} 不在支持的游戏列表中,跳过检查") + return False + + install_path_base = os.path.basename(self.game_info[game_version]["install_path"]) + patch_file_path = os.path.join(game_dir, install_path_base) + + # 检查补丁文件和禁用的补丁文件 + if os.path.exists(patch_file_path) or os.path.exists(f"{patch_file_path}.fain"): + return True + + return False + + def check_patch_installed(self, game_dir, game_version): + """检查游戏是否已安装补丁(此方法可能导致阻塞,推荐使用异步版本)""" + return self._check_patch_installed_sync(game_dir, game_version) + + def check_patch_disabled(self, game_dir, game_version): + """检查游戏的补丁是否已被禁用""" + debug_mode = self._is_debug_mode() + + if game_version not in self.game_info: + return False, None + + install_path_base = os.path.basename(self.game_info[game_version]["install_path"]) + patch_file_path = os.path.join(game_dir, install_path_base) + disabled_path = f"{patch_file_path}.fain" + + if os.path.exists(disabled_path): + if debug_mode: + logger.debug(f"找到禁用的补丁文件: {disabled_path}") + return True, disabled_path + + if debug_mode: + logger.debug(f"{game_version} 在 {game_dir} 的补丁未被禁用") + + return False, None + + def detect_installable_games(self, game_dirs): + """检测可安装补丁的游戏""" + debug_mode = self._is_debug_mode() + + if debug_mode: + logger.debug(f"开始检测可安装补丁的游戏,游戏目录: {game_dirs}") + + already_installed_games = [] + installable_games = [] + disabled_patch_games = [] + + for game_version, game_dir in game_dirs.items(): + is_patch_installed = self.check_patch_installed(game_dir, game_version) + hash_check_passed = self.main_window.installed_status.get(game_version, False) + + if is_patch_installed or hash_check_passed: + if debug_mode: + logger.info(f"DEBUG: {game_version} 已安装补丁,不需要再次安装") + logger.info(f"DEBUG: 文件检查结果: {is_patch_installed}, 哈希检查结果: {hash_check_passed}") + already_installed_games.append(game_version) + self.main_window.installed_status[game_version] = True + else: + is_disabled, disabled_path = self.check_patch_disabled(game_dir, game_version) + if is_disabled: + if debug_mode: + logger.info(f"DEBUG: {game_version} 存在被禁用的补丁: {disabled_path}") + disabled_patch_games.append(game_version) + else: + if debug_mode: + logger.info(f"DEBUG: {game_version} 未安装补丁,可以安装") + logger.info(f"DEBUG: 文件检查结果: {is_patch_installed}, 哈希检查结果: {hash_check_passed}") + installable_games.append(game_version) + + if debug_mode: + logger.debug(f"检测结果 - 已安装补丁: {already_installed_games}") + logger.debug(f"检测结果 - 可安装补丁: {installable_games}") + logger.debug(f"检测结果 - 禁用补丁: {disabled_patch_games}") + + return already_installed_games, installable_games, disabled_patch_games + + def verify_patch_hash(self, game_version, file_path): + """验证补丁文件的哈希值""" + expected_hash = self.plugin_hash.get(game_version, "") + + if not expected_hash: + logger.warning(f"DEBUG: 未找到 {game_version} 的预期哈希值") + return False + + debug_mode = self._is_debug_mode() + + if debug_mode: + logger.debug(f"DEBUG: 开始验证补丁文件: {file_path}") + logger.debug(f"DEBUG: 游戏版本: {game_version}") + logger.debug(f"DEBUG: 预期哈希值: {expected_hash}") + + try: + if not os.path.exists(file_path) or os.path.getsize(file_path) == 0: + return False + + with tempfile.TemporaryDirectory() as temp_dir: + if debug_mode: + logger.debug(f"DEBUG: 创建临时目录: {temp_dir}") + + try: + with py7zr.SevenZipFile(file_path, mode="r") as archive: + archive.extractall(path=temp_dir) + except Exception as e: + if debug_mode: + logger.error(f"DEBUG: 解压补丁文件失败: {e}") + return False + + patch_file = self._find_patch_file_in_temp_dir(temp_dir, game_version) + + if not patch_file or not os.path.exists(patch_file): + if debug_mode: + logger.warning(f"DEBUG: 未找到解压后的补丁文件") + return False + + if debug_mode: + logger.debug(f"DEBUG: 找到解压后的补丁文件: {patch_file}") + + try: + with open(patch_file, "rb") as f: + file_hash = hashlib.sha256(f.read()).hexdigest() + + result = file_hash.lower() == expected_hash.lower() + + if debug_mode: + logger.debug(f"DEBUG: 补丁文件 {patch_file} 哈希值验证: {'成功' if result else '失败'}") + + return result + except Exception as e: + if debug_mode: + logger.error(f"DEBUG: 计算补丁文件哈希值失败: {e}") + return False + except Exception as e: + if debug_mode: + logger.error(f"DEBUG: 验证补丁哈希值失败: {e}") + return False + + def _find_patch_file_in_temp_dir(self, temp_dir, game_version): + """在临时目录中查找解压后的补丁文件""" + game_patch_map = { + "Vol.1": os.path.join("vol.1", "adultsonly.xp3"), + "Vol.2": os.path.join("vol.2", "adultsonly.xp3"), + "Vol.3": os.path.join("vol.3", "update00.int"), + "Vol.4": os.path.join("vol.4", "vol4adult.xp3"), + "After": os.path.join("after", "afteradult.xp3"), + } + + for version_keyword, relative_path in game_patch_map.items(): + if version_keyword in game_version: + return os.path.join(temp_dir, relative_path) + + # 如果没有找到,则进行通用搜索 + for root, dirs, files in os.walk(temp_dir): + for file in files: + if file.endswith('.xp3') or file.endswith('.int'): + return os.path.join(root, file) + return None + + def create_hash_thread(self, mode, install_paths): + from workers.hash_thread import HashThread + return HashThread(mode, install_paths, PLUGIN_HASH, self.main_window.installed_status, self.main_window) + + def after_hash_compare(self): + is_offline = self.main_window.offline_mode_manager.is_in_offline_mode() + + self.main_window.close_hash_msg_box() + self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="after", is_offline=is_offline) + + install_paths = self.main_window.download_manager.get_install_paths() + + self.main_window.hash_thread = self.create_hash_thread("after", install_paths) + self.main_window.hash_thread.after_finished.connect(self.on_after_hash_finished) + self.main_window.hash_thread.start() + + def on_after_hash_finished(self, result): + self.main_window.close_hash_msg_box() + + if not result["passed"]: + self.main_window.setEnabled(True) + game = result.get("game", "未知游戏") + message = result.get("message", "发生未知错误。") + QMessageBox.critical(self.main_window, f"文件校验失败 - {APP_NAME}", message) + + self.main_window.setEnabled(True) + self.main_window.ui.start_install_text.setText("开始安装") + QTimer.singleShot(100, self.main_window.show_result) + + def on_offline_pre_hash_finished(self, updated_status, game_dirs): + self.main_window.installed_status = updated_status + + if self.main_window.hash_msg_box and self.main_window.hash_msg_box.isVisible(): + self.main_window.hash_msg_box.accept() + self.main_window.hash_msg_box = None + + self.main_window.setEnabled(True) + + already_installed_games, installable_games, disabled_patch_games = self.detect_installable_games(game_dirs) + + status_message = "" + if already_installed_games: + status_message += f"已安装补丁的游戏:\n{chr(10).join(already_installed_games)}\n\n" + + if disabled_patch_games: + disabled_msg = f"检测到以下游戏的补丁已被禁用:\n{chr(10).join(disabled_patch_games)}\n\n是否要启用这些补丁?" + + from PySide6 import QtWidgets + reply = QtWidgets.QMessageBox.question( + self.main_window, + f"检测到禁用补丁 - {APP_NAME}", + disabled_msg, + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No + ) + + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + disabled_game_dirs = {game: game_dirs[game] for game in disabled_patch_games} + + success_count, fail_count, results = self.main_window.patch_manager.batch_toggle_patches( + disabled_game_dirs, + operation="enable" + ) + + self.main_window.patch_manager.show_toggle_result(success_count, fail_count, results) + + for game_version in disabled_patch_games: + self.main_window.installed_status[game_version] = True + if game_version in installable_games: + installable_games.remove(game_version) + if game_version not in already_installed_games: + already_installed_games.append(game_version) + else: + installable_games.extend(disabled_patch_games) + + if disabled_patch_games: + status_message += f"禁用补丁的游戏:\n{chr(10).join(disabled_patch_games)}\n\n" + + if not installable_games: + if already_installed_games: + QMessageBox.information( + self.main_window, + f"信息 - {APP_NAME}", + f"\n所有游戏已安装补丁,无需重复安装。\n\n{status_message}", + ) + else: + QMessageBox.warning( + self.main_window, + f"警告 - {APP_NAME}", + "\n未检测到任何需要安装补丁的游戏。\n\n请确保游戏文件夹位于选择的目录中。\n", + ) + + self.main_window.ui.start_install_text.setText("开始安装") + return + + from PySide6 import QtWidgets + dialog = QtWidgets.QDialog(self.main_window) + dialog.setWindowTitle(f"选择要安装的游戏 - {APP_NAME}") + dialog.setMinimumWidth(300) + + layout = QtWidgets.QVBoxLayout() + label = QtWidgets.QLabel("请选择要安装补丁的游戏:") + layout.addWidget(label) + + list_widget = QtWidgets.QListWidget() + list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.MultiSelection) + + for game in installable_games: + item = QtWidgets.QListWidgetItem(game) + list_widget.addItem(item) + item.setSelected(True) + + layout.addWidget(list_widget) + + button_box = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok | + QtWidgets.QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(dialog.accept) + button_box.rejected.connect(dialog.reject) + layout.addWidget(button_box) + + dialog.setLayout(layout) + + result = dialog.exec() + if result != QtWidgets.QDialog.DialogCode.Accepted or not list_widget.selectedItems(): + self.main_window.ui.start_install_text.setText("开始安装") + return + + selected_games = [item.text() for item in list_widget.selectedItems()] + + self.main_window.offline_mode_manager.install_offline_patches(selected_games) \ No newline at end of file diff --git a/source/core/managers/patch_manager.py b/source/core/managers/patch_manager.py new file mode 100644 index 0000000..fa3d8fc --- /dev/null +++ b/source/core/managers/patch_manager.py @@ -0,0 +1,983 @@ +import os +import shutil +import traceback +from PySide6.QtWidgets import QMessageBox +from utils.logger import setup_logger +from data.config import APP_NAME +from utils import msgbox_frame + +class PatchManager: + """补丁管理器,用于处理补丁的安装和卸载""" + + def __init__(self, app_name, game_info, debug_manager=None, main_window=None): + """初始化补丁管理器 + + Args: + app_name: 应用程序名称,用于显示消息框标题 + game_info: 游戏信息字典,包含各版本的安装路径和可执行文件名 + debug_manager: 调试管理器实例,用于输出调试信息 + main_window: 主窗口实例,用于访问UI和状态 + """ + self.app_name = app_name + self.game_info = game_info + self.debug_manager = debug_manager + self.main_window = main_window # 添加main_window属性 + self.installed_status = {} # 游戏版本的安装状态 + self.logger = setup_logger("patch_manager") + self.patch_detector = None # 将在main_window初始化后设置 + + def set_patch_detector(self, patch_detector): + """设置补丁检测器实例 + + Args: + patch_detector: 补丁检测器实例 + """ + self.patch_detector = patch_detector + + def _is_debug_mode(self): + """检查是否处于调试模式 + + Returns: + bool: 是否处于调试模式 + """ + if hasattr(self.debug_manager, 'ui_manager') and hasattr(self.debug_manager.ui_manager, 'debug_action'): + return self.debug_manager.ui_manager.debug_action.isChecked() + return False + + def initialize_status(self): + """初始化所有游戏版本的安装状态""" + self.installed_status = {f"NEKOPARA Vol.{i}": False for i in range(1, 5)} + self.installed_status["NEKOPARA After"] = False + + def update_status(self, game_version, is_installed): + """更新游戏版本的安装状态 + + Args: + game_version: 游戏版本 + is_installed: 是否已安装 + """ + self.installed_status[game_version] = is_installed + + def get_status(self, game_version=None): + """获取游戏版本的安装状态 + + Args: + game_version: 游戏版本,如果为None则返回所有状态 + + Returns: + bool或dict: 指定版本的安装状态或所有版本的安装状态 + """ + if game_version: + return self.installed_status.get(game_version, False) + return self.installed_status + + def uninstall_patch(self, game_dir, game_version, silent=False): + """卸载补丁 + + Args: + game_dir: 游戏目录路径 + game_version: 游戏版本 + silent: 是否静默模式(不显示弹窗) + + Returns: + bool: 卸载成功返回True,失败返回False + dict: 在silent=True时,返回包含卸载结果信息的字典 + """ + debug_mode = self._is_debug_mode() + + if debug_mode: + self.logger.debug(f"DEBUG: 开始卸载 {game_version} 补丁,目录: {game_dir}") + + self.logger.info(f"开始卸载 {game_version} 补丁,目录: {game_dir}") + + if game_version not in self.game_info: + error_msg = f"无法识别游戏版本: {game_version}" + if debug_mode: + self.logger.debug(f"DEBUG: 卸载失败 - {error_msg}") + self.logger.error(f"卸载失败 - {error_msg}") + + if not silent: + QMessageBox.critical( + None, + f"错误 - {self.app_name}", + f"\n{error_msg}\n", + QMessageBox.StandardButton.Ok, + ) + return False if not silent else {"success": False, "message": error_msg, "files_removed": 0} + + try: + files_removed = 0 + + # 获取可能的补丁文件路径 + install_path_base = os.path.basename(self.game_info[game_version]["install_path"]) + patch_file_path = os.path.join(game_dir, install_path_base) + + if debug_mode: + self.logger.debug(f"DEBUG: 基础补丁文件路径: {patch_file_path}") + + # 尝试查找补丁文件,支持不同大小写 + patch_files_to_check = [ + patch_file_path, + patch_file_path.lower(), + patch_file_path.upper(), + patch_file_path.replace("_", ""), + patch_file_path.replace("_", "-"), + ] + + if debug_mode: + self.logger.debug(f"DEBUG: 查找以下可能的补丁文件路径: {patch_files_to_check}") + + # 查找并删除补丁文件,包括启用和禁用的 + patch_file_found = False + for patch_path in patch_files_to_check: + # 检查常规补丁文件 + if os.path.exists(patch_path): + patch_file_found = True + if debug_mode: + self.logger.debug(f"DEBUG: 找到补丁文件: {patch_path},准备删除") + self.logger.info(f"删除补丁文件: {patch_path}") + + os.remove(patch_path) + files_removed += 1 + if debug_mode: + self.logger.debug(f"DEBUG: 已删除补丁文件: {patch_path}") + + # 检查被禁用的补丁文件(带.fain后缀) + disabled_path = f"{patch_path}.fain" + if os.path.exists(disabled_path): + patch_file_found = True + if debug_mode: + self.logger.debug(f"DEBUG: 找到被禁用的补丁文件: {disabled_path},准备删除") + self.logger.info(f"删除被禁用的补丁文件: {disabled_path}") + + os.remove(disabled_path) + files_removed += 1 + if debug_mode: + self.logger.debug(f"DEBUG: 已删除被禁用的补丁文件: {disabled_path}") + + if not patch_file_found: + if debug_mode: + self.logger.debug(f"DEBUG: 未找到补丁文件,检查了以下路径: {patch_files_to_check}") + self.logger.debug(f"DEBUG: 也检查了禁用的补丁文件(.fain后缀)") + self.logger.warning(f"未找到 {game_version} 的补丁文件") + + # 检查是否有额外的签名文件 (.sig) + if game_version == "NEKOPARA After": + if debug_mode: + self.logger.debug(f"DEBUG: {game_version} 需要检查额外的签名文件") + + for patch_path in patch_files_to_check: + # 检查常规签名文件 + sig_file_path = f"{patch_path}.sig" + if os.path.exists(sig_file_path): + if debug_mode: + self.logger.debug(f"DEBUG: 找到签名文件: {sig_file_path},准备删除") + self.logger.info(f"删除签名文件: {sig_file_path}") + + os.remove(sig_file_path) + files_removed += 1 + if debug_mode: + self.logger.debug(f"DEBUG: 已删除签名文件: {sig_file_path}") + + # 检查被禁用补丁的签名文件 + disabled_sig_path = f"{patch_path}.fain.sig" + if os.path.exists(disabled_sig_path): + if debug_mode: + self.logger.debug(f"DEBUG: 找到被禁用补丁的签名文件: {disabled_sig_path},准备删除") + self.logger.info(f"删除被禁用补丁的签名文件: {disabled_sig_path}") + + os.remove(disabled_sig_path) + files_removed += 1 + if debug_mode: + self.logger.debug(f"DEBUG: 已删除被禁用补丁的签名文件: {disabled_sig_path}") + + # 删除patch文件夹 + if debug_mode: + self.logger.debug(f"DEBUG: 检查并删除patch文件夹") + + patch_folders_to_check = [ + os.path.join(game_dir, "patch"), + os.path.join(game_dir, "Patch"), + os.path.join(game_dir, "PATCH"), + ] + + for patch_folder in patch_folders_to_check: + if os.path.exists(patch_folder): + if debug_mode: + self.logger.debug(f"DEBUG: 找到补丁文件夹: {patch_folder},准备删除") + self.logger.info(f"删除补丁文件夹: {patch_folder}") + + import shutil + shutil.rmtree(patch_folder) + files_removed += 1 + if debug_mode: + self.logger.debug(f"DEBUG: 已删除补丁文件夹: {patch_folder}") + + # 删除game/patch文件夹 + if debug_mode: + self.logger.debug(f"DEBUG: 检查并删除game/patch文件夹") + + game_folders = ["game", "Game", "GAME"] + patch_folders = ["patch", "Patch", "PATCH"] + + for game_folder in game_folders: + for patch_folder in patch_folders: + game_patch_folder = os.path.join(game_dir, game_folder, patch_folder) + if os.path.exists(game_patch_folder): + if debug_mode: + self.logger.debug(f"DEBUG: 找到game/patch文件夹: {game_patch_folder},准备删除") + self.logger.info(f"删除game/patch文件夹: {game_patch_folder}") + + import shutil + shutil.rmtree(game_patch_folder) + files_removed += 1 + if debug_mode: + self.logger.debug(f"DEBUG: 已删除game/patch文件夹: {game_patch_folder}") + + # 删除配置文件 + if debug_mode: + self.logger.debug(f"DEBUG: 检查并删除配置文件和脚本文件") + + config_files = ["config.json", "Config.json", "CONFIG.JSON"] + script_files = ["scripts.json", "Scripts.json", "SCRIPTS.JSON"] + + for game_folder in game_folders: + game_path = os.path.join(game_dir, game_folder) + if os.path.exists(game_path): + # 删除配置文件 + for config_file in config_files: + config_path = os.path.join(game_path, config_file) + if os.path.exists(config_path): + if debug_mode: + self.logger.debug(f"DEBUG: 找到配置文件: {config_path},准备删除") + self.logger.info(f"删除配置文件: {config_path}") + + os.remove(config_path) + files_removed += 1 + if debug_mode: + self.logger.debug(f"DEBUG: 已删除配置文件: {config_path}") + + # 删除脚本文件 + for script_file in script_files: + script_path = os.path.join(game_path, script_file) + if os.path.exists(script_path): + if debug_mode: + self.logger.debug(f"DEBUG: 找到脚本文件: {script_path},准备删除") + self.logger.info(f"删除脚本文件: {script_path}") + + os.remove(script_path) + files_removed += 1 + if debug_mode: + self.logger.debug(f"DEBUG: 已删除脚本文件: {script_path}") + + # 更新安装状态 + self.installed_status[game_version] = False + if debug_mode: + self.logger.debug(f"DEBUG: 已更新 {game_version} 的安装状态为未安装") + + # 在非静默模式且非批量卸载模式下显示卸载成功消息 + if not silent and game_version != "all": + # 显示卸载成功消息 + if files_removed > 0: + success_msg = f"\n{game_version} 补丁卸载成功!\n共删除 {files_removed} 个文件/文件夹。\n" + if debug_mode: + self.logger.debug(f"DEBUG: 显示卸载成功消息: {success_msg}") + + QMessageBox.information( + None, + f"卸载完成 - {self.app_name}", + success_msg, + QMessageBox.StandardButton.Ok, + ) + else: + warning_msg = f"\n未找到 {game_version} 的补丁文件,可能未安装补丁或已被移除。\n" + if debug_mode: + self.logger.debug(f"DEBUG: 显示警告消息: {warning_msg}") + + QMessageBox.warning( + None, + f"警告 - {self.app_name}", + warning_msg, + QMessageBox.StandardButton.Ok, + ) + + # 卸载成功 + if debug_mode: + self.logger.debug(f"DEBUG: {game_version} 卸载完成,共删除 {files_removed} 个文件/文件夹") + self.logger.info(f"{game_version} 卸载完成,共删除 {files_removed} 个文件/文件夹") + + if silent: + return {"success": True, "message": f"{game_version} 补丁卸载成功", "files_removed": files_removed} + return True + + except Exception as e: + error_message = f"卸载 {game_version} 补丁时出错:{str(e)}" + if debug_mode: + self.logger.debug(f"DEBUG: {error_message}") + import traceback + self.logger.debug(f"DEBUG: 错误详情:\n{traceback.format_exc()}") + self.logger.error(error_message) + + # 在非静默模式且非批量卸载模式下显示卸载失败消息 + if not silent and game_version != "all": + # 显示卸载失败消息 + error_message = f"\n卸载 {game_version} 补丁时出错:\n\n{str(e)}\n" + if debug_mode: + self.logger.debug(f"DEBUG: 显示卸载失败消息") + + QMessageBox.critical( + None, + f"卸载失败 - {self.app_name}", + error_message, + QMessageBox.StandardButton.Ok, + ) + + # 卸载失败 + if silent: + return {"success": False, "message": f"卸载 {game_version} 补丁时出错: {str(e)}", "files_removed": 0} + return False + + def batch_uninstall_patches(self, game_dirs): + """批量卸载多个游戏的补丁 + + Args: + game_dirs: 游戏版本到游戏目录的映射字典 + + Returns: + tuple: (成功数量, 失败数量, 详细结果列表) + """ + success_count = 0 + fail_count = 0 + debug_mode = self._is_debug_mode() + results = [] + + if debug_mode: + self.logger.debug(f"DEBUG: 开始批量卸载补丁,游戏数量: {len(game_dirs)}") + self.logger.debug(f"DEBUG: 要卸载的游戏: {list(game_dirs.keys())}") + + self.logger.info(f"开始批量卸载补丁,游戏数量: {len(game_dirs)}") + self.logger.info(f"要卸载的游戏: {list(game_dirs.keys())}") + + for version, path in game_dirs.items(): + if debug_mode: + self.logger.debug(f"DEBUG: 处理游戏 {version},路径: {path}") + + self.logger.info(f"开始卸载 {version} 的补丁") + + try: + # 在批量模式下使用静默卸载 + if debug_mode: + self.logger.debug(f"DEBUG: 使用静默模式卸载 {version}") + + result = self.uninstall_patch(path, version, silent=True) + + if isinstance(result, dict): # 使用了静默模式 + if result["success"]: + success_count += 1 + if debug_mode: + self.logger.debug(f"DEBUG: {version} 卸载成功,删除了 {result['files_removed']} 个文件/文件夹") + self.logger.info(f"{version} 卸载成功,删除了 {result['files_removed']} 个文件/文件夹") + else: + fail_count += 1 + if debug_mode: + self.logger.debug(f"DEBUG: {version} 卸载失败,原因: {result['message']}") + self.logger.warning(f"{version} 卸载失败,原因: {result['message']}") + + results.append({ + "version": version, + "success": result["success"], + "message": result["message"], + "files_removed": result["files_removed"] + }) + else: # 兼容旧代码,不应该执行到这里 + if result: + success_count += 1 + if debug_mode: + self.logger.debug(f"DEBUG: {version} 卸载成功(旧格式)") + self.logger.info(f"{version} 卸载成功(旧格式)") + else: + fail_count += 1 + if debug_mode: + self.logger.debug(f"DEBUG: {version} 卸载失败(旧格式)") + self.logger.warning(f"{version} 卸载失败(旧格式)") + + results.append({ + "version": version, + "success": result, + "message": f"{version} 卸载{'成功' if result else '失败'}", + "files_removed": 0 + }) + + except Exception as e: + if debug_mode: + self.logger.debug(f"DEBUG: 卸载 {version} 时出错: {str(e)}") + import traceback + self.logger.debug(f"DEBUG: 错误详情:\n{traceback.format_exc()}") + + self.logger.error(f"卸载 {version} 时出错: {str(e)}") + + fail_count += 1 + results.append({ + "version": version, + "success": False, + "message": f"卸载出错: {str(e)}", + "files_removed": 0 + }) + + if debug_mode: + self.logger.debug(f"DEBUG: 批量卸载完成,成功: {success_count},失败: {fail_count}") + + self.logger.info(f"批量卸载完成,成功: {success_count},失败: {fail_count}") + + return success_count, fail_count, results + + def show_uninstall_result(self, success_count, fail_count, results=None): + """显示批量卸载结果 + + Args: + success_count: 成功卸载的数量 + fail_count: 卸载失败的数量 + results: 详细结果列表,如果提供,会显示更详细的信息 + """ + debug_mode = self._is_debug_mode() + + if debug_mode: + self.logger.debug(f"DEBUG: 显示卸载结果,成功: {success_count},失败: {fail_count}") + + result_text = f"\n批量卸载完成!\n成功: {success_count} 个\n失败: {fail_count} 个\n" + + # 如果有详细结果,添加到消息中 + if results: + success_list = [r["version"] for r in results if r["success"]] + fail_list = [r["version"] for r in results if not r["success"]] + + if debug_mode: + self.logger.debug(f"DEBUG: 成功卸载的游戏: {success_list}") + self.logger.debug(f"DEBUG: 卸载失败的游戏: {fail_list}") + + if success_list: + result_text += f"\n【成功卸载】:\n{chr(10).join(success_list)}\n" + + if fail_list: + result_text += f"\n【卸载失败】:\n{chr(10).join(fail_list)}\n" + + # 记录更详细的失败原因 + if debug_mode: + for r in results: + if not r["success"]: + self.logger.debug(f"DEBUG: {r['version']} 卸载失败原因: {r['message']}") + + if debug_mode: + self.logger.debug(f"DEBUG: 显示卸载结果对话框") + + QMessageBox.information( + None, + f"批量卸载完成 - {self.app_name}", + result_text, + QMessageBox.StandardButton.Ok, + ) + + def check_patch_installed(self, game_dir, game_version): + """检查游戏是否已安装补丁(调用patch_detector) + + Args: + game_dir: 游戏目录路径 + game_version: 游戏版本 + + Returns: + bool: 如果已安装补丁或有被禁用的补丁文件返回True,否则返回False + """ + if self.patch_detector: + return self.patch_detector.check_patch_installed(game_dir, game_version) + + # 如果patch_detector未设置,使用原始逻辑(应该不会执行到这里) + debug_mode = self._is_debug_mode() + + if game_version not in self.game_info: + return False + + # 获取可能的补丁文件路径 + install_path_base = os.path.basename(self.game_info[game_version]["install_path"]) + patch_file_path = os.path.join(game_dir, install_path_base) + + # 尝试查找补丁文件,支持不同大小写 + patch_files_to_check = [ + patch_file_path, + patch_file_path.lower(), + patch_file_path.upper(), + patch_file_path.replace("_", ""), + patch_file_path.replace("_", "-"), + ] + + # 查找补丁文件 + for patch_path in patch_files_to_check: + if os.path.exists(patch_path): + if debug_mode: + self.logger.debug(f"找到补丁文件: {patch_path}") + return True + # 检查是否存在被禁用的补丁文件(带.fain后缀) + disabled_path = f"{patch_path}.fain" + if os.path.exists(disabled_path): + if debug_mode: + self.logger.debug(f"找到被禁用的补丁文件: {disabled_path}") + return True + + # 检查是否有补丁文件夹 + patch_folders_to_check = [ + os.path.join(game_dir, "patch"), + os.path.join(game_dir, "Patch"), + os.path.join(game_dir, "PATCH"), + ] + + for patch_folder in patch_folders_to_check: + if os.path.exists(patch_folder): + if debug_mode: + self.logger.debug(f"找到补丁文件夹: {patch_folder}") + return True + + # 检查game/patch文件夹 + game_folders = ["game", "Game", "GAME"] + patch_folders = ["patch", "Patch", "PATCH"] + + for game_folder in game_folders: + for patch_folder in patch_folders: + game_patch_folder = os.path.join(game_dir, game_folder, patch_folder) + if os.path.exists(game_patch_folder): + if debug_mode: + self.logger.debug(f"找到game/patch文件夹: {game_patch_folder}") + return True + + # 检查配置文件 + config_files = ["config.json", "Config.json", "CONFIG.JSON"] + script_files = ["scripts.json", "Scripts.json", "SCRIPTS.JSON"] + + for game_folder in game_folders: + game_path = os.path.join(game_dir, game_folder) + if os.path.exists(game_path): + # 检查配置文件 + for config_file in config_files: + config_path = os.path.join(game_path, config_file) + if os.path.exists(config_path): + if debug_mode: + self.logger.debug(f"找到配置文件: {config_path}") + return True + + # 检查脚本文件 + for script_file in script_files: + script_path = os.path.join(game_path, script_file) + if os.path.exists(script_path): + if debug_mode: + self.logger.debug(f"找到脚本文件: {script_path}") + return True + + # 没有找到补丁文件或文件夹 + if debug_mode: + self.logger.debug(f"{game_version} 在 {game_dir} 中没有安装补丁") + return False + + def check_patch_disabled(self, game_dir, game_version): + """检查游戏的补丁是否已被禁用(调用patch_detector) + + Args: + game_dir: 游戏目录路径 + game_version: 游戏版本 + + Returns: + bool: 如果补丁被禁用返回True,否则返回False + str: 禁用的补丁文件路径,如果没有禁用返回None + """ + if self.patch_detector: + return self.patch_detector.check_patch_disabled(game_dir, game_version) + + # 如果patch_detector未设置,使用原始逻辑(应该不会执行到这里) + debug_mode = self._is_debug_mode() + + if game_version not in self.game_info: + return False, None + + # 获取可能的补丁文件路径 + install_path_base = os.path.basename(self.game_info[game_version]["install_path"]) + patch_file_path = os.path.join(game_dir, install_path_base) + + # 检查是否存在禁用的补丁文件(.fain后缀) + disabled_patch_files = [ + f"{patch_file_path}.fain", + f"{patch_file_path.lower()}.fain", + f"{patch_file_path.upper()}.fain", + f"{patch_file_path.replace('_', '')}.fain", + f"{patch_file_path.replace('_', '-')}.fain", + ] + + # 检查是否有禁用的补丁文件 + for disabled_path in disabled_patch_files: + if os.path.exists(disabled_path): + if debug_mode: + self.logger.debug(f"找到禁用的补丁文件: {disabled_path}") + return True, disabled_path + + if debug_mode: + self.logger.debug(f"{game_version} 在 {game_dir} 的补丁未被禁用") + + return False, None + + def toggle_patch(self, game_dir, game_version, operation=None, silent=False): + """切换补丁的禁用/启用状态 + + Args: + game_dir: 游戏目录路径 + game_version: 游戏版本 + operation: 指定操作,可以是"enable"、"disable"或None(None则自动切换当前状态) + silent: 是否静默模式(不显示弹窗) + + Returns: + dict: 包含操作结果信息的字典 + """ + debug_mode = self._is_debug_mode() + + if debug_mode: + self.logger.debug(f"开始切换补丁状态 - 游戏版本: {game_version}, 游戏目录: {game_dir}, 操作: {operation}") + + if game_version not in self.game_info: + if debug_mode: + self.logger.debug(f"无法识别游戏版本: {game_version}") + if not silent: + QMessageBox.critical( + None, + f"错误 - {self.app_name}", + f"\n无法识别游戏版本: {game_version}\n", + QMessageBox.StandardButton.Ok, + ) + return {"success": False, "message": f"无法识别游戏版本: {game_version}", "action": "none"} + + # 检查补丁是否已安装 + is_patch_installed = self.check_patch_installed(game_dir, game_version) + if debug_mode: + self.logger.debug(f"补丁安装状态检查结果: {is_patch_installed}") + + if not is_patch_installed: + if debug_mode: + self.logger.debug(f"{game_version} 未安装补丁,无法进行禁用/启用操作") + if not silent: + QMessageBox.warning( + None, + f"提示 - {self.app_name}", + f"\n{game_version} 未安装补丁,无法进行禁用/启用操作。\n", + QMessageBox.StandardButton.Ok, + ) + return {"success": False, "message": f"{game_version} 未安装补丁", "action": "none"} + + try: + # 检查当前状态 + is_disabled, disabled_path = self.check_patch_disabled(game_dir, game_version) + if debug_mode: + self.logger.debug(f"补丁禁用状态检查结果 - 是否禁用: {is_disabled}, 禁用路径: {disabled_path}") + + # 获取可能的补丁文件路径 + install_path_base = os.path.basename(self.game_info[game_version]["install_path"]) + patch_file_path = os.path.join(game_dir, install_path_base) + + # 尝试查找原始补丁文件,支持不同大小写 + patch_files_to_check = [ + patch_file_path, + patch_file_path.lower(), + patch_file_path.upper(), + patch_file_path.replace("_", ""), + patch_file_path.replace("_", "-"), + ] + + if debug_mode: + self.logger.debug(f"将检查以下可能的补丁文件: {patch_files_to_check}") + + # 确定操作类型 + if operation: + if operation == "enable": + action_needed = is_disabled # 只有当前是禁用状态时才需要启用 + elif operation == "disable": + action_needed = not is_disabled # 只有当前是启用状态时才需要禁用 + else: + action_needed = True # 无效操作类型,强制进行操作 + else: + action_needed = True # 未指定操作类型,始终执行切换 + + if debug_mode: + self.logger.debug(f"操作决策 - 操作类型: {operation}, 是否需要执行操作: {action_needed}") + + if not action_needed: + # 补丁已经是目标状态,无需操作 + if operation == "enable": + message = f"{game_version} 补丁已经是启用状态" + else: + message = f"{game_version} 补丁已经是禁用状态" + + if debug_mode: + self.logger.debug(f"{message}, 无需操作") + + if not silent: + QMessageBox.information( + None, + f"提示 - {self.app_name}", + f"\n{message}\n", + QMessageBox.StandardButton.Ok, + ) + return {"success": True, "message": message, "action": "none"} + + if is_disabled: + # 当前是禁用状态,需要启用 + if disabled_path and os.path.exists(disabled_path): + # 从禁用文件名去掉.fain后缀 + enabled_path = disabled_path[:-5] # 去掉.fain + if debug_mode: + self.logger.debug(f"正在启用补丁 - 从 {disabled_path} 重命名为 {enabled_path}") + os.rename(disabled_path, enabled_path) + if debug_mode: + self.logger.debug(f"已启用 {game_version} 的补丁,重命名文件成功") + action = "enable" + message = f"{game_version} 补丁已启用" + else: + # 未找到禁用的补丁文件,但状态是禁用 + message = f"未找到禁用的补丁文件: {disabled_path}" + if debug_mode: + self.logger.debug(f"{message}") + return {"success": False, "message": message, "action": "none"} + else: + # 当前是启用状态,需要禁用 + # 查找正在使用的补丁文件 + active_patch_file = None + for patch_path in patch_files_to_check: + if os.path.exists(patch_path): + active_patch_file = patch_path + if debug_mode: + self.logger.debug(f"找到活跃的补丁文件: {active_patch_file}") + break + + if active_patch_file: + # 给补丁文件添加.fain后缀禁用它 + disabled_path = f"{active_patch_file}.fain" + if debug_mode: + self.logger.debug(f"正在禁用补丁 - 从 {active_patch_file} 重命名为 {disabled_path}") + os.rename(active_patch_file, disabled_path) + if debug_mode: + self.logger.debug(f"已禁用 {game_version} 的补丁,重命名文件成功") + action = "disable" + message = f"{game_version} 补丁已禁用" + else: + # 未找到活跃的补丁文件,但状态是启用 + message = f"未找到启用的补丁文件,请检查游戏目录: {game_dir}" + if debug_mode: + self.logger.debug(f"{message}") + return {"success": False, "message": message, "action": "none"} + + # 非静默模式下显示操作结果 + if not silent: + QMessageBox.information( + None, + f"操作成功 - {self.app_name}", + f"\n{message}\n", + QMessageBox.StandardButton.Ok, + ) + + if debug_mode: + self.logger.debug(f"切换补丁状态操作完成 - 结果: 成功, 操作: {action}, 消息: {message}") + + return {"success": True, "message": message, "action": action} + + except Exception as e: + error_message = f"切换 {game_version} 补丁状态时出错: {str(e)}" + + if debug_mode: + self.logger.debug(f"{error_message}") + import traceback + self.logger.debug(f"错误详情:\n{traceback.format_exc()}") + + if not silent: + QMessageBox.critical( + None, + f"操作失败 - {self.app_name}", + f"\n{error_message}\n", + QMessageBox.StandardButton.Ok, + ) + + return {"success": False, "message": error_message, "action": "none"} + + def batch_toggle_patches(self, game_dirs, operation=None): + """批量切换多个游戏补丁的禁用/启用状态 + + Args: + game_dirs: 游戏版本到游戏目录的映射字典 + operation: 指定操作,可以是"enable"、"disable"或None(None则自动切换当前状态) + + Returns: + tuple: (成功数量, 失败数量, 详细结果列表) + """ + success_count = 0 + fail_count = 0 + debug_mode = self._is_debug_mode() + results = [] + + if debug_mode: + self.logger.debug(f"开始批量切换补丁状态 - 操作: {operation}, 游戏数量: {len(game_dirs)}") + self.logger.debug(f"游戏列表: {list(game_dirs.keys())}") + + for version, path in game_dirs.items(): + try: + if debug_mode: + self.logger.debug(f"处理游戏 {version}, 目录: {path}") + + # 在批量模式下使用静默操作 + result = self.toggle_patch(path, version, operation=operation, silent=True) + + if debug_mode: + self.logger.debug(f"游戏 {version} 操作结果: {result}") + + if result["success"]: + success_count += 1 + if debug_mode: + self.logger.debug(f"游戏 {version} 操作成功,操作类型: {result['action']}") + else: + fail_count += 1 + if debug_mode: + self.logger.debug(f"游戏 {version} 操作失败,原因: {result['message']}") + + results.append({ + "version": version, + "success": result["success"], + "message": result["message"], + "action": result["action"] + }) + + except Exception as e: + if debug_mode: + self.logger.debug(f"切换 {version} 补丁状态时出错: {str(e)}") + import traceback + self.logger.debug(f"错误详情:\n{traceback.format_exc()}") + + fail_count += 1 + results.append({ + "version": version, + "success": False, + "message": f"操作出错: {str(e)}", + "action": "none" + }) + + if debug_mode: + self.logger.debug(f"批量切换补丁状态完成 - 成功: {success_count}, 失败: {fail_count}") + + return success_count, fail_count, results + + def show_toggle_result(self, success_count, fail_count, results=None): + """显示批量切换补丁状态的结果 + + Args: + success_count: 成功操作的数量 + fail_count: 操作失败的数量 + results: 详细结果列表,如果提供,会显示更详细的信息 + """ + result_text = f"\n批量操作完成!\n成功: {success_count} 个\n失败: {fail_count} 个\n" + + # 如果有详细结果,添加到消息中 + if results: + enabled_list = [r["version"] for r in results if r["success"] and r["action"] == "enable"] + disabled_list = [r["version"] for r in results if r["success"] and r["action"] == "disable"] + skipped_list = [r["version"] for r in results if r["success"] and r["action"] == "none"] + fail_list = [r["version"] for r in results if not r["success"]] + + if enabled_list: + result_text += f"\n【已启用补丁】:\n{chr(10).join(enabled_list)}\n" + + if disabled_list: + result_text += f"\n【已禁用补丁】:\n{chr(10).join(disabled_list)}\n" + + if skipped_list: + result_text += f"\n【无需操作】:\n{chr(10).join(skipped_list)}\n" + + if fail_list: + result_text += f"\n【操作失败】:\n{chr(10).join(fail_list)}\n" + + QMessageBox.information( + None, + f"批量操作完成 - {self.app_name}", + result_text, + QMessageBox.StandardButton.Ok, + ) + + def show_result(self): + """显示安装结果,区分不同情况""" + # 获取当前安装状态 + installed_versions = [] # 成功安装的版本 + skipped_versions = [] # 已有补丁跳过的版本 + failed_versions = [] # 安装失败的版本 + not_found_versions = [] # 未找到的版本 + + # 获取所有游戏版本路径 + install_paths = self.main_window.download_manager.get_install_paths() if hasattr(self.main_window.download_manager, "get_install_paths") else {} + + # 检查是否处于离线模式 + is_offline_mode = False + if hasattr(self.main_window, 'offline_mode_manager'): + is_offline_mode = self.main_window.offline_mode_manager.is_in_offline_mode() + + # 获取本次实际安装的游戏列表 + installed_games = [] + + # 在线模式下使用download_queue_history + if hasattr(self.main_window, 'download_queue_history') and self.main_window.download_queue_history: + installed_games = self.main_window.download_queue_history + + # 离线模式下使用offline_mode_manager.installed_games + if is_offline_mode and hasattr(self.main_window.offline_mode_manager, 'installed_games'): + installed_games = self.main_window.offline_mode_manager.installed_games + + debug_mode = self._is_debug_mode() + + if debug_mode: + self.logger.debug(f"DEBUG: 显示安装结果,离线模式: {is_offline_mode}") + self.logger.debug(f"DEBUG: 本次安装的游戏: {installed_games}") + + for game_version, is_installed in self.main_window.installed_status.items(): + # 只处理install_paths中存在的游戏版本 + if game_version in install_paths: + path = install_paths[game_version] + + # 检查游戏是否存在但未通过本次安装补丁 + if is_installed: + # 游戏已安装补丁 + if game_version in installed_games: + # 本次成功安装 + installed_versions.append(game_version) + else: + # 已有补丁,被跳过下载 + skipped_versions.append(game_version) + else: + # 游戏未安装补丁 + if os.path.exists(path): + # 游戏文件夹存在,但安装失败 + failed_versions.append(game_version) + else: + # 游戏文件夹不存在 + not_found_versions.append(game_version) + + # 构建结果信息 + result_text = f"\n安装结果:\n" + + # 总数统计 - 只显示本次实际安装的数量 + total_installed = len(installed_versions) + total_failed = len(failed_versions) + + result_text += f"安装成功:{total_installed} 个 安装失败:{total_failed} 个\n\n" + + # 详细列表 + if installed_versions: + result_text += f"【成功安装】:\n{chr(10).join(installed_versions)}\n\n" + + if failed_versions: + result_text += f"【安装失败】:\n{chr(10).join(failed_versions)}\n\n" + + if not_found_versions: + # 只有在真正检测到了游戏但未安装补丁时才显示 + result_text += f"【尚未安装补丁的游戏】:\n{chr(10).join(not_found_versions)}\n" + + QMessageBox.information( + self.main_window, + f"安装完成 - {APP_NAME}", + result_text + ) \ No newline at end of file diff --git a/source/core/managers/privacy_manager.py b/source/core/managers/privacy_manager.py new file mode 100644 index 0000000..2d16115 --- /dev/null +++ b/source/core/managers/privacy_manager.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import json +from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QTextBrowser, QPushButton, QCheckBox, QLabel, QMessageBox +from PySide6.QtCore import Qt + +from data.privacy_policy import PRIVACY_POLICY_BRIEF, get_local_privacy_policy, PRIVACY_POLICY_VERSION +from data.config import CACHE, APP_NAME, APP_VERSION +from utils import msgbox_frame +from utils.logger import setup_logger + +class PrivacyManager: + """隐私协议管理器,负责显示隐私协议对话框并处理用户选择""" + + def __init__(self): + """初始化隐私协议管理器""" + # 初始化日志 + self.logger = setup_logger("privacy_manager") + self.logger.info("正在初始化隐私协议管理器") + # 确保缓存目录存在 + os.makedirs(CACHE, exist_ok=True) + self.config_file = os.path.join(CACHE, "privacy_config.json") + self.privacy_config = self._load_privacy_config() + + # 获取隐私协议内容和版本 + self.logger.info("读取本地隐私协议文件") + self.privacy_content, self.current_privacy_version, error = get_local_privacy_policy() + if error: + self.logger.warning(f"读取本地隐私协议文件警告: {error}") + # 使用默认版本作为备用 + self.current_privacy_version = PRIVACY_POLICY_VERSION + self.logger.info(f"隐私协议版本: {self.current_privacy_version}") + + # 检查隐私协议版本和用户同意状态 + self.privacy_accepted = self._check_privacy_acceptance() + + def _load_privacy_config(self): + """加载隐私协议配置 + + Returns: + dict: 隐私协议配置信息 + """ + if os.path.exists(self.config_file): + try: + with open(self.config_file, "r", encoding="utf-8") as f: + config = json.load(f) + return config + except (json.JSONDecodeError, IOError) as e: + self.logger.error(f"读取隐私配置失败: {e}") + # 如果读取失败,返回空配置,强制显示隐私协议 + return {"privacy_accepted": False} + return {"privacy_accepted": False} + + def _check_privacy_acceptance(self): + """检查隐私协议是否需要重新同意 + + 如果隐私协议版本变更,则需要重新同意 + + Returns: + bool: 是否已有有效的隐私协议同意 + """ + # 获取存储的版本信息 + stored_privacy_version = self.privacy_config.get("privacy_version", "0.0.0") + stored_app_version = self.privacy_config.get("app_version", "0.0.0") + privacy_accepted = self.privacy_config.get("privacy_accepted", False) + + self.logger.info(f"存储的隐私协议版本: {stored_privacy_version}, 当前版本: {self.current_privacy_version}") + self.logger.info(f"存储的应用版本: {stored_app_version}, 当前版本: {APP_VERSION}") + self.logger.info(f"隐私协议接受状态: {privacy_accepted}") + + # 如果隐私协议版本变更,需要重新同意 + if stored_privacy_version != self.current_privacy_version: + self.logger.info("隐私协议版本已变更,需要重新同意") + return False + + # 返回当前的同意状态 + return privacy_accepted + + def _save_privacy_config(self, accepted): + """保存隐私协议配置 + + Args: + accepted: 用户是否同意隐私协议 + + Returns: + bool: 配置是否保存成功 + """ + try: + # 确保目录存在 + os.makedirs(os.path.dirname(self.config_file), exist_ok=True) + + # 写入配置文件,包含应用版本和隐私协议版本 + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump({ + "privacy_accepted": accepted, + "privacy_version": self.current_privacy_version, # 保存当前隐私协议版本 + "app_version": APP_VERSION # 保存当前应用版本 + }, f, indent=2) + + # 更新实例变量 + self.privacy_accepted = accepted + self.privacy_config = { + "privacy_accepted": accepted, + "privacy_version": self.current_privacy_version, + "app_version": APP_VERSION + } + return True + except IOError as e: + self.logger.error(f"保存隐私协议配置失败: {e}") + # 显示保存失败的提示 + QMessageBox.warning( + None, + f"配置保存警告 - {APP_NAME}", + f"隐私设置无法保存到配置文件,下次启动时可能需要重新确认。\n\n错误信息:{e}" + ) + return False + + def show_privacy_dialog(self): + """显示隐私协议对话框 + + Returns: + bool: 用户是否同意隐私协议 + """ + # 如果用户已经同意了隐私协议,直接返回True不显示对话框 + if self.privacy_accepted: + self.logger.info("用户已同意当前版本的隐私协议,无需再次显示") + return True + + self.logger.info("首次运行或隐私协议版本变更,显示隐私对话框") + + # 创建隐私协议对话框 + dialog = QDialog() + dialog.setWindowTitle(f"隐私政策 - {APP_NAME}") + dialog.setMinimumSize(600, 400) + dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowContextHelpButtonHint) + + # 创建布局 + layout = QVBoxLayout(dialog) + + # 添加标题和版本信息 + title_label = QLabel(f"请阅读并同意以下隐私政策 (更新日期: {self.current_privacy_version})") + title_label.setStyleSheet("font-size: 14px; font-weight: bold;") + layout.addWidget(title_label) + + # 添加隐私协议文本框 + text_browser = QTextBrowser() + # 这里使用PRIVACY_POLICY_BRIEF而不是self.privacy_content,保持UI简洁 + text_browser.setMarkdown(PRIVACY_POLICY_BRIEF) + text_browser.setOpenExternalLinks(True) + layout.addWidget(text_browser) + + # 添加同意选择框 + checkbox = QCheckBox("我已阅读并同意上述隐私政策") + layout.addWidget(checkbox) + + # 添加按钮 + buttons_layout = QHBoxLayout() + agree_button = QPushButton("同意并继续") + agree_button.setEnabled(False) # 初始状态为禁用 + decline_button = QPushButton("不同意并退出") + buttons_layout.addWidget(agree_button) + buttons_layout.addWidget(decline_button) + layout.addLayout(buttons_layout) + + # 连接选择框状态变化 - 修复勾选后按钮不亮起的问题 + def on_checkbox_state_changed(state): + self.logger.debug(f"复选框状态变更为: {state}") + agree_button.setEnabled(state == 2) # Qt.Checked 在 PySide6 中值为 2 + + checkbox.stateChanged.connect(on_checkbox_state_changed) + + # 连接按钮点击事件 + agree_button.clicked.connect(lambda: self._on_agree(dialog)) + decline_button.clicked.connect(lambda: self._on_decline(dialog)) + + # 显示对话框 + result = dialog.exec() + + # 返回用户选择结果 + return self.privacy_accepted + + def _on_agree(self, dialog): + """处理用户同意隐私协议 + + Args: + dialog: 对话框实例 + """ + # 保存配置并更新状态 + self._save_privacy_config(True) + dialog.accept() + + def _on_decline(self, dialog): + """处理用户拒绝隐私协议 + + Args: + dialog: 对话框实例 + """ + # 显示拒绝信息 + msg_box = msgbox_frame( + f"退出 - {APP_NAME}", + "\n您需要同意隐私政策才能使用本软件。\n软件将立即退出。\n", + QMessageBox.Ok, + ) + msg_box.exec() + + # 保存拒绝状态 + self._save_privacy_config(False) + dialog.reject() + + def is_privacy_accepted(self): + """检查用户是否已同意隐私协议 + + Returns: + bool: 用户是否已同意隐私协议 + """ + return self.privacy_accepted + + def reset_privacy_agreement(self): + """重置隐私协议同意状态,用于测试或重新显示隐私协议 + + Returns: + bool: 重置是否成功 + """ + return self._save_privacy_config(False) \ No newline at end of file diff --git a/source/core/managers/ui_manager.py b/source/core/managers/ui_manager.py new file mode 100644 index 0000000..1cfd24b --- /dev/null +++ b/source/core/managers/ui_manager.py @@ -0,0 +1,971 @@ +from PySide6.QtGui import QIcon, QAction, QFont, QCursor, QActionGroup +from PySide6.QtWidgets import QMessageBox, QMainWindow, QMenu, QPushButton +from PySide6.QtCore import Qt, QRect +import webbrowser +import os + +from utils import load_base64_image, msgbox_frame, resource_path +from data.config import APP_NAME, APP_VERSION, LOG_FILE +from core.ipv6_manager import IPv6Manager # 导入新的IPv6Manager类 + +class UIManager: + def __init__(self, main_window): + """初始化UI管理器 + + Args: + main_window: 主窗口实例,用于设置UI元素 + """ + self.main_window = main_window + # 使用getattr获取ui属性,如果不存在则为None + self.ui = getattr(main_window, 'ui', None) + self.debug_action = None + self.turbo_download_action = None + self.dev_menu = None + self.privacy_menu = None # 隐私协议菜单 + self.about_menu = None # 关于菜单 + self.about_btn = None # 关于按钮 + + # 获取主窗口的IPv6Manager实例 + self.ipv6_manager = getattr(main_window, 'ipv6_manager', None) + + def setup_ui(self): + """设置UI元素,包括窗口图标、标题和菜单""" + # 设置窗口图标 + import os + from utils import resource_path + icon_path = resource_path(os.path.join("IMG", "ICO", "icon.png")) + if os.path.exists(icon_path): + self.main_window.setWindowIcon(QIcon(icon_path)) + + # 获取当前离线模式状态 + is_offline_mode = False + if hasattr(self.main_window, 'offline_mode_manager'): + is_offline_mode = self.main_window.offline_mode_manager.is_in_offline_mode() + + # 设置窗口标题和UI标题标签 + mode_indicator = "[离线模式]" if is_offline_mode else "[在线模式]" + self.main_window.setWindowTitle(f"{APP_NAME} v{APP_VERSION} {mode_indicator}") + + # 更新UI中的标题标签 + if hasattr(self.main_window, 'ui') and hasattr(self.main_window.ui, 'title_label'): + self.main_window.ui.title_label.setText(f"{APP_NAME} v{APP_VERSION} {mode_indicator}") + + # 创建关于按钮 + self._create_about_button() + + # 设置菜单 + self._setup_help_menu() + self._setup_about_menu() # 新增关于菜单 + self._setup_settings_menu() + + def _create_about_button(self): + """创建"关于"按钮""" + if not self.ui or not hasattr(self.ui, 'menu_area'): + return + + # 获取菜单字体和样式 + menu_font = self._get_menu_font() + + # 创建关于按钮 + self.about_btn = QPushButton("关于", self.ui.menu_area) + self.about_btn.setObjectName(u"about_btn") + + # 获取帮助按钮的位置和样式 + help_btn_x = 0 + help_btn_width = 0 + if hasattr(self.ui, 'help_btn'): + help_btn_x = self.ui.help_btn.x() + help_btn_width = self.ui.help_btn.width() + + # 设置位置在"帮助"按钮右侧 + self.about_btn.setGeometry(QRect(help_btn_x + help_btn_width + 20, 1, 80, 28)) + self.about_btn.setFont(menu_font) + self.about_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + + # 复制帮助按钮的样式 + if hasattr(self.ui, 'help_btn'): + self.about_btn.setStyleSheet(self.ui.help_btn.styleSheet()) + else: + # 默认样式 + self.about_btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + color: white; + border: none; + text-align: left; + padding-left: 10px; + } + QPushButton:hover { + background-color: #F47A5B; + border-radius: 4px; + } + QPushButton:pressed { + background-color: #D25A3C; + border-radius: 4px; + } + """) + + def _setup_help_menu(self): + """设置"帮助"菜单""" + if not self.ui or not hasattr(self.ui, 'menu_2'): + return + + # 获取菜单字体 + menu_font = self._get_menu_font() + + # 创建菜单项 - 移除"项目主页",添加"常见问题"和"提交错误" + faq_action = QAction("常见问题", self.main_window) + faq_action.triggered.connect(self.open_faq_page) + faq_action.setFont(menu_font) + + report_issue_action = QAction("提交错误", self.main_window) + report_issue_action.triggered.connect(self.open_issues_page) + report_issue_action.setFont(menu_font) + + # 清除现有菜单项并添加新的菜单项 + self.ui.menu_2.clear() + self.ui.menu_2.addAction(faq_action) + self.ui.menu_2.addAction(report_issue_action) + + # 连接按钮点击事件,如果使用按钮式菜单 + if hasattr(self.ui, 'help_btn'): + # 按钮已经连接到显示菜单,不需要额外处理 + pass + + def _setup_about_menu(self): + """设置"关于"菜单""" + # 获取菜单字体 + menu_font = self._get_menu_font() + + # 创建关于菜单 + self.about_menu = QMenu("关于", self.main_window) + self.about_menu.setFont(menu_font) + + # 设置菜单样式 + font_family = menu_font.family() + menu_style = self._get_menu_style(font_family) + self.about_menu.setStyleSheet(menu_style) + + # 创建菜单项 + about_project_action = QAction("关于本项目", self.main_window) + about_project_action.setFont(menu_font) + about_project_action.triggered.connect(self.show_about_dialog) + + # 添加项目主页选项(从帮助菜单移动过来) + project_home_action = QAction("Github项目主页", self.main_window) + project_home_action.setFont(menu_font) + project_home_action.triggered.connect(self.open_project_home_page) + + # 添加加入QQ群选项 + qq_group_action = QAction("加入QQ群", self.main_window) + qq_group_action.setFont(menu_font) + qq_group_action.triggered.connect(self.open_qq_group) + + # 创建隐私协议菜单 + self._setup_privacy_menu() + + # 添加到关于菜单 + self.about_menu.addAction(about_project_action) + self.about_menu.addAction(project_home_action) + self.about_menu.addAction(qq_group_action) + self.about_menu.addSeparator() + self.about_menu.addMenu(self.privacy_menu) + + # 连接按钮点击事件 + if self.about_btn: + self.about_btn.clicked.connect(lambda: self.show_menu(self.about_menu, self.about_btn)) + + def _setup_privacy_menu(self): + """设置"隐私协议"菜单""" + # 获取菜单字体 + menu_font = self._get_menu_font() + + # 创建隐私协议子菜单 + self.privacy_menu = QMenu("隐私协议", self.main_window) + self.privacy_menu.setFont(menu_font) + + # 设置与其他菜单一致的样式 + font_family = menu_font.family() + menu_style = self._get_menu_style(font_family) + self.privacy_menu.setStyleSheet(menu_style) + + # 添加子选项 + view_privacy_action = QAction("查看完整隐私协议", self.main_window) + view_privacy_action.setFont(menu_font) + view_privacy_action.triggered.connect(self.open_privacy_policy) + + revoke_privacy_action = QAction("撤回隐私协议", self.main_window) + revoke_privacy_action.setFont(menu_font) + revoke_privacy_action.triggered.connect(self.revoke_privacy_agreement) + + # 添加到子菜单 + self.privacy_menu.addAction(view_privacy_action) + self.privacy_menu.addAction(revoke_privacy_action) + + def _get_menu_style(self, font_family): + """获取统一的菜单样式""" + return f""" + QMenu {{ + background-color: #E96948; + color: white; + font-family: "{font_family}"; + font-size: 14px; + font-weight: bold; + border: 1px solid #F47A5B; + padding: 8px; + border-radius: 6px; + margin-top: 2px; + }} + QMenu::item {{ + padding: 6px 20px 6px 15px; + background-color: transparent; + min-width: 120px; + color: white; + font-family: "{font_family}"; + font-size: 14px; + font-weight: bold; + }} + QMenu::item:selected {{ + background-color: #F47A5B; + border-radius: 4px; + }} + QMenu::separator {{ + height: 1px; + background-color: #F47A5B; + margin: 5px 15px; + }} + QMenu::item:checked {{ + background-color: #D25A3C; + border-radius: 4px; + }} + """ + + def _get_menu_font(self): + """获取菜单字体""" + font_family = "Arial" # 默认字体族 + + try: + from PySide6.QtGui import QFontDatabase + + # 尝试加载字体 + font_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fonts", "SmileySans-Oblique.ttf") + if os.path.exists(font_path): + font_id = QFontDatabase.addApplicationFont(font_path) + if font_id != -1: + font_family = QFontDatabase.applicationFontFamilies(font_id)[0] + + # 创建菜单字体 + menu_font = QFont(font_family, 14) + menu_font.setBold(True) + return menu_font + + except Exception as e: + print(f"加载字体失败: {e}") + # 返回默认字体 + menu_font = QFont(font_family, 14) + menu_font.setBold(True) + return menu_font + + def _setup_settings_menu(self): + """设置"设置"菜单""" + if not self.ui or not hasattr(self.ui, 'menu'): + return + + # 获取菜单字体 + menu_font = self._get_menu_font() + font_family = menu_font.family() + + # 创建工作模式子菜单 + self.work_mode_menu = QMenu("工作模式", self.main_window) + self.work_mode_menu.setFont(menu_font) + self.work_mode_menu.setStyleSheet(self._get_menu_style(font_family)) + + # 获取当前离线模式状态 + is_offline_mode = False + if hasattr(self.main_window, 'offline_mode_manager'): + is_offline_mode = self.main_window.offline_mode_manager.is_in_offline_mode() + + # 创建在线模式和离线模式选项 + self.online_mode_action = QAction("在线模式", self.main_window, checkable=True) + self.online_mode_action.setFont(menu_font) + self.online_mode_action.setChecked(not is_offline_mode) # 根据当前状态设置 + + self.offline_mode_action = QAction("离线模式", self.main_window, checkable=True) + self.offline_mode_action.setFont(menu_font) + self.offline_mode_action.setChecked(is_offline_mode) # 根据当前状态设置 + + # 将两个模式选项添加到同一个互斥组 + mode_group = QActionGroup(self.main_window) + mode_group.addAction(self.online_mode_action) + mode_group.addAction(self.offline_mode_action) + mode_group.setExclusive(True) # 确保只能选择一个模式 + + # 连接切换事件 + self.online_mode_action.triggered.connect(lambda: self.switch_work_mode("online")) + self.offline_mode_action.triggered.connect(lambda: self.switch_work_mode("offline")) + + # 添加到工作模式子菜单 + self.work_mode_menu.addAction(self.online_mode_action) + self.work_mode_menu.addAction(self.offline_mode_action) + + # 创建开发者选项子菜单 + self.dev_menu = QMenu("开发者选项", self.main_window) + self.dev_menu.setFont(menu_font) # 设置与UI_install.py中相同的字体 + + # 使用和主菜单相同的样式 + menu_style = self._get_menu_style(font_family) + self.dev_menu.setStyleSheet(menu_style) + + # 创建Debug子菜单 + self.debug_submenu = QMenu("Debug模式", self.main_window) + self.debug_submenu.setFont(menu_font) + self.debug_submenu.setStyleSheet(menu_style) + + # 创建hosts文件选项子菜单 + self.hosts_submenu = QMenu("hosts文件选项", self.main_window) + self.hosts_submenu.setFont(menu_font) + self.hosts_submenu.setStyleSheet(menu_style) + + # 添加IPv6支持选项 + self.ipv6_action = QAction("启用IPv6支持", self.main_window, checkable=True) + self.ipv6_action.setFont(menu_font) + + # 添加IPv6检测按钮,用于显示详细信息 + self.ipv6_test_action = QAction("测试IPv6连接", self.main_window) + self.ipv6_test_action.setFont(menu_font) + if self.ipv6_manager: + self.ipv6_test_action.triggered.connect(self.ipv6_manager.show_ipv6_details) + else: + self.ipv6_test_action.triggered.connect(self.show_ipv6_manager_not_ready) + + # 创建IPv6支持子菜单 + self.ipv6_submenu = QMenu("IPv6支持", self.main_window) + self.ipv6_submenu.setFont(menu_font) + self.ipv6_submenu.setStyleSheet(menu_style) + + # 检查配置中是否已启用IPv6 + config = getattr(self.main_window, 'config', {}) + ipv6_enabled = False + if isinstance(config, dict): + ipv6_enabled = config.get("ipv6_enabled", False) + + self.ipv6_action.setChecked(ipv6_enabled) + + # 连接IPv6支持切换事件 + self.ipv6_action.triggered.connect(self._handle_ipv6_toggle) + + # 将选项添加到IPv6子菜单 + self.ipv6_submenu.addAction(self.ipv6_action) + self.ipv6_submenu.addAction(self.ipv6_test_action) + + # 添加hosts子选项 + self.restore_hosts_action = QAction("还原软件备份的hosts文件", self.main_window) + self.restore_hosts_action.setFont(menu_font) + self.restore_hosts_action.triggered.connect(self.restore_hosts_backup) + + self.clean_hosts_action = QAction("手动删除软件添加的hosts条目", self.main_window) + self.clean_hosts_action.setFont(menu_font) + self.clean_hosts_action.triggered.connect(self.clean_hosts_entries) + + # 添加禁用自动还原hosts的选项 + self.disable_auto_restore_action = QAction("禁用关闭/重启自动还原hosts", self.main_window, checkable=True) + self.disable_auto_restore_action.setFont(menu_font) + + # 从配置中读取当前状态 + config = getattr(self.main_window, 'config', {}) + disable_auto_restore = False + if isinstance(config, dict): + disable_auto_restore = config.get("disable_auto_restore_hosts", False) + + self.disable_auto_restore_action.setChecked(disable_auto_restore) + self.disable_auto_restore_action.triggered.connect(self.toggle_disable_auto_restore_hosts) + + # 添加打开hosts文件选项 + self.open_hosts_action = QAction("打开hosts文件", self.main_window) + self.open_hosts_action.setFont(menu_font) + self.open_hosts_action.triggered.connect(self.open_hosts_file) + + # 添加到hosts子菜单 + self.hosts_submenu.addAction(self.disable_auto_restore_action) + self.hosts_submenu.addAction(self.restore_hosts_action) + self.hosts_submenu.addAction(self.clean_hosts_action) + self.hosts_submenu.addAction(self.open_hosts_action) + + # 创建Debug开关选项 + self.debug_action = QAction("Debug开关", self.main_window, checkable=True) + self.debug_action.setFont(menu_font) + + # 安全地获取config属性 + config = getattr(self.main_window, 'config', {}) + debug_mode = False + if isinstance(config, dict): + debug_mode = config.get("debug_mode", False) + + self.debug_action.setChecked(debug_mode) + + # 安全地连接toggle_debug_mode方法 + if hasattr(self.main_window, 'toggle_debug_mode'): + self.debug_action.triggered.connect(self.main_window.toggle_debug_mode) + + # 创建打开log文件选项 + self.open_log_action = QAction("打开log.txt", self.main_window) + self.open_log_action.setFont(menu_font) + # 初始状态根据debug模式设置启用状态 + self.open_log_action.setEnabled(debug_mode) + + # 连接打开log文件的事件 + self.open_log_action.triggered.connect(self.open_log_file) + + # 添加到Debug子菜单 + self.debug_submenu.addAction(self.debug_action) + self.debug_submenu.addAction(self.open_log_action) + + # 创建下载设置子菜单 + self.download_settings_menu = QMenu("下载设置", self.main_window) + self.download_settings_menu.setFont(menu_font) + self.download_settings_menu.setStyleSheet(menu_style) + + # "修改下载源"按钮移至下载设置菜单 + self.switch_source_action = QAction("修改下载源", self.main_window) + self.switch_source_action.setFont(menu_font) + self.switch_source_action.setEnabled(True) + self.switch_source_action.triggered.connect(self.show_under_development) + + # 添加下载线程设置选项 + self.thread_settings_action = QAction("下载线程设置", self.main_window) + self.thread_settings_action.setFont(menu_font) + # 连接到下载线程设置对话框 + self.thread_settings_action.triggered.connect(self.show_download_thread_settings) + + # 添加到下载设置子菜单 + self.download_settings_menu.addAction(self.switch_source_action) + self.download_settings_menu.addAction(self.thread_settings_action) + + # 添加到主菜单 + self.ui.menu.addMenu(self.work_mode_menu) # 添加工作模式子菜单 + self.ui.menu.addMenu(self.download_settings_menu) # 添加下载设置子菜单 + self.ui.menu.addSeparator() + self.ui.menu.addMenu(self.dev_menu) # 添加开发者选项子菜单 + + # 添加Debug子菜单到开发者选项菜单 + self.dev_menu.addMenu(self.debug_submenu) + self.dev_menu.addMenu(self.hosts_submenu) # 添加hosts文件选项子菜单 + self.dev_menu.addMenu(self.ipv6_submenu) # 添加IPv6支持子菜单 + + def _handle_ipv6_toggle(self, enabled): + """处理IPv6支持切换事件 + + Args: + enabled: 是否启用IPv6支持 + """ + if not self.ipv6_manager: + # 显示错误提示 + msg_box = self._create_message_box("错误", "\nIPv6管理器尚未初始化,请稍后再试。\n") + msg_box.exec() + # 恢复复选框状态 + self.ipv6_action.setChecked(not enabled) + return + + if enabled: + # 先显示警告提示 + warning_msg_box = self._create_message_box( + "警告", + "\n目前IPv6支持功能仍在测试阶段,可能会发生意料之外的bug!\n\n您确定需要启用吗?\n", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + response = warning_msg_box.exec() + + # 如果用户选择不启用,直接返回 + if response != QMessageBox.StandardButton.Yes: + # 恢复复选框状态 + self.ipv6_action.setChecked(False) + return + + # 显示正在校验IPv6的提示 + msg_box = self._create_message_box("IPv6检测", "\n正在校验是否支持IPv6,请稍候...\n") + msg_box.open() # 使用open而不是exec,这样不会阻塞UI + + # 处理消息队列,确保对话框显示 + from PySide6.QtCore import QCoreApplication + QCoreApplication.processEvents() + + # 检查IPv6是否可用 + ipv6_available = self.ipv6_manager.check_ipv6_availability() + + # 关闭提示对话框 + msg_box.accept() + + if not ipv6_available: + # 显示IPv6不可用的提示 + error_msg_box = self._create_message_box( + "IPv6不可用", + "\n未检测到可用的IPv6连接,无法启用IPv6支持。\n\n请确保您的网络环境支持IPv6且已正确配置。\n" + ) + error_msg_box.exec() + # 恢复复选框状态 + self.ipv6_action.setChecked(False) + return False + + # 使用IPv6Manager处理切换 + success = self.ipv6_manager.toggle_ipv6_support(enabled) + # 如果切换失败,恢复复选框状态 + if not success: + self.ipv6_action.setChecked(not enabled) + + def show_menu(self, menu, button): + """显示菜单 + + Args: + menu: 要显示的菜单 + button: 触发菜单的按钮 + """ + # 检查Ui_install中是否定义了show_menu方法 + if hasattr(self.ui, 'show_menu'): + # 如果存在,使用UI中定义的方法 + self.ui.show_menu(menu, button) + else: + # 否则,使用默认的弹出方法 + global_pos = button.mapToGlobal(button.rect().bottomLeft()) + menu.popup(global_pos) + + def open_project_home_page(self): + """打开项目主页""" + webbrowser.open("https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT") + + def open_github_page(self): + """打开项目GitHub页面""" + webbrowser.open("https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT") + + def open_faq_page(self): + """打开常见问题页面""" + import locale + # 根据系统语言选择FAQ页面 + system_lang = locale.getdefaultlocale()[0] + if system_lang and system_lang.startswith('zh'): + webbrowser.open("https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/FAQ.md") + else: + webbrowser.open("https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/FAQ-en.md") + + def open_issues_page(self): + """打开GitHub问题页面""" + webbrowser.open("https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/issues") + + def open_qq_group(self): + """打开QQ群链接""" + webbrowser.open("https://qm.qq.com/q/g9i04i5eec") + + def open_privacy_policy(self): + """打开完整隐私协议(在GitHub上)""" + webbrowser.open("https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/PRIVACY.md") + + def revoke_privacy_agreement(self): + """撤回隐私协议同意,并重启软件""" + # 创建确认对话框 + msg_box = self._create_message_box( + "确认操作", + "\n您确定要撤回隐私协议同意吗?\n\n撤回后软件将立即重启,您需要重新阅读并同意隐私协议。\n", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + + reply = msg_box.exec() + if reply == QMessageBox.StandardButton.Yes: + # 用户确认撤回 + try: + # 导入隐私管理器 + from core.privacy_manager import PrivacyManager + import sys + import subprocess + import os + + # 创建实例并重置隐私协议同意 + privacy_manager = PrivacyManager() + if privacy_manager.reset_privacy_agreement(): + # 显示重启提示 + restart_msg = self._create_message_box( + "操作成功", + "\n已成功撤回隐私协议同意。\n\n软件将立即重启。\n" + ) + restart_msg.exec() + + # 获取当前执行的Python解释器路径和脚本路径 + python_executable = sys.executable + script_path = os.path.abspath(sys.argv[0]) + + # 构建重启命令 + restart_cmd = [python_executable, script_path] + + # 启动新进程 + subprocess.Popen(restart_cmd) + + # 退出当前进程 + sys.exit(0) + else: + # 显示失败提示 + fail_msg = self._create_message_box( + "操作失败", + "\n撤回隐私协议同意失败。\n\n请检查应用权限或稍后再试。\n" + ) + fail_msg.exec() + except Exception as e: + # 显示错误提示 + error_msg = self._create_message_box( + "错误", + f"\n撤回隐私协议同意时发生错误:\n\n{str(e)}\n" + ) + error_msg.exec() + + def _create_message_box(self, title, message, buttons=QMessageBox.StandardButton.Ok): + """创建统一风格的消息框 + + Args: + title: 消息框标题 + message: 消息内容 + buttons: 按钮类型,默认为确定按钮 + + Returns: + QMessageBox: 配置好的消息框实例 + """ + msg_box = msgbox_frame( + f"{title} - {APP_NAME}", + message, + buttons, + ) + return msg_box + + def show_under_development(self): + """显示功能正在开发中的提示""" + msg_box = self._create_message_box("提示", "\n该功能正在开发中,敬请期待!\n") + msg_box.exec() + + def show_download_thread_settings(self): + """显示下载线程设置对话框""" + if hasattr(self.main_window, 'download_manager'): + self.main_window.download_manager.show_download_thread_settings() + else: + # 如果下载管理器不可用,显示错误信息 + msg_box = self._create_message_box("错误", "\n下载管理器未初始化,无法修改下载线程设置。\n") + msg_box.exec() + + def open_log_file(self): + """打开当前日志文件""" + try: + # 检查日志文件是否存在 + if os.path.exists(LOG_FILE): + # 获取日志文件大小 + file_size = os.path.getsize(LOG_FILE) + if file_size == 0: + msg_box = self._create_message_box("提示", f"\n当前日志文件 {os.path.basename(LOG_FILE)} 存在但为空。\n\n日志文件位置:{os.path.abspath(LOG_FILE)}") + msg_box.exec() + return + + # 根据文件大小决定是使用文本查看器还是直接打开 + if file_size > 1024 * 1024: # 大于1MB + # 文件较大,显示警告 + msg_box = self._create_message_box( + "警告", + f"\n日志文件较大 ({file_size / 1024 / 1024:.2f} MB),是否仍要打开?\n\n日志文件位置:{os.path.abspath(LOG_FILE)}", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if msg_box.exec() != QMessageBox.StandardButton.Yes: + return + + # 使用操作系统默认程序打开日志文件 + if os.name == 'nt': # Windows + os.startfile(LOG_FILE) + else: # macOS 和 Linux + import subprocess + subprocess.call(['xdg-open', LOG_FILE]) + else: + # 文件不存在,显示信息 + # 搜索log文件夹下所有可能的日志文件 + root_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + log_dir = os.path.join(root_dir, "log") + + # 如果log文件夹不存在,尝试创建它 + if not os.path.exists(log_dir): + try: + os.makedirs(log_dir, exist_ok=True) + msg_box = self._create_message_box( + "信息", + f"\n日志文件夹不存在,已创建新的日志文件夹:\n{log_dir}\n\n请在启用调试模式后重试。" + ) + msg_box.exec() + return + except Exception as e: + msg_box = self._create_message_box( + "错误", + f"\n创建日志文件夹失败:\n\n{str(e)}" + ) + msg_box.exec() + return + + # 搜索log文件夹中的日志文件 + try: + log_files = [f for f in os.listdir(log_dir) if f.startswith("log-") and f.endswith(".txt")] + except Exception as e: + msg_box = self._create_message_box( + "错误", + f"\n无法读取日志文件夹:\n\n{str(e)}" + ) + msg_box.exec() + return + + if log_files: + # 按照修改时间排序,获取最新的日志文件 + log_files.sort(key=lambda x: os.path.getmtime(os.path.join(log_dir, x)), reverse=True) + latest_log = os.path.join(log_dir, log_files[0]) + + # 获取最新日志文件的创建时间信息 + try: + log_datetime = "-".join(os.path.basename(latest_log)[4:-4].split("-")[:2]) + log_date = log_datetime.split("-")[0] + log_time = log_datetime.split("-")[1] if "-" in log_datetime else "未知时间" + date_info = f"日期: {log_date[:4]}-{log_date[4:6]}-{log_date[6:]}" + time_info = f"时间: {log_time[:2]}:{log_time[2:4]}:{log_time[4:]}" + except: + date_info = "日期未知 " + time_info = "时间未知" + + msg_box = self._create_message_box( + "信息", + f"\n当前日志文件 {os.path.basename(LOG_FILE)} 不存在。\n\n" + f"发现最新的日志文件: {os.path.basename(latest_log)}\n" + f"({date_info}{time_info})\n\n" + f"是否打开此文件?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + + if msg_box.exec() == QMessageBox.StandardButton.Yes: + if os.name == 'nt': # Windows + os.startfile(latest_log) + else: # macOS 和 Linux + import subprocess + subprocess.call(['xdg-open', latest_log]) + return + + # 如果没有找到任何日志文件或用户选择不打开最新的日志文件 + msg_box = self._create_message_box( + "信息", + f"\n没有找到有效的日志文件。\n\n" + f"预期的日志文件夹:{log_dir}\n\n" + f"请确认调试模式已启用,并执行一些操作后再查看日志。" + ) + msg_box.exec() + + except Exception as e: + msg_box = self._create_message_box("错误", f"\n处理日志文件时出错:\n\n{str(e)}\n\n文件位置:{os.path.abspath(LOG_FILE)}") + msg_box.exec() + + def restore_hosts_backup(self): + """还原软件备份的hosts文件""" + if hasattr(self.main_window, 'download_manager') and hasattr(self.main_window.download_manager, 'hosts_manager'): + try: + # 调用恢复hosts文件的方法 + result = self.main_window.download_manager.hosts_manager.restore() + + if result: + msg_box = self._create_message_box("成功", "\nhosts文件已成功还原为备份版本。\n") + else: + msg_box = self._create_message_box("警告", "\n还原hosts文件失败或没有找到备份文件。\n") + + msg_box.exec() + except Exception as e: + msg_box = self._create_message_box("错误", f"\n还原hosts文件时发生错误:\n\n{str(e)}\n") + msg_box.exec() + else: + msg_box = self._create_message_box("错误", "\n无法访问hosts管理器。\n") + msg_box.exec() + + def clean_hosts_entries(self): + """手动删除软件添加的hosts条目""" + if hasattr(self.main_window, 'download_manager') and hasattr(self.main_window.download_manager, 'hosts_manager'): + try: + # 调用清理hosts条目的方法,强制清理即使禁用了自动还原 + result = self.main_window.download_manager.hosts_manager.check_and_clean_all_entries(force_clean=True) + + if result: + msg_box = self._create_message_box("成功", "\n已成功清理软件添加的hosts条目。\n") + else: + msg_box = self._create_message_box("提示", "\n未发现软件添加的hosts条目或清理操作失败。\n") + + msg_box.exec() + except Exception as e: + msg_box = self._create_message_box("错误", f"\n清理hosts条目时发生错误:\n\n{str(e)}\n") + msg_box.exec() + else: + msg_box = self._create_message_box("错误", "\n无法访问hosts管理器。\n") + msg_box.exec() + + def open_hosts_file(self): + """打开系统hosts文件""" + try: + # 获取hosts文件路径 + hosts_path = os.path.join(os.environ['SystemRoot'], 'System32', 'drivers', 'etc', 'hosts') + + # 检查文件是否存在 + if os.path.exists(hosts_path): + # 使用操作系统默认程序打开hosts文件 + if os.name == 'nt': # Windows + # 尝试以管理员权限打开记事本编辑hosts文件 + try: + # 使用PowerShell以管理员身份启动记事本 + subprocess.Popen(["powershell", "Start-Process", "notepad", hosts_path, "-Verb", "RunAs"]) + except Exception as e: + # 如果失败,尝试直接打开 + os.startfile(hosts_path) + else: # macOS 和 Linux + import subprocess + subprocess.call(['xdg-open', hosts_path]) + else: + msg_box = self._create_message_box("错误", f"\nhosts文件不存在:\n{hosts_path}\n") + msg_box.exec() + except Exception as e: + msg_box = self._create_message_box("错误", f"\n打开hosts文件时发生错误:\n\n{str(e)}\n") + msg_box.exec() + + def toggle_disable_auto_restore_hosts(self, checked): + """切换禁用自动还原hosts的状态 + + Args: + checked: 是否禁用自动还原 + """ + if hasattr(self.main_window, 'download_manager') and hasattr(self.main_window.download_manager, 'hosts_manager'): + try: + # 调用HostsManager的方法设置自动还原标志 + result = self.main_window.download_manager.hosts_manager.set_auto_restore_disabled(checked) + + if result: + # 同时更新内部配置,确保立即生效 + if hasattr(self.main_window, 'config'): + self.main_window.config['disable_auto_restore_hosts'] = checked + + # 显示成功提示 + status = "禁用" if checked else "启用" + msg_box = self._create_message_box( + "设置已更新", + f"\n已{status}关闭/重启时自动还原hosts。\n\n{'hosts将被保留' if checked else 'hosts将在关闭时自动还原'}。\n" + ) + msg_box.exec() + else: + # 如果设置失败,恢复复选框状态 + self.disable_auto_restore_action.setChecked(not checked) + msg_box = self._create_message_box( + "设置失败", + "\n更新设置时发生错误,请稍后再试。\n" + ) + msg_box.exec() + except Exception as e: + # 如果发生异常,恢复复选框状态 + self.disable_auto_restore_action.setChecked(not checked) + msg_box = self._create_message_box( + "错误", + f"\n更新设置时发生异常:\n\n{str(e)}\n" + ) + msg_box.exec() + else: + # 如果hosts管理器不可用,恢复复选框状态 + self.disable_auto_restore_action.setChecked(not checked) + msg_box = self._create_message_box( + "错误", + "\nhosts管理器不可用,无法更新设置。\n" + ) + msg_box.exec() + + def show_about_dialog(self): + """显示关于对话框""" + about_text = f""" +
{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, '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/managers/window_manager.py b/source/core/managers/window_manager.py new file mode 100644 index 0000000..ae55bc9 --- /dev/null +++ b/source/core/managers/window_manager.py @@ -0,0 +1,149 @@ +from PySide6.QtCore import Qt, QPoint, QRect, QSize +from PySide6.QtGui import QPainterPath, QRegion + +class WindowManager: + """窗口管理器类,用于处理窗口的基本行为,如拖拽、调整大小和圆角设置""" + + def __init__(self, parent_window): + """初始化窗口管理器 + + Args: + parent_window: 父窗口实例 + """ + self.window = parent_window + self.ui = parent_window.ui + + # 拖动窗口相关变量 + self._drag_position = QPoint() + self._is_dragging = False + + # 窗口比例 + self.aspect_ratio = 16 / 9 + self.updateRoundedCorners = True + + # 设置圆角窗口 + self.setRoundedCorners() + + def setRoundedCorners(self): + """设置窗口圆角""" + # 实现圆角窗口 + path = QPainterPath() + path.addRoundedRect(self.window.rect(), 20, 20) + mask = QRegion(path.toFillPolygon().toPolygon()) + self.window.setMask(mask) + + # 更新resize事件时更新圆角 + self.updateRoundedCorners = True + + def handle_mouse_press(self, event): + """处理鼠标按下事件 + + Args: + event: 鼠标事件 + """ + if event.button() == Qt.MouseButton.LeftButton: + # 只有当鼠标在标题栏区域时才可以拖动 + if hasattr(self.ui, 'title_bar') and self.ui.title_bar.geometry().contains(event.position().toPoint()): + self._is_dragging = True + self._drag_position = event.globalPosition().toPoint() - self.window.frameGeometry().topLeft() + event.accept() + + def handle_mouse_move(self, event): + """处理鼠标移动事件 + + Args: + event: 鼠标事件 + """ + if event.buttons() & Qt.MouseButton.LeftButton and self._is_dragging: + self.window.move(event.globalPosition().toPoint() - self._drag_position) + event.accept() + + def handle_mouse_release(self, event): + """处理鼠标释放事件 + + Args: + event: 鼠标事件 + """ + if event.button() == Qt.MouseButton.LeftButton: + self._is_dragging = False + event.accept() + + def handle_resize(self, event): + """当窗口大小改变时更新圆角和维持纵横比 + + Args: + event: 窗口大小改变事件 + """ + # 计算基于当前宽度的合适高度,以维持16:9比例 + new_width = event.size().width() + new_height = int(new_width / self.aspect_ratio) + + if new_height != event.size().height(): + # 阻止变形,保持比例 + self.window.resize(new_width, new_height) + + # 更新主容器大小 + if hasattr(self.ui, 'main_container'): + self.ui.main_container.setGeometry(0, 0, new_width, new_height) + + # 更新内容容器大小 + if hasattr(self.ui, 'content_container'): + self.ui.content_container.setGeometry(0, 0, new_width, new_height) + + # 更新标题栏宽度和高度 + if hasattr(self.ui, 'title_bar'): + self.ui.title_bar.setGeometry(0, 0, new_width, 35) + + # 更新菜单区域 + if hasattr(self.ui, 'menu_area'): + self.ui.menu_area.setGeometry(0, 35, new_width, 30) + + # 更新内容区域大小 + if hasattr(self.ui, 'inner_content'): + self.ui.inner_content.setGeometry(0, 65, new_width, new_height - 65) + + # 更新背景图大小 + if hasattr(self.ui, 'Mainbg'): + self.ui.Mainbg.setGeometry(0, 0, new_width, new_height - 65) + + if hasattr(self.ui, 'loadbg'): + self.ui.loadbg.setGeometry(0, 0, new_width, new_height - 65) + + # 调整按钮位置 - 固定在右侧 + right_margin = 20 # 减小右边距,使按钮更靠右 + if hasattr(self.ui, 'button_container'): + btn_width = 211 # 扩大后的容器宽度 + btn_height = 111 # 扩大后的容器高度 + x_pos = new_width - btn_width - right_margin + y_pos = int((new_height - 65) * 0.18) - 10 # 从0.28改为0.18,向上移动 + self.ui.button_container.setGeometry(x_pos, y_pos, btn_width, btn_height) + + # 添加禁/启用补丁按钮容器的位置调整 + if hasattr(self.ui, 'toggle_patch_container'): + btn_width = 211 # 扩大后的容器宽度 + btn_height = 111 # 扩大后的容器高度 + x_pos = new_width - btn_width - right_margin + y_pos = int((new_height - 65) * 0.36) - 10 # 从0.46改为0.36,向上移动 + self.ui.toggle_patch_container.setGeometry(x_pos, y_pos, btn_width, btn_height) + + # 添加卸载补丁按钮容器的位置调整 + if hasattr(self.ui, 'uninstall_container'): + btn_width = 211 # 扩大后的容器宽度 + btn_height = 111 # 扩大后的容器高度 + x_pos = new_width - btn_width - right_margin + y_pos = int((new_height - 65) * 0.54) - 10 # 从0.64改为0.54,向上移动 + self.ui.uninstall_container.setGeometry(x_pos, y_pos, btn_width, btn_height) + + if hasattr(self.ui, 'exit_container'): + btn_width = 211 # 扩大后的容器宽度 + btn_height = 111 # 扩大后的容器高度 + x_pos = new_width - btn_width - right_margin + y_pos = int((new_height - 65) * 0.72) - 10 # 从0.82改为0.72,向上移动 + self.ui.exit_container.setGeometry(x_pos, y_pos, btn_width, btn_height) + + # 更新圆角 + if hasattr(self, 'updateRoundedCorners') and self.updateRoundedCorners: + path = QPainterPath() + path.addRoundedRect(self.window.rect(), 20, 20) + mask = QRegion(path.toFillPolygon().toPolygon()) + self.window.setMask(mask) \ No newline at end of file diff --git a/source/core/offline_mode_manager.py b/source/core/offline_mode_manager.py index a690fed..65a2ad6 100644 --- a/source/core/offline_mode_manager.py +++ b/source/core/offline_mode_manager.py @@ -29,6 +29,11 @@ class OfflineModeManager: self.offline_patches = {} # 存储离线补丁信息 {补丁名称: 文件路径} self.is_offline_mode = False self.installed_games = [] # 跟踪本次实际安装的游戏 + # 保持对哈希线程的引用,避免运行中被销毁 + self.hash_thread = None + # 解压线程与进度窗口引用,避免运行中被销毁,且确保UI可更新 + self.extraction_thread = None + self.extraction_progress_window = None def _is_debug_mode(self): """检查是否处于调试模式 @@ -523,6 +528,10 @@ class OfflineModeManager: # 保存引用以便后续使用 self.hash_thread = hash_thread + try: + self.hash_thread.finished.connect(lambda: setattr(self, 'hash_thread', None)) + except Exception: + pass hash_thread.start() def _on_hash_check_finished(self, result, game_version, install_tasks): @@ -586,16 +595,30 @@ class OfflineModeManager: self.process_next_offline_install_task(install_tasks) def _on_extraction_finished_with_hash_check(self, success, error_message, game_version, install_tasks): - """解压完成后进行哈希校验 + """解压完成后进行哈希校验(后台线程回调)""" + # 关闭解压进度窗口 + 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 - Args: - success: 是否解压成功 - error_message: 错误信息 - game_version: 游戏版本 - install_tasks: 剩余的安装任务列表 - """ - # 这个方法已不再使用,保留为空以兼容旧版本调用 - pass + # 清理线程引用 + self.extraction_thread = None + + if not success: + # 解压失败,提示并继续下一个任务 + msgbox_frame( + f"安装错误 - {self.app_name}", + error_message or f"\n{game_version} 的安装过程中发生错误。\n", + QMessageBox.StandardButton.Ok + ).exec() + self.process_next_offline_install_task(install_tasks) + return + + # 解压成功,进入安装后哈希校验 + self._perform_hash_check(game_version, install_tasks) def on_extraction_thread_finished(self, success, error_message, game_version, install_tasks): """解压线程完成后的处理(兼容旧版本) @@ -823,14 +846,12 @@ class OfflineModeManager: if debug_mode: logger.debug(f"DEBUG: 有未找到离线补丁文件的游戏: {self.missing_offline_patches}") - # 先显示已安装的结果 + # 不再先弹出安装结果,直接询问是否联网继续 if self.installed_games: installed_msg = f"已成功安装以下补丁:\n\n{chr(10).join(self.installed_games)}\n\n" else: installed_msg = "" - - # 使用QTimer延迟显示询问对话框,确保安装结果窗口先显示并关闭 - QTimer.singleShot(500, lambda: self._show_missing_patches_dialog(installed_msg)) + QTimer.singleShot(100, lambda: self._show_missing_patches_dialog(installed_msg)) else: # 恢复UI状态 self.main_window.setEnabled(True) @@ -846,104 +867,66 @@ class OfflineModeManager: logger.debug(f"DEBUG: 补丁文件: {patch_file}") logger.debug(f"DEBUG: 游戏目录: {game_folder}") - # 显示安装进度窗口 - self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="offline_installation", is_offline=True) - + # 使用后台线程进行解压,避免阻塞UI try: # 确保游戏目录存在 os.makedirs(game_folder, exist_ok=True) - - # 从GAME_INFO获取目标文件名 - target_filename = os.path.basename(GAME_INFO[game_version]["install_path"]) - if not target_filename: - raise ValueError(f"未知的游戏版本或配置错误: {game_version}") - # 直接从源7z文件解压 - with py7zr.SevenZipFile(patch_file, mode="r") as archive: - file_list = archive.getnames() - target_file_in_archive = None - - # 查找压缩包中的目标文件 - for f_path in file_list: - if target_filename in f_path: - target_file_in_archive = f_path - break + # 创建非阻塞的解压进度窗口 + self.extraction_progress_window = self.main_window.create_extraction_progress_window() + try: + self.extraction_progress_window.show() + QtWidgets.QApplication.processEvents() + except Exception: + pass - if not target_file_in_archive: - raise FileNotFoundError(f"在压缩包 {os.path.basename(patch_file)} 中未找到目标文件 {target_filename}") + # 启动解压线程 + self.extraction_thread = self.main_window.create_extraction_thread( + patch_file, game_folder, plugin_path, game_version + ) - # 使用临时目录来解压单个文件 - with tempfile.TemporaryDirectory() as temp_dir: - archive.extract(path=temp_dir, targets=[target_file_in_archive]) - extracted_file_path = os.path.join(temp_dir, target_file_in_archive) - - # 最终目标路径 - target_path = os.path.join(game_folder, target_filename) - - # 复制到游戏目录 - shutil.copy2(extracted_file_path, target_path) + # 连接进度更新到窗口控件 + if 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) + ) + ) - if debug_mode: - logger.debug(f"DEBUG: 已解压并复制文件到 {target_path}") + # 完成后进入哈希校验 + self.extraction_thread.finished.connect( + lambda success, error, gv: self._on_extraction_finished_with_hash_check(success, error, gv, install_tasks) + ) - # 对于NEKOPARA After,还需要处理签名文件 - if game_version == "NEKOPARA After": - sig_filename = f"{target_filename}.sig" - sig_file_in_archive = None - - for f_path in file_list: - if sig_filename in f_path: - sig_file_in_archive = f_path - break - - if sig_file_in_archive: - try: - archive.extract(path=temp_dir, targets=[sig_file_in_archive]) - extracted_sig_path = os.path.join(temp_dir, sig_file_in_archive) - sig_target = os.path.join(game_folder, sig_filename) - shutil.copy2(extracted_sig_path, sig_target) - if debug_mode: - logger.debug(f"DEBUG: 已解压并复制签名文件到 {sig_target}") - except py7zr.exceptions.CrcError as sig_e: - if debug_mode: - logger.warning(f"DEBUG: 签名文件 '{sig_e.filename}' CRC校验失败,已忽略此文件。") + # 线程结束时清理引用 + try: + self.extraction_thread.finished.connect(lambda *_: setattr(self, 'extraction_thread', None)) + except Exception: + pass - # 进行安装后的哈希校验 - self._perform_hash_check(game_version, install_tasks) + self.extraction_thread.start() - except py7zr.exceptions.CrcError as e: - if debug_mode: - logger.error(f"DEBUG: CRC校验失败,文件可能已损坏: {e}") - logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}") - - self.main_window.close_hash_msg_box() - - msgbox_frame( - f"安装错误 - {self.app_name}", - f"\n补丁文件 {os.path.basename(patch_file)} 在解压时CRC校验失败。\n" - f"这通常意味着文件已损坏,请尝试重新下载该文件。\n\n" - f"游戏: {game_version}\n" - f"错误文件: {e.filename}\n\n" - "跳过此游戏的安装。", - QMessageBox.StandardButton.Ok - ).exec() - - self.process_next_offline_install_task(install_tasks) except Exception as e: if debug_mode: logger.error(f"DEBUG: 离线安装任务处理失败: {e}") logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}") - - # 关闭安装进度窗口 - self.main_window.close_hash_msg_box() - + + # 关闭可能存在的解压进度窗口 + try: + if self.extraction_progress_window and self.extraction_progress_window.isVisible(): + self.extraction_progress_window.close() + except Exception: + pass + self.extraction_progress_window = None + # 显示错误消息 msgbox_frame( 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) @@ -1004,4 +987,9 @@ class OfflineModeManager: # 恢复UI状态 self.main_window.setEnabled(True) - self.main_window.ui.start_install_text.setText("开始安装") \ No newline at end of file + self.main_window.ui.start_install_text.setText("开始安装") + # 用户拒绝联网后,再显示本次安装结果 + try: + QTimer.singleShot(100, self.main_window.show_result) + except Exception: + pass \ No newline at end of file diff --git a/source/core/patch_detector.py b/source/core/patch_detector.py index 07206ed..5742487 100644 --- a/source/core/patch_detector.py +++ b/source/core/patch_detector.py @@ -269,7 +269,19 @@ class PatchDetector: self.main_window.setEnabled(True) self.main_window.ui.start_install_text.setText("开始安装") - QTimer.singleShot(100, self.main_window.show_result) + # 当处于离线模式且存在缺失补丁需要联网继续时,暂不立即显示安装结果 + should_defer_result = False + try: + offline_mgr = getattr(self.main_window, 'offline_mode_manager', None) + if offline_mgr and offline_mgr.is_in_offline_mode(): + missing_list = getattr(offline_mgr, 'missing_offline_patches', []) + if missing_list: + should_defer_result = True + except Exception: + should_defer_result = False + + if not should_defer_result: + QTimer.singleShot(100, self.main_window.show_result) def on_offline_pre_hash_finished(self, updated_status, game_dirs): self.main_window.installed_status = updated_status diff --git a/source/main_window.py b/source/main_window.py index 83c0856..5e1b5fd 100644 --- a/source/main_window.py +++ b/source/main_window.py @@ -118,6 +118,9 @@ class MainWindow(QMainWindow): self.version_warning = False # 添加版本警告标志 self.install_button_enabled = True # 默认启用安装按钮 self.progress_window = None + # 线程持有引用,避免 QThread 在运行中被销毁 + self.pre_hash_thread = None + self.hash_thread = None # after 校验线程引用(由 PatchDetector 赋值) # 设置关闭按钮事件连接 if hasattr(self.ui, 'close_btn'): @@ -477,7 +480,61 @@ class MainWindow(QMainWindow): if event: event.ignore() return - + + # 在退出前优雅地清理后台线程,避免 QThread 在运行中被销毁 + def _graceful_stop(thread_obj, name="thread", timeout_ms=2000): + try: + if thread_obj and hasattr(thread_obj, 'isRunning') and thread_obj.isRunning(): + # 首选等待自然结束 + if hasattr(thread_obj, 'requestInterruption'): + try: + thread_obj.requestInterruption() + except Exception: + pass + thread_obj.wait(timeout_ms) + # 仍未退出时,最后手段终止 + if thread_obj.isRunning(): + try: + thread_obj.terminate() + except Exception: + pass + thread_obj.wait(1000) + except Exception: + pass + + # 清理主窗口直接持有的线程 + _graceful_stop(getattr(self, 'pre_hash_thread', None), 'pre_hash_thread') + _graceful_stop(getattr(self, 'hash_thread', None), 'hash_thread') + + # 清理离线管理器中的线程 + try: + if hasattr(self, 'offline_mode_manager') and self.offline_mode_manager: + _graceful_stop(getattr(self.offline_mode_manager, 'hash_thread', None), 'offline_hash_thread') + _graceful_stop(getattr(self.offline_mode_manager, 'extraction_thread', None), 'extraction_thread') + except Exception: + pass + + # 清理配置获取线程 + try: + if hasattr(self, 'config_manager') and hasattr(self.config_manager, 'config_fetch_thread'): + _graceful_stop(self.config_manager.config_fetch_thread, 'config_fetch_thread', 1000) + except Exception: + pass + + # 清理游戏识别线程 + try: + if hasattr(self, 'game_detector') and hasattr(self.game_detector, 'detection_thread'): + _graceful_stop(self.game_detector.detection_thread, 'detection_thread', 1000) + except Exception: + pass + + # 清理补丁检查线程 + try: + if hasattr(self, 'patch_detector') and hasattr(self.patch_detector, 'patch_check_thread'): + _graceful_stop(self.patch_detector.patch_check_thread, 'patch_check_thread', 1000) + except Exception: + pass + # 恢复hosts文件(如果未禁用自动还原) self.download_manager.hosts_manager.restore() @@ -640,11 +697,16 @@ class MainWindow(QMainWindow): install_paths = self.download_manager.get_install_paths() # 使用异步方式进行哈希预检查 - hash_thread = self.patch_detector.create_hash_thread("pre", install_paths) - hash_thread.pre_finished.connect( + self.pre_hash_thread = self.patch_detector.create_hash_thread("pre", install_paths) + self.pre_hash_thread.pre_finished.connect( lambda updated_status: self.on_pre_hash_finished(updated_status, game_dirs) ) - hash_thread.start() + # 在线程自然结束时清理引用 + try: + self.pre_hash_thread.finished.connect(lambda: setattr(self, 'pre_hash_thread', None)) + except Exception: + pass + self.pre_hash_thread.start() def on_pre_hash_finished(self, updated_status, game_dirs): """哈希预检查完成后的回调""" diff --git a/source/workers/extraction_thread.py b/source/workers/extraction_thread.py index 4897014..f348020 100644 --- a/source/workers/extraction_thread.py +++ b/source/workers/extraction_thread.py @@ -1,7 +1,7 @@ import os import shutil import py7zr -from PySide6.QtCore import QThread, Signal, QCoreApplication +from PySide6.QtCore import QThread, Signal from data.config import PLUGIN, GAME_INFO class ExtractionThread(QThread): @@ -20,33 +20,37 @@ class ExtractionThread(QThread): try: # 确保游戏目录存在 os.makedirs(self.game_folder, exist_ok=True) - - # 发送初始进度信号 - self.progress.emit(0, f"开始处理 {self.game_version} 的补丁文件...") - # 确保UI更新 - QCoreApplication.processEvents() - + + def update_progress(percent: int, message: str): + try: + self.progress.emit(percent, message) + except Exception: + pass + + update_progress(0, f"开始处理 {self.game_version} 的补丁文件...") + + # 支持外部请求中断 + if self.isInterruptionRequested(): + self.finished.emit(False, "操作已取消", self.game_version) + return + # 如果提供了已解压文件路径,直接使用它 if self.extracted_path and os.path.exists(self.extracted_path): - # 发送进度信号 - self.progress.emit(20, f"正在复制 {self.game_version} 的补丁文件...") - QCoreApplication.processEvents() - + update_progress(20, f"正在复制 {self.game_version} 的补丁文件...\n(在此过程中可能会卡顿或无响应,请不要关闭软件)") + # 直接复制已解压的文件到游戏目录 target_file = os.path.join(self.game_folder, os.path.basename(self.plugin_path)) shutil.copy(self.extracted_path, target_file) - - # 发送进度信号 - self.progress.emit(60, f"正在完成 {self.game_version} 的补丁安装...") - QCoreApplication.processEvents() - + + update_progress(60, f"正在完成 {self.game_version} 的补丁安装...") + # 对于NEKOPARA After,还需要复制签名文件 if self.game_version == "NEKOPARA After": # 从已解压文件的目录中获取签名文件 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 os.path.exists(sig_path): shutil.copy(sig_path, self.game_folder) @@ -54,93 +58,79 @@ class ExtractionThread(QThread): # 如果签名文件不存在,则使用原始路径 sig_path = os.path.join(PLUGIN, GAME_INFO[self.game_version]["sig_path"]) shutil.copy(sig_path, self.game_folder) - - # 发送完成进度信号 - self.progress.emit(100, f"{self.game_version} 补丁文件处理完成") - QCoreApplication.processEvents() - else: - # 如果没有提供已解压文件路径,直接解压到游戏目录 - # 获取目标文件名 - target_filename = os.path.basename(self.plugin_path) - target_path = os.path.join(self.game_folder, target_filename) - - # 发送进度信号 - self.progress.emit(10, f"正在打开 {self.game_version} 的补丁压缩包...") - QCoreApplication.processEvents() - - # 使用7z解压 - with py7zr.SevenZipFile(self._7z_path, mode="r") as archive: - # 获取压缩包内的文件列表 - file_list = archive.getnames() - - # 发送进度信号 - self.progress.emit(20, f"正在分析 {self.game_version} 的补丁文件...") - QCoreApplication.processEvents() - - # 解析压缩包内的文件结构 - 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: - raise FileNotFoundError(f"在压缩包中未找到目标文件 {target_filename}") - - # 发送进度信号 - self.progress.emit(30, f"正在解压 {self.game_version} 的补丁文件...") - QCoreApplication.processEvents() - - # 创建一个临时目录用于解压单个文件 - import tempfile - with tempfile.TemporaryDirectory() as temp_dir: - # 解压特定文件到临时目录 - archive.extract(path=temp_dir, targets=[target_file_in_archive]) - - # 发送进度信号 - self.progress.emit(60, f"正在复制 {self.game_version} 的补丁文件...") - QCoreApplication.processEvents() - - # 找到解压后的文件 - extracted_file_path = os.path.join(temp_dir, target_file_in_archive) - - # 复制到目标位置 - shutil.copy2(extracted_file_path, target_path) - - # 发送进度信号 - self.progress.emit(80, f"正在完成 {self.game_version} 的补丁安装...") - QCoreApplication.processEvents() - - # 对于NEKOPARA After,还需要复制签名文件 - if self.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) + + update_progress(100, f"{self.game_version} 补丁文件处理完成") + self.finished.emit(True, "", self.game_version) + return + + # 否则解压源压缩包到临时目录,再复制目标文件 + target_filename = os.path.basename(self.plugin_path) + target_path = os.path.join(self.game_folder, target_filename) + + update_progress(10, f"正在打开 {self.game_version} 的补丁压缩包...") + + with py7zr.SevenZipFile(self._7z_path, mode="r") as archive: + # 获取压缩包内的文件列表 + file_list = archive.getnames() + + update_progress(20, f"正在分析 {self.game_version} 的补丁文件...") + + # 查找压缩包内的目标文件 + 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: + raise FileNotFoundError(f"在压缩包中未找到目标文件 {target_filename}") + + update_progress(30, f"正在解压 {self.game_version} 的补丁文件...\n(在此过程中可能会卡顿或无响应,请不要关闭软件)") + + import tempfile + with tempfile.TemporaryDirectory() as temp_dir: + # 解压特定文件到临时目录 + archive.extract(path=temp_dir, targets=[target_file_in_archive]) + + update_progress(60, f"正在复制 {self.game_version} 的补丁文件...") + + # 找到解压后的文件 + extracted_file_path = os.path.join(temp_dir, target_file_in_archive) + + # 复制到目标位置 + shutil.copy2(extracted_file_path, target_path) + + update_progress(80, f"正在完成 {self.game_version} 的补丁安装...") + + # 对于NEKOPARA After,还需要复制签名文件 + if self.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(self.game_folder, sig_filename) + shutil.copy2(extracted_sig_path, sig_target) + else: + # 如果签名文件不存在,则使用原始路径 + sig_path = os.path.join(PLUGIN, GAME_INFO[self.game_version]["sig_path"]) + if os.path.exists(sig_path): sig_target = os.path.join(self.game_folder, sig_filename) - shutil.copy2(extracted_sig_path, sig_target) - else: - # 如果签名文件不存在,则使用原始路径 - sig_path = os.path.join(PLUGIN, GAME_INFO[self.game_version]["sig_path"]) - if os.path.exists(sig_path): - sig_target = os.path.join(self.game_folder, sig_filename) - shutil.copy2(sig_path, sig_target) - - # 发送完成进度信号 - self.progress.emit(100, f"{self.game_version} 补丁文件解压完成") - QCoreApplication.processEvents() - + shutil.copy2(sig_path, sig_target) + + update_progress(100, f"{self.game_version} 补丁文件解压完成") self.finished.emit(True, "", self.game_version) except (py7zr.Bad7zFile, FileNotFoundError, Exception) as e: - self.progress.emit(100, f"处理 {self.game_version} 的补丁文件失败") - QCoreApplication.processEvents() + 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 b856a16..f49a5cc 100644 --- a/source/workers/hash_thread.py +++ b/source/workers/hash_thread.py @@ -43,6 +43,8 @@ class HashThread(QThread): status_copy = self.installed_status.copy() for game_version, install_path in self.install_paths.items(): + if self.isInterruptionRequested(): + break if not os.path.exists(install_path): status_copy[game_version] = False if debug_mode: @@ -57,8 +59,17 @@ class HashThread(QThread): # 当没有预期哈希值时,保持当前状态不变 continue + # 分块读取,避免大文件一次性读取内存 + hash_obj = hashlib.sha256() with open(install_path, "rb") as f: - file_hash = hashlib.sha256(f.read()).hexdigest() + while True: + if self.isInterruptionRequested(): + break + chunk = f.read(1024 * 1024) + if not chunk: + break + hash_obj.update(chunk) + file_hash = hash_obj.hexdigest() if debug_mode: logger.debug(f"DEBUG: 哈希预检查 - {game_version}") @@ -86,6 +97,8 @@ class HashThread(QThread): result = {"passed": True, "game": "", "message": ""} for game_version, install_path in self.install_paths.items(): + if self.isInterruptionRequested(): + break if not os.path.exists(install_path): if debug_mode: logger.debug(f"DEBUG: 哈希后检查 - {game_version} 补丁文件不存在: {install_path}") @@ -99,8 +112,17 @@ class HashThread(QThread): # 当没有预期哈希值时,跳过检查 continue + # 分块读取,避免大文件一次性读取内存 + hash_obj = hashlib.sha256() with open(install_path, "rb") as f: - file_hash = hashlib.sha256(f.read()).hexdigest() + while True: + if self.isInterruptionRequested(): + break + chunk = f.read(1024 * 1024) + if not chunk: + break + hash_obj.update(chunk) + file_hash = hash_obj.hexdigest() if debug_mode: logger.debug(f"DEBUG: 哈希后检查 - {game_version}") @@ -167,9 +189,7 @@ class OfflineHashVerifyThread(QThread): if not expected_hash: logger.warning(f"DEBUG: 未找到 {self.game_version} 的预期哈希值") - # 确保发送100%进度信号,以便UI更新 self.progress.emit(100) - QApplication.processEvents() self.finished.emit(False, f"未找到 {self.game_version} 的预期哈希值", "") return @@ -183,9 +203,7 @@ class OfflineHashVerifyThread(QThread): if not os.path.exists(self.file_path): if debug_mode: logger.warning(f"DEBUG: 补丁文件不存在: {self.file_path}") - # 确保发送100%进度信号,以便UI更新 self.progress.emit(100) - QApplication.processEvents() self.finished.emit(False, f"补丁文件不存在: {self.file_path}", "") return @@ -197,9 +215,7 @@ class OfflineHashVerifyThread(QThread): if file_size == 0: if debug_mode: logger.warning(f"DEBUG: 补丁文件大小为0,无效文件") - # 确保发送100%进度信号,以便UI更新 self.progress.emit(100) - QApplication.processEvents() self.finished.emit(False, "补丁文件大小为0,无效文件", "") return @@ -233,7 +249,6 @@ class OfflineHashVerifyThread(QThread): if debug_mode: logger.warning(f"DEBUG: 未知的游戏版本: {self.game_version}") self.progress.emit(100) - QApplication.processEvents() self.finished.emit(False, f"未知的游戏版本: {self.game_version}", "") return @@ -284,7 +299,6 @@ class OfflineHashVerifyThread(QThread): if debug_mode: logger.warning(f"DEBUG: 未找到解压后的补丁文件") self.progress.emit(100) - QApplication.processEvents() self.finished.emit(False, "未找到解压后的补丁文件", "") return else: @@ -331,9 +345,7 @@ class OfflineHashVerifyThread(QThread): logger.debug(f"DEBUG: 文件: {files}") if not os.path.exists(patch_file): - # 确保发送100%进度信号,以便UI更新 self.progress.emit(100) - QApplication.processEvents() self.finished.emit(False, f"未找到解压后的补丁文件", "") return @@ -352,7 +364,12 @@ class OfflineHashVerifyThread(QThread): with open(patch_file, "rb") as f: bytes_read = 0 - while chunk := f.read(chunk_size): + while True: + if self.isInterruptionRequested(): + break + chunk = f.read(chunk_size) + if not chunk: + break hash_obj.update(chunk) bytes_read += len(chunk) # 计算进度 (70-95%) @@ -366,8 +383,6 @@ class OfflineHashVerifyThread(QThread): # 发送进度信号 - 100% self.progress.emit(100) - # 确保UI更新 - QApplication.processEvents() if debug_mode: logger.debug(f"DEBUG: 补丁文件 {patch_file} 哈希值验证: {'成功' if result else '失败'}") @@ -382,26 +397,20 @@ class OfflineHashVerifyThread(QThread): if debug_mode: logger.error(f"DEBUG: 计算补丁文件哈希值失败: {e}") logger.error(f"DEBUG: 错误类型: {type(e).__name__}") - # 确保发送100%进度信号,以便UI更新 self.progress.emit(100) - QApplication.processEvents() self.finished.emit(False, f"计算补丁文件哈希值失败: {str(e)}", "") except Exception as e: if debug_mode: logger.error(f"DEBUG: 解压补丁文件失败: {e}") logger.error(f"DEBUG: 错误类型: {type(e).__name__}") logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}") - # 确保发送100%进度信号,以便UI更新 self.progress.emit(100) - QApplication.processEvents() self.finished.emit(False, f"解压补丁文件失败: {str(e)}", "") return except Exception as e: if debug_mode: logger.error(f"DEBUG: 验证补丁哈希值失败: {e}") logger.error(f"DEBUG: 错误类型: {type(e).__name__}") - logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}") - # 确保发送100%进度信号,以便UI更新 + logger.error(f"DEBUG: 错误堆栈: {traceback.format_exc()}" ) self.progress.emit(100) - QApplication.processEvents() self.finished.emit(False, f"验证补丁哈希值失败: {str(e)}", "") \ No newline at end of file