diff --git a/infra/instance/instance.go b/infra/instance/instance.go new file mode 100644 index 0000000..6e53e57 --- /dev/null +++ b/infra/instance/instance.go @@ -0,0 +1,130 @@ +package instance + +import ( + "bufio" + "log" + "net" + "os" + "strings" + "time" + + "github.com/debrief-dev/debrief/infra/config" +) + +// connTimeout bounds how long an accepted or dialed connection may be idle. +const connTimeout = 2 * time.Second + +// TryAcquire attempts to become the single running instance. +// If another instance is already listening, it sends a "show" command and +// returns (false, nil) — the caller should exit. +// If this is the first instance, it starts a listener and returns +// (true, cleanup) where cleanup closes the listener and removes the socket. +func TryAcquire(windowSignalChan chan<- string) (acquired bool, cleanup func()) { + path := socketPath() + network := socketNetwork() + + // Try to connect to an existing instance. + if sendShow(network, path) { + return false, nil + } + + // No listener found — remove stale socket file and start listening. + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + log.Printf("instance: failed to remove stale socket %s: %v", path, err) + } + + ln, err := net.Listen(network, path) //nolint:noctx // no cancellation needed for a long-lived listener + if err != nil { + // Race: another instance grabbed the socket between our Dial and Listen. + // Retry connecting once. + if sendShow(network, path) { + return false, nil + } + + log.Printf("instance: failed to listen on %s: %v (continuing without single-instance guard)", path, err) + + return true, nil + } + + // Restrict socket permissions to owner only. + if err := os.Chmod(path, config.FilePermissions); err != nil { + log.Printf("instance: failed to chmod socket %s: %v", path, err) + } + + go acceptLoop(ln, windowSignalChan) + + return true, func() { + if err := ln.Close(); err != nil { + log.Printf("instance: failed to close listener: %v", err) + } + + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + log.Printf("instance: failed to remove socket %s: %v", path, err) + } + } +} + +// acceptLoop accepts connections and forwards "show" commands to the signal channel. +func acceptLoop(ln net.Listener, windowSignalChan chan<- string) { + for { + conn, err := ln.Accept() + if err != nil { + // Listener closed (normal shutdown). + return + } + + go handleConn(conn, windowSignalChan) + } +} + +// handleConn reads commands from a connection and forwards them. +func handleConn(conn net.Conn, windowSignalChan chan<- string) { + defer func() { + if err := conn.Close(); err != nil { + log.Printf("instance: failed to close connection: %v", err) + } + }() + + if err := conn.SetDeadline(time.Now().Add(connTimeout)); err != nil { + log.Printf("instance: failed to set connection deadline: %v", err) + return + } + + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + cmd := strings.TrimSpace(scanner.Text()) + if cmd == "show" { + select { + case windowSignalChan <- "show": + default: + // Channel full — a signal is already pending. + } + } + } +} + +// sendShow tries to connect to an existing instance and send a "show" command. +// Returns true if the command was delivered successfully. +func sendShow(network, path string) bool { + dialer := net.Dialer{Timeout: connTimeout} + + conn, err := dialer.Dial(network, path) //nolint:noctx // one-shot connection, no cancellation needed + if err != nil { + return false + } + + defer func() { + if err := conn.Close(); err != nil { + log.Printf("instance: failed to close connection: %v", err) + } + }() + + if err := conn.SetDeadline(time.Now().Add(connTimeout)); err != nil { + log.Printf("instance: failed to set write deadline: %v", err) + return false + } + + _, err = conn.Write([]byte("show\n")) + + return err == nil +} diff --git a/infra/instance/socket_unix.go b/infra/instance/socket_unix.go new file mode 100644 index 0000000..050801b --- /dev/null +++ b/infra/instance/socket_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package instance + +import ( + "log" + "path/filepath" + + "github.com/debrief-dev/debrief/infra/config" +) + +func socketPath() string { + dir, err := config.AppDirectory() + if err != nil { + log.Printf("instance: cannot determine app directory: %v, falling back to /tmp", err) + return "/tmp/debrief.sock" + } + + return filepath.Join(dir, "debrief.sock") +} + +func socketNetwork() string { + return "unix" +} diff --git a/infra/instance/socket_windows.go b/infra/instance/socket_windows.go new file mode 100644 index 0000000..5a59c64 --- /dev/null +++ b/infra/instance/socket_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package instance + +import ( + "log" + "os" + "path/filepath" + + "github.com/debrief-dev/debrief/infra/config" +) + +func socketPath() string { + dir, err := config.AppDirectory() + if err != nil { + log.Printf("instance: cannot determine app directory: %v", err) + return filepath.Join(os.TempDir(), "debrief.sock") + } + + return filepath.Join(dir, "debrief.sock") +} + +func socketNetwork() string { + return "unix" +} diff --git a/main.go b/main.go index da0d7bf..b00629b 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,7 @@ import ( "github.com/debrief-dev/debrief/infra/autostart" "github.com/debrief-dev/debrief/infra/config" "github.com/debrief-dev/debrief/infra/hotkey" + "github.com/debrief-dev/debrief/infra/instance" "github.com/debrief-dev/debrief/infra/platform" "github.com/debrief-dev/debrief/infra/tray" "github.com/debrief-dev/debrief/infra/window" @@ -80,6 +81,17 @@ func main() { log.Printf("Debrief started - version %s", config.AppVersion) } + // Create window signal channel for communication between tray/hotkey and window + // receiver drains faster than human can produce signals - 1 is sufficient + windowSignalChan := make(chan string, 1) + + // Single-instance check: if another instance is running, signal it to show and exit. + acquired, cleanupInstance := instance.TryAcquire(windowSignalChan) + if !acquired { + log.Println("Another instance is already running; signaled it to show. Exiting.") + return + } + // Start pprof server for profiling (only when --pprof flag is passed) if *pprofEnabled { go func() { @@ -91,10 +103,6 @@ func main() { }() } - // Create window signal channel for communication between tray/hotkey and window - // receiver drains faster than human can produce signals - 1 is sufficient - windowSignalChan := make(chan string, 1) - // Quit signal - only true when explicitly quitting from tray shouldQuit := make(chan bool, 1) @@ -214,6 +222,11 @@ func main() { isFirstWindow := true quitApp := func() { + // Clean up single-instance socket before exiting. + if cleanupInstance != nil { + cleanupInstance() + } + // os.Exit does not run deferred functions, so flush the log file // explicitly to avoid losing buffered entries. if logFile != nil {