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)
|
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
||||||
liveBand string
|
liveBand string
|
||||||
liveMode 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
|
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
||||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||||
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
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 {
|
if err == nil {
|
||||||
q.ID = id
|
q.ID = id
|
||||||
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
|
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).
|
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
|
||||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||||
a.saveQSORecording(&q)
|
a.saveQSORecording(&q)
|
||||||
@@ -9287,6 +9289,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
return 0, fmt.Errorf("insert qso: %w", err)
|
return 0, fmt.Errorf("insert qso: %w", err)
|
||||||
}
|
}
|
||||||
q.ID = id
|
q.ID = id
|
||||||
|
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
||||||
a.saveQSORecording(&q)
|
a.saveQSORecording(&q)
|
||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
a.extsvc.OnQSOLogged(id)
|
a.extsvc.OnQSOLogged(id)
|
||||||
|
|||||||
+94
-16
@@ -13,7 +13,7 @@ import {
|
|||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
GetStartupStatus, CheckForUpdate,
|
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
SetCompactMode,
|
SetCompactMode,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
GetUIPref,
|
GetUIPref,
|
||||||
ReportLiveActivity,
|
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
|
||||||
AwardRefsForQSOs,
|
AwardRefsForQSOs,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
@@ -1091,6 +1091,30 @@ export default function App() {
|
|||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
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.
|
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
||||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||||
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
||||||
@@ -1111,15 +1135,39 @@ export default function App() {
|
|||||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
||||||
// Check GitHub for a newer release once at startup (unless disabled in
|
const [updating, setUpdating] = useState(false);
|
||||||
// General); surface a toast if one exists. Best effort — silent on failure.
|
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(() => {
|
useEffect(() => {
|
||||||
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
||||||
CheckForUpdate().then((u: any) => {
|
const check = () => CheckForUpdate().then((u: any) => {
|
||||||
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? '') });
|
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
||||||
}).catch(() => {});
|
}).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 [deletingAll, setDeletingAll] = useState(false);
|
||||||
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
||||||
const [refsDownloading, setRefsDownloading] = useState(false);
|
const [refsDownloading, setRefsDownloading] = useState(false);
|
||||||
@@ -3940,22 +3988,42 @@ export default function App() {
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
|
<div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-semibold">OpsLog v{updateInfo.latest} available</p>
|
<p className="text-sm font-semibold">{t('upd.available', { v: updateInfo.latest })}</p>
|
||||||
<p className="text-xs text-muted-foreground">You're on v{APP_VERSION}.</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">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<button
|
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
||||||
onClick={() => { if (updateInfo.url) BrowserOpenURL(updateInfo.url); setUpdateInfo(null); }}
|
{updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
|
||||||
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>
|
</button>
|
||||||
|
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!updating && (
|
||||||
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -4880,6 +4948,16 @@ export default function App() {
|
|||||||
disabled={!rotatorHeading.enabled}
|
disabled={!rotatorHeading.enabled}
|
||||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
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
|
{/* 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),
|
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
|
but CLICK opens the full text wrapped — long messages (a TQSL or
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ type Dict = Record<string, string>;
|
|||||||
const en: Dict = {
|
const en: Dict = {
|
||||||
// Menu bar
|
// Menu bar
|
||||||
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
|
'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',
|
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
|
||||||
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
||||||
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
||||||
@@ -324,6 +332,9 @@ const en: Dict = {
|
|||||||
|
|
||||||
const fr: Dict = {
|
const fr: Dict = {
|
||||||
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
|
'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',
|
'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)',
|
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
|
||||||
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
'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 DownloadAllReferenceLists():Promise<string>;
|
||||||
|
|
||||||
|
export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
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 ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
|
|
||||||
|
export function LiveLastQSOAgeSec():Promise<number>;
|
||||||
|
|
||||||
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ export function DownloadAllReferenceLists() {
|
|||||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadAndApplyUpdate(arg1) {
|
||||||
|
return window['go']['main']['App']['DownloadAndApplyUpdate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadClublogCty() {
|
export function DownloadClublogCty() {
|
||||||
return window['go']['main']['App']['DownloadClublogCty']();
|
return window['go']['main']['App']['DownloadClublogCty']();
|
||||||
}
|
}
|
||||||
@@ -1050,6 +1054,10 @@ export function ListUDPIntegrations() {
|
|||||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LiveLastQSOAgeSec() {
|
||||||
|
return window['go']['main']['App']['LiveLastQSOAgeSec']();
|
||||||
|
}
|
||||||
|
|
||||||
export function LoTWUserInfo(arg1) {
|
export function LoTWUserInfo(arg1) {
|
||||||
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2763,6 +2763,7 @@ export namespace main {
|
|||||||
latest: string;
|
latest: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
|
download_url: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new UpdateInfo(source);
|
return new UpdateInfo(source);
|
||||||
@@ -2774,6 +2775,7 @@ export namespace main {
|
|||||||
this.latest = source["latest"];
|
this.latest = source["latest"];
|
||||||
this.available = source["available"];
|
this.available = source["available"];
|
||||||
this.url = source["url"];
|
this.url = source["url"];
|
||||||
|
this.download_url = source["download_url"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class WKMacro {
|
export class WKMacro {
|
||||||
|
|||||||
@@ -1895,6 +1895,32 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
|||||||
return n, err
|
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
|
// 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
|
// `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
|
// (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"
|
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.
|
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
||||||
func (a *App) GetLiveStatusEnabled() bool {
|
func (a *App) GetLiveStatusEnabled() bool {
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
@@ -50,11 +67,44 @@ func (a *App) SetLiveStatusEnabled(on bool) error {
|
|||||||
return nil
|
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
|
// liveStatusLoop heartbeats the current activity while enabled. Started once at
|
||||||
// startup; cheap no-op when disabled or not on MySQL.
|
// startup; cheap no-op when disabled or not on MySQL.
|
||||||
func (a *App) liveStatusLoop() {
|
func (a *App) liveStatusLoop() {
|
||||||
defer func() { _ = recover() }() // never crash the app from here
|
defer func() { _ = recover() }() // never crash the app from here
|
||||||
applog.Printf("livestatus: loop started")
|
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
|
a.publishLiveStatus() // attempt immediately, don't wait the first tick
|
||||||
t := time.NewTicker(15 * time.Second)
|
t := time.NewTicker(15 * time.Second)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
@@ -132,34 +182,62 @@ func (a *App) publishLiveStatus() {
|
|||||||
if mode == "" {
|
if mode == "" {
|
||||||
mode = a.liveMode
|
mode = a.liveMode
|
||||||
}
|
}
|
||||||
|
lastQSO := a.liveLastQSOAt
|
||||||
a.liveActMu.Unlock()
|
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 {
|
if err := a.ensureLiveStatusTable(); err != nil {
|
||||||
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err := a.logDb.ExecContext(a.ctx,
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
"INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+
|
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, last_qso_at, updated_at) "+
|
||||||
"VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
"VALUES (?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||||
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
||||||
"band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()",
|
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), "+
|
||||||
op, station, freqHz, band, mode)
|
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
|
||||||
|
op, station, freqHz, band, mode, online, lastQSOArg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("livestatus: INSERT failed: %v", err)
|
applog.Printf("livestatus: INSERT failed: %v", err)
|
||||||
return
|
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 {
|
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 ("+
|
"CREATE TABLE IF NOT EXISTS live_status ("+
|
||||||
"operator VARCHAR(32) PRIMARY KEY, "+
|
"operator VARCHAR(32) PRIMARY KEY, "+
|
||||||
"station VARCHAR(32), "+
|
"station VARCHAR(32), "+
|
||||||
"freq_hz BIGINT, "+
|
"freq_hz BIGINT, "+
|
||||||
"band VARCHAR(16), "+
|
"band VARCHAR(16), "+
|
||||||
"mode VARCHAR(16), "+
|
"mode VARCHAR(16), "+
|
||||||
"updated_at DATETIME)")
|
"online TINYINT DEFAULT 0, "+
|
||||||
|
"last_qso_at DATETIME NULL, "+
|
||||||
|
"updated_at DATETIME)"); err != nil {
|
||||||
return err
|
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).
|
// clearLiveStatus removes this operator's row (on disable / shutdown).
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
@@ -35,15 +36,53 @@ func profileArg(args []string) string {
|
|||||||
return ""
|
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() {
|
func main() {
|
||||||
// Single-instance guard: if OpsLog is already running, focus that window and
|
// 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
|
// exit instead of spawning a duplicate. A second process would open its own
|
||||||
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
|
// 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
|
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
|
||||||
// its own" when a windowless zombie instance was left running.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
if postUpdate {
|
||||||
|
cleanupOldUpdateBinary()
|
||||||
|
}
|
||||||
|
|
||||||
// Create an instance of the app structure
|
// Create an instance of the app structure
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,12 +22,13 @@ import (
|
|||||||
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
|
// 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"
|
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 {
|
type UpdateInfo struct {
|
||||||
Current string `json:"current"` // this build's version (appVersion)
|
Current string `json:"current"` // this build's version (appVersion)
|
||||||
Latest string `json:"latest"` // newest published release, "" if unknown
|
Latest string `json:"latest"` // newest published release, "" if unknown
|
||||||
Available bool `json:"available"` // Latest > Current
|
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
|
// CheckForUpdate asks GitHub for the latest release and compares it to this
|
||||||
@@ -45,6 +54,10 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
|||||||
var r struct {
|
var r struct {
|
||||||
TagName string `json:"tag_name"`
|
TagName string `json:"tag_name"`
|
||||||
HTMLURL string `json:"html_url"`
|
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 {
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||||
return out
|
return out
|
||||||
@@ -52,8 +65,25 @@ func (a *App) CheckForUpdate() UpdateInfo {
|
|||||||
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
||||||
out.URL = r.HTMLURL
|
out.URL = r.HTMLURL
|
||||||
out.Available = versionLess(appVersion, out.Latest)
|
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 {
|
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
|
return out
|
||||||
}
|
}
|
||||||
@@ -82,6 +112,164 @@ func versionLess(a, b string) bool {
|
|||||||
return false
|
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.
|
// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
|
||||||
func leadingInt(s string) int {
|
func leadingInt(s string) int {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
|
|||||||
Reference in New Issue
Block a user