feat: ON AIR status-bar badge + auto-updater UI (10-min check, progress)
ON AIR badge (bottom status bar, shown when live-status publishing is on): a blinking red light + "On air" when a QSO was logged in the last 5 min, dim "Offline" otherwise. Seeded at launch from LiveLastQSOAgeSec so it's right immediately, flips online on qso:logged, expires via a 10s tick. Auto-updater UI: the update check now also runs every 10 minutes, and the update notification downloads and installs in-app — a live progress bar (update:progress) then OpsLog restarts on the new build. Falls back to opening the release page when there's no auto-download asset or on error (retry). Regenerated bindings for DownloadAndApplyUpdate / LiveLastQSOAgeSec.
This commit is contained in:
+100
-22
@@ -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>
|
||||
<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>
|
||||
<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={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>
|
||||
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
{!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 {
|
||||
|
||||
Reference in New Issue
Block a user