Physical-device iOS support (#64) (#66)

* feat(companion): add appState, eraseText, pressKey runner handlers

The Go runner transport already calls these methods; the in-device runner
implemented them only latently. They become load-bearing on the device
path, where the hybrid's legacy-companion fallback is absent. Backward
compatible: the simulator hybrid never calls them.

* 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.

* feat(ioscompanion): runner-only device driver mode

NewDevice reuses Driver with d.companion set to the runner dialed over an
iproxy usbmux tunnel, hybrid=false, runnerClient=nil. The existing accessor
seams then route launch/snapshot/text/gesture to the runner with no new
DeviceDriver methods. Device seams swap clear-state to a devicectl
reinstall, container reset to a warn-once no-op, and paste grant to a no-op.
realSpawnDeviceRunner builds and signs the runner at run time via the App
Store Connect API key (no Xcode UI), caching on a source hash.

* test(ioscompanion): cover device wiring, routing, and shell-out argv

Seam-driven NewDevice wiring + gesture/text routing (asserting no keyboard
HID), devicectl/build/test/iproxy argv builders, xctestrun test-target dict
name parsing, signing-credential env checks, and source-hash cache keying.

* feat(testrun): route physical-device iOS runs to the device driver

Execute resolves a non-simulator iOS target through ios.ResolveDevice into
its hardware UDID and CoreDevice id; buildDriver constructs NewDevice via a
seam instead of rejecting the device. Generalizes the --ios-device and
--ios-app-path help to cover the device path; signing stays env-read, never
a flag.

* feat(doctor): device prereqs replace java/sidecar for ios-device

iosDeviceChecks now verifies devicectl, iproxy on PATH, a connected+paired
device (via ios.ConnectedDevices), and App Store Connect signing creds (via
ioscompanion.VerifyDeviceSigning). The retired JVM sidecar checks stay only
under android.

* feat(conformance): device backend uses iphoneos app and tunnel orphan checks

The device backend now builds via just ios-device, points --ios-app-path at
the Debug-iphoneos bundle, and reinstalls each run for clear-state. The G5
orphan scan replaces the retired sidecar.jar check with lingering iproxy and
device test-without-building sessions (destination platform=iOS,id=).

* feat(folio): device build linking the iosArm64 framework

project.yml selects the Kotlin framework slice by SDK (iosArm64 for
iphoneos, iosSimulatorArm64 for simulator) and links via -framework Shared
on the SDK-conditional search path. New ios-device/test-ios-device recipes
mirror ios/test-ios, signing the Debug-iphoneos build with the .env API key.

* docs(cli): document ios-device doctor checks and the device flags

The --ios-device flag now also selects a connected device; --ios-app-path
covers the device install; the doctor gains an ios-device platform whose
checks are devicectl, iproxy, a paired device, and signing credentials.
Corrects the --clear-data default to true.

* fix(ioscompanion): resolve signing key path to absolute

xcodebuild's -authenticationKeyPath requires an absolute path, but .env
files commonly carry a repo-relative one. Resolve it against the working
directory before the stat so a relative ASC_API_KEY_PATH still signs.

* fix(ioscompanion): re-enable signing for the device runner build

companion/project.yml disables code signing for the simulator build, so
the device build inherited it and produced an unsigned runner that the
device rejected at install (0xe8008018). build-for-testing now forces
CODE_SIGNING_ALLOWED/REQUIRED=YES so automatic provisioning signs it.

* fix(ioscompanion): key the device build cache on signing identity

The cache marker hashed only sources, so switching signing team or key
reused a runner signed with the stale identity, which the device rejects at
install (0xe8008018). Fold team + key id into the cache key so a signing
change forces a rebuild.

* docs(getting-started): document physical iOS device setup

Lists the iproxy requirement and the App Store Connect signing env vars
(SANDERLING_IOS_TEAM, ASC_API_*) a device run needs, plus the
test-ios-device recipe and the doctor check.

* feat(ios): native usbmux client and in-process tunnel forwarder

Talk to macOS usbmuxd directly instead of shelling out to iproxy, so the
device path depends on nothing beyond macOS + Xcode.

* refactor(ios): drive device tunnel via io.Closer seam

Replace the tunnelChild *exec.Cmd and spawnTunnel seam with a tunnel
io.Closer and startTunnel seam backed by the in-process usbmux forwarder.

* refactor(ios): remove iproxy spawn from device runner

* test(ios): cover tunnel close via io.Closer not child process

* feat(doctor): check usbmuxd socket instead of iproxy on PATH

* chore(conformance): drop iproxy orphan check; tunnel is in-process

* docs(ios): device tunnel uses native usbmux, nothing to install

* chore: gitignore the signing keys directory

* feat(folio): add Android launcher icon (black bg, white dot)

* feat(folio): add iOS app icon (black bg, white dot)

* feat(folio): add web favicon (black bg, white dot)

* docs(ioscompanion): fix stale const comments

* refactor(ioscompanion): inline single-use devicectl argv builders

* refactor(ioscompanion): inline xcodegenArgs, drop tautological argv tests

* refactor(ioscompanion): inline firstNonEmpty

* refactor(doctor): dedup usbmuxd socket path via ioscompanion seam

* test(doctor): trim redundant signing-check test

* refactor(ioscompanion): deliver COMPANION_PORT via TEST_RUNNER_ env

* fix(testrun): seam preflight so iOS routing tests pass on CI without xcrun
This commit is contained in:
pj authored and GitHub committed 2026-06-09 18:38:52 +05:30
1 parent e04631d4c9
commit 90224dfd06
35 files changed
+2377 -88

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
// This file implements the physical-device mode of Driver. The device is driven
// runner-only: the in-device XCUITest runner serves every capability over a
// usbmux tunnel, with no legacy companion. The simulator hybrid path is left
// byte-identical; device mode swaps three sim-only seams (reinstall, container
// reset, paste grant) and brings the runner up over the tunnel instead of a
// local listener.
package ioscompanion
import (
"context"
"errors"
"fmt"
"io"
"net"
"os/exec"
"strings"
"time"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
)
// DeviceOptions configures a device-mode Driver. Signing credentials are not
// carried here: realSpawnDeviceRunner reads them from the environment at the
// point of use so secrets never reach the Options struct or run artifacts.
type DeviceOptions struct {
// HardwareUDID feeds xcodebuild -destination and the usbmux device match.
HardwareUDID string
// CoreDeviceID feeds devicectl install/uninstall.
CoreDeviceID string
// BundleID is the app under test.
BundleID string
// AppPath is the .app bundle installed via devicectl for clear-state.
AppPath string
// Output receives the runner session log path and driver warnings.
Output io.Writer
// DoubleTapGapMilliseconds overrides the synthesized double-tap gap.
DoubleTapGapMilliseconds float64
// Test seams. Production leaves them nil and NewDevice wires the real
// build/spawn/tunnel/dial.
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
dialRunner func(address string) (transport.Companion, error)
pickAddress func() (string, error)
}
// deviceStartupTimeout bounds the runner's startup once its hosting test
// session is spawned and the tunnel is up. The session's cold start on a
// physical device is slower than the simulator's, and the build that precedes
// it runs outside this window (under the process context, not the startup one).
const deviceStartupTimeout = 180 * time.Second
// NewDevice brings up a runner-only Driver against a physical device: it builds
// and spawns the in-device runner, opens a usbmux tunnel to it, dials the runner
// over the tunnel, health-probes it, and caches the screen dimensions. Call
// Close when done to stop the runner session and the tunnel.
func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
if options.HardwareUDID == "" {
return nil, errors.New("ios device: HardwareUDID is required")
}
if options.CoreDeviceID == "" {
return nil, errors.New("ios device: CoreDeviceID is required")
}
output := options.Output
if output == nil {
output = io.Discard
}
gap := options.DoubleTapGapMilliseconds
if gap <= 0 {
gap = DefaultDoubleTapGapMilliseconds
}
d := &Driver{
udid: options.HardwareUDID,
coreDeviceID: options.CoreDeviceID,
bundleID: options.BundleID,
appPath: options.AppPath,
output: output,
doubleTapGapMilliseconds: gap,
deviceMode: true,
hybrid: false,
spawnRunner: options.spawnRunner,
startTunnel: options.startTunnel,
}
if d.spawnRunner == nil {
d.spawnRunner = d.realSpawnDeviceRunner
}
if d.startTunnel == nil {
d.startTunnel = startUsbmuxTunnel
}
d.dialRunner = options.dialRunner
if d.dialRunner == nil {
d.dialRunner = func(address string) (transport.Companion, error) {
return transport.DialRunner(address, d.udid, d.bundleID)
}
}
if options.pickAddress != nil {
d.pickDeviceAddress = options.pickAddress
} else {
d.pickDeviceAddress = pickLoopbackAddress
}
// Device seams: clear-state reinstalls via devicectl; the container reset and
// paste grant are simulator-only and become no-ops. The runner types
// natively, so no paste prompt is ever hit.
d.reinstallApp = d.devicectlReinstall
d.resetContainer = d.deviceResetContainerUnsupported
d.grantPaste = func(context.Context) error { return nil }
d.restart = d.respawnDevice
d.processContext, d.processCancel = context.WithCancel(ctx)
if err := d.bringUpDevice(ctx); err != nil {
d.Close()
return nil, err
}
description, err := d.companion.Describe(ctx)
if err != nil {
d.Close()
return nil, fmt.Errorf("describe device: %w", err)
}
d.screenWidth = description.WidthPoints
d.screenHeight = description.HeightPoints
return d, nil
}
// bringUpDevice builds (if needed) and spawns the in-device runner, opens the
// tunnel, waits for the forwarded listener, dials the runner, and confirms
// health. The build runs inside spawnRunner under the process context, so the
// startup timeout only bounds the post-spawn wait, not the build.
func (d *Driver) bringUpDevice(ctx context.Context) error {
address, err := d.pickDeviceAddress()
if err != nil {
return err
}
_, port, err := net.SplitHostPort(address)
if err != nil {
return err
}
d.runnerAddress = address
// The runner listens on the device loopback at the same port number the host
// tunnel forwards from, so one picked free port covers both ends.
runnerChild, err := d.spawnRunner(d.processContext, address)
if err != nil {
return fmt.Errorf("spawn device runner: %w", err)
}
d.runnerChild = runnerChild
// The forwarder listens on the host loopback port and bridges to the same
// port number on the device, where the runner listens.
tunnel, err := d.startTunnel(d.processContext, d.udid, address, port)
if err != nil {
d.stopRunnerChild()
return fmt.Errorf("start tunnel: %w", err)
}
d.tunnel = tunnel
startupCtx, cancel := context.WithTimeout(ctx, deviceStartupTimeout)
defer cancel()
if err := waitForListener(startupCtx, address); err != nil {
d.stopTunnel()
d.stopRunnerChild()
return fmt.Errorf("device runner listener: %w", err)
}
companion, err := d.dialRunner(address)
if err != nil {
d.stopTunnel()
d.stopRunnerChild()
return fmt.Errorf("dial device runner: %w", err)
}
d.companion = companion
if err := d.waitForHealth(startupCtx); err != nil {
_ = companion.Close()
d.stopTunnel()
d.stopRunnerChild()
return fmt.Errorf("device runner health: %w", err)
}
return nil
}
// respawnDevice is the device-path supervision restart: it tears down the runner
// transport, its hosting session, and the tunnel, then brings a fresh set up.
// Both the session and the tunnel restart together because a dropped usbmux
// connection can take either down.
func (d *Driver) respawnDevice(ctx context.Context) error {
if d.companion != nil {
_ = d.companion.Close()
}
d.stopRunnerChild()
d.stopTunnel()
return d.bringUpDevice(ctx)
}
// devicectlReinstall uninstalls then installs the app bundle via devicectl,
// keyed on the CoreDevice id. App lifecycle stays with devicectl: the runner's
// own install path is simulator-specific.
func (d *Driver) devicectlReinstall(ctx context.Context) error {
_ = exec.CommandContext(ctx, "xcrun", "devicectl", "device", "uninstall", "app", "--device", d.coreDeviceID, d.bundleID).Run()
output, err := exec.CommandContext(ctx, "xcrun", "devicectl", "device", "install", "app", "--device", d.coreDeviceID, d.appPath).CombinedOutput()
if err != nil {
return fmt.Errorf("devicectl install: %w: %s", err, strings.TrimSpace(string(output)))
}
return nil
}
// deviceResetContainerUnsupported warns once that device clear-state needs an
// app path for a devicectl reinstall: there is no simulator-style data-container
// wipe on a physical device.
func (d *Driver) deviceResetContainerUnsupported(context.Context) error {
if !d.clearStateWarned {
fmt.Fprintln(d.output, "clear-state on a physical device requires --ios-app-path for a reinstall; skipping (state not cleared)")
d.clearStateWarned = true
}
return nil
}
+197
View File
@@ -0,0 +1,197 @@
package ioscompanion
import (
"bytes"
"context"
"io"
"net"
"os/exec"
"testing"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
)
// startLoopbackListener accepts and immediately closes connections so
// waitForListener succeeds against a real address without a real runner.
func startLoopbackListener(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { listener.Close() })
go func() {
for {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
_ = conn.Close()
}
}()
return listener.Addr().String()
}
func testDeviceOptions(address string, companion transport.Companion) DeviceOptions {
return DeviceOptions{
HardwareUDID: "00008140-HW",
CoreDeviceID: "CORE-1",
BundleID: "com.example.app",
Output: &bytes.Buffer{},
spawnRunner: func(context.Context, string) (*exec.Cmd, error) { return &exec.Cmd{}, nil },
startTunnel: func(context.Context, string, string, string) (io.Closer, error) { return io.NopCloser(nil), nil },
dialRunner: func(string) (transport.Companion, error) { return companion, nil },
pickAddress: func() (string, error) { return address, nil },
}
}
func newDeviceCompanion() *fakeTextEditingCompanion {
companion := &fakeTextEditingCompanion{}
companion.accessibilityJSON = "[]"
companion.describe = transport.ScreenDescription{WidthPoints: 393, HeightPoints: 852}
return companion
}
func TestNewDeviceWiresRunnerOnlyMode(t *testing.T) {
address := startLoopbackListener(t)
companion := newDeviceCompanion()
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
if err != nil {
t.Fatalf("NewDevice: %v", err)
}
defer d.Close()
if !d.deviceMode {
t.Fatal("deviceMode must be true")
}
if d.hybrid {
t.Fatal("device mode must not be hybrid")
}
if d.runnerClient != nil {
t.Fatal("device mode must leave runnerClient nil so InputText avoids the hybrid HID chord")
}
if d.companion != companion {
t.Fatal("d.companion must be the runner dialed over the tunnel")
}
if d.coreDeviceID != "CORE-1" {
t.Fatalf("coreDeviceID = %q, want CORE-1", d.coreDeviceID)
}
if d.screenWidth != 393 || d.screenHeight != 852 {
t.Fatalf("screen = %dx%d, want 393x852", d.screenWidth, d.screenHeight)
}
}
func TestNewDeviceRequiresIdentifiers(t *testing.T) {
if _, err := NewDevice(context.Background(), DeviceOptions{CoreDeviceID: "x"}); err == nil {
t.Fatal("missing HardwareUDID must error")
}
if _, err := NewDevice(context.Background(), DeviceOptions{HardwareUDID: "x"}); err == nil {
t.Fatal("missing CoreDeviceID must error")
}
}
func TestDeviceInputTextUsesNativeEditorNotKeyboardHID(t *testing.T) {
address := startLoopbackListener(t)
companion := newDeviceCompanion()
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
if err != nil {
t.Fatal(err)
}
defer d.Close()
if err := d.InputText(context.Background(), "héllo 🌟"); err != nil {
t.Fatal(err)
}
if len(companion.inputTexts) != 1 || companion.inputTexts[0] != "héllo 🌟" {
t.Fatalf("inputTexts = %v, want native replace of the text", companion.inputTexts)
}
for _, call := range companion.recorded() {
if call == "hid" {
t.Fatal("device text must go through the native editor, never a keyboard HID chord")
}
}
}
func TestDeviceGesturesRouteTouchHIDToRunner(t *testing.T) {
address := startLoopbackListener(t)
companion := newDeviceCompanion()
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
if err != nil {
t.Fatal(err)
}
defer d.Close()
if err := d.DoubleTap(context.Background(), 10, 20); err != nil {
t.Fatal(err)
}
if indexOf(companion.recorded(), "hid") < 0 {
t.Fatalf("gesture must send touch HID to the runner; got %v", companion.recorded())
}
}
func TestDeviceEraseAndPressKeyRouteThroughEditor(t *testing.T) {
address := startLoopbackListener(t)
companion := newDeviceCompanion()
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
if err != nil {
t.Fatal(err)
}
defer d.Close()
if err := d.EraseText(context.Background(), 4); err != nil {
t.Fatal(err)
}
if err := d.PressKey(context.Background(), "enter"); err != nil {
t.Fatal(err)
}
if len(companion.eraseCounts) != 1 || companion.eraseCounts[0] != 4 {
t.Fatalf("eraseCounts = %v, want [4]", companion.eraseCounts)
}
if len(companion.pressedKeys) != 1 || companion.pressedKeys[0] != "enter" {
t.Fatalf("pressedKeys = %v, want [enter]", companion.pressedKeys)
}
}
func TestDeviceClearStateWithoutAppPathWarnsOnce(t *testing.T) {
output := &bytes.Buffer{}
d := &Driver{output: output, deviceMode: true}
d.resetContainer = d.deviceResetContainerUnsupported
for i := 0; i < 2; i++ {
if err := d.deviceResetContainerUnsupported(context.Background()); err != nil {
t.Fatal(err)
}
}
if got := bytes.Count(output.Bytes(), []byte("requires --ios-app-path")); got != 1 {
t.Fatalf("warning emitted %d times, want once", got)
}
}
type recordingCloser struct{ closed bool }
func (c *recordingCloser) Close() error {
c.closed = true
return nil
}
func TestDeviceCloseStopsRunnerAndTunnel(t *testing.T) {
d := &Driver{output: &bytes.Buffer{}}
runner := exec.Command("sleep", "30")
if err := runner.Start(); err != nil {
t.Fatal(err)
}
tunnel := &recordingCloser{}
d.runnerChild = runner
d.tunnel = tunnel
d.Close()
if d.runnerChild != nil || d.tunnel != nil {
t.Fatal("Close must clear the runner child and the tunnel")
}
if runner.ProcessState == nil {
t.Fatal("Close must reap the runner session child")
}
if !tunnel.closed {
t.Fatal("Close must close the usbmux tunnel")
}
}
@@ -0,0 +1,335 @@
package ioscompanion
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
)
// These env vars name the signing inputs so the account-specific team id is
// never committed. The App Store Connect API key path/id/issuer come from the
// same source. They back the no-Xcode-UI signing path.
const (
envTeam = "SANDERLING_IOS_TEAM"
envTeamFallback = "DEVELOPMENT_TEAM"
envAuthKeyPath = "ASC_API_KEY_PATH"
envAuthKeyID = "ASC_API_KEY_ID"
envAuthIssuer = "ASC_API_ISSUER_ID"
envCompanionDir = "SANDERLING_COMPANION_DIR"
)
// deviceRunnerScheme and deviceRunnerProject mirror companion/project.yml. The
// build product is the runner whose xctestrun the test session consumes.
const (
deviceRunnerScheme = "CompanionRunner"
deviceRunnerProject = "CompanionRunner.xcodeproj"
)
// signingCredentials carries the no-UI signing inputs read from the environment.
type signingCredentials struct {
team string
authKeyPath string
authKeyID string
authIssuerID string
}
// readSigningCredentials gathers the signing inputs from the environment and
// reports every missing one at once. The .p8 key must exist on disk.
func readSigningCredentials() (signingCredentials, error) {
team := os.Getenv(envTeam)
if team == "" {
team = os.Getenv(envTeamFallback)
}
creds := signingCredentials{
team: team,
authKeyPath: os.Getenv(envAuthKeyPath),
authKeyID: os.Getenv(envAuthKeyID),
authIssuerID: os.Getenv(envAuthIssuer),
}
var missing []string
if creds.team == "" {
missing = append(missing, envTeam)
}
if creds.authKeyPath == "" {
missing = append(missing, envAuthKeyPath)
}
if creds.authKeyID == "" {
missing = append(missing, envAuthKeyID)
}
if creds.authIssuerID == "" {
missing = append(missing, envAuthIssuer)
}
if len(missing) > 0 {
return creds, fmt.Errorf("device signing requires environment variables: %s", strings.Join(missing, ", "))
}
// xcodebuild's -authenticationKeyPath demands an absolute path, but .env
// files commonly carry a repo-relative one. Resolve it against the working
// directory before the stat so a relative key still works.
if absolute, err := filepath.Abs(creds.authKeyPath); err == nil {
creds.authKeyPath = absolute
}
if _, err := os.Stat(creds.authKeyPath); err != nil {
return creds, fmt.Errorf("App Store Connect key not found at %s: %w", creds.authKeyPath, err)
}
return creds, nil
}
// VerifyDeviceSigning reports whether the device signing environment is complete
// and the App Store Connect key file exists. The doctor calls it so the device
// preflight surfaces missing credentials before a run reaches the build step.
func VerifyDeviceSigning() error {
_, err := readSigningCredentials()
return err
}
// realSpawnDeviceRunner regenerates the runner project, builds it for the device
// (skipping when the cached build matches the current sources), and spawns the
// test session that hosts the runner on the device, passing the session port via
// TEST_RUNNER_COMPANION_PORT. address carries the host loopback port, reused as
// the device-side COMPANION_PORT.
func (d *Driver) realSpawnDeviceRunner(ctx context.Context, address string) (*exec.Cmd, error) {
_, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
companionDir, err := resolveCompanionDir()
if err != nil {
return nil, err
}
creds, err := readSigningCredentials()
if err != nil {
return nil, err
}
derivedDataPath := filepath.Join(os.TempDir(), "sanderling-device-runner")
if err := os.MkdirAll(derivedDataPath, 0o755); err != nil {
return nil, err
}
if err := d.buildDeviceRunnerIfNeeded(ctx, companionDir, derivedDataPath, creds); err != nil {
return nil, err
}
xctestrunPath, err := locateDeviceXctestrun(derivedDataPath)
if err != nil {
return nil, err
}
logPath := filepath.Join(derivedDataPath, "device-session-"+port+".log")
logFile, err := os.Create(logPath)
if err != nil {
return nil, fmt.Errorf("create device session log: %w", err)
}
args := testWithoutBuildingArgs(xctestrunPath, d.udid, creds)
command := exec.CommandContext(ctx, "xcrun", args...)
command.Stdout = logFile
command.Stderr = logFile
// Minimal environment: the session can echo its environment into the run
// log, so secrets in the parent environment must not reach it. Signing is
// passed by flag (a key-file path), not env. xcodebuild forwards any
// TEST_RUNNER_-prefixed var into the runner with the prefix stripped, so the
// non-secret COMPANION_PORT reaches the device that way instead of a plist
// patch.
command.Env = []string{
"HOME=" + os.Getenv("HOME"),
"PATH=/usr/bin:/bin",
"TMPDIR=" + os.TempDir(),
"TEST_RUNNER_COMPANION_PORT=" + port,
}
command.Cancel = func() error { return command.Process.Signal(syscall.SIGTERM) }
command.WaitDelay = shutdownGrace
startErr := command.Start()
logFile.Close()
if startErr != nil {
return nil, fmt.Errorf("start device session: %w", startErr)
}
fmt.Fprintf(d.output, "device runner session pid=%d port=%s (log: %s)\n", command.Process.Pid, port, logPath)
return command, nil
}
// buildDeviceRunnerIfNeeded regenerates the project and runs build-for-testing,
// skipping the build when a marker recording the current build key already
// matches. The device signature is per-account/per-device, so the build cannot
// be embedded; the stable derivedDataPath makes the build incremental.
func (d *Driver) buildDeviceRunnerIfNeeded(ctx context.Context, companionDir, derivedDataPath string, creds signingCredentials) error {
key, err := buildCacheKey(companionDir, creds)
if err != nil {
return err
}
marker := filepath.Join(derivedDataPath, "device-runner.sha256")
if existing, readErr := os.ReadFile(marker); readErr == nil && string(existing) == key {
fmt.Fprintln(d.output, "device runner build is up to date; skipping build")
return nil
}
if out, genErr := runQuiet(ctx, companionDir, "xcodegen", "--spec", filepath.Join(companionDir, "project.yml")); genErr != nil {
return fmt.Errorf("xcodegen: %w: %s", genErr, strings.TrimSpace(string(out)))
}
projectPath := filepath.Join(companionDir, deviceRunnerProject)
args := buildForTestingArgs(projectPath, derivedDataPath, creds)
fmt.Fprintln(d.output, "building device runner (first run is slow; subsequent runs are cached)")
if out, buildErr := runQuiet(ctx, companionDir, append([]string{"xcrun"}, args...)...); buildErr != nil {
return fmt.Errorf("build-for-testing: %w: %s", buildErr, tailLines(string(out), 20))
}
if err := os.WriteFile(marker, []byte(key), 0o644); err != nil {
return err
}
return nil
}
// buildCacheKey combines the source hash with the signing identity so a changed
// team or key invalidates the cached build. A runner signed with a stale
// identity would otherwise be reused and rejected at install (0xe8008018).
func buildCacheKey(companionDir string, creds signingCredentials) (string, error) {
sources, err := sourceHash(companionDir)
if err != nil {
return "", err
}
sum := sha256.Sum256([]byte(sources + "\x00" + creds.team + "\x00" + creds.authKeyID))
return hex.EncodeToString(sum[:]), nil
}
// buildForTestingArgs builds the runner for a generic device destination, signed
// through the App Store Connect API key with automatic provisioning. A generic
// destination keeps the build off any specific booted device; the wildcard dev
// profile covers every provisioned device in the team.
func buildForTestingArgs(projectPath, derivedDataPath string, creds signingCredentials) []string {
return []string{"xcodebuild", "build-for-testing",
"-project", projectPath,
"-scheme", deviceRunnerScheme,
"-destination", "generic/platform=iOS",
"-derivedDataPath", derivedDataPath,
"-allowProvisioningUpdates",
"-authenticationKeyPath", creds.authKeyPath,
"-authenticationKeyID", creds.authKeyID,
"-authenticationKeyIssuerID", creds.authIssuerID,
// companion/project.yml disables signing for the simulator build; the
// device install rejects an unsigned runner (0xe8008018), so signing is
// re-enabled here and the team drives automatic provisioning.
"CODE_SIGNING_ALLOWED=YES",
"CODE_SIGNING_REQUIRED=YES",
"CODE_SIGN_STYLE=Automatic",
"DEVELOPMENT_TEAM=" + creds.team,
"GENERATE_INFOPLIST_FILE=YES",
}
}
// testWithoutBuildingArgs runs the prebuilt runner's test session on the
// specific device, installing the signed runner via the same automatic
// provisioning the build used.
func testWithoutBuildingArgs(xctestrunPath, hardwareUDID string, creds signingCredentials) []string {
return []string{"xcodebuild", "test-without-building",
"-xctestrun", xctestrunPath,
"-destination", "platform=iOS,id=" + hardwareUDID,
"-allowProvisioningUpdates",
"-authenticationKeyPath", creds.authKeyPath,
"-authenticationKeyID", creds.authKeyID,
"-authenticationKeyIssuerID", creds.authIssuerID,
}
}
// locateDeviceXctestrun finds the device build's xctestrun under the derived
// data products. The name embeds the device SDK version, so it is discovered
// rather than hardcoded.
func locateDeviceXctestrun(derivedDataPath string) (string, error) {
products := filepath.Join(derivedDataPath, "Build", "Products")
entries, err := os.ReadDir(products)
if err != nil {
return "", fmt.Errorf("read build products: %w", err)
}
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".xctestrun") {
return filepath.Join(products, entry.Name()), nil
}
}
return "", fmt.Errorf("no xctestrun under %s", products)
}
// sourceHash digests the runner sources and project spec so a source edit
// invalidates the cached device build. Mirrors the runnerassets checksum reuse.
func sourceHash(companionDir string) (string, error) {
var paths []string
sourcesDir := filepath.Join(companionDir, "Sources")
walkErr := filepath.Walk(sourcesDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
paths = append(paths, path)
}
return nil
})
if walkErr != nil {
return "", walkErr
}
paths = append(paths, filepath.Join(companionDir, "project.yml"))
sort.Strings(paths)
hash := sha256.New()
for _, path := range paths {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
fmt.Fprintf(hash, "%s\n", path)
hash.Write(content)
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// resolveCompanionDir finds the companion source tree: the SANDERLING_COMPANION_DIR
// override if set, otherwise the nearest ancestor of the working directory that
// holds companion/project.yml. The device runner is built from source at run
// time, so the tree must be present (it is, in a source checkout).
func resolveCompanionDir() (string, error) {
if override := os.Getenv(envCompanionDir); override != "" {
if _, err := os.Stat(filepath.Join(override, "project.yml")); err != nil {
return "", fmt.Errorf("%s=%s has no project.yml: %w", envCompanionDir, override, err)
}
return override, nil
}
directory, err := os.Getwd()
if err != nil {
return "", err
}
for {
candidate := filepath.Join(directory, "companion")
if _, statErr := os.Stat(filepath.Join(candidate, "project.yml")); statErr == nil {
return candidate, nil
}
parent := filepath.Dir(directory)
if parent == directory {
break
}
directory = parent
}
return "", fmt.Errorf("companion source tree not found; run from a sanderling checkout or set %s", envCompanionDir)
}
// runQuiet runs a command in dir with the inherited environment and returns its
// combined output. Used for the transient build steps (xcodegen, xcodebuild
// build-for-testing) whose output is surfaced only on failure.
func runQuiet(ctx context.Context, dir string, args ...string) ([]byte, error) {
command := exec.CommandContext(ctx, args[0], args[1:]...)
command.Dir = dir
return command.CombinedOutput()
}
// tailLines returns the last n lines of text, so a long xcodebuild failure log
// surfaces its tail (where the error is) without flooding the run output.
func tailLines(text string, n int) string {
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
if len(lines) <= n {
return strings.Join(lines, "\n")
}
return strings.Join(lines[len(lines)-n:], "\n")
}
@@ -0,0 +1,129 @@
package ioscompanion
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestReadSigningCredentialsReportsMissing(t *testing.T) {
for _, key := range []string{envTeam, envTeamFallback, envAuthKeyPath, envAuthKeyID, envAuthIssuer} {
t.Setenv(key, "")
}
_, err := readSigningCredentials()
if err == nil {
t.Fatal("missing credentials must error")
}
for _, want := range []string{envTeam, envAuthKeyPath, envAuthKeyID, envAuthIssuer} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error should name missing var %q: %v", want, err)
}
}
}
func TestReadSigningCredentialsRejectsMissingKeyFile(t *testing.T) {
t.Setenv(envTeam, "TEAM1")
t.Setenv(envAuthKeyID, "KID")
t.Setenv(envAuthIssuer, "ISS")
t.Setenv(envAuthKeyPath, filepath.Join(t.TempDir(), "absent.p8"))
if _, err := readSigningCredentials(); err == nil {
t.Fatal("a missing .p8 key file must error")
}
}
func TestReadSigningCredentialsAcceptsPresentKey(t *testing.T) {
keyPath := filepath.Join(t.TempDir(), "AuthKey.p8")
if err := os.WriteFile(keyPath, []byte("-----BEGIN PRIVATE KEY-----"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(envTeam, "")
t.Setenv(envTeamFallback, "FALLBACKTEAM")
t.Setenv(envAuthKeyID, "KID")
t.Setenv(envAuthIssuer, "ISS")
t.Setenv(envAuthKeyPath, keyPath)
creds, err := readSigningCredentials()
if err != nil {
t.Fatal(err)
}
if creds.team != "FALLBACKTEAM" {
t.Fatalf("team = %q, want the DEVELOPMENT_TEAM fallback", creds.team)
}
}
func TestReadSigningCredentialsResolvesRelativeKeyPath(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile("AuthKey.p8", []byte("key"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(envTeam, "TEAM1")
t.Setenv(envAuthKeyID, "KID")
t.Setenv(envAuthIssuer, "ISS")
t.Setenv(envAuthKeyPath, "AuthKey.p8")
creds, err := readSigningCredentials()
if err != nil {
t.Fatal(err)
}
if !filepath.IsAbs(creds.authKeyPath) {
t.Fatalf("authKeyPath = %q, want an absolute path for xcodebuild", creds.authKeyPath)
}
}
func TestBuildCacheKeyChangesWithSigningIdentity(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "Sources"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v1"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "project.yml"), []byte("name: x"), 0o644); err != nil {
t.Fatal(err)
}
base, err := buildCacheKey(dir, signingCredentials{team: "TEAM1", authKeyID: "KID1"})
if err != nil {
t.Fatal(err)
}
otherTeam, err := buildCacheKey(dir, signingCredentials{team: "TEAM2", authKeyID: "KID1"})
if err != nil {
t.Fatal(err)
}
otherKey, err := buildCacheKey(dir, signingCredentials{team: "TEAM1", authKeyID: "KID2"})
if err != nil {
t.Fatal(err)
}
if base == otherTeam {
t.Fatal("a changed team must invalidate the cached build")
}
if base == otherKey {
t.Fatal("a changed signing key must invalidate the cached build")
}
}
func TestSourceHashChangesWithSources(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "Sources"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v1"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "project.yml"), []byte("name: x"), 0o644); err != nil {
t.Fatal(err)
}
first, err := sourceHash(dir)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v2"), 0o644); err != nil {
t.Fatal(err)
}
second, err := sourceHash(dir)
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatal("a source edit must change the hash so the cached build is invalidated")
}
}
+22
View File
@@ -125,6 +125,16 @@ type Driver struct {
dialRunner func(address string) (transport.Companion, error)
hybrid bool
// Device-mode fields. On the physical-device path d.companion is the runner
// dialed over a usbmux tunnel, hybrid is false, and runnerClient is nil.
// coreDeviceID feeds devicectl; tunnel is the in-process usbmux forwarder
// bridging the host loopback port to the runner's device-side port.
deviceMode bool
coreDeviceID string
tunnel io.Closer
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
pickDeviceAddress func() (string, error)
// processContext owns the companion child's lifetime: it is derived from
// New's context (so a canceled run still reaps the child) and canceled by
// Close. Spawning under a startup-scoped context would SIGTERM the child
@@ -985,11 +995,23 @@ func (d *Driver) Close() {
d.runnerClient = nil
}
d.stopRunnerChild()
d.stopTunnel()
if d.processCancel != nil {
d.processCancel()
}
}
// stopTunnel closes the in-process usbmux forwarder on the device path. Closing
// its listener ends the accept loop and lets the open bridges drain; a nil
// tunnel (the simulator path) is a no-op.
func (d *Driver) stopTunnel() {
tunnel := d.tunnel
d.tunnel = nil
if tunnel != nil {
_ = tunnel.Close()
}
}
// stopChild terminates the companion child gracefully (SIGTERM, grace window,
// then SIGKILL) so it leaves no orphan behind.
func (d *Driver) stopChild() {
+433
View File
@@ -0,0 +1,433 @@
// 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"
"os"
"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"
// VerifyUsbmuxdSocket reports whether the macOS usbmuxd socket is present. The
// doctor calls it so the device preflight confirms the tunnel's transport
// before a run reaches the build step.
func VerifyUsbmuxdSocket() error {
if _, err := os.Stat(usbmuxdSocket); err != nil {
return fmt.Errorf("usbmuxd socket not found at %s: %w", usbmuxdSocket, err)
}
return nil
}
// 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(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
buffer.WriteString(`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n")
buffer.WriteString(`<plist version="1.0">` + "\n<dict>\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, "<key>%s</key>", key)
switch value := fields[key].(type) {
case string:
buffer.WriteString("<string>")
xml.EscapeText(&buffer, []byte(value))
buffer.WriteString("</string>\n")
case int:
fmt.Fprintf(&buffer, "<integer>%d</integer>\n", value)
default:
return nil, fmt.Errorf("usbmux: unsupported plist value type %T for key %s", value, key)
}
}
buffer.WriteString("</dict>\n</plist>\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 <plist> and of each <key>.
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 <key>, 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)
}
+228
View File
@@ -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 <c>"})
if err != nil {
t.Fatal(err)
}
dict, err := parsePlistDict(encoded)
if err != nil {
t.Fatal(err)
}
if dict["Name"] != "a & b <c>" {
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 = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>DeviceList</key>
<array>
<dict>
<key>DeviceID</key>
<integer>7</integer>
<key>MessageType</key>
<string>Attached</string>
<key>Properties</key>
<dict>
<key>ConnectionType</key>
<string>USB</string>
<key>SerialNumber</key>
<string>00008140-00022C4A3E13001C</string>
</dict>
</dict>
</array>
</dict>
</plist>`
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 := `<?xml version="1.0"?><plist version="1.0"><array></array></plist>`
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")
}
}