diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml deleted file mode 100644 index a2da0b6..0000000 --- a/.github/workflows/build-app.yml +++ /dev/null @@ -1,283 +0,0 @@ -# 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 . - -# 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\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 }} diff --git a/.github/workflows/mirrorchyan.yml b/.github/workflows/mirrorchyan.yml deleted file mode 100644 index 604328c..0000000 --- a/.github/workflows/mirrorchyan.yml +++ /dev/null @@ -1,21 +0,0 @@ -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 }} diff --git a/.github/workflows/mirrorchyan_release_note.yml b/.github/workflows/mirrorchyan_release_note.yml deleted file mode 100644 index deb0274..0000000 --- a/.github/workflows/mirrorchyan_release_note.yml +++ /dev/null @@ -1,19 +0,0 @@ -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 }} diff --git a/.gitignore b/.gitignore index eb5a04e..5366a11 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,31 @@ +# 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/ -resources/notice.json -resources/theme_image.json -resources/images/Home/BannerTheme.jpg \ No newline at end of file +res/notice.json +res/theme_image.json +res/images/Home/BannerTheme.jpg \ No newline at end of file diff --git a/Go_Updater/Makefile b/Go_Updater/Makefile deleted file mode 100644 index 34bacca..0000000 --- a/Go_Updater/Makefile +++ /dev/null @@ -1,116 +0,0 @@ -# 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" \ No newline at end of file diff --git a/Go_Updater/README.MD b/Go_Updater/README.MD deleted file mode 100644 index b5832d6..0000000 --- a/Go_Updater/README.MD +++ /dev/null @@ -1,15 +0,0 @@ -# 用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"` \ No newline at end of file diff --git a/Go_Updater/api/client.go b/Go_Updater/api/client.go deleted file mode 100644 index ef28e82..0000000 --- a/Go_Updater/api/client.go +++ /dev/null @@ -1,290 +0,0 @@ -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) - - // 比较每个组件 - maxLen := len(parts1) - if len(parts2) > maxLen { - maxLen = len(parts2) - } - - for i := 0; i < maxLen; 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 - } - } - - 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 - } - } - 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) -} diff --git a/Go_Updater/api/client_test.go b/Go_Updater/api/client_test.go deleted file mode 100644 index bdb9f72..0000000 --- a/Go_Updater/api/client_test.go +++ /dev/null @@ -1,186 +0,0 @@ -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) - } - }) - } -} diff --git a/Go_Updater/app.rc b/Go_Updater/app.rc deleted file mode 100644 index 7cc1f3b..0000000 --- a/Go_Updater/app.rc +++ /dev/null @@ -1,34 +0,0 @@ -#include - -// 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 \ No newline at end of file diff --git a/Go_Updater/app.syso b/Go_Updater/app.syso deleted file mode 100644 index 6bf5237..0000000 Binary files a/Go_Updater/app.syso and /dev/null differ diff --git a/Go_Updater/assets/assets.go b/Go_Updater/assets/assets.go deleted file mode 100644 index 557ccaf..0000000 --- a/Go_Updater/assets/assets.go +++ /dev/null @@ -1,34 +0,0 @@ -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 -} diff --git a/Go_Updater/assets/assets_test.go b/Go_Updater/assets/assets_test.go deleted file mode 100644 index 0bcab5a..0000000 --- a/Go_Updater/assets/assets_test.go +++ /dev/null @@ -1,100 +0,0 @@ -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) -} diff --git a/Go_Updater/assets/config_template.yaml b/Go_Updater/assets/config_template.yaml deleted file mode 100644 index bac2b03..0000000 --- a/Go_Updater/assets/config_template.yaml +++ /dev/null @@ -1,7 +0,0 @@ -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 \ No newline at end of file diff --git a/Go_Updater/build-config.yaml b/Go_Updater/build-config.yaml deleted file mode 100644 index a15503e..0000000 --- a/Go_Updater/build-config.yaml +++ /dev/null @@ -1,55 +0,0 @@ -# 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 \ No newline at end of file diff --git a/Go_Updater/build.bat b/Go_Updater/build.bat deleted file mode 100644 index f71fee2..0000000 --- a/Go_Updater/build.bat +++ /dev/null @@ -1,93 +0,0 @@ -@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=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 ======================================== diff --git a/Go_Updater/build.ps1 b/Go_Updater/build.ps1 deleted file mode 100644 index b4bff73..0000000 --- a/Go_Updater/build.ps1 +++ /dev/null @@ -1,105 +0,0 @@ -# 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 \ No newline at end of file diff --git a/Go_Updater/config/config.go b/Go_Updater/config/config.go deleted file mode 100644 index ded8ea0..0000000 --- a/Go_Updater/config/config.go +++ /dev/null @@ -1,198 +0,0 @@ -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 "." -} diff --git a/Go_Updater/config/config.json b/Go_Updater/config/config.json deleted file mode 100644 index 2a13ef4..0000000 --- a/Go_Updater/config/config.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "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" - } -} \ No newline at end of file diff --git a/Go_Updater/config/config_test.go b/Go_Updater/config/config_test.go deleted file mode 100644 index 36c6f26..0000000 --- a/Go_Updater/config/config_test.go +++ /dev/null @@ -1,153 +0,0 @@ -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) - } -} diff --git a/Go_Updater/download/manager.go b/Go_Updater/download/manager.go deleted file mode 100644 index 07cfd5d..0000000 --- a/Go_Updater/download/manager.go +++ /dev/null @@ -1,224 +0,0 @@ -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 -} \ No newline at end of file diff --git a/Go_Updater/download/manager_test.go b/Go_Updater/download/manager_test.go deleted file mode 100644 index f406e10..0000000 --- a/Go_Updater/download/manager_test.go +++ /dev/null @@ -1,1392 +0,0 @@ -package download - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestNewManager(t *testing.T) { - manager := NewManager() - if manager == nil { - t.Fatal("NewManager() returned nil") - } - if manager.client == nil { - t.Fatal("Manager client is nil") - } - if manager.timeout != 30*time.Second { - t.Errorf("Expected timeout 30s, got %v", manager.timeout) - } -} - -func TestDownload(t *testing.T) { - // Create test content - testContent := "This is test content for download" - - // Create test server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent)) - })) - defer server.Close() - - // Create temporary directory - tempDir, err := os.MkdirTemp("", "download_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - // Test download - manager := NewManager() - destPath := filepath.Join(tempDir, "test_file.txt") - - var progressUpdates []DownloadProgress - progressCallback := func(progress DownloadProgress) { - progressUpdates = append(progressUpdates, progress) - } - - err = manager.Download(server.URL, destPath, progressCallback) - if err != nil { - t.Fatalf("Download failed: %v", err) - } - - // Verify file exists and content - content, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("Failed to read downloaded file: %v", err) - } - - if string(content) != testContent { - t.Errorf("Expected content %q, got %q", testContent, string(content)) - } - - // Verify progress updates - if len(progressUpdates) == 0 { - t.Error("No progress updates received") - } - - // Check final progress - finalProgress := progressUpdates[len(progressUpdates)-1] - if finalProgress.Percentage != 100 { - t.Errorf("Expected final percentage 100, got %f", finalProgress.Percentage) - } - if finalProgress.BytesDownloaded != int64(len(testContent)) { - t.Errorf("Expected bytes downloaded %d, got %d", len(testContent), finalProgress.BytesDownloaded) - } -} - -func TestDownloadWithResume(t *testing.T) { - testContent := "This is a longer test content for resume functionality testing" - partialContent := testContent[:20] // First 20 bytes - remainingContent := testContent[20:] // Remaining bytes - - // Create test server that supports range requests - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rangeHeader := r.Header.Get("Range") - - if rangeHeader != "" { - // Handle range request - if strings.HasPrefix(rangeHeader, "bytes=20-") { - w.Header().Set("Content-Range", fmt.Sprintf("bytes 20-%d/%d", len(testContent)-1, len(testContent))) - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(remainingContent))) - w.WriteHeader(http.StatusPartialContent) - w.Write([]byte(remainingContent)) - return - } - } - - // Handle normal request - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent)) - })) - defer server.Close() - - // Create temporary directory - tempDir, err := os.MkdirTemp("", "download_resume_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - destPath := filepath.Join(tempDir, "test_resume_file.txt") - - // Create partial file - err = os.WriteFile(destPath, []byte(partialContent), 0644) - if err != nil { - t.Fatal(err) - } - - // Test resume download - manager := NewManager() - - var progressUpdates []DownloadProgress - progressCallback := func(progress DownloadProgress) { - progressUpdates = append(progressUpdates, progress) - } - - err = manager.DownloadWithResume(server.URL, destPath, progressCallback) - if err != nil { - t.Fatalf("Resume download failed: %v", err) - } - - // Verify complete file content - content, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("Failed to read resumed file: %v", err) - } - - if string(content) != testContent { - t.Errorf("Expected content %q, got %q", testContent, string(content)) - } -} - -func TestValidateChecksum(t *testing.T) { - // Create test content and calculate its checksum - testContent := "Test content for checksum validation" - hash := sha256.Sum256([]byte(testContent)) - expectedChecksum := hex.EncodeToString(hash[:]) - - // Create temporary file - tempDir, err := os.MkdirTemp("", "checksum_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - testFile := filepath.Join(tempDir, "test_checksum.txt") - err = os.WriteFile(testFile, []byte(testContent), 0644) - if err != nil { - t.Fatal(err) - } - - manager := NewManager() - - // Test valid checksum - err = manager.ValidateChecksum(testFile, expectedChecksum) - if err != nil { - t.Errorf("Valid checksum validation failed: %v", err) - } - - // Test invalid checksum - invalidChecksum := "invalid_checksum_value" - err = manager.ValidateChecksum(testFile, invalidChecksum) - if err == nil { - t.Error("Invalid checksum validation should have failed") - } - - // Test empty checksum (should pass) - err = manager.ValidateChecksum(testFile, "") - if err != nil { - t.Errorf("Empty checksum validation failed: %v", err) - } - - // Test non-existent file - err = manager.ValidateChecksum("non_existent_file.txt", expectedChecksum) - if err == nil { - t.Error("Non-existent file validation should have failed") - } -} - -func TestDownloadError(t *testing.T) { - manager := NewManager() - - // Test invalid URL - err := manager.Download("invalid-url", "/tmp/test", nil) - if err == nil { - t.Error("Download with invalid URL should have failed") - } - - // Test server error - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer server.Close() - - tempDir, err := os.MkdirTemp("", "download_error_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - destPath := filepath.Join(tempDir, "error_test.txt") - err = manager.Download(server.URL, destPath, nil) - if err == nil { - t.Error("Download with server error should have failed") - } -} - -func TestProgressCallback(t *testing.T) { - testContent := strings.Repeat("A", 1024*100) // 100KB content for more progress updates - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.WriteHeader(http.StatusOK) - - // Write content in smaller chunks to trigger multiple progress updates - writer := w.(http.Flusher) - for i := 0; i < len(testContent); i += 1024 { - end := i + 1024 - if end > len(testContent) { - end = len(testContent) - } - w.Write([]byte(testContent[i:end])) - writer.Flush() - time.Sleep(50 * time.Millisecond) // Longer delay to ensure progress updates - } - })) - defer server.Close() - - tempDir, err := os.MkdirTemp("", "progress_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - manager := NewManager() - destPath := filepath.Join(tempDir, "progress_test.txt") - - var progressUpdates []DownloadProgress - progressCallback := func(progress DownloadProgress) { - progressUpdates = append(progressUpdates, progress) - - // Validate progress values - if progress.BytesDownloaded < 0 { - t.Errorf("Negative bytes downloaded: %d", progress.BytesDownloaded) - } - if progress.Percentage < 0 || progress.Percentage > 100 { - t.Errorf("Invalid percentage: %f", progress.Percentage) - } - if progress.Speed < 0 { - t.Errorf("Negative speed: %d", progress.Speed) - } - } - - err = manager.Download(server.URL, destPath, progressCallback) - if err != nil { - t.Fatalf("Download failed: %v", err) - } - - // Should have received at least one progress update (final one is guaranteed) - if len(progressUpdates) < 1 { - t.Errorf("Expected at least one progress update, got %d", len(progressUpdates)) - } - - // Final progress should be 100% - finalProgress := progressUpdates[len(progressUpdates)-1] - if finalProgress.Percentage != 100 { - t.Errorf("Expected final percentage 100, got %f", finalProgress.Percentage) - } - - // Verify that we got the correct total bytes - if finalProgress.BytesDownloaded != int64(len(testContent)) { - t.Errorf("Expected bytes downloaded %d, got %d", len(testContent), finalProgress.BytesDownloaded) - } -} - -func TestDownloadWithSources(t *testing.T) { - testContent := "Test content for multi-source download" - - // Create primary server (Mirror酱 - higher priority) - primaryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent)) - })) - defer primaryServer.Close() - - // Create backup server (regular download site - lower priority) - backupServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent)) - })) - defer backupServer.Close() - - tempDir, err := os.MkdirTemp("", "multi_source_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - manager := NewManager() - destPath := filepath.Join(tempDir, "multi_source_test.txt") - - // Test with multiple sources - should use primary (lower priority number) - sources := []DownloadSource{ - {URL: backupServer.URL, Priority: 2, Name: "Backup Server"}, - {URL: primaryServer.URL, Priority: 1, Name: "Mirror Server"}, // Higher priority - } - - var progressUpdates []DownloadProgress - progressCallback := func(progress DownloadProgress) { - progressUpdates = append(progressUpdates, progress) - } - - err = manager.DownloadWithSources(sources, destPath, progressCallback) - if err != nil { - t.Fatalf("Multi-source download failed: %v", err) - } - - // Verify file content - content, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("Failed to read downloaded file: %v", err) - } - - if string(content) != testContent { - t.Errorf("Expected content %q, got %q", testContent, string(content)) - } - - // Verify progress updates - if len(progressUpdates) == 0 { - t.Error("No progress updates received") - } -} - -func TestDownloadWithSourcesFallback(t *testing.T) { - testContent := "Test content for fallback download" - - // Create failing primary server - failingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer failingServer.Close() - - // Create working backup server - workingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent)) - })) - defer workingServer.Close() - - tempDir, err := os.MkdirTemp("", "fallback_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - manager := NewManager() - destPath := filepath.Join(tempDir, "fallback_test.txt") - - // Test fallback - primary fails, backup succeeds - sources := []DownloadSource{ - {URL: failingServer.URL, Priority: 1, Name: "Failing Server"}, - {URL: workingServer.URL, Priority: 2, Name: "Working Server"}, - } - - err = manager.DownloadWithSources(sources, destPath, nil) - if err != nil { - t.Fatalf("Fallback download failed: %v", err) - } - - // Verify file content - content, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("Failed to read downloaded file: %v", err) - } - - if string(content) != testContent { - t.Errorf("Expected content %q, got %q", testContent, string(content)) - } -} - -func TestDownloadWithSourcesAllFail(t *testing.T) { - // Create two failing servers - failingServer1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer failingServer1.Close() - - failingServer2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - defer failingServer2.Close() - - tempDir, err := os.MkdirTemp("", "all_fail_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - manager := NewManager() - destPath := filepath.Join(tempDir, "all_fail_test.txt") - - // Test when all sources fail - sources := []DownloadSource{ - {URL: failingServer1.URL, Priority: 1, Name: "Failing Server 1"}, - {URL: failingServer2.URL, Priority: 2, Name: "Failing Server 2"}, - } - - err = manager.DownloadWithSources(sources, destPath, nil) - if err == nil { - t.Error("Expected download to fail when all sources fail") - } - - // Verify error message contains information about all sources failing - if !strings.Contains(err.Error(), "all download sources failed") { - t.Errorf("Expected error message about all sources failing, got: %v", err) - } -} - -func TestDownloadSourcePriority(t *testing.T) { - testContent1 := "Content from server 1" - testContent2 := "Content from server 2" - - // Create two working servers with different content - server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent1))) - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent1)) - })) - defer server1.Close() - - server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent2))) - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent2)) - })) - defer server2.Close() - - tempDir, err := os.MkdirTemp("", "priority_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - manager := NewManager() - destPath := filepath.Join(tempDir, "priority_test.txt") - - // Test priority ordering - server2 has higher priority (lower number) - sources := []DownloadSource{ - {URL: server1.URL, Priority: 5, Name: "Server 1"}, - {URL: server2.URL, Priority: 1, Name: "Server 2"}, // Higher priority - } - - err = manager.DownloadWithSources(sources, destPath, nil) - if err != nil { - t.Fatalf("Priority download failed: %v", err) - } - - // Should have downloaded from server2 (higher priority) - content, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("Failed to read downloaded file: %v", err) - } - - if string(content) != testContent2 { - t.Errorf("Expected content from server 2 %q, got %q", testContent2, string(content)) - } -} - -func TestSetTimeout(t *testing.T) { - manager := NewManager() - - // Test default timeout - if manager.timeout != 30*time.Second { - t.Errorf("Expected default timeout 30s, got %v", manager.timeout) - } - - // Test setting custom timeout - customTimeout := 60 * time.Second - manager.SetTimeout(customTimeout) - - if manager.timeout != customTimeout { - t.Errorf("Expected timeout %v, got %v", customTimeout, manager.timeout) - } - - if manager.client.Timeout != customTimeout { - t.Errorf("Expected client timeout %v, got %v", customTimeout, manager.client.Timeout) - } -} - -func TestDownloadWithSourcesEmptyList(t *testing.T) { - manager := NewManager() - tempDir, err := os.MkdirTemp("", "empty_sources_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - destPath := filepath.Join(tempDir, "empty_test.txt") - - // Test with empty sources list - var sources []DownloadSource - err = manager.DownloadWithSources(sources, destPath, nil) - - if err == nil { - t.Error("Expected error when no download sources provided") - } - - if !strings.Contains(err.Error(), "no download sources provided") { - t.Errorf("Expected error about no sources, got: %v", err) - } -} - -func TestDownloadWithInvalidDestination(t *testing.T) { - testContent := "Test content" - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent)) - })) - defer server.Close() - - manager := NewManager() - - // Test with invalid destination path (directory that can't be created) - invalidPath := string([]byte{0}) + "/invalid/path/file.txt" - - err := manager.Download(server.URL, invalidPath, nil) - if err == nil { - t.Error("Expected error with invalid destination path") - } -} - -func TestDownloadWithTimeout(t *testing.T) { - // Create a server that delays response - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(2 * time.Second) // Delay longer than timeout - w.WriteHeader(http.StatusOK) - w.Write([]byte("delayed content")) - })) - defer server.Close() - - manager := NewManager() - manager.SetTimeout(500 * time.Millisecond) // Short timeout - - tempDir, err := os.MkdirTemp("", "timeout_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - destPath := filepath.Join(tempDir, "timeout_test.txt") - - err = manager.Download(server.URL, destPath, nil) - if err == nil { - t.Error("Expected timeout error") - } - - // Check that it's a timeout-related error - if !strings.Contains(err.Error(), "timeout") && !strings.Contains(err.Error(), "context deadline exceeded") { - t.Errorf("Expected timeout error, got: %v", err) - } -} - -func TestDownloadWithLargeFile(t *testing.T) { - // Create large test content (1MB) - largeContent := strings.Repeat("A", 1024*1024) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(largeContent))) - w.WriteHeader(http.StatusOK) - - // Write in chunks to simulate real download - chunkSize := 8192 - for i := 0; i < len(largeContent); i += chunkSize { - end := i + chunkSize - if end > len(largeContent) { - end = len(largeContent) - } - w.Write([]byte(largeContent[i:end])) - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - time.Sleep(1 * time.Millisecond) // Small delay to allow progress updates - } - })) - defer server.Close() - - tempDir, err := os.MkdirTemp("", "large_file_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - manager := NewManager() - destPath := filepath.Join(tempDir, "large_file.txt") - - var progressUpdates []DownloadProgress - progressCallback := func(progress DownloadProgress) { - progressUpdates = append(progressUpdates, progress) - } - - err = manager.Download(server.URL, destPath, progressCallback) - if err != nil { - t.Fatalf("Large file download failed: %v", err) - } - - // Verify file size - stat, err := os.Stat(destPath) - if err != nil { - t.Fatalf("Failed to stat downloaded file: %v", err) - } - - if stat.Size() != int64(len(largeContent)) { - t.Errorf("Expected file size %d, got %d", len(largeContent), stat.Size()) - } - - // Verify we got multiple progress updates - if len(progressUpdates) < 2 { - t.Errorf("Expected multiple progress updates for large file, got %d", len(progressUpdates)) - } - - // Verify final progress is 100% - if len(progressUpdates) > 0 { - finalProgress := progressUpdates[len(progressUpdates)-1] - if finalProgress.Percentage != 100 { - t.Errorf("Expected final percentage 100, got %f", finalProgress.Percentage) - } - } -} - -func TestDownloadResumeWithExistingFile(t *testing.T) { - fullContent := "This is the complete file content for resume testing" - partialContent := fullContent[:20] // First 20 bytes - remainingContent := fullContent[20:] // Remaining bytes - - // Create test server that supports range requests - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rangeHeader := r.Header.Get("Range") - - if rangeHeader != "" { - // Handle range request - if strings.HasPrefix(rangeHeader, "bytes=20-") { - w.Header().Set("Content-Range", fmt.Sprintf("bytes 20-%d/%d", len(fullContent)-1, len(fullContent))) - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(remainingContent))) - w.WriteHeader(http.StatusPartialContent) - w.Write([]byte(remainingContent)) - return - } - } - - // Handle normal request (shouldn't happen in resume test) - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(fullContent))) - w.WriteHeader(http.StatusOK) - w.Write([]byte(fullContent)) - })) - defer server.Close() - - tempDir, err := os.MkdirTemp("", "resume_existing_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - destPath := filepath.Join(tempDir, "resume_existing.txt") - - // Create partial file first - err = os.WriteFile(destPath, []byte(partialContent), 0644) - if err != nil { - t.Fatal(err) - } - - manager := NewManager() - - // Test resume download - err = manager.DownloadWithResume(server.URL, destPath, nil) - if err != nil { - t.Fatalf("Resume download failed: %v", err) - } - - // Verify complete file content - content, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("Failed to read resumed file: %v", err) - } - - if string(content) != fullContent { - t.Errorf("Expected complete content %q, got %q", fullContent, string(content)) - } -} - -func TestDownloadWithInvalidChecksum(t *testing.T) { - testContent := "Test content for checksum validation" - - tempDir, err := os.MkdirTemp("", "checksum_invalid_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - testFile := filepath.Join(tempDir, "checksum_test.txt") - err = os.WriteFile(testFile, []byte(testContent), 0644) - if err != nil { - t.Fatal(err) - } - - manager := NewManager() - - // Test with completely wrong checksum - wrongChecksum := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - err = manager.ValidateChecksum(testFile, wrongChecksum) - if err == nil { - t.Error("Expected checksum validation to fail with wrong checksum") - } - - if !strings.Contains(err.Error(), "checksum mismatch") { - t.Errorf("Expected checksum mismatch error, got: %v", err) - } -} - -func TestDownloadSourcesSorting(t *testing.T) { - testContent := "Test content for source sorting" - - // Create multiple servers with different priorities - server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fullContent := testContent + " from server1" - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(fullContent))) - w.WriteHeader(http.StatusOK) - w.Write([]byte(fullContent)) - })) - defer server1.Close() - - server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fullContent := testContent + " from server2" - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(fullContent))) - w.WriteHeader(http.StatusOK) - w.Write([]byte(fullContent)) - })) - defer server2.Close() - - server3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fullContent := testContent + " from server3" - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(fullContent))) - w.WriteHeader(http.StatusOK) - w.Write([]byte(fullContent)) - })) - defer server3.Close() - - tempDir, err := os.MkdirTemp("", "sorting_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - manager := NewManager() - destPath := filepath.Join(tempDir, "sorting_test.txt") - - // Test with sources in random order - should use highest priority (lowest number) - sources := []DownloadSource{ - {URL: server1.URL, Priority: 10, Name: "Server 1"}, // Lowest priority - {URL: server2.URL, Priority: 1, Name: "Server 2"}, // Highest priority - {URL: server3.URL, Priority: 5, Name: "Server 3"}, // Medium priority - } - - err = manager.DownloadWithSources(sources, destPath, nil) - if err != nil { - t.Fatalf("Download with sources failed: %v", err) - } - - // Should have downloaded from server2 (highest priority) - content, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("Failed to read downloaded file: %v", err) - } - - if !strings.Contains(string(content), "from server2") { - t.Errorf("Expected content from server2, got: %s", string(content)) - } -} - -func TestDownloadProgressAccuracy(t *testing.T) { - // Create content with known size - contentSize := 50000 // 50KB - testContent := strings.Repeat("X", contentSize) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.WriteHeader(http.StatusOK) - - // Write in small chunks to get more progress updates - chunkSize := 1024 - for i := 0; i < len(testContent); i += chunkSize { - end := i + chunkSize - if end > len(testContent) { - end = len(testContent) - } - w.Write([]byte(testContent[i:end])) - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - time.Sleep(10 * time.Millisecond) // Small delay for progress updates - } - })) - defer server.Close() - - tempDir, err := os.MkdirTemp("", "progress_accuracy_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - manager := NewManager() - destPath := filepath.Join(tempDir, "progress_test.txt") - - var progressUpdates []DownloadProgress - progressCallback := func(progress DownloadProgress) { - progressUpdates = append(progressUpdates, progress) - - // Validate progress values are reasonable - if progress.BytesDownloaded < 0 { - t.Errorf("Negative bytes downloaded: %d", progress.BytesDownloaded) - } - if progress.Percentage < 0 || progress.Percentage > 100 { - t.Errorf("Invalid percentage: %f", progress.Percentage) - } - if progress.TotalBytes > 0 && progress.BytesDownloaded > progress.TotalBytes { - t.Errorf("Downloaded bytes (%d) exceed total bytes (%d)", progress.BytesDownloaded, progress.TotalBytes) - } - if progress.Speed < 0 { - t.Errorf("Negative speed: %d", progress.Speed) - } - } - - err = manager.Download(server.URL, destPath, progressCallback) - if err != nil { - t.Fatalf("Download failed: %v", err) - } - - // Verify we got progress updates - if len(progressUpdates) == 0 { - t.Error("Expected at least one progress update") - } - - // Verify progress is monotonically increasing - for i := 1; i < len(progressUpdates); i++ { - if progressUpdates[i].BytesDownloaded < progressUpdates[i-1].BytesDownloaded { - t.Errorf("Progress went backwards: %d -> %d", - progressUpdates[i-1].BytesDownloaded, - progressUpdates[i].BytesDownloaded) - } - } - - // Verify final progress - if len(progressUpdates) > 0 { - final := progressUpdates[len(progressUpdates)-1] - if final.Percentage != 100 { - t.Errorf("Expected final percentage 100, got %f", final.Percentage) - } - if final.BytesDownloaded != int64(contentSize) { - t.Errorf("Expected final bytes %d, got %d", contentSize, final.BytesDownloaded) - } - } -} - -func TestTestSpeeds(t *testing.T) { - testContent := strings.Repeat("A", 64*1024) // 64KB content for speed testing (smaller size) - - // Create fast server - fastServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.Header().Set("Connection", "close") // Ensure connection is closed - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent)) - })) - defer fastServer.Close() - - // Create slow server - slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.Header().Set("Connection", "close") // Ensure connection is closed - w.WriteHeader(http.StatusOK) - - // Write slowly - chunkSize := 1024 - for i := 0; i < len(testContent); i += chunkSize { - end := i + chunkSize - if end > len(testContent) { - end = len(testContent) - } - w.Write([]byte(testContent[i:end])) - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - time.Sleep(10 * time.Millisecond) // Reduced delay - } - })) - defer slowServer.Close() - - // Create failing server - failingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Connection", "close") // Ensure connection is closed - w.WriteHeader(http.StatusInternalServerError) - })) - defer failingServer.Close() - - manager := NewManager() - - sources := []DownloadSource{ - {URL: fastServer.URL, Priority: 1, Name: "Fast Server"}, - {URL: slowServer.URL, Priority: 2, Name: "Slow Server"}, - {URL: failingServer.URL, Priority: 3, Name: "Failing Server"}, - } - - testSize := int64(32 * 1024) // 32KB test size (smaller) - timeout := 5 * time.Second // Shorter timeout - - results, err := manager.TestSpeeds(sources, testSize, timeout) - if err != nil { - t.Fatalf("Speed test failed: %v", err) - } - - if len(results) != len(sources) { - t.Errorf("Expected %d results, got %d", len(sources), len(results)) - } - - // Results should be sorted by speed (descending) - for i := 1; i < len(results); i++ { - if results[i-1].Error == nil && results[i].Error == nil { - if results[i-1].Speed < results[i].Speed { - t.Errorf("Results not sorted by speed: %f < %f", results[i-1].Speed, results[i].Speed) - } - } - } - - // Fast server should have higher speed than slow server (if both succeed) - var fastResult, slowResult *SpeedTestResult - for _, result := range results { - if result.Source.Name == "Fast Server" { - fastResult = &result - } else if result.Source.Name == "Slow Server" { - slowResult = &result - } - } - - if fastResult != nil && slowResult != nil { - if fastResult.Error == nil && slowResult.Error == nil { - if fastResult.Speed <= slowResult.Speed { - t.Logf("Fast server speed: %f MB/s, Slow server speed: %f MB/s", - fastResult.Speed, slowResult.Speed) - // Note: Due to the small test size, speeds might be similar, so we'll just log instead of failing - } - } - } - - // Failing server should have an error - var failingResult *SpeedTestResult - for _, result := range results { - if result.Source.Name == "Failing Server" { - failingResult = &result - } - } - - if failingResult != nil && failingResult.Error == nil { - t.Error("Failing server should have an error") - } - - // Give servers time to close connections properly - time.Sleep(100 * time.Millisecond) -} - -func TestDownloadMultiThreaded(t *testing.T) { - // Create large test content (1MB) - contentSize := 1024 * 1024 - testContent := strings.Repeat("B", contentSize) - - // Create server that supports range requests - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rangeHeader := r.Header.Get("Range") - - if rangeHeader != "" { - // Parse range header (simplified for testing) - var start, end int64 - if n, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); n == 2 && err == nil { - if start >= 0 && end < int64(len(testContent)) && start <= end { - content := testContent[start:end+1] - w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(testContent))) - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) - w.WriteHeader(http.StatusPartialContent) - w.Write([]byte(content)) - return - } - } - } - - // Handle HEAD request - if r.Method == "HEAD" { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.Header().Set("Accept-Ranges", "bytes") - w.WriteHeader(http.StatusOK) - return - } - - // Handle normal GET request - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.Header().Set("Accept-Ranges", "bytes") - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent)) - })) - defer server.Close() - - tempDir, err := os.MkdirTemp("", "multithread_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - manager := NewManager() - destPath := filepath.Join(tempDir, "multithread_test.txt") - - config := MultiThreadConfig{ - ThreadCount: 4, - ChunkSize: 0, // Use default chunk size - } - - var progressUpdates []DownloadProgress - progressCallback := func(progress DownloadProgress) { - progressUpdates = append(progressUpdates, progress) - } - - err = manager.DownloadMultiThreaded(server.URL, destPath, config, progressCallback) - if err != nil { - t.Fatalf("Multi-threaded download failed: %v", err) - } - - // Verify file content - content, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("Failed to read downloaded file: %v", err) - } - - if len(content) != contentSize { - t.Errorf("Expected content size %d, got %d", contentSize, len(content)) - } - - if string(content) != testContent { - t.Error("Downloaded content doesn't match original") - } - - // Verify progress updates - if len(progressUpdates) == 0 { - t.Error("No progress updates received") - } - - // Final progress should be 100% - if len(progressUpdates) > 0 { - finalProgress := progressUpdates[len(progressUpdates)-1] - if finalProgress.Percentage != 100 { - t.Errorf("Expected final percentage 100, got %f", finalProgress.Percentage) - } - } -} - -func TestDownloadMultiThreadedFallback(t *testing.T) { - testContent := "Test content for fallback" - - // Create server that doesn't support range requests - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "HEAD" { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - // Don't set Accept-Ranges header - w.WriteHeader(http.StatusOK) - return - } - - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent)) - })) - defer server.Close() - - tempDir, err := os.MkdirTemp("", "multithread_fallback_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - manager := NewManager() - destPath := filepath.Join(tempDir, "fallback_test.txt") - - config := MultiThreadConfig{ - ThreadCount: 4, - } - - // Should fallback to single-threaded download - err = manager.DownloadMultiThreaded(server.URL, destPath, config, nil) - if err != nil { - t.Fatalf("Fallback download failed: %v", err) - } - - // Verify file content - content, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("Failed to read downloaded file: %v", err) - } - - if string(content) != testContent { - t.Errorf("Expected content %q, got %q", testContent, string(content)) - } -} - -func TestDownloadMultiThreadedNoContentLength(t *testing.T) { - testContent := "Test content without content length" - - // Create server that doesn't provide content length - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "HEAD" { - // Don't set Content-Length header - w.WriteHeader(http.StatusOK) - return - } - - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent)) - })) - defer server.Close() - - tempDir, err := os.MkdirTemp("", "multithread_no_length_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - manager := NewManager() - destPath := filepath.Join(tempDir, "no_length_test.txt") - - config := MultiThreadConfig{ - ThreadCount: 4, - } - - // Should fallback to single-threaded download - err = manager.DownloadMultiThreaded(server.URL, destPath, config, nil) - if err != nil { - t.Fatalf("No content length download failed: %v", err) - } - - // Verify file content - content, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("Failed to read downloaded file: %v", err) - } - - if string(content) != testContent { - t.Errorf("Expected content %q, got %q", testContent, string(content)) - } -} - -func TestSpeedTestTimeout(t *testing.T) { - // Create slow server that will timeout - slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Connection", "close") // Ensure connection is closed - time.Sleep(2 * time.Second) // Longer than timeout - w.WriteHeader(http.StatusOK) - w.Write([]byte("slow content")) - })) - defer slowServer.Close() - - manager := NewManager() - - sources := []DownloadSource{ - {URL: slowServer.URL, Priority: 1, Name: "Slow Server"}, - } - - testSize := int64(1024) - timeout := 500 * time.Millisecond // Short timeout - - results, err := manager.TestSpeeds(sources, testSize, timeout) - if err != nil { - t.Fatalf("Speed test failed: %v", err) - } - - if len(results) != 1 { - t.Errorf("Expected 1 result, got %d", len(results)) - } - - // Should have timed out - if results[0].Error == nil { - t.Error("Expected timeout error") - } - - // Give server time to close connections properly - time.Sleep(100 * time.Millisecond) -} - -func TestDownloadMultiThreadedChunkMerging(t *testing.T) { - // Create content with distinct patterns for each chunk - chunk1 := strings.Repeat("1", 1024) - chunk2 := strings.Repeat("2", 1024) - chunk3 := strings.Repeat("3", 1024) - chunk4 := strings.Repeat("4", 1024) - testContent := chunk1 + chunk2 + chunk3 + chunk4 - - // Create server that supports range requests - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rangeHeader := r.Header.Get("Range") - - if rangeHeader != "" { - var start, end int64 - if n, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); n == 2 && err == nil { - if start >= 0 && end < int64(len(testContent)) && start <= end { - content := testContent[start:end+1] - w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(testContent))) - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) - w.WriteHeader(http.StatusPartialContent) - w.Write([]byte(content)) - return - } - } - } - - if r.Method == "HEAD" { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.Header().Set("Accept-Ranges", "bytes") - w.WriteHeader(http.StatusOK) - return - } - - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.Header().Set("Accept-Ranges", "bytes") - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent)) - })) - defer server.Close() - - tempDir, err := os.MkdirTemp("", "chunk_merge_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - manager := NewManager() - destPath := filepath.Join(tempDir, "chunk_merge_test.txt") - - config := MultiThreadConfig{ - ThreadCount: 4, - ChunkSize: 1024, // Each chunk is exactly 1024 bytes - } - - err = manager.DownloadMultiThreaded(server.URL, destPath, config, nil) - if err != nil { - t.Fatalf("Multi-threaded download failed: %v", err) - } - - // Verify file content is correctly merged - content, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("Failed to read downloaded file: %v", err) - } - - if string(content) != testContent { - t.Error("Chunks were not merged correctly") - - // Debug: check each chunk - if len(content) >= 1024 && string(content[0:1024]) != chunk1 { - t.Error("Chunk 1 incorrect") - } - if len(content) >= 2048 && string(content[1024:2048]) != chunk2 { - t.Error("Chunk 2 incorrect") - } - if len(content) >= 3072 && string(content[2048:3072]) != chunk3 { - t.Error("Chunk 3 incorrect") - } - if len(content) >= 4096 && string(content[3072:4096]) != chunk4 { - t.Error("Chunk 4 incorrect") - } - } - - // Verify no temporary chunk files remain - for i := 0; i < 4; i++ { - chunkFile := fmt.Sprintf("%s.part%d", destPath, i) - if _, err := os.Stat(chunkFile); !os.IsNotExist(err) { - t.Errorf("Temporary chunk file %s should have been removed", chunkFile) - } - } -} - -func TestSpeedTestEmptySources(t *testing.T) { - manager := NewManager() - - var sources []DownloadSource - testSize := int64(1024) - timeout := 10 * time.Second - - results, err := manager.TestSpeeds(sources, testSize, timeout) - if err == nil { - t.Error("Expected error for empty sources") - } - - if results != nil { - t.Error("Expected nil results for empty sources") - } - - if !strings.Contains(err.Error(), "no sources provided") { - t.Errorf("Expected 'no sources provided' error, got: %v", err) - } -} - -func TestDownloadMultiThreadedDefaultConfig(t *testing.T) { - testContent := strings.Repeat("C", 8192) // 8KB content - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rangeHeader := r.Header.Get("Range") - - if rangeHeader != "" { - var start, end int64 - if n, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); n == 2 && err == nil { - if start >= 0 && end < int64(len(testContent)) && start <= end { - content := testContent[start:end+1] - w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(testContent))) - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) - w.WriteHeader(http.StatusPartialContent) - w.Write([]byte(content)) - return - } - } - } - - if r.Method == "HEAD" { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.Header().Set("Accept-Ranges", "bytes") - w.WriteHeader(http.StatusOK) - return - } - - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent))) - w.Header().Set("Accept-Ranges", "bytes") - w.WriteHeader(http.StatusOK) - w.Write([]byte(testContent)) - })) - defer server.Close() - - tempDir, err := os.MkdirTemp("", "default_config_test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - manager := NewManager() - destPath := filepath.Join(tempDir, "default_config_test.txt") - - // Test with zero thread count (should default to 4) - config := MultiThreadConfig{ - ThreadCount: 0, - } - - err = manager.DownloadMultiThreaded(server.URL, destPath, config, nil) - if err != nil { - t.Fatalf("Default config download failed: %v", err) - } - - // Verify file content - content, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("Failed to read downloaded file: %v", err) - } - - if string(content) != testContent { - t.Errorf("Expected content length %d, got %d", len(testContent), len(content)) - } -} \ No newline at end of file diff --git a/Go_Updater/errors/errors.go b/Go_Updater/errors/errors.go deleted file mode 100644 index f6a7742..0000000 --- a/Go_Updater/errors/errors.go +++ /dev/null @@ -1,219 +0,0 @@ -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 "发生未知错误,请联系技术支持" -} \ No newline at end of file diff --git a/Go_Updater/errors/errors_test.go b/Go_Updater/errors/errors_test.go deleted file mode 100644 index bb8dd3f..0000000 --- a/Go_Updater/errors/errors_test.go +++ /dev/null @@ -1,287 +0,0 @@ -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) - } - }) -} \ No newline at end of file diff --git a/Go_Updater/go.mod b/Go_Updater/go.mod deleted file mode 100644 index 143c5e4..0000000 --- a/Go_Updater/go.mod +++ /dev/null @@ -1,42 +0,0 @@ -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 -) diff --git a/Go_Updater/go.sum b/Go_Updater/go.sum deleted file mode 100644 index 83677a5..0000000 --- a/Go_Updater/go.sum +++ /dev/null @@ -1,80 +0,0 @@ -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= diff --git a/Go_Updater/gui/manager.go b/Go_Updater/gui/manager.go deleted file mode 100644 index d2b8350..0000000 --- a/Go_Updater/gui/manager.go +++ /dev/null @@ -1,513 +0,0 @@ -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() - } -} diff --git a/Go_Updater/gui/manager_test.go b/Go_Updater/gui/manager_test.go deleted file mode 100644 index c03be1b..0000000 --- a/Go_Updater/gui/manager_test.go +++ /dev/null @@ -1,227 +0,0 @@ -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)) - } -} \ No newline at end of file diff --git a/Go_Updater/icon/AUTO_MAA_Go_Updater.ico b/Go_Updater/icon/AUTO_MAA_Go_Updater.ico deleted file mode 100644 index 5520beb..0000000 Binary files a/Go_Updater/icon/AUTO_MAA_Go_Updater.ico and /dev/null differ diff --git a/Go_Updater/icon/AUTO_MAA_Go_Updater.png b/Go_Updater/icon/AUTO_MAA_Go_Updater.png deleted file mode 100644 index 630a52b..0000000 Binary files a/Go_Updater/icon/AUTO_MAA_Go_Updater.png and /dev/null differ diff --git a/Go_Updater/install/manager.go b/Go_Updater/install/manager.go deleted file mode 100644 index 88fe26a..0000000 --- a/Go_Updater/install/manager.go +++ /dev/null @@ -1,474 +0,0 @@ -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 - }) -} diff --git a/Go_Updater/install/manager_test.go b/Go_Updater/install/manager_test.go deleted file mode 100644 index 5bf9381..0000000 --- a/Go_Updater/install/manager_test.go +++ /dev/null @@ -1,1033 +0,0 @@ -package install - -import ( - "archive/zip" - "fmt" - "os" - "path/filepath" - "testing" -) - -func TestNewManager(t *testing.T) { - manager := NewManager() - if manager == nil { - t.Fatal("NewManager() returned nil") - } - if manager.tempDirs == nil { - t.Fatal("tempDirs slice not initialized") - } -} - -func TestCreateTempDir(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - - // Verify directory exists - if _, err := os.Stat(tempDir); os.IsNotExist(err) { - t.Fatalf("Temp directory was not created: %s", tempDir) - } - - // Verify it's tracked - if len(manager.tempDirs) != 1 || manager.tempDirs[0] != tempDir { - t.Fatalf("Temp directory not properly tracked") - } - - // Cleanup - defer manager.CleanupTempDir(tempDir) -} - -func TestCleanupTempDir(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - - // Create a test file in temp directory - testFile := filepath.Join(tempDir, "test.txt") - if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - - // Cleanup - err = manager.CleanupTempDir(tempDir) - if err != nil { - t.Fatalf("CleanupTempDir() failed: %v", err) - } - - // Verify directory is removed - if _, err := os.Stat(tempDir); !os.IsNotExist(err) { - t.Fatalf("Temp directory was not removed: %s", tempDir) - } - - // Verify it's no longer tracked - if len(manager.tempDirs) != 0 { - t.Fatalf("Temp directory still tracked after cleanup") - } -} - -func TestExtractZip(t *testing.T) { - manager := NewManager() - - // Create a temporary ZIP file for testing - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - zipPath := filepath.Join(tempDir, "test.zip") - extractDir := filepath.Join(tempDir, "extract") - - // Create test ZIP file - if err := createTestZip(zipPath); err != nil { - t.Fatalf("Failed to create test ZIP: %v", err) - } - - // Extract ZIP - err = manager.ExtractZip(zipPath, extractDir) - if err != nil { - t.Fatalf("ExtractZip() failed: %v", err) - } - - // Verify extracted files - testFile := filepath.Join(extractDir, "test.txt") - if _, err := os.Stat(testFile); os.IsNotExist(err) { - t.Fatalf("Extracted file not found: %s", testFile) - } - - // Verify file contents - content, err := os.ReadFile(testFile) - if err != nil { - t.Fatalf("Failed to read extracted file: %v", err) - } - - expected := "Hello, World!" - if string(content) != expected { - t.Fatalf("File content mismatch. Expected: %s, Got: %s", expected, string(content)) - } - - // Verify directory structure - subDir := filepath.Join(extractDir, "subdir") - if _, err := os.Stat(subDir); os.IsNotExist(err) { - t.Fatalf("Extracted subdirectory not found: %s", subDir) - } - - subFile := filepath.Join(subDir, "sub.txt") - if _, err := os.Stat(subFile); os.IsNotExist(err) { - t.Fatalf("Extracted subdirectory file not found: %s", subFile) - } -} - -func TestExtractZipInvalidPath(t *testing.T) { - manager := NewManager() - - // Test with non-existent ZIP file - err := manager.ExtractZip("nonexistent.zip", "dest") - if err == nil { - t.Fatal("ExtractZip() should fail with non-existent ZIP file") - } -} - -func TestExtractZipDirectoryTraversal(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - zipPath := filepath.Join(tempDir, "malicious.zip") - extractDir := filepath.Join(tempDir, "extract") - - // Create ZIP with directory traversal attempt - if err := createMaliciousZip(zipPath); err != nil { - t.Fatalf("Failed to create malicious ZIP: %v", err) - } - - // Extract should fail or sanitize the path - err = manager.ExtractZip(zipPath, extractDir) - if err != nil { - // This is expected behavior - the extraction should fail - return - } - - // If extraction succeeded, verify no files were created outside extract dir - parentDir := filepath.Dir(extractDir) - maliciousFile := filepath.Join(parentDir, "malicious.txt") - if _, err := os.Stat(maliciousFile); !os.IsNotExist(err) { - t.Fatal("Directory traversal attack succeeded - malicious file created outside extract directory") - } -} - -// Helper function to create a test ZIP file -func createTestZip(zipPath string) error { - file, err := os.Create(zipPath) - if err != nil { - return err - } - defer file.Close() - - writer := zip.NewWriter(file) - defer writer.Close() - - // Add a test file - f1, err := writer.Create("test.txt") - if err != nil { - return err - } - _, err = f1.Write([]byte("Hello, World!")) - if err != nil { - return err - } - - // Add a subdirectory and file - f2, err := writer.Create("subdir/sub.txt") - if err != nil { - return err - } - _, err = f2.Write([]byte("Subdirectory file")) - if err != nil { - return err - } - - return nil -} - -func TestProcessChanges(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - // Test with valid changes.json - changesPath := filepath.Join(tempDir, "changes.json") - changesData := `{ - "deleted": ["old_file.txt", "deprecated/module.dll"], - "added": ["new_file.txt", "features/new_module.dll"], - "modified": ["main.exe", "config.ini"] - }` - - if err := os.WriteFile(changesPath, []byte(changesData), 0644); err != nil { - t.Fatalf("Failed to create test changes.json: %v", err) - } - - changes, err := manager.ProcessChanges(changesPath) - if err != nil { - t.Fatalf("ProcessChanges() failed: %v", err) - } - - // Verify parsed data - if len(changes.Deleted) != 2 { - t.Fatalf("Expected 2 deleted files, got %d", len(changes.Deleted)) - } - if changes.Deleted[0] != "old_file.txt" { - t.Fatalf("Expected first deleted file to be 'old_file.txt', got '%s'", changes.Deleted[0]) - } - - if len(changes.Added) != 2 { - t.Fatalf("Expected 2 added files, got %d", len(changes.Added)) - } - - if len(changes.Modified) != 2 { - t.Fatalf("Expected 2 modified files, got %d", len(changes.Modified)) - } -} - -func TestProcessChangesNonExistent(t *testing.T) { - manager := NewManager() - - // Test with non-existent changes.json - changes, err := manager.ProcessChanges("nonexistent.json") - if err != nil { - t.Fatalf("ProcessChanges() should not fail with non-existent file: %v", err) - } - - // Should return empty changes - if len(changes.Deleted) != 0 || len(changes.Added) != 0 || len(changes.Modified) != 0 { - t.Fatalf("Expected empty changes for non-existent file") - } -} - -func TestProcessChangesInvalidJSON(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - // Test with invalid JSON - changesPath := filepath.Join(tempDir, "invalid.json") - invalidData := `{"deleted": ["file1.txt", "file2.txt"` // Missing closing bracket - - if err := os.WriteFile(changesPath, []byte(invalidData), 0644); err != nil { - t.Fatalf("Failed to create invalid JSON file: %v", err) - } - - _, err = manager.ProcessChanges(changesPath) - if err == nil { - t.Fatal("ProcessChanges() should fail with invalid JSON") - } -} - -func TestHandleRunningProcess(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - // Create a test executable file - testExe := filepath.Join(tempDir, "test.exe") - if err := os.WriteFile(testExe, []byte("test executable"), 0755); err != nil { - t.Fatalf("Failed to create test executable: %v", err) - } - - // Test handling non-existent process - err = manager.HandleRunningProcess("nonexistent.exe") - if err != nil { - t.Fatalf("HandleRunningProcess() should not fail with non-existent process: %v", err) - } -} - -func TestDeleteMarkedFiles(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - // Create test files to be deleted - testFile1 := filepath.Join(tempDir, "file1.txt") - testFile2 := filepath.Join(tempDir, "file2.txt") - - if err := os.WriteFile(testFile1, []byte("test1"), 0644); err != nil { - t.Fatalf("Failed to create test file1: %v", err) - } - if err := os.WriteFile(testFile2, []byte("test2"), 0644); err != nil { - t.Fatalf("Failed to create test file2: %v", err) - } - - // Create marker files - marker1 := testFile1 + ".delete_on_restart" - marker2 := testFile2 + ".delete_on_restart" - - if err := os.WriteFile(marker1, []byte(testFile1), 0644); err != nil { - t.Fatalf("Failed to create marker file1: %v", err) - } - if err := os.WriteFile(marker2, []byte(testFile2), 0644); err != nil { - t.Fatalf("Failed to create marker file2: %v", err) - } - - // Delete marked files - err = manager.DeleteMarkedFiles(tempDir) - if err != nil { - t.Fatalf("DeleteMarkedFiles() failed: %v", err) - } - - // Verify files are deleted - if _, err := os.Stat(testFile1); !os.IsNotExist(err) { - t.Fatalf("Test file1 should be deleted") - } - if _, err := os.Stat(testFile2); !os.IsNotExist(err) { - t.Fatalf("Test file2 should be deleted") - } - - // Verify marker files are deleted - if _, err := os.Stat(marker1); !os.IsNotExist(err) { - t.Fatalf("Marker file1 should be deleted") - } - if _, err := os.Stat(marker2); !os.IsNotExist(err) { - t.Fatalf("Marker file2 should be deleted") - } -} - -func TestApplyUpdate(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - // Create source and target directories - sourceDir := filepath.Join(tempDir, "source") - targetDir := filepath.Join(tempDir, "target") - - if err := os.MkdirAll(sourceDir, 0755); err != nil { - t.Fatalf("Failed to create source directory: %v", err) - } - if err := os.MkdirAll(targetDir, 0755); err != nil { - t.Fatalf("Failed to create target directory: %v", err) - } - - // Create test files in source directory - newFile := filepath.Join(sourceDir, "new_file.txt") - modifiedFile := filepath.Join(sourceDir, "modified_file.txt") - - if err := os.WriteFile(newFile, []byte("new content"), 0644); err != nil { - t.Fatalf("Failed to create new file: %v", err) - } - if err := os.WriteFile(modifiedFile, []byte("updated content"), 0644); err != nil { - t.Fatalf("Failed to create modified file: %v", err) - } - - // Create existing files in target directory - existingModified := filepath.Join(targetDir, "modified_file.txt") - existingDeleted := filepath.Join(targetDir, "deleted_file.txt") - - if err := os.WriteFile(existingModified, []byte("old content"), 0644); err != nil { - t.Fatalf("Failed to create existing modified file: %v", err) - } - if err := os.WriteFile(existingDeleted, []byte("to be deleted"), 0644); err != nil { - t.Fatalf("Failed to create file to be deleted: %v", err) - } - - // Define changes - changes := &ChangesInfo{ - Added: []string{"new_file.txt"}, - Modified: []string{"modified_file.txt"}, - Deleted: []string{"deleted_file.txt"}, - } - - // Apply update - err = manager.ApplyUpdate(sourceDir, targetDir, changes) - if err != nil { - t.Fatalf("ApplyUpdate() failed: %v", err) - } - - // Verify new file was added - newTargetFile := filepath.Join(targetDir, "new_file.txt") - if _, err := os.Stat(newTargetFile); os.IsNotExist(err) { - t.Fatalf("New file was not added to target directory") - } - - // Verify modified file was updated - content, err := os.ReadFile(existingModified) - if err != nil { - t.Fatalf("Failed to read modified file: %v", err) - } - if string(content) != "updated content" { - t.Fatalf("Modified file content incorrect. Expected: 'updated content', Got: '%s'", string(content)) - } - - // Verify deleted file was removed - if _, err := os.Stat(existingDeleted); !os.IsNotExist(err) { - t.Fatalf("Deleted file still exists") - } -} - -func TestApplyUpdateWithRollback(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - // Create source and target directories - sourceDir := filepath.Join(tempDir, "source") - targetDir := filepath.Join(tempDir, "target") - - if err := os.MkdirAll(sourceDir, 0755); err != nil { - t.Fatalf("Failed to create source directory: %v", err) - } - if err := os.MkdirAll(targetDir, 0755); err != nil { - t.Fatalf("Failed to create target directory: %v", err) - } - - // Create existing file in target directory - existingFile := filepath.Join(targetDir, "existing_file.txt") - originalContent := "original content" - if err := os.WriteFile(existingFile, []byte(originalContent), 0644); err != nil { - t.Fatalf("Failed to create existing file: %v", err) - } - - // Create a source file that will cause a copy failure by making target read-only - sourceFile := filepath.Join(sourceDir, "existing_file.txt") - if err := os.WriteFile(sourceFile, []byte("new content"), 0644); err != nil { - t.Fatalf("Failed to create source file: %v", err) - } - - // Make target directory read-only to cause copy failure - readOnlyDir := filepath.Join(targetDir, "readonly") - if err := os.MkdirAll(readOnlyDir, 0755); err != nil { - t.Fatalf("Failed to create readonly directory: %v", err) - } - - // Create a file in readonly directory that we'll try to modify - readOnlyFile := filepath.Join(readOnlyDir, "readonly_file.txt") - if err := os.WriteFile(readOnlyFile, []byte("readonly content"), 0644); err != nil { - t.Fatalf("Failed to create readonly file: %v", err) - } - - // Create source file for readonly file - sourceReadOnlyFile := filepath.Join(sourceDir, "readonly", "readonly_file.txt") - if err := os.MkdirAll(filepath.Dir(sourceReadOnlyFile), 0755); err != nil { - t.Fatalf("Failed to create source readonly directory: %v", err) - } - if err := os.WriteFile(sourceReadOnlyFile, []byte("new readonly content"), 0644); err != nil { - t.Fatalf("Failed to create source readonly file: %v", err) - } - - // Make the readonly directory read-only (Windows specific) - if err := os.Chmod(readOnlyDir, 0444); err != nil { - t.Fatalf("Failed to make directory read-only: %v", err) - } - - // Restore permissions after test - defer func() { - os.Chmod(readOnlyDir, 0755) - os.RemoveAll(readOnlyDir) - }() - - // Define changes that will cause failure due to read-only directory - changes := &ChangesInfo{ - Modified: []string{"existing_file.txt", "readonly/readonly_file.txt"}, - } - - // Apply update (should fail and rollback) - err = manager.ApplyUpdate(sourceDir, targetDir, changes) - if err == nil { - // On some systems, the read-only test might not work as expected - // Let's just verify the update completed successfully in this case - t.Log("Update completed successfully (read-only test may not work on this system)") - return - } - - // Verify rollback occurred - original file should be restored - content, err := os.ReadFile(existingFile) - if err != nil { - t.Fatalf("Failed to read file after rollback: %v", err) - } - if string(content) != originalContent { - t.Fatalf("Rollback failed. Expected: '%s', Got: '%s'", originalContent, string(content)) - } -} - -func TestCopyFileWithDirs(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - // Create source file - srcFile := filepath.Join(tempDir, "source.txt") - content := "test content" - if err := os.WriteFile(srcFile, []byte(content), 0644); err != nil { - t.Fatalf("Failed to create source file: %v", err) - } - - // Copy to destination with nested directories - dstFile := filepath.Join(tempDir, "nested", "dir", "destination.txt") - - err = manager.copyFileWithDirs(srcFile, dstFile) - if err != nil { - t.Fatalf("copyFileWithDirs() failed: %v", err) - } - - // Verify file was copied - if _, err := os.Stat(dstFile); os.IsNotExist(err) { - t.Fatalf("Destination file was not created") - } - - // Verify content - dstContent, err := os.ReadFile(dstFile) - if err != nil { - t.Fatalf("Failed to read destination file: %v", err) - } - if string(dstContent) != content { - t.Fatalf("File content mismatch. Expected: '%s', Got: '%s'", content, string(dstContent)) - } - - // Verify parent directories were created - parentDir := filepath.Join(tempDir, "nested", "dir") - if _, err := os.Stat(parentDir); os.IsNotExist(err) { - t.Fatalf("Parent directories were not created") - } -} - -// Helper function to create a malicious ZIP file with directory traversal -func createMaliciousZip(zipPath string) error { - file, err := os.Create(zipPath) - if err != nil { - return err - } - defer file.Close() - - writer := zip.NewWriter(file) - defer writer.Close() - - // Add a file with directory traversal path - f1, err := writer.Create("../malicious.txt") - if err != nil { - return err - } - _, err = f1.Write([]byte("This should not be extracted outside the target directory")) - if err != nil { - return err - } - - return nil -} - -func TestCleanupAllTempDirs(t *testing.T) { - manager := NewManager() - - // Create multiple temp directories - tempDir1, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("Failed to create temp dir 1: %v", err) - } - - tempDir2, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("Failed to create temp dir 2: %v", err) - } - - tempDir3, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("Failed to create temp dir 3: %v", err) - } - - // Verify all directories exist - for _, dir := range []string{tempDir1, tempDir2, tempDir3} { - if _, err := os.Stat(dir); os.IsNotExist(err) { - t.Fatalf("Temp directory should exist: %s", dir) - } - } - - // Verify manager is tracking all directories - if len(manager.tempDirs) != 3 { - t.Fatalf("Expected 3 tracked temp dirs, got %d", len(manager.tempDirs)) - } - - // Cleanup all temp directories - err = manager.CleanupAllTempDirs() - if err != nil { - t.Fatalf("CleanupAllTempDirs failed: %v", err) - } - - // Verify all directories are removed - for _, dir := range []string{tempDir1, tempDir2, tempDir3} { - if _, err := os.Stat(dir); !os.IsNotExist(err) { - t.Fatalf("Temp directory should be removed: %s", dir) - } - } - - // Verify manager is no longer tracking directories - if len(manager.tempDirs) != 0 { - t.Fatalf("Expected 0 tracked temp dirs after cleanup, got %d", len(manager.tempDirs)) - } -} - -func TestExtractZipWithNestedDirectories(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - zipPath := filepath.Join(tempDir, "nested.zip") - extractDir := filepath.Join(tempDir, "extract") - - // Create ZIP with nested directory structure - if err := createNestedZip(zipPath); err != nil { - t.Fatalf("Failed to create nested ZIP: %v", err) - } - - // Extract ZIP - err = manager.ExtractZip(zipPath, extractDir) - if err != nil { - t.Fatalf("ExtractZip() failed: %v", err) - } - - // Verify nested structure was created - expectedFiles := []string{ - "level1/file1.txt", - "level1/level2/file2.txt", - "level1/level2/level3/file3.txt", - } - - for _, expectedFile := range expectedFiles { - fullPath := filepath.Join(extractDir, expectedFile) - if _, err := os.Stat(fullPath); os.IsNotExist(err) { - t.Fatalf("Expected nested file not found: %s", expectedFile) - } - - // Verify file content - content, err := os.ReadFile(fullPath) - if err != nil { - t.Fatalf("Failed to read nested file %s: %v", expectedFile, err) - } - - expectedContent := fmt.Sprintf("Content of %s", filepath.Base(expectedFile)) - if string(content) != expectedContent { - t.Fatalf("File content mismatch for %s. Expected: %s, Got: %s", - expectedFile, expectedContent, string(content)) - } - } -} - -func TestProcessChangesWithComplexStructure(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - // Create complex changes.json with nested paths - changesPath := filepath.Join(tempDir, "complex_changes.json") - changesData := `{ - "deleted": [ - "old/legacy/file1.txt", - "deprecated/module.dll", - "temp/cache.dat" - ], - "added": [ - "new/features/feature1.dll", - "resources/icons/icon.png", - "config/new_settings.json" - ], - "modified": [ - "core/main.exe", - "lib/utils.dll", - "data/database.db" - ] - }` - - if err := os.WriteFile(changesPath, []byte(changesData), 0644); err != nil { - t.Fatalf("Failed to create complex changes.json: %v", err) - } - - changes, err := manager.ProcessChanges(changesPath) - if err != nil { - t.Fatalf("ProcessChanges() failed: %v", err) - } - - // Verify all changes were parsed correctly - expectedDeleted := []string{"old/legacy/file1.txt", "deprecated/module.dll", "temp/cache.dat"} - expectedAdded := []string{"new/features/feature1.dll", "resources/icons/icon.png", "config/new_settings.json"} - expectedModified := []string{"core/main.exe", "lib/utils.dll", "data/database.db"} - - if len(changes.Deleted) != len(expectedDeleted) { - t.Fatalf("Expected %d deleted files, got %d", len(expectedDeleted), len(changes.Deleted)) - } - - for i, expected := range expectedDeleted { - if changes.Deleted[i] != expected { - t.Errorf("Deleted[%d]: expected %s, got %s", i, expected, changes.Deleted[i]) - } - } - - if len(changes.Added) != len(expectedAdded) { - t.Fatalf("Expected %d added files, got %d", len(expectedAdded), len(changes.Added)) - } - - for i, expected := range expectedAdded { - if changes.Added[i] != expected { - t.Errorf("Added[%d]: expected %s, got %s", i, expected, changes.Added[i]) - } - } - - if len(changes.Modified) != len(expectedModified) { - t.Fatalf("Expected %d modified files, got %d", len(expectedModified), len(changes.Modified)) - } - - for i, expected := range expectedModified { - if changes.Modified[i] != expected { - t.Errorf("Modified[%d]: expected %s, got %s", i, expected, changes.Modified[i]) - } - } -} - -func TestApplyUpdateWithNestedPaths(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - // Create source and target directories - sourceDir := filepath.Join(tempDir, "source") - targetDir := filepath.Join(tempDir, "target") - - if err := os.MkdirAll(sourceDir, 0755); err != nil { - t.Fatalf("Failed to create source directory: %v", err) - } - if err := os.MkdirAll(targetDir, 0755); err != nil { - t.Fatalf("Failed to create target directory: %v", err) - } - - // Create nested source files - nestedFiles := map[string]string{ - "level1/new_file.txt": "New file content", - "level1/level2/modified.txt": "Modified content", - "features/feature1/config.json": `{"enabled": true}`, - } - - for filePath, content := range nestedFiles { - fullPath := filepath.Join(sourceDir, filePath) - if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { - t.Fatalf("Failed to create source directory for %s: %v", filePath, err) - } - if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { - t.Fatalf("Failed to create source file %s: %v", filePath, err) - } - } - - // Create existing target files - existingFiles := map[string]string{ - "level1/level2/modified.txt": "Old content", - "old/deprecated.txt": "To be deleted", - } - - for filePath, content := range existingFiles { - fullPath := filepath.Join(targetDir, filePath) - if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { - t.Fatalf("Failed to create target directory for %s: %v", filePath, err) - } - if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { - t.Fatalf("Failed to create target file %s: %v", filePath, err) - } - } - - // Define changes with nested paths - changes := &ChangesInfo{ - Added: []string{"level1/new_file.txt", "features/feature1/config.json"}, - Modified: []string{"level1/level2/modified.txt"}, - Deleted: []string{"old/deprecated.txt"}, - } - - // Apply update - err = manager.ApplyUpdate(sourceDir, targetDir, changes) - if err != nil { - t.Fatalf("ApplyUpdate() failed: %v", err) - } - - // Verify added files - for _, addedFile := range changes.Added { - targetFile := filepath.Join(targetDir, addedFile) - if _, err := os.Stat(targetFile); os.IsNotExist(err) { - t.Fatalf("Added file not found: %s", addedFile) - } - - // Verify content matches source - sourceFile := filepath.Join(sourceDir, addedFile) - sourceContent, _ := os.ReadFile(sourceFile) - targetContent, _ := os.ReadFile(targetFile) - - if string(sourceContent) != string(targetContent) { - t.Fatalf("Content mismatch for added file %s", addedFile) - } - } - - // Verify modified files - modifiedFile := filepath.Join(targetDir, "level1/level2/modified.txt") - content, err := os.ReadFile(modifiedFile) - if err != nil { - t.Fatalf("Failed to read modified file: %v", err) - } - if string(content) != "Modified content" { - t.Fatalf("Modified file content incorrect. Expected: 'Modified content', Got: '%s'", string(content)) - } - - // Verify deleted files - deletedFile := filepath.Join(targetDir, "old/deprecated.txt") - if _, err := os.Stat(deletedFile); !os.IsNotExist(err) { - t.Fatalf("Deleted file still exists: %s", deletedFile) - } -} - -func TestMarkFileForDeletion(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - // Create a test file - testFile := filepath.Join(tempDir, "test.exe") - if err := os.WriteFile(testFile, []byte("test executable"), 0755); err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - - // Mark file for deletion - err = manager.markFileForDeletion(testFile) - if err != nil { - t.Fatalf("markFileForDeletion() failed: %v", err) - } - - // Verify marker file was created - markerFile := testFile + ".delete_on_restart" - if _, err := os.Stat(markerFile); os.IsNotExist(err) { - t.Fatalf("Marker file was not created: %s", markerFile) - } - - // Verify marker file contains correct path - content, err := os.ReadFile(markerFile) - if err != nil { - t.Fatalf("Failed to read marker file: %v", err) - } - - if string(content) != testFile { - t.Fatalf("Marker file content incorrect. Expected: %s, Got: %s", testFile, string(content)) - } -} - -func TestIsFileInUse(t *testing.T) { - // Test with nil error - if isFileInUse(nil) { - t.Error("isFileInUse(nil) should return false") - } - - // Test with regular error - regularErr := fmt.Errorf("regular error") - if isFileInUse(regularErr) { - t.Error("isFileInUse with regular error should return false") - } - - // Test with file in use error message - fileInUseErr := fmt.Errorf("file is being used by another process") - if !isFileInUse(fileInUseErr) { - t.Error("isFileInUse with 'being used by another process' should return true") - } - - // Test with access denied error message - accessDeniedErr := fmt.Errorf("access is denied") - if !isFileInUse(accessDeniedErr) { - t.Error("isFileInUse with 'access is denied' should return true") - } -} - -func TestExtractFileEdgeCases(t *testing.T) { - manager := NewManager() - - tempDir, err := manager.CreateTempDir() - if err != nil { - t.Fatalf("CreateTempDir() failed: %v", err) - } - defer manager.CleanupTempDir(tempDir) - - zipPath := filepath.Join(tempDir, "edge_cases.zip") - extractDir := filepath.Join(tempDir, "extract") - - // Create ZIP with edge cases - if err := createEdgeCaseZip(zipPath); err != nil { - t.Fatalf("Failed to create edge case ZIP: %v", err) - } - - // Extract ZIP - err = manager.ExtractZip(zipPath, extractDir) - if err != nil { - t.Fatalf("ExtractZip() failed: %v", err) - } - - // Verify files with special names were extracted - specialFiles := []string{ - "file with spaces.txt", - "file-with-dashes.txt", - "file_with_underscores.txt", - "UPPERCASE.TXT", - } - - for _, fileName := range specialFiles { - filePath := filepath.Join(extractDir, fileName) - if _, err := os.Stat(filePath); os.IsNotExist(err) { - t.Fatalf("Special file not extracted: %s", fileName) - } - } -} - -// Helper function to create a ZIP with nested directories -func createNestedZip(zipPath string) error { - file, err := os.Create(zipPath) - if err != nil { - return err - } - defer file.Close() - - writer := zip.NewWriter(file) - defer writer.Close() - - // Create nested structure - files := map[string]string{ - "level1/file1.txt": "Content of file1.txt", - "level1/level2/file2.txt": "Content of file2.txt", - "level1/level2/level3/file3.txt": "Content of file3.txt", - } - - for filePath, content := range files { - f, err := writer.Create(filePath) - if err != nil { - return err - } - _, err = f.Write([]byte(content)) - if err != nil { - return err - } - } - - return nil -} - -// Helper function to create a ZIP with edge case file names -func createEdgeCaseZip(zipPath string) error { - file, err := os.Create(zipPath) - if err != nil { - return err - } - defer file.Close() - - writer := zip.NewWriter(file) - defer writer.Close() - - // Create files with special names - files := []string{ - "file with spaces.txt", - "file-with-dashes.txt", - "file_with_underscores.txt", - "UPPERCASE.TXT", - } - - for _, fileName := range files { - f, err := writer.Create(fileName) - if err != nil { - return err - } - _, err = f.Write([]byte(fmt.Sprintf("Content of %s", fileName))) - if err != nil { - return err - } - } - - return nil -} \ No newline at end of file diff --git a/Go_Updater/integration_test.go b/Go_Updater/integration_test.go deleted file mode 100644 index 508cdc8..0000000 --- a/Go_Updater/integration_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import ( - "testing" -) - -// 集成测试将在此处实现 -// 此文件目前是占位符 - -func TestIntegrationPlaceholder(t *testing.T) { - t.Skip("集成测试尚未实现") -} diff --git a/Go_Updater/logger/logger.go b/Go_Updater/logger/logger.go deleted file mode 100644 index 68b2f50..0000000 --- a/Go_Updater/logger/logger.go +++ /dev/null @@ -1,438 +0,0 @@ -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() -} diff --git a/Go_Updater/logger/logger_test.go b/Go_Updater/logger/logger_test.go deleted file mode 100644 index be99ae9..0000000 --- a/Go_Updater/logger/logger_test.go +++ /dev/null @@ -1,300 +0,0 @@ -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") - } -} \ No newline at end of file diff --git a/Go_Updater/main.go b/Go_Updater/main.go deleted file mode 100644 index 45459d6..0000000 --- a/Go_Updater/main.go +++ /dev/null @@ -1,1035 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "os" - "os/exec" - "os/signal" - "path/filepath" - "strings" - "sync" - "syscall" - "time" - - "AUTO_MAA_Go_Updater/api" - "AUTO_MAA_Go_Updater/config" - "AUTO_MAA_Go_Updater/download" - "AUTO_MAA_Go_Updater/errors" - "AUTO_MAA_Go_Updater/install" - "AUTO_MAA_Go_Updater/logger" - appversion "AUTO_MAA_Go_Updater/version" -) - -// UpdateState 表示更新过程的当前状态 -type UpdateState int - -const ( - StateIdle UpdateState = iota - StateChecking - StateUpdateAvailable - StateDownloading - StateInstalling - StateCompleted - StateError -) - -// String 返回更新状态的字符串表示 -func (s UpdateState) String() string { - switch s { - case StateIdle: - return "Idle" - case StateChecking: - return "Checking" - case StateUpdateAvailable: - return "UpdateAvailable" - case StateDownloading: - return "Downloading" - case StateInstalling: - return "Installing" - case StateCompleted: - return "Completed" - case StateError: - return "Error" - default: - return "Unknown" - } -} - -// GUIManager 可选 GUI 功能的接口 -type GUIManager interface { - ShowMainWindow() - UpdateStatus(status int, message string) - ShowProgress(percentage float64) - ShowError(errorMsg string) - Close() -} - -// UpdateInfo 包含可用更新的信息 -type UpdateInfo struct { - CurrentVersion string - NewVersion string - DownloadURL string - ReleaseNotes string - IsAvailable bool -} - -// Application 表示主应用程序实例 -type Application struct { - config *config.Config - configManager config.ConfigManager - apiClient api.MirrorClient - downloadManager download.DownloadManager - installManager install.InstallManager - guiManager GUIManager - logger logger.Logger - errorHandler errors.ErrorHandler - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup - - // 更新流程状态 - currentState UpdateState - stateMutex sync.RWMutex - updateInfo *UpdateInfo - userConfirmed chan bool -} - -// 命令行标志 -var ( - configPath = flag.String("config", "", "Path to configuration file") - logLevel = flag.String("log-level", "info", "Log level (debug, info, warn, error)") - noGUI = flag.Bool("no-gui", false, "Run without GUI (command line mode)") - version = flag.Bool("version", false, "Show version information") - help = flag.Bool("help", false, "Show help information") - channel = flag.String("channel", "", "Update channel (stable or beta)") - currentVersion = flag.String("current-version", "", "Current version to check against") -) - -// 版本信息现在由 version 包处理 - -func main() { - // 解析命令行参数 - flag.Parse() - - // 显示版本信息 - if *version { - showVersion() - return - } - - // 显示帮助信息 - if *help { - showHelp() - return - } - - // 检查单实例运行 - if err := ensureSingleInstance(); err != nil { - fmt.Fprintf(os.Stderr, "另一个实例已在运行: %v\n", err) - os.Exit(1) - } - - // 初始化应用程序 - app, err := initializeApplication() - if err != nil { - fmt.Fprintf(os.Stderr, "初始化应用程序失败: %v\n", err) - os.Exit(1) - } - defer app.cleanup() - - // 处理启动时标记删除的文件清理 - if err := app.handleStartupCleanup(); err != nil { - app.logger.Warn("清理标记文件失败: %v", err) - } - - // 设置信号处理 - app.setupSignalHandling() - - // 启动应用程序 - if err := app.run(); err != nil { - app.logger.Error("应用程序错误: %v", err) - os.Exit(1) - } -} - -// initializeApplication 初始化所有应用程序组件 -func initializeApplication() (*Application, error) { - // 创建优雅关闭的上下文 - ctx, cancel := context.WithCancel(context.Background()) - - // 首先初始化日志记录器 - loggerConfig := logger.DefaultLoggerConfig() - - // 从命令行设置日志级别 - switch *logLevel { - case "debug": - loggerConfig.Level = logger.DEBUG - case "info": - loggerConfig.Level = logger.INFO - case "warn": - loggerConfig.Level = logger.WARN - case "error": - loggerConfig.Level = logger.ERROR - } - - var appLogger logger.Logger - fileLogger, err := logger.NewFileLogger(loggerConfig) - if err != nil { - // 回退到控制台日志记录器 - appLogger = logger.NewConsoleLogger(os.Stdout) - } else { - appLogger = fileLogger - } - - appLogger.Info("正在初始化 AUTO_MAA_Go_Updater v%s", appversion.Version) - - // 初始化配置管理器 - var configManager config.ConfigManager - if *configPath != "" { - // 自定义配置路径尚未在配置包中实现 - // 目前使用默认管理器 - configManager = config.NewConfigManager() - appLogger.Warn("自定义配置路径尚未完全支持,使用默认配置") - } else { - configManager = config.NewConfigManager() - } - - // 加载配置 - cfg, err := configManager.Load() - if err != nil { - appLogger.Error("加载配置失败: %v", err) - return nil, fmt.Errorf("加载配置失败: %w", err) - } - - appLogger.Info("配置加载成功") - - // 初始化 API 客户端 - apiClient := api.NewClient() - - // 初始化下载管理器 - downloadManager := download.NewManager() - - // 初始化安装管理器 - installManager := install.NewManager() - - // 初始化错误处理器 - errorHandler := errors.NewDefaultErrorHandler() - - // 初始化 GUI 管理器(如果不是无 GUI 模式) - var guiManager GUIManager - if !*noGUI { - // GUI 将在 GUI 依赖项可用时实现 - appLogger.Info("请求 GUI 模式但此构建中不可用") - guiManager = nil - } else { - appLogger.Info("运行在无 GUI 模式") - } - - app := &Application{ - config: cfg, - configManager: configManager, - apiClient: apiClient, - downloadManager: downloadManager, - installManager: installManager, - guiManager: guiManager, - logger: appLogger, - errorHandler: errorHandler, - ctx: ctx, - cancel: cancel, - currentState: StateIdle, - userConfirmed: make(chan bool, 1), - } - - appLogger.Info("应用程序初始化成功") - return app, nil -} - -// run 启动主应用程序逻辑 -func (app *Application) run() error { - app.logger.Info("启动应用程序") - - if app.guiManager != nil { - // 使用 GUI 运行 - return app.runWithGUI() - } else { - // 在命令行模式下运行 - return app.runCommandLine() - } -} - -// runWithGUI 使用 GUI 运行应用程序 -func (app *Application) runWithGUI() error { - app.logger.Info("启动 GUI 模式") - - // 设置 GUI 回调 - app.setupGUICallbacks() - - // 显示主窗口(这将阻塞直到窗口关闭) - app.guiManager.ShowMainWindow() - - return nil -} - -// runCommandLine 在命令行模式下运行应用程序 -func (app *Application) runCommandLine() error { - app.logger.Info("启动命令行模式") - - // 开始完整的更新流程 - return app.executeUpdateFlow() -} - -// setupGUICallbacks 为 GUI 交互设置回调 -func (app *Application) setupGUICallbacks() { - if app.guiManager == nil { - return - } - - // GUI 回调将在 GUI 可用时实现 - app.logger.Info("请求 GUI 回调设置但 GUI 不可用") - - // 目前,我们将设置基本的交互处理 - // 实际的 GUI 集成将在 GUI 依赖项解决后完成 -} - -// handleStartupCleanup 处理启动时标记删除的文件清理 -func (app *Application) handleStartupCleanup() error { - app.logger.Info("执行启动清理") - - // 获取当前可执行文件目录 - exePath, err := os.Executable() - if err != nil { - return fmt.Errorf("获取可执行文件路径失败: %w", err) - } - - exeDir := filepath.Dir(exePath) - - // 删除标记删除的文件 - if installMgr, ok := app.installManager.(*install.Manager); ok { - if err := installMgr.DeleteMarkedFiles(exeDir); err != nil { - return fmt.Errorf("删除标记文件失败: %w", err) - } - } - - app.logger.Info("启动清理完成") - return nil -} - -// setupSignalHandling 设置系统信号的优雅关闭 -func (app *Application) setupSignalHandling() { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - go func() { - sig := <-sigChan - app.logger.Info("接收到信号: %v", sig) - app.logger.Info("启动优雅关闭...") - app.cancel() - }() -} - -// cleanup 执行应用程序清理 -func (app *Application) cleanup() { - app.logger.Info("清理应用程序资源") - - // 取消上下文以停止所有操作 - app.cancel() - - // 等待所有 goroutine 完成 - app.wg.Wait() - - // 清理安装管理器临时目录 - if installMgr, ok := app.installManager.(*install.Manager); ok { - if err := installMgr.CleanupAllTempDirs(); err != nil { - app.logger.Error("清理临时目录失败: %v", err) - } - } - - app.logger.Info("应用程序清理完成") - - // 最后关闭日志记录器 - if err := app.logger.Close(); err != nil { - fmt.Fprintf(os.Stderr, "关闭日志记录器失败: %v\n", err) - } -} - -// ensureSingleInstance 确保应用程序只有一个实例在运行 -func ensureSingleInstance() error { - // 在临时目录中创建锁文件 - tempDir := os.TempDir() - lockFile := filepath.Join(tempDir, "AUTO_MAA_Go_Updater.lock") - - // 尝试独占创建锁文件 - file, err := os.OpenFile(lockFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) - if err != nil { - if os.IsExist(err) { - // 检查进程是否仍在运行 - if isProcessRunning(lockFile) { - return fmt.Errorf("另一个实例已在运行") - } - // 删除过期的锁文件并重试 - os.Remove(lockFile) - return ensureSingleInstance() - } - return fmt.Errorf("创建锁文件失败: %w", err) - } - - // 将当前进程 ID 写入锁文件 - fmt.Fprintf(file, "%d", os.Getpid()) - file.Close() - - // 退出时删除锁文件 - go func() { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - <-sigChan - os.Remove(lockFile) - }() - - return nil -} - -// isProcessRunning 检查锁文件中的进程是否仍在运行 -func isProcessRunning(lockFile string) bool { - data, err := os.ReadFile(lockFile) - if err != nil { - return false - } - - var pid int - if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil { - return false - } - - // 检查进程是否存在(Windows 特定) - process, err := os.FindProcess(pid) - if err != nil { - return false - } - - // 在 Windows 上,FindProcess 总是成功,所以我们需要不同的检查方式 - // 尝试发送信号 0 来检查进程是否存在 - err = process.Signal(syscall.Signal(0)) - return err == nil -} - -// showVersion 显示版本信息 -func showVersion() { - fmt.Printf("AUTO_MAA_Go_Updater\n") - fmt.Printf("Version: %s\n", appversion.Version) - fmt.Printf("Build Time: %s\n", appversion.BuildTime) - fmt.Printf("Git Commit: %s\n", appversion.GitCommit) -} - -// showHelp 显示帮助信息 -func showHelp() { - fmt.Printf("AUTO_MAA_Go_Updater\n\n") - fmt.Printf("Usage: %s [options]\n\n", os.Args[0]) - fmt.Printf("Options:\n") - flag.PrintDefaults() - fmt.Printf("\nExamples:\n") - fmt.Printf(" %s # 使用 GUI 运行\n", os.Args[0]) - fmt.Printf(" %s -no-gui # 在命令行模式下运行\n", os.Args[0]) - fmt.Printf(" %s -log-level debug # 使用调试日志运行\n", os.Args[0]) - fmt.Printf(" %s -version # 显示版本信息\n", os.Args[0]) -} - -// executeUpdateFlow 执行完整的更新流程和状态机管理 -func (app *Application) executeUpdateFlow() error { - app.logger.Info("开始执行更新流程") - - // 执行状态机 - for { - select { - case <-app.ctx.Done(): - app.logger.Info("更新流程已取消") - return app.ctx.Err() - default: - } - - // 获取当前状态 - state := app.getCurrentState() - app.logger.Debug("当前状态: %s", state.String()) - - // 执行状态逻辑 - nextState, err := app.executeState(state) - if err != nil { - app.logger.Error("状态执行失败: %v", err) - app.setState(StateError) - return err - } - - // 检查是否完成 - if nextState == StateCompleted || nextState == StateError { - app.setState(nextState) - break - } - - // 转换到下一个状态 - app.setState(nextState) - } - - finalState := app.getCurrentState() - app.logger.Info("更新流程完成,状态: %s", finalState.String()) - - if finalState == StateError { - return fmt.Errorf("更新流程失败") - } - - return nil -} - -// executeState 执行当前状态的逻辑并返回下一个状态 -func (app *Application) executeState(state UpdateState) (UpdateState, error) { - switch state { - case StateIdle: - return app.executeIdleState() - case StateChecking: - return app.executeCheckingState() - case StateUpdateAvailable: - return app.executeUpdateAvailableState() - case StateDownloading: - return app.executeDownloadingState() - case StateInstalling: - return app.executeInstallingState() - case StateCompleted: - return StateCompleted, nil - case StateError: - return StateError, nil - default: - return StateError, fmt.Errorf("未知状态: %s", state.String()) - } -} - -// executeIdleState 处理空闲状态 -func (app *Application) executeIdleState() (UpdateState, error) { - app.logger.Info("开始更新检查...") - fmt.Println("正在检查更新...") - return StateChecking, nil -} - -// executeCheckingState 处理检查状态 -func (app *Application) executeCheckingState() (UpdateState, error) { - app.logger.Info("检查更新中") - - // 确定要使用的版本和渠道 - var currentVer, updateChannel string - var err error - - // 优先级: 命令行参数 > 版本文件 > 配置 - if *currentVersion != "" { - currentVer = *currentVersion - app.logger.Info("使用命令行当前版本: %s", currentVer) - } else { - // 尝试从 resources/version.json 加载版本 - versionManager := appversion.NewVersionManager() - versionInfo, err := versionManager.LoadVersionFromFile() - if err != nil { - app.logger.Warn("从文件加载版本失败: %v,使用配置版本", err) - currentVer = app.config.CurrentVersion - } else { - currentVer = versionInfo.MainVersion - app.logger.Info("使用版本文件中的当前版本: %s", currentVer) - } - } - - // 确定渠道 - if *channel != "" { - updateChannel = *channel - app.logger.Info("使用命令行渠道: %s", updateChannel) - } else { - // 尝试从 config.json 加载渠道 - updateChannel = app.loadChannelFromConfig() - app.logger.Info("使用配置中的渠道: %s", updateChannel) - } - - // 准备 API 参数 - params := api.UpdateCheckParams{ - ResourceID: "AUTO_MAA", // AUTO_MAA 的固定资源 ID - CurrentVersion: currentVer, - Channel: updateChannel, - UserAgent: app.config.UserAgent, - } - - // 调用 MirrorChyan API 检查更新 - response, err := app.apiClient.CheckUpdate(params) - switch updateChannel { - case "beta": - fmt.Println("检查更新类别:公测版") - case "stable": - fmt.Println("检查更新类别:稳定版") - default: - fmt.Printf("检查更新类别:%v\n", updateChannel) - } - fmt.Printf("当前版本:%v\n", currentVer) - app.logger.Info("当前更新类别:" + updateChannel + ";当前版本:" + currentVer) - if err != nil { - app.logger.Error("检查更新失败: %v", err) - fmt.Printf("检查更新失败: %v\n", err) - return StateError, fmt.Errorf("检查更新失败: %w", err) - } - - // 检查是否有可用更新 - isUpdateAvailable := app.apiClient.IsUpdateAvailable(response, currentVer) - - if !isUpdateAvailable { - app.logger.Info("无可用更新") - fmt.Println("当前已是最新版本") - - // 延迟 5 秒再退出 - fmt.Println("5 秒后自动退出...") - time.Sleep(5 * time.Second) - - return StateCompleted, nil - } - - // 使用下载站获取下载链接 - downloadURL := app.apiClient.GetDownloadURL(response.Data.VersionName) - app.logger.Info("使用下载站 URL: %s", downloadURL) - - // 存储更新信息 - app.updateInfo = &UpdateInfo{ - CurrentVersion: currentVer, - NewVersion: response.Data.VersionName, - DownloadURL: downloadURL, - ReleaseNotes: response.Data.ReleaseNote, - IsAvailable: true, - } - - app.logger.Info("有可用更新: %s -> %s", currentVer, response.Data.VersionName) - fmt.Printf("发现新版本: %s -> %s\n", currentVer, response.Data.VersionName) - - return StateUpdateAvailable, nil -} - -// executeUpdateAvailableState 处理更新可用状态 -func (app *Application) executeUpdateAvailableState() (UpdateState, error) { - app.logger.Info("有可用更新,自动开始下载") - - // 自动开始下载,无需用户确认 - fmt.Println("开始下载更新...") - return StateDownloading, nil -} - -// executeDownloadingState 处理下载状态 -func (app *Application) executeDownloadingState() (UpdateState, error) { - app.logger.Info("开始下载") - - if app.updateInfo == nil || app.updateInfo.DownloadURL == "" { - return StateError, fmt.Errorf("无可用下载 URL") - } - - // 获取当前可执行文件目录 - exePath, err := os.Executable() - if err != nil { - return StateError, fmt.Errorf("获取可执行文件路径失败: %w", err) - } - exeDir := filepath.Dir(exePath) - - // 为下载创建 AUTOMAA_UPDATE_TEMP 目录 - tempDir := filepath.Join(exeDir, "AUTOMAA_UPDATE_TEMP") - if err := os.MkdirAll(tempDir, 0755); err != nil { - return StateError, fmt.Errorf("创建临时目录失败: %w", err) - } - - // 下载文件 - downloadPath := filepath.Join(tempDir, "update.zip") - - fmt.Println("正在下载更新包...") - - // 创建进度回调 - progressCallback := func(progress download.DownloadProgress) { - if progress.TotalBytes > 0 { - fmt.Printf("\r下载进度: %.1f%% (%s/s)", - progress.Percentage, - app.formatBytes(progress.Speed)) - } - } - - // 下载更新文件 - downloadErr := app.downloadManager.Download(app.updateInfo.DownloadURL, downloadPath, progressCallback) - - fmt.Println() // 进度后换行 - - if downloadErr != nil { - app.logger.Error("下载失败: %v", downloadErr) - fmt.Printf("下载失败: %v\n", downloadErr) - return StateError, fmt.Errorf("下载失败: %w", downloadErr) - } - - app.logger.Info("下载成功完成") - fmt.Println("下载完成") - - // 存储下载路径用于安装 - app.updateInfo.DownloadURL = downloadPath - - return StateInstalling, nil -} - -// executeInstallingState 处理安装状态 -func (app *Application) executeInstallingState() (UpdateState, error) { - app.logger.Info("开始安装") - fmt.Println("正在安装更新...") - - if app.updateInfo == nil || app.updateInfo.DownloadURL == "" { - return StateError, fmt.Errorf("无可用下载文件") - } - - downloadPath := app.updateInfo.DownloadURL - - // 为解压创建临时目录 - tempDir, err := app.installManager.CreateTempDir() - if err != nil { - return StateError, fmt.Errorf("创建临时目录失败: %w", err) - } - - // 解压下载的 zip 文件 - app.logger.Info("解压更新包") - if err := app.installManager.ExtractZip(downloadPath, tempDir); err != nil { - app.logger.Error("解压 zip 失败: %v", err) - return StateError, fmt.Errorf("解压更新包失败: %w", err) - } - - // 如果存在 changes.json 则处理(供将来使用) - changesPath := filepath.Join(tempDir, "changes.json") - _, err = app.installManager.ProcessChanges(changesPath) - if err != nil { - app.logger.Warn("处理变更失败(非关键): %v", err) - // 这对于 AUTO_MAA-Setup.exe 安装不是关键的 - } - - // 获取当前可执行文件目录 - exePath, err := os.Executable() - if err != nil { - return StateError, fmt.Errorf("获取可执行文件路径失败: %w", err) - } - targetDir := filepath.Dir(exePath) - - // 处理正在运行的进程(但跳过更新器本身) - updaterName := filepath.Base(exePath) - if err := app.handleRunningProcesses(targetDir, updaterName); err != nil { - app.logger.Warn("处理正在运行的进程失败: %v", err) - // 继续安装,这不是关键的 - } - - // 在解压的文件中查找 AUTO_MAA-Setup.exe - setupExePath := filepath.Join(tempDir, "AUTO_MAA-Setup.exe") - if _, err := os.Stat(setupExePath); err != nil { - app.logger.Error("在更新包中未找到 AUTO_MAA-Setup.exe: %v", err) - return StateError, fmt.Errorf("在更新包中未找到 AUTO_MAA-Setup.exe: %w", err) - } - - // 运行安装可执行文件 - app.logger.Info("运行 AUTO_MAA-Setup.exe") - fmt.Println("正在运行安装程序...") - - if err := app.runSetupExecutable(setupExePath); err != nil { - app.logger.Error("运行安装可执行文件失败: %v", err) - return StateError, fmt.Errorf("运行安装可执行文件失败: %w", err) - } - - // 使用新版本更新 version.json 文件 - if err := app.updateVersionFile(app.updateInfo.NewVersion); err != nil { - app.logger.Warn("更新版本文件失败: %v", err) - // 这不是关键的,继续 - } - - // 安装后清理 AUTOMAA_UPDATE_TEMP 目录 - if err := os.RemoveAll(tempDir); err != nil { - app.logger.Warn("清理临时目录失败: %v", err) - // 这不是关键的,继续 - } else { - app.logger.Info("清理临时目录: %s", tempDir) - } - - app.logger.Info("安装成功完成") - fmt.Println("安装完成") - fmt.Printf("已更新到版本: %s\n", app.updateInfo.NewVersion) - - return StateCompleted, nil -} - -// getCurrentState 线程安全地返回当前状态 -func (app *Application) getCurrentState() UpdateState { - app.stateMutex.RLock() - defer app.stateMutex.RUnlock() - return app.currentState -} - -// setState 线程安全地设置当前状态 -func (app *Application) setState(state UpdateState) { - app.stateMutex.Lock() - defer app.stateMutex.Unlock() - - app.logger.Debug("状态转换: %s -> %s", app.currentState.String(), state.String()) - app.currentState = state - - // 如果可用则更新 GUI - if app.guiManager != nil { - app.updateGUIStatus(state) - } -} - -// updateGUIStatus 根据当前状态更新 GUI -func (app *Application) updateGUIStatus(state UpdateState) { - if app.guiManager == nil { - return - } - - switch state { - case StateIdle: - app.guiManager.UpdateStatus(0, "准备检查更新...") - case StateChecking: - app.guiManager.UpdateStatus(1, "正在检查更新...") - case StateUpdateAvailable: - if app.updateInfo != nil { - message := fmt.Sprintf("发现新版本: %s", app.updateInfo.NewVersion) - app.guiManager.UpdateStatus(2, message) - } - case StateDownloading: - app.guiManager.UpdateStatus(3, "正在下载更新...") - case StateInstalling: - app.guiManager.UpdateStatus(4, "正在安装更新...") - case StateCompleted: - app.guiManager.UpdateStatus(5, "更新完成") - case StateError: - app.guiManager.UpdateStatus(6, "更新失败") - } -} - -// formatBytes 将字节格式化为人类可读格式 -func (app *Application) formatBytes(bytes int64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} - -// handleUserInteraction 处理 GUI 模式的用户交互 -func (app *Application) handleUserInteraction(action string) { - switch action { - case "confirm_update": - select { - case app.userConfirmed <- true: - default: - } - case "cancel_update": - select { - case app.userConfirmed <- false: - default: - } - case "check_update": - // 在 goroutine 中启动更新流程 - app.wg.Add(1) - go func() { - defer app.wg.Done() - if err := app.executeUpdateFlow(); err != nil { - app.logger.Error("更新流程失败: %v", err) - } - }() - } -} - -// updateVersionFile 使用新版本更新目标软件的 version.json 文件 -func (app *Application) updateVersionFile(newVersion string) error { - // 获取当前可执行文件目录(目标软件所在位置) - exePath, err := os.Executable() - if err != nil { - return fmt.Errorf("获取可执行文件路径失败: %w", err) - } - targetDir := filepath.Dir(exePath) - - // 目标软件版本文件的路径 - versionFilePath := filepath.Join(targetDir, "resources", "version.json") - - // 尝试加载现有版本文件 - versionManager := appversion.NewVersionManager() - versionInfo, err := versionManager.LoadVersionFromFile() - if err != nil { - app.logger.Warn("无法加载现有版本文件,创建新文件: %v", err) - // 创建基本版本信息结构 - versionInfo = &appversion.VersionInfo{ - MainVersion: newVersion, - VersionInfo: make(map[string]map[string][]string), - } - } - - // 解析新版本以获取正确格式 - parsedVersion, err := appversion.ParseVersion(newVersion) - if err != nil { - // 如果无法从 API 响应解析版本,尝试从显示格式提取 - if strings.HasPrefix(newVersion, "v") { - // 将 "v4.4.1-beta3" 转换为 "4.4.1.3" 格式 - versionStr := strings.TrimPrefix(newVersion, "v") - if strings.Contains(versionStr, "-beta") { - parts := strings.Split(versionStr, "-beta") - if len(parts) == 2 { - baseVersion := parts[0] - betaNum := parts[1] - versionInfo.MainVersion = fmt.Sprintf("%s.%s", baseVersion, betaNum) - } else { - versionInfo.MainVersion = versionStr + ".0" - } - } else { - versionInfo.MainVersion = versionStr + ".0" - } - } else { - versionInfo.MainVersion = newVersion - } - } else { - // 使用解析的版本创建正确格式 - versionInfo.MainVersion = parsedVersion.ToVersionString() - } - - // 如果 resources 目录不存在则创建 - resourcesDir := filepath.Join(targetDir, "resources") - if err := os.MkdirAll(resourcesDir, 0755); err != nil { - return fmt.Errorf("创建 resources 目录失败: %w", err) - } - - // 写入更新的版本文件 - data, err := json.MarshalIndent(versionInfo, "", " ") - if err != nil { - return fmt.Errorf("序列化版本信息失败: %w", err) - } - - if err := os.WriteFile(versionFilePath, data, 0644); err != nil { - return fmt.Errorf("写入版本文件失败: %w", err) - } - - app.logger.Info("更新版本文件: %s -> %s", versionFilePath, versionInfo.MainVersion) - return nil -} - -// handleRunningProcesses 处理正在运行的进程但排除更新器本身 -func (app *Application) handleRunningProcesses(targetDir, updaterName string) error { - app.logger.Info("处理正在运行的进程,排除更新器: %s", updaterName) - - // 获取目标目录中的可执行文件列表 - files, err := os.ReadDir(targetDir) - if err != nil { - return fmt.Errorf("读取目标目录失败: %w", err) - } - - for _, file := range files { - if file.IsDir() { - continue - } - - fileName := file.Name() - - // 跳过更新器本身 - if fileName == updaterName { - app.logger.Info("跳过更新器文件: %s", fileName) - continue - } - - // 只处理 .exe 文件 - if !strings.HasSuffix(strings.ToLower(fileName), ".exe") { - continue - } - - // 处理此可执行文件 - if err := app.installManager.HandleRunningProcess(fileName); err != nil { - app.logger.Warn("处理正在运行的进程 %s 失败: %v", fileName, err) - // 继续处理其他文件,不要让整个过程失败 - } - } - - return nil -} - -// runSetupExecutable 使用适当参数运行安装可执行文件 -func (app *Application) runSetupExecutable(setupExePath string) error { - app.logger.Info("执行安装文件: %s", setupExePath) - - // 获取当前可执行文件目录作为安装目录 - exePath, err := os.Executable() - if err != nil { - return fmt.Errorf("获取可执行文件路径失败: %w", err) - } - installDir := filepath.Dir(exePath) - - // 设置与 Python 实现匹配的命令参数 - args := []string{ - "/SP-", // 跳过欢迎页面 - "/SILENT", // 静默安装 - "/NOCANCEL", // 无取消按钮 - "/FORCECLOSEAPPLICATIONS", // 强制关闭应用程序 - "/LANG=Chinese", // 中文语言 - fmt.Sprintf("/DIR=%s", installDir), // 安装目录 - } - - app.logger.Info("使用参数运行安装程序: %v", args) - - // 使用参数创建命令 - cmd := exec.Command(setupExePath, args...) - - // 设置工作目录为安装文件的目录 - cmd.Dir = filepath.Dir(setupExePath) - - // 运行命令并等待完成 - if err := cmd.Run(); err != nil { - return fmt.Errorf("执行安装程序失败: %w", err) - } - - app.logger.Info("安装可执行文件成功完成") - return nil -} - -// AutoMAAConfig 表示 config/config.json 的结构 -type AutoMAAConfig struct { - Update struct { - UpdateType string `json:"UpdateType"` - } `json:"Update"` -} - -// loadChannelFromConfig 从 config/config.json 加载更新渠道 -func (app *Application) loadChannelFromConfig() string { - // 获取当前可执行文件目录 - exePath, err := os.Executable() - if err != nil { - app.logger.Warn("获取可执行文件路径失败: %v", err) - return "stable" - } - - configPath := filepath.Join(filepath.Dir(exePath), "config", "config.json") - - // 检查配置文件是否存在 - if _, err := os.Stat(configPath); os.IsNotExist(err) { - app.logger.Info("配置文件未找到: %s,使用默认渠道", configPath) - return "stable" - } - - // 读取配置文件 - data, err := os.ReadFile(configPath) - if err != nil { - app.logger.Warn("读取配置文件失败: %v,使用默认渠道", err) - return "stable" - } - - // 解析 JSON - var config AutoMAAConfig - if err := json.Unmarshal(data, &config); err != nil { - app.logger.Warn("解析配置文件失败: %v,使用默认渠道", err) - return "stable" - } - - // 获取更新渠道 - updateType := config.Update.UpdateType - if updateType == "" { - app.logger.Info("配置中未找到 UpdateType,使用默认渠道") - return "stable" - } - - app.logger.Info("从配置加载更新渠道: %s", updateType) - return updateType -} diff --git a/Go_Updater/version/manager.go b/Go_Updater/version/manager.go deleted file mode 100644 index 9f708d9..0000000 --- a/Go_Updater/version/manager.go +++ /dev/null @@ -1,178 +0,0 @@ -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 将版本转换为显示格式 (v4.4.0 或 v4.4.1-beta3) -func (pv *ParsedVersion) ToDisplayVersion() string { - if pv.Beta == 0 { - return fmt.Sprintf("v%d.%d.%d", pv.Major, pv.Minor, pv.Patch) - } - return fmt.Sprintf("v%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 - } - return pv.Beta > other.Beta -} diff --git a/Go_Updater/version/manager_test.go b/Go_Updater/version/manager_test.go deleted file mode 100644 index 41a3f7c..0000000 --- a/Go_Updater/version/manager_test.go +++ /dev/null @@ -1,366 +0,0 @@ -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)) - } -} \ No newline at end of file diff --git a/Go_Updater/version/version.go b/Go_Updater/version/version.go deleted file mode 100644 index 7eda7f7..0000000 --- a/Go_Updater/version/version.go +++ /dev/null @@ -1,19 +0,0 @@ -package version - -import ( - "runtime" -) - -var ( - // Version 应用程序的当前版本 - Version = "1.0.0" - - // BuildTime 在构建时设置 - BuildTime = "unknown" - - // GitCommit 在构建时设置 - GitCommit = "unknown" - - // GoVersion 用于构建的 Go 版本 - GoVersion = runtime.Version() -) diff --git a/README.md b/README.md index ea0da17..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,119 +0,0 @@ -

AUTO_MAA

-

- MAA多账号管理与自动化软件

- 软件图标 -

- ---- - -

- GitHub Stars - GitHub Forks - GitHub Downloads - GitHub Issues - GitHub Contributors - GitHub License - DeepWiki - mirrorc -

- -## 软件介绍 - -### 性质 - -本软件是明日方舟第三方软件`MAA`的第三方工具,即第33方软件。旨在优化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/)。 - -## 贡献者 - -感谢以下贡献者对本项目做出的贡献 - - - - - - - -![Alt](https://repobeats.axiom.co/api/embed/6c2f834141eff1ac297db70d12bd11c6236a58a5.svg "Repobeats analytics image") - -## Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=DLmaster361/AUTO_MAA&type=Date)](https://star-history.com/#DLmaster361/AUTO_MAA&Date) - -## 交流与赞助 - -欢迎加入AUTO_MAA项目组,欢迎反馈bug - -- QQ交流群:[957750551](https://qm.qq.com/q/bd9fISNoME) - ---- - -如果喜欢这个项目的话,给作者来杯咖啡吧! - -![payid](resources/images/README/payid.png "payid") diff --git a/app/__init__.py b/app/__init__.py index b2f64f6..29fe0ce 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,6 @@ # AUTO_MAA:A MAA Multi Account Management and Automation Tool # Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox # This file is part of AUTO_MAA. @@ -18,32 +19,15 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA主程序包 -v4.4 -作者:DLmaster_361 -""" - -__version__ = "4.2.0" +__version__ = "5.0.0" __author__ = "DLmaster361 " __license__ = "GPL-3.0 license" -from .core import QueueConfig, MaaConfig, MaaUserConfig, Task, TaskManager, MainTimer -from .models import MaaManager -from .services import Notify, Crypto, System -from .ui import AUTO_MAA -__all__ = [ - "QueueConfig", - "MaaConfig", - "MaaUserConfig", - "Task", - "TaskManager", - "MainTimer", - "MaaManager", - "Notify", - "Crypto", - "System", - "AUTO_MAA", -] +from .api import * +from .core import * +from .models import * +from .services import * +from .utils import * + +__all__ = ["api", "core", "models", "services", "utils"] diff --git a/app/core/logger.py b/app/api/__init__.py similarity index 55% rename from app/core/logger.py rename to app/api/__init__.py index aa942dd..9a3f295 100644 --- a/app/core/logger.py +++ b/app/api/__init__.py @@ -1,5 +1,6 @@ # AUTO_MAA:A MAA Multi Account Management and Automation Tool # Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox # This file is part of AUTO_MAA. @@ -18,17 +19,26 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA日志组件 -v4.4 -作者:DLmaster_361 -""" +__version__ = "5.0.0" +__author__ = "DLmaster361 " +__license__ = "GPL-3.0 license" -from loguru import logger as _logger +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 -# 设置日志 module 字段默认值 -logger = _logger.patch( - lambda record: record["extra"].setdefault("module", "未知模块") or True -) -logger.remove(0) +__all__ = [ + "core_router", + "info_router", + "scripts_router", + "plan_router", + "queue_router", + "dispatch_router", + "history_router", + "setting_router", +] diff --git a/app/api/core.py b/app/api/core.py new file mode 100644 index 0000000..25331cd --- /dev/null +++ b/app/api/core.py @@ -0,0 +1,49 @@ +# AUTO_MAA:A MAA Multi Account Management and Automation Tool +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox + +# 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 . + +# Contact: DLmaster_361@163.com + + +import asyncio +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from app.core import Config, Broadcast +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): + await websocket.accept() + Config.websocket = websocket + while True: + try: + data = await asyncio.wait_for(websocket.receive_json(), timeout=30.0) + await Broadcast.put(data) + except asyncio.TimeoutError: + await websocket.send_json( + WebSocketMessage( + id="Main", type="Signal", data={"Ping": "无描述"} + ).model_dump() + ) + except WebSocketDisconnect: + break + await System.set_power("KillSelf") diff --git a/app/api/dispatch.py b/app/api/dispatch.py new file mode 100644 index 0000000..77160a8 --- /dev/null +++ b/app/api/dispatch.py @@ -0,0 +1,72 @@ +# AUTO_MAA:A MAA Multi Account Management and Automation Tool +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox + +# 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 . + +# Contact: DLmaster_361@163.com + + +import uuid +import asyncio +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Body, Path + +from app.core import TaskManager, Broadcast +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("/power", summary="电源操作", response_model=OutBase, status_code=200) +async def power_task(task: PowerIn = Body(...)) -> OutBase: + + try: + await System.set_power(task.signal) + except Exception as e: + return OutBase( + code=500, status="error", message=f"{type(e).__name__}: {str(e)}" + ) + return OutBase() diff --git a/app/api/history.py b/app/api/history.py new file mode 100644 index 0000000..a7e3bd7 --- /dev/null +++ b/app/api/history.py @@ -0,0 +1,84 @@ +# AUTO_MAA:A MAA Multi Account Management and Automation Tool +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox + +# 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 . + +# 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) + 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) diff --git a/app/api/info.py b/app/api/info.py new file mode 100644 index 0000000..e7194ac --- /dev/null +++ b/app/api/info.py @@ -0,0 +1,203 @@ +# AUTO_MAA:A MAA Multi Account Management and Automation Tool +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox + +# 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 . + +# 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( + "/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( + "/startuptask", + summary="获取启动时运行的队列ID", + response_model=InfoOut, + status_code=200, +) +async def get_startup_task() -> InfoOut: + + try: + data = await Config.get_startup_task() + except Exception as e: + return InfoOut( + code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data={} + ) + return InfoOut(data={"queueIdList": 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}) diff --git a/app/api/plan.py b/app/api/plan.py new file mode 100644 index 0000000..d418a8c --- /dev/null +++ b/app/api/plan.py @@ -0,0 +1,105 @@ +# AUTO_MAA:A MAA Multi Account Management and Automation Tool +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox + +# 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 . + +# 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() diff --git a/app/api/queue.py b/app/api/queue.py new file mode 100644 index 0000000..8fb39de --- /dev/null +++ b/app/api/queue.py @@ -0,0 +1,258 @@ +# AUTO_MAA:A MAA Multi Account Management and Automation Tool +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox + +# 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 . + +# 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() diff --git a/app/api/scripts.py b/app/api/scripts.py new file mode 100644 index 0000000..82719e3 --- /dev/null +++ b/app/api/scripts.py @@ -0,0 +1,282 @@ +# AUTO_MAA:A MAA Multi Account Management and Automation Tool +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox + +# 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 . + +# 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_scripts(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() diff --git a/app/api/setting.py b/app/api/setting.py new file mode 100644 index 0000000..f6f664b --- /dev/null +++ b/app/api/setting.py @@ -0,0 +1,82 @@ +# AUTO_MAA:A MAA Multi Account Management and Automation Tool +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox + +# 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 . + +# 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 +from app.models.schema import * + +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() diff --git a/app/core/__init__.py b/app/core/__init__.py index 9240bc7..f9b827c 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -1,5 +1,6 @@ # AUTO_MAA:A MAA Multi Account Management and Automation Tool # Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox # This file is part of AUTO_MAA. @@ -18,46 +19,22 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA核心组件包 -v4.4 -作者:DLmaster_361 -""" - -__version__ = "4.2.0" +__version__ = "5.0.0" __author__ = "DLmaster361 " __license__ = "GPL-3.0 license" -from .config import ( - QueueConfig, - MaaConfig, - MaaUserConfig, - MaaPlanConfig, - GeneralConfig, - GeneralSubConfig, - Config, -) -from .logger import logger -from .main_info_bar import MainInfoBar -from .network import Network -from .sound_player import SoundPlayer -from .task_manager import Task, TaskManager +from .broadcast import Broadcast +from .config import Config, MaaConfig, GeneralConfig, MaaUserConfig, GeneralUserConfig from .timer import MainTimer +from .task_manager import TaskManager __all__ = [ + "Broadcast", "Config", - "QueueConfig", "MaaConfig", - "MaaUserConfig", - "MaaPlanConfig", "GeneralConfig", - "GeneralSubConfig", - "logger", - "MainInfoBar", - "Network", - "SoundPlayer", - "Task", - "TaskManager", "MainTimer", + "TaskManager", + "MaaUserConfig", + "GeneralUserConfig", ] diff --git a/app/core/broadcast.py b/app/core/broadcast.py new file mode 100644 index 0000000..d145deb --- /dev/null +++ b/app/core/broadcast.py @@ -0,0 +1,51 @@ +# 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 . + +# 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() diff --git a/app/core/config.py b/app/core/config.py index d734263..b52e3bf 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,5 +1,6 @@ # AUTO_MAA:A MAA Multi Account Management and Automation Tool # Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox # This file is part of AUTO_MAA. @@ -18,269 +19,153 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA配置管理 -v4.4 -作者:DLmaster_361 -""" -from PySide6.QtCore import Signal -import argparse -import sqlite3 -import json -import sys -import shutil import re -import base64 +import shutil +import asyncio +import uvicorn +import sqlite3 import calendar -from datetime import datetime, timedelta, date -from collections import defaultdict +import requests +import truststore from pathlib import Path -from qfluentwidgets import ( - QConfig, - ConfigItem, - OptionsConfigItem, - RangeConfigItem, - ConfigValidator, - FolderValidator, - BoolValidator, - RangeValidator, - OptionsValidator, - exceptionHandler, -) -from urllib.parse import urlparse -from typing import Union, Dict, List +from fastapi import WebSocket +from urllib.parse import quote +from collections import defaultdict +from datetime import datetime, timedelta, date, timezone +from typing import Literal, Optional -from .logger import logger -from .network import Network +from app.models.ConfigBase import * +from app.utils.constants import * +from app.utils import get_logger + +logger = get_logger("配置管理") -class FileValidator(ConfigValidator): - """File validator""" - - def validate(self, value): - return Path(value).exists() - - def correct(self, value): - path = Path(value) - return str(path.absolute()).replace("\\", "/") - - -class UrlListValidator(ConfigValidator): - """Url list validator""" - - def validate(self, value): - - try: - result = urlparse(value) - return all([result.scheme, result.netloc]) - except ValueError: - return False - - def correct(self, value: List[str]): - - urls = [] - - for url in [_ for _ in value if _ != ""]: - if url[-1] != "/": - urls.append(f"{url}/") - else: - urls.append(url) - - return list(set([_ for _ in urls if self.validate(_)])) - - -class LQConfig(QConfig): - """局域配置类""" - - def __init__(self) -> None: - super().__init__() - - def toDict(self, serialize=True): - """convert config items to `dict`""" - items = {} - for name in dir(self._cfg): - item = getattr(self._cfg, name) - if not isinstance(item, ConfigItem): - continue - - value = item.serialize() if serialize else item.value - if not items.get(item.group): - if not item.name: - items[item.group] = value - else: - items[item.group] = {} - - if item.name: - items[item.group][item.name] = value - - return items - - @exceptionHandler() - def load(self, file=None, config=None): - """load config - - Parameters - ---------- - file: str or Path - the path of json config file - - config: Config - config object to be initialized - """ - if isinstance(config, QConfig): - self._cfg = config - self._cfg.themeChanged.connect(self.themeChanged) - - if isinstance(file, (str, Path)): - self._cfg.file = Path(file) - - try: - with open(self._cfg.file, encoding="utf-8") as f: - cfg = json.load(f) - except: - cfg = {} - - # map config items'key to item - items = {} - for name in dir(self._cfg): - item = getattr(self._cfg, name) - if isinstance(item, ConfigItem): - items[item.key] = item - - # update the value of config item - for k, v in cfg.items(): - if not isinstance(v, dict) and items.get(k) is not None: - items[k].deserializeFrom(v) - elif isinstance(v, dict): - for key, value in v.items(): - key = k + "." + key - if items.get(key) is not None: - items[key].deserializeFrom(value) - - self.theme = self.get(self._cfg.themeMode) - - -class GlobalConfig(LQConfig): +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_IfCompanyWebHookBot = ConfigItem( + "Notify", "IfCompanyWebHookBot", False, BoolValidator() + ) + Notify_CompanyWebHookBotUrl = ConfigItem("Notify", "CompanyWebHookBotUrl", "") + + Update_IfAutoUpdate = ConfigItem("Update", "IfAutoUpdate", False, BoolValidator()) + Update_UpdateType = ConfigItem( + "Update", "UpdateType", "stable", OptionsValidator(["stable", "beta"]) + ) + Update_Source = ConfigItem( + "Update", + "Source", + "GitHub", + OptionsValidator(["GitHub", "MirrorChyan", "AutoSite"]), + ) + Update_ProxyAddress = ConfigItem("Update", "ProxyAddress", "") + Update_MirrorChyanCDK = ConfigItem( + "Update", "MirrorChyanCDK", "", EncryptValidator() + ) + + Data_LastStageUpdated = ConfigItem( + "Data", "LastStageUpdated", "2000-01-01 00:00:00" + ) + Data_StageTimeStamp = ConfigItem("Data", "StageTimeStamp", "2000-01-01 00:00:00") + Data_Stage = ConfigItem("Data", "Stage", "{ }") + Data_LastNoticeUpdated = ConfigItem( + "Data", "LastNoticeUpdated", "2000-01-01 00:00:00" + ) + Data_IfShowNotice = ConfigItem("Data", "IfShowNotice", True, BoolValidator()) + Data_Notice = ConfigItem("Data", "Notice", "{ }") + Data_LastWebConfigUpdated = ConfigItem( + "Data", "LastWebConfigUpdated", "2000-01-01 00:00:00" + ) + Data_WebConfig = ConfigItem("Data", "WebConfig", "{ }") + + +class QueueItem(ConfigBase): + """队列项配置""" + def __init__(self) -> None: super().__init__() - self.function_HomeImageMode = OptionsConfigItem( - "Function", - "HomeImageMode", - "默认", - OptionsValidator(["默认", "自定义", "主题图像"]), - ) - self.function_HistoryRetentionTime = OptionsConfigItem( - "Function", - "HistoryRetentionTime", - 0, - OptionsValidator([7, 15, 30, 60, 90, 180, 365, 0]), - ) - self.function_IfAllowSleep = ConfigItem( - "Function", "IfAllowSleep", False, BoolValidator() - ) - self.function_IfSilence = ConfigItem( - "Function", "IfSilence", False, BoolValidator() - ) - self.function_BossKey = ConfigItem("Function", "BossKey", "") - self.function_UnattendedMode = ConfigItem( - "Function", "UnattendedMode", False, BoolValidator() - ) - self.function_IfAgreeBilibili = ConfigItem( - "Function", "IfAgreeBilibili", False, BoolValidator() - ) - self.function_IfSkipMumuSplashAds = ConfigItem( - "Function", "IfSkipMumuSplashAds", False, BoolValidator() - ) - - self.voice_Enabled = ConfigItem("Voice", "Enabled", False, BoolValidator()) - self.voice_Type = OptionsConfigItem( - "Voice", "Type", "simple", OptionsValidator(["simple", "noisy"]) - ) - - self.start_IfSelfStart = ConfigItem( - "Start", "IfSelfStart", False, BoolValidator() - ) - self.start_IfMinimizeDirectly = ConfigItem( - "Start", "IfMinimizeDirectly", False, BoolValidator() - ) - - self.ui_IfShowTray = ConfigItem("UI", "IfShowTray", False, BoolValidator()) - self.ui_IfToTray = ConfigItem("UI", "IfToTray", False, BoolValidator()) - self.ui_size = ConfigItem("UI", "size", "1200x700") - self.ui_location = ConfigItem("UI", "location", "100x100") - self.ui_maximized = ConfigItem("UI", "maximized", False, BoolValidator()) - - self.notify_SendTaskResultTime = OptionsConfigItem( - "Notify", - "SendTaskResultTime", - "不推送", - OptionsValidator(["不推送", "任何时刻", "仅失败时"]), - ) - self.notify_IfSendStatistic = ConfigItem( - "Notify", "IfSendStatistic", False, BoolValidator() - ) - self.notify_IfSendSixStar = ConfigItem( - "Notify", "IfSendSixStar", False, BoolValidator() - ) - self.notify_IfPushPlyer = ConfigItem( - "Notify", "IfPushPlyer", False, BoolValidator() - ) - self.notify_IfSendMail = ConfigItem( - "Notify", "IfSendMail", False, BoolValidator() - ) - self.notify_SMTPServerAddress = ConfigItem("Notify", "SMTPServerAddress", "") - self.notify_AuthorizationCode = ConfigItem("Notify", "AuthorizationCode", "") - self.notify_FromAddress = ConfigItem("Notify", "FromAddress", "") - self.notify_ToAddress = ConfigItem("Notify", "ToAddress", "") - self.notify_IfServerChan = ConfigItem( - "Notify", "IfServerChan", False, BoolValidator() - ) - self.notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "") - self.notify_ServerChanChannel = ConfigItem("Notify", "ServerChanChannel", "") - self.notify_ServerChanTag = ConfigItem("Notify", "ServerChanTag", "") - self.notify_IfCompanyWebHookBot = ConfigItem( - "Notify", "IfCompanyWebHookBot", False, BoolValidator() - ) - self.notify_CompanyWebHookBotUrl = ConfigItem( - "Notify", "CompanyWebHookBotUrl", "" - ) - - self.update_IfAutoUpdate = ConfigItem( - "Update", "IfAutoUpdate", False, BoolValidator() - ) - self.update_UpdateType = OptionsConfigItem( - "Update", "UpdateType", "stable", OptionsValidator(["stable", "beta"]) - ) - self.update_ThreadNumb = RangeConfigItem( - "Update", "ThreadNumb", 8, RangeValidator(1, 32) - ) - self.update_ProxyAddress = ConfigItem("Update", "ProxyAddress", "") - self.update_ProxyUrlList = ConfigItem( - "Update", "ProxyUrlList", [], UrlListValidator() - ) - self.update_MirrorChyanCDK = ConfigItem("Update", "MirrorChyanCDK", "") + self.Info_ScriptId = ConfigItem("Info", "ScriptId", None, UidValidator()) -class QueueConfig(LQConfig): +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") + + +class QueueConfig(ConfigBase): """队列配置""" def __init__(self) -> None: super().__init__() - self.QueueSet_Name = ConfigItem("QueueSet", "Name", "") - self.QueueSet_TimeEnabled = ConfigItem( - "QueueSet", "TimeEnabled", False, BoolValidator() + self.Info_Name = ConfigItem("Info", "Name", "新队列") + self.Info_TimeEnabled = ConfigItem( + "Info", "TimeEnabled", False, BoolValidator() ) - self.QueueSet_StartUpEnabled = ConfigItem( - "QueueSet", "StartUpEnabled", False, BoolValidator() + self.Info_StartUpEnabled = ConfigItem( + "Info", "StartUpEnabled", False, BoolValidator() ) - self.QueueSet_AfterAccomplish = OptionsConfigItem( - "QueueSet", + self.Info_AfterAccomplish = ConfigItem( + "Info", "AfterAccomplish", "NoAction", OptionsValidator( @@ -295,78 +180,11 @@ class QueueConfig(LQConfig): ), ) - self.config_item_dict: dict[str, Dict[str, ConfigItem]] = { - "Queue": {}, - "Time": {}, - } - - for i in range(10): - - self.config_item_dict["Time"][f"Enabled_{i}"] = ConfigItem( - "Time", f"Enabled_{i}", False, BoolValidator() - ) - self.config_item_dict["Time"][f"Set_{i}"] = ConfigItem( - "Time", f"Set_{i}", "00:00" - ) - self.config_item_dict["Queue"][f"Script_{i}"] = OptionsConfigItem( - "Queue", f"Script_{i}", "禁用" - ) - - setattr( - self, f"Time_Enabled_{i}", self.config_item_dict["Time"][f"Enabled_{i}"] - ) - setattr(self, f"Time_Set_{i}", self.config_item_dict["Time"][f"Set_{i}"]) - setattr( - self, f"Queue_Script_{i}", self.config_item_dict["Queue"][f"Script_{i}"] - ) - - self.Data_LastProxyTime = ConfigItem( - "Data", "LastProxyTime", "2000-01-01 00:00:00" - ) - self.Data_LastProxyHistory = ConfigItem( - "Data", "LastProxyHistory", "暂无历史运行记录" - ) + self.TimeSet = MultipleConfig([TimeSet]) + self.QueueItem = MultipleConfig([QueueItem]) -class MaaConfig(LQConfig): - """MAA配置""" - - def __init__(self) -> None: - super().__init__() - - self.MaaSet_Name = ConfigItem("MaaSet", "Name", "") - self.MaaSet_Path = ConfigItem("MaaSet", "Path", ".", FolderValidator()) - - self.RunSet_TaskTransitionMethod = OptionsConfigItem( - "RunSet", - "TaskTransitionMethod", - "ExitEmulator", - OptionsValidator(["NoAction", "ExitGame", "ExitEmulator"]), - ) - self.RunSet_ProxyTimesLimit = RangeConfigItem( - "RunSet", "ProxyTimesLimit", 0, RangeValidator(0, 1024) - ) - self.RunSet_ADBSearchRange = RangeConfigItem( - "RunSet", "ADBSearchRange", 0, RangeValidator(0, 3) - ) - self.RunSet_RunTimesLimit = RangeConfigItem( - "RunSet", "RunTimesLimit", 3, RangeValidator(1, 1024) - ) - self.RunSet_AnnihilationTimeLimit = RangeConfigItem( - "RunSet", "AnnihilationTimeLimit", 40, RangeValidator(1, 1024) - ) - self.RunSet_RoutineTimeLimit = RangeConfigItem( - "RunSet", "RoutineTimeLimit", 10, RangeValidator(1, 1024) - ) - self.RunSet_AnnihilationWeeklyLimit = ConfigItem( - "RunSet", "AnnihilationWeeklyLimit", True, BoolValidator() - ) - - def get_name(self) -> str: - return self.get(self.MaaSet_Name) - - -class MaaUserConfig(LQConfig): +class MaaUserConfig(ConfigBase): """MAA用户配置""" def __init__(self) -> None: @@ -374,11 +192,11 @@ class MaaUserConfig(LQConfig): self.Info_Name = ConfigItem("Info", "Name", "新用户") self.Info_Id = ConfigItem("Info", "Id", "") - self.Info_Mode = OptionsConfigItem( + self.Info_Mode = ConfigItem( "Info", "Mode", "简洁", OptionsValidator(["简洁", "详细"]) ) - self.Info_StageMode = ConfigItem("Info", "StageMode", "固定") - self.Info_Server = OptionsConfigItem( + self.Info_StageMode = ConfigItem("Info", "StageMode", "Fixed") + self.Info_Server = ConfigItem( "Info", "Server", "Official", @@ -388,9 +206,9 @@ class MaaUserConfig(LQConfig): ) self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator()) self.Info_RemainedDay = ConfigItem( - "Info", "RemainedDay", -1, RangeValidator(-1, 1024) + "Info", "RemainedDay", -1, RangeValidator(-1, 9999) ) - self.Info_Annihilation = OptionsConfigItem( + self.Info_Annihilation = ConfigItem( "Info", "Annihilation", "Annihilation", @@ -405,18 +223,19 @@ class MaaUserConfig(LQConfig): ), ) self.Info_Routine = ConfigItem("Info", "Routine", True, BoolValidator()) - self.Info_InfrastMode = OptionsConfigItem( + self.Info_InfrastMode = ConfigItem( "Info", "InfrastMode", "Normal", OptionsValidator(["Normal", "Rotation", "Custom"]), ) - self.Info_Password = ConfigItem("Info", "Password", "") + self.Info_InfrastPath = ConfigItem("Info", "InfrastPath", ".", FileValidator()) + self.Info_Password = ConfigItem("Info", "Password", "", EncryptValidator()) self.Info_Notes = ConfigItem("Info", "Notes", "无") self.Info_MedicineNumb = ConfigItem( - "Info", "MedicineNumb", 0, RangeValidator(0, 1024) + "Info", "MedicineNumb", 0, RangeValidator(0, 9999) ) - self.Info_SeriesNumb = OptionsConfigItem( + self.Info_SeriesNumb = ConfigItem( "Info", "SeriesNumb", "0", @@ -428,7 +247,9 @@ class MaaUserConfig(LQConfig): 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", "") + self.Info_SklandToken = ConfigItem( + "Info", "SklandToken", "", EncryptValidator() + ) self.Data_LastProxyDate = ConfigItem("Data", "LastProxyDate", "2000-01-01") self.Data_LastAnnihilationDate = ConfigItem( @@ -436,7 +257,7 @@ class MaaUserConfig(LQConfig): ) self.Data_LastSklandDate = ConfigItem("Data", "LastSklandDate", "2000-01-01") self.Data_ProxyTimes = ConfigItem( - "Data", "ProxyTimes", 0, RangeValidator(0, 1024) + "Data", "ProxyTimes", 0, RangeValidator(0, 9999) ) self.Data_IfPassCheck = ConfigItem("Data", "IfPassCheck", True, BoolValidator()) self.Data_CustomInfrastPlanIndex = ConfigItem( @@ -473,8 +294,6 @@ class MaaUserConfig(LQConfig): "Notify", "IfServerChan", False, BoolValidator() ) self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "") - self.Notify_ServerChanChannel = ConfigItem("Notify", "ServerChanChannel", "") - self.Notify_ServerChanTag = ConfigItem("Notify", "ServerChanTag", "") self.Notify_IfCompanyWebHookBot = ConfigItem( "Notify", "IfCompanyWebHookBot", False, BoolValidator() ) @@ -485,37 +304,77 @@ class MaaUserConfig(LQConfig): def get_plan_info(self) -> Dict[str, Union[str, int]]: """获取当前的计划下信息""" - if self.get(self.Info_StageMode) == "固定": + if self.get("Info", "StageMode") == "Fixed": return { - "MedicineNumb": self.get(self.Info_MedicineNumb), - "SeriesNumb": self.get(self.Info_SeriesNumb), - "Stage": self.get(self.Info_Stage), - "Stage_1": self.get(self.Info_Stage_1), - "Stage_2": self.get(self.Info_Stage_2), - "Stage_3": self.get(self.Info_Stage_3), - "Stage_Remain": self.get(self.Info_Stage_Remain), - } - elif "计划" in self.get(self.Info_StageMode): - plan = Config.plan_dict[self.get(self.Info_StageMode)]["Config"] - return { - "MedicineNumb": plan.get(plan.get_current_info("MedicineNumb")), - "SeriesNumb": plan.get(plan.get_current_info("SeriesNumb")), - "Stage": plan.get(plan.get_current_info("Stage")), - "Stage_1": plan.get(plan.get_current_info("Stage_1")), - "Stage_2": plan.get(plan.get_current_info("Stage_2")), - "Stage_3": plan.get(plan.get_current_info("Stage_3")), - "Stage_Remain": plan.get(plan.get_current_info("Stage_Remain")), + "MedicineNumb": self.get("Info", "MedicineNumb"), + "SeriesNumb": self.get("Info", "SeriesNumb"), + "Stage": self.get("Info", "Stage"), + "Stage_1": self.get("Info", "Stage_1"), + "Stage_2": self.get("Info", "Stage_2"), + "Stage_3": self.get("Info", "Stage_3"), + "Stage_Remain": self.get("Info", "Stage_Remain"), } + else: + plan = Config.PlanConfig[uuid.UUID(self.get("Info", "StageMode"))] + if isinstance(plan, MaaPlanConfig): + return { + "MedicineNumb": plan.get_current_info("MedicineNumb").getValue(), + "SeriesNumb": plan.get_current_info("SeriesNumb").getValue(), + "Stage": plan.get_current_info("Stage").getValue(), + "Stage_1": plan.get_current_info("Stage_1").getValue(), + "Stage_2": plan.get_current_info("Stage_2").getValue(), + "Stage_3": plan.get_current_info("Stage_3").getValue(), + "Stage_Remain": plan.get_current_info("Stage_Remain").getValue(), + } + else: + raise ValueError("不存在的计划表配置") -class MaaPlanConfig(LQConfig): +class MaaConfig(ConfigBase): + """MAA配置""" + + def __init__(self) -> None: + super().__init__() + + self.Info_Name = ConfigItem("Info", "Name", "新 MAA 脚本") + self.Info_Path = ConfigItem("Info", "Path", ".", 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", "") - self.Info_Mode = OptionsConfigItem( + self.Info_Name = ConfigItem("Info", "Name", "新 MAA 计划表") + self.Info_Mode = ConfigItem( "Info", "Mode", "ALL", OptionsValidator(["ALL", "Weekly"]) ) @@ -534,9 +393,9 @@ class MaaPlanConfig(LQConfig): self.config_item_dict[group] = {} self.config_item_dict[group]["MedicineNumb"] = ConfigItem( - group, "MedicineNumb", 0, RangeValidator(0, 1024) + group, "MedicineNumb", 0, RangeValidator(0, 9999) ) - self.config_item_dict[group]["SeriesNumb"] = OptionsConfigItem( + self.config_item_dict[group]["SeriesNumb"] = ConfigItem( group, "SeriesNumb", "0", @@ -564,11 +423,11 @@ class MaaPlanConfig(LQConfig): def get_current_info(self, name: str) -> ConfigItem: """获取当前的计划表配置项""" - if self.get(self.Info_Mode) == "ALL": + if self.get("Info", "Mode") == "ALL": return self.config_item_dict["ALL"][name] - elif self.get(self.Info_Mode) == "Weekly": + elif self.get("Info", "Mode") == "Weekly": dt = datetime.now() if dt.time() < datetime.min.time().replace(hour=4): @@ -580,86 +439,20 @@ class MaaPlanConfig(LQConfig): else: return self.config_item_dict["ALL"][name] + else: + raise ValueError("非法的计划表模式") -class GeneralConfig(LQConfig): - """通用配置""" + +class GeneralUserConfig(ConfigBase): + """通用脚本用户配置""" def __init__(self) -> None: super().__init__() - self.Script_Name = ConfigItem("Script", "Name", "") - self.Script_RootPath = ConfigItem("Script", "RootPath", ".", FileValidator()) - self.Script_ScriptPath = ConfigItem( - "Script", "ScriptPath", ".", FileValidator() - ) - self.Script_Arguments = ConfigItem("Script", "Arguments", "") - self.Script_IfTrackProcess = ConfigItem( - "Script", "IfTrackProcess", False, BoolValidator() - ) - self.Script_ConfigPath = ConfigItem( - "Script", "ConfigPath", ".", FileValidator() - ) - self.Script_ConfigPathMode = OptionsConfigItem( - "Script", - "ConfigPathMode", - "所有文件 (*)", - OptionsValidator(["所有文件 (*)", "文件夹"]), - ) - self.Script_UpdateConfigMode = OptionsConfigItem( - "Script", - "UpdateConfigMode", - "Never", - OptionsValidator(["Never", "Success", "Failure", "Always"]), - ) - self.Script_LogPath = ConfigItem("Script", "LogPath", ".", FileValidator()) - self.Script_LogPathFormat = ConfigItem("Script", "LogPathFormat", "%Y-%m-%d") - self.Script_LogTimeStart = ConfigItem( - "Script", "LogTimeStart", 1, RangeValidator(1, 1024) - ) - self.Script_LogTimeEnd = ConfigItem( - "Script", "LogTimeEnd", 1, RangeValidator(1, 1024) - ) - 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_Style = OptionsConfigItem( - "Game", "Style", "Emulator", OptionsValidator(["Emulator", "Client"]) - ) - self.Game_Path = ConfigItem("Game", "Path", ".", FileValidator()) - self.Game_Arguments = ConfigItem("Game", "Arguments", "") - self.Game_WaitTime = ConfigItem("Game", "WaitTime", 0, RangeValidator(0, 1024)) - self.Game_IfForceClose = ConfigItem( - "Game", "IfForceClose", False, BoolValidator() - ) - - self.Run_ProxyTimesLimit = RangeConfigItem( - "Run", "ProxyTimesLimit", 0, RangeValidator(0, 1024) - ) - self.Run_RunTimesLimit = RangeConfigItem( - "Run", "RunTimesLimit", 3, RangeValidator(1, 1024) - ) - self.Run_RunTimeLimit = RangeConfigItem( - "Run", "RunTimeLimit", 10, RangeValidator(1, 1024) - ) - - def get_name(self) -> str: - return self.get(self.Script_Name) - - -class GeneralSubConfig(LQConfig): - """通用子配置""" - - def __init__(self) -> None: - super().__init__() - - self.Info_Name = ConfigItem("Info", "Name", "新配置") + self.Info_Name = ConfigItem("Info", "Name", "新用户") self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator()) self.Info_RemainedDay = ConfigItem( - "Info", "RemainedDay", -1, RangeValidator(-1, 1024) + "Info", "RemainedDay", -1, RangeValidator(-1, 9999) ) self.Info_IfScriptBeforeTask = ConfigItem( "Info", "IfScriptBeforeTask", False, BoolValidator() @@ -677,7 +470,7 @@ class GeneralSubConfig(LQConfig): self.Data_LastProxyDate = ConfigItem("Data", "LastProxyDate", "2000-01-01") self.Data_ProxyTimes = ConfigItem( - "Data", "ProxyTimes", 0, RangeValidator(0, 1024) + "Data", "ProxyTimes", 0, RangeValidator(0, 9999) ) self.Notify_Enabled = ConfigItem("Notify", "Enabled", False, BoolValidator()) @@ -692,8 +485,6 @@ class GeneralSubConfig(LQConfig): "Notify", "IfServerChan", False, BoolValidator() ) self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "") - self.Notify_ServerChanChannel = ConfigItem("Notify", "ServerChanChannel", "") - self.Notify_ServerChanTag = ConfigItem("Notify", "ServerChanTag", "") self.Notify_IfCompanyWebHookBot = ConfigItem( "Notify", "IfCompanyWebHookBot", False, BoolValidator() ) @@ -702,121 +493,129 @@ class GeneralSubConfig(LQConfig): ) -class AppConfig(GlobalConfig): - - VERSION = "4.4.1.0" - - stage_refreshed = Signal() - PASSWORD_refreshed = Signal() - sub_info_changed = Signal() - power_sign_changed = Signal() +class GeneralConfig(ConfigBase): + """通用配置""" def __init__(self) -> None: super().__init__() - self.app_path = Path(sys.argv[0]).resolve().parent - self.app_path_sys = Path(sys.argv[0]).resolve() + self.Info_Name = ConfigItem("Info", "Name", "新通用脚本") + self.Info_RootPath = ConfigItem("Info", "RootPath", ".", FileValidator()) - self.log_path = self.app_path / "debug/AUTO_MAA.log" - self.database_path = self.app_path / "data/data.db" - self.config_path = self.app_path / "config/config.json" - self.key_path = self.app_path / "data/key" - - self.main_window = None - self.PASSWORD = "" - self.running_list = [] - self.silence_dict: Dict[Path, datetime] = {} - self.info_bar_list = [] - self.stage_dict = { - "ALL": {"value": [], "text": []}, - "Monday": {"value": [], "text": []}, - "Tuesday": {"value": [], "text": []}, - "Wednesday": {"value": [], "text": []}, - "Thursday": {"value": [], "text": []}, - "Friday": {"value": [], "text": []}, - "Saturday": {"value": [], "text": []}, - "Sunday": {"value": [], "text": []}, - } - self.power_sign = "NoAction" - self.if_ignore_silence = False - self.if_database_opened = False - - self.initialize() - - self.search_script() - self.search_queue() - - parser = argparse.ArgumentParser( - prog="AUTO_MAA", - description="A MAA Multi Account Management and Automation Tool", + self.Script_ScriptPath = ConfigItem( + "Script", "ScriptPath", ".", FileValidator() ) - parser.add_argument( - "--mode", - choices=["gui", "cli"], - default="gui", - help="使用UI界面或命令行模式运行程序", + self.Script_Arguments = ConfigItem("Script", "Arguments", "") + self.Script_IfTrackProcess = ConfigItem( + "Script", "IfTrackProcess", False, BoolValidator() ) - parser.add_argument( - "--config", - nargs="+", - choices=list(self.script_dict.keys()) + list(self.queue_dict.keys()), - help="指定需要运行哪些配置项", + self.Script_ConfigPath = ConfigItem( + "Script", "ConfigPath", ".", FileValidator() ) - self.args = parser.parse_args() + 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", ".", 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", "") - logger.info( - f"运行模式: {'图形化界面' if self.args.mode == 'gui' else '命令行界面'},配置项: {self.args.config if self.args.config else '启动时运行的调度队列'}", - module="配置管理", + self.Game_Enabled = ConfigItem("Game", "Enabled", False, BoolValidator()) + self.Game_Type = ConfigItem( + "Game", "Type", "Emulator", OptionsValidator(["Emulator", "Client"]) + ) + self.Game_Path = ConfigItem("Game", "Path", ".", 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() ) - def initialize(self) -> None: - """初始化程序配置管理模块""" + 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} +TYPE_BOOK = {"MaaConfig": "MAA", "GeneralConfig": "通用"} + + +class AppConfig(GlobalConfig): + + VERSION = "5.0.0.1" + + def __init__(self) -> None: + super().__init__(if_save_multi_config=False) + + logger.info("") + logger.info("===================================") + logger.info("AUTO_MAA 后端应用程序") + logger.info(f"版本号: v{self.VERSION}") + logger.info(f"工作目录: {Path.cwd()}") + logger.info("===================================") + + self.log_path = Path.cwd() / "debug/app.log" + self.database_path = Path.cwd() / "data/data.db" + self.config_path = Path.cwd() / "config" + self.history_path = Path.cwd() / "history" # 检查目录 - (self.app_path / "config").mkdir(parents=True, exist_ok=True) - (self.app_path / "data").mkdir(parents=True, exist_ok=True) - (self.app_path / "debug").mkdir(parents=True, exist_ok=True) - (self.app_path / "history").mkdir(parents=True, exist_ok=True) + self.log_path.parent.mkdir(parents=True, exist_ok=True) + self.database_path.parent.mkdir(parents=True, exist_ok=True) + self.config_path.mkdir(parents=True, exist_ok=True) + self.history_path.mkdir(parents=True, exist_ok=True) - self.load(self.config_path, self) - self.save() + self.server: Optional[uvicorn.Server] = None + self.websocket: Optional[WebSocket] = None + self.silence_dict: Dict[Path, datetime] = {} + self.if_ignore_silence: List[uuid.UUID] = [] + self.temp_task: List[asyncio.Task] = [] - self.init_logger() - self.check_data() - logger.info("程序初始化完成", module="配置管理") + self.ScriptConfig = MultipleConfig([MaaConfig, GeneralConfig]) + self.PlanConfig = MultipleConfig([MaaPlanConfig]) + self.QueueConfig = MultipleConfig([QueueConfig]) - def init_logger(self) -> None: - """初始化日志记录器""" + truststore.inject_into_ssl() - logger.add( - sink=self.log_path, - level="DEBUG", - format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[module]} | {message}", - enqueue=True, - backtrace=True, - diagnose=True, - rotation="1 week", - retention="1 month", - compression="zip", - ) - logger.add( - sink=sys.stderr, - level="DEBUG", - format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[module]} | {message}", - enqueue=True, - backtrace=True, - diagnose=True, - colorize=True, - ) + async def init_config(self) -> None: + """初始化配置管理""" - logger.info("", module="配置管理") - logger.info("===================================", module="配置管理") - logger.info("AUTO_MAA 主程序", module="配置管理") - logger.info(f"版本号: v{self.VERSION}", module="配置管理") - logger.info(f"根目录: {self.app_path}", module="配置管理") - logger.info("===================================", module="配置管理") + await self.check_data() - def check_data(self) -> None: + await self.connect(self.config_path / "Config.json") + await self.ScriptConfig.connect(self.config_path / "ScriptConfig.json") + await self.PlanConfig.connect(self.config_path / "PlanConfig.json") + await self.QueueConfig.connect(self.config_path / "QueueConfig.json") + + from .task_manager import TaskManager + + self.task_dict = TaskManager.task_dict + + logger.info("程序初始化完成") + + async def check_data(self) -> None: """检查用户数据文件并处理数据文件版本更新""" # 生成主数据库 @@ -824,7 +623,7 @@ class AppConfig(GlobalConfig): db = sqlite3.connect(self.database_path) cur = db.cursor() cur.execute("CREATE TABLE version(v text)") - cur.execute("INSERT INTO version VALUES(?)", ("v1.8",)) + cur.execute("INSERT INTO version VALUES(?)", ("v1.9",)) db.commit() cur.close() db.close() @@ -835,244 +634,20 @@ class AppConfig(GlobalConfig): cur.execute("SELECT * FROM version WHERE True") version = cur.fetchall() - if version[0][0] != "v1.8": - logger.info("数据文件版本更新开始", module="配置管理") + if version[0][0] != "v1.9": + logger.info( + "数据文件版本更新开始", + ) if_streaming = False - # v1.4-->v1.5 - if version[0][0] == "v1.4" or if_streaming: - logger.info("数据文件版本更新:v1.4-->v1.5", module="配置管理") - if_streaming = True - - member_dict: Dict[str, Dict[str, Union[str, Path]]] = {} - if (self.app_path / "config/MaaConfig").exists(): - for maa_dir in (self.app_path / "config/MaaConfig").iterdir(): - if maa_dir.is_dir(): - member_dict[maa_dir.name] = { - "Type": "Maa", - "Path": maa_dir, - } - - member_dict = dict( - sorted(member_dict.items(), key=lambda x: int(x[0][3:])) - ) - - for name, config in member_dict.items(): - if config["Type"] == "Maa": - - _db = sqlite3.connect(config["Path"] / "user_data.db") - _cur = _db.cursor() - _cur.execute("SELECT * FROM adminx WHERE True") - data = _cur.fetchall() - data = [list(row) for row in data] - data = sorted(data, key=lambda x: (-len(x[15]), x[16])) - _cur.close() - _db.close() - - (config["Path"] / "user_data.db").unlink() - - (config["Path"] / f"UserData").mkdir( - parents=True, exist_ok=True - ) - - for i in range(len(data)): - - info = { - "Data": { - "IfPassCheck": True, - "LastAnnihilationDate": "2000-01-01", - "LastProxyDate": data[i][5], - "ProxyTimes": data[i][14], - }, - "Info": { - "Annihilation": bool(data[i][10] == "y"), - "GameId": data[i][6], - "GameIdMode": "固定", - "GameId_1": data[i][7], - "GameId_2": data[i][8], - "Id": data[i][1], - "Infrastructure": bool(data[i][11] == "y"), - "MedicineNumb": 0, - "Mode": ( - "简洁" if data[i][15] == "simple" else "详细" - ), - "Name": data[i][0], - "Notes": data[i][13], - "Password": base64.b64encode(data[i][12]).decode( - "utf-8" - ), - "RemainedDay": data[i][3], - "Routine": bool(data[i][9] == "y"), - "Server": data[i][2], - "Status": bool(data[i][4] == "y"), - }, - } - - (config["Path"] / f"UserData/用户_{i + 1}").mkdir( - parents=True, exist_ok=True - ) - with ( - config["Path"] / f"UserData/用户_{i + 1}/config.json" - ).open(mode="w", encoding="utf-8") as f: - json.dump(info, f, ensure_ascii=False, indent=4) - - if ( - self.app_path - / f"config/MaaConfig/{name}/{data[i][15]}/{data[i][16]}/annihilation/gui.json" - ).exists(): - ( - config["Path"] - / f"UserData/用户_{i + 1}/Annihilation" - ).mkdir(parents=True, exist_ok=True) - shutil.move( - self.app_path - / f"config/MaaConfig/{name}/{data[i][15]}/{data[i][16]}/annihilation/gui.json", - config["Path"] - / f"UserData/用户_{i + 1}/Annihilation/gui.json", - ) - if ( - self.app_path - / f"config/MaaConfig/{name}/{data[i][15]}/{data[i][16]}/routine/gui.json" - ).exists(): - ( - config["Path"] / f"UserData/用户_{i + 1}/Routine" - ).mkdir(parents=True, exist_ok=True) - shutil.move( - self.app_path - / f"config/MaaConfig/{name}/{data[i][15]}/{data[i][16]}/routine/gui.json", - config["Path"] - / f"UserData/用户_{i + 1}/Routine/gui.json", - ) - if ( - self.app_path - / f"config/MaaConfig/{name}/{data[i][15]}/{data[i][16]}/infrastructure/infrastructure.json" - ).exists(): - ( - config["Path"] - / f"UserData/用户_{i + 1}/Infrastructure" - ).mkdir(parents=True, exist_ok=True) - shutil.move( - self.app_path - / f"config/MaaConfig/{name}/{data[i][15]}/{data[i][16]}/infrastructure/infrastructure.json", - config["Path"] - / f"UserData/用户_{i + 1}/Infrastructure/infrastructure.json", - ) - - if (config["Path"] / f"simple").exists(): - shutil.rmtree(config["Path"] / f"simple") - if (config["Path"] / f"beta").exists(): - shutil.rmtree(config["Path"] / f"beta") - - cur.execute("DELETE FROM version WHERE v = ?", ("v1.4",)) - cur.execute("INSERT INTO version VALUES(?)", ("v1.5",)) - db.commit() - # v1.5-->v1.6 - if version[0][0] == "v1.5" or if_streaming: - logger.info("数据文件版本更新:v1.5-->v1.6", module="配置管理") - if_streaming = True - cur.execute("DELETE FROM version WHERE v = ?", ("v1.5",)) - cur.execute("INSERT INTO version VALUES(?)", ("v1.6",)) - db.commit() - # 删除旧的注册表项 - import winreg - - key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Run", - 0, - winreg.KEY_READ, - ) - - try: - value, _ = winreg.QueryValueEx(key, "AUTO_MAA") - winreg.CloseKey(key) - key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Run", - winreg.KEY_SET_VALUE, - winreg.KEY_ALL_ACCESS - | winreg.KEY_WRITE - | winreg.KEY_CREATE_SUB_KEY, - ) - winreg.DeleteValue(key, "AUTO_MAA") - winreg.CloseKey(key) - except FileNotFoundError: - pass - # v1.6-->v1.7 - if version[0][0] == "v1.6" or if_streaming: - logger.info("数据文件版本更新:v1.6-->v1.7", module="配置管理") - if_streaming = True - - if (self.app_path / "config/MaaConfig").exists(): - - for MaaConfig in (self.app_path / "config/MaaConfig").iterdir(): - if MaaConfig.is_dir(): - for user in (MaaConfig / "UserData").iterdir(): - if user.is_dir(): - if (user / "config.json").exists(): - with (user / "config.json").open( - encoding="utf-8" - ) as f: - user_config = json.load(f) - user_config["Info"]["Stage"] = user_config[ - "Info" - ]["GameId"] - user_config["Info"]["StageMode"] = user_config[ - "Info" - ]["GameIdMode"] - user_config["Info"]["Stage_1"] = user_config[ - "Info" - ]["GameId_1"] - user_config["Info"]["Stage_2"] = user_config[ - "Info" - ]["GameId_2"] - user_config["Info"]["Stage_Remain"] = ( - user_config["Info"]["GameId_Remain"] - ) - with (user / "config.json").open( - "w", encoding="utf-8" - ) as f: - json.dump( - user_config, - f, - ensure_ascii=False, - indent=4, - ) - - if (self.app_path / "config/MaaPlanConfig").exists(): - for MaaPlanConfig in ( - self.app_path / "config/MaaPlanConfig" - ).iterdir(): - if ( - MaaPlanConfig.is_dir() - and (MaaPlanConfig / "config.json").exists() - ): - with (MaaPlanConfig / "config.json").open( - encoding="utf-8" - ) as f: - plan_config = json.load(f) - - for k in self.stage_dict.keys(): - plan_config[k]["Stage"] = plan_config[k]["GameId"] - plan_config[k]["Stage_1"] = plan_config[k]["GameId_1"] - plan_config[k]["Stage_2"] = plan_config[k]["GameId_2"] - plan_config[k]["Stage_Remain"] = plan_config[k][ - "GameId_Remain" - ] - with (MaaPlanConfig / "config.json").open( - "w", encoding="utf-8" - ) as f: - json.dump(plan_config, f, ensure_ascii=False, indent=4) - - cur.execute("DELETE FROM version WHERE v = ?", ("v1.6",)) - cur.execute("INSERT INTO version VALUES(?)", ("v1.7",)) - db.commit() # v1.7-->v1.8 if version[0][0] == "v1.7" or if_streaming: - logger.info("数据文件版本更新:v1.7-->v1.8", module="配置管理") + logger.info( + "数据文件版本更新: v1.7-->v1.8", + ) if_streaming = True - if (self.app_path / "config/QueueConfig").exists(): - for QueueConfig in (self.app_path / "config/QueueConfig").glob( + if (Path.cwd() / "config/QueueConfig").exists(): + for QueueConfig in (Path.cwd() / "config/QueueConfig").glob( "*.json" ): with QueueConfig.open(encoding="utf-8") as f: @@ -1099,91 +674,819 @@ class AppConfig(GlobalConfig): cur.execute("DELETE FROM version WHERE v = ?", ("v1.7",)) cur.execute("INSERT INTO version VALUES(?)", ("v1.8",)) db.commit() + # v1.8-->v1.9 + if version[0][0] == "v1.8" or if_streaming: + logger.info( + "数据文件版本更新: v1.8-->v1.9", + ) + if_streaming = True + + await self.ScriptConfig.connect(self.config_path / "ScriptConfig.json") + await self.PlanConfig.connect(self.config_path / "PlanConfig.json") + await self.QueueConfig.connect(self.config_path / "QueueConfig.json") + + if (Path.cwd() / "config/config.json").exists(): + (Path.cwd() / "config/config.json").rename( + Path.cwd() / "config/Config.json" + ) + await self.connect(self.config_path / "Config.json") + + plan_dict = {"固定": "Fixed"} + + if (Path.cwd() / "config/MaaPlanConfig").exists(): + for MaaPlanConfig in ( + Path.cwd() / "config/MaaPlanConfig" + ).iterdir(): + if ( + MaaPlanConfig.is_dir() + and (MaaPlanConfig / "config.json").exists() + ): + + maa_plan_config = json.loads( + (MaaPlanConfig / "config.json").read_text( + encoding="utf-8" + ) + ) + uid, pc = await self.add_plan("MaaPlan") + plan_dict[MaaPlanConfig.name] = str(uid) + + await pc.load(maa_plan_config) + + await self.PlanConfig.save() + + script_dict: Dict[str, Optional[str]] = {"禁用": None} + + if (Path.cwd() / "config/MaaConfig").exists(): + + for MaaConfig in (Path.cwd() / "config/MaaConfig").iterdir(): + if MaaConfig.is_dir(): + + maa_config = json.loads( + (MaaConfig / "config.json").read_text(encoding="utf-8") + ) + maa_config["Info"] = maa_config["MaaSet"] + maa_config["Run"] = maa_config["RunSet"] + + uid, sc = await self.add_script("MAA") + script_dict[MaaConfig.name] = str(uid) + await sc.load(maa_config) + + if (MaaConfig / "Default/gui.json").exists(): + (Path.cwd() / f"data/{uid}/Default/ConfigFile").mkdir( + parents=True, exist_ok=True + ) + shutil.copy( + MaaConfig / "Default/gui.json", + Path.cwd() + / f"data/{uid}/Default/ConfigFile/gui.json", + ) + + for user in (MaaConfig / "UserData").iterdir(): + if user.is_dir() and (user / "config.json").exists(): + user_config = json.loads( + (user / "config.json").read_text( + encoding="utf-8" + ) + ) + + user_config["Info"]["StageMode"] = plan_dict.get( + user_config["Info"]["StageMode"], "Fixed" + ) + user_config["Info"]["Password"] = "" + + user_uid, uc = await self.add_user(str(uid)) + await uc.load(user_config) + + if (user / "Routine/gui.json").exists(): + ( + Path.cwd() + / f"data/{uid}/{user_uid}/ConfigFile" + ).mkdir(parents=True, exist_ok=True) + shutil.copy( + user / "Routine/gui.json", + Path.cwd() + / f"data/{uid}/{user_uid}/ConfigFile/gui.json", + ) + if ( + user / "Infrastructure/infrastructure.json" + ).exists(): + ( + Path.cwd() + / f"data/{uid}/{user_uid}/Infrastructure" + ).mkdir(parents=True, exist_ok=True) + shutil.copy( + user / "Infrastructure/infrastructure.json", + Path.cwd() + / f"data/{uid}/{user_uid}/Infrastructure/infrastructure.json", + ) + + if (Path.cwd() / "config/GeneralConfig").exists(): + + for GeneralConfig in ( + Path.cwd() / "config/GeneralConfig" + ).iterdir(): + if GeneralConfig.is_dir(): + + general_config = json.loads( + (GeneralConfig / "config.json").read_text( + encoding="utf-8" + ) + ) + general_config["Info"] = { + "Name": general_config["Script"]["Name"], + "RootPath": general_config["Script"]["RootPath"], + } + + uid, sc = await self.add_script("General") + script_dict[GeneralConfig.name] = str(uid) + await sc.load(general_config) + + for user in (GeneralConfig / "SubData").iterdir(): + if user.is_dir() and (user / "config.json").exists(): + user_config = json.loads( + (user / "config.json").read_text( + encoding="utf-8" + ) + ) + + user_uid, uc = await self.add_user(str(uid)) + await uc.load(user_config) + + if (user / "ConfigFiles").exists(): + (Path.cwd() / f"data/{uid}/{user_uid}").mkdir( + parents=True, exist_ok=True + ) + shutil.move( + user / "ConfigFiles", + Path.cwd() + / f"data/{uid}/{user_uid}/ConfigFile", + ) + + await self.ScriptConfig.save() + + if (Path.cwd() / "config/QueueConfig").exists(): + for QueueConfig in (Path.cwd() / "config/QueueConfig").glob( + "*.json" + ): + queue_config = json.loads( + QueueConfig.read_text(encoding="utf-8") + ) + + uid, qc = await self.add_queue() + + queue_config["Info"] = queue_config["QueueSet"] + await qc.load(queue_config) + + for i in range(10): + item_uid, item = await self.add_queue_item(str(uid)) + time_uid, time = await self.add_time_set(str(uid)) + + await time.load( + { + "Info": { + "Enabled": queue_config["Time"][f"Enabled_{i}"], + "Time": queue_config["Time"][f"Set_{i}"], + } + } + ) + await item.load( + { + "Info": { + "ScriptId": script_dict.get( + queue_config["Queue"][f"Script_{i}"], None + ) + } + } + ) + await self.QueueConfig.save() + + if (Path.cwd() / "config/QueueConfig").exists(): + shutil.rmtree(Path.cwd() / "config/QueueConfig") + if (Path.cwd() / "config/MaaPlanConfig").exists(): + shutil.rmtree(Path.cwd() / "config/MaaPlanConfig") + if (Path.cwd() / "config/MaaConfig").exists(): + shutil.rmtree(Path.cwd() / "config/MaaConfig") + if (Path.cwd() / "config/GeneralConfig").exists(): + shutil.rmtree(Path.cwd() / "config/GeneralConfig") + if (Path.cwd() / "data/gameid.txt").exists(): + (Path.cwd() / "data/gameid.txt").unlink() + if (Path.cwd() / "data/key").exists(): + shutil.rmtree(Path.cwd() / "data/key") + + cur.execute("DELETE FROM version WHERE v = ?", ("v1.8",)) + cur.execute("INSERT INTO version VALUES(?)", ("v1.9",)) + db.commit() cur.close() db.close() - logger.success("数据文件版本更新完成", module="配置管理") + logger.success("数据文件版本更新完成") - def get_stage(self) -> None: - """从MAA服务器更新活动关卡信息""" - - logger.info("开始获取活动关卡信息", module="配置管理") - network = Network.add_task( - mode="get", - url="https://api.maa.plus/MaaAssistantArknights/api/gui/StageActivity.json", - ) - network.loop.exec() - network_result = Network.get_result(network) - if network_result["status_code"] == 200: - stage_infos: List[Dict[str, Union[str, Dict[str, Union[str, int]]]]] = ( - network_result["response_json"]["Official"]["sideStoryStage"] - ) + async def send_json(self, data: dict) -> None: + """通过WebSocket发送JSON数据""" + if Config.websocket is None: + raise RuntimeError("WebSocket 未连接") else: - logger.warning( - f"无法从MAA服务器获取活动关卡信息:{network_result['error_message']}", - module="配置管理", - ) - stage_infos = [] + await Config.websocket.send_json(data) - ss_stage_dict = {"value": [], "text": []} + async def add_script( + self, script: Literal["MAA", "General"] + ) -> tuple[uuid.UUID, ConfigBase]: + """添加脚本配置""" - for stage_info in stage_infos: + logger.info(f"添加脚本配置: {script}") - if ( - datetime.strptime( - stage_info["Activity"]["UtcStartTime"], "%Y/%m/%d %H:%M:%S" - ) - < datetime.now() - < datetime.strptime( - stage_info["Activity"]["UtcExpireTime"], "%Y/%m/%d %H:%M:%S" - ) + return await self.ScriptConfig.add(CLASS_BOOK[script]) + + async def get_script(self, script_id: Optional[str]) -> tuple[list, dict]: + """获取脚本配置""" + + logger.info(f"获取脚本配置: {script_id}") + + if script_id is None: + data = await self.ScriptConfig.toDict() + else: + data = await self.ScriptConfig.get(uuid.UUID(script_id)) + + index = data.pop("instances", []) + + return list(index), data + + async def update_script( + self, script_id: str, data: Dict[str, Dict[str, Any]] + ) -> None: + """更新脚本配置""" + + logger.info(f"更新脚本配置: {script_id}") + + uid = uuid.UUID(script_id) + + if uid in self.task_dict: + raise RuntimeError(f"脚本 {script_id} 正在运行, 无法更新配置项") + + for group, items in data.items(): + for name, value in items.items(): + logger.debug(f"更新脚本配置: {script_id} - {group}.{name} = {value}") + await self.ScriptConfig[uid].set(group, name, value) + + await self.ScriptConfig.save() + + async def del_script(self, script_id: str) -> None: + """删除脚本配置""" + + logger.info(f"删除脚本配置: {script_id}") + + uid = uuid.UUID(script_id) + + if uid in self.task_dict: + raise RuntimeError(f"脚本 {script_id} 正在运行, 无法删除") + + await self.ScriptConfig.remove(uid) + + async def reorder_script(self, index_list: list[str]) -> None: + """重新排序脚本""" + + logger.info(f"重新排序脚本: {index_list}") + + await self.ScriptConfig.setOrder([uuid.UUID(_) for _ in index_list]) + + async def import_script_from_file(self, script_id: str, jsonFile: str) -> None: + """从文件加载脚本配置""" + + logger.info(f"从文件加载脚本配置: {script_id} - {jsonFile}") + uid = uuid.UUID(script_id) + file_path = Path(jsonFile) + + if uid not in self.ScriptConfig: + logger.error(f"{script_id} 不存在") + raise KeyError(f"脚本 {script_id} 不存在") + if not isinstance(self.ScriptConfig[uid], GeneralConfig): + logger.error(f"{script_id} 不是通用脚本配置") + raise TypeError(f"脚本 {script_id} 不是通用脚本配置") + if not Path(file_path).exists(): + logger.error(f"文件不存在: {file_path}") + raise FileNotFoundError(f"文件不存在: {file_path}") + + data = json.loads(file_path.read_text(encoding="utf-8")) + await self.ScriptConfig[uid].load(data) + await self.ScriptConfig.save() + + logger.success(f"{script_id} 配置加载成功") + + async def export_script_to_file(self, script_id: str, jsonFile: str): + """导出脚本配置到文件""" + + logger.info(f"导出配置到文件: {script_id} - {jsonFile}") + + uid = uuid.UUID(script_id) + file_path = Path(jsonFile) + + if uid not in self.ScriptConfig: + logger.error(f"{script_id} 不存在") + raise KeyError(f"脚本 {script_id} 不存在") + if not isinstance(self.ScriptConfig[uid], GeneralConfig): + logger.error(f"{script_id} 不是通用脚本配置") + raise TypeError(f"脚本 {script_id} 不是通用脚本配置") + + temp = await self.ScriptConfig[uid].toDict( + ignore_multi_config=True, if_decrypt=False + ) + + # 移除配置中可能存在的隐私信息 + temp["Info"]["Name"] = Path(file_path).stem + for path in ["ScriptPath", "ConfigPath", "LogPath"]: + + if Path(temp["Script"][path]).is_relative_to( + Path(temp["Info"]["RootPath"]) ): - ss_stage_dict["value"].append(stage_info["Value"]) - ss_stage_dict["text"].append(stage_info["Value"]) - # 生成每日关卡信息 - stage_daily_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": "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": "LS-6", "text": "经验-6/5", "days": [1, 2, 3, 4, 5, 6, 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]}, - ] + temp["Script"][path] = str( + Path(r"C:/脚本根目录") + / Path(temp["Script"][path]).relative_to( + Path(temp["Info"]["RootPath"]) + ) + ) + temp["Info"]["RootPath"] = str(Path(r"C:/脚本根目录")) - for day in range(0, 8): + file_path.write_text( + json.dumps(temp, ensure_ascii=False, indent=4), encoding="utf-8" + ) - today_stage_dict = {"value": [], "text": []} + logger.success(f"{script_id} 配置导出成功") - for stage_info in stage_daily_info: + async def import_script_from_web(self, script_id: str, url: str): + """从「AUTO_MAA 配置分享中心」导入配置""" - if day in stage_info["days"] or day == 0: - today_stage_dict["value"].append(stage_info["value"]) - today_stage_dict["text"].append(stage_info["text"]) + logger.info(f"从网络加载脚本配置: {script_id} - {url}") + uid = uuid.UUID(script_id) - self.stage_dict[calendar.day_name[day - 1] if day > 0 else "ALL"] = { - "value": today_stage_dict["value"] + ss_stage_dict["value"], - "text": today_stage_dict["text"] + ss_stage_dict["text"], - } + if uid not in self.ScriptConfig: + logger.error(f"{script_id} 不存在") + raise KeyError(f"脚本 {script_id} 不存在") + if not isinstance(self.ScriptConfig[uid], GeneralConfig): + logger.error(f"{script_id} 不是通用脚本配置") + raise TypeError(f"脚本 {script_id} 不是通用脚本配置") - self.stage_refreshed.emit() + response = requests.get(url, timeout=10, proxies=self.get_proxies()) + if response.status_code == 200: + data = response.json() + else: + logger.warning(f"无法从 AUTO_MAA 服务器获取配置内容: {response.text}") + raise ConnectionError( + f"无法从 AUTO_MAA 服务器获取配置内容: {response.status_code}" + ) - logger.success("活动关卡信息更新完成", module="配置管理") + await self.ScriptConfig[uid].load(data) + await self.ScriptConfig.save() + + logger.success(f"{script_id} 配置加载成功") + + async def upload_script_to_web( + self, script_id: str, config_name: str, author: str, description: str + ): + """上传配置到「AUTO_MAA 配置分享中心」""" + + logger.info(f"上传配置到网络: {script_id} - {config_name} - {author}") + + uid = uuid.UUID(script_id) + + if uid not in self.ScriptConfig: + logger.error(f"{script_id} 不存在") + raise KeyError(f"脚本 {script_id} 不存在") + if not isinstance(self.ScriptConfig[uid], GeneralConfig): + logger.error(f"{script_id} 不是通用脚本配置") + raise TypeError(f"脚本 {script_id} 不是通用脚本配置") + + temp = await self.ScriptConfig[uid].toDict( + ignore_multi_config=True, if_decrypt=False + ) + + # 移除配置中可能存在的隐私信息 + temp["Info"]["Name"] = config_name + for path in ["ScriptPath", "ConfigPath", "LogPath"]: + if Path(temp["Script"][path]).is_relative_to( + Path(temp["Info"]["RootPath"]) + ): + temp["Script"][path] = str( + Path(r"C:/脚本根目录") + / Path(temp["Script"][path]).relative_to( + Path(temp["Info"]["RootPath"]) + ) + ) + temp["Info"]["RootPath"] = str(Path(r"C:/脚本根目录")) + + files = { + "file": ( + f"{config_name}&&{author}&&{description}&&{int(datetime.now().timestamp() * 1000)}.json", + json.dumps(temp, ensure_ascii=False), + "application/json", + ) + } + data = {"username": author, "description": description} + + response = requests.post( + "http://221.236.27.82:10023/api/upload/share", + files=files, + data=data, + timeout=10, + proxies=self.get_proxies(), + ) + + if response.status_code == 200: + logger.success("配置上传成功") + else: + logger.error(f"无法上传配置到 AUTO_MAA 服务器: {response.text}") + raise ConnectionError( + f"无法上传配置到 AUTO_MAA 服务器: {response.status_code} - {response.text}" + ) + + async def get_user( + self, script_id: str, user_id: Optional[str] + ) -> tuple[list, dict]: + """获取用户配置""" + + logger.info(f"获取用户配置: {script_id} - {user_id}") + + uid = uuid.UUID(script_id) + sc = self.ScriptConfig[uid] + + if isinstance(sc, (MaaConfig | GeneralConfig)): + if user_id is None: + data = await sc.UserData.toDict() + else: + data = await sc.UserData.get(uuid.UUID(user_id)) + else: + logger.error(f"不支持的脚本配置类型: {type(sc)}") + raise TypeError(f"不支持的脚本配置类型: {type(sc)}") + + index = data.pop("instances", []) + + return list(index), data + + async def add_user(self, script_id: str) -> tuple[uuid.UUID, ConfigBase]: + """添加用户配置""" + + logger.info(f"{script_id} 添加用户配置") + + script_config = self.ScriptConfig[uuid.UUID(script_id)] + + if isinstance(script_config, MaaConfig): + uid, config = await script_config.UserData.add(MaaUserConfig) + elif isinstance(script_config, GeneralConfig): + uid, config = await script_config.UserData.add(GeneralUserConfig) + else: + raise TypeError(f"不支持的脚本配置类型: {type(script_config)}") + + await self.ScriptConfig.save() + return uid, config + + async def update_user( + self, script_id: str, user_id: str, data: Dict[str, Dict[str, Any]] + ) -> None: + """更新用户配置""" + + logger.info(f"{script_id} 更新用户配置: {user_id}") + + script_config = self.ScriptConfig[uuid.UUID(script_id)] + uid = uuid.UUID(user_id) + + for group, items in data.items(): + for name, value in items.items(): + logger.debug(f"更新脚本配置: {script_id} - {group}.{name} = {value}") + if isinstance(script_config, (MaaConfig | GeneralConfig)): + await script_config.UserData[uid].set(group, name, value) + + await self.ScriptConfig.save() + + async def del_user(self, script_id: str, user_id: str) -> None: + """删除用户配置""" + + logger.info(f"{script_id} 删除用户配置: {user_id}") + + script_config = self.ScriptConfig[uuid.UUID(script_id)] + uid = uuid.UUID(user_id) + + if isinstance(script_config, (MaaConfig | GeneralConfig)): + await script_config.UserData.remove(uid) + await self.ScriptConfig.save() + + async def reorder_user(self, script_id: str, index_list: list[str]) -> None: + """重新排序用户""" + + logger.info(f"{script_id} 重新排序用户: {index_list}") + + script_config = self.ScriptConfig[uuid.UUID(script_id)] + + if isinstance(script_config, (MaaConfig | GeneralConfig)): + await script_config.UserData.setOrder([uuid.UUID(_) for _ in index_list]) + await self.ScriptConfig.save() + + async def set_infrastructure( + self, script_id: str, user_id: str, jsonFile: str + ) -> None: + + logger.info(f"{script_id} - {user_id} 设置基建配置: {jsonFile}") + + script_config = self.ScriptConfig[uuid.UUID(script_id)] + uid = uuid.UUID(user_id) + json_path = Path(jsonFile) + + if not json_path.exists(): + raise FileNotFoundError(f"文件未找到: {json_path}") + + (Path.cwd() / f"data/{script_id}/{user_id}/Infrastructure").mkdir( + parents=True, exist_ok=True + ) + shutil.copy( + json_path, + Path.cwd() + / f"data/{script_id}/{user_id}/Infrastructure/infrastructure.json", + ) + + if isinstance(script_config, (MaaConfig)): + await script_config.UserData[uid].set("Info", "InfrastPath", str(json_path)) + + async def add_plan( + self, script: Literal["MaaPlan"] + ) -> tuple[uuid.UUID, ConfigBase]: + """添加计划表""" + + logger.info(f"添加计划表: {script}") + + return await self.PlanConfig.add(CLASS_BOOK[script]) + + async def get_plan(self, plan_id: Optional[str]) -> tuple[list, dict]: + """获取计划表配置""" + + logger.info(f"获取计划表配置: {plan_id}") + + if plan_id is None: + data = await self.PlanConfig.toDict() + else: + data = await self.PlanConfig.get(uuid.UUID(plan_id)) + + index = data.pop("instances", []) + + return list(index), data + + async def update_plan(self, plan_id: str, data: Dict[str, Dict[str, Any]]) -> None: + """更新计划表配置""" + + logger.info(f"更新计划表配置: {plan_id}") + + uid = uuid.UUID(plan_id) + + for group, items in data.items(): + for name, value in items.items(): + logger.debug(f"更新计划表配置: {plan_id} - {group}.{name} = {value}") + await self.PlanConfig[uid].set(group, name, value) + + await self.PlanConfig.save() + + async def del_plan(self, plan_id: str) -> None: + """删除计划表配置""" + + logger.info(f"删除计划表配置: {plan_id}") + + await self.PlanConfig.remove(uuid.UUID(plan_id)) + + async def reorder_plan(self, index_list: list[str]) -> None: + """重新排序计划表""" + + logger.info(f"重新排序计划表: {index_list}") + + await self.PlanConfig.setOrder([uuid.UUID(_) for _ in index_list]) + + async def add_queue(self) -> tuple[uuid.UUID, ConfigBase]: + """添加调度队列""" + + logger.info("添加调度队列") + + return await self.QueueConfig.add(QueueConfig) + + async def get_queue(self, queue_id: Optional[str]) -> tuple[list, dict]: + """获取调度队列配置""" + + logger.info(f"获取调度队列配置: {queue_id}") + + if queue_id is None: + data = await self.QueueConfig.toDict() + else: + data = await self.QueueConfig.get(uuid.UUID(queue_id)) + + index = data.pop("instances", []) + + return list(index), data + + async def update_queue( + self, queue_id: str, data: Dict[str, Dict[str, Any]] + ) -> None: + """更新调度队列配置""" + + logger.info(f"更新调度队列配置: {queue_id}") + + uid = uuid.UUID(queue_id) + + for group, items in data.items(): + for name, value in items.items(): + logger.debug(f"更新调度队列配置: {queue_id} - {group}.{name} = {value}") + await self.QueueConfig[uid].set(group, name, value) + + await self.QueueConfig.save() + + async def del_queue(self, queue_id: str) -> None: + """删除调度队列配置""" + + logger.info(f"删除调度队列配置: {queue_id}") + + await self.QueueConfig.remove(uuid.UUID(queue_id)) + + async def reorder_queue(self, index_list: list[str]) -> None: + """重新排序调度队列""" + + logger.info(f"重新排序调度队列: {index_list}") + + await self.QueueConfig.setOrder([uuid.UUID(_) for _ in index_list]) + + async def get_time_set( + self, queue_id: str, time_set_id: Optional[str] + ) -> tuple[list, dict]: + """获取时间设置配置""" + + logger.info(f"Get time set of queue: {queue_id} - {time_set_id}") + + uid = uuid.UUID(queue_id) + qc = self.QueueConfig[uid] + + if isinstance(qc, QueueConfig): + if time_set_id is None: + data = await qc.TimeSet.toDict() + else: + data = await qc.TimeSet.get(uuid.UUID(time_set_id)) + else: + logger.error(f"不支持的队列配置类型: {type(qc)}") + raise TypeError(f"不支持的队列配置类型: {type(qc)}") + + index = data.pop("instances", []) + + return list(index), data + + async def add_time_set(self, queue_id: str) -> tuple[uuid.UUID, ConfigBase]: + """添加时间设置配置""" + + logger.info(f"{queue_id} 添加时间设置配置") + + queue_config = self.QueueConfig[uuid.UUID(queue_id)] + + if isinstance(queue_config, QueueConfig): + uid, config = await queue_config.TimeSet.add(TimeSet) + else: + raise TypeError(f"不支持的队列配置类型: {type(queue_config)}") + + await self.QueueConfig.save() + return uid, config + + async def update_time_set( + self, queue_id: str, time_set_id: str, data: Dict[str, Dict[str, Any]] + ) -> None: + """更新时间设置配置""" + + logger.info(f"{queue_id} 更新时间设置配置: {time_set_id}") + + queue_config = self.QueueConfig[uuid.UUID(queue_id)] + uid = uuid.UUID(time_set_id) + + for group, items in data.items(): + for name, value in items.items(): + logger.debug(f"更新时间设置配置: {queue_id} - {group}.{name} = {value}") + if isinstance(queue_config, QueueConfig): + await queue_config.TimeSet[uid].set(group, name, value) + + await self.QueueConfig.save() + + async def del_time_set(self, queue_id: str, time_set_id: str) -> None: + """删除时间设置配置""" + + logger.info(f"{queue_id} 删除时间设置配置: {time_set_id}") + + queue_config = self.QueueConfig[uuid.UUID(queue_id)] + uid = uuid.UUID(time_set_id) + + if isinstance(queue_config, QueueConfig): + await queue_config.TimeSet.remove(uid) + await self.QueueConfig.save() + + async def reorder_time_set(self, queue_id: str, index_list: list[str]) -> None: + """重新排序时间设置""" + + logger.info(f"{queue_id} 重新排序时间设置: {index_list}") + + queue_config = self.QueueConfig[uuid.UUID(queue_id)] + + if isinstance(queue_config, QueueConfig): + await queue_config.TimeSet.setOrder([uuid.UUID(_) for _ in index_list]) + await self.QueueConfig.save() + + async def get_queue_item( + self, queue_id: str, queue_item_id: Optional[str] + ) -> tuple[list, dict]: + """获取队列项配置""" + + logger.info(f"Get queue item of queue: {queue_id} - {queue_item_id}") + + uid = uuid.UUID(queue_id) + qc = self.QueueConfig[uid] + + if isinstance(qc, QueueConfig): + if queue_item_id is None: + data = await qc.QueueItem.toDict() + else: + data = await qc.QueueItem.get(uuid.UUID(queue_item_id)) + else: + logger.error(f"不支持的队列配置类型: {type(qc)}") + raise TypeError(f"不支持的队列配置类型: {type(qc)}") + + index = data.pop("instances", []) + + return list(index), data + + async def add_queue_item(self, queue_id: str) -> tuple[uuid.UUID, ConfigBase]: + """添加队列项配置""" + + logger.info(f"{queue_id} 添加队列项配置") + + queue_config = self.QueueConfig[uuid.UUID(queue_id)] + + if isinstance(queue_config, QueueConfig): + uid, config = await queue_config.QueueItem.add(QueueItem) + else: + logger.warning(f"不支持的队列配置类型: {type(queue_config)}") + raise TypeError(f"不支持的队列配置类型: {type(queue_config)}") + + await self.QueueConfig.save() + return uid, config + + async def update_queue_item( + self, queue_id: str, queue_item_id: str, data: Dict[str, Dict[str, Any]] + ) -> None: + """更新队列项配置""" + + logger.info(f"{queue_id} 更新队列项配置: {queue_item_id}") + + queue_config = self.QueueConfig[uuid.UUID(queue_id)] + uid = uuid.UUID(queue_item_id) + + for group, items in data.items(): + for name, value in items.items(): + if uuid.UUID(value) not in self.ScriptConfig: + logger.warning(f"脚本 {value} 不存在") + raise ValueError(f"脚本 {value} 不存在") + logger.debug(f"更新队列项配置: {queue_id} - {group}.{name} = {value}") + if isinstance(queue_config, QueueConfig): + await queue_config.QueueItem[uid].set(group, name, value) + + await self.QueueConfig.save() + + async def del_queue_item(self, queue_id: str, queue_item_id: str) -> None: + """删除队列项配置""" + + logger.info(f"{queue_id} 删除队列项配置: {queue_item_id}") + + queue_config = self.QueueConfig[uuid.UUID(queue_id)] + uid = uuid.UUID(queue_item_id) + + if isinstance(queue_config, QueueConfig): + await queue_config.QueueItem.remove(uid) + await self.QueueConfig.save() + + async def reorder_queue_item(self, queue_id: str, index_list: list[str]) -> None: + """重新排序队列项""" + + logger.info(f"{queue_id} 重新排序队列项: {index_list}") + + queue_config = self.QueueConfig[uuid.UUID(queue_id)] + + if isinstance(queue_config, QueueConfig): + await queue_config.QueueItem.setOrder([uuid.UUID(_) for _ in index_list]) + await self.QueueConfig.save() + + async def get_setting(self) -> Dict[str, Any]: + """获取全局设置""" + + logger.info("获取全局设置") + + return await self.toDict(ignore_multi_config=True) + + async def update_setting(self, data: Dict[str, Dict[str, Any]]) -> None: + """更新全局设置""" + + logger.info("更新全局设置") + + for group, items in data.items(): + for name, value in items.items(): + logger.debug(f"更新全局设置 - {group}.{name} = {value}") + await self.set(group, name, value) + + logger.success("全局设置更新成功") def server_date(self) -> date: """ @@ -1198,338 +1501,412 @@ class AppConfig(GlobalConfig): dt = dt - timedelta(days=1) return dt.date() - def search_script(self) -> None: - """更新脚本实例配置信息""" - - logger.info("开始搜索并读入脚本实例配置", module="配置管理") - self.script_dict: Dict[ - str, - Dict[ - str, - Union[ - str, - Path, - Union[MaaConfig, GeneralConfig], - Dict[str, Dict[str, Union[Path, MaaUserConfig, GeneralSubConfig]]], - ], - ], - ] = {} - if (self.app_path / "config/MaaConfig").exists(): - for maa_dir in (self.app_path / "config/MaaConfig").iterdir(): - if maa_dir.is_dir(): - - maa_config = MaaConfig() - maa_config.load(maa_dir / "config.json", maa_config) - maa_config.save() - - self.script_dict[maa_dir.name] = { - "Type": "Maa", - "Path": maa_dir, - "Config": maa_config, - "UserData": None, - } - if (self.app_path / "config/GeneralConfig").exists(): - for general_dir in (self.app_path / "config/GeneralConfig").iterdir(): - if general_dir.is_dir(): - - general_config = GeneralConfig() - general_config.load(general_dir / "config.json", general_config) - general_config.save() - - self.script_dict[general_dir.name] = { - "Type": "General", - "Path": general_dir, - "Config": general_config, - "SubData": None, - } - - self.script_dict = dict( - sorted(self.script_dict.items(), key=lambda x: int(x[0][3:])) - ) - - logger.success( - f"脚本实例配置搜索完成,共找到 {len(self.script_dict)} 个实例", - module="配置管理", - ) - - def search_maa_user(self, name: str) -> None: - """ - 更新指定 MAA 脚本实例的用户信息 - - :param name: 脚本实例名称 - :type name: str - """ - - logger.info(f"开始搜索并读入 MAA 脚本实例 {name} 的用户信息", module="配置管理") - - user_dict: Dict[str, Dict[str, Union[Path, MaaUserConfig]]] = {} - for user_dir in (Config.script_dict[name]["Path"] / "UserData").iterdir(): - if user_dir.is_dir(): - - user_config = MaaUserConfig() - user_config.load(user_dir / "config.json", user_config) - user_config.save() - - user_dict[user_dir.stem] = {"Path": user_dir, "Config": user_config} - - self.script_dict[name]["UserData"] = dict( - sorted(user_dict.items(), key=lambda x: int(x[0][3:])) - ) - - logger.success( - f"MAA 脚本实例 {name} 的用户信息搜索完成,共找到 {len(user_dict)} 个用户", - module="配置管理", - ) - - def search_general_sub(self, name: str) -> None: - """ - 更新指定通用脚本实例的子配置信息 - - :param name: 脚本实例名称 - :type name: str - """ - - logger.info( - f"开始搜索并读入通用脚本实例 {name} 的子配置信息", module="配置管理" - ) - - user_dict: Dict[str, Dict[str, Union[Path, GeneralSubConfig]]] = {} - for sub_dir in (Config.script_dict[name]["Path"] / "SubData").iterdir(): - if sub_dir.is_dir(): - - sub_config = GeneralSubConfig() - sub_config.load(sub_dir / "config.json", sub_config) - sub_config.save() - - user_dict[sub_dir.stem] = {"Path": sub_dir, "Config": sub_config} - - self.script_dict[name]["SubData"] = dict( - sorted(user_dict.items(), key=lambda x: int(x[0][3:])) - ) - - logger.success( - f"通用脚本实例 {name} 的子配置信息搜索完成,共找到 {len(user_dict)} 个子配置", - module="配置管理", - ) - - def search_plan(self) -> None: - """更新计划表配置信息""" - - logger.info("开始搜索并读入计划表配置", module="配置管理") - - self.plan_dict: Dict[str, Dict[str, Union[str, Path, MaaPlanConfig]]] = {} - if (self.app_path / "config/MaaPlanConfig").exists(): - for maa_plan_dir in (self.app_path / "config/MaaPlanConfig").iterdir(): - if maa_plan_dir.is_dir(): - - maa_plan_config = MaaPlanConfig() - maa_plan_config.load(maa_plan_dir / "config.json", maa_plan_config) - maa_plan_config.save() - - self.plan_dict[maa_plan_dir.name] = { - "Type": "Maa", - "Path": maa_plan_dir, - "Config": maa_plan_config, - } - - self.plan_dict = dict( - sorted(self.plan_dict.items(), key=lambda x: int(x[0][3:])) - ) - - logger.success( - f"计划表配置搜索完成,共找到 {len(self.plan_dict)} 个计划表", - module="配置管理", - ) - - def search_queue(self): - """更新调度队列实例配置信息""" - - logger.info("开始搜索并读入调度队列配置", module="配置管理") - - self.queue_dict: Dict[str, Dict[str, Union[Path, QueueConfig]]] = {} - - if (self.app_path / "config/QueueConfig").exists(): - for json_file in (self.app_path / "config/QueueConfig").glob("*.json"): - - queue_config = QueueConfig() - queue_config.load(json_file, queue_config) - queue_config.save() - - self.queue_dict[json_file.stem] = { - "Path": json_file, - "Config": queue_config, - } - - self.queue_dict = dict( - sorted(self.queue_dict.items(), key=lambda x: int(x[0][5:])) - ) - - logger.success( - f"调度队列配置搜索完成,共找到 {len(self.queue_dict)} 个调度队列", - module="配置管理", - ) - - def change_queue(self, old: str, new: str) -> None: - """ - 修改调度队列配置文件的队列参数 - - :param old: 旧脚本名 - :param new: 新脚本名 - """ - - logger.info(f"开始修改调度队列参数:{old} -> {new}", module="配置管理") - - for queue in self.queue_dict.values(): - - for i in range(10): - - if ( - queue["Config"].get( - queue["Config"].config_item_dict["Queue"][f"Script_{i}"] - ) - == old - ): - queue["Config"].set( - queue["Config"].config_item_dict["Queue"][f"Script_{i}"], new - ) - - logger.success(f"调度队列参数修改完成:{old} -> {new}", module="配置管理") - - def change_plan(self, old: str, new: str) -> None: - """ - 修改脚本管理所有下属用户的计划表配置参数 - - :param old: 旧计划表名 - :param new: 新计划表名 - """ - - logger.info(f"开始修改计划表参数:{old} -> {new}", module="配置管理") - - for script in self.script_dict.values(): - - for user in script["UserData"].values(): - - if user["Config"].get(user["Config"].Info_StageMode) == old: - user["Config"].set(user["Config"].Info_StageMode, new) - - logger.success(f"计划表参数修改完成:{old} -> {new}", module="配置管理") - - def change_maa_user_info( - self, name: str, user_data: Dict[str, Dict[str, Union[str, Path, dict]]] - ) -> None: - """ - 保存代理完成后发生改动的用户信息 - - :param name: 脚本实例名称 - :type name: str - :param user_data: 用户信息字典,包含用户名称和对应的配置信息 - :type user_data: Dict[str, Dict[str, Union[str, Path, dict]]] - """ - - logger.info(f"开始保存 MAA 脚本实例 {name} 的用户信息变动", module="配置管理") - - for user, info in user_data.items(): - - user_config = self.script_dict[name]["UserData"][user]["Config"] - - user_config.set( - user_config.Info_RemainedDay, info["Config"]["Info"]["RemainedDay"] - ) - user_config.set( - user_config.Data_LastProxyDate, info["Config"]["Data"]["LastProxyDate"] - ) - user_config.set( - user_config.Data_LastAnnihilationDate, - info["Config"]["Data"]["LastAnnihilationDate"], - ) - user_config.set( - user_config.Data_LastSklandDate, - info["Config"]["Data"]["LastSklandDate"], - ) - user_config.set( - user_config.Data_ProxyTimes, info["Config"]["Data"]["ProxyTimes"] - ) - user_config.set( - user_config.Data_IfPassCheck, info["Config"]["Data"]["IfPassCheck"] - ) - user_config.set( - user_config.Data_CustomInfrastPlanIndex, - info["Config"]["Data"]["CustomInfrastPlanIndex"], - ) - - self.sub_info_changed.emit() - - logger.success(f"MAA 脚本实例 {name} 的用户信息变动保存完成", module="配置管理") - - def change_general_sub_info( - self, name: str, sub_data: Dict[str, Dict[str, Union[str, Path, dict]]] - ) -> None: - """ - 保存代理完成后发生改动的配置信息 - - :param name: 脚本实例名称 - :type name: str - :param sub_data: 子配置信息字典,包含子配置名称和对应的配置信息 - :type sub_data: Dict[str, Dict[str, Union[str, Path, dict]]] - """ - - logger.info(f"开始保存通用脚本实例 {name} 的子配置信息变动", module="配置管理") - - for sub, info in sub_data.items(): - - sub_config = self.script_dict[name]["SubData"][sub]["Config"] - - sub_config.set( - sub_config.Info_RemainedDay, info["Config"]["Info"]["RemainedDay"] - ) - sub_config.set( - sub_config.Data_LastProxyDate, info["Config"]["Data"]["LastProxyDate"] - ) - sub_config.set( - sub_config.Data_ProxyTimes, info["Config"]["Data"]["ProxyTimes"] - ) - - self.sub_info_changed.emit() - - logger.success( - f"通用脚本实例 {name} 的子配置信息变动保存完成", module="配置管理" - ) - - def set_power_sign(self, sign: str) -> None: - """ - 设置当前电源状态 - - :param sign: 电源状态标志 - """ - - self.power_sign = sign - self.power_sign_changed.emit() - - logger.info(f"电源状态已更改为: {sign}", module="配置管理") - - def save_history(self, key: str, content: dict) -> None: - """ - 保存历史记录 - - :param key: 调度队列的键 - :type key: str - :param content: 包含时间和历史记录内容的字典 - :type content: dict - """ - - if key in self.queue_dict: - logger.info(f"保存调度队列 {key} 的历史记录", module="配置管理") - self.queue_dict[key]["Config"].set( - self.queue_dict[key]["Config"].Data_LastProxyTime, content["Time"] - ) - self.queue_dict[key]["Config"].set( - self.queue_dict[key]["Config"].Data_LastProxyHistory, content["History"] - ) - logger.success(f"调度队列 {key} 的历史记录已保存", module="配置管理") + def get_proxies(self) -> Dict[str, str]: + """获取代理设置""" + return { + "http": self.get("Update", "ProxyAddress"), + "https": self.get("Update", "ProxyAddress"), + } + + async def get_stage_info( + self, + type: Literal[ + "Today", + "ALL", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + "Info", + ], + ): + """获取关卡信息""" + + if type == "Today": + dt = self.server_date() + index = dt.strftime("%A") else: - logger.warning(f"保存历史记录时未找到调度队列: {key}") + index = type - def save_maa_log(self, log_path: Path, logs: list, maa_result: str) -> bool: + if json.loads(self.get("Data", "Stage")) != {}: + task = asyncio.create_task(self.get_stage()) + self.temp_task.append(task) + task.add_done_callback(lambda t: self.temp_task.remove(t)) + else: + await self.get_stage() + + if index == "Info": + today = self.server_date().isoweekday() + res_stage_info = [] + for stage in RESOURCE_STAGE_INFO: + if ( + today in stage["days"] + and stage["value"] in RESOURCE_STAGE_DROP_INFO + ): + res_stage_info.append(RESOURCE_STAGE_DROP_INFO[stage["value"]]) + return { + "Activity": json.loads(self.get("Data", "Stage")).get("Info", []), + "Resource": res_stage_info, + } + else: + return json.loads(self.get("Data", "Stage")).get(index, []) + + async def get_proxy_overview(self) -> Dict[str, Any]: + """获取代理情况概览信息""" + + logger.info("获取代理情况概览信息") + + history_index = await self.search_history( + "按日合并", self.server_date(), self.server_date() + ) + if self.server_date().strftime("%Y年 %m月 %d日") not in history_index: + return {} + history_data = { + k: await self.merge_statistic_info(v) + for k, v in history_index[ + self.server_date().strftime("%Y年 %m月 %d日") + ].items() + } + overview = {} + for user, data in history_data.items(): + last_proxy_date = max( + datetime.strptime(_["date"], "%Y年%m月%d日 %H:%M:%S") + for _ in data.get("index", []) + ).strftime("%Y年%m月%d日 %H:%M:%S") + proxy_times = len(data.get("index", [])) + error_info = data.get("error_info", {}) + error_times = len(error_info) + overview[user] = { + "LastProxyDate": last_proxy_date, + "ProxyTimes": proxy_times, + "ErrorTimes": error_times, + "ErrorInfo": error_info, + } + return overview + + async def get_stage( + self, if_start: bool = False + ) -> Optional[Dict[str, List[Dict[str, str]]]]: + """更新活动关卡信息""" + + if datetime.now() - timedelta(hours=1) < datetime.strptime( + self.get("Data", "LastStageUpdated"), "%Y-%m-%d %H:%M:%S" + ): + logger.info("一小时内已进行过一次检查, 直接使用缓存的活动关卡信息") + return json.loads(self.get("Data", "Stage")) + + logger.info("开始获取活动关卡信息") + + try: + response = requests.get( + "https://api.maa.plus/MaaAssistantArknights/api/stageAndTasksUpdateTime.json", + timeout=3 if if_start else 10, + proxies=self.get_proxies(), + ) + if response.status_code == 200: + remote_time_stamp = datetime.strptime( + str(response.json().get("timestamp", 20000101000000)), + "%Y%m%d%H%M%S", + ) + else: + logger.warning(f"无法从MAA服务器获取活动关卡时间戳:{response.text}") + remote_time_stamp = datetime.fromtimestamp(0) + except Exception as e: + logger.warning(f"无法从MAA服务器获取活动关卡时间戳: {e}") + remote_time_stamp = datetime.fromtimestamp(0) + + local_time_stamp = datetime.strptime( + self.get("Data", "StageTimeStamp"), "%Y-%m-%d %H:%M:%S" + ) + + # 本地关卡信息无需更新, 直接返回本地数据 + if datetime.fromtimestamp(0) < remote_time_stamp <= local_time_stamp: + + logger.info("使用本地关卡信息") + await self.set( + "Data", "LastStageUpdated", datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + return json.loads(self.get("Data", "Stage")) + + # 需要更新关卡信息 + logger.info("从远端更新关卡信息") + + try: + response = requests.get( + "https://api.maa.plus/MaaAssistantArknights/api/gui/StageActivity.json", + timeout=3 if if_start else 10, + proxies=self.get_proxies(), + ) + if response.status_code == 200: + remote_activity_stage_info = ( + response.json().get("Official", {}).get("sideStoryStage", []) + ) + if_get_maa_stage = True + else: + logger.warning(f"无法从MAA服务器获取活动关卡信息:{response.text}") + if_get_maa_stage = False + remote_activity_stage_info = [] + except Exception as e: + logger.warning(f"无法从MAA服务器获取活动关卡信息: {e}") + if_get_maa_stage = False + remote_activity_stage_info = [] + + def normalize_drop(value: str) -> str: + # 去前后空格与常见零宽字符 + s = str(value).strip() + s = re.sub(r"[\u200b\u200c\u200d\ufeff]", "", s) + return s + + def parse_utc(dt_str: str) -> datetime: + return datetime.strptime(dt_str, "%Y/%m/%d %H:%M:%S").replace( + tzinfo=timezone.utc + ) + + now_utc = datetime.now(timezone.utc) + activity_stage_drop_info: List[Dict[str, Any]] = [] + + for stage in remote_activity_stage_info: + if "SSReopen" in stage.get("Display", ""): + continue + act = stage.get("Activity", {}) or {} + try: + start_utc = parse_utc(act["UtcStartTime"]) + expire_utc = parse_utc(act["UtcExpireTime"]) + except Exception: + continue + + if start_utc <= now_utc < expire_utc: + raw_drop = stage.get("Drop", "") + drop_id = normalize_drop(raw_drop) + + if drop_id.isdigit(): + drop_name = MATERIALS_MAP.get(drop_id, "未知材料") + else: + drop_name = ( + "DESC:" + drop_id + ) # 非纯数字, 直接用文本.加一个DESC前缀方便前端区分 + + activity_stage_drop_info.append( + { + "Display": stage.get("Display", ""), + "Value": stage.get("Value", ""), + "Drop": raw_drop, + "DropName": drop_name, + "Activity": stage.get("Activity", {}), + } + ) + + activity_stage_combox = [] + + for stage in remote_activity_stage_info: + + if ( + datetime.strptime( + stage["Activity"]["UtcStartTime"], "%Y/%m/%d %H:%M:%S" + ) + < datetime.now() + < datetime.strptime( + stage["Activity"]["UtcExpireTime"], "%Y/%m/%d %H:%M:%S" + ) + ): + activity_stage_combox.append( + {"label": stage["Value"], "value": stage["Value"]} + ) + + stage_data = {} + + for day in range(0, 8): + + res_stage = [] + + for stage in RESOURCE_STAGE_INFO: + + if day in stage["days"] or day == 0: + res_stage.append({"label": stage["text"], "value": stage["value"]}) + + stage_data[calendar.day_name[day - 1] if day > 0 else "ALL"] = ( + res_stage[0:1] + activity_stage_combox + res_stage[1:] + ) + + stage_data["Info"] = activity_stage_drop_info + + if if_get_maa_stage: + + logger.success("成功获取远端活动关卡信息") + await self.set( + "Data", "LastStageUpdated", datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + await self.set( + "Data", + "StageTimeStamp", + remote_time_stamp.strftime("%Y-%m-%d %H:%M:%S"), + ) + await self.set("Data", "Stage", json.dumps(stage_data, ensure_ascii=False)) + + return stage_data + + async def get_script_combox(self): + """获取脚本下拉框信息""" + + logger.info("Getting script combo box information...") + data = [{"label": "未选择", "value": None}] + for uid, script in self.ScriptConfig.items(): + data.append( + { + "label": f"{TYPE_BOOK[type(script).__name__]} - {script.get('Info', 'Name')}", + "value": str(uid), + } + ) + logger.success("Script combo box information retrieved successfully.") + + return data + + async def get_task_combox(self): + """获取任务下拉框信息""" + + logger.info("开始获取任务下拉框信息") + data = [{"label": "未选择", "value": None}] + for uid, queue in self.QueueConfig.items(): + data.append( + { + "label": f"队列 - {queue.get('Info', 'Name')}", + "value": str(uid), + } + ) + for uid, script in self.ScriptConfig.items(): + data.append( + { + "label": f"脚本 - {TYPE_BOOK[type(script).__name__]} - {script.get('Info', 'Name')}", + "value": str(uid), + } + ) + logger.success("任务下拉框信息获取成功") + + return data + + async def get_plan_combox(self): + """获取计划下拉框信息""" + + logger.info("开始获取计划下拉框信息") + data = [{"label": "固定", "value": "Fixed"}] + for uid, plan in self.PlanConfig.items(): + data.append({"label": plan.get("Info", "Name"), "value": str(uid)}) + logger.success("计划下拉框信息获取成功") + + return data + + async def get_notice(self) -> tuple[bool, Dict[str, str]]: + """获取公告信息""" + + local_notice = json.loads(self.get("Data", "Notice")) + if datetime.now() - timedelta(hours=1) < datetime.strptime( + self.get("Data", "LastNoticeUpdated"), "%Y-%m-%d %H:%M:%S" + ): + logger.info("一小时内已进行过一次检查, 直接使用缓存的公告信息") + return False, local_notice.get("notice_dict", {}) + + logger.info(f"开始从 AUTO_MAA 服务器获取公告信息") + + try: + response = requests.get( + "https://download.auto-mas.top/d/AUTO_MAA/Server/notice.json", + timeout=10, + proxies=self.get_proxies(), + ) + if response.status_code == 200: + remote_notice = response.json() + else: + logger.warning(f"无法从 AUTO_MAA 服务器获取公告信息:{response.text}") + remote_notice = None + except Exception as e: + logger.warning(f"无法从 AUTO_MAA 服务器获取公告信息: {e}") + remote_notice = None + + if remote_notice is None: + logger.warning("使用本地公告信息") + return False, local_notice.get("notice_dict", {}) + + await self.set( + "Data", "LastNoticeUpdated", datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + + local_time_stamp = datetime.strptime( + local_notice.get("time", "2000-01-01 00:00"), "%Y-%m-%d %H:%M" + ) + remote_time_stamp = datetime.strptime( + remote_notice.get("time", "2000-01-01 00:00"), "%Y-%m-%d %H:%M" + ) + + # 本地公告信息需更新且持续展示 + if local_time_stamp < remote_time_stamp < datetime.now(): + + logger.info("要求展示本地公告信息") + await self.set( + "Data", "Notice", json.dumps(remote_notice, ensure_ascii=False) + ) + await self.set("Data", "IfShowNotice", True) + + return self.get("Data", "IfShowNotice"), remote_notice.get("notice_dict", {}) + + async def get_web_config(self): + """获取「AUTO_MAA 配置分享中心」配置""" + + local_web_config = json.loads(self.get("Data", "WebConfig")) + if datetime.now() - timedelta(hours=1) < datetime.strptime( + self.get("Data", "LastWebConfigUpdated"), "%Y-%m-%d %H:%M:%S" + ): + logger.info("一小时内已进行过一次检查, 直接使用缓存的配置分享中心信息") + return local_web_config + + logger.info(f"开始从 AUTO_MAA 服务器获取配置分享中心信息") + + try: + response = requests.get( + "http://221.236.27.82:10023/api/list/config/general", + timeout=10, + proxies=self.get_proxies(), + ) + if response.status_code == 200: + remote_web_config = response.json() + else: + logger.warning( + f"无法从 AUTO_MAA 服务器获取配置分享中心信息:{response.text}" + ) + remote_web_config = None + except Exception as e: + logger.warning(f"无法从 AUTO_MAA 服务器获取配置分享中心信息: {e}") + remote_web_config = None + + if remote_web_config is None: + logger.warning("使用本地配置分享中心信息") + return local_web_config + + await self.set( + "Data", "LastWebConfigUpdated", datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + await self.set( + "Data", "WebConfig", json.dumps(remote_web_config, ensure_ascii=False) + ) + + return remote_web_config + + async def get_startup_task(self): + """获取启动时需要运行的队列信息""" + + logger.info("获取启动时需要运行的队列信息") + data = [ + str(uid) + for uid, queue in self.QueueConfig.items() + if queue.get("Info", "StartUpEnabled") + ] + logger.success("启动时需要运行的队列信息获取成功") + + return data + + async def save_maa_log(self, log_path: Path, logs: list, maa_result: str) -> bool: """ 保存MAA日志并生成对应统计数据 @@ -1543,12 +1920,9 @@ class AppConfig(GlobalConfig): :rtype: bool """ - logger.info( - f"开始处理 MAA 日志,日志长度: {len(logs)},日志标记:{maa_result}", - module="配置管理", - ) + logger.info(f"开始处理 MAA 日志, 日志长度: {len(logs)}, 日志标记: {maa_result}") - data: Dict[str, Union[str, Dict[str, Union[int, dict]]]] = { + data = { "recruit_statistics": defaultdict(int), "drop_statistics": defaultdict(dict), "maa_result": maa_result, @@ -1579,7 +1953,7 @@ class AppConfig(GlobalConfig): if confirmed_recruit and current_star_level: data["recruit_statistics"][current_star_level] += 1 - confirmed_recruit = False # 重置,等待下一次公招 + confirmed_recruit = False # 重置, 等待下一次公招 current_star_level = None # 清空已处理的星级 i += 1 @@ -1598,13 +1972,13 @@ class AppConfig(GlobalConfig): if "完成任务: Fight" in logs[j] or "完成任务: 刷理智" in logs[j]: end_index = j break - # 如果遇到新的Fight任务开始,则当前任务没有正常结束 + # 如果遇到新的Fight任务开始, 则当前任务没有正常结束 if j < len(logs) and ( "开始任务: Fight" in logs[j] or "开始任务: 刷理智" in logs[j] ): break - # 如果找到了结束位置,记录这个任务的范围 + # 如果找到了结束位置, 记录这个任务的范围 if end_index != -1: fight_tasks.append((i, end_index)) @@ -1618,15 +1992,15 @@ class AppConfig(GlobalConfig): current_stage = None for line in task_logs: - # 匹配掉落统计行,如"1-7 掉落统计:" + # 匹配掉落统计行, 如"1-7 掉落统计:" drop_match = re.search(r"([A-Za-z0-9\-]+) 掉落统计:", line) if drop_match: - # 发现新的掉落统计,重置当前关卡的掉落数据 + # 发现新的掉落统计, 重置当前关卡的掉落数据 current_stage = drop_match.group(1) last_drop_stats = {} continue - # 如果已经找到了关卡,处理掉落物 + # 如果已经找到了关卡, 处理掉落物 if current_stage: item_match: List[str] = re.findall( r"^(?!\[)(\S+?)\s*:\s*([\d,]+)(?:\s*\(\+[\d,]+\))?", @@ -1647,7 +2021,7 @@ class AppConfig(GlobalConfig): ]: last_drop_stats[item] = total - # 如果任务中有掉落统计,更新总统计 + # 如果任务中有掉落统计, 更新总统计 if current_stage and last_drop_stats: if current_stage not in all_stage_drops: all_stage_drops[current_stage] = {} @@ -1667,11 +2041,13 @@ class AppConfig(GlobalConfig): with log_path.with_suffix(".json").open("w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=4) - logger.success(f"MAA 日志统计完成,日志路径:{log_path}", module="配置管理") + logger.success(f"MAA 日志统计完成, 日志路径: {log_path}") return if_six_star - def save_general_log(self, log_path: Path, logs: list, general_result: str) -> None: + async def save_general_log( + self, log_path: Path, logs: list, general_result: str + ) -> None: """ 保存通用日志并生成对应统计数据 @@ -1681,8 +2057,7 @@ class AppConfig(GlobalConfig): """ logger.info( - f"开始处理通用日志,日志长度: {len(logs)},日志标记:{general_result}", - module="配置管理", + f"开始处理通用日志, 日志长度: {len(logs)}, 日志标记: {general_result}" ) data: Dict[str, str] = {"general_result": general_result} @@ -1694,12 +2069,9 @@ class AppConfig(GlobalConfig): with log_path.with_suffix(".json").open("w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=4) - logger.success( - f"通用日志统计完成,日志路径:{log_path.with_suffix('.log')}", - module="配置管理", - ) + logger.success(f"通用日志统计完成, 日志路径: {log_path.with_suffix('.log')}") - def merge_statistic_info(self, statistic_path_list: List[Path]) -> dict: + async def merge_statistic_info(self, statistic_path_list: List[Path]) -> dict: """ 合并指定数据统计信息文件 @@ -1707,19 +2079,14 @@ class AppConfig(GlobalConfig): :return: 合并后的统计信息字典 """ - logger.info( - f"开始合并统计信息文件,共计 {len(statistic_path_list)} 个文件", - module="配置管理", - ) + logger.info(f"开始合并统计信息文件, 共计 {len(statistic_path_list)} 个文件") - data = {"index": {}} + data: Dict[str, Any] = {"index": {}} for json_file in statistic_path_list: with json_file.open("r", encoding="utf-8") as f: - single_data: Dict[str, Union[str, Dict[str, Union[int, dict]]]] = ( - json.load(f) - ) + single_data = json.load(f) for key in single_data.keys(): @@ -1765,27 +2132,25 @@ class AppConfig(GlobalConfig): if single_data[key] != "Success!": if "error_info" not in data: data["error_info"] = {} - data["error_info"][actual_date.strftime("%d日 %H:%M:%S")] = ( - single_data[key] - ) + data["error_info"][ + actual_date.strftime("%Y年%m月%d日 %H:%M:%S") + ] = single_data[key] - data["index"][actual_date] = [ - actual_date.strftime("%d日 %H:%M:%S"), - ("完成" if single_data[key] == "Success!" else "异常"), - json_file, - ] + data["index"][actual_date] = { + "date": actual_date.strftime("%Y年%m月%d日 %H:%M:%S"), + "status": ( + "完成" if single_data[key] == "Success!" else "异常" + ), + "jsonFile": str(json_file), + } data["index"] = [data["index"][_] for _ in sorted(data["index"])] - logger.success( - f"统计信息合并完成,共计 {len(data['index'])} 条记录", module="配置管理" - ) + logger.success(f"统计信息合并完成, 共计 {len(data['index'])} 条记录") return {k: v for k, v in data.items() if v} - def search_history( - self, mode: str, start_date: datetime, end_date: datetime - ) -> dict: + async def search_history(self, mode: str, start_date: date, end_date: date) -> dict: """ 搜索指定范围内的历史记录 @@ -1796,19 +2161,18 @@ class AppConfig(GlobalConfig): """ logger.info( - f"开始搜索历史记录,合并模式:{mode},日期范围:{start_date} 至 {end_date}", - module="配置管理", + f"开始搜索历史记录, 合并模式: {mode}, 日期范围: {start_date} 至 {end_date}" ) history_dict = {} - for date_folder in (Config.app_path / "history").iterdir(): + for date_folder in self.history_path.iterdir(): if not date_folder.is_dir(): continue # 只处理日期文件夹 try: - date = datetime.strptime(date_folder.name, "%Y-%m-%d") + date = datetime.strptime(date_folder.name, "%Y-%m-%d").date() if not (start_date <= date <= end_date): continue # 只统计在范围内的日期 @@ -1840,27 +2204,25 @@ class AppConfig(GlobalConfig): except ValueError: logger.warning(f"非日期格式的目录: {date_folder}") - logger.success( - f"历史记录搜索完成,共计 {len(history_dict)} 条记录", module="配置管理" - ) + logger.success(f"历史记录搜索完成, 共计 {len(history_dict)} 条记录") return { k: v for k, v in sorted(history_dict.items(), key=lambda x: x[0], reverse=True) } - def clean_old_history(self): + async def clean_old_history(self): """删除超过用户设定天数的历史记录文件(基于目录日期)""" - if self.get(self.function_HistoryRetentionTime) == 0: - logger.info("历史记录永久保留,跳过历史记录清理", module="配置管理") + if self.get("Function", "HistoryRetentionTime") == 0: + logger.info("历史记录永久保留, 跳过历史记录清理") return - logger.info("开始清理超过设定天数的历史记录", module="配置管理") + logger.info("开始清理超过设定天数的历史记录") deleted_count = 0 - for date_folder in (self.app_path / "history").iterdir(): + for date_folder in self.history_path.iterdir(): if not date_folder.is_dir(): continue # 只处理日期文件夹 @@ -1868,15 +2230,15 @@ class AppConfig(GlobalConfig): # 只检查 `YYYY-MM-DD` 格式的文件夹 folder_date = datetime.strptime(date_folder.name, "%Y-%m-%d") if datetime.now() - folder_date > timedelta( - days=self.get(self.function_HistoryRetentionTime) + days=self.get("Function", "HistoryRetentionTime") ): shutil.rmtree(date_folder, ignore_errors=True) deleted_count += 1 - logger.info(f"已删除超期日志目录: {date_folder}", module="配置管理") + logger.info(f"已删除超期日志目录: {date_folder}") except ValueError: - logger.warning(f"非日期格式的目录: {date_folder}", module="配置管理") + logger.warning(f"非日期格式的目录: {date_folder}") - logger.success(f"清理完成: {deleted_count} 个日期目录", module="配置管理") + logger.success(f"清理完成: {deleted_count} 个日期目录") Config = AppConfig() diff --git a/app/core/main_info_bar.py b/app/core/main_info_bar.py deleted file mode 100644 index 745050a..0000000 --- a/app/core/main_info_bar.py +++ /dev/null @@ -1,109 +0,0 @@ -# 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 . - -# 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() diff --git a/app/core/network.py b/app/core/network.py deleted file mode 100644 index 9d63fe2..0000000 --- a/app/core/network.py +++ /dev/null @@ -1,308 +0,0 @@ -# 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 . - -# 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() diff --git a/app/core/sound_player.py b/app/core/sound_player.py deleted file mode 100644 index e8f5268..0000000 --- a/app/core/sound_player.py +++ /dev/null @@ -1,79 +0,0 @@ -# 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 . - -# 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() diff --git a/app/core/task_manager.py b/app/core/task_manager.py index c044767..fec90e0 100644 --- a/app/core/task_manager.py +++ b/app/core/task_manager.py @@ -18,443 +18,249 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA业务调度器 -v4.4 -作者:DLmaster_361 -""" -from PySide6.QtCore import QThread, QObject, Signal -from qfluentwidgets import MessageBox -from datetime import datetime -from packaging import version -from typing import Dict, Union +import uuid +import asyncio +from functools import partial +from typing import Dict, Optional -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 +from .config import Config, MaaConfig, GeneralConfig, QueueConfig +from app.models.schema import WebSocketMessage +from app.utils import get_logger +from app.task import * -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() +logger = get_logger("业务调度") -class _TaskManager(QObject): +class _TaskManager: """业务调度器""" - create_gui = Signal(Task) - connect_gui = Signal(Task) - def __init__(self): - super(_TaskManager, self).__init__() + super().__init__() - self.task_dict: Dict[str, Task] = {} + self.task_dict: Dict[uuid.UUID, asyncio.Task] = {} - def add_task( - self, mode: str, name: str, info: Dict[str, Dict[str, Union[str, int, bool]]] - ): + async def add_task(self, mode: str, uid: str) -> uuid.UUID: """ 添加任务 :param mode: 任务模式 - :param name: 任务名称 - :param info: 任务信息 + :param uid: 任务UID """ - if name in Config.running_list or name in self.task_dict: + actual_id = uuid.UUID(uid) - logger.warning(f"任务已存在:{name}") - MainInfoBar.push_info_bar("warning", "任务已存在", name, 5000) - return None + 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 ( + isinstance(script, (MaaConfig | GeneralConfig)) + and 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.info(f"任务开始:{name},模式:{mode}", module="业务调度") - MainInfoBar.push_info_bar("info", "任务开始", name, 3000) - SoundPlayer.play("任务开始") + if task_id in self.task_dict or ( + actual_id is not None and actual_id in self.task_dict + ): - # 标记任务为运行中 - Config.running_list.append(name) + raise RuntimeError(f"任务 {task_id} 已在运行") - # 创建任务实例并连接信号 - 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) + logger.info(f"创建任务: {task_id}, 模式: {mode}") + self.task_dict[task_id] = asyncio.create_task( + self.run_task(mode, task_id, actual_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) + self.task_dict[task_id].add_done_callback( + lambda t: asyncio.create_task(self.remove_task(t, mode, task_id)) ) - # 向UI发送信号以创建或连接GUI - if "新调度台" in mode: - self.create_gui.emit(self.task_dict[name]) + return task_id - elif "主调度台" in mode: - self.connect_gui.emit(self.task_dict[name]) + # @logger.catch + async def run_task( + self, mode: str, task_id: uuid.UUID, actual_id: Optional[uuid.UUID] + ): - # 启动任务线程 - self.task_dict[name].start() + logger.info(f"开始运行任务: {task_id}, 模式: {mode}") - def stop_task(self, name: str) -> None: + 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)) + await self.task_dict[uid] + + else: + + if task_id in Config.QueueConfig: + + queue = Config.QueueConfig[task_id] + if not isinstance(queue, QueueConfig): + logger.error( + f"不支持的队列类型: {type(Config.QueueConfig[task_id]).__name__}" + ) + await Config.send_json( + WebSocketMessage( + id=str(task_id), + type="Info", + data={"Error": "队列类型不支持"}, + ).model_dump() + ) + return + + task_list = [] + for queue_item in queue.QueueItem.values(): + if queue_item.get("Info", "ScriptId") is None: + continue + uid = uuid.UUID(queue_item.get("Info", "ScriptId")) + task_list.append( + { + "script_id": str(uid), + "status": "等待", + "name": Config.ScriptConfig[uid].get("Info", "Name"), + } + ) + + elif actual_id is not None and actual_id in Config.ScriptConfig: + + task_list = [{"script_id": str(actual_id), "status": "等待"}] + + 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 + + # 标记为运行中 + 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) + ) + await self.task_dict[script_id] + + async def stop_task(self, task_id: str) -> None: """ 中止任务 - :param name: 任务名称 + :param task_id: 任务ID """ - logger.info(f"中止任务:{name}", module="业务调度") - MainInfoBar.push_info_bar("info", "中止任务", name, 3000) + logger.info(f"中止任务: {task_id}") - if name == "ALL": + 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(f"任务 {uid} 未在运行") + self.task_dict[uid].cancel() - 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: + async def remove_task( + self, task: asyncio.Task, mode: str, task_id: uuid.UUID + ) -> None: """ 处理任务结束后的收尾工作 - :param mode: 任务模式 - :param name: 任务名称 - :param logs: 任务日志 + Parameters + ---------- + task : asyncio.Task + 任务对象 + mode : str + 任务模式 + task_id : uuid.UUID + 任务ID """ - logger.info(f"任务结束:{name}", module="业务调度") - MainInfoBar.push_info_bar("info", "任务结束", name, 3000) - SoundPlayer.play("任务结束") + logger.info(f"任务结束: {task_id}") - # 删除任务线程,移除运行中标记 - self.task_dict[name].deleteLater() - self.task_dict.pop(name) - Config.running_list.remove(name) + # 从任务字典中移除任务 + try: + await task + except asyncio.CancelledError: + logger.info(f"任务 {task_id} 已结束") + self.task_dict.pop(task_id) - 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", + await Config.send_json( + WebSocketMessage( + id=str(task_id), type="Signal", data={"Accomplish": "无描述"} + ).model_dump() ) - network.loop.exec() - network_result = Network.get_result(network) - if network_result["status_code"] == 200: - maa_info = network_result["response_json"] - else: - logger.warning( - f"获取MAA版本信息时出错:{network_result['error_message']}", - module="业务调度", - ) - MainInfoBar.push_info_bar( - "warning", - "获取MAA版本信息时出错", - f"网络错误:{network_result['status_code']}", - 5000, - ) - return None - - if version.parse(maa_info["data"]["version_name"]) > version.parse(v): - - logger.info( - f"检测到MAA版本过低:{v},最新版本:{maa_info['data']['version_name']}", - module="业务调度", - ) - MainInfoBar.push_info_bar( - "info", - "MAA版本过低", - f"当前版本:{v},最新稳定版:{maa_info['data']['version_name']}", - -1, - ) - - logger.success( - f"MAA版本检查完成:{v},最新版本:{maa_info['data']['version_name']}", - module="业务调度", - ) - - def push_dialog(self, name: str, title: str, content: str): - """ - 推送来自任务线程的对话框 - - :param name: 任务名称 - :param title: 对话框标题 - :param content: 对话框内容 - """ - - choice = MessageBox(title, content, Config.main_window) - choice.yesButton.setText("是") - choice.cancelButton.setText("否") - - self.task_dict[name].question_response.emit(bool(choice.exec())) TaskManager = _TaskManager() diff --git a/app/core/timer.py b/app/core/timer.py index 66d8dee..55ad9a6 100644 --- a/app/core/timer.py +++ b/app/core/timer.py @@ -18,99 +18,40 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA主业务定时器 -v4.4 -作者:DLmaster_361 -""" - -from PySide6.QtCore import QObject, QTimer -from datetime import datetime +import asyncio import keyboard +from datetime import datetime -from .logger import logger -from .config import Config -from .task_manager import TaskManager from app.services import System +from app.utils import get_logger +from .config import Config -class _MainTimer(QObject): +logger = get_logger("主业务定时器") - def __init__(self, parent=None): - super().__init__(parent) - self.Timer = QTimer() - self.Timer.timeout.connect(self.timed_start) - self.Timer.timeout.connect(self.set_silence) - self.Timer.timeout.connect(self.check_power) +class _MainTimer: - self.LongTimer = QTimer() - self.LongTimer.timeout.connect(self.long_timed_task) + async def second_task(self): + """每秒定期任务""" + logger.info("每秒定期任务启动") - def start(self): - """启动定时器""" + while True: - logger.info("启动主定时器", module="主业务定时器") - self.Timer.start(1000) - self.LongTimer.start(3600000) + await self.set_silence() - def stop(self): - """停止定时器""" + await asyncio.sleep(1) - logger.info("停止主定时器", module="主业务定时器") - self.Timer.stop() - self.Timer.deleteLater() - self.LongTimer.stop() - self.LongTimer.deleteLater() - - def long_timed_task(self): - """长时间定期检定任务""" - - logger.info("执行长时间定期检定任务", module="主业务定时器") - - Config.get_stage() - Config.main_window.setting.show_notice() - if Config.get(Config.update_IfAutoUpdate): - Config.main_window.setting.check_update() - - def timed_start(self): - """定时启动代理任务""" - - for name, info in Config.queue_dict.items(): - - if not info["Config"].get(info["Config"].QueueSet_TimeEnabled): - continue - - data = info["Config"].toDict() - - time_set = [ - data["Time"][f"Set_{_}"] - for _ in range(10) - if data["Time"][f"Enabled_{_}"] - ] - # 按时间调起代理任务 - curtime = datetime.now().strftime("%Y-%m-%d %H:%M") - if ( - curtime[11:16] in time_set - and curtime - != info["Config"].get(info["Config"].Data_LastProxyTime)[:16] - and name not in Config.running_list - ): - - logger.info(f"定时唤起任务:{name}。", module="主业务定时器") - TaskManager.add_task("自动代理_新调度台", name, data) - - def set_silence(self): - """设置静默模式""" + async def set_silence(self): + """静默模式通过模拟老板键来隐藏模拟器窗口""" if ( - not Config.if_ignore_silence - and Config.get(Config.function_IfSilence) - and Config.get(Config.function_BossKey) != "" + len(Config.if_ignore_silence) > 0 + and Config.get("Function", "IfSilence") + and Config.get("Function", "BossKey") != "" ): - windows = System.get_window_info() + windows = await System.get_window_info() emulator_windows = [] for window in windows: @@ -124,52 +65,17 @@ class _MainTimer(QObject): if emulator_windows: - logger.info( - f"检测到模拟器窗口:{emulator_windows}", module="主业务定时器" - ) + logger.info(f"检测到模拟器窗口: {emulator_windows}") try: keyboard.press_and_release( "+".join( _.strip().lower() - for _ in Config.get(Config.function_BossKey).split("+") + for _ in Config.get("Function", "BossKey").split("+") ) ) - logger.info( - f"模拟按键:{Config.get(Config.function_BossKey)}", - module="主业务定时器", - ) + logger.info(f"模拟按键: {Config.get('Function', 'BossKey')}") except Exception as e: - logger.exception(f"模拟按键时出错:{e}", module="主业务定时器") - - def check_power(self): - """检查电源操作""" - - if Config.power_sign != "NoAction" and not Config.running_list: - - logger.info(f"触发电源操作:{Config.power_sign}", module="主业务定时器") - - from app.ui import ProgressRingMessageBox - - mode_book = { - "KillSelf": "退出软件", - "Sleep": "睡眠", - "Hibernate": "休眠", - "Shutdown": "关机", - "ShutdownForce": "关机(强制)", - } - - choice = ProgressRingMessageBox( - Config.main_window, f"{mode_book[Config.power_sign]}倒计时" - ) - if choice.exec(): - logger.info( - f"确认执行电源操作:{Config.power_sign}", module="主业务定时器" - ) - System.set_power(Config.power_sign) - Config.set_power_sign("NoAction") - else: - logger.info(f"取消电源操作:{Config.power_sign}", module="主业务定时器") - Config.set_power_sign("NoAction") + logger.exception(f"模拟按键时出错: {e}") MainTimer = _MainTimer() diff --git a/app/models/ConfigBase.py b/app/models/ConfigBase.py new file mode 100644 index 0000000..d49a43d --- /dev/null +++ b/app/models/ConfigBase.py @@ -0,0 +1,736 @@ +# AUTO_MAA:A MAA Multi Account Management and Automation Tool +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox + +# 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 . + +# Contact: DLmaster_361@163.com + + +import json +import uuid +import win32com.client +from copy import deepcopy +from pathlib import Path +from typing import List, Any, Dict, Union + + +from app.utils import dpapi_encrypt, dpapi_decrypt + + +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 UidValidator(ConfigValidator): + """UID验证器""" + + def validate(self, value: Any) -> bool: + if value is None: + return True + try: + uuid.UUID(value) + return True + except (TypeError, ValueError): + return False + + def correct(self, value: Any) -> Any: + return value if self.validate(value) else None + + +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 = "." + 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 = "." + return Path(value).resolve().as_posix() + + +class ConfigItem: + """配置项""" + + def __init__( + self, + group: str, + name: str, + default: Any, + validator: None | 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 + + self.setValue(default) + + 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) -> Any: + """ + 获取配置项值 + """ + + if isinstance(self.validator, EncryptValidator): + return dpapi_decrypt(self.value) + return self.value + + 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: None | 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() + + with self.file.open("r", encoding="utf-8") as f: + try: + data = json.load(f) + 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 if_decrypt else item.value + ) + + 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}' 不存在") + + 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) + with self.file.open("w", encoding="utf-8") as f: + json.dump( + await self.toDict(not self.if_save_multi_config, if_decrypt=False), + f, + ensure_ascii=False, + indent=4, + ) + + 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() + + +class MultipleConfig: + """ + 多配置项管理类 + + 这个类允许管理多个配置项实例, 可以添加、删除、修改配置项, 并将其保存到 JSON 文件中。 + 允许通过 `config[uuid]` 访问配置项, 使用 `uuid in config` 检查是否存在配置项, 使用 `len(config)` 获取配置项数量。 + + Parameters + ---------- + sub_config_type: List[type] + 子配置项的类型列表, 必须是 ConfigBase 的子类 + """ + + def __init__(self, sub_config_type: List[type]): + + 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 = sub_config_type + self.file: None | Path = None + self.order: List[uuid.UUID] = [] + self.data: Dict[uuid.UUID, ConfigBase] = {} + self.is_locked = False + + def __getitem__(self, key: uuid.UUID) -> ConfigBase: + """允许通过 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() + + with self.file.open("r", encoding="utf-8") as f: + try: + data = json.load(f) + 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) -> 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() + 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) + with self.file.open("w", encoding="utf-8") as f: + json.dump(await self.toDict(), f, ensure_ascii=False, indent=4) + + async def add(self, config_type: type) -> tuple[uuid.UUID, ConfigBase]: + """ + 添加一个新的配置项 + + 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()) diff --git a/app/models/MAA.py b/app/models/MAA.py deleted file mode 100644 index 3df88d5..0000000 --- a/app/models/MAA.py +++ /dev/null @@ -1,2161 +0,0 @@ -# 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 . - -# Contact: DLmaster_361@163.com - -""" -AUTO_MAA -MAA功能组件 -v4.4 -作者:DLmaster_361 -""" - -from PySide6.QtCore import QObject, Signal, QEventLoop, QFileSystemWatcher, QTimer -import json -import subprocess -import shutil -import re -import win32com.client -from functools import partial -from datetime import datetime, timedelta -from pathlib import Path -from jinja2 import Environment, FileSystemLoader -from typing import Union, List, Dict - -from app.core import Config, MaaConfig, MaaUserConfig, logger -from app.services import Notify, Crypto, System, skland_sign_in -from app.utils import ProcessManager - - -class MaaManager(QObject): - """MAA控制器""" - - check_maa_version = Signal(str) - question = Signal(str, str) - question_response = Signal(bool) - update_user_info = Signal(str, dict) - push_info_bar = Signal(str, str, str, int) - play_sound = Signal(str) - create_user_list = Signal(list) - update_user_list = Signal(list) - update_log_text = Signal(str) - interrupt = Signal() - accomplish = Signal(dict) - - def __init__( - self, - mode: str, - config: Dict[ - str, - Union[ - str, - Path, - MaaConfig, - Dict[str, Dict[str, Union[Path, MaaUserConfig]]], - ], - ], - user_config_path: Path = None, - ): - super(MaaManager, self).__init__() - - self.user_list = "" - self.mode = mode - self.config_path = config["Path"] - self.name = config["Config"].get(config["Config"].MaaSet_Name) - self.user_config_path = user_config_path - - self.emulator_process_manager = ProcessManager() - self.maa_process_manager = ProcessManager() - - self.log_monitor = QFileSystemWatcher() - self.log_monitor.fileChanged.connect(self.check_maa_log) - self.log_monitor_timer = QTimer() - self.log_monitor_timer.timeout.connect(self.refresh_maa_log) - self.monitor_loop = QEventLoop() - self.log_start_time = datetime.now() - self.log_check_mode = None - self.maa_logs = [] - self.maa_result = "Wait" - - self.maa_process_manager.processClosed.connect(self.check_maa_log) - - self.question_loop = QEventLoop() - self.question_response.connect(self.__capture_response) - self.question_response.connect(self.question_loop.quit) - - self.wait_loop = QEventLoop() - - self.isInterruptionRequested = False - self.interrupt.connect(self.quit_monitor) - - self.maa_version = None - self.maa_update_package = "" - self.task_dict = {} - self.set = config["Config"].toDict() - - self.data = {} - if "设置MAA" not in self.mode: - for name, info in config["UserData"].items(): - self.data[name] = { - "Path": info["Path"], - "Config": info["Config"].toDict(), - } - planed_info = info["Config"].get_plan_info() - for key, value in planed_info.items(): - self.data[name]["Config"]["Info"][key] = value - - self.data = dict(sorted(self.data.items(), key=lambda x: int(x[0][3:]))) - - logger.success( - f"MAA控制器初始化完成,当前模式: {self.mode}", - module=f"MAA调度器-{self.name}", - ) - - def configure(self): - """提取配置信息""" - - self.maa_root_path = Path(self.set["MaaSet"]["Path"]) - self.maa_set_path = self.maa_root_path / "config/gui.json" - self.maa_log_path = self.maa_root_path / "debug/gui.log" - self.maa_exe_path = self.maa_root_path / "MAA.exe" - self.maa_tasks_path = self.maa_root_path / "resource/tasks/tasks.json" - self.port_range = [0] + [ - (i // 2 + 1) * (-1 if i % 2 else 1) - for i in range(0, 2 * self.set["RunSet"]["ADBSearchRange"]) - ] - - logger.success("MAA配置提取完成", module=f"MAA调度器-{self.name}") - - def run(self): - """主进程,运行MAA代理进程""" - - current_date = datetime.now().strftime("%m-%d") - curdate = Config.server_date().strftime("%Y-%m-%d") - begin_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - self.configure() - # 检查MAA路径是否可用 - if not self.maa_exe_path.exists() or not self.maa_set_path.exists(): - - logger.error( - "未正确配置MAA路径,MAA代理进程中止", module=f"MAA调度器-{self.name}" - ) - self.push_info_bar.emit( - "error", "启动MAA代理进程失败", "您还未正确配置MAA路径!", -1 - ) - self.accomplish.emit( - { - "Time": begin_time, - "History": "由于未正确配置MAA路径,MAA代理进程中止", - } - ) - return None - - # 记录 MAA 配置文件 - logger.info( - f"记录 MAA 配置文件:{self.maa_set_path}", - module=f"MAA调度器-{self.name}", - ) - (self.config_path / "Temp").mkdir(parents=True, exist_ok=True) - if self.maa_set_path.exists(): - shutil.copy(self.maa_set_path, self.config_path / "Temp/gui.json") - - # 整理用户数据,筛选需代理的用户 - if "设置MAA" not in self.mode: - - self.data = dict( - sorted( - self.data.items(), - key=lambda x: (x[1]["Config"]["Info"]["Mode"], int(x[0][3:])), - ) - ) - self.user_list: List[List[str, str, str]] = [ - [_["Config"]["Info"]["Name"], "等待", index] - for index, _ in self.data.items() - if ( - _["Config"]["Info"]["RemainedDay"] != 0 - and _["Config"]["Info"]["Status"] - ) - ] - self.create_user_list.emit(self.user_list) - - logger.info( - f"用户列表创建完成,已筛选用户数:{len(self.user_list)}", - module=f"MAA调度器-{self.name}", - ) - - # 自动代理模式 - if self.mode == "自动代理": - - # 标记是否需要重启模拟器 - self.if_open_emulator = True - # 执行情况预处理 - for _ in self.user_list: - if self.data[_[2]]["Config"]["Data"]["LastProxyDate"] != curdate: - self.data[_[2]]["Config"]["Data"]["LastProxyDate"] = curdate - self.data[_[2]]["Config"]["Data"]["ProxyTimes"] = 0 - _[ - 0 - ] += f" - 第{self.data[_[2]]["Config"]["Data"]["ProxyTimes"] + 1}次代理" - - # 开始代理 - for user in self.user_list: - - user_data = self.data[user[2]]["Config"] - - if self.isInterruptionRequested: - break - - if ( - self.set["RunSet"]["ProxyTimesLimit"] == 0 - or user_data["Data"]["ProxyTimes"] - < self.set["RunSet"]["ProxyTimesLimit"] - ): - user[1] = "运行" - self.update_user_list.emit(self.user_list) - else: - user[1] = "跳过" - self.update_user_list.emit(self.user_list) - continue - - logger.info(f"开始代理用户: {user[0]}", module=f"MAA调度器-{self.name}") - - # 简洁模式用户默认开启日常选项 - if user_data["Info"]["Mode"] == "简洁": - user_data["Info"]["Routine"] = True - # 详细模式用户首次代理需打开模拟器 - elif user_data["Info"]["Mode"] == "详细": - self.if_open_emulator = True - - # 初始化代理情况记录和模式替换表 - run_book = { - "Annihilation": bool(user_data["Info"]["Annihilation"] == "Close"), - "Routine": not user_data["Info"]["Routine"], - } - mode_book = { - "Annihilation": "自动代理_剿灭", - "Routine": "自动代理_日常", - } - - user_logs_list = [] - user_start_time = datetime.now() - - if user_data["Info"]["IfSkland"] and user_data["Info"]["SklandToken"]: - - if user_data["Data"]["LastSklandDate"] != datetime.now().strftime( - "%Y-%m-%d" - ): - - self.update_log_text.emit("正在执行森空岛签到中\n请稍候~") - - skland_result = skland_sign_in( - Crypto.win_decryptor(user_data["Info"]["SklandToken"]) - ) - - for type, user_list in skland_result.items(): - - if type != "总计" and len(user_list) > 0: - - logger.info( - f"用户: {user[0]} - 森空岛签到{type}: {'、'.join(user_list)}", - module=f"MAA调度器-{self.name}", - ) - self.push_info_bar.emit( - "info", - f"森空岛签到{type}", - "、".join(user_list), - -1 if type == "失败" else 5000, - ) - - if skland_result["总计"] == 0: - self.push_info_bar.emit( - "info", "森空岛签到失败", user[0], -1 - ) - - if ( - skland_result["总计"] > 0 - and len(skland_result["失败"]) == 0 - ): - user_data["Data"][ - "LastSklandDate" - ] = datetime.now().strftime("%Y-%m-%d") - logger.success( - f"用户: {user[0]} - 森空岛签到成功", - module=f"MAA调度器-{self.name}", - ) - self.play_sound.emit("森空岛签到成功") - else: - logger.warning( - f"用户: {user[0]} - 森空岛签到失败", - module=f"MAA调度器-{self.name}", - ) - self.play_sound.emit("森空岛签到失败") - - elif user_data["Info"]["IfSkland"]: - logger.warning( - f"用户: {user[0]} - 未配置森空岛签到Token,跳过森空岛签到", - module=f"MAA调度器-{self.name}", - ) - self.push_info_bar.emit( - "warning", "森空岛签到失败", "未配置鹰角网络通行证登录凭证", -1 - ) - - # 剿灭-日常模式循环 - for mode in ["Annihilation", "Routine"]: - - if self.isInterruptionRequested: - break - - if run_book[mode]: - continue - - # 剿灭模式;满足条件跳过剿灭 - if ( - mode == "Annihilation" - and self.set["RunSet"]["AnnihilationWeeklyLimit"] - and datetime.strptime( - user_data["Data"]["LastAnnihilationDate"], "%Y-%m-%d" - ).isocalendar()[:2] - == datetime.strptime(curdate, "%Y-%m-%d").isocalendar()[:2] - ): - logger.info( - f"用户: {user_data['Info']['Name']} - 本周剿灭模式已达上限,跳过执行剿灭任务", - module=f"MAA调度器-{self.name}", - ) - run_book[mode] = True - continue - else: - self.weekly_annihilation_limit_reached = False - - if ( - user_data["Info"]["Mode"] == "详细" - and not ( - self.data[user[2]]["Path"] / "Routine/gui.json" - ).exists() - ): - logger.error( - f"用户: {user[0]} - 未找到日常详细配置文件", - module=f"MAA调度器-{self.name}", - ) - self.push_info_bar.emit( - "error", - "启动MAA代理进程失败", - f"未找到{user[0]}的详细配置文件!", - -1, - ) - run_book[mode] = False - break - - # 更新当前模式到界面 - self.update_user_list.emit( - [ - ( - [f"{_[0]} - {mode_book[mode][5:7]}", _[1], _[2]] - if _[2] == user[2] - else _ - ) - for _ in self.user_list - ] - ) - - # 解析任务构成 - if mode == "Routine": - - self.task_dict = { - "WakeUp": str(user_data["Task"]["IfWakeUp"]), - "Recruiting": str(user_data["Task"]["IfRecruiting"]), - "Base": str(user_data["Task"]["IfBase"]), - "Combat": str(user_data["Task"]["IfCombat"]), - "Mission": str(user_data["Task"]["IfMission"]), - "Mall": str(user_data["Task"]["IfMall"]), - "AutoRoguelike": str(user_data["Task"]["IfAutoRoguelike"]), - "Reclamation": str(user_data["Task"]["IfReclamation"]), - } - - elif mode == "Annihilation": - - self.task_dict = { - "WakeUp": "True", - "Recruiting": "False", - "Base": "False", - "Combat": "True", - "Mission": "False", - "Mall": "False", - "AutoRoguelike": "False", - "Reclamation": "False", - } - - logger.info( - f"用户: {user[0]} - 模式: {mode_book[mode]} - 任务列表: {self.task_dict.values()}", - module=f"MAA调度器-{self.name}", - ) - - # 尝试次数循环 - for i in range(self.set["RunSet"]["RunTimesLimit"]): - - if self.isInterruptionRequested: - break - - if run_book[mode]: - break - - logger.info( - f"用户: {user[0]} - 模式: {mode_book[mode]} - 尝试次数: {i + 1}/{self.set["RunSet"]["RunTimesLimit"]}", - module=f"MAA调度器-{self.name}", - ) - - # 配置MAA - set = self.set_maa(mode_book[mode], user[2]) - # 记录当前时间 - self.log_start_time = datetime.now() - - # 记录模拟器与ADB路径 - self.emulator_path = Path( - set["Configurations"]["Default"]["Start.EmulatorPath"] - ) - self.emulator_arguments = set["Configurations"]["Default"][ - "Start.EmulatorAddCommand" - ].split() - # 如果是快捷方式,进行解析 - if ( - self.emulator_path.suffix == ".lnk" - and self.emulator_path.exists() - ): - try: - shell = win32com.client.Dispatch("WScript.Shell") - shortcut = shell.CreateShortcut(str(self.emulator_path)) - self.emulator_path = Path(shortcut.TargetPath) - self.emulator_arguments = shortcut.Arguments.split() - except Exception as e: - logger.exception( - f"解析快捷方式时出现异常:{e}", - module=f"MAA调度器-{self.name}", - ) - self.push_info_bar.emit( - "error", - "解析快捷方式时出现异常", - "请检查快捷方式", - -1, - ) - self.if_open_emulator = True - break - elif not self.emulator_path.exists(): - logger.error( - f"模拟器快捷方式不存在:{self.emulator_path}", - module=f"MAA调度器-{self.name}", - ) - self.push_info_bar.emit( - "error", - "启动模拟器时出现异常", - "模拟器快捷方式不存在", - -1, - ) - self.if_open_emulator = True - break - - self.wait_time = int( - set["Configurations"]["Default"][ - "Start.EmulatorWaitSeconds" - ] - ) - - self.ADB_path = Path( - set["Configurations"]["Default"]["Connect.AdbPath"] - ) - self.ADB_path = ( - self.ADB_path - if self.ADB_path.is_absolute() - else self.maa_root_path / self.ADB_path - ) - self.ADB_address = set["Configurations"]["Default"][ - "Connect.Address" - ] - self.if_kill_emulator = bool( - set["Configurations"]["Default"]["MainFunction.PostActions"] - == "12" - ) - self.if_open_emulator_process = bool( - set["Configurations"]["Default"][ - "Start.OpenEmulatorAfterLaunch" - ] - == "True" - ) - - # 任务开始前释放ADB - try: - logger.info( - f"释放ADB:{self.ADB_address}", - module=f"MAA调度器-{self.name}", - ) - subprocess.run( - [self.ADB_path, "disconnect", self.ADB_address], - creationflags=subprocess.CREATE_NO_WINDOW, - ) - except subprocess.CalledProcessError as e: - # 忽略错误,因为可能本来就没有连接 - logger.warning( - f"释放ADB时出现异常:{e}", - module=f"MAA调度器-{self.name}", - ) - except Exception as e: - logger.exception( - f"释放ADB时出现异常:{e}", - module=f"MAA调度器-{self.name}", - ) - self.push_info_bar.emit( - "error", - "释放ADB时出现异常", - "请检查MAA中ADB路径设置", - -1, - ) - - if self.if_open_emulator_process: - try: - logger.info( - f"启动模拟器:{self.emulator_path},参数:{self.emulator_arguments}", - module=f"MAA调度器-{self.name}", - ) - self.emulator_process_manager.open_process( - self.emulator_path, self.emulator_arguments, 0 - ) - except Exception as e: - logger.exception( - f"启动模拟器时出现异常:{e}", - module=f"MAA调度器-{self.name}", - ) - self.push_info_bar.emit( - "error", - "启动模拟器时出现异常", - "请检查MAA中模拟器路径设置", - -1, - ) - self.if_open_emulator = True - break - - # 更新静默进程标记有效时间 - logger.info( - f"更新静默进程标记:{self.emulator_path},标记有效时间:{datetime.now() + timedelta(seconds=self.wait_time + 10)}", - module=f"MAA调度器-{self.name}", - ) - Config.silence_dict[self.emulator_path] = ( - datetime.now() + timedelta(seconds=self.wait_time + 10) - ) - - self.search_ADB_address() - - # 创建MAA任务 - logger.info( - f"启动MAA进程:{self.maa_exe_path}", - module=f"MAA调度器-{self.name}", - ) - self.maa_process_manager.open_process(self.maa_exe_path, [], 0) - - # 监测MAA运行状态 - self.log_check_mode = mode_book[mode] - self.start_monitor() - - # 处理MAA结果 - if self.maa_result == "Success!": - - # 标记任务完成 - run_book[mode] = True - - logger.info( - f"用户: {user[0]} - MAA进程完成代理任务", - module=f"MAA调度器-{self.name}", - ) - self.update_log_text.emit( - "检测到MAA进程完成代理任务\n正在等待相关程序结束\n请等待10s" - ) - - else: - logger.error( - f"用户: {user[0]} - 代理任务异常: {self.maa_result}", - module=f"MAA调度器-{self.name}", - ) - # 打印中止信息 - # 此时,log变量内存储的就是出现异常的日志信息,可以保存或发送用于问题排查 - self.update_log_text.emit( - f"{self.maa_result}\n正在中止相关程序\n请等待10s" - ) - # 无命令行中止MAA与其子程序 - logger.info( - f"中止MAA进程:{self.maa_exe_path}", - module=f"MAA调度器-{self.name}", - ) - self.maa_process_manager.kill(if_force=True) - System.kill_process(self.maa_exe_path) - - # 中止模拟器进程 - logger.info( - f"中止模拟器进程:{list(self.emulator_process_manager.tracked_pids)}", - module=f"MAA调度器-{self.name}", - ) - self.emulator_process_manager.kill() - - self.if_open_emulator = True - - # 推送异常通知 - Notify.push_plyer( - "用户自动代理出现异常!", - f"用户 {user[0].replace("_", " 今天的")}的{mode_book[mode][5:7]}部分出现一次异常", - f"{user[0].replace("_", " ")}的{mode_book[mode][5:7]}出现异常", - 1, - ) - if i == self.set["RunSet"]["RunTimesLimit"] - 1: - self.play_sound.emit("子任务失败") - else: - self.play_sound.emit(self.maa_result) - - self.sleep(10) - - # 任务结束后释放ADB - try: - logger.info( - f"释放ADB:{self.ADB_address}", - module=f"MAA调度器-{self.name}", - ) - subprocess.run( - [self.ADB_path, "disconnect", self.ADB_address], - creationflags=subprocess.CREATE_NO_WINDOW, - ) - except subprocess.CalledProcessError as e: - # 忽略错误,因为可能本来就没有连接 - logger.warning( - f"释放ADB时出现异常:{e}", - module=f"MAA调度器-{self.name}", - ) - except Exception as e: - logger.exception( - f"释放ADB时出现异常:{e}", - module=f"MAA调度器-{self.name}", - ) - self.push_info_bar.emit( - "error", - "释放ADB时出现异常", - "请检查MAA中ADB路径设置", - -1, - ) - # 任务结束后再次手动中止模拟器进程,防止退出不彻底 - if self.if_kill_emulator: - logger.info( - f"任务结束后再次中止模拟器进程:{list(self.emulator_process_manager.tracked_pids)}", - module=f"MAA调度器-{self.name}", - ) - self.emulator_process_manager.kill() - self.if_open_emulator = True - - # 从配置文件中解析所需信息 - with self.maa_set_path.open(mode="r", encoding="utf-8") as f: - data = json.load(f) - - # 记录自定义基建索引 - user_data["Data"]["CustomInfrastPlanIndex"] = data[ - "Configurations" - ]["Default"]["Infrast.CustomInfrastPlanIndex"] - - # 记录更新包路径 - if ( - data["Global"]["VersionUpdate.package"] - and ( - self.maa_root_path - / data["Global"]["VersionUpdate.package"] - ).exists() - ): - self.maa_update_package = data["Global"][ - "VersionUpdate.package" - ] - - # 记录剿灭情况 - if ( - mode == "Annihilation" - and self.weekly_annihilation_limit_reached - ): - user_data["Data"]["LastAnnihilationDate"] = curdate - # 保存运行日志以及统计信息 - if_six_star = Config.save_maa_log( - Config.app_path - / f"history/{curdate}/{user_data["Info"]["Name"]}/{self.log_start_time.strftime("%H-%M-%S")}.log", - self.maa_logs, - self.maa_result, - ) - user_logs_list.append( - Config.app_path - / f"history/{curdate}/{user_data["Info"]["Name"]}/{self.log_start_time.strftime("%H-%M-%S")}.json", - ) - if if_six_star: - self.push_notification( - "公招六星", - f"喜报:用户 {user[0]} 公招出六星啦!", - { - "user_name": user_data["Info"]["Name"], - }, - user_data, - ) - self.play_sound.emit("六星喜报") - - # 执行MAA解压更新动作 - if self.maa_update_package: - - logger.info( - f"检测到MAA更新,正在执行更新动作", - module=f"MAA调度器-{self.name}", - ) - - self.update_log_text.emit( - f"检测到MAA存在更新\nMAA正在执行更新动作\n请等待10s" - ) - self.play_sound.emit("MAA更新") - self.set_maa("更新MAA", None) - subprocess.Popen( - [self.maa_exe_path], - creationflags=subprocess.CREATE_NO_WINDOW, - ) - self.sleep(10) - System.kill_process(self.maa_exe_path) - - self.maa_update_package = "" - - logger.info( - f"更新动作结束", module=f"MAA调度器-{self.name}" - ) - - # 发送统计信息 - statistics = Config.merge_statistic_info(user_logs_list) - statistics["user_index"] = user[2] - statistics["user_info"] = user[0] - statistics["start_time"] = user_start_time.strftime("%Y-%m-%d %H:%M:%S") - statistics["end_time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - statistics["maa_result"] = ( - "代理任务全部完成" - if (run_book["Annihilation"] and run_book["Routine"]) - else "代理任务未全部完成" - ) - self.push_notification( - "统计信息", - f"{current_date} | 用户 {user[0]} 的自动代理统计报告", - statistics, - user_data, - ) - - if run_book["Annihilation"] and run_book["Routine"]: - # 成功完成代理的用户修改相关参数 - if ( - user_data["Data"]["ProxyTimes"] == 0 - and user_data["Info"]["RemainedDay"] != -1 - ): - user_data["Info"]["RemainedDay"] -= 1 - user_data["Data"]["ProxyTimes"] += 1 - user[1] = "完成" - logger.success( - f"用户 {user[0]} 的自动代理任务已完成", - module=f"MAA调度器-{self.name}", - ) - Notify.push_plyer( - "成功完成一个自动代理任务!", - f"已完成用户 {user[0].replace("_", " 今天的")}任务", - f"已完成 {user[0].replace("_", " 的")}", - 3, - ) - else: - # 录入代理失败的用户 - logger.error( - f"用户 {user[0]} 的自动代理任务未完成", - module=f"MAA调度器-{self.name}", - ) - user[1] = "异常" - - self.update_user_list.emit(self.user_list) - - # 人工排查模式 - elif self.mode == "人工排查": - - # 人工排查时,屏蔽静默操作 - logger.info( - "人工排查任务开始,屏蔽静默操作", module=f"MAA调度器-{self.name}" - ) - Config.if_ignore_silence = True - - # 标记是否需要启动模拟器 - self.if_open_emulator = True - # 标识排查模式 - for _ in self.user_list: - _[0] += "_排查模式" - - # 开始排查 - for user in self.user_list: - - user_data = self.data[user[2]]["Config"] - - if self.isInterruptionRequested: - break - - logger.info(f"开始排查用户: {user[0]}", module=f"MAA调度器-{self.name}") - - user[1] = "运行" - self.update_user_list.emit(self.user_list) - - if user_data["Info"]["Mode"] == "详细": - self.if_open_emulator = True - - run_book = [False for _ in range(2)] - - # 启动重试循环 - while not self.isInterruptionRequested: - - # 配置MAA - self.set_maa("人工排查", user[2]) - - # 记录当前时间 - self.log_start_time = datetime.now() - # 创建MAA任务 - logger.info( - f"启动MAA进程:{self.maa_exe_path}", - module=f"MAA调度器-{self.name}", - ) - self.maa_process_manager.open_process(self.maa_exe_path, [], 0) - - # 监测MAA运行状态 - self.log_check_mode = "人工排查" - self.start_monitor() - - if self.maa_result == "Success!": - logger.info( - f"用户: {user[0]} - MAA进程成功登录PRTS", - module=f"MAA调度器-{self.name}", - ) - run_book[0] = True - self.update_log_text.emit("检测到MAA进程成功登录PRTS") - else: - logger.error( - f"用户: {user[0]} - MAA未能正确登录到PRTS: {self.maa_result}", - module=f"MAA调度器-{self.name}", - ) - self.update_log_text.emit( - f"{self.maa_result}\n正在中止相关程序\n请等待10s" - ) - # 无命令行中止MAA与其子程序 - logger.info( - f"中止MAA进程:{self.maa_exe_path}", - module=f"MAA调度器-{self.name}", - ) - self.maa_process_manager.kill(if_force=True) - System.kill_process(self.maa_exe_path) - self.if_open_emulator = True - self.sleep(10) - - # 登录成功,结束循环 - if run_book[0]: - break - # 登录失败,询问是否结束循环 - elif not self.isInterruptionRequested: - - self.play_sound.emit("排查重试") - if not self.push_question( - "操作提示", "MAA未能正确登录到PRTS,是否重试?" - ): - break - - # 登录成功,录入人工排查情况 - if run_book[0] and not self.isInterruptionRequested: - - self.play_sound.emit("排查录入") - if self.push_question( - "操作提示", "请检查用户代理情况,该用户是否正确完成代理任务?" - ): - run_book[1] = True - - # 结果录入 - if run_book[0] and run_book[1]: - logger.info( - f"用户 {user[0]} 通过人工排查", module=f"MAA调度器-{self.name}" - ) - user_data["Data"]["IfPassCheck"] = True - user[1] = "完成" - else: - logger.info( - f"用户 {user[0]} 未通过人工排查", - module=f"MAA调度器-{self.name}", - ) - user_data["Data"]["IfPassCheck"] = False - user[1] = "异常" - - self.update_user_list.emit(self.user_list) - - # 解除静默操作屏蔽 - logger.info( - "人工排查任务结束,解除静默操作屏蔽", module=f"MAA调度器-{self.name}" - ) - Config.if_ignore_silence = False - - # 设置MAA模式 - elif "设置MAA" in self.mode: - - # 配置MAA - self.set_maa(self.mode, "") - # 创建MAA任务 - logger.info( - f"启动MAA进程:{self.maa_exe_path}", module=f"MAA调度器-{self.name}" - ) - self.maa_process_manager.open_process(self.maa_exe_path, [], 0) - # 记录当前时间 - self.log_start_time = datetime.now() - - # 监测MAA运行状态 - self.log_check_mode = "设置MAA" - self.start_monitor() - - if "全局" in self.mode: - (self.config_path / "Default").mkdir(parents=True, exist_ok=True) - shutil.copy(self.maa_set_path, self.config_path / "Default") - logger.success( - f"全局MAA配置文件已保存到 {self.config_path / 'Default/gui.json'}", - module=f"MAA调度器-{self.name}", - ) - - elif "用户" in self.mode: - self.user_config_path.mkdir(parents=True, exist_ok=True) - shutil.copy(self.maa_set_path, self.user_config_path) - logger.success( - f"用户MAA配置文件已保存到 {self.user_config_path}", - module=f"MAA调度器-{self.name}", - ) - - result_text = "" - - # 导出结果 - if self.mode in ["自动代理", "人工排查"]: - - # 关闭可能未正常退出的MAA进程 - if self.isInterruptionRequested: - logger.info( - f"关闭可能未正常退出的MAA进程:{self.maa_exe_path}", - module=f"MAA调度器-{self.name}", - ) - self.maa_process_manager.kill(if_force=True) - System.kill_process(self.maa_exe_path) - - # 更新用户数据 - updated_info = {_[2]: self.data[_[2]] for _ in self.user_list} - self.update_user_info.emit(self.config_path.name, updated_info) - - error_index = [_[2] for _ in self.user_list if _[1] == "异常"] - over_index = [_[2] for _ in self.user_list if _[1] == "完成"] - wait_index = [_[2] for _ in self.user_list if _[1] == "等待"] - - # 保存运行日志 - title = ( - f"{current_date} | {self.set["MaaSet"]["Name"]}的{self.mode[:4]}任务报告" - if self.set["MaaSet"]["Name"] != "" - else f"{current_date} | {self.mode[:4]}任务报告" - ) - result = { - "title": f"{self.mode[:4]}任务报告", - "script_name": ( - self.set["MaaSet"]["Name"] - if self.set["MaaSet"]["Name"] != "" - else "空白" - ), - "start_time": begin_time, - "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "completed_count": len(over_index), - "uncompleted_count": len(error_index) + len(wait_index), - "failed_user": [ - self.data[_]["Config"]["Info"]["Name"] for _ in error_index - ], - "waiting_user": [ - self.data[_]["Config"]["Info"]["Name"] for _ in wait_index - ], - } - - # 生成结果文本 - result_text = ( - f"任务开始时间:{result["start_time"]},结束时间:{result["end_time"]}\n" - f"已完成数:{result["completed_count"]},未完成数:{result["uncompleted_count"]}\n\n" - ) - if len(result["failed_user"]) > 0: - result_text += f"{self.mode[2:4]}未成功的用户:\n{"\n".join(result["failed_user"])}\n" - if len(result["waiting_user"]) > 0: - result_text += f"\n未开始{self.mode[2:4]}的用户:\n{"\n".join(result["waiting_user"])}\n" - - # 推送代理结果通知 - Notify.push_plyer( - title.replace("报告", "已完成!"), - f"已完成用户数:{len(over_index)},未完成用户数:{len(error_index) + len(wait_index)}", - f"已完成用户数:{len(over_index)},未完成用户数:{len(error_index) + len(wait_index)}", - 10, - ) - self.push_notification("代理结果", title, result) - - # 复原 MAA 配置文件 - logger.info( - f"复原 MAA 配置文件:{self.config_path / 'Temp/gui.json'}", - module=f"MAA调度器-{self.name}", - ) - if (self.config_path / "Temp/gui.json").exists(): - shutil.copy(self.config_path / "Temp/gui.json", self.maa_set_path) - shutil.rmtree(self.config_path / "Temp") - - self.agree_bilibili(False) - self.log_monitor.deleteLater() - self.log_monitor_timer.deleteLater() - self.accomplish.emit({"Time": begin_time, "History": result_text}) - - def requestInterruption(self) -> None: - """请求中止任务""" - - logger.info(f"收到任务中止申请", module=f"MAA调度器-{self.name}") - - if len(self.log_monitor.files()) != 0: - self.interrupt.emit() - - self.maa_result = "任务被手动中止" - self.isInterruptionRequested = True - self.wait_loop.quit() - - def push_question(self, title: str, message: str) -> bool: - """推送询问窗口""" - - logger.info( - f"推送询问窗口:{title} - {message}", module=f"MAA调度器-{self.name}" - ) - - self.question.emit(title, message) - self.question_loop.exec() - return self.response - - def __capture_response(self, response: bool) -> None: - """捕获询问窗口的响应""" - logger.info(f"捕获询问窗口响应:{response}", module=f"MAA调度器-{self.name}") - self.response = response - - def sleep(self, time: int) -> None: - """非阻塞型等待""" - - logger.info(f"等待 {time} 秒", module=f"MAA调度器-{self.name}") - QTimer.singleShot(time * 1000, self.wait_loop.quit) - self.wait_loop.exec() - - def search_ADB_address(self) -> None: - """搜索ADB实际地址""" - - self.update_log_text.emit( - f"即将搜索ADB实际地址\n正在等待模拟器完成启动\n请等待{self.wait_time}s" - ) - - self.sleep(self.wait_time) - - if self.isInterruptionRequested: - return None - - if "-" in self.ADB_address: - ADB_ip = f"{self.ADB_address.split("-")[0]}-" - ADB_port = int(self.ADB_address.split("-")[1]) - - elif ":" in self.ADB_address: - ADB_ip = f"{self.ADB_address.split(':')[0]}:" - ADB_port = int(self.ADB_address.split(":")[1]) - - logger.info( - f"正在搜索ADB实际地址,ADB前缀:{ADB_ip},初始端口:{ADB_port},搜索范围:{self.port_range}", - module=f"MAA调度器-{self.name}", - ) - - for port in self.port_range: - - ADB_address = f"{ADB_ip}{ADB_port + port}" - - # 尝试通过ADB连接到指定地址 - connect_result = subprocess.run( - [self.ADB_path, "connect", ADB_address], - creationflags=subprocess.CREATE_NO_WINDOW, - stdin=subprocess.DEVNULL, - capture_output=True, - text=True, - encoding="utf-8", - ) - - if "connected" in connect_result.stdout: - - # 检查连接状态 - devices_result = subprocess.run( - [self.ADB_path, "devices"], - creationflags=subprocess.CREATE_NO_WINDOW, - stdin=subprocess.DEVNULL, - capture_output=True, - text=True, - encoding="utf-8", - ) - if ADB_address in devices_result.stdout: - - logger.info( - f"ADB实际地址:{ADB_address}", module=f"MAA调度器-{self.name}" - ) - - # 断开连接 - logger.info( - f"断开ADB连接:{ADB_address}", module=f"MAA调度器-{self.name}" - ) - subprocess.run( - [self.ADB_path, "disconnect", ADB_address], - creationflags=subprocess.CREATE_NO_WINDOW, - ) - - self.ADB_address = ADB_address - - # 覆写当前ADB地址 - logger.info( - f"开始使用实际 ADB 地址覆写:{self.ADB_address}", - module=f"MAA调度器-{self.name}", - ) - self.maa_process_manager.kill(if_force=True) - System.kill_process(self.maa_exe_path) - with self.maa_set_path.open(mode="r", encoding="utf-8") as f: - data = json.load(f) - data["Configurations"]["Default"][ - "Connect.Address" - ] = self.ADB_address - data["Configurations"]["Default"]["Start.EmulatorWaitSeconds"] = "0" - with self.maa_set_path.open(mode="w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=4) - - self.play_sound.emit("ADB成功") - return None - - else: - logger.info( - f"无法连接到ADB地址:{ADB_address}", - module=f"MAA调度器-{self.name}", - ) - else: - logger.info( - f"无法连接到ADB地址:{ADB_address}", module=f"MAA调度器-{self.name}" - ) - - if not self.isInterruptionRequested: - self.play_sound.emit("ADB失败") - - def refresh_maa_log(self) -> None: - """刷新MAA日志""" - - if self.maa_log_path.exists(): - with self.maa_log_path.open(mode="r", encoding="utf-8") as f: - logger.debug( - f"刷新MAA日志:{self.maa_log_path}", module=f"MAA调度器-{self.name}" - ) - else: - logger.warning( - f"MAA日志文件不存在:{self.maa_log_path}", - module=f"MAA调度器-{self.name}", - ) - - # 一分钟内未执行日志变化检查,强制检查一次 - if datetime.now() - self.last_check_time > timedelta(minutes=1): - logger.info("触发 1 分钟超时检查", module=f"MAA调度器-{self.name}") - self.check_maa_log() - - def check_maa_log(self) -> None: - """获取MAA日志并检查以判断MAA程序运行状态""" - - self.last_check_time = datetime.now() - - # 获取日志 - if self.maa_log_path.exists(): - self.maa_logs = [] - if_log_start = False - with self.maa_log_path.open(mode="r", encoding="utf-8") as f: - for entry in f: - if not if_log_start: - try: - entry_time = datetime.strptime( - entry[1:20], "%Y-%m-%d %H:%M:%S" - ) - if entry_time > self.log_start_time: - if_log_start = True - self.maa_logs.append(entry) - except ValueError: - pass - else: - self.maa_logs.append(entry) - else: - logger.warning( - f"MAA日志文件不存在:{self.maa_log_path}", - module=f"MAA调度器-{self.name}", - ) - return None - - log = "".join(self.maa_logs) - - # 更新MAA日志 - if self.maa_process_manager.is_running(): - - self.update_log_text.emit( - "".join(self.maa_logs) - if len(self.maa_logs) < 100 - else "".join(self.maa_logs[-100:]) - ) - - if "自动代理" in self.log_check_mode: - - # 获取最近一条日志的时间 - latest_time = self.log_start_time - for _ in self.maa_logs[::-1]: - try: - if "如果长时间无进一步日志更新,可能需要手动干预。" in _: - continue - latest_time = datetime.strptime(_[1:20], "%Y-%m-%d %H:%M:%S") - break - except ValueError: - pass - - logger.info( - f"MAA最近一条日志时间:{latest_time}", module=f"MAA调度器-{self.name}" - ) - - time_book = { - "自动代理_剿灭": "AnnihilationTimeLimit", - "自动代理_日常": "RoutineTimeLimit", - } - - if self.log_check_mode == "自动代理_剿灭" and "剿灭任务失败" in log: - self.weekly_annihilation_limit_reached = True - else: - self.weekly_annihilation_limit_reached = False - - if "任务出错: StartUp" in log: - self.maa_result = "MAA未能正确登录PRTS" - - elif "任务已全部完成!" in log: - - if "完成任务: StartUp" in log or "完成任务: 开始唤醒" in log: - self.task_dict["WakeUp"] = "False" - if "完成任务: Recruit" in log or "完成任务: 自动公招" in log: - self.task_dict["Recruiting"] = "False" - if "完成任务: Infrast" in log or "完成任务: 基建换班" in log: - self.task_dict["Base"] = "False" - if ( - "完成任务: Fight" in log - or "完成任务: 刷理智" in log - or "剿灭任务失败" in log - ): - self.task_dict["Combat"] = "False" - if "完成任务: Mall" in log or "完成任务: 获取信用及购物" in log: - self.task_dict["Mall"] = "False" - if "完成任务: Award" in log or "完成任务: 领取奖励" in log: - self.task_dict["Mission"] = "False" - if "完成任务: Roguelike" in log or "完成任务: 自动肉鸽" in log: - self.task_dict["AutoRoguelike"] = "False" - if "完成任务: Reclamation" in log or "完成任务: 生息演算" in log: - self.task_dict["Reclamation"] = "False" - - if all(v == "False" for v in self.task_dict.values()): - self.maa_result = "Success!" - else: - self.maa_result = "MAA部分任务执行失败" - - elif "请 「检查连接设置」 → 「尝试重启模拟器与 ADB」 → 「重启电脑」" in log: - self.maa_result = "MAA的ADB连接异常" - - elif "未检测到任何模拟器" in log: - self.maa_result = "MAA未检测到任何模拟器" - - elif "已停止" in log: - self.maa_result = "MAA在完成任务前中止" - - elif ( - "MaaAssistantArknights GUI exited" in log - or not self.maa_process_manager.is_running() - ): - self.maa_result = "MAA在完成任务前退出" - - elif datetime.now() - latest_time > timedelta( - minutes=self.set["RunSet"][time_book[self.log_check_mode]] - ): - self.maa_result = "MAA进程超时" - - elif self.isInterruptionRequested: - self.maa_result = "任务被手动中止" - - else: - self.maa_result = "Wait" - - elif self.log_check_mode == "人工排查": - if "完成任务: StartUp" in log or "完成任务: 开始唤醒" in log: - self.maa_result = "Success!" - elif "请 「检查连接设置」 → 「尝试重启模拟器与 ADB」 → 「重启电脑」" in log: - self.maa_result = "MAA的ADB连接异常" - elif "未检测到任何模拟器" in log: - self.maa_result = "MAA未检测到任何模拟器" - elif "已停止" in log: - self.maa_result = "MAA在完成任务前中止" - elif ( - "MaaAssistantArknights GUI exited" in log - or not self.maa_process_manager.is_running() - ): - self.maa_result = "MAA在完成任务前退出" - elif self.isInterruptionRequested: - self.maa_result = "任务被手动中止" - else: - self.maa_result = "Wait" - - elif self.log_check_mode == "设置MAA": - if ( - "MaaAssistantArknights GUI exited" in log - or not self.maa_process_manager.is_running() - ): - self.maa_result = "Success!" - else: - self.maa_result = "Wait" - - logger.info( - f"MAA日志分析结果:{self.maa_result}", module=f"MAA调度器-{self.name}" - ) - - if self.maa_result != "Wait": - - self.quit_monitor() - - def start_monitor(self) -> None: - """开始监视MAA日志""" - - logger.info( - f"开始监视MAA日志,路径:{self.maa_log_path},日志起始时间:{self.log_start_time},模式:{self.log_check_mode}", - module=f"MAA调度器-{self.name}", - ) - self.log_monitor.addPath(str(self.maa_log_path)) - self.log_monitor_timer.start(1000) - self.last_check_time = datetime.now() - self.monitor_loop.exec() - - def quit_monitor(self) -> None: - """退出MAA日志监视进程""" - - if len(self.log_monitor.files()) != 0: - - logger.info( - f"MAA日志监视器移除路径:{self.maa_log_path}", - module=f"MAA调度器-{self.name}", - ) - self.log_monitor.removePath(str(self.maa_log_path)) - - else: - logger.warning( - f"MAA日志监视器没有正在监看的路径:{self.log_monitor.files()}", - module=f"MAA调度器-{self.name}", - ) - - self.log_monitor_timer.stop() - self.last_check_time = None - self.monitor_loop.quit() - - logger.info("MAA日志监视锁已释放", module=f"MAA调度器-{self.name}") - - def set_maa(self, mode, index) -> dict: - """配置MAA运行参数""" - logger.info( - f"开始配置MAA运行参数: {mode}/{index}", module=f"MAA调度器-{self.name}" - ) - - if "设置MAA" not in self.mode and "更新MAA" not in mode: - - user_data = self.data[index]["Config"] - - if user_data["Info"]["Server"] == "Bilibili": - self.agree_bilibili(True) - else: - self.agree_bilibili(False) - - # 配置MAA前关闭可能未正常退出的MAA进程 - self.maa_process_manager.kill(if_force=True) - System.kill_process(self.maa_exe_path) - - # 预导入MAA配置文件 - if mode == "设置MAA_用户": - if self.user_config_path.exists(): - shutil.copy(self.user_config_path / "gui.json", self.maa_set_path) - else: - shutil.copy( - self.config_path / "Default/gui.json", - self.maa_set_path, - ) - elif (mode in ["设置MAA_全局", "更新MAA"]) or ( - ("自动代理" in mode or "人工排查" in mode) - and user_data["Info"]["Mode"] == "简洁" - ): - shutil.copy( - self.config_path / "Default/gui.json", - self.maa_set_path, - ) - elif "自动代理" in mode and user_data["Info"]["Mode"] == "详细": - shutil.copy( - self.data[index]["Path"] / "Routine/gui.json", self.maa_set_path - ) - elif "人工排查" in mode and user_data["Info"]["Mode"] == "详细": - shutil.copy( - self.data[index]["Path"] / "Routine/gui.json", - self.maa_set_path, - ) - with self.maa_set_path.open(mode="r", encoding="utf-8") as f: - data = json.load(f) - - # 切换配置 - if data["Current"] != "Default": - - data["Configurations"]["Default"] = data["Configurations"][data["Current"]] - data["Current"] = "Default" - - # 时间设置 - for i in range(1, 9): - data["Global"][f"Timer.Timer{i}"] = "False" - - # 自动代理配置 - if "自动代理" in mode: - - if ( - next((i for i, _ in enumerate(self.user_list) if _[2] == index), None) - == len(self.user_list) - 1 - ) or ( - self.data[ - self.user_list[ - next( - (i for i, _ in enumerate(self.user_list) if _[2] == index), - None, - ) - + 1 - ][2] - ]["Config"]["Info"]["Mode"] - == "详细" - ): - data["Configurations"]["Default"][ - "MainFunction.PostActions" - ] = "12" # 完成后退出MAA和模拟器 - else: - method_dict = {"NoAction": "8", "ExitGame": "9", "ExitEmulator": "12"} - data["Configurations"]["Default"]["MainFunction.PostActions"] = ( - method_dict[self.set["RunSet"]["TaskTransitionMethod"]] - ) # 完成后行为 - - data["Configurations"]["Default"][ - "Start.RunDirectly" - ] = "True" # 启动MAA后直接运行 - data["Configurations"]["Default"]["Start.OpenEmulatorAfterLaunch"] = str( - self.if_open_emulator - ) # 启动MAA后自动开启模拟器 - - data["Global"][ - "VersionUpdate.ScheduledUpdateCheck" - ] = "False" # 定时检查更新 - data["Global"][ - "VersionUpdate.AutoDownloadUpdatePackage" - ] = "True" # 自动下载更新包 - data["Global"][ - "VersionUpdate.AutoInstallUpdatePackage" - ] = "False" # 自动安装更新包 - - if Config.get(Config.function_IfSilence): - data["Global"]["Start.MinimizeDirectly"] = "True" # 启动MAA后直接最小化 - data["Global"]["GUI.UseTray"] = "True" # 显示托盘图标 - data["Global"]["GUI.MinimizeToTray"] = "True" # 最小化时隐藏至托盘 - - # 客户端类型 - data["Configurations"]["Default"]["Start.ClientType"] = user_data["Info"][ - "Server" - ] - - # 账号切换 - if user_data["Info"]["Server"] == "Official": - data["Configurations"]["Default"]["Start.AccountName"] = ( - f"{user_data["Info"]["Id"][:3]}****{user_data["Info"]["Id"][7:]}" - if len(user_data["Info"]["Id"]) == 11 - else user_data["Info"]["Id"] - ) - elif user_data["Info"]["Server"] == "Bilibili": - data["Configurations"]["Default"]["Start.AccountName"] = user_data[ - "Info" - ]["Id"] - - # 按预设设定任务 - data["Configurations"]["Default"][ - "TaskQueue.WakeUp.IsChecked" - ] = "True" # 开始唤醒 - data["Configurations"]["Default"]["TaskQueue.Recruiting.IsChecked"] = ( - self.task_dict["Recruiting"] - ) # 自动公招 - data["Configurations"]["Default"]["TaskQueue.Base.IsChecked"] = ( - self.task_dict["Base"] - ) # 基建换班 - data["Configurations"]["Default"]["TaskQueue.Combat.IsChecked"] = ( - self.task_dict["Combat"] - ) # 刷理智 - data["Configurations"]["Default"]["TaskQueue.Mission.IsChecked"] = ( - self.task_dict["Mission"] - ) # 领取奖励 - data["Configurations"]["Default"]["TaskQueue.Mall.IsChecked"] = ( - self.task_dict["Mall"] - ) # 获取信用及购物 - data["Configurations"]["Default"]["TaskQueue.AutoRoguelike.IsChecked"] = ( - self.task_dict["AutoRoguelike"] - ) # 自动肉鸽 - data["Configurations"]["Default"]["TaskQueue.Reclamation.IsChecked"] = ( - self.task_dict["Reclamation"] - ) # 生息演算 - - # 整理任务顺序 - if "剿灭" in mode or user_data["Info"]["Mode"] == "简洁": - - data["Configurations"]["Default"]["TaskQueue.Order.WakeUp"] = "0" - data["Configurations"]["Default"]["TaskQueue.Order.Recruiting"] = "1" - data["Configurations"]["Default"]["TaskQueue.Order.Base"] = "2" - data["Configurations"]["Default"]["TaskQueue.Order.Combat"] = "3" - data["Configurations"]["Default"]["TaskQueue.Order.Mall"] = "4" - data["Configurations"]["Default"]["TaskQueue.Order.Mission"] = "5" - data["Configurations"]["Default"]["TaskQueue.Order.AutoRoguelike"] = "6" - data["Configurations"]["Default"]["TaskQueue.Order.Reclamation"] = "7" - - data["Configurations"]["Default"]["MainFunction.UseMedicine"] = ( - "False" if user_data["Info"]["MedicineNumb"] == 0 else "True" - ) # 吃理智药 - data["Configurations"]["Default"]["MainFunction.UseMedicine.Quantity"] = ( - str(user_data["Info"]["MedicineNumb"]) - ) # 吃理智药数量 - data["Configurations"]["Default"][ - "MainFunction.Series.Quantity" - ] = user_data["Info"][ - "SeriesNumb" - ] # 连战次数 - - if "剿灭" in mode: - - data["Configurations"]["Default"][ - "MainFunction.Stage1" - ] = "Annihilation" # 主关卡 - data["Configurations"]["Default"][ - "MainFunction.Stage2" - ] = "" # 备选关卡1 - data["Configurations"]["Default"][ - "MainFunction.Stage3" - ] = "" # 备选关卡2 - data["Configurations"]["Default"][ - "Fight.RemainingSanityStage" - ] = "" # 剩余理智关卡 - data["Configurations"]["Default"][ - "MainFunction.Series.Quantity" - ] = "1" # 连战次数 - data["Configurations"]["Default"][ - "MainFunction.Annihilation.UseCustom" - ] = "True" # 自定义剿灭关卡 - data["Configurations"]["Default"][ - "MainFunction.Annihilation.Stage" - ] = user_data["Info"][ - "Annihilation" - ] # 自定义剿灭关卡号 - data["Configurations"]["Default"][ - "Penguin.IsDrGrandet" - ] = "False" # 博朗台模式 - data["Configurations"]["Default"][ - "GUI.CustomStageCode" - ] = "True" # 手动输入关卡名 - data["Configurations"]["Default"][ - "GUI.UseAlternateStage" - ] = "False" # 使用备选关卡 - data["Configurations"]["Default"][ - "Fight.UseRemainingSanityStage" - ] = "False" # 使用剩余理智 - data["Configurations"]["Default"][ - "Fight.UseExpiringMedicine" - ] = "True" # 无限吃48小时内过期的理智药 - data["Configurations"]["Default"][ - "GUI.HideSeries" - ] = "False" # 隐藏连战次数 - - elif "日常" in mode: - - data["Configurations"]["Default"]["MainFunction.Stage1"] = ( - user_data["Info"]["Stage"] - if user_data["Info"]["Stage"] != "-" - else "" - ) # 主关卡 - data["Configurations"]["Default"]["MainFunction.Stage2"] = ( - user_data["Info"]["Stage_1"] - if user_data["Info"]["Stage_1"] != "-" - else "" - ) # 备选关卡1 - data["Configurations"]["Default"]["MainFunction.Stage3"] = ( - user_data["Info"]["Stage_2"] - if user_data["Info"]["Stage_2"] != "-" - else "" - ) # 备选关卡2 - data["Configurations"]["Default"]["MainFunction.Stage4"] = ( - user_data["Info"]["Stage_3"] - if user_data["Info"]["Stage_3"] != "-" - else "" - ) # 备选关卡3 - data["Configurations"]["Default"]["Fight.RemainingSanityStage"] = ( - user_data["Info"]["Stage_Remain"] - if user_data["Info"]["Stage_Remain"] != "-" - else "" - ) # 剩余理智关卡 - data["Configurations"]["Default"][ - "GUI.UseAlternateStage" - ] = "True" # 备选关卡 - data["Configurations"]["Default"]["Fight.UseRemainingSanityStage"] = ( - "True" if user_data["Info"]["Stage_Remain"] != "-" else "False" - ) # 使用剩余理智 - - if user_data["Info"]["Mode"] == "简洁": - - data["Configurations"]["Default"][ - "Penguin.IsDrGrandet" - ] = "False" # 博朗台模式 - data["Configurations"]["Default"][ - "GUI.CustomStageCode" - ] = "True" # 手动输入关卡名 - data["Configurations"]["Default"][ - "Fight.UseExpiringMedicine" - ] = "True" # 无限吃48小时内过期的理智药 - # 自定义基建配置 - if user_data["Info"]["InfrastMode"] == "Custom": - - if ( - self.data[index]["Path"] - / "Infrastructure/infrastructure.json" - ).exists(): - - data["Configurations"]["Default"][ - "Infrast.InfrastMode" - ] = "Custom" # 基建模式 - data["Configurations"]["Default"][ - "Infrast.CustomInfrastPlanIndex" - ] = user_data["Data"][ - "CustomInfrastPlanIndex" - ] # 自定义基建配置索引 - data["Configurations"]["Default"][ - "Infrast.DefaultInfrast" - ] = "user_defined" # 内置配置 - data["Configurations"]["Default"][ - "Infrast.IsCustomInfrastFileReadOnly" - ] = "False" # 自定义基建配置文件只读 - data["Configurations"]["Default"][ - "Infrast.CustomInfrastFile" - ] = str( - self.data[index]["Path"] - / "Infrastructure/infrastructure.json" - ) # 自定义基建配置文件地址 - else: - logger.warning( - f"未选择用户 {user_data["Info"]["Name"]} 的自定义基建配置文件" - ) - self.push_info_bar.emit( - "warning", - "启用自定义基建失败", - f"未选择用户 {user_data["Info"]["Name"]} 的自定义基建配置文件", - -1, - ) - data["Configurations"]["Default"][ - "Infrast.CustomInfrastEnabled" - ] = "Normal" # 基建模式 - else: - data["Configurations"]["Default"][ - "Infrast.InfrastMode" - ] = user_data["Info"][ - "InfrastMode" - ] # 基建模式 - - elif user_data["Info"]["Mode"] == "详细": - - # 基建模式 - if ( - data["Configurations"]["Default"]["Infrast.InfrastMode"] - == "Custom" - ): - data["Configurations"]["Default"][ - "Infrast.CustomInfrastPlanIndex" - ] = user_data["Data"][ - "CustomInfrastPlanIndex" - ] # 自定义基建配置索引 - - # 人工排查配置 - elif "人工排查" in mode: - - data["Configurations"]["Default"][ - "MainFunction.PostActions" - ] = "8" # 完成后退出MAA - data["Configurations"]["Default"][ - "Start.RunDirectly" - ] = "True" # 启动MAA后直接运行 - data["Global"]["Start.MinimizeDirectly"] = "True" # 启动MAA后直接最小化 - data["Global"]["GUI.UseTray"] = "True" # 显示托盘图标 - data["Global"]["GUI.MinimizeToTray"] = "True" # 最小化时隐藏至托盘 - data["Configurations"]["Default"]["Start.OpenEmulatorAfterLaunch"] = str( - self.if_open_emulator - ) # 启动MAA后自动开启模拟器 - data["Global"][ - "VersionUpdate.ScheduledUpdateCheck" - ] = "False" # 定时检查更新 - data["Global"][ - "VersionUpdate.AutoDownloadUpdatePackage" - ] = "False" # 自动下载更新包 - data["Global"][ - "VersionUpdate.AutoInstallUpdatePackage" - ] = "False" # 自动安装更新包 - - # 客户端类型 - data["Configurations"]["Default"]["Start.ClientType"] = user_data["Info"][ - "Server" - ] - - # 账号切换 - if user_data["Info"]["Server"] == "Official": - data["Configurations"]["Default"]["Start.AccountName"] = ( - f"{user_data["Info"]["Id"][:3]}****{user_data["Info"]["Id"][7:]}" - if len(user_data["Info"]["Id"]) == 11 - else user_data["Info"]["Id"] - ) - elif user_data["Info"]["Server"] == "Bilibili": - data["Configurations"]["Default"]["Start.AccountName"] = user_data[ - "Info" - ]["Id"] - - data["Configurations"]["Default"][ - "TaskQueue.WakeUp.IsChecked" - ] = "True" # 开始唤醒 - data["Configurations"]["Default"][ - "TaskQueue.Recruiting.IsChecked" - ] = "False" # 自动公招 - data["Configurations"]["Default"][ - "TaskQueue.Base.IsChecked" - ] = "False" # 基建换班 - data["Configurations"]["Default"][ - "TaskQueue.Combat.IsChecked" - ] = "False" # 刷理智 - data["Configurations"]["Default"][ - "TaskQueue.Mission.IsChecked" - ] = "False" # 领取奖励 - data["Configurations"]["Default"][ - "TaskQueue.Mall.IsChecked" - ] = "False" # 获取信用及购物 - data["Configurations"]["Default"][ - "TaskQueue.AutoRoguelike.IsChecked" - ] = "False" # 自动肉鸽 - data["Configurations"]["Default"][ - "TaskQueue.Reclamation.IsChecked" - ] = "False" # 生息演算 - - # 设置MAA配置 - elif "设置MAA" in mode: - - data["Configurations"]["Default"][ - "MainFunction.PostActions" - ] = "0" # 完成后无动作 - data["Configurations"]["Default"][ - "Start.RunDirectly" - ] = "False" # 启动MAA后直接运行 - data["Configurations"]["Default"][ - "Start.OpenEmulatorAfterLaunch" - ] = "False" # 启动MAA后自动开启模拟器 - data["Global"][ - "VersionUpdate.ScheduledUpdateCheck" - ] = "False" # 定时检查更新 - data["Global"][ - "VersionUpdate.AutoDownloadUpdatePackage" - ] = "False" # 自动下载更新包 - data["Global"][ - "VersionUpdate.AutoInstallUpdatePackage" - ] = "False" # 自动安装更新包 - - if Config.get(Config.function_IfSilence): - data["Global"][ - "Start.MinimizeDirectly" - ] = "False" # 启动MAA后直接最小化 - - data["Configurations"]["Default"][ - "TaskQueue.WakeUp.IsChecked" - ] = "False" # 开始唤醒 - data["Configurations"]["Default"][ - "TaskQueue.Recruiting.IsChecked" - ] = "False" # 自动公招 - data["Configurations"]["Default"][ - "TaskQueue.Base.IsChecked" - ] = "False" # 基建换班 - data["Configurations"]["Default"][ - "TaskQueue.Combat.IsChecked" - ] = "False" # 刷理智 - data["Configurations"]["Default"][ - "TaskQueue.Mission.IsChecked" - ] = "False" # 领取奖励 - data["Configurations"]["Default"][ - "TaskQueue.Mall.IsChecked" - ] = "False" # 获取信用及购物 - data["Configurations"]["Default"][ - "TaskQueue.AutoRoguelike.IsChecked" - ] = "False" # 自动肉鸽 - data["Configurations"]["Default"][ - "TaskQueue.Reclamation.IsChecked" - ] = "False" # 生息演算 - - elif mode == "更新MAA": - - data["Configurations"]["Default"][ - "MainFunction.PostActions" - ] = "0" # 完成后无动作 - data["Configurations"]["Default"][ - "Start.RunDirectly" - ] = "False" # 启动MAA后直接运行 - data["Configurations"]["Default"][ - "Start.OpenEmulatorAfterLaunch" - ] = "False" # 启动MAA后自动开启模拟器 - data["Global"]["Start.MinimizeDirectly"] = "True" # 启动MAA后直接最小化 - data["Global"]["GUI.UseTray"] = "True" # 显示托盘图标 - data["Global"]["GUI.MinimizeToTray"] = "True" # 最小化时隐藏至托盘 - data["Global"][ - "VersionUpdate.package" - ] = self.maa_update_package # 更新包路径 - - data["Global"][ - "VersionUpdate.ScheduledUpdateCheck" - ] = "False" # 定时检查更新 - data["Global"][ - "VersionUpdate.AutoDownloadUpdatePackage" - ] = "False" # 自动下载更新包 - data["Global"][ - "VersionUpdate.AutoInstallUpdatePackage" - ] = "True" # 自动安装更新包 - data["Configurations"]["Default"][ - "TaskQueue.WakeUp.IsChecked" - ] = "False" # 开始唤醒 - data["Configurations"]["Default"][ - "TaskQueue.Recruiting.IsChecked" - ] = "False" # 自动公招 - data["Configurations"]["Default"][ - "TaskQueue.Base.IsChecked" - ] = "False" # 基建换班 - data["Configurations"]["Default"][ - "TaskQueue.Combat.IsChecked" - ] = "False" # 刷理智 - data["Configurations"]["Default"][ - "TaskQueue.Mission.IsChecked" - ] = "False" # 领取奖励 - data["Configurations"]["Default"][ - "TaskQueue.Mall.IsChecked" - ] = "False" # 获取信用及购物 - data["Configurations"]["Default"][ - "TaskQueue.AutoRoguelike.IsChecked" - ] = "False" # 自动肉鸽 - data["Configurations"]["Default"][ - "TaskQueue.Reclamation.IsChecked" - ] = "False" # 生息演算 - - # 启动模拟器仅生效一次 - if "设置MAA" not in mode and "更新MAA" not in mode and self.if_open_emulator: - self.if_open_emulator = False - - # 覆写配置文件 - with self.maa_set_path.open(mode="w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=4) - - logger.success( - f"MAA运行参数配置完成: {mode}/{index}", module=f"MAA调度器-{self.name}" - ) - - return data - - def agree_bilibili(self, if_agree): - """向MAA写入Bilibili协议相关任务""" - logger.info( - f"Bilibili协议相关任务状态: {'启用' if if_agree else '禁用'}", - module=f"MAA调度器-{self.name}", - ) - - with self.maa_tasks_path.open(mode="r", encoding="utf-8") as f: - data = json.load(f) - - if if_agree and Config.get(Config.function_IfAgreeBilibili): - data["BilibiliAgreement_AUTO"] = { - "algorithm": "OcrDetect", - "action": "ClickSelf", - "text": ["同意"], - "maxTimes": 5, - "Doc": "关闭B服用户协议", - "next": ["StartUpThemes#next"], - } - if "BilibiliAgreement_AUTO" not in data["StartUpThemes"]["next"]: - data["StartUpThemes"]["next"].insert(0, "BilibiliAgreement_AUTO") - else: - if "BilibiliAgreement_AUTO" in data: - data.pop("BilibiliAgreement_AUTO") - if "BilibiliAgreement_AUTO" in data["StartUpThemes"]["next"]: - data["StartUpThemes"]["next"].remove("BilibiliAgreement_AUTO") - - with self.maa_tasks_path.open(mode="w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=4) - - def push_notification( - self, - mode: str, - title: str, - message: Union[str, dict], - user_data: Dict[str, Dict[str, Union[str, int, bool]]] = None, - ) -> None: - """通过所有渠道推送通知""" - logger.info( - f"开始推送通知,模式:{mode},标题:{title}", - module=f"MAA调度器-{self.name}", - ) - - env = Environment( - loader=FileSystemLoader(str(Config.app_path / "resources/html")) - ) - - if mode == "代理结果" and ( - Config.get(Config.notify_SendTaskResultTime) == "任何时刻" - or ( - Config.get(Config.notify_SendTaskResultTime) == "仅失败时" - and message["uncompleted_count"] != 0 - ) - ): - # 生成文本通知内容 - message_text = ( - f"任务开始时间:{message["start_time"]},结束时间:{message["end_time"]}\n" - f"已完成数:{message["completed_count"]},未完成数:{message["uncompleted_count"]}\n\n" - ) - - if len(message["failed_user"]) > 0: - message_text += f"{self.mode[2:4]}未成功的用户:\n{"\n".join(message["failed_user"])}\n" - if len(message["waiting_user"]) > 0: - message_text += f"\n未开始{self.mode[2:4]}的用户:\n{"\n".join(message["waiting_user"])}\n" - - # 生成HTML通知内容 - message["failed_user"] = "、".join(message["failed_user"]) - message["waiting_user"] = "、".join(message["waiting_user"]) - - template = env.get_template("MAA_result.html") - message_html = template.render(message) - - # ServerChan的换行是两个换行符。故而将\n替换为\n\n - serverchan_message = message_text.replace("\n", "\n\n") - - # 发送全局通知 - - if Config.get(Config.notify_IfSendMail): - Notify.send_mail( - "网页", title, message_html, Config.get(Config.notify_ToAddress) - ) - - if Config.get(Config.notify_IfServerChan): - Notify.ServerChanPush( - title, - f"{serverchan_message}\n\nAUTO_MAA 敬上", - Config.get(Config.notify_ServerChanKey), - Config.get(Config.notify_ServerChanTag), - Config.get(Config.notify_ServerChanChannel), - ) - - if Config.get(Config.notify_IfCompanyWebHookBot): - Notify.CompanyWebHookBotPush( - title, - f"{message_text}\n\nAUTO_MAA 敬上", - Config.get(Config.notify_CompanyWebHookBotUrl), - ) - - elif mode == "统计信息": - - # 生成文本通知内容 - formatted = [] - if "drop_statistics" in message: - for stage, items in message["drop_statistics"].items(): - formatted.append(f"掉落统计({stage}):") - for item, quantity in items.items(): - formatted.append(f" {item}: {quantity}") - drop_text = "\n".join(formatted) - - formatted = ["招募统计:"] - if "recruit_statistics" in message: - for star, count in message["recruit_statistics"].items(): - formatted.append(f" {star}: {count}") - recruit_text = "\n".join(formatted) - - message_text = ( - f"开始时间: {message['start_time']}\n" - f"结束时间: {message['end_time']}\n" - f"MAA执行结果: {message['maa_result']}\n\n" - f"{recruit_text}\n" - f"{drop_text}" - ) - - # 生成HTML通知内容 - template = env.get_template("MAA_statistics.html") - message_html = template.render(message) - - # ServerChan的换行是两个换行符。故而将\n替换为\n\n - serverchan_message = message_text.replace("\n", "\n\n") - - # 发送全局通知 - if Config.get(Config.notify_IfSendStatistic): - - if Config.get(Config.notify_IfSendMail): - Notify.send_mail( - "网页", title, message_html, Config.get(Config.notify_ToAddress) - ) - - if Config.get(Config.notify_IfServerChan): - Notify.ServerChanPush( - title, - f"{serverchan_message}\n\nAUTO_MAA 敬上", - Config.get(Config.notify_ServerChanKey), - Config.get(Config.notify_ServerChanTag), - Config.get(Config.notify_ServerChanChannel), - ) - - if Config.get(Config.notify_IfCompanyWebHookBot): - Notify.CompanyWebHookBotPush( - title, - f"{message_text}\n\nAUTO_MAA 敬上", - Config.get(Config.notify_CompanyWebHookBotUrl), - ) - - # 发送用户单独通知 - if ( - user_data["Notify"]["Enabled"] - and user_data["Notify"]["IfSendStatistic"] - ): - - # 发送邮件通知 - if user_data["Notify"]["IfSendMail"]: - if user_data["Notify"]["ToAddress"]: - Notify.send_mail( - "网页", - title, - message_html, - user_data["Notify"]["ToAddress"], - ) - else: - logger.error(f"用户邮箱地址为空,无法发送用户单独的邮件通知") - - # 发送ServerChan通知 - if user_data["Notify"]["IfServerChan"]: - if user_data["Notify"]["ServerChanKey"]: - Notify.ServerChanPush( - title, - f"{serverchan_message}\n\nAUTO_MAA 敬上", - user_data["Notify"]["ServerChanKey"], - user_data["Notify"]["ServerChanTag"], - user_data["Notify"]["ServerChanChannel"], - ) - else: - logger.error( - f"{self.name} |用户ServerChan密钥为空,无法发送用户单独的ServerChan通知" - ) - - # 推送CompanyWebHookBot通知 - if user_data["Notify"]["IfCompanyWebHookBot"]: - if user_data["Notify"]["CompanyWebHookBotUrl"]: - Notify.CompanyWebHookBotPush( - title, - f"{message_text}\n\nAUTO_MAA 敬上", - user_data["Notify"]["CompanyWebHookBotUrl"], - ) - else: - logger.error( - f"{self.name} |用户CompanyWebHookBot密钥为空,无法发送用户单独的CompanyWebHookBot通知" - ) - - elif mode == "公招六星": - - # 生成HTML通知内容 - template = env.get_template("MAA_six_star.html") - - message_html = template.render(message) - - # 发送全局通知 - if Config.get(Config.notify_IfSendSixStar): - - if Config.get(Config.notify_IfSendMail): - Notify.send_mail( - "网页", title, message_html, Config.get(Config.notify_ToAddress) - ) - - if Config.get(Config.notify_IfServerChan): - Notify.ServerChanPush( - title, - "好羡慕~\n\nAUTO_MAA 敬上", - Config.get(Config.notify_ServerChanKey), - Config.get(Config.notify_ServerChanTag), - Config.get(Config.notify_ServerChanChannel), - ) - - if Config.get(Config.notify_IfCompanyWebHookBot): - Notify.CompanyWebHookBotPush( - title, - "好羡慕~\n\nAUTO_MAA 敬上", - Config.get(Config.notify_CompanyWebHookBotUrl), - ) - Notify.CompanyWebHookBotPushImage( - Config.app_path / "resources/images/notification/six_star.png", - Config.get(Config.notify_CompanyWebHookBotUrl), - ) - - # 发送用户单独通知 - if user_data["Notify"]["Enabled"] and user_data["Notify"]["IfSendSixStar"]: - - # 发送邮件通知 - if user_data["Notify"]["IfSendMail"]: - if user_data["Notify"]["ToAddress"]: - Notify.send_mail( - "网页", - title, - message_html, - user_data["Notify"]["ToAddress"], - ) - else: - logger.error(f"用户邮箱地址为空,无法发送用户单独的邮件通知") - - # 发送ServerChan通知 - if user_data["Notify"]["IfServerChan"]: - - if user_data["Notify"]["ServerChanKey"]: - Notify.ServerChanPush( - title, - "好羡慕~\n\nAUTO_MAA 敬上", - user_data["Notify"]["ServerChanKey"], - user_data["Notify"]["ServerChanTag"], - user_data["Notify"]["ServerChanChannel"], - ) - else: - logger.error( - f"{self.name} |用户ServerChan密钥为空,无法发送用户单独的ServerChan通知" - ) - - # 推送CompanyWebHookBot通知 - if user_data["Notify"]["IfCompanyWebHookBot"]: - if user_data["Notify"]["CompanyWebHookBotUrl"]: - Notify.CompanyWebHookBotPush( - title, - "好羡慕~\n\nAUTO_MAA 敬上", - user_data["Notify"]["CompanyWebHookBotUrl"], - ) - Notify.CompanyWebHookBotPushImage( - Config.app_path - / "resources/images/notification/six_star.png", - Config.get(Config.notify_CompanyWebHookBotUrl), - ) - else: - logger.error( - f"{self.name} |用户CompanyWebHookBot密钥为空,无法发送用户单独的CompanyWebHookBot通知" - ) - - return None diff --git a/app/models/__init__.py b/app/models/__init__.py index fdb7653..73060c4 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,6 @@ # AUTO_MAA:A MAA Multi Account Management and Automation Tool # Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox # This file is part of AUTO_MAA. @@ -18,18 +19,11 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA模组包 -v4.4 -作者:DLmaster_361 -""" - -__version__ = "4.2.0" +__version__ = "5.0.0" __author__ = "DLmaster361 " __license__ = "GPL-3.0 license" -from .general import GeneralManager -from .MAA import MaaManager +from .ConfigBase import * +from .schema import * -__all__ = ["GeneralManager", "MaaManager"] +__all__ = ["ConfigBase", "schema"] diff --git a/app/models/general.py b/app/models/general.py deleted file mode 100644 index 8ef8d15..0000000 --- a/app/models/general.py +++ /dev/null @@ -1,1201 +0,0 @@ -# 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 . - -# Contact: DLmaster_361@163.com - -""" -AUTO_MAA -通用功能组件 -v4.4 -作者:DLmaster_361 -""" - -from PySide6.QtCore import QObject, Signal, QEventLoop, QFileSystemWatcher, QTimer -import os -import sys -import shutil -import subprocess -from functools import partial -from datetime import datetime, timedelta -from pathlib import Path -from jinja2 import Environment, FileSystemLoader -from typing import Union, List, Dict - -from app.core import Config, GeneralConfig, GeneralSubConfig, logger -from app.services import Notify, System -from app.utils import ProcessManager - - -class GeneralManager(QObject): - """通用脚本通用控制器""" - - question = Signal(str, str) - question_response = Signal(bool) - update_sub_info = Signal(str, dict) - push_info_bar = Signal(str, str, str, int) - play_sound = Signal(str) - create_user_list = Signal(list) - update_user_list = Signal(list) - update_log_text = Signal(str) - interrupt = Signal() - accomplish = Signal(dict) - - def __init__( - self, - mode: str, - config: Dict[ - str, - Union[ - str, - Path, - GeneralConfig, - Dict[str, Dict[str, Union[Path, GeneralSubConfig]]], - ], - ], - sub_config_path: Path = None, - ): - super(GeneralManager, self).__init__() - - self.sub_list = [] - self.mode = mode - self.config_path = config["Path"] - self.name = config["Config"].get(config["Config"].Script_Name) - self.sub_config_path = sub_config_path - - self.game_process_manager = ProcessManager() - self.script_process_manager = ProcessManager() - - self.log_monitor = QFileSystemWatcher() - self.log_monitor.fileChanged.connect(self.check_script_log) - self.log_monitor_timer = QTimer() - self.log_monitor_timer.timeout.connect(self.refresh_log) - self.monitor_loop = QEventLoop() - self.loge_start_time = datetime.now() - self.script_logs = [] - self.script_result = "Wait" - - self.script_process_manager.processClosed.connect(self.check_script_log) - - self.question_loop = QEventLoop() - self.question_response.connect(self.__capture_response) - self.question_response.connect(self.question_loop.quit) - - self.wait_loop = QEventLoop() - - self.isInterruptionRequested = False - self.interrupt.connect(self.quit_monitor) - - self.task_dict = {} - self.set = config["Config"].toDict() - - self.data: Dict[str, Dict[str, Union[Path, dict]]] = {} - if self.mode != "设置通用脚本": - for name, info in config["SubData"].items(): - self.data[name] = { - "Path": info["Path"], - "Config": info["Config"].toDict(), - } - - self.data = dict(sorted(self.data.items(), key=lambda x: int(x[0][3:]))) - - logger.success( - f"初始化通用调度器,模式:{self.mode}", module=f"通用调度器-{self.name}" - ) - - def check_config_info(self) -> bool: - """检查配置完整性""" - - if not ( - Path(self.set["Script"]["RootPath"]).exists() - and Path(self.set["Script"]["ScriptPath"]).exists() - and Path(self.set["Script"]["ConfigPath"]).exists() - and Path(self.set["Script"]["LogPath"]).parent.exists() - and self.set["Script"]["LogTimeFormat"] - and self.set["Script"]["ErrorLog"] - ) or ( - self.set["Game"]["Enabled"] and not Path(self.set["Game"]["Path"]).exists() - ): - logger.error("脚本配置缺失", module=f"通用调度器-{self.name}") - self.push_info_bar.emit("error", "脚本配置缺失", "请检查脚本配置!", -1) - return False - - return True - - def configure(self): - """提取配置信息""" - - self.script_root_path = Path(self.set["Script"]["RootPath"]) - self.script_path = Path(self.set["Script"]["ScriptPath"]) - - arguments_list = [] - path_list = [] - - for argument in [ - _.strip() - for _ in str(self.set["Script"]["Arguments"]).split("|") - if _.strip() - ]: - arg = [_.strip() for _ in argument.split("%") if _.strip()] - if len(arg) > 1: - path_list.append((self.script_path / arg[0]).resolve()) - arguments_list.append( - [_.strip() for _ in arg[1].split(" ") if _.strip()] - ) - elif len(arg) > 0: - path_list.append(self.script_path) - arguments_list.append( - [_.strip() for _ in arg[0].split(" ") if _.strip()] - ) - - self.script_exe_path = path_list[0] if len(path_list) > 0 else self.script_path - self.script_arguments = arguments_list[0] if len(arguments_list) > 0 else [] - self.script_set_exe_path = ( - path_list[1] if len(path_list) > 1 else self.script_path - ) - self.script_set_arguments = arguments_list[1] if len(arguments_list) > 1 else [] - - self.script_config_path = Path(self.set["Script"]["ConfigPath"]) - self.script_log_path = ( - Path(self.set["Script"]["LogPath"]).with_stem( - datetime.now().strftime(self.set["Script"]["LogPathFormat"]) - ) - if self.set["Script"]["LogPathFormat"] - else Path(self.set["Script"]["LogPath"]) - ) - if not self.script_log_path.exists(): - self.script_log_path.parent.mkdir(parents=True, exist_ok=True) - self.script_log_path.touch(exist_ok=True) - self.game_path = Path(self.set["Game"]["Path"]) - self.log_time_range = [ - self.set["Script"]["LogTimeStart"] - 1, - self.set["Script"]["LogTimeEnd"], - ] - self.success_log = ( - [_.strip() for _ in self.set["Script"]["SuccessLog"].split("|")] - if self.set["Script"]["SuccessLog"] - else [] - ) - self.error_log = [_.strip() for _ in self.set["Script"]["ErrorLog"].split("|")] - - def run(self): - """主进程,运行通用脚本代理进程""" - - current_date = datetime.now().strftime("%m-%d") - curdate = Config.server_date().strftime("%Y-%m-%d") - begin_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - if self.mode == "人工排查": - - logger.error("通用脚本不支持人工排查模式", module=f"通用调度器-{self.name}") - self.accomplish.emit( - { - "Time": begin_time, - "History": "通用脚本不支持人工排查模式,通用代理进程中止", - } - ) - return None - - # 检查配置完整性 - if not self.check_config_info(): - - logger.error( - "配置不完整,无法启动通用代理进程", module=f"通用调度器-{self.name}" - ) - self.accomplish.emit( - {"Time": begin_time, "History": "由于配置不完整,通用代理进程中止"} - ) - return None - - self.configure() - - # 记录配置文件 - logger.info( - f"记录通用脚本配置文件:{self.script_config_path}", - module=f"通用调度器-{self.name}", - ) - (self.config_path / "Temp").mkdir(parents=True, exist_ok=True) - if self.set["Script"]["ConfigPathMode"] == "文件夹": - if self.script_config_path.exists(): - shutil.copytree( - self.script_config_path, - self.config_path / "Temp", - dirs_exist_ok=True, - ) - elif self.script_config_path.exists(): - shutil.copy(self.script_config_path, self.config_path / "Temp/config.temp") - - # 整理用户数据,筛选需代理的用户 - if self.mode != "设置通用脚本": - - self.data = dict(sorted(self.data.items(), key=lambda x: int(x[0][3:]))) - self.sub_list: List[List[str, str, str]] = [ - [_["Config"]["Info"]["Name"], "等待", index] - for index, _ in self.data.items() - if ( - _["Config"]["Info"]["RemainedDay"] != 0 - and _["Config"]["Info"]["Status"] - ) - ] - self.create_user_list.emit(self.sub_list) - - logger.info( - f"配置列表创建完成,已筛选子配置数:{len(self.sub_list)}", - module=f"通用调度器-{self.name}", - ) - - # 自动代理模式 - if self.mode == "自动代理": - - # 执行情况预处理 - for _ in self.sub_list: - if self.data[_[2]]["Config"]["Data"]["LastProxyDate"] != curdate: - self.data[_[2]]["Config"]["Data"]["LastProxyDate"] = curdate - self.data[_[2]]["Config"]["Data"]["ProxyTimes"] = 0 - _[ - 0 - ] += f" - 第{self.data[_[2]]['Config']['Data']['ProxyTimes'] + 1}次代理" - - # 开始代理 - for sub in self.sub_list: - - sub_data = self.data[sub[2]]["Config"] - - if self.isInterruptionRequested: - break - - if ( - self.set["Run"]["ProxyTimesLimit"] == 0 - or sub_data["Data"]["ProxyTimes"] - < self.set["Run"]["ProxyTimesLimit"] - ): - sub[1] = "运行" - self.update_user_list.emit(self.sub_list) - else: - sub[1] = "跳过" - self.update_user_list.emit(self.sub_list) - continue - - logger.info(f"开始代理配置: {sub[0]}", module=f"通用调度器-{self.name}") - - sub_start_time = datetime.now() - - run_book = False - - if not (self.data[sub[2]]["Path"] / "ConfigFiles").exists(): - logger.error( - f"配置: {sub[0]} - 未找到配置文件", - module=f"通用调度器-{self.name}", - ) - self.push_info_bar.emit( - "error", - "启动通用代理进程失败", - f"未找到{sub[0]}的配置文件!", - -1, - ) - run_book = False - continue - - # 尝试次数循环 - for i in range(self.set["Run"]["RunTimesLimit"]): - - if self.isInterruptionRequested or run_book: - break - - logger.info( - f"用户: {sub[0]} - 尝试次数: {i + 1}/{self.set['Run']['RunTimesLimit']}", - module=f"通用调度器-{self.name}", - ) - - # 记录当前时间 - self.log_start_time = datetime.now() - # 配置脚本 - self.set_sub(sub[2]) - # 执行任务前脚本 - if ( - sub_data["Info"]["IfScriptBeforeTask"] - and Path(sub_data["Info"]["ScriptBeforeTask"]).exists() - ): - self.execute_script_task( - Path(sub_data["Info"]["ScriptBeforeTask"]), "脚本前任务" - ) - - # 启动游戏/模拟器 - if self.set["Game"]["Enabled"]: - - try: - logger.info( - f"启动游戏/模拟器:{self.game_path},参数:{self.set['Game']['Arguments']}", - module=f"通用调度器-{self.name}", - ) - self.game_process_manager.open_process( - self.game_path, - str(self.set["Game"]["Arguments"]).split(" "), - 0, - ) - except Exception as e: - logger.exception( - f"启动游戏/模拟器时出现异常:{e}", - module=f"通用调度器-{self.name}", - ) - self.push_info_bar.emit( - "error", - "启动游戏/模拟器时出现异常", - "请检查游戏/模拟器路径设置", - -1, - ) - self.script_result = "游戏/模拟器启动失败" - break - - # 更新静默进程标记 - if self.set["Game"]["Style"] == "Emulator": - logger.info( - f"更新静默进程标记:{self.game_path},标记有效时间:{datetime.now() + timedelta(seconds=self.set['Game']['WaitTime'] + 10)}", - module=f"通用调度器-{self.name}", - ) - Config.silence_dict[ - self.game_path - ] = datetime.now() + timedelta( - seconds=self.set["Game"]["WaitTime"] + 10 - ) - - self.update_log_text.emit( - f"正在等待游戏/模拟器完成启动\n请等待{self.set['Game']['WaitTime']}s" - ) - - self.sleep(self.set["Game"]["WaitTime"]) - - # 运行脚本任务 - logger.info( - f"运行脚本任务:{self.script_exe_path},参数:{self.script_arguments}", - module=f"通用调度器-{self.name}", - ) - self.script_process_manager.open_process( - self.script_exe_path, - self.script_arguments, - tracking_time=60 if self.set["Script"]["IfTrackProcess"] else 0, - ) - - # 监测运行状态 - self.start_monitor() - - if self.script_result == "Success!": - - # 标记任务完成 - run_book = True - - # 中止相关程序 - logger.info( - f"中止相关程序:{self.script_exe_path}", - module=f"通用调度器-{self.name}", - ) - self.script_process_manager.kill() - System.kill_process(self.script_exe_path) - if self.set["Game"]["Enabled"]: - logger.info( - f"中止游戏/模拟器进程:{list(self.game_process_manager.tracked_pids)}", - module=f"通用调度器-{self.name}", - ) - self.game_process_manager.kill() - if self.set["Game"]["IfForceClose"]: - System.kill_process(self.game_path) - - logger.info( - f"配置: {sub[0]} - 通用脚本进程完成代理任务", - module=f"通用调度器-{self.name}", - ) - self.update_log_text.emit( - "检测到通用脚本进程完成代理任务\n正在等待相关程序结束\n请等待10s" - ) - - self.sleep(10) - - # 更新脚本配置文件 - if self.set["Script"]["UpdateConfigMode"] in [ - "Success", - "Always", - ]: - - if self.set["Script"]["ConfigPathMode"] == "文件夹": - shutil.copytree( - self.script_config_path, - self.data[sub[2]]["Path"] / "ConfigFiles", - dirs_exist_ok=True, - ) - else: - shutil.copy( - self.script_config_path, - self.data[sub[2]]["Path"] - / "ConfigFiles" - / self.script_config_path.name, - ) - logger.success( - "通用脚本配置文件已更新", - module=f"通用调度器-{self.name}", - ) - - else: - logger.error( - f"配置: {sub[0]} - 代理任务异常: {self.script_result}", - module=f"通用调度器-{self.name}", - ) - # 打印中止信息 - # 此时,log变量内存储的就是出现异常的日志信息,可以保存或发送用于问题排查 - self.update_log_text.emit( - f"{self.script_result}\n正在中止相关程序\n请等待10s" - ) - - # 中止相关程序 - logger.info( - f"中止相关程序:{self.script_exe_path}", - module=f"通用调度器-{self.name}", - ) - self.script_process_manager.kill() - System.kill_process(self.script_exe_path) - if self.set["Game"]["Enabled"]: - logger.info( - f"中止游戏/模拟器进程:{list(self.game_process_manager.tracked_pids)}", - module=f"通用调度器-{self.name}", - ) - self.game_process_manager.kill() - if self.set["Game"]["IfForceClose"]: - System.kill_process(self.game_path) - - # 推送异常通知 - Notify.push_plyer( - "用户自动代理出现异常!", - f"用户 {sub[0].replace("_", " 今天的")}出现一次异常", - f"{sub[0].replace("_", " ")}出现异常", - 1, - ) - if i == self.set["Run"]["RunTimesLimit"] - 1: - self.play_sound.emit("子任务失败") - else: - self.play_sound.emit(self.script_result) - - self.sleep(10) - - # 更新脚本配置文件 - if self.set["Script"]["UpdateConfigMode"] in [ - "Failure", - "Always", - ]: - - if self.set["Script"]["ConfigPathMode"] == "文件夹": - shutil.copytree( - self.script_config_path, - self.data[sub[2]]["Path"] / "ConfigFiles", - dirs_exist_ok=True, - ) - else: - shutil.copy( - self.script_config_path, - self.data[sub[2]]["Path"] - / "ConfigFiles" - / self.script_config_path.name, - ) - logger.success( - "通用脚本配置文件已更新", - module=f"通用调度器-{self.name}", - ) - - # 执行任务后脚本 - if ( - sub_data["Info"]["IfScriptAfterTask"] - and Path(sub_data["Info"]["ScriptAfterTask"]).exists() - ): - self.execute_script_task( - Path(sub_data["Info"]["ScriptAfterTask"]), "脚本后任务" - ) - - # 保存运行日志以及统计信息 - Config.save_general_log( - Config.app_path - / f"history/{curdate}/{sub_data['Info']['Name']}/{self.log_start_time.strftime("%H-%M-%S")}.log", - self.script_logs, - self.script_result, - ) - - # 发送统计信息 - statistics = { - "sub_index": sub[2], - "sub_info": sub[0], - "start_time": sub_start_time.strftime("%Y-%m-%d %H:%M:%S"), - "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "sub_result": "代理成功" if run_book else self.script_result, - } - self.push_notification( - "统计信息", - f"{current_date} | 配置 {sub[0]} 的自动代理统计报告", - statistics, - sub_data, - ) - - if run_book: - # 成功完成代理的用户修改相关参数 - if ( - sub_data["Data"]["ProxyTimes"] == 0 - and sub_data["Info"]["RemainedDay"] != -1 - ): - sub_data["Info"]["RemainedDay"] -= 1 - sub_data["Data"]["ProxyTimes"] += 1 - sub[1] = "完成" - logger.success( - f"配置: {sub[0]} - 代理任务完成", - module=f"通用调度器-{self.name}", - ) - Notify.push_plyer( - "成功完成一个自动代理任务!", - f"已完成配置 {sub[0].replace("_", " 今天的")}任务", - f"已完成 {sub[0].replace("_", " 的")}", - 3, - ) - else: - # 录入代理失败的用户 - sub[1] = "异常" - logger.error( - f"配置: {sub[0]} - 代理任务异常: {self.script_result}", - module=f"通用调度器-{self.name}", - ) - - self.update_user_list.emit(self.sub_list) - - # 设置通用脚本模式 - elif self.mode == "设置通用脚本": - - # 配置通用脚本 - self.set_sub() - - try: - # 创建通用脚本任务 - logger.info( - f"运行脚本任务:{self.script_set_exe_path},参数:{self.script_set_arguments}", - module=f"通用调度器-{self.name}", - ) - self.script_process_manager.open_process( - self.script_set_exe_path, - self.script_set_arguments, - tracking_time=60 if self.set["Script"]["IfTrackProcess"] else 0, - ) - - # 记录当前时间 - self.log_start_time = datetime.now() - - # 监测通用脚本运行状态 - self.start_monitor() - - self.sub_config_path.mkdir(parents=True, exist_ok=True) - if self.set["Script"]["ConfigPathMode"] == "文件夹": - shutil.copytree( - self.script_config_path, - self.sub_config_path, - dirs_exist_ok=True, - ) - logger.success( - f"通用脚本配置已保存到:{self.sub_config_path}", - module=f"通用调度器-{self.name}", - ) - else: - shutil.copy(self.script_config_path, self.sub_config_path) - logger.success( - f"通用脚本配置已保存到:{self.sub_config_path}", - module=f"通用调度器-{self.name}", - ) - - except Exception as e: - logger.exception( - f"启动通用脚本时出现异常:{e}", module=f"通用调度器-{self.name}" - ) - self.push_info_bar.emit( - "error", "启动通用脚本时出现异常", "请检查相关设置", -1 - ) - - result_text = "" - - # 导出结果 - if self.mode in ["自动代理"]: - - # 关闭可能未正常退出的通用脚本进程 - if self.isInterruptionRequested: - logger.info( - f"关闭可能未正常退出的通用脚本进程:{self.script_exe_path}", - module=f"通用调度器-{self.name}", - ) - self.script_process_manager.kill(if_force=True) - System.kill_process(self.script_exe_path) - if self.set["Game"]["Enabled"]: - logger.info( - f"关闭可能未正常退出的游戏/模拟器进程:{list(self.game_process_manager.tracked_pids)}", - module=f"通用调度器-{self.name}", - ) - self.game_process_manager.kill(if_force=True) - if self.set["Game"]["IfForceClose"]: - System.kill_process(self.game_path) - - # 更新用户数据 - updated_info = {_[2]: self.data[_[2]] for _ in self.sub_list} - self.update_sub_info.emit(self.config_path.name, updated_info) - - error_index = [_[2] for _ in self.sub_list if _[1] == "异常"] - over_index = [_[2] for _ in self.sub_list if _[1] == "完成"] - wait_index = [_[2] for _ in self.sub_list if _[1] == "等待"] - - # 保存运行日志 - title = ( - f"{current_date} | {self.name}的{self.mode[:4]}任务报告" - if self.name != "" - else f"{current_date} | {self.mode[:4]}任务报告" - ) - result = { - "title": f"{self.mode[:4]}任务报告", - "script_name": (self.name if self.name != "" else "空白"), - "start_time": begin_time, - "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "completed_count": len(over_index), - "uncompleted_count": len(error_index) + len(wait_index), - "failed_sub": [ - self.data[_]["Config"]["Info"]["Name"] for _ in error_index - ], - "waiting_sub": [ - self.data[_]["Config"]["Info"]["Name"] for _ in wait_index - ], - } - - # 生成结果文本 - result_text = ( - f"任务开始时间:{result['start_time']},结束时间:{result['end_time']}\n" - f"已完成数:{result['completed_count']},未完成数:{result['uncompleted_count']}\n\n" - ) - if len(result["failed_sub"]) > 0: - result_text += f"{self.mode[2:4]}未成功的配置:\n{"\n".join(result['failed_sub'])}\n" - if len(result["waiting_sub"]) > 0: - result_text += f"\n未开始{self.mode[2:4]}的配置:\n{"\n".join(result['waiting_sub'])}\n" - - # 推送代理结果通知 - Notify.push_plyer( - title.replace("报告", "已完成!"), - f"已完成配置数:{len(over_index)},未完成配置数:{len(error_index) + len(wait_index)}", - f"已完成配置数:{len(over_index)},未完成配置数:{len(error_index) + len(wait_index)}", - 10, - ) - self.push_notification("代理结果", title, result) - - # 复原通用脚本配置文件 - logger.info( - f"复原通用脚本配置文件:{self.config_path / 'Temp'}", - module=f"通用调度器-{self.name}", - ) - if self.set["Script"]["ConfigPathMode"] == "文件夹": - if (self.config_path / "Temp").exists(): - shutil.copytree( - self.config_path / "Temp", - self.script_config_path, - dirs_exist_ok=True, - ) - elif (self.config_path / "Temp/config.temp").exists(): - shutil.copy(self.config_path / "Temp/config.temp", self.script_config_path) - shutil.rmtree(self.config_path / "Temp") - - self.log_monitor.deleteLater() - self.log_monitor_timer.deleteLater() - self.accomplish.emit({"Time": begin_time, "History": result_text}) - - def requestInterruption(self) -> None: - """请求中止通用脚本任务""" - - logger.info(f"收到任务中止申请", module=f"通用调度器-{self.name}") - - if len(self.log_monitor.files()) != 0: - self.interrupt.emit() - - self.script_result = "任务被手动中止" - self.isInterruptionRequested = True - self.wait_loop.quit() - - def push_question(self, title: str, message: str) -> bool: - """推送问题询问""" - - logger.info( - f"推送问题询问:{title} - {message}", module=f"通用调度器-{self.name}" - ) - - self.question.emit(title, message) - self.question_loop.exec() - return self.response - - def __capture_response(self, response: bool) -> None: - """捕获问题询问的响应""" - - logger.info(f"捕获问题询问的响应:{response}", module=f"通用调度器-{self.name}") - self.response = response - - def sleep(self, time: int) -> None: - """非阻塞型等待""" - - logger.info(f"等待 {time} 秒", module=f"通用调度器-{self.name}") - - QTimer.singleShot(time * 1000, self.wait_loop.quit) - self.wait_loop.exec() - - def refresh_log(self) -> None: - """刷新脚本日志""" - - if self.script_log_path.exists(): - with self.script_log_path.open(mode="r", encoding="utf-8") as f: - logger.debug( - f"刷新通用脚本日志:{self.script_log_path}", - module=f"通用调度器-{self.name}", - ) - else: - logger.warning( - f"通用脚本日志文件不存在:{self.script_log_path}", - module=f"通用调度器-{self.name}", - ) - - # 一分钟内未执行日志变化检查,强制检查一次 - if (datetime.now() - self.last_check_time).total_seconds() > 60: - logger.info("触发 1 分钟超时检查", module=f"通用调度器-{self.name}") - self.check_script_log() - - def strptime( - self, date_string: str, format: str, default_date: datetime - ) -> datetime: - """根据指定格式解析日期字符串""" - - # 时间字段映射表 - time_fields = { - "%Y": "year", - "%m": "month", - "%d": "day", - "%H": "hour", - "%M": "minute", - "%S": "second", - "%f": "microsecond", - } - - 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) - - def check_script_log(self) -> None: - """获取脚本日志并检查以判断脚本程序运行状态""" - - self.last_check_time = datetime.now() - - # 获取日志 - if self.script_log_path.exists(): - self.script_logs = [] - if_log_start = False - with self.script_log_path.open(mode="r", encoding="utf-8") as f: - for entry in f: - if not if_log_start: - try: - entry_time = self.strptime( - entry[self.log_time_range[0] : self.log_time_range[1]], - self.set["Script"]["LogTimeFormat"], - self.last_check_time, - ) - - if entry_time > self.log_start_time: - if_log_start = True - self.script_logs.append(entry) - except ValueError: - pass - else: - self.script_logs.append(entry) - else: - logger.warning( - f"通用脚本日志文件不存在:{self.script_log_path}", - module=f"通用调度器-{self.name}", - ) - return None - - log = "".join(self.script_logs) - - # 更新日志 - if self.script_process_manager.is_running(): - - self.update_log_text.emit( - "".join(self.script_logs) - if len(self.script_logs) < 100 - else "".join(self.script_logs[-100:]) - ) - - if "自动代理" in self.mode: - - # 获取最近一条日志的时间 - latest_time = self.log_start_time - for _ in self.script_logs[::-1]: - try: - latest_time = self.strptime( - _[self.log_time_range[0] : self.log_time_range[1]], - self.set["Script"]["LogTimeFormat"], - self.last_check_time, - ) - break - except ValueError: - pass - - logger.info( - f"通用脚本最近一条日志时间:{latest_time}", - module=f"通用调度器-{self.name}", - ) - - for success_sign in self.success_log: - if success_sign in log: - self.script_result = "Success!" - break - else: - - if self.isInterruptionRequested: - self.script_result = "任务被手动中止" - elif datetime.now() - latest_time > timedelta( - minutes=self.set["Run"]["RunTimeLimit"] - ): - self.script_result = "脚本进程超时" - else: - for error_sign in self.error_log: - if error_sign in log: - self.script_result = f"异常日志:{error_sign}" - break - else: - if self.script_process_manager.is_running(): - self.script_result = "Wait" - elif self.success_log: - self.script_result = "脚本在完成任务前退出" - else: - self.script_result = "Success!" - - elif self.mode == "设置通用脚本": - if self.script_process_manager.is_running(): - self.script_result = "Wait" - else: - self.script_result = "Success!" - - logger.info( - f"通用脚本日志分析结果:{self.script_result}", - module=f"通用调度器-{self.name}", - ) - - if self.script_result != "Wait": - - self.quit_monitor() - - def start_monitor(self) -> None: - """开始监视通用脚本日志""" - - logger.info( - f"开始监视通用脚本日志,路径:{self.script_log_path},日志起始时间:{self.log_start_time}", - module=f"通用调度器-{self.name}", - ) - self.log_monitor.addPath(str(self.script_log_path)) - self.log_monitor_timer.start(1000) - self.last_check_time = datetime.now() - self.monitor_loop.exec() - - def quit_monitor(self) -> None: - """退出通用脚本日志监视进程""" - - if len(self.log_monitor.files()) != 0: - - logger.info( - f"通用脚本日志监视器移除路径:{self.script_log_path}", - module=f"通用调度器-{self.name}", - ) - self.log_monitor.removePath(str(self.script_log_path)) - - else: - logger.warning( - f"通用脚本日志监视器没有正在监看的路径:{self.log_monitor.files()}", - module=f"通用调度器-{self.name}", - ) - - self.log_monitor_timer.stop() - self.last_check_time = None - self.monitor_loop.quit() - - logger.info("通用脚本日志监视锁已释放", module=f"通用调度器-{self.name}") - - def set_sub(self, index: str = "") -> dict: - """配置通用脚本运行参数""" - logger.info(f"开始配置脚本运行参数:{index}", module=f"通用调度器-{self.name}") - - # 配置前关闭可能未正常退出的脚本进程 - if self.mode == "自动代理": - System.kill_process(self.script_exe_path) - elif self.mode == "设置通用脚本": - System.kill_process(self.script_set_exe_path) - - # 预导入配置文件 - if self.mode == "设置通用脚本": - if self.sub_config_path.exists(): - if self.set["Script"]["ConfigPathMode"] == "文件夹": - shutil.copytree( - self.sub_config_path, - self.script_config_path, - dirs_exist_ok=True, - ) - elif (self.sub_config_path / self.script_config_path.name).exists(): - shutil.copy( - self.sub_config_path / self.script_config_path.name, - self.script_config_path, - ) - else: - if self.set["Script"]["ConfigPathMode"] == "文件夹": - shutil.copytree( - self.data[index]["Path"] / "ConfigFiles", - self.script_config_path, - dirs_exist_ok=True, - ) - else: - shutil.copy( - self.data[index]["Path"] - / "ConfigFiles" - / self.script_config_path.name, - self.script_config_path, - ) - - logger.info(f"脚本运行参数配置完成:{index}", module=f"通用调度器-{self.name}") - - def execute_script_task(self, script_path: Path, task_name: str) -> bool: - """执行脚本任务并等待结束""" - - try: - logger.info( - f"开始执行{task_name}: {script_path}", module=f"通用调度器-{self.name}" - ) - - # 根据文件类型选择执行方式 - if script_path.suffix.lower() == ".py": - cmd = [sys.executable, script_path] - elif script_path.suffix.lower() in [".bat", ".cmd", ".exe"]: - cmd = [str(script_path)] - elif script_path.suffix.lower() == "": - logger.warning( - f"{task_name}脚本没有指定后缀名,无法执行", - module=f"通用调度器-{self.name}", - ) - return False - else: - # 使用系统默认程序打开 - os.startfile(str(script_path)) - return True - - # 执行脚本并等待结束 - result = subprocess.run( - cmd, - cwd=script_path.parent, - stdin=subprocess.DEVNULL, - creationflags=( - subprocess.CREATE_NO_WINDOW - if Config.get(Config.function_IfSilence) - else 0 - ), - timeout=600, - capture_output=True, - errors="ignore", - ) - - if result.returncode == 0: - logger.info(f"{task_name}执行成功", module=f"通用调度器-{self.name}") - if result.stdout.strip(): - logger.info( - f"{task_name}输出: {result.stdout}", - module=f"通用调度器-{self.name}", - ) - return True - else: - logger.error( - f"{task_name}执行失败,返回码: {result.returncode}", - module=f"通用调度器-{self.name}", - ) - if result.stderr.strip(): - logger.error( - f"{task_name}错误输出: {result.stderr}", - module=f"通用调度器-{self.name}", - ) - return False - - except subprocess.TimeoutExpired: - logger.error(f"{task_name}执行超时", module=f"通用调度器-{self.name}") - return False - except Exception as e: - logger.exception( - f"执行{task_name}时出现异常: {e}", module=f"通用调度器-{self.name}" - ) - return False - - def push_notification( - self, - mode: str, - title: str, - message: Union[str, dict], - sub_data: Dict[str, Dict[str, Union[str, int, bool]]] = None, - ) -> None: - """通过所有渠道推送通知""" - - logger.info( - f"开始推送通知,模式:{mode},标题:{title}", - module=f"通用调度器-{self.name}", - ) - - env = Environment( - loader=FileSystemLoader(str(Config.app_path / "resources/html")) - ) - - if mode == "代理结果" and ( - Config.get(Config.notify_SendTaskResultTime) == "任何时刻" - or ( - Config.get(Config.notify_SendTaskResultTime) == "仅失败时" - and message["uncompleted_count"] != 0 - ) - ): - # 生成文本通知内容 - message_text = ( - f"任务开始时间:{message['start_time']},结束时间:{message['end_time']}\n" - f"已完成数:{message['completed_count']},未完成数:{message['uncompleted_count']}\n\n" - ) - - if len(message["failed_sub"]) > 0: - message_text += f"{self.mode[2:4]}未成功的配置:\n{"\n".join(message['failed_sub'])}\n" - if len(message["waiting_sub"]) > 0: - message_text += f"\n未开始{self.mode[2:4]}的配置:\n{"\n".join(message['waiting_sub'])}\n" - - # 生成HTML通知内容 - message["failed_sub"] = "、".join(message["failed_sub"]) - message["waiting_sub"] = "、".join(message["waiting_sub"]) - - template = env.get_template("general_result.html") - message_html = template.render(message) - - # ServerChan的换行是两个换行符。故而将\n替换为\n\n - serverchan_message = message_text.replace("\n", "\n\n") - - # 发送全局通知 - - if Config.get(Config.notify_IfSendMail): - Notify.send_mail( - "网页", title, message_html, Config.get(Config.notify_ToAddress) - ) - - if Config.get(Config.notify_IfServerChan): - Notify.ServerChanPush( - title, - f"{serverchan_message}\n\nAUTO_MAA 敬上", - Config.get(Config.notify_ServerChanKey), - Config.get(Config.notify_ServerChanTag), - Config.get(Config.notify_ServerChanChannel), - ) - - if Config.get(Config.notify_IfCompanyWebHookBot): - Notify.CompanyWebHookBotPush( - title, - f"{message_text}\n\nAUTO_MAA 敬上", - Config.get(Config.notify_CompanyWebHookBotUrl), - ) - - elif mode == "统计信息": - - message_text = ( - f"开始时间: {message['start_time']}\n" - f"结束时间: {message['end_time']}\n" - f"通用脚本执行结果: {message['sub_result']}\n\n" - ) - - # 生成HTML通知内容 - template = env.get_template("general_statistics.html") - message_html = template.render(message) - - # ServerChan的换行是两个换行符。故而将\n替换为\n\n - serverchan_message = message_text.replace("\n", "\n\n") - - # 发送全局通知 - if Config.get(Config.notify_IfSendStatistic): - - if Config.get(Config.notify_IfSendMail): - Notify.send_mail( - "网页", title, message_html, Config.get(Config.notify_ToAddress) - ) - - if Config.get(Config.notify_IfServerChan): - Notify.ServerChanPush( - title, - f"{serverchan_message}\n\nAUTO_MAA 敬上", - Config.get(Config.notify_ServerChanKey), - Config.get(Config.notify_ServerChanTag), - Config.get(Config.notify_ServerChanChannel), - ) - - if Config.get(Config.notify_IfCompanyWebHookBot): - Notify.CompanyWebHookBotPush( - title, - f"{message_text}\n\nAUTO_MAA 敬上", - Config.get(Config.notify_CompanyWebHookBotUrl), - ) - - # 发送用户单独通知 - if sub_data["Notify"]["Enabled"] and sub_data["Notify"]["IfSendStatistic"]: - - # 发送邮件通知 - if sub_data["Notify"]["IfSendMail"]: - if sub_data["Notify"]["ToAddress"]: - Notify.send_mail( - "网页", - title, - message_html, - sub_data["Notify"]["ToAddress"], - ) - else: - logger.error(f"用户邮箱地址为空,无法发送用户单独的邮件通知") - - # 发送ServerChan通知 - if sub_data["Notify"]["IfServerChan"]: - if sub_data["Notify"]["ServerChanKey"]: - Notify.ServerChanPush( - title, - f"{serverchan_message}\n\nAUTO_MAA 敬上", - sub_data["Notify"]["ServerChanKey"], - sub_data["Notify"]["ServerChanTag"], - sub_data["Notify"]["ServerChanChannel"], - ) - else: - logger.error( - f"{self.name} |用户ServerChan密钥为空,无法发送用户单独的ServerChan通知" - ) - - # 推送CompanyWebHookBot通知 - if sub_data["Notify"]["IfCompanyWebHookBot"]: - if sub_data["Notify"]["CompanyWebHookBotUrl"]: - Notify.CompanyWebHookBotPush( - title, - f"{message_text}\n\nAUTO_MAA 敬上", - sub_data["Notify"]["CompanyWebHookBotUrl"], - ) - else: - logger.error( - f"{self.name} |用户CompanyWebHookBot密钥为空,无法发送用户单独的CompanyWebHookBot通知" - ) - - return None diff --git a/app/models/schema.py b/app/models/schema.py new file mode 100644 index 0000000..9aa87f2 --- /dev/null +++ b/app/models/schema.py @@ -0,0 +1,779 @@ +# AUTO_MAA:A MAA Multi Account Management and Automation Tool +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox + +# 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 . + +# 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 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 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推送密钥") + IfCompanyWebHookBot: Optional[bool] = Field( + default=None, description="是否使用企微Webhook推送" + ) + CompanyWebHookBotUrl: Optional[str] = Field( + default=None, description="企微Webhook Bot URL" + ) + + +class GlobalConfig_Update(BaseModel): + IfAutoUpdate: Optional[bool] = Field(default=None, description="是否自动更新") + UpdateType: Optional[Literal["stable", "beta"]] = Field( + default=None, description="更新类型, stable为稳定版, beta为测试版" + ) + 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 UserConfig_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") + 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[UserConfig_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[UserConfig_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 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="全局设置需要更新的数据") diff --git a/app/services/__init__.py b/app/services/__init__.py index 0bdbea6..bab395d 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -18,20 +18,11 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA服务包 -v4.4 -作者:DLmaster_361 -""" - -__version__ = "4.2.0" +__version__ = "5.0.0" __author__ = "DLmaster361 " __license__ = "GPL-3.0 license" from .notification import Notify -from .security import Crypto from .system import System -from .skland import skland_sign_in -__all__ = ["Notify", "Crypto", "System", "skland_sign_in"] +__all__ = ["Notify", "System"] diff --git a/app/services/notification.py b/app/services/notification.py index 0fd13f4..0c1519b 100644 --- a/app/services/notification.py +++ b/app/services/notification.py @@ -18,39 +18,28 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA通知服务 -v4.4 -作者:DLmaster_361 -""" import re import smtplib -import time +import requests from email.header import Header from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formataddr from pathlib import Path -from typing import Union - -import requests -from PySide6.QtCore import QObject, Signal from plyer import notification -from app.core import Config, logger -from app.services.security import Crypto -from app.utils.ImageUtils import ImageUtils +from app.core import Config +from app.utils import get_logger, ImageUtils + +logger = get_logger("通知服务") -class Notification(QObject): +class Notification: - push_info_bar = Signal(str, str, str, int) - - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self): + super().__init__() def push_plyer(self, title, message, ticker, t) -> bool: """ @@ -63,19 +52,22 @@ class Notification(QObject): :return: bool """ - if Config.get(Config.notify_IfPushPlyer): + if Config.get("Notify", "IfPushPlyer"): - logger.info(f"推送系统通知:{title}", module="通知服务") + logger.info(f"推送系统通知: {title}") - notification.notify( - title=title, - message=message, - app_name="AUTO_MAA", - app_icon=str(Config.app_path / "resources/icons/AUTO_MAA.ico"), - timeout=t, - ticker=ticker, - toast=True, - ) + if notification.notify is not None: + notification.notify( + title=title, + message=message, + app_name="AUTO_MAA", + app_icon=(Path.cwd() / "res/icons/AUTO_MAA.ico").as_posix(), + timeout=t, + ticker=ticker, + toast=True, + ) + else: + logger.error("plyer.notification 未正确导入, 无法推送系统通知") return True @@ -83,19 +75,19 @@ class Notification(QObject): """ 推送邮件通知 - :param mode: 邮件内容模式,支持 "文本" 和 "网页" + :param mode: 邮件内容模式, 支持 "文本" 和 "网页" :param title: 邮件标题 :param content: 邮件内容 :param to_address: 收件人地址 """ if ( - Config.get(Config.notify_SMTPServerAddress) == "" - or Config.get(Config.notify_AuthorizationCode) == "" + Config.get("Notify", "SMTPServerAddress") == "" + or Config.get("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), + Config.get("Notify", "FromAddress"), ) ) or not bool( @@ -106,379 +98,183 @@ class Notification(QObject): ) ): logger.error( - "请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址", - module="通知服务", + "请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址" ) - self.push_info_bar.emit( - "error", - "邮件通知推送异常", - "请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址", - -1, + raise ValueError( + "邮件通知的SMTP服务器地址、授权码、发件人地址或收件人地址未正确配置" ) - return None - try: - # 定义邮件正文 - if mode == "文本": - message = MIMEText(content, "plain", "utf-8") - elif mode == "网页": - message = MIMEMultipart("alternative") - message["From"] = formataddr( - ( - Header("AUTO_MAA通知服务", "utf-8").encode(), - Config.get(Config.notify_FromAddress), - ) - ) # 发件人显示的名字 - message["To"] = formataddr( - (Header("AUTO_MAA用户", "utf-8").encode(), to_address) - ) # 收件人显示的名字 - message["Subject"] = Header(title, "utf-8") - - if mode == "网页": - message.attach(MIMEText(content, "html", "utf-8")) - - smtpObj = smtplib.SMTP_SSL(Config.get(Config.notify_SMTPServerAddress), 465) - smtpObj.login( - Config.get(Config.notify_FromAddress), - Crypto.win_decryptor(Config.get(Config.notify_AuthorizationCode)), + # 定义邮件正文 + if mode == "文本": + message = MIMEText(content, "plain", "utf-8") + elif mode == "网页": + message = MIMEMultipart("alternative") + message["From"] = formataddr( + ( + Header("AUTO_MAA通知服务", "utf-8").encode(), + Config.get("Notify", "FromAddress"), ) - 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) + ) # 发件人显示的名字 + message["To"] = formataddr( + (Header("AUTO_MAA用户", "utf-8").encode(), to_address) + ) # 收件人显示的名字 + message["Subject"] = str(Header(title, "utf-8")) - def ServerChanPush( - self, title, content, send_key, tag, channel - ) -> Union[bool, str]: + if mode == "网页": + message.attach(MIMEText(content, "html", "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}") + + def ServerChanPush(self, title, content, send_key) -> None: """ 使用Server酱推送通知 :param title: 通知标题 :param content: 通知内容 :param send_key: Server酱的SendKey - :param tag: 通知标签 - :param channel: 通知频道 - :return: bool or str """ if not send_key: - logger.error("请正确设置Server酱的SendKey", module="通知服务") - self.push_info_bar.emit( - "error", "Server酱通知推送异常", "请正确设置Server酱的SendKey", -1 - ) - return None + raise ValueError("ServerChan SendKey 不能为空") - try: - # 构造 URL - if send_key.startswith("sctp"): - match = re.match(r"^sctp(\d+)t", send_key) - if match: - url = f"https://{match.group(1)}.push.ft07.com/send/{send_key}.send" - else: - raise ValueError("SendKey 格式错误(sctp)") + # 构造 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: - url = f"https://sctapi.ftqq.com/{send_key}.send" + raise ValueError("SendKey 格式不正确 (sctp)") + else: + url = f"https://sctapi.ftqq.com/{send_key}.send" - # 构建 tags 和 channel - def is_valid(s): - return s == "" or ( - s == "|".join(s.split("|")) - and (s.count("|") == 0 or all(s.split("|"))) - ) + # 请求发送 + params = {"title": title, "desp": content} + headers = {"Content-Type": "application/json;charset=utf-8"} - tags = "|".join(_.strip() for _ in tag.split("|")) - channels = "|".join(_.strip() for _ in channel.split("|")) + response = requests.post( + url, json=params, headers=headers, timeout=10, proxies=Config.get_proxies() + ) + result = response.json() - 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 result.get("code") == 0: + logger.success(f"Server酱推送通知成功: {title}") + else: + raise Exception(f"ServerChan 推送通知失败: {response.text}") - if is_valid(channels): - options["channel"] = channels - else: - logger.warning( - "Server酱 Channel 配置不正确,将被忽略", module="通知服务" - ) - self.push_info_bar.emit( - "warning", - "Server酱通知推送异常", - "请正确设置 ServerChan 的 Channel", - -1, - ) - - # 请求发送 - params = {"title": title, "desp": content, **options} - headers = {"Content-Type": "application/json;charset=utf-8"} - - response = requests.post( - url, - json=params, - headers=headers, - timeout=10, - proxies={ - "http": Config.get(Config.update_ProxyAddress), - "https": Config.get(Config.update_ProxyAddress), - }, - ) - result = response.json() - - if result.get("code") == 0: - logger.success(f"Server酱推送通知成功:{title}", module="通知服务") - return True - else: - error_code = result.get("code", "-1") - logger.exception( - f"Server酱通知推送失败:响应码:{error_code}", module="通知服务" - ) - self.push_info_bar.emit( - "error", "Server酱通知推送失败", f"响应码:{error_code}", -1 - ) - return f"Server酱通知推送失败:{error_code}" - - except Exception as e: - logger.exception(f"Server酱通知推送异常:{e}", module="通知服务") - self.push_info_bar.emit( - "error", - "Server酱通知推送异常", - "请检查相关设置和网络连接。如全部配置正确,请稍后再试。", - -1, - ) - return f"Server酱通知推送异常:{str(e)}" - - def CompanyWebHookBotPush(self, title, content, webhook_url) -> Union[bool, str]: + def WebHookPush(self, title, content, webhook_url) -> None: """ - 使用企业微信群机器人推送通知 + WebHook 推送通知 :param title: 通知标题 :param content: 通知内容 - :param webhook_url: 企业微信群机器人的WebHook地址 - :return: bool or str + :param webhook_url: WebHook地址 """ - if webhook_url == "": - logger.error("请正确设置企业微信群机器人的WebHook地址", module="通知服务") - self.push_info_bar.emit( - "error", - "企业微信群机器人通知推送异常", - "请正确设置企业微信群机器人的WebHook地址", - -1, - ) - return None + if not webhook_url: + raise ValueError("WebHook 地址不能为空") content = f"{title}\n{content}" data = {"msgtype": "text", "text": {"content": content}} - for _ in range(3): - try: - response = requests.post( - url=webhook_url, - json=data, - timeout=10, - proxies={ - "http": Config.get(Config.update_ProxyAddress), - "https": Config.get(Config.update_ProxyAddress), - }, - ) - info = response.json() - break - except Exception as e: - err = e - time.sleep(0.1) - else: - logger.error(f"推送企业微信群机器人时出错:{err}", module="通知服务") - self.push_info_bar.emit( - "error", - "企业微信群机器人通知推送失败", - f"使用企业微信群机器人推送通知时出错:{err}", - -1, - ) - return None + response = requests.post( + url=webhook_url, json=data, timeout=10, proxies=Config.get_proxies() + ) + info = response.json() if info["errcode"] == 0: - logger.success(f"企业微信群机器人推送通知成功:{title}", module="通知服务") - return True + logger.success(f"WebHook 推送通知成功: {title}") else: - logger.error(f"企业微信群机器人推送通知失败:{info}", module="通知服务") - self.push_info_bar.emit( - "error", - "企业微信群机器人通知推送失败", - f"使用企业微信群机器人推送通知时出错:{err}", - -1, - ) - return f"使用企业微信群机器人推送通知时出错:{err}" + raise Exception(f"WebHook 推送通知失败: {response.text}") - def CompanyWebHookBotPushImage(self, image_path: Path, webhook_url: str) -> bool: + def CompanyWebHookBotPushImage(self, image_path: Path, webhook_url: str) -> None: """ 使用企业微信群机器人推送图片通知 :param image_path: 图片文件路径 :param webhook_url: 企业微信群机器人的WebHook地址 - :return: bool """ - try: - # 压缩图片 - ImageUtils.compress_image_if_needed(image_path) + if not webhook_url: + raise ValueError("webhook URL 不能为空") - # 检查图片是否存在 - if not image_path.exists(): - logger.error( - "图片推送异常 | 图片不存在或者压缩失败,请检查图片路径是否正确", - module="通知服务", - ) - self.push_info_bar.emit( - "error", - "企业微信群机器人通知推送异常", - "图片不存在或者压缩失败,请检查图片路径是否正确", - -1, - ) - return False + # 压缩图片 + ImageUtils.compress_image_if_needed(image_path) - if not webhook_url: - logger.error( - "请正确设置企业微信群机器人的WebHook地址", module="通知服务" - ) - self.push_info_bar.emit( - "error", - "企业微信群机器人通知推送异常", - "请正确设置企业微信群机器人的WebHook地址", - -1, - ) - return False + # 检查图片是否存在 + if not image_path.exists(): + raise FileNotFoundError(f"文件未找到: {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 + # 获取图片base64和md5 + image_base64 = ImageUtils.get_base64_from_file(str(image_path)) + image_md5 = ImageUtils.calculate_md5_from_file(str(image_path)) - data = { - "msgtype": "image", - "image": {"base64": image_base64, "md5": image_md5}, - } + data = { + "msgtype": "image", + "image": {"base64": image_base64, "md5": image_md5}, + } - for _ in range(3): - try: - response = requests.post( - url=webhook_url, - json=data, - timeout=10, - proxies={ - "http": Config.get(Config.update_ProxyAddress), - "https": Config.get(Config.update_ProxyAddress), - }, - ) - info = response.json() - break - except requests.RequestException as e: - err = e - logger.exception( - f"推送企业微信群机器人图片第{_+1}次失败:{e}", module="通知服务" - ) - time.sleep(0.1) - else: - logger.error("推送企业微信群机器人图片时出错", module="通知服务") - self.push_info_bar.emit( - "error", - "企业微信群机器人图片推送失败", - f"使用企业微信群机器人推送图片时出错:{err}", - -1, - ) - return False + response = requests.post( + url=webhook_url, json=data, timeout=10, proxies=Config.get_proxies() + ) + info = response.json() - 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 + if info.get("errcode") == 0: + logger.success(f"企业微信群机器人推送图片成功: {image_path.name}") + else: + raise Exception(f"企业微信群机器人推送图片失败: {response.text}") - except Exception as e: - logger.error(f"推送企业微信群机器人图片时发生未知异常:{e}") - self.push_info_bar.emit( - "error", - "企业微信群机器人图片推送失败", - f"发生未知异常:{e}", - -1, - ) - return False - - def send_test_notification(self): + def send_test_notification(self) -> None: """发送测试通知到所有已启用的通知渠道""" - logger.info("发送测试通知到所有已启用的通知渠道", module="通知服务") + logger.info("发送测试通知到所有已启用的通知渠道") # 发送系统通知 self.push_plyer( "测试通知", - "这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!", + "这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容, 说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!", "测试通知", 3, ) # 发送邮件通知 - if Config.get(Config.notify_IfSendMail): + if Config.get("Notify", "IfSendMail"): self.send_mail( "文本", "AUTO_MAA测试通知", - "这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!", - Config.get(Config.notify_ToAddress), + "这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容, 说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!", + Config.get("Notify", "ToAddress"), ) # 发送Server酱通知 - if Config.get(Config.notify_IfServerChan): + if Config.get("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), + "这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容, 说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!", + Config.get("Notify", "ServerChanKey"), ) - # 发送企业微信机器人通知 - if Config.get(Config.notify_IfCompanyWebHookBot): - self.CompanyWebHookBotPush( + # 发送WebHook通知 + if Config.get("Notify", "IfCompanyWebHookBot"): + self.WebHookPush( "AUTO_MAA测试通知", - "这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容,说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!", - Config.get(Config.notify_CompanyWebHookBotUrl), + "这是 AUTO_MAA 外部通知测试信息。如果你看到了这段内容, 说明 AUTO_MAA 的通知功能已经正确配置且可以正常工作!", + Config.get("Notify", "CompanyWebHookBotUrl"), ) Notify.CompanyWebHookBotPushImage( - Config.app_path / "resources/images/notification/test_notify.png", - Config.get(Config.notify_CompanyWebHookBotUrl), + Path.cwd() / "res/images/notification/test_notify.png", + Config.get("Notify", "CompanyWebHookBotUrl"), ) - logger.info("测试通知发送完成", module="通知服务") - - return True + logger.success("测试通知发送完成") Notify = Notification() diff --git a/app/services/security.py b/app/services/security.py deleted file mode 100644 index d597f60..0000000 --- a/app/services/security.py +++ /dev/null @@ -1,270 +0,0 @@ -# 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 . - -# 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() diff --git a/app/services/system.py b/app/services/system.py index 36a9fba..707c4d9 100644 --- a/app/services/system.py +++ b/app/services/system.py @@ -18,14 +18,7 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA系统服务 -v4.4 -作者:DLmaster_361 -""" -from PySide6.QtWidgets import QApplication import sys import ctypes import win32gui @@ -36,8 +29,12 @@ import tempfile import getpass from datetime import datetime from pathlib import Path +from typing import Literal -from app.core import Config, logger +from app.core import Config +from app.utils.logger import get_logger + +logger = get_logger("系统服务") class _SystemHandler: @@ -45,15 +42,10 @@ class _SystemHandler: ES_CONTINUOUS = 0x80000000 ES_SYSTEM_REQUIRED = 0x00000001 - def __init__(self): - - self.set_Sleep() - self.set_SelfStart() - - def set_Sleep(self) -> None: + async def set_Sleep(self) -> None: """同步系统休眠状态""" - if Config.get(Config.function_IfAllowSleep): + if Config.get("Function", "IfAllowSleep"): # 设置系统电源状态 ctypes.windll.kernel32.SetThreadExecutionState( self.ES_CONTINUOUS | self.ES_SYSTEM_REQUIRED @@ -62,10 +54,10 @@ class _SystemHandler: # 恢复系统电源状态 ctypes.windll.kernel32.SetThreadExecutionState(self.ES_CONTINUOUS) - def set_SelfStart(self) -> None: + async def set_SelfStart(self) -> None: """同步开机自启""" - if Config.get(Config.start_IfSelfStart) and not self.is_startup(): + if Config.get("Start", "IfSelfStart") and not await self.is_startup(): # 创建任务计划 try: @@ -116,7 +108,7 @@ class _SystemHandler: - "{Config.app_path_sys}" + "{Path.cwd() / 'AUTO_MAA.exe'}" """ @@ -147,14 +139,10 @@ class _SystemHandler: if result.returncode == 0: logger.success( - f"程序自启动任务计划已创建: {Config.app_path_sys}", - module="系统服务", + f"程序自启动任务计划已创建: {Path.cwd() / 'AUTO_MAA.exe'}" ) else: - logger.error( - f"程序自启动任务计划创建失败: {result.stderr}", - module="系统服务", - ) + logger.error(f"程序自启动任务计划创建失败: {result.stderr}") finally: # 删除临时文件 @@ -164,9 +152,9 @@ class _SystemHandler: pass except Exception as e: - logger.exception(f"程序自启动任务计划创建失败: {e}", module="系统服务") + logger.exception(f"程序自启动任务计划创建失败: {e}") - elif not Config.get(Config.start_IfSelfStart) and self.is_startup(): + elif not Config.get("Start", "IfSelfStart") and await self.is_startup(): try: @@ -179,90 +167,88 @@ class _SystemHandler: ) if result.returncode == 0: - logger.success("程序自启动任务计划已删除", module="系统服务") + logger.success("程序自启动任务计划已删除") else: - logger.error( - f"程序自启动任务计划删除失败: {result.stderr}", - module="系统服务", - ) + logger.error(f"程序自启动任务计划删除失败: {result.stderr}") except Exception as e: - logger.exception(f"程序自启动任务计划删除失败: {e}", module="系统服务") + logger.exception(f"程序自启动任务计划删除失败: {e}") - def set_power(self, mode) -> None: + async def set_power( + self, + mode: Literal[ + "NoAction", "Shutdown", "ShutdownForce", "Hibernate", "Sleep", "KillSelf" + ], + ) -> None: """ 执行系统电源操作 - :param mode: 电源操作模式,支持 "NoAction", "Shutdown", "Hibernate", "Sleep", "KillSelf", "ShutdownForce" + :param mode: 电源操作 """ if sys.platform.startswith("win"): if mode == "NoAction": - logger.info("不执行系统电源操作", module="系统服务") + logger.info("不执行系统电源操作") elif mode == "Shutdown": - self.kill_emulator_processes() - logger.info("执行关机操作", module="系统服务") + await self.kill_emulator_processes() + logger.info("执行关机操作") subprocess.run(["shutdown", "/s", "/t", "0"]) elif mode == "ShutdownForce": - logger.info("执行强制关机操作", module="系统服务") + logger.info("执行强制关机操作") subprocess.run(["shutdown", "/s", "/t", "0", "/f"]) elif mode == "Hibernate": - logger.info("执行休眠操作", module="系统服务") + logger.info("执行休眠操作") subprocess.run(["shutdown", "/h"]) elif mode == "Sleep": - logger.info("执行睡眠操作", module="系统服务") + logger.info("执行睡眠操作") subprocess.run( ["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"] ) - elif mode == "KillSelf": + elif mode == "KillSelf" and Config.server is not None: - logger.info("执行退出主程序操作", module="系统服务") - Config.main_window.close() - QApplication.quit() - sys.exit(0) + logger.info("执行退出主程序操作") + Config.server.should_exit = True elif sys.platform.startswith("linux"): if mode == "NoAction": - logger.info("不执行系统电源操作", module="系统服务") + logger.info("不执行系统电源操作") elif mode == "Shutdown": - logger.info("执行关机操作", module="系统服务") + logger.info("执行关机操作") subprocess.run(["shutdown", "-h", "now"]) elif mode == "Hibernate": - logger.info("执行休眠操作", module="系统服务") + logger.info("执行休眠操作") subprocess.run(["systemctl", "hibernate"]) elif mode == "Sleep": - logger.info("执行睡眠操作", module="系统服务") + logger.info("执行睡眠操作") subprocess.run(["systemctl", "suspend"]) - elif mode == "KillSelf": + elif mode == "KillSelf" and Config.server is not None: - logger.info("执行退出主程序操作", module="系统服务") - Config.main_window.close() - QApplication.quit() - sys.exit(0) + logger.info("执行退出主程序操作") + Config.server.should_exit = True - def kill_emulator_processes(self): + async def kill_emulator_processes(self): """这里暂时仅支持 MuMu 模拟器""" - logger.info("正在清除模拟器进程", module="系统服务") + logger.info("正在清除模拟器进程") keywords = ["Nemu", "nemu", "emulator", "MuMu"] for proc in psutil.process_iter(["pid", "name"]): @@ -270,16 +256,13 @@ 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']}", - module="系统服务", - ) + logger.info(f"已关闭 MuMu 模拟器进程: {proc.info['name']}") except (psutil.NoSuchProcess, psutil.AccessDenied): continue - logger.success("模拟器进程清除完成", module="系统服务") + logger.success("模拟器进程清除完成") - def is_startup(self) -> bool: + async def is_startup(self) -> bool: """判断程序是否已经开机自启""" try: @@ -292,10 +275,10 @@ class _SystemHandler: ) return result.returncode == 0 except Exception as e: - logger.exception(f"检查任务计划程序失败: {e}", module="系统服务") + logger.exception(f"检查任务计划程序失败: {e}") return False - def get_window_info(self) -> list: + async def get_window_info(self) -> list: """获取当前前台窗口信息""" def callback(hwnd, window_info): @@ -309,16 +292,16 @@ class _SystemHandler: win32gui.EnumWindows(callback, window_info) return window_info - def kill_process(self, path: Path) -> None: + async def kill_process(self, path: Path) -> None: """ 根据路径中止进程 :param path: 进程路径 """ - logger.info(f"开始中止进程: {path}", module="系统服务") + logger.info(f"开始中止进程: {path}") - for pid in self.search_pids(path): + for pid in await self.search_pids(path): killprocess = subprocess.Popen( f"taskkill /F /T /PID {pid}", shell=True, @@ -326,9 +309,9 @@ class _SystemHandler: ) killprocess.wait() - logger.success(f"进程已中止: {path}", module="系统服务") + logger.success(f"进程已中止: {path}") - def search_pids(self, path: Path) -> list: + async def search_pids(self, path: Path) -> list: """ 根据路径查找进程PID @@ -336,7 +319,7 @@ class _SystemHandler: :return: 匹配的进程PID列表 """ - logger.info(f"开始查找进程 PID: {path}", module="系统服务") + logger.info(f"开始查找进程 PID: {path}") pids = [] for proc in psutil.process_iter(["pid", "exe"]): @@ -344,7 +327,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 diff --git a/app/task/MAA.py b/app/task/MAA.py new file mode 100644 index 0000000..c339246 --- /dev/null +++ b/app/task/MAA.py @@ -0,0 +1,2100 @@ +# 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 . + +# Contact: DLmaster_361@163.com + + +import json +import uuid +import asyncio +import subprocess +import shutil +import win32com.client +from pathlib import Path +from datetime import datetime, timedelta +from jinja2 import Environment, FileSystemLoader +from typing import List, Dict, Optional + +from app.core import Broadcast, Config, MaaConfig, MaaUserConfig +from app.models.schema import WebSocketMessage +from app.models.ConfigBase import MultipleConfig +from app.services import Notify, System +from app.utils import get_logger, LogMonitor, ProcessManager +from .skland import skland_sign_in + + +logger = get_logger("MAA 调度器") + + +METHOD_BOOK = {"NoAction": "8", "ExitGame": "9", "ExitEmulator": "12"} +MOOD_BOOK = {"Annihilation": "剿灭", "Routine": "日常"} + + +class MaaManager: + """MAA控制器""" + + def __init__( + self, mode: str, script_id: uuid.UUID, user_id: Optional[uuid.UUID], ws_id: str + ): + super().__init__() + + self.mode = mode + self.script_id = script_id + self.user_id = user_id + self.ws_id = ws_id + + self.emulator_process_manager = ProcessManager() + self.maa_process_manager = ProcessManager() + self.wait_event = asyncio.Event() + self.message_queue = asyncio.Queue() + + self.maa_logs = [] + self.maa_result = "Wait" + self.maa_update_package = "" + + async def configure(self): + """提取配置信息""" + + await Broadcast.subscribe(self.message_queue) + + await Config.ScriptConfig[self.script_id].lock() + + self.script_config = Config.ScriptConfig[self.script_id] + if isinstance(self.script_config, MaaConfig): + self.user_config = MultipleConfig([MaaUserConfig]) + await self.user_config.load(await self.script_config.UserData.toDict()) + + self.maa_root_path = Path(self.script_config.get("Info", "Path")) + self.maa_set_path = self.maa_root_path / "config/gui.json" + self.maa_log_path = self.maa_root_path / "debug/gui.log" + self.maa_exe_path = self.maa_root_path / "MAA.exe" + self.maa_tasks_path = self.maa_root_path / "resource/tasks/tasks.json" + self.port_range = [0] + [ + (i // 2 + 1) * (-1 if i % 2 else 1) + for i in range(0, 2 * self.script_config.get("Run", "ADBSearchRange")) + ] + self.maa_log_monitor = LogMonitor( + (1, 20), "%Y-%m-%d %H:%M:%S", self.check_maa_log + ) + + logger.success(f"{self.script_id}已锁定, MAA配置提取完成") + + def check_config(self) -> str: + """检查配置是否可用""" + + if not self.maa_exe_path.exists(): + return "MAA.exe文件不存在, 请检查MAA路径设置!" + if not self.maa_set_path.exists(): + return "MAA配置文件不存在, 请检查MAA路径设置!" + if (self.mode != "设置脚本" or self.user_id is not None) and not ( + Path.cwd() / f"data/{self.script_id}/Default/ConfigFile/gui.json" + ).exists(): + return "未完成 MAA 全局设置, 请先设置 MAA!" + return "Success!" + + async def run(self): + """主进程, 运行MAA代理进程""" + + self.current_date = datetime.now().strftime("%m-%d") + self.curdate = Config.server_date().strftime("%Y-%m-%d") + self.begin_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + await self.configure() + self.check_result = self.check_config() + if self.check_result != "Success!": + logger.error(f"未通过配置检查: {self.check_result}") + await Config.send_json( + WebSocketMessage( + id=self.ws_id, type="Info", data={"Error": self.check_result} + ).model_dump() + ) + return + + # 记录 MAA 配置文件 + logger.info(f"记录 MAA 配置文件: {self.maa_set_path}") + (Path.cwd() / f"data/{self.script_id}/Temp").mkdir(parents=True, exist_ok=True) + if self.maa_set_path.exists(): + shutil.copy( + self.maa_set_path, Path.cwd() / f"data/{self.script_id}/Temp/gui.json" + ) + + # 整理用户数据, 筛选需代理的用户 + if self.mode != "设置脚本": + + self.user_list: List[Dict[str, str]] = [ + { + "user_id": str(uid), + "status": "等待", + "name": config.get("Info", "Name"), + } + for uid, config in self.user_config.items() + if config.get("Info", "Status") + and config.get("Info", "RemainedDay") != 0 + ] + self.user_list = sorted( + self.user_list, + key=lambda x: ( + self.user_config[uuid.UUID(x["user_id"])].get("Info", "Mode") + ), + ) + + logger.info(f"用户列表创建完成, 已筛选用户数: {len(self.user_list)}") + + # 自动代理模式 + if self.mode == "自动代理": + + # 标记是否需要重启模拟器 + self.if_open_emulator = True + # # 执行情况预处理 + for _ in self.user_list: + if ( + self.user_config[uuid.UUID(_["user_id"])].get( + "Data", "LastProxyDate" + ) + != self.curdate + ): + await self.user_config[uuid.UUID(_["user_id"])].set( + "Data", "LastProxyDate", self.curdate + ) + await self.user_config[uuid.UUID(_["user_id"])].set( + "Data", "ProxyTimes", 0 + ) + + # 开始代理 + for self.index, user in enumerate(self.user_list): + + self.cur_user_data = self.user_config[uuid.UUID(user["user_id"])] + + if (self.script_config.get("Run", "ProxyTimesLimit") == 0) or ( + self.cur_user_data.get("Data", "ProxyTimes") + < self.script_config.get("Run", "ProxyTimesLimit") + ): + user["status"] = "运行" + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={"user_list": self.user_list}, + ).model_dump() + ) + else: + user["status"] = "跳过" + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={"user_list": self.user_list}, + ).model_dump() + ) + continue + + logger.info(f"开始代理用户: {user['user_id']}") + + # 详细模式用户首次代理需打开模拟器 + if self.cur_user_data.get("Info", "Mode") == "详细": + self.if_open_emulator = True + + # 初始化代理情况记录和模式替换表 + self.run_book = { + "Annihilation": bool( + self.cur_user_data.get("Info", "Annihilation") == "Close" + ), + "Routine": self.cur_user_data.get("Info", "Mode") == "复杂" + and not self.cur_user_data.get("Info", "Routine"), + } + + self.user_logs_list = [] + self.user_start_time = datetime.now() + + if self.cur_user_data.get( + "Info", "IfSkland" + ) and self.cur_user_data.get("Info", "SklandToken"): + + if self.cur_user_data.get( + "Data", "LastSklandDate" + ) != datetime.now().strftime("%Y-%m-%d"): + + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={"log": "正在执行森空岛签到中\n请稍候~"}, + ).model_dump() + ) + + skland_result = await skland_sign_in( + self.cur_user_data.get("Info", "SklandToken") + ) + + for type, user_list in skland_result.items(): + + if type != "总计" and len(user_list) > 0: + + logger.info( + f"用户: {user['user_id']} - 森空岛签到{type}: {'、'.join(user_list)}" + ) + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Info", + data={ + ( + "Info" if type != "失败" else "Error" + ): f"用户 {user['name']} 森空岛签到{type}: {'、'.join(user_list)}" + }, + ).model_dump() + ) + if skland_result["总计"] == 0: + logger.info(f"用户: {user['user_id']} - 森空岛签到失败") + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Info", + data={ + "Error": f"用户 {user['name']} 森空岛签到失败", + }, + ).model_dump() + ) + + if ( + skland_result["总计"] > 0 + and len(skland_result["失败"]) == 0 + ): + await self.cur_user_data.set( + "Data", + "LastSklandDate", + datetime.now().strftime("%Y-%m-%d"), + ) + + elif self.cur_user_data.get("Info", "IfSkland"): + logger.warning( + f"用户: {user['user_id']} - 未配置森空岛签到Token, 跳过森空岛签到" + ) + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Info", + data={ + "Warning": f"用户 {user['name']} 未配置森空岛签到Token, 跳过森空岛签到" + }, + ).model_dump() + ) + + # 剿灭-日常模式循环 + for mode in ["Annihilation", "Routine"]: + + if self.run_book[mode]: + continue + + # 剿灭模式;满足条件跳过剿灭 + if ( + mode == "Annihilation" + and self.script_config.get("Run", "AnnihilationWeeklyLimit") + and datetime.strptime( + self.cur_user_data.get("Data", "LastAnnihilationDate"), + "%Y-%m-%d", + ).isocalendar()[:2] + == datetime.strptime(self.curdate, "%Y-%m-%d").isocalendar()[:2] + ): + logger.info( + f"用户: {user['user_id']} - 本周剿灭模式已达上限, 跳过执行剿灭任务" + ) + self.run_book[mode] = True + continue + else: + self.weekly_annihilation_limit_reached = False + + if ( + self.cur_user_data.get("Info", "Mode") == "详细" + and not ( + Path.cwd() + / f"data/{self.script_id}/{user['user_id']}/ConfigFile/gui.json" + ).exists() + ): + logger.error( + f"用户: {user['user_id']} - 未找到日常详细配置文件" + ) + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Info", + data={"Error": f"未找到 {user['name']} 的详细配置文件"}, + ).model_dump() + ) + self.run_book[mode] = False + break + + # 更新当前模式到界面 + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={ + "user_status": { + "user_id": user["user_id"], + "type": mode, + } + }, + ).model_dump() + ) + + # 解析任务构成 + if mode == "Routine": + + self.task_dict = { + "WakeUp": str(self.cur_user_data.get("Task", "IfWakeUp")), + "Recruiting": str( + self.cur_user_data.get("Task", "IfRecruiting") + ), + "Base": str(self.cur_user_data.get("Task", "IfBase")), + "Combat": str(self.cur_user_data.get("Task", "IfCombat")), + "Mission": str(self.cur_user_data.get("Task", "IfMission")), + "Mall": str(self.cur_user_data.get("Task", "IfMall")), + "AutoRoguelike": str( + self.cur_user_data.get("Task", "IfAutoRoguelike") + ), + "Reclamation": str( + self.cur_user_data.get("Task", "IfReclamation") + ), + } + + elif mode == "Annihilation": + + self.task_dict = { + "WakeUp": "True", + "Recruiting": "False", + "Base": "False", + "Combat": "True", + "Mission": "False", + "Mall": "False", + "AutoRoguelike": "False", + "Reclamation": "False", + } + + logger.info( + f"用户 {user['name']} - 模式: {mode} - 任务列表: {self.task_dict.values()}" + ) + + # 尝试次数循环 + for i in range(self.script_config.get("Run", "RunTimesLimit")): + + if self.run_book[mode]: + break + + logger.info( + f"用户 {user['name']} - 模式: {mode} - 尝试次数: {i + 1}/{self.script_config.get('Run','RunTimesLimit')}" + ) + + # 配置MAA + set = await self.set_maa(mode) + # 记录当前时间 + self.log_start_time = datetime.now() + + # 记录模拟器与ADB路径 + self.emulator_path = Path( + set["Configurations"]["Default"]["Start.EmulatorPath"] + ) + self.emulator_arguments = set["Configurations"]["Default"][ + "Start.EmulatorAddCommand" + ].split() + # 如果是快捷方式, 进行解析 + if ( + self.emulator_path.suffix == ".lnk" + and self.emulator_path.exists() + ): + try: + shell = win32com.client.Dispatch("WScript.Shell") + shortcut = shell.CreateShortcut(str(self.emulator_path)) + self.emulator_path = Path(shortcut.TargetPath) + self.emulator_arguments = shortcut.Arguments.split() + except Exception as e: + logger.exception(f"解析快捷方式时出现异常: {e}") + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Info", + data={ + "Error": f"解析快捷方式时出现异常: {e}", + }, + ).model_dump() + ) + self.if_open_emulator = True + break + elif not self.emulator_path.exists(): + logger.error(f"模拟器快捷方式不存在: {self.emulator_path}") + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Info", + data={ + "Error": f"模拟器快捷方式 {self.emulator_path} 不存在", + }, + ).model_dump() + ) + self.if_open_emulator = True + break + + self.wait_time = int( + set["Configurations"]["Default"][ + "Start.EmulatorWaitSeconds" + ] + ) + + self.ADB_path = Path( + set["Configurations"]["Default"]["Connect.AdbPath"] + ) + self.ADB_path = ( + self.ADB_path + if self.ADB_path.is_absolute() + else self.maa_root_path / self.ADB_path + ) + self.ADB_address = set["Configurations"]["Default"][ + "Connect.Address" + ] + self.if_kill_emulator = bool( + set["Configurations"]["Default"]["MainFunction.PostActions"] + == "12" + ) + self.if_open_emulator_process = bool( + set["Configurations"]["Default"][ + "Start.OpenEmulatorAfterLaunch" + ] + == "True" + ) + + # 任务开始前释放ADB + try: + logger.info(f"释放ADB: {self.ADB_address}") + subprocess.run( + [self.ADB_path, "disconnect", self.ADB_address], + creationflags=subprocess.CREATE_NO_WINDOW, + ) + except subprocess.CalledProcessError as e: + # 忽略错误,因为可能本来就没有连接 + logger.warning(f"释放ADB时出现异常: {e}") + except Exception as e: + logger.exception(f"释放ADB时出现异常: {e}") + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Info", + data={"Warning": f"释放ADB时出现异常: {e}"}, + ).model_dump() + ) + + if self.if_open_emulator_process: + try: + logger.info( + f"启动模拟器: {self.emulator_path}, 参数: {self.emulator_arguments}" + ) + await self.emulator_process_manager.open_process( + self.emulator_path, self.emulator_arguments, 0 + ) + except Exception as e: + logger.exception(f"启动模拟器时出现异常: {e}") + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Info", + data={ + "Error": "启动模拟器时出现异常, 请检查MAA中模拟器路径设置" + }, + ).model_dump() + ) + self.if_open_emulator = True + break + + # 更新静默进程标记有效时间 + logger.info( + f"更新静默进程标记: {self.emulator_path}, 标记有效时间: {datetime.now() + timedelta(seconds=self.wait_time + 10)}" + ) + Config.silence_dict[self.emulator_path] = ( + datetime.now() + timedelta(seconds=self.wait_time + 10) + ) + + await self.search_ADB_address() + + # 创建MAA任务 + logger.info(f"启动MAA进程: {self.maa_exe_path}") + await self.maa_process_manager.open_process( + self.maa_exe_path, [], 0 + ) + + # 监测MAA运行状态 + self.log_check_mode = mode + await self.maa_log_monitor.start( + self.maa_log_path, self.log_start_time + ) + + self.wait_event.clear() + await self.wait_event.wait() + + await self.maa_log_monitor.stop() + + # 处理MAA结果 + if self.maa_result == "Success!": + + # 标记任务完成 + self.run_book[mode] = True + + logger.info( + f"用户: {user['user_id']} - MAA进程完成代理任务" + ) + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={ + "log": "检测到MAA进程完成代理任务\n正在等待相关程序结束\n请等待10s" + }, + ).model_dump() + ) + + else: + logger.error( + f"用户: {user['user_id']} - 代理任务异常: {self.maa_result}" + ) + # 打印中止信息 + # 此时, log变量内存储的就是出现异常的日志信息, 可以保存或发送用于问题排查 + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={ + "log": f"{self.maa_result}\n正在中止相关程序\n请等待10s" + }, + ).model_dump() + ) + # 无命令行中止MAA与其子程序 + logger.info(f"中止MAA进程: {self.maa_exe_path}") + await self.maa_process_manager.kill(if_force=True) + await System.kill_process(self.maa_exe_path) + + # 中止模拟器进程 + logger.info( + f"中止模拟器进程: {list(self.emulator_process_manager.tracked_pids)}" + ) + await self.emulator_process_manager.kill() + + self.if_open_emulator = True + + # 推送异常通知 + Notify.push_plyer( + "用户自动代理出现异常!", + f"用户 {user['name']} 的{MOOD_BOOK[mode]}部分出现一次异常", + f"{user['name']}的{MOOD_BOOK[mode]}出现异常", + 3, + ) + + await asyncio.sleep(10) + + # 任务结束后释放ADB + try: + logger.info(f"释放ADB: {self.ADB_address}") + subprocess.run( + [self.ADB_path, "disconnect", self.ADB_address], + creationflags=subprocess.CREATE_NO_WINDOW, + ) + except subprocess.CalledProcessError as e: + # 忽略错误,因为可能本来就没有连接 + logger.warning(f"释放ADB时出现异常: {e}") + except Exception as e: + logger.exception(f"释放ADB时出现异常: {e}") + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Info", + data={"Error": f"释放ADB时出现异常: {e}"}, + ).model_dump() + ) + # 任务结束后再次手动中止模拟器进程, 防止退出不彻底 + if self.if_kill_emulator: + logger.info( + f"任务结束后再次中止模拟器进程: {list(self.emulator_process_manager.tracked_pids)}" + ) + await self.emulator_process_manager.kill() + self.if_open_emulator = True + + # 从配置文件中解析所需信息 + with self.maa_set_path.open(mode="r", encoding="utf-8") as f: + data = json.load(f) + + # 记录自定义基建索引 + await self.cur_user_data.set( + "Data", + "CustomInfrastPlanIndex", + data["Configurations"]["Default"][ + "Infrast.CustomInfrastPlanIndex" + ], + ) + + # 记录更新包路径 + if ( + data["Global"]["VersionUpdate.package"] + and ( + self.maa_root_path + / data["Global"]["VersionUpdate.package"] + ).exists() + ): + self.maa_update_package = data["Global"][ + "VersionUpdate.package" + ] + + # 记录剿灭情况 + if ( + mode == "Annihilation" + and self.weekly_annihilation_limit_reached + ): + await self.cur_user_data.set( + "Data", "LastAnnihilationDate", self.curdate + ) + # 保存运行日志以及统计信息 + if_six_star = await Config.save_maa_log( + Path.cwd() + / f"history/{self.curdate}/{user['name']}/{self.log_start_time.strftime('%H-%M-%S')}.log", + self.maa_logs, + self.maa_result, + ) + self.user_logs_list.append( + Path.cwd() + / f"history/{self.curdate}/{user['name']}/{self.log_start_time.strftime('%H-%M-%S')}.json" + ) + if if_six_star: + await self.push_notification( + "公招六星", + f"喜报: 用户 {user['name']} 公招出六星啦!", + { + "user_name": user["name"], + }, + ) + + # 执行MAA解压更新动作 + if self.maa_update_package: + + logger.info(f"检测到MAA更新, 正在执行更新动作") + + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={ + "log": "检测到MAA存在更新\nMAA正在执行更新动作\n请等待10s" + }, + ).model_dump() + ) + await self.set_maa("Update") + subprocess.Popen( + [self.maa_exe_path], + creationflags=subprocess.CREATE_NO_WINDOW, + ) + await asyncio.sleep(10) + await System.kill_process(self.maa_exe_path) + + self.maa_update_package = "" + + logger.info(f"更新动作结束") + + await self.result_record() + + # 人工排查模式 + elif self.mode == "人工排查": + + # 人工排查时, 屏蔽静默操作 + logger.info("人工排查任务开始, 屏蔽静默操作") + Config.if_ignore_silence.append(self.script_id) + + # 标记是否需要启动模拟器 + self.if_open_emulator = True + + # 开始排查 + for self.index, user in enumerate(self.user_list): + + self.cur_user_data = self.user_config[uuid.UUID(user["user_id"])] + + logger.info(f"开始排查用户: {user['user_id']}") + + user["status"] = "运行" + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={"user_list": self.user_list}, + ).model_dump() + ) + + if self.cur_user_data.get("Info", "Mode") == "详细": + self.if_open_emulator = True + + self.run_book = {"SignIn": False, "PassCheck": False} + + # 启动重试循环 + while True: + + # 配置MAA + await self.set_maa("人工排查") + + # 记录当前时间 + self.log_start_time = datetime.now() + # 创建MAA任务 + logger.info(f"启动MAA进程: {self.maa_exe_path}") + await self.maa_process_manager.open_process( + self.maa_exe_path, [], 0 + ) + + # 监测MAA运行状态 + self.log_check_mode = "人工排查" + await self.maa_log_monitor.start( + self.maa_log_path, self.log_start_time + ) + + self.wait_event.clear() + await self.wait_event.wait() + + await self.maa_log_monitor.stop() + + logger.info( + f"用户: {user['user_id']} - MAA进程登录PRTS结果: {self.maa_result}" + ) + + if self.maa_result == "Success!": + logger.info(f"用户: {user['user_id']} - MAA进程成功登录PRTS") + self.run_book["SignIn"] = True + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={"log": "检测到MAA进程成功登录PRTS"}, + ).model_dump() + ) + else: + logger.error( + f"用户: {user['user_id']} - MAA未能正确登录到PRTS: {self.maa_result}" + ) + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={ + "log": f"{self.maa_result}\n正在中止相关程序\n请等待10s" + }, + ).model_dump() + ) + # 无命令行中止MAA与其子程序 + logger.info(f"中止MAA进程: {self.maa_exe_path}") + await self.maa_process_manager.kill(if_force=True) + await System.kill_process(self.maa_exe_path) + self.if_open_emulator = True + await asyncio.sleep(10) + + # 登录成功, 结束循环 + if self.run_book["SignIn"]: + break + # 登录失败, 询问是否结束循环 + else: + + uid = str(uuid.uuid4()) + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Message", + data={ + "message_id": uid, + "type": "Question", + "title": "操作提示", + "message": "MAA未能正确登录到PRTS, 是否重试?", + }, + ).model_dump() + ) + result = await self.get_message(uid) + if result.get("choice", False): + break + + # 登录成功, 录入人工排查情况 + if self.run_book["SignIn"]: + + uid = str(uuid.uuid4()) + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Message", + data={ + "message_id": uid, + "type": "Question", + "title": "操作提示", + "message": "请检查用户代理情况, 该用户是否正确完成代理任务?", + }, + ).model_dump() + ) + result = await self.get_message(uid) + if result.get("choice", False): + self.run_book["PassCheck"] = True + + await self.result_record() + + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={"user_list": self.user_list}, + ).model_dump() + ) + + # 设置MAA模式 + elif self.mode == "设置脚本": + + # 配置MAA + await self.set_maa(self.mode) + # 创建MAA任务 + logger.info(f"启动MAA进程: {self.maa_exe_path}") + await self.maa_process_manager.open_process(self.maa_exe_path, [], 0) + # 记录当前时间 + self.log_start_time = datetime.now() + + # 监测MAA运行状态 + self.log_check_mode = "设置脚本" + await self.maa_log_monitor.start(self.maa_log_path, self.log_start_time) + self.wait_event.clear() + await self.wait_event.wait() + await self.maa_log_monitor.stop() + + async def result_record(self): + """记录用户结果信息""" + + if self.mode == "自动代理": + # 发送统计信息 + statistics = await Config.merge_statistic_info(self.user_logs_list) + statistics["user_info"] = self.user_list[self.index]["name"] + statistics["start_time"] = self.user_start_time.strftime( + "%Y-%m-%d %H:%M:%S" + ) + statistics["end_time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + statistics["maa_result"] = ( + "代理任务全部完成" + if (self.run_book["Annihilation"] and self.run_book["Routine"]) + else "代理任务未全部完成" + ) + await self.push_notification( + "统计信息", + f"{self.current_date} | 用户 {self.user_list[self.index]['name']} 的自动代理统计报告", + statistics, + ) + + if self.run_book["Annihilation"] and self.run_book["Routine"]: + # 成功完成代理的用户修改相关参数 + if ( + self.cur_user_data.get("Data", "ProxyTimes") == 0 + and self.cur_user_data.get("Info", "RemainedDay") != -1 + ): + await self.cur_user_data.set( + "Info", + "RemainedDay", + self.cur_user_data.get("Info", "RemainedDay") - 1, + ) + await self.cur_user_data.set( + "Data", + "ProxyTimes", + self.cur_user_data.get("Data", "ProxyTimes") + 1, + ) + self.user_list[self.index]["status"] = "完成" + logger.success( + f"用户 {self.user_list[self.index]['user_id']} 的自动代理任务已完成" + ) + Notify.push_plyer( + "成功完成一个自动代理任务!", + f"已完成用户 {self.user_list[self.index]['name']} 的自动代理任务", + f"已完成 {self.user_list[self.index]['name']} 的自动代理任务", + 3, + ) + else: + # 录入代理失败的用户 + logger.error( + f"用户 {self.user_list[self.index]['user_id']} 的自动代理任务未完成" + ) + self.user_list[self.index]["status"] = "异常" + + await Config.send_json( + WebSocketMessage( + id=self.ws_id, type="Update", data={"user_list": self.user_list} + ).model_dump() + ) + + elif self.mode == "人工排查": + + if self.run_book["SignIn"] and self.run_book["PassCheck"]: + logger.info( + f"用户 {self.user_list[self.index]['user_id']} 通过人工排查" + ) + await self.cur_user_data.set("Data", "IfPassCheck", True) + self.user_list[self.index]["status"] = "完成" + else: + logger.info( + f"用户 {self.user_list[self.index]['user_id']} 未通过人工排查" + ) + await self.cur_user_data.set("Data", "IfPassCheck", False) + self.user_list[self.index]["status"] = "异常" + + async def final_task(self, task: asyncio.Task): + """结束时的收尾工作""" + + logger.info("MAA 主任务已结束, 开始执行后续操作") + + await Config.ScriptConfig[self.script_id].unlock() + logger.success(f"已解锁脚本配置 {self.script_id}") + + # 结束各子任务 + await Broadcast.unsubscribe(self.message_queue) + await self.maa_process_manager.kill(if_force=True) + await System.kill_process(self.maa_exe_path) + await self.emulator_process_manager.kill() + await self.maa_log_monitor.stop() + del self.maa_process_manager + del self.emulator_process_manager + del self.maa_log_monitor + + if self.check_result != "Success!": + return self.check_result + + if self.mode == "人工排查": + + # 解除静默操作屏蔽 + logger.info("人工排查任务结束, 解除静默操作屏蔽") + if self.script_id in Config.if_ignore_silence: + Config.if_ignore_silence.remove(self.script_id) + + if self.mode == "自动代理" and self.user_list[self.index]["status"] == "运行": + + if not self.maa_update_package: + + self.maa_result = "用户手动中止任务" + + # 保存运行日志以及统计信息 + if_six_star = await Config.save_maa_log( + Path.cwd() + / f"history/{self.curdate}/{self.user_list[self.index]['name']}/{self.log_start_time.strftime('%H-%M-%S')}.log", + self.maa_logs, + self.maa_result, + ) + self.user_logs_list.append( + Path.cwd() + / f"history/{self.curdate}/{self.user_list[self.index]['name']}/{self.log_start_time.strftime('%H-%M-%S')}.json" + ) + if if_six_star: + await self.push_notification( + "公招六星", + f"喜报: 用户 {self.user_list[self.index]['name']} 公招出六星啦!", + { + "user_name": self.user_list[self.index]["name"], + }, + ) + + await self.result_record() + + elif self.mode == "人工排查" and self.user_list[self.index]["status"] == "运行": + + await self.result_record() + + # 导出结果 + if self.mode in ["自动代理", "人工排查"]: + + # 更新用户数据 + sc = Config.ScriptConfig[self.script_id] + if isinstance(sc, MaaConfig): + await sc.UserData.load(await self.user_config.toDict()) + await Config.ScriptConfig.save() + + error_user = [_["name"] for _ in self.user_list if _["status"] == "异常"] + over_user = [_["name"] for _ in self.user_list if _["status"] == "完成"] + wait_user = [_["name"] for _ in self.user_list if _["status"] == "等待"] + + # 保存运行日志 + title = ( + f"{self.current_date} | {self.script_config.get("Info", "Name")}的{self.mode}任务报告" + if self.script_config.get("Info", "Name") != "" + else f"{self.current_date} | {self.mode}任务报告" + ) + result = { + "title": f"{self.mode}任务报告", + "script_name": ( + self.script_config.get("Info", "Name") + if self.script_config.get("Info", "Name") != "" + else "空白" + ), + "start_time": self.begin_time, + "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "completed_count": len(over_user), + "uncompleted_count": len(error_user) + len(wait_user), + "failed_user": error_user, + "waiting_user": wait_user, + } + + # 生成结果文本 + result_text = ( + f"任务开始时间: {result["start_time"]}, 结束时间: {result["end_time"]}\n" + f"已完成数: {result["completed_count"]}, 未完成数: {result["uncompleted_count"]}\n\n" + ) + if len(result["failed_user"]) > 0: + result_text += ( + f"{self.mode}未成功的用户: \n{"\n".join(result["failed_user"])}\n" + ) + if len(result["waiting_user"]) > 0: + result_text += f"\n未开始{self.mode}的用户: \n{"\n".join(result["waiting_user"])}\n" + + # 推送代理结果通知 + Notify.push_plyer( + title.replace("报告", "已完成!"), + f"已完成用户数: {len(over_user)}, 未完成用户数: {len(error_user) + len(wait_user)}", + f"已完成用户数: {len(over_user)}, 未完成用户数: {len(error_user) + len(wait_user)}", + 10, + ) + await self.push_notification("代理结果", title, result) + + elif self.mode == "设置脚本": + ( + Path.cwd() + / f"data/{self.script_id}/{self.user_id if self.user_id else 'Default'}/ConfigFile" + ).mkdir(parents=True, exist_ok=True) + shutil.copy( + self.maa_set_path, + ( + Path.cwd() + / f"data/{self.script_id}/{self.user_id if self.user_id else 'Default'}/ConfigFile/gui.json" + ), + ) + + result_text = "" + + # 复原 MAA 配置文件 + logger.info(f"复原 MAA 配置文件: {Path.cwd() / f'data/{self.script_id}/Temp'}") + if (Path.cwd() / f"data/{self.script_id}/Temp/gui.json").exists(): + shutil.copy( + Path.cwd() / f"data/{self.script_id}/Temp/gui.json", self.maa_set_path + ) + shutil.rmtree(Path.cwd() / f"data/{self.script_id}/Temp") + + self.agree_bilibili(False) + return result_text + + async def get_message(self, message_id: str): + """获取当前任务的属性值""" + + logger.info(f"等待客户端回应消息: {message_id}") + + while True: + message = await self.message_queue.get() + if message.get("message_id") == message_id: + self.message_queue.task_done() + logger.success(f"收到客户端回应消息: {message_id}") + return message + else: + self.message_queue.task_done() + + async def search_ADB_address(self) -> None: + """搜索ADB实际地址""" + + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={ + "log": f"即将搜索ADB实际地址\n正在等待模拟器完成启动\n请等待{self.wait_time}s" + }, + ).model_dump() + ) + + await asyncio.sleep(self.wait_time) + + if "-" in self.ADB_address: + ADB_ip = f"{self.ADB_address.split("-")[0]}-" + ADB_port = int(self.ADB_address.split("-")[1]) + + elif ":" in self.ADB_address: + ADB_ip = f"{self.ADB_address.split(':')[0]}:" + ADB_port = int(self.ADB_address.split(":")[1]) + + logger.info( + f"正在搜索ADB实际地址, ADB前缀: {ADB_ip}, 初始端口: {ADB_port}, 搜索范围: {self.port_range}" + ) + + for port in self.port_range: + + ADB_address = f"{ADB_ip}{ADB_port + port}" + + # 尝试通过ADB连接到指定地址 + connect_result = subprocess.run( + [self.ADB_path, "connect", ADB_address], + creationflags=subprocess.CREATE_NO_WINDOW, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + encoding="utf-8", + ) + + if "connected" in connect_result.stdout: + + # 检查连接状态 + devices_result = subprocess.run( + [self.ADB_path, "devices"], + creationflags=subprocess.CREATE_NO_WINDOW, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + encoding="utf-8", + ) + if ADB_address in devices_result.stdout: + + logger.info(f"ADB实际地址: {ADB_address}") + + # 断开连接 + logger.info(f"断开ADB连接: {ADB_address}") + subprocess.run( + [self.ADB_path, "disconnect", ADB_address], + creationflags=subprocess.CREATE_NO_WINDOW, + ) + + self.ADB_address = ADB_address + + # 覆写当前ADB地址 + logger.info(f"开始使用实际 ADB 地址覆写: {self.ADB_address}") + await self.maa_process_manager.kill(if_force=True) + await System.kill_process(self.maa_exe_path) + with self.maa_set_path.open(mode="r", encoding="utf-8") as f: + data = json.load(f) + data["Configurations"]["Default"][ + "Connect.Address" + ] = self.ADB_address + data["Configurations"]["Default"]["Start.EmulatorWaitSeconds"] = "0" + with self.maa_set_path.open(mode="w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=4) + + return None + + else: + logger.info(f"无法连接到ADB地址: {ADB_address}") + else: + logger.info(f"无法连接到ADB地址: {ADB_address}") + + async def check_maa_log(self, log_content: List[str]) -> None: + """获取MAA日志并检查以判断MAA程序运行状态""" + + self.maa_logs = log_content + log = "".join(log_content) + + # 更新MAA日志 + if await self.maa_process_manager.is_running(): + + await Config.send_json( + WebSocketMessage( + id=self.ws_id, type="Update", data={"log": log} + ).model_dump() + ) + + if self.mode == "自动代理": + + # 获取最近一条日志的时间 + latest_time = self.log_start_time + for _ in self.maa_logs[::-1]: + try: + if "如果长时间无进一步日志更新, 可能需要手动干预。" in _: + continue + latest_time = datetime.strptime(_[1:20], "%Y-%m-%d %H:%M:%S") + break + except ValueError: + pass + + logger.info(f"MAA最近一条日志时间: {latest_time}") + + if self.log_check_mode == "Annihilation" and "任务出错: 刷理智" in log: + self.weekly_annihilation_limit_reached = True + else: + self.weekly_annihilation_limit_reached = False + + if "任务出错: StartUp" in log or "任务出错: 开始唤醒" in log: + self.maa_result = "MAA未能正确登录PRTS" + + elif "任务已全部完成!" in log: + + if "完成任务: StartUp" in log or "完成任务: 开始唤醒" in log: + self.task_dict["WakeUp"] = "False" + if "完成任务: Recruit" in log or "完成任务: 自动公招" in log: + self.task_dict["Recruiting"] = "False" + if "完成任务: Infrast" in log or "完成任务: 基建换班" in log: + self.task_dict["Base"] = "False" + if ( + "完成任务: Fight" in log + or "完成任务: 刷理智" in log + or ( + self.log_check_mode == "Annihilation" + and "任务出错: 刷理智" in log + ) + ): + self.task_dict["Combat"] = "False" + if "完成任务: Mall" in log or "完成任务: 获取信用及购物" in log: + self.task_dict["Mall"] = "False" + if "完成任务: Award" in log or "完成任务: 领取奖励" in log: + self.task_dict["Mission"] = "False" + if "完成任务: Roguelike" in log or "完成任务: 自动肉鸽" in log: + self.task_dict["AutoRoguelike"] = "False" + if "完成任务: Reclamation" in log or "完成任务: 生息演算" in log: + self.task_dict["Reclamation"] = "False" + + if all(v == "False" for v in self.task_dict.values()): + self.maa_result = "Success!" + else: + self.maa_result = "MAA部分任务执行失败" + + elif "请 「检查连接设置」 → 「尝试重启模拟器与 ADB」 → 「重启电脑」" in log: + self.maa_result = "MAA的ADB连接异常" + + elif "未检测到任何模拟器" in log: + self.maa_result = "MAA未检测到任何模拟器" + + elif "已停止" in log: + self.maa_result = "MAA在完成任务前中止" + + elif ( + "MaaAssistantArknights GUI exited" in log + or not await self.maa_process_manager.is_running() + ): + self.maa_result = "MAA在完成任务前退出" + + elif datetime.now() - latest_time > timedelta( + minutes=self.script_config.get("Run", f"{self.log_check_mode}TimeLimit") + ): + self.maa_result = "MAA进程超时" + + else: + self.maa_result = "Wait" + + elif self.mode == "人工排查": + if "完成任务: StartUp" in log or "完成任务: 开始唤醒" in log: + self.maa_result = "Success!" + elif "请 「检查连接设置」 → 「尝试重启模拟器与 ADB」 → 「重启电脑」" in log: + self.maa_result = "MAA的ADB连接异常" + elif "未检测到任何模拟器" in log: + self.maa_result = "MAA未检测到任何模拟器" + elif "已停止" in log: + self.maa_result = "MAA在完成任务前中止" + elif ( + "MaaAssistantArknights GUI exited" in log + or not await self.maa_process_manager.is_running() + ): + self.maa_result = "MAA在完成任务前退出" + else: + self.maa_result = "Wait" + + elif self.mode == "设置脚本": + if ( + "MaaAssistantArknights GUI exited" in log + or not await self.maa_process_manager.is_running() + ): + self.maa_result = "Success!" + else: + self.maa_result = "Wait" + + logger.debug(f"MAA 日志分析结果: {self.maa_result}") + + if self.maa_result != "Wait": + + logger.info(f"MAA 任务结果: {self.maa_result}, 日志锁已释放") + self.wait_event.set() + + async def set_maa(self, mode: str) -> dict: + """配置MAA运行参数""" + logger.info(f"开始配置MAA运行参数: {mode}") + + if self.mode != "设置脚本" and mode != "Update": + + if self.cur_user_data.get("Info", "Server") == "Bilibili": + self.agree_bilibili(True) + else: + self.agree_bilibili(False) + + # 配置MAA前关闭可能未正常退出的MAA进程 + await self.maa_process_manager.kill(if_force=True) + await System.kill_process(self.maa_exe_path) + + # 预导入MAA配置文件 + if self.mode in ["自动代理", "人工排查"]: + if self.cur_user_data.get("Info", "Mode") == "简洁": + shutil.copy( + (Path.cwd() / f"data/{self.script_id}/Default/ConfigFile/gui.json"), + self.maa_set_path, + ) + elif self.cur_user_data.get("Info", "Mode") == "详细": + shutil.copy( + ( + Path.cwd() + / f"data/{self.script_id}/{self.user_id}/ConfigFile/gui.json" + ), + self.maa_set_path, + ) + elif self.mode == "设置脚本": + if ( + self.user_id is None + and ( + Path.cwd() / f"data/{self.script_id}/Default/ConfigFile/gui.json" + ).exists() + ): + shutil.copy( + (Path.cwd() / f"data/{self.script_id}/Default/ConfigFile/gui.json"), + self.maa_set_path, + ) + elif self.user_id is not None: + if ( + Path.cwd() + / f"data/{self.script_id}/{self.user_id}/ConfigFile/gui.json" + ).exists(): + shutil.copy( + ( + Path.cwd() + / f"data/{self.script_id}/{self.user_id}/ConfigFile/gui.json" + ), + self.maa_set_path, + ) + else: + shutil.copy( + ( + Path.cwd() + / f"data/{self.script_id}/Default/ConfigFile/gui.json" + ), + self.maa_set_path, + ) + + with self.maa_set_path.open(mode="r", encoding="utf-8") as f: + data = json.load(f) + + # 切换配置 + if data["Current"] != "Default": + + data["Configurations"]["Default"] = data["Configurations"][data["Current"]] + data["Current"] = "Default" + + # 时间设置 + for i in range(1, 9): + data["Global"][f"Timer.Timer{i}"] = "False" + + # 自动代理配置 + if self.mode == "自动代理" and mode in ["Annihilation", "Routine"]: + + if (self.index == len(self.user_list) - 1) or ( + self.user_config[ + uuid.UUID(self.user_list[self.index + 1]["user_id"]) + ].get("Info", "Mode") + == "详细" + ): + data["Configurations"]["Default"][ + "MainFunction.PostActions" + ] = "12" # 完成后退出MAA和模拟器 + else: + + data["Configurations"]["Default"]["MainFunction.PostActions"] = ( + METHOD_BOOK[self.script_config.get("Run", "TaskTransitionMethod")] + ) # 完成后行为 + + data["Configurations"]["Default"][ + "Start.RunDirectly" + ] = "True" # 启动MAA后直接运行 + data["Configurations"]["Default"]["Start.OpenEmulatorAfterLaunch"] = str( + self.if_open_emulator + ) # 启动MAA后自动开启模拟器 + + data["Global"][ + "VersionUpdate.ScheduledUpdateCheck" + ] = "False" # 定时检查更新 + data["Global"][ + "VersionUpdate.AutoDownloadUpdatePackage" + ] = "True" # 自动下载更新包 + data["Global"][ + "VersionUpdate.AutoInstallUpdatePackage" + ] = "False" # 自动安装更新包 + + if Config.get("Function", "IfSilence"): + data["Global"]["Start.MinimizeDirectly"] = "True" # 启动MAA后直接最小化 + data["Global"]["GUI.UseTray"] = "True" # 显示托盘图标 + data["Global"]["GUI.MinimizeToTray"] = "True" # 最小化时隐藏至托盘 + + # 客户端类型 + data["Configurations"]["Default"]["Start.ClientType"] = ( + self.cur_user_data.get("Info", "Server") + ) + + # 账号切换 + if self.cur_user_data.get("Info", "Server") == "Official": + data["Configurations"]["Default"]["Start.AccountName"] = ( + f"{self.cur_user_data.get("Info", "Id")[:3]}****{self.cur_user_data.get("Info", "Id")[7:]}" + if len(self.cur_user_data.get("Info", "Id")) == 11 + else self.cur_user_data.get("Info", "Id") + ) + elif self.cur_user_data.get("Info", "Server") == "Bilibili": + data["Configurations"]["Default"]["Start.AccountName"] = ( + self.cur_user_data.get("Info", "Id") + ) + + # 按预设设定任务 + data["Configurations"]["Default"][ + "TaskQueue.WakeUp.IsChecked" + ] = "True" # 开始唤醒 + data["Configurations"]["Default"]["TaskQueue.Recruiting.IsChecked"] = ( + self.task_dict["Recruiting"] + ) # 自动公招 + data["Configurations"]["Default"]["TaskQueue.Base.IsChecked"] = ( + self.task_dict["Base"] + ) # 基建换班 + data["Configurations"]["Default"]["TaskQueue.Combat.IsChecked"] = ( + self.task_dict["Combat"] + ) # 刷理智 + data["Configurations"]["Default"]["TaskQueue.Mission.IsChecked"] = ( + self.task_dict["Mission"] + ) # 领取奖励 + data["Configurations"]["Default"]["TaskQueue.Mall.IsChecked"] = ( + self.task_dict["Mall"] + ) # 获取信用及购物 + data["Configurations"]["Default"]["TaskQueue.AutoRoguelike.IsChecked"] = ( + self.task_dict["AutoRoguelike"] + ) # 自动肉鸽 + data["Configurations"]["Default"]["TaskQueue.Reclamation.IsChecked"] = ( + self.task_dict["Reclamation"] + ) # 生息演算 + + # 整理任务顺序 + if ( + mode == "Annihilation" + or self.cur_user_data.get("Info", "Mode") == "简洁" + ): + + data["Configurations"]["Default"]["TaskQueue.Order.WakeUp"] = "0" + data["Configurations"]["Default"]["TaskQueue.Order.Recruiting"] = "1" + data["Configurations"]["Default"]["TaskQueue.Order.Base"] = "2" + data["Configurations"]["Default"]["TaskQueue.Order.Combat"] = "3" + data["Configurations"]["Default"]["TaskQueue.Order.Mall"] = "4" + data["Configurations"]["Default"]["TaskQueue.Order.Mission"] = "5" + data["Configurations"]["Default"]["TaskQueue.Order.AutoRoguelike"] = "6" + data["Configurations"]["Default"]["TaskQueue.Order.Reclamation"] = "7" + + if isinstance(self.cur_user_data, MaaUserConfig): + try: + plan_data = self.cur_user_data.get_plan_info() + except Exception as e: + logger.error( + f"获取用户 {self.user_list[self.index]['user_id']} 的代理计划信息失败: {e}" + ) + plan_data = {} + else: + plan_data = {} + + data["Configurations"]["Default"]["MainFunction.UseMedicine"] = ( + "False" if plan_data.get("MedicineNumb", 0) == 0 else "True" + ) # 吃理智药 + data["Configurations"]["Default"]["MainFunction.UseMedicine.Quantity"] = ( + str(plan_data.get("MedicineNumb", 0)) + ) # 吃理智药数量 + data["Configurations"]["Default"]["MainFunction.Series.Quantity"] = ( + plan_data.get("SeriesNumb", "0") + ) # 连战次数 + + if mode == "Annihilation": + + data["Configurations"]["Default"][ + "MainFunction.Stage1" + ] = "Annihilation" # 主关卡 + data["Configurations"]["Default"][ + "MainFunction.Stage2" + ] = "" # 备选关卡1 + data["Configurations"]["Default"][ + "MainFunction.Stage3" + ] = "" # 备选关卡2 + data["Configurations"]["Default"][ + "Fight.RemainingSanityStage" + ] = "" # 剩余理智关卡 + data["Configurations"]["Default"][ + "MainFunction.Series.Quantity" + ] = "1" # 连战次数 + data["Configurations"]["Default"][ + "MainFunction.Annihilation.UseCustom" + ] = "True" # 自定义剿灭关卡 + data["Configurations"]["Default"]["MainFunction.Annihilation.Stage"] = ( + self.cur_user_data.get("Info", "Annihilation") + ) # 自定义剿灭关卡号 + data["Configurations"]["Default"][ + "Penguin.IsDrGrandet" + ] = "False" # 博朗台模式 + data["Configurations"]["Default"][ + "GUI.CustomStageCode" + ] = "True" # 手动输入关卡名 + data["Configurations"]["Default"][ + "GUI.UseAlternateStage" + ] = "False" # 使用备选关卡 + data["Configurations"]["Default"][ + "Fight.UseRemainingSanityStage" + ] = "False" # 使用剩余理智 + data["Configurations"]["Default"][ + "Fight.UseExpiringMedicine" + ] = "True" # 无限吃48小时内过期的理智药 + data["Configurations"]["Default"][ + "GUI.HideSeries" + ] = "False" # 隐藏连战次数 + + elif mode == "Routine": + + data["Configurations"]["Default"]["MainFunction.Stage1"] = ( + plan_data.get("Stage") if plan_data.get("Stage", "-") != "-" else "" + ) # 主关卡 + data["Configurations"]["Default"]["MainFunction.Stage2"] = ( + plan_data.get("Stage_1") + if plan_data.get("Stage_1", "-") != "-" + else "" + ) # 备选关卡1 + data["Configurations"]["Default"]["MainFunction.Stage3"] = ( + plan_data.get("Stage_2") + if plan_data.get("Stage_2", "-") != "-" + else "" + ) # 备选关卡2 + data["Configurations"]["Default"]["MainFunction.Stage4"] = ( + plan_data.get("Stage_3") + if plan_data.get("Stage_3", "-") != "-" + else "" + ) # 备选关卡3 + data["Configurations"]["Default"]["Fight.RemainingSanityStage"] = ( + plan_data.get("Stage_Remain") + if plan_data.get("Stage_Remain", "-") != "-" + else "" + ) # 剩余理智关卡 + data["Configurations"]["Default"][ + "GUI.UseAlternateStage" + ] = "True" # 备选关卡 + data["Configurations"]["Default"]["Fight.UseRemainingSanityStage"] = ( + "True" if plan_data.get("Stage_Remain", "-") != "-" else "False" + ) # 使用剩余理智 + + if self.cur_user_data.get("Info", "Mode") == "简洁": + + data["Configurations"]["Default"][ + "Penguin.IsDrGrandet" + ] = "False" # 博朗台模式 + data["Configurations"]["Default"][ + "GUI.CustomStageCode" + ] = "True" # 手动输入关卡名 + data["Configurations"]["Default"][ + "Fight.UseExpiringMedicine" + ] = "True" # 无限吃48小时内过期的理智药 + # 自定义基建配置 + if self.cur_user_data.get("Info", "InfrastMode") == "Custom": + + if ( + Path.cwd() + / f"data/{self.script_id}/{self.user_id}/Infrastructure/infrastructure.json" + ).exists(): + + data["Configurations"]["Default"][ + "Infrast.InfrastMode" + ] = "Custom" # 基建模式 + data["Configurations"]["Default"][ + "Infrast.CustomInfrastPlanIndex" + ] = self.cur_user_data.get( + "Data", "CustomInfrastPlanIndex" + ) # 自定义基建配置索引 + data["Configurations"]["Default"][ + "Infrast.DefaultInfrast" + ] = "user_defined" # 内置配置 + data["Configurations"]["Default"][ + "Infrast.IsCustomInfrastFileReadOnly" + ] = "False" # 自定义基建配置文件只读 + data["Configurations"]["Default"][ + "Infrast.CustomInfrastFile" + ] = str( + Path.cwd() + / f"data/{self.script_id}/{self.user_id}/Infrastructure/infrastructure.json" + ) # 自定义基建配置文件地址 + else: + logger.warning( + f"未选择用户 {self.cur_user_data.get('Info', 'Name')} 的自定义基建配置文件" + ) + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Info", + data={ + "warning": f"未选择用户 {self.cur_user_data.get('Info', 'Name')} 的自定义基建配置文件" + }, + ).model_dump() + ) + data["Configurations"]["Default"][ + "Infrast.CustomInfrastEnabled" + ] = "Normal" # 基建模式 + else: + data["Configurations"]["Default"]["Infrast.InfrastMode"] = ( + self.cur_user_data.get("Info", "InfrastMode") + ) # 基建模式 + + elif self.cur_user_data.get("Info", "Mode") == "详细": + + # 基建模式 + if ( + data["Configurations"]["Default"]["Infrast.InfrastMode"] + == "Custom" + ): + data["Configurations"]["Default"][ + "Infrast.CustomInfrastPlanIndex" + ] = self.cur_user_data.get( + "Data", "CustomInfrastPlanIndex" + ) # 自定义基建配置索引 + + # 人工排查配置 + elif self.mode == "人工排查" and self.cur_user_data is not None: + + data["Configurations"]["Default"][ + "MainFunction.PostActions" + ] = "8" # 完成后退出MAA + data["Configurations"]["Default"][ + "Start.RunDirectly" + ] = "True" # 启动MAA后直接运行 + data["Global"]["Start.MinimizeDirectly"] = "True" # 启动MAA后直接最小化 + data["Global"]["GUI.UseTray"] = "True" # 显示托盘图标 + data["Global"]["GUI.MinimizeToTray"] = "True" # 最小化时隐藏至托盘 + data["Configurations"]["Default"]["Start.OpenEmulatorAfterLaunch"] = str( + self.if_open_emulator + ) # 启动MAA后自动开启模拟器 + data["Global"][ + "VersionUpdate.ScheduledUpdateCheck" + ] = "False" # 定时检查更新 + data["Global"][ + "VersionUpdate.AutoDownloadUpdatePackage" + ] = "False" # 自动下载更新包 + data["Global"][ + "VersionUpdate.AutoInstallUpdatePackage" + ] = "False" # 自动安装更新包 + + # 客户端类型 + data["Configurations"]["Default"]["Start.ClientType"] = ( + self.cur_user_data.get("Info", "Server") + ) + + # 账号切换 + if self.cur_user_data.get("Info", "Server") == "Official": + data["Configurations"]["Default"]["Start.AccountName"] = ( + f"{self.cur_user_data.get('Info', 'Id')[:3]}****{self.cur_user_data.get('Info', 'Id')[7:]}" + if len(self.cur_user_data.get("Info", "Id")) == 11 + else self.cur_user_data.get("Info", "Id") + ) + elif self.cur_user_data.get("Info", "Server") == "Bilibili": + data["Configurations"]["Default"]["Start.AccountName"] = ( + self.cur_user_data.get("Info", "Id") + ) + + data["Configurations"]["Default"][ + "TaskQueue.WakeUp.IsChecked" + ] = "True" # 开始唤醒 + data["Configurations"]["Default"][ + "TaskQueue.Recruiting.IsChecked" + ] = "False" # 自动公招 + data["Configurations"]["Default"][ + "TaskQueue.Base.IsChecked" + ] = "False" # 基建换班 + data["Configurations"]["Default"][ + "TaskQueue.Combat.IsChecked" + ] = "False" # 刷理智 + data["Configurations"]["Default"][ + "TaskQueue.Mission.IsChecked" + ] = "False" # 领取奖励 + data["Configurations"]["Default"][ + "TaskQueue.Mall.IsChecked" + ] = "False" # 获取信用及购物 + data["Configurations"]["Default"][ + "TaskQueue.AutoRoguelike.IsChecked" + ] = "False" # 自动肉鸽 + data["Configurations"]["Default"][ + "TaskQueue.Reclamation.IsChecked" + ] = "False" # 生息演算 + + # 设置脚本配置 + elif self.mode == "设置脚本": + + data["Configurations"]["Default"][ + "MainFunction.PostActions" + ] = "0" # 完成后无动作 + data["Configurations"]["Default"][ + "Start.RunDirectly" + ] = "False" # 启动MAA后直接运行 + data["Configurations"]["Default"][ + "Start.OpenEmulatorAfterLaunch" + ] = "False" # 启动MAA后自动开启模拟器 + data["Global"][ + "VersionUpdate.ScheduledUpdateCheck" + ] = "False" # 定时检查更新 + data["Global"][ + "VersionUpdate.AutoDownloadUpdatePackage" + ] = "False" # 自动下载更新包 + data["Global"][ + "VersionUpdate.AutoInstallUpdatePackage" + ] = "False" # 自动安装更新包 + + if Config.get("Function", "IfSilence"): + data["Global"][ + "Start.MinimizeDirectly" + ] = "False" # 启动MAA后直接最小化 + + data["Configurations"]["Default"][ + "TaskQueue.WakeUp.IsChecked" + ] = "False" # 开始唤醒 + data["Configurations"]["Default"][ + "TaskQueue.Recruiting.IsChecked" + ] = "False" # 自动公招 + data["Configurations"]["Default"][ + "TaskQueue.Base.IsChecked" + ] = "False" # 基建换班 + data["Configurations"]["Default"][ + "TaskQueue.Combat.IsChecked" + ] = "False" # 刷理智 + data["Configurations"]["Default"][ + "TaskQueue.Mission.IsChecked" + ] = "False" # 领取奖励 + data["Configurations"]["Default"][ + "TaskQueue.Mall.IsChecked" + ] = "False" # 获取信用及购物 + data["Configurations"]["Default"][ + "TaskQueue.AutoRoguelike.IsChecked" + ] = "False" # 自动肉鸽 + data["Configurations"]["Default"][ + "TaskQueue.Reclamation.IsChecked" + ] = "False" # 生息演算 + + elif mode == "Update": + + data["Configurations"]["Default"][ + "MainFunction.PostActions" + ] = "0" # 完成后无动作 + data["Configurations"]["Default"][ + "Start.RunDirectly" + ] = "False" # 启动MAA后直接运行 + data["Configurations"]["Default"][ + "Start.OpenEmulatorAfterLaunch" + ] = "False" # 启动MAA后自动开启模拟器 + data["Global"]["Start.MinimizeDirectly"] = "True" # 启动MAA后直接最小化 + data["Global"]["GUI.UseTray"] = "True" # 显示托盘图标 + data["Global"]["GUI.MinimizeToTray"] = "True" # 最小化时隐藏至托盘 + data["Global"][ + "VersionUpdate.package" + ] = self.maa_update_package # 更新包路径 + + data["Global"][ + "VersionUpdate.ScheduledUpdateCheck" + ] = "False" # 定时检查更新 + data["Global"][ + "VersionUpdate.AutoDownloadUpdatePackage" + ] = "False" # 自动下载更新包 + data["Global"][ + "VersionUpdate.AutoInstallUpdatePackage" + ] = "True" # 自动安装更新包 + data["Configurations"]["Default"][ + "TaskQueue.WakeUp.IsChecked" + ] = "False" # 开始唤醒 + data["Configurations"]["Default"][ + "TaskQueue.Recruiting.IsChecked" + ] = "False" # 自动公招 + data["Configurations"]["Default"][ + "TaskQueue.Base.IsChecked" + ] = "False" # 基建换班 + data["Configurations"]["Default"][ + "TaskQueue.Combat.IsChecked" + ] = "False" # 刷理智 + data["Configurations"]["Default"][ + "TaskQueue.Mission.IsChecked" + ] = "False" # 领取奖励 + data["Configurations"]["Default"][ + "TaskQueue.Mall.IsChecked" + ] = "False" # 获取信用及购物 + data["Configurations"]["Default"][ + "TaskQueue.AutoRoguelike.IsChecked" + ] = "False" # 自动肉鸽 + data["Configurations"]["Default"][ + "TaskQueue.Reclamation.IsChecked" + ] = "False" # 生息演算 + + # 启动模拟器仅生效一次 + if self.mode != "设置脚本" and mode != "Update" and self.if_open_emulator: + self.if_open_emulator = False + + # 覆写配置文件 + with self.maa_set_path.open(mode="w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=4) + + logger.success(f"MAA运行参数配置完成: {mode}") + + return data + + def agree_bilibili(self, if_agree): + """向MAA写入Bilibili协议相关任务""" + logger.info(f"Bilibili协议相关任务状态: {'启用' if if_agree else '禁用'}") + + with self.maa_tasks_path.open(mode="r", encoding="utf-8") as f: + data = json.load(f) + + if if_agree and Config.get("Function", "IfAgreeBilibili"): + data["BilibiliAgreement_AUTO"] = { + "algorithm": "OcrDetect", + "action": "ClickSelf", + "text": ["同意"], + "maxTimes": 5, + "Doc": "关闭B服用户协议", + "next": ["StartUpThemes#next"], + } + if "BilibiliAgreement_AUTO" not in data["StartUpThemes"]["next"]: + data["StartUpThemes"]["next"].insert(0, "BilibiliAgreement_AUTO") + else: + if "BilibiliAgreement_AUTO" in data: + data.pop("BilibiliAgreement_AUTO") + if "BilibiliAgreement_AUTO" in data["StartUpThemes"]["next"]: + data["StartUpThemes"]["next"].remove("BilibiliAgreement_AUTO") + + with self.maa_tasks_path.open(mode="w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=4) + + async def push_notification(self, mode: str, title: str, message) -> None: + """通过所有渠道推送通知""" + logger.info(f"开始推送通知, 模式: {mode}, 标题: {title}") + + env = Environment(loader=FileSystemLoader(str(Path.cwd() / "res/html"))) + + if mode == "代理结果" and ( + Config.get("Notify", "SendTaskResultTime") == "任何时刻" + or ( + Config.get("Notify", "SendTaskResultTime") == "仅失败时" + and message["uncompleted_count"] != 0 + ) + ): + # 生成文本通知内容 + message_text = ( + f"任务开始时间: {message['start_time']}, 结束时间: {message['end_time']}\n" + f"已完成数: {message['completed_count']}, 未完成数: {message['uncompleted_count']}\n\n" + ) + + if len(message["failed_user"]) > 0: + message_text += f"{self.mode[2:4]}未成功的用户: \n{"\n".join(message["failed_user"])}\n" + if len(message["waiting_user"]) > 0: + message_text += f"\n未开始{self.mode[2:4]}的用户: \n{"\n".join(message["waiting_user"])}\n" + + # 生成HTML通知内容 + message["failed_user"] = "、".join(message["failed_user"]) + message["waiting_user"] = "、".join(message["waiting_user"]) + + template = env.get_template("MAA_result.html") + message_html = template.render(message) + + # ServerChan的换行是两个换行符。故而将\n替换为\n\n + serverchan_message = message_text.replace("\n", "\n\n") + + # 发送全局通知 + + if Config.get("Notify", "IfSendMail"): + Notify.send_mail( + "网页", title, message_html, Config.get("Notify", "ToAddress") + ) + + if Config.get("Notify", "IfServerChan"): + Notify.ServerChanPush( + title, + f"{serverchan_message}\n\nAUTO_MAA 敬上", + Config.get("Notify", "ServerChanKey"), + ) + + if Config.get("Notify", "IfCompanyWebHookBot"): + Notify.WebHookPush( + title, + f"{message_text}\n\nAUTO_MAA 敬上", + Config.get("Notify", "CompanyWebHookBotUrl"), + ) + + elif mode == "统计信息": + + # 生成文本通知内容 + formatted = [] + if "drop_statistics" in message: + for stage, items in message["drop_statistics"].items(): + formatted.append(f"掉落统计({stage}):") + for item, quantity in items.items(): + formatted.append(f" {item}: {quantity}") + drop_text = "\n".join(formatted) + + formatted = ["招募统计:"] + if "recruit_statistics" in message: + for star, count in message["recruit_statistics"].items(): + formatted.append(f" {star}: {count}") + recruit_text = "\n".join(formatted) + + message_text = ( + f"开始时间: {message['start_time']}\n" + f"结束时间: {message['end_time']}\n" + f"MAA执行结果: {message['maa_result']}\n\n" + f"{recruit_text}\n" + f"{drop_text}" + ) + + # 生成HTML通知内容 + template = env.get_template("MAA_statistics.html") + message_html = template.render(message) + + # ServerChan的换行是两个换行符。故而将\n替换为\n\n + serverchan_message = message_text.replace("\n", "\n\n") + + # 发送全局通知 + if Config.get("Notify", "IfSendStatistic"): + + if Config.get("Notify", "IfSendMail"): + Notify.send_mail( + "网页", title, message_html, Config.get("Notify", "ToAddress") + ) + + if Config.get("Notify", "IfServerChan"): + Notify.ServerChanPush( + title, + f"{serverchan_message}\n\nAUTO_MAA 敬上", + Config.get("Notify", "ServerChanKey"), + ) + + if Config.get("Notify", "IfCompanyWebHookBot"): + Notify.WebHookPush( + title, + f"{message_text}\n\nAUTO_MAA 敬上", + Config.get("Notify", "CompanyWebHookBotUrl"), + ) + + # 发送用户单独通知 + if self.cur_user_data.get("Notify", "Enabled") and self.cur_user_data.get( + "Notify", "IfSendStatistic" + ): + + # 发送邮件通知 + if self.cur_user_data.get("Notify", "IfSendMail"): + if self.cur_user_data.get("Notify", "ToAddress"): + Notify.send_mail( + "网页", + title, + message_html, + self.cur_user_data.get("Notify", "ToAddress"), + ) + else: + logger.error(f"用户邮箱地址为空, 无法发送用户单独的邮件通知") + + # 发送ServerChan通知 + if self.cur_user_data.get("Notify", "IfServerChan"): + if self.cur_user_data.get("Notify", "ServerChanKey"): + Notify.ServerChanPush( + title, + f"{serverchan_message}\n\nAUTO_MAA 敬上", + self.cur_user_data.get("Notify", "ServerChanKey"), + ) + else: + logger.error( + "用户ServerChan密钥为空, 无法发送用户单独的ServerChan通知" + ) + + # 推送CompanyWebHookBot通知 + if self.cur_user_data.get("Notify", "IfCompanyWebHookBot"): + if self.cur_user_data.get("Notify", "CompanyWebHookBotUrl"): + Notify.WebHookPush( + title, + f"{message_text}\n\nAUTO_MAA 敬上", + self.cur_user_data.get("Notify", "CompanyWebHookBotUrl"), + ) + else: + logger.error( + "用户CompanyWebHookBot密钥为空, 无法发送用户单独的CompanyWebHookBot通知" + ) + + elif mode == "公招六星": + + # 生成HTML通知内容 + template = env.get_template("MAA_six_star.html") + + message_html = template.render(message) + + # 发送全局通知 + if Config.get("Notify", "IfSendSixStar"): + + if Config.get("Notify", "IfSendMail"): + Notify.send_mail( + "网页", title, message_html, Config.get("Notify", "ToAddress") + ) + + if Config.get("Notify", "IfServerChan"): + Notify.ServerChanPush( + title, + "好羡慕~\n\nAUTO_MAA 敬上", + Config.get("Notify", "ServerChanKey"), + ) + + if Config.get("Notify", "IfCompanyWebHookBot"): + Notify.WebHookPush( + title, + "好羡慕~\n\nAUTO_MAA 敬上", + Config.get("Notify", "CompanyWebHookBotUrl"), + ) + Notify.CompanyWebHookBotPushImage( + Path.cwd() / "res/images/notification/six_star.png", + Config.get("Notify", "CompanyWebHookBotUrl"), + ) + + # 发送用户单独通知 + if self.cur_user_data.get("Notify", "Enabled") and self.cur_user_data.get( + "Notify", "IfSendSixStar" + ): + + # 发送邮件通知 + if self.cur_user_data.get("Notify", "IfSendMail"): + if self.cur_user_data.get("Notify", "ToAddress"): + Notify.send_mail( + "网页", + title, + message_html, + self.cur_user_data.get("Notify", "ToAddress"), + ) + else: + logger.error("用户邮箱地址为空, 无法发送用户单独的邮件通知") + + # 发送ServerChan通知 + if self.cur_user_data.get("Notify", "IfServerChan"): + + if self.cur_user_data.get("Notify", "ServerChanKey"): + Notify.ServerChanPush( + title, + "好羡慕~\n\nAUTO_MAA 敬上", + self.cur_user_data.get("Notify", "ServerChanKey"), + ) + else: + logger.error( + "用户ServerChan密钥为空, 无法发送用户单独的ServerChan通知" + ) + + # 推送CompanyWebHookBot通知 + if self.cur_user_data.get("Notify", "IfCompanyWebHookBot"): + if self.cur_user_data.get("Notify", "CompanyWebHookBotUrl"): + Notify.WebHookPush( + title, + "好羡慕~\n\nAUTO_MAA 敬上", + self.cur_user_data.get("Notify", "CompanyWebHookBotUrl"), + ) + Notify.CompanyWebHookBotPushImage( + Path.cwd() / "res/images/notification/six_star.png", + self.cur_user_data.get("Notify", "CompanyWebHookBotUrl"), + ) + else: + logger.error( + "用户CompanyWebHookBot密钥为空, 无法发送用户单独的CompanyWebHookBot通知" + ) + + return None diff --git a/app/ui/__init__.py b/app/task/__init__.py similarity index 80% rename from app/ui/__init__.py rename to app/task/__init__.py index ea7d35a..9a347ca 100644 --- a/app/ui/__init__.py +++ b/app/task/__init__.py @@ -1,5 +1,6 @@ # AUTO_MAA:A MAA Multi Account Management and Automation Tool # Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox # This file is part of AUTO_MAA. @@ -18,18 +19,13 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA图形化界面包 -v4.4 -作者:DLmaster_361 -""" - -__version__ = "4.2.0" +__version__ = "5.0.0" __author__ = "DLmaster361 " __license__ = "GPL-3.0 license" -from .main_window import AUTO_MAA -from .Widget import ProgressRingMessageBox -__all__ = ["AUTO_MAA", "ProgressRingMessageBox"] +from .skland import skland_sign_in +from .general import GeneralManager +from .MAA import MaaManager + +__all__ = ["skland_sign_in", "GeneralManager", "MaaManager"] diff --git a/app/task/general.py b/app/task/general.py new file mode 100644 index 0000000..aa99917 --- /dev/null +++ b/app/task/general.py @@ -0,0 +1,1080 @@ +# 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 . + +# Contact: DLmaster_361@163.com + + +import os +import sys +import uuid +import shutil +import asyncio +import subprocess +from pathlib import Path +from fastapi import WebSocket +from datetime import datetime, timedelta +from jinja2 import Environment, FileSystemLoader +from typing import Union, List, Dict, Optional + + +from app.core import Config, GeneralConfig, GeneralUserConfig +from app.models.schema import WebSocketMessage +from app.models.ConfigBase import MultipleConfig +from app.services import Notify, System +from app.utils import get_logger, LogMonitor, ProcessManager, strptime + + +logger = get_logger("通用调度器") + + +class GeneralManager: + """通用脚本通用控制器""" + + def __init__( + self, mode: str, script_id: uuid.UUID, user_id: Optional[uuid.UUID], ws_id: str + ): + super(GeneralManager, self).__init__() + + self.mode = mode + self.script_id = script_id + self.user_id = user_id + self.ws_id = ws_id + + self.game_process_manager = ProcessManager() + self.general_process_manager = ProcessManager() + self.wait_event = asyncio.Event() + + self.general_logs = [] + self.general_result = "Wait" + + async def configure(self): + """提取配置信息""" + + await Config.ScriptConfig[self.script_id].lock() + + self.script_config = Config.ScriptConfig[self.script_id] + if isinstance(self.script_config, GeneralConfig): + self.user_config = MultipleConfig([GeneralUserConfig]) + await self.user_config.load(await self.script_config.UserData.toDict()) + + self.script_root_path = Path(self.script_config.get("Info", "RootPath")) + self.script_path = Path(self.script_config.get("Script", "ScriptPath")) + + arguments_list = [] + path_list = [] + + for argument in [ + _.strip() + for _ in str(self.script_config.get("Script", "Arguments")).split("|") + if _.strip() + ]: + arg = [_.strip() for _ in argument.split("%") if _.strip()] + if len(arg) > 1: + path_list.append((self.script_path / arg[0]).resolve()) + arguments_list.append( + [_.strip() for _ in arg[1].split(" ") if _.strip()] + ) + elif len(arg) > 0: + path_list.append(self.script_path) + arguments_list.append( + [_.strip() for _ in arg[0].split(" ") if _.strip()] + ) + + self.script_exe_path = path_list[0] if len(path_list) > 0 else self.script_path + self.script_arguments = arguments_list[0] if len(arguments_list) > 0 else [] + self.script_set_exe_path = ( + path_list[1] if len(path_list) > 1 else self.script_path + ) + self.script_set_arguments = arguments_list[1] if len(arguments_list) > 1 else [] + + self.script_config_path = Path(self.script_config.get("Script", "ConfigPath")) + self.script_log_path = ( + Path(self.script_config.get("Script", "LogPath")).with_stem( + datetime.now().strftime( + self.script_config.get("Script", "LogPathFormat") + ) + ) + if self.script_config.get("Script", "LogPathFormat") + else Path(self.script_config.get("Script", "LogPath")) + ) + if not self.script_log_path.exists(): + self.script_log_path.parent.mkdir(parents=True, exist_ok=True) + self.script_log_path.touch(exist_ok=True) + self.game_path = Path(self.script_config.get("Game", "Path")) + self.log_time_range = ( + self.script_config.get("Script", "LogTimeStart") - 1, + self.script_config.get("Script", "LogTimeEnd"), + ) + self.success_log = ( + [ + _.strip() + for _ in self.script_config.get("Script", "SuccessLog").split("|") + ] + if self.script_config.get("Script", "SuccessLog") + else [] + ) + self.error_log = [ + _.strip() for _ in self.script_config.get("Script", "ErrorLog").split("|") + ] + self.general_log_monitor = LogMonitor( + self.log_time_range, + self.script_config.get("Script", "LogTimeFormat"), + self.check_general_log, + ) + + logger.success(f"{self.script_id}已锁定, 通用配置提取完成") + + def check_config(self) -> str: + """检查配置是否可用""" + + if self.mode == "人工排查": + return "通用脚本不支持人工排查模式" + if self.mode == "设置脚本" and self.user_id is None: + return "设置脚本模式下用户ID不能为空" + + return "Success!" + + async def run(self): + """主进程, 运行通用脚本代理进程""" + + self.current_date = datetime.now().strftime("%m-%d") + self.curdate = Config.server_date().strftime("%Y-%m-%d") + self.begin_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + await self.configure() + self.check_result = self.check_config() + if self.check_result != "Success!": + logger.error(f"未通过配置检查: {self.check_result}") + await Config.send_json( + WebSocketMessage( + id=self.ws_id, type="Info", data={"Error": self.check_result} + ).model_dump() + ) + return + + # 记录配置文件 + logger.info(f"记录通用脚本配置文件: {self.script_config_path}") + (Path.cwd() / f"data/{self.script_id}/Temp").mkdir(parents=True, exist_ok=True) + if self.script_config_path.exists(): + if self.script_config.get("Script", "ConfigPathMode") == "Folder": + shutil.copytree( + self.script_config_path, + Path.cwd() / f"data/{self.script_id}/Temp", + dirs_exist_ok=True, + ) + elif self.script_config.get("Script", "ConfigPathMode") == "File": + shutil.copy( + self.script_config_path, + Path.cwd() / f"data/{self.script_id}/Temp/config.temp", + ) + + # 整理用户数据, 筛选需代理的用户 + if self.mode != "设置脚本": + + self.user_list: List[Dict[str, str]] = [ + { + "user_id": str(uid), + "status": "等待", + "name": config.get("Info", "Name"), + } + for uid, config in self.user_config.items() + if config.get("Info", "Status") + and config.get("Info", "RemainedDay") != 0 + ] + + logger.info(f"用户列表创建完成, 已筛选子配置数: {len(self.user_list)}") + + # 自动代理模式 + if self.mode == "自动代理": + + # 执行情况预处理 + for _ in self.user_list: + if ( + self.user_config[uuid.UUID(_["user_id"])].get( + "Data", "LastProxyDate" + ) + != self.curdate + ): + await self.user_config[uuid.UUID(_["user_id"])].set( + "Data", "LastProxyDate", self.curdate + ) + await self.user_config[uuid.UUID(_["user_id"])].set( + "Data", "ProxyTimes", 0 + ) + + # 开始代理 + for self.index, user in enumerate(self.user_list): + + self.cur_user_data = self.user_config[uuid.UUID(user["user_id"])] + + if (self.script_config.get("Run", "ProxyTimesLimit") == 0) or ( + self.cur_user_data.get("Data", "ProxyTimes") + < self.script_config.get("Run", "ProxyTimesLimit") + ): + user["status"] = "运行" + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={"user_list": self.user_list}, + ).model_dump() + ) + else: + user["status"] = "跳过" + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={"user_list": self.user_list}, + ).model_dump() + ) + continue + + logger.info(f"开始代理用户: {user['user_id']}") + + self.user_start_time = datetime.now() + + self.run_book = False + + if not ( + Path.cwd() / f"data/{self.script_id}/{user['user_id']}/ConfigFile" + ).exists(): + + logger.error(f"用户: {user['user_id']} - 未找到配置文件") + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Info", + data={"Error": f"未找到 {user['user_id']} 的配置文件"}, + ).model_dump() + ) + self.run_book = False + continue + + # 尝试次数循环 + for i in range(self.script_config.get("Run", "RunTimesLimit")): + + if self.run_book: + break + + logger.info( + f"用户 {user['user_id']} - 尝试次数: {i + 1}/{self.script_config.get('Run','RunTimesLimit')}" + ) + + # 配置脚本 + await self.set_general() + # 记录当前时间 + self.log_start_time = datetime.now() + + # 执行任务前脚本 + if ( + self.cur_user_data.get("Info", "IfScriptBeforeTask") + and Path( + self.cur_user_data.get("Info", "ScriptBeforeTask") + ).exists() + ): + await self.execute_script_task( + Path(self.cur_user_data.get("Info", "ScriptBeforeTask")), + "脚本前任务", + ) + + # 启动游戏/模拟器 + if self.script_config.get("Game", "Enabled"): + + try: + logger.info( + f"启动游戏/模拟器: {self.game_path}, 参数: {self.script_config.get('Game','Arguments')}" + ) + await self.game_process_manager.open_process( + self.game_path, + str(self.script_config.get("Game", "Arguments")).split( + " " + ), + 0, + ) + except Exception as e: + logger.exception(f"启动游戏/模拟器时出现异常: {e}") + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Info", + data={"Error": f"启动游戏/模拟器时出现异常: {e}"}, + ).model_dump() + ) + self.general_result = "游戏/模拟器启动失败" + break + + # 更新静默进程标记有效时间 + if self.script_config.get("Game", "Type") == "Emulator": + logger.info( + f"更新静默进程标记: {self.game_path}, 标记有效时间: {datetime.now() + timedelta(seconds=self.script_config.get('Game', 'WaitTime') + 10)}" + ) + Config.silence_dict[ + self.game_path + ] = datetime.now() + timedelta( + seconds=self.script_config.get("Game", "WaitTime") + 10 + ) + + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={ + "log": f"正在等待游戏/模拟器完成启动\n请等待{self.script_config.get('Game', 'WaitTime')}s" + }, + ).model_dump() + ) + await asyncio.sleep(self.script_config.get("Game", "WaitTime")) + + # 运行脚本任务 + logger.info( + f"运行脚本任务: {self.script_exe_path}, 参数: {self.script_arguments}" + ) + await self.general_process_manager.open_process( + self.script_exe_path, + self.script_arguments, + tracking_time=( + 60 + if self.script_config.get("Script", "IfTrackProcess") + else 0 + ), + ) + + # 监测运行状态 + await self.general_log_monitor.start( + self.script_log_path, self.log_start_time + ) + self.wait_event.clear() + await self.wait_event.wait() + + await self.general_log_monitor.stop() + + # 处理通用脚本结果 + if self.general_result == "Success!": + + # 标记任务完成 + self.run_book = True + + logger.info( + f"用户: {user['user_id']} - 通用脚本进程完成代理任务" + ) + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={ + "log": "检测到通用脚本进程完成代理任务\n正在等待相关程序结束\n请等待10s" + }, + ).model_dump() + ) + + # 中止相关程序 + logger.info(f"中止相关程序: {self.script_exe_path}") + await self.general_process_manager.kill() + await System.kill_process(self.script_exe_path) + if self.script_config.get("Game", "Enabled"): + logger.info( + f"中止游戏/模拟器进程: {list(self.game_process_manager.tracked_pids)}" + ) + await self.game_process_manager.kill() + if self.script_config.get("Game", "IfForceClose"): + await System.kill_process(self.game_path) + + await asyncio.sleep(10) + + # 更新脚本配置文件 + if self.script_config.get("Script", "UpdateConfigMode") in [ + "Success", + "Always", + ]: + + if ( + self.script_config.get("Script", "ConfigPathMode") + == "Folder" + ): + shutil.copytree( + self.script_config_path, + Path.cwd() + / f"data/{self.script_id}/{user['user_id']}/ConfigFile", + dirs_exist_ok=True, + ) + elif ( + self.script_config.get("Script", "ConfigPathMode") + == "File" + ): + shutil.copy( + self.script_config_path, + Path.cwd() + / f"data/{self.script_id}/{user['user_id']}/ConfigFile" + / self.script_config_path.name, + ) + logger.success("通用脚本配置文件已更新") + + else: + logger.error( + f"配置: {user['user_id']} - 代理任务异常: {self.general_result}" + ) + # 打印中止信息 + # 此时, log变量内存储的就是出现异常的日志信息, 可以保存或发送用于问题排查 + await Config.send_json( + WebSocketMessage( + id=self.ws_id, + type="Update", + data={ + "log": f"{self.general_result}\n正在中止相关程序\n请等待10s" + }, + ).model_dump() + ) + + # 中止相关程序 + logger.info(f"中止相关程序: {self.script_exe_path}") + await self.general_process_manager.kill() + await System.kill_process(self.script_exe_path) + if self.script_config.get("Game", "Enabled"): + logger.info( + f"中止游戏/模拟器进程: {list(self.game_process_manager.tracked_pids)}" + ) + await self.game_process_manager.kill() + if self.script_config.get("Game", "IfForceClose"): + await System.kill_process(self.game_path) + + # 推送异常通知 + Notify.push_plyer( + "用户自动代理出现异常!", + f"用户 {user['name']} 的自动代理出现一次异常", + f"{user['name']} 的自动代理出现异常", + 3, + ) + + await asyncio.sleep(10) + + # 更新脚本配置文件 + if self.script_config.get("Script", "UpdateConfigMode") in [ + "Failure", + "Always", + ]: + + if ( + self.script_config.get("Script", "ConfigPathMode") + == "Folder" + ): + shutil.copytree( + self.script_config_path, + Path.cwd() + / f"data/{self.script_id}/{user['user_id']}/ConfigFile", + dirs_exist_ok=True, + ) + elif ( + self.script_config.get("Script", "ConfigPathMode") + == "File" + ): + shutil.copy( + self.script_config_path, + Path.cwd() + / f"data/{self.script_id}/{user['user_id']}/ConfigFile" + / self.script_config_path.name, + ) + logger.success("通用脚本配置文件已更新") + + # 执行任务后脚本 + if ( + self.cur_user_data.get("Info", "IfScriptAfterTask") + and Path( + self.cur_user_data.get("Info", "ScriptAfterTask") + ).exists() + ): + await self.execute_script_task( + Path(self.cur_user_data.get("Info", "ScriptAfterTask")), + "脚本后任务", + ) + + # 保存运行日志以及统计信息 + await Config.save_general_log( + Path.cwd() + / f"history/{self.curdate}/{user['name']}/{self.log_start_time.strftime('%H-%M-%S')}.log", + self.general_logs, + self.general_result, + ) + + await self.result_record() + + # 设置通用脚本模式 + elif self.mode == "设置脚本": + + # 配置通用脚本 + await self.set_general() + # 创建通用脚本任务 + logger.info( + f"运行脚本任务: {self.script_set_exe_path}, 参数: {self.script_set_arguments}" + ) + await self.general_process_manager.open_process( + self.script_set_exe_path, + self.script_set_arguments, + tracking_time=( + 60 if self.script_config.get("Script", "IfTrackProcess") else 0 + ), + ) + # 记录当前时间 + self.log_start_time = datetime.now() + + # 监测MAA运行状态 + await self.general_log_monitor.start( + self.script_log_path, self.log_start_time + ) + self.wait_event.clear() + await self.wait_event.wait() + await self.general_log_monitor.stop() + + async def result_record(self) -> None: + """记录用户结果信息""" + + # 发送统计信息 + statistics = { + "user_info": self.user_list[self.index]["name"], + "start_time": self.user_start_time.strftime("%Y-%m-%d %H:%M:%S"), + "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "user_result": "代理成功" if self.run_book else self.general_result, + } + await self.push_notification( + "统计信息", + f"{self.current_date} | 用户 {self.user_list[self.index]['name']} 的自动代理统计报告", + statistics, + ) + + if self.run_book: + # 成功完成代理的用户修改相关参数 + if ( + self.cur_user_data.get("Data", "ProxyTimes") == 0 + and self.cur_user_data.get("Info", "RemainedDay") != -1 + ): + await self.cur_user_data.set( + "Info", + "RemainedDay", + self.cur_user_data.get("Info", "RemainedDay") - 1, + ) + await self.cur_user_data.set( + "Data", + "ProxyTimes", + self.cur_user_data.get("Data", "ProxyTimes") + 1, + ) + self.user_list[self.index]["status"] = "完成" + logger.success( + f"用户 {self.user_list[self.index]['user_id']} 的自动代理任务已完成" + ) + Notify.push_plyer( + "成功完成一个自动代理任务!", + f"已完成用户 {self.user_list[self.index]['name']} 的自动代理任务", + f"已完成 {self.user_list[self.index]['name']} 的自动代理任务", + 3, + ) + else: + # 录入代理失败的用户 + logger.error( + f"用户 {self.user_list[self.index]['user_id']} 的自动代理任务未完成" + ) + self.user_list[self.index]["status"] = "异常" + + async def final_task(self, task: asyncio.Task): + """结束时的收尾工作""" + + logger.info("MAA 主任务已结束, 开始执行后续操作") + + await Config.ScriptConfig[self.script_id].unlock() + logger.success(f"已解锁脚本配置 {self.script_id}") + + # 结束各子任务 + await self.general_process_manager.kill(if_force=True) + await System.kill_process(self.script_exe_path) + await System.kill_process(self.script_set_exe_path) + await self.game_process_manager.kill() + await self.general_log_monitor.stop() + del self.general_process_manager + del self.game_process_manager + del self.general_log_monitor + + if self.check_result != "Success!": + return self.check_result + + if self.mode == "自动代理" and self.user_list[self.index]["status"] == "运行": + + self.general_result = "用户手动中止任务" + + # 更新脚本配置文件 + if self.script_config.get("Script", "UpdateConfigMode") in [ + "Failure", + "Always", + ]: + + if self.script_config.get("Script", "ConfigPathMode") == "Folder": + shutil.copytree( + self.script_config_path, + Path.cwd() + / f"data/{self.script_id}/{self.user_list[self.index]['user_id']}/ConfigFile", + dirs_exist_ok=True, + ) + elif self.script_config.get("Script", "ConfigPathMode") == "File": + shutil.copy( + self.script_config_path, + Path.cwd() + / f"data/{self.script_id}/{self.user_list[self.index]['user_id']}/ConfigFile" + / self.script_config_path.name, + ) + logger.success("通用脚本配置文件已更新") + + # 执行任务后脚本 + if ( + self.cur_user_data.get("Info", "IfScriptAfterTask") + and Path(self.cur_user_data.get("Info", "ScriptAfterTask")).exists() + ): + await self.execute_script_task( + Path(self.cur_user_data.get("Info", "ScriptAfterTask")), + "脚本后任务", + ) + + # 保存运行日志以及统计信息 + await Config.save_general_log( + Path.cwd() + / f"history/{self.curdate}/{self.user_list[self.index]['name']}/{self.log_start_time.strftime('%H-%M-%S')}.log", + self.general_logs, + self.general_result, + ) + + await self.result_record() + + # 导出结果 + if self.mode == "自动代理": + + # 更新用户数据 + sc = Config.ScriptConfig[self.script_id] + if isinstance(sc, GeneralConfig): + await sc.UserData.load(await self.user_config.toDict()) + await Config.ScriptConfig.save() + + error_user = [_["name"] for _ in self.user_list if _["status"] == "异常"] + over_user = [_["name"] for _ in self.user_list if _["status"] == "完成"] + wait_user = [_["name"] for _ in self.user_list if _["status"] == "等待"] + + # 保存运行日志 + title = ( + f"{self.current_date} | {self.script_config.get("Info", "Name")}的{self.mode}任务报告" + if self.script_config.get("Info", "Name") != "" + else f"{self.current_date} | {self.mode}任务报告" + ) + result = { + "title": f"{self.mode}任务报告", + "script_name": ( + self.script_config.get("Info", "Name") + if self.script_config.get("Info", "Name") != "" + else "空白" + ), + "start_time": self.begin_time, + "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "completed_count": len(over_user), + "uncompleted_count": len(error_user) + len(wait_user), + "failed_user": error_user, + "waiting_user": wait_user, + } + + # 生成结果文本 + result_text = ( + f"任务开始时间: {result['start_time']}, 结束时间: {result['end_time']}\n" + f"已完成数: {result['completed_count']}, 未完成数: {result['uncompleted_count']}\n\n" + ) + if len(result["failed_user"]) > 0: + result_text += ( + f"{self.mode}未成功的用户: \n{"\n".join(result['failed_user'])}\n" + ) + if len(result["waiting_user"]) > 0: + result_text += f"\n未开始{self.mode}的用户: \n{"\n".join(result['waiting_user'])}\n" + + # 推送代理结果通知 + Notify.push_plyer( + title.replace("报告", "已完成!"), + f"已完成配置数: {len(over_user)}, 未完成配置数: {len(error_user) + len(wait_user)}", + f"已完成配置数: {len(over_user)}, 未完成配置数: {len(error_user) + len(wait_user)}", + 10, + ) + await self.push_notification("代理结果", title, result) + + elif self.mode == "设置脚本": + + (Path.cwd() / f"data/{self.script_id}/{self.user_id}/ConfigFile").mkdir( + parents=True, exist_ok=True + ) + if self.script_config.get("Script", "ConfigPathMode") == "Folder": + shutil.copytree( + self.script_config_path, + Path.cwd() / f"data/{self.script_id}/{self.user_id}/ConfigFile", + dirs_exist_ok=True, + ) + logger.success( + f"通用脚本配置已保存到: {Path.cwd() / f'data/{self.script_id}/{self.user_id}/ConfigFile'}" + ) + elif self.script_config.get("Script", "ConfigPathMode") == "File": + shutil.copy( + self.script_config_path, + Path.cwd() + / f"data/{self.script_id}/{self.user_id}/ConfigFile" + / self.script_config_path.name, + ) + logger.success( + f"通用脚本配置已保存到: {Path.cwd() / f'data/{self.script_id}/{self.user_id}/ConfigFile' / self.script_config_path.name}" + ) + result_text = "" + + # 复原通用脚本配置文件 + if ( + self.script_config.get("Script", "ConfigPathMode") == "Folder" + and (Path.cwd() / f"data/{self.script_id}/Temp").exists() + ): + logger.info( + f"复原通用脚本配置文件: {Path.cwd() / f"data/{self.script_id}/Temp"}" + ) + shutil.copytree( + Path.cwd() / f"data/{self.script_id}/Temp", + self.script_config_path, + dirs_exist_ok=True, + ) + shutil.rmtree(Path.cwd() / f"data/{self.script_id}/Temp") + elif ( + self.script_config.get("Script", "ConfigPathMode") == "File" + and (Path.cwd() / f"data/{self.script_id}/Temp/config.temp").exists() + ): + logger.info( + f"复原通用脚本配置文件: {Path.cwd() / f"data/{self.script_id}/Temp/config.temp"}" + ) + shutil.copy( + Path.cwd() / f"data/{self.script_id}/Temp/config.temp", + self.script_config_path, + ) + shutil.rmtree(Path.cwd() / f"data/{self.script_id}/Temp") + + return result_text + + async def check_general_log(self, log_content: List[str]) -> None: + """获取脚本日志并检查以判断脚本程序运行状态""" + + self.general_logs = log_content + log = "".join(log_content) + + # 更新日志 + if await self.general_process_manager.is_running(): + + await Config.send_json( + WebSocketMessage( + id=self.ws_id, type="Update", data={"log": log} + ).model_dump() + ) + + if "自动代理" in self.mode: + + # 获取最近一条日志的时间 + latest_time = self.log_start_time + for _ in self.general_logs[::-1]: + try: + latest_time = strptime( + _[self.log_time_range[0] : self.log_time_range[1]], + self.script_config.get("Script", "LogTimeFormat"), + self.log_start_time, + ) + break + except ValueError: + pass + + logger.info(f"通用脚本最近一条日志时间: {latest_time}") + + for success_sign in self.success_log: + if success_sign in log: + self.general_result = "Success!" + break + else: + + if datetime.now() - latest_time > timedelta( + minutes=self.script_config.get("Run", "RunTimeLimit") + ): + self.general_result = "脚本进程超时" + else: + for error_sign in self.error_log: + if error_sign in log: + self.general_result = f"异常日志: {error_sign}" + break + else: + if await self.general_process_manager.is_running(): + self.general_result = "Wait" + elif self.success_log: + self.general_result = "脚本在完成任务前退出" + else: + self.general_result = "Success!" + + elif self.mode == "设置通用脚本": + if await self.general_process_manager.is_running(): + self.general_result = "Wait" + else: + self.general_result = "Success!" + + logger.info(f"通用脚本日志分析结果: {self.general_result}") + + if self.general_result != "Wait": + + logger.info(f"MAA 任务结果: {self.general_result}, 日志锁已释放") + self.wait_event.set() + + async def set_general(self) -> None: + """配置通用脚本运行参数""" + logger.info(f"开始配置脚本运行参数: {self.mode}") + + # 配置前关闭可能未正常退出的脚本进程 + if self.mode == "自动代理": + await System.kill_process(self.script_exe_path) + elif self.mode == "设置脚本": + await System.kill_process(self.script_set_exe_path) + + # 预导入配置文件 + if self.mode == "设置脚本": + if ( + self.script_config.get("Script", "ConfigPathMode") == "Folder" + and ( + Path.cwd() / f"data/{self.script_id}/{self.user_id}/ConfigFile" + ).exists() + ): + shutil.copytree( + Path.cwd() / f"data/{self.script_id}/{self.user_id}/ConfigFile", + self.script_config_path, + dirs_exist_ok=True, + ) + elif ( + self.script_config.get("Script", "ConfigPathMode") == "File" + and ( + Path.cwd() + / f"data/{self.script_id}/{self.user_id}/ConfigFile" + / self.script_config_path.name + ).exists() + ): + shutil.copy( + Path.cwd() + / f"data/{self.script_id}/{self.user_id}/ConfigFile" + / self.script_config_path.name, + self.script_config_path, + ) + else: + if self.script_config.get("Script", "ConfigPathMode") == "Folder": + shutil.copytree( + Path.cwd() + / f"data/{self.script_id}/{self.user_list[self.index]['user_id']}/ConfigFile", + self.script_config_path, + dirs_exist_ok=True, + ) + elif self.script_config.get("Script", "ConfigPathMode") == "File": + shutil.copy( + Path.cwd() + / f"data/{self.script_id}/{self.user_list[self.index]['user_id']}/ConfigFile" + / self.script_config_path.name, + self.script_config_path, + ) + + logger.info(f"脚本运行参数配置完成: {self.mode}") + + async def execute_script_task(self, script_path: Path, task_name: str) -> bool: + """执行脚本任务并等待结束""" + + try: + logger.info(f"开始执行{task_name}: {script_path}") + + # 根据文件类型选择执行方式 + if script_path.suffix.lower() == ".py": + cmd = [sys.executable, script_path] + elif script_path.suffix.lower() in [".bat", ".cmd", ".exe"]: + cmd = [str(script_path)] + elif script_path.suffix.lower() == "": + logger.warning(f"{task_name}脚本没有指定后缀名, 无法执行") + return False + else: + # 使用系统默认程序打开 + os.startfile(str(script_path)) + return True + + # 执行脚本并等待结束 + result = subprocess.run( + cmd, + cwd=script_path.parent, + stdin=subprocess.DEVNULL, + creationflags=( + subprocess.CREATE_NO_WINDOW + if Config.get("Function", "IfSilence") + else 0 + ), + timeout=600, + capture_output=True, + errors="ignore", + ) + + if result.returncode == 0: + logger.info(f"{task_name}执行成功") + if result.stdout.strip(): + logger.info(f"{task_name}输出: {result.stdout}") + return True + else: + logger.error(f"{task_name}执行失败, 返回码: {result.returncode}") + if result.stderr.strip(): + logger.error(f"{task_name}错误输出: {result.stderr}") + return False + + except subprocess.TimeoutExpired: + logger.error(f"{task_name}执行超时") + return False + except Exception as e: + logger.exception(f"执行{task_name}时出现异常: {e}") + return False + + async def push_notification(self, mode: str, title: str, message) -> None: + """通过所有渠道推送通知""" + + logger.info(f"开始推送通知, 模式: {mode}, 标题: {title}") + + env = Environment(loader=FileSystemLoader(str(Path.cwd() / "res/html"))) + + if mode == "代理结果" and ( + Config.get("Notify", "SendTaskResultTime") == "任何时刻" + or ( + Config.get("Notify", "SendTaskResultTime") == "仅失败时" + and message["uncompleted_count"] != 0 + ) + ): + # 生成文本通知内容 + message_text = ( + f"任务开始时间: {message['start_time']}, 结束时间: {message['end_time']}\n" + f"已完成数: {message['completed_count']}, 未完成数: {message['uncompleted_count']}\n\n" + ) + + if len(message["failed_user"]) > 0: + message_text += f"{self.mode[2:4]}未成功的配置: \n{"\n".join(message['failed_user'])}\n" + if len(message["waiting_user"]) > 0: + message_text += f"\n未开始{self.mode[2:4]}的配置: \n{"\n".join(message['waiting_user'])}\n" + + # 生成HTML通知内容 + message["failed_user"] = "、".join(message["failed_user"]) + message["waiting_user"] = "、".join(message["waiting_user"]) + + template = env.get_template("general_result.html") + message_html = template.render(message) + + # ServerChan的换行是两个换行符。故而将\n替换为\n\n + serverchan_message = message_text.replace("\n", "\n\n") + + # 发送全局通知 + + if Config.get("Notify", "IfSendMail"): + Notify.send_mail( + "网页", title, message_html, Config.get("Notify", "ToAddress") + ) + + if Config.get("Notify", "IfServerChan"): + Notify.ServerChanPush( + title, + f"{serverchan_message}\n\nAUTO_MAA 敬上", + Config.get("Notify", "ServerChanKey"), + ) + + if Config.get("Notify", "IfCompanyWebHookBot"): + Notify.WebHookPush( + title, + f"{message_text}\n\nAUTO_MAA 敬上", + Config.get("Notify", "CompanyWebHookBotUrl"), + ) + + elif mode == "统计信息": + + message_text = ( + f"开始时间: {message['start_time']}\n" + f"结束时间: {message['end_time']}\n" + f"通用脚本执行结果: {message['user_result']}\n\n" + ) + + # 生成HTML通知内容 + template = env.get_template("general_statistics.html") + message_html = template.render(message) + + # ServerChan的换行是两个换行符。故而将\n替换为\n\n + serverchan_message = message_text.replace("\n", "\n\n") + + # 发送全局通知 + if Config.get("Notify", "IfSendStatistic"): + + if Config.get("Notify", "IfSendMail"): + Notify.send_mail( + "网页", title, message_html, Config.get("Notify", "ToAddress") + ) + + if Config.get("Notify", "IfServerChan"): + Notify.ServerChanPush( + title, + f"{serverchan_message}\n\nAUTO_MAA 敬上", + Config.get("Notify", "ServerChanKey"), + ) + + if Config.get("Notify", "IfCompanyWebHookBot"): + Notify.WebHookPush( + title, + f"{message_text}\n\nAUTO_MAA 敬上", + Config.get("Notify", "CompanyWebHookBotUrl"), + ) + + # 发送用户单独通知 + if self.cur_user_data.get("Notify", "Enabled") and self.cur_user_data.get( + "Notify", "IfSendStatistic" + ): + + # 发送邮件通知 + if self.cur_user_data.get("Notify", "IfSendMail"): + if self.cur_user_data.get("Notify", "ToAddress"): + Notify.send_mail( + "网页", + title, + message_html, + self.cur_user_data.get("Notify", "ToAddress"), + ) + else: + logger.error(f"用户邮箱地址为空, 无法发送用户单独的邮件通知") + + # 发送ServerChan通知 + if self.cur_user_data.get("Notify", "IfServerChan"): + if self.cur_user_data.get("Notify", "ServerChanKey"): + Notify.ServerChanPush( + title, + f"{serverchan_message}\n\nAUTO_MAA 敬上", + self.cur_user_data.get("Notify", "ServerChanKey"), + ) + else: + logger.error( + "用户ServerChan密钥为空, 无法发送用户单独的ServerChan通知" + ) + + # 推送CompanyWebHookBot通知 + if self.cur_user_data.get("Notify", "IfCompanyWebHookBot"): + if self.cur_user_data.get("Notify", "CompanyWebHookBotUrl"): + Notify.WebHookPush( + title, + f"{message_text}\n\nAUTO_MAA 敬上", + self.cur_user_data.get("Notify", "CompanyWebHookBotUrl"), + ) + else: + logger.error( + "用户CompanyWebHookBot密钥为空, 无法发送用户单独的CompanyWebHookBot通知" + ) + + return None diff --git a/app/services/skland.py b/app/task/skland.py similarity index 77% rename from app/services/skland.py rename to app/task/skland.py index 901a352..2ecf8ea 100644 --- a/app/services/skland.py +++ b/app/task/skland.py @@ -1,5 +1,6 @@ # 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: @@ -25,24 +26,21 @@ # 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, logger +from app.core import Config +from app.utils.logger import get_logger + +logger = get_logger("森空岛签到任务") -def skland_sign_in(token) -> dict: +async def skland_sign_in(token) -> dict: """森空岛签到""" app_code = "4ca99fa6b56cc2ba" @@ -76,11 +74,11 @@ 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 @@ -127,7 +125,7 @@ def skland_sign_in(token) -> dict: v["cred"] = cred return v - def login_by_token(token_code): + async def login_by_token(token_code): """ 使用token一步步拿到cred和sign_token @@ -140,10 +138,10 @@ def skland_sign_in(token) -> dict: token_code = t["data"]["content"] except: pass - grant_code = get_grant_code(token_code) - return get_cred(grant_code) + grant_code = await get_grant_code(token_code) + return await get_cred(grant_code) - def get_cred(grant): + async def get_cred(grant): """ 通过grant code获取cred和sign_token @@ -155,18 +153,15 @@ def skland_sign_in(token) -> dict: cred_code_url, json={"code": grant, "kind": 1}, headers=header_login, - proxies={ - "http": Config.get(Config.update_ProxyAddress), - "https": Config.get(Config.update_ProxyAddress), - }, + proxies=Config.get_proxies(), ).json() if rsp["code"] != 0: - raise Exception(f'获得cred失败:{rsp.get("messgae")}') + raise Exception(f"获得cred失败: {rsp.get('message')}") sign_token = rsp["data"]["token"] cred = rsp["data"]["cred"] return cred, sign_token - def get_grant_code(token): + async def get_grant_code(token): """ 通过token获取grant code @@ -177,18 +172,15 @@ def skland_sign_in(token) -> dict: grant_code_url, json={"appCode": app_code, "token": token, "type": 0}, headers=header_login, - proxies={ - "http": Config.get(Config.update_ProxyAddress), - "https": Config.get(Config.update_ProxyAddress), - }, + proxies=Config.get_proxies(), ).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"] - def get_binding_list(cred, sign_token): + async def get_binding_list(cred, sign_token): """ 查询已绑定的角色列表 @@ -202,21 +194,12 @@ def skland_sign_in(token) -> dict: headers=get_sign_header( binding_url, "get", None, copy_header(cred), sign_token ), - proxies={ - "http": Config.get(Config.update_ProxyAddress), - "https": Config.get(Config.update_ProxyAddress), - }, + proxies=Config.get_proxies(), ).json() if rsp["code"] != 0: - logger.error( - f"森空岛服务 | 请求角色列表出现问题:{rsp['message']}", - module="森空岛签到", - ) + logger.error(f"请求角色列表出现问题: {rsp['message']}") if rsp.get("message") == "用户未登录": - logger.error( - f"森空岛服务 | 用户登录可能失效了,请重新登录!", - module="森空岛签到", - ) + logger.error(f"用户登录可能失效了, 请重新登录!") return v # 只取明日方舟(arknights)的绑定账号 for i in rsp["data"]["list"]: @@ -225,7 +208,7 @@ def skland_sign_in(token) -> dict: v.extend(i.get("bindingList")) return v - def do_sign(cred, sign_token) -> dict: + async def do_sign(cred, sign_token) -> dict: """ 对所有绑定的角色进行签到 @@ -234,7 +217,7 @@ def skland_sign_in(token) -> dict: :return: 签到结果字典 """ - characters = get_binding_list(cred, sign_token) + characters = await get_binding_list(cred, sign_token) result = {"成功": [], "重复": [], "失败": [], "总计": len(characters)} for character in characters: @@ -249,10 +232,7 @@ def skland_sign_in(token) -> dict: sign_url, "post", body, copy_header(cred), sign_token ), json=body, - proxies={ - "http": Config.get(Config.update_ProxyAddress), - "https": Config.get(Config.update_ProxyAddress), - }, + proxies=Config.get_proxies(), ).json() if rsp["code"] != 0: @@ -276,10 +256,10 @@ def skland_sign_in(token) -> dict: # 主流程 try: # 拿到cred和sign_token - cred, sign_token = login_by_token(token) - time.sleep(1) + cred, sign_token = await login_by_token(token) + await asyncio.sleep(1) # 依次签到 - return do_sign(cred, sign_token) + return await do_sign(cred, sign_token) except Exception as e: - logger.exception(f"森空岛服务 | 森空岛签到失败: {e}", module="森空岛签到") + logger.exception(f"森空岛签到失败: {e}") return {"成功": [], "重复": [], "失败": [], "总计": 0} diff --git a/app/ui/Widget.py b/app/ui/Widget.py deleted file mode 100644 index 7af06f5..0000000 --- a/app/ui/Widget.py +++ /dev/null @@ -1,2207 +0,0 @@ -# AUTO_MAA:A MAA Multi Account Management and Automation Tool -# Copyright © 2024-2025 DLmaster361 - -# This file incorporates work covered by the following copyright and -# permission notice: -# -# ZenlessZoneZero-OneDragon Copyright © 2024-2025 DoctorReid -# https://github.com/DoctorReid/ZenlessZoneZero-OneDragon - -# 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 . - -# Contact: DLmaster_361@163.com - -""" -AUTO_MAA -AUTO_MAA组件 -v4.4 -作者:DLmaster_361 -""" - -import os -import re -import win32com.client -from pathlib import Path -from datetime import datetime -from functools import partial -from typing import Optional, Union, List, Dict -from urllib.parse import urlparse - -import markdown -from PySide6.QtCore import Qt, QTime, QTimer, QEvent, QSize -from PySide6.QtGui import QIcon, QPixmap, QPainter, QPainterPath -from PySide6.QtWidgets import ( - QApplication, - QWidget, - QLabel, - QHBoxLayout, - QVBoxLayout, - QSizePolicy, - QFileDialog, -) -from qfluentwidgets import ( - LineEdit, - PasswordLineEdit, - MessageBoxBase, - MessageBox, - SubtitleLabel, - SettingCard, - FluentIconBase, - Signal, - ComboBox, - EditableComboBox, - CheckBox, - IconWidget, - FluentIcon, - CardWidget, - BodyLabel, - QConfig, - ConfigItem, - OptionsConfigItem, - TeachingTip, - TransparentToolButton, - TeachingTipTailPosition, - ExpandSettingCard, - ExpandGroupSettingCard, - ToolButton, - PushButton, - PrimaryPushButton, - ProgressRing, - TextBrowser, - HeaderCardWidget, - SwitchButton, - IndicatorPosition, - Slider, - ScrollArea, - Pivot, - PivotItem, - FlyoutViewBase, - PushSettingCard, -) -from qfluentwidgets.common.overload import singledispatchmethod - -from app.core import Config -from app.services import Crypto - -from qfluentwidgets import SpinBox as SpinBoxBase -from qfluentwidgets import TimeEdit as TimeEditBase - - -class SpinBox(SpinBoxBase): - """忽视滚轮事件的SpinBox""" - - def wheelEvent(self, event): - event.ignore() - - -class TimeEdit(TimeEditBase): - """忽视滚轮事件的TimeEdit""" - - def wheelEvent(self, event): - event.ignore() - - -class LineEditMessageBox(MessageBoxBase): - """输入对话框""" - - def __init__(self, parent, title: str, content: Union[str, None], mode: str): - super().__init__(parent) - self.title = SubtitleLabel(title) - - if mode == "明文": - self.input = LineEdit() - self.input.setClearButtonEnabled(True) - elif mode == "密码": - self.input = PasswordLineEdit() - - self.input.returnPressed.connect(self.yesButton.click) - self.input.setPlaceholderText(content) - - # 将组件添加到布局中 - self.viewLayout.addWidget(self.title) - self.viewLayout.addWidget(self.input) - - self.input.setFocus() - - -class ComboBoxMessageBox(MessageBoxBase): - """选择对话框""" - - def __init__( - self, - parent, - title: str, - content: List[str], - text_list: List[List[str]], - data_list: List[List[str]] = None, - ): - super().__init__(parent) - self.title = SubtitleLabel(title) - - Widget = QWidget() - Layout = QHBoxLayout(Widget) - - self.input: List[ComboBox] = [] - - for i in range(len(content)): - - self.input.append(ComboBox()) - if data_list: - for j in range(len(text_list[i])): - self.input[i].addItem(text_list[i][j], userData=data_list[i][j]) - else: - self.input[i].addItems(text_list[i]) - self.input[i].setCurrentIndex(-1) - self.input[i].setPlaceholderText(content[i]) - Layout.addWidget(self.input[i]) - - # 将组件添加到布局中 - self.viewLayout.addWidget(self.title) - self.viewLayout.addWidget(Widget) - - -class ProgressRingMessageBox(MessageBoxBase): - """进度环倒计时对话框""" - - def __init__(self, parent, title: str): - super().__init__(parent) - self.title = SubtitleLabel(title) - - self.time = 100 if Config.args.mode == "gui" else 1 - Widget = QWidget() - Layout = QHBoxLayout(Widget) - self.ring = ProgressRing() - self.ring.setRange(0, 100) - self.ring.setValue(100) - self.ring.setTextVisible(True) - self.ring.setFormat("%p 秒") - self.ring.setFixedSize(100, 100) - self.ring.setStrokeWidth(4) - Layout.addWidget(self.ring) - - self.yesButton.hide() - self.cancelButton.clicked.connect(self.__quit_timer) - self.buttonLayout.insertStretch(1) - - # 将组件添加到布局中 - self.viewLayout.addWidget(self.title) - self.viewLayout.addWidget(Widget) - - self.timer = QTimer(self) - self.timer.timeout.connect(self.__update_time) - self.timer.start(1000) - - def __update_time(self): - - self.time -= 1 - self.ring.setValue(self.time) - - if self.time == 0: - self.timer.stop() - self.timer.deleteLater() - self.yesButton.click() - - def __quit_timer(self): - self.timer.stop() - self.timer.deleteLater() - - -class NoticeMessageBox(MessageBoxBase): - """公告对话框""" - - def __init__(self, parent, title: str, content: Dict[str, str]): - super().__init__(parent) - - self.index = self.NoticeIndexCard(title, content, self) - self.text = TextBrowser(self) - self.text.setOpenExternalLinks(True) - self.button_yes = PrimaryPushButton("确认", self) - self.button_cancel = PrimaryPushButton("取消", self) - - self.buttonGroup.hide() - - self.v_layout = QVBoxLayout() - self.v_layout.addWidget(self.text) - self.button_layout = QHBoxLayout() - self.button_layout.addWidget(self.button_yes) - self.button_layout.addWidget(self.button_cancel) - self.v_layout.addLayout(self.button_layout) - - self.h_layout = QHBoxLayout() - self.h_layout.addWidget(self.index) - self.h_layout.addLayout(self.v_layout) - self.h_layout.setStretch(0, 1) - self.h_layout.setStretch(1, 3) - - # 将组件添加到布局中 - self.viewLayout.addLayout(self.h_layout) - self.widget.setFixedSize(800, 600) - - self.index.index_changed.connect(self.__update_text) - self.button_yes.clicked.connect(self.yesButton.click) - self.button_cancel.clicked.connect(self.cancelButton.click) - self.index.index_cards[0].clicked.emit() - - def __update_text(self, index: int, text: str): - - self.currentIndex = index - - html = markdown.markdown(text).replace("\n", "") - html = re.sub( - r"(.*?)", - r"\1", - html, - ) - html = re.sub( - r'(]*href="[^"]+"[^>]*)>', r'\1 style="color: #009faa;">', html - ) - html = re.sub(r"
  • (.*?)

  • ", r"

    \1

    ", html) - html = re.sub(r"
      (.*?)
    ", r"\1", html) - - self.text.setHtml(f"{html}") - - class NoticeIndexCard(HeaderCardWidget): - - index_changed = Signal(int, str) - - def __init__(self, title: str, content: Dict[str, str], parent=None): - super().__init__(parent) - self.setTitle(title) - - self.Layout = QVBoxLayout() - self.viewLayout.addLayout(self.Layout) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - self.index_cards: List[QuantifiedItemCard] = [] - - for index, text in content.items(): - - self.index_cards.append(QuantifiedItemCard([index, ""])) - self.index_cards[-1].clicked.connect( - partial(self.index_changed.emit, len(self.index_cards), text) - ) - self.Layout.addWidget(self.index_cards[-1]) - - if not content: - self.Layout.addWidget(QuantifiedItemCard(["暂无信息", ""])) - self.currentIndex = 0 - - self.Layout.addStretch(1) - - -class SettingFlyoutView(FlyoutViewBase): - """设置卡二级菜单弹出组件""" - - def __init__( - self, - parent, - title: str, - setting_cards: List[Union[SettingCard, HeaderCardWidget]], - ): - super().__init__(parent) - - self.title = SubtitleLabel(title) - - content_widget = QWidget() - content_layout = QVBoxLayout(content_widget) - content_layout.setSpacing(0) - content_layout.setContentsMargins(0, 0, 11, 0) - for setting_card in setting_cards: - content_layout.addWidget(setting_card) - - scrollArea = ScrollArea() - scrollArea.setWidgetResizable(True) - scrollArea.setContentsMargins(0, 0, 0, 0) - scrollArea.setStyleSheet("background: transparent; border: none;") - scrollArea.setWidget(content_widget) - - self.viewLayout = QVBoxLayout(self) - self.viewLayout.setSpacing(12) - self.viewLayout.setContentsMargins(20, 16, 9, 16) - self.viewLayout.addWidget(self.title) - self.viewLayout.addWidget(scrollArea) - - self.setVisible(False) - - -class SwitchSettingCard(SettingCard): - """Setting card with switch button""" - - checkedChanged = Signal(bool) - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - qconfig: QConfig, - configItem: ConfigItem, - parent=None, - ): - super().__init__(icon, title, content, parent) - self.qconfig = qconfig - self.configItem = configItem - self.switchButton = SwitchButton(self.tr("Off"), self, IndicatorPosition.RIGHT) - - if configItem: - self.setValue(self.qconfig.get(configItem)) - configItem.valueChanged.connect(self.setValue) - - # add switch button to layout - self.hBoxLayout.addWidget(self.switchButton, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - self.switchButton.checkedChanged.connect(self.__onCheckedChanged) - - def __onCheckedChanged(self, isChecked: bool): - """switch button checked state changed slot""" - self.setValue(isChecked) - self.checkedChanged.emit(isChecked) - - def setValue(self, isChecked: bool): - if self.configItem: - self.qconfig.set(self.configItem, isChecked) - - self.switchButton.setChecked(isChecked) - self.switchButton.setText(self.tr("On") if isChecked else self.tr("Off")) - - def setChecked(self, isChecked: bool): - self.setValue(isChecked) - - def isChecked(self): - return self.switchButton.isChecked() - - -class RangeSettingCard(SettingCard): - """Setting card with a slider""" - - valueChanged = Signal(int) - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - qconfig: QConfig, - configItem: ConfigItem, - parent=None, - ): - super().__init__(icon, title, content, parent) - self.qconfig = qconfig - self.configItem = configItem - self.slider = Slider(Qt.Horizontal, self) - self.valueLabel = QLabel(self) - self.slider.setMinimumWidth(268) - - self.slider.setSingleStep(1) - self.slider.setRange(*configItem.range) - self.slider.setValue(configItem.value) - self.valueLabel.setNum(configItem.value) - - self.hBoxLayout.addStretch(1) - self.hBoxLayout.addWidget(self.valueLabel, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(6) - self.hBoxLayout.addWidget(self.slider, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - self.valueLabel.setObjectName("valueLabel") - configItem.valueChanged.connect(self.setValue) - self.slider.valueChanged.connect(self.__onValueChanged) - - def __onValueChanged(self, value: int): - """slider value changed slot""" - self.setValue(value) - self.valueChanged.emit(value) - - def setValue(self, value): - self.qconfig.set(self.configItem, value) - self.valueLabel.setNum(value) - self.valueLabel.adjustSize() - self.slider.setValue(value) - - -class ComboBoxSettingCard(SettingCard): - """Setting card with a combo box""" - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - texts: List[str], - qconfig: QConfig, - configItem: OptionsConfigItem, - parent=None, - ): - - super().__init__(icon, title, content, parent) - self.qconfig = qconfig - self.configItem = configItem - self.comboBox = ComboBox(self) - self.hBoxLayout.addWidget(self.comboBox, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - self.optionToText = {o: t for o, t in zip(configItem.options, texts)} - for text, option in zip(texts, configItem.options): - self.comboBox.addItem(text, userData=option) - - self.comboBox.setCurrentText(self.optionToText[self.qconfig.get(configItem)]) - self.comboBox.currentIndexChanged.connect(self._onCurrentIndexChanged) - configItem.valueChanged.connect(self.setValue) - - def _onCurrentIndexChanged(self, index: int): - - self.qconfig.set(self.configItem, self.comboBox.itemData(index)) - - def setValue(self, value): - if value not in self.optionToText: - return - - self.comboBox.setCurrentText(self.optionToText[value]) - self.qconfig.set(self.configItem, value) - - -class LineEditSettingCard(SettingCard): - """Setting card with LineEdit""" - - textChanged = Signal(str) - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - text: str, - qconfig: QConfig, - configItem: ConfigItem, - parent=None, - ): - - super().__init__(icon, title, content, parent) - self.qconfig = qconfig - self.configItem = configItem - self.LineEdit = LineEdit(self) - self.LineEdit.setMinimumWidth(250) - self.LineEdit.setPlaceholderText(text) - - self.hBoxLayout.addWidget(self.LineEdit, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - self.configItem.valueChanged.connect(self.setValue) - self.LineEdit.textChanged.connect(self.__textChanged) - - self.setValue(self.qconfig.get(configItem)) - - def __textChanged(self, content: str): - - self.configItem.valueChanged.disconnect(self.setValue) - self.qconfig.set(self.configItem, content.strip()) - self.configItem.valueChanged.connect(self.setValue) - - self.textChanged.emit(content.strip()) - - def setValue(self, content: str): - - self.LineEdit.textChanged.disconnect(self.__textChanged) - self.LineEdit.setText(content.strip()) - self.LineEdit.textChanged.connect(self.__textChanged) - - -class PasswordLineEditSettingCard(SettingCard): - """Setting card with PasswordLineEdit""" - - textChanged = Signal() - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - text: str, - algorithm: str, - qconfig: QConfig, - configItem: ConfigItem, - parent=None, - ): - - super().__init__(icon, title, content, parent) - self.algorithm = algorithm - self.qconfig = qconfig - self.configItem = configItem - self.LineEdit = PasswordLineEdit(self) - self.LineEdit.setMinimumWidth(200) - self.LineEdit.setPlaceholderText(text) - if algorithm == "AUTO": - self.LineEdit.setViewPasswordButtonVisible(False) - - self.hBoxLayout.addWidget(self.LineEdit, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - self.configItem.valueChanged.connect(self.setValue) - self.LineEdit.textChanged.connect(self.__textChanged) - - self.setValue(self.qconfig.get(configItem)) - - def __textChanged(self, content: str): - - self.configItem.valueChanged.disconnect(self.setValue) - if self.algorithm == "DPAPI": - self.qconfig.set(self.configItem, Crypto.win_encryptor(content)) - elif self.algorithm == "AUTO": - self.qconfig.set(self.configItem, Crypto.AUTO_encryptor(content)) - self.configItem.valueChanged.connect(self.setValue) - - self.textChanged.emit() - - def setValue(self, content: str): - - self.LineEdit.textChanged.disconnect(self.__textChanged) - if self.algorithm == "DPAPI": - self.LineEdit.setText(Crypto.win_decryptor(content)) - elif self.algorithm == "AUTO": - if Crypto.check_PASSWORD(Config.PASSWORD): - self.LineEdit.setText(Crypto.AUTO_decryptor(content, Config.PASSWORD)) - self.LineEdit.setPasswordVisible(True) - self.LineEdit.setReadOnly(False) - elif Config.PASSWORD: - self.LineEdit.setText("管理密钥错误") - self.LineEdit.setPasswordVisible(True) - self.LineEdit.setReadOnly(True) - else: - self.LineEdit.setText("************") - self.LineEdit.setPasswordVisible(False) - self.LineEdit.setReadOnly(True) - self.LineEdit.textChanged.connect(self.__textChanged) - - -class PathSettingCard(PushSettingCard): - - pathChanged = Signal(Path, Path) - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - mode: Union[str, OptionsConfigItem], - text: str, - qconfig: QConfig, - configItem: ConfigItem, - parent=None, - ): - super().__init__(text, icon, title, "未设置", parent) - - self.title = title - self.mode = mode - self.qconfig = qconfig - self.configItem = configItem - - if isinstance(mode, OptionsConfigItem): - - self.ComboBox = ComboBox(self) - self.hBoxLayout.insertWidget(5, self.ComboBox, 0, Qt.AlignRight) - - for option in mode.options: - self.ComboBox.addItem(option, userData=option) - - self.ComboBox.setCurrentText(self.qconfig.get(mode)) - self.ComboBox.currentIndexChanged.connect(self._onCurrentIndexChanged) - mode.valueChanged.connect(self.setValue) - - self.setContent(self.qconfig.get(self.configItem)) - - self.clicked.connect(self.ChoosePath) - self.configItem.valueChanged.connect( - lambda: self.setContent(self.qconfig.get(self.configItem)) - ) - - def ChoosePath(self): - """选择文件或文件夹路径""" - - old_path = Path(self.qconfig.get(self.configItem)) - - if self.get_mode() == "文件夹": - - folder = QFileDialog.getExistingDirectory( - self, "选择文件夹", self.qconfig.get(self.configItem) - ) - if folder: - self.qconfig.set(self.configItem, folder) - self.pathChanged.emit(old_path, Path(folder)) - - else: - - file_path, _ = QFileDialog.getOpenFileName( - self, "打开文件", self.qconfig.get(self.configItem), self.get_mode() - ) - if file_path: - file_path = self.analysis_lnk(file_path) - self.qconfig.set(self.configItem, str(file_path)) - self.pathChanged.emit(old_path, file_path) - - def analysis_lnk(self, path: str) -> Path: - """快捷方式解析""" - - lnk_path = Path(path) - if lnk_path.suffix == ".lnk": - try: - shell = win32com.client.Dispatch("WScript.Shell") - shortcut = shell.CreateShortcut(str(lnk_path)) - return Path(shortcut.TargetPath) - except Exception as e: - return lnk_path - else: - return lnk_path - - def get_mode(self) -> str: - """获取当前模式""" - if isinstance(self.mode, OptionsConfigItem): - return self.qconfig.get(self.mode) - return self.mode - - def _onCurrentIndexChanged(self, index: int): - - self.qconfig.set(self.mode, self.ComboBox.itemData(index)) - - def setValue(self, value): - - self.ComboBox.setCurrentText(value) - self.qconfig.set(self.mode, value) - - -class PushAndSwitchButtonSettingCard(SettingCard): - """Setting card with push & switch button""" - - checkedChanged = Signal(bool) - clicked = Signal() - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - text: str, - qconfig: QConfig, - configItem: ConfigItem, - parent=None, - ): - super().__init__(icon, title, content, parent) - self.qconfig = qconfig - self.configItem = configItem - self.switchButton = SwitchButton("关", self, IndicatorPosition.RIGHT) - self.button = PushButton(text, self) - self.hBoxLayout.addWidget(self.button, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - self.button.clicked.connect(self.clicked) - - if configItem: - self.setValue(self.qconfig.get(configItem)) - configItem.valueChanged.connect(self.setValue) - - # add switch button to layout - self.hBoxLayout.addWidget(self.switchButton, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - self.switchButton.checkedChanged.connect(self.__onCheckedChanged) - - def __onCheckedChanged(self, isChecked: bool): - """switch button checked state changed slot""" - self.setValue(isChecked) - self.checkedChanged.emit(isChecked) - - def setValue(self, isChecked: bool): - if self.configItem: - self.qconfig.set(self.configItem, isChecked) - - self.switchButton.setChecked(isChecked) - self.switchButton.setText("开" if isChecked else "关") - - -class PasswordLineAndSwitchButtonSettingCard(SettingCard): - """Setting card with PasswordLineEdit and SwitchButton""" - - textChanged = Signal() - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - text: str, - algorithm: str, - qconfig: QConfig, - configItem_bool: ConfigItem, - configItem_info: ConfigItem, - parent=None, - ): - - super().__init__(icon, title, content, parent) - self.algorithm = algorithm - self.qconfig = qconfig - self.configItem_bool = configItem_bool - self.configItem_info = configItem_info - self.LineEdit = PasswordLineEdit(self) - self.LineEdit.setMinimumWidth(200) - self.LineEdit.setPlaceholderText(text) - if algorithm == "AUTO": - self.LineEdit.setViewPasswordButtonVisible(False) - self.SwitchButton = SwitchButton(self) - - self.hBoxLayout.addWidget(self.LineEdit, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - self.hBoxLayout.addWidget(self.SwitchButton, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - self.configItem_info.valueChanged.connect(self.setInfo) - self.LineEdit.textChanged.connect(self.__textChanged) - self.configItem_bool.valueChanged.connect(self.SwitchButton.setChecked) - self.SwitchButton.checkedChanged.connect( - lambda isChecked: self.qconfig.set(self.configItem_bool, isChecked) - ) - - self.setInfo(self.qconfig.get(configItem_info)) - self.SwitchButton.setChecked(self.qconfig.get(configItem_bool)) - - def __textChanged(self, content: str): - - self.configItem_info.valueChanged.disconnect(self.setInfo) - if self.algorithm == "DPAPI": - self.qconfig.set(self.configItem_info, Crypto.win_encryptor(content)) - elif self.algorithm == "AUTO": - self.qconfig.set(self.configItem_info, Crypto.AUTO_encryptor(content)) - self.configItem_info.valueChanged.connect(self.setInfo) - - self.textChanged.emit() - - def setInfo(self, content: str): - - self.LineEdit.textChanged.disconnect(self.__textChanged) - if self.algorithm == "DPAPI": - self.LineEdit.setText(Crypto.win_decryptor(content)) - elif self.algorithm == "AUTO": - if Crypto.check_PASSWORD(Config.PASSWORD): - self.LineEdit.setText(Crypto.AUTO_decryptor(content, Config.PASSWORD)) - self.LineEdit.setPasswordVisible(True) - self.LineEdit.setReadOnly(False) - elif Config.PASSWORD: - self.LineEdit.setText("管理密钥错误") - self.LineEdit.setPasswordVisible(True) - self.LineEdit.setReadOnly(True) - else: - self.LineEdit.setText("************") - self.LineEdit.setPasswordVisible(False) - self.LineEdit.setReadOnly(True) - self.LineEdit.textChanged.connect(self.__textChanged) - - -class PushAndComboBoxSettingCard(SettingCard): - """Setting card with push & combo box""" - - clicked = Signal() - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - text: str, - texts: List[str], - qconfig: QConfig, - configItem: OptionsConfigItem, - parent=None, - ): - - super().__init__(icon, title, content, parent) - self.qconfig = qconfig - self.configItem = configItem - self.comboBox = ComboBox(self) - self.button = PushButton(text, self) - self.hBoxLayout.addWidget(self.button, 0, Qt.AlignRight) - self.hBoxLayout.addWidget(self.comboBox, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - self.button.clicked.connect(self.clicked) - - self.optionToText = {o: t for o, t in zip(configItem.options, texts)} - for text, option in zip(texts, configItem.options): - self.comboBox.addItem(text, userData=option) - - self.comboBox.setCurrentText(self.optionToText[self.qconfig.get(configItem)]) - self.comboBox.currentIndexChanged.connect(self._onCurrentIndexChanged) - configItem.valueChanged.connect(self.setValue) - - def _onCurrentIndexChanged(self, index: int): - - self.qconfig.set(self.configItem, self.comboBox.itemData(index)) - - def setValue(self, value): - if value not in self.optionToText: - return - - self.comboBox.setCurrentText(self.optionToText[value]) - self.qconfig.set(self.configItem, value) - - -class SpinBoxSettingCard(SettingCard): - """Setting card with SpinBox""" - - textChanged = Signal(int) - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - range: tuple[int, int], - qconfig: QConfig, - configItem: ConfigItem, - parent=None, - ): - - super().__init__(icon, title, content, parent) - self.qconfig = qconfig - self.configItem = configItem - self.SpinBox = SpinBox(self) - self.SpinBox.setRange(range[0], range[1]) - self.SpinBox.setMinimumWidth(150) - - if configItem: - self.setValue(qconfig.get(configItem)) - configItem.valueChanged.connect(self.setValue) - - self.hBoxLayout.addWidget(self.SpinBox, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - self.SpinBox.valueChanged.connect(self.__valueChanged) - - def __valueChanged(self, value: int): - self.setValue(value) - self.textChanged.emit(value) - - def setValue(self, value: int): - if self.configItem: - self.qconfig.set(self.configItem, value) - - self.SpinBox.setValue(value) - - -class NoOptionComboBoxSettingCard(SettingCard): - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - value: List[str], - texts: List[str], - qconfig: QConfig, - configItem: ConfigItem, - parent=None, - ): - - super().__init__(icon, title, content, parent) - self.qconfig = qconfig - self.configItem = configItem - self.comboBox = ComboBox(self) - self.comboBox.setMinimumWidth(250) - self.hBoxLayout.addWidget(self.comboBox, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - self.optionToText = {o: t for o, t in zip(value, texts)} - for text, option in zip(texts, value): - self.comboBox.addItem(text, userData=option) - - self.comboBox.setCurrentText(self.optionToText[self.qconfig.get(configItem)]) - self.comboBox.currentIndexChanged.connect(self._onCurrentIndexChanged) - configItem.valueChanged.connect(self.setValue) - - def _onCurrentIndexChanged(self, index: int): - - self.qconfig.set(self.configItem, self.comboBox.itemData(index)) - - def setValue(self, value): - if value not in self.optionToText: - return - - self.comboBox.setCurrentText(self.optionToText[value]) - self.qconfig.set(self.configItem, value) - - def reLoadOptions(self, value: List[str], texts: List[str]): - - self.comboBox.currentIndexChanged.disconnect(self._onCurrentIndexChanged) - self.comboBox.clear() - self.optionToText = {o: t for o, t in zip(value, texts)} - for text, option in zip(texts, value): - self.comboBox.addItem(text, userData=option) - self.comboBox.setCurrentText( - self.optionToText[self.qconfig.get(self.configItem)] - ) - self.comboBox.currentIndexChanged.connect(self._onCurrentIndexChanged) - - -class EditableComboBoxSettingCard(SettingCard): - """Setting card with EditableComboBox""" - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - value: List[str], - texts: List[str], - qconfig: QConfig, - configItem: ConfigItem, - parent=None, - ): - - super().__init__(icon, title, content, parent) - self.qconfig = qconfig - self.configItem = configItem - self.comboBox = self._EditableComboBox(self) - self.comboBox.setMinimumWidth(100) - self.hBoxLayout.addWidget(self.comboBox, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - self.optionToText = {o: t for o, t in zip(value, texts)} - for text, option in zip(texts, value): - self.comboBox.addItem(text, userData=option) - - if qconfig.get(configItem) not in self.optionToText: - self.optionToText[qconfig.get(configItem)] = qconfig.get(configItem) - self.comboBox.addItem( - qconfig.get(configItem), userData=qconfig.get(configItem) - ) - - self.comboBox.setCurrentText(self.optionToText[qconfig.get(configItem)]) - self.comboBox.currentIndexChanged.connect(self._onCurrentIndexChanged) - configItem.valueChanged.connect(self.setValue) - - def _onCurrentIndexChanged(self, index: int): - - self.qconfig.set( - self.configItem, - ( - self.comboBox.itemData(index) - if self.comboBox.itemData(index) - else self.comboBox.itemText(index) - ), - ) - - def setValue(self, value): - if value not in self.optionToText: - self.optionToText[value] = value - if self.comboBox.findText(value) == -1: - self.comboBox.addItem(value, userData=value) - else: - self.comboBox.setItemData(self.comboBox.findText(value), value) - - self.comboBox.setCurrentText(self.optionToText[value]) - self.qconfig.set(self.configItem, value) - - def reLoadOptions(self, value: List[str], texts: List[str]): - - self.comboBox.currentIndexChanged.disconnect(self._onCurrentIndexChanged) - self.comboBox.clear() - self.optionToText = {o: t for o, t in zip(value, texts)} - for text, option in zip(texts, value): - self.comboBox.addItem(text, userData=option) - if self.qconfig.get(self.configItem) not in self.optionToText: - self.optionToText[self.qconfig.get(self.configItem)] = self.qconfig.get( - self.configItem - ) - self.comboBox.addItem( - self.qconfig.get(self.configItem), - userData=self.qconfig.get(self.configItem), - ) - self.comboBox.setCurrentText( - self.optionToText[self.qconfig.get(self.configItem)] - ) - self.comboBox.currentIndexChanged.connect(self._onCurrentIndexChanged) - - class _EditableComboBox(EditableComboBox): - """EditableComboBox""" - - def __init__(self, parent=None): - super().__init__(parent) - - def _onReturnPressed(self): - if not self.text(): - return - - index = self.findText(self.text()) - if index >= 0 and index != self.currentIndex(): - self._currentIndex = index - self.currentIndexChanged.emit(index) - elif index == -1: - self.addItem(self.text()) - self.setCurrentIndex(self.count() - 1) - self.currentIndexChanged.emit(self.count() - 1) - - -class SpinBoxWithPlanSettingCard(SpinBoxSettingCard): - - textChanged = Signal(int) - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - range: tuple[int, int], - qconfig: QConfig, - configItem: ConfigItem, - parent=None, - ): - - super().__init__(icon, title, content, range, qconfig, configItem, parent) - - self.configItem_plan = None - - self.LineEdit = LineEdit(self) - self.LineEdit.setMinimumWidth(150) - self.LineEdit.setReadOnly(True) - self.LineEdit.setVisible(False) - - self.hBoxLayout.insertWidget(5, self.LineEdit, 0, Qt.AlignRight) - - def setText(self, value: int) -> None: - self.LineEdit.setText(str(value)) - - def switch_mode(self, mode: str) -> None: - """切换模式""" - - if mode == "固定": - - self.LineEdit.setVisible(False) - self.SpinBox.setVisible(True) - - elif mode == "计划": - - self.SpinBox.setVisible(False) - self.LineEdit.setVisible(True) - - def change_plan(self, configItem_plan: ConfigItem) -> None: - """切换计划""" - - if self.configItem_plan is not None: - self.configItem_plan.valueChanged.disconnect(self.setText) - self.configItem_plan = configItem_plan - self.configItem_plan.valueChanged.connect(self.setText) - self.setText(self.qconfig.get(self.configItem_plan)) - - -class ComboBoxWithPlanSettingCard(ComboBoxSettingCard): - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - texts: List[str], - qconfig: QConfig, - configItem: OptionsConfigItem, - parent=None, - ): - - super().__init__(icon, title, content, texts, qconfig, configItem, parent) - - self.configItem_plan = None - - self.LineEdit = LineEdit(self) - self.LineEdit.setMinimumWidth(150) - self.LineEdit.setReadOnly(True) - self.LineEdit.setVisible(False) - - self.hBoxLayout.insertWidget(5, self.LineEdit, 0, Qt.AlignRight) - - def setText(self, value: str) -> None: - - if value not in self.optionToText: - self.optionToText[value] = value - - self.LineEdit.setText(self.optionToText[value]) - - def switch_mode(self, mode: str) -> None: - """切换模式""" - - if mode == "固定": - - self.LineEdit.setVisible(False) - self.comboBox.setVisible(True) - - elif mode == "计划": - - self.comboBox.setVisible(False) - self.LineEdit.setVisible(True) - - def change_plan(self, configItem_plan: ConfigItem) -> None: - """切换计划""" - - if self.configItem_plan is not None: - self.configItem_plan.valueChanged.disconnect(self.setText) - self.configItem_plan = configItem_plan - self.configItem_plan.valueChanged.connect(self.setText) - self.setText(self.qconfig.get(self.configItem_plan)) - - -class EditableComboBoxWithPlanSettingCard(EditableComboBoxSettingCard): - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - value: List[str], - texts: List[str], - qconfig: QConfig, - configItem: ConfigItem, - parent=None, - ): - - super().__init__( - icon, title, content, value, texts, qconfig, configItem, parent - ) - - self.configItem_plan = None - - self.LineEdit = LineEdit(self) - self.LineEdit.setMinimumWidth(150) - self.LineEdit.setReadOnly(True) - self.LineEdit.setVisible(False) - - self.hBoxLayout.insertWidget(5, self.LineEdit, 0, Qt.AlignRight) - - def setText(self, value: str) -> None: - - if value not in self.optionToText: - self.optionToText[value] = value - - self.LineEdit.setText(self.optionToText[value]) - - def switch_mode(self, mode: str) -> None: - """切换模式""" - - if mode == "固定": - - self.LineEdit.setVisible(False) - self.comboBox.setVisible(True) - - elif mode == "计划": - - self.comboBox.setVisible(False) - self.LineEdit.setVisible(True) - - def change_plan(self, configItem_plan: ConfigItem) -> None: - """切换计划""" - - if self.configItem_plan is not None: - self.configItem_plan.valueChanged.disconnect(self.setText) - self.configItem_plan = configItem_plan - self.configItem_plan.valueChanged.connect(self.setText) - self.setText(self.qconfig.get(self.configItem_plan)) - - -class TimeEditSettingCard(SettingCard): - - enabledChanged = Signal(bool) - timeChanged = Signal(str) - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - qconfig: QConfig, - configItem_bool: ConfigItem, - configItem_time: ConfigItem, - parent=None, - ): - - super().__init__(icon, title, content, parent) - self.qconfig = qconfig - self.configItem_bool = configItem_bool - self.configItem_time = configItem_time - self.CheckBox = CheckBox(self) - self.CheckBox.setTristate(False) - self.TimeEdit = TimeEdit(self) - self.TimeEdit.setDisplayFormat("HH:mm") - self.TimeEdit.setMinimumWidth(150) - - if configItem_bool: - self.setValue_bool(qconfig.get(configItem_bool)) - configItem_bool.valueChanged.connect(self.setValue_bool) - - if configItem_time: - self.setValue_time(qconfig.get(configItem_time)) - configItem_time.valueChanged.connect(self.setValue_time) - - self.hBoxLayout.addWidget(self.CheckBox, 0, Qt.AlignRight) - self.hBoxLayout.addWidget(self.TimeEdit, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - self.CheckBox.stateChanged.connect(self.__enableChanged) - self.TimeEdit.timeChanged.connect(self.__timeChanged) - - def __timeChanged(self, value: QTime): - self.setValue_time(value.toString("HH:mm")) - self.timeChanged.emit(value.toString("HH:mm")) - - def __enableChanged(self, value: int): - if value == 0: - self.setValue_bool(False) - self.enabledChanged.emit(False) - else: - self.setValue_bool(True) - self.enabledChanged.emit(True) - - def setValue_bool(self, value: bool): - if self.configItem_bool: - self.qconfig.set(self.configItem_bool, value) - - self.CheckBox.setChecked(value) - - def setValue_time(self, value: str): - if self.configItem_time: - self.qconfig.set(self.configItem_time, value) - - self.TimeEdit.setTime(QTime.fromString(value, "HH:mm")) - - -class SubLableSettingCard(SettingCard): - """Setting card with Sub's Lable""" - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - qconfig: QConfig, - configItems: Dict[str, ConfigItem], - parent=None, - ): - - super().__init__(icon, title, content, parent) - self.qconfig = qconfig - self.configItems = configItems - self.Lable = SubtitleLabel(self) - - if configItems: - for configItem in configItems.values(): - configItem.valueChanged.connect(self.setValue) - self.setValue() - - self.hBoxLayout.addWidget(self.Lable, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - def setValue(self): - - text_list = [] - - if self.configItems: - - text_list.append( - f"今日已代理{self.qconfig.get(self.configItems["ProxyTimes"])}次" - if Config.server_date().strftime("%Y-%m-%d") - == self.qconfig.get(self.configItems["LastProxyDate"]) - else "今日未进行代理" - ) - - self.Lable.setText(" | ".join(text_list)) - - -class UserLableSettingCard(SettingCard): - """Setting card with User's Lable""" - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - qconfig: QConfig, - configItems: Dict[str, ConfigItem], - parent=None, - ): - - super().__init__(icon, title, content, parent) - self.qconfig = qconfig - self.configItems = configItems - self.Lable = SubtitleLabel(self) - - if configItems: - for configItem in configItems.values(): - configItem.valueChanged.connect(self.setValue) - self.setValue() - - self.hBoxLayout.addWidget(self.Lable, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - def setValue(self): - - text_list = [] - - if self.configItems: - - if not self.qconfig.get(self.configItems["IfPassCheck"]): - text_list.append("未通过人工排查") - text_list.append( - f"今日已代理{self.qconfig.get(self.configItems["ProxyTimes"])}次" - if Config.server_date().strftime("%Y-%m-%d") - == self.qconfig.get(self.configItems["LastProxyDate"]) - else "今日未进行代理" - ) - text_list.append( - "本周剿灭已完成" - if datetime.strptime( - self.qconfig.get(self.configItems["LastAnnihilationDate"]), - "%Y-%m-%d", - ).isocalendar()[:2] - == Config.server_date().isocalendar()[:2] - else "本周剿灭未完成" - ) - if self.qconfig.get(self.configItems["IfSkland"]): - text_list.append( - "森空岛已签到" - if datetime.now().strftime("%Y-%m-%d") - == self.qconfig.get(self.configItems["LastSklandDate"]) - else "森空岛未签到" - ) - - self.Lable.setText(" | ".join(text_list)) - - -class UserTaskSettingCard(PushSettingCard): - """Setting card with User's Task""" - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - text: str, - qconfig: QConfig, - configItems: Dict[str, ConfigItem], - parent=None, - ): - - super().__init__(text, icon, title, content, parent) - self.qconfig = qconfig - self.configItems = configItems - self.Lable = SubtitleLabel(self) - - if configItems: - for config_item in configItems.values(): - config_item.valueChanged.connect(self.setValues) - self.setValues() - - self.hBoxLayout.addWidget(self.Lable, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - def setValues(self): - - text_list = [] - - if self.configItems: - - if self.qconfig.get(self.configItems["IfWakeUp"]): - text_list.append("开始唤醒") - if self.qconfig.get(self.configItems["IfRecruiting"]): - text_list.append("自动公招") - if self.qconfig.get(self.configItems["IfBase"]): - text_list.append("基建换班") - if self.qconfig.get(self.configItems["IfCombat"]): - text_list.append("刷理智") - if self.qconfig.get(self.configItems["IfMall"]): - text_list.append("获取信用及购物") - if self.qconfig.get(self.configItems["IfMission"]): - text_list.append("领取奖励") - if self.qconfig.get(self.configItems["IfAutoRoguelike"]): - text_list.append("自动肉鸽") - if self.qconfig.get(self.configItems["IfReclamation"]): - text_list.append("生息演算") - - if text_list: - self.setContent(f"任务序列:{" - ".join(text_list)}") - else: - self.setContent("未启用任何任务项") - - -class UserNoticeSettingCard(PushAndSwitchButtonSettingCard): - """Setting card with User's Notice""" - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - text: str, - qconfig: QConfig, - configItem: ConfigItem, - configItems: Dict[str, ConfigItem], - parent=None, - ): - - super().__init__(icon, title, content, text, qconfig, configItem, parent) - self.qconfig = qconfig - self.configItems = configItems - self.Lable = SubtitleLabel(self) - - if configItems: - for config_item in configItems.values(): - config_item.valueChanged.connect(self.setValues) - self.setValues() - - self.hBoxLayout.addWidget(self.Lable, 0, Qt.AlignRight) - self.hBoxLayout.addSpacing(16) - - def setValues(self): - - def short_str(s: str) -> str: - if s.startswith(("SC", "sc")): - # SendKey:首4 + 末4 - return f"{s[:4]}***{s[-4:]}" if len(s) > 8 else s - - elif s.startswith(("http://", "https://")): - # Webhook URL:域名 + 路径尾3 - parsed_url = urlparse(s) - domain = parsed_url.netloc - path_tail = ( - parsed_url.path[-3:] - if len(parsed_url.path) > 3 - else parsed_url.path - ) - return f"{domain}***{path_tail}" - - elif "@" in s: - # 邮箱:@前3/6 + 域名 - username, domain = s.split("@", 1) - displayed_name = f"{username[:3]}***" if len(username) > 6 else username - return f"{displayed_name}@{domain}" - - else: - # 普通字符串:末尾3字符 - return f"***{s[-3:]}" if len(s) > 3 else s - - text_list = [] - - if self.configItems: - - if not ( - self.qconfig.get(self.configItems["IfSendStatistic"]) - or ( - "IfSendSixStar" in self.configItems - and self.qconfig.get(self.configItems["IfSendSixStar"]) - ) - ): - text_list.append("未启用任何通知项") - - if self.qconfig.get(self.configItems["IfSendStatistic"]): - text_list.append("统计信息已启用") - if "IfSendSixStar" in self.configItems and self.qconfig.get( - self.configItems["IfSendSixStar"] - ): - text_list.append("六星喜报已启用") - - if self.qconfig.get(self.configItems["IfSendMail"]): - text_list.append( - f"邮箱通知:{short_str(self.qconfig.get(self.configItems["ToAddress"]))}" - ) - if self.qconfig.get(self.configItems["IfServerChan"]): - text_list.append( - f"Server酱通知:{short_str(self.qconfig.get(self.configItems["ServerChanKey"]))}" - ) - if self.qconfig.get(self.configItems["IfCompanyWebHookBot"]): - text_list.append( - f"企业微信通知:{short_str(self.qconfig.get(self.configItems["CompanyWebHookBotUrl"]))}" - ) - - self.setContent(" | ".join(text_list)) - - -class StatusSwitchSetting(SwitchButton): - - def __init__( - self, - qconfig: QConfig, - configItem_check: ConfigItem, - configItem_enable: ConfigItem, - parent=None, - ): - super().__init__(parent) - self.qconfig = qconfig - self.configItem_check = configItem_check - self.configItem_enable = configItem_enable - self.setOffText("") - self.setOnText("") - - if configItem_check: - self.setValue(self.qconfig.get(configItem_check)) - configItem_check.valueChanged.connect(self.setValue) - if configItem_enable: - self.setEnabled(self.qconfig.get(configItem_enable)) - configItem_enable.valueChanged.connect(self.setEnabled) - - self.checkedChanged.connect(self.setValue) - - def setValue(self, isChecked: bool): - if self.configItem_check: - self.qconfig.set(self.configItem_check, isChecked) - - self.setChecked(isChecked) - - -class ComboBoxSetting(ComboBox): - - def __init__( - self, - texts: List[str], - qconfig: QConfig, - configItem: OptionsConfigItem, - parent=None, - ): - - super().__init__(parent) - self.qconfig = qconfig - self.configItem = configItem - - self.optionToText = {o: t for o, t in zip(configItem.options, texts)} - for text, option in zip(texts, configItem.options): - self.addItem(text, userData=option) - - self.setCurrentText(self.optionToText[self.qconfig.get(configItem)]) - self.currentIndexChanged.connect(self._onCurrentIndexChanged) - configItem.valueChanged.connect(self.setValue) - - def _onCurrentIndexChanged(self, index: int): - - self.qconfig.set(self.configItem, self.itemData(index)) - - def setValue(self, value): - if value not in self.optionToText: - return - - self.setCurrentText(self.optionToText[value]) - self.qconfig.set(self.configItem, value) - - -class NoOptionComboBoxSetting(ComboBox): - - def __init__( - self, - value: List[str], - texts: List[str], - qconfig: QConfig, - configItem: OptionsConfigItem, - parent=None, - ): - - super().__init__(parent) - self.qconfig = qconfig - self.configItem = configItem - - self.optionToText = {o: t for o, t in zip(value, texts)} - for text, option in zip(texts, value): - self.addItem(text, userData=option) - - self.setCurrentText(self.optionToText[self.qconfig.get(configItem)]) - self.currentIndexChanged.connect(self._onCurrentIndexChanged) - configItem.valueChanged.connect(self.setValue) - - def _onCurrentIndexChanged(self, index: int): - - self.qconfig.set(self.configItem, self.itemData(index)) - - def setValue(self, value): - if value not in self.optionToText: - return - - self.setCurrentText(self.optionToText[value]) - self.qconfig.set(self.configItem, value) - - def reLoadOptions(self, value: List[str], texts: List[str]): - - self.currentIndexChanged.disconnect(self._onCurrentIndexChanged) - self.clear() - self.optionToText = {o: t for o, t in zip(value, texts)} - for text, option in zip(texts, value): - self.addItem(text, userData=option) - self.setCurrentText(self.optionToText[self.qconfig.get(self.configItem)]) - self.currentIndexChanged.connect(self._onCurrentIndexChanged) - - -class EditableComboBoxSetting(EditableComboBox): - - def __init__( - self, - value: List[str], - texts: List[str], - qconfig: QConfig, - configItem: OptionsConfigItem, - parent=None, - ): - - super().__init__(parent) - self.qconfig = qconfig - self.configItem = configItem - - self.optionToText = {o: t for o, t in zip(value, texts)} - for text, option in zip(texts, value): - self.addItem(text, userData=option) - - if qconfig.get(configItem) not in self.optionToText: - self.optionToText[qconfig.get(configItem)] = qconfig.get(configItem) - self.addItem(qconfig.get(configItem), userData=qconfig.get(configItem)) - - self.setCurrentText(self.optionToText[qconfig.get(configItem)]) - self.currentIndexChanged.connect(self._onCurrentIndexChanged) - configItem.valueChanged.connect(self.setValue) - - def _onCurrentIndexChanged(self, index: int): - - self.qconfig.set( - self.configItem, - (self.itemData(index) if self.itemData(index) else self.itemText(index)), - ) - - def setValue(self, value): - if value not in self.optionToText: - self.optionToText[value] = value - if self.findText(value) == -1: - self.addItem(value, userData=value) - else: - self.setItemData(self.findText(value), value) - - self.setCurrentText(self.optionToText[value]) - self.qconfig.set(self.configItem, value) - - def reLoadOptions(self, value: List[str], texts: List[str]): - - self.currentIndexChanged.disconnect(self._onCurrentIndexChanged) - self.clear() - self.optionToText = {o: t for o, t in zip(value, texts)} - for text, option in zip(texts, value): - self.addItem(text, userData=option) - if self.qconfig.get(self.configItem) not in self.optionToText: - self.optionToText[self.qconfig.get(self.configItem)] = self.qconfig.get( - self.configItem - ) - self.addItem( - self.qconfig.get(self.configItem), - userData=self.qconfig.get(self.configItem), - ) - self.setCurrentText(self.optionToText[self.qconfig.get(self.configItem)]) - self.currentIndexChanged.connect(self._onCurrentIndexChanged) - - def _onReturnPressed(self): - if not self.text(): - return - - index = self.findText(self.text()) - if index >= 0 and index != self.currentIndex(): - self._currentIndex = index - self.currentIndexChanged.emit(index) - elif index == -1: - self.addItem(self.text()) - self.setCurrentIndex(self.count() - 1) - self.currentIndexChanged.emit(self.count() - 1) - - -class SpinBoxSetting(SpinBox): - - def __init__( - self, - range: tuple[int, int], - qconfig: QConfig, - configItem: ConfigItem, - parent=None, - ): - - super().__init__(parent) - self.qconfig = qconfig - self.configItem = configItem - self.setRange(range[0], range[1]) - - if configItem: - self.set_value(qconfig.get(configItem)) - configItem.valueChanged.connect(self.set_value) - - self.valueChanged.connect(self.set_value) - - def set_value(self, value: int): - if self.configItem: - self.qconfig.set(self.configItem, value) - - self.setValue(value) - - -class HistoryCard(HeaderCardWidget): - - def __init__(self, qconfig: QConfig, configItem: ConfigItem, parent=None): - super().__init__(parent) - self.setTitle("历史运行记录") - - self.qconfig = qconfig - self.configItem = configItem - - self.text = TextBrowser() - self.text.setMinimumHeight(300) - - if configItem: - self.setValue(self.qconfig.get(configItem)) - configItem.valueChanged.connect(self.setValue) - - self.viewLayout.addWidget(self.text) - - def setValue(self, content: str): - if self.configItem: - self.qconfig.set(self.configItem, content) - - self.text.setPlainText(content) - - -class UrlItem(QWidget): - """Url item""" - - removed = Signal(QWidget) - - def __init__(self, url: str, parent=None): - super().__init__(parent=parent) - self.url = url - self.hBoxLayout = QHBoxLayout(self) - self.folderLabel = QLabel(url, self) - self.removeButton = ToolButton(FluentIcon.CLOSE, self) - - self.removeButton.setFixedSize(39, 29) - self.removeButton.setIconSize(QSize(12, 12)) - - self.setFixedHeight(53) - self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) - self.hBoxLayout.setContentsMargins(48, 0, 60, 0) - self.hBoxLayout.addWidget(self.folderLabel, 0, Qt.AlignLeft) - self.hBoxLayout.addSpacing(16) - self.hBoxLayout.addStretch(1) - self.hBoxLayout.addWidget(self.removeButton, 0, Qt.AlignRight) - self.hBoxLayout.setAlignment(Qt.AlignVCenter) - - self.removeButton.clicked.connect(lambda: self.removed.emit(self)) - - -class UrlListSettingCard(ExpandSettingCard): - """Url list setting card""" - - urlChanged = Signal(list) - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Union[str, None], - qconfig: QConfig, - configItem: ConfigItem, - parent=None, - ): - super().__init__(icon, title, content, parent) - self.qconfig = qconfig - self.configItem = configItem - self.addUrlButton = PushButton("添加代理网址", self) - - self.urls: List[str] = self.qconfig.get(configItem).copy() - self.__initWidget() - - def __initWidget(self): - self.addWidget(self.addUrlButton) - - # initialize layout - self.viewLayout.setSpacing(0) - self.viewLayout.setAlignment(Qt.AlignTop) - self.viewLayout.setContentsMargins(0, 0, 0, 0) - for url in self.urls: - self.__addUrlItem(url) - - self.addUrlButton.clicked.connect(self.__showUrlDialog) - - def __showUrlDialog(self): - """show url dialog""" - - choice = LineEditMessageBox( - self.window(), "添加代理网址", "请输入代理网址", "明文" - ) - if choice.exec() and self.__validate(choice.input.text()): - - if choice.input.text()[-1] == "/": - url = choice.input.text() - else: - url = f"{choice.input.text()}/" - - if url in self.urls: - return - - self.__addUrlItem(url) - self.urls.append(url) - self.qconfig.set(self.configItem, self.urls) - self.urlChanged.emit(self.urls) - - def __addUrlItem(self, url: str): - """add url item""" - item = UrlItem(url, self.view) - item.removed.connect(self.__showConfirmDialog) - self.viewLayout.addWidget(item) - item.show() - self._adjustViewSize() - - def __showConfirmDialog(self, item: UrlItem): - """show confirm dialog""" - - choice = MessageBox( - "确认", f"确定要删除 {item.url} 代理网址吗?", self.window() - ) - if choice.exec(): - self.__removeUrl(item) - - def __removeUrl(self, item: UrlItem): - """remove folder""" - if item.url not in self.urls: - return - - self.urls.remove(item.url) - self.viewLayout.removeWidget(item) - item.deleteLater() - self._adjustViewSize() - - self.urlChanged.emit(self.urls) - self.qconfig.set(self.configItem, self.urls) - - def __validate(self, value): - - try: - result = urlparse(value) - return all([result.scheme, result.netloc]) - except ValueError: - return False - - -class StatefulItemCard(CardWidget): - - def __init__(self, item: list, parent=None): - super().__init__(parent) - - self.Layout = QHBoxLayout(self) - - self.Label = BodyLabel(item[0], self) - self.icon = IconWidget(FluentIcon.MORE, self) - self.icon.setFixedSize(16, 16) - self.update_status(item[1]) - - self.Layout.addWidget(self.icon) - self.Layout.addWidget(self.Label) - self.Layout.addStretch(1) - - def update_status(self, status: str): - - if status == "完成": - self.icon.setIcon(FluentIcon.ACCEPT) - self.Label.setTextColor("#0eb840", "#0eb840") - elif status == "等待": - self.icon.setIcon(FluentIcon.MORE) - self.Label.setTextColor("#161823", "#e3f9fd") - elif status == "运行": - self.icon.setIcon(FluentIcon.PLAY) - self.Label.setTextColor("#177cb0", "#70f3ff") - elif status == "跳过": - self.icon.setIcon(FluentIcon.REMOVE) - self.Label.setTextColor("#75878a", "#7397ab") - elif status == "异常": - self.icon.setIcon(FluentIcon.CLOSE) - self.Label.setTextColor("#ff2121", "#ff2121") - - -class QuantifiedItemCard(CardWidget): - - def __init__(self, item: list, parent=None): - super().__init__(parent) - - self.Layout = QHBoxLayout(self) - - self.Name = BodyLabel(item[0], self) - self.Numb = BodyLabel(str(item[1]), self) - - self.Layout.addWidget(self.Name) - self.Layout.addStretch(1) - self.Layout.addWidget(self.Numb) - - -class PivotArea(ScrollArea): - def __init__(self, parent=None): - super().__init__(parent) - - # 创建中间容器并设置布局 - self.center_container = QWidget() - self.center_layout = QHBoxLayout(self.center_container) - self.center_layout.setContentsMargins(0, 0, 0, 0) - self.center_layout.setSpacing(0) - self.center_container.setStyleSheet("background: transparent; border: none;") - self.center_container.setFixedHeight(45) - - self.pivot = self._Pivot(self) - self.pivot.ItemNumbChanged.connect( - lambda: QTimer.singleShot( - 100, - lambda: ( - self.center_container.setFixedWidth( - max(self.width() - 2, self.pivot.width()) - ) - ), - ) - ) - - self.center_layout.addStretch(1) - self.center_layout.addWidget(self.pivot) - self.center_layout.addStretch(1) - - self.setWidgetResizable(False) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.viewport().setCursor(Qt.ArrowCursor) - self.setStyleSheet("background: transparent; border: none;") - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.setWidget(self.center_container) - - def wheelEvent(self, event): - scroll_bar = self.horizontalScrollBar() - if scroll_bar.maximum() > 0: - delta = event.angleDelta().y() - scroll_bar.setValue(scroll_bar.value() - delta // 15) - event.ignore() - - def resizeEvent(self, event): - super().resizeEvent(event) - - self.center_container.setFixedWidth(max(self.width() - 2, self.pivot.width())) - QTimer.singleShot( - 100, - lambda: ( - self.center_container.setFixedWidth( - max(self.width() - 2, self.pivot.width()) - ) - ), - ) - - class _Pivot(Pivot): - - ItemNumbChanged = Signal() - - def __init__(self, parent=None): - super().__init__(parent) - - def insertWidget( - self, index: int, routeKey: str, widget: PivotItem, onClick=None - ): - super().insertWidget(index, routeKey, widget, onClick) - self.ItemNumbChanged.emit() - - def removeWidget(self, routeKey: str): - super().removeWidget(routeKey) - self.ItemNumbChanged.emit() - - def clear(self): - super().clear() - self.ItemNumbChanged.emit() - - -class QuickExpandGroupCard(ExpandGroupSettingCard): - """全局配置""" - - def __init__( - self, - icon: Union[str, QIcon, FluentIcon], - title: str, - content: str = None, - parent=None, - ): - super().__init__(icon, title, content, parent) - - def setExpand(self, isExpand: bool): - """set the expand status of card""" - if self.isExpand == isExpand: - return - - # update style sheet - self.isExpand = isExpand - self.setProperty("isExpand", isExpand) - self.setStyle(QApplication.style()) - - # start expand animation - if isExpand: - h = self.viewLayout.sizeHint().height() - self.verticalScrollBar().setValue(h) - self.expandAni.setStartValue(h) - self.expandAni.setEndValue(0) - self.expandAni.start() - else: - self.setFixedHeight(self.viewportMargins().top()) - - self.card.expandButton.setExpand(isExpand) - - -class IconButton(TransparentToolButton): - """包含下拉框的自定义设置卡片类。""" - - @singledispatchmethod - def __init__(self, parent: QWidget = None): - TransparentToolButton.__init__(self, parent) - - self._tooltip: Optional[TeachingTip] = None - - @__init__.register - def _(self, icon: Union[str, QIcon, FluentIconBase], parent: QWidget = None): - self.__init__(parent) - self.setIcon(icon) - - @__init__.register - def _( - self, - icon: Union[str, QIcon, FluentIconBase], - isTooltip: bool, - tip_title: str, - tip_content: Union[str, None], - parent: QWidget = None, - ): - self.__init__(parent) - self.setIcon(icon) - - # 处理工具提示 - if isTooltip: - self.installEventFilter(self) - - self.tip_title: str = tip_title - self.tip_content: str = tip_content - - def eventFilter(self, obj, event: QEvent) -> bool: - """处理鼠标事件。""" - if event.type() == QEvent.Type.Enter: - self._show_tooltip() - elif event.type() == QEvent.Type.Leave: - self._hide_tooltip() - return super().eventFilter(obj, event) - - def _show_tooltip(self) -> None: - """显示工具提示。""" - self._tooltip = TeachingTip.create( - target=self, - title=self.tip_title, - content=self.tip_content, - tailPosition=TeachingTipTailPosition.RIGHT, - isClosable=False, - duration=-1, - parent=self, - ) - # 设置偏移 - if self._tooltip: - tooltip_pos = self.mapToGlobal(self.rect().topRight()) - - tooltip_pos.setX( - tooltip_pos.x() - self._tooltip.size().width() - 40 - ) # 水平偏移 - tooltip_pos.setY( - tooltip_pos.y() - self._tooltip.size().height() / 2 + 35 - ) # 垂直偏移 - - self._tooltip.move(tooltip_pos) - - def _hide_tooltip(self) -> None: - """隐藏工具提示。""" - if self._tooltip: - self._tooltip.close() - self._tooltip = None - - def __hash__(self): - return id(self) - - def __eq__(self, other): - if not isinstance(other, self.__class__): - return NotImplemented - return self is other - - -class Banner(QWidget): - """展示带有圆角的固定大小横幅小部件""" - - def __init__(self, image_path: str = None, parent=None): - QWidget.__init__(self, parent) - self.image_path = None - self.banner_image = None - self.scaled_image = None - - if image_path: - self.set_banner_image(image_path) - - def set_banner_image(self, image_path: str): - """设置横幅图片""" - self.image_path = image_path - self.banner_image = self.load_banner_image(image_path) - self.update_scaled_image() - - def load_banner_image(self, image_path: str) -> QPixmap: - """加载横幅图片,或创建渐变备用图片""" - if os.path.isfile(image_path): - return QPixmap(image_path) - return self._create_fallback_image() - - def _create_fallback_image(self): - """创建渐变备用图片""" - fallback_image = QPixmap(2560, 1280) # 使用原始图片的大小 - fallback_image.fill(Qt.GlobalColor.gray) - return fallback_image - - def update_scaled_image(self): - """按高度缩放图片,宽度保持比例,超出裁剪""" - if self.banner_image: - self.scaled_image = self.banner_image.scaled( - self.size(), - Qt.AspectRatioMode.KeepAspectRatioByExpanding, - Qt.TransformationMode.SmoothTransformation, - ) - self.update() - - def paintEvent(self, event): - """重载 paintEvent 以绘制缩放后的图片""" - if self.scaled_image: - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) - - # 创建圆角路径 - path = QPainterPath() - path.addRoundedRect(self.rect(), 20, 20) - painter.setClipPath(path) - - # 计算绘制位置,使图片居中 - x = (self.width() - self.scaled_image.width()) // 2 - y = (self.height() - self.scaled_image.height()) // 2 - - # 绘制缩放后的图片 - painter.drawPixmap(x, y, self.scaled_image) - - def resizeEvent(self, event): - """重载 resizeEvent 以更新缩放后的图片""" - self.update_scaled_image() - QWidget.resizeEvent(self, event) - - def set_percentage_size(self, width_percentage, height_percentage): - """设置 Banner 的大小为窗口大小的百分比""" - parent = self.parentWidget() - if parent: - new_width = int(parent.width() * width_percentage) - new_height = int(parent.height() * height_percentage) - self.setFixedSize(new_width, new_height) - self.update_scaled_image() diff --git a/app/ui/dispatch_center.py b/app/ui/dispatch_center.py deleted file mode 100644 index 3993dfa..0000000 --- a/app/ui/dispatch_center.py +++ /dev/null @@ -1,625 +0,0 @@ -# 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 . - -# 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() diff --git a/app/ui/downloader.py b/app/ui/downloader.py deleted file mode 100644 index e9b3ae4..0000000 --- a/app/ui/downloader.py +++ /dev/null @@ -1,729 +0,0 @@ -# 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 . - -# 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() diff --git a/app/ui/history.py b/app/ui/history.py deleted file mode 100644 index d0d45b3..0000000 --- a/app/ui/history.py +++ /dev/null @@ -1,398 +0,0 @@ -# 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 . - -# 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 -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.Layout = QVBoxLayout() - self.viewLayout.addLayout(self.Layout) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - 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) - - class StatisticsCard(HeaderCardWidget): - """历史记录统计信息卡片组""" - - def __init__(self, name: str, item_list: list, parent=None): - super().__init__(parent) - self.setTitle(name) - - self.Layout = QVBoxLayout() - self.viewLayout.addLayout(self.Layout) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - 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) - - class LogCard(HeaderCardWidget): - """历史记录日志卡片""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setTitle("日志") - - 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) diff --git a/app/ui/home.py b/app/ui/home.py deleted file mode 100644 index 7cde6f6..0000000 --- a/app/ui/home.py +++ /dev/null @@ -1,416 +0,0 @@ -# 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 . - -# 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 = "

    主题图像

    主题图像信息未知

    " - - self.banner_text.setHtml(re.sub(r"]*>", "", 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") - ) diff --git a/app/ui/main_window.py b/app/ui/main_window.py deleted file mode 100644 index 63715f6..0000000 --- a/app/ui/main_window.py +++ /dev/null @@ -1,488 +0,0 @@ -# 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 . - -# 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() diff --git a/app/ui/plan_manager.py b/app/ui/plan_manager.py deleted file mode 100644 index 3ee8cc0..0000000 --- a/app/ui/plan_manager.py +++ /dev/null @@ -1,515 +0,0 @@ -# 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 . - -# 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"], - ) diff --git a/app/ui/queue_manager.py b/app/ui/queue_manager.py deleted file mode 100644 index 16159b3..0000000 --- a/app/ui/queue_manager.py +++ /dev/null @@ -1,533 +0,0 @@ -# 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 . - -# 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) diff --git a/app/ui/script_manager.py b/app/ui/script_manager.py deleted file mode 100644 index 9c2d5a7..0000000 --- a/app/ui/script_manager.py +++ /dev/null @@ -1,3876 +0,0 @@ -# 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 . - -# Contact: DLmaster_361@163.com - -""" -AUTO_MAA -AUTO_MAA脚本管理界面 -v4.4 -作者:DLmaster_361 -""" - -from PySide6.QtWidgets import ( - QWidget, - QFileDialog, - QHBoxLayout, - QVBoxLayout, - QStackedWidget, - QTableWidgetItem, - QHeaderView, -) -from PySide6.QtGui import QIcon, Qt -from qfluentwidgets import ( - Action, - ConfigItem, - ScrollArea, - FluentIcon, - MessageBox, - HeaderCardWidget, - CommandBar, - ExpandGroupSettingCard, - PushSettingCard, - TableWidget, - PrimaryToolButton, - Flyout, - FlyoutAnimationType, -) -from PySide6.QtCore import Signal -from datetime import datetime -from functools import partial -from pathlib import Path -from typing import List, Dict, Union, Type -import shutil -import json - -from app.core import ( - Config, - logger, - MainInfoBar, - TaskManager, - MaaConfig, - MaaUserConfig, - GeneralConfig, - GeneralSubConfig, - Network, - SoundPlayer, -) -from app.services import Crypto -from .downloader import DownloadManager -from .Widget import ( - LineEditMessageBox, - LineEditSettingCard, - SpinBoxSettingCard, - ComboBoxMessageBox, - SettingFlyoutView, - NoOptionComboBoxSettingCard, - ComboBoxWithPlanSettingCard, - EditableComboBoxWithPlanSettingCard, - SpinBoxWithPlanSettingCard, - PasswordLineEditSettingCard, - PasswordLineAndSwitchButtonSettingCard, - UserLableSettingCard, - UserTaskSettingCard, - SubLableSettingCard, - ComboBoxSettingCard, - SwitchSettingCard, - PathSettingCard, - PushAndSwitchButtonSettingCard, - PushAndComboBoxSettingCard, - StatusSwitchSetting, - UserNoticeSettingCard, - NoticeMessageBox, - PivotArea, -) - - -class ScriptManager(QWidget): - """脚本管理父界面""" - - def __init__(self, parent=None): - super().__init__(parent) - - self.setObjectName("脚本管理") - - layout = QVBoxLayout(self) - - self.tools = CommandBar() - self.script_manager = self.ScriptSettingBox(self) - - # 逐个添加动作 - self.tools.addActions( - [ - Action(FluentIcon.ADD_TO, "新建脚本实例", triggered=self.add_script), - Action( - FluentIcon.REMOVE_FROM, "删除脚本实例", triggered=self.del_script - ), - ] - ) - self.tools.addSeparator() - self.tools.addActions( - [ - Action(FluentIcon.LEFT_ARROW, "向左移动", triggered=self.left_script), - Action(FluentIcon.RIGHT_ARROW, "向右移动", triggered=self.right_script), - ] - ) - self.tools.addSeparator() - self.tools.addAction( - Action( - FluentIcon.DOWNLOAD, - "脚本下载器", - triggered=self.script_downloader, - ) - ) - self.tools.addSeparator() - self.key = Action( - FluentIcon.HIDE, - "显示/隐藏密码", - checkable=True, - triggered=self.show_password, - ) - self.tools.addAction(self.key) - - layout.addWidget(self.tools) - layout.addWidget(self.script_manager) - - def add_script(self): - """添加一个脚本实例""" - - choice = ComboBoxMessageBox( - self.window(), - "选择一个脚本类型以添加相应脚本实例", - ["选择脚本类型"], - [["MAA", "通用"]], - ) - if choice.exec() and choice.input[0].currentIndex() != -1: - - logger.info( - f"添加脚本实例: {choice.input[0].currentText()}", module="脚本管理" - ) - - if choice.input[0].currentText() == "MAA": - - index = len(Config.script_dict) + 1 - - # 初始化 MAA 配置 - maa_config = MaaConfig() - maa_config.load( - Config.app_path / f"config/MaaConfig/脚本_{index}/config.json", - maa_config, - ) - maa_config.save() - (Config.app_path / f"config/MaaConfig/脚本_{index}/UserData").mkdir( - parents=True, exist_ok=True - ) - - Config.script_dict[f"脚本_{index}"] = { - "Type": "Maa", - "Path": Config.app_path / f"config/MaaConfig/脚本_{index}", - "Config": maa_config, - "UserData": {}, - } - - # 添加 MAA 实例设置界面 - self.script_manager.add_SettingBox( - index, self.ScriptSettingBox.MaaSettingBox - ) - self.script_manager.switch_SettingBox(index) - - logger.success(f"MAA实例 脚本_{index} 添加成功", module="脚本管理") - MainInfoBar.push_info_bar( - "success", "操作成功", f"添加 MAA 实例 脚本_{index}", 3000 - ) - SoundPlayer.play("添加脚本实例") - - elif choice.input[0].currentText() == "通用": - - index = len(Config.script_dict) + 1 - - # 初始化通用配置 - general_config = GeneralConfig() - general_config.load( - Config.app_path / f"config/GeneralConfig/脚本_{index}/config.json", - general_config, - ) - general_config.save() - (Config.app_path / f"config/GeneralConfig/脚本_{index}/SubData").mkdir( - parents=True, exist_ok=True - ) - - Config.script_dict[f"脚本_{index}"] = { - "Type": "General", - "Path": Config.app_path / f"config/GeneralConfig/脚本_{index}", - "Config": general_config, - "SubData": {}, - } - - # 添加通用实例设置界面 - self.script_manager.add_SettingBox( - index, self.ScriptSettingBox.GeneralSettingBox - ) - self.script_manager.switch_SettingBox(index) - - logger.success(f"通用实例 脚本_{index} 添加成功", module="脚本管理") - MainInfoBar.push_info_bar( - "success", "操作成功", f"添加通用实例 脚本_{index}", 3000 - ) - SoundPlayer.play("添加脚本实例") - - def del_script(self): - """删除一个脚本实例""" - - name = self.script_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.script_manager.clear_SettingBox() - - # 删除脚本实例的配置文件并同步修改相应配置项 - shutil.rmtree(Config.script_dict[name]["Path"]) - Config.change_queue(name, "禁用") - for i in range(int(name[3:]) + 1, len(Config.script_dict) + 1): - if Config.script_dict[f"脚本_{i}"]["Path"].exists(): - Config.script_dict[f"脚本_{i}"]["Path"].rename( - Config.script_dict[f"脚本_{i}"]["Path"].with_name(f"脚本_{i-1}") - ) - Config.change_queue(f"脚本_{i}", f"脚本_{i-1}") - - self.script_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_script(self): - """向左移动脚本实例""" - - name = self.script_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.script_manager.clear_SettingBox() - - # 移动脚本实例配置文件并同步修改配置项 - Config.script_dict[name]["Path"].rename( - Config.script_dict[name]["Path"].with_name("脚本_0") - ) - Config.change_queue(name, "脚本_0") - Config.script_dict[f"脚本_{index-1}"]["Path"].rename( - Config.script_dict[f"脚本_{index-1}"]["Path"].with_name(name) - ) - Config.change_queue(f"脚本_{index-1}", name) - Config.script_dict[name]["Path"].with_name("脚本_0").rename( - Config.script_dict[name]["Path"].with_name(f"脚本_{index-1}") - ) - Config.change_queue("脚本_0", f"脚本_{index-1}") - - self.script_manager.show_SettingBox(index - 1) - - logger.success(f"脚本实例 {name} 左移成功", module="脚本管理") - MainInfoBar.push_info_bar("success", "操作成功", f"左移脚本实例 {name}", 3000) - - def right_script(self): - """向右移动脚本实例""" - - name = self.script_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.script_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.script_manager.clear_SettingBox() - - # 移动脚本实例配置文件并同步修改配置项 - Config.script_dict[name]["Path"].rename( - Config.script_dict[name]["Path"].with_name("脚本_0") - ) - Config.change_queue(name, "脚本_0") - Config.script_dict[f"脚本_{index+1}"]["Path"].rename( - Config.script_dict[f"脚本_{index+1}"]["Path"].with_name(name) - ) - Config.change_queue(f"脚本_{index+1}", name) - Config.script_dict[name]["Path"].with_name("脚本_0").rename( - Config.script_dict[name]["Path"].with_name(f"脚本_{index+1}") - ) - Config.change_queue("脚本_0", f"脚本_{index+1}") - - self.script_manager.show_SettingBox(index + 1) - - logger.success(f"脚本实例 {name} 右移成功", module="脚本管理") - MainInfoBar.push_info_bar("success", "操作成功", f"右移脚本实例 {name}", 3000) - - def script_downloader(self): - """脚本下载器""" - - if not Config.get(Config.update_MirrorChyanCDK): - - logger.warning("脚本下载器未设置CDK", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", - "未设置Mirror酱CDK", - "下载器依赖于Mirror酱,未设置CDK时无法使用", - 5000, - ) - return None - - # 从远程服务器获取应用列表 - network = Network.add_task( - mode="get", - url="http://221.236.27.82:10197/d/AUTO_MAA/Server/apps_info.json", - ) - network.loop.exec() - network_result = Network.get_result(network) - if network_result["status_code"] == 200: - apps_info = 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 - - choice = ComboBoxMessageBox( - self.window(), - "选择一个脚本类型以下载相应脚本", - ["选择脚本类型"], - [list(apps_info.keys())], - ) - if choice.exec() and choice.input[0].currentIndex() != -1: - - app_name = choice.input[0].currentText() - app_rid = apps_info[app_name]["rid"] - - (Config.app_path / f"script/{app_rid}").mkdir(parents=True, exist_ok=True) - folder = QFileDialog.getExistingDirectory( - self, - f"选择{app_name}下载目录", - str(Config.app_path / f"script/{app_rid}"), - ) - if not folder: - logger.warning( - f"选择{app_name}下载目录时未选择文件夹", module="脚本管理" - ) - MainInfoBar.push_info_bar( - "warning", "警告", f"未选择{app_name}下载目录", 5000 - ) - return None - - # 从mirrorc服务器获取最新版本信息 - network = Network.add_task( - mode="get", - url=f"https://mirrorchyan.com/api/resources/{app_rid}/latest?user_agent=AutoMaaGui&cdk={Crypto.win_decryptor(Config.get(Config.update_MirrorChyanCDK))}&os={apps_info[app_name]["os"]}&arch={apps_info[app_name]["arch"]}&channel=stable", - ) - network.loop.exec() - network_result = Network.get_result(network) - if network_result["status_code"] == 200: - app_info = network_result["response_json"] - else: - - if network_result["response_json"]: - - app_info = network_result["response_json"] - - if app_info["code"] != 0: - - logger.error( - f"获取应用版本信息时出错:{app_info["msg"]}", - module="脚本管理", - ) - - error_remark_dict = { - 1001: "获取版本信息的URL参数不正确", - 7001: "填入的 CDK 已过期", - 7002: "填入的 CDK 错误", - 7003: "填入的 CDK 今日下载次数已达上限", - 7004: "填入的 CDK 类型和待下载的资源不匹配", - 7005: "填入的 CDK 已被封禁", - 8001: "对应架构和系统下的资源不存在", - 8002: "错误的系统参数", - 8003: "错误的架构参数", - 8004: "错误的更新通道参数", - 1: app_info["msg"], - } - - if app_info["code"] in error_remark_dict: - MainInfoBar.push_info_bar( - "error", - "获取版本信息时出错", - error_remark_dict[app_info["code"]], - -1, - ) - else: - MainInfoBar.push_info_bar( - "error", - "获取版本信息时出错", - "意料之外的错误,请及时联系项目组以获取来自 Mirror 酱的技术支持", - -1, - ) - - return None - - logger.warning( - f"获取版本信息时出错:{network_result['error_message']}", - module="脚本管理", - ) - MainInfoBar.push_info_bar( - "warning", - "获取版本信息时出错", - f"网络错误:{network_result['status_code']}", - 5000, - ) - return None - - # 创建下载管理器并开始下载 - logger.info(f"开始下载{app_name},下载目录:{folder}", module="脚本管理") - self.downloader = DownloadManager( - Path(folder), - app_rid, - [], - { - "mode": "MirrorChyan", - "thread_numb": 1, - "url": app_info["data"]["url"], - }, - ) - self.downloader.setWindowTitle("AUTO_MAA下载器 - Mirror酱渠道") - self.downloader.setWindowIcon( - QIcon(str(Config.app_path / "resources/icons/MirrorChyan.ico")) - ) - self.downloader.show() - self.downloader.run() - - def show_password(self): - """显示或隐藏密码""" - - if Config.PASSWORD == "": - choice = LineEditMessageBox( - self.window(), - "请输入管理密钥", - "管理密钥", - "密码", - ) - if choice.exec() and choice.input.text() != "": - Config.PASSWORD = choice.input.text() - Config.PASSWORD_refreshed.emit() - self.key.setIcon(FluentIcon.VIEW) - self.key.setChecked(True) - else: - Config.PASSWORD = "" - Config.PASSWORD_refreshed.emit() - self.key.setIcon(FluentIcon.HIDE) - self.key.setChecked(False) - else: - Config.PASSWORD = "" - Config.PASSWORD_refreshed.emit() - self.key.setIcon(FluentIcon.HIDE) - self.key.setChecked(False) - - def reload_plan_name(self): - """刷新计划表名称""" - - # 生成计划列表信息 - plan_list = [ - ["固定"] + [_ for _ in Config.plan_dict.keys()], - ["固定"] - + [ - ( - k - if v["Config"].get(v["Config"].Info_Name) == "" - else f"{k} - {v["Config"].get(v["Config"].Info_Name)}" - ) - for k, v in Config.plan_dict.items() - ], - ] - - # 刷新所有脚本实例的计划表名称 - for script in self.script_manager.script_list: - - if isinstance(script, ScriptManager.ScriptSettingBox.MaaSettingBox): - - for user_setting in script.user_setting.user_manager.script_list: - - user_setting.card_StageMode.comboBox.currentIndexChanged.disconnect( - user_setting.switch_stage_mode - ) - user_setting.card_StageMode.reLoadOptions( - plan_list[0], plan_list[1] - ) - user_setting.card_StageMode.comboBox.currentIndexChanged.connect( - user_setting.switch_stage_mode - ) - - self.refresh_plan_info() - - def refresh_dashboard(self): - """刷新所有脚本实例的仪表盘""" - - for script in self.script_manager.script_list: - - if isinstance(script, ScriptManager.ScriptSettingBox.MaaSettingBox): - script.user_setting.user_manager.user_dashboard.load_info() - elif isinstance(script, ScriptManager.ScriptSettingBox.GeneralSettingBox): - script.branch_manager.sub_manager.sub_dashboard.load_info() - - def refresh_plan_info(self): - """刷新所有计划信息""" - - for script in self.script_manager.script_list: - - if isinstance(script, ScriptManager.ScriptSettingBox.MaaSettingBox): - - script.user_setting.user_manager.user_dashboard.load_info() - for user_setting in script.user_setting.user_manager.script_list: - user_setting.switch_stage_mode() - - class ScriptSettingBox(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[ - Union[ - ScriptManager.ScriptSettingBox.MaaSettingBox, - ScriptManager.ScriptSettingBox.GeneralSettingBox, - ] - ] = [] - - 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: 要切换到的子界面索引 - :type index: int - """ - - Config.search_script() - - for name, info in Config.script_dict.items(): - if info["Type"] == "Maa": - self.add_SettingBox(int(name[3:]), self.MaaSettingBox) - elif info["Type"] == "General": - self.add_SettingBox(int(name[3:]), self.GeneralSettingBox) - - self.switch_SettingBox(index) - - def switch_SettingBox(self, index: int, if_chang_pivot: bool = True) -> None: - """ - 切换到指定的子界面 - - :param index: 要切换到的子界面索引 - :type index: int - :param if_chang_pivot: 是否更改导航栏的当前项 - :type if_chang_pivot: bool - """ - - if len(Config.script_dict) == 0: - return None - - if index > len(Config.script_dict): - return None - - if if_chang_pivot: - self.pivot.setCurrentItem(self.script_list[index - 1].objectName()) - self.stackedWidget.setCurrentWidget(self.script_list[index - 1]) - - if isinstance( - self.script_list[index - 1], - ScriptManager.ScriptSettingBox.MaaSettingBox, - ): - self.script_list[index - 1].user_setting.user_manager.switch_SettingBox( - "用户仪表盘" - ) - elif isinstance( - self.script_list[index - 1], - ScriptManager.ScriptSettingBox.GeneralSettingBox, - ): - self.script_list[ - index - 1 - ].branch_manager.sub_manager.switch_SettingBox("配置仪表盘") - - 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, type: Type) -> None: - """ - 添加指定类型设置子界面 - - :param uid: 脚本实例的唯一标识符 - :type uid: int - :param type: 要添加的设置子界面类型 - :type type: Type - """ - - if type == self.MaaSettingBox: - setting_box = self.MaaSettingBox(uid, self) - elif type == self.GeneralSettingBox: - setting_box = self.GeneralSettingBox(uid, self) - else: - return None - - self.script_list.append(setting_box) - self.stackedWidget.addWidget(self.script_list[-1]) - self.pivot.addItem(routeKey=f"脚本_{uid}", text=f"脚本 {uid}") - - class MaaSettingBox(QWidget): - """MAA类脚本设置界面""" - - def __init__(self, uid: int, parent=None): - super().__init__(parent) - - self.setObjectName(f"脚本_{uid}") - self.config = Config.script_dict[f"脚本_{uid}"]["Config"] - - self.app_setting = self.AppSettingCard(f"脚本_{uid}", self.config, self) - self.user_setting = self.UserManager(f"脚本_{uid}", self) - - content_widget = QWidget() - content_layout = QVBoxLayout(content_widget) - content_layout.setContentsMargins(0, 0, 11, 0) - content_layout.addWidget(self.app_setting) - content_layout.addWidget(self.user_setting) - 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 AppSettingCard(HeaderCardWidget): - - def __init__(self, name: str, config: MaaConfig, parent=None): - super().__init__(parent) - - self.setTitle("MAA实例") - - self.name = name - self.config = config - - Layout = QVBoxLayout() - - self.card_Name = LineEditSettingCard( - icon=FluentIcon.EDIT, - title="实例名称", - content="用于标识MAA实例的名称", - text="请输入实例名称", - qconfig=self.config, - configItem=self.config.MaaSet_Name, - parent=self, - ) - self.card_Path = PushSettingCard( - text="选择文件夹", - icon=FluentIcon.FOLDER, - title="MAA目录", - content=self.config.get(self.config.MaaSet_Path), - parent=self, - ) - self.card_Set = PushSettingCard( - text="设置", - icon=FluentIcon.HOME, - title="MAA全局配置", - content="简洁模式下MAA将继承全局配置", - parent=self, - ) - self.RunSet = self.RunSetSettingCard(self.config, self) - - self.card_Path.clicked.connect(self.PathClicked) - self.config.MaaSet_Path.valueChanged.connect( - lambda: self.card_Path.setContent( - self.config.get(self.config.MaaSet_Path) - ) - ) - self.card_Set.clicked.connect( - lambda: TaskManager.add_task("设置MAA_全局", self.name, None) - ) - - Layout.addWidget(self.card_Name) - Layout.addWidget(self.card_Path) - Layout.addWidget(self.card_Set) - Layout.addWidget(self.RunSet) - - self.viewLayout.addLayout(Layout) - - def PathClicked(self): - """选择MAA目录并验证""" - - folder = QFileDialog.getExistingDirectory( - self, - "选择MAA目录", - self.config.get(self.config.MaaSet_Path), - ) - if not folder or self.config.get(self.config.MaaSet_Path) == folder: - logger.warning( - "选择MAA目录时未选择文件夹或未更改文件夹", module="脚本管理" - ) - MainInfoBar.push_info_bar( - "warning", "警告", "未选择文件夹或未更改文件夹", 5000 - ) - return None - elif ( - not (Path(folder) / "config/gui.json").exists() - or not (Path(folder) / "MAA.exe").exists() - ): - logger.warning( - "选择MAA目录时未找到MAA程序或配置文件", module="脚本管理" - ) - MainInfoBar.push_info_bar( - "warning", "警告", "未找到MAA程序或配置文件", 5000 - ) - return None - - (Config.script_dict[self.name]["Path"] / "Default").mkdir( - parents=True, exist_ok=True - ) - shutil.copy( - Path(folder) / "config/gui.json", - Config.script_dict[self.name]["Path"] / "Default/gui.json", - ) - self.config.set(self.config.MaaSet_Path, folder) - - class RunSetSettingCard(ExpandGroupSettingCard): - - def __init__(self, config: MaaConfig, parent=None): - super().__init__( - FluentIcon.SETTING, "运行", "MAA运行调控选项", parent - ) - self.config = config - - self.card_TaskTransitionMethod = ComboBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="任务切换方式", - content="相邻两个任务间的切换方式,使用「详细」配置的用户固定为「重启模拟器」", - texts=["直接切换账号", "重启明日方舟", "重启模拟器"], - qconfig=self.config, - configItem=self.config.RunSet_TaskTransitionMethod, - parent=self, - ) - self.card_ProxyTimesLimit = SpinBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="用户单日代理次数上限", - content="当用户本日代理成功次数达到该阈值时跳过代理,阈值为「0」时视为无代理次数上限", - range=(0, 1024), - qconfig=self.config, - configItem=self.config.RunSet_ProxyTimesLimit, - parent=self, - ) - self.card_ADBSearchRange = SpinBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="ADB端口号搜索范围", - content="在【±端口号范围】内搜索实际ADB端口号", - range=(0, 3), - qconfig=self.config, - configItem=self.config.RunSet_ADBSearchRange, - parent=self, - ) - self.card_RunTimesLimit = SpinBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="代理重试次数限制", - content="若超过该次数限制仍未完成代理,视为代理失败", - range=(1, 1024), - qconfig=self.config, - configItem=self.config.RunSet_RunTimesLimit, - parent=self, - ) - self.card_AnnihilationTimeLimit = SpinBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="剿灭代理超时限制", - content="MAA日志无变化时间超过该阈值视为超时,单位为分钟", - range=(1, 1024), - qconfig=self.config, - configItem=self.config.RunSet_AnnihilationTimeLimit, - parent=self, - ) - self.card_RoutineTimeLimit = SpinBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="自动代理超时限制", - content="MAA日志无变化时间超过该阈值视为超时,单位为分钟", - range=(1, 1024), - qconfig=self.config, - configItem=self.config.RunSet_RoutineTimeLimit, - parent=self, - ) - self.card_AnnihilationWeeklyLimit = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="每周剿灭仅执行到上限", - content="每周剿灭模式执行到上限,本周剩下时间不再执行剿灭任务", - qconfig=self.config, - configItem=self.config.RunSet_AnnihilationWeeklyLimit, - parent=self, - ) - - widget = QWidget() - Layout = QVBoxLayout(widget) - Layout.addWidget(self.card_TaskTransitionMethod) - Layout.addWidget(self.card_ProxyTimesLimit) - Layout.addWidget(self.card_ADBSearchRange) - Layout.addWidget(self.card_RunTimesLimit) - Layout.addWidget(self.card_AnnihilationTimeLimit) - Layout.addWidget(self.card_RoutineTimeLimit) - Layout.addWidget(self.card_AnnihilationWeeklyLimit) - self.viewLayout.setContentsMargins(0, 0, 0, 0) - self.viewLayout.setSpacing(0) - self.addGroupWidget(widget) - - class UserManager(HeaderCardWidget): - """用户管理父页面""" - - def __init__(self, name: str, parent=None): - super().__init__(parent) - - self.setObjectName(f"{name}_用户管理") - self.setTitle("下属用户") - self.name = name - - self.tools = CommandBar() - self.user_manager = self.UserSettingBox(self.name, self) - - # 逐个添加动作 - self.tools.addActions( - [ - Action( - FluentIcon.ADD_TO, "新建用户", triggered=self.add_user - ), - Action( - FluentIcon.REMOVE_FROM, - "删除用户", - triggered=self.del_user, - ), - ] - ) - self.tools.addSeparator() - self.tools.addActions( - [ - Action( - FluentIcon.LEFT_ARROW, - "向前移动", - triggered=self.left_user, - ), - Action( - FluentIcon.RIGHT_ARROW, - "向后移动", - triggered=self.right_user, - ), - ] - ) - - layout = QVBoxLayout() - layout.addWidget(self.tools) - layout.addWidget(self.user_manager) - self.viewLayout.addLayout(layout) - - def add_user(self): - """添加一个用户""" - - index = len(Config.script_dict[self.name]["UserData"]) + 1 - - logger.info(f"正在添加 {self.name} 用户_{index}", module="脚本管理") - - # 初始化用户配置信息 - user_config = MaaUserConfig() - user_config.load( - Config.script_dict[self.name]["Path"] - / f"UserData/用户_{index}/config.json", - user_config, - ) - user_config.save() - - Config.script_dict[self.name]["UserData"][f"用户_{index}"] = { - "Path": Config.script_dict[self.name]["Path"] - / f"UserData/用户_{index}", - "Config": user_config, - } - - # 添加用户设置面板 - self.user_manager.add_userSettingBox(index) - self.user_manager.switch_SettingBox(f"用户_{index}") - - logger.success( - f"{self.name} 用户_{index} 添加成功", module="脚本管理" - ) - MainInfoBar.push_info_bar( - "success", "操作成功", f"{self.name} 添加 用户_{index}", 3000 - ) - SoundPlayer.play("添加用户") - - def del_user(self): - """删除一个用户""" - - name = self.user_manager.pivot.currentRouteKey() - - if name is None: - logger.warning("未选择用户", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "未选择用户", "请先选择一个用户", 5000 - ) - return None - if name == "用户仪表盘": - logger.warning("试图删除用户仪表盘", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "未选择用户", "请勿尝试删除用户仪表盘", 5000 - ) - return None - - if self.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"正在删除 {self.name} {name}", module="脚本管理") - - self.user_manager.clear_SettingBox() - - # 删除用户配置文件并同步修改相应配置项 - shutil.rmtree( - Config.script_dict[self.name]["UserData"][name]["Path"] - ) - for i in range( - int(name[3:]) + 1, - len(Config.script_dict[self.name]["UserData"]) + 1, - ): - if Config.script_dict[self.name]["UserData"][f"用户_{i}"][ - "Path" - ].exists(): - Config.script_dict[self.name]["UserData"][f"用户_{i}"][ - "Path" - ].rename( - Config.script_dict[self.name]["UserData"][ - f"用户_{i}" - ]["Path"].with_name(f"用户_{i-1}") - ) - - self.user_manager.show_SettingBox( - f"用户_{max(int(name[3:]) - 1, 1)}" - ) - - logger.success( - f"{self.name} {name} 删除成功", module="脚本管理" - ) - MainInfoBar.push_info_bar( - "success", "操作成功", f"{self.name} 删除 {name}", 3000 - ) - SoundPlayer.play("删除用户") - - def left_user(self): - """向前移动用户""" - - name = self.user_manager.pivot.currentRouteKey() - - if name is None: - logger.warning("未选择用户", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "未选择用户", "请先选择一个用户", 5000 - ) - return None - if name == "用户仪表盘": - 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 self.name in Config.running_list: - logger.warning("所属脚本正在运行", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "所属脚本正在运行", "请先停止任务", 5000 - ) - return None - - logger.info(f"正在向前移动 {self.name} {name}", module="脚本管理") - - self.user_manager.clear_SettingBox() - - # 移动用户配置文件并同步修改配置项 - Config.script_dict[self.name]["UserData"][name]["Path"].rename( - Config.script_dict[self.name]["UserData"][name][ - "Path" - ].with_name("用户_0") - ) - Config.script_dict[self.name]["UserData"][f"用户_{index-1}"][ - "Path" - ].rename(Config.script_dict[self.name]["UserData"][name]["Path"]) - Config.script_dict[self.name]["UserData"][name]["Path"].with_name( - "用户_0" - ).rename( - Config.script_dict[self.name]["UserData"][f"用户_{index-1}"][ - "Path" - ] - ) - - self.user_manager.show_SettingBox(f"用户_{index - 1}") - - logger.success(f"{self.name} {name} 前移成功", module="脚本管理") - MainInfoBar.push_info_bar( - "success", "操作成功", f"{self.name} 前移 {name}", 3000 - ) - - def right_user(self): - """向后移动用户""" - - name = self.user_manager.pivot.currentRouteKey() - - if name is None: - logger.warning("未选择用户", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "未选择用户", "请先选择一个用户", 5000 - ) - return None - if name == "用户仪表盘": - logger.warning("试图删除用户仪表盘", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "未选择用户", "请勿尝试移动用户仪表盘", 5000 - ) - return None - - index = int(name[3:]) - - if index == len(Config.script_dict[self.name]["UserData"]): - logger.warning("向后移动用户时已到达最右端", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "已经是最后一个用户", "无法向后移动", 5000 - ) - return None - - if self.name in Config.running_list: - logger.warning("所属脚本正在运行", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "所属脚本正在运行", "请先停止任务", 5000 - ) - return None - - logger.info(f"正在向后移动 {self.name} {name}", module="脚本管理") - - self.user_manager.clear_SettingBox() - - Config.script_dict[self.name]["UserData"][name]["Path"].rename( - Config.script_dict[self.name]["UserData"][name][ - "Path" - ].with_name("用户_0") - ) - Config.script_dict[self.name]["UserData"][f"用户_{index+1}"][ - "Path" - ].rename(Config.script_dict[self.name]["UserData"][name]["Path"]) - Config.script_dict[self.name]["UserData"][name]["Path"].with_name( - "用户_0" - ).rename( - Config.script_dict[self.name]["UserData"][f"用户_{index+1}"][ - "Path" - ] - ) - - self.user_manager.show_SettingBox(f"用户_{index + 1}") - - logger.success(f"{self.name} {name} 后移成功", module="脚本管理") - MainInfoBar.push_info_bar( - "success", "操作成功", f"{self.name} 后移 {name}", 3000 - ) - - class UserSettingBox(QWidget): - """用户管理子页面组""" - - def __init__(self, name: str, parent=None): - super().__init__(parent) - - self.setObjectName("用户管理") - self.name = name - - 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[ - ScriptManager.ScriptSettingBox.MaaSettingBox.UserManager.UserSettingBox.UserMemberSettingBox - ] = [] - - self.user_dashboard = self.UserDashboard(self.name, self) - self.user_dashboard.switch_to.connect(self.switch_SettingBox) - self.stackedWidget.addWidget(self.user_dashboard) - self.pivot.addItem(routeKey="用户仪表盘", text="用户仪表盘") - - 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( - index, if_change_pivot=False - ) - ) - - self.show_SettingBox("用户仪表盘") - - def show_SettingBox(self, index: str) -> None: - """ - 加载所有子界面并切换到指定子界面 - - :param index: 要切换到的子界面索引或名称 - :type index: str - """ - - Config.search_maa_user(self.name) - - for name in Config.script_dict[self.name]["UserData"].keys(): - self.add_userSettingBox(name[3:]) - - self.switch_SettingBox(index) - - def switch_SettingBox( - self, index: str, if_change_pivot: bool = True - ) -> None: - """ - 切换到指定的子界面 - - :param index: 要切换到的子界面索引或名称 - :type index: str - :param if_change_pivot: 是否更改导航栏的当前项 - :type if_change_pivot: bool - """ - - if len(Config.script_dict[self.name]["UserData"]) == 0: - index = "用户仪表盘" - - if index != "用户仪表盘" and int(index[3:]) > len( - Config.script_dict[self.name]["UserData"] - ): - return None - - if index == "用户仪表盘": - self.user_dashboard.load_info() - - if if_change_pivot: - self.pivot.setCurrentItem(index) - self.stackedWidget.setCurrentWidget( - self.user_dashboard - if index == "用户仪表盘" - else self.script_list[int(index[3:]) - 1] - ) - - def clear_SettingBox(self) -> None: - """清空除用户仪表盘外所有子界面""" - - for sub_interface in self.script_list: - Config.stage_refreshed.disconnect( - sub_interface.refresh_stage - ) - Config.PASSWORD_refreshed.disconnect( - sub_interface.refresh_password - ) - self.stackedWidget.removeWidget(sub_interface) - sub_interface.deleteLater() - self.script_list.clear() - self.pivot.clear() - self.user_dashboard.dashboard.setRowCount(0) - self.stackedWidget.addWidget(self.user_dashboard) - self.pivot.addItem(routeKey="用户仪表盘", text="用户仪表盘") - - def add_userSettingBox(self, uid: int) -> None: - """ - 添加一个用户设置界面 - - :param uid: 用户的唯一标识符 - :type uid: int - """ - - setting_box = self.UserMemberSettingBox(self.name, 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 UserDashboard(HeaderCardWidget): - """用户仪表盘页面""" - - switch_to = Signal(str) - - def __init__(self, name: str, parent=None): - super().__init__(parent) - self.setObjectName("用户仪表盘") - self.setTitle("用户仪表盘") - self.name = name - - self.dashboard = TableWidget(self) - self.dashboard.setColumnCount(12) - self.dashboard.setHorizontalHeaderLabels( - [ - "用户名", - "账号ID", - "密码", - "状态", - "代理情况", - "给药量", - "关卡选择", - "备选 - 1", - "备选 - 2", - "备选 - 3", - "剩余理智", - "详", - ] - ) - self.dashboard.setEditTriggers(TableWidget.NoEditTriggers) - self.dashboard.verticalHeader().setVisible(False) - for col in range(6): - self.dashboard.horizontalHeader().setSectionResizeMode( - col, QHeaderView.ResizeMode.ResizeToContents - ) - for col in range(6, 11): - self.dashboard.horizontalHeader().setSectionResizeMode( - col, QHeaderView.ResizeMode.Stretch - ) - self.dashboard.horizontalHeader().setSectionResizeMode( - 11, QHeaderView.ResizeMode.Fixed - ) - self.dashboard.setColumnWidth(11, 32) - - self.viewLayout.addWidget(self.dashboard) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - Config.PASSWORD_refreshed.connect(self.load_info) - - def load_info(self): - """加载用户信息到仪表盘""" - - logger.info( - f"正在加载 {self.name} 用户信息到仪表盘", - module="脚本管理", - ) - - self.user_data = Config.script_dict[self.name]["UserData"] - - self.dashboard.setRowCount(len(self.user_data)) - - for name, info in self.user_data.items(): - - config = info["Config"] - - text_list = [] - if not config.get(config.Data_IfPassCheck): - text_list.append("未通过人工排查") - text_list.append( - f"今日已代理{config.get(config.Data_ProxyTimes)}次" - if Config.server_date().strftime("%Y-%m-%d") - == config.get(config.Data_LastProxyDate) - else "今日未进行代理" - ) - text_list.append( - "本周剿灭已完成" - if datetime.strptime( - config.get(config.Data_LastAnnihilationDate), - "%Y-%m-%d", - ).isocalendar()[:2] - == Config.server_date().isocalendar()[:2] - else "本周剿灭未完成" - ) - - stage_info = config.get_plan_info() - - button = PrimaryToolButton( - FluentIcon.CHEVRON_RIGHT, self - ) - button.setFixedSize(32, 32) - button.clicked.connect( - partial(self.switch_to.emit, name) - ) - - self.dashboard.setItem( - int(name[3:]) - 1, - 0, - QTableWidgetItem(config.get(config.Info_Name)), - ) - self.dashboard.setItem( - int(name[3:]) - 1, - 1, - QTableWidgetItem(config.get(config.Info_Id)), - ) - self.dashboard.setItem( - int(name[3:]) - 1, - 2, - QTableWidgetItem( - Crypto.AUTO_decryptor( - config.get(config.Info_Password), - Config.PASSWORD, - ) - if Config.PASSWORD - else "******" - ), - ) - self.dashboard.setCellWidget( - int(name[3:]) - 1, - 3, - StatusSwitchSetting( - qconfig=config, - configItem_check=config.Info_Status, - configItem_enable=config.Info_RemainedDay, - parent=self, - ), - ) - self.dashboard.setItem( - int(name[3:]) - 1, - 4, - QTableWidgetItem(" | ".join(text_list)), - ) - self.dashboard.setItem( - int(name[3:]) - 1, - 5, - QTableWidgetItem(str(stage_info["MedicineNumb"])), - ) - self.dashboard.setItem( - int(name[3:]) - 1, - 6, - QTableWidgetItem( - Config.stage_dict["ALL"]["text"][ - Config.stage_dict["ALL"]["value"].index( - stage_info["Stage"] - ) - ] - if stage_info["Stage"] - in Config.stage_dict["ALL"]["value"] - else stage_info["Stage"] - ), - ) - self.dashboard.setItem( - int(name[3:]) - 1, - 7, - QTableWidgetItem( - Config.stage_dict["ALL"]["text"][ - Config.stage_dict["ALL"]["value"].index( - stage_info["Stage_1"] - ) - ] - if stage_info["Stage_1"] - in Config.stage_dict["ALL"]["value"] - else stage_info["Stage_1"] - ), - ) - self.dashboard.setItem( - int(name[3:]) - 1, - 8, - QTableWidgetItem( - Config.stage_dict["ALL"]["text"][ - Config.stage_dict["ALL"]["value"].index( - stage_info["Stage_2"] - ) - ] - if stage_info["Stage_2"] - in Config.stage_dict["ALL"]["value"] - else stage_info["Stage_2"] - ), - ) - self.dashboard.setItem( - int(name[3:]) - 1, - 9, - QTableWidgetItem( - Config.stage_dict["ALL"]["text"][ - Config.stage_dict["ALL"]["value"].index( - stage_info["Stage_3"] - ) - ] - if stage_info["Stage_3"] - in Config.stage_dict["ALL"]["value"] - else stage_info["Stage_3"] - ), - ) - self.dashboard.setItem( - int(name[3:]) - 1, - 10, - QTableWidgetItem( - "不使用" - if stage_info["Stage_Remain"] == "-" - else ( - ( - Config.stage_dict["ALL"]["text"][ - Config.stage_dict["ALL"][ - "value" - ].index(stage_info["Stage_Remain"]) - ] - ) - if stage_info["Stage_Remain"] - in Config.stage_dict["ALL"]["value"] - else stage_info["Stage_Remain"] - ) - ), - ) - self.dashboard.setCellWidget( - int(name[3:]) - 1, 11, button - ) - - logger.success( - f"{self.name} 用户仪表盘成功加载信息", module="脚本管理" - ) - - class UserMemberSettingBox(HeaderCardWidget): - """用户管理子页面""" - - def __init__(self, name: str, uid: int, parent=None): - super().__init__(parent) - - self.setObjectName(f"用户_{uid}") - self.setTitle(f"用户 {uid}") - self.name = name - self.config = Config.script_dict[self.name]["UserData"][ - f"用户_{uid}" - ]["Config"] - self.user_path = Config.script_dict[self.name]["UserData"][ - f"用户_{uid}" - ]["Path"] - - plan_list = [ - ["固定"] + [_ for _ in Config.plan_dict.keys()], - ["固定"] - + [ - ( - k - if v["Config"].get(v["Config"].Info_Name) == "" - else f"{k} - {v["Config"].get(v["Config"].Info_Name)}" - ) - for k, v in Config.plan_dict.items() - ], - ] - - self.card_Name = LineEditSettingCard( - icon=FluentIcon.PEOPLE, - title="用户名", - content="用户的昵称", - text="请输入用户名", - qconfig=self.config, - configItem=self.config.Info_Name, - parent=self, - ) - self.card_Id = LineEditSettingCard( - icon=FluentIcon.PEOPLE, - title="账号ID", - content="官服输入手机号,B服输入B站ID", - text="请输入账号ID", - qconfig=self.config, - configItem=self.config.Info_Id, - parent=self, - ) - self.card_Mode = ComboBoxSettingCard( - icon=FluentIcon.DICTIONARY, - title="用户配置模式", - content="用户信息配置模式", - texts=["简洁", "详细"], - qconfig=self.config, - configItem=self.config.Info_Mode, - parent=self, - ) - self.card_Server = ComboBoxSettingCard( - icon=FluentIcon.PROJECTOR, - title="服务器", - content="选择服务器类型", - texts=[ - "官服", - "B服", - "悠星国际服", - "悠星日服", - "悠星韩服", - "繁中服", - ], - qconfig=self.config, - configItem=self.config.Info_Server, - parent=self, - ) - self.card_Status = SwitchSettingCard( - icon=FluentIcon.CHECKBOX, - title="用户状态", - content="启用或禁用该用户", - qconfig=self.config, - configItem=self.config.Info_Status, - parent=self, - ) - self.card_RemainedDay = SpinBoxSettingCard( - icon=FluentIcon.CALENDAR, - title="剩余天数", - content="剩余代理天数,-1表示无限代理", - range=(-1, 1024), - qconfig=self.config, - configItem=self.config.Info_RemainedDay, - parent=self, - ) - self.card_Annihilation = ComboBoxSettingCard( - icon=FluentIcon.CAFE, - title="剿灭代理", - content="剿灭代理子任务相关设置", - texts=[ - "关闭", - "当期剿灭", - "切尔诺伯格", - "龙门外环", - "龙门市区", - ], - qconfig=self.config, - configItem=self.config.Info_Annihilation, - parent=self, - ) - self.card_Routine = PushAndSwitchButtonSettingCard( - icon=FluentIcon.CAFE, - title="日常代理", - content="日常代理子任务相关设置", - text="设置具体配置", - qconfig=self.config, - configItem=self.config.Info_Routine, - parent=self, - ) - self.card_InfrastMode = PushAndComboBoxSettingCard( - icon=FluentIcon.CAFE, - title="基建模式", - content="自定义基建配置文件未生效", - text="选择配置文件", - texts=[ - "常规模式", - "一键轮休", - "自定义基建", - ], - qconfig=self.config, - configItem=self.config.Info_InfrastMode, - parent=self, - ) - self.card_Password = PasswordLineEditSettingCard( - icon=FluentIcon.VPN, - title="密码", - content="仅用于用户密码记录", - text="请输入用户密码", - algorithm="AUTO", - qconfig=self.config, - configItem=self.config.Info_Password, - parent=self, - ) - self.card_Notes = LineEditSettingCard( - icon=FluentIcon.PENCIL_INK, - title="备注", - content="用户备注信息", - text="请输入备注", - qconfig=self.config, - configItem=self.config.Info_Notes, - parent=self, - ) - self.card_MedicineNumb = SpinBoxWithPlanSettingCard( - icon=FluentIcon.GAME, - title="吃理智药", - content="吃理智药次数,输入0以关闭", - range=(0, 1024), - qconfig=self.config, - configItem=self.config.Info_MedicineNumb, - parent=self, - ) - self.card_SeriesNumb = ComboBoxWithPlanSettingCard( - icon=FluentIcon.GAME, - title="连战次数", - content="连战次数较大时建议搭配剩余理智关卡使用", - texts=["AUTO", "6", "5", "4", "3", "2", "1", "不选择"], - qconfig=self.config, - configItem=self.config.Info_SeriesNumb, - parent=self, - ) - self.card_SeriesNumb.comboBox.setMinimumWidth(150) - self.card_StageMode = NoOptionComboBoxSettingCard( - icon=FluentIcon.DICTIONARY, - title="关卡配置模式", - content="刷理智关卡号的配置模式", - value=plan_list[0], - texts=plan_list[1], - qconfig=self.config, - configItem=self.config.Info_StageMode, - parent=self, - ) - self.card_StageMode.comboBox.setMinimumWidth(150) - self.card_Stage = EditableComboBoxWithPlanSettingCard( - icon=FluentIcon.GAME, - title="关卡选择", - content="按下回车以添加自定义关卡号", - value=Config.stage_dict["ALL"]["value"], - texts=Config.stage_dict["ALL"]["text"], - qconfig=self.config, - configItem=self.config.Info_Stage, - parent=self, - ) - self.card_Stage_1 = EditableComboBoxWithPlanSettingCard( - icon=FluentIcon.GAME, - title="备选关卡 - 1", - content="按下回车以添加自定义关卡号", - value=Config.stage_dict["ALL"]["value"], - texts=Config.stage_dict["ALL"]["text"], - qconfig=self.config, - configItem=self.config.Info_Stage_1, - parent=self, - ) - self.card_Stage_2 = EditableComboBoxWithPlanSettingCard( - icon=FluentIcon.GAME, - title="备选关卡 - 2", - content="按下回车以添加自定义关卡号", - value=Config.stage_dict["ALL"]["value"], - texts=Config.stage_dict["ALL"]["text"], - qconfig=self.config, - configItem=self.config.Info_Stage_2, - parent=self, - ) - self.card_Stage_3 = EditableComboBoxWithPlanSettingCard( - icon=FluentIcon.GAME, - title="备选关卡 - 3", - content="按下回车以添加自定义关卡号", - value=Config.stage_dict["ALL"]["value"], - texts=Config.stage_dict["ALL"]["text"], - qconfig=self.config, - configItem=self.config.Info_Stage_3, - parent=self, - ) - self.card_Stage_Remain = ( - EditableComboBoxWithPlanSettingCard( - icon=FluentIcon.GAME, - title="剩余理智关卡", - content="按下回车以添加自定义关卡号", - value=Config.stage_dict["ALL"]["value"], - texts=[ - "不使用" if _ == "当前/上次" else _ - for _ in Config.stage_dict["ALL"]["text"] - ], - qconfig=self.config, - configItem=self.config.Info_Stage_Remain, - parent=self, - ) - ) - self.card_Skland = PasswordLineAndSwitchButtonSettingCard( - icon=FluentIcon.CERTIFICATE, - title="森空岛签到", - content="此功能具有一定风险,请谨慎使用!获取登录凭证请查阅「文档-进阶功能」。", - text="鹰角网络通行证登录凭证", - algorithm="DPAPI", - qconfig=self.config, - configItem_bool=self.config.Info_IfSkland, - configItem_info=self.config.Info_SklandToken, - parent=self, - ) - self.card_Skland.LineEdit.setMinimumWidth(250) - - self.card_UserLable = UserLableSettingCard( - icon=FluentIcon.INFO, - title="状态信息", - content="用户的代理情况汇总", - qconfig=self.config, - configItems={ - "LastProxyDate": self.config.Data_LastProxyDate, - "LastAnnihilationDate": self.config.Data_LastAnnihilationDate, - "ProxyTimes": self.config.Data_ProxyTimes, - "IfPassCheck": self.config.Data_IfPassCheck, - "IfSkland": self.config.Info_IfSkland, - "LastSklandDate": self.config.Data_LastSklandDate, - }, - parent=self, - ) - - # 单独任务卡片 - self.card_TaskSet = UserTaskSettingCard( - icon=FluentIcon.LIBRARY, - title="自动日常代理任务序列", - content="未启用任何任务项", - text="设置", - qconfig=self.config, - configItems={ - "IfWakeUp": self.config.Task_IfWakeUp, - "IfRecruiting": self.config.Task_IfRecruiting, - "IfBase": self.config.Task_IfBase, - "IfCombat": self.config.Task_IfCombat, - "IfMall": self.config.Task_IfMall, - "IfMission": self.config.Task_IfMission, - "IfAutoRoguelike": self.config.Task_IfAutoRoguelike, - "IfReclamation": self.config.Task_IfReclamation, - }, - parent=self, - ) - self.card_IfWakeUp = SwitchSettingCard( - icon=FluentIcon.TILES, - title="开始唤醒", - content="", - qconfig=self.config, - configItem=self.config.Task_IfWakeUp, - parent=self, - ) - self.card_IfRecruiting = SwitchSettingCard( - icon=FluentIcon.TILES, - title="自动公招", - content="", - qconfig=self.config, - configItem=self.config.Task_IfRecruiting, - parent=self, - ) - self.card_IfBase = SwitchSettingCard( - icon=FluentIcon.TILES, - title="基建换班", - content="", - qconfig=self.config, - configItem=self.config.Task_IfBase, - parent=self, - ) - self.card_IfCombat = SwitchSettingCard( - icon=FluentIcon.TILES, - title="刷理智", - content="", - qconfig=self.config, - configItem=self.config.Task_IfCombat, - parent=self, - ) - self.card_IfMall = SwitchSettingCard( - icon=FluentIcon.TILES, - title="获取信用及购物", - content="", - qconfig=self.config, - configItem=self.config.Task_IfMall, - parent=self, - ) - self.card_IfMission = SwitchSettingCard( - icon=FluentIcon.TILES, - title="领取奖励", - content="", - qconfig=self.config, - configItem=self.config.Task_IfMission, - parent=self, - ) - self.card_IfAutoRoguelike = SwitchSettingCard( - icon=FluentIcon.TILES, - title="自动肉鸽", - content="", - qconfig=self.config, - configItem=self.config.Task_IfAutoRoguelike, - parent=self, - ) - self.card_IfReclamation = SwitchSettingCard( - icon=FluentIcon.TILES, - title="生息演算", - content="", - qconfig=self.config, - configItem=self.config.Task_IfReclamation, - parent=self, - ) - - self.TaskSetCard = SettingFlyoutView( - self, - "自动日常代理任务序列设置", - [ - self.card_IfWakeUp, - self.card_IfRecruiting, - self.card_IfBase, - self.card_IfCombat, - self.card_IfMall, - self.card_IfMission, - self.card_IfAutoRoguelike, - self.card_IfReclamation, - ], - ) - - # 单独通知卡片 - self.card_NotifySet = UserNoticeSettingCard( - icon=FluentIcon.MAIL, - title="用户单独通知设置", - content="未启用任何通知项", - text="设置", - qconfig=self.config, - configItem=self.config.Notify_Enabled, - configItems={ - "IfSendStatistic": self.config.Notify_IfSendStatistic, - "IfSendSixStar": self.config.Notify_IfSendSixStar, - "IfSendMail": self.config.Notify_IfSendMail, - "ToAddress": self.config.Notify_ToAddress, - "IfServerChan": self.config.Notify_IfServerChan, - "ServerChanKey": self.config.Notify_ServerChanKey, - "IfCompanyWebHookBot": self.config.Notify_IfCompanyWebHookBot, - "CompanyWebHookBotUrl": self.config.Notify_CompanyWebHookBotUrl, - }, - parent=self, - ) - self.card_NotifyContent = self.NotifyContentSettingCard( - self.config, self - ) - self.card_EMail = self.EMailSettingCard(self.config, self) - self.card_ServerChan = self.ServerChanSettingCard( - self.config, self - ) - self.card_CompanyWebhookBot = ( - self.CompanyWechatPushSettingCard(self.config, self) - ) - - self.NotifySetCard = SettingFlyoutView( - self, - "用户通知设置", - [ - self.card_NotifyContent, - self.card_EMail, - self.card_ServerChan, - self.card_CompanyWebhookBot, - ], - ) - - h1_layout = QHBoxLayout() - h1_layout.addWidget(self.card_Name) - h1_layout.addWidget(self.card_Id) - h2_layout = QHBoxLayout() - h2_layout.addWidget(self.card_Mode) - h2_layout.addWidget(self.card_Server) - h3_layout = QHBoxLayout() - h3_layout.addWidget(self.card_Status) - h3_layout.addWidget(self.card_RemainedDay) - h4_layout = QHBoxLayout() - h4_layout.addWidget(self.card_Annihilation) - h4_layout.addWidget(self.card_Routine) - h4_layout.addWidget(self.card_InfrastMode) - h5_layout = QHBoxLayout() - h5_layout.addWidget(self.card_Password) - h5_layout.addWidget(self.card_Notes) - h6_layout = QHBoxLayout() - h6_layout.addWidget(self.card_MedicineNumb) - h6_layout.addWidget(self.card_SeriesNumb) - h7_layout = QHBoxLayout() - h7_layout.addWidget(self.card_StageMode) - h7_layout.addWidget(self.card_Stage) - h8_layout = QHBoxLayout() - h8_layout.addWidget(self.card_Stage_1) - h8_layout.addWidget(self.card_Stage_2) - h9_layout = QHBoxLayout() - h9_layout.addWidget(self.card_Stage_3) - h9_layout.addWidget(self.card_Stage_Remain) - - Layout = QVBoxLayout() - Layout.addLayout(h1_layout) - Layout.addLayout(h2_layout) - Layout.addLayout(h3_layout) - Layout.addWidget(self.card_UserLable) - Layout.addLayout(h4_layout) - Layout.addLayout(h5_layout) - Layout.addLayout(h6_layout) - Layout.addLayout(h7_layout) - Layout.addLayout(h8_layout) - Layout.addLayout(h9_layout) - Layout.addWidget(self.card_Skland) - Layout.addWidget(self.card_TaskSet) - Layout.addWidget(self.card_NotifySet) - - self.viewLayout.addLayout(Layout) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - self.card_Mode.comboBox.currentIndexChanged.connect( - self.switch_mode - ) - self.card_InfrastMode.comboBox.currentIndexChanged.connect( - self.switch_infrastructure - ) - self.card_Routine.clicked.connect( - lambda: self.set_maa("Routine") - ) - self.card_InfrastMode.clicked.connect( - self.set_infrastructure - ) - self.card_TaskSet.clicked.connect(self.set_task) - self.card_NotifySet.clicked.connect(self.set_notify) - self.card_StageMode.comboBox.currentIndexChanged.connect( - self.switch_stage_mode - ) - Config.stage_refreshed.connect(self.refresh_stage) - Config.PASSWORD_refreshed.connect(self.refresh_password) - - self.switch_mode() - self.switch_stage_mode() - self.switch_infrastructure() - - def switch_mode(self) -> None: - """切换用户配置模式""" - - if self.config.get(self.config.Info_Mode) == "简洁": - - self.card_Routine.setVisible(False) - self.card_InfrastMode.setVisible(True) - - elif self.config.get(self.config.Info_Mode) == "详细": - - self.card_InfrastMode.setVisible(False) - self.card_Routine.setVisible(True) - - def switch_stage_mode(self) -> None: - """切换关卡配置模式""" - - for card, name in zip( - [ - self.card_MedicineNumb, - self.card_SeriesNumb, - self.card_Stage, - self.card_Stage_1, - self.card_Stage_2, - self.card_Stage_3, - self.card_Stage_Remain, - ], - [ - "MedicineNumb", - "SeriesNumb", - "Stage", - "Stage_1", - "Stage_2", - "Stage_3", - "Stage_Remain", - ], - ): - - card.switch_mode( - self.config.get(self.config.Info_StageMode)[:2] - ) - if ( - self.config.get(self.config.Info_StageMode) - != "固定" - ): - card.change_plan( - Config.plan_dict[ - self.config.get(self.config.Info_StageMode) - ]["Config"].get_current_info(name) - ) - - def switch_infrastructure(self) -> None: - """切换基建配置模式""" - - if ( - self.config.get(self.config.Info_InfrastMode) - == "Custom" - ): - self.card_InfrastMode.button.setVisible(True) - with ( - self.user_path - / "Infrastructure/infrastructure.json" - ).open(mode="r", encoding="utf-8") as f: - infrastructure = json.load(f) - self.card_InfrastMode.setContent( - f"当前基建配置:{infrastructure.get("title","未命名")}" - ) - else: - self.card_InfrastMode.button.setVisible(False) - self.card_InfrastMode.setContent( - "自定义基建配置文件未生效" - ) - - def refresh_stage(self): - """刷新关卡配置""" - - self.card_Stage.reLoadOptions( - Config.stage_dict["ALL"]["value"], - Config.stage_dict["ALL"]["text"], - ) - self.card_Stage_1.reLoadOptions( - Config.stage_dict["ALL"]["value"], - Config.stage_dict["ALL"]["text"], - ) - self.card_Stage_2.reLoadOptions( - Config.stage_dict["ALL"]["value"], - Config.stage_dict["ALL"]["text"], - ) - self.card_Stage_3.reLoadOptions( - Config.stage_dict["ALL"]["value"], - Config.stage_dict["ALL"]["text"], - ) - self.card_Stage_Remain.reLoadOptions( - Config.stage_dict["ALL"]["value"], - Config.stage_dict["ALL"]["text"], - ) - - def refresh_password(self): - """刷新密码配置""" - - self.card_Password.setValue( - self.card_Password.qconfig.get( - self.card_Password.configItem - ) - ) - - def set_infrastructure(self) -> None: - """配置自定义基建""" - - if self.name in Config.running_list: - logger.warning("所属脚本正在运行") - MainInfoBar.push_info_bar( - "warning", "所属脚本正在运行", "请先停止任务", 5000 - ) - return None - - file_path, _ = QFileDialog.getOpenFileName( - self, - "选择自定义基建文件", - ".", - "JSON 文件 (*.json)", - ) - if file_path != "": - (self.user_path / "Infrastructure").mkdir( - parents=True, exist_ok=True - ) - shutil.copy( - file_path, - self.user_path - / "Infrastructure/infrastructure.json", - ) - self.switch_infrastructure() - else: - logger.warning("未选择自定义基建文件") - MainInfoBar.push_info_bar( - "warning", "警告", "未选择自定义基建文件", 5000 - ) - - def set_maa(self, mode: str) -> None: - """配置MAA子配置""" - - if self.name in Config.running_list: - logger.warning("所属脚本正在运行", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "所属脚本正在运行", "请先停止任务", 5000 - ) - return None - - TaskManager.add_task( - "设置MAA_用户", - self.name, - { - "SetMaaInfo": { - "Path": self.user_path / mode, - } - }, - ) - - def set_task(self) -> None: - """设置用户任务序列相关配置""" - - self.TaskSetCard.setVisible(True) - Flyout.make( - self.TaskSetCard, - self.card_TaskSet, - self, - aniType=FlyoutAnimationType.PULL_UP, - isDeleteOnClose=False, - ) - - def set_notify(self) -> None: - """设置用户通知相关配置""" - - self.NotifySetCard.setVisible(True) - Flyout.make( - self.NotifySetCard, - self.card_NotifySet, - self, - aniType=FlyoutAnimationType.PULL_UP, - isDeleteOnClose=False, - ) - - class NotifyContentSettingCard(HeaderCardWidget): - - def __init__(self, config: MaaUserConfig, parent=None): - super().__init__(parent) - self.setTitle("用户通知内容选项") - - self.config = config - - self.card_IfSendStatistic = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送统计信息", - content="推送自动代理统计信息的通知", - qconfig=self.config, - configItem=self.config.Notify_IfSendStatistic, - parent=self, - ) - self.card_IfSendSixStar = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送公招高资喜报", - content="公招出现六星词条时推送喜报", - qconfig=self.config, - configItem=self.config.Notify_IfSendSixStar, - parent=self, - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_IfSendStatistic) - Layout.addWidget(self.card_IfSendSixStar) - self.viewLayout.addLayout(Layout) - self.viewLayout.setSpacing(3) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - class EMailSettingCard(HeaderCardWidget): - - def __init__(self, config: MaaUserConfig, parent=None): - super().__init__(parent) - self.setTitle("用户邮箱通知") - - self.config = config - - self.card_IfSendMail = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送用户邮件通知", - content="是否启用用户邮件通知功能", - qconfig=self.config, - configItem=self.config.Notify_IfSendMail, - parent=self, - ) - self.card_ToAddress = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="用户收信邮箱地址", - content="接收用户通知的邮箱地址", - text="请输入用户收信邮箱地址", - qconfig=self.config, - configItem=self.config.Notify_ToAddress, - parent=self, - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_IfSendMail) - Layout.addWidget(self.card_ToAddress) - self.viewLayout.addLayout(Layout) - self.viewLayout.setSpacing(3) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - class ServerChanSettingCard(HeaderCardWidget): - - def __init__(self, config: MaaUserConfig, parent=None): - super().__init__(parent) - self.setTitle("用户ServerChan通知") - - self.config = config - - self.card_IfServerChan = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送用户Server酱通知", - content="是否启用用户Server酱通知功能", - qconfig=self.config, - configItem=self.config.Notify_IfServerChan, - parent=self, - ) - self.card_ServerChanKey = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="用户SendKey", - content="SC3与SCT均须填写", - text="请输入用户SendKey", - qconfig=self.config, - configItem=self.config.Notify_ServerChanKey, - parent=self, - ) - self.card_ServerChanChannel = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="用户ServerChanChannel代码", - content="留空则默认,多个请使用「|」隔开", - text="请输入Channel代码,仅SCT生效", - qconfig=self.config, - configItem=self.config.Notify_ServerChanChannel, - parent=self, - ) - self.card_ServerChanTag = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="用户Tag内容", - content="留空则默认,多个请使用「|」隔开", - text="请输入加入推送的Tag,仅SC3生效", - qconfig=self.config, - configItem=self.config.Notify_ServerChanTag, - parent=self, - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_IfServerChan) - Layout.addWidget(self.card_ServerChanKey) - Layout.addWidget(self.card_ServerChanChannel) - Layout.addWidget(self.card_ServerChanTag) - self.viewLayout.addLayout(Layout) - self.viewLayout.setSpacing(3) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - class CompanyWechatPushSettingCard(HeaderCardWidget): - - def __init__(self, config: MaaUserConfig, parent=None): - super().__init__(parent) - self.setTitle("用户企业微信推送") - - self.config = config - - self.card_IfCompanyWebHookBot = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送用户企业微信机器人通知", - content="是否启用用户企微机器人通知功能", - qconfig=self.config, - configItem=self.config.Notify_IfCompanyWebHookBot, - parent=self, - ) - self.card_CompanyWebHookBotUrl = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="WebhookUrl", - content="用户企微群机器人Webhook地址", - text="请输入用户Webhook的Url", - qconfig=self.config, - configItem=self.config.Notify_CompanyWebHookBotUrl, - parent=self, - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_IfCompanyWebHookBot) - Layout.addWidget(self.card_CompanyWebHookBotUrl) - self.viewLayout.addLayout(Layout) - self.viewLayout.setSpacing(3) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - class GeneralSettingBox(QWidget): - """通用脚本设置界面""" - - def __init__(self, uid: int, parent=None): - super().__init__(parent) - - self.setObjectName(f"脚本_{uid}") - self.config = Config.script_dict[f"脚本_{uid}"]["Config"] - - self.app_setting = self.AppSettingCard(f"脚本_{uid}", self.config, self) - self.branch_manager = self.BranchManager(f"脚本_{uid}", self) - - content_widget = QWidget() - content_layout = QVBoxLayout(content_widget) - content_layout.setContentsMargins(0, 0, 11, 0) - content_layout.addWidget(self.app_setting) - content_layout.addWidget(self.branch_manager) - 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 AppSettingCard(HeaderCardWidget): - - def __init__(self, name: str, config: GeneralConfig, parent=None): - super().__init__(parent) - - self.setTitle("通用实例") - - self.name = name - self.config = config - - Layout = QVBoxLayout() - - self.card_Name = LineEditSettingCard( - icon=FluentIcon.EDIT, - title="实例名称", - content="用于标识通用实例的名称", - text="请输入实例名称", - qconfig=self.config, - configItem=self.config.Script_Name, - parent=self, - ) - self.card_Script = self.ScriptSettingCard(self.config, self) - self.card_Game = self.GameSettingCard(self.config, self) - self.card_Run = self.RunSettingCard(self.config, self) - self.card_Config = self.ConfigSettingCard( - self.name, self.config, self - ) - - Layout.addWidget(self.card_Name) - Layout.addWidget(self.card_Script) - Layout.addWidget(self.card_Game) - Layout.addWidget(self.card_Run) - Layout.addWidget(self.card_Config) - self.viewLayout.addLayout(Layout) - - class ScriptSettingCard(ExpandGroupSettingCard): - - def __init__(self, config: GeneralConfig, parent=None): - super().__init__( - FluentIcon.SETTING, "脚本设置", "脚本属性配置选项", parent - ) - self.config = config - - self.card_RootPath = PathSettingCard( - icon=FluentIcon.FOLDER, - title="脚本根目录 - [必填]", - mode="文件夹", - text="选择文件夹", - qconfig=self.config, - configItem=self.config.Script_RootPath, - parent=self, - ) - self.card_ScriptPath = PathSettingCard( - icon=FluentIcon.FOLDER, - title="脚本路径 - [必填]", - mode="可执行文件 (*.exe *.bat)", - text="选择程序", - qconfig=self.config, - configItem=self.config.Script_ScriptPath, - parent=self, - ) - self.card_Arguments = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="脚本启动参数", - content="脚本启动时的附加参数", - text="请输入脚本参数", - qconfig=self.config, - configItem=self.config.Script_Arguments, - parent=self, - ) - self.card_IfTrackProcess = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="追踪脚本子进程", - content="启用后将在脚本启动后 60s 内追踪其子进程,并仅在所有子进程结束后判定脚本中止", - qconfig=self.config, - configItem=self.config.Script_IfTrackProcess, - parent=self, - ) - self.card_ConfigPath = PathSettingCard( - icon=FluentIcon.FOLDER, - title="脚本配置文件路径 - [必填]", - mode=self.config.Script_ConfigPathMode, - text="选择路径", - qconfig=self.config, - configItem=self.config.Script_ConfigPath, - parent=self, - ) - self.card_UpdateConfigMode = ComboBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="脚本配置文件更新时机", - content="在选定的时机自动更新程序保存的配置文件", - texts=[ - "从不", - "仅任务成功后", - "仅任务失败后", - "任务完成后", - ], - qconfig=self.config, - configItem=self.config.Script_UpdateConfigMode, - parent=self, - ) - self.card_LogPath = PathSettingCard( - icon=FluentIcon.FOLDER, - title="脚本日志文件路径 - [必填]", - mode="所有文件 (*)", - text="选择文件", - qconfig=self.config, - configItem=self.config.Script_LogPath, - parent=self, - ) - self.card_LogPathFormat = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="脚本日志文件名格式", - content="若脚本日志文件名中随时间变化,请填入时间格式文本,留空则不启用", - text="请输入脚本日志文件名格式", - qconfig=self.config, - configItem=self.config.Script_LogPathFormat, - parent=self, - ) - self.card_LogTimeStart = SpinBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="脚本日志时间戳起始位置 - [必填]", - content="脚本日志中时间戳的起始位置,单位为字符", - range=(1, 1024), - qconfig=self.config, - configItem=self.config.Script_LogTimeStart, - parent=self, - ) - self.card_LogTimeEnd = SpinBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="脚本日志时间戳结束位置 - [必填]", - content="脚本日志中时间戳的结束位置,单位为字符", - range=(1, 1024), - qconfig=self.config, - configItem=self.config.Script_LogTimeEnd, - parent=self, - ) - self.card_LogTimeFormat = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="脚本日志时间戳格式 - [必填]", - content="脚本日志中时间戳的格式", - text="请输入脚本日志时间格式", - qconfig=self.config, - configItem=self.config.Script_LogTimeFormat, - parent=self, - ) - self.card_SuccessLog = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="脚本成功日志", - content="任务成功完成时出现的日志,多条请使用「|」隔开", - text="请输入脚本成功日志内容", - qconfig=self.config, - configItem=self.config.Script_SuccessLog, - parent=self, - ) - self.card_ErrorLog = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="脚本异常日志 - [必填]", - content="脚本运行异常时的日志内容,多条请使用「|」隔开", - text="请输入脚本异常日志内容", - qconfig=self.config, - configItem=self.config.Script_ErrorLog, - parent=self, - ) - - self.card_RootPath.pathChanged.connect(self.change_path) - self.card_ScriptPath.pathChanged.connect( - lambda old, new: self.check_path( - self.config.Script_ScriptPath, old, new - ) - ) - self.card_ConfigPath.pathChanged.connect( - lambda old, new: self.check_path( - self.config.Script_ConfigPath, old, new - ) - ) - self.card_LogPath.pathChanged.connect( - lambda old, new: self.check_path( - self.config.Script_LogPath, old, new - ) - ) - - h_layout = QHBoxLayout() - h_layout.addWidget(self.card_LogTimeStart) - h_layout.addWidget(self.card_LogTimeEnd) - - widget = QWidget() - Layout = QVBoxLayout(widget) - Layout.addWidget(self.card_RootPath) - Layout.addWidget(self.card_ScriptPath) - Layout.addWidget(self.card_Arguments) - Layout.addWidget(self.card_IfTrackProcess) - Layout.addWidget(self.card_ConfigPath) - Layout.addWidget(self.card_UpdateConfigMode) - Layout.addWidget(self.card_LogPath) - Layout.addWidget(self.card_LogPathFormat) - Layout.addLayout(h_layout) - Layout.addWidget(self.card_LogTimeFormat) - Layout.addWidget(self.card_SuccessLog) - Layout.addWidget(self.card_ErrorLog) - self.viewLayout.setContentsMargins(0, 0, 0, 0) - self.viewLayout.setSpacing(0) - self.addGroupWidget(widget) - - def change_path(self, old_path: Path, new_path: Path) -> None: - """ - 根据脚本根目录重新计算配置文件路径 - - :param old_path: 旧路径 - :param new_path: 新路径 - """ - - path_list = [ - self.config.Script_ScriptPath, - self.config.Script_ConfigPath, - self.config.Script_LogPath, - ] - - for path in path_list: - - if Path(self.config.get(path)).is_relative_to(old_path): - - relative_path = Path(self.config.get(path)).relative_to( - old_path - ) - self.config.set(path, str(new_path / relative_path)) - - def check_path( - self, configItem: ConfigItem, old_path: Path, new_path: Path - ) -> None: - """检查配置路径是否合法""" - - if not new_path.is_relative_to( - Path(self.config.get(self.config.Script_RootPath)) - ): - - self.config.set(configItem, str(old_path)) - logger.warning( - f"配置路径 {new_path} 不在脚本根目录下,已重置为 {old_path}", - module="脚本管理", - ) - MainInfoBar.push_info_bar( - "warning", "路径异常", "所选路径不在脚本根目录下", 5000 - ) - - class GameSettingCard(ExpandGroupSettingCard): - - def __init__(self, config: GeneralConfig, parent=None): - super().__init__( - FluentIcon.SETTING, - "游戏设置", - "游戏/模拟器属性配置选项", - parent, - ) - self.config = config - - self.card_Enabled = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="游戏/模拟器相关功能", - content="是否由AUTO_MAA管理游戏/模拟器相关进程", - qconfig=self.config, - configItem=self.config.Game_Enabled, - parent=self, - ) - self.card_Style = ComboBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="游戏平台类型", - content="游戏运行在安卓模拟器还是客户端上", - texts=["安卓模拟器", "客户端"], - qconfig=self.config, - configItem=self.config.Game_Style, - parent=self, - ) - self.card_Path = PathSettingCard( - icon=FluentIcon.FOLDER, - title="游戏/模拟器路径", - mode="可执行文件 (*.exe *.bat)", - text="选择文件", - qconfig=self.config, - configItem=self.config.Game_Path, - parent=self, - ) - self.card_Arguments = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="游戏/模拟器启动参数", - content="游戏/模拟器启动时的附加参数", - text="请输入游戏/模拟器参数", - qconfig=self.config, - configItem=self.config.Game_Arguments, - parent=self, - ) - self.card_WaitTime = SpinBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="等待游戏/模拟器启动时间", - content="启动游戏/模拟器与启动对应脚本的间隔时间,单位为秒", - range=(0, 1024), - qconfig=self.config, - configItem=self.config.Game_WaitTime, - parent=self, - ) - self.card_IfForceClose = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="游戏/模拟器强制关闭", - content="是否强制结束所有同路径进程", - qconfig=self.config, - configItem=self.config.Game_IfForceClose, - parent=self, - ) - - widget = QWidget() - Layout = QVBoxLayout(widget) - Layout.addWidget(self.card_Enabled) - Layout.addWidget(self.card_Style) - Layout.addWidget(self.card_Path) - Layout.addWidget(self.card_Arguments) - Layout.addWidget(self.card_WaitTime) - Layout.addWidget(self.card_IfForceClose) - self.viewLayout.setContentsMargins(0, 0, 0, 0) - self.viewLayout.setSpacing(0) - self.addGroupWidget(widget) - - class RunSettingCard(ExpandGroupSettingCard): - - def __init__(self, config: GeneralConfig, parent=None): - super().__init__( - FluentIcon.SETTING, "运行设置", "运行调控配置选项", parent - ) - self.config = config - - self.card_ProxyTimesLimit = SpinBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="子配置单日代理次数上限", - content="当子配置本日代理成功次数达到该阈值时跳过代理,阈值为「0」时视为无代理次数上限", - range=(0, 1024), - qconfig=self.config, - configItem=self.config.Run_ProxyTimesLimit, - parent=self, - ) - - self.card_RunTimesLimit = SpinBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="代理重试次数限制", - content="若超过该次数限制仍未完成代理,视为代理失败", - range=(1, 1024), - qconfig=self.config, - configItem=self.config.Run_RunTimesLimit, - parent=self, - ) - self.card_RunTimeLimit = SpinBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="自动代理超时限制", - content="脚本日志无变化时间超过该阈值视为超时,单位为分钟", - range=(1, 1024), - qconfig=self.config, - configItem=self.config.Run_RunTimeLimit, - parent=self, - ) - - widget = QWidget() - Layout = QVBoxLayout(widget) - Layout.addWidget(self.card_ProxyTimesLimit) - Layout.addWidget(self.card_RunTimesLimit) - Layout.addWidget(self.card_RunTimeLimit) - self.viewLayout.setContentsMargins(0, 0, 0, 0) - self.viewLayout.setSpacing(0) - self.addGroupWidget(widget) - - class ConfigSettingCard(ExpandGroupSettingCard): - - def __init__(self, name: str, config: GeneralConfig, parent=None): - super().__init__( - FluentIcon.SETTING, - "配置管理", - "使用配置模板文件快速设置脚本", - parent, - ) - self.name = name - self.config = config - - self.card_ImportFromFile = PushSettingCard( - text="从文件导入", - icon=FluentIcon.PAGE_RIGHT, - title="从文件导入通用配置", - content="选择一个配置文件,导入其中的配置信息", - parent=self, - ) - self.card_ExportToFile = PushSettingCard( - text="导出到文件", - icon=FluentIcon.PAGE_RIGHT, - title="导出通用配置到文件", - content="选择一个保存路径,将当前配置信息导出到文件", - parent=self, - ) - self.card_ImportFromWeb = PushSettingCard( - text="查看", - icon=FluentIcon.PAGE_RIGHT, - title="从「AUTO_MAA 配置分享中心」导入", - content="从「AUTO_MAA 配置分享中心」选择一个用户分享的通用配置模板,导入其中的配置信息", - parent=self, - ) - self.card_UploadToWeb = PushSettingCard( - text="上传", - icon=FluentIcon.PAGE_RIGHT, - title="上传到「AUTO_MAA 配置分享中心」", - content="将当前通用配置分享到「AUTO_MAA 配置分享中心」,通过审核后可供其他用户下载使用", - parent=self, - ) - - self.card_ImportFromFile.clicked.connect(self.import_from_file) - self.card_ExportToFile.clicked.connect(self.export_to_file) - self.card_ImportFromWeb.clicked.connect(self.import_from_web) - self.card_UploadToWeb.clicked.connect(self.upload_to_web) - - widget = QWidget() - Layout = QVBoxLayout(widget) - Layout.addWidget(self.card_ImportFromFile) - Layout.addWidget(self.card_ExportToFile) - Layout.addWidget(self.card_ImportFromWeb) - Layout.addWidget(self.card_UploadToWeb) - self.viewLayout.setContentsMargins(0, 0, 0, 0) - self.viewLayout.setSpacing(0) - self.addGroupWidget(widget) - - def import_from_file(self): - """从文件导入配置""" - - file_path, _ = QFileDialog.getOpenFileName( - self, "选择配置文件", "", "JSON Files (*.json)" - ) - if file_path: - - shutil.copy( - file_path, - Config.script_dict[self.name]["Path"] / "config.json", - ) - self.config.load( - Config.script_dict[self.name]["Path"] / "config.json" - ) - - logger.success( - f"{self.name} 配置导入成功", module="脚本管理" - ) - MainInfoBar.push_info_bar( - "success", - "操作成功", - f"{self.name} 配置导入成功", - 3000, - ) - - def export_to_file(self): - """导出配置到文件""" - - file_path, _ = QFileDialog.getSaveFileName( - self, "选择保存路径", "", "JSON Files (*.json)" - ) - if file_path: - - temp = self.config.toDict() - - # 移除配置中可能存在的隐私信息 - temp["Script"]["Name"] = Path(file_path).stem - for path in ["ScriptPath", "ConfigPath", "LogPath"]: - - if Path(temp["Script"][path]).is_relative_to( - Path(temp["Script"]["RootPath"]) - ): - - temp["Script"][path] = str( - Path(r"C:/脚本根目录") - / Path(temp["Script"][path]).relative_to( - Path(temp["Script"]["RootPath"]) - ) - ) - temp["Script"]["RootPath"] = str(Path(r"C:/脚本根目录")) - - with open(file_path, "w", encoding="utf-8") as file: - json.dump(temp, file, ensure_ascii=False, indent=4) - - logger.success( - f"{self.name} 配置导出成功", module="脚本管理" - ) - MainInfoBar.push_info_bar( - "success", - "操作成功", - f"{self.name} 配置导出成功", - 3000, - ) - - def import_from_web(self): - """从「AUTO_MAA 配置分享中心」导入配置""" - - # 从远程服务器获取配置列表 - network = Network.add_task( - mode="get", - url="http://221.236.27.82:10023/api/list/config/general", - ) - network.loop.exec() - network_result = Network.get_result(network) - if network_result["status_code"] == 200: - config_info: List[Dict[str, str]] = 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 - - choice = NoticeMessageBox( - self.window(), - "配置分享中心", - { - _[ - "configName" - ]: f""" -# {_['configName']} - -- **作者**: {_['author']} - -- **发布时间**:{_['createTime']} - -- **描述**:{_['description']} -""" - for _ in config_info - }, - ) - if choice.exec() and choice.currentIndex != 0: - - # 从远程服务器获取具体配置 - network = Network.add_task( - mode="get", - url=config_info[choice.currentIndex - 1]["downloadUrl"], - ) - network.loop.exec() - network_result = Network.get_result(network) - if network_result["status_code"] == 200: - config_data = 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 - - with ( - Config.script_dict[self.name]["Path"] / "config.json" - ).open("w", encoding="utf-8") as file: - json.dump( - config_data, file, ensure_ascii=False, indent=4 - ) - self.config.load( - Config.script_dict[self.name]["Path"] / "config.json" - ) - - logger.success( - f"{self.name} 配置导入成功", module="脚本管理" - ) - MainInfoBar.push_info_bar( - "success", - "操作成功", - f"{self.name} 配置导入成功", - 3000, - ) - - def upload_to_web(self): - """上传配置到「AUTO_MAA 配置分享中心」""" - - choice = LineEditMessageBox( - self.window(), "请输入你的用户名", "用户名", "明文" - ) - choice.input.setMinimumWidth(200) - if choice.exec() and choice.input.text() != "": - - author = choice.input.text() - - choice = LineEditMessageBox( - self.window(), "请输入配置名称", "配置名称", "明文" - ) - choice.input.setMinimumWidth(200) - if choice.exec() and choice.input.text() != "": - - config_name = choice.input.text() - - choice = LineEditMessageBox( - self.window(), - "请描述一下您要分享的配置", - "配置描述", - "明文", - ) - choice.input.setMinimumWidth(300) - if choice.exec() and choice.input.text() != "": - - description = choice.input.text() - - temp = self.config.toDict() - - # 移除配置中可能存在的隐私信息 - temp["Script"]["Name"] = config_name - for path in ["ScriptPath", "ConfigPath", "LogPath"]: - if Path(temp["Script"][path]).is_relative_to( - Path(temp["Script"]["RootPath"]) - ): - temp["Script"][path] = str( - Path(r"C:/脚本根目录") - / Path( - temp["Script"][path] - ).relative_to( - Path(temp["Script"]["RootPath"]) - ) - ) - temp["Script"]["RootPath"] = str( - Path(r"C:/脚本根目录") - ) - - files = { - "file": ( - f"{config_name}&&{author}&&{description}&&{int(datetime.now().timestamp() * 1000)}.json", - json.dumps(temp, ensure_ascii=False), - "application/json", - ) - } - data = { - "username": author, - "description": description, - } - - # 配置上传至远程服务器 - network = Network.add_task( - "upload_file", - "http://221.236.27.82:10023/api/upload/share", - files=files, - data=data, - ) - network.loop.exec() - network_result = Network.get_result(network) - if network_result["status_code"] == 200: - response = 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 - - logger.success( - f"{self.name} 配置上传成功", module="脚本管理" - ) - MainInfoBar.push_info_bar( - "success", - "上传配置成功", - ( - response["message"] - if "message" in response - else response["text"] - ), - 5000, - ) - - class BranchManager(HeaderCardWidget): - """分支管理父页面""" - - def __init__(self, name: str, parent=None): - super().__init__(parent) - - self.setObjectName(f"{name}_分支管理") - self.setTitle("下属配置") - self.name = name - - self.tools = CommandBar() - self.sub_manager = self.SubConfigSettingBox(self.name, self) - - # 逐个添加动作 - self.tools.addActions( - [ - Action( - FluentIcon.ADD_TO, "新建配置", triggered=self.add_sub - ), - Action( - FluentIcon.REMOVE_FROM, - "删除配置", - triggered=self.del_sub, - ), - ] - ) - self.tools.addSeparator() - self.tools.addActions( - [ - Action( - FluentIcon.LEFT_ARROW, - "向前移动", - triggered=self.left_sub, - ), - Action( - FluentIcon.RIGHT_ARROW, - "向后移动", - triggered=self.right_sub, - ), - ] - ) - - layout = QVBoxLayout() - layout.addWidget(self.tools) - layout.addWidget(self.sub_manager) - self.viewLayout.addLayout(layout) - - def add_sub(self): - """添加一个配置""" - - index = len(Config.script_dict[self.name]["SubData"]) + 1 - - logger.info( - f"正在添加 {self.name} 的配置_{index}", module="脚本管理" - ) - - # 初始化通用配置 - sub_config = GeneralSubConfig() - sub_config.load( - Config.script_dict[self.name]["Path"] - / f"SubData/配置_{index}/config.json", - sub_config, - ) - sub_config.save() - - Config.script_dict[self.name]["SubData"][f"配置_{index}"] = { - "Path": Config.script_dict[self.name]["Path"] - / f"SubData/配置_{index}", - "Config": sub_config, - } - - # 添加通用配置页面 - self.sub_manager.add_SettingBox(index) - self.sub_manager.switch_SettingBox(f"配置_{index}") - - logger.success( - f"{self.name} 配置_{index} 添加成功", module="脚本管理" - ) - MainInfoBar.push_info_bar( - "success", "操作成功", f"{self.name} 添加 配置_{index}", 3000 - ) - SoundPlayer.play("添加配置") - - def del_sub(self): - """删除一个配置""" - - name = self.sub_manager.pivot.currentRouteKey() - - if name is None: - logger.warning("未选择配置", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "未选择配置", "请先选择一个配置", 5000 - ) - return None - if name == "配置仪表盘": - logger.warning("试图删除配置仪表盘", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "未选择配置", "请勿尝试删除配置仪表盘", 5000 - ) - return None - - if self.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"正在删除 {self.name} 的配置_{name}", module="脚本管理" - ) - - self.sub_manager.clear_SettingBox() - - # 删除配置文件并同步到相关配置项 - shutil.rmtree( - Config.script_dict[self.name]["SubData"][name]["Path"] - ) - for i in range( - int(name[3:]) + 1, - len(Config.script_dict[self.name]["SubData"]) + 1, - ): - if Config.script_dict[self.name]["SubData"][f"配置_{i}"][ - "Path" - ].exists(): - Config.script_dict[self.name]["SubData"][f"配置_{i}"][ - "Path" - ].rename( - Config.script_dict[self.name]["SubData"][ - f"配置_{i}" - ]["Path"].with_name(f"配置_{i-1}") - ) - - self.sub_manager.show_SettingBox( - f"配置_{max(int(name[3:]) - 1, 1)}" - ) - - logger.success( - f"{self.name} {name} 删除成功", module="脚本管理" - ) - MainInfoBar.push_info_bar( - "success", "操作成功", f"{self.name} 删除 {name}", 3000 - ) - SoundPlayer.play("删除配置") - - def left_sub(self): - """向前移动配置""" - - name = self.sub_manager.pivot.currentRouteKey() - - if name is None: - logger.warning("未选择配置", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "未选择配置", "请先选择一个配置", 5000 - ) - return None - if name == "配置仪表盘": - 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 self.name in Config.running_list: - logger.warning("所属脚本正在运行", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "所属脚本正在运行", "请先停止任务", 5000 - ) - return None - - logger.info( - f"正在将 {self.name} 的配置_{name} 前移", module="脚本管理" - ) - - self.sub_manager.clear_SettingBox() - - # 移动配置文件并同步到相关配置项 - Config.script_dict[self.name]["SubData"][name]["Path"].rename( - Config.script_dict[self.name]["SubData"][name][ - "Path" - ].with_name("配置_0") - ) - Config.script_dict[self.name]["SubData"][f"配置_{index-1}"][ - "Path" - ].rename(Config.script_dict[self.name]["SubData"][name]["Path"]) - Config.script_dict[self.name]["SubData"][name]["Path"].with_name( - "配置_0" - ).rename( - Config.script_dict[self.name]["SubData"][f"配置_{index-1}"][ - "Path" - ] - ) - - self.sub_manager.show_SettingBox(f"配置_{index - 1}") - - logger.success(f"{self.name} {name} 前移成功", module="脚本管理") - MainInfoBar.push_info_bar( - "success", "操作成功", f"{self.name} 前移 {name}", 3000 - ) - - def right_sub(self): - """向后移动配置""" - - name = self.sub_manager.pivot.currentRouteKey() - - if name is None: - logger.warning("未选择配置", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "未选择配置", "请先选择一个配置", 5000 - ) - return None - if name == "配置仪表盘": - logger.warning("试图删除配置仪表盘", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "未选择配置", "请勿尝试移动配置仪表盘", 5000 - ) - return None - - index = int(name[3:]) - - if index == len(Config.script_dict[self.name]["SubData"]): - logger.warning("向后移动配置时已到达最右端", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "已经是最后一个配置", "无法向后移动", 5000 - ) - return None - - if self.name in Config.running_list: - logger.warning("所属脚本正在运行", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "所属脚本正在运行", "请先停止任务", 5000 - ) - return None - - logger.info( - f"正在将 {self.name} 的配置_{name} 后移", module="脚本管理" - ) - - self.sub_manager.clear_SettingBox() - - # 移动配置文件并同步到相关配置项 - Config.script_dict[self.name]["SubData"][name]["Path"].rename( - Config.script_dict[self.name]["SubData"][name][ - "Path" - ].with_name("配置_0") - ) - Config.script_dict[self.name]["SubData"][f"配置_{index+1}"][ - "Path" - ].rename(Config.script_dict[self.name]["SubData"][name]["Path"]) - Config.script_dict[self.name]["SubData"][name]["Path"].with_name( - "配置_0" - ).rename( - Config.script_dict[self.name]["SubData"][f"配置_{index+1}"][ - "Path" - ] - ) - - self.sub_manager.show_SettingBox(f"配置_{index + 1}") - - logger.success(f"{self.name} {name} 后移成功", module="脚本管理") - MainInfoBar.push_info_bar( - "success", "操作成功", f"{self.name} 后移 {name}", 3000 - ) - - class SubConfigSettingBox(QWidget): - """配置管理子页面组""" - - def __init__(self, name: str, parent=None): - super().__init__(parent) - - self.setObjectName("配置管理") - self.name = name - - 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[ - ScriptManager.ScriptSettingBox.GeneralSettingBox.BranchManager.SubConfigSettingBox.SubMemberSettingBox - ] = [] - - self.sub_dashboard = self.SubDashboard(self.name, self) - self.sub_dashboard.switch_to.connect(self.switch_SettingBox) - self.stackedWidget.addWidget(self.sub_dashboard) - self.pivot.addItem(routeKey="配置仪表盘", text="配置仪表盘") - - 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( - index, if_change_pivot=False - ) - ) - - self.show_SettingBox("配置仪表盘") - - def show_SettingBox(self, index: str) -> None: - """ - 加载所有子界面 - - :param index: 要显示的子界面索引 - """ - - Config.search_general_sub(self.name) - - for name in Config.script_dict[self.name]["SubData"].keys(): - self.add_SettingBox(name[3:]) - - self.switch_SettingBox(index) - - def switch_SettingBox( - self, index: str, if_change_pivot: bool = True - ) -> None: - """ - 切换到指定的子界面 - - :param index: 要切换到的子界面索引 - :param if_change_pivot: 是否更改 pivot 的当前项 - """ - - if len(Config.script_dict[self.name]["SubData"]) == 0: - index = "配置仪表盘" - - if index != "配置仪表盘" and int(index[3:]) > len( - Config.script_dict[self.name]["SubData"] - ): - return None - - if index == "配置仪表盘": - self.sub_dashboard.load_info() - - if if_change_pivot: - self.pivot.setCurrentItem(index) - self.stackedWidget.setCurrentWidget( - self.sub_dashboard - if index == "配置仪表盘" - else self.script_list[int(index[3:]) - 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() - self.sub_dashboard.dashboard.setRowCount(0) - self.stackedWidget.addWidget(self.sub_dashboard) - self.pivot.addItem(routeKey="配置仪表盘", text="配置仪表盘") - - def add_SettingBox(self, uid: int) -> None: - """ - 添加一个配置设置界面 - - :param uid: 配置的唯一标识符 - """ - - setting_box = self.SubMemberSettingBox(self.name, 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 SubDashboard(HeaderCardWidget): - """配置仪表盘页面""" - - switch_to = Signal(str) - - def __init__(self, name: str, parent=None): - super().__init__(parent) - self.setObjectName("配置仪表盘") - self.setTitle("配置仪表盘") - self.name = name - - self.dashboard = TableWidget(self) - self.dashboard.setColumnCount(5) - self.dashboard.setHorizontalHeaderLabels( - ["配置名", "状态", "代理情况", "备注", "详"] - ) - self.dashboard.setEditTriggers(TableWidget.NoEditTriggers) - self.dashboard.verticalHeader().setVisible(False) - for col in range(2): - self.dashboard.horizontalHeader().setSectionResizeMode( - col, QHeaderView.ResizeMode.ResizeToContents - ) - for col in range(2, 4): - self.dashboard.horizontalHeader().setSectionResizeMode( - col, QHeaderView.ResizeMode.Stretch - ) - self.dashboard.horizontalHeader().setSectionResizeMode( - 4, QHeaderView.ResizeMode.Fixed - ) - self.dashboard.setColumnWidth(4, 32) - - self.viewLayout.addWidget(self.dashboard) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - Config.PASSWORD_refreshed.connect(self.load_info) - - def load_info(self): - """加载配置仪表盘信息""" - - logger.info( - f"正在加载 {self.name} 的配置仪表盘信息", - module="脚本管理", - ) - - self.sub_data = Config.script_dict[self.name]["SubData"] - - self.dashboard.setRowCount(len(self.sub_data)) - - for name, info in self.sub_data.items(): - - config = info["Config"] - - text_list = [] - text_list.append( - f"今日已代理{config.get(config.Data_ProxyTimes)}次" - if Config.server_date().strftime("%Y-%m-%d") - == config.get(config.Data_LastProxyDate) - else "今日未进行代理" - ) - - button = PrimaryToolButton( - FluentIcon.CHEVRON_RIGHT, self - ) - button.setFixedSize(32, 32) - button.clicked.connect( - partial(self.switch_to.emit, name) - ) - - self.dashboard.setItem( - int(name[3:]) - 1, - 0, - QTableWidgetItem(config.get(config.Info_Name)), - ) - self.dashboard.setCellWidget( - int(name[3:]) - 1, - 1, - StatusSwitchSetting( - qconfig=config, - configItem_check=config.Info_Status, - configItem_enable=config.Info_RemainedDay, - parent=self, - ), - ) - self.dashboard.setItem( - int(name[3:]) - 1, - 2, - QTableWidgetItem(" | ".join(text_list)), - ) - self.dashboard.setItem( - int(name[3:]) - 1, - 3, - QTableWidgetItem(config.get(config.Info_Notes)), - ) - self.dashboard.setCellWidget( - int(name[3:]) - 1, 4, button - ) - - logger.success( - f"{self.name} 配置仪表盘信息加载成功", module="脚本管理" - ) - - class SubMemberSettingBox(HeaderCardWidget): - """配置管理子页面""" - - def __init__(self, name: str, uid: int, parent=None): - super().__init__(parent) - - self.setObjectName(f"配置_{uid}") - self.setTitle(f"配置 {uid}") - self.name = name - self.config = Config.script_dict[self.name]["SubData"][ - f"配置_{uid}" - ]["Config"] - self.sub_path = Config.script_dict[self.name]["SubData"][ - f"配置_{uid}" - ]["Path"] - - self.card_Name = LineEditSettingCard( - icon=FluentIcon.PEOPLE, - title="配置名", - content="用于标识配置", - text="请输入配置名", - qconfig=self.config, - configItem=self.config.Info_Name, - parent=self, - ) - self.card_SetConfig = PushSettingCard( - text="设置具体配置", - icon=FluentIcon.CAFE, - title="具体配置", - content="在脚本原始界面中查看具体配置内容", - parent=self, - ) - self.card_Status = SwitchSettingCard( - icon=FluentIcon.CHECKBOX, - title="配置状态", - content="启用或禁用该配置", - qconfig=self.config, - configItem=self.config.Info_Status, - parent=self, - ) - self.card_RemainedDay = SpinBoxSettingCard( - icon=FluentIcon.CALENDAR, - title="剩余天数", - content="剩余代理天数,-1表示无限代理", - range=(-1, 1024), - qconfig=self.config, - configItem=self.config.Info_RemainedDay, - parent=self, - ) - self.item_IfScriptBeforeTask = StatusSwitchSetting( - qconfig=self.config, - configItem_check=self.config.Info_IfScriptBeforeTask, - configItem_enable=None, - parent=self, - ) - self.card_ScriptBeforeTask = PathSettingCard( - icon=FluentIcon.FOLDER, - title="脚本前置任务", - mode="脚本文件 (*.py *.bat *.cmd *.exe)", - text="选择脚本文件", - qconfig=self.config, - configItem=self.config.Info_ScriptBeforeTask, - parent=self, - ) - self.item_IfScriptAfterTask = StatusSwitchSetting( - qconfig=self.config, - configItem_check=self.config.Info_IfScriptAfterTask, - configItem_enable=None, - parent=self, - ) - self.card_ScriptAfterTask = PathSettingCard( - icon=FluentIcon.FOLDER, - title="脚本后置任务", - mode="脚本文件 (*.py *.bat *.cmd *.exe)", - text="选择脚本文件", - qconfig=self.config, - configItem=self.config.Info_ScriptAfterTask, - parent=self, - ) - self.card_Notes = LineEditSettingCard( - icon=FluentIcon.PENCIL_INK, - title="备注", - content="配置备注信息", - text="请输入备注", - qconfig=self.config, - configItem=self.config.Info_Notes, - parent=self, - ) - - self.card_UserLable = SubLableSettingCard( - icon=FluentIcon.INFO, - title="状态信息", - content="配置的代理情况汇总", - qconfig=self.config, - configItems={ - "LastProxyDate": self.config.Data_LastProxyDate, - "ProxyTimes": self.config.Data_ProxyTimes, - }, - parent=self, - ) - - self.card_ScriptBeforeTask.hBoxLayout.insertWidget( - 5, self.item_IfScriptBeforeTask, 0, Qt.AlignRight - ) - self.card_ScriptAfterTask.hBoxLayout.insertWidget( - 5, self.item_IfScriptAfterTask, 0, Qt.AlignRight - ) - - # 单独通知卡片 - self.card_NotifySet = UserNoticeSettingCard( - icon=FluentIcon.MAIL, - title="用户单独通知设置", - content="未启用任何通知项", - text="设置", - qconfig=self.config, - configItem=self.config.Notify_Enabled, - configItems={ - "IfSendStatistic": self.config.Notify_IfSendStatistic, - "IfSendMail": self.config.Notify_IfSendMail, - "ToAddress": self.config.Notify_ToAddress, - "IfServerChan": self.config.Notify_IfServerChan, - "ServerChanKey": self.config.Notify_ServerChanKey, - "IfCompanyWebHookBot": self.config.Notify_IfCompanyWebHookBot, - "CompanyWebHookBotUrl": self.config.Notify_CompanyWebHookBotUrl, - }, - parent=self, - ) - self.card_NotifyContent = self.NotifyContentSettingCard( - self.config, self - ) - self.card_EMail = self.EMailSettingCard(self.config, self) - self.card_ServerChan = self.ServerChanSettingCard( - self.config, self - ) - self.card_CompanyWebhookBot = ( - self.CompanyWechatPushSettingCard(self.config, self) - ) - - self.NotifySetCard = SettingFlyoutView( - self, - "用户通知设置", - [ - self.card_NotifyContent, - self.card_EMail, - self.card_ServerChan, - self.card_CompanyWebhookBot, - ], - ) - - h1_layout = QHBoxLayout() - h1_layout.addWidget(self.card_Name) - h1_layout.addWidget(self.card_SetConfig) - h2_layout = QHBoxLayout() - h2_layout.addWidget(self.card_Status) - h2_layout.addWidget(self.card_RemainedDay) - - Layout = QVBoxLayout() - Layout.addLayout(h1_layout) - Layout.addLayout(h2_layout) - Layout.addWidget(self.card_UserLable) - Layout.addWidget(self.card_ScriptBeforeTask) - Layout.addWidget(self.card_ScriptAfterTask) - Layout.addWidget(self.card_Notes) - Layout.addWidget(self.card_NotifySet) - - self.viewLayout.addLayout(Layout) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - self.card_SetConfig.clicked.connect(self.set_sub) - self.card_NotifySet.clicked.connect(self.set_notify) - - def set_sub(self) -> None: - """配置子配置""" - - if self.name in Config.running_list: - logger.warning("所属脚本正在运行", module="脚本管理") - MainInfoBar.push_info_bar( - "warning", "所属脚本正在运行", "请先停止任务", 5000 - ) - return None - - TaskManager.add_task( - "设置通用脚本", - self.name, - {"SetSubInfo": {"Path": self.sub_path / "ConfigFiles"}}, - ) - - def set_notify(self) -> None: - """设置用户通知相关配置""" - - self.NotifySetCard.setVisible(True) - Flyout.make( - self.NotifySetCard, - self.card_NotifySet, - self, - aniType=FlyoutAnimationType.PULL_UP, - isDeleteOnClose=False, - ) - - class NotifyContentSettingCard(HeaderCardWidget): - - def __init__(self, config: MaaUserConfig, parent=None): - super().__init__(parent) - self.setTitle("用户通知内容选项") - - self.config = config - - self.card_IfSendStatistic = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送统计信息", - content="推送自动代理统计信息的通知", - qconfig=self.config, - configItem=self.config.Notify_IfSendStatistic, - parent=self, - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_IfSendStatistic) - self.viewLayout.addLayout(Layout) - self.viewLayout.setSpacing(3) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - class EMailSettingCard(HeaderCardWidget): - - def __init__(self, config: MaaUserConfig, parent=None): - super().__init__(parent) - self.setTitle("用户邮箱通知") - - self.config = config - - self.card_IfSendMail = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送用户邮件通知", - content="是否启用用户邮件通知功能", - qconfig=self.config, - configItem=self.config.Notify_IfSendMail, - parent=self, - ) - self.card_ToAddress = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="用户收信邮箱地址", - content="接收用户通知的邮箱地址", - text="请输入用户收信邮箱地址", - qconfig=self.config, - configItem=self.config.Notify_ToAddress, - parent=self, - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_IfSendMail) - Layout.addWidget(self.card_ToAddress) - self.viewLayout.addLayout(Layout) - self.viewLayout.setSpacing(3) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - class ServerChanSettingCard(HeaderCardWidget): - - def __init__(self, config: MaaUserConfig, parent=None): - super().__init__(parent) - self.setTitle("用户ServerChan通知") - - self.config = config - - self.card_IfServerChan = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送用户Server酱通知", - content="是否启用用户Server酱通知功能", - qconfig=self.config, - configItem=self.config.Notify_IfServerChan, - parent=self, - ) - self.card_ServerChanKey = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="用户SendKey", - content="SC3与SCT均须填写", - text="请输入用户SendKey", - qconfig=self.config, - configItem=self.config.Notify_ServerChanKey, - parent=self, - ) - self.card_ServerChanChannel = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="用户ServerChanChannel代码", - content="留空则默认,多个请使用「|」隔开", - text="请输入Channel代码,仅SCT生效", - qconfig=self.config, - configItem=self.config.Notify_ServerChanChannel, - parent=self, - ) - self.card_ServerChanTag = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="用户Tag内容", - content="留空则默认,多个请使用「|」隔开", - text="请输入加入推送的Tag,仅SC3生效", - qconfig=self.config, - configItem=self.config.Notify_ServerChanTag, - parent=self, - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_IfServerChan) - Layout.addWidget(self.card_ServerChanKey) - Layout.addWidget(self.card_ServerChanChannel) - Layout.addWidget(self.card_ServerChanTag) - self.viewLayout.addLayout(Layout) - self.viewLayout.setSpacing(3) - self.viewLayout.setContentsMargins(3, 0, 3, 3) - - class CompanyWechatPushSettingCard(HeaderCardWidget): - - def __init__(self, config: MaaUserConfig, parent=None): - super().__init__(parent) - self.setTitle("用户企业微信推送") - - self.config = config - - self.card_IfCompanyWebHookBot = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送用户企业微信机器人通知", - content="是否启用用户企微机器人通知功能", - qconfig=self.config, - configItem=self.config.Notify_IfCompanyWebHookBot, - parent=self, - ) - self.card_CompanyWebHookBotUrl = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="WebhookUrl", - content="用户企微群机器人Webhook地址", - text="请输入用户Webhook的Url", - qconfig=self.config, - configItem=self.config.Notify_CompanyWebHookBotUrl, - parent=self, - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_IfCompanyWebHookBot) - Layout.addWidget(self.card_CompanyWebHookBotUrl) - self.viewLayout.addLayout(Layout) - self.viewLayout.setSpacing(3) - self.viewLayout.setContentsMargins(3, 0, 3, 3) diff --git a/app/ui/setting.py b/app/ui/setting.py deleted file mode 100644 index 48c90f4..0000000 --- a/app/ui/setting.py +++ /dev/null @@ -1,1313 +0,0 @@ -# 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 . - -# Contact: DLmaster_361@163.com - -""" -AUTO_MAA -AUTO_MAA设置界面 -v4.4 -作者:DLmaster_361 -""" - -from PySide6.QtWidgets import QWidget, QVBoxLayout -from PySide6.QtGui import QIcon -from PySide6.QtCore import Qt -from qfluentwidgets import ( - ScrollArea, - FluentIcon, - MessageBox, - HyperlinkCard, - HeaderCardWidget, - ExpandGroupSettingCard, - PushSettingCard, - HyperlinkButton, -) -import os -import re -import json -import shutil -import subprocess -from datetime import datetime -from packaging import version -from pathlib import Path -from typing import Dict, Union - -from app.core import Config, MainInfoBar, Network, SoundPlayer, logger -from app.services import Crypto, System, Notify -from .downloader import DownloadManager -from .Widget import ( - SwitchSettingCard, - RangeSettingCard, - ComboBoxSettingCard, - LineEditMessageBox, - LineEditSettingCard, - PasswordLineEditSettingCard, - UrlListSettingCard, - NoticeMessageBox, -) - - -class Setting(QWidget): - - def __init__(self, parent=None): - super().__init__(parent) - self.setObjectName("设置") - - self.function = FunctionSettingCard(self) - self.voice = VoiceSettingCard(self) - self.start = StartSettingCard(self) - self.ui = UiSettingCard(self) - self.notification = NotifySettingCard(self) - self.security = SecuritySettingCard(self) - self.updater = UpdaterSettingCard(self) - self.other = OtherSettingCard(self) - - self.function.card_IfAllowSleep.checkedChanged.connect(System.set_Sleep) - self.function.card_IfAgreeBilibili.checkedChanged.connect(self.agree_bilibili) - self.function.card_IfSkipMumuSplashAds.checkedChanged.connect( - self.skip_MuMu_splash_ads - ) - self.start.card_IfSelfStart.checkedChanged.connect(System.set_SelfStart) - self.security.card_changePASSWORD.clicked.connect(self.change_PASSWORD) - self.security.card_resetPASSWORD.clicked.connect(self.reset_PASSWORD) - self.updater.card_CheckUpdate.clicked.connect( - lambda: self.check_update(if_show=True) - ) - self.other.card_Notice.clicked.connect(lambda: self.show_notice(if_show=True)) - - content_widget = QWidget() - content_layout = QVBoxLayout(content_widget) - content_layout.setContentsMargins(0, 0, 11, 0) - content_layout.addWidget(self.function) - content_layout.addWidget(self.voice) - content_layout.addWidget(self.start) - content_layout.addWidget(self.ui) - content_layout.addWidget(self.notification) - content_layout.addWidget(self.security) - content_layout.addWidget(self.updater) - content_layout.addWidget(self.other) - - 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) - - def agree_bilibili(self) -> None: - """授权bilibili游戏隐私政策""" - - if Config.get(Config.function_IfAgreeBilibili): - - choice = MessageBox( - "授权声明", - "开启「托管bilibili游戏隐私政策」功能,即代表您已完整阅读并同意《哔哩哔哩弹幕网用户使用协议》、《哔哩哔哩隐私政策》和《哔哩哔哩游戏中心用户协议》,并授权AUTO_MAA在其认定需要时以其认定合适的方法替您处理相关弹窗\n\n是否同意授权?", - self.window(), - ) - if choice.exec(): - logger.success("确认授权bilibili游戏隐私政策", module="设置界面") - MainInfoBar.push_info_bar( - "success", "操作成功", "已确认授权bilibili游戏隐私政策", 3000 - ) - else: - Config.set(Config.function_IfAgreeBilibili, False) - else: - - logger.info("取消授权bilibili游戏隐私政策", module="设置界面") - MainInfoBar.push_info_bar( - "info", "操作成功", "已取消授权bilibili游戏隐私政策", 3000 - ) - - def skip_MuMu_splash_ads(self) -> None: - """跳过MuMu启动广告""" - - MuMu_splash_ads_path = ( - Path(os.getenv("APPDATA")) / "Netease/MuMuPlayer-12.0/data/startupImage" - ) - - if Config.get(Config.function_IfSkipMumuSplashAds): - - choice = MessageBox( - "风险声明", - "开启「跳过MuMu启动广告」功能,即代表您已安装MuMu模拟器-12且允许AUTO_MAA以其认定合适的方法屏蔽MuMu启动广告,并接受此操作带来的风险\n\n此功能即时生效,是否仍要开启此功能?", - self.window(), - ) - if choice.exec(): - - if MuMu_splash_ads_path.exists() and MuMu_splash_ads_path.is_dir(): - shutil.rmtree(MuMu_splash_ads_path) - - MuMu_splash_ads_path.touch() - - logger.success("开启跳过MuMu启动广告功能", module="设置界面") - MainInfoBar.push_info_bar( - "success", "操作成功", "已开启跳过MuMu启动广告功能", 3000 - ) - else: - Config.set(Config.function_IfSkipMumuSplashAds, False) - - else: - - if MuMu_splash_ads_path.exists() and MuMu_splash_ads_path.is_file(): - MuMu_splash_ads_path.unlink() - - logger.info("关闭跳过MuMu启动广告功能", module="设置界面") - MainInfoBar.push_info_bar( - "info", "操作成功", "已关闭跳过MuMu启动广告功能", 3000 - ) - - def check_PASSWORD(self) -> None: - """检查并配置管理密钥""" - - if Config.key_path.exists(): - return None - - logger.info("未设置管理密钥,开始要求用户进行设置", module="设置界面") - - while True: - - choice = LineEditMessageBox( - self.window(), "请设置您的管理密钥", "管理密钥", "密码" - ) - if choice.exec() and choice.input.text() != "": - Crypto.get_PASSWORD(choice.input.text()) - logger.success("成功设置管理密钥", module="设置界面") - break - else: - choice = MessageBox( - "警告", - "您没有设置管理密钥,无法使用本软件,请先设置管理密钥", - self.window(), - ) - choice.cancelButton.hide() - choice.buttonLayout.insertStretch(1) - choice.exec() - - def change_PASSWORD(self) -> None: - """修改管理密钥""" - - if_change = True - - while if_change: - - choice = LineEditMessageBox( - self.window(), "请输入旧的管理密钥", "旧管理密钥", "密码" - ) - if choice.exec() and choice.input.text() != "": - - # 验证旧管理密钥 - if Crypto.check_PASSWORD(choice.input.text()): - - PASSWORD_old = choice.input.text() - # 获取新的管理密钥 - while True: - - choice = LineEditMessageBox( - self.window(), - "请输入新的管理密钥", - "新管理密钥", - "密码", - ) - if choice.exec() and choice.input.text() != "": - - # 修改管理密钥 - Crypto.change_PASSWORD(PASSWORD_old, choice.input.text()) - logger.success("成功修改管理密钥", module="设置界面") - MainInfoBar.push_info_bar( - "success", "操作成功", "管理密钥修改成功", 3000 - ) - if_change = False - break - - else: - - choice = MessageBox( - "确认", - "您没有输入新的管理密钥,是否取消修改管理密钥?", - self.window(), - ) - if choice.exec(): - if_change = False - break - - else: - choice = MessageBox("错误", "管理密钥错误", self.window()) - choice.cancelButton.hide() - choice.buttonLayout.insertStretch(1) - choice.exec() - else: - choice = MessageBox( - "确认", "您没有输入管理密钥,是否取消修改管理密钥?", self.window() - ) - if choice.exec(): - break - - def reset_PASSWORD(self) -> None: - """重置管理密钥""" - - choice = MessageBox( - "确认", - "重置管理密钥将清空所有使用管理密钥加密的数据,您确认要重置管理密钥吗?", - self.window(), - ) - if choice.exec(): - choice = LineEditMessageBox( - self.window(), - "请输入文本提示框内的验证信息", - "AUTO_MAA绝赞DeBug中!", - "明文", - ) - - if choice.exec() and choice.input.text() in [ - "AUTO_MAA绝赞DeBug中!", - "AUTO_MAA绝赞DeBug中!", - ]: - - # 获取新的管理密钥 - while True: - - choice = LineEditMessageBox( - self.window(), "请输入新的管理密钥", "新管理密钥", "密码" - ) - if choice.exec() and choice.input.text() != "": - - # 重置管理密钥 - Crypto.reset_PASSWORD(choice.input.text()) - logger.success("成功重置管理密钥", module="设置界面") - MainInfoBar.push_info_bar( - "success", "操作成功", "管理密钥重置成功", 3000 - ) - break - - else: - - choice = MessageBox( - "确认", - "您没有输入新的管理密钥,是否取消修改管理密钥?", - self.window(), - ) - if choice.exec(): - break - - else: - - MainInfoBar.push_info_bar( - "info", - "验证未通过", - "请输入「AUTO_MAA绝赞DeBug中!」后单击确认键", - 3000, - ) - - def check_update(self, if_show: bool = False, if_first: bool = False) -> None: - """ - 检查版本更新,调起更新线程 - - :param if_show: 是否显示更新信息 - :param if_first: 是否为启动时检查更新 - """ - - current_version = list(map(int, Config.VERSION.split("."))) - - # 从远程服务器获取最新版本信息 - network = Network.add_task( - mode="get", - url=f"https://mirrorchyan.com/api/resources/AUTO_MAA/latest?user_agent=AutoMaaGui¤t_version={version_text(current_version)}&cdk={Crypto.win_decryptor(Config.get(Config.update_MirrorChyanCDK))}&channel={Config.get(Config.update_UpdateType)}", - ) - network.loop.exec() - network_result = Network.get_result(network) - if network_result["status_code"] == 200: - version_info: Dict[str, Union[int, str, Dict[str, str]]] = network_result[ - "response_json" - ] - else: - - if network_result["response_json"]: - - version_info = network_result["response_json"] - - if version_info["code"] != 0: - - logger.error( - f"获取版本信息时出错:{version_info['msg']}", module="设置界面" - ) - - error_remark_dict = { - 1001: "获取版本信息的URL参数不正确", - 7001: "填入的 CDK 已过期", - 7002: "填入的 CDK 错误", - 7003: "填入的 CDK 今日下载次数已达上限", - 7004: "填入的 CDK 类型和待下载的资源不匹配", - 7005: "填入的 CDK 已被封禁", - 8001: "对应架构和系统下的资源不存在", - 8002: "错误的系统参数", - 8003: "错误的架构参数", - 8004: "错误的更新通道参数", - 1: version_info["msg"], - } - - if version_info["code"] in error_remark_dict: - MainInfoBar.push_info_bar( - "error", - "获取版本信息时出错", - error_remark_dict[version_info["code"]], - -1, - ) - else: - MainInfoBar.push_info_bar( - "error", - "获取版本信息时出错", - "意料之外的错误,请及时联系项目组以获取来自 Mirror 酱的技术支持", - -1, - ) - - return None - - logger.warning( - f"获取版本信息时出错:{network_result['error_message']}", - module="设置界面", - ) - MainInfoBar.push_info_bar( - "warning", - "获取版本信息时出错", - f"网络错误:{network_result['status_code']}", - 5000, - ) - return None - - remote_version = list( - map( - int, - version_info["data"]["version_name"][1:] - .replace("-beta", "") - .split("."), - ) - ) - - if ( - if_show - or ( - not if_show - and if_first - and not Config.get(Config.function_UnattendedMode) - ) - ) and version.parse(version_text(remote_version)) > version.parse( - version_text(current_version) - ): - - version_info_json: Dict[str, Dict[str, str]] = json.loads( - re.sub( - r"^$", - r"\1", - version_info["data"]["release_note"].splitlines()[0], - ) - ) - - # 生成版本更新信息 - main_version_info = f"## 主程序:{version_text(current_version)} --> {version_text(remote_version)}" - - update_version_info = {} - all_version_info = {} - for v_i in [ - info - for ver, info in version_info_json.items() - if version.parse(version_text(list(map(int, ver.split("."))))) - > version.parse(version_text(current_version)) - ]: - - for key, value in v_i.items(): - if key in update_version_info: - update_version_info[key] += value.copy() - else: - update_version_info[key] = value.copy() - for v_i in version_info_json.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() - - # 询问是否开始版本更新 - SoundPlayer.play("有新版本") - choice = NoticeMessageBox( - self.window(), - "版本更新", - { - "更新总览": f"{main_version_info}\n\n{version_info_markdown(update_version_info)}", - "ALL~版本信息": version_info_markdown(all_version_info), - **{ - version_text( - list(map(int, k.split("."))) - ): version_info_markdown(v) - for k, v in version_info_json.items() - }, - }, - ) - if choice.exec(): - - if "url" in version_info["data"]: - download_config = { - "mode": "MirrorChyan", - "thread_numb": 1, - "url": version_info["data"]["url"], - } - else: - - # 从远程服务器获取代理信息 - network = Network.add_task( - mode="get", - url="http://221.236.27.82:10197/d/AUTO_MAA/Server/download_info.json", - ) - network.loop.exec() - network_result = Network.get_result(network) - if network_result["status_code"] == 200: - download_info = 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 - - download_config = { - "mode": "Proxy", - "thread_numb": Config.get(Config.update_ThreadNumb), - "proxy_list": list( - set( - Config.get(Config.update_ProxyUrlList) - + download_info["proxy_list"] - ) - ), - "download_dict": download_info["download_dict"], - } - - logger.info("开始执行更新任务", module="设置界面") - - self.downloader = DownloadManager( - Config.app_path, "AUTO_MAA", remote_version, download_config - ) - self.downloader.setWindowTitle("AUTO_MAA更新器") - self.downloader.setWindowIcon( - QIcon(str(Config.app_path / "resources/icons/AUTO_MAA_Updater.ico")) - ) - self.downloader.download_accomplish.connect(self.start_setup) - self.downloader.show() - self.downloader.run() - - elif ( - if_show - or if_first - or version.parse(version_text(remote_version)) - > version.parse(version_text(current_version)) - ): - - if version.parse(version_text(remote_version)) > version.parse( - version_text(current_version) - ): - MainInfoBar.push_info_bar( - "info", - "发现新版本", - f"{version_text(current_version)} --> {version_text(remote_version)}", - 3600000, - if_force=True, - ) - SoundPlayer.play("有新版本") - else: - MainInfoBar.push_info_bar("success", "更新检查", "已是最新版本~", 3000) - SoundPlayer.play("无新版本") - - def start_setup(self) -> None: - """启动安装程序""" - - logger.info("启动安装程序", module="设置界面") - subprocess.Popen( - [ - Config.app_path / "AUTO_MAA-Setup.exe", - "/SP-", - "/SILENT", - "/NOCANCEL", - "/FORCECLOSEAPPLICATIONS", - "/LANG=Chinese", - f"/DIR={Config.app_path}", - ], - creationflags=subprocess.CREATE_NEW_PROCESS_GROUP - | subprocess.DETACHED_PROCESS - | subprocess.CREATE_NO_WINDOW, - ) - System.set_power("KillSelf") - - def show_notice(self, if_show: bool = False, if_first: bool = False) -> None: - """显示公告""" - - # 从远程服务器获取最新公告 - network = Network.add_task( - mode="get", - url="http://221.236.27.82:10197/d/AUTO_MAA/Server/notice.json", - ) - network.loop.exec() - network_result = Network.get_result(network) - if network_result["status_code"] == 200: - notice = 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/notice.json").exists(): - with (Config.app_path / "resources/notice.json").open( - mode="r", encoding="utf-8" - ) as f: - notice_local = json.load(f) - time_local = datetime.strptime(notice_local["time"], "%Y-%m-%d %H:%M") - else: - time_local = datetime.strptime("2000-01-01 00:00", "%Y-%m-%d %H:%M") - - notice["notice_dict"] = { - "ALL~公告": "\n---\n".join( - [str(_) for _ in notice["notice_dict"].values() if isinstance(_, str)] - ), - **notice["notice_dict"], - } - - if if_show or ( - if_first - and datetime.now() - > datetime.strptime(notice["time"], "%Y-%m-%d %H:%M") - > time_local - and not Config.get(Config.function_UnattendedMode) - ): - - choice = NoticeMessageBox(self.window(), "公告", notice["notice_dict"]) - choice.button_cancel.hide() - choice.button_layout.insertStretch(0, 1) - SoundPlayer.play("公告展示") - if choice.exec(): - with (Config.app_path / "resources/notice.json").open( - mode="w", encoding="utf-8" - ) as f: - json.dump(notice, f, ensure_ascii=False, indent=4) - else: - import random - - if random.random() < 0.1: - cc = NoticeMessageBox( - self.window(), - "用户守则", - { - "用户守则 - 第一版": """ -0. 用户守则的每一条都应该清晰可读、不含任何语法错误。如果发现任何一条不符合以上描述,请忽视它。 -1. AUTO_MAA 的所有版本均包含完整源代码与 LICENSE 文件,若发现此内容缺失,请立即关闭软件,并联系最近的 AUTO_MAA 开发者。 -2. AUTO_MAA 不会对您许下任何承诺,请自行保护好自己的数据,若软件运行过程中发生了数据损坏,项目组不负任何责任。 -3. AUTO_MAA 只会注册一个启动项,若发现两个 AUTO_MAA 同时自启动,请立即使用系统或杀软的 **启动项管理** 功能删除所有名为 AUTO_MAA 的启动项后重启软件。 -4. AUTO_MAA 正式版不应该包含命令行窗口,如果您看到了它,请立即关闭软件,通过 AUTO_MAA.exe 文件重新打开软件。 -5. 深色模式是危险的,但并非无法使用。 -6. 第 0 条规则不存在。如果你看到了,请忘记它,并正常使用软件 -7. **Mirror 酱** 是善良的,你只要付出小小的代价,就能得到祂的庇护。 -8. AUTO_MAA 没有实时合成语音的能力,软件所有语音都存储在本地。如果听到本地不存在的语音,立即关闭扬声器,并检查是否有未知脚本在运行。 -9. AUTO_MAA 不会在周六凌晨更新。如果收到更新提示,请忽略,不要查看更新内容,直到第二天天亮。 -10. 用户守则仅有一页""", - "--- 标记文档中止 ---": "xdfv-serfcx-jiol,m: !1 $bad food of do $5b 9630-300 $daad 100-1\n\n// 0 == o //\n\n∠( °ω°)/", - }, - ) - cc.button_cancel.hide() - cc.button_layout.insertStretch(0, 1) - cc.exec() - - elif ( - datetime.now() - > datetime.strptime(notice["time"], "%Y-%m-%d %H:%M") - > time_local - ): - - MainInfoBar.push_info_bar( - "info", "有新公告", "请前往设置界面查看公告", 3600000, if_force=True - ) - SoundPlayer.play("公告通知") - return None - - -class FunctionSettingCard(HeaderCardWidget): - - def __init__(self, parent=None): - super().__init__(parent) - self.setTitle("功能") - - self.card_HomeImageMode = ComboBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="主页背景图模式", - content="选择主页背景图的来源", - texts=["默认", "自定义", "主题图像"], - qconfig=Config, - configItem=Config.function_HomeImageMode, - parent=self, - ) - self.card_HistoryRetentionTime = ComboBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="历史记录保留时间", - content="选择历史记录的保留时间,超期自动清理", - texts=["7 天", "15 天", "30 天", "60 天", "90 天", "半年", "一年", "永久"], - qconfig=Config, - configItem=Config.function_HistoryRetentionTime, - parent=self, - ) - self.card_IfAllowSleep = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="启动时阻止系统休眠", - content="仅阻止电脑自动休眠,不会影响屏幕是否熄灭", - qconfig=Config, - configItem=Config.function_IfAllowSleep, - parent=self, - ) - self.card_IfSilence = self.SilenceSettingCard(self) - self.card_UnattendedMode = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="无人值守模式", - content="开启后AUTO_MAA不再主动弹出对话框,以免影响代理任务运行", - qconfig=Config, - configItem=Config.function_UnattendedMode, - parent=self, - ) - self.card_IfAgreeBilibili = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="托管bilibili游戏隐私政策", - content="授权AUTO_MAA同意bilibili游戏隐私政策", - qconfig=Config, - configItem=Config.function_IfAgreeBilibili, - parent=self, - ) - self.card_IfSkipMumuSplashAds = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="跳过MuMu启动广告", - content="启动MuMu模拟器时屏蔽启动广告", - qconfig=Config, - configItem=Config.function_IfSkipMumuSplashAds, - parent=self, - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_HomeImageMode) - Layout.addWidget(self.card_HistoryRetentionTime) - Layout.addWidget(self.card_IfAllowSleep) - Layout.addWidget(self.card_IfSilence) - Layout.addWidget(self.card_UnattendedMode) - Layout.addWidget(self.card_IfAgreeBilibili) - Layout.addWidget(self.card_IfSkipMumuSplashAds) - self.viewLayout.addLayout(Layout) - - class SilenceSettingCard(ExpandGroupSettingCard): - - def __init__(self, parent=None): - super().__init__( - FluentIcon.SETTING, - "静默模式", - "将各代理窗口置于后台运行,减少对前台的干扰", - parent, - ) - - self.card_IfSilence = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="静默模式", - content="是否启用静默模式", - qconfig=Config, - configItem=Config.function_IfSilence, - parent=self, - ) - self.card_BossKey = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="模拟器老板键", - content="请输入对应的模拟器老板键,请直接输入文字,多个键位之间请用「+」隔开。如:「Alt+Q」", - text="请以文字形式输入模拟器老板快捷键", - qconfig=Config, - configItem=Config.function_BossKey, - parent=self, - ) - - widget = QWidget() - Layout = QVBoxLayout(widget) - Layout.addWidget(self.card_IfSilence) - Layout.addWidget(self.card_BossKey) - self.viewLayout.setContentsMargins(0, 0, 0, 0) - self.viewLayout.setSpacing(0) - self.addGroupWidget(widget) - - -class VoiceSettingCard(HeaderCardWidget): - - def __init__(self, parent=None): - super().__init__(parent) - self.setTitle("音效") - - self.card_Enabled = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="音效开关", - content="是否启用音效", - qconfig=Config, - configItem=Config.voice_Enabled, - parent=self, - ) - self.card_Type = ComboBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="音效模式", - content="选择音效的播放模式", - texts=["简洁", "聒噪"], - qconfig=Config, - configItem=Config.voice_Type, - parent=self, - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_Enabled) - Layout.addWidget(self.card_Type) - self.viewLayout.addLayout(Layout) - - -class StartSettingCard(HeaderCardWidget): - - def __init__(self, parent=None): - super().__init__(parent) - self.setTitle("启动") - - self.card_IfSelfStart = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="开机时自动启动", - content="将AUTO_MAA添加到开机启动项", - qconfig=Config, - configItem=Config.start_IfSelfStart, - parent=self, - ) - self.card_IfMinimizeDirectly = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="启动后直接最小化", - content="启动AUTO_MAA后直接最小化", - qconfig=Config, - configItem=Config.start_IfMinimizeDirectly, - parent=self, - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_IfSelfStart) - Layout.addWidget(self.card_IfMinimizeDirectly) - self.viewLayout.addLayout(Layout) - - -class UiSettingCard(HeaderCardWidget): - - def __init__(self, parent=None): - super().__init__(parent) - self.setTitle("界面") - - self.card_IfShowTray = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="显示托盘图标", - content="常态显示托盘图标", - qconfig=Config, - configItem=Config.ui_IfShowTray, - parent=self, - ) - self.card_IfToTray = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="最小化到托盘", - content="最小化时隐藏到托盘", - qconfig=Config, - configItem=Config.ui_IfToTray, - parent=self, - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_IfShowTray) - Layout.addWidget(self.card_IfToTray) - self.viewLayout.addLayout(Layout) - - -class NotifySettingCard(HeaderCardWidget): - - def __init__(self, parent=None): - super().__init__(parent) - - self.setTitle("通知") - - self.card_NotifyContent = self.NotifyContentSettingCard(self) - self.card_Plyer = self.PlyerSettingCard(self) - self.card_EMail = self.EMailSettingCard(self) - self.card_ServerChan = self.ServerChanSettingCard(self) - self.card_CompanyWebhookBot = self.CompanyWechatPushSettingCard(self) - self.card_TestNotification = PushSettingCard( - text="发送测试通知", - icon=FluentIcon.SEND, - title="测试通知", - content="发送测试通知到所有已启用的通知渠道", - parent=self, - ) - self.card_TestNotification.clicked.connect(self.send_test_notification) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_NotifyContent) - Layout.addWidget(self.card_Plyer) - Layout.addWidget(self.card_EMail) - Layout.addWidget(self.card_ServerChan) - Layout.addWidget(self.card_CompanyWebhookBot) - Layout.addWidget(self.card_TestNotification) - self.viewLayout.addLayout(Layout) - - def send_test_notification(self): - """发送测试通知到所有已启用的通知渠道""" - if Notify.send_test_notification(): - MainInfoBar.push_info_bar( - "success", - "测试通知已发送", - "请检查已配置的通知渠道是否正常收到消息", - 3000, - ) - - class NotifyContentSettingCard(ExpandGroupSettingCard): - - def __init__(self, parent=None): - super().__init__( - FluentIcon.SETTING, "通知内容选项", "选择需要推送的通知内容", parent - ) - - self.card_SendTaskResultTime = ComboBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送任务结果选项", - content="选择推送自动代理与人工排查任务结果的时机", - texts=["不推送", "任何时刻", "仅失败时"], - qconfig=Config, - configItem=Config.notify_SendTaskResultTime, - parent=self, - ) - self.card_IfSendStatistic = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送统计信息", - content="推送自动代理统计信息的通知", - qconfig=Config, - configItem=Config.notify_IfSendStatistic, - parent=self, - ) - self.card_IfSendSixStar = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送公招高资喜报", - content="公招出现六星词条时推送喜报", - qconfig=Config, - configItem=Config.notify_IfSendSixStar, - parent=self, - ) - - widget = QWidget() - Layout = QVBoxLayout(widget) - Layout.addWidget(self.card_SendTaskResultTime) - Layout.addWidget(self.card_IfSendStatistic) - Layout.addWidget(self.card_IfSendSixStar) - self.viewLayout.setContentsMargins(0, 0, 0, 0) - self.viewLayout.setSpacing(0) - self.addGroupWidget(widget) - - class PlyerSettingCard(ExpandGroupSettingCard): - - def __init__(self, parent=None): - super().__init__( - FluentIcon.SETTING, "推送系统通知", "Plyer系统通知推送渠道", parent - ) - - self.card_IfPushPlyer = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送系统通知", - content="使用Plyer推送系统级通知,不会在通知中心停留", - qconfig=Config, - configItem=Config.notify_IfPushPlyer, - parent=self, - ) - - widget = QWidget() - Layout = QVBoxLayout(widget) - Layout.addWidget(self.card_IfPushPlyer) - self.viewLayout.setContentsMargins(0, 0, 0, 0) - self.viewLayout.setSpacing(0) - self.addGroupWidget(widget) - - class EMailSettingCard(ExpandGroupSettingCard): - - def __init__(self, parent=None): - super().__init__( - FluentIcon.SETTING, "推送邮件通知", "电子邮箱通知推送渠道", parent - ) - - self.card_IfSendMail = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送邮件通知", - content="是否启用邮件通知功能", - qconfig=Config, - configItem=Config.notify_IfSendMail, - parent=self, - ) - self.card_SMTPServerAddress = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="SMTP服务器地址", - content="发信邮箱的SMTP服务器地址", - text="请输入SMTP服务器地址", - qconfig=Config, - configItem=Config.notify_SMTPServerAddress, - parent=self, - ) - self.card_FromAddress = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="发信邮箱地址", - content="发送通知的邮箱地址", - text="请输入发信邮箱地址", - qconfig=Config, - configItem=Config.notify_FromAddress, - parent=self, - ) - self.card_AuthorizationCode = PasswordLineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="发信邮箱授权码", - content="发送通知的邮箱授权码", - text="请输入发信邮箱授权码", - algorithm="DPAPI", - qconfig=Config, - configItem=Config.notify_AuthorizationCode, - parent=self, - ) - self.card_ToAddress = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="收信邮箱地址", - content="接收通知的邮箱地址", - text="请输入收信邮箱地址", - qconfig=Config, - configItem=Config.notify_ToAddress, - parent=self, - ) - - widget = QWidget() - Layout = QVBoxLayout(widget) - Layout.addWidget(self.card_IfSendMail) - Layout.addWidget(self.card_SMTPServerAddress) - Layout.addWidget(self.card_FromAddress) - Layout.addWidget(self.card_AuthorizationCode) - Layout.addWidget(self.card_ToAddress) - self.viewLayout.setContentsMargins(0, 0, 0, 0) - self.viewLayout.setSpacing(0) - self.addGroupWidget(widget) - - class ServerChanSettingCard(ExpandGroupSettingCard): - def __init__(self, parent=None): - super().__init__( - FluentIcon.SETTING, - "推送ServerChan通知", - "ServerChan通知推送渠道", - parent, - ) - - self.card_IfServerChan = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送SeverChan通知", - content="是否启用SeverChan通知功能", - qconfig=Config, - configItem=Config.notify_IfServerChan, - parent=self, - ) - self.card_ServerChanKey = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="SendKey", - content="Server酱的SendKey(SC3与SCT都可以)", - text="请输入SendKey", - qconfig=Config, - configItem=Config.notify_ServerChanKey, - parent=self, - ) - self.card_ServerChanChannel = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="ServerChanChannel代码", - content="可以留空,留空则默认。可以多个,请使用「|」隔开", - text="请输入需要推送的Channel代码(SCT生效)", - qconfig=Config, - configItem=Config.notify_ServerChanChannel, - parent=self, - ) - self.card_ServerChanTag = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="Tag内容", - content="可以留空,留空则默认。可以多个,请使用「|」隔开", - text="请输入加入推送的Tag(SC3生效)", - qconfig=Config, - configItem=Config.notify_ServerChanTag, - parent=self, - ) - - widget = QWidget() - Layout = QVBoxLayout(widget) - Layout.addWidget(self.card_IfServerChan) - Layout.addWidget(self.card_ServerChanKey) - Layout.addWidget(self.card_ServerChanChannel) - Layout.addWidget(self.card_ServerChanTag) - self.viewLayout.setContentsMargins(0, 0, 0, 0) - self.viewLayout.setSpacing(0) - self.addGroupWidget(widget) - - class CompanyWechatPushSettingCard(ExpandGroupSettingCard): - def __init__(self, parent=None): - super().__init__( - FluentIcon.SETTING, - "推送企业微信机器人通知", - "企业微信机器人Webhook通知推送渠道", - parent, - ) - - self.card_IfCompanyWechat = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="推送企业微信机器人通知", - content="是否启用企业微信机器人通知功能", - qconfig=Config, - configItem=Config.notify_IfCompanyWebHookBot, - parent=self, - ) - self.card_CompanyWebHookBotUrl = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="WebhookUrl", - content="企业微信群机器人的Webhook地址", - text="请输入Webhook的Url", - qconfig=Config, - configItem=Config.notify_CompanyWebHookBotUrl, - parent=self, - ) - - widget = QWidget() - Layout = QVBoxLayout(widget) - Layout.addWidget(self.card_IfCompanyWechat) - Layout.addWidget(self.card_CompanyWebHookBotUrl) - self.viewLayout.setContentsMargins(0, 0, 0, 0) - self.viewLayout.setSpacing(0) - self.addGroupWidget(widget) - - -class SecuritySettingCard(HeaderCardWidget): - - def __init__(self, parent=None): - super().__init__(parent) - self.setTitle("安全") - - self.card_changePASSWORD = PushSettingCard( - text="修改", - icon=FluentIcon.VPN, - title="修改管理密钥", - content="修改用于解密用户密码的管理密钥", - parent=self, - ) - self.card_resetPASSWORD = PushSettingCard( - text="重置", - icon=FluentIcon.VPN, - title="重置管理密钥", - content="重置用于解密用户密码的管理密钥", - parent=self, - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_changePASSWORD) - Layout.addWidget(self.card_resetPASSWORD) - self.viewLayout.addLayout(Layout) - - -class UpdaterSettingCard(HeaderCardWidget): - - def __init__(self, parent=None): - super().__init__(parent) - self.setTitle("更新") - - self.card_CheckUpdate = PushSettingCard( - text="检查更新", - icon=FluentIcon.UPDATE, - title="获取最新版本", - content="检查AUTO_MAA是否有新版本", - parent=self, - ) - self.card_IfAutoUpdate = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="自动检查更新", - content="将在启动时自动检查AUTO_MAA是否有新版本", - qconfig=Config, - configItem=Config.update_IfAutoUpdate, - parent=self, - ) - self.card_UpdateType = ComboBoxSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="版本更新类别", - content="选择AUTO_MAA的更新类别", - texts=["稳定版", "公测版"], - qconfig=Config, - configItem=Config.update_UpdateType, - parent=self, - ) - self.card_ThreadNumb = RangeSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="下载器线程数", - content="更新器的下载线程数,建议仅在下载速度较慢时适量拉高", - qconfig=Config, - configItem=Config.update_ThreadNumb, - parent=self, - ) - self.card_ProxyAddress = LineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="网络代理地址", - content="使用网络代理软件时,若出现网络连接问题,请尝试设置代理地址,此设置全局生效", - text="请输入代理地址", - qconfig=Config, - configItem=Config.update_ProxyAddress, - parent=self, - ) - self.card_ProxyUrlList = UrlListSettingCard( - icon=FluentIcon.SETTING, - title="代理地址列表", - content="更新器代理地址列表", - qconfig=Config, - configItem=Config.update_ProxyUrlList, - parent=self, - ) - self.card_MirrorChyanCDK = PasswordLineEditSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="Mirror酱CDK", - content="填写后改为使用由Mirror酱提供的下载服务", - text="请输入Mirror酱CDK", - algorithm="DPAPI", - qconfig=Config, - configItem=Config.update_MirrorChyanCDK, - parent=self, - ) - mirrorchyan_url = HyperlinkButton( - "https://mirrorchyan.com/zh/get-start?source=auto_maa-setting", - "获取Mirror酱CDK", - self, - ) - self.card_MirrorChyanCDK.hBoxLayout.insertWidget( - 5, mirrorchyan_url, 0, Qt.AlignRight - ) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_CheckUpdate) - Layout.addWidget(self.card_IfAutoUpdate) - Layout.addWidget(self.card_UpdateType) - Layout.addWidget(self.card_ThreadNumb) - Layout.addWidget(self.card_ProxyAddress) - Layout.addWidget(self.card_ProxyUrlList) - Layout.addWidget(self.card_MirrorChyanCDK) - self.viewLayout.addLayout(Layout) - - -class OtherSettingCard(HeaderCardWidget): - - def __init__(self, parent=None): - super().__init__(parent) - self.setTitle("其他") - - self.card_Notice = PushSettingCard( - text="查看", - icon=FluentIcon.PAGE_RIGHT, - title="公告", - content="查看AUTO_MAA的最新公告", - parent=self, - ) - self.card_UserDocs = HyperlinkCard( - url="https://clozya.github.io/AUTOMAA_docs", - text="查看指南", - icon=FluentIcon.PAGE_RIGHT, - title="用户指南", - content="访问AUTO_MAA的官方文档站,获取使用指南和项目相关信息", - parent=self, - ) - self.card_Association = self.AssociationSettingCard(self) - - Layout = QVBoxLayout() - Layout.addWidget(self.card_Notice) - Layout.addWidget(self.card_UserDocs) - Layout.addWidget(self.card_Association) - self.viewLayout.addLayout(Layout) - - class AssociationSettingCard(ExpandGroupSettingCard): - - def __init__(self, parent=None): - super().__init__( - FluentIcon.SETTING, - "AUTO_MAA官方社群", - "加入AUTO_MAA官方社群,获取更多帮助", - parent, - ) - - self.card_GitHubRepository = HyperlinkCard( - url="https://github.com/DLmaster361/AUTO_MAA", - text="访问GitHub仓库", - icon=FluentIcon.GITHUB, - title="GitHub", - content="查看AUTO_MAA的源代码,提交问题和建议,欢迎参与开发", - parent=self, - ) - self.card_QQGroup = HyperlinkCard( - url="https://qm.qq.com/q/bd9fISNoME", - text="加入官方QQ交流群", - icon=FluentIcon.CHAT, - title="QQ群", - content="与AUTO_MAA开发者和用户交流", - parent=self, - ) - - widget = QWidget() - Layout = QVBoxLayout(widget) - Layout.addWidget(self.card_GitHubRepository) - Layout.addWidget(self.card_QQGroup) - self.viewLayout.setContentsMargins(0, 0, 0, 0) - self.viewLayout.setSpacing(0) - self.addGroupWidget(widget) - - -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\n" - for v in value: - version_info += f"- {v}\n\n" - return version_info diff --git a/app/utils/AUTO_MAA.iss b/app/utils/AUTO_MAA.iss deleted file mode 100644 index 5dec878..0000000 --- a/app/utils/AUTO_MAA.iss +++ /dev/null @@ -1,93 +0,0 @@ -; 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; diff --git a/app/utils/ImageUtils.py b/app/utils/ImageUtils.py index 2631dc9..7d49f14 100644 --- a/app/utils/ImageUtils.py +++ b/app/utils/ImageUtils.py @@ -18,12 +18,6 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA图像组件 -v4.4 -作者:ClozyA -""" import base64 import hashlib @@ -54,12 +48,10 @@ class ImageUtils: @staticmethod def compress_image_if_needed(image_path: Path, max_size_mb=2) -> Path: """ - 如果图片大于max_size_mb,则压缩并覆盖原文件,返回原始路径(Path对象) + 如果图片大于max_size_mb, 则压缩并覆盖原文件, 返回原始路径(Path对象) """ - if hasattr(Image, "Resampling"): # Pillow 9.1.0及以后 - RESAMPLE = Image.Resampling.LANCZOS - else: - RESAMPLE = Image.ANTIALIAS + + RESAMPLE = Image.Resampling.LANCZOS # Pillow 9.1.0及以后 max_size = max_size_mb * 1024 * 1024 if image_path.stat().st_size <= max_size: @@ -70,7 +62,7 @@ class ImageUtils: quality = 90 if suffix in [".jpg", ".jpeg"] else None step = 5 - if suffix in [".jpg", ".jpeg"]: + if quality is not None: while True: img.save(image_path, quality=quality, optimize=True) if image_path.stat().st_size <= max_size or quality <= 10: @@ -90,6 +82,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 diff --git a/app/utils/LogMonitor.py b/app/utils/LogMonitor.py new file mode 100644 index 0000000..91118f4 --- /dev/null +++ b/app/utils/LogMonitor.py @@ -0,0 +1,156 @@ +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 diff --git a/app/utils/ProcessManager.py b/app/utils/ProcessManager.py index a57406d..07eff3e 100644 --- a/app/utils/ProcessManager.py +++ b/app/utils/ProcessManager.py @@ -18,44 +18,36 @@ # 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(QObject): - """进程监视器类,用于跟踪主进程及其所有子进程的状态""" - - processClosed = Signal() +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() - 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: + async def open_process( + self, path: Path, args: list = [], tracking_time: int = 60 + ) -> None: """ - 启动一个新进程并返回其pid,并开始监视该进程 + 启动一个新进程并返回其pid, 并开始监视该进程 - :param path: 可执行文件的路径 - :param args: 启动参数列表 - :param tracking_time: 子进程追踪持续时间(秒) - :return: 新进程的PID + Parameters + ---------- + path: 可执行文件的路径 + args: 启动参数列表 + tracking_time: 子进程追踪持续时间(秒) """ process = subprocess.Popen( @@ -67,17 +59,17 @@ class ProcessManager(QObject): stderr=subprocess.DEVNULL, ) - self.start_monitoring(process.pid, tracking_time) + await self.start_monitoring(process.pid, tracking_time) - def start_monitoring(self, pid: int, tracking_time: int = 60) -> None: + async def start_monitoring(self, pid: int, tracking_time: int = 60) -> None: """ - 启动进程监视器,跟踪指定的主进程及其子进程 + 启动进程监视器, 跟踪指定的主进程及其子进程 :param pid: 被监视进程的PID :param tracking_time: 子进程追踪持续时间(秒) """ - self.clear() + await self.clear() self.main_pid = pid self.tracking_time = tracking_time @@ -96,16 +88,15 @@ class ProcessManager(QObject): except psutil.NoSuchProcess: pass - # 启动持续追踪机制 - self.start_time = datetime.now() - self.check_timer.start(100) + # 启动持续追踪任务 + if tracking_time > 0: + self.track_end_time = datetime.now() + timedelta(seconds=tracking_time) + self.check_task = asyncio.create_task(self.track_processes()) - def check_processes(self) -> None: - """检查跟踪的进程是否仍在运行,并更新子进程列表""" - - # 仅在时限内持续更新跟踪的进程列表,发现新的子进程 - if (datetime.now() - self.start_time).total_seconds() < self.tracking_time: + 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: @@ -116,12 +107,9 @@ class ProcessManager(QObject): self.tracked_pids.add(child.pid) except psutil.NoSuchProcess: continue + await asyncio.sleep(0.1) - if not self.is_running(): - self.clear() - self.processClosed.emit() - - def is_running(self) -> bool: + async def is_running(self) -> bool: """检查所有跟踪的进程是否还在运行""" for pid in self.tracked_pids: @@ -134,11 +122,9 @@ class ProcessManager(QObject): return False - def kill(self, if_force: bool = False) -> None: + async def kill(self, if_force: bool = False) -> None: """停止监视器并中止所有跟踪的进程""" - self.check_timer.stop() - for pid in self.tracked_pids: try: proc = psutil.Process(pid) @@ -152,13 +138,18 @@ class ProcessManager(QObject): except psutil.NoSuchProcess: continue - if self.main_pid: - self.processClosed.emit() - self.clear() + await self.clear() - def clear(self) -> None: + 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.check_timer.stop() self.tracked_pids.clear() diff --git a/app/utils/__init__.py b/app/utils/__init__.py index ead5c44..c471269 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -1,5 +1,6 @@ # AUTO_MAA:A MAA Multi Account Management and Automation Tool # Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox # This file is part of AUTO_MAA. @@ -18,18 +19,25 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA工具包 -v4.4 -作者:DLmaster_361 -""" - -__version__ = "4.2.0" +__version__ = "5.0.0" __author__ = "DLmaster361 " __license__ = "GPL-3.0 license" -from .ImageUtils import ImageUtils -from .ProcessManager import ProcessManager -__all__ = ["ImageUtils", "ProcessManager"] +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", +] diff --git a/app/utils/constants.py b/app/utils/constants.py new file mode 100644 index 0000000..1291491 --- /dev/null +++ b/app/utils/constants.py @@ -0,0 +1,214 @@ +# AUTO_MAA:A MAA Multi Account Management and Automation Tool +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 ClozyA + +# This file is part of AUTO_MAA. + +# AUTO_MAA is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published +# by the Free Software Foundation, either version 3 of the License, +# or (at your option) any later version. + +# AUTO_MAA is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with AUTO_MAA. If not, see . + +# Contact: DLmaster_361@163.com + + +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": "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": "LS-6", "text": "经验-6/5 | 常驻开放", "days": [1, 2, 3, 4, 5, 6, 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_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": "近卫/特种芯片", +} +"""掉落物索引表""" diff --git a/app/utils/logger.py b/app/utils/logger.py new file mode 100644 index 0000000..bd541f8 --- /dev/null +++ b/app/utils/logger.py @@ -0,0 +1,69 @@ +# AUTO_MAA:A MAA Multi Account Management and Automation Tool +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox + +# 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 . + +# 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="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[module]} | {message}", + enqueue=True, + backtrace=True, + diagnose=True, + rotation="1 week", + retention="1 month", + compression="zip", +) + +_logger.add( + sink=sys.stderr, + level="DEBUG", + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[module]} | {message}", + 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"] diff --git a/app/utils/package.py b/app/utils/package.py deleted file mode 100644 index 59d2d20..0000000 --- a/app/utils/package.py +++ /dev/null @@ -1,143 +0,0 @@ -# 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 . - -# 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\n{version_info_markdown(all_version_info)}", - encoding="utf-8", - ) diff --git a/app/utils/security.py b/app/utils/security.py new file mode 100644 index 0000000..6dad28a --- /dev/null +++ b/app/utils/security.py @@ -0,0 +1,69 @@ +# 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 . + +# 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") diff --git a/dev.md b/dev.md new file mode 100644 index 0000000..24348f2 --- /dev/null +++ b/dev.md @@ -0,0 +1,23 @@ + +## 创建环境 +```bash +uv venv +``` + +## 安装依赖 +```bash +uv pip install -e . +``` + + +## 添加依赖 +```bash +uv add +``` + +## 删除依赖 +```bash +uv remove +``` + +> 💡 推荐使用uv作为默认包管理器, 支持现代Python项目管理特性 \ No newline at end of file diff --git a/main.py b/main.py index f255c39..d52b73d 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ # AUTO_MAA:A MAA Multi Account Management and Automation Tool # Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox # This file is part of AUTO_MAA. @@ -18,39 +19,38 @@ # Contact: DLmaster_361@163.com -""" -AUTO_MAA -AUTO_MAA主程序 -v4.4 -作者:DLmaster_361 -""" - -# 屏蔽广告 -import builtins - -original_print = builtins.print - - -def no_print(*args, **kwargs): - if ( - args - and isinstance(args[0], str) - and "QFluentWidgets Pro is now released." in args[0] - ): - return - return original_print(*args, **kwargs) - - -builtins.print = no_print - import os import sys import ctypes -from PySide6.QtWidgets import QApplication -from qfluentwidgets import FluentTranslator +import logging +from pathlib import Path -from app.core.logger import logger +current_dir = Path(__file__).resolve().parent +if str(current_dir) not in sys.path: + sys.path.insert(0, str(current_dir)) + +from app.utils import get_logger + +logger = get_logger("主程序") + + +class InterceptHandler(logging.Handler): + def emit(self, record): + # 获取对应 loguru 的 level + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + # 转发日志 + logger.opt(depth=6, exception=record.exc_info).log(level, record.getMessage()) + + +# 拦截标准 logging +logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) +for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "fastapi"): + logging.getLogger(name).handlers = [InterceptHandler()] + logging.getLogger(name).propagate = False def is_admin() -> bool: @@ -61,28 +61,105 @@ def is_admin() -> bool: return False -@logger.catch +# @logger.catch def main(): - application = QApplication(sys.argv) - - translator = FluentTranslator() - application.installTranslator(translator) - - from app.ui.main_window import AUTO_MAA - - window = AUTO_MAA() - window.show_ui("显示主窗口", if_start=True) - window.start_up_task() - sys.exit(application.exec()) - - -if __name__ == "__main__": - if is_admin(): - main() + + import asyncio + import uvicorn + from fastapi import FastAPI + from fastapi.staticfiles import StaticFiles + from contextlib import asynccontextmanager + + @asynccontextmanager + async def lifespan(app: FastAPI): + + from app.core import Config, MainTimer, TaskManager + from app.services import System + + await Config.init_config() + await Config.get_stage(if_start=True) + await Config.clean_old_history() + main_timer = asyncio.create_task(MainTimer.second_task()) + await System.set_Sleep() + await System.set_SelfStart() + + yield + + await TaskManager.stop_task("ALL") + main_timer.cancel() + try: + await main_timer + except asyncio.CancelledError: + logger.info("主业务定时器已关闭") + + logger.info("AUTO_MAA 后端程序关闭") + + from fastapi.middleware.cors import CORSMiddleware + from app.api import ( + core_router, + info_router, + scripts_router, + plan_router, + queue_router, + dispatch_router, + history_router, + setting_router, + ) + + app = FastAPI( + title="AUTO_MAA", + description="API for managing automation scripts, plans, and tasks", + version="1.0.0", + lifespan=lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 允许所有域名跨域访问 + allow_credentials=True, + allow_methods=["*"], # 允许所有请求方法, 如 GET、POST、PUT、DELETE + allow_headers=["*"], # 允许所有请求头 + ) + + app.include_router(core_router) + app.include_router(info_router) + app.include_router(scripts_router) + app.include_router(plan_router) + app.include_router(queue_router) + app.include_router(dispatch_router) + app.include_router(history_router) + app.include_router(setting_router) + + app.mount( + "/api/res/materials", + StaticFiles(directory=str(Path.cwd() / "res/images/materials")), + name="materials", + ) + + async def run_server(): + + config = uvicorn.Config( + app, host="0.0.0.0", port=36163, log_level="info", log_config=None + ) + server = uvicorn.Server(config) + + from app.core import Config + + Config.server = server + await server.serve() + + asyncio.run(run_server()) + else: + ctypes.windll.shell32.ShellExecuteW( None, "runas", sys.executable, os.path.realpath(sys.argv[0]), None, 1 ) sys.exit(0) + + +if __name__ == "__main__": + + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9e08ffc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "AUTO_MAA" +version = "4.0.0.1" +description = "AUTO_MAA~" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "loguru==0.7.3", + "fastapi==0.116.1", + "pydantic==2.11.7", + "uvicorn==0.35.0", + "plyer==2.1.0", + "psutil==7.0.0", + "jinja2==3.1.6", + "pywin32==310", + "keyboard==0.13.5", + "truststore==0.10.1", + "requests==2.32.4", + "pillow==11.3.0", + "packaging==25.0", +] diff --git a/requirements.txt b/requirements.txt index 49ef1b2..e987640 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,15 @@ loguru==0.7.3 +fastapi==0.116.1 +pydantic==2.11.7 +uvicorn==0.35.0 +websockets==15.0.1 +aiofiles==24.1.0 plyer==2.1.0 -PySide6==6.9.1 -PySide6-Fluent-Widgets[full]==1.8.3 psutil==7.0.0 +jinja2==3.1.6 pywin32==310 keyboard==0.13.5 -pycryptodome==3.23.0 -certifi==2025.4.26 truststore==0.10.1 requests==2.32.4 -markdown==3.8.2 -Jinja2==3.1.6 -nuitka==2.7.12 pillow==11.3.0 -packaging==25.0 +packaging==25.0 \ No newline at end of file diff --git a/resources/docs/ChineseSimplified.isl b/res/docs/ChineseSimplified.isl similarity index 100% rename from resources/docs/ChineseSimplified.isl rename to res/docs/ChineseSimplified.isl diff --git a/resources/docs/MAA_config_info.txt b/res/docs/MAA_config_info.txt similarity index 100% rename from resources/docs/MAA_config_info.txt rename to res/docs/MAA_config_info.txt diff --git a/resources/html/MAA_result.html b/res/html/MAA_result.html similarity index 100% rename from resources/html/MAA_result.html rename to res/html/MAA_result.html diff --git a/resources/html/MAA_six_star.html b/res/html/MAA_six_star.html similarity index 100% rename from resources/html/MAA_six_star.html rename to res/html/MAA_six_star.html diff --git a/resources/html/MAA_statistics.html b/res/html/MAA_statistics.html similarity index 100% rename from resources/html/MAA_statistics.html rename to res/html/MAA_statistics.html diff --git a/resources/html/general_result.html b/res/html/general_result.html similarity index 100% rename from resources/html/general_result.html rename to res/html/general_result.html diff --git a/resources/html/general_statistics.html b/res/html/general_statistics.html similarity index 100% rename from resources/html/general_statistics.html rename to res/html/general_statistics.html diff --git a/resources/icons/AUTO_MAA.ico b/res/icons/AUTO_MAA.ico similarity index 100% rename from resources/icons/AUTO_MAA.ico rename to res/icons/AUTO_MAA.ico diff --git a/resources/icons/AUTO_MAA_Updater.ico b/res/icons/AUTO_MAA_Updater.ico similarity index 100% rename from resources/icons/AUTO_MAA_Updater.ico rename to res/icons/AUTO_MAA_Updater.ico diff --git a/res/icons/AUTO_MAS.ico b/res/icons/AUTO_MAS.ico new file mode 100644 index 0000000..8339571 Binary files /dev/null and b/res/icons/AUTO_MAS.ico differ diff --git a/resources/icons/MirrorChyan.ico b/res/icons/MirrorChyan.ico similarity index 100% rename from resources/icons/MirrorChyan.ico rename to res/icons/MirrorChyan.ico diff --git a/resources/images/AUTO_MAA.png b/res/images/AUTO_MAA.png similarity index 100% rename from resources/images/AUTO_MAA.png rename to res/images/AUTO_MAA.png diff --git a/resources/images/Home/BannerDefault.png b/res/images/Home/BannerDefault.png similarity index 100% rename from resources/images/Home/BannerDefault.png rename to res/images/Home/BannerDefault.png diff --git a/resources/images/README/payid.png b/res/images/README/payid.png similarity index 100% rename from resources/images/README/payid.png rename to res/images/README/payid.png diff --git a/res/images/materials/2001.png b/res/images/materials/2001.png new file mode 100644 index 0000000..7211599 Binary files /dev/null and b/res/images/materials/2001.png differ diff --git a/res/images/materials/2002.png b/res/images/materials/2002.png new file mode 100644 index 0000000..9a682dd Binary files /dev/null and b/res/images/materials/2002.png differ diff --git a/res/images/materials/2003.png b/res/images/materials/2003.png new file mode 100644 index 0000000..dce123c Binary files /dev/null and b/res/images/materials/2003.png differ diff --git a/res/images/materials/2004.png b/res/images/materials/2004.png new file mode 100644 index 0000000..c9a09bc Binary files /dev/null and b/res/images/materials/2004.png differ diff --git a/res/images/materials/30011.png b/res/images/materials/30011.png new file mode 100644 index 0000000..196997d Binary files /dev/null and b/res/images/materials/30011.png differ diff --git a/res/images/materials/30012.png b/res/images/materials/30012.png new file mode 100644 index 0000000..667f1c7 Binary files /dev/null and b/res/images/materials/30012.png differ diff --git a/res/images/materials/30013.png b/res/images/materials/30013.png new file mode 100644 index 0000000..c203fc9 Binary files /dev/null and b/res/images/materials/30013.png differ diff --git a/res/images/materials/30014.png b/res/images/materials/30014.png new file mode 100644 index 0000000..dcbef60 Binary files /dev/null and b/res/images/materials/30014.png differ diff --git a/res/images/materials/30021.png b/res/images/materials/30021.png new file mode 100644 index 0000000..9dd787e Binary files /dev/null and b/res/images/materials/30021.png differ diff --git a/res/images/materials/30022.png b/res/images/materials/30022.png new file mode 100644 index 0000000..be43280 Binary files /dev/null and b/res/images/materials/30022.png differ diff --git a/res/images/materials/30023.png b/res/images/materials/30023.png new file mode 100644 index 0000000..387992a Binary files /dev/null and b/res/images/materials/30023.png differ diff --git a/res/images/materials/30024.png b/res/images/materials/30024.png new file mode 100644 index 0000000..17fae0a Binary files /dev/null and b/res/images/materials/30024.png differ diff --git a/res/images/materials/30031.png b/res/images/materials/30031.png new file mode 100644 index 0000000..29eb94a Binary files /dev/null and b/res/images/materials/30031.png differ diff --git a/res/images/materials/30032.png b/res/images/materials/30032.png new file mode 100644 index 0000000..79d866b Binary files /dev/null and b/res/images/materials/30032.png differ diff --git a/res/images/materials/30033.png b/res/images/materials/30033.png new file mode 100644 index 0000000..3599d8d Binary files /dev/null and b/res/images/materials/30033.png differ diff --git a/res/images/materials/30034.png b/res/images/materials/30034.png new file mode 100644 index 0000000..5ccafb2 Binary files /dev/null and b/res/images/materials/30034.png differ diff --git a/res/images/materials/30041.png b/res/images/materials/30041.png new file mode 100644 index 0000000..12639b0 Binary files /dev/null and b/res/images/materials/30041.png differ diff --git a/res/images/materials/30042.png b/res/images/materials/30042.png new file mode 100644 index 0000000..d28be03 Binary files /dev/null and b/res/images/materials/30042.png differ diff --git a/res/images/materials/30043.png b/res/images/materials/30043.png new file mode 100644 index 0000000..e4ba4cc Binary files /dev/null and b/res/images/materials/30043.png differ diff --git a/res/images/materials/30044.png b/res/images/materials/30044.png new file mode 100644 index 0000000..aa06c52 Binary files /dev/null and b/res/images/materials/30044.png differ diff --git a/res/images/materials/30051.png b/res/images/materials/30051.png new file mode 100644 index 0000000..79b2d96 Binary files /dev/null and b/res/images/materials/30051.png differ diff --git a/res/images/materials/30052.png b/res/images/materials/30052.png new file mode 100644 index 0000000..3f26b14 Binary files /dev/null and b/res/images/materials/30052.png differ diff --git a/res/images/materials/30053.png b/res/images/materials/30053.png new file mode 100644 index 0000000..62026f8 Binary files /dev/null and b/res/images/materials/30053.png differ diff --git a/res/images/materials/30054.png b/res/images/materials/30054.png new file mode 100644 index 0000000..05acc92 Binary files /dev/null and b/res/images/materials/30054.png differ diff --git a/res/images/materials/30061.png b/res/images/materials/30061.png new file mode 100644 index 0000000..f1b58ce Binary files /dev/null and b/res/images/materials/30061.png differ diff --git a/res/images/materials/30062.png b/res/images/materials/30062.png new file mode 100644 index 0000000..ddc298c Binary files /dev/null and b/res/images/materials/30062.png differ diff --git a/res/images/materials/30063.png b/res/images/materials/30063.png new file mode 100644 index 0000000..16345b3 Binary files /dev/null and b/res/images/materials/30063.png differ diff --git a/res/images/materials/30064.png b/res/images/materials/30064.png new file mode 100644 index 0000000..f43def1 Binary files /dev/null and b/res/images/materials/30064.png differ diff --git a/res/images/materials/30073.png b/res/images/materials/30073.png new file mode 100644 index 0000000..64b88b6 Binary files /dev/null and b/res/images/materials/30073.png differ diff --git a/res/images/materials/30074.png b/res/images/materials/30074.png new file mode 100644 index 0000000..f5359c6 Binary files /dev/null and b/res/images/materials/30074.png differ diff --git a/res/images/materials/30083.png b/res/images/materials/30083.png new file mode 100644 index 0000000..1e2f87f Binary files /dev/null and b/res/images/materials/30083.png differ diff --git a/res/images/materials/30084.png b/res/images/materials/30084.png new file mode 100644 index 0000000..746cb96 Binary files /dev/null and b/res/images/materials/30084.png differ diff --git a/res/images/materials/30093.png b/res/images/materials/30093.png new file mode 100644 index 0000000..a68e16b Binary files /dev/null and b/res/images/materials/30093.png differ diff --git a/res/images/materials/30094.png b/res/images/materials/30094.png new file mode 100644 index 0000000..bc6c56b Binary files /dev/null and b/res/images/materials/30094.png differ diff --git a/res/images/materials/30103.png b/res/images/materials/30103.png new file mode 100644 index 0000000..de0cba6 Binary files /dev/null and b/res/images/materials/30103.png differ diff --git a/res/images/materials/30104.png b/res/images/materials/30104.png new file mode 100644 index 0000000..5fcd144 Binary files /dev/null and b/res/images/materials/30104.png differ diff --git a/res/images/materials/30115.png b/res/images/materials/30115.png new file mode 100644 index 0000000..ac065be Binary files /dev/null and b/res/images/materials/30115.png differ diff --git a/res/images/materials/30125.png b/res/images/materials/30125.png new file mode 100644 index 0000000..4502fec Binary files /dev/null and b/res/images/materials/30125.png differ diff --git a/res/images/materials/30135.png b/res/images/materials/30135.png new file mode 100644 index 0000000..f856865 Binary files /dev/null and b/res/images/materials/30135.png differ diff --git a/res/images/materials/30145.png b/res/images/materials/30145.png new file mode 100644 index 0000000..84c37b0 Binary files /dev/null and b/res/images/materials/30145.png differ diff --git a/res/images/materials/30155.png b/res/images/materials/30155.png new file mode 100644 index 0000000..27f3890 Binary files /dev/null and b/res/images/materials/30155.png differ diff --git a/res/images/materials/30165.png b/res/images/materials/30165.png new file mode 100644 index 0000000..47b38ca Binary files /dev/null and b/res/images/materials/30165.png differ diff --git a/res/images/materials/31013.png b/res/images/materials/31013.png new file mode 100644 index 0000000..7ea5d52 Binary files /dev/null and b/res/images/materials/31013.png differ diff --git a/res/images/materials/31014.png b/res/images/materials/31014.png new file mode 100644 index 0000000..9c7765b Binary files /dev/null and b/res/images/materials/31014.png differ diff --git a/res/images/materials/31023.png b/res/images/materials/31023.png new file mode 100644 index 0000000..b06e1fc Binary files /dev/null and b/res/images/materials/31023.png differ diff --git a/res/images/materials/31024.png b/res/images/materials/31024.png new file mode 100644 index 0000000..caff072 Binary files /dev/null and b/res/images/materials/31024.png differ diff --git a/res/images/materials/31033.png b/res/images/materials/31033.png new file mode 100644 index 0000000..721103f Binary files /dev/null and b/res/images/materials/31033.png differ diff --git a/res/images/materials/31034.png b/res/images/materials/31034.png new file mode 100644 index 0000000..d5990c8 Binary files /dev/null and b/res/images/materials/31034.png differ diff --git a/res/images/materials/31043.png b/res/images/materials/31043.png new file mode 100644 index 0000000..8059ac8 Binary files /dev/null and b/res/images/materials/31043.png differ diff --git a/res/images/materials/31044.png b/res/images/materials/31044.png new file mode 100644 index 0000000..e5b2bb7 Binary files /dev/null and b/res/images/materials/31044.png differ diff --git a/res/images/materials/31053.png b/res/images/materials/31053.png new file mode 100644 index 0000000..bb589f8 Binary files /dev/null and b/res/images/materials/31053.png differ diff --git a/res/images/materials/31054.png b/res/images/materials/31054.png new file mode 100644 index 0000000..29a01dc Binary files /dev/null and b/res/images/materials/31054.png differ diff --git a/res/images/materials/31063.png b/res/images/materials/31063.png new file mode 100644 index 0000000..a707b2a Binary files /dev/null and b/res/images/materials/31063.png differ diff --git a/res/images/materials/31064.png b/res/images/materials/31064.png new file mode 100644 index 0000000..33fbdc6 Binary files /dev/null and b/res/images/materials/31064.png differ diff --git a/res/images/materials/31073.png b/res/images/materials/31073.png new file mode 100644 index 0000000..f7063bf Binary files /dev/null and b/res/images/materials/31073.png differ diff --git a/res/images/materials/31074.png b/res/images/materials/31074.png new file mode 100644 index 0000000..62af816 Binary files /dev/null and b/res/images/materials/31074.png differ diff --git a/res/images/materials/31083.png b/res/images/materials/31083.png new file mode 100644 index 0000000..2e1b55f Binary files /dev/null and b/res/images/materials/31083.png differ diff --git a/res/images/materials/31084.png b/res/images/materials/31084.png new file mode 100644 index 0000000..306deab Binary files /dev/null and b/res/images/materials/31084.png differ diff --git a/res/images/materials/31093.png b/res/images/materials/31093.png new file mode 100644 index 0000000..cd8fd82 Binary files /dev/null and b/res/images/materials/31093.png differ diff --git a/res/images/materials/31094.png b/res/images/materials/31094.png new file mode 100644 index 0000000..a20b0c7 Binary files /dev/null and b/res/images/materials/31094.png differ diff --git a/res/images/materials/3112.png b/res/images/materials/3112.png new file mode 100644 index 0000000..a240b7b Binary files /dev/null and b/res/images/materials/3112.png differ diff --git a/res/images/materials/3113.png b/res/images/materials/3113.png new file mode 100644 index 0000000..4202368 Binary files /dev/null and b/res/images/materials/3113.png differ diff --git a/res/images/materials/3114.png b/res/images/materials/3114.png new file mode 100644 index 0000000..3e71f3d Binary files /dev/null and b/res/images/materials/3114.png differ diff --git a/res/images/materials/3211.png b/res/images/materials/3211.png new file mode 100644 index 0000000..c3b3150 Binary files /dev/null and b/res/images/materials/3211.png differ diff --git a/res/images/materials/3212.png b/res/images/materials/3212.png new file mode 100644 index 0000000..7618c2c Binary files /dev/null and b/res/images/materials/3212.png differ diff --git a/res/images/materials/3213.png b/res/images/materials/3213.png new file mode 100644 index 0000000..cbda9ce Binary files /dev/null and b/res/images/materials/3213.png differ diff --git a/res/images/materials/3221.png b/res/images/materials/3221.png new file mode 100644 index 0000000..0f19ea0 Binary files /dev/null and b/res/images/materials/3221.png differ diff --git a/res/images/materials/3222.png b/res/images/materials/3222.png new file mode 100644 index 0000000..1acee90 Binary files /dev/null and b/res/images/materials/3222.png differ diff --git a/res/images/materials/3223.png b/res/images/materials/3223.png new file mode 100644 index 0000000..9837951 Binary files /dev/null and b/res/images/materials/3223.png differ diff --git a/res/images/materials/3231.png b/res/images/materials/3231.png new file mode 100644 index 0000000..8ae7344 Binary files /dev/null and b/res/images/materials/3231.png differ diff --git a/res/images/materials/3232.png b/res/images/materials/3232.png new file mode 100644 index 0000000..e75f531 Binary files /dev/null and b/res/images/materials/3232.png differ diff --git a/res/images/materials/3233.png b/res/images/materials/3233.png new file mode 100644 index 0000000..b0c9f21 Binary files /dev/null and b/res/images/materials/3233.png differ diff --git a/res/images/materials/3241.png b/res/images/materials/3241.png new file mode 100644 index 0000000..32fc401 Binary files /dev/null and b/res/images/materials/3241.png differ diff --git a/res/images/materials/3242.png b/res/images/materials/3242.png new file mode 100644 index 0000000..51d60ba Binary files /dev/null and b/res/images/materials/3242.png differ diff --git a/res/images/materials/3243.png b/res/images/materials/3243.png new file mode 100644 index 0000000..09425bb Binary files /dev/null and b/res/images/materials/3243.png differ diff --git a/res/images/materials/3251.png b/res/images/materials/3251.png new file mode 100644 index 0000000..96abeb4 Binary files /dev/null and b/res/images/materials/3251.png differ diff --git a/res/images/materials/3252.png b/res/images/materials/3252.png new file mode 100644 index 0000000..cf9357b Binary files /dev/null and b/res/images/materials/3252.png differ diff --git a/res/images/materials/3253.png b/res/images/materials/3253.png new file mode 100644 index 0000000..d9a42f8 Binary files /dev/null and b/res/images/materials/3253.png differ diff --git a/res/images/materials/3261.png b/res/images/materials/3261.png new file mode 100644 index 0000000..409c1a6 Binary files /dev/null and b/res/images/materials/3261.png differ diff --git a/res/images/materials/3262.png b/res/images/materials/3262.png new file mode 100644 index 0000000..a298d23 Binary files /dev/null and b/res/images/materials/3262.png differ diff --git a/res/images/materials/3263.png b/res/images/materials/3263.png new file mode 100644 index 0000000..cc9a8d9 Binary files /dev/null and b/res/images/materials/3263.png differ diff --git a/res/images/materials/3271.png b/res/images/materials/3271.png new file mode 100644 index 0000000..8de0cc7 Binary files /dev/null and b/res/images/materials/3271.png differ diff --git a/res/images/materials/3272.png b/res/images/materials/3272.png new file mode 100644 index 0000000..5556cd2 Binary files /dev/null and b/res/images/materials/3272.png differ diff --git a/res/images/materials/3273.png b/res/images/materials/3273.png new file mode 100644 index 0000000..7e700d8 Binary files /dev/null and b/res/images/materials/3273.png differ diff --git a/res/images/materials/3281.png b/res/images/materials/3281.png new file mode 100644 index 0000000..5207057 Binary files /dev/null and b/res/images/materials/3281.png differ diff --git a/res/images/materials/3282.png b/res/images/materials/3282.png new file mode 100644 index 0000000..0bd6cbe Binary files /dev/null and b/res/images/materials/3282.png differ diff --git a/res/images/materials/3283.png b/res/images/materials/3283.png new file mode 100644 index 0000000..6a1e43c Binary files /dev/null and b/res/images/materials/3283.png differ diff --git a/res/images/materials/3301.png b/res/images/materials/3301.png new file mode 100644 index 0000000..6977bc4 Binary files /dev/null and b/res/images/materials/3301.png differ diff --git a/res/images/materials/3302.png b/res/images/materials/3302.png new file mode 100644 index 0000000..48ca15b Binary files /dev/null and b/res/images/materials/3302.png differ diff --git a/res/images/materials/3303.png b/res/images/materials/3303.png new file mode 100644 index 0000000..c132cbf Binary files /dev/null and b/res/images/materials/3303.png differ diff --git a/res/images/materials/4001.png b/res/images/materials/4001.png new file mode 100644 index 0000000..2f7691a Binary files /dev/null and b/res/images/materials/4001.png differ diff --git a/res/images/materials/4006.png b/res/images/materials/4006.png new file mode 100644 index 0000000..cb0238a Binary files /dev/null and b/res/images/materials/4006.png differ diff --git a/res/images/materials/PR-A.png b/res/images/materials/PR-A.png new file mode 100644 index 0000000..1367a59 Binary files /dev/null and b/res/images/materials/PR-A.png differ diff --git a/res/images/materials/PR-B.png b/res/images/materials/PR-B.png new file mode 100644 index 0000000..46b9995 Binary files /dev/null and b/res/images/materials/PR-B.png differ diff --git a/res/images/materials/PR-C.png b/res/images/materials/PR-C.png new file mode 100644 index 0000000..9b4bb3d Binary files /dev/null and b/res/images/materials/PR-C.png differ diff --git a/res/images/materials/PR-D.png b/res/images/materials/PR-D.png new file mode 100644 index 0000000..a175026 Binary files /dev/null and b/res/images/materials/PR-D.png differ diff --git a/resources/images/notification/six_star.png b/res/images/notification/six_star.png similarity index 100% rename from resources/images/notification/six_star.png rename to res/images/notification/six_star.png diff --git a/resources/images/notification/test_notify.png b/res/images/notification/test_notify.png similarity index 100% rename from resources/images/notification/test_notify.png rename to res/images/notification/test_notify.png diff --git a/resources/sounds/both/删除用户.wav b/res/sounds/both/删除用户.wav similarity index 100% rename from resources/sounds/both/删除用户.wav rename to res/sounds/both/删除用户.wav diff --git a/resources/sounds/both/删除脚本实例.wav b/res/sounds/both/删除脚本实例.wav similarity index 100% rename from resources/sounds/both/删除脚本实例.wav rename to res/sounds/both/删除脚本实例.wav diff --git a/resources/sounds/both/删除计划表.wav b/res/sounds/both/删除计划表.wav similarity index 100% rename from resources/sounds/both/删除计划表.wav rename to res/sounds/both/删除计划表.wav diff --git a/resources/sounds/both/删除调度队列.wav b/res/sounds/both/删除调度队列.wav similarity index 100% rename from resources/sounds/both/删除调度队列.wav rename to res/sounds/both/删除调度队列.wav diff --git a/resources/sounds/both/欢迎回来.wav b/res/sounds/both/欢迎回来.wav similarity index 100% rename from resources/sounds/both/欢迎回来.wav rename to res/sounds/both/欢迎回来.wav diff --git a/resources/sounds/both/添加用户.wav b/res/sounds/both/添加用户.wav similarity index 100% rename from resources/sounds/both/添加用户.wav rename to res/sounds/both/添加用户.wav diff --git a/resources/sounds/both/添加脚本实例.wav b/res/sounds/both/添加脚本实例.wav similarity index 100% rename from resources/sounds/both/添加脚本实例.wav rename to res/sounds/both/添加脚本实例.wav diff --git a/resources/sounds/both/添加计划表.wav b/res/sounds/both/添加计划表.wav similarity index 100% rename from resources/sounds/both/添加计划表.wav rename to res/sounds/both/添加计划表.wav diff --git a/resources/sounds/both/添加调度队列.wav b/res/sounds/both/添加调度队列.wav similarity index 100% rename from resources/sounds/both/添加调度队列.wav rename to res/sounds/both/添加调度队列.wav diff --git a/resources/sounds/noisy/ADB失败.wav b/res/sounds/noisy/ADB失败.wav similarity index 100% rename from resources/sounds/noisy/ADB失败.wav rename to res/sounds/noisy/ADB失败.wav diff --git a/resources/sounds/noisy/ADB成功.wav b/res/sounds/noisy/ADB成功.wav similarity index 100% rename from resources/sounds/noisy/ADB成功.wav rename to res/sounds/noisy/ADB成功.wav diff --git a/resources/sounds/noisy/MAA在完成任务前中止.wav b/res/sounds/noisy/MAA在完成任务前中止.wav similarity index 100% rename from resources/sounds/noisy/MAA在完成任务前中止.wav rename to res/sounds/noisy/MAA在完成任务前中止.wav diff --git a/resources/sounds/noisy/MAA在完成任务前退出.wav b/res/sounds/noisy/MAA在完成任务前退出.wav similarity index 100% rename from resources/sounds/noisy/MAA在完成任务前退出.wav rename to res/sounds/noisy/MAA在完成任务前退出.wav diff --git a/resources/sounds/noisy/MAA更新.wav b/res/sounds/noisy/MAA更新.wav similarity index 100% rename from resources/sounds/noisy/MAA更新.wav rename to res/sounds/noisy/MAA更新.wav diff --git a/resources/sounds/noisy/MAA未检测到任何模拟器.wav b/res/sounds/noisy/MAA未检测到任何模拟器.wav similarity index 100% rename from resources/sounds/noisy/MAA未检测到任何模拟器.wav rename to res/sounds/noisy/MAA未检测到任何模拟器.wav diff --git a/resources/sounds/noisy/MAA未能正确登录PRTS.wav b/res/sounds/noisy/MAA未能正确登录PRTS.wav similarity index 100% rename from resources/sounds/noisy/MAA未能正确登录PRTS.wav rename to res/sounds/noisy/MAA未能正确登录PRTS.wav diff --git a/resources/sounds/noisy/MAA的ADB连接异常.wav b/res/sounds/noisy/MAA的ADB连接异常.wav similarity index 100% rename from resources/sounds/noisy/MAA的ADB连接异常.wav rename to res/sounds/noisy/MAA的ADB连接异常.wav diff --git a/resources/sounds/noisy/MAA进程超时.wav b/res/sounds/noisy/MAA进程超时.wav similarity index 100% rename from resources/sounds/noisy/MAA进程超时.wav rename to res/sounds/noisy/MAA进程超时.wav diff --git a/resources/sounds/noisy/MAA部分任务执行失败.wav b/res/sounds/noisy/MAA部分任务执行失败.wav similarity index 100% rename from resources/sounds/noisy/MAA部分任务执行失败.wav rename to res/sounds/noisy/MAA部分任务执行失败.wav diff --git a/resources/sounds/noisy/任务开始.wav b/res/sounds/noisy/任务开始.wav similarity index 100% rename from resources/sounds/noisy/任务开始.wav rename to res/sounds/noisy/任务开始.wav diff --git a/resources/sounds/noisy/任务结束.wav b/res/sounds/noisy/任务结束.wav similarity index 100% rename from resources/sounds/noisy/任务结束.wav rename to res/sounds/noisy/任务结束.wav diff --git a/resources/sounds/noisy/公告展示.wav b/res/sounds/noisy/公告展示.wav similarity index 100% rename from resources/sounds/noisy/公告展示.wav rename to res/sounds/noisy/公告展示.wav diff --git a/resources/sounds/noisy/公告通知.wav b/res/sounds/noisy/公告通知.wav similarity index 100% rename from resources/sounds/noisy/公告通知.wav rename to res/sounds/noisy/公告通知.wav diff --git a/resources/sounds/noisy/六星喜报.wav b/res/sounds/noisy/六星喜报.wav similarity index 100% rename from resources/sounds/noisy/六星喜报.wav rename to res/sounds/noisy/六星喜报.wav diff --git a/resources/sounds/noisy/历史记录查询.wav b/res/sounds/noisy/历史记录查询.wav similarity index 100% rename from resources/sounds/noisy/历史记录查询.wav rename to res/sounds/noisy/历史记录查询.wav diff --git a/resources/sounds/noisy/发生异常.wav b/res/sounds/noisy/发生异常.wav similarity index 100% rename from resources/sounds/noisy/发生异常.wav rename to res/sounds/noisy/发生异常.wav diff --git a/resources/sounds/noisy/发生错误.wav b/res/sounds/noisy/发生错误.wav similarity index 100% rename from resources/sounds/noisy/发生错误.wav rename to res/sounds/noisy/发生错误.wav diff --git a/resources/sounds/noisy/子任务失败.wav b/res/sounds/noisy/子任务失败.wav similarity index 100% rename from resources/sounds/noisy/子任务失败.wav rename to res/sounds/noisy/子任务失败.wav diff --git a/resources/sounds/noisy/排查录入.wav b/res/sounds/noisy/排查录入.wav similarity index 100% rename from resources/sounds/noisy/排查录入.wav rename to res/sounds/noisy/排查录入.wav diff --git a/resources/sounds/noisy/排查重试.wav b/res/sounds/noisy/排查重试.wav similarity index 100% rename from resources/sounds/noisy/排查重试.wav rename to res/sounds/noisy/排查重试.wav diff --git a/resources/sounds/noisy/无新版本.wav b/res/sounds/noisy/无新版本.wav similarity index 100% rename from resources/sounds/noisy/无新版本.wav rename to res/sounds/noisy/无新版本.wav diff --git a/resources/sounds/noisy/有新版本.wav b/res/sounds/noisy/有新版本.wav similarity index 100% rename from resources/sounds/noisy/有新版本.wav rename to res/sounds/noisy/有新版本.wav diff --git a/resources/sounds/noisy/森空岛签到失败.wav b/res/sounds/noisy/森空岛签到失败.wav similarity index 100% rename from resources/sounds/noisy/森空岛签到失败.wav rename to res/sounds/noisy/森空岛签到失败.wav diff --git a/resources/sounds/noisy/森空岛签到成功.wav b/res/sounds/noisy/森空岛签到成功.wav similarity index 100% rename from resources/sounds/noisy/森空岛签到成功.wav rename to res/sounds/noisy/森空岛签到成功.wav diff --git a/resources/sounds/simple/任务开始.wav b/res/sounds/simple/任务开始.wav similarity index 100% rename from resources/sounds/simple/任务开始.wav rename to res/sounds/simple/任务开始.wav diff --git a/resources/sounds/simple/任务结束.wav b/res/sounds/simple/任务结束.wav similarity index 100% rename from resources/sounds/simple/任务结束.wav rename to res/sounds/simple/任务结束.wav diff --git a/resources/sounds/simple/公告展示.wav b/res/sounds/simple/公告展示.wav similarity index 100% rename from resources/sounds/simple/公告展示.wav rename to res/sounds/simple/公告展示.wav diff --git a/resources/sounds/simple/公告通知.wav b/res/sounds/simple/公告通知.wav similarity index 100% rename from resources/sounds/simple/公告通知.wav rename to res/sounds/simple/公告通知.wav diff --git a/resources/sounds/simple/历史记录查询.wav b/res/sounds/simple/历史记录查询.wav similarity index 100% rename from resources/sounds/simple/历史记录查询.wav rename to res/sounds/simple/历史记录查询.wav diff --git a/resources/sounds/simple/发生异常.wav b/res/sounds/simple/发生异常.wav similarity index 100% rename from resources/sounds/simple/发生异常.wav rename to res/sounds/simple/发生异常.wav diff --git a/resources/sounds/simple/发生错误.wav b/res/sounds/simple/发生错误.wav similarity index 100% rename from resources/sounds/simple/发生错误.wav rename to res/sounds/simple/发生错误.wav diff --git a/resources/sounds/simple/无新版本.wav b/res/sounds/simple/无新版本.wav similarity index 100% rename from resources/sounds/simple/无新版本.wav rename to res/sounds/simple/无新版本.wav diff --git a/resources/sounds/simple/有新版本.wav b/res/sounds/simple/有新版本.wav similarity index 100% rename from resources/sounds/simple/有新版本.wav rename to res/sounds/simple/有新版本.wav diff --git a/resources/version.json b/res/version.json similarity index 92% rename from resources/version.json rename to res/version.json index 3d6eb9a..189c808 100644 --- a/resources/version.json +++ b/res/version.json @@ -18,7 +18,7 @@ ], "程序优化": [ "优化调度队列配置逻辑", - "优化静默进程标记逻辑,避免未能及时移除导致相关功能持续开启", + "优化静默进程标记逻辑, 避免未能及时移除导致相关功能持续开启", "MAA 代理时更新改为强制开启", "移除 MAA 详细配置模式中的剿灭项" ] @@ -54,7 +54,7 @@ "修复模拟器界面被异常关闭且无法重新打开的问题" ], "程序优化": [ - "重构日志记录,载入更多日志记录项", + "重构日志记录, 载入更多日志记录项", "优化日志监看启停逻辑", "SpinBox和TimeEdit组件忽视滚轮事件" ] diff --git a/updater.py b/updater.py new file mode 100644 index 0000000..1904ea9 --- /dev/null +++ b/updater.py @@ -0,0 +1,379 @@ +# 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 . + +# Contact: DLmaster_361@163.com + + +import re +import sys +import time +import json +import psutil +import base64 +import zipfile +import requests +import argparse +import truststore +import subprocess +import win32crypt + +from packaging import version +from pathlib import Path +from typing import List, Dict + +current_dir = Path(__file__).resolve().parent +if str(current_dir) not in sys.path: + sys.path.insert(0, str(current_dir)) + + +MIRROR_ERROR_INFO = { + 1001: "获取版本信息的URL参数不正确", + 7001: "填入的 CDK 已过期", + 7002: "填入的 CDK 错误", + 7003: "填入的 CDK 今日下载次数已达上限", + 7004: "填入的 CDK 类型和待下载的资源不匹配", + 7005: "填入的 CDK 已被封禁", + 8001: "对应架构和系统下的资源不存在", + 8002: "错误的系统参数", + 8003: "错误的架构参数", + 8004: "错误的更新通道参数", + 1: "未知错误类型", +} + + +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") + + +def kill_process(path: Path) -> None: + """ + 根据路径中止进程 + + :param path: 进程路径 + """ + + print(f"开始中止进程: {path}") + + for pid in search_pids(path): + killprocess = subprocess.Popen( + f"taskkill /F /T /PID {pid}", + shell=True, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + killprocess.wait() + + print(f"进程已中止: {path}") + + +def search_pids(path: Path) -> list: + """ + 根据路径查找进程PID + + :param path: 进程路径 + :return: 匹配的进程PID列表 + """ + + print(f"开始查找进程 PID: {path}") + + pids = [] + for proc in psutil.process_iter(["pid", "exe"]): + try: + if proc.info["exe"] and proc.info["exe"].lower() == str(path).lower(): + pids.append(proc.info["pid"]) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + # 进程可能在此期间已结束或无法访问, 忽略这些异常 + pass + return pids + + +truststore.inject_into_ssl() +parser = argparse.ArgumentParser( + prog="AUTO-MAS更新器", description="为AUTO-MAS前端提供更新服务" +) +parser.add_argument( + "--version", "-v", type=str, required=False, default=None, help="前端程序版本号" +) +args = parser.parse_args() + +if (Path.cwd() / "config/Config.json").exists(): + config = json.loads( + (Path.cwd() / "config/Config.json").read_text(encoding="utf-8") + ).get( + "Update", + { + "MirrorChyanCDK": "", + "ProxyAddress": "", + "Source": "GitHub", + "UpdateType": "stable", + }, + ) +else: + config = { + "MirrorChyanCDK": "", + "ProxyAddress": "", + "Source": "GitHub", + "UpdateType": "stable", + } + +if ( + config.get("Source", "GitHub") == "MirrorChyan" + and dpapi_decrypt(config.get("MirrorChyanCDK", "")) == "" +): + print("使用 MirrorChyan源但未填写 MirrorChyanCDK, 转用 GitHub 源") + config["Source"] = "GitHub" + config["MirrorChyanCDK"] = "" + +print(f"当前配置: {config}") + + +download_source = config.get("Source", "GitHub") +proxies = { + "http": config.get("ProxyAddress", ""), + "https": config.get("ProxyAddress", ""), +} + +if args.version: + current_version = args.version +else: + current_version = "v0.0.0" + +print(f"当前版本: {current_version}") + + +response = requests.get( + f"https://mirrorchyan.com/api/resources/AUTO_MAA/latest?user_agent=AutoMaaGui¤t_version={current_version}&cdk={dpapi_decrypt(config.get('MirrorChyanCDK', ''))}&channel={config.get('UpdateType', 'stable')}", + timeout=10, + proxies=proxies, +) +if response.status_code == 200: + version_info = response.json() +else: + try: + result = response.json() + + if result["code"] != 0: + if result["code"] in MIRROR_ERROR_INFO: + print(f"获取版本信息时出错: {MIRROR_ERROR_INFO[result['code']]}") + else: + print( + "获取版本信息时出错: 意料之外的错误, 请及时联系项目组以获取来自 Mirror 酱的技术支持" + ) + print(f" {result['msg']}") + sys.exit(1) + except Exception: + print(f"获取版本信息时出错: {response.text}") + + sys.exit(1) + + +remote_version = version_info["data"]["version_name"] + +if version.parse(remote_version) > version.parse(current_version): + + # 版本更新信息 + print(f"发现新版本: {remote_version}, 当前版本: {current_version}") + + version_info_json: Dict[str, Dict[str, List[str]]] = json.loads( + re.sub( + r"^$", + r"\1", + version_info["data"]["release_note"].splitlines()[0], + ) + ) + + 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 update_version_info: + update_version_info[key] = [] + update_version_info[key] += value + + for key, value in update_version_info.items(): + print(f"{key}: ") + for v in value: + print(f" - {v}") + + if download_source == "GitHub": + + download_url = f"https://github.com/DLmaster361/AUTO_MAA/releases/download/{remote_version}/AUTO_MAA_{remote_version}.zip" + + elif download_source == "MirrorChyan": + if "url" in version_info["data"]: + with requests.get( + version_info["data"]["url"], + allow_redirects=True, + timeout=10, + stream=True, + proxies=proxies, + ) as response: + if response.status_code == 200: + download_url = response.url + else: + print(f"MirrorChyan 未返回下载链接, 使用自建下载站") + download_url = f"https://download.auto-mas.top/d/AUTO_MAA/AUTO_MAA_{remote_version}.zip" + + elif download_source == "AutoSite": + download_url = ( + f"https://download.auto-mas.top/d/AUTO_MAA/AUTO_MAA_{remote_version}.zip" + ) + + else: + print(f"未知的下载源: {download_source}, 请检查配置文件") + sys.exit(1) + + print(f"开始下载: {download_url}") + + # 清理可能存在的临时文件 + if (Path.cwd() / "download.temp").exists(): + (Path.cwd() / "download.temp").unlink() + + check_times = 3 + while check_times != 0: + + try: + + start_time = time.time() + + response = requests.get( + download_url, timeout=10, stream=True, proxies=proxies + ) + + if response.status_code not in [200, 206]: + + if check_times != -1: + check_times -= 1 + + print( + f"连接失败: {download_url}, 状态码: {response.status_code}, 剩余重试次数: {check_times}", + ) + + time.sleep(1) + continue + + print(f"连接成功: {download_url}, 状态码: {response.status_code}") + + file_size = int(response.headers.get("content-length", 0)) + downloaded_size = 0 + last_download_size = 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() + + if speed >= 1024: + print( + f"正在下载: AUTO-MAS 已下载: {downloaded_size / 1048576:.2f}/{file_size / 1048576:.2f} MB ({downloaded_size / file_size * 100:.2f}%) 下载速度: {speed / 1024:.2f} MB/s", + ) + else: + print( + f"正在下载: AUTO-MAS 已下载: {downloaded_size / 1048576:.2f}/{file_size / 1048576:.2f} MB ({downloaded_size / file_size * 100:.2f}%) 下载速度: {speed:.2f} KB/s", + ) + + print( + f"下载完成: {download_url}, 实际下载大小: {downloaded_size} 字节, 耗时: {time.time() - start_time:.2f} 秒", + ) + + break + + except Exception as e: + + if check_times != -1: + check_times -= 1 + + print( + f"下载出错: {download_url}, 错误信息: {e}, 剩余重试次数: {check_times}", + ) + time.sleep(1) + + else: + + if (Path.cwd() / "download.temp").exists(): + (Path.cwd() / "download.temp").unlink() + print(f"下载失败: {download_url}") + sys.exit(1) + + print(f"开始解压: {Path.cwd() / 'download.temp'} 到 {Path.cwd()}") + + while True: + + try: + with zipfile.ZipFile(Path.cwd() / "download.temp", "r") as zip_ref: + zip_ref.extractall(Path.cwd()) + print(f"解压完成: {Path.cwd() / 'download.temp'} 到 {Path.cwd()}") + break + except PermissionError: + print(f"解压出错: AUTO_MAA正在运行, 正在尝试将其关闭") + kill_process(Path.cwd() / "AUTO_MAA.exe") + time.sleep(1) + + print("正在删除临时文件") + if (Path.cwd() / "changes.json").exists(): + (Path.cwd() / "changes.json").unlink() + if (Path.cwd() / "download.temp").exists(): + (Path.cwd() / "download.temp").unlink() + + print("正在启动AUTO_MAA") + subprocess.Popen( + [Path.cwd() / "AUTO_MAA.exe"], + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.DETACHED_PROCESS + | subprocess.CREATE_NO_WINDOW, + ) + + print("更新完成") + sys.exit(0) + +else: + + print(f"当前版本为最新版本: {current_version}") + sys.exit(0)