diff --git a/app.go b/app.go
index f5d46ae..05074ef 100644
--- a/app.go
+++ b/app.go
@@ -1637,6 +1637,14 @@ func (a *App) saveWindowState() {
if w > 0 && h > 0 {
ws.Width, ws.Height, ws.X, ws.Y = w, h, x, y
}
+ } else if max {
+ // The POSITION is recorded even when maximised — it is what identifies the
+ // MONITOR. Without it a multi-screen operator who always runs maximised got
+ // no position at all, and Windows reopened OpsLog on the primary screen
+ // every time. The SIZE is deliberately not touched here: it would be the
+ // maximised size, which must not become the restored-down size.
+ x, y := wruntime.WindowGetPosition(a.ctx)
+ ws.X, ws.Y = x, y
}
writeWindowState(a.dataDir, ws)
}
@@ -1651,7 +1659,24 @@ func (a *App) restoreWindowPosition() {
return
}
ws, ok := readWindowState(a.dataDir)
- if !ok || ws.Maximised {
+ if !ok {
+ return
+ }
+ // Maximised: Windows reopens it on the PRIMARY screen unless we say otherwise,
+ // so a two-screen operator lost OpsLog to the wrong monitor at every launch.
+ // Un-maximise, move to the saved corner (which names the monitor), maximise
+ // again — all while the window is still hidden, so nothing flickers.
+ if ws.Maximised {
+ if ws.X == 0 && ws.Y == 0 {
+ return // never recorded (or genuinely the primary corner) — nothing to do
+ }
+ if !onSomeMonitor(ws.X, ws.Y, normalMinW, normalMinH) {
+ applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y)
+ return
+ }
+ wruntime.WindowUnmaximise(a.ctx)
+ wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y)
+ wruntime.WindowMaximise(a.ctx)
return
}
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
@@ -6541,7 +6566,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
return err
}
}
- a.reloadCAT()
+ a.restartAsync("cat", a.reloadCAT)
return nil
}
@@ -11764,12 +11789,34 @@ func (a *App) SwitchCATRig(n int) error {
if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil {
return err
}
- a.reloadCAT()
+ a.restartAsync("cat", a.reloadCAT)
return nil
}
// reloadCAT (re)starts the CAT manager based on the current settings.
// Called at startup and after the user saves new CAT config.
+// restartAsync runs a hardware (re)start off the caller's goroutine.
+//
+// Saving settings must never block the UI on a device. Restarting a link tears
+// the old one down FIRST and waits for its poll goroutine to exit — and that
+// goroutine can be tens of seconds deep inside a call that cannot be
+// interrupted: OmniRig's COM Connect when another program (MSHV, a contest
+// logger) is holding the rig takes ~45 s to give up. Save-and-close froze for
+// exactly that long, because the Wails binding was waiting on it.
+//
+// The restart still takes as long; it just no longer holds the window. The
+// duration is logged, since "the rig took 45 s to let go" is invisible
+// otherwise and looks like OpsLog hanging.
+func (a *App) restartAsync(name string, f func()) {
+ go func() {
+ t0 := time.Now()
+ f()
+ if d := time.Since(t0); d > 2*time.Second {
+ applog.Printf("%s: restart took %s (device slow to release/connect)", name, d.Round(time.Millisecond))
+ }
+ }()
+}
+
func (a *App) reloadCAT() {
if a.cat == nil {
return
@@ -12105,7 +12152,10 @@ func (a *App) reloadAfterProfileSwitch() {
if a.extsvc != nil {
a.extsvc.SetConfig(a.loadExternalServices())
}
- a.reloadCAT()
+ // Off the caller's goroutine for the same reason as the settings save: this
+ // runs from ActivateProfile, a click, and a rig that is slow to release would
+ // otherwise freeze the switch.
+ a.restartAsync("cat", a.reloadCAT)
a.startQSORecorderIfEnabled()
}
@@ -12835,7 +12885,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
return err
}
}
- a.startUltrabeam()
+ a.restartAsync("antenna", a.startUltrabeam)
return nil
}
@@ -13408,7 +13458,7 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
return err
}
}
- a.startAmps()
+ a.restartAsync("amp", a.startAmps)
return nil
}
@@ -13544,7 +13594,7 @@ func (a *App) SaveAmplifiers(list []AmpConfig) error {
if err := a.settings.Set(a.ctx, keyAmpsList, string(b)); err != nil {
return err
}
- a.startAmps()
+ a.restartAsync("amp", a.startAmps)
return nil
}
@@ -13989,6 +14039,18 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
return nil
}
+// SetWinkeyerTrace turns the byte-level WinKeyer protocol trace on or off.
+//
+// The K1EL command set differs across WK1 / WK2 / WK3 and a mismatch is silent:
+// the keyer accepts a byte meant for another command and keys something odd
+// (one element then a long pause, a sidetone that will not switch off). Reading
+// the actual wire is the only way to tell which command the firmware misread,
+// and it beats guessing from a description — a guessed fix breaks the operators
+// for whom it currently works.
+func (a *App) SetWinkeyerTrace(on bool) {
+ winkeyer.SetTrace(on)
+}
+
// WinkeyerConnect opens the serial link using the saved config.
func (a *App) WinkeyerConnect() error {
if a.winkeyer == nil {
diff --git a/changelog.json b/changelog.json
index 67f5fc1..8cae551 100644
--- a/changelog.json
+++ b/changelog.json
@@ -1,4 +1,32 @@
[
+ {
+ "version": "0.21.6",
+ "date": "2026-07-27",
+ "en": [
+ "Statistics: the activity chart now follows the period you selected, and Day / Week / Month / Year choose the bucket size (QSOs per week over the whole period, for instance). It used to show fixed windows — the last 7 days, the last 30 — regardless of the period.",
+ "Statistics: new \"When you are on the air\" card — by hour and by day of week, over the selected period.",
+ "Band map: the CW / data / phone sub-bands are no longer shaded in the status colours — the SSB portion was amber, the same amber that means \"new band\" on the spots above it. They now use distinct, colour-blind-checked hues and appear in the legend.",
+ "Help menu: a Support OpsLog entry, which opens the donation page in your browser.",
+ "The Callsign field is now visually marked out, so it is no longer mistaken for Name next to it.",
+ "Multi-screen: OpsLog reopens on the screen it was closed on, including when it was maximised — it used to come back on the primary screen every time.",
+ "The Windows title bar is gone: minimise, maximise and close now sit in the OpsLog bar, which you drag to move the window (double-click to maximise). That is 32 pixels of screen back.",
+ "Saving the settings no longer freezes OpsLog while a device is slow to answer. Choosing OmniRig while another program held the rig locked the window for about 45 seconds — the time OmniRig takes to give up. The link is now re-established in the background, and a slow restart is written to the diagnostic log.",
+ "Edit QSO → QSL Info: the sent and received dates now have a calendar picker, plus a button for today's date (UTC).",
+ "CW keyer: the diagnostic log now names the keyer generation (WK1 / WK2 / WK3), and Settings → CW keyer can log every byte exchanged with it — for reporting a keyer that behaves oddly."
+ ],
+ "fr": [
+ "Statistiques : le graphique d'activité suit désormais la période choisie, et Jour / Semaine / Mois / Année en choisissent le pas (les QSO par semaine sur toute la période, par exemple). Il affichait auparavant des fenêtres fixes — les 7 derniers jours, les 30 derniers — quelle que soit la période.",
+ "Statistiques : nouvelle carte « Quand vous trafiquez » — par heure et par jour de semaine, sur la période choisie.",
+ "Carte de bande : les portions CW / numérique / phonie ne sont plus teintées avec les couleurs de statut — la partie SSB était ambre, l'ambre qui signifie « nouvelle bande » sur les spots posés dessus. Elles utilisent maintenant des teintes distinctes, vérifiées pour le daltonisme, et figurent dans la légende.",
+ "Menu Aide : une entrée « Soutenir OpsLog », qui ouvre la page de don dans votre navigateur.",
+ "Le champ Indicatif est désormais mis en évidence, pour ne plus être confondu avec le champ Nom voisin.",
+ "Multi-écrans : OpsLog se rouvre sur l'écran où il a été fermé, y compris s'il était maximisé — il revenait jusqu'ici sur l'écran principal à chaque fois.",
+ "La barre de titre Windows a disparu : réduire, agrandir et fermer sont désormais dans la barre OpsLog, qu'on saisit pour déplacer la fenêtre (double-clic pour agrandir). Autant de pixels d'écran repris.",
+ "Enregistrer les réglages ne fige plus OpsLog quand un appareil tarde à répondre. Choisir OmniRig alors qu'un autre logiciel tenait la radio bloquait la fenêtre une quarantaine de secondes — le temps qu'OmniRig renonce. La liaison est désormais rétablie en arrière-plan, et un redémarrage lent est noté dans le journal de diagnostic.",
+ "Édition QSO → Infos QSL : les dates d'envoi et de réception disposent d'un calendrier, et d'un bouton pour la date du jour (UTC).",
+ "Keyer CW : le journal de diagnostic nomme désormais la génération du keyer (WK1 / WK2 / WK3), et Réglages → Keyer CW permet de journaliser chaque octet échangé avec lui — pour signaler un keyer au comportement anormal."
+ ]
+ },
{
"version": "0.21.5",
"date": "2026-07-27",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index eb457e0..f5005af 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -52,7 +52,7 @@ import {
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
-import { EventsOn, BrowserOpenURL } from '../wailsjs/runtime/runtime';
+import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -331,6 +331,54 @@ function computePrefix(call: string): string {
return lastDigit >= 0 ? c.slice(0, lastDigit + 1) : c;
}
+// WindowControls — minimise / maximise / close, since the window is frameless.
+//
+// Deliberately shaped like the Windows ones (flat, wide, close goes red on
+// hover) rather than styled like the app's own buttons: these are OS controls,
+// and an operator must recognise them without reading them. Close runs the
+// normal shutdown path — the same one the OS button used — so the backup and
+// on-close uploads still happen.
+function WindowControls() {
+ const [max, setMax] = useState(false);
+ useEffect(() => {
+ WindowIsMaximised().then(setMax).catch(() => {});
+ }, []);
+ const btn = 'inline-flex items-center justify-center w-11 h-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors';
+ return (
+
+
+
+
+
+ );
+}
+
export default function App() {
const { t, lang } = useI18n();
// === Lists from settings (fallback for first paint) ===
@@ -3292,6 +3340,8 @@ export default function App() {
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
] : []),
{ type: 'separator' },
+ { type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
+ { type: 'separator' },
{ type: 'item', label: t('help.about'), action: 'help.about' },
]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
@@ -3325,6 +3375,9 @@ export default function App() {
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
case 'help.sendlog': sendLogToDeveloper(); break;
+ // Opens in the system browser, NOT the app WebView: a payment page must show
+ // the address bar and padlock the donor knows how to check.
+ case 'help.donate': BrowserOpenURL('https://www.paypal.com/donate/?hosted_button_id=PDMY7KV99K38S'); break;
}
}
@@ -3456,7 +3509,7 @@ export default function App() {
const callsignBlock = (