Files
daily-timer/internal/services/updater/updater.go
2026-02-10 16:00:06 +03:00

429 lines
9.9 KiB
Go

package updater
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"daily-timer/internal/version"
)
const (
GiteaAPIURL = "https://git.movida.biz/api/v1/repos/bell/daily-timer/releases/latest"
AppName = "Daily Timer.app"
InstallPath = "/Applications"
DownloadPrefix = "Daily-Timer-"
)
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
Assets []Asset `json:"assets"`
}
type Asset struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
}
type UpdateInfo struct {
Available bool `json:"available"`
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
ReleaseNotes string `json:"releaseNotes"`
DownloadURL string `json:"downloadURL"`
DownloadSize int64 `json:"downloadSize"`
IsRebuild bool `json:"isRebuild"`
}
type Updater struct {
latestRelease *Release
downloadURL string
}
func New() *Updater {
return &Updater{}
}
func (u *Updater) CheckForUpdates() (*UpdateInfo, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(GiteaAPIURL)
if err != nil {
return nil, fmt.Errorf("failed to check for updates: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var release Release
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to parse release info: %w", err)
}
u.latestRelease = &release
// Find macOS arm64 asset
var downloadAsset *Asset
for i := range release.Assets {
if strings.Contains(release.Assets[i].Name, "macos-arm64") && strings.HasSuffix(release.Assets[i].Name, ".zip") {
downloadAsset = &release.Assets[i]
break
}
}
if downloadAsset == nil {
return nil, fmt.Errorf("no macOS arm64 asset found")
}
u.downloadURL = downloadAsset.BrowserDownloadURL
// Find checksum asset
var checksumAsset *Asset
for i := range release.Assets {
if strings.Contains(release.Assets[i].Name, "macos-arm64") && strings.HasSuffix(release.Assets[i].Name, ".sha256") {
checksumAsset = &release.Assets[i]
break
}
}
latestVersion := strings.TrimPrefix(release.TagName, "v")
currentVersion := strings.TrimPrefix(version.Version, "v")
isNewer := isNewerVersion(latestVersion, currentVersion)
isRebuild := false
// Check if same version but different checksum (rebuild)
if !isNewer && checksumAsset != nil {
remoteChecksum, err := u.downloadChecksum(checksumAsset.BrowserDownloadURL)
if err == nil {
localChecksum, err := u.calculateBinaryChecksum()
if err == nil && remoteChecksum != localChecksum {
isRebuild = true
}
}
}
info := &UpdateInfo{
Available: isNewer || isRebuild,
CurrentVersion: version.Version,
LatestVersion: release.TagName,
ReleaseNotes: release.Body,
DownloadURL: downloadAsset.BrowserDownloadURL,
DownloadSize: downloadAsset.Size,
IsRebuild: isRebuild,
}
return info, nil
}
func (u *Updater) DownloadAndInstall(progressCallback func(float64)) error {
if u.downloadURL == "" {
return fmt.Errorf("no download URL available, run CheckForUpdates first")
}
// Create temp directory
tmpDir, err := os.MkdirTemp("", "daily-timer-update-")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
zipPath := filepath.Join(tmpDir, "update.zip")
// Download ZIP
if err := u.downloadFile(zipPath, progressCallback); err != nil {
return fmt.Errorf("failed to download update: %w", err)
}
// Extract ZIP
extractPath := filepath.Join(tmpDir, "extracted")
if err := u.extractZip(zipPath, extractPath); err != nil {
return fmt.Errorf("failed to extract update: %w", err)
}
// Find .app in extracted folder
appPath := filepath.Join(extractPath, AppName)
if _, err := os.Stat(appPath); os.IsNotExist(err) {
return fmt.Errorf("app not found in update package")
}
// Remove old app from /Applications
destPath := filepath.Join(InstallPath, AppName)
if _, err := os.Stat(destPath); err == nil {
if err := os.RemoveAll(destPath); err != nil {
return fmt.Errorf("failed to remove old app: %w", err)
}
}
// Copy new app to /Applications
if err := u.copyDir(appPath, destPath); err != nil {
return fmt.Errorf("failed to install update: %w", err)
}
// Remove quarantine attribute
cmd := exec.Command("xattr", "-cr", destPath)
if err := cmd.Run(); err != nil {
fmt.Printf("Warning: failed to remove quarantine: %v\n", err)
}
return nil
}
func (u *Updater) RestartApp() error {
destPath := filepath.Join(InstallPath, AppName)
// Use shell to launch new app after this process exits
// The sleep ensures the current app has time to exit
script := fmt.Sprintf(`sleep 1 && open "%s"`, destPath)
cmd := exec.Command("sh", "-c", script)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
// Detach the process so it continues after we exit
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to launch updated app: %w", err)
}
// Release the process so it doesn't become a zombie
go func() {
_ = cmd.Wait()
}()
// Give the shell time to start
time.Sleep(100 * time.Millisecond)
// Exit current app
os.Exit(0)
return nil
}
func (u *Updater) downloadFile(destPath string, progressCallback func(float64)) error {
resp, err := http.Get(u.downloadURL)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
out, err := os.Create(destPath)
if err != nil {
return err
}
defer func() { _ = out.Close() }()
totalSize := resp.ContentLength
var downloaded int64 = 0
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return writeErr
}
downloaded += int64(n)
if progressCallback != nil && totalSize > 0 {
progressCallback(float64(downloaded) / float64(totalSize))
}
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return nil
}
func (u *Updater) extractZip(zipPath, destPath string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer func() { _ = r.Close() }()
if err := os.MkdirAll(destPath, 0755); err != nil {
return err
}
for _, f := range r.File {
fpath := filepath.Join(destPath, f.Name)
// Prevent ZipSlip vulnerability
if !strings.HasPrefix(fpath, filepath.Clean(destPath)+string(os.PathSeparator)) {
return fmt.Errorf("illegal file path: %s", fpath)
}
if f.FileInfo().IsDir() {
_ = os.MkdirAll(fpath, f.Mode())
continue
}
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
return err
}
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
_ = outFile.Close()
return err
}
_, err = io.Copy(outFile, rc)
_ = outFile.Close()
_ = rc.Close()
if err != nil {
return err
}
}
return nil
}
func (u *Updater) copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
dstPath := filepath.Join(dst, relPath)
if info.IsDir() {
return os.MkdirAll(dstPath, info.Mode())
}
// Handle symlinks
if info.Mode()&os.ModeSymlink != 0 {
link, err := os.Readlink(path)
if err != nil {
return err
}
return os.Symlink(link, dstPath)
}
srcFile, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = srcFile.Close() }()
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
if err != nil {
return err
}
defer func() { _ = dstFile.Close() }()
_, err = io.Copy(dstFile, srcFile)
return err
})
}
// downloadChecksum fetches the remote SHA256 checksum file
func (u *Updater) downloadChecksum(url string) (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to download checksum: status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}
// calculateBinaryChecksum calculates SHA256 of the current running binary
func (u *Updater) calculateBinaryChecksum() (string, error) {
execPath, err := os.Executable()
if err != nil {
return "", err
}
// Resolve symlinks to get actual binary path
execPath, err = filepath.EvalSymlinks(execPath)
if err != nil {
return "", err
}
file, err := os.Open(execPath)
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
// isNewerVersion compares semver-like versions (e.g., "0.1.0" vs "0.2.0")
func isNewerVersion(latest, current string) bool {
if current == "dev" || current == "unknown" {
return true
}
latest = strings.TrimPrefix(latest, "v")
current = strings.TrimPrefix(current, "v")
// Handle dirty versions (e.g., "0.1.0-dirty" or "0.1.0-3-g1234567")
if strings.Contains(current, "-") {
parts := strings.Split(current, "-")
current = parts[0]
}
latestParts := strings.Split(latest, ".")
currentParts := strings.Split(current, ".")
for i := 0; i < len(latestParts) && i < len(currentParts); i++ {
var l, c int
_, _ = fmt.Sscanf(latestParts[i], "%d", &l)
_, _ = fmt.Sscanf(currentParts[i], "%d", &c)
if l > c {
return true
} else if l < c {
return false
}
}
return len(latestParts) > len(currentParts)
}