// 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" "sync" "time" ) var ( mu sync.Mutex file *os.File path string ) // 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) } } // Truncate if the file grew past ~5MB so we don't accumulate logs // forever. We keep one file — simple and adequate for diagnostics. if fi, err := os.Stat(logPath); err == nil && fi.Size() > 5*1024*1024 { _ = os.Remove(logPath) } f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return "", err } file = f path = logPath // 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 { fmt.Fprintf(file, "%s %s\n", stamp, msg) } // 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) } // 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 } }