feat(Go_Updater): 添加全新 Go 语言实现的自动更新器
- 新增多个源文件和目录,包括 app.rc、assets、build 脚本等 - 实现了与 MirrorChyan API 交互的客户端逻辑 - 添加了版本检查、更新检测和下载 URL 生成等功能 - 嵌入了配置模板和资源文件系统 - 提供了完整的构建和发布流程
This commit is contained in:
116
Go_Updater/Makefile
Normal file
116
Go_Updater/Makefile
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# AUTO_MAA_Go_Updater Makefile
|
||||||
|
|
||||||
|
# Build variables
|
||||||
|
VERSION ?= 1.0.0
|
||||||
|
BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
OUTPUT_NAME := AUTO_MAA_Go_Updater
|
||||||
|
BUILD_DIR := build
|
||||||
|
DIST_DIR := dist
|
||||||
|
|
||||||
|
# Go build flags
|
||||||
|
LDFLAGS := -s -w -X lightweight-updater/version.Version=$(VERSION) -X lightweight-updater/version.BuildTime=$(BUILD_TIME) -X lightweight-updater/version.GitCommit=$(GIT_COMMIT)
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
.PHONY: all
|
||||||
|
all: clean build
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning build artifacts..."
|
||||||
|
@rm -rf $(BUILD_DIR) $(DIST_DIR)
|
||||||
|
@mkdir -p $(BUILD_DIR) $(DIST_DIR)
|
||||||
|
|
||||||
|
# Build for Windows 64-bit
|
||||||
|
.PHONY: build
|
||||||
|
build: clean
|
||||||
|
@echo "========================================="
|
||||||
|
@echo "Building AUTO_MAA_Go_Updater"
|
||||||
|
@echo "========================================="
|
||||||
|
@echo "Version: $(VERSION)"
|
||||||
|
@echo "Build Time: $(BUILD_TIME)"
|
||||||
|
@echo "Git Commit: $(GIT_COMMIT)"
|
||||||
|
@echo "Target: Windows 64-bit"
|
||||||
|
@echo ""
|
||||||
|
@echo "Building application..."
|
||||||
|
@GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(OUTPUT_NAME).exe .
|
||||||
|
@echo "Build completed successfully!"
|
||||||
|
@echo ""
|
||||||
|
@echo "Build Results:"
|
||||||
|
@ls -lh $(BUILD_DIR)/$(OUTPUT_NAME).exe
|
||||||
|
@cp $(BUILD_DIR)/$(OUTPUT_NAME).exe $(DIST_DIR)/$(OUTPUT_NAME).exe
|
||||||
|
@echo "Copied to: $(DIST_DIR)/$(OUTPUT_NAME).exe"
|
||||||
|
|
||||||
|
# Build with UPX compression
|
||||||
|
.PHONY: build-compressed
|
||||||
|
build-compressed: build
|
||||||
|
@echo ""
|
||||||
|
@echo "Compressing with UPX..."
|
||||||
|
@if command -v upx >/dev/null 2>&1; then \
|
||||||
|
upx --best $(BUILD_DIR)/$(OUTPUT_NAME).exe; \
|
||||||
|
echo "Compression completed!"; \
|
||||||
|
ls -lh $(BUILD_DIR)/$(OUTPUT_NAME).exe; \
|
||||||
|
cp $(BUILD_DIR)/$(OUTPUT_NAME).exe $(DIST_DIR)/$(OUTPUT_NAME).exe; \
|
||||||
|
else \
|
||||||
|
echo "UPX not found. Skipping compression."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@echo "Running tests..."
|
||||||
|
@go test -v ./...
|
||||||
|
|
||||||
|
# Run with version flag
|
||||||
|
.PHONY: version
|
||||||
|
version: build
|
||||||
|
@echo ""
|
||||||
|
@echo "Testing version information:"
|
||||||
|
@$(BUILD_DIR)/$(OUTPUT_NAME).exe -version
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
.PHONY: deps
|
||||||
|
deps:
|
||||||
|
@echo "Installing dependencies..."
|
||||||
|
@go mod tidy
|
||||||
|
@go mod download
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
.PHONY: fmt
|
||||||
|
fmt:
|
||||||
|
@echo "Formatting code..."
|
||||||
|
@go fmt ./...
|
||||||
|
|
||||||
|
# Lint code
|
||||||
|
.PHONY: lint
|
||||||
|
lint:
|
||||||
|
@echo "Linting code..."
|
||||||
|
@if command -v golangci-lint >/dev/null 2>&1; then \
|
||||||
|
golangci-lint run; \
|
||||||
|
else \
|
||||||
|
echo "golangci-lint not found. Install it with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Development build (faster, no optimizations)
|
||||||
|
.PHONY: dev
|
||||||
|
dev:
|
||||||
|
@echo "Building development version..."
|
||||||
|
@go build -o $(BUILD_DIR)/$(OUTPUT_NAME)-dev.exe .
|
||||||
|
@echo "Development build completed: $(BUILD_DIR)/$(OUTPUT_NAME)-dev.exe"
|
||||||
|
|
||||||
|
# Help
|
||||||
|
.PHONY: help
|
||||||
|
help:
|
||||||
|
@echo "Available targets:"
|
||||||
|
@echo " all - Clean and build (default)"
|
||||||
|
@echo " build - Build for Windows 64-bit"
|
||||||
|
@echo " build-compressed - Build and compress with UPX"
|
||||||
|
@echo " clean - Clean build artifacts"
|
||||||
|
@echo " test - Run tests"
|
||||||
|
@echo " version - Build and show version"
|
||||||
|
@echo " deps - Install dependencies"
|
||||||
|
@echo " fmt - Format code"
|
||||||
|
@echo " lint - Lint code"
|
||||||
|
@echo " dev - Development build"
|
||||||
|
@echo " help - Show this help"
|
||||||
15
Go_Updater/README.MD
Normal file
15
Go_Updater/README.MD
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 用Go语言实现的一个AUTO_MAA下载器
|
||||||
|
用于直接下载AUTO_MAA软件本体,在Python版本出现问题时使用。
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
1. 下载并安装Go语言环境(需要配置环境变量)
|
||||||
|
2. 运行 `go mod tidy` 命令,安装依赖包。
|
||||||
|
3. 运行 `go run main.go` 命令,程序会自动下载并安装AUTO_MAA软件。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
运行 `.\build.ps1` 脚本即可完成构建。
|
||||||
|
|
||||||
|
参数说明:
|
||||||
|
-Version:指定要构建的版本号
|
||||||
|
|
||||||
|
运行命令: `.\build.ps1 -Version "1.0.8"`
|
||||||
332
Go_Updater/api/client.go
Normal file
332
Go_Updater/api/client.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MirrorResponse represents the response from 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"` // Only present when using CDK
|
||||||
|
SHA256 string `json:"sha256,omitempty"` // Only present when using CDK
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
UpdateType string `json:"update_type,omitempty"` // Only present when using CDK
|
||||||
|
ReleaseNote string `json:"release_note"`
|
||||||
|
FileSize int64 `json:"filesize,omitempty"` // Only present when using CDK
|
||||||
|
CDKExpiredTime int64 `json:"cdk_expired_time,omitempty"` // Only present when using CDK
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCheckParams represents parameters for update checking
|
||||||
|
type UpdateCheckParams struct {
|
||||||
|
ResourceID string
|
||||||
|
CurrentVersion string
|
||||||
|
Channel string
|
||||||
|
CDK string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MirrorClient interface defines the methods for Mirror API client
|
||||||
|
type MirrorClient interface {
|
||||||
|
CheckUpdate(params UpdateCheckParams) (*MirrorResponse, error)
|
||||||
|
CheckUpdateLegacy(resourceID, currentVersion, cdk, userAgent string) (*MirrorResponse, error)
|
||||||
|
IsUpdateAvailable(response *MirrorResponse, currentVersion string) bool
|
||||||
|
GetOfficialDownloadURL(versionName string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client implements MirrorClient interface
|
||||||
|
type Client struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new Mirror API client
|
||||||
|
func NewClient() *Client {
|
||||||
|
return &Client{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
baseURL: "https://mirrorchyan.com/api/resources",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckUpdate calls MirrorChyan API to check for updates with new parameter structure
|
||||||
|
func (c *Client) CheckUpdate(params UpdateCheckParams) (*MirrorResponse, error) {
|
||||||
|
// Construct the API URL
|
||||||
|
apiURL := fmt.Sprintf("%s/%s/latest", c.baseURL, params.ResourceID)
|
||||||
|
|
||||||
|
// Parse URL to add query parameters
|
||||||
|
u, err := url.Parse(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse API URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add query parameters
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("current_version", params.CurrentVersion)
|
||||||
|
q.Set("channel", params.Channel)
|
||||||
|
q.Set("os", "") // Empty for cross-platform
|
||||||
|
q.Set("arch", "") // Empty for cross-platform
|
||||||
|
|
||||||
|
if params.CDK != "" {
|
||||||
|
q.Set("cdk", params.CDK)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, err := http.NewRequest("GET", u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set User-Agent header
|
||||||
|
if params.UserAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", params.UserAgent)
|
||||||
|
} else {
|
||||||
|
req.Header.Set("User-Agent", "LightweightUpdater/1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make HTTP request
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to make HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Check HTTP status code
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("API returned non-200 status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read response body
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
var mirrorResp MirrorResponse
|
||||||
|
if err := json.Unmarshal(body, &mirrorResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mirrorResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckUpdateLegacy calls Mirror API to check for updates (legacy method for backward compatibility)
|
||||||
|
func (c *Client) CheckUpdateLegacy(resourceID, currentVersion, cdk, userAgent string) (*MirrorResponse, error) {
|
||||||
|
// Construct the API URL
|
||||||
|
apiURL := fmt.Sprintf("%s/%s/latest", c.baseURL, resourceID)
|
||||||
|
|
||||||
|
// Parse URL to add query parameters
|
||||||
|
u, err := url.Parse(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse API URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add query parameters
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("current_version", currentVersion)
|
||||||
|
if cdk != "" {
|
||||||
|
q.Set("cdk", cdk)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, err := http.NewRequest("GET", u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set User-Agent header
|
||||||
|
if userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
} else {
|
||||||
|
req.Header.Set("User-Agent", "LightweightUpdater/1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make HTTP request
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to make HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Check HTTP status code
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("API returned non-200 status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read response body
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
var mirrorResp MirrorResponse
|
||||||
|
if err := json.Unmarshal(body, &mirrorResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mirrorResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUpdateAvailable compares current version with the latest version from API response
|
||||||
|
func (c *Client) IsUpdateAvailable(response *MirrorResponse, currentVersion string) bool {
|
||||||
|
// Check if API response is successful
|
||||||
|
if response.Code != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get latest version from response
|
||||||
|
latestVersion := response.Data.VersionName
|
||||||
|
if latestVersion == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert version formats for comparison
|
||||||
|
currentVersionNormalized := c.normalizeVersionForComparison(currentVersion)
|
||||||
|
latestVersionNormalized := c.normalizeVersionForComparison(latestVersion)
|
||||||
|
|
||||||
|
// Compare versions using semantic version comparison
|
||||||
|
return compareVersions(currentVersionNormalized, latestVersionNormalized) < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeVersionForComparison converts different version formats to comparable format
|
||||||
|
func (c *Client) normalizeVersionForComparison(version string) string {
|
||||||
|
// Handle AUTO_MAA version format: "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 as-is if already in standard format
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareVersions compares two semantic version strings
|
||||||
|
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
||||||
|
func compareVersions(v1, v2 string) int {
|
||||||
|
// Normalize versions by removing 'v' prefix if present
|
||||||
|
v1 = normalizeVersion(v1)
|
||||||
|
v2 = normalizeVersion(v2)
|
||||||
|
|
||||||
|
// Parse version components
|
||||||
|
parts1 := parseVersionParts(v1)
|
||||||
|
parts2 := parseVersionParts(v2)
|
||||||
|
|
||||||
|
// Compare each component
|
||||||
|
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 removes 'v' prefix and handles common version formats
|
||||||
|
func normalizeVersion(version string) string {
|
||||||
|
if len(version) > 0 && (version[0] == 'v' || version[0] == 'V') {
|
||||||
|
return version[1:]
|
||||||
|
}
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVersionParts parses version string into numeric components
|
||||||
|
func parseVersionParts(version string) []int {
|
||||||
|
if version == "" {
|
||||||
|
return []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]int, 0, 3)
|
||||||
|
current := 0
|
||||||
|
|
||||||
|
for _, char := range version {
|
||||||
|
if char >= '0' && char <= '9' {
|
||||||
|
current = current*10 + int(char-'0')
|
||||||
|
} else if char == '.' {
|
||||||
|
parts = append(parts, current)
|
||||||
|
current = 0
|
||||||
|
} else {
|
||||||
|
// Stop parsing at non-numeric, non-dot characters (like pre-release identifiers)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the last component
|
||||||
|
parts = append(parts, current)
|
||||||
|
|
||||||
|
// Ensure at least 3 components (major.minor.patch)
|
||||||
|
for len(parts) < 3 {
|
||||||
|
parts = append(parts, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOfficialDownloadURL generates the official download URL based on version name
|
||||||
|
func (c *Client) GetOfficialDownloadURL(versionName string) string {
|
||||||
|
// Official download site base URL
|
||||||
|
baseURL := "http://221.236.27.82:10197/d/AUTO_MAA"
|
||||||
|
|
||||||
|
// Convert version name to filename format
|
||||||
|
// e.g., "v4.4.0" -> "AUTO_MAA_v4.4.0.zip"
|
||||||
|
// e.g., "v4.4.1-beta3" -> "AUTO_MAA_v4.4.1-beta.3.zip"
|
||||||
|
filename := fmt.Sprintf("AUTO_MAA_%s.zip", versionName)
|
||||||
|
|
||||||
|
// Handle beta versions: convert "beta3" to "beta.3"
|
||||||
|
if strings.Contains(filename, "-beta") && !strings.Contains(filename, "-beta.") {
|
||||||
|
filename = strings.Replace(filename, "-beta", "-beta.", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s/%s", baseURL, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCDKDownloadURL checks if the response contains a CDK download URL
|
||||||
|
func (c *Client) HasCDKDownloadURL(response *MirrorResponse) bool {
|
||||||
|
return response != nil && response.Data.URL != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDownloadURL returns the appropriate download URL based on available options
|
||||||
|
func (c *Client) GetDownloadURL(response *MirrorResponse) string {
|
||||||
|
if c.HasCDKDownloadURL(response) {
|
||||||
|
return response.Data.URL
|
||||||
|
}
|
||||||
|
return c.GetOfficialDownloadURL(response.Data.VersionName)
|
||||||
|
}
|
||||||
423
Go_Updater/api/client_test.go
Normal file
423
Go_Updater/api/client_test.go
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewClient(t *testing.T) {
|
||||||
|
client := NewClient()
|
||||||
|
if client == nil {
|
||||||
|
t.Fatal("NewClient() returned nil")
|
||||||
|
}
|
||||||
|
if client.httpClient == nil {
|
||||||
|
t.Fatal("HTTP client is nil")
|
||||||
|
}
|
||||||
|
if client.baseURL != "https://mirrorchyan.com/api/resources" {
|
||||||
|
t.Errorf("Expected base URL 'https://mirrorchyan.com/api/resources', got '%s'", client.baseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOfficialDownloadURL(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"},
|
||||||
|
{"v1.2.3-beta1", "http://221.236.27.82:10197/d/AUTO_MAA/AUTO_MAA_v1.2.3-beta.1.zip"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
result := client.GetOfficialDownloadURL(test.versionName)
|
||||||
|
if result != test.expected {
|
||||||
|
t.Errorf("For version %s, expected %s, got %s", test.versionName, test.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeVersionForComparison(t *testing.T) {
|
||||||
|
client := NewClient()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"4.4.0.0", "v4.4.0"},
|
||||||
|
{"4.4.1.3", "v4.4.1-beta3"},
|
||||||
|
{"v4.4.0", "v4.4.0"},
|
||||||
|
{"v4.4.1-beta3", "v4.4.1-beta3"},
|
||||||
|
{"1.2.3", "1.2.3"}, // Not 4-part version, return as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
result := client.normalizeVersionForComparison(test.input)
|
||||||
|
if result != test.expected {
|
||||||
|
t.Errorf("For input %s, expected %s, got %s", test.input, test.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckUpdate(t *testing.T) {
|
||||||
|
// Create test server
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Verify request parameters
|
||||||
|
if r.URL.Query().Get("current_version") != "4.4.0.0" {
|
||||||
|
t.Errorf("Expected current_version=4.4.0.0, got %s", r.URL.Query().Get("current_version"))
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("channel") != "stable" {
|
||||||
|
t.Errorf("Expected channel=stable, got %s", r.URL.Query().Get("channel"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return mock response
|
||||||
|
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"`
|
||||||
|
CDKExpiredTime int64 `json:"cdk_expired_time,omitempty"`
|
||||||
|
}{
|
||||||
|
VersionName: "v4.4.1",
|
||||||
|
VersionNumber: 48,
|
||||||
|
Channel: "stable",
|
||||||
|
OS: "",
|
||||||
|
Arch: "",
|
||||||
|
ReleaseNote: "Test release notes",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Create client with test server URL
|
||||||
|
client := &Client{
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
baseURL: server.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test update check
|
||||||
|
params := UpdateCheckParams{
|
||||||
|
ResourceID: "AUTO_MAA",
|
||||||
|
CurrentVersion: "4.4.0.0",
|
||||||
|
Channel: "stable",
|
||||||
|
CDK: "",
|
||||||
|
UserAgent: "TestAgent/1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := client.CheckUpdate(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckUpdate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.Code != 0 {
|
||||||
|
t.Errorf("Expected code 0, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if response.Data.VersionName != "v4.4.1" {
|
||||||
|
t.Errorf("Expected version v4.4.1, got %s", response.Data.VersionName)
|
||||||
|
}
|
||||||
|
if response.Data.Channel != "stable" {
|
||||||
|
t.Errorf("Expected channel stable, got %s", response.Data.Channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckUpdateWithCDK(t *testing.T) {
|
||||||
|
// Create test server
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Verify CDK parameter
|
||||||
|
if r.URL.Query().Get("cdk") != "test_cdk_123" {
|
||||||
|
t.Errorf("Expected cdk=test_cdk_123, got %s", r.URL.Query().Get("cdk"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return mock response with CDK download URL
|
||||||
|
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"`
|
||||||
|
CDKExpiredTime int64 `json:"cdk_expired_time,omitempty"`
|
||||||
|
}{
|
||||||
|
VersionName: "v4.4.1",
|
||||||
|
VersionNumber: 48,
|
||||||
|
URL: "https://mirrorchyan.com/api/resources/download/test123",
|
||||||
|
SHA256: "abcd1234",
|
||||||
|
Channel: "stable",
|
||||||
|
OS: "",
|
||||||
|
Arch: "",
|
||||||
|
UpdateType: "full",
|
||||||
|
ReleaseNote: "Test release notes",
|
||||||
|
FileSize: 12345678,
|
||||||
|
CDKExpiredTime: 1776013593,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Create client with test server URL
|
||||||
|
client := &Client{
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
baseURL: server.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test update check with CDK
|
||||||
|
params := UpdateCheckParams{
|
||||||
|
ResourceID: "AUTO_MAA",
|
||||||
|
CurrentVersion: "4.4.0.0",
|
||||||
|
Channel: "stable",
|
||||||
|
CDK: "test_cdk_123",
|
||||||
|
UserAgent: "TestAgent/1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := client.CheckUpdate(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckUpdate with CDK failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.Data.URL == "" {
|
||||||
|
t.Error("Expected CDK download URL, but got empty")
|
||||||
|
}
|
||||||
|
if response.Data.SHA256 == "" {
|
||||||
|
t.Error("Expected SHA256 hash, but got empty")
|
||||||
|
}
|
||||||
|
if response.Data.FileSize == 0 {
|
||||||
|
t.Error("Expected file size, but got 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsUpdateAvailable(t *testing.T) {
|
||||||
|
client := NewClient()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response *MirrorResponse
|
||||||
|
currentVersion string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Update available - stable",
|
||||||
|
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"`
|
||||||
|
CDKExpiredTime int64 `json:"cdk_expired_time,omitempty"`
|
||||||
|
}{VersionName: "v4.4.1"},
|
||||||
|
},
|
||||||
|
currentVersion: "4.4.0.0",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No update available - same version",
|
||||||
|
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"`
|
||||||
|
CDKExpiredTime int64 `json:"cdk_expired_time,omitempty"`
|
||||||
|
}{VersionName: "v4.4.0"},
|
||||||
|
},
|
||||||
|
currentVersion: "4.4.0.0",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "API error",
|
||||||
|
response: &MirrorResponse{
|
||||||
|
Code: 1,
|
||||||
|
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"`
|
||||||
|
CDKExpiredTime int64 `json:"cdk_expired_time,omitempty"`
|
||||||
|
}{VersionName: "v4.4.1"},
|
||||||
|
},
|
||||||
|
currentVersion: "4.4.0.0",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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("Expected %t, got %t", test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasCDKDownloadURL(t *testing.T) {
|
||||||
|
client := NewClient()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response *MirrorResponse
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Has CDK URL",
|
||||||
|
response: &MirrorResponse{
|
||||||
|
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"`
|
||||||
|
CDKExpiredTime int64 `json:"cdk_expired_time,omitempty"`
|
||||||
|
}{URL: "https://mirrorchyan.com/download/test"},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No CDK URL",
|
||||||
|
response: &MirrorResponse{
|
||||||
|
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"`
|
||||||
|
CDKExpiredTime int64 `json:"cdk_expired_time,omitempty"`
|
||||||
|
}{URL: ""},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Nil response",
|
||||||
|
response: nil,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result := client.HasCDKDownloadURL(test.response)
|
||||||
|
if result != test.expected {
|
||||||
|
t.Errorf("Expected %t, got %t", test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDownloadURL(t *testing.T) {
|
||||||
|
client := NewClient()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response *MirrorResponse
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "CDK URL available",
|
||||||
|
response: &MirrorResponse{
|
||||||
|
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"`
|
||||||
|
CDKExpiredTime int64 `json:"cdk_expired_time,omitempty"`
|
||||||
|
}{
|
||||||
|
VersionName: "v4.4.1",
|
||||||
|
URL: "https://mirrorchyan.com/download/test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "https://mirrorchyan.com/download/test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Official URL fallback",
|
||||||
|
response: &MirrorResponse{
|
||||||
|
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"`
|
||||||
|
CDKExpiredTime int64 `json:"cdk_expired_time,omitempty"`
|
||||||
|
}{
|
||||||
|
VersionName: "v4.4.1",
|
||||||
|
URL: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "http://221.236.27.82:10197/d/AUTO_MAA/AUTO_MAA_v4.4.1.zip",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result := client.GetDownloadURL(test.response)
|
||||||
|
if result != test.expected {
|
||||||
|
t.Errorf("Expected %s, got %s", test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Go_Updater/app.rc
Normal file
34
Go_Updater/app.rc
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
// Application icon
|
||||||
|
IDI_ICON1 ICON "icon/AUTO_MAA_Go_Updater.ico"
|
||||||
|
|
||||||
|
// Version information
|
||||||
|
VS_VERSION_INFO VERSIONINFO
|
||||||
|
FILEVERSION 1,0,0,0
|
||||||
|
PRODUCTVERSION 1,0,0,0
|
||||||
|
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
|
||||||
|
FILEFLAGS 0x0L
|
||||||
|
FILEOS VOS__WINDOWS32
|
||||||
|
FILETYPE VFT_APP
|
||||||
|
FILESUBTYPE VFT2_UNKNOWN
|
||||||
|
BEGIN
|
||||||
|
BLOCK "StringFileInfo"
|
||||||
|
BEGIN
|
||||||
|
BLOCK "040904B0"
|
||||||
|
BEGIN
|
||||||
|
VALUE "CompanyName", "AUTO MAA Team"
|
||||||
|
VALUE "FileDescription", "AUTO MAA Go Updater"
|
||||||
|
VALUE "FileVersion", "1.0.0.0"
|
||||||
|
VALUE "InternalName", "AUTO_MAA_Go_Updater"
|
||||||
|
VALUE "LegalCopyright", "Copyright (C) 2025"
|
||||||
|
VALUE "OriginalFilename", "AUTO_MAA_Go_Updater.exe"
|
||||||
|
VALUE "ProductName", "AUTO MAA Go Updater"
|
||||||
|
VALUE "ProductVersion", "1.0.0.0"
|
||||||
|
END
|
||||||
|
END
|
||||||
|
BLOCK "VarFileInfo"
|
||||||
|
BEGIN
|
||||||
|
VALUE "Translation", 0x409, 1200
|
||||||
|
END
|
||||||
|
END
|
||||||
BIN
Go_Updater/app.syso
Normal file
BIN
Go_Updater/app.syso
Normal file
Binary file not shown.
34
Go_Updater/assets/assets.go
Normal file
34
Go_Updater/assets/assets.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package assets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed config_template.yaml
|
||||||
|
var EmbeddedAssets embed.FS
|
||||||
|
|
||||||
|
// GetConfigTemplate returns the embedded config template
|
||||||
|
func GetConfigTemplate() ([]byte, error) {
|
||||||
|
return EmbeddedAssets.ReadFile("config_template.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAssetFS returns the embedded filesystem
|
||||||
|
func GetAssetFS() fs.FS {
|
||||||
|
return EmbeddedAssets
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAssets returns a list of all embedded assets
|
||||||
|
func ListAssets() ([]string, error) {
|
||||||
|
var assets []string
|
||||||
|
err := fs.WalkDir(EmbeddedAssets, ".", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !d.IsDir() {
|
||||||
|
assets = append(assets, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return assets, err
|
||||||
|
}
|
||||||
100
Go_Updater/assets/assets_test.go
Normal file
100
Go_Updater/assets/assets_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package assets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetConfigTemplate(t *testing.T) {
|
||||||
|
data, err := GetConfigTemplate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get config template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) == 0 {
|
||||||
|
t.Fatal("Config template is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that it contains expected content
|
||||||
|
content := string(data)
|
||||||
|
if !contains(content, "resource_id") {
|
||||||
|
t.Error("Config template should contain 'resource_id'")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !contains(content, "current_version") {
|
||||||
|
t.Error("Config template should contain 'current_version'")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !contains(content, "user_agent") {
|
||||||
|
t.Error("Config template should contain 'user_agent'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAssets(t *testing.T) {
|
||||||
|
assets, err := ListAssets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list assets: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(assets) == 0 {
|
||||||
|
t.Fatal("No assets found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that config template is in the list
|
||||||
|
found := false
|
||||||
|
for _, asset := range assets {
|
||||||
|
if asset == "config_template.yaml" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Error("config_template.yaml should be in the assets list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAssetFS(t *testing.T) {
|
||||||
|
fs := GetAssetFS()
|
||||||
|
if fs == nil {
|
||||||
|
t.Fatal("Asset filesystem should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to open the config template
|
||||||
|
file, err := fs.Open("config_template.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to open config template from filesystem: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Check that we can read from it
|
||||||
|
buffer := make([]byte, 100)
|
||||||
|
n, err := file.Read(buffer)
|
||||||
|
if err != nil && err.Error() != "EOF" {
|
||||||
|
t.Fatalf("Failed to read from config template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
t.Fatal("Config template appears to be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if string contains substring
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||||
|
(len(s) > len(substr) && (s[:len(substr)] == substr ||
|
||||||
|
s[len(s)-len(substr):] == substr ||
|
||||||
|
containsAt(s, substr, 1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAt(s, substr string, start int) bool {
|
||||||
|
if start >= len(s) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if start+len(substr) > len(s) {
|
||||||
|
return containsAt(s, substr, start+1)
|
||||||
|
}
|
||||||
|
if s[start:start+len(substr)] == substr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return containsAt(s, substr, start+1)
|
||||||
|
}
|
||||||
8
Go_Updater/assets/config_template.yaml
Normal file
8
Go_Updater/assets/config_template.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
resource_id: "AUTO_MAA"
|
||||||
|
current_version: "v1.0.0"
|
||||||
|
cdk: "" # Will be encrypted when saved
|
||||||
|
user_agent: "AUTO_MAA_Go_Updater/1.0"
|
||||||
|
backup_url: "https://backup-download-site.com/releases"
|
||||||
|
log_level: "info"
|
||||||
|
auto_check: true
|
||||||
|
check_interval: 3600 # seconds
|
||||||
55
Go_Updater/build-config.yaml
Normal file
55
Go_Updater/build-config.yaml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Build Configuration for Lightweight Updater
|
||||||
|
|
||||||
|
project:
|
||||||
|
name: "Lightweight Updater"
|
||||||
|
module: "lightweight-updater"
|
||||||
|
description: "轻量级自动更新器"
|
||||||
|
|
||||||
|
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: "lightweight-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: "lightweight-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
|
||||||
99
Go_Updater/build.bat
Normal file
99
Go_Updater/build.bat
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
echo ========================================
|
||||||
|
echo AUTO_MAA_Go_Updater Build Script
|
||||||
|
echo ========================================
|
||||||
|
|
||||||
|
:: Set build variables
|
||||||
|
set VERSION=1.0.0
|
||||||
|
set OUTPUT_NAME=AUTO_MAA_Go_Updater.exe
|
||||||
|
set BUILD_DIR=build
|
||||||
|
set DIST_DIR=dist
|
||||||
|
|
||||||
|
:: Get current timestamp
|
||||||
|
for /f "tokens=2 delims==" %%a in ('wmic OS Get localdatetime /value') do set "dt=%%a"
|
||||||
|
set "YY=%dt:~2,2%" & set "YYYY=%dt:~0,4%" & set "MM=%dt:~4,2%" & set "DD=%dt:~6,2%"
|
||||||
|
set "HH=%dt:~8,2%" & set "Min=%dt:~10,2%" & set "Sec=%dt:~12,2%"
|
||||||
|
set "BUILD_TIME=%YYYY%-%MM%-%DD%T%HH%:%Min%:%Sec%Z"
|
||||||
|
|
||||||
|
:: Get git commit hash (if available)
|
||||||
|
git rev-parse --short HEAD > temp_commit.txt 2>nul
|
||||||
|
if exist temp_commit.txt (
|
||||||
|
set /p GIT_COMMIT=<temp_commit.txt
|
||||||
|
del temp_commit.txt
|
||||||
|
) else (
|
||||||
|
set GIT_COMMIT=unknown
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Build Information:
|
||||||
|
echo - Version: %VERSION%
|
||||||
|
echo - Build Time: %BUILD_TIME%
|
||||||
|
echo - Git Commit: %GIT_COMMIT%
|
||||||
|
echo - Target: Windows 64-bit
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: Create build directories
|
||||||
|
if not exist %BUILD_DIR% mkdir %BUILD_DIR%
|
||||||
|
if not exist %DIST_DIR% mkdir %DIST_DIR%
|
||||||
|
|
||||||
|
:: Set build flags
|
||||||
|
set LDFLAGS=-s -w -X lightweight-updater/version.Version=%VERSION% -X lightweight-updater/version.BuildTime=%BUILD_TIME% -X lightweight-updater/version.GitCommit=%GIT_COMMIT%
|
||||||
|
|
||||||
|
echo Building application...
|
||||||
|
|
||||||
|
:: Ensure icon resource is compiled
|
||||||
|
if not exist app.syso (
|
||||||
|
echo Compiling icon resource...
|
||||||
|
where rsrc >nul 2>&1
|
||||||
|
if !ERRORLEVEL! equ 0 (
|
||||||
|
rsrc -ico icon\AUTO_MAA_Go_Updater.ico -o app.syso
|
||||||
|
if !ERRORLEVEL! equ 0 (
|
||||||
|
echo Icon resource compiled successfully
|
||||||
|
) else (
|
||||||
|
echo Warning: Failed to compile icon resource
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo Warning: rsrc not found. Install with: go install github.com/akavel/rsrc@latest
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
set 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
|
||||||
|
|
||||||
|
:: Convert bytes to MB
|
||||||
|
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)
|
||||||
|
|
||||||
|
:: Check if file size is within requirements (<10MB)
|
||||||
|
if %FILE_SIZE_MB% gtr 10 (
|
||||||
|
echo WARNING: File size exceeds 10MB requirement!
|
||||||
|
) else (
|
||||||
|
echo File size meets requirements (^<10MB)
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 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 ========================================
|
||||||
111
Go_Updater/build.ps1
Normal file
111
Go_Updater/build.ps1
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Lightweight Updater Build Script (PowerShell)
|
||||||
|
param(
|
||||||
|
[string]$Version = "1.0.0",
|
||||||
|
[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: $Version"
|
||||||
|
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 lightweight-updater/version.Version=$Version -X lightweight-updater/version.BuildTime=$BuildTime -X lightweight-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)"
|
||||||
|
|
||||||
|
# Check file size requirement
|
||||||
|
if ($FileSizeMB -gt 10) {
|
||||||
|
Write-Host "WARNING: File size exceeds 10MB requirement!" -ForegroundColor Red
|
||||||
|
} else {
|
||||||
|
Write-Host "File size meets requirements (<10MB)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
224
Go_Updater/download/manager.go
Normal file
224
Go_Updater/download/manager.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DownloadProgress represents the current download progress
|
||||||
|
type DownloadProgress struct {
|
||||||
|
BytesDownloaded int64
|
||||||
|
TotalBytes int64
|
||||||
|
Percentage float64
|
||||||
|
Speed int64 // bytes per second
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProgressCallback is called during download to report progress
|
||||||
|
type ProgressCallback func(DownloadProgress)
|
||||||
|
|
||||||
|
// DownloadManager interface defines download operations
|
||||||
|
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 implements DownloadManager interface
|
||||||
|
type Manager struct {
|
||||||
|
client *http.Client
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new download manager
|
||||||
|
func NewManager() *Manager {
|
||||||
|
return &Manager{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download downloads a file from the given URL to the destination path
|
||||||
|
func (m *Manager) Download(url, destination string, progressCallback ProgressCallback) error {
|
||||||
|
return m.downloadWithContext(context.Background(), url, destination, progressCallback, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadWithResume downloads a file with resume capability
|
||||||
|
func (m *Manager) DownloadWithResume(url, destination string, progressCallback ProgressCallback) error {
|
||||||
|
return m.downloadWithContext(context.Background(), url, destination, progressCallback, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadWithContext performs the actual download with context support
|
||||||
|
func (m *Manager) downloadWithContext(ctx context.Context, url, destination string, progressCallback ProgressCallback, resume bool) error {
|
||||||
|
// Create destination directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists for resume
|
||||||
|
var existingSize int64
|
||||||
|
if resume {
|
||||||
|
if stat, err := os.Stat(destination); err == nil {
|
||||||
|
existingSize = stat.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add range header for resume
|
||||||
|
if resume && existingSize > 0 {
|
||||||
|
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", existingSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
resp, err := m.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to execute request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Check response status
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||||
|
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total size
|
||||||
|
totalSize := existingSize
|
||||||
|
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
|
||||||
|
if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil {
|
||||||
|
totalSize += size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open destination file
|
||||||
|
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("failed to create destination file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Download with progress tracking
|
||||||
|
return m.copyWithProgress(resp.Body, file, existingSize, totalSize, progressCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyWithProgress copies data while tracking progress
|
||||||
|
func (m *Manager) copyWithProgress(src io.Reader, dst io.Writer, startBytes, totalBytes int64, progressCallback ProgressCallback) error {
|
||||||
|
buffer := make([]byte, 32*1024) // 32KB buffer
|
||||||
|
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("failed to write to destination: %w", writeErr)
|
||||||
|
}
|
||||||
|
downloaded += int64(n)
|
||||||
|
|
||||||
|
// Update progress every 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("failed to read from source: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final progress update
|
||||||
|
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 validates the SHA256 checksum of a file
|
||||||
|
func (m *Manager) ValidateChecksum(filePath, expectedChecksum string) error {
|
||||||
|
if expectedChecksum == "" {
|
||||||
|
return nil // No checksum to validate
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open file for checksum validation: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
hash := sha256.New()
|
||||||
|
if _, err := io.Copy(hash, file); err != nil {
|
||||||
|
return fmt.Errorf("failed to calculate checksum: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
actualChecksum := hex.EncodeToString(hash.Sum(nil))
|
||||||
|
if actualChecksum != expectedChecksum {
|
||||||
|
return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTimeout sets the timeout for download operations
|
||||||
|
func (m *Manager) SetTimeout(timeout time.Duration) {
|
||||||
|
m.timeout = timeout
|
||||||
|
m.client.Timeout = timeout
|
||||||
|
}
|
||||||
1392
Go_Updater/download/manager_test.go
Normal file
1392
Go_Updater/download/manager_test.go
Normal file
File diff suppressed because it is too large
Load Diff
219
Go_Updater/errors/errors.go
Normal file
219
Go_Updater/errors/errors.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorType 定义错误类型枚举
|
||||||
|
type ErrorType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
NetworkError ErrorType = iota
|
||||||
|
APIError
|
||||||
|
FileError
|
||||||
|
ConfigError
|
||||||
|
InstallError
|
||||||
|
)
|
||||||
|
|
||||||
|
// String 返回错误类型的字符串表示
|
||||||
|
func (et ErrorType) String() string {
|
||||||
|
switch et {
|
||||||
|
case NetworkError:
|
||||||
|
return "NetworkError"
|
||||||
|
case APIError:
|
||||||
|
return "APIError"
|
||||||
|
case FileError:
|
||||||
|
return "FileError"
|
||||||
|
case ConfigError:
|
||||||
|
return "ConfigError"
|
||||||
|
case InstallError:
|
||||||
|
return "InstallError"
|
||||||
|
default:
|
||||||
|
return "UnknownError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdaterError 统一的错误结构体
|
||||||
|
type UpdaterError struct {
|
||||||
|
Type ErrorType
|
||||||
|
Message string
|
||||||
|
Cause error
|
||||||
|
Timestamp time.Time
|
||||||
|
Context map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error 实现error接口
|
||||||
|
func (ue *UpdaterError) Error() string {
|
||||||
|
if ue.Cause != nil {
|
||||||
|
return fmt.Sprintf("[%s] %s: %v", ue.Type, ue.Message, ue.Cause)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[%s] %s", ue.Type, ue.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap 支持错误链
|
||||||
|
func (ue *UpdaterError) Unwrap() error {
|
||||||
|
return ue.Cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUpdaterError 创建新的UpdaterError
|
||||||
|
func NewUpdaterError(errorType ErrorType, message string, cause error) *UpdaterError {
|
||||||
|
return &UpdaterError{
|
||||||
|
Type: errorType,
|
||||||
|
Message: message,
|
||||||
|
Cause: cause,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Context: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithContext 添加上下文信息
|
||||||
|
func (ue *UpdaterError) WithContext(key string, value interface{}) *UpdaterError {
|
||||||
|
ue.Context[key] = value
|
||||||
|
return ue
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserFriendlyMessage 获取用户友好的错误消息
|
||||||
|
func (ue *UpdaterError) GetUserFriendlyMessage() string {
|
||||||
|
switch ue.Type {
|
||||||
|
case NetworkError:
|
||||||
|
return "网络连接失败,请检查网络连接后重试"
|
||||||
|
case APIError:
|
||||||
|
return "服务器响应异常,请稍后重试或联系技术支持"
|
||||||
|
case FileError:
|
||||||
|
return "文件操作失败,请检查文件权限和磁盘空间"
|
||||||
|
case ConfigError:
|
||||||
|
return "配置文件错误,程序将使用默认配置"
|
||||||
|
case InstallError:
|
||||||
|
return "安装过程中出现错误,程序将尝试回滚更改"
|
||||||
|
default:
|
||||||
|
return "发生未知错误,请联系技术支持"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryConfig 重试配置
|
||||||
|
type RetryConfig struct {
|
||||||
|
MaxRetries int
|
||||||
|
InitialDelay time.Duration
|
||||||
|
MaxDelay time.Duration
|
||||||
|
BackoffFactor float64
|
||||||
|
RetryableErrors []ErrorType
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRetryConfig 默认重试配置
|
||||||
|
func DefaultRetryConfig() *RetryConfig {
|
||||||
|
return &RetryConfig{
|
||||||
|
MaxRetries: 3,
|
||||||
|
InitialDelay: time.Second,
|
||||||
|
MaxDelay: 30 * time.Second,
|
||||||
|
BackoffFactor: 2.0,
|
||||||
|
RetryableErrors: []ErrorType{NetworkError, APIError},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRetryable 检查错误是否可重试
|
||||||
|
func (rc *RetryConfig) IsRetryable(err error) bool {
|
||||||
|
if ue, ok := err.(*UpdaterError); ok {
|
||||||
|
for _, retryableType := range rc.RetryableErrors {
|
||||||
|
if ue.Type == retryableType {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateDelay 计算重试延迟时间
|
||||||
|
func (rc *RetryConfig) CalculateDelay(attempt int) time.Duration {
|
||||||
|
delay := time.Duration(float64(rc.InitialDelay) * pow(rc.BackoffFactor, float64(attempt)))
|
||||||
|
if delay > rc.MaxDelay {
|
||||||
|
delay = rc.MaxDelay
|
||||||
|
}
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
// pow 简单的幂运算实现
|
||||||
|
func pow(base, exp float64) float64 {
|
||||||
|
result := 1.0
|
||||||
|
for i := 0; i < int(exp); i++ {
|
||||||
|
result *= base
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryableOperation 可重试的操作函数类型
|
||||||
|
type RetryableOperation func() error
|
||||||
|
|
||||||
|
// ExecuteWithRetry 执行带重试的操作
|
||||||
|
func ExecuteWithRetry(operation RetryableOperation, config *RetryConfig) error {
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||||
|
err := operation()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
|
||||||
|
// 如果不是可重试的错误,直接返回
|
||||||
|
if !config.IsRetryable(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经是最后一次尝试,不再等待
|
||||||
|
if attempt == config.MaxRetries {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算延迟时间并等待
|
||||||
|
delay := config.CalculateDelay(attempt)
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorHandler 错误处理器接口
|
||||||
|
type ErrorHandler interface {
|
||||||
|
HandleError(err error) error
|
||||||
|
ShouldRetry(err error) bool
|
||||||
|
GetUserMessage(err error) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultErrorHandler 默认错误处理器
|
||||||
|
type DefaultErrorHandler struct {
|
||||||
|
retryConfig *RetryConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDefaultErrorHandler 创建默认错误处理器
|
||||||
|
func NewDefaultErrorHandler() *DefaultErrorHandler {
|
||||||
|
return &DefaultErrorHandler{
|
||||||
|
retryConfig: DefaultRetryConfig(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleError 处理错误
|
||||||
|
func (h *DefaultErrorHandler) HandleError(err error) error {
|
||||||
|
if ue, ok := err.(*UpdaterError); ok {
|
||||||
|
// 记录错误上下文
|
||||||
|
ue.WithContext("handled_at", time.Now())
|
||||||
|
return ue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将普通错误包装为UpdaterError
|
||||||
|
return NewUpdaterError(NetworkError, "未分类错误", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldRetry 判断是否应该重试
|
||||||
|
func (h *DefaultErrorHandler) ShouldRetry(err error) bool {
|
||||||
|
return h.retryConfig.IsRetryable(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserMessage 获取用户友好的错误消息
|
||||||
|
func (h *DefaultErrorHandler) GetUserMessage(err error) string {
|
||||||
|
if ue, ok := err.(*UpdaterError); ok {
|
||||||
|
return ue.GetUserFriendlyMessage()
|
||||||
|
}
|
||||||
|
return "发生未知错误,请联系技术支持"
|
||||||
|
}
|
||||||
287
Go_Updater/errors/errors_test.go
Normal file
287
Go_Updater/errors/errors_test.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdaterError_Error(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err *UpdaterError
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "error with cause",
|
||||||
|
err: &UpdaterError{
|
||||||
|
Type: NetworkError,
|
||||||
|
Message: "connection failed",
|
||||||
|
Cause: fmt.Errorf("timeout"),
|
||||||
|
},
|
||||||
|
expected: "[NetworkError] connection failed: timeout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error without cause",
|
||||||
|
err: &UpdaterError{
|
||||||
|
Type: APIError,
|
||||||
|
Message: "invalid response",
|
||||||
|
Cause: nil,
|
||||||
|
},
|
||||||
|
expected: "[APIError] invalid response",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.err.Error(); got != tt.expected {
|
||||||
|
t.Errorf("UpdaterError.Error() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewUpdaterError(t *testing.T) {
|
||||||
|
cause := fmt.Errorf("original error")
|
||||||
|
err := NewUpdaterError(FileError, "test message", cause)
|
||||||
|
|
||||||
|
if err.Type != FileError {
|
||||||
|
t.Errorf("Expected type %v, got %v", FileError, err.Type)
|
||||||
|
}
|
||||||
|
if err.Message != "test message" {
|
||||||
|
t.Errorf("Expected message 'test message', got '%v'", err.Message)
|
||||||
|
}
|
||||||
|
if err.Cause != cause {
|
||||||
|
t.Errorf("Expected cause %v, got %v", cause, err.Cause)
|
||||||
|
}
|
||||||
|
if err.Context == nil {
|
||||||
|
t.Error("Expected context to be initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdaterError_WithContext(t *testing.T) {
|
||||||
|
err := NewUpdaterError(ConfigError, "test", nil)
|
||||||
|
err.WithContext("key1", "value1").WithContext("key2", 42)
|
||||||
|
|
||||||
|
if err.Context["key1"] != "value1" {
|
||||||
|
t.Errorf("Expected context key1 to be 'value1', got %v", err.Context["key1"])
|
||||||
|
}
|
||||||
|
if err.Context["key2"] != 42 {
|
||||||
|
t.Errorf("Expected context key2 to be 42, got %v", err.Context["key2"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdaterError_GetUserFriendlyMessage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
errorType ErrorType
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{NetworkError, "网络连接失败,请检查网络连接后重试"},
|
||||||
|
{APIError, "服务器响应异常,请稍后重试或联系技术支持"},
|
||||||
|
{FileError, "文件操作失败,请检查文件权限和磁盘空间"},
|
||||||
|
{ConfigError, "配置文件错误,程序将使用默认配置"},
|
||||||
|
{InstallError, "安装过程中出现错误,程序将尝试回滚更改"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.errorType.String(), func(t *testing.T) {
|
||||||
|
err := NewUpdaterError(tt.errorType, "test", nil)
|
||||||
|
if got := err.GetUserFriendlyMessage(); got != tt.expected {
|
||||||
|
t.Errorf("GetUserFriendlyMessage() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryConfig_IsRetryable(t *testing.T) {
|
||||||
|
config := DefaultRetryConfig()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "retryable network error",
|
||||||
|
err: NewUpdaterError(NetworkError, "test", nil),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "retryable api error",
|
||||||
|
err: NewUpdaterError(APIError, "test", nil),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-retryable file error",
|
||||||
|
err: NewUpdaterError(FileError, "test", nil),
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regular error",
|
||||||
|
err: fmt.Errorf("regular error"),
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := config.IsRetryable(tt.err); got != tt.expected {
|
||||||
|
t.Errorf("IsRetryable() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryConfig_CalculateDelay(t *testing.T) {
|
||||||
|
config := DefaultRetryConfig()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
attempt int
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{0, time.Second},
|
||||||
|
{1, 2 * time.Second},
|
||||||
|
{2, 4 * time.Second},
|
||||||
|
{10, 30 * time.Second}, // should be capped at MaxDelay
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(fmt.Sprintf("attempt_%d", tt.attempt), func(t *testing.T) {
|
||||||
|
if got := config.CalculateDelay(tt.attempt); got != tt.expected {
|
||||||
|
t.Errorf("CalculateDelay(%d) = %v, want %v", tt.attempt, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteWithRetry(t *testing.T) {
|
||||||
|
config := DefaultRetryConfig()
|
||||||
|
config.InitialDelay = time.Millisecond // 加快测试速度
|
||||||
|
|
||||||
|
t.Run("success on first try", func(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
operation := func() error {
|
||||||
|
attempts++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ExecuteWithRetry(operation, config)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 1 {
|
||||||
|
t.Errorf("Expected 1 attempt, got %d", attempts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success after retries", func(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
operation := func() error {
|
||||||
|
attempts++
|
||||||
|
if attempts < 3 {
|
||||||
|
return NewUpdaterError(NetworkError, "temporary failure", nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ExecuteWithRetry(operation, config)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 3 {
|
||||||
|
t.Errorf("Expected 3 attempts, got %d", attempts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-retryable error", func(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
operation := func() error {
|
||||||
|
attempts++
|
||||||
|
return NewUpdaterError(FileError, "file not found", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ExecuteWithRetry(operation, config)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error, got nil")
|
||||||
|
}
|
||||||
|
if attempts != 1 {
|
||||||
|
t.Errorf("Expected 1 attempt, got %d", attempts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("max retries exceeded", func(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
operation := func() error {
|
||||||
|
attempts++
|
||||||
|
return NewUpdaterError(NetworkError, "persistent failure", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ExecuteWithRetry(operation, config)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error, got nil")
|
||||||
|
}
|
||||||
|
expectedAttempts := config.MaxRetries + 1
|
||||||
|
if attempts != expectedAttempts {
|
||||||
|
t.Errorf("Expected %d attempts, got %d", expectedAttempts, attempts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultErrorHandler(t *testing.T) {
|
||||||
|
handler := NewDefaultErrorHandler()
|
||||||
|
|
||||||
|
t.Run("handle updater error", func(t *testing.T) {
|
||||||
|
originalErr := NewUpdaterError(NetworkError, "test", nil)
|
||||||
|
handledErr := handler.HandleError(originalErr)
|
||||||
|
|
||||||
|
if handledErr != originalErr {
|
||||||
|
t.Error("Expected same error instance")
|
||||||
|
}
|
||||||
|
if originalErr.Context["handled_at"] == nil {
|
||||||
|
t.Error("Expected handled_at context to be set")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handle regular error", func(t *testing.T) {
|
||||||
|
originalErr := fmt.Errorf("regular error")
|
||||||
|
handledErr := handler.HandleError(originalErr)
|
||||||
|
|
||||||
|
if ue, ok := handledErr.(*UpdaterError); ok {
|
||||||
|
if ue.Type != NetworkError {
|
||||||
|
t.Errorf("Expected NetworkError, got %v", ue.Type)
|
||||||
|
}
|
||||||
|
if ue.Cause != originalErr {
|
||||||
|
t.Error("Expected original error as cause")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Error("Expected UpdaterError")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should retry", func(t *testing.T) {
|
||||||
|
retryableErr := NewUpdaterError(NetworkError, "test", nil)
|
||||||
|
nonRetryableErr := NewUpdaterError(FileError, "test", nil)
|
||||||
|
|
||||||
|
if !handler.ShouldRetry(retryableErr) {
|
||||||
|
t.Error("Expected network error to be retryable")
|
||||||
|
}
|
||||||
|
if handler.ShouldRetry(nonRetryableErr) {
|
||||||
|
t.Error("Expected file error to not be retryable")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("get user message", func(t *testing.T) {
|
||||||
|
updaterErr := NewUpdaterError(NetworkError, "test", nil)
|
||||||
|
regularErr := fmt.Errorf("regular error")
|
||||||
|
|
||||||
|
userMsg1 := handler.GetUserMessage(updaterErr)
|
||||||
|
userMsg2 := handler.GetUserMessage(regularErr)
|
||||||
|
|
||||||
|
if userMsg1 != "网络连接失败,请检查网络连接后重试" {
|
||||||
|
t.Errorf("Unexpected user message: %s", userMsg1)
|
||||||
|
}
|
||||||
|
if userMsg2 != "发生未知错误,请联系技术支持" {
|
||||||
|
t.Errorf("Unexpected user message: %s", userMsg2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
42
Go_Updater/go.mod
Normal file
42
Go_Updater/go.mod
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
module lightweight-updater
|
||||||
|
|
||||||
|
go 1.24.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
fyne.io/fyne/v2 v2.6.1
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
fyne.io/systray v1.11.0 // indirect
|
||||||
|
github.com/BurntSushi/toml v1.4.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/fredbi/uri v1.1.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/fyne-io/gl-js v0.1.0 // indirect
|
||||||
|
github.com/fyne-io/glfw-js v0.2.0 // indirect
|
||||||
|
github.com/fyne-io/image v0.1.1 // indirect
|
||||||
|
github.com/fyne-io/oksvg v0.1.0 // indirect
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
|
||||||
|
github.com/go-text/render v0.2.0 // indirect
|
||||||
|
github.com/go-text/typesetting v0.2.1 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||||
|
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rymdport/portal v0.4.1 // indirect
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||||
|
github.com/stretchr/testify v1.10.0 // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
|
golang.org/x/image v0.24.0 // indirect
|
||||||
|
golang.org/x/net v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
)
|
||||||
80
Go_Updater/go.sum
Normal file
80
Go_Updater/go.sum
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
fyne.io/fyne/v2 v2.6.1 h1:kjPJD4/rBS9m2nHJp+npPSuaK79yj6ObMTuzR6VQ1Is=
|
||||||
|
fyne.io/fyne/v2 v2.6.1/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU=
|
||||||
|
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
|
||||||
|
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||||
|
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||||
|
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||||
|
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||||
|
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
|
||||||
|
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM=
|
||||||
|
github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
||||||
|
github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM=
|
||||||
|
github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
|
||||||
|
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||||
|
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||||
|
github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
|
||||||
|
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
|
||||||
|
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
|
||||||
|
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
|
||||||
|
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||||
|
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||||
|
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
||||||
|
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
|
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||||
|
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
|
||||||
|
github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
522
Go_Updater/gui/manager.go
Normal file
522
Go_Updater/gui/manager.go
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"fyne.io/fyne/v2/app"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateStatus represents the current status of the update process
|
||||||
|
type UpdateStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusChecking UpdateStatus = iota
|
||||||
|
StatusUpdateAvailable
|
||||||
|
StatusDownloading
|
||||||
|
StatusInstalling
|
||||||
|
StatusCompleted
|
||||||
|
StatusError
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the configuration structure for the GUI
|
||||||
|
type Config struct {
|
||||||
|
ResourceID string
|
||||||
|
CurrentVersion string
|
||||||
|
CDK string
|
||||||
|
UserAgent string
|
||||||
|
BackupURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GUIManager interface defines the methods for GUI management
|
||||||
|
type GUIManager interface {
|
||||||
|
ShowMainWindow()
|
||||||
|
UpdateStatus(status UpdateStatus, message string)
|
||||||
|
ShowProgress(percentage float64)
|
||||||
|
ShowError(errorMsg string)
|
||||||
|
ShowConfigDialog() (*Config, error)
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager implements the GUIManager interface
|
||||||
|
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("轻量级更新器")
|
||||||
|
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() *container.VBox {
|
||||||
|
// Header section
|
||||||
|
header := container.NewVBox(
|
||||||
|
widget.NewCard("", "", container.NewVBox(
|
||||||
|
widget.NewLabelWithStyle("轻量级更新器", 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")
|
||||||
|
|
||||||
|
cdkEntry := widget.NewPasswordEntry()
|
||||||
|
cdkEntry.SetPlaceHolder("输入您的CDK(可选)")
|
||||||
|
|
||||||
|
userAgentEntry := widget.NewEntry()
|
||||||
|
userAgentEntry.SetText("LightweightUpdater/1.0")
|
||||||
|
|
||||||
|
backupURLEntry := widget.NewEntry()
|
||||||
|
backupURLEntry.SetPlaceHolder("备用下载地址(可选)")
|
||||||
|
|
||||||
|
// Create form
|
||||||
|
form := &widget.Form{
|
||||||
|
Items: []*widget.FormItem{
|
||||||
|
{Text: "资源ID:", Widget: resourceIDEntry},
|
||||||
|
{Text: "当前版本:", Widget: versionEntry},
|
||||||
|
{Text: "CDK:", Widget: cdkEntry},
|
||||||
|
{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,
|
||||||
|
CDK: cdkEntry.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酱服务中的资源标识符
|
||||||
|
- **当前版本**: 当前软件的版本号
|
||||||
|
- **CDK**: Mirror酱服务的访问密钥(可选,提供更好的下载体验)
|
||||||
|
- **用户代理**: HTTP请求的用户代理字符串
|
||||||
|
- **备用下载地址**: 当Mirror酱服务不可用时的备用下载地址
|
||||||
|
|
||||||
|
如需获取CDK,请访问 [Mirror酱官网](https://mirrorchyan.com)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Create container with help text
|
||||||
|
dialogContent := container.NewVBox(
|
||||||
|
form,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
helpText,
|
||||||
|
)
|
||||||
|
|
||||||
|
configDialog.SetContent(dialogContent)
|
||||||
|
configDialog.Resize(fyne.NewSize(600, 500))
|
||||||
|
configDialog.Show()
|
||||||
|
|
||||||
|
// Wait for result
|
||||||
|
select {
|
||||||
|
case config := <-resultChan:
|
||||||
|
return config, nil
|
||||||
|
case err := <-errorChan:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVersionInfo updates the version display
|
||||||
|
func (m *Manager) SetVersionInfo(version string) {
|
||||||
|
m.versionLabel.SetText(fmt.Sprintf("当前版本: %s", version))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowReleaseNotes displays the release notes
|
||||||
|
func (m *Manager) ShowReleaseNotes(notes string) {
|
||||||
|
if notes != "" {
|
||||||
|
m.releaseNotes.ParseMarkdown(notes)
|
||||||
|
// Find the release notes card and show it
|
||||||
|
if parent := m.window.Content().(*container.VBox); parent != nil {
|
||||||
|
for _, obj := range parent.Objects {
|
||||||
|
if card, ok := obj.(*widget.Card); ok && card.Title == "更新日志" {
|
||||||
|
card.Show()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatusWithDetails updates status with detailed information
|
||||||
|
func (m *Manager) UpdateStatusWithDetails(status UpdateStatus, message string, details map[string]string) {
|
||||||
|
m.UpdateStatus(status, message)
|
||||||
|
|
||||||
|
// Update version info if provided
|
||||||
|
if version, ok := details["version"]; ok {
|
||||||
|
m.SetVersionInfo(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show release notes if provided
|
||||||
|
if notes, ok := details["release_notes"]; ok {
|
||||||
|
m.ShowReleaseNotes(notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress if provided
|
||||||
|
if progress, ok := details["progress"]; ok {
|
||||||
|
if p, err := fmt.Sscanf(progress, "%f", new(float64)); err == nil && p == 1 {
|
||||||
|
var progressValue float64
|
||||||
|
fmt.Sscanf(progress, "%f", &progressValue)
|
||||||
|
m.ShowProgress(progressValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowProgressWithSpeed shows progress with download speed information
|
||||||
|
func (m *Manager) ShowProgressWithSpeed(percentage float64, speed int64, eta string) {
|
||||||
|
m.ShowProgress(percentage)
|
||||||
|
|
||||||
|
// Update status with speed and ETA information
|
||||||
|
speedText := m.formatSpeed(speed)
|
||||||
|
statusText := fmt.Sprintf("下载中... %.1f%% (%s)", percentage, speedText)
|
||||||
|
if eta != "" {
|
||||||
|
statusText += fmt.Sprintf(" - 剩余时间: %s", eta)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.statusLabel.SetText(statusText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatSpeed formats the download speed for display
|
||||||
|
func (m *Manager) formatSpeed(bytesPerSecond int64) string {
|
||||||
|
if bytesPerSecond < 1024 {
|
||||||
|
return fmt.Sprintf("%d B/s", bytesPerSecond)
|
||||||
|
} else if bytesPerSecond < 1024*1024 {
|
||||||
|
return fmt.Sprintf("%.1f KB/s", float64(bytesPerSecond)/1024)
|
||||||
|
} else {
|
||||||
|
return fmt.Sprintf("%.1f MB/s", float64(bytesPerSecond)/(1024*1024))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowConfirmDialog shows a confirmation dialog
|
||||||
|
func (m *Manager) ShowConfirmDialog(title, message string, callback func(bool)) {
|
||||||
|
dialog.ShowConfirm(title, message, callback, m.window)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowInfoDialog shows an information dialog
|
||||||
|
func (m *Manager) ShowInfoDialog(title, message string) {
|
||||||
|
dialog.ShowInformation(title, message, m.window)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowUpdateAvailableDialog shows a dialog when update is available
|
||||||
|
func (m *Manager) ShowUpdateAvailableDialog(currentVersion, newVersion, releaseNotes string, onConfirm func()) {
|
||||||
|
content := container.NewVBox(
|
||||||
|
widget.NewLabel(fmt.Sprintf("发现新版本: %s", newVersion)),
|
||||||
|
widget.NewLabel(fmt.Sprintf("当前版本: %s", currentVersion)),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if releaseNotes != "" {
|
||||||
|
notesWidget := widget.NewRichText()
|
||||||
|
notesWidget.ParseMarkdown(releaseNotes)
|
||||||
|
|
||||||
|
notesScroll := container.NewScroll(notesWidget)
|
||||||
|
notesScroll.SetMinSize(fyne.NewSize(400, 200))
|
||||||
|
|
||||||
|
content.Add(widget.NewLabel("更新内容:"))
|
||||||
|
content.Add(notesScroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.ShowCustomConfirm(
|
||||||
|
"发现新版本",
|
||||||
|
"立即更新",
|
||||||
|
"稍后提醒",
|
||||||
|
content,
|
||||||
|
func(confirmed bool) {
|
||||||
|
if confirmed && onConfirm != nil {
|
||||||
|
onConfirm()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
m.window,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActionButtonCallback sets the callback for the main action button
|
||||||
|
func (m *Manager) SetActionButtonCallback(callback func()) {
|
||||||
|
if m.actionButton != nil {
|
||||||
|
m.actionButton.OnTapped = callback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableActionButton enables or disables the action button
|
||||||
|
func (m *Manager) EnableActionButton(enabled bool) {
|
||||||
|
if m.actionButton != nil {
|
||||||
|
if enabled {
|
||||||
|
m.actionButton.Enable()
|
||||||
|
} else {
|
||||||
|
m.actionButton.Disable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActionButtonText sets the text of the action button
|
||||||
|
func (m *Manager) SetActionButtonText(text string) {
|
||||||
|
if m.actionButton != nil {
|
||||||
|
m.actionButton.SetText(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowErrorWithRetry shows an error with retry option
|
||||||
|
func (m *Manager) ShowErrorWithRetry(errorMsg string, onRetry func()) {
|
||||||
|
dialog.ShowCustomConfirm(
|
||||||
|
"错误",
|
||||||
|
"重试",
|
||||||
|
"取消",
|
||||||
|
widget.NewLabel(errorMsg),
|
||||||
|
func(retry bool) {
|
||||||
|
if retry && onRetry != nil {
|
||||||
|
onRetry()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
m.window,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProgressBar updates the progress bar with custom styling
|
||||||
|
func (m *Manager) UpdateProgressBar(percentage float64, color string) {
|
||||||
|
m.ShowProgress(percentage)
|
||||||
|
// Note: Fyne doesn't support custom colors easily, but we keep the interface for future enhancement
|
||||||
|
}
|
||||||
|
|
||||||
|
// HideProgressBar hides the progress bar
|
||||||
|
func (m *Manager) HideProgressBar() {
|
||||||
|
if m.progressBar != nil {
|
||||||
|
m.progressBar.Hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowProgressBar shows the progress bar
|
||||||
|
func (m *Manager) ShowProgressBar() {
|
||||||
|
if m.progressBar != nil {
|
||||||
|
m.progressBar.Show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWindowTitle sets the window title
|
||||||
|
func (m *Manager) SetWindowTitle(title string) {
|
||||||
|
if m.window != nil {
|
||||||
|
m.window.SetTitle(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentStatus returns the current update status
|
||||||
|
func (m *Manager) GetCurrentStatus() UpdateStatus {
|
||||||
|
return m.currentStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWindowVisible returns whether the window is currently visible
|
||||||
|
func (m *Manager) IsWindowVisible() bool {
|
||||||
|
return m.window != nil && m.window.Content() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshUI refreshes the user interface
|
||||||
|
func (m *Manager) RefreshUI() {
|
||||||
|
if m.window != nil && m.window.Content() != nil {
|
||||||
|
m.window.Content().Refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the application
|
||||||
|
func (m *Manager) Close() {
|
||||||
|
if m.window != nil {
|
||||||
|
m.window.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
227
Go_Updater/gui/manager_test.go
Normal file
227
Go_Updater/gui/manager_test.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewManager(t *testing.T) {
|
||||||
|
manager := NewManager()
|
||||||
|
if manager == nil {
|
||||||
|
t.Fatal("NewManager() returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if manager.app == nil {
|
||||||
|
t.Error("Manager app is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if manager.window == nil {
|
||||||
|
t.Error("Manager window is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateStatus(t *testing.T) {
|
||||||
|
manager := NewManager()
|
||||||
|
manager.createUIComponents()
|
||||||
|
|
||||||
|
// Test different status updates
|
||||||
|
testCases := []struct {
|
||||||
|
status UpdateStatus
|
||||||
|
message string
|
||||||
|
}{
|
||||||
|
{StatusChecking, "检查更新中..."},
|
||||||
|
{StatusUpdateAvailable, "发现新版本"},
|
||||||
|
{StatusDownloading, "下载中..."},
|
||||||
|
{StatusInstalling, "安装中..."},
|
||||||
|
{StatusCompleted, "更新完成"},
|
||||||
|
{StatusError, "更新失败"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
manager.UpdateStatus(tc.status, tc.message)
|
||||||
|
|
||||||
|
if manager.GetCurrentStatus() != tc.status {
|
||||||
|
t.Errorf("Expected status %v, got %v", tc.status, manager.GetCurrentStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
if manager.statusLabel.Text != tc.message {
|
||||||
|
t.Errorf("Expected message '%s', got '%s'", tc.message, manager.statusLabel.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowProgress(t *testing.T) {
|
||||||
|
manager := NewManager()
|
||||||
|
manager.createUIComponents()
|
||||||
|
|
||||||
|
// Test progress values
|
||||||
|
testValues := []float64{0, 25.5, 50, 75.8, 100, 150, -10}
|
||||||
|
expectedValues := []float64{0, 25.5, 50, 75.8, 100, 100, 0}
|
||||||
|
|
||||||
|
for i, value := range testValues {
|
||||||
|
manager.ShowProgress(value)
|
||||||
|
expected := expectedValues[i] / 100.0
|
||||||
|
|
||||||
|
if manager.progressBar.Value != expected {
|
||||||
|
t.Errorf("Expected progress %.2f, got %.2f", expected, manager.progressBar.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetVersionInfo(t *testing.T) {
|
||||||
|
manager := NewManager()
|
||||||
|
manager.createUIComponents()
|
||||||
|
|
||||||
|
version := "v1.2.3"
|
||||||
|
manager.SetVersionInfo(version)
|
||||||
|
|
||||||
|
expectedText := "当前版本: v1.2.3"
|
||||||
|
if manager.versionLabel.Text != expectedText {
|
||||||
|
t.Errorf("Expected version text '%s', got '%s'", expectedText, manager.versionLabel.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatSpeed(t *testing.T) {
|
||||||
|
manager := NewManager()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
speed int64
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{512, "512 B/s"},
|
||||||
|
{1536, "1.5 KB/s"},
|
||||||
|
{1048576, "1.0 MB/s"},
|
||||||
|
{2621440, "2.5 MB/s"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
result := manager.formatSpeed(tc.speed)
|
||||||
|
if result != tc.expected {
|
||||||
|
t.Errorf("Expected speed format '%s', got '%s'", tc.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowProgressWithSpeed(t *testing.T) {
|
||||||
|
manager := NewManager()
|
||||||
|
manager.createUIComponents()
|
||||||
|
|
||||||
|
percentage := 45.5
|
||||||
|
speed := int64(1048576) // 1 MB/s
|
||||||
|
eta := "2分钟"
|
||||||
|
|
||||||
|
manager.ShowProgressWithSpeed(percentage, speed, eta)
|
||||||
|
|
||||||
|
expectedProgress := percentage / 100.0
|
||||||
|
if manager.progressBar.Value != expectedProgress {
|
||||||
|
t.Errorf("Expected progress %.2f, got %.2f", expectedProgress, manager.progressBar.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedStatus := "下载中... 45.5% (1.0 MB/s) - 剩余时间: 2分钟"
|
||||||
|
if manager.statusLabel.Text != expectedStatus {
|
||||||
|
t.Errorf("Expected status '%s', got '%s'", expectedStatus, manager.statusLabel.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionButtonStates(t *testing.T) {
|
||||||
|
manager := NewManager()
|
||||||
|
manager.createUIComponents()
|
||||||
|
|
||||||
|
// Test enabling/disabling
|
||||||
|
manager.EnableActionButton(false)
|
||||||
|
if !manager.actionButton.Disabled() {
|
||||||
|
t.Error("Action button should be disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.EnableActionButton(true)
|
||||||
|
if manager.actionButton.Disabled() {
|
||||||
|
t.Error("Action button should be enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test text setting
|
||||||
|
testText := "测试按钮"
|
||||||
|
manager.SetActionButtonText(testText)
|
||||||
|
if manager.actionButton.Text != testText {
|
||||||
|
t.Errorf("Expected button text '%s', got '%s'", testText, manager.actionButton.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressBarVisibility(t *testing.T) {
|
||||||
|
manager := NewManager()
|
||||||
|
manager.createUIComponents()
|
||||||
|
|
||||||
|
// Initially hidden
|
||||||
|
if manager.progressBar.Visible() {
|
||||||
|
t.Error("Progress bar should be initially hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show progress bar
|
||||||
|
manager.ShowProgressBar()
|
||||||
|
if !manager.progressBar.Visible() {
|
||||||
|
t.Error("Progress bar should be visible after ShowProgressBar()")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide progress bar
|
||||||
|
manager.HideProgressBar()
|
||||||
|
if manager.progressBar.Visible() {
|
||||||
|
t.Error("Progress bar should be hidden after HideProgressBar()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetCallbacks(t *testing.T) {
|
||||||
|
manager := NewManager()
|
||||||
|
|
||||||
|
checkUpdateCalled := false
|
||||||
|
cancelCalled := false
|
||||||
|
|
||||||
|
onCheckUpdate := func() {
|
||||||
|
checkUpdateCalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancel := func() {
|
||||||
|
cancelCalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.SetCallbacks(onCheckUpdate, onCancel)
|
||||||
|
|
||||||
|
// Verify callbacks are set
|
||||||
|
if manager.onCheckUpdate == nil {
|
||||||
|
t.Error("onCheckUpdate callback not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if manager.onCancel == nil {
|
||||||
|
t.Error("onCancel callback not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test callback execution
|
||||||
|
manager.onCheckUpdate()
|
||||||
|
if !checkUpdateCalled {
|
||||||
|
t.Error("onCheckUpdate callback was not called")
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.onCancel()
|
||||||
|
if !cancelCalled {
|
||||||
|
t.Error("onCancel callback was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark tests for performance
|
||||||
|
func BenchmarkUpdateStatus(b *testing.B) {
|
||||||
|
manager := NewManager()
|
||||||
|
manager.createUIComponents()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
manager.UpdateStatus(StatusDownloading, "下载中...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkShowProgress(b *testing.B) {
|
||||||
|
manager := NewManager()
|
||||||
|
manager.createUIComponents()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
manager.ShowProgress(float64(i % 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Go_Updater/icon/AUTO_MAA_Go_Updater.ico
Normal file
BIN
Go_Updater/icon/AUTO_MAA_Go_Updater.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
Go_Updater/icon/AUTO_MAA_Go_Updater.png
Normal file
BIN
Go_Updater/icon/AUTO_MAA_Go_Updater.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
474
Go_Updater/install/manager.go
Normal file
474
Go_Updater/install/manager.go
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
package install
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChangesInfo represents the structure of changes.json file
|
||||||
|
type ChangesInfo struct {
|
||||||
|
Deleted []string `json:"deleted"`
|
||||||
|
Added []string `json:"added"`
|
||||||
|
Modified []string `json:"modified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallManager interface defines the contract for installation operations
|
||||||
|
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 implements the InstallManager interface
|
||||||
|
type Manager struct {
|
||||||
|
tempDirs []string // Track temporary directories for cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new install manager instance
|
||||||
|
func NewManager() *Manager {
|
||||||
|
return &Manager{
|
||||||
|
tempDirs: make([]string, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTempDir creates a temporary directory for extraction
|
||||||
|
func (m *Manager) CreateTempDir() (string, error) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "updater_*")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create temp directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track temp directory for cleanup
|
||||||
|
m.tempDirs = append(m.tempDirs, tempDir)
|
||||||
|
return tempDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupTempDir removes a temporary directory and its contents
|
||||||
|
func (m *Manager) CleanupTempDir(tempDir string) error {
|
||||||
|
if tempDir == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := os.RemoveAll(tempDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to cleanup temp directory %s: %w", tempDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from tracking list
|
||||||
|
for i, dir := range m.tempDirs {
|
||||||
|
if dir == tempDir {
|
||||||
|
m.tempDirs = append(m.tempDirs[:i], m.tempDirs[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupAllTempDirs removes all tracked temporary directories
|
||||||
|
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("failed to cleanup %s: %v", tempDir, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.tempDirs = m.tempDirs[:0] // Clear the slice
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
return fmt.Errorf("cleanup errors: %s", strings.Join(errors, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractZip extracts a ZIP file to the specified destination directory
|
||||||
|
func (m *Manager) ExtractZip(zipPath, destPath string) error {
|
||||||
|
// Open ZIP file for reading
|
||||||
|
reader, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open ZIP file %s: %w", zipPath, err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Create destination directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(destPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination directory %s: %w", destPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract files
|
||||||
|
for _, file := range reader.File {
|
||||||
|
if err := m.extractFile(file, destPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to extract file %s: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFile extracts a single file from the ZIP archive
|
||||||
|
func (m *Manager) extractFile(file *zip.File, destPath string) error {
|
||||||
|
// Clean the file path to prevent directory traversal attacks
|
||||||
|
cleanPath := filepath.Clean(file.Name)
|
||||||
|
if strings.Contains(cleanPath, "..") {
|
||||||
|
return fmt.Errorf("invalid file path: %s", file.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create full destination path
|
||||||
|
destFile := filepath.Join(destPath, cleanPath)
|
||||||
|
|
||||||
|
// Create directory structure if needed
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
return os.MkdirAll(destFile, file.FileInfo().Mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create parent directories
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destFile), 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create parent directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open file in ZIP archive
|
||||||
|
rc, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open file in archive: %w", err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
// Create destination file
|
||||||
|
outFile, err := os.OpenFile(destFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.FileInfo().Mode())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination file: %w", err)
|
||||||
|
}
|
||||||
|
defer outFile.Close()
|
||||||
|
|
||||||
|
// Copy file contents
|
||||||
|
_, err = io.Copy(outFile, rc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to copy file contents: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessChanges reads and parses the changes.json file
|
||||||
|
func (m *Manager) ProcessChanges(changesPath string) (*ChangesInfo, error) {
|
||||||
|
// Check if changes.json exists
|
||||||
|
if _, err := os.Stat(changesPath); os.IsNotExist(err) {
|
||||||
|
// If changes.json doesn't exist, return empty changes info
|
||||||
|
return &ChangesInfo{
|
||||||
|
Deleted: []string{},
|
||||||
|
Added: []string{},
|
||||||
|
Modified: []string{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the changes.json file
|
||||||
|
data, err := os.ReadFile(changesPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read changes file %s: %w", changesPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON
|
||||||
|
var changes ChangesInfo
|
||||||
|
if err := json.Unmarshal(data, &changes); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse changes JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRunningProcess handles running processes by renaming files that are in use
|
||||||
|
func (m *Manager) HandleRunningProcess(processName string) error {
|
||||||
|
// Get the current executable path
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get executable path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exeDir := filepath.Dir(exePath)
|
||||||
|
targetFile := filepath.Join(exeDir, processName)
|
||||||
|
|
||||||
|
// Check if the target file exists
|
||||||
|
if _, err := os.Stat(targetFile); os.IsNotExist(err) {
|
||||||
|
// File doesn't exist, nothing to handle
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to rename the file to indicate it should be deleted on next startup
|
||||||
|
oldFile := targetFile + ".old"
|
||||||
|
|
||||||
|
// Remove existing .old file if it exists
|
||||||
|
if _, err := os.Stat(oldFile); err == nil {
|
||||||
|
if err := os.Remove(oldFile); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove existing old file %s: %w", oldFile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename the current file to .old
|
||||||
|
if err := os.Rename(targetFile, oldFile); err != nil {
|
||||||
|
// If rename fails, the process might be running
|
||||||
|
// On Windows, we can't rename a running executable
|
||||||
|
if isFileInUse(err) {
|
||||||
|
// Mark the file for deletion on next reboot (Windows specific)
|
||||||
|
return m.markFileForDeletion(targetFile)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to rename running process file %s: %w", targetFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFileInUse checks if the error indicates the file is in use
|
||||||
|
func isFileInUse(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Windows-specific "file in use" errors
|
||||||
|
if pathErr, ok := err.(*os.PathError); ok {
|
||||||
|
if errno, ok := pathErr.Err.(syscall.Errno); ok {
|
||||||
|
// ERROR_SHARING_VIOLATION (32) or 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 marks a file for deletion on next system reboot (Windows specific)
|
||||||
|
func (m *Manager) markFileForDeletion(filePath string) error {
|
||||||
|
// This is a Windows-specific implementation
|
||||||
|
// For now, we'll create a marker file that can be handled by the main application
|
||||||
|
markerFile := filePath + ".delete_on_restart"
|
||||||
|
|
||||||
|
// Create a marker file
|
||||||
|
file, err := os.Create(markerFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create deletion marker file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Write the target file path to the marker
|
||||||
|
_, err = file.WriteString(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write to marker file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMarkedFiles removes files that were marked for deletion
|
||||||
|
func (m *Manager) DeleteMarkedFiles(directory string) error {
|
||||||
|
// Find all .delete_on_restart files
|
||||||
|
pattern := filepath.Join(directory, "*.delete_on_restart")
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find marker files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errors []string
|
||||||
|
for _, markerFile := range matches {
|
||||||
|
// Read the target file path
|
||||||
|
data, err := os.ReadFile(markerFile)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("failed to read marker file %s: %v", markerFile, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
targetFile := strings.TrimSpace(string(data))
|
||||||
|
|
||||||
|
// Try to delete the target file
|
||||||
|
if err := os.Remove(targetFile); err != nil && !os.IsNotExist(err) {
|
||||||
|
errors = append(errors, fmt.Sprintf("failed to delete marked file %s: %v", targetFile, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the marker file
|
||||||
|
if err := os.Remove(markerFile); err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("failed to remove marker file %s: %v", markerFile, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
return fmt.Errorf("deletion errors: %s", strings.Join(errors, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyUpdate applies the update by copying files from source to target directory
|
||||||
|
func (m *Manager) ApplyUpdate(sourcePath, targetPath string, changes *ChangesInfo) error {
|
||||||
|
// Create backup directory
|
||||||
|
backupDir, err := m.createBackupDir(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup existing files before applying update
|
||||||
|
if err := m.backupFiles(targetPath, backupDir, changes); err != nil {
|
||||||
|
return fmt.Errorf("failed to backup files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the update
|
||||||
|
if err := m.applyUpdateFiles(sourcePath, targetPath, changes); err != nil {
|
||||||
|
// Rollback on failure
|
||||||
|
if rollbackErr := m.rollbackUpdate(targetPath, backupDir); rollbackErr != nil {
|
||||||
|
return fmt.Errorf("update failed and rollback failed: update error: %w, rollback error: %v", err, rollbackErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("update failed and was rolled back: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up backup directory after successful update
|
||||||
|
if err := os.RemoveAll(backupDir); err != nil {
|
||||||
|
// Log warning but don't fail the update
|
||||||
|
fmt.Printf("Warning: failed to cleanup backup directory %s: %v\n", backupDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createBackupDir creates a backup directory for the update
|
||||||
|
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("failed to create backup directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return backupDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// backupFiles creates backups of files that will be modified or deleted
|
||||||
|
func (m *Manager) backupFiles(targetPath, backupDir string, changes *ChangesInfo) error {
|
||||||
|
// Backup files that will be modified
|
||||||
|
for _, file := range changes.Modified {
|
||||||
|
srcFile := filepath.Join(targetPath, file)
|
||||||
|
if _, err := os.Stat(srcFile); os.IsNotExist(err) {
|
||||||
|
continue // File doesn't exist, skip backup
|
||||||
|
}
|
||||||
|
|
||||||
|
backupFile := filepath.Join(backupDir, file)
|
||||||
|
if err := m.copyFileWithDirs(srcFile, backupFile); err != nil {
|
||||||
|
return fmt.Errorf("failed to backup modified file %s: %w", file, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup files that will be deleted
|
||||||
|
for _, file := range changes.Deleted {
|
||||||
|
srcFile := filepath.Join(targetPath, file)
|
||||||
|
if _, err := os.Stat(srcFile); os.IsNotExist(err) {
|
||||||
|
continue // File doesn't exist, skip backup
|
||||||
|
}
|
||||||
|
|
||||||
|
backupFile := filepath.Join(backupDir, file)
|
||||||
|
if err := m.copyFileWithDirs(srcFile, backupFile); err != nil {
|
||||||
|
return fmt.Errorf("failed to backup deleted file %s: %w", file, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUpdateFiles applies the actual file changes
|
||||||
|
func (m *Manager) applyUpdateFiles(sourcePath, targetPath string, changes *ChangesInfo) error {
|
||||||
|
// Delete files marked for deletion
|
||||||
|
for _, file := range changes.Deleted {
|
||||||
|
targetFile := filepath.Join(targetPath, file)
|
||||||
|
if err := os.Remove(targetFile); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to delete file %s: %w", file, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy new and modified files
|
||||||
|
filesToCopy := append(changes.Added, changes.Modified...)
|
||||||
|
for _, file := range filesToCopy {
|
||||||
|
srcFile := filepath.Join(sourcePath, file)
|
||||||
|
targetFile := filepath.Join(targetPath, file)
|
||||||
|
|
||||||
|
// Check if source file exists
|
||||||
|
if _, err := os.Stat(srcFile); os.IsNotExist(err) {
|
||||||
|
continue // Source file doesn't exist, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.copyFileWithDirs(srcFile, targetFile); err != nil {
|
||||||
|
return fmt.Errorf("failed to copy file %s: %w", file, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFileWithDirs copies a file and creates necessary directories
|
||||||
|
func (m *Manager) copyFileWithDirs(src, dst string) error {
|
||||||
|
// Create parent directories
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create parent directories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open source file
|
||||||
|
srcFile, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open source file: %w", err)
|
||||||
|
}
|
||||||
|
defer srcFile.Close()
|
||||||
|
|
||||||
|
// Get source file info
|
||||||
|
srcInfo, err := srcFile.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get source file info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create destination file
|
||||||
|
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination file: %w", err)
|
||||||
|
}
|
||||||
|
defer dstFile.Close()
|
||||||
|
|
||||||
|
// Copy file contents
|
||||||
|
_, err = io.Copy(dstFile, srcFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to copy file contents: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollbackUpdate restores files from backup in case of update failure
|
||||||
|
func (m *Manager) rollbackUpdate(targetPath, backupDir string) error {
|
||||||
|
// Walk through backup directory and restore files
|
||||||
|
return filepath.Walk(backupDir, func(backupFile string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil // Skip directories
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate relative path
|
||||||
|
relPath, err := filepath.Rel(backupDir, backupFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to calculate relative path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore file to target location
|
||||||
|
targetFile := filepath.Join(targetPath, relPath)
|
||||||
|
if err := m.copyFileWithDirs(backupFile, targetFile); err != nil {
|
||||||
|
return fmt.Errorf("failed to restore file %s: %w", relPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
1033
Go_Updater/install/manager_test.go
Normal file
1033
Go_Updater/install/manager_test.go
Normal file
File diff suppressed because it is too large
Load Diff
12
Go_Updater/integration_test.go
Normal file
12
Go_Updater/integration_test.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Integration tests will be implemented here
|
||||||
|
// This file is currently a placeholder
|
||||||
|
|
||||||
|
func TestIntegrationPlaceholder(t *testing.T) {
|
||||||
|
t.Skip("Integration tests not yet implemented")
|
||||||
|
}
|
||||||
438
Go_Updater/logger/logger.go
Normal file
438
Go_Updater/logger/logger.go
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogLevel 日志级别
|
||||||
|
type LogLevel int
|
||||||
|
|
||||||
|
const (
|
||||||
|
DEBUG LogLevel = iota
|
||||||
|
INFO
|
||||||
|
WARN
|
||||||
|
ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
// String 返回日志级别的字符串表示
|
||||||
|
func (l LogLevel) String() string {
|
||||||
|
switch l {
|
||||||
|
case DEBUG:
|
||||||
|
return "DEBUG"
|
||||||
|
case INFO:
|
||||||
|
return "INFO"
|
||||||
|
case WARN:
|
||||||
|
return "WARN"
|
||||||
|
case ERROR:
|
||||||
|
return "ERROR"
|
||||||
|
default:
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger 日志记录器接口
|
||||||
|
type Logger interface {
|
||||||
|
Debug(msg string, fields ...interface{})
|
||||||
|
Info(msg string, fields ...interface{})
|
||||||
|
Warn(msg string, fields ...interface{})
|
||||||
|
Error(msg string, fields ...interface{})
|
||||||
|
SetLevel(level LogLevel)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileLogger 文件日志记录器
|
||||||
|
type FileLogger struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
file *os.File
|
||||||
|
logger *log.Logger
|
||||||
|
level LogLevel
|
||||||
|
maxSize int64 // 最大文件大小(字节)
|
||||||
|
maxBackups int // 最大备份文件数
|
||||||
|
logDir string // 日志目录
|
||||||
|
filename string // 日志文件名
|
||||||
|
currentSize int64 // 当前文件大小
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoggerConfig 日志配置
|
||||||
|
type LoggerConfig struct {
|
||||||
|
Level LogLevel
|
||||||
|
MaxSize int64 // 最大文件大小(字节),默认10MB
|
||||||
|
MaxBackups int // 最大备份文件数,默认5
|
||||||
|
LogDir string // 日志目录,默认%APPDATA%/LightweightUpdater/logs
|
||||||
|
Filename string // 日志文件名,默认updater.log
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultLoggerConfig 默认日志配置
|
||||||
|
func DefaultLoggerConfig() *LoggerConfig {
|
||||||
|
// 获取当前可执行文件目录
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
var logDir string
|
||||||
|
if err != nil {
|
||||||
|
logDir = "debug"
|
||||||
|
} else {
|
||||||
|
exeDir := filepath.Dir(exePath)
|
||||||
|
logDir = filepath.Join(exeDir, "debug")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LoggerConfig{
|
||||||
|
Level: INFO,
|
||||||
|
MaxSize: 10 * 1024 * 1024, // 10MB
|
||||||
|
MaxBackups: 5,
|
||||||
|
LogDir: logDir,
|
||||||
|
Filename: "AUTO_MAA_Go_Updater.log",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileLogger 创建新的文件日志记录器
|
||||||
|
func NewFileLogger(config *LoggerConfig) (*FileLogger, error) {
|
||||||
|
if config == nil {
|
||||||
|
config = DefaultLoggerConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建日志目录
|
||||||
|
if err := os.MkdirAll(config.LogDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create log directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logPath := filepath.Join(config.LogDir, config.Filename)
|
||||||
|
|
||||||
|
// 打开或创建日志文件
|
||||||
|
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open log file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前文件大小
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
return nil, fmt.Errorf("failed to get file stats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := &FileLogger{
|
||||||
|
file: file,
|
||||||
|
logger: log.New(file, "", 0), // 我们自己处理格式
|
||||||
|
level: config.Level,
|
||||||
|
maxSize: config.MaxSize,
|
||||||
|
maxBackups: config.MaxBackups,
|
||||||
|
logDir: config.LogDir,
|
||||||
|
filename: config.Filename,
|
||||||
|
currentSize: stat.Size(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return logger, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatMessage 格式化日志消息
|
||||||
|
func (fl *FileLogger) formatMessage(level LogLevel, msg string, fields ...interface{}) string {
|
||||||
|
timestamp := time.Now().Format("2006-01-02 15:04:05.000")
|
||||||
|
|
||||||
|
if len(fields) > 0 {
|
||||||
|
msg = fmt.Sprintf(msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("[%s] %s %s\n", timestamp, level.String(), msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLog 写入日志
|
||||||
|
func (fl *FileLogger) writeLog(level LogLevel, msg string, fields ...interface{}) {
|
||||||
|
fl.mu.Lock()
|
||||||
|
defer fl.mu.Unlock()
|
||||||
|
|
||||||
|
// 检查日志级别
|
||||||
|
if level < fl.level {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formattedMsg := fl.formatMessage(level, msg, fields...)
|
||||||
|
|
||||||
|
// 检查是否需要轮转
|
||||||
|
if fl.currentSize+int64(len(formattedMsg)) > fl.maxSize {
|
||||||
|
if err := fl.rotate(); err != nil {
|
||||||
|
// 轮转失败,尝试写入stderr
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to rotate log: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入日志
|
||||||
|
n, err := fl.file.WriteString(formattedMsg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to write log: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fl.currentSize += int64(n)
|
||||||
|
fl.file.Sync() // 确保写入磁盘
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotate 轮转日志文件
|
||||||
|
func (fl *FileLogger) rotate() error {
|
||||||
|
// 关闭当前文件
|
||||||
|
if err := fl.file.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close current log file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 轮转备份文件
|
||||||
|
if err := fl.rotateBackups(); err != nil {
|
||||||
|
return fmt.Errorf("failed to rotate backups: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的日志文件
|
||||||
|
logPath := filepath.Join(fl.logDir, fl.filename)
|
||||||
|
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create new log file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fl.file = file
|
||||||
|
fl.logger.SetOutput(file)
|
||||||
|
fl.currentSize = 0
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotateBackups 轮转备份文件
|
||||||
|
func (fl *FileLogger) rotateBackups() error {
|
||||||
|
basePath := filepath.Join(fl.logDir, fl.filename)
|
||||||
|
|
||||||
|
// 删除最老的备份文件
|
||||||
|
if fl.maxBackups > 0 {
|
||||||
|
oldestBackup := fmt.Sprintf("%s.%d", basePath, fl.maxBackups)
|
||||||
|
os.Remove(oldestBackup) // 忽略错误,文件可能不存在
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重命名现有备份文件
|
||||||
|
for i := fl.maxBackups - 1; i > 0; i-- {
|
||||||
|
oldName := fmt.Sprintf("%s.%d", basePath, i)
|
||||||
|
newName := fmt.Sprintf("%s.%d", basePath, i+1)
|
||||||
|
os.Rename(oldName, newName) // 忽略错误,文件可能不存在
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将当前日志文件重命名为第一个备份
|
||||||
|
if fl.maxBackups > 0 {
|
||||||
|
backupName := fmt.Sprintf("%s.1", basePath)
|
||||||
|
return os.Rename(basePath, backupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug 记录调试级别日志
|
||||||
|
func (fl *FileLogger) Debug(msg string, fields ...interface{}) {
|
||||||
|
fl.writeLog(DEBUG, msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info 记录信息级别日志
|
||||||
|
func (fl *FileLogger) Info(msg string, fields ...interface{}) {
|
||||||
|
fl.writeLog(INFO, msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn 记录警告级别日志
|
||||||
|
func (fl *FileLogger) Warn(msg string, fields ...interface{}) {
|
||||||
|
fl.writeLog(WARN, msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error 记录错误级别日志
|
||||||
|
func (fl *FileLogger) Error(msg string, fields ...interface{}) {
|
||||||
|
fl.writeLog(ERROR, msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLevel 设置日志级别
|
||||||
|
func (fl *FileLogger) SetLevel(level LogLevel) {
|
||||||
|
fl.mu.Lock()
|
||||||
|
defer fl.mu.Unlock()
|
||||||
|
fl.level = level
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭日志记录器
|
||||||
|
func (fl *FileLogger) Close() error {
|
||||||
|
fl.mu.Lock()
|
||||||
|
defer fl.mu.Unlock()
|
||||||
|
|
||||||
|
if fl.file != nil {
|
||||||
|
return fl.file.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiLogger 多输出日志记录器
|
||||||
|
type MultiLogger struct {
|
||||||
|
loggers []Logger
|
||||||
|
level LogLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMultiLogger 创建多输出日志记录器
|
||||||
|
func NewMultiLogger(loggers ...Logger) *MultiLogger {
|
||||||
|
return &MultiLogger{
|
||||||
|
loggers: loggers,
|
||||||
|
level: INFO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug 记录调试级别日志
|
||||||
|
func (ml *MultiLogger) Debug(msg string, fields ...interface{}) {
|
||||||
|
for _, logger := range ml.loggers {
|
||||||
|
logger.Debug(msg, fields...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info 记录信息级别日志
|
||||||
|
func (ml *MultiLogger) Info(msg string, fields ...interface{}) {
|
||||||
|
for _, logger := range ml.loggers {
|
||||||
|
logger.Info(msg, fields...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn 记录警告级别日志
|
||||||
|
func (ml *MultiLogger) Warn(msg string, fields ...interface{}) {
|
||||||
|
for _, logger := range ml.loggers {
|
||||||
|
logger.Warn(msg, fields...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error 记录错误级别日志
|
||||||
|
func (ml *MultiLogger) Error(msg string, fields ...interface{}) {
|
||||||
|
for _, logger := range ml.loggers {
|
||||||
|
logger.Error(msg, fields...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLevel 设置日志级别
|
||||||
|
func (ml *MultiLogger) SetLevel(level LogLevel) {
|
||||||
|
ml.level = level
|
||||||
|
for _, logger := range ml.loggers {
|
||||||
|
logger.SetLevel(level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭所有日志记录器
|
||||||
|
func (ml *MultiLogger) Close() error {
|
||||||
|
var lastErr error
|
||||||
|
for _, logger := range ml.loggers {
|
||||||
|
if err := logger.Close(); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsoleLogger 控制台日志记录器
|
||||||
|
type ConsoleLogger struct {
|
||||||
|
writer io.Writer
|
||||||
|
level LogLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConsoleLogger 创建控制台日志记录器
|
||||||
|
func NewConsoleLogger(writer io.Writer) *ConsoleLogger {
|
||||||
|
if writer == nil {
|
||||||
|
writer = os.Stdout
|
||||||
|
}
|
||||||
|
return &ConsoleLogger{
|
||||||
|
writer: writer,
|
||||||
|
level: INFO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatMessage 格式化控制台日志消息
|
||||||
|
func (cl *ConsoleLogger) formatMessage(level LogLevel, msg string, fields ...interface{}) string {
|
||||||
|
timestamp := time.Now().Format("15:04:05")
|
||||||
|
|
||||||
|
if len(fields) > 0 {
|
||||||
|
msg = fmt.Sprintf(msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("[%s] %s %s\n", timestamp, level.String(), msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLog 写入控制台日志
|
||||||
|
func (cl *ConsoleLogger) writeLog(level LogLevel, msg string, fields ...interface{}) {
|
||||||
|
if level < cl.level {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formattedMsg := cl.formatMessage(level, msg, fields...)
|
||||||
|
fmt.Fprint(cl.writer, formattedMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug 记录调试级别日志
|
||||||
|
func (cl *ConsoleLogger) Debug(msg string, fields ...interface{}) {
|
||||||
|
cl.writeLog(DEBUG, msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info 记录信息级别日志
|
||||||
|
func (cl *ConsoleLogger) Info(msg string, fields ...interface{}) {
|
||||||
|
cl.writeLog(INFO, msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn 记录警告级别日志
|
||||||
|
func (cl *ConsoleLogger) Warn(msg string, fields ...interface{}) {
|
||||||
|
cl.writeLog(WARN, msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error 记录错误级别日志
|
||||||
|
func (cl *ConsoleLogger) Error(msg string, fields ...interface{}) {
|
||||||
|
cl.writeLog(ERROR, msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLevel 设置日志级别
|
||||||
|
func (cl *ConsoleLogger) SetLevel(level LogLevel) {
|
||||||
|
cl.level = level
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭控制台日志记录器(无操作)
|
||||||
|
func (cl *ConsoleLogger) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局日志记录器实例
|
||||||
|
var (
|
||||||
|
defaultLogger Logger
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetDefaultLogger 获取默认日志记录器
|
||||||
|
func GetDefaultLogger() Logger {
|
||||||
|
once.Do(func() {
|
||||||
|
fileLogger, err := NewFileLogger(DefaultLoggerConfig())
|
||||||
|
if err != nil {
|
||||||
|
// 如果文件日志创建失败,使用控制台日志
|
||||||
|
defaultLogger = NewConsoleLogger(os.Stderr)
|
||||||
|
} else {
|
||||||
|
// 同时输出到文件和控制台
|
||||||
|
consoleLogger := NewConsoleLogger(os.Stdout)
|
||||||
|
defaultLogger = NewMultiLogger(fileLogger, consoleLogger)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return defaultLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
// 便捷函数
|
||||||
|
func Debug(msg string, fields ...interface{}) {
|
||||||
|
GetDefaultLogger().Debug(msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Info(msg string, fields ...interface{}) {
|
||||||
|
GetDefaultLogger().Info(msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Warn(msg string, fields ...interface{}) {
|
||||||
|
GetDefaultLogger().Warn(msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Error(msg string, fields ...interface{}) {
|
||||||
|
GetDefaultLogger().Error(msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetLevel(level LogLevel) {
|
||||||
|
GetDefaultLogger().SetLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Close() error {
|
||||||
|
return GetDefaultLogger().Close()
|
||||||
|
}
|
||||||
300
Go_Updater/logger/logger_test.go
Normal file
300
Go_Updater/logger/logger_test.go
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogLevel_String(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
level LogLevel
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{DEBUG, "DEBUG"},
|
||||||
|
{INFO, "INFO"},
|
||||||
|
{WARN, "WARN"},
|
||||||
|
{ERROR, "ERROR"},
|
||||||
|
{LogLevel(999), "UNKNOWN"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.expected, func(t *testing.T) {
|
||||||
|
if got := tt.level.String(); got != tt.expected {
|
||||||
|
t.Errorf("LogLevel.String() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultLoggerConfig(t *testing.T) {
|
||||||
|
config := DefaultLoggerConfig()
|
||||||
|
|
||||||
|
if config.Level != INFO {
|
||||||
|
t.Errorf("Expected default level INFO, got %v", config.Level)
|
||||||
|
}
|
||||||
|
if config.MaxSize != 10*1024*1024 {
|
||||||
|
t.Errorf("Expected default max size 10MB, got %v", config.MaxSize)
|
||||||
|
}
|
||||||
|
if config.MaxBackups != 5 {
|
||||||
|
t.Errorf("Expected default max backups 5, got %v", config.MaxBackups)
|
||||||
|
}
|
||||||
|
if config.Filename != "updater.log" {
|
||||||
|
t.Errorf("Expected default filename 'updater.log', got %v", config.Filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleLogger(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := NewConsoleLogger(&buf)
|
||||||
|
|
||||||
|
t.Run("log levels", func(t *testing.T) {
|
||||||
|
logger.SetLevel(DEBUG)
|
||||||
|
|
||||||
|
logger.Debug("debug message")
|
||||||
|
logger.Info("info message")
|
||||||
|
logger.Warn("warn message")
|
||||||
|
logger.Error("error message")
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
if !strings.Contains(output, "DEBUG debug message") {
|
||||||
|
t.Error("Expected debug message in output")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "INFO info message") {
|
||||||
|
t.Error("Expected info message in output")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "WARN warn message") {
|
||||||
|
t.Error("Expected warn message in output")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "ERROR error message") {
|
||||||
|
t.Error("Expected error message in output")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("log level filtering", func(t *testing.T) {
|
||||||
|
buf.Reset()
|
||||||
|
logger.SetLevel(WARN)
|
||||||
|
|
||||||
|
logger.Debug("debug message")
|
||||||
|
logger.Info("info message")
|
||||||
|
logger.Warn("warn message")
|
||||||
|
logger.Error("error message")
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
if strings.Contains(output, "DEBUG") {
|
||||||
|
t.Error("Debug message should be filtered out")
|
||||||
|
}
|
||||||
|
if strings.Contains(output, "INFO") {
|
||||||
|
t.Error("Info message should be filtered out")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "WARN warn message") {
|
||||||
|
t.Error("Expected warn message in output")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "ERROR error message") {
|
||||||
|
t.Error("Expected error message in output")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("formatted messages", func(t *testing.T) {
|
||||||
|
buf.Reset()
|
||||||
|
logger.SetLevel(DEBUG)
|
||||||
|
|
||||||
|
logger.Info("formatted message: %s %d", "test", 42)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
if !strings.Contains(output, "formatted message: test 42") {
|
||||||
|
t.Error("Expected formatted message in output")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileLogger(t *testing.T) {
|
||||||
|
// 创建临时目录
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
config := &LoggerConfig{
|
||||||
|
Level: DEBUG,
|
||||||
|
MaxSize: 1024, // 1KB for testing rotation
|
||||||
|
MaxBackups: 3,
|
||||||
|
LogDir: tempDir,
|
||||||
|
Filename: "test.log",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger, err := NewFileLogger(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create file logger: %v", err)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
|
||||||
|
t.Run("basic logging", func(t *testing.T) {
|
||||||
|
logger.Info("test message")
|
||||||
|
logger.Error("error message with %s", "formatting")
|
||||||
|
|
||||||
|
// 读取日志文件
|
||||||
|
logPath := filepath.Join(tempDir, "test.log")
|
||||||
|
content, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read log file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := string(content)
|
||||||
|
if !strings.Contains(output, "INFO test message") {
|
||||||
|
t.Error("Expected info message in log file")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "ERROR error message with formatting") {
|
||||||
|
t.Error("Expected formatted error message in log file")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("log rotation", func(t *testing.T) {
|
||||||
|
// 写入大量数据触发轮转
|
||||||
|
longMessage := strings.Repeat("a", 200)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
logger.Info("Long message %d: %s", i, longMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否创建了备份文件
|
||||||
|
logPath := filepath.Join(tempDir, "test.log")
|
||||||
|
backupPath := filepath.Join(tempDir, "test.log.1")
|
||||||
|
|
||||||
|
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
||||||
|
t.Error("Main log file should exist")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||||
|
t.Error("Backup log file should exist after rotation")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiLogger(t *testing.T) {
|
||||||
|
var buf1, buf2 bytes.Buffer
|
||||||
|
logger1 := NewConsoleLogger(&buf1)
|
||||||
|
logger2 := NewConsoleLogger(&buf2)
|
||||||
|
|
||||||
|
multiLogger := NewMultiLogger(logger1, logger2)
|
||||||
|
multiLogger.SetLevel(INFO)
|
||||||
|
|
||||||
|
multiLogger.Info("test message")
|
||||||
|
multiLogger.Error("error message")
|
||||||
|
|
||||||
|
// 检查两个logger都收到了消息
|
||||||
|
output1 := buf1.String()
|
||||||
|
output2 := buf2.String()
|
||||||
|
|
||||||
|
if !strings.Contains(output1, "INFO test message") {
|
||||||
|
t.Error("Expected info message in first logger")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output1, "ERROR error message") {
|
||||||
|
t.Error("Expected error message in first logger")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output2, "INFO test message") {
|
||||||
|
t.Error("Expected info message in second logger")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output2, "ERROR error message") {
|
||||||
|
t.Error("Expected error message in second logger")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileLoggerRotation(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
config := &LoggerConfig{
|
||||||
|
Level: DEBUG,
|
||||||
|
MaxSize: 100, // Very small for testing
|
||||||
|
MaxBackups: 2,
|
||||||
|
LogDir: tempDir,
|
||||||
|
Filename: "rotation_test.log",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger, err := NewFileLogger(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create file logger: %v", err)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
|
||||||
|
// 写入足够的数据触发多次轮转
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
logger.Info("Message %d: %s", i, strings.Repeat("x", 50))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件存在性
|
||||||
|
logPath := filepath.Join(tempDir, "rotation_test.log")
|
||||||
|
backup1Path := filepath.Join(tempDir, "rotation_test.log.1")
|
||||||
|
backup2Path := filepath.Join(tempDir, "rotation_test.log.2")
|
||||||
|
backup3Path := filepath.Join(tempDir, "rotation_test.log.3")
|
||||||
|
|
||||||
|
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
||||||
|
t.Error("Main log file should exist")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(backup1Path); os.IsNotExist(err) {
|
||||||
|
t.Error("First backup should exist")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(backup2Path); os.IsNotExist(err) {
|
||||||
|
t.Error("Second backup should exist")
|
||||||
|
}
|
||||||
|
// 第三个备份不应该存在(MaxBackups=2)
|
||||||
|
if _, err := os.Stat(backup3Path); !os.IsNotExist(err) {
|
||||||
|
t.Error("Third backup should not exist (exceeds MaxBackups)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobalLoggerFunctions(t *testing.T) {
|
||||||
|
// 这个测试比较简单,主要确保全局函数不会panic
|
||||||
|
Debug("debug message")
|
||||||
|
Info("info message")
|
||||||
|
Warn("warn message")
|
||||||
|
Error("error message")
|
||||||
|
|
||||||
|
SetLevel(ERROR)
|
||||||
|
|
||||||
|
// 这些调用不应该panic
|
||||||
|
Debug("filtered debug")
|
||||||
|
Info("filtered info")
|
||||||
|
Error("visible error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileLoggerErrorHandling(t *testing.T) {
|
||||||
|
t.Run("invalid directory", func(t *testing.T) {
|
||||||
|
// 使用一个真正无效的路径
|
||||||
|
config := &LoggerConfig{
|
||||||
|
Level: INFO,
|
||||||
|
MaxSize: 1024,
|
||||||
|
MaxBackups: 3,
|
||||||
|
LogDir: string([]byte{0}), // 无效的路径字符
|
||||||
|
Filename: "test.log",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := NewFileLogger(config)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error when creating logger with invalid directory")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerFormatting(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := NewConsoleLogger(&buf)
|
||||||
|
logger.SetLevel(DEBUG)
|
||||||
|
|
||||||
|
// 测试时间戳格式
|
||||||
|
logger.Info("test message")
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||||
|
if len(lines) == 0 {
|
||||||
|
t.Fatal("Expected at least one log line")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查格式:[HH:MM:SS] LEVEL message
|
||||||
|
line := lines[0]
|
||||||
|
if !strings.Contains(line, "INFO test message") {
|
||||||
|
t.Errorf("Expected 'INFO test message' in output, got: %s", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查时间戳格式(简单检查)
|
||||||
|
if !strings.HasPrefix(line, "[") {
|
||||||
|
t.Error("Expected log line to start with timestamp in brackets")
|
||||||
|
}
|
||||||
|
}
|
||||||
1046
Go_Updater/main.go
Normal file
1046
Go_Updater/main.go
Normal file
File diff suppressed because it is too large
Load Diff
3
Go_Updater/utils/utils.go
Normal file
3
Go_Updater/utils/utils.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
// Package utils provides utility functions for the updater
|
||||||
193
Go_Updater/version/manager.go
Normal file
193
Go_Updater/version/manager.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"lightweight-updater/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VersionInfo represents the version information from version.json
|
||||||
|
type VersionInfo struct {
|
||||||
|
MainVersion string `json:"main_version"`
|
||||||
|
VersionInfo map[string]map[string][]string `json:"version_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedVersion represents a parsed version with major, minor, patch, and beta components
|
||||||
|
type ParsedVersion struct {
|
||||||
|
Major int
|
||||||
|
Minor int
|
||||||
|
Patch int
|
||||||
|
Beta int
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionManager handles version-related operations
|
||||||
|
type VersionManager struct {
|
||||||
|
executableDir string
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVersionManager creates a new version manager
|
||||||
|
func NewVersionManager() *VersionManager {
|
||||||
|
execPath, _ := os.Executable()
|
||||||
|
execDir := filepath.Dir(execPath)
|
||||||
|
return &VersionManager{
|
||||||
|
executableDir: execDir,
|
||||||
|
logger: logger.GetDefaultLogger(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVersionManagerWithLogger creates a new version manager with a custom logger
|
||||||
|
func NewVersionManagerWithLogger(customLogger logger.Logger) *VersionManager {
|
||||||
|
execPath, _ := os.Executable()
|
||||||
|
execDir := filepath.Dir(execPath)
|
||||||
|
return &VersionManager{
|
||||||
|
executableDir: execDir,
|
||||||
|
logger: customLogger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDefaultVersion creates a default version structure with v0.0.0
|
||||||
|
func (vm *VersionManager) createDefaultVersion() *VersionInfo {
|
||||||
|
return &VersionInfo{
|
||||||
|
MainVersion: "0.0.0.0", // Corresponds to v0.0.0
|
||||||
|
VersionInfo: make(map[string]map[string][]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadVersionFromFile loads version information from resources/version.json with fallback handling
|
||||||
|
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) {
|
||||||
|
vm.logger.Info("Version file not found at %s, will use default version", versionPath)
|
||||||
|
return vm.createDefaultVersion(), nil
|
||||||
|
}
|
||||||
|
vm.logger.Warn("Failed to read version file at %s: %v, will use default version", versionPath, err)
|
||||||
|
return vm.createDefaultVersion(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var versionInfo VersionInfo
|
||||||
|
if err := json.Unmarshal(data, &versionInfo); err != nil {
|
||||||
|
vm.logger.Warn("Failed to parse version file at %s: %v, will use default version", versionPath, err)
|
||||||
|
return vm.createDefaultVersion(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
vm.logger.Debug("Successfully loaded version information from %s", versionPath)
|
||||||
|
return &versionInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadVersionWithDefault loads version information with guaranteed fallback to default
|
||||||
|
func (vm *VersionManager) LoadVersionWithDefault() *VersionInfo {
|
||||||
|
versionInfo, err := vm.LoadVersionFromFile()
|
||||||
|
if err != nil {
|
||||||
|
// This should not happen with the updated LoadVersionFromFile, but adding as extra safety
|
||||||
|
vm.logger.Error("Unexpected error loading version file: %v, using default version", err)
|
||||||
|
return vm.createDefaultVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we have a valid version structure
|
||||||
|
if versionInfo == nil {
|
||||||
|
vm.logger.Warn("Version info is nil, using default version")
|
||||||
|
return vm.createDefaultVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
if versionInfo.MainVersion == "" {
|
||||||
|
vm.logger.Warn("Version info has empty main version, using default version")
|
||||||
|
return vm.createDefaultVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
if versionInfo.VersionInfo == nil {
|
||||||
|
vm.logger.Debug("Version info map is nil, initializing empty map")
|
||||||
|
versionInfo.VersionInfo = make(map[string]map[string][]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
return versionInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseVersion parses a version string like "4.4.1.3" into components
|
||||||
|
func ParseVersion(versionStr string) (*ParsedVersion, error) {
|
||||||
|
parts := strings.Split(versionStr, ".")
|
||||||
|
if len(parts) < 3 || len(parts) > 4 {
|
||||||
|
return nil, fmt.Errorf("invalid version format: %s", versionStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
major, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid major version: %s", parts[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
minor, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid minor version: %s", parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
patch, err := strconv.Atoi(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid patch version: %s", parts[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
beta := 0
|
||||||
|
if len(parts) == 4 {
|
||||||
|
beta, err = strconv.Atoi(parts[3])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid beta version: %s", parts[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ParsedVersion{
|
||||||
|
Major: major,
|
||||||
|
Minor: minor,
|
||||||
|
Patch: patch,
|
||||||
|
Beta: beta,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToVersionString converts a ParsedVersion back to version string format
|
||||||
|
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 converts version to display format (v4.4.0 or 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 returns the channel (stable or beta) based on version
|
||||||
|
func (pv *ParsedVersion) GetChannel() string {
|
||||||
|
if pv.Beta == 0 {
|
||||||
|
return "stable"
|
||||||
|
}
|
||||||
|
return "beta"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultChannel returns the default channel
|
||||||
|
func GetDefaultChannel() string {
|
||||||
|
return "stable"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNewer checks if this version is newer than the other version
|
||||||
|
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
|
||||||
|
}
|
||||||
366
Go_Updater/version/manager_test.go
Normal file
366
Go_Updater/version/manager_test.go
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected *ParsedVersion
|
||||||
|
hasError bool
|
||||||
|
}{
|
||||||
|
{"4.4.0.0", &ParsedVersion{4, 4, 0, 0}, false},
|
||||||
|
{"4.4.1.3", &ParsedVersion{4, 4, 1, 3}, false},
|
||||||
|
{"1.2.3", &ParsedVersion{1, 2, 3, 0}, false},
|
||||||
|
{"invalid", nil, true},
|
||||||
|
{"1.2", nil, true},
|
||||||
|
{"1.2.3.4.5", nil, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
result, err := ParseVersion(test.input)
|
||||||
|
|
||||||
|
if test.hasError {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Expected error for input %s, but got none", test.input)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error for input %s: %v", test.input, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Major != test.expected.Major ||
|
||||||
|
result.Minor != test.expected.Minor ||
|
||||||
|
result.Patch != test.expected.Patch ||
|
||||||
|
result.Beta != test.expected.Beta {
|
||||||
|
t.Errorf("For input %s, expected %+v, got %+v", test.input, test.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToDisplayVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
version *ParsedVersion
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{&ParsedVersion{4, 4, 0, 0}, "v4.4.0"},
|
||||||
|
{&ParsedVersion{4, 4, 1, 3}, "v4.4.1-beta3"},
|
||||||
|
{&ParsedVersion{1, 2, 3, 0}, "v1.2.3"},
|
||||||
|
{&ParsedVersion{1, 2, 3, 5}, "v1.2.3-beta5"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
result := test.version.ToDisplayVersion()
|
||||||
|
if result != test.expected {
|
||||||
|
t.Errorf("For version %+v, expected %s, got %s", test.version, test.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetChannel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
version *ParsedVersion
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{&ParsedVersion{4, 4, 0, 0}, "stable"},
|
||||||
|
{&ParsedVersion{4, 4, 1, 3}, "beta"},
|
||||||
|
{&ParsedVersion{1, 2, 3, 0}, "stable"},
|
||||||
|
{&ParsedVersion{1, 2, 3, 1}, "beta"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
result := test.version.GetChannel()
|
||||||
|
if result != test.expected {
|
||||||
|
t.Errorf("For version %+v, expected channel %s, got %s", test.version, test.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsNewer(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
v1 *ParsedVersion
|
||||||
|
v2 *ParsedVersion
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{&ParsedVersion{4, 4, 1, 0}, &ParsedVersion{4, 4, 0, 0}, true},
|
||||||
|
{&ParsedVersion{4, 4, 0, 0}, &ParsedVersion{4, 4, 1, 0}, false},
|
||||||
|
{&ParsedVersion{4, 4, 1, 3}, &ParsedVersion{4, 4, 1, 2}, true},
|
||||||
|
{&ParsedVersion{4, 4, 1, 2}, &ParsedVersion{4, 4, 1, 3}, false},
|
||||||
|
{&ParsedVersion{4, 4, 1, 0}, &ParsedVersion{4, 4, 1, 0}, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
result := test.v1.IsNewer(test.v2)
|
||||||
|
if result != test.expected {
|
||||||
|
t.Errorf("For %+v.IsNewer(%+v), expected %t, got %t", test.v1, test.v2, test.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionFromFile(t *testing.T) {
|
||||||
|
// Create a temporary directory
|
||||||
|
tempDir, err := os.MkdirTemp("", "version_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create resources directory
|
||||||
|
resourcesDir := filepath.Join(tempDir, "resources")
|
||||||
|
if err := os.MkdirAll(resourcesDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test version file
|
||||||
|
versionData := VersionInfo{
|
||||||
|
MainVersion: "4.4.1.3",
|
||||||
|
VersionInfo: map[string]map[string][]string{
|
||||||
|
"4.4.1.3": {
|
||||||
|
"修复BUG": {"移除崩溃弹窗机制"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(versionData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versionFile := filepath.Join(resourcesDir, "version.json")
|
||||||
|
if err := os.WriteFile(versionFile, data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create version manager with custom executable directory and logger
|
||||||
|
vm := NewVersionManager()
|
||||||
|
vm.executableDir = tempDir
|
||||||
|
|
||||||
|
// Test loading version
|
||||||
|
result, err := vm.LoadVersionFromFile()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MainVersion != "4.4.1.3" {
|
||||||
|
t.Errorf("Expected main version 4.4.1.3, got %s", result.MainVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.VersionInfo) != 1 {
|
||||||
|
t.Errorf("Expected 1 version info entry, got %d", len(result.VersionInfo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionFromFileNotFound(t *testing.T) {
|
||||||
|
// Create a temporary directory without version file
|
||||||
|
tempDir, err := os.MkdirTemp("", "version_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create version manager with custom executable directory and logger
|
||||||
|
vm := NewVersionManager()
|
||||||
|
vm.executableDir = tempDir
|
||||||
|
|
||||||
|
// Test loading version (should now return default version instead of error)
|
||||||
|
result, err := vm.LoadVersionFromFile()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error with fallback mechanism, but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return default version
|
||||||
|
if result.MainVersion != "0.0.0.0" {
|
||||||
|
t.Errorf("Expected default version 0.0.0.0, got %s", result.MainVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.VersionInfo == nil {
|
||||||
|
t.Error("Expected initialized VersionInfo map, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionWithDefault(t *testing.T) {
|
||||||
|
// Create a temporary directory
|
||||||
|
tempDir, err := os.MkdirTemp("", "version_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create version manager with custom executable directory
|
||||||
|
vm := NewVersionManager()
|
||||||
|
vm.executableDir = tempDir
|
||||||
|
|
||||||
|
// Test loading version with default (no file exists)
|
||||||
|
result := vm.LoadVersionWithDefault()
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Expected non-nil result from LoadVersionWithDefault")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MainVersion != "0.0.0.0" {
|
||||||
|
t.Errorf("Expected default version 0.0.0.0, got %s", result.MainVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.VersionInfo == nil {
|
||||||
|
t.Error("Expected initialized VersionInfo map, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionWithDefaultValidFile(t *testing.T) {
|
||||||
|
// Create a temporary directory
|
||||||
|
tempDir, err := os.MkdirTemp("", "version_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create resources directory
|
||||||
|
resourcesDir := filepath.Join(tempDir, "resources")
|
||||||
|
if err := os.MkdirAll(resourcesDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test version file
|
||||||
|
versionData := VersionInfo{
|
||||||
|
MainVersion: "4.4.1.3",
|
||||||
|
VersionInfo: map[string]map[string][]string{
|
||||||
|
"4.4.1.3": {
|
||||||
|
"修复BUG": {"移除崩溃弹窗机制"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(versionData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versionFile := filepath.Join(resourcesDir, "version.json")
|
||||||
|
if err := os.WriteFile(versionFile, data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create version manager with custom executable directory
|
||||||
|
vm := NewVersionManager()
|
||||||
|
vm.executableDir = tempDir
|
||||||
|
|
||||||
|
// Test loading version with default (valid file exists)
|
||||||
|
result := vm.LoadVersionWithDefault()
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Expected non-nil result from LoadVersionWithDefault")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MainVersion != "4.4.1.3" {
|
||||||
|
t.Errorf("Expected version 4.4.1.3, got %s", result.MainVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.VersionInfo) != 1 {
|
||||||
|
t.Errorf("Expected 1 version info entry, got %d", len(result.VersionInfo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionFromFileCorrupted(t *testing.T) {
|
||||||
|
// Create a temporary directory
|
||||||
|
tempDir, err := os.MkdirTemp("", "version_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create resources directory
|
||||||
|
resourcesDir := filepath.Join(tempDir, "resources")
|
||||||
|
if err := os.MkdirAll(resourcesDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create corrupted version file
|
||||||
|
versionFile := filepath.Join(resourcesDir, "version.json")
|
||||||
|
if err := os.WriteFile(versionFile, []byte("invalid json content"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create version manager with custom executable directory
|
||||||
|
vm := NewVersionManager()
|
||||||
|
vm.executableDir = tempDir
|
||||||
|
|
||||||
|
// Test loading version (should return default version for corrupted file)
|
||||||
|
result, err := vm.LoadVersionFromFile()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error with fallback mechanism for corrupted file, but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return default version
|
||||||
|
if result.MainVersion != "0.0.0.0" {
|
||||||
|
t.Errorf("Expected default version 0.0.0.0 for corrupted file, got %s", result.MainVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.VersionInfo == nil {
|
||||||
|
t.Error("Expected initialized VersionInfo map for corrupted file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionWithDefaultCorrupted(t *testing.T) {
|
||||||
|
// Create a temporary directory
|
||||||
|
tempDir, err := os.MkdirTemp("", "version_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create resources directory
|
||||||
|
resourcesDir := filepath.Join(tempDir, "resources")
|
||||||
|
if err := os.MkdirAll(resourcesDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create corrupted version file
|
||||||
|
versionFile := filepath.Join(resourcesDir, "version.json")
|
||||||
|
if err := os.WriteFile(versionFile, []byte("invalid json content"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create version manager with custom executable directory
|
||||||
|
vm := NewVersionManager()
|
||||||
|
vm.executableDir = tempDir
|
||||||
|
|
||||||
|
// Test loading version with default (corrupted file)
|
||||||
|
result := vm.LoadVersionWithDefault()
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Expected non-nil result from LoadVersionWithDefault for corrupted file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MainVersion != "0.0.0.0" {
|
||||||
|
t.Errorf("Expected default version 0.0.0.0 for corrupted file, got %s", result.MainVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.VersionInfo == nil {
|
||||||
|
t.Error("Expected initialized VersionInfo map for corrupted file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateDefaultVersion(t *testing.T) {
|
||||||
|
vm := NewVersionManager()
|
||||||
|
|
||||||
|
result := vm.createDefaultVersion()
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Expected non-nil result from createDefaultVersion")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MainVersion != "0.0.0.0" {
|
||||||
|
t.Errorf("Expected default version 0.0.0.0, got %s", result.MainVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.VersionInfo == nil {
|
||||||
|
t.Error("Expected initialized VersionInfo map, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.VersionInfo) != 0 {
|
||||||
|
t.Errorf("Expected empty VersionInfo map, got %d entries", len(result.VersionInfo))
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Go_Updater/version/version.go
Normal file
41
Go_Updater/version/version.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Version is the current version of the application
|
||||||
|
Version = "1.0.0"
|
||||||
|
|
||||||
|
// BuildTime is set during build time
|
||||||
|
BuildTime = "unknown"
|
||||||
|
|
||||||
|
// GitCommit is set during build time
|
||||||
|
GitCommit = "unknown"
|
||||||
|
|
||||||
|
// GoVersion is the Go version used to build
|
||||||
|
GoVersion = runtime.Version()
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetVersionInfo returns formatted version information
|
||||||
|
func GetVersionInfo() string {
|
||||||
|
return fmt.Sprintf("Version: %s\nBuild Time: %s\nGit Commit: %s\nGo Version: %s",
|
||||||
|
Version, BuildTime, GitCommit, GoVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetShortVersion returns just the version number
|
||||||
|
func GetShortVersion() string {
|
||||||
|
return Version
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBuildInfo returns build-specific information
|
||||||
|
func GetBuildInfo() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"version": Version,
|
||||||
|
"build_time": BuildTime,
|
||||||
|
"git_commit": GitCommit,
|
||||||
|
"go_version": GoVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user