feat: add checksum-based update detection

This commit is contained in:
Mikhail Kiselev
2026-02-10 15:53:26 +03:00
parent 75dc03b0fd
commit 2b86eb9d20
4 changed files with 87 additions and 4 deletions

View File

@@ -64,8 +64,10 @@ release: lint
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true @xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
@rm -rf dist && mkdir -p dist @rm -rf dist && mkdir -p dist
cd build/bin && zip -r "../../dist/Daily-Timer-$(VERSION)-macos-arm64.zip" "Daily Timer.app" cd build/bin && zip -r "../../dist/Daily-Timer-$(VERSION)-macos-arm64.zip" "Daily Timer.app"
@shasum -a 256 "build/bin/Daily Timer.app/Contents/MacOS/daily-timer" | awk '{print $$1}' > "dist/Daily-Timer-$(VERSION)-macos-arm64.sha256"
@echo "Release package: dist/Daily-Timer-$(VERSION)-macos-arm64.zip" @echo "Release package: dist/Daily-Timer-$(VERSION)-macos-arm64.zip"
@ls -lh dist/*.zip @echo "Checksum: $$(cat dist/Daily-Timer-$(VERSION)-macos-arm64.sha256)"
@ls -lh dist/*
# Release for both architectures # Release for both architectures
release-all: lint release-all: lint
@@ -89,7 +91,7 @@ release-upload:
-d '{"tag_name": "$(VERSION)", "name": "$(VERSION)", "body": "Release $(VERSION)"}' \ -d '{"tag_name": "$(VERSION)", "name": "$(VERSION)", "body": "Release $(VERSION)"}' \
| jq -r '.id'); \ | jq -r '.id'); \
echo "Created release ID: $$RELEASE_ID"; \ echo "Created release ID: $$RELEASE_ID"; \
for file in dist/*.zip; do \ for file in dist/*; do \
filename=$$(basename "$$file"); \ filename=$$(basename "$$file"); \
echo "Uploading $$filename..."; \ echo "Uploading $$filename..."; \
curl -s -X POST \ curl -s -X POST \

View File

@@ -309,7 +309,11 @@
</div> </div>
{:else if updateInfo?.available} {:else if updateInfo?.available}
<div class="update-status available"> <div class="update-status available">
{$t('updates.updateAvailable')}: <strong>{updateInfo.latestVersion}</strong> {#if updateInfo.isRebuild}
{$t('updates.rebuildAvailable')}: <strong>{updateInfo.latestVersion}</strong>
{:else}
{$t('updates.updateAvailable')}: <strong>{updateInfo.latestVersion}</strong>
{/if}
</div> </div>
<button class="update-btn primary" on:click={downloadAndInstall}> <button class="update-btn primary" on:click={downloadAndInstall}>
{$t('updates.downloadAndInstall')} {$t('updates.downloadAndInstall')}

View File

@@ -114,6 +114,7 @@ export const translations = {
currentVersion: 'Текущая версия', currentVersion: 'Текущая версия',
checkingForUpdates: 'Проверка обновлений...', checkingForUpdates: 'Проверка обновлений...',
updateAvailable: 'Доступно обновление', updateAvailable: 'Доступно обновление',
rebuildAvailable: 'Доступна пересборка',
upToDate: 'У вас последняя версия', upToDate: 'У вас последняя версия',
downloadAndInstall: 'Скачать и установить', downloadAndInstall: 'Скачать и установить',
downloading: 'Загрузка...', downloading: 'Загрузка...',
@@ -281,6 +282,7 @@ export const translations = {
currentVersion: 'Current version', currentVersion: 'Current version',
checkingForUpdates: 'Checking for updates...', checkingForUpdates: 'Checking for updates...',
updateAvailable: 'Update available', updateAvailable: 'Update available',
rebuildAvailable: 'Rebuild available',
upToDate: 'You have the latest version', upToDate: 'You have the latest version',
downloadAndInstall: 'Download and install', downloadAndInstall: 'Download and install',
downloading: 'Downloading...', downloading: 'Downloading...',

View File

@@ -2,6 +2,8 @@ package updater
import ( import (
"archive/zip" "archive/zip"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -43,6 +45,7 @@ type UpdateInfo struct {
ReleaseNotes string `json:"releaseNotes"` ReleaseNotes string `json:"releaseNotes"`
DownloadURL string `json:"downloadURL"` DownloadURL string `json:"downloadURL"`
DownloadSize int64 `json:"downloadSize"` DownloadSize int64 `json:"downloadSize"`
IsRebuild bool `json:"isRebuild"`
} }
type Updater struct { type Updater struct {
@@ -89,16 +92,40 @@ func (u *Updater) CheckForUpdates() (*UpdateInfo, error) {
u.downloadURL = downloadAsset.BrowserDownloadURL 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") latestVersion := strings.TrimPrefix(release.TagName, "v")
currentVersion := strings.TrimPrefix(version.Version, "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{ info := &UpdateInfo{
Available: isNewerVersion(latestVersion, currentVersion), Available: isNewer || isRebuild,
CurrentVersion: version.Version, CurrentVersion: version.Version,
LatestVersion: release.TagName, LatestVersion: release.TagName,
ReleaseNotes: release.Body, ReleaseNotes: release.Body,
DownloadURL: downloadAsset.BrowserDownloadURL, DownloadURL: downloadAsset.BrowserDownloadURL,
DownloadSize: downloadAsset.Size, DownloadSize: downloadAsset.Size,
IsRebuild: isRebuild,
} }
return info, nil return info, nil
@@ -304,6 +331,54 @@ func (u *Updater) copyDir(src, dst string) error {
}) })
} }
// 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") // isNewerVersion compares semver-like versions (e.g., "0.1.0" vs "0.2.0")
func isNewerVersion(latest, current string) bool { func isNewerVersion(latest, current string) bool {
if current == "dev" || current == "unknown" { if current == "dev" || current == "unknown" {