Compare commits
621 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11d5ba385f | ||
|
|
dc1fc52c33 | ||
|
|
6b37ba0ce3 | ||
| 23b3691a13 | |||
|
|
0755d34903 | ||
|
|
2c0e457976 | ||
|
|
8948d0fb18 | ||
|
|
908da0bc47 | ||
|
|
aae17208b0 | ||
|
|
cbd8918c61 | ||
|
|
f2b4f9e8fc | ||
|
|
a2a11647bb | ||
|
|
1d9c275b61 | ||
|
|
52468928c6 | ||
|
|
3d9471779a | ||
|
|
155c4b00d5 | ||
|
|
f4b45e9eae | ||
|
|
d836b58b2d | ||
|
|
4fc747f1c6 | ||
|
|
b27f2c43ae | ||
|
|
c5f947e14a | ||
|
|
67c41ab3ee | ||
|
|
9f330e2245 | ||
|
|
d785262312 | ||
|
|
27faaabcf2 | ||
| 226d68cb1c | |||
|
|
e776cc2319 | ||
|
|
3cdb1e511d | ||
| a4f867665f | |||
|
|
6b0583b139 | ||
| 2aad0c65c2 | |||
| 6b646378b6 | |||
| 747ad6387b | |||
| 228e66315c | |||
|
|
97c283797a | ||
|
|
eb1fade6f5 | ||
|
|
8d6071f794 | ||
|
|
8a109e34f8 | ||
|
|
fd72d72692 | ||
|
|
63ffacff96 | ||
|
|
1b4bb6fccc | ||
|
|
9b492b5e0d | ||
|
|
8427bd9f6b | ||
|
|
4cbd921ab6 | ||
|
|
2c915161d5 | ||
|
|
75b06ca770 | ||
|
|
c3468a3387 | ||
|
|
a2f4adb647 | ||
|
|
403f69df8b | ||
|
|
12cf10f97a | ||
|
|
6084befe2c | ||
|
|
1aa99ea613 | ||
|
|
d539c0f808 | ||
|
|
bc509806fb | ||
|
|
c52820550f | ||
|
|
98b30f90a1 | ||
|
|
4efbafc174 | ||
|
|
6d3fda50d3 | ||
|
|
70b936012f | ||
|
|
54917fbe6d | ||
|
|
abeb9f054d | ||
|
|
c6d6c5fb2a | ||
|
|
5b0d7f0012 | ||
|
|
d9043aab0a | ||
|
|
b9281b68ab | ||
|
|
5c6a20be4e | ||
|
|
1c0a65957d | ||
|
|
7c315624b1 | ||
|
|
0572caa528 | ||
|
|
4233040585 | ||
|
|
c27dc8e380 | ||
|
|
e746756e56 | ||
|
|
1829d1cd0b | ||
|
|
fb979e5639 | ||
|
|
e7d0a85ad5 | ||
|
|
a384711327 | ||
|
|
3fd4778a48 | ||
|
|
4841dc09b3 | ||
|
|
b3aa4fc776 | ||
|
|
a9b3b8b6f4 | ||
|
|
56ef196695 | ||
| 242238d341 | |||
| f66f6d38fe | |||
| d58077f58b | |||
| 4d4d6dbedf | |||
| f60b276916 | |||
| 87857fd499 | |||
| 3c371cd079 | |||
|
|
428b849bcc | ||
|
|
85f3b4f607 | ||
|
|
916396f855 | ||
|
|
211c8d2b04 | ||
|
|
92e274d3fd | ||
|
|
d511ea48d5 | ||
|
|
1aa4da1adf | ||
|
|
0e8b6b0b6b | ||
|
|
1a2c1b976f | ||
|
|
1cc242fa51 | ||
|
|
18dfdba15d | ||
|
|
b04ac4eec6 | ||
|
|
c009f0c891 | ||
|
|
d2dc0bd295 | ||
|
|
ddbb5b7f19 | ||
|
|
954c25090b | ||
|
|
0b6cc59de1 | ||
|
|
2271b5741d | ||
|
|
8a438b041f | ||
|
|
dd92fcc4d8 | ||
|
|
8f66ca0e16 | ||
|
|
895ba1d24a | ||
| e49b807bef | |||
|
|
73c15b5e93 | ||
|
|
e505ea8c51 | ||
|
|
21e7df7c3e | ||
|
|
2d72ca66a4 | ||
|
|
4725a30165 | ||
|
|
f3c977f1b3 | ||
|
|
9a0e7265c6 | ||
|
|
3f8e2fbe6b | ||
|
|
590b13e916 | ||
|
|
0f6aee56e5 | ||
|
|
daf18e7295 | ||
|
|
9bcc87f663 | ||
|
|
e7205ce0aa | ||
|
|
e3c4b2edc8 | ||
|
|
222a3b35a2 | ||
|
|
cd5dfd56b2 | ||
|
|
7d5c6b8222 | ||
|
|
4dbf4736e4 | ||
|
|
d50504181e | ||
|
|
c7e94dfcd1 | ||
|
|
a752b67ca1 | ||
|
|
078736337d | ||
|
|
de1058a28c | ||
|
|
740797a689 | ||
|
|
26328920a2 | ||
|
|
9c447bbdf9 | ||
|
|
fac85a889f | ||
|
|
f5d898c89e | ||
| 974a4b634a | |||
| 3127c83603 | |||
|
|
8d69e43f72 | ||
|
|
86df9e7a50 | ||
| 59ff9bf818 | |||
|
|
1641e32e3d | ||
|
|
a482087abd | ||
| bc5b15cec2 | |||
| 3787c25a77 | |||
|
|
0b06b499e4 | ||
|
|
04079dd57b | ||
|
|
34ac0c5ab3 | ||
| 0d904b229e | |||
| c0f887ff9d | |||
|
|
cf95075d01 | ||
|
|
d78a764d87 | ||
|
|
a368f4b722 | ||
|
|
803fe4568f | ||
|
|
1162d5dcc1 | ||
|
|
aa83058e39 | ||
|
|
061f205224 | ||
|
|
5d966f98df | ||
|
|
0037914db8 | ||
|
|
13d0115475 | ||
|
|
5bdb5ad2bf | ||
| a5d733c8df | |||
|
|
0b038e2997 | ||
|
|
5e46040db6 | ||
|
|
f2b04dd0f6 | ||
|
|
2177c1b40e | ||
|
|
d1f4cffe8f | ||
|
|
74ce441b90 | ||
|
|
5893aa2426 | ||
|
|
cb7e7bf9d4 | ||
|
|
fbfdc6aa12 | ||
|
|
e7b6743e10 | ||
|
|
ff4283e917 | ||
|
|
890886d62d | ||
|
|
fd75dda2b1 | ||
|
|
f22c1aeae3 | ||
|
|
d68d49a469 | ||
|
|
1900d4eaf5 | ||
|
|
02833209d5 | ||
| 2058c0218c | |||
|
|
8896e723eb | ||
|
|
edcc614833 | ||
|
|
23fe1ff0be | ||
|
|
19d1dc9f28 | ||
|
|
24b93cfcad | ||
|
|
d3298fac8a | ||
|
|
fba5395bf0 | ||
|
|
2c4508ee16 | ||
| d239443555 | |||
| e45ad08fab | |||
| ddf5d26c4b | |||
|
|
ce74dcf912 | ||
|
|
41412e1ef4 | ||
|
|
1395d48cd0 | ||
|
|
418c3d4742 | ||
|
|
17ec962a22 | ||
|
|
989ee73549 | ||
|
|
7e452e1253 | ||
|
|
5bdb5c8025 | ||
|
|
924a5fea0b | ||
|
|
b51a57a6ee | ||
|
|
4079188881 | ||
|
|
174163e305 | ||
|
|
0886439685 | ||
|
|
34bf5a4fe8 | ||
|
|
e6a97f2b17 | ||
|
|
fecff625a3 | ||
|
|
6f540036a0 | ||
|
|
86d72aec39 | ||
|
|
39876832f3 | ||
|
|
f3af6ddbbc | ||
|
|
ba7299e20c | ||
|
|
5db9d934b2 | ||
|
|
5c8eebf12c | ||
|
|
e725f6d2b2 | ||
|
|
494b655156 | ||
|
|
2940f2557c | ||
|
|
5e4660670f | ||
| e8d592ae76 | |||
|
|
97ea51df59 | ||
|
|
986061dc97 | ||
|
|
fe1910d16f | ||
|
|
63cb1aaa74 | ||
| 49ebd50077 | |||
|
|
4a6f874210 | ||
|
|
9394c7a9c5 | ||
|
|
7e502420fa | ||
|
|
12f4b764de | ||
|
|
4da4b7d552 | ||
|
|
d38abbbaa0 | ||
|
|
67bf7f649e | ||
|
|
acb35403b0 | ||
|
|
7d5dccc649 | ||
|
|
a7e0e7b217 | ||
|
|
9ce75b2dda | ||
|
|
d2022819f6 | ||
|
|
c8b342ba01 | ||
|
|
63823d5c89 | ||
|
|
cb17cc32da | ||
|
|
14d0e6d438 | ||
|
|
878fbad06a | ||
|
|
deb0506163 | ||
|
|
c4aeb673fd | ||
|
|
915ee59643 | ||
|
|
1568e120be | ||
|
|
d19dd3496d | ||
|
|
62c86ce477 | ||
|
|
c727eddc54 | ||
|
|
9c946ef6dc | ||
|
|
38a04fc4b2 | ||
| bded794647 | |||
|
|
539cb1de99 | ||
| 2e9ff47dbb | |||
|
|
c01079af1b | ||
|
|
cca1acb6f6 | ||
|
|
d7e502e22f | ||
|
|
bbeab360bc | ||
|
|
a78b7fdb29 | ||
| 273fbe2261 | |||
| ba9855c616 | |||
| c54f894f4f | |||
|
|
9f88f92ec0 | ||
|
|
a80e96c2cd | ||
|
|
7774612810 | ||
| 088ea1817c | |||
|
|
f362c8f7ef | ||
|
|
648f42b7e0 | ||
|
|
9a56cc350d | ||
|
|
50cd49217f | ||
|
|
7ed4b7db57 | ||
| b359cd623b | |||
|
|
a363e8dc34 | ||
|
|
52affc0d76 | ||
|
|
fe26f29f93 | ||
|
|
67b8725156 | ||
|
|
2a235b2bc9 | ||
|
|
dd022cf356 | ||
|
|
62e5bb30e2 | ||
|
|
675e11960a | ||
|
|
0c274ecbe0 | ||
|
|
2dfcd3f131 | ||
|
|
053acd138f | ||
|
|
3f20ae62be | ||
|
|
d342c7c827 | ||
|
|
3da0cfd0d0 | ||
|
|
acc4045580 | ||
|
|
6ee577302f | ||
|
|
d52856180a | ||
|
|
d4d479ca20 | ||
|
|
364af4b9c5 | ||
|
|
9e0d81fb1d | ||
|
|
2ee2c37479 | ||
|
|
528925b969 | ||
| 4851b40777 | |||
|
|
6372ad4e0a | ||
|
|
465bc9137e | ||
|
|
e8b6f5d893 | ||
|
|
54b697f2ee | ||
|
|
70df428825 | ||
|
|
8993d66056 | ||
|
|
863e6fb25e | ||
|
|
181856173e | ||
|
|
576fe59bbc | ||
|
|
c73aca71f7 | ||
|
|
ce264de963 | ||
|
|
1feb0cf83f | ||
|
|
6292624d41 | ||
| 4271a07f03 | |||
|
|
254fb6916f | ||
|
|
21857325a2 | ||
|
|
175d6860a3 | ||
|
|
d1c8f98408 | ||
|
|
3499fa9067 | ||
|
|
cca2cd774c | ||
|
|
6d60f8adb8 | ||
|
|
3b406a7974 | ||
| a116b3359c | |||
| 928019390b | |||
| 022b698f54 | |||
| 0228ac8393 | |||
|
|
a99f381f7f | ||
|
|
7c0af24bf5 | ||
|
|
d3aa45cfb9 | ||
|
|
f5461deb81 | ||
|
|
c19068128f | ||
|
|
1367daf1b7 | ||
|
|
5fc6e74cd6 | ||
|
|
5d7227c009 | ||
| 3a9c670172 | |||
|
|
2768faed53 | ||
|
|
85f3d6f09f | ||
|
|
c99707ecb4 | ||
|
|
2b8e648fe6 | ||
|
|
fcf61fd39a | ||
|
|
8e3026f91e | ||
|
|
8e00676faf | ||
|
|
ae293c4c20 | ||
| df4a5f3318 | |||
|
|
1da96c4d1d | ||
|
|
144c7f5db7 | ||
|
|
b3a3ccfea3 | ||
|
|
c3212f0ca1 | ||
|
|
a946999782 | ||
|
|
284c41feb7 | ||
|
|
ddf905cb13 | ||
|
|
d5082d18ef | ||
|
|
af51831062 | ||
|
|
fe4707df84 | ||
|
|
292e7f51e0 | ||
|
|
8936b1c41d | ||
|
|
d45da439bd | ||
|
|
7dc057e30f | ||
|
|
eb2f9d2cea | ||
|
|
fb1895c4eb | ||
|
|
90d3dad8c8 | ||
|
|
de12339c3e | ||
|
|
f07cd2b44a | ||
|
|
c7fbbf6f50 | ||
|
|
de262ee6bd | ||
|
|
0c123e9389 | ||
|
|
d13fbb063d | ||
|
|
5c24eb7d56 | ||
|
|
6c2f19a884 | ||
|
|
4ff632ed2a | ||
|
|
d445c0054f | ||
|
|
748fa7a004 | ||
|
|
c3e710b5cf | ||
|
|
a93a60d125 | ||
|
|
07f24c6168 | ||
|
|
7f5478b098 | ||
|
|
fb7a429ff2 | ||
| 3307793a3d | |||
|
|
0da9f4b7ab | ||
|
|
f45dc3a34c | ||
|
|
1c17f3d878 | ||
|
|
75e4d2b290 | ||
|
|
32fe941735 | ||
|
|
27633b1017 | ||
|
|
c34ca0dea9 | ||
|
|
0574e9c6cb | ||
|
|
b7f09141f1 | ||
|
|
022e59e65c | ||
|
|
a0731331a8 | ||
|
|
4b01222648 | ||
|
|
cae4b26c89 | ||
|
|
427c2332f5 | ||
|
|
6f0aec329b | ||
|
|
4e4d1d068f | ||
|
|
074f4f2ca9 | ||
|
|
c51f9ad901 | ||
|
|
792452c048 | ||
|
|
662eb0bc7f | ||
|
|
94a9bdbb93 | ||
|
|
df96183f42 | ||
|
|
a5b4f6f59f | ||
|
|
6f7497cbe9 | ||
|
|
dbdc2144b7 | ||
|
|
e34106f857 | ||
|
|
c3c07804cd | ||
|
|
0b5ac6bb6e | ||
|
|
ea87eefb9b | ||
|
|
20dc4656dc | ||
|
|
3f0f1612e3 | ||
|
|
e92b6ecfe6 | ||
|
|
37502b6fd8 | ||
|
|
8c1c3c5675 | ||
|
|
b4228e3f35 | ||
|
|
e78f3973be | ||
|
|
29536003a4 | ||
|
|
ffa3767198 | ||
|
|
8702070725 | ||
|
|
793259a194 | ||
|
|
3a63a73244 | ||
|
|
43448cc68d | ||
|
|
f0d272cce5 | ||
|
|
9983455b60 | ||
|
|
0a411c150a | ||
|
|
89b49a1143 | ||
|
|
917454c0b9 | ||
|
|
170b87e7a8 | ||
|
|
68db248a7e | ||
|
|
24614099ed | ||
|
|
8bbfdcbc04 | ||
|
|
126799d2a2 | ||
|
|
c625354dec | ||
|
|
7e08c88a3e | ||
|
|
764d0afb50 | ||
|
|
fe87547406 | ||
|
|
a1fd27722b | ||
|
|
3bd6611cd5 | ||
|
|
f3e1b4580a | ||
|
|
449d8a032e | ||
|
|
b40fa35622 | ||
|
|
ff7e433634 | ||
|
|
1ea9d10bb4 | ||
|
|
04fd2b10fa | ||
|
|
e79417ec5e | ||
|
|
684211c129 | ||
|
|
f94e129cba | ||
|
|
87a140d373 | ||
|
|
f135a6510f | ||
|
|
6960184911 | ||
|
|
7bd270c662 | ||
|
|
f54e83673f | ||
|
|
ee40fdb3c3 | ||
|
|
52ebf7b027 | ||
|
|
85891dc918 | ||
|
|
7348a87a20 | ||
|
|
1dfa3e3f44 | ||
|
|
37ced2e535 | ||
|
|
11876acc62 | ||
|
|
756c0926ec | ||
|
|
9604fc9a8e | ||
|
|
5bcc527889 | ||
|
|
0d616289ed | ||
|
|
a8473b6a04 | ||
|
|
e1352586b7 | ||
|
|
849d5f18eb | ||
|
|
77298f4dab | ||
|
|
b7a2b045fb | ||
|
|
c7072da81d | ||
|
|
4c9b9fb74a | ||
|
|
feb4b516a4 | ||
|
|
d50191c6b8 | ||
|
|
3db3fa4e1f | ||
|
|
ad31961c2b | ||
|
|
ccdaefeb61 | ||
|
|
8c9bcba198 | ||
|
|
a273af0abe | ||
|
|
05a063b001 | ||
|
|
8487195512 | ||
|
|
250c47ccb7 | ||
|
|
e5aeb4f3a7 | ||
|
|
eab8d984e6 | ||
|
|
6b3f19a618 | ||
|
|
d5f8871064 | ||
|
|
77e899957c | ||
|
|
dd3515305c | ||
|
|
73c3ec4820 | ||
|
|
fde5160d56 | ||
|
|
13923705e5 | ||
|
|
d3afc95261 | ||
|
|
326d7e474c | ||
|
|
537b5d9725 | ||
|
|
697c1b5b43 | ||
|
|
2b6057161e | ||
|
|
8e2a6444bd | ||
|
|
b04df40b7d | ||
|
|
3e17d94904 | ||
|
|
29123054cc | ||
|
|
530c038985 | ||
|
|
2e9d2f0491 | ||
|
|
69ab9b4f15 | ||
|
|
1c8b940925 | ||
|
|
0e05a4ea18 | ||
|
|
9e4285b4c5 | ||
|
|
e89d2af8ae | ||
|
|
8e7d663060 | ||
|
|
179b33387e | ||
|
|
2c9a7c443f | ||
|
|
0de964e68c | ||
|
|
97046af931 | ||
|
|
ec24e776e7 | ||
|
|
92d4ef05fa | ||
|
|
fb27c50c75 | ||
|
|
0141bf039a | ||
|
|
54f9bb7f21 | ||
|
|
2c4e5eb5cc | ||
|
|
cd42f45a1f | ||
|
|
ec38895765 | ||
|
|
fc30579bbc | ||
|
|
3e6223e4e5 | ||
|
|
27c71124ff | ||
|
|
3a8a8a36f5 | ||
|
|
ed6de5e806 | ||
|
|
bf8a5befa3 | ||
|
|
a3fe641f6b | ||
|
|
f4f99c25db | ||
|
|
640d80e334 | ||
|
|
4f196b516f | ||
|
|
87f07bf95c | ||
|
|
1bfd7891b5 | ||
|
|
472eb0ee52 | ||
|
|
7638c67da6 | ||
|
|
7a0c143b5b | ||
|
|
fff8e11524 | ||
|
|
af24208cf3 | ||
|
|
85bccea2dd | ||
|
|
f7c8cac6ec | ||
|
|
97a5ac5bb0 | ||
|
|
1d57076010 | ||
|
|
d298ac872c | ||
|
|
c0581e781c | ||
|
|
6befd6341a | ||
|
|
504ef8dd68 | ||
|
|
127500d890 | ||
|
|
ab94173df7 | ||
|
|
e50697aae1 | ||
|
|
c71389fc0b | ||
|
|
732129ba34 | ||
|
|
77dfc895ed | ||
|
|
0c6f3123f8 | ||
|
|
e872d30b3a | ||
|
|
48fa4413c8 | ||
|
|
94e693db77 | ||
|
|
025d328d79 | ||
|
|
d086a5a152 | ||
|
|
5f2337d949 | ||
|
|
5e163181a8 | ||
|
|
0913cbb813 | ||
|
|
6aed64ce4e | ||
|
|
3c95e58fb2 | ||
|
|
64d0d4ed41 | ||
|
|
7fb659c511 | ||
|
|
d4ec91940f | ||
|
|
77f63ebb44 | ||
|
|
051b062e29 | ||
|
|
ec9083b556 | ||
|
|
156080a3b7 | ||
|
|
5b6effd43e | ||
|
|
97e8d41c39 | ||
|
|
d64ef447d2 | ||
|
|
461b2a62b0 | ||
|
|
f8988651b5 | ||
|
|
ffe865e29e | ||
|
|
cd5250c09f | ||
|
|
8f01bf6027 | ||
|
|
81eb11f5c5 | ||
|
|
80cadfa52c | ||
|
|
9974d70d99 | ||
|
|
6e3e4662f4 | ||
|
|
e2fb2e5565 | ||
|
|
6f8be226b0 | ||
|
|
2606f1e587 | ||
|
|
4d1fd7ea76 | ||
|
|
e61fbe6c69 | ||
|
|
0c287577ca | ||
|
|
a4aa562db9 | ||
|
|
33c5ff3a52 | ||
|
|
7c8e43bd35 | ||
|
|
bc0014dbe6 | ||
|
|
e41eca33bc | ||
|
|
e39b965459 | ||
|
|
f2aa3d7347 | ||
|
|
76c126284f | ||
|
|
44ee917d88 | ||
|
|
669019c051 | ||
|
|
4685c12570 | ||
|
|
d815529510 | ||
|
|
1da3620feb | ||
|
|
44d529c60f | ||
|
|
cdbcebd945 | ||
|
|
5bc2bf9397 | ||
|
|
d41419a579 | ||
|
|
a7e15e509e | ||
|
|
6518a378ac | ||
|
|
ebb73182f7 | ||
|
|
8fcc69165f | ||
|
|
5b98c58926 | ||
|
|
ce1534657b | ||
|
|
e2f02ea616 | ||
|
|
76134c2e2b | ||
|
|
2010855139 | ||
|
|
e550b9a155 | ||
|
|
53049d4acd | ||
|
|
bb4b45bc36 | ||
|
|
caf37e6c99 | ||
|
|
5af41d3b72 | ||
|
|
ebd3273251 | ||
|
|
63ed257a5f | ||
|
|
3bcfe6b1d0 | ||
|
|
7b789c71ca | ||
|
|
f78158e102 | ||
|
|
6d1c3490ba | ||
|
|
1a9af53e4d | ||
|
|
eac2d13fee | ||
|
|
1aad39fe7f | ||
|
|
8452ba95db |
283
.github/workflows/build-app.yml
vendored
Normal 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
@@ -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 }}
|
||||
19
.github/workflows/mirrorchyan_release_note.yml
vendored
Normal 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
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
config/
|
||||
data/
|
||||
debug/
|
||||
history/
|
||||
script/
|
||||
resources/notice.json
|
||||
resources/theme_image.json
|
||||
resources/images/Home/BannerTheme.jpg
|
||||
BIN
AUTO_MAA.exe
29
AUTO_MAA.py
@@ -1,29 +0,0 @@
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import datetime
|
||||
import time
|
||||
import os
|
||||
from termcolor import colored
|
||||
|
||||
DATABASE="data/data.db"
|
||||
db=sqlite3.connect(DATABASE)
|
||||
cur=db.cursor()
|
||||
cur.execute("SELECT * FROM timeset WHERE True")
|
||||
timeset=cur.fetchall()
|
||||
timeset=[list(row) for row in timeset]
|
||||
while True:
|
||||
curtime=datetime.datetime.now().strftime("%H:%M")
|
||||
print(colored("当前时间:"+curtime,'green'))
|
||||
timenew=[]
|
||||
timenew.append(curtime)
|
||||
if timenew in timeset:
|
||||
print(colored("开始执行",'yellow'))
|
||||
maa=subprocess.Popen(["run.exe"])
|
||||
maapid=maa.pid
|
||||
while True:
|
||||
if os.path.exists("OVER"):
|
||||
os.system('taskkill /F /T /PID '+str(maapid))
|
||||
os.remove("OVER")
|
||||
print(colored("执行完毕",'yellow'))
|
||||
break
|
||||
time.sleep(1)
|
||||
116
Go_Updater/Makefile
Normal 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
@@ -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
@@ -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)
|
||||
}
|
||||
186
Go_Updater/api/client_test.go
Normal 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
@@ -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
34
Go_Updater/assets/assets.go
Normal 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
|
||||
}
|
||||
100
Go_Updater/assets/assets_test.go
Normal 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)
|
||||
}
|
||||
7
Go_Updater/assets/config_template.yaml
Normal 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
|
||||
55
Go_Updater/build-config.yaml
Normal 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
@@ -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
@@ -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
@@ -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 "."
|
||||
}
|
||||
55
Go_Updater/config/config.json
Normal 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"
|
||||
}
|
||||
}
|
||||
153
Go_Updater/config/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
224
Go_Updater/download/manager.go
Normal 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
|
||||
}
|
||||
1392
Go_Updater/download/manager_test.go
Normal file
219
Go_Updater/errors/errors.go
Normal 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 "发生未知错误,请联系技术支持"
|
||||
}
|
||||
287
Go_Updater/errors/errors_test.go
Normal 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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
227
Go_Updater/gui/manager_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
BIN
Go_Updater/icon/AUTO_MAA_Go_Updater.ico
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
Go_Updater/icon/AUTO_MAA_Go_Updater.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
474
Go_Updater/install/manager.go
Normal 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
|
||||
})
|
||||
}
|
||||
1033
Go_Updater/install/manager_test.go
Normal file
12
Go_Updater/integration_test.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 集成测试将在此处实现
|
||||
// 此文件目前是占位符
|
||||
|
||||
func TestIntegrationPlaceholder(t *testing.T) {
|
||||
t.Skip("集成测试尚未实现")
|
||||
}
|
||||
438
Go_Updater/logger/logger.go
Normal 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()
|
||||
}
|
||||
300
Go_Updater/logger/logger_test.go
Normal 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
189
Go_Updater/version/manager.go
Normal 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.3(beta3)
|
||||
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
|
||||
}
|
||||
366
Go_Updater/version/manager_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
19
Go_Updater/version/version.go
Normal 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()
|
||||
)
|
||||
687
LICENSE
@@ -1,21 +1,674 @@
|
||||
MIT License
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (c) 2024 DLmaster
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program 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.
|
||||
|
||||
This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
287
README.md
@@ -1,250 +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>
|
||||
|
||||

|
||||
---
|
||||
|
||||
----------------------------------------------------------------------------------------------
|
||||
<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多账号功能体验。该软件包可以存储明日方舟多账号数据,并通过修改MAA配置文件、读取MAA日志等行为自动完成多账号代理。
|
||||
## 软件介绍
|
||||
|
||||
This software is open source, free of charge and for learning and exchange purposes only. The developer team has the final right to interpret this project. All problems arising from the use of this software are not related to this project and the developer team. If you encounter a merchant using this software to practice on your behalf and charging for it, it may be the cost of equipment and time, etc. The problems and consequences arising from this software have nothing to do with it.
|
||||
### 性质
|
||||
|
||||
本软件开源、免费,仅供学习交流使用。开发者团队拥有本项目的最终解释权。使用本软件产生的所有问题与本项目与开发者团队无关。若您遇到商家使用本软件进行代练并收费,可能是设备与时间等费用,产生的问题及后果与本软件无关。
|
||||
本软件是明日方舟第三方软件`MAA`的第三方工具,即第3<sup>3</sup>方软件。旨在优化MAA多账号功能体验,并通过一些方法解决MAA项目未能解决的部分问题,提高代理的稳定性。
|
||||
|
||||
## 安装与配置MAA
|
||||
- **集中管理**:一站式管理多个MAA脚本与多个用户配置,和凌乱的散装脚本窗口说再见!
|
||||
- **无人值守**:自动处理MAA相关报错,再也不用为代理任务卡死时自己不在电脑旁烦恼啦!
|
||||
- **配置灵活**:通过调度队列与脚本的组合,自由实现您能想到的所有调度需求!
|
||||
- **信息统计**:自动统计用户的公招与关卡掉落物,看看这个月您的收益是多少!
|
||||
|
||||
本软件是MAA的外部工具,需要安装配置MAA后才能使用。
|
||||
### 原理
|
||||
|
||||
### MAA安装
|
||||
本软件可以存储多个明日方舟账号数据,并通过以下流程实现代理功能:
|
||||
|
||||
什么是MAA? [官网](https://maa.plus/)/[GitHub](https://github.com/CHNZYX/Auto_Simulated_Universe/archive/refs/heads/main.zip)
|
||||
1. **配置:** 根据对应用户的配置信息,生成配置文件并将其导入MAA。
|
||||
2. **监测:** 在MAA开始代理后,持续读取MAA的日志以判断其运行状态。当软件认定MAA出现异常时,通过重启MAA使之仍能继续完成任务。
|
||||
3. **循环:** 重复上述步骤,使MAA依次完成各个用户的自动代理任务。
|
||||
|
||||
MAA下载地址 [GitHub下载](https://github.com/MaaAssistantArknights/MaaAssistantArknights/releases)
|
||||
### 优势
|
||||
|
||||
### MAA配置
|
||||
- **高效稳定**:通过日志监测、异常处理等机制,保障代理任务顺利完成。
|
||||
- **简洁易用**:无需手动修改配置文件,实现自动化调度与多开管理。
|
||||
- **兼容扩展**:支持 MAA 几乎所有的配置选项,满足不同用户需求。
|
||||
|
||||
1.完成MAA的adb配置等基本配置
|
||||
## 重要声明
|
||||
|
||||
2.在“完成后”菜单,选择“退出MAA和模拟器”。勾选“手动输入关卡名”和“无限吃48小时内过期的理智药”
|
||||
本开发团队承诺,不会修改明日方舟游戏本体与相关配置文件。本项目使用GPL开源,相关细则如下:
|
||||
|
||||

|
||||
- **作者:** AUTO_MAA软件作者为DLmaster、DLmaster361或DLmaster_361,以上均指代同一人。
|
||||
- **使用:** 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主页默认图像` 用于任何商业用途。
|
||||
|
||||
3.确保当前配置名为“Default”,取消所有“定时执行”
|
||||
以上细则是本项目对GPL的相关补充与强调。未提及的以GPL为准,发生冲突的以本细则为准。如有不清楚的部分,请发Issues询问。若发生纠纷,相关内容也没有在Issues上提及的,项目组拥有最终解释权。
|
||||
|
||||

|
||||
---
|
||||
|
||||
4.取消勾选“开机自启动MAA”,勾选“启动MAA后直接运行”和“启动MAA后自动开启模拟器”。配置自己模拟器所在的位置并根据实际情况填写“等待模拟器启动时间”(建议预留10s以防意外)。如果是多开用户,需要填写“附加命令”,具体填写值参见多开模拟器对应快捷方式路径(如“-v 1”)。
|
||||
# 使用方法
|
||||
|
||||

|
||||
访问AUTO_MAA官方文档站以获取使用指南和项目相关信息
|
||||
|
||||
5.勾选“定时检查更新”、“自动下载更新包”和“自动安装更新包”
|
||||
- [AUTO_MAA官方文档站](https://clozya.github.io/AUTOMAA_docs)
|
||||
|
||||

|
||||
---
|
||||
|
||||
## 下载AUTO_MAA软件包 [](https://github.com/DLmaster361/AUTO_MAA/releases)
|
||||
# 关于
|
||||
|
||||
GitHub下载地址 [GitHub下载](https://github.com/DLmaster361/AUTO_MAA/releases)
|
||||
## 项目开发情况
|
||||
|
||||
## 配置用户信息与相关参数
|
||||
可在[《AUTO_MAA开发者协作文档》](https://docs.qq.com/aio/DQ3Z5eHNxdmxFQmZX)的`开发任务`页面中查看开发进度。
|
||||
|
||||
**注意:** 当前所有的密码输入部分都存在一点“小问题”,请在输入密码时避免输入Delete、F12、Tab等功能键。
|
||||
## 代码签名策略(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)
|
||||
|
||||
双击启动`manage.exe`,输入MAA所在文件夹路径并回车(注意使用斜杠的种类,不要使用反斜杠),然后设置管理密钥(密钥可以包含字母大小写与特殊字符)。
|
||||
## 隐私政策(Privacy policy)
|
||||
|
||||

|
||||
除非用户、安装者或使用者特别要求,否则本程序不会将任何信息传输到其他网络系统。
|
||||
|
||||
管理密钥是解密用户密码的唯一凭证,与数据库绑定。密钥丢失或`data/key/`目录下任一文件损坏都将导致解密无法正常进行。
|
||||
This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it.
|
||||
|
||||
本项目采用自主开发的混合加密模式,项目组也无法找回您的管理密钥或修复`data/key/`目录下的文件。如果不幸的事发生,建议您删除`data/data.db`重新录入信息。
|
||||
## 特别鸣谢
|
||||
|
||||
### 添加用户
|
||||
- 下载服务器:由[AoXuan (@ClozyA)](https://github.com/ClozyA) 个人为项目赞助。
|
||||
|
||||
输入“+”以开始添加用户。依次输入:
|
||||
- EXE签名: 由 [SignPath.io](https://signpath.io/)提供免费代码签名,签名来自[SignPath Foundation](https://signpath.org/)。
|
||||
|
||||
**用户名:** 管理用户的惟一凭证
|
||||
|
||||
**手机号码:** 允许隐去中间四位以“****”代替
|
||||
|
||||
**代理天数:** 这个还要我解释吗?
|
||||
|
||||

|
||||
|
||||
### 删除用户
|
||||
|
||||
输入用户名+“-”以删除用户。格式:
|
||||
|
||||
```
|
||||
用户名 -
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 配置用户状态
|
||||
|
||||
**启用代理:** 输入用户名+“y”以启用该用户的代理。格式:
|
||||
|
||||
```
|
||||
用户名 y
|
||||
```
|
||||
|
||||

|
||||
|
||||
**禁用代理:** 输入用户名+“n”以禁用该用户的代理。格式:
|
||||
|
||||
```
|
||||
用户名 n
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 续期
|
||||
|
||||
输入用户名+续期天数+“+”以延长该用户的代理天数。格式:
|
||||
|
||||
```
|
||||
用户名 续期天数 +
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 修改刷取关卡
|
||||
|
||||
输入用户名+关卡号+“~”以更改该用户的代理关卡。格式:
|
||||
|
||||
```
|
||||
用户名 关卡号 ~
|
||||
```
|
||||
|
||||

|
||||
|
||||
**特别的:**
|
||||
|
||||
你可以自定义关卡号替换方案。程序会读取`gameid.txt`中的数据,依据此进行关卡号的替换,便于常用关卡的使用。`gameid.txt`在初始已经存储了一些常用资源本的替代方案。
|
||||
|
||||

|
||||
|
||||
### 设置MAA路径
|
||||
|
||||
输入“/”+新的MAA文件夹路径以修改MAA安装位置的配置。格式:
|
||||
|
||||
```
|
||||
/新的MAA文件夹路径
|
||||
```
|
||||
|
||||
**注意:** ‘/’与路径间没有空格,路径同样不能使用反斜杠
|
||||
|
||||

|
||||
|
||||
### 设置启动时间
|
||||
|
||||
**添加启动时间:** 输入“:+”+时间以添加定时启动时间。格式:
|
||||
|
||||
```
|
||||
:+小时:分钟
|
||||
```
|
||||
|
||||
**注意:** 所有输入间没有空格
|
||||
|
||||

|
||||
|
||||
**删除启动时间:** 输入“:-”+时间以删除定时启动时间。格式:
|
||||
|
||||
```
|
||||
:-小时:分钟
|
||||
```
|
||||
|
||||
**注意:** 所有输入间没有空格
|
||||
|
||||

|
||||
|
||||
### 检索信息
|
||||
|
||||
**检索所有信息:** `manage.exe`打开时会打印所有用户与配置信息。除此之外,你可以通过输入“all ?”以打印所有信息,如下:
|
||||
|
||||
```
|
||||
all ?
|
||||
```
|
||||
|
||||

|
||||
|
||||
**检索MAA路径:** 输入“maa ?”以检索MAA安装路径,如下:
|
||||
|
||||
```
|
||||
maa ?
|
||||
```
|
||||
|
||||

|
||||
|
||||
**检索启动时间:** 输入“time ?”以检索定时启动的时间,如下:
|
||||
|
||||
```
|
||||
time ?
|
||||
```
|
||||
|
||||

|
||||
|
||||
**检索指定用户:** 输入用户名+“?”以检索指定用户信息,如下:
|
||||
|
||||
```
|
||||
用户名 ?
|
||||
```
|
||||
|
||||
**注意:** 由于需要检索用户密码,每一次`manage.exe`启动后的首次查询需要验证管理密钥。为了方便操作,之后的查询不会再要求重复验证。因此,完成密码查询后,请及时关闭`manage.exe`。
|
||||
|
||||

|
||||
|
||||
### 修改管理密钥
|
||||
|
||||
输入“*”以开始修改管理密钥。依次输入:
|
||||
|
||||
**旧管理密钥:** 当数据库中没有存储用户信息时,允许跳过验证直接配置新管理密钥
|
||||
|
||||
**新管理密钥:** 请妥善保管,丢失无法找回。
|
||||
|
||||

|
||||
|
||||
### 退出
|
||||
|
||||
输入“-”以退出`manage.exe`,如下:
|
||||
|
||||
```
|
||||
-
|
||||
```
|
||||
|
||||
## 运行代理
|
||||
|
||||
### 直接运行
|
||||
|
||||
双击`run.exe`直接运行
|
||||
|
||||
### 定时运行
|
||||
|
||||
双击`AUTO_MAA.exe`打开,不要关闭。它会读取设定时间,在该时刻自动运行
|
||||
|
||||
**注意:** 周一将自动进行剿灭代理
|
||||
|
||||
## 关于
|
||||
|
||||
项目图标由文心一格AI生成
|
||||
|
||||
----------------------------------------------------------------------------------------------
|
||||
|
||||
欢迎加入AUTO_MAA项目组,欢迎反馈bug
|
||||
|
||||
QQ群:暂时没有
|
||||
|
||||
----------------------------------------------------------------------------------------------
|
||||
|
||||
如果喜欢本项目,可以打赏送作者一杯咖啡喵!
|
||||
|
||||

|
||||
|
||||
----------------------------------------------------------------------------------------------
|
||||
## 贡献者
|
||||
|
||||
感谢以下贡献者对本项目做出的贡献
|
||||
@@ -259,4 +104,16 @@ QQ群:暂时没有
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#DLmaster361/AUTO_MAA&Date)
|
||||
[](https://star-history.com/#DLmaster361/AUTO_MAA&Date)
|
||||
|
||||
## 交流与赞助
|
||||
|
||||
欢迎加入AUTO_MAA项目组,欢迎反馈bug
|
||||
|
||||
- QQ交流群:[957750551](https://qm.qq.com/q/bd9fISNoME)
|
||||
|
||||
---
|
||||
|
||||
如果喜欢这个项目的话,给作者来杯咖啡吧!
|
||||
|
||||

|
||||
|
||||
49
app/__init__.py
Normal 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
@@ -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
34
app/core/logger.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# 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 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
35
app/models/__init__.py
Normal 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
37
app/services/__init__.py
Normal 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"]
|
||||
484
app/services/notification.py
Normal 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
@@ -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
@@ -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
@@ -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
35
app/ui/__init__.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
1313
app/ui/setting.py
Normal file
93
app/utils/AUTO_MAA.iss
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
龙门币:CE-6
|
||||
技能:CA-5
|
||||
红票:AP-5
|
||||
经验:CA-5
|
||||
剿灭模式:Annihilation
|
||||
88
main.py
Normal 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)
|
||||
BIN
manage.exe
462
manage.py
@@ -1,462 +0,0 @@
|
||||
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():
|
||||
db=sqlite3.connect(DATABASE)
|
||||
cur=db.cursor()
|
||||
adminx=input("用户名:")
|
||||
#用户名重复验证
|
||||
while search(adminx,0)=="":
|
||||
print("该用户已存在,请重新输入")
|
||||
adminx=input("用户名:")
|
||||
numberx=input("手机号码:")
|
||||
dayx=int(input("代理天数:"))
|
||||
gamex=input("关卡号:")
|
||||
passwordx=readpass("密码:")
|
||||
passwordx=encryptx(passwordx)
|
||||
#应用更新
|
||||
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):
|
||||
db=sqlite3.connect(DATABASE)
|
||||
cur=db.cursor()
|
||||
#检查用户是否存在
|
||||
cur.execute("SELECT * FROM adminx WHERE admin=?",(id,))
|
||||
data=cur.fetchall()
|
||||
if len(data)==0:
|
||||
return "未找到"+id
|
||||
#应用更新
|
||||
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":
|
||||
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
|
||||
#检查用户是否存在
|
||||
db=sqlite3.connect(DATABASE)
|
||||
cur=db.cursor()
|
||||
cur.execute("SELECT * FROM adminx WHERE admin=?",(id,))
|
||||
data=cur.fetchall()
|
||||
if len(data)==0:
|
||||
cur.close()
|
||||
db.close()
|
||||
return "未找到"+id
|
||||
#应用更新
|
||||
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):
|
||||
#检查用户是否存在
|
||||
db=sqlite3.connect(DATABASE)
|
||||
cur=db.cursor()
|
||||
cur.execute("SELECT * FROM adminx WHERE admin=?",(id,))
|
||||
data=cur.fetchall()
|
||||
if len(data)==0:
|
||||
cur.close()
|
||||
db.close()
|
||||
return "未找到"+id
|
||||
#应用更新
|
||||
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
|
||||
#检查用户是否存在
|
||||
db=sqlite3.connect(DATABASE)
|
||||
cur=db.cursor()
|
||||
cur.execute("SELECT * FROM adminx WHERE admin=?",(id,))
|
||||
data=cur.fetchall()
|
||||
if len(data)==0:
|
||||
cur.close()
|
||||
db.close()
|
||||
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]
|
||||
#应用更新
|
||||
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:
|
||||
exit()
|
||||
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
@@ -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
|
||||
BIN
res/AUTO_MAA.ico
|
Before Width: | Height: | Size: 1.0 MiB |
BIN
res/AUTO_MAA.png
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 746 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 85 KiB |