diff --git a/client.go b/client.go new file mode 100644 index 0000000..54f695d --- /dev/null +++ b/client.go @@ -0,0 +1,84 @@ +package main + +import ( + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "time" +) + +// Client communicates with the vmux daemon over a Unix socket. +type Client struct { + sockPath string +} + +// NewClient creates a client targeting the given socket path. +func NewClient(sockPath string) *Client { + return &Client{sockPath: sockPath} +} + +// Send transmits a Request to the daemon and returns the Response. +func (c *Client) Send(req Request) (*Response, error) { + conn, err := net.DialTimeout("unix", c.sockPath, 2*time.Second) + if err != nil { + return nil, fmt.Errorf("dial daemon: %w", err) + } + defer conn.Close() + + conn.SetDeadline(time.Now().Add(5 * time.Second)) + + if err := json.NewEncoder(conn).Encode(req); err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + + var resp Response + if err := json.NewDecoder(conn).Decode(&resp); err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + return &resp, nil +} + +// EnsureDaemon checks if a daemon is already running on sockPath. +// If not, it spawns one in the background and waits until it's reachable. +func EnsureDaemon(sockPath string) error { + // Already running? + conn, err := net.DialTimeout("unix", sockPath, 200*time.Millisecond) + if err == nil { + conn.Close() + return nil + } + + // Spawn daemon in background + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve executable: %w", err) + } + + cmd := exec.Command(exe, "daemon") + cmd.SysProcAttr = newSysProcAttr() + cmd.Stdout = nil + cmd.Stderr = nil + cmd.Stdin = nil + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start daemon: %w", err) + } + + // Detach: don't wait for the child + go cmd.Wait() + + // Retry until daemon is reachable (50ms x 20 = 1s max) + for i := 0; i < 20; i++ { + time.Sleep(50 * time.Millisecond) + conn, err := net.DialTimeout("unix", sockPath, 200*time.Millisecond) + if err == nil { + conn.Close() + return nil + } + } + + return fmt.Errorf("daemon did not start within 1s") +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..94c00cc --- /dev/null +++ b/client_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "encoding/json" + "net" + "path/filepath" + "testing" + "time" + + i3 "go.i3wm.org/i3/v4" +) + +func TestClientSendReceive(t *testing.T) { + d := newTestDaemon(t) + + if err := d.Start(); err != nil { + t.Fatalf("start: %v", err) + } + defer d.Stop() + + // Populate a session + d.registry.Update(SessionInfo{ + PID: 42, + SessionID: "test-sess", + State: "Working", + Cwd: "/tmp/test", + }) + + client := NewClient(d.sockPath) + resp, err := client.Send(Request{Action: "list"}) + if err != nil { + t.Fatalf("send: %v", err) + } + if !resp.OK { + t.Fatalf("resp.OK = false, error = %q", resp.Error) + } + if len(resp.Sessions) != 1 { + t.Fatalf("sessions len = %d, want 1", len(resp.Sessions)) + } + if resp.Sessions[0].SessionID != "test-sess" { + t.Errorf("session_id = %q, want %q", resp.Sessions[0].SessionID, "test-sess") + } +} + +func TestEnsureDaemonAlreadyRunning(t *testing.T) { + d := newTestDaemon(t) + + if err := d.Start(); err != nil { + t.Fatalf("start: %v", err) + } + defer d.Stop() + + // EnsureDaemon should return nil immediately since daemon is already listening + err := EnsureDaemon(d.sockPath) + if err != nil { + t.Errorf("EnsureDaemon should succeed when daemon is running: %v", err) + } +} + +func TestEnsureDaemonNoSocket(t *testing.T) { + dir := t.TempDir() + sockPath := filepath.Join(dir, "nonexistent.sock") + + // EnsureDaemon with no real daemon and a fake executable will fail, + // but we can verify it doesn't panic and returns an error + err := EnsureDaemon(sockPath) + if err == nil { + t.Error("EnsureDaemon should fail when no daemon can be started") + } +} + +func TestClientSendSwitchNoMatch(t *testing.T) { + d := newTestDaemon(t) + + if err := d.Start(); err != nil { + t.Fatalf("start: %v", err) + } + defer d.Stop() + + client := NewClient(d.sockPath) + args := mustMarshalJSON(t, SwitchArgs{Query: "nonexistent"}) + resp, err := client.Send(Request{Action: "switch", Args: args}) + if err != nil { + t.Fatalf("send: %v", err) + } + if resp.OK { + t.Error("expected OK=false for no matching session") + } + if resp.Error == "" { + t.Error("expected error message") + } +} + +func TestClientSendSwitchNoWorkspace(t *testing.T) { + d := newTestDaemon(t) + + if err := d.Start(); err != nil { + t.Fatalf("start: %v", err) + } + defer d.Stop() + + d.registry.Update(SessionInfo{ + PID: 42, + SessionID: "sess-1", + State: "Working", + Cwd: "/tmp/test", + GitBranch: "feat-auth", + Workspace: "", // no workspace + }) + + client := NewClient(d.sockPath) + args := mustMarshalJSON(t, SwitchArgs{Query: "auth"}) + resp, err := client.Send(Request{Action: "switch", Args: args}) + if err != nil { + t.Fatalf("send: %v", err) + } + if resp.OK { + t.Error("expected OK=false when session has no workspace") + } +} + +func TestClientConnectionRefused(t *testing.T) { + dir := t.TempDir() + sockPath := filepath.Join(dir, "dead.sock") + + client := NewClient(sockPath) + _, err := client.Send(Request{Action: "list"}) + if err == nil { + t.Error("expected error when daemon is not running") + } +} + +func TestClientSendSwitchWithMockI3(t *testing.T) { + d := newTestDaemon(t) + d.i3commander = &mockI3Commander{results: []i3.CommandResult{{Success: true}}} + + if err := d.Start(); err != nil { + t.Fatalf("start: %v", err) + } + defer d.Stop() + + d.registry.Update(SessionInfo{ + PID: 42, + SessionID: "sess-1", + State: "Working", + Cwd: "/tmp/vmux-project", + GitBranch: "feat-auth", + Workspace: "3", + }) + + client := NewClient(d.sockPath) + args := mustMarshalJSON(t, SwitchArgs{Query: "auth"}) + resp, err := client.Send(Request{Action: "switch", Args: args}) + if err != nil { + t.Fatalf("send: %v", err) + } + if !resp.OK { + t.Errorf("expected OK=true, got error: %q", resp.Error) + } + + // Verify workspace was switched (SwitchToWorkspace sends "workspace number 3") + mock := d.i3commander.(*mockI3Commander) + want := "workspace number 3" + if mock.lastCommand != want { + t.Errorf("lastCommand = %q, want %q", mock.lastCommand, want) + } +} + +// mockI3Commander is defined in i3bridge_test.go (same package). + +func mustMarshalJSON(t *testing.T, v any) []byte { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return data +} + +// Verify Client respects timeouts on dead connections +func TestClientTimeout(t *testing.T) { + dir := t.TempDir() + sockPath := filepath.Join(dir, "slow.sock") + + // Create a listener that accepts but never responds + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + // Hold the connection open without responding + time.Sleep(10 * time.Second) + conn.Close() + }() + + client := NewClient(sockPath) + _, err = client.Send(Request{Action: "list"}) + if err == nil { + t.Error("expected timeout error") + } +} diff --git a/daemon.go b/daemon.go index 78c1c17..9698799 100644 --- a/daemon.go +++ b/daemon.go @@ -151,6 +151,7 @@ type Daemon struct { procDir string claudeDir string workspaceResolver func(claudePID int) string // nil = no workspace resolution + i3commander I3Commander pollInterval time.Duration stopCh chan struct{} listener net.Listener @@ -169,6 +170,21 @@ func NewDaemon(sockPath, procDir, claudeDir string, labels *LabelStore) *Daemon } } +// InitWorkspaceResolver sets up i3+X11 workspace resolution. +// If i3 or X11 is unavailable, logs a warning and continues without workspace info. +func (d *Daemon) InitWorkspaceResolver(treeProvider I3TreeProvider, x11 X11PIDResolver) { + if treeProvider == nil || x11 == nil { + return + } + d.workspaceResolver = func(claudePID int) string { + termMap, err := BuildTerminalWorkspaceMap(treeProvider, x11) + if err != nil { + return "" + } + return ResolveWorkspace(d.procDir, claudePID, termMap) + } +} + // Start runs the daemon: initial scan, then listens on the Unix socket // and polls for sessions in the background. func (d *Daemon) Start() error { @@ -329,6 +345,32 @@ func (d *Daemon) handleConnection(conn net.Conn) { d.registry.mu.Unlock() writeResponse(conn, Response{OK: true}) + case "switch": + var args SwitchArgs + if err := json.Unmarshal(req.Args, &args); err != nil { + writeResponse(conn, Response{Error: "invalid switch args: " + err.Error()}) + return + } + sessions := d.registry.List() + match := FuzzyMatch(args.Query, sessions) + if match == nil { + writeResponse(conn, Response{Error: "no session matching: " + args.Query}) + return + } + if match.Workspace == "" { + writeResponse(conn, Response{Error: "no workspace for session " + match.SessionID}) + return + } + if d.i3commander == nil { + writeResponse(conn, Response{Error: "i3 commander not available"}) + return + } + if err := SwitchToWorkspace(d.i3commander, match.Workspace); err != nil { + writeResponse(conn, Response{Error: "switch workspace: " + err.Error()}) + return + } + writeResponse(conn, Response{OK: true}) + case "stop": writeResponse(conn, Response{OK: true}) d.Stop() diff --git a/sysattr_linux.go b/sysattr_linux.go new file mode 100644 index 0000000..1168635 --- /dev/null +++ b/sysattr_linux.go @@ -0,0 +1,7 @@ +package main + +import "syscall" + +func newSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setsid: true} +}