Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions activation_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions activation_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
117 changes: 88 additions & 29 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
Expand Down Expand Up @@ -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
// -------------------------
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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."
Expand Down
129 changes: 129 additions & 0 deletions paths.go
Original file line number Diff line number Diff line change
@@ -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()
}
Binary file added tray_icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.