feat: check for available updates
This commit is contained in:
+43
-2
@@ -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<string | undefined>(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() {
|
||||
<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 && (
|
||||
<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()}>
|
||||
@@ -2517,6 +2551,13 @@ export default function App() {
|
||||
</div>
|
||||
<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>
|
||||
{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">
|
||||
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
|
||||
</p>
|
||||
|
||||
@@ -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) {
|
||||
<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>
|
||||
</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 />
|
||||
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
|
||||
Vendored
+2
@@ -37,6 +37,8 @@ export function BrowseExecutable():Promise<string>;
|
||||
|
||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||
|
||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||
|
||||
export function ClearLookupCache():Promise<void>;
|
||||
|
||||
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
-100
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user