From 41c86ca3ca783e69ef066cc010f4ca5931a23b4b Mon Sep 17 00:00:00 2001 From: Seto Elkahfi Date: Thu, 2 Jul 2026 14:52:57 +0200 Subject: [PATCH] Support installing the agent on real Apple TV devices Real Apple TV units are discovered over the same go-ios path as iPhones and iPads, so detect them by product type (AppleTV*) and report platform tvos. Route real tvOS devices through a shared installResignedRunner helper that downloads devicekit-tvos-runner.ipa and re-signs it with the provisioning profile, and suffix-match the tvOS runner bundle id so the re-signed, team-prefixed id is recognised. Add unit tests for platform detection and agent matching. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/agent.go | 37 +++++++++++++++++------ cli/agent_test.go | 57 ++++++++++++++++++++++++++++++++++++ devices/ios.go | 7 +++++ devices/ios_platform_test.go | 31 ++++++++++++++++++++ 4 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 cli/agent_test.go create mode 100644 devices/ios_platform_test.go diff --git a/cli/agent.go b/cli/agent.go index 31dc3b3..fd38c96 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -31,7 +31,11 @@ var agentChecksums = map[string]string{ // 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", - "mobilenext-devicekit.apk": "c58484b40db6ab84c7445ccaa346ee0804e155e37507a42f1ac37b92a52bdd4f", + // 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", + "mobilenext-devicekit.apk": "c58484b40db6ab84c7445ccaa346ee0804e155e37507a42f1ac37b92a52bdd4f", } type agentMessageResponse struct { @@ -139,8 +143,13 @@ var agentInstallCmd = &cobra.Command{ 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 (only the tvOS Simulator is supported)", device.DeviceType()) + return fmt.Errorf("unsupported tvOS device type: %s", device.DeviceType()) } case "android": installErr = installAgentOnAndroid(device) @@ -300,9 +309,11 @@ 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 downloads a real-device runner IPA, re-signs it with the +// configured provisioning profile, and installs it. It backs both the iOS and the +// tvOS real-device flows, which differ only in the artifact filename and version. +func installResignedRunner(device devices.ControllableDevice, filename, version string) error { + 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 { @@ -320,6 +331,14 @@ func installAgentOnRealIOS(device devices.ControllableDevice) error { }) } +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 { filename := "mobilenext-devicekit.apk" agentURL := fmt.Sprintf("https://github.com/mobile-next/devicekit-android/releases/download/%s/%s", agentVersionAndroid, filename) @@ -356,10 +375,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 @@ -396,5 +415,5 @@ 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)") } diff --git a/cli/agent_test.go b/cli/agent_test.go new file mode 100644 index 0000000..5702b7d --- /dev/null +++ b/cli/agent_test.go @@ -0,0 +1,57 @@ +package cli + +import "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) + } + } +} diff --git a/devices/ios.go b/devices/ios.go index 5a85b7a..ed0af6f 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -96,7 +96,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" } diff --git a/devices/ios_platform_test.go b/devices/ios_platform_test.go new file mode 100644 index 0000000..3d14c90 --- /dev/null +++ b/devices/ios_platform_test.go @@ -0,0 +1,31 @@ +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") + } +}