diff --git a/cli/agent.go b/cli/agent.go index 7242f1a..8de4af8 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -16,8 +16,10 @@ import ( const ( agentVersionIOS = "0.0.20" + agentVersionTVOS = "0.0.20" agentVersionAndroid = "1.2.4" iosRunnerBundleID = "com.mobilenext.devicekit-iosUITests.xctrunner" + tvosRunnerBundleID = "com.mobilenext.devicekit-tvosUITests.xctrunner" androidPackageName = "com.mobilenext.devicekit" ) @@ -26,7 +28,14 @@ var agentChecksums = map[string]string{ "devicekit-ios-Sim-arm64.zip": "8040f4918892f63d79713b5824184ac5f296c5ec9b23266c25af34777550f28c", "devicekit-ios-Sim-x86_64.zip": "78a8f2d208a22523efbaa5cb2a735557e807f877bb8ec1a1c31c886f2e425684", "devicekit-ios-runner.ipa": "f5fe88d4169c39001ed012101651c5ac00e8ab54aefb72c74455e7037c2e8205", - "devicekit.apk": "63b1111fbd3b986c7452bc7c28150b1e9c0d611b2ecd7f6917a0f50a84d0836b", + // tvOS simulator runner is published from the same devicekit-ios release. + // Update this checksum whenever the tvOS runner artifact is rebuilt/republished. + "devicekit-tvos-Sim-arm64.zip": "49061f17046055c7e89dbf27067a59ab0bffd9d7d14d63031d5411c5963ccb81", + // Real Apple TV runner IPA, re-signed at install time with a provisioning profile. + // Placeholder until the tvOS runner IPA is published from a devicekit-ios release; + // replace with the released artifact's SHA-256 when available. + "devicekit-tvos-runner.ipa": "0000000000000000000000000000000000000000000000000000000000000000", + "devicekit.apk": "63b1111fbd3b986c7452bc7c28150b1e9c0d611b2ecd7f6917a0f50a84d0836b", } type agentMessageResponse struct { @@ -39,8 +48,9 @@ type agentInfo struct { } type agentStatusResponse struct { - Message string `json:"message"` - Agent agentInfo `json:"agent"` + Message string `json:"message"` + Agent agentInfo `json:"agent"` + Reachable *bool `json:"reachable,omitempty"` } var agentCmd = &cobra.Command{ @@ -75,11 +85,27 @@ var agentStatusCmd = &cobra.Command{ Version: agent.Version, BundleID: agent.PackageName, }, + Reachable: tvosAgentReachable(device), })) return nil }, } +// tvosAgentReachable returns a pointer to the runner-session reachability for real +// tvOS devices (installed + reachable over the tunnel), or nil for other devices +// so the field is omitted and existing response shapes are preserved. +func tvosAgentReachable(device devices.ControllableDevice) *bool { + if device.Platform() != "tvos" || device.DeviceType() != "real" { + return nil + } + pinger, ok := device.(interface{ TVOSAgentReachable() bool }) + if !ok { + return nil + } + reachable := pinger.TVOSAgentReachable() + return &reachable +} + var agentInstallCmd = &cobra.Command{ Use: "install", Short: "Install the agent on a device", @@ -130,6 +156,18 @@ var agentInstallCmd = &cobra.Command{ default: return fmt.Errorf("unsupported device type: %s", device.DeviceType()) } + case "tvos": + switch device.DeviceType() { + case "simulator": + installErr = installAgentOnSimulator(device) + case "real": + if agentProvisioningProfile == "" { + return fmt.Errorf("--provisioning-profile is required for real Apple TV devices") + } + installErr = installAgentOnRealTVOS(device) + default: + return fmt.Errorf("unsupported tvOS device type: %s", device.DeviceType()) + } case "android": installErr = installAgentOnAndroid(device) default: @@ -195,6 +233,8 @@ func agentPackageForPlatform(platform string) string { return androidPackageName case "ios": return iosRunnerBundleID + case "tvos": + return tvosRunnerBundleID default: return "" } @@ -206,6 +246,8 @@ func agentVersionForPlatform(platform string) string { return agentVersionAndroid case "ios": return agentVersionIOS + case "tvos": + return agentVersionTVOS default: return "" } @@ -259,8 +301,21 @@ func installAgentOnSimulator(device devices.ControllableDevice) error { arch = "arm64" } - filename := fmt.Sprintf("devicekit-ios-Sim-%s.zip", arch) - agentURL := fmt.Sprintf("https://github.com/mobile-next/devicekit-ios/releases/download/%s/%s", agentVersionIOS, filename) + var filename, version string + switch device.Platform() { + case "tvos": + // The tvOS runner is currently published for Apple Silicon (arm64) only. + if arch != "arm64" { + return fmt.Errorf("the tvOS simulator runner is only available for arm64 (Apple Silicon)") + } + filename = "devicekit-tvos-Sim-arm64.zip" + version = agentVersionTVOS + default: + filename = fmt.Sprintf("devicekit-ios-Sim-%s.zip", arch) + version = agentVersionIOS + } + + agentURL := fmt.Sprintf("https://github.com/mobile-next/devicekit-ios/releases/download/%s/%s", version, filename) tmpDir, err := os.MkdirTemp("", "mobilecli-agent-*") if err != nil { @@ -271,24 +326,102 @@ func installAgentOnSimulator(device devices.ControllableDevice) error { return downloadAndInstallAgent(device, agentURL, filepath.Join(tmpDir, filename), nil) } -func installAgentOnRealIOS(device devices.ControllableDevice) error { - filename := "devicekit-ios-runner.ipa" - agentURL := fmt.Sprintf("https://github.com/mobile-next/devicekit-ios/releases/download/%s/%s", agentVersionIOS, filename) - +// installResignedRunner installs a real-device runner IPA and re-signs it with the +// configured provisioning profile. It supports two sources: +// - a locally built artifact via --agent-path (download + checksum skipped), and +// - the published release artifact (downloaded and checksum-verified). +// +// For tvOS it also caches the signed runner app + a generated .xctestrun so a later +// StartAgent can launch the XCTest session without re-signing. +func installResignedRunner(device devices.ControllableDevice, filename, version string) error { tmpDir, err := os.MkdirTemp("", "mobilecli-agent-*") if err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } defer func() { _ = os.RemoveAll(tmpDir) }() - return downloadAndInstallAgent(device, agentURL, filepath.Join(tmpDir, filename), func(downloaded string) (string, error) { - utils.Verbose("re-signing agent with provisioning profile %s", agentProvisioningProfile) - resignedPath, err := utils.ResignIPA(downloaded, device.ID(), agentProvisioningProfile, "") + localIPA, artifactChecksum, err := resolveRunnerArtifact(tmpDir, filename, version) + if err != nil { + return err + } + + utils.Verbose("re-signing agent with provisioning profile %s", agentProvisioningProfile) + resignedPath, err := utils.ResignIPA(localIPA, device.ID(), agentProvisioningProfile, "") + if err != nil { + return fmt.Errorf("failed to re-sign agent: %w", err) + } + defer func() { _ = os.Remove(resignedPath) }() + + utils.Verbose("installing agent on device %s", device.ID()) + if err := device.InstallApp(resignedPath); err != nil { + return fmt.Errorf("failed to install agent: %w", err) + } + + if device.Platform() == "tvos" { + cacheResignedTVOSRunner(device, resignedPath, artifactChecksum) + } + + return waitForAgentInstalled(device) +} + +// resolveRunnerArtifact returns a local path to the runner IPA and the checksum +// used as a cache key. With --agent-path it uses the local file and skips both the +// download and the pinned checksum verification; otherwise it downloads the +// release artifact and verifies it against the pinned checksum. +func resolveRunnerArtifact(tmpDir, filename, version string) (string, string, error) { + if agentPath != "" { + if _, err := os.Stat(agentPath); err != nil { + return "", "", fmt.Errorf("agent path not found: %s: %w", agentPath, err) + } + utils.Verbose("using local runner artifact %s (download and checksum verification skipped)", agentPath) + checksum, err := utils.SHA256File(agentPath) if err != nil { - return "", fmt.Errorf("failed to re-sign agent: %w", err) + return "", "", fmt.Errorf("failed to compute artifact checksum: %w", err) } - return resignedPath, nil - }) + return agentPath, checksum, nil + } + + agentURL := fmt.Sprintf("https://github.com/mobile-next/devicekit-ios/releases/download/%s/%s", version, filename) + localIPA := filepath.Join(tmpDir, filename) + utils.Verbose("downloading agent from %s", agentURL) + if err := utils.DownloadFile(agentURL, localIPA); err != nil { + return "", "", fmt.Errorf("failed to download agent: %w", err) + } + + expectedHash, ok := agentChecksums[filename] + if !ok { + return "", "", fmt.Errorf("no pinned checksum for %s", filename) + } + actualHash, err := utils.SHA256File(localIPA) + if err != nil { + return "", "", fmt.Errorf("failed to compute checksum: %w", err) + } + if actualHash != expectedHash { + return "", "", fmt.Errorf("checksum mismatch for %s: expected %s, got %s", filename, expectedHash, actualHash) + } + utils.Verbose("checksum verified for %s", filename) + return localIPA, actualHash, nil +} + +// cacheResignedTVOSRunner persists the signed runner app + a generated .xctestrun +// under the mobilecli cache. Cache failures are non-fatal to the install. +func cacheResignedTVOSRunner(device devices.ControllableDevice, resignedPath, artifactChecksum string) { + profileUUID, err := utils.ProvisioningProfileUUID(agentProvisioningProfile) + if err != nil { + utils.Verbose("warning: could not read provisioning profile UUID for cache key: %v", err) + return + } + if _, err := devices.CacheTVOSRunner(device.ID(), resignedPath, artifactChecksum, profileUUID); err != nil { + utils.Verbose("warning: failed to cache tvOS runner: %v", err) + } +} + +func installAgentOnRealIOS(device devices.ControllableDevice) error { + return installResignedRunner(device, "devicekit-ios-runner.ipa", agentVersionIOS) +} + +func installAgentOnRealTVOS(device devices.ControllableDevice) error { + return installResignedRunner(device, "devicekit-tvos-runner.ipa", agentVersionTVOS) } func installAgentOnAndroid(device devices.ControllableDevice) error { @@ -327,10 +460,10 @@ func findInstalledAgent(device devices.ControllableDevice) *devices.InstalledApp } // agentMatchesApp reports whether an installed app's bundle id identifies the agent. -// On iOS the runner bundle id can carry a signing/team prefix when re-signed, so a -// suffix match is used; other platforms require an exact match. +// On iOS and tvOS the runner bundle id can carry a signing/team prefix when re-signed +// for a real device, so a suffix match is used; other platforms require an exact match. func agentMatchesApp(platform, installedPackage, agentPackage string) bool { - if platform == "ios" { + if platform == "ios" || platform == "tvos" { return strings.HasSuffix(installedPackage, agentPackage) } return installedPackage == agentPackage @@ -367,5 +500,6 @@ func init() { agentStatusCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to check") agentUninstallCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to uninstall the agent from") agentInstallCmd.Flags().BoolVar(&agentForce, "force", false, "force install even if agent is already installed") - agentInstallCmd.Flags().StringVar(&agentProvisioningProfile, "provisioning-profile", "", "path to a .mobileprovision file to use for re-signing (required for real iOS devices)") + agentInstallCmd.Flags().StringVar(&agentProvisioningProfile, "provisioning-profile", "", "path to a .mobileprovision file to use for re-signing (required for real iOS and tvOS devices)") + agentInstallCmd.Flags().StringVar(&agentPath, "agent-path", "", "path to a locally built runner IPA to install instead of downloading the published release artifact") } diff --git a/cli/agent_test.go b/cli/agent_test.go new file mode 100644 index 0000000..997a0e4 --- /dev/null +++ b/cli/agent_test.go @@ -0,0 +1,104 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAgentPackageForPlatform(t *testing.T) { + cases := map[string]string{ + "ios": iosRunnerBundleID, + "tvos": tvosRunnerBundleID, + "android": androidPackageName, + "unknown": "", + } + for platform, want := range cases { + if got := agentPackageForPlatform(platform); got != want { + t.Errorf("agentPackageForPlatform(%q) = %q, want %q", platform, got, want) + } + } +} + +func TestAgentVersionForPlatform(t *testing.T) { + cases := map[string]string{ + "ios": agentVersionIOS, + "tvos": agentVersionTVOS, + "android": agentVersionAndroid, + "unknown": "", + } + for platform, want := range cases { + if got := agentVersionForPlatform(platform); got != want { + t.Errorf("agentVersionForPlatform(%q) = %q, want %q", platform, got, want) + } + } +} + +func TestAgentMatchesApp(t *testing.T) { + // Re-signing a runner for a real device prefixes the bundle id with the team id, + // so iOS and tvOS must match on suffix while other platforms match exactly. + cases := []struct { + name string + platform string + installedPackage string + agentPackage string + want bool + }{ + {"ios exact", "ios", iosRunnerBundleID, iosRunnerBundleID, true}, + {"ios team-prefixed", "ios", "ABCDE12345." + iosRunnerBundleID, iosRunnerBundleID, true}, + {"tvos exact", "tvos", tvosRunnerBundleID, tvosRunnerBundleID, true}, + {"tvos team-prefixed", "tvos", "ABCDE12345." + tvosRunnerBundleID, tvosRunnerBundleID, true}, + {"tvos mismatch", "tvos", "com.example.other", tvosRunnerBundleID, false}, + {"android exact", "android", androidPackageName, androidPackageName, true}, + {"android no suffix match", "android", "prefix." + androidPackageName, androidPackageName, false}, + } + for _, c := range cases { + if got := agentMatchesApp(c.platform, c.installedPackage, c.agentPackage); got != c.want { + t.Errorf("%s: agentMatchesApp(%q, %q, %q) = %v, want %v", + c.name, c.platform, c.installedPackage, c.agentPackage, got, c.want) + } + } +} + +func TestResolveRunnerArtifactUsesLocalPath(t *testing.T) { + // With --agent-path set, resolveRunnerArtifact must skip the download and the + // pinned checksum verification, returning the local path + its checksum. + dir := t.TempDir() + local := filepath.Join(dir, "custom-runner.ipa") + if err := os.WriteFile(local, []byte("hello runner"), 0600); err != nil { + t.Fatalf("failed to write local artifact: %v", err) + } + + agentPath = local + t.Cleanup(func() { agentPath = "" }) + + path, checksum, err := resolveRunnerArtifact(t.TempDir(), "devicekit-tvos-runner.ipa", agentVersionTVOS) + if err != nil { + t.Fatalf("resolveRunnerArtifact returned error: %v", err) + } + if path != local { + t.Errorf("expected local artifact path %q, got %q", local, path) + } + const want = "7d01a911d78908f3f3c466d39a0bd6a5c9ff9e20ab58bfda37909f5fd6f35afd" + if checksum != want { + t.Errorf("expected checksum %q, got %q", want, checksum) + } +} + +func TestResolveRunnerArtifactMissingLocalPath(t *testing.T) { + agentPath = filepath.Join(t.TempDir(), "does-not-exist.ipa") + t.Cleanup(func() { agentPath = "" }) + + if _, _, err := resolveRunnerArtifact(t.TempDir(), "devicekit-tvos-runner.ipa", agentVersionTVOS); err == nil { + t.Fatal("expected error for missing agent-path, got nil") + } +} + +func TestReleasePathRequiresPinnedChecksum(t *testing.T) { + // The release path (no --agent-path) stays checksum-gated: an unknown filename + // has no pinned checksum and must be rejected rather than installed unverified. + agentPath = "" + if _, ok := agentChecksums["devicekit-ios-runner.ipa"]; !ok { + t.Error("expected a pinned checksum for the iOS release runner") + } +} diff --git a/cli/flags.go b/cli/flags.go index 16d1463..430af2a 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -25,6 +25,7 @@ var ( // for agent install command agentForce bool agentProvisioningProfile string + agentPath string // for fleet allocate command fleetType string diff --git a/cli/io.go b/cli/io.go index 9bb8ce6..5f91ec4 100644 --- a/cli/io.go +++ b/cli/io.go @@ -198,6 +198,32 @@ var ioSwipeCmd = &cobra.Command{ }, } +var ( + focusIdentifier string + focusLabel string +) + +var ioFocusCmd = &cobra.Command{ + Use: "focus", + Short: "Focus an element by accessibility identity (tvOS)", + Long: `Drives Siri Remote focus to an on-screen element selected by its accessibility identifier and/or label. At least one of --identifier or --label is required. Real Apple TV only.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + req := commands.FocusRequest{ + DeviceID: deviceId, + Identifier: focusIdentifier, + Label: focusLabel, + } + + response := commands.FocusCommand(req) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + func init() { rootCmd.AddCommand(ioCmd) @@ -205,6 +231,7 @@ func init() { ioCmd.AddCommand(ioTapCmd) ioCmd.AddCommand(ioLongPressCmd) ioCmd.AddCommand(ioButtonCmd) + ioCmd.AddCommand(ioFocusCmd) ioCmd.AddCommand(ioTextCmd) ioCmd.AddCommand(ioKeysCmd) ioCmd.AddCommand(ioSwipeCmd) @@ -214,6 +241,9 @@ func init() { ioLongPressCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to long press on") ioLongPressCmd.Flags().IntVar(&longPressDuration, "duration", 500, "duration of the long press in milliseconds") ioButtonCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to press button on") + ioFocusCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to focus an element on") + ioFocusCmd.Flags().StringVar(&focusIdentifier, "identifier", "", "accessibility identifier of the element to focus") + ioFocusCmd.Flags().StringVar(&focusLabel, "label", "", "accessibility label of the element to focus") ioTextCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to send keys to") ioKeysCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to press keys on") ioSwipeCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to swipe on") diff --git a/commands/focus.go b/commands/focus.go new file mode 100644 index 0000000..6d8aa70 --- /dev/null +++ b/commands/focus.go @@ -0,0 +1,64 @@ +package commands + +import ( + "fmt" + + "github.com/mobile-next/mobilecli/devices" +) + +// FocusRequest represents the parameters for a focus-by-identity command. +type FocusRequest struct { + DeviceID string `json:"deviceId"` + Identifier string `json:"identifier"` + Label string `json:"label"` +} + +// focuser is implemented by devices that support accessibility-identity focus +// (real Apple TV via the DeviceKit device.io.focus RPC over the tunnel transport). +type focuser interface { + Focus(identifier, label string) (any, error) +} + +// FocusResult wraps the focused element returned by the device. +type FocusResult struct { + Element any `json:"element"` +} + +// FocusCommand drives Siri Remote focus to an element selected by accessibility +// identifier and/or label on the specified device. +func FocusCommand(req FocusRequest) *CommandResponse { + if req.Identifier == "" && req.Label == "" { + return NewErrorResponse(fmt.Errorf("at least one of --identifier or --label is required")) + } + + targetDevice, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %v", err)) + } + + focusDevice, ok := targetDevice.(focuser) + if !ok { + return NewErrorResponse(fmt.Errorf("focus by identity is not supported on device %s", targetDevice.ID())) + } + + // device.io.focus drives Siri Remote focus and is only wired for tvOS. Guard + // before starting the agent so a real iPhone fails fast with a clear message + // instead of starting an agent and then failing at the RPC. + if targetDevice.Platform() != "tvos" { + return NewErrorResponse(fmt.Errorf("device.io.focus is only supported on tvOS")) + } + + err = targetDevice.StartAgent(devices.StartAgentConfig{ + Hook: GetShutdownHook(), + }) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to start agent on device %s: %v", targetDevice.ID(), err)) + } + + element, err := focusDevice.Focus(req.Identifier, req.Label) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to focus element on device %s: %v", targetDevice.ID(), err)) + } + + return NewSuccessResponse(FocusResult{Element: element}) +} diff --git a/commands/info.go b/commands/info.go index f890e22..38a2f24 100644 --- a/commands/info.go +++ b/commands/info.go @@ -17,16 +17,19 @@ func InfoCommand(deviceID string) *CommandResponse { return NewErrorResponse(fmt.Errorf("error finding device: %v", err)) } - err = targetDevice.StartAgent(devices.StartAgentConfig{ - Hook: GetShutdownHook(), - }) - if err != nil { - return NewErrorResponse(fmt.Errorf("error starting agent: %v", err)) - } - info, err := targetDevice.Info() if err != nil { - return NewErrorResponse(fmt.Errorf("error getting device info: %v", err)) + err = targetDevice.StartAgent(devices.StartAgentConfig{ + Hook: GetShutdownHook(), + }) + if err != nil { + return NewErrorResponse(fmt.Errorf("error starting agent: %v", err)) + } + + info, err = targetDevice.Info() + if err != nil { + return NewErrorResponse(fmt.Errorf("error getting device info: %v", err)) + } } response := DeviceInfoResponse{ diff --git a/commands/screenshot.go b/commands/screenshot.go index c3d490c..25c7299 100644 --- a/commands/screenshot.go +++ b/commands/screenshot.go @@ -53,18 +53,20 @@ func ScreenshotCommand(req ScreenshotRequest) *CommandResponse { } } - // Start agent if needed - err = targetDevice.StartAgent(devices.StartAgentConfig{ - Hook: GetShutdownHook(), - }) - if err != nil { - return NewErrorResponse(fmt.Errorf("failed to start agent on device %s: %v", targetDevice.ID(), err)) - } - // Take screenshot imageBytes, err := targetDevice.TakeScreenshot() if err != nil { - return NewErrorResponse(fmt.Errorf("error taking screenshot: %v", err)) + err = targetDevice.StartAgent(devices.StartAgentConfig{ + Hook: GetShutdownHook(), + }) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to start agent on device %s: %v", targetDevice.ID(), err)) + } + + imageBytes, err = targetDevice.TakeScreenshot() + if err != nil { + return NewErrorResponse(fmt.Errorf("error taking screenshot: %v", err)) + } } // Convert to JPEG if requested diff --git a/devices/common.go b/devices/common.go index b5a3639..631c1ba 100644 --- a/devices/common.go +++ b/devices/common.go @@ -347,7 +347,7 @@ func GetDeviceInfoList(opts DeviceListOptions) ([]DeviceInfo, error) { // get model for devices model := "" - if d.Platform() == "ios" { + if d.Platform() == "ios" || d.Platform() == "tvos" { if d.DeviceType() == "real" { if iosDevice, ok := d.(*IOSDevice); ok { model = iosDevice.ProductType diff --git a/devices/ios.go b/devices/ios.go index f1e0b3a..e85e963 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -39,6 +39,7 @@ const ( deviceKitAppLaunchTimeout = 5 * time.Second deviceKitBroadcastTimeout = 5 * time.Second agentRunnerBundleID = "com.mobilenext.devicekit-iosUITests.xctrunner" + agentRunnerBundleIDTVOS = "com.mobilenext.devicekit-tvosUITests.xctrunner" ) // deviceInfoCache caches device name and OS version to avoid expensive GetValues() calls @@ -66,12 +67,43 @@ func getDeviceInfoCache() *lru.Cache[string, deviceInfoCacheEntry] { return deviceInfoCache } +func newIOSDevice(udid, deviceName, osVersion, productType string) (IOSDevice, error) { + device := IOSDevice{ + Udid: udid, + DeviceName: deviceName, + OSVersion: osVersion, + ProductType: productType, + } + + tunnelManager, err := ios.NewTunnelManager(udid) + if err != nil { + return IOSDevice{}, fmt.Errorf("failed to create tunnel manager for device %s: %w", udid, err) + } + + device.tunnelManager = tunnelManager + device.wdaClient = wda.NewWdaClient("localhost:8100") + + return device, nil +} + type IOSDevice struct { Udid string `json:"UniqueDeviceID"` DeviceName string `json:"DeviceName"` OSVersion string `json:"Version"` ProductType string `json:"ProductType"` + // CoreDeviceIdentifier is the identifier used by devicectl/Xcode for a + // CoreDevice-discovered device. It is internal; ID()/Udid keep returning the + // public hardware UDID. Empty for go-ios-discovered devices. + CoreDeviceIdentifier string `json:"-"` + // TunnelIP is the CoreDevice tunnel IP address (may be IPv6) used to reach the + // on-device DeviceKit server directly over the paired developer tunnel. + TunnelIP string `json:"-"` + // coreDeviceState caches the state derived from CoreDevice boot/tunnel state. + coreDeviceState string + // isWireless marks a CoreDevice-discovered device reachable over localNetwork. + isWireless bool + mu sync.Mutex // protects fields below tunnelManager *ios.TunnelManager wdaClient *wda.WdaClient @@ -81,12 +113,23 @@ type IOSDevice struct { portForwarderMjpeg *ios.PortForwarder portForwarderDeviceKit *ios.PortForwarder // devicekit http forwarder portForwarderAvc *ios.PortForwarder // devicekit h264 stream forwarder + tvosRunnerCancel context.CancelFunc // cancels an owned xcodebuild runner process } func (d IOSDevice) ID() string { return d.Udid } +// coreDeviceID resolves the identifier to pass to devicectl --device. It prefers +// the CoreDevice identifier when known, falling back to the public hardware UDID +// (which devicectl also accepts). +func (d IOSDevice) coreDeviceID() string { + if d.CoreDeviceIdentifier != "" { + return d.CoreDeviceIdentifier + } + return d.Udid +} + func (d IOSDevice) Name() string { return d.DeviceName } @@ -95,7 +138,14 @@ func (d IOSDevice) Version() string { return d.OSVersion } +// Platform reports the OS family of the connected device. Real Apple TV units are +// discovered over the same usbmuxd/go-ios path as iPhones and iPads, so they are +// distinguished by their product type (e.g. "AppleTV14,1") rather than a separate +// device class. func (d IOSDevice) Platform() string { + if strings.HasPrefix(d.ProductType, "AppleTV") { + return "tvos" + } return "ios" } @@ -104,6 +154,9 @@ func (d IOSDevice) DeviceType() string { } func (d IOSDevice) State() string { + if d.coreDeviceState != "" { + return d.coreDeviceState + } return "online" } @@ -138,45 +191,75 @@ func getDeviceInfo(deviceEntry goios.DeviceEntry) (IOSDevice, error) { }) } - device := IOSDevice{ - Udid: udid, - DeviceName: deviceName, - OSVersion: osVersion, - ProductType: productType, - } - - tunnelManager, err := ios.NewTunnelManager(udid) - if err != nil { - return IOSDevice{}, fmt.Errorf("failed to create tunnel manager for device %s: %w", udid, err) - } - - device.tunnelManager = tunnelManager - device.wdaClient = wda.NewWdaClient("localhost:8100") - - return device, nil + return newIOSDevice(udid, deviceName, osVersion, productType) } func ListIOSDevices() ([]IOSDevice, error) { log.SetLevel(log.WarnLevel) + var ( + devices []IOSDevice + seen = make(map[string]bool) + goIOSErr error + coreDevErr error + ) + deviceList, err := goios.ListDevices() if err != nil { - return []IOSDevice{}, fmt.Errorf("failed getting device list: %w", err) + goIOSErr = fmt.Errorf("failed getting device list via go-ios: %w", err) + } else { + devices = make([]IOSDevice, 0, len(deviceList.DeviceList)) + for _, deviceEntry := range deviceList.DeviceList { + device, err := getDeviceInfo(deviceEntry) + if err != nil { + utils.Verbose("Warning: failed to get go-ios device info for %s: %v", deviceEntry.Properties.SerialNumber, err) + continue + } + devices = append(devices, device) + seen[device.Udid] = true + } + } + + coreDevices, err := listCoreDevicePhysicalDevices() + if err != nil { + coreDevErr = err + } else { + for _, device := range coreDevices { + if seen[device.Udid] { + continue + } + devices = append(devices, device) + seen[device.Udid] = true + } } - devices := make([]IOSDevice, len(deviceList.DeviceList)) - for i, deviceEntry := range deviceList.DeviceList { - device, err := getDeviceInfo(deviceEntry) - if err != nil { - return []IOSDevice{}, fmt.Errorf("failed to get device info: %w", err) + if len(devices) > 0 { + if goIOSErr != nil { + utils.Verbose("Warning: %v", goIOSErr) + } + if coreDevErr != nil { + utils.Verbose("Warning: %v", coreDevErr) } - devices[i] = device + return devices, nil } - return devices, nil + if goIOSErr != nil { + if coreDevErr != nil { + return []IOSDevice{}, fmt.Errorf("%v; %v", goIOSErr, coreDevErr) + } + return []IOSDevice{}, goIOSErr + } + if coreDevErr != nil { + return []IOSDevice{}, coreDevErr + } + + return []IOSDevice{}, nil } func (d IOSDevice) TakeScreenshot() ([]byte, error) { + if d.isLocalNetworkCoreDevice() { + return captureCoreDeviceScreenshot(d.coreDeviceID()) + } return d.wdaClient.TakeScreenshot() } @@ -289,6 +372,10 @@ func (d *IOSDevice) Cleanup() error { errs = append(errs, err) } + if err := d.cleanupTVOSRunner(); err != nil { + errs = append(errs, err) + } + if err := d.cleanupPortForwarders(); err != nil { errs = append(errs, err) } @@ -310,13 +397,14 @@ func (d *IOSDevice) hasResourcesToCleanup() bool { defer d.mu.Unlock() hasWda := d.wdaCancel != nil + hasTvosRunner := d.tvosRunnerCancel != nil hasWdaPort := d.portForwarderWda != nil && d.portForwarderWda.IsRunning() hasMjpegPort := d.portForwarderMjpeg != nil && d.portForwarderMjpeg.IsRunning() hasHTTPPort := d.portForwarderDeviceKit != nil && d.portForwarderDeviceKit.IsRunning() hasStreamPort := d.portForwarderAvc != nil && d.portForwarderAvc.IsRunning() hasTunnel := d.tunnelManager != nil && d.tunnelManager.IsTunnelRunning() - return hasWda || hasWdaPort || hasMjpegPort || hasHTTPPort || hasStreamPort || hasTunnel + return hasWda || hasTvosRunner || hasWdaPort || hasMjpegPort || hasHTTPPort || hasStreamPort || hasTunnel } // cleanupWDA cancels the WebDriverAgent context @@ -436,6 +524,11 @@ func (d *IOSDevice) startTunnel() error { return nil } + if hasConnectedCoreDeviceTunnel(d.Udid) { + utils.Verbose("Using existing CoreDevice tunnel for device %s", d.Udid) + return nil + } + // start tunnel if not already running // TunnelManager.StartTunnel() will return error if already running err := d.tunnelManager.StartTunnel() @@ -454,6 +547,13 @@ func (d *IOSDevice) startTunnel() error { } func (d *IOSDevice) StartAgent(config StartAgentConfig) error { + // Real Apple TV discovered over CoreDevice uses a dedicated tunnel transport: + // no go-ios lookup, no usbmuxd port-forward. Launch the runner via xcodebuild + // and talk to the DeviceKit server directly over the CoreDevice tunnel IP. + if d.Platform() == "tvos" && d.isLocalNetworkCoreDevice() { + return d.startTVOSAgent(config) + } + // register cleanup hook for this device if config.Hook != nil { hookName := fmt.Sprintf("ios-device-%s", d.Udid) @@ -485,11 +585,18 @@ func (d *IOSDevice) StartAgent(config StartAgentConfig) error { return fmt.Errorf("failed to list apps: %w", err) } + expectedAgentRunnerBundleID := agentRunnerBundleID + xctestConfig := "devicekit-iosUITests.xctest" + if d.Platform() == "tvos" { + expectedAgentRunnerBundleID = agentRunnerBundleIDTVOS + xctestConfig = "devicekit-tvosUITests.xctest" + } + // check if agent is installed. the runner bundle id can carry a signing/team // prefix when re-signed, so match on suffix rather than exact equality. agentBundleId := "" for _, app := range apps { - if strings.HasSuffix(app.PackageName, agentRunnerBundleID) { + if strings.HasSuffix(app.PackageName, expectedAgentRunnerBundleID) { utils.Verbose("agent is installed, launching it") agentBundleId = app.PackageName break @@ -561,7 +668,7 @@ func (d *IOSDevice) StartAgent(config StartAgentConfig) error { } // launch agent using testmanagerd - err = d.LaunchTestRunner(agentBundleId, agentBundleId, "devicekit-iosUITests.xctest") + err = d.LaunchTestRunner(agentBundleId, agentBundleId, xctestConfig) if err != nil { return fmt.Errorf("failed to launch agent: %w", err) } @@ -653,6 +760,9 @@ func (d *IOSDevice) LaunchTestRunner(bundleID, testRunnerBundleID, xctestConfig } func (d *IOSDevice) PressButton(key string) error { + if err := wda.ValidateButtonForPlatform(d.Platform(), key); err != nil { + return err + } return d.wdaClient.PressButton(key) } @@ -732,6 +842,13 @@ func (d IOSDevice) LaunchApp(bundleID string, launchOpts LaunchOptions) error { return fmt.Errorf("--activity is not supported on iOS") } + if d.isLocalNetworkCoreDevice() { + if len(launchOpts.Locales) > 0 { + utils.Verbose("Ignoring iOS locales for CoreDevice fallback launch of %s", bundleID) + } + return launchCoreDeviceApp(d.coreDeviceID(), bundleID) + } + log.SetLevel(log.WarnLevel) // ensure tunnel is running for iOS 17+ @@ -773,6 +890,10 @@ func (d IOSDevice) TerminateApp(bundleID string) error { return fmt.Errorf("bundleID cannot be empty") } + if d.isLocalNetworkCoreDevice() { + return terminateCoreDeviceApp(d.coreDeviceID(), bundleID) + } + log.SetLevel(log.WarnLevel) // ensure tunnel is running for iOS 17+ @@ -848,12 +969,21 @@ func (d IOSDevice) PressKeys(combos []KeyCombo) error { } func (d IOSDevice) OpenURL(url string) error { + // Best-effort deep-link on real Apple TV over CoreDevice; surface the + // underlying actionable error rather than a silent no-op or "device not found". + if d.isLocalNetworkCoreDevice() { + return openCoreDeviceURL(d.coreDeviceID(), url) + } return d.wdaClient.OpenURL(url) } func (d *IOSDevice) ListApps(onlyLaunchable bool) ([]InstalledAppInfo, error) { log.SetLevel(log.WarnLevel) + if d.isLocalNetworkCoreDevice() { + return listCoreDeviceApps(d.coreDeviceID()) + } + // Lock to prevent concurrent access to usbmuxd (race condition on ReadPair) d.mu.Lock() defer d.mu.Unlock() @@ -925,6 +1055,26 @@ func (d *IOSDevice) GetForegroundApp() (*ForegroundAppInfo, error) { } func (d IOSDevice) Info() (*FullDeviceInfo, error) { + if d.isLocalNetworkCoreDevice() { + screenSize, err := getCoreDeviceDisplayInfo(d.coreDeviceID()) + if err != nil { + return nil, fmt.Errorf("failed to get display info from CoreDevice: %w", err) + } + + return &FullDeviceInfo{ + DeviceInfo: DeviceInfo{ + ID: d.ID(), + Name: d.Name(), + Platform: d.Platform(), + Type: d.DeviceType(), + Version: d.Version(), + State: d.State(), + Model: d.ProductType, + }, + ScreenSize: screenSize, + }, nil + } + wdaSize, err := d.wdaClient.GetWindowSize() if err != nil { return nil, fmt.Errorf("failed to get window size from WDA: %w", err) @@ -1084,6 +1234,10 @@ func (d IOSDevice) DumpSourceRaw() (any, error) { func (d IOSDevice) InstallApp(path string) error { log.SetLevel(log.WarnLevel) + if d.isLocalNetworkCoreDevice() { + return installCoreDeviceApp(d.coreDeviceID(), path) + } + // ensure tunnel is running for iOS 17+ err := d.startTunnel() if err != nil { @@ -1112,6 +1266,13 @@ func (d IOSDevice) InstallApp(path string) error { func (d IOSDevice) UninstallApp(packageName string) (*InstalledAppInfo, error) { log.SetLevel(log.WarnLevel) + if d.isLocalNetworkCoreDevice() { + if err := uninstallCoreDeviceApp(d.coreDeviceID(), packageName); err != nil { + return nil, err + } + return &InstalledAppInfo{PackageName: packageName}, nil + } + // ensure tunnel is running for iOS 17+ err := d.startTunnel() if err != nil { diff --git a/devices/ios_coredevice.go b/devices/ios_coredevice.go new file mode 100644 index 0000000..a0ea7f4 --- /dev/null +++ b/devices/ios_coredevice.go @@ -0,0 +1,505 @@ +package devices + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + + "github.com/mobile-next/mobilecli/utils" +) + +type coreDeviceListOutput struct { + Result struct { + Devices []coreDeviceEntry `json:"devices"` + } `json:"result"` +} + +type coreDeviceEntry struct { + Identifier string `json:"identifier"` + ConnectionProperties struct { + PairingState string `json:"pairingState"` + TransportType string `json:"transportType"` + TunnelState string `json:"tunnelState"` + TunnelIPAddress string `json:"tunnelIPAddress"` + } `json:"connectionProperties"` + DeviceProperties struct { + BootState string `json:"bootState"` + Name string `json:"name"` + OSVersionNumber string `json:"osVersionNumber"` + } `json:"deviceProperties"` + HardwareProperties struct { + ProductType string `json:"productType"` + Reality string `json:"reality"` + UDID string `json:"udid"` + } `json:"hardwareProperties"` +} + +type coreDevicePhysicalInfo struct { + Identifier string + UDID string + Name string + OSVersion string + ProductType string + Transport string + BootState string + TunnelState string + TunnelIP string +} + +type coreDeviceAppsOutput struct { + Result struct { + Apps []struct { + BundleIdentifier string `json:"bundleIdentifier"` + Name string `json:"name"` + Version string `json:"version"` + } `json:"apps"` + } `json:"result"` +} + +type coreDeviceDisplaysOutput struct { + Result struct { + Displays []struct { + Bounds [][]int `json:"bounds"` + Name string `json:"name"` + Primary bool `json:"primary"` + PointScale int `json:"pointScale"` + } `json:"displays"` + } `json:"result"` +} + +type coreDeviceDetails struct { + Identifier string + UDID string + Name string + OSVersion string + ProductType string + TransportType string + TunnelState string + TunnelIP string +} + +func parseCoreDevicePhysicalInfos(data []byte) ([]coreDevicePhysicalInfo, error) { + var output coreDeviceListOutput + if err := json.Unmarshal(data, &output); err != nil { + return nil, fmt.Errorf("parse devicectl json: %w", err) + } + + var devices []coreDevicePhysicalInfo + for _, entry := range output.Result.Devices { + if !isConnectedCoreDevicePhysical(entry) { + continue + } + + udid := strings.TrimSpace(entry.HardwareProperties.UDID) + if udid == "" { + continue + } + + devices = append(devices, coreDevicePhysicalInfo{ + Identifier: strings.TrimSpace(entry.Identifier), + UDID: udid, + Name: strings.TrimSpace(entry.DeviceProperties.Name), + OSVersion: strings.TrimSpace(entry.DeviceProperties.OSVersionNumber), + ProductType: strings.TrimSpace(entry.HardwareProperties.ProductType), + Transport: strings.TrimSpace(entry.ConnectionProperties.TransportType), + BootState: strings.TrimSpace(entry.DeviceProperties.BootState), + TunnelState: strings.TrimSpace(entry.ConnectionProperties.TunnelState), + TunnelIP: strings.TrimSpace(entry.ConnectionProperties.TunnelIPAddress), + }) + } + + if devices == nil { + devices = []coreDevicePhysicalInfo{} + } + + return devices, nil +} + +func runDevicectlJSON(args ...string) ([]byte, error) { + if runtime.GOOS != "darwin" { + return nil, fmt.Errorf("devicectl is only available on macOS") + } + + fullArgs := append(args, "--json-output", "-") + cmd := exec.Command("xcrun", append([]string{"devicectl"}, fullArgs...)...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + output, err := cmd.Output() + if err != nil { + message := strings.TrimSpace(stderr.String()) + if message == "" { + message = err.Error() + } + return nil, fmt.Errorf("devicectl %s failed: %s", strings.Join(args, " "), message) + } + + return output, nil +} + +func getCoreDeviceDetails(udid string) (*coreDeviceDetails, error) { + output, err := runDevicectlJSON("device", "info", "details", "--device", udid) + if err != nil { + return nil, err + } + + var result struct { + Result coreDeviceEntry `json:"result"` + } + if err := json.Unmarshal(output, &result); err != nil { + return nil, fmt.Errorf("parse devicectl device details json: %w", err) + } + + entry := result.Result + entry.HardwareProperties.UDID = strings.TrimSpace(entry.HardwareProperties.UDID) + if entry.HardwareProperties.UDID == "" { + return nil, fmt.Errorf("device %s not found in devicectl details", udid) + } + + return &coreDeviceDetails{ + Identifier: strings.TrimSpace(entry.Identifier), + UDID: entry.HardwareProperties.UDID, + Name: strings.TrimSpace(entry.DeviceProperties.Name), + OSVersion: strings.TrimSpace(entry.DeviceProperties.OSVersionNumber), + ProductType: strings.TrimSpace(entry.HardwareProperties.ProductType), + TransportType: strings.TrimSpace(entry.ConnectionProperties.TransportType), + TunnelState: strings.TrimSpace(entry.ConnectionProperties.TunnelState), + TunnelIP: strings.TrimSpace(entry.ConnectionProperties.TunnelIPAddress), + }, nil +} + +func isLocalNetworkCoreDevice(udid string) bool { + details, err := getCoreDeviceDetails(udid) + if err != nil { + return false + } + + return strings.EqualFold(details.TransportType, "localNetwork") +} + +// isLocalNetworkCoreDevice reports whether this device is reachable over a +// CoreDevice localNetwork tunnel. For CoreDevice-discovered devices it consults +// the isWireless value cached at discovery, avoiding a `devicectl device info +// details` shell on every op (M1.7). Devices not discovered via CoreDevice (no +// CoreDeviceIdentifier) fall back to the devicectl query so their behavior is +// unchanged. +func (d *IOSDevice) isLocalNetworkCoreDevice() bool { + if d.CoreDeviceIdentifier != "" { + return d.isWireless + } + return isLocalNetworkCoreDevice(d.Udid) +} + +func hasConnectedCoreDeviceTunnel(udid string) bool { + details, err := getCoreDeviceDetails(udid) + if err != nil { + return false + } + + return strings.EqualFold(details.TransportType, "localNetwork") && + strings.EqualFold(details.TunnelState, "connected") && + strings.TrimSpace(details.TunnelIP) != "" +} + +func listCoreDeviceApps(udid string) ([]InstalledAppInfo, error) { + output, err := runDevicectlJSON("device", "info", "apps", "--device", udid) + if err != nil { + return nil, err + } + + var result coreDeviceAppsOutput + if err := json.Unmarshal(output, &result); err != nil { + return nil, fmt.Errorf("parse devicectl apps json: %w", err) + } + + apps := make([]InstalledAppInfo, 0, len(result.Result.Apps)) + for _, app := range result.Result.Apps { + bundleID := strings.TrimSpace(app.BundleIdentifier) + if bundleID == "" { + continue + } + apps = append(apps, InstalledAppInfo{ + PackageName: bundleID, + AppName: strings.TrimSpace(app.Name), + Version: strings.TrimSpace(app.Version), + }) + } + + return apps, nil +} + +func launchCoreDeviceApp(udid, bundleID string) error { + _, err := runDevicectlJSON("device", "process", "launch", "--device", udid, bundleID) + return err +} + +func openCoreDeviceURL(deviceID, url string) error { + _, err := runDevicectlJSON("device", "process", "openURL", "--device", deviceID, url) + if err != nil { + return fmt.Errorf("failed to open url %q on CoreDevice %s: %w", url, deviceID, err) + } + return nil +} + +func captureCoreDeviceScreenshot(udid string) ([]byte, error) { + tempDir, err := os.MkdirTemp("", "mobilecli-coredevice-screenshot-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp dir for screenshot: %w", err) + } + defer os.RemoveAll(tempDir) + + path := filepath.Join(tempDir, "screenshot.png") + cmd := exec.Command("xcrun", "devicectl", "device", "capture", "screenshot", "--device", udid, "--destination", path) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + message := strings.TrimSpace(stderr.String()) + if message == "" { + message = err.Error() + } + return nil, fmt.Errorf("devicectl capture screenshot failed: %s", message) + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read devicectl screenshot: %w", err) + } + + return data, nil +} + +func getCoreDeviceDisplayInfo(udid string) (*ScreenSize, error) { + output, err := runDevicectlJSON("device", "info", "displays", "--device", udid) + if err != nil { + return nil, err + } + + var result coreDeviceDisplaysOutput + if err := json.Unmarshal(output, &result); err != nil { + return nil, fmt.Errorf("parse devicectl displays json: %w", err) + } + + for _, display := range result.Result.Displays { + if !display.Primary || len(display.Bounds) != 2 || len(display.Bounds[1]) != 2 { + continue + } + return &ScreenSize{ + Width: display.Bounds[1][0], + Height: display.Bounds[1][1], + Scale: display.PointScale, + }, nil + } + + return nil, fmt.Errorf("primary display not found") +} + +func isConnectedCoreDevicePhysical(entry coreDeviceEntry) bool { + if !strings.EqualFold(strings.TrimSpace(entry.HardwareProperties.Reality), "physical") { + return false + } + + if !strings.EqualFold(strings.TrimSpace(entry.ConnectionProperties.PairingState), "paired") { + return false + } + + booted := strings.EqualFold(strings.TrimSpace(entry.DeviceProperties.BootState), "booted") + tunnelConnected := strings.EqualFold(strings.TrimSpace(entry.ConnectionProperties.TunnelState), "connected") + transport := strings.TrimSpace(entry.ConnectionProperties.TransportType) + + return booted || tunnelConnected || transport == "usb" || transport == "localNetwork" +} + +func listCoreDevicePhysicalDevices() ([]IOSDevice, error) { + if runtime.GOOS != "darwin" { + return []IOSDevice{}, nil + } + + cmd := exec.Command("xcrun", "devicectl", "list", "devices", "--json-output", "-") + var stderr bytes.Buffer + cmd.Stderr = &stderr + + output, err := cmd.Output() + if err != nil { + message := strings.TrimSpace(stderr.String()) + if message == "" { + message = err.Error() + } + return nil, fmt.Errorf("devicectl list devices failed: %s", message) + } + + infos, err := parseCoreDevicePhysicalInfos(output) + if err != nil { + return nil, err + } + + devices := make([]IOSDevice, 0, len(infos)) + for _, info := range infos { + device, err := newIOSDevice(info.UDID, info.Name, info.OSVersion, info.ProductType) + if err != nil { + utils.Verbose("Warning: Failed to create iOS device from devicectl entry %s: %v", info.UDID, err) + continue + } + // Retain both identifiers: ID()/Udid keeps returning the public hardware + // UDID, while the CoreDevice identifier + tunnel IP are internal and used + // for devicectl/tunnel operations. + device.CoreDeviceIdentifier = info.Identifier + device.TunnelIP = info.TunnelIP + device.coreDeviceState = deriveCoreDeviceState(info.BootState, info.TunnelState) + device.isWireless = strings.EqualFold(info.Transport, "localNetwork") + devices = append(devices, device) + } + + return devices, nil +} + +// deriveCoreDeviceState maps CoreDevice boot/tunnel state onto the public device +// state vocabulary. A disconnected Apple TV must not be reported as online. +func deriveCoreDeviceState(bootState, tunnelState string) string { + if strings.EqualFold(strings.TrimSpace(bootState), "booted") { + return "online" + } + if strings.EqualFold(strings.TrimSpace(tunnelState), "connected") { + return "online" + } + return "offline" +} + +// installCoreDeviceApp installs an app bundle on a CoreDevice-discovered device +// using devicectl, avoiding any go-ios device lookup. +func installCoreDeviceApp(deviceID, path string) error { + _, err := runDevicectlJSON("device", "install", "app", "--device", deviceID, path) + return err +} + +// uninstallCoreDeviceApp removes an app by bundle id on a CoreDevice-discovered +// device using devicectl. +func uninstallCoreDeviceApp(deviceID, bundleID string) error { + _, err := runDevicectlJSON("device", "uninstall", "app", "--device", deviceID, bundleID) + return err +} + +type coreDeviceAppsRawOutput struct { + Result struct { + Apps []struct { + BundleIdentifier string `json:"bundleIdentifier"` + URL string `json:"url"` + Path string `json:"path"` + } `json:"apps"` + } `json:"result"` +} + +type coreDeviceProcessesOutput struct { + Result struct { + RunningProcesses []struct { + ProcessIdentifier int `json:"processIdentifier"` + Executable string `json:"executable"` + } `json:"runningProcesses"` + } `json:"result"` +} + +// normalizeDevicectlPath strips a leading file:// scheme and any trailing slash +// so app bundle URLs and process executable paths can be prefix-compared. +func normalizeDevicectlPath(p string) string { + p = strings.TrimSpace(p) + p = strings.TrimPrefix(p, "file://") + return strings.TrimRight(p, "/") +} + +// coreDeviceAppBundlePath resolves the on-device .app bundle path for a bundle id. +func coreDeviceAppBundlePath(deviceID, bundleID string) (string, error) { + output, err := runDevicectlJSON("device", "info", "apps", "--device", deviceID) + if err != nil { + return "", err + } + return parseCoreDeviceAppBundlePath(output, deviceID, bundleID) +} + +// parseCoreDeviceAppBundlePath extracts the on-device .app bundle path for a +// bundle id from `devicectl device info apps` JSON, preferring url over path and +// tolerating missing fields. +func parseCoreDeviceAppBundlePath(data []byte, deviceID, bundleID string) (string, error) { + var result coreDeviceAppsRawOutput + if err := json.Unmarshal(data, &result); err != nil { + return "", fmt.Errorf("parse devicectl apps json: %w", err) + } + + for _, app := range result.Result.Apps { + if strings.TrimSpace(app.BundleIdentifier) != bundleID { + continue + } + if p := normalizeDevicectlPath(app.URL); p != "" { + return p, nil + } + if p := normalizeDevicectlPath(app.Path); p != "" { + return p, nil + } + } + + return "", fmt.Errorf("bundle %s not installed on device %s", bundleID, deviceID) +} + +// matchesBundlePath reports whether an executable path belongs to the given app +// bundle. It matches on a "/" boundary so a sibling bundle sharing a +// path prefix (e.g. .../Foo.app vs .../FooBar.app) is not treated as a match. +func matchesBundlePath(exe, bundlePath string) bool { + if exe == "" || bundlePath == "" { + return false + } + if exe == bundlePath { + return true + } + return strings.HasPrefix(exe, strings.TrimRight(bundlePath, "/")+"/") +} + +// resolveCoreDevicePID finds the running process id backing a bundle id by +// matching the app bundle path against the running-process executable paths. +func resolveCoreDevicePID(deviceID, bundleID string) (int, error) { + bundlePath, err := coreDeviceAppBundlePath(deviceID, bundleID) + if err != nil { + return 0, err + } + + output, err := runDevicectlJSON("device", "info", "processes", "--device", deviceID) + if err != nil { + return 0, err + } + + return parseCoreDevicePID(output, deviceID, bundleID, bundlePath) +} + +// parseCoreDevicePID selects the running process id whose executable path lives +// inside bundlePath from `devicectl device info processes` JSON. +func parseCoreDevicePID(data []byte, deviceID, bundleID, bundlePath string) (int, error) { + var result coreDeviceProcessesOutput + if err := json.Unmarshal(data, &result); err != nil { + return 0, fmt.Errorf("parse devicectl processes json: %w", err) + } + + for _, proc := range result.Result.RunningProcesses { + exe := normalizeDevicectlPath(proc.Executable) + if matchesBundlePath(exe, bundlePath) { + return proc.ProcessIdentifier, nil + } + } + + return 0, fmt.Errorf("no running process found for %s on device %s", bundleID, deviceID) +} + +// terminateCoreDeviceApp terminates a running app by bundle id on a +// CoreDevice-discovered device: resolve the pid, then ask devicectl to +// terminate it. +func terminateCoreDeviceApp(deviceID, bundleID string) error { + pid, err := resolveCoreDevicePID(deviceID, bundleID) + if err != nil { + return err + } + + _, err = runDevicectlJSON("device", "process", "terminate", "--device", deviceID, "--pid", strconv.Itoa(pid)) + return err +} diff --git a/devices/ios_coredevice_test.go b/devices/ios_coredevice_test.go new file mode 100644 index 0000000..ce14ec2 --- /dev/null +++ b/devices/ios_coredevice_test.go @@ -0,0 +1,291 @@ +package devices + +import "testing" + +func TestParseCoreDevicePhysicalInfos(t *testing.T) { + data := []byte(`{ + "result": { + "devices": [ + { + "connectionProperties": { + "pairingState": "paired", + "transportType": "localNetwork", + "tunnelState": "connected" + }, + "deviceProperties": { + "bootState": "booted", + "name": "iPhone-J7KRQL2Q75", + "osVersionNumber": "26.5.2" + }, + "hardwareProperties": { + "productType": "iPhone17,5", + "reality": "physical", + "udid": "00008140-001975D9349B801C" + } + }, + { + "connectionProperties": { + "pairingState": "paired", + "transportType": "sameMachine", + "tunnelState": "disconnected" + }, + "deviceProperties": { + "bootState": "booted", + "name": "UnitTests (iOS)", + "osVersionNumber": "26.2" + }, + "hardwareProperties": { + "productType": "iPhone18,3", + "reality": "simulated", + "udid": "56BABF10-C8A0-43F4-93B0-1891E98BE95E" + } + } + ] + } + }`) + + infos, err := parseCoreDevicePhysicalInfos(data) + if err != nil { + t.Fatalf("parseCoreDevicePhysicalInfos returned error: %v", err) + } + + if len(infos) != 1 { + t.Fatalf("expected 1 physical device, got %d: %+v", len(infos), infos) + } + + info := infos[0] + if info.UDID != "00008140-001975D9349B801C" { + t.Fatalf("expected UDID to be preserved, got %q", info.UDID) + } + if info.Name != "iPhone-J7KRQL2Q75" { + t.Fatalf("expected device name to be parsed, got %q", info.Name) + } + if info.OSVersion != "26.5.2" { + t.Fatalf("expected OS version to be parsed, got %q", info.OSVersion) + } + if info.ProductType != "iPhone17,5" { + t.Fatalf("expected product type to be parsed, got %q", info.ProductType) + } +} + +func TestParseCoreDevicePhysicalInfosRetainsAppleTVIdentity(t *testing.T) { + data := []byte(`{ + "result": { + "devices": [ + { + "identifier": "8A2B39A3-F7B6-5EF5-B0AC-9E7D17592953", + "connectionProperties": { + "pairingState": "paired", + "transportType": "localNetwork", + "tunnelState": "connected", + "tunnelIPAddress": "fd7a:1234::1" + }, + "deviceProperties": { + "bootState": "booted", + "name": "Bedroom", + "osVersionNumber": "26.5" + }, + "hardwareProperties": { + "productType": "AppleTV14,1", + "reality": "physical", + "udid": "bbfebc944c272f42d78ff80b8553655d3f936046" + } + } + ] + } + }`) + + infos, err := parseCoreDevicePhysicalInfos(data) + if err != nil { + t.Fatalf("parseCoreDevicePhysicalInfos returned error: %v", err) + } + if len(infos) != 1 { + t.Fatalf("expected 1 physical Apple TV, got %d: %+v", len(infos), infos) + } + + info := infos[0] + if info.Identifier != "8A2B39A3-F7B6-5EF5-B0AC-9E7D17592953" { + t.Errorf("expected CoreDevice identifier to be retained, got %q", info.Identifier) + } + if info.UDID != "bbfebc944c272f42d78ff80b8553655d3f936046" { + t.Errorf("expected hardware UDID to be retained, got %q", info.UDID) + } + if info.TunnelIP != "fd7a:1234::1" { + t.Errorf("expected tunnel IP to be retained, got %q", info.TunnelIP) + } + if info.BootState != "booted" { + t.Errorf("expected boot state to be parsed, got %q", info.BootState) + } + if info.TunnelState != "connected" { + t.Errorf("expected tunnel state to be parsed, got %q", info.TunnelState) + } +} + +func TestCoreDeviceIDResolution(t *testing.T) { + withIdentifier := IOSDevice{Udid: "hardware-udid", CoreDeviceIdentifier: "core-id"} + if got := withIdentifier.coreDeviceID(); got != "core-id" { + t.Errorf("expected CoreDevice identifier, got %q", got) + } + // ID() must keep returning the public hardware UDID. + if got := withIdentifier.ID(); got != "hardware-udid" { + t.Errorf("expected ID() to return hardware UDID, got %q", got) + } + + withoutIdentifier := IOSDevice{Udid: "hardware-udid"} + if got := withoutIdentifier.coreDeviceID(); got != "hardware-udid" { + t.Errorf("expected fallback to hardware UDID, got %q", got) + } +} + +func TestDeriveCoreDeviceState(t *testing.T) { + cases := []struct { + boot string + tunnel string + want string + }{ + {"booted", "connected", "online"}, + {"booted", "disconnected", "online"}, + {"notBooted", "connected", "online"}, + {"notBooted", "disconnected", "offline"}, + {"", "", "offline"}, + } + for _, c := range cases { + if got := deriveCoreDeviceState(c.boot, c.tunnel); got != c.want { + t.Errorf("deriveCoreDeviceState(%q, %q) = %q, want %q", c.boot, c.tunnel, got, c.want) + } + } +} + +func TestNormalizeDevicectlPath(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"file:///private/var/containers/Bundle/Application/AAA/Demo.app", "/private/var/containers/Bundle/Application/AAA/Demo.app"}, + {" file:///var/Demo.app/ ", "/var/Demo.app"}, + {"/var/Demo.app/", "/var/Demo.app"}, + {"", ""}, + {"file://", ""}, + } + for _, c := range cases { + if got := normalizeDevicectlPath(c.in); got != c.want { + t.Errorf("normalizeDevicectlPath(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestMatchesBundlePathRejectsSiblingPrefix(t *testing.T) { + bundlePath := "/var/containers/Bundle/Application/AAA/Foo.app" + cases := []struct { + exe string + want bool + }{ + // executable inside the bundle matches + {"/var/containers/Bundle/Application/AAA/Foo.app/Foo", true}, + // exact bundle path matches + {bundlePath, true}, + // sibling bundle sharing a path prefix must NOT match + {"/var/containers/Bundle/Application/AAA/FooBar.app/FooBar", false}, + // unrelated path + {"/var/containers/Bundle/Application/BBB/Baz.app/Baz", false}, + // empty inputs never match + {"", false}, + } + for _, c := range cases { + if got := matchesBundlePath(c.exe, bundlePath); got != c.want { + t.Errorf("matchesBundlePath(%q, %q) = %v, want %v", c.exe, bundlePath, got, c.want) + } + } + if matchesBundlePath("/var/Foo.app/Foo", "") { + t.Error("expected empty bundlePath to never match") + } +} + +func TestParseCoreDeviceAppBundlePath(t *testing.T) { + data := []byte(`{ + "result": { + "apps": [ + {"bundleIdentifier": "com.example.other", "url": "file:///var/Other.app/"}, + {"bundleIdentifier": "com.example.demo", "url": "file:///var/containers/Bundle/Application/AAA/Demo.app/"} + ] + } + }`) + + path, err := parseCoreDeviceAppBundlePath(data, "device-1", "com.example.demo") + if err != nil { + t.Fatalf("parseCoreDeviceAppBundlePath returned error: %v", err) + } + if path != "/var/containers/Bundle/Application/AAA/Demo.app" { + t.Errorf("unexpected bundle path %q", path) + } +} + +func TestParseCoreDeviceAppBundlePathFallsBackToPath(t *testing.T) { + // url missing/empty must fall back to path without panicking. + data := []byte(`{ + "result": { + "apps": [ + {"bundleIdentifier": "com.example.demo", "path": "/var/containers/Bundle/Application/AAA/Demo.app"} + ] + } + }`) + + path, err := parseCoreDeviceAppBundlePath(data, "device-1", "com.example.demo") + if err != nil { + t.Fatalf("parseCoreDeviceAppBundlePath returned error: %v", err) + } + if path != "/var/containers/Bundle/Application/AAA/Demo.app" { + t.Errorf("expected fallback to path field, got %q", path) + } +} + +func TestParseCoreDeviceAppBundlePathMissingBundle(t *testing.T) { + data := []byte(`{"result": {"apps": [{"bundleIdentifier": "com.example.other", "url": "file:///var/Other.app"}]}}`) + + if _, err := parseCoreDeviceAppBundlePath(data, "device-1", "com.example.demo"); err == nil { + t.Fatal("expected error for missing bundle, got nil") + } +} + +func TestParseCoreDevicePIDMatchingProcess(t *testing.T) { + data := []byte(`{ + "result": { + "runningProcesses": [ + {"processIdentifier": 101, "executable": "file:///usr/libexec/other"}, + {"processIdentifier": 202, "executable": "file:///var/containers/Bundle/Application/AAA/Demo.app/Demo"} + ] + } + }`) + + pid, err := parseCoreDevicePID(data, "device-1", "com.example.demo", "/var/containers/Bundle/Application/AAA/Demo.app") + if err != nil { + t.Fatalf("parseCoreDevicePID returned error: %v", err) + } + if pid != 202 { + t.Errorf("expected pid 202, got %d", pid) + } +} + +func TestParseCoreDevicePIDNoMatch(t *testing.T) { + // A sibling bundle sharing a prefix must not be matched, yielding a clean error. + data := []byte(`{ + "result": { + "runningProcesses": [ + {"processIdentifier": 303, "executable": "file:///var/containers/Bundle/Application/AAA/DemoTests.app/DemoTests"} + ] + } + }`) + + if _, err := parseCoreDevicePID(data, "device-1", "com.example.demo", "/var/containers/Bundle/Application/AAA/Demo.app"); err == nil { + t.Fatal("expected error when no process matches, got nil") + } +} + +func TestParseCoreDevicePIDMissingExecutable(t *testing.T) { + // Missing executable field must not panic and must yield a clean error. + data := []byte(`{"result": {"runningProcesses": [{"processIdentifier": 404}]}}`) + + if _, err := parseCoreDevicePID(data, "device-1", "com.example.demo", "/var/containers/Bundle/Application/AAA/Demo.app"); err == nil { + t.Fatal("expected error when executable is missing, got nil") + } +} diff --git a/devices/ios_platform_test.go b/devices/ios_platform_test.go new file mode 100644 index 0000000..5015a1a --- /dev/null +++ b/devices/ios_platform_test.go @@ -0,0 +1,78 @@ +package devices + +import "testing" + +func TestIOSDevicePlatform(t *testing.T) { + // Real Apple TV units are discovered over the same go-ios path as iPhones/iPads + // and are identified by their product type rather than a separate device class. + cases := []struct { + productType string + want string + }{ + {"iPhone15,3", "ios"}, + {"iPad13,1", "ios"}, + {"AppleTV14,1", "tvos"}, + {"AppleTV11,1", "tvos"}, + {"", "ios"}, + } + for _, c := range cases { + d := IOSDevice{ProductType: c.productType} + if got := d.Platform(); got != c.want { + t.Errorf("IOSDevice{ProductType: %q}.Platform() = %q, want %q", c.productType, got, c.want) + } + } +} + +func TestIOSDeviceType(t *testing.T) { + d := IOSDevice{ProductType: "AppleTV14,1"} + if got := d.DeviceType(); got != "real" { + t.Errorf("IOSDevice.DeviceType() = %q, want %q", got, "real") + } +} + +func TestIOSDeviceState(t *testing.T) { + // Non-CoreDevice devices default to "online". + def := IOSDevice{ProductType: "AppleTV14,1"} + if got := def.State(); got != "online" { + t.Errorf("default IOSDevice.State() = %q, want %q", got, "online") + } + + // CoreDevice-discovered devices report their cached state. + offline := IOSDevice{ProductType: "AppleTV14,1", coreDeviceState: "offline"} + if got := offline.State(); got != "offline" { + t.Errorf("offline IOSDevice.State() = %q, want %q", got, "offline") + } + + online := IOSDevice{ProductType: "AppleTV14,1", coreDeviceState: "online"} + if got := online.State(); got != "online" { + t.Errorf("online IOSDevice.State() = %q, want %q", got, "online") + } +} + +func TestTVOSRunnerAction(t *testing.T) { + cases := []struct { + healthy bool + hasOwnedProcess bool + want string + }{ + {true, false, "reuse"}, + {true, true, "reuse"}, + {false, true, "restart"}, + {false, false, "launch"}, + } + for _, c := range cases { + if got := tvosRunnerAction(c.healthy, c.hasOwnedProcess); got != c.want { + t.Errorf("tvosRunnerAction(%v, %v) = %q, want %q", c.healthy, c.hasOwnedProcess, got, c.want) + } + } +} + +func TestTVOSTunnelBaseURL(t *testing.T) { + if got := tvosTunnelBaseURL("10.0.0.5", 12004); got != "http://10.0.0.5:12004" { + t.Errorf("tvosTunnelBaseURL ipv4 = %q", got) + } + // IPv6 tunnel addresses must be bracketed. + if got := tvosTunnelBaseURL("fd7a:1234::1", 12004); got != "http://[fd7a:1234::1]:12004" { + t.Errorf("tvosTunnelBaseURL ipv6 = %q", got) + } +} diff --git a/devices/simulator.go b/devices/simulator.go index 6112a33..9fc1447 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -55,11 +55,12 @@ type SimulatorDevice struct { wdaClient *wda.WdaClient } -// parseSimulatorVersion parses iOS version from simulator runtime string +// parseSimulatorVersion parses the OS version from a simulator runtime string // e.g., "com.apple.CoreSimulator.SimRuntime.iOS-18-6" -> "18.6" +// e.g., "com.apple.CoreSimulator.SimRuntime.tvOS-26-5" -> "26.5" func parseSimulatorVersion(runtime string) string { - // Use regex to extract iOS version from runtime string - re := regexp.MustCompile(`iOS-(\d+)-(\d+)`) + // Use regex to extract the OS version from the runtime string + re := regexp.MustCompile(`(?:iOS|tvOS|watchOS|xrOS)-(\d+)-(\d+)`) matches := re.FindStringSubmatch(runtime) if len(matches) == 3 { return matches[1] + "." + matches[2] @@ -69,11 +70,38 @@ func parseSimulatorVersion(runtime string) string { return runtime } +// simulatorPlatform derives the mobilecli platform identifier from a simulator +// runtime string. tvOS simulators report "tvos"; everything else defaults to "ios". +func simulatorPlatform(runtime string) string { + if strings.Contains(runtime, "tvOS") { + return "tvos" + } + return "ios" +} + func (s SimulatorDevice) ID() string { return s.UDID } func (s SimulatorDevice) Name() string { return s.Simulator.Name } -func (s SimulatorDevice) Platform() string { return "ios" } +func (s SimulatorDevice) Platform() string { return simulatorPlatform(s.Runtime) } func (s SimulatorDevice) DeviceType() string { return "simulator" } func (s SimulatorDevice) Version() string { return parseSimulatorVersion(s.Runtime) } + +// agentRunnerBundleIDForPlatform returns the runner bundle id suffix expected +// for this simulator's platform (iOS vs tvOS). +func (s SimulatorDevice) agentRunnerBundleIDForPlatform() string { + if s.Platform() == "tvos" { + return agentRunnerBundleIDTVOS + } + return agentRunnerBundleID +} + +// agentRunnerProcessNameForPlatform returns the runner process name used to +// locate the running agent for this simulator's platform. +func (s SimulatorDevice) agentRunnerProcessNameForPlatform() string { + if s.Platform() == "tvos" { + return "devicekit-tvosUITests-Runner" + } + return "devicekit-iosUITests-Runner" +} func (s SimulatorDevice) State() string { if s.Simulator.State == "Booted" { return "online" @@ -300,7 +328,7 @@ func (s SimulatorDevice) findInstalledAgentBundleID() (string, error) { } for bundleID := range installedApps { - if strings.HasSuffix(bundleID, agentRunnerBundleID) { + if strings.HasSuffix(bundleID, s.agentRunnerBundleIDForPlatform()) { return bundleID, nil } } @@ -589,7 +617,7 @@ func (s *SimulatorDevice) Info() (*FullDeviceInfo, error) { DeviceInfo: DeviceInfo{ ID: s.UDID, Name: s.Simulator.Name, - Platform: "ios", + Platform: s.Platform(), Type: "simulator", Version: parseSimulatorVersion(s.Runtime), State: s.State(), @@ -715,7 +743,7 @@ func listAllProcesses() ([]ProcessInfo, error) { return processes, nil } -func findWdaProcessForDevice(deviceUDID string) (int, string, error) { +func findWdaProcessForDevice(deviceUDID, runnerProcessName string) (int, string, error) { processes, err := listAllProcesses() if err != nil { return 0, "", err @@ -724,7 +752,7 @@ func findWdaProcessForDevice(deviceUDID string) (int, string, error) { devicePath := fmt.Sprintf("/Library/Developer/CoreSimulator/Devices/%s", deviceUDID) for _, proc := range processes { - if strings.Contains(proc.Command, devicePath) && strings.Contains(proc.Command, "devicekit-iosUITests-Runner") { + if strings.Contains(proc.Command, devicePath) && strings.Contains(proc.Command, runnerProcessName) { return proc.PID, proc.Command, nil } } @@ -763,7 +791,7 @@ func extractEnvValue(output, envVar string) (string, error) { } func (s *SimulatorDevice) getWdaEnvPort(envVar string) (int, error) { - pid, processInfo, err := findWdaProcessForDevice(s.UDID) + pid, processInfo, err := findWdaProcessForDevice(s.UDID, s.agentRunnerProcessNameForPlatform()) if err != nil { utils.Verbose("Could not find WDA process: %v", err) return 0, err diff --git a/devices/tvos_agent.go b/devices/tvos_agent.go new file mode 100644 index 0000000..65adece --- /dev/null +++ b/devices/tvos_agent.go @@ -0,0 +1,484 @@ +package devices + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/mobile-next/mobilecli/devices/wda" + "github.com/mobile-next/mobilecli/utils" + "howett.net/plist" +) + +const ( + tvosRunnerXctestrunName = "devicekit-tvos.xctestrun" + tvosRunnerCacheKeyFile = "cache.key" + tvosAgentReadyTimeout = 90 * time.Second + tvosAgentReadyInterval = 2 * time.Second +) + +// tvosAgentCacheDir returns the per-device cache directory used to persist the +// signed runner app + generated .xctestrun under the user's application cache. +func tvosAgentCacheDir(deviceUDID string) (string, error) { + base, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("failed to resolve user cache dir: %w", err) + } + return filepath.Join(base, "mobilecli", "agent-cache", deviceUDID), nil +} + +// tvosRunnerCacheKey keys the cache on the input artifact checksum, device UDID, +// and provisioning-profile UUID so a rebuild/re-sign is only needed when one of +// those changes. +func tvosRunnerCacheKey(artifactChecksum, deviceUDID, profileUUID string) string { + sum := sha256.Sum256([]byte(artifactChecksum + "|" + deviceUDID + "|" + profileUUID)) + return hex.EncodeToString(sum[:]) +} + +// tvosRunnerDeviceKey derives the device-binding token persisted alongside the +// cache. It lets cachedTVOSXctestrunPath confirm a cache belongs to the expected +// device UDID at reuse time, when the artifact checksum and profile UUID inputs +// to tvosRunnerCacheKey are not available. +func tvosRunnerDeviceKey(deviceUDID string) string { + sum := sha256.Sum256([]byte("device|" + deviceUDID)) + return hex.EncodeToString(sum[:]) +} + +// CacheTVOSRunner extracts the signed runner app from a re-signed IPA into the +// per-device cache and generates a .xctestrun that Milestone 3's StartAgent path +// launches via `xcodebuild test-without-building`. It returns the .xctestrun path. +func CacheTVOSRunner(deviceUDID, resignedIPAPath, artifactChecksum, profileUUID string) (string, error) { + cacheDir, err := tvosAgentCacheDir(deviceUDID) + if err != nil { + return "", err + } + + // start from a clean slate so a stale runner app never lingers + if err := os.RemoveAll(cacheDir); err != nil { + return "", fmt.Errorf("failed to clear runner cache: %w", err) + } + + payloadRoot := filepath.Join(cacheDir, "Payload") + if err := utils.UnzipToDir(resignedIPAPath, cacheDir); err != nil { + return "", fmt.Errorf("failed to unzip runner into cache: %w", err) + } + + runnerApp, err := findRunnerApp(payloadRoot) + if err != nil { + return "", err + } + + xctestBundle, err := findRunnerTestBundle(runnerApp) + if err != nil { + return "", err + } + + xctestrunPath := filepath.Join(cacheDir, tvosRunnerXctestrunName) + if err := writeTVOSXctestrun(xctestrunPath, cacheDir, runnerApp, xctestBundle); err != nil { + return "", err + } + + keyPath := filepath.Join(cacheDir, tvosRunnerCacheKeyFile) + key := tvosRunnerCacheKey(artifactChecksum, deviceUDID, profileUUID) + // Line 1 is the device-binding token verified at reuse time; line 2 is the + // full artifact/profile-bound key retained for a future check once StartAgent + // can supply those inputs. + keyContents := tvosRunnerDeviceKey(deviceUDID) + "\n" + key + "\n" + if err := os.WriteFile(keyPath, []byte(keyContents), 0600); err != nil { + return "", fmt.Errorf("failed to write cache key: %w", err) + } + + utils.Verbose("cached tvOS runner + xctestrun at %s", xctestrunPath) + return xctestrunPath, nil +} + +// cachedTVOSXctestrunPath returns the cached .xctestrun path for a device only +// when a well-formed cache key is present whose device-binding token matches the +// requested device UDID, so StartAgent never launches a runner from a cache that +// was not produced by an install for THIS device. +// +// What is validated at reuse time: +// - the .xctestrun exists in the per-device cache directory +// - the cache.key file exists and is well-formed +// - the stored device-binding token equals tvosRunnerDeviceKey(deviceUDID) +// +// What is NOT validated here: the artifact checksum and provisioning-profile +// UUID. StartAgent has neither value at reuse time, so the full artifact/profile +// key (stored on line 2 of cache.key) is retained for a future check but cannot +// be enforced here. Any artifact/profile change is fully re-applied by +// CacheTVOSRunner, which wipes and rewrites the per-device cache on every install. +func cachedTVOSXctestrunPath(deviceUDID string) (string, bool) { + cacheDir, err := tvosAgentCacheDir(deviceUDID) + if err != nil { + return "", false + } + xctestrunPath := filepath.Join(cacheDir, tvosRunnerXctestrunName) + if _, err := os.Stat(xctestrunPath); err != nil { + return "", false + } + if !tvosCacheKeyMatchesDevice(cacheDir, deviceUDID) { + utils.Verbose("refusing cached tvOS runner for %s: cache key missing or device mismatch", deviceUDID) + return "", false + } + return xctestrunPath, true +} + +// tvosCacheKeyMatchesDevice reports whether cache.key exists in cacheDir and its +// device-binding token (line 1) matches the given device UDID. +func tvosCacheKeyMatchesDevice(cacheDir, deviceUDID string) bool { + data, err := os.ReadFile(filepath.Join(cacheDir, tvosRunnerCacheKeyFile)) + if err != nil { + return false + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) == 0 { + return false + } + return strings.TrimSpace(lines[0]) == tvosRunnerDeviceKey(deviceUDID) +} + +// findRunnerApp locates the single *.app bundle inside an extracted Payload dir. +func findRunnerApp(payloadDir string) (string, error) { + entries, err := os.ReadDir(payloadDir) + if err != nil { + return "", fmt.Errorf("failed to read runner Payload dir: %w", err) + } + for _, entry := range entries { + if entry.IsDir() && strings.HasSuffix(entry.Name(), ".app") { + return filepath.Join(payloadDir, entry.Name()), nil + } + } + return "", fmt.Errorf("no .app bundle found in %s", payloadDir) +} + +// findRunnerTestBundle locates the UITests .xctest bundle inside the runner app. +func findRunnerTestBundle(runnerApp string) (string, error) { + pluginsDir := filepath.Join(runnerApp, "PlugIns") + entries, err := os.ReadDir(pluginsDir) + if err != nil { + return "", fmt.Errorf("failed to read runner PlugIns dir: %w", err) + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".xctest") { + return filepath.Join(pluginsDir, entry.Name()), nil + } + } + return "", fmt.Errorf("no .xctest bundle found in %s", pluginsDir) +} + +// writeTVOSXctestrun generates a format-version-1 .xctestrun for the tvOS UITests +// runner. Paths use __TESTROOT__ so the file is relocatable within the cache dir. +func writeTVOSXctestrun(xctestrunPath, cacheDir, runnerApp, xctestBundle string) error { + runnerRel, err := filepath.Rel(cacheDir, runnerApp) + if err != nil { + return fmt.Errorf("failed to compute runner rel path: %w", err) + } + xctestRel, err := filepath.Rel(runnerApp, xctestBundle) + if err != nil { + return fmt.Errorf("failed to compute xctest rel path: %w", err) + } + + moduleName := strings.TrimSuffix(filepath.Base(xctestBundle), ".xctest") + targetName := moduleName + moduleName = strings.ReplaceAll(moduleName, "-", "_") + + target := map[string]any{ + "BlueprintName": targetName, + "BlueprintProviderName": "devicekit-ios", + "BlueprintProviderRelativePath": "devicekit-ios.xcodeproj", + "BundleIdentifiersForCrashReportEmphasis": []string{"com.mobilenext.devicekit-tvosUITests"}, + "TestHostPath": "__TESTROOT__/" + runnerRel, + "TestHostBundleIdentifier": agentRunnerBundleIDTVOS, + "TestBundlePath": "__TESTHOST__/" + xctestRel, + "DefaultTestExecutionTimeAllowance": 600, + "DiagnosticCollectionPolicy": 1, + "IsUITestBundle": true, + "IsXCTRunnerHostedTestBundle": true, + "PreferredScreenCaptureFormat": "screenRecording", + "ProductModuleName": moduleName, + "RunOrder": 0, + "SystemAttachmentLifetime": "deleteOnSuccess", + "TestTimeoutsEnabled": false, + "TestLanguage": "", + "TestRegion": "", + "ToolchainsSettingValue": []string{}, + "UseUITargetAppProvidedByTests": true, + "UserAttachmentLifetime": "deleteOnSuccess", + "CommandLineArguments": []string{}, + "EnvironmentVariables": map[string]any{ + "APP_DISTRIBUTOR_ID_OVERRIDE": "com.apple.AppStore", + "DYLD_INSERT_LIBRARIES": "/usr/lib/libRPAC.dylib", + "OS_ACTIVITY_DT_MODE": "YES", + "PERFC_ENABLE_EXTENDED_DIAGNOSTIC_FORMAT": "1", + "PERFC_ENABLE_PROFILE_MODE": "1", + "PERFC_RESET_INSERT_LIBRARIES": "1", + "PERFC_SUPPRESS_SYSTEM_REPORTS": "1", + "SQLITE_ENABLE_THREAD_ASSERTIONS": "1", + "TERM": "dumb", + }, + "TestingEnvironmentVariables": map[string]any{ + "DYLD_INSERT_LIBRARIES": "/usr/lib/libRPAC.dylib", + "PERFC_SUPPRESS_SYSTEM_REPORTS": "1", + "XCODE_SCHEME_NAME": "devicekit-tvos", + }, + "UITargetAppCommandLineArguments": []string{}, + "UITargetAppEnvironmentVariables": map[string]any{ + "APP_DISTRIBUTOR_ID_OVERRIDE": "com.apple.AppStore", + "XCODE_SCHEME_NAME": "devicekit-tvos", + }, + "UITargetAppPerformanceAntipatternCheckerEnabled": true, + "DependentProductPaths": []string{ + "__TESTROOT__/" + runnerRel, + "__TESTHOST__/" + xctestRel, + }, + } + + doc := map[string]any{targetName: target} + + data, err := plist.MarshalIndent(doc, plist.XMLFormat, "\t") + if err != nil { + return fmt.Errorf("failed to marshal xctestrun: %w", err) + } + + if err := os.WriteFile(xctestrunPath, data, 0600); err != nil { + return fmt.Errorf("failed to write xctestrun: %w", err) + } + return nil +} + +func patchTVOSXctestrunListenEnv(xctestrunPath, host string, port int) error { + data, err := os.ReadFile(xctestrunPath) + if err != nil { + return fmt.Errorf("failed to read xctestrun: %w", err) + } + + var doc map[string]any + if _, err := plist.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("failed to parse xctestrun: %w", err) + } + + portString := strconv.Itoa(port) + for _, value := range doc { + target, ok := value.(map[string]any) + if !ok { + continue + } + if isUITestBundle, _ := target["IsUITestBundle"].(bool); !isUITestBundle { + continue + } + + env, _ := target["TestingEnvironmentVariables"].(map[string]any) + if env == nil { + env = map[string]any{} + target["TestingEnvironmentVariables"] = env + } + env["DEVICEKIT_LISTEN_HOST"] = host + env["DEVICEKIT_LISTEN_PORT"] = portString + + patched, err := plist.MarshalIndent(doc, plist.XMLFormat, "\t") + if err != nil { + return fmt.Errorf("failed to marshal xctestrun: %w", err) + } + if err := os.WriteFile(xctestrunPath, patched, 0600); err != nil { + return fmt.Errorf("failed to write xctestrun: %w", err) + } + return nil + } + + return fmt.Errorf("failed to find tvOS UI test target in xctestrun") +} + +// tvosRunnerAction is a pure decision helper for the runner lifecycle: reuse a +// healthy session, restart an owned-but-dead session, or launch a fresh one. +func tvosRunnerAction(healthy, hasOwnedProcess bool) string { + switch { + case healthy: + return "reuse" + case hasOwnedProcess: + return "restart" + default: + return "launch" + } +} + +// tvosTunnelBaseURL builds an IPv6-safe DeviceKit URL for the CoreDevice tunnel IP. +func tvosTunnelBaseURL(tunnelIP string, port int) string { + return "http://" + net.JoinHostPort(tunnelIP, strconv.Itoa(port)) +} + +// startTVOSAgent starts (or reuses) the DeviceKit XCTest runner on a real Apple +// TV over the CoreDevice tunnel, without go-ios lookup or usbmuxd port forwarding. +func (d *IOSDevice) startTVOSAgent(config StartAgentConfig) error { + if config.Hook != nil { + hookName := fmt.Sprintf("tvos-device-%s", d.Udid) + config.Hook.Register(hookName, d.Cleanup) + } + + tunnelIP := d.TunnelIP + if tunnelIP == "" { + details, err := getCoreDeviceDetails(d.Udid) + if err != nil { + return fmt.Errorf("failed to resolve CoreDevice tunnel for %s: %w", d.Udid, err) + } + tunnelIP = details.TunnelIP + } + if tunnelIP == "" { + return fmt.Errorf("no CoreDevice tunnel IP available for device %s; ensure it is awake and paired", d.Udid) + } + + // bind the DeviceKit client directly at the tunnel IP (never the LAN) + d.mu.Lock() + d.TunnelIP = tunnelIP + d.wdaClient = wda.NewWdaClient(tvosTunnelBaseURL(tunnelIP, deviceKitHTTPPort)) + hasOwnedProcess := d.tvosRunnerCancel != nil + d.mu.Unlock() + + _, statusErr := d.wdaClient.GetStatus() + action := tvosRunnerAction(statusErr == nil, hasOwnedProcess) + utils.Verbose("tvOS runner lifecycle action for %s: %s", d.Udid, action) + + switch action { + case "reuse": + return nil + case "restart": + _ = d.cleanupTVOSRunner() + } + + xctestrunPath, ok := cachedTVOSXctestrunPath(d.Udid) + if !ok { + return fmt.Errorf("no cached tvOS runner for device %s; run 'mobilecli agent install --device %s --agent-path --provisioning-profile ' first", d.Udid, d.Udid) + } + + if config.OnProgress != nil { + config.OnProgress("Launching tvOS XCTest runner") + } + if err := d.launchTVOSTestRunner(xctestrunPath); err != nil { + return err + } + + if config.OnProgress != nil { + config.OnProgress("Waiting for DeviceKit server over CoreDevice tunnel") + } + return d.waitForTVOSAgentReady() +} + +// launchTVOSTestRunner spawns and owns an xcodebuild test-without-building process +// that runs the DeviceKit UITest runner on the Apple TV over CoreDevice. +func (d *IOSDevice) launchTVOSTestRunner(xctestrunPath string) error { + ctx, cancel := context.WithCancel(context.Background()) + + if err := patchTVOSXctestrunListenEnv(xctestrunPath, d.TunnelIP, deviceKitHTTPPort); err != nil { + cancel() + return err + } + + cmd := exec.CommandContext(ctx, "xcodebuild", "test-without-building", + "-xctestrun", xctestrunPath, + "-destination", "id="+d.coreDeviceID()) + + // Pass the tunnel bind host to the on-device DeviceKit server via the + // TEST_RUNNER_ prefix so it binds a tunnel-reachable address (never 0.0.0.0, + // never the LAN). + cmd.Env = append(os.Environ(), + "TEST_RUNNER_DEVICEKIT_LISTEN_HOST="+d.TunnelIP, + fmt.Sprintf("TEST_RUNNER_DEVICEKIT_LISTEN_PORT=%d", deviceKitHTTPPort), + ) + + if err := cmd.Start(); err != nil { + cancel() + return fmt.Errorf("failed to launch xcodebuild runner for device %s: %w", d.Udid, err) + } + + d.mu.Lock() + d.tvosRunnerCancel = cancel + d.mu.Unlock() + + go func() { + err := cmd.Wait() + if err != nil { + utils.Verbose("tvOS xcodebuild runner for %s ended: %v", d.Udid, err) + } else { + utils.Verbose("tvOS xcodebuild runner for %s ended", d.Udid) + } + d.mu.Lock() + d.tvosRunnerCancel = nil + d.mu.Unlock() + }() + + utils.Verbose("tvOS xcodebuild runner launched for device %s", d.Udid) + return nil +} + +// waitForTVOSAgentReady polls the DeviceKit health endpoint over the tunnel until +// it answers or a bounded timeout elapses, returning an actionable error. +func (d *IOSDevice) waitForTVOSAgentReady() error { + deadline := time.After(tvosAgentReadyTimeout) + ticker := time.NewTicker(tvosAgentReadyInterval) + defer ticker.Stop() + + var lastErr error + for { + if _, err := d.wdaClient.GetStatus(); err == nil { + utils.Verbose("tvOS DeviceKit server ready for device %s", d.Udid) + return nil + } else { + lastErr = err + } + + select { + case <-deadline: + return fmt.Errorf("tvOS DeviceKit server for device %s (%s) did not become ready within %s: %w", + d.DeviceName, d.Udid, tvosAgentReadyTimeout, lastErr) + case <-ticker.C: + } + } +} + +// cleanupTVOSRunner cancels an owned xcodebuild runner process, if any. +func (d *IOSDevice) cleanupTVOSRunner() error { + d.mu.Lock() + cancel := d.tvosRunnerCancel + d.tvosRunnerCancel = nil + d.mu.Unlock() + + if cancel != nil { + utils.Verbose("Stopping tvOS xcodebuild runner for device %s", d.Udid) + cancel() + } + return nil +} + +// Focus selects an on-screen element by accessibility identifier and/or label and +// drives Siri Remote focus to it via the DeviceKit device.io.focus RPC over the +// tunnel transport. It returns the focused element as raw JSON. +func (d *IOSDevice) Focus(identifier, label string) (any, error) { + return d.wdaClient.Focus(identifier, label) +} + +// TVOSAgentReachable reports whether the DeviceKit runner session answers over the +// CoreDevice tunnel. It is a lightweight, read-only health ping for agent status. +func (d *IOSDevice) TVOSAgentReachable() bool { + tunnelIP := d.TunnelIP + if tunnelIP == "" { + details, err := getCoreDeviceDetails(d.Udid) + if err != nil { + return false + } + tunnelIP = details.TunnelIP + } + if tunnelIP == "" { + return false + } + + client := wda.NewWdaClient(tvosTunnelBaseURL(tunnelIP, deviceKitHTTPPort)) + _, err := client.GetStatus() + return err == nil +} diff --git a/devices/tvos_agent_test.go b/devices/tvos_agent_test.go new file mode 100644 index 0000000..4d3cd07 --- /dev/null +++ b/devices/tvos_agent_test.go @@ -0,0 +1,132 @@ +package devices + +import ( + "os" + "path/filepath" + "testing" + + "howett.net/plist" +) + +func TestTVOSRunnerCacheKeyVaries(t *testing.T) { + base := tvosRunnerCacheKey("checksum-a", "udid-1", "profile-1") + if base == "" { + t.Fatal("expected a non-empty cache key") + } + // Same inputs are stable. + if base != tvosRunnerCacheKey("checksum-a", "udid-1", "profile-1") { + t.Error("expected cache key to be deterministic") + } + // Any input change alters the key. + for _, other := range []string{ + tvosRunnerCacheKey("checksum-b", "udid-1", "profile-1"), + tvosRunnerCacheKey("checksum-a", "udid-2", "profile-1"), + tvosRunnerCacheKey("checksum-a", "udid-1", "profile-2"), + } { + if other == base { + t.Error("expected cache key to change when an input changes") + } + } +} + +func TestWriteTVOSXctestrun(t *testing.T) { + cacheDir := t.TempDir() + runnerApp := filepath.Join(cacheDir, "Payload", "devicekit-tvos-Runner.app") + xctest := filepath.Join(runnerApp, "PlugIns", "devicekit-tvosUITests.xctest") + xctestrunPath := filepath.Join(cacheDir, tvosRunnerXctestrunName) + + if err := writeTVOSXctestrun(xctestrunPath, cacheDir, runnerApp, xctest); err != nil { + t.Fatalf("writeTVOSXctestrun returned error: %v", err) + } + + data, err := os.ReadFile(xctestrunPath) + if err != nil { + t.Fatalf("failed to read xctestrun: %v", err) + } + + var doc map[string]map[string]any + if _, err := plist.Unmarshal(data, &doc); err != nil { + t.Fatalf("failed to parse generated xctestrun: %v", err) + } + + target, ok := doc["devicekit-tvosUITests"] + if !ok { + t.Fatalf("expected a devicekit-tvosUITests target, got keys %v", keysOf(doc)) + } + if got := target["TestHostPath"]; got != "__TESTROOT__/Payload/devicekit-tvos-Runner.app" { + t.Errorf("unexpected TestHostPath: %v", got) + } + if got := target["TestBundlePath"]; got != "__TESTHOST__/PlugIns/devicekit-tvosUITests.xctest" { + t.Errorf("unexpected TestBundlePath: %v", got) + } + if got, _ := target["IsUITestBundle"].(bool); !got { + t.Errorf("expected IsUITestBundle true, got %v", target["IsUITestBundle"]) + } +} + +func TestPatchTVOSXctestrunListenEnv(t *testing.T) { + cacheDir := t.TempDir() + runnerApp := filepath.Join(cacheDir, "Payload", "devicekit-tvos-Runner.app") + xctest := filepath.Join(runnerApp, "PlugIns", "devicekit-tvosUITests.xctest") + xctestrunPath := filepath.Join(cacheDir, tvosRunnerXctestrunName) + + if err := writeTVOSXctestrun(xctestrunPath, cacheDir, runnerApp, xctest); err != nil { + t.Fatalf("writeTVOSXctestrun returned error: %v", err) + } + if err := patchTVOSXctestrunListenEnv(xctestrunPath, "fd75:74f5:d670::1", 12004); err != nil { + t.Fatalf("patchTVOSXctestrunListenEnv returned error: %v", err) + } + + data, err := os.ReadFile(xctestrunPath) + if err != nil { + t.Fatalf("failed to read xctestrun: %v", err) + } + + var doc map[string]map[string]any + if _, err := plist.Unmarshal(data, &doc); err != nil { + t.Fatalf("failed to parse generated xctestrun: %v", err) + } + env, ok := doc["devicekit-tvosUITests"]["TestingEnvironmentVariables"].(map[string]any) + if !ok { + t.Fatalf("expected TestingEnvironmentVariables map, got %T", doc["devicekit-tvosUITests"]["TestingEnvironmentVariables"]) + } + if got := env["DEVICEKIT_LISTEN_HOST"]; got != "fd75:74f5:d670::1" { + t.Errorf("unexpected DEVICEKIT_LISTEN_HOST: %v", got) + } + if got := env["DEVICEKIT_LISTEN_PORT"]; got != "12004" { + t.Errorf("unexpected DEVICEKIT_LISTEN_PORT: %v", got) + } +} + +func keysOf(m map[string]map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +func TestTVOSCacheKeyMatchesDevice(t *testing.T) { + dir := t.TempDir() + udid := "udid-1" + keyPath := filepath.Join(dir, tvosRunnerCacheKeyFile) + + // A well-formed key file whose device token matches is accepted. + contents := tvosRunnerDeviceKey(udid) + "\n" + tvosRunnerCacheKey("checksum-a", udid, "profile-1") + "\n" + if err := os.WriteFile(keyPath, []byte(contents), 0600); err != nil { + t.Fatalf("failed to write cache key: %v", err) + } + if !tvosCacheKeyMatchesDevice(dir, udid) { + t.Error("expected matching device token to be accepted") + } + + // A cache produced for a different device must be rejected. + if tvosCacheKeyMatchesDevice(dir, "udid-2") { + t.Error("expected device mismatch to be rejected") + } + + // A missing cache key file must be rejected. + if tvosCacheKeyMatchesDevice(t.TempDir(), udid) { + t.Error("expected missing cache key to be rejected") + } +} diff --git a/devices/wda/focus.go b/devices/wda/focus.go new file mode 100644 index 0000000..2434933 --- /dev/null +++ b/devices/wda/focus.go @@ -0,0 +1,35 @@ +package wda + +import ( + "encoding/json" + "fmt" +) + +// Focus selects an on-screen element by accessibility identifier and/or label and +// drives Siri Remote focus to it via the tvOS DeviceKit device.io.focus handler. +// At least one of identifier or label must be provided. It returns the focused +// element as decoded JSON. +func (c *WdaClient) Focus(identifier, label string) (any, error) { + if identifier == "" && label == "" { + return nil, fmt.Errorf("focus requires at least one of identifier or label") + } + + params := map[string]string{} + if identifier != "" { + params["identifier"] = identifier + } + if label != "" { + params["label"] = label + } + + result, err := c.CallRPC("device.io.focus", params) + if err != nil { + return nil, err + } + + var value any + if err := json.Unmarshal(result, &value); err != nil { + return nil, fmt.Errorf("failed to parse focus result: %w", err) + } + return value, nil +} diff --git a/devices/wda/press-button.go b/devices/wda/press-button.go index 67338be..49fe5e5 100644 --- a/devices/wda/press-button.go +++ b/devices/wda/press-button.go @@ -2,12 +2,57 @@ package wda import "fmt" +// siriRemoteButtons are the tvOS Siri Remote buttons handled by the tvOS +// device.io.button handler. They are unsupported on non-tvOS platforms. +var siriRemoteButtons = map[string]bool{ + "UP": true, + "DOWN": true, + "LEFT": true, + "RIGHT": true, + "SELECT": true, + "MENU": true, + "PLAY_PAUSE": true, +} + +// iosOnlyButtons are iPhone/iPad-only hardware buttons with no Siri Remote +// equivalent; they are rejected on tvOS. +var iosOnlyButtons = map[string]bool{ + "HOME": true, + "LOCK": true, + "VOLUME_UP": true, + "VOLUME_DOWN": true, +} + +// ValidateButtonForPlatform enforces per-platform button gating: tvOS accepts the +// Siri Remote buttons and rejects iPhone-only buttons, while non-tvOS platforms +// reject Siri Remote buttons. Unknown buttons fall through to the button map. +func ValidateButtonForPlatform(platform, key string) error { + if platform == "tvos" { + if iosOnlyButtons[key] { + return fmt.Errorf("unsupported on tvOS: %s", key) + } + return nil + } + if siriRemoteButtons[key] { + return fmt.Errorf("unsupported on %s: %s", platform, key) + } + return nil +} + func (c *WdaClient) PressButton(key string) error { buttonMap := map[string]string{ "VOLUME_UP": "volumeUp", "VOLUME_DOWN": "volumeDown", "HOME": "home", "LOCK": "lock", + // tvOS Siri Remote buttons (handled by the tvOS device.io.button handler). + "UP": "up", + "DOWN": "down", + "LEFT": "left", + "RIGHT": "right", + "SELECT": "select", + "MENU": "menu", + "PLAY_PAUSE": "playPause", } if key == "ENTER" { diff --git a/devices/wda/press-button_test.go b/devices/wda/press-button_test.go new file mode 100644 index 0000000..65f6999 --- /dev/null +++ b/devices/wda/press-button_test.go @@ -0,0 +1,51 @@ +package wda + +import ( + "strings" + "testing" +) + +func TestValidateButtonForPlatform(t *testing.T) { + cases := []struct { + name string + platform string + button string + wantErr bool + errFrag string + }{ + // tvOS accepts all seven Siri Remote buttons. + {"tvos up", "tvos", "UP", false, ""}, + {"tvos down", "tvos", "DOWN", false, ""}, + {"tvos left", "tvos", "LEFT", false, ""}, + {"tvos right", "tvos", "RIGHT", false, ""}, + {"tvos select", "tvos", "SELECT", false, ""}, + {"tvos menu", "tvos", "MENU", false, ""}, + {"tvos play_pause", "tvos", "PLAY_PAUSE", false, ""}, + // tvOS rejects iPhone-only buttons explicitly. + {"tvos home", "tvos", "HOME", true, "unsupported on tvOS"}, + {"tvos lock", "tvos", "LOCK", true, "unsupported on tvOS"}, + {"tvos volume_up", "tvos", "VOLUME_UP", true, "unsupported on tvOS"}, + {"tvos volume_down", "tvos", "VOLUME_DOWN", true, "unsupported on tvOS"}, + // iOS accepts its hardware buttons. + {"ios home", "ios", "HOME", false, ""}, + {"ios volume_up", "ios", "VOLUME_UP", false, ""}, + // iOS rejects Siri Remote buttons. + {"ios select", "ios", "SELECT", true, "unsupported on ios"}, + {"ios menu", "ios", "MENU", true, "unsupported on ios"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := ValidateButtonForPlatform(c.platform, c.button) + if c.wantErr && err == nil { + t.Fatalf("expected error for %s/%s, got nil", c.platform, c.button) + } + if !c.wantErr && err != nil { + t.Fatalf("unexpected error for %s/%s: %v", c.platform, c.button, err) + } + if c.wantErr && c.errFrag != "" && !strings.Contains(err.Error(), c.errFrag) { + t.Errorf("error %q does not contain %q", err.Error(), c.errFrag) + } + }) + } +} diff --git a/devices/wda/source.go b/devices/wda/source.go index 58a131f..c8908b9 100644 --- a/devices/wda/source.go +++ b/devices/wda/source.go @@ -42,7 +42,7 @@ func filterSourceElements(source sourceTreeElement) []types.ScreenElement { childElements = append(childElements, filterSourceElements(child)...) } - acceptedTypes := []string{"TextField", "Button", "Switch", "Icon", "SearchField", "StaticText", "Image", "SecureTextField", "WebView"} + acceptedTypes := []string{"TextField", "Button", "Switch", "Icon", "SearchField", "StaticText", "Image", "SecureTextField", "WebView", "Cell"} // strip XCUIElementType prefix if present elementType := strings.TrimPrefix(source.Type, "XCUIElementType") diff --git a/go.mod b/go.mod index fdd2889..1e41f63 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.2 require ( al.essio.dev/pkg/shellescape v1.5.1 - github.com/danielpaulus/go-ios v1.0.211 + github.com/danielpaulus/go-ios v1.2.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/golang-lru/v2 v2.0.7 @@ -45,7 +45,7 @@ require ( github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 // indirect github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect - go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect + go.mozilla.org/pkcs7 v0.9.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect @@ -59,5 +59,5 @@ require ( golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gvisor.dev/gvisor v0.0.0-20240405191320-0878b34101b5 // indirect - software.sslmate.com/src/go-pkcs12 v0.2.0 // indirect + software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect ) diff --git a/go.sum b/go.sum index 653cc29..e9dd064 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= -github.com/danielpaulus/go-ios v1.0.211 h1:REv11Hc+kt3LEAEwYkps0r7KUew0kYWs1rgw2UJeEug= -github.com/danielpaulus/go-ios v1.0.211/go.mod h1:f5q5S4XJT53AA8cdgp3rLA41YaIpyaDg+w8aURzLNhM= +github.com/danielpaulus/go-ios v1.2.0 h1:BqMvUajo9MAR+TuoLSwTx2fMHMPa7c3MEZ2Oka6HOHw= +github.com/danielpaulus/go-ios v1.2.0/go.mod h1:ITNBU1yXL+MfjHCxKeFVVJygJsO437cxpELC0KwP3tI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -100,13 +100,12 @@ github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivz github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= -go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 h1:CCriYyAfq1Br1aIYettdHZTy8mBTIPo7We18TuO/bak= -go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= +go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI= +go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= @@ -118,7 +117,6 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -128,22 +126,16 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= @@ -164,5 +156,5 @@ gvisor.dev/gvisor v0.0.0-20240405191320-0878b34101b5 h1:DOUDfNS+CFMM46k18FRF5k/0 gvisor.dev/gvisor v0.0.0-20240405191320-0878b34101b5/go.mod h1:NQHVAzMwvZ+Qe3ElSiHmq9RUm1MdNHpUZ52fiEqvn+0= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= -software.sslmate.com/src/go-pkcs12 v0.2.0 h1:nlFkj7bTysH6VkC4fGphtjXRbezREPgrHuJG20hBGPE= -software.sslmate.com/src/go-pkcs12 v0.2.0/go.mod h1:23rNcYsMabIc1otwLpTkCCPwUq6kQsTyowttG/as0kQ= +software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0= +software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/server/dispatch.go b/server/dispatch.go index a22e254..ece521c 100644 --- a/server/dispatch.go +++ b/server/dispatch.go @@ -22,6 +22,7 @@ func GetMethodRegistry() map[string]HandlerFunc { "device.io.text": handleIoText, "device.io.keys": handleIoKeys, "device.io.button": handleIoButton, + "device.io.focus": handleIoFocus, "device.io.swipe": handleIoSwipe, "device.io.gesture": handleIoGesture, "device.url": handleURL, diff --git a/server/server.go b/server/server.go index 4d433e2..4cad734 100644 --- a/server/server.go +++ b/server/server.go @@ -593,6 +593,12 @@ type IoButtonParams struct { Button string `json:"button"` } +type IoFocusParams struct { + DeviceID string `json:"deviceId"` + Identifier string `json:"identifier"` + Label string `json:"label"` +} + type IoGestureParams struct { DeviceID string `json:"deviceId"` Actions []any `json:"actions"` @@ -699,6 +705,30 @@ func handleIoButton(params json.RawMessage) (any, error) { return okResponse, nil } +func handleIoFocus(params json.RawMessage) (any, error) { + if len(params) == 0 { + return nil, fmt.Errorf("'params' is required with fields: deviceId, identifier and/or label") + } + + var ioFocusParams IoFocusParams + if err := json.Unmarshal(params, &ioFocusParams); err != nil { + return nil, fmt.Errorf("invalid parameters: %w. Expected fields: deviceId, identifier and/or label", err) + } + + req := commands.FocusRequest{ + DeviceID: ioFocusParams.DeviceID, + Identifier: ioFocusParams.Identifier, + Label: ioFocusParams.Label, + } + + response := commands.FocusCommand(req) + if response.Status == "error" { + return nil, fmt.Errorf("%s", response.Error) + } + + return response.Data, nil +} + func handleIoGesture(params json.RawMessage) (any, error) { if len(params) == 0 { return nil, fmt.Errorf("'params' is required with fields: deviceId, actions") diff --git a/utils/resign.go b/utils/resign.go index 4db59ab..1566b01 100644 --- a/utils/resign.go +++ b/utils/resign.go @@ -14,6 +14,7 @@ import ( type provisioningProfile struct { Name string `plist:"Name"` + UUID string `plist:"UUID"` TeamIdentifier []string `plist:"TeamIdentifier"` ProvisionedDevices []string `plist:"ProvisionedDevices"` Entitlements entitlements `plist:"Entitlements"` @@ -212,6 +213,19 @@ func decodeProvisioningProfile(profilePath string) (*provisioningProfile, error) return &profile, nil } +// ProvisioningProfileUUID returns the UUID of a .mobileprovision file. It is used +// to key the runner cache without logging any profile contents. +func ProvisioningProfileUUID(profilePath string) (string, error) { + profile, err := decodeProvisioningProfile(profilePath) + if err != nil { + return "", err + } + if profile.UUID == "" { + return "", fmt.Errorf("provisioning profile %s has no UUID", profilePath) + } + return profile.UUID, nil +} + type profileMatch int const ( diff --git a/utils/zipfile.go b/utils/zipfile.go index 9aaed77..e02c34a 100644 --- a/utils/zipfile.go +++ b/utils/zipfile.go @@ -24,6 +24,78 @@ func Unzip(zipPath string) (string, error) { return tempDir, nil } +// UnzipToDir extracts a zip archive into destDir, preserving file modes. Unlike +// Unzip it does not allocate a temp directory and keeps executable bits, which is +// required when caching a signed .app bundle for later launch. +func UnzipToDir(zipPath, destDir string) error { + if err := os.MkdirAll(destDir, 0750); err != nil { + return err + } + return unzipFileWithMode(zipPath, destDir) +} + +// unzipFileWithMode extracts a zip file to destDir, preserving each entry's mode. +func unzipFileWithMode(zipPath, destDir string) error { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer func() { _ = reader.Close() }() + + for _, file := range reader.File { + if filepath.IsAbs(file.Name) || strings.Contains(file.Name, "..") { + return fmt.Errorf("illegal file path in archive: %s", file.Name) + } + + path := filepath.Join(destDir, file.Name) + relPath, err := filepath.Rel(destDir, path) + if err != nil || strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) || relPath == ".." { + return fmt.Errorf("path traversal attempt: %s resolves to %s", file.Name, path) + } + + if file.FileInfo().IsDir() { + if err := os.MkdirAll(path, 0750); err != nil { + return err + } + continue + } + + if err := extractZipEntry(file, path); err != nil { + return err + } + } + return nil +} + +func extractZipEntry(file *zip.File, path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + return err + } + + mode := file.Mode() + if mode == 0 { + mode = 0600 + } + + outFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return err + } + defer func() { _ = outFile.Close() }() + + rc, err := file.Open() + if err != nil { + return err + } + defer func() { _ = rc.Close() }() + + // #nosec G110 -- runner IPA is a trusted, locally re-signed artifact + if _, err := io.Copy(outFile, rc); err != nil { + return err + } + return nil +} + // unzipFile extracts a zip file to the specified destination func unzipFile(zipPath, destDir string) error { reader, err := zip.OpenReader(zipPath)