diff --git a/CLAUDE.md b/CLAUDE.md index 8c079da..c4de107 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ There are no tests in this project. **Core application** — primarily `main.go`, with platform files for Dock policy, Windows console hiding, and Darwin mouse: -1. **Configuration**: `Config` struct, load/save from `~/.bitfocus_listener.config` (JSON) +1. **Configuration**: `Config` struct, load/save from the OS config dir (JSON). Config and audit-log paths come from `paths.go` (`appConfigPath` / `appLogPath`); the old `~/.bitfocus_listener.*` dotfiles are migrated on first run. 2. **System Info**: CPU/memory/process metrics via gopsutil 3. **WebSocket & Commands**: Auth, serialized writes, subscriptions, command handling 4. **Key Access Control**: Category-based and individual key whitelisting diff --git a/activation_darwin.go b/activation_darwin.go index 7a847f7..78a5673 100644 --- a/activation_darwin.go +++ b/activation_darwin.go @@ -13,6 +13,19 @@ void setAccessoryActivationPolicy(void) { */ import "C" +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/theme" +) + +// trayIconResource returns the menu bar glyph. Fyne only marks a systray icon +// as a macOS template image when the resource is a *theme.ThemedResource, so +// wrap the monochrome PNG in one; macOS then tints it for the current menu bar +// appearance instead of drawing our full-colour app icon. +func trayIconResource(fyne.Resource) fyne.Resource { + return theme.NewThemedResource(fyne.NewStaticResource("tray_icon.png", trayIconPNG)) +} + // hideDockIcon runs the app as an accessory (menu-bar) process so it does not // appear in the Dock. Needed because GLFW/Fyne can reset activation policy even // when Info.plist has LSUIElement=true. diff --git a/activation_other.go b/activation_other.go index 5521bea..15ee453 100644 --- a/activation_other.go +++ b/activation_other.go @@ -2,4 +2,10 @@ package main +import "fyne.io/fyne/v2" + func hideDockIcon() {} + +// trayIconResource keeps the full-colour app icon in the tray on platforms +// without macOS template image handling. +func trayIconResource(appIcon fyne.Resource) fyne.Resource { return appIcon } diff --git a/main.go b/main.go index cf3691f..735ec00 100644 --- a/main.go +++ b/main.go @@ -16,7 +16,6 @@ import ( "net/http" "os" "os/exec" - "path/filepath" "regexp" "runtime" "sort" @@ -51,6 +50,13 @@ import ( //go:embed icon.png var iconPNG []byte +// trayIconPNG is the monochrome menu-bar/tray glyph. It is a macOS template +// image (shape carried entirely in the alpha channel) so the system can tint +// it for light/dark menu bars. +// +//go:embed tray_icon.png +var trayIconPNG []byte + // ------------------------- // Configuration Handling // ------------------------- @@ -112,11 +118,12 @@ func init() { } func loadConfig() error { - home, err := os.UserHomeDir() + path, err := appConfigPath() if err != nil { return err } - configPath = filepath.Join(home, ".bitfocus_listener.config") + configPath = path + migrateLegacyFile(legacyHomePath(".bitfocus_listener.config"), configPath) if _, err := os.Stat(configPath); os.IsNotExist(err) { config = Config{ Password: generatePassword(), @@ -236,6 +243,7 @@ func (e AuditEntry) String() string { type AuditLogger struct { mu sync.Mutex entries []AuditEntry + seq uint64 file *os.File filePath string fileSize int64 @@ -244,11 +252,12 @@ type AuditLogger struct { var audit *AuditLogger func initAuditLogger() error { - home, err := os.UserHomeDir() + logPath, err := appLogPath() if err != nil { return err } - logPath := filepath.Join(home, ".bitfocus_listener.log") + migrateLegacyFile(legacyHomePath(".bitfocus_listener.log"), logPath) + migrateLegacyFile(legacyHomePath(".bitfocus_listener.log.1"), logPath+".1") a := &AuditLogger{ entries: make([]AuditEntry, 0, auditRingSize), @@ -281,7 +290,7 @@ func (a *AuditLogger) rotateIfNeeded() { log.Printf("Failed to rotate audit log (rename): %v", err) return } - // a.filePath is a fixed, application-controlled path (~/.bitfocus_listener.log), + // a.filePath is a fixed, application-controlled path (see appLogPath), // never user input, so reopening it cannot be abused for file inclusion. // nosec f, err := os.OpenFile(a.filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) @@ -312,6 +321,7 @@ func (a *AuditLogger) Log(ip, action, detail string) { a.entries = a.entries[1:] } a.entries = append(a.entries, entry) + a.seq++ // Write to file if a.file == nil { @@ -327,13 +337,16 @@ func (a *AuditLogger) Log(ip, action, detail string) { } } -// Entries returns a copy of the ring buffer contents. -func (a *AuditLogger) Entries() []AuditEntry { +// Snapshot returns a copy of the ring buffer contents along with the total +// number of entries ever logged. Once the ring is full its length stops +// changing, so the counter is the only reliable way for viewers to tell that +// the contents moved on. +func (a *AuditLogger) Snapshot() ([]AuditEntry, uint64) { a.mu.Lock() defer a.mu.Unlock() result := make([]AuditEntry, len(a.entries)) copy(result, a.entries) - return result + return result, a.seq } // auditLog is the convenience function used throughout the codebase. @@ -1517,6 +1530,16 @@ func flashButtonText(btn *widget.Button, temp, original string) { }) } +func flashLabelText(lbl *widget.Label, temp, original string) { + if lbl == nil { + return + } + lbl.SetText(temp) + time.AfterFunc(1500*time.Millisecond, func() { + lbl.SetText(original) + }) +} + func openAccessibilitySettings() { if runtime.GOOS != "darwin" { return @@ -2093,31 +2116,63 @@ func showKeyControlDialog(parent fyne.Window, updateStatusCallback func()) { } func showActivityLogDialog(parent fyne.Window) { - logEntry := widget.NewMultiLineEntry() - logEntry.TextStyle = fyne.TextStyle{Monospace: true} - logEntry.Wrapping = fyne.TextWrapOff - var logContent string - // Keep selectable/copyable; reject edits so the viewer stays read-only. - logEntry.OnChanged = func(s string) { - if s == logContent { - return + type logRow struct{ stamp, message, full string } + var lines []logRow + logList := widget.NewList( + func() int { return len(lines) }, + func() fyne.CanvasObject { + stamp := widget.NewRichText(&widget.TextSegment{ + Style: widget.RichTextStyle{ + SizeName: theme.SizeNameCaptionText, + TextStyle: fyne.TextStyle{Monospace: true}, + Inline: true, + }, + }) + msg := widget.NewLabel("") + msg.TextStyle = fyne.TextStyle{Monospace: true} + msg.Truncation = fyne.TextTruncateEllipsis + return container.NewBorder(nil, nil, stamp, nil, msg) + }, + func(i widget.ListItemID, o fyne.CanvasObject) { + objs := o.(*fyne.Container).Objects + msg := objs[0].(*widget.Label) + stamp := objs[1].(*widget.RichText) + stamp.Segments[0].(*widget.TextSegment).Text = lines[i].stamp + stamp.Refresh() + msg.SetText(lines[i].message) + }, + ) + var copyHint *widget.Label + // Selecting a row copies the full line. Unselect immediately so the same + // row can be copied again and no stale highlight survives a refresh. + logList.OnSelected = func(i widget.ListItemID) { + if i < len(lines) { + parent.Clipboard().SetContent(lines[i].full) + flashLabelText(copyHint, "Copied line to clipboard", "Click a line to copy it.") } - logEntry.SetText(logContent) + logList.UnselectAll() } + lastSeq := ^uint64(0) refreshLog := func() { - entries := audit.Entries() - var sb strings.Builder + entries, seq := audit.Snapshot() + if seq == lastSeq { + return + } + lastSeq = seq + next := make([]logRow, 0, len(entries)) for _, e := range entries { - sb.WriteString(e.String()) - sb.WriteByte('\n') + next = append(next, logRow{ + stamp: e.Time.Format("2006-01-02 15:04:05"), + message: fmt.Sprintf("[%s] %s %s", e.IP, e.Action, e.Detail), + full: e.String(), + }) } - next := sb.String() - if next == logContent { - return + lines = next + logList.Refresh() + if len(lines) > 0 { + logList.ScrollToBottom() } - logContent = next - logEntry.SetText(logContent) } refreshLog() @@ -2158,12 +2213,16 @@ func showActivityLogDialog(parent fyne.Window) { flashButtonText(copyPathBtn, "Copied", "Copy Path") }) + copyHint = widget.NewLabel("Click a line to copy it.") + copyHint.TextStyle = fyne.TextStyle{Italic: true} + bottomBar := container.NewVBox( + copyHint, container.NewBorder(nil, nil, fileLabel, copyPathBtn), container.NewHBox(layout.NewSpacer(), closeBtn, layout.NewSpacer()), ) - content := container.NewBorder(nil, container.NewPadded(bottomBar), nil, nil, logEntry) + content := container.NewBorder(nil, container.NewPadded(bottomBar), nil, nil, logList) logDialog = dialog.NewCustomWithoutButtons("Activity Log", content, parent) logDialog.SetOnClosed(func() { @@ -2235,7 +2294,7 @@ func startGUI() { if desk, ok := myApp.(desktop.App); ok { trayEnabled = true deskApp = desk - desk.SetSystemTrayIcon(appIcon) + desk.SetSystemTrayIcon(trayIconResource(appIcon)) trayHint.Text = "Runs in the menu bar when this window is closed." } else { trayHint.Text = "Quit from the window close button when tray is unavailable." diff --git a/paths.go b/paths.go new file mode 100644 index 0000000..8b8c655 --- /dev/null +++ b/paths.go @@ -0,0 +1,129 @@ +package main + +import ( + "io" + "log" + "os" + "path/filepath" + "runtime" +) + +// appDirName is the per-platform directory name used under the OS config/log roots. +func appDirName() string { + if runtime.GOOS == "linux" { + return "bitfocus-listener" + } + return "Bitfocus Listener" +} + +// appConfigPath returns the OS-conventional config file location: +// +// macOS ~/Library/Application Support/Bitfocus Listener/config.json +// Windows %APPDATA%\Bitfocus Listener\config.json +// Linux $XDG_CONFIG_HOME/bitfocus-listener/config.json +func appConfigPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + dir = filepath.Join(dir, appDirName()) + if err := os.MkdirAll(dir, 0700); err != nil { + return "", err + } + return filepath.Join(dir, "config.json"), nil +} + +// appLogPath returns the OS-conventional log file location: +// +// macOS ~/Library/Logs/Bitfocus Listener/listener.log (visible in Console.app) +// Windows %LOCALAPPDATA%\Bitfocus Listener\Logs\listener.log +// Linux $XDG_STATE_HOME/bitfocus-listener/listener.log +func appLogPath() (string, error) { + var dir string + switch runtime.GOOS { + case "darwin": + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir = filepath.Join(home, "Library", "Logs", appDirName()) + case "windows": + // UserCacheDir is %LocalAppData% on Windows. + local, err := os.UserCacheDir() + if err != nil { + return "", err + } + dir = filepath.Join(local, appDirName(), "Logs") + default: + state := os.Getenv("XDG_STATE_HOME") + if state == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + state = filepath.Join(home, ".local", "state") + } + dir = filepath.Join(state, appDirName()) + } + if err := os.MkdirAll(dir, 0700); err != nil { + return "", err + } + return filepath.Join(dir, "listener.log"), nil +} + +// legacyHomePath returns the pre-1.1 dotfile location in $HOME, or "" if the +// home directory can't be resolved. +func legacyHomePath(name string) string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, name) +} + +// migrateLegacyFile moves a pre-1.1 dotfile to its new location once. It is a +// no-op when the legacy file is absent or the new file already exists. +func migrateLegacyFile(legacy, current string) { + if legacy == "" { + return + } + if _, err := os.Stat(current); err == nil { + return + } + if _, err := os.Stat(legacy); err != nil { + return + } + if err := os.Rename(legacy, current); err == nil { + log.Printf("Migrated %s to %s", legacy, current) + return + } + // Rename fails across filesystems (e.g. $HOME and the log dir on separate + // volumes); fall back to copy-then-remove. + if err := copyFile(legacy, current); err != nil { + log.Printf("Failed to migrate %s: %v", legacy, err) + return + } + if err := os.Remove(legacy); err != nil { + log.Printf("Migrated %s but could not remove the original: %v", legacy, err) + return + } + log.Printf("Migrated %s to %s", legacy, current) +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) // #nosec G304 -- fixed application-controlled paths + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + os.Remove(dst) + return err + } + return out.Close() +} diff --git a/tray_icon.png b/tray_icon.png new file mode 100644 index 0000000..e6d954e Binary files /dev/null and b/tray_icon.png differ