Compare commits

..
4 Commits
Author SHA1 Message Date
rouggy 5f044b959e chore: release v0.20.3 2026-07-20 01:25:28 +02:00
rouggy 68a49be8c1 feat: rate meter OP/Team display, on-air-only station widget, column-filter count
Rate meter: on a shared MySQL logbook it now shows two lines — OP (the active
operator, accent) and Team (all operators, foreground) — each with the 10/60-min
QSOs/hour; single-op keeps the one-line display.

Live-stations widget: only stations currently on air are listed (offline ones
are hidden rather than greyed).

Recent QSOs footer: 'Showing X of Y' now reflects the AG-Grid COLUMN filters
(the funnel icons) — the grid reports its displayed row count, so filtering a
column shows how many QSOs remain instead of always the full total.
2026-07-20 01:08:17 +02:00
rouggy 8eb82d6cdb feat: QSO rate meter — per-operator AND team totals
GetQSORate now returns both the active operator's rate (their own performance)
and the whole station's rate (all operators combined). RecentRateBreakdown
computes both in one scan of the recent rows (per-operator + all-operators),
replacing RecentRate.
2026-07-20 01:08:17 +02:00
rouggy d327db3f57 fix: auto-update relaunch — clear Mark-of-the-Web + wait for exit
The new build downloaded and OpsLog quit, but never came back. Two Windows
causes: the freshly written exe carried the internet Zone.Identifier mark, so
SmartScreen wanted to prompt "are you sure you want to open this?" — invisibly,
since we launch it programmatically — and silently blocked the launch; and
starting the new exe while the old one was still exiting raced the single-
instance mutex.

Now the swapped exe's Zone.Identifier stream is removed, and the relaunch is
done by a detached, hidden PowerShell that Wait-Process's on our PID (so we're
fully gone and the mutex is free) before Start-Process'ing the new exe.
2026-07-19 19:53:19 +02:00
8 changed files with 119 additions and 62 deletions
+11 -11
View File
@@ -4518,31 +4518,31 @@ func (a *App) GetOperators() ([]string, error) {
// QSORate is the live QSO-rate meter shown in the header: how many QSOs were // QSORate is the live QSO-rate meter shown in the header: how many QSOs were
// logged in the trailing 10 and 60 minutes. // logged in the trailing 10 and 60 minutes.
type QSORate struct { type QSORate struct {
Last10 int `json:"last10"` Last10 int `json:"last10"` // active operator, last 10 min
Last60 int `json:"last60"` Last60 int `json:"last60"` // active operator, last 60 min
TeamLast10 int `json:"team_last10"` // ALL operators (the whole station), last 10 min
TeamLast60 int `json:"team_last60"` // ALL operators, last 60 min
} }
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes. // GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes, both
// Cheap (scans only the most recent rows); polled by the header and refreshed on // for the active operator (their own performance) AND for all operators combined
// each qso:logged event. // (the team/station rate). Cheap (one scan of the most recent rows); polled by the
// header and refreshed on each qso:logged event.
func (a *App) GetQSORate() QSORate { func (a *App) GetQSORate() QSORate {
if a.qso == nil { if a.qso == nil {
return QSORate{} return QSORate{}
} }
// Per-operator on a shared logbook: count only the ACTIVE profile's operator
// so each op sees their own performance, not the cumulative station rate. An
// empty operator (single-op / station owner) matches all their QSOs.
operator := "" operator := ""
if a.profiles != nil { if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil { if p, err := a.profiles.Active(a.ctx); err == nil {
operator = p.Operator operator = p.Operator
} }
} }
counts, err := a.qso.RecentRate(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute) op, all, err := a.qso.RecentRateBreakdown(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
if err != nil || len(counts) < 2 { if err != nil || len(op) < 2 || len(all) < 2 {
return QSORate{} return QSORate{}
} }
return QSORate{Last10: counts[0], Last60: counts[1]} return QSORate{Last10: op[0], Last60: op[1], TeamLast10: all[0], TeamLast60: all[1]}
} }
// GetContestRuns lists the (contest, year) pairs actually present in the log, so // GetContestRuns lists the (contest, year) pairs actually present in the log, so
+37 -10
View File
@@ -637,6 +637,7 @@ export default function App() {
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' }); const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
const [matchCount, setMatchCount] = useState<number | null>(null); const [matchCount, setMatchCount] = useState<number | null>(null);
const [gridFilteredCount, setGridFilteredCount] = useState<number | null>(null); // rows after AG-Grid column filters, or null if none
// The selected tab is remembered across restarts. Only the always-present tabs // The selected tab is remembered across restarts. Only the always-present tabs
// are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on // are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on
// a feature or CAT backend that isn't known this early, and restoring one that // a feature or CAT backend that isn't known this early, and restoring one that
@@ -1134,10 +1135,10 @@ export default function App() {
// 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]);
const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number }>({ last10: 0, last60: 0 }); const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number; team10: number; team60: number }>({ last10: 0, last60: 0, team10: 0, team60: 0 });
useEffect(() => { useEffect(() => {
if (!showQsoRate) return; if (!showQsoRate) return;
const load = () => { GetQSORate().then((r) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0 })).catch(() => {}); }; const load = () => { GetQSORate().then((r: any) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0, team10: r?.team_last10 ?? 0, team60: r?.team_last60 ?? 0 })).catch(() => {}); };
load(); load();
// Refresh on each logged QSO (immediate feedback) and on a 30s tick so the // Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
// trailing windows roll forward even when nothing new is logged. // trailing windows roll forward even when nothing new is logged.
@@ -3887,12 +3888,28 @@ export default function App() {
the last columns (profile / band map / compact) onto a 2nd row. */} the last columns (profile / band map / compact) onto a 2nd row. */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{showQsoRate && ( {showQsoRate && (
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap" <div className="flex items-center gap-2 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
title={t('rate.title')}> title={t('rate.title')}>
{/* Contest-style rate: QSOs/hour projected from each window (10-min
count ×6; the 60-min count is already per hour). On a shared MySQL
logbook it shows both OP (the active operator, accent) and TEAM (all
operators, muted); single-op shows one line. */}
<Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} /> <Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} />
{/* Contest-style rate: QSOs/hour projected from each window {dbConn?.backend === 'mysql' ? (
(10-min count ×6; the 60-min count is already per hour). Numbers <div className="flex flex-col gap-0.5 leading-none">
glow the brand accent when active, dim to muted when idle. */} <div className="flex items-center gap-1.5">
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">OP</span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span><span className="text-muted-foreground text-[7px]">10</span></span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span><span className="text-muted-foreground text-[7px]">60</span></span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">Team</span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team10 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team10 * 6}</span><span className="text-muted-foreground text-[7px]">10</span></span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team60 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team60}</span><span className="text-muted-foreground text-[7px]">60</span></span>
</div>
</div>
) : (
<>
<span className="inline-flex items-baseline gap-1"> <span className="inline-flex items-baseline gap-1">
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10</span> <span className="text-muted-foreground uppercase tracking-wider text-[9px]">10</span>
<span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span> <span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
@@ -3901,7 +3918,9 @@ export default function App() {
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60</span> <span className="text-muted-foreground uppercase tracking-wider text-[9px]">60</span>
<span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span> <span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
</span> </span>
<span className="text-muted-foreground text-[9px] uppercase tracking-wider">Q/h</span> </>
)}
<span className="text-muted-foreground text-[9px] uppercase tracking-wider self-center">Q/h</span>
</div> </div>
)} )}
@@ -4259,16 +4278,16 @@ export default function App() {
<Radio className="size-3.5 text-primary" /> <Radio className="size-3.5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span> <span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span>
<div className="flex-1" /> <div className="flex-1" />
<span className="text-[10px] text-muted-foreground tabular-nums">{liveStations.filter((s) => s.online).length}/{liveStations.length}</span> <span className="text-[10px] text-muted-foreground tabular-nums">{liveStations.filter((s) => s.online).length}</span>
<button type="button" className="text-muted-foreground hover:text-foreground shrink-0" <button type="button" className="text-muted-foreground hover:text-foreground shrink-0"
onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}> onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}>
<X className="size-3.5" /> <X className="size-3.5" />
</button> </button>
</div> </div>
<div className="flex-1 min-h-0 overflow-auto p-1.5 flex flex-col gap-1"> <div className="flex-1 min-h-0 overflow-auto p-1.5 flex flex-col gap-1">
{liveStations.length === 0 ? ( {liveStations.filter((s) => s.online).length === 0 ? (
<p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p> <p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
) : liveStations.map((s, i) => { ) : liveStations.filter((s) => s.online).map((s, i) => {
const mc = modeAccent(s.mode); const mc = modeAccent(s.mode);
return ( return (
<div key={i} className={cn('flex items-center gap-2 rounded-md px-2 py-1.5 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}> <div key={i} className={cn('flex items-center gap-2 rounded-md px-2 py-1.5 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
@@ -4620,6 +4639,7 @@ export default function App() {
rows={qsosWithAwards as any} rows={qsosWithAwards as any}
total={total} total={total}
awardCols={awardCols} awardCols={awardCols}
onFilteredCountChange={setGridFilteredCount}
onRowDoubleClicked={(q) => openEdit(q.id as number)} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromCty={bulkUpdateFromCty}
onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromQRZ={bulkUpdateFromQRZ}
@@ -4655,11 +4675,18 @@ export default function App() {
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }} onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
>clear</button> >clear</button>
) : null} ) : null}
{gridFilteredCount != null ? (
<span>
Showing <span className="font-semibold text-foreground">{gridFilteredCount}</span> of{' '}
<span className="font-semibold text-foreground">{qsos.length}</span> (column filter)
</span>
) : (
<span> <span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '} Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span> <span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''} {(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
</span> </span>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && ( {qsos.length >= qsoLimit && qsos.length < total && (
+14 -1
View File
@@ -49,6 +49,10 @@ type Props = {
onExportCabrilloSelected?: (ids: number[]) => void; onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => void; onExportCabrilloFiltered?: () => void;
onDelete?: (ids: number[]) => void; onDelete?: (ids: number[]) => void;
// Reports how many rows the grid shows after its COLUMN filters (the funnel
// icons), or null when no column filter is active — so the parent's "Showing X
// of Y" can reflect them. Fired on filter change and when the data updates.
onFilteredCountChange?: (count: number | null) => void;
// One column per defined award; the cell shows the reference this QSO counts // One column per defined award; the cell shows the reference this QSO counts
// for (from row.award_refs[CODE], attached by the parent). Hidden by default. // for (from row.award_refs[CODE], attached by the parent). Hidden by default.
awardCols?: { code: string; name: string }[]; awardCols?: { code: string; name: string }[];
@@ -245,7 +249,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] => const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_')); (st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) { export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
@@ -360,6 +364,13 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
} }
}); });
} }
// Report the post-column-filter row count (funnel filters) to the parent, or
// null when no column filter is active, so "Showing X of Y" reflects them.
const reportFilteredCount = useCallback((e: { api?: any }) => {
const api = e?.api ?? gridRef.current?.api;
if (!api || !onFilteredCountChange) return;
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
}, [onFilteredCountChange]);
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
@@ -467,6 +478,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
defaultColDef={defaultColDef} defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }} rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onGridReady={onGridReady} onGridReady={onGridReady}
onFilterChanged={reportFilteredCount}
onModelUpdated={reportFilteredCount}
onColumnResized={saveColumnState} onColumnResized={saveColumnState}
onColumnMoved={saveColumnState} onColumnMoved={saveColumnState}
onColumnPinned={saveColumnState} onColumnPinned={saveColumnState}
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.20.2'; export const APP_VERSION = '0.20.3';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+4
View File
@@ -2407,6 +2407,8 @@ export namespace main {
export class QSORate { export class QSORate {
last10: number; last10: number;
last60: number; last60: number;
team_last10: number;
team_last60: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSORate(source); return new QSORate(source);
@@ -2416,6 +2418,8 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.last10 = source["last10"]; this.last10 = source["last10"];
this.last60 = source["last60"]; this.last60 = source["last60"];
this.team_last10 = source["team_last10"];
this.team_last60 = source["team_last60"];
} }
} }
export class RelayAutoRule { export class RelayAutoRule {
+19 -19
View File
@@ -1921,21 +1921,20 @@ func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, boo
return time.Time{}, false return time.Time{}, false
} }
// RecentRate counts QSOs whose start time falls within each trailing window from // RecentRateBreakdown counts, in ONE pass over the most recent rows, QSOs whose
// `now` — the live "QSO rate" meter shown in the header. When operator is non-empty // start time falls within each trailing window from `now` — for a specific operator
// (multi-op on a shared logbook) only that operator's QSOs are counted, so each op // (their own rate, `op`) AND for ALL operators combined (the team/station rate,
// sees their OWN performance, not the cumulative rate; empty operator matches every // `all`). The header rate meter shows both. It scans only recently inserted rows
// QSO. It scans only the most recently inserted rows (ORDER BY id DESC LIMIT), since // (ORDER BY id DESC LIMIT), since any QSO in the last hour was inserted recently, so
// any QSO in the last hour was inserted recently; that keeps it cheap even on a large // it stays cheap on a large log. qso_date is parsed with parseTimeLoose (backend-
// log. qso_date is the repo's text column, parsed with parseTimeLoose (backend-format // format agnostic).
// agnostic). func (r *Repo) RecentRateBreakdown(ctx context.Context, now time.Time, operator string, windows ...time.Duration) (op []int, all []int, err error) {
func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, windows ...time.Duration) ([]int, error) { op = make([]int, len(windows))
counts := make([]int, len(windows)) all = make([]int, len(windows))
// 2000 rows covers a full hour for one operator even in a busy multi-op run // 2000 rows covers a full hour even in a busy multi-op run.
// (other operators' rows are discarded before counting).
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`) rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
if err != nil { if err != nil {
return counts, err return op, all, err
} }
defer rows.Close() defer rows.Close()
now = now.UTC() now = now.UTC()
@@ -1943,23 +1942,24 @@ func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, w
for rows.Next() { for rows.Next() {
var oper, dateStr sql.NullString var oper, dateStr sql.NullString
if err := rows.Scan(&oper, &dateStr); err != nil { if err := rows.Scan(&oper, &dateStr); err != nil {
return counts, err return op, all, err
}
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
continue // a different operator's QSO — not part of my rate
} }
t := parseTimeLoose(dateStr.String).UTC() t := parseTimeLoose(dateStr.String).UTC()
if t.IsZero() || t.After(now) { if t.IsZero() || t.After(now) {
continue continue
} }
mine := strings.ToUpper(strings.TrimSpace(oper.String)) == opFilter
age := now.Sub(t) age := now.Sub(t)
for i, w := range windows { for i, w := range windows {
if age <= w { if age <= w {
counts[i]++ all[i]++
if mine {
op[i]++
} }
} }
} }
return counts, rows.Err() }
return op, all, rows.Err()
} }
// ExistingDedupeKeys returns a set of every QSO key currently in the DB, // ExistingDedupeKeys returns a set of every QSO key currently in the DB,
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.2" appVersion = "0.20.3"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.
+19 -6
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"syscall"
"time" "time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime" wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
@@ -159,14 +160,26 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
_ = os.Rename(oldExe, exe) // roll back _ = os.Rename(oldExe, exe) // roll back
return fmt.Errorf("install new exe: %w", err) return fmt.Errorf("install new exe: %w", err)
} }
applog.Printf("update: installed new exe, relaunching") // Clear the "downloaded from the internet" mark (NTFS Zone.Identifier stream).
// Otherwise Windows SmartScreen wants to prompt "are you sure you want to open
// this?" — but since we launch the exe programmatically that prompt never shows,
// and the launch is silently blocked. This is exactly why the relaunch failed.
_ = os.Remove(exe + ":Zone.Identifier")
applog.Printf("update: installed new exe, scheduling relaunch")
// Relaunch with a flag so the fresh instance waits for THIS one to exit and // Relaunch via a detached, hidden PowerShell that WAITS for this process to exit
// free the single-instance mutex instead of bailing out immediately. // (so the single-instance mutex is free) and THEN starts the new exe. Launching
cmd := exec.Command(exe, "--post-update") // the new exe directly while we're still alive raced the mutex and often left
cmd.Dir = dir // nothing running; waiting for our own exit first makes the restart reliable,
// and the launcher outlives us.
quoted := strings.ReplaceAll(exe, "'", "''")
ps := fmt.Sprintf(
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
os.Getpid(), quoted)
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch: %w", err) return fmt.Errorf("schedule relaunch: %w", err)
} }
if a.ctx != nil { if a.ctx != nil {
wruntime.Quit(a.ctx) wruntime.Quit(a.ctx)