diff --git a/internal/driver/ioscompanion/usbmux.go b/internal/driver/ioscompanion/usbmux.go
new file mode 100644
index 0000000..e96d526
--- /dev/null
+++ b/internal/driver/ioscompanion/usbmux.go
@@ -0,0 +1,422 @@
+// This file implements the host-to-device TCP forward the device runner needs,
+// natively, against macOS's own usbmuxd. The runner exposes a JSON-RPC server on
+// the device loopback; the host dials it through this forward. usbmuxd is the
+// macOS daemon at /var/run/usbmuxd that already multiplexes every USB device
+// connection; a third-party client (iproxy and the like) is only a thin speaker
+// of the same protocol, so talking to the socket directly keeps the device path
+// dependent on nothing beyond the OS.
+package ioscompanion
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// usbmuxdSocket is the macOS usbmuxd unix socket. It is part of the base OS, not
+// an installed dependency.
+const usbmuxdSocket = "/var/run/usbmuxd"
+
+// usbmux message framing: a 16-byte little-endian header (length including the
+// header, protocol version, payload type, request tag) precedes an XML plist.
+const (
+ usbmuxHeaderLength = 16
+ usbmuxVersion = 1
+ usbmuxPayloadPlist = 8
+ usbmuxTag = 1
+)
+
+// usbmuxDial opens a live byte pipe to devicePort on the device identified by
+// hardwareUDID: it resolves the device's usbmux id, then issues a Connect whose
+// success turns the usbmuxd socket into a raw conduit to that device port. The
+// returned net.Conn is the device side of the runner's TCP server.
+func usbmuxDial(ctx context.Context, hardwareUDID string, devicePort int) (net.Conn, error) {
+ deviceID, err := usbmuxDeviceID(ctx, hardwareUDID)
+ if err != nil {
+ return nil, err
+ }
+ conn, err := dialUsbmuxd(ctx)
+ if err != nil {
+ return nil, err
+ }
+ // usbmux carries the port in network byte order.
+ request, err := encodePlistDict(map[string]any{
+ "MessageType": "Connect",
+ "DeviceID": deviceID,
+ "PortNumber": int(htons(uint16(devicePort))),
+ })
+ if err != nil {
+ conn.Close()
+ return nil, err
+ }
+ if err := writeUsbmuxMessage(conn, request); err != nil {
+ conn.Close()
+ return nil, err
+ }
+ reply, err := readUsbmuxMessage(conn)
+ if err != nil {
+ conn.Close()
+ return nil, err
+ }
+ result, err := parsePlistDict(reply)
+ if err != nil {
+ conn.Close()
+ return nil, err
+ }
+ if number, _ := result["Number"].(int); number != 0 {
+ conn.Close()
+ return nil, fmt.Errorf("usbmux: connect to device port %d failed (result %d)", devicePort, number)
+ }
+ return conn, nil
+}
+
+// usbmuxDeviceID lists attached devices and returns the usbmux id of the one
+// whose serial matches hardwareUDID. usbmux ids are assigned per attachment and
+// can change across reconnects, so it is resolved fresh on every dial.
+func usbmuxDeviceID(ctx context.Context, hardwareUDID string) (int, error) {
+ conn, err := dialUsbmuxd(ctx)
+ if err != nil {
+ return 0, err
+ }
+ defer conn.Close()
+
+ request, err := encodePlistDict(map[string]any{"MessageType": "ListDevices"})
+ if err != nil {
+ return 0, err
+ }
+ if err := writeUsbmuxMessage(conn, request); err != nil {
+ return 0, err
+ }
+ reply, err := readUsbmuxMessage(conn)
+ if err != nil {
+ return 0, err
+ }
+ list, err := parsePlistDict(reply)
+ if err != nil {
+ return 0, err
+ }
+ return selectDeviceID(list, hardwareUDID)
+}
+
+// selectDeviceID picks the usbmux DeviceID for hardwareUDID from a parsed
+// ListDevices reply, matching the serial with dashes and case ignored so the
+// devicectl HardwareUDID (dashed) and the raw USB serial (undashed) both resolve.
+func selectDeviceID(list map[string]any, hardwareUDID string) (int, error) {
+ devices, _ := list["DeviceList"].([]any)
+ target := normalizeSerial(hardwareUDID)
+ for _, entry := range devices {
+ device, ok := entry.(map[string]any)
+ if !ok {
+ continue
+ }
+ properties, _ := device["Properties"].(map[string]any)
+ serial, _ := properties["SerialNumber"].(string)
+ if normalizeSerial(serial) != target {
+ continue
+ }
+ if id, ok := device["DeviceID"].(int); ok {
+ return id, nil
+ }
+ if id, ok := properties["DeviceID"].(int); ok {
+ return id, nil
+ }
+ }
+ return 0, fmt.Errorf("usbmux: device %s not attached", hardwareUDID)
+}
+
+func normalizeSerial(serial string) string {
+ return strings.ToLower(strings.ReplaceAll(serial, "-", ""))
+}
+
+// htons swaps a port to network byte order, as the usbmux Connect PortNumber
+// requires.
+func htons(port uint16) uint16 {
+ return port<<8 | port>>8
+}
+
+func dialUsbmuxd(ctx context.Context) (net.Conn, error) {
+ dialer := net.Dialer{}
+ conn, err := dialer.DialContext(ctx, "unix", usbmuxdSocket)
+ if err != nil {
+ return nil, fmt.Errorf("usbmux: dial %s: %w", usbmuxdSocket, err)
+ }
+ return conn, nil
+}
+
+func writeUsbmuxMessage(conn net.Conn, payload []byte) error {
+ header := make([]byte, usbmuxHeaderLength)
+ binary.LittleEndian.PutUint32(header[0:4], uint32(usbmuxHeaderLength+len(payload)))
+ binary.LittleEndian.PutUint32(header[4:8], usbmuxVersion)
+ binary.LittleEndian.PutUint32(header[8:12], usbmuxPayloadPlist)
+ binary.LittleEndian.PutUint32(header[12:16], usbmuxTag)
+ if _, err := conn.Write(header); err != nil {
+ return fmt.Errorf("usbmux: write header: %w", err)
+ }
+ if _, err := conn.Write(payload); err != nil {
+ return fmt.Errorf("usbmux: write payload: %w", err)
+ }
+ return nil
+}
+
+func readUsbmuxMessage(conn net.Conn) ([]byte, error) {
+ header := make([]byte, usbmuxHeaderLength)
+ if _, err := io.ReadFull(conn, header); err != nil {
+ return nil, fmt.Errorf("usbmux: read header: %w", err)
+ }
+ length := binary.LittleEndian.Uint32(header[0:4])
+ if length < usbmuxHeaderLength {
+ return nil, fmt.Errorf("usbmux: reply length %d shorter than header", length)
+ }
+ payload := make([]byte, length-usbmuxHeaderLength)
+ if _, err := io.ReadFull(conn, payload); err != nil {
+ return nil, fmt.Errorf("usbmux: read payload: %w", err)
+ }
+ return payload, nil
+}
+
+// encodePlistDict renders a flat dict (string or int values) as the XML plist
+// usbmux requests use. Keys are sorted so the output is deterministic.
+func encodePlistDict(fields map[string]any) ([]byte, error) {
+ var buffer bytes.Buffer
+ buffer.WriteString(`` + "\n")
+ buffer.WriteString(`` + "\n")
+ buffer.WriteString(`` + "\n\n")
+ keys := make([]string, 0, len(fields))
+ for key := range fields {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for _, key := range keys {
+ fmt.Fprintf(&buffer, "%s", key)
+ switch value := fields[key].(type) {
+ case string:
+ buffer.WriteString("")
+ xml.EscapeText(&buffer, []byte(value))
+ buffer.WriteString("\n")
+ case int:
+ fmt.Fprintf(&buffer, "%d\n", value)
+ default:
+ return nil, fmt.Errorf("usbmux: unsupported plist value type %T for key %s", value, key)
+ }
+ }
+ buffer.WriteString("\n\n")
+ return buffer.Bytes(), nil
+}
+
+// parsePlistDict parses an XML plist whose root is a dict into a generic map.
+func parsePlistDict(data []byte) (map[string]any, error) {
+ value, err := parsePlist(data)
+ if err != nil {
+ return nil, err
+ }
+ dict, ok := value.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("usbmux: plist root is %T, want dict", value)
+ }
+ return dict, nil
+}
+
+// parsePlist decodes an XML plist into nested map[string]any / []any / string /
+// int / bool values. It covers the element set usbmux replies use.
+func parsePlist(data []byte) (any, error) {
+ decoder := xml.NewDecoder(bytes.NewReader(data))
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ return nil, fmt.Errorf("usbmux: parse plist: %w", err)
+ }
+ if start, ok := token.(xml.StartElement); ok && start.Name.Local == "plist" {
+ return parsePlistChild(decoder)
+ }
+ }
+}
+
+// parsePlistChild reads forward to the next start element and parses it as a
+// value. It is used for the lone child of and of each .
+func parsePlistChild(decoder *xml.Decoder) (any, error) {
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ return nil, err
+ }
+ switch element := token.(type) {
+ case xml.StartElement:
+ return parsePlistElement(decoder, element)
+ case xml.EndElement:
+ return nil, nil
+ }
+ }
+}
+
+func parsePlistElement(decoder *xml.Decoder, start xml.StartElement) (any, error) {
+ switch start.Name.Local {
+ case "dict":
+ return parsePlistDictBody(decoder)
+ case "array":
+ return parsePlistArray(decoder)
+ case "string":
+ return parsePlistText(decoder)
+ case "integer":
+ text, err := parsePlistText(decoder)
+ if err != nil {
+ return nil, err
+ }
+ number, err := strconv.Atoi(strings.TrimSpace(text))
+ if err != nil {
+ return nil, fmt.Errorf("usbmux: parse integer %q: %w", text, err)
+ }
+ return number, nil
+ case "true":
+ return true, decoder.Skip()
+ case "false":
+ return false, decoder.Skip()
+ default:
+ // Unhandled scalar (real, data, date): consume it and report nil so an
+ // unexpected field never aborts parsing the fields that matter.
+ return nil, decoder.Skip()
+ }
+}
+
+func parsePlistDictBody(decoder *xml.Decoder) (map[string]any, error) {
+ result := map[string]any{}
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ return nil, err
+ }
+ switch element := token.(type) {
+ case xml.StartElement:
+ if element.Name.Local != "key" {
+ return nil, fmt.Errorf("usbmux: expected , got <%s>", element.Name.Local)
+ }
+ key, err := parsePlistText(decoder)
+ if err != nil {
+ return nil, err
+ }
+ value, err := parsePlistChild(decoder)
+ if err != nil {
+ return nil, err
+ }
+ result[key] = value
+ case xml.EndElement:
+ return result, nil
+ }
+ }
+}
+
+func parsePlistArray(decoder *xml.Decoder) ([]any, error) {
+ var result []any
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ return nil, err
+ }
+ switch element := token.(type) {
+ case xml.StartElement:
+ value, err := parsePlistElement(decoder, element)
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, value)
+ case xml.EndElement:
+ return result, nil
+ }
+ }
+}
+
+func parsePlistText(decoder *xml.Decoder) (string, error) {
+ var text strings.Builder
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ return "", err
+ }
+ switch element := token.(type) {
+ case xml.CharData:
+ text.Write(element)
+ case xml.EndElement:
+ return text.String(), nil
+ }
+ }
+}
+
+// usbmuxForwarder is the in-process replacement for an iproxy child: it accepts
+// host loopback connections and bridges each to a fresh device-port conduit over
+// usbmux. It satisfies io.Closer so the driver tears it down like any other
+// tunnel handle; closing the listener ends the accept loop and the bridges drain
+// as their copies finish.
+type usbmuxForwarder struct {
+ listener net.Listener
+ dialDevice func(ctx context.Context) (net.Conn, error)
+ ctx context.Context
+}
+
+// startUsbmuxForwarder listens on localAddress and forwards every accepted
+// connection to devicePort on the device, over usbmux.
+func startUsbmuxForwarder(ctx context.Context, hardwareUDID, localAddress string, devicePort int) (*usbmuxForwarder, error) {
+ return startForwarder(ctx, localAddress, func(dialCtx context.Context) (net.Conn, error) {
+ return usbmuxDial(dialCtx, hardwareUDID, devicePort)
+ })
+}
+
+// startForwarder is the seam-friendly core: the device dialer is injected so a
+// test can bridge to an in-process echo server without a real device.
+func startForwarder(ctx context.Context, localAddress string, dialDevice func(context.Context) (net.Conn, error)) (*usbmuxForwarder, error) {
+ listener, err := net.Listen("tcp", localAddress)
+ if err != nil {
+ return nil, fmt.Errorf("usbmux forwarder: listen %s: %w", localAddress, err)
+ }
+ forwarder := &usbmuxForwarder{listener: listener, dialDevice: dialDevice, ctx: ctx}
+ go forwarder.serve()
+ return forwarder, nil
+}
+
+func (f *usbmuxForwarder) serve() {
+ for {
+ hostConn, err := f.listener.Accept()
+ if err != nil {
+ return
+ }
+ go f.bridge(hostConn)
+ }
+}
+
+// bridge connects to the device side, then copies bytes in both directions
+// until either end closes. A failed device dial (the runner not yet listening)
+// closes the host side, which the runner transport reads as a dropped
+// connection and recovers from on its next call.
+func (f *usbmuxForwarder) bridge(hostConn net.Conn) {
+ defer hostConn.Close()
+ deviceConn, err := f.dialDevice(f.ctx)
+ if err != nil {
+ return
+ }
+ defer deviceConn.Close()
+ done := make(chan struct{}, 2)
+ go func() { io.Copy(deviceConn, hostConn); done <- struct{}{} }()
+ go func() { io.Copy(hostConn, deviceConn); done <- struct{}{} }()
+ // One direction ending closes both conns via the defers, which unblocks the
+ // other copy; waiting for one is enough to know the bridge is finished.
+ <-done
+}
+
+func (f *usbmuxForwarder) Close() error {
+ return f.listener.Close()
+}
+
+// startUsbmuxTunnel adapts the forwarder to the driver's startTunnel seam,
+// returning it as an io.Closer.
+func startUsbmuxTunnel(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error) {
+ port, err := strconv.Atoi(devicePort)
+ if err != nil {
+ return nil, fmt.Errorf("usbmux tunnel: device port %q: %w", devicePort, err)
+ }
+ return startUsbmuxForwarder(ctx, hardwareUDID, localAddress, port)
+}
diff --git a/internal/driver/ioscompanion/usbmux_test.go b/internal/driver/ioscompanion/usbmux_test.go
new file mode 100644
index 0000000..5db8196
--- /dev/null
+++ b/internal/driver/ioscompanion/usbmux_test.go
@@ -0,0 +1,228 @@
+package ioscompanion
+
+import (
+ "context"
+ "io"
+ "net"
+ "testing"
+)
+
+func TestHtonsSwapsBytes(t *testing.T) {
+ // 49200 = 0xC030; network order swaps to 0x30C0 = 12480.
+ if got := htons(49200); got != 12480 {
+ t.Fatalf("htons(49200) = %d, want 12480", got)
+ }
+ if got := htons(0x1234); got != 0x3412 {
+ t.Fatalf("htons(0x1234) = %#x, want 0x3412", got)
+ }
+}
+
+func TestEncodePlistDictRoundTrips(t *testing.T) {
+ encoded, err := encodePlistDict(map[string]any{
+ "MessageType": "Connect",
+ "DeviceID": 7,
+ "PortNumber": 12480,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ dict, err := parsePlistDict(encoded)
+ if err != nil {
+ t.Fatalf("parse round-trip: %v", err)
+ }
+ if dict["MessageType"] != "Connect" {
+ t.Fatalf("MessageType = %v, want Connect", dict["MessageType"])
+ }
+ if dict["DeviceID"] != 7 {
+ t.Fatalf("DeviceID = %v, want 7", dict["DeviceID"])
+ }
+ if dict["PortNumber"] != 12480 {
+ t.Fatalf("PortNumber = %v, want 12480", dict["PortNumber"])
+ }
+}
+
+func TestEncodePlistDictEscapesStrings(t *testing.T) {
+ encoded, err := encodePlistDict(map[string]any{"Name": "a & b "})
+ if err != nil {
+ t.Fatal(err)
+ }
+ dict, err := parsePlistDict(encoded)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dict["Name"] != "a & b " {
+ t.Fatalf("Name = %q, want the unescaped original", dict["Name"])
+ }
+}
+
+// listDevicesReply is a representative usbmux ListDevices response with one USB
+// device, matching the shape macOS usbmuxd returns.
+const listDevicesReply = `
+
+
+
+ DeviceList
+
+
+ DeviceID
+ 7
+ MessageType
+ Attached
+ Properties
+
+ ConnectionType
+ USB
+ SerialNumber
+ 00008140-00022C4A3E13001C
+
+
+
+
+`
+
+func TestSelectDeviceIDMatchesDashedSerial(t *testing.T) {
+ list, err := parsePlistDict([]byte(listDevicesReply))
+ if err != nil {
+ t.Fatal(err)
+ }
+ id, err := selectDeviceID(list, "00008140-00022C4A3E13001C")
+ if err != nil {
+ t.Fatalf("select by dashed serial: %v", err)
+ }
+ if id != 7 {
+ t.Fatalf("DeviceID = %d, want 7", id)
+ }
+}
+
+func TestSelectDeviceIDMatchesUndashedSerial(t *testing.T) {
+ list, err := parsePlistDict([]byte(listDevicesReply))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The raw USB serial carries no dash; it must still resolve.
+ id, err := selectDeviceID(list, "0000814000022C4A3E13001C")
+ if err != nil {
+ t.Fatalf("select by undashed serial: %v", err)
+ }
+ if id != 7 {
+ t.Fatalf("DeviceID = %d, want 7", id)
+ }
+}
+
+func TestSelectDeviceIDNotAttached(t *testing.T) {
+ list, err := parsePlistDict([]byte(listDevicesReply))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := selectDeviceID(list, "DEADBEEF-NOTHERE"); err == nil {
+ t.Fatal("a serial not in the list must error")
+ }
+}
+
+func TestParsePlistDictRejectsNonDictRoot(t *testing.T) {
+ arrayRoot := ``
+ if _, err := parsePlistDict([]byte(arrayRoot)); err == nil {
+ t.Fatal("a non-dict plist root must error")
+ }
+}
+
+// TestForwarderBridgesToDevice drives the forwarder end to end against an
+// in-process echo "device": a host connection through the loopback listener must
+// reach the injected device dialer and round-trip bytes.
+func TestForwarderBridgesToDevice(t *testing.T) {
+ deviceListener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer deviceListener.Close()
+ go func() {
+ for {
+ conn, acceptErr := deviceListener.Accept()
+ if acceptErr != nil {
+ return
+ }
+ go io.Copy(conn, conn)
+ }
+ }()
+
+ localAddress, err := pickLoopbackAddress()
+ if err != nil {
+ t.Fatal(err)
+ }
+ forwarder, err := startForwarder(context.Background(), localAddress, func(ctx context.Context) (net.Conn, error) {
+ return net.Dial("tcp", deviceListener.Addr().String())
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer forwarder.Close()
+
+ hostConn, err := net.Dial("tcp", localAddress)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer hostConn.Close()
+
+ message := []byte("ping\n")
+ if _, err := hostConn.Write(message); err != nil {
+ t.Fatal(err)
+ }
+ buffer := make([]byte, len(message))
+ if _, err := io.ReadFull(hostConn, buffer); err != nil {
+ t.Fatal(err)
+ }
+ if string(buffer) != string(message) {
+ t.Fatalf("round-trip = %q, want %q", buffer, message)
+ }
+}
+
+// TestForwarderClosesHostOnDeviceDialFailure asserts the failure path the runner
+// transport relies on: when the device dial fails (the runner not yet
+// listening), the forwarder closes the host side so the caller sees a dropped
+// connection and retries.
+func TestForwarderClosesHostOnDeviceDialFailure(t *testing.T) {
+ localAddress, err := pickLoopbackAddress()
+ if err != nil {
+ t.Fatal(err)
+ }
+ forwarder, err := startForwarder(context.Background(), localAddress, func(ctx context.Context) (net.Conn, error) {
+ return nil, io.ErrUnexpectedEOF
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer forwarder.Close()
+
+ hostConn, err := net.Dial("tcp", localAddress)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer hostConn.Close()
+
+ // The forwarder closes its side after the failed device dial; the read
+ // returns EOF rather than blocking forever.
+ if _, err := io.ReadFull(hostConn, make([]byte, 1)); err == nil {
+ t.Fatal("read must fail once the forwarder closes the host side")
+ }
+}
+
+func TestForwarderCloseStopsAcceptLoop(t *testing.T) {
+ localAddress, err := pickLoopbackAddress()
+ if err != nil {
+ t.Fatal(err)
+ }
+ forwarder, err := startForwarder(context.Background(), localAddress, func(ctx context.Context) (net.Conn, error) {
+ return nil, io.EOF
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := forwarder.Close(); err != nil {
+ t.Fatal(err)
+ }
+ // After Close the listener is gone, so a dial must fail.
+ if conn, dialErr := net.Dial("tcp", localAddress); dialErr == nil {
+ conn.Close()
+ t.Fatal("dial must fail after the forwarder is closed")
+ }
+}