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 ? ( +
+
+ {updateProgress >= 100 ? t('upd.installing') : t('upd.downloading')} + {updateProgress}% +
+
+
+
+

{t('upd.restartNote')}

+
+ ) : updateError ? ( +
+

{updateError}

+
+ + {updateInfo.url && } +
+
+ ) : ( +
+ + +
+ )}
- + {!updating && ( + + )}
)} @@ -4880,6 +4948,16 @@ export default function App() { disabled={!rotatorHeading.enabled} onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }} /> + {liveStatusOn && ( +
+ + {onAir ? t('live.onAir') : t('live.offline')} +
+ )} {/* 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 diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index bf9f3a9..3633f8e 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -14,6 +14,14 @@ type Dict = Record; 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', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index dbd08b0..db1627e 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -146,6 +146,8 @@ export function DismissAwardUpdate(arg1:string):Promise; export function DownloadAllReferenceLists():Promise; +export function DownloadAndApplyUpdate(arg1:string):Promise; + export function DownloadClublogCty():Promise; export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise; @@ -546,6 +548,8 @@ export function ListTQSLStationLocations():Promise export function ListUDPIntegrations():Promise>; +export function LiveLastQSOAgeSec():Promise; + export function LoTWUserInfo(arg1:string):Promise; export function LogUDPLoggedADIF(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 73ead07..89f2130 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -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); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 6113f84..027e6a6 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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 {