From 957182611dcfc7d2bccb4ea15117039045989f16 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Tue, 16 Jun 2026 19:52:23 +0200 Subject: [PATCH] feat: check for available updates --- frontend/src/App.tsx | 45 +++++++++- frontend/src/components/SettingsModal.tsx | 5 ++ frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + frontend/wailsjs/go/models.ts | 18 ++++ release.ps1 | 100 ---------------------- update.go | 97 +++++++++++++++++++++ 7 files changed, 169 insertions(+), 102 deletions(-) delete mode 100644 release.ps1 create mode 100644 update.go diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 393b069..03f1af5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,7 @@ import { GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO, UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, LookupCallsign, GetStationSettings, GetListsSettings, - GetStartupStatus, + GetStartupStatus, CheckForUpdate, WorkedBefore, SetCompactMode, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, @@ -34,7 +34,7 @@ import { } from '../wailsjs/go/main/App'; import { Combobox } from '@/components/ui/combobox'; import { applyAwardRefs } from '@/lib/awardRefs'; -import { EventsOn } from '../wailsjs/runtime/runtime'; +import { EventsOn, BrowserOpenURL } from '../wailsjs/runtime/runtime'; import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models'; import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; @@ -678,6 +678,15 @@ export default function App() { const [settingsSection, setSettingsSection] = useState(undefined); const [showDeleteAll, setShowDeleteAll] = useState(false); const [showAbout, setShowAbout] = useState(false); + const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null); + // Check GitHub for a newer release once at startup (unless disabled in + // General); surface a toast if one exists. Best effort — silent on failure. + useEffect(() => { + if (localStorage.getItem('opslog.checkUpdates') === '0') return; + CheckForUpdate().then((u: any) => { + if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? '') }); + }).catch(() => {}); + }, []); const [deletingAll, setDeletingAll] = useState(false); const [ctyRefreshing, setCtyRefreshing] = useState(false); const [refsDownloading, setRefsDownloading] = useState(false); @@ -2508,6 +2517,31 @@ export default function App() { { setShowFirstRun(false); loadStation(); refresh(); }} /> )} + {updateInfo && ( +
+
+
+
+

OpsLog v{updateInfo.latest} available

+

You're on v{APP_VERSION}.

+
+ + +
+
+ +
+
+ )} + {showAbout && (
setShowAbout(false)}>
e.stopPropagation()}> @@ -2517,6 +2551,13 @@ export default function App() {

Ham-radio logbook

version {APP_VERSION}

+ {updateInfo ? ( + + ) : ( +

You're up to date

+ )}

Developed by {APP_AUTHOR}

diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 0cfeba7..9e15d49 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -593,6 +593,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { // General behaviour prefs (mirrored to the DB so they travel with data/). const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0'); + const [checkUpdates, setCheckUpdates] = useState(() => localStorage.getItem('opslog.checkUpdates') !== '0'); const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1'); const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); @@ -3285,6 +3286,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} /> Look up the callsign only after leaving the field (not while typing) +
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index e3c72cc..5fb6745 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -37,6 +37,8 @@ export function BrowseExecutable():Promise; export function BulkUpdateQSL(arg1:Array,arg2:main.QSLBulkUpdate):Promise; +export function CheckForUpdate():Promise; + export function ClearLookupCache():Promise; export function ClusterSpotStatuses(arg1:Array):Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 37129ac..e5fce3a 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -46,6 +46,10 @@ export function BulkUpdateQSL(arg1, arg2) { return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2); } +export function CheckForUpdate() { + return window['go']['main']['App']['CheckForUpdate'](); +} + export function ClearLookupCache() { return window['go']['main']['App']['ClearLookupCache'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index bf37463..b5d0021 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1591,6 +1591,24 @@ export namespace main { this.moving = source["moving"]; } } + export class UpdateInfo { + current: string; + latest: string; + available: boolean; + url: string; + + static createFrom(source: any = {}) { + return new UpdateInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.current = source["current"]; + this.latest = source["latest"]; + this.available = source["available"]; + this.url = source["url"]; + } + } export class WKMacro { label: string; text: string; diff --git a/release.ps1 b/release.ps1 deleted file mode 100644 index 4ba090b..0000000 --- a/release.ps1 +++ /dev/null @@ -1,100 +0,0 @@ -# OpsLog release script — source → Gitea (origin), exe → Gitea + GitHub releases. -# Mirrors the DXHunter workflow, adapted for the Wails build and OpsLog's version -# files. Run from the repo root in PowerShell. - -# Force UTF-8 throughout — prevents git log em-dashes / accents from corrupting the API body -$OutputEncoding = [System.Text.Encoding]::UTF8 -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 - -# ── Config ──────────────────────────────────────────────────────────────────── -$GitHubRepo = "GregTroar/OpsLog" # GitHub repo that hosts the public exe (adjust if different) -$ExePath = "build/bin/OpsLog.exe" # Wails build output -$Wails = Join-Path $HOME "go\bin\wails.exe" # the v2.11 wails (not the global one) -if (-not (Test-Path $Wails)) { $Wails = "wails" } # fall back to PATH - -# Parse token, host, and repo path from the Gitea remote URL (origin) -$remoteUrl = git remote get-url origin -if ($remoteUrl -match 'https://([^@]+)@([^/]+)/(.+?)\.git') { - $token = $Matches[1] - $gitHost = $Matches[2] - $repo = $Matches[3] -} else { - Write-Host "Cannot parse Gitea remote URL (expected https://@/.git)." -ForegroundColor Red; exit 1 -} - -git add . -$msg = Read-Host "Commit message" -if ($msg) { git commit -m $msg } - -$ver = Read-Host "Version (ex: 0.2)" -if (-not $ver) { Write-Host "Aborted." -ForegroundColor Red; exit 1 } - -# ── Bump the version in the single sources of truth ───────────────────────────── -$lastMsg = git log -1 --pretty=format:"%s" -if ($lastMsg -ne "chore: release v$ver") { - # Frontend (UI header + About popup) - (Get-Content frontend/src/version.ts) -replace "APP_VERSION = '.*'", "APP_VERSION = '$ver'" | Set-Content frontend/src/version.ts -Encoding utf8 - # Backend (telemetry heartbeat version) - (Get-Content telemetry.go) -replace 'appVersion = ".*"', "appVersion = `"$ver`"" | Set-Content telemetry.go -Encoding utf8 - git add frontend/src/version.ts telemetry.go - git commit -m "chore: release v$ver" -} else { - Write-Host "Release commit already exists, skipping version bump..." -ForegroundColor Yellow -} -git tag "v$ver" 2>$null -if ($LASTEXITCODE -ne 0) { Write-Host "Tag v$ver already exists locally, continuing..." -ForegroundColor Yellow } - -# Push source to Gitea (origin) — source code stays on Gitea only -git push -if ($LASTEXITCODE -ne 0) { Write-Host "git push failed!" -ForegroundColor Red; exit 1 } -git push --tags -if ($LASTEXITCODE -ne 0) { Write-Host "git push --tags failed!" -ForegroundColor Red; exit 1 } - -# ── Release notes from commits since the previous tag ─────────────────────────── -$prevTag = git describe --tags --abbrev=0 "v$ver^" 2>$null -$changelog = if ($prevTag) { - git log "$prevTag..v$ver" --pretty=format:"- %s" --no-merges -} else { - git log "v$ver" --pretty=format:"- %s" --no-merges -} -$body = "## Changelog`n`n$($changelog -join "`n")" -Write-Host "`nRelease notes:`n$body`n" -ForegroundColor DarkGray - -# ── Build the Windows exe (Wails compiles frontend + Go) ───────────────────────── -Write-Host "Building OpsLog.exe v$ver ..." -ForegroundColor Cyan -& $Wails build -if ($LASTEXITCODE -ne 0) { Write-Host "Build failed!" -ForegroundColor Red; exit 1 } -if (-not (Test-Path $ExePath)) { Write-Host "Built exe not found at $ExePath" -ForegroundColor Red; exit 1 } - -# ── Gitea release — get existing or create new, then upload the exe ────────────── -$api = "https://$gitHost/api/v1/repos/$repo" -$headers = @{ Authorization = "token $token"; 'Content-Type' = 'application/json' } -try { - $release = Invoke-RestMethod "$api/releases/tags/v$ver" -Method GET -Headers $headers - Write-Host "Gitea: found existing release for v$ver (id=$($release.id)), uploading exe..." -ForegroundColor Yellow -} catch { - $payloadBytes = [System.Text.Encoding]::UTF8.GetBytes((@{ tag_name = "v$ver"; target_commitish = "main"; name = "OpsLog v$ver"; body = $body } | ConvertTo-Json)) - try { - $release = Invoke-RestMethod "$api/releases" -Method POST -Headers $headers -Body $payloadBytes - } catch { - Write-Host "Gitea release creation failed: $_" -ForegroundColor Red; exit 1 - } -} -$uploadUri = "https://$gitHost/api/v1/repos/$repo/releases/$($release.id)/assets?name=OpsLog.exe" -curl.exe -s -H "Authorization: token $token" -F "attachment=@$ExePath" $uploadUri | Out-Null -Write-Host "Gitea: release v$ver published." -ForegroundColor Green - -# ── GitHub release (requires gh CLI: https://cli.github.com) ──────────────────── -if (Get-Command gh -ErrorAction SilentlyContinue) { - Write-Host "Creating GitHub release v$ver ..." -ForegroundColor Cyan - $notesFile = [System.IO.Path]::GetTempFileName() - $body | Out-File -FilePath $notesFile -Encoding utf8 - gh release create "v$ver" $ExePath ` - --repo $GitHubRepo ` - --title "OpsLog v$ver" ` - --notes-file $notesFile - Remove-Item $notesFile - Write-Host "GitHub: release v$ver published." -ForegroundColor Green -} else { - Write-Host "gh CLI not found - skipping GitHub release (install from https://cli.github.com, then: gh auth login)" -ForegroundColor Yellow -} diff --git a/update.go b/update.go new file mode 100644 index 0000000..9818965 --- /dev/null +++ b/update.go @@ -0,0 +1,97 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "hamlog/internal/applog" +) + +// updateCheckURL is the GitHub Releases "latest" endpoint for the public OpsLog +// build (the exe lives there; source stays on Gitea). Adjust the repo if needed. +const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest" + +// UpdateInfo is the result of the startup version check. +type UpdateInfo struct { + Current string `json:"current"` // this build's version (appVersion) + Latest string `json:"latest"` // newest published release, "" if unknown + Available bool `json:"available"` // Latest > Current + URL string `json:"url"` // release page to open +} + +// CheckForUpdate asks GitHub for the latest release and compares it to this +// build. Best effort — on any failure it reports "no update" so the app never +// nags about a check it couldn't complete. +func (a *App) CheckForUpdate() UpdateInfo { + out := UpdateInfo{Current: appVersion} + client := &http.Client{Timeout: 8 * time.Second} + req, err := http.NewRequest(http.MethodGet, updateCheckURL, nil) + if err != nil { + return out + } + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := client.Do(req) + if err != nil { + applog.Printf("update: check failed: %v", err) + return out + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return out // no release yet (404) or rate-limited — treat as up to date + } + var r struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + } + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return out + } + out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v") + out.URL = r.HTMLURL + out.Available = versionLess(appVersion, out.Latest) + if out.Available { + applog.Printf("update: newer version available — current=%s latest=%s", appVersion, out.Latest) + } + return out +} + +// versionLess reports whether version a is older than b. Compares dot-separated +// numeric parts ("0.9" < "0.10" < "1.0"); non-numeric junk in a part counts as 0. +func versionLess(a, b string) bool { + pa := strings.Split(a, ".") + pb := strings.Split(b, ".") + n := len(pa) + if len(pb) > n { + n = len(pb) + } + for i := 0; i < n; i++ { + ai, bi := 0, 0 + if i < len(pa) { + ai = leadingInt(pa[i]) + } + if i < len(pb) { + bi = leadingInt(pb[i]) + } + if ai != bi { + return ai < bi + } + } + return false +} + +// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none. +func leadingInt(s string) int { + s = strings.TrimSpace(s) + end := 0 + for end < len(s) && s[end] >= '0' && s[end] <= '9' { + end++ + } + if end == 0 { + return 0 + } + n, _ := strconv.Atoi(s[:end]) + return n +}