diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4cf062f..c0ce4b8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() {
OpsLog v{updateInfo.latest} available
-You're on v{APP_VERSION}.
-{t('upd.available', { v: updateInfo.latest })}
+{t('upd.current', { v: APP_VERSION })}
+ + {updating ? ( +{t('upd.restartNote')}
+{updateError}
+