36 Commits
1.1.0 ... 1.3.2

Author SHA1 Message Date
hyb-oyqq
19da86c808 feat(docs): 更新隐私政策以支持IPv6功能
- 在隐私政策中添加IPv6连接测试和IPv6地址获取的信息。
- 更新相关条款以反映最新的隐私信息收集方式。
2025-08-04 09:41:58 +08:00
欧阳淇淇
96d20c6a5b feat(core): 集成IPv6Manager并优化UI管理器
- 在主窗口和UI管理器中集成IPv6Manager,增强IPv6支持功能。
- 更新UI管理器以处理IPv6连接状态和切换事件,提供更好的用户反馈。
- 优化代码结构,简化IPv6相关功能的实现,提升可维护性。
- 添加打开hosts文件的功能,增强用户操作体验。
2025-08-02 18:46:40 +08:00
欧阳淇淇
0d33d5610a feat(core): 完善IPv6支持功能和下载线程优化
- 添加IPv6连接测试功能,提供详细的连接状态反馈。
- 整合IPv6相关选项,提升用户体验。
- 优化下载线程中的进度更新逻辑,减少延迟。
2025-08-02 17:17:30 +08:00
欧阳淇淇
1c749079a2 feat(core): 增强IPv6支持和优化功能
- 添加IPv6可用性检查,优化用户界面反馈。
- 实现IPv6检测方法,确保用户在启用IPv6时获得准确提示。
- 更新下载线程设置,修正aria2c参数以提高兼容性。
2025-08-02 16:12:19 +08:00
欧阳淇淇
291c471b9e feat(core): 初步添加IPv6支持和优化功能
- 在CloudflareOptimizer中添加IPv6优化功能,支持同时进行IPv4和IPv6的优选。
- 更新UIManager,增加IPv6支持选项,并实现用户切换功能。
- 在下载线程中检查IPv6支持状态,并根据设置决定是否禁用IPv6。
- 在IP优化器中实现获取最优IPv6地址的功能,优化测速参数设置。
- 改进用户提示信息,确保用户了解IPv6优选的时间和效果。
2025-08-02 01:49:08 +08:00
欧阳淇淇
6399382242 feat(docs): 更新FAQ和README文件,明确系统兼容性
- 在FAQ和README中添加关于系统兼容性的说明,明确本工具仅支持Windows 10/11 64位系统,其他平台或版本未经测试。
- 修改相关条款以提高用户理解和使用体验。
2025-08-01 23:48:13 +08:00
hyb-oyqq
3fc74555cb feat(core): 优化下载管理器和进度窗口
- 在下载管理器中引入 CloudflareOptimizer、DownloadTaskManager 和 ExtractionHandler 模块,重构下载流程
- 优化下载进度更新频率,减少 UI 卡顿
- 改进下载和解压缩的处理逻辑,确保更流畅的用户体验
- 更新下载线程设置,支持更灵活的下载管理
2025-08-01 17:54:38 +08:00
hyb-oyqq
5c06802f65 feat(core): 添加下载线程设置功能
- 在下载管理器中引入下载线程级别设置,支持用户自定义线程数
- 在主菜单中添加下载设置子菜单,包含修改下载源和下载线程设置选项
- 优化下载流程,动态调整下载线程数以提高下载效率
- 在动画过程中禁用相关按钮,确保用户体验流畅
- 更新配置文件,增加下载线程档位设置
2025-08-01 16:34:30 +08:00
hyb-oyqq
a93991ca9d feat(downloader): 更新 aria2c 为修改版 aria2c-fast_x64
- 替换原有的 aria2c.exe 为 aria2c-fast_x64.exe
- 更新 aria2c 路径和相关配置
- 优化下载线程中的 aria2c 参数设置
- 更新 README 文件中的贡献者列表
- 版本号从 1.3.0 升级到 1.3.1
2025-08-01 15:40:43 +08:00
hyb-oyqq
c5b9f1746a feat(core): 优化卸载功能并添加批量卸载支持
- 重构卸载流程,支持批量卸载补丁
- 新增已安装补丁游戏的检测和显示
- 改进用户界面,增加多选支持和更详细的结果反馈
- 优化代码结构,提高可维护性和可读性
2025-07-31 17:10:47 +08:00
hyb-oyqq
c941c03446 feat(core): 优化隐私协议管理并添加日志功能
- 重构 PrivacyManager 类,增加隐私协议版本检查和用户同意状态管理
- 在主窗口初始化时获取云端配置,提高效率
- 添加日志功能,记录应用启动、隐私协议加载等关键事件
- 优化错误处理和用户提示信息
2025-07-31 14:38:12 +08:00
hyb-oyqq
5ad4062346 refactor(branding): 修正软件名称
- 在隐私政策文档和代码中替换正确的软件名称
2025-07-31 11:05:44 +08:00
hyb-oyqq
cbfe0d7ff6 feat(core): 添加隐私协议管理和关于菜单功能
- 在 Main.py 中初始化隐私协议管理器,并在程序启动前显示隐私协议对话框
- 在 core/__init__.py 中添加 PrivacyManager 的引用
- 在 ui_manager.py 中实现关于菜单和隐私协议相关功能,包括:
  - 创建关于按钮和菜单
  - 添加隐私协议子菜单
  - 实现撤回隐私协议同意并重启软件的功能
  - 优化菜单样式和字体加载
2025-07-31 10:59:42 +08:00
hyb-oyqq
c837370470 feat(core): 优化安装流程并添加新功能
- 重构安装流程,提高用户体验
- 添加游戏版本选择功能
- 实现下载暂停和恢复功能
- 优化 Cloudflare 加速选项
- 改进错误处理和用户提示
2025-07-30 18:53:05 +08:00
hyb-oyqq
db9736cc4e feat(core): 优化菜单样式和字体,添加开发者选项菜单
- 从 UI_install.py 中获取字体属性,确保菜单样式一致
- 优化下载命令参数,提高下载速度和性能
- 修复部分 UI 元素的显示问题
2025-07-30 18:12:02 +08:00
hyb-oyqq
a411461f63 feat(core): 重构核心模块并添加新功能
- 重构 __init__.py,引入新模块:WindowManager, GameDetector, PatchManager, ConfigManager
- 更新 DebugManager,添加 set_ui_manager 方法
- 改进 DownloadManager,优化下载流程和错误处理
- 重构 MainWindow 类,移除冗余代码,委托部分功能给新模块
- 更新 UI 组件,简化界面逻辑
- 优化导入结构,提高代码可维护性
2025-07-30 17:18:57 +08:00
hyb-oyqq
331f7a25d2 refactor(ui): 重构 UI 相关代码并移除冗余资源
- 从 ui_manager.py 和 install.ui 中移除了使用 base64 图片数据的方式
- 采用直接加载图片文件的方法,提高了代码的可读性和维护性
- 删除了未使用的 popup.ui 文件
- 优化了资源路径的获取方式,使用 resource_path 函数统一处理
2025-07-28 15:22:31 +08:00
hyb-oyqq
642b2ec17f feat(main_window): 添加批量卸载游戏补丁功能
- 在卸载补丁界面添加"全部卸载"选项
- 实现批量卸载所有游戏补丁的功能
- 优化单个游戏卸载流程,统一卸载成功和失败的返回值
- 调整卸载消息提示,适应批量卸载和单个卸载的不同场景
2025-07-28 13:09:43 +08:00
hyb-oyqq
41aab89669 feat(core): 优化游戏目录识别和补丁卸载流程
- 改进游戏目录识别算法,支持大小写不敏感和特殊字符处理
- 增加递归搜索可执行文件的功能,提高识别准确率
- 优化补丁卸载流程,支持更灵活的文件路径和名称
- 增加调试模式下的日志输出,便于问题排查
- 重构部分代码结构,提高可维护性和可扩展性
2025-07-28 11:54:52 +08:00
hyb-oyqq
f6a57215c2 refactor(source): 优化窗口大小和背景图显示
- 设置窗口最小和最大尺寸
- 优化背景图加载和显示,使用setScaledContents简化处理
- 调整标题栏和菜单区域的大小和位置
- 重构部分UI代码,提高可读性和维护性
2025-07-25 17:21:30 +08:00
hyb-oyqq
38549e098e feat(core): 添加卸载补丁功能并优化用户界面
- 在主界面添加卸载补丁按钮,实现卸载功能
- 优化菜单区域,使用按钮替代传统菜单栏
- 更新主题颜色,调整按钮布局和样式
- 优化帮助和设置菜单,提升用户体验
2025-07-25 17:00:55 +08:00
hyb-oyqq
286270a819 feat(core): 优化窗口动画和布局
- 新增按钮点击动画效果
- 动态调整窗口大小和布局
- 实现圆角窗口和拖动功能
- 优化安装按钮状态管理
- 更新菜单动画逻辑
- 为2.0新窗口做预实现
2025-07-25 15:18:05 +08:00
hyb-oyqq
0f9c91b59a docs(README): 更新文档状态说明
- 中英文 README 文件中的文档状态说明已更新
2025-07-24 18:24:58 +08:00
hyb-oyqq
3753375bed feat(core): 重构按钮显示和动画
- 更新动画逻辑,使用新的按钮容器
- 优化按钮点击区域和视觉效果
- 添加了得意黑作为字体和样式
2025-07-24 17:43:11 +08:00
hyb-oyqq
dab2ba2dc5 feat(core): 优化安装结果显示和下载管理
- 优化 Cloudflare 加速提示信息格式
- 增加下载历史记录列表,跟踪本次安装的游戏
- 改进安装结果显示,区分不同情况
- 优化哈希检查提示信息
2025-07-24 16:57:16 +08:00
hyb-oyqq
f86cb7aa7e refactor(core): 优化消息框显示和下载流程
- 修改 hash_pop_window 函数,增加检查类型参数
- 优化 Cloudflare 优选失败和成功消息框显示
- 调整下载前后检查的消息框内容
- 改进解压缩后的文件检查流程
2025-07-24 16:29:30 +08:00
hyb-oyqq
0cf9f5e6c2 feat(core): 优化 Cloudflare IP 加速功能
- 在消息框中添加 Cloudflare 图标
- 更新应用版本号至 1.1.3
- 优化配置获取流程,增加错误处理
- 移除未使用的资源文件
- 调整资源路径获取逻辑
2025-07-24 15:14:29 +08:00
hyb-oyqq
f9715f91f7 build: 更新应用版本号 2025-07-24 11:21:52 +08:00
hyb-oyqq
98e51d443e perf(ip_optimizer): 优化 IP 优选逻辑
- 修改 speedtest-cli 命令参数,避免写入结果文件
- 修复最优 IP 查找逻辑,确保只保存第一个匹配的 IP
- 移除不必要的循环退出条件,简化代码逻辑
2025-07-24 11:20:56 +08:00
hyb-oyqq
c8985f1a85 chore: 更新应用版本和优化资源路径获取逻辑
- 将应用版本更新至 1.1.2
- 修改资源路径获取函数的文档,支持 Nuitka 打包环境
- 优化特殊可执行文件和数据文件的路径处理逻辑
2025-07-23 22:34:24 +08:00
hyb-oyqq
5bd83bfcda docs(FAQ): 更新常见问题文档并优化首页布局
- 更新提交错误报告的说明
- 优化 FAQ 文档结构
- 删除旧的主窗口代码文件
- 清理不必要的 IP 优化代码
2025-07-21 10:06:26 +08:00
hyb-oyqq
36f30571f3 chore: 更新 .gitignore 文件并删除 result.csv 文件
- 将 result.csv 文件添加到 .gitignore 中以避免其被跟踪
- 删除不再需要的 result.csv 文件以清理项目文件结构
2025-07-18 19:34:01 +08:00
hyb-oyqq
f202925333 chore: 项目文件结构重构
删除多个不再使用的源文件,包括动画、下载、配置、UI 相关文件及图标,清理代码库以提高可维护性。
2025-07-18 18:59:19 +08:00
hyb-oyqq
2e6f71d962 feat(main): 预加载云端配置并优化下载逻辑
- 在主线程中添加云端配置预加载功能
- 优化下载 URL 获取逻辑,使用预加载的配置数据
- 添加配置数据缺失和版本更新提示功能
- 调整动画系统,添加动画完成信号
- 重构部分代码以提高可维护性和可读性
2025-07-18 12:01:51 +08:00
hyb-oyqq
ffcb527adc docs: 更新 README 中的预览图片链接 2025-07-17 18:27:49 +08:00
hyb-oyqq
12ca55a372 docs(README): 更新特别鸣谢部分 2025-07-17 18:24:18 +08:00
53 changed files with 6916 additions and 1702 deletions

1
.gitignore vendored
View File

@@ -173,3 +173,4 @@ cython_debug/
nuitka-crash-report.xml
build.bat
log.txt
result.csv

236
FAQ-en.md Normal file
View File

@@ -0,0 +1,236 @@
<div align="center" style="margin-top: 20px; margin-bottom: 20px;">
<img src="./introduction_imgs/main.png" alt="FRAISEMOE Logo" />
<h2 style="margin: 10px 0 5px 0; font-weight: bold; color: #e75480;">🍓 FRAISEMOE NEKOPARA Addons Installer NEXT🍓</h2>
<p style="font-size: 1.1em; color: #555;">An application for installing patches for the Nekopara series games.</p>
<p>
<a href="./FAQ.md">简体中文</a> |
<a href="./FAQ-en.md">English</a>
</p>
<blockquote style="color: #c00; font-weight: bold; border-left: 4px solid #e75480; background: #fff0f5; padding: 10px;">
The English version is not updated in real-time! Please check the Simplified Chinese version for more updates! Thank you for your support!
</blockquote>
<blockquote style="color: #c00; font-weight: bold; border-left: 4px solid #e75480; background: #fff0f5; padding: 10px;">
Please strictly follow all the rules in the <a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/FAQ.md">User Guide</a>. The developers are not responsible for any violations.<br>
This tool is for educational and communication purposes only. Do not use it for commercial purposes.
</blockquote>
</div>
---
## 🎮 Applicable Games:
- **NEKOPARA Vol. 1**
- **NEKOPARA Vol. 2**
- **NEKOPARA Vol. 3**
- **NEKOPARA Vol. 4**
- **NEKOPARA After**
---
**Special Reminder:**
> **1. Patches cannot be installed for NEKOPARA Vol. 0 & NEKOPARA Extra ❗**
> **2. Patches will not be installed for games you do not own ❗**
> **3. This tool is only for installing patches, not for installing games ❗ It only runs on Windows 10/11 64-bit systems (other platforms or versions have not been tested) ❗**
> **4. The tool requires administrator privileges to run ❗
> Reason: To prevent installation issues caused by the game running, the tool will get game process information to close the game before starting ❗**
> **5. Before using this tool, you need to understand the basics of patch installation:**
>
> **5-1. Why install the patch? What's in the patch?**
>
> **5-2. Based on the documentation, why do errors occur? Or why does the installation fail?**
>
> **5-3. After a successful installation, how to check the patch settings to confirm it was installed correctly?**
>
> ***If you are completely unfamiliar with the above, please do not use this tool or watch the tutorial video. If you have already downloaded the tool, it is recommended to move it to the Recycle Bin and delete it.***
> **6. Make sure you are using the latest version of the application (please regularly check for updates on the mirror site or GitHub and download them) ❗**
---
## **🔄 Usage/Flow:**
1. Download "FRAISEMOE Addons Installer NEXT.exe" from the repository.
2. **Close any running games from the ["Applicable Games"](#-applicable-games) list.**
3. If the application asks for administrator privileges, grant them. **If administrator privileges cannot be obtained, the application will not run and will exit automatically.**
4. If the application asks to close a running game, select "Yes". **If the running game cannot be closed, the application will not run and will exit automatically.**
5. After launching the application, select "Start Install" and choose the **parent directory of the game directory.**<br />
> **Important Note 1** ❓ What is the "parent directory of the game directory"? How to get it?
> Taking Steam as an example, find the "Library" tab at the top, then find a [patchable game](#-applicable-games) in the game list on the left;<br />Right-click the game in the list, select "Manage" -> "Browse local files" to get the game directory. Then, click the "←" button in the address bar. If you see the [game's folder, e.g., "NEKOPARA Vol. 1"](#-applicable-games) in the file explorer, then this is the parent directory of the game directory. Select and copy the full path from the address bar.
> **Important Note 2** ❓ How to use this tool for games from third-party installers?
> Since the installation path for third-party games is not fixed, please copy the parent directory path of the game directory yourself, similar to the method for Steam.
> Example (for illustration only, do not copy directly):
> Game folder: C: (drive letter may vary)\Steam\steamapps\common\NEKOPARA Vol. 1
> Parent directory of game directory: C: (drive letter may vary)\Steam\steamapps\common
6. In the folder selection dialog, **paste the copied path into the address bar** and click "Select Folder".<br />**(Note whether the text next to the "Select Folder" button is the last folder name in the path. If not, you may need to re-select).**
> √ Correct Example (for illustration only, do not copy directly):
> Path entered in the address bar above: C: (drive letter may vary)\Steam\steamapps\common
> Folder name below: common
7. After selecting the folder, you may encounter the following situations:
<table>
<tr>
<td><h5>Status</h5></td>
<td><h5>Action</h5></td>
</tr>
<tr>
<td>Game exists, but patch is not installed</td>
<td>Proceeds directly to download task</td>
</tr>
<tr>
<td>Game exists,<br />but patch is installed from another source or patch file is corrupted</td>
<td>Asks whether to reinstall the patch from this tool. If the patch from another source is usable, you can choose not to reinstall</td>
</tr>
<tr>
<td>Game does not exist</td>
<td>Skips the patch installation step</td>
</tr>
<tr>
<td>Game exists,<br />but the corresponding patch version cannot be installed with this tool</td>
<td>Repeat the previous installation steps</td>
</tr>
</table>
8. Confirm the final installation result, then select "Exit".
9. Enter the game and check if there are more options in "Settings". Or if you have previously entered the extra story and it appears in the "EXTRA" option, it means the patch was installed successfully. If none of the above is observed, repeat the installation steps.
---
## 🔰 Software Features:
- Detects installed patch files for ["Applicable Games"](#-applicable-games) and compares their [Hash (SHA-256)](#-hashsha-256-checksums) to verify integrity. If normal, it skips installation for that version;<br />If patches from other sources are used or patch files are corrupted, it asks whether to reinstall. If you choose to reinstall, it will automatically delete old patch files, download the patch package, and reinstall it.
- Detects all unpatched versions and performs installation tasks.
---
## ❓ FAQ & User Guide
---
<h4><u>【Important】Why did the download fail?</u></h4>
1. Please check if the "final folder" in the address bar of the "Folder Selector" matches the "Folder Name" below (above the "Select Folder" button). If they do not match, it will not work correctly. If this is not the issue, proceed to the next step.
2. Please check if the selected folder contains the [game folder](#-usageflow) (refer to step 5 in the usage flow). If the game folder does not exist, it will not work correctly. If this is not the issue, proceed to the next step.
3. Please check if your network environment is normal and the connection is stable. If this is not the issue, proceed to the next step.
4. If the installation result is displayed directly (i.e., the installation step was skipped), it means the path is incorrect and the game could not be identified. Please check the path and try again. <b>If you are using a non-Steam version, please find the relevant resources to install it yourself.</b>
5. Please go to [GitHub](https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT) or the [domestic mirror site blog](https://blog.ovofish.com/posts/c54d3755.html) to <b>check if you are using the latest version. The application will not work correctly if it is not the latest version.</b>
---
<h4><u>【Important】Encountered an error and need to report it? How to submit a bug report?</u></h4>
1. First, please rule out if it is a local network problem.
2. Second, please enable debug mode, run the program again, and <b>save a screenshot of the error along with the log.txt file from the same directory.</b>
3. Finally, <b>please [submit an Issue on GitHub](https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/issues).</b>
![issues_main](./introduction_imgs/issues_main.png)
---
<h4><u>During Use</u></h4>
- The application is open but loading slowly.
- Please wait patiently for a while; the program is not unresponsive.
- Do not open the application multiple times during loading to avoid unnecessary issues.
<h4><u>"Application is already running" / "In use" prompt when opening</u></h4>
- This is caused by opening the application too frequently, causing Task Manager to fail to refresh. Please manually open Task Manager, find "FRAISEMOE Addons Installer NEXT", end its process, and then restart.
<h4><u>1. Download Errors</u></h4>
<table>
<tr>
<td><h5>Common Error Types</h5></td>
<td><h5>Error Information</h5></td>
</tr>
<tr>
<td>Contains "403" / "Access denied by server"</td>
<td>Access denied by the server. Check if you are using a network proxy (VPN), reset the network proxy (or exit the VPN program), then "Restart the application" and try again.</td>
</tr>
<tr>
<td>Contains "port=443" / "An existing connection was forcibly closed by the remote host"</td>
<td>Download interrupted. After other tasks have verified file integrity / download tasks are complete, use "Start Install" again and select the previously entered "parent directory of the game" to install.</td>
</tr>
<tr>
<td>Contains other messages</td>
<td>1. Mostly user network status is abnormal. Check and fix your network status before trying again.<br />2. In some cases, it may be a server failure. Please report the problem via GitHub Issues.</td>
</tr>
</table>
<h4><u>2. Problems During Download and Installation</u></h4>
<table>
<tr>
<td><h5>Common Problem Types</h5></td>
<td><h5>Solution</h5></td>
</tr>
<tr>
<td>Download progress is slow or seems to have stalled</td>
<td>If the progress has stalled but no error is reported, wait a moment.</td>
</tr>
<tr>
<td>Window flickers when verifying file integrity</td>
<td>This is normal, no action is needed.</td>
</tr>
<tr>
<td>The download progress window pops up and is covered by the hash check window / the window close button turns red</td>
<td>Some patch files are large, and calculating the hash value takes longer. Please wait a moment. If the wait is too long, you can manually click the main window/download progress pop-up/hash check window to refresh the status.</td>
</tr>
</table>
<h4><u>3. Cannot exit the program during download</u></h4>
- To ensure the effectiveness of the patch, do not exit the program during download and installation. The user is responsible for any negative consequences of violating this notice.
<h4><u>4. Forcibly terminating the program during download</u></h4>
- This may cause patch file corruption. Restarting the application will automatically overwrite downloaded files. The user is responsible for any negative consequences of violating this notice.
<h4><u>5. Network speed significantly decreases after multiple downloads and installations, despite a normal network status</u></h4>
- To ensure server stability and resource security, download sources are divided into domestic and international. To guarantee download quality for more users, domestic sources have a download limit. Tasks exceeding the limit are forwarded to international sources for download.
<h4><u>6. Found an identical/similar repository/application outside of this repository</u></h4>
- It may be modified by other developers or use patch files from unknown sources. Do not download/use such repositories/applications.
<h4><u>7. User obtained this application through non-free means before using it</u></h4>
- This application is free and open-source. If you obtained it through a paid channel, please request a refund immediately and take action to protect your rights.
---
## 💫 HASH(SHA-256) Checksums
<table>
<tr>
<td><h5>Game Patch</h5></td>
<td><h5>SHA-256 (Hash creation date: 2024/07-2024-08)</h5></td>
</tr>
<tr>
<td>Vol.1</td>
<td>04b48b231a7f34431431e5027fcc7b27affaa951b8169c541709156acf754f3e</td>
</tr>
<tr>
<td>Vol.2</td>
<td>b9c00a2b113a1e768bf78400e4f9075ceb7b35349cdeca09be62eb014f0d4b42</td>
</tr>
<tr>
<td>Vol.3</td>
<td>2ce7b223c84592e1ebc3b72079dee1e5e8d064ade15723328a64dee58833b9d5</td>
</tr>
<tr>
<td>Vol.4</td>
<td>4a4a9ae5a75a18aacbe3ab0774d7f93f99c046afe3a777ee0363e8932b90f36a</td>
</tr>
</table>

30
FAQ.md
View File

@@ -2,19 +2,10 @@
<img src="./introduction_imgs/main.png" alt="FRAISEMOE Logo" />
<h2 style="margin: 10px 0 5px 0; font-weight: bold; color: #e75480;">🍓 FRAISEMOE NEKOPARA Addons Installer NEXT🍓</h2>
<p style="font-size: 1.1em; color: #555;">一个为 Nekopara 系列游戏安装补丁的应用。</p>
<p>
<a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/" style="margin-right: 15px;">
<img src="https://img.shields.io/github/stars/Yanam1Anna/FRAISEMOE-Addons-Installer?style=social" alt="GitHub stars" />
GitHub
</a>
<a href="https://www.bilibili.com/video/BV1hn9UYwE6p/" style="margin-right: 15px;">
<img src="https://img.shields.io/badge/Bilibili-视频讲解-00A1D6?logo=bilibili&logoColor=white" alt="Bilibili" />
Bilibili
</a>
</p>
<p>
<a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/FAQ.md">中文</a> |
<a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/FAQ-en.md">English</a>
<a href="./FAQ.md">中文</a> |
<a href="./FAQ-en.md">English</a>
</p>
<blockquote style="color: #c00; font-weight: bold; border-left: 4px solid #e75480; background: #fff0f5; padding: 10px;">
请严格遵守 <a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/FAQ.md">使用须知文档</a> 的所有条例,如有违反,全体开发人员不承担任何责任。<br>
@@ -40,7 +31,7 @@
> **2. 尚未拥有的游戏将不会进行补丁安装 ❗**
> **3. 本工具仅适用于补丁安装,不适用于安装游戏 ❗ 且仅限于在 Windows x64 系统上运行 ❗**
> **3. 本工具仅适用于补丁安装,不适用于安装游戏 ❗ 且仅限于在 Windows 10/11 64系统上运行(其他平台或版本未经测试)❗**
> **4. 工具需要使用管理员权限运行 ❗
> 原因:为了防止用户在没有关闭正在运行的游戏而影响本工具的安装效果,启用应用前会获取游戏进程信息从而关闭游戏 ❗**
@@ -133,13 +124,12 @@
---
<h4><u>【重要】为什么开发者无视我的问题?如何提交错误报告?</u></h4>
<h4><u>【重要】遇到错误需要反馈?如何提交错误报告?</u></h4>
1. 首先,每个人都会有没空的时候,请耐心等待回复或问题处理。
2. 其次,文档和视频中已详细介绍了使用方法和常见问题解决方式,请检查你遇到的问题,或相似类别的问题是否存在于文档中,如果存在,一般不回复处理。
3. 最后,如果遇到了未提及的问题,<b>请勿在视频站内或博客站内以评论,私信等方式报告你的问题,请到[GitHub中提交Issues](https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/issues)。</b>
4. 提交问题报告时,<b>请附上下载报错窗口的报错信息,而不是安装最终结果显示,</b>安装结果显示是给用户看的,不是给开发者看的。
![issues_main](https://raw.githubusercontent.com/Yanam1Anna/FRAISEMOE-Addons-Installer/refs/heads/master/introduction_imgs/issues_main.png)
1. 首先,请排除是否计算机本机网络问题
2. 其次,请打开debug模式再次运行程序<b>将报错截图与同目录下的log.txt文件一并保存。</b>
3. 最后,<b>请到[GitHub中提交Issues](https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/issues)。</b>
![issues_main](./introduction_imgs/issues_main.png)
---
@@ -151,7 +141,7 @@
<h4><u>打开应用时提示应用正在运行 / 被占用的情况</u></h4>
- 由于开启应用动作过于频繁,造成任务管理器刷新失败,请手动进入任务管理器中找到"FRAISEMOE-Addons-Installer",结束其程序进程后再次重启。
- 由于开启应用动作过于频繁,造成任务管理器刷新失败,请手动进入任务管理器中找到"FRAISEMOE Addons Installer NEXT",结束其程序进程后再次重启。
<h4><u>1. 下载报错</u></h4>

91
PRIVACY.md Normal file
View File

@@ -0,0 +1,91 @@
# FRAISEMOE Addons Installer NEXT 隐私政策
## 1. 引言
本隐私政策旨在说明 FRAISEMOE Addons Installer NEXT以下简称"本应用")在使用过程中如何收集、使用和保护您的个人信息。我们致力于保护您的隐私,并确保您了解我们如何处理您的数据。
## 2. 收集的信息
本应用在运行过程中可能会收集或处理以下信息:
### 2.1 系统信息
- 程序版本号:用于检查更新和兼容性
### 2.2 网络相关信息
- **IP 地址、ISP 及地理位置**: 应用启动时,为获取云端配置,您的 IP 地址会被服务器记录。服务器可能会根据您的 IP 地址推断您的互联网服务提供商ISP和地理位置这些信息仅用于用户数量、区域分布的统计和软件使用情况分析。当您使用 Cloudflare 加速功能时,您的 IP 地址也会被用于节点优选。
- **下载统计信息**:用于监控下载进度和速度
- **IPv6 连接测试**:应用会访问 testipv6.cntest-ipv6.com 的中国大陆镜像网站)以判断软件是否支持 IPv6 连接
- **IPv6 地址获取**:应用在测试 IPv6 功能时会请求 ipw.cn 获取您的公网 IPv6 地址,仅用于显示和连接测试目的
### 2.3 文件信息
- 游戏安装路径:用于识别已安装的游戏和安装补丁
- 文件哈希值:用于验证文件完整性
## 3. 信息使用
我们收集的信息仅用于以下目的:
### 3.1 功能实现
- 游戏目录识别:识别已安装的游戏版本
- 文件完整性验证:确保下载文件的完整性和安全性
- 下载加速:通过 Cloudflare 优化下载速度
### 3.2 服务改进
- **应用更新**:检查应用版本并推送更新。
- **使用情况分析**通过统计IP地址、ISP和地理位置等信息分析用户下载次数与软件使用情况以帮助我们改进服务。您的所有信息都仅用于软件使用统计不会用于其他特殊目的。
- **错误报告**:收集错误信息以改进应用体验。
## 4. 数据存储
### 4.1 本地存储
- 配置文件:保存在系统临时文件夹的 FRAISEMOE 子目录下
- 临时下载文件:保存在系统临时文件夹中
- 日志文件:记录程序运行日志
### 4.2 网络传输
所有网络请求均使用安全的 HTTPS 协议进行传输。
## 5. 修改系统文件
### 5.1 hosts 文件修改
- 当您选择使用 Cloudflare 加速功能时,本应用会临时修改系统 hosts 文件
- 修改前会自动创建备份(位于 %SystemRoot%\System32\drivers\etc\hosts.bak.FRAISEMOE Addons Installer NEXT
- 程序退出时会自动恢复原始 hosts 文件
## 6. 第三方服务
本应用使用以下第三方服务:
### 6.1 Cloudflare
- 本应用使用第三方开源项目 [CloudflareSpeedTest (CFST)](https://github.com/XIU2/CloudflareSpeedTest/) 为您提供 Cloudflare 加速功能。该优选服务由 CFST 项目提供,本项目及作者不负责其功能的实际维护。
- 启用此功能时CFST 将向 Cloudflare 的所有节点发送请求以测试延迟,此过程不可避免地会将您的 IP 地址提交至 Cloudflare。我们建议您遵循并查阅 Cloudflare 的相关用户协议和隐私政策。
### 6.2 云端配置服务
- 本应用启动时会从云端服务器获取配置信息如下载链接等。在此过程中服务器会获取并统计您的IP地址、地理位置及ISP等信息以用于软件使用情况分析。
- 为确保通信安全和服务的稳定性,云端服务器设置了严格的 User-Agent 校验,仅允许本应用内置的特定 User-Agent 发出请求。非本应用指定的 User-Agent 将无法访问服务。
## 7. 用户控制
您对以下功能有完全的控制权:
- 选择是否使用 Cloudflare 加速功能(需修改 hosts 文件)
- 选择安装目录和需要安装的游戏版本
- 选择是否终止可能冲突的进程
## 8. 数据安全
我们采取以下措施保护您的数据:
- 本地配置文件不包含敏感个人信息
- 网络请求使用安全的 HTTPS 协议
- hosts 文件修改会在程序退出时自动恢复
## 9. 联系我们
如果您对本隐私政策有任何疑问或建议,请通过 GitHub 项目页面联系我们。
## 10. 政策更新
本隐私政策可能会根据应用功能的变化而更新。请定期查看最新版本。
最后更新日期2025年8月4日

95
README-en.md Normal file
View File

@@ -0,0 +1,95 @@
# 🍓FRAISEMOE-Addons-Installer-NEXT🍓
```
🔊 Note: This repository's documentation updates have stabilized. If there are any missing parts, please promptly raise an issue. Thank you all for your support!
The English version is not updated in real-time! Please check the Simplified Chinese version for more updates! Thank you for your support!
```
<!-- PROJECT SHIELDS -->
<p align="center">
<a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT">
<img src="./introduction_imgs/main.png" alt="Logo">
</a>
<br />
<br />
If you find this tool helpful, please give it a Star⭐~
<br />
<br />
⚠️ This is an unofficial tool and does not represent any official stance. ⚠️
<br />
<br />
⚠️ This NEXT version is currently under active development, and stability is not guaranteed. ⚠️
<br />
<br />
<a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/issues">Report a Bug</a>
·
<a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/issues">Request a Feature</a>
·
<a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/FAQ.md">【Must Read Before Use】User Guide</a>
<br />
</p>
<!-- LANGUAGE -->
<p align="center">
<a href="README.md">简体中文</a> |
<a href="README-en.md">English</a>
</p>
---
## 📕 Table of Contents
- [Getting Started](#getting-started)
- [Installation](#installation)
- [Usage Steps](#usage-steps)
- [Versioning](#versioning)
- [Authors](#authors)
- [Important Notes](#important-notes)
- [Special Thanks](#special-thanks)
- [License](#license)
---
## Getting Started
### 📥 Installation
Please download the latest version of the application from the [Releases Page](https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/releases).
![preview](./introduction_imgs/preview.png)
### ❗ Usage Steps
1. **Important**: Please be sure to read the [User Guide](https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/FAQ.md) before use.
2. **Detailed Tutorial**: Refer to the [Video Tutorial](https://www.bilibili.com/video/BV1hn9UYwE6p/).
### ⭕ Versioning
This project uses Git for version control. You can view the currently available versions on the [Releases Page](https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/releases).
---
## 💡 Important Notes
1. **Do not use modified applications**: The authors and developers are not responsible for any personal loss resulting from the use of applications from unknown or modified sources.
2. **Follow all rules**: Please strictly adhere to the rules in the [User Guide](https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/FAQ.md) and this document. The authors and developers are not liable for any violations.
3. **Free and Open Source**: This application is free and open-source. If you obtained it through a paid channel, please request a refund immediately and take action to protect your rights.
4. **System Compatibility**: This application has been tested and works with Windows 10/11 64-bit systems. Other platforms or versions have not been tested.
---
## 👨‍💻 Authors
- [ouyangqiqi](https://github.com/hyb-oyqq): Current maintainer of this repository.
## 🎉 Special Thanks
- [Yanam1Anna](https://github.com/Yanam1Anna): The original author of this project, who provided extensive code and resources.
- [HTony03](https://github.com/HTony03): Provided support for refactoring, logic optimization, and feature implementation for parts of the original source code.
- [钨鸮](https://github.com/ABSIDIA): Provided support for cloud resource storage.
- [XIU2/CloudflareSpeedTest](https://github.com/XIU2/CloudflareSpeedTest): Provided core support for the IP optimization feature of this project.
- [hosxy/aria2-fast](https://github.com/hosxy/aria2-fast): Provided a modified version of aria2c for improved download speed and performance.
## 📖 License
This application is licensed under the [GPL-3.0](https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/LICENSE) license. Please see the [LICENSE](https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/LICENSE) file for more information.

View File

@@ -1,7 +1,7 @@
# 🍓FRAISEMOE-Addons-Installer-NEXT🍓
```
🔊 注意:本库仍然努力更新中,大部分文档不可用,敬请谅解。
🔊 注意:本库文档更新已趋于稳定如有遗漏部分请及时提出issue感谢各位支持
```
<!-- PROJECT SHIELDS -->
@@ -32,7 +32,7 @@
<!-- LANGUAGE -->
<p align="center">
<a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT">中文</a> |
<a href="#">English</a>
<a href="README-en.md">English</a>
</p>
---
@@ -55,7 +55,7 @@
请从 [应用发布页面](https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/releases) 下载最新版本的应用程序。
![preview](https://raw.githubusercontent.com/Yanam1Anna/FRAISEMOE-Addons-Installer/master/introduction_imgs/preview.png)
![preview](./introduction_imgs/preview.png)
### ❗ 使用步骤
@@ -73,6 +73,7 @@
1. 请勿使用经过二次修改的应用:若使用未知来源或修改后的应用导致个人利益受损,作者和开发人员不承担任何责任。
2. 请遵循所有规则:请严格遵守 [使用须知文档](https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/FAQ.md) 和本文档中的规则,如有违反,作者和开发人员不承担责任。
3. 免费开源:本应用免费、开源,如有通过非免费途径获取,请立即向来源申请退款并积极维权。
4. 系统兼容性本应用已实测可兼容Windows 10/11 64位系统其他平台或版本未经测试。
---
@@ -80,18 +81,13 @@
- [ouyangqiqi](https://github.com/hyb-oyqq): 本仓库现维护者
## 💡 注意事项
1. 请勿使用经过二次修改的应用:若使用未知来源或修改后的应用导致个人利益受损,作者和开发人员不承担任何责任。
2. 请遵循所有规则:请严格遵守 [使用须知文档](https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/FAQ.md) 和本文档中的规则,如有违反,作者和开发人员不承担责任。
3. 免费开源:本应用免费、开源,如有通过非免费途径获取,请立即向来源申请退款并积极维权。
## 🎉 特别鸣谢
- [Yanam1Anna](https://github.com/Yanam1Anna): 本项目的原作者,提供了大量代码和资源。
- [HTony03](https://github.com/HTony03):对于项目部分源码的重构、逻辑优化和功能实现提供了大力支持。
- [HTony03](https://github.com/HTony03):对于项目部分源码的重构、逻辑优化和功能实现提供了支持。
- [钨鸮](https://github.com/ABSIDIA):对于云端资源存储提供了支持。
- [XIU2/CloudflareSpeedTest](https://github.com/XIU2/CloudflareSpeedTest):为本项目提供了 IP 优选功能的核心支持。
- [hosxy/aria2-fast](https://github.com/hosxy/aria2-fast)提供了修改版aria2c提高了下载速度和性能。
## 📖 协议

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
source/IMG/BG/title_bg1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
source/IMG/BG/title_bg2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
source/IMG/BTN/Button.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -1,9 +1,35 @@
import sys
from PySide6.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication, QMessageBox
from main_window import MainWindow
from core.privacy_manager import PrivacyManager
from utils.logger import setup_logger
if __name__ == "__main__":
# 初始化日志
logger = setup_logger("main")
logger.info("应用启动")
app = QApplication(sys.argv)
# 初始化隐私协议管理器
try:
privacy_manager = PrivacyManager()
except Exception as e:
logger.error(f"初始化隐私协议管理器失败: {e}")
QMessageBox.critical(
None,
"隐私协议加载错误",
f"无法加载隐私协议管理器,程序将退出。\n\n错误信息:{e}"
)
sys.exit(1)
# 显示隐私协议对话框
if not privacy_manager.show_privacy_dialog():
logger.info("用户未同意隐私协议,程序退出")
sys.exit(0) # 如果用户不同意隐私协议,退出程序
# 用户已同意隐私协议,继续启动程序
logger.info("隐私协议已同意,启动主程序")
window = MainWindow()
window.show()
sys.exit(app.exec())

View File

@@ -1,169 +0,0 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'install.ui'
##
## Created by: Qt User Interface Compiler version 6.9.1
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from pic_data import img_data
from PySide6.QtGui import QPixmap
import base64
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
QCursor, QFont, QFontDatabase, QGradient,
QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient,
QTransform)
from PySide6.QtWidgets import (QApplication, QLabel, QMainWindow, QMenu,
QMenuBar, QPushButton, QSizePolicy, QWidget)
def load_base64_image(base64_str):
pixmap = QPixmap()
pixmap.loadFromData(base64.b64decode(base64_str))
return pixmap
class Ui_MainWindows(object):
def setupUi(self, MainWindows):
if not MainWindows.objectName():
MainWindows.setObjectName(u"MainWindows")
MainWindows.setEnabled(True)
MainWindows.resize(1024, 576)
MainWindows.setMinimumSize(QSize(1024, 576))
MainWindows.setMaximumSize(QSize(1024, 576))
MainWindows.setMouseTracking(False)
MainWindows.setTabletTracking(False)
MainWindows.setAcceptDrops(True)
MainWindows.setAutoFillBackground(True)
MainWindows.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
MainWindows.setAnimated(True)
MainWindows.setDocumentMode(False)
MainWindows.setDockNestingEnabled(False)
self.action_2 = QAction(MainWindows)
self.action_2.setObjectName(u"action_2")
self.centralwidget = QWidget(MainWindows)
self.centralwidget.setObjectName(u"centralwidget")
self.centralwidget.setAutoFillBackground(True)
self.loadbg = QLabel(self.centralwidget)
self.loadbg.setObjectName(u"loadbg")
self.loadbg.setGeometry(QRect(0, 0, 1031, 561))
self.loadbg.setPixmap(load_base64_image(img_data["loadbg"]))
self.loadbg.setScaledContents(True)
self.vol1bg = QLabel(self.centralwidget)
self.vol1bg.setObjectName(u"vol1bg")
self.vol1bg.setGeometry(QRect(0, 120, 93, 64))
self.vol1bg.setPixmap(load_base64_image(img_data["vol1"]))
self.vol1bg.setScaledContents(True)
self.vol2bg = QLabel(self.centralwidget)
self.vol2bg.setObjectName(u"vol2bg")
self.vol2bg.setGeometry(QRect(0, 180, 93, 64))
self.vol2bg.setPixmap(load_base64_image(img_data["vol2"]))
self.vol2bg.setScaledContents(True)
self.vol3bg = QLabel(self.centralwidget)
self.vol3bg.setObjectName(u"vol3bg")
self.vol3bg.setGeometry(QRect(0, 240, 93, 64))
self.vol3bg.setPixmap(load_base64_image(img_data["vol3"]))
self.vol3bg.setScaledContents(True)
self.vol4bg = QLabel(self.centralwidget)
self.vol4bg.setObjectName(u"vol4bg")
self.vol4bg.setGeometry(QRect(0, 300, 93, 64))
self.vol4bg.setPixmap(load_base64_image(img_data["vol4"]))
self.vol4bg.setScaledContents(True)
self.afterbg = QLabel(self.centralwidget)
self.afterbg.setObjectName(u"afterbg")
self.afterbg.setGeometry(QRect(0, 360, 93, 64))
self.afterbg.setPixmap(load_base64_image(img_data["after"]))
self.afterbg.setScaledContents(True)
self.Mainbg = QLabel(self.centralwidget)
self.Mainbg.setObjectName(u"Mainbg")
self.Mainbg.setGeometry(QRect(0, 0, 1031, 561))
self.Mainbg.setPixmap(load_base64_image(img_data["Mainbg"]))
self.Mainbg.setScaledContents(True)
self.start_install_btn = QPushButton(self.centralwidget)
self.start_install_btn.setObjectName(u"start_install_btn")
self.start_install_btn.setEnabled(True)
self.start_install_btn.setGeometry(QRect(780, 250, 191, 91))
self.start_install_btn.setAutoFillBackground(False)
start_install_icon = QIcon()
start_install_pixmap = load_base64_image(img_data["start_install_btn"])
if not start_install_pixmap.isNull():
start_install_icon.addPixmap(start_install_pixmap)
self.start_install_btn.setIcon(start_install_icon)
self.start_install_btn.setIcon(start_install_icon)
self.start_install_btn.setIconSize(QSize(189, 110))
self.start_install_btn.setCheckable(False)
self.start_install_btn.setAutoRepeat(False)
self.start_install_btn.setAutoDefault(False)
self.start_install_btn.setFlat(True)
self.exit_btn = QPushButton(self.centralwidget)
self.exit_btn.setObjectName(u"exit_btn")
self.exit_btn.setEnabled(True)
self.exit_btn.setGeometry(QRect(780, 340, 191, 91))
self.exit_btn.setAutoFillBackground(False)
exit_icon = QIcon()
exit_pixmap = load_base64_image(img_data["exit_btn"])
if not exit_pixmap.isNull():
exit_icon.addPixmap(exit_pixmap)
self.exit_btn.setIcon(exit_icon)
self.exit_btn.setIcon(exit_icon)
self.exit_btn.setIconSize(QSize(189, 110))
self.exit_btn.setCheckable(False)
self.exit_btn.setFlat(True)
self.menubg = QLabel(self.centralwidget)
self.menubg.setObjectName(u"menubg")
self.menubg.setGeometry(QRect(710, 0, 321, 561))
self.menubg.setPixmap(load_base64_image(img_data["menubg"]))
self.menubg.setScaledContents(True)
MainWindows.setCentralWidget(self.centralwidget)
self.loadbg.raise_()
self.vol1bg.raise_()
self.vol2bg.raise_()
self.vol3bg.raise_()
self.vol4bg.raise_()
self.afterbg.raise_()
self.Mainbg.raise_()
self.menubg.raise_()
self.start_install_btn.raise_()
self.exit_btn.raise_()
self.menubar = QMenuBar(MainWindows)
self.menubar.setObjectName(u"menubar")
self.menubar.setGeometry(QRect(0, 0, 1024, 21))
self.menu = QMenu(self.menubar)
self.menu.setObjectName(u"menu")
self.menu_2 = QMenu(self.menubar)
self.menu_2.setObjectName(u"menu_2")
MainWindows.setMenuBar(self.menubar)
self.menubar.addAction(self.menu.menuAction())
self.menubar.addAction(self.menu_2.menuAction())
self.menu.addSeparator()
self.menu.addAction(self.action_2)
self.retranslateUi(MainWindows)
QMetaObject.connectSlotsByName(MainWindows)
# setupUi
def retranslateUi(self, MainWindows):
MainWindows.setWindowTitle(QCoreApplication.translate("MainWindows", u" UI Test", None))
self.action_2.setText(QCoreApplication.translate("MainWindows", u"\u68c0\u67e5\u66f4\u65b0(\u672a\u5b8c\u6210)", None))
self.loadbg.setText("")
self.vol1bg.setText("")
self.vol2bg.setText("")
self.vol3bg.setText("")
self.vol4bg.setText("")
self.afterbg.setText("")
self.Mainbg.setText("")
#if QT_CONFIG(accessibility)
self.start_install_btn.setAccessibleDescription("")
#endif // QT_CONFIG(accessibility)
self.start_install_btn.setText("")
self.exit_btn.setText("")
self.menubg.setText("")
self.menu.setTitle(QCoreApplication.translate("MainWindows", u"\u8bbe\u7f6e", None))
self.menu_2.setTitle(QCoreApplication.translate("MainWindows", u"\u5e2e\u52a9", None))
# retranslateUi

View File

@@ -1,158 +0,0 @@
from PySide6.QtCore import (QPropertyAnimation, QParallelAnimationGroup,
QPoint, QEasingCurve, QTimer)
from PySide6.QtWidgets import QGraphicsOpacityEffect
class MultiStageAnimations:
def __init__(self, ui):
self.ui = ui
# 获取画布尺寸
self.canvas_width = ui.centralwidget.width()
self.canvas_height = ui.centralwidget.height()
# 动画时序配置
self.animation_config = {
"logo": {
"delay_after": 2800
},
"mainbg": {
"delay_after": 500
}
}
# 第一阶段Logo动画配置
self.logo_widgets = [
{"widget": ui.vol1bg, "delay": 0, "duration": 500, "end_pos": QPoint(0, 120)},
{"widget": ui.vol2bg, "delay": 80, "duration": 500, "end_pos": QPoint(0, 180)},
{"widget": ui.vol3bg, "delay": 160, "duration": 500, "end_pos": QPoint(0, 240)},
{"widget": ui.vol4bg, "delay": 240, "duration": 500, "end_pos": QPoint(0, 300)},
{"widget": ui.afterbg, "delay": 320, "duration": 500, "end_pos": QPoint(0, 360)}
]
# 第二阶段:菜单元素
self.menu_widgets = [
{"widget": ui.menubg, "end_pos": QPoint(710, 0), "duration": 600},
{"widget": ui.start_install_btn, "end_pos": QPoint(780, 250), "duration": 600},
{"widget": ui.exit_btn, "end_pos": QPoint(780, 340), "duration": 600}
]
self.animations = []
self.timers = []
def initialize(self):
"""初始化所有组件状态"""
# 设置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()
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):
"""启动菜单动画(从下往上)"""
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)
anim_group.start()
self.animations.append(anim_group)
def start_animations(self):
"""启动完整动画序列"""
self.clear_animations()
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()

27
source/core/__init__.py Normal file
View File

@@ -0,0 +1,27 @@
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
__all__ = [
'MultiStageAnimations',
'UIManager',
'DownloadManager',
'DebugManager',
'WindowManager',
'GameDetector',
'PatchManager',
'ConfigManager',
'PrivacyManager',
'CloudflareOptimizer',
'DownloadTaskManager',
'ExtractionHandler'
]

360
source/core/animations.py Normal file
View File

@@ -0,0 +1,360 @@
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.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.28) - 10 # 与resizeEvent中保持一致
# 更新动画目标位置
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, 'uninstall_container'):
btn_width = self.ui.uninstall_container.width()
x_pos = width - btn_width - right_margin
y_pos = int((height - 65) * 0.46) - 10 # 与resizeEvent中保持一致
# 更新动画目标位置
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.64) - 10 # 与resizeEvent中保持一致
# 更新动画目标位置
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.uninstall_container:
item["end_pos"] = QPoint(1050, 310)
elif item["widget"] == self.ui.exit_container:
item["end_pos"] = QPoint(1050, 420)
def start_animations(self):
"""启动完整动画序列"""
self.clear_animations()
# 确保按钮在动画开始时被禁用
self.ui.start_install_btn.setEnabled(False)
self.ui.uninstall_btn.setEnabled(False)
self.ui.exit_btn.setEnabled(False)
self.start_logo_animations()
def clear_animations(self):
"""清理所有动画资源"""
for timer in self.timers:
timer.stop()
for anim in self.animations:
anim.stop()
self.timers.clear()
self.animations.clear()

View File

@@ -0,0 +1,401 @@
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
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
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
"""
# 创建取消状态标记
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:
print("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
print(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():
print("等待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
print(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():
print("等待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结果显示相应提示"""
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
if success:
msg_box = QtWidgets.QMessageBox(self.main_window)
msg_box.setWindowTitle(f"成功 - {self.main_window.APP_NAME}")
msg_box.setText(f"\n已将优选IP应用到hosts文件\n{success_message}\n10秒后自动继续...")
msg_box.setIcon(QtWidgets.QMessageBox.Icon.Information)
ok_button = msg_box.addButton("确定 (10)", QtWidgets.QMessageBox.ButtonRole.AcceptRole)
cancel_button = msg_box.addButton("取消安装", QtWidgets.QMessageBox.ButtonRole.RejectRole)
# 创建计时器实现倒计时
countdown = 10
timer = QTimer(self.main_window)
def update_countdown():
nonlocal countdown
countdown -= 1
ok_button.setText(f"确定 ({countdown})")
if countdown <= 0:
timer.stop()
if msg_box.isVisible():
msg_box.accept()
timer.timeout.connect(update_countdown)
timer.start(1000) # 每秒更新一次
# 显示对话框并等待用户响应
result = msg_box.exec()
# 停止计时器
timer.stop()
# 如果用户点击了取消安装
if msg_box.clickedButton() == cancel_button:
# 恢复主窗口状态
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
return False
else:
QtWidgets.QMessageBox.critical(
self.main_window,
f"错误 - {self.main_window.APP_NAME}",
"\n修改hosts文件失败请检查程序是否以管理员权限运行。\n"
)
# 恢复主窗口状态
self.main_window.ui.start_install_text.setText("开始安装")
return False
# 用户点击了继续,重新禁用主窗口
self.main_window.setEnabled(False)
# 标记倒计时已完成
self.countdown_finished = True
return True
def stop_optimization(self):
"""停止正在进行的IP优化"""
if hasattr(self, 'ip_optimizer_thread') and self.ip_optimizer_thread and self.ip_optimizer_thread.isRunning():
self.ip_optimizer_thread.stop()
self.ip_optimizer_thread.wait()
if hasattr(self, 'ipv6_optimizer_thread') and self.ipv6_optimizer_thread and self.ipv6_optimizer_thread.isRunning():
self.ipv6_optimizer_thread.stop()
self.ipv6_optimizer_thread.wait()
if hasattr(self, 'optimizing_msg_box') and self.optimizing_msg_box:
if self.optimizing_msg_box.isVisible():
self.optimizing_msg_box.accept()
self.optimizing_msg_box = None

View File

@@ -0,0 +1,169 @@
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"
msg_box = msgbox_frame(
f"更新提示 - {self.app_name}",
"\n当前版本过低,请及时更新。\n",
QMessageBox.StandardButton.Ok,
)
msg_box.exec()
# 在浏览器中打开项目主页
webbrowser.open("https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/")
# 版本过低,应当显示"无法安装"
return {"action": "disable_button", "then": "exit"}
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": "disable_button"}
else:
self.cloud_config = data
# 标记配置有效
self.config_valid = True
# 清除错误信息
self.last_error_message = ""
if debug_mode:
print("--- Cloud config fetched successfully ---")
print(json.dumps(data, indent=2))
# 获取配置成功,允许安装
return {"action": "enable_button"}
def is_config_valid(self):
"""检查配置是否有效
Returns:
bool: 配置是否有效
"""
return self.config_valid
def get_cloud_config(self):
"""获取云端配置
Returns:
dict: 云端配置
"""
return self.cloud_config
def get_last_error(self):
"""获取最后一次错误信息
Returns:
str: 错误信息
"""
return self.last_error_message

View File

@@ -0,0 +1,82 @@
import os
import sys
from PySide6 import QtWidgets
from data.config import LOG_FILE
from utils import Logger
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: 是否处于调试模式
"""
if hasattr(self, 'ui_manager') and hasattr(self.ui_manager, 'debug_action'):
return self.ui_manager.debug_action.isChecked()
return False
def toggle_debug_mode(self, checked):
"""切换调试模式
Args:
checked: 是否启用调试模式
"""
print(f"Toggle debug mode: {checked}")
self.main_window.config["debug_mode"] = checked
self.main_window.save_config(self.main_window.config)
# 更新打开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()
else:
self.stop_logging()
def start_logging(self):
"""启动日志记录"""
if self.logger is None:
try:
if os.path.exists(LOG_FILE):
os.remove(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
print("--- Debug mode enabled ---")
except (IOError, OSError) as e:
QtWidgets.QMessageBox.critical(self.main_window, "错误", f"无法创建日志文件: {e}")
self.logger = None
def stop_logging(self):
"""停止日志记录"""
if self.logger:
print("--- Debug mode disabled ---")
sys.stdout = self.original_stdout
sys.stderr = self.original_stderr
self.logger.close()
self.logger = None

View File

@@ -0,0 +1,642 @@
import os
import requests
import json
from collections import deque
from urllib.parse import urlparse
import re # Added for recursive search
from PySide6 import QtWidgets, QtCore
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon, QPixmap, QFont
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
class DownloadManager:
def __init__(self, main_window):
"""初始化下载管理器
Args:
main_window: 主窗口实例用于访问UI和状态
"""
self.main_window = main_window
self.main_window.APP_NAME = 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:
print(f"DEBUG: 使用识别到的游戏目录 {game}: {game_dir}")
print(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():
print("--- 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():
print(f"DEBUG: Parsed JSON data: {json.dumps(config_data, indent=2)}")
# 统一处理URL提取确保返回扁平化的字典
urls = {}
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"]
if "after.data" in config_data and "url" in config_data["after.data"]:
urls["after"] = config_data["after.data"]["url"]
# 检查是否成功提取了所有URL
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]
raise ValueError(f"配置文件缺少必要的键: {', '.join(missing_original_keys)}")
if self.is_debug_mode():
print(f"DEBUG: Extracted URLs: {urls}")
print("--- 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():
print(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():
print(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 download_action(self):
"""开始下载流程"""
# 主窗口在file_dialog中已被禁用
# 清空下载历史记录
self.main_window.download_queue_history = []
# 使用改进的目录识别功能
game_dirs = self.main_window.game_detector.identify_game_directories_improved(self.selected_folder)
debug_mode = self.is_debug_mode()
if debug_mode:
print(f"DEBUG: 开始下载流程, 识别到 {len(game_dirs)} 个游戏目录")
# 检查是否找到任何游戏目录
if not game_dirs:
if debug_mode:
print("DEBUG: 未识别到任何游戏目录,设置目录未找到错误")
# 设置特定的错误类型,以便在按钮点击处理中区分处理
self.main_window.last_error_message = "directory_not_found"
QtWidgets.QMessageBox.warning(
self.main_window,
f"目录错误 - {APP_NAME}",
"\n未能识别到任何游戏目录。\n\n请确认您选择的是游戏的上级目录并且该目录中包含NEKOPARA系列游戏文件夹。\n"
)
# 恢复主窗口
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
return
# 显示哈希检查窗口
self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="pre")
# 执行预检查,先判断哪些游戏版本已安装了补丁
install_paths = self.get_install_paths()
self.main_window.hash_thread = self.main_window.create_hash_thread("pre", install_paths)
# 使用lambda连接传递game_dirs参数
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
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
debug_mode = self.is_debug_mode()
# 临时启用窗口以显示选择对话框
self.main_window.setEnabled(True)
# 获取可安装的游戏版本列表(尚未安装补丁的版本)
installable_games = []
already_installed_games = []
for game_version, game_dir in game_dirs.items():
if self.main_window.installed_status.get(game_version, False):
if debug_mode:
print(f"DEBUG: {game_version} 已安装补丁,不需要再次安装")
already_installed_games.append(game_version)
else:
if debug_mode:
print(f"DEBUG: {game_version} 未安装补丁,可以安装")
installable_games.append(game_version)
# 显示状态消息
status_message = ""
if already_installed_games:
status_message += f"已安装补丁的游戏:\n{chr(10).join(already_installed_games)}\n\n"
if not installable_games:
# 如果没有可安装的游戏
QtWidgets.QMessageBox.information(
self.main_window,
f"信息 - {APP_NAME}",
f"\n所有检测到的游戏都已安装补丁。\n\n{status_message}"
)
# 恢复主窗口
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
return
# 如果有可安装的游戏版本,让用户选择
from PySide6.QtWidgets import QInputDialog, QListWidget, QVBoxLayout, QDialog, QLabel, QPushButton, QAbstractItemView, QHBoxLayout
# 创建自定义选择对话框
dialog = QDialog(self.main_window)
dialog.setWindowTitle("选择要安装的游戏")
dialog.resize(400, 300)
layout = QVBoxLayout(dialog)
# 先显示已安装补丁的游戏
if already_installed_games:
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)
already_installed_list = QLabel(chr(10).join(already_installed_games), dialog)
layout.addWidget(already_installed_list)
# 添加一些间距
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 in installable_games:
list_widget.addItem(game)
layout.addWidget(list_widget)
# 添加全选按钮
select_all_btn = QPushButton("全选", dialog)
select_all_btn.clicked.connect(lambda: list_widget.selectAll())
layout.addWidget(select_all_btn)
# 添加确定和取消按钮
buttons_layout = QHBoxLayout()
ok_button = QPushButton("确定", dialog)
cancel_button = QPushButton("取消", dialog)
buttons_layout.addWidget(ok_button)
buttons_layout.addWidget(cancel_button)
layout.addLayout(buttons_layout)
# 连接按钮事件
ok_button.clicked.connect(dialog.accept)
cancel_button.clicked.connect(dialog.reject)
# 显示对话框并等待用户选择
result = dialog.exec()
if result != QDialog.DialogCode.Accepted or list_widget.selectedItems() == []:
# 用户取消或未选择任何游戏
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
return
# 获取用户选择的游戏
selected_games = [item.text() for item in list_widget.selectedItems()]
if debug_mode:
print(f"DEBUG: 用户选择了以下游戏进行安装: {selected_games}")
# 过滤game_dirs只保留选中的游戏
selected_game_dirs = {game: game_dirs[game] for game in selected_games if game in game_dirs}
# 重新禁用窗口
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_download_queue(config, selected_game_dirs)
# 如果没有需要下载的内容,直接进行最终校验
if not self.download_queue:
self.main_window.after_hash_compare()
return
# 询问是否使用Cloudflare加速
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:
print(f"DEBUG: 填充下载队列, 游戏目录: {game_dirs}")
# 添加nekopara 1-4
for i in range(1, 5):
game_version = f"NEKOPARA Vol.{i}"
# 只处理game_dirs中包含的游戏版本(如果用户选择了特定版本)
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:
print(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)
# 添加nekopara after
game_version = "NEKOPARA After"
# 只处理game_dirs中包含的游戏版本(如果用户选择了特定版本)
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:
print(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 _show_cloudflare_option(self):
"""显示Cloudflare加速选择对话框"""
# 临时启用窗口以显示对话框
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文件请注意放行。")
# 设置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():
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]
# 保存当前URL供CloudflareOptimizer使用
self.main_window.current_url = first_url
# 使用CloudflareOptimizer进行IP优化
self.cloudflare_optimizer.start_ip_optimization(first_url)
# 等待CloudflareOptimizer的回调
# on_optimization_finished会被调用然后决定是否继续
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:
# 否则继续等待100ms后再次检查
QtCore.QTimer.singleShot(100, self.check_optimization_status)
def next_download_task(self):
"""处理下载队列中的下一个任务"""
if not self.download_queue:
self.main_window.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:
print(f"DEBUG: 准备下载游戏 {game_version}")
print(f"DEBUG: 游戏文件夹: {game_folder}")
# 游戏可执行文件已在填充下载队列时验证过,不需要再次检查
# 因为game_folder是从已验证的game_dirs中获取的
game_exe_exists = True
# 检查游戏是否已安装
if (
not game_exe_exists
or self.main_window.installed_status[game_version]
):
if debug_mode:
print(f"DEBUG: 跳过下载游戏 {game_version}")
print(f"DEBUG: 游戏存在: {game_exe_exists}")
print(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
# 创建进度窗口并开始下载
self.main_window.progress_window = self.main_window.create_progress_window()
# 从CloudflareOptimizer获取已优选的IP
self.optimized_ip = self.cloudflare_optimizer.get_optimized_ip()
if self.optimized_ip:
print(f"已为 {game_version} 获取到优选IP: {self.optimized_ip}")
else:
print(f"未能为 {game_version} 获取优选IP将使用默认线路。")
# 使用DownloadTaskManager开始下载
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 self.main_window.progress_window and self.main_window.progress_window.isVisible():
self.main_window.progress_window.reject()
self.main_window.progress_window = None
# 处理下载失败
if not success:
print(f"--- Download Failed: {game_version} ---")
print(error)
print("------------------------------------")
# 临时启用窗口以显示对话框
self.main_window.setEnabled(True)
msg_box = QtWidgets.QMessageBox(self.main_window)
msg_box.setWindowTitle(f"下载失败 - {APP_NAME}")
msg_box.setText(f"\n文件获取失败: {game_version}\n错误: {error}\n\n是否重试?")
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
# 下载成功使用ExtractionHandler开始解压缩
self.extraction_handler.start_extraction(_7z_path, game_folder, plugin_path, game_version)
# extraction_handler的回调会处理下一步操作
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):
"""当用户点击停止按钮或选择结束时调用的函数"""
# 停止IP优化线程
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
# 退出应用程序
print("下载已全部停止。")
# 恢复主窗口状态
self.main_window.setEnabled(True)
self.main_window.ui.start_install_text.setText("开始安装")
# 显示取消安装的消息
QtWidgets.QMessageBox.information(
self.main_window,
f"已取消 - {APP_NAME}",
"\n已成功取消安装进程。\n"
)
# 以下方法委托给DownloadTaskManager
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()

View File

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

View File

@@ -0,0 +1,81 @@
import os
from PySide6 import QtWidgets
from PySide6.QtWidgets import QMessageBox
class ExtractionHandler:
"""解压处理器,负责管理解压任务和结果处理"""
def __init__(self, main_window):
"""初始化解压处理器
Args:
main_window: 主窗口实例用于访问UI和状态
"""
self.main_window = main_window
self.APP_NAME = main_window.APP_NAME if hasattr(main_window, 'APP_NAME') else ""
def start_extraction(self, _7z_path, game_folder, plugin_path, game_version):
"""开始解压任务
Args:
_7z_path: 7z文件路径
game_folder: 游戏文件夹路径
plugin_path: 插件路径
game_version: 游戏版本名称
"""
# 显示解压中的消息窗口
self.main_window.hash_msg_box = self.main_window.hash_manager.hash_pop_window(check_type="extraction")
# 创建并启动解压线程
self.main_window.extraction_thread = self.main_window.create_extraction_thread(
_7z_path, game_folder, plugin_path, game_version
)
self.main_window.extraction_thread.finished.connect(self.on_extraction_finished)
self.main_window.extraction_thread.start()
def on_extraction_finished(self, success, error_message, game_version):
"""解压完成后的处理
Args:
success: 是否解压成功
error_message: 错误信息
game_version: 游戏版本
"""
# 关闭哈希检查窗口
if self.main_window.hash_msg_box and self.main_window.hash_msg_box.isVisible():
self.main_window.hash_msg_box.close()
self.main_window.hash_msg_box = None
# 处理解压结果
if not success:
# 临时启用窗口以显示错误消息
self.main_window.setEnabled(True)
QtWidgets.QMessageBox.critical(self.main_window, f"错误 - {self.APP_NAME}", error_message)
self.main_window.installed_status[game_version] = False
# 询问用户是否继续其他游戏的安装
reply = QtWidgets.QMessageBox.question(
self.main_window,
f"继续安装? - {self.APP_NAME}",
f"\n{game_version} 的补丁安装失败。\n\n是否继续安装其他游戏的补丁?\n",
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
QtWidgets.QMessageBox.StandardButton.Yes
)
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
# 继续下一个,重新禁用窗口
self.main_window.setEnabled(False)
# 通知DownloadManager继续下一个下载任务
self.main_window.download_manager.on_extraction_finished(True)
else:
# 用户选择停止,保持窗口启用状态
self.main_window.ui.start_install_text.setText("开始安装")
# 通知DownloadManager停止下载队列
self.main_window.download_manager.on_extraction_finished(False)
else:
# 更新安装状态
self.main_window.installed_status[game_version] = True
# 通知DownloadManager继续下一个下载任务
self.main_window.download_manager.on_extraction_finished(True)

View File

@@ -0,0 +1,311 @@
import os
import re
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
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:
print(f"DEBUG: 尝试识别游戏版本: {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:
print(f"DEBUG: 从目录名推测游戏版本: {potential_version}, 卷号: {vol_num}")
elif "after" in dir_name:
potential_version = "NEKOPARA After"
if debug_mode:
print(f"DEBUG: 从目录名推测游戏版本: 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:
print(f"DEBUG: 通过可执行文件确认游戏版本: {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:
print(f"DEBUG: 通过递归搜索确认游戏版本: {potential_version}, 文件: {file}")
return potential_version
# 如果仍然没有找到,基于目录名的推测返回结果
if potential_version:
if debug_mode:
print(f"DEBUG: 基于目录名返回推测的游戏版本: {potential_version}")
return potential_version
if debug_mode:
print(f"DEBUG: 无法识别游戏版本: {game_dir}")
return None
def identify_game_directories_improved(self, selected_folder):
"""改进的游戏目录识别,支持大小写不敏感和特殊字符处理
Args:
selected_folder: 选择的上级目录
Returns:
dict: 游戏版本到游戏目录的映射
"""
debug_mode = self._is_debug_mode()
if debug_mode:
print(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:
print(f"DEBUG: 找到以下子目录: {all_dirs}")
except Exception as e:
if debug_mode:
print(f"DEBUG: 无法读取目录 {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:
print(f"DEBUG: 搜索游戏 {game}, 预期目录: {expected_dir}, 预期可执行文件: {expected_exe}")
# 尝试不同的匹配方法
found_dir = None
# 1. 精确匹配
if expected_dir in all_dirs:
found_dir = expected_dir
if debug_mode:
print(f"DEBUG: 精确匹配成功: {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:
print(f"DEBUG: 大小写不敏感匹配成功: {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:
print(f"DEBUG: 模糊匹配成功: {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:
print(f"DEBUG: 提取卷号: {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:
print(f"DEBUG: 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:
print(f"DEBUG: 卷号匹配成功: {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:
print(f"DEBUG: 验证成功,找到游戏可执行文件: {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:
print(f"DEBUG: 通过递归搜索找到游戏可执行文件: {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:
print(f"DEBUG: 通过递归搜索找到After游戏可执行文件: {found_exe}")
break
if exe_exists:
break
# 如果找到了可执行文件,将该目录添加到游戏目录列表
if exe_exists:
game_paths[game] = potential_path
if debug_mode:
print(f"DEBUG: 验证成功,将 {potential_path} 添加为 {game} 的目录")
else:
if debug_mode:
print(f"DEBUG: 未找到任何可执行文件变体,游戏 {game}{potential_path} 未找到")
if debug_mode:
print(f"DEBUG: 最终识别的游戏目录: {game_paths}")
print(f"--- 目录识别结束 ---")
return game_paths

345
source/core/ipv6_manager.py Normal file
View File

@@ -0,0 +1,345 @@
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}")
# 如果用户尝试启用IPv6检查系统是否支持IPv6并发出警告
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:
return False
# 用户确认启用后继续检查IPv6可用性
ipv6_available = self.check_ipv6_availability()
if not ipv6_available:
msg_box = self._create_message_box("错误", "\n未检测到可用的IPv6连接无法启用IPv6支持。\n\n请确保您的网络环境支持IPv6且已正确配置。\n")
msg_box.exec()
return False
# 保存设置到配置
if self.config is not None:
self.config["ipv6_enabled"] = enabled
# 直接使用utils.save_config保存配置
from utils import save_config
save_config(self.config)
# 显示设置已保存的消息
status = "启用" if enabled else "禁用"
msg_box = self._create_message_box("IPv6设置", f"\nIPv6支持已{status}。新的设置将在下一次下载时生效。\n")
msg_box.exec()
return True
def _create_message_box(self, title, message, buttons=QMessageBox.StandardButton.Ok):
"""创建统一风格的消息框
Args:
title: 消息框标题
message: 消息内容
buttons: 按钮类型,默认为确定按钮
Returns:
QMessageBox: 配置好的消息框实例
"""
msg_box = msgbox_frame(
f"{title} - {APP_NAME}",
message,
buttons,
)
return msg_box

View File

@@ -0,0 +1,391 @@
import os
import shutil
from PySide6.QtWidgets import QMessageBox
class PatchManager:
"""补丁管理器,用于处理补丁的安装和卸载"""
def __init__(self, app_name, game_info, debug_manager=None):
"""初始化补丁管理器
Args:
app_name: 应用程序名称,用于显示消息框标题
game_info: 游戏信息字典,包含各版本的安装路径和可执行文件名
debug_manager: 调试管理器实例,用于输出调试信息
"""
self.app_name = app_name
self.game_info = game_info
self.debug_manager = debug_manager
self.installed_status = {} # 游戏版本的安装状态
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 game_version not in self.game_info:
if not silent:
QMessageBox.critical(
None,
f"错误 - {self.app_name}",
f"\n无法识别游戏版本: {game_version}\n",
QMessageBox.StandardButton.Ok,
)
return False if not silent else {"success": False, "message": f"无法识别游戏版本: {game_version}", "files_removed": 0}
if debug_mode:
print(f"DEBUG: 开始卸载 {game_version} 补丁,目录: {game_dir}")
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)
# 尝试查找补丁文件,支持不同大小写
patch_files_to_check = [
patch_file_path,
patch_file_path.lower(),
patch_file_path.upper(),
patch_file_path.replace("_", ""),
patch_file_path.replace("_", "-"),
]
# 查找并删除补丁文件
patch_file_found = False
for patch_path in patch_files_to_check:
if os.path.exists(patch_path):
patch_file_found = True
os.remove(patch_path)
files_removed += 1
if debug_mode:
print(f"DEBUG: 已删除补丁文件: {patch_path}")
if not patch_file_found and debug_mode:
print(f"DEBUG: 未找到补丁文件,检查了以下路径: {patch_files_to_check}")
# 检查是否有额外的签名文件 (.sig)
if game_version == "NEKOPARA After":
for patch_path in patch_files_to_check:
sig_file_path = f"{patch_path}.sig"
if os.path.exists(sig_file_path):
os.remove(sig_file_path)
files_removed += 1
if debug_mode:
print(f"DEBUG: 已删除签名文件: {sig_file_path}")
# 删除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):
shutil.rmtree(patch_folder)
files_removed += 1
if debug_mode:
print(f"DEBUG: 已删除补丁文件夹: {patch_folder}")
# 删除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):
shutil.rmtree(game_patch_folder)
files_removed += 1
if debug_mode:
print(f"DEBUG: 已删除game/patch文件夹: {game_patch_folder}")
# 删除配置文件
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):
os.remove(config_path)
files_removed += 1
if debug_mode:
print(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):
os.remove(script_path)
files_removed += 1
if debug_mode:
print(f"DEBUG: 已删除脚本文件: {script_path}")
# 更新安装状态
self.installed_status[game_version] = False
# 在非静默模式且非批量卸载模式下显示卸载成功消息
if not silent and game_version != "all":
# 显示卸载成功消息
if files_removed > 0:
QMessageBox.information(
None,
f"卸载完成 - {self.app_name}",
f"\n{game_version} 补丁卸载成功!\n共删除 {files_removed} 个文件/文件夹。\n",
QMessageBox.StandardButton.Ok,
)
else:
QMessageBox.warning(
None,
f"警告 - {self.app_name}",
f"\n未找到 {game_version} 的补丁文件,可能未安装补丁或已被移除。\n",
QMessageBox.StandardButton.Ok,
)
# 卸载成功
if silent:
return {"success": True, "message": f"{game_version} 补丁卸载成功", "files_removed": files_removed}
return True
except Exception as e:
# 在非静默模式且非批量卸载模式下显示卸载失败消息
if not silent and game_version != "all":
# 显示卸载失败消息
error_message = f"\n卸载 {game_version} 补丁时出错:\n\n{str(e)}\n"
if debug_mode:
print(f"DEBUG: 卸载错误 - {str(e)}")
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 = []
for version, path in game_dirs.items():
try:
# 在批量模式下使用静默卸载
result = self.uninstall_patch(path, version, silent=True)
if isinstance(result, dict): # 使用了静默模式
if result["success"]:
success_count += 1
else:
fail_count += 1
results.append({
"version": version,
"success": result["success"],
"message": result["message"],
"files_removed": result["files_removed"]
})
else: # 兼容旧代码,不应该执行到这里
if result:
success_count += 1
else:
fail_count += 1
results.append({
"version": version,
"success": result,
"message": f"{version} 卸载{'成功' if result else '失败'}",
"files_removed": 0
})
except Exception as e:
if debug_mode:
print(f"DEBUG: 卸载 {version} 时出错: {str(e)}")
fail_count += 1
results.append({
"version": version,
"success": False,
"message": f"卸载出错: {str(e)}",
"files_removed": 0
})
return success_count, fail_count, results
def show_uninstall_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:
success_list = [r["version"] for r in results if r["success"]]
fail_list = [r["version"] for r in results if not r["success"]]
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"
QMessageBox.information(
None,
f"批量卸载完成 - {self.app_name}",
result_text,
QMessageBox.StandardButton.Ok,
)
def check_patch_installed(self, game_dir, game_version):
"""检查游戏是否已安装补丁
Args:
game_dir: 游戏目录路径
game_version: 游戏版本
Returns:
bool: 如果已安装补丁返回True否则返回False
"""
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:
print(f"DEBUG: 找到补丁文件: {patch_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:
print(f"DEBUG: 找到补丁文件夹: {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:
print(f"DEBUG: 找到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:
print(f"DEBUG: 找到配置文件: {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:
print(f"DEBUG: 找到脚本文件: {script_path}")
return True
# 没有找到补丁文件或文件夹
if debug_mode:
print(f"DEBUG: {game_version}{game_dir} 中没有安装补丁")
return False

View File

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

673
source/core/ui_manager.py Normal file
View File

@@ -0,0 +1,673 @@
from PySide6.QtGui import QIcon, QAction, QFont, QCursor
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))
# 设置窗口标题
self.main_window.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
# 创建关于按钮
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.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是否可用
ipv6_available = False
if self.ipv6_manager:
ipv6_available = self.ipv6_manager.check_ipv6_availability()
if not ipv6_available:
self.ipv6_action.setText("启用IPv6支持 (不可用)")
self.ipv6_action.setEnabled(False)
self.ipv6_action.setToolTip("未检测到可用的IPv6连接")
# 检查配置中是否已启用IPv6
config = getattr(self.main_window, 'config', {})
ipv6_enabled = False
if isinstance(config, dict):
ipv6_enabled = config.get("ipv6_enabled", False)
# 如果配置中启用了IPv6但实际不可用则强制禁用
if ipv6_enabled and not ipv6_available:
config["ipv6_enabled"] = False
ipv6_enabled = False
# 使用utils.save_config直接保存配置
from utils import save_config
save_config(config)
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.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.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.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
# 使用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):
"""打开log.txt文件"""
try:
# 使用操作系统默认程序打开日志文件
if os.name == 'nt': # Windows
os.startfile(LOG_FILE)
else: # macOS 和 Linux
import subprocess
subprocess.call(['xdg-open', LOG_FILE])
except Exception as e:
msg_box = self._create_message_box("错误", f"\n打开log.txt文件失败\n\n{str(e)}\n")
msg_box.exec()
def restore_hosts_backup(self):
"""还原软件备份的hosts文件"""
if hasattr(self.main_window, 'download_manager') and hasattr(self.main_window.download_manager, 'hosts_manager'):
try:
# 调用恢复hosts文件的方法
result = self.main_window.download_manager.hosts_manager.restore()
if result:
msg_box = self._create_message_box("成功", "\nhosts文件已成功还原为备份版本。\n")
else:
msg_box = self._create_message_box("警告", "\n还原hosts文件失败或没有找到备份文件。\n")
msg_box.exec()
except Exception as e:
msg_box = self._create_message_box("错误", f"\n还原hosts文件时发生错误\n\n{str(e)}\n")
msg_box.exec()
else:
msg_box = self._create_message_box("错误", "\n无法访问hosts管理器。\n")
msg_box.exec()
def clean_hosts_entries(self):
"""手动删除软件添加的hosts条目"""
if hasattr(self.main_window, 'download_manager') and hasattr(self.main_window.download_manager, 'hosts_manager'):
try:
# 调用清理hosts条目的方法
result = self.main_window.download_manager.hosts_manager.check_and_clean_all_entries()
if result:
msg_box = self._create_message_box("成功", "\n已成功清理软件添加的hosts条目。\n")
else:
msg_box = self._create_message_box("提示", "\n未发现软件添加的hosts条目或清理操作失败。\n")
msg_box.exec()
except Exception as e:
msg_box = self._create_message_box("错误", f"\n清理hosts条目时发生错误\n\n{str(e)}\n")
msg_box.exec()
else:
msg_box = self._create_message_box("错误", "\n无法访问hosts管理器。\n")
msg_box.exec()
def open_hosts_file(self):
"""打开系统hosts文件"""
try:
# 获取hosts文件路径
hosts_path = os.path.join(os.environ['SystemRoot'], 'System32', 'drivers', 'etc', 'hosts')
# 检查文件是否存在
if os.path.exists(hosts_path):
# 使用操作系统默认程序打开hosts文件
if os.name == 'nt': # Windows
# 尝试以管理员权限打开记事本编辑hosts文件
try:
# 使用PowerShell以管理员身份启动记事本
subprocess.Popen(["powershell", "Start-Process", "notepad", hosts_path, "-Verb", "RunAs"])
except Exception as e:
# 如果失败,尝试直接打开
os.startfile(hosts_path)
else: # macOS 和 Linux
import subprocess
subprocess.call(['xdg-open', hosts_path])
else:
msg_box = self._create_message_box("错误", f"\nhosts文件不存在\n{hosts_path}\n")
msg_box.exec()
except Exception as e:
msg_box = self._create_message_box("错误", f"\n打开hosts文件时发生错误\n\n{str(e)}\n")
msg_box.exec()
def show_about_dialog(self):
"""显示关于对话框"""
about_text = f"""
<p><b>{APP_NAME} v{APP_VERSION}</b></p>
<p>GitHub: <a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT">https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT</a></p>
<p>原作: <a href="https://github.com/Yanam1Anna">Yanam1Anna</a></p>
<p>此应用根据 <a href="https://github.com/hyb-oyqq/FRAISEMOE-Addons-Installer-NEXT/blob/master/LICENSE">GPL-3.0 许可证</a> 授权。</p>
<br>
<p><b>感谢:</b></p>
<p>- <a href="https://github.com/HTony03">HTony03</a>:对原项目部分源码的重构、逻辑优化和功能实现提供了支持。</p>
<p>- <a href="https://github.com/ABSIDIA">钨鸮</a>:对于云端资源存储提供了支持。</p>
<p>- <a href="https://github.com/XIU2/CloudflareSpeedTest">XIU2/CloudflareSpeedTest</a>:提供了 IP 优选功能的核心支持。</p>
<p>- <a href="https://github.com/hosxy/aria2-fast">hosxy/aria2-fast</a>提供了修改版aria2c提高了下载速度和性能。</p>
"""
msg_box = msgbox_frame(
f"关于 - {APP_NAME}",
about_text,
QMessageBox.StandardButton.Ok,
)
msg_box.setTextFormat(Qt.TextFormat.RichText) # 使用Qt.TextFormat
msg_box.exec()
def show_ipv6_manager_not_ready(self):
"""显示IPv6管理器未准备好的提示"""
msg_box = self._create_message_box("错误", "\nIPv6管理器尚未初始化请稍后再试。\n")
msg_box.exec()

View File

@@ -0,0 +1,141 @@
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.28) - 10 # 调整为更靠上的位置
self.ui.button_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.46) - 10 # 调整为中间位置
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.64) - 10 # 调整为更靠下的位置
self.ui.exit_container.setGeometry(x_pos, y_pos, btn_width, btn_height)
# 更新圆角
if hasattr(self, 'updateRoundedCorners') and self.updateRoundedCorners:
path = QPainterPath()
path.addRoundedRect(self.window.rect(), 20, 20)
mask = QRegion(path.toFillPolygon().toPolygon())
self.window.setMask(mask)

View File

@@ -3,7 +3,7 @@ import base64
# 配置信息
app_data = {
"APP_VERSION": "1.1.0",
"APP_VERSION": "1.3.1",
"APP_NAME": "FRAISEMOE Addons Installer NEXT",
"TEMP": "TEMP",
"CACHE": "FRAISEMOE",
@@ -63,4 +63,16 @@ GAME_INFO = app_data["game_info"]
BLOCK_SIZE = 67108864
HASH_SIZE = 134217728
PLUGIN_HASH = {game: info["hash"] for game, info in GAME_INFO.items()}
PROCESS_INFO = {info["exe"]: game for game, info in GAME_INFO.items()}
PROCESS_INFO = {info["exe"]: game for game, info in GAME_INFO.items()}
# 下载线程档位设置
DOWNLOAD_THREADS = {
"low": 1, # 低速
"medium": 8, # 中速(默认)
"high": 16, # 高速
"extreme": 32, # 极速
"insane": 64 # 狂暴
}
# 默认下载线程档位
DEFAULT_DOWNLOAD_THREAD_LEVEL = "high"

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import re
import sys
from datetime import datetime
# 隐私协议的缩略版内容
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')
print(f"成功读取本地隐私协议文件: {path}, 版本: {date_version}")
return content, date_version, ""
except ValueError:
print(f"本地隐私协议日期格式解析错误: {path}")
else:
print(f"本地隐私协议未找到更新日期: {path}")
except Exception as e:
print(f"读取本地隐私协议失败 {path}: {str(e)}")
# 所有路径都尝试失败,使用默认版本
return PRIVACY_POLICY_BRIEF, PRIVACY_POLICY_VERSION, "无法读取本地隐私协议文件"

View File

@@ -1,186 +0,0 @@
import os
import sys
import subprocess
import re
from urllib.parse import urlparse
from PySide6 import QtCore, QtWidgets
from PySide6.QtCore import (Qt, Signal, QThread, QTimer)
from PySide6.QtWidgets import (QLabel, QProgressBar, QVBoxLayout, QDialog)
from utils import resource_path
from config import APP_NAME, UA
# 下载线程类
class DownloadThread(QThread):
progress = Signal(dict)
finished = Signal(bool, str)
def __init__(self, url, _7z_path, game_version, parent=None):
super().__init__(parent)
self.url = url
self._7z_path = _7z_path
self.game_version = game_version
self.process = None
self._is_running = True
def stop(self):
if self.process and self.process.poll() is None:
self._is_running = False
try:
# 使用 taskkill 强制终止进程及其子进程,并隐藏窗口
subprocess.run(['taskkill', '/F', '/T', '/PID', str(self.process.pid)], check=True, creationflags=subprocess.CREATE_NO_WINDOW)
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print(f"停止下载进程时出错: {e}")
def run(self):
try:
if not self._is_running:
self.finished.emit(False, "下载已手动停止。")
return
aria2c_path = resource_path("aria2c.exe")
download_dir = os.path.dirname(self._7z_path)
file_name = os.path.basename(self._7z_path)
parsed_url = urlparse(self.url)
referer = f"{parsed_url.scheme}://{parsed_url.netloc}/"
command = [
aria2c_path,
]
command.extend([
'--dir', download_dir,
'--out', file_name,
'--user-agent', UA,
'--referer', referer,
'--header', f'Origin: {referer.rstrip("/")}',
'--header', 'Accept: */*',
'--header', 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8',
'--header', 'Accept-Encoding: gzip, deflate, br',
'--header', 'Cache-Control: no-cache',
'--header', 'Pragma: no-cache',
'--header', 'DNT: 1',
'--header', 'Sec-Fetch-Dest: empty',
'--header', 'Sec-Fetch-Mode: cors',
'--header', 'Sec-Fetch-Site: same-origin',
'--http-accept-gzip=true',
'--console-log-level=info',
'--summary-interval=1',
'--log-level=info',
'--max-tries=3',
'--retry-wait=2',
'--connect-timeout=60',
'--timeout=60',
'--auto-file-renaming=false',
'--allow-overwrite=true',
'--split=16',
'--max-connection-per-server=16'
])
# 证书验证现在总是需要因为我们依赖hosts文件
command.append('--check-certificate=false')
command.append(self.url)
# 打印将要执行的命令,用于调试
print(f"即将执行的 Aria2c 命令: {' '.join(command)}")
creation_flags = subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0
self.process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', errors='replace', creationflags=creation_flags)
# 正则表达式用于解析aria2c的输出
# 例如: #1 GID[...]( 5%) CN:1 DL:10.5MiB/s ETA:1m30s
progress_pattern = re.compile(r'\((\d{1,3})%\).*?CN:(\d+).*?DL:\s*([^\s]+).*?ETA:\s*([^\s]+)')
full_output = []
while self._is_running and self.process.poll() is None:
if self.process.stdout:
line = self.process.stdout.readline()
if not line:
break
else:
break
full_output.append(line)
print(line.strip()) # 在控制台输出实时日志
match = progress_pattern.search(line)
if match:
percent = int(match.group(1))
threads = match.group(2)
speed = match.group(3)
eta = match.group(4)
self.progress.emit({
"game": self.game_version,
"percent": percent,
"threads": threads,
"speed": speed,
"eta": eta
})
return_code = self.process.wait()
if not self._is_running: # 如果是手动停止的
self.finished.emit(False, "下载已手动停止。")
return
if return_code == 0:
self.progress.emit({
"game": self.game_version,
"percent": 100,
"threads": "N/A",
"speed": "N/A",
"eta": "完成"
})
self.finished.emit(True, "")
else:
error_message = f"\nAria2c下载失败退出码: {return_code}\n\n--- Aria2c 输出 ---\n{''.join(full_output)}\n---------------------\n"
self.finished.emit(False, error_message)
except Exception as e:
if self._is_running:
self.finished.emit(False, f"\n下载时发生未知错误\n\n【错误信息】: {e}\n")
# 下载进度窗口类
class ProgressWindow(QDialog):
def __init__(self, parent=None):
super(ProgressWindow, self).__init__(parent)
self.setWindowTitle(f"下载进度 - {APP_NAME}")
self.resize(450, 180)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowSystemMenuHint)
layout = QVBoxLayout()
self.game_label = QLabel("正在启动下载,请稍后...")
self.progress_bar = QProgressBar()
self.progress_bar.setValue(0)
self.stats_label = QLabel("速度: - | 线程: - | 剩余时间: -")
self.stop_button = QtWidgets.QPushButton("停止下载")
layout.addWidget(self.game_label)
layout.addWidget(self.progress_bar)
layout.addWidget(self.stats_label)
layout.addWidget(self.stop_button)
self.setLayout(layout)
def update_progress(self, data):
game_version = data.get("game", "未知游戏")
percent = data.get("percent", 0)
speed = data.get("speed", "-")
threads = data.get("threads", "-")
eta = data.get("eta", "-")
self.game_label.setText(f"正在下载: {game_version}")
self.progress_bar.setValue(int(percent))
self.stats_label.setText(f"速度: {speed} | 线程: {threads} | 剩余时间: {eta}")
if percent == 100:
self.stop_button.setEnabled(False)
self.stop_button.setText("下载完成")
QTimer.singleShot(1500, self.accept)
def closeEvent(self, event):
# 覆盖默认的关闭事件,防止用户通过其他方式关闭窗口
# 如果需要,可以在这里添加逻辑,例如询问用户是否要停止下载
event.ignore()

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

View File

@@ -1,333 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindows</class>
<widget class="QMainWindow" name="MainWindows">
<property name="enabled">
<bool>true</bool>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1024</width>
<height>576</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>1024</width>
<height>576</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>1024</width>
<height>576</height>
</size>
</property>
<property name="mouseTracking">
<bool>false</bool>
</property>
<property name="tabletTracking">
<bool>false</bool>
</property>
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="windowTitle">
<string> UI Test</string>
</property>
<property name="autoFillBackground">
<bool>true</bool>
</property>
<property name="toolButtonStyle">
<enum>Qt::ToolButtonStyle::ToolButtonIconOnly</enum>
</property>
<property name="animated">
<bool>true</bool>
</property>
<property name="documentMode">
<bool>false</bool>
</property>
<property name="dockNestingEnabled">
<bool>false</bool>
</property>
<widget class="QWidget" name="centralwidget">
<property name="autoFillBackground">
<bool>true</bool>
</property>
<widget class="QLabel" name="loadbg">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1031</width>
<height>561</height>
</rect>
</property>
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap>IMG/BG/bg2.jpg</pixmap>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
</widget>
<widget class="QLabel" name="vol1bg">
<property name="geometry">
<rect>
<x>0</x>
<y>120</y>
<width>93</width>
<height>64</height>
</rect>
</property>
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap>IMG/LOGO/vo01_logo.png</pixmap>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
</widget>
<widget class="QLabel" name="vol2bg">
<property name="geometry">
<rect>
<x>0</x>
<y>180</y>
<width>93</width>
<height>64</height>
</rect>
</property>
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap>IMG/LOGO/vo02_logo.png</pixmap>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
</widget>
<widget class="QLabel" name="vol3bg">
<property name="geometry">
<rect>
<x>0</x>
<y>240</y>
<width>93</width>
<height>64</height>
</rect>
</property>
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap>IMG/LOGO/vo03_logo.png</pixmap>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
</widget>
<widget class="QLabel" name="vol4bg">
<property name="geometry">
<rect>
<x>0</x>
<y>300</y>
<width>93</width>
<height>64</height>
</rect>
</property>
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap>IMG/LOGO/vo04_logo.png</pixmap>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
</widget>
<widget class="QLabel" name="afterbg">
<property name="geometry">
<rect>
<x>0</x>
<y>360</y>
<width>93</width>
<height>64</height>
</rect>
</property>
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap>IMG/LOGO/voaf_logo.png</pixmap>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
</widget>
<widget class="QLabel" name="Mainbg">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1031</width>
<height>561</height>
</rect>
</property>
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap>IMG/BG/bg3.jpg</pixmap>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
</widget>
<widget class="QPushButton" name="start_install_btn">
<property name="enabled">
<bool>true</bool>
</property>
<property name="geometry">
<rect>
<x>780</x>
<y>250</y>
<width>191</width>
<height>91</height>
</rect>
</property>
<property name="accessibleDescription">
<string/>
</property>
<property name="autoFillBackground">
<bool>false</bool>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset>
<normaloff>IMG/BTN/start_install.bmp</normaloff>IMG/BTN/start_install.bmp</iconset>
</property>
<property name="iconSize">
<size>
<width>189</width>
<height>110</height>
</size>
</property>
<property name="checkable">
<bool>false</bool>
</property>
<property name="autoRepeat">
<bool>false</bool>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
<property name="flat">
<bool>true</bool>
</property>
</widget>
<widget class="QPushButton" name="exit_btn">
<property name="enabled">
<bool>true</bool>
</property>
<property name="geometry">
<rect>
<x>780</x>
<y>340</y>
<width>191</width>
<height>91</height>
</rect>
</property>
<property name="autoFillBackground">
<bool>false</bool>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset>
<normaloff>IMG/BTN/exit.bmp</normaloff>IMG/BTN/exit.bmp</iconset>
</property>
<property name="iconSize">
<size>
<width>189</width>
<height>110</height>
</size>
</property>
<property name="checkable">
<bool>false</bool>
</property>
<property name="flat">
<bool>true</bool>
</property>
</widget>
<widget class="QLabel" name="menubg">
<property name="geometry">
<rect>
<x>710</x>
<y>0</y>
<width>321</width>
<height>561</height>
</rect>
</property>
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap>IMG/BG/menubg.jpg</pixmap>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
</widget>
<zorder>loadbg</zorder>
<zorder>vol1bg</zorder>
<zorder>vol2bg</zorder>
<zorder>vol3bg</zorder>
<zorder>vol4bg</zorder>
<zorder>afterbg</zorder>
<zorder>Mainbg</zorder>
<zorder>menubg</zorder>
<zorder>start_install_btn</zorder>
<zorder>exit_btn</zorder>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1024</width>
<height>21</height>
</rect>
</property>
<widget class="QMenu" name="menu">
<property name="title">
<string>设置</string>
</property>
<addaction name="separator"/>
<addaction name="action_2"/>
</widget>
<widget class="QMenu" name="menu_2">
<property name="title">
<string>关于</string>
</property>
</widget>
<addaction name="menu"/>
<addaction name="menu_2"/>
</widget>
<action name="action_2">
<property name="text">
<string>update - sd</string>
</property>
</action>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -1,146 +0,0 @@
import os
import re
import subprocess
import sys
import time
from urllib.parse import urlparse
from utils import resource_path
class IpOptimizer:
def __init__(self):
self.process = None
def get_optimal_ip(self, url: str) -> str | None:
"""
使用 CloudflareSpeedTest 工具获取给定 URL 的最优 Cloudflare IP。
Args:
url: 需要进行优选的下载链接。
Returns:
最优的 IP 地址字符串,如果找不到则返回 None。
"""
try:
cst_path = resource_path("cfst.exe")
if not os.path.exists(cst_path):
print(f"错误: cfst.exe 未在资源路径中找到。")
return None
ip_txt_path = resource_path("ip.txt")
command = [
cst_path,
"-p", "1",
"-o", "",
"-url", url,
"-f", ip_txt_path,
"-dd",
]
creation_flags = subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0
print("--- CloudflareSpeedTest 开始执行 ---")
self.process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding='utf-8',
errors='replace',
creationflags=creation_flags,
bufsize=0
)
# 立即向 stdin 发送换行符,以便程序在 Windows 下正常退出
if self.process.stdin:
try:
self.process.stdin.write('\n')
self.process.stdin.flush()
except:
pass
finally:
self.process.stdin.close()
ip_pattern = re.compile(r'^\s*([\d\.]+)\s+\d+\s+\d+\s+[\d\.]+%?\s+[\d\.]+\s+[\d\.]+\s+.*$')
stdout = self.process.stdout
if not stdout:
print("错误: 无法获取子进程的输出流。")
return None
optimal_ip = None
timeout_counter = 0
max_timeout = 60
while True:
if self.process.poll() is not None:
break
try:
ready = True
try:
line = stdout.readline()
except:
ready = False
if not ready or not line:
timeout_counter += 1
if timeout_counter > max_timeout:
print("超时: CloudflareSpeedTest 响应超时")
break
time.sleep(1)
continue
timeout_counter = 0
cleaned_line = line.strip()
if cleaned_line:
print(cleaned_line)
match = ip_pattern.match(cleaned_line)
if match:
optimal_ip = match.group(1)
print(f"找到最优 IP: {optimal_ip}, 正在终止测速进程...")
break
except Exception as e:
print(f"读取输出时发生错误: {e}")
break
self.stop()
print("--- CloudflareSpeedTest 执行结束 ---")
return optimal_ip
except Exception as e:
print(f"执行 CloudflareSpeedTest 时发生错误: {e}")
return None
def stop(self):
if self.process and self.process.poll() is None:
print("正在终止 CloudflareSpeedTest 进程...")
try:
if self.process.stdin and not self.process.stdin.closed:
self.process.stdin.write('\n')
self.process.stdin.flush()
self.process.stdin.close()
except:
pass
try:
self.process.terminate()
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
print("CloudflareSpeedTest 进程已终止。")
if __name__ == '__main__':
# 用于直接测试此模块
test_url = "https://speed.cloudflare.com/__down?during=download&bytes=104857600"
optimizer = IpOptimizer()
ip = optimizer.get_optimal_ip(test_url)
if ip:
print(f"{test_url} 找到的最优 IP 是: {ip}")
else:
print(f"未能为 {test_url} 找到最优 IP。")

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>480</width>
<height>270</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
</widget>
<resources/>
<connections/>
</ui>

519
source/ui/Ui_install.py Normal file
View File

@@ -0,0 +1,519 @@
from PySide6.QtGui import QPixmap
import base64
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
QCursor, QFont, QFontDatabase, QGradient,
QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient,
QTransform, QPainterPath, QRegion)
from PySide6.QtWidgets import (QApplication, QLabel, QMainWindow, QMenu,
QMenuBar, QPushButton, QSizePolicy, QWidget, QHBoxLayout)
import os
# 导入配置常量
from data.config import APP_NAME, APP_VERSION
from utils import load_image_from_file
class Ui_MainWindows(object):
def setupUi(self, MainWindows):
if not MainWindows.objectName():
MainWindows.setObjectName(u"MainWindows")
MainWindows.setEnabled(True)
# 调整窗口默认大小为1280x720以匹配背景图片
MainWindows.resize(1280, 720)
# 锁定窗口比例为16:9确保不会变形同时限制最大尺寸不超过背景图片
MainWindows.setMinimumSize(QSize(1024, 576)) # 16:9最小尺寸
MainWindows.setMaximumSize(QSize(1280, 720)) # 将最大尺寸限制为1280x720
# 设置固定纵横比
self.aspect_ratio = 16/9
MainWindows.setMouseTracking(False)
MainWindows.setTabletTracking(False)
MainWindows.setAcceptDrops(True)
MainWindows.setAutoFillBackground(False)
MainWindows.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
MainWindows.setAnimated(True)
MainWindows.setDocumentMode(False)
MainWindows.setDockNestingEnabled(False)
# 加载自定义字体
font_id = QFontDatabase.addApplicationFont(os.path.join(os.path.dirname(os.path.dirname(__file__)), "fonts", "SmileySans-Oblique.ttf"))
font_family = QFontDatabase.applicationFontFamilies(font_id)[0] if font_id != -1 else "Arial"
self.custom_font = QFont(font_family, 16) # 创建字体对象大小为16
self.custom_font.setWeight(QFont.Weight.Medium) # 设置为中等粗细,不要太粗
self.centralwidget = QWidget(MainWindows)
self.centralwidget.setObjectName(u"centralwidget")
self.centralwidget.setAutoFillBackground(False) # 修改为False以支持透明背景
self.centralwidget.setStyleSheet("""
QWidget#centralwidget {
background-color: transparent;
}
""")
# 圆角背景容器
self.main_container = QWidget(self.centralwidget)
self.main_container.setObjectName(u"main_container")
self.main_container.setGeometry(QRect(0, 0, 1280, 720))
self.main_container.setStyleSheet("""
QWidget#main_container {
background-color: #E96948;
border-radius: 20px;
border: 1px solid #E96948;
}
""")
# 内容容器 - 用于限制内容在圆角范围内
self.content_container = QWidget(self.main_container)
self.content_container.setObjectName(u"content_container")
self.content_container.setGeometry(QRect(0, 0, 1280, 720))
self.content_container.setStyleSheet("""
QWidget#content_container {
background-color: transparent;
border-radius: 20px;
}
""")
# 添加圆角裁剪,确保内容在圆角范围内
rect = QRect(0, 0, 1280, 720)
path = QPainterPath()
path.addRoundedRect(rect, 20, 20)
region = QRegion(path.toFillPolygon().toPolygon())
self.content_container.setMask(region)
# 标题栏
self.title_bar = QWidget(self.content_container)
self.title_bar.setObjectName(u"title_bar")
self.title_bar.setGeometry(QRect(0, 0, 1280, 35)) # 减小高度从40到35
self.title_bar.setStyleSheet("""
QWidget#title_bar {
background-color: #E96948;
border-top-left-radius: 20px;
border-top-right-radius: 20px;
border-bottom: 1px solid #F47A5B;
}
""")
# 标题栏布局
self.title_layout = QHBoxLayout(self.title_bar)
self.title_layout.setSpacing(10)
self.title_layout.setContentsMargins(10, 0, 10, 0)
# 添加最小化和关闭按钮到标题栏
self.minimize_btn = QPushButton(self.title_bar)
self.minimize_btn.setObjectName(u"minimize_btn")
self.minimize_btn.setMinimumSize(QSize(24, 24))
self.minimize_btn.setMaximumSize(QSize(24, 24))
self.minimize_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.minimize_btn.setStyleSheet("""
QPushButton {
background-color: #FFC107;
border-radius: 12px;
border: none;
}
QPushButton:hover {
background-color: #FFD54F;
}
QPushButton:pressed {
background-color: #FFA000;
}
""")
self.minimize_btn.setText("")
self.minimize_btn.setFont(QFont(font_family, 10))
self.close_btn = QPushButton(self.title_bar)
self.close_btn.setObjectName(u"close_btn")
self.close_btn.setMinimumSize(QSize(24, 24))
self.close_btn.setMaximumSize(QSize(24, 24))
self.close_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.close_btn.setStyleSheet("""
QPushButton {
background-color: #F44336;
border-radius: 12px;
border: none;
color: white;
font-weight: bold;
}
QPushButton:hover {
background-color: #EF5350;
}
QPushButton:pressed {
background-color: #D32F2F;
}
""")
self.close_btn.setText("×")
self.close_btn.setFont(QFont(font_family, 14))
# 标题文本
self.title_label = QLabel(self.title_bar)
self.title_label.setObjectName(u"title_label")
# 直接使用APP_NAME并添加版本号
self.title_label.setText(f"{APP_NAME} v{APP_VERSION}")
title_font = QFont(font_family, 14) # 减小字体从16到14
title_font.setBold(True)
self.title_label.setFont(title_font)
self.title_label.setStyleSheet("color: #333333; padding-left: 10px;")
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# 添加按钮到标题栏布局
self.title_layout.addWidget(self.title_label)
self.title_layout.addStretch(1)
self.title_layout.addWidget(self.minimize_btn)
self.title_layout.addSpacing(5)
self.title_layout.addWidget(self.close_btn)
# 修改菜单区域 - 确保足够宽以容纳更多菜单项
self.menu_area = QWidget(self.content_container)
self.menu_area.setObjectName(u"menu_area")
self.menu_area.setGeometry(QRect(0, 35, 1280, 30)) # 调整位置从40到35高度从35到30
self.menu_area.setStyleSheet("""
QWidget#menu_area {
background-color: #E96948;
}
""")
# 不再使用菜单栏,改用普通按钮
# 创建菜单按钮字体
menu_font = QFont(font_family, 14) # 进一步减小字体大小到14
menu_font.setBold(True)
# 设置按钮
self.settings_btn = QPushButton("设置", self.menu_area)
self.settings_btn.setObjectName(u"settings_btn")
self.settings_btn.setGeometry(QRect(20, 1, 80, 28)) # 调整高度和Y位置
self.settings_btn.setFont(menu_font)
self.settings_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.settings_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;
}
""")
# 帮助按钮
self.help_btn = QPushButton("帮助", self.menu_area)
self.help_btn.setObjectName(u"help_btn")
self.help_btn.setGeometry(QRect(120, 1, 80, 28)) # 调整高度和Y位置
self.help_btn.setFont(menu_font)
self.help_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.help_btn.setStyleSheet(self.settings_btn.styleSheet())
# 将原来的菜单项移到全局,方便访问
self.menu = QMenu(self.content_container)
self.menu.setObjectName(u"menu")
self.menu.setTitle("设置")
self.menu.setFont(menu_font)
# 创建菜单样式表,直接在样式表中指定字体族
menu_style = 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-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;
}}
"""
self.menu.setStyleSheet(menu_style)
self.menu_2 = QMenu(self.content_container)
self.menu_2.setObjectName(u"menu_2")
self.menu_2.setTitle("帮助")
self.menu_2.setFont(menu_font)
self.menu_2.setStyleSheet(menu_style)
# 连接按钮点击事件到显示对应菜单
self.settings_btn.clicked.connect(lambda: self.show_menu(self.menu, self.settings_btn))
self.help_btn.clicked.connect(lambda: self.show_menu(self.menu_2, self.help_btn))
# 预留位置给未来可能的第三个按钮
# 第三个按钮可以这样添加:
# self.third_btn = QPushButton("第三项", self.menu_area)
# self.third_btn.setObjectName(u"third_btn")
# self.third_btn.setGeometry(QRect(320, 0, 120, 35))
# self.third_btn.setFont(menu_font)
# self.third_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
# self.third_btn.setStyleSheet(self.settings_btn.styleSheet())
# self.third_btn.clicked.connect(lambda: self.show_menu(self.menu_3, self.third_btn))
# 内容子容器
self.inner_content = QWidget(self.content_container)
self.inner_content.setObjectName(u"inner_content")
# 确保宽度足够大,保证右侧元素完全显示
self.inner_content.setGeometry(QRect(0, 65, 1280, 655)) # 调整Y位置从75到65高度从645到655
self.inner_content.setStyleSheet("""
QWidget#inner_content {
background-color: transparent;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
""")
# 添加底部圆角裁剪
inner_rect = QRect(0, 0, 1280, 665)
inner_path = QPainterPath()
inner_path.addRoundedRect(inner_rect, 20, 20)
inner_region = QRegion(inner_path.toFillPolygon().toPolygon())
self.inner_content.setMask(inner_region)
# 在主容器中添加背景和内容元素
# 修改loadbg使用title_bg1.png作为整个背景
# 原来的loadbg保持不变
self.loadbg = QLabel(self.inner_content)
self.loadbg.setObjectName(u"loadbg")
self.loadbg.setGeometry(QRect(0, 0, 1280, 655))
# 加载背景图并允许拉伸
bg_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "BG", "bg1.jpg")
bg_pixmap = QPixmap(bg_path)
self.loadbg.setPixmap(bg_pixmap)
self.loadbg.setScaledContents(True)
self.vol1bg = QLabel(self.inner_content)
self.vol1bg.setObjectName(u"vol1bg")
self.vol1bg.setGeometry(QRect(0, 150, 93, 64))
# 直接加载图片文件
vol1_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "LOGO", "vo01_logo.png")
self.vol1bg.setPixmap(QPixmap(vol1_path))
self.vol1bg.setScaledContents(True)
self.vol2bg = QLabel(self.inner_content)
self.vol2bg.setObjectName(u"vol2bg")
self.vol2bg.setGeometry(QRect(0, 210, 93, 64))
# 直接加载图片文件
vol2_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "LOGO", "vo02_logo.png")
self.vol2bg.setPixmap(QPixmap(vol2_path))
self.vol2bg.setScaledContents(True)
self.vol3bg = QLabel(self.inner_content)
self.vol3bg.setObjectName(u"vol3bg")
self.vol3bg.setGeometry(QRect(0, 270, 93, 64))
# 直接加载图片文件
vol3_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "LOGO", "vo03_logo.png")
self.vol3bg.setPixmap(QPixmap(vol3_path))
self.vol3bg.setScaledContents(True)
self.vol4bg = QLabel(self.inner_content)
self.vol4bg.setObjectName(u"vol4bg")
self.vol4bg.setGeometry(QRect(0, 330, 93, 64))
# 直接加载图片文件
vol4_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "LOGO", "vo04_logo.png")
self.vol4bg.setPixmap(QPixmap(vol4_path))
self.vol4bg.setScaledContents(True)
self.afterbg = QLabel(self.inner_content)
self.afterbg.setObjectName(u"afterbg")
self.afterbg.setGeometry(QRect(0, 390, 93, 64))
# 直接加载图片文件
after_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "LOGO", "voaf_logo.png")
self.afterbg.setPixmap(QPixmap(after_path))
self.afterbg.setScaledContents(True)
# 修复Mainbg位置并使用title_bg1.png作为背景图片
self.Mainbg = QLabel(self.inner_content)
self.Mainbg.setObjectName(u"Mainbg")
self.Mainbg.setGeometry(QRect(0, 0, 1280, 655))
# 允许拉伸以填满整个区域
main_bg_pixmap = load_image_from_file(os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "BG", "title_bg1.png"))
# 如果加载的图片不是空的,则设置,并允许拉伸填满
if not main_bg_pixmap.isNull():
self.Mainbg.setPixmap(main_bg_pixmap)
self.Mainbg.setScaledContents(True)
self.Mainbg.setAlignment(Qt.AlignmentFlag.AlignCenter)
# 使用新的按钮图片
button_pixmap = load_image_from_file(os.path.join(os.path.dirname(os.path.dirname(__file__)), "IMG", "BTN", "Button.png"))
# 创建文本标签布局的按钮
# 开始安装按钮 - 基于背景图片和标签组合
# 调整开始安装按钮的位置
self.button_container = QWidget(self.inner_content)
self.button_container.setObjectName(u"start_install_container")
self.button_container.setGeometry(QRect(1050, 200, 211, 111)) # 调整Y坐标上移至200
# 不要隐藏容器,让动画系统来控制它的可见性和位置
# 使用原来的按钮背景图片
self.start_install_bg = QLabel(self.button_container)
self.start_install_bg.setObjectName(u"start_install_bg")
self.start_install_bg.setGeometry(QRect(10, 10, 191, 91)) # 居中放置在扩大的容器中
self.start_install_bg.setPixmap(button_pixmap)
self.start_install_bg.setScaledContents(True)
self.start_install_text = QLabel(self.button_container)
self.start_install_text.setObjectName(u"start_install_text")
self.start_install_text.setGeometry(QRect(10, 7, 191, 91)) # 居中放置在扩大的容器中
self.start_install_text.setText("开始安装")
self.start_install_text.setFont(self.custom_font)
self.start_install_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.start_install_text.setStyleSheet("letter-spacing: 1px;")
# 点击区域透明按钮
self.start_install_btn = QPushButton(self.button_container)
self.start_install_btn.setObjectName(u"start_install_btn")
self.start_install_btn.setGeometry(QRect(10, 10, 191, 91)) # 居中放置在扩大的容器中
self.start_install_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) # 设置鼠标悬停时为手形光标
self.start_install_btn.setFlat(True)
self.start_install_btn.raise_() # 确保按钮在最上层
self.start_install_btn.setStyleSheet("""
QPushButton {
background-color: transparent;
border: none;
}
""")
# 添加卸载补丁按钮 - 新增
self.uninstall_container = QWidget(self.inner_content)
self.uninstall_container.setObjectName(u"uninstall_container")
self.uninstall_container.setGeometry(QRect(1050, 310, 211, 111)) # 调整Y坐标位于310位置
# 使用相同的按钮背景图片
self.uninstall_bg = QLabel(self.uninstall_container)
self.uninstall_bg.setObjectName(u"uninstall_bg")
self.uninstall_bg.setGeometry(QRect(10, 10, 191, 91)) # 居中放置在扩大的容器中
self.uninstall_bg.setPixmap(button_pixmap)
self.uninstall_bg.setScaledContents(True)
self.uninstall_text = QLabel(self.uninstall_container)
self.uninstall_text.setObjectName(u"uninstall_text")
self.uninstall_text.setGeometry(QRect(10, 7, 191, 91)) # 居中放置在扩大的容器中
self.uninstall_text.setText("卸载补丁")
self.uninstall_text.setFont(self.custom_font)
self.uninstall_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.uninstall_text.setStyleSheet("letter-spacing: 1px;")
# 点击区域透明按钮
self.uninstall_btn = QPushButton(self.uninstall_container)
self.uninstall_btn.setObjectName(u"uninstall_btn")
self.uninstall_btn.setGeometry(QRect(10, 10, 191, 91)) # 居中放置在扩大的容器中
self.uninstall_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) # 设置鼠标悬停时为手形光标
self.uninstall_btn.setFlat(True)
self.uninstall_btn.raise_() # 确保按钮在最上层
self.uninstall_btn.setStyleSheet("""
QPushButton {
background-color: transparent;
border: none;
}
""")
# 退出按钮 - 基于背景图片和标签组合,调整位置
self.exit_container = QWidget(self.inner_content)
self.exit_container.setObjectName(u"exit_container")
self.exit_container.setGeometry(QRect(1050, 420, 211, 111)) # 调整Y坐标下移至420
# 不要隐藏容器,让动画系统来控制它的可见性和位置
# 使用原来的按钮背景图片
self.exit_bg = QLabel(self.exit_container)
self.exit_bg.setObjectName(u"exit_bg")
self.exit_bg.setGeometry(QRect(10, 10, 191, 91)) # 居中放置在扩大的容器中
self.exit_bg.setPixmap(button_pixmap)
self.exit_bg.setScaledContents(True)
self.exit_text = QLabel(self.exit_container)
self.exit_text.setObjectName(u"exit_text")
self.exit_text.setGeometry(QRect(10, 7, 191, 91)) # 居中放置在扩大的容器中
self.exit_text.setText("退出程序")
self.exit_text.setFont(self.custom_font)
self.exit_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.exit_text.setStyleSheet("letter-spacing: 1px;")
# 点击区域透明按钮
self.exit_btn = QPushButton(self.exit_container)
self.exit_btn.setObjectName(u"exit_btn")
self.exit_btn.setGeometry(QRect(10, 10, 191, 91)) # 居中放置在扩大的容器中
self.exit_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) # 设置鼠标悬停时为手形光标
self.exit_btn.setFlat(True)
self.exit_btn.raise_() # 确保按钮在最上层
self.exit_btn.setStyleSheet("""
QPushButton {
background-color: transparent;
border: none;
}
""")
MainWindows.setCentralWidget(self.centralwidget)
# 调整层级顺序
self.loadbg.raise_()
self.vol1bg.raise_()
self.vol2bg.raise_()
self.vol3bg.raise_()
self.vol4bg.raise_()
self.afterbg.raise_()
self.Mainbg.raise_()
self.button_container.raise_()
self.uninstall_container.raise_() # 添加新按钮到层级顺序
self.exit_container.raise_()
self.menu_area.raise_() # 确保菜单区域在背景之上
# self.menubar.raise_() # 不再需要菜单栏
self.settings_btn.raise_() # 确保设置按钮在上层
self.help_btn.raise_() # 确保帮助按钮在上层
self.title_bar.raise_() # 确保标题栏在最上层
self.retranslateUi(MainWindows)
QMetaObject.connectSlotsByName(MainWindows)
# setupUi
def retranslateUi(self, MainWindows):
MainWindows.setWindowTitle(QCoreApplication.translate("MainWindows", f"{APP_NAME} v{APP_VERSION}", None))
self.loadbg.setText("")
self.vol1bg.setText("")
self.vol2bg.setText("")
self.vol3bg.setText("")
self.vol4bg.setText("")
self.afterbg.setText("")
self.Mainbg.setText("")
#if QT_CONFIG(accessibility)
self.start_install_btn.setAccessibleDescription("")
#endif // QT_CONFIG(accessibility)
self.menu.setTitle(QCoreApplication.translate("MainWindows", u"设置", None))
self.menu_2.setTitle(QCoreApplication.translate("MainWindows", u"帮助", None))
# retranslateUi
def show_menu(self, menu, button):
"""显示菜单
Args:
menu: 要显示的菜单
button: 触发菜单的按钮
"""
# 计算菜单显示位置
pos = button.mapToGlobal(button.rect().bottomLeft())
menu.exec(pos)

5
source/ui/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .Ui_install import Ui_MainWindows
__all__ = [
'Ui_MainWindows'
]

20
source/utils/__init__.py Normal file
View File

@@ -0,0 +1,20 @@
from .logger import Logger
from .helpers import (
load_base64_image, HashManager, AdminPrivileges, msgbox_frame,
load_config, save_config, HostsManager, censor_url, resource_path,
load_image_from_file
)
__all__ = [
'Logger',
'load_base64_image',
'load_image_from_file',
'HashManager',
'AdminPrivileges',
'msgbox_frame',
'load_config',
'save_config',
'HostsManager',
'censor_url',
'resource_path'
]

View File

@@ -9,17 +9,26 @@ import psutil
from PySide6 import QtCore, QtWidgets
import re
from PySide6.QtGui import QIcon, QPixmap
from pic_data import img_data
from config import APP_NAME, CONFIG_FILE
from data.config import APP_NAME, CONFIG_FILE
def resource_path(relative_path):
"""获取资源的绝对路径,适用于开发环境和PyInstaller打包环境"""
"""获取资源的绝对路径,适用于开发环境和Nuitka打包环境"""
if getattr(sys, 'frozen', False):
# PyInstaller创建的临时文件夹并将路径存储在_MEIPASS中
base_path = getattr(sys, '_MEIPASS', os.path.dirname(sys.executable))
# Nuitka/PyInstaller创建的临时文件夹并将路径存储在_MEIPASS中或与可执行文件同目录
if hasattr(sys, '_MEIPASS'):
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(sys.executable)
else:
# 在开发环境中运行
base_path = os.path.dirname(os.path.abspath(__file__))
base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
# 处理特殊的可执行文件和数据文件路径
if relative_path in ("aria2c-fast_x64.exe", "cfst.exe"):
return os.path.join(base_path, 'bin', relative_path)
elif relative_path in ("ip.txt", "ipv6.txt"):
return os.path.join(base_path, 'data', relative_path)
return os.path.join(base_path, relative_path)
def load_base64_image(base64_str):
@@ -27,14 +36,28 @@ def load_base64_image(base64_str):
pixmap.loadFromData(base64.b64decode(base64_str))
return pixmap
def load_image_from_file(file_path):
"""加载图像文件到QPixmap
Args:
file_path: 图像文件路径
Returns:
QPixmap: 加载的图像
"""
if os.path.exists(file_path):
return QPixmap(file_path)
return QPixmap()
def msgbox_frame(title, text, buttons=QtWidgets.QMessageBox.StandardButton.NoButton):
msg_box = QtWidgets.QMessageBox()
msg_box.setWindowTitle(title)
msg_box.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
icon_data = img_data.get("icon")
if icon_data:
pixmap = load_base64_image(icon_data)
# 直接加载图标文件
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():
msg_box.setWindowIcon(QIcon(pixmap))
msg_box.setIconPixmap(pixmap.scaled(64, 64, QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.TransformationMode.SmoothTransformation))
@@ -89,8 +112,25 @@ class HashManager:
print(f"Error calculating hash for {file_path}: {e}")
return results
def hash_pop_window(self):
msg_box = msgbox_frame(f"通知 - {APP_NAME}", "\n正在检验文件状态...\n")
def hash_pop_window(self, check_type="default"):
"""显示文件检验窗口
Args:
check_type: 检查类型可以是 'pre'(预检查), 'after'(后检查), 'extraction'(解压后检查)
Returns:
QMessageBox: 消息框实例
"""
message = "\n正在检验文件状态...\n"
if check_type == "pre":
message = "\n正在检查游戏文件以确定需要安装的补丁...\n"
elif check_type == "after":
message = "\n正在检验本地文件完整性...\n"
elif check_type == "extraction":
message = "\n正在验证下载的解压文件完整性...\n"
msg_box = msgbox_frame(f"通知 - {APP_NAME}", message)
msg_box.open()
QtWidgets.QApplication.processEvents()
return msg_box
@@ -148,6 +188,7 @@ class AdminPrivileges:
"nekopara_vol1.exe",
"nekopara_vol2.exe",
"NEKOPARAvol3.exe",
"NEKOPARAvol3.exe.nocrack",
"nekopara_vol4.exe",
"nekopara_after.exe",
]
@@ -190,33 +231,40 @@ class AdminPrivileges:
def check_and_terminate_processes(self):
for proc in psutil.process_iter(["pid", "name"]):
if proc.info["name"] in self.required_exes:
msg_box = msgbox_frame(
f"进程检测 - {APP_NAME}",
f"\n检测到游戏正在运行: {proc.info['name']} \n\n是否终止?\n",
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
)
reply = msg_box.exec()
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
try:
proc.terminate()
proc.wait(timeout=3)
except psutil.AccessDenied:
proc_name = proc.info["name"].lower() if proc.info["name"] else ""
# 检查进程名是否匹配任何需要终止的游戏进程
for exe in self.required_exes:
if exe.lower() == proc_name:
# 获取不带.nocrack的游戏名称用于显示
display_name = exe.replace(".nocrack", "")
msg_box = msgbox_frame(
f"进程检测 - {APP_NAME}",
f"\n检测到游戏正在运行: {display_name} \n\n是否终止?\n",
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
)
reply = msg_box.exec()
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
try:
proc.terminate()
proc.wait(timeout=3)
except psutil.AccessDenied:
msg_box = msgbox_frame(
f"错误 - {APP_NAME}",
f"\n无法关闭游戏: {display_name} \n\n请手动关闭后重启应用\n",
QtWidgets.QMessageBox.StandardButton.Ok,
)
msg_box.exec()
sys.exit(1)
else:
msg_box = msgbox_frame(
f"错误 - {APP_NAME}",
f"\n无法关闭游戏: {proc.info['name']} \n\n请手动关闭后重启应用\n",
f"进程检测 - {APP_NAME}",
f"\n关闭游戏: {display_name} \n\n请手动关闭后重启应用\n",
QtWidgets.QMessageBox.StandardButton.Ok,
)
msg_box.exec()
sys.exit(1)
else:
msg_box = msgbox_frame(
f"进程检测 - {APP_NAME}",
f"\n未关闭的游戏: {proc.info['name']} \n\n请手动关闭后重启应用\n",
QtWidgets.QMessageBox.StandardButton.Ok,
)
msg_box.exec()
sys.exit(1)
class HostsManager:
def __init__(self):
@@ -224,6 +272,7 @@ class HostsManager:
self.backup_path = os.path.join(os.path.dirname(self.hosts_path), f'hosts.bak.{APP_NAME}')
self.original_content = None
self.modified = False
self.modified_hostnames = set() # 跟踪被修改的主机名
def backup(self):
if not AdminPrivileges().is_admin():
@@ -241,8 +290,50 @@ class HostsManager:
msg_box = msgbox_frame(f"错误 - {APP_NAME}", f"\n无法备份hosts文件请检查权限。\n\n【错误信息】:{e}\n", QtWidgets.QMessageBox.StandardButton.Ok)
msg_box.exec()
return False
def clean_hostname_entries(self, hostname):
"""清理hosts文件中指定域名的所有记录
Args:
hostname: 要清理的域名
Returns:
bool: 清理是否成功
"""
if not self.original_content:
if not self.backup():
return False
# 确保original_content不为None
if not self.original_content:
print("无法读取hosts文件内容操作中止。")
return False
if not AdminPrivileges().is_admin():
print("需要管理员权限来修改hosts文件。")
return False
try:
lines = self.original_content.splitlines()
new_lines = [line for line in lines if hostname not in line]
# 如果没有变化,不需要写入
if len(new_lines) == len(lines):
print(f"Hosts文件中没有找到 {hostname} 的记录")
return True
with open(self.hosts_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(new_lines))
# 更新原始内容
self.original_content = '\n'.join(new_lines)
print(f"已从hosts文件中清理 {hostname} 的记录")
return True
except IOError as e:
print(f"清理hosts文件失败: {e}")
return False
def apply_ip(self, hostname, ip_address):
def apply_ip(self, hostname, ip_address, clean=True):
if not self.original_content:
if not self.backup():
return False
@@ -256,17 +347,24 @@ class HostsManager:
return False
try:
lines = self.original_content.splitlines()
new_lines = [line for line in lines if not (hostname in line and line.strip().startswith(ip_address))]
# 首先清理已有的同域名记录(如果需要)
if clean:
self.clean_hostname_entries(hostname)
# 然后添加新记录
lines = self.original_content.splitlines()
new_entry = f"{ip_address}\t{hostname}"
new_lines.append(f"\n# Added by {APP_NAME}")
new_lines.append(new_entry)
lines.append(f"\n# Added by {APP_NAME}")
lines.append(new_entry)
with open(self.hosts_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(new_lines))
f.write('\n'.join(lines))
# 更新原始内容
self.original_content = '\n'.join(lines)
self.modified = True
# 记录被修改的主机名,用于最终清理
self.modified_hostnames.add(hostname)
print(f"Hosts文件已更新: {new_entry}")
return True
except IOError as e:
@@ -275,6 +373,55 @@ class HostsManager:
msg_box.exec()
return False
def check_and_clean_all_entries(self):
"""检查并清理所有由本应用程序添加的hosts记录
Returns:
bool: 清理是否成功
"""
if not AdminPrivileges().is_admin():
print("需要管理员权限来检查和清理hosts文件。")
return False
try:
# 读取当前hosts文件内容
with open(self.hosts_path, 'r', encoding='utf-8') as f:
current_content = f.read()
lines = current_content.splitlines()
new_lines = []
skip_next = False
for line in lines:
# 如果上一行是我们的注释标记,跳过当前行
if skip_next:
skip_next = False
continue
# 检查是否是我们添加的注释行
if f"# Added by {APP_NAME}" in line:
skip_next = True # 跳过下一行实际的hosts记录
continue
# 保留其他所有行
new_lines.append(line)
# 检查是否有变化
if len(new_lines) == len(lines):
print("Hosts文件中没有找到由本应用添加的记录")
return True
# 写回清理后的内容
with open(self.hosts_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(new_lines))
print(f"已清理所有由 {APP_NAME} 添加的hosts记录")
return True
except IOError as e:
print(f"检查和清理hosts文件失败: {e}")
return False
def restore(self):
if not self.modified:
if os.path.exists(self.backup_path):
@@ -282,6 +429,8 @@ class HostsManager:
os.remove(self.backup_path)
except OSError:
pass
# 即使没有修改过,也检查一次是否有残留
self.check_and_clean_all_entries()
return True
if not AdminPrivileges().is_admin():
@@ -299,6 +448,8 @@ class HostsManager:
os.remove(self.backup_path)
except OSError:
pass
# 恢复后再检查一次是否有残留
self.check_and_clean_all_entries()
return True
except IOError as e:
print(f"从内存恢复hosts文件失败: {e}")
@@ -309,6 +460,8 @@ class HostsManager:
def restore_from_backup_file(self):
if not os.path.exists(self.backup_path):
print("未找到hosts备份文件无法恢复。")
# 即使没有备份文件,也尝试清理可能的残留
self.check_and_clean_all_entries()
return False
try:
with open(self.backup_path, 'r', encoding='utf-8') as bf:
@@ -318,11 +471,15 @@ class HostsManager:
os.remove(self.backup_path)
self.modified = False
print("Hosts文件已从备份文件恢复。")
# 恢复后再检查一次是否有残留
self.check_and_clean_all_entries()
return True
except (IOError, OSError) as e:
print(f"从备份文件恢复hosts失败: {e}")
msg_box = msgbox_frame(f"警告 - {APP_NAME}", f"\n自动恢复hosts文件失败请手动从 {self.backup_path} 恢复。\n\n【错误信息】:{e}\n", QtWidgets.QMessageBox.StandardButton.Ok)
msg_box.exec()
# 尽管恢复失败,仍然尝试清理可能的残留
self.check_and_clean_all_entries()
return False
def censor_url(text):

64
source/utils/logger.py Normal file
View File

@@ -0,0 +1,64 @@
from .helpers import censor_url
import logging
import os
from data.config import CACHE
class Logger:
def __init__(self, filename, stream):
self.terminal = stream
self.log = open(filename, "w", encoding="utf-8")
def write(self, message):
censored_message = censor_url(message)
self.terminal.write(censored_message)
self.log.write(censored_message)
self.flush()
def flush(self):
self.terminal.flush()
self.log.flush()
def close(self):
self.log.close()
def setup_logger(name):
"""设置并返回一个命名的logger
Args:
name: logger的名称
Returns:
logging.Logger: 配置好的logger对象
"""
# 创建logger
logger = logging.getLogger(name)
# 避免重复添加处理器
if logger.hasHandlers():
return logger
logger.setLevel(logging.DEBUG)
# 确保日志目录存在
log_dir = os.path.join(CACHE, "logs")
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, f"{name}.log")
# 创建文件处理器
file_handler = logging.FileHandler(log_file, encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
# 创建控制台处理器
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
# 创建格式器并添加到处理器
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
file_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)
# 添加处理器到logger
logger.addHandler(file_handler)
logger.addHandler(console_handler)
return logger

View File

@@ -0,0 +1,14 @@
from .hash_thread import HashThread
from .extraction_thread import ExtractionThread
from .config_fetch_thread import ConfigFetchThread
from .ip_optimizer import IpOptimizerThread
from .download import DownloadThread, ProgressWindow
__all__ = [
'IpOptimizerThread',
'HashThread',
'ExtractionThread',
'ConfigFetchThread',
'DownloadThread',
'ProgressWindow'
]

View File

@@ -0,0 +1,65 @@
import json
import requests
import webbrowser
from PySide6.QtCore import QThread, Signal
from PySide6.QtWidgets import QMessageBox
import sys
class ConfigFetchThread(QThread):
finished = Signal(object, str) # data, error_message
def __init__(self, url, headers, debug_mode=False, parent=None):
super().__init__(parent)
self.url = url
self.headers = headers
self.debug_mode = debug_mode
def run(self):
try:
if self.debug_mode:
print("--- Starting to fetch cloud config ---")
print(f"DEBUG: Requesting URL: {self.url}")
print(f"DEBUG: Using Headers: {self.headers}")
response = requests.get(self.url, headers=self.headers, timeout=10)
if self.debug_mode:
print(f"DEBUG: Response Status Code: {response.status_code}")
print(f"DEBUG: Response Headers: {response.headers}")
print(f"DEBUG: Response Text: {response.text}")
response.raise_for_status()
# 首先总是尝试解析JSON
config_data = response.json()
# 检查是否是要求更新的错误信息 - 使用Unicode编码的更新提示文本
update_required_msg = "\u8bf7\u4f7f\u7528\u6700\u65b0\u7248\u672c\u7684FraiseMoe2-Next\u8fdb\u884c\u4e0b\u8f7d"
if isinstance(config_data, str) and config_data == update_required_msg:
self.finished.emit(None, "update_required")
return
elif isinstance(config_data, dict) and config_data.get("message") == update_required_msg:
self.finished.emit(None, "update_required")
return
# 检查是否是有效的配置文件
required_keys = [f"vol.{i+1}.data" for i in range(4)] + ["after.data"]
missing_keys = [key for key in required_keys if key not in config_data]
if missing_keys:
self.finished.emit(None, f"missing_keys:{','.join(missing_keys)}")
return
self.finished.emit(config_data, "")
except requests.exceptions.RequestException as e:
error_msg = "访问云端配置失败,请检查网络状况或稍后再试。"
if self.debug_mode:
error_msg += f"\n详细错误: {e}"
self.finished.emit(None, error_msg)
except (ValueError, json.JSONDecodeError) as e:
error_msg = "访问云端配置失败,请检查网络状况或稍后再试。"
if self.debug_mode:
error_msg += f"\nJSON解析失败: {e}"
self.finished.emit(None, error_msg)
finally:
if self.debug_mode:
print("--- Finished fetching cloud config ---")

372
source/workers/download.py Normal file
View File

@@ -0,0 +1,372 @@
import os
import sys
import subprocess
import re
from urllib.parse import urlparse
from PySide6 import QtCore, QtWidgets
from PySide6.QtCore import (Qt, Signal, QThread, QTimer)
from PySide6.QtWidgets import (QLabel, QProgressBar, QVBoxLayout, QDialog, QHBoxLayout)
from utils import resource_path
from data.config import APP_NAME, UA
import signal
import ctypes
import time
# Windows API常量和函数
if sys.platform == 'win32':
kernel32 = ctypes.windll.kernel32
PROCESS_ALL_ACCESS = 0x1F0FFF
THREAD_SUSPEND_RESUME = 0x0002
TH32CS_SNAPTHREAD = 0x00000004
class THREADENTRY32(ctypes.Structure):
_fields_ = [
('dwSize', ctypes.c_ulong),
('cntUsage', ctypes.c_ulong),
('th32ThreadID', ctypes.c_ulong),
('th32OwnerProcessID', ctypes.c_ulong),
('tpBasePri', ctypes.c_ulong),
('tpDeltaPri', ctypes.c_ulong),
('dwFlags', ctypes.c_ulong)
]
# 下载线程类
class DownloadThread(QThread):
progress = Signal(dict)
finished = Signal(bool, str)
def __init__(self, url, _7z_path, game_version, parent=None):
super().__init__(parent)
self.url = url
self._7z_path = _7z_path
self.game_version = game_version
self.process = None
self._is_running = True
self._is_paused = False
self.threads = []
def stop(self):
if self.process and self.process.poll() is None:
self._is_running = False
try:
# 使用 taskkill 强制终止进程及其子进程,并隐藏窗口
subprocess.run(['taskkill', '/F', '/T', '/PID', str(self.process.pid)], check=True, creationflags=subprocess.CREATE_NO_WINDOW)
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print(f"停止下载进程时出错: {e}")
def _get_process_threads(self, pid):
"""获取进程的所有线程ID"""
if sys.platform != 'win32':
return []
thread_ids = []
h_snapshot = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0)
if h_snapshot == -1:
return []
thread_entry = THREADENTRY32()
thread_entry.dwSize = ctypes.sizeof(THREADENTRY32)
res = kernel32.Thread32First(h_snapshot, ctypes.byref(thread_entry))
while res:
if thread_entry.th32OwnerProcessID == pid:
thread_ids.append(thread_entry.th32ThreadID)
res = kernel32.Thread32Next(h_snapshot, ctypes.byref(thread_entry))
kernel32.CloseHandle(h_snapshot)
return thread_ids
def pause(self):
"""暂停下载进程"""
if not self._is_paused and self.process and self.process.poll() is None:
try:
if sys.platform == 'win32':
# 获取所有线程
self.threads = self._get_process_threads(self.process.pid)
if not self.threads:
print("未找到可暂停的线程")
return False
# 暂停所有线程
for thread_id in self.threads:
h_thread = kernel32.OpenThread(THREAD_SUSPEND_RESUME, False, thread_id)
if h_thread:
kernel32.SuspendThread(h_thread)
kernel32.CloseHandle(h_thread)
self._is_paused = True
print(f"下载进程已暂停: PID {self.process.pid}, 线程数: {len(self.threads)}")
return True
else:
# 在Unix系统上使用SIGSTOP
os.kill(self.process.pid, signal.SIGSTOP)
self._is_paused = True
print(f"下载进程已暂停: PID {self.process.pid}")
return True
except Exception as e:
print(f"暂停下载进程时出错: {e}")
return False
return False
def resume(self):
"""恢复下载进程"""
if self._is_paused and self.process and self.process.poll() is None:
try:
if sys.platform == 'win32':
# 恢复所有线程
for thread_id in self.threads:
h_thread = kernel32.OpenThread(THREAD_SUSPEND_RESUME, False, thread_id)
if h_thread:
kernel32.ResumeThread(h_thread)
kernel32.CloseHandle(h_thread)
self._is_paused = False
print(f"下载进程已恢复: PID {self.process.pid}, 线程数: {len(self.threads)}")
return True
else:
# 在Unix系统上使用SIGCONT
os.kill(self.process.pid, signal.SIGCONT)
self._is_paused = False
print(f"下载进程已恢复: PID {self.process.pid}")
return True
except Exception as e:
print(f"恢复下载进程时出错: {e}")
return False
return False
def is_paused(self):
"""返回当前下载是否处于暂停状态"""
return self._is_paused
def run(self):
try:
if not self._is_running:
self.finished.emit(False, "下载已手动停止。")
return
aria2c_path = resource_path("aria2c-fast_x64.exe")
download_dir = os.path.dirname(self._7z_path)
file_name = os.path.basename(self._7z_path)
parsed_url = urlparse(self.url)
referer = f"{parsed_url.scheme}://{parsed_url.netloc}/"
command = [
aria2c_path,
]
# 获取主窗口的下载管理器对象
thread_count = 64 # 默认值
if hasattr(self.parent(), 'download_manager'):
# 从下载管理器获取线程数设置
thread_count = self.parent().download_manager.get_download_thread_count()
# 检查是否启用IPv6支持
ipv6_enabled = False
if hasattr(self.parent(), 'config'):
ipv6_enabled = self.parent().config.get("ipv6_enabled", False)
# 打印IPv6状态
print(f"IPv6支持状态: {ipv6_enabled}")
# 将所有的优化参数应用于每个下载任务
command.extend([
'--dir', download_dir,
'--out', file_name,
'--user-agent', UA,
'--referer', referer,
'--header', f'Origin: {referer.rstrip("/")}',
'--header', 'Accept: */*',
'--header', 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8',
'--header', 'Accept-Encoding: gzip, deflate, br',
'--header', 'Cache-Control: no-cache',
'--header', 'Pragma: no-cache',
'--header', 'DNT: 1',
'--header', 'Sec-Fetch-Dest: empty',
'--header', 'Sec-Fetch-Mode: cors',
'--header', 'Sec-Fetch-Site: same-origin',
'--http-accept-gzip=true',
'--console-log-level=notice',
'--summary-interval=1',
'--log-level=notice',
'--max-tries=3',
'--retry-wait=2',
'--connect-timeout=60',
'--timeout=60',
'--auto-file-renaming=false',
'--allow-overwrite=true',
'--split=128',
f'--max-connection-per-server={thread_count}', # 使用动态的线程数
'--min-split-size=1M', # 减小最小分片大小
'--optimize-concurrent-downloads=true', # 优化并发下载
'--file-allocation=none', # 禁用文件预分配加快开始
'--async-dns=true', # 使用异步DNS
])
# 根据IPv6设置决定是否禁用IPv6
if not ipv6_enabled:
command.append('--disable-ipv6=true')
print("已禁用IPv6支持")
else:
print("已启用IPv6支持")
# 证书验证现在总是需要因为我们依赖hosts文件
command.append('--check-certificate=false')
command.append(self.url)
# 打印将要执行的命令,用于调试
print(f"即将执行的 Aria2c 命令: {' '.join(command)}")
creation_flags = subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0
self.process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', errors='replace', creationflags=creation_flags)
# 正则表达式用于解析aria2c的输出
# 例如: #1 GID[...]( 5%) CN:1 DL:10.5MiB/s ETA:1m30s
progress_pattern = re.compile(r'\((\d{1,3})%\).*?CN:(\d+).*?DL:\s*([^\s]+).*?ETA:\s*([^\s\]]+)')
# 添加限流计时器防止更新过于频繁导致UI卡顿
last_update_time = 0
update_interval = 0.2 # 限制UI更新频率每0.2秒最多更新一次
full_output = []
while self._is_running and self.process.poll() is None:
if self.process.stdout:
line = self.process.stdout.readline()
if not line:
break
else:
break
full_output.append(line)
print(line.strip()) # 在控制台输出实时日志
match = progress_pattern.search(line)
if match:
# 检查是否达到更新间隔
current_time = time.time()
if current_time - last_update_time >= update_interval:
percent = int(match.group(1))
threads = match.group(2)
speed = match.group(3)
eta = match.group(4)
# 直接发送进度信号不使用invokeMethod
self.progress.emit({
"game": self.game_version,
"percent": percent,
"threads": threads,
"speed": speed,
"eta": eta
})
last_update_time = current_time
return_code = self.process.wait()
if not self._is_running: # 如果是手动停止的
self.finished.emit(False, "下载已手动停止。")
return
if return_code == 0:
self.progress.emit({
"game": self.game_version,
"percent": 100,
"threads": "N/A",
"speed": "N/A",
"eta": "完成"
})
self.finished.emit(True, "")
else:
error_message = f"\nAria2c下载失败退出码: {return_code}\n\n--- Aria2c 输出 ---\n{''.join(full_output)}\n---------------------\n"
self.finished.emit(False, error_message)
except Exception as e:
if self._is_running:
self.finished.emit(False, f"\n下载时发生未知错误\n\n【错误信息】: {e}\n")
# 下载进度窗口类
class ProgressWindow(QDialog):
def __init__(self, parent=None):
super(ProgressWindow, self).__init__(parent)
self.setWindowTitle(f"下载进度 - {APP_NAME}")
self.resize(450, 180)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowSystemMenuHint)
layout = QVBoxLayout()
self.game_label = QLabel("正在启动下载,请稍后...")
self.progress_bar = QProgressBar()
self.progress_bar.setValue(0)
self.stats_label = QLabel("速度: - | 线程: - | 剩余时间: -")
# 创建按钮布局
button_layout = QHBoxLayout()
# 创建暂停/恢复按钮
self.pause_resume_button = QtWidgets.QPushButton("暂停下载")
self.pause_resume_button.setToolTip("暂停或恢复下载")
# 创建停止按钮
self.stop_button = QtWidgets.QPushButton("取消下载")
self.stop_button.setToolTip("取消整个下载过程")
# 添加按钮到按钮布局
button_layout.addWidget(self.pause_resume_button)
button_layout.addWidget(self.stop_button)
layout.addWidget(self.game_label)
layout.addWidget(self.progress_bar)
layout.addWidget(self.stats_label)
layout.addLayout(button_layout)
self.setLayout(layout)
# 设置暂停/恢复状态
self.is_paused = False
# 添加最后进度记录用于优化UI更新
self._last_percent = -1
def update_pause_button_state(self, is_paused):
"""更新暂停按钮的显示状态
Args:
is_paused: 是否处于暂停状态
"""
self.is_paused = is_paused
if is_paused:
self.pause_resume_button.setText("恢复下载")
else:
self.pause_resume_button.setText("暂停下载")
def update_progress(self, data):
game_version = data.get("game", "未知游戏")
percent = data.get("percent", 0)
speed = data.get("speed", "-")
threads = data.get("threads", "-")
eta = data.get("eta", "-")
# 清除ETA值中可能存在的"]"符号
if isinstance(eta, str):
eta = eta.replace("]", "")
# 优化UI更新
if hasattr(self, '_last_percent') and self._last_percent == percent and percent < 100:
# 如果百分比没变只更新速度和ETA信息
self.stats_label.setText(f"速度: {speed} | 线程: {threads} | 剩余时间: {eta}")
else:
# 百分比变化或初次更新,更新所有信息
self._last_percent = percent
self.game_label.setText(f"正在下载 {game_version} 的补丁")
self.progress_bar.setValue(int(percent))
self.stats_label.setText(f"速度: {speed} | 线程: {threads} | 剩余时间: {eta}")
if percent == 100:
self.pause_resume_button.setEnabled(False)
self.stop_button.setEnabled(False)
self.stop_button.setText("下载完成")
QTimer.singleShot(1500, self.accept)
def closeEvent(self, event):
# 覆盖默认的关闭事件,防止用户通过其他方式关闭窗口
event.ignore()

View File

@@ -0,0 +1,31 @@
import os
import shutil
import py7zr
from PySide6.QtCore import QThread, Signal
from data.config import PLUGIN, GAME_INFO
class ExtractionThread(QThread):
finished = Signal(bool, str, str) # success, error_message, game_version
def __init__(self, _7z_path, game_folder, plugin_path, game_version, parent=None):
super().__init__(parent)
self._7z_path = _7z_path
self.game_folder = game_folder
self.plugin_path = plugin_path
self.game_version = game_version
def run(self):
try:
with py7zr.SevenZipFile(self._7z_path, mode="r") as archive:
archive.extractall(path=PLUGIN)
os.makedirs(self.game_folder, exist_ok=True)
shutil.copy(self.plugin_path, self.game_folder)
if self.game_version == "NEKOPARA After":
sig_path = os.path.join(PLUGIN, GAME_INFO[self.game_version]["sig_path"])
shutil.copy(sig_path, self.game_folder)
self.finished.emit(True, "", self.game_version)
except (py7zr.Bad7zFile, FileNotFoundError, Exception) as e:
self.finished.emit(False, f"\n文件操作失败,请重试\n\n【错误信息】:{e}\n", self.game_version)

View File

@@ -0,0 +1,28 @@
from PySide6.QtCore import QThread, Signal
from utils import HashManager
from data.config import BLOCK_SIZE
class HashThread(QThread):
pre_finished = Signal(dict)
after_finished = Signal(dict)
def __init__(self, mode, install_paths, plugin_hash, installed_status, parent=None):
super().__init__(parent)
self.mode = mode
self.install_paths = install_paths
self.plugin_hash = plugin_hash
self.installed_status = installed_status
# 每个线程都应该有自己的HashManager实例
self.hash_manager = HashManager(BLOCK_SIZE)
def run(self):
if self.mode == "pre":
updated_status = self.hash_manager.cfg_pre_hash_compare(
self.install_paths, self.plugin_hash, self.installed_status
)
self.pre_finished.emit(updated_status)
elif self.mode == "after":
result = self.hash_manager.cfg_after_hash_compare(
self.install_paths, self.plugin_hash, self.installed_status
)
self.after_finished.emit(result)

View File

@@ -0,0 +1,337 @@
import os
import re
import subprocess
import sys
import time
from urllib.parse import urlparse
from PySide6.QtCore import QThread, Signal
from utils import resource_path
class IpOptimizer:
def __init__(self):
self.process = None
def get_optimal_ip(self, url: str) -> str | None:
"""
使用 CloudflareSpeedTest 工具获取给定 URL 的最优 Cloudflare IP。
Args:
url: 需要进行优选的下载链接。
Returns:
最优的 IP 地址字符串,如果找不到则返回 None。
"""
try:
cst_path = resource_path("cfst.exe")
if not os.path.exists(cst_path):
print(f"错误: cfst.exe 未在资源路径中找到。")
return None
ip_txt_path = resource_path("ip.txt")
# 正确的参数设置根据cfst帮助文档
command = [
cst_path,
"-n", "1000", # 延迟测速线程数 (默认200)
"-p", "1", # 显示结果数量 (默认10个)
"-url", url, # 指定测速地址
"-f", ip_txt_path, # IP文件
"-dd", # 禁用下载测速,按延迟排序
"-o"," " # 不写入结果文件
]
creation_flags = subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0
print("--- CloudflareSpeedTest 开始执行 ---")
self.process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding='utf-8',
errors='replace',
creationflags=creation_flags,
bufsize=0
)
# 更新正则表达式以匹配cfst输出中的IP格式
# 匹配格式: IP地址在行首后面跟着一些数字和文本
ip_pattern = re.compile(r'^(\d+\.\d+\.\d+\.\d+)\s+.*')
# 标记是否已经找到结果表头和完成标记
found_header = False
found_completion = False
stdout = self.process.stdout
if not stdout:
print("错误: 无法获取子进程的输出流。")
return None
optimal_ip = None
timeout_counter = 0
max_timeout = 300 # 增加超时时间到5分钟
while True:
if self.process.poll() is not None:
break
try:
ready = True
try:
line = stdout.readline()
except:
ready = False
if not ready or not line:
timeout_counter += 1
if timeout_counter > max_timeout:
print("超时: CloudflareSpeedTest 响应超时")
break
time.sleep(1)
continue
timeout_counter = 0
cleaned_line = line.strip()
if cleaned_line:
print(cleaned_line)
# 检测结果表头
if "IP 地址" in cleaned_line and "平均延迟" in cleaned_line:
print("检测到IP结果表头准备获取IP地址...")
found_header = True
continue
# 检测完成标记
if "完整测速结果已写入" in cleaned_line or "按下 回车键 或 Ctrl+C 退出" in cleaned_line:
print("检测到测速完成信息")
found_completion = True
# 如果已经找到了IP可以退出了
if optimal_ip:
break
# 已找到表头后尝试匹配IP地址行
if found_header:
match = ip_pattern.search(cleaned_line)
if match and not optimal_ip: # 只保存第一个匹配的IP最优IP
optimal_ip = match.group(1)
print(f"找到最优 IP: {optimal_ip}")
# 找到最优IP后立即退出循环不等待完成标记
break
except Exception as e:
print(f"读取输出时发生错误: {e}")
break
# 确保完全读取输出后再发送退出信号
if self.process and self.process.poll() is None:
try:
if self.process.stdin and not self.process.stdin.closed:
print("发送退出信号...")
self.process.stdin.write('\n')
self.process.stdin.flush()
except:
pass
self.stop()
print("--- CloudflareSpeedTest 执行结束 ---")
return optimal_ip
except Exception as e:
print(f"执行 CloudflareSpeedTest 时发生错误: {e}")
return None
def get_optimal_ipv6(self, url: str) -> str | None:
"""
使用 CloudflareSpeedTest 工具获取给定 URL 的最优 Cloudflare IPv6 地址。
Args:
url: 需要进行优选的下载链接。
Returns:
最优的 IPv6 地址字符串,如果找不到则返回 None。
"""
try:
cst_path = resource_path("cfst.exe")
if not os.path.exists(cst_path):
print(f"错误: cfst.exe 未在资源路径中找到。")
return None
ipv6_txt_path = resource_path("data/ipv6.txt")
if not os.path.exists(ipv6_txt_path):
print(f"错误: ipv6.txt 未在资源路径中找到。")
return None
# 正确的参数设置根据cfst帮助文档
command = [
cst_path,
"-n", "1000", # 延迟测速线程数IPv6测试线程稍少
"-p", "1", # 显示结果数量 (默认10个)
"-url", url, # 指定测速地址
"-f", ipv6_txt_path, # IPv6文件
"-dd", # 禁用下载测速,按延迟排序
"-o", " " # 不写入结果文件
]
creation_flags = subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0
print("--- CloudflareSpeedTest IPv6 开始执行 ---")
self.process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding='utf-8',
errors='replace',
creationflags=creation_flags,
bufsize=0
)
# 更新正则表达式以匹配cfst输出中的IPv6格式
# IPv6格式更加复杂可能有多种表示形式
ipv6_pattern = re.compile(r'^([0-9a-fA-F:]+)\s+.*')
# 标记是否已经找到结果表头和完成标记
found_header = False
found_completion = False
stdout = self.process.stdout
if not stdout:
print("错误: 无法获取子进程的输出流。")
return None
optimal_ipv6 = None
timeout_counter = 0
max_timeout = 300 # 增加超时时间到5分钟
while True:
if self.process.poll() is not None:
break
try:
ready = True
try:
line = stdout.readline()
except:
ready = False
if not ready or not line:
timeout_counter += 1
if timeout_counter > max_timeout:
print("超时: CloudflareSpeedTest IPv6 响应超时")
break
time.sleep(1)
continue
timeout_counter = 0
cleaned_line = line.strip()
if cleaned_line:
print(cleaned_line)
# 检测结果表头
if "IP 地址" in cleaned_line and "平均延迟" in cleaned_line:
print("检测到IPv6结果表头准备获取IPv6地址...")
found_header = True
continue
# 检测完成标记
if "完整测速结果已写入" in cleaned_line or "按下 回车键 或 Ctrl+C 退出" in cleaned_line:
print("检测到IPv6测速完成信息")
found_completion = True
# 如果已经找到了IPv6可以退出了
if optimal_ipv6:
break
# 已找到表头后尝试匹配IPv6地址行
if found_header:
match = ipv6_pattern.search(cleaned_line)
if match and not optimal_ipv6: # 只保存第一个匹配的IPv6最优IPv6
optimal_ipv6 = match.group(1)
print(f"找到最优 IPv6: {optimal_ipv6}")
# 找到最优IPv6后立即退出循环不等待完成标记
break
except Exception as e:
print(f"读取输出时发生错误: {e}")
break
# 确保完全读取输出后再发送退出信号
if self.process and self.process.poll() is None:
try:
if self.process.stdin and not self.process.stdin.closed:
print("发送退出信号...")
self.process.stdin.write('\n')
self.process.stdin.flush()
except:
pass
self.stop()
print("--- CloudflareSpeedTest IPv6 执行结束 ---")
return optimal_ipv6
except Exception as e:
print(f"执行 CloudflareSpeedTest IPv6 时发生错误: {e}")
return None
def stop(self):
if self.process and self.process.poll() is None:
print("正在终止 CloudflareSpeedTest 进程...")
try:
if self.process.stdin and not self.process.stdin.closed:
self.process.stdin.write('\n')
self.process.stdin.flush()
self.process.stdin.close()
except:
pass
try:
self.process.terminate()
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
print("CloudflareSpeedTest 进程已终止。")
class IpOptimizerThread(QThread):
"""用于在后台线程中运行IP优化的类
注意IPv6连接测试功能已迁移至IPv6Manager类
本类仅负责IP优化相关功能
"""
finished = Signal(str)
def __init__(self, url, parent=None, use_ipv6=False):
super().__init__(parent)
self.url = url
self.optimizer = IpOptimizer()
self.use_ipv6 = use_ipv6
def run(self):
if self.use_ipv6:
optimal_ip = self.optimizer.get_optimal_ipv6(self.url)
else:
optimal_ip = self.optimizer.get_optimal_ip(self.url)
self.finished.emit(optimal_ip if optimal_ip else "")
def stop(self):
self.optimizer.stop()
if __name__ == '__main__':
# 用于直接测试此模块
test_url = "https://speed.cloudflare.com/__down?during=download&bytes=104857600"
optimizer = IpOptimizer()
ip = optimizer.get_optimal_ip(test_url)
if ip:
print(f"{test_url} 找到的最优 IP 是: {ip}")
else:
print(f"未能为 {test_url} 找到最优 IP。")