From 6a34ce52c06cd71600a4901fa1fbb6e0405be4c7 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 8 Jun 2026 23:07:31 +0530 Subject: [PATCH] feat(ios): resolve physical devices from devicectl ResolveDevice parses xcrun devicectl list devices into Device{Name, HardwareUDID, CoreDeviceID}: the hardware UDID feeds xcodebuild/iproxy and the CoreDevice id feeds devicectl install. Matches by name or either id; errors list candidates on none/ambiguous. Fixes the stale sidecar comment on ResolveTarget. --- internal/ios/device.go | 123 ++++++++++++++++++++++++++++ internal/ios/device_test.go | 158 ++++++++++++++++++++++++++++++++++++ internal/ios/ios.go | 4 +- 3 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 internal/ios/device.go create mode 100644 internal/ios/device_test.go diff --git a/internal/ios/device.go b/internal/ios/device.go new file mode 100644 index 0000000..816b8e2 --- /dev/null +++ b/internal/ios/device.go @@ -0,0 +1,123 @@ +package ios + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" +) + +// Device is a physical iOS device resolved from devicectl. A device carries two +// identifiers: HardwareUDID feeds xcodebuild -destination and iproxy -u, while +// CoreDeviceID feeds devicectl install/uninstall. +type Device struct { + Name string + HardwareUDID string + CoreDeviceID string +} + +// coreDevice mirrors the fields of one entry in devicectl's JSON device list. +type coreDevice struct { + Identifier string `json:"identifier"` + HardwareProperties struct { + UDID string `json:"udid"` + } `json:"hardwareProperties"` + DeviceProperties struct { + Name string `json:"name"` + } `json:"deviceProperties"` +} + +type coreDeviceList struct { + Result struct { + Devices []coreDevice `json:"devices"` + } `json:"result"` +} + +// listDevices is a seam: overridable in tests so ResolveDevice runs against +// canned devicectl output without invoking xcrun. +var listDevices = coreDevices + +// ResolveDevice picks the physical iOS device a run drives. An empty query +// resolves the single connected device (an error names them all when several +// are connected). A non-empty query matches a device by name, hardware UDID, or +// CoreDevice id. No match and an ambiguous match are both errors that list the +// connected devices so the caller can refine --ios-device. +func ResolveDevice(ctx context.Context, query string) (Device, error) { + devices, err := listDevices(ctx) + if err != nil { + return Device{}, fmt.Errorf("list devices: %w", err) + } + if len(devices) == 0 { + return Device{}, fmt.Errorf("no connected iOS device found; connect and pair an iPhone, then check `xcrun devicectl list devices`") + } + + if query == "" { + if len(devices) == 1 { + return devices[0], nil + } + return Device{}, fmt.Errorf("multiple connected iOS devices; pass --ios-device to select one:%s", deviceLines(devices)) + } + + var matches []Device + for _, device := range devices { + if device.Name == query || device.HardwareUDID == query || device.CoreDeviceID == query { + matches = append(matches, device) + } + } + switch len(matches) { + case 1: + return matches[0], nil + case 0: + return Device{}, fmt.Errorf("no connected iOS device matches %q; connected devices:%s", query, deviceLines(devices)) + default: + return Device{}, fmt.Errorf("--ios-device %q matches multiple devices; select by hardware UDID or CoreDevice id:%s", query, deviceLines(matches)) + } +} + +func deviceLines(devices []Device) string { + var lines strings.Builder + for _, device := range devices { + fmt.Fprintf(&lines, "\n %s (udid %s, id %s)", device.Name, device.HardwareUDID, device.CoreDeviceID) + } + return lines.String() +} + +// coreDevices invokes devicectl and parses its JSON device list. devicectl +// writes the JSON to a file path rather than stdout, so a temp file backs the +// --json-output flag and is read back after the command runs. +func coreDevices(ctx context.Context) ([]Device, error) { + outputFile, err := os.CreateTemp("", "sanderling-devices-*.json") + if err != nil { + return nil, err + } + outputPath := outputFile.Name() + outputFile.Close() + defer os.Remove(outputPath) + + if err := exec.CommandContext(ctx, "xcrun", "devicectl", "list", "devices", "--json-output", outputPath).Run(); err != nil { + return nil, fmt.Errorf("xcrun devicectl list devices: %w", err) + } + data, err := os.ReadFile(outputPath) + if err != nil { + return nil, err + } + return parseDevices(data) +} + +func parseDevices(data []byte) ([]Device, error) { + var list coreDeviceList + if err := json.Unmarshal(data, &list); err != nil { + return nil, err + } + var devices []Device + for _, entry := range list.Result.Devices { + devices = append(devices, Device{ + Name: entry.DeviceProperties.Name, + HardwareUDID: entry.HardwareProperties.UDID, + CoreDeviceID: entry.Identifier, + }) + } + return devices, nil +} diff --git a/internal/ios/device_test.go b/internal/ios/device_test.go new file mode 100644 index 0000000..a436ccd --- /dev/null +++ b/internal/ios/device_test.go @@ -0,0 +1,158 @@ +package ios + +import ( + "context" + "errors" + "strings" + "testing" +) + +// devicectlJSON mirrors the shape of `xcrun devicectl list devices +// --json-output` for two connected devices, trimmed to the fields the parser +// reads. +const devicectlJSON = `{ + "info": {"outcome": "success"}, + "result": { + "devices": [ + { + "identifier": "1FB35C36-A358-56F9-AD9F-931DC1C867FF", + "hardwareProperties": {"udid": "00008140-00022C4A3E13001C"}, + "deviceProperties": {"name": "iPhone"} + }, + { + "identifier": "2AC46D47-B469-67A0-BE0A-042ED2D978AA", + "hardwareProperties": {"udid": "00008110-000A1B2C3D4E5F60"}, + "deviceProperties": {"name": "Test iPad"} + } + ] + } +}` + +func swapListDevices(t *testing.T, devices []Device, err error) { + t.Helper() + original := listDevices + t.Cleanup(func() { listDevices = original }) + listDevices = func(context.Context) ([]Device, error) { return devices, err } +} + +func TestParseDevices(t *testing.T) { + got, err := parseDevices([]byte(devicectlJSON)) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("parsed %d devices, want 2", len(got)) + } + want := Device{Name: "iPhone", HardwareUDID: "00008140-00022C4A3E13001C", CoreDeviceID: "1FB35C36-A358-56F9-AD9F-931DC1C867FF"} + if got[0] != want { + t.Fatalf("device[0] = %+v, want %+v", got[0], want) + } +} + +func TestParseDevices_InvalidJSON(t *testing.T) { + if _, err := parseDevices([]byte("not json")); err == nil { + t.Fatal("expected error on malformed JSON") + } +} + +func TestResolveDevice_MatchByName(t *testing.T) { + devices, _ := parseDevices([]byte(devicectlJSON)) + swapListDevices(t, devices, nil) + got, err := ResolveDevice(context.Background(), "iPhone") + if err != nil { + t.Fatal(err) + } + if got.HardwareUDID != "00008140-00022C4A3E13001C" { + t.Fatalf("resolved %+v, want the iPhone", got) + } +} + +func TestResolveDevice_MatchByHardwareUDID(t *testing.T) { + devices, _ := parseDevices([]byte(devicectlJSON)) + swapListDevices(t, devices, nil) + got, err := ResolveDevice(context.Background(), "00008110-000A1B2C3D4E5F60") + if err != nil { + t.Fatal(err) + } + if got.Name != "Test iPad" { + t.Fatalf("resolved %+v, want Test iPad", got) + } +} + +func TestResolveDevice_MatchByCoreDeviceID(t *testing.T) { + devices, _ := parseDevices([]byte(devicectlJSON)) + swapListDevices(t, devices, nil) + got, err := ResolveDevice(context.Background(), "1FB35C36-A358-56F9-AD9F-931DC1C867FF") + if err != nil { + t.Fatal(err) + } + if got.Name != "iPhone" { + t.Fatalf("resolved %+v, want iPhone", got) + } +} + +func TestResolveDevice_EmptyQuerySingleDevice(t *testing.T) { + swapListDevices(t, []Device{{Name: "iPhone", HardwareUDID: "udid-1", CoreDeviceID: "id-1"}}, nil) + got, err := ResolveDevice(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if got.HardwareUDID != "udid-1" { + t.Fatalf("resolved %+v, want the only device", got) + } +} + +func TestResolveDevice_EmptyQueryMultipleErrors(t *testing.T) { + devices, _ := parseDevices([]byte(devicectlJSON)) + swapListDevices(t, devices, nil) + _, err := ResolveDevice(context.Background(), "") + if err == nil { + t.Fatal("expected ambiguity error for multiple devices with no query") + } + for _, want := range []string{"--ios-device", "iPhone", "Test iPad"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("ambiguity error missing %q: %v", want, err) + } + } +} + +func TestResolveDevice_NotFound(t *testing.T) { + devices, _ := parseDevices([]byte(devicectlJSON)) + swapListDevices(t, devices, nil) + _, err := ResolveDevice(context.Background(), "Pixel") + if err == nil { + t.Fatal("expected not-found error") + } + if !strings.Contains(err.Error(), "iPhone") { + t.Errorf("not-found error should list candidates: %v", err) + } +} + +func TestResolveDevice_NoneConnected(t *testing.T) { + swapListDevices(t, nil, nil) + _, err := ResolveDevice(context.Background(), "iPhone") + if err == nil { + t.Fatal("expected error when no device is connected") + } +} + +func TestResolveDevice_ListError(t *testing.T) { + swapListDevices(t, nil, errors.New("devicectl blew up")) + if _, err := ResolveDevice(context.Background(), ""); err == nil { + t.Fatal("expected list error to propagate") + } +} + +func TestResolveDevice_AmbiguousMatch(t *testing.T) { + swapListDevices(t, []Device{ + {Name: "iPhone", HardwareUDID: "udid-a", CoreDeviceID: "id-a"}, + {Name: "iPhone", HardwareUDID: "udid-b", CoreDeviceID: "id-b"}, + }, nil) + _, err := ResolveDevice(context.Background(), "iPhone") + if err == nil { + t.Fatal("expected ambiguous-match error for duplicate names") + } + if !strings.Contains(err.Error(), "udid-a") || !strings.Contains(err.Error(), "udid-b") { + t.Errorf("ambiguous error should list both candidates: %v", err) + } +} diff --git a/internal/ios/ios.go b/internal/ios/ios.go index e24f467..0d9443f 100644 --- a/internal/ios/ios.go +++ b/internal/ios/ios.go @@ -79,8 +79,8 @@ func BootedUDID(ctx context.Context) string { // available) resolves to that simulator. With no query, exactly one booted // simulator resolves to it; multiple booted simulators is an error that lists // them and asks the caller to pass --ios-device. A query that matches no -// simulator resolves as a physical device (udid = query, simulator false) so -// the sidecar path drives it. +// simulator resolves as a physical device (udid = query, simulator false), +// which the caller then resolves through ResolveDevice for the device driver. func ResolveTarget(ctx context.Context, query string) (udid string, isSimulator bool, err error) { if query != "" { booted, err := listBootedAll(ctx)