Compare commits
6 Commits
v5.0.0-alp
...
v4.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc1fc52c33 | ||
|
|
6b37ba0ce3 | ||
| 23b3691a13 | |||
|
|
0755d34903 | ||
|
|
2c0e457976 | ||
|
|
4cbd921ab6 |
283
.github/workflows/build-app.yml
vendored
Normal file
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
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
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 }}
|
||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -1,31 +1,9 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
logs/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
.python-version
|
||||
list/
|
||||
uv.lock
|
||||
|
||||
# IDE and editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# User files
|
||||
config/
|
||||
data/
|
||||
debug/
|
||||
history/
|
||||
script/
|
||||
res/notice.json
|
||||
res/theme_image.json
|
||||
res/images/Home/BannerTheme.jpg
|
||||
resources/notice.json
|
||||
resources/theme_image.json
|
||||
resources/images/Home/BannerTheme.jpg
|
||||
116
Go_Updater/Makefile
Normal file
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
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
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
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
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
BIN
Go_Updater/app.syso
Normal file
Binary file not shown.
34
Go_Updater/assets/assets.go
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
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
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
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
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
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
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
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
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
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
1392
Go_Updater/download/manager_test.go
Normal file
File diff suppressed because it is too large
Load Diff
219
Go_Updater/errors/errors.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
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
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
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
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
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
BIN
Go_Updater/icon/AUTO_MAA_Go_Updater.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
Go_Updater/icon/AUTO_MAA_Go_Updater.png
Normal file
BIN
Go_Updater/icon/AUTO_MAA_Go_Updater.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
474
Go_Updater/install/manager.go
Normal file
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
1033
Go_Updater/install/manager_test.go
Normal file
File diff suppressed because it is too large
Load Diff
12
Go_Updater/integration_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
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
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
1049
Go_Updater/main.go
Normal file
File diff suppressed because it is too large
Load Diff
189
Go_Updater/version/manager.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
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
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()
|
||||
)
|
||||
121
README.md
121
README.md
@@ -1,2 +1,119 @@
|
||||
TEST
|
||||
TEST
|
||||
<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`的第三方工具,即第3<sup>3</sup>方软件。旨在优化MAA多账号功能体验,并通过一些方法解决MAA项目未能解决的部分问题,提高代理的稳定性。
|
||||
|
||||
- **集中管理**:一站式管理多个MAA脚本与多个用户配置,和凌乱的散装脚本窗口说再见!
|
||||
- **无人值守**:自动处理MAA相关报错,再也不用为代理任务卡死时自己不在电脑旁烦恼啦!
|
||||
- **配置灵活**:通过调度队列与脚本的组合,自由实现您能想到的所有调度需求!
|
||||
- **信息统计**:自动统计用户的公招与关卡掉落物,看看这个月您的收益是多少!
|
||||
|
||||
### 原理
|
||||
|
||||
本软件可以存储多个明日方舟账号数据,并通过以下流程实现代理功能:
|
||||
|
||||
1. **配置:** 根据对应用户的配置信息,生成配置文件并将其导入MAA。
|
||||
2. **监测:** 在MAA开始代理后,持续读取MAA的日志以判断其运行状态。当软件认定MAA出现异常时,通过重启MAA使之仍能继续完成任务。
|
||||
3. **循环:** 重复上述步骤,使MAA依次完成各个用户的自动代理任务。
|
||||
|
||||
### 优势
|
||||
|
||||
- **高效稳定**:通过日志监测、异常处理等机制,保障代理任务顺利完成。
|
||||
- **简洁易用**:无需手动修改配置文件,实现自动化调度与多开管理。
|
||||
- **兼容扩展**:支持 MAA 几乎所有的配置选项,满足不同用户需求。
|
||||
|
||||
## 重要声明
|
||||
|
||||
本开发团队承诺,不会修改明日方舟游戏本体与相关配置文件。本项目使用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主页默认图像` 用于任何商业用途。
|
||||
|
||||
以上细则是本项目对GPL的相关补充与强调。未提及的以GPL为准,发生冲突的以本细则为准。如有不清楚的部分,请发Issues询问。若发生纠纷,相关内容也没有在Issues上提及的,项目组拥有最终解释权。
|
||||
|
||||
---
|
||||
|
||||
# 使用方法
|
||||
|
||||
访问AUTO_MAA官方文档站以获取使用指南和项目相关信息
|
||||
|
||||
- [AUTO_MAA官方文档站](https://clozya.github.io/AUTOMAA_docs)
|
||||
|
||||
---
|
||||
|
||||
# 关于
|
||||
|
||||
## 项目开发情况
|
||||
|
||||
可在[《AUTO_MAA开发者协作文档》](https://docs.qq.com/aio/DQ3Z5eHNxdmxFQmZX)的`开发任务`页面中查看开发进度。
|
||||
|
||||
## 代码签名策略(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.
|
||||
|
||||
## 特别鸣谢
|
||||
|
||||
- 下载服务器:由[AoXuan (@ClozyA)](https://github.com/ClozyA) 个人为项目赞助。
|
||||
|
||||
- EXE签名: 由 [SignPath.io](https://signpath.io/)提供免费代码签名,签名来自[SignPath Foundation](https://signpath.org/)。
|
||||
|
||||
## 贡献者
|
||||
|
||||
感谢以下贡献者对本项目做出的贡献
|
||||
|
||||
<a href="https://github.com/DLmaster361/AUTO_MAA/graphs/contributors">
|
||||
|
||||
<img src="https://contrib.rocks/image?repo=DLmaster361/AUTO_MAA" />
|
||||
|
||||
</a>
|
||||
|
||||

|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#DLmaster361/AUTO_MAA&Date)
|
||||
|
||||
## 交流与赞助
|
||||
|
||||
欢迎加入AUTO_MAA项目组,欢迎反馈bug
|
||||
|
||||
- QQ交流群:[957750551](https://qm.qq.com/q/bd9fISNoME)
|
||||
|
||||
---
|
||||
|
||||
如果喜欢这个项目的话,给作者来杯咖啡吧!
|
||||
|
||||

|
||||
|
||||
@@ -1,34 +1,49 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# 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-MAS is distributed in the hope that it will be useful,
|
||||
# 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
__version__ = "5.0.0"
|
||||
"""
|
||||
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
|
||||
|
||||
from .api import *
|
||||
from .core import *
|
||||
from .models import *
|
||||
from .services import *
|
||||
from .utils import *
|
||||
|
||||
__all__ = ["api", "core", "models", "services", "utils"]
|
||||
__all__ = [
|
||||
"QueueConfig",
|
||||
"MaaConfig",
|
||||
"MaaUserConfig",
|
||||
"Task",
|
||||
"TaskManager",
|
||||
"MainTimer",
|
||||
"MaaManager",
|
||||
"Notify",
|
||||
"Crypto",
|
||||
"System",
|
||||
"AUTO_MAA",
|
||||
]
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
__version__ = "5.0.0"
|
||||
__author__ = "DLmaster361 <DLmaster_361@163.com>"
|
||||
__license__ = "GPL-3.0 license"
|
||||
|
||||
from .core import router as core_router
|
||||
from .info import router as info_router
|
||||
from .scripts import router as scripts_router
|
||||
from .plan import router as plan_router
|
||||
from .queue import router as queue_router
|
||||
from .dispatch import router as dispatch_router
|
||||
from .history import router as history_router
|
||||
from .setting import router as setting_router
|
||||
from .update import router as update_router
|
||||
|
||||
__all__ = [
|
||||
"core_router",
|
||||
"info_router",
|
||||
"scripts_router",
|
||||
"plan_router",
|
||||
"queue_router",
|
||||
"dispatch_router",
|
||||
"history_router",
|
||||
"setting_router",
|
||||
"update_router",
|
||||
]
|
||||
@@ -1,95 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import time
|
||||
import asyncio
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.core import Config, Broadcast, TaskManager
|
||||
from app.services import System
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/core", tags=["核心信息"])
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def connect_websocket(websocket: WebSocket):
|
||||
|
||||
if Config.websocket is not None:
|
||||
await websocket.close(code=1000, reason="已有连接")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
Config.websocket = websocket
|
||||
last_pong = time.monotonic()
|
||||
last_ping = time.monotonic()
|
||||
data = {}
|
||||
|
||||
await TaskManager.start_startup_queue()
|
||||
|
||||
while True:
|
||||
|
||||
try:
|
||||
|
||||
data = await asyncio.wait_for(websocket.receive_json(), timeout=1000005.0)
|
||||
if data.get("type") == "Signal" and "Pong" in data.get("data", {}):
|
||||
last_pong = time.monotonic()
|
||||
elif data.get("type") == "Signal" and "Ping" in data.get("data", {}):
|
||||
await websocket.send_json(
|
||||
WebSocketMessage(
|
||||
id="Main", type="Signal", data={"Pong": "无描述"}
|
||||
).model_dump()
|
||||
)
|
||||
else:
|
||||
await Broadcast.put(data)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
if last_pong < last_ping:
|
||||
await websocket.close(code=1000, reason="Ping超时")
|
||||
break
|
||||
await websocket.send_json(
|
||||
WebSocketMessage(
|
||||
id="Main", type="Signal", data={"Ping": "无描述"}
|
||||
).model_dump()
|
||||
)
|
||||
last_ping = time.monotonic()
|
||||
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
Config.websocket = None
|
||||
await System.set_power("KillSelf")
|
||||
|
||||
|
||||
@router.post("/close")
|
||||
async def close():
|
||||
"""关闭后端程序"""
|
||||
|
||||
try:
|
||||
await System.set_power("KillSelf")
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
@@ -1,87 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config, TaskManager
|
||||
from app.services import System
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/dispatch", tags=["任务调度"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/start", summary="添加任务", response_model=TaskCreateOut, status_code=200
|
||||
)
|
||||
async def add_task(task: TaskCreateIn = Body(...)) -> TaskCreateOut:
|
||||
|
||||
try:
|
||||
task_id = await TaskManager.add_task(task.mode, task.taskId)
|
||||
except Exception as e:
|
||||
return TaskCreateOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
websocketId="",
|
||||
)
|
||||
return TaskCreateOut(websocketId=str(task_id))
|
||||
|
||||
|
||||
@router.post("/stop", summary="中止任务", response_model=OutBase, status_code=200)
|
||||
async def stop_task(task: DispatchIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await TaskManager.stop_task(task.taskId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/set/power", summary="设置电源标志", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def set_power(task: PowerIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
Config.power_sign = task.signal
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cancel/power", summary="取消电源任务", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def cancel_power_task() -> OutBase:
|
||||
|
||||
try:
|
||||
await System.cancel_power_task()
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
@@ -1,88 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/history", tags=["历史记录"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/search",
|
||||
summary="搜索历史记录总览信息",
|
||||
response_model=HistorySearchOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def search_history(history: HistorySearchIn) -> HistorySearchOut:
|
||||
|
||||
try:
|
||||
data = await Config.search_history(
|
||||
history.mode,
|
||||
datetime.strptime(history.start_date, "%Y-%m-%d").date(),
|
||||
datetime.strptime(history.end_date, "%Y-%m-%d").date(),
|
||||
)
|
||||
for date, users in data.items():
|
||||
for user, records in users.items():
|
||||
record = await Config.merge_statistic_info(records)
|
||||
# 安全检查:确保 index 字段存在
|
||||
if "index" not in record:
|
||||
record["index"] = []
|
||||
record["index"] = [HistoryIndexItem(**_) for _ in record["index"]]
|
||||
record = HistoryData(**record)
|
||||
data[date][user] = record
|
||||
except Exception as e:
|
||||
return HistorySearchOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
data={},
|
||||
)
|
||||
return HistorySearchOut(data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/data",
|
||||
summary="从指定文件内获取历史记录数据",
|
||||
response_model=HistoryDataGetOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_history_data(history: HistoryDataGetIn = Body(...)) -> HistoryDataGetOut:
|
||||
|
||||
try:
|
||||
path = Path(history.jsonPath)
|
||||
data = await Config.merge_statistic_info([path])
|
||||
data.pop("index", None)
|
||||
data["log_content"] = path.with_suffix(".log").read_text(encoding="utf-8")
|
||||
data = HistoryData(**data)
|
||||
except Exception as e:
|
||||
return HistoryDataGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
data=HistoryData(**{}),
|
||||
)
|
||||
return HistoryDataGetOut(data=data)
|
||||
215
app/api/info.py
215
app/api/info.py
@@ -1,215 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/info", tags=["信息获取"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/version",
|
||||
summary="获取后端git版本信息",
|
||||
response_model=VersionOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_git_version() -> VersionOut:
|
||||
|
||||
try:
|
||||
is_latest, commit_hash, commit_time = await Config.get_git_version()
|
||||
except Exception as e:
|
||||
return VersionOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
if_need_update=False,
|
||||
current_hash="unknown",
|
||||
current_time="unknown",
|
||||
current_version=Config.version(),
|
||||
)
|
||||
return VersionOut(
|
||||
if_need_update=not is_latest,
|
||||
current_hash=commit_hash,
|
||||
current_time=commit_time,
|
||||
current_version=Config.version(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/combox/stage",
|
||||
summary="获取关卡号下拉框信息",
|
||||
response_model=ComboBoxOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_stage_combox(
|
||||
stage: GetStageIn = Body(..., description="关卡号类型")
|
||||
) -> ComboBoxOut:
|
||||
|
||||
try:
|
||||
raw_data = await Config.get_stage_info(stage.type)
|
||||
data = (
|
||||
[ComboBoxItem(**item) for item in raw_data if isinstance(item, dict)]
|
||||
if raw_data
|
||||
else []
|
||||
)
|
||||
except Exception as e:
|
||||
return ComboBoxOut(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
|
||||
)
|
||||
return ComboBoxOut(data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/combox/script",
|
||||
summary="获取脚本下拉框信息",
|
||||
response_model=ComboBoxOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_script_combox() -> ComboBoxOut:
|
||||
|
||||
try:
|
||||
raw_data = await Config.get_script_combox()
|
||||
data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
|
||||
except Exception as e:
|
||||
return ComboBoxOut(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
|
||||
)
|
||||
return ComboBoxOut(data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/combox/task",
|
||||
summary="获取可选任务下拉框信息",
|
||||
response_model=ComboBoxOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_task_combox() -> ComboBoxOut:
|
||||
|
||||
try:
|
||||
raw_data = await Config.get_task_combox()
|
||||
data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
|
||||
except Exception as e:
|
||||
return ComboBoxOut(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
|
||||
)
|
||||
return ComboBoxOut(data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/combox/plan",
|
||||
summary="获取可选计划下拉框信息",
|
||||
response_model=ComboBoxOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_plan_combox() -> ComboBoxOut:
|
||||
|
||||
try:
|
||||
raw_data = await Config.get_plan_combox()
|
||||
data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
|
||||
except Exception as e:
|
||||
return ComboBoxOut(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
|
||||
)
|
||||
return ComboBoxOut(data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notice/get", summary="获取通知信息", response_model=NoticeOut, status_code=200
|
||||
)
|
||||
async def get_notice_info() -> NoticeOut:
|
||||
|
||||
try:
|
||||
if_need_show, data = await Config.get_notice()
|
||||
except Exception as e:
|
||||
return NoticeOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
if_need_show=False,
|
||||
data={},
|
||||
)
|
||||
return NoticeOut(if_need_show=if_need_show, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notice/confirm", summary="确认通知", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def confirm_notice() -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.set("Data", "IfShowNotice", False)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
# @router.post(
|
||||
# "/apps_info", summary="获取可下载应用信息", response_model=InfoOut, status_code=200
|
||||
# )
|
||||
# async def get_apps_info() -> InfoOut:
|
||||
|
||||
# try:
|
||||
# data = await Config.get_server_info("apps_info")
|
||||
# except Exception as e:
|
||||
# return InfoOut(
|
||||
# code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data={}
|
||||
# )
|
||||
# return InfoOut(data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webconfig",
|
||||
summary="获取配置分享中心的配置信息",
|
||||
response_model=InfoOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_web_config() -> InfoOut:
|
||||
|
||||
try:
|
||||
data = await Config.get_web_config()
|
||||
except Exception as e:
|
||||
return InfoOut(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data={}
|
||||
)
|
||||
return InfoOut(data={"WebConfig": data})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/get/overview", summary="信息总览", response_model=InfoOut, status_code=200
|
||||
)
|
||||
async def get_overview() -> InfoOut:
|
||||
try:
|
||||
stage = await Config.get_stage_info("Info")
|
||||
proxy = await Config.get_proxy_overview()
|
||||
except Exception as e:
|
||||
return InfoOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
data={"Stage": [], "Proxy": []},
|
||||
)
|
||||
return InfoOut(data={"Stage": stage, "Proxy": proxy})
|
||||
106
app/api/plan.py
106
app/api/plan.py
@@ -1,106 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/plan", tags=["计划管理"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/add", summary="添加计划表", response_model=PlanCreateOut, status_code=200
|
||||
)
|
||||
async def add_plan(plan: PlanCreateIn = Body(...)) -> PlanCreateOut:
|
||||
|
||||
try:
|
||||
uid, config = await Config.add_plan(plan.type)
|
||||
data = MaaPlanConfig(**(await config.toDict()))
|
||||
except Exception as e:
|
||||
return PlanCreateOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
planId="",
|
||||
data=MaaPlanConfig(**{}),
|
||||
)
|
||||
return PlanCreateOut(planId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post("/get", summary="查询计划表", response_model=PlanGetOut, status_code=200)
|
||||
async def get_plan(plan: PlanGetIn = Body(...)) -> PlanGetOut:
|
||||
|
||||
try:
|
||||
index, data = await Config.get_plan(plan.planId)
|
||||
index = [PlanIndexItem(**_) for _ in index]
|
||||
data = {uid: MaaPlanConfig(**cfg) for uid, cfg in data.items()}
|
||||
except Exception as e:
|
||||
return PlanGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return PlanGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/update", summary="更新计划表配置信息", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_plan(plan: PlanUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_plan(plan.planId, plan.data.model_dump(exclude_unset=True))
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post("/delete", summary="删除计划表", response_model=OutBase, status_code=200)
|
||||
async def delete_plan(plan: PlanDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_plan(plan.planId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/order", summary="重新排序计划表", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def reorder_plan(plan: PlanReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_plan(plan.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
259
app/api/queue.py
259
app/api/queue.py
@@ -1,259 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/queue", tags=["调度队列管理"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/add", summary="添加调度队列", response_model=QueueCreateOut, status_code=200
|
||||
)
|
||||
async def add_queue() -> QueueCreateOut:
|
||||
|
||||
try:
|
||||
uid, config = await Config.add_queue()
|
||||
data = QueueConfig(**(await config.toDict()))
|
||||
except Exception as e:
|
||||
return QueueCreateOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
queueId="",
|
||||
data=QueueConfig(**{}),
|
||||
)
|
||||
return QueueCreateOut(queueId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/get", summary="查询调度队列配置信息", response_model=QueueGetOut, status_code=200
|
||||
)
|
||||
async def get_queues(queue: QueueGetIn = Body(...)) -> QueueGetOut:
|
||||
|
||||
try:
|
||||
index, config = await Config.get_queue(queue.queueId)
|
||||
index = [QueueIndexItem(**_) for _ in index]
|
||||
data = {uid: QueueConfig(**cfg) for uid, cfg in config.items()}
|
||||
except Exception as e:
|
||||
return QueueGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return QueueGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/update", summary="更新调度队列配置信息", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_queue(queue: QueueUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_queue(
|
||||
queue.queueId, queue.data.model_dump(exclude_unset=True)
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post("/delete", summary="删除调度队列", response_model=OutBase, status_code=200)
|
||||
async def delete_queue(queue: QueueDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_queue(queue.queueId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post("/order", summary="重新排序", response_model=OutBase, status_code=200)
|
||||
async def reorder_queue(script: QueueReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_queue(script.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/time/get", summary="查询定时项", response_model=TimeSetGetOut, status_code=200
|
||||
)
|
||||
async def get_time_set(time: TimeSetGetIn = Body(...)) -> TimeSetGetOut:
|
||||
|
||||
try:
|
||||
index, data = await Config.get_time_set(time.queueId, time.timeSetId)
|
||||
index = [TimeSetIndexItem(**_) for _ in index]
|
||||
data = {uid: TimeSet(**cfg) for uid, cfg in data.items()}
|
||||
except Exception as e:
|
||||
return TimeSetGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return TimeSetGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/time/add", summary="添加定时项", response_model=TimeSetCreateOut, status_code=200
|
||||
)
|
||||
async def add_time_set(time: QueueSetInBase = Body(...)) -> TimeSetCreateOut:
|
||||
|
||||
uid, config = await Config.add_time_set(time.queueId)
|
||||
data = TimeSet(**(await config.toDict()))
|
||||
return TimeSetCreateOut(timeSetId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/time/update", summary="更新定时项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_time_set(time: TimeSetUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_time_set(
|
||||
time.queueId, time.timeSetId, time.data.model_dump(exclude_unset=True)
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/time/delete", summary="删除定时项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def delete_time_set(time: TimeSetDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_time_set(time.queueId, time.timeSetId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/time/order", summary="重新排序定时项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def reorder_time_set(time: TimeSetReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_time_set(time.queueId, time.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/item/get", summary="查询队列项", response_model=QueueItemGetOut, status_code=200
|
||||
)
|
||||
async def get_item(item: QueueItemGetIn = Body(...)) -> QueueItemGetOut:
|
||||
|
||||
try:
|
||||
index, data = await Config.get_queue_item(item.queueId, item.queueItemId)
|
||||
index = [QueueItemIndexItem(**_) for _ in index]
|
||||
data = {uid: QueueItem(**cfg) for uid, cfg in data.items()}
|
||||
except Exception as e:
|
||||
return QueueItemGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return QueueItemGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/item/add",
|
||||
summary="添加队列项",
|
||||
response_model=QueueItemCreateOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def add_item(item: QueueSetInBase = Body(...)) -> QueueItemCreateOut:
|
||||
|
||||
uid, config = await Config.add_queue_item(item.queueId)
|
||||
data = QueueItem(**(await config.toDict()))
|
||||
return QueueItemCreateOut(queueItemId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/item/update", summary="更新队列项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_item(item: QueueItemUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_queue_item(
|
||||
item.queueId, item.queueItemId, item.data.model_dump(exclude_unset=True)
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/item/delete", summary="删除队列项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def delete_item(item: QueueItemDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_queue_item(item.queueId, item.queueItemId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/item/order", summary="重新排序队列项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def reorder_item(item: QueueItemReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_queue_item(item.queueId, item.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
@@ -1,283 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import uuid
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/scripts", tags=["脚本管理"])
|
||||
|
||||
|
||||
SCRIPT_BOOK = {"MaaConfig": MaaConfig, "GeneralConfig": GeneralConfig}
|
||||
USER_BOOK = {"MaaConfig": MaaUserConfig, "GeneralConfig": GeneralUserConfig}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/add", summary="添加脚本", response_model=ScriptCreateOut, status_code=200
|
||||
)
|
||||
async def add_script(script: ScriptCreateIn = Body(...)) -> ScriptCreateOut:
|
||||
|
||||
try:
|
||||
uid, config = await Config.add_script(script.type)
|
||||
data = SCRIPT_BOOK[type(config).__name__](**(await config.toDict()))
|
||||
except Exception as e:
|
||||
return ScriptCreateOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
scriptId="",
|
||||
data=GeneralConfig(**{}),
|
||||
)
|
||||
return ScriptCreateOut(scriptId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/get", summary="查询脚本配置信息", response_model=ScriptGetOut, status_code=200
|
||||
)
|
||||
async def get_script(script: ScriptGetIn = Body(...)) -> ScriptGetOut:
|
||||
|
||||
try:
|
||||
index, data = await Config.get_script(script.scriptId)
|
||||
index = [ScriptIndexItem(**_) for _ in index]
|
||||
data = {
|
||||
uid: SCRIPT_BOOK[next((_.type for _ in index if _.uid == uid), "General")](
|
||||
**cfg
|
||||
)
|
||||
for uid, cfg in data.items()
|
||||
}
|
||||
except Exception as e:
|
||||
return ScriptGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return ScriptGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/update", summary="更新脚本配置信息", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_script(script: ScriptUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_script(
|
||||
script.scriptId, script.data.model_dump(exclude_unset=True)
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post("/delete", summary="删除脚本", response_model=OutBase, status_code=200)
|
||||
async def delete_script(script: ScriptDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_script(script.scriptId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post("/order", summary="重新排序脚本", response_model=OutBase, status_code=200)
|
||||
async def reorder_script(script: ScriptReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_script(script.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/import/file", summary="从文件加载脚本", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def import_script_from_file(script: ScriptFileIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.import_script_from_file(script.scriptId, script.jsonFile)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/export/file", summary="导出脚本到文件", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def export_script_to_file(script: ScriptFileIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.export_script_to_file(script.scriptId, script.jsonFile)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/import/web", summary="从网络加载脚本", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def import_script_from_web(script: ScriptUrlIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.import_script_from_web(script.scriptId, script.url)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/Upload/web", summary="上传脚本配置到网络", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def upload_script_to_web(script: ScriptUploadIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.upload_script_to_web(
|
||||
script.scriptId, script.config_name, script.author, script.description
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/get", summary="查询用户", response_model=UserGetOut, status_code=200
|
||||
)
|
||||
async def get_user(user: UserGetIn = Body(...)) -> UserGetOut:
|
||||
|
||||
try:
|
||||
index, data = await Config.get_user(user.scriptId, user.userId)
|
||||
index = [UserIndexItem(**_) for _ in index]
|
||||
data = {
|
||||
uid: USER_BOOK[
|
||||
type(Config.ScriptConfig[uuid.UUID(user.scriptId)]).__name__
|
||||
](**cfg)
|
||||
for uid, cfg in data.items()
|
||||
}
|
||||
except Exception as e:
|
||||
return UserGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return UserGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/add", summary="添加用户", response_model=UserCreateOut, status_code=200
|
||||
)
|
||||
async def add_user(user: UserInBase = Body(...)) -> UserCreateOut:
|
||||
|
||||
try:
|
||||
uid, config = await Config.add_user(user.scriptId)
|
||||
data = USER_BOOK[type(Config.ScriptConfig[uuid.UUID(user.scriptId)]).__name__](
|
||||
**(await config.toDict())
|
||||
)
|
||||
except Exception as e:
|
||||
return UserCreateOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
userId="",
|
||||
data=GeneralUserConfig(**{}),
|
||||
)
|
||||
return UserCreateOut(userId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/update", summary="更新用户配置信息", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_user(user: UserUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_user(
|
||||
user.scriptId, user.userId, user.data.model_dump(exclude_unset=True)
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/delete", summary="删除用户", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def delete_user(user: UserDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_user(user.scriptId, user.userId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/order", summary="重新排序用户", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def reorder_user(user: UserReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_user(user.scriptId, user.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/infrastructure",
|
||||
summary="导入基建配置文件",
|
||||
response_model=OutBase,
|
||||
status_code=200,
|
||||
)
|
||||
async def import_infrastructure(user: UserSetIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.set_infrastructure(user.scriptId, user.userId, user.jsonFile)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
@@ -1,198 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.services import System, Notify
|
||||
from app.models.schema import *
|
||||
from app.models.config import Webhook as WebhookConfig
|
||||
|
||||
router = APIRouter(prefix="/api/setting", tags=["全局设置"])
|
||||
|
||||
|
||||
@router.post("/get", summary="查询配置", response_model=SettingGetOut, status_code=200)
|
||||
async def get_scripts() -> SettingGetOut:
|
||||
"""查询配置"""
|
||||
|
||||
try:
|
||||
data = await Config.get_setting()
|
||||
except Exception as e:
|
||||
return SettingGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
data=GlobalConfig(**{}),
|
||||
)
|
||||
return SettingGetOut(data=GlobalConfig(**data))
|
||||
|
||||
|
||||
@router.post("/update", summary="更新配置", response_model=OutBase, status_code=200)
|
||||
async def update_script(script: SettingUpdateIn = Body(...)) -> OutBase:
|
||||
"""更新配置"""
|
||||
|
||||
try:
|
||||
data = script.data.model_dump(exclude_unset=True)
|
||||
await Config.update_setting(data)
|
||||
|
||||
if data.get("Start", {}).get("IfSelfStart", None) is not None:
|
||||
await System.set_SelfStart()
|
||||
if data.get("Function", None) is not None:
|
||||
function = data["Function"]
|
||||
if function.get("IfAllowSleep", None) is not None:
|
||||
await System.set_Sleep()
|
||||
if function.get("IfSkipMumuSplashAds", None) is not None:
|
||||
MuMu_splash_ads_path = (
|
||||
Path(os.getenv("APPDATA") or "")
|
||||
/ "Netease/MuMuPlayer-12.0/data/startupImage"
|
||||
)
|
||||
if Config.get("Function", "IfSkipMumuSplashAds"):
|
||||
if MuMu_splash_ads_path.exists() and MuMu_splash_ads_path.is_dir():
|
||||
shutil.rmtree(MuMu_splash_ads_path)
|
||||
MuMu_splash_ads_path.touch()
|
||||
else:
|
||||
if MuMu_splash_ads_path.exists() and MuMu_splash_ads_path.is_file():
|
||||
MuMu_splash_ads_path.unlink()
|
||||
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/test_notify", summary="测试通知", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def test_notify() -> OutBase:
|
||||
"""测试通知"""
|
||||
|
||||
try:
|
||||
await Notify.send_test_notification()
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhook/get",
|
||||
summary="查询 webhook 配置",
|
||||
response_model=WebhookGetOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def get_webhook(webhook: WebhookGetIn = Body(...)) -> WebhookGetOut:
|
||||
|
||||
try:
|
||||
index, data = await Config.get_webhook(None, None, webhook.webhookId)
|
||||
index = [WebhookIndexItem(**_) for _ in index]
|
||||
data = {uid: Webhook(**cfg) for uid, cfg in data.items()}
|
||||
except Exception as e:
|
||||
return WebhookGetOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
index=[],
|
||||
data={},
|
||||
)
|
||||
return WebhookGetOut(index=index, data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhook/add",
|
||||
summary="添加定时项",
|
||||
response_model=WebhookCreateOut,
|
||||
status_code=200,
|
||||
)
|
||||
async def add_webhook() -> WebhookCreateOut:
|
||||
|
||||
uid, config = await Config.add_webhook(None, None)
|
||||
data = Webhook(**(await config.toDict()))
|
||||
return WebhookCreateOut(webhookId=str(uid), data=data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhook/update", summary="更新定时项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def update_webhook(webhook: WebhookUpdateIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.update_webhook(
|
||||
None, None, webhook.webhookId, webhook.data.model_dump(exclude_unset=True)
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhook/delete", summary="删除定时项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def delete_webhook(webhook: WebhookDeleteIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.del_webhook(None, None, webhook.webhookId)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhook/order", summary="重新排序定时项", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def reorder_webhook(webhook: WebhookReorderIn = Body(...)) -> OutBase:
|
||||
|
||||
try:
|
||||
await Config.reorder_webhook(None, None, webhook.indexList)
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhook/test", summary="测试Webhook配置", response_model=OutBase, status_code=200
|
||||
)
|
||||
async def test_webhook(webhook: WebhookTestIn = Body(...)) -> OutBase:
|
||||
"""测试自定义Webhook"""
|
||||
|
||||
try:
|
||||
webhook_config = WebhookConfig()
|
||||
await webhook_config.load(webhook.data.model_dump())
|
||||
await Notify.WebhookPush(
|
||||
"AUTO-MAS Webhook测试",
|
||||
"这是一条测试消息,如果您收到此消息,说明Webhook配置正确!",
|
||||
webhook_config,
|
||||
)
|
||||
except Exception as e:
|
||||
return OutBase(code=500, status="error", message=f"Webhook测试失败: {str(e)}")
|
||||
return OutBase()
|
||||
@@ -1,82 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import asyncio
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from app.core import Config
|
||||
from app.services import Updater
|
||||
from app.models.schema import *
|
||||
|
||||
router = APIRouter(prefix="/api/update", tags=["软件更新"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/check", summary="检查更新", response_model=UpdateCheckOut, status_code=200
|
||||
)
|
||||
async def check_update(version: UpdateCheckIn = Body(...)) -> UpdateCheckOut:
|
||||
|
||||
try:
|
||||
if_need, latest_version, update_info = await Updater.check_update(
|
||||
current_version=version.current_version, if_force=version.if_force
|
||||
)
|
||||
except Exception as e:
|
||||
return UpdateCheckOut(
|
||||
code=500,
|
||||
status="error",
|
||||
message=f"{type(e).__name__}: {str(e)}",
|
||||
if_need_update=False,
|
||||
latest_version="",
|
||||
update_info={},
|
||||
)
|
||||
return UpdateCheckOut(
|
||||
if_need_update=if_need, latest_version=latest_version, update_info=update_info
|
||||
)
|
||||
|
||||
|
||||
@router.post("/download", summary="下载更新", response_model=OutBase, status_code=200)
|
||||
async def download_update() -> OutBase:
|
||||
|
||||
try:
|
||||
task = asyncio.create_task(Updater.download_update())
|
||||
Config.temp_task.append(task)
|
||||
task.add_done_callback(lambda t: Config.temp_task.remove(t))
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
|
||||
|
||||
@router.post("/install", summary="安装更新", response_model=OutBase, status_code=200)
|
||||
async def install_update() -> OutBase:
|
||||
|
||||
try:
|
||||
task = asyncio.create_task(Updater.install_update())
|
||||
Config.temp_task.append(task)
|
||||
task.add_done_callback(lambda t: Config.temp_task.remove(t))
|
||||
except Exception as e:
|
||||
return OutBase(
|
||||
code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
|
||||
)
|
||||
return OutBase()
|
||||
@@ -1,41 +1,63 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# 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-MAS is distributed in the hope that it will be useful,
|
||||
# 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
__version__ = "5.0.0"
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA核心组件包
|
||||
v4.4
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
__version__ = "4.2.0"
|
||||
__author__ = "DLmaster361 <DLmaster_361@163.com>"
|
||||
__license__ = "GPL-3.0 license"
|
||||
|
||||
from .broadcast import Broadcast
|
||||
from .config import Config, MaaConfig, GeneralConfig, MaaUserConfig, GeneralUserConfig
|
||||
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
|
||||
from .task_manager import TaskManager
|
||||
|
||||
__all__ = [
|
||||
"Broadcast",
|
||||
"Config",
|
||||
"QueueConfig",
|
||||
"MaaConfig",
|
||||
"GeneralConfig",
|
||||
"MainTimer",
|
||||
"TaskManager",
|
||||
"MaaUserConfig",
|
||||
"GeneralUserConfig",
|
||||
"MaaPlanConfig",
|
||||
"GeneralConfig",
|
||||
"GeneralSubConfig",
|
||||
"logger",
|
||||
"MainInfoBar",
|
||||
"Network",
|
||||
"SoundPlayer",
|
||||
"Task",
|
||||
"TaskManager",
|
||||
"MainTimer",
|
||||
]
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from typing import Set
|
||||
|
||||
from app.utils import get_logger
|
||||
|
||||
|
||||
logger = get_logger("消息广播")
|
||||
|
||||
|
||||
class _Broadcast:
|
||||
|
||||
def __init__(self):
|
||||
self.__subscribers: Set[asyncio.Queue] = set()
|
||||
|
||||
async def subscribe(self, queue: asyncio.Queue):
|
||||
"""订阅者注册"""
|
||||
self.__subscribers.add(queue)
|
||||
|
||||
async def unsubscribe(self, queue: asyncio.Queue):
|
||||
"""取消订阅"""
|
||||
self.__subscribers.remove(queue)
|
||||
|
||||
async def put(self, item):
|
||||
"""向所有订阅者广播消息"""
|
||||
for subscriber in self.__subscribers:
|
||||
await subscriber.put(deepcopy(item))
|
||||
|
||||
|
||||
Broadcast = _Broadcast()
|
||||
2989
app/core/config.py
2989
app/core/config.py
File diff suppressed because it is too large
Load Diff
34
app/core/logger.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
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
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
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()
|
||||
@@ -1,384 +1,460 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# 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-MAS is distributed in the hope that it will be useful,
|
||||
# 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
# 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 uuid
|
||||
import asyncio
|
||||
from functools import partial
|
||||
from typing import Dict, Optional, Literal
|
||||
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 .config import Config, MaaConfig, GeneralConfig, QueueConfig
|
||||
from app.services import System
|
||||
from app.models.schema import WebSocketMessage
|
||||
from app.utils import get_logger
|
||||
from app.task import *
|
||||
from app.utils.constants import POWER_SIGN_MAP
|
||||
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
|
||||
|
||||
|
||||
logger = get_logger("业务调度")
|
||||
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:
|
||||
class _TaskManager(QObject):
|
||||
"""业务调度器"""
|
||||
|
||||
create_gui = Signal(Task)
|
||||
connect_gui = Signal(Task)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
super(_TaskManager, self).__init__()
|
||||
|
||||
self.task_dict: Dict[uuid.UUID, asyncio.Task] = {}
|
||||
self.task_dict: Dict[str, Task] = {}
|
||||
|
||||
async def add_task(
|
||||
self, mode: Literal["自动代理", "人工排查", "设置脚本"], uid: str
|
||||
) -> uuid.UUID:
|
||||
def add_task(
|
||||
self, mode: str, name: str, info: Dict[str, Dict[str, Union[str, int, bool]]]
|
||||
):
|
||||
"""
|
||||
添加任务
|
||||
|
||||
:param mode: 任务模式
|
||||
:param uid: 任务UID
|
||||
:param name: 任务名称
|
||||
:param info: 任务信息
|
||||
"""
|
||||
|
||||
actual_id = uuid.UUID(uid)
|
||||
if name in Config.running_list or name in self.task_dict:
|
||||
|
||||
if mode == "设置脚本":
|
||||
if actual_id in Config.ScriptConfig:
|
||||
task_id = actual_id
|
||||
actual_id = None
|
||||
else:
|
||||
for script_id, script in Config.ScriptConfig.items():
|
||||
if actual_id in script.UserData:
|
||||
task_id = script_id
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"任务 {uid} 无法找到对应脚本配置")
|
||||
elif actual_id in Config.QueueConfig:
|
||||
task_id = actual_id
|
||||
actual_id = None
|
||||
elif actual_id in Config.ScriptConfig:
|
||||
task_id = uuid.uuid4()
|
||||
else:
|
||||
raise ValueError(f"任务 {uid} 无法找到对应脚本配置")
|
||||
logger.warning(f"任务已存在:{name}")
|
||||
MainInfoBar.push_info_bar("warning", "任务已存在", name, 5000)
|
||||
return None
|
||||
|
||||
if task_id in self.task_dict or (
|
||||
actual_id is not None and actual_id in self.task_dict
|
||||
):
|
||||
raise RuntimeError(f"任务 {task_id} 已在运行")
|
||||
logger.info(f"任务开始:{name},模式:{mode}", module="业务调度")
|
||||
MainInfoBar.push_info_bar("info", "任务开始", name, 3000)
|
||||
SoundPlayer.play("任务开始")
|
||||
|
||||
logger.info(f"创建任务: {task_id}, 模式: {mode}")
|
||||
self.task_dict[task_id] = asyncio.create_task(
|
||||
self.run_task(mode, task_id, actual_id)
|
||||
# 标记任务为运行中
|
||||
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[task_id].add_done_callback(
|
||||
lambda t: asyncio.create_task(self.remove_task(t, mode, task_id))
|
||||
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)
|
||||
)
|
||||
|
||||
return task_id
|
||||
# 向UI发送信号以创建或连接GUI
|
||||
if "新调度台" in mode:
|
||||
self.create_gui.emit(self.task_dict[name])
|
||||
|
||||
@logger.catch
|
||||
async def run_task(
|
||||
self, mode: str, task_id: uuid.UUID, actual_id: Optional[uuid.UUID]
|
||||
):
|
||||
elif "主调度台" in mode:
|
||||
self.connect_gui.emit(self.task_dict[name])
|
||||
|
||||
logger.info(f"开始运行任务: {task_id}, 模式: {mode}")
|
||||
# 启动任务线程
|
||||
self.task_dict[name].start()
|
||||
|
||||
if mode == "设置脚本":
|
||||
|
||||
if isinstance(Config.ScriptConfig[task_id], MaaConfig):
|
||||
task_item = MaaManager(mode, task_id, actual_id, str(task_id))
|
||||
elif isinstance(Config.ScriptConfig[task_id], GeneralConfig):
|
||||
task_item = GeneralManager(mode, task_id, actual_id, str(task_id))
|
||||
else:
|
||||
logger.error(
|
||||
f"不支持的脚本类型: {type(Config.ScriptConfig[task_id]).__name__}"
|
||||
)
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Info",
|
||||
data={"Error": "脚本类型不支持"},
|
||||
).model_dump()
|
||||
)
|
||||
return
|
||||
|
||||
uid = actual_id or uuid.uuid4()
|
||||
self.task_dict[uid] = asyncio.create_task(task_item.run())
|
||||
self.task_dict[uid].add_done_callback(
|
||||
lambda t: asyncio.create_task(task_item.final_task(t))
|
||||
)
|
||||
self.task_dict[uid].add_done_callback(partial(self.task_dict.pop, uid))
|
||||
try:
|
||||
await self.task_dict[uid]
|
||||
except Exception as e:
|
||||
logger.error(f"任务 {task_id} 运行出错: {type(e).__name__}: {str(e)}")
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Info",
|
||||
data={"Error": f"任务运行时出错 {type(e).__name__}: {str(e)}"},
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
# 初始化任务列表
|
||||
if task_id in Config.QueueConfig:
|
||||
|
||||
task_list = []
|
||||
for queue_item in Config.QueueConfig[task_id].QueueItem.values():
|
||||
if queue_item.get("Info", "ScriptId") == "-":
|
||||
continue
|
||||
script_uid = uuid.UUID(queue_item.get("Info", "ScriptId"))
|
||||
|
||||
task_list.append(
|
||||
{
|
||||
"script_id": str(script_uid),
|
||||
"status": "等待",
|
||||
"name": Config.ScriptConfig[script_uid].get("Info", "Name"),
|
||||
"user_list": [
|
||||
{
|
||||
"user_id": str(user_id),
|
||||
"status": "等待",
|
||||
"name": config.get("Info", "Name"),
|
||||
}
|
||||
for user_id, config in Config.ScriptConfig[
|
||||
script_uid
|
||||
].UserData.items()
|
||||
if config.get("Info", "Status")
|
||||
and config.get("Info", "RemainedDay") != 0
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
elif actual_id is not None and actual_id in Config.ScriptConfig:
|
||||
|
||||
task_list = [
|
||||
{
|
||||
"script_id": str(actual_id),
|
||||
"status": "等待",
|
||||
"name": Config.ScriptConfig[actual_id].get("Info", "Name"),
|
||||
"user_list": [
|
||||
{
|
||||
"user_id": str(user_id),
|
||||
"status": "等待",
|
||||
"name": config.get("Info", "Name"),
|
||||
}
|
||||
for user_id, config in Config.ScriptConfig[
|
||||
actual_id
|
||||
].UserData.items()
|
||||
if config.get("Info", "Status")
|
||||
and config.get("Info", "RemainedDay") != 0
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id), type="Update", data={"task_dict": task_list}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
# 清理用户列表初值
|
||||
for task in task_list:
|
||||
task.pop("user_list", None)
|
||||
|
||||
for task in task_list:
|
||||
|
||||
script_id = uuid.UUID(task["script_id"])
|
||||
|
||||
# 检查任务是否在运行列表中
|
||||
if script_id in self.task_dict:
|
||||
|
||||
task["status"] = "跳过"
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Update",
|
||||
data={"task_list": task_list},
|
||||
).model_dump()
|
||||
)
|
||||
logger.info(f"跳过任务: {script_id}, 该任务已在运行列表中")
|
||||
continue
|
||||
|
||||
# 检查任务对应脚本是否仍存在
|
||||
if script_id in self.task_dict:
|
||||
|
||||
task["status"] = "异常"
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Update",
|
||||
data={"task_list": task_list},
|
||||
).model_dump()
|
||||
)
|
||||
logger.info(f"跳过任务: {script_id}, 该任务对应脚本已被删除")
|
||||
continue
|
||||
|
||||
# 标记为运行中
|
||||
task["status"] = "运行"
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id), type="Update", data={"task_list": task_list}
|
||||
).model_dump()
|
||||
)
|
||||
logger.info(f"任务开始: {script_id}")
|
||||
|
||||
if isinstance(Config.ScriptConfig[script_id], MaaConfig):
|
||||
task_item = MaaManager(mode, script_id, None, str(task_id))
|
||||
elif isinstance(Config.ScriptConfig[script_id], GeneralConfig):
|
||||
task_item = GeneralManager(mode, script_id, actual_id, str(task_id))
|
||||
else:
|
||||
logger.error(
|
||||
f"不支持的脚本类型: {type(Config.ScriptConfig[script_id]).__name__}"
|
||||
)
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Info",
|
||||
data={"Error": "脚本类型不支持"},
|
||||
).model_dump()
|
||||
)
|
||||
continue
|
||||
|
||||
self.task_dict[script_id] = asyncio.create_task(task_item.run())
|
||||
self.task_dict[script_id].add_done_callback(
|
||||
lambda t: asyncio.create_task(task_item.final_task(t))
|
||||
)
|
||||
self.task_dict[script_id].add_done_callback(
|
||||
partial(self.task_dict.pop, script_id)
|
||||
)
|
||||
try:
|
||||
await self.task_dict[script_id]
|
||||
task["status"] = "完成"
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"任务 {script_id} 运行出错: {type(e).__name__}: {str(e)}"
|
||||
)
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Info",
|
||||
data={
|
||||
"Error": f"任务运行时出错 {type(e).__name__}: {str(e)}"
|
||||
},
|
||||
).model_dump()
|
||||
)
|
||||
task["status"] = "异常"
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id),
|
||||
type="Update",
|
||||
data={"task_list": task_list},
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
async def stop_task(self, task_id: str) -> None:
|
||||
def stop_task(self, name: str) -> None:
|
||||
"""
|
||||
中止任务
|
||||
|
||||
:param task_id: 任务ID
|
||||
:param name: 任务名称
|
||||
"""
|
||||
|
||||
logger.info(f"中止任务: {task_id}")
|
||||
logger.info(f"中止任务:{name}", module="业务调度")
|
||||
MainInfoBar.push_info_bar("info", "中止任务", name, 3000)
|
||||
|
||||
if task_id == "ALL":
|
||||
for task in self.task_dict.values():
|
||||
task.cancel()
|
||||
else:
|
||||
uid = uuid.UUID(task_id)
|
||||
if uid not in self.task_dict:
|
||||
raise ValueError("任务未在运行")
|
||||
self.task_dict[uid].cancel()
|
||||
if name == "ALL":
|
||||
|
||||
async def remove_task(
|
||||
self, task: asyncio.Task, mode: str, task_id: uuid.UUID
|
||||
) -> None:
|
||||
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:
|
||||
"""
|
||||
处理任务结束后的收尾工作
|
||||
|
||||
Parameters
|
||||
----------
|
||||
task : asyncio.Task
|
||||
任务对象
|
||||
mode : str
|
||||
任务模式
|
||||
task_id : uuid.UUID
|
||||
任务ID
|
||||
:param mode: 任务模式
|
||||
:param name: 任务名称
|
||||
:param logs: 任务日志
|
||||
"""
|
||||
|
||||
logger.info(f"任务结束: {task_id}")
|
||||
logger.info(f"任务结束:{name}", module="业务调度")
|
||||
MainInfoBar.push_info_bar("info", "任务结束", name, 3000)
|
||||
SoundPlayer.play("任务结束")
|
||||
|
||||
# 从任务字典中移除任务
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"任务 {task_id} 已结束")
|
||||
self.task_dict.pop(task_id)
|
||||
# 删除任务线程,移除运行中标记
|
||||
self.task_dict[name].deleteLater()
|
||||
self.task_dict.pop(name)
|
||||
Config.running_list.remove(name)
|
||||
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id=str(task_id), type="Signal", data={"Accomplish": "无描述"}
|
||||
).model_dump()
|
||||
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="业务调度",
|
||||
)
|
||||
|
||||
if mode == "自动代理" and task_id in Config.QueueConfig:
|
||||
def push_dialog(self, name: str, title: str, content: str):
|
||||
"""
|
||||
推送来自任务线程的对话框
|
||||
|
||||
if Config.power_sign == "NoAction":
|
||||
Config.power_sign = Config.QueueConfig[task_id].get(
|
||||
"Info", "AfterAccomplish"
|
||||
)
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Main", type="Update", data={"PowerSign": Config.power_sign}
|
||||
).model_dump()
|
||||
)
|
||||
:param name: 任务名称
|
||||
:param title: 对话框标题
|
||||
:param content: 对话框内容
|
||||
"""
|
||||
|
||||
if len(self.task_dict) == 0 and Config.power_sign != "NoAction":
|
||||
logger.info(f"所有任务已结束,准备执行电源操作: {Config.power_sign}")
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Main",
|
||||
type="Message",
|
||||
data={
|
||||
"type": "Countdown",
|
||||
"title": f"{POWER_SIGN_MAP[Config.power_sign]}倒计时",
|
||||
"message": f"程序将在倒计时结束后执行 {POWER_SIGN_MAP[Config.power_sign]} 操作",
|
||||
},
|
||||
).model_dump()
|
||||
)
|
||||
await System.start_power_task()
|
||||
choice = MessageBox(title, content, Config.main_window)
|
||||
choice.yesButton.setText("是")
|
||||
choice.cancelButton.setText("否")
|
||||
|
||||
async def start_startup_queue(self):
|
||||
"""开始运行启动时运行的调度队列"""
|
||||
|
||||
logger.info("开始运行启动时任务")
|
||||
for uid, queue in Config.QueueConfig.items():
|
||||
|
||||
if queue.get("Info", "StartUpEnabled") and uid not in self.task_dict:
|
||||
logger.info(f"启动时需要运行的队列:{uid}")
|
||||
task_id = await TaskManager.add_task("自动代理", str(uid))
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="TaskManager", type="Signal", data={"newTask": str(task_id)}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
logger.success("启动时任务开始运行")
|
||||
self.task_dict[name].question_response.emit(bool(choice.exec()))
|
||||
|
||||
|
||||
TaskManager = _TaskManager()
|
||||
|
||||
@@ -1,123 +1,116 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# 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-MAS is distributed in the hope that it will be useful,
|
||||
# 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
import asyncio
|
||||
import keyboard
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA主业务定时器
|
||||
v4.4
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
from PySide6.QtCore import QObject, QTimer
|
||||
from datetime import datetime
|
||||
import keyboard
|
||||
|
||||
from app.services import Matomo, System
|
||||
from app.utils import get_logger
|
||||
from app.models.schema import WebSocketMessage
|
||||
from .config import Config, QueueConfig
|
||||
from .logger import logger
|
||||
from .config import Config
|
||||
from .task_manager import TaskManager
|
||||
from app.services import System
|
||||
|
||||
|
||||
logger = get_logger("主业务定时器")
|
||||
class _MainTimer(QObject):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
class _MainTimer:
|
||||
self.Timer = QTimer()
|
||||
self.Timer.timeout.connect(self.timed_start)
|
||||
self.Timer.timeout.connect(self.set_silence)
|
||||
self.Timer.timeout.connect(self.check_power)
|
||||
|
||||
async def second_task(self):
|
||||
"""每秒定期任务"""
|
||||
logger.info("每秒定期任务启动")
|
||||
self.LongTimer = QTimer()
|
||||
self.LongTimer.timeout.connect(self.long_timed_task)
|
||||
|
||||
while True:
|
||||
def start(self):
|
||||
"""启动定时器"""
|
||||
|
||||
await self.set_silence()
|
||||
await self.timed_start()
|
||||
logger.info("启动主定时器", module="主业务定时器")
|
||||
self.Timer.start(1000)
|
||||
self.LongTimer.start(3600000)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
def stop(self):
|
||||
"""停止定时器"""
|
||||
|
||||
async def hour_task(self):
|
||||
"""每小时定期任务"""
|
||||
logger.info("停止主定时器", module="主业务定时器")
|
||||
self.Timer.stop()
|
||||
self.Timer.deleteLater()
|
||||
self.LongTimer.stop()
|
||||
self.LongTimer.deleteLater()
|
||||
|
||||
logger.info("每小时定期任务启动")
|
||||
def long_timed_task(self):
|
||||
"""长时间定期检定任务"""
|
||||
|
||||
while True:
|
||||
logger.info("执行长时间定期检定任务", module="主业务定时器")
|
||||
|
||||
if (
|
||||
datetime.strptime(
|
||||
Config.get("Data", "LastStatisticsUpload"), "%Y-%m-%d %H:%M:%S"
|
||||
).date()
|
||||
!= datetime.now().date()
|
||||
):
|
||||
await Matomo.send_event(
|
||||
"App",
|
||||
"Version",
|
||||
Config.version(),
|
||||
1 if "beta" in Config.version() else 0,
|
||||
)
|
||||
await Config.set(
|
||||
"Data",
|
||||
"LastStatisticsUpload",
|
||||
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
Config.get_stage()
|
||||
Config.main_window.setting.show_notice()
|
||||
if Config.get(Config.update_IfAutoUpdate):
|
||||
Config.main_window.setting.check_update()
|
||||
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
@logger.catch()
|
||||
async def timed_start(self):
|
||||
def timed_start(self):
|
||||
"""定时启动代理任务"""
|
||||
|
||||
curtime = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
for name, info in Config.queue_dict.items():
|
||||
|
||||
for uid, queue in Config.QueueConfig.items():
|
||||
|
||||
if not queue.get("Info", "TimeEnabled"):
|
||||
if not info["Config"].get(info["Config"].QueueSet_TimeEnabled):
|
||||
continue
|
||||
|
||||
# 避免重复调起任务
|
||||
if curtime == queue.get("Data", "LastTimedStart"):
|
||||
continue
|
||||
data = info["Config"].toDict()
|
||||
|
||||
for time_set in queue.TimeSet.values():
|
||||
if (
|
||||
time_set.get("Info", "Enabled")
|
||||
and curtime[11:16] == time_set.get("Info", "Time")
|
||||
and uid not in Config.task_dict
|
||||
):
|
||||
logger.info(f"定时唤起任务:{uid}")
|
||||
task_id = await TaskManager.add_task("自动代理", str(uid))
|
||||
await queue.set("Data", "LastTimedStart", curtime)
|
||||
await Config.QueueConfig.save()
|
||||
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
|
||||
):
|
||||
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="TaskManager",
|
||||
type="Signal",
|
||||
data={"newTask": str(task_id)},
|
||||
).model_dump()
|
||||
)
|
||||
logger.info(f"定时唤起任务:{name}。", module="主业务定时器")
|
||||
TaskManager.add_task("自动代理_新调度台", name, data)
|
||||
|
||||
@logger.catch()
|
||||
async def set_silence(self):
|
||||
"""静默模式通过模拟老板键来隐藏模拟器窗口"""
|
||||
def set_silence(self):
|
||||
"""设置静默模式"""
|
||||
|
||||
if (
|
||||
len(Config.if_ignore_silence) == 0
|
||||
and Config.get("Function", "IfSilence")
|
||||
and Config.get("Function", "BossKey") != ""
|
||||
not Config.if_ignore_silence
|
||||
and Config.get(Config.function_IfSilence)
|
||||
and Config.get(Config.function_BossKey) != ""
|
||||
):
|
||||
|
||||
windows = await System.get_window_info()
|
||||
windows = System.get_window_info()
|
||||
|
||||
emulator_windows = []
|
||||
for window in windows:
|
||||
@@ -131,17 +124,52 @@ class _MainTimer:
|
||||
|
||||
if emulator_windows:
|
||||
|
||||
logger.info(f"检测到模拟器窗口: {emulator_windows}")
|
||||
logger.info(
|
||||
f"检测到模拟器窗口:{emulator_windows}", module="主业务定时器"
|
||||
)
|
||||
try:
|
||||
keyboard.press_and_release(
|
||||
"+".join(
|
||||
_.strip().lower()
|
||||
for _ in Config.get("Function", "BossKey").split("+")
|
||||
for _ in Config.get(Config.function_BossKey).split("+")
|
||||
)
|
||||
)
|
||||
logger.info(f"模拟按键: {Config.get('Function', 'BossKey')}")
|
||||
logger.info(
|
||||
f"模拟按键:{Config.get(Config.function_BossKey)}",
|
||||
module="主业务定时器",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"模拟按键时出错: {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()
|
||||
|
||||
@@ -1,934 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import win32com.client
|
||||
from copy import deepcopy
|
||||
from urllib.parse import urlparse
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Any, Dict, Union, Optional, TypeVar, Generic, Type
|
||||
|
||||
|
||||
from app.utils import dpapi_encrypt, dpapi_decrypt
|
||||
from app.utils.constants import RESERVED_NAMES, ILLEGAL_CHARS, DEFAULT_DATETIME
|
||||
|
||||
|
||||
class ConfigValidator:
|
||||
"""基础配置验证器"""
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
"""验证值是否合法"""
|
||||
return True
|
||||
|
||||
def correct(self, value: Any) -> Any:
|
||||
"""修正非法值"""
|
||||
return value
|
||||
|
||||
|
||||
class RangeValidator(ConfigValidator):
|
||||
"""范围验证器"""
|
||||
|
||||
def __init__(self, min: int | float, max: int | float):
|
||||
self.min = min
|
||||
self.max = max
|
||||
self.range = (min, max)
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, (int | float)):
|
||||
return False
|
||||
return self.min <= value <= self.max
|
||||
|
||||
def correct(self, value: Any) -> int | float:
|
||||
if not isinstance(value, (int, float)):
|
||||
try:
|
||||
value = float(value)
|
||||
except TypeError:
|
||||
return self.min
|
||||
return min(max(self.min, value), self.max)
|
||||
|
||||
|
||||
class OptionsValidator(ConfigValidator):
|
||||
"""选项验证器"""
|
||||
|
||||
def __init__(self, options: list):
|
||||
if not options:
|
||||
raise ValueError("可选项不能为空")
|
||||
|
||||
self.options = options
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
return value in self.options
|
||||
|
||||
def correct(self, value: Any) -> Any:
|
||||
return value if self.validate(value) else self.options[0]
|
||||
|
||||
|
||||
class UUIDValidator(ConfigValidator):
|
||||
"""UUID验证器"""
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
def correct(self, value: Any) -> Any:
|
||||
return value if self.validate(value) else str(uuid.uuid4())
|
||||
|
||||
|
||||
class DateTimeValidator(ConfigValidator):
|
||||
"""日期时间验证器"""
|
||||
|
||||
def __init__(self, date_format: str) -> None:
|
||||
if not date_format:
|
||||
raise ValueError("日期时间格式不能为空")
|
||||
self.date_format = date_format
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
datetime.strptime(value, self.date_format)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def correct(self, value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return DEFAULT_DATETIME.strftime(self.date_format)
|
||||
try:
|
||||
datetime.strptime(value, self.date_format)
|
||||
return value
|
||||
except ValueError:
|
||||
return DEFAULT_DATETIME.strftime(self.date_format)
|
||||
|
||||
|
||||
class JSONValidator(ConfigValidator):
|
||||
|
||||
def __init__(self, tpye: type[dict] | type[list] = dict) -> None:
|
||||
self.type = tpye
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
data = json.loads(value)
|
||||
if isinstance(data, self.type):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except json.JSONDecodeError:
|
||||
return False
|
||||
|
||||
def correct(self, value: Any) -> str:
|
||||
return (
|
||||
value if self.validate(value) else ("{ }" if self.type == dict else "[ ]")
|
||||
)
|
||||
|
||||
|
||||
class EncryptValidator(ConfigValidator):
|
||||
"""加密数据验证器"""
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
dpapi_decrypt(value)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def correct(self, value: Any) -> Any:
|
||||
return value if self.validate(value) else dpapi_encrypt("数据损坏, 请重新设置")
|
||||
|
||||
|
||||
class BoolValidator(OptionsValidator):
|
||||
"""布尔值验证器"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__([True, False])
|
||||
|
||||
|
||||
class FileValidator(ConfigValidator):
|
||||
"""文件路径验证器"""
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
if not Path(value).is_absolute():
|
||||
return False
|
||||
if Path(value).suffix == ".lnk":
|
||||
return False
|
||||
return True
|
||||
|
||||
def correct(self, value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
value = str(Path.cwd())
|
||||
if not Path(value).is_absolute():
|
||||
value = Path(value).resolve().as_posix()
|
||||
if Path(value).suffix == ".lnk":
|
||||
try:
|
||||
shell = win32com.client.Dispatch("WScript.Shell")
|
||||
shortcut = shell.CreateShortcut(value)
|
||||
value = shortcut.TargetPath
|
||||
except:
|
||||
pass
|
||||
return Path(value).resolve().as_posix()
|
||||
|
||||
|
||||
class FolderValidator(ConfigValidator):
|
||||
"""文件夹路径验证器"""
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
if not Path(value).is_absolute():
|
||||
return False
|
||||
return True
|
||||
|
||||
def correct(self, value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
value = str(Path.cwd())
|
||||
return Path(value).resolve().as_posix()
|
||||
|
||||
|
||||
class UserNameValidator(ConfigValidator):
|
||||
"""用户名验证器"""
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
|
||||
if not value or not value.strip():
|
||||
return False
|
||||
|
||||
if value != value.strip() or value != value.strip("."):
|
||||
return False
|
||||
|
||||
if any(char in ILLEGAL_CHARS for char in value):
|
||||
return False
|
||||
|
||||
if value.upper() in RESERVED_NAMES:
|
||||
return False
|
||||
if len(value) > 255:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def correct(self, value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
value = "默认用户名"
|
||||
|
||||
value = value.strip().strip(".")
|
||||
|
||||
value = "".join(char for char in value if char not in ILLEGAL_CHARS)
|
||||
|
||||
if value.upper() in RESERVED_NAMES or not value:
|
||||
value = "默认用户名"
|
||||
|
||||
if len(value) > 255:
|
||||
value = value[:255]
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class URLValidator(ConfigValidator):
|
||||
"""URL格式验证器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schemes: list[str] | None = None,
|
||||
require_netloc: bool = True,
|
||||
default: str = "",
|
||||
):
|
||||
"""
|
||||
:param schemes: 允许的协议列表, 若为 None 则允许任意协议
|
||||
:param require_netloc: 是否要求必须包含网络位置, 如域名或IP
|
||||
"""
|
||||
self.schemes = [s.lower() for s in schemes] if schemes else None
|
||||
self.require_netloc = require_netloc
|
||||
self.default = default
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
|
||||
if value == self.default:
|
||||
return True
|
||||
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
|
||||
try:
|
||||
parsed = urlparse(value)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# 检查协议
|
||||
if self.schemes is not None:
|
||||
if not parsed.scheme or parsed.scheme.lower() not in self.schemes:
|
||||
return False
|
||||
else:
|
||||
# 不限制协议仍要求有 scheme
|
||||
if not parsed.scheme:
|
||||
return False
|
||||
|
||||
# 检查是否包含网络位置
|
||||
if self.require_netloc and not parsed.netloc:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def correct(self, value: Any) -> str:
|
||||
|
||||
if self.validate(value):
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
# 简单尝试:若看起来像域名,加上 https://
|
||||
stripped = value.strip()
|
||||
if stripped and not stripped.startswith(("http://", "https://")):
|
||||
candidate = f"https://{stripped}"
|
||||
if self.validate(candidate):
|
||||
return candidate
|
||||
|
||||
return self.default
|
||||
|
||||
|
||||
class ConfigItem:
|
||||
"""配置项"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
group: str,
|
||||
name: str,
|
||||
default: Any,
|
||||
validator: Optional[ConfigValidator] = None,
|
||||
):
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
group: str
|
||||
配置项分组名称
|
||||
|
||||
name: str
|
||||
配置项字段名称
|
||||
|
||||
default: Any
|
||||
配置项默认值
|
||||
|
||||
validator: ConfigValidator
|
||||
配置项验证器, 默认为 None, 表示不进行验证
|
||||
"""
|
||||
super().__init__()
|
||||
self.group = group
|
||||
self.name = name
|
||||
self.value: Any = default
|
||||
self.validator = validator or ConfigValidator()
|
||||
self.is_locked = False
|
||||
|
||||
if not self.validator.validate(self.value):
|
||||
raise ValueError(
|
||||
f"配置项 '{self.group}.{self.name}' 的默认值 '{self.value}' 不合法"
|
||||
)
|
||||
|
||||
def setValue(self, value: Any):
|
||||
"""
|
||||
设置配置项值, 将自动进行验证和修正
|
||||
|
||||
Parameters
|
||||
----------
|
||||
value: Any
|
||||
要设置的值, 可以是任何合法类型
|
||||
"""
|
||||
|
||||
if (
|
||||
dpapi_decrypt(self.value)
|
||||
if isinstance(self.validator, EncryptValidator)
|
||||
else self.value
|
||||
) == value:
|
||||
return
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError(f"配置项 '{self.group}.{self.name}' 已锁定, 无法修改")
|
||||
|
||||
# deepcopy new value
|
||||
try:
|
||||
self.value = deepcopy(value)
|
||||
except:
|
||||
self.value = value
|
||||
|
||||
if isinstance(self.validator, EncryptValidator):
|
||||
if self.validator.validate(self.value):
|
||||
self.value = self.value
|
||||
else:
|
||||
self.value = dpapi_encrypt(self.value)
|
||||
|
||||
if not self.validator.validate(self.value):
|
||||
self.value = self.validator.correct(self.value)
|
||||
|
||||
def getValue(self, if_decrypt: bool = True) -> Any:
|
||||
"""
|
||||
获取配置项值
|
||||
"""
|
||||
|
||||
v = (
|
||||
self.value
|
||||
if self.validator.validate(self.value)
|
||||
else self.validator.correct(self.value)
|
||||
)
|
||||
|
||||
if isinstance(self.validator, EncryptValidator) and if_decrypt:
|
||||
return dpapi_decrypt(v)
|
||||
return v
|
||||
|
||||
def lock(self):
|
||||
"""
|
||||
锁定配置项, 锁定后无法修改配置项值
|
||||
"""
|
||||
self.is_locked = True
|
||||
|
||||
def unlock(self):
|
||||
"""
|
||||
解锁配置项, 解锁后可以修改配置项值
|
||||
"""
|
||||
self.is_locked = False
|
||||
|
||||
|
||||
class ConfigBase:
|
||||
"""
|
||||
配置基类
|
||||
|
||||
这个类提供了基本的配置项管理功能, 包括连接配置文件、加载配置数据、获取和设置配置项值等。
|
||||
|
||||
此类不支持直接实例化, 必须通过子类来实现具体的配置项, 请继承此类并在子类中定义具体的配置项。
|
||||
若将配置项设为类属性, 则所有实例都会共享同一份配置项数据。
|
||||
若将配置项设为实例属性, 则每个实例都会有独立的配置项数据。
|
||||
子配置项可以是 `MultipleConfig` 的实例。
|
||||
"""
|
||||
|
||||
def __init__(self, if_save_multi_config: bool = True):
|
||||
|
||||
self.file: Optional[Path] = None
|
||||
self.if_save_multi_config = if_save_multi_config
|
||||
self.is_locked = False
|
||||
|
||||
async def connect(self, path: Path):
|
||||
"""
|
||||
将配置文件连接到指定配置文件
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path: Path
|
||||
配置文件路径, 必须为 JSON 文件, 如果不存在则会创建
|
||||
"""
|
||||
|
||||
if path.suffix != ".json":
|
||||
raise ValueError("配置文件必须是扩展名为 '.json' 的 JSON 文件")
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError("配置已锁定, 无法修改")
|
||||
|
||||
self.file = path
|
||||
|
||||
if not self.file.exists():
|
||||
self.file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.file.touch()
|
||||
|
||||
try:
|
||||
data = json.loads(self.file.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
|
||||
await self.load(data)
|
||||
|
||||
async def load(self, data: dict):
|
||||
"""
|
||||
从字典加载配置数据
|
||||
|
||||
这个方法会遍历字典中的配置项, 并将其设置到对应的 ConfigItem 实例中。
|
||||
如果字典中包含 "SubConfigsInfo" 键, 则会加载子配置项, 这些子配置项应该是 MultipleConfig 的实例。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data: dict
|
||||
配置数据字典
|
||||
"""
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError("配置已锁定, 无法修改")
|
||||
|
||||
# update the value of config item
|
||||
if data.get("SubConfigsInfo"):
|
||||
for k, v in data["SubConfigsInfo"].items():
|
||||
if hasattr(self, k):
|
||||
sub_config = getattr(self, k)
|
||||
if isinstance(sub_config, MultipleConfig):
|
||||
await sub_config.load(v)
|
||||
data.pop("SubConfigsInfo")
|
||||
|
||||
for group, info in data.items():
|
||||
for name, value in info.items():
|
||||
if hasattr(self, f"{group}_{name}"):
|
||||
configItem = getattr(self, f"{group}_{name}")
|
||||
if isinstance(configItem, ConfigItem):
|
||||
configItem.setValue(value)
|
||||
|
||||
if self.file:
|
||||
await self.save()
|
||||
|
||||
async def toDict(
|
||||
self, ignore_multi_config: bool = False, if_decrypt: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""将配置项转换为字典"""
|
||||
|
||||
data = {}
|
||||
for name in dir(self):
|
||||
item = getattr(self, name)
|
||||
|
||||
if isinstance(item, ConfigItem):
|
||||
|
||||
if not data.get(item.group):
|
||||
data[item.group] = {}
|
||||
if item.name:
|
||||
data[item.group][item.name] = item.getValue(if_decrypt)
|
||||
|
||||
elif not ignore_multi_config and isinstance(item, MultipleConfig):
|
||||
|
||||
if not data.get("SubConfigsInfo"):
|
||||
data["SubConfigsInfo"] = {}
|
||||
data["SubConfigsInfo"][name] = await item.toDict()
|
||||
|
||||
return data
|
||||
|
||||
def get(self, group: str, name: str) -> Any:
|
||||
"""获取配置项的值"""
|
||||
|
||||
if not hasattr(self, f"{group}_{name}"):
|
||||
raise AttributeError(f"配置项 '{group}.{name}' 不存在")
|
||||
|
||||
configItem = getattr(self, f"{group}_{name}")
|
||||
if isinstance(configItem, ConfigItem):
|
||||
return configItem.getValue()
|
||||
else:
|
||||
raise TypeError(f"配置项 '{group}.{name}' 不是 ConfigItem 实例")
|
||||
|
||||
async def set(self, group: str, name: str, value: Any):
|
||||
"""
|
||||
设置配置项的值
|
||||
|
||||
Parameters
|
||||
----------
|
||||
group: str
|
||||
配置项分组名称
|
||||
name: str
|
||||
配置项名称
|
||||
value: Any
|
||||
配置项新值
|
||||
"""
|
||||
|
||||
if not hasattr(self, f"{group}_{name}"):
|
||||
raise AttributeError(f"配置项 '{group}.{name}' 不存在")
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError("配置已锁定, 无法修改")
|
||||
|
||||
configItem = getattr(self, f"{group}_{name}")
|
||||
if isinstance(configItem, ConfigItem):
|
||||
configItem.setValue(value)
|
||||
if self.file:
|
||||
await self.save()
|
||||
else:
|
||||
raise TypeError(f"配置项 '{group}.{name}' 不是 ConfigItem 实例")
|
||||
|
||||
async def save(self):
|
||||
"""保存配置"""
|
||||
|
||||
if not self.file:
|
||||
raise ValueError("文件路径未设置, 请先调用 `connect` 方法连接配置文件")
|
||||
|
||||
self.file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.file.write_text(
|
||||
json.dumps(
|
||||
await self.toDict(not self.if_save_multi_config, if_decrypt=False),
|
||||
ensure_ascii=False,
|
||||
indent=4,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
async def lock(self):
|
||||
"""
|
||||
锁定配置项, 锁定后无法修改配置项值
|
||||
"""
|
||||
|
||||
self.is_locked = True
|
||||
|
||||
for name in dir(self):
|
||||
item = getattr(self, name)
|
||||
if isinstance(item, ConfigItem):
|
||||
item.lock()
|
||||
elif isinstance(item, MultipleConfig):
|
||||
await item.lock()
|
||||
|
||||
async def unlock(self):
|
||||
"""
|
||||
解锁配置项, 解锁后可以修改配置项值
|
||||
"""
|
||||
|
||||
self.is_locked = False
|
||||
|
||||
for name in dir(self):
|
||||
item = getattr(self, name)
|
||||
if isinstance(item, ConfigItem):
|
||||
item.unlock()
|
||||
elif isinstance(item, MultipleConfig):
|
||||
await item.unlock()
|
||||
|
||||
|
||||
T = TypeVar("T", bound="ConfigBase")
|
||||
|
||||
|
||||
class MultipleConfig(Generic[T]):
|
||||
"""
|
||||
多配置项管理类
|
||||
|
||||
这个类允许管理多个配置项实例, 可以添加、删除、修改配置项, 并将其保存到 JSON 文件中。
|
||||
允许通过 `config[uuid]` 访问配置项, 使用 `uuid in config` 检查是否存在配置项, 使用 `len(config)` 获取配置项数量。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sub_config_type: List[type]
|
||||
子配置项的类型列表, 必须是 ConfigBase 的子类
|
||||
"""
|
||||
|
||||
def __init__(self, sub_config_type: List[Type[T]]):
|
||||
|
||||
if not sub_config_type:
|
||||
raise ValueError("子配置项类型列表不能为空")
|
||||
|
||||
for config_type in sub_config_type:
|
||||
if not issubclass(config_type, ConfigBase):
|
||||
raise TypeError(
|
||||
f"配置类型 {config_type.__name__} 必须是 ConfigBase 的子类"
|
||||
)
|
||||
|
||||
self.sub_config_type: List[Type[T]] = sub_config_type
|
||||
self.file: Path | None = None
|
||||
self.order: List[uuid.UUID] = []
|
||||
self.data: Dict[uuid.UUID, T] = {}
|
||||
self.is_locked = False
|
||||
|
||||
def __getitem__(self, key: uuid.UUID) -> T:
|
||||
"""允许通过 config[uuid] 访问配置项"""
|
||||
if key not in self.data:
|
||||
raise KeyError(f"配置项 '{key}' 不存在")
|
||||
return self.data[key]
|
||||
|
||||
def __contains__(self, key: uuid.UUID) -> bool:
|
||||
"""允许使用 uuid in config 检查是否存在"""
|
||||
return key in self.data
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""允许使用 len(config) 获取配置项数量"""
|
||||
return len(self.data)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""更好的字符串表示"""
|
||||
return f"MultipleConfig(items={len(self.data)}, types={[t.__name__ for t in self.sub_config_type]})"
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""用户友好的字符串表示"""
|
||||
return f"MultipleConfig with {len(self.data)} items"
|
||||
|
||||
async def connect(self, path: Path):
|
||||
"""
|
||||
将配置文件连接到指定配置文件
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path: Path
|
||||
配置文件路径, 必须为 JSON 文件, 如果不存在则会创建
|
||||
"""
|
||||
|
||||
if path.suffix != ".json":
|
||||
raise ValueError("配置文件必须是带有 '.json' 扩展名的 JSON 文件。")
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError("配置已锁定, 无法修改")
|
||||
|
||||
self.file = path
|
||||
|
||||
if not self.file.exists():
|
||||
self.file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.file.touch()
|
||||
|
||||
try:
|
||||
data = json.loads(self.file.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
|
||||
await self.load(data)
|
||||
|
||||
async def load(self, data: dict):
|
||||
"""
|
||||
从字典加载配置数据
|
||||
|
||||
这个方法会遍历字典中的配置项, 并将其设置到对应的 ConfigBase 实例中。
|
||||
如果字典中包含 "instances" 键, 则会加载子配置项, 这些子配置项应该是 ConfigBase 子类的实例。
|
||||
如果字典中没有 "instances" 键, 则清空当前配置项。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data: dict
|
||||
配置数据字典
|
||||
"""
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError("配置已锁定, 无法修改")
|
||||
|
||||
if not data.get("instances"):
|
||||
self.order = []
|
||||
self.data = {}
|
||||
return
|
||||
|
||||
self.order = []
|
||||
self.data = {}
|
||||
|
||||
for instance in data["instances"]:
|
||||
|
||||
if not isinstance(instance, dict) or not data.get(instance.get("uid")):
|
||||
continue
|
||||
|
||||
type_name = instance.get("type", self.sub_config_type[0].__name__)
|
||||
|
||||
for class_type in self.sub_config_type:
|
||||
|
||||
if class_type.__name__ == type_name:
|
||||
self.order.append(uuid.UUID(instance["uid"]))
|
||||
self.data[self.order[-1]] = class_type()
|
||||
await self.data[self.order[-1]].load(data[instance["uid"]])
|
||||
break
|
||||
|
||||
else:
|
||||
|
||||
raise ValueError(f"未知的子配置类型: {type_name}")
|
||||
|
||||
if self.file:
|
||||
await self.save()
|
||||
|
||||
async def toDict(
|
||||
self, ignore_multi_config: bool = False, if_decrypt: bool = True
|
||||
) -> Dict[str, Union[list, dict]]:
|
||||
"""
|
||||
将配置项转换为字典
|
||||
|
||||
返回一个字典, 包含所有配置项的 UID 和类型, 以及每个配置项的具体数据。
|
||||
"""
|
||||
|
||||
data: Dict[str, Union[list, dict]] = {
|
||||
"instances": [
|
||||
{"uid": str(_), "type": type(self.data[_]).__name__} for _ in self.order
|
||||
]
|
||||
}
|
||||
for uid, config in self.items():
|
||||
data[str(uid)] = await config.toDict(ignore_multi_config, if_decrypt)
|
||||
return data
|
||||
|
||||
async def get(self, uid: uuid.UUID) -> Dict[str, Union[list, dict]]:
|
||||
"""
|
||||
获取指定 UID 的配置项
|
||||
|
||||
Parameters
|
||||
----------
|
||||
uid: uuid.UUID
|
||||
要获取的配置项的唯一标识符
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Union[list, dict]]
|
||||
对应的配置项数据字典
|
||||
"""
|
||||
|
||||
if uid not in self.data:
|
||||
raise ValueError(f"配置项 '{uid}' 不存在。")
|
||||
|
||||
data: Dict[str, Union[list, dict]] = {
|
||||
"instances": [
|
||||
{"uid": str(_), "type": type(self.data[_]).__name__}
|
||||
for _ in self.order
|
||||
if _ == uid
|
||||
]
|
||||
}
|
||||
data[str(uid)] = await self.data[uid].toDict()
|
||||
|
||||
return data
|
||||
|
||||
async def save(self):
|
||||
"""保存配置"""
|
||||
|
||||
if not self.file:
|
||||
raise ValueError("文件路径未设置, 请先调用 `connect` 方法连接配置文件")
|
||||
|
||||
self.file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.file.write_text(
|
||||
json.dumps(await self.toDict(), ensure_ascii=False, indent=4),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
async def add(self, config_type: Type[T]) -> tuple[uuid.UUID, T]:
|
||||
"""
|
||||
添加一个新的配置项
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config_type: type
|
||||
配置项的类型, 必须是初始化时已声明的 ConfigBase 子类
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[uuid.UUID, ConfigBase]
|
||||
新创建的配置项的唯一标识符和实例
|
||||
"""
|
||||
|
||||
if config_type not in self.sub_config_type:
|
||||
raise ValueError(f"配置类型 {config_type.__name__} 不被允许")
|
||||
|
||||
uid = uuid.uuid4()
|
||||
self.order.append(uid)
|
||||
self.data[uid] = config_type()
|
||||
|
||||
if self.file:
|
||||
await self.save()
|
||||
|
||||
return uid, self.data[uid]
|
||||
|
||||
async def remove(self, uid: uuid.UUID):
|
||||
"""
|
||||
移除配置项
|
||||
|
||||
Parameters
|
||||
----------
|
||||
uid: uuid.UUID
|
||||
要移除的配置项的唯一标识符
|
||||
"""
|
||||
|
||||
if self.is_locked:
|
||||
raise ValueError("配置已锁定, 无法修改")
|
||||
|
||||
if uid not in self.data:
|
||||
raise ValueError(f"配置项 '{uid}' 不存在")
|
||||
|
||||
if self.data[uid].is_locked:
|
||||
raise ValueError(f"配置项 '{uid}' 已锁定, 无法移除")
|
||||
|
||||
self.data.pop(uid)
|
||||
self.order.remove(uid)
|
||||
|
||||
if self.file:
|
||||
await self.save()
|
||||
|
||||
async def setOrder(self, order: List[uuid.UUID]):
|
||||
"""
|
||||
设置配置项的顺序
|
||||
|
||||
Parameters
|
||||
----------
|
||||
order: List[uuid.UUID]
|
||||
新的配置项顺序
|
||||
"""
|
||||
|
||||
if set(order) != set(self.data.keys()):
|
||||
raise ValueError("顺序与当前配置项不匹配")
|
||||
|
||||
self.order = order
|
||||
|
||||
if self.file:
|
||||
await self.save()
|
||||
|
||||
async def lock(self):
|
||||
"""
|
||||
锁定配置项, 锁定后无法修改配置项值
|
||||
"""
|
||||
|
||||
self.is_locked = True
|
||||
|
||||
for item in self.values():
|
||||
await item.lock()
|
||||
|
||||
async def unlock(self):
|
||||
"""
|
||||
解锁配置项, 解锁后可以修改配置项值
|
||||
"""
|
||||
|
||||
self.is_locked = False
|
||||
|
||||
for item in self.values():
|
||||
await item.unlock()
|
||||
|
||||
def keys(self):
|
||||
"""返回配置项的所有唯一标识符"""
|
||||
|
||||
return iter(self.order)
|
||||
|
||||
def values(self):
|
||||
"""返回配置项的所有实例"""
|
||||
|
||||
if not self.data:
|
||||
return iter([])
|
||||
|
||||
return iter([self.data[_] for _ in self.order])
|
||||
|
||||
def items(self):
|
||||
"""返回配置项的所有唯一标识符和实例的元组"""
|
||||
|
||||
return zip(self.keys(), self.values())
|
||||
|
||||
|
||||
class MultipleUIDValidator(ConfigValidator):
|
||||
"""多配置管理类UID验证器"""
|
||||
|
||||
def __init__(
|
||||
self, default: Any, related_config: Dict[str, MultipleConfig], config_name: str
|
||||
):
|
||||
self.default = default
|
||||
self.related_config = related_config
|
||||
self.config_name = config_name
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
if value == self.default:
|
||||
return True
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
uid = uuid.UUID(value)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if uid in self.related_config.get(self.config_name, {}):
|
||||
return True
|
||||
return False
|
||||
|
||||
def correct(self, value: Any) -> Any:
|
||||
if self.validate(value):
|
||||
return value
|
||||
return self.default
|
||||
2161
app/models/MAA.py
Normal file
2161
app/models/MAA.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,35 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# 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-MAS is distributed in the hope that it will be useful,
|
||||
# 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
__version__ = "5.0.0"
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA模组包
|
||||
v4.4
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
__version__ = "4.2.0"
|
||||
__author__ = "DLmaster361 <DLmaster_361@163.com>"
|
||||
__license__ = "GPL-3.0 license"
|
||||
|
||||
from .ConfigBase import *
|
||||
from .config import *
|
||||
from .schema import *
|
||||
from .general import GeneralManager
|
||||
from .MAA import MaaManager
|
||||
|
||||
__all__ = ["ConfigBase", "config", "schema"]
|
||||
__all__ = ["GeneralManager", "MaaManager"]
|
||||
|
||||
@@ -1,569 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .ConfigBase import *
|
||||
|
||||
|
||||
class Webhook(ConfigBase):
|
||||
"""Webhook 配置"""
|
||||
|
||||
Info_Name = ConfigItem("Info", "Name", "")
|
||||
Info_Enabled = ConfigItem("Info", "Enabled", True, BoolValidator())
|
||||
|
||||
Data_Url = ConfigItem("Data", "Url", "", URLValidator())
|
||||
Data_Template = ConfigItem("Data", "Template", "")
|
||||
Data_Headers = ConfigItem("Data", "Headers", "{ }", JSONValidator())
|
||||
Data_Method = ConfigItem(
|
||||
"Data", "Method", "POST", OptionsValidator(["POST", "GET"])
|
||||
)
|
||||
|
||||
|
||||
class GlobalConfig(ConfigBase):
|
||||
"""全局配置"""
|
||||
|
||||
Function_HistoryRetentionTime = ConfigItem(
|
||||
"Function",
|
||||
"HistoryRetentionTime",
|
||||
0,
|
||||
OptionsValidator([7, 15, 30, 60, 90, 180, 365, 0]),
|
||||
)
|
||||
Function_IfAllowSleep = ConfigItem(
|
||||
"Function", "IfAllowSleep", False, BoolValidator()
|
||||
)
|
||||
Function_IfSilence = ConfigItem("Function", "IfSilence", False, BoolValidator())
|
||||
Function_BossKey = ConfigItem("Function", "BossKey", "")
|
||||
Function_IfAgreeBilibili = ConfigItem(
|
||||
"Function", "IfAgreeBilibili", False, BoolValidator()
|
||||
)
|
||||
Function_IfSkipMumuSplashAds = ConfigItem(
|
||||
"Function", "IfSkipMumuSplashAds", False, BoolValidator()
|
||||
)
|
||||
|
||||
Voice_Enabled = ConfigItem("Voice", "Enabled", False, BoolValidator())
|
||||
Voice_Type = ConfigItem(
|
||||
"Voice", "Type", "simple", OptionsValidator(["simple", "noisy"])
|
||||
)
|
||||
|
||||
Start_IfSelfStart = ConfigItem("Start", "IfSelfStart", False, BoolValidator())
|
||||
Start_IfMinimizeDirectly = ConfigItem(
|
||||
"Start", "IfMinimizeDirectly", False, BoolValidator()
|
||||
)
|
||||
|
||||
UI_IfShowTray = ConfigItem("UI", "IfShowTray", False, BoolValidator())
|
||||
UI_IfToTray = ConfigItem("UI", "IfToTray", False, BoolValidator())
|
||||
|
||||
Notify_SendTaskResultTime = ConfigItem(
|
||||
"Notify",
|
||||
"SendTaskResultTime",
|
||||
"不推送",
|
||||
OptionsValidator(["不推送", "任何时刻", "仅失败时"]),
|
||||
)
|
||||
Notify_IfSendStatistic = ConfigItem(
|
||||
"Notify", "IfSendStatistic", False, BoolValidator()
|
||||
)
|
||||
Notify_IfSendSixStar = ConfigItem("Notify", "IfSendSixStar", False, BoolValidator())
|
||||
Notify_IfPushPlyer = ConfigItem("Notify", "IfPushPlyer", False, BoolValidator())
|
||||
Notify_IfSendMail = ConfigItem("Notify", "IfSendMail", False, BoolValidator())
|
||||
Notify_SMTPServerAddress = ConfigItem("Notify", "SMTPServerAddress", "")
|
||||
Notify_AuthorizationCode = ConfigItem(
|
||||
"Notify", "AuthorizationCode", "", EncryptValidator()
|
||||
)
|
||||
Notify_FromAddress = ConfigItem("Notify", "FromAddress", "")
|
||||
Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
|
||||
Notify_IfServerChan = ConfigItem("Notify", "IfServerChan", False, BoolValidator())
|
||||
Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
|
||||
Notify_CustomWebhooks = MultipleConfig([Webhook])
|
||||
|
||||
Update_IfAutoUpdate = ConfigItem("Update", "IfAutoUpdate", False, BoolValidator())
|
||||
Update_Source = ConfigItem(
|
||||
"Update",
|
||||
"Source",
|
||||
"GitHub",
|
||||
OptionsValidator(["GitHub", "MirrorChyan", "AutoSite"]),
|
||||
)
|
||||
Update_ProxyAddress = ConfigItem("Update", "ProxyAddress", "")
|
||||
Update_MirrorChyanCDK = ConfigItem(
|
||||
"Update", "MirrorChyanCDK", "", EncryptValidator()
|
||||
)
|
||||
|
||||
Data_UID = ConfigItem("Data", "UID", str(uuid.uuid4()), UUIDValidator())
|
||||
Data_LastStatisticsUpload = ConfigItem(
|
||||
"Data",
|
||||
"LastStatisticsUpload",
|
||||
"2000-01-01 00:00:00",
|
||||
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
Data_LastStageUpdated = ConfigItem(
|
||||
"Data",
|
||||
"LastStageUpdated",
|
||||
"2000-01-01 00:00:00",
|
||||
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
Data_StageTimeStamp = ConfigItem(
|
||||
"Data",
|
||||
"StageTimeStamp",
|
||||
"2000-01-01 00:00:00",
|
||||
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
Data_Stage = ConfigItem("Data", "Stage", "{ }", JSONValidator())
|
||||
Data_LastNoticeUpdated = ConfigItem(
|
||||
"Data",
|
||||
"LastNoticeUpdated",
|
||||
"2000-01-01 00:00:00",
|
||||
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
Data_IfShowNotice = ConfigItem("Data", "IfShowNotice", True, BoolValidator())
|
||||
Data_Notice = ConfigItem("Data", "Notice", "{ }", JSONValidator())
|
||||
Data_LastWebConfigUpdated = ConfigItem(
|
||||
"Data",
|
||||
"LastWebConfigUpdated",
|
||||
"2000-01-01 00:00:00",
|
||||
DateTimeValidator("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
Data_WebConfig = ConfigItem("Data", "WebConfig", "{ }", JSONValidator())
|
||||
|
||||
|
||||
class QueueItem(ConfigBase):
|
||||
"""队列项配置"""
|
||||
|
||||
related_config: dict[str, MultipleConfig] = {}
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_ScriptId = ConfigItem(
|
||||
"Info",
|
||||
"ScriptId",
|
||||
"-",
|
||||
MultipleUIDValidator("-", self.related_config, "ScriptConfig"),
|
||||
)
|
||||
|
||||
|
||||
class TimeSet(ConfigBase):
|
||||
"""时间设置配置"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Enabled = ConfigItem("Info", "Enabled", False, BoolValidator())
|
||||
self.Info_Time = ConfigItem("Info", "Time", "00:00", DateTimeValidator("%H:%M"))
|
||||
|
||||
|
||||
class QueueConfig(ConfigBase):
|
||||
"""队列配置"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Name = ConfigItem("Info", "Name", "新队列")
|
||||
self.Info_TimeEnabled = ConfigItem(
|
||||
"Info", "TimeEnabled", False, BoolValidator()
|
||||
)
|
||||
self.Info_StartUpEnabled = ConfigItem(
|
||||
"Info", "StartUpEnabled", False, BoolValidator()
|
||||
)
|
||||
self.Info_AfterAccomplish = ConfigItem(
|
||||
"Info",
|
||||
"AfterAccomplish",
|
||||
"NoAction",
|
||||
OptionsValidator(
|
||||
[
|
||||
"NoAction",
|
||||
"KillSelf",
|
||||
"Sleep",
|
||||
"Hibernate",
|
||||
"Shutdown",
|
||||
"ShutdownForce",
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
self.Data_LastTimedStart = ConfigItem(
|
||||
"Data",
|
||||
"LastTimedStart",
|
||||
"2000-01-01 00:00",
|
||||
DateTimeValidator("%Y-%m-%d %H:%M"),
|
||||
)
|
||||
|
||||
self.TimeSet = MultipleConfig([TimeSet])
|
||||
self.QueueItem = MultipleConfig([QueueItem])
|
||||
|
||||
|
||||
class MaaUserConfig(ConfigBase):
|
||||
"""MAA用户配置"""
|
||||
|
||||
related_config: dict[str, MultipleConfig] = {}
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator())
|
||||
self.Info_Id = ConfigItem("Info", "Id", "")
|
||||
self.Info_Mode = ConfigItem(
|
||||
"Info", "Mode", "简洁", OptionsValidator(["简洁", "详细"])
|
||||
)
|
||||
self.Info_StageMode = ConfigItem(
|
||||
"Info",
|
||||
"StageMode",
|
||||
"Fixed",
|
||||
MultipleUIDValidator("Fixed", self.related_config, "PlanConfig"),
|
||||
)
|
||||
self.Info_Server = ConfigItem(
|
||||
"Info",
|
||||
"Server",
|
||||
"Official",
|
||||
OptionsValidator(
|
||||
["Official", "Bilibili", "YoStarEN", "YoStarJP", "YoStarKR", "txwy"]
|
||||
),
|
||||
)
|
||||
self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator())
|
||||
self.Info_RemainedDay = ConfigItem(
|
||||
"Info", "RemainedDay", -1, RangeValidator(-1, 9999)
|
||||
)
|
||||
self.Info_Annihilation = ConfigItem(
|
||||
"Info",
|
||||
"Annihilation",
|
||||
"Annihilation",
|
||||
OptionsValidator(
|
||||
[
|
||||
"Close",
|
||||
"Annihilation",
|
||||
"Chernobog@Annihilation",
|
||||
"LungmenOutskirts@Annihilation",
|
||||
"LungmenDowntown@Annihilation",
|
||||
]
|
||||
),
|
||||
)
|
||||
self.Info_Routine = ConfigItem("Info", "Routine", True, BoolValidator())
|
||||
self.Info_InfrastMode = ConfigItem(
|
||||
"Info",
|
||||
"InfrastMode",
|
||||
"Normal",
|
||||
OptionsValidator(["Normal", "Rotation", "Custom"]),
|
||||
)
|
||||
self.Info_InfrastPath = ConfigItem(
|
||||
"Info", "InfrastPath", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
self.Info_Password = ConfigItem("Info", "Password", "", EncryptValidator())
|
||||
self.Info_Notes = ConfigItem("Info", "Notes", "无")
|
||||
self.Info_MedicineNumb = ConfigItem(
|
||||
"Info", "MedicineNumb", 0, RangeValidator(0, 9999)
|
||||
)
|
||||
self.Info_SeriesNumb = ConfigItem(
|
||||
"Info",
|
||||
"SeriesNumb",
|
||||
"0",
|
||||
OptionsValidator(["0", "6", "5", "4", "3", "2", "1", "-1"]),
|
||||
)
|
||||
self.Info_Stage = ConfigItem("Info", "Stage", "-")
|
||||
self.Info_Stage_1 = ConfigItem("Info", "Stage_1", "-")
|
||||
self.Info_Stage_2 = ConfigItem("Info", "Stage_2", "-")
|
||||
self.Info_Stage_3 = ConfigItem("Info", "Stage_3", "-")
|
||||
self.Info_Stage_Remain = ConfigItem("Info", "Stage_Remain", "-")
|
||||
self.Info_IfSkland = ConfigItem("Info", "IfSkland", False, BoolValidator())
|
||||
self.Info_SklandToken = ConfigItem(
|
||||
"Info", "SklandToken", "", EncryptValidator()
|
||||
)
|
||||
|
||||
self.Data_LastProxyDate = ConfigItem(
|
||||
"Data", "LastProxyDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
|
||||
)
|
||||
self.Data_LastAnnihilationDate = ConfigItem(
|
||||
"Data", "LastAnnihilationDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
|
||||
)
|
||||
self.Data_LastSklandDate = ConfigItem(
|
||||
"Data", "LastSklandDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
|
||||
)
|
||||
self.Data_ProxyTimes = ConfigItem(
|
||||
"Data", "ProxyTimes", 0, RangeValidator(0, 9999)
|
||||
)
|
||||
self.Data_IfPassCheck = ConfigItem("Data", "IfPassCheck", True, BoolValidator())
|
||||
self.Data_CustomInfrastPlanIndex = ConfigItem(
|
||||
"Data", "CustomInfrastPlanIndex", "0"
|
||||
)
|
||||
|
||||
self.Task_IfWakeUp = ConfigItem("Task", "IfWakeUp", True, BoolValidator())
|
||||
self.Task_IfRecruiting = ConfigItem(
|
||||
"Task", "IfRecruiting", True, BoolValidator()
|
||||
)
|
||||
self.Task_IfBase = ConfigItem("Task", "IfBase", True, BoolValidator())
|
||||
self.Task_IfCombat = ConfigItem("Task", "IfCombat", True, BoolValidator())
|
||||
self.Task_IfMall = ConfigItem("Task", "IfMall", True, BoolValidator())
|
||||
self.Task_IfMission = ConfigItem("Task", "IfMission", True, BoolValidator())
|
||||
self.Task_IfAutoRoguelike = ConfigItem(
|
||||
"Task", "IfAutoRoguelike", False, BoolValidator()
|
||||
)
|
||||
self.Task_IfReclamation = ConfigItem(
|
||||
"Task", "IfReclamation", False, BoolValidator()
|
||||
)
|
||||
|
||||
self.Notify_Enabled = ConfigItem("Notify", "Enabled", False, BoolValidator())
|
||||
self.Notify_IfSendStatistic = ConfigItem(
|
||||
"Notify", "IfSendStatistic", False, BoolValidator()
|
||||
)
|
||||
self.Notify_IfSendSixStar = ConfigItem(
|
||||
"Notify", "IfSendSixStar", False, BoolValidator()
|
||||
)
|
||||
self.Notify_IfSendMail = ConfigItem(
|
||||
"Notify", "IfSendMail", False, BoolValidator()
|
||||
)
|
||||
self.Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
|
||||
self.Notify_IfServerChan = ConfigItem(
|
||||
"Notify", "IfServerChan", False, BoolValidator()
|
||||
)
|
||||
self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
|
||||
self.Notify_CustomWebhooks = MultipleConfig([Webhook])
|
||||
|
||||
|
||||
class MaaConfig(ConfigBase):
|
||||
"""MAA配置"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Name = ConfigItem("Info", "Name", "新 MAA 脚本")
|
||||
self.Info_Path = ConfigItem("Info", "Path", str(Path.cwd()), FolderValidator())
|
||||
|
||||
self.Run_TaskTransitionMethod = ConfigItem(
|
||||
"Run",
|
||||
"TaskTransitionMethod",
|
||||
"ExitEmulator",
|
||||
OptionsValidator(["NoAction", "ExitGame", "ExitEmulator"]),
|
||||
)
|
||||
self.Run_ProxyTimesLimit = ConfigItem(
|
||||
"Run", "ProxyTimesLimit", 0, RangeValidator(0, 9999)
|
||||
)
|
||||
self.Run_ADBSearchRange = ConfigItem(
|
||||
"Run", "ADBSearchRange", 0, RangeValidator(0, 3)
|
||||
)
|
||||
self.Run_RunTimesLimit = ConfigItem(
|
||||
"Run", "RunTimesLimit", 3, RangeValidator(1, 9999)
|
||||
)
|
||||
self.Run_AnnihilationTimeLimit = ConfigItem(
|
||||
"Run", "AnnihilationTimeLimit", 40, RangeValidator(1, 9999)
|
||||
)
|
||||
self.Run_RoutineTimeLimit = ConfigItem(
|
||||
"Run", "RoutineTimeLimit", 10, RangeValidator(1, 9999)
|
||||
)
|
||||
self.Run_AnnihilationWeeklyLimit = ConfigItem(
|
||||
"Run", "AnnihilationWeeklyLimit", True, BoolValidator()
|
||||
)
|
||||
|
||||
self.UserData = MultipleConfig([MaaUserConfig])
|
||||
|
||||
|
||||
class MaaPlanConfig(ConfigBase):
|
||||
"""MAA计划表配置"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Name = ConfigItem("Info", "Name", "新 MAA 计划表")
|
||||
self.Info_Mode = ConfigItem(
|
||||
"Info", "Mode", "ALL", OptionsValidator(["ALL", "Weekly"])
|
||||
)
|
||||
|
||||
self.config_item_dict: dict[str, Dict[str, ConfigItem]] = {}
|
||||
|
||||
for group in [
|
||||
"ALL",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
]:
|
||||
self.config_item_dict[group] = {}
|
||||
|
||||
self.config_item_dict[group]["MedicineNumb"] = ConfigItem(
|
||||
group, "MedicineNumb", 0, RangeValidator(0, 9999)
|
||||
)
|
||||
self.config_item_dict[group]["SeriesNumb"] = ConfigItem(
|
||||
group,
|
||||
"SeriesNumb",
|
||||
"0",
|
||||
OptionsValidator(["0", "6", "5", "4", "3", "2", "1", "-1"]),
|
||||
)
|
||||
self.config_item_dict[group]["Stage"] = ConfigItem(group, "Stage", "-")
|
||||
self.config_item_dict[group]["Stage_1"] = ConfigItem(group, "Stage_1", "-")
|
||||
self.config_item_dict[group]["Stage_2"] = ConfigItem(group, "Stage_2", "-")
|
||||
self.config_item_dict[group]["Stage_3"] = ConfigItem(group, "Stage_3", "-")
|
||||
self.config_item_dict[group]["Stage_Remain"] = ConfigItem(
|
||||
group, "Stage_Remain", "-"
|
||||
)
|
||||
|
||||
for name in [
|
||||
"MedicineNumb",
|
||||
"SeriesNumb",
|
||||
"Stage",
|
||||
"Stage_1",
|
||||
"Stage_2",
|
||||
"Stage_3",
|
||||
"Stage_Remain",
|
||||
]:
|
||||
setattr(self, f"{group}_{name}", self.config_item_dict[group][name])
|
||||
|
||||
def get_current_info(self, name: str) -> ConfigItem:
|
||||
"""获取当前的计划表配置项"""
|
||||
|
||||
if self.get("Info", "Mode") == "ALL":
|
||||
|
||||
return self.config_item_dict["ALL"][name]
|
||||
|
||||
elif self.get("Info", "Mode") == "Weekly":
|
||||
|
||||
dt = datetime.now()
|
||||
if dt.time() < datetime.min.time().replace(hour=4):
|
||||
dt = dt - timedelta(days=1)
|
||||
today = dt.strftime("%A")
|
||||
|
||||
if today in self.config_item_dict:
|
||||
return self.config_item_dict[today][name]
|
||||
else:
|
||||
return self.config_item_dict["ALL"][name]
|
||||
|
||||
else:
|
||||
raise ValueError("非法的计划表模式")
|
||||
|
||||
|
||||
class GeneralUserConfig(ConfigBase):
|
||||
"""通用脚本用户配置"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator())
|
||||
self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator())
|
||||
self.Info_RemainedDay = ConfigItem(
|
||||
"Info", "RemainedDay", -1, RangeValidator(-1, 9999)
|
||||
)
|
||||
self.Info_IfScriptBeforeTask = ConfigItem(
|
||||
"Info", "IfScriptBeforeTask", False, BoolValidator()
|
||||
)
|
||||
self.Info_ScriptBeforeTask = ConfigItem(
|
||||
"Info", "ScriptBeforeTask", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
self.Info_IfScriptAfterTask = ConfigItem(
|
||||
"Info", "IfScriptAfterTask", False, BoolValidator()
|
||||
)
|
||||
self.Info_ScriptAfterTask = ConfigItem(
|
||||
"Info", "ScriptAfterTask", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
self.Info_Notes = ConfigItem("Info", "Notes", "无")
|
||||
|
||||
self.Data_LastProxyDate = ConfigItem(
|
||||
"Data", "LastProxyDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
|
||||
)
|
||||
self.Data_ProxyTimes = ConfigItem(
|
||||
"Data", "ProxyTimes", 0, RangeValidator(0, 9999)
|
||||
)
|
||||
|
||||
self.Notify_Enabled = ConfigItem("Notify", "Enabled", False, BoolValidator())
|
||||
self.Notify_IfSendStatistic = ConfigItem(
|
||||
"Notify", "IfSendStatistic", False, BoolValidator()
|
||||
)
|
||||
self.Notify_IfSendMail = ConfigItem(
|
||||
"Notify", "IfSendMail", False, BoolValidator()
|
||||
)
|
||||
self.Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
|
||||
self.Notify_IfServerChan = ConfigItem(
|
||||
"Notify", "IfServerChan", False, BoolValidator()
|
||||
)
|
||||
self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
|
||||
self.Notify_CustomWebhooks = MultipleConfig([Webhook])
|
||||
|
||||
|
||||
class GeneralConfig(ConfigBase):
|
||||
"""通用配置"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.Info_Name = ConfigItem("Info", "Name", "新通用脚本")
|
||||
self.Info_RootPath = ConfigItem(
|
||||
"Info", "RootPath", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
|
||||
self.Script_ScriptPath = ConfigItem(
|
||||
"Script", "ScriptPath", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
self.Script_Arguments = ConfigItem("Script", "Arguments", "")
|
||||
self.Script_IfTrackProcess = ConfigItem(
|
||||
"Script", "IfTrackProcess", False, BoolValidator()
|
||||
)
|
||||
self.Script_ConfigPath = ConfigItem(
|
||||
"Script", "ConfigPath", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
self.Script_ConfigPathMode = ConfigItem(
|
||||
"Script", "ConfigPathMode", "File", OptionsValidator(["File", "Folder"])
|
||||
)
|
||||
self.Script_UpdateConfigMode = ConfigItem(
|
||||
"Script",
|
||||
"UpdateConfigMode",
|
||||
"Never",
|
||||
OptionsValidator(["Never", "Success", "Failure", "Always"]),
|
||||
)
|
||||
self.Script_LogPath = ConfigItem(
|
||||
"Script", "LogPath", str(Path.cwd()), FileValidator()
|
||||
)
|
||||
self.Script_LogPathFormat = ConfigItem("Script", "LogPathFormat", "%Y-%m-%d")
|
||||
self.Script_LogTimeStart = ConfigItem(
|
||||
"Script", "LogTimeStart", 1, RangeValidator(1, 9999)
|
||||
)
|
||||
self.Script_LogTimeEnd = ConfigItem(
|
||||
"Script", "LogTimeEnd", 1, RangeValidator(1, 9999)
|
||||
)
|
||||
self.Script_LogTimeFormat = ConfigItem(
|
||||
"Script", "LogTimeFormat", "%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
self.Script_SuccessLog = ConfigItem("Script", "SuccessLog", "")
|
||||
self.Script_ErrorLog = ConfigItem("Script", "ErrorLog", "")
|
||||
|
||||
self.Game_Enabled = ConfigItem("Game", "Enabled", False, BoolValidator())
|
||||
self.Game_Type = ConfigItem(
|
||||
"Game", "Type", "Emulator", OptionsValidator(["Emulator", "Client"])
|
||||
)
|
||||
self.Game_Path = ConfigItem("Game", "Path", str(Path.cwd()), FileValidator())
|
||||
self.Game_Arguments = ConfigItem("Game", "Arguments", "")
|
||||
self.Game_WaitTime = ConfigItem("Game", "WaitTime", 0, RangeValidator(0, 9999))
|
||||
self.Game_IfForceClose = ConfigItem(
|
||||
"Game", "IfForceClose", False, BoolValidator()
|
||||
)
|
||||
|
||||
self.Run_ProxyTimesLimit = ConfigItem(
|
||||
"Run", "ProxyTimesLimit", 0, RangeValidator(0, 9999)
|
||||
)
|
||||
self.Run_RunTimesLimit = ConfigItem(
|
||||
"Run", "RunTimesLimit", 3, RangeValidator(1, 9999)
|
||||
)
|
||||
self.Run_RunTimeLimit = ConfigItem(
|
||||
"Run", "RunTimeLimit", 10, RangeValidator(1, 9999)
|
||||
)
|
||||
|
||||
self.UserData = MultipleConfig([GeneralUserConfig])
|
||||
|
||||
|
||||
CLASS_BOOK = {"MAA": MaaConfig, "MaaPlan": MaaPlanConfig, "General": GeneralConfig}
|
||||
"""配置类映射表"""
|
||||
1201
app/models/general.py
Normal file
1201
app/models/general.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,872 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any, Dict, List, Union, Optional, Literal
|
||||
|
||||
|
||||
class OutBase(BaseModel):
|
||||
code: int = Field(default=200, description="状态码")
|
||||
status: str = Field(default="success", description="操作状态")
|
||||
message: str = Field(default="操作成功", description="操作消息")
|
||||
|
||||
|
||||
class InfoOut(OutBase):
|
||||
data: Dict[str, Any] = Field(..., description="收到的服务器数据")
|
||||
|
||||
|
||||
class VersionOut(OutBase):
|
||||
if_need_update: bool = Field(..., description="后端代码是否需要更新")
|
||||
current_hash: str = Field(..., description="后端代码当前哈希值")
|
||||
current_time: str = Field(..., description="后端代码当前时间戳")
|
||||
current_version: str = Field(..., description="后端当前版本号")
|
||||
|
||||
|
||||
class NoticeOut(OutBase):
|
||||
if_need_show: bool = Field(..., description="是否需要显示公告")
|
||||
data: Dict[str, str] = Field(
|
||||
..., description="公告信息, key为公告标题, value为公告内容"
|
||||
)
|
||||
|
||||
|
||||
class ComboBoxItem(BaseModel):
|
||||
label: str = Field(..., description="展示值")
|
||||
value: Optional[str] = Field(..., description="实际值")
|
||||
|
||||
|
||||
class ComboBoxOut(OutBase):
|
||||
data: List[ComboBoxItem] = Field(..., description="下拉框选项")
|
||||
|
||||
|
||||
class GetStageIn(BaseModel):
|
||||
type: Literal[
|
||||
"Today",
|
||||
"ALL",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
] = Field(
|
||||
...,
|
||||
description="选择的日期类型, Today为当天, ALL为包含当天未开放关卡在内的所有项",
|
||||
)
|
||||
|
||||
|
||||
class WebhookIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["Webhook"] = Field(..., description="配置类型")
|
||||
|
||||
|
||||
class Webhook_Info(BaseModel):
|
||||
Name: Optional[str] = Field(default=None, description="Webhook名称")
|
||||
Enabled: Optional[bool] = Field(default=None, description="是否启用")
|
||||
|
||||
|
||||
class Webhook_Data(BaseModel):
|
||||
Url: Optional[str] = Field(default=None, description="Webhook URL")
|
||||
Template: Optional[str] = Field(default=None, description="消息模板")
|
||||
Headers: Optional[Dict[str, str]] = Field(default=None, description="自定义请求头")
|
||||
Method: Optional[Literal["POST", "GET"]] = Field(
|
||||
default=None, description="请求方法"
|
||||
)
|
||||
|
||||
|
||||
class Webhook(BaseModel):
|
||||
Info: Optional[Webhook_Info] = Field(default=None, description="Webhook基础信息")
|
||||
Data: Optional[Webhook_Data] = Field(default=None, description="Webhook配置数据")
|
||||
|
||||
|
||||
class GlobalConfig_Function(BaseModel):
|
||||
HistoryRetentionTime: Optional[Literal[7, 15, 30, 60, 90, 180, 365, 0]] = Field(
|
||||
None, description="历史记录保留时间, 0表示永久保存"
|
||||
)
|
||||
IfAllowSleep: Optional[bool] = Field(default=None, description="允许休眠")
|
||||
IfSilence: Optional[bool] = Field(default=None, description="静默模式")
|
||||
BossKey: Optional[str] = Field(default=None, description="模拟器老板键")
|
||||
IfAgreeBilibili: Optional[bool] = Field(
|
||||
default=None, description="同意哔哩哔哩用户协议"
|
||||
)
|
||||
IfSkipMumuSplashAds: Optional[bool] = Field(
|
||||
default=None, description="跳过Mumu模拟器启动广告"
|
||||
)
|
||||
|
||||
|
||||
class GlobalConfig_Voice(BaseModel):
|
||||
Enabled: Optional[bool] = Field(default=None, description="语音功能是否启用")
|
||||
Type: Optional[Literal["simple", "noisy"]] = Field(
|
||||
default=None, description="语音类型, simple为简洁, noisy为聒噪"
|
||||
)
|
||||
|
||||
|
||||
class GlobalConfig_Start(BaseModel):
|
||||
IfSelfStart: Optional[bool] = Field(
|
||||
default=None, description="是否在系统启动时自动运行"
|
||||
)
|
||||
IfMinimizeDirectly: Optional[bool] = Field(
|
||||
default=None, description="启动时是否直接最小化到托盘而不显示主窗口"
|
||||
)
|
||||
|
||||
|
||||
class GlobalConfig_UI(BaseModel):
|
||||
IfShowTray: Optional[bool] = Field(default=None, description="是否常态显示托盘图标")
|
||||
IfToTray: Optional[bool] = Field(default=None, description="是否最小化到托盘")
|
||||
|
||||
|
||||
class GlobalConfig_Notify(BaseModel):
|
||||
SendTaskResultTime: Optional[Literal["不推送", "任何时刻", "仅失败时"]] = Field(
|
||||
default=None, description="任务结果推送时机"
|
||||
)
|
||||
IfSendStatistic: Optional[bool] = Field(
|
||||
default=None, description="是否发送统计信息"
|
||||
)
|
||||
IfSendSixStar: Optional[bool] = Field(
|
||||
default=None, description="是否发送公招六星通知"
|
||||
)
|
||||
IfPushPlyer: Optional[bool] = Field(default=None, description="是否推送系统通知")
|
||||
IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件通知")
|
||||
SMTPServerAddress: Optional[str] = Field(default=None, description="SMTP服务器地址")
|
||||
AuthorizationCode: Optional[str] = Field(default=None, description="SMTP授权码")
|
||||
FromAddress: Optional[str] = Field(default=None, description="邮件发送地址")
|
||||
ToAddress: Optional[str] = Field(default=None, description="邮件接收地址")
|
||||
IfServerChan: Optional[bool] = Field(
|
||||
default=None, description="是否使用ServerChan推送"
|
||||
)
|
||||
ServerChanKey: Optional[str] = Field(default=None, description="ServerChan推送密钥")
|
||||
|
||||
|
||||
class GlobalConfig_Update(BaseModel):
|
||||
IfAutoUpdate: Optional[bool] = Field(default=None, description="是否自动更新")
|
||||
Source: Optional[Literal["GitHub", "MirrorChyan", "AutoSite"]] = Field(
|
||||
default=None, description="更新源: GitHub源, Mirror酱源, 自建源"
|
||||
)
|
||||
ProxyAddress: Optional[str] = Field(default=None, description="网络代理地址")
|
||||
MirrorChyanCDK: Optional[str] = Field(default=None, description="Mirror酱CDK")
|
||||
|
||||
|
||||
class GlobalConfig(BaseModel):
|
||||
Function: Optional[GlobalConfig_Function] = Field(
|
||||
default=None, description="功能相关配置"
|
||||
)
|
||||
Voice: Optional[GlobalConfig_Voice] = Field(
|
||||
default=None, description="语音相关配置"
|
||||
)
|
||||
Start: Optional[GlobalConfig_Start] = Field(
|
||||
default=None, description="启动相关配置"
|
||||
)
|
||||
UI: Optional[GlobalConfig_UI] = Field(default=None, description="界面相关配置")
|
||||
Notify: Optional[GlobalConfig_Notify] = Field(
|
||||
default=None, description="通知相关配置"
|
||||
)
|
||||
Update: Optional[GlobalConfig_Update] = Field(
|
||||
default=None, description="更新相关配置"
|
||||
)
|
||||
|
||||
|
||||
class QueueIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["QueueConfig"] = Field(..., description="配置类型")
|
||||
|
||||
|
||||
class QueueItemIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["QueueItem"] = Field(..., description="配置类型")
|
||||
|
||||
|
||||
class TimeSetIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["TimeSet"] = Field(..., description="配置类型")
|
||||
|
||||
|
||||
class QueueItem_Info(BaseModel):
|
||||
ScriptId: Optional[str] = Field(
|
||||
default=None, description="任务所对应的脚本ID, 为None时表示未选择"
|
||||
)
|
||||
|
||||
|
||||
class QueueItem(BaseModel):
|
||||
Info: Optional[QueueItem_Info] = Field(default=None, description="队列项")
|
||||
|
||||
|
||||
class TimeSet_Info(BaseModel):
|
||||
Enabled: Optional[bool] = Field(default=None, description="是否启用")
|
||||
Time: Optional[str] = Field(default=None, description="时间设置, 格式为HH:MM")
|
||||
|
||||
|
||||
class TimeSet(BaseModel):
|
||||
Info: Optional[TimeSet_Info] = Field(default=None, description="时间项")
|
||||
|
||||
|
||||
class QueueConfig_Info(BaseModel):
|
||||
Name: Optional[str] = Field(default=None, description="队列名称")
|
||||
TimeEnabled: Optional[bool] = Field(default=None, description="是否启用定时")
|
||||
StartUpEnabled: Optional[bool] = Field(default=None, description="是否启动时运行")
|
||||
AfterAccomplish: Optional[
|
||||
Literal[
|
||||
"NoAction", "KillSelf", "Sleep", "Hibernate", "Shutdown", "ShutdownForce"
|
||||
]
|
||||
] = Field(default=None, description="完成后操作")
|
||||
|
||||
|
||||
class QueueConfig(BaseModel):
|
||||
Info: Optional[QueueConfig_Info] = Field(default=None, description="队列信息")
|
||||
|
||||
|
||||
class ScriptIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["MaaConfig", "GeneralConfig"] = Field(..., description="配置类型")
|
||||
|
||||
|
||||
class UserIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["MaaUserConfig", "GeneralUserConfig"] = Field(
|
||||
..., description="配置类型"
|
||||
)
|
||||
|
||||
|
||||
class MaaUserConfig_Info(BaseModel):
|
||||
Name: Optional[str] = Field(default=None, description="用户名")
|
||||
Id: Optional[str] = Field(default=None, description="用户ID")
|
||||
Mode: Optional[Literal["简洁", "详细"]] = Field(
|
||||
default=None, description="用户配置模式"
|
||||
)
|
||||
StageMode: Optional[str] = Field(default=None, description="关卡配置模式")
|
||||
Server: Optional[
|
||||
Literal["Official", "Bilibili", "YoStarEN", "YoStarJP", "YoStarKR", "txwy"]
|
||||
] = Field(default=None, description="服务器")
|
||||
Status: Optional[bool] = Field(default=None, description="用户状态")
|
||||
RemainedDay: Optional[int] = Field(default=None, description="剩余天数")
|
||||
Annihilation: Optional[
|
||||
Literal[
|
||||
"Close",
|
||||
"Annihilation",
|
||||
"Chernobog@Annihilation",
|
||||
"LungmenOutskirts@Annihilation",
|
||||
"LungmenDowntown@Annihilation",
|
||||
]
|
||||
] = Field(default=None, description="剿灭模式")
|
||||
Routine: Optional[bool] = Field(default=None, description="是否启用日常")
|
||||
InfrastMode: Optional[Literal["Normal", "Rotation", "Custom"]] = Field(
|
||||
default=None, description="基建模式"
|
||||
)
|
||||
InfrastPath: Optional[str] = Field(default=None, description="自定义基建文件路径")
|
||||
Password: Optional[str] = Field(default=None, description="密码")
|
||||
Notes: Optional[str] = Field(default=None, description="备注")
|
||||
MedicineNumb: Optional[int] = Field(default=None, description="吃理智药数量")
|
||||
SeriesNumb: Optional[Literal["0", "6", "5", "4", "3", "2", "1", "-1"]] = Field(
|
||||
default=None, description="连战次数"
|
||||
)
|
||||
Stage: Optional[str] = Field(default=None, description="关卡选择")
|
||||
Stage_1: Optional[str] = Field(default=None, description="备选关卡 - 1")
|
||||
Stage_2: Optional[str] = Field(default=None, description="备选关卡 - 2")
|
||||
Stage_3: Optional[str] = Field(default=None, description="备选关卡 - 3")
|
||||
Stage_Remain: Optional[str] = Field(default=None, description="剩余理智关卡")
|
||||
IfSkland: Optional[bool] = Field(default=None, description="是否启用森空岛签到")
|
||||
SklandToken: Optional[str] = Field(default=None, description="SklandToken")
|
||||
|
||||
|
||||
class MaaUserConfig_Data(BaseModel):
|
||||
LastProxyDate: Optional[str] = Field(default=None, description="上次代理日期")
|
||||
LastAnnihilationDate: Optional[str] = Field(
|
||||
default=None, description="上次剿灭日期"
|
||||
)
|
||||
LastSklandDate: Optional[str] = Field(
|
||||
default=None, description="上次森空岛签到日期"
|
||||
)
|
||||
ProxyTimes: Optional[int] = Field(default=None, description="代理次数")
|
||||
IfPassCheck: Optional[bool] = Field(default=None, description="是否通过人工排查")
|
||||
|
||||
|
||||
class MaaUserConfig_Task(BaseModel):
|
||||
IfWakeUp: Optional[bool] = Field(default=None, description="开始唤醒")
|
||||
IfRecruiting: Optional[bool] = Field(default=None, description="自动公招")
|
||||
IfBase: Optional[bool] = Field(default=None, description="基建换班")
|
||||
IfCombat: Optional[bool] = Field(default=None, description="刷理智")
|
||||
IfMall: Optional[bool] = Field(default=None, description="获取信用及购物")
|
||||
IfMission: Optional[bool] = Field(default=None, description="领取奖励")
|
||||
IfAutoRoguelike: Optional[bool] = Field(default=None, description="自动肉鸽")
|
||||
IfReclamation: Optional[bool] = Field(default=None, description="生息演算")
|
||||
|
||||
|
||||
class MaaUserConfig_Notify(BaseModel):
|
||||
Enabled: Optional[bool] = Field(default=None, description="是否启用通知")
|
||||
IfSendStatistic: Optional[bool] = Field(
|
||||
default=None, description="是否发送统计信息"
|
||||
)
|
||||
IfSendSixStar: Optional[bool] = Field(default=None, description="是否发送高资喜报")
|
||||
IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件通知")
|
||||
ToAddress: Optional[str] = Field(default=None, description="邮件接收地址")
|
||||
IfServerChan: Optional[bool] = Field(
|
||||
default=None, description="是否使用Server酱推送"
|
||||
)
|
||||
ServerChanKey: Optional[str] = Field(default=None, description="ServerChanKey")
|
||||
|
||||
|
||||
class GeneralUserConfig_Notify(BaseModel):
|
||||
Enabled: Optional[bool] = Field(default=None, description="是否启用通知")
|
||||
IfSendStatistic: Optional[bool] = Field(
|
||||
default=None, description="是否发送统计信息"
|
||||
)
|
||||
IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件通知")
|
||||
ToAddress: Optional[str] = Field(default=None, description="邮件接收地址")
|
||||
IfServerChan: Optional[bool] = Field(
|
||||
default=None, description="是否使用Server酱推送"
|
||||
)
|
||||
ServerChanKey: Optional[str] = Field(default=None, description="ServerChanKey")
|
||||
IfCompanyWebHookBot: Optional[bool] = Field(
|
||||
default=None, description="是否使用Webhook推送"
|
||||
)
|
||||
CompanyWebHookBotUrl: Optional[str] = Field(
|
||||
default=None, description="企微Webhook Bot URL"
|
||||
)
|
||||
|
||||
|
||||
class MaaUserConfig(BaseModel):
|
||||
Info: Optional[MaaUserConfig_Info] = Field(default=None, description="基础信息")
|
||||
Data: Optional[MaaUserConfig_Data] = Field(default=None, description="用户数据")
|
||||
Task: Optional[MaaUserConfig_Task] = Field(default=None, description="任务列表")
|
||||
Notify: Optional[MaaUserConfig_Notify] = Field(default=None, description="单独通知")
|
||||
|
||||
|
||||
class MaaConfig_Info(BaseModel):
|
||||
Name: Optional[str] = Field(default=None, description="脚本名称")
|
||||
Path: Optional[str] = Field(default=None, description="脚本路径")
|
||||
|
||||
|
||||
class MaaConfig_Run(BaseModel):
|
||||
TaskTransitionMethod: Optional[Literal["NoAction", "ExitGame", "ExitEmulator"]] = (
|
||||
Field(default=None, description="简洁任务间切换方式")
|
||||
)
|
||||
ProxyTimesLimit: Optional[int] = Field(default=None, description="每日代理次数限制")
|
||||
ADBSearchRange: Optional[int] = Field(default=None, description="ADB端口搜索范围")
|
||||
RunTimesLimit: Optional[int] = Field(default=None, description="重试次数限制")
|
||||
AnnihilationTimeLimit: Optional[int] = Field(
|
||||
default=None, description="剿灭超时限制"
|
||||
)
|
||||
RoutineTimeLimit: Optional[int] = Field(default=None, description="日常超时限制")
|
||||
AnnihilationWeeklyLimit: Optional[bool] = Field(
|
||||
default=None, description="剿灭每周仅代理至上限"
|
||||
)
|
||||
|
||||
|
||||
class MaaConfig(BaseModel):
|
||||
Info: Optional[MaaConfig_Info] = Field(default=None, description="脚本基础信息")
|
||||
Run: Optional[MaaConfig_Run] = Field(default=None, description="脚本运行配置")
|
||||
|
||||
|
||||
class GeneralUserConfig_Info(BaseModel):
|
||||
|
||||
Name: Optional[str] = Field(default=None, description="用户名")
|
||||
Status: Optional[bool] = Field(default=None, description="用户状态")
|
||||
RemainedDay: Optional[int] = Field(default=None, description="剩余天数")
|
||||
IfScriptBeforeTask: Optional[bool] = Field(
|
||||
default=None, description="是否在任务前执行脚本"
|
||||
)
|
||||
ScriptBeforeTask: Optional[str] = Field(default=None, description="任务前脚本路径")
|
||||
IfScriptAfterTask: Optional[bool] = Field(
|
||||
default=None, description="是否在任务后执行脚本"
|
||||
)
|
||||
ScriptAfterTask: Optional[str] = Field(default=None, description="任务后脚本路径")
|
||||
Notes: Optional[str] = Field(default=None, description="备注")
|
||||
|
||||
|
||||
class GeneralUserConfig_Data(BaseModel):
|
||||
LastProxyDate: Optional[str] = Field(default=None, description="上次代理日期")
|
||||
ProxyTimes: Optional[int] = Field(default=None, description="代理次数")
|
||||
|
||||
|
||||
class GeneralUserConfig(BaseModel):
|
||||
Info: Optional[GeneralUserConfig_Info] = Field(default=None, description="用户信息")
|
||||
Data: Optional[GeneralUserConfig_Data] = Field(default=None, description="用户数据")
|
||||
Notify: Optional[GeneralUserConfig_Notify] = Field(
|
||||
default=None, description="单独通知"
|
||||
)
|
||||
|
||||
|
||||
class GeneralConfig_Info(BaseModel):
|
||||
Name: Optional[str] = Field(default=None, description="脚本名称")
|
||||
RootPath: Optional[str] = Field(default=None, description="脚本根目录")
|
||||
|
||||
|
||||
class GeneralConfig_Script(BaseModel):
|
||||
ScriptPath: Optional[str] = Field(default=None, description="脚本可执行文件路径")
|
||||
Arguments: Optional[str] = Field(default=None, description="脚本启动附加命令参数")
|
||||
IfTrackProcess: Optional[bool] = Field(
|
||||
default=None, description="是否追踪脚本子进程"
|
||||
)
|
||||
ConfigPath: Optional[str] = Field(default=None, description="配置文件路径")
|
||||
ConfigPathMode: Optional[Literal["File", "Folder"]] = Field(
|
||||
default=None, description="配置文件类型: 单个文件, 文件夹"
|
||||
)
|
||||
UpdateConfigMode: Optional[Literal["Never", "Success", "Failure", "Always"]] = (
|
||||
Field(
|
||||
default=None,
|
||||
description="更新配置时机, 从不, 仅成功时, 仅失败时, 任务结束时",
|
||||
)
|
||||
)
|
||||
LogPath: Optional[str] = Field(default=None, description="日志文件路径")
|
||||
LogPathFormat: Optional[str] = Field(default=None, description="日志文件名格式")
|
||||
LogTimeStart: Optional[int] = Field(default=None, description="日志时间戳开始位置")
|
||||
LogTimeEnd: Optional[int] = Field(default=None, description="日志时间戳结束位置")
|
||||
LogTimeFormat: Optional[str] = Field(default=None, description="日志时间戳格式")
|
||||
SuccessLog: Optional[str] = Field(default=None, description="成功时日志")
|
||||
ErrorLog: Optional[str] = Field(default=None, description="错误时日志")
|
||||
|
||||
|
||||
class GeneralConfig_Game(BaseModel):
|
||||
Enabled: Optional[bool] = Field(
|
||||
default=None, description="游戏/模拟器相关功能是否启用"
|
||||
)
|
||||
Type: Optional[Literal["Emulator", "Client"]] = Field(
|
||||
default=None, description="类型: 模拟器, PC端"
|
||||
)
|
||||
Path: Optional[str] = Field(default=None, description="游戏/模拟器程序路径")
|
||||
Arguments: Optional[str] = Field(default=None, description="游戏/模拟器启动参数")
|
||||
WaitTime: Optional[int] = Field(default=None, description="游戏/模拟器等待启动时间")
|
||||
IfForceClose: Optional[bool] = Field(
|
||||
default=None, description="是否强制关闭游戏/模拟器进程"
|
||||
)
|
||||
|
||||
|
||||
class GeneralConfig_Run(BaseModel):
|
||||
ProxyTimesLimit: Optional[int] = Field(default=None, description="每日代理次数限制")
|
||||
RunTimesLimit: Optional[int] = Field(default=None, description="重试次数限制")
|
||||
RunTimeLimit: Optional[int] = Field(default=None, description="日志超时限制")
|
||||
|
||||
|
||||
class GeneralConfig(BaseModel):
|
||||
|
||||
Info: Optional[GeneralConfig_Info] = Field(default=None, description="脚本基础信息")
|
||||
Script: Optional[GeneralConfig_Script] = Field(default=None, description="脚本配置")
|
||||
Game: Optional[GeneralConfig_Game] = Field(default=None, description="游戏配置")
|
||||
Run: Optional[GeneralConfig_Run] = Field(default=None, description="运行配置")
|
||||
|
||||
|
||||
class PlanIndexItem(BaseModel):
|
||||
uid: str = Field(..., description="唯一标识符")
|
||||
type: Literal["MaaPlanConfig"] = Field(..., description="配置类型")
|
||||
|
||||
|
||||
class MaaPlanConfig_Info(BaseModel):
|
||||
Name: Optional[str] = Field(default=None, description="计划表名称")
|
||||
Mode: Optional[Literal["ALL", "Weekly"]] = Field(
|
||||
default=None, description="计划表模式"
|
||||
)
|
||||
|
||||
|
||||
class MaaPlanConfig_Item(BaseModel):
|
||||
MedicineNumb: Optional[int] = Field(default=None, description="吃理智药")
|
||||
SeriesNumb: Optional[Literal["0", "6", "5", "4", "3", "2", "1", "-1"]] = Field(
|
||||
None, description="连战次数"
|
||||
)
|
||||
Stage: Optional[str] = Field(default=None, description="关卡选择")
|
||||
Stage_1: Optional[str] = Field(default=None, description="备选关卡 - 1")
|
||||
Stage_2: Optional[str] = Field(default=None, description="备选关卡 - 2")
|
||||
Stage_3: Optional[str] = Field(default=None, description="备选关卡 - 3")
|
||||
Stage_Remain: Optional[str] = Field(default=None, description="剩余理智关卡")
|
||||
|
||||
|
||||
class MaaPlanConfig(BaseModel):
|
||||
|
||||
Info: Optional[MaaPlanConfig_Info] = Field(default=None, description="基础信息")
|
||||
ALL: Optional[MaaPlanConfig_Item] = Field(default=None, description="全局")
|
||||
Monday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周一")
|
||||
Tuesday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周二")
|
||||
Wednesday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周三")
|
||||
Thursday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周四")
|
||||
Friday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周五")
|
||||
Saturday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周六")
|
||||
Sunday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周日")
|
||||
|
||||
|
||||
class HistoryIndexItem(BaseModel):
|
||||
date: str = Field(..., description="日期")
|
||||
status: Literal["完成", "异常"] = Field(..., description="状态")
|
||||
jsonFile: str = Field(..., description="对应JSON文件")
|
||||
|
||||
|
||||
class HistoryData(BaseModel):
|
||||
index: Optional[List[HistoryIndexItem]] = Field(
|
||||
default=None, description="历史记录索引列表"
|
||||
)
|
||||
recruit_statistics: Optional[Dict[str, int]] = Field(
|
||||
default=None, description="公招统计数据, key为星级, value为对应的公招数量"
|
||||
)
|
||||
drop_statistics: Optional[Dict[str, Dict[str, int]]] = Field(
|
||||
default=None,
|
||||
description="掉落统计数据, 格式为 { '关卡号': { '掉落物': 数量 } }",
|
||||
)
|
||||
error_info: Optional[Dict[str, str]] = Field(
|
||||
default=None, description="报错信息, key为时间戳, value为错误描述"
|
||||
)
|
||||
log_content: Optional[str] = Field(
|
||||
default=None, description="日志内容, 仅在提取单条历史记录数据时返回"
|
||||
)
|
||||
|
||||
|
||||
class ScriptCreateIn(BaseModel):
|
||||
type: Literal["MAA", "General"] = Field(
|
||||
..., description="脚本类型: MAA脚本, 通用脚本"
|
||||
)
|
||||
|
||||
|
||||
class ScriptCreateOut(OutBase):
|
||||
scriptId: str = Field(..., description="新创建的脚本ID")
|
||||
data: Union[MaaConfig, GeneralConfig] = Field(..., description="脚本配置数据")
|
||||
|
||||
|
||||
class ScriptGetIn(BaseModel):
|
||||
scriptId: Optional[str] = Field(
|
||||
default=None, description="脚本ID, 未携带时表示获取所有脚本数据"
|
||||
)
|
||||
|
||||
|
||||
class ScriptGetOut(OutBase):
|
||||
index: List[ScriptIndexItem] = Field(..., description="脚本索引列表")
|
||||
data: Dict[str, Union[MaaConfig, GeneralConfig]] = Field(
|
||||
..., description="脚本数据字典, key来自于index列表的uid"
|
||||
)
|
||||
|
||||
|
||||
class ScriptUpdateIn(BaseModel):
|
||||
scriptId: str = Field(..., description="脚本ID")
|
||||
data: Union[MaaConfig, GeneralConfig] = Field(..., description="脚本更新数据")
|
||||
|
||||
|
||||
class ScriptDeleteIn(BaseModel):
|
||||
scriptId: str = Field(..., description="脚本ID")
|
||||
|
||||
|
||||
class ScriptReorderIn(BaseModel):
|
||||
indexList: List[str] = Field(..., description="脚本ID列表, 按新顺序排列")
|
||||
|
||||
|
||||
class ScriptFileIn(BaseModel):
|
||||
scriptId: str = Field(..., description="脚本ID")
|
||||
jsonFile: str = Field(..., description="配置文件路径")
|
||||
|
||||
|
||||
class ScriptUrlIn(BaseModel):
|
||||
scriptId: str = Field(..., description="脚本ID")
|
||||
url: str = Field(..., description="配置文件URL")
|
||||
|
||||
|
||||
class ScriptUploadIn(BaseModel):
|
||||
scriptId: str = Field(..., description="脚本ID")
|
||||
config_name: str = Field(..., description="配置名称")
|
||||
author: str = Field(..., description="作者")
|
||||
description: str = Field(..., description="描述")
|
||||
|
||||
|
||||
class UserInBase(BaseModel):
|
||||
scriptId: str = Field(..., description="所属脚本ID")
|
||||
|
||||
|
||||
class UserGetIn(UserInBase):
|
||||
userId: Optional[str] = Field(
|
||||
default=None, description="用户ID, 未携带时表示获取所有用户数据"
|
||||
)
|
||||
|
||||
|
||||
class UserGetOut(OutBase):
|
||||
index: List[UserIndexItem] = Field(..., description="用户索引列表")
|
||||
data: Dict[str, Union[MaaUserConfig, GeneralUserConfig]] = Field(
|
||||
..., description="用户数据字典, key来自于index列表的uid"
|
||||
)
|
||||
|
||||
|
||||
class UserCreateOut(OutBase):
|
||||
userId: str = Field(..., description="新创建的用户ID")
|
||||
data: Union[MaaUserConfig, GeneralUserConfig] = Field(
|
||||
..., description="用户配置数据"
|
||||
)
|
||||
|
||||
|
||||
class UserUpdateIn(UserInBase):
|
||||
userId: str = Field(..., description="用户ID")
|
||||
data: Union[MaaUserConfig, GeneralUserConfig] = Field(
|
||||
..., description="用户更新数据"
|
||||
)
|
||||
|
||||
|
||||
class UserDeleteIn(UserInBase):
|
||||
userId: str = Field(..., description="用户ID")
|
||||
|
||||
|
||||
class UserReorderIn(UserInBase):
|
||||
indexList: List[str] = Field(..., description="用户ID列表, 按新顺序排列")
|
||||
|
||||
|
||||
class UserSetIn(UserInBase):
|
||||
userId: str = Field(..., description="用户ID")
|
||||
jsonFile: str = Field(..., description="JSON文件路径, 用于导入自定义基建文件")
|
||||
|
||||
|
||||
class WebhookInBase(BaseModel):
|
||||
scriptId: Optional[str] = Field(
|
||||
default=None, description="所属脚本ID, 获取全局设置的Webhook数据时无需携带"
|
||||
)
|
||||
userId: Optional[str] = Field(
|
||||
default=None, description="所属用户ID, 获取全局设置的Webhook数据时无需携带"
|
||||
)
|
||||
|
||||
|
||||
class WebhookGetIn(WebhookInBase):
|
||||
webhookId: Optional[str] = Field(
|
||||
default=None, description="Webhook ID, 未携带时表示获取所有Webhook数据"
|
||||
)
|
||||
|
||||
|
||||
class WebhookGetOut(OutBase):
|
||||
index: List[WebhookIndexItem] = Field(..., description="Webhook索引列表")
|
||||
data: Dict[str, Webhook] = Field(
|
||||
..., description="Webhook数据字典, key来自于index列表的uid"
|
||||
)
|
||||
|
||||
|
||||
class WebhookCreateOut(OutBase):
|
||||
webhookId: str = Field(..., description="新创建的Webhook ID")
|
||||
data: Webhook = Field(..., description="Webhook配置数据")
|
||||
|
||||
|
||||
class WebhookUpdateIn(WebhookInBase):
|
||||
webhookId: str = Field(..., description="Webhook ID")
|
||||
data: Webhook = Field(..., description="Webhook更新数据")
|
||||
|
||||
|
||||
class WebhookDeleteIn(WebhookInBase):
|
||||
webhookId: str = Field(..., description="Webhook ID")
|
||||
|
||||
|
||||
class WebhookReorderIn(WebhookInBase):
|
||||
indexList: List[str] = Field(..., description="Webhook ID列表, 按新顺序排列")
|
||||
|
||||
|
||||
class WebhookTestIn(WebhookInBase):
|
||||
data: Webhook = Field(..., description="Webhook配置数据")
|
||||
|
||||
|
||||
class PlanCreateIn(BaseModel):
|
||||
type: Literal["MaaPlan"]
|
||||
|
||||
|
||||
class PlanCreateOut(OutBase):
|
||||
planId: str = Field(..., description="新创建的计划ID")
|
||||
data: MaaPlanConfig = Field(..., description="计划配置数据")
|
||||
|
||||
|
||||
class PlanGetIn(BaseModel):
|
||||
planId: Optional[str] = Field(
|
||||
default=None, description="计划ID, 未携带时表示获取所有计划数据"
|
||||
)
|
||||
|
||||
|
||||
class PlanGetOut(OutBase):
|
||||
index: List[PlanIndexItem] = Field(..., description="计划索引列表")
|
||||
data: Dict[str, MaaPlanConfig] = Field(..., description="计划列表或单个计划数据")
|
||||
|
||||
|
||||
class PlanUpdateIn(BaseModel):
|
||||
planId: str = Field(..., description="计划ID")
|
||||
data: MaaPlanConfig = Field(..., description="计划更新数据")
|
||||
|
||||
|
||||
class PlanDeleteIn(BaseModel):
|
||||
planId: str = Field(..., description="计划ID")
|
||||
|
||||
|
||||
class PlanReorderIn(BaseModel):
|
||||
indexList: List[str] = Field(..., description="计划ID列表, 按新顺序排列")
|
||||
|
||||
|
||||
class QueueCreateOut(OutBase):
|
||||
queueId: str = Field(..., description="新创建的队列ID")
|
||||
data: QueueConfig = Field(..., description="队列配置数据")
|
||||
|
||||
|
||||
class QueueGetIn(BaseModel):
|
||||
queueId: Optional[str] = Field(
|
||||
default=None, description="队列ID, 未携带时表示获取所有队列数据"
|
||||
)
|
||||
|
||||
|
||||
class QueueGetOut(OutBase):
|
||||
index: List[QueueIndexItem] = Field(..., description="队列索引列表")
|
||||
data: Dict[str, QueueConfig] = Field(
|
||||
..., description="队列数据字典, key来自于index列表的uid"
|
||||
)
|
||||
|
||||
|
||||
class QueueUpdateIn(BaseModel):
|
||||
queueId: str = Field(..., description="队列ID")
|
||||
data: QueueConfig = Field(..., description="队列更新数据")
|
||||
|
||||
|
||||
class QueueDeleteIn(BaseModel):
|
||||
queueId: str = Field(..., description="队列ID")
|
||||
|
||||
|
||||
class QueueReorderIn(BaseModel):
|
||||
indexList: List[str] = Field(..., description="按新顺序排列的调度队列UID列表")
|
||||
|
||||
|
||||
class QueueSetInBase(BaseModel):
|
||||
queueId: str = Field(..., description="所属队列ID")
|
||||
|
||||
|
||||
class TimeSetGetIn(QueueSetInBase):
|
||||
timeSetId: Optional[str] = Field(
|
||||
default=None, description="时间设置ID, 未携带时表示获取所有时间设置数据"
|
||||
)
|
||||
|
||||
|
||||
class TimeSetGetOut(OutBase):
|
||||
index: List[TimeSetIndexItem] = Field(..., description="时间设置索引列表")
|
||||
data: Dict[str, TimeSet] = Field(
|
||||
..., description="时间设置数据字典, key来自于index列表的uid"
|
||||
)
|
||||
|
||||
|
||||
class TimeSetCreateOut(OutBase):
|
||||
timeSetId: str = Field(..., description="新创建的时间设置ID")
|
||||
data: TimeSet = Field(..., description="时间设置配置数据")
|
||||
|
||||
|
||||
class TimeSetUpdateIn(QueueSetInBase):
|
||||
timeSetId: str = Field(..., description="时间设置ID")
|
||||
data: TimeSet = Field(..., description="时间设置更新数据")
|
||||
|
||||
|
||||
class TimeSetDeleteIn(QueueSetInBase):
|
||||
timeSetId: str = Field(..., description="时间设置ID")
|
||||
|
||||
|
||||
class TimeSetReorderIn(QueueSetInBase):
|
||||
indexList: List[str] = Field(..., description="时间设置ID列表, 按新顺序排列")
|
||||
|
||||
|
||||
class QueueItemGetIn(QueueSetInBase):
|
||||
queueItemId: Optional[str] = Field(
|
||||
default=None, description="队列项ID, 未携带时表示获取所有队列项数据"
|
||||
)
|
||||
|
||||
|
||||
class QueueItemGetOut(OutBase):
|
||||
index: List[QueueItemIndexItem] = Field(..., description="队列项索引列表")
|
||||
data: Dict[str, QueueItem] = Field(
|
||||
..., description="队列项数据字典, key来自于index列表的uid"
|
||||
)
|
||||
|
||||
|
||||
class QueueItemCreateOut(OutBase):
|
||||
queueItemId: str = Field(..., description="新创建的队列项ID")
|
||||
data: QueueItem = Field(..., description="队列项配置数据")
|
||||
|
||||
|
||||
class QueueItemUpdateIn(QueueSetInBase):
|
||||
queueItemId: str = Field(..., description="队列项ID")
|
||||
data: QueueItem = Field(..., description="队列项更新数据")
|
||||
|
||||
|
||||
class QueueItemDeleteIn(QueueSetInBase):
|
||||
queueItemId: str = Field(..., description="队列项ID")
|
||||
|
||||
|
||||
class QueueItemReorderIn(QueueSetInBase):
|
||||
indexList: List[str] = Field(..., description="队列项ID列表, 按新顺序排列")
|
||||
|
||||
|
||||
class DispatchIn(BaseModel):
|
||||
taskId: str = Field(
|
||||
...,
|
||||
description="目标任务ID, 设置类任务可选对应脚本ID或用户ID, 代理类任务可选对应队列ID或脚本ID",
|
||||
)
|
||||
|
||||
|
||||
class TaskCreateIn(DispatchIn):
|
||||
mode: Literal["自动代理", "人工排查", "设置脚本"] = Field(
|
||||
..., description="任务模式"
|
||||
)
|
||||
|
||||
|
||||
class TaskCreateOut(OutBase):
|
||||
websocketId: str = Field(..., description="新创建的任务ID")
|
||||
|
||||
|
||||
class WebSocketMessage(BaseModel):
|
||||
id: str = Field(..., description="消息ID, 为Main时表示消息来自主进程")
|
||||
type: Literal["Update", "Message", "Info", "Signal"] = Field(
|
||||
...,
|
||||
description="消息类型 Update: 更新数据, Message: 请求弹出对话框, Info: 需要在UI显示的消息, Signal: 程序信号",
|
||||
)
|
||||
data: Dict[str, Any] = Field(..., description="消息数据, 具体内容根据type类型而定")
|
||||
|
||||
|
||||
class PowerIn(BaseModel):
|
||||
signal: Literal[
|
||||
"NoAction", "Shutdown", "ShutdownForce", "Hibernate", "Sleep", "KillSelf"
|
||||
] = Field(..., description="电源操作信号")
|
||||
|
||||
|
||||
class HistorySearchIn(BaseModel):
|
||||
mode: Literal["按日合并", "按周合并", "按月合并"] = Field(
|
||||
..., description="合并模式"
|
||||
)
|
||||
start_date: str = Field(..., description="开始日期, 格式YYYY-MM-DD")
|
||||
end_date: str = Field(..., description="结束日期, 格式YYYY-MM-DD")
|
||||
|
||||
|
||||
class HistorySearchOut(OutBase):
|
||||
data: Dict[str, Dict[str, HistoryData]] = Field(
|
||||
...,
|
||||
description="历史记录索引数据字典, 格式为 { '日期': { '用户名': [历史记录信息] } }",
|
||||
)
|
||||
|
||||
|
||||
class HistoryDataGetIn(BaseModel):
|
||||
jsonPath: str = Field(..., description="需要提取数据的历史记录JSON文件")
|
||||
|
||||
|
||||
class HistoryDataGetOut(OutBase):
|
||||
data: HistoryData = Field(..., description="历史记录数据")
|
||||
|
||||
|
||||
class SettingGetOut(OutBase):
|
||||
data: GlobalConfig = Field(..., description="全局设置数据")
|
||||
|
||||
|
||||
class SettingUpdateIn(BaseModel):
|
||||
data: GlobalConfig = Field(..., description="全局设置需要更新的数据")
|
||||
|
||||
|
||||
class UpdateCheckIn(BaseModel):
|
||||
current_version: str = Field(..., description="当前前端版本号")
|
||||
if_force: bool = Field(default=False, description="是否强制拉取更新信息")
|
||||
|
||||
|
||||
class UpdateCheckOut(OutBase):
|
||||
if_need_update: bool = Field(..., description="是否需要更新前端")
|
||||
latest_version: str = Field(..., description="最新前端版本号")
|
||||
update_info: Dict[str, List[str]] = Field(..., description="版本更新信息字典")
|
||||
@@ -1,31 +1,37 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# 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-MAS is distributed in the hope that it will be useful,
|
||||
# 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
__version__ = "5.0.0"
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA服务包
|
||||
v4.4
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
__version__ = "4.2.0"
|
||||
__author__ = "DLmaster361 <DLmaster_361@163.com>"
|
||||
__license__ = "GPL-3.0 license"
|
||||
|
||||
from .matomo import Matomo
|
||||
from .notification import Notify
|
||||
from .security import Crypto
|
||||
from .system import System
|
||||
from .update import Updater
|
||||
from .skland import skland_sign_in
|
||||
|
||||
__all__ = ["Matomo", "Notify", "System", "Updater"]
|
||||
__all__ = ["Notify", "Crypto", "System", "skland_sign_in"]
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import json
|
||||
import uuid
|
||||
import platform
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from app.core import Config
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("信息上报")
|
||||
|
||||
|
||||
class _MatomoHandler:
|
||||
"""Matomo统计上报服务"""
|
||||
|
||||
base_url = "https://statistics.auto-mas.top/matomo.php"
|
||||
site_id = "3"
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self.session = None
|
||||
|
||||
async def _get_session(self):
|
||||
"""获取HTTP会话"""
|
||||
|
||||
if self.session is None or self.session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
self.session = aiohttp.ClientSession(timeout=timeout)
|
||||
return self.session
|
||||
|
||||
async def close(self):
|
||||
"""关闭HTTP会话"""
|
||||
if self.session and not self.session.closed:
|
||||
await self.session.close()
|
||||
|
||||
def _build_base_params(self, custom_vars: Optional[Dict[str, Any]] = None):
|
||||
"""构建基础参数"""
|
||||
params = {
|
||||
"idsite": self.site_id,
|
||||
"rec": "1",
|
||||
"action_name": "AUTO-MAS后端",
|
||||
"_id": Config.get("Data", "UID")[:16],
|
||||
"uid": Config.get("Data", "UID"),
|
||||
"rand": str(uuid.uuid4().int)[:10],
|
||||
"apiv": "1",
|
||||
"h": time.strftime("%H"),
|
||||
"m": time.strftime("%M"),
|
||||
"s": time.strftime("%S"),
|
||||
"ua": f"AUTO-MAS/{Config.version()} ({platform.system()} {platform.release()})",
|
||||
}
|
||||
|
||||
# 添加自定义变量
|
||||
if custom_vars is not None:
|
||||
cvar = {}
|
||||
for i, (key, value) in enumerate(custom_vars.items(), 1):
|
||||
if i <= 5:
|
||||
cvar[str(i)] = [str(key), str(value)]
|
||||
if cvar:
|
||||
params["_cvar"] = json.dumps(cvar)
|
||||
|
||||
return params
|
||||
|
||||
async def send_event(
|
||||
self,
|
||||
category: str,
|
||||
action: str,
|
||||
name: Optional[str] = None,
|
||||
value: Optional[float] = None,
|
||||
custom_vars: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""发送事件数据到Matomo
|
||||
|
||||
Args:
|
||||
category: 事件类别,如 "Script", "Config", "User"
|
||||
action: 事件动作,如 "Execute", "Update", "Login"
|
||||
name: 事件名称,如具体的脚本名称
|
||||
value: 事件值,如执行时长、文件大小等数值
|
||||
custom_vars: 自定义变量字典
|
||||
"""
|
||||
try:
|
||||
session = await self._get_session()
|
||||
if session is None:
|
||||
return
|
||||
|
||||
params = self._build_base_params(custom_vars)
|
||||
params.update({"e_c": category, "e_a": action, "e_n": name, "e_v": value})
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
async with session.get(self.base_url, params=params) as response:
|
||||
if response.status == 200:
|
||||
logger.debug(f"Matomo事件上报成功: {category}/{action}")
|
||||
else:
|
||||
logger.warning(f"Matomo事件上报失败: {response.status}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Matomo事件上报超时")
|
||||
except Exception as e:
|
||||
logger.error(f"Matomo事件上报错误: {e}")
|
||||
|
||||
|
||||
Matomo = _MatomoHandler()
|
||||
@@ -1,431 +1,484 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# 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-MAS is distributed in the hope that it will be useful,
|
||||
# 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
# 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 json
|
||||
import smtplib
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from plyer import notification
|
||||
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 Literal
|
||||
from typing import Union
|
||||
|
||||
from app.core import Config
|
||||
from app.models.config import Webhook
|
||||
from app.utils import get_logger, ImageUtils
|
||||
import requests
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
logger = get_logger("通知服务")
|
||||
from plyer import notification
|
||||
|
||||
from app.core import Config, logger
|
||||
from app.services.security import Crypto
|
||||
from app.utils.ImageUtils import ImageUtils
|
||||
|
||||
|
||||
class Notification:
|
||||
class Notification(QObject):
|
||||
|
||||
async def push_plyer(self, title: str, message: str, ticker: str, t: int) -> None:
|
||||
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:
|
||||
"""
|
||||
推送系统通知
|
||||
|
||||
Parameters
|
||||
----------
|
||||
title: str
|
||||
通知标题
|
||||
message: str
|
||||
通知内容
|
||||
ticker: str
|
||||
通知横幅
|
||||
t: int
|
||||
通知持续时间
|
||||
:param title: 通知标题
|
||||
:param message: 通知内容
|
||||
:param ticker: 通知横幅
|
||||
:param t: 通知持续时间
|
||||
:return: bool
|
||||
"""
|
||||
|
||||
if not Config.get("Notify", "IfPushPlyer"):
|
||||
return
|
||||
if Config.get(Config.notify_IfPushPlyer):
|
||||
|
||||
logger.info(f"推送系统通知: {title}")
|
||||
logger.info(f"推送系统通知:{title}", module="通知服务")
|
||||
|
||||
if notification.notify is not None:
|
||||
notification.notify(
|
||||
title=title,
|
||||
message=message,
|
||||
app_name="AUTO-MAS",
|
||||
app_icon=(Path.cwd() / "res/icons/AUTO-MAS.ico").as_posix(),
|
||||
app_name="AUTO_MAA",
|
||||
app_icon=str(Config.app_path / "resources/icons/AUTO_MAA.ico"),
|
||||
timeout=t,
|
||||
ticker=ticker,
|
||||
toast=True,
|
||||
)
|
||||
else:
|
||||
logger.error("plyer.notification 未正确导入, 无法推送系统通知")
|
||||
|
||||
async def send_mail(
|
||||
self, mode: Literal["文本", "网页"], title: str, content: str, to_address: str
|
||||
) -> None:
|
||||
return True
|
||||
|
||||
def send_mail(self, mode, title, content, to_address) -> None:
|
||||
"""
|
||||
推送邮件通知
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mode: Literal["文本", "网页"]
|
||||
邮件内容模式, 支持 "文本" 和 "网页"
|
||||
title: str
|
||||
邮件标题
|
||||
content: str
|
||||
邮件内容
|
||||
to_address: str
|
||||
收件人地址
|
||||
:param mode: 邮件内容模式,支持 "文本" 和 "网页"
|
||||
:param title: 邮件标题
|
||||
:param content: 邮件内容
|
||||
:param to_address: 收件人地址
|
||||
"""
|
||||
|
||||
if Config.get("Notify", "SMTPServerAddress") == "":
|
||||
raise ValueError("邮件通知的SMTP服务器地址不能为空")
|
||||
if Config.get("Notify", "AuthorizationCode") == "":
|
||||
raise ValueError("邮件通知的授权码不能为空")
|
||||
if not bool(
|
||||
re.match(
|
||||
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
|
||||
Config.get("Notify", "FromAddress"),
|
||||
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,
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError("邮件通知的发送邮箱格式错误或为空")
|
||||
if not bool(
|
||||
re.match(
|
||||
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
|
||||
to_address,
|
||||
logger.error(
|
||||
"请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址",
|
||||
module="通知服务",
|
||||
)
|
||||
):
|
||||
raise ValueError("邮件通知的接收邮箱格式错误或为空")
|
||||
|
||||
# 定义邮件正文
|
||||
if mode == "文本":
|
||||
message = MIMEText(content, "plain", "utf-8")
|
||||
elif mode == "网页":
|
||||
message = MIMEMultipart("alternative")
|
||||
message["From"] = formataddr(
|
||||
(
|
||||
Header("AUTO-MAS通知服务", "utf-8").encode(),
|
||||
Config.get("Notify", "FromAddress"),
|
||||
self.push_info_bar.emit(
|
||||
"error",
|
||||
"邮件通知推送异常",
|
||||
"请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址",
|
||||
-1,
|
||||
)
|
||||
) # 发件人显示的名字
|
||||
message["To"] = formataddr(
|
||||
(Header("AUTO-MAS用户", "utf-8").encode(), to_address)
|
||||
) # 收件人显示的名字
|
||||
message["Subject"] = str(Header(title, "utf-8"))
|
||||
return None
|
||||
|
||||
if mode == "网页":
|
||||
message.attach(MIMEText(content, "html", "utf-8"))
|
||||
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")
|
||||
|
||||
smtpObj = smtplib.SMTP_SSL(Config.get("Notify", "SMTPServerAddress"), 465)
|
||||
smtpObj.login(
|
||||
Config.get("Notify", "FromAddress"),
|
||||
Config.get("Notify", "AuthorizationCode"),
|
||||
)
|
||||
smtpObj.sendmail(
|
||||
Config.get("Notify", "FromAddress"), to_address, message.as_string()
|
||||
)
|
||||
smtpObj.quit()
|
||||
logger.success(f"邮件发送成功: {title}")
|
||||
if mode == "网页":
|
||||
message.attach(MIMEText(content, "html", "utf-8"))
|
||||
|
||||
async def ServerChanPush(self, title: str, content: str, send_key: str) -> None:
|
||||
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酱推送通知
|
||||
|
||||
Parameters
|
||||
----------
|
||||
title: str
|
||||
通知标题
|
||||
content: str
|
||||
通知内容
|
||||
send_key: str
|
||||
Server酱的SendKey
|
||||
:param title: 通知标题
|
||||
:param content: 通知内容
|
||||
:param send_key: Server酱的SendKey
|
||||
:param tag: 通知标签
|
||||
:param channel: 通知频道
|
||||
:return: bool or str
|
||||
"""
|
||||
|
||||
if send_key == "":
|
||||
raise ValueError("ServerChan SendKey 不能为空")
|
||||
if not send_key:
|
||||
logger.error("请正确设置Server酱的SendKey", module="通知服务")
|
||||
self.push_info_bar.emit(
|
||||
"error", "Server酱通知推送异常", "请正确设置Server酱的SendKey", -1
|
||||
)
|
||||
return None
|
||||
|
||||
# 构造 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<int>)")
|
||||
else:
|
||||
url = f"https://sctapi.ftqq.com/{send_key}.send"
|
||||
|
||||
# 请求发送
|
||||
params = {"title": title, "desp": content}
|
||||
headers = {"Content-Type": "application/json;charset=utf-8"}
|
||||
|
||||
response = requests.post(
|
||||
url, json=params, headers=headers, timeout=10, proxies=Config.get_proxies()
|
||||
)
|
||||
result = response.json()
|
||||
|
||||
if result.get("code") == 0:
|
||||
logger.success(f"Server酱推送通知成功: {title}")
|
||||
else:
|
||||
raise Exception(f"ServerChan 推送通知失败: {response.text}")
|
||||
|
||||
async def WebhookPush(self, title: str, content: str, webhook: Webhook) -> None:
|
||||
"""
|
||||
Webhook 推送通知
|
||||
|
||||
Parameters
|
||||
----------
|
||||
title: str
|
||||
通知标题
|
||||
content: str
|
||||
通知内容
|
||||
webhook: Webhook
|
||||
Webhook配置对象
|
||||
"""
|
||||
if not webhook.get("Info", "Enabled"):
|
||||
return
|
||||
|
||||
if webhook.get("Data", "Url") == "":
|
||||
raise ValueError("Webhook URL 不能为空")
|
||||
|
||||
# 解析模板
|
||||
template = (
|
||||
webhook.get("Data", "Template")
|
||||
or '{"title": "{title}", "content": "{content}"}'
|
||||
)
|
||||
|
||||
# 替换模板变量
|
||||
try:
|
||||
|
||||
# 准备模板变量
|
||||
template_vars = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"date": datetime.now().strftime("%Y-%m-%d"),
|
||||
"time": datetime.now().strftime("%H:%M:%S"),
|
||||
}
|
||||
|
||||
logger.debug(f"原始模板: {template}")
|
||||
logger.debug(f"模板变量: {template_vars}")
|
||||
|
||||
# 先尝试作为JSON模板处理
|
||||
try:
|
||||
# 解析模板为JSON对象,然后替换其中的变量
|
||||
template_obj = json.loads(template)
|
||||
|
||||
# 递归替换JSON对象中的变量
|
||||
def replace_variables(obj):
|
||||
if isinstance(obj, dict):
|
||||
return {k: replace_variables(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [replace_variables(item) for item in obj]
|
||||
elif isinstance(obj, str):
|
||||
result = obj
|
||||
for key, value in template_vars.items():
|
||||
result = result.replace(f"{{{key}}}", str(value))
|
||||
return result
|
||||
else:
|
||||
return obj
|
||||
|
||||
data = replace_variables(template_obj)
|
||||
logger.debug(f"成功解析JSON模板: {data}")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# 如果不是有效的JSON,作为字符串模板处理
|
||||
logger.debug("模板不是有效JSON,作为字符串模板处理")
|
||||
formatted_template = template
|
||||
for key, value in template_vars.items():
|
||||
# 转义特殊字符以避免JSON解析错误
|
||||
safe_value = (
|
||||
str(value)
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
)
|
||||
formatted_template = formatted_template.replace(
|
||||
f"{{{key}}}", safe_value
|
||||
)
|
||||
|
||||
# 再次尝试解析为JSON
|
||||
try:
|
||||
data = json.loads(formatted_template)
|
||||
logger.debug(f"字符串模板解析为JSON成功: {data}")
|
||||
except json.JSONDecodeError:
|
||||
# 最终作为纯文本发送
|
||||
data = formatted_template
|
||||
logger.debug(f"作为纯文本发送: {data}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"模板解析失败,使用默认格式: {e}")
|
||||
data = {"title": title, "content": content}
|
||||
|
||||
# 准备请求头
|
||||
headers = {"Content-Type": "application/json"}
|
||||
headers.update(json.loads(webhook.get("Data", "Headers")))
|
||||
|
||||
if webhook.get("Data", "Method") == "POST":
|
||||
if isinstance(data, dict):
|
||||
response = requests.post(
|
||||
url=webhook.get("Data", "Url"),
|
||||
json=data,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
proxies=Config.get_proxies(),
|
||||
)
|
||||
elif isinstance(data, str):
|
||||
response = requests.post(
|
||||
url=webhook.get("Data", "Url"),
|
||||
data=data,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
proxies=Config.get_proxies(),
|
||||
)
|
||||
elif webhook.get("Data", "Method") == "GET":
|
||||
if isinstance(data, dict):
|
||||
# Flatten params to ensure all values are str or list of str
|
||||
params = {}
|
||||
for k, v in data.items():
|
||||
if isinstance(v, (dict, list)):
|
||||
params[k] = json.dumps(v, ensure_ascii=False)
|
||||
else:
|
||||
params[k] = str(v)
|
||||
# 构造 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:
|
||||
params = {"message": str(data)}
|
||||
response = requests.get(
|
||||
url=webhook.get("Data", "Url"),
|
||||
params=params,
|
||||
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=Config.get_proxies(),
|
||||
proxies={
|
||||
"http": Config.get(Config.update_ProxyAddress),
|
||||
"https": Config.get(Config.update_ProxyAddress),
|
||||
},
|
||||
)
|
||||
result = response.json()
|
||||
|
||||
# 检查响应
|
||||
if response.status_code == 200:
|
||||
logger.success(
|
||||
f"自定义Webhook推送成功: {webhook.get('Info', 'Name')} - {title}"
|
||||
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,
|
||||
)
|
||||
else:
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
return f"Server酱通知推送异常:{str(e)}"
|
||||
|
||||
async def _WebHookPush(self, title, content, webhook_url) -> None:
|
||||
def CompanyWebHookBotPush(self, title, content, webhook_url) -> Union[bool, str]:
|
||||
"""
|
||||
WebHook 推送通知 (即将弃用)
|
||||
使用企业微信群机器人推送通知
|
||||
|
||||
:param title: 通知标题
|
||||
:param content: 通知内容
|
||||
:param webhook_url: WebHook地址
|
||||
:param webhook_url: 企业微信群机器人的WebHook地址
|
||||
:return: bool or str
|
||||
"""
|
||||
|
||||
if not webhook_url:
|
||||
raise ValueError("WebHook 地址不能为空")
|
||||
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}}
|
||||
|
||||
response = requests.post(
|
||||
url=webhook_url, json=data, timeout=10, proxies=Config.get_proxies()
|
||||
)
|
||||
info = response.json()
|
||||
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"WebHook 推送通知成功: {title}")
|
||||
logger.success(f"企业微信群机器人推送通知成功:{title}", module="通知服务")
|
||||
return True
|
||||
else:
|
||||
raise Exception(f"WebHook 推送通知失败: {response.text}")
|
||||
logger.error(f"企业微信群机器人推送通知失败:{info}", module="通知服务")
|
||||
self.push_info_bar.emit(
|
||||
"error",
|
||||
"企业微信群机器人通知推送失败",
|
||||
f"使用企业微信群机器人推送通知时出错:{err}",
|
||||
-1,
|
||||
)
|
||||
return f"使用企业微信群机器人推送通知时出错:{err}"
|
||||
|
||||
async def CompanyWebHookBotPushImage(
|
||||
self, image_path: Path, webhook_url: str
|
||||
) -> None:
|
||||
def CompanyWebHookBotPushImage(self, image_path: Path, webhook_url: str) -> bool:
|
||||
"""
|
||||
使用企业微信群机器人推送图片通知(等待重新适配)
|
||||
使用企业微信群机器人推送图片通知
|
||||
|
||||
:param image_path: 图片文件路径
|
||||
:param webhook_url: 企业微信群机器人的WebHook地址
|
||||
:return: bool
|
||||
"""
|
||||
|
||||
if not webhook_url:
|
||||
raise ValueError("webhook URL 不能为空")
|
||||
try:
|
||||
# 压缩图片
|
||||
ImageUtils.compress_image_if_needed(image_path)
|
||||
|
||||
# 压缩图片
|
||||
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 image_path.exists():
|
||||
raise FileNotFoundError(f"文件未找到: {image_path}")
|
||||
if not webhook_url:
|
||||
logger.error(
|
||||
"请正确设置企业微信群机器人的WebHook地址", module="通知服务"
|
||||
)
|
||||
self.push_info_bar.emit(
|
||||
"error",
|
||||
"企业微信群机器人通知推送异常",
|
||||
"请正确设置企业微信群机器人的WebHook地址",
|
||||
-1,
|
||||
)
|
||||
return False
|
||||
|
||||
# 获取图片base64和md5
|
||||
image_base64 = ImageUtils.get_base64_from_file(str(image_path))
|
||||
image_md5 = ImageUtils.calculate_md5_from_file(str(image_path))
|
||||
# 获取图片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},
|
||||
}
|
||||
data = {
|
||||
"msgtype": "image",
|
||||
"image": {"base64": image_base64, "md5": image_md5},
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url=webhook_url, json=data, timeout=10, proxies=Config.get_proxies()
|
||||
)
|
||||
info = response.json()
|
||||
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}")
|
||||
else:
|
||||
raise Exception(f"企业微信群机器人推送图片失败: {response.text}")
|
||||
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
|
||||
|
||||
async def send_test_notification(self) -> None:
|
||||
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("发送测试通知到所有已启用的通知渠道")
|
||||
logger.info("发送测试通知到所有已启用的通知渠道", module="通知服务")
|
||||
|
||||
# 发送系统通知
|
||||
await self.push_plyer(
|
||||
self.push_plyer(
|
||||
"测试通知",
|
||||
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
|
||||
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
|
||||
"测试通知",
|
||||
3,
|
||||
)
|
||||
|
||||
# 发送邮件通知
|
||||
if Config.get("Notify", "IfSendMail"):
|
||||
await self.send_mail(
|
||||
if Config.get(Config.notify_IfSendMail):
|
||||
self.send_mail(
|
||||
"文本",
|
||||
"AUTO-MAS测试通知",
|
||||
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
|
||||
Config.get("Notify", "ToAddress"),
|
||||
"AUTO_MAA测试通知",
|
||||
"这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!",
|
||||
Config.get(Config.notify_ToAddress),
|
||||
)
|
||||
|
||||
# 发送Server酱通知
|
||||
if Config.get("Notify", "IfServerChan"):
|
||||
await self.ServerChanPush(
|
||||
"AUTO-MAS测试通知",
|
||||
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
|
||||
Config.get("Notify", "ServerChanKey"),
|
||||
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),
|
||||
)
|
||||
|
||||
# 发送自定义Webhook通知
|
||||
for webhook in Config.Notify_CustomWebhooks.values():
|
||||
await self.WebhookPush(
|
||||
"AUTO-MAS测试通知",
|
||||
"这是 AUTO-MAS 外部通知测试信息。如果你看到了这段内容, 说明 AUTO-MAS 的通知功能已经正确配置且可以正常工作!",
|
||||
webhook,
|
||||
# 发送企业微信机器人通知
|
||||
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.success("测试通知发送完成")
|
||||
logger.info("测试通知发送完成", module="通知服务")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
Notify = Notification()
|
||||
|
||||
270
app/services/security.py
Normal file
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()
|
||||
@@ -1,47 +1,48 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 ClozyA
|
||||
|
||||
# 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
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# 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-MAS is distributed in the hope that it will be useful,
|
||||
# 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
# 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 asyncio
|
||||
import hashlib
|
||||
import requests
|
||||
from urllib import parse
|
||||
|
||||
from app.core import Config
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("森空岛签到任务")
|
||||
from app.core import Config, logger
|
||||
|
||||
|
||||
async def skland_sign_in(token) -> dict:
|
||||
def skland_sign_in(token) -> dict:
|
||||
"""森空岛签到"""
|
||||
|
||||
app_code = "4ca99fa6b56cc2ba"
|
||||
@@ -75,11 +76,11 @@ async def skland_sign_in(token) -> dict:
|
||||
|
||||
:param token_for_sign: 用于加密的token
|
||||
:param path: 请求路径(如 /api/v1/game/player/binding)
|
||||
:param body_or_query: GET用query字符串, POST用body字符串
|
||||
:param body_or_query: GET用query字符串,POST用body字符串
|
||||
:return: (sign, 新的header_for_sign字典)
|
||||
"""
|
||||
|
||||
t = str(int(time.time()) - 2) # 时间戳, -2秒以防服务器时间不一致
|
||||
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
|
||||
@@ -126,7 +127,7 @@ async def skland_sign_in(token) -> dict:
|
||||
v["cred"] = cred
|
||||
return v
|
||||
|
||||
async def login_by_token(token_code):
|
||||
def login_by_token(token_code):
|
||||
"""
|
||||
使用token一步步拿到cred和sign_token
|
||||
|
||||
@@ -139,10 +140,10 @@ async def skland_sign_in(token) -> dict:
|
||||
token_code = t["data"]["content"]
|
||||
except:
|
||||
pass
|
||||
grant_code = await get_grant_code(token_code)
|
||||
return await get_cred(grant_code)
|
||||
grant_code = get_grant_code(token_code)
|
||||
return get_cred(grant_code)
|
||||
|
||||
async def get_cred(grant):
|
||||
def get_cred(grant):
|
||||
"""
|
||||
通过grant code获取cred和sign_token
|
||||
|
||||
@@ -154,15 +155,18 @@ async def skland_sign_in(token) -> dict:
|
||||
cred_code_url,
|
||||
json={"code": grant, "kind": 1},
|
||||
headers=header_login,
|
||||
proxies=Config.get_proxies(),
|
||||
proxies={
|
||||
"http": Config.get(Config.update_ProxyAddress),
|
||||
"https": Config.get(Config.update_ProxyAddress),
|
||||
},
|
||||
).json()
|
||||
if rsp["code"] != 0:
|
||||
raise Exception(f"获得cred失败: {rsp.get('message')}")
|
||||
raise Exception(f'获得cred失败:{rsp.get("messgae")}')
|
||||
sign_token = rsp["data"]["token"]
|
||||
cred = rsp["data"]["cred"]
|
||||
return cred, sign_token
|
||||
|
||||
async def get_grant_code(token):
|
||||
def get_grant_code(token):
|
||||
"""
|
||||
通过token获取grant code
|
||||
|
||||
@@ -173,15 +177,18 @@ async def skland_sign_in(token) -> dict:
|
||||
grant_code_url,
|
||||
json={"appCode": app_code, "token": token, "type": 0},
|
||||
headers=header_login,
|
||||
proxies=Config.get_proxies(),
|
||||
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')}"
|
||||
f'使用token: {token[:3]}******{token[-3:]} 获得认证代码失败:{rsp.get("msg")}'
|
||||
)
|
||||
return rsp["data"]["code"]
|
||||
|
||||
async def get_binding_list(cred, sign_token):
|
||||
def get_binding_list(cred, sign_token):
|
||||
"""
|
||||
查询已绑定的角色列表
|
||||
|
||||
@@ -195,12 +202,21 @@ async def skland_sign_in(token) -> dict:
|
||||
headers=get_sign_header(
|
||||
binding_url, "get", None, copy_header(cred), sign_token
|
||||
),
|
||||
proxies=Config.get_proxies(),
|
||||
proxies={
|
||||
"http": Config.get(Config.update_ProxyAddress),
|
||||
"https": Config.get(Config.update_ProxyAddress),
|
||||
},
|
||||
).json()
|
||||
if rsp["code"] != 0:
|
||||
logger.error(f"请求角色列表出现问题: {rsp['message']}")
|
||||
logger.error(
|
||||
f"森空岛服务 | 请求角色列表出现问题:{rsp['message']}",
|
||||
module="森空岛签到",
|
||||
)
|
||||
if rsp.get("message") == "用户未登录":
|
||||
logger.error(f"用户登录可能失效了, 请重新登录!")
|
||||
logger.error(
|
||||
f"森空岛服务 | 用户登录可能失效了,请重新登录!",
|
||||
module="森空岛签到",
|
||||
)
|
||||
return v
|
||||
# 只取明日方舟(arknights)的绑定账号
|
||||
for i in rsp["data"]["list"]:
|
||||
@@ -209,7 +225,7 @@ async def skland_sign_in(token) -> dict:
|
||||
v.extend(i.get("bindingList"))
|
||||
return v
|
||||
|
||||
async def do_sign(cred, sign_token) -> dict:
|
||||
def do_sign(cred, sign_token) -> dict:
|
||||
"""
|
||||
对所有绑定的角色进行签到
|
||||
|
||||
@@ -218,7 +234,7 @@ async def skland_sign_in(token) -> dict:
|
||||
:return: 签到结果字典
|
||||
"""
|
||||
|
||||
characters = await get_binding_list(cred, sign_token)
|
||||
characters = get_binding_list(cred, sign_token)
|
||||
result = {"成功": [], "重复": [], "失败": [], "总计": len(characters)}
|
||||
|
||||
for character in characters:
|
||||
@@ -233,7 +249,10 @@ async def skland_sign_in(token) -> dict:
|
||||
sign_url, "post", body, copy_header(cred), sign_token
|
||||
),
|
||||
json=body,
|
||||
proxies=Config.get_proxies(),
|
||||
proxies={
|
||||
"http": Config.get(Config.update_ProxyAddress),
|
||||
"https": Config.get(Config.update_ProxyAddress),
|
||||
},
|
||||
).json()
|
||||
|
||||
if rsp["code"] != 0:
|
||||
@@ -250,17 +269,17 @@ async def skland_sign_in(token) -> dict:
|
||||
f"{character.get("nickName")}({character.get("channelName")})"
|
||||
)
|
||||
|
||||
await asyncio.sleep(3)
|
||||
time.sleep(3)
|
||||
|
||||
return result
|
||||
|
||||
# 主流程
|
||||
try:
|
||||
# 拿到cred和sign_token
|
||||
cred, sign_token = await login_by_token(token)
|
||||
await asyncio.sleep(1)
|
||||
cred, sign_token = login_by_token(token)
|
||||
time.sleep(1)
|
||||
# 依次签到
|
||||
return await do_sign(cred, sign_token)
|
||||
return do_sign(cred, sign_token)
|
||||
except Exception as e:
|
||||
logger.exception(f"森空岛签到失败: {e}")
|
||||
logger.exception(f"森空岛服务 | 森空岛签到失败: {e}", module="森空岛签到")
|
||||
return {"成功": [], "重复": [], "失败": [], "总计": 0}
|
||||
@@ -1,28 +1,33 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# 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-MAS is distributed in the hope that it will be useful,
|
||||
# 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
# 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 asyncio
|
||||
import win32gui
|
||||
import win32process
|
||||
import psutil
|
||||
@@ -31,28 +36,24 @@ import tempfile
|
||||
import getpass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import WebSocketMessage
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("系统服务")
|
||||
from app.core import Config, logger
|
||||
|
||||
|
||||
class _SystemHandler:
|
||||
|
||||
ES_CONTINUOUS = 0x80000000
|
||||
ES_SYSTEM_REQUIRED = 0x00000001
|
||||
countdown = 60
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.power_task: Optional[asyncio.Task] = None
|
||||
def __init__(self):
|
||||
|
||||
async def set_Sleep(self) -> None:
|
||||
self.set_Sleep()
|
||||
self.set_SelfStart()
|
||||
|
||||
def set_Sleep(self) -> None:
|
||||
"""同步系统休眠状态"""
|
||||
|
||||
if Config.get("Function", "IfAllowSleep"):
|
||||
if Config.get(Config.function_IfAllowSleep):
|
||||
# 设置系统电源状态
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(
|
||||
self.ES_CONTINUOUS | self.ES_SYSTEM_REQUIRED
|
||||
@@ -61,10 +62,10 @@ class _SystemHandler:
|
||||
# 恢复系统电源状态
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(self.ES_CONTINUOUS)
|
||||
|
||||
async def set_SelfStart(self) -> None:
|
||||
def set_SelfStart(self) -> None:
|
||||
"""同步开机自启"""
|
||||
|
||||
if Config.get("Start", "IfSelfStart") and not await self.is_startup():
|
||||
if Config.get(Config.start_IfSelfStart) and not self.is_startup():
|
||||
|
||||
# 创建任务计划
|
||||
try:
|
||||
@@ -79,8 +80,8 @@ class _SystemHandler:
|
||||
<RegistrationInfo>
|
||||
<Date>{current_time}</Date>
|
||||
<Author>{current_user}</Author>
|
||||
<Description>AUTO-MAS自启动服务</Description>
|
||||
<URI>\\AUTO-MAS_AutoStart</URI>
|
||||
<Description>AUTO_MAA自启动服务</Description>
|
||||
<URI>\\AUTO_MAA_AutoStart</URI>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<LogonTrigger>
|
||||
@@ -115,7 +116,7 @@ class _SystemHandler:
|
||||
</Settings>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
<Command>"{Path.cwd() / 'AUTO-MAS.exe'}"</Command>
|
||||
<Command>"{Config.app_path_sys}"</Command>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>"""
|
||||
@@ -133,7 +134,7 @@ class _SystemHandler:
|
||||
"schtasks",
|
||||
"/create",
|
||||
"/tn",
|
||||
"AUTO-MAS_AutoStart",
|
||||
"AUTO_MAA_AutoStart",
|
||||
"/xml",
|
||||
xml_file,
|
||||
"/f",
|
||||
@@ -146,10 +147,14 @@ class _SystemHandler:
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.success(
|
||||
f"程序自启动任务计划已创建: {Path.cwd() / 'AUTO-MAS.exe'}"
|
||||
f"程序自启动任务计划已创建: {Config.app_path_sys}",
|
||||
module="系统服务",
|
||||
)
|
||||
else:
|
||||
logger.error(f"程序自启动任务计划创建失败: {result.stderr}")
|
||||
logger.error(
|
||||
f"程序自启动任务计划创建失败: {result.stderr}",
|
||||
module="系统服务",
|
||||
)
|
||||
|
||||
finally:
|
||||
# 删除临时文件
|
||||
@@ -159,14 +164,14 @@ class _SystemHandler:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"程序自启动任务计划创建失败: {e}")
|
||||
logger.exception(f"程序自启动任务计划创建失败: {e}", module="系统服务")
|
||||
|
||||
elif not Config.get("Start", "IfSelfStart") and await self.is_startup():
|
||||
elif not Config.get(Config.start_IfSelfStart) and self.is_startup():
|
||||
|
||||
try:
|
||||
|
||||
result = subprocess.run(
|
||||
["schtasks", "/delete", "/tn", "AUTO-MAS_AutoStart", "/f"],
|
||||
["schtasks", "/delete", "/tn", "AUTO_MAA_AutoStart", "/f"],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
@@ -174,129 +179,90 @@ class _SystemHandler:
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.success("程序自启动任务计划已删除")
|
||||
logger.success("程序自启动任务计划已删除", module="系统服务")
|
||||
else:
|
||||
logger.error(f"程序自启动任务计划删除失败: {result.stderr}")
|
||||
logger.error(
|
||||
f"程序自启动任务计划删除失败: {result.stderr}",
|
||||
module="系统服务",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"程序自启动任务计划删除失败: {e}")
|
||||
logger.exception(f"程序自启动任务计划删除失败: {e}", module="系统服务")
|
||||
|
||||
async def set_power(
|
||||
self,
|
||||
mode: Literal[
|
||||
"NoAction", "Shutdown", "ShutdownForce", "Hibernate", "Sleep", "KillSelf"
|
||||
],
|
||||
) -> None:
|
||||
def set_power(self, mode) -> None:
|
||||
"""
|
||||
执行系统电源操作
|
||||
|
||||
:param mode: 电源操作
|
||||
:param mode: 电源操作模式,支持 "NoAction", "Shutdown", "Hibernate", "Sleep", "KillSelf", "ShutdownForce"
|
||||
"""
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
|
||||
if mode == "NoAction":
|
||||
|
||||
logger.info("不执行系统电源操作")
|
||||
logger.info("不执行系统电源操作", module="系统服务")
|
||||
|
||||
elif mode == "Shutdown":
|
||||
|
||||
await self.kill_emulator_processes()
|
||||
logger.info("执行关机操作")
|
||||
self.kill_emulator_processes()
|
||||
logger.info("执行关机操作", module="系统服务")
|
||||
subprocess.run(["shutdown", "/s", "/t", "0"])
|
||||
|
||||
elif mode == "ShutdownForce":
|
||||
logger.info("执行强制关机操作")
|
||||
logger.info("执行强制关机操作", module="系统服务")
|
||||
subprocess.run(["shutdown", "/s", "/t", "0", "/f"])
|
||||
|
||||
elif mode == "Hibernate":
|
||||
|
||||
logger.info("执行休眠操作")
|
||||
logger.info("执行休眠操作", module="系统服务")
|
||||
subprocess.run(["shutdown", "/h"])
|
||||
|
||||
elif mode == "Sleep":
|
||||
|
||||
logger.info("执行睡眠操作")
|
||||
logger.info("执行睡眠操作", module="系统服务")
|
||||
subprocess.run(
|
||||
["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"]
|
||||
)
|
||||
|
||||
elif mode == "KillSelf" and Config.server is not None:
|
||||
elif mode == "KillSelf":
|
||||
|
||||
logger.info("执行退出主程序操作")
|
||||
Config.server.should_exit = True
|
||||
logger.info("执行退出主程序操作", module="系统服务")
|
||||
Config.main_window.close()
|
||||
QApplication.quit()
|
||||
sys.exit(0)
|
||||
|
||||
elif sys.platform.startswith("linux"):
|
||||
|
||||
if mode == "NoAction":
|
||||
|
||||
logger.info("不执行系统电源操作")
|
||||
logger.info("不执行系统电源操作", module="系统服务")
|
||||
|
||||
elif mode == "Shutdown":
|
||||
|
||||
logger.info("执行关机操作")
|
||||
logger.info("执行关机操作", module="系统服务")
|
||||
subprocess.run(["shutdown", "-h", "now"])
|
||||
|
||||
elif mode == "Hibernate":
|
||||
|
||||
logger.info("执行休眠操作")
|
||||
logger.info("执行休眠操作", module="系统服务")
|
||||
subprocess.run(["systemctl", "hibernate"])
|
||||
|
||||
elif mode == "Sleep":
|
||||
|
||||
logger.info("执行睡眠操作")
|
||||
logger.info("执行睡眠操作", module="系统服务")
|
||||
subprocess.run(["systemctl", "suspend"])
|
||||
|
||||
elif mode == "KillSelf" and Config.server is not None:
|
||||
elif mode == "KillSelf":
|
||||
|
||||
logger.info("执行退出主程序操作")
|
||||
Config.server.should_exit = True
|
||||
logger.info("执行退出主程序操作", module="系统服务")
|
||||
Config.main_window.close()
|
||||
QApplication.quit()
|
||||
sys.exit(0)
|
||||
|
||||
async def _power_task(
|
||||
self,
|
||||
power_sign: Literal[
|
||||
"NoAction", "Shutdown", "ShutdownForce", "Hibernate", "Sleep", "KillSelf"
|
||||
],
|
||||
) -> None:
|
||||
"""电源任务"""
|
||||
|
||||
await asyncio.sleep(self.countdown)
|
||||
if power_sign == "KillSelf":
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Main", type="Signal", data={"RequestClose": "请求前端关闭"}
|
||||
).model_dump()
|
||||
)
|
||||
await self.set_power(power_sign)
|
||||
|
||||
async def start_power_task(self):
|
||||
"""开始电源任务"""
|
||||
|
||||
if self.power_task is None or self.power_task.done():
|
||||
self.power_task = asyncio.create_task(self._power_task(Config.power_sign))
|
||||
logger.info(
|
||||
f"电源任务已启动, {self.countdown}秒后执行: {Config.power_sign}"
|
||||
)
|
||||
else:
|
||||
logger.warning("已有电源任务在运行, 请勿重复启动")
|
||||
|
||||
async def cancel_power_task(self):
|
||||
"""取消电源任务"""
|
||||
|
||||
if self.power_task is not None and not self.power_task.done():
|
||||
self.power_task.cancel()
|
||||
try:
|
||||
await self.power_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("电源任务已取消")
|
||||
else:
|
||||
logger.warning("当前无电源任务在运行")
|
||||
raise RuntimeError("当前无电源任务在运行")
|
||||
|
||||
async def kill_emulator_processes(self):
|
||||
def kill_emulator_processes(self):
|
||||
"""这里暂时仅支持 MuMu 模拟器"""
|
||||
|
||||
logger.info("正在清除模拟器进程")
|
||||
logger.info("正在清除模拟器进程", module="系统服务")
|
||||
|
||||
keywords = ["Nemu", "nemu", "emulator", "MuMu"]
|
||||
for proc in psutil.process_iter(["pid", "name"]):
|
||||
@@ -304,18 +270,21 @@ class _SystemHandler:
|
||||
pname = proc.info["name"].lower()
|
||||
if any(keyword.lower() in pname for keyword in keywords):
|
||||
proc.kill()
|
||||
logger.info(f"已关闭 MuMu 模拟器进程: {proc.info['name']}")
|
||||
logger.info(
|
||||
f"已关闭 MuMu 模拟器进程: {proc.info['name']}",
|
||||
module="系统服务",
|
||||
)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
|
||||
logger.success("模拟器进程清除完成")
|
||||
logger.success("模拟器进程清除完成", module="系统服务")
|
||||
|
||||
async def is_startup(self) -> bool:
|
||||
def is_startup(self) -> bool:
|
||||
"""判断程序是否已经开机自启"""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["schtasks", "/query", "/tn", "AUTO-MAS_AutoStart"],
|
||||
["schtasks", "/query", "/tn", "AUTO_MAA_AutoStart"],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
@@ -323,10 +292,10 @@ class _SystemHandler:
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
logger.exception(f"检查任务计划程序失败: {e}")
|
||||
logger.exception(f"检查任务计划程序失败: {e}", module="系统服务")
|
||||
return False
|
||||
|
||||
async def get_window_info(self) -> list:
|
||||
def get_window_info(self) -> list:
|
||||
"""获取当前前台窗口信息"""
|
||||
|
||||
def callback(hwnd, window_info):
|
||||
@@ -340,16 +309,16 @@ class _SystemHandler:
|
||||
win32gui.EnumWindows(callback, window_info)
|
||||
return window_info
|
||||
|
||||
async def kill_process(self, path: Path) -> None:
|
||||
def kill_process(self, path: Path) -> None:
|
||||
"""
|
||||
根据路径中止进程
|
||||
|
||||
:param path: 进程路径
|
||||
"""
|
||||
|
||||
logger.info(f"开始中止进程: {path}")
|
||||
logger.info(f"开始中止进程: {path}", module="系统服务")
|
||||
|
||||
for pid in await self.search_pids(path):
|
||||
for pid in self.search_pids(path):
|
||||
killprocess = subprocess.Popen(
|
||||
f"taskkill /F /T /PID {pid}",
|
||||
shell=True,
|
||||
@@ -357,9 +326,9 @@ class _SystemHandler:
|
||||
)
|
||||
killprocess.wait()
|
||||
|
||||
logger.success(f"进程已中止: {path}")
|
||||
logger.success(f"进程已中止: {path}", module="系统服务")
|
||||
|
||||
async def search_pids(self, path: Path) -> list:
|
||||
def search_pids(self, path: Path) -> list:
|
||||
"""
|
||||
根据路径查找进程PID
|
||||
|
||||
@@ -367,7 +336,7 @@ class _SystemHandler:
|
||||
:return: 匹配的进程PID列表
|
||||
"""
|
||||
|
||||
logger.info(f"开始查找进程 PID: {path}")
|
||||
logger.info(f"开始查找进程 PID: {path}", module="系统服务")
|
||||
|
||||
pids = []
|
||||
for proc in psutil.process_iter(["pid", "exe"]):
|
||||
@@ -375,7 +344,7 @@ class _SystemHandler:
|
||||
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
|
||||
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import asyncio
|
||||
import zipfile
|
||||
import requests
|
||||
import subprocess
|
||||
from packaging import version
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
from pathlib import Path
|
||||
|
||||
from app.core import Config
|
||||
from app.models.schema import WebSocketMessage
|
||||
from app.utils.constants import MIRROR_ERROR_INFO
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("更新服务")
|
||||
|
||||
|
||||
class _UpdateHandler:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.is_locked: bool = False
|
||||
self.remote_version: Optional[str] = None
|
||||
self.last_check_time: Optional[datetime] = None
|
||||
self.update_version_info: Optional[Dict[str, List[str]]] = None
|
||||
self.mirror_chyan_download_url: Optional[str] = None
|
||||
|
||||
async def check_update(
|
||||
self, current_version: str, if_force: bool = False
|
||||
) -> tuple[bool, str, Dict[str, List[str]]]:
|
||||
|
||||
if (
|
||||
not if_force
|
||||
and self.remote_version is not None
|
||||
and self.last_check_time is not None
|
||||
and self.update_version_info is not None
|
||||
and self.last_check_time > datetime.now() - timedelta(hours=4)
|
||||
):
|
||||
logger.info("四小时内已进行过一次检查, 直接使用缓存的版本更新信息")
|
||||
return (
|
||||
bool(
|
||||
version.parse(self.remote_version) > version.parse(current_version)
|
||||
),
|
||||
self.remote_version,
|
||||
self.update_version_info,
|
||||
)
|
||||
|
||||
logger.info("开始检查更新")
|
||||
|
||||
response = requests.get(
|
||||
f"https://mirrorchyan.com/api/resources/AUTO_MAA/latest?user_agent=AutoMasGui¤t_version={current_version}&cdk={Config.get('Update', 'MirrorChyanCDK')}&channel=stable",
|
||||
timeout=10,
|
||||
proxies=Config.get_proxies(),
|
||||
)
|
||||
if response.status_code == 200:
|
||||
version_info = response.json()
|
||||
else:
|
||||
result = response.json()
|
||||
|
||||
if result["code"] != 0:
|
||||
if result["code"] in MIRROR_ERROR_INFO:
|
||||
raise Exception(
|
||||
f"获取版本信息时出错: {MIRROR_ERROR_INFO[result['code']]}"
|
||||
)
|
||||
else:
|
||||
raise Exception(
|
||||
"获取版本信息时出错: 意料之外的错误, 请及时联系项目组以获取来自 Mirror 酱的技术支持"
|
||||
)
|
||||
|
||||
logger.success("获取版本信息成功")
|
||||
self.last_check_time = datetime.now()
|
||||
|
||||
self.remote_version = version_info["data"]["version_name"]
|
||||
if self.remote_version is None:
|
||||
raise Exception("Mirror 酱未返回版本号, 请稍后重试")
|
||||
if "url" in version_info["data"]:
|
||||
self.mirror_chyan_download_url = version_info["data"]["url"]
|
||||
|
||||
if version.parse(self.remote_version) > version.parse(current_version):
|
||||
|
||||
# 版本更新信息
|
||||
version_info_json: Dict[str, Dict[str, List[str]]] = json.loads(
|
||||
re.sub(
|
||||
r"^<!--\s*(.*?)\s*-->$",
|
||||
r"\1",
|
||||
version_info["data"]["release_note"].splitlines()[0],
|
||||
)
|
||||
)
|
||||
|
||||
self.update_version_info = {}
|
||||
for v_i in [
|
||||
info
|
||||
for ver, info in version_info_json.items()
|
||||
if version.parse(ver) > version.parse(current_version)
|
||||
]:
|
||||
|
||||
for key, value in v_i.items():
|
||||
if key not in self.update_version_info:
|
||||
self.update_version_info[key] = []
|
||||
self.update_version_info[key] += value
|
||||
|
||||
return True, self.remote_version, self.update_version_info
|
||||
|
||||
else:
|
||||
return False, current_version, {}
|
||||
|
||||
async def download_update(self) -> None:
|
||||
|
||||
if self.is_locked:
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={"Failed": "已有更新任务在进行中, 请勿重复操作"},
|
||||
).model_dump()
|
||||
)
|
||||
return None
|
||||
|
||||
self.is_locked = True
|
||||
|
||||
if self.remote_version is None:
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={"Failed": "未检测到可用的远程版本, 请先检查更新"},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
return None
|
||||
|
||||
if (Path.cwd() / f"UpdatePack_{self.remote_version}.zip").exists():
|
||||
logger.info(
|
||||
f"更新包已存在: {Path.cwd() / f'UpdatePack_{self.remote_version}.zip'}"
|
||||
)
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={
|
||||
"Accomplish": str(
|
||||
Path.cwd() / f"UpdatePack_{self.remote_version}.zip"
|
||||
)
|
||||
},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
return None
|
||||
|
||||
if Config.get("Update", "Source") == "GitHub":
|
||||
|
||||
download_url = f"https://github.com/AUTO-MAS-Project/AUTO-MAS/releases/download/{self.remote_version}/AUTO-MAS_{self.remote_version}.zip"
|
||||
|
||||
elif Config.get("Update", "Source") == "MirrorChyan":
|
||||
|
||||
if self.mirror_chyan_download_url is None:
|
||||
logger.warning("MirrorChyan 未返回下载链接, 使用自建下载站")
|
||||
download_url = f"https://download.auto-mas.top/d/AUTO-MAS/AUTO-MAS_{self.remote_version}.zip"
|
||||
|
||||
else:
|
||||
with requests.get(
|
||||
self.mirror_chyan_download_url,
|
||||
allow_redirects=True,
|
||||
timeout=10,
|
||||
stream=True,
|
||||
proxies=Config.get_proxies(),
|
||||
) as response:
|
||||
if response.status_code == 200:
|
||||
download_url = response.url
|
||||
elif Config.get("Update", "Source") == "AutoSite":
|
||||
download_url = f"https://download.auto-mas.top/d/AUTO-MAS/AUTO-MAS_{self.remote_version}.zip"
|
||||
|
||||
else:
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={
|
||||
"Failed": f"未知的下载源: {Config.get('Update', 'Source')}, 请检查配置文件"
|
||||
},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
return None
|
||||
|
||||
logger.info(f"开始下载: {download_url}")
|
||||
|
||||
check_times = 3
|
||||
while check_times != 0:
|
||||
|
||||
try:
|
||||
# 清理可能存在的临时文件
|
||||
if (Path.cwd() / "download.temp").exists():
|
||||
(Path.cwd() / "download.temp").unlink()
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
response = requests.get(
|
||||
download_url, timeout=10, stream=True, proxies=Config.get_proxies()
|
||||
)
|
||||
|
||||
if response.status_code not in [200, 206]:
|
||||
|
||||
if check_times != -1:
|
||||
check_times -= 1
|
||||
|
||||
logger.warning(
|
||||
f"连接失败: {download_url}, 状态码: {response.status_code}, 剩余重试次数: {check_times}"
|
||||
)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
logger.info(f"连接成功: {download_url}, 状态码: {response.status_code}")
|
||||
|
||||
file_size = int(response.headers.get("content-length", 0))
|
||||
downloaded_size = 0
|
||||
last_download_size = 0
|
||||
speed = 0
|
||||
last_time = time.time()
|
||||
with (Path.cwd() / "download.temp").open(mode="wb") as f:
|
||||
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
|
||||
# 更新指定线程的下载进度, 每秒更新一次
|
||||
if time.time() - last_time >= 1.0:
|
||||
speed = (
|
||||
(downloaded_size - last_download_size)
|
||||
/ (time.time() - last_time)
|
||||
/ 1024
|
||||
)
|
||||
last_download_size = downloaded_size
|
||||
last_time = time.time()
|
||||
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Update",
|
||||
data={
|
||||
"downloaded_size": downloaded_size,
|
||||
"file_size": file_size,
|
||||
"speed": speed,
|
||||
},
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
(Path.cwd() / "download.temp").rename(
|
||||
Path.cwd() / f"UpdatePack_{self.remote_version}.zip"
|
||||
)
|
||||
|
||||
logger.success(
|
||||
f"下载完成: {download_url}, 实际下载大小: {downloaded_size} 字节, 耗时: {time.time() - start_time:.2f} 秒, 保存位置: {Path.cwd() / f'UpdatePack_{self.remote_version}.zip'}"
|
||||
)
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={
|
||||
"Accomplish": str(
|
||||
Path.cwd() / f"UpdatePack_{self.remote_version}.zip"
|
||||
)
|
||||
},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
|
||||
if check_times != -1:
|
||||
check_times -= 1
|
||||
|
||||
logger.info(
|
||||
f"下载出错: {download_url}, 错误信息: {e}, 剩余重试次数: {check_times}"
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
else:
|
||||
|
||||
if (Path.cwd() / "download.temp").exists():
|
||||
(Path.cwd() / "download.temp").unlink()
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={"Failed": f"下载失败: {download_url}"},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
|
||||
async def install_update(self):
|
||||
|
||||
if self.is_locked:
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={"Failed": "已有更新任务在进行中, 请勿重复操作"},
|
||||
).model_dump()
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info("开始应用更新")
|
||||
self.is_locked = True
|
||||
|
||||
versions = {
|
||||
version.parse(match.group(1)): f.name
|
||||
for f in Path.cwd().glob("UpdatePack_*.zip")
|
||||
if (match := re.match(r"UpdatePack_(.+)\.zip$", f.name))
|
||||
}
|
||||
logger.info(f"检测到的更新包: {versions.values()}")
|
||||
|
||||
if not versions:
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Signal",
|
||||
data={"Failed": "未检测到更新包, 请先下载更新"},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
return None
|
||||
|
||||
update_package = Path.cwd() / versions[max(versions)]
|
||||
|
||||
logger.info(f"开始解压: {update_package} 到 {Path.cwd()}")
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(update_package, "r") as zip_ref:
|
||||
zip_ref.extractall(Path.cwd())
|
||||
except Exception as e:
|
||||
logger.error(f"解压失败, {type(e).__name__}: {e}")
|
||||
await Config.send_json(
|
||||
WebSocketMessage(
|
||||
id="Update",
|
||||
type="Info",
|
||||
data={"Error": f"解压失败, {type(e).__name__}: {e}"},
|
||||
).model_dump()
|
||||
)
|
||||
self.is_locked = False
|
||||
return None
|
||||
|
||||
logger.success(f"解压完成: {update_package} 到 {Path.cwd()}")
|
||||
|
||||
logger.info("正在删除临时文件与旧更新包文件")
|
||||
if (Path.cwd() / "changes.json").exists():
|
||||
(Path.cwd() / "changes.json").unlink()
|
||||
for f in versions.values():
|
||||
if (Path.cwd() / f).exists():
|
||||
(Path.cwd() / f).unlink()
|
||||
|
||||
logger.info("启动更新程序")
|
||||
self.is_locked = False
|
||||
subprocess.Popen(
|
||||
[Path.cwd() / "AUTO-MAS-Setup.exe"],
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
| subprocess.DETACHED_PROCESS
|
||||
| subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
|
||||
|
||||
Updater = _UpdateHandler()
|
||||
2086
app/task/MAA.py
2086
app/task/MAA.py
File diff suppressed because it is too large
Load Diff
1103
app/task/general.py
1103
app/task/general.py
File diff suppressed because it is too large
Load Diff
2217
app/ui/Widget.py
Normal file
2217
app/ui/Widget.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,35 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# 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-MAS is distributed in the hope that it will be useful,
|
||||
# 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
__version__ = "5.0.0"
|
||||
"""
|
||||
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
|
||||
|
||||
from .skland import skland_sign_in
|
||||
from .general import GeneralManager
|
||||
from .MAA import MaaManager
|
||||
|
||||
__all__ = ["skland_sign_in", "GeneralManager", "MaaManager"]
|
||||
__all__ = ["AUTO_MAA", "ProgressRingMessageBox"]
|
||||
625
app/ui/dispatch_center.py
Normal file
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
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
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
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
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
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
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
3876
app/ui/script_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
1313
app/ui/setting.py
Normal file
1313
app/ui/setting.py
Normal file
File diff suppressed because it is too large
Load Diff
93
app/utils/AUTO_MAA.iss
Normal file
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;
|
||||
@@ -1,24 +1,29 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2025 ClozyA
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# 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-MAS is distributed in the hope that it will be useful,
|
||||
# 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
# 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
|
||||
@@ -49,10 +54,12 @@ class ImageUtils:
|
||||
@staticmethod
|
||||
def compress_image_if_needed(image_path: Path, max_size_mb=2) -> Path:
|
||||
"""
|
||||
如果图片大于max_size_mb, 则压缩并覆盖原文件, 返回原始路径(Path对象)
|
||||
如果图片大于max_size_mb,则压缩并覆盖原文件,返回原始路径(Path对象)
|
||||
"""
|
||||
|
||||
RESAMPLE = Image.Resampling.LANCZOS # Pillow 9.1.0及以后
|
||||
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:
|
||||
@@ -63,7 +70,7 @@ class ImageUtils:
|
||||
quality = 90 if suffix in [".jpg", ".jpeg"] else None
|
||||
step = 5
|
||||
|
||||
if quality is not None:
|
||||
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:
|
||||
@@ -83,6 +90,6 @@ class ImageUtils:
|
||||
height = int(height * 0.95)
|
||||
img = img.resize((width, height), RESAMPLE)
|
||||
else:
|
||||
raise ValueError("仅支持JPG/JPEG和PNG格式图片的压缩")
|
||||
raise ValueError("仅支持JPG/JPEG和PNG格式图片的压缩。")
|
||||
|
||||
return image_path
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import asyncio
|
||||
import aiofiles
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, List, Awaitable
|
||||
|
||||
from .logger import get_logger
|
||||
|
||||
logger = get_logger("日志监控器")
|
||||
|
||||
TIME_FIELDS = {
|
||||
"%Y": "year",
|
||||
"%m": "month",
|
||||
"%d": "day",
|
||||
"%H": "hour",
|
||||
"%M": "minute",
|
||||
"%S": "second",
|
||||
"%f": "microsecond",
|
||||
}
|
||||
"""时间字段映射表"""
|
||||
|
||||
|
||||
def strptime(date_string: str, format: str, default_date: datetime) -> datetime:
|
||||
"""根据指定格式解析日期字符串"""
|
||||
|
||||
date = datetime.strptime(date_string, format)
|
||||
|
||||
# 构建参数字典
|
||||
datetime_kwargs = {}
|
||||
for format_code, field_name in TIME_FIELDS.items():
|
||||
if format_code in format:
|
||||
datetime_kwargs[field_name] = getattr(date, field_name)
|
||||
else:
|
||||
datetime_kwargs[field_name] = getattr(default_date, field_name)
|
||||
|
||||
return datetime(**datetime_kwargs)
|
||||
|
||||
|
||||
class LogMonitor:
|
||||
def __init__(
|
||||
self,
|
||||
time_stamp_range: tuple[int, int],
|
||||
time_format: str,
|
||||
callback: Callable[[List[str]], Awaitable[None]],
|
||||
encoding: str = "utf-8",
|
||||
):
|
||||
self.time_stamp_range = time_stamp_range
|
||||
self.time_format = time_format
|
||||
self.callback = callback
|
||||
self.encoding = encoding
|
||||
self.log_file_path: Optional[Path] = None
|
||||
self.log_start_time: datetime = datetime.now()
|
||||
self.last_callback_time: datetime = datetime.now()
|
||||
self.log_contents: List[str] = []
|
||||
self.task: Optional[asyncio.Task] = None
|
||||
self.__is_running = False
|
||||
|
||||
async def monitor_log(self):
|
||||
"""监控日志文件的主循环"""
|
||||
if self.log_file_path is None or not self.log_file_path.exists():
|
||||
raise ValueError("日志文件路径未设置或文件不存在")
|
||||
|
||||
logger.info(f"开始监控日志文件: {self.log_file_path}")
|
||||
|
||||
while self.__is_running:
|
||||
logger.debug("正在检查日志文件...")
|
||||
log_contents = []
|
||||
if_log_start = False
|
||||
|
||||
# 检查文件是否仍然存在
|
||||
if not self.log_file_path.exists():
|
||||
logger.warning(f"日志文件不存在: {self.log_file_path}")
|
||||
continue
|
||||
|
||||
# 尝试读取文件
|
||||
try:
|
||||
async with aiofiles.open(
|
||||
self.log_file_path, "r", encoding=self.encoding
|
||||
) as f:
|
||||
async for line in f:
|
||||
if not if_log_start:
|
||||
try:
|
||||
entry_time = strptime(
|
||||
line[
|
||||
self.time_stamp_range[
|
||||
0
|
||||
] : self.time_stamp_range[1]
|
||||
],
|
||||
self.time_format,
|
||||
self.last_callback_time,
|
||||
)
|
||||
if entry_time > self.log_start_time:
|
||||
if_log_start = True
|
||||
log_contents.append(line)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
else:
|
||||
log_contents.append(line)
|
||||
|
||||
except (FileNotFoundError, PermissionError) as e:
|
||||
logger.warning(f"文件访问错误: {e}")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
except UnicodeDecodeError as e:
|
||||
logger.error(f"文件编码错误: {e}")
|
||||
await asyncio.sleep(10)
|
||||
continue
|
||||
|
||||
# 调用回调
|
||||
if (
|
||||
log_contents != self.log_contents
|
||||
or datetime.now() - self.last_callback_time > timedelta(minutes=1)
|
||||
):
|
||||
self.log_contents = log_contents
|
||||
self.last_callback_time = datetime.now()
|
||||
|
||||
# 安全调用回调函数
|
||||
try:
|
||||
await self.callback(log_contents)
|
||||
except Exception as e:
|
||||
logger.error(f"回调函数执行失败: {e}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def start(self, log_file_path: Path, start_time: datetime) -> None:
|
||||
"""启动监控"""
|
||||
|
||||
if log_file_path.is_dir():
|
||||
raise ValueError(f"日志文件不能是目录: {log_file_path}")
|
||||
|
||||
if self.task is not None and not self.task.done():
|
||||
await self.stop()
|
||||
|
||||
self.__is_running = True
|
||||
self.log_contents = []
|
||||
self.log_file_path = log_file_path
|
||||
self.log_start_time = start_time
|
||||
self.task = asyncio.create_task(self.monitor_log())
|
||||
logger.info(f"日志监控已启动: {self.log_file_path}")
|
||||
|
||||
async def stop(self):
|
||||
"""停止监控"""
|
||||
|
||||
logger.info("请求取消日志监控任务")
|
||||
|
||||
if self.task is not None and not self.task.done():
|
||||
self.task.cancel()
|
||||
|
||||
try:
|
||||
await self.task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("日志监控任务已中止")
|
||||
|
||||
logger.success("日志监控任务已停止")
|
||||
self.task = None
|
||||
@@ -1,54 +1,61 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# 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-MAS is distributed in the hope that it will be useful,
|
||||
# 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
# 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 asyncio
|
||||
import psutil
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from PySide6.QtCore import QTimer, QObject, Signal
|
||||
|
||||
|
||||
class ProcessManager:
|
||||
"""进程监视器类, 用于跟踪主进程及其所有子进程的状态"""
|
||||
class ProcessManager(QObject):
|
||||
"""进程监视器类,用于跟踪主进程及其所有子进程的状态"""
|
||||
|
||||
processClosed = Signal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.main_pid = None
|
||||
self.tracked_pids = set()
|
||||
self.check_task = None
|
||||
self.track_end_time = datetime.now()
|
||||
|
||||
async def open_process(
|
||||
self, path: Path, args: list = [], tracking_time: int = 60
|
||||
) -> None:
|
||||
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, 并开始监视该进程
|
||||
启动一个新进程并返回其pid,并开始监视该进程
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path: 可执行文件的路径
|
||||
args: 启动参数列表
|
||||
tracking_time: 子进程追踪持续时间(秒)
|
||||
:param path: 可执行文件的路径
|
||||
:param args: 启动参数列表
|
||||
:param tracking_time: 子进程追踪持续时间(秒)
|
||||
:return: 新进程的PID
|
||||
"""
|
||||
|
||||
process = subprocess.Popen(
|
||||
@@ -60,17 +67,17 @@ class ProcessManager:
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
await self.start_monitoring(process.pid, tracking_time)
|
||||
self.start_monitoring(process.pid, tracking_time)
|
||||
|
||||
async def start_monitoring(self, pid: int, tracking_time: int = 60) -> None:
|
||||
def start_monitoring(self, pid: int, tracking_time: int = 60) -> None:
|
||||
"""
|
||||
启动进程监视器, 跟踪指定的主进程及其子进程
|
||||
启动进程监视器,跟踪指定的主进程及其子进程
|
||||
|
||||
:param pid: 被监视进程的PID
|
||||
:param tracking_time: 子进程追踪持续时间(秒)
|
||||
"""
|
||||
|
||||
await self.clear()
|
||||
self.clear()
|
||||
|
||||
self.main_pid = pid
|
||||
self.tracking_time = tracking_time
|
||||
@@ -89,15 +96,16 @@ class ProcessManager:
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
|
||||
# 启动持续追踪任务
|
||||
if tracking_time > 0:
|
||||
self.track_end_time = datetime.now() + timedelta(seconds=tracking_time)
|
||||
self.check_task = asyncio.create_task(self.track_processes())
|
||||
# 启动持续追踪机制
|
||||
self.start_time = datetime.now()
|
||||
self.check_timer.start(100)
|
||||
|
||||
async def track_processes(self) -> None:
|
||||
"""更新子进程列表"""
|
||||
def check_processes(self) -> None:
|
||||
"""检查跟踪的进程是否仍在运行,并更新子进程列表"""
|
||||
|
||||
# 仅在时限内持续更新跟踪的进程列表,发现新的子进程
|
||||
if (datetime.now() - self.start_time).total_seconds() < self.tracking_time:
|
||||
|
||||
while datetime.now() < self.track_end_time:
|
||||
current_pids = set(self.tracked_pids)
|
||||
for pid in current_pids:
|
||||
try:
|
||||
@@ -108,9 +116,12 @@ class ProcessManager:
|
||||
self.tracked_pids.add(child.pid)
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def is_running(self) -> bool:
|
||||
if not self.is_running():
|
||||
self.clear()
|
||||
self.processClosed.emit()
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""检查所有跟踪的进程是否还在运行"""
|
||||
|
||||
for pid in self.tracked_pids:
|
||||
@@ -123,9 +134,11 @@ class ProcessManager:
|
||||
|
||||
return False
|
||||
|
||||
async def kill(self, if_force: bool = False) -> None:
|
||||
def kill(self, if_force: bool = False) -> None:
|
||||
"""停止监视器并中止所有跟踪的进程"""
|
||||
|
||||
self.check_timer.stop()
|
||||
|
||||
for pid in self.tracked_pids:
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
@@ -139,18 +152,13 @@ class ProcessManager:
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
|
||||
await self.clear()
|
||||
if self.main_pid:
|
||||
self.processClosed.emit()
|
||||
self.clear()
|
||||
|
||||
async def clear(self) -> None:
|
||||
def clear(self) -> None:
|
||||
"""清空跟踪的进程列表"""
|
||||
|
||||
if self.check_task is not None and not self.check_task.done():
|
||||
self.check_task.cancel()
|
||||
|
||||
try:
|
||||
await self.check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self.main_pid = None
|
||||
self.check_timer.stop()
|
||||
self.tracked_pids.clear()
|
||||
|
||||
@@ -1,44 +1,35 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# AUTO_MAA:A MAA Multi Account Management and Automation Tool
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
# This file is part of AUTO_MAA.
|
||||
|
||||
# AUTO-MAS is free software: you can redistribute it and/or modify
|
||||
# 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-MAS is distributed in the hope that it will be useful,
|
||||
# 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
# along with AUTO_MAA. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
__version__ = "5.0.0"
|
||||
"""
|
||||
AUTO_MAA
|
||||
AUTO_MAA工具包
|
||||
v4.4
|
||||
作者:DLmaster_361
|
||||
"""
|
||||
|
||||
__version__ = "4.2.0"
|
||||
__author__ = "DLmaster361 <DLmaster_361@163.com>"
|
||||
__license__ = "GPL-3.0 license"
|
||||
|
||||
|
||||
from .constants import *
|
||||
from .logger import get_logger
|
||||
from .ImageUtils import ImageUtils
|
||||
from .LogMonitor import LogMonitor, strptime
|
||||
from .ProcessManager import ProcessManager
|
||||
from .security import dpapi_encrypt, dpapi_decrypt
|
||||
|
||||
__all__ = [
|
||||
"constants",
|
||||
"get_logger",
|
||||
"ImageUtils",
|
||||
"LogMonitor",
|
||||
"ProcessManager",
|
||||
"dpapi_encrypt",
|
||||
"dpapi_decrypt",
|
||||
"strptime",
|
||||
]
|
||||
__all__ = ["ImageUtils", "ProcessManager"]
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 ClozyA
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
TYPE_BOOK = {"MaaConfig": "MAA", "GeneralConfig": "通用"}
|
||||
"""配置类型映射表"""
|
||||
|
||||
RESOURCE_STAGE_INFO = [
|
||||
{"value": "-", "text": "当前/上次", "days": [1, 2, 3, 4, 5, 6, 7]},
|
||||
{"value": "1-7", "text": "1-7", "days": [1, 2, 3, 4, 5, 6, 7]},
|
||||
{"value": "R8-11", "text": "R8-11", "days": [1, 2, 3, 4, 5, 6, 7]},
|
||||
{"value": "12-17-HARD", "text": "12-17-HARD", "days": [1, 2, 3, 4, 5, 6, 7]},
|
||||
{"value": "LS-6", "text": "经验-6/5", "days": [1, 2, 3, 4, 5, 6, 7]},
|
||||
{"value": "CE-6", "text": "龙门币-6/5", "days": [2, 4, 6, 7]},
|
||||
{"value": "AP-5", "text": "红票-5", "days": [1, 4, 6, 7]},
|
||||
{"value": "CA-5", "text": "技能-5", "days": [2, 3, 5, 7]},
|
||||
{"value": "SK-5", "text": "碳-5", "days": [1, 3, 5, 6]},
|
||||
{"value": "PR-A-1", "text": "奶/盾芯片", "days": [1, 4, 5, 7]},
|
||||
{"value": "PR-A-2", "text": "奶/盾芯片组", "days": [1, 4, 5, 7]},
|
||||
{"value": "PR-B-1", "text": "术/狙芯片", "days": [1, 2, 5, 6]},
|
||||
{"value": "PR-B-2", "text": "术/狙芯片组", "days": [1, 2, 5, 6]},
|
||||
{"value": "PR-C-1", "text": "先/辅芯片", "days": [3, 4, 6, 7]},
|
||||
{"value": "PR-C-2", "text": "先/辅芯片组", "days": [3, 4, 6, 7]},
|
||||
{"value": "PR-D-1", "text": "近/特芯片", "days": [2, 3, 6, 7]},
|
||||
{"value": "PR-D-2", "text": "近/特芯片组", "days": [2, 3, 6, 7]},
|
||||
]
|
||||
"""常规资源关信息"""
|
||||
|
||||
|
||||
RESOURCE_STAGE_DATE_TEXT = {
|
||||
"LS-6": "经验-6/5 | 常驻开放",
|
||||
"CE-6": "龙门币-6/5 | 二四六日开放",
|
||||
"AP-5": "红票-5 | 一四六日开放",
|
||||
"CA-5": "技能-5 | 二三五日开放",
|
||||
"SK-5": "碳-5 | 一三五六开放",
|
||||
"PR-A-1": "奶/盾芯片 | 一四五日开放",
|
||||
"PR-A-2": "奶/盾芯片组 | 一四五日开放",
|
||||
"PR-B-1": "术/狙芯片 | 一二五六日开放",
|
||||
"PR-B-2": "术/狙芯片组 | 一二五六日开放",
|
||||
"PR-C-1": "先/辅芯片 | 三四六日开放",
|
||||
"PR-C-2": "先/辅芯片组 | 三四六日开放",
|
||||
"PR-D-1": "近/特芯片 | 二三六日开放",
|
||||
"PR-D-2": "近/特芯片组 | 二三六日开放",
|
||||
}
|
||||
"""常规资源关开放日文本映射"""
|
||||
|
||||
|
||||
RESOURCE_STAGE_DROP_INFO = {
|
||||
"CE-6": {
|
||||
"Display": "CE-6",
|
||||
"Value": "CE-6",
|
||||
"Drop": "4001",
|
||||
"DropName": "龙门币",
|
||||
"Activity": {"Tip": "二四六日", "StageName": "资源关卡"},
|
||||
},
|
||||
"AP-5": {
|
||||
"Display": "AP-5",
|
||||
"Value": "AP-5",
|
||||
"Drop": "4006",
|
||||
"DropName": "采购凭证",
|
||||
"Activity": {"Tip": "一四六日", "StageName": "资源关卡"},
|
||||
},
|
||||
"CA-5": {
|
||||
"Display": "CA-5",
|
||||
"Value": "CA-5",
|
||||
"Drop": "3303",
|
||||
"DropName": "技巧概要",
|
||||
"Activity": {"Tip": "二三五日", "StageName": "资源关卡"},
|
||||
},
|
||||
"LS-6": {
|
||||
"Display": "LS-6",
|
||||
"Value": "LS-6",
|
||||
"Drop": "2004",
|
||||
"DropName": "作战记录",
|
||||
"Activity": {"Tip": "常驻开放", "StageName": "资源关卡"},
|
||||
},
|
||||
"SK-5": {
|
||||
"Display": "SK-5",
|
||||
"Value": "SK-5",
|
||||
"Drop": "3114",
|
||||
"DropName": "碳素组",
|
||||
"Activity": {"Tip": "一三五六", "StageName": "资源关卡"},
|
||||
},
|
||||
"PR-A-1": {
|
||||
"Display": "PR-A",
|
||||
"Value": "PR-A",
|
||||
"Drop": "PR-A",
|
||||
"DropName": "奶/盾芯片",
|
||||
"Activity": {"Tip": "一四五日", "StageName": "资源关卡"},
|
||||
},
|
||||
"PR-B-1": {
|
||||
"Display": "PR-B",
|
||||
"Value": "PR-B",
|
||||
"Drop": "PR-B",
|
||||
"DropName": "术/狙芯片",
|
||||
"Activity": {"Tip": "一二五六", "StageName": "资源关卡"},
|
||||
},
|
||||
"PR-C-1": {
|
||||
"Display": "PR-C",
|
||||
"Value": "PR-C",
|
||||
"Drop": "PR-C",
|
||||
"DropName": "先/辅芯片",
|
||||
"Activity": {"Tip": "三四六日", "StageName": "资源关卡"},
|
||||
},
|
||||
"PR-D-1": {
|
||||
"Display": "PR-D",
|
||||
"Value": "PR-D",
|
||||
"Drop": "PR-D",
|
||||
"DropName": "近/特芯片",
|
||||
"Activity": {"Tip": "二三六日", "StageName": "资源关卡"},
|
||||
},
|
||||
}
|
||||
"""常规资源关掉落信息"""
|
||||
|
||||
MATERIALS_MAP = {
|
||||
"4001": "龙门币",
|
||||
"4006": "采购凭证",
|
||||
"2004": "高级作战记录",
|
||||
"2003": "中级作战记录",
|
||||
"2002": "初级作战记录",
|
||||
"2001": "基础作战记录",
|
||||
"3303": "技巧概要·卷3",
|
||||
"3302": "技巧概要·卷2",
|
||||
"3301": "技巧概要·卷1",
|
||||
"30165": "重相位对映体",
|
||||
"30155": "烧结核凝晶",
|
||||
"30145": "晶体电子单元",
|
||||
"30135": "D32钢",
|
||||
"30125": "双极纳米片",
|
||||
"30115": "聚合剂",
|
||||
"31094": "手性屈光体",
|
||||
"31093": "类凝结核",
|
||||
"31084": "环烃预制体",
|
||||
"31083": "环烃聚质",
|
||||
"31074": "固化纤维板",
|
||||
"31073": "褐素纤维",
|
||||
"31064": "转质盐聚块",
|
||||
"31063": "转质盐组",
|
||||
"31054": "切削原液",
|
||||
"31053": "化合切削液",
|
||||
"31044": "精炼溶剂",
|
||||
"31043": "半自然溶剂",
|
||||
"31034": "晶体电路",
|
||||
"31033": "晶体元件",
|
||||
"31024": "炽合金块",
|
||||
"31023": "炽合金",
|
||||
"31014": "聚合凝胶",
|
||||
"31013": "凝胶",
|
||||
"30074": "白马醇",
|
||||
"30073": "扭转醇",
|
||||
"30084": "三水锰矿",
|
||||
"30083": "轻锰矿",
|
||||
"30094": "五水研磨石",
|
||||
"30093": "研磨石",
|
||||
"30104": "RMA70-24",
|
||||
"30103": "RMA70-12",
|
||||
"30014": "提纯源岩",
|
||||
"30013": "固源岩组",
|
||||
"30012": "固源岩",
|
||||
"30011": "源岩",
|
||||
"30064": "改量装置",
|
||||
"30063": "全新装置",
|
||||
"30062": "装置",
|
||||
"30061": "破损装置",
|
||||
"30034": "聚酸酯块",
|
||||
"30033": "聚酸酯组",
|
||||
"30032": "聚酸酯",
|
||||
"30031": "酯原料",
|
||||
"30024": "糖聚块",
|
||||
"30023": "糖组",
|
||||
"30022": "糖",
|
||||
"30021": "代糖",
|
||||
"30044": "异铁块",
|
||||
"30043": "异铁组",
|
||||
"30042": "异铁",
|
||||
"30041": "异铁碎片",
|
||||
"30054": "酮阵列",
|
||||
"30053": "酮凝集组",
|
||||
"30052": "酮凝集",
|
||||
"30051": "双酮",
|
||||
"3114": "碳素组",
|
||||
"3113": "碳素",
|
||||
"3112": "碳",
|
||||
"3213": "先锋双芯片",
|
||||
"3223": "近卫双芯片",
|
||||
"3233": "重装双芯片",
|
||||
"3243": "狙击双芯片",
|
||||
"3253": "术师双芯片",
|
||||
"3263": "医疗双芯片",
|
||||
"3273": "辅助双芯片",
|
||||
"3283": "特种双芯片",
|
||||
"3212": "先锋芯片组",
|
||||
"3222": "近卫芯片组",
|
||||
"3232": "重装芯片组",
|
||||
"3242": "狙击芯片组",
|
||||
"3252": "术师芯片组",
|
||||
"3262": "医疗芯片组",
|
||||
"3272": "辅助芯片组",
|
||||
"3282": "特种芯片组",
|
||||
"3211": "先锋芯片",
|
||||
"3221": "近卫芯片",
|
||||
"3231": "重装芯片",
|
||||
"3241": "狙击芯片",
|
||||
"3251": "术师芯片",
|
||||
"3261": "医疗芯片",
|
||||
"3271": "辅助芯片",
|
||||
"3281": "特种芯片",
|
||||
"PR-A": "医疗/重装芯片",
|
||||
"PR-B": "术师/狙击芯片",
|
||||
"PR-C": "先锋/辅助芯片",
|
||||
"PR-D": "近卫/特种芯片",
|
||||
}
|
||||
"""掉落物索引表"""
|
||||
|
||||
POWER_SIGN_MAP = {
|
||||
"NoAction": "无动作",
|
||||
"Shutdown": "关机",
|
||||
"ShutdownForce": "强制关机",
|
||||
"Hibernate": "休眠",
|
||||
"Sleep": "睡眠",
|
||||
"KillSelf": "退出程序",
|
||||
}
|
||||
"""电源操作类型索引表"""
|
||||
|
||||
RESERVED_NAMES = {
|
||||
"CON",
|
||||
"PRN",
|
||||
"AUX",
|
||||
"NUL",
|
||||
"COM1",
|
||||
"COM2",
|
||||
"COM3",
|
||||
"COM4",
|
||||
"COM5",
|
||||
"COM6",
|
||||
"COM7",
|
||||
"COM8",
|
||||
"COM9",
|
||||
"LPT1",
|
||||
"LPT2",
|
||||
"LPT3",
|
||||
"LPT4",
|
||||
"LPT5",
|
||||
"LPT6",
|
||||
"LPT7",
|
||||
"LPT8",
|
||||
"LPT9",
|
||||
}
|
||||
"""Windows保留名称列表"""
|
||||
|
||||
ILLEGAL_CHARS = set('<>:"/\\|?*')
|
||||
"""文件名非法字符集合"""
|
||||
|
||||
MIRROR_ERROR_INFO = {
|
||||
1001: "获取版本信息的URL参数不正确",
|
||||
7001: "填入的 CDK 已过期",
|
||||
7002: "填入的 CDK 错误",
|
||||
7003: "填入的 CDK 今日下载次数已达上限",
|
||||
7004: "填入的 CDK 类型和待下载的资源不匹配",
|
||||
7005: "填入的 CDK 已被封禁",
|
||||
8001: "对应架构和系统下的资源不存在",
|
||||
8002: "错误的系统参数",
|
||||
8003: "错误的架构参数",
|
||||
8004: "错误的更新通道参数",
|
||||
1: "未知错误类型",
|
||||
}
|
||||
"""MirrorChyan错误代码映射表"""
|
||||
|
||||
DEFAULT_DATETIME = datetime.strptime("2000-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
"""默认日期时间"""
|
||||
@@ -1,5 +0,0 @@
|
||||
from .mumu import MumuManager
|
||||
from .ldplayer import LDManager
|
||||
from .utils import BaseDevice, DeviceStatus
|
||||
|
||||
__all__ = ["MumuManager", "LDManager", "BaseDevice", "DeviceStatus"]
|
||||
@@ -1,492 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import asyncio
|
||||
import psutil
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from app.utils.device_manager.utils import BaseDevice, DeviceStatus
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
|
||||
class ProcessManager:
|
||||
"""进程监视器类, 用于跟踪主进程及其所有子进程的状态"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.main_pid = None
|
||||
self.tracked_pids = set()
|
||||
self.check_task = None
|
||||
self.track_end_time = datetime.now()
|
||||
|
||||
async def open_process(
|
||||
self, path: Path, args: list = [], tracking_time: int = 60
|
||||
) -> None:
|
||||
"""
|
||||
启动一个新进程并返回其pid, 并开始监视该进程
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path: 可执行文件的路径
|
||||
args: 启动参数列表
|
||||
tracking_time: 子进程追踪持续时间(秒)
|
||||
"""
|
||||
|
||||
process = subprocess.Popen(
|
||||
[path, *args],
|
||||
cwd=path.parent,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
await self.start_monitoring(process.pid, tracking_time)
|
||||
|
||||
async def start_monitoring(self, pid: int, tracking_time: int = 60) -> None:
|
||||
"""
|
||||
启动进程监视器, 跟踪指定的主进程及其子进程
|
||||
|
||||
:param pid: 被监视进程的PID
|
||||
:param tracking_time: 子进程追踪持续时间(秒)
|
||||
"""
|
||||
|
||||
await 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
|
||||
|
||||
# 启动持续追踪任务
|
||||
if tracking_time > 0:
|
||||
self.track_end_time = datetime.now() + timedelta(seconds=tracking_time)
|
||||
self.check_task = asyncio.create_task(self.track_processes())
|
||||
|
||||
async def track_processes(self) -> None:
|
||||
"""更新子进程列表"""
|
||||
|
||||
while datetime.now() < self.track_end_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
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async 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
|
||||
|
||||
async def kill(self, if_force: bool = False) -> None:
|
||||
"""停止监视器并中止所有跟踪的进程"""
|
||||
|
||||
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
|
||||
|
||||
await self.clear()
|
||||
|
||||
async def clear(self) -> None:
|
||||
"""清空跟踪的进程列表"""
|
||||
|
||||
if self.check_task is not None and not self.check_task.done():
|
||||
self.check_task.cancel()
|
||||
|
||||
try:
|
||||
await self.check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self.main_pid = None
|
||||
self.tracked_pids.clear()
|
||||
|
||||
|
||||
class GeneralDeviceManager(BaseDevice):
|
||||
"""
|
||||
通用设备管理器,基于BaseDevice和ProcessManager实现
|
||||
用于管理一般应用程序进程
|
||||
"""
|
||||
|
||||
def __init__(self, executable_path: str, name: str = "通用设备"):
|
||||
"""
|
||||
初始化通用设备管理器
|
||||
|
||||
Args:
|
||||
executable_path (str): 可执行文件的绝对路径
|
||||
name (str): 设备管理器名称
|
||||
"""
|
||||
self.executable_path = Path(executable_path)
|
||||
self.name = name
|
||||
self.logger = get_logger(f"{name}管理器")
|
||||
|
||||
# 进程管理实例字典,以idx为键
|
||||
self.process_managers: Dict[str, ProcessManager] = {}
|
||||
|
||||
# 设备信息存储
|
||||
self.device_info: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# 默认等待时间
|
||||
self.wait_time = 60
|
||||
|
||||
if not self.executable_path.exists():
|
||||
raise FileNotFoundError(f"可执行文件不存在: {executable_path}")
|
||||
|
||||
async def start(self, idx: str, package_name: str = "") -> tuple[bool, int, dict]:
|
||||
"""
|
||||
启动设备
|
||||
|
||||
Args:
|
||||
idx: 设备ID
|
||||
package_name: 包名(可选)
|
||||
|
||||
Returns:
|
||||
tuple[bool, int, dict]: (是否成功, 状态码, 启动信息)
|
||||
"""
|
||||
try:
|
||||
# 检查是否已经在运行
|
||||
current_status = await self.get_status(idx)
|
||||
if current_status in [DeviceStatus.ONLINE, DeviceStatus.STARTING]:
|
||||
self.logger.warning(f"设备{idx}已经在运行,状态: {current_status}")
|
||||
return False, current_status, {}
|
||||
|
||||
# 创建进程管理器
|
||||
if idx not in self.process_managers:
|
||||
self.process_managers[idx] = ProcessManager()
|
||||
|
||||
# 准备启动参数
|
||||
args = []
|
||||
if package_name:
|
||||
args.extend(["-pkg", package_name])
|
||||
|
||||
# 启动进程
|
||||
await self.process_managers[idx].open_process(
|
||||
self.executable_path, args, tracking_time=self.wait_time
|
||||
)
|
||||
|
||||
# 等待进程启动
|
||||
start_time = datetime.now()
|
||||
timeout = timedelta(seconds=self.wait_time)
|
||||
|
||||
while datetime.now() - start_time < timeout:
|
||||
if await self.process_managers[idx].is_running():
|
||||
self.device_info[idx] = {
|
||||
"title": f"{self.name}_{idx}",
|
||||
"status": str(DeviceStatus.ONLINE),
|
||||
"pid": self.process_managers[idx].main_pid,
|
||||
"start_time": start_time.isoformat(),
|
||||
}
|
||||
|
||||
self.logger.info(f"设备{idx}启动成功")
|
||||
return True, DeviceStatus.ONLINE, self.device_info[idx]
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
self.logger.error(f"设备{idx}启动超时")
|
||||
return False, DeviceStatus.ERROR, {}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"启动设备{idx}失败: {str(e)}")
|
||||
return False, DeviceStatus.ERROR, {}
|
||||
|
||||
async def close(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
关闭设备或服务
|
||||
|
||||
Args:
|
||||
idx: 设备ID
|
||||
|
||||
Returns:
|
||||
tuple[bool, int]: (是否成功, 状态码)
|
||||
"""
|
||||
try:
|
||||
if idx not in self.process_managers:
|
||||
self.logger.warning(f"设备{idx}的进程管理器不存在")
|
||||
return False, DeviceStatus.NOT_FOUND
|
||||
|
||||
# 检查进程是否在运行
|
||||
if not await self.process_managers[idx].is_running():
|
||||
self.logger.info(f"设备{idx}进程已经停止")
|
||||
return True, DeviceStatus.OFFLINE
|
||||
|
||||
# 终止进程
|
||||
await self.process_managers[idx].kill(if_force=False)
|
||||
|
||||
# 等待进程完全停止
|
||||
stop_time = datetime.now()
|
||||
timeout = timedelta(seconds=10) # 10秒超时
|
||||
|
||||
while datetime.now() - stop_time < timeout:
|
||||
if not await self.process_managers[idx].is_running():
|
||||
# 清理设备信息
|
||||
if idx in self.device_info:
|
||||
del self.device_info[idx]
|
||||
|
||||
self.logger.info(f"设备{idx}已成功关闭")
|
||||
return True, DeviceStatus.OFFLINE
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 强制终止
|
||||
self.logger.warning(f"设备{idx}未能正常关闭,尝试强制终止")
|
||||
await self.process_managers[idx].kill(if_force=True)
|
||||
|
||||
if idx in self.device_info:
|
||||
del self.device_info[idx]
|
||||
|
||||
return True, DeviceStatus.OFFLINE
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"关闭设备{idx}失败: {str(e)}")
|
||||
return False, DeviceStatus.ERROR
|
||||
|
||||
async def get_status(self, idx: str) -> int:
|
||||
"""
|
||||
获取指定设备当前状态
|
||||
|
||||
Args:
|
||||
idx: 设备ID
|
||||
|
||||
Returns:
|
||||
int: 状态码
|
||||
"""
|
||||
try:
|
||||
if idx not in self.process_managers:
|
||||
return DeviceStatus.OFFLINE
|
||||
|
||||
if await self.process_managers[idx].is_running():
|
||||
return DeviceStatus.ONLINE
|
||||
else:
|
||||
return DeviceStatus.OFFLINE
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"获取设备{idx}状态失败: {str(e)}")
|
||||
return DeviceStatus.ERROR
|
||||
|
||||
async def hide_device(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
隐藏设备窗口
|
||||
|
||||
Args:
|
||||
idx: 设备ID
|
||||
|
||||
Returns:
|
||||
tuple[bool, int]: (是否成功, 状态码)
|
||||
"""
|
||||
try:
|
||||
status = await self.get_status(idx)
|
||||
if status != DeviceStatus.ONLINE:
|
||||
return False, status
|
||||
|
||||
if (
|
||||
idx not in self.process_managers
|
||||
or not self.process_managers[idx].main_pid
|
||||
):
|
||||
return False, DeviceStatus.NOT_FOUND
|
||||
|
||||
# 窗口隐藏功能(简化实现)
|
||||
# 注意:完整的窗口隐藏功能需要更复杂的Windows API调用
|
||||
self.logger.info(f"设备{idx}窗口隐藏请求已处理(简化实现)")
|
||||
return True, DeviceStatus.ONLINE
|
||||
|
||||
self.logger.info(f"设备{idx}窗口已隐藏")
|
||||
return True, DeviceStatus.ONLINE
|
||||
|
||||
except ImportError:
|
||||
self.logger.warning("隐藏窗口功能需要pywin32库")
|
||||
return False, DeviceStatus.ERROR
|
||||
except Exception as e:
|
||||
self.logger.error(f"隐藏设备{idx}窗口失败: {str(e)}")
|
||||
return False, DeviceStatus.ERROR
|
||||
|
||||
async def show_device(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
显示设备窗口
|
||||
|
||||
Args:
|
||||
idx: 设备ID
|
||||
|
||||
Returns:
|
||||
tuple[bool, int]: (是否成功, 状态码)
|
||||
"""
|
||||
try:
|
||||
status = await self.get_status(idx)
|
||||
if status != DeviceStatus.ONLINE:
|
||||
return False, status
|
||||
|
||||
if (
|
||||
idx not in self.process_managers
|
||||
or not self.process_managers[idx].main_pid
|
||||
):
|
||||
return False, DeviceStatus.NOT_FOUND
|
||||
|
||||
# 窗口显示功能(简化实现)
|
||||
# 注意:完整的窗口显示功能需要更复杂的Windows API调用
|
||||
self.logger.info(f"设备{idx}窗口显示请求已处理(简化实现)")
|
||||
return True, DeviceStatus.ONLINE
|
||||
|
||||
self.logger.info(f"设备{idx}窗口已显示")
|
||||
return True, DeviceStatus.ONLINE
|
||||
|
||||
except ImportError:
|
||||
self.logger.warning("显示窗口功能需要pywin32库")
|
||||
return False, DeviceStatus.ERROR
|
||||
except Exception as e:
|
||||
self.logger.error(f"显示设备{idx}窗口失败: {str(e)}")
|
||||
return False, DeviceStatus.ERROR
|
||||
|
||||
async def get_all_info(self) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
获取所有设备信息
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, str]]: 设备信息字典
|
||||
结构示例:
|
||||
{
|
||||
"0": {
|
||||
"title": "设备名称",
|
||||
"status": "1"
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = {}
|
||||
|
||||
for idx in list(self.process_managers.keys()):
|
||||
try:
|
||||
status = await self.get_status(idx)
|
||||
|
||||
if idx in self.device_info:
|
||||
title = self.device_info[idx].get("title", f"{self.name}_{idx}")
|
||||
else:
|
||||
title = f"{self.name}_{idx}"
|
||||
|
||||
result[idx] = {"title": title, "status": str(status)}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"获取设备{idx}信息失败: {str(e)}")
|
||||
result[idx] = {
|
||||
"title": f"{self.name}_{idx}",
|
||||
"status": str(DeviceStatus.ERROR),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""
|
||||
清理所有资源
|
||||
"""
|
||||
self.logger.info("开始清理设备管理器资源")
|
||||
|
||||
for idx, pm in list(self.process_managers.items()):
|
||||
try:
|
||||
if await pm.is_running():
|
||||
await pm.kill(if_force=True)
|
||||
await pm.clear()
|
||||
except Exception as e:
|
||||
self.logger.error(f"清理设备{idx}资源失败: {str(e)}")
|
||||
|
||||
self.process_managers.clear()
|
||||
self.device_info.clear()
|
||||
|
||||
self.logger.info("设备管理器资源清理完成")
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数,确保资源被正确释放"""
|
||||
try:
|
||||
# 注意:析构函数中不能使用async/await
|
||||
# 这里只是标记,实际清理需要显式调用cleanup()
|
||||
if hasattr(self, "process_managers") and self.process_managers:
|
||||
self.logger.warning("设备管理器未正确清理,请显式调用cleanup()方法")
|
||||
except: # noqa: E722
|
||||
pass
|
||||
|
||||
|
||||
# 使用示例
|
||||
if __name__ == "__main__":
|
||||
|
||||
async def main():
|
||||
# 创建通用设备管理器
|
||||
manager = GeneralDeviceManager(
|
||||
executable_path=r"C:\Windows\System32\notepad.exe", name="记事本"
|
||||
)
|
||||
|
||||
try:
|
||||
# 启动设备
|
||||
success, status, info = await manager.start("0")
|
||||
print(f"启动结果: {success}, 状态: {status}, 信息: {info}")
|
||||
|
||||
if success:
|
||||
# 获取所有设备信息
|
||||
all_info = await manager.get_all_info()
|
||||
print(f"所有设备信息: {all_info}")
|
||||
|
||||
# 等待5秒
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# 关闭设备
|
||||
close_success, close_status = await manager.close("0")
|
||||
print(f"关闭结果: {close_success}, 状态: {close_status}")
|
||||
|
||||
finally:
|
||||
# 清理资源
|
||||
await manager.cleanup()
|
||||
|
||||
# 运行示例
|
||||
asyncio.run(main())
|
||||
@@ -1,278 +0,0 @@
|
||||
"""
|
||||
键盘工具模块
|
||||
|
||||
提供虚拟键码到keyboard库按键名称的转换功能,以及相关的键盘操作辅助函数。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import keyboard
|
||||
from typing import List
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("键盘工具")
|
||||
|
||||
|
||||
def vk_code_to_key_name(vk_code: int) -> str:
|
||||
"""
|
||||
将Windows虚拟键码转换为keyboard库识别的按键名称
|
||||
|
||||
Args:
|
||||
vk_code (int): Windows虚拟键码
|
||||
|
||||
Returns:
|
||||
str: keyboard库识别的按键名称
|
||||
|
||||
Examples:
|
||||
>>> vk_code_to_key_name(0x1B)
|
||||
'esc'
|
||||
>>> vk_code_to_key_name(0x70)
|
||||
'f1'
|
||||
>>> vk_code_to_key_name(0x41)
|
||||
'a'
|
||||
"""
|
||||
# Windows虚拟键码到keyboard库按键名称的映射
|
||||
vk_mapping = {
|
||||
# 常用功能键
|
||||
0x1B: "esc", # VK_ESCAPE
|
||||
0x0D: "enter", # VK_RETURN
|
||||
0x20: "space", # VK_SPACE
|
||||
0x08: "backspace", # VK_BACK
|
||||
0x09: "tab", # VK_TAB
|
||||
0x2E: "delete", # VK_DELETE
|
||||
0x24: "home", # VK_HOME
|
||||
0x23: "end", # VK_END
|
||||
0x21: "page up", # VK_PRIOR
|
||||
0x22: "page down", # VK_NEXT
|
||||
0x2D: "insert", # VK_INSERT
|
||||
# 修饰键
|
||||
0x10: "shift", # VK_SHIFT
|
||||
0x11: "ctrl", # VK_CONTROL
|
||||
0x12: "alt", # VK_MENU
|
||||
0x5B: "left windows", # VK_LWIN
|
||||
0x5C: "right windows", # VK_RWIN
|
||||
0x5D: "apps", # VK_APPS (右键菜单键)
|
||||
# 方向键
|
||||
0x25: "left", # VK_LEFT
|
||||
0x26: "up", # VK_UP
|
||||
0x27: "right", # VK_RIGHT
|
||||
0x28: "down", # VK_DOWN
|
||||
# 功能键 F1-F12
|
||||
0x70: "f1",
|
||||
0x71: "f2",
|
||||
0x72: "f3",
|
||||
0x73: "f4",
|
||||
0x74: "f5",
|
||||
0x75: "f6",
|
||||
0x76: "f7",
|
||||
0x77: "f8",
|
||||
0x78: "f9",
|
||||
0x79: "f10",
|
||||
0x7A: "f11",
|
||||
0x7B: "f12",
|
||||
# 数字键 0-9
|
||||
0x30: "0",
|
||||
0x31: "1",
|
||||
0x32: "2",
|
||||
0x33: "3",
|
||||
0x34: "4",
|
||||
0x35: "5",
|
||||
0x36: "6",
|
||||
0x37: "7",
|
||||
0x38: "8",
|
||||
0x39: "9",
|
||||
# 字母键 A-Z
|
||||
0x41: "a",
|
||||
0x42: "b",
|
||||
0x43: "c",
|
||||
0x44: "d",
|
||||
0x45: "e",
|
||||
0x46: "f",
|
||||
0x47: "g",
|
||||
0x48: "h",
|
||||
0x49: "i",
|
||||
0x4A: "j",
|
||||
0x4B: "k",
|
||||
0x4C: "l",
|
||||
0x4D: "m",
|
||||
0x4E: "n",
|
||||
0x4F: "o",
|
||||
0x50: "p",
|
||||
0x51: "q",
|
||||
0x52: "r",
|
||||
0x53: "s",
|
||||
0x54: "t",
|
||||
0x55: "u",
|
||||
0x56: "v",
|
||||
0x57: "w",
|
||||
0x58: "x",
|
||||
0x59: "y",
|
||||
0x5A: "z",
|
||||
# 数字小键盘
|
||||
0x60: "num 0",
|
||||
0x61: "num 1",
|
||||
0x62: "num 2",
|
||||
0x63: "num 3",
|
||||
0x64: "num 4",
|
||||
0x65: "num 5",
|
||||
0x66: "num 6",
|
||||
0x67: "num 7",
|
||||
0x68: "num 8",
|
||||
0x69: "num 9",
|
||||
0x6A: "num *",
|
||||
0x6B: "num +",
|
||||
0x6D: "num -",
|
||||
0x6E: "num .",
|
||||
0x6F: "num /",
|
||||
0x90: "num lock",
|
||||
# 标点符号和特殊键
|
||||
0xBA: ";", # VK_OEM_1 (;:)
|
||||
0xBB: "=", # VK_OEM_PLUS (=+)
|
||||
0xBC: ",", # VK_OEM_COMMA (,<)
|
||||
0xBD: "-", # VK_OEM_MINUS (-_)
|
||||
0xBE: ".", # VK_OEM_PERIOD (.>)
|
||||
0xBF: "/", # VK_OEM_2 (/?)
|
||||
0xC0: "`", # VK_OEM_3 (`~)
|
||||
0xDB: "[", # VK_OEM_4 ([{)
|
||||
0xDC: "\\", # VK_OEM_5 (\|)
|
||||
0xDD: "]", # VK_OEM_6 (]})
|
||||
0xDE: "'", # VK_OEM_7 ('")
|
||||
# 系统键
|
||||
0x14: "caps lock", # VK_CAPITAL
|
||||
0x91: "scroll lock", # VK_SCROLL
|
||||
0x13: "pause", # VK_PAUSE
|
||||
0x2C: "print screen", # VK_SNAPSHOT
|
||||
# 媒体键
|
||||
0xA0: "left shift", # VK_LSHIFT
|
||||
0xA1: "right shift", # VK_RSHIFT
|
||||
0xA2: "left ctrl", # VK_LCONTROL
|
||||
0xA3: "right ctrl", # VK_RCONTROL
|
||||
0xA4: "left alt", # VK_LMENU
|
||||
0xA5: "right alt", # VK_RMENU
|
||||
}
|
||||
|
||||
return vk_mapping.get(vk_code, f"vk_{vk_code}")
|
||||
|
||||
|
||||
def vk_codes_to_key_names(vk_codes: List[int]) -> List[str]:
|
||||
"""
|
||||
将多个虚拟键码转换为keyboard库识别的按键名称列表
|
||||
|
||||
Args:
|
||||
vk_codes (List[int]): Windows虚拟键码列表
|
||||
|
||||
Returns:
|
||||
List[str]: keyboard库识别的按键名称列表
|
||||
|
||||
Examples:
|
||||
>>> vk_codes_to_key_names([0x11, 0x12, 0x44])
|
||||
['ctrl', 'alt', 'd']
|
||||
"""
|
||||
return [vk_code_to_key_name(vk) for vk in vk_codes]
|
||||
|
||||
|
||||
async def send_key_combination(key_names: List[str], hold_time: float = 0.05) -> bool:
|
||||
"""
|
||||
发送按键组合
|
||||
|
||||
Args:
|
||||
key_names (List[str]): 按键名称列表
|
||||
hold_time (float): 按键保持时间(秒),默认 0.05 秒
|
||||
|
||||
Returns:
|
||||
bool: 操作是否成功
|
||||
|
||||
Examples:
|
||||
>>> await send_key_combination(['ctrl', 'alt', 'd'])
|
||||
True
|
||||
>>> await send_key_combination(['f1'])
|
||||
True
|
||||
"""
|
||||
try:
|
||||
if not key_names:
|
||||
logger.warning("按键名称列表为空")
|
||||
return False
|
||||
|
||||
if len(key_names) == 1:
|
||||
# 单个按键
|
||||
keyboard.press_and_release(key_names[0])
|
||||
logger.debug(f"发送单个按键: {key_names[0]}")
|
||||
else:
|
||||
# 组合键:按下所有键,然后释放
|
||||
logger.debug(f"发送组合键: {'+'.join(key_names)}")
|
||||
for key in key_names:
|
||||
keyboard.press(key)
|
||||
await asyncio.sleep(hold_time) # 保持按键状态
|
||||
for key in reversed(key_names):
|
||||
keyboard.release(key)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送按键组合失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def send_vk_combination(vk_codes: List[int], hold_time: float = 0.05) -> bool:
|
||||
"""
|
||||
发送虚拟键码组合
|
||||
|
||||
Args:
|
||||
vk_codes (List[int]): Windows虚拟键码列表
|
||||
hold_time (float): 按键保持时间(秒),默认 0.05 秒
|
||||
|
||||
Returns:
|
||||
bool: 操作是否成功
|
||||
|
||||
Examples:
|
||||
>>> await send_vk_combination([0x11, 0x12, 0x44]) # Ctrl+Alt+D
|
||||
True
|
||||
>>> await send_vk_combination([0x70]) # F1
|
||||
True
|
||||
"""
|
||||
try:
|
||||
key_names = vk_codes_to_key_names(vk_codes)
|
||||
return await send_key_combination(key_names, hold_time)
|
||||
except Exception as e:
|
||||
logger.error(f"发送虚拟键码组合失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_common_boss_keys() -> dict[str, List[int]]:
|
||||
"""
|
||||
获取常见的BOSS键组合
|
||||
|
||||
Returns:
|
||||
dict[str, List[int]]: 常见BOSS键组合的名称和对应的虚拟键码
|
||||
"""
|
||||
return {
|
||||
"alt_tab": [0x12, 0x09], # Alt+Tab
|
||||
"ctrl_alt_d": [0x11, 0x12, 0x44], # Ctrl+Alt+D
|
||||
"win_d": [0x5B, 0x44], # Win+D (显示桌面)
|
||||
"win_m": [0x5B, 0x4D], # Win+M (最小化所有窗口)
|
||||
"f1": [0x70], # F1
|
||||
"f2": [0x71], # F2
|
||||
"f3": [0x72], # F3
|
||||
"f4": [0x73], # F4
|
||||
"alt_f4": [0x12, 0x73], # Alt+F4 (关闭窗口)
|
||||
"ctrl_shift_esc": [0x11, 0x10, 0x1B], # Ctrl+Shift+Esc (任务管理器)
|
||||
}
|
||||
|
||||
|
||||
def describe_vk_combination(vk_codes: List[int]) -> str:
|
||||
"""
|
||||
描述虚拟键码组合
|
||||
|
||||
Args:
|
||||
vk_codes (List[int]): Windows虚拟键码列表
|
||||
|
||||
Returns:
|
||||
str: 组合键的描述字符串
|
||||
|
||||
Examples:
|
||||
>>> describe_vk_combination([0x11, 0x12, 0x44])
|
||||
'Ctrl+Alt+D'
|
||||
>>> describe_vk_combination([0x70])
|
||||
'F1'
|
||||
"""
|
||||
key_names = vk_codes_to_key_names(vk_codes)
|
||||
return "+".join(name.title() for name in key_names)
|
||||
@@ -1,329 +0,0 @@
|
||||
import asyncio
|
||||
from typing import Literal
|
||||
from app.utils.device_manager.utils import BaseDevice, ExeRunner, DeviceStatus
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.device_manager.keyboard_utils import (
|
||||
vk_codes_to_key_names,
|
||||
send_key_combination,
|
||||
)
|
||||
import psutil
|
||||
from pydantic import BaseModel
|
||||
import win32gui
|
||||
|
||||
|
||||
class EmulatorInfo(BaseModel):
|
||||
idx: int
|
||||
title: str
|
||||
top_hwnd: int
|
||||
bind_hwnd: int
|
||||
in_android: int
|
||||
pid: int
|
||||
vbox_pid: int
|
||||
width: int
|
||||
height: int
|
||||
density: int
|
||||
|
||||
|
||||
class LDManager(BaseDevice):
|
||||
"""
|
||||
基于dnconsole.exe的模拟器管理
|
||||
|
||||
!需要管理员权限
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, exe_path: str) -> None:
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
exe_path (str): dnconsole.exe的绝对路径
|
||||
"""
|
||||
self.runner = ExeRunner(exe_path, "gbk")
|
||||
self.logger = get_logger("雷电模拟器管理器")
|
||||
self.wait_time = 60 # 配置获取 后续改一下 单位为s
|
||||
|
||||
async def start(self, idx: str, package_name="") -> tuple[bool, int, dict]:
|
||||
"""
|
||||
启动指定模拟器
|
||||
Returns:
|
||||
tuple[bool, int, str]: 是否成功, 当前状态码, ADB端口信息
|
||||
"""
|
||||
OK, info, status = await self.get_device_info(idx)
|
||||
if status != DeviceStatus.OFFLINE:
|
||||
self.logger.error(
|
||||
f"模拟器{idx}未处于关闭状态,当前状态码: {status}, 需求状态码: {DeviceStatus.OFFLINE}"
|
||||
)
|
||||
return False, status, {}
|
||||
if package_name:
|
||||
result = self.runner.run(
|
||||
"launch",
|
||||
"--index",
|
||||
idx,
|
||||
"--packagename",
|
||||
f'"{package_name}"',
|
||||
)
|
||||
else:
|
||||
result = self.runner.run(
|
||||
"launch",
|
||||
"--index",
|
||||
idx,
|
||||
)
|
||||
# 参考命令 dnconsole.exe launch --index 0
|
||||
self.logger.debug(f"启动结果:{result}")
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"命令执行失败: {result}")
|
||||
|
||||
i = 0
|
||||
while i < self.wait_time * 10:
|
||||
OK, info, status = await self.get_device_info(idx)
|
||||
if status == DeviceStatus.ERROR or status == DeviceStatus.UNKNOWN:
|
||||
self.logger.error(f"模拟器{idx}启动失败,状态码: {status}")
|
||||
return False, status, {}
|
||||
if status == DeviceStatus.ONLINE:
|
||||
self.logger.debug(info)
|
||||
if OK and isinstance(info, EmulatorInfo):
|
||||
pid: int = info.vbox_pid
|
||||
adb_port = ""
|
||||
adb_host_ip = await self.get_adb_ports(pid)
|
||||
print(adb_host_ip)
|
||||
if adb_host_ip:
|
||||
return (
|
||||
True,
|
||||
status,
|
||||
{"adb_port": adb_port, "adb_host_ip": adb_host_ip},
|
||||
)
|
||||
|
||||
return True, status, {}
|
||||
await asyncio.sleep(0.1)
|
||||
i += 1
|
||||
return False, DeviceStatus.UNKNOWN, {}
|
||||
|
||||
async def close(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
关闭指定模拟器
|
||||
Returns:
|
||||
- tuple[bool, int]: 是否成功, 当前状态码
|
||||
|
||||
参考命令行:dnconsole.exe quit --index 0
|
||||
"""
|
||||
OK, info, status = await self.get_device_info(idx)
|
||||
if status != DeviceStatus.ONLINE and status != DeviceStatus.STARTING:
|
||||
return False, DeviceStatus.NOT_FOUND
|
||||
result = self.runner.run(
|
||||
"quit",
|
||||
"--index",
|
||||
idx,
|
||||
)
|
||||
# 参考命令 dnconsole.exe quit --index 0
|
||||
if result.returncode != 0:
|
||||
return True, DeviceStatus.OFFLINE
|
||||
i = 0
|
||||
while i < self.wait_time * 10:
|
||||
OK, info, status = await self.get_device_info(idx)
|
||||
if status == DeviceStatus.ERROR or status == DeviceStatus.UNKNOWN:
|
||||
return False, status
|
||||
if status == DeviceStatus.OFFLINE:
|
||||
return True, DeviceStatus.OFFLINE
|
||||
await asyncio.sleep(0.1)
|
||||
i += 1
|
||||
|
||||
return False, DeviceStatus.UNKNOWN
|
||||
|
||||
async def get_status(self, idx: str) -> int:
|
||||
"""
|
||||
获取指定模拟器当前状态
|
||||
返回值: 状态码
|
||||
"""
|
||||
_, _, status = await self.get_device_info(idx)
|
||||
return status
|
||||
|
||||
async def get_device_info(
|
||||
self,
|
||||
idx: str,
|
||||
data: dict[int, EmulatorInfo] | None = None,
|
||||
) -> tuple[Literal[True], EmulatorInfo, int] | tuple[Literal[False], dict, int]:
|
||||
"""
|
||||
获取指定模拟器的信息和状态
|
||||
Returns:
|
||||
- tuple[bool, EmulatorInfo | dict, int]: 是否成功, 模拟器信息或空字典, 状态码
|
||||
|
||||
参考命令行:dnconsole.exe list2
|
||||
"""
|
||||
if not data:
|
||||
result = await self._get_all_info()
|
||||
else:
|
||||
result = data
|
||||
|
||||
try:
|
||||
emulator_info = result.get(int(idx))
|
||||
print(emulator_info)
|
||||
if not emulator_info:
|
||||
self.logger.error(f"未找到模拟器{idx}的信息")
|
||||
return False, {}, DeviceStatus.UNKNOWN
|
||||
|
||||
self.logger.debug(f"获取模拟器{idx}信息: {emulator_info}")
|
||||
|
||||
# 计算状态码
|
||||
if emulator_info.in_android == 1:
|
||||
status = DeviceStatus.ONLINE
|
||||
elif emulator_info.in_android == 2:
|
||||
if emulator_info.vbox_pid > 0:
|
||||
status = DeviceStatus.STARTING
|
||||
# 雷电启动后, vbox_pid为-1, 目前不知道有什么区别
|
||||
else:
|
||||
status = DeviceStatus.STARTING
|
||||
elif emulator_info.in_android == 0:
|
||||
status = DeviceStatus.OFFLINE
|
||||
else:
|
||||
status = DeviceStatus.UNKNOWN
|
||||
|
||||
self.logger.debug(f"获取模拟器{idx}状态: {status}")
|
||||
return True, emulator_info, status
|
||||
except: # noqa: E722
|
||||
self.logger.error(f"获取模拟器{idx}信息失败")
|
||||
return False, {}, DeviceStatus.UNKNOWN
|
||||
|
||||
async def _get_all_info(self) -> dict[int, EmulatorInfo]:
|
||||
result = self.runner.run("list2")
|
||||
# self.logger.debug(f"全部信息{result.stdout.strip()}")
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"命令执行失败: {result}")
|
||||
|
||||
emulators: dict[int, EmulatorInfo] = {}
|
||||
data = result.stdout.strip()
|
||||
|
||||
for line in data.strip().splitlines():
|
||||
parts = line.strip().split(",")
|
||||
if len(parts) != 10:
|
||||
raise ValueError(f"数据格式错误: {line}")
|
||||
try:
|
||||
info = EmulatorInfo(
|
||||
idx=int(parts[0]),
|
||||
title=parts[1],
|
||||
top_hwnd=int(parts[2]),
|
||||
bind_hwnd=int(parts[3]),
|
||||
in_android=int(parts[4]),
|
||||
pid=int(parts[5]),
|
||||
vbox_pid=int(parts[6]),
|
||||
width=int(parts[7]),
|
||||
height=int(parts[8]),
|
||||
density=int(parts[9]),
|
||||
)
|
||||
emulators[info.idx] = info
|
||||
except Exception as e:
|
||||
self.logger.warning(f"解析失败: {line}, 错误: {e}")
|
||||
pass
|
||||
return emulators
|
||||
|
||||
# ?wk雷电你都返回了什么啊
|
||||
|
||||
async def get_all_info(self) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
解析_emulator_info字典,提取idx和title,便于前端显示
|
||||
"""
|
||||
raw_data = await self._get_all_info()
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
for info in raw_data.values():
|
||||
OK, device_info, status = await self.get_device_info(
|
||||
str(info.idx), raw_data
|
||||
)
|
||||
result[str(info.idx)] = {"title": info.title, "status": str(status)}
|
||||
return result
|
||||
|
||||
async def send_boss_key(
|
||||
self,
|
||||
boss_keys: list[int],
|
||||
result: EmulatorInfo,
|
||||
is_show: bool = False,
|
||||
# True: 显示, False: 隐藏
|
||||
) -> bool:
|
||||
"""
|
||||
发送BOSS键
|
||||
|
||||
Args:
|
||||
idx (str): 模拟器索引
|
||||
boss_keys (list[int]): BOSS键的虚拟键码列表
|
||||
result (EmulatorInfo): 模拟器信息
|
||||
is_show (bool, optional): 将要隐藏或显示窗口,默认为 False(隐藏)。
|
||||
"""
|
||||
hwnd = result.top_hwnd
|
||||
try:
|
||||
# 使用键盘工具发送按键组合
|
||||
success = await send_key_combination(vk_codes_to_key_names(boss_keys))
|
||||
if not success:
|
||||
return False
|
||||
|
||||
# 等待系统处理
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 检查窗口可见性是否符合预期
|
||||
current_visible = win32gui.IsWindowVisible(hwnd)
|
||||
expected_visible = is_show
|
||||
|
||||
if current_visible == expected_visible:
|
||||
return True
|
||||
else:
|
||||
# 如果第一次没有成功,再试一次
|
||||
success = await send_key_combination(vk_codes_to_key_names(boss_keys))
|
||||
if not success:
|
||||
return False
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
current_visible = win32gui.IsWindowVisible(hwnd)
|
||||
return current_visible == expected_visible
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"发送BOSS键失败: {e}")
|
||||
return False
|
||||
|
||||
async def hide_device(
|
||||
self,
|
||||
idx: str,
|
||||
boss_keys: list[int] = [],
|
||||
) -> tuple[bool, int]:
|
||||
"""隐藏设备窗口"""
|
||||
OK, result, status = await self.get_device_info(idx)
|
||||
if not OK or not isinstance(result, EmulatorInfo):
|
||||
return False, DeviceStatus.UNKNOWN
|
||||
if status != DeviceStatus.ONLINE:
|
||||
return False, status
|
||||
|
||||
return await self.send_boss_key(boss_keys, result, False), status
|
||||
|
||||
async def show_device(
|
||||
self,
|
||||
idx: str,
|
||||
boss_keys: list[int] = [],
|
||||
) -> tuple[bool, int]:
|
||||
"""显示设备窗口"""
|
||||
OK, result, status = await self.get_device_info(idx)
|
||||
if not OK or not isinstance(result, EmulatorInfo):
|
||||
return False, DeviceStatus.UNKNOWN
|
||||
if status != DeviceStatus.ONLINE:
|
||||
return False, status
|
||||
|
||||
return await self.send_boss_key(boss_keys, result, True), status
|
||||
|
||||
async def get_adb_ports(self, pid: int) -> int:
|
||||
"""使用psutil获取adb端口"""
|
||||
try:
|
||||
process = psutil.Process(pid)
|
||||
connections = process.connections(kind="inet")
|
||||
for conn in connections:
|
||||
if conn.status == psutil.CONN_LISTEN and conn.laddr.port != 2222:
|
||||
return conn.laddr.port
|
||||
return 0 # 如果没有找到合适的端口,返回0
|
||||
except: # noqa: E722
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
MANAGER_PATH = (
|
||||
r"C:\leidian\LDPlayer9\dnconsole.exe" # 替换为实际的dnconsole.exe路径
|
||||
)
|
||||
idx = "0" # 替换为实际存在的模拟器实例索
|
||||
|
||||
manager = LDManager(MANAGER_PATH)
|
||||
# asyncio.run(manager._get_all_info())
|
||||
a = asyncio.run(manager.start("0"))
|
||||
print(a)
|
||||
@@ -1,220 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
from app.utils.device_manager.utils import BaseDevice, ExeRunner, DeviceStatus
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
|
||||
class MumuManager(BaseDevice):
|
||||
"""
|
||||
基于MuMuManager.exe的模拟器管理
|
||||
"""
|
||||
|
||||
def __init__(self, exe_path: str) -> None:
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
exe_path (str): MuMuManager.exe的绝对路径
|
||||
"""
|
||||
self.runner = ExeRunner(exe_path, "utf-8")
|
||||
self.logger = get_logger("MuMu管理器")
|
||||
self.wait_time = 60 # 配置获取 后续改一下 单位为s
|
||||
|
||||
async def start(self, idx: str, package_name="") -> tuple[bool, int, dict]:
|
||||
"""
|
||||
启动指定模拟器
|
||||
Returns:
|
||||
tuple[bool, int, str]: 是否成功, 当前状态码, ADB端口信息
|
||||
"""
|
||||
status = await self.get_status(idx)
|
||||
if status != DeviceStatus.OFFLINE:
|
||||
self.logger.error(
|
||||
f"模拟器{idx}未处于关闭状态,当前状态码: {status}, 需求状态码: {DeviceStatus.OFFLINE}"
|
||||
)
|
||||
return False, status, {}
|
||||
if package_name:
|
||||
result = self.runner.run(
|
||||
"control",
|
||||
"-v",
|
||||
idx,
|
||||
"launch",
|
||||
"-pkg",
|
||||
package_name,
|
||||
)
|
||||
else:
|
||||
result = self.runner.run(
|
||||
"control",
|
||||
"-v",
|
||||
idx,
|
||||
"launch",
|
||||
)
|
||||
# 参考命令 MuMuManager.exe control -v 2 launch
|
||||
self.logger.debug(f"启动结果:{result}")
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"命令执行失败: {result}")
|
||||
|
||||
i = 0
|
||||
while i < self.wait_time * 10:
|
||||
status = await self.get_status(idx)
|
||||
if status == DeviceStatus.ERROR or status == DeviceStatus.UNKNOWN:
|
||||
self.logger.error(f"模拟器{idx}启动失败,状态码: {status}")
|
||||
return False, status, {}
|
||||
if status == DeviceStatus.ONLINE:
|
||||
OK, info = await self.get_device_info(idx)
|
||||
self.logger.debug(info)
|
||||
if OK:
|
||||
data = json.loads(info)
|
||||
adb_port = data.get("adb_port")
|
||||
adb_host_ip = data.get("adb_host_ip")
|
||||
if adb_port and adb_host_ip:
|
||||
return (
|
||||
True,
|
||||
status,
|
||||
{"adb_port": adb_port, "adb_host_ip": adb_host_ip},
|
||||
)
|
||||
|
||||
return True, status, {}
|
||||
await asyncio.sleep(0.1)
|
||||
i += 1
|
||||
return False, DeviceStatus.UNKNOWN, {}
|
||||
|
||||
async def close(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
关闭指定模拟器
|
||||
Returns:
|
||||
tuple[bool, int]: 是否成功, 当前状态码
|
||||
"""
|
||||
status = await self.get_status(idx)
|
||||
if status != DeviceStatus.ONLINE and status != DeviceStatus.STARTING:
|
||||
return False, DeviceStatus.NOT_FOUND
|
||||
result = self.runner.run(
|
||||
"control",
|
||||
"-v",
|
||||
idx,
|
||||
"shutdown",
|
||||
)
|
||||
# 参考命令 MuMuManager.exe control -v 2 shutdown
|
||||
if result.returncode != 0:
|
||||
return True, DeviceStatus.OFFLINE
|
||||
i = 0
|
||||
while i < self.wait_time * 10:
|
||||
status = await self.get_status(idx)
|
||||
if status == DeviceStatus.ERROR or status == DeviceStatus.UNKNOWN:
|
||||
return False, status
|
||||
if status == DeviceStatus.OFFLINE:
|
||||
return True, DeviceStatus.OFFLINE
|
||||
await asyncio.sleep(0.1)
|
||||
i += 1
|
||||
|
||||
return False, DeviceStatus.UNKNOWN
|
||||
|
||||
async def get_status(self, idx: str, data: str | None = None) -> int:
|
||||
if not data:
|
||||
OK, result_str = await self.get_device_info(idx)
|
||||
self.logger.debug(f"获取状态结果{result_str}")
|
||||
else:
|
||||
OK, result_str = True, data
|
||||
|
||||
try:
|
||||
result_json = json.loads(result_str)
|
||||
|
||||
if OK:
|
||||
if result_json["is_android_started"]:
|
||||
return DeviceStatus.STARTING
|
||||
elif result_json["is_process_started"]:
|
||||
return DeviceStatus.ONLINE
|
||||
else:
|
||||
return DeviceStatus.OFFLINE
|
||||
|
||||
else:
|
||||
if result_json["errmsg"] == "unknown error":
|
||||
return DeviceStatus.UNKNOWN
|
||||
else:
|
||||
return DeviceStatus.ERROR
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"JSON解析错误: {e}")
|
||||
return DeviceStatus.UNKNOWN
|
||||
|
||||
async def get_device_info(self, idx: str) -> tuple[bool, str]:
|
||||
result = self.runner.run(
|
||||
"info",
|
||||
"-v",
|
||||
idx,
|
||||
)
|
||||
self.logger.debug(f"获取模拟器{idx}信息: {result}")
|
||||
if result.returncode != 0:
|
||||
return False, result.stdout.strip()
|
||||
else:
|
||||
return True, result.stdout.strip()
|
||||
|
||||
async def _get_all_info(self) -> str:
|
||||
result = self.runner.run(
|
||||
"info",
|
||||
"-v",
|
||||
"all",
|
||||
)
|
||||
# self.logger.debug(f"result{result.stdout.strip()}")
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"命令执行失败: {result}")
|
||||
return result.stdout.strip()
|
||||
|
||||
async def get_all_info(self) -> dict[str, dict[str, str]]:
|
||||
json_data = await self._get_all_info()
|
||||
data = json.loads(json_data)
|
||||
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
|
||||
if not data:
|
||||
return result
|
||||
|
||||
if isinstance(data, dict) and "index" in data and "name" in data:
|
||||
index = data["index"]
|
||||
name = data["name"]
|
||||
status = self.get_status(index, json_data)
|
||||
result[index] = {
|
||||
"title": name,
|
||||
"status": str(status),
|
||||
}
|
||||
|
||||
elif isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict) and "index" in value and "name" in value:
|
||||
index = value["index"]
|
||||
name = value["name"]
|
||||
status = await self.get_status(index)
|
||||
result[index] = {
|
||||
"title": name,
|
||||
"status": str(status),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def hide_device(self, idx: str) -> tuple[bool, int]:
|
||||
"""隐藏设备窗口"""
|
||||
status = await self.get_status(idx)
|
||||
if status != DeviceStatus.ONLINE:
|
||||
return False, status
|
||||
result = self.runner.run(
|
||||
"control",
|
||||
"-v",
|
||||
idx,
|
||||
"hide_window",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False, status
|
||||
return True, DeviceStatus.ONLINE
|
||||
|
||||
async def show_device(self, idx: str) -> tuple[bool, int]:
|
||||
"""显示设备窗口"""
|
||||
status = await self.get_status(idx)
|
||||
if status != DeviceStatus.ONLINE:
|
||||
return False, status
|
||||
result = self.runner.run(
|
||||
"control",
|
||||
"-v",
|
||||
idx,
|
||||
"show_window",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False, status
|
||||
return True, DeviceStatus.ONLINE
|
||||
@@ -1,104 +0,0 @@
|
||||
import subprocess
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class DeviceStatus(IntEnum):
|
||||
ONLINE = 0
|
||||
"""设备在线"""
|
||||
OFFLINE = 1
|
||||
"""设备离线"""
|
||||
STARTING = 2
|
||||
"""设备开启中"""
|
||||
CLOSEING = 3
|
||||
"""设备关闭中"""
|
||||
ERROR = 4
|
||||
"""错误"""
|
||||
NOT_FOUND = 5
|
||||
"""未找到设备"""
|
||||
UNKNOWN = 10
|
||||
|
||||
|
||||
class ExeRunner:
|
||||
def __init__(self, exe_path, encoding) -> None:
|
||||
"""
|
||||
指定 exe 路径
|
||||
!请传入绝对路径,使用/分隔路径
|
||||
"""
|
||||
if not os.path.isfile(exe_path):
|
||||
raise FileNotFoundError(f"找不到文件: {exe_path}")
|
||||
self.exe_path = os.path.abspath(exe_path) # 转为绝对路径
|
||||
self.encoding = encoding
|
||||
|
||||
def run(self, *args) -> subprocess.CompletedProcess[str]:
|
||||
"""
|
||||
执行命令,返回结果
|
||||
"""
|
||||
cmd = [self.exe_path] + list(args)
|
||||
print(f"执行: {' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding=self.encoding,
|
||||
errors="replace",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class BaseDevice(ABC):
|
||||
@abstractmethod
|
||||
async def start(self, idx: str, package_name: str) -> tuple[bool, int, dict]:
|
||||
"""
|
||||
启动设备
|
||||
返回值: (是否成功, 状态码, 启动信息)
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
关闭设备或服务
|
||||
返回值: (是否成功, 状态码)
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_status(self, idx: str) -> int:
|
||||
"""
|
||||
获取指定模拟器当前状态
|
||||
返回值: 状态码
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def hide_device(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
隐藏设备窗口
|
||||
返回值: (是否成功, 状态码)
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def show_device(self, idx: str) -> tuple[bool, int]:
|
||||
"""
|
||||
显示设备窗口
|
||||
返回值: (是否成功, 状态码)
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_all_info(self) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
获取设备信息
|
||||
返回值: 设备字典
|
||||
结构示例:
|
||||
{
|
||||
"0":{
|
||||
"title": 模拟器名字,
|
||||
"status": "1"
|
||||
}
|
||||
}
|
||||
"""
|
||||
...
|
||||
@@ -1,70 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 MoeSnowyFox
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
from loguru import logger as _logger
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
(Path.cwd() / "debug").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
_logger.remove()
|
||||
|
||||
|
||||
_logger.add(
|
||||
sink=Path.cwd() / "debug/app.log",
|
||||
level="INFO",
|
||||
format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{extra[module]}</cyan> | <level>{message}</level>",
|
||||
enqueue=True,
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
rotation="1 week",
|
||||
retention="1 month",
|
||||
compression="zip",
|
||||
)
|
||||
|
||||
_logger.add(
|
||||
sink=sys.stderr,
|
||||
level="DEBUG",
|
||||
format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{extra[module]}</cyan> | <level>{message}</level>",
|
||||
enqueue=True,
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
colorize=True,
|
||||
)
|
||||
|
||||
|
||||
_logger = _logger.patch(lambda record: record["extra"].setdefault("module", "未知模块"))
|
||||
|
||||
|
||||
def get_logger(module_name: str):
|
||||
"""
|
||||
获取一个绑定 module 名的日志器
|
||||
|
||||
:param module_name: 模块名称, 如 "用户管理"
|
||||
:return: 绑定后的 logger
|
||||
"""
|
||||
return _logger.bind(module=module_name)
|
||||
|
||||
|
||||
__all__ = ["get_logger"]
|
||||
143
app/utils/package.py
Normal file
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,70 +0,0 @@
|
||||
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
|
||||
# Copyright © 2024-2025 DLmaster361
|
||||
# Copyright © 2025 AUTO-MAS Team
|
||||
|
||||
# This file is part of AUTO-MAS.
|
||||
|
||||
# AUTO-MAS 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-MAS 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-MAS. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Contact: DLmaster_361@163.com
|
||||
|
||||
|
||||
import base64
|
||||
import win32crypt
|
||||
|
||||
|
||||
def dpapi_encrypt(
|
||||
note: str, description: None | str = None, entropy: None | 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 dpapi_decrypt(note: str, entropy: None | 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")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user