Compare commits
3
Commits
4ab4f70349
...
9156acea5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9156acea5f | ||
|
|
5d0906f00e | ||
|
|
901e967b53 |
@@ -505,6 +505,7 @@ type App struct {
|
||||
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
||||
liveBand string
|
||||
liveMode string
|
||||
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
|
||||
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
||||
@@ -1871,6 +1872,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
||||
if err == nil {
|
||||
q.ID = id
|
||||
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
|
||||
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
||||
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
|
||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||
a.saveQSORecording(&q)
|
||||
@@ -9287,6 +9289,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
return 0, fmt.Errorf("insert qso: %w", err)
|
||||
}
|
||||
q.ID = id
|
||||
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
||||
a.saveQSORecording(&q)
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
|
||||
+94
-16
@@ -13,7 +13,7 @@ import {
|
||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||
GetStartupStatus, CheckForUpdate,
|
||||
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate,
|
||||
WorkedBefore,
|
||||
SetCompactMode,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||
GetAwardDefs,
|
||||
GetUIPref,
|
||||
ReportLiveActivity,
|
||||
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
|
||||
AwardRefsForQSOs,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
@@ -1091,6 +1091,30 @@ export default function App() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator
|
||||
// publishes — online (blinking) when a QSO was logged in the last 5 min, else
|
||||
// offline. Only shown when live-status publishing is enabled (Settings→General).
|
||||
const [liveStatusOn, setLiveStatusOn] = useState(false);
|
||||
const [onAir, setOnAir] = useState(false);
|
||||
const lastQsoAtRef = useRef(0);
|
||||
useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]);
|
||||
useEffect(() => {
|
||||
const LIVE_WINDOW = 5 * 60 * 1000; // 5 min, matches the backend
|
||||
const evalOnAir = () => setOnAir(liveStatusOn && lastQsoAtRef.current > 0 && (Date.now() - lastQsoAtRef.current) < LIVE_WINDOW);
|
||||
const off = EventsOn('qso:logged', () => { lastQsoAtRef.current = Date.now(); evalOnAir(); });
|
||||
// Seed from the DB at launch so a QSO logged just before starting OpsLog still
|
||||
// counts (otherwise the badge showed offline until the next contact).
|
||||
LiveLastQSOAgeSec().then((sec: number) => {
|
||||
if (typeof sec === 'number' && sec >= 0) {
|
||||
const at = Date.now() - sec * 1000;
|
||||
if (at > lastQsoAtRef.current) lastQsoAtRef.current = at;
|
||||
evalOnAir();
|
||||
}
|
||||
}).catch(() => {});
|
||||
evalOnAir();
|
||||
const id = window.setInterval(evalOnAir, 10 * 1000); // flip to offline within ~10s of the window elapsing
|
||||
return () => { off(); window.clearInterval(id); };
|
||||
}, [liveStatusOn]);
|
||||
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
||||
@@ -1111,15 +1135,39 @@ export default function App() {
|
||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [showDuplicates, setShowDuplicates] = 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.
|
||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [updateProgress, setUpdateProgress] = useState(0);
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
// Check GitHub for a newer release at startup AND every 10 minutes (unless
|
||||
// disabled in General). 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 ?? '') });
|
||||
const check = () => CheckForUpdate().then((u: any) => {
|
||||
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
||||
}).catch(() => {});
|
||||
check();
|
||||
const id = window.setInterval(check, 10 * 60 * 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
// Live download progress for the in-app updater.
|
||||
useEffect(() => {
|
||||
const off = EventsOn('update:progress', (p: any) => setUpdateProgress(Math.max(0, Math.min(100, Number(p) || 0))));
|
||||
return () => { off(); };
|
||||
}, []);
|
||||
// startUpdate downloads the new build in-app and (on success) swaps + relaunches.
|
||||
// Falls back to opening the release page when the release has no auto-download asset.
|
||||
const startUpdate = useCallback(async () => {
|
||||
if (!updateInfo) return;
|
||||
if (!updateInfo.downloadUrl) { if (updateInfo.url) BrowserOpenURL(updateInfo.url); return; }
|
||||
setUpdating(true); setUpdateProgress(0); setUpdateError('');
|
||||
try {
|
||||
await DownloadAndApplyUpdate(updateInfo.downloadUrl); // app quits + relaunches on success
|
||||
} catch (e: any) {
|
||||
setUpdateError(String(e?.message ?? e));
|
||||
setUpdating(false);
|
||||
}
|
||||
}, [updateInfo]);
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
||||
const [refsDownloading, setRefsDownloading] = useState(false);
|
||||
@@ -3940,22 +3988,42 @@ export default function App() {
|
||||
<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>
|
||||
<p className="text-sm font-semibold">{t('upd.available', { v: updateInfo.latest })}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('upd.current', { v: APP_VERSION })}</p>
|
||||
|
||||
{updating ? (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
|
||||
<span>{updateProgress >= 100 ? t('upd.installing') : t('upd.downloading')}</span>
|
||||
<span className="tabular-nums">{updateProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full bg-primary transition-[width] duration-150" style={{ width: `${updateProgress}%` }} />
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">{t('upd.restartNote')}</p>
|
||||
</div>
|
||||
) : updateError ? (
|
||||
<div className="mt-2">
|
||||
<p className="text-[11px] text-destructive break-words">{updateError}</p>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">{t('upd.retry')}</button>
|
||||
{updateInfo.url && <button onClick={() => BrowserOpenURL(updateInfo.url)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.browser')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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 onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
||||
{updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
|
||||
</button>
|
||||
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!updating && (
|
||||
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -4880,6 +4948,16 @@ export default function App() {
|
||||
disabled={!rotatorHeading.enabled}
|
||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||
/>
|
||||
{liveStatusOn && (
|
||||
<div
|
||||
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
|
||||
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
|
||||
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
|
||||
>
|
||||
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
|
||||
{onAir ? t('live.onAir') : t('live.offline')}
|
||||
</div>
|
||||
)}
|
||||
{/* Toasts / errors: the status bar's free space is far wider than the
|
||||
header band they used to sit in. Still one line (the bar is 28px),
|
||||
but CLICK opens the full text wrapped — long messages (a TQSL or
|
||||
|
||||
@@ -14,6 +14,14 @@ type Dict = Record<string, string>;
|
||||
const en: Dict = {
|
||||
// Menu bar
|
||||
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
|
||||
'live.onAir': 'On air', 'live.offline': 'Offline',
|
||||
'live.onAirTip': 'On air — a QSO was logged in the last 5 minutes (published to the live status)',
|
||||
'live.offlineTip': 'Offline — no QSO logged in the last 5 minutes',
|
||||
'upd.available': 'OpsLog v{v} available', 'upd.current': "You're on v{v}.",
|
||||
'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later',
|
||||
'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…',
|
||||
'upd.restartNote': 'OpsLog will restart on the new version.',
|
||||
'upd.retry': 'Retry', 'upd.browser': 'Open page',
|
||||
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
|
||||
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
||||
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
||||
@@ -324,6 +332,9 @@ const en: Dict = {
|
||||
|
||||
const fr: Dict = {
|
||||
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
|
||||
'live.onAir': 'On air', 'live.offline': 'Hors ligne',
|
||||
'live.onAirTip': "On air — un QSO a été loggé dans les 5 dernières minutes (publié dans le statut live)",
|
||||
'live.offlineTip': 'Hors ligne — aucun QSO loggé depuis 5 minutes',
|
||||
'rate.title': 'Rythme QSO (QSO/heure) — projeté sur les 10 / 60 dernières minutes',
|
||||
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
|
||||
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
||||
|
||||
Vendored
+4
@@ -146,6 +146,8 @@ export function DismissAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadAllReferenceLists():Promise<string>;
|
||||
|
||||
export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||
@@ -546,6 +548,8 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
||||
|
||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||
|
||||
export function LiveLastQSOAgeSec():Promise<number>;
|
||||
|
||||
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||
|
||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
@@ -250,6 +250,10 @@ export function DownloadAllReferenceLists() {
|
||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||
}
|
||||
|
||||
export function DownloadAndApplyUpdate(arg1) {
|
||||
return window['go']['main']['App']['DownloadAndApplyUpdate'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadClublogCty() {
|
||||
return window['go']['main']['App']['DownloadClublogCty']();
|
||||
}
|
||||
@@ -1050,6 +1054,10 @@ export function ListUDPIntegrations() {
|
||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||
}
|
||||
|
||||
export function LiveLastQSOAgeSec() {
|
||||
return window['go']['main']['App']['LiveLastQSOAgeSec']();
|
||||
}
|
||||
|
||||
export function LoTWUserInfo(arg1) {
|
||||
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||
}
|
||||
|
||||
@@ -2763,6 +2763,7 @@ export namespace main {
|
||||
latest: string;
|
||||
available: boolean;
|
||||
url: string;
|
||||
download_url: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UpdateInfo(source);
|
||||
@@ -2774,6 +2775,7 @@ export namespace main {
|
||||
this.latest = source["latest"];
|
||||
this.available = source["available"];
|
||||
this.url = source["url"];
|
||||
this.download_url = source["download_url"];
|
||||
}
|
||||
}
|
||||
export class WKMacro {
|
||||
|
||||
@@ -1895,6 +1895,32 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// LastQSOTime returns the start time of the most recently LOGGED QSO for an
|
||||
// operator (highest id wins) — used to seed the live "on air" state at launch so an
|
||||
// operator who just worked someone before (re)starting OpsLog shows online right
|
||||
// away instead of waiting for their next QSO. Empty operator matches every QSO.
|
||||
func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
defer rows.Close()
|
||||
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||
for rows.Next() {
|
||||
var oper, dateStr sql.NullString
|
||||
if err := rows.Scan(&oper, &dateStr); err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
|
||||
continue
|
||||
}
|
||||
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// RecentRate counts QSOs whose start time falls within each trailing window from
|
||||
// `now` — the live "QSO rate" meter shown in the header. When operator is non-empty
|
||||
// (multi-op on a shared logbook) only that operator's QSOs are counted, so each op
|
||||
|
||||
+85
-7
@@ -19,6 +19,23 @@ import (
|
||||
|
||||
const keyLiveStatusEnabled = "livestatus.enabled"
|
||||
|
||||
// liveOnlineWindow is how long after the last logged contact an operator still
|
||||
// counts as "on air". Leaving the log open without working anyone flips them
|
||||
// offline once this elapses; logging a new QSO flips them back online.
|
||||
const liveOnlineWindow = 5 * time.Minute
|
||||
|
||||
// noteLiveQSO records that this operator just logged a new contact and pushes the
|
||||
// live status right away, so they flip back to online the instant they work
|
||||
// someone. Called from the logging paths (manual entry, UDP auto-log).
|
||||
func (a *App) noteLiveQSO() {
|
||||
a.liveActMu.Lock()
|
||||
a.liveLastQSOAt = time.Now()
|
||||
a.liveActMu.Unlock()
|
||||
if a.liveStatusActive() {
|
||||
go a.publishLiveStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
||||
func (a *App) GetLiveStatusEnabled() bool {
|
||||
if a.settings == nil {
|
||||
@@ -50,11 +67,44 @@ func (a *App) SetLiveStatusEnabled(on bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who
|
||||
// worked someone shortly before (re)starting OpsLog is shown "on air" right away
|
||||
// instead of offline until their next QSO.
|
||||
func (a *App) seedLiveLastQSO() {
|
||||
if a.qso == nil {
|
||||
return
|
||||
}
|
||||
op, _ := a.liveStatusOperator()
|
||||
if op == "" {
|
||||
return
|
||||
}
|
||||
if t, ok := a.qso.LastQSOTime(a.ctx, op); ok {
|
||||
a.liveActMu.Lock()
|
||||
if a.liveLastQSOAt.IsZero() {
|
||||
a.liveLastQSOAt = t
|
||||
}
|
||||
a.liveActMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if
|
||||
// none is known — the UI uses it to seed the "on air" badge at launch.
|
||||
func (a *App) LiveLastQSOAgeSec() int {
|
||||
a.liveActMu.Lock()
|
||||
last := a.liveLastQSOAt
|
||||
a.liveActMu.Unlock()
|
||||
if last.IsZero() {
|
||||
return -1
|
||||
}
|
||||
return int(time.Since(last).Seconds())
|
||||
}
|
||||
|
||||
// liveStatusLoop heartbeats the current activity while enabled. Started once at
|
||||
// startup; cheap no-op when disabled or not on MySQL.
|
||||
func (a *App) liveStatusLoop() {
|
||||
defer func() { _ = recover() }() // never crash the app from here
|
||||
applog.Printf("livestatus: loop started")
|
||||
a.seedLiveLastQSO() // so online/offline is right at launch, not only after the next QSO
|
||||
a.publishLiveStatus() // attempt immediately, don't wait the first tick
|
||||
t := time.NewTicker(15 * time.Second)
|
||||
defer t.Stop()
|
||||
@@ -132,34 +182,62 @@ func (a *App) publishLiveStatus() {
|
||||
if mode == "" {
|
||||
mode = a.liveMode
|
||||
}
|
||||
lastQSO := a.liveLastQSOAt
|
||||
a.liveActMu.Unlock()
|
||||
// Online = a new contact was logged within the window. An operator who leaves
|
||||
// the log open but stops working shows offline after `liveOnlineWindow`; the
|
||||
// next QSO flips them back on. never-logged (zero time) → offline.
|
||||
online := 0
|
||||
var lastQSOArg any
|
||||
if !lastQSO.IsZero() {
|
||||
lastQSOArg = lastQSO.UTC()
|
||||
if time.Since(lastQSO) < liveOnlineWindow {
|
||||
online = 1
|
||||
}
|
||||
}
|
||||
if err := a.ensureLiveStatusTable(); err != nil {
|
||||
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
||||
return
|
||||
}
|
||||
_, err := a.logDb.ExecContext(a.ctx,
|
||||
"INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+
|
||||
"VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, last_qso_at, updated_at) "+
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
||||
"band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()",
|
||||
op, station, freqHz, band, mode)
|
||||
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), "+
|
||||
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
|
||||
op, station, freqHz, band, mode, online, lastQSOArg)
|
||||
if err != nil {
|
||||
applog.Printf("livestatus: INSERT failed: %v", err)
|
||||
return
|
||||
}
|
||||
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s", op, station, freqHz, band, mode)
|
||||
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s online=%d", op, station, freqHz, band, mode, online)
|
||||
}
|
||||
|
||||
func (a *App) ensureLiveStatusTable() error {
|
||||
_, err := a.logDb.ExecContext(a.ctx,
|
||||
if _, err := a.logDb.ExecContext(a.ctx,
|
||||
"CREATE TABLE IF NOT EXISTS live_status ("+
|
||||
"operator VARCHAR(32) PRIMARY KEY, "+
|
||||
"station VARCHAR(32), "+
|
||||
"freq_hz BIGINT, "+
|
||||
"band VARCHAR(16), "+
|
||||
"mode VARCHAR(16), "+
|
||||
"updated_at DATETIME)")
|
||||
"online TINYINT DEFAULT 0, "+
|
||||
"last_qso_at DATETIME NULL, "+
|
||||
"updated_at DATETIME)"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Add the online/last_qso_at columns to a table created by an older build.
|
||||
// MySQL has no portable "ADD COLUMN IF NOT EXISTS", so just run the ALTERs and
|
||||
// ignore the duplicate-column error when they already exist.
|
||||
for _, ddl := range []string{
|
||||
"ALTER TABLE live_status ADD COLUMN online TINYINT DEFAULT 0",
|
||||
"ALTER TABLE live_status ADD COLUMN last_qso_at DATETIME NULL",
|
||||
} {
|
||||
if _, err := a.logDb.ExecContext(a.ctx, ddl); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
|
||||
applog.Printf("livestatus: %q: %v", ddl, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearLiveStatus removes this operator's row (on disable / shutdown).
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"embed"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
@@ -35,15 +36,53 @@ func profileArg(args []string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// hasFlag reports whether flag is present in args.
|
||||
func hasFlag(args []string, flag string) bool {
|
||||
for _, a := range args {
|
||||
if a == flag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// acquireInstance grabs the single-instance mutex. On a normal launch it's a plain
|
||||
// try (fail → another OpsLog is running, so exit). On a --post-update relaunch the
|
||||
// previous instance may still be shutting down and holding the mutex, so retry for
|
||||
// a few seconds until it frees.
|
||||
func acquireInstance(postUpdate bool) bool {
|
||||
if acquireSingleInstance() {
|
||||
return true
|
||||
}
|
||||
if !postUpdate {
|
||||
return false
|
||||
}
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if acquireSingleInstance() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Single-instance guard: if OpsLog is already running, focus that window and
|
||||
// exit instead of spawning a duplicate. A second process would open its own
|
||||
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
|
||||
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
|
||||
// its own" when a windowless zombie instance was left running.
|
||||
if !acquireSingleInstance() {
|
||||
// A --post-update relaunch (from the auto-updater) may start while the previous
|
||||
// instance is still exiting and holding the single-instance mutex — wait for it
|
||||
// to free instead of bailing out. Then clear the old exe it left behind.
|
||||
postUpdate := hasFlag(os.Args[1:], "--post-update")
|
||||
if !acquireInstance(postUpdate) {
|
||||
return
|
||||
}
|
||||
if postUpdate {
|
||||
cleanupOldUpdateBinary()
|
||||
}
|
||||
|
||||
// Create an instance of the app structure
|
||||
app := NewApp()
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
@@ -14,12 +22,13 @@ import (
|
||||
// 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.
|
||||
// UpdateInfo is the result of the 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
|
||||
URL string `json:"url"` // release page to open (manual fallback)
|
||||
DownloadURL string `json:"download_url"` // the .exe/.zip asset to auto-download, "" if none
|
||||
}
|
||||
|
||||
// CheckForUpdate asks GitHub for the latest release and compares it to this
|
||||
@@ -45,6 +54,10 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
||||
var r struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||
return out
|
||||
@@ -52,8 +65,25 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
||||
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
||||
out.URL = r.HTMLURL
|
||||
out.Available = versionLess(appVersion, out.Latest)
|
||||
// Pick the auto-download asset: a bare Windows .exe (portable build) first,
|
||||
// else a .zip we can unpack. The frontend hands this straight to
|
||||
// DownloadAndApplyUpdate for a one-click in-app update.
|
||||
for _, as := range r.Assets {
|
||||
if strings.HasSuffix(strings.ToLower(as.Name), ".exe") {
|
||||
out.DownloadURL = as.URL
|
||||
break
|
||||
}
|
||||
}
|
||||
if out.DownloadURL == "" {
|
||||
for _, as := range r.Assets {
|
||||
if strings.HasSuffix(strings.ToLower(as.Name), ".zip") {
|
||||
out.DownloadURL = as.URL
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.Available {
|
||||
applog.Printf("update: newer version available — current=%s latest=%s", appVersion, out.Latest)
|
||||
applog.Printf("update: newer version available — current=%s latest=%s asset=%q", appVersion, out.Latest, out.DownloadURL)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -82,6 +112,164 @@ func versionLess(a, b string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// DownloadAndApplyUpdate downloads the new build, swaps it in for the running exe
|
||||
// and relaunches — the fully in-app update. Progress is emitted on "update:progress"
|
||||
// (0-100) so the UI can show a bar. On success it never returns normally: it starts
|
||||
// the new process and quits this one.
|
||||
func (a *App) DownloadAndApplyUpdate(url string) error {
|
||||
if strings.TrimSpace(url) == "" {
|
||||
return fmt.Errorf("no download URL")
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("locate executable: %w", err)
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
|
||||
// Download to a temp file next to the exe (same volume, so the rename-swap is
|
||||
// atomic and can't fail across drives).
|
||||
tmp := filepath.Join(dir, ".opslog-update.download")
|
||||
_ = os.Remove(tmp)
|
||||
if err := a.downloadWithProgress(url, tmp); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("download: %w", err)
|
||||
}
|
||||
|
||||
// The asset is either the bare exe or a zip holding it. Resolve to the new exe.
|
||||
newExe := tmp
|
||||
if strings.HasSuffix(strings.ToLower(url), ".zip") {
|
||||
extracted, xerr := extractExeFromZip(tmp, dir)
|
||||
_ = os.Remove(tmp)
|
||||
if xerr != nil {
|
||||
return fmt.Errorf("unpack: %w", xerr)
|
||||
}
|
||||
newExe = extracted
|
||||
}
|
||||
|
||||
// Swap: rename the running exe out of the way (Windows allows renaming a
|
||||
// running image), move the new one into its place, then relaunch. Roll back if
|
||||
// the second rename fails so we never end up with no exe.
|
||||
oldExe := exe + ".old"
|
||||
_ = os.Remove(oldExe)
|
||||
if err := os.Rename(exe, oldExe); err != nil {
|
||||
_ = os.Remove(newExe)
|
||||
return fmt.Errorf("stage current exe: %w", err)
|
||||
}
|
||||
if err := os.Rename(newExe, exe); err != nil {
|
||||
_ = os.Rename(oldExe, exe) // roll back
|
||||
return fmt.Errorf("install new exe: %w", err)
|
||||
}
|
||||
applog.Printf("update: installed new exe, relaunching")
|
||||
|
||||
// Relaunch with a flag so the fresh instance waits for THIS one to exit and
|
||||
// free the single-instance mutex instead of bailing out immediately.
|
||||
cmd := exec.Command(exe, "--post-update")
|
||||
cmd.Dir = dir
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("relaunch: %w", err)
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.Quit(a.ctx)
|
||||
} else {
|
||||
os.Exit(0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadWithProgress streams url into dest, emitting "update:progress" (0-100).
|
||||
func (a *App) downloadWithProgress(url, dest string) error {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
total := resp.ContentLength
|
||||
var read int64
|
||||
last := -1
|
||||
buf := make([]byte, 64*1024)
|
||||
emit := func(pct int) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "update:progress", pct)
|
||||
}
|
||||
}
|
||||
emit(0)
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := f.Write(buf[:n]); werr != nil {
|
||||
return werr
|
||||
}
|
||||
read += int64(n)
|
||||
if total > 0 {
|
||||
if pct := int(read * 100 / total); pct != last {
|
||||
last = pct
|
||||
emit(pct)
|
||||
}
|
||||
}
|
||||
}
|
||||
if rerr == io.EOF {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
}
|
||||
emit(100)
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractExeFromZip unpacks the first *.exe found in the zip into dir and returns
|
||||
// its path.
|
||||
func extractExeFromZip(zipPath, dir string) (string, error) {
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer zr.Close()
|
||||
for _, zf := range zr.File {
|
||||
if !strings.HasSuffix(strings.ToLower(zf.Name), ".exe") {
|
||||
continue
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := filepath.Join(dir, ".opslog-update.exe")
|
||||
f, err := os.Create(out)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return "", err
|
||||
}
|
||||
_, cerr := io.Copy(f, rc)
|
||||
rc.Close()
|
||||
f.Close()
|
||||
if cerr != nil {
|
||||
return "", cerr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return "", fmt.Errorf("no .exe inside the archive")
|
||||
}
|
||||
|
||||
// cleanupOldUpdateBinary removes the previous exe left behind by a self-update
|
||||
// (exe + ".old"). Called at startup after a --post-update relaunch. Best-effort:
|
||||
// the file may still be briefly locked, in which case the next launch gets it.
|
||||
func cleanupOldUpdateBinary() {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
_ = os.Remove(exe + ".old")
|
||||
}
|
||||
}
|
||||
|
||||
// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
|
||||
func leadingInt(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
Reference in New Issue
Block a user