feat: implement server self-upgrade functionality

- Added update service to fetch the latest server release from GitHub.
- Implemented server upgrade scheduling and execution logic.
- Created platform-specific restart mechanisms for Unix and Windows.
- Introduced API endpoints for fetching the latest release and triggering upgrades.
- Developed UI components for version upgrade modal, including loading and error states.
- Updated documentation to reflect new upgrade features and instructions.
- Refactored settings types to streamline interface definitions.
This commit is contained in:
ryan
2026-03-11 22:05:42 +08:00
parent c0ec718563
commit 6140ed1718
22 changed files with 3332 additions and 2392 deletions
+326
View File
@@ -0,0 +1,326 @@
package service
import (
"atsflare/common"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
const latestReleaseURL = "https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest"
var updateHTTPClient = &http.Client{
Timeout: 30 * time.Second,
}
var serverUpgradeState struct {
sync.Mutex
inProgress bool
}
type LatestServerRelease struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
CurrentVersion string `json:"current_version"`
HasUpdate bool `json:"has_update"`
UpgradeSupported bool `json:"upgrade_supported"`
InProgress bool `json:"in_progress"`
}
type githubReleaseResponse struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
Assets []githubAsset `json:"assets"`
}
type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
type preparedServerUpgrade struct {
release *LatestServerRelease
downloadURL string
execPath string
}
func GetLatestServerRelease(ctx context.Context) (*LatestServerRelease, error) {
release, err := fetchLatestRelease(ctx)
if err != nil {
return nil, err
}
return buildLatestServerReleaseView(release), nil
}
func ScheduleServerUpgrade() (*LatestServerRelease, error) {
serverUpgradeState.Lock()
if serverUpgradeState.inProgress {
serverUpgradeState.Unlock()
return nil, fmt.Errorf("服务升级已在执行中,请稍后再试")
}
prepared, err := prepareServerUpgrade(context.Background())
if err != nil {
serverUpgradeState.Unlock()
return nil, err
}
serverUpgradeState.inProgress = true
serverUpgradeState.Unlock()
prepared.release.InProgress = true
go func(task *preparedServerUpgrade) {
time.Sleep(500 * time.Millisecond)
if err := executeServerUpgrade(task); err != nil {
log.Printf("server self-update failed: %v", err)
serverUpgradeState.Lock()
serverUpgradeState.inProgress = false
serverUpgradeState.Unlock()
}
}(prepared)
return prepared.release, nil
}
func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil)
if err != nil {
return nil, fmt.Errorf("创建更新请求失败")
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "ATSFlare-Server")
resp, err := updateHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取最新版本失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
}
var release githubReleaseResponse
if err = json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("解析最新版本信息失败")
}
return &release, nil
}
func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerRelease {
currentVersion := strings.TrimSpace(common.Version)
hasUpdate := false
if release != nil {
hasUpdate = isVersionNewer(currentVersion, release.TagName)
}
serverUpgradeState.Lock()
inProgress := serverUpgradeState.inProgress
serverUpgradeState.Unlock()
view := &LatestServerRelease{
CurrentVersion: currentVersion,
HasUpdate: hasUpdate,
UpgradeSupported: runtime.GOOS != "windows",
InProgress: inProgress,
}
if release != nil {
view.TagName = release.TagName
view.Body = release.Body
view.HTMLURL = release.HTMLURL
view.PublishedAt = release.PublishedAt
}
return view
}
func prepareServerUpgrade(ctx context.Context) (*preparedServerUpgrade, error) {
release, err := fetchLatestRelease(ctx)
if err != nil {
return nil, err
}
view := buildLatestServerReleaseView(release)
if !view.HasUpdate {
return nil, fmt.Errorf("当前已是最新版本")
}
if !view.UpgradeSupported {
return nil, fmt.Errorf("当前平台暂不支持自动升级")
}
assetName := serverAssetName(runtime.GOOS, runtime.GOARCH)
var downloadURL string
for _, asset := range release.Assets {
if asset.Name == assetName {
downloadURL = asset.BrowserDownloadURL
break
}
}
if downloadURL == "" {
return nil, fmt.Errorf("最新版本缺少当前平台的服务端二进制: %s", assetName)
}
execPath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("获取当前服务程序路径失败: %v", err)
}
if err = verifyExecutableDirectoryWritable(execPath); err != nil {
return nil, err
}
return &preparedServerUpgrade{
release: view,
downloadURL: downloadURL,
execPath: execPath,
}, nil
}
func verifyExecutableDirectoryWritable(execPath string) error {
dir := filepath.Dir(execPath)
tempFile, err := os.CreateTemp(dir, "atsflare-server-upgrade-check-*")
if err != nil {
return fmt.Errorf("当前服务二进制目录不可写,无法升级: %v", err)
}
tempPath := tempFile.Name()
if closeErr := tempFile.Close(); closeErr != nil {
_ = os.Remove(tempPath)
return fmt.Errorf("校验服务升级目录失败: %v", closeErr)
}
if err = os.Remove(tempPath); err != nil {
return fmt.Errorf("清理升级校验文件失败: %v", err)
}
return nil
}
func executeServerUpgrade(task *preparedServerUpgrade) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, task.downloadURL, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/octet-stream")
req.Header.Set("User-Agent", "ATSFlare-Server")
resp, err := updateHTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载服务端升级包失败: %s", resp.Status)
}
tmpPath := task.execPath + ".update"
tmpFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return err
}
if _, err = io.Copy(tmpFile, resp.Body); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
return err
}
if err = tmpFile.Close(); err != nil {
_ = os.Remove(tmpPath)
return err
}
common.SysLog("server self-update starting: from=" + strings.TrimSpace(common.Version) + " to=" + task.release.TagName)
return replaceAndRestartServer(task.execPath, tmpPath)
}
func serverAssetName(goos string, goarch string) string {
name := fmt.Sprintf("atsflare-server-%s-%s", goos, goarch)
if goos == "windows" {
return name + ".exe"
}
return name
}
func isVersionNewer(current string, latest string) bool {
currentParts := parseVersionParts(current)
latestParts := parseVersionParts(latest)
maxLen := len(currentParts)
if len(latestParts) > maxLen {
maxLen = len(latestParts)
}
for i := 0; i < maxLen; i++ {
currentPart := 0
latestPart := 0
if i < len(currentParts) {
currentPart = currentParts[i]
}
if i < len(latestParts) {
latestPart = latestParts[i]
}
if latestPart > currentPart {
return true
}
if latestPart < currentPart {
return false
}
}
return false
}
func parseVersionParts(version string) []int {
normalized := strings.TrimSpace(strings.TrimPrefix(version, "v"))
if normalized == "" || normalized == "dev" {
return nil
}
segments := strings.Split(normalized, ".")
parts := make([]int, 0, len(segments))
for _, segment := range segments {
segment = strings.TrimSpace(segment)
if segment == "" {
parts = append(parts, 0)
continue
}
numeric := strings.Builder{}
for _, r := range segment {
if r < '0' || r > '9' {
break
}
numeric.WriteRune(r)
}
if numeric.Len() == 0 {
parts = append(parts, 0)
continue
}
value, err := strconv.Atoi(numeric.String())
if err != nil {
parts = append(parts, 0)
continue
}
parts = append(parts, value)
}
return parts
}
func UpdateHTTPClientForTest() *http.Client {
return updateHTTPClient
}
func SetUpdateHTTPClientForTest(client *http.Client) {
updateHTTPClient = client
}
@@ -0,0 +1,27 @@
//go:build !windows
package service
import (
"fmt"
"os"
"syscall"
)
func replaceAndRestartServer(execPath string, tmpPath string) error {
backupPath := execPath + ".bak"
_ = os.Remove(backupPath)
if err := os.Rename(execPath, backupPath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("备份当前服务端二进制失败: %w", err)
}
if err := os.Rename(tmpPath, execPath); err != nil {
_ = os.Rename(backupPath, execPath)
return fmt.Errorf("替换服务端二进制失败: %w", err)
}
_ = os.Remove(backupPath)
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
return fmt.Errorf("重启服务失败: %w", err)
}
return fmt.Errorf("unreachable after exec")
}
@@ -0,0 +1,55 @@
//go:build windows
package service
import (
"fmt"
"os"
"os/exec"
"strings"
)
func replaceAndRestartServer(execPath string, tmpPath string) error {
backupPath := execPath + ".bak"
scriptPath := execPath + ".update.cmd"
script := fmt.Sprintf(`@echo off
setlocal
:waitloop
move /Y "%s" "%s" >nul 2>nul
if errorlevel 1 (
ping 127.0.0.1 -n 2 >nul
goto waitloop
)
move /Y "%s" "%s" >nul 2>nul
if errorlevel 1 exit /b 1
start "" %s
del /Q "%s" >nul 2>nul
del /Q "%%~f0" >nul 2>nul
`, execPath, backupPath, tmpPath, execPath, buildWindowsCommandLine(execPath, os.Args[1:]), backupPath)
if err := os.WriteFile(scriptPath, []byte(script), 0o700); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("写入升级重启脚本失败: %w", err)
}
cmd := exec.Command("cmd", "/C", "start", "", scriptPath)
if err := cmd.Start(); err != nil {
_ = os.Remove(scriptPath)
_ = os.Remove(tmpPath)
return fmt.Errorf("调度升级重启失败: %w", err)
}
os.Exit(0)
return nil
}
func buildWindowsCommandLine(execPath string, args []string) string {
parts := []string{quoteWindowsArg(execPath)}
for _, arg := range args {
parts = append(parts, quoteWindowsArg(arg))
}
return strings.Join(parts, " ")
}
func quoteWindowsArg(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}
+65
View File
@@ -0,0 +1,65 @@
package service
import (
"atsflare/common"
"testing"
)
func TestIsVersionNewer(t *testing.T) {
testCases := []struct {
name string
current string
latest string
expected bool
}{
{name: "newer patch", current: "v1.2.3", latest: "v1.2.4", expected: true},
{name: "same version", current: "v1.2.3", latest: "v1.2.3", expected: false},
{name: "older remote", current: "v1.3.0", latest: "v1.2.9", expected: false},
{name: "double digit segment", current: "v1.9.9", latest: "v1.10.0", expected: true},
{name: "dev build", current: "dev", latest: "v0.4.0", expected: true},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
actual := isVersionNewer(testCase.current, testCase.latest)
if actual != testCase.expected {
t.Fatalf("unexpected compare result: current=%s latest=%s actual=%v expected=%v", testCase.current, testCase.latest, actual, testCase.expected)
}
})
}
}
func TestBuildLatestServerReleaseView(t *testing.T) {
originalVersion := common.Version
common.Version = "v0.4.0"
t.Cleanup(func() {
common.Version = originalVersion
serverUpgradeState.Lock()
serverUpgradeState.inProgress = false
serverUpgradeState.Unlock()
})
serverUpgradeState.Lock()
serverUpgradeState.inProgress = true
serverUpgradeState.Unlock()
view := buildLatestServerReleaseView(&githubReleaseResponse{
TagName: "v0.5.0",
Body: "release notes",
HTMLURL: "https://github.com/Rain-kl/ATSFlare/releases/tag/v0.5.0",
PublishedAt: "2026-03-11T00:00:00Z",
})
if view.CurrentVersion != "v0.4.0" {
t.Fatalf("unexpected current version: %s", view.CurrentVersion)
}
if !view.HasUpdate {
t.Fatal("expected has_update to be true")
}
if !view.InProgress {
t.Fatal("expected in_progress to reflect upgrade state")
}
if view.TagName != "v0.5.0" {
t.Fatalf("unexpected tag name: %s", view.TagName)
}
}