Compare commits

...

612 Commits
v2.1.2 ... main

Author SHA1 Message Date
DLmaster361
11d5ba385f fix: 适配 MAA 剿灭关卡任务出错日志 2025-08-19 23:39:04 +08:00
DLmaster361
dc1fc52c33 Merge branch 'dev' 2025-08-12 16:27:36 +08:00
DLmaster361
6b37ba0ce3 fix(maa): 修复 MAA 开始唤醒识别错误问题 2025-08-12 16:26:16 +08:00
23b3691a13 feat(version): 优化版本比较和显示逻辑
- 修改版本比较逻辑,优先比较主版本号,然后是次版本号和修订号
- 增加对 beta版本的特殊处理,正式版比 beta 版更新
- 优化版本显示格式,移除不必要的 "v" 前缀
- 在主界面和日志中使用优化后的版本显示格式
2025-08-04 00:12:36 +08:00
DLmaster361
0755d34903 feat(ui): 优化统计列表,支持上下滑动 2025-08-02 10:42:17 +08:00
DLmaster361
2c0e457976 Merge branch 'dev' 2025-08-01 11:02:32 +08:00
DLmaster361
8948d0fb18 feat: 发布v4.4.1 2025-08-01 11:02:08 +08:00
DLmaster361
908da0bc47 fix: 修复计划表未能按照鹰历获取关卡号的问题 2025-08-01 09:35:54 +08:00
DLmaster361
aae17208b0 fix: 修正部分文案 2025-08-01 01:09:10 +08:00
DLmaster361
cbd8918c61 feat(core): 启动时支持直接运行复数调度队列 2025-07-31 23:18:36 +08:00
DLmaster361
f2b4f9e8fc ci: fix ci 2025-07-31 22:07:21 +08:00
DLmaster361
a2a11647bb fix: ci 2025-07-31 21:58:53 +08:00
DLmaster361
1d9c275b61 ci: fix 2025-07-31 21:53:44 +08:00
DLmaster361
52468928c6 feat: 新增 Go_Updater 独立更新器 2025-07-31 20:47:49 +08:00
DLmaster361
3d9471779a Merge branch 'ClozyA_GoUpdater_dev' into dev 2025-07-31 20:42:52 +08:00
DLmaster361
155c4b00d5 ci: 适配Go_Updater 2025-07-31 20:42:36 +08:00
DLmaster361
f4b45e9eae feat(general): 通用脚本启动附加命令添加额外的语法以适应UI可执行文件与任务可执行文件不同的情况 2025-07-31 20:01:32 +08:00
DLmaster361
d836b58b2d feat: 新增完成任务后自动复原脚本配置 2025-07-31 17:06:08 +08:00
DLmaster361
4fc747f1c6 feat(maa): 适配 MAA 长期开放剿灭关卡 #58 2025-07-31 16:13:35 +08:00
DLmaster361
b27f2c43ae feat: MAA代理时更新改为强制开启 2025-07-31 13:50:19 +08:00
DLmaster361
c5f947e14a fix: 优化静默进程标记逻辑 2025-07-31 13:39:38 +08:00
DLmaster361
67c41ab3ee chore(core): 优化调度队列配置逻辑 2025-07-31 01:01:30 +08:00
DLmaster
9f330e2245 Merge pull request #62 from Zrief:dev
修复依赖缺失
2025-07-31 00:49:39 +08:00
Zrief
d785262312 依赖相关问题
在 app/core/task_manager.py 文件中,第 31 行使用了 packaging 模块。其并非标准库,因此需要确保它被包含在项目依赖中。
2025-07-31 00:44:07 +08:00
DLmaster361
27faaabcf2 fix: 修复脚本下载器卡初始化 2025-07-27 23:30:34 +08:00
226d68cb1c feat(api): 修复beta版本的更新检查 2025-07-27 14:12:54 +08:00
DLmaster361
e776cc2319 fix: 修复一般更新方案卡初始化 2025-07-26 16:23:22 +08:00
DLmaster361
3cdb1e511d fix: 修正部分格式 2025-07-26 13:11:23 +08:00
a4f867665f feat(power): 添加强制关机功能并优化关机流程 2025-07-26 00:27:41 +08:00
DLmaster361
6b0583b139 fix: 移除部分调试代码 2025-07-25 20:42:50 +08:00
2aad0c65c2 chore: 添加无更新可用时的延迟退出功能 2025-07-22 23:03:23 +08:00
6b646378b6 refactor(updater): 重构 Go 版本更新器
- 更新项目名称为 AUTO_MAA_Go_Updater
- 重构代码结构,优化函数命名和逻辑
- 移除 CDK 相关的冗余代码
- 调整版本号为 git commit hash
- 更新构建配置和脚本
- 优化 API 客户端实现
2025-07-22 21:51:58 +08:00
747ad6387b feat(config): 添加配置管理功能 2025-07-20 18:12:35 +08:00
228e66315c feat(Go_Updater): 添加全新 Go 语言实现的自动更新器
- 新增多个源文件和目录,包括 app.rc、assets、build 脚本等
- 实现了与 MirrorChyan API 交互的客户端逻辑
- 添加了版本检查、更新检测和下载 URL 生成等功能
- 嵌入了配置模板和资源文件系统
- 提供了完整的构建和发布流程
2025-07-20 16:30:14 +08:00
DLmaster361
97c283797a fix: 移除崩溃弹窗机制 2025-07-19 22:23:59 +08:00
DLmaster361
eb1fade6f5 feat: AUTO_MAA 配置分享中心上线 2025-07-19 21:38:38 +08:00
DLmaster361
8d6071f794 fix: 修复 QTimer.singleShot 参数问题 2025-07-19 17:08:57 +08:00
DLmaster361
8a109e34f8 refactor(module): 重构日志读取兜底机制 2025-07-19 10:27:19 +08:00
DLmaster361
fd72d72692 fix(module): 日志读取添加兜底机制 2025-07-19 10:13:48 +08:00
DLmaster361
63ffacff96 fix(ui): SpinBox和TimeEdit组件忽视滚轮事件 2025-07-18 21:59:18 +08:00
DLmaster361
1b4bb6fccc feat(general): 通用脚本支持在选定的时机自动更新配置文件 2025-07-18 21:48:39 +08:00
DLmaster361
9b492b5e0d feat(core): 重构日志记录,载入更多日志记录项 2025-07-18 18:12:47 +08:00
DLmaster361
8427bd9f6b Merge branch 'inno_dev' into dev 2025-07-16 20:13:45 +08:00
DLmaster361
4cbd921ab6 Merge branch 'dev' 2025-07-15 18:16:23 +08:00
DLmaster361
2c915161d5 Merge commit '75b06ca770ebcee68bf05ab95f069c6e8fd0cae8' into dev 2025-07-15 18:15:48 +08:00
DLmaster361
75b06ca770 feat(general): 通用配置模式接入日志系统 2025-07-15 18:15:26 +08:00
DLmaster361
c3468a3387 Merge branch 'generic_dev' into dev 2025-07-15 16:20:26 +08:00
DLmaster361
a2f4adb647 fix: 适配 MAA 任务及基建设施日志翻译 2025-07-15 16:20:09 +08:00
DLmaster361
403f69df8b refactor(ui): 重构历史记录保存与载入逻辑 2025-07-15 16:08:11 +08:00
DLmaster361
12cf10f97a fix((ui): 修复MAA用户仪表盘备选1字段错误 2025-07-14 19:12:59 +08:00
DLmaster361
6084befe2c Merge branch 'dev' into generic_dev 2025-07-13 23:25:02 +08:00
DLmaster361
1aa99ea613 Merge branch 'network_dev' into dev 2025-07-13 23:00:59 +08:00
DLmaster361
d539c0f808 fix(core): 信任系统证书,并添加网络代理地址配置项 #50 2025-07-13 22:50:04 +08:00
DLmaster361
bc509806fb Merge branch 'generic_dev' into dev 2025-07-12 23:11:35 +08:00
DLmaster361
c52820550f feat(ui): 添加导入导出通用配置功能 2025-07-12 21:54:40 +08:00
DLmaster361
98b30f90a1 Merge commit '4efbafc174504bd2fbd5b22075e3b5429406691b' into generic_dev 2025-07-12 19:41:10 +08:00
DLmaster361
4efbafc174 fix(system): 修复开机自启相关功能 2025-07-12 16:08:07 +08:00
DLmaster361
6d3fda50d3 feat(security): 添加重置管理密钥功能 2025-07-12 01:27:27 +08:00
DLmaster361
70b936012f Merge commit '54917fbe6de3ca5ac4a07bc88cdf0178a23f88dc' into dev 2025-07-11 18:42:40 +08:00
DLmaster361
54917fbe6d fix(general): 修复无成功日志时的脚本判定逻辑 2025-07-11 18:38:58 +08:00
DLmaster361
abeb9f054d fix(maa): 适配 MAA 备选关卡字段修改 2025-07-11 14:48:23 +08:00
DLmaster361
c6d6c5fb2a Merge branch 'generic_dev' into dev 2025-07-10 17:35:22 +08:00
DLmaster361
5b0d7f0012 feat(ui): 修改版本号到v4.4
- 进一步适配三月七相关配置项
2025-07-10 17:34:41 +08:00
DLmaster361
d9043aab0a fix(ui): 适配 Mirror 酱 平台下载策略调整 2025-07-10 13:29:17 +08:00
DLmaster361
b9281b68ab feat(utils): 安装包适配管理员权限 2025-07-10 04:53:32 +08:00
DLmaster361
5c6a20be4e Merge branch 'generic_dev' into dev 2025-07-10 02:30:02 +08:00
DLmaster361
1c0a65957d feat(core): 初步完成通用调度模块 2025-07-10 02:29:08 +08:00
DLmaster361
7c315624b1 Merge commit '0572caa528deb72bc97c42e0b169292823b1e8a8' into dev 2025-06-17 19:55:55 +08:00
DLmaster361
0572caa528 fix: 固定certifi版本号 2025-06-17 03:18:55 +08:00
DLmaster361
4233040585 fix: 尝试固定certifi版本号 2025-06-17 02:46:27 +08:00
DLmaster361
c27dc8e380 fix: 移除无用依赖项 2025-06-17 02:00:32 +08:00
DLmaster361
e746756e56 ci: 临时固定Nuitka打包版本号 2025-06-17 01:14:29 +08:00
DLmaster361
1829d1cd0b fix(ui): 修复删除计划表引发的错误 2025-06-17 00:34:07 +08:00
DLmaster361
fb979e5639 docs: 补充代码签名策略 2025-06-16 21:38:25 +08:00
DLmaster361
e7d0a85ad5 ci: 修正project-slug 2025-06-16 21:24:05 +08:00
DLmaster361
a384711327 Merge branch 'dev' 2025-06-16 14:33:49 +08:00
DLmaster361
3fd4778a48 feat(maa): 适配 MAA 无Default配置情况 2025-06-16 14:26:29 +08:00
DLmaster361
4841dc09b3 refactor(res): 更新软件主页用图 2025-06-14 17:20:03 +08:00
DLmaster361
b3aa4fc776 fix(maa): 修复森空岛签到信息特定情况下不弹出通知的问题 2025-06-13 10:02:43 +08:00
DLmaster361
a9b3b8b6f4 Merge branch 'Clozy_dev' into dev 2025-06-11 23:05:59 +08:00
DLmaster361
56ef196695 refactor(models/services): 简化测试用图 2025-06-11 23:05:46 +08:00
242238d341 refactor(models/services): 优化代码结构和可读性
-移除了不必要的变量声明
-简化了图像路径的构建方式
- 统一了代码格式
2025-06-11 22:51:26 +08:00
f66f6d38fe feat(notification): 用户单独通知六星喜报 2025-06-11 22:31:51 +08:00
d58077f58b fix(app): 修复企业微信机器人图片推送异常
- 移除了不必要的变量 final_image_path
-直接使用 image_path 进行图片存在性检查
- 更新了图片 base64 和 md5计算的逻辑
2025-06-11 22:26:44 +08:00
4d4d6dbedf refactor(app): 重构图片压缩功能并优化类型注解
- 使用 Path 对象替换字符串表示文件路径,提高代码可读性和功能
- 优化类型注解,包括函数参数和返回值- 重构 ImageUtils.compress_image_if_needed 方法,简化逻辑并提高性能
- 更新 notification.py 中使用该方法的代码,直接返回压缩后的 Path 对象
2025-06-11 22:14:09 +08:00
f60b276916 refactor(notify): 重构企业微信群机器人图片推送功能
-将图片压缩、存在性检查、Base64编码和MD5计算移至 CompanyWebHookBotPushImage 方法内部
- 优化错误处理和日志记录
- 简化调用接口,提高代码可读性和维护性
2025-06-11 20:54:45 +08:00
87857fd499 feat(notification): 新增图片压缩处理
- 新增 ImageUtils.compress_image_if_needed 方法,用于压缩图片大小
- 在 MAA.py 和 notification.py 中集成图片压缩功能
- 添加对不同图片格式(JPEG、PNG)的压缩支持
- 优化图片路径处理,确保压缩后图片正确发送
- 更新 requirements.txt,添加 pillow 依赖
2025-06-11 19:50:58 +08:00
3c371cd079 feat(notification): 企业微信群机器人支持图片推送
- 新增 ImageUtils 类,提供图像处理相关工具方法
- 在 MAA.py 中集成 ImageUtils,用于获取和处理通知图片
- 在 notification.py 中实现 CompanyWebHookBotPushImage 方法,支持企业微信群机器人推送图片
- 修改测试通知方法,增加图片推送测试
2025-06-11 17:36:11 +08:00
DLmaster361
428b849bcc fix(maa): 静默模式控制时段延长至模拟器完成启动的10s后 2025-06-11 01:13:57 +08:00
DLmaster361
85f3b4f607 Merge branch 'dev' 2025-06-10 18:49:00 +08:00
DLmaster361
916396f855 refactor: 使用 keyboard 模块替代 pyautogui 模块 2025-06-10 18:48:41 +08:00
DLmaster361
211c8d2b04 Merge branch 'dev' 2025-06-10 14:10:01 +08:00
DLmaster361
92e274d3fd ci: 移除pyscreeze 2025-06-10 14:09:40 +08:00
DLmaster361
d511ea48d5 Merge branch 'main' into dev 2025-06-09 23:45:03 +08:00
DLmaster361
1aa4da1adf feat: 支持使用命令行调用 2025-06-09 23:43:36 +08:00
DLmaster361
0e8b6b0b6b feat: 添加用户守则 2025-06-08 23:41:18 +08:00
DLmaster361
1a2c1b976f fix(maa): 更新动作执行后移除相应标记 2025-06-07 15:59:06 +08:00
DLmaster361
1cc242fa51 feat: 优化下载器测速中止条件 2025-06-06 22:38:37 +08:00
DLmaster361
18dfdba15d ci: 测试完整工作流 2025-06-06 00:09:01 +08:00
DLmaster361
b04ac4eec6 ci: 使用预设配置 2025-06-05 23:50:56 +08:00
DLmaster361
c009f0c891 ci: 测试证书注册 2025-06-05 23:39:09 +08:00
DLmaster361
d2dc0bd295 ci: 上传测试之二 2025-06-05 23:28:15 +08:00
DLmaster361
ddbb5b7f19 ci: 名字作出区分 2025-06-05 23:25:48 +08:00
DLmaster361
954c25090b ci: 测试上传工作 2025-06-05 23:25:08 +08:00
DLmaster361
0b6cc59de1 ci: 构建部分测试流程 2025-06-05 22:38:24 +08:00
DLmaster361
2271b5741d ci: 移除不支持的参数 2025-06-05 22:08:41 +08:00
DLmaster361
8a438b041f ci: 修正signpath证书参数 2025-06-05 21:09:03 +08:00
DLmaster361
dd92fcc4d8 ci: 添加证书测试工作流 2025-06-05 20:02:17 +08:00
DLmaster361
8f66ca0e16 Merge branch 'dev' 2025-06-05 19:10:49 +08:00
DLmaster361
895ba1d24a feat(res): 公招喜报模板优化 2025-06-04 11:21:34 +08:00
e49b807bef feat(ui): 完善森空岛签到功能的提示信息 2025-06-02 14:26:25 +08:00
DLmaster361
73c15b5e93 refactor(skland): 森空岛签到功能拆分独立 2025-06-02 14:05:31 +08:00
DLmaster361
e505ea8c51 feat(maa): 森空岛签到功能上线 2025-06-02 02:35:01 +08:00
DLmaster361
21e7df7c3e Merge branch 'dev' 2025-06-01 20:04:16 +08:00
DLmaster361
2d72ca66a4 fix(core): 修复网络模块子线程未及时销毁导致的程序崩溃 2025-06-01 19:52:22 +08:00
DLmaster361
4725a30165 fix(ui): 修复语音包禁忌二重奏 2025-06-01 04:31:29 +08:00
DLmaster361
f3c977f1b3 Merge branch 'main' into dev 2025-06-01 03:18:39 +08:00
DLmaster361
9a0e7265c6 feat(core): 语音功能上线 2025-06-01 03:16:56 +08:00
DLmaster361
3f8e2fbe6b Merge branch 'dev' 2025-05-31 13:37:06 +08:00
DLmaster361
590b13e916 ci: 移除测试打包流程 2025-05-31 13:36:09 +08:00
DLmaster361
0f6aee56e5 ci: 修正测试工作流名称 2025-05-31 11:21:13 +08:00
DLmaster361
daf18e7295 Merge branch 'dev' 2025-05-31 11:20:04 +08:00
DLmaster361
9bcc87f663 ci: 添加引入打包action的测试工作流 2025-05-31 11:19:36 +08:00
DLmaster361
e7205ce0aa fix(services): 非UI组件转为QObject类 2025-05-30 21:06:39 +08:00
DLmaster361
e3c4b2edc8 fix(core): 网络模块支持并发请求 2025-05-30 20:30:23 +08:00
DLmaster361
222a3b35a2 fix(maa): 修复ADB与模拟器相关日志信息报错时不显示 2025-05-29 21:06:09 +08:00
DLmaster361
cd5dfd56b2 Merge branch 'dev' 2025-05-28 23:13:15 +08:00
DLmaster361
7d5c6b8222 docs: 添加DeepWiki徽章 2025-05-28 23:12:56 +08:00
DLmaster361
4dbf4736e4 Merge branch 'dev' 2025-05-28 21:18:24 +08:00
DLmaster361
d50504181e feat(ui): 吐司通知在主窗口隐藏时不再弹出 2025-05-28 21:17:14 +08:00
DLmaster361
c7e94dfcd1 Merge branch 'DLMS_dev' into dev 2025-05-27 18:17:29 +08:00
DLmaster361
a752b67ca1 feat(ui): UI界面添加自动日常代理任务序列设置项 2025-05-26 16:05:46 +08:00
DLmaster361
078736337d fix: 修复雷电模拟器静默模式无法正常识别模拟器是否隐藏相关问题 2025-05-25 23:31:37 +08:00
DLmaster361
de1058a28c feat(core): 计划表功能上线 2025-05-25 21:12:22 +08:00
DLmaster361
740797a689 Merge branch 'dev' into DLMS_dev 2025-05-24 19:56:16 +08:00
DLmaster361
26328920a2 chore(ui): 输入文本框适配文本插入操作 2025-05-24 19:55:49 +08:00
DLmaster361
9c447bbdf9 Merge branch 'ClozyA_dev' into dev 2025-05-24 18:34:03 +08:00
DLmaster361
fac85a889f fix(ui): 简单优化用户通知显示效果 2025-05-24 18:33:46 +08:00
DLmaster361
f5d898c89e fix(maa): 将通知判定过程全部移入push_notification 2025-05-23 22:59:09 +08:00
974a4b634a feat(ui): 优化敏感信息显示逻辑 2025-05-23 15:19:35 +08:00
3127c83603 feat(notification): 增加用户单独通知功能 2025-05-23 15:01:26 +08:00
DLmaster361
8d69e43f72 feat(ui): 初步添加周计划表 2025-05-22 23:20:38 +08:00
DLmaster361
86df9e7a50 chore(ui): 优化二级菜单显示效果 2025-05-21 01:00:38 +08:00
59ff9bf818 refactor(notification): 重构通知模块 2025-05-20 02:11:54 +08:00
DLmaster361
1641e32e3d feat(ui): 完成完整用户通知子菜单设计 2025-05-19 17:31:50 +08:00
DLmaster361
a482087abd feat(ui): 初步完成用户通知相关界面 2025-05-19 16:54:14 +08:00
bc5b15cec2 feat(notification): 优化用户通知服务 2025-05-19 01:10:20 +08:00
3787c25a77 Merge branch 'dev' into ClozyA_dev 2025-05-19 00:18:37 +08:00
DLmaster361
0b06b499e4 fix(models): 修复雷电ADB端口号相关问题 2025-05-18 23:35:35 +08:00
DLmaster361
04079dd57b Merge branch 'dev' of github.com:DLmaster361/AUTO_MAA into dev 2025-05-18 13:54:19 +08:00
DLmaster361
34ac0c5ab3 fix: 主动关闭剩余理智选项 2025-05-18 13:54:14 +08:00
0d904b229e feat(core): 初步完成新增用户单独通知功能 2025-05-18 01:57:48 +08:00
c0f887ff9d refactor(notification): 更新任务报告标题格式,增加日期前缀 2025-05-18 00:17:06 +08:00
DLmaster361
cf95075d01 fix(ui): 修复窗口最大化功能异常 2025-05-17 22:26:41 +08:00
DLmaster361
d78a764d87 fix: 修正搜索ADB时的等待方法 2025-05-17 00:07:34 +08:00
DLmaster
a368f4b722 feat(ui): 用户仪表盘支持直接控制用户状态
优化仪表盘排版
2025-05-16 23:27:53 +08:00
DLmaster361
803fe4568f chore(ui): 优化实现方法 2025-05-16 23:23:02 +08:00
DLmaster361
1162d5dcc1 feat(ui): 添加对剩余天数为0的展示 2025-05-16 22:50:54 +08:00
Zrief
aa83058e39 Update member_manager.py 2025-05-16 21:56:20 +08:00
Zrief
061f205224 Merge branch 'DLmaster361:dev' into dev 2025-05-16 21:55:16 +08:00
Zrief
5d966f98df 修改逻辑不完备的地方 2025-05-16 21:53:03 +08:00
DLmaster361
0037914db8 Merge branch 'DLMS_dev' into dev 2025-05-16 20:41:04 +08:00
DLmaster361
13d0115475 feat(core): 添加ADB端口号宽幅适配能力 2025-05-16 20:09:29 +08:00
Zrief
5bdb5ad2bf 优化脚本仪表盘排版
可以直接在仪表盘里面开关用户状态了
2025-05-15 21:20:30 +08:00
a5d733c8df feat(notification): 为自动代理统计报告添加日期前缀 2025-05-15 16:39:56 +08:00
DLmaster361
0b038e2997 Merge branch 'main' into dev 2025-05-12 14:54:17 +08:00
DLmaster361
5e46040db6 feat: 日志分析忽略MAA超时提示 2025-05-12 14:53:57 +08:00
DLmaster361
f2b04dd0f6 Merge branch 'dev' 2025-05-11 23:28:18 +08:00
DLmaster361
2177c1b40e fix(models): 适配MAAv5.16.3的ADB报错信息更改 2025-05-11 23:26:42 +08:00
DLmaster361
d1f4cffe8f feat(ui): 主调度台添加仅一次电源任务,UI样式优化 2025-05-10 23:15:54 +08:00
DLmaster361
74ce441b90 feat(core): 电源相关选项改为所有任务完成后生效 2025-05-09 21:52:18 +08:00
DLmaster361
5893aa2426 feat(ui): 自定义基建显示配置名称 2025-05-09 19:54:07 +08:00
DLmaster361
cb7e7bf9d4 Merge branch 'dev' 2025-05-09 14:54:29 +08:00
DLmaster361
fbfdc6aa12 feat: 优化更新方式 2025-05-09 14:54:09 +08:00
DLmaster361
e7b6743e10 test: 移除测试ci 2025-05-06 17:43:23 +08:00
DLmaster361
ff4283e917 Merge branch 'dev' 2025-05-06 17:42:42 +08:00
DLmaster361
890886d62d fix(ci): 适配新下载站模式 2025-05-06 17:42:24 +08:00
DLmaster361
fd75dda2b1 feat(ui): 适配MAA连战次数AUTO模式 2025-05-06 13:57:49 +08:00
DLmaster361
f22c1aeae3 test(ci): 补充步骤 2025-05-06 13:07:56 +08:00
DLmaster361
d68d49a469 test(ci): 更正环境 2025-05-06 13:00:04 +08:00
DLmaster361
1900d4eaf5 test(ci): 测试ssh部分 2025-05-06 12:57:55 +08:00
DLmaster361
02833209d5 Merge branch 'dev' 2025-05-06 00:18:44 +08:00
2058c0218c ci: 更新下载服务器配置 2025-05-06 00:09:14 +08:00
DLmaster361
8896e723eb fix: 补充版本号 2025-05-05 20:14:25 +08:00
DLmaster361
edcc614833 Merge commit '23fe1ff0beb234eb7ebbc07b189782b7fb84d5e5' 2025-05-05 20:13:22 +08:00
DLmaster361
23fe1ff0be feat: release中添加安装程序 2025-05-05 20:12:59 +08:00
DLmaster361
19d1dc9f28 fix: 修复详细模式下非定时自定义基建无法正常换班的问题 2025-05-05 15:59:07 +08:00
DLmaster361
24b93cfcad feat: 下载器支持调用Mirror酱 2025-05-04 23:43:04 +08:00
DLmaster361
d3298fac8a fix: 移除serverchan_sdk 2025-05-04 17:01:36 +08:00
DLmaster361
fba5395bf0 Merge branch 'dev' 2025-05-04 16:44:46 +08:00
DLmaster361
2c4508ee16 fix: 修复版本号错误 2025-05-04 16:43:49 +08:00
d239443555 chore(release): 发版 4.3.6.2 2025-05-04 16:36:36 +08:00
e45ad08fab refactor(notification): 重构 Server酱 推送服务 2025-05-04 16:25:46 +08:00
ddf5d26c4b refactor(notification): 重构 Server酱 推送服务 2025-05-04 16:12:45 +08:00
DLmaster361
ce74dcf912 Merge branch 'dev' of github.com:DLmaster361/AUTO_MAA into dev 2025-05-04 15:18:31 +08:00
DLmaster361
41412e1ef4 feat(ui): 新增无人值守模式 2025-05-04 15:18:26 +08:00
雪影
1395d48cd0 头部信息居中 (#43)
Co-authored-by: DLmaster361 <DLmaster_361@163.com>
2025-05-04 13:57:56 +08:00
DLmaster361
418c3d4742 fix(ui): 修复隐藏到托盘时,托盘无法退出主程序的问题 2025-05-04 11:12:34 +08:00
DLmaster361
17ec962a22 fix(ui): 修复软件窗口相关问题
- 修复软件窗口最大化异常问题
- 修复异常操作导致窗口离开屏幕后难以复原的问题
- 修正剩余理智关卡文案
- 主窗口显示版本号
2025-05-04 03:14:14 +08:00
DLmaster361
989ee73549 feat(maa): 单次自动代理任务中,已完成的子任务在重复执行时不再启用 2025-05-02 11:36:40 +08:00
DLmaster361
7e452e1253 Merge branch 'dev' 2025-05-02 00:40:55 +08:00
DLmaster361
5bdb5c8025 fix: 详细配置模式下更新也由AUTO统筹配置 2025-05-02 00:39:58 +08:00
DLmaster361
924a5fea0b feat: 适配6周年游戏变动的优化
- 用户设置中新增连战次数与剩余理智关卡两项配置项
- 支持自动代理时更新MAA
- 移除增效任务
2025-05-01 21:31:09 +08:00
DLmaster361
b51a57a6ee fix: 修复无法建立网络连接时软件卡死问题 2025-04-29 09:40:11 +08:00
DLmaster361
4079188881 fix: 脚本实例任务完成后即时更新状态信息 2025-04-28 20:41:08 +08:00
DLmaster361
174163e305 feat: 模拟器路径适配快捷方式 2025-04-28 18:35:56 +08:00
DLmaster361
0886439685 Merge branch 'dev' 2025-04-27 01:00:18 +08:00
DLmaster361
34bf5a4fe8 fix: 同步到MAAv5.15.4的tasks目录结构变化 2025-04-27 00:59:46 +08:00
DLmaster361
e6a97f2b17 feat(ui): 主调度台支持直接启动多开调度台 2025-04-26 22:40:47 +08:00
DLmaster361
fecff625a3 feat: 新增MAA稳定版更新提醒 2025-04-26 11:28:14 +08:00
DLmaster361
6f540036a0 feat(ui): 历史记录功能升级 2025-04-25 22:47:00 +08:00
DLmaster361
86d72aec39 feat: 历史记录页面添加选中最近一月选中最近一周选项 2025-04-23 15:04:36 +08:00
DLmaster361
39876832f3 fix: 历史记录加载方法优化 2025-04-21 19:21:27 +08:00
DLmaster361
f3af6ddbbc fix: 修复更新时主程序退出不彻底与ADB路径与模拟器路径相关问题 2025-04-20 16:40:14 +08:00
DLmaster361
ba7299e20c fix: 同步核心版本号 2025-04-20 11:14:21 +08:00
DLmaster361
5db9d934b2 fix: 修复更新器使用mirrorc下载时删除已弃用的文件卡死 2025-04-20 11:07:41 +08:00
DLmaster361
5c8eebf12c fix: 清理debug时的print 2025-04-20 00:18:31 +08:00
DLmaster361
e725f6d2b2 Merge branch 'main' into dev 2025-04-20 00:05:17 +08:00
DLmaster361
494b655156 Merge branch 'dev' 2025-04-20 00:02:42 +08:00
DLmaster361
2940f2557c Merge branch 'DLMS_dev' into dev 2025-04-20 00:02:25 +08:00
DLmaster361
5e4660670f chore: 性能优化
- 调度队列历史记录归入配置类管理
- 添加.gitignore
- 工作流删除冗余部分
- 自动代理与人工排查结束后MAA恢复到全局配置 #40
- 网络相关操作由子线程执行
2025-04-20 00:01:49 +08:00
e8d592ae76 refactor(app): 优化关卡掉落物品正则匹配 2025-04-16 16:58:22 +08:00
DLmaster361
97ea51df59 docs: README链接修复 2025-04-16 11:23:24 +08:00
DLmaster361
986061dc97 Merge branch 'DLMS_dev' into dev 2025-04-15 22:34:52 +08:00
DLmaster361
fe1910d16f fix: 修正部分配置项文案 2025-04-15 22:34:37 +08:00
DLmaster361
63cb1aaa74 feat: 自动代理流程优化 2025-04-15 22:15:59 +08:00
49ebd50077 refactor(ui): 优化模拟器老板键设置卡片的提示文本 2025-04-14 16:04:53 +08:00
DLmaster361
4a6f874210 fix: request 添加超时限制 2025-04-14 15:03:29 +08:00
DLmaster
9394c7a9c5 Merge branch 'dev' 2025-04-13 16:52:12 +08:00
DLmaster
7e502420fa fix: 修复更新器无法下载MAA的异常 2025-04-13 16:46:54 +08:00
DLmaster
12f4b764de Merge branch 'dev' 2025-04-13 02:50:34 +08:00
DLmaster
4da4b7d552 fix: 发布到stable 2025-04-13 02:50:23 +08:00
DLmaster
d38abbbaa0 Merge branch 'dev' 2025-04-13 00:46:14 +08:00
DLmaster
67bf7f649e fix(utils): 改回单文件打包 2025-04-13 00:45:47 +08:00
DLmaster
acb35403b0 Merge branch 'main' into dev 2025-04-12 23:10:19 +08:00
DLmaster
7d5dccc649 fix(ui): 修复更新器无法启动的异常 2025-04-12 23:09:47 +08:00
DLmaster
a7e0e7b217 Merge branch 'dev' 2025-04-12 21:11:25 +08:00
DLmaster
9ce75b2dda fix(ci): 规避v4.3.0错误包 2025-04-12 21:11:05 +08:00
DLmaster
d2022819f6 Merge branch 'dev' 2025-04-12 19:03:54 +08:00
DLmaster
c8b342ba01 fix(core): 修复版本号问题 2025-04-12 19:03:39 +08:00
DLmaster
63823d5c89 Merge branch 'dev' 2025-04-12 16:54:03 +08:00
DLmaster
cb17cc32da feat(ci): 发布v4.3.0.0 2025-04-12 16:53:40 +08:00
DLmaster
14d0e6d438 fix(ci): 打包流程修复 2025-04-12 12:08:13 +08:00
DLmaster
878fbad06a fix(ci): 打包流程更正 2025-04-12 10:31:00 +08:00
DLmaster
deb0506163 feat(ui): 添加MirrorChyan购买链接 2025-04-12 09:46:29 +08:00
DLmaster
c4aeb673fd fix(ci): 恢复单文件打包方法 2025-04-12 09:38:34 +08:00
DLmaster
915ee59643 fix(core): 清理临时更新器改为主程序退出时进行 2025-04-12 06:43:43 +08:00
DLmaster
1568e120be Merge branch 'user_dashboard_dev' into dev 2025-04-12 06:18:39 +08:00
DLmaster
d19dd3496d feat(gui): 优化与修复
- 添加用户仪表盘子界面
- 更新逻辑修复
- 获取关卡号,用户密码解密逻辑优化
2025-04-12 06:18:20 +08:00
DLmaster
62c86ce477 feat(ci): 重新激活流程 2025-04-11 21:06:35 +08:00
DLmaster
c727eddc54 fix(ci): 添加写权限 2025-04-11 20:56:40 +08:00
DLmaster
9c946ef6dc feat(ci): 适配最新打包代码 2025-04-11 19:10:59 +08:00
DLmaster
38a04fc4b2 Squashed commit of the following:
commit 8724c545a8af8f34565aa71620e66cbd71547f37
Author: DLmaster <DLmaster_361@163.com>
Date:   Fri Apr 11 18:08:28 2025 +0800

    feat(core): 预接入mirrorc

commit d57ebaa281ff7c418aa8f11fe8e8ba260d8dbeca
Author: DLmaster <DLmaster_361@163.com>
Date:   Thu Apr 10 12:37:26 2025 +0800

    chore(core): 基础配置相关内容重构

    - 添加理智药设置选项 #34
    - 输入对话框添加回车键确认能力 #35
    - 用户列表UI改版升级
    - 配置类取消单例限制
    - 配置读取方式与界面渲染方法优化

commit 710542287d04719c8443b91acb227de1dccc20d0
Author: DLmaster <DLmaster_361@163.com>
Date:   Fri Mar 28 23:32:17 2025 +0800

    chore(core): search相关结构重整

commit 8009c69236655e29119ce62ff53a0360abaed2af
Merge: 648f42b 9f88f92
Author: DLmaster <DLmaster_361@163.com>
Date:   Mon Mar 24 15:31:40 2025 +0800

    Merge branch 'dev' into user_list_dev
2025-04-11 18:57:10 +08:00
bded794647 Merge remote-tracking branch 'upstream/main' into dev
# Conflicts:
#	.github/workflows/build-app.yml
#	.github/workflows/build-pre.yml
#	app/core/config.py
2025-04-11 10:37:21 +08:00
AoXuan
539cb1de99 Merge pull request #37 from 134820134820/fix(config)-修复统计关卡掉落错误
fix(config):修复相同战斗关卡的掉落物累加问题
2025-04-11 10:22:53 +08:00
2e9ff47dbb fix(config): 移除了剿灭模式的特殊处理逻辑、优化累加掉落数据的效率 2025-04-11 10:18:42 +08:00
MistEO
c01079af1b ci: add mirrorchyan uploading (#38)
* Create mirrorchyan.yml

* Merge pull request #1 from MistEO/patch-2

* Update build-app.yml

* Update build-pre.yml
2025-04-11 09:09:04 +08:00
Dave-Desktop
cca1acb6f6 fix(config):修复相同战斗关卡的掉落物累加问题 2025-04-11 02:12:14 +10:00
DLmaster
d7e502e22f fix(ui): 对win10主题进一步适配 2025-04-09 11:22:50 +08:00
DLmaster
bbeab360bc Merge pull request #36 from Nether-Dream/dev
对主题代码优化,使其尽量适配Win10
2025-04-09 11:08:51 +08:00
Nether-Dream
a78b7fdb29 对主题代码优化,使其尽量适配Win10
因为是Win10环境修改,需要Win11用户进行测试
2025-04-09 10:33:34 +08:00
273fbe2261 fix(MAA): 修复技巧概要·卷1等技能书不能被掉落统计正确统计。 2025-03-26 14:26:06 +08:00
ba9855c616 fix(notify): 修复 ServerChan 消息推送的一个多余的字母 2025-03-25 14:41:18 +08:00
c54f894f4f fix(notify): 修复 ServerChan 消息换行显示异常问题 2025-03-25 14:14:06 +08:00
DLmaster
9f88f92ec0 Merge branch 'notification_dev' into dev 2025-03-24 15:07:07 +08:00
DLmaster
a80e96c2cd feat(notification): 程序优化
- loguru开始捕获子线程异常
- 通知服务添加校验项
2025-03-24 15:05:27 +08:00
DLmaster
7774612810 Merge branch 'notification_dev' into dev 2025-03-24 11:46:46 +08:00
088ea1817c feat(notification): 添加测试通知功能 2025-03-24 10:15:39 +08:00
DLmaster
f362c8f7ef Merge branch 'user_list_dev' into dev 2025-03-21 22:48:57 +08:00
DLmaster
648f42b7e0 fix(core): 修复版本更新相关的若干问题
- 修复更新器解压失败问题
- 主程序版本号完全写死在代码内部
2025-03-21 22:48:05 +08:00
DLmaster
9a56cc350d fix(core): 修复更新通知阻碍调度开始问题 #32 2025-03-20 19:55:15 +08:00
DLmaster
50cd49217f Merge branch 'annihilation_dev' into dev 2025-03-20 12:42:03 +08:00
DLmaster
7ed4b7db57 fix(core): 补充版本信息文件 2025-03-20 12:41:47 +08:00
b359cd623b feat(maa): 添加每周剿灭模式上限功能 2025-03-20 10:42:46 +08:00
DLmaster
a363e8dc34 fix(utils): 修复打包中版本信息生成方法 2025-03-18 23:05:20 +08:00
DLmaster
52affc0d76 feat(utils): 更新器优化
- 更新信息样式优化
- 更新器支持动态获取下载站
2025-03-18 22:56:33 +08:00
DLmaster
fe26f29f93 test(ci): 测试新下载站-2 2025-03-17 21:06:33 +08:00
DLmaster
67b8725156 test(ci): 测试新下载站-1 2025-03-17 20:52:39 +08:00
DLmaster
2a235b2bc9 test(ci): 测试新下载站 2025-03-17 18:52:11 +08:00
DLmaster
dd022cf356 Merge branch 'Notice_dev' into dev 2025-03-16 01:10:25 +08:00
DLmaster
62e5bb30e2 feat(ui): 公告样式优化 2025-03-16 01:10:13 +08:00
DLmaster
675e11960a fix(ui): 补充网址外链调色 2025-03-16 01:01:23 +08:00
DLmaster
0c274ecbe0 feat(ui): 初步完成公告界面升级 2025-03-16 00:22:24 +08:00
DLmaster
2dfcd3f131 fix(core): 日志版本号更新 2025-03-15 17:52:31 +08:00
DLmaster
053acd138f fix(models): 修复MAA超时判定异常失效 2025-03-15 14:02:47 +08:00
DLmaster
3f20ae62be fix(models): 修复检测到MAA未能实际执行任务报错被异常屏蔽 2025-03-15 13:21:52 +08:00
DLmaster
d342c7c827 Merge branch 'DLMS_dev' into dev 2025-03-15 13:11:52 +08:00
DLmaster
3da0cfd0d0 feat(core): 添加强制关闭ADB与模拟器等增强任务项 2025-03-15 13:11:39 +08:00
DLmaster
acc4045580 Merge branch 'DLMS_dev' into dev 2025-03-15 00:00:57 +08:00
DLmaster
6ee577302f fix(core): 人工排查时自动屏蔽静默操作 2025-03-14 23:59:55 +08:00
DLmaster
d52856180a fix(maa): 人工排查弹窗方法优化 2025-03-14 23:35:58 +08:00
DLmaster
d4d479ca20 feat(ui): 关机等电源操作添加100s倒计时 2025-03-14 23:06:51 +08:00
DLmaster
364af4b9c5 fix(ui): 修复密码显示按钮动画异常 2025-03-13 21:15:45 +08:00
DLmaster
9e0d81fb1d Merge branch 'updater_dev' into dev 2025-03-13 21:00:37 +08:00
DLmaster
2ee2c37479 feat(utils): 更新器支持指定线程数 2025-03-13 21:00:09 +08:00
DLmaster
528925b969 feat(utils): 更新器初步支持多线程下载 2025-03-13 20:21:25 +08:00
4851b40777 fix(main_window): 修改网络错误提示,不展示具体报错信息,只在log中展示 2025-03-10 16:51:47 +08:00
DLmaster
6372ad4e0a Merge pull request #31 from Zrief/dev
Update main_info_bar.py
2025-03-09 22:12:03 +08:00
Zrief
465bc9137e Update main_info_bar.py
简化代码长度
2025-03-09 18:51:41 +08:00
DLmaster
e8b6f5d893 feat(core): 屏蔽MuMu模拟器开屏广告功能上线 2025-03-07 17:48:43 +08:00
DLmaster
54b697f2ee Merge branch 'dev' 2025-03-07 11:56:54 +08:00
DLmaster
70df428825 fix(rescourse): 修复统计信息HTML模板公招匹配错误 2025-03-07 11:56:28 +08:00
DLmaster
8993d66056 Merge branch 'dev' 2025-03-07 01:19:18 +08:00
DLmaster
863e6fb25e Merge branch 'notice_dev' into dev 2025-03-06 23:43:10 +08:00
DLmaster
181856173e feat(core): 调整获取主题图像的时机 2025-03-06 23:42:59 +08:00
DLmaster
576fe59bbc fix(models): 修复任务被手动中止项目无法被正常录入的问题 2025-03-06 23:37:51 +08:00
DLmaster
c73aca71f7 feat(core): 优化通知服务
- 优化相关设置项排布
- 邮件添加HTML模板
2025-03-06 23:02:23 +08:00
DLmaster
ce264de963 feat(ui): 6星公招通知添加独立设置项 2025-03-05 16:00:48 +08:00
DLmaster
1feb0cf83f fix(serices): 修复通知服务使用InfoBar报错时程序崩溃问题 2025-03-05 15:11:57 +08:00
DLmaster
6292624d41 fix(services): 添加邮箱通知校验过程 2025-03-05 14:34:15 +08:00
4271a07f03 fix: 修复企业微信群机器人推送通知消息未正确换行 2025-03-05 14:07:03 +08:00
DLmaster
254fb6916f feat(core): 初步完成六星公招通知 2025-03-03 21:59:37 +08:00
DLmaster
21857325a2 Merge branch 'notice_dev' into dev 2025-03-03 20:41:17 +08:00
DLmaster
175d6860a3 feat(core): 添加统计信息通知功能 2025-03-03 20:40:28 +08:00
DLmaster
d1c8f98408 feat(ui): 主页添加主题活动信息 2025-03-02 17:14:09 +08:00
DLmaster
3499fa9067 feat(utils): 更新器拥有多网址测速功能 2025-03-02 15:32:56 +08:00
DLmaster
cca2cd774c chore(release): 发版 v4.2.4-beta.6 2025-03-01 22:53:08 +08:00
DLmaster
6d60f8adb8 Merge branch 'gui_dev' into dev 2025-03-01 22:51:30 +08:00
DLmaster
3b406a7974 feat(gui): 主页功能完善 2025-03-01 22:49:51 +08:00
a116b3359c fix: 修复掉落统计正则表达式错误统计时间的问题 2025-02-27 16:04:45 +08:00
928019390b chore(release): 发版 v4.2.4-beta.5 2025-02-26 21:58:29 +08:00
022b698f54 fix: 添加剿灭模式特殊适配 2025-02-26 16:13:46 +08:00
0228ac8393 fix: 修复 RMA70-12 匹配正则表达式 2025-02-26 16:12:42 +08:00
DLmaster
a99f381f7f fix(ui): 换个正常的主页 2025-02-22 00:22:57 +08:00
DLmaster
7c0af24bf5 fix(ui): 修正部分主页网址 2025-02-21 21:39:16 +08:00
DLmaster
d3aa45cfb9 Merge branch 'DLMS_dev' into dev 2025-02-21 18:36:32 +08:00
DLmaster
f5461deb81 feat(ui): 软件主页初步完成 2025-02-21 18:35:55 +08:00
DLmaster
c19068128f feat(core): 添加启动时直接最小化功能 2025-02-21 16:15:55 +08:00
DLmaster
1367daf1b7 feat(ui): 添加软件临时主页 2025-02-21 15:54:28 +08:00
DLmaster
5fc6e74cd6 Merge branch 'log_dev' into dev 2025-02-21 12:05:08 +08:00
DLmaster
5d7227c009 feat(ui): 初步完成历史记录前端适配 2025-02-21 12:04:52 +08:00
3a9c670172 feat(core): 新增日志管理功能
- 在配置文件中添加日志保存和保留天数设置项
- 实现日志保存功能,每次运行后保存日志到指定目录
- 添加日志分析功能,掉落信息并保存为 JSON 文件
- 在设置界面新增日志管理相关配置选项

todo: 日志清理可能有问题、多账号日志可能会保存为上一个账号的日志(加了time.sleep还没测)
2025-02-18 17:29:13 +08:00
DLmaster
2768faed53 fix(core): 修正更新器方法;取消MAA运行中自动更新;补充MAA监测字段 2025-02-18 16:17:03 +08:00
DLmaster
85f3d6f09f fix(core): 修改下载器默认下载目录为/script 2025-02-17 22:54:45 +08:00
DLmaster
c99707ecb4 Merge branch 'dev' 2025-02-17 18:09:42 +08:00
DLmaster
2b8e648fe6 Merge branch 'DLMS_dev' into dev 2025-02-17 18:08:45 +08:00
DLmaster
fcf61fd39a feat(core): 接入镜像源 2025-02-17 18:06:05 +08:00
DLmaster
8e3026f91e ci(build): 激活测试流程 2025-02-17 14:27:06 +08:00
DLmaster
8e00676faf ci(build): 测试服务器上传功能 2025-02-17 14:20:23 +08:00
DLmaster
ae293c4c20 Merge pull request #28 from ClozyA:dev
ci(build): 增加预发布版本服务器上传
2025-02-17 13:28:53 +08:00
df4a5f3318 ci(build): 增加预发布版本服务器上传 2025-02-17 12:02:03 +08:00
DLmaster
1da96c4d1d fix(ui): 矫正老板键文案 2025-02-17 11:06:07 +08:00
DLmaster
144c7f5db7 feat(modles): 配置MAA前关闭可能未正常退出的MAA进程 2025-02-16 18:44:46 +08:00
DLmaster
b3a3ccfea3 feat(core): 恢复启动后直接运行主任务功能以及相关托盘菜单 2025-02-16 14:56:25 +08:00
DLmaster
c3212f0ca1 Merge branch 'DLMS_dev' into dev 2025-02-15 17:32:47 +08:00
DLmaster
a946999782 fix: 修正版本号 2025-02-15 17:32:33 +08:00
DLmaster
284c41feb7 fix(ui): 适配深色模式 2025-02-15 17:28:54 +08:00
DLmaster
ddf905cb13 docs: 文档站试运行 2025-02-15 15:55:25 +08:00
DLmaster
d5082d18ef fix: 更正版本描述 2025-02-14 23:21:38 +08:00
DLmaster
af51831062 Merge branch 'DLMS_dev' into dev 2025-02-14 16:51:04 +08:00
DLmaster
fe4707df84 feat(ui): 优化主调度台默认选项 2025-02-14 16:50:45 +08:00
DLmaster
292e7f51e0 chore(core): 升级日志监看方法 2025-02-14 16:27:18 +08:00
DLmaster
8936b1c41d fix(core): 修复部分异常;添加高级代理文件校验过程 2025-02-13 13:03:44 +08:00
DLmaster
d45da439bd chore(models): 优化MAA关闭方法 2025-02-12 22:15:17 +08:00
DLmaster
7dc057e30f feat(models): 简洁用户列表下相邻两个任务间的切换方式 2025-02-12 22:06:04 +08:00
DLmaster
eb2f9d2cea fix(models): 修复静默代理标记移除异常情况 2025-02-11 13:47:12 +08:00
DLmaster
fb1895c4eb Merge branch 'dev' 2025-02-10 19:26:25 +08:00
DLmaster
90d3dad8c8 fix(services): 修复邮箱发信人信息错误 2025-02-10 11:29:27 +08:00
DLmaster
de12339c3e Merge branch 'DLMS_dev' into dev 2025-02-09 21:36:57 +08:00
DLmaster
f07cd2b44a feat(core): 邮箱推送功能调整,改由用户提供发信邮箱 2025-02-09 21:36:33 +08:00
DLmaster
c7fbbf6f50 Merge branch 'DLMS_dev' into dev 2025-02-08 16:58:23 +08:00
DLmaster
de262ee6bd fix(models): 修复设置MAA时异常调用B服任务设置 2025-02-08 16:58:11 +08:00
DLmaster
0c123e9389 feat(services): 通知标题添加脚本实例信息 2025-02-08 12:25:48 +08:00
DLmaster
d13fbb063d feat(core): 初步完成托管bilibili游戏隐私政策功能 2025-02-08 12:05:28 +08:00
DLmaster
5c24eb7d56 docs: 改用腾讯文档展示使用方法 2025-02-07 20:15:12 +08:00
DLmaster
6c2f19a884 Revert "新增用户字段today_stauts"
This reverts commit 4ff632ed2a.
2025-02-07 19:56:41 +08:00
heziziziscool
4ff632ed2a 新增用户字段today_stauts
新增用户字段`today_status`(位于user_db),用于记录用户的代理是否代理成功,便于后续的衍生项目与二次开发。
2025-02-07 18:50:32 +08:00
DLmaster
d445c0054f Merge branch 'DLMS_dev' into dev 2025-02-07 18:27:29 +08:00
DLmaster
748fa7a004 fix(gui): 修复窗口记忆功能失效问题 2025-02-07 18:27:01 +08:00
DLmaster
c3e710b5cf chore(gui): 调整MAA设置目录时打开当前已配置的目录位置 2025-02-07 15:53:37 +08:00
DLmaster
a93a60d125 chore(core): 优化静默判定逻辑 2025-02-07 15:37:07 +08:00
DLmaster
07f24c6168 Merge branch 'DLMS_dev' into dev 2025-02-06 23:33:16 +08:00
DLmaster
7f5478b098 feat(core): 添加调度队列完成任务后行为选项 2025-02-06 23:33:00 +08:00
DLmaster
fb7a429ff2 Merge pull request #22 from ClozyA:auto_shutdown
feat(core): 添加运行完成后自动关机功能
2025-02-06 18:33:12 +08:00
3307793a3d feat: 添加运行完成后自动关机功能 2025-02-06 15:55:55 +08:00
DLmaster
0da9f4b7ab Merge branch 'main' into dev 2025-02-04 22:48:09 +08:00
DLmaster
f45dc3a34c chore(utils): 修改代理优先级 2025-02-04 22:47:26 +08:00
DLmaster
1c17f3d878 Merge branch 'dev' 2025-02-04 22:36:34 +08:00
DLmaster
75e4d2b290 fix(utils): 修复更新器异常并覆盖版本 2025-02-04 22:34:17 +08:00
DLmaster
32fe941735 Merge branch 'dev' 2025-02-04 21:52:20 +08:00
DLmaster
27633b1017 Merge branch 'DLMS_dev' into dev 2025-02-04 18:56:27 +08:00
DLmaster
c34ca0dea9 fix(utils): 更新代理镜像 2025-02-04 18:56:09 +08:00
DLmaster
0574e9c6cb fix(gui): 调整部分选项文案 2025-02-04 18:48:58 +08:00
DLmaster
b7f09141f1 feat(core): 添加更新类别可选项 2025-02-04 18:27:19 +08:00
DLmaster
022e59e65c fix(gha): pr时不再自动发版 2025-02-04 15:56:21 +08:00
DLmaster
a0731331a8 fix(gui): 修复高级MAA配置序号错位;修复高级用户无法配置问题 2025-02-04 15:17:15 +08:00
DLmaster
4b01222648 Merge branch 'dev' into DLMS_dev 2025-02-04 14:16:07 +08:00
DLmaster
cae4b26c89 Merge branch 'dev' of https://github.com/DLmaster361/AUTO_MAA into dev 2025-02-04 14:08:27 +08:00
DLmaster
427c2332f5 chore: 补充版本信息 2025-02-04 14:08:13 +08:00
heziziziscool
6f0aec329b 新增代理成功消息推送渠道Server酱与企业微信群机器人推送 2025-02-04 14:07:45 +08:00
DLmaster
4e4d1d068f chore: 补充版本信息 2025-02-03 20:12:19 +08:00
DLmaster
074f4f2ca9 Merge branch 'hz_dev' into dev 2025-02-03 20:05:34 +08:00
heziziziscool
c51f9ad901 新增代理成功消息推送渠道Server酱与企业微信群机器人推送 2025-02-03 19:22:36 +08:00
heziziziscool
792452c048 Revert "revert 提交到main"
This reverts commit 662eb0bc7f.
2025-02-03 19:00:01 +08:00
heziziziscool
662eb0bc7f revert 提交到main 2025-02-03 18:57:40 +08:00
heziziziscool
94a9bdbb93 Revert "- 新增Server酱与企业微信群机器人推送代理成功渠道。"
This reverts commit df96183f42.
2025-02-03 18:54:09 +08:00
heziziziscool
df96183f42 - 新增Server酱与企业微信群机器人推送代理成功渠道。 2025-02-03 18:49:46 +08:00
DLmaster
a5b4f6f59f fix(gui): 修复主调度台运行时选项变动问题 2025-02-03 16:20:26 +08:00
DLmaster
6f7497cbe9 fix(utils): 修复更新器文件夹定位问题 2025-02-03 09:47:10 +08:00
DLmaster
dbdc2144b7 Merge branch 'dev' 2025-02-02 09:26:20 +08:00
DLmaster
e34106f857 Merge branch 'fix_inf_dev' into dev 2025-02-02 01:20:01 +08:00
DLmaster
c3c07804cd fix(core): 修复自定义基建无法正常使用的问题
feat(core): 添加用户每日代理次数上限功能
2025-02-02 01:19:46 +08:00
DLmaster
0b5ac6bb6e Merge branch 'dev' 2025-01-31 22:03:03 +08:00
DLmaster
ea87eefb9b doc: 修复部分图片位置 2025-01-31 22:02:51 +08:00
DLmaster
20dc4656dc Merge branch 'dev' 2025-01-31 21:52:57 +08:00
DLmaster
3f0f1612e3 doc: README同步至v4.2.2版本 2025-01-31 21:52:31 +08:00
DLmaster
e92b6ecfe6 fix(maa): 修正人工排查文案 2025-01-29 10:24:03 +08:00
DLmaster
37502b6fd8 Merge branch 'dev' 2025-01-28 20:05:58 +08:00
DLmaster
8c1c3c5675 发布v4.2.2 2025-01-28 20:04:56 +08:00
DLmaster
b4228e3f35 fix(core): 补充公告逻辑 2025-01-28 19:28:04 +08:00
DLmaster
e78f3973be fix(services): 修复管理密钥修改逻辑 2025-01-28 17:28:51 +08:00
DLmaster
29536003a4 fix(core): 修复调度队列为空时定时执行被反复调起 2025-01-28 16:13:12 +08:00
DLmaster
ffa3767198 fix(package): 固定nuitka版本号 2025-01-28 14:53:36 +08:00
DLmaster
8702070725 test package -2 2025-01-28 13:36:01 +08:00
DLmaster
793259a194 test package -1 2025-01-28 12:50:49 +08:00
DLmaster
3a63a73244 fix(test): 补充更新公告 2025-01-28 11:55:50 +08:00
DLmaster
43448cc68d fix(timer): 修复反复记录FailSafeException影响log阅读 2025-01-28 10:22:17 +08:00
DLmaster
f0d272cce5 Merge branch 'user_edit_dev' into dev 2025-01-27 23:18:34 +08:00
DLmaster
9983455b60 fix(maa): 修复手机号码不全时发生的混乱 2025-01-27 23:10:46 +08:00
DLmaster
0a411c150a fix(core): 规范自动化构筑流程 2025-01-27 22:29:45 +08:00
DLmaster
89b49a1143 Merge branch 'audio_dev' into dev 2025-01-27 22:17:54 +08:00
DLmaster
917454c0b9 fix(core): 调整公告方式 2025-01-27 22:17:31 +08:00
DLmaster
170b87e7a8 feat(core): 添加公告功能 2025-01-27 21:40:34 +08:00
DLmaster
68db248a7e 重复打包 2025-01-27 17:06:35 +08:00
DLmaster
24614099ed Merge branch 'fluent-gui-dev' into dev 2025-01-27 12:32:12 +08:00
DLmaster
8bbfdcbc04 fix(utils): 修复 version_text 引起的打包问题 2025-01-27 12:20:07 +08:00
DLmaster
126799d2a2 feat(core): 恢复基本所有原功能,数据文件版本更新 2025-01-27 10:38:17 +08:00
DLmaster
c625354dec feat(core):初步完成主调度自动代理功能开发 2025-01-26 07:58:33 +08:00
DLmaster
7e08c88a3e feat(core):初步完成定时执行功能开发 2025-01-25 18:00:56 +08:00
DLmaster
764d0afb50 Release v4.2.1 2025-01-22 19:01:01 +08:00
DLmaster
fe87547406 Merge pull request #16 from DLmaster361/dev
对于MAAv5.12.1版本后两个字段`Start.RunDirectly`与`Start.OpenEmulatorAfterLaunch`的适配
2025-01-22 18:53:19 +08:00
heziziziscool
a1fd27722b Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	app/models/MAA.py
2025-01-22 18:29:07 +08:00
heziziziscool
3bd6611cd5 对于MAAv5.12.1版本后两个字段Start.RunDirectlyStart.OpenEmulatorAfterLaunch的适配 2025-01-22 18:28:42 +08:00
heziziziscool
f3e1b4580a 对于MAAv5.12.1版本后两个字段Start.RunDirectlyStart.OpenEmulatorAfterLaunch的适配 2025-01-22 17:24:52 +08:00
heziziziscool
449d8a032e 对于MAAv5.12.1版本后两个字段Start.RunDirectlyStart.OpenEmulatorAfterLaunch的适配 2025-01-22 17:24:26 +08:00
heziziziscool
b40fa35622 对于MAAv5.12.1版本后两个字段Start.RunDirectlyStart.OpenEmulatorAfterLaunch的适配 2025-01-22 17:22:01 +08:00
DLmaster
ff7e433634 fix(gui): 修复界面显示的若干方法 2025-01-12 11:59:22 +08:00
DLmaster
1ea9d10bb4 修复开机自启时目录错误 2025-01-07 11:32:06 +08:00
DLmaster
04fd2b10fa 修改缓存文件夹 2025-01-06 18:45:48 +08:00
DLmaster
e79417ec5e 调度队列逻辑修复 2025-01-06 16:23:45 +08:00
DLmaster
684211c129 初步完成调度队列开发 2025-01-05 19:24:01 +08:00
DLmaster
f94e129cba 添加启动界面 2025-01-04 14:22:45 +08:00
DLmaster
87a140d373 覆盖pre版本 2025-01-04 04:03:27 +08:00
DLmaster
f135a6510f 设置界面与实例管理界面初步重构 2025-01-04 04:02:20 +08:00
DLmaster
6960184911 回退版本 2025-01-02 10:37:49 +08:00
DLmaster
7bd270c662 Merge branch 'fluent-gui-dev' 2025-01-01 21:55:16 +08:00
DLmaster
f54e83673f 初步完成UI美化 2025-01-01 21:50:40 +08:00
DLmaster
ee40fdb3c3 Merge branch 'main' 2025-01-01 11:54:55 +08:00
DLmaster
52ebf7b027 改用pathlib处理文件目录 2025-01-01 11:52:38 +08:00
DLmaster
85891dc918 修正更新器产品号 2024-12-30 23:16:21 +08:00
DLmaster
7348a87a20 优化自动化打包流程显示 2024-12-30 22:11:50 +08:00
DLmaster
1dfa3e3f44 Merge branch 'Dev' 2024-12-30 20:16:22 +08:00
DLmaster
37ced2e535 修改自动化流程提示文本样式 2024-12-30 20:01:38 +08:00
DLmaster
11876acc62 更新版本至4.2.0,添加nuitka支持,优化项目结构并模块化功能组件 2024-12-30 19:51:57 +08:00
DLmaster
756c0926ec 完善打包流程 2024-12-30 19:41:25 +08:00
DLmaster
9604fc9a8e 修正打包参数 2024-12-29 17:35:36 +08:00
DLmaster
5bcc527889 优化打包参数 2024-12-29 17:18:28 +08:00
DLmaster
0d616289ed 添加pyautogui异常逻辑捕获处理 2024-12-29 17:10:27 +08:00
DLmaster
a8473b6a04 清除无效整合步骤 2024-12-29 01:55:48 +08:00
DLmaster
e1352586b7 Updater改为独立打包 2024-12-29 01:42:03 +08:00
DLmaster
849d5f18eb 添加新架构测试文件 2024-12-29 00:01:55 +08:00
DLmaster
77298f4dab 修正工作流名称 2024-12-28 23:59:06 +08:00
DLmaster
b7a2b045fb 创建全新模块化架构 2024-12-28 23:31:50 +08:00
DLmaster
c7072da81d 同步“启动MAA后直接最小化”字段 2024-12-27 20:48:54 +08:00
DLmaster
4c9b9fb74a tips文案更新 2024-12-25 23:03:29 +08:00
DLmaster
feb4b516a4 修复初始设置的异常 2024-12-24 22:28:40 +08:00
DLmaster
d50191c6b8 添加psutil库 2024-12-23 21:19:24 +08:00
DLmaster
3db3fa4e1f 后台静默代理功能上线 2024-12-23 20:30:12 +08:00
DLmaster
ad31961c2b 修正使用方法适配最新版本 2024-12-19 17:52:16 +08:00
DLmaster
ccdaefeb61 统一标点符号 2024-12-18 02:51:54 +08:00
DLmaster
8c9bcba198 修正更新器的若干问题 2024-12-18 02:41:40 +08:00
DLmaster
a273af0abe 更新器优化;添加邮件仅推送异常信息选项 2024-12-18 02:04:40 +08:00
DLmaster
05a063b001 修改配置方法 2024-12-14 13:41:15 +08:00
DLmaster
8487195512 调整通知停留时长 2024-12-10 20:48:09 +08:00
DLmaster
250c47ccb7 添加托盘中止任务选项 2024-12-10 19:59:12 +08:00
DLmaster
e5aeb4f3a7 添加托盘名称 2024-12-05 22:12:57 +08:00
DLmaster
eab8d984e6 Merge branch 'main' of https://github.com/DLmaster361/AUTO_MAA 2024-12-05 21:37:14 +08:00
DLmaster
6b3f19a618 修复浅色模式下UI异常 2024-12-05 21:35:32 +08:00
DLmaster
d5f8871064 重构MainTimer逻辑 2024-12-05 17:21:39 +08:00
DLmaster
77e899957c Merge pull request #11 from ClozyA/patch-1
fix: MAA项目地址
2024-12-04 15:54:23 +08:00
AoXuan
dd3515305c fix: MAA项目地址 2024-12-04 15:17:49 +08:00
DLmaster
73c3ec4820 修复深色模式下UI异常 2024-12-04 14:27:58 +08:00
DLmaster
fde5160d56 补充重要声明 2024-12-01 10:46:10 +08:00
DLmaster
13923705e5 修复窗口管理方面的部分问题 2024-12-01 00:16:49 +08:00
DLmaster
d3afc95261 启动时不显示托盘 2024-11-29 18:58:50 +08:00
DLmaster
326d7e474c 修复新用户无法设置MAA路径 2024-11-29 18:02:16 +08:00
DLmaster
537b5d9725 移除最小化启动模拟器选项 2024-11-29 11:01:55 +08:00
DLmaster
697c1b5b43 优化记忆窗口与最小化到托盘的体验 2024-11-28 20:43:12 +08:00
DLmaster
2b6057161e 更新时彻底关闭主程序 2024-11-27 21:17:05 +08:00
DLmaster
8e2a6444bd 修复设定时刻无法中止任务的问题 2024-11-27 20:36:51 +08:00
DLmaster
b04df40b7d 图标优化;添加最小化到托盘功能;更新器摆脱UI文件限制 2024-11-27 00:04:37 +08:00
DLmaster
3e17d94904 合并MAA启动器配置流程 2024-11-21 19:52:15 +08:00
DLmaster
29123054cc 3^3 2024-11-19 20:31:52 +08:00
DLmaster
530c038985 3^3 2024-11-19 20:25:53 +08:00
DLmaster
2e9d2f0491 添加软件介绍 2024-11-19 20:20:46 +08:00
DLmaster
69ab9b4f15 邮件通知功能上线 2024-11-16 10:42:56 +08:00
DLmaster
1c8b940925 记忆窗口位置 2024-11-15 10:08:50 +08:00
DLmaster
0e05a4ea18 修正version配置 2024-11-14 15:50:06 +08:00
DLmaster
9e4285b4c5 发布4.1.2.0 2024-11-14 15:40:46 +08:00
DLmaster
e89d2af8ae 发布4.1.2正式版 2024-11-14 15:35:32 +08:00
DLmaster
8e7d663060 补充注释 2024-11-10 01:51:24 +08:00
DLmaster
179b33387e 完成主程序更新后打开AUTO_MAA 2024-11-10 01:46:38 +08:00
DLmaster
2c9a7c443f 加注释 2024-11-10 01:30:51 +08:00
DLmaster
0de964e68c 修复解压失败时本地版本号异常变动问题 2024-11-09 16:30:25 +08:00
DLmaster
97046af931 version修复缺失字段 2024-11-09 16:24:44 +08:00
DLmaster
ec24e776e7 修复#9与人工排查模式报错 2024-11-09 16:09:28 +08:00
DLmaster
92d4ef05fa 进一步限制log_text长度 2024-11-09 11:58:16 +08:00
DLmaster
fb27c50c75 解决log_text过长导致的log显示于中间 2024-11-09 11:15:44 +08:00
DLmaster
0141bf039a 添加清理可能存在的临时文件步骤 2024-11-08 22:11:48 +08:00
DLmaster
54f9bb7f21 修复url错误 2024-11-08 22:05:06 +08:00
DLmaster
2c4e5eb5cc 发布v4.1.2 2024-11-08 21:57:28 +08:00
DLmaster
cd42f45a1f 打包目录结构优化 2024-11-08 20:32:06 +08:00
DLmaster
ec38895765 修改更新器图标 2024-11-08 19:01:16 +08:00
DLmaster
fc30579bbc 修复部分组件缩放问题 2024-11-07 23:45:19 +08:00
DLmaster
3e6223e4e5 发布公测消息 2024-11-07 23:35:04 +08:00
DLmaster
27c71124ff 尝试解决log框卡中间问题 2024-11-07 23:18:34 +08:00
DLmaster
3a8a8a36f5 去除误注释 2024-11-07 21:55:04 +08:00
DLmaster
ed6de5e806 打包方式优化 2024-11-07 21:51:15 +08:00
DLmaster
bf8a5befa3 添加无限代理天数模式 2024-11-07 21:00:32 +08:00
DLmaster
a3fe641f6b 添加启动AUTO_MAA后直接代理功能 2024-11-07 17:25:58 +08:00
DLmaster
f4f99c25db 适配“启动MAA后自动开启模拟器”字段更改 2024-11-06 21:51:56 +08:00
DLmaster
640d80e334 当前为最新版本通知修复,报错格式化 2024-11-03 01:23:32 +08:00
DLmaster
4f196b516f 部分功能恢复 2024-11-03 00:55:18 +08:00
DLmaster
87f07bf95c 调整参数适配主分支 2024-11-02 23:57:23 +08:00
DLmaster
1bfd7891b5 Merge branch 'Updater' 2024-11-02 23:30:16 +08:00
DLmaster
472eb0ee52 修正部分措辞 2024-10-31 20:12:30 +08:00
DLmaster
7638c67da6 更详细的使用说明书 2024-10-31 20:04:17 +08:00
DLmaster
7a0c143b5b 适配MAA的adb连接文案修改 2024-10-29 08:55:01 +08:00
DLmaster
fff8e11524 恢复代理时的自动更新 2024-10-29 08:11:06 +08:00
DLmaster
af24208cf3 无命令行中止MAA进程 2024-10-26 15:31:49 +08:00
DLmaster
85bccea2dd 删除冗余代码 2024-10-26 11:20:12 +08:00
DLmaster
f7c8cac6ec 人工排查接管更多MAA启动设置 2024-10-26 10:41:02 +08:00
DLmaster
97a5ac5bb0 修复MAA切换配置选项 2024-10-25 18:50:34 +08:00
DLmaster
1d57076010 修复自定义基建默认配置 2024-10-25 18:30:15 +08:00
DLmaster
d298ac872c 优化自定义基建配置方法 2024-10-25 15:04:05 +08:00
DLmaster
c0581e781c 工作流适配新目录结构 2024-10-24 20:08:22 +08:00
DLmaster
6befd6341a gameid.txt改由主程序进行初始化 2024-10-24 20:03:23 +08:00
DLmaster
504ef8dd68 修改部分格式 2024-10-23 21:56:41 +08:00
DLmaster
127500d890 禁用MAA路径直接编辑;更新README至最新版 2024-10-23 21:41:35 +08:00
DLmaster
ab94173df7 支持B服,beta模式上线 2024-10-23 15:19:49 +08:00
DLmaster
e50697aae1 添加系统通知功能 2024-10-09 14:16:37 +08:00
DLmaster
c71389fc0b 优化MAA设置逻辑 2024-10-08 19:26:42 +08:00
DLmaster
732129ba34 修正变量命名逻辑 2024-10-07 20:08:26 +08:00
DLmaster
77dfc895ed 修复内部任务失败逻辑错误 2024-10-06 22:09:33 +08:00
DLmaster
0c6f3123f8 人工排查功能上线;修复未完成用户无法正确加载的问题 2024-10-06 15:48:06 +08:00
DLmaster
e872d30b3a 除去冗余通配符 2024-10-02 15:19:01 +08:00
DLmaster
48fa4413c8 修复打包目录结构 2024-10-02 14:54:42 +08:00
DLmaster
94e693db77 添加对内部任务失败的识别 2024-09-20 23:27:16 +08:00
DLmaster
025d328d79 修复索引错误 2024-09-10 11:55:54 +08:00
DLmaster
d086a5a152 修改更新release的逻辑 2024-09-10 08:44:10 +08:00
DLmaster
5f2337d949 修复部分语法错误 2024-09-10 08:12:38 +08:00
DLmaster
5e163181a8 去除测试标记 2024-09-10 07:50:13 +08:00
DLmaster
0913cbb813 修改部分语法错误 2024-09-10 07:40:42 +08:00
DLmaster
6aed64ce4e 添加创建zip动作 2024-09-10 07:32:32 +08:00
DLmaster
3c95e58fb2 五次测试 2024-09-09 23:02:25 +08:00
DLmaster
64d0d4ed41 四次测试 2024-09-09 22:45:05 +08:00
DLmaster
7fb659c511 三处测试 2024-09-09 22:40:00 +08:00
DLmaster
d4ec91940f 二次测试 2024-09-09 22:35:47 +08:00
DLmaster
77f63ebb44 测试 2024-09-09 22:24:34 +08:00
DLmaster
051b062e29 修复发布新版本时无法上传软件包 2024-09-09 22:14:21 +08:00
DLmaster
ec9083b556 修复发布新版本的换行符问题 2024-09-09 22:03:30 +08:00
DLmaster
156080a3b7 修复无法发布新版本 2024-09-09 21:55:08 +08:00
DLmaster
5b6effd43e 优化log文件读取策略,MAA运行判定修改前期准备 2024-09-09 21:34:06 +08:00
DLmaster
97e8d41c39 修复部分语法错误 2024-09-06 22:42:50 +08:00
DLmaster
d64ef447d2 修复部分语法错误 2024-09-06 22:35:07 +08:00
DLmaster
461b2a62b0 修复版本号不显示 2024-09-06 22:04:03 +08:00
DLmaster
f8988651b5 修复换行符 2024-09-06 21:34:06 +08:00
DLmaster
ffe865e29e 忽略部分pr以减少自动构建压力 2024-09-06 21:26:04 +08:00
DLmaster
cd5250c09f 适配最新语法规则 2024-09-06 21:20:04 +08:00
DLmaster
8f01bf6027 修复部分语法错误 2024-09-06 21:12:13 +08:00
DLmaster
81eb11f5c5 修复部分语法错误 2024-09-06 21:04:58 +08:00
DLmaster
80cadfa52c 添加覆盖更新能力 2024-09-06 20:30:57 +08:00
DLmaster
9974d70d99 修复部分语法错误 2024-09-06 20:19:28 +08:00
DLmaster
6e3e4662f4 修复部分语法错误 2024-09-06 20:12:19 +08:00
DLmaster
e2fb2e5565 修复部分语法错误 2024-09-06 19:48:30 +08:00
DLmaster
6f8be226b0 修复部分语法错误 2024-09-06 19:42:23 +08:00
DLmaster
2606f1e587 修复部分语法错误 2024-09-06 19:37:17 +08:00
DLmaster
4d1fd7ea76 修复部分语法错误 2024-09-06 19:31:58 +08:00
DLmaster
e61fbe6c69 修复部分语法错误 2024-09-06 19:18:50 +08:00
DLmaster
0c287577ca 精简步骤 2024-09-06 19:06:38 +08:00
DLmaster
a4aa562db9 修复部分语法错误 2024-09-06 17:32:26 +08:00
DLmaster
33c5ff3a52 修复部分语法错误 2024-09-06 17:03:18 +08:00
DLmaster
7c8e43bd35 修复部分语法错误 2024-09-06 16:34:05 +08:00
DLmaster
bc0014dbe6 修复部分语法错误 2024-09-06 16:26:36 +08:00
DLmaster
e41eca33bc 修复部分语法错误 2024-09-06 16:01:46 +08:00
DLmaster
e39b965459 修复部分语法错误 2024-09-06 14:57:25 +08:00
DLmaster
f2aa3d7347 修复部分语法错误 2024-09-06 14:51:33 +08:00
DLmaster
76c126284f 自动构建 2024-09-06 14:32:30 +08:00
DLmaster
44ee917d88 同步到MAA新版本的配置方法 2024-08-03 13:11:54 +08:00
DLmaster
669019c051 优化超时判定 2024-07-28 10:42:55 +08:00
DLmaster
4685c12570 Update python-app.yml
自动构建
2024-07-27 22:25:28 +08:00
DLmaster
d815529510 Update python-app.yml
自动构建
2024-07-27 22:03:02 +08:00
DLmaster
1da3620feb Update python-app.yml
自动构建
2024-07-27 22:00:23 +08:00
DLmaster
44d529c60f Update python-app.yml
自动构建
2024-07-27 21:53:49 +08:00
DLmaster
cdbcebd945 Update python-app.yml
自动构建
2024-07-27 21:51:37 +08:00
DLmaster
5bc2bf9397 Update python-app.yml
自动构建
2024-07-27 21:48:27 +08:00
DLmaster
d41419a579 Update python-app.yml
自动构建
2024-07-27 21:40:34 +08:00
DLmaster
a7e15e509e Create python-app.yml
自动构建
2024-07-27 21:30:41 +08:00
DLmaster
6518a378ac 修正v3.0_Beta版更新说明 2024-07-24 21:13:50 +08:00
DLmaster
ebb73182f7 添加开机自启与阻止休眠功能;优化配置文件处理方式 2024-07-24 20:57:15 +08:00
DLmaster
8fcc69165f 更新自述文件 2024-07-17 16:31:04 +08:00
DLmaster
5b98c58926 添加对自定义基建的支持 2024-07-17 16:19:38 +08:00
DLmaster
ce1534657b 修复禁用剿灭时的任务完成判定 2024-07-10 09:28:11 +08:00
DLmaster
e2f02ea616 修复定时判定;MAA路径运行检查 2024-07-09 11:48:58 +08:00
DLmaster
76134c2e2b 更新GUI相关自述文件 2024-07-07 14:26:47 +08:00
DLmaster
2010855139 重构,GUI初步开发 2024-07-07 12:43:40 +08:00
DLmaster
e550b9a155 添加QQ交流群 2024-06-09 17:03:20 +08:00
DLmaster
53049d4acd 添加对1-7连战适配 2024-05-26 12:31:24 +08:00
DLmaster
bb4b45bc36 请求祝福 2024-05-25 15:13:11 +08:00
DLmaster
caf37e6c99 优化剿灭逻辑 2024-04-30 22:59:01 +08:00
DLmaster
5af41d3b72 添加依赖环境描述文件 2024-04-30 21:55:08 +08:00
DLmaster
ebd3273251 修复死循环,日志读取优化,运行流程优化 2024-04-03 20:06:48 +08:00
165 changed files with 29833 additions and 1013 deletions

283
.github/workflows/build-app.yml vendored Normal file
View File

@@ -0,0 +1,283 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
name: Build AUTO_MAA
on:
workflow_dispatch:
permissions:
contents: write
actions: write
jobs:
pre_check:
name: Pre Checks
runs-on: ubuntu-latest
steps:
- name: Repo Check
id: repo_check
run: |
if [[ "$GITHUB_REPOSITORY" != "DLmaster361/AUTO_MAA" ]]; then
echo "When forking this repository to make your own builds, you have to adjust this check."
exit 1
fi
exit 0
build_AUTO_MAA:
runs-on: windows-latest
needs: pre_check
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24'
- name: Build go updater
shell: pwsh
run: |
cd Go_Updater
go install github.com/akavel/rsrc@latest
.\build.ps1
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Get version
id: get_version
run: |
$version = (Get-Content resources/version.json | ConvertFrom-Json).main_version
echo "main_version=$version" >> $env:GITHUB_OUTPUT
- name: Nuitka build main program
uses: Nuitka/Nuitka-Action@main
with:
script-name: main.py
mode: app
enable-plugins: pyside6
onefile-tempdir-spec: "{TEMP}/AUTO_MAA"
windows-console-mode: attach
windows-icon-from-ico: resources/icons/AUTO_MAA.ico
windows-uac-admin: true
company-name: AUTO_MAA Team
product-name: AUTO_MAA
file-version: ${{ steps.get_version.outputs.main_version }}
product-version: ${{ steps.get_version.outputs.main_version }}
file-description: AUTO_MAA Component
copyright: Copyright © 2024-2025 DLmaster361
assume-yes-for-downloads: true
output-file: AUTO_MAA
output-dir: AUTO_MAA
- name: Upload unsigned main program
id: upload-unsigned-main-program
uses: actions/upload-artifact@v4
with:
name: AUTO_MAA
path: AUTO_MAA/AUTO_MAA.exe
- name: Sign main program
id: sign_main_program
uses: signpath/github-action-submit-signing-request@v1.2
with:
api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
organization-id: '787a1d5f-6177-4f30-9559-d2646473584a'
project-slug: 'AUTO_MAA'
signing-policy-slug: 'release-signing'
artifact-configuration-slug: "AUTO_MAA"
github-artifact-id: '${{ steps.upload-unsigned-main-program.outputs.artifact-id }}'
wait-for-completion: true
output-artifact-directory: 'AUTO_MAA'
- name: Add other resources
shell: pwsh
run: |
$root = "${{ github.workspace }}"
$ver = "${{ steps.get_version.outputs.main_version }}"
Copy-Item "$root/app" "$root/AUTO_MAA/app" -Recurse
Copy-Item "$root/resources" "$root/AUTO_MAA/resources" -Recurse
Copy-Item "$root/Go_Updater" "$root/AUTO_MAA/Go_Updater" -Recurse
Move-Item "$root/AUTO_MAA/Go_Updater/build/AUTO_MAA_Go_Updater.exe" "$root/AUTO_MAA/AUTO_MAA_Go_Updater_install.exe"
Copy-Item "$root/main.py" "$root/AUTO_MAA/"
Copy-Item "$root/requirements.txt" "$root/AUTO_MAA/"
Copy-Item "$root/README.md" "$root/AUTO_MAA/"
Copy-Item "$root/LICENSE" "$root/AUTO_MAA/"
- name: Create Inno Setup script
shell: pwsh
run: |
$root = "${{ github.workspace }}"
$ver = "${{ steps.get_version.outputs.main_version }}"
$iss = Get-Content "$root/app/utils/AUTO_MAA.iss" -Raw
$iss = $iss -replace '#define MyAppVersion ""', "#define MyAppVersion `"$ver`""
$iss = $iss -replace '#define MyAppPath ""', "#define MyAppPath `"$root/AUTO_MAA`""
$iss = $iss -replace '#define OutputDir ""', "#define OutputDir `"$root`""
Set-Content -Path "$root/AUTO_MAA.iss" -Value $iss
- name: Build setup program
uses: Minionguyjpro/Inno-Setup-Action@v1.2.5
with:
path: AUTO_MAA.iss
- name: Upload unsigned setup program
id: upload-unsigned-setup-program
uses: actions/upload-artifact@v4
with:
name: AUTO_MAA-Setup
path: AUTO_MAA-Setup.exe
- name: Sign setup program
id: sign_setup_program
uses: signpath/github-action-submit-signing-request@v1.2
with:
api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
organization-id: '787a1d5f-6177-4f30-9559-d2646473584a'
project-slug: 'AUTO_MAA'
signing-policy-slug: 'release-signing'
artifact-configuration-slug: "AUTO_MAA-Setup"
github-artifact-id: '${{ steps.upload-unsigned-setup-program.outputs.artifact-id }}'
wait-for-completion: true
output-artifact-directory: 'AUTO_MAA_Setup'
- name: Compress setup exe
shell: pwsh
run: Compress-Archive -Path AUTO_MAA_Setup/* -DestinationPath AUTO_MAA_${{ steps.get_version.outputs.main_version }}.zip
- name: Generate version info
shell: python
run: |
import json
from pathlib import Path
def version_text(version_numb):
while len(version_numb) < 4:
version_numb.append(0)
if version_numb[3] == 0:
return f"v{'.'.join(str(_) for _ in version_numb[0:3])}"
else:
return f"v{'.'.join(str(_) for _ in version_numb[0:3])}-beta.{version_numb[3]}"
def version_info_markdown(info):
version_info = ""
for key, value in info.items():
version_info += f"## {key}\n"
for v in value:
version_info += f"- {v}\n"
return version_info
root_path = Path(".")
version = json.loads((root_path / "resources/version.json").read_text(encoding="utf-8"))
main_version_numb = list(map(int, version["main_version"].split(".")))
all_version_info = {}
for v_i in version["version_info"].values():
for key, value in v_i.items():
if key in all_version_info:
all_version_info[key] += value.copy()
else:
all_version_info[key] = value.copy()
(root_path / "version_info.txt").write_text(
f"{version_text(main_version_numb)}\n\n<!--{json.dumps(version['version_info'], ensure_ascii=False)}-->\n{version_info_markdown(all_version_info)}",
encoding="utf-8",
)
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: AUTO_MAA_${{ steps.get_version.outputs.main_version }}
path: AUTO_MAA_${{ steps.get_version.outputs.main_version }}.zip
- name: Upload Version_Info Artifact
uses: actions/upload-artifact@v4
with:
name: version_info
path: version_info.txt
publish_release:
name: Publish release
needs: build_AUTO_MAA
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
pattern: AUTO_MAA_*
merge-multiple: true
path: artifacts
- name: Download Version_Info
uses: actions/download-artifact@v4
with:
name: version_info
path: ./
- name: Create release
id: create_release
run: |
set -xe
shopt -s nullglob
NAME="$(sed 's/\r$//g' <(head -n 1 version_info.txt))"
TAGNAME="$(sed 's/\r$//g' <(head -n 1 version_info.txt))"
NOTES_MAIN="$(sed 's/\r$//g' <(tail -n +3 version_info.txt))"
NOTES="$NOTES_MAIN
## 代码签名策略Code signing policy
Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/)
- 审批人Approvers: [DLmaster (@DLmaster361)](https://github.com/DLmaster361)
## 隐私政策Privacy policy
除非用户、安装者或使用者特别要求,否则本程序不会将任何信息传输到其他网络系统。
This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it.
[已有 Mirror酱 CDK ?前往 Mirror酱 高速下载](https://mirrorchyan.com/zh/projects?rid=AUTO_MAA&source=auto_maa-release)
\`\`\`本release通过GitHub Actions自动构建\`\`\`"
if [ "${{ github.ref_name }}" == "main" ]; then
PRERELEASE_FLAG=""
else
PRERELEASE_FLAG="--prerelease"
fi
gh release create "$TAGNAME" --target "main" --title "$NAME" --notes "$NOTES" $PRERELEASE_FLAG artifacts/*
env:
GITHUB_TOKEN: ${{ secrets.WORKFLOW_TOKEN }}
- name: Trigger MirrorChyanUploading
run: |
gh workflow run --repo $GITHUB_REPOSITORY mirrorchyan
gh workflow run --repo $GITHUB_REPOSITORY mirrorchyan_release_note
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

21
.github/workflows/mirrorchyan.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: mirrorchyan
on:
workflow_dispatch:
jobs:
mirrorchyan:
runs-on: macos-latest
steps:
- id: uploading
uses: MirrorChyan/uploading-action@v1
with:
filetype: latest-release
filename: "AUTO_MAA*.zip"
mirrorchyan_rid: AUTO_MAA
owner: DLmaster361
repo: AUTO_MAA
github_token: ${{ secrets.GITHUB_TOKEN }}
upload_token: ${{ secrets.MirrorChyanUploadToken }}

View File

@@ -0,0 +1,19 @@
name: mirrorchyan_release_note
on:
workflow_dispatch:
release:
types: [edited]
jobs:
mirrorchyan:
runs-on: macos-latest
steps:
- id: uploading
uses: MirrorChyan/release-note-action@v1
with:
mirrorchyan_rid: AUTO_MAA
upload_token: ${{ secrets.MirrorChyanUploadToken }}
github_token: ${{ secrets.GITHUB_TOKEN }}

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
config/
data/
debug/
history/
script/
resources/notice.json
resources/theme_image.json
resources/images/Home/BannerTheme.jpg

Binary file not shown.

View File

@@ -1,74 +0,0 @@
# <AUTO_MAA:A MAA Multi Account Management and Automation Tool>
# Copyright © <2024> <DLmaster361>
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# DLmaster_361@163.com
import sqlite3
import subprocess
import atexit
import datetime
import time
import os
from termcolor import colored
#资源回收
def cleanup():
if os.path.exists("state/BEGIN"):
os.remove("state/BEGIN")
DATABASE="data/data.db"
#设置回调函数
atexit.register(cleanup)
while True:
db=sqlite3.connect(DATABASE)
cur=db.cursor()
cur.execute("SELECT * FROM timeset WHERE True")
timeset=cur.fetchall()
cur.close()
db.close()
timeset=[list(row) for row in timeset]
timeset=[timeset[i][0] for i in range(len(timeset))]
for i in range(60):
#展示当前信息
curtime=datetime.datetime.now().strftime("%H:%M")
os.system('cls')
if len(timeset)!=0:
print(colored("设定时间:"+''.join(timeset),'green'))
print(colored("当前时间:"+curtime,'green'))
print(colored("运行日志:",'green'))
if os.path.exists("state/running"):
print(colored("正在运行代理",'yellow'))
elif os.path.exists("log.txt"):
with open("log.txt",'r',encoding="utf-8") as f:
linex=f.read()
print(colored(linex,'light_green'))
else:
print(colored("暂无",'light_green'))
if (curtime in timeset) and not os.path.exists("state/running"):
with open("state/BEGIN","w",encoding="utf-8") as f:
print("BEGIN",file=f)
maa=subprocess.Popen(["run.exe"])
maapid=maa.pid
while True:
if os.path.exists("state/END"):
os.system('taskkill /F /T /PID '+str(maapid))
os.remove("state/END")
break
os.remove("state/BEGIN")
time.sleep(1)

116
Go_Updater/Makefile Normal file
View File

@@ -0,0 +1,116 @@
# AUTO_MAA_Go_Updater Makefile
# Build variables
VERSION ?= 1.0.0
BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
OUTPUT_NAME := AUTO_MAA_Go_Updater
BUILD_DIR := build
DIST_DIR := dist
# Go build flags
LDFLAGS := -s -w -X AUTO_MAA_Go_Updater/version.Version=$(VERSION) -X AUTO_MAA_Go_Updater/version.BuildTime=$(BUILD_TIME) -X AUTO_MAA_Go_Updater/version.GitCommit=$(GIT_COMMIT)
# Default target
.PHONY: all
all: clean build
# Clean build artifacts
.PHONY: clean
clean:
@echo "Cleaning build artifacts..."
@rm -rf $(BUILD_DIR) $(DIST_DIR)
@mkdir -p $(BUILD_DIR) $(DIST_DIR)
# Build for Windows 64-bit
.PHONY: build
build: clean
@echo "========================================="
@echo "Building AUTO_MAA_Go_Updater"
@echo "========================================="
@echo "Version: $(VERSION)"
@echo "Build Time: $(BUILD_TIME)"
@echo "Git Commit: $(GIT_COMMIT)"
@echo "Target: Windows 64-bit"
@echo ""
@echo "Building application..."
@GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(OUTPUT_NAME).exe .
@echo "Build completed successfully!"
@echo ""
@echo "Build Results:"
@ls -lh $(BUILD_DIR)/$(OUTPUT_NAME).exe
@cp $(BUILD_DIR)/$(OUTPUT_NAME).exe $(DIST_DIR)/$(OUTPUT_NAME).exe
@echo "Copied to: $(DIST_DIR)/$(OUTPUT_NAME).exe"
# Build with UPX compression
.PHONY: build-compressed
build-compressed: build
@echo ""
@echo "Compressing with UPX..."
@if command -v upx >/dev/null 2>&1; then \
upx --best $(BUILD_DIR)/$(OUTPUT_NAME).exe; \
echo "Compression completed!"; \
ls -lh $(BUILD_DIR)/$(OUTPUT_NAME).exe; \
cp $(BUILD_DIR)/$(OUTPUT_NAME).exe $(DIST_DIR)/$(OUTPUT_NAME).exe; \
else \
echo "UPX not found. Skipping compression."; \
fi
# Run tests
.PHONY: test
test:
@echo "Running tests..."
@go test -v ./...
# Run with version flag
.PHONY: version
version: build
@echo ""
@echo "Testing version information:"
@$(BUILD_DIR)/$(OUTPUT_NAME).exe -version
# Install dependencies
.PHONY: deps
deps:
@echo "Installing dependencies..."
@go mod tidy
@go mod download
# Format code
.PHONY: fmt
fmt:
@echo "Formatting code..."
@go fmt ./...
# Lint code
.PHONY: lint
lint:
@echo "Linting code..."
@if command -v golangci-lint >/dev/null 2>&1; then \
golangci-lint run; \
else \
echo "golangci-lint not found. Install it with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
fi
# Development build (faster, no optimizations)
.PHONY: dev
dev:
@echo "Building development version..."
@go build -o $(BUILD_DIR)/$(OUTPUT_NAME)-dev.exe .
@echo "Development build completed: $(BUILD_DIR)/$(OUTPUT_NAME)-dev.exe"
# Help
.PHONY: help
help:
@echo "Available targets:"
@echo " all - Clean and build (default)"
@echo " build - Build for Windows 64-bit"
@echo " build-compressed - Build and compress with UPX"
@echo " clean - Clean build artifacts"
@echo " test - Run tests"
@echo " version - Build and show version"
@echo " deps - Install dependencies"
@echo " fmt - Format code"
@echo " lint - Lint code"
@echo " dev - Development build"
@echo " help - Show this help"

15
Go_Updater/README.MD Normal file
View File

@@ -0,0 +1,15 @@
# 用Go语言实现的一个AUTO_MAA下载器
用于直接下载AUTO_MAA软件本体在Python版本出现问题时使用。
## 使用方法
1. 下载并安装Go语言环境需要配置环境变量
2. 运行 `go mod tidy` 命令,安装依赖包。
3. 运行 `go run main.go` 命令程序会自动下载并安装AUTO_MAA软件。
## 构建
运行 `.\build.ps1` 脚本即可完成构建。
参数说明:
-Version指定要构建的版本号
运行命令: `.\build.ps1 -Version "1.0.8"`

312
Go_Updater/api/client.go Normal file
View File

@@ -0,0 +1,312 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// MirrorResponse 表示 MirrorChyan API 的响应结构
type MirrorResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
VersionName string `json:"version_name"`
VersionNumber int `json:"version_number"`
URL string `json:"url,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Channel string `json:"channel"`
OS string `json:"os"`
Arch string `json:"arch"`
UpdateType string `json:"update_type,omitempty"`
ReleaseNote string `json:"release_note"`
FileSize int64 `json:"filesize,omitempty"`
} `json:"data"`
}
// UpdateCheckParams 表示更新检查的参数
type UpdateCheckParams struct {
ResourceID string
CurrentVersion string
Channel string
UserAgent string
}
// MirrorClient 定义 Mirror API 客户端的接口方法
type MirrorClient interface {
CheckUpdate(params UpdateCheckParams) (*MirrorResponse, error)
IsUpdateAvailable(response *MirrorResponse, currentVersion string) bool
GetDownloadURL(versionName string) string
}
// Client 实现 MirrorClient 接口
type Client struct {
httpClient *http.Client
baseURL string
downloadURL string
}
// NewClient 创建新的 Mirror API 客户端
func NewClient() *Client {
return &Client{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: "https://mirrorchyan.com/api/resources",
downloadURL: "http://221.236.27.82:10197/d/AUTO_MAA",
}
}
// CheckUpdate 调用 MirrorChyan API 检查更新
func (c *Client) CheckUpdate(params UpdateCheckParams) (*MirrorResponse, error) {
// 构建 API URL
apiURL := fmt.Sprintf("%s/%s/latest", c.baseURL, params.ResourceID)
// 解析 URL 并添加查询参数
u, err := url.Parse(apiURL)
if err != nil {
return nil, fmt.Errorf("解析 API URL 失败: %w", err)
}
// 添加查询参数
q := u.Query()
q.Set("current_version", params.CurrentVersion)
q.Set("channel", params.Channel)
q.Set("os", "") // 跨平台为空
q.Set("arch", "") // 跨平台为空
u.RawQuery = q.Encode()
// 创建 HTTP 请求
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err)
}
// 设置 User-Agent 头
if params.UserAgent != "" {
req.Header.Set("User-Agent", params.UserAgent)
} else {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36")
}
// 发送 HTTP 请求
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("发送 HTTP 请求失败: %w", err)
}
defer resp.Body.Close()
// 检查 HTTP 状态码
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API 返回非 200 状态码: %d", resp.StatusCode)
}
// 读取响应体
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应体失败: %w", err)
}
// 解析 JSON 响应
var mirrorResp MirrorResponse
if err := json.Unmarshal(body, &mirrorResp); err != nil {
return nil, fmt.Errorf("解析 JSON 响应失败: %w", err)
}
return &mirrorResp, nil
}
// IsUpdateAvailable 比较当前版本与 API 响应中的最新版本
func (c *Client) IsUpdateAvailable(response *MirrorResponse, currentVersion string) bool {
// 检查 API 响应是否成功
if response.Code != 0 {
return false
}
// 从响应中获取最新版本
latestVersion := response.Data.VersionName
if latestVersion == "" {
return false
}
// 转换版本格式以便比较
currentVersionNormalized := c.normalizeVersionForComparison(currentVersion)
latestVersionNormalized := c.normalizeVersionForComparison(latestVersion)
// 调试输出
// fmt.Printf("Current: %s -> %s\n", currentVersion, currentVersionNormalized)
// fmt.Printf("Latest: %s -> %s\n", latestVersion, latestVersionNormalized)
// fmt.Printf("Compare result: %d\n", compareVersions(currentVersionNormalized, latestVersionNormalized))
// 使用语义版本比较
return compareVersions(currentVersionNormalized, latestVersionNormalized) < 0
}
// normalizeVersionForComparison 将不同版本格式转换为可比较格式
func (c *Client) normalizeVersionForComparison(version string) string {
// 处理 AUTO_MAA 版本格式: "4.4.1.3" -> "v4.4.1-beta3"
if !strings.HasPrefix(version, "v") && strings.Count(version, ".") == 3 {
parts := strings.Split(version, ".")
if len(parts) == 4 {
major, minor, patch, beta := parts[0], parts[1], parts[2], parts[3]
if beta == "0" {
return fmt.Sprintf("v%s.%s.%s", major, minor, patch)
} else {
return fmt.Sprintf("v%s.%s.%s-beta%s", major, minor, patch, beta)
}
}
}
// 如果已经是标准格式则直接返回
return version
}
// compareVersions 比较两个语义版本字符串
// 返回值: -1 如果 v1 < v2, 0 如果 v1 == v2, 1 如果 v1 > v2
func compareVersions(v1, v2 string) int {
// 通过移除 'v' 前缀来标准化版本
v1 = normalizeVersion(v1)
v2 = normalizeVersion(v2)
// 解析版本组件
parts1 := parseVersionParts(v1)
parts2 := parseVersionParts(v2)
// 比较前三个组件 (major.minor.patch)
for i := 0; i < 3; i++ {
var p1, p2 int
if i < len(parts1) {
p1 = parts1[i]
}
if i < len(parts2) {
p2 = parts2[i]
}
if p1 < p2 {
return -1
} else if p1 > p2 {
return 1
}
}
// 如果前三个组件相同比较beta版本号
var beta1, beta2 int
if len(parts1) > 3 {
beta1 = parts1[3]
}
if len(parts2) > 3 {
beta2 = parts2[3]
}
// 特殊处理beta版本比较
// - 如果一个是正式版(beta=0)另一个是beta版(beta>0),正式版更新
// - 如果都是beta版比较beta版本号
if beta1 == 0 && beta2 > 0 {
return 1 // 正式版比beta版更新
}
if beta1 > 0 && beta2 == 0 {
return -1 // beta版比正式版旧
}
// 都是正式版或都是beta版直接比较
if beta1 < beta2 {
return -1
} else if beta1 > beta2 {
return 1
}
return 0
}
// normalizeVersion 移除 'v' 前缀并处理常见版本格式
func normalizeVersion(version string) string {
if len(version) > 0 && (version[0] == 'v' || version[0] == 'V') {
return version[1:]
}
return version
}
// parseVersionParts 将版本字符串解析为数字组件包括beta版本号
func parseVersionParts(version string) []int {
if version == "" {
return []int{0}
}
parts := make([]int, 0, 4)
current := 0
// 先检查是否包含 -beta
betaIndex := strings.Index(version, "-beta")
var mainVersion, betaVersion string
if betaIndex != -1 {
mainVersion = version[:betaIndex]
betaVersion = version[betaIndex+5:] // 跳过 "-beta"
} else {
mainVersion = version
betaVersion = ""
}
// 解析主版本号 (major.minor.patch)
for _, char := range mainVersion {
if char >= '0' && char <= '9' {
current = current*10 + int(char-'0')
} else if char == '.' {
parts = append(parts, current)
current = 0
} else {
// 遇到非数字非点字符,停止解析
break
}
}
// 添加最后一个主版本组件
parts = append(parts, current)
// 确保至少有 3 个组件 (major.minor.patch)
for len(parts) < 3 {
parts = append(parts, 0)
}
// 解析beta版本号
if betaVersion != "" {
// 跳过可能的点号
if strings.HasPrefix(betaVersion, ".") {
betaVersion = betaVersion[1:]
}
betaNum := 0
for _, char := range betaVersion {
if char >= '0' && char <= '9' {
betaNum = betaNum*10 + int(char-'0')
} else {
break
}
}
// Beta版本号保持正数但在比较时会特殊处理
parts = append(parts, betaNum)
} else {
// 非beta版本添加0作为beta版本号
parts = append(parts, 0)
}
return parts
}
// GetDownloadURL 根据版本名生成下载站的下载 URL
func (c *Client) GetDownloadURL(versionName string) string {
// 将版本名转换为文件名格式
// 例如: "v4.4.0" -> "AUTO_MAA_v4.4.0.zip"
// 例如: "v4.4.1-beta3" -> "AUTO_MAA_v4.4.1-beta.3.zip"
filename := fmt.Sprintf("AUTO_MAA_%s.zip", versionName)
// 处理 beta 版本: 将 "beta3" 转换为 "beta.3"
if strings.Contains(filename, "-beta") && !strings.Contains(filename, "-beta.") {
filename = strings.Replace(filename, "-beta", "-beta.", 1)
}
return fmt.Sprintf("%s/%s", c.downloadURL, filename)
}

View File

@@ -0,0 +1,186 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestNewClient(t *testing.T) {
client := NewClient()
if client == nil {
t.Fatal("NewClient() 返回 nil")
}
if client.httpClient == nil {
t.Fatal("HTTP 客户端为 nil")
}
if client.baseURL != "https://mirrorchyan.com/api/resources" {
t.Errorf("期望基础 URL 'https://mirrorchyan.com/api/resources',得到 '%s'", client.baseURL)
}
if client.downloadURL != "http://221.236.27.82:10197/d/AUTO_MAA" {
t.Errorf("期望下载 URL 'http://221.236.27.82:10197/d/AUTO_MAA',得到 '%s'", client.downloadURL)
}
}
func TestGetDownloadURL(t *testing.T) {
client := NewClient()
tests := []struct {
versionName string
expected string
}{
{"v4.4.0", "http://221.236.27.82:10197/d/AUTO_MAA/AUTO_MAA_v4.4.0.zip"},
{"v4.4.1-beta3", "http://221.236.27.82:10197/d/AUTO_MAA/AUTO_MAA_v4.4.1-beta.3.zip"},
{"v1.2.3", "http://221.236.27.82:10197/d/AUTO_MAA/AUTO_MAA_v1.2.3.zip"},
}
for _, test := range tests {
result := client.GetDownloadURL(test.versionName)
if result != test.expected {
t.Errorf("版本 %s期望 %s得到 %s", test.versionName, test.expected, result)
}
}
}
func TestCheckUpdate(t *testing.T) {
// 创建测试服务器
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := MirrorResponse{
Code: 0,
Msg: "success",
Data: struct {
VersionName string `json:"version_name"`
VersionNumber int `json:"version_number"`
URL string `json:"url,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Channel string `json:"channel"`
OS string `json:"os"`
Arch string `json:"arch"`
UpdateType string `json:"update_type,omitempty"`
ReleaseNote string `json:"release_note"`
FileSize int64 `json:"filesize,omitempty"`
}{
VersionName: "v4.4.1",
VersionNumber: 48,
Channel: "stable",
ReleaseNote: "测试发布说明",
},
}
w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(response)
if err != nil {
return
}
}))
defer server.Close()
// 使用测试服务器 URL 创建客户端
client := &Client{
httpClient: &http.Client{},
baseURL: server.URL,
downloadURL: "http://221.236.27.82:10197/d/AUTO_MAA",
}
// 测试更新检查
params := UpdateCheckParams{
ResourceID: "AUTO_MAA",
CurrentVersion: "4.4.0.0",
Channel: "stable",
UserAgent: "TestAgent/1.0",
}
response, err := client.CheckUpdate(params)
if err != nil {
t.Fatalf("CheckUpdate 失败: %v", err)
}
if response.Code != 0 {
t.Errorf("期望代码 0得到 %d", response.Code)
}
if response.Data.VersionName != "v4.4.1" {
t.Errorf("期望版本 v4.4.1,得到 %s", response.Data.VersionName)
}
}
func TestIsUpdateAvailable(t *testing.T) {
client := NewClient()
tests := []struct {
name string
response *MirrorResponse
currentVersion string
expected bool
}{
{
name: "有可用更新",
response: &MirrorResponse{
Code: 0,
Data: struct {
VersionName string `json:"version_name"`
VersionNumber int `json:"version_number"`
URL string `json:"url,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Channel string `json:"channel"`
OS string `json:"os"`
Arch string `json:"arch"`
UpdateType string `json:"update_type,omitempty"`
ReleaseNote string `json:"release_note"`
FileSize int64 `json:"filesize,omitempty"`
}{VersionName: "v4.4.1"},
},
currentVersion: "4.4.0.0",
expected: true,
},
{
name: "无可用更新",
response: &MirrorResponse{
Code: 0,
Data: struct {
VersionName string `json:"version_name"`
VersionNumber int `json:"version_number"`
URL string `json:"url,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Channel string `json:"channel"`
OS string `json:"os"`
Arch string `json:"arch"`
UpdateType string `json:"update_type,omitempty"`
ReleaseNote string `json:"release_note"`
FileSize int64 `json:"filesize,omitempty"`
}{VersionName: "v4.4.0"},
},
currentVersion: "4.4.0.0",
expected: false,
},
{
name: "beta版本有更新",
response: &MirrorResponse{
Code: 0,
Data: struct {
VersionName string `json:"version_name"`
VersionNumber int `json:"version_number"`
URL string `json:"url,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Channel string `json:"channel"`
OS string `json:"os"`
Arch string `json:"arch"`
UpdateType string `json:"update_type,omitempty"`
ReleaseNote string `json:"release_note"`
FileSize int64 `json:"filesize,omitempty"`
}{VersionName: "v4.4.1-beta.4"},
},
currentVersion: "4.4.1.3",
expected: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := client.IsUpdateAvailable(test.response, test.currentVersion)
if result != test.expected {
t.Errorf("期望 %t得到 %t", test.expected, result)
}
})
}
}

34
Go_Updater/app.rc Normal file
View File

@@ -0,0 +1,34 @@
#include <windows.h>
// Application icon
IDI_ICON1 ICON "icon/AUTO_MAA_Go_Updater.ico"
// Version information
VS_VERSION_INFO VERSIONINFO
FILEVERSION 1,0,0,0
PRODUCTVERSION 1,0,0,0
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
FILEFLAGS 0x0L
FILEOS VOS__WINDOWS32
FILETYPE VFT_APP
FILESUBTYPE VFT2_UNKNOWN
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904B0"
BEGIN
VALUE "CompanyName", "AUTO MAA Team"
VALUE "FileDescription", "AUTO MAA Go Updater"
VALUE "FileVersion", "1.0.0.0"
VALUE "InternalName", "AUTO_MAA_Go_Updater"
VALUE "LegalCopyright", "Copyright (C) 2025"
VALUE "OriginalFilename", "AUTO_MAA_Go_Updater.exe"
VALUE "ProductName", "AUTO MAA Go Updater"
VALUE "ProductVersion", "1.0.0.0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END

BIN
Go_Updater/app.syso Normal file

Binary file not shown.

View File

@@ -0,0 +1,34 @@
package assets
import (
"embed"
"io/fs"
)
//go:embed config_template.yaml
var EmbeddedAssets embed.FS
// GetConfigTemplate 返回嵌入的配置模板
func GetConfigTemplate() ([]byte, error) {
return EmbeddedAssets.ReadFile("config_template.yaml")
}
// GetAssetFS 返回嵌入的文件系统
func GetAssetFS() fs.FS {
return EmbeddedAssets
}
// ListAssets 返回所有嵌入资源的列表
func ListAssets() ([]string, error) {
var assets []string
err := fs.WalkDir(EmbeddedAssets, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
assets = append(assets, path)
}
return nil
})
return assets, err
}

View File

@@ -0,0 +1,100 @@
package assets
import (
"testing"
)
func TestGetConfigTemplate(t *testing.T) {
data, err := GetConfigTemplate()
if err != nil {
t.Fatalf("Failed to get config template: %v", err)
}
if len(data) == 0 {
t.Fatal("Config template is empty")
}
// Check that it contains expected content
content := string(data)
if !contains(content, "resource_id") {
t.Error("Config template should contain 'resource_id'")
}
if !contains(content, "current_version") {
t.Error("Config template should contain 'current_version'")
}
if !contains(content, "user_agent") {
t.Error("Config template should contain 'user_agent'")
}
}
func TestListAssets(t *testing.T) {
assets, err := ListAssets()
if err != nil {
t.Fatalf("Failed to list assets: %v", err)
}
if len(assets) == 0 {
t.Fatal("No assets found")
}
// Check that config template is in the list
found := false
for _, asset := range assets {
if asset == "config_template.yaml" {
found = true
break
}
}
if !found {
t.Error("config_template.yaml should be in the assets list")
}
}
func TestGetAssetFS(t *testing.T) {
fs := GetAssetFS()
if fs == nil {
t.Fatal("Asset filesystem should not be nil")
}
// Try to open the config template
file, err := fs.Open("config_template.yaml")
if err != nil {
t.Fatalf("Failed to open config template from filesystem: %v", err)
}
defer file.Close()
// Check that we can read from it
buffer := make([]byte, 100)
n, err := file.Read(buffer)
if err != nil && err.Error() != "EOF" {
t.Fatalf("Failed to read from config template: %v", err)
}
if n == 0 {
t.Fatal("Config template appears to be empty")
}
}
// Helper function to check if string contains substring
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > len(substr) && (s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
containsAt(s, substr, 1))))
}
func containsAt(s, substr string, start int) bool {
if start >= len(s) {
return false
}
if start+len(substr) > len(s) {
return containsAt(s, substr, start+1)
}
if s[start:start+len(substr)] == substr {
return true
}
return containsAt(s, substr, start+1)
}

View File

@@ -0,0 +1,7 @@
resource_id: "AUTO_MAA"
current_version: "v1.0.0"
user_agent: "AUTO_MAA_Go_Updater/1.0"
backup_url: "https://backup-download-site.com/releases"
log_level: "info"
auto_check: true
check_interval: 3600 # seconds

View File

@@ -0,0 +1,55 @@
# Build Configuration for AUTO_MAA_Go_Updater
project:
name: "AUTO_MAA_Go_Updater"
module: "AUTO_MAA_Go_Updater"
description: "AUTO_MAA_Go版本更新器"
version:
default: "1.0.0"
build_time_format: "2006-01-02T15:04:05Z"
targets:
- name: "windows-amd64"
goos: "windows"
goarch: "amd64"
cgo_enabled: true
output: "AUTO_MAA_Go_Updater.exe"
build:
flags:
ldflags: "-s -w"
tags: []
optimization:
strip_debug: true
strip_symbols: true
upx_compression: false # Optional, requires UPX
size_requirements:
max_size_mb: 10
warn_size_mb: 8
assets:
embed:
- "assets/config_template.yaml"
directories:
build: "build"
dist: "dist"
temp: "temp"
version_injection:
package: "AUTO_MAA_Go_Updater/version"
variables:
- name: "Version"
source: "version"
- name: "BuildTime"
source: "build_time"
- name: "GitCommit"
source: "git_commit"
quality:
run_tests: true
run_lint: false # Optional
format_code: true

93
Go_Updater/build.bat Normal file
View File

@@ -0,0 +1,93 @@
@echo off
setlocal enabledelayedexpansion
echo ========================================
echo AUTO_MAA_Go_Updater Build Script
echo ========================================
:: Set build variables
set OUTPUT_NAME=AUTO_MAA_Go_Updater.exe
set BUILD_DIR=build
set DIST_DIR=dist
:: Get current datetime for build time
for /f "tokens=2 delims==" %%a in ('wmic OS Get localdatetime /value') do set "dt=%%a"
set "YYYY=%dt:~0,4%" & set "MM=%dt:~4,2%" & set "DD=%dt:~6,2%"
set "HH=%dt:~8,2%" & set "Min=%dt:~10,2%" & set "Sec=%dt:~12,2%"
set "BUILD_TIME=%YYYY%-%MM%-%DD%T%HH%:%Min%:%Sec%Z"
:: Get git commit hash (if available)
git rev-parse --short HEAD > temp_commit.txt 2>nul
if exist temp_commit.txt (
set /p GIT_COMMIT=<temp_commit.txt
del temp_commit.txt
) else (
set GIT_COMMIT=unknown
)
:: Use commit hash as version
set VERSION=%GIT_COMMIT%
echo Build Information:
echo - Version: %VERSION%
echo - Build Time: %BUILD_TIME%
echo - Git Commit: %GIT_COMMIT%
echo - Target: Windows 64-bit
echo.
:: Create build directories
if not exist %BUILD_DIR% mkdir %BUILD_DIR%
if not exist %DIST_DIR% mkdir %DIST_DIR%
:: Set build flags
set LDFLAGS=-s -w -X AUTO_MAA_Go_Updater/version.Version=%VERSION% -X AUTO_MAA_Go_Updater/version.BuildTime=%BUILD_TIME% -X AUTO_MAA_Go_Updater/version.GitCommit=%GIT_COMMIT%
echo Building application...
:: Ensure icon resource is compiled
if not exist app.syso (
echo Compiling icon resource...
where rsrc >nul 2>&1
if !ERRORLEVEL! equ 0 (
rsrc -ico icon\AUTO_MAA_Go_Updater.ico -o app.syso
if !ERRORLEVEL! equ 0 (
echo Icon resource compiled successfully
) else (
echo Warning: Failed to compile icon resource
)
) else (
echo Warning: rsrc not found. Install with: go install github.com/akavel/rsrc@latest
)
)
:: Set environment variables for Go build
set GOOS=windows
set GOARCH=amd64
set CGO_ENABLED=1
:: Build the application
go build -ldflags="%LDFLAGS%" -o %BUILD_DIR%\%OUTPUT_NAME% .
if %ERRORLEVEL% neq 0 (
echo Build failed!
exit /b 1
)
echo Build completed successfully!
:: Get file size
for %%A in (%BUILD_DIR%\%OUTPUT_NAME%) do set FILE_SIZE=%%~zA
set /a FILE_SIZE_MB=%FILE_SIZE%/1024/1024
echo.
echo Build Results:
echo - Output: %BUILD_DIR%\%OUTPUT_NAME%
echo - Size: %FILE_SIZE% bytes (~%FILE_SIZE_MB% MB)
:: Copy to dist directory
copy %BUILD_DIR%\%OUTPUT_NAME% %DIST_DIR%\%OUTPUT_NAME% >nul
echo - Copied to: %DIST_DIR%\%OUTPUT_NAME%
echo.
echo Build script completed successfully!
echo ========================================

105
Go_Updater/build.ps1 Normal file
View File

@@ -0,0 +1,105 @@
# AUTO_MAA_Go_Updater Build Script (PowerShell)
param(
[string]$OutputName = "AUTO_MAA_Go_Updater.exe",
[switch]$Compress = $false
)
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "AUTO_MAA_Go_Updater Build Script" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
# Set build variables
$BuildDir = "build"
$DistDir = "dist"
$BuildTime = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ")
# Get git commit hash
try {
$GitCommit = (git rev-parse --short HEAD 2>$null).Trim()
if (-not $GitCommit) { $GitCommit = "unknown" }
} catch {
$GitCommit = "unknown"
}
Write-Host "Build Information:" -ForegroundColor Yellow
Write-Host "- Version: $GitCommit"
Write-Host "- Build Time: $BuildTime"
Write-Host "- Git Commit: $GitCommit"
Write-Host "- Target: Windows 64-bit"
Write-Host ""
# Create build directories
if (-not (Test-Path $BuildDir)) { New-Item -ItemType Directory -Path $BuildDir | Out-Null }
if (-not (Test-Path $DistDir)) { New-Item -ItemType Directory -Path $DistDir | Out-Null }
# Set environment variables
$env:GOOS = "windows"
$env:GOARCH = "amd64"
$env:CGO_ENABLED = "1"
# Set build flags
$LdFlags = "-s -w -X AUTO_MAA_Go_Updater/version.Version=$Version -X AUTO_MAA_Go_Updater/version.BuildTime=$BuildTime -X AUTO_MAA_Go_Updater/version.GitCommit=$GitCommit"
Write-Host "Building application..." -ForegroundColor Green
# Ensure icon resource is compiled
if (-not (Test-Path "app.syso")) {
Write-Host "Compiling icon resource..." -ForegroundColor Yellow
if (Get-Command rsrc -ErrorAction SilentlyContinue) {
rsrc -ico icon/AUTO_MAA_Go_Updater.ico -o app.syso
if ($LASTEXITCODE -ne 0) {
Write-Host "Warning: Failed to compile icon resource" -ForegroundColor Yellow
} else {
Write-Host "Icon resource compiled successfully" -ForegroundColor Green
}
} else {
Write-Host "Warning: rsrc not found. Install with: go install github.com/akavel/rsrc@latest" -ForegroundColor Yellow
}
}
# Build the application
$BuildCommand = "go build -ldflags=`"$LdFlags`" -o $BuildDir\$OutputName ."
Invoke-Expression $BuildCommand
if ($LASTEXITCODE -ne 0) {
Write-Host "Build failed!" -ForegroundColor Red
exit 1
}
Write-Host "Build completed successfully!" -ForegroundColor Green
# Get file information
$OutputFile = Get-Item "$BuildDir\$OutputName"
$FileSizeMB = [math]::Round($OutputFile.Length / 1MB, 2)
Write-Host ""
Write-Host "Build Results:" -ForegroundColor Yellow
Write-Host "- Output: $($OutputFile.FullName)"
Write-Host "- Size: $($OutputFile.Length) bytes (~$FileSizeMB MB)"
# Optional UPX compression
if ($Compress) {
Write-Host ""
Write-Host "Compressing with UPX..." -ForegroundColor Yellow
if (Get-Command upx -ErrorAction SilentlyContinue) {
upx --best "$BuildDir\$OutputName"
$CompressedFile = Get-Item "$BuildDir\$OutputName"
$CompressedSizeMB = [math]::Round($CompressedFile.Length / 1MB, 2)
Write-Host "- Compressed Size: $($CompressedFile.Length) bytes (~$CompressedSizeMB MB)" -ForegroundColor Green
} else {
Write-Host "UPX not found. Skipping compression." -ForegroundColor Yellow
}
}
# Copy to dist directory
Copy-Item "$BuildDir\$OutputName" "$DistDir\$OutputName" -Force
Write-Host "- Copied to: $DistDir\$OutputName"
Write-Host ""
Write-Host "Build script completed successfully!" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan

198
Go_Updater/config/config.go Normal file
View File

@@ -0,0 +1,198 @@
package config
import (
"fmt"
"os"
"path/filepath"
"AUTO_MAA_Go_Updater/assets"
"gopkg.in/yaml.v3"
)
// Config 表示应用程序配置
type Config struct {
ResourceID string `yaml:"resource_id"`
CurrentVersion string `yaml:"current_version"`
UserAgent string `yaml:"user_agent"`
BackupURL string `yaml:"backup_url"`
LogLevel string `yaml:"log_level"`
AutoCheck bool `yaml:"auto_check"`
CheckInterval int `yaml:"check_interval"` // 秒
}
// ConfigManager 定义配置管理的接口方法
type ConfigManager interface {
Load() (*Config, error)
Save(config *Config) error
GetConfigPath() string
}
// DefaultConfigManager 实现 ConfigManager 接口
type DefaultConfigManager struct {
configPath string
}
// NewConfigManager 创建新的配置管理器
func NewConfigManager() ConfigManager {
configDir := getConfigDir()
configPath := filepath.Join(configDir, "config.yaml")
return &DefaultConfigManager{
configPath: configPath,
}
}
// GetConfigPath 返回配置文件的路径
func (cm *DefaultConfigManager) GetConfigPath() string {
return cm.configPath
}
// Load 读取并解析配置文件
func (cm *DefaultConfigManager) Load() (*Config, error) {
// 如果配置目录不存在则创建
configDir := filepath.Dir(cm.configPath)
if err := os.MkdirAll(configDir, 0755); err != nil {
return nil, fmt.Errorf("创建配置目录失败: %w", err)
}
// 如果配置文件不存在,创建默认配置
if _, err := os.Stat(cm.configPath); os.IsNotExist(err) {
defaultConfig := getDefaultConfig()
if err := cm.Save(defaultConfig); err != nil {
return nil, fmt.Errorf("创建默认配置失败: %w", err)
}
return defaultConfig, nil
}
// 读取现有配置文件
data, err := os.ReadFile(cm.configPath)
if err != nil {
return nil, fmt.Errorf("读取配置文件失败: %w", err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
// 验证并应用缺失字段的默认值
if err := validateAndApplyDefaults(&config); err != nil {
return nil, fmt.Errorf("配置验证失败: %w", err)
}
return &config, nil
}
// Save 将配置写入文件
func (cm *DefaultConfigManager) Save(config *Config) error {
// 保存前验证配置
if err := validateConfig(config); err != nil {
return fmt.Errorf("配置验证失败: %w", err)
}
// 如果配置目录不存在则创建
configDir := filepath.Dir(cm.configPath)
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("创建配置目录失败: %w", err)
}
// 将配置序列化为 YAML
data, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("序列化配置失败: %w", err)
}
// 写入文件
if err := os.WriteFile(cm.configPath, data, 0644); err != nil {
return fmt.Errorf("写入配置文件失败: %w", err)
}
return nil
}
// getDefaultConfig 返回带有默认值的配置
func getDefaultConfig() *Config {
// 首先尝试从嵌入模板加载
if templateData, err := assets.GetConfigTemplate(); err == nil {
var config Config
if err := yaml.Unmarshal(templateData, &config); err == nil {
return &config
}
}
// 如果模板加载失败则回退到硬编码默认值
return &Config{
ResourceID: "M9A", // 默认资源 ID
CurrentVersion: "v1.0.0",
UserAgent: "AUTO_MAA_Go_Updater/1.0",
BackupURL: "",
LogLevel: "info",
AutoCheck: true,
CheckInterval: 3600, // 1 小时
}
}
// validateConfig 验证配置值
func validateConfig(config *Config) error {
if config == nil {
return fmt.Errorf("配置不能为空")
}
if config.ResourceID == "" {
return fmt.Errorf("resource_id 不能为空")
}
if config.CurrentVersion == "" {
return fmt.Errorf("current_version 不能为空")
}
if config.UserAgent == "" {
return fmt.Errorf("user_agent 不能为空")
}
validLogLevels := map[string]bool{
"debug": true,
"info": true,
"warn": true,
"error": true,
}
if !validLogLevels[config.LogLevel] {
return fmt.Errorf("无效的 log_level: %s (必须是 debug, info, warn 或 error)", config.LogLevel)
}
if config.CheckInterval < 60 {
return fmt.Errorf("check_interval 必须至少为 60 秒")
}
return nil
}
// validateAndApplyDefaults 验证配置并为缺失字段应用默认值
func validateAndApplyDefaults(config *Config) error {
defaults := getDefaultConfig()
// 为空字段应用默认值
if config.UserAgent == "" {
config.UserAgent = defaults.UserAgent
}
if config.LogLevel == "" {
config.LogLevel = defaults.LogLevel
}
if config.CheckInterval == 0 {
config.CheckInterval = defaults.CheckInterval
}
if config.CurrentVersion == "" {
config.CurrentVersion = defaults.CurrentVersion
}
// 应用默认值后进行验证
return validateConfig(config)
}
// getConfigDir 返回配置目录路径
func getConfigDir() string {
// 在 Windows 上使用 APPDATA回退到当前目录
if appData := os.Getenv("APPDATA"); appData != "" {
return filepath.Join(appData, "AUTO_MAA_Go_Updater")
}
return "."
}

View File

@@ -0,0 +1,55 @@
{
"Function": {
"BossKey": "",
"HistoryRetentionTime": 0,
"HomeImageMode": "默认",
"IfAgreeBilibili": true,
"IfAllowSleep": false,
"IfSilence": false,
"IfSkipMumuSplashAds": false,
"UnattendedMode": false
},
"Notify": {
"AuthorizationCode": "",
"CompanyWebHookBotUrl": "",
"FromAddress": "",
"IfCompanyWebHookBot": false,
"IfPushPlyer": false,
"IfSendMail": false,
"IfSendSixStar": false,
"IfSendStatistic": false,
"IfServerChan": false,
"SMTPServerAddress": "",
"SendTaskResultTime": "不推送",
"ServerChanChannel": "",
"ServerChanKey": "",
"ServerChanTag": "",
"ToAddress": ""
},
"Start": {
"IfMinimizeDirectly": false,
"IfRunDirectly": false,
"IfSelfStart": false
},
"QFluentWidgets": {
"ThemeColor": "#ff009faa",
"ThemeMode": "Dark"
},
"UI": {
"IfShowTray": false,
"IfToTray": false,
"location": "100x100",
"maximized": false,
"size": "1200x700"
},
"Update": {
"IfAutoUpdate": false,
"ProxyUrlList": [],
"ThreadNumb": 8,
"UpdateType": "stable"
},
"Voice": {
"Enabled": false,
"Type": "simple"
}
}

View File

@@ -0,0 +1,153 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestConfigManagerLoadSave(t *testing.T) {
// 为测试创建临时目录
tempDir := t.TempDir()
// 使用临时路径创建配置管理器
cm := &DefaultConfigManager{
configPath: filepath.Join(tempDir, "test-config.yaml"),
}
// 测试加载不存在的配置(应创建默认配置)
config, err := cm.Load()
if err != nil {
t.Errorf("加载配置失败: %v", err)
}
if config == nil {
t.Errorf("配置不应为 nil")
}
// 验证默认值
if config.CurrentVersion != "v1.0.0" {
t.Errorf("期望默认版本 v1.0.0,得到 %s", config.CurrentVersion)
}
if config.UserAgent != "AUTO_MAA_Go_Updater/1.0" {
t.Errorf("期望默认用户代理,得到 %s", config.UserAgent)
}
// 设置一些值
config.ResourceID = "TEST123"
// 保存配置
err = cm.Save(config)
if err != nil {
t.Errorf("保存配置失败: %v", err)
}
// 再次加载配置
loadedConfig, err := cm.Load()
if err != nil {
t.Errorf("加载已保存配置失败: %v", err)
}
// 验证值
if loadedConfig.ResourceID != "TEST123" {
t.Errorf("期望 ResourceID TEST123得到 %s", loadedConfig.ResourceID)
}
}
func TestConfigValidation(t *testing.T) {
tests := []struct {
name string
config *Config
expectError bool
}{
{
name: "空配置",
config: nil,
expectError: true,
},
{
name: "空 ResourceID",
config: &Config{
ResourceID: "",
CurrentVersion: "v1.0.0",
UserAgent: "Test/1.0",
LogLevel: "info",
CheckInterval: 3600,
},
expectError: true,
},
{
name: "有效配置",
config: &Config{
ResourceID: "TEST",
CurrentVersion: "v1.0.0",
UserAgent: "Test/1.0",
LogLevel: "info",
CheckInterval: 3600,
},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateConfig(tt.config)
if tt.expectError && err == nil {
t.Errorf("期望错误但没有得到")
}
if !tt.expectError && err != nil {
t.Errorf("期望无错误但得到: %v", err)
}
})
}
}
func TestGetDefaultConfig(t *testing.T) {
config := getDefaultConfig()
if config == nil {
t.Fatal("getDefaultConfig() 返回 nil")
}
// 验证默认值
if config.ResourceID != "AUTO_MAA" {
t.Errorf("期望 ResourceID 'AUTO_MAA',得到 %s", config.ResourceID)
}
if config.CurrentVersion != "v1.0.0" {
t.Errorf("期望 CurrentVersion 'v1.0.0',得到 %s", config.CurrentVersion)
}
if config.UserAgent != "AUTO_MAA_Go_Updater/1.0" {
t.Errorf("期望 UserAgent 'AUTO_MAA_Go_Updater/1.0',得到 %s", config.UserAgent)
}
if config.LogLevel != "info" {
t.Errorf("期望 LogLevel 'info',得到 %s", config.LogLevel)
}
if config.CheckInterval != 3600 {
t.Errorf("期望 CheckInterval 3600得到 %d", config.CheckInterval)
}
if !config.AutoCheck {
t.Errorf("期望 AutoCheck true得到 %v", config.AutoCheck)
}
}
func TestGetConfigDir(t *testing.T) {
// 保存原始 APPDATA
originalAppData := os.Getenv("APPDATA")
defer os.Setenv("APPDATA", originalAppData)
// 测试设置了 APPDATA
os.Setenv("APPDATA", "C:\\Users\\Test\\AppData\\Roaming")
dir := getConfigDir()
expected := "C:\\Users\\Test\\AppData\\Roaming\\AUTO_MAA_Go_Updater"
if dir != expected {
t.Errorf("期望 %s得到 %s", expected, dir)
}
// 测试没有 APPDATA
os.Unsetenv("APPDATA")
dir = getConfigDir()
if dir != "." {
t.Errorf("期望当前目录,得到 %s", dir)
}
}

View File

@@ -0,0 +1,224 @@
package download
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
)
// DownloadProgress 表示当前下载进度
type DownloadProgress struct {
BytesDownloaded int64
TotalBytes int64
Percentage float64
Speed int64 // 每秒字节数
}
// ProgressCallback 在下载过程中调用以报告进度
type ProgressCallback func(DownloadProgress)
// DownloadManager 定义下载操作的接口
type DownloadManager interface {
Download(url, destination string, progressCallback ProgressCallback) error
DownloadWithResume(url, destination string, progressCallback ProgressCallback) error
ValidateChecksum(filePath, expectedChecksum string) error
SetTimeout(timeout time.Duration)
}
// Manager 实现 DownloadManager 接口
type Manager struct {
client *http.Client
timeout time.Duration
}
// NewManager 创建新的下载管理器
func NewManager() *Manager {
return &Manager{
client: &http.Client{
Timeout: 30 * time.Second,
},
timeout: 30 * time.Second,
}
}
// Download 从给定 URL 下载文件到目标路径
func (m *Manager) Download(url, destination string, progressCallback ProgressCallback) error {
return m.downloadWithContext(context.Background(), url, destination, progressCallback, false)
}
// DownloadWithResume 下载文件并支持断点续传
func (m *Manager) DownloadWithResume(url, destination string, progressCallback ProgressCallback) error {
return m.downloadWithContext(context.Background(), url, destination, progressCallback, true)
}
// downloadWithContext 执行实际的下载并支持上下文
func (m *Manager) downloadWithContext(ctx context.Context, url, destination string, progressCallback ProgressCallback, resume bool) error {
// 如果目标目录不存在则创建
if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil {
return fmt.Errorf("创建目标目录失败: %w", err)
}
// 检查文件是否存在以支持断点续传
var existingSize int64
if resume {
if stat, err := os.Stat(destination); err == nil {
existingSize = stat.Size()
}
}
// 创建 HTTP 请求
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("创建请求失败: %w", err)
}
// 为断点续传添加范围头
if resume && existingSize > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", existingSize))
}
// 执行请求
resp, err := m.client.Do(req)
if err != nil {
return fmt.Errorf("执行请求失败: %w", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return fmt.Errorf("意外的状态码: %d", resp.StatusCode)
}
// 获取总大小
totalSize := existingSize
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil {
totalSize += size
}
}
// 打开目标文件
var file *os.File
if resume && existingSize > 0 {
file, err = os.OpenFile(destination, os.O_WRONLY|os.O_APPEND, 0644)
} else {
file, err = os.Create(destination)
existingSize = 0
}
if err != nil {
return fmt.Errorf("创建目标文件失败: %w", err)
}
defer file.Close()
// 下载并跟踪进度
return m.copyWithProgress(resp.Body, file, existingSize, totalSize, progressCallback)
}
// copyWithProgress 复制数据并跟踪进度
func (m *Manager) copyWithProgress(src io.Reader, dst io.Writer, startBytes, totalBytes int64, progressCallback ProgressCallback) error {
buffer := make([]byte, 32*1024) // 32KB 缓冲区
downloaded := startBytes
startTime := time.Now()
lastUpdate := startTime
for {
n, err := src.Read(buffer)
if n > 0 {
if _, writeErr := dst.Write(buffer[:n]); writeErr != nil {
return fmt.Errorf("写入目标失败: %w", writeErr)
}
downloaded += int64(n)
// 每 100ms 更新一次进度
now := time.Now()
if progressCallback != nil && now.Sub(lastUpdate) >= 100*time.Millisecond {
elapsed := now.Sub(startTime).Seconds()
speed := int64(0)
if elapsed > 0 {
speed = int64(float64(downloaded-startBytes) / elapsed)
}
percentage := float64(0)
if totalBytes > 0 {
percentage = float64(downloaded) / float64(totalBytes) * 100
}
progressCallback(DownloadProgress{
BytesDownloaded: downloaded,
TotalBytes: totalBytes,
Percentage: percentage,
Speed: speed,
})
lastUpdate = now
}
}
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("从源读取失败: %w", err)
}
}
// 最终进度更新
if progressCallback != nil {
elapsed := time.Since(startTime).Seconds()
speed := int64(0)
if elapsed > 0 {
speed = int64(float64(downloaded-startBytes) / elapsed)
}
percentage := float64(100)
if totalBytes > 0 {
percentage = float64(downloaded) / float64(totalBytes) * 100
}
progressCallback(DownloadProgress{
BytesDownloaded: downloaded,
TotalBytes: totalBytes,
Percentage: percentage,
Speed: speed,
})
}
return nil
}
// ValidateChecksum 验证文件的 SHA256 校验和
func (m *Manager) ValidateChecksum(filePath, expectedChecksum string) error {
if expectedChecksum == "" {
return nil // 没有校验和需要验证
}
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("打开文件进行校验和验证失败: %w", err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fmt.Errorf("计算校验和失败: %w", err)
}
actualChecksum := hex.EncodeToString(hash.Sum(nil))
if actualChecksum != expectedChecksum {
return fmt.Errorf("校验和不匹配: 期望 %s得到 %s", expectedChecksum, actualChecksum)
}
return nil
}
// SetTimeout 设置下载操作的超时时间
func (m *Manager) SetTimeout(timeout time.Duration) {
m.timeout = timeout
m.client.Timeout = timeout
}

File diff suppressed because it is too large Load Diff

219
Go_Updater/errors/errors.go Normal file
View File

@@ -0,0 +1,219 @@
package errors
import (
"fmt"
"time"
)
// ErrorType 定义错误类型枚举
type ErrorType int
const (
NetworkError ErrorType = iota
APIError
FileError
ConfigError
InstallError
)
// String 返回错误类型的字符串表示
func (et ErrorType) String() string {
switch et {
case NetworkError:
return "NetworkError"
case APIError:
return "APIError"
case FileError:
return "FileError"
case ConfigError:
return "ConfigError"
case InstallError:
return "InstallError"
default:
return "UnknownError"
}
}
// UpdaterError 统一的错误结构体
type UpdaterError struct {
Type ErrorType
Message string
Cause error
Timestamp time.Time
Context map[string]interface{}
}
// Error 实现error接口
func (ue *UpdaterError) Error() string {
if ue.Cause != nil {
return fmt.Sprintf("[%s] %s: %v", ue.Type, ue.Message, ue.Cause)
}
return fmt.Sprintf("[%s] %s", ue.Type, ue.Message)
}
// Unwrap 支持错误链
func (ue *UpdaterError) Unwrap() error {
return ue.Cause
}
// NewUpdaterError 创建新的UpdaterError
func NewUpdaterError(errorType ErrorType, message string, cause error) *UpdaterError {
return &UpdaterError{
Type: errorType,
Message: message,
Cause: cause,
Timestamp: time.Now(),
Context: make(map[string]interface{}),
}
}
// WithContext 添加上下文信息
func (ue *UpdaterError) WithContext(key string, value interface{}) *UpdaterError {
ue.Context[key] = value
return ue
}
// GetUserFriendlyMessage 获取用户友好的错误消息
func (ue *UpdaterError) GetUserFriendlyMessage() string {
switch ue.Type {
case NetworkError:
return "网络连接失败,请检查网络连接后重试"
case APIError:
return "服务器响应异常,请稍后重试或联系技术支持"
case FileError:
return "文件操作失败,请检查文件权限和磁盘空间"
case ConfigError:
return "配置文件错误,程序将使用默认配置"
case InstallError:
return "安装过程中出现错误,程序将尝试回滚更改"
default:
return "发生未知错误,请联系技术支持"
}
}
// RetryConfig 重试配置
type RetryConfig struct {
MaxRetries int
InitialDelay time.Duration
MaxDelay time.Duration
BackoffFactor float64
RetryableErrors []ErrorType
}
// DefaultRetryConfig 默认重试配置
func DefaultRetryConfig() *RetryConfig {
return &RetryConfig{
MaxRetries: 3,
InitialDelay: time.Second,
MaxDelay: 30 * time.Second,
BackoffFactor: 2.0,
RetryableErrors: []ErrorType{NetworkError, APIError},
}
}
// IsRetryable 检查错误是否可重试
func (rc *RetryConfig) IsRetryable(err error) bool {
if ue, ok := err.(*UpdaterError); ok {
for _, retryableType := range rc.RetryableErrors {
if ue.Type == retryableType {
return true
}
}
}
return false
}
// CalculateDelay 计算重试延迟时间
func (rc *RetryConfig) CalculateDelay(attempt int) time.Duration {
delay := time.Duration(float64(rc.InitialDelay) * pow(rc.BackoffFactor, float64(attempt)))
if delay > rc.MaxDelay {
delay = rc.MaxDelay
}
return delay
}
// pow 简单的幂运算实现
func pow(base, exp float64) float64 {
result := 1.0
for i := 0; i < int(exp); i++ {
result *= base
}
return result
}
// RetryableOperation 可重试的操作函数类型
type RetryableOperation func() error
// ExecuteWithRetry 执行带重试的操作
func ExecuteWithRetry(operation RetryableOperation, config *RetryConfig) error {
var lastErr error
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
err := operation()
if err == nil {
return nil
}
lastErr = err
// 如果不是可重试的错误,直接返回
if !config.IsRetryable(err) {
return err
}
// 如果已经是最后一次尝试,不再等待
if attempt == config.MaxRetries {
break
}
// 计算延迟时间并等待
delay := config.CalculateDelay(attempt)
time.Sleep(delay)
}
return lastErr
}
// ErrorHandler 错误处理器接口
type ErrorHandler interface {
HandleError(err error) error
ShouldRetry(err error) bool
GetUserMessage(err error) string
}
// DefaultErrorHandler 默认错误处理器
type DefaultErrorHandler struct {
retryConfig *RetryConfig
}
// NewDefaultErrorHandler 创建默认错误处理器
func NewDefaultErrorHandler() *DefaultErrorHandler {
return &DefaultErrorHandler{
retryConfig: DefaultRetryConfig(),
}
}
// HandleError 处理错误
func (h *DefaultErrorHandler) HandleError(err error) error {
if ue, ok := err.(*UpdaterError); ok {
// 记录错误上下文
ue.WithContext("handled_at", time.Now())
return ue
}
// 将普通错误包装为UpdaterError
return NewUpdaterError(NetworkError, "未分类错误", err)
}
// ShouldRetry 判断是否应该重试
func (h *DefaultErrorHandler) ShouldRetry(err error) bool {
return h.retryConfig.IsRetryable(err)
}
// GetUserMessage 获取用户友好的错误消息
func (h *DefaultErrorHandler) GetUserMessage(err error) string {
if ue, ok := err.(*UpdaterError); ok {
return ue.GetUserFriendlyMessage()
}
return "发生未知错误,请联系技术支持"
}

View File

@@ -0,0 +1,287 @@
package errors
import (
"fmt"
"testing"
"time"
)
func TestUpdaterError_Error(t *testing.T) {
tests := []struct {
name string
err *UpdaterError
expected string
}{
{
name: "error with cause",
err: &UpdaterError{
Type: NetworkError,
Message: "connection failed",
Cause: fmt.Errorf("timeout"),
},
expected: "[NetworkError] connection failed: timeout",
},
{
name: "error without cause",
err: &UpdaterError{
Type: APIError,
Message: "invalid response",
Cause: nil,
},
expected: "[APIError] invalid response",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.err.Error(); got != tt.expected {
t.Errorf("UpdaterError.Error() = %v, want %v", got, tt.expected)
}
})
}
}
func TestNewUpdaterError(t *testing.T) {
cause := fmt.Errorf("original error")
err := NewUpdaterError(FileError, "test message", cause)
if err.Type != FileError {
t.Errorf("Expected type %v, got %v", FileError, err.Type)
}
if err.Message != "test message" {
t.Errorf("Expected message 'test message', got '%v'", err.Message)
}
if err.Cause != cause {
t.Errorf("Expected cause %v, got %v", cause, err.Cause)
}
if err.Context == nil {
t.Error("Expected context to be initialized")
}
}
func TestUpdaterError_WithContext(t *testing.T) {
err := NewUpdaterError(ConfigError, "test", nil)
err.WithContext("key1", "value1").WithContext("key2", 42)
if err.Context["key1"] != "value1" {
t.Errorf("Expected context key1 to be 'value1', got %v", err.Context["key1"])
}
if err.Context["key2"] != 42 {
t.Errorf("Expected context key2 to be 42, got %v", err.Context["key2"])
}
}
func TestUpdaterError_GetUserFriendlyMessage(t *testing.T) {
tests := []struct {
errorType ErrorType
expected string
}{
{NetworkError, "网络连接失败,请检查网络连接后重试"},
{APIError, "服务器响应异常,请稍后重试或联系技术支持"},
{FileError, "文件操作失败,请检查文件权限和磁盘空间"},
{ConfigError, "配置文件错误,程序将使用默认配置"},
{InstallError, "安装过程中出现错误,程序将尝试回滚更改"},
}
for _, tt := range tests {
t.Run(tt.errorType.String(), func(t *testing.T) {
err := NewUpdaterError(tt.errorType, "test", nil)
if got := err.GetUserFriendlyMessage(); got != tt.expected {
t.Errorf("GetUserFriendlyMessage() = %v, want %v", got, tt.expected)
}
})
}
}
func TestRetryConfig_IsRetryable(t *testing.T) {
config := DefaultRetryConfig()
tests := []struct {
name string
err error
expected bool
}{
{
name: "retryable network error",
err: NewUpdaterError(NetworkError, "test", nil),
expected: true,
},
{
name: "retryable api error",
err: NewUpdaterError(APIError, "test", nil),
expected: true,
},
{
name: "non-retryable file error",
err: NewUpdaterError(FileError, "test", nil),
expected: false,
},
{
name: "regular error",
err: fmt.Errorf("regular error"),
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := config.IsRetryable(tt.err); got != tt.expected {
t.Errorf("IsRetryable() = %v, want %v", got, tt.expected)
}
})
}
}
func TestRetryConfig_CalculateDelay(t *testing.T) {
config := DefaultRetryConfig()
tests := []struct {
attempt int
expected time.Duration
}{
{0, time.Second},
{1, 2 * time.Second},
{2, 4 * time.Second},
{10, 30 * time.Second}, // should be capped at MaxDelay
}
for _, tt := range tests {
t.Run(fmt.Sprintf("attempt_%d", tt.attempt), func(t *testing.T) {
if got := config.CalculateDelay(tt.attempt); got != tt.expected {
t.Errorf("CalculateDelay(%d) = %v, want %v", tt.attempt, got, tt.expected)
}
})
}
}
func TestExecuteWithRetry(t *testing.T) {
config := DefaultRetryConfig()
config.InitialDelay = time.Millisecond // 加快测试速度
t.Run("success on first try", func(t *testing.T) {
attempts := 0
operation := func() error {
attempts++
return nil
}
err := ExecuteWithRetry(operation, config)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if attempts != 1 {
t.Errorf("Expected 1 attempt, got %d", attempts)
}
})
t.Run("success after retries", func(t *testing.T) {
attempts := 0
operation := func() error {
attempts++
if attempts < 3 {
return NewUpdaterError(NetworkError, "temporary failure", nil)
}
return nil
}
err := ExecuteWithRetry(operation, config)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if attempts != 3 {
t.Errorf("Expected 3 attempts, got %d", attempts)
}
})
t.Run("non-retryable error", func(t *testing.T) {
attempts := 0
operation := func() error {
attempts++
return NewUpdaterError(FileError, "file not found", nil)
}
err := ExecuteWithRetry(operation, config)
if err == nil {
t.Error("Expected error, got nil")
}
if attempts != 1 {
t.Errorf("Expected 1 attempt, got %d", attempts)
}
})
t.Run("max retries exceeded", func(t *testing.T) {
attempts := 0
operation := func() error {
attempts++
return NewUpdaterError(NetworkError, "persistent failure", nil)
}
err := ExecuteWithRetry(operation, config)
if err == nil {
t.Error("Expected error, got nil")
}
expectedAttempts := config.MaxRetries + 1
if attempts != expectedAttempts {
t.Errorf("Expected %d attempts, got %d", expectedAttempts, attempts)
}
})
}
func TestDefaultErrorHandler(t *testing.T) {
handler := NewDefaultErrorHandler()
t.Run("handle updater error", func(t *testing.T) {
originalErr := NewUpdaterError(NetworkError, "test", nil)
handledErr := handler.HandleError(originalErr)
if handledErr != originalErr {
t.Error("Expected same error instance")
}
if originalErr.Context["handled_at"] == nil {
t.Error("Expected handled_at context to be set")
}
})
t.Run("handle regular error", func(t *testing.T) {
originalErr := fmt.Errorf("regular error")
handledErr := handler.HandleError(originalErr)
if ue, ok := handledErr.(*UpdaterError); ok {
if ue.Type != NetworkError {
t.Errorf("Expected NetworkError, got %v", ue.Type)
}
if ue.Cause != originalErr {
t.Error("Expected original error as cause")
}
} else {
t.Error("Expected UpdaterError")
}
})
t.Run("should retry", func(t *testing.T) {
retryableErr := NewUpdaterError(NetworkError, "test", nil)
nonRetryableErr := NewUpdaterError(FileError, "test", nil)
if !handler.ShouldRetry(retryableErr) {
t.Error("Expected network error to be retryable")
}
if handler.ShouldRetry(nonRetryableErr) {
t.Error("Expected file error to not be retryable")
}
})
t.Run("get user message", func(t *testing.T) {
updaterErr := NewUpdaterError(NetworkError, "test", nil)
regularErr := fmt.Errorf("regular error")
userMsg1 := handler.GetUserMessage(updaterErr)
userMsg2 := handler.GetUserMessage(regularErr)
if userMsg1 != "网络连接失败,请检查网络连接后重试" {
t.Errorf("Unexpected user message: %s", userMsg1)
}
if userMsg2 != "发生未知错误,请联系技术支持" {
t.Errorf("Unexpected user message: %s", userMsg2)
}
})
}

42
Go_Updater/go.mod Normal file
View File

@@ -0,0 +1,42 @@
module AUTO_MAA_Go_Updater
go 1.24.5
require (
fyne.io/fyne/v2 v2.6.1
gopkg.in/yaml.v3 v3.0.1
)
require (
fyne.io/systray v1.11.0 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fredbi/uri v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fyne-io/gl-js v0.1.0 // indirect
github.com/fyne-io/glfw-js v0.2.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.1.0 // indirect
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
github.com/go-text/render v0.2.0 // indirect
github.com/go-text/typesetting v0.2.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rymdport/portal v0.4.1 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

80
Go_Updater/go.sum Normal file
View File

@@ -0,0 +1,80 @@
fyne.io/fyne/v2 v2.6.1 h1:kjPJD4/rBS9m2nHJp+npPSuaK79yj6ObMTuzR6VQ1Is=
fyne.io/fyne/v2 v2.6.1/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU=
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM=
github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM=
github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc=
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

513
Go_Updater/gui/manager.go Normal file
View File

@@ -0,0 +1,513 @@
package gui
import (
"fmt"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// UpdateStatus 表示更新过程的当前状态
type UpdateStatus int
const (
StatusChecking UpdateStatus = iota
StatusUpdateAvailable
StatusDownloading
StatusInstalling
StatusCompleted
StatusError
)
// Config 表示 GUI 的配置结构
type Config struct {
ResourceID string
CurrentVersion string
UserAgent string
BackupURL string
}
// GUIManager 定义 GUI 管理的接口方法
type GUIManager interface {
ShowMainWindow()
UpdateStatus(status UpdateStatus, message string)
ShowProgress(percentage float64)
ShowError(errorMsg string)
ShowConfigDialog() (*Config, error)
Close()
}
// Manager 实现 GUIManager 接口
type Manager struct {
app fyne.App
window fyne.Window
statusLabel *widget.Label
progressBar *widget.ProgressBar
actionButton *widget.Button
versionLabel *widget.Label
releaseNotes *widget.RichText
currentStatus UpdateStatus
onCheckUpdate func()
onCancel func()
}
// NewManager creates a new GUI manager instance
func NewManager() *Manager {
a := app.New()
a.SetIcon(theme.ComputerIcon())
w := a.NewWindow("AUTO_MAA_Go_Updater")
w.Resize(fyne.NewSize(500, 400))
w.SetFixedSize(false)
w.CenterOnScreen()
return &Manager{
app: a,
window: w,
}
}
// SetCallbacks sets the callback functions for user actions
func (m *Manager) SetCallbacks(onCheckUpdate, onCancel func()) {
m.onCheckUpdate = onCheckUpdate
m.onCancel = onCancel
}
// ShowMainWindow displays the main application window
func (m *Manager) ShowMainWindow() {
// Create UI components
m.createUIComponents()
// Create main layout
content := m.createMainLayout()
m.window.SetContent(content)
m.window.ShowAndRun()
}
// createUIComponents initializes all UI components
func (m *Manager) createUIComponents() {
// Status label
m.statusLabel = widget.NewLabel("准备检查更新...")
m.statusLabel.Alignment = fyne.TextAlignCenter
// Progress bar
m.progressBar = widget.NewProgressBar()
m.progressBar.Hide()
// Version label
m.versionLabel = widget.NewLabel("当前版本: 未知")
m.versionLabel.TextStyle = fyne.TextStyle{Italic: true}
// Release notes
m.releaseNotes = widget.NewRichText()
m.releaseNotes.Hide()
// Action button
m.actionButton = widget.NewButton("检查更新", func() {
if m.onCheckUpdate != nil {
m.onCheckUpdate()
}
})
m.actionButton.Importance = widget.HighImportance
}
// createMainLayout creates the main window layout
func (m *Manager) createMainLayout() *fyne.Container {
// Header section
header := container.NewVBox(
widget.NewCard("", "", container.NewVBox(
widget.NewLabelWithStyle("AUTO_MAA_Go_Updater", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
m.versionLabel,
)),
)
// Status section
statusSection := container.NewVBox(
m.statusLabel,
m.progressBar,
)
// Release notes section
releaseNotesCard := widget.NewCard("更新日志", "", container.NewScroll(m.releaseNotes))
releaseNotesCard.Hide()
// Button section
buttonSection := container.NewHBox(
widget.NewButton("配置", func() {
m.showConfigDialog()
}),
widget.NewSpacer(),
m.actionButton,
)
// Main layout
return container.NewVBox(
header,
widget.NewSeparator(),
statusSection,
releaseNotesCard,
widget.NewSeparator(),
buttonSection,
)
}
// UpdateStatus updates the current status and UI accordingly
func (m *Manager) UpdateStatus(status UpdateStatus, message string) {
m.currentStatus = status
m.statusLabel.SetText(message)
switch status {
case StatusChecking:
m.actionButton.SetText("检查中...")
m.actionButton.Disable()
m.progressBar.Hide()
case StatusUpdateAvailable:
m.actionButton.SetText("开始更新")
m.actionButton.Enable()
m.progressBar.Hide()
case StatusDownloading:
m.actionButton.SetText("下载中...")
m.actionButton.Disable()
m.progressBar.Show()
case StatusInstalling:
m.actionButton.SetText("安装中...")
m.actionButton.Disable()
m.progressBar.Show()
case StatusCompleted:
m.actionButton.SetText("完成")
m.actionButton.Enable()
m.progressBar.Hide()
case StatusError:
m.actionButton.SetText("重试")
m.actionButton.Enable()
m.progressBar.Hide()
}
}
// ShowProgress updates the progress bar
func (m *Manager) ShowProgress(percentage float64) {
if percentage < 0 {
percentage = 0
}
if percentage > 100 {
percentage = 100
}
m.progressBar.SetValue(percentage / 100.0)
m.progressBar.Show()
}
// ShowError displays an error dialog
func (m *Manager) ShowError(errorMsg string) {
dialog.ShowError(fmt.Errorf(errorMsg), m.window)
}
// ShowConfigDialog displays the configuration dialog
func (m *Manager) ShowConfigDialog() (*Config, error) {
return m.showConfigDialog()
}
// showConfigDialog creates and shows the configuration dialog
func (m *Manager) showConfigDialog() (*Config, error) {
// Create form entries
resourceIDEntry := widget.NewEntry()
resourceIDEntry.SetPlaceHolder("例如: M9A")
versionEntry := widget.NewEntry()
versionEntry.SetPlaceHolder("例如: v1.0.0")
userAgentEntry := widget.NewEntry()
userAgentEntry.SetText("AUTO_MAA_Go_Updater/1.0")
backupURLEntry := widget.NewEntry()
backupURLEntry.SetPlaceHolder("备用下载地址(可选)")
// Create form
form := &widget.Form{
Items: []*widget.FormItem{
{Text: "资源ID:", Widget: resourceIDEntry},
{Text: "当前版本:", Widget: versionEntry},
{Text: "用户代理:", Widget: userAgentEntry},
{Text: "备用下载地址:", Widget: backupURLEntry},
},
}
// Create result channel
resultChan := make(chan *Config, 1)
errorChan := make(chan error, 1)
// Create dialog
configDialog := dialog.NewCustomConfirm(
"配置设置",
"保存",
"取消",
form,
func(confirmed bool) {
if confirmed {
config := &Config{
ResourceID: resourceIDEntry.Text,
CurrentVersion: versionEntry.Text,
UserAgent: userAgentEntry.Text,
BackupURL: backupURLEntry.Text,
}
// Basic validation
if config.ResourceID == "" {
errorChan <- fmt.Errorf("资源ID不能为空")
return
}
if config.CurrentVersion == "" {
errorChan <- fmt.Errorf("当前版本不能为空")
return
}
resultChan <- config
} else {
errorChan <- fmt.Errorf("用户取消了配置")
}
},
m.window,
)
// Add help text
helpText := widget.NewRichTextFromMarkdown(`
**配置说明:**
- **资源ID**: Mirror酱服务中的资源标识符
- **当前版本**: 当前软件的版本号
- **用户代理**: HTTP请求的用户代理字符串
- **备用下载地址**: 当Mirror酱服务不可用时的备用下载地址
`)
// Create container with help text
dialogContent := container.NewVBox(
form,
widget.NewSeparator(),
helpText,
)
configDialog.SetContent(dialogContent)
configDialog.Resize(fyne.NewSize(600, 500))
configDialog.Show()
// Wait for result
select {
case config := <-resultChan:
return config, nil
case err := <-errorChan:
return nil, err
}
}
// SetVersionInfo updates the version display
func (m *Manager) SetVersionInfo(version string) {
m.versionLabel.SetText(fmt.Sprintf("当前版本: %s", version))
}
// ShowReleaseNotes displays the release notes
func (m *Manager) ShowReleaseNotes(notes string) {
if notes != "" {
m.releaseNotes.ParseMarkdown(notes)
// Find the release notes card and show it
if parent := m.window.Content().(*container.VBox); parent != nil {
for _, obj := range parent.Objects {
if card, ok := obj.(*widget.Card); ok && card.Title == "更新日志" {
card.Show()
break
}
}
}
}
}
// UpdateStatusWithDetails updates status with detailed information
func (m *Manager) UpdateStatusWithDetails(status UpdateStatus, message string, details map[string]string) {
m.UpdateStatus(status, message)
// Update version info if provided
if version, ok := details["version"]; ok {
m.SetVersionInfo(version)
}
// Show release notes if provided
if notes, ok := details["release_notes"]; ok {
m.ShowReleaseNotes(notes)
}
// Update progress if provided
if progress, ok := details["progress"]; ok {
if p, err := fmt.Sscanf(progress, "%f", new(float64)); err == nil && p == 1 {
var progressValue float64
fmt.Sscanf(progress, "%f", &progressValue)
m.ShowProgress(progressValue)
}
}
}
// ShowProgressWithSpeed shows progress with download speed information
func (m *Manager) ShowProgressWithSpeed(percentage float64, speed int64, eta string) {
m.ShowProgress(percentage)
// Update status with speed and ETA information
speedText := m.formatSpeed(speed)
statusText := fmt.Sprintf("下载中... %.1f%% (%s)", percentage, speedText)
if eta != "" {
statusText += fmt.Sprintf(" - 剩余时间: %s", eta)
}
m.statusLabel.SetText(statusText)
}
// formatSpeed formats the download speed for display
func (m *Manager) formatSpeed(bytesPerSecond int64) string {
if bytesPerSecond < 1024 {
return fmt.Sprintf("%d B/s", bytesPerSecond)
} else if bytesPerSecond < 1024*1024 {
return fmt.Sprintf("%.1f KB/s", float64(bytesPerSecond)/1024)
} else {
return fmt.Sprintf("%.1f MB/s", float64(bytesPerSecond)/(1024*1024))
}
}
// ShowConfirmDialog shows a confirmation dialog
func (m *Manager) ShowConfirmDialog(title, message string, callback func(bool)) {
dialog.ShowConfirm(title, message, callback, m.window)
}
// ShowInfoDialog shows an information dialog
func (m *Manager) ShowInfoDialog(title, message string) {
dialog.ShowInformation(title, message, m.window)
}
// ShowUpdateAvailableDialog shows a dialog when update is available
func (m *Manager) ShowUpdateAvailableDialog(currentVersion, newVersion, releaseNotes string, onConfirm func()) {
content := container.NewVBox(
widget.NewLabel(fmt.Sprintf("发现新版本: %s", newVersion)),
widget.NewLabel(fmt.Sprintf("当前版本: %s", currentVersion)),
widget.NewSeparator(),
)
if releaseNotes != "" {
notesWidget := widget.NewRichText()
notesWidget.ParseMarkdown(releaseNotes)
notesScroll := container.NewScroll(notesWidget)
notesScroll.SetMinSize(fyne.NewSize(400, 200))
content.Add(widget.NewLabel("更新内容:"))
content.Add(notesScroll)
}
dialog.ShowCustomConfirm(
"发现新版本",
"立即更新",
"稍后提醒",
content,
func(confirmed bool) {
if confirmed && onConfirm != nil {
onConfirm()
}
},
m.window,
)
}
// SetActionButtonCallback sets the callback for the main action button
func (m *Manager) SetActionButtonCallback(callback func()) {
if m.actionButton != nil {
m.actionButton.OnTapped = callback
}
}
// EnableActionButton enables or disables the action button
func (m *Manager) EnableActionButton(enabled bool) {
if m.actionButton != nil {
if enabled {
m.actionButton.Enable()
} else {
m.actionButton.Disable()
}
}
}
// SetActionButtonText sets the text of the action button
func (m *Manager) SetActionButtonText(text string) {
if m.actionButton != nil {
m.actionButton.SetText(text)
}
}
// ShowErrorWithRetry shows an error with retry option
func (m *Manager) ShowErrorWithRetry(errorMsg string, onRetry func()) {
dialog.ShowCustomConfirm(
"错误",
"重试",
"取消",
widget.NewLabel(errorMsg),
func(retry bool) {
if retry && onRetry != nil {
onRetry()
}
},
m.window,
)
}
// UpdateProgressBar updates the progress bar with custom styling
func (m *Manager) UpdateProgressBar(percentage float64, color string) {
m.ShowProgress(percentage)
// Note: Fyne doesn't support custom colors easily, but we keep the interface for future enhancement
}
// HideProgressBar hides the progress bar
func (m *Manager) HideProgressBar() {
if m.progressBar != nil {
m.progressBar.Hide()
}
}
// ShowProgressBar shows the progress bar
func (m *Manager) ShowProgressBar() {
if m.progressBar != nil {
m.progressBar.Show()
}
}
// SetWindowTitle sets the window title
func (m *Manager) SetWindowTitle(title string) {
if m.window != nil {
m.window.SetTitle(title)
}
}
// GetCurrentStatus returns the current update status
func (m *Manager) GetCurrentStatus() UpdateStatus {
return m.currentStatus
}
// IsWindowVisible returns whether the window is currently visible
func (m *Manager) IsWindowVisible() bool {
return m.window != nil && m.window.Content() != nil
}
// RefreshUI refreshes the user interface
func (m *Manager) RefreshUI() {
if m.window != nil && m.window.Content() != nil {
m.window.Content().Refresh()
}
}
// Close closes the application
func (m *Manager) Close() {
if m.window != nil {
m.window.Close()
}
}

View File

@@ -0,0 +1,227 @@
package gui
import (
"testing"
"time"
)
func TestNewManager(t *testing.T) {
manager := NewManager()
if manager == nil {
t.Fatal("NewManager() returned nil")
}
if manager.app == nil {
t.Error("Manager app is nil")
}
if manager.window == nil {
t.Error("Manager window is nil")
}
}
func TestUpdateStatus(t *testing.T) {
manager := NewManager()
manager.createUIComponents()
// Test different status updates
testCases := []struct {
status UpdateStatus
message string
}{
{StatusChecking, "检查更新中..."},
{StatusUpdateAvailable, "发现新版本"},
{StatusDownloading, "下载中..."},
{StatusInstalling, "安装中..."},
{StatusCompleted, "更新完成"},
{StatusError, "更新失败"},
}
for _, tc := range testCases {
manager.UpdateStatus(tc.status, tc.message)
if manager.GetCurrentStatus() != tc.status {
t.Errorf("Expected status %v, got %v", tc.status, manager.GetCurrentStatus())
}
if manager.statusLabel.Text != tc.message {
t.Errorf("Expected message '%s', got '%s'", tc.message, manager.statusLabel.Text)
}
}
}
func TestShowProgress(t *testing.T) {
manager := NewManager()
manager.createUIComponents()
// Test progress values
testValues := []float64{0, 25.5, 50, 75.8, 100, 150, -10}
expectedValues := []float64{0, 25.5, 50, 75.8, 100, 100, 0}
for i, value := range testValues {
manager.ShowProgress(value)
expected := expectedValues[i] / 100.0
if manager.progressBar.Value != expected {
t.Errorf("Expected progress %.2f, got %.2f", expected, manager.progressBar.Value)
}
}
}
func TestSetVersionInfo(t *testing.T) {
manager := NewManager()
manager.createUIComponents()
version := "v1.2.3"
manager.SetVersionInfo(version)
expectedText := "当前版本: v1.2.3"
if manager.versionLabel.Text != expectedText {
t.Errorf("Expected version text '%s', got '%s'", expectedText, manager.versionLabel.Text)
}
}
func TestFormatSpeed(t *testing.T) {
manager := NewManager()
testCases := []struct {
speed int64
expected string
}{
{512, "512 B/s"},
{1536, "1.5 KB/s"},
{1048576, "1.0 MB/s"},
{2621440, "2.5 MB/s"},
}
for _, tc := range testCases {
result := manager.formatSpeed(tc.speed)
if result != tc.expected {
t.Errorf("Expected speed format '%s', got '%s'", tc.expected, result)
}
}
}
func TestShowProgressWithSpeed(t *testing.T) {
manager := NewManager()
manager.createUIComponents()
percentage := 45.5
speed := int64(1048576) // 1 MB/s
eta := "2分钟"
manager.ShowProgressWithSpeed(percentage, speed, eta)
expectedProgress := percentage / 100.0
if manager.progressBar.Value != expectedProgress {
t.Errorf("Expected progress %.2f, got %.2f", expectedProgress, manager.progressBar.Value)
}
expectedStatus := "下载中... 45.5% (1.0 MB/s) - 剩余时间: 2分钟"
if manager.statusLabel.Text != expectedStatus {
t.Errorf("Expected status '%s', got '%s'", expectedStatus, manager.statusLabel.Text)
}
}
func TestActionButtonStates(t *testing.T) {
manager := NewManager()
manager.createUIComponents()
// Test enabling/disabling
manager.EnableActionButton(false)
if !manager.actionButton.Disabled() {
t.Error("Action button should be disabled")
}
manager.EnableActionButton(true)
if manager.actionButton.Disabled() {
t.Error("Action button should be enabled")
}
// Test text setting
testText := "测试按钮"
manager.SetActionButtonText(testText)
if manager.actionButton.Text != testText {
t.Errorf("Expected button text '%s', got '%s'", testText, manager.actionButton.Text)
}
}
func TestProgressBarVisibility(t *testing.T) {
manager := NewManager()
manager.createUIComponents()
// Initially hidden
if manager.progressBar.Visible() {
t.Error("Progress bar should be initially hidden")
}
// Show progress bar
manager.ShowProgressBar()
if !manager.progressBar.Visible() {
t.Error("Progress bar should be visible after ShowProgressBar()")
}
// Hide progress bar
manager.HideProgressBar()
if manager.progressBar.Visible() {
t.Error("Progress bar should be hidden after HideProgressBar()")
}
}
func TestSetCallbacks(t *testing.T) {
manager := NewManager()
checkUpdateCalled := false
cancelCalled := false
onCheckUpdate := func() {
checkUpdateCalled = true
}
onCancel := func() {
cancelCalled = true
}
manager.SetCallbacks(onCheckUpdate, onCancel)
// Verify callbacks are set
if manager.onCheckUpdate == nil {
t.Error("onCheckUpdate callback not set")
}
if manager.onCancel == nil {
t.Error("onCancel callback not set")
}
// Test callback execution
manager.onCheckUpdate()
if !checkUpdateCalled {
t.Error("onCheckUpdate callback was not called")
}
manager.onCancel()
if !cancelCalled {
t.Error("onCancel callback was not called")
}
}
// Benchmark tests for performance
func BenchmarkUpdateStatus(b *testing.B) {
manager := NewManager()
manager.createUIComponents()
b.ResetTimer()
for i := 0; i < b.N; i++ {
manager.UpdateStatus(StatusDownloading, "下载中...")
}
}
func BenchmarkShowProgress(b *testing.B) {
manager := NewManager()
manager.createUIComponents()
b.ResetTimer()
for i := 0; i < b.N; i++ {
manager.ShowProgress(float64(i % 100))
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,474 @@
package install
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
)
// ChangesInfo 表示 changes.json 文件的结构
type ChangesInfo struct {
Deleted []string `json:"deleted"`
Added []string `json:"added"`
Modified []string `json:"modified"`
}
// InstallManager 定义安装操作的接口契约
type InstallManager interface {
ExtractZip(zipPath, destPath string) error
ProcessChanges(changesPath string) (*ChangesInfo, error)
ApplyUpdate(sourcePath, targetPath string, changes *ChangesInfo) error
HandleRunningProcess(processName string) error
CreateTempDir() (string, error)
CleanupTempDir(tempDir string) error
}
// Manager 实现 InstallManager 接口
type Manager struct {
tempDirs []string // 跟踪临时目录以便清理
}
// NewManager 创建新的安装管理器实例
func NewManager() *Manager {
return &Manager{
tempDirs: make([]string, 0),
}
}
// CreateTempDir 为解压创建临时目录
func (m *Manager) CreateTempDir() (string, error) {
tempDir, err := os.MkdirTemp("", "updater_*")
if err != nil {
return "", fmt.Errorf("创建临时目录失败: %w", err)
}
// 跟踪临时目录以便清理
m.tempDirs = append(m.tempDirs, tempDir)
return tempDir, nil
}
// CleanupTempDir 删除临时目录及其内容
func (m *Manager) CleanupTempDir(tempDir string) error {
if tempDir == "" {
return nil
}
err := os.RemoveAll(tempDir)
if err != nil {
return fmt.Errorf("清理临时目录 %s 失败: %w", tempDir, err)
}
// 从跟踪列表中删除
for i, dir := range m.tempDirs {
if dir == tempDir {
m.tempDirs = append(m.tempDirs[:i], m.tempDirs[i+1:]...)
break
}
}
return nil
}
// CleanupAllTempDirs 删除所有跟踪的临时目录
func (m *Manager) CleanupAllTempDirs() error {
var errors []string
for _, tempDir := range m.tempDirs {
if err := os.RemoveAll(tempDir); err != nil {
errors = append(errors, fmt.Sprintf("清理 %s 失败: %v", tempDir, err))
}
}
m.tempDirs = m.tempDirs[:0] // 清空切片
if len(errors) > 0 {
return fmt.Errorf("清理错误: %s", strings.Join(errors, "; "))
}
return nil
}
// ExtractZip 将 ZIP 文件解压到指定的目标目录
func (m *Manager) ExtractZip(zipPath, destPath string) error {
// 打开 ZIP 文件进行读取
reader, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("打开 ZIP 文件 %s 失败: %w", zipPath, err)
}
defer reader.Close()
// 如果目标目录不存在则创建
if err := os.MkdirAll(destPath, 0755); err != nil {
return fmt.Errorf("创建目标目录 %s 失败: %w", destPath, err)
}
// 解压文件
for _, file := range reader.File {
if err := m.extractFile(file, destPath); err != nil {
return fmt.Errorf("解压文件 %s 失败: %w", file.Name, err)
}
}
return nil
}
// extractFile 从 ZIP 归档中解压单个文件
func (m *Manager) extractFile(file *zip.File, destPath string) error {
// 清理文件路径以防止目录遍历攻击
cleanPath := filepath.Clean(file.Name)
if strings.Contains(cleanPath, "..") {
return fmt.Errorf("无效的文件路径: %s", file.Name)
}
// 创建完整的目标路径
destFile := filepath.Join(destPath, cleanPath)
// 如果需要则创建目录结构
if file.FileInfo().IsDir() {
return os.MkdirAll(destFile, file.FileInfo().Mode())
}
// 创建父目录
if err := os.MkdirAll(filepath.Dir(destFile), 0755); err != nil {
return fmt.Errorf("创建父目录失败: %w", err)
}
// 打开 ZIP 归档中的文件
rc, err := file.Open()
if err != nil {
return fmt.Errorf("打开归档中的文件失败: %w", err)
}
defer rc.Close()
// 创建目标文件
outFile, err := os.OpenFile(destFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.FileInfo().Mode())
if err != nil {
return fmt.Errorf("创建目标文件失败: %w", err)
}
defer outFile.Close()
// 复制文件内容
_, err = io.Copy(outFile, rc)
if err != nil {
return fmt.Errorf("复制文件内容失败: %w", err)
}
return nil
}
// ProcessChanges 读取并解析 changes.json 文件
func (m *Manager) ProcessChanges(changesPath string) (*ChangesInfo, error) {
// 检查 changes.json 是否存在
if _, err := os.Stat(changesPath); os.IsNotExist(err) {
// 如果 changes.json 不存在,返回空的变更信息
return &ChangesInfo{
Deleted: []string{},
Added: []string{},
Modified: []string{},
}, nil
}
// 读取 changes.json 文件
data, err := os.ReadFile(changesPath)
if err != nil {
return nil, fmt.Errorf("读取变更文件 %s 失败: %w", changesPath, err)
}
// 解析 JSON
var changes ChangesInfo
if err := json.Unmarshal(data, &changes); err != nil {
return nil, fmt.Errorf("解析变更 JSON 失败: %w", err)
}
return &changes, nil
}
// HandleRunningProcess 通过重命名正在使用的文件来处理正在运行的进程
func (m *Manager) HandleRunningProcess(processName string) error {
// 获取当前可执行文件路径
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("获取可执行文件路径失败: %w", err)
}
exeDir := filepath.Dir(exePath)
targetFile := filepath.Join(exeDir, processName)
// 检查目标文件是否存在
if _, err := os.Stat(targetFile); os.IsNotExist(err) {
// 文件不存在,无需处理
return nil
}
// 尝试重命名文件以指示应在下次启动时删除
oldFile := targetFile + ".old"
// 如果存在现有的 .old 文件则删除
if _, err := os.Stat(oldFile); err == nil {
if err := os.Remove(oldFile); err != nil {
return fmt.Errorf("删除现有旧文件 %s 失败: %w", oldFile, err)
}
}
// 将当前文件重命名为 .old
if err := os.Rename(targetFile, oldFile); err != nil {
// 如果重命名失败,进程可能正在运行
// 在 Windows 上,我们无法重命名正在运行的可执行文件
if isFileInUse(err) {
// 标记文件在下次重启时删除Windows 特定)
return m.markFileForDeletion(targetFile)
}
return fmt.Errorf("重命名正在运行的进程文件 %s 失败: %w", targetFile, err)
}
return nil
}
// isFileInUse 检查错误是否表示文件正在使用中
func isFileInUse(err error) bool {
if err == nil {
return false
}
// 检查 Windows 特定的"文件正在使用"错误
if pathErr, ok := err.(*os.PathError); ok {
if errno, ok := pathErr.Err.(syscall.Errno); ok {
// ERROR_SHARING_VIOLATION (32) 或 ERROR_ACCESS_DENIED (5)
return errno == syscall.Errno(32) || errno == syscall.Errno(5)
}
}
return strings.Contains(err.Error(), "being used by another process") ||
strings.Contains(err.Error(), "access is denied")
}
// markFileForDeletion 标记文件在下次系统重启时删除Windows 特定)
func (m *Manager) markFileForDeletion(filePath string) error {
// 这是 Windows 特定的实现
// 目前,我们将创建一个可由主应用程序处理的标记文件
markerFile := filePath + ".delete_on_restart"
// 创建标记文件
file, err := os.Create(markerFile)
if err != nil {
return fmt.Errorf("创建删除标记文件失败: %w", err)
}
defer file.Close()
// 将目标文件路径写入标记文件
_, err = file.WriteString(filePath)
if err != nil {
return fmt.Errorf("写入标记文件失败: %w", err)
}
return nil
}
// DeleteMarkedFiles 删除标记为删除的文件
func (m *Manager) DeleteMarkedFiles(directory string) error {
// 查找所有 .delete_on_restart 文件
pattern := filepath.Join(directory, "*.delete_on_restart")
matches, err := filepath.Glob(pattern)
if err != nil {
return fmt.Errorf("查找标记文件失败: %w", err)
}
var errors []string
for _, markerFile := range matches {
// 读取目标文件路径
data, err := os.ReadFile(markerFile)
if err != nil {
errors = append(errors, fmt.Sprintf("读取标记文件 %s 失败: %v", markerFile, err))
continue
}
targetFile := strings.TrimSpace(string(data))
// 尝试删除目标文件
if err := os.Remove(targetFile); err != nil && !os.IsNotExist(err) {
errors = append(errors, fmt.Sprintf("删除标记文件 %s 失败: %v", targetFile, err))
}
// 删除标记文件
if err := os.Remove(markerFile); err != nil {
errors = append(errors, fmt.Sprintf("删除标记文件 %s 失败: %v", markerFile, err))
}
}
if len(errors) > 0 {
return fmt.Errorf("删除错误: %s", strings.Join(errors, "; "))
}
return nil
}
// ApplyUpdate 通过从源目录复制文件到目标目录来应用更新
func (m *Manager) ApplyUpdate(sourcePath, targetPath string, changes *ChangesInfo) error {
// 创建备份目录
backupDir, err := m.createBackupDir(targetPath)
if err != nil {
return fmt.Errorf("创建备份目录失败: %w", err)
}
// 在应用更新前备份现有文件
if err := m.backupFiles(targetPath, backupDir, changes); err != nil {
return fmt.Errorf("备份文件失败: %w", err)
}
// 应用更新
if err := m.applyUpdateFiles(sourcePath, targetPath, changes); err != nil {
// 失败时回滚
if rollbackErr := m.rollbackUpdate(targetPath, backupDir); rollbackErr != nil {
return fmt.Errorf("更新失败且回滚失败: 更新错误: %w, 回滚错误: %v", err, rollbackErr)
}
return fmt.Errorf("更新失败已回滚: %w", err)
}
// 成功更新后清理备份目录
if err := os.RemoveAll(backupDir); err != nil {
// 记录警告但不让更新失败
fmt.Printf("警告: 清理备份目录 %s 失败: %v\n", backupDir, err)
}
return nil
}
// createBackupDir 为更新创建备份目录
func (m *Manager) createBackupDir(targetPath string) (string, error) {
backupDir := filepath.Join(targetPath, ".backup_"+fmt.Sprintf("%d", os.Getpid()))
if err := os.MkdirAll(backupDir, 0755); err != nil {
return "", fmt.Errorf("创建备份目录失败: %w", err)
}
return backupDir, nil
}
// backupFiles 创建将被修改或删除的文件的备份
func (m *Manager) backupFiles(targetPath, backupDir string, changes *ChangesInfo) error {
// 备份将被修改的文件
for _, file := range changes.Modified {
srcFile := filepath.Join(targetPath, file)
if _, err := os.Stat(srcFile); os.IsNotExist(err) {
continue // 文件不存在,跳过备份
}
backupFile := filepath.Join(backupDir, file)
if err := m.copyFileWithDirs(srcFile, backupFile); err != nil {
return fmt.Errorf("备份修改文件 %s 失败: %w", file, err)
}
}
// 备份将被删除的文件
for _, file := range changes.Deleted {
srcFile := filepath.Join(targetPath, file)
if _, err := os.Stat(srcFile); os.IsNotExist(err) {
continue // 文件不存在,跳过备份
}
backupFile := filepath.Join(backupDir, file)
if err := m.copyFileWithDirs(srcFile, backupFile); err != nil {
return fmt.Errorf("备份删除文件 %s 失败: %w", file, err)
}
}
return nil
}
// applyUpdateFiles 应用实际的文件更改
func (m *Manager) applyUpdateFiles(sourcePath, targetPath string, changes *ChangesInfo) error {
// 删除标记为删除的文件
for _, file := range changes.Deleted {
targetFile := filepath.Join(targetPath, file)
if err := os.Remove(targetFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("删除文件 %s 失败: %w", file, err)
}
}
// 复制新文件和修改的文件
filesToCopy := append(changes.Added, changes.Modified...)
for _, file := range filesToCopy {
srcFile := filepath.Join(sourcePath, file)
targetFile := filepath.Join(targetPath, file)
// 检查源文件是否存在
if _, err := os.Stat(srcFile); os.IsNotExist(err) {
continue // 源文件不存在,跳过
}
if err := m.copyFileWithDirs(srcFile, targetFile); err != nil {
return fmt.Errorf("复制文件 %s 失败: %w", file, err)
}
}
return nil
}
// copyFileWithDirs 复制文件并创建必要的目录
func (m *Manager) copyFileWithDirs(src, dst string) error {
// 创建父目录
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return fmt.Errorf("创建父目录失败: %w", err)
}
// 打开源文件
srcFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("打开源文件失败: %w", err)
}
defer srcFile.Close()
// 获取源文件信息
srcInfo, err := srcFile.Stat()
if err != nil {
return fmt.Errorf("获取源文件信息失败: %w", err)
}
// 创建目标文件
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode())
if err != nil {
return fmt.Errorf("创建目标文件失败: %w", err)
}
defer dstFile.Close()
// 复制文件内容
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return fmt.Errorf("复制文件内容失败: %w", err)
}
return nil
}
// rollbackUpdate 在更新失败时从备份恢复文件
func (m *Manager) rollbackUpdate(targetPath, backupDir string) error {
// 遍历备份目录并恢复文件
return filepath.Walk(backupDir, func(backupFile string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil // 跳过目录
}
// 计算相对路径
relPath, err := filepath.Rel(backupDir, backupFile)
if err != nil {
return fmt.Errorf("计算相对路径失败: %w", err)
}
// 将文件恢复到目标位置
targetFile := filepath.Join(targetPath, relPath)
if err := m.copyFileWithDirs(backupFile, targetFile); err != nil {
return fmt.Errorf("恢复文件 %s 失败: %w", relPath, err)
}
return nil
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
package main
import (
"testing"
)
// 集成测试将在此处实现
// 此文件目前是占位符
func TestIntegrationPlaceholder(t *testing.T) {
t.Skip("集成测试尚未实现")
}

438
Go_Updater/logger/logger.go Normal file
View File

@@ -0,0 +1,438 @@
package logger
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"sync"
"time"
)
// LogLevel 日志级别
type LogLevel int
const (
DEBUG LogLevel = iota
INFO
WARN
ERROR
)
// String 返回日志级别的字符串表示
func (l LogLevel) String() string {
switch l {
case DEBUG:
return "DEBUG"
case INFO:
return "INFO"
case WARN:
return "WARN"
case ERROR:
return "ERROR"
default:
return "UNKNOWN"
}
}
// Logger 日志记录器接口
type Logger interface {
Debug(msg string, fields ...interface{})
Info(msg string, fields ...interface{})
Warn(msg string, fields ...interface{})
Error(msg string, fields ...interface{})
SetLevel(level LogLevel)
Close() error
}
// FileLogger 文件日志记录器
type FileLogger struct {
mu sync.RWMutex
file *os.File
logger *log.Logger
level LogLevel
maxSize int64 // 最大文件大小(字节)
maxBackups int // 最大备份文件数
logDir string // 日志目录
filename string // 日志文件名
currentSize int64 // 当前文件大小
}
// LoggerConfig 日志配置
type LoggerConfig struct {
Level LogLevel
MaxSize int64 // 最大文件大小字节默认10MB
MaxBackups int // 最大备份文件数默认5
LogDir string // 日志目录
Filename string // 日志文件名
}
// DefaultLoggerConfig 默认日志配置
func DefaultLoggerConfig() *LoggerConfig {
// 获取当前可执行文件目录
exePath, err := os.Executable()
var logDir string
if err != nil {
logDir = "debug"
} else {
exeDir := filepath.Dir(exePath)
logDir = filepath.Join(exeDir, "debug")
}
return &LoggerConfig{
Level: INFO,
MaxSize: 10 * 1024 * 1024, // 10MB
MaxBackups: 5,
LogDir: logDir,
Filename: "AUTO_MAA_Go_Updater.log",
}
}
// NewFileLogger 创建新的文件日志记录器
func NewFileLogger(config *LoggerConfig) (*FileLogger, error) {
if config == nil {
config = DefaultLoggerConfig()
}
// 创建日志目录
if err := os.MkdirAll(config.LogDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create log directory: %w", err)
}
logPath := filepath.Join(config.LogDir, config.Filename)
// 打开或创建日志文件
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, fmt.Errorf("failed to open log file: %w", err)
}
// 获取当前文件大小
stat, err := file.Stat()
if err != nil {
file.Close()
return nil, fmt.Errorf("failed to get file stats: %w", err)
}
logger := &FileLogger{
file: file,
logger: log.New(file, "", 0), // 我们自己处理格式
level: config.Level,
maxSize: config.MaxSize,
maxBackups: config.MaxBackups,
logDir: config.LogDir,
filename: config.Filename,
currentSize: stat.Size(),
}
return logger, nil
}
// formatMessage 格式化日志消息
func (fl *FileLogger) formatMessage(level LogLevel, msg string, fields ...interface{}) string {
timestamp := time.Now().Format("2006-01-02 15:04:05.000")
if len(fields) > 0 {
msg = fmt.Sprintf(msg, fields...)
}
return fmt.Sprintf("[%s] %s %s\n", timestamp, level.String(), msg)
}
// writeLog 写入日志
func (fl *FileLogger) writeLog(level LogLevel, msg string, fields ...interface{}) {
fl.mu.Lock()
defer fl.mu.Unlock()
// 检查日志级别
if level < fl.level {
return
}
formattedMsg := fl.formatMessage(level, msg, fields...)
// 检查是否需要轮转
if fl.currentSize+int64(len(formattedMsg)) > fl.maxSize {
if err := fl.rotate(); err != nil {
// 轮转失败尝试写入stderr
fmt.Fprintf(os.Stderr, "Failed to rotate log: %v\n", err)
}
}
// 写入日志
n, err := fl.file.WriteString(formattedMsg)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to write log: %v\n", err)
return
}
fl.currentSize += int64(n)
fl.file.Sync() // 确保写入磁盘
}
// rotate 轮转日志文件
func (fl *FileLogger) rotate() error {
// 关闭当前文件
if err := fl.file.Close(); err != nil {
return fmt.Errorf("failed to close current log file: %w", err)
}
// 轮转备份文件
if err := fl.rotateBackups(); err != nil {
return fmt.Errorf("failed to rotate backups: %w", err)
}
// 创建新的日志文件
logPath := filepath.Join(fl.logDir, fl.filename)
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("failed to create new log file: %w", err)
}
fl.file = file
fl.logger.SetOutput(file)
fl.currentSize = 0
return nil
}
// rotateBackups 轮转备份文件
func (fl *FileLogger) rotateBackups() error {
basePath := filepath.Join(fl.logDir, fl.filename)
// 删除最老的备份文件
if fl.maxBackups > 0 {
oldestBackup := fmt.Sprintf("%s.%d", basePath, fl.maxBackups)
os.Remove(oldestBackup) // 忽略错误,文件可能不存在
}
// 重命名现有备份文件
for i := fl.maxBackups - 1; i > 0; i-- {
oldName := fmt.Sprintf("%s.%d", basePath, i)
newName := fmt.Sprintf("%s.%d", basePath, i+1)
os.Rename(oldName, newName) // 忽略错误,文件可能不存在
}
// 将当前日志文件重命名为第一个备份
if fl.maxBackups > 0 {
backupName := fmt.Sprintf("%s.1", basePath)
return os.Rename(basePath, backupName)
}
return nil
}
// Debug 记录调试级别日志
func (fl *FileLogger) Debug(msg string, fields ...interface{}) {
fl.writeLog(DEBUG, msg, fields...)
}
// Info 记录信息级别日志
func (fl *FileLogger) Info(msg string, fields ...interface{}) {
fl.writeLog(INFO, msg, fields...)
}
// Warn 记录警告级别日志
func (fl *FileLogger) Warn(msg string, fields ...interface{}) {
fl.writeLog(WARN, msg, fields...)
}
// Error 记录错误级别日志
func (fl *FileLogger) Error(msg string, fields ...interface{}) {
fl.writeLog(ERROR, msg, fields...)
}
// SetLevel 设置日志级别
func (fl *FileLogger) SetLevel(level LogLevel) {
fl.mu.Lock()
defer fl.mu.Unlock()
fl.level = level
}
// Close 关闭日志记录器
func (fl *FileLogger) Close() error {
fl.mu.Lock()
defer fl.mu.Unlock()
if fl.file != nil {
return fl.file.Close()
}
return nil
}
// MultiLogger 多输出日志记录器
type MultiLogger struct {
loggers []Logger
level LogLevel
}
// NewMultiLogger 创建多输出日志记录器
func NewMultiLogger(loggers ...Logger) *MultiLogger {
return &MultiLogger{
loggers: loggers,
level: INFO,
}
}
// Debug 记录调试级别日志
func (ml *MultiLogger) Debug(msg string, fields ...interface{}) {
for _, logger := range ml.loggers {
logger.Debug(msg, fields...)
}
}
// Info 记录信息级别日志
func (ml *MultiLogger) Info(msg string, fields ...interface{}) {
for _, logger := range ml.loggers {
logger.Info(msg, fields...)
}
}
// Warn 记录警告级别日志
func (ml *MultiLogger) Warn(msg string, fields ...interface{}) {
for _, logger := range ml.loggers {
logger.Warn(msg, fields...)
}
}
// Error 记录错误级别日志
func (ml *MultiLogger) Error(msg string, fields ...interface{}) {
for _, logger := range ml.loggers {
logger.Error(msg, fields...)
}
}
// SetLevel 设置日志级别
func (ml *MultiLogger) SetLevel(level LogLevel) {
ml.level = level
for _, logger := range ml.loggers {
logger.SetLevel(level)
}
}
// Close 关闭所有日志记录器
func (ml *MultiLogger) Close() error {
var lastErr error
for _, logger := range ml.loggers {
if err := logger.Close(); err != nil {
lastErr = err
}
}
return lastErr
}
// ConsoleLogger 控制台日志记录器
type ConsoleLogger struct {
writer io.Writer
level LogLevel
}
// NewConsoleLogger 创建控制台日志记录器
func NewConsoleLogger(writer io.Writer) *ConsoleLogger {
if writer == nil {
writer = os.Stdout
}
return &ConsoleLogger{
writer: writer,
level: INFO,
}
}
// formatMessage 格式化控制台日志消息
func (cl *ConsoleLogger) formatMessage(level LogLevel, msg string, fields ...interface{}) string {
timestamp := time.Now().Format("15:04:05")
if len(fields) > 0 {
msg = fmt.Sprintf(msg, fields...)
}
return fmt.Sprintf("[%s] %s %s\n", timestamp, level.String(), msg)
}
// writeLog 写入控制台日志
func (cl *ConsoleLogger) writeLog(level LogLevel, msg string, fields ...interface{}) {
if level < cl.level {
return
}
formattedMsg := cl.formatMessage(level, msg, fields...)
fmt.Fprint(cl.writer, formattedMsg)
}
// Debug 记录调试级别日志
func (cl *ConsoleLogger) Debug(msg string, fields ...interface{}) {
cl.writeLog(DEBUG, msg, fields...)
}
// Info 记录信息级别日志
func (cl *ConsoleLogger) Info(msg string, fields ...interface{}) {
cl.writeLog(INFO, msg, fields...)
}
// Warn 记录警告级别日志
func (cl *ConsoleLogger) Warn(msg string, fields ...interface{}) {
cl.writeLog(WARN, msg, fields...)
}
// Error 记录错误级别日志
func (cl *ConsoleLogger) Error(msg string, fields ...interface{}) {
cl.writeLog(ERROR, msg, fields...)
}
// SetLevel 设置日志级别
func (cl *ConsoleLogger) SetLevel(level LogLevel) {
cl.level = level
}
// Close 关闭控制台日志记录器(无操作)
func (cl *ConsoleLogger) Close() error {
return nil
}
// 全局日志记录器实例
var (
defaultLogger Logger
once sync.Once
)
// GetDefaultLogger 获取默认日志记录器
func GetDefaultLogger() Logger {
once.Do(func() {
fileLogger, err := NewFileLogger(DefaultLoggerConfig())
if err != nil {
// 如果文件日志创建失败,使用控制台日志
defaultLogger = NewConsoleLogger(os.Stderr)
} else {
// 同时输出到文件和控制台
consoleLogger := NewConsoleLogger(os.Stdout)
defaultLogger = NewMultiLogger(fileLogger, consoleLogger)
}
})
return defaultLogger
}
// 便捷函数
func Debug(msg string, fields ...interface{}) {
GetDefaultLogger().Debug(msg, fields...)
}
func Info(msg string, fields ...interface{}) {
GetDefaultLogger().Info(msg, fields...)
}
func Warn(msg string, fields ...interface{}) {
GetDefaultLogger().Warn(msg, fields...)
}
func Error(msg string, fields ...interface{}) {
GetDefaultLogger().Error(msg, fields...)
}
func SetLevel(level LogLevel) {
GetDefaultLogger().SetLevel(level)
}
func Close() error {
return GetDefaultLogger().Close()
}

View File

@@ -0,0 +1,300 @@
package logger
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLogLevel_String(t *testing.T) {
tests := []struct {
level LogLevel
expected string
}{
{DEBUG, "DEBUG"},
{INFO, "INFO"},
{WARN, "WARN"},
{ERROR, "ERROR"},
{LogLevel(999), "UNKNOWN"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
if got := tt.level.String(); got != tt.expected {
t.Errorf("LogLevel.String() = %v, want %v", got, tt.expected)
}
})
}
}
func TestDefaultLoggerConfig(t *testing.T) {
config := DefaultLoggerConfig()
if config.Level != INFO {
t.Errorf("Expected default level INFO, got %v", config.Level)
}
if config.MaxSize != 10*1024*1024 {
t.Errorf("Expected default max size 10MB, got %v", config.MaxSize)
}
if config.MaxBackups != 5 {
t.Errorf("Expected default max backups 5, got %v", config.MaxBackups)
}
if config.Filename != "updater.log" {
t.Errorf("Expected default filename 'updater.log', got %v", config.Filename)
}
}
func TestConsoleLogger(t *testing.T) {
var buf bytes.Buffer
logger := NewConsoleLogger(&buf)
t.Run("log levels", func(t *testing.T) {
logger.SetLevel(DEBUG)
logger.Debug("debug message")
logger.Info("info message")
logger.Warn("warn message")
logger.Error("error message")
output := buf.String()
if !strings.Contains(output, "DEBUG debug message") {
t.Error("Expected debug message in output")
}
if !strings.Contains(output, "INFO info message") {
t.Error("Expected info message in output")
}
if !strings.Contains(output, "WARN warn message") {
t.Error("Expected warn message in output")
}
if !strings.Contains(output, "ERROR error message") {
t.Error("Expected error message in output")
}
})
t.Run("log level filtering", func(t *testing.T) {
buf.Reset()
logger.SetLevel(WARN)
logger.Debug("debug message")
logger.Info("info message")
logger.Warn("warn message")
logger.Error("error message")
output := buf.String()
if strings.Contains(output, "DEBUG") {
t.Error("Debug message should be filtered out")
}
if strings.Contains(output, "INFO") {
t.Error("Info message should be filtered out")
}
if !strings.Contains(output, "WARN warn message") {
t.Error("Expected warn message in output")
}
if !strings.Contains(output, "ERROR error message") {
t.Error("Expected error message in output")
}
})
t.Run("formatted messages", func(t *testing.T) {
buf.Reset()
logger.SetLevel(DEBUG)
logger.Info("formatted message: %s %d", "test", 42)
output := buf.String()
if !strings.Contains(output, "formatted message: test 42") {
t.Error("Expected formatted message in output")
}
})
}
func TestFileLogger(t *testing.T) {
// 创建临时目录
tempDir := t.TempDir()
config := &LoggerConfig{
Level: DEBUG,
MaxSize: 1024, // 1KB for testing rotation
MaxBackups: 3,
LogDir: tempDir,
Filename: "test.log",
}
logger, err := NewFileLogger(config)
if err != nil {
t.Fatalf("Failed to create file logger: %v", err)
}
defer logger.Close()
t.Run("basic logging", func(t *testing.T) {
logger.Info("test message")
logger.Error("error message with %s", "formatting")
// 读取日志文件
logPath := filepath.Join(tempDir, "test.log")
content, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
output := string(content)
if !strings.Contains(output, "INFO test message") {
t.Error("Expected info message in log file")
}
if !strings.Contains(output, "ERROR error message with formatting") {
t.Error("Expected formatted error message in log file")
}
})
t.Run("log rotation", func(t *testing.T) {
// 写入大量数据触发轮转
longMessage := strings.Repeat("a", 200)
for i := 0; i < 10; i++ {
logger.Info("Long message %d: %s", i, longMessage)
}
// 检查是否创建了备份文件
logPath := filepath.Join(tempDir, "test.log")
backupPath := filepath.Join(tempDir, "test.log.1")
if _, err := os.Stat(logPath); os.IsNotExist(err) {
t.Error("Main log file should exist")
}
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
t.Error("Backup log file should exist after rotation")
}
})
}
func TestMultiLogger(t *testing.T) {
var buf1, buf2 bytes.Buffer
logger1 := NewConsoleLogger(&buf1)
logger2 := NewConsoleLogger(&buf2)
multiLogger := NewMultiLogger(logger1, logger2)
multiLogger.SetLevel(INFO)
multiLogger.Info("test message")
multiLogger.Error("error message")
// 检查两个logger都收到了消息
output1 := buf1.String()
output2 := buf2.String()
if !strings.Contains(output1, "INFO test message") {
t.Error("Expected info message in first logger")
}
if !strings.Contains(output1, "ERROR error message") {
t.Error("Expected error message in first logger")
}
if !strings.Contains(output2, "INFO test message") {
t.Error("Expected info message in second logger")
}
if !strings.Contains(output2, "ERROR error message") {
t.Error("Expected error message in second logger")
}
}
func TestFileLoggerRotation(t *testing.T) {
tempDir := t.TempDir()
config := &LoggerConfig{
Level: DEBUG,
MaxSize: 100, // Very small for testing
MaxBackups: 2,
LogDir: tempDir,
Filename: "rotation_test.log",
}
logger, err := NewFileLogger(config)
if err != nil {
t.Fatalf("Failed to create file logger: %v", err)
}
defer logger.Close()
// 写入足够的数据触发多次轮转
for i := 0; i < 20; i++ {
logger.Info("Message %d: %s", i, strings.Repeat("x", 50))
}
// 检查文件存在性
logPath := filepath.Join(tempDir, "rotation_test.log")
backup1Path := filepath.Join(tempDir, "rotation_test.log.1")
backup2Path := filepath.Join(tempDir, "rotation_test.log.2")
backup3Path := filepath.Join(tempDir, "rotation_test.log.3")
if _, err := os.Stat(logPath); os.IsNotExist(err) {
t.Error("Main log file should exist")
}
if _, err := os.Stat(backup1Path); os.IsNotExist(err) {
t.Error("First backup should exist")
}
if _, err := os.Stat(backup2Path); os.IsNotExist(err) {
t.Error("Second backup should exist")
}
// 第三个备份不应该存在MaxBackups=2
if _, err := os.Stat(backup3Path); !os.IsNotExist(err) {
t.Error("Third backup should not exist (exceeds MaxBackups)")
}
}
func TestGlobalLoggerFunctions(t *testing.T) {
// 这个测试比较简单主要确保全局函数不会panic
Debug("debug message")
Info("info message")
Warn("warn message")
Error("error message")
SetLevel(ERROR)
// 这些调用不应该panic
Debug("filtered debug")
Info("filtered info")
Error("visible error")
}
func TestFileLoggerErrorHandling(t *testing.T) {
t.Run("invalid directory", func(t *testing.T) {
// 使用一个真正无效的路径
config := &LoggerConfig{
Level: INFO,
MaxSize: 1024,
MaxBackups: 3,
LogDir: string([]byte{0}), // 无效的路径字符
Filename: "test.log",
}
_, err := NewFileLogger(config)
if err == nil {
t.Error("Expected error when creating logger with invalid directory")
}
})
}
func TestLoggerFormatting(t *testing.T) {
var buf bytes.Buffer
logger := NewConsoleLogger(&buf)
logger.SetLevel(DEBUG)
// 测试时间戳格式
logger.Info("test message")
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) == 0 {
t.Fatal("Expected at least one log line")
}
// 检查格式:[HH:MM:SS] LEVEL message
line := lines[0]
if !strings.Contains(line, "INFO test message") {
t.Errorf("Expected 'INFO test message' in output, got: %s", line)
}
// 检查时间戳格式(简单检查)
if !strings.HasPrefix(line, "[") {
t.Error("Expected log line to start with timestamp in brackets")
}
}

1049
Go_Updater/main.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,189 @@
package version
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"AUTO_MAA_Go_Updater/logger"
)
// VersionInfo 表示来自 version.json 的版本信息
type VersionInfo struct {
MainVersion string `json:"main_version"`
VersionInfo map[string]map[string][]string `json:"version_info"`
}
// ParsedVersion 表示解析后的版本,包含主版本号、次版本号、补丁版本号和测试版本号组件
type ParsedVersion struct {
Major int
Minor int
Patch int
Beta int
}
// VersionManager 处理版本相关操作
type VersionManager struct {
executableDir string
logger logger.Logger
}
// NewVersionManager 创建新的版本管理器
func NewVersionManager() *VersionManager {
execPath, _ := os.Executable()
execDir := filepath.Dir(execPath)
return &VersionManager{
executableDir: execDir,
logger: logger.GetDefaultLogger(),
}
}
// createDefaultVersion 创建默认版本结构 v0.0.0
func (vm *VersionManager) createDefaultVersion() *VersionInfo {
return &VersionInfo{
MainVersion: "0.0.0.0", // 对应 v0.0.0
VersionInfo: make(map[string]map[string][]string),
}
}
// LoadVersionFromFile 从 resources/version.json 加载版本信息并处理回退
func (vm *VersionManager) LoadVersionFromFile() (*VersionInfo, error) {
versionPath := filepath.Join(vm.executableDir, "resources", "version.json")
data, err := os.ReadFile(versionPath)
if err != nil {
if os.IsNotExist(err) {
fmt.Println("未读取到版本信息,使用默认版本进行更新。")
return vm.createDefaultVersion(), nil
}
vm.logger.Warn("读取版本文件 %s 失败: %v将使用默认版本", versionPath, err)
return vm.createDefaultVersion(), nil
}
var versionInfo VersionInfo
if err := json.Unmarshal(data, &versionInfo); err != nil {
vm.logger.Warn("解析版本文件 %s 失败: %v将使用默认版本", versionPath, err)
return vm.createDefaultVersion(), nil
}
vm.logger.Debug("成功从 %s 加载版本信息", versionPath)
return &versionInfo, nil
}
// LoadVersionWithDefault 加载版本信息并保证回退到默认版本
func (vm *VersionManager) LoadVersionWithDefault() *VersionInfo {
versionInfo, err := vm.LoadVersionFromFile()
if err != nil {
// 这在更新的 LoadVersionFromFile 中不应该发生,但添加作为额外安全措施
vm.logger.Error("加载版本文件时出现意外错误: %v使用默认版本", err)
return vm.createDefaultVersion()
}
// 验证我们有一个有效的版本结构
if versionInfo == nil {
vm.logger.Warn("版本信息为空,使用默认版本")
return vm.createDefaultVersion()
}
if versionInfo.MainVersion == "" {
vm.logger.Warn("版本信息主版本为空,使用默认版本")
return vm.createDefaultVersion()
}
if versionInfo.VersionInfo == nil {
vm.logger.Debug("版本信息映射为空,初始化空映射")
versionInfo.VersionInfo = make(map[string]map[string][]string)
}
return versionInfo
}
// ParseVersion 解析版本字符串如 "4.4.1.3" 为组件
func ParseVersion(versionStr string) (*ParsedVersion, error) {
parts := strings.Split(versionStr, ".")
if len(parts) < 3 || len(parts) > 4 {
return nil, fmt.Errorf("无效的版本格式: %s", versionStr)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("无效的主版本号: %s", parts[0])
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("无效的次版本号: %s", parts[1])
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
return nil, fmt.Errorf("无效的补丁版本号: %s", parts[2])
}
beta := 0
if len(parts) == 4 {
beta, err = strconv.Atoi(parts[3])
if err != nil {
return nil, fmt.Errorf("无效的测试版本号: %s", parts[3])
}
}
return &ParsedVersion{
Major: major,
Minor: minor,
Patch: patch,
Beta: beta,
}, nil
}
// ToVersionString 将 ParsedVersion 转换回版本字符串格式
func (pv *ParsedVersion) ToVersionString() string {
if pv.Beta == 0 {
return fmt.Sprintf("%d.%d.%d.0", pv.Major, pv.Minor, pv.Patch)
}
return fmt.Sprintf("%d.%d.%d.%d", pv.Major, pv.Minor, pv.Patch, pv.Beta)
}
// ToDisplayVersion 将版本转换为显示格式 (4.4.0 或 4.4.1-beta3)
func (pv *ParsedVersion) ToDisplayVersion() string {
if pv.Beta == 0 {
return fmt.Sprintf("%d.%d.%d", pv.Major, pv.Minor, pv.Patch)
}
return fmt.Sprintf("%d.%d.%d-beta%d", pv.Major, pv.Minor, pv.Patch, pv.Beta)
}
// GetChannel 根据版本返回渠道 (stable 或 beta)
func (pv *ParsedVersion) GetChannel() string {
if pv.Beta == 0 {
return "stable"
}
return "beta"
}
// IsNewer 检查此版本是否比其他版本更新
func (pv *ParsedVersion) IsNewer(other *ParsedVersion) bool {
if pv.Major != other.Major {
return pv.Major > other.Major
}
if pv.Minor != other.Minor {
return pv.Minor > other.Minor
}
if pv.Patch != other.Patch {
return pv.Patch > other.Patch
}
// 对于相同的主版本号正式版本Beta=0比beta版本Beta>0更新
// 例如4.4.1.0(正式版)> 4.4.1.3beta3
if pv.Beta == 0 && other.Beta > 0 {
return true // 正式版比beta版更新
}
if pv.Beta > 0 && other.Beta == 0 {
return false // beta版比正式版旧
}
// 如果都是beta版本比较beta版本号
return pv.Beta > other.Beta
}

View File

@@ -0,0 +1,366 @@
package version
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestParseVersion(t *testing.T) {
tests := []struct {
input string
expected *ParsedVersion
hasError bool
}{
{"4.4.0.0", &ParsedVersion{4, 4, 0, 0}, false},
{"4.4.1.3", &ParsedVersion{4, 4, 1, 3}, false},
{"1.2.3", &ParsedVersion{1, 2, 3, 0}, false},
{"invalid", nil, true},
{"1.2", nil, true},
{"1.2.3.4.5", nil, true},
}
for _, test := range tests {
result, err := ParseVersion(test.input)
if test.hasError {
if err == nil {
t.Errorf("Expected error for input %s, but got none", test.input)
}
continue
}
if err != nil {
t.Errorf("Unexpected error for input %s: %v", test.input, err)
continue
}
if result.Major != test.expected.Major ||
result.Minor != test.expected.Minor ||
result.Patch != test.expected.Patch ||
result.Beta != test.expected.Beta {
t.Errorf("For input %s, expected %+v, got %+v", test.input, test.expected, result)
}
}
}
func TestToDisplayVersion(t *testing.T) {
tests := []struct {
version *ParsedVersion
expected string
}{
{&ParsedVersion{4, 4, 0, 0}, "v4.4.0"},
{&ParsedVersion{4, 4, 1, 3}, "v4.4.1-beta3"},
{&ParsedVersion{1, 2, 3, 0}, "v1.2.3"},
{&ParsedVersion{1, 2, 3, 5}, "v1.2.3-beta5"},
}
for _, test := range tests {
result := test.version.ToDisplayVersion()
if result != test.expected {
t.Errorf("For version %+v, expected %s, got %s", test.version, test.expected, result)
}
}
}
func TestGetChannel(t *testing.T) {
tests := []struct {
version *ParsedVersion
expected string
}{
{&ParsedVersion{4, 4, 0, 0}, "stable"},
{&ParsedVersion{4, 4, 1, 3}, "beta"},
{&ParsedVersion{1, 2, 3, 0}, "stable"},
{&ParsedVersion{1, 2, 3, 1}, "beta"},
}
for _, test := range tests {
result := test.version.GetChannel()
if result != test.expected {
t.Errorf("For version %+v, expected channel %s, got %s", test.version, test.expected, result)
}
}
}
func TestIsNewer(t *testing.T) {
tests := []struct {
v1 *ParsedVersion
v2 *ParsedVersion
expected bool
}{
{&ParsedVersion{4, 4, 1, 0}, &ParsedVersion{4, 4, 0, 0}, true},
{&ParsedVersion{4, 4, 0, 0}, &ParsedVersion{4, 4, 1, 0}, false},
{&ParsedVersion{4, 4, 1, 3}, &ParsedVersion{4, 4, 1, 2}, true},
{&ParsedVersion{4, 4, 1, 2}, &ParsedVersion{4, 4, 1, 3}, false},
{&ParsedVersion{4, 4, 1, 0}, &ParsedVersion{4, 4, 1, 0}, false},
}
for _, test := range tests {
result := test.v1.IsNewer(test.v2)
if result != test.expected {
t.Errorf("For %+v.IsNewer(%+v), expected %t, got %t", test.v1, test.v2, test.expected, result)
}
}
}
func TestLoadVersionFromFile(t *testing.T) {
// Create a temporary directory
tempDir, err := os.MkdirTemp("", "version_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
// Create resources directory
resourcesDir := filepath.Join(tempDir, "resources")
if err := os.MkdirAll(resourcesDir, 0755); err != nil {
t.Fatal(err)
}
// Create test version file
versionData := VersionInfo{
MainVersion: "4.4.1.3",
VersionInfo: map[string]map[string][]string{
"4.4.1.3": {
"修复BUG": {"移除崩溃弹窗机制"},
},
},
}
data, err := json.Marshal(versionData)
if err != nil {
t.Fatal(err)
}
versionFile := filepath.Join(resourcesDir, "version.json")
if err := os.WriteFile(versionFile, data, 0644); err != nil {
t.Fatal(err)
}
// Create version manager with custom executable directory and logger
vm := NewVersionManager()
vm.executableDir = tempDir
// Test loading version
result, err := vm.LoadVersionFromFile()
if err != nil {
t.Fatalf("Failed to load version: %v", err)
}
if result.MainVersion != "4.4.1.3" {
t.Errorf("Expected main version 4.4.1.3, got %s", result.MainVersion)
}
if len(result.VersionInfo) != 1 {
t.Errorf("Expected 1 version info entry, got %d", len(result.VersionInfo))
}
}
func TestLoadVersionFromFileNotFound(t *testing.T) {
// Create a temporary directory without version file
tempDir, err := os.MkdirTemp("", "version_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
// Create version manager with custom executable directory and logger
vm := NewVersionManager()
vm.executableDir = tempDir
// Test loading version (should now return default version instead of error)
result, err := vm.LoadVersionFromFile()
if err != nil {
t.Errorf("Expected no error with fallback mechanism, but got: %v", err)
}
// Should return default version
if result.MainVersion != "0.0.0.0" {
t.Errorf("Expected default version 0.0.0.0, got %s", result.MainVersion)
}
if result.VersionInfo == nil {
t.Error("Expected initialized VersionInfo map, got nil")
}
}
func TestLoadVersionWithDefault(t *testing.T) {
// Create a temporary directory
tempDir, err := os.MkdirTemp("", "version_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
// Create version manager with custom executable directory
vm := NewVersionManager()
vm.executableDir = tempDir
// Test loading version with default (no file exists)
result := vm.LoadVersionWithDefault()
if result == nil {
t.Fatal("Expected non-nil result from LoadVersionWithDefault")
}
if result.MainVersion != "0.0.0.0" {
t.Errorf("Expected default version 0.0.0.0, got %s", result.MainVersion)
}
if result.VersionInfo == nil {
t.Error("Expected initialized VersionInfo map, got nil")
}
}
func TestLoadVersionWithDefaultValidFile(t *testing.T) {
// Create a temporary directory
tempDir, err := os.MkdirTemp("", "version_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
// Create resources directory
resourcesDir := filepath.Join(tempDir, "resources")
if err := os.MkdirAll(resourcesDir, 0755); err != nil {
t.Fatal(err)
}
// Create test version file
versionData := VersionInfo{
MainVersion: "4.4.1.3",
VersionInfo: map[string]map[string][]string{
"4.4.1.3": {
"修复BUG": {"移除崩溃弹窗机制"},
},
},
}
data, err := json.Marshal(versionData)
if err != nil {
t.Fatal(err)
}
versionFile := filepath.Join(resourcesDir, "version.json")
if err := os.WriteFile(versionFile, data, 0644); err != nil {
t.Fatal(err)
}
// Create version manager with custom executable directory
vm := NewVersionManager()
vm.executableDir = tempDir
// Test loading version with default (valid file exists)
result := vm.LoadVersionWithDefault()
if result == nil {
t.Fatal("Expected non-nil result from LoadVersionWithDefault")
}
if result.MainVersion != "4.4.1.3" {
t.Errorf("Expected version 4.4.1.3, got %s", result.MainVersion)
}
if len(result.VersionInfo) != 1 {
t.Errorf("Expected 1 version info entry, got %d", len(result.VersionInfo))
}
}
func TestLoadVersionFromFileCorrupted(t *testing.T) {
// Create a temporary directory
tempDir, err := os.MkdirTemp("", "version_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
// Create resources directory
resourcesDir := filepath.Join(tempDir, "resources")
if err := os.MkdirAll(resourcesDir, 0755); err != nil {
t.Fatal(err)
}
// Create corrupted version file
versionFile := filepath.Join(resourcesDir, "version.json")
if err := os.WriteFile(versionFile, []byte("invalid json content"), 0644); err != nil {
t.Fatal(err)
}
// Create version manager with custom executable directory
vm := NewVersionManager()
vm.executableDir = tempDir
// Test loading version (should return default version for corrupted file)
result, err := vm.LoadVersionFromFile()
if err != nil {
t.Errorf("Expected no error with fallback mechanism for corrupted file, but got: %v", err)
}
// Should return default version
if result.MainVersion != "0.0.0.0" {
t.Errorf("Expected default version 0.0.0.0 for corrupted file, got %s", result.MainVersion)
}
if result.VersionInfo == nil {
t.Error("Expected initialized VersionInfo map for corrupted file, got nil")
}
}
func TestLoadVersionWithDefaultCorrupted(t *testing.T) {
// Create a temporary directory
tempDir, err := os.MkdirTemp("", "version_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
// Create resources directory
resourcesDir := filepath.Join(tempDir, "resources")
if err := os.MkdirAll(resourcesDir, 0755); err != nil {
t.Fatal(err)
}
// Create corrupted version file
versionFile := filepath.Join(resourcesDir, "version.json")
if err := os.WriteFile(versionFile, []byte("invalid json content"), 0644); err != nil {
t.Fatal(err)
}
// Create version manager with custom executable directory
vm := NewVersionManager()
vm.executableDir = tempDir
// Test loading version with default (corrupted file)
result := vm.LoadVersionWithDefault()
if result == nil {
t.Fatal("Expected non-nil result from LoadVersionWithDefault for corrupted file")
}
if result.MainVersion != "0.0.0.0" {
t.Errorf("Expected default version 0.0.0.0 for corrupted file, got %s", result.MainVersion)
}
if result.VersionInfo == nil {
t.Error("Expected initialized VersionInfo map for corrupted file, got nil")
}
}
func TestCreateDefaultVersion(t *testing.T) {
vm := NewVersionManager()
result := vm.createDefaultVersion()
if result == nil {
t.Fatal("Expected non-nil result from createDefaultVersion")
}
if result.MainVersion != "0.0.0.0" {
t.Errorf("Expected default version 0.0.0.0, got %s", result.MainVersion)
}
if result.VersionInfo == nil {
t.Error("Expected initialized VersionInfo map, got nil")
}
if len(result.VersionInfo) != 0 {
t.Errorf("Expected empty VersionInfo map, got %d entries", len(result.VersionInfo))
}
}

View File

@@ -0,0 +1,19 @@
package version
import (
"runtime"
)
var (
// Version 应用程序的当前版本
Version = "1.0.0"
// BuildTime 在构建时设置
BuildTime = "unknown"
// GitCommit 在构建时设置
GitCommit = "unknown"
// GoVersion 用于构建的 Go 版本
GoVersion = runtime.Version()
)

313
README.md
View File

@@ -1,262 +1,95 @@
# AUTO_MAA
MAA多账号管理与自动化软件
<h1 align="center">AUTO_MAA</h1>
<p align="center">
MAA多账号管理与自动化软件<br><br>
<img alt="软件图标" src="https://github.com/DLmaster361/AUTO_MAA/blob/main/resources/images/AUTO_MAA.png">
</p>
!["软件图标"](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/AUTO_MAA.png "软件图标")
---
----------------------------------------------------------------------------------------------
<p align="center">
<a href="https://github.com/DLmaster361/AUTO_MAA/stargazers"><img alt="GitHub Stars" src="https://img.shields.io/github/stars/DLmaster361/AUTO_MAA?style=flat-square"></a>
<a href="https://github.com/DLmaster361/AUTO_MAA/network"><img alt="GitHub Forks" src="https://img.shields.io/github/forks/DLmaster361/AUTO_MAA?style=flat-square"></a>
<a href="https://github.com/DLmaster361/AUTO_MAA/releases/latest"><img alt="GitHub Downloads" src="https://img.shields.io/github/downloads/DLmaster361/AUTO_MAA/total?style=flat-square"></a>
<a href="https://github.com/DLmaster361/AUTO_MAA/issues"><img alt="GitHub Issues" src="https://img.shields.io/github/issues/DLmaster361/AUTO_MAA?style=flat-square"></a>
<a href="https://github.com/DLmaster361/AUTO_MAA/graphs/contributors"><img alt="GitHub Contributors" src="https://img.shields.io/github/contributors/DLmaster361/AUTO_MAA?style=flat-square"></a>
<a href="https://github.com/DLmaster361/AUTO_MAA/blob/main/LICENSE"><img alt="GitHub License" src="https://img.shields.io/github/license/DLmaster361/AUTO_MAA?style=flat-square"></a>
<a href="https://deepwiki.com/DLmaster361/AUTO_MAA"><img alt="DeepWiki" src="https://deepwiki.com/badge.svg"></a>
<a href="https://mirrorchyan.com/zh/projects?rid=AUTO_MAA&source=auto_maa-readme"><img alt="mirrorc" src="https://img.shields.io/badge/Mirror%E9%85%B1-%239af3f6?logo=countingworkspro&logoColor=4f46e5"></a>
</p>
## 软件介绍
### 性质
本软件是明日方舟第三方软件`MAA`的第三方工具即第3<sup>3</sup>方软件。旨在优化MAA多账号功能体验并通过一些方法解决MAA项目未能解决的部分问题提高代理的稳定性。
- **集中管理**一站式管理多个MAA脚本与多个用户配置和凌乱的散装脚本窗口说再见
- **无人值守**自动处理MAA相关报错再也不用为代理任务卡死时自己不在电脑旁烦恼啦
- **配置灵活**:通过调度队列与脚本的组合,自由实现您能想到的所有调度需求!
- **信息统计**:自动统计用户的公招与关卡掉落物,看看这个月您的收益是多少!
### 原理
本软件可以存储多个明日方舟账号数据,并通过以下流程实现代理功能:
1. **配置:** 根据对应用户的配置信息生成配置文件并将其导入MAA。
2. **监测:** 在MAA开始代理后持续读取MAA的日志以判断其运行状态。当软件认定MAA出现异常时通过重启MAA使之仍能继续完成任务。
3. **循环:** 重复上述步骤使MAA依次完成各个用户的自动代理任务。
### 优势
- **高效稳定**:通过日志监测、异常处理等机制,保障代理任务顺利完成。
- **简洁易用**:无需手动修改配置文件,实现自动化调度与多开管理。
- **兼容扩展**:支持 MAA 几乎所有的配置选项,满足不同用户需求。
## 重要声明
本软件是一个外部工具旨在优化MAA多账号功能体验。该软件包可以存储明日方舟多账号数据并通过修改MAA配置文件、读取MAA日志等行为自动完成多账号代理。本开发团队承诺不会修改明日方舟游戏本体与相关配置文件。
本项目使用GPL开源相关细则如下
本开发团队承诺,不会修改明日方舟游戏本体与相关配置文件。本项目使用GPL开源相关细则如下
- **作者:** AUTO_MAA软件作者为DLmaster、DLmaster361或DLmaster_361以上均指代同一人。
- **使用:** AUTO_MAA使用者可以按自己的意愿自由使用本软件。依据GPL对于由此可能产生的损失AUTO_MAA项目组不负任何责任
- **分发:** AUTO_MAA允许任何人自由分发本软件包括进行商业活动牟利。但所有分发者必须遵循GPL向接收者提供本软件项目地址、完整的软件源码与GPL协议原文违反者可能会被追究法律责任
- **传播:** AUTO_MAA原则上允许传播者自由传播本软件。由于软件性质项目组不希望发现任何人在明日方舟官方媒体包括官方媒体账号与森空岛社区等或明日方舟游戏相关内容包括同好群、线下活动与游戏内容讨论等下提及AUTO_MAA或MAA希望各位理解
- **衍生:** AUTO_MAA允许任何人对软件本体或软件部分代码进行二次开发或利用。但依据GPL相关成果也必须使用GPL开源
- **授权** 如果希望在使用AUTO_MAA的相关成果后仍保持自己的项目闭源请在Issues中说明来意。得到项目组认可后我们可以提供另一份使用不同协议的代码此协议主要内容如下被授权者可以自由使用该代码并维持闭源被授权者必须定期为AUTO_MAA作出贡献
- **贡献** 不论是直接参与软件的维护编写或是撰写文档、测试、反馈BUG、给出建议、参与讨论都为AUTO_MAA项目的发展完善做出了不可忽视的贡献。项目组提倡各位贡献者遵照GitHub开源社区惯例发布Issues参与项目。避免私信或私发邮件安全性漏洞或敏感问题除外以帮助更多用户
- **使用:** AUTO_MAA使用者可以按自己的意愿自由使用本软件。依据GPL对于由此可能产生的损失AUTO_MAA项目组不负任何责任
- **分发:** AUTO_MAA允许任何人自由分发本软件包括进行商业活动牟利。若为直接分发本软件,必须遵循GPL向接收者提供本软件项目地址、完整的软件源码与GPL协议原文若为修改软件后进行分发必须遵循GPL向接收者提供本软件项目地址、修改前的完整软件源码副本与GPL协议原文,违反者可能会被追究法律责任
- **传播:** AUTO_MAA原则上允许传播者自由传播本软件,但无论在何种传播过程中,不得删除项目作者与开发者所留版权声明,不得隐瞒项目作者与相关开发者的存在。由于软件性质项目组不希望发现任何人在明日方舟官方媒体包括官方媒体账号与森空岛社区等或明日方舟游戏相关内容包括同好群、线下活动与游戏内容讨论等下提及AUTO_MAA或MAA希望各位理解
- **衍生:** AUTO_MAA允许任何人对软件本体或软件部分代码进行二次开发或利用。但依据GPL相关成果再次分发时也必须使用GPL或兼容的协议开源
- **贡献** 不论是直接参与软件的维护编写或是撰写文档、测试、反馈BUG、给出建议、参与讨论都为AUTO_MAA项目的发展完善做出了不可忽视的贡献。项目组提倡各位贡献者遵照GitHub开源社区惯例发布Issues参与项目。避免私信或私发邮件安全性漏洞或敏感问题除外以帮助更多用户。
- **图像** `AUTO_MAA主页默认图像` 并不适用开源协议,著作权归 [NARINpopo](https://space.bilibili.com/1877154) 画师所有,商业使用权归 [DLmaster (@DLmaster361)](https://github.com/DLmaster361) 所有,软件用户仅拥有非商业使用权。不得以开源协议已授权为由在未经授权的情况下使用 `AUTO_MAA主页默认图像`,不得在未经授权的情况下将 `AUTO_MAA主页默认图像` 用于任何商业用途。
以上细则是本项目对GPL的相关补充与强调。未提及的以GPL为准发生冲突的以GPL为准。如有不清楚的部分请发Issues询问。若发生纠纷相关内容也没有在Issues上提及的项目组拥有最终解释权
以上细则是本项目对GPL的相关补充与强调。未提及的以GPL为准发生冲突的以本细则为准。如有不清楚的部分请发Issues询问。若发生纠纷相关内容也没有在Issues上提及的项目组拥有最终解释权
**注意**
- 由于本软件有修改其它目录JSON文件等行为使用前请将AUTO_MAA添加入Windows Defender信任区以及防病毒软件的信任区或开发者目录避免被误杀
- 如程序无法正常启动,请删除`state`目录下所有文件后重试
---
## 安装与配置MAA
# 使用方法
本软件是MAA的外部工具需要安装配置MAA后才能使用。
访问AUTO_MAA官方文档站以获取使用指南和项目相关信息
### MAA安装
- [AUTO_MAA官方文档站](https://clozya.github.io/AUTOMAA_docs)
什么是MAA [官网](https://maa.plus/)/[GitHub](https://github.com/CHNZYX/Auto_Simulated_Universe/archive/refs/heads/main.zip)
---
MAA下载地址 [GitHub下载](https://github.com/MaaAssistantArknights/MaaAssistantArknights/releases)
# 关于
### MAA配置
## 项目开发情况
1.完成MAA的adb配置等基本配置
可在[《AUTO_MAA开发者协作文档》](https://docs.qq.com/aio/DQ3Z5eHNxdmxFQmZX)的`开发任务`页面中查看开发进度。
2.在“完成后”菜单选择“退出MAA和模拟器”。勾选“手动输入关卡名”和“无限吃48小时内过期的理智药”
## 代码签名策略Code signing policy
![MAA配置1](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/MAA配置1.png "MAA配置1")
Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/)
3.确保当前配置名为“Default”取消所有“定时执行”
- 审批人Approvers: [DLmaster (@DLmaster361)](https://github.com/DLmaster361)
![MAA配置2](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/MAA配置2.png "MAA配置2")
## 隐私政策Privacy policy
4.取消勾选“开机自启动MAA”勾选“启动MAA后直接运行”和“启动MAA后自动开启模拟器”。配置自己模拟器所在的位置并根据实际情况填写“等待模拟器启动时间”建议预留10s以防意外。如果是多开用户需要填写“附加命令”具体填写值参见多开模拟器对应快捷方式路径如“-v 1”
除非用户、安装者或使用者特别要求,否则本程序不会将任何信息传输到其他网络系统
![MAA配置3](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/MAA配置3.png "MAA配置3")
This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it.
5.勾选“定时检查更新”、“自动下载更新包”和“自动安装更新包”
## 特别鸣谢
![MAA配置4](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/MAA配置4.png "MAA配置4")
- 下载服务器:由[AoXuan (@ClozyA)](https://github.com/ClozyA) 个人为项目赞助。
## 下载AUTO_MAA软件包 [![](https://img.shields.io/github/downloads/DLmaster361/AUTO_MAA/total?color=66ccff)](https://github.com/DLmaster361/AUTO_MAA/releases)
- EXE签名: 由 [SignPath.io](https://signpath.io/)提供免费代码签名,签名来自[SignPath Foundation](https://signpath.org/)。
GitHub下载地址 [GitHub下载](https://github.com/DLmaster361/AUTO_MAA/releases)
## 配置用户信息与相关参数
**注意:** 当前所有的密码输入部分都存在一点“小问题”请在输入密码时避免输入Delete、F12、Tab等功能键。
-------------------------------------------------
### 第一次启动
双击启动`manage.exe`输入MAA所在文件夹路径并回车注意使用斜杠的种类不要使用反斜杠然后设置管理密钥密钥可以包含字母大小写与特殊字符
![信息配置1](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置1.png "信息配置1")
管理密钥是解密用户密码的唯一凭证,与数据库绑定。密钥丢失或`data/key/`目录下任一文件损坏都将导致解密无法正常进行。
本项目采用自主开发的混合加密模式,项目组也无法找回您的管理密钥或修复`data/key/`目录下的文件。如果不幸的事发生,建议您删除`data/data.db`重新录入信息。
### 添加用户
输入“+”以开始添加用户。依次输入:
**用户名:** 管理用户的惟一凭证
**手机号码:** 允许隐去中间四位以“****”代替
**代理天数:** 这个还要我解释吗?
![信息配置2](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置2.png "信息配置2")
### 删除用户
输入用户名+“-”以删除用户。格式:
```
用户名 -
```
![信息配置3](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置3.png "信息配置3")
### 配置用户状态
**启用代理:** 输入用户名+“y”以启用该用户的代理。格式
```
用户名 y
```
![信息配置4](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置4.png "信息配置4")
**禁用代理:** 输入用户名+“n”以禁用该用户的代理。格式
```
用户名 n
```
![信息配置5](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置5.png "信息配置5")
### 续期
输入用户名+续期天数+“+”以延长该用户的代理天数。格式:
```
用户名 续期天数 +
```
![信息配置6](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置6.png "信息配置6")
### 修改刷取关卡
输入用户名+关卡号+“~”以更改该用户的代理关卡。格式:
```
用户名 关卡号 ~
```
![信息配置7](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置7.png "信息配置7")
**特别的:**
你可以自定义关卡号替换方案。程序会读取`gameid.txt`中的数据,依据此进行关卡号的替换,便于常用关卡的使用。`gameid.txt`在初始已经存储了一些常用资源本的替代方案。
![gameid](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/gameid.png "gameid")
### 设置MAA路径
输入“/”+新的MAA文件夹路径以修改MAA安装位置的配置。格式
```
/新的MAA文件夹路径
```
**注意:** /’与路径间没有空格,路径同样不能使用反斜杠
![信息配置8](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置8.png "信息配置8")
### 设置启动时间
**添加启动时间:** 输入“:+”+时间以添加定时启动时间。格式:
```
:+小时:分钟
```
**注意:** 所有输入间没有空格
![信息配置9](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置9.png "信息配置9")
**删除启动时间:** 输入“:-”+时间以删除定时启动时间。格式:
```
:-小时:分钟
```
**注意:** 所有输入间没有空格
![信息配置10](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置10.png "信息配置10")
### 检索信息
**检索所有信息:** `manage.exe`打开时会打印所有用户与配置信息。除此之外你可以通过输入“all ?”以打印所有信息,如下:
```
all ?
```
![信息配置11](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置11.png "信息配置11")
**检索MAA路径** 输入“maa ?”以检索MAA安装路径如下
```
maa ?
```
![信息配置12](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置12.png "信息配置12")
**检索启动时间:** 输入“time ?”以检索定时启动的时间,如下:
```
time ?
```
![信息配置13](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置13.png "信息配置13")
**检索指定用户:** 输入用户名+“?”以检索指定用户信息,如下:
```
用户名 ?
```
**注意:** 由于需要检索用户密码,每一次`manage.exe`启动后的首次查询需要验证管理密钥。为了方便操作,之后的查询不会再要求重复验证。因此,完成密码查询后,请及时关闭`manage.exe`
![信息配置14](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置14.png "信息配置14")
### 修改管理密钥
输入“*”以开始修改管理密钥。依次输入:
**旧管理密钥:** 当数据库中没有存储用户信息时,允许跳过验证直接配置新管理密钥
**新管理密钥:** 请妥善保管,丢失无法找回。
![信息配置15](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/信息配置15.png "信息配置15")
### 退出
输入“-”以退出`manage.exe`,如下:
```
-
```
## 运行代理
### 直接运行
双击`run.exe`直接运行
### 定时运行
双击`AUTO_MAA.exe`打开,不要关闭。它会读取设定时间,在该时刻自动运行
**注意:** 将优先进行剿灭代理
## 关于
项目图标由文心一格AI生成
----------------------------------------------------------------------------------------------
欢迎加入AUTO_MAA项目组欢迎反馈bug
QQ群暂时没有
----------------------------------------------------------------------------------------------
如果喜欢本项目,可以打赏送作者一杯咖啡喵!
![打赏](https://github.com/DLmaster361/AUTO_MAA/blob/main/res/README/payid.png "打赏")
----------------------------------------------------------------------------------------------
## 贡献者
感谢以下贡献者对本项目做出的贡献
@@ -271,4 +104,16 @@ QQ群暂时没有
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=DLmaster361/AUTO_MAA&type=Date)](https://star-history.com/#DLmaster361/AUTO_MAA&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=DLmaster361/AUTO_MAA&type=Date)](https://star-history.com/#DLmaster361/AUTO_MAA&Date)
## 交流与赞助
欢迎加入AUTO_MAA项目组欢迎反馈bug
- QQ交流群[957750551](https://qm.qq.com/q/bd9fISNoME)
---
如果喜欢这个项目的话,给作者来杯咖啡吧!
![payid](resources/images/README/payid.png "payid")

49
app/__init__.py Normal file
View File

@@ -0,0 +1,49 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA主程序包
v4.4
作者DLmaster_361
"""
__version__ = "4.2.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .core import QueueConfig, MaaConfig, MaaUserConfig, Task, TaskManager, MainTimer
from .models import MaaManager
from .services import Notify, Crypto, System
from .ui import AUTO_MAA
__all__ = [
"QueueConfig",
"MaaConfig",
"MaaUserConfig",
"Task",
"TaskManager",
"MainTimer",
"MaaManager",
"Notify",
"Crypto",
"System",
"AUTO_MAA",
]

63
app/core/__init__.py Normal file
View File

@@ -0,0 +1,63 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA核心组件包
v4.4
作者DLmaster_361
"""
__version__ = "4.2.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .config import (
QueueConfig,
MaaConfig,
MaaUserConfig,
MaaPlanConfig,
GeneralConfig,
GeneralSubConfig,
Config,
)
from .logger import logger
from .main_info_bar import MainInfoBar
from .network import Network
from .sound_player import SoundPlayer
from .task_manager import Task, TaskManager
from .timer import MainTimer
__all__ = [
"Config",
"QueueConfig",
"MaaConfig",
"MaaUserConfig",
"MaaPlanConfig",
"GeneralConfig",
"GeneralSubConfig",
"logger",
"MainInfoBar",
"Network",
"SoundPlayer",
"Task",
"TaskManager",
"MainTimer",
]

1882
app/core/config.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
# <AUTO_MAA:A MAA Multi Account Management and Automation Tool>
# Copyright © <2024> <DLmaster361>
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
@@ -16,10 +16,19 @@
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# DLmaster_361@163.com
# Contact: DLmaster_361@163.com
import os
"""
AUTO_MAA
AUTO_MAA日志组件
v4.4
作者DLmaster_361
"""
os.system("pyinstaller -F --version-file res/info.txt -i res/AUTO_MAA.ico manage.py")
os.system("pyinstaller -F --version-file res/info.txt -i res/AUTO_MAA.ico run.py")
os.system("pyinstaller -F --version-file res/info.txt -i res/AUTO_MAA.ico AUTO_MAA.py")
from loguru import logger as _logger
# 设置日志 module 字段默认值
logger = _logger.patch(
lambda record: record["extra"].setdefault("module", "未知模块") or True
)
logger.remove(0)

109
app/core/main_info_bar.py Normal file
View File

@@ -0,0 +1,109 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA信息通知栏
v4.4
作者DLmaster_361
"""
from PySide6.QtCore import Qt
from qfluentwidgets import InfoBar, InfoBarPosition
from .logger import logger
from .config import Config
from .sound_player import SoundPlayer
class _MainInfoBar:
"""信息通知栏"""
# 模式到 InfoBar 方法的映射
mode_mapping = {
"success": InfoBar.success,
"warning": InfoBar.warning,
"error": InfoBar.error,
"info": InfoBar.info,
}
def push_info_bar(
self, mode: str, title: str, content: str, time: int, if_force: bool = False
) -> None:
"""
推送消息到吐司通知栏
:param mode: 通知栏模式,支持 "success", "warning", "error", "info"
:param title: 通知栏标题
:type title: str
:param content: 通知栏内容
:type content: str
:param time: 显示时长,单位为毫秒
:type time: int
:param if_force: 是否强制推送
:type if_force: bool
"""
if Config.main_window is None:
logger.error("信息通知栏未设置父窗口", module="吐司通知栏")
return None
# 根据 mode 获取对应的 InfoBar 方法
info_bar_method = self.mode_mapping.get(mode)
if not info_bar_method:
logger.error(f"未知的通知栏模式: {mode}", module="吐司通知栏")
return None
if Config.main_window.isVisible():
# 主窗口可见时直接推送通知
info_bar_method(
title=title,
content=content,
orient=Qt.Horizontal,
isClosable=True,
position=InfoBarPosition.TOP_RIGHT,
duration=time,
parent=Config.main_window,
)
elif if_force:
# 如果主窗口不可见且强制推送,则录入消息队列等待窗口显示后推送
info_bar_item = {
"mode": mode,
"title": title,
"content": content,
"time": time,
}
if info_bar_item not in Config.info_bar_list:
Config.info_bar_list.append(info_bar_item)
logger.info(
f"主窗口不可见,已将通知栏消息录入队列: {info_bar_item}",
module="吐司通知栏",
)
if mode == "warning":
SoundPlayer.play("发生异常")
if mode == "error":
SoundPlayer.play("发生错误")
MainInfoBar = _MainInfoBar()

308
app/core/network.py Normal file
View File

@@ -0,0 +1,308 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA网络请求线程
v4.4
作者DLmaster_361
"""
from PySide6.QtCore import QObject, QThread, QEventLoop
import re
import time
import requests
import truststore
from pathlib import Path
from typing import Dict
from .logger import logger
class NetworkThread(QThread):
"""网络请求线程类"""
max_retries = 3
timeout = 10
backoff_factor = 0.1
def __init__(
self,
mode: str,
url: str,
path: Path = None,
files: Dict = None,
data: Dict = None,
) -> None:
super().__init__()
self.setObjectName(
f"NetworkThread-{mode}-{re.sub(r'(&cdk=)[^&]+(&)', r'\1******\2', url)}"
)
logger.info(f"创建网络请求线程: {self.objectName()}", module="网络请求子线程")
self.mode = mode
self.url = url
self.path = path
self.files = files
self.data = data
from .config import Config
self.proxies = {
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
}
self.status_code = None
self.response_json = None
self.error_message = None
self.loop = QEventLoop()
truststore.inject_into_ssl() # 信任系统证书
@logger.catch
def run(self) -> None:
"""运行网络请求线程"""
if self.mode == "get":
self.get_json(self.url)
elif self.mode == "get_file":
self.get_file(self.url, self.path)
elif self.mode == "upload_file":
self.upload_file(self.url, self.files, self.data)
def get_json(self, url: str) -> None:
"""
通过get方法获取json数据
:param url: 请求的URL
"""
logger.info(f"子线程 {self.objectName()} 开始网络请求", module="网络请求子线程")
response = None
for _ in range(self.max_retries):
try:
response = requests.get(url, timeout=self.timeout, proxies=self.proxies)
self.status_code = response.status_code
self.response_json = response.json()
self.error_message = None
break
except Exception as e:
self.status_code = response.status_code if response else None
self.response_json = None
self.error_message = str(e)
logger.exception(
f"子线程 {self.objectName()} 网络请求失败:{e},第{_+1}次尝试",
module="网络请求子线程",
)
time.sleep(self.backoff_factor)
self.loop.quit()
def get_file(self, url: str, path: Path) -> None:
"""
通过get方法下载文件到指定路径
:param url: 请求的URL
:param path: 下载文件的保存路径
"""
logger.info(f"子线程 {self.objectName()} 开始下载文件", module="网络请求子线程")
response = None
try:
response = requests.get(url, timeout=self.timeout, proxies=self.proxies)
if response.status_code == 200:
with open(path, "wb") as file:
file.write(response.content)
self.status_code = response.status_code
self.error_message = None
else:
self.status_code = response.status_code
self.error_message = f"下载失败,状态码: {response.status_code}"
except Exception as e:
self.status_code = response.status_code if response else None
self.error_message = str(e)
logger.exception(
f"子线程 {self.objectName()} 网络请求失败:{e}", module="网络请求子线程"
)
self.loop.quit()
def upload_file(self, url: str, files: Dict, data: Dict = None) -> None:
"""
通过POST方法上传文件
:param url: 请求的URL
:param files: 文件字典,格式为 {'file': ('filename', file_obj, 'content_type')}
:param data: 表单数据字典
"""
logger.info(f"子线程 {self.objectName()} 开始上传文件", module="网络请求子线程")
response = None
for _ in range(self.max_retries):
try:
response = requests.post(
url,
files=files,
data=data,
timeout=self.timeout,
proxies=self.proxies,
)
self.status_code = response.status_code
# 尝试解析JSON响应
try:
self.response_json = response.json()
except ValueError:
# 如果不是JSON格式保存文本内容
self.response_json = {"text": response.text}
self.error_message = None
break
except Exception as e:
self.status_code = response.status_code if response else None
self.response_json = None
self.error_message = str(e)
logger.exception(
f"子线程 {self.objectName()} 文件上传失败:{e},第{_+1}次尝试",
module="网络请求子线程",
)
time.sleep(self.backoff_factor)
self.loop.quit()
class _Network(QObject):
"""网络请求线程管理类"""
def __init__(self) -> None:
super().__init__()
self.task_queue = []
def add_task(
self,
mode: str,
url: str,
path: Path = None,
files: Dict = None,
data: Dict = None,
) -> NetworkThread:
"""
添加网络请求任务
:param mode: 请求模式,支持 "get", "get_file", "upload_file"
:param url: 请求的URL
:param path: 下载文件的保存路径,仅在 mode 为 "get_file" 时有效
:param files: 上传文件字典,仅在 mode 为 "upload_file" 时有效
:param data: 表单数据字典,仅在 mode 为 "upload_file" 时有效
:return: 返回创建的 NetworkThread 实例
"""
logger.info(f"添加网络请求任务: {mode} {url} {path}", module="网络请求")
network_thread = NetworkThread(mode, url, path, files, data)
self.task_queue.append(network_thread)
network_thread.start()
return network_thread
def upload_config_file(
self, file_path: Path, username: str = "", description: str = ""
) -> NetworkThread:
"""
上传配置文件到分享服务器
:param file_path: 要上传的文件路径
:param username: 用户名(可选)
:param description: 文件描述(必填)
:return: 返回创建的 NetworkThread 实例
"""
if not file_path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}")
if not description:
raise ValueError("文件描述不能为空")
# 准备上传的文件
with open(file_path, "rb") as f:
files = {"file": (file_path.name, f.read(), "application/json")}
# 准备表单数据
data = {"description": description}
if username:
data["username"] = username
url = "http://221.236.27.82:10023/api/upload/share"
logger.info(
f"准备上传配置文件: {file_path.name},用户: {username or '匿名'},描述: {description}",
extra={"module": "网络请求"},
)
return self.add_task("upload_file", url, files=files, data=data)
def get_result(self, network_thread: NetworkThread) -> dict:
"""
获取网络请求结果
:param network_thread: 网络请求线程实例
:return: 包含状态码、响应JSON和错误信息的字典
"""
result = {
"status_code": network_thread.status_code,
"response_json": network_thread.response_json,
"error_message": (
re.sub(r"(&cdk=)[^&]+(&)", r"\1******\2", network_thread.error_message)
if network_thread.error_message
else None
),
}
network_thread.quit()
network_thread.wait()
self.task_queue.remove(network_thread)
network_thread.deleteLater()
logger.info(
f"网络请求结果: {result['status_code']},请求子线程已结束",
module="网络请求",
)
return result
Network = _Network()

79
app/core/sound_player.py Normal file
View File

@@ -0,0 +1,79 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA音效播放器
v4.4
作者DLmaster_361
"""
from PySide6.QtCore import QObject, QUrl
from PySide6.QtMultimedia import QSoundEffect
from pathlib import Path
from .logger import logger
from .config import Config
class _SoundPlayer(QObject):
def __init__(self):
super().__init__()
self.sounds_path = Config.app_path / "resources/sounds"
def play(self, sound_name: str):
"""
播放指定名称的音效
:param sound_name: 音效文件名(不带扩展名)
"""
if not Config.get(Config.voice_Enabled):
return
if (self.sounds_path / f"both/{sound_name}.wav").exists():
self.play_voice(self.sounds_path / f"both/{sound_name}.wav")
elif (
self.sounds_path / Config.get(Config.voice_Type) / f"{sound_name}.wav"
).exists():
self.play_voice(
self.sounds_path / Config.get(Config.voice_Type) / f"{sound_name}.wav"
)
def play_voice(self, sound_path: Path):
"""
播放音效文件
:param sound_path: 音效文件的完整路径
"""
effect = QSoundEffect(self)
effect.setVolume(1)
effect.setSource(QUrl.fromLocalFile(sound_path))
effect.play()
SoundPlayer = _SoundPlayer()

460
app/core/task_manager.py Normal file
View File

@@ -0,0 +1,460 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA业务调度器
v4.4
作者DLmaster_361
"""
from PySide6.QtCore import QThread, QObject, Signal
from qfluentwidgets import MessageBox
from datetime import datetime
from packaging import version
from typing import Dict, Union
from .logger import logger
from .config import Config
from .main_info_bar import MainInfoBar
from .network import Network
from .sound_player import SoundPlayer
from app.models import MaaManager, GeneralManager
class Task(QThread):
"""业务线程"""
check_maa_version = Signal(str)
push_info_bar = Signal(str, str, str, int)
play_sound = Signal(str)
question = Signal(str, str)
question_response = Signal(bool)
update_maa_user_info = Signal(str, dict)
update_general_sub_info = Signal(str, dict)
create_task_list = Signal(list)
create_user_list = Signal(list)
update_task_list = Signal(list)
update_user_list = Signal(list)
update_log_text = Signal(str)
accomplish = Signal(list)
def __init__(
self, mode: str, name: str, info: Dict[str, Dict[str, Union[str, int, bool]]]
):
super(Task, self).__init__()
self.setObjectName(f"Task-{mode}-{name}")
self.mode = mode
self.name = name
self.info = info
self.logs = []
self.question_response.connect(lambda: print("response"))
@logger.catch
def run(self):
if "设置MAA" in self.mode:
logger.info(f"任务开始:设置{self.name}", module=f"业务 {self.name}")
self.push_info_bar.emit("info", "设置MAA", self.name, 3000)
self.task = MaaManager(
self.mode,
Config.script_dict[self.name],
(None if "全局" in self.mode else self.info["SetMaaInfo"]["Path"]),
)
self.task.check_maa_version.connect(self.check_maa_version.emit)
self.task.push_info_bar.connect(self.push_info_bar.emit)
self.task.play_sound.connect(self.play_sound.emit)
self.task.accomplish.connect(lambda: self.accomplish.emit([]))
try:
self.task.run()
except Exception as e:
logger.exception(
f"任务异常:{self.name},错误信息:{e}", module=f"业务 {self.name}"
)
self.push_info_bar.emit("error", "任务异常", self.name, -1)
elif self.mode == "设置通用脚本":
logger.info(f"任务开始:设置{self.name}", module=f"业务 {self.name}")
self.push_info_bar.emit("info", "设置通用脚本", self.name, 3000)
self.task = GeneralManager(
self.mode,
Config.script_dict[self.name],
self.info["SetSubInfo"]["Path"],
)
self.task.push_info_bar.connect(self.push_info_bar.emit)
self.task.play_sound.connect(self.play_sound.emit)
self.task.accomplish.connect(lambda: self.accomplish.emit([]))
try:
self.task.run()
except Exception as e:
logger.exception(
f"任务异常:{self.name},错误信息:{e}", module=f"业务 {self.name}"
)
self.push_info_bar.emit("error", "任务异常", self.name, -1)
else:
logger.info(f"任务开始:{self.name}", module=f"业务 {self.name}")
self.task_list = [
[
(
value
if Config.script_dict[value]["Config"].get_name() == ""
else f"{value} - {Config.script_dict[value]["Config"].get_name()}"
),
"等待",
value,
]
for _, value in sorted(
self.info["Queue"].items(), key=lambda x: int(x[0][7:])
)
if value != "禁用"
]
self.create_task_list.emit(self.task_list)
for task in self.task_list:
if self.isInterruptionRequested():
break
task[1] = "运行"
self.update_task_list.emit(self.task_list)
# 检查任务是否在运行列表中
if task[2] in Config.running_list:
task[1] = "跳过"
self.update_task_list.emit(self.task_list)
logger.info(
f"跳过任务:{task[0]},该任务已在运行列表中",
module=f"业务 {self.name}",
)
self.push_info_bar.emit("info", "跳过任务", task[0], 3000)
continue
# 标记为运行中
Config.running_list.append(task[2])
logger.info(f"任务开始:{task[0]}", module=f"业务 {self.name}")
self.push_info_bar.emit("info", "任务开始", task[0], 3000)
if Config.script_dict[task[2]]["Type"] == "Maa":
self.task = MaaManager(
self.mode[0:4],
Config.script_dict[task[2]],
)
self.task.check_maa_version.connect(self.check_maa_version.emit)
self.task.question.connect(self.question.emit)
self.question_response.disconnect()
self.question_response.connect(self.task.question_response.emit)
self.task.push_info_bar.connect(self.push_info_bar.emit)
self.task.play_sound.connect(self.play_sound.emit)
self.task.create_user_list.connect(self.create_user_list.emit)
self.task.update_user_list.connect(self.update_user_list.emit)
self.task.update_log_text.connect(self.update_log_text.emit)
self.task.update_user_info.connect(self.update_maa_user_info.emit)
self.task.accomplish.connect(
lambda log: self.task_accomplish(task[2], log)
)
elif Config.script_dict[task[2]]["Type"] == "General":
self.task = GeneralManager(
self.mode[0:4],
Config.script_dict[task[2]],
)
self.task.question.connect(self.question.emit)
self.question_response.disconnect()
self.question_response.connect(self.task.question_response.emit)
self.task.push_info_bar.connect(self.push_info_bar.emit)
self.task.play_sound.connect(self.play_sound.emit)
self.task.create_user_list.connect(self.create_user_list.emit)
self.task.update_user_list.connect(self.update_user_list.emit)
self.task.update_log_text.connect(self.update_log_text.emit)
self.task.update_sub_info.connect(self.update_general_sub_info.emit)
self.task.accomplish.connect(
lambda log: self.task_accomplish(task[2], log)
)
try:
self.task.run() # 运行任务业务
task[1] = "完成"
self.update_task_list.emit(self.task_list)
logger.info(f"任务完成:{task[0]}", module=f"业务 {self.name}")
self.push_info_bar.emit("info", "任务完成", task[0], 3000)
except Exception as e:
self.task_accomplish(
task[2],
{
"Time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"History": f"任务异常,异常简报:{e}",
},
)
task[1] = "异常"
self.update_task_list.emit(self.task_list)
logger.exception(
f"任务异常:{task[0]},错误信息:{e}",
module=f"业务 {self.name}",
)
self.push_info_bar.emit("error", "任务异常", task[0], -1)
# 任务结束后从运行列表中移除
Config.running_list.remove(task[2])
self.accomplish.emit(self.logs)
def task_accomplish(self, name: str, log: dict):
"""
销毁任务线程并保存任务结果
:param name: 任务名称
:param log: 任务日志记录
"""
logger.info(
f"任务完成:{name},日志记录:{list(log.values())}",
module=f"业务 {self.name}",
)
self.logs.append([name, log])
self.task.deleteLater()
class _TaskManager(QObject):
"""业务调度器"""
create_gui = Signal(Task)
connect_gui = Signal(Task)
def __init__(self):
super(_TaskManager, self).__init__()
self.task_dict: Dict[str, Task] = {}
def add_task(
self, mode: str, name: str, info: Dict[str, Dict[str, Union[str, int, bool]]]
):
"""
添加任务
:param mode: 任务模式
:param name: 任务名称
:param info: 任务信息
"""
if name in Config.running_list or name in self.task_dict:
logger.warning(f"任务已存在:{name}")
MainInfoBar.push_info_bar("warning", "任务已存在", name, 5000)
return None
logger.info(f"任务开始:{name},模式:{mode}", module="业务调度")
MainInfoBar.push_info_bar("info", "任务开始", name, 3000)
SoundPlayer.play("任务开始")
# 标记任务为运行中
Config.running_list.append(name)
# 创建任务实例并连接信号
self.task_dict[name] = Task(mode, name, info)
self.task_dict[name].check_maa_version.connect(self.check_maa_version)
self.task_dict[name].question.connect(
lambda title, content: self.push_dialog(name, title, content)
)
self.task_dict[name].push_info_bar.connect(MainInfoBar.push_info_bar)
self.task_dict[name].play_sound.connect(SoundPlayer.play)
self.task_dict[name].update_maa_user_info.connect(Config.change_maa_user_info)
self.task_dict[name].update_general_sub_info.connect(
Config.change_general_sub_info
)
self.task_dict[name].accomplish.connect(
lambda logs: self.remove_task(mode, name, logs)
)
# 向UI发送信号以创建或连接GUI
if "新调度台" in mode:
self.create_gui.emit(self.task_dict[name])
elif "主调度台" in mode:
self.connect_gui.emit(self.task_dict[name])
# 启动任务线程
self.task_dict[name].start()
def stop_task(self, name: str) -> None:
"""
中止任务
:param name: 任务名称
"""
logger.info(f"中止任务:{name}", module="业务调度")
MainInfoBar.push_info_bar("info", "中止任务", name, 3000)
if name == "ALL":
for name in self.task_dict:
self.task_dict[name].task.requestInterruption()
self.task_dict[name].requestInterruption()
self.task_dict[name].quit()
self.task_dict[name].wait()
elif name in self.task_dict:
self.task_dict[name].task.requestInterruption()
self.task_dict[name].requestInterruption()
self.task_dict[name].quit()
self.task_dict[name].wait()
def remove_task(self, mode: str, name: str, logs: list) -> None:
"""
处理任务结束后的收尾工作
:param mode: 任务模式
:param name: 任务名称
:param logs: 任务日志
"""
logger.info(f"任务结束:{name}", module="业务调度")
MainInfoBar.push_info_bar("info", "任务结束", name, 3000)
SoundPlayer.play("任务结束")
# 删除任务线程,移除运行中标记
self.task_dict[name].deleteLater()
self.task_dict.pop(name)
Config.running_list.remove(name)
if "调度队列" in name and "人工排查" not in mode:
# 保存调度队列历史记录
if len(logs) > 0:
time = logs[0][1]["Time"]
history = ""
for log in logs:
history += f"任务名称:{log[0]}{log[1]["History"].replace("\n","\n ")}\n"
Config.save_history(name, {"Time": time, "History": history})
else:
Config.save_history(
name,
{
"Time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"History": "没有任务被执行",
},
)
# 根据调度队列情况设置电源状态
if (
Config.queue_dict[name]["Config"].get(
Config.queue_dict[name]["Config"].QueueSet_AfterAccomplish
)
!= "NoAction"
and Config.power_sign == "NoAction"
):
Config.set_power_sign(
Config.queue_dict[name]["Config"].get(
Config.queue_dict[name]["Config"].QueueSet_AfterAccomplish
)
)
if Config.args.mode == "cli" and Config.power_sign == "NoAction":
Config.set_power_sign("KillSelf")
def check_maa_version(self, v: str) -> None:
"""
检查MAA版本如果版本过低则推送通知
:param v: 当前MAA版本
"""
logger.info(f"检查MAA版本{v}", module="业务调度")
network = Network.add_task(
mode="get",
url="https://mirrorchyan.com/api/resources/MAA/latest?user_agent=AutoMaaGui&os=win&arch=x64&channel=stable",
)
network.loop.exec()
network_result = Network.get_result(network)
if network_result["status_code"] == 200:
maa_info = network_result["response_json"]
else:
logger.warning(
f"获取MAA版本信息时出错{network_result['error_message']}",
module="业务调度",
)
MainInfoBar.push_info_bar(
"warning",
"获取MAA版本信息时出错",
f"网络错误:{network_result['status_code']}",
5000,
)
return None
if version.parse(maa_info["data"]["version_name"]) > version.parse(v):
logger.info(
f"检测到MAA版本过低{v},最新版本:{maa_info['data']['version_name']}",
module="业务调度",
)
MainInfoBar.push_info_bar(
"info",
"MAA版本过低",
f"当前版本:{v},最新稳定版:{maa_info['data']['version_name']}",
-1,
)
logger.success(
f"MAA版本检查完成{v},最新版本:{maa_info['data']['version_name']}",
module="业务调度",
)
def push_dialog(self, name: str, title: str, content: str):
"""
推送来自任务线程的对话框
:param name: 任务名称
:param title: 对话框标题
:param content: 对话框内容
"""
choice = MessageBox(title, content, Config.main_window)
choice.yesButton.setText("")
choice.cancelButton.setText("")
self.task_dict[name].question_response.emit(bool(choice.exec()))
TaskManager = _TaskManager()

175
app/core/timer.py Normal file
View File

@@ -0,0 +1,175 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA主业务定时器
v4.4
作者DLmaster_361
"""
from PySide6.QtCore import QObject, QTimer
from datetime import datetime
import keyboard
from .logger import logger
from .config import Config
from .task_manager import TaskManager
from app.services import System
class _MainTimer(QObject):
def __init__(self, parent=None):
super().__init__(parent)
self.Timer = QTimer()
self.Timer.timeout.connect(self.timed_start)
self.Timer.timeout.connect(self.set_silence)
self.Timer.timeout.connect(self.check_power)
self.LongTimer = QTimer()
self.LongTimer.timeout.connect(self.long_timed_task)
def start(self):
"""启动定时器"""
logger.info("启动主定时器", module="主业务定时器")
self.Timer.start(1000)
self.LongTimer.start(3600000)
def stop(self):
"""停止定时器"""
logger.info("停止主定时器", module="主业务定时器")
self.Timer.stop()
self.Timer.deleteLater()
self.LongTimer.stop()
self.LongTimer.deleteLater()
def long_timed_task(self):
"""长时间定期检定任务"""
logger.info("执行长时间定期检定任务", module="主业务定时器")
Config.get_stage()
Config.main_window.setting.show_notice()
if Config.get(Config.update_IfAutoUpdate):
Config.main_window.setting.check_update()
def timed_start(self):
"""定时启动代理任务"""
for name, info in Config.queue_dict.items():
if not info["Config"].get(info["Config"].QueueSet_TimeEnabled):
continue
data = info["Config"].toDict()
time_set = [
data["Time"][f"Set_{_}"]
for _ in range(10)
if data["Time"][f"Enabled_{_}"]
]
# 按时间调起代理任务
curtime = datetime.now().strftime("%Y-%m-%d %H:%M")
if (
curtime[11:16] in time_set
and curtime
!= info["Config"].get(info["Config"].Data_LastProxyTime)[:16]
and name not in Config.running_list
):
logger.info(f"定时唤起任务:{name}", module="主业务定时器")
TaskManager.add_task("自动代理_新调度台", name, data)
def set_silence(self):
"""设置静默模式"""
if (
not Config.if_ignore_silence
and Config.get(Config.function_IfSilence)
and Config.get(Config.function_BossKey) != ""
):
windows = System.get_window_info()
emulator_windows = []
for window in windows:
for emulator_path, endtime in Config.silence_dict.items():
if (
datetime.now() < endtime
and str(emulator_path) in window
and window[0] != "新通知" # 此处排除雷电名为新通知的窗口
):
emulator_windows.append(window)
if emulator_windows:
logger.info(
f"检测到模拟器窗口:{emulator_windows}", module="主业务定时器"
)
try:
keyboard.press_and_release(
"+".join(
_.strip().lower()
for _ in Config.get(Config.function_BossKey).split("+")
)
)
logger.info(
f"模拟按键:{Config.get(Config.function_BossKey)}",
module="主业务定时器",
)
except Exception as e:
logger.exception(f"模拟按键时出错:{e}", module="主业务定时器")
def check_power(self):
"""检查电源操作"""
if Config.power_sign != "NoAction" and not Config.running_list:
logger.info(f"触发电源操作:{Config.power_sign}", module="主业务定时器")
from app.ui import ProgressRingMessageBox
mode_book = {
"KillSelf": "退出软件",
"Sleep": "睡眠",
"Hibernate": "休眠",
"Shutdown": "关机",
"ShutdownForce": "关机(强制)",
}
choice = ProgressRingMessageBox(
Config.main_window, f"{mode_book[Config.power_sign]}倒计时"
)
if choice.exec():
logger.info(
f"确认执行电源操作:{Config.power_sign}", module="主业务定时器"
)
System.set_power(Config.power_sign)
Config.set_power_sign("NoAction")
else:
logger.info(f"取消电源操作:{Config.power_sign}", module="主业务定时器")
Config.set_power_sign("NoAction")
MainTimer = _MainTimer()

2164
app/models/MAA.py Normal file

File diff suppressed because it is too large Load Diff

35
app/models/__init__.py Normal file
View File

@@ -0,0 +1,35 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA模组包
v4.4
作者DLmaster_361
"""
__version__ = "4.2.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .general import GeneralManager
from .MAA import MaaManager
__all__ = ["GeneralManager", "MaaManager"]

1201
app/models/general.py Normal file

File diff suppressed because it is too large Load Diff

37
app/services/__init__.py Normal file
View File

@@ -0,0 +1,37 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA服务包
v4.4
作者DLmaster_361
"""
__version__ = "4.2.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .notification import Notify
from .security import Crypto
from .system import System
from .skland import skland_sign_in
__all__ = ["Notify", "Crypto", "System", "skland_sign_in"]

View File

@@ -0,0 +1,484 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA通知服务
v4.4
作者DLmaster_361
"""
import re
import smtplib
import time
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr
from pathlib import Path
from typing import Union
import requests
from PySide6.QtCore import QObject, Signal
from plyer import notification
from app.core import Config, logger
from app.services.security import Crypto
from app.utils.ImageUtils import ImageUtils
class Notification(QObject):
push_info_bar = Signal(str, str, str, int)
def __init__(self, parent=None):
super().__init__(parent)
def push_plyer(self, title, message, ticker, t) -> bool:
"""
推送系统通知
:param title: 通知标题
:param message: 通知内容
:param ticker: 通知横幅
:param t: 通知持续时间
:return: bool
"""
if Config.get(Config.notify_IfPushPlyer):
logger.info(f"推送系统通知:{title}", module="通知服务")
notification.notify(
title=title,
message=message,
app_name="AUTO_MAA",
app_icon=str(Config.app_path / "resources/icons/AUTO_MAA.ico"),
timeout=t,
ticker=ticker,
toast=True,
)
return True
def send_mail(self, mode, title, content, to_address) -> None:
"""
推送邮件通知
:param mode: 邮件内容模式,支持 "文本""网页"
:param title: 邮件标题
:param content: 邮件内容
:param to_address: 收件人地址
"""
if (
Config.get(Config.notify_SMTPServerAddress) == ""
or Config.get(Config.notify_AuthorizationCode) == ""
or not bool(
re.match(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
Config.get(Config.notify_FromAddress),
)
)
or not bool(
re.match(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
to_address,
)
)
):
logger.error(
"请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址",
module="通知服务",
)
self.push_info_bar.emit(
"error",
"邮件通知推送异常",
"请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址",
-1,
)
return None
try:
# 定义邮件正文
if mode == "文本":
message = MIMEText(content, "plain", "utf-8")
elif mode == "网页":
message = MIMEMultipart("alternative")
message["From"] = formataddr(
(
Header("AUTO_MAA通知服务", "utf-8").encode(),
Config.get(Config.notify_FromAddress),
)
) # 发件人显示的名字
message["To"] = formataddr(
(Header("AUTO_MAA用户", "utf-8").encode(), to_address)
) # 收件人显示的名字
message["Subject"] = Header(title, "utf-8")
if mode == "网页":
message.attach(MIMEText(content, "html", "utf-8"))
smtpObj = smtplib.SMTP_SSL(Config.get(Config.notify_SMTPServerAddress), 465)
smtpObj.login(
Config.get(Config.notify_FromAddress),
Crypto.win_decryptor(Config.get(Config.notify_AuthorizationCode)),
)
smtpObj.sendmail(
Config.get(Config.notify_FromAddress), to_address, message.as_string()
)
smtpObj.quit()
logger.success(f"邮件发送成功:{title}", module="通知服务")
except Exception as e:
logger.exception(f"发送邮件时出错:{e}", module="通知服务")
self.push_info_bar.emit("error", "发送邮件时出错", f"{e}", -1)
def ServerChanPush(
self, title, content, send_key, tag, channel
) -> Union[bool, str]:
"""
使用Server酱推送通知
:param title: 通知标题
:param content: 通知内容
:param send_key: Server酱的SendKey
:param tag: 通知标签
:param channel: 通知频道
:return: bool or str
"""
if not send_key:
logger.error("请正确设置Server酱的SendKey", module="通知服务")
self.push_info_bar.emit(
"error", "Server酱通知推送异常", "请正确设置Server酱的SendKey", -1
)
return None
try:
# 构造 URL
if send_key.startswith("sctp"):
match = re.match(r"^sctp(\d+)t", send_key)
if match:
url = f"https://{match.group(1)}.push.ft07.com/send/{send_key}.send"
else:
raise ValueError("SendKey 格式错误sctp")
else:
url = f"https://sctapi.ftqq.com/{send_key}.send"
# 构建 tags 和 channel
def is_valid(s):
return s == "" or (
s == "|".join(s.split("|"))
and (s.count("|") == 0 or all(s.split("|")))
)
tags = "|".join(_.strip() for _ in tag.split("|"))
channels = "|".join(_.strip() for _ in channel.split("|"))
options = {}
if is_valid(tags):
options["tags"] = tags
else:
logger.warning("Server酱 Tag 配置不正确,将被忽略", module="通知服务")
self.push_info_bar.emit(
"warning",
"Server酱通知推送异常",
"请正确设置 ServerChan 的 Tag",
-1,
)
if is_valid(channels):
options["channel"] = channels
else:
logger.warning(
"Server酱 Channel 配置不正确,将被忽略", module="通知服务"
)
self.push_info_bar.emit(
"warning",
"Server酱通知推送异常",
"请正确设置 ServerChan 的 Channel",
-1,
)
# 请求发送
params = {"title": title, "desp": content, **options}
headers = {"Content-Type": "application/json;charset=utf-8"}
response = requests.post(
url,
json=params,
headers=headers,
timeout=10,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
)
result = response.json()
if result.get("code") == 0:
logger.success(f"Server酱推送通知成功{title}", module="通知服务")
return True
else:
error_code = result.get("code", "-1")
logger.exception(
f"Server酱通知推送失败响应码{error_code}", module="通知服务"
)
self.push_info_bar.emit(
"error", "Server酱通知推送失败", f"响应码:{error_code}", -1
)
return f"Server酱通知推送失败{error_code}"
except Exception as e:
logger.exception(f"Server酱通知推送异常{e}", module="通知服务")
self.push_info_bar.emit(
"error",
"Server酱通知推送异常",
"请检查相关设置和网络连接。如全部配置正确,请稍后再试。",
-1,
)
return f"Server酱通知推送异常{str(e)}"
def CompanyWebHookBotPush(self, title, content, webhook_url) -> Union[bool, str]:
"""
使用企业微信群机器人推送通知
:param title: 通知标题
:param content: 通知内容
:param webhook_url: 企业微信群机器人的WebHook地址
:return: bool or str
"""
if webhook_url == "":
logger.error("请正确设置企业微信群机器人的WebHook地址", module="通知服务")
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送异常",
"请正确设置企业微信群机器人的WebHook地址",
-1,
)
return None
content = f"{title}\n{content}"
data = {"msgtype": "text", "text": {"content": content}}
for _ in range(3):
try:
response = requests.post(
url=webhook_url,
json=data,
timeout=10,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
)
info = response.json()
break
except Exception as e:
err = e
time.sleep(0.1)
else:
logger.error(f"推送企业微信群机器人时出错:{err}", module="通知服务")
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送失败",
f"使用企业微信群机器人推送通知时出错:{err}",
-1,
)
return None
if info["errcode"] == 0:
logger.success(f"企业微信群机器人推送通知成功:{title}", module="通知服务")
return True
else:
logger.error(f"企业微信群机器人推送通知失败:{info}", module="通知服务")
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送失败",
f"使用企业微信群机器人推送通知时出错:{err}",
-1,
)
return f"使用企业微信群机器人推送通知时出错:{err}"
def CompanyWebHookBotPushImage(self, image_path: Path, webhook_url: str) -> bool:
"""
使用企业微信群机器人推送图片通知
:param image_path: 图片文件路径
:param webhook_url: 企业微信群机器人的WebHook地址
:return: bool
"""
try:
# 压缩图片
ImageUtils.compress_image_if_needed(image_path)
# 检查图片是否存在
if not image_path.exists():
logger.error(
"图片推送异常 | 图片不存在或者压缩失败,请检查图片路径是否正确",
module="通知服务",
)
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送异常",
"图片不存在或者压缩失败,请检查图片路径是否正确",
-1,
)
return False
if not webhook_url:
logger.error(
"请正确设置企业微信群机器人的WebHook地址", module="通知服务"
)
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送异常",
"请正确设置企业微信群机器人的WebHook地址",
-1,
)
return False
# 获取图片base64和md5
try:
image_base64 = ImageUtils.get_base64_from_file(str(image_path))
image_md5 = ImageUtils.calculate_md5_from_file(str(image_path))
except Exception as e:
logger.exception(f"图片编码或MD5计算失败{e}", module="通知服务")
self.push_info_bar.emit(
"error",
"企业微信群机器人通知推送异常",
f"图片编码或MD5计算失败{e}",
-1,
)
return False
data = {
"msgtype": "image",
"image": {"base64": image_base64, "md5": image_md5},
}
for _ in range(3):
try:
response = requests.post(
url=webhook_url,
json=data,
timeout=10,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
)
info = response.json()
break
except requests.RequestException as e:
err = e
logger.exception(
f"推送企业微信群机器人图片第{_+1}次失败:{e}", module="通知服务"
)
time.sleep(0.1)
else:
logger.error("推送企业微信群机器人图片时出错", module="通知服务")
self.push_info_bar.emit(
"error",
"企业微信群机器人图片推送失败",
f"使用企业微信群机器人推送图片时出错:{err}",
-1,
)
return False
if info.get("errcode") == 0:
logger.success(
f"企业微信群机器人推送图片成功:{image_path.name}",
module="通知服务",
)
return True
else:
logger.error(f"企业微信群机器人推送图片失败:{info}", module="通知服务")
self.push_info_bar.emit(
"error",
"企业微信群机器人图片推送失败",
f"使用企业微信群机器人推送图片时出错:{info}",
-1,
)
return False
except Exception as e:
logger.error(f"推送企业微信群机器人图片时发生未知异常:{e}")
self.push_info_bar.emit(
"error",
"企业微信群机器人图片推送失败",
f"发生未知异常:{e}",
-1,
)
return False
def send_test_notification(self):
"""发送测试通知到所有已启用的通知渠道"""
logger.info("发送测试通知到所有已启用的通知渠道", module="通知服务")
# 发送系统通知
self.push_plyer(
"测试通知",
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
"测试通知",
3,
)
# 发送邮件通知
if Config.get(Config.notify_IfSendMail):
self.send_mail(
"文本",
"AUTO_MAA测试通知",
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
Config.get(Config.notify_ToAddress),
)
# 发送Server酱通知
if Config.get(Config.notify_IfServerChan):
self.ServerChanPush(
"AUTO_MAA测试通知",
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
Config.get(Config.notify_ServerChanKey),
Config.get(Config.notify_ServerChanTag),
Config.get(Config.notify_ServerChanChannel),
)
# 发送企业微信机器人通知
if Config.get(Config.notify_IfCompanyWebHookBot):
self.CompanyWebHookBotPush(
"AUTO_MAA测试通知",
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
Config.get(Config.notify_CompanyWebHookBotUrl),
)
Notify.CompanyWebHookBotPushImage(
Config.app_path / "resources/images/notification/test_notify.png",
Config.get(Config.notify_CompanyWebHookBotUrl),
)
logger.info("测试通知发送完成", module="通知服务")
return True
Notify = Notification()

270
app/services/security.py Normal file
View File

@@ -0,0 +1,270 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA安全服务
v4.4
作者DLmaster_361
"""
import hashlib
import random
import secrets
import base64
import win32crypt
from Crypto.Cipher import AES
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP
from Crypto.Util.Padding import pad, unpad
from app.core import Config
class CryptoHandler:
def get_PASSWORD(self, PASSWORD: str) -> None:
"""
配置管理密钥
:param PASSWORD: 管理密钥
:type PASSWORD: str
"""
# 生成目录
Config.key_path.mkdir(parents=True, exist_ok=True)
# 生成RSA密钥对
key = RSA.generate(2048)
public_key_local = key.publickey()
private_key = key
# 保存RSA公钥
(Config.app_path / "data/key/public_key.pem").write_bytes(
public_key_local.exportKey()
)
# 生成密钥转换与校验随机盐
PASSWORD_salt = secrets.token_hex(random.randint(32, 1024))
(Config.app_path / "data/key/PASSWORDsalt.txt").write_text(
PASSWORD_salt,
encoding="utf-8",
)
verify_salt = secrets.token_hex(random.randint(32, 1024))
(Config.app_path / "data/key/verifysalt.txt").write_text(
verify_salt,
encoding="utf-8",
)
# 将管理密钥转化为AES-256密钥
AES_password = hashlib.sha256(
(PASSWORD + PASSWORD_salt).encode("utf-8")
).digest()
# 生成AES-256密钥校验哈希值并保存
AES_password_verify = hashlib.sha256(
AES_password + verify_salt.encode("utf-8")
).digest()
(Config.app_path / "data/key/AES_password_verify.bin").write_bytes(
AES_password_verify
)
# AES-256加密RSA私钥并保存密文
AES_key = AES.new(AES_password, AES.MODE_ECB)
private_key_local = AES_key.encrypt(pad(private_key.exportKey(), 32))
(Config.app_path / "data/key/private_key.bin").write_bytes(private_key_local)
def AUTO_encryptor(self, note: str) -> str:
"""
使用AUTO_MAA的算法加密数据
:param note: 数据明文
:type note: str
"""
if note == "":
return ""
# 读取RSA公钥
public_key_local = RSA.import_key(
(Config.app_path / "data/key/public_key.pem").read_bytes()
)
# 使用RSA公钥对数据进行加密
cipher = PKCS1_OAEP.new(public_key_local)
encrypted = cipher.encrypt(note.encode("utf-8"))
return base64.b64encode(encrypted).decode("utf-8")
def AUTO_decryptor(self, note: str, PASSWORD: str) -> str:
"""
使用AUTO_MAA的算法解密数据
:param note: 数据密文
:type note: str
:param PASSWORD: 管理密钥
:type PASSWORD: str
:return: 解密后的明文
:rtype: str
"""
if note == "":
return ""
# 读入RSA私钥密文、盐与校验哈希值
private_key_local = (
(Config.app_path / "data/key/private_key.bin").read_bytes().strip()
)
PASSWORD_salt = (
(Config.app_path / "data/key/PASSWORDsalt.txt")
.read_text(encoding="utf-8")
.strip()
)
verify_salt = (
(Config.app_path / "data/key/verifysalt.txt")
.read_text(encoding="utf-8")
.strip()
)
AES_password_verify = (
(Config.app_path / "data/key/AES_password_verify.bin").read_bytes().strip()
)
# 将管理密钥转化为AES-256密钥并验证
AES_password = hashlib.sha256(
(PASSWORD + PASSWORD_salt).encode("utf-8")
).digest()
AES_password_SHA = hashlib.sha256(
AES_password + verify_salt.encode("utf-8")
).digest()
if AES_password_SHA != AES_password_verify:
return "管理密钥错误"
else:
# AES解密RSA私钥
AES_key = AES.new(AES_password, AES.MODE_ECB)
private_key_pem = unpad(AES_key.decrypt(private_key_local), 32)
private_key = RSA.import_key(private_key_pem)
# 使用RSA私钥解密数据
decrypter = PKCS1_OAEP.new(private_key)
note = decrypter.decrypt(base64.b64decode(note)).decode("utf-8")
return note
def change_PASSWORD(self, PASSWORD_old: str, PASSWORD_new: str) -> None:
"""
修改管理密钥
:param PASSWORD_old: 旧管理密钥
:type PASSWORD_old: str
:param PASSWORD_new: 新管理密钥
:type PASSWORD_new: str
"""
for script in Config.script_dict.values():
# 使用旧管理密钥解密
if script["Type"] == "Maa":
for user in script["UserData"].values():
user["Password"] = self.AUTO_decryptor(
user["Config"].get(user["Config"].Info_Password), PASSWORD_old
)
self.get_PASSWORD(PASSWORD_new)
for script in Config.script_dict.values():
# 使用新管理密钥重新加密
if script["Type"] == "Maa":
for user in script["UserData"].values():
user["Config"].set(
user["Config"].Info_Password,
self.AUTO_encryptor(user["Password"]),
)
user["Password"] = None
del user["Password"]
def reset_PASSWORD(self, PASSWORD_new: str) -> None:
"""
重置管理密钥
:param PASSWORD_new: 新管理密钥
:type PASSWORD_new: str
"""
self.get_PASSWORD(PASSWORD_new)
for script in Config.script_dict.values():
if script["Type"] == "Maa":
for user in script["UserData"].values():
user["Config"].set(
user["Config"].Info_Password, self.AUTO_encryptor("数据已重置")
)
def win_encryptor(
self, note: str, description: str = None, entropy: bytes = None
) -> str:
"""
使用Windows DPAPI加密数据
:param note: 数据明文
:type note: str
:param description: 描述信息
:type description: str
:param entropy: 随机熵
:type entropy: bytes
:return: 加密后的数据
:rtype: str
"""
if note == "":
return ""
encrypted = win32crypt.CryptProtectData(
note.encode("utf-8"), description, entropy, None, None, 0
)
return base64.b64encode(encrypted).decode("utf-8")
def win_decryptor(self, note: str, entropy: bytes = None) -> str:
"""
使用Windows DPAPI解密数据
:param note: 数据密文
:type note: str
:param entropy: 随机熵
:type entropy: bytes
:return: 解密后的明文
:rtype: str
"""
if note == "":
return ""
decrypted = win32crypt.CryptUnprotectData(
base64.b64decode(note), entropy, None, None, 0
)
return decrypted[1].decode("utf-8")
def check_PASSWORD(self, PASSWORD: str) -> bool:
"""
验证管理密钥
:param PASSWORD: 管理密钥
:type PASSWORD: str
:return: 是否验证通过
:rtype: bool
"""
return bool(
self.AUTO_decryptor(self.AUTO_encryptor("-"), PASSWORD) != "管理密钥错误"
)
Crypto = CryptoHandler()

285
app/services/skland.py Normal file
View File

@@ -0,0 +1,285 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file incorporates work covered by the following copyright and
# permission notice:
#
# skland-checkin-ghaction Copyright © 2023 Yanstory
# https://github.com/Yanstory/skland-checkin-ghaction
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA森空岛服务
v4.4
作者DLmaster_361、ClozyA
"""
import time
import json
import hmac
import hashlib
import requests
from urllib import parse
from app.core import Config, logger
def skland_sign_in(token) -> dict:
"""森空岛签到"""
app_code = "4ca99fa6b56cc2ba"
# 用于获取grant code
grant_code_url = "https://as.hypergryph.com/user/oauth2/v2/grant"
# 用于获取cred
cred_code_url = "https://zonai.skland.com/api/v1/user/auth/generate_cred_by_code"
# 查询角色绑定
binding_url = "https://zonai.skland.com/api/v1/game/player/binding"
# 签到接口
sign_url = "https://zonai.skland.com/api/v1/game/attendance"
# 基础请求头
header = {
"cred": "",
"User-Agent": "Skland/1.5.1 (com.hypergryph.skland; build:100501001; Android 34;) Okhttp/4.11.0",
"Accept-Encoding": "gzip",
"Connection": "close",
}
header_login = header.copy()
header_for_sign = {
"platform": "1",
"timestamp": "",
"dId": "",
"vName": "1.5.1",
}
def generate_signature(token_for_sign: str, path, body_or_query):
"""
生成请求签名
:param token_for_sign: 用于加密的token
:param path: 请求路径(如 /api/v1/game/player/binding
:param body_or_query: GET用query字符串POST用body字符串
:return: (sign, 新的header_for_sign字典)
"""
t = str(int(time.time()) - 2) # 时间戳,-2秒以防服务器时间不一致
token_bytes = token_for_sign.encode("utf-8")
header_ca = dict(header_for_sign)
header_ca["timestamp"] = t
header_ca_str = json.dumps(header_ca, separators=(",", ":"))
s = path + body_or_query + t + header_ca_str # 拼接原始字符串
# HMAC-SHA256 + MD5得到最终sign
hex_s = hmac.new(token_bytes, s.encode("utf-8"), hashlib.sha256).hexdigest()
md5 = hashlib.md5(hex_s.encode("utf-8")).hexdigest()
return md5, header_ca
def get_sign_header(url: str, method, body, old_header, sign_token):
"""
获取带签名的请求头
:param url: 请求完整url
:param method: 请求方式 GET/POST
:param body: POST请求体或GET时为None
:param old_header: 原始请求头
:param sign_token: 当前会话的签名token
:return: 新请求头
"""
h = json.loads(json.dumps(old_header))
p = parse.urlparse(url)
if method.lower() == "get":
sign, header_ca = generate_signature(sign_token, p.path, p.query)
else:
sign, header_ca = generate_signature(
sign_token, p.path, json.dumps(body) if body else ""
)
h["sign"] = sign
for i in header_ca:
h[i] = header_ca[i]
return h
def copy_header(cred):
"""
复制请求头并添加cred
:param cred: 当前会话的cred
:return: 新的请求头
"""
v = json.loads(json.dumps(header))
v["cred"] = cred
return v
def login_by_token(token_code):
"""
使用token一步步拿到cred和sign_token
:param token_code: 你的skyland token
:return: (cred, sign_token)
"""
try:
# token为json对象时提取data.content
t = json.loads(token_code)
token_code = t["data"]["content"]
except:
pass
grant_code = get_grant_code(token_code)
return get_cred(grant_code)
def get_cred(grant):
"""
通过grant code获取cred和sign_token
:param grant: grant code
:return: (cred, sign_token)
"""
rsp = requests.post(
cred_code_url,
json={"code": grant, "kind": 1},
headers=header_login,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
).json()
if rsp["code"] != 0:
raise Exception(f'获得cred失败{rsp.get("messgae")}')
sign_token = rsp["data"]["token"]
cred = rsp["data"]["cred"]
return cred, sign_token
def get_grant_code(token):
"""
通过token获取grant code
:param token: 你的skyland token
:return: grant code
"""
rsp = requests.post(
grant_code_url,
json={"appCode": app_code, "token": token, "type": 0},
headers=header_login,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
).json()
if rsp["status"] != 0:
raise Exception(
f'使用token: {token[:3]}******{token[-3:]} 获得认证代码失败:{rsp.get("msg")}'
)
return rsp["data"]["code"]
def get_binding_list(cred, sign_token):
"""
查询已绑定的角色列表
:param cred: 当前cred
:param sign_token: 当前sign_token
:return: 角色列表
"""
v = []
rsp = requests.get(
binding_url,
headers=get_sign_header(
binding_url, "get", None, copy_header(cred), sign_token
),
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
).json()
if rsp["code"] != 0:
logger.error(
f"森空岛服务 | 请求角色列表出现问题:{rsp['message']}",
module="森空岛签到",
)
if rsp.get("message") == "用户未登录":
logger.error(
f"森空岛服务 | 用户登录可能失效了,请重新登录!",
module="森空岛签到",
)
return v
# 只取明日方舟arknights的绑定账号
for i in rsp["data"]["list"]:
if i.get("appCode") != "arknights":
continue
v.extend(i.get("bindingList"))
return v
def do_sign(cred, sign_token) -> dict:
"""
对所有绑定的角色进行签到
:param cred: 当前cred
:param sign_token: 当前sign_token
:return: 签到结果字典
"""
characters = get_binding_list(cred, sign_token)
result = {"成功": [], "重复": [], "失败": [], "总计": len(characters)}
for character in characters:
body = {
"uid": character.get("uid"),
"gameId": character.get("channelMasterId"),
}
rsp = requests.post(
sign_url,
headers=get_sign_header(
sign_url, "post", body, copy_header(cred), sign_token
),
json=body,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
).json()
if rsp["code"] != 0:
result[
"重复" if rsp.get("message") == "请勿重复签到!" else "失败"
].append(
f"{character.get("nickName")}{character.get("channelName")}"
)
else:
result["成功"].append(
f"{character.get("nickName")}{character.get("channelName")}"
)
time.sleep(3)
return result
# 主流程
try:
# 拿到cred和sign_token
cred, sign_token = login_by_token(token)
time.sleep(1)
# 依次签到
return do_sign(cred, sign_token)
except Exception as e:
logger.exception(f"森空岛服务 | 森空岛签到失败: {e}", module="森空岛签到")
return {"成功": [], "重复": [], "失败": [], "总计": 0}

352
app/services/system.py Normal file
View File

@@ -0,0 +1,352 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA系统服务
v4.4
作者DLmaster_361
"""
from PySide6.QtWidgets import QApplication
import sys
import ctypes
import win32gui
import win32process
import psutil
import subprocess
import tempfile
import getpass
from datetime import datetime
from pathlib import Path
from app.core import Config, logger
class _SystemHandler:
ES_CONTINUOUS = 0x80000000
ES_SYSTEM_REQUIRED = 0x00000001
def __init__(self):
self.set_Sleep()
self.set_SelfStart()
def set_Sleep(self) -> None:
"""同步系统休眠状态"""
if Config.get(Config.function_IfAllowSleep):
# 设置系统电源状态
ctypes.windll.kernel32.SetThreadExecutionState(
self.ES_CONTINUOUS | self.ES_SYSTEM_REQUIRED
)
else:
# 恢复系统电源状态
ctypes.windll.kernel32.SetThreadExecutionState(self.ES_CONTINUOUS)
def set_SelfStart(self) -> None:
"""同步开机自启"""
if Config.get(Config.start_IfSelfStart) and not self.is_startup():
# 创建任务计划
try:
# 获取当前用户和时间
current_user = getpass.getuser()
current_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
# XML 模板
xml_content = f"""<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Date>{current_time}</Date>
<Author>{current_user}</Author>
<Description>AUTO_MAA自启动服务</Description>
<URI>\\AUTO_MAA_AutoStart</URI>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<StartBoundary>{current_time}</StartBoundary>
<Enabled>true</Enabled>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<LogonType>InteractiveToken</LogonType>
<RunLevel>HighestAvailable</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>false</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>false</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>7</Priority>
</Settings>
<Actions Context="Author">
<Exec>
<Command>"{Config.app_path_sys}"</Command>
</Exec>
</Actions>
</Task>"""
# 创建临时 XML 文件并执行
with tempfile.NamedTemporaryFile(
mode="w", suffix=".xml", delete=False, encoding="utf-16"
) as f:
f.write(xml_content)
xml_file = f.name
try:
result = subprocess.run(
[
"schtasks",
"/create",
"/tn",
"AUTO_MAA_AutoStart",
"/xml",
xml_file,
"/f",
],
creationflags=subprocess.CREATE_NO_WINDOW,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
)
if result.returncode == 0:
logger.success(
f"程序自启动任务计划已创建: {Config.app_path_sys}",
module="系统服务",
)
else:
logger.error(
f"程序自启动任务计划创建失败: {result.stderr}",
module="系统服务",
)
finally:
# 删除临时文件
try:
Path(xml_file).unlink()
except:
pass
except Exception as e:
logger.exception(f"程序自启动任务计划创建失败: {e}", module="系统服务")
elif not Config.get(Config.start_IfSelfStart) and self.is_startup():
try:
result = subprocess.run(
["schtasks", "/delete", "/tn", "AUTO_MAA_AutoStart", "/f"],
creationflags=subprocess.CREATE_NO_WINDOW,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
)
if result.returncode == 0:
logger.success("程序自启动任务计划已删除", module="系统服务")
else:
logger.error(
f"程序自启动任务计划删除失败: {result.stderr}",
module="系统服务",
)
except Exception as e:
logger.exception(f"程序自启动任务计划删除失败: {e}", module="系统服务")
def set_power(self, mode) -> None:
"""
执行系统电源操作
:param mode: 电源操作模式,支持 "NoAction", "Shutdown", "Hibernate", "Sleep", "KillSelf", "ShutdownForce"
"""
if sys.platform.startswith("win"):
if mode == "NoAction":
logger.info("不执行系统电源操作", module="系统服务")
elif mode == "Shutdown":
self.kill_emulator_processes()
logger.info("执行关机操作", module="系统服务")
subprocess.run(["shutdown", "/s", "/t", "0"])
elif mode == "ShutdownForce":
logger.info("执行强制关机操作", module="系统服务")
subprocess.run(["shutdown", "/s", "/t", "0", "/f"])
elif mode == "Hibernate":
logger.info("执行休眠操作", module="系统服务")
subprocess.run(["shutdown", "/h"])
elif mode == "Sleep":
logger.info("执行睡眠操作", module="系统服务")
subprocess.run(
["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"]
)
elif mode == "KillSelf":
logger.info("执行退出主程序操作", module="系统服务")
Config.main_window.close()
QApplication.quit()
sys.exit(0)
elif sys.platform.startswith("linux"):
if mode == "NoAction":
logger.info("不执行系统电源操作", module="系统服务")
elif mode == "Shutdown":
logger.info("执行关机操作", module="系统服务")
subprocess.run(["shutdown", "-h", "now"])
elif mode == "Hibernate":
logger.info("执行休眠操作", module="系统服务")
subprocess.run(["systemctl", "hibernate"])
elif mode == "Sleep":
logger.info("执行睡眠操作", module="系统服务")
subprocess.run(["systemctl", "suspend"])
elif mode == "KillSelf":
logger.info("执行退出主程序操作", module="系统服务")
Config.main_window.close()
QApplication.quit()
sys.exit(0)
def kill_emulator_processes(self):
"""这里暂时仅支持 MuMu 模拟器"""
logger.info("正在清除模拟器进程", module="系统服务")
keywords = ["Nemu", "nemu", "emulator", "MuMu"]
for proc in psutil.process_iter(["pid", "name"]):
try:
pname = proc.info["name"].lower()
if any(keyword.lower() in pname for keyword in keywords):
proc.kill()
logger.info(
f"已关闭 MuMu 模拟器进程: {proc.info['name']}",
module="系统服务",
)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
logger.success("模拟器进程清除完成", module="系统服务")
def is_startup(self) -> bool:
"""判断程序是否已经开机自启"""
try:
result = subprocess.run(
["schtasks", "/query", "/tn", "AUTO_MAA_AutoStart"],
creationflags=subprocess.CREATE_NO_WINDOW,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
)
return result.returncode == 0
except Exception as e:
logger.exception(f"检查任务计划程序失败: {e}", module="系统服务")
return False
def get_window_info(self) -> list:
"""获取当前前台窗口信息"""
def callback(hwnd, window_info):
if win32gui.IsWindowVisible(hwnd) and win32gui.GetWindowText(hwnd):
_, pid = win32process.GetWindowThreadProcessId(hwnd)
process = psutil.Process(pid)
window_info.append((win32gui.GetWindowText(hwnd), process.exe()))
return True
window_info = []
win32gui.EnumWindows(callback, window_info)
return window_info
def kill_process(self, path: Path) -> None:
"""
根据路径中止进程
:param path: 进程路径
"""
logger.info(f"开始中止进程: {path}", module="系统服务")
for pid in self.search_pids(path):
killprocess = subprocess.Popen(
f"taskkill /F /T /PID {pid}",
shell=True,
creationflags=subprocess.CREATE_NO_WINDOW,
)
killprocess.wait()
logger.success(f"进程已中止: {path}", module="系统服务")
def search_pids(self, path: Path) -> list:
"""
根据路径查找进程PID
:param path: 进程路径
:return: 匹配的进程PID列表
"""
logger.info(f"开始查找进程 PID: {path}", module="系统服务")
pids = []
for proc in psutil.process_iter(["pid", "exe"]):
try:
if proc.info["exe"] and proc.info["exe"].lower() == str(path).lower():
pids.append(proc.info["pid"])
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
# 进程可能在此期间已结束或无法访问,忽略这些异常
pass
return pids
System = _SystemHandler()

2217
app/ui/Widget.py Normal file

File diff suppressed because it is too large Load Diff

35
app/ui/__init__.py Normal file
View File

@@ -0,0 +1,35 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA图形化界面包
v4.4
作者DLmaster_361
"""
__version__ = "4.2.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .main_window import AUTO_MAA
from .Widget import ProgressRingMessageBox
__all__ = ["AUTO_MAA", "ProgressRingMessageBox"]

625
app/ui/dispatch_center.py Normal file
View File

@@ -0,0 +1,625 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA调度中枢界面
v4.4
作者DLmaster_361
"""
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QStackedWidget,
QHBoxLayout,
)
from qfluentwidgets import (
BodyLabel,
CardWidget,
ScrollArea,
FluentIcon,
HeaderCardWidget,
FluentIcon,
TextBrowser,
ComboBox,
SubtitleLabel,
PushButton,
)
from PySide6.QtGui import QTextCursor
from typing import List, Dict
from app.core import Config, TaskManager, Task, MainInfoBar, logger
from .Widget import StatefulItemCard, ComboBoxMessageBox, PivotArea
class DispatchCenter(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("调度中枢")
# 添加任务按钮
self.multi_button = PushButton(FluentIcon.ADD, "添加任务", self)
self.multi_button.setToolTip("添加任务")
self.multi_button.clicked.connect(self.start_multi_task)
# 电源动作设置组件
self.power_combox = ComboBox()
self.power_combox.addItem("无动作", userData="NoAction")
self.power_combox.addItem("退出软件", userData="KillSelf")
self.power_combox.addItem("睡眠", userData="Sleep")
self.power_combox.addItem("休眠", userData="Hibernate")
self.power_combox.addItem("关机", userData="Shutdown")
self.power_combox.addItem("关机(强制)", userData="ShutdownForce")
self.power_combox.setCurrentText("无动作")
self.power_combox.currentIndexChanged.connect(self.set_power_sign)
# 导航栏
self.pivotArea = PivotArea(self)
self.pivot = self.pivotArea.pivot
# 导航页面组
self.stackedWidget = QStackedWidget(self)
self.stackedWidget.setContentsMargins(0, 0, 0, 0)
self.stackedWidget.setStyleSheet("background: transparent; border: none;")
self.script_list: Dict[str, DispatchCenter.DispatchBox] = {}
# 添加主调度台
dispatch_box = self.DispatchBox("主调度台", self)
self.script_list["主调度台"] = dispatch_box
self.stackedWidget.addWidget(self.script_list["主调度台"])
self.pivot.addItem(
routeKey="主调度台",
text="主调度台",
onClick=self.update_top_bar,
icon=FluentIcon.CAFE,
)
# 顶栏组合
h_layout = QHBoxLayout()
h_layout.addWidget(self.multi_button)
h_layout.addWidget(self.pivotArea)
h_layout.addWidget(BodyLabel("全部完成后", self))
h_layout.addWidget(self.power_combox)
h_layout.setContentsMargins(11, 5, 11, 0)
self.Layout = QVBoxLayout(self)
self.Layout.addLayout(h_layout)
self.Layout.addWidget(self.stackedWidget)
self.Layout.setContentsMargins(0, 0, 0, 0)
self.pivot.currentItemChanged.connect(
lambda index: self.stackedWidget.setCurrentWidget(self.script_list[index])
)
def add_board(self, task: Task) -> None:
"""
为任务添加一个调度台界面并绑定信号
:param task: 任务对象
"""
logger.info(f"添加调度台:{task.name}", module="调度中枢")
dispatch_box = self.DispatchBox(task.name, self)
dispatch_box.top_bar.main_button.clicked.connect(
lambda: TaskManager.stop_task(task.name)
)
task.create_task_list.connect(dispatch_box.info.task.create_task)
task.create_user_list.connect(dispatch_box.info.user.create_user)
task.update_task_list.connect(dispatch_box.info.task.update_task)
task.update_user_list.connect(dispatch_box.info.user.update_user)
task.update_log_text.connect(dispatch_box.info.log_text.text.setText)
task.accomplish.connect(lambda: self.del_board(f"调度台_{task.name}"))
self.script_list[f"调度台_{task.name}"] = dispatch_box
self.stackedWidget.addWidget(self.script_list[f"调度台_{task.name}"])
self.pivot.addItem(routeKey=f"调度台_{task.name}", text=f"调度台 {task.name}")
logger.success(f"调度台 {task.name} 添加成功", module="调度中枢")
def del_board(self, name: str) -> None:
"""
删除指定子界面
:param name: 子界面名称
"""
logger.info(f"删除调度台:{name}", module="调度中枢")
self.pivot.setCurrentItem("主调度台")
self.stackedWidget.removeWidget(self.script_list[name])
self.script_list[name].deleteLater()
self.script_list.pop(name)
self.pivot.removeWidget(name)
logger.success(f"调度台 {name} 删除成功", module="调度中枢")
def connect_main_board(self, task: Task) -> None:
"""
将任务连接到主调度台
:param task: 任务对象
"""
logger.info(f"主调度台载入任务:{task.name}", module="调度中枢")
self.script_list["主调度台"].top_bar.Lable.setText(
f"{task.name} - {task.mode.replace('_主调度台','')}模式"
)
self.script_list["主调度台"].top_bar.Lable.show()
self.script_list["主调度台"].top_bar.object.hide()
self.script_list["主调度台"].top_bar.mode.hide()
self.script_list["主调度台"].top_bar.main_button.clicked.disconnect()
self.script_list["主调度台"].top_bar.main_button.setText("中止任务")
self.script_list["主调度台"].top_bar.main_button.clicked.connect(
lambda: TaskManager.stop_task(task.name)
)
task.create_task_list.connect(
self.script_list["主调度台"].info.task.create_task
)
task.create_user_list.connect(
self.script_list["主调度台"].info.user.create_user
)
task.update_task_list.connect(
self.script_list["主调度台"].info.task.update_task
)
task.update_user_list.connect(
self.script_list["主调度台"].info.user.update_user
)
task.update_log_text.connect(
self.script_list["主调度台"].info.log_text.text.setText
)
task.accomplish.connect(
lambda logs: self.disconnect_main_board(task.name, logs)
)
logger.success(f"主调度台成功载入:{task.name} ", module="调度中枢")
def disconnect_main_board(self, name: str, logs: list) -> None:
"""
断开主调度台
:param name: 任务名称
:param logs: 任务日志列表
"""
logger.info(f"主调度台断开任务:{name}", module="调度中枢")
self.script_list["主调度台"].top_bar.Lable.hide()
self.script_list["主调度台"].top_bar.object.show()
self.script_list["主调度台"].top_bar.mode.show()
self.script_list["主调度台"].top_bar.main_button.clicked.disconnect()
self.script_list["主调度台"].top_bar.main_button.setText("开始任务")
self.script_list["主调度台"].top_bar.main_button.clicked.connect(
self.script_list["主调度台"].top_bar.start_main_task
)
if len(logs) > 0:
history = ""
for log in logs:
history += (
f"任务名称:{log[0]}{log[1]["History"].replace("\n","\n ")}\n"
)
self.script_list["主调度台"].info.log_text.text.setText(history)
else:
self.script_list["主调度台"].info.log_text.text.setText("没有任务被执行")
logger.success(f"主调度台成功断开:{name}", module="调度中枢")
def update_top_bar(self):
"""更新顶栏"""
self.script_list["主调度台"].top_bar.object.clear()
for name, info in Config.queue_dict.items():
self.script_list["主调度台"].top_bar.object.addItem(
(
"队列"
if info["Config"].get(info["Config"].QueueSet_Name) == ""
else f"队列 - {info["Config"].get(info["Config"].QueueSet_Name)}"
),
userData=name,
)
for name, info in Config.script_dict.items():
self.script_list["主调度台"].top_bar.object.addItem(
(
f"实例 - {info['Type']}"
if info["Config"].get_name() == ""
else f"实例 - {info['Type']} - {info['Config'].get_name()}"
),
userData=name,
)
if len(Config.queue_dict) == 1:
self.script_list["主调度台"].top_bar.object.setCurrentIndex(0)
elif len(Config.script_dict) == 1:
self.script_list["主调度台"].top_bar.object.setCurrentIndex(
len(Config.queue_dict)
)
else:
self.script_list["主调度台"].top_bar.object.setCurrentIndex(-1)
self.script_list["主调度台"].top_bar.mode.clear()
self.script_list["主调度台"].top_bar.mode.addItems(["自动代理", "人工排查"])
self.script_list["主调度台"].top_bar.mode.setCurrentIndex(0)
def update_power_sign(self) -> None:
"""更新电源设置"""
mode_book = {
"NoAction": "无动作",
"KillSelf": "退出软件",
"Sleep": "睡眠",
"Hibernate": "休眠",
"Shutdown": "关机",
"ShutdownForce": "关机(强制)",
}
self.power_combox.currentIndexChanged.disconnect()
self.power_combox.setCurrentText(mode_book[Config.power_sign])
self.power_combox.currentIndexChanged.connect(self.set_power_sign)
def set_power_sign(self) -> None:
"""设置所有任务完成后动作"""
if not Config.running_list:
self.power_combox.currentIndexChanged.disconnect()
self.power_combox.setCurrentText("无动作")
self.power_combox.currentIndexChanged.connect(self.set_power_sign)
logger.warning("没有正在运行的任务,无法设置任务完成后动作")
MainInfoBar.push_info_bar(
"warning", "没有正在运行的任务", "无法设置任务完成后动作", 5000
)
else:
Config.set_power_sign(self.power_combox.currentData())
def start_multi_task(self) -> None:
"""开始多开任务"""
# 获取所有可用的队列和实例
text_list = []
data_list = []
for name, info in Config.queue_dict.items():
if name in Config.running_list:
continue
text_list.append(
"队列"
if info["Config"].get(info["Config"].QueueSet_Name) == ""
else f"队列 - {info["Config"].get(info["Config"].QueueSet_Name)}"
)
data_list.append(name)
for name, info in Config.script_dict.items():
if name in Config.running_list:
continue
text_list.append(
f"实例 - {info['Type']}"
if info["Config"].get_name() == ""
else f"实例 - {info['Type']} - {info['Config'].get_name()}"
)
data_list.append(name)
choice = ComboBoxMessageBox(
self.window(),
"选择一个对象以添加相应多开任务",
["选择调度对象"],
[text_list],
[data_list],
)
if choice.exec() and choice.input[0].currentIndex() != -1:
if choice.input[0].currentData() in Config.running_list:
logger.warning(
f"任务已存在:{choice.input[0].currentData()}", module="调度中枢"
)
MainInfoBar.push_info_bar(
"warning", "任务已存在", choice.input[0].currentData(), 5000
)
return None
if "调度队列" in choice.input[0].currentData():
logger.info(
f"用户添加任务:{choice.input[0].currentData()}", module="调度中枢"
)
TaskManager.add_task(
"自动代理_新调度台",
choice.input[0].currentData(),
Config.queue_dict[choice.input[0].currentData()]["Config"].toDict(),
)
elif "脚本" in choice.input[0].currentData():
logger.info(
f"用户添加任务:{choice.input[0].currentData()}", module="调度中枢"
)
TaskManager.add_task(
"自动代理_新调度台",
f"自定义队列 - {choice.input[0].currentData()}",
{"Queue": {"Script_0": choice.input[0].currentData()}},
)
class DispatchBox(QWidget):
def __init__(self, name: str, parent=None):
super().__init__(parent)
self.setObjectName(name)
self.top_bar = self.DispatchTopBar(self, name)
self.info = self.DispatchInfoCard(self)
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.addWidget(self.top_bar)
content_layout.addWidget(self.info)
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setContentsMargins(0, 0, 0, 0)
scrollArea.setStyleSheet("background: transparent; border: none;")
scrollArea.setWidget(content_widget)
layout = QVBoxLayout(self)
layout.addWidget(scrollArea)
class DispatchTopBar(CardWidget):
def __init__(self, parent=None, name: str = None):
super().__init__(parent)
Layout = QHBoxLayout(self)
if name == "主调度台":
self.Lable = SubtitleLabel("", self)
self.Lable.hide()
self.object = ComboBox()
self.object.setPlaceholderText("请选择调度对象")
self.mode = ComboBox()
self.mode.setPlaceholderText("请选择调度模式")
self.main_button = PushButton("开始任务")
self.main_button.clicked.connect(self.start_main_task)
Layout.addWidget(self.Lable)
Layout.addWidget(self.object)
Layout.addWidget(self.mode)
Layout.addStretch(1)
Layout.addWidget(self.main_button)
else:
self.Lable = SubtitleLabel(name, self)
self.main_button = PushButton("中止任务")
Layout.addWidget(self.Lable)
Layout.addStretch(1)
Layout.addWidget(self.main_button)
def start_main_task(self):
"""从主调度台开始任务"""
if self.object.currentIndex() == -1:
logger.warning("未选择调度对象", module="调度中枢")
MainInfoBar.push_info_bar(
"warning", "未选择调度对象", "请选择后再开始任务", 5000
)
return None
if self.mode.currentIndex() == -1:
logger.warning("未选择调度模式", module="调度中枢")
MainInfoBar.push_info_bar(
"warning", "未选择调度模式", "请选择后再开始任务", 5000
)
return None
if self.object.currentData() in Config.running_list:
logger.warning(
f"任务已存在:{self.object.currentData()}", module="调度中枢"
)
MainInfoBar.push_info_bar(
"warning", "任务已存在", self.object.currentData(), 5000
)
return None
if (
"脚本" in self.object.currentData()
and Config.script_dict[self.object.currentData()]["Type"]
== "General"
and self.mode.currentData() == "人工排查"
):
logger.warning("通用脚本类型不存在人工排查功能", module="调度中枢")
MainInfoBar.push_info_bar(
"warning", "不支持的任务", "通用脚本无人工排查功能", 5000
)
return None
if "调度队列" in self.object.currentData():
logger.info(
f"用户添加任务:{self.object.currentData()}", module="调度中枢"
)
TaskManager.add_task(
f"{self.mode.currentText()}_主调度台",
self.object.currentData(),
Config.queue_dict[self.object.currentData()]["Config"].toDict(),
)
elif "脚本" in self.object.currentData():
logger.info(
f"用户添加任务:{self.object.currentData()}", module="调度中枢"
)
TaskManager.add_task(
f"{self.mode.currentText()}_主调度台",
"自定义队列",
{"Queue": {"Script_0": self.object.currentData()}},
)
class DispatchInfoCard(HeaderCardWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setTitle("调度信息")
self.task = self.TaskInfoCard(self)
self.user = self.UserInfoCard(self)
self.log_text = self.LogCard(self)
self.viewLayout.addWidget(self.task)
self.viewLayout.addWidget(self.user)
self.viewLayout.addWidget(self.log_text)
self.viewLayout.setStretch(0, 1)
self.viewLayout.setStretch(1, 1)
self.viewLayout.setStretch(2, 5)
def update_board(self, task_list: list, user_list: list, log: str):
"""更新调度信息"""
self.task.update_task(task_list)
self.user.update_user(user_list)
self.log_text.text.setText(log)
class TaskInfoCard(HeaderCardWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setTitle("任务队列")
self.Layout = QVBoxLayout()
self.viewLayout.addLayout(self.Layout)
self.viewLayout.setContentsMargins(3, 0, 3, 3)
self.task_cards: List[StatefulItemCard] = []
def create_task(self, task_list: list):
"""
创建任务队列
:param task_list: 包含任务信息的任务列表
"""
while self.Layout.count() > 0:
item = self.Layout.takeAt(0)
if item.spacerItem():
self.Layout.removeItem(item.spacerItem())
elif item.widget():
item.widget().deleteLater()
self.task_cards = []
for task in task_list:
self.task_cards.append(StatefulItemCard(task))
self.Layout.addWidget(self.task_cards[-1])
self.Layout.addStretch(1)
def update_task(self, task_list: list):
"""
更新任务队列信息
:param task_list: 包含任务信息的任务列表
"""
for i in range(len(task_list)):
self.task_cards[i].update_status(task_list[i][1])
class UserInfoCard(HeaderCardWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setTitle("用户队列")
self.Layout = QVBoxLayout()
self.viewLayout.addLayout(self.Layout)
self.viewLayout.setContentsMargins(3, 0, 3, 3)
self.user_cards: List[StatefulItemCard] = []
def create_user(self, user_list: list):
"""
创建用户队列
:param user_list: 包含用户信息的用户列表
"""
while self.Layout.count() > 0:
item = self.Layout.takeAt(0)
if item.spacerItem():
self.Layout.removeItem(item.spacerItem())
elif item.widget():
item.widget().deleteLater()
self.user_cards = []
for user in user_list:
self.user_cards.append(StatefulItemCard(user))
self.Layout.addWidget(self.user_cards[-1])
self.Layout.addStretch(1)
def update_user(self, user_list: list):
"""
更新用户队列信息
:param user_list: 包含用户信息的用户列表
"""
for i in range(len(user_list)):
self.user_cards[i].Label.setText(user_list[i][0])
self.user_cards[i].update_status(user_list[i][1])
class LogCard(HeaderCardWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setTitle("日志")
self.text = TextBrowser()
self.viewLayout.setContentsMargins(3, 0, 3, 3)
self.viewLayout.addWidget(self.text)
self.text.textChanged.connect(self.to_end)
def to_end(self):
"""滚动到底部"""
self.text.moveCursor(QTextCursor.End)
self.text.ensureCursorVisible()

729
app/ui/downloader.py Normal file
View File

@@ -0,0 +1,729 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA更新器
v4.4
作者DLmaster_361
"""
import zipfile
import requests
import subprocess
import time
from functools import partial
from pathlib import Path
from PySide6.QtWidgets import QDialog, QVBoxLayout
from qfluentwidgets import (
ProgressBar,
IndeterminateProgressBar,
BodyLabel,
setTheme,
Theme,
)
from PySide6.QtGui import QCloseEvent
from PySide6.QtCore import QThread, Signal, QTimer, QEventLoop
from typing import List, Dict, Union
from app.core import Config, logger
from app.services import System
def version_text(version_numb: list) -> str:
"""将版本号列表转为可读的文本信息"""
while len(version_numb) < 4:
version_numb.append(0)
if version_numb[3] == 0:
version = f"v{'.'.join(str(_) for _ in version_numb[0:3])}"
else:
version = (
f"v{'.'.join(str(_) for _ in version_numb[0:3])}-beta.{version_numb[3]}"
)
return version
class DownloadProcess(QThread):
"""分段下载子线程"""
progress = Signal(int)
accomplish = Signal(float)
def __init__(
self,
url: str,
start_byte: int,
end_byte: int,
download_path: Path,
check_times: int = -1,
) -> None:
super(DownloadProcess, self).__init__()
self.setObjectName(f"DownloadProcess-{url}-{start_byte}-{end_byte}")
logger.info(f"创建下载子线程:{self.objectName()}", module="下载子线程")
self.url = url
self.start_byte = start_byte
self.end_byte = end_byte
self.download_path = download_path
self.check_times = check_times
@logger.catch
def run(self) -> None:
# 清理可能存在的临时文件
if self.download_path.exists():
self.download_path.unlink()
logger.info(
f"开始下载:{self.url},范围:{self.start_byte}-{self.end_byte},存储地址:{self.download_path}",
module="下载子线程",
)
headers = (
{"Range": f"bytes={self.start_byte}-{self.end_byte}"}
if not (self.start_byte == -1 or self.end_byte == -1)
else None
)
while not self.isInterruptionRequested() and self.check_times != 0:
try:
start_time = time.time()
response = requests.get(
self.url,
headers=headers,
timeout=10,
stream=True,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
)
if response.status_code not in [200, 206]:
if self.check_times != -1:
self.check_times -= 1
logger.error(
f"连接失败:{self.url},状态码:{response.status_code},剩余重试次数:{self.check_times}",
module="下载子线程",
)
time.sleep(1)
continue
logger.info(
f"连接成功:{self.url},状态码:{response.status_code}",
module="下载子线程",
)
downloaded_size = 0
with self.download_path.open(mode="wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if self.isInterruptionRequested():
break
f.write(chunk)
downloaded_size += len(chunk)
self.progress.emit(downloaded_size)
if self.isInterruptionRequested():
if self.download_path.exists():
self.download_path.unlink()
self.accomplish.emit(0)
logger.info(f"下载中止:{self.url}", module="下载子线程")
else:
self.accomplish.emit(time.time() - start_time)
logger.success(
f"下载完成:{self.url},实际下载大小:{downloaded_size} 字节,耗时:{time.time() - start_time:.2f}",
module="下载子线程",
)
break
except Exception as e:
if self.check_times != -1:
self.check_times -= 1
logger.exception(
f"下载出错:{self.url},错误信息:{e},剩余重试次数:{self.check_times}",
module="下载子线程",
)
time.sleep(1)
else:
if self.download_path.exists():
self.download_path.unlink()
self.accomplish.emit(0)
logger.error(f"下载失败:{self.url}", module="下载子线程")
class ZipExtractProcess(QThread):
"""解压子线程"""
info = Signal(str)
accomplish = Signal()
def __init__(self, name: str, app_path: Path, download_path: Path) -> None:
super(ZipExtractProcess, self).__init__()
self.setObjectName(f"ZipExtractProcess-{name}")
logger.info(f"创建解压子线程:{self.objectName()}", module="解压子线程")
self.name = name
self.app_path = app_path
self.download_path = download_path
@logger.catch
def run(self) -> None:
try:
logger.info(
f"开始解压:{self.download_path}{self.app_path}",
module="解压子线程",
)
while True:
if self.isInterruptionRequested():
self.download_path.unlink()
return None
try:
with zipfile.ZipFile(self.download_path, "r") as zip_ref:
zip_ref.extractall(self.app_path)
self.accomplish.emit()
logger.success(
f"解压完成:{self.download_path}{self.app_path}",
module="解压子线程",
)
break
except PermissionError:
if self.name == "AUTO_MAA":
self.info.emit(f"解压出错AUTO_MAA正在运行正在尝试将其关闭")
System.kill_process(self.app_path / "AUTO_MAA.exe")
else:
self.info.emit(f"解压出错:{self.name}正在运行,正在等待其关闭")
logger.warning(
f"解压出错:{self.name}正在运行,正在等待其关闭",
module="解压子线程",
)
time.sleep(1)
except Exception as e:
e = str(e)
e = "\n".join([e[_ : _ + 75] for _ in range(0, len(e), 75)])
self.info.emit(f"解压更新时出错:\n{e}")
logger.exception(f"解压更新时出错:{e}", module="解压子线程")
return None
class DownloadManager(QDialog):
"""下载管理器"""
speed_test_accomplish = Signal()
download_accomplish = Signal()
download_process_clear = Signal()
isInterruptionRequested = False
def __init__(self, app_path: Path, name: str, version: list, config: dict) -> None:
super().__init__()
self.app_path = app_path
self.name = name
self.version = version
self.config = config
self.download_path = app_path / "DOWNLOAD_TEMP.zip" # 临时下载文件的路径
self.download_process_dict: Dict[str, DownloadProcess] = {}
self.timer_dict: Dict[str, QTimer] = {}
self.if_speed_test_accomplish = False
self.resize(700, 70)
setTheme(Theme.AUTO, lazy=True)
# 创建垂直布局
self.Layout = QVBoxLayout(self)
self.info = BodyLabel("正在初始化", self)
self.progress_1 = IndeterminateProgressBar(self)
self.progress_2 = ProgressBar(self)
self.update_progress(0, 0, 0)
self.Layout.addWidget(self.info)
self.Layout.addStretch(1)
self.Layout.addWidget(self.progress_1)
self.Layout.addWidget(self.progress_2)
self.Layout.addStretch(1)
def run(self) -> None:
logger.info(
f"开始执行下载任务:{self.name},版本:{version_text(self.version)}",
module="下载管理器",
)
if self.name == "AUTO_MAA":
if self.config["mode"] == "Proxy":
self.start_test_speed()
self.speed_test_accomplish.connect(self.start_download)
elif self.config["mode"] == "MirrorChyan":
self.start_download()
elif self.config["mode"] == "MirrorChyan":
self.start_download()
def get_download_url(self, mode: str) -> Union[str, Dict[str, str]]:
"""
生成下载链接
:param mode: "测速""下载"
:return: 测速模式返回 url 字典,下载模式返回 url 字符串
"""
url_dict = {}
if mode == "测速":
url_dict["GitHub站"] = (
f"https://github.com/DLmaster361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
)
url_dict["官方镜像站"] = (
f"https://gitee.com/DLmaster_361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
)
for name, download_url_head in self.config["download_dict"].items():
url_dict[name] = (
f"{download_url_head}AUTO_MAA_{version_text(self.version)}.zip"
)
for proxy_url in self.config["proxy_list"]:
url_dict[proxy_url] = (
f"{proxy_url}https://github.com/DLmaster361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
)
return url_dict
elif mode == "下载":
if self.name == "AUTO_MAA":
if self.config["mode"] == "Proxy":
if "selected" in self.config:
selected_url = self.config["selected"]
elif "speed_result" in self.config:
selected_url = max(
self.config["speed_result"],
key=self.config["speed_result"].get,
)
if selected_url == "GitHub站":
return f"https://github.com/DLmaster361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
elif selected_url == "官方镜像站":
return f"https://gitee.com/DLmaster_361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
elif selected_url in self.config["download_dict"].keys():
return f"{self.config["download_dict"][selected_url]}AUTO_MAA_{version_text(self.version)}.zip"
else:
return f"{selected_url}https://github.com/DLmaster361/AUTO_MAA/releases/download/{version_text(self.version)}/AUTO_MAA_{version_text(self.version)}.zip"
elif self.config["mode"] == "MirrorChyan":
with requests.get(
self.config["url"],
allow_redirects=True,
timeout=10,
stream=True,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
) as response:
if response.status_code == 200:
return response.url
elif self.config["mode"] == "MirrorChyan":
with requests.get(
self.config["url"],
allow_redirects=True,
timeout=10,
stream=True,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
) as response:
if response.status_code == 200:
return response.url
def start_test_speed(self) -> None:
"""启动测速任务下载4MB文件以测试下载速度"""
if self.isInterruptionRequested:
return None
url_dict = self.get_download_url("测速")
self.test_speed_result: Dict[str, float] = {}
logger.info(
f"开始测速任务,链接:{list(url_dict.items())}", module="下载管理器"
)
for name, url in url_dict.items():
if self.isInterruptionRequested:
break
# 创建测速线程下载4MB文件以测试下载速度
self.download_process_dict[name] = DownloadProcess(
url,
0,
4194304,
self.app_path / f"{name.replace('/','').replace(':','')}.zip",
10,
)
self.test_speed_result[name] = -1
self.download_process_dict[name].accomplish.connect(
partial(self.check_test_speed, name)
)
self.download_process_dict[name].start()
# 创建防超时定时器30秒后强制停止测速
timer = QTimer(self)
timer.setSingleShot(True)
timer.timeout.connect(partial(self.kill_speed_test, name))
timer.start(30000)
self.timer_dict[name] = timer
self.update_info("正在测速预计用时30秒")
self.update_progress(0, 1, 0)
def kill_speed_test(self, name: str) -> None:
"""
强制停止测速任务
:param name: 测速任务的名称
"""
if name in self.download_process_dict:
self.download_process_dict[name].requestInterruption()
def check_test_speed(self, name: str, t: float) -> None:
"""
更新测速子任务wc信息并检查测速任务是否允许结束
:param name: 测速任务的名称
:param t: 测速任务的耗时
"""
# 计算下载速度
if self.isInterruptionRequested:
self.update_info(f"已中止测速进程:{name}")
self.test_speed_result[name] = 0
elif t != 0:
self.update_info(f"{name}{ 4 / t:.2f} MB/s")
self.test_speed_result[name] = 4 / t
else:
self.update_info(f"{name}{ 0:.2f} MB/s")
self.test_speed_result[name] = 0
self.update_progress(
0,
len(self.test_speed_result),
sum(1 for speed in self.test_speed_result.values() if speed != -1),
)
# 删除临时文件
if (self.app_path / f"{name.replace('/','').replace(':','')}.zip").exists():
(self.app_path / f"{name.replace('/','').replace(':','')}.zip").unlink()
# 清理下载线程
self.timer_dict[name].stop()
self.timer_dict[name].deleteLater()
self.timer_dict.pop(name)
self.download_process_dict[name].requestInterruption()
self.download_process_dict[name].quit()
self.download_process_dict[name].wait()
self.download_process_dict[name].deleteLater()
self.download_process_dict.pop(name)
if not self.download_process_dict:
self.download_process_clear.emit()
# 当有速度大于1 MB/s的链接或存在3个即以上链接测速完成时停止其他测速
if not self.if_speed_test_accomplish and (
sum(1 for speed in self.test_speed_result.values() if speed > 0) >= 3
or any(speed > 1 for speed in self.test_speed_result.values())
):
self.if_speed_test_accomplish = True
for timer in self.timer_dict.values():
timer.timeout.emit()
if any(speed == -1 for _, speed in self.test_speed_result.items()):
return None
# 保存测速结果
self.config["speed_result"] = self.test_speed_result
logger.success(
f"测速完成,结果:{list(self.test_speed_result.items())}",
module="下载管理器",
)
self.update_info("测速完成!")
self.speed_test_accomplish.emit()
def start_download(self) -> None:
"""开始下载任务"""
if self.isInterruptionRequested:
return None
url = self.get_download_url("下载")
self.downloaded_size_list: List[List[int, bool]] = []
logger.info(f"开始下载任务,链接:{url}", module="下载管理器")
response = requests.head(
url,
timeout=10,
proxies={
"http": Config.get(Config.update_ProxyAddress),
"https": Config.get(Config.update_ProxyAddress),
},
)
self.file_size = int(response.headers.get("content-length", 0))
part_size = self.file_size // self.config["thread_numb"]
self.downloaded_size = 0
self.last_download_size = 0
self.last_time = time.time()
self.speed = 0
# 拆分下载任务,启用多线程下载
for i in range(self.config["thread_numb"]):
if self.isInterruptionRequested:
break
# 计算单任务下载范围
start_byte = i * part_size
end_byte = (
(i + 1) * part_size - 1
if (i != self.config["thread_numb"] - 1)
else self.file_size - 1
)
# 创建下载子线程
self.download_process_dict[f"part{i}"] = DownloadProcess(
url,
-1 if self.config["mode"] == "MirrorChyan" else start_byte,
-1 if self.config["mode"] == "MirrorChyan" else end_byte,
self.download_path.with_suffix(f".part{i}"),
1 if self.config["mode"] == "MirrorChyan" else -1,
)
self.downloaded_size_list.append([0, False])
self.download_process_dict[f"part{i}"].progress.connect(
partial(self.update_download, i)
)
self.download_process_dict[f"part{i}"].accomplish.connect(
partial(self.check_download, i)
)
self.download_process_dict[f"part{i}"].start()
def update_download(self, index: str, current: int) -> None:
"""
更新子任务下载进度,将信息更新到 UI 上
:param index: 下载任务的索引
:param current: 当前下载大小
"""
# 更新指定线程的下载进度
self.downloaded_size_list[index][0] = current
self.downloaded_size = sum([_[0] for _ in self.downloaded_size_list])
self.update_progress(0, self.file_size, self.downloaded_size)
# 速度每秒更新一次
if time.time() - self.last_time >= 1.0:
self.speed = (
(self.downloaded_size - self.last_download_size)
/ (time.time() - self.last_time)
/ 1024
)
self.last_download_size = self.downloaded_size
self.last_time = time.time()
if self.speed >= 1024:
self.update_info(
f"正在下载:{self.name} 已下载:{self.downloaded_size / 1048576:.2f}/{self.file_size / 1048576:.2f} MB {self.downloaded_size / self.file_size * 100:.2f}% 下载速度:{self.speed / 1024:.2f} MB/s",
)
else:
self.update_info(
f"正在下载:{self.name} 已下载:{self.downloaded_size / 1048576:.2f}/{self.file_size / 1048576:.2f} MB {self.downloaded_size / self.file_size * 100:.2f}% 下载速度:{self.speed:.2f} KB/s",
)
def check_download(self, index: str, t: float) -> None:
"""
更新下载子任务完成信息,检查下载任务是否完成,完成后自动执行后续处理任务
:param index: 下载任务的索引
:param t: 下载任务的耗时
"""
# 标记下载线程完成
self.downloaded_size_list[index][1] = True
# 清理下载线程
self.download_process_dict[f"part{index}"].requestInterruption()
self.download_process_dict[f"part{index}"].quit()
self.download_process_dict[f"part{index}"].wait()
self.download_process_dict[f"part{index}"].deleteLater()
self.download_process_dict.pop(f"part{index}")
if not self.download_process_dict:
self.download_process_clear.emit()
if (
any([not _[1] for _ in self.downloaded_size_list])
or self.isInterruptionRequested
):
return None
# 合并下载的分段文件
logger.info(
f"所有分段下载完成:{self.name},开始合并分段文件到 {self.download_path}",
module="下载管理器",
)
with self.download_path.open(mode="wb") as outfile:
for i in range(self.config["thread_numb"]):
with self.download_path.with_suffix(f".part{i}").open(
mode="rb"
) as infile:
outfile.write(infile.read())
self.download_path.with_suffix(f".part{i}").unlink()
logger.success(
f"合并完成:{self.name},下载文件大小:{self.download_path.stat().st_size} 字节",
module="下载管理器",
)
self.update_info("正在解压更新文件")
self.update_progress(0, 0, 0)
# 创建解压线程
self.zip_extract = ZipExtractProcess(
self.name, self.app_path, self.download_path
)
self.zip_loop = QEventLoop()
self.zip_extract.info.connect(self.update_info)
self.zip_extract.accomplish.connect(self.zip_loop.quit)
self.zip_extract.start()
self.zip_loop.exec()
self.update_info("正在删除临时文件")
self.update_progress(0, 0, 0)
if (self.app_path / "changes.json").exists():
(self.app_path / "changes.json").unlink()
if self.download_path.exists():
self.download_path.unlink()
# 下载完成后打开对应程序
if not self.isInterruptionRequested and self.name == "MAA":
subprocess.Popen(
[self.app_path / "MAA.exe"],
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.DETACHED_PROCESS
| subprocess.CREATE_NO_WINDOW,
)
if self.name == "AUTO_MAA":
self.update_info(f"即将安装{self.name}")
else:
self.update_info(f"{self.name}下载成功!")
self.update_progress(0, 100, 100)
self.download_accomplish.emit()
def update_info(self, text: str) -> None:
"""
更新信息文本
:param text: 要显示的信息文本
"""
self.info.setText(text)
def update_progress(self, begin: int, end: int, current: int) -> None:
"""
更新进度条
:param begin: 进度条起始值
:param end: 进度条结束值
:param current: 进度条当前值
"""
if begin == 0 and end == 0:
self.progress_2.setVisible(False)
self.progress_1.setVisible(True)
else:
self.progress_1.setVisible(False)
self.progress_2.setVisible(True)
self.progress_2.setRange(begin, end)
self.progress_2.setValue(current)
def requestInterruption(self) -> None:
"""请求中断下载任务"""
logger.info("收到下载任务中止请求", module="下载管理器")
self.isInterruptionRequested = True
if hasattr(self, "zip_extract") and self.zip_extract:
self.zip_extract.requestInterruption()
if hasattr(self, "zip_loop") and self.zip_loop:
self.zip_loop.quit()
for process in self.download_process_dict.values():
process.requestInterruption()
if self.download_process_dict:
loop = QEventLoop()
self.download_process_clear.connect(loop.quit)
loop.exec()
def closeEvent(self, event: QCloseEvent):
"""清理残余进程"""
self.requestInterruption()
event.accept()

421
app/ui/history.py Normal file
View File

@@ -0,0 +1,421 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA历史记录界面
v4.4
作者DLmaster_361
"""
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
)
from qfluentwidgets import (
ScrollArea,
FluentIcon,
HeaderCardWidget,
PushButton,
TextBrowser,
CardWidget,
ComboBox,
ZhDatePicker,
SubtitleLabel,
)
from PySide6.QtCore import Signal, QDate, Qt
import os
import subprocess
from datetime import datetime, timedelta
from functools import partial
from pathlib import Path
from typing import List, Dict
from app.core import Config, SoundPlayer, logger
from .Widget import StatefulItemCard, QuantifiedItemCard, QuickExpandGroupCard
class History(QWidget):
"""历史记录界面"""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("历史记录")
self.history_top_bar = self.HistoryTopBar(self)
self.history_top_bar.search_history.connect(self.reload_history)
content_widget = QWidget()
self.content_layout = QVBoxLayout(content_widget)
self.content_layout.setContentsMargins(0, 0, 11, 0)
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setContentsMargins(0, 0, 0, 0)
scrollArea.setStyleSheet("background: transparent; border: none;")
scrollArea.setWidget(content_widget)
layout = QVBoxLayout(self)
layout.addWidget(self.history_top_bar)
layout.addWidget(scrollArea)
self.history_card_list = []
def reload_history(self, mode: str, start_date: QDate, end_date: QDate) -> None:
"""
加载历史记录界面
:param mode: 查询模式
:param start_date: 查询范围起始日期
:param end_date: 查询范围结束日期
"""
logger.info(
f"查询历史记录: {mode}, {start_date.toString()}, {end_date.toString()}",
module="历史记录",
)
SoundPlayer.play("历史记录查询")
# 清空已有的历史记录卡片
while self.content_layout.count() > 0:
item = self.content_layout.takeAt(0)
if item.spacerItem():
self.content_layout.removeItem(item.spacerItem())
elif item.widget():
item.widget().deleteLater()
self.history_card_list = []
history_dict = Config.search_history(
mode,
datetime(start_date.year(), start_date.month(), start_date.day()),
datetime(end_date.year(), end_date.month(), end_date.day()),
)
# 生成历史记录卡片并添加到布局中
for date, user_dict in history_dict.items():
self.history_card_list.append(self.HistoryCard(date, user_dict, self))
self.content_layout.addWidget(self.history_card_list[-1])
self.content_layout.addStretch(1)
class HistoryTopBar(CardWidget):
"""历史记录顶部工具栏"""
search_history = Signal(str, QDate, QDate)
def __init__(self, parent=None):
super().__init__(parent)
Layout = QHBoxLayout(self)
self.lable_1 = SubtitleLabel("查询范围:")
self.start_date = ZhDatePicker()
self.start_date.setDate(QDate(2019, 5, 1))
self.lable_2 = SubtitleLabel("")
self.end_date = ZhDatePicker()
server_date = Config.server_date()
self.end_date.setDate(
QDate(server_date.year, server_date.month, server_date.day)
)
self.mode = ComboBox()
self.mode.setPlaceholderText("请选择查询模式")
self.mode.addItems(["按日合并", "按周合并", "按月合并"])
self.select_month = PushButton(FluentIcon.TAG, "最近一月")
self.select_week = PushButton(FluentIcon.TAG, "最近一周")
self.search = PushButton(FluentIcon.SEARCH, "查询")
self.select_month.clicked.connect(lambda: self.select_date("month"))
self.select_week.clicked.connect(lambda: self.select_date("week"))
self.search.clicked.connect(
lambda: self.search_history.emit(
self.mode.currentText(),
self.start_date.getDate(),
self.end_date.getDate(),
)
)
Layout.addWidget(self.lable_1)
Layout.addWidget(self.start_date)
Layout.addWidget(self.lable_2)
Layout.addWidget(self.end_date)
Layout.addWidget(self.mode)
Layout.addStretch(1)
Layout.addWidget(self.select_month)
Layout.addWidget(self.select_week)
Layout.addWidget(self.search)
def select_date(self, date: str) -> None:
"""
选中最近一段时间并启动查询
:param date: 选择的时间段("week""month"
"""
logger.info(f"选择最近{date}的记录并开始查询", module="历史记录")
server_date = Config.server_date()
if date == "week":
begin_date = server_date - timedelta(weeks=1)
elif date == "month":
begin_date = server_date - timedelta(days=30)
self.start_date.setDate(
QDate(begin_date.year, begin_date.month, begin_date.day)
)
self.end_date.setDate(
QDate(server_date.year, server_date.month, server_date.day)
)
self.search.clicked.emit()
class HistoryCard(QuickExpandGroupCard):
"""历史记录卡片"""
def __init__(self, date: str, user_dict: Dict[str, List[Path]], parent=None):
super().__init__(
FluentIcon.HISTORY, date, f"{date}的历史运行记录与统计信息", parent
)
widget = QWidget()
Layout = QVBoxLayout(widget)
self.viewLayout.setContentsMargins(0, 0, 0, 0)
self.viewLayout.setSpacing(0)
self.addGroupWidget(widget)
self.user_history_card_list = []
# 生成用户历史记录卡片并添加到布局中
for user, info in user_dict.items():
self.user_history_card_list.append(
self.UserHistoryCard(user, info, self)
)
Layout.addWidget(self.user_history_card_list[-1])
class UserHistoryCard(HeaderCardWidget):
"""用户历史记录卡片"""
def __init__(self, name: str, user_history: List[Path], parent=None):
super().__init__(parent)
self.setTitle(name)
self.user_history = user_history
self.index_card = self.IndexCard(self.user_history, self)
self.index_card.index_changed.connect(self.update_info)
self.statistics_card = QHBoxLayout()
self.log_card = self.LogCard(self)
self.viewLayout.addWidget(self.index_card)
self.viewLayout.addLayout(self.statistics_card)
self.viewLayout.addWidget(self.log_card)
self.viewLayout.setContentsMargins(0, 0, 0, 0)
self.viewLayout.setSpacing(0)
self.viewLayout.setStretch(0, 1)
self.viewLayout.setStretch(2, 4)
self.update_info("数据总览")
def get_statistics(self, mode: str) -> dict:
"""
生成GUI相应结构化统计数据
:param mode: 查询模式
:return: 结构化统计数据
"""
history_info = Config.merge_statistic_info(
self.user_history if mode == "数据总览" else [Path(mode)]
)
statistics_info = {}
if "recruit_statistics" in history_info:
statistics_info["公招统计"] = list(
history_info["recruit_statistics"].items()
)
if "drop_statistics" in history_info:
for game_id, drops in history_info["drop_statistics"].items():
statistics_info[f"掉落统计:{game_id}"] = list(drops.items())
if mode == "数据总览" and "error_info" in history_info:
statistics_info["报错汇总"] = list(
history_info["error_info"].items()
)
return statistics_info
def update_info(self, index: str) -> None:
"""
更新信息到UI界面
:param index: 选择的索引
"""
# 移除已有统计信息UI组件
while self.statistics_card.count() > 0:
item = self.statistics_card.takeAt(0)
if item.spacerItem():
self.statistics_card.removeItem(item.spacerItem())
elif item.widget():
item.widget().deleteLater()
# 统计信息上传至 UI
if index == "数据总览":
# 生成数据统计信息卡片组
for name, item_list in self.get_statistics("数据总览").items():
statistics_card = self.StatisticsCard(name, item_list, self)
self.statistics_card.addWidget(statistics_card)
self.log_card.hide()
else:
single_history = self.get_statistics(index)
log_path = Path(index).with_suffix(".log")
# 生成单个历史记录的统计信息卡片组
for name, item_list in single_history.items():
statistics_card = self.StatisticsCard(name, item_list, self)
self.statistics_card.addWidget(statistics_card)
# 显示日志信息并绑定点击事件
with log_path.open("r", encoding="utf-8") as f:
log = f.read()
self.log_card.text.setText(log)
self.log_card.open_file.clicked.disconnect()
self.log_card.open_file.clicked.connect(
lambda: os.startfile(log_path)
)
self.log_card.open_dir.clicked.disconnect()
self.log_card.open_dir.clicked.connect(
lambda: subprocess.Popen(["explorer", "/select,", log_path])
)
self.log_card.show()
self.viewLayout.setStretch(1, self.statistics_card.count())
self.setMinimumHeight(300)
class IndexCard(HeaderCardWidget):
"""历史记录索引卡片组"""
index_changed = Signal(str)
def __init__(self, history_list: List[Path], parent=None):
super().__init__(parent)
self.setTitle("记录条目")
self.setFixedHeight(500)
content_widget = QWidget()
self.Layout = QVBoxLayout(content_widget)
self.Layout.setContentsMargins(0, 0, 11, 0)
self.index_cards: List[StatefulItemCard] = []
# 生成索引卡片信息
index_list = Config.merge_statistic_info(history_list)["index"]
index_list.insert(0, ["数据总览", "运行", "数据总览"])
# 生成索引卡片组件并绑定点击事件
for index in index_list:
self.index_cards.append(StatefulItemCard(index[:2]))
self.index_cards[-1].clicked.connect(
partial(self.index_changed.emit, str(index[2]))
)
self.Layout.addWidget(self.index_cards[-1])
self.Layout.addStretch(1)
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setContentsMargins(0, 0, 0, 0)
scrollArea.setStyleSheet("background: transparent; border: none;")
scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scrollArea.setWidget(content_widget)
self.viewLayout.addWidget(scrollArea)
self.viewLayout.setContentsMargins(3, 0, 3, 3)
class StatisticsCard(HeaderCardWidget):
"""历史记录统计信息卡片组"""
def __init__(self, name: str, item_list: list, parent=None):
super().__init__(parent)
self.setTitle(name)
self.setFixedHeight(500)
content_widget = QWidget()
self.Layout = QVBoxLayout(content_widget)
self.Layout.setContentsMargins(0, 0, 11, 0)
self.item_cards: List[QuantifiedItemCard] = []
for item in item_list:
self.item_cards.append(QuantifiedItemCard(item))
self.Layout.addWidget(self.item_cards[-1])
if len(item_list) == 0:
self.Layout.addWidget(QuantifiedItemCard(["暂无记录", ""]))
self.Layout.addStretch(1)
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setContentsMargins(0, 0, 0, 0)
scrollArea.setStyleSheet("background: transparent; border: none;")
scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scrollArea.setWidget(content_widget)
self.viewLayout.addWidget(scrollArea)
self.viewLayout.setContentsMargins(3, 0, 3, 3)
class LogCard(HeaderCardWidget):
"""历史记录日志卡片"""
def __init__(self, parent=None):
super().__init__(parent)
self.setTitle("日志")
self.setFixedHeight(500)
self.text = TextBrowser(self)
self.open_file = PushButton("打开日志文件", self)
self.open_file.clicked.connect(lambda: print("打开日志文件"))
self.open_dir = PushButton("打开所在目录", self)
self.open_dir.clicked.connect(lambda: print("打开所在文件"))
Layout = QVBoxLayout()
h_layout = QHBoxLayout()
h_layout.addWidget(self.open_file)
h_layout.addWidget(self.open_dir)
Layout.addWidget(self.text)
Layout.addLayout(h_layout)
self.viewLayout.setContentsMargins(3, 0, 3, 3)
self.viewLayout.addLayout(Layout)

416
app/ui/home.py Normal file
View File

@@ -0,0 +1,416 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA主界面
v4.4
作者DLmaster_361
"""
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QSpacerItem,
QSizePolicy,
QFileDialog,
)
from PySide6.QtCore import Qt, QSize, QUrl
from PySide6.QtGui import QDesktopServices, QColor
from qfluentwidgets import (
FluentIcon,
ScrollArea,
SimpleCardWidget,
PrimaryToolButton,
TextBrowser,
)
import re
import shutil
import json
from datetime import datetime
from pathlib import Path
from app.core import Config, MainInfoBar, Network, logger
from .Widget import Banner, IconButton
class Home(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("主页")
self.banner = Banner()
self.banner_text = TextBrowser()
v_layout = QVBoxLayout(self.banner)
v_layout.setContentsMargins(0, 0, 0, 15)
v_layout.setSpacing(5)
v_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# 空白占位符
v_layout.addItem(
QSpacerItem(10, 20, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
)
# 顶部部分 (按钮组)
h1_layout = QHBoxLayout()
h1_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# 左边留白区域
h1_layout.addStretch()
# 按钮组
buttonGroup = ButtonGroup()
buttonGroup.setMaximumHeight(320)
h1_layout.addWidget(buttonGroup)
# 空白占位符
h1_layout.addItem(
QSpacerItem(20, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
)
# 将顶部水平布局添加到垂直布局
v_layout.addLayout(h1_layout)
# 中间留白区域
v_layout.addItem(
QSpacerItem(10, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
)
v_layout.addStretch()
# 中间留白区域
v_layout.addItem(
QSpacerItem(10, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
)
v_layout.addStretch()
# 底部部分 (图片切换按钮)
h2_layout = QHBoxLayout()
h2_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# 左边留白区域
h2_layout.addItem(
QSpacerItem(20, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
)
# # 公告卡片
# noticeCard = NoticeCard()
# h2_layout.addWidget(noticeCard)
h2_layout.addStretch()
# 自定义图像按钮布局
self.imageButton = PrimaryToolButton(FluentIcon.IMAGE_EXPORT)
self.imageButton.setFixedSize(56, 56)
self.imageButton.setIconSize(QSize(32, 32))
self.imageButton.clicked.connect(self.get_home_image)
v1_layout = QVBoxLayout()
v1_layout.addWidget(self.imageButton, alignment=Qt.AlignmentFlag.AlignBottom)
h2_layout.addLayout(v1_layout)
# 空白占位符
h2_layout.addItem(
QSpacerItem(25, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
)
# 将底部水平布局添加到垂直布局
v_layout.addLayout(h2_layout)
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.addWidget(self.banner)
content_layout.addWidget(self.banner_text)
content_layout.setStretch(0, 2)
content_layout.setStretch(1, 3)
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setContentsMargins(0, 0, 0, 0)
scrollArea.setStyleSheet("background: transparent; border: none;")
scrollArea.setWidget(content_widget)
layout = QVBoxLayout(self)
layout.addWidget(scrollArea)
self.set_banner()
def get_home_image(self) -> None:
"""获取主页图片"""
logger.info("获取主页图片", module="主页")
if Config.get(Config.function_HomeImageMode) == "默认":
logger.info("使用默认主页图片", module="主页")
elif Config.get(Config.function_HomeImageMode) == "自定义":
file_path, _ = QFileDialog.getOpenFileName(
self, "打开自定义主页图片", "", "图片文件 (*.png *.jpg *.bmp)"
)
if file_path:
for file in Config.app_path.glob(
"resources/images/Home/BannerCustomize.*"
):
file.unlink()
shutil.copy(
file_path,
Config.app_path
/ f"resources/images/Home/BannerCustomize{Path(file_path).suffix}",
)
logger.info(f"自定义主页图片更换成功:{file_path}", module="主页")
MainInfoBar.push_info_bar(
"success",
"主页图片更换成功",
"自定义主页图片更换成功!",
3000,
)
else:
logger.warning("自定义主页图片更换失败:未选择图片文件", module="主页")
MainInfoBar.push_info_bar(
"warning",
"主页图片更换失败",
"未选择图片文件!",
5000,
)
elif Config.get(Config.function_HomeImageMode) == "主题图像":
# 从远程服务器获取最新主题图像信息
network = Network.add_task(
mode="get",
url="http://221.236.27.82:10197/d/AUTO_MAA/Server/theme_image.json",
)
network.loop.exec()
network_result = Network.get_result(network)
if network_result["status_code"] == 200:
theme_image = network_result["response_json"]
else:
logger.warning(
f"获取最新主题图像时出错:{network_result['error_message']}",
module="主页",
)
MainInfoBar.push_info_bar(
"warning",
"获取最新主题图像时出错",
f"网络错误:{network_result['status_code']}",
5000,
)
return None
if (Config.app_path / "resources/theme_image.json").exists():
with (Config.app_path / "resources/theme_image.json").open(
mode="r", encoding="utf-8"
) as f:
theme_image_local = json.load(f)
time_local = datetime.strptime(
theme_image_local["time"], "%Y-%m-%d %H:%M"
)
else:
time_local = datetime.strptime("2000-01-01 00:00", "%Y-%m-%d %H:%M")
# 检查主题图像是否需要更新
if not (
Config.app_path / "resources/images/Home/BannerTheme.jpg"
).exists() or (
datetime.now()
> datetime.strptime(theme_image["time"], "%Y-%m-%d %H:%M")
> time_local
):
network = Network.add_task(
mode="get_file",
url=theme_image["url"],
path=Config.app_path / "resources/images/Home/BannerTheme.jpg",
)
network.loop.exec()
network_result = Network.get_result(network)
if network_result["status_code"] == 200:
with (Config.app_path / "resources/theme_image.json").open(
mode="w", encoding="utf-8"
) as f:
json.dump(theme_image, f, ensure_ascii=False, indent=4)
logger.success(
f"主题图像「{theme_image["name"]}」下载成功", module="主页"
)
MainInfoBar.push_info_bar(
"success",
"主题图像下载成功",
f"{theme_image["name"]}」下载成功!",
3000,
)
else:
logger.warning(
f"下载最新主题图像时出错:{network_result['error_message']}",
module="主页",
)
MainInfoBar.push_info_bar(
"warning",
"下载最新主题图像时出错",
f"网络错误:{network_result['status_code']}",
5000,
)
else:
logger.info("主题图像已是最新", module="主页")
MainInfoBar.push_info_bar(
"info", "主题图像已是最新", "主题图像已是最新!", 3000
)
self.set_banner()
def set_banner(self):
"""设置主页图像"""
if Config.get(Config.function_HomeImageMode) == "默认":
self.banner.set_banner_image(
str(Config.app_path / "resources/images/Home/BannerDefault.png")
)
self.imageButton.hide()
self.banner_text.setVisible(False)
elif Config.get(Config.function_HomeImageMode) == "自定义":
for file in Config.app_path.glob("resources/images/Home/BannerCustomize.*"):
self.banner.set_banner_image(str(file))
break
self.imageButton.show()
self.banner_text.setVisible(False)
elif Config.get(Config.function_HomeImageMode) == "主题图像":
self.banner.set_banner_image(
str(Config.app_path / "resources/images/Home/BannerTheme.jpg")
)
self.imageButton.show()
self.banner_text.setVisible(True)
if (Config.app_path / "resources/theme_image.json").exists():
with (Config.app_path / "resources/theme_image.json").open(
mode="r", encoding="utf-8"
) as f:
theme_image = json.load(f)
html_content = theme_image["html"]
else:
html_content = "<h1>主题图像</h1><p>主题图像信息未知</p>"
self.banner_text.setHtml(re.sub(r"<img[^>]*>", "", html_content))
class ButtonGroup(SimpleCardWidget):
"""显示主页和 GitHub 按钮的竖直按钮组"""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.setFixedSize(56, 180)
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# 创建主页按钮
home_button = IconButton(
FluentIcon.HOME.icon(color=QColor("#fff")),
tip_title="AUTO_MAA官网",
tip_content="AUTO_MAA官方文档站",
isTooltip=True,
)
home_button.setIconSize(QSize(32, 32))
home_button.clicked.connect(self.open_home)
layout.addWidget(home_button)
# 创建 GitHub 按钮
github_button = IconButton(
FluentIcon.GITHUB.icon(color=QColor("#fff")),
tip_title="Github仓库",
tip_content="如果本项目有帮助到您~\n不妨给项目点一个Star⭐",
isTooltip=True,
)
github_button.setIconSize(QSize(32, 32))
github_button.clicked.connect(self.open_github)
layout.addWidget(github_button)
# # 创建 文档 按钮
# doc_button = IconButton(
# FluentIcon.DICTIONARY.icon(color=QColor("#fff")),
# tip_title="自助排障文档",
# tip_content="点击打开自助排障文档,好孩子都能看懂",
# isTooltip=True,
# )
# doc_button.setIconSize(QSize(32, 32))
# doc_button.clicked.connect(self.open_doc)
# layout.addWidget(doc_button)
# 创建 Q群 按钮
doc_button = IconButton(
FluentIcon.CHAT.icon(color=QColor("#fff")),
tip_title="官方社群",
tip_content="加入官方群聊「AUTO_MAA绝赞DeBug中",
isTooltip=True,
)
doc_button.setIconSize(QSize(32, 32))
doc_button.clicked.connect(self.open_chat)
layout.addWidget(doc_button)
# 创建 MirrorChyan 按钮
doc_button = IconButton(
FluentIcon.SHOPPING_CART.icon(color=QColor("#fff")),
tip_title="非官方店铺",
tip_content="获取 MirrorChyan CDK更新快人一步",
isTooltip=True,
)
doc_button.setIconSize(QSize(32, 32))
doc_button.clicked.connect(self.open_sales)
layout.addWidget(doc_button)
def _normalBackgroundColor(self):
return QColor(0, 0, 0, 96)
def open_home(self):
"""打开主页链接"""
QDesktopServices.openUrl(QUrl("https://clozya.github.io/AUTOMAA_docs"))
def open_github(self):
"""打开 GitHub 链接"""
QDesktopServices.openUrl(QUrl("https://github.com/DLmaster361/AUTO_MAA"))
def open_chat(self):
"""打开 Q群 链接"""
QDesktopServices.openUrl(QUrl("https://qm.qq.com/q/bd9fISNoME"))
def open_doc(self):
"""打开 文档 链接"""
QDesktopServices.openUrl(QUrl("https://clozya.github.io/AUTOMAA_docs"))
def open_sales(self):
"""打开 MirrorChyan 链接"""
QDesktopServices.openUrl(
QUrl("https://mirrorchyan.com/zh/get-start?source=auto_maa-home")
)

488
app/ui/main_window.py Normal file
View File

@@ -0,0 +1,488 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA主界面
v4.4
作者DLmaster_361
"""
from PySide6.QtWidgets import QApplication, QSystemTrayIcon
from qfluentwidgets import (
Action,
SystemTrayMenu,
SplashScreen,
FluentIcon,
setTheme,
isDarkTheme,
SystemThemeListener,
Theme,
MSFluentWindow,
NavigationItemPosition,
)
from PySide6.QtGui import QIcon, QCloseEvent
from PySide6.QtCore import QTimer
import darkdetect
from app.core import Config, logger, TaskManager, MainTimer, MainInfoBar, SoundPlayer
from app.services import Notify, Crypto, System
from .home import Home
from .script_manager import ScriptManager
from .plan_manager import PlanManager
from .queue_manager import QueueManager
from .dispatch_center import DispatchCenter
from .history import History
from .setting import Setting
class AUTO_MAA(MSFluentWindow):
"""AUTO_MAA主界面"""
def __init__(self):
super().__init__()
self.setWindowIcon(QIcon(str(Config.app_path / "resources/icons/AUTO_MAA.ico")))
version_numb = list(map(int, Config.VERSION.split(".")))
version_text = (
f"v{'.'.join(str(_) for _ in version_numb[0:3])}"
if version_numb[3] == 0
else f"v{'.'.join(str(_) for _ in version_numb[0:3])}-beta.{version_numb[3]}"
)
self.setWindowTitle(f"AUTO_MAA - {version_text}")
self.switch_theme()
self.splashScreen = SplashScreen(self.windowIcon(), self)
self.show_ui("显示主窗口", if_quick=True)
# 设置主窗口的引用,便于各组件访问
Config.main_window = self.window()
# 创建各子窗口
logger.info("正在创建各子窗口", module="主窗口")
self.home = Home(self)
self.plan_manager = PlanManager(self)
self.script_manager = ScriptManager(self)
self.queue_manager = QueueManager(self)
self.dispatch_center = DispatchCenter(self)
self.history = History(self)
self.setting = Setting(self)
self.addSubInterface(
self.home,
FluentIcon.HOME,
"主页",
FluentIcon.HOME,
NavigationItemPosition.TOP,
)
self.addSubInterface(
self.script_manager,
FluentIcon.ROBOT,
"脚本管理",
FluentIcon.ROBOT,
NavigationItemPosition.TOP,
)
self.addSubInterface(
self.plan_manager,
FluentIcon.CALENDAR,
"计划管理",
FluentIcon.CALENDAR,
NavigationItemPosition.TOP,
)
self.addSubInterface(
self.queue_manager,
FluentIcon.BOOK_SHELF,
"调度队列",
FluentIcon.BOOK_SHELF,
NavigationItemPosition.TOP,
)
self.addSubInterface(
self.dispatch_center,
FluentIcon.IOT,
"调度中心",
FluentIcon.IOT,
NavigationItemPosition.TOP,
)
self.addSubInterface(
self.history,
FluentIcon.HISTORY,
"历史记录",
FluentIcon.HISTORY,
NavigationItemPosition.BOTTOM,
)
self.addSubInterface(
self.setting,
FluentIcon.SETTING,
"设置",
FluentIcon.SETTING,
NavigationItemPosition.BOTTOM,
)
self.stackedWidget.currentChanged.connect(self.__currentChanged)
logger.success("各子窗口创建完成", module="主窗口")
# 创建系统托盘及其菜单
logger.info("正在创建系统托盘", module="主窗口")
self.tray = QSystemTrayIcon(
QIcon(str(Config.app_path / "resources/icons/AUTO_MAA.ico")), self
)
self.tray.setToolTip("AUTO_MAA")
self.tray_menu = SystemTrayMenu("AUTO_MAA", self)
# 显示主界面菜单项
self.tray_menu.addAction(
Action(
FluentIcon.CAFE,
"显示主界面",
triggered=lambda: self.show_ui("显示主窗口"),
)
)
self.tray_menu.addSeparator()
# 开始任务菜单项
self.tray_menu.addActions(
[
Action(FluentIcon.PLAY, "运行启动时队列", triggered=self.start_up_task),
Action(
FluentIcon.PAUSE,
"中止所有任务",
triggered=lambda: TaskManager.stop_task("ALL"),
),
]
)
self.tray_menu.addSeparator()
# 退出主程序菜单项
self.tray_menu.addAction(
Action(
FluentIcon.POWER_BUTTON,
"退出主程序",
triggered=lambda: System.set_power("KillSelf"),
)
)
# 设置托盘菜单
self.tray.setContextMenu(self.tray_menu)
self.tray.activated.connect(self.on_tray_activated)
logger.success("系统托盘创建完成", module="主窗口")
self.set_min_method()
# 绑定各组件信号
Config.sub_info_changed.connect(self.script_manager.refresh_dashboard)
Config.power_sign_changed.connect(self.dispatch_center.update_power_sign)
TaskManager.create_gui.connect(self.dispatch_center.add_board)
TaskManager.connect_gui.connect(self.dispatch_center.connect_main_board)
Notify.push_info_bar.connect(MainInfoBar.push_info_bar)
self.setting.ui.card_IfShowTray.checkedChanged.connect(
lambda: self.show_ui("配置托盘")
)
self.setting.ui.card_IfToTray.checkedChanged.connect(self.set_min_method)
self.setting.function.card_HomeImageMode.comboBox.currentIndexChanged.connect(
lambda index: (
self.home.get_home_image() if index == 2 else self.home.set_banner()
)
)
self.splashScreen.finish()
self.themeListener = SystemThemeListener(self)
self.themeListener.systemThemeChanged.connect(self.switch_theme)
self.themeListener.start()
logger.success("AUTO_MAA主程序初始化完成", module="主窗口")
def switch_theme(self) -> None:
"""切换主题"""
setTheme(
Theme(darkdetect.theme()) if darkdetect.theme() else Theme.LIGHT, lazy=True
)
QTimer.singleShot(300, lambda: setTheme(Theme.AUTO, lazy=True))
# 云母特效启用时需要增加重试机制
# 云母特效不兼容Win10,如果True则通过云母进行主题转换,False则根据当前主题设置背景颜色
if self.isMicaEffectEnabled():
QTimer.singleShot(
300,
lambda: self.windowEffect.setMicaEffect(self.winId(), isDarkTheme()),
)
else:
# 根据当前主题设置背景颜色
if isDarkTheme():
self.setStyleSheet(
"""
CardWidget {background-color: #313131;}
HeaderCardWidget {background-color: #313131;}
background-color: #313131;
"""
)
else:
self.setStyleSheet("background-color: #ffffff;")
def set_min_method(self) -> None:
"""设置最小化方法"""
if Config.get(Config.ui_IfToTray):
self.titleBar.minBtn.clicked.disconnect()
self.titleBar.minBtn.clicked.connect(lambda: self.show_ui("隐藏到托盘"))
else:
self.titleBar.minBtn.clicked.disconnect()
self.titleBar.minBtn.clicked.connect(self.window().showMinimized)
def on_tray_activated(self, reason):
"""双击返回主界面"""
if reason == QSystemTrayIcon.DoubleClick:
self.show_ui("显示主窗口")
def show_ui(
self, mode: str, if_quick: bool = False, if_start: bool = False
) -> None:
"""配置窗口状态"""
if Config.args.mode != "gui":
return None
self.switch_theme()
if mode == "显示主窗口":
# 配置主窗口
if not self.window().isVisible():
size = list(
map(
int,
Config.get(Config.ui_size).split("x"),
)
)
location = list(
map(
int,
Config.get(Config.ui_location).split("x"),
)
)
if self.window().isMaximized():
self.window().showNormal()
self.window().setGeometry(location[0], location[1], size[0], size[1])
self.window().show()
if not if_quick:
if (
Config.get(Config.ui_maximized)
and not self.window().isMaximized()
):
self.titleBar.maxBtn.click()
SoundPlayer.play("欢迎回来")
self.show_ui("配置托盘")
elif if_start:
if Config.get(Config.ui_maximized) and not self.window().isMaximized():
self.titleBar.maxBtn.click()
self.show_ui("配置托盘")
# 如果窗口不在屏幕内,则重置窗口位置
if not any(
self.window().geometry().intersects(screen.availableGeometry())
for screen in QApplication.screens()
):
self.window().showNormal()
self.window().setGeometry(100, 100, 1200, 700)
self.window().raise_()
self.window().activateWindow()
while Config.info_bar_list:
info_bar_item = Config.info_bar_list.pop(0)
MainInfoBar.push_info_bar(
info_bar_item["mode"],
info_bar_item["title"],
info_bar_item["content"],
info_bar_item["time"],
)
elif mode == "配置托盘":
if Config.get(Config.ui_IfShowTray):
self.tray.show()
else:
self.tray.hide()
elif mode == "隐藏到托盘":
# 保存窗口相关属性
if not self.window().isMaximized():
Config.set(
Config.ui_size,
f"{self.geometry().width()}x{self.geometry().height()}",
)
Config.set(
Config.ui_location,
f"{self.geometry().x()}x{self.geometry().y()}",
)
Config.set(Config.ui_maximized, self.window().isMaximized())
Config.save()
# 隐藏主窗口
if not if_quick:
self.window().hide()
self.tray.show()
def start_up_task(self) -> None:
"""启动时任务"""
logger.info("开始执行启动时任务", module="主窗口")
# 清理旧历史记录
Config.clean_old_history()
# 清理安装包
if (Config.app_path / "AUTO_MAA-Setup.exe").exists():
try:
(Config.app_path / "AUTO_MAA-Setup.exe").unlink()
except Exception:
pass
# 恢复Go_Updater独立更新器
if (Config.app_path / "AUTO_MAA_Go_Updater_install.exe").exists():
try:
(Config.app_path / "AUTO_MAA_Go_Updater_install.exe").rename(
"AUTO_MAA_Go_Updater.exe"
)
except Exception:
pass
# 检查密码
self.setting.check_PASSWORD()
# 获取关卡号信息
Config.get_stage()
# 获取主题图像
if Config.get(Config.function_HomeImageMode) == "主题图像":
self.home.get_home_image()
# 启动定时器
MainTimer.start()
# 获取公告
self.setting.show_notice(if_first=True)
# 检查更新
if Config.get(Config.update_IfAutoUpdate):
self.setting.check_update(if_first=True)
# 直接最小化
if Config.get(Config.start_IfMinimizeDirectly):
self.titleBar.minBtn.click()
if Config.args.config:
for config in [_ for _ in Config.args.config if _ in Config.queue_dict]:
TaskManager.add_task(
"自动代理_新调度台",
config,
Config.queue_dict["调度队列_1"]["Config"].toDict(),
)
for config in [_ for _ in Config.args.config if _ in Config.script_dict]:
TaskManager.add_task(
"自动代理_新调度台",
"自定义队列",
{"Queue": {"Script_0": config}},
)
if not any(
_ in (list(Config.script_dict.keys()) + list(Config.queue_dict.keys()))
for _ in Config.args.config
):
logger.warning(
"当前运行模式为命令行模式,由于您使用了错误的 --config 参数进行配置,程序自动退出"
)
System.set_power("KillSelf")
elif Config.args.mode == "cli":
logger.warning(
"当前运行模式为命令行模式,由于您未使用 --config 参数进行配置,程序自动退出"
)
System.set_power("KillSelf")
elif Config.args.mode == "gui":
self.start_up_queue()
logger.success("启动时任务执行完成", module="主窗口")
def start_up_queue(self) -> None:
"""启动时运行的调度队列"""
logger.info("开始调度启动时运行的调度队列", module="主窗口")
for name, queue in Config.queue_dict.items():
if queue["Config"].get(queue["Config"].QueueSet_StartUpEnabled):
logger.info(f"自动添加任务:{name}", module="主窗口")
TaskManager.add_task(
"自动代理_新调度台", name, queue["Config"].toDict()
)
logger.success("开始调度启动时运行的调度队列启动完成", module="主窗口")
def __currentChanged(self, index: int) -> None:
"""切换界面时任务"""
if index == 1:
self.script_manager.reload_plan_name()
elif index == 3:
self.queue_manager.reload_script_name()
elif index == 4:
self.dispatch_center.pivot.setCurrentItem("主调度台")
self.dispatch_center.update_top_bar()
def closeEvent(self, event: QCloseEvent):
"""清理残余进程"""
logger.info("保存窗口位置与大小信息", module="主窗口")
self.show_ui("隐藏到托盘", if_quick=True)
# 清理各功能线程
MainTimer.stop()
TaskManager.stop_task("ALL")
# 关闭主题监听
self.themeListener.terminate()
self.themeListener.deleteLater()
logger.info("AUTO_MAA主程序关闭", module="主窗口")
logger.info("----------------END----------------", module="主窗口")
event.accept()

515
app/ui/plan_manager.py Normal file
View File

@@ -0,0 +1,515 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA计划管理界面
v4.4
作者DLmaster_361
"""
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QStackedWidget,
QHeaderView,
)
from qfluentwidgets import (
Action,
FluentIcon,
MessageBox,
HeaderCardWidget,
CommandBar,
TableWidget,
)
from typing import List, Dict, Union
import shutil
from app.core import Config, MainInfoBar, MaaPlanConfig, SoundPlayer, logger
from .Widget import (
ComboBoxMessageBox,
LineEditSettingCard,
ComboBoxSettingCard,
SpinBoxSetting,
EditableComboBoxSetting,
ComboBoxSetting,
PivotArea,
)
class PlanManager(QWidget):
"""计划管理父界面"""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("计划管理")
layout = QVBoxLayout(self)
self.tools = CommandBar()
self.plan_manager = self.PlanSettingBox(self)
# 逐个添加动作
self.tools.addActions(
[
Action(FluentIcon.ADD_TO, "新建计划表", triggered=self.add_plan),
Action(FluentIcon.REMOVE_FROM, "删除计划表", triggered=self.del_plan),
]
)
self.tools.addSeparator()
self.tools.addActions(
[
Action(FluentIcon.LEFT_ARROW, "向左移动", triggered=self.left_plan),
Action(FluentIcon.RIGHT_ARROW, "向右移动", triggered=self.right_plan),
]
)
self.tools.addSeparator()
layout.addWidget(self.tools)
layout.addWidget(self.plan_manager)
def add_plan(self):
"""添加一个计划表"""
choice = ComboBoxMessageBox(
self.window(),
"选择一个计划类型以添加相应计划表",
["选择计划类型"],
[["MAA"]],
)
if choice.exec() and choice.input[0].currentIndex() != -1:
if choice.input[0].currentText() == "MAA":
index = len(Config.plan_dict) + 1
# 初始化 MaaPlanConfig
maa_plan_config = MaaPlanConfig()
maa_plan_config.load(
Config.app_path / f"config/MaaPlanConfig/计划_{index}/config.json",
maa_plan_config,
)
maa_plan_config.save()
Config.plan_dict[f"计划_{index}"] = {
"Type": "Maa",
"Path": Config.app_path / f"config/MaaPlanConfig/计划_{index}",
"Config": maa_plan_config,
}
# 添加计划表到界面
self.plan_manager.add_MaaPlanSettingBox(index)
self.plan_manager.switch_SettingBox(index)
logger.success(f"计划管理 计划_{index} 添加成功", module="计划管理")
MainInfoBar.push_info_bar(
"success", "操作成功", f"添加计划表 计划_{index}", 3000
)
SoundPlayer.play("添加计划表")
def del_plan(self):
"""删除一个计划表"""
name = self.plan_manager.pivot.currentRouteKey()
if name is None:
logger.warning("删除计划表时未选择计划表", module="计划管理")
MainInfoBar.push_info_bar(
"warning", "未选择计划表", "请选择一个计划表", 5000
)
return None
if len(Config.running_list) > 0:
logger.warning("删除计划表时调度队列未停止运行", module="计划管理")
MainInfoBar.push_info_bar(
"warning", "调度中心正在执行任务", "请等待或手动中止任务", 5000
)
return None
choice = MessageBox("确认", f"确定要删除 {name} 吗?", self.window())
if choice.exec():
logger.info(f"正在删除计划表 {name}", module="计划管理")
self.plan_manager.clear_SettingBox()
# 删除计划表配置文件并同步到相关配置项
shutil.rmtree(Config.plan_dict[name]["Path"])
Config.change_plan(name, "固定")
for i in range(int(name[3:]) + 1, len(Config.plan_dict) + 1):
if Config.plan_dict[f"计划_{i}"]["Path"].exists():
Config.plan_dict[f"计划_{i}"]["Path"].rename(
Config.plan_dict[f"计划_{i}"]["Path"].with_name(f"计划_{i-1}")
)
Config.change_plan(f"计划_{i}", f"计划_{i-1}")
self.plan_manager.show_SettingBox(max(int(name[3:]) - 1, 1))
logger.success(f"计划表 {name} 删除成功", module="计划管理")
MainInfoBar.push_info_bar("success", "操作成功", f"删除计划表 {name}", 3000)
SoundPlayer.play("删除计划表")
def left_plan(self):
"""向左移动计划表"""
name = self.plan_manager.pivot.currentRouteKey()
if name is None:
logger.warning("向左移动计划表时未选择计划表", module="计划管理")
MainInfoBar.push_info_bar(
"warning", "未选择计划表", "请选择一个计划表", 5000
)
return None
index = int(name[3:])
if index == 1:
logger.warning("向左移动计划表时已到达最左端", module="计划管理")
MainInfoBar.push_info_bar(
"warning", "已经是第一个计划表", "无法向左移动", 5000
)
return None
if len(Config.running_list) > 0:
logger.warning("向左移动计划表时调度队列未停止运行", module="计划管理")
MainInfoBar.push_info_bar(
"warning", "调度中心正在执行任务", "请等待或手动中止任务", 5000
)
return None
logger.info(f"正在左移计划表 {name}", module="计划管理")
self.plan_manager.clear_SettingBox()
# 移动配置文件并同步到相关配置项
Config.plan_dict[name]["Path"].rename(
Config.plan_dict[name]["Path"].with_name("计划_0")
)
Config.change_plan(name, "计划_0")
Config.plan_dict[f"计划_{index-1}"]["Path"].rename(
Config.plan_dict[name]["Path"]
)
Config.change_plan(f"计划_{index-1}", name)
Config.plan_dict[name]["Path"].with_name("计划_0").rename(
Config.plan_dict[f"计划_{index-1}"]["Path"]
)
Config.change_plan("计划_0", f"计划_{index-1}")
self.plan_manager.show_SettingBox(index - 1)
logger.success(f"计划表 {name} 左移成功", module="计划管理")
MainInfoBar.push_info_bar("success", "操作成功", f"左移计划表 {name}", 3000)
def right_plan(self):
"""向右移动计划表"""
name = self.plan_manager.pivot.currentRouteKey()
if name is None:
logger.warning("向右移动计划表时未选择计划表", module="计划管理")
MainInfoBar.push_info_bar(
"warning", "未选择计划表", "请选择一个计划表", 5000
)
return None
index = int(name[3:])
if index == len(Config.plan_dict):
logger.warning("向右移动计划表时已到达最右端", module="计划管理")
MainInfoBar.push_info_bar(
"warning", "已经是最后一个计划表", "无法向右移动", 5000
)
return None
if len(Config.running_list) > 0:
logger.warning("向右移动计划表时调度队列未停止运行", module="计划管理")
MainInfoBar.push_info_bar(
"warning", "调度中心正在执行任务", "请等待或手动中止任务", 5000
)
return None
logger.info(f"正在右移计划表 {name}", module="计划管理")
self.plan_manager.clear_SettingBox()
# 移动配置文件并同步到相关配置项
Config.plan_dict[name]["Path"].rename(
Config.plan_dict[name]["Path"].with_name("计划_0")
)
Config.change_plan(name, "计划_0")
Config.plan_dict[f"计划_{index+1}"]["Path"].rename(
Config.plan_dict[name]["Path"]
)
Config.change_plan(f"计划_{index+1}", name)
Config.plan_dict[name]["Path"].with_name("计划_0").rename(
Config.plan_dict[f"计划_{index+1}"]["Path"]
)
Config.change_plan("计划_0", f"计划_{index+1}")
self.plan_manager.show_SettingBox(index + 1)
logger.success(f"计划表 {name} 右移成功", module="计划管理")
MainInfoBar.push_info_bar("success", "操作成功", f"右移计划表 {name}", 3000)
class PlanSettingBox(QWidget):
"""计划管理子页面组"""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("计划管理页面组")
self.pivotArea = PivotArea(self)
self.pivot = self.pivotArea.pivot
self.stackedWidget = QStackedWidget(self)
self.stackedWidget.setContentsMargins(0, 0, 0, 0)
self.stackedWidget.setStyleSheet("background: transparent; border: none;")
self.script_list: List[PlanManager.PlanSettingBox.MaaPlanSettingBox] = []
self.Layout = QVBoxLayout(self)
self.Layout.addWidget(self.pivotArea)
self.Layout.addWidget(self.stackedWidget)
self.Layout.setContentsMargins(0, 0, 0, 0)
self.pivot.currentItemChanged.connect(
lambda index: self.switch_SettingBox(
int(index[3:]), if_chang_pivot=False
)
)
self.show_SettingBox(1)
def show_SettingBox(self, index) -> None:
"""
加载所有子界面并切换到指定的子界面
:param index: 要显示的子界面索引
"""
Config.search_plan()
for name, info in Config.plan_dict.items():
if info["Type"] == "Maa":
self.add_MaaPlanSettingBox(int(name[3:]))
self.switch_SettingBox(index)
def switch_SettingBox(self, index: int, if_chang_pivot: bool = True) -> None:
"""
切换到指定的子界面
:param index: 要切换到的子界面索引
:param if_chang_pivot: 是否更改 pivot 的当前项
"""
if len(Config.plan_dict) == 0:
return None
if index > len(Config.plan_dict):
return None
if if_chang_pivot:
self.pivot.setCurrentItem(self.script_list[index - 1].objectName())
self.stackedWidget.setCurrentWidget(self.script_list[index - 1])
def clear_SettingBox(self) -> None:
"""清空所有子界面"""
for sub_interface in self.script_list:
Config.stage_refreshed.disconnect(sub_interface.refresh_stage)
self.stackedWidget.removeWidget(sub_interface)
sub_interface.deleteLater()
self.script_list.clear()
self.pivot.clear()
def add_MaaPlanSettingBox(self, uid: int) -> None:
"""
添加一个MAA设置界面
:param uid: MAA计划表的唯一标识符
"""
maa_plan_setting_box = self.MaaPlanSettingBox(uid, self)
self.script_list.append(maa_plan_setting_box)
self.stackedWidget.addWidget(self.script_list[-1])
self.pivot.addItem(routeKey=f"计划_{uid}", text=f"计划 {uid}")
class MaaPlanSettingBox(HeaderCardWidget):
"""MAA类计划设置界面"""
def __init__(self, uid: int, parent=None):
super().__init__(parent)
self.setObjectName(f"计划_{uid}")
self.setTitle("MAA计划表")
self.config = Config.plan_dict[f"计划_{uid}"]["Config"]
self.card_Name = LineEditSettingCard(
icon=FluentIcon.EDIT,
title="计划表名称",
content="用于标识计划表的名称",
text="请输入计划表名称",
qconfig=self.config,
configItem=self.config.Info_Name,
parent=self,
)
self.card_Mode = ComboBoxSettingCard(
icon=FluentIcon.DICTIONARY,
title="计划模式",
content="全局模式下计划内容固定,周计划模式下计划按周一到周日切换",
texts=["全局", "周计划"],
qconfig=self.config,
configItem=self.config.Info_Mode,
parent=self,
)
self.table = TableWidget(self)
self.table.setColumnCount(8)
self.table.setRowCount(7)
self.table.setHorizontalHeaderLabels(
["全局", "周一", "周二", "周三", "周四", "周五", "周六", "周日"]
)
self.table.setVerticalHeaderLabels(
[
"吃理智药",
"连战次数",
"关卡选择",
"备选 - 1",
"备选 - 2",
"备选 - 3",
"剩余理智",
]
)
self.table.setAlternatingRowColors(False)
self.table.setEditTriggers(TableWidget.NoEditTriggers)
for col in range(8):
self.table.horizontalHeader().setSectionResizeMode(
col, QHeaderView.ResizeMode.Stretch
)
for row in range(7):
self.table.verticalHeader().setSectionResizeMode(
row, QHeaderView.ResizeMode.ResizeToContents
)
self.item_dict: Dict[
str,
Dict[
str,
Union[SpinBoxSetting, ComboBoxSetting, EditableComboBoxSetting],
],
] = {}
for col, (group, name_dict) in enumerate(
self.config.config_item_dict.items()
):
self.item_dict[group] = {}
for row, (name, configItem) in enumerate(name_dict.items()):
if name == "MedicineNumb":
self.item_dict[group][name] = SpinBoxSetting(
range=(0, 1024),
qconfig=self.config,
configItem=configItem,
parent=self,
)
elif name == "SeriesNumb":
self.item_dict[group][name] = ComboBoxSetting(
texts=["AUTO", "6", "5", "4", "3", "2", "1", "不选择"],
qconfig=self.config,
configItem=configItem,
parent=self,
)
elif name == "Stage_Remain":
self.item_dict[group][name] = EditableComboBoxSetting(
value=Config.stage_dict[group]["value"],
texts=[
"不使用" if _ == "当前/上次" else _
for _ in Config.stage_dict[group]["text"]
],
qconfig=self.config,
configItem=configItem,
parent=self,
)
elif "Stage" in name:
self.item_dict[group][name] = EditableComboBoxSetting(
value=Config.stage_dict[group]["value"],
texts=Config.stage_dict[group]["text"],
qconfig=self.config,
configItem=configItem,
parent=self,
)
self.table.setCellWidget(row, col, self.item_dict[group][name])
Layout = QVBoxLayout()
Layout.addWidget(self.card_Name)
Layout.addWidget(self.card_Mode)
Layout.addWidget(self.table)
self.viewLayout.addLayout(Layout)
self.viewLayout.setSpacing(3)
self.viewLayout.setContentsMargins(3, 0, 3, 3)
self.card_Mode.comboBox.currentIndexChanged.connect(self.switch_mode)
Config.stage_refreshed.connect(self.refresh_stage)
self.switch_mode()
def switch_mode(self) -> None:
"""切换计划模式"""
for group, name_dict in self.item_dict.items():
for name, setting_item in name_dict.items():
setting_item.setEnabled(
(group == "ALL")
== (self.config.get(self.config.Info_Mode) == "ALL")
)
def refresh_stage(self):
"""刷新关卡列表"""
for group, name_dict in self.item_dict.items():
for name, setting_item in name_dict.items():
if name == "Stage_Remain":
setting_item.reLoadOptions(
Config.stage_dict[group]["value"],
[
"不使用" if _ == "当前/上次" else _
for _ in Config.stage_dict[group]["text"]
],
)
elif "Stage" in name:
setting_item.reLoadOptions(
Config.stage_dict[group]["value"],
Config.stage_dict[group]["text"],
)

533
app/ui/queue_manager.py Normal file
View File

@@ -0,0 +1,533 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA调度队列界面
v4.4
作者DLmaster_361
"""
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QStackedWidget,
QHBoxLayout,
)
from qfluentwidgets import (
Action,
ScrollArea,
FluentIcon,
MessageBox,
HeaderCardWidget,
CommandBar,
)
from typing import List, Dict
from app.core import QueueConfig, Config, MainInfoBar, SoundPlayer, logger
from .Widget import (
SwitchSettingCard,
ComboBoxSettingCard,
LineEditSettingCard,
TimeEditSettingCard,
NoOptionComboBoxSettingCard,
HistoryCard,
PivotArea,
)
class QueueManager(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("调度队列")
layout = QVBoxLayout(self)
self.tools = CommandBar()
self.queue_manager = self.QueueSettingBox(self)
# 逐个添加动作
self.tools.addActions(
[
Action(FluentIcon.ADD_TO, "新建调度队列", triggered=self.add_queue),
Action(
FluentIcon.REMOVE_FROM, "删除调度队列", triggered=self.del_queue
),
]
)
self.tools.addSeparator()
self.tools.addActions(
[
Action(FluentIcon.LEFT_ARROW, "向左移动", triggered=self.left_queue),
Action(FluentIcon.RIGHT_ARROW, "向右移动", triggered=self.right_queue),
]
)
layout.addWidget(self.tools)
layout.addWidget(self.queue_manager)
def add_queue(self):
"""添加一个调度队列"""
index = len(Config.queue_dict) + 1
logger.info(f"正在添加调度队列_{index}", module="队列管理")
# 初始化队列配置
queue_config = QueueConfig()
queue_config.load(
Config.app_path / f"config/QueueConfig/调度队列_{index}.json", queue_config
)
queue_config.save()
Config.queue_dict[f"调度队列_{index}"] = {
"Path": Config.app_path / f"config/QueueConfig/调度队列_{index}.json",
"Config": queue_config,
}
# 添加到配置界面
self.queue_manager.add_SettingBox(index)
self.queue_manager.switch_SettingBox(index)
logger.success(f"调度队列_{index} 添加成功", module="队列管理")
MainInfoBar.push_info_bar("success", "操作成功", f"添加 调度队列_{index}", 3000)
SoundPlayer.play("添加调度队列")
def del_queue(self):
"""删除一个调度队列实例"""
name = self.queue_manager.pivot.currentRouteKey()
if name is None:
logger.warning("未选择调度队列", module="队列管理")
MainInfoBar.push_info_bar(
"warning", "未选择调度队列", "请先选择一个调度队列", 5000
)
return None
if name in Config.running_list:
logger.warning("调度队列正在运行", module="队列管理")
MainInfoBar.push_info_bar(
"warning", "调度队列正在运行", "请先停止调度队列", 5000
)
return None
choice = MessageBox("确认", f"确定要删除 {name} 吗?", self.window())
if choice.exec():
logger.info(f"正在删除调度队列 {name}", module="队列管理")
self.queue_manager.clear_SettingBox()
# 删除队列配置文件并同步到相关配置项
Config.queue_dict[name]["Path"].unlink()
for i in range(int(name[5:]) + 1, len(Config.queue_dict) + 1):
if Config.queue_dict[f"调度队列_{i}"]["Path"].exists():
Config.queue_dict[f"调度队列_{i}"]["Path"].rename(
Config.queue_dict[f"调度队列_{i}"]["Path"].with_name(
f"调度队列_{i-1}.json"
)
)
self.queue_manager.show_SettingBox(max(int(name[5:]) - 1, 1))
logger.success(f"{name} 删除成功", module="队列管理")
MainInfoBar.push_info_bar("success", "操作成功", f"删除 {name}", 3000)
SoundPlayer.play("删除调度队列")
def left_queue(self):
"""向左移动调度队列实例"""
name = self.queue_manager.pivot.currentRouteKey()
if name is None:
logger.warning("未选择调度队列", module="队列管理")
MainInfoBar.push_info_bar(
"warning", "未选择调度队列", "请先选择一个调度队列", 5000
)
return None
index = int(name[5:])
if index == 1:
logger.warning("向左移动调度队列时已到达最左端", module="队列管理")
MainInfoBar.push_info_bar(
"warning", "已经是第一个调度队列", "无法向左移动", 5000
)
return None
if name in Config.running_list or f"调度队列_{index-1}" in Config.running_list:
logger.warning("相关调度队列正在运行", module="队列管理")
MainInfoBar.push_info_bar(
"warning", "相关调度队列正在运行", "请先停止调度队列", 5000
)
return None
logger.info(f"正在左移调度队列 {name}", module="队列管理")
self.queue_manager.clear_SettingBox()
# 移动配置文件并同步到相关配置项
Config.queue_dict[name]["Path"].rename(
Config.queue_dict[name]["Path"].with_name("调度队列_0.json")
)
Config.queue_dict[f"调度队列_{index-1}"]["Path"].rename(
Config.queue_dict[name]["Path"]
)
Config.queue_dict[name]["Path"].with_name("调度队列_0.json").rename(
Config.queue_dict[f"调度队列_{index-1}"]["Path"]
)
self.queue_manager.show_SettingBox(index - 1)
logger.success(f"{name} 左移成功", module="队列管理")
MainInfoBar.push_info_bar("success", "操作成功", f"左移 {name}", 3000)
def right_queue(self):
"""向右移动调度队列实例"""
name = self.queue_manager.pivot.currentRouteKey()
if name is None:
logger.warning("未选择调度队列", module="队列管理")
MainInfoBar.push_info_bar(
"warning", "未选择调度队列", "请先选择一个调度队列", 5000
)
return None
index = int(name[5:])
if index == len(Config.queue_dict):
logger.warning("向右移动调度队列时已到达最右端", module="队列管理")
MainInfoBar.push_info_bar(
"warning", "已经是最后一个调度队列", "无法向右移动", 5000
)
return None
if name in Config.running_list or f"调度队列_{index+1}" in Config.running_list:
logger.warning("相关调度队列正在运行", module="队列管理")
MainInfoBar.push_info_bar(
"warning", "相关调度队列正在运行", "请先停止调度队列", 5000
)
return None
logger.info(f"正在右移调度队列 {name}", module="队列管理")
self.queue_manager.clear_SettingBox()
# 移动配置文件并同步到相关配置项
Config.queue_dict[name]["Path"].rename(
Config.queue_dict[name]["Path"].with_name("调度队列_0.json")
)
Config.queue_dict[f"调度队列_{index+1}"]["Path"].rename(
Config.queue_dict[name]["Path"]
)
Config.queue_dict[name]["Path"].with_name("调度队列_0.json").rename(
Config.queue_dict[f"调度队列_{index+1}"]["Path"]
)
self.queue_manager.show_SettingBox(index + 1)
logger.success(f"{name} 右移成功", module="队列管理")
MainInfoBar.push_info_bar("success", "操作成功", f"右移 {name}", 3000)
def reload_script_name(self):
"""刷新调度队列脚本成员名称"""
# 获取脚本成员列表
script_list = [
["禁用"] + [_ for _ in Config.script_dict.keys()],
["未启用"]
+ [
(
k
if v["Config"].get_name() == ""
else f"{k} - {v["Config"].get_name()}"
)
for k, v in Config.script_dict.items()
],
]
for script in self.queue_manager.script_list:
for card in script.task.card_dict.values():
card.reLoadOptions(value=script_list[0], texts=script_list[1])
class QueueSettingBox(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("调度队列管理")
self.pivotArea = PivotArea()
self.pivot = self.pivotArea.pivot
self.stackedWidget = QStackedWidget(self)
self.stackedWidget.setContentsMargins(0, 0, 0, 0)
self.stackedWidget.setStyleSheet("background: transparent; border: none;")
self.script_list: List[
QueueManager.QueueSettingBox.QueueMemberSettingBox
] = []
self.Layout = QVBoxLayout(self)
self.Layout.addWidget(self.pivotArea)
self.Layout.addWidget(self.stackedWidget)
self.Layout.setContentsMargins(0, 0, 0, 0)
self.pivot.currentItemChanged.connect(
lambda index: self.switch_SettingBox(
int(index[5:]), if_change_pivot=False
)
)
self.show_SettingBox(1)
def show_SettingBox(self, index) -> None:
"""加载所有子界面"""
Config.search_queue()
for name in Config.queue_dict.keys():
self.add_SettingBox(int(name[5:]))
self.switch_SettingBox(index)
def switch_SettingBox(self, index: int, if_change_pivot: bool = True) -> None:
"""
切换到指定的子界面并切换到指定的子页面
:param index: 要切换到的子界面索引
:param if_change_pivot: 是否更改导航栏当前项
"""
if len(Config.queue_dict) == 0:
return None
if index > len(Config.queue_dict):
return None
if if_change_pivot:
self.pivot.setCurrentItem(self.script_list[index - 1].objectName())
self.stackedWidget.setCurrentWidget(self.script_list[index - 1])
def clear_SettingBox(self) -> None:
"""清空所有子界面"""
for sub_interface in self.script_list:
self.stackedWidget.removeWidget(sub_interface)
sub_interface.deleteLater()
self.script_list.clear()
self.pivot.clear()
def add_SettingBox(self, uid: int) -> None:
"""添加一个调度队列设置界面"""
setting_box = self.QueueMemberSettingBox(uid, self)
self.script_list.append(setting_box)
self.stackedWidget.addWidget(self.script_list[-1])
self.pivot.addItem(routeKey=f"调度队列_{uid}", text=f"调度队列 {uid}")
class QueueMemberSettingBox(QWidget):
def __init__(self, uid: int, parent=None):
super().__init__(parent)
self.setObjectName(f"调度队列_{uid}")
self.config = Config.queue_dict[f"调度队列_{uid}"]["Config"]
self.queue_set = self.QueueSetSettingCard(self.config, self)
self.time = self.TimeSettingCard(self.config, self)
self.task = self.TaskSettingCard(self.config, self)
self.history = HistoryCard(
qconfig=self.config,
configItem=self.config.Data_LastProxyHistory,
parent=self,
)
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(0, 0, 11, 0)
content_layout.addWidget(self.queue_set)
content_layout.addWidget(self.time)
content_layout.addWidget(self.task)
content_layout.addWidget(self.history)
content_layout.addStretch(1)
scrollArea = ScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setContentsMargins(0, 0, 0, 0)
scrollArea.setStyleSheet("background: transparent; border: none;")
scrollArea.setWidget(content_widget)
layout = QVBoxLayout(self)
layout.addWidget(scrollArea)
class QueueSetSettingCard(HeaderCardWidget):
def __init__(self, config: QueueConfig, parent=None):
super().__init__(parent)
self.setTitle("队列设置")
self.config = config
self.card_Name = LineEditSettingCard(
icon=FluentIcon.EDIT,
title="调度队列名称",
content="用于标识调度队列的名称",
text="请输入调度队列名称",
qconfig=self.config,
configItem=self.config.QueueSet_Name,
parent=self,
)
self.card_StartUpEnabled = SwitchSettingCard(
icon=FluentIcon.CHECKBOX,
title="启动时运行",
content="调度队列启动时运行状态,启用后将在软件启动时自动运行本队列",
qconfig=self.config,
configItem=self.config.QueueSet_StartUpEnabled,
parent=self,
)
self.card_TimeEnable = SwitchSettingCard(
icon=FluentIcon.CHECKBOX,
title="定时运行",
content="调度队列定时运行状态,启用时会执行定时任务",
qconfig=self.config,
configItem=self.config.QueueSet_TimeEnabled,
parent=self,
)
self.card_AfterAccomplish = ComboBoxSettingCard(
icon=FluentIcon.POWER_BUTTON,
title="调度队列结束后",
content="选择调度队列结束后的操作",
texts=[
"无动作",
"退出AUTO_MAA",
"睡眠win系统需禁用休眠",
"休眠",
"关机",
"关机(强制)",
],
qconfig=self.config,
configItem=self.config.QueueSet_AfterAccomplish,
parent=self,
)
Layout = QVBoxLayout()
Layout.addWidget(self.card_Name)
Layout.addWidget(self.card_StartUpEnabled)
Layout.addWidget(self.card_TimeEnable)
Layout.addWidget(self.card_AfterAccomplish)
self.viewLayout.addLayout(Layout)
class TimeSettingCard(HeaderCardWidget):
def __init__(self, config: QueueConfig, parent=None):
super().__init__(parent)
self.setTitle("定时设置")
self.config = config
widget_1 = QWidget()
Layout_1 = QVBoxLayout(widget_1)
widget_2 = QWidget()
Layout_2 = QVBoxLayout(widget_2)
Layout = QHBoxLayout()
self.card_dict: Dict[str, TimeEditSettingCard] = {}
for i in range(10):
self.card_dict[f"Time_{i}"] = TimeEditSettingCard(
icon=FluentIcon.STOP_WATCH,
title=f"定时 {i + 1}",
content=None,
qconfig=self.config,
configItem_bool=self.config.config_item_dict["Time"][
f"Enabled_{i}"
],
configItem_time=self.config.config_item_dict["Time"][
f"Set_{i}"
],
parent=self,
)
if i < 5:
Layout_1.addWidget(self.card_dict[f"Time_{i}"])
else:
Layout_2.addWidget(self.card_dict[f"Time_{i}"])
Layout.addWidget(widget_1)
Layout.addWidget(widget_2)
self.viewLayout.addLayout(Layout)
class TaskSettingCard(HeaderCardWidget):
def __init__(self, config: QueueConfig, parent=None):
super().__init__(parent)
self.setTitle("任务队列")
self.config = config
script_list = [
["禁用"] + [_ for _ in Config.script_dict.keys()],
["未启用"]
+ [
(
k
if v["Config"].get_name() == ""
else f"{k} - {v["Config"].get_name()}"
)
for k, v in Config.script_dict.items()
],
]
self.card_dict: Dict[
str,
NoOptionComboBoxSettingCard,
] = {}
Layout = QVBoxLayout()
for i in range(10):
self.card_dict[f"Script_{i}"] = NoOptionComboBoxSettingCard(
icon=FluentIcon.APPLICATION,
title=f"任务实例 {i + 1}",
content=f"{i + 1}个调起的脚本任务实例",
value=script_list[0],
texts=script_list[1],
qconfig=self.config,
configItem=self.config.config_item_dict["Queue"][
f"Script_{i}"
],
parent=self,
)
Layout.addWidget(self.card_dict[f"Script_{i}"])
self.viewLayout.addLayout(Layout)

3876
app/ui/script_manager.py Normal file

File diff suppressed because it is too large Load Diff

1313
app/ui/setting.py Normal file

File diff suppressed because it is too large Load Diff

93
app/utils/AUTO_MAA.iss Normal file
View File

@@ -0,0 +1,93 @@
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "AUTO_MAA"
#define MyAppVersion ""
#define MyAppPublisher "AUTO_MAA Team"
#define MyAppURL "https://doc.automaa.xyz/"
#define MyAppExeName "AUTO_MAA.exe"
#define MyAppPath ""
#define OutputDir ""
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{D116A92A-E174-4699-B777-61C5FD837B19}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppVerName={#MyAppName}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName}
; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run
; on anything but x64 and Windows 11 on Arm.
ArchitecturesAllowed=x64compatible
; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the
; install be done in "64-bit mode" on x64 or Windows 11 on Arm,
; meaning it should use the native 64-bit Program Files directory and
; the 64-bit view of the registry.
ArchitecturesInstallIn64BitMode=x64compatible
DisableProgramGroupPage=yes
LicenseFile={#MyAppPath}\LICENSE
PrivilegesRequired=admin
OutputDir={#OutputDir}
OutputBaseFilename=AUTO_MAA-Setup
SetupIconFile={#MyAppPath}\resources\icons\AUTO_MAA.ico
SolidCompression=yes
WizardStyle=modern
AppMutex=AUTO_MAA_Installer_Mutex
[Languages]
Name: "Chinese"; MessagesFile: "{#MyAppPath}\resources\docs\ChineseSimplified.isl"
Name: "English"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "{#MyAppPath}\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MyAppPath}\app\*"; DestDir: "{app}\app"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#MyAppPath}\resources\*"; DestDir: "{app}\resources"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#MyAppPath}\Go_Updater\*"; DestDir: "{app}\Go_Updater"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#MyAppPath}\AUTO_MAA_Go_Updater_install.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MyAppPath}\main.py"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MyAppPath}\requirements.txt"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MyAppPath}\README.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#MyAppPath}\LICENSE"; DestDir: "{app}"; Flags: ignoreversion
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall runascurrentuser
[Code]
var
DeleteDataQuestion: Boolean;
function InitializeUninstall: Boolean;
begin
DeleteDataQuestion := MsgBox('您确认要完全移除 AUTO_MAA 的所有配置、用户数据与子组件吗?' + #13#10 +
'选择"是"将删除所有配置文件、数据与子组件程序。' + #13#10 +
'选择"否"将保留数据文件与子组件。',
mbConfirmation, MB_YESNO) = IDYES;
Result := True;
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usPostUninstall then
begin
DelTree(ExpandConstant('{app}\app'), True, True, True);
DelTree(ExpandConstant('{app}\resources'), True, True, True);
if DeleteDataQuestion then
begin
DelTree(ExpandConstant('{app}'), True, True, True);
end;
end;
end;

95
app/utils/ImageUtils.py Normal file
View File

@@ -0,0 +1,95 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2025 ClozyA
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA图像组件
v4.4
作者ClozyA
"""
import base64
import hashlib
from pathlib import Path
from PIL import Image
class ImageUtils:
@staticmethod
def get_base64_from_file(image_path):
"""从本地文件读取并返回base64编码字符串"""
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
@staticmethod
def calculate_md5_from_file(image_path):
"""从本地文件读取并返回md5值hex字符串"""
with open(image_path, "rb") as f:
return hashlib.md5(f.read()).hexdigest()
@staticmethod
def calculate_md5_from_base64(base64_content):
"""从base64字符串计算md5"""
image_data = base64.b64decode(base64_content)
return hashlib.md5(image_data).hexdigest()
@staticmethod
def compress_image_if_needed(image_path: Path, max_size_mb=2) -> Path:
"""
如果图片大于max_size_mb则压缩并覆盖原文件返回原始路径Path对象
"""
if hasattr(Image, "Resampling"): # Pillow 9.1.0及以后
RESAMPLE = Image.Resampling.LANCZOS
else:
RESAMPLE = Image.ANTIALIAS
max_size = max_size_mb * 1024 * 1024
if image_path.stat().st_size <= max_size:
return image_path
img = Image.open(image_path)
suffix = image_path.suffix.lower()
quality = 90 if suffix in [".jpg", ".jpeg"] else None
step = 5
if suffix in [".jpg", ".jpeg"]:
while True:
img.save(image_path, quality=quality, optimize=True)
if image_path.stat().st_size <= max_size or quality <= 10:
break
quality -= step
elif suffix == ".png":
width, height = img.size
while True:
img.save(image_path, optimize=True)
if (
image_path.stat().st_size <= max_size
or width <= 200
or height <= 200
):
break
width = int(width * 0.95)
height = int(height * 0.95)
img = img.resize((width, height), RESAMPLE)
else:
raise ValueError("仅支持JPG/JPEG和PNG格式图片的压缩。")
return image_path

164
app/utils/ProcessManager.py Normal file
View File

@@ -0,0 +1,164 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA进程管理组件
v4.4
作者DLmaster_361
"""
import psutil
import subprocess
from pathlib import Path
from datetime import datetime
from PySide6.QtCore import QTimer, QObject, Signal
class ProcessManager(QObject):
"""进程监视器类,用于跟踪主进程及其所有子进程的状态"""
processClosed = Signal()
def __init__(self):
super().__init__()
self.main_pid = None
self.tracked_pids = set()
self.check_timer = QTimer()
self.check_timer.timeout.connect(self.check_processes)
def open_process(self, path: Path, args: list = [], tracking_time: int = 60) -> int:
"""
启动一个新进程并返回其pid并开始监视该进程
:param path: 可执行文件的路径
:param args: 启动参数列表
:param tracking_time: 子进程追踪持续时间(秒)
:return: 新进程的PID
"""
process = subprocess.Popen(
[path, *args],
cwd=path.parent,
creationflags=subprocess.CREATE_NO_WINDOW,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self.start_monitoring(process.pid, tracking_time)
def start_monitoring(self, pid: int, tracking_time: int = 60) -> None:
"""
启动进程监视器,跟踪指定的主进程及其子进程
:param pid: 被监视进程的PID
:param tracking_time: 子进程追踪持续时间(秒)
"""
self.clear()
self.main_pid = pid
self.tracking_time = tracking_time
# 扫描并记录所有相关进程
try:
# 获取主进程
main_proc = psutil.Process(self.main_pid)
self.tracked_pids.add(self.main_pid)
# 递归获取所有子进程
if tracking_time:
for child in main_proc.children(recursive=True):
self.tracked_pids.add(child.pid)
except psutil.NoSuchProcess:
pass
# 启动持续追踪机制
self.start_time = datetime.now()
self.check_timer.start(100)
def check_processes(self) -> None:
"""检查跟踪的进程是否仍在运行,并更新子进程列表"""
# 仅在时限内持续更新跟踪的进程列表,发现新的子进程
if (datetime.now() - self.start_time).total_seconds() < self.tracking_time:
current_pids = set(self.tracked_pids)
for pid in current_pids:
try:
proc = psutil.Process(pid)
for child in proc.children():
if child.pid not in self.tracked_pids:
# 新发现的子进程
self.tracked_pids.add(child.pid)
except psutil.NoSuchProcess:
continue
if not self.is_running():
self.clear()
self.processClosed.emit()
def is_running(self) -> bool:
"""检查所有跟踪的进程是否还在运行"""
for pid in self.tracked_pids:
try:
proc = psutil.Process(pid)
if proc.is_running():
return True
except psutil.NoSuchProcess:
continue
return False
def kill(self, if_force: bool = False) -> None:
"""停止监视器并中止所有跟踪的进程"""
self.check_timer.stop()
for pid in self.tracked_pids:
try:
proc = psutil.Process(pid)
if if_force:
kill_process = subprocess.Popen(
["taskkill", "/F", "/T", "/PID", str(pid)],
creationflags=subprocess.CREATE_NO_WINDOW,
)
kill_process.wait()
proc.terminate()
except psutil.NoSuchProcess:
continue
if self.main_pid:
self.processClosed.emit()
self.clear()
def clear(self) -> None:
"""清空跟踪的进程列表"""
self.main_pid = None
self.check_timer.stop()
self.tracked_pids.clear()

35
app/utils/__init__.py Normal file
View File

@@ -0,0 +1,35 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA工具包
v4.4
作者DLmaster_361
"""
__version__ = "4.2.0"
__author__ = "DLmaster361 <DLmaster_361@163.com>"
__license__ = "GPL-3.0 license"
from .ImageUtils import ImageUtils
from .ProcessManager import ProcessManager
__all__ = ["ImageUtils", "ProcessManager"]

143
app/utils/package.py Normal file
View File

@@ -0,0 +1,143 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA打包程序
v4.4
作者DLmaster_361
"""
import os
import sys
import json
import shutil
from pathlib import Path
def version_text(version_numb: list) -> str:
"""将版本号列表转为可读的文本信息"""
while len(version_numb) < 4:
version_numb.append(0)
if version_numb[3] == 0:
version = f"v{'.'.join(str(_) for _ in version_numb[0:3])}"
else:
version = (
f"v{'.'.join(str(_) for _ in version_numb[0:3])}-beta.{version_numb[3]}"
)
return version
def version_info_markdown(info: dict) -> str:
"""将版本信息字典转为markdown信息"""
version_info = ""
for key, value in info.items():
version_info += f"## {key}\n"
for v in value:
version_info += f"- {v}\n"
return version_info
if __name__ == "__main__":
root_path = Path(sys.argv[0]).resolve().parent
with (root_path / "resources/version.json").open(mode="r", encoding="utf-8") as f:
version = json.load(f)
main_version_numb = list(map(int, version["main_version"].split(".")))
print("Packaging AUTO_MAA main program ...")
os.system(
"powershell -Command python -m nuitka --standalone --onefile --mingw64 --windows-uac-admin"
" --enable-plugins=pyside6 --windows-console-mode=attach"
" --onefile-tempdir-spec='{TEMP}\\AUTO_MAA'"
" --windows-icon-from-ico=resources\\icons\\AUTO_MAA.ico"
" --company-name='AUTO_MAA Team' --product-name=AUTO_MAA"
f" --file-version={version['main_version']}"
f" --product-version={version['main_version']}"
" --file-description='AUTO_MAA Component'"
" --copyright='Copyright © 2024-2025 DLmaster361'"
" --assume-yes-for-downloads --output-filename=AUTO_MAA"
" --remove-output main.py"
)
print("AUTO_MAA main program packaging completed !")
print("start to create setup program ...")
(root_path / "AUTO_MAA").mkdir(parents=True, exist_ok=True)
shutil.move(root_path / "AUTO_MAA.exe", root_path / "AUTO_MAA/")
shutil.copytree(root_path / "app", root_path / "AUTO_MAA/app")
shutil.copytree(root_path / "resources", root_path / "AUTO_MAA/resources")
shutil.copy(root_path / "main.py", root_path / "AUTO_MAA/")
shutil.copy(root_path / "requirements.txt", root_path / "AUTO_MAA/")
shutil.copy(root_path / "README.md", root_path / "AUTO_MAA/")
shutil.copy(root_path / "LICENSE", root_path / "AUTO_MAA/")
with (root_path / "app/utils/AUTO_MAA.iss").open(mode="r", encoding="utf-8") as f:
iss = f.read()
iss = (
iss.replace(
'#define MyAppVersion ""',
f'#define MyAppVersion "{version["main_version"]}"',
)
.replace(
'#define MyAppPath ""', f'#define MyAppPath "{root_path / "AUTO_MAA"}"'
)
.replace('#define OutputDir ""', f'#define OutputDir "{root_path}"')
)
with (root_path / "AUTO_MAA.iss").open(mode="w", encoding="utf-8") as f:
f.write(iss)
os.system(f'ISCC "{root_path / "AUTO_MAA.iss"}"')
(root_path / "AUTO_MAA_Setup").mkdir(parents=True, exist_ok=True)
shutil.move(root_path / "AUTO_MAA-Setup.exe", root_path / "AUTO_MAA_Setup")
shutil.make_archive(
base_name=root_path / f"AUTO_MAA_{version_text(main_version_numb)}",
format="zip",
root_dir=root_path / "AUTO_MAA_Setup",
base_dir=".",
)
print("setup program created !")
(root_path / "AUTO_MAA.iss").unlink(missing_ok=True)
shutil.rmtree(root_path / "AUTO_MAA")
shutil.rmtree(root_path / "AUTO_MAA_Setup")
all_version_info = {}
for v_i in version["version_info"].values():
for key, value in v_i.items():
if key in all_version_info:
all_version_info[key] += value.copy()
else:
all_version_info[key] = value.copy()
(root_path / "version_info.txt").write_text(
f"{version_text(main_version_numb)}\n\n<!--{json.dumps(version["version_info"], ensure_ascii=False)}-->\n{version_info_markdown(all_version_info)}",
encoding="utf-8",
)

View File

@@ -1,5 +0,0 @@
龙门币CE-6
技能CA-5
红票AP-5
经验LS-6
剿灭模式Annihilation

View File

@@ -1,3 +0,0 @@
管理密钥是解密用户密码的唯一凭证与数据库绑定。密钥丢失或data/key/目录下任一文件损坏都将导致解密无法正常进行。
本项目采用自主开发的混合加密模式项目组也无法找回您的管理密钥或修复data/key/目录下的文件。如果不幸的事发生建议您删除data/data.db重新录入信息。

88
main.py Normal file
View File

@@ -0,0 +1,88 @@
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
# Copyright © 2024-2025 DLmaster361
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# Contact: DLmaster_361@163.com
"""
AUTO_MAA
AUTO_MAA主程序
v4.4
作者DLmaster_361
"""
# 屏蔽广告
import builtins
original_print = builtins.print
def no_print(*args, **kwargs):
if (
args
and isinstance(args[0], str)
and "QFluentWidgets Pro is now released." in args[0]
):
return
return original_print(*args, **kwargs)
builtins.print = no_print
import os
import sys
import ctypes
from PySide6.QtWidgets import QApplication
from qfluentwidgets import FluentTranslator
from app.core.logger import logger
def is_admin() -> bool:
"""检查当前程序是否以管理员身份运行"""
try:
return ctypes.windll.shell32.IsUserAnAdmin()
except:
return False
@logger.catch
def main():
application = QApplication(sys.argv)
translator = FluentTranslator()
application.installTranslator(translator)
from app.ui.main_window import AUTO_MAA
window = AUTO_MAA()
window.show_ui("显示主窗口", if_start=True)
window.start_up_task()
sys.exit(application.exec())
if __name__ == "__main__":
if is_admin():
main()
else:
ctypes.windll.shell32.ShellExecuteW(
None, "runas", sys.executable, os.path.realpath(sys.argv[0]), None, 1
)
sys.exit(0)

Binary file not shown.

470
manage.py
View File

@@ -1,470 +0,0 @@
# <AUTO_MAA:A MAA Multi Account Management and Automation Tool>
# Copyright © <2024> <DLmaster361>
# This file is part of AUTO_MAA.
# AUTO_MAA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# AUTO_MAA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
# DLmaster_361@163.com
import sqlite3
import datetime
import msvcrt
import sys
import os
import hashlib
import random
import secrets
from Crypto.Cipher import AES
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP
from Crypto.Util.Padding import pad,unpad
#读入密码
def readpass(text):
sys.stdout=sys.__stdout__
sys.stdout.write(text)
sys.stdout.flush()
p=''
while True:
typed=msvcrt.getch()
if len(p)!=0:
if typed==b'\r':
sys.stdout.write('\b*')
sys.stdout.flush()
break
elif typed==b'\b':
p=p[:-1]
sys.stdout.write('\b \b')
sys.stdout.flush()
else:
p+=typed.decode("utf-8")
sys.stdout.write('\b*'+typed.decode("utf-8"))
sys.stdout.flush()
elif typed!=b'\r' and typed!=b'\b':
p+=typed.decode("utf-8")
sys.stdout.write(typed.decode("utf-8"))
sys.stdout.flush()
print('')
return p
#配置密钥
def getPASSWORD(PASSWORD):
#生成RSA密钥对
key=RSA.generate(2048)
public_key_local=key.publickey()
private_key=key
#保存RSA公钥
with open('data/key/public_key.pem','wb') as f:
f.write(public_key_local.exportKey())
#生成密钥转换与校验随机盐
PASSWORDsalt=secrets.token_hex(random.randint(32,1024))
with open("data/key/PASSWORDsalt.txt","w",encoding="utf-8") as f:
print(PASSWORDsalt,file=f)
verifysalt=secrets.token_hex(random.randint(32,1024))
with open("data/key/verifysalt.txt","w",encoding="utf-8") as f:
print(verifysalt,file=f)
#将管理密钥转化为AES-256密钥
AES_password=hashlib.sha256((PASSWORD+PASSWORDsalt).encode("utf-8")).digest()
#生成AES-256密钥校验哈希值并保存
AES_password_verify=hashlib.sha256(AES_password+verifysalt.encode("utf-8")).digest()
with open("data/key/AES_password_verify.bin","wb") as f:
f.write(AES_password_verify)
#AES-256加密RSA私钥并保存密文
AES_key=AES.new(AES_password,AES.MODE_ECB)
private_key_local=AES_key.encrypt(pad(private_key.exportKey(),32))
with open("data/key/private_key.bin","wb") as f:
f.write(private_key_local)
#加密
def encryptx(note):
#读取RSA公钥
with open('data/key/public_key.pem','rb') as f:
public_key_local=RSA.import_key(f.read())
#使用RSA公钥对数据进行加密
cipher=PKCS1_OAEP.new(public_key_local)
encrypted=cipher.encrypt(note.encode("utf-8"))
return encrypted
#解密
def decryptx(note,PASSWORD):
#读入RSA私钥密文、盐与校验哈希值
with open("data/key/private_key.bin","rb") as f:
private_key_local=f.read().strip()
with open("data/key/PASSWORDsalt.txt","r",encoding="utf-8") as f:
PASSWORDsalt=f.read().strip()
with open("data/key/verifysalt.txt","r",encoding="utf-8") as f:
verifysalt=f.read().strip()
with open("data/key/AES_password_verify.bin","rb") as f:
AES_password_verify=f.read().strip()
#将管理密钥转化为AES-256密钥并验证
AES_password=hashlib.sha256((PASSWORD+PASSWORDsalt).encode("utf-8")).digest()
AES_password_SHA=hashlib.sha256(AES_password+verifysalt.encode("utf-8")).digest()
if AES_password_SHA!=AES_password_verify:
return "管理密钥错误"
else:
#AES解密RSA私钥
AES_key=AES.new(AES_password,AES.MODE_ECB)
private_key_pem=unpad(AES_key.decrypt(private_key_local),32)
private_key=RSA.import_key(private_key_pem)
#使用RSA私钥解密数据
decrypter=PKCS1_OAEP.new(private_key)
note=decrypter.decrypt(note)
return note.decode("utf-8")
#修改管理密钥
def changePASSWORD():
#获取用户信息
db=sqlite3.connect(DATABASE)
cur=db.cursor()
cur.execute("SELECT * FROM adminx WHERE True")
data=cur.fetchall()
cur.close()
db.close()
data=[list(row) for row in data]
global PASSWORD
#验证管理密钥
PASSWORDold=readpass("请输入旧管理密钥:")
if len(data)==0:
print("当前无用户,验证自动通过")
PASSWORDnew=readpass("请输入新管理密钥:")
getPASSWORD(PASSWORDnew)
PASSWORD=PASSWORDnew
return "管理密钥修改成功"
while decryptx(data[0][6],PASSWORDold)=="管理密钥错误":
print("管理密钥错误")
PASSWORDold=readpass("请输入旧管理密钥:")
print("验证通过")
#修改管理密钥
PASSWORDnew=readpass("请输入新管理密钥:")
#使用旧管理密钥解密
for i in range(len(data)):
data[i][6]=decryptx(data[i][6],PASSWORDold)
#使用新管理密钥重新加密
getPASSWORD(PASSWORDnew)
db=sqlite3.connect(DATABASE)
cur=db.cursor()
for i in range(len(data)):
cur.execute("UPDATE adminx SET password=? WHERE admin=?",(encryptx(data[i][6]),data[i][0]))
db.commit()
cur.close()
db.close()
PASSWORD=PASSWORDnew
return "管理密钥修改成功"
#添加用户
def add():
adminx=input("用户名:")
#用户名重复验证
while search(adminx,0)=="":
print("该用户已存在,请重新输入")
adminx=input("用户名:")
numberx=input("手机号码:")
dayx=int(input("代理天数:"))
gamex=input("关卡号:")
passwordx=readpass("密码:")
passwordx=encryptx(passwordx)
#应用更新
db=sqlite3.connect(DATABASE)
cur=db.cursor()
cur.execute("INSERT INTO adminx VALUES(?,?,?,'y','2000-01-01',?,?)",(adminx,numberx,dayx,gamex,passwordx))
db.commit()
cur.close()
db.close()
return "操作成功"
#删除用户信息
def delete(id):
#检查用户是否存在
if search(id,0)!="":
return "未找到"+id
#应用更新
db=sqlite3.connect(DATABASE)
cur=db.cursor()
cur.execute("DELETE FROM adminx WHERE admin=?",(id,))
db.commit()
cur.close()
db.close()
return "成功删除"+id
#检索用户信息与配置
def search(id,book):
db=sqlite3.connect(DATABASE)
cur=db.cursor()
#处理启动时间查询
if id=="time":
cur.execute("SELECT * FROM timeset WHERE True")
timex=cur.fetchall()
timex=[list(row) for row in timex]
cur.close()
db.close()
if len(timex)==0:
return "启动时间未设置"
else:
for i in range(len(timex)):
print(timex[i][0])
return ""
#处理MAA路径查询
if id=="maa":
cur.execute("SELECT * FROM pathset WHERE True")
pathx=cur.fetchall()
if len(pathx)>0:
cur.close()
db.close()
return pathx[0][0]
else:
cur.close()
db.close()
return "MAA路径未设置"
#处理用户查询与全部信息查询
if id=="all":
cur.execute("SELECT * FROM adminx WHERE True")
else:
cur.execute("SELECT * FROM adminx WHERE admin=?",(id,))
data=cur.fetchall()
#处理全部信息查询时的MAA路径与启动时间查询
if id=="all":
cur.execute("SELECT * FROM pathset WHERE True")
pathx=cur.fetchall()
if len(pathx)>0:
print("\nMAA路径"+pathx[0][0])
else:
print("\nMAA路径未设置")
cur.execute("SELECT * FROM timeset WHERE True")
timex=cur.fetchall()
timex=[list(row) for row in timex]
if len(timex)==0:
print("\n启动时间未设置")
else:
print("启动时间:",end='')
for i in range(len(timex)):
print(timex[i][0],end=' ')
print('')
cur.close()
db.close()
data=[list(row) for row in data]
if len(data)>0:
#转译执行情况、用户状态,对全部信息查询与无输出查询隐去密码
curdate=datetime.date.today()
curdate=curdate.strftime('%Y-%m-%d')
for i in range(len(data)):
if data[i][4]==curdate:
data[i][4]="今日已执行"
else:
data[i][4]="今日未执行"
if data[i][3]=='y':
data[i][3]="启用"
else:
data[i][3]="禁用"
if id=="all" or book==0:
data[i][6]="******"
else:
#解密
global PASSWORD
if PASSWORD==0 or decryptx(data[i][6],PASSWORD)=="管理密钥错误":
PASSWORD=readpass("请输入管理密钥:")
data[i][6]=decryptx(data[i][6],PASSWORD)
#制表输出
if book==1:
print('')
print(unit("用户名",15),unit("手机号码",12),unit("代理天数",8),unit("状态",4),unit("执行情况",10),unit("关卡",10),unit("密码",25))
for i in range(len(data)):
print(unit(data[i][0],15),unit(data[i][1],12),unit(data[i][2],8),unit(data[i][3],4),unit(data[i][4],10),unit(data[i][5],10),unit(data[i][6],25))
return ""
elif id=="all":
return "\n当前没有用户记录"
else:
return "未找到"+id
#续期
def renewal(readxx):
#提取用户名与续期时间
for i in range(len(readxx)):
if readxx[i]==' ':
id=readxx[:i]
dayp=int(readxx[i+1:])
break
#检查用户是否存在
if search(id,0)!="":
return "未找到"+id
#应用更新
db=sqlite3.connect(DATABASE)
cur=db.cursor()
cur.execute("SELECT * FROM adminx WHERE admin=?",(id,))
data=cur.fetchall()
cur.execute("UPDATE adminx SET day=? WHERE admin=?",(data[0][2]+dayp,id))
db.commit()
cur.close()
db.close()
return '成功更新'+id+'的代理天数至'+str(data[0][2]+dayp)+''
#用户状态配置
def turn(id,t):
#检查用户是否存在
if search(id,0)!="":
return "未找到"+id
#应用更新
db=sqlite3.connect(DATABASE)
cur=db.cursor()
cur.execute("UPDATE adminx SET status=? WHERE admin=?",(t,id))
db.commit()
cur.close()
db.close()
if t=='y':
return '已启用'+id
else:
return '已禁用'+id
#修改刷取关卡
def gameid(readxx):
#提取用户名与修改值
for i in range(len(readxx)):
if readxx[i]==' ':
id=readxx[:i]
gamep=readxx[i+1:]
break
#检查用户是否存在
if search(id,0)!="":
return "未找到"+id
#导入与应用特殊关卡规则
games={}
with open('data/gameid.txt',encoding='utf-8') as f:
gameids=f.readlines()
for i in range(len(gameids)):
for j in range(len(gameids[i])):
if gameids[i][j]=='':
gamein=gameids[i][:j]
gameout=gameids[i][j+1:]
break
games[gamein]=gameout.strip()
if gamep in games:
gamep=games[gamep]
#应用更新
db=sqlite3.connect(DATABASE)
cur=db.cursor()
cur.execute("UPDATE adminx SET game=? WHERE admin=?",(gamep,id))
db.commit()
cur.close()
db.close()
return '成功更新'+id+'的关卡为'+gamep
#设置MAA路径
def setpath(pathx):
db=sqlite3.connect(DATABASE)
cur=db.cursor()
cur.execute("SELECT * FROM pathset WHERE True")
pathold=cur.fetchall()
if len(pathold)>0:
cur.execute("UPDATE pathset SET path=? WHERE True",(pathx,))
else:
cur.execute("INSERT INTO pathset VALUES(?)",(pathx,))
db.commit()
cur.close()
db.close()
return "MAA路径已设置为"+pathx
#设置启动时间
def settime(book,timex):
db=sqlite3.connect(DATABASE)
cur=db.cursor()
#检查待操作对象存在情况
cur.execute("SELECT * FROM timeset WHERE True")
timeold=cur.fetchall()
timeold=[list(row) for row in timeold]
timenew=[]
timenew.append(timex)
#添加时间设置
if book=='+':
if timenew in timeold:
cur.close()
db.close()
return "已存在"+timex
else:
cur.execute("INSERT INTO timeset VALUES(?)",(timex,))
db.commit()
cur.close()
db.close()
return "已添加"+timex
#删除时间设置
elif book=='-':
if timenew in timeold:
cur.execute("DELETE FROM timeset WHERE time=?",(timex,))
db.commit()
cur.close()
db.close()
return "已删除"+timex
else:
cur.close()
db.close()
return "未找到"+timex
#统一制表单元
def unit(x,m):
#字母与连接符占1位中文占2位
x=str(x)
n=0
for i in x:
if 'a'<=i<='z' or 'A'<=i<='Z' or '0'<=i<='9' or i=='_' or i=='-':
n+=1
return ' '+x+' '*(m-2*len(x)+n)
#初期检查
DATABASE="data/data.db"
PASSWORD=0
if not os.path.exists(DATABASE):
db=sqlite3.connect(DATABASE)
cur=db.cursor()
db.execute("CREATE TABLE adminx(admin text,number text,day int,status text,last date,game text,password byte)")
db.execute("CREATE TABLE pathset(path text)")
db.execute("CREATE TABLE timeset(time text)")
readx=input("首次启动请设置MAA路径")
cur.execute("INSERT INTO pathset VALUES(?)",(readx,))
db.commit()
cur.close()
db.close()
PASSWORD=readpass("请设置管理密钥(密钥与数据库绑定):")
getPASSWORD(PASSWORD)
#初始界面
print("Good evening!")
print(search("all",1))
#主程序
while True:
read=input()
if len(read)==0:
print("无法识别的输入")
elif read[0]=='+' and len(read)==1:
print(add())
elif read[0]=='-' and len(read)==1:
os._exit(0)
elif read[0]=='/':
print(setpath(read[1:]))
elif read[0]=='*' and len(read)==1:
print(changePASSWORD())
elif read[0]==':' and (read[1]=='+' or read[1]=='-'):
print(settime(read[1],read[2:]))
else:
if read[-1]=='?' and read[-2]==' ':
print(search(read[:-2],1))
elif read[-1]=='+' and read[-2]==' ':
print(renewal(read[:-2]))
elif read[-1]=='-' and read[-2]==' ':
print(delete(read[:-2]))
elif read[-1]=='~' and read[-2]==' ':
print(gameid(read[:-2]))
elif (read[-1]=='y' or read[-1]=='n') and read[-2]==' ':
print(turn(read[:-2],read[-1]))
else:
print("无法识别的输入")

16
requirements.txt Normal file
View File

@@ -0,0 +1,16 @@
loguru==0.7.3
plyer==2.1.0
PySide6==6.9.1
PySide6-Fluent-Widgets[full]==1.8.3
psutil==7.0.0
pywin32==310
keyboard==0.13.5
pycryptodome==3.23.0
certifi==2025.4.26
truststore==0.10.1
requests==2.32.4
markdown==3.8.2
Jinja2==3.1.6
nuitka==2.7.12
pillow==11.3.0
packaging==25.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Some files were not shown because too many files have changed in this diff Show More