429 lines
9.9 KiB
Go
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)
|
|
}
|