Panadapter spots are now worth reading. The comment carries the spotter, the
entity and the status in DXHunter's own shape — "CQ up 2 [F4BPO] [Franz Josef
Land] [New Slot]" — which needed two things nothing documents: SmartSDR splits
its command line on SPACES, so the words ran together until every space became
non-breaking; and it truncates past ~60 characters, so the cluster's own words
are trimmed first and the three brackets always survive. RBN column padding is
collapsed on the way in, or a preserved run of spaces opened a gap wide enough
to push the rest off screen.
"Already worked" means the CALLSIGN is in the log, not the entity: saying it of
a station never contacted was simply wrong. Each status can also be kept off the
panadapter entirely, and the WSJT-X decode spots obey the same switches — the
palette governs the panadapter, not one of the two things that feed it.
And the radio is no longer hammered: a spot whose frequency, colour and comment
are unchanged is not removed and redrawn. A busy skimmer feed re-spots the same
station every few seconds; one two-minute session sent 2128 adds, 88 of them for
a single callsign, and the display did not move a pixel for any of them.
CI-V, from an IC-7850 that kept killing JTDX: a reply the rig sent to another
controller on the same bus is no longer taken for ours, and a set_ptt, set_freq
or set_mode whose acknowledgement goes missing is verified by reading the rig
back instead of being reported as a failure. WSJT-X and JTDX answer a failed
command with a Rig Control Error and drop the link mid-over — 98 keyings, 6 lost
acknowledgements, 2 dropped connections in one session. The check waits 700 ms,
not the poll's 150: the rig has just failed to answer twice because it was
retuning, and a short probe would fail for the same reason.
Auto-call is withdrawn — it duplicated DXHunter, which already answers decodes,
and two programs deciding that from one shack key over each other. The library
is kept whole and dormant; a guard in App.tsx makes sure a stored preference
cannot key a transmitter whose switch no longer exists.
Also:
- the log rotates while running, not only at startup: the CI-V trace left on
wrote 416 MB and nothing would have stopped it before the disk did. Closing
it now releases the crash file too — the runtime keeps its own duplicate.
- the interface zoom announces itself, with a badge, a click back to 100% and
a View menu; Ctrl+wheel and Ctrl+0 always worked and nothing said so.
- no more elastic bounce, and no swipe-to-navigate out of the app.
- Edit QSO: your own TX power and the contacted station's extended locator
were saved and written back with no box to set them.
- FT decodes: continents are a multiple choice; a compound MSHV message that
answers two stations in one line is recognised as addressed to you.
187 lines
6.6 KiB
Go
187 lines
6.6 KiB
Go
// Package applog routes the app's diagnostic output to a rotating log
|
|
// file inside the user's data dir. Wails builds with the Windows GUI
|
|
// subsystem by default — fmt.Println output is dropped, so launching
|
|
// from cmd never showed anything. The file gives us a reliable place to
|
|
// inspect what the UDP listener / cluster / CAT layer is doing.
|
|
package applog
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
file *os.File
|
|
path string
|
|
written int64 // bytes in the CURRENT file, for the size check in Printf
|
|
crashFile *os.File // kept open so the runtime can write a crash traceback to it
|
|
)
|
|
|
|
// maxLogBytes is where the file rolls over, at startup AND while running.
|
|
// A variable, not a constant, so the rotation can be exercised in a test without
|
|
// writing ten megabytes to do it.
|
|
var maxLogBytes int64 = 10 * 1024 * 1024
|
|
|
|
// Init opens (creates) the log file in dataDir. On rotation we truncate
|
|
// at startup if the file is too big; for now it's a single file, no
|
|
// rolling — the volume is low (a few KB per session).
|
|
func Init(dataDir string) (string, error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if file != nil {
|
|
return path, nil
|
|
}
|
|
if dataDir == "" {
|
|
return "", fmt.Errorf("empty data dir")
|
|
}
|
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
logPath := filepath.Join(dataDir, "opslog.log")
|
|
// One-shot rename for users coming from the HamLog era.
|
|
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
|
oldLog := filepath.Join(dataDir, "hamlog.log")
|
|
if _, err := os.Stat(oldLog); err == nil {
|
|
_ = os.Rename(oldLog, logPath)
|
|
}
|
|
}
|
|
// Rotate (don't delete) once the file grows past maxLogBytes: rename it to
|
|
// opslog.log.1 so the PREVIOUS session's log survives. Deleting it outright used
|
|
// to erase exactly the diagnostics we needed when a user reported an issue from
|
|
// the session that just ended. One generation kept — enough, without unbounded growth.
|
|
if fi, err := os.Stat(logPath); err == nil && fi.Size() > maxLogBytes {
|
|
_ = os.Remove(logPath + ".1") // drop the older generation
|
|
if err := os.Rename(logPath, logPath+".1"); err != nil {
|
|
_ = os.Remove(logPath) // rename failed (locked?) → fall back to the old behaviour
|
|
}
|
|
}
|
|
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
file = f
|
|
path = logPath
|
|
|
|
// Capture a full traceback on a FATAL crash (a Go panic that escapes our
|
|
// recover()s, or a runtime-fatal error like a concurrent map write, or a
|
|
// Windows access violation routed through the Go signal handler) into a
|
|
// dedicated file the runtime writes directly — so otherwise-silent process
|
|
// deaths leave a stack we can read.
|
|
if cf, cerr := os.OpenFile(filepath.Join(dataDir, "opslog-crash.log"),
|
|
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); cerr == nil {
|
|
crashFile = cf
|
|
_ = debug.SetCrashOutput(cf, debug.CrashOptions{})
|
|
}
|
|
|
|
// Redirect log.Print* and the standard logger to the file too, so
|
|
// any third-party output stays consistent.
|
|
log.SetOutput(io.MultiWriter(file, os.Stderr))
|
|
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
|
|
|
fmt.Fprintf(file, "\n────── OpsLog start %s ──────\n", time.Now().Format(time.RFC3339))
|
|
return logPath, nil
|
|
}
|
|
|
|
// Printf writes a formatted line with a timestamp. Caller's format may
|
|
// or may not end with a newline — we strip a trailing one before adding
|
|
// our own, so log entries always look like "HH:MM:SS.mmm msg\n".
|
|
func Printf(format string, args ...any) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
stamp := time.Now().Format("15:04:05.000")
|
|
msg := fmt.Sprintf(format, args...)
|
|
for len(msg) > 0 && (msg[len(msg)-1] == '\n' || msg[len(msg)-1] == '\r') {
|
|
msg = msg[:len(msg)-1]
|
|
}
|
|
if file != nil {
|
|
n, _ := fmt.Fprintf(file, "%s %s\n", stamp, msg)
|
|
written += int64(n)
|
|
rotateIfBigLocked()
|
|
}
|
|
// Also dump to stderr in case the binary was launched with a console
|
|
// attached (wails dev, custom build).
|
|
fmt.Fprintf(os.Stderr, "%s %s\n", stamp, msg)
|
|
}
|
|
|
|
// rotateIfBigLocked rolls the file over mid-session once it passes maxLogBytes.
|
|
//
|
|
// The check used to happen at STARTUP only, on the reasonable belief that a
|
|
// session writes a few kilobytes. The CI-V wire trace disproved it: left on for
|
|
// a day it wrote 416 MB, and nothing would have stopped it before the disk did.
|
|
// A log that has to be rotated by quitting the program is not a bound.
|
|
//
|
|
// Same one-generation scheme as the startup rotation, so the previous stretch
|
|
// survives — which is the half an operator usually needs, the part just before
|
|
// the thing they are reporting.
|
|
//
|
|
// Caller holds mu.
|
|
func rotateIfBigLocked() {
|
|
if written < maxLogBytes || file == nil || path == "" {
|
|
return
|
|
}
|
|
fmt.Fprintf(file, "%s ── log reached %d MB, rotating to %s.1 ──\n",
|
|
time.Now().Format("15:04:05.000"), maxLogBytes/(1024*1024), filepath.Base(path))
|
|
_ = file.Close()
|
|
file = nil
|
|
_ = os.Remove(path + ".1")
|
|
if err := os.Rename(path, path+".1"); err != nil {
|
|
// Rename refused (the file is held open elsewhere, a virus scanner): keep
|
|
// writing to the old handle rather than losing the log entirely, and try
|
|
// again in another maxLogBytes rather than on every single line.
|
|
f, oerr := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if oerr == nil {
|
|
file = f
|
|
}
|
|
written = 0
|
|
return
|
|
}
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return // nothing more to be done; the next Printf simply writes nowhere
|
|
}
|
|
file = f
|
|
written = 0
|
|
fmt.Fprintf(file, "%s ── continued from %s.1 ──\n",
|
|
time.Now().Format("15:04:05.000"), filepath.Base(path))
|
|
}
|
|
|
|
// Path returns where the file is so the UI can surface it.
|
|
func Path() string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return path
|
|
}
|
|
|
|
// Close flushes and releases the handle. Called from shutdown.
|
|
func Close() {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if file != nil {
|
|
_ = file.Close()
|
|
file = nil
|
|
}
|
|
// The crash file is held open for the whole run so the runtime can dump a
|
|
// traceback into it without allocating; on the way out it has to be released
|
|
// like any other handle, or the data directory cannot be moved or removed.
|
|
if crashFile != nil {
|
|
// The RUNTIME holds its own duplicate of this handle (SetCrashOutput), so
|
|
// closing ours releases nothing on Windows — the data directory stays
|
|
// locked. Hand the runtime a nil first. Anything fatal in the last moments
|
|
// of shutdown is no longer captured, which is the right trade at a point
|
|
// where the databases are already closed.
|
|
_ = debug.SetCrashOutput(nil, debug.CrashOptions{})
|
|
_ = crashFile.Close()
|
|
crashFile = nil
|
|
}
|
|
path = ""
|
|
written = 0
|
|
}
|