diff --git a/.air.toml b/.air.toml deleted file mode 100644 index 3fe1e4f..0000000 --- a/.air.toml +++ /dev/null @@ -1,46 +0,0 @@ -root = "." -testdata_dir = "testdata" -tmp_dir = "tmp" - -[build] - args_bin = ["serve"] - bin = "./tmp/hyprvoice" - cmd = "go install ./cmd/hyprvoice && go build -o ./tmp/hyprvoice ./cmd/hyprvoice" - delay = 100 - exclude_dir = ["assets", "tmp", "vendor", "testdata"] - exclude_file = [] - exclude_regex = ["_test.go"] - exclude_unchanged = false - follow_symlink = false - full_bin = "" - include_dir = [] - include_ext = ["go", "mod", "sum"] - include_file = [] - kill_delay = "1s" - log = "build-errors.log" - poll = false - poll_interval = 0 - post_cmd = [] - pre_cmd = ["./tmp/hyprvoice stop 2>/dev/null || true"] - rerun = true - rerun_delay = 200 - send_interrupt = false - stop_on_error = false - -[color] - app = "" - build = "yellow" - main = "magenta" - runner = "green" - watcher = "cyan" - -[log] - main_only = false - time = false - -[misc] - clean_on_exit = false - -[screen] - clear_on_rebuild = false - keep_scroll = true diff --git a/cmd/hyprvoice/main.go b/cmd/hyprvoice/main.go index f315037..8a04d1f 100644 --- a/cmd/hyprvoice/main.go +++ b/cmd/hyprvoice/main.go @@ -5,6 +5,7 @@ import ( "github.com/leonardotrapani/hyprvoice/internal/bus" "github.com/leonardotrapani/hyprvoice/internal/hotkeydaemon" + "github.com/leonardotrapani/hyprvoice/internal/notify" "github.com/spf13/cobra" ) @@ -32,7 +33,7 @@ func serveCmd() *cobra.Command { Use: "serve", Short: "Run the hotkey daemon", RunE: func(cmd *cobra.Command, args []string) error { - return (hotkeydaemon.New(nil)).Run() + return (hotkeydaemon.New(notify.Desktop{})).Run() }, } } @@ -44,9 +45,9 @@ func toggleCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { resp, err := bus.SendCommand('t') if err != nil { - return fmt.Errorf("failed to toggle: %w", err) + return fmt.Errorf("failed to toggle recording: %w", err) } - fmt.Print(resp) // print STATUS line + fmt.Print(resp) return nil }, } @@ -61,7 +62,7 @@ func statusCmd() *cobra.Command { if err != nil { return fmt.Errorf("failed to get status: %w", err) } - fmt.Print(resp) // print STATUS line + fmt.Print(resp) return nil }, } @@ -76,7 +77,7 @@ func versionCmd() *cobra.Command { if err != nil { return fmt.Errorf("failed to get version: %w", err) } - fmt.Print(resp) // print STATUS proto= line + fmt.Print(resp) return nil }, } @@ -91,7 +92,7 @@ func stopCmd() *cobra.Command { if err != nil { return fmt.Errorf("failed to stop daemon: %w", err) } - fmt.Print(resp) // print OK quitting + fmt.Print(resp) return nil }, } diff --git a/internal/bus/bus.go b/internal/bus/bus.go index d5d500b..32933c6 100644 --- a/internal/bus/bus.go +++ b/internal/bus/bus.go @@ -2,12 +2,15 @@ package bus import ( "bufio" + "fmt" "net" "os" "path/filepath" + "strconv" ) const SockName = "control.sock" +const PidName = "hyprvoice.pid" const ProtoVer = "0.1" // ~/.cache/hyprvoice/control.sock @@ -20,6 +23,16 @@ func SockPath() (string, error) { return filepath.Join(hd, SockName), nil } +// ~/.cache/hyprvoice/hyprvoice.pid +func PidPath() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", err + } + hd := filepath.Join(dir, "hyprvoice") + return filepath.Join(hd, PidName), nil +} + func Listen() (net.Listener, error) { sp, err := SockPath() if err != nil { @@ -55,3 +68,58 @@ func SendCommand(cmd byte) (string, error) { resp, err := bufio.NewReader(c).ReadString('\n') return resp, err } + +func CheckExistingDaemon() error { + pidPath, err := PidPath() + if err != nil { + return err + } + + pidData, err := os.ReadFile(pidPath) + if os.IsNotExist(err) { + return nil // no existing daemon + } + if err != nil { + return err + } + + pid, err := strconv.Atoi(string(pidData)) + if err != nil { + return nil // invalid pid file, assume stale + } + + // Check if process exists + proc, err := os.FindProcess(pid) + if err != nil { + return nil + } + + // Try to signal the process to check if it's alive + if err := proc.Signal(os.Signal(nil)); err != nil { + return nil // process not alive, stale pid file + } + + return fmt.Errorf("daemon already running with PID %d", pid) +} + +func CreatePidFile() error { + pidPath, err := PidPath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(pidPath), 0o700); err != nil { + return err + } + + pid := os.Getpid() + return os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600) +} + +func RemovePidFile() error { + pidPath, err := PidPath() + if err != nil { + return err + } + return os.Remove(pidPath) +} diff --git a/internal/hotkeydaemon/daemon.go b/internal/hotkeydaemon/daemon.go index 9035ba9..7809664 100644 --- a/internal/hotkeydaemon/daemon.go +++ b/internal/hotkeydaemon/daemon.go @@ -2,9 +2,14 @@ package hotkeydaemon import ( "bufio" + "context" "fmt" + "log" "net" + "os" + "os/signal" "sync" + "syscall" "time" "github.com/leonardotrapani/hyprvoice/internal/bus" @@ -15,14 +20,19 @@ type Daemon struct { mu sync.Mutex recording bool notifier notify.Notifier + ctx context.Context + cancel context.CancelFunc } func New(n notify.Notifier) *Daemon { if n == nil { n = notify.Desktop{} } + ctx, cancel := context.WithCancel(context.Background()) return &Daemon{ notifier: n, + ctx: ctx, + cancel: cancel, } } @@ -33,13 +43,54 @@ func (d *Daemon) Rec() bool { } func (d *Daemon) Run() error { + // Check if daemon is already running + if err := bus.CheckExistingDaemon(); err != nil { + return err + } + ln, err := bus.Listen() if err != nil { return err } + defer ln.Close() + + // Create PID file + if err := bus.CreatePidFile(); err != nil { + return fmt.Errorf("failed to create PID file: %w", err) + } + defer bus.RemovePidFile() + + // Set up signal handling for graceful shutdown + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + + go func() { + sig := <-sigCh + log.Printf("Received signal %v, shutting down gracefully", sig) + d.cancel() + }() + + log.Printf("Daemon started, listening on socket") for { + select { + case <-d.ctx.Done(): + log.Printf("Shutdown requested, exiting") + return nil + default: + } + + // Set a timeout for Accept to make it cancellable + if tcpListener, ok := ln.(*net.UnixListener); ok { + tcpListener.SetDeadline(time.Now().Add(100 * time.Millisecond)) + } + c, err := ln.Accept() if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue // timeout, check for shutdown + } + log.Printf("Accept error: %v", err) + time.Sleep(100 * time.Millisecond) continue } go d.handle(c) @@ -49,7 +100,12 @@ func (d *Daemon) Run() error { func (d *Daemon) handle(c net.Conn) { defer c.Close() - line, _ := bufio.NewReader(c).ReadString('\n') + line, err := bufio.NewReader(c).ReadString('\n') + if err != nil { + log.Printf("Client read error: %v", err) + fmt.Fprintf(c, "ERR read_error: %v\n", err) + return + } if len(line) == 0 { fmt.Fprint(c, "ERR empty\n") return @@ -63,18 +119,21 @@ func (d *Daemon) handle(c net.Conn) { case 't': // toggle d.recording = !d.recording d.notifier.RecordingChanged(d.recording) + log.Printf("Recording toggled: %t", d.recording) fmt.Fprintf(c, "STATUS recording=%t\n", d.recording) case 's': // status fmt.Fprintf(c, "STATUS recording=%t\n", d.recording) case 'v': // protocol version fmt.Fprintf(c, "STATUS proto=%s\n", bus.ProtoVer) case 'q': // quit daemon + log.Printf("Shutdown requested") fmt.Fprint(c, "OK quitting\n") go func() { time.Sleep(100 * time.Millisecond) // give time for client to read - panic("daemon exit requested") + d.cancel() // trigger graceful shutdown }() default: + log.Printf("Unknown command: %c", cmd) fmt.Fprintf(c, "ERR unknown=%q\n", cmd) } } diff --git a/internal/hotkeydaemon/daemon_test.go b/internal/hotkeydaemon/daemon_test.go new file mode 100644 index 0000000..c4e07b7 --- /dev/null +++ b/internal/hotkeydaemon/daemon_test.go @@ -0,0 +1,66 @@ +package hotkeydaemon + +import ( + "testing" + "time" + + "github.com/leonardotrapani/hyprvoice/internal/bus" + "github.com/leonardotrapani/hyprvoice/internal/notify" +) + +func TestToggle(t *testing.T) { + // Clean up any existing daemon + bus.RemovePidFile() + + d := New(notify.Nop{}) + + // Start daemon in goroutine + errCh := make(chan error, 1) + go func() { + errCh <- d.Run() + }() + + // Wait for daemon to be ready by trying to connect + maxAttempts := 50 + for i := range maxAttempts { + if _, err := bus.SendCommand('s'); err == nil { + break // daemon is ready + } + if i == maxAttempts-1 { + t.Fatal("daemon failed to start within timeout") + } + time.Sleep(10 * time.Millisecond) + } + + defer func() { + bus.SendCommand('q') + // Wait for daemon to exit + select { + case <-errCh: + case <-time.After(3 * time.Second): + t.Error("daemon did not exit within timeout") + } + }() + + // Test first toggle + if out, err := bus.SendCommand('t'); err != nil { + t.Fatalf("first toggle failed: %v", err) + } else if out != "STATUS recording=true\n" { + t.Fatalf("unexpected first toggle response: %s", out) + } + + if !d.Rec() { + t.Fatalf("state should be true after first toggle") + } + + // Test second toggle + if out, err := bus.SendCommand('t'); err != nil { + t.Fatalf("second toggle failed: %v", err) + } else if out != "STATUS recording=false\n" { + t.Fatalf("unexpected second toggle response: %s", out) + } + + if d.Rec() { + t.Fatalf("state should be false after second toggle") + } +} diff --git a/internal/hotkeydaemon/deamon_test.go b/internal/hotkeydaemon/deamon_test.go deleted file mode 100644 index dd4ca17..0000000 --- a/internal/hotkeydaemon/deamon_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package hotkeydaemon - -import ( - "testing" - "time" - - "github.com/leonardotrapani/hyprvoice/internal/bus" - "github.com/leonardotrapani/hyprvoice/internal/notify" -) - -func TestToggle(t *testing.T) { - d := New(notify.Nop{}) - go d.Run() - time.Sleep(50 * time.Millisecond) // give listener time to start - defer bus.SendCommand('q') - - if out, _ := bus.SendCommand('t'); out != "STATUS recording=true\n" { - t.Fatalf("unexpected: %s", out) - } - if !d.Rec() { - t.Fatalf("state should be true after first toggle") - } - if out, _ := bus.SendCommand('t'); out != "STATUS recording=false\n" { - t.Fatalf("unexpected: %s", out) - } - if d.Rec() { - t.Fatalf("state should be false after second toggle") - } -} diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 31a0e72..bee7b87 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -2,6 +2,7 @@ package notify import ( "fmt" + "log" "os/exec" ) @@ -17,12 +18,18 @@ func (Desktop) RecordingChanged(on bool) { if on { state = "Started" } - exec.Command("notify-send", "-a", "Hyprvoice", - fmt.Sprintf("Hyprvoice: %s Recording", state)).Run() + cmd := exec.Command("notify-send", "-a", "Hyprvoice", + fmt.Sprintf("Hyprvoice: %s Recording", state)) + if err := cmd.Run(); err != nil { + log.Printf("Failed to send notification: %v", err) + } } func (Desktop) Error(msg string) { - exec.Command("notify-send", "-a", "Hyprvoice", "-u", "critical", msg).Run() + cmd := exec.Command("notify-send", "-a", "Hyprvoice", "-u", "critical", msg) + if err := cmd.Run(); err != nil { + log.Printf("Failed to send error notification: %v", err) + } } // Nop is a Notifier that does absolutely nothing.