/ internal / tools / axclient.go
axclient.go
  1  package tools
  2  
  3  import (
  4  	"bufio"
  5  	"context"
  6  	"encoding/json"
  7  	"fmt"
  8  	"io"
  9  	"net"
 10  	"os"
 11  	"os/exec"
 12  	"path/filepath"
 13  	"runtime"
 14  	"sync"
 15  	"sync/atomic"
 16  	"syscall"
 17  	"time"
 18  )
 19  
 20  // AXRequest is a JSON-RPC request sent to ax_server.
 21  type AXRequest struct {
 22  	ID     int64  `json:"id"`
 23  	Method string `json:"method"`
 24  	Params any    `json:"params"`
 25  }
 26  
 27  // AXResponse is a JSON-RPC response from ax_server.
 28  type AXResponse struct {
 29  	ID     int64            `json:"id"`
 30  	Result json.RawMessage  `json:"result,omitempty"`
 31  	Error  *AXError         `json:"error,omitempty"`
 32  }
 33  
 34  // AXError is an error returned by ax_server.
 35  type AXError struct {
 36  	Code    int    `json:"code"`
 37  	Message string `json:"message"`
 38  }
 39  
 40  // SharedAXClient returns the process-wide singleton AXClient.
 41  // Both the tools (accessibility, computer, wait) and daemon permission
 42  // endpoints must use the same instance, because the socket server
 43  // accepts only one client at a time.
 44  func SharedAXClient() *AXClient {
 45  	sharedOnce.Do(func() {
 46  		sharedInstance = &AXClient{}
 47  	})
 48  	return sharedInstance
 49  }
 50  
 51  var (
 52  	sharedOnce     sync.Once
 53  	sharedInstance *AXClient
 54  )
 55  
 56  // AXClient manages a persistent ax_server process and multiplexes
 57  // requests by ID. Multiple goroutines can call Call() concurrently.
 58  //
 59  // Two transport modes:
 60  // - Bundled: ax_server is inside a .app bundle, launched via LaunchServices
 61  //   (`open -a`), communicates over a Unix domain socket. Required for TCC
 62  //   permission attribution on macOS.
 63  // - Fallback: ax_server is a bare binary, launched via exec.Command,
 64  //   communicates over stdin/stdout pipes. Used for dev, npm, and CLI.
 65  type AXClient struct {
 66  	mu      sync.Mutex // guards process lifecycle (start/restart)
 67  	writeMu sync.Mutex // guards writes to ax_server
 68  
 69  	// Transport-agnostic I/O
 70  	writer io.WriteCloser
 71  	nextID atomic.Int64
 72  
 73  	// Process management
 74  	cmd        *exec.Cmd // non-nil in fallback mode
 75  	conn       net.Conn  // non-nil in bundled mode
 76  	bundlePID  int       // ax_server PID in bundled mode (for cleanup)
 77  	started    bool
 78  
 79  	pendingMu sync.Mutex
 80  	pending   map[int64]chan AXResponse
 81  }
 82  
 83  // Ensure starts the ax_server process if not already running.
 84  func (c *AXClient) Ensure(ctx context.Context) error {
 85  	c.mu.Lock()
 86  	defer c.mu.Unlock()
 87  
 88  	if c.started {
 89  		return nil
 90  	}
 91  
 92  	binPath, bundlePath, err := AXServerPaths()
 93  	if err != nil {
 94  		return err
 95  	}
 96  
 97  	c.pending = make(map[int64]chan AXResponse)
 98  
 99  	if bundlePath != "" {
100  		return c.startBundled(ctx, bundlePath)
101  	}
102  	return c.startFallback(binPath)
103  }
104  
105  // startBundled launches ax_server via LaunchServices and connects over Unix socket.
106  func (c *AXClient) startBundled(ctx context.Context, bundlePath string) error {
107  	socketPath := AXSocketPath()
108  
109  	// Try connecting to an existing socket first — ax_server may already be running
110  	// (e.g. from a previous open -a that's still alive).
111  	conn, err := net.Dial("unix", socketPath)
112  	if err != nil {
113  		// Not running or stale socket — clean up and launch fresh
114  		os.Remove(socketPath)
115  
116  		// Launch via open(1) with -n (new instance) — gives ax_server its own TCC
117  		// identity and avoids reusing a stale instance with different --args.
118  		cmd := exec.CommandContext(ctx, "open", "-n", "-a", bundlePath, "--args", "--socket", socketPath)
119  		cmd.Stderr = os.Stderr
120  		if err := cmd.Run(); err != nil {
121  			return fmt.Errorf("ax_server launch: %w", err)
122  		}
123  
124  		// Wait for socket to appear
125  		deadline := time.Now().Add(10 * time.Second)
126  		for time.Now().Before(deadline) {
127  			conn, err = net.Dial("unix", socketPath)
128  			if err == nil {
129  				break
130  			}
131  			time.Sleep(100 * time.Millisecond)
132  		}
133  	}
134  	if conn == nil {
135  		return fmt.Errorf("ax_server: socket not available after 10s at %s", socketPath)
136  	}
137  
138  	c.conn = conn
139  	c.writer = conn
140  	c.started = true
141  
142  	// Find the ax_server PID for cleanup on Close().
143  	// pgrep -f matches the socket path in the command line args.
144  	if out, err := exec.Command("pgrep", "-f", socketPath).Output(); err == nil {
145  		var pid int
146  		if _, err := fmt.Sscanf(string(out), "%d", &pid); err == nil {
147  			c.bundlePID = pid
148  		}
149  	}
150  
151  	// Reader goroutine dispatches responses by ID.
152  	go c.readLoop(conn)
153  
154  	return nil
155  }
156  
157  // startFallback launches ax_server via exec.Command with stdin/stdout pipes.
158  func (c *AXClient) startFallback(binPath string) error {
159  	// Use exec.Command (not CommandContext) — the process lifecycle is managed
160  	// independently of any single request's context.
161  	c.cmd = exec.Command(binPath)
162  	var pipeErr error
163  	c.writer, pipeErr = c.cmd.StdinPipe()
164  	if pipeErr != nil {
165  		return fmt.Errorf("ax_server stdin pipe: %w", pipeErr)
166  	}
167  	stdout, pipeErr := c.cmd.StdoutPipe()
168  	if pipeErr != nil {
169  		return fmt.Errorf("ax_server stdout pipe: %w", pipeErr)
170  	}
171  	c.cmd.Stderr = os.Stderr
172  
173  	if err := c.cmd.Start(); err != nil {
174  		return fmt.Errorf("ax_server start: %w", err)
175  	}
176  	c.started = true
177  
178  	// Reader goroutine dispatches responses by ID.
179  	go func() {
180  		c.readLoop(stdout)
181  		// Wait for process exit and mark as dead so next Ensure() restarts it.
182  		c.cmd.Wait()
183  		c.mu.Lock()
184  		c.started = false
185  		c.mu.Unlock()
186  	}()
187  
188  	return nil
189  }
190  
191  // readLoop reads NDJSON responses and dispatches them to pending callers.
192  func (c *AXClient) readLoop(reader io.Reader) {
193  	scanner := bufio.NewScanner(reader)
194  	scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
195  	for scanner.Scan() {
196  		var resp AXResponse
197  		if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil {
198  			continue
199  		}
200  		c.pendingMu.Lock()
201  		ch, ok := c.pending[resp.ID]
202  		if ok {
203  			delete(c.pending, resp.ID)
204  		}
205  		c.pendingMu.Unlock()
206  		if ok {
207  			ch <- resp
208  		}
209  	}
210  	// EOF: ax_server died or disconnected — unblock all pending callers
211  	c.pendingMu.Lock()
212  	for id, ch := range c.pending {
213  		ch <- AXResponse{ID: id, Error: &AXError{Code: -1, Message: "ax_server: unexpected EOF"}}
214  		delete(c.pending, id)
215  	}
216  	c.pendingMu.Unlock()
217  
218  	// Mark as not started so next Ensure() reconnects
219  	c.mu.Lock()
220  	c.started = false
221  	c.mu.Unlock()
222  }
223  
224  // Call sends a request and waits for the response.
225  func (c *AXClient) Call(ctx context.Context, method string, params any) (json.RawMessage, error) {
226  	if runtime.GOOS != "darwin" {
227  		return nil, fmt.Errorf("ax_server is macOS-only")
228  	}
229  
230  	if err := c.Ensure(ctx); err != nil {
231  		return nil, err
232  	}
233  
234  	id := c.nextID.Add(1)
235  	req := AXRequest{ID: id, Method: method, Params: params}
236  
237  	// Register pending channel BEFORE writing
238  	ch := make(chan AXResponse, 1)
239  	c.pendingMu.Lock()
240  	c.pending[id] = ch
241  	c.pendingMu.Unlock()
242  
243  	data, _ := json.Marshal(req)
244  	data = append(data, '\n')
245  
246  	c.writeMu.Lock()
247  	n, writeErr := c.writer.Write(data)
248  	if writeErr == nil && n < len(data) {
249  		writeErr = io.ErrShortWrite
250  	}
251  	c.writeMu.Unlock()
252  
253  	if writeErr != nil {
254  		c.pendingMu.Lock()
255  		delete(c.pending, id)
256  		c.pendingMu.Unlock()
257  		return nil, fmt.Errorf("ax_server write: %w", writeErr)
258  	}
259  
260  	select {
261  	case resp := <-ch:
262  		if resp.Error != nil {
263  			return nil, fmt.Errorf("ax_server: %s", resp.Error.Message)
264  		}
265  		return resp.Result, nil
266  	case <-ctx.Done():
267  		c.pendingMu.Lock()
268  		delete(c.pending, id)
269  		c.pendingMu.Unlock()
270  		return nil, ctx.Err()
271  	}
272  }
273  
274  // Close terminates the ax_server process and cleans up resources.
275  func (c *AXClient) Close() {
276  	c.mu.Lock()
277  	defer c.mu.Unlock()
278  	if c.conn != nil {
279  		// Bundled mode: c.writer == c.conn, so only close once.
280  		c.conn.Close()
281  		c.conn = nil
282  		c.writer = nil
283  		os.Remove(AXSocketPath())
284  	} else if c.writer != nil {
285  		// Fallback mode: close stdin pipe.
286  		c.writer.Close()
287  	}
288  	// Bundled mode: kill the LaunchServices-launched ax_server process.
289  	// Closing the socket causes ax_server to exit on its own (it exits
290  	// after the sole client disconnects), but SIGTERM is a safety net.
291  	if c.bundlePID > 0 {
292  		if proc, err := os.FindProcess(c.bundlePID); err == nil {
293  			proc.Signal(syscall.SIGTERM)
294  		}
295  		c.bundlePID = 0
296  	}
297  	// Fallback mode: kill the subprocess
298  	if c.cmd != nil && c.cmd.Process != nil {
299  		c.cmd.Process.Kill()
300  		c.cmd.Wait()
301  	}
302  	c.started = false
303  }
304  
305  // AXSocketPath returns the Unix socket path for bundled mode.
306  func AXSocketPath() string {
307  	tmpDir := os.TempDir()
308  	return filepath.Join(tmpDir, fmt.Sprintf("run.shannon.shanclaw.ax-server.%d.sock", os.Getpid()))
309  }
310  
311  // AXServerPaths returns the binary path and (optionally) the .app bundle path.
312  // If bundlePath is non-empty, use LaunchServices + socket mode.
313  // If bundlePath is empty, use exec.Command + stdin/stdout with binPath.
314  func AXServerPaths() (binPath, bundlePath string, err error) {
315  	exe, exeErr := os.Executable()
316  	if exeErr == nil {
317  		dir := filepath.Dir(exe)
318  
319  		// Bundled: nested app inside engine helper's Helpers/
320  		bp := filepath.Join(dir, "..", "Helpers", "Kocoro AX.app")
321  		bin := filepath.Join(bp, "Contents", "MacOS", "ax_server")
322  		if _, err := os.Stat(bin); err == nil {
323  			return bin, bp, nil
324  		}
325  
326  		// Flat: same directory as shan binary
327  		p := filepath.Join(dir, "ax_server")
328  		if _, err := os.Stat(p); err == nil {
329  			return p, "", nil
330  		}
331  
332  		// npm: bin/ax_server
333  		p = filepath.Join(dir, "bin", "ax_server")
334  		if _, err := os.Stat(p); err == nil {
335  			return p, "", nil
336  		}
337  	}
338  
339  	// Development: relative to working directory
340  	p := filepath.Join("internal", "tools", "axserver", ".build", "debug", "ax_server")
341  	if _, err := os.Stat(p); err == nil {
342  		return p, "", nil
343  	}
344  
345  	return "", "", fmt.Errorf("ax_server binary not found")
346  }