feat: add auto-update functionality
This commit is contained in:
@@ -9,8 +9,10 @@ import (
|
||||
"time"
|
||||
|
||||
"daily-timer/internal/models"
|
||||
"daily-timer/internal/services/updater"
|
||||
"daily-timer/internal/storage"
|
||||
"daily-timer/internal/timer"
|
||||
"daily-timer/internal/version"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
@@ -21,12 +23,14 @@ type App struct {
|
||||
timer *timer.Timer
|
||||
session *models.MeetingSession
|
||||
currentLogs map[uint]*models.ParticipantLog
|
||||
updater *updater.Updater
|
||||
}
|
||||
|
||||
func New(store *storage.Storage) *App {
|
||||
return &App{
|
||||
store: store,
|
||||
currentLogs: make(map[uint]*models.ParticipantLog),
|
||||
updater: updater.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,3 +483,28 @@ func (a *App) GetSoundsDir() string {
|
||||
_ = os.MkdirAll(soundsDir, 0755)
|
||||
return soundsDir
|
||||
}
|
||||
|
||||
// Updates
|
||||
|
||||
func (a *App) GetVersion() string {
|
||||
return version.Version
|
||||
}
|
||||
|
||||
func (a *App) CheckForUpdates() (*updater.UpdateInfo, error) {
|
||||
return a.updater.CheckForUpdates()
|
||||
}
|
||||
|
||||
func (a *App) DownloadAndInstallUpdate() error {
|
||||
err := a.updater.DownloadAndInstall(func(progress float64) {
|
||||
runtime.EventsEmit(a.ctx, "update:progress", progress)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "update:complete", true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) RestartApp() error {
|
||||
return a.updater.RestartApp()
|
||||
}
|
||||
|
||||
338
internal/services/updater/updater.go
Normal file
338
internal/services/updater/updater.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"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"`
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
latestVersion := strings.TrimPrefix(release.TagName, "v")
|
||||
currentVersion := strings.TrimPrefix(version.Version, "v")
|
||||
|
||||
info := &UpdateInfo{
|
||||
Available: isNewerVersion(latestVersion, currentVersion),
|
||||
CurrentVersion: version.Version,
|
||||
LatestVersion: release.TagName,
|
||||
ReleaseNotes: release.Body,
|
||||
DownloadURL: downloadAsset.BrowserDownloadURL,
|
||||
DownloadSize: downloadAsset.Size,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Launch new app
|
||||
cmd := exec.Command("open", destPath)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to launch updated app: %w", err)
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
10
internal/version/version.go
Normal file
10
internal/version/version.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package version
|
||||
|
||||
// Version is set at build time via ldflags
|
||||
var Version = "dev"
|
||||
|
||||
// GitCommit is set at build time via ldflags
|
||||
var GitCommit = "unknown"
|
||||
|
||||
// BuildTime is set at build time via ldflags
|
||||
var BuildTime = "unknown"
|
||||
Reference in New Issue
Block a user