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) }