diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index dfef137..a2da0b6 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -51,6 +51,18 @@ jobs: - 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: @@ -59,16 +71,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest pip install -r requirements.txt - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Get version id: get_version run: | @@ -122,6 +126,8 @@ jobs: $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/" diff --git a/Go_Updater/Makefile b/Go_Updater/Makefile new file mode 100644 index 0000000..34bacca --- /dev/null +++ b/Go_Updater/Makefile @@ -0,0 +1,116 @@ +# AUTO_MAA_Go_Updater Makefile + +# Build variables +VERSION ?= 1.0.0 +BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +OUTPUT_NAME := AUTO_MAA_Go_Updater +BUILD_DIR := build +DIST_DIR := dist + +# Go build flags +LDFLAGS := -s -w -X AUTO_MAA_Go_Updater/version.Version=$(VERSION) -X AUTO_MAA_Go_Updater/version.BuildTime=$(BUILD_TIME) -X AUTO_MAA_Go_Updater/version.GitCommit=$(GIT_COMMIT) + +# Default target +.PHONY: all +all: clean build + +# Clean build artifacts +.PHONY: clean +clean: + @echo "Cleaning build artifacts..." + @rm -rf $(BUILD_DIR) $(DIST_DIR) + @mkdir -p $(BUILD_DIR) $(DIST_DIR) + +# Build for Windows 64-bit +.PHONY: build +build: clean + @echo "=========================================" + @echo "Building AUTO_MAA_Go_Updater" + @echo "=========================================" + @echo "Version: $(VERSION)" + @echo "Build Time: $(BUILD_TIME)" + @echo "Git Commit: $(GIT_COMMIT)" + @echo "Target: Windows 64-bit" + @echo "" + @echo "Building application..." + @GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(OUTPUT_NAME).exe . + @echo "Build completed successfully!" + @echo "" + @echo "Build Results:" + @ls -lh $(BUILD_DIR)/$(OUTPUT_NAME).exe + @cp $(BUILD_DIR)/$(OUTPUT_NAME).exe $(DIST_DIR)/$(OUTPUT_NAME).exe + @echo "Copied to: $(DIST_DIR)/$(OUTPUT_NAME).exe" + +# Build with UPX compression +.PHONY: build-compressed +build-compressed: build + @echo "" + @echo "Compressing with UPX..." + @if command -v upx >/dev/null 2>&1; then \ + upx --best $(BUILD_DIR)/$(OUTPUT_NAME).exe; \ + echo "Compression completed!"; \ + ls -lh $(BUILD_DIR)/$(OUTPUT_NAME).exe; \ + cp $(BUILD_DIR)/$(OUTPUT_NAME).exe $(DIST_DIR)/$(OUTPUT_NAME).exe; \ + else \ + echo "UPX not found. Skipping compression."; \ + fi + +# Run tests +.PHONY: test +test: + @echo "Running tests..." + @go test -v ./... + +# Run with version flag +.PHONY: version +version: build + @echo "" + @echo "Testing version information:" + @$(BUILD_DIR)/$(OUTPUT_NAME).exe -version + +# Install dependencies +.PHONY: deps +deps: + @echo "Installing dependencies..." + @go mod tidy + @go mod download + +# Format code +.PHONY: fmt +fmt: + @echo "Formatting code..." + @go fmt ./... + +# Lint code +.PHONY: lint +lint: + @echo "Linting code..." + @if command -v golangci-lint >/dev/null 2>&1; then \ + golangci-lint run; \ + else \ + echo "golangci-lint not found. Install it with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + fi + +# Development build (faster, no optimizations) +.PHONY: dev +dev: + @echo "Building development version..." + @go build -o $(BUILD_DIR)/$(OUTPUT_NAME)-dev.exe . + @echo "Development build completed: $(BUILD_DIR)/$(OUTPUT_NAME)-dev.exe" + +# Help +.PHONY: help +help: + @echo "Available targets:" + @echo " all - Clean and build (default)" + @echo " build - Build for Windows 64-bit" + @echo " build-compressed - Build and compress with UPX" + @echo " clean - Clean build artifacts" + @echo " test - Run tests" + @echo " version - Build and show version" + @echo " deps - Install dependencies" + @echo " fmt - Format code" + @echo " lint - Lint code" + @echo " dev - Development build" + @echo " help - Show this help" \ No newline at end of file diff --git a/Go_Updater/README.MD b/Go_Updater/README.MD new file mode 100644 index 0000000..b5832d6 --- /dev/null +++ b/Go_Updater/README.MD @@ -0,0 +1,15 @@ +# 用Go语言实现的一个AUTO_MAA下载器 +用于直接下载AUTO_MAA软件本体,在Python版本出现问题时使用。 + +## 使用方法 +1. 下载并安装Go语言环境(需要配置环境变量) +2. 运行 `go mod tidy` 命令,安装依赖包。 +3. 运行 `go run main.go` 命令,程序会自动下载并安装AUTO_MAA软件。 + +## 构建 +运行 `.\build.ps1` 脚本即可完成构建。 + +参数说明: +-Version:指定要构建的版本号 + +运行命令: `.\build.ps1 -Version "1.0.8"` \ No newline at end of file diff --git a/Go_Updater/api/client.go b/Go_Updater/api/client.go new file mode 100644 index 0000000..ef28e82 --- /dev/null +++ b/Go_Updater/api/client.go @@ -0,0 +1,290 @@ +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 new file mode 100644 index 0000000..bdb9f72 --- /dev/null +++ b/Go_Updater/api/client_test.go @@ -0,0 +1,186 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewClient(t *testing.T) { + client := NewClient() + if client == nil { + t.Fatal("NewClient() 返回 nil") + } + if client.httpClient == nil { + t.Fatal("HTTP 客户端为 nil") + } + if client.baseURL != "https://mirrorchyan.com/api/resources" { + t.Errorf("期望基础 URL 'https://mirrorchyan.com/api/resources',得到 '%s'", client.baseURL) + } + if client.downloadURL != "http://221.236.27.82:10197/d/AUTO_MAA" { + t.Errorf("期望下载 URL 'http://221.236.27.82:10197/d/AUTO_MAA',得到 '%s'", client.downloadURL) + } +} + +func TestGetDownloadURL(t *testing.T) { + client := NewClient() + + tests := []struct { + versionName string + expected string + }{ + {"v4.4.0", "http://221.236.27.82:10197/d/AUTO_MAA/AUTO_MAA_v4.4.0.zip"}, + {"v4.4.1-beta3", "http://221.236.27.82:10197/d/AUTO_MAA/AUTO_MAA_v4.4.1-beta.3.zip"}, + {"v1.2.3", "http://221.236.27.82:10197/d/AUTO_MAA/AUTO_MAA_v1.2.3.zip"}, + } + + for _, test := range tests { + result := client.GetDownloadURL(test.versionName) + if result != test.expected { + t.Errorf("版本 %s,期望 %s,得到 %s", test.versionName, test.expected, result) + } + } +} + +func TestCheckUpdate(t *testing.T) { + // 创建测试服务器 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := MirrorResponse{ + Code: 0, + Msg: "success", + Data: struct { + VersionName string `json:"version_name"` + VersionNumber int `json:"version_number"` + URL string `json:"url,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Channel string `json:"channel"` + OS string `json:"os"` + Arch string `json:"arch"` + UpdateType string `json:"update_type,omitempty"` + ReleaseNote string `json:"release_note"` + FileSize int64 `json:"filesize,omitempty"` + }{ + VersionName: "v4.4.1", + VersionNumber: 48, + Channel: "stable", + ReleaseNote: "测试发布说明", + }, + } + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(response) + if err != nil { + return + } + })) + defer server.Close() + + // 使用测试服务器 URL 创建客户端 + client := &Client{ + httpClient: &http.Client{}, + baseURL: server.URL, + downloadURL: "http://221.236.27.82:10197/d/AUTO_MAA", + } + + // 测试更新检查 + params := UpdateCheckParams{ + ResourceID: "AUTO_MAA", + CurrentVersion: "4.4.0.0", + Channel: "stable", + UserAgent: "TestAgent/1.0", + } + + response, err := client.CheckUpdate(params) + if err != nil { + t.Fatalf("CheckUpdate 失败: %v", err) + } + + if response.Code != 0 { + t.Errorf("期望代码 0,得到 %d", response.Code) + } + if response.Data.VersionName != "v4.4.1" { + t.Errorf("期望版本 v4.4.1,得到 %s", response.Data.VersionName) + } +} + +func TestIsUpdateAvailable(t *testing.T) { + client := NewClient() + + tests := []struct { + name string + response *MirrorResponse + currentVersion string + expected bool + }{ + { + name: "有可用更新", + response: &MirrorResponse{ + Code: 0, + Data: struct { + VersionName string `json:"version_name"` + VersionNumber int `json:"version_number"` + URL string `json:"url,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Channel string `json:"channel"` + OS string `json:"os"` + Arch string `json:"arch"` + UpdateType string `json:"update_type,omitempty"` + ReleaseNote string `json:"release_note"` + FileSize int64 `json:"filesize,omitempty"` + }{VersionName: "v4.4.1"}, + }, + currentVersion: "4.4.0.0", + expected: true, + }, + { + name: "无可用更新", + response: &MirrorResponse{ + Code: 0, + Data: struct { + VersionName string `json:"version_name"` + VersionNumber int `json:"version_number"` + URL string `json:"url,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Channel string `json:"channel"` + OS string `json:"os"` + Arch string `json:"arch"` + UpdateType string `json:"update_type,omitempty"` + ReleaseNote string `json:"release_note"` + FileSize int64 `json:"filesize,omitempty"` + }{VersionName: "v4.4.0"}, + }, + currentVersion: "4.4.0.0", + expected: false, + }, + { + name: "beta版本有更新", + response: &MirrorResponse{ + Code: 0, + Data: struct { + VersionName string `json:"version_name"` + VersionNumber int `json:"version_number"` + URL string `json:"url,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Channel string `json:"channel"` + OS string `json:"os"` + Arch string `json:"arch"` + UpdateType string `json:"update_type,omitempty"` + ReleaseNote string `json:"release_note"` + FileSize int64 `json:"filesize,omitempty"` + }{VersionName: "v4.4.1-beta.4"}, + }, + currentVersion: "4.4.1.3", + expected: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := client.IsUpdateAvailable(test.response, test.currentVersion) + if result != test.expected { + t.Errorf("期望 %t,得到 %t", test.expected, result) + } + }) + } +} diff --git a/Go_Updater/app.rc b/Go_Updater/app.rc new file mode 100644 index 0000000..7cc1f3b --- /dev/null +++ b/Go_Updater/app.rc @@ -0,0 +1,34 @@ +#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 new file mode 100644 index 0000000..6bf5237 Binary files /dev/null and b/Go_Updater/app.syso differ diff --git a/Go_Updater/assets/assets.go b/Go_Updater/assets/assets.go new file mode 100644 index 0000000..557ccaf --- /dev/null +++ b/Go_Updater/assets/assets.go @@ -0,0 +1,34 @@ +package assets + +import ( + "embed" + "io/fs" +) + +//go:embed config_template.yaml +var EmbeddedAssets embed.FS + +// GetConfigTemplate 返回嵌入的配置模板 +func GetConfigTemplate() ([]byte, error) { + return EmbeddedAssets.ReadFile("config_template.yaml") +} + +// GetAssetFS 返回嵌入的文件系统 +func GetAssetFS() fs.FS { + return EmbeddedAssets +} + +// ListAssets 返回所有嵌入资源的列表 +func ListAssets() ([]string, error) { + var assets []string + err := fs.WalkDir(EmbeddedAssets, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + assets = append(assets, path) + } + return nil + }) + return assets, err +} diff --git a/Go_Updater/assets/assets_test.go b/Go_Updater/assets/assets_test.go new file mode 100644 index 0000000..0bcab5a --- /dev/null +++ b/Go_Updater/assets/assets_test.go @@ -0,0 +1,100 @@ +package assets + +import ( + "testing" +) + +func TestGetConfigTemplate(t *testing.T) { + data, err := GetConfigTemplate() + if err != nil { + t.Fatalf("Failed to get config template: %v", err) + } + + if len(data) == 0 { + t.Fatal("Config template is empty") + } + + // Check that it contains expected content + content := string(data) + if !contains(content, "resource_id") { + t.Error("Config template should contain 'resource_id'") + } + + if !contains(content, "current_version") { + t.Error("Config template should contain 'current_version'") + } + + if !contains(content, "user_agent") { + t.Error("Config template should contain 'user_agent'") + } +} + +func TestListAssets(t *testing.T) { + assets, err := ListAssets() + if err != nil { + t.Fatalf("Failed to list assets: %v", err) + } + + if len(assets) == 0 { + t.Fatal("No assets found") + } + + // Check that config template is in the list + found := false + for _, asset := range assets { + if asset == "config_template.yaml" { + found = true + break + } + } + + if !found { + t.Error("config_template.yaml should be in the assets list") + } +} + +func TestGetAssetFS(t *testing.T) { + fs := GetAssetFS() + if fs == nil { + t.Fatal("Asset filesystem should not be nil") + } + + // Try to open the config template + file, err := fs.Open("config_template.yaml") + if err != nil { + t.Fatalf("Failed to open config template from filesystem: %v", err) + } + defer file.Close() + + // Check that we can read from it + buffer := make([]byte, 100) + n, err := file.Read(buffer) + if err != nil && err.Error() != "EOF" { + t.Fatalf("Failed to read from config template: %v", err) + } + + if n == 0 { + t.Fatal("Config template appears to be empty") + } +} + +// Helper function to check if string contains substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > len(substr) && (s[:len(substr)] == substr || + s[len(s)-len(substr):] == substr || + containsAt(s, substr, 1)))) +} + +func containsAt(s, substr string, start int) bool { + if start >= len(s) { + return false + } + if start+len(substr) > len(s) { + return containsAt(s, substr, start+1) + } + if s[start:start+len(substr)] == substr { + return true + } + return containsAt(s, substr, start+1) +} diff --git a/Go_Updater/assets/config_template.yaml b/Go_Updater/assets/config_template.yaml new file mode 100644 index 0000000..bac2b03 --- /dev/null +++ b/Go_Updater/assets/config_template.yaml @@ -0,0 +1,7 @@ +resource_id: "AUTO_MAA" +current_version: "v1.0.0" +user_agent: "AUTO_MAA_Go_Updater/1.0" +backup_url: "https://backup-download-site.com/releases" +log_level: "info" +auto_check: true +check_interval: 3600 # seconds \ No newline at end of file diff --git a/Go_Updater/build-config.yaml b/Go_Updater/build-config.yaml new file mode 100644 index 0000000..a15503e --- /dev/null +++ b/Go_Updater/build-config.yaml @@ -0,0 +1,55 @@ +# Build Configuration for AUTO_MAA_Go_Updater + +project: + name: "AUTO_MAA_Go_Updater" + module: "AUTO_MAA_Go_Updater" + description: "AUTO_MAA_Go版本更新器" + +version: + default: "1.0.0" + build_time_format: "2006-01-02T15:04:05Z" + +targets: + - name: "windows-amd64" + goos: "windows" + goarch: "amd64" + cgo_enabled: true + output: "AUTO_MAA_Go_Updater.exe" + +build: + flags: + ldflags: "-s -w" + tags: [] + + optimization: + strip_debug: true + strip_symbols: true + upx_compression: false # Optional, requires UPX + + size_requirements: + max_size_mb: 10 + warn_size_mb: 8 + +assets: + embed: + - "assets/config_template.yaml" + +directories: + build: "build" + dist: "dist" + temp: "temp" + +version_injection: + package: "AUTO_MAA_Go_Updater/version" + variables: + - name: "Version" + source: "version" + - name: "BuildTime" + source: "build_time" + - name: "GitCommit" + source: "git_commit" + +quality: + run_tests: true + run_lint: false # Optional + format_code: true \ No newline at end of file diff --git a/Go_Updater/build.bat b/Go_Updater/build.bat new file mode 100644 index 0000000..f71fee2 --- /dev/null +++ b/Go_Updater/build.bat @@ -0,0 +1,93 @@ +@echo off +setlocal enabledelayedexpansion + +echo ======================================== +echo AUTO_MAA_Go_Updater Build Script +echo ======================================== + +:: Set build variables +set OUTPUT_NAME=AUTO_MAA_Go_Updater.exe +set BUILD_DIR=build +set DIST_DIR=dist + +:: Get current datetime for build time +for /f "tokens=2 delims==" %%a in ('wmic OS Get localdatetime /value') do set "dt=%%a" +set "YYYY=%dt:~0,4%" & set "MM=%dt:~4,2%" & set "DD=%dt:~6,2%" +set "HH=%dt:~8,2%" & set "Min=%dt:~10,2%" & set "Sec=%dt:~12,2%" +set "BUILD_TIME=%YYYY%-%MM%-%DD%T%HH%:%Min%:%Sec%Z" + +:: Get git commit hash (if available) +git rev-parse --short HEAD > temp_commit.txt 2>nul +if exist temp_commit.txt ( + set /p GIT_COMMIT=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 new file mode 100644 index 0000000..b4bff73 --- /dev/null +++ b/Go_Updater/build.ps1 @@ -0,0 +1,105 @@ +# AUTO_MAA_Go_Updater Build Script (PowerShell) +param( + [string]$OutputName = "AUTO_MAA_Go_Updater.exe", + [switch]$Compress = $false +) + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "AUTO_MAA_Go_Updater Build Script" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan + +# Set build variables +$BuildDir = "build" +$DistDir = "dist" +$BuildTime = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ") + + +# Get git commit hash +try { + $GitCommit = (git rev-parse --short HEAD 2>$null).Trim() + if (-not $GitCommit) { $GitCommit = "unknown" } +} catch { + $GitCommit = "unknown" +} + +Write-Host "Build Information:" -ForegroundColor Yellow +Write-Host "- Version: $GitCommit" +Write-Host "- Build Time: $BuildTime" +Write-Host "- Git Commit: $GitCommit" +Write-Host "- Target: Windows 64-bit" +Write-Host "" + +# Create build directories +if (-not (Test-Path $BuildDir)) { New-Item -ItemType Directory -Path $BuildDir | Out-Null } +if (-not (Test-Path $DistDir)) { New-Item -ItemType Directory -Path $DistDir | Out-Null } + +# Set environment variables +$env:GOOS = "windows" +$env:GOARCH = "amd64" +$env:CGO_ENABLED = "1" + +# Set build flags +$LdFlags = "-s -w -X AUTO_MAA_Go_Updater/version.Version=$Version -X AUTO_MAA_Go_Updater/version.BuildTime=$BuildTime -X AUTO_MAA_Go_Updater/version.GitCommit=$GitCommit" + +Write-Host "Building application..." -ForegroundColor Green + +# Ensure icon resource is compiled +if (-not (Test-Path "app.syso")) { + Write-Host "Compiling icon resource..." -ForegroundColor Yellow + if (Get-Command rsrc -ErrorAction SilentlyContinue) { + rsrc -ico icon/AUTO_MAA_Go_Updater.ico -o app.syso + if ($LASTEXITCODE -ne 0) { + Write-Host "Warning: Failed to compile icon resource" -ForegroundColor Yellow + } else { + Write-Host "Icon resource compiled successfully" -ForegroundColor Green + } + } else { + Write-Host "Warning: rsrc not found. Install with: go install github.com/akavel/rsrc@latest" -ForegroundColor Yellow + } +} + +# Build the application +$BuildCommand = "go build -ldflags=`"$LdFlags`" -o $BuildDir\$OutputName ." +Invoke-Expression $BuildCommand + +if ($LASTEXITCODE -ne 0) { + Write-Host "Build failed!" -ForegroundColor Red + exit 1 +} + +Write-Host "Build completed successfully!" -ForegroundColor Green + +# Get file information +$OutputFile = Get-Item "$BuildDir\$OutputName" +$FileSizeMB = [math]::Round($OutputFile.Length / 1MB, 2) + +Write-Host "" +Write-Host "Build Results:" -ForegroundColor Yellow +Write-Host "- Output: $($OutputFile.FullName)" +Write-Host "- Size: $($OutputFile.Length) bytes (~$FileSizeMB MB)" + + +# Optional UPX compression +if ($Compress) { + Write-Host "" + Write-Host "Compressing with UPX..." -ForegroundColor Yellow + + if (Get-Command upx -ErrorAction SilentlyContinue) { + upx --best "$BuildDir\$OutputName" + + $CompressedFile = Get-Item "$BuildDir\$OutputName" + $CompressedSizeMB = [math]::Round($CompressedFile.Length / 1MB, 2) + + Write-Host "- Compressed Size: $($CompressedFile.Length) bytes (~$CompressedSizeMB MB)" -ForegroundColor Green + } else { + Write-Host "UPX not found. Skipping compression." -ForegroundColor Yellow + } +} + +# Copy to dist directory +Copy-Item "$BuildDir\$OutputName" "$DistDir\$OutputName" -Force +Write-Host "- Copied to: $DistDir\$OutputName" + +Write-Host "" +Write-Host "Build script completed successfully!" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan \ No newline at end of file diff --git a/Go_Updater/config/config.go b/Go_Updater/config/config.go new file mode 100644 index 0000000..ded8ea0 --- /dev/null +++ b/Go_Updater/config/config.go @@ -0,0 +1,198 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "AUTO_MAA_Go_Updater/assets" + "gopkg.in/yaml.v3" +) + +// Config 表示应用程序配置 +type Config struct { + ResourceID string `yaml:"resource_id"` + CurrentVersion string `yaml:"current_version"` + UserAgent string `yaml:"user_agent"` + BackupURL string `yaml:"backup_url"` + LogLevel string `yaml:"log_level"` + AutoCheck bool `yaml:"auto_check"` + CheckInterval int `yaml:"check_interval"` // 秒 +} + +// ConfigManager 定义配置管理的接口方法 +type ConfigManager interface { + Load() (*Config, error) + Save(config *Config) error + GetConfigPath() string +} + +// DefaultConfigManager 实现 ConfigManager 接口 +type DefaultConfigManager struct { + configPath string +} + +// NewConfigManager 创建新的配置管理器 +func NewConfigManager() ConfigManager { + configDir := getConfigDir() + configPath := filepath.Join(configDir, "config.yaml") + return &DefaultConfigManager{ + configPath: configPath, + } +} + +// GetConfigPath 返回配置文件的路径 +func (cm *DefaultConfigManager) GetConfigPath() string { + return cm.configPath +} + +// Load 读取并解析配置文件 +func (cm *DefaultConfigManager) Load() (*Config, error) { + // 如果配置目录不存在则创建 + configDir := filepath.Dir(cm.configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return nil, fmt.Errorf("创建配置目录失败: %w", err) + } + + // 如果配置文件不存在,创建默认配置 + if _, err := os.Stat(cm.configPath); os.IsNotExist(err) { + defaultConfig := getDefaultConfig() + if err := cm.Save(defaultConfig); err != nil { + return nil, fmt.Errorf("创建默认配置失败: %w", err) + } + return defaultConfig, nil + } + + // 读取现有配置文件 + data, err := os.ReadFile(cm.configPath) + if err != nil { + return nil, fmt.Errorf("读取配置文件失败: %w", err) + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("解析配置文件失败: %w", err) + } + + // 验证并应用缺失字段的默认值 + if err := validateAndApplyDefaults(&config); err != nil { + return nil, fmt.Errorf("配置验证失败: %w", err) + } + + return &config, nil +} + +// Save 将配置写入文件 +func (cm *DefaultConfigManager) Save(config *Config) error { + // 保存前验证配置 + if err := validateConfig(config); err != nil { + return fmt.Errorf("配置验证失败: %w", err) + } + + // 如果配置目录不存在则创建 + configDir := filepath.Dir(cm.configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("创建配置目录失败: %w", err) + } + + // 将配置序列化为 YAML + data, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("序列化配置失败: %w", err) + } + + // 写入文件 + if err := os.WriteFile(cm.configPath, data, 0644); err != nil { + return fmt.Errorf("写入配置文件失败: %w", err) + } + + return nil +} + +// getDefaultConfig 返回带有默认值的配置 +func getDefaultConfig() *Config { + // 首先尝试从嵌入模板加载 + if templateData, err := assets.GetConfigTemplate(); err == nil { + var config Config + if err := yaml.Unmarshal(templateData, &config); err == nil { + return &config + } + } + + // 如果模板加载失败则回退到硬编码默认值 + return &Config{ + ResourceID: "M9A", // 默认资源 ID + CurrentVersion: "v1.0.0", + UserAgent: "AUTO_MAA_Go_Updater/1.0", + BackupURL: "", + LogLevel: "info", + AutoCheck: true, + CheckInterval: 3600, // 1 小时 + } +} + +// validateConfig 验证配置值 +func validateConfig(config *Config) error { + if config == nil { + return fmt.Errorf("配置不能为空") + } + + if config.ResourceID == "" { + return fmt.Errorf("resource_id 不能为空") + } + + if config.CurrentVersion == "" { + return fmt.Errorf("current_version 不能为空") + } + + if config.UserAgent == "" { + return fmt.Errorf("user_agent 不能为空") + } + + validLogLevels := map[string]bool{ + "debug": true, + "info": true, + "warn": true, + "error": true, + } + if !validLogLevels[config.LogLevel] { + return fmt.Errorf("无效的 log_level: %s (必须是 debug, info, warn 或 error)", config.LogLevel) + } + + if config.CheckInterval < 60 { + return fmt.Errorf("check_interval 必须至少为 60 秒") + } + + return nil +} + +// validateAndApplyDefaults 验证配置并为缺失字段应用默认值 +func validateAndApplyDefaults(config *Config) error { + defaults := getDefaultConfig() + + // 为空字段应用默认值 + if config.UserAgent == "" { + config.UserAgent = defaults.UserAgent + } + if config.LogLevel == "" { + config.LogLevel = defaults.LogLevel + } + if config.CheckInterval == 0 { + config.CheckInterval = defaults.CheckInterval + } + if config.CurrentVersion == "" { + config.CurrentVersion = defaults.CurrentVersion + } + + // 应用默认值后进行验证 + return validateConfig(config) +} + +// getConfigDir 返回配置目录路径 +func getConfigDir() string { + // 在 Windows 上使用 APPDATA,回退到当前目录 + if appData := os.Getenv("APPDATA"); appData != "" { + return filepath.Join(appData, "AUTO_MAA_Go_Updater") + } + return "." +} diff --git a/Go_Updater/config/config.json b/Go_Updater/config/config.json new file mode 100644 index 0000000..2a13ef4 --- /dev/null +++ b/Go_Updater/config/config.json @@ -0,0 +1,55 @@ +{ + "Function": { + "BossKey": "", + "HistoryRetentionTime": 0, + "HomeImageMode": "默认", + "IfAgreeBilibili": true, + "IfAllowSleep": false, + "IfSilence": false, + "IfSkipMumuSplashAds": false, + "UnattendedMode": false + }, + "Notify": { + "AuthorizationCode": "", + "CompanyWebHookBotUrl": "", + "FromAddress": "", + "IfCompanyWebHookBot": false, + "IfPushPlyer": false, + "IfSendMail": false, + "IfSendSixStar": false, + "IfSendStatistic": false, + "IfServerChan": false, + "SMTPServerAddress": "", + "SendTaskResultTime": "不推送", + "ServerChanChannel": "", + "ServerChanKey": "", + "ServerChanTag": "", + "ToAddress": "" + }, + "Start": { + "IfMinimizeDirectly": false, + "IfRunDirectly": false, + "IfSelfStart": false + }, + "QFluentWidgets": { + "ThemeColor": "#ff009faa", + "ThemeMode": "Dark" + }, + "UI": { + "IfShowTray": false, + "IfToTray": false, + "location": "100x100", + "maximized": false, + "size": "1200x700" + }, + "Update": { + "IfAutoUpdate": false, + "ProxyUrlList": [], + "ThreadNumb": 8, + "UpdateType": "stable" + }, + "Voice": { + "Enabled": false, + "Type": "simple" + } +} \ No newline at end of file diff --git a/Go_Updater/config/config_test.go b/Go_Updater/config/config_test.go new file mode 100644 index 0000000..36c6f26 --- /dev/null +++ b/Go_Updater/config/config_test.go @@ -0,0 +1,153 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConfigManagerLoadSave(t *testing.T) { + // 为测试创建临时目录 + tempDir := t.TempDir() + + // 使用临时路径创建配置管理器 + cm := &DefaultConfigManager{ + configPath: filepath.Join(tempDir, "test-config.yaml"), + } + + // 测试加载不存在的配置(应创建默认配置) + config, err := cm.Load() + if err != nil { + t.Errorf("加载配置失败: %v", err) + } + + if config == nil { + t.Errorf("配置不应为 nil") + } + + // 验证默认值 + if config.CurrentVersion != "v1.0.0" { + t.Errorf("期望默认版本 v1.0.0,得到 %s", config.CurrentVersion) + } + + if config.UserAgent != "AUTO_MAA_Go_Updater/1.0" { + t.Errorf("期望默认用户代理,得到 %s", config.UserAgent) + } + + // 设置一些值 + config.ResourceID = "TEST123" + + // 保存配置 + err = cm.Save(config) + if err != nil { + t.Errorf("保存配置失败: %v", err) + } + + // 再次加载配置 + loadedConfig, err := cm.Load() + if err != nil { + t.Errorf("加载已保存配置失败: %v", err) + } + + // 验证值 + if loadedConfig.ResourceID != "TEST123" { + t.Errorf("期望 ResourceID TEST123,得到 %s", loadedConfig.ResourceID) + } +} + +func TestConfigValidation(t *testing.T) { + tests := []struct { + name string + config *Config + expectError bool + }{ + { + name: "空配置", + config: nil, + expectError: true, + }, + { + name: "空 ResourceID", + config: &Config{ + ResourceID: "", + CurrentVersion: "v1.0.0", + UserAgent: "Test/1.0", + LogLevel: "info", + CheckInterval: 3600, + }, + expectError: true, + }, + { + name: "有效配置", + config: &Config{ + ResourceID: "TEST", + CurrentVersion: "v1.0.0", + UserAgent: "Test/1.0", + LogLevel: "info", + CheckInterval: 3600, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateConfig(tt.config) + if tt.expectError && err == nil { + t.Errorf("期望错误但没有得到") + } + if !tt.expectError && err != nil { + t.Errorf("期望无错误但得到: %v", err) + } + }) + } +} + +func TestGetDefaultConfig(t *testing.T) { + config := getDefaultConfig() + + if config == nil { + t.Fatal("getDefaultConfig() 返回 nil") + } + + // 验证默认值 + if config.ResourceID != "AUTO_MAA" { + t.Errorf("期望 ResourceID 'AUTO_MAA',得到 %s", config.ResourceID) + } + if config.CurrentVersion != "v1.0.0" { + t.Errorf("期望 CurrentVersion 'v1.0.0',得到 %s", config.CurrentVersion) + } + if config.UserAgent != "AUTO_MAA_Go_Updater/1.0" { + t.Errorf("期望 UserAgent 'AUTO_MAA_Go_Updater/1.0',得到 %s", config.UserAgent) + } + if config.LogLevel != "info" { + t.Errorf("期望 LogLevel 'info',得到 %s", config.LogLevel) + } + if config.CheckInterval != 3600 { + t.Errorf("期望 CheckInterval 3600,得到 %d", config.CheckInterval) + } + if !config.AutoCheck { + t.Errorf("期望 AutoCheck true,得到 %v", config.AutoCheck) + } +} + +func TestGetConfigDir(t *testing.T) { + // 保存原始 APPDATA + originalAppData := os.Getenv("APPDATA") + defer os.Setenv("APPDATA", originalAppData) + + // 测试设置了 APPDATA + os.Setenv("APPDATA", "C:\\Users\\Test\\AppData\\Roaming") + dir := getConfigDir() + expected := "C:\\Users\\Test\\AppData\\Roaming\\AUTO_MAA_Go_Updater" + if dir != expected { + t.Errorf("期望 %s,得到 %s", expected, dir) + } + + // 测试没有 APPDATA + os.Unsetenv("APPDATA") + dir = getConfigDir() + if dir != "." { + t.Errorf("期望当前目录,得到 %s", dir) + } +} diff --git a/Go_Updater/download/manager.go b/Go_Updater/download/manager.go new file mode 100644 index 0000000..07cfd5d --- /dev/null +++ b/Go_Updater/download/manager.go @@ -0,0 +1,224 @@ +package download + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "time" +) + +// DownloadProgress 表示当前下载进度 +type DownloadProgress struct { + BytesDownloaded int64 + TotalBytes int64 + Percentage float64 + Speed int64 // 每秒字节数 +} + +// ProgressCallback 在下载过程中调用以报告进度 +type ProgressCallback func(DownloadProgress) + +// DownloadManager 定义下载操作的接口 +type DownloadManager interface { + Download(url, destination string, progressCallback ProgressCallback) error + DownloadWithResume(url, destination string, progressCallback ProgressCallback) error + ValidateChecksum(filePath, expectedChecksum string) error + SetTimeout(timeout time.Duration) +} + +// Manager 实现 DownloadManager 接口 +type Manager struct { + client *http.Client + timeout time.Duration +} + +// NewManager 创建新的下载管理器 +func NewManager() *Manager { + return &Manager{ + client: &http.Client{ + Timeout: 30 * time.Second, + }, + timeout: 30 * time.Second, + } +} + +// Download 从给定 URL 下载文件到目标路径 +func (m *Manager) Download(url, destination string, progressCallback ProgressCallback) error { + return m.downloadWithContext(context.Background(), url, destination, progressCallback, false) +} + +// DownloadWithResume 下载文件并支持断点续传 +func (m *Manager) DownloadWithResume(url, destination string, progressCallback ProgressCallback) error { + return m.downloadWithContext(context.Background(), url, destination, progressCallback, true) +} + +// downloadWithContext 执行实际的下载并支持上下文 +func (m *Manager) downloadWithContext(ctx context.Context, url, destination string, progressCallback ProgressCallback, resume bool) error { + // 如果目标目录不存在则创建 + if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + return fmt.Errorf("创建目标目录失败: %w", err) + } + + // 检查文件是否存在以支持断点续传 + var existingSize int64 + if resume { + if stat, err := os.Stat(destination); err == nil { + existingSize = stat.Size() + } + } + + // 创建 HTTP 请求 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return fmt.Errorf("创建请求失败: %w", err) + } + + // 为断点续传添加范围头 + if resume && existingSize > 0 { + req.Header.Set("Range", fmt.Sprintf("bytes=%d-", existingSize)) + } + + // 执行请求 + resp, err := m.client.Do(req) + if err != nil { + return fmt.Errorf("执行请求失败: %w", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { + return fmt.Errorf("意外的状态码: %d", resp.StatusCode) + } + + // 获取总大小 + totalSize := existingSize + if contentLength := resp.Header.Get("Content-Length"); contentLength != "" { + if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil { + totalSize += size + } + } + + // 打开目标文件 + var file *os.File + if resume && existingSize > 0 { + file, err = os.OpenFile(destination, os.O_WRONLY|os.O_APPEND, 0644) + } else { + file, err = os.Create(destination) + existingSize = 0 + } + if err != nil { + return fmt.Errorf("创建目标文件失败: %w", err) + } + defer file.Close() + + // 下载并跟踪进度 + return m.copyWithProgress(resp.Body, file, existingSize, totalSize, progressCallback) +} + +// copyWithProgress 复制数据并跟踪进度 +func (m *Manager) copyWithProgress(src io.Reader, dst io.Writer, startBytes, totalBytes int64, progressCallback ProgressCallback) error { + buffer := make([]byte, 32*1024) // 32KB 缓冲区 + downloaded := startBytes + startTime := time.Now() + lastUpdate := startTime + + for { + n, err := src.Read(buffer) + if n > 0 { + if _, writeErr := dst.Write(buffer[:n]); writeErr != nil { + return fmt.Errorf("写入目标失败: %w", writeErr) + } + downloaded += int64(n) + + // 每 100ms 更新一次进度 + now := time.Now() + if progressCallback != nil && now.Sub(lastUpdate) >= 100*time.Millisecond { + elapsed := now.Sub(startTime).Seconds() + speed := int64(0) + if elapsed > 0 { + speed = int64(float64(downloaded-startBytes) / elapsed) + } + + percentage := float64(0) + if totalBytes > 0 { + percentage = float64(downloaded) / float64(totalBytes) * 100 + } + + progressCallback(DownloadProgress{ + BytesDownloaded: downloaded, + TotalBytes: totalBytes, + Percentage: percentage, + Speed: speed, + }) + lastUpdate = now + } + } + + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("从源读取失败: %w", err) + } + } + + // 最终进度更新 + if progressCallback != nil { + elapsed := time.Since(startTime).Seconds() + speed := int64(0) + if elapsed > 0 { + speed = int64(float64(downloaded-startBytes) / elapsed) + } + + percentage := float64(100) + if totalBytes > 0 { + percentage = float64(downloaded) / float64(totalBytes) * 100 + } + + progressCallback(DownloadProgress{ + BytesDownloaded: downloaded, + TotalBytes: totalBytes, + Percentage: percentage, + Speed: speed, + }) + } + + return nil +} + +// ValidateChecksum 验证文件的 SHA256 校验和 +func (m *Manager) ValidateChecksum(filePath, expectedChecksum string) error { + if expectedChecksum == "" { + return nil // 没有校验和需要验证 + } + + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("打开文件进行校验和验证失败: %w", err) + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return fmt.Errorf("计算校验和失败: %w", err) + } + + actualChecksum := hex.EncodeToString(hash.Sum(nil)) + if actualChecksum != expectedChecksum { + return fmt.Errorf("校验和不匹配: 期望 %s,得到 %s", expectedChecksum, actualChecksum) + } + + return nil +} + +// SetTimeout 设置下载操作的超时时间 +func (m *Manager) SetTimeout(timeout time.Duration) { + m.timeout = timeout + m.client.Timeout = timeout +} \ No newline at end of file diff --git a/Go_Updater/download/manager_test.go b/Go_Updater/download/manager_test.go new file mode 100644 index 0000000..f406e10 --- /dev/null +++ b/Go_Updater/download/manager_test.go @@ -0,0 +1,1392 @@ +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 new file mode 100644 index 0000000..f6a7742 --- /dev/null +++ b/Go_Updater/errors/errors.go @@ -0,0 +1,219 @@ +package errors + +import ( + "fmt" + "time" +) + +// ErrorType 定义错误类型枚举 +type ErrorType int + +const ( + NetworkError ErrorType = iota + APIError + FileError + ConfigError + InstallError +) + +// String 返回错误类型的字符串表示 +func (et ErrorType) String() string { + switch et { + case NetworkError: + return "NetworkError" + case APIError: + return "APIError" + case FileError: + return "FileError" + case ConfigError: + return "ConfigError" + case InstallError: + return "InstallError" + default: + return "UnknownError" + } +} + +// UpdaterError 统一的错误结构体 +type UpdaterError struct { + Type ErrorType + Message string + Cause error + Timestamp time.Time + Context map[string]interface{} +} + +// Error 实现error接口 +func (ue *UpdaterError) Error() string { + if ue.Cause != nil { + return fmt.Sprintf("[%s] %s: %v", ue.Type, ue.Message, ue.Cause) + } + return fmt.Sprintf("[%s] %s", ue.Type, ue.Message) +} + +// Unwrap 支持错误链 +func (ue *UpdaterError) Unwrap() error { + return ue.Cause +} + +// NewUpdaterError 创建新的UpdaterError +func NewUpdaterError(errorType ErrorType, message string, cause error) *UpdaterError { + return &UpdaterError{ + Type: errorType, + Message: message, + Cause: cause, + Timestamp: time.Now(), + Context: make(map[string]interface{}), + } +} + +// WithContext 添加上下文信息 +func (ue *UpdaterError) WithContext(key string, value interface{}) *UpdaterError { + ue.Context[key] = value + return ue +} + +// GetUserFriendlyMessage 获取用户友好的错误消息 +func (ue *UpdaterError) GetUserFriendlyMessage() string { + switch ue.Type { + case NetworkError: + return "网络连接失败,请检查网络连接后重试" + case APIError: + return "服务器响应异常,请稍后重试或联系技术支持" + case FileError: + return "文件操作失败,请检查文件权限和磁盘空间" + case ConfigError: + return "配置文件错误,程序将使用默认配置" + case InstallError: + return "安装过程中出现错误,程序将尝试回滚更改" + default: + return "发生未知错误,请联系技术支持" + } +} + +// RetryConfig 重试配置 +type RetryConfig struct { + MaxRetries int + InitialDelay time.Duration + MaxDelay time.Duration + BackoffFactor float64 + RetryableErrors []ErrorType +} + +// DefaultRetryConfig 默认重试配置 +func DefaultRetryConfig() *RetryConfig { + return &RetryConfig{ + MaxRetries: 3, + InitialDelay: time.Second, + MaxDelay: 30 * time.Second, + BackoffFactor: 2.0, + RetryableErrors: []ErrorType{NetworkError, APIError}, + } +} + +// IsRetryable 检查错误是否可重试 +func (rc *RetryConfig) IsRetryable(err error) bool { + if ue, ok := err.(*UpdaterError); ok { + for _, retryableType := range rc.RetryableErrors { + if ue.Type == retryableType { + return true + } + } + } + return false +} + +// CalculateDelay 计算重试延迟时间 +func (rc *RetryConfig) CalculateDelay(attempt int) time.Duration { + delay := time.Duration(float64(rc.InitialDelay) * pow(rc.BackoffFactor, float64(attempt))) + if delay > rc.MaxDelay { + delay = rc.MaxDelay + } + return delay +} + +// pow 简单的幂运算实现 +func pow(base, exp float64) float64 { + result := 1.0 + for i := 0; i < int(exp); i++ { + result *= base + } + return result +} + +// RetryableOperation 可重试的操作函数类型 +type RetryableOperation func() error + +// ExecuteWithRetry 执行带重试的操作 +func ExecuteWithRetry(operation RetryableOperation, config *RetryConfig) error { + var lastErr error + + for attempt := 0; attempt <= config.MaxRetries; attempt++ { + err := operation() + if err == nil { + return nil + } + + lastErr = err + + // 如果不是可重试的错误,直接返回 + if !config.IsRetryable(err) { + return err + } + + // 如果已经是最后一次尝试,不再等待 + if attempt == config.MaxRetries { + break + } + + // 计算延迟时间并等待 + delay := config.CalculateDelay(attempt) + time.Sleep(delay) + } + + return lastErr +} + +// ErrorHandler 错误处理器接口 +type ErrorHandler interface { + HandleError(err error) error + ShouldRetry(err error) bool + GetUserMessage(err error) string +} + +// DefaultErrorHandler 默认错误处理器 +type DefaultErrorHandler struct { + retryConfig *RetryConfig +} + +// NewDefaultErrorHandler 创建默认错误处理器 +func NewDefaultErrorHandler() *DefaultErrorHandler { + return &DefaultErrorHandler{ + retryConfig: DefaultRetryConfig(), + } +} + +// HandleError 处理错误 +func (h *DefaultErrorHandler) HandleError(err error) error { + if ue, ok := err.(*UpdaterError); ok { + // 记录错误上下文 + ue.WithContext("handled_at", time.Now()) + return ue + } + + // 将普通错误包装为UpdaterError + return NewUpdaterError(NetworkError, "未分类错误", err) +} + +// ShouldRetry 判断是否应该重试 +func (h *DefaultErrorHandler) ShouldRetry(err error) bool { + return h.retryConfig.IsRetryable(err) +} + +// GetUserMessage 获取用户友好的错误消息 +func (h *DefaultErrorHandler) GetUserMessage(err error) string { + if ue, ok := err.(*UpdaterError); ok { + return ue.GetUserFriendlyMessage() + } + return "发生未知错误,请联系技术支持" +} \ No newline at end of file diff --git a/Go_Updater/errors/errors_test.go b/Go_Updater/errors/errors_test.go new file mode 100644 index 0000000..bb8dd3f --- /dev/null +++ b/Go_Updater/errors/errors_test.go @@ -0,0 +1,287 @@ +package errors + +import ( + "fmt" + "testing" + "time" +) + +func TestUpdaterError_Error(t *testing.T) { + tests := []struct { + name string + err *UpdaterError + expected string + }{ + { + name: "error with cause", + err: &UpdaterError{ + Type: NetworkError, + Message: "connection failed", + Cause: fmt.Errorf("timeout"), + }, + expected: "[NetworkError] connection failed: timeout", + }, + { + name: "error without cause", + err: &UpdaterError{ + Type: APIError, + Message: "invalid response", + Cause: nil, + }, + expected: "[APIError] invalid response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.err.Error(); got != tt.expected { + t.Errorf("UpdaterError.Error() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestNewUpdaterError(t *testing.T) { + cause := fmt.Errorf("original error") + err := NewUpdaterError(FileError, "test message", cause) + + if err.Type != FileError { + t.Errorf("Expected type %v, got %v", FileError, err.Type) + } + if err.Message != "test message" { + t.Errorf("Expected message 'test message', got '%v'", err.Message) + } + if err.Cause != cause { + t.Errorf("Expected cause %v, got %v", cause, err.Cause) + } + if err.Context == nil { + t.Error("Expected context to be initialized") + } +} + +func TestUpdaterError_WithContext(t *testing.T) { + err := NewUpdaterError(ConfigError, "test", nil) + err.WithContext("key1", "value1").WithContext("key2", 42) + + if err.Context["key1"] != "value1" { + t.Errorf("Expected context key1 to be 'value1', got %v", err.Context["key1"]) + } + if err.Context["key2"] != 42 { + t.Errorf("Expected context key2 to be 42, got %v", err.Context["key2"]) + } +} + +func TestUpdaterError_GetUserFriendlyMessage(t *testing.T) { + tests := []struct { + errorType ErrorType + expected string + }{ + {NetworkError, "网络连接失败,请检查网络连接后重试"}, + {APIError, "服务器响应异常,请稍后重试或联系技术支持"}, + {FileError, "文件操作失败,请检查文件权限和磁盘空间"}, + {ConfigError, "配置文件错误,程序将使用默认配置"}, + {InstallError, "安装过程中出现错误,程序将尝试回滚更改"}, + } + + for _, tt := range tests { + t.Run(tt.errorType.String(), func(t *testing.T) { + err := NewUpdaterError(tt.errorType, "test", nil) + if got := err.GetUserFriendlyMessage(); got != tt.expected { + t.Errorf("GetUserFriendlyMessage() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestRetryConfig_IsRetryable(t *testing.T) { + config := DefaultRetryConfig() + + tests := []struct { + name string + err error + expected bool + }{ + { + name: "retryable network error", + err: NewUpdaterError(NetworkError, "test", nil), + expected: true, + }, + { + name: "retryable api error", + err: NewUpdaterError(APIError, "test", nil), + expected: true, + }, + { + name: "non-retryable file error", + err: NewUpdaterError(FileError, "test", nil), + expected: false, + }, + { + name: "regular error", + err: fmt.Errorf("regular error"), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := config.IsRetryable(tt.err); got != tt.expected { + t.Errorf("IsRetryable() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestRetryConfig_CalculateDelay(t *testing.T) { + config := DefaultRetryConfig() + + tests := []struct { + attempt int + expected time.Duration + }{ + {0, time.Second}, + {1, 2 * time.Second}, + {2, 4 * time.Second}, + {10, 30 * time.Second}, // should be capped at MaxDelay + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("attempt_%d", tt.attempt), func(t *testing.T) { + if got := config.CalculateDelay(tt.attempt); got != tt.expected { + t.Errorf("CalculateDelay(%d) = %v, want %v", tt.attempt, got, tt.expected) + } + }) + } +} + +func TestExecuteWithRetry(t *testing.T) { + config := DefaultRetryConfig() + config.InitialDelay = time.Millisecond // 加快测试速度 + + t.Run("success on first try", func(t *testing.T) { + attempts := 0 + operation := func() error { + attempts++ + return nil + } + + err := ExecuteWithRetry(operation, config) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if attempts != 1 { + t.Errorf("Expected 1 attempt, got %d", attempts) + } + }) + + t.Run("success after retries", func(t *testing.T) { + attempts := 0 + operation := func() error { + attempts++ + if attempts < 3 { + return NewUpdaterError(NetworkError, "temporary failure", nil) + } + return nil + } + + err := ExecuteWithRetry(operation, config) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if attempts != 3 { + t.Errorf("Expected 3 attempts, got %d", attempts) + } + }) + + t.Run("non-retryable error", func(t *testing.T) { + attempts := 0 + operation := func() error { + attempts++ + return NewUpdaterError(FileError, "file not found", nil) + } + + err := ExecuteWithRetry(operation, config) + if err == nil { + t.Error("Expected error, got nil") + } + if attempts != 1 { + t.Errorf("Expected 1 attempt, got %d", attempts) + } + }) + + t.Run("max retries exceeded", func(t *testing.T) { + attempts := 0 + operation := func() error { + attempts++ + return NewUpdaterError(NetworkError, "persistent failure", nil) + } + + err := ExecuteWithRetry(operation, config) + if err == nil { + t.Error("Expected error, got nil") + } + expectedAttempts := config.MaxRetries + 1 + if attempts != expectedAttempts { + t.Errorf("Expected %d attempts, got %d", expectedAttempts, attempts) + } + }) +} + +func TestDefaultErrorHandler(t *testing.T) { + handler := NewDefaultErrorHandler() + + t.Run("handle updater error", func(t *testing.T) { + originalErr := NewUpdaterError(NetworkError, "test", nil) + handledErr := handler.HandleError(originalErr) + + if handledErr != originalErr { + t.Error("Expected same error instance") + } + if originalErr.Context["handled_at"] == nil { + t.Error("Expected handled_at context to be set") + } + }) + + t.Run("handle regular error", func(t *testing.T) { + originalErr := fmt.Errorf("regular error") + handledErr := handler.HandleError(originalErr) + + if ue, ok := handledErr.(*UpdaterError); ok { + if ue.Type != NetworkError { + t.Errorf("Expected NetworkError, got %v", ue.Type) + } + if ue.Cause != originalErr { + t.Error("Expected original error as cause") + } + } else { + t.Error("Expected UpdaterError") + } + }) + + t.Run("should retry", func(t *testing.T) { + retryableErr := NewUpdaterError(NetworkError, "test", nil) + nonRetryableErr := NewUpdaterError(FileError, "test", nil) + + if !handler.ShouldRetry(retryableErr) { + t.Error("Expected network error to be retryable") + } + if handler.ShouldRetry(nonRetryableErr) { + t.Error("Expected file error to not be retryable") + } + }) + + t.Run("get user message", func(t *testing.T) { + updaterErr := NewUpdaterError(NetworkError, "test", nil) + regularErr := fmt.Errorf("regular error") + + userMsg1 := handler.GetUserMessage(updaterErr) + userMsg2 := handler.GetUserMessage(regularErr) + + if userMsg1 != "网络连接失败,请检查网络连接后重试" { + t.Errorf("Unexpected user message: %s", userMsg1) + } + if userMsg2 != "发生未知错误,请联系技术支持" { + t.Errorf("Unexpected user message: %s", userMsg2) + } + }) +} \ No newline at end of file diff --git a/Go_Updater/go.mod b/Go_Updater/go.mod new file mode 100644 index 0000000..143c5e4 --- /dev/null +++ b/Go_Updater/go.mod @@ -0,0 +1,42 @@ +module AUTO_MAA_Go_Updater + +go 1.24.5 + +require ( + fyne.io/fyne/v2 v2.6.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + fyne.io/systray v1.11.0 // indirect + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fredbi/uri v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fyne-io/gl-js v0.1.0 // indirect + github.com/fyne-io/glfw-js v0.2.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.1.0 // indirect + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect + github.com/go-text/render v0.2.0 // indirect + github.com/go-text/typesetting v0.2.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rymdport/portal v0.4.1 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/image v0.24.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/Go_Updater/go.sum b/Go_Updater/go.sum new file mode 100644 index 0000000..83677a5 --- /dev/null +++ b/Go_Updater/go.sum @@ -0,0 +1,80 @@ +fyne.io/fyne/v2 v2.6.1 h1:kjPJD4/rBS9m2nHJp+npPSuaK79yj6ObMTuzR6VQ1Is= +fyne.io/fyne/v2 v2.6.1/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU= +fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= +fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= +github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM= +github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM= +github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= +github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= +github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= +github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= +github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc= +github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= +github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA= +github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/Go_Updater/gui/manager.go b/Go_Updater/gui/manager.go new file mode 100644 index 0000000..d2b8350 --- /dev/null +++ b/Go_Updater/gui/manager.go @@ -0,0 +1,513 @@ +package gui + +import ( + "fmt" + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +// UpdateStatus 表示更新过程的当前状态 +type UpdateStatus int + +const ( + StatusChecking UpdateStatus = iota + StatusUpdateAvailable + StatusDownloading + StatusInstalling + StatusCompleted + StatusError +) + +// Config 表示 GUI 的配置结构 +type Config struct { + ResourceID string + CurrentVersion string + UserAgent string + BackupURL string +} + +// GUIManager 定义 GUI 管理的接口方法 +type GUIManager interface { + ShowMainWindow() + UpdateStatus(status UpdateStatus, message string) + ShowProgress(percentage float64) + ShowError(errorMsg string) + ShowConfigDialog() (*Config, error) + Close() +} + +// Manager 实现 GUIManager 接口 +type Manager struct { + app fyne.App + window fyne.Window + statusLabel *widget.Label + progressBar *widget.ProgressBar + actionButton *widget.Button + versionLabel *widget.Label + releaseNotes *widget.RichText + currentStatus UpdateStatus + onCheckUpdate func() + onCancel func() +} + +// NewManager creates a new GUI manager instance +func NewManager() *Manager { + a := app.New() + a.SetIcon(theme.ComputerIcon()) + + w := a.NewWindow("AUTO_MAA_Go_Updater") + w.Resize(fyne.NewSize(500, 400)) + w.SetFixedSize(false) + w.CenterOnScreen() + + return &Manager{ + app: a, + window: w, + } +} + +// SetCallbacks sets the callback functions for user actions +func (m *Manager) SetCallbacks(onCheckUpdate, onCancel func()) { + m.onCheckUpdate = onCheckUpdate + m.onCancel = onCancel +} + +// ShowMainWindow displays the main application window +func (m *Manager) ShowMainWindow() { + // Create UI components + m.createUIComponents() + + // Create main layout + content := m.createMainLayout() + + m.window.SetContent(content) + m.window.ShowAndRun() +} + +// createUIComponents initializes all UI components +func (m *Manager) createUIComponents() { + // Status label + m.statusLabel = widget.NewLabel("准备检查更新...") + m.statusLabel.Alignment = fyne.TextAlignCenter + + // Progress bar + m.progressBar = widget.NewProgressBar() + m.progressBar.Hide() + + // Version label + m.versionLabel = widget.NewLabel("当前版本: 未知") + m.versionLabel.TextStyle = fyne.TextStyle{Italic: true} + + // Release notes + m.releaseNotes = widget.NewRichText() + m.releaseNotes.Hide() + + // Action button + m.actionButton = widget.NewButton("检查更新", func() { + if m.onCheckUpdate != nil { + m.onCheckUpdate() + } + }) + m.actionButton.Importance = widget.HighImportance +} + +// createMainLayout creates the main window layout +func (m *Manager) createMainLayout() *fyne.Container { + // Header section + header := container.NewVBox( + widget.NewCard("", "", container.NewVBox( + widget.NewLabelWithStyle("AUTO_MAA_Go_Updater", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), + m.versionLabel, + )), + ) + + // Status section + statusSection := container.NewVBox( + m.statusLabel, + m.progressBar, + ) + + // Release notes section + releaseNotesCard := widget.NewCard("更新日志", "", container.NewScroll(m.releaseNotes)) + releaseNotesCard.Hide() + + // Button section + buttonSection := container.NewHBox( + widget.NewButton("配置", func() { + m.showConfigDialog() + }), + widget.NewSpacer(), + m.actionButton, + ) + + // Main layout + return container.NewVBox( + header, + widget.NewSeparator(), + statusSection, + releaseNotesCard, + widget.NewSeparator(), + buttonSection, + ) +} + +// UpdateStatus updates the current status and UI accordingly +func (m *Manager) UpdateStatus(status UpdateStatus, message string) { + m.currentStatus = status + m.statusLabel.SetText(message) + + switch status { + case StatusChecking: + m.actionButton.SetText("检查中...") + m.actionButton.Disable() + m.progressBar.Hide() + + case StatusUpdateAvailable: + m.actionButton.SetText("开始更新") + m.actionButton.Enable() + m.progressBar.Hide() + + case StatusDownloading: + m.actionButton.SetText("下载中...") + m.actionButton.Disable() + m.progressBar.Show() + + case StatusInstalling: + m.actionButton.SetText("安装中...") + m.actionButton.Disable() + m.progressBar.Show() + + case StatusCompleted: + m.actionButton.SetText("完成") + m.actionButton.Enable() + m.progressBar.Hide() + + case StatusError: + m.actionButton.SetText("重试") + m.actionButton.Enable() + m.progressBar.Hide() + } +} + +// ShowProgress updates the progress bar +func (m *Manager) ShowProgress(percentage float64) { + if percentage < 0 { + percentage = 0 + } + if percentage > 100 { + percentage = 100 + } + + m.progressBar.SetValue(percentage / 100.0) + m.progressBar.Show() +} + +// ShowError displays an error dialog +func (m *Manager) ShowError(errorMsg string) { + dialog.ShowError(fmt.Errorf(errorMsg), m.window) +} + +// ShowConfigDialog displays the configuration dialog +func (m *Manager) ShowConfigDialog() (*Config, error) { + return m.showConfigDialog() +} + +// showConfigDialog creates and shows the configuration dialog +func (m *Manager) showConfigDialog() (*Config, error) { + // Create form entries + resourceIDEntry := widget.NewEntry() + resourceIDEntry.SetPlaceHolder("例如: M9A") + + versionEntry := widget.NewEntry() + versionEntry.SetPlaceHolder("例如: v1.0.0") + + userAgentEntry := widget.NewEntry() + userAgentEntry.SetText("AUTO_MAA_Go_Updater/1.0") + + backupURLEntry := widget.NewEntry() + backupURLEntry.SetPlaceHolder("备用下载地址(可选)") + + // Create form + form := &widget.Form{ + Items: []*widget.FormItem{ + {Text: "资源ID:", Widget: resourceIDEntry}, + {Text: "当前版本:", Widget: versionEntry}, + {Text: "用户代理:", Widget: userAgentEntry}, + {Text: "备用下载地址:", Widget: backupURLEntry}, + }, + } + + // Create result channel + resultChan := make(chan *Config, 1) + errorChan := make(chan error, 1) + + // Create dialog + configDialog := dialog.NewCustomConfirm( + "配置设置", + "保存", + "取消", + form, + func(confirmed bool) { + if confirmed { + config := &Config{ + ResourceID: resourceIDEntry.Text, + CurrentVersion: versionEntry.Text, + UserAgent: userAgentEntry.Text, + BackupURL: backupURLEntry.Text, + } + + // Basic validation + if config.ResourceID == "" { + errorChan <- fmt.Errorf("资源ID不能为空") + return + } + if config.CurrentVersion == "" { + errorChan <- fmt.Errorf("当前版本不能为空") + return + } + + resultChan <- config + } else { + errorChan <- fmt.Errorf("用户取消了配置") + } + }, + m.window, + ) + + // Add help text + helpText := widget.NewRichTextFromMarkdown(` +**配置说明:** +- **资源ID**: Mirror酱服务中的资源标识符 +- **当前版本**: 当前软件的版本号 +- **用户代理**: HTTP请求的用户代理字符串 +- **备用下载地址**: 当Mirror酱服务不可用时的备用下载地址 +`) + + // Create container with help text + dialogContent := container.NewVBox( + form, + widget.NewSeparator(), + helpText, + ) + + configDialog.SetContent(dialogContent) + configDialog.Resize(fyne.NewSize(600, 500)) + configDialog.Show() + + // Wait for result + select { + case config := <-resultChan: + return config, nil + case err := <-errorChan: + return nil, err + } +} + +// SetVersionInfo updates the version display +func (m *Manager) SetVersionInfo(version string) { + m.versionLabel.SetText(fmt.Sprintf("当前版本: %s", version)) +} + +// ShowReleaseNotes displays the release notes +func (m *Manager) ShowReleaseNotes(notes string) { + if notes != "" { + m.releaseNotes.ParseMarkdown(notes) + // Find the release notes card and show it + if parent := m.window.Content().(*container.VBox); parent != nil { + for _, obj := range parent.Objects { + if card, ok := obj.(*widget.Card); ok && card.Title == "更新日志" { + card.Show() + break + } + } + } + } +} + +// UpdateStatusWithDetails updates status with detailed information +func (m *Manager) UpdateStatusWithDetails(status UpdateStatus, message string, details map[string]string) { + m.UpdateStatus(status, message) + + // Update version info if provided + if version, ok := details["version"]; ok { + m.SetVersionInfo(version) + } + + // Show release notes if provided + if notes, ok := details["release_notes"]; ok { + m.ShowReleaseNotes(notes) + } + + // Update progress if provided + if progress, ok := details["progress"]; ok { + if p, err := fmt.Sscanf(progress, "%f", new(float64)); err == nil && p == 1 { + var progressValue float64 + fmt.Sscanf(progress, "%f", &progressValue) + m.ShowProgress(progressValue) + } + } +} + +// ShowProgressWithSpeed shows progress with download speed information +func (m *Manager) ShowProgressWithSpeed(percentage float64, speed int64, eta string) { + m.ShowProgress(percentage) + + // Update status with speed and ETA information + speedText := m.formatSpeed(speed) + statusText := fmt.Sprintf("下载中... %.1f%% (%s)", percentage, speedText) + if eta != "" { + statusText += fmt.Sprintf(" - 剩余时间: %s", eta) + } + + m.statusLabel.SetText(statusText) +} + +// formatSpeed formats the download speed for display +func (m *Manager) formatSpeed(bytesPerSecond int64) string { + if bytesPerSecond < 1024 { + return fmt.Sprintf("%d B/s", bytesPerSecond) + } else if bytesPerSecond < 1024*1024 { + return fmt.Sprintf("%.1f KB/s", float64(bytesPerSecond)/1024) + } else { + return fmt.Sprintf("%.1f MB/s", float64(bytesPerSecond)/(1024*1024)) + } +} + +// ShowConfirmDialog shows a confirmation dialog +func (m *Manager) ShowConfirmDialog(title, message string, callback func(bool)) { + dialog.ShowConfirm(title, message, callback, m.window) +} + +// ShowInfoDialog shows an information dialog +func (m *Manager) ShowInfoDialog(title, message string) { + dialog.ShowInformation(title, message, m.window) +} + +// ShowUpdateAvailableDialog shows a dialog when update is available +func (m *Manager) ShowUpdateAvailableDialog(currentVersion, newVersion, releaseNotes string, onConfirm func()) { + content := container.NewVBox( + widget.NewLabel(fmt.Sprintf("发现新版本: %s", newVersion)), + widget.NewLabel(fmt.Sprintf("当前版本: %s", currentVersion)), + widget.NewSeparator(), + ) + + if releaseNotes != "" { + notesWidget := widget.NewRichText() + notesWidget.ParseMarkdown(releaseNotes) + + notesScroll := container.NewScroll(notesWidget) + notesScroll.SetMinSize(fyne.NewSize(400, 200)) + + content.Add(widget.NewLabel("更新内容:")) + content.Add(notesScroll) + } + + dialog.ShowCustomConfirm( + "发现新版本", + "立即更新", + "稍后提醒", + content, + func(confirmed bool) { + if confirmed && onConfirm != nil { + onConfirm() + } + }, + m.window, + ) +} + +// SetActionButtonCallback sets the callback for the main action button +func (m *Manager) SetActionButtonCallback(callback func()) { + if m.actionButton != nil { + m.actionButton.OnTapped = callback + } +} + +// EnableActionButton enables or disables the action button +func (m *Manager) EnableActionButton(enabled bool) { + if m.actionButton != nil { + if enabled { + m.actionButton.Enable() + } else { + m.actionButton.Disable() + } + } +} + +// SetActionButtonText sets the text of the action button +func (m *Manager) SetActionButtonText(text string) { + if m.actionButton != nil { + m.actionButton.SetText(text) + } +} + +// ShowErrorWithRetry shows an error with retry option +func (m *Manager) ShowErrorWithRetry(errorMsg string, onRetry func()) { + dialog.ShowCustomConfirm( + "错误", + "重试", + "取消", + widget.NewLabel(errorMsg), + func(retry bool) { + if retry && onRetry != nil { + onRetry() + } + }, + m.window, + ) +} + +// UpdateProgressBar updates the progress bar with custom styling +func (m *Manager) UpdateProgressBar(percentage float64, color string) { + m.ShowProgress(percentage) + // Note: Fyne doesn't support custom colors easily, but we keep the interface for future enhancement +} + +// HideProgressBar hides the progress bar +func (m *Manager) HideProgressBar() { + if m.progressBar != nil { + m.progressBar.Hide() + } +} + +// ShowProgressBar shows the progress bar +func (m *Manager) ShowProgressBar() { + if m.progressBar != nil { + m.progressBar.Show() + } +} + +// SetWindowTitle sets the window title +func (m *Manager) SetWindowTitle(title string) { + if m.window != nil { + m.window.SetTitle(title) + } +} + +// GetCurrentStatus returns the current update status +func (m *Manager) GetCurrentStatus() UpdateStatus { + return m.currentStatus +} + +// IsWindowVisible returns whether the window is currently visible +func (m *Manager) IsWindowVisible() bool { + return m.window != nil && m.window.Content() != nil +} + +// RefreshUI refreshes the user interface +func (m *Manager) RefreshUI() { + if m.window != nil && m.window.Content() != nil { + m.window.Content().Refresh() + } +} + +// Close closes the application +func (m *Manager) Close() { + if m.window != nil { + m.window.Close() + } +} diff --git a/Go_Updater/gui/manager_test.go b/Go_Updater/gui/manager_test.go new file mode 100644 index 0000000..c03be1b --- /dev/null +++ b/Go_Updater/gui/manager_test.go @@ -0,0 +1,227 @@ +package gui + +import ( + "testing" + "time" +) + +func TestNewManager(t *testing.T) { + manager := NewManager() + if manager == nil { + t.Fatal("NewManager() returned nil") + } + + if manager.app == nil { + t.Error("Manager app is nil") + } + + if manager.window == nil { + t.Error("Manager window is nil") + } +} + +func TestUpdateStatus(t *testing.T) { + manager := NewManager() + manager.createUIComponents() + + // Test different status updates + testCases := []struct { + status UpdateStatus + message string + }{ + {StatusChecking, "检查更新中..."}, + {StatusUpdateAvailable, "发现新版本"}, + {StatusDownloading, "下载中..."}, + {StatusInstalling, "安装中..."}, + {StatusCompleted, "更新完成"}, + {StatusError, "更新失败"}, + } + + for _, tc := range testCases { + manager.UpdateStatus(tc.status, tc.message) + + if manager.GetCurrentStatus() != tc.status { + t.Errorf("Expected status %v, got %v", tc.status, manager.GetCurrentStatus()) + } + + if manager.statusLabel.Text != tc.message { + t.Errorf("Expected message '%s', got '%s'", tc.message, manager.statusLabel.Text) + } + } +} + +func TestShowProgress(t *testing.T) { + manager := NewManager() + manager.createUIComponents() + + // Test progress values + testValues := []float64{0, 25.5, 50, 75.8, 100, 150, -10} + expectedValues := []float64{0, 25.5, 50, 75.8, 100, 100, 0} + + for i, value := range testValues { + manager.ShowProgress(value) + expected := expectedValues[i] / 100.0 + + if manager.progressBar.Value != expected { + t.Errorf("Expected progress %.2f, got %.2f", expected, manager.progressBar.Value) + } + } +} + +func TestSetVersionInfo(t *testing.T) { + manager := NewManager() + manager.createUIComponents() + + version := "v1.2.3" + manager.SetVersionInfo(version) + + expectedText := "当前版本: v1.2.3" + if manager.versionLabel.Text != expectedText { + t.Errorf("Expected version text '%s', got '%s'", expectedText, manager.versionLabel.Text) + } +} + +func TestFormatSpeed(t *testing.T) { + manager := NewManager() + + testCases := []struct { + speed int64 + expected string + }{ + {512, "512 B/s"}, + {1536, "1.5 KB/s"}, + {1048576, "1.0 MB/s"}, + {2621440, "2.5 MB/s"}, + } + + for _, tc := range testCases { + result := manager.formatSpeed(tc.speed) + if result != tc.expected { + t.Errorf("Expected speed format '%s', got '%s'", tc.expected, result) + } + } +} + +func TestShowProgressWithSpeed(t *testing.T) { + manager := NewManager() + manager.createUIComponents() + + percentage := 45.5 + speed := int64(1048576) // 1 MB/s + eta := "2分钟" + + manager.ShowProgressWithSpeed(percentage, speed, eta) + + expectedProgress := percentage / 100.0 + if manager.progressBar.Value != expectedProgress { + t.Errorf("Expected progress %.2f, got %.2f", expectedProgress, manager.progressBar.Value) + } + + expectedStatus := "下载中... 45.5% (1.0 MB/s) - 剩余时间: 2分钟" + if manager.statusLabel.Text != expectedStatus { + t.Errorf("Expected status '%s', got '%s'", expectedStatus, manager.statusLabel.Text) + } +} + +func TestActionButtonStates(t *testing.T) { + manager := NewManager() + manager.createUIComponents() + + // Test enabling/disabling + manager.EnableActionButton(false) + if !manager.actionButton.Disabled() { + t.Error("Action button should be disabled") + } + + manager.EnableActionButton(true) + if manager.actionButton.Disabled() { + t.Error("Action button should be enabled") + } + + // Test text setting + testText := "测试按钮" + manager.SetActionButtonText(testText) + if manager.actionButton.Text != testText { + t.Errorf("Expected button text '%s', got '%s'", testText, manager.actionButton.Text) + } +} + +func TestProgressBarVisibility(t *testing.T) { + manager := NewManager() + manager.createUIComponents() + + // Initially hidden + if manager.progressBar.Visible() { + t.Error("Progress bar should be initially hidden") + } + + // Show progress bar + manager.ShowProgressBar() + if !manager.progressBar.Visible() { + t.Error("Progress bar should be visible after ShowProgressBar()") + } + + // Hide progress bar + manager.HideProgressBar() + if manager.progressBar.Visible() { + t.Error("Progress bar should be hidden after HideProgressBar()") + } +} + +func TestSetCallbacks(t *testing.T) { + manager := NewManager() + + checkUpdateCalled := false + cancelCalled := false + + onCheckUpdate := func() { + checkUpdateCalled = true + } + + onCancel := func() { + cancelCalled = true + } + + manager.SetCallbacks(onCheckUpdate, onCancel) + + // Verify callbacks are set + if manager.onCheckUpdate == nil { + t.Error("onCheckUpdate callback not set") + } + + if manager.onCancel == nil { + t.Error("onCancel callback not set") + } + + // Test callback execution + manager.onCheckUpdate() + if !checkUpdateCalled { + t.Error("onCheckUpdate callback was not called") + } + + manager.onCancel() + if !cancelCalled { + t.Error("onCancel callback was not called") + } +} + +// Benchmark tests for performance +func BenchmarkUpdateStatus(b *testing.B) { + manager := NewManager() + manager.createUIComponents() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + manager.UpdateStatus(StatusDownloading, "下载中...") + } +} + +func BenchmarkShowProgress(b *testing.B) { + manager := NewManager() + manager.createUIComponents() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + manager.ShowProgress(float64(i % 100)) + } +} \ 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 new file mode 100644 index 0000000..5520beb Binary files /dev/null and b/Go_Updater/icon/AUTO_MAA_Go_Updater.ico differ diff --git a/Go_Updater/icon/AUTO_MAA_Go_Updater.png b/Go_Updater/icon/AUTO_MAA_Go_Updater.png new file mode 100644 index 0000000..630a52b Binary files /dev/null and b/Go_Updater/icon/AUTO_MAA_Go_Updater.png differ diff --git a/Go_Updater/install/manager.go b/Go_Updater/install/manager.go new file mode 100644 index 0000000..88fe26a --- /dev/null +++ b/Go_Updater/install/manager.go @@ -0,0 +1,474 @@ +package install + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" +) + +// ChangesInfo 表示 changes.json 文件的结构 +type ChangesInfo struct { + Deleted []string `json:"deleted"` + Added []string `json:"added"` + Modified []string `json:"modified"` +} + +// InstallManager 定义安装操作的接口契约 +type InstallManager interface { + ExtractZip(zipPath, destPath string) error + ProcessChanges(changesPath string) (*ChangesInfo, error) + ApplyUpdate(sourcePath, targetPath string, changes *ChangesInfo) error + HandleRunningProcess(processName string) error + CreateTempDir() (string, error) + CleanupTempDir(tempDir string) error +} + +// Manager 实现 InstallManager 接口 +type Manager struct { + tempDirs []string // 跟踪临时目录以便清理 +} + +// NewManager 创建新的安装管理器实例 +func NewManager() *Manager { + return &Manager{ + tempDirs: make([]string, 0), + } +} + +// CreateTempDir 为解压创建临时目录 +func (m *Manager) CreateTempDir() (string, error) { + tempDir, err := os.MkdirTemp("", "updater_*") + if err != nil { + return "", fmt.Errorf("创建临时目录失败: %w", err) + } + + // 跟踪临时目录以便清理 + m.tempDirs = append(m.tempDirs, tempDir) + return tempDir, nil +} + +// CleanupTempDir 删除临时目录及其内容 +func (m *Manager) CleanupTempDir(tempDir string) error { + if tempDir == "" { + return nil + } + + err := os.RemoveAll(tempDir) + if err != nil { + return fmt.Errorf("清理临时目录 %s 失败: %w", tempDir, err) + } + + // 从跟踪列表中删除 + for i, dir := range m.tempDirs { + if dir == tempDir { + m.tempDirs = append(m.tempDirs[:i], m.tempDirs[i+1:]...) + break + } + } + + return nil +} + +// CleanupAllTempDirs 删除所有跟踪的临时目录 +func (m *Manager) CleanupAllTempDirs() error { + var errors []string + + for _, tempDir := range m.tempDirs { + if err := os.RemoveAll(tempDir); err != nil { + errors = append(errors, fmt.Sprintf("清理 %s 失败: %v", tempDir, err)) + } + } + + m.tempDirs = m.tempDirs[:0] // 清空切片 + + if len(errors) > 0 { + return fmt.Errorf("清理错误: %s", strings.Join(errors, "; ")) + } + + return nil +} + +// ExtractZip 将 ZIP 文件解压到指定的目标目录 +func (m *Manager) ExtractZip(zipPath, destPath string) error { + // 打开 ZIP 文件进行读取 + reader, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("打开 ZIP 文件 %s 失败: %w", zipPath, err) + } + defer reader.Close() + + // 如果目标目录不存在则创建 + if err := os.MkdirAll(destPath, 0755); err != nil { + return fmt.Errorf("创建目标目录 %s 失败: %w", destPath, err) + } + + // 解压文件 + for _, file := range reader.File { + if err := m.extractFile(file, destPath); err != nil { + return fmt.Errorf("解压文件 %s 失败: %w", file.Name, err) + } + } + + return nil +} + +// extractFile 从 ZIP 归档中解压单个文件 +func (m *Manager) extractFile(file *zip.File, destPath string) error { + // 清理文件路径以防止目录遍历攻击 + cleanPath := filepath.Clean(file.Name) + if strings.Contains(cleanPath, "..") { + return fmt.Errorf("无效的文件路径: %s", file.Name) + } + + // 创建完整的目标路径 + destFile := filepath.Join(destPath, cleanPath) + + // 如果需要则创建目录结构 + if file.FileInfo().IsDir() { + return os.MkdirAll(destFile, file.FileInfo().Mode()) + } + + // 创建父目录 + if err := os.MkdirAll(filepath.Dir(destFile), 0755); err != nil { + return fmt.Errorf("创建父目录失败: %w", err) + } + + // 打开 ZIP 归档中的文件 + rc, err := file.Open() + if err != nil { + return fmt.Errorf("打开归档中的文件失败: %w", err) + } + defer rc.Close() + + // 创建目标文件 + outFile, err := os.OpenFile(destFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.FileInfo().Mode()) + if err != nil { + return fmt.Errorf("创建目标文件失败: %w", err) + } + defer outFile.Close() + + // 复制文件内容 + _, err = io.Copy(outFile, rc) + if err != nil { + return fmt.Errorf("复制文件内容失败: %w", err) + } + + return nil +} + +// ProcessChanges 读取并解析 changes.json 文件 +func (m *Manager) ProcessChanges(changesPath string) (*ChangesInfo, error) { + // 检查 changes.json 是否存在 + if _, err := os.Stat(changesPath); os.IsNotExist(err) { + // 如果 changes.json 不存在,返回空的变更信息 + return &ChangesInfo{ + Deleted: []string{}, + Added: []string{}, + Modified: []string{}, + }, nil + } + + // 读取 changes.json 文件 + data, err := os.ReadFile(changesPath) + if err != nil { + return nil, fmt.Errorf("读取变更文件 %s 失败: %w", changesPath, err) + } + + // 解析 JSON + var changes ChangesInfo + if err := json.Unmarshal(data, &changes); err != nil { + return nil, fmt.Errorf("解析变更 JSON 失败: %w", err) + } + + return &changes, nil +} + +// HandleRunningProcess 通过重命名正在使用的文件来处理正在运行的进程 +func (m *Manager) HandleRunningProcess(processName string) error { + // 获取当前可执行文件路径 + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("获取可执行文件路径失败: %w", err) + } + + exeDir := filepath.Dir(exePath) + targetFile := filepath.Join(exeDir, processName) + + // 检查目标文件是否存在 + if _, err := os.Stat(targetFile); os.IsNotExist(err) { + // 文件不存在,无需处理 + return nil + } + + // 尝试重命名文件以指示应在下次启动时删除 + oldFile := targetFile + ".old" + + // 如果存在现有的 .old 文件则删除 + if _, err := os.Stat(oldFile); err == nil { + if err := os.Remove(oldFile); err != nil { + return fmt.Errorf("删除现有旧文件 %s 失败: %w", oldFile, err) + } + } + + // 将当前文件重命名为 .old + if err := os.Rename(targetFile, oldFile); err != nil { + // 如果重命名失败,进程可能正在运行 + // 在 Windows 上,我们无法重命名正在运行的可执行文件 + if isFileInUse(err) { + // 标记文件在下次重启时删除(Windows 特定) + return m.markFileForDeletion(targetFile) + } + return fmt.Errorf("重命名正在运行的进程文件 %s 失败: %w", targetFile, err) + } + + return nil +} + +// isFileInUse 检查错误是否表示文件正在使用中 +func isFileInUse(err error) bool { + if err == nil { + return false + } + + // 检查 Windows 特定的"文件正在使用"错误 + if pathErr, ok := err.(*os.PathError); ok { + if errno, ok := pathErr.Err.(syscall.Errno); ok { + // ERROR_SHARING_VIOLATION (32) 或 ERROR_ACCESS_DENIED (5) + return errno == syscall.Errno(32) || errno == syscall.Errno(5) + } + } + + return strings.Contains(err.Error(), "being used by another process") || + strings.Contains(err.Error(), "access is denied") +} + +// markFileForDeletion 标记文件在下次系统重启时删除(Windows 特定) +func (m *Manager) markFileForDeletion(filePath string) error { + // 这是 Windows 特定的实现 + // 目前,我们将创建一个可由主应用程序处理的标记文件 + markerFile := filePath + ".delete_on_restart" + + // 创建标记文件 + file, err := os.Create(markerFile) + if err != nil { + return fmt.Errorf("创建删除标记文件失败: %w", err) + } + defer file.Close() + + // 将目标文件路径写入标记文件 + _, err = file.WriteString(filePath) + if err != nil { + return fmt.Errorf("写入标记文件失败: %w", err) + } + + return nil +} + +// DeleteMarkedFiles 删除标记为删除的文件 +func (m *Manager) DeleteMarkedFiles(directory string) error { + // 查找所有 .delete_on_restart 文件 + pattern := filepath.Join(directory, "*.delete_on_restart") + matches, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("查找标记文件失败: %w", err) + } + + var errors []string + for _, markerFile := range matches { + // 读取目标文件路径 + data, err := os.ReadFile(markerFile) + if err != nil { + errors = append(errors, fmt.Sprintf("读取标记文件 %s 失败: %v", markerFile, err)) + continue + } + + targetFile := strings.TrimSpace(string(data)) + + // 尝试删除目标文件 + if err := os.Remove(targetFile); err != nil && !os.IsNotExist(err) { + errors = append(errors, fmt.Sprintf("删除标记文件 %s 失败: %v", targetFile, err)) + } + + // 删除标记文件 + if err := os.Remove(markerFile); err != nil { + errors = append(errors, fmt.Sprintf("删除标记文件 %s 失败: %v", markerFile, err)) + } + } + + if len(errors) > 0 { + return fmt.Errorf("删除错误: %s", strings.Join(errors, "; ")) + } + + return nil +} + +// ApplyUpdate 通过从源目录复制文件到目标目录来应用更新 +func (m *Manager) ApplyUpdate(sourcePath, targetPath string, changes *ChangesInfo) error { + // 创建备份目录 + backupDir, err := m.createBackupDir(targetPath) + if err != nil { + return fmt.Errorf("创建备份目录失败: %w", err) + } + + // 在应用更新前备份现有文件 + if err := m.backupFiles(targetPath, backupDir, changes); err != nil { + return fmt.Errorf("备份文件失败: %w", err) + } + + // 应用更新 + if err := m.applyUpdateFiles(sourcePath, targetPath, changes); err != nil { + // 失败时回滚 + if rollbackErr := m.rollbackUpdate(targetPath, backupDir); rollbackErr != nil { + return fmt.Errorf("更新失败且回滚失败: 更新错误: %w, 回滚错误: %v", err, rollbackErr) + } + return fmt.Errorf("更新失败已回滚: %w", err) + } + + // 成功更新后清理备份目录 + if err := os.RemoveAll(backupDir); err != nil { + // 记录警告但不让更新失败 + fmt.Printf("警告: 清理备份目录 %s 失败: %v\n", backupDir, err) + } + + return nil +} + +// createBackupDir 为更新创建备份目录 +func (m *Manager) createBackupDir(targetPath string) (string, error) { + backupDir := filepath.Join(targetPath, ".backup_"+fmt.Sprintf("%d", os.Getpid())) + + if err := os.MkdirAll(backupDir, 0755); err != nil { + return "", fmt.Errorf("创建备份目录失败: %w", err) + } + + return backupDir, nil +} + +// backupFiles 创建将被修改或删除的文件的备份 +func (m *Manager) backupFiles(targetPath, backupDir string, changes *ChangesInfo) error { + // 备份将被修改的文件 + for _, file := range changes.Modified { + srcFile := filepath.Join(targetPath, file) + if _, err := os.Stat(srcFile); os.IsNotExist(err) { + continue // 文件不存在,跳过备份 + } + + backupFile := filepath.Join(backupDir, file) + if err := m.copyFileWithDirs(srcFile, backupFile); err != nil { + return fmt.Errorf("备份修改文件 %s 失败: %w", file, err) + } + } + + // 备份将被删除的文件 + for _, file := range changes.Deleted { + srcFile := filepath.Join(targetPath, file) + if _, err := os.Stat(srcFile); os.IsNotExist(err) { + continue // 文件不存在,跳过备份 + } + + backupFile := filepath.Join(backupDir, file) + if err := m.copyFileWithDirs(srcFile, backupFile); err != nil { + return fmt.Errorf("备份删除文件 %s 失败: %w", file, err) + } + } + + return nil +} + +// applyUpdateFiles 应用实际的文件更改 +func (m *Manager) applyUpdateFiles(sourcePath, targetPath string, changes *ChangesInfo) error { + // 删除标记为删除的文件 + for _, file := range changes.Deleted { + targetFile := filepath.Join(targetPath, file) + if err := os.Remove(targetFile); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("删除文件 %s 失败: %w", file, err) + } + } + + // 复制新文件和修改的文件 + filesToCopy := append(changes.Added, changes.Modified...) + for _, file := range filesToCopy { + srcFile := filepath.Join(sourcePath, file) + targetFile := filepath.Join(targetPath, file) + + // 检查源文件是否存在 + if _, err := os.Stat(srcFile); os.IsNotExist(err) { + continue // 源文件不存在,跳过 + } + + if err := m.copyFileWithDirs(srcFile, targetFile); err != nil { + return fmt.Errorf("复制文件 %s 失败: %w", file, err) + } + } + + return nil +} + +// copyFileWithDirs 复制文件并创建必要的目录 +func (m *Manager) copyFileWithDirs(src, dst string) error { + // 创建父目录 + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return fmt.Errorf("创建父目录失败: %w", err) + } + + // 打开源文件 + srcFile, err := os.Open(src) + if err != nil { + return fmt.Errorf("打开源文件失败: %w", err) + } + defer srcFile.Close() + + // 获取源文件信息 + srcInfo, err := srcFile.Stat() + if err != nil { + return fmt.Errorf("获取源文件信息失败: %w", err) + } + + // 创建目标文件 + dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode()) + if err != nil { + return fmt.Errorf("创建目标文件失败: %w", err) + } + defer dstFile.Close() + + // 复制文件内容 + _, err = io.Copy(dstFile, srcFile) + if err != nil { + return fmt.Errorf("复制文件内容失败: %w", err) + } + + return nil +} + +// rollbackUpdate 在更新失败时从备份恢复文件 +func (m *Manager) rollbackUpdate(targetPath, backupDir string) error { + // 遍历备份目录并恢复文件 + return filepath.Walk(backupDir, func(backupFile string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil // 跳过目录 + } + + // 计算相对路径 + relPath, err := filepath.Rel(backupDir, backupFile) + if err != nil { + return fmt.Errorf("计算相对路径失败: %w", err) + } + + // 将文件恢复到目标位置 + targetFile := filepath.Join(targetPath, relPath) + if err := m.copyFileWithDirs(backupFile, targetFile); err != nil { + return fmt.Errorf("恢复文件 %s 失败: %w", relPath, err) + } + + return nil + }) +} diff --git a/Go_Updater/install/manager_test.go b/Go_Updater/install/manager_test.go new file mode 100644 index 0000000..5bf9381 --- /dev/null +++ b/Go_Updater/install/manager_test.go @@ -0,0 +1,1033 @@ +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 new file mode 100644 index 0000000..508cdc8 --- /dev/null +++ b/Go_Updater/integration_test.go @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..68b2f50 --- /dev/null +++ b/Go_Updater/logger/logger.go @@ -0,0 +1,438 @@ +package logger + +import ( + "fmt" + "io" + "log" + "os" + "path/filepath" + "sync" + "time" +) + +// LogLevel 日志级别 +type LogLevel int + +const ( + DEBUG LogLevel = iota + INFO + WARN + ERROR +) + +// String 返回日志级别的字符串表示 +func (l LogLevel) String() string { + switch l { + case DEBUG: + return "DEBUG" + case INFO: + return "INFO" + case WARN: + return "WARN" + case ERROR: + return "ERROR" + default: + return "UNKNOWN" + } +} + +// Logger 日志记录器接口 +type Logger interface { + Debug(msg string, fields ...interface{}) + Info(msg string, fields ...interface{}) + Warn(msg string, fields ...interface{}) + Error(msg string, fields ...interface{}) + SetLevel(level LogLevel) + Close() error +} + +// FileLogger 文件日志记录器 +type FileLogger struct { + mu sync.RWMutex + file *os.File + logger *log.Logger + level LogLevel + maxSize int64 // 最大文件大小(字节) + maxBackups int // 最大备份文件数 + logDir string // 日志目录 + filename string // 日志文件名 + currentSize int64 // 当前文件大小 +} + +// LoggerConfig 日志配置 +type LoggerConfig struct { + Level LogLevel + MaxSize int64 // 最大文件大小(字节),默认10MB + MaxBackups int // 最大备份文件数,默认5 + LogDir string // 日志目录 + Filename string // 日志文件名 +} + +// DefaultLoggerConfig 默认日志配置 +func DefaultLoggerConfig() *LoggerConfig { + // 获取当前可执行文件目录 + exePath, err := os.Executable() + var logDir string + if err != nil { + logDir = "debug" + } else { + exeDir := filepath.Dir(exePath) + logDir = filepath.Join(exeDir, "debug") + } + + return &LoggerConfig{ + Level: INFO, + MaxSize: 10 * 1024 * 1024, // 10MB + MaxBackups: 5, + LogDir: logDir, + Filename: "AUTO_MAA_Go_Updater.log", + } +} + +// NewFileLogger 创建新的文件日志记录器 +func NewFileLogger(config *LoggerConfig) (*FileLogger, error) { + if config == nil { + config = DefaultLoggerConfig() + } + + // 创建日志目录 + if err := os.MkdirAll(config.LogDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create log directory: %w", err) + } + + logPath := filepath.Join(config.LogDir, config.Filename) + + // 打开或创建日志文件 + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + + // 获取当前文件大小 + stat, err := file.Stat() + if err != nil { + file.Close() + return nil, fmt.Errorf("failed to get file stats: %w", err) + } + + logger := &FileLogger{ + file: file, + logger: log.New(file, "", 0), // 我们自己处理格式 + level: config.Level, + maxSize: config.MaxSize, + maxBackups: config.MaxBackups, + logDir: config.LogDir, + filename: config.Filename, + currentSize: stat.Size(), + } + + return logger, nil +} + +// formatMessage 格式化日志消息 +func (fl *FileLogger) formatMessage(level LogLevel, msg string, fields ...interface{}) string { + timestamp := time.Now().Format("2006-01-02 15:04:05.000") + + if len(fields) > 0 { + msg = fmt.Sprintf(msg, fields...) + } + + return fmt.Sprintf("[%s] %s %s\n", timestamp, level.String(), msg) +} + +// writeLog 写入日志 +func (fl *FileLogger) writeLog(level LogLevel, msg string, fields ...interface{}) { + fl.mu.Lock() + defer fl.mu.Unlock() + + // 检查日志级别 + if level < fl.level { + return + } + + formattedMsg := fl.formatMessage(level, msg, fields...) + + // 检查是否需要轮转 + if fl.currentSize+int64(len(formattedMsg)) > fl.maxSize { + if err := fl.rotate(); err != nil { + // 轮转失败,尝试写入stderr + fmt.Fprintf(os.Stderr, "Failed to rotate log: %v\n", err) + } + } + + // 写入日志 + n, err := fl.file.WriteString(formattedMsg) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to write log: %v\n", err) + return + } + + fl.currentSize += int64(n) + fl.file.Sync() // 确保写入磁盘 +} + +// rotate 轮转日志文件 +func (fl *FileLogger) rotate() error { + // 关闭当前文件 + if err := fl.file.Close(); err != nil { + return fmt.Errorf("failed to close current log file: %w", err) + } + + // 轮转备份文件 + if err := fl.rotateBackups(); err != nil { + return fmt.Errorf("failed to rotate backups: %w", err) + } + + // 创建新的日志文件 + logPath := filepath.Join(fl.logDir, fl.filename) + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to create new log file: %w", err) + } + + fl.file = file + fl.logger.SetOutput(file) + fl.currentSize = 0 + + return nil +} + +// rotateBackups 轮转备份文件 +func (fl *FileLogger) rotateBackups() error { + basePath := filepath.Join(fl.logDir, fl.filename) + + // 删除最老的备份文件 + if fl.maxBackups > 0 { + oldestBackup := fmt.Sprintf("%s.%d", basePath, fl.maxBackups) + os.Remove(oldestBackup) // 忽略错误,文件可能不存在 + } + + // 重命名现有备份文件 + for i := fl.maxBackups - 1; i > 0; i-- { + oldName := fmt.Sprintf("%s.%d", basePath, i) + newName := fmt.Sprintf("%s.%d", basePath, i+1) + os.Rename(oldName, newName) // 忽略错误,文件可能不存在 + } + + // 将当前日志文件重命名为第一个备份 + if fl.maxBackups > 0 { + backupName := fmt.Sprintf("%s.1", basePath) + return os.Rename(basePath, backupName) + } + + return nil +} + +// Debug 记录调试级别日志 +func (fl *FileLogger) Debug(msg string, fields ...interface{}) { + fl.writeLog(DEBUG, msg, fields...) +} + +// Info 记录信息级别日志 +func (fl *FileLogger) Info(msg string, fields ...interface{}) { + fl.writeLog(INFO, msg, fields...) +} + +// Warn 记录警告级别日志 +func (fl *FileLogger) Warn(msg string, fields ...interface{}) { + fl.writeLog(WARN, msg, fields...) +} + +// Error 记录错误级别日志 +func (fl *FileLogger) Error(msg string, fields ...interface{}) { + fl.writeLog(ERROR, msg, fields...) +} + +// SetLevel 设置日志级别 +func (fl *FileLogger) SetLevel(level LogLevel) { + fl.mu.Lock() + defer fl.mu.Unlock() + fl.level = level +} + +// Close 关闭日志记录器 +func (fl *FileLogger) Close() error { + fl.mu.Lock() + defer fl.mu.Unlock() + + if fl.file != nil { + return fl.file.Close() + } + return nil +} + +// MultiLogger 多输出日志记录器 +type MultiLogger struct { + loggers []Logger + level LogLevel +} + +// NewMultiLogger 创建多输出日志记录器 +func NewMultiLogger(loggers ...Logger) *MultiLogger { + return &MultiLogger{ + loggers: loggers, + level: INFO, + } +} + +// Debug 记录调试级别日志 +func (ml *MultiLogger) Debug(msg string, fields ...interface{}) { + for _, logger := range ml.loggers { + logger.Debug(msg, fields...) + } +} + +// Info 记录信息级别日志 +func (ml *MultiLogger) Info(msg string, fields ...interface{}) { + for _, logger := range ml.loggers { + logger.Info(msg, fields...) + } +} + +// Warn 记录警告级别日志 +func (ml *MultiLogger) Warn(msg string, fields ...interface{}) { + for _, logger := range ml.loggers { + logger.Warn(msg, fields...) + } +} + +// Error 记录错误级别日志 +func (ml *MultiLogger) Error(msg string, fields ...interface{}) { + for _, logger := range ml.loggers { + logger.Error(msg, fields...) + } +} + +// SetLevel 设置日志级别 +func (ml *MultiLogger) SetLevel(level LogLevel) { + ml.level = level + for _, logger := range ml.loggers { + logger.SetLevel(level) + } +} + +// Close 关闭所有日志记录器 +func (ml *MultiLogger) Close() error { + var lastErr error + for _, logger := range ml.loggers { + if err := logger.Close(); err != nil { + lastErr = err + } + } + return lastErr +} + +// ConsoleLogger 控制台日志记录器 +type ConsoleLogger struct { + writer io.Writer + level LogLevel +} + +// NewConsoleLogger 创建控制台日志记录器 +func NewConsoleLogger(writer io.Writer) *ConsoleLogger { + if writer == nil { + writer = os.Stdout + } + return &ConsoleLogger{ + writer: writer, + level: INFO, + } +} + +// formatMessage 格式化控制台日志消息 +func (cl *ConsoleLogger) formatMessage(level LogLevel, msg string, fields ...interface{}) string { + timestamp := time.Now().Format("15:04:05") + + if len(fields) > 0 { + msg = fmt.Sprintf(msg, fields...) + } + + return fmt.Sprintf("[%s] %s %s\n", timestamp, level.String(), msg) +} + +// writeLog 写入控制台日志 +func (cl *ConsoleLogger) writeLog(level LogLevel, msg string, fields ...interface{}) { + if level < cl.level { + return + } + + formattedMsg := cl.formatMessage(level, msg, fields...) + fmt.Fprint(cl.writer, formattedMsg) +} + +// Debug 记录调试级别日志 +func (cl *ConsoleLogger) Debug(msg string, fields ...interface{}) { + cl.writeLog(DEBUG, msg, fields...) +} + +// Info 记录信息级别日志 +func (cl *ConsoleLogger) Info(msg string, fields ...interface{}) { + cl.writeLog(INFO, msg, fields...) +} + +// Warn 记录警告级别日志 +func (cl *ConsoleLogger) Warn(msg string, fields ...interface{}) { + cl.writeLog(WARN, msg, fields...) +} + +// Error 记录错误级别日志 +func (cl *ConsoleLogger) Error(msg string, fields ...interface{}) { + cl.writeLog(ERROR, msg, fields...) +} + +// SetLevel 设置日志级别 +func (cl *ConsoleLogger) SetLevel(level LogLevel) { + cl.level = level +} + +// Close 关闭控制台日志记录器(无操作) +func (cl *ConsoleLogger) Close() error { + return nil +} + +// 全局日志记录器实例 +var ( + defaultLogger Logger + once sync.Once +) + +// GetDefaultLogger 获取默认日志记录器 +func GetDefaultLogger() Logger { + once.Do(func() { + fileLogger, err := NewFileLogger(DefaultLoggerConfig()) + if err != nil { + // 如果文件日志创建失败,使用控制台日志 + defaultLogger = NewConsoleLogger(os.Stderr) + } else { + // 同时输出到文件和控制台 + consoleLogger := NewConsoleLogger(os.Stdout) + defaultLogger = NewMultiLogger(fileLogger, consoleLogger) + } + }) + return defaultLogger +} + +// 便捷函数 +func Debug(msg string, fields ...interface{}) { + GetDefaultLogger().Debug(msg, fields...) +} + +func Info(msg string, fields ...interface{}) { + GetDefaultLogger().Info(msg, fields...) +} + +func Warn(msg string, fields ...interface{}) { + GetDefaultLogger().Warn(msg, fields...) +} + +func Error(msg string, fields ...interface{}) { + GetDefaultLogger().Error(msg, fields...) +} + +func SetLevel(level LogLevel) { + GetDefaultLogger().SetLevel(level) +} + +func Close() error { + return GetDefaultLogger().Close() +} diff --git a/Go_Updater/logger/logger_test.go b/Go_Updater/logger/logger_test.go new file mode 100644 index 0000000..be99ae9 --- /dev/null +++ b/Go_Updater/logger/logger_test.go @@ -0,0 +1,300 @@ +package logger + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLogLevel_String(t *testing.T) { + tests := []struct { + level LogLevel + expected string + }{ + {DEBUG, "DEBUG"}, + {INFO, "INFO"}, + {WARN, "WARN"}, + {ERROR, "ERROR"}, + {LogLevel(999), "UNKNOWN"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + if got := tt.level.String(); got != tt.expected { + t.Errorf("LogLevel.String() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestDefaultLoggerConfig(t *testing.T) { + config := DefaultLoggerConfig() + + if config.Level != INFO { + t.Errorf("Expected default level INFO, got %v", config.Level) + } + if config.MaxSize != 10*1024*1024 { + t.Errorf("Expected default max size 10MB, got %v", config.MaxSize) + } + if config.MaxBackups != 5 { + t.Errorf("Expected default max backups 5, got %v", config.MaxBackups) + } + if config.Filename != "updater.log" { + t.Errorf("Expected default filename 'updater.log', got %v", config.Filename) + } +} + +func TestConsoleLogger(t *testing.T) { + var buf bytes.Buffer + logger := NewConsoleLogger(&buf) + + t.Run("log levels", func(t *testing.T) { + logger.SetLevel(DEBUG) + + logger.Debug("debug message") + logger.Info("info message") + logger.Warn("warn message") + logger.Error("error message") + + output := buf.String() + if !strings.Contains(output, "DEBUG debug message") { + t.Error("Expected debug message in output") + } + if !strings.Contains(output, "INFO info message") { + t.Error("Expected info message in output") + } + if !strings.Contains(output, "WARN warn message") { + t.Error("Expected warn message in output") + } + if !strings.Contains(output, "ERROR error message") { + t.Error("Expected error message in output") + } + }) + + t.Run("log level filtering", func(t *testing.T) { + buf.Reset() + logger.SetLevel(WARN) + + logger.Debug("debug message") + logger.Info("info message") + logger.Warn("warn message") + logger.Error("error message") + + output := buf.String() + if strings.Contains(output, "DEBUG") { + t.Error("Debug message should be filtered out") + } + if strings.Contains(output, "INFO") { + t.Error("Info message should be filtered out") + } + if !strings.Contains(output, "WARN warn message") { + t.Error("Expected warn message in output") + } + if !strings.Contains(output, "ERROR error message") { + t.Error("Expected error message in output") + } + }) + + t.Run("formatted messages", func(t *testing.T) { + buf.Reset() + logger.SetLevel(DEBUG) + + logger.Info("formatted message: %s %d", "test", 42) + + output := buf.String() + if !strings.Contains(output, "formatted message: test 42") { + t.Error("Expected formatted message in output") + } + }) +} + +func TestFileLogger(t *testing.T) { + // 创建临时目录 + tempDir := t.TempDir() + + config := &LoggerConfig{ + Level: DEBUG, + MaxSize: 1024, // 1KB for testing rotation + MaxBackups: 3, + LogDir: tempDir, + Filename: "test.log", + } + + logger, err := NewFileLogger(config) + if err != nil { + t.Fatalf("Failed to create file logger: %v", err) + } + defer logger.Close() + + t.Run("basic logging", func(t *testing.T) { + logger.Info("test message") + logger.Error("error message with %s", "formatting") + + // 读取日志文件 + logPath := filepath.Join(tempDir, "test.log") + content, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("Failed to read log file: %v", err) + } + + output := string(content) + if !strings.Contains(output, "INFO test message") { + t.Error("Expected info message in log file") + } + if !strings.Contains(output, "ERROR error message with formatting") { + t.Error("Expected formatted error message in log file") + } + }) + + t.Run("log rotation", func(t *testing.T) { + // 写入大量数据触发轮转 + longMessage := strings.Repeat("a", 200) + for i := 0; i < 10; i++ { + logger.Info("Long message %d: %s", i, longMessage) + } + + // 检查是否创建了备份文件 + logPath := filepath.Join(tempDir, "test.log") + backupPath := filepath.Join(tempDir, "test.log.1") + + if _, err := os.Stat(logPath); os.IsNotExist(err) { + t.Error("Main log file should exist") + } + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + t.Error("Backup log file should exist after rotation") + } + }) +} + +func TestMultiLogger(t *testing.T) { + var buf1, buf2 bytes.Buffer + logger1 := NewConsoleLogger(&buf1) + logger2 := NewConsoleLogger(&buf2) + + multiLogger := NewMultiLogger(logger1, logger2) + multiLogger.SetLevel(INFO) + + multiLogger.Info("test message") + multiLogger.Error("error message") + + // 检查两个logger都收到了消息 + output1 := buf1.String() + output2 := buf2.String() + + if !strings.Contains(output1, "INFO test message") { + t.Error("Expected info message in first logger") + } + if !strings.Contains(output1, "ERROR error message") { + t.Error("Expected error message in first logger") + } + if !strings.Contains(output2, "INFO test message") { + t.Error("Expected info message in second logger") + } + if !strings.Contains(output2, "ERROR error message") { + t.Error("Expected error message in second logger") + } +} + +func TestFileLoggerRotation(t *testing.T) { + tempDir := t.TempDir() + + config := &LoggerConfig{ + Level: DEBUG, + MaxSize: 100, // Very small for testing + MaxBackups: 2, + LogDir: tempDir, + Filename: "rotation_test.log", + } + + logger, err := NewFileLogger(config) + if err != nil { + t.Fatalf("Failed to create file logger: %v", err) + } + defer logger.Close() + + // 写入足够的数据触发多次轮转 + for i := 0; i < 20; i++ { + logger.Info("Message %d: %s", i, strings.Repeat("x", 50)) + } + + // 检查文件存在性 + logPath := filepath.Join(tempDir, "rotation_test.log") + backup1Path := filepath.Join(tempDir, "rotation_test.log.1") + backup2Path := filepath.Join(tempDir, "rotation_test.log.2") + backup3Path := filepath.Join(tempDir, "rotation_test.log.3") + + if _, err := os.Stat(logPath); os.IsNotExist(err) { + t.Error("Main log file should exist") + } + if _, err := os.Stat(backup1Path); os.IsNotExist(err) { + t.Error("First backup should exist") + } + if _, err := os.Stat(backup2Path); os.IsNotExist(err) { + t.Error("Second backup should exist") + } + // 第三个备份不应该存在(MaxBackups=2) + if _, err := os.Stat(backup3Path); !os.IsNotExist(err) { + t.Error("Third backup should not exist (exceeds MaxBackups)") + } +} + +func TestGlobalLoggerFunctions(t *testing.T) { + // 这个测试比较简单,主要确保全局函数不会panic + Debug("debug message") + Info("info message") + Warn("warn message") + Error("error message") + + SetLevel(ERROR) + + // 这些调用不应该panic + Debug("filtered debug") + Info("filtered info") + Error("visible error") +} + +func TestFileLoggerErrorHandling(t *testing.T) { + t.Run("invalid directory", func(t *testing.T) { + // 使用一个真正无效的路径 + config := &LoggerConfig{ + Level: INFO, + MaxSize: 1024, + MaxBackups: 3, + LogDir: string([]byte{0}), // 无效的路径字符 + Filename: "test.log", + } + + _, err := NewFileLogger(config) + if err == nil { + t.Error("Expected error when creating logger with invalid directory") + } + }) +} + +func TestLoggerFormatting(t *testing.T) { + var buf bytes.Buffer + logger := NewConsoleLogger(&buf) + logger.SetLevel(DEBUG) + + // 测试时间戳格式 + logger.Info("test message") + + output := buf.String() + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) == 0 { + t.Fatal("Expected at least one log line") + } + + // 检查格式:[HH:MM:SS] LEVEL message + line := lines[0] + if !strings.Contains(line, "INFO test message") { + t.Errorf("Expected 'INFO test message' in output, got: %s", line) + } + + // 检查时间戳格式(简单检查) + if !strings.HasPrefix(line, "[") { + t.Error("Expected log line to start with timestamp in brackets") + } +} \ No newline at end of file diff --git a/Go_Updater/main.go b/Go_Updater/main.go new file mode 100644 index 0000000..45459d6 --- /dev/null +++ b/Go_Updater/main.go @@ -0,0 +1,1035 @@ +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 new file mode 100644 index 0000000..9f708d9 --- /dev/null +++ b/Go_Updater/version/manager.go @@ -0,0 +1,178 @@ +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 new file mode 100644 index 0000000..41a3f7c --- /dev/null +++ b/Go_Updater/version/manager_test.go @@ -0,0 +1,366 @@ +package version + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestParseVersion(t *testing.T) { + tests := []struct { + input string + expected *ParsedVersion + hasError bool + }{ + {"4.4.0.0", &ParsedVersion{4, 4, 0, 0}, false}, + {"4.4.1.3", &ParsedVersion{4, 4, 1, 3}, false}, + {"1.2.3", &ParsedVersion{1, 2, 3, 0}, false}, + {"invalid", nil, true}, + {"1.2", nil, true}, + {"1.2.3.4.5", nil, true}, + } + + for _, test := range tests { + result, err := ParseVersion(test.input) + + if test.hasError { + if err == nil { + t.Errorf("Expected error for input %s, but got none", test.input) + } + continue + } + + if err != nil { + t.Errorf("Unexpected error for input %s: %v", test.input, err) + continue + } + + if result.Major != test.expected.Major || + result.Minor != test.expected.Minor || + result.Patch != test.expected.Patch || + result.Beta != test.expected.Beta { + t.Errorf("For input %s, expected %+v, got %+v", test.input, test.expected, result) + } + } +} + +func TestToDisplayVersion(t *testing.T) { + tests := []struct { + version *ParsedVersion + expected string + }{ + {&ParsedVersion{4, 4, 0, 0}, "v4.4.0"}, + {&ParsedVersion{4, 4, 1, 3}, "v4.4.1-beta3"}, + {&ParsedVersion{1, 2, 3, 0}, "v1.2.3"}, + {&ParsedVersion{1, 2, 3, 5}, "v1.2.3-beta5"}, + } + + for _, test := range tests { + result := test.version.ToDisplayVersion() + if result != test.expected { + t.Errorf("For version %+v, expected %s, got %s", test.version, test.expected, result) + } + } +} + +func TestGetChannel(t *testing.T) { + tests := []struct { + version *ParsedVersion + expected string + }{ + {&ParsedVersion{4, 4, 0, 0}, "stable"}, + {&ParsedVersion{4, 4, 1, 3}, "beta"}, + {&ParsedVersion{1, 2, 3, 0}, "stable"}, + {&ParsedVersion{1, 2, 3, 1}, "beta"}, + } + + for _, test := range tests { + result := test.version.GetChannel() + if result != test.expected { + t.Errorf("For version %+v, expected channel %s, got %s", test.version, test.expected, result) + } + } +} + +func TestIsNewer(t *testing.T) { + tests := []struct { + v1 *ParsedVersion + v2 *ParsedVersion + expected bool + }{ + {&ParsedVersion{4, 4, 1, 0}, &ParsedVersion{4, 4, 0, 0}, true}, + {&ParsedVersion{4, 4, 0, 0}, &ParsedVersion{4, 4, 1, 0}, false}, + {&ParsedVersion{4, 4, 1, 3}, &ParsedVersion{4, 4, 1, 2}, true}, + {&ParsedVersion{4, 4, 1, 2}, &ParsedVersion{4, 4, 1, 3}, false}, + {&ParsedVersion{4, 4, 1, 0}, &ParsedVersion{4, 4, 1, 0}, false}, + } + + for _, test := range tests { + result := test.v1.IsNewer(test.v2) + if result != test.expected { + t.Errorf("For %+v.IsNewer(%+v), expected %t, got %t", test.v1, test.v2, test.expected, result) + } + } +} + +func TestLoadVersionFromFile(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "version_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create resources directory + resourcesDir := filepath.Join(tempDir, "resources") + if err := os.MkdirAll(resourcesDir, 0755); err != nil { + t.Fatal(err) + } + + // Create test version file + versionData := VersionInfo{ + MainVersion: "4.4.1.3", + VersionInfo: map[string]map[string][]string{ + "4.4.1.3": { + "修复BUG": {"移除崩溃弹窗机制"}, + }, + }, + } + + data, err := json.Marshal(versionData) + if err != nil { + t.Fatal(err) + } + + versionFile := filepath.Join(resourcesDir, "version.json") + if err := os.WriteFile(versionFile, data, 0644); err != nil { + t.Fatal(err) + } + + // Create version manager with custom executable directory and logger + vm := NewVersionManager() + vm.executableDir = tempDir + + // Test loading version + result, err := vm.LoadVersionFromFile() + if err != nil { + t.Fatalf("Failed to load version: %v", err) + } + + if result.MainVersion != "4.4.1.3" { + t.Errorf("Expected main version 4.4.1.3, got %s", result.MainVersion) + } + + if len(result.VersionInfo) != 1 { + t.Errorf("Expected 1 version info entry, got %d", len(result.VersionInfo)) + } +} + +func TestLoadVersionFromFileNotFound(t *testing.T) { + // Create a temporary directory without version file + tempDir, err := os.MkdirTemp("", "version_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create version manager with custom executable directory and logger + vm := NewVersionManager() + vm.executableDir = tempDir + + // Test loading version (should now return default version instead of error) + result, err := vm.LoadVersionFromFile() + if err != nil { + t.Errorf("Expected no error with fallback mechanism, but got: %v", err) + } + + // Should return default version + if result.MainVersion != "0.0.0.0" { + t.Errorf("Expected default version 0.0.0.0, got %s", result.MainVersion) + } + + if result.VersionInfo == nil { + t.Error("Expected initialized VersionInfo map, got nil") + } +} + +func TestLoadVersionWithDefault(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "version_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create version manager with custom executable directory + vm := NewVersionManager() + vm.executableDir = tempDir + + // Test loading version with default (no file exists) + result := vm.LoadVersionWithDefault() + if result == nil { + t.Fatal("Expected non-nil result from LoadVersionWithDefault") + } + + if result.MainVersion != "0.0.0.0" { + t.Errorf("Expected default version 0.0.0.0, got %s", result.MainVersion) + } + + if result.VersionInfo == nil { + t.Error("Expected initialized VersionInfo map, got nil") + } +} + +func TestLoadVersionWithDefaultValidFile(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "version_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create resources directory + resourcesDir := filepath.Join(tempDir, "resources") + if err := os.MkdirAll(resourcesDir, 0755); err != nil { + t.Fatal(err) + } + + // Create test version file + versionData := VersionInfo{ + MainVersion: "4.4.1.3", + VersionInfo: map[string]map[string][]string{ + "4.4.1.3": { + "修复BUG": {"移除崩溃弹窗机制"}, + }, + }, + } + + data, err := json.Marshal(versionData) + if err != nil { + t.Fatal(err) + } + + versionFile := filepath.Join(resourcesDir, "version.json") + if err := os.WriteFile(versionFile, data, 0644); err != nil { + t.Fatal(err) + } + + // Create version manager with custom executable directory + vm := NewVersionManager() + vm.executableDir = tempDir + + // Test loading version with default (valid file exists) + result := vm.LoadVersionWithDefault() + if result == nil { + t.Fatal("Expected non-nil result from LoadVersionWithDefault") + } + + if result.MainVersion != "4.4.1.3" { + t.Errorf("Expected version 4.4.1.3, got %s", result.MainVersion) + } + + if len(result.VersionInfo) != 1 { + t.Errorf("Expected 1 version info entry, got %d", len(result.VersionInfo)) + } +} + +func TestLoadVersionFromFileCorrupted(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "version_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create resources directory + resourcesDir := filepath.Join(tempDir, "resources") + if err := os.MkdirAll(resourcesDir, 0755); err != nil { + t.Fatal(err) + } + + // Create corrupted version file + versionFile := filepath.Join(resourcesDir, "version.json") + if err := os.WriteFile(versionFile, []byte("invalid json content"), 0644); err != nil { + t.Fatal(err) + } + + // Create version manager with custom executable directory + vm := NewVersionManager() + vm.executableDir = tempDir + + // Test loading version (should return default version for corrupted file) + result, err := vm.LoadVersionFromFile() + if err != nil { + t.Errorf("Expected no error with fallback mechanism for corrupted file, but got: %v", err) + } + + // Should return default version + if result.MainVersion != "0.0.0.0" { + t.Errorf("Expected default version 0.0.0.0 for corrupted file, got %s", result.MainVersion) + } + + if result.VersionInfo == nil { + t.Error("Expected initialized VersionInfo map for corrupted file, got nil") + } +} + +func TestLoadVersionWithDefaultCorrupted(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "version_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create resources directory + resourcesDir := filepath.Join(tempDir, "resources") + if err := os.MkdirAll(resourcesDir, 0755); err != nil { + t.Fatal(err) + } + + // Create corrupted version file + versionFile := filepath.Join(resourcesDir, "version.json") + if err := os.WriteFile(versionFile, []byte("invalid json content"), 0644); err != nil { + t.Fatal(err) + } + + // Create version manager with custom executable directory + vm := NewVersionManager() + vm.executableDir = tempDir + + // Test loading version with default (corrupted file) + result := vm.LoadVersionWithDefault() + if result == nil { + t.Fatal("Expected non-nil result from LoadVersionWithDefault for corrupted file") + } + + if result.MainVersion != "0.0.0.0" { + t.Errorf("Expected default version 0.0.0.0 for corrupted file, got %s", result.MainVersion) + } + + if result.VersionInfo == nil { + t.Error("Expected initialized VersionInfo map for corrupted file, got nil") + } +} + +func TestCreateDefaultVersion(t *testing.T) { + vm := NewVersionManager() + + result := vm.createDefaultVersion() + if result == nil { + t.Fatal("Expected non-nil result from createDefaultVersion") + } + + if result.MainVersion != "0.0.0.0" { + t.Errorf("Expected default version 0.0.0.0, got %s", result.MainVersion) + } + + if result.VersionInfo == nil { + t.Error("Expected initialized VersionInfo map, got nil") + } + + if len(result.VersionInfo) != 0 { + t.Errorf("Expected empty VersionInfo map, got %d entries", len(result.VersionInfo)) + } +} \ No newline at end of file diff --git a/Go_Updater/version/version.go b/Go_Updater/version/version.go new file mode 100644 index 0000000..7eda7f7 --- /dev/null +++ b/Go_Updater/version/version.go @@ -0,0 +1,19 @@ +package version + +import ( + "runtime" +) + +var ( + // Version 应用程序的当前版本 + Version = "1.0.0" + + // BuildTime 在构建时设置 + BuildTime = "unknown" + + // GitCommit 在构建时设置 + GitCommit = "unknown" + + // GoVersion 用于构建的 Go 版本 + GoVersion = runtime.Version() +) diff --git a/app/core/__init__.py b/app/core/__init__.py index 8a9058d..9240bc7 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -38,6 +38,7 @@ from .config import ( GeneralSubConfig, Config, ) +from .logger import logger from .main_info_bar import MainInfoBar from .network import Network from .sound_player import SoundPlayer @@ -52,6 +53,7 @@ __all__ = [ "MaaPlanConfig", "GeneralConfig", "GeneralSubConfig", + "logger", "MainInfoBar", "Network", "SoundPlayer", diff --git a/app/core/config.py b/app/core/config.py index fe19472..d734263 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -25,7 +25,6 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtCore import Signal import argparse import sqlite3 @@ -53,6 +52,7 @@ from qfluentwidgets import ( from urllib.parse import urlparse from typing import Union, Dict, List +from .logger import logger from .network import Network @@ -205,9 +205,6 @@ class GlobalConfig(LQConfig): self.start_IfSelfStart = ConfigItem( "Start", "IfSelfStart", False, BoolValidator() ) - self.start_IfRunDirectly = ConfigItem( - "Start", "IfRunDirectly", False, BoolValidator() - ) self.start_IfMinimizeDirectly = ConfigItem( "Start", "IfMinimizeDirectly", False, BoolValidator() ) @@ -275,79 +272,53 @@ class QueueConfig(LQConfig): def __init__(self) -> None: super().__init__() - self.queueSet_Name = ConfigItem("QueueSet", "Name", "") - self.queueSet_Enabled = ConfigItem( - "QueueSet", "Enabled", False, BoolValidator() + self.QueueSet_Name = ConfigItem("QueueSet", "Name", "") + self.QueueSet_TimeEnabled = ConfigItem( + "QueueSet", "TimeEnabled", False, BoolValidator() ) - self.queueSet_AfterAccomplish = OptionsConfigItem( + self.QueueSet_StartUpEnabled = ConfigItem( + "QueueSet", "StartUpEnabled", False, BoolValidator() + ) + self.QueueSet_AfterAccomplish = OptionsConfigItem( "QueueSet", "AfterAccomplish", "NoAction", OptionsValidator( - ["NoAction", "KillSelf", "Sleep", "Hibernate", "Shutdown"] + [ + "NoAction", + "KillSelf", + "Sleep", + "Hibernate", + "Shutdown", + "ShutdownForce", + ] ), ) - self.time_TimeEnabled_0 = ConfigItem( - "Time", "TimeEnabled_0", False, BoolValidator() - ) - self.time_TimeSet_0 = ConfigItem("Time", "TimeSet_0", "00:00") + self.config_item_dict: dict[str, Dict[str, ConfigItem]] = { + "Queue": {}, + "Time": {}, + } - self.time_TimeEnabled_1 = ConfigItem( - "Time", "TimeEnabled_1", False, BoolValidator() - ) - self.time_TimeSet_1 = ConfigItem("Time", "TimeSet_1", "00:00") + for i in range(10): - self.time_TimeEnabled_2 = ConfigItem( - "Time", "TimeEnabled_2", False, BoolValidator() - ) - self.time_TimeSet_2 = ConfigItem("Time", "TimeSet_2", "00:00") + 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}", "禁用" + ) - self.time_TimeEnabled_3 = ConfigItem( - "Time", "TimeEnabled_3", False, BoolValidator() - ) - self.time_TimeSet_3 = ConfigItem("Time", "TimeSet_3", "00:00") - - self.time_TimeEnabled_4 = ConfigItem( - "Time", "TimeEnabled_4", False, BoolValidator() - ) - self.time_TimeSet_4 = ConfigItem("Time", "TimeSet_4", "00:00") - - self.time_TimeEnabled_5 = ConfigItem( - "Time", "TimeEnabled_5", False, BoolValidator() - ) - self.time_TimeSet_5 = ConfigItem("Time", "TimeSet_5", "00:00") - - self.time_TimeEnabled_6 = ConfigItem( - "Time", "TimeEnabled_6", False, BoolValidator() - ) - self.time_TimeSet_6 = ConfigItem("Time", "TimeSet_6", "00:00") - - self.time_TimeEnabled_7 = ConfigItem( - "Time", "TimeEnabled_7", False, BoolValidator() - ) - self.time_TimeSet_7 = ConfigItem("Time", "TimeSet_7", "00:00") - - self.time_TimeEnabled_8 = ConfigItem( - "Time", "TimeEnabled_8", False, BoolValidator() - ) - self.time_TimeSet_8 = ConfigItem("Time", "TimeSet_8", "00:00") - - self.time_TimeEnabled_9 = ConfigItem( - "Time", "TimeEnabled_9", False, BoolValidator() - ) - self.time_TimeSet_9 = ConfigItem("Time", "TimeSet_9", "00:00") - - self.queue_Member_1 = OptionsConfigItem("Queue", "Member_1", "禁用") - self.queue_Member_2 = OptionsConfigItem("Queue", "Member_2", "禁用") - self.queue_Member_3 = OptionsConfigItem("Queue", "Member_3", "禁用") - self.queue_Member_4 = OptionsConfigItem("Queue", "Member_4", "禁用") - self.queue_Member_5 = OptionsConfigItem("Queue", "Member_5", "禁用") - self.queue_Member_6 = OptionsConfigItem("Queue", "Member_6", "禁用") - self.queue_Member_7 = OptionsConfigItem("Queue", "Member_7", "禁用") - self.queue_Member_8 = OptionsConfigItem("Queue", "Member_8", "禁用") - self.queue_Member_9 = OptionsConfigItem("Queue", "Member_9", "禁用") - self.queue_Member_10 = OptionsConfigItem("Queue", "Member_10", "禁用") + 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" @@ -388,10 +359,7 @@ class MaaConfig(LQConfig): "RunSet", "RoutineTimeLimit", 10, RangeValidator(1, 1024) ) self.RunSet_AnnihilationWeeklyLimit = ConfigItem( - "RunSet", "AnnihilationWeeklyLimit", False, BoolValidator() - ) - self.RunSet_AutoUpdateMaa = ConfigItem( - "RunSet", "AutoUpdateMaa", False, BoolValidator() + "RunSet", "AnnihilationWeeklyLimit", True, BoolValidator() ) def get_name(self) -> str: @@ -411,16 +379,32 @@ class MaaUserConfig(LQConfig): ) self.Info_StageMode = ConfigItem("Info", "StageMode", "固定") self.Info_Server = OptionsConfigItem( - "Info", "Server", "Official", OptionsValidator(["Official", "Bilibili"]) + "Info", + "Server", + "Official", + OptionsValidator( + ["Official", "Bilibili", "YoStarEN", "YoStarJP", "YoStarKR", "txwy"] + ), ) self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator()) self.Info_RemainedDay = ConfigItem( "Info", "RemainedDay", -1, RangeValidator(-1, 1024) ) - self.Info_Annihilation = ConfigItem( - "Info", "Annihilation", False, BoolValidator() + self.Info_Annihilation = OptionsConfigItem( + "Info", + "Annihilation", + "Annihilation", + OptionsValidator( + [ + "Close", + "Annihilation", + "Chernobog@Annihilation", + "LungmenOutskirts@Annihilation", + "LungmenDowntown@Annihilation", + ] + ), ) - self.Info_Routine = ConfigItem("Info", "Routine", False, BoolValidator()) + self.Info_Routine = ConfigItem("Info", "Routine", True, BoolValidator()) self.Info_InfrastMode = OptionsConfigItem( "Info", "InfrastMode", @@ -581,9 +565,16 @@ class MaaPlanConfig(LQConfig): """获取当前的计划表配置项""" if self.get(self.Info_Mode) == "ALL": + return self.config_item_dict["ALL"][name] + elif self.get(self.Info_Mode) == "Weekly": - today = datetime.now().strftime("%A") + + dt = datetime.now() + if dt.time() < datetime.min.time().replace(hour=4): + dt = dt - timedelta(days=1) + today = dt.strftime("%A") + if today in self.config_item_dict: return self.config_item_dict[today][name] else: @@ -597,7 +588,7 @@ class GeneralConfig(LQConfig): super().__init__() self.Script_Name = ConfigItem("Script", "Name", "") - self.Script_RootPath = ConfigItem("Script", "RootPath", ".", FolderValidator()) + self.Script_RootPath = ConfigItem("Script", "RootPath", ".", FileValidator()) self.Script_ScriptPath = ConfigItem( "Script", "ScriptPath", ".", FileValidator() ) @@ -614,6 +605,12 @@ class GeneralConfig(LQConfig): "所有文件 (*)", 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( @@ -707,7 +704,7 @@ class GeneralSubConfig(LQConfig): class AppConfig(GlobalConfig): - VERSION = "4.4.0.0" + VERSION = "4.4.1.0" stage_refreshed = Signal() PASSWORD_refreshed = Signal() @@ -717,8 +714,8 @@ class AppConfig(GlobalConfig): 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.app_path = Path(sys.argv[0]).resolve().parent + self.app_path_sys = Path(sys.argv[0]).resolve() self.log_path = self.app_path / "debug/AUTO_MAA.log" self.database_path = self.app_path / "data/data.db" @@ -728,7 +725,7 @@ class AppConfig(GlobalConfig): self.main_window = None self.PASSWORD = "" self.running_list = [] - self.silence_list = [] + self.silence_dict: Dict[Path, datetime] = {} self.info_bar_list = [] self.stage_dict = { "ALL": {"value": [], "text": []}, @@ -744,7 +741,9 @@ class AppConfig(GlobalConfig): self.if_ignore_silence = False self.if_database_opened = False - self.search_member() + self.initialize() + + self.search_script() self.search_queue() parser = argparse.ArgumentParser( @@ -760,12 +759,15 @@ class AppConfig(GlobalConfig): parser.add_argument( "--config", nargs="+", - choices=list(self.member_dict.keys()) + list(self.queue_dict.keys()), + choices=list(self.script_dict.keys()) + list(self.queue_dict.keys()), help="指定需要运行哪些配置项", ) self.args = parser.parse_args() - self.initialize() + logger.info( + f"运行模式: {'图形化界面' if self.args.mode == 'gui' else '命令行界面'},配置项: {self.args.config if self.args.config else '启动时运行的调度队列'}", + module="配置管理", + ) def initialize(self) -> None: """初始化程序配置管理模块""" @@ -781,7 +783,7 @@ class AppConfig(GlobalConfig): self.init_logger() self.check_data() - logger.info("程序初始化完成") + logger.info("程序初始化完成", module="配置管理") def init_logger(self) -> None: """初始化日志记录器""" @@ -789,7 +791,7 @@ class AppConfig(GlobalConfig): logger.add( sink=self.log_path, level="DEBUG", - format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {message}", + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[module]} | {message}", enqueue=True, backtrace=True, diagnose=True, @@ -797,102 +799,22 @@ class AppConfig(GlobalConfig): retention="1 month", compression="zip", ) - logger.info("") - logger.info("===================================") - logger.info("AUTO_MAA 主程序") - logger.info(f"版本号: v{self.VERSION}") - logger.info(f"根目录: {self.app_path}") - logger.info( - f"运行模式: {'图形化界面' if self.args.mode == 'gui' else '命令行界面'}" + 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.info("===================================") - logger.info("日志记录器初始化完成") - - def get_stage(self) -> None: - """从MAA服务器获取活动关卡信息""" - - 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"] - ) - else: - logger.warning( - f"无法从MAA服务器获取活动关卡信息:{network_result['error_message']}" - ) - stage_infos = [] - - ss_stage_dict = {"value": [], "text": []} - - for stage_info in stage_infos: - - 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" - ) - ): - 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]}, - ] - - for day in range(0, 8): - - today_stage_dict = {"value": [], "text": []} - - for stage_info in stage_daily_info: - - 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"]) - - 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"], - } - - self.stage_refreshed.emit() - - def server_date(self) -> date: - """获取当前的服务器日期""" - - dt = datetime.now() - if dt.time() < datetime.min.time().replace(hour=4): - dt = dt - timedelta(days=1) - return dt.date() + 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="配置管理") def check_data(self) -> None: """检查用户数据文件并处理数据文件版本更新""" @@ -902,7 +824,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.7",)) + cur.execute("INSERT INTO version VALUES(?)", ("v1.8",)) db.commit() cur.close() db.close() @@ -913,12 +835,12 @@ class AppConfig(GlobalConfig): cur.execute("SELECT * FROM version WHERE True") version = cur.fetchall() - if version[0][0] != "v1.7": - logger.info("数据文件版本更新开始") + if version[0][0] != "v1.8": + logger.info("数据文件版本更新开始", module="配置管理") if_streaming = False # v1.4-->v1.5 if version[0][0] == "v1.4" or if_streaming: - logger.info("数据文件版本更新:v1.4-->v1.5") + logger.info("数据文件版本更新:v1.4-->v1.5", module="配置管理") if_streaming = True member_dict: Dict[str, Dict[str, Union[str, Path]]] = {} @@ -1043,10 +965,9 @@ class AppConfig(GlobalConfig): 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") + 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",)) @@ -1078,7 +999,7 @@ class AppConfig(GlobalConfig): pass # v1.6-->v1.7 if version[0][0] == "v1.6" or if_streaming: - logger.info("数据文件版本更新:v1.6-->v1.7") + logger.info("数据文件版本更新:v1.6-->v1.7", module="配置管理") if_streaming = True if (self.app_path / "config/MaaConfig").exists(): @@ -1145,15 +1066,143 @@ class AppConfig(GlobalConfig): 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="配置管理") + if_streaming = True + + if (self.app_path / "config/QueueConfig").exists(): + for QueueConfig in (self.app_path / "config/QueueConfig").glob( + "*.json" + ): + with QueueConfig.open(encoding="utf-8") as f: + queue_config = json.load(f) + + queue_config["QueueSet"]["TimeEnabled"] = queue_config[ + "QueueSet" + ]["Enabled"] + + for i in range(10): + queue_config["Queue"][f"Script_{i}"] = queue_config[ + "Queue" + ][f"Member_{i + 1}"] + queue_config["Time"][f"Enabled_{i}"] = queue_config["Time"][ + f"TimeEnabled_{i}" + ] + queue_config["Time"][f"Set_{i}"] = queue_config["Time"][ + f"TimeSet_{i}" + ] + + with QueueConfig.open("w", encoding="utf-8") as f: + json.dump(queue_config, f, ensure_ascii=False, indent=4) + + cur.execute("DELETE FROM version WHERE v = ?", ("v1.7",)) + cur.execute("INSERT INTO version VALUES(?)", ("v1.8",)) + db.commit() cur.close() db.close() - logger.info("数据文件版本更新完成") + logger.success("数据文件版本更新完成", module="配置管理") - def search_member(self) -> None: - """搜索所有脚本实例""" + def get_stage(self) -> None: + """从MAA服务器更新活动关卡信息""" - self.member_dict: Dict[ + 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"] + ) + else: + logger.warning( + f"无法从MAA服务器获取活动关卡信息:{network_result['error_message']}", + module="配置管理", + ) + stage_infos = [] + + ss_stage_dict = {"value": [], "text": []} + + for stage_info in stage_infos: + + 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" + ) + ): + 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]}, + ] + + for day in range(0, 8): + + today_stage_dict = {"value": [], "text": []} + + for stage_info in stage_daily_info: + + 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"]) + + 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"], + } + + self.stage_refreshed.emit() + + logger.success("活动关卡信息更新完成", module="配置管理") + + def server_date(self) -> date: + """ + 获取当前的服务器日期 + + :return: 当前的服务器日期 + :rtype: date + """ + + dt = datetime.now() + if dt.time() < datetime.min.time().replace(hour=4): + dt = dt - timedelta(days=1) + return dt.date() + + def search_script(self) -> None: + """更新脚本实例配置信息""" + + logger.info("开始搜索并读入脚本实例配置", module="配置管理") + self.script_dict: Dict[ str, Dict[ str, @@ -1173,7 +1222,7 @@ class AppConfig(GlobalConfig): maa_config.load(maa_dir / "config.json", maa_config) maa_config.save() - self.member_dict[maa_dir.name] = { + self.script_dict[maa_dir.name] = { "Type": "Maa", "Path": maa_dir, "Config": maa_config, @@ -1187,21 +1236,34 @@ class AppConfig(GlobalConfig): general_config.load(general_dir / "config.json", general_config) general_config.save() - self.member_dict[general_dir.name] = { + self.script_dict[general_dir.name] = { "Type": "General", "Path": general_dir, "Config": general_config, "SubData": None, } - self.member_dict = dict( - sorted(self.member_dict.items(), key=lambda x: int(x[0][3:])) + 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.member_dict[name]["Path"] / "UserData").iterdir(): + for user_dir in (Config.script_dict[name]["Path"] / "UserData").iterdir(): if user_dir.is_dir(): user_config = MaaUserConfig() @@ -1210,14 +1272,29 @@ class AppConfig(GlobalConfig): user_dict[user_dir.stem] = {"Path": user_dir, "Config": user_config} - self.member_dict[name]["UserData"] = dict( + 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.member_dict[name]["Path"] / "SubData").iterdir(): + for sub_dir in (Config.script_dict[name]["Path"] / "SubData").iterdir(): if sub_dir.is_dir(): sub_config = GeneralSubConfig() @@ -1226,12 +1303,19 @@ class AppConfig(GlobalConfig): user_dict[sub_dir.stem] = {"Path": sub_dir, "Config": sub_config} - self.member_dict[name]["SubData"] = dict( + 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(): @@ -1252,8 +1336,15 @@ class AppConfig(GlobalConfig): 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]]] = {} @@ -1273,50 +1364,73 @@ class AppConfig(GlobalConfig): 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(): - if queue["Config"].get(queue["Config"].queue_Member_1) == old: - queue["Config"].set(queue["Config"].queue_Member_1, new) - if queue["Config"].get(queue["Config"].queue_Member_2) == old: - queue["Config"].set(queue["Config"].queue_Member_2, new) - if queue["Config"].get(queue["Config"].queue_Member_3) == old: - queue["Config"].set(queue["Config"].queue_Member_3, new) - if queue["Config"].get(queue["Config"].queue_Member_4) == old: - queue["Config"].set(queue["Config"].queue_Member_4, new) - if queue["Config"].get(queue["Config"].queue_Member_5) == old: - queue["Config"].set(queue["Config"].queue_Member_5, new) - if queue["Config"].get(queue["Config"].queue_Member_6) == old: - queue["Config"].set(queue["Config"].queue_Member_6, new) - if queue["Config"].get(queue["Config"].queue_Member_7) == old: - queue["Config"].set(queue["Config"].queue_Member_7, new) - if queue["Config"].get(queue["Config"].queue_Member_8) == old: - queue["Config"].set(queue["Config"].queue_Member_8, new) - if queue["Config"].get(queue["Config"].queue_Member_9) == old: - queue["Config"].set(queue["Config"].queue_Member_9, new) - if queue["Config"].get(queue["Config"].queue_Member_10) == old: - queue["Config"].set(queue["Config"].queue_Member_10, new) + 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: - """修改脚本管理所有下属用户的计划表配置参数""" + """ + 修改脚本管理所有下属用户的计划表配置参数 - for member in self.member_dict.values(): + :param old: 旧计划表名 + :param new: 新计划表名 + """ - for user in member["UserData"].values(): + 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.member_dict[name]["UserData"][user]["Config"] + user_config = self.script_dict[name]["UserData"][user]["Config"] user_config.set( user_config.Info_RemainedDay, info["Config"]["Info"]["RemainedDay"] @@ -1345,14 +1459,25 @@ class AppConfig(GlobalConfig): 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.member_dict[name]["SubData"][sub]["Config"] + sub_config = self.script_dict[name]["SubData"][sub]["Config"] sub_config.set( sub_config.Info_RemainedDay, info["Config"]["Info"]["RemainedDay"] @@ -1366,27 +1491,62 @@ class AppConfig(GlobalConfig): 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="配置管理") else: logger.warning(f"保存历史记录时未找到调度队列: {key}") def save_maa_log(self, log_path: Path, logs: list, maa_result: str) -> bool: - """保存MAA日志并生成对应统计数据""" + """ + 保存MAA日志并生成对应统计数据 + + :param log_path: 日志文件保存路径 + :type log_path: Path + :param logs: 日志内容列表 + :type logs: list + :param maa_result: MAA 结果 + :type maa_result: str + :return: 是否包含6★招募 + :rtype: bool + """ + + logger.info( + f"开始处理 MAA 日志,日志长度: {len(logs)},日志标记:{maa_result}", + module="配置管理", + ) data: Dict[str, Union[str, Dict[str, Union[int, dict]]]] = { "recruit_statistics": defaultdict(int), @@ -1431,15 +1591,17 @@ class AppConfig(GlobalConfig): # 查找所有Fight任务的开始和结束位置 fight_tasks = [] for i, line in enumerate(logs): - if "开始任务: Fight" in line: + if "开始任务: Fight" in line or "开始任务: 刷理智" in line: # 查找对应的任务结束位置 end_index = -1 for j in range(i + 1, len(logs)): - if "完成任务: Fight" in logs[j]: + if "完成任务: Fight" in logs[j] or "完成任务: 刷理智" in logs[j]: end_index = j break # 如果遇到新的Fight任务开始,则当前任务没有正常结束 - if j < len(logs) and "开始任务: Fight" in logs[j]: + if j < len(logs) and ( + "开始任务: Fight" in logs[j] or "开始任务: 刷理智" in logs[j] + ): break # 如果找到了结束位置,记录这个任务的范围 @@ -1505,12 +1667,23 @@ 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.info(f"处理完成:{log_path}") + logger.success(f"MAA 日志统计完成,日志路径:{log_path}", module="配置管理") return if_six_star def save_general_log(self, log_path: Path, logs: list, general_result: str) -> None: - """保存通用日志并生成对应统计数据""" + """ + 保存通用日志并生成对应统计数据 + + :param log_path: 日志文件保存路径 + :param logs: 日志内容列表 + :param general_result: 待保存的日志结果信息 + """ + + logger.info( + f"开始处理通用日志,日志长度: {len(logs)},日志标记:{general_result}", + module="配置管理", + ) data: Dict[str, str] = {"general_result": general_result} @@ -1521,10 +1694,23 @@ 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.info(f"处理完成:{log_path}") + logger.success( + f"通用日志统计完成,日志路径:{log_path.with_suffix('.log')}", + module="配置管理", + ) def merge_statistic_info(self, statistic_path_list: List[Path]) -> dict: - """合并指定数据统计信息文件""" + """ + 合并指定数据统计信息文件 + + :param statistic_path_list: 需要合并的统计信息文件路径列表 + :return: 合并后的统计信息字典 + """ + + logger.info( + f"开始合并统计信息文件,共计 {len(statistic_path_list)} 个文件", + module="配置管理", + ) data = {"index": {}} @@ -1591,12 +1777,28 @@ class AppConfig(GlobalConfig): data["index"] = [data["index"][_] for _ in sorted(data["index"])] + logger.success( + f"统计信息合并完成,共计 {len(data['index'])} 条记录", module="配置管理" + ) + return {k: v for k, v in data.items() if v} def search_history( self, mode: str, start_date: datetime, end_date: datetime ) -> dict: - """搜索所有历史记录""" + """ + 搜索指定范围内的历史记录 + + :param mode: 合并模式(按日合并、按周合并、按月合并) + :param start_date: 开始日期 + :param end_date: 结束日期 + :return: 搜索到的历史记录字典 + """ + + logger.info( + f"开始搜索历史记录,合并模式:{mode},日期范围:{start_date} 至 {end_date}", + module="配置管理", + ) history_dict = {} @@ -1638,10 +1840,43 @@ class AppConfig(GlobalConfig): except ValueError: logger.warning(f"非日期格式的目录: {date_folder}") + logger.success( + f"历史记录搜索完成,共计 {len(history_dict)} 条记录", module="配置管理" + ) + return { k: v for k, v in sorted(history_dict.items(), key=lambda x: x[0], reverse=True) } + def clean_old_history(self): + """删除超过用户设定天数的历史记录文件(基于目录日期)""" + + if self.get(self.function_HistoryRetentionTime) == 0: + logger.info("历史记录永久保留,跳过历史记录清理", module="配置管理") + return + + logger.info("开始清理超过设定天数的历史记录", module="配置管理") + + deleted_count = 0 + + for date_folder in (self.app_path / "history").iterdir(): + if not date_folder.is_dir(): + continue # 只处理日期文件夹 + + try: + # 只检查 `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) + ): + shutil.rmtree(date_folder, ignore_errors=True) + deleted_count += 1 + logger.info(f"已删除超期日志目录: {date_folder}", module="配置管理") + except ValueError: + logger.warning(f"非日期格式的目录: {date_folder}", module="配置管理") + + logger.success(f"清理完成: {deleted_count} 个日期目录", module="配置管理") + Config = AppConfig() diff --git a/app/core/logger.py b/app/core/logger.py new file mode 100644 index 0000000..aa942dd --- /dev/null +++ b/app/core/logger.py @@ -0,0 +1,34 @@ +# AUTO_MAA:A MAA Multi Account Management and Automation Tool +# Copyright © 2024-2025 DLmaster361 + +# This file is part of AUTO_MAA. + +# AUTO_MAA is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published +# by the Free Software Foundation, either version 3 of the License, +# or (at your option) any later version. + +# AUTO_MAA is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with AUTO_MAA. If not, see . + +# Contact: DLmaster_361@163.com + +""" +AUTO_MAA +AUTO_MAA日志组件 +v4.4 +作者:DLmaster_361 +""" + +from loguru import logger as _logger + +# 设置日志 module 字段默认值 +logger = _logger.patch( + lambda record: record["extra"].setdefault("module", "未知模块") or True +) +logger.remove(0) diff --git a/app/core/main_info_bar.py b/app/core/main_info_bar.py index 715ff25..745050a 100644 --- a/app/core/main_info_bar.py +++ b/app/core/main_info_bar.py @@ -25,10 +25,10 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtCore import Qt from qfluentwidgets import InfoBar, InfoBarPosition +from .logger import logger from .config import Config from .sound_player import SoundPlayer @@ -46,20 +46,34 @@ class _MainInfoBar: 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("信息通知栏未设置父窗口") + logger.error("信息通知栏未设置父窗口", module="吐司通知栏") return None # 根据 mode 获取对应的 InfoBar 方法 info_bar_method = self.mode_mapping.get(mode) if not info_bar_method: - logger.error(f"未知的通知栏模式: {mode}") + logger.error(f"未知的通知栏模式: {mode}", module="吐司通知栏") return None if Config.main_window.isVisible(): + # 主窗口可见时直接推送通知 info_bar_method( title=title, content=content, @@ -69,6 +83,7 @@ class _MainInfoBar: duration=time, parent=Config.main_window, ) + elif if_force: # 如果主窗口不可见且强制推送,则录入消息队列等待窗口显示后推送 info_bar_item = { @@ -80,6 +95,11 @@ class _MainInfoBar: 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": diff --git a/app/core/network.py b/app/core/network.py index 003145a..9d63fe2 100644 --- a/app/core/network.py +++ b/app/core/network.py @@ -25,13 +25,15 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger 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): @@ -41,16 +43,27 @@ class NetworkThread(QThread): timeout = 10 backoff_factor = 0.1 - def __init__(self, mode: str, url: str, path: Path = None) -> None: + 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 @@ -65,7 +78,7 @@ class NetworkThread(QThread): self.loop = QEventLoop() - truststore.inject_into_ssl() + truststore.inject_into_ssl() # 信任系统证书 @logger.catch def run(self) -> None: @@ -75,9 +88,17 @@ class NetworkThread(QThread): 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数据""" + """ + 通过get方法获取json数据 + + :param url: 请求的URL + """ + + logger.info(f"子线程 {self.objectName()} 开始网络请求", module="网络请求子线程") response = None @@ -92,44 +113,123 @@ class NetworkThread(QThread): 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方法下载文件""" + """ + 通过get方法下载文件到指定路径 + + :param url: 请求的URL + :param path: 下载文件的保存路径 + """ + + logger.info(f"子线程 {self.objectName()} 开始下载文件", module="网络请求子线程") response = None try: - response = requests.get(url, timeout=10, proxies=self.proxies) + 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 = "下载失败" + 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) -> NetworkThread: - """添加网络请求任务""" + def add_task( + self, + mode: str, + url: str, + path: Path = None, + files: Dict = None, + data: Dict = None, + ) -> NetworkThread: + """ + 添加网络请求任务 - network_thread = NetworkThread(mode, url, path) + :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) @@ -137,8 +237,50 @@ class _Network(QObject): 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, @@ -155,6 +297,11 @@ class _Network(QObject): self.task_queue.remove(network_thread) network_thread.deleteLater() + logger.info( + f"网络请求结果: {result['status_code']},请求子线程已结束", + module="网络请求", + ) + return result diff --git a/app/core/sound_player.py b/app/core/sound_player.py index 422ae0a..e8f5268 100644 --- a/app/core/sound_player.py +++ b/app/core/sound_player.py @@ -25,12 +25,12 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtCore import QObject, QUrl from PySide6.QtMultimedia import QSoundEffect from pathlib import Path +from .logger import logger from .config import Config @@ -42,6 +42,11 @@ class _SoundPlayer(QObject): 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 @@ -59,6 +64,11 @@ class _SoundPlayer(QObject): ) def play_voice(self, sound_path: Path): + """ + 播放音效文件 + + :param sound_path: 音效文件的完整路径 + """ effect = QSoundEffect(self) effect.setVolume(1) diff --git a/app/core/task_manager.py b/app/core/task_manager.py index 67365b4..c044767 100644 --- a/app/core/task_manager.py +++ b/app/core/task_manager.py @@ -25,19 +25,18 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtCore import QThread, QObject, Signal from qfluentwidgets import MessageBox from datetime import datetime from packaging import version from typing import Dict, Union +from .logger import logger from .config import Config from .main_info_bar import MainInfoBar from .network import Network from .sound_player import SoundPlayer from app.models import MaaManager, GeneralManager -from app.services import System class Task(QThread): @@ -77,12 +76,12 @@ class Task(QThread): if "设置MAA" in self.mode: - logger.info(f"任务开始:设置{self.name}") + 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.member_dict[self.name], + 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) @@ -93,17 +92,19 @@ class Task(QThread): try: self.task.run() except Exception as e: - logger.exception(f"任务异常:{self.name},错误信息:{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}") + logger.info(f"任务开始:设置{self.name}", module=f"业务 {self.name}") self.push_info_bar.emit("info", "设置通用脚本", self.name, 3000) self.task = GeneralManager( self.mode, - Config.member_dict[self.name], + Config.script_dict[self.name], self.info["SetSubInfo"]["Path"], ) self.task.push_info_bar.connect(self.push_info_bar.emit) @@ -113,17 +114,20 @@ class Task(QThread): try: self.task.run() except Exception as e: - logger.exception(f"任务异常:{self.name},错误信息:{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.member_dict[value]["Config"].get_name() == "" - else f"{value} - {Config.member_dict[value]["Config"].get_name()}" + if Config.script_dict[value]["Config"].get_name() == "" + else f"{value} - {Config.script_dict[value]["Config"].get_name()}" ), "等待", value, @@ -144,23 +148,28 @@ class Task(QThread): 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]}") + 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]}") + logger.info(f"任务开始:{task[0]}", module=f"业务 {self.name}") self.push_info_bar.emit("info", "任务开始", task[0], 3000) - if Config.member_dict[task[2]]["Type"] == "Maa": + if Config.script_dict[task[2]]["Type"] == "Maa": self.task = MaaManager( self.mode[0:4], - Config.member_dict[task[2]], + Config.script_dict[task[2]], ) self.task.check_maa_version.connect(self.check_maa_version.emit) @@ -177,11 +186,11 @@ class Task(QThread): lambda log: self.task_accomplish(task[2], log) ) - elif Config.member_dict[task[2]]["Type"] == "General": + elif Config.script_dict[task[2]]["Type"] == "General": self.task = GeneralManager( self.mode[0:4], - Config.member_dict[task[2]], + Config.script_dict[task[2]], ) self.task.question.connect(self.question.emit) @@ -198,11 +207,11 @@ class Task(QThread): ) try: - self.task.run() + self.task.run() # 运行任务业务 task[1] = "完成" self.update_task_list.emit(self.task_list) - logger.info(f"任务完成:{task[0]}") + logger.info(f"任务完成:{task[0]}", module=f"业务 {self.name}") self.push_info_bar.emit("info", "任务完成", task[0], 3000) except Exception as e: @@ -217,15 +226,29 @@ class Task(QThread): task[1] = "异常" self.update_task_list.emit(self.task_list) - logger.exception(f"任务异常:{task[0]},错误信息:{e}") + 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() @@ -245,7 +268,13 @@ class _TaskManager(QObject): def add_task( self, mode: str, name: str, info: Dict[str, Dict[str, Union[str, int, bool]]] ): - """添加任务""" + """ + 添加任务 + + :param mode: 任务模式 + :param name: 任务名称 + :param info: 任务信息 + """ if name in Config.running_list or name in self.task_dict: @@ -253,11 +282,14 @@ class _TaskManager(QObject): MainInfoBar.push_info_bar("warning", "任务已存在", name, 5000) return None - logger.info(f"任务开始:{name}") + logger.info(f"任务开始:{name},模式:{mode}", module="业务调度") MainInfoBar.push_info_bar("info", "任务开始", name, 3000) SoundPlayer.play("任务开始") + # 标记任务为运行中 Config.running_list.append(name) + + # 创建任务实例并连接信号 self.task_dict[name] = Task(mode, name, info) self.task_dict[name].check_maa_version.connect(self.check_maa_version) self.task_dict[name].question.connect( @@ -273,18 +305,24 @@ class _TaskManager(QObject): lambda logs: self.remove_task(mode, name, logs) ) + # 向UI发送信号以创建或连接GUI if "新调度台" in mode: self.create_gui.emit(self.task_dict[name]) elif "主调度台" in mode: self.connect_gui.emit(self.task_dict[name]) + # 启动任务线程 self.task_dict[name].start() - def stop_task(self, name: str): - """中止任务""" + def stop_task(self, name: str) -> None: + """ + 中止任务 - logger.info(f"中止任务:{name}") + :param name: 任务名称 + """ + + logger.info(f"中止任务:{name}", module="业务调度") MainInfoBar.push_info_bar("info", "中止任务", name, 3000) if name == "ALL": @@ -303,19 +341,27 @@ class _TaskManager(QObject): self.task_dict[name].quit() self.task_dict[name].wait() - def remove_task(self, mode: str, name: str, logs: list): - """任务结束后的处理""" + def remove_task(self, mode: str, name: str, logs: list) -> None: + """ + 处理任务结束后的收尾工作 - logger.info(f"任务结束:{name}") + :param mode: 任务模式 + :param name: 任务名称 + :param logs: 任务日志 + """ + + logger.info(f"任务结束:{name}", module="业务调度") MainInfoBar.push_info_bar("info", "任务结束", name, 3000) SoundPlayer.play("任务结束") + # 删除任务线程,移除运行中标记 self.task_dict[name].deleteLater() self.task_dict.pop(name) Config.running_list.remove(name) if "调度队列" in name and "人工排查" not in mode: + # 保存调度队列历史记录 if len(logs) > 0: time = logs[0][1]["Time"] history = "" @@ -331,25 +377,31 @@ class _TaskManager(QObject): }, ) + # 根据调度队列情况设置电源状态 if ( Config.queue_dict[name]["Config"].get( - Config.queue_dict[name]["Config"].queueSet_AfterAccomplish + 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 + 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): - """检查MAA版本""" + 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", @@ -359,7 +411,10 @@ class _TaskManager(QObject): if network_result["status_code"] == 200: maa_info = network_result["response_json"] else: - logger.warning(f"获取MAA版本信息时出错:{network_result['error_message']}") + logger.warning( + f"获取MAA版本信息时出错:{network_result['error_message']}", + module="业务调度", + ) MainInfoBar.push_info_bar( "warning", "获取MAA版本信息时出错", @@ -371,7 +426,8 @@ class _TaskManager(QObject): if version.parse(maa_info["data"]["version_name"]) > version.parse(v): logger.info( - f"检测到MAA版本过低:{v},最新版本:{maa_info['data']['version_name']}" + f"检测到MAA版本过低:{v},最新版本:{maa_info['data']['version_name']}", + module="业务调度", ) MainInfoBar.push_info_bar( "info", @@ -380,8 +436,19 @@ class _TaskManager(QObject): -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("是") diff --git a/app/core/timer.py b/app/core/timer.py index 4566d4d..66d8dee 100644 --- a/app/core/timer.py +++ b/app/core/timer.py @@ -25,12 +25,11 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtCore import QObject, QTimer from datetime import datetime -from pathlib import Path import keyboard +from .logger import logger from .config import Config from .task_manager import TaskManager from app.services import System @@ -45,14 +44,31 @@ class _MainTimer(QObject): self.Timer.timeout.connect(self.timed_start) self.Timer.timeout.connect(self.set_silence) self.Timer.timeout.connect(self.check_power) - self.Timer.start(1000) + self.LongTimer = QTimer() self.LongTimer.timeout.connect(self.long_timed_task) + + def start(self): + """启动定时器""" + + logger.info("启动主定时器", module="主业务定时器") + self.Timer.start(1000) self.LongTimer.start(3600000) + def stop(self): + """停止定时器""" + + logger.info("停止主定时器", module="主业务定时器") + self.Timer.stop() + self.Timer.deleteLater() + self.LongTimer.stop() + self.LongTimer.deleteLater() + def long_timed_task(self): """长时间定期检定任务""" + logger.info("执行长时间定期检定任务", module="主业务定时器") + Config.get_stage() Config.main_window.setting.show_notice() if Config.get(Config.update_IfAutoUpdate): @@ -63,15 +79,15 @@ class _MainTimer(QObject): for name, info in Config.queue_dict.items(): - if not info["Config"].get(info["Config"].queueSet_Enabled): + if not info["Config"].get(info["Config"].QueueSet_TimeEnabled): continue data = info["Config"].toDict() time_set = [ - data["Time"][f"TimeSet_{_}"] + data["Time"][f"Set_{_}"] for _ in range(10) - if data["Time"][f"TimeEnabled_{_}"] + if data["Time"][f"Enabled_{_}"] ] # 按时间调起代理任务 curtime = datetime.now().strftime("%Y-%m-%d %H:%M") @@ -82,7 +98,7 @@ class _MainTimer(QObject): and name not in Config.running_list ): - logger.info(f"定时任务:{name}") + logger.info(f"定时唤起任务:{name}。", module="主业务定时器") TaskManager.add_task("自动代理_新调度台", name, data) def set_silence(self): @@ -96,12 +112,21 @@ class _MainTimer(QObject): windows = System.get_window_info() - # 此处排除雷电名为新通知的窗口 - if any( - str(emulator_path) in window and window[0] != "新通知" - for window in windows - for emulator_path in Config.silence_list - ): + emulator_windows = [] + for window in windows: + for emulator_path, endtime in Config.silence_dict.items(): + if ( + datetime.now() < endtime + and str(emulator_path) in window + and window[0] != "新通知" # 此处排除雷电名为新通知的窗口 + ): + emulator_windows.append(window) + + if emulator_windows: + + logger.info( + f"检测到模拟器窗口:{emulator_windows}", module="主业务定时器" + ) try: keyboard.press_and_release( "+".join( @@ -109,13 +134,20 @@ class _MainTimer(QObject): for _ in Config.get(Config.function_BossKey).split("+") ) ) + logger.info( + f"模拟按键:{Config.get(Config.function_BossKey)}", + module="主业务定时器", + ) except Exception as e: - logger.error(f"模拟按键时出错:{e}") + logger.exception(f"模拟按键时出错:{e}", module="主业务定时器") def check_power(self): + """检查电源操作""" if Config.power_sign != "NoAction" and not Config.running_list: + logger.info(f"触发电源操作:{Config.power_sign}", module="主业务定时器") + from app.ui import ProgressRingMessageBox mode_book = { @@ -123,15 +155,20 @@ class _MainTimer(QObject): "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") diff --git a/app/models/MAA.py b/app/models/MAA.py index 974ba65..3df88d5 100644 --- a/app/models/MAA.py +++ b/app/models/MAA.py @@ -25,7 +25,6 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtCore import QObject, Signal, QEventLoop, QFileSystemWatcher, QTimer import json import subprocess @@ -38,7 +37,7 @@ from pathlib import Path from jinja2 import Environment, FileSystemLoader from typing import Union, List, Dict -from app.core import Config, MaaConfig, MaaUserConfig +from app.core import Config, MaaConfig, MaaUserConfig, logger from app.services import Notify, Crypto, System, skland_sign_in from app.utils import ProcessManager @@ -77,19 +76,23 @@ class MaaManager(QObject): 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( - lambda: self.log_monitor.fileChanged.emit("进程结束检查") - ) + self.maa_process_manager.processClosed.connect(self.check_maa_log) self.question_loop = QEventLoop() self.question_response.connect(self.__capture_response) @@ -118,10 +121,14 @@ class MaaManager(QObject): 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.name = self.set["MaaSet"]["Name"] 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" @@ -132,6 +139,8 @@ class MaaManager(QObject): for i in range(0, 2 * self.set["RunSet"]["ADBSearchRange"]) ] + logger.success("MAA配置提取完成", module=f"MAA调度器-{self.name}") + def run(self): """主进程,运行MAA代理进程""" @@ -143,7 +152,9 @@ class MaaManager(QObject): # 检查MAA路径是否可用 if not self.maa_exe_path.exists() or not self.maa_set_path.exists(): - logger.error("未正确配置MAA路径,MAA代理进程中止") + logger.error( + "未正确配置MAA路径,MAA代理进程中止", module=f"MAA调度器-{self.name}" + ) self.push_info_bar.emit( "error", "启动MAA代理进程失败", "您还未正确配置MAA路径!", -1 ) @@ -155,6 +166,15 @@ class MaaManager(QObject): ) 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: @@ -174,6 +194,11 @@ class MaaManager(QObject): ] self.create_user_list.emit(self.user_list) + logger.info( + f"用户列表创建完成,已筛选用户数:{len(self.user_list)}", + module=f"MAA调度器-{self.name}", + ) + # 自动代理模式 if self.mode == "自动代理": @@ -208,14 +233,7 @@ class MaaManager(QObject): self.update_user_list.emit(self.user_list) continue - logger.info(f"{self.name} | 开始代理用户: {user[0]}") - - # 初始化代理情况记录和模式替换表 - run_book = {"Annihilation": False, "Routine": False} - mode_book = { - "Annihilation": "自动代理_剿灭", - "Routine": "自动代理_日常", - } + logger.info(f"开始代理用户: {user[0]}", module=f"MAA调度器-{self.name}") # 简洁模式用户默认开启日常选项 if user_data["Info"]["Mode"] == "简洁": @@ -224,6 +242,16 @@ class MaaManager(QObject): 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() @@ -244,7 +272,8 @@ class MaaManager(QObject): if type != "总计" and len(user_list) > 0: logger.info( - f"{self.name} | 用户: {user[0]} - 森空岛签到{type}: {'、'.join(user_list)}" + f"用户: {user[0]} - 森空岛签到{type}: {'、'.join(user_list)}", + module=f"MAA调度器-{self.name}", ) self.push_info_bar.emit( "info", @@ -255,10 +284,7 @@ class MaaManager(QObject): if skland_result["总计"] == 0: self.push_info_bar.emit( - "info", - "森空岛签到失败", - user[0], - -1, + "info", "森空岛签到失败", user[0], -1 ) if ( @@ -268,13 +294,22 @@ class MaaManager(QObject): 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"{self.name} | 用户: {user[0]} - 未配置森空岛签到Token,跳过森空岛签到" + f"用户: {user[0]} - 未配置森空岛签到Token,跳过森空岛签到", + module=f"MAA调度器-{self.name}", ) self.push_info_bar.emit( "warning", "森空岛签到失败", "未配置鹰角网络通行证登录凭证", -1 @@ -286,6 +321,9 @@ class MaaManager(QObject): if self.isInterruptionRequested: break + if run_book[mode]: + continue + # 剿灭模式;满足条件跳过剿灭 if ( mode == "Annihilation" @@ -296,33 +334,32 @@ class MaaManager(QObject): == datetime.strptime(curdate, "%Y-%m-%d").isocalendar()[:2] ): logger.info( - f"{self.name} | 用户: {user_data["Info"]["Name"]} - 本周剿灭模式已达上限,跳过执行剿灭任务" + f"用户: {user_data['Info']['Name']} - 本周剿灭模式已达上限,跳过执行剿灭任务", + module=f"MAA调度器-{self.name}", ) run_book[mode] = True continue else: self.weekly_annihilation_limit_reached = False - if not user_data["Info"][mode]: - run_book[mode] = True - continue - - if user_data["Info"]["Mode"] == "详细": - - if not ( - self.data[user[2]]["Path"] / f"{mode}/gui.json" - ).exists(): - logger.error( - f"{self.name} | 用户: {user[0]} - 未找到{mode_book[mode][5:7]}配置文件" - ) - self.push_info_bar.emit( - "error", - "启动MAA代理进程失败", - f"未找到{user[0]}的{mode_book[mode][5:7]}配置文件!", - -1, - ) - run_book[mode] = False - continue + 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( @@ -352,52 +389,21 @@ class MaaManager(QObject): elif mode == "Annihilation": - if user_data["Info"]["Mode"] == "简洁": + self.task_dict = { + "WakeUp": "True", + "Recruiting": "False", + "Base": "False", + "Combat": "True", + "Mission": "False", + "Mall": "False", + "AutoRoguelike": "False", + "Reclamation": "False", + } - self.task_dict = { - "WakeUp": "True", - "Recruiting": "False", - "Base": "False", - "Combat": "True", - "Mission": "False", - "Mall": "False", - "AutoRoguelike": "False", - "Reclamation": "False", - } - - elif user_data["Info"]["Mode"] == "详细": - - with (self.data[user[2]]["Path"] / f"{mode}/gui.json").open( - mode="r", encoding="utf-8" - ) as f: - data = json.load(f) - - self.task_dict = { - "WakeUp": data["Configurations"]["Default"][ - "TaskQueue.WakeUp.IsChecked" - ], - "Recruiting": data["Configurations"]["Default"][ - "TaskQueue.Recruiting.IsChecked" - ], - "Base": data["Configurations"]["Default"][ - "TaskQueue.Base.IsChecked" - ], - "Combat": data["Configurations"]["Default"][ - "TaskQueue.Combat.IsChecked" - ], - "Mission": data["Configurations"]["Default"][ - "TaskQueue.Mission.IsChecked" - ], - "Mall": data["Configurations"]["Default"][ - "TaskQueue.Mall.IsChecked" - ], - "AutoRoguelike": data["Configurations"]["Default"][ - "TaskQueue.AutoRoguelike.IsChecked" - ], - "Reclamation": data["Configurations"]["Default"][ - "TaskQueue.Reclamation.IsChecked" - ], - } + 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"]): @@ -409,13 +415,14 @@ class MaaManager(QObject): break logger.info( - f"{self.name} | 用户: {user[0]} - 模式: {mode_book[mode]} - 尝试次数: {i + 1}/{self.set["RunSet"]["RunTimesLimit"]}" + 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]) # 记录当前时间 - start_time = datetime.now() + self.log_start_time = datetime.now() # 记录模拟器与ADB路径 self.emulator_path = Path( @@ -435,8 +442,9 @@ class MaaManager(QObject): self.emulator_path = Path(shortcut.TargetPath) self.emulator_arguments = shortcut.Arguments.split() except Exception as e: - logger.error( - f"{self.name} | 解析快捷方式时出现异常:{e}" + logger.exception( + f"解析快捷方式时出现异常:{e}", + module=f"MAA调度器-{self.name}", ) self.push_info_bar.emit( "error", @@ -448,7 +456,8 @@ class MaaManager(QObject): break elif not self.emulator_path.exists(): logger.error( - f"{self.name} | 模拟器快捷方式不存在:{self.emulator_path}" + f"模拟器快捷方式不存在:{self.emulator_path}", + module=f"MAA调度器-{self.name}", ) self.push_info_bar.emit( "error", @@ -489,16 +498,25 @@ class MaaManager(QObject): # 任务开始前释放ADB try: - logger.info(f"{self.name} | 释放ADB:{self.ADB_address}") + 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"{self.name} | 释放ADB时出现异常:{e}") + logger.warning( + f"释放ADB时出现异常:{e}", + module=f"MAA调度器-{self.name}", + ) except Exception as e: - logger.error(f"{self.name} | 释放ADB时出现异常:{e}") + logger.exception( + f"释放ADB时出现异常:{e}", + module=f"MAA调度器-{self.name}", + ) self.push_info_bar.emit( "error", "释放ADB时出现异常", @@ -509,13 +527,17 @@ class MaaManager(QObject): if self.if_open_emulator_process: try: logger.info( - f"{self.name} | 启动模拟器:{self.emulator_path},参数:{self.emulator_arguments}" + 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.error(f"{self.name} | 启动模拟器时出现异常:{e}") + logger.exception( + f"启动模拟器时出现异常:{e}", + module=f"MAA调度器-{self.name}", + ) self.push_info_bar.emit( "error", "启动模拟器时出现异常", @@ -525,55 +547,46 @@ class MaaManager(QObject): self.if_open_emulator = True break - # 添加静默进程标记 - Config.silence_list.append(self.emulator_path) + # 更新静默进程标记有效时间 + 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任务 - self.maa_process_manager.open_process(self.maa_exe_path, [], 10) - # 监测MAA运行状态 - self.start_monitor(start_time, mode_book[mode]) + 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 - # 从配置文件中解析所需信息 - 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" - ] - logger.info( - f"{self.name} | 用户: {user[0]} - MAA进程完成代理任务" + f"用户: {user[0]} - MAA进程完成代理任务", + module=f"MAA调度器-{self.name}", ) self.update_log_text.emit( "检测到MAA进程完成代理任务\n正在等待相关程序结束\n请等待10s" ) - self.sleep(10) else: logger.error( - f"{self.name} | 用户: {user[0]} - 代理任务异常: {self.maa_result}" + f"用户: {user[0]} - 代理任务异常: {self.maa_result}", + module=f"MAA调度器-{self.name}", ) # 打印中止信息 # 此时,log变量内存储的就是出现异常的日志信息,可以保存或发送用于问题排查 @@ -581,38 +594,22 @@ class MaaManager(QObject): 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 - # 从配置文件中解析所需信息 - with self.maa_set_path.open( - mode="r", encoding="utf-8" - ) as f: - data = json.load(f) - - # 记录自定义基建索引 - if self.task_dict["Base"] == "False": - 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" - ] - # 推送异常通知 Notify.push_plyer( "用户自动代理出现异常!", @@ -624,20 +621,30 @@ class MaaManager(QObject): self.play_sound.emit("子任务失败") else: self.play_sound.emit(self.maa_result) - self.sleep(10) + + self.sleep(10) # 任务结束后释放ADB try: - logger.info(f"{self.name} | 释放ADB:{self.ADB_address}") + 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"{self.name} | 释放ADB时出现异常:{e}") + logger.warning( + f"释放ADB时出现异常:{e}", + module=f"MAA调度器-{self.name}", + ) except Exception as e: - logger.error(f"{self.name} | 释放ADB时出现异常:{e}") + logger.exception( + f"释放ADB时出现异常:{e}", + module=f"MAA调度器-{self.name}", + ) self.push_info_bar.emit( "error", "释放ADB时出现异常", @@ -646,9 +653,34 @@ class MaaManager(QObject): ) # 任务结束后再次手动中止模拟器进程,防止退出不彻底 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" @@ -658,13 +690,13 @@ class MaaManager(QObject): # 保存运行日志以及统计信息 if_six_star = Config.save_maa_log( Config.app_path - / f"history/{curdate}/{user_data["Info"]["Name"]}/{start_time.strftime("%H-%M-%S")}.log", - self.check_maa_log(start_time, mode_book[mode]), + / 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"]}/{start_time.strftime("%H-%M-%S")}.json", + / f"history/{curdate}/{user_data["Info"]["Name"]}/{self.log_start_time.strftime("%H-%M-%S")}.json", ) if if_six_star: self.push_notification( @@ -681,7 +713,8 @@ class MaaManager(QObject): if self.maa_update_package: logger.info( - f"{self.name} | 检测到MAA更新,正在执行更新动作" + f"检测到MAA更新,正在执行更新动作", + module=f"MAA调度器-{self.name}", ) self.update_log_text.emit( @@ -698,7 +731,9 @@ class MaaManager(QObject): self.maa_update_package = "" - logger.info(f"{self.name} | 更新动作结束") + logger.info( + f"更新动作结束", module=f"MAA调度器-{self.name}" + ) # 发送统计信息 statistics = Config.merge_statistic_info(user_logs_list) @@ -727,6 +762,10 @@ class MaaManager(QObject): 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("_", " 今天的")}任务", @@ -735,6 +774,10 @@ class MaaManager(QObject): ) else: # 录入代理失败的用户 + logger.error( + f"用户 {user[0]} 的自动代理任务未完成", + module=f"MAA调度器-{self.name}", + ) user[1] = "异常" self.update_user_list.emit(self.user_list) @@ -743,6 +786,9 @@ class MaaManager(QObject): elif self.mode == "人工排查": # 人工排查时,屏蔽静默操作 + logger.info( + "人工排查任务开始,屏蔽静默操作", module=f"MAA调度器-{self.name}" + ) Config.if_ignore_silence = True # 标记是否需要启动模拟器 @@ -759,7 +805,7 @@ class MaaManager(QObject): if self.isInterruptionRequested: break - logger.info(f"{self.name} | 开始排查用户: {user[0]}") + logger.info(f"开始排查用户: {user[0]}", module=f"MAA调度器-{self.name}") user[1] = "运行" self.update_user_list.emit(self.user_list) @@ -776,27 +822,38 @@ class MaaManager(QObject): self.set_maa("人工排查", user[2]) # 记录当前时间 - start_time = datetime.now() + self.log_start_time = datetime.now() # 创建MAA任务 - self.maa_process_manager.open_process(self.maa_exe_path, [], 10) + 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.start_monitor(start_time, "人工排查") + self.log_check_mode = "人工排查" + self.start_monitor() if self.maa_result == "Success!": logger.info( - f"{self.name} | 用户: {user[0]} - MAA进程成功登录PRTS" + 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"{self.name} | 用户: {user[0]} - MAA未能正确登录到PRTS: {self.maa_result}" + 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 @@ -825,17 +882,25 @@ class MaaManager(QObject): # 结果录入 if run_book[0] and run_book[1]: - logger.info(f"{self.name} | 用户 {user[0]} 通过人工排查") + logger.info( + f"用户 {user[0]} 通过人工排查", module=f"MAA调度器-{self.name}" + ) user_data["Data"]["IfPassCheck"] = True user[1] = "完成" else: - logger.info(f"{self.name} | 用户 {user[0]} 未通过人工排查") + 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模式 @@ -844,20 +909,32 @@ class MaaManager(QObject): # 配置MAA self.set_maa(self.mode, "") # 创建MAA任务 - self.maa_process_manager.open_process(self.maa_exe_path, [], 10) + logger.info( + f"启动MAA进程:{self.maa_exe_path}", module=f"MAA调度器-{self.name}" + ) + self.maa_process_manager.open_process(self.maa_exe_path, [], 0) # 记录当前时间 - start_time = datetime.now() + self.log_start_time = datetime.now() # 监测MAA运行状态 - self.start_monitor(start_time, "设置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 = "" @@ -866,12 +943,13 @@ class MaaManager(QObject): # 关闭可能未正常退出的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) - # 复原MAA配置文件 - shutil.copy(self.config_path / "Default/gui.json", self.maa_set_path) - # 更新用户数据 updated_info = {_[2]: self.data[_[2]] for _ in self.user_list} self.update_user_info.emit(self.config_path.name, updated_info) @@ -924,13 +1002,24 @@ class MaaManager(QObject): ) 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"{self.name} | 收到任务中止申请") + """请求中止任务""" + + logger.info(f"收到任务中止申请", module=f"MAA调度器-{self.name}") if len(self.log_monitor.files()) != 0: self.interrupt.emit() @@ -940,17 +1029,25 @@ class MaaManager(QObject): 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() @@ -966,11 +1063,6 @@ class MaaManager(QObject): if self.isInterruptionRequested: return None - # 10s后移除静默进程标记 - QTimer.singleShot( - 10000, partial(Config.silence_list.remove, self.emulator_path) - ) - if "-" in self.ADB_address: ADB_ip = f"{self.ADB_address.split("-")[0]}-" ADB_port = int(self.ADB_address.split("-")[1]) @@ -980,7 +1072,8 @@ class MaaManager(QObject): ADB_port = int(self.ADB_address.split(":")[1]) logger.info( - f"{self.name} | 正在搜索ADB实际地址,ADB前缀:{ADB_ip},初始端口:{ADB_port},搜索范围:{self.port_range}" + f"正在搜索ADB实际地址,ADB前缀:{ADB_ip},初始端口:{ADB_port},搜索范围:{self.port_range}", + module=f"MAA调度器-{self.name}", ) for port in self.port_range: @@ -1010,9 +1103,14 @@ class MaaManager(QObject): ) if ADB_address in devices_result.stdout: - logger.info(f"{self.name} | ADB实际地址:{ADB_address}") + 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, @@ -1021,6 +1119,10 @@ class MaaManager(QObject): 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: @@ -1036,9 +1138,14 @@ class MaaManager(QObject): return None else: - logger.info(f"{self.name} | 无法连接到ADB地址:{ADB_address}") + logger.info( + f"无法连接到ADB地址:{ADB_address}", + module=f"MAA调度器-{self.name}", + ) else: - logger.info(f"{self.name} | 无法连接到ADB地址:{ADB_address}") + logger.info( + f"无法连接到ADB地址:{ADB_address}", module=f"MAA调度器-{self.name}" + ) if not self.isInterruptionRequested: self.play_sound.emit("ADB失败") @@ -1046,59 +1153,68 @@ class MaaManager(QObject): def refresh_maa_log(self) -> None: """刷新MAA日志""" - with self.maa_log_path.open(mode="r", encoding="utf-8") as f: - pass + 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): - self.log_monitor.fileChanged.emit("1分钟超时检查") + logger.info("触发 1 分钟超时检查", module=f"MAA调度器-{self.name}") + self.check_maa_log() - def check_maa_log(self, start_time: datetime, mode: str) -> list: + def check_maa_log(self) -> None: """获取MAA日志并检查以判断MAA程序运行状态""" self.last_check_time = datetime.now() # 获取日志 - 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 > start_time: - if_log_start = True - logs.append(entry) - except ValueError: - pass - else: - logs.append(entry) - log = "".join(logs) + 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 len(logs) > 100: - self.update_log_text.emit("".join(logs[-100:])) - else: - self.update_log_text.emit("".join(logs)) + if self.maa_process_manager.is_running(): - # 获取MAA版本号 - if not self.set["RunSet"]["AutoUpdateMaa"] and not self.maa_version: + self.update_log_text.emit( + "".join(self.maa_logs) + if len(self.maa_logs) < 100 + else "".join(self.maa_logs[-100:]) + ) - section_match = re.search(r"={35}(.*?)={35}", log, re.DOTALL) - if section_match: - - version_match = re.search( - r"Version\s+v(\d+\.\d+\.\d+(?:-\w+\.\d+)?)", section_match.group(1) - ) - if version_match: - self.maa_version = f"v{version_match.group(1)}" - self.check_maa_version.emit(self.maa_version) - - if "自动代理" in mode: + if "自动代理" in self.log_check_mode: # 获取最近一条日志的时间 - latest_time = start_time - for _ in logs[::-1]: + latest_time = self.log_start_time + for _ in self.maa_logs[::-1]: try: if "如果长时间无进一步日志更新,可能需要手动干预。" in _: continue @@ -1107,12 +1223,16 @@ class MaaManager(QObject): except ValueError: pass + logger.info( + f"MAA最近一条日志时间:{latest_time}", module=f"MAA调度器-{self.name}" + ) + time_book = { "自动代理_剿灭": "AnnihilationTimeLimit", "自动代理_日常": "RoutineTimeLimit", } - if mode == "自动代理_剿灭" and "剿灭任务失败" in log: + if self.log_check_mode == "自动代理_剿灭" and "剿灭任务失败" in log: self.weekly_annihilation_limit_reached = True else: self.weekly_annihilation_limit_reached = False @@ -1164,7 +1284,7 @@ class MaaManager(QObject): self.maa_result = "MAA在完成任务前退出" elif datetime.now() - latest_time > timedelta( - minutes=self.set["RunSet"][time_book[mode]] + minutes=self.set["RunSet"][time_book[self.log_check_mode]] ): self.maa_result = "MAA进程超时" @@ -1174,7 +1294,7 @@ class MaaManager(QObject): else: self.maa_result = "Wait" - elif mode == "人工排查": + elif self.log_check_mode == "人工排查": if "完成任务: StartUp" in log or "完成任务: 开始唤醒" in log: self.maa_result = "Success!" elif "请 「检查连接设置」 → 「尝试重启模拟器与 ADB」 → 「重启电脑」" in log: @@ -1193,7 +1313,7 @@ class MaaManager(QObject): else: self.maa_result = "Wait" - elif mode == "设置MAA": + elif self.log_check_mode == "设置MAA": if ( "MaaAssistantArknights GUI exited" in log or not self.maa_process_manager.is_running() @@ -1202,20 +1322,22 @@ class MaaManager(QObject): 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() - return logs - - def start_monitor(self, start_time: datetime, mode: str) -> None: + def start_monitor(self) -> None: """开始监视MAA日志""" - logger.info(f"{self.name} | 开始监视MAA日志") - self.log_monitor.addPath(str(self.maa_log_path)) - self.log_monitor.fileChanged.connect( - lambda: self.check_maa_log(start_time, mode) + 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() @@ -1225,20 +1347,39 @@ class MaaManager(QObject): if len(self.log_monitor.files()) != 0: - logger.info(f"{self.name} | 退出MAA日志监视") + logger.info( + f"MAA日志监视器移除路径:{self.maa_log_path}", + module=f"MAA调度器-{self.name}", + ) self.log_monitor.removePath(str(self.maa_log_path)) - self.log_monitor.fileChanged.disconnect() - self.log_monitor_timer.stop() - self.last_check_time = None - self.monitor_loop.quit() + + 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"{self.name} | 配置MAA运行参数: {mode}/{index}") + 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) @@ -1261,16 +1402,9 @@ class MaaManager(QObject): self.maa_set_path, ) elif "自动代理" in mode and user_data["Info"]["Mode"] == "详细": - if mode == "自动代理_剿灭": - shutil.copy( - self.data[index]["Path"] / "Annihilation/gui.json", - self.maa_set_path, - ) - elif mode == "自动代理_日常": - shutil.copy( - self.data[index]["Path"] / "Routine/gui.json", - self.maa_set_path, - ) + 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", @@ -1279,32 +1413,19 @@ class MaaManager(QObject): with self.maa_set_path.open(mode="r", encoding="utf-8") as f: data = json.load(f) - if ("设置MAA" not in self.mode and "更新MAA" not in mode) and ( - ( - user_data["Info"]["Mode"] == "简洁" - and user_data["Info"]["Server"] == "Bilibili" - ) - or ( - user_data["Info"]["Mode"] == "详细" - and data["Configurations"]["Default"]["Start.ClientType"] == "Bilibili" - ) - ): - self.agree_bilibili(True) - else: - self.agree_bilibili(False) - # 切换配置 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: - for i in range(1, 9): - data["Global"][f"Timer.Timer{i}"] = "False" # 时间设置 - if ( next((i for i, _ in enumerate(self.user_list) if _[2] == index), None) == len(self.user_list) - 1 @@ -1339,9 +1460,9 @@ class MaaManager(QObject): data["Global"][ "VersionUpdate.ScheduledUpdateCheck" ] = "False" # 定时检查更新 - data["Global"]["VersionUpdate.AutoDownloadUpdatePackage"] = str( - self.set["RunSet"]["AutoUpdateMaa"] - ) # 自动下载更新包 + data["Global"][ + "VersionUpdate.AutoDownloadUpdatePackage" + ] = "True" # 自动下载更新包 data["Global"][ "VersionUpdate.AutoInstallUpdatePackage" ] = "False" # 自动安装更新包 @@ -1351,6 +1472,11 @@ class MaaManager(QObject): 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"] = ( @@ -1389,15 +1515,9 @@ class MaaManager(QObject): self.task_dict["Reclamation"] ) # 生息演算 - if user_data["Info"]["Mode"] == "简洁": + # 整理任务顺序 + if "剿灭" in mode or user_data["Info"]["Mode"] == "简洁": - data["Configurations"]["Default"]["Start.ClientType"] = user_data[ - "Info" - ][ - "Server" - ] # 客户端类型 - - # 整理任务顺序 data["Configurations"]["Default"]["TaskQueue.Order.WakeUp"] = "0" data["Configurations"]["Default"]["TaskQueue.Order.Recruiting"] = "1" data["Configurations"]["Default"]["TaskQueue.Order.Base"] = "2" @@ -1407,96 +1527,104 @@ class MaaManager(QObject): data["Configurations"]["Default"]["TaskQueue.Order.AutoRoguelike"] = "6" data["Configurations"]["Default"]["TaskQueue.Order.Reclamation"] = "7" - if "剿灭" in mode: + 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"][ - "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"][ "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.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.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"][ - "MainFunction.Series.Quantity" - ] = user_data["Info"][ - "SeriesNumb" - ] # 连战次数 - data["Configurations"]["Default"][ - "Penguin.IsDrGrandet" - ] = "False" # 博朗台模式 - data["Configurations"]["Default"][ - "GUI.CustomStageCode" - ] = "True" # 手动输入关卡名 - data["Configurations"]["Default"][ - "GUI.UseAlternateStage" - ] = "True" # 备选关卡 - data["Configurations"]["Default"][ - "Fight.UseRemainingSanityStage" - ] = ( - "True" if user_data["Info"]["Stage_Remain"] != "-" else "False" - ) # 使用剩余理智 data["Configurations"]["Default"][ "Fight.UseExpiringMedicine" ] = "True" # 无限吃48小时内过期的理智药 @@ -1548,60 +1676,7 @@ class MaaManager(QObject): "InfrastMode" ] # 基建模式 - elif user_data["Info"]["Mode"] == "详细": - - if "剿灭" in mode: - - pass - - elif "日常" in mode: - - 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.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"][ - "MainFunction.Series.Quantity" - ] = user_data["Info"][ - "SeriesNumb" - ] # 连战次数 - data["Configurations"]["Default"][ - "GUI.UseAlternateStage" - ] = "True" # 备选关卡 - data["Configurations"]["Default"][ - "Fight.UseRemainingSanityStage" - ] = ( - "True" if user_data["Info"]["Stage_Remain"] != "-" else "False" - ) # 使用剩余理智 + elif user_data["Info"]["Mode"] == "详细": # 基建模式 if ( @@ -1617,8 +1692,6 @@ class MaaManager(QObject): # 人工排查配置 elif "人工排查" in mode: - for i in range(1, 9): - data["Global"][f"Timer.Timer{i}"] = "False" # 时间设置 data["Configurations"]["Default"][ "MainFunction.PostActions" ] = "8" # 完成后退出MAA @@ -1641,6 +1714,11 @@ class MaaManager(QObject): "VersionUpdate.AutoInstallUpdatePackage" ] = "False" # 自动安装更新包 + # 客户端类型 + data["Configurations"]["Default"]["Start.ClientType"] = user_data["Info"][ + "Server" + ] + # 账号切换 if user_data["Info"]["Server"] == "Official": data["Configurations"]["Default"]["Start.AccountName"] = ( @@ -1653,14 +1731,6 @@ class MaaManager(QObject): "Info" ]["Id"] - if user_data["Info"]["Mode"] == "简洁": - - data["Configurations"]["Default"]["Start.ClientType"] = user_data[ - "Info" - ][ - "Server" - ] # 客户端类型 - data["Configurations"]["Default"][ "TaskQueue.WakeUp.IsChecked" ] = "True" # 开始唤醒 @@ -1689,8 +1759,6 @@ class MaaManager(QObject): # 设置MAA配置 elif "设置MAA" in mode: - for i in range(1, 9): - data["Global"][f"Timer.Timer{i}"] = "False" # 时间设置 data["Configurations"]["Default"][ "MainFunction.PostActions" ] = "0" # 完成后无动作 @@ -1715,37 +1783,33 @@ class MaaManager(QObject): "Start.MinimizeDirectly" ] = "False" # 启动MAA后直接最小化 - if "全局" in mode: - - 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" # 生息演算 + 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": - for i in range(1, 9): - data["Global"][f"Timer.Timer{i}"] = "False" # 时间设置 data["Configurations"]["Default"][ "MainFunction.PostActions" ] = "0" # 完成后无动作 @@ -1804,12 +1868,17 @@ class MaaManager(QObject): 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"{self.name} | Bilibili协议相关任务状态: {"启用" if if_agree else "禁用"}" + f"Bilibili协议相关任务状态: {'启用' if if_agree else '禁用'}", + module=f"MAA调度器-{self.name}", ) with self.maa_tasks_path.open(mode="r", encoding="utf-8") as f: @@ -1843,6 +1912,10 @@ class MaaManager(QObject): 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")) @@ -1971,9 +2044,7 @@ class MaaManager(QObject): user_data["Notify"]["ToAddress"], ) else: - logger.error( - f"{self.name} | 用户邮箱地址为空,无法发送用户单独的邮件通知" - ) + logger.error(f"用户邮箱地址为空,无法发送用户单独的邮件通知") # 发送ServerChan通知 if user_data["Notify"]["IfServerChan"]: @@ -2051,9 +2122,7 @@ class MaaManager(QObject): user_data["Notify"]["ToAddress"], ) else: - logger.error( - f"{self.name} | 用户邮箱地址为空,无法发送用户单独的邮件通知" - ) + logger.error(f"用户邮箱地址为空,无法发送用户单独的邮件通知") # 发送ServerChan通知 if user_data["Notify"]["IfServerChan"]: diff --git a/app/models/general.py b/app/models/general.py index 5966bb2..8ef8d15 100644 --- a/app/models/general.py +++ b/app/models/general.py @@ -25,7 +25,6 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtCore import QObject, Signal, QEventLoop, QFileSystemWatcher, QTimer import os import sys @@ -37,7 +36,7 @@ from pathlib import Path from jinja2 import Environment, FileSystemLoader from typing import Union, List, Dict -from app.core import Config, GeneralConfig, GeneralSubConfig +from app.core import Config, GeneralConfig, GeneralSubConfig, logger from app.services import Notify, System from app.utils import ProcessManager @@ -75,19 +74,22 @@ class GeneralManager(QObject): 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( - lambda: self.log_monitor.fileChanged.emit("进程结束检查") - ) + self.script_process_manager.processClosed.connect(self.check_script_log) self.question_loop = QEventLoop() self.question_response.connect(self.__capture_response) @@ -111,6 +113,10 @@ class GeneralManager(QObject): 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: """检查配置完整性""" @@ -124,7 +130,7 @@ class GeneralManager(QObject): ) or ( self.set["Game"]["Enabled"] and not Path(self.set["Game"]["Path"]).exists() ): - logger.error("脚本配置缺失") + logger.error("脚本配置缺失", module=f"通用调度器-{self.name}") self.push_info_bar.emit("error", "脚本配置缺失", "请检查脚本配置!", -1) return False @@ -133,9 +139,36 @@ class GeneralManager(QObject): def configure(self): """提取配置信息""" - self.name = self.set["Script"]["Name"] self.script_root_path = Path(self.set["Script"]["RootPath"]) - self.script_exe_path = Path(self.set["Script"]["ScriptPath"]) + 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( @@ -166,9 +199,23 @@ class GeneralManager(QObject): 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": "由于配置不完整,通用代理进程中止"} ) @@ -176,6 +223,22 @@ class GeneralManager(QObject): 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 != "设置通用脚本": @@ -190,6 +253,11 @@ class GeneralManager(QObject): ] self.create_user_list.emit(self.sub_list) + logger.info( + f"配置列表创建完成,已筛选子配置数:{len(self.sub_list)}", + module=f"通用调度器-{self.name}", + ) + # 自动代理模式 if self.mode == "自动代理": @@ -222,14 +290,17 @@ class GeneralManager(QObject): self.update_user_list.emit(self.sub_list) continue - logger.info(f"{self.name} | 开始代理配置: {sub[0]}") + 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"{self.name} | 配置: {sub[0]} - 未找到配置文件") + logger.error( + f"配置: {sub[0]} - 未找到配置文件", + module=f"通用调度器-{self.name}", + ) self.push_info_bar.emit( "error", "启动通用代理进程失败", @@ -246,11 +317,12 @@ class GeneralManager(QObject): break logger.info( - f"{self.name} | 用户: {sub[0]} - 尝试次数: {i + 1}/{self.set['Run']['RunTimesLimit']}" + f"用户: {sub[0]} - 尝试次数: {i + 1}/{self.set['Run']['RunTimesLimit']}", + module=f"通用调度器-{self.name}", ) # 记录当前时间 - start_time = datetime.now() + self.log_start_time = datetime.now() # 配置脚本 self.set_sub(sub[2]) # 执行任务前脚本 @@ -267,7 +339,8 @@ class GeneralManager(QObject): try: logger.info( - f"{self.name} | 启动游戏/模拟器:{self.game_path},参数:{self.set['Game']['Arguments']}" + f"启动游戏/模拟器:{self.game_path},参数:{self.set['Game']['Arguments']}", + module=f"通用调度器-{self.name}", ) self.game_process_manager.open_process( self.game_path, @@ -275,8 +348,9 @@ class GeneralManager(QObject): 0, ) except Exception as e: - logger.error( - f"{self.name} | 启动游戏/模拟器时出现异常:{e}" + logger.exception( + f"启动游戏/模拟器时出现异常:{e}", + module=f"通用调度器-{self.name}", ) self.push_info_bar.emit( "error", @@ -287,9 +361,17 @@ class GeneralManager(QObject): self.script_result = "游戏/模拟器启动失败" break - # 添加静默进程标记 + # 更新静默进程标记 if self.set["Game"]["Style"] == "Emulator": - Config.silence_list.append(self.game_path) + 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" @@ -297,25 +379,19 @@ class GeneralManager(QObject): self.sleep(self.set["Game"]["WaitTime"]) - # 10s后移除静默进程标记 - if self.set["Game"]["Style"] == "Emulator": - QTimer.singleShot( - 10000, - partial(Config.silence_list.remove, self.game_path), - ) - # 运行脚本任务 logger.info( - f"{self.name} | 运行脚本任务:{self.script_exe_path},参数:{self.set['Script']['Arguments']}" + f"运行脚本任务:{self.script_exe_path},参数:{self.script_arguments}", + module=f"通用调度器-{self.name}", ) self.script_process_manager.open_process( self.script_exe_path, - str(self.set["Script"]["Arguments"]).split(" "), + self.script_arguments, tracking_time=60 if self.set["Script"]["IfTrackProcess"] else 0, ) # 监测运行状态 - self.start_monitor(start_time) + self.start_monitor() if self.script_result == "Success!": @@ -323,24 +399,59 @@ class GeneralManager(QObject): 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"{self.name} | 配置: {sub[0]} - 通用脚本进程完成代理任务" + 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"{self.name} | 配置: {sub[0]} - 代理任务异常: {self.script_result}" + f"配置: {sub[0]} - 代理任务异常: {self.script_result}", + module=f"通用调度器-{self.name}", ) # 打印中止信息 # 此时,log变量内存储的就是出现异常的日志信息,可以保存或发送用于问题排查 @@ -349,8 +460,17 @@ class GeneralManager(QObject): ) # 中止相关程序 + 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) @@ -366,8 +486,33 @@ class GeneralManager(QObject): 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"] @@ -380,8 +525,8 @@ class GeneralManager(QObject): # 保存运行日志以及统计信息 Config.save_general_log( Config.app_path - / f"history/{curdate}/{sub_data['Info']['Name']}/{start_time.strftime("%H-%M-%S")}.log", - self.check_script_log(start_time), + / f"history/{curdate}/{sub_data['Info']['Name']}/{self.log_start_time.strftime("%H-%M-%S")}.log", + self.script_logs, self.script_result, ) @@ -409,6 +554,10 @@ class GeneralManager(QObject): 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("_", " 今天的")}任务", @@ -418,6 +567,10 @@ class GeneralManager(QObject): else: # 录入代理失败的用户 sub[1] = "异常" + logger.error( + f"配置: {sub[0]} - 代理任务异常: {self.script_result}", + module=f"通用调度器-{self.name}", + ) self.update_user_list.emit(self.sub_list) @@ -429,17 +582,21 @@ class GeneralManager(QObject): try: # 创建通用脚本任务 - logger.info(f"{self.name} | 无参数启动通用脚本:{self.script_exe_path}") + logger.info( + f"运行脚本任务:{self.script_set_exe_path},参数:{self.script_set_arguments}", + module=f"通用调度器-{self.name}", + ) self.script_process_manager.open_process( - self.script_exe_path, + self.script_set_exe_path, + self.script_set_arguments, tracking_time=60 if self.set["Script"]["IfTrackProcess"] else 0, ) # 记录当前时间 - start_time = datetime.now() + self.log_start_time = datetime.now() # 监测通用脚本运行状态 - self.start_monitor(start_time) + self.start_monitor() self.sub_config_path.mkdir(parents=True, exist_ok=True) if self.set["Script"]["ConfigPathMode"] == "文件夹": @@ -448,16 +605,23 @@ class GeneralManager(QObject): 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.error(f"{self.name} | 启动通用脚本时出现异常:{e}") + logger.exception( + f"启动通用脚本时出现异常:{e}", module=f"通用调度器-{self.name}" + ) self.push_info_bar.emit( - "error", - "启动通用脚本时出现异常", - "请检查相关设置", - -1, + "error", "启动通用脚本时出现异常", "请检查相关设置", -1 ) result_text = "" @@ -467,9 +631,17 @@ class GeneralManager(QObject): # 关闭可能未正常退出的通用脚本进程 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) @@ -522,12 +694,30 @@ class GeneralManager(QObject): ) 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"{self.name} | 收到任务中止申请") + """请求中止通用脚本任务""" + + logger.info(f"收到任务中止申请", module=f"通用调度器-{self.name}") if len(self.log_monitor.files()) != 0: self.interrupt.emit() @@ -537,29 +727,49 @@ class GeneralManager(QObject): 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: """刷新脚本日志""" - with self.script_log_path.open(mode="r", encoding="utf-8") as f: - pass + 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: - self.log_monitor.fileChanged.emit("1分钟超时检查") + logger.info("触发 1 分钟超时检查", module=f"通用调度器-{self.name}") + self.check_script_log() def strptime( self, date_string: str, format: str, default_date: datetime @@ -589,44 +799,55 @@ class GeneralManager(QObject): return datetime(**datetime_kwargs) - def check_script_log(self, start_time: datetime) -> list: + def check_script_log(self) -> None: """获取脚本日志并检查以判断脚本程序运行状态""" self.last_check_time = datetime.now() # 获取日志 - 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 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 > start_time: - if_log_start = True - logs.append(entry) - except ValueError: - pass - else: - logs.append(entry) - log = "".join(logs) + 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 len(logs) > 100: - self.update_log_text.emit("".join(logs[-100:])) - else: - self.update_log_text.emit("".join(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 = start_time - for _ in logs[::-1]: + 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]], @@ -637,6 +858,11 @@ class GeneralManager(QObject): 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!" @@ -668,18 +894,23 @@ class GeneralManager(QObject): else: self.script_result = "Success!" + logger.info( + f"通用脚本日志分析结果:{self.script_result}", + module=f"通用调度器-{self.name}", + ) + if self.script_result != "Wait": self.quit_monitor() - return logs - - def start_monitor(self, start_time: datetime) -> None: + def start_monitor(self) -> None: """开始监视通用脚本日志""" - logger.info(f"{self.name} | 开始监视通用脚本日志") + 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.fileChanged.connect(lambda: self.check_script_log(start_time)) self.log_monitor_timer.start(1000) self.last_check_time = datetime.now() self.monitor_loop.exec() @@ -689,19 +920,33 @@ class GeneralManager(QObject): if len(self.log_monitor.files()) != 0: - logger.info(f"{self.name} | 退出通用脚本日志监视") + logger.info( + f"通用脚本日志监视器移除路径:{self.script_log_path}", + module=f"通用调度器-{self.name}", + ) self.log_monitor.removePath(str(self.script_log_path)) - self.log_monitor.fileChanged.disconnect() - self.log_monitor_timer.stop() - self.last_check_time = None - self.monitor_loop.quit() + + 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"{self.name} | 配置脚本运行参数: {index}") + logger.info(f"开始配置脚本运行参数:{index}", module=f"通用调度器-{self.name}") # 配置前关闭可能未正常退出的脚本进程 - System.kill_process(self.script_exe_path) + if self.mode == "自动代理": + System.kill_process(self.script_exe_path) + elif self.mode == "设置通用脚本": + System.kill_process(self.script_set_exe_path) # 预导入配置文件 if self.mode == "设置通用脚本": @@ -732,11 +977,15 @@ class GeneralManager(QObject): 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"{self.name} | 开始执行{task_name}: {script_path}") + logger.info( + f"开始执行{task_name}: {script_path}", module=f"通用调度器-{self.name}" + ) # 根据文件类型选择执行方式 if script_path.suffix.lower() == ".py": @@ -744,7 +993,10 @@ class GeneralManager(QObject): elif script_path.suffix.lower() in [".bat", ".cmd", ".exe"]: cmd = [str(script_path)] elif script_path.suffix.lower() == "": - logger.warning(f"{self.name} | {task_name}脚本没有指定后缀名,无法执行") + logger.warning( + f"{task_name}脚本没有指定后缀名,无法执行", + module=f"通用调度器-{self.name}", + ) return False else: # 使用系统默认程序打开 @@ -767,23 +1019,32 @@ class GeneralManager(QObject): ) if result.returncode == 0: - logger.info(f"{self.name} | {task_name}执行成功") + logger.info(f"{task_name}执行成功", module=f"通用调度器-{self.name}") if result.stdout.strip(): - logger.info(f"{self.name} | {task_name}输出: {result.stdout}") + logger.info( + f"{task_name}输出: {result.stdout}", + module=f"通用调度器-{self.name}", + ) return True else: logger.error( - f"{self.name} | {task_name}执行失败,返回码: {result.returncode}" + f"{task_name}执行失败,返回码: {result.returncode}", + module=f"通用调度器-{self.name}", ) if result.stderr.strip(): - logger.error(f"{self.name} | {task_name}错误输出: {result.stderr}") + logger.error( + f"{task_name}错误输出: {result.stderr}", + module=f"通用调度器-{self.name}", + ) return False except subprocess.TimeoutExpired: - logger.error(f"{self.name} | {task_name}执行超时") + logger.error(f"{task_name}执行超时", module=f"通用调度器-{self.name}") return False except Exception as e: - logger.exception(f"{self.name} | 执行{task_name}时出现异常: {e}") + logger.exception( + f"执行{task_name}时出现异常: {e}", module=f"通用调度器-{self.name}" + ) return False def push_notification( @@ -795,6 +1056,11 @@ class GeneralManager(QObject): ) -> None: """通过所有渠道推送通知""" + logger.info( + f"开始推送通知,模式:{mode},标题:{title}", + module=f"通用调度器-{self.name}", + ) + env = Environment( loader=FileSystemLoader(str(Config.app_path / "resources/html")) ) @@ -902,9 +1168,7 @@ class GeneralManager(QObject): sub_data["Notify"]["ToAddress"], ) else: - logger.error( - f"{self.name} | 用户邮箱地址为空,无法发送用户单独的邮件通知" - ) + logger.error(f"用户邮箱地址为空,无法发送用户单独的邮件通知") # 发送ServerChan通知 if sub_data["Notify"]["IfServerChan"]: diff --git a/app/services/notification.py b/app/services/notification.py index 56ddf04..0fd13f4 100644 --- a/app/services/notification.py +++ b/app/services/notification.py @@ -33,13 +33,14 @@ 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 loguru import logger + from plyer import notification -from app.core import Config +from app.core import Config, logger from app.services.security import Crypto from app.utils.ImageUtils import ImageUtils @@ -51,11 +52,21 @@ class Notification(QObject): def __init__(self, parent=None): super().__init__(parent) - def push_plyer(self, title, message, ticker, t): - """推送系统通知""" + def push_plyer(self, title, message, ticker, t) -> bool: + """ + 推送系统通知 + + :param title: 通知标题 + :param message: 通知内容 + :param ticker: 通知横幅 + :param t: 通知持续时间 + :return: bool + """ if Config.get(Config.notify_IfPushPlyer): + logger.info(f"推送系统通知:{title}", module="通知服务") + notification.notify( title=title, message=message, @@ -69,7 +80,15 @@ class Notification(QObject): return True def send_mail(self, mode, title, content, to_address) -> None: - """推送邮件通知""" + """ + 推送邮件通知 + + :param mode: 邮件内容模式,支持 "文本" 和 "网页" + :param title: 邮件标题 + :param content: 邮件内容 + :param to_address: 收件人地址 + """ + if ( Config.get(Config.notify_SMTPServerAddress) == "" or Config.get(Config.notify_AuthorizationCode) == "" @@ -87,7 +106,8 @@ class Notification(QObject): ) ): logger.error( - "请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址" + "请正确设置邮件通知的SMTP服务器地址、授权码、发件人地址和收件人地址", + module="通知服务", ) self.push_info_bar.emit( "error", @@ -110,42 +130,43 @@ class Notification(QObject): ) ) # 发件人显示的名字 message["To"] = formataddr( - ( - Header("AUTO_MAA用户", "utf-8").encode(), - to_address, - ) + (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 = smtplib.SMTP_SSL(Config.get(Config.notify_SMTPServerAddress), 465) smtpObj.login( Config.get(Config.notify_FromAddress), Crypto.win_decryptor(Config.get(Config.notify_AuthorizationCode)), ) smtpObj.sendmail( - Config.get(Config.notify_FromAddress), - to_address, - message.as_string(), + Config.get(Config.notify_FromAddress), to_address, message.as_string() ) smtpObj.quit() - logger.success("邮件发送成功") - return None + logger.success(f"邮件发送成功:{title}", module="通知服务") except Exception as e: - logger.error(f"发送邮件时出错:\n{e}") + logger.exception(f"发送邮件时出错:{e}", module="通知服务") self.push_info_bar.emit("error", "发送邮件时出错", f"{e}", -1) - return None - return None - def ServerChanPush(self, title, content, send_key, tag, channel): - """使用Server酱推送通知""" + def ServerChanPush( + self, title, content, send_key, tag, channel + ) -> Union[bool, str]: + """ + 使用Server酱推送通知 + + :param title: 通知标题 + :param content: 通知内容 + :param send_key: Server酱的SendKey + :param tag: 通知标签 + :param channel: 通知频道 + :return: bool or str + """ + if not send_key: - logger.error("请正确设置Server酱的SendKey") + logger.error("请正确设置Server酱的SendKey", module="通知服务") self.push_info_bar.emit( "error", "Server酱通知推送异常", "请正确设置Server酱的SendKey", -1 ) @@ -176,7 +197,7 @@ class Notification(QObject): if is_valid(tags): options["tags"] = tags else: - logger.warning("Server酱 Tag 配置不正确,将被忽略") + logger.warning("Server酱 Tag 配置不正确,将被忽略", module="通知服务") self.push_info_bar.emit( "warning", "Server酱通知推送异常", @@ -187,7 +208,9 @@ class Notification(QObject): if is_valid(channels): options["channel"] = channels else: - logger.warning("Server酱 Channel 配置不正确,将被忽略") + logger.warning( + "Server酱 Channel 配置不正确,将被忽略", module="通知服务" + ) self.push_info_bar.emit( "warning", "Server酱通知推送异常", @@ -212,18 +235,20 @@ class Notification(QObject): result = response.json() if result.get("code") == 0: - logger.info("Server酱推送通知成功") + logger.success(f"Server酱推送通知成功:{title}", module="通知服务") return True else: error_code = result.get("code", "-1") - logger.error(f"Server酱通知推送失败:响应码:{error_code}") + 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("Server酱通知推送异常") + logger.exception(f"Server酱通知推送异常:{e}", module="通知服务") self.push_info_bar.emit( "error", "Server酱通知推送异常", @@ -232,10 +257,18 @@ class Notification(QObject): ) return f"Server酱通知推送异常:{str(e)}" - def CompanyWebHookBotPush(self, title, content, webhook_url): - """使用企业微信群机器人推送通知""" + def CompanyWebHookBotPush(self, title, content, webhook_url) -> Union[bool, str]: + """ + 使用企业微信群机器人推送通知 + + :param title: 通知标题 + :param content: 通知内容 + :param webhook_url: 企业微信群机器人的WebHook地址 + :return: bool or str + """ + if webhook_url == "": - logger.error("请正确设置企业微信群机器人的WebHook地址") + logger.error("请正确设置企业微信群机器人的WebHook地址", module="通知服务") self.push_info_bar.emit( "error", "企业微信群机器人通知推送异常", @@ -264,7 +297,7 @@ class Notification(QObject): err = e time.sleep(0.1) else: - logger.error(f"推送企业微信群机器人时出错:{err}") + logger.error(f"推送企业微信群机器人时出错:{err}", module="通知服务") self.push_info_bar.emit( "error", "企业微信群机器人通知推送失败", @@ -274,10 +307,10 @@ class Notification(QObject): return None if info["errcode"] == 0: - logger.info("企业微信群机器人推送通知成功") + logger.success(f"企业微信群机器人推送通知成功:{title}", module="通知服务") return True else: - logger.error(f"企业微信群机器人推送通知失败:{info}") + logger.error(f"企业微信群机器人推送通知失败:{info}", module="通知服务") self.push_info_bar.emit( "error", "企业微信群机器人通知推送失败", @@ -287,7 +320,14 @@ class Notification(QObject): return f"使用企业微信群机器人推送通知时出错:{err}" def CompanyWebHookBotPushImage(self, image_path: Path, webhook_url: str) -> bool: - """使用企业微信群机器人推送图片通知""" + """ + 使用企业微信群机器人推送图片通知 + + :param image_path: 图片文件路径 + :param webhook_url: 企业微信群机器人的WebHook地址 + :return: bool + """ + try: # 压缩图片 ImageUtils.compress_image_if_needed(image_path) @@ -295,7 +335,8 @@ class Notification(QObject): # 检查图片是否存在 if not image_path.exists(): logger.error( - "图片推送异常 | 图片不存在或者压缩失败,请检查图片路径是否正确" + "图片推送异常 | 图片不存在或者压缩失败,请检查图片路径是否正确", + module="通知服务", ) self.push_info_bar.emit( "error", @@ -306,7 +347,9 @@ class Notification(QObject): return False if not webhook_url: - logger.error("请正确设置企业微信群机器人的WebHook地址") + logger.error( + "请正确设置企业微信群机器人的WebHook地址", module="通知服务" + ) self.push_info_bar.emit( "error", "企业微信群机器人通知推送异常", @@ -320,7 +363,7 @@ class Notification(QObject): 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}") + logger.exception(f"图片编码或MD5计算失败:{e}", module="通知服务") self.push_info_bar.emit( "error", "企业微信群机器人通知推送异常", @@ -349,10 +392,12 @@ class Notification(QObject): break except requests.RequestException as e: err = e - logger.warning(f"推送企业微信群机器人图片第{_+1}次失败:{e}") + logger.exception( + f"推送企业微信群机器人图片第{_+1}次失败:{e}", module="通知服务" + ) time.sleep(0.1) else: - logger.error(f"推送企业微信群机器人图片时出错:{err}") + logger.error("推送企业微信群机器人图片时出错", module="通知服务") self.push_info_bar.emit( "error", "企业微信群机器人图片推送失败", @@ -362,10 +407,13 @@ class Notification(QObject): return False if info.get("errcode") == 0: - logger.info("企业微信群机器人推送图片成功") + logger.success( + f"企业微信群机器人推送图片成功:{image_path.name}", + module="通知服务", + ) return True else: - logger.error(f"企业微信群机器人推送图片失败:{info}") + logger.error(f"企业微信群机器人推送图片失败:{info}", module="通知服务") self.push_info_bar.emit( "error", "企业微信群机器人图片推送失败", @@ -386,6 +434,9 @@ class Notification(QObject): def send_test_notification(self): """发送测试通知到所有已启用的通知渠道""" + + logger.info("发送测试通知到所有已启用的通知渠道", module="通知服务") + # 发送系统通知 self.push_plyer( "测试通知", @@ -425,6 +476,8 @@ class Notification(QObject): Config.get(Config.notify_CompanyWebHookBotUrl), ) + logger.info("测试通知发送完成", module="通知服务") + return True diff --git a/app/services/security.py b/app/services/security.py index b7bcc1a..d597f60 100644 --- a/app/services/security.py +++ b/app/services/security.py @@ -25,18 +25,15 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger import hashlib import random import secrets import base64 import win32crypt -from pathlib import Path from Crypto.Cipher import AES from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_OAEP from Crypto.Util.Padding import pad, unpad -from typing import List, Dict, Union from app.core import Config @@ -44,7 +41,12 @@ 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) @@ -85,7 +87,12 @@ class CryptoHandler: (Config.app_path / "data/key/private_key.bin").write_bytes(private_key_local) def AUTO_encryptor(self, note: str) -> str: - """使用AUTO_MAA的算法加密数据""" + """ + 使用AUTO_MAA的算法加密数据 + + :param note: 数据明文 + :type note: str + """ if note == "": return "" @@ -100,7 +107,16 @@ class CryptoHandler: return base64.b64encode(encrypted).decode("utf-8") def AUTO_decryptor(self, note: str, PASSWORD: str) -> str: - """使用AUTO_MAA的算法解密数据""" + """ + 使用AUTO_MAA的算法解密数据 + + :param note: 数据密文 + :type note: str + :param PASSWORD: 管理密钥 + :type PASSWORD: str + :return: 解密后的明文 + :rtype: str + """ if note == "": return "" @@ -142,24 +158,31 @@ class CryptoHandler: return note def change_PASSWORD(self, PASSWORD_old: str, PASSWORD_new: str) -> None: - """修改管理密钥""" + """ + 修改管理密钥 - for member in Config.member_dict.values(): + :param PASSWORD_old: 旧管理密钥 + :type PASSWORD_old: str + :param PASSWORD_new: 新管理密钥 + :type PASSWORD_new: str + """ + + for script in Config.script_dict.values(): # 使用旧管理密钥解密 - if member["Type"] == "Maa": - for user in member["UserData"].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 member in Config.member_dict.values(): + for script in Config.script_dict.values(): # 使用新管理密钥重新加密 - if member["Type"] == "Maa": - for user in member["UserData"].values(): + if script["Type"] == "Maa": + for user in script["UserData"].values(): user["Config"].set( user["Config"].Info_Password, self.AUTO_encryptor(user["Password"]), @@ -168,20 +191,38 @@ class CryptoHandler: del user["Password"] def reset_PASSWORD(self, PASSWORD_new: str) -> None: - """重置管理密钥""" + """ + 重置管理密钥 + + :param PASSWORD_new: 新管理密钥 + :type PASSWORD_new: str + """ self.get_PASSWORD(PASSWORD_new) - for member in Config.member_dict.values(): + for script in Config.script_dict.values(): - if member["Type"] == "Maa": - for user in member["UserData"].values(): - user["Config"].set(user["Config"].Info_Password, "") + 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加密数据""" + """ + 使用Windows DPAPI加密数据 + + :param note: 数据明文 + :type note: str + :param description: 描述信息 + :type description: str + :param entropy: 随机熵 + :type entropy: bytes + :return: 加密后的数据 + :rtype: str + """ if note == "": return "" @@ -192,7 +233,16 @@ class CryptoHandler: return base64.b64encode(encrypted).decode("utf-8") def win_decryptor(self, note: str, entropy: bytes = None) -> str: - """使用Windows DPAPI解密数据""" + """ + 使用Windows DPAPI解密数据 + + :param note: 数据密文 + :type note: str + :param entropy: 随机熵 + :type entropy: bytes + :return: 解密后的明文 + :rtype: str + """ if note == "": return "" @@ -202,21 +252,15 @@ class CryptoHandler: ) return decrypted[1].decode("utf-8") - def search_member(self) -> List[Dict[str, Union[Path, list]]]: - """搜索所有脚本实例及其用户数据库路径""" - - member_list = [] - - if (Config.app_path / "config/MaaConfig").exists(): - for subdir in (Config.app_path / "config/MaaConfig").iterdir(): - if subdir.is_dir(): - - member_list.append({"Path": subdir / "user_data.db"}) - - return member_list - def check_PASSWORD(self, PASSWORD: str) -> bool: - """验证管理密钥""" + """ + 验证管理密钥 + + :param PASSWORD: 管理密钥 + :type PASSWORD: str + :return: 是否验证通过 + :rtype: bool + """ return bool( self.AUTO_decryptor(self.AUTO_encryptor("-"), PASSWORD) != "管理密钥错误" diff --git a/app/services/skland.py b/app/services/skland.py index d752662..901a352 100644 --- a/app/services/skland.py +++ b/app/services/skland.py @@ -32,7 +32,6 @@ v4.4 作者:DLmaster_361、ClozyA """ -from loguru import logger import time import json import hmac @@ -40,7 +39,7 @@ import hashlib import requests from urllib import parse -from app.core import Config +from app.core import Config, logger def skland_sign_in(token) -> dict: @@ -71,15 +70,16 @@ def skland_sign_in(token) -> dict: "vName": "1.5.1", } - # 生成签名 def generate_signature(token_for_sign: str, path, body_or_query): """ 生成请求签名 + :param token_for_sign: 用于加密的token :param path: 请求路径(如 /api/v1/game/player/binding) :param body_or_query: GET用query字符串,POST用body字符串 :return: (sign, 新的header_for_sign字典) """ + t = str(int(time.time()) - 2) # 时间戳,-2秒以防服务器时间不一致 token_bytes = token_for_sign.encode("utf-8") header_ca = dict(header_for_sign) @@ -91,10 +91,10 @@ def skland_sign_in(token) -> dict: md5 = hashlib.md5(hex_s.encode("utf-8")).hexdigest() return md5, header_ca - # 获取带签名的header def get_sign_header(url: str, method, body, old_header, sign_token): """ 获取带签名的请求头 + :param url: 请求完整url :param method: 请求方式 GET/POST :param body: POST请求体或GET时为None @@ -102,6 +102,7 @@ def skland_sign_in(token) -> dict: :param sign_token: 当前会话的签名token :return: 新请求头 """ + h = json.loads(json.dumps(old_header)) p = parse.urlparse(url) if method.lower() == "get": @@ -115,15 +116,21 @@ def skland_sign_in(token) -> dict: h[i] = header_ca[i] return h - # 复制请求头并添加cred def copy_header(cred): + """ + 复制请求头并添加cred + + :param cred: 当前会话的cred + :return: 新的请求头 + """ v = json.loads(json.dumps(header)) v["cred"] = cred return v - # 使用token一步步拿到cred和sign_token def login_by_token(token_code): """ + 使用token一步步拿到cred和sign_token + :param token_code: 你的skyland token :return: (cred, sign_token) """ @@ -136,8 +143,14 @@ def skland_sign_in(token) -> dict: grant_code = get_grant_code(token_code) return get_cred(grant_code) - # 通过grant code换cred和sign_token def get_cred(grant): + """ + 通过grant code获取cred和sign_token + + :param grant: grant code + :return: (cred, sign_token) + """ + rsp = requests.post( cred_code_url, json={"code": grant, "kind": 1}, @@ -153,8 +166,13 @@ def skland_sign_in(token) -> dict: cred = rsp["data"]["cred"] return cred, sign_token - # 通过token换grant code def get_grant_code(token): + """ + 通过token获取grant code + + :param token: 你的skyland token + :return: grant code + """ rsp = requests.post( grant_code_url, json={"appCode": app_code, "token": token, "type": 0}, @@ -170,10 +188,10 @@ def skland_sign_in(token) -> dict: ) return rsp["data"]["code"] - # 获取已绑定的角色列表 def get_binding_list(cred, sign_token): """ - 查询绑定的角色 + 查询已绑定的角色列表 + :param cred: 当前cred :param sign_token: 当前sign_token :return: 角色列表 @@ -190,9 +208,15 @@ def skland_sign_in(token) -> dict: }, ).json() if rsp["code"] != 0: - logger.error(f"森空岛服务 | 请求角色列表出现问题:{rsp['message']}") + logger.error( + f"森空岛服务 | 请求角色列表出现问题:{rsp['message']}", + module="森空岛签到", + ) if rsp.get("message") == "用户未登录": - logger.error(f"森空岛服务 | 用户登录可能失效了,请重新登录!") + logger.error( + f"森空岛服务 | 用户登录可能失效了,请重新登录!", + module="森空岛签到", + ) return v # 只取明日方舟(arknights)的绑定账号 for i in rsp["data"]["list"]: @@ -201,10 +225,10 @@ def skland_sign_in(token) -> dict: v.extend(i.get("bindingList")) return v - # 执行签到 def do_sign(cred, sign_token) -> dict: """ 对所有绑定的角色进行签到 + :param cred: 当前cred :param sign_token: 当前sign_token :return: 签到结果字典 @@ -257,5 +281,5 @@ def skland_sign_in(token) -> dict: # 依次签到 return do_sign(cred, sign_token) except Exception as e: - logger.error(f"森空岛服务 | 森空岛签到失败: {e}") + logger.exception(f"森空岛服务 | 森空岛签到失败: {e}", module="森空岛签到") return {"成功": [], "重复": [], "失败": [], "总计": 0} diff --git a/app/services/system.py b/app/services/system.py index e856061..36a9fba 100644 --- a/app/services/system.py +++ b/app/services/system.py @@ -25,7 +25,6 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtWidgets import QApplication import sys import ctypes @@ -38,7 +37,7 @@ import getpass from datetime import datetime from pathlib import Path -from app.core import Config +from app.core import Config, logger class _SystemHandler: @@ -147,9 +146,15 @@ class _SystemHandler: ) if result.returncode == 0: - logger.info(f"任务计划程序自启动已创建: {Config.app_path_sys}") + logger.success( + f"程序自启动任务计划已创建: {Config.app_path_sys}", + module="系统服务", + ) else: - logger.error(f"创建任务计划失败: {result.stderr}") + logger.error( + f"程序自启动任务计划创建失败: {result.stderr}", + module="系统服务", + ) finally: # 删除临时文件 @@ -159,7 +164,7 @@ class _SystemHandler: pass except Exception as e: - logger.exception(f"设置任务计划程序自启动失败: {e}") + logger.exception(f"程序自启动任务计划创建失败: {e}", module="系统服务") elif not Config.get(Config.start_IfSelfStart) and self.is_startup(): @@ -174,40 +179,54 @@ class _SystemHandler: ) if result.returncode == 0: - logger.info("任务计划程序自启动已删除") + logger.success("程序自启动任务计划已删除", module="系统服务") else: - logger.error(f"删除任务计划失败: {result.stderr}") + logger.error( + f"程序自启动任务计划删除失败: {result.stderr}", + module="系统服务", + ) except Exception as e: - logger.exception(f"删除任务计划程序自启动失败: {e}") + logger.exception(f"程序自启动任务计划删除失败: {e}", module="系统服务") def set_power(self, mode) -> None: + """ + 执行系统电源操作 + + :param mode: 电源操作模式,支持 "NoAction", "Shutdown", "Hibernate", "Sleep", "KillSelf", "ShutdownForce" + """ if sys.platform.startswith("win"): if mode == "NoAction": - logger.info("不执行系统电源操作") + logger.info("不执行系统电源操作", module="系统服务") elif mode == "Shutdown": - logger.info("执行关机操作") + self.kill_emulator_processes() + logger.info("执行关机操作", module="系统服务") subprocess.run(["shutdown", "/s", "/t", "0"]) + elif mode == "ShutdownForce": + logger.info("执行强制关机操作", module="系统服务") + subprocess.run(["shutdown", "/s", "/t", "0", "/f"]) + elif mode == "Hibernate": - logger.info("执行休眠操作") + logger.info("执行休眠操作", module="系统服务") subprocess.run(["shutdown", "/h"]) elif mode == "Sleep": - logger.info("执行睡眠操作") + logger.info("执行睡眠操作", module="系统服务") subprocess.run( ["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"] ) elif mode == "KillSelf": + logger.info("执行退出主程序操作", module="系统服务") Config.main_window.close() QApplication.quit() sys.exit(0) @@ -216,29 +235,50 @@ class _SystemHandler: if mode == "NoAction": - logger.info("不执行系统电源操作") + logger.info("不执行系统电源操作", module="系统服务") elif mode == "Shutdown": - logger.info("执行关机操作") + logger.info("执行关机操作", module="系统服务") subprocess.run(["shutdown", "-h", "now"]) elif mode == "Hibernate": - logger.info("执行休眠操作") + logger.info("执行休眠操作", module="系统服务") subprocess.run(["systemctl", "hibernate"]) elif mode == "Sleep": - logger.info("执行睡眠操作") + logger.info("执行睡眠操作", module="系统服务") subprocess.run(["systemctl", "suspend"]) elif mode == "KillSelf": + logger.info("执行退出主程序操作", module="系统服务") Config.main_window.close() QApplication.quit() sys.exit(0) + def kill_emulator_processes(self): + """这里暂时仅支持 MuMu 模拟器""" + + logger.info("正在清除模拟器进程", module="系统服务") + + keywords = ["Nemu", "nemu", "emulator", "MuMu"] + for proc in psutil.process_iter(["pid", "name"]): + try: + pname = proc.info["name"].lower() + if any(keyword.lower() in pname for keyword in keywords): + proc.kill() + logger.info( + f"已关闭 MuMu 模拟器进程: {proc.info['name']}", + module="系统服务", + ) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + logger.success("模拟器进程清除完成", module="系统服务") + def is_startup(self) -> bool: """判断程序是否已经开机自启""" @@ -252,11 +292,11 @@ class _SystemHandler: ) return result.returncode == 0 except Exception as e: - logger.error(f"检查任务计划程序失败: {e}") + logger.exception(f"检查任务计划程序失败: {e}", module="系统服务") return False def get_window_info(self) -> list: - """获取当前窗口信息""" + """获取当前前台窗口信息""" def callback(hwnd, window_info): if win32gui.IsWindowVisible(hwnd) and win32gui.GetWindowText(hwnd): @@ -270,7 +310,13 @@ class _SystemHandler: return window_info def kill_process(self, path: Path) -> None: - """根据路径中止进程""" + """ + 根据路径中止进程 + + :param path: 进程路径 + """ + + logger.info(f"开始中止进程: {path}", module="系统服务") for pid in self.search_pids(path): killprocess = subprocess.Popen( @@ -280,8 +326,17 @@ class _SystemHandler: ) killprocess.wait() + logger.success(f"进程已中止: {path}", module="系统服务") + def search_pids(self, path: Path) -> list: - """根据路径查找进程PID""" + """ + 根据路径查找进程PID + + :param path: 进程路径 + :return: 匹配的进程PID列表 + """ + + logger.info(f"开始查找进程 PID: {path}", module="系统服务") pids = [] for proc in psutil.process_iter(["pid", "exe"]): diff --git a/app/ui/Widget.py b/app/ui/Widget.py index a6c91e2..7af06f5 100644 --- a/app/ui/Widget.py +++ b/app/ui/Widget.py @@ -59,7 +59,6 @@ from qfluentwidgets import ( MessageBox, SubtitleLabel, SettingCard, - SpinBox, FluentIconBase, Signal, ComboBox, @@ -71,7 +70,6 @@ from qfluentwidgets import ( BodyLabel, QConfig, ConfigItem, - TimeEdit, OptionsConfigItem, TeachingTip, TransparentToolButton, @@ -98,6 +96,23 @@ 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): """输入对话框""" @@ -240,7 +255,9 @@ class NoticeMessageBox(MessageBoxBase): self.button_cancel.clicked.connect(self.cancelButton.click) self.index.index_cards[0].clicked.emit() - def __update_text(self, text: str): + def __update_text(self, index: int, text: str): + + self.currentIndex = index html = markdown.markdown(text).replace("\n", "") html = re.sub( @@ -258,7 +275,7 @@ class NoticeMessageBox(MessageBoxBase): class NoticeIndexCard(HeaderCardWidget): - index_changed = Signal(str) + index_changed = Signal(int, str) def __init__(self, title: str, content: Dict[str, str], parent=None): super().__init__(parent) @@ -274,12 +291,13 @@ class NoticeMessageBox(MessageBoxBase): self.index_cards.append(QuantifiedItemCard([index, ""])) self.index_cards[-1].clicked.connect( - partial(self.index_changed.emit, text) + 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) diff --git a/app/ui/dispatch_center.py b/app/ui/dispatch_center.py index d216411..3993dfa 100644 --- a/app/ui/dispatch_center.py +++ b/app/ui/dispatch_center.py @@ -25,7 +25,6 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtWidgets import ( QWidget, QVBoxLayout, @@ -48,7 +47,7 @@ from PySide6.QtGui import QTextCursor from typing import List, Dict -from app.core import Config, TaskManager, Task, MainInfoBar, SoundPlayer +from app.core import Config, TaskManager, Task, MainInfoBar, logger from .Widget import StatefulItemCard, ComboBoxMessageBox, PivotArea @@ -59,28 +58,34 @@ class DispatchCenter(QWidget): 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["主调度台"]) @@ -91,6 +96,7 @@ class DispatchCenter(QWidget): icon=FluentIcon.CAFE, ) + # 顶栏组合 h_layout = QHBoxLayout() h_layout.addWidget(self.multi_button) h_layout.addWidget(self.pivotArea) @@ -108,7 +114,13 @@ class DispatchCenter(QWidget): ) def add_board(self, task: Task) -> None: - """添加一个调度台界面""" + """ + 为任务添加一个调度台界面并绑定信号 + + :param task: 任务对象 + """ + + logger.info(f"添加调度台:{task.name}", module="调度中枢") dispatch_box = self.DispatchBox(task.name, self) @@ -129,19 +141,36 @@ class DispatchCenter(QWidget): 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("_主调度台","")}模式" + f"{task.name} - {task.mode.replace('_主调度台','')}模式" ) self.script_list["主调度台"].top_bar.Lable.show() self.script_list["主调度台"].top_bar.object.hide() @@ -170,8 +199,17 @@ class DispatchCenter(QWidget): 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() @@ -191,6 +229,8 @@ class DispatchCenter(QWidget): else: self.script_list["主调度台"].info.log_text.text.setText("没有任务被执行") + logger.success(f"主调度台成功断开:{name}", module="调度中枢") + def update_top_bar(self): """更新顶栏""" @@ -200,13 +240,13 @@ class DispatchCenter(QWidget): self.script_list["主调度台"].top_bar.object.addItem( ( "队列" - if info["Config"].get(info["Config"].queueSet_Name) == "" - else f"队列 - {info["Config"].get(info["Config"].queueSet_Name)}" + if info["Config"].get(info["Config"].QueueSet_Name) == "" + else f"队列 - {info["Config"].get(info["Config"].QueueSet_Name)}" ), userData=name, ) - for name, info in Config.member_dict.items(): + for name, info in Config.script_dict.items(): self.script_list["主调度台"].top_bar.object.addItem( ( f"实例 - {info['Type']}" @@ -218,7 +258,7 @@ class DispatchCenter(QWidget): if len(Config.queue_dict) == 1: self.script_list["主调度台"].top_bar.object.setCurrentIndex(0) - elif len(Config.member_dict) == 1: + elif len(Config.script_dict) == 1: self.script_list["主调度台"].top_bar.object.setCurrentIndex( len(Config.queue_dict) ) @@ -238,6 +278,7 @@ class DispatchCenter(QWidget): "Sleep": "睡眠", "Hibernate": "休眠", "Shutdown": "关机", + "ShutdownForce": "关机(强制)", } self.power_combox.currentIndexChanged.disconnect() self.power_combox.setCurrentText(mode_book[Config.power_sign]) @@ -253,10 +294,7 @@ class DispatchCenter(QWidget): self.power_combox.currentIndexChanged.connect(self.set_power_sign) logger.warning("没有正在运行的任务,无法设置任务完成后动作") MainInfoBar.push_info_bar( - "warning", - "没有正在运行的任务", - "无法设置任务完成后动作", - 5000, + "warning", "没有正在运行的任务", "无法设置任务完成后动作", 5000 ) else: @@ -264,7 +302,7 @@ class DispatchCenter(QWidget): Config.set_power_sign(self.power_combox.currentData()) def start_multi_task(self) -> None: - """开始任务""" + """开始多开任务""" # 获取所有可用的队列和实例 text_list = [] @@ -274,12 +312,12 @@ class DispatchCenter(QWidget): continue text_list.append( "队列" - if info["Config"].get(info["Config"].queueSet_Name) == "" - else f"队列 - {info["Config"].get(info["Config"].queueSet_Name)}" + 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.member_dict.items(): + for name, info in Config.script_dict.items(): if name in Config.running_list: continue text_list.append( @@ -300,7 +338,9 @@ class DispatchCenter(QWidget): 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()}") + logger.warning( + f"任务已存在:{choice.input[0].currentData()}", module="调度中枢" + ) MainInfoBar.push_info_bar( "warning", "任务已存在", choice.input[0].currentData(), 5000 ) @@ -308,7 +348,9 @@ class DispatchCenter(QWidget): if "调度队列" in choice.input[0].currentData(): - logger.info(f"用户添加任务:{choice.input[0].currentData()}") + logger.info( + f"用户添加任务:{choice.input[0].currentData()}", module="调度中枢" + ) TaskManager.add_task( "自动代理_新调度台", choice.input[0].currentData(), @@ -317,11 +359,13 @@ class DispatchCenter(QWidget): elif "脚本" in choice.input[0].currentData(): - logger.info(f"用户添加任务:{choice.input[0].currentData()}") + logger.info( + f"用户添加任务:{choice.input[0].currentData()}", module="调度中枢" + ) TaskManager.add_task( "自动代理_新调度台", f"自定义队列 - {choice.input[0].currentData()}", - {"Queue": {"Member_1": choice.input[0].currentData()}}, + {"Queue": {"Script_0": choice.input[0].currentData()}}, ) class DispatchBox(QWidget): @@ -384,24 +428,26 @@ class DispatchCenter(QWidget): Layout.addWidget(self.main_button) def start_main_task(self): - """开始任务""" + """从主调度台开始任务""" if self.object.currentIndex() == -1: - logger.warning("未选择调度对象") + logger.warning("未选择调度对象", module="调度中枢") MainInfoBar.push_info_bar( "warning", "未选择调度对象", "请选择后再开始任务", 5000 ) return None if self.mode.currentIndex() == -1: - logger.warning("未选择调度模式") + 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()}") + logger.warning( + f"任务已存在:{self.object.currentData()}", module="调度中枢" + ) MainInfoBar.push_info_bar( "warning", "任务已存在", self.object.currentData(), 5000 ) @@ -409,11 +455,11 @@ class DispatchCenter(QWidget): if ( "脚本" in self.object.currentData() - and Config.member_dict[self.object.currentData()]["Type"] + and Config.script_dict[self.object.currentData()]["Type"] == "General" and self.mode.currentData() == "人工排查" ): - logger.warning("通用脚本类型不存在人工排查功能") + logger.warning("通用脚本类型不存在人工排查功能", module="调度中枢") MainInfoBar.push_info_bar( "warning", "不支持的任务", "通用脚本无人工排查功能", 5000 ) @@ -421,7 +467,9 @@ class DispatchCenter(QWidget): if "调度队列" in self.object.currentData(): - logger.info(f"用户添加任务:{self.object.currentData()}") + logger.info( + f"用户添加任务:{self.object.currentData()}", module="调度中枢" + ) TaskManager.add_task( f"{self.mode.currentText()}_主调度台", self.object.currentData(), @@ -430,11 +478,13 @@ class DispatchCenter(QWidget): elif "脚本" in self.object.currentData(): - logger.info(f"用户添加任务:{self.object.currentData()}") + logger.info( + f"用户添加任务:{self.object.currentData()}", module="调度中枢" + ) TaskManager.add_task( f"{self.mode.currentText()}_主调度台", "自定义队列", - {"Queue": {"Member_1": self.object.currentData()}}, + {"Queue": {"Script_0": self.object.currentData()}}, ) class DispatchInfoCard(HeaderCardWidget): @@ -476,7 +526,11 @@ class DispatchCenter(QWidget): 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) @@ -495,7 +549,11 @@ class DispatchCenter(QWidget): self.Layout.addStretch(1) def update_task(self, task_list: list): - """更新任务队列""" + """ + 更新任务队列信息 + + :param task_list: 包含任务信息的任务列表 + """ for i in range(len(task_list)): @@ -514,7 +572,11 @@ class DispatchCenter(QWidget): 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) @@ -533,7 +595,11 @@ class DispatchCenter(QWidget): self.Layout.addStretch(1) def update_user(self, user_list: list): - """更新用户队列""" + """ + 更新用户队列信息 + + :param user_list: 包含用户信息的用户列表 + """ for i in range(len(user_list)): diff --git a/app/ui/downloader.py b/app/ui/downloader.py index ae3aac1..e9b3ae4 100644 --- a/app/ui/downloader.py +++ b/app/ui/downloader.py @@ -25,7 +25,6 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger import zipfile import requests import subprocess @@ -46,7 +45,7 @@ from PySide6.QtCore import QThread, Signal, QTimer, QEventLoop from typing import List, Dict, Union -from app.core import Config +from app.core import Config, logger from app.services import System @@ -83,6 +82,8 @@ class DownloadProcess(QThread): 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 @@ -97,7 +98,8 @@ class DownloadProcess(QThread): self.download_path.unlink() logger.info( - f"开始下载:{self.url},范围:{self.start_byte}-{self.end_byte},存储地址:{self.download_path}" + f"开始下载:{self.url},范围:{self.start_byte}-{self.end_byte},存储地址:{self.download_path}", + module="下载子线程", ) headers = ( @@ -129,13 +131,17 @@ class DownloadProcess(QThread): self.check_times -= 1 logger.error( - f"连接失败:{self.url},状态码:{response.status_code},剩余重试次数:{self.check_times}" + f"连接失败:{self.url},状态码:{response.status_code},剩余重试次数:{self.check_times}", + module="下载子线程", ) time.sleep(1) continue - logger.info(f"连接成功:{self.url},状态码:{response.status_code}") + logger.info( + f"连接成功:{self.url},状态码:{response.status_code}", + module="下载子线程", + ) downloaded_size = 0 with self.download_path.open(mode="wb") as f: @@ -155,13 +161,14 @@ class DownloadProcess(QThread): if self.download_path.exists(): self.download_path.unlink() self.accomplish.emit(0) - logger.info(f"下载中止:{self.url}") + 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} 秒" + f"下载完成:{self.url},实际下载大小:{downloaded_size} 字节,耗时:{time.time() - start_time:.2f} 秒", + module="下载子线程", ) break @@ -172,7 +179,8 @@ class DownloadProcess(QThread): self.check_times -= 1 logger.exception( - f"下载出错:{self.url},错误信息:{e},剩余重试次数:{self.check_times}" + f"下载出错:{self.url},错误信息:{e},剩余重试次数:{self.check_times}", + module="下载子线程", ) time.sleep(1) @@ -181,7 +189,7 @@ class DownloadProcess(QThread): if self.download_path.exists(): self.download_path.unlink() self.accomplish.emit(0) - logger.error(f"下载失败:{self.url}") + logger.error(f"下载失败:{self.url}", module="下载子线程") class ZipExtractProcess(QThread): @@ -195,6 +203,8 @@ class ZipExtractProcess(QThread): self.setObjectName(f"ZipExtractProcess-{name}") + logger.info(f"创建解压子线程:{self.objectName()}", module="解压子线程") + self.name = name self.app_path = app_path self.download_path = download_path @@ -204,7 +214,10 @@ class ZipExtractProcess(QThread): try: - logger.info(f"开始解压:{self.download_path} 到 {self.app_path}") + logger.info( + f"开始解压:{self.download_path} 到 {self.app_path}", + module="解压子线程", + ) while True: @@ -215,7 +228,10 @@ class ZipExtractProcess(QThread): 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}") + logger.success( + f"解压完成:{self.download_path} 到 {self.app_path}", + module="解压子线程", + ) break except PermissionError: if self.name == "AUTO_MAA": @@ -223,7 +239,10 @@ class ZipExtractProcess(QThread): System.kill_process(self.app_path / "AUTO_MAA.exe") else: self.info.emit(f"解压出错:{self.name}正在运行,正在等待其关闭") - logger.warning(f"解压出错:{self.name}正在运行,正在等待其关闭") + logger.warning( + f"解压出错:{self.name}正在运行,正在等待其关闭", + module="解压子线程", + ) time.sleep(1) except Exception as e: @@ -231,7 +250,7 @@ class ZipExtractProcess(QThread): 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}") + logger.exception(f"解压更新时出错:{e}", module="解压子线程") return None @@ -277,17 +296,27 @@ class DownloadManager(QDialog): 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.test_speed_task1() - self.speed_test_accomplish.connect(self.download_task1) + self.start_test_speed() + self.speed_test_accomplish.connect(self.start_download) elif self.config["mode"] == "MirrorChyan": - self.download_task1() + self.start_download() elif self.config["mode"] == "MirrorChyan": - self.download_task1() + self.start_download() def get_download_url(self, mode: str) -> Union[str, Dict[str, str]]: - """获取下载链接""" + """ + 生成下载链接 + + :param mode: "测速" 或 "下载" + :return: 测速模式返回 url 字典,下载模式返回 url 字符串 + """ url_dict = {} @@ -362,7 +391,8 @@ class DownloadManager(QDialog): if response.status_code == 200: return response.url - def test_speed_task1(self) -> None: + def start_test_speed(self) -> None: + """启动测速任务,下载4MB文件以测试下载速度""" if self.isInterruptionRequested: return None @@ -370,7 +400,9 @@ class DownloadManager(QDialog): url_dict = self.get_download_url("测速") self.test_speed_result: Dict[str, float] = {} - logger.info(f"测速链接:{url_dict}") + logger.info( + f"开始测速任务,链接:{list(url_dict.items())}", module="下载管理器" + ) for name, url in url_dict.items(): @@ -387,10 +419,11 @@ class DownloadManager(QDialog): ) self.test_speed_result[name] = -1 self.download_process_dict[name].accomplish.connect( - partial(self.test_speed_task2, name) + 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)) @@ -401,11 +434,22 @@ class DownloadManager(QDialog): 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 test_speed_task2(self, name: str, t: float) -> None: + def check_test_speed(self, name: str, t: float) -> None: + """ + 更新测速子任务wc信息,并检查测速任务是否允许结束 + + :param name: 测速任务的名称 + :param t: 测速任务的耗时 + """ # 计算下载速度 if self.isInterruptionRequested: @@ -453,12 +497,16 @@ class DownloadManager(QDialog): # 保存测速结果 self.config["speed_result"] = self.test_speed_result - logger.info(f"测速结果:{self.test_speed_result}") + logger.success( + f"测速完成,结果:{list(self.test_speed_result.items())}", + module="下载管理器", + ) self.update_info("测速完成!") self.speed_test_accomplish.emit() - def download_task1(self) -> None: + def start_download(self) -> None: + """开始下载任务""" if self.isInterruptionRequested: return None @@ -466,6 +514,8 @@ class DownloadManager(QDialog): url = self.get_download_url("下载") self.downloaded_size_list: List[List[int, bool]] = [] + logger.info(f"开始下载任务,链接:{url}", module="下载管理器") + response = requests.head( url, timeout=10, @@ -506,20 +556,27 @@ class DownloadManager(QDialog): ) self.downloaded_size_list.append([0, False]) self.download_process_dict[f"part{i}"].progress.connect( - partial(self.download_task2, i) + partial(self.update_download, i) ) self.download_process_dict[f"part{i}"].accomplish.connect( - partial(self.download_task3, i) + partial(self.check_download, i) ) self.download_process_dict[f"part{i}"].start() - def download_task2(self, index: str, current: int) -> None: - """更新下载进度""" + 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) @@ -538,7 +595,13 @@ class DownloadManager(QDialog): 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 download_task3(self, index: str, t: float) -> None: + def check_download(self, index: str, t: float) -> None: + """ + 更新下载子任务完成信息,检查下载任务是否完成,完成后自动执行后续处理任务 + + :param index: 下载任务的索引 + :param t: 下载任务的耗时 + """ # 标记下载线程完成 self.downloaded_size_list[index][1] = True @@ -560,7 +623,8 @@ class DownloadManager(QDialog): # 合并下载的分段文件 logger.info( - f"所有分段下载完成:{self.name},开始合并分段文件到 {self.download_path}" + f"所有分段下载完成:{self.name},开始合并分段文件到 {self.download_path}", + module="下载管理器", ) with self.download_path.open(mode="wb") as outfile: for i in range(self.config["thread_numb"]): @@ -571,7 +635,8 @@ class DownloadManager(QDialog): self.download_path.with_suffix(f".part{i}").unlink() logger.success( - f"合并完成:{self.name},下载文件大小:{self.download_path.stat().st_size} 字节" + f"合并完成:{self.name},下载文件大小:{self.download_path.stat().st_size} 字节", + module="下载管理器", ) self.update_info("正在解压更新文件") @@ -610,9 +675,21 @@ class DownloadManager(QDialog): 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) @@ -626,7 +703,7 @@ class DownloadManager(QDialog): def requestInterruption(self) -> None: """请求中断下载任务""" - logger.info("收到下载任务中止请求") + logger.info("收到下载任务中止请求", module="下载管理器") self.isInterruptionRequested = True diff --git a/app/ui/history.py b/app/ui/history.py index b253f1d..d0d45b3 100644 --- a/app/ui/history.py +++ b/app/ui/history.py @@ -25,7 +25,6 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtWidgets import ( QWidget, QVBoxLayout, @@ -51,11 +50,12 @@ from pathlib import Path from typing import List, Dict -from app.core import Config, SoundPlayer +from app.core import Config, SoundPlayer, logger from .Widget import StatefulItemCard, QuantifiedItemCard, QuickExpandGroupCard class History(QWidget): + """历史记录界面""" def __init__(self, parent=None): super().__init__(parent) @@ -81,10 +81,21 @@ class History(QWidget): 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(): @@ -100,6 +111,7 @@ class History(QWidget): 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)) @@ -154,7 +166,13 @@ class History(QWidget): 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": @@ -187,6 +205,7 @@ class History(QWidget): self.user_history_card_list = [] + # 生成用户历史记录卡片并添加到布局中 for user, info in user_dict.items(): self.user_history_card_list.append( self.UserHistoryCard(user, info, self) @@ -219,7 +238,12 @@ class History(QWidget): self.update_info("数据总览") def get_statistics(self, mode: str) -> dict: - """生成GUI相应结构化统计数据""" + """ + 生成GUI相应结构化统计数据 + + :param mode: 查询模式 + :return: 结构化统计数据 + """ history_info = Config.merge_statistic_info( self.user_history if mode == "数据总览" else [Path(mode)] @@ -244,7 +268,11 @@ class History(QWidget): return statistics_info def update_info(self, index: str) -> None: - """更新信息""" + """ + 更新信息到UI界面 + + :param index: 选择的索引 + """ # 移除已有统计信息UI组件 while self.statistics_card.count() > 0: @@ -254,8 +282,10 @@ class History(QWidget): 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) @@ -268,10 +298,12 @@ class History(QWidget): 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() @@ -291,6 +323,7 @@ class History(QWidget): self.setMinimumHeight(300) class IndexCard(HeaderCardWidget): + """历史记录索引卡片组""" index_changed = Signal(str) @@ -304,9 +337,11 @@ class History(QWidget): 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])) @@ -318,6 +353,7 @@ class History(QWidget): self.Layout.addStretch(1) class StatisticsCard(HeaderCardWidget): + """历史记录统计信息卡片组""" def __init__(self, name: str, item_list: list, parent=None): super().__init__(parent) @@ -340,6 +376,7 @@ class History(QWidget): self.Layout.addStretch(1) class LogCard(HeaderCardWidget): + """历史记录日志卡片""" def __init__(self, parent=None): super().__init__(parent) diff --git a/app/ui/home.py b/app/ui/home.py index fa3dba5..7cde6f6 100644 --- a/app/ui/home.py +++ b/app/ui/home.py @@ -25,7 +25,6 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtWidgets import ( QWidget, QVBoxLayout, @@ -49,7 +48,7 @@ import json from datetime import datetime from pathlib import Path -from app.core import Config, MainInfoBar, Network +from app.core import Config, MainInfoBar, Network, logger from .Widget import Banner, IconButton @@ -160,8 +159,12 @@ class Home(QWidget): def get_home_image(self) -> None: """获取主页图片""" + logger.info("获取主页图片", module="主页") + if Config.get(Config.function_HomeImageMode) == "默认": - pass + + logger.info("使用默认主页图片", module="主页") + elif Config.get(Config.function_HomeImageMode) == "自定义": file_path, _ = QFileDialog.getOpenFileName( @@ -180,7 +183,7 @@ class Home(QWidget): / f"resources/images/Home/BannerCustomize{Path(file_path).suffix}", ) - logger.info(f"自定义主页图片更换成功:{file_path}") + logger.info(f"自定义主页图片更换成功:{file_path}", module="主页") MainInfoBar.push_info_bar( "success", "主页图片更换成功", @@ -189,7 +192,7 @@ class Home(QWidget): ) else: - logger.warning("自定义主页图片更换失败:未选择图片文件") + logger.warning("自定义主页图片更换失败:未选择图片文件", module="主页") MainInfoBar.push_info_bar( "warning", "主页图片更换失败", @@ -198,10 +201,10 @@ class Home(QWidget): ) elif Config.get(Config.function_HomeImageMode) == "主题图像": - # 从远程服务器获取最新主题图像 + # 从远程服务器获取最新主题图像信息 network = Network.add_task( mode="get", - url="https://gitee.com/DLmaster_361/AUTO_MAA/raw/server/theme_image.json", + url="http://221.236.27.82:10197/d/AUTO_MAA/Server/theme_image.json", ) network.loop.exec() network_result = Network.get_result(network) @@ -209,7 +212,8 @@ class Home(QWidget): theme_image = network_result["response_json"] else: logger.warning( - f"获取最新主题图像时出错:{network_result['error_message']}" + f"获取最新主题图像时出错:{network_result['error_message']}", + module="主页", ) MainInfoBar.push_info_bar( "warning", @@ -230,6 +234,7 @@ class Home(QWidget): 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 ( @@ -253,7 +258,9 @@ class Home(QWidget): ) as f: json.dump(theme_image, f, ensure_ascii=False, indent=4) - logger.success(f"主题图像「{theme_image["name"]}」下载成功") + logger.success( + f"主题图像「{theme_image["name"]}」下载成功", module="主页" + ) MainInfoBar.push_info_bar( "success", "主题图像下载成功", @@ -264,7 +271,8 @@ class Home(QWidget): else: logger.warning( - f"下载最新主题图像时出错:{network_result['error_message']}" + f"下载最新主题图像时出错:{network_result['error_message']}", + module="主页", ) MainInfoBar.push_info_bar( "warning", @@ -275,18 +283,16 @@ class Home(QWidget): else: - logger.info("主题图像已是最新") + logger.info("主题图像已是最新", module="主页") MainInfoBar.push_info_bar( - "info", - "主题图像已是最新", - "主题图像已是最新!", - 3000, + "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") @@ -366,7 +372,7 @@ class ButtonGroup(SimpleCardWidget): doc_button = IconButton( FluentIcon.CHAT.icon(color=QColor("#fff")), tip_title="官方社群", - tip_content="加入官方群聊【AUTO_MAA绝赞DeBug中!】", + tip_content="加入官方群聊「AUTO_MAA绝赞DeBug中!」", isTooltip=True, ) doc_button.setIconSize(QSize(32, 32)) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 3a97d12..63715f6 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -25,7 +25,6 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtWidgets import QApplication, QSystemTrayIcon from qfluentwidgets import ( Action, @@ -41,14 +40,12 @@ from qfluentwidgets import ( ) from PySide6.QtGui import QIcon, QCloseEvent from PySide6.QtCore import QTimer -from datetime import datetime, timedelta -import shutil import darkdetect -from app.core import Config, TaskManager, MainTimer, MainInfoBar, SoundPlayer +from app.core import Config, logger, TaskManager, MainTimer, MainInfoBar, SoundPlayer from app.services import Notify, Crypto, System from .home import Home -from .member_manager import MemberManager +from .script_manager import ScriptManager from .plan_manager import PlanManager from .queue_manager import QueueManager from .dispatch_center import DispatchCenter @@ -57,6 +54,7 @@ from .setting import Setting class AUTO_MAA(MSFluentWindow): + """AUTO_MAA主界面""" def __init__(self): super().__init__() @@ -77,12 +75,14 @@ class AUTO_MAA(MSFluentWindow): 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.member_manager = MemberManager(self) + self.script_manager = ScriptManager(self) self.queue_manager = QueueManager(self) self.dispatch_center = DispatchCenter(self) self.history = History(self) @@ -96,7 +96,7 @@ class AUTO_MAA(MSFluentWindow): NavigationItemPosition.TOP, ) self.addSubInterface( - self.member_manager, + self.script_manager, FluentIcon.ROBOT, "脚本管理", FluentIcon.ROBOT, @@ -138,8 +138,10 @@ class AUTO_MAA(MSFluentWindow): 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 ) @@ -159,7 +161,7 @@ class AUTO_MAA(MSFluentWindow): # 开始任务菜单项 self.tray_menu.addActions( [ - Action(FluentIcon.PLAY, "运行自动代理", triggered=self.start_main_task), + Action(FluentIcon.PLAY, "运行启动时队列", triggered=self.start_up_task), Action( FluentIcon.PAUSE, "中止所有任务", @@ -181,10 +183,12 @@ class AUTO_MAA(MSFluentWindow): # 设置托盘菜单 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.member_manager.refresh_dashboard) + # 绑定各组件信号 + 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) @@ -205,6 +209,8 @@ class AUTO_MAA(MSFluentWindow): self.themeListener.systemThemeChanged.connect(self.switch_theme) self.themeListener.start() + logger.success("AUTO_MAA主程序初始化完成", module="主窗口") + def switch_theme(self) -> None: """切换主题""" @@ -348,8 +354,10 @@ class AUTO_MAA(MSFluentWindow): def start_up_task(self) -> None: """启动时任务""" - # 清理旧日志 - self.clean_old_logs() + logger.info("开始执行启动时任务", module="主窗口") + + # 清理旧历史记录 + Config.clean_old_history() # 清理安装包 if (Config.app_path / "AUTO_MAA-Setup.exe").exists(): @@ -358,6 +366,15 @@ class AUTO_MAA(MSFluentWindow): 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() @@ -368,10 +385,8 @@ class AUTO_MAA(MSFluentWindow): if Config.get(Config.function_HomeImageMode) == "主题图像": self.home.get_home_image() - # 直接运行主任务 - if Config.get(Config.start_IfRunDirectly): - - self.start_main_task() + # 启动定时器 + MainTimer.start() # 获取公告 self.setting.show_notice(if_first=True) @@ -395,16 +410,16 @@ class AUTO_MAA(MSFluentWindow): Config.queue_dict["调度队列_1"]["Config"].toDict(), ) - for config in [_ for _ in Config.args.config if _ in Config.member_dict]: + for config in [_ for _ in Config.args.config if _ in Config.script_dict]: TaskManager.add_task( "自动代理_新调度台", "自定义队列", - {"Queue": {"Member_1": config}}, + {"Queue": {"Script_0": config}}, ) if not any( - _ in (list(Config.member_dict.keys()) + list(Config.queue_dict.keys())) + _ in (list(Config.script_dict.keys()) + list(Config.queue_dict.keys())) for _ in Config.args.config ): @@ -420,68 +435,35 @@ class AUTO_MAA(MSFluentWindow): ) System.set_power("KillSelf") - def clean_old_logs(self): - """ - 删除超过用户设定天数的日志文件(基于目录日期) - """ + elif Config.args.mode == "gui": - if Config.get(Config.function_HistoryRetentionTime) == 0: - logger.info("由于用户设置日志永久保留,跳过日志清理") - return + self.start_up_queue() - deleted_count = 0 + logger.success("启动时任务执行完成", module="主窗口") - for date_folder in (Config.app_path / "history").iterdir(): - if not date_folder.is_dir(): - continue # 只处理日期文件夹 + def start_up_queue(self) -> None: + """启动时运行的调度队列""" - try: - # 只检查 `YYYY-MM-DD` 格式的文件夹 - folder_date = datetime.strptime(date_folder.name, "%Y-%m-%d") - if datetime.now() - folder_date > timedelta( - days=Config.get(Config.function_HistoryRetentionTime) - ): - shutil.rmtree(date_folder, ignore_errors=True) - deleted_count += 1 - logger.info(f"已删除超期日志目录: {date_folder}") - except ValueError: - logger.warning(f"非日期格式的目录: {date_folder}") + logger.info("开始调度启动时运行的调度队列", module="主窗口") - logger.info(f"清理完成: {deleted_count} 个日期目录") + for name, queue in Config.queue_dict.items(): - def start_main_task(self) -> None: - """启动主任务""" + if queue["Config"].get(queue["Config"].QueueSet_StartUpEnabled): - if "调度队列_1" in Config.queue_dict: + logger.info(f"自动添加任务:{name}", module="主窗口") + TaskManager.add_task( + "自动代理_新调度台", name, queue["Config"].toDict() + ) - logger.info("自动添加任务:调度队列_1") - TaskManager.add_task( - "自动代理_主调度台", - "调度队列_1", - Config.queue_dict["调度队列_1"]["Config"].toDict(), - ) - - elif "脚本_1" in Config.member_dict: - - logger.info("自动添加任务:脚本_1") - TaskManager.add_task( - "自动代理_主调度台", "自定义队列", {"Queue": {"Member_1": "脚本_1"}} - ) - - else: - - logger.warning("启动主任务失败:未找到有效的主任务配置文件") - MainInfoBar.push_info_bar( - "warning", "启动主任务失败", "「调度队列_1」与「脚本_1」均不存在", -1 - ) + logger.success("开始调度启动时运行的调度队列启动完成", module="主窗口") def __currentChanged(self, index: int) -> None: """切换界面时任务""" if index == 1: - self.member_manager.reload_plan_name() + self.script_manager.reload_plan_name() elif index == 3: - self.queue_manager.reload_member_name() + self.queue_manager.reload_script_name() elif index == 4: self.dispatch_center.pivot.setCurrentItem("主调度台") self.dispatch_center.update_top_bar() @@ -489,20 +471,18 @@ class AUTO_MAA(MSFluentWindow): def closeEvent(self, event: QCloseEvent): """清理残余进程""" + logger.info("保存窗口位置与大小信息", module="主窗口") self.show_ui("隐藏到托盘", if_quick=True) # 清理各功能线程 - MainTimer.Timer.stop() - MainTimer.Timer.deleteLater() - MainTimer.LongTimer.stop() - MainTimer.LongTimer.deleteLater() + MainTimer.stop() TaskManager.stop_task("ALL") # 关闭主题监听 self.themeListener.terminate() self.themeListener.deleteLater() - logger.info("AUTO_MAA主程序关闭") - logger.info("----------------END----------------") + 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 index 5c6c84d..3ee8cc0 100644 --- a/app/ui/plan_manager.py +++ b/app/ui/plan_manager.py @@ -25,7 +25,6 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtWidgets import ( QWidget, QVBoxLayout, @@ -43,7 +42,7 @@ from qfluentwidgets import ( from typing import List, Dict, Union import shutil -from app.core import Config, MainInfoBar, MaaPlanConfig, SoundPlayer +from app.core import Config, MainInfoBar, MaaPlanConfig, SoundPlayer, logger from .Widget import ( ComboBoxMessageBox, LineEditSettingCard, @@ -66,27 +65,20 @@ class PlanManager(QWidget): layout = QVBoxLayout(self) self.tools = CommandBar() - self.plan_manager = self.PlanSettingBox(self) # 逐个添加动作 self.tools.addActions( [ - Action(FluentIcon.ADD_TO, "新建计划表", triggered=self.add_setting_box), - Action( - FluentIcon.REMOVE_FROM, "删除计划表", triggered=self.del_setting_box - ), + 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_setting_box - ), - Action( - FluentIcon.RIGHT_ARROW, "向右移动", triggered=self.right_setting_box - ), + Action(FluentIcon.LEFT_ARROW, "向左移动", triggered=self.left_plan), + Action(FluentIcon.RIGHT_ARROW, "向右移动", triggered=self.right_plan), ] ) self.tools.addSeparator() @@ -94,7 +86,7 @@ class PlanManager(QWidget): layout.addWidget(self.tools) layout.addWidget(self.plan_manager) - def add_setting_box(self): + def add_plan(self): """添加一个计划表""" choice = ComboBoxMessageBox( @@ -109,6 +101,7 @@ class PlanManager(QWidget): index = len(Config.plan_dict) + 1 + # 初始化 MaaPlanConfig maa_plan_config = MaaPlanConfig() maa_plan_config.load( Config.app_path / f"config/MaaPlanConfig/计划_{index}/config.json", @@ -122,29 +115,30 @@ class PlanManager(QWidget): "Config": maa_plan_config, } + # 添加计划表到界面 self.plan_manager.add_MaaPlanSettingBox(index) self.plan_manager.switch_SettingBox(index) - logger.success(f"计划管理 计划_{index} 添加成功") + logger.success(f"计划管理 计划_{index} 添加成功", module="计划管理") MainInfoBar.push_info_bar( "success", "操作成功", f"添加计划表 计划_{index}", 3000 ) SoundPlayer.play("添加计划表") - def del_setting_box(self): + def del_plan(self): """删除一个计划表""" name = self.plan_manager.pivot.currentRouteKey() if name is None: - logger.warning("删除计划表时未选择计划表") + logger.warning("删除计划表时未选择计划表", module="计划管理") MainInfoBar.push_info_bar( "warning", "未选择计划表", "请选择一个计划表", 5000 ) return None if len(Config.running_list) > 0: - logger.warning("删除计划表时调度队列未停止运行") + logger.warning("删除计划表时调度队列未停止运行", module="计划管理") MainInfoBar.push_info_bar( "warning", "调度中心正在执行任务", "请等待或手动中止任务", 5000 ) @@ -153,8 +147,11 @@ class PlanManager(QWidget): 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): @@ -166,17 +163,17 @@ class PlanManager(QWidget): self.plan_manager.show_SettingBox(max(int(name[3:]) - 1, 1)) - logger.success(f"计划表 {name} 删除成功") + logger.success(f"计划表 {name} 删除成功", module="计划管理") MainInfoBar.push_info_bar("success", "操作成功", f"删除计划表 {name}", 3000) SoundPlayer.play("删除计划表") - def left_setting_box(self): + def left_plan(self): """向左移动计划表""" name = self.plan_manager.pivot.currentRouteKey() if name is None: - logger.warning("向左移动计划表时未选择计划表") + logger.warning("向左移动计划表时未选择计划表", module="计划管理") MainInfoBar.push_info_bar( "warning", "未选择计划表", "请选择一个计划表", 5000 ) @@ -185,21 +182,24 @@ class PlanManager(QWidget): index = int(name[3:]) if index == 1: - logger.warning("向左移动计划表时已到达最左端") + logger.warning("向左移动计划表时已到达最左端", module="计划管理") MainInfoBar.push_info_bar( "warning", "已经是第一个计划表", "无法向左移动", 5000 ) return None if len(Config.running_list) > 0: - logger.warning("向左移动计划表时调度队列未停止运行") + 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") ) @@ -215,16 +215,16 @@ class PlanManager(QWidget): self.plan_manager.show_SettingBox(index - 1) - logger.success(f"计划表 {name} 左移成功") + logger.success(f"计划表 {name} 左移成功", module="计划管理") MainInfoBar.push_info_bar("success", "操作成功", f"左移计划表 {name}", 3000) - def right_setting_box(self): + def right_plan(self): """向右移动计划表""" name = self.plan_manager.pivot.currentRouteKey() if name is None: - logger.warning("向右移动计划表时未选择计划表") + logger.warning("向右移动计划表时未选择计划表", module="计划管理") MainInfoBar.push_info_bar( "warning", "未选择计划表", "请选择一个计划表", 5000 ) @@ -233,21 +233,24 @@ class PlanManager(QWidget): index = int(name[3:]) if index == len(Config.plan_dict): - logger.warning("向右移动计划表时已到达最右端") + logger.warning("向右移动计划表时已到达最右端", module="计划管理") MainInfoBar.push_info_bar( "warning", "已经是最后一个计划表", "无法向右移动", 5000 ) return None if len(Config.running_list) > 0: - logger.warning("向右移动计划表时调度队列未停止运行") + 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") ) @@ -263,7 +266,7 @@ class PlanManager(QWidget): self.plan_manager.show_SettingBox(index + 1) - logger.success(f"计划表 {name} 右移成功") + logger.success(f"计划表 {name} 右移成功", module="计划管理") MainInfoBar.push_info_bar("success", "操作成功", f"右移计划表 {name}", 3000) class PlanSettingBox(QWidget): @@ -297,7 +300,11 @@ class PlanManager(QWidget): self.show_SettingBox(1) def show_SettingBox(self, index) -> None: - """加载所有子界面""" + """ + 加载所有子界面并切换到指定的子界面 + + :param index: 要显示的子界面索引 + """ Config.search_plan() @@ -308,7 +315,12 @@ class PlanManager(QWidget): 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 @@ -331,7 +343,11 @@ class PlanManager(QWidget): self.pivot.clear() def add_MaaPlanSettingBox(self, uid: int) -> None: - """添加一个MAA设置界面""" + """ + 添加一个MAA设置界面 + + :param uid: MAA计划表的唯一标识符 + """ maa_plan_setting_box = self.MaaPlanSettingBox(uid, self) @@ -475,6 +491,7 @@ class PlanManager(QWidget): ) def refresh_stage(self): + """刷新关卡列表""" for group, name_dict in self.item_dict.items(): diff --git a/app/ui/queue_manager.py b/app/ui/queue_manager.py index 303edda..16159b3 100644 --- a/app/ui/queue_manager.py +++ b/app/ui/queue_manager.py @@ -25,7 +25,6 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtWidgets import ( QWidget, QVBoxLayout, @@ -40,9 +39,9 @@ from qfluentwidgets import ( HeaderCardWidget, CommandBar, ) -from typing import List +from typing import List, Dict -from app.core import QueueConfig, Config, MainInfoBar, SoundPlayer +from app.core import QueueConfig, Config, MainInfoBar, SoundPlayer, logger from .Widget import ( SwitchSettingCard, ComboBoxSettingCard, @@ -70,36 +69,31 @@ class QueueManager(QWidget): # 逐个添加动作 self.tools.addActions( [ + Action(FluentIcon.ADD_TO, "新建调度队列", triggered=self.add_queue), Action( - FluentIcon.ADD_TO, "新建调度队列", triggered=self.add_setting_box - ), - Action( - FluentIcon.REMOVE_FROM, - "删除调度队列", - triggered=self.del_setting_box, + FluentIcon.REMOVE_FROM, "删除调度队列", triggered=self.del_queue ), ] ) self.tools.addSeparator() self.tools.addActions( [ - Action( - FluentIcon.LEFT_ARROW, "向左移动", triggered=self.left_setting_box - ), - Action( - FluentIcon.RIGHT_ARROW, "向右移动", triggered=self.right_setting_box - ), + 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_setting_box(self): + 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 @@ -111,27 +105,28 @@ class QueueManager(QWidget): "Config": queue_config, } + # 添加到配置界面 self.queue_manager.add_SettingBox(index) self.queue_manager.switch_SettingBox(index) - logger.success(f"调度队列_{index} 添加成功") + logger.success(f"调度队列_{index} 添加成功", module="队列管理") MainInfoBar.push_info_bar("success", "操作成功", f"添加 调度队列_{index}", 3000) SoundPlayer.play("添加调度队列") - def del_setting_box(self): + def del_queue(self): """删除一个调度队列实例""" name = self.queue_manager.pivot.currentRouteKey() if name is None: - logger.warning("未选择调度队列") + logger.warning("未选择调度队列", module="队列管理") MainInfoBar.push_info_bar( "warning", "未选择调度队列", "请先选择一个调度队列", 5000 ) return None if name in Config.running_list: - logger.warning("调度队列正在运行") + logger.warning("调度队列正在运行", module="队列管理") MainInfoBar.push_info_bar( "warning", "调度队列正在运行", "请先停止调度队列", 5000 ) @@ -140,8 +135,11 @@ class QueueManager(QWidget): 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(): @@ -153,17 +151,17 @@ class QueueManager(QWidget): self.queue_manager.show_SettingBox(max(int(name[5:]) - 1, 1)) - logger.success(f"{name} 删除成功") + logger.success(f"{name} 删除成功", module="队列管理") MainInfoBar.push_info_bar("success", "操作成功", f"删除 {name}", 3000) SoundPlayer.play("删除调度队列") - def left_setting_box(self): + def left_queue(self): """向左移动调度队列实例""" name = self.queue_manager.pivot.currentRouteKey() if name is None: - logger.warning("未选择调度队列") + logger.warning("未选择调度队列", module="队列管理") MainInfoBar.push_info_bar( "warning", "未选择调度队列", "请先选择一个调度队列", 5000 ) @@ -172,21 +170,24 @@ class QueueManager(QWidget): index = int(name[5:]) if index == 1: - logger.warning("向左移动调度队列时已到达最左端") + 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("相关调度队列正在运行") + 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") ) @@ -199,16 +200,16 @@ class QueueManager(QWidget): self.queue_manager.show_SettingBox(index - 1) - logger.success(f"{name} 左移成功") + logger.success(f"{name} 左移成功", module="队列管理") MainInfoBar.push_info_bar("success", "操作成功", f"左移 {name}", 3000) - def right_setting_box(self): + def right_queue(self): """向右移动调度队列实例""" name = self.queue_manager.pivot.currentRouteKey() if name is None: - logger.warning("未选择调度队列") + logger.warning("未选择调度队列", module="队列管理") MainInfoBar.push_info_bar( "warning", "未选择调度队列", "请先选择一个调度队列", 5000 ) @@ -217,21 +218,24 @@ class QueueManager(QWidget): index = int(name[5:]) if index == len(Config.queue_dict): - logger.warning("向右移动调度队列时已到达最右端") + 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("相关调度队列正在运行") + 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") ) @@ -244,14 +248,15 @@ class QueueManager(QWidget): self.queue_manager.show_SettingBox(index + 1) - logger.success(f"{name} 右移成功") + logger.success(f"{name} 右移成功", module="队列管理") MainInfoBar.push_info_bar("success", "操作成功", f"右移 {name}", 3000) - def reload_member_name(self): - """刷新调度队列成员""" + def reload_script_name(self): + """刷新调度队列脚本成员名称""" - member_list = [ - ["禁用"] + [_ for _ in Config.member_dict.keys()], + # 获取脚本成员列表 + script_list = [ + ["禁用"] + [_ for _ in Config.script_dict.keys()], ["未启用"] + [ ( @@ -259,41 +264,12 @@ class QueueManager(QWidget): if v["Config"].get_name() == "" else f"{k} - {v["Config"].get_name()}" ) - for k, v in Config.member_dict.items() + for k, v in Config.script_dict.items() ], ] for script in self.queue_manager.script_list: - - script.task.card_Member_1.reLoadOptions( - value=member_list[0], texts=member_list[1] - ) - script.task.card_Member_2.reLoadOptions( - value=member_list[0], texts=member_list[1] - ) - script.task.card_Member_3.reLoadOptions( - value=member_list[0], texts=member_list[1] - ) - script.task.card_Member_4.reLoadOptions( - value=member_list[0], texts=member_list[1] - ) - script.task.card_Member_5.reLoadOptions( - value=member_list[0], texts=member_list[1] - ) - script.task.card_Member_6.reLoadOptions( - value=member_list[0], texts=member_list[1] - ) - script.task.card_Member_7.reLoadOptions( - value=member_list[0], texts=member_list[1] - ) - script.task.card_Member_8.reLoadOptions( - value=member_list[0], texts=member_list[1] - ) - script.task.card_Member_9.reLoadOptions( - value=member_list[0], texts=member_list[1] - ) - script.task.card_Member_10.reLoadOptions( - value=member_list[0], texts=member_list[1] - ) + for card in script.task.card_dict.values(): + card.reLoadOptions(value=script_list[0], texts=script_list[1]) class QueueSettingBox(QWidget): @@ -337,7 +313,12 @@ class QueueManager(QWidget): 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 @@ -418,15 +399,23 @@ class QueueManager(QWidget): content="用于标识调度队列的名称", text="请输入调度队列名称", qconfig=self.config, - configItem=self.config.queueSet_Name, + configItem=self.config.QueueSet_Name, parent=self, ) - self.card_Enable = SwitchSettingCard( - icon=FluentIcon.HOME, - title="状态", - content="调度队列状态,仅启用时会执行定时任务", + self.card_StartUpEnabled = SwitchSettingCard( + icon=FluentIcon.CHECKBOX, + title="启动时运行", + content="调度队列启动时运行状态,启用后将在软件启动时自动运行本队列", qconfig=self.config, - configItem=self.config.queueSet_Enabled, + 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( @@ -439,15 +428,17 @@ class QueueManager(QWidget): "睡眠(win系统需禁用休眠)", "休眠", "关机", + "关机(强制)", ], qconfig=self.config, - configItem=self.config.queueSet_AfterAccomplish, + configItem=self.config.QueueSet_AfterAccomplish, parent=self, ) Layout = QVBoxLayout() Layout.addWidget(self.card_Name) - Layout.addWidget(self.card_Enable) + Layout.addWidget(self.card_StartUpEnabled) + Layout.addWidget(self.card_TimeEnable) Layout.addWidget(self.card_AfterAccomplish) self.viewLayout.addLayout(Layout) @@ -466,107 +457,29 @@ class QueueManager(QWidget): Layout_2 = QVBoxLayout(widget_2) Layout = QHBoxLayout() - self.card_Time_0 = TimeEditSettingCard( - icon=FluentIcon.STOP_WATCH, - title="定时 1", - content=None, - qconfig=self.config, - configItem_bool=self.config.time_TimeEnabled_0, - configItem_time=self.config.time_TimeSet_0, - parent=self, - ) - self.card_Time_1 = TimeEditSettingCard( - icon=FluentIcon.STOP_WATCH, - title="定时 2", - content=None, - qconfig=self.config, - configItem_bool=self.config.time_TimeEnabled_1, - configItem_time=self.config.time_TimeSet_1, - parent=self, - ) - self.card_Time_2 = TimeEditSettingCard( - icon=FluentIcon.STOP_WATCH, - title="定时 3", - content=None, - qconfig=self.config, - configItem_bool=self.config.time_TimeEnabled_2, - configItem_time=self.config.time_TimeSet_2, - parent=self, - ) - self.card_Time_3 = TimeEditSettingCard( - icon=FluentIcon.STOP_WATCH, - title="定时 4", - content=None, - qconfig=self.config, - configItem_bool=self.config.time_TimeEnabled_3, - configItem_time=self.config.time_TimeSet_3, - parent=self, - ) - self.card_Time_4 = TimeEditSettingCard( - icon=FluentIcon.STOP_WATCH, - title="定时 5", - content=None, - qconfig=self.config, - configItem_bool=self.config.time_TimeEnabled_4, - configItem_time=self.config.time_TimeSet_4, - parent=self, - ) - self.card_Time_5 = TimeEditSettingCard( - icon=FluentIcon.STOP_WATCH, - title="定时 6", - content=None, - qconfig=self.config, - configItem_bool=self.config.time_TimeEnabled_5, - configItem_time=self.config.time_TimeSet_5, - parent=self, - ) - self.card_Time_6 = TimeEditSettingCard( - icon=FluentIcon.STOP_WATCH, - title="定时 7", - content=None, - qconfig=self.config, - configItem_bool=self.config.time_TimeEnabled_6, - configItem_time=self.config.time_TimeSet_6, - parent=self, - ) - self.card_Time_7 = TimeEditSettingCard( - icon=FluentIcon.STOP_WATCH, - title="定时 8", - content=None, - qconfig=self.config, - configItem_bool=self.config.time_TimeEnabled_7, - configItem_time=self.config.time_TimeSet_7, - parent=self, - ) - self.card_Time_8 = TimeEditSettingCard( - icon=FluentIcon.STOP_WATCH, - title="定时 9", - content=None, - qconfig=self.config, - configItem_bool=self.config.time_TimeEnabled_8, - configItem_time=self.config.time_TimeSet_8, - parent=self, - ) - self.card_Time_9 = TimeEditSettingCard( - icon=FluentIcon.STOP_WATCH, - title="定时 10", - content=None, - qconfig=self.config, - configItem_bool=self.config.time_TimeEnabled_9, - configItem_time=self.config.time_TimeSet_9, - parent=self, - ) + 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_1.addWidget(self.card_Time_0) - Layout_1.addWidget(self.card_Time_1) - Layout_1.addWidget(self.card_Time_2) - Layout_1.addWidget(self.card_Time_3) - Layout_1.addWidget(self.card_Time_4) - Layout_2.addWidget(self.card_Time_5) - Layout_2.addWidget(self.card_Time_6) - Layout_2.addWidget(self.card_Time_7) - Layout_2.addWidget(self.card_Time_8) - Layout_2.addWidget(self.card_Time_9) Layout.addWidget(widget_1) Layout.addWidget(widget_2) @@ -580,8 +493,8 @@ class QueueManager(QWidget): self.setTitle("任务队列") self.config = config - member_list = [ - ["禁用"] + [_ for _ in Config.member_dict.keys()], + script_list = [ + ["禁用"] + [_ for _ in Config.script_dict.keys()], ["未启用"] + [ ( @@ -589,121 +502,32 @@ class QueueManager(QWidget): if v["Config"].get_name() == "" else f"{k} - {v["Config"].get_name()}" ) - for k, v in Config.member_dict.items() + for k, v in Config.script_dict.items() ], ] - self.card_Member_1 = NoOptionComboBoxSettingCard( - icon=FluentIcon.APPLICATION, - title="任务实例 1", - content="第一个调起的脚本任务实例", - value=member_list[0], - texts=member_list[1], - qconfig=self.config, - configItem=self.config.queue_Member_1, - parent=self, - ) - self.card_Member_2 = NoOptionComboBoxSettingCard( - icon=FluentIcon.APPLICATION, - title="任务实例 2", - content="第二个调起的脚本任务实例", - value=member_list[0], - texts=member_list[1], - qconfig=self.config, - configItem=self.config.queue_Member_2, - parent=self, - ) - self.card_Member_3 = NoOptionComboBoxSettingCard( - icon=FluentIcon.APPLICATION, - title="任务实例 3", - content="第三个调起的脚本任务实例", - value=member_list[0], - texts=member_list[1], - qconfig=self.config, - configItem=self.config.queue_Member_3, - parent=self, - ) - self.card_Member_4 = NoOptionComboBoxSettingCard( - icon=FluentIcon.APPLICATION, - title="任务实例 4", - content="第四个调起的脚本任务实例", - value=member_list[0], - texts=member_list[1], - qconfig=self.config, - configItem=self.config.queue_Member_4, - parent=self, - ) - self.card_Member_5 = NoOptionComboBoxSettingCard( - icon=FluentIcon.APPLICATION, - title="任务实例 5", - content="第五个调起的脚本任务实例", - value=member_list[0], - texts=member_list[1], - qconfig=self.config, - configItem=self.config.queue_Member_5, - parent=self, - ) - self.card_Member_6 = NoOptionComboBoxSettingCard( - icon=FluentIcon.APPLICATION, - title="任务实例 6", - content="第六个调起的脚本任务实例", - value=member_list[0], - texts=member_list[1], - qconfig=self.config, - configItem=self.config.queue_Member_6, - parent=self, - ) - self.card_Member_7 = NoOptionComboBoxSettingCard( - icon=FluentIcon.APPLICATION, - title="任务实例 7", - content="第七个调起的脚本任务实例", - value=member_list[0], - texts=member_list[1], - qconfig=self.config, - configItem=self.config.queue_Member_7, - parent=self, - ) - self.card_Member_8 = NoOptionComboBoxSettingCard( - icon=FluentIcon.APPLICATION, - title="任务实例 8", - content="第八个调起的脚本任务实例", - value=member_list[0], - texts=member_list[1], - qconfig=self.config, - configItem=self.config.queue_Member_8, - parent=self, - ) - self.card_Member_9 = NoOptionComboBoxSettingCard( - icon=FluentIcon.APPLICATION, - title="任务实例 9", - content="第九个调起的脚本任务实例", - value=member_list[0], - texts=member_list[1], - qconfig=self.config, - configItem=self.config.queue_Member_9, - parent=self, - ) - self.card_Member_10 = NoOptionComboBoxSettingCard( - icon=FluentIcon.APPLICATION, - title="任务实例 10", - content="第十个调起的脚本任务实例", - value=member_list[0], - texts=member_list[1], - qconfig=self.config, - configItem=self.config.queue_Member_10, - parent=self, - ) + self.card_dict: Dict[ + str, + NoOptionComboBoxSettingCard, + ] = {} Layout = QVBoxLayout() - Layout.addWidget(self.card_Member_1) - Layout.addWidget(self.card_Member_2) - Layout.addWidget(self.card_Member_3) - Layout.addWidget(self.card_Member_4) - Layout.addWidget(self.card_Member_5) - Layout.addWidget(self.card_Member_6) - Layout.addWidget(self.card_Member_7) - Layout.addWidget(self.card_Member_8) - Layout.addWidget(self.card_Member_9) - Layout.addWidget(self.card_Member_10) + + 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/member_manager.py b/app/ui/script_manager.py similarity index 82% rename from app/ui/member_manager.py rename to app/ui/script_manager.py index 13d5f07..9c2d5a7 100644 --- a/app/ui/member_manager.py +++ b/app/ui/script_manager.py @@ -25,7 +25,6 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtWidgets import ( QWidget, QFileDialog, @@ -55,12 +54,13 @@ from PySide6.QtCore import Signal from datetime import datetime from functools import partial from pathlib import Path -from typing import List, Union, Type +from typing import List, Dict, Union, Type import shutil import json from app.core import ( Config, + logger, MainInfoBar, TaskManager, MaaConfig, @@ -94,11 +94,12 @@ from .Widget import ( PushAndComboBoxSettingCard, StatusSwitchSetting, UserNoticeSettingCard, + NoticeMessageBox, PivotArea, ) -class MemberManager(QWidget): +class ScriptManager(QWidget): """脚本管理父界面""" def __init__(self, parent=None): @@ -109,31 +110,22 @@ class MemberManager(QWidget): layout = QVBoxLayout(self) self.tools = CommandBar() - - self.member_manager = self.MemberSettingBox(self) + self.script_manager = self.ScriptSettingBox(self) # 逐个添加动作 self.tools.addActions( [ + Action(FluentIcon.ADD_TO, "新建脚本实例", triggered=self.add_script), Action( - FluentIcon.ADD_TO, "新建脚本实例", triggered=self.add_setting_box - ), - Action( - FluentIcon.REMOVE_FROM, - "删除脚本实例", - triggered=self.del_setting_box, + FluentIcon.REMOVE_FROM, "删除脚本实例", triggered=self.del_script ), ] ) self.tools.addSeparator() self.tools.addActions( [ - Action( - FluentIcon.LEFT_ARROW, "向左移动", triggered=self.left_setting_box - ), - Action( - FluentIcon.RIGHT_ARROW, "向右移动", triggered=self.right_setting_box - ), + Action(FluentIcon.LEFT_ARROW, "向左移动", triggered=self.left_script), + Action(FluentIcon.RIGHT_ARROW, "向右移动", triggered=self.right_script), ] ) self.tools.addSeparator() @@ -141,7 +133,7 @@ class MemberManager(QWidget): Action( FluentIcon.DOWNLOAD, "脚本下载器", - triggered=self.member_downloader, + triggered=self.script_downloader, ) ) self.tools.addSeparator() @@ -154,9 +146,9 @@ class MemberManager(QWidget): self.tools.addAction(self.key) layout.addWidget(self.tools) - layout.addWidget(self.member_manager) + layout.addWidget(self.script_manager) - def add_setting_box(self): + def add_script(self): """添加一个脚本实例""" choice = ComboBoxMessageBox( @@ -167,10 +159,15 @@ class MemberManager(QWidget): ) 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.member_dict) + 1 + index = len(Config.script_dict) + 1 + # 初始化 MAA 配置 maa_config = MaaConfig() maa_config.load( Config.app_path / f"config/MaaConfig/脚本_{index}/config.json", @@ -181,19 +178,20 @@ class MemberManager(QWidget): parents=True, exist_ok=True ) - Config.member_dict[f"脚本_{index}"] = { + Config.script_dict[f"脚本_{index}"] = { "Type": "Maa", "Path": Config.app_path / f"config/MaaConfig/脚本_{index}", "Config": maa_config, "UserData": {}, } - self.member_manager.add_SettingBox( - index, self.MemberSettingBox.MaaSettingBox + # 添加 MAA 实例设置界面 + self.script_manager.add_SettingBox( + index, self.ScriptSettingBox.MaaSettingBox ) - self.member_manager.switch_SettingBox(index) + self.script_manager.switch_SettingBox(index) - logger.success(f"MAA实例 脚本_{index} 添加成功") + logger.success(f"MAA实例 脚本_{index} 添加成功", module="脚本管理") MainInfoBar.push_info_bar( "success", "操作成功", f"添加 MAA 实例 脚本_{index}", 3000 ) @@ -201,8 +199,9 @@ class MemberManager(QWidget): elif choice.input[0].currentText() == "通用": - index = len(Config.member_dict) + 1 + index = len(Config.script_dict) + 1 + # 初始化通用配置 general_config = GeneralConfig() general_config.load( Config.app_path / f"config/GeneralConfig/脚本_{index}/config.json", @@ -213,38 +212,39 @@ class MemberManager(QWidget): parents=True, exist_ok=True ) - Config.member_dict[f"脚本_{index}"] = { + Config.script_dict[f"脚本_{index}"] = { "Type": "General", "Path": Config.app_path / f"config/GeneralConfig/脚本_{index}", "Config": general_config, "SubData": {}, } - self.member_manager.add_SettingBox( - index, self.MemberSettingBox.GeneralSettingBox + # 添加通用实例设置界面 + self.script_manager.add_SettingBox( + index, self.ScriptSettingBox.GeneralSettingBox ) - self.member_manager.switch_SettingBox(index) + self.script_manager.switch_SettingBox(index) - logger.success(f"通用实例 脚本_{index} 添加成功") + logger.success(f"通用实例 脚本_{index} 添加成功", module="脚本管理") MainInfoBar.push_info_bar( "success", "操作成功", f"添加通用实例 脚本_{index}", 3000 ) SoundPlayer.play("添加脚本实例") - def del_setting_box(self): + def del_script(self): """删除一个脚本实例""" - name = self.member_manager.pivot.currentRouteKey() + name = self.script_manager.pivot.currentRouteKey() if name is None: - logger.warning("删除脚本实例时未选择脚本实例") + logger.warning("删除脚本实例时未选择脚本实例", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择脚本实例", "请选择一个脚本实例", 5000 ) return None if len(Config.running_list) > 0: - logger.warning("删除脚本实例时调度队列未停止运行") + logger.warning("删除脚本实例时调度队列未停止运行", module="脚本管理") MainInfoBar.push_info_bar( "warning", "调度中心正在执行任务", "请等待或手动中止任务", 5000 ) @@ -253,32 +253,35 @@ class MemberManager(QWidget): choice = MessageBox("确认", f"确定要删除 {name} 实例吗?", self.window()) if choice.exec(): - self.member_manager.clear_SettingBox() + logger.info(f"正在删除脚本实例: {name}", module="脚本管理") - shutil.rmtree(Config.member_dict[name]["Path"]) + 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.member_dict) + 1): - if Config.member_dict[f"脚本_{i}"]["Path"].exists(): - Config.member_dict[f"脚本_{i}"]["Path"].rename( - Config.member_dict[f"脚本_{i}"]["Path"].with_name(f"脚本_{i-1}") + 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.member_manager.show_SettingBox(max(int(name[3:]) - 1, 1)) + self.script_manager.show_SettingBox(max(int(name[3:]) - 1, 1)) - logger.success(f"脚本实例 {name} 删除成功") + logger.success(f"脚本实例 {name} 删除成功", module="脚本管理") MainInfoBar.push_info_bar( "success", "操作成功", f"删除脚本实例 {name}", 3000 ) SoundPlayer.play("删除脚本实例") - def left_setting_box(self): + def left_script(self): """向左移动脚本实例""" - name = self.member_manager.pivot.currentRouteKey() + name = self.script_manager.pivot.currentRouteKey() if name is None: - logger.warning("向左移动脚本实例时未选择脚本实例") + logger.warning("向左移动脚本实例时未选择脚本实例", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择脚本实例", "请选择一个脚本实例", 5000 ) @@ -287,46 +290,49 @@ class MemberManager(QWidget): index = int(name[3:]) if index == 1: - logger.warning("向左移动脚本实例时已到达最左端") + logger.warning("向左移动脚本实例时已到达最左端", module="脚本管理") MainInfoBar.push_info_bar( "warning", "已经是第一个脚本实例", "无法向左移动", 5000 ) return None if len(Config.running_list) > 0: - logger.warning("向左移动脚本实例时调度队列未停止运行") + logger.warning("向左移动脚本实例时调度队列未停止运行", module="脚本管理") MainInfoBar.push_info_bar( "warning", "调度中心正在执行任务", "请等待或手动中止任务", 5000 ) return None - self.member_manager.clear_SettingBox() + logger.info(f"正在向左移动脚本实例: {name}", module="脚本管理") - Config.member_dict[name]["Path"].rename( - Config.member_dict[name]["Path"].with_name("脚本_0") + 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.member_dict[f"脚本_{index-1}"]["Path"].rename( - Config.member_dict[f"脚本_{index-1}"]["Path"].with_name(name) + 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.member_dict[name]["Path"].with_name("脚本_0").rename( - Config.member_dict[name]["Path"].with_name(f"脚本_{index-1}") + 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.member_manager.show_SettingBox(index - 1) + self.script_manager.show_SettingBox(index - 1) - logger.success(f"脚本实例 {name} 左移成功") + logger.success(f"脚本实例 {name} 左移成功", module="脚本管理") MainInfoBar.push_info_bar("success", "操作成功", f"左移脚本实例 {name}", 3000) - def right_setting_box(self): + def right_script(self): """向右移动脚本实例""" - name = self.member_manager.pivot.currentRouteKey() + name = self.script_manager.pivot.currentRouteKey() if name is None: - logger.warning("向右移动脚本实例时未选择脚本实例") + logger.warning("向右移动脚本实例时未选择脚本实例", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择脚本实例", "请选择一个脚本实例", 5000 ) @@ -334,46 +340,49 @@ class MemberManager(QWidget): index = int(name[3:]) - if index == len(Config.member_dict): - logger.warning("向右移动脚本实例时已到达最右端") + 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("向右移动脚本实例时调度队列未停止运行") + logger.warning("向右移动脚本实例时调度队列未停止运行", module="脚本管理") MainInfoBar.push_info_bar( "warning", "调度中心正在执行任务", "请等待或手动中止任务", 5000 ) return None - self.member_manager.clear_SettingBox() + logger.info(f"正在向右移动脚本实例: {name}", module="脚本管理") - Config.member_dict[name]["Path"].rename( - Config.member_dict[name]["Path"].with_name("脚本_0") + 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.member_dict[f"脚本_{index+1}"]["Path"].rename( - Config.member_dict[f"脚本_{index+1}"]["Path"].with_name(name) + 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.member_dict[name]["Path"].with_name("脚本_0").rename( - Config.member_dict[name]["Path"].with_name(f"脚本_{index+1}") + 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.member_manager.show_SettingBox(index + 1) + self.script_manager.show_SettingBox(index + 1) - logger.success(f"脚本实例 {name} 右移成功") + logger.success(f"脚本实例 {name} 右移成功", module="脚本管理") MainInfoBar.push_info_bar("success", "操作成功", f"右移脚本实例 {name}", 3000) - def member_downloader(self): + def script_downloader(self): """脚本下载器""" if not Config.get(Config.update_MirrorChyanCDK): - logger.warning("脚本下载器未设置CDK") + logger.warning("脚本下载器未设置CDK", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未设置Mirror酱CDK", @@ -385,14 +394,17 @@ class MemberManager(QWidget): # 从远程服务器获取应用列表 network = Network.add_task( mode="get", - url="https://gitee.com/DLmaster_361/AUTO_MAA/raw/server/apps_info.json", + 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']}") + logger.warning( + f"获取应用列表时出错:{network_result['error_message']}", + module="脚本管理", + ) MainInfoBar.push_info_bar( "warning", "获取应用列表时出错", @@ -419,7 +431,9 @@ class MemberManager(QWidget): str(Config.app_path / f"script/{app_rid}"), ) if not folder: - logger.warning(f"选择{app_name}下载目录时未选择文件夹") + logger.warning( + f"选择{app_name}下载目录时未选择文件夹", module="脚本管理" + ) MainInfoBar.push_info_bar( "warning", "警告", f"未选择{app_name}下载目录", 5000 ) @@ -442,7 +456,10 @@ class MemberManager(QWidget): if app_info["code"] != 0: - logger.error(f"获取版本信息时出错:{app_info["msg"]}") + logger.error( + f"获取应用版本信息时出错:{app_info["msg"]}", + module="脚本管理", + ) error_remark_dict = { 1001: "获取版本信息的URL参数不正确", @@ -475,7 +492,10 @@ class MemberManager(QWidget): return None - logger.warning(f"获取版本信息时出错:{network_result['error_message']}") + logger.warning( + f"获取版本信息时出错:{network_result['error_message']}", + module="脚本管理", + ) MainInfoBar.push_info_bar( "warning", "获取版本信息时出错", @@ -484,10 +504,12 @@ class MemberManager(QWidget): ) return None + # 创建下载管理器并开始下载 + logger.info(f"开始下载{app_name},下载目录:{folder}", module="脚本管理") self.downloader = DownloadManager( Path(folder), app_rid, - None, + [], { "mode": "MirrorChyan", "thread_numb": 1, @@ -502,6 +524,7 @@ class MemberManager(QWidget): self.downloader.run() def show_password(self): + """显示或隐藏密码""" if Config.PASSWORD == "": choice = LineEditMessageBox( @@ -529,6 +552,7 @@ class MemberManager(QWidget): def reload_plan_name(self): """刷新计划表名称""" + # 生成计划列表信息 plan_list = [ ["固定"] + [_ for _ in Config.plan_dict.keys()], ["固定"] @@ -541,11 +565,13 @@ class MemberManager(QWidget): for k, v in Config.plan_dict.items() ], ] - for member in self.member_manager.script_list: - if isinstance(member, MemberManager.MemberSettingBox.MaaSettingBox): + # 刷新所有脚本实例的计划表名称 + for script in self.script_manager.script_list: - for user_setting in member.user_setting.user_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 @@ -562,25 +588,25 @@ class MemberManager(QWidget): def refresh_dashboard(self): """刷新所有脚本实例的仪表盘""" - for member in self.member_manager.script_list: + for script in self.script_manager.script_list: - if isinstance(member, MemberManager.MemberSettingBox.MaaSettingBox): - member.user_setting.user_manager.user_dashboard.load_info() - elif isinstance(member, MemberManager.MemberSettingBox.GeneralSettingBox): - member.branch_manager.sub_manager.sub_dashboard.load_info() + 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 member in self.member_manager.script_list: + for script in self.script_manager.script_list: - if isinstance(member, MemberManager.MemberSettingBox.MaaSettingBox): + if isinstance(script, ScriptManager.ScriptSettingBox.MaaSettingBox): - member.user_setting.user_manager.user_dashboard.load_info() - for user_setting in member.user_setting.user_manager.script_list: + 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 MemberSettingBox(QWidget): + class ScriptSettingBox(QWidget): """脚本管理子页面组""" def __init__(self, parent=None): @@ -597,8 +623,8 @@ class MemberManager(QWidget): self.script_list: List[ Union[ - MemberManager.MemberSettingBox.MaaSettingBox, - MemberManager.MemberSettingBox.GeneralSettingBox, + ScriptManager.ScriptSettingBox.MaaSettingBox, + ScriptManager.ScriptSettingBox.GeneralSettingBox, ] ] = [] @@ -616,11 +642,16 @@ class MemberManager(QWidget): self.show_SettingBox(1) def show_SettingBox(self, index) -> None: - """加载所有子界面""" + """ + 加载所有子界面并切换到指定子界面 - Config.search_member() + :param index: 要切换到的子界面索引 + :type index: int + """ - for name, info in Config.member_dict.items(): + 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": @@ -629,12 +660,19 @@ class MemberManager(QWidget): self.switch_SettingBox(index) def switch_SettingBox(self, index: int, if_chang_pivot: bool = True) -> None: - """切换到指定的子界面""" + """ + 切换到指定的子界面 - if len(Config.member_dict) == 0: + :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.member_dict): + if index > len(Config.script_dict): return None if if_chang_pivot: @@ -643,14 +681,14 @@ class MemberManager(QWidget): if isinstance( self.script_list[index - 1], - MemberManager.MemberSettingBox.MaaSettingBox, + ScriptManager.ScriptSettingBox.MaaSettingBox, ): self.script_list[index - 1].user_setting.user_manager.switch_SettingBox( "用户仪表盘" ) elif isinstance( self.script_list[index - 1], - MemberManager.MemberSettingBox.GeneralSettingBox, + ScriptManager.ScriptSettingBox.GeneralSettingBox, ): self.script_list[ index - 1 @@ -666,7 +704,14 @@ class MemberManager(QWidget): 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) @@ -686,7 +731,7 @@ class MemberManager(QWidget): super().__init__(parent) self.setObjectName(f"脚本_{uid}") - self.config = Config.member_dict[f"脚本_{uid}"]["Config"] + 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) @@ -762,6 +807,7 @@ class MemberManager(QWidget): self.viewLayout.addLayout(Layout) def PathClicked(self): + """选择MAA目录并验证""" folder = QFileDialog.getExistingDirectory( self, @@ -769,7 +815,9 @@ class MemberManager(QWidget): self.config.get(self.config.MaaSet_Path), ) if not folder or self.config.get(self.config.MaaSet_Path) == folder: - logger.warning("选择MAA目录时未选择文件夹或未更改文件夹") + logger.warning( + "选择MAA目录时未选择文件夹或未更改文件夹", module="脚本管理" + ) MainInfoBar.push_info_bar( "warning", "警告", "未选择文件夹或未更改文件夹", 5000 ) @@ -778,18 +826,20 @@ class MemberManager(QWidget): not (Path(folder) / "config/gui.json").exists() or not (Path(folder) / "MAA.exe").exists() ): - logger.warning("选择MAA目录时未找到MAA程序或配置文件") + logger.warning( + "选择MAA目录时未找到MAA程序或配置文件", module="脚本管理" + ) MainInfoBar.push_info_bar( "warning", "警告", "未找到MAA程序或配置文件", 5000 ) return None - (Config.member_dict[self.name]["Path"] / "Default").mkdir( + (Config.script_dict[self.name]["Path"] / "Default").mkdir( parents=True, exist_ok=True ) shutil.copy( Path(folder) / "config/gui.json", - Config.member_dict[self.name]["Path"] / "Default/gui.json", + Config.script_dict[self.name]["Path"] / "Default/gui.json", ) self.config.set(self.config.MaaSet_Path, folder) @@ -863,14 +913,6 @@ class MemberManager(QWidget): configItem=self.config.RunSet_AnnihilationWeeklyLimit, parent=self, ) - self.card_AutoUpdateMaa = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="自动代理时自动更新MAA", - content="执行自动代理任务时自动更新MAA,关闭后仍会进行MAA版本检查", - qconfig=self.config, - configItem=self.config.RunSet_AutoUpdateMaa, - parent=self, - ) widget = QWidget() Layout = QVBoxLayout(widget) @@ -881,7 +923,6 @@ class MemberManager(QWidget): Layout.addWidget(self.card_AnnihilationTimeLimit) Layout.addWidget(self.card_RoutineTimeLimit) Layout.addWidget(self.card_AnnihilationWeeklyLimit) - Layout.addWidget(self.card_AutoUpdateMaa) self.viewLayout.setContentsMargins(0, 0, 0, 0) self.viewLayout.setSpacing(0) self.addGroupWidget(widget) @@ -936,26 +977,32 @@ class MemberManager(QWidget): def add_user(self): """添加一个用户""" - index = len(Config.member_dict[self.name]["UserData"]) + 1 + index = len(Config.script_dict[self.name]["UserData"]) + 1 + logger.info(f"正在添加 {self.name} 用户_{index}", module="脚本管理") + + # 初始化用户配置信息 user_config = MaaUserConfig() user_config.load( - Config.member_dict[self.name]["Path"] + Config.script_dict[self.name]["Path"] / f"UserData/用户_{index}/config.json", user_config, ) user_config.save() - Config.member_dict[self.name]["UserData"][f"用户_{index}"] = { - "Path": Config.member_dict[self.name]["Path"] + 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} 添加成功") + logger.success( + f"{self.name} 用户_{index} 添加成功", module="脚本管理" + ) MainInfoBar.push_info_bar( "success", "操作成功", f"{self.name} 添加 用户_{index}", 3000 ) @@ -967,20 +1014,20 @@ class MemberManager(QWidget): name = self.user_manager.pivot.currentRouteKey() if name is None: - logger.warning("未选择用户") + logger.warning("未选择用户", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择用户", "请先选择一个用户", 5000 ) return None if name == "用户仪表盘": - logger.warning("试图删除用户仪表盘") + logger.warning("试图删除用户仪表盘", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择用户", "请勿尝试删除用户仪表盘", 5000 ) return None if self.name in Config.running_list: - logger.warning("所属脚本正在运行") + logger.warning("所属脚本正在运行", module="脚本管理") MainInfoBar.push_info_bar( "warning", "所属脚本正在运行", "请先停止任务", 5000 ) @@ -991,22 +1038,25 @@ class MemberManager(QWidget): ) if choice.exec(): + logger.info(f"正在删除 {self.name} {name}", module="脚本管理") + self.user_manager.clear_SettingBox() + # 删除用户配置文件并同步修改相应配置项 shutil.rmtree( - Config.member_dict[self.name]["UserData"][name]["Path"] + Config.script_dict[self.name]["UserData"][name]["Path"] ) for i in range( int(name[3:]) + 1, - len(Config.member_dict[self.name]["UserData"]) + 1, + len(Config.script_dict[self.name]["UserData"]) + 1, ): - if Config.member_dict[self.name]["UserData"][f"用户_{i}"][ + if Config.script_dict[self.name]["UserData"][f"用户_{i}"][ "Path" ].exists(): - Config.member_dict[self.name]["UserData"][f"用户_{i}"][ + Config.script_dict[self.name]["UserData"][f"用户_{i}"][ "Path" ].rename( - Config.member_dict[self.name]["UserData"][ + Config.script_dict[self.name]["UserData"][ f"用户_{i}" ]["Path"].with_name(f"用户_{i-1}") ) @@ -1015,7 +1065,9 @@ class MemberManager(QWidget): f"用户_{max(int(name[3:]) - 1, 1)}" ) - logger.success(f"{self.name} {name} 删除成功") + logger.success( + f"{self.name} {name} 删除成功", module="脚本管理" + ) MainInfoBar.push_info_bar( "success", "操作成功", f"{self.name} 删除 {name}", 3000 ) @@ -1027,13 +1079,13 @@ class MemberManager(QWidget): name = self.user_manager.pivot.currentRouteKey() if name is None: - logger.warning("未选择用户") + logger.warning("未选择用户", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择用户", "请先选择一个用户", 5000 ) return None if name == "用户仪表盘": - logger.warning("试图移动用户仪表盘") + logger.warning("试图移动用户仪表盘", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择用户", "请勿尝试移动用户仪表盘", 5000 ) @@ -1042,40 +1094,43 @@ class MemberManager(QWidget): index = int(name[3:]) if index == 1: - logger.warning("向前移动用户时已到达最左端") + logger.warning("向前移动用户时已到达最左端", module="脚本管理") MainInfoBar.push_info_bar( "warning", "已经是第一个用户", "无法向前移动", 5000 ) return None if self.name in Config.running_list: - logger.warning("所属脚本正在运行") + logger.warning("所属脚本正在运行", module="脚本管理") MainInfoBar.push_info_bar( "warning", "所属脚本正在运行", "请先停止任务", 5000 ) return None + logger.info(f"正在向前移动 {self.name} {name}", module="脚本管理") + self.user_manager.clear_SettingBox() - Config.member_dict[self.name]["UserData"][name]["Path"].rename( - Config.member_dict[self.name]["UserData"][name][ + # 移动用户配置文件并同步修改配置项 + Config.script_dict[self.name]["UserData"][name]["Path"].rename( + Config.script_dict[self.name]["UserData"][name][ "Path" ].with_name("用户_0") ) - Config.member_dict[self.name]["UserData"][f"用户_{index-1}"][ + Config.script_dict[self.name]["UserData"][f"用户_{index-1}"][ "Path" - ].rename(Config.member_dict[self.name]["UserData"][name]["Path"]) - Config.member_dict[self.name]["UserData"][name]["Path"].with_name( + ].rename(Config.script_dict[self.name]["UserData"][name]["Path"]) + Config.script_dict[self.name]["UserData"][name]["Path"].with_name( "用户_0" ).rename( - Config.member_dict[self.name]["UserData"][f"用户_{index-1}"][ + Config.script_dict[self.name]["UserData"][f"用户_{index-1}"][ "Path" ] ) self.user_manager.show_SettingBox(f"用户_{index - 1}") - logger.success(f"{self.name} {name} 前移成功") + logger.success(f"{self.name} {name} 前移成功", module="脚本管理") MainInfoBar.push_info_bar( "success", "操作成功", f"{self.name} 前移 {name}", 3000 ) @@ -1086,13 +1141,13 @@ class MemberManager(QWidget): name = self.user_manager.pivot.currentRouteKey() if name is None: - logger.warning("未选择用户") + logger.warning("未选择用户", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择用户", "请先选择一个用户", 5000 ) return None if name == "用户仪表盘": - logger.warning("试图删除用户仪表盘") + logger.warning("试图删除用户仪表盘", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择用户", "请勿尝试移动用户仪表盘", 5000 ) @@ -1100,41 +1155,43 @@ class MemberManager(QWidget): index = int(name[3:]) - if index == len(Config.member_dict[self.name]["UserData"]): - logger.warning("向后移动用户时已到达最右端") + 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("所属脚本正在运行") + logger.warning("所属脚本正在运行", module="脚本管理") MainInfoBar.push_info_bar( "warning", "所属脚本正在运行", "请先停止任务", 5000 ) return None + logger.info(f"正在向后移动 {self.name} {name}", module="脚本管理") + self.user_manager.clear_SettingBox() - Config.member_dict[self.name]["UserData"][name]["Path"].rename( - Config.member_dict[self.name]["UserData"][name][ + Config.script_dict[self.name]["UserData"][name]["Path"].rename( + Config.script_dict[self.name]["UserData"][name][ "Path" ].with_name("用户_0") ) - Config.member_dict[self.name]["UserData"][f"用户_{index+1}"][ + Config.script_dict[self.name]["UserData"][f"用户_{index+1}"][ "Path" - ].rename(Config.member_dict[self.name]["UserData"][name]["Path"]) - Config.member_dict[self.name]["UserData"][name]["Path"].with_name( + ].rename(Config.script_dict[self.name]["UserData"][name]["Path"]) + Config.script_dict[self.name]["UserData"][name]["Path"].with_name( "用户_0" ).rename( - Config.member_dict[self.name]["UserData"][f"用户_{index+1}"][ + Config.script_dict[self.name]["UserData"][f"用户_{index+1}"][ "Path" ] ) self.user_manager.show_SettingBox(f"用户_{index + 1}") - logger.success(f"{self.name} {name} 后移成功") + logger.success(f"{self.name} {name} 后移成功", module="脚本管理") MainInfoBar.push_info_bar( "success", "操作成功", f"{self.name} 后移 {name}", 3000 ) @@ -1158,7 +1215,7 @@ class MemberManager(QWidget): ) self.script_list: List[ - MemberManager.MemberSettingBox.MaaSettingBox.UserManager.UserSettingBox.UserMemberSettingBox + ScriptManager.ScriptSettingBox.MaaSettingBox.UserManager.UserSettingBox.UserMemberSettingBox ] = [] self.user_dashboard = self.UserDashboard(self.name, self) @@ -1180,11 +1237,16 @@ class MemberManager(QWidget): 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.member_dict[self.name]["UserData"].keys(): + for name in Config.script_dict[self.name]["UserData"].keys(): self.add_userSettingBox(name[3:]) self.switch_SettingBox(index) @@ -1192,13 +1254,20 @@ class MemberManager(QWidget): def switch_SettingBox( self, index: str, if_change_pivot: bool = True ) -> None: - """切换到指定的子界面""" + """ + 切换到指定的子界面 - if len(Config.member_dict[self.name]["UserData"]) == 0: + :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.member_dict[self.name]["UserData"] + Config.script_dict[self.name]["UserData"] ): return None @@ -1214,7 +1283,7 @@ class MemberManager(QWidget): ) def clear_SettingBox(self) -> None: - """清空所有子界面""" + """清空除用户仪表盘外所有子界面""" for sub_interface in self.script_list: Config.stage_refreshed.disconnect( @@ -1232,7 +1301,12 @@ class MemberManager(QWidget): 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) @@ -1292,8 +1366,14 @@ class MemberManager(QWidget): Config.PASSWORD_refreshed.connect(self.load_info) def load_info(self): + """加载用户信息到仪表盘""" - self.user_data = Config.member_dict[self.name]["UserData"] + logger.info( + f"正在加载 {self.name} 用户信息到仪表盘", + module="脚本管理", + ) + + self.user_data = Config.script_dict[self.name]["UserData"] self.dashboard.setRowCount(len(self.user_data)) @@ -1452,6 +1532,10 @@ class MemberManager(QWidget): int(name[3:]) - 1, 11, button ) + logger.success( + f"{self.name} 用户仪表盘成功加载信息", module="脚本管理" + ) + class UserMemberSettingBox(HeaderCardWidget): """用户管理子页面""" @@ -1461,10 +1545,10 @@ class MemberManager(QWidget): self.setObjectName(f"用户_{uid}") self.setTitle(f"用户 {uid}") self.name = name - self.config = Config.member_dict[self.name]["UserData"][ + self.config = Config.script_dict[self.name]["UserData"][ f"用户_{uid}" ]["Config"] - self.user_path = Config.member_dict[self.name]["UserData"][ + self.user_path = Config.script_dict[self.name]["UserData"][ f"用户_{uid}" ]["Path"] @@ -1512,7 +1596,14 @@ class MemberManager(QWidget): icon=FluentIcon.PROJECTOR, title="服务器", content="选择服务器类型", - texts=["官服", "B服"], + texts=[ + "官服", + "B服", + "悠星国际服", + "悠星日服", + "悠星韩服", + "繁中服", + ], qconfig=self.config, configItem=self.config.Info_Server, parent=self, @@ -1534,11 +1625,17 @@ class MemberManager(QWidget): configItem=self.config.Info_RemainedDay, parent=self, ) - self.card_Annihilation = PushAndSwitchButtonSettingCard( + self.card_Annihilation = ComboBoxSettingCard( icon=FluentIcon.CAFE, title="剿灭代理", content="剿灭代理子任务相关设置", - text="设置具体配置", + texts=[ + "关闭", + "当期剿灭", + "切尔诺伯格", + "龙门外环", + "龙门市区", + ], qconfig=self.config, configItem=self.config.Info_Annihilation, parent=self, @@ -1893,9 +1990,6 @@ class MemberManager(QWidget): self.card_InfrastMode.comboBox.currentIndexChanged.connect( self.switch_infrastructure ) - self.card_Annihilation.clicked.connect( - lambda: self.set_maa("Annihilation") - ) self.card_Routine.clicked.connect( lambda: self.set_maa("Routine") ) @@ -1915,22 +2009,20 @@ class MemberManager(QWidget): self.switch_infrastructure() def switch_mode(self) -> None: + """切换用户配置模式""" if self.config.get(self.config.Info_Mode) == "简洁": self.card_Routine.setVisible(False) - self.card_Server.setVisible(True) - self.card_Annihilation.button.setVisible(False) self.card_InfrastMode.setVisible(True) elif self.config.get(self.config.Info_Mode) == "详细": - self.card_Server.setVisible(False) self.card_InfrastMode.setVisible(False) - self.card_Annihilation.button.setVisible(True) self.card_Routine.setVisible(True) def switch_stage_mode(self) -> None: + """切换关卡配置模式""" for card, name in zip( [ @@ -1967,6 +2059,7 @@ class MemberManager(QWidget): ) def switch_infrastructure(self) -> None: + """切换基建配置模式""" if ( self.config.get(self.config.Info_InfrastMode) @@ -1988,6 +2081,7 @@ class MemberManager(QWidget): ) def refresh_stage(self): + """刷新关卡配置""" self.card_Stage.reLoadOptions( Config.stage_dict["ALL"]["value"], @@ -2011,6 +2105,7 @@ class MemberManager(QWidget): ) def refresh_password(self): + """刷新密码配置""" self.card_Password.setValue( self.card_Password.qconfig.get( @@ -2054,7 +2149,7 @@ class MemberManager(QWidget): """配置MAA子配置""" if self.name in Config.running_list: - logger.warning("所属脚本正在运行") + logger.warning("所属脚本正在运行", module="脚本管理") MainInfoBar.push_info_bar( "warning", "所属脚本正在运行", "请先停止任务", 5000 ) @@ -2252,7 +2347,7 @@ class MemberManager(QWidget): super().__init__(parent) self.setObjectName(f"脚本_{uid}") - self.config = Config.member_dict[f"脚本_{uid}"]["Config"] + 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) @@ -2360,6 +2455,20 @@ class MemberManager(QWidget): 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="脚本日志文件路径 - [必填]", @@ -2372,7 +2481,7 @@ class MemberManager(QWidget): self.card_LogPathFormat = LineEditSettingCard( icon=FluentIcon.PAGE_RIGHT, title="脚本日志文件名格式", - content="若脚本日志文件名中随时间变化,请填入时间格式,留空则不启用", + content="若脚本日志文件名中随时间变化,请填入时间格式文本,留空则不启用", text="请输入脚本日志文件名格式", qconfig=self.config, configItem=self.config.Script_LogPathFormat, @@ -2380,8 +2489,8 @@ class MemberManager(QWidget): ) self.card_LogTimeStart = SpinBoxSettingCard( icon=FluentIcon.PAGE_RIGHT, - title="脚本日志时间起始位置 - [必填]", - content="脚本日志中时间的起始位置,单位为字符", + title="脚本日志时间戳起始位置 - [必填]", + content="脚本日志中时间戳的起始位置,单位为字符", range=(1, 1024), qconfig=self.config, configItem=self.config.Script_LogTimeStart, @@ -2389,8 +2498,8 @@ class MemberManager(QWidget): ) self.card_LogTimeEnd = SpinBoxSettingCard( icon=FluentIcon.PAGE_RIGHT, - title="脚本日志时间结束位置 - [必填]", - content="脚本日志中时间的结束位置,单位为字符", + title="脚本日志时间戳结束位置 - [必填]", + content="脚本日志中时间戳的结束位置,单位为字符", range=(1, 1024), qconfig=self.config, configItem=self.config.Script_LogTimeEnd, @@ -2398,8 +2507,8 @@ class MemberManager(QWidget): ) self.card_LogTimeFormat = LineEditSettingCard( icon=FluentIcon.PAGE_RIGHT, - title="脚本日志时间格式 - [必填]", - content="脚本日志中时间的格式", + title="脚本日志时间戳格式 - [必填]", + content="脚本日志中时间戳的格式", text="请输入脚本日志时间格式", qconfig=self.config, configItem=self.config.Script_LogTimeFormat, @@ -2452,6 +2561,7 @@ class MemberManager(QWidget): 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) @@ -2463,7 +2573,12 @@ class MemberManager(QWidget): 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, @@ -2491,7 +2606,8 @@ class MemberManager(QWidget): self.config.set(configItem, str(old_path)) logger.warning( - f"配置路径 {new_path} 不在脚本根目录下,已重置为 {old_path}" + f"配置路径 {new_path} 不在脚本根目录下,已重置为 {old_path}", + module="脚本管理", ) MainInfoBar.push_info_bar( "warning", "路径异常", "所选路径不在脚本根目录下", 5000 @@ -2645,14 +2761,32 @@ class MemberManager(QWidget): 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) @@ -2667,10 +2801,20 @@ class MemberManager(QWidget): shutil.copy( file_path, - Config.member_dict[self.name]["Path"] / "config.json", + Config.script_dict[self.name]["Path"] / "config.json", ) self.config.load( - Config.member_dict[self.name]["Path"] / "config.json" + 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): @@ -2682,10 +2826,225 @@ class MemberManager(QWidget): 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): """分支管理父页面""" @@ -2736,26 +3095,34 @@ class MemberManager(QWidget): def add_sub(self): """添加一个配置""" - index = len(Config.member_dict[self.name]["SubData"]) + 1 + index = len(Config.script_dict[self.name]["SubData"]) + 1 + logger.info( + f"正在添加 {self.name} 的配置_{index}", module="脚本管理" + ) + + # 初始化通用配置 sub_config = GeneralSubConfig() sub_config.load( - Config.member_dict[self.name]["Path"] + Config.script_dict[self.name]["Path"] / f"SubData/配置_{index}/config.json", sub_config, ) sub_config.save() - Config.member_dict[self.name]["SubData"][f"配置_{index}"] = { - "Path": Config.member_dict[self.name]["Path"] + 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} 添加成功") + logger.success( + f"{self.name} 配置_{index} 添加成功", module="脚本管理" + ) MainInfoBar.push_info_bar( "success", "操作成功", f"{self.name} 添加 配置_{index}", 3000 ) @@ -2767,20 +3134,20 @@ class MemberManager(QWidget): name = self.sub_manager.pivot.currentRouteKey() if name is None: - logger.warning("未选择配置") + logger.warning("未选择配置", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择配置", "请先选择一个配置", 5000 ) return None if name == "配置仪表盘": - logger.warning("试图删除配置仪表盘") + logger.warning("试图删除配置仪表盘", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择配置", "请勿尝试删除配置仪表盘", 5000 ) return None if self.name in Config.running_list: - logger.warning("所属脚本正在运行") + logger.warning("所属脚本正在运行", module="脚本管理") MainInfoBar.push_info_bar( "warning", "所属脚本正在运行", "请先停止任务", 5000 ) @@ -2791,22 +3158,27 @@ class MemberManager(QWidget): ) if choice.exec(): + logger.info( + f"正在删除 {self.name} 的配置_{name}", module="脚本管理" + ) + self.sub_manager.clear_SettingBox() + # 删除配置文件并同步到相关配置项 shutil.rmtree( - Config.member_dict[self.name]["SubData"][name]["Path"] + Config.script_dict[self.name]["SubData"][name]["Path"] ) for i in range( int(name[3:]) + 1, - len(Config.member_dict[self.name]["SubData"]) + 1, + len(Config.script_dict[self.name]["SubData"]) + 1, ): - if Config.member_dict[self.name]["SubData"][f"配置_{i}"][ + if Config.script_dict[self.name]["SubData"][f"配置_{i}"][ "Path" ].exists(): - Config.member_dict[self.name]["SubData"][f"配置_{i}"][ + Config.script_dict[self.name]["SubData"][f"配置_{i}"][ "Path" ].rename( - Config.member_dict[self.name]["SubData"][ + Config.script_dict[self.name]["SubData"][ f"配置_{i}" ]["Path"].with_name(f"配置_{i-1}") ) @@ -2815,7 +3187,9 @@ class MemberManager(QWidget): f"配置_{max(int(name[3:]) - 1, 1)}" ) - logger.success(f"{self.name} {name} 删除成功") + logger.success( + f"{self.name} {name} 删除成功", module="脚本管理" + ) MainInfoBar.push_info_bar( "success", "操作成功", f"{self.name} 删除 {name}", 3000 ) @@ -2827,13 +3201,13 @@ class MemberManager(QWidget): name = self.sub_manager.pivot.currentRouteKey() if name is None: - logger.warning("未选择配置") + logger.warning("未选择配置", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择配置", "请先选择一个配置", 5000 ) return None if name == "配置仪表盘": - logger.warning("试图移动配置仪表盘") + logger.warning("试图移动配置仪表盘", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择配置", "请勿尝试移动配置仪表盘", 5000 ) @@ -2842,40 +3216,45 @@ class MemberManager(QWidget): index = int(name[3:]) if index == 1: - logger.warning("向前移动配置时已到达最左端") + logger.warning("向前移动配置时已到达最左端", module="脚本管理") MainInfoBar.push_info_bar( "warning", "已经是第一个配置", "无法向前移动", 5000 ) return None if self.name in Config.running_list: - logger.warning("所属脚本正在运行") + logger.warning("所属脚本正在运行", module="脚本管理") MainInfoBar.push_info_bar( "warning", "所属脚本正在运行", "请先停止任务", 5000 ) return None + logger.info( + f"正在将 {self.name} 的配置_{name} 前移", module="脚本管理" + ) + self.sub_manager.clear_SettingBox() - Config.member_dict[self.name]["SubData"][name]["Path"].rename( - Config.member_dict[self.name]["SubData"][name][ + # 移动配置文件并同步到相关配置项 + Config.script_dict[self.name]["SubData"][name]["Path"].rename( + Config.script_dict[self.name]["SubData"][name][ "Path" ].with_name("配置_0") ) - Config.member_dict[self.name]["SubData"][f"配置_{index-1}"][ + Config.script_dict[self.name]["SubData"][f"配置_{index-1}"][ "Path" - ].rename(Config.member_dict[self.name]["SubData"][name]["Path"]) - Config.member_dict[self.name]["SubData"][name]["Path"].with_name( + ].rename(Config.script_dict[self.name]["SubData"][name]["Path"]) + Config.script_dict[self.name]["SubData"][name]["Path"].with_name( "配置_0" ).rename( - Config.member_dict[self.name]["SubData"][f"配置_{index-1}"][ + Config.script_dict[self.name]["SubData"][f"配置_{index-1}"][ "Path" ] ) self.sub_manager.show_SettingBox(f"配置_{index - 1}") - logger.success(f"{self.name} {name} 前移成功") + logger.success(f"{self.name} {name} 前移成功", module="脚本管理") MainInfoBar.push_info_bar( "success", "操作成功", f"{self.name} 前移 {name}", 3000 ) @@ -2886,13 +3265,13 @@ class MemberManager(QWidget): name = self.sub_manager.pivot.currentRouteKey() if name is None: - logger.warning("未选择配置") + logger.warning("未选择配置", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择配置", "请先选择一个配置", 5000 ) return None if name == "配置仪表盘": - logger.warning("试图删除配置仪表盘") + logger.warning("试图删除配置仪表盘", module="脚本管理") MainInfoBar.push_info_bar( "warning", "未选择配置", "请勿尝试移动配置仪表盘", 5000 ) @@ -2900,41 +3279,46 @@ class MemberManager(QWidget): index = int(name[3:]) - if index == len(Config.member_dict[self.name]["SubData"]): - logger.warning("向后移动配置时已到达最右端") + 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("所属脚本正在运行") + logger.warning("所属脚本正在运行", module="脚本管理") MainInfoBar.push_info_bar( "warning", "所属脚本正在运行", "请先停止任务", 5000 ) return None + logger.info( + f"正在将 {self.name} 的配置_{name} 后移", module="脚本管理" + ) + self.sub_manager.clear_SettingBox() - Config.member_dict[self.name]["SubData"][name]["Path"].rename( - Config.member_dict[self.name]["SubData"][name][ + # 移动配置文件并同步到相关配置项 + Config.script_dict[self.name]["SubData"][name]["Path"].rename( + Config.script_dict[self.name]["SubData"][name][ "Path" ].with_name("配置_0") ) - Config.member_dict[self.name]["SubData"][f"配置_{index+1}"][ + Config.script_dict[self.name]["SubData"][f"配置_{index+1}"][ "Path" - ].rename(Config.member_dict[self.name]["SubData"][name]["Path"]) - Config.member_dict[self.name]["SubData"][name]["Path"].with_name( + ].rename(Config.script_dict[self.name]["SubData"][name]["Path"]) + Config.script_dict[self.name]["SubData"][name]["Path"].with_name( "配置_0" ).rename( - Config.member_dict[self.name]["SubData"][f"配置_{index+1}"][ + Config.script_dict[self.name]["SubData"][f"配置_{index+1}"][ "Path" ] ) self.sub_manager.show_SettingBox(f"配置_{index + 1}") - logger.success(f"{self.name} {name} 后移成功") + logger.success(f"{self.name} {name} 后移成功", module="脚本管理") MainInfoBar.push_info_bar( "success", "操作成功", f"{self.name} 后移 {name}", 3000 ) @@ -2958,7 +3342,7 @@ class MemberManager(QWidget): ) self.script_list: List[ - MemberManager.MemberSettingBox.GeneralSettingBox.BranchManager.SubConfigSettingBox.SubMemberSettingBox + ScriptManager.ScriptSettingBox.GeneralSettingBox.BranchManager.SubConfigSettingBox.SubMemberSettingBox ] = [] self.sub_dashboard = self.SubDashboard(self.name, self) @@ -2980,11 +3364,15 @@ class MemberManager(QWidget): self.show_SettingBox("配置仪表盘") def show_SettingBox(self, index: str) -> None: - """加载所有子界面""" + """ + 加载所有子界面 + + :param index: 要显示的子界面索引 + """ Config.search_general_sub(self.name) - for name in Config.member_dict[self.name]["SubData"].keys(): + for name in Config.script_dict[self.name]["SubData"].keys(): self.add_SettingBox(name[3:]) self.switch_SettingBox(index) @@ -2992,13 +3380,18 @@ class MemberManager(QWidget): def switch_SettingBox( self, index: str, if_change_pivot: bool = True ) -> None: - """切换到指定的子界面""" + """ + 切换到指定的子界面 - if len(Config.member_dict[self.name]["SubData"]) == 0: + :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.member_dict[self.name]["SubData"] + Config.script_dict[self.name]["SubData"] ): return None @@ -3026,7 +3419,11 @@ class MemberManager(QWidget): self.pivot.addItem(routeKey="配置仪表盘", text="配置仪表盘") def add_SettingBox(self, uid: int) -> None: - """添加一个配置设置界面""" + """ + 添加一个配置设置界面 + + :param uid: 配置的唯一标识符 + """ setting_box = self.SubMemberSettingBox(self.name, uid, self) @@ -3073,8 +3470,14 @@ class MemberManager(QWidget): Config.PASSWORD_refreshed.connect(self.load_info) def load_info(self): + """加载配置仪表盘信息""" - self.sub_data = Config.member_dict[self.name]["SubData"] + logger.info( + f"正在加载 {self.name} 的配置仪表盘信息", + module="脚本管理", + ) + + self.sub_data = Config.script_dict[self.name]["SubData"] self.dashboard.setRowCount(len(self.sub_data)) @@ -3127,6 +3530,10 @@ class MemberManager(QWidget): int(name[3:]) - 1, 4, button ) + logger.success( + f"{self.name} 配置仪表盘信息加载成功", module="脚本管理" + ) + class SubMemberSettingBox(HeaderCardWidget): """配置管理子页面""" @@ -3136,10 +3543,10 @@ class MemberManager(QWidget): self.setObjectName(f"配置_{uid}") self.setTitle(f"配置 {uid}") self.name = name - self.config = Config.member_dict[self.name]["SubData"][ + self.config = Config.script_dict[self.name]["SubData"][ f"配置_{uid}" ]["Config"] - self.sub_path = Config.member_dict[self.name]["SubData"][ + self.sub_path = Config.script_dict[self.name]["SubData"][ f"配置_{uid}" ]["Path"] @@ -3302,7 +3709,7 @@ class MemberManager(QWidget): """配置子配置""" if self.name in Config.running_list: - logger.warning("所属脚本正在运行") + logger.warning("所属脚本正在运行", module="脚本管理") MainInfoBar.push_info_bar( "warning", "所属脚本正在运行", "请先停止任务", 5000 ) diff --git a/app/ui/setting.py b/app/ui/setting.py index 71c35c7..48c90f4 100644 --- a/app/ui/setting.py +++ b/app/ui/setting.py @@ -25,7 +25,6 @@ v4.4 作者:DLmaster_361 """ -from loguru import logger from PySide6.QtWidgets import QWidget, QVBoxLayout from PySide6.QtGui import QIcon from PySide6.QtCore import Qt @@ -49,7 +48,7 @@ from packaging import version from pathlib import Path from typing import Dict, Union -from app.core import Config, MainInfoBar, Network, SoundPlayer +from app.core import Config, MainInfoBar, Network, SoundPlayer, logger from app.services import Crypto, System, Notify from .downloader import DownloadManager from .Widget import ( @@ -124,7 +123,7 @@ class Setting(QWidget): self.window(), ) if choice.exec(): - logger.success("确认授权bilibili游戏隐私政策") + logger.success("确认授权bilibili游戏隐私政策", module="设置界面") MainInfoBar.push_info_bar( "success", "操作成功", "已确认授权bilibili游戏隐私政策", 3000 ) @@ -132,7 +131,7 @@ class Setting(QWidget): Config.set(Config.function_IfAgreeBilibili, False) else: - logger.info("取消授权bilibili游戏隐私政策") + logger.info("取消授权bilibili游戏隐私政策", module="设置界面") MainInfoBar.push_info_bar( "info", "操作成功", "已取消授权bilibili游戏隐私政策", 3000 ) @@ -158,7 +157,7 @@ class Setting(QWidget): MuMu_splash_ads_path.touch() - logger.success("开启跳过MuMu启动广告功能") + logger.success("开启跳过MuMu启动广告功能", module="设置界面") MainInfoBar.push_info_bar( "success", "操作成功", "已开启跳过MuMu启动广告功能", 3000 ) @@ -170,7 +169,7 @@ class Setting(QWidget): if MuMu_splash_ads_path.exists() and MuMu_splash_ads_path.is_file(): MuMu_splash_ads_path.unlink() - logger.info("关闭跳过MuMu启动广告功能") + logger.info("关闭跳过MuMu启动广告功能", module="设置界面") MainInfoBar.push_info_bar( "info", "操作成功", "已关闭跳过MuMu启动广告功能", 3000 ) @@ -181,6 +180,8 @@ class Setting(QWidget): if Config.key_path.exists(): return None + logger.info("未设置管理密钥,开始要求用户进行设置", module="设置界面") + while True: choice = LineEditMessageBox( @@ -188,6 +189,7 @@ class Setting(QWidget): ) if choice.exec() and choice.input.text() != "": Crypto.get_PASSWORD(choice.input.text()) + logger.success("成功设置管理密钥", module="设置界面") break else: choice = MessageBox( @@ -207,10 +209,7 @@ class Setting(QWidget): while if_change: choice = LineEditMessageBox( - self.window(), - "请输入旧的管理密钥", - "旧管理密钥", - "密码", + self.window(), "请输入旧的管理密钥", "旧管理密钥", "密码" ) if choice.exec() and choice.input.text() != "": @@ -231,6 +230,7 @@ class Setting(QWidget): # 修改管理密钥 Crypto.change_PASSWORD(PASSWORD_old, choice.input.text()) + logger.success("成功修改管理密钥", module="设置界面") MainInfoBar.push_info_bar( "success", "操作成功", "管理密钥修改成功", 3000 ) @@ -291,6 +291,7 @@ class Setting(QWidget): # 重置管理密钥 Crypto.reset_PASSWORD(choice.input.text()) + logger.success("成功重置管理密钥", module="设置界面") MainInfoBar.push_info_bar( "success", "操作成功", "管理密钥重置成功", 3000 ) @@ -316,7 +317,12 @@ class Setting(QWidget): ) 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("."))) @@ -339,7 +345,9 @@ class Setting(QWidget): if version_info["code"] != 0: - logger.error(f"获取版本信息时出错:{version_info['msg']}") + logger.error( + f"获取版本信息时出错:{version_info['msg']}", module="设置界面" + ) error_remark_dict = { 1001: "获取版本信息的URL参数不正确", @@ -372,7 +380,10 @@ class Setting(QWidget): return None - logger.warning(f"获取版本信息时出错:{network_result['error_message']}") + logger.warning( + f"获取版本信息时出错:{network_result['error_message']}", + module="设置界面", + ) MainInfoBar.push_info_bar( "warning", "获取版本信息时出错", @@ -462,7 +473,7 @@ class Setting(QWidget): # 从远程服务器获取代理信息 network = Network.add_task( mode="get", - url="https://gitee.com/DLmaster_361/AUTO_MAA/raw/server/download_info.json", + url="http://221.236.27.82:10197/d/AUTO_MAA/Server/download_info.json", ) network.loop.exec() network_result = Network.get_result(network) @@ -470,11 +481,12 @@ class Setting(QWidget): download_info = network_result["response_json"] else: logger.warning( - f"获取应用列表时出错:{network_result['error_message']}" + f"获取下载信息时出错:{network_result['error_message']}", + module="设置界面", ) MainInfoBar.push_info_bar( "warning", - "获取应用列表时出错", + "获取下载信息时出错", f"网络错误:{network_result['status_code']}", 5000, ) @@ -492,6 +504,8 @@ class Setting(QWidget): "download_dict": download_info["download_dict"], } + logger.info("开始执行更新任务", module="设置界面") + self.downloader = DownloadManager( Config.app_path, "AUTO_MAA", remote_version, download_config ) @@ -526,6 +540,9 @@ class Setting(QWidget): SoundPlayer.play("无新版本") def start_setup(self) -> None: + """启动安装程序""" + + logger.info("启动安装程序", module="设置界面") subprocess.Popen( [ Config.app_path / "AUTO_MAA-Setup.exe", @@ -548,14 +565,17 @@ class Setting(QWidget): # 从远程服务器获取最新公告 network = Network.add_task( mode="get", - url="https://gitee.com/DLmaster_361/AUTO_MAA/raw/server/notice.json", + 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']}") + logger.warning( + f"获取最新公告时出错:{network_result['error_message']}", + module="设置界面", + ) MainInfoBar.push_info_bar( "warning", "获取最新公告时出错", @@ -786,14 +806,6 @@ class StartSettingCard(HeaderCardWidget): configItem=Config.start_IfSelfStart, parent=self, ) - self.card_IfRunDirectly = SwitchSettingCard( - icon=FluentIcon.PAGE_RIGHT, - title="启动后直接运行主任务", - content="启动AUTO_MAA后自动运行自动代理任务,优先级:调度队列 1 > 脚本 1", - qconfig=Config, - configItem=Config.start_IfRunDirectly, - parent=self, - ) self.card_IfMinimizeDirectly = SwitchSettingCard( icon=FluentIcon.PAGE_RIGHT, title="启动后直接最小化", @@ -805,7 +817,6 @@ class StartSettingCard(HeaderCardWidget): Layout = QVBoxLayout() Layout.addWidget(self.card_IfSelfStart) - Layout.addWidget(self.card_IfRunDirectly) Layout.addWidget(self.card_IfMinimizeDirectly) self.viewLayout.addLayout(Layout) @@ -1192,7 +1203,7 @@ class UpdaterSettingCard(HeaderCardWidget): parent=self, ) mirrorchyan_url = HyperlinkButton( - "https://mirrorchyan.com/zh/get-start?source=auto_maa-setting_card", + "https://mirrorchyan.com/zh/get-start?source=auto_maa-setting", "获取Mirror酱CDK", self, ) diff --git a/app/utils/AUTO_MAA.iss b/app/utils/AUTO_MAA.iss index ebb2710..5dec878 100644 --- a/app/utils/AUTO_MAA.iss +++ b/app/utils/AUTO_MAA.iss @@ -20,7 +20,7 @@ AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} AppSupportURL={#MyAppURL} AppUpdatesURL={#MyAppURL} -DefaultDirName=D:\{#MyAppName} +DefaultDirName={autopf}\{#MyAppName} UninstallDisplayIcon={app}\{#MyAppExeName} ; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run ; on anything but x64 and Windows 11 on Arm. @@ -32,13 +32,13 @@ ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible DisableProgramGroupPage=yes LicenseFile={#MyAppPath}\LICENSE -; Remove the following line to run in administrative install mode (install for all users). -PrivilegesRequired=lowest +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" @@ -51,6 +51,8 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ 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 @@ -62,7 +64,7 @@ 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 +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall runascurrentuser [Code] var @@ -70,7 +72,10 @@ var function InitializeUninstall: Boolean; begin - DeleteDataQuestion := MsgBox('您确认要完全移除 AUTO_MAA 的所有用户数据文件与子组件吗?', mbConfirmation, MB_YESNO) = IDYES; + DeleteDataQuestion := MsgBox('您确认要完全移除 AUTO_MAA 的所有配置、用户数据与子组件吗?' + #13#10 + + '选择"是"将删除所有配置文件、数据与子组件程序。' + #13#10 + + '选择"否"将保留数据文件与子组件。', + mbConfirmation, MB_YESNO) = IDYES; Result := True; end; diff --git a/app/utils/ProcessManager.py b/app/utils/ProcessManager.py index c06d21f..a57406d 100644 --- a/app/utils/ProcessManager.py +++ b/app/utils/ProcessManager.py @@ -48,12 +48,7 @@ class ProcessManager(QObject): 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: + def open_process(self, path: Path, args: list = [], tracking_time: int = 60) -> int: """ 启动一个新进程并返回其pid,并开始监视该进程 @@ -89,7 +84,7 @@ class ProcessManager(QObject): # 扫描并记录所有相关进程 try: - # 获取主进程及其子进程 + # 获取主进程 main_proc = psutil.Process(self.main_pid) self.tracked_pids.add(self.main_pid) diff --git a/main.py b/main.py index 85e963b..f255c39 100644 --- a/main.py +++ b/main.py @@ -44,13 +44,14 @@ def no_print(*args, **kwargs): builtins.print = no_print -from loguru import logger import os import sys import ctypes from PySide6.QtWidgets import QApplication from qfluentwidgets import FluentTranslator +from app.core.logger import logger + def is_admin() -> bool: """检查当前程序是否以管理员身份运行""" diff --git a/requirements.txt b/requirements.txt index ab2ff4b..49ef1b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ loguru==0.7.3 plyer==2.1.0 PySide6==6.9.1 -PySide6-Fluent-Widgets[full] +PySide6-Fluent-Widgets[full]==1.8.3 psutil==7.0.0 pywin32==310 keyboard==0.13.5 @@ -12,4 +12,5 @@ requests==2.32.4 markdown==3.8.2 Jinja2==3.1.6 nuitka==2.7.12 -pillow==11.3.0 \ No newline at end of file +pillow==11.3.0 +packaging==25.0 diff --git a/resources/docs/MAA_config_info.txt b/resources/docs/MAA_config_info.txt index 8bce40e..a19847f 100644 --- a/resources/docs/MAA_config_info.txt +++ b/resources/docs/MAA_config_info.txt @@ -27,6 +27,8 @@ "MainFunction.Stage4": "" #备选关卡3 "Fight.RemainingSanityStage": "Annihilation" #剩余理智关卡 "MainFunction.Series.Quantity": "1" #连战次数 +"MainFunction.Annihilation.UseCustom": "True" #自定义剿灭关卡 +"MainFunction.Annihilation.Stage": "Annihilation"、"Chernobog@Annihilation"、"LungmenOutskirts@Annihilation"、"LungmenDowntown@Annihilation" #自定义剿灭关卡号 "Penguin.IsDrGrandet": "True" #博朗台模式 "GUI.CustomStageCode": "False" #手动输入关卡名 "GUI.UseAlternateStage": "False" #使用备选关卡 @@ -45,7 +47,7 @@ "Infrast.IsCustomInfrastFileReadOnly": "False" #自定义基建配置文件只读 "Infrast.CustomInfrastFile": "" #自定义基建配置文件地址 #设置 -"Start.ClientType": "Bilibili"、 "Official" #服务器 +"Start.ClientType": "Official"、"Bilibili"、"YoStarEN"、"YoStarJP"、"YoStarKR"、"txwy" #服务器 G"Timer.Timer1": "False" #时间设置1 "Connect.AdbPath" #ADB路径 "Connect.Address": "127.0.0.1:16448" #连接地址 diff --git a/resources/version.json b/resources/version.json index b32de3f..3d6eb9a 100644 --- a/resources/version.json +++ b/resources/version.json @@ -1,60 +1,62 @@ { - "main_version": "4.4.0.0", + "main_version": "4.4.1.0", "version_info": { - "4.4.0.0": { + "4.4.1.0": { "新增功能": [ - "通用配置模式接入日志系统" + "启动时支持直接运行复数调度队列" ], "修复BUG": [ - "信任系统证书,并添加网络代理地址配置项 #50", - "适配 MAA 任务及基建设施日志翻译" + "修复计划表未能按照鹰历获取关卡号的问题" + ] + }, + "4.4.1.5": { + "新增功能": [ + "适配 MAA 长期开放剿灭关卡", + "新增完成任务后自动复原脚本配置", + "通用脚本启动附加命令添加额外的语法以适应UI可执行文件与任务可执行文件不同的情况", + "新增 Go_Updater 独立更新器" ], "程序优化": [ - "重构历史记录保存与载入逻辑" + "优化调度队列配置逻辑", + "优化静默进程标记逻辑,避免未能及时移除导致相关功能持续开启", + "MAA 代理时更新改为强制开启", + "移除 MAA 详细配置模式中的剿灭项" ] }, - "4.4.0.5": { + "4.4.1.4": { + "修复BUG": [ + "添加强制关机功能并优化关机流程" + ] + }, + "4.4.1.3": { + "修复BUG": [ + "移除崩溃弹窗机制" + ] + }, + "4.4.1.2": { "新增功能": [ - "添加导入导出通用配置功能" + "AUTO_MAA 配置分享中心上线" ], "修复BUG": [ - "修复开机自启相关功能" - ] - }, - "4.4.0.4": { - "新增功能": [ - "添加重置管理密钥功能" - ], - "修复BUG": [ - "修复无计划表时数据系统无法正常升级到v1.7的问题" - ] - }, - "4.4.0.3": { - "修复BUG": [ - "适配 MAA 备选关卡字段修改", - "修复无成功日志时的脚本判定逻辑" + "日志读取添加兜底机制", + "修复 QTimer.singleShot 参数问题" ], "程序优化": [ - "`GameId`字段改为 `Stage`,与 MAA 保持一致" + "小文件配置信息转移至AUTO_MAA自建服务" ] }, - "4.4.0.2": { + "4.4.1.1": { "新增功能": [ - "进一步适配三月七相关配置项" + "通用脚本支持在选定的时机自动更新配置文件" ], "修复BUG": [ - "适配 Mirror 酱 平台下载策略调整" - ] - }, - "4.4.0.1": { - "新增功能": [ - "初步完成通用调度模块" - ], - "修复BUG": [ - "修复了程序BUG较少的BUG" + "修复MAA掉落物统计功能", + "修复模拟器界面被异常关闭且无法重新打开的问题" ], "程序优化": [ - "子线程卡死不再阻塞调度任务" + "重构日志记录,载入更多日志记录项", + "优化日志监看启停逻辑", + "SpinBox和TimeEdit组件忽视滚轮事件" ] } }