feat: check for available updates

This commit is contained in:
2026-06-16 19:52:23 +02:00
parent 69d0780bac
commit 957182611d
7 changed files with 169 additions and 102 deletions
+43 -2
View File
@@ -10,7 +10,7 @@ import {
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO, GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings, LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus, GetStartupStatus, CheckForUpdate,
WorkedBefore, WorkedBefore,
SetCompactMode, SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
@@ -34,7 +34,7 @@ import {
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs'; 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 { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -678,6 +678,15 @@ export default function App() {
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined); const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
const [showDeleteAll, setShowDeleteAll] = useState(false); const [showDeleteAll, setShowDeleteAll] = useState(false);
const [showAbout, setShowAbout] = 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 [deletingAll, setDeletingAll] = useState(false);
const [ctyRefreshing, setCtyRefreshing] = useState(false); const [ctyRefreshing, setCtyRefreshing] = useState(false);
const [refsDownloading, setRefsDownloading] = useState(false); const [refsDownloading, setRefsDownloading] = useState(false);
@@ -2508,6 +2517,31 @@ export default function App() {
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} /> <FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
)} )}
{updateInfo && (
<div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
<div className="flex items-start gap-2">
<div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">OpsLog v{updateInfo.latest} available</p>
<p className="text-xs text-muted-foreground">You're on v{APP_VERSION}.</p>
<div className="mt-2 flex items-center gap-2">
<button
onClick={() => { if (updateInfo.url) BrowserOpenURL(updateInfo.url); setUpdateInfo(null); }}
className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
Download
</button>
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">
Later
</button>
</div>
</div>
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
<X className="size-4" />
</button>
</div>
</div>
)}
{showAbout && ( {showAbout && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowAbout(false)}> <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowAbout(false)}>
<div className="w-full max-w-sm rounded-xl border border-border bg-card shadow-2xl p-6 text-center animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}> <div className="w-full max-w-sm rounded-xl border border-border bg-card shadow-2xl p-6 text-center animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
@@ -2517,6 +2551,13 @@ export default function App() {
</div> </div>
<p className="text-sm text-muted-foreground">Ham-radio logbook</p> <p className="text-sm text-muted-foreground">Ham-radio logbook</p>
<p className="mt-3 font-mono text-sm">version <span className="font-semibold text-foreground">{APP_VERSION}</span></p> <p className="mt-3 font-mono text-sm">version <span className="font-semibold text-foreground">{APP_VERSION}</span></p>
{updateInfo ? (
<button onClick={() => updateInfo.url && BrowserOpenURL(updateInfo.url)} className="mt-1 text-xs text-primary underline hover:opacity-80">
Update available: v{updateInfo.latest} — download
</button>
) : (
<p className="mt-1 text-[11px] text-emerald-600">You're up to date</p>
)}
<p className="mt-3 text-sm"> <p className="mt-3 text-sm">
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span> Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
</p> </p>
@@ -593,6 +593,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
// General behaviour prefs (mirrored to the DB so they travel with data/). // General behaviour prefs (mirrored to the DB so they travel with data/).
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0'); 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 [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1'); const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
@@ -3285,6 +3286,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} /> <Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span> Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span>
</label> </label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
Check for updates at startup <span className="text-xs text-muted-foreground">(notifies when a newer OpsLog is published)</span>
</label>
<TelemetryToggle /> <TelemetryToggle />
<div className="border-t border-border/60 pt-4 space-y-2"> <div className="border-t border-border/60 pt-4 space-y-2">
+2
View File
@@ -37,6 +37,8 @@ export function BrowseExecutable():Promise<string>;
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>; export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function CheckForUpdate():Promise<main.UpdateInfo>;
export function ClearLookupCache():Promise<void>; export function ClearLookupCache():Promise<void>;
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>; export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
+4
View File
@@ -46,6 +46,10 @@ export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2); return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
} }
export function CheckForUpdate() {
return window['go']['main']['App']['CheckForUpdate']();
}
export function ClearLookupCache() { export function ClearLookupCache() {
return window['go']['main']['App']['ClearLookupCache'](); return window['go']['main']['App']['ClearLookupCache']();
} }
+18
View File
@@ -1591,6 +1591,24 @@ export namespace main {
this.moving = source["moving"]; 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 { export class WKMacro {
label: string; label: string;
text: string; text: string;
-100
View File
@@ -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://<token>@<host>/<repo>.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
}
+97
View File
@@ -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
}