diff --git a/README.md b/README.md
index 66233d1c..6983edae 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
- ipatool is a command line tool that allows you to search for iOS, iPadOS, tvOS, visionOS, and macOS apps on the App Store, and download .ipa or macOS .pkg app packages.
+ ipatool is a command line tool that allows you to search for iOS, iPadOS, tvOS, watchOS, visionOS, and macOS apps on the App Store, and download .ipa or macOS .pkg app packages.
@@ -45,13 +45,13 @@ Usage:
Available Commands:
auth Authenticate with the App Store
completion Generate the autocompletion script for the specified shell
- download Download iOS, iPadOS, tvOS, visionOS, and macOS app packages from the App Store
+ download Download iOS, iPadOS, tvOS, watchOS, visionOS, and macOS app packages from the App Store
get-version-metadata Retrieves the metadata for a specific version of an app
help Help about any command
list-purchases List apps owned by the authenticated App Store account
list-versions List the available versions of an App Store app
purchase Obtain a license for the app from the App Store
- search Search for iOS, iPadOS, tvOS, visionOS, and macOS apps available on the App Store
+ search Search for iOS, iPadOS, tvOS, watchOS, visionOS, and macOS apps available on the App Store
Flags:
--format format sets output format for command; can be 'text', 'json' (default text)
diff --git a/cmd/download.go b/cmd/download.go
index b0bada90..cc2a185a 100644
--- a/cmd/download.go
+++ b/cmd/download.go
@@ -29,7 +29,7 @@ func downloadCmdWithAppStore(appStore func() appstore.AppStore) *cobra.Command {
cmd := &cobra.Command{
Use: "download",
- Short: "Download iOS, iPadOS, tvOS, visionOS, and macOS app packages from the App Store",
+ Short: "Download iOS, iPadOS, tvOS, watchOS, visionOS, and macOS app packages from the App Store",
RunE: func(cmd *cobra.Command, args []string) error {
if appID == 0 && bundleID == "" {
return errors.New("either the app ID or the bundle identifier must be specified")
@@ -171,7 +171,7 @@ func downloadCmdWithAppStore(appStore func() appstore.AppStore) *cobra.Command {
cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target app (overrides the app ID when found)")
cmd.Flags().StringVarP(&outputPath, "output", "o", "", "The destination path of the downloaded app package")
cmd.Flags().StringVar(&externalVersionID, "external-version-id", "", "External version identifier of the target app (defaults to latest version when not specified)")
- cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to download for: iphone (iOS), ipad (iPadOS), appletv (tvOS), visionos, or macos")
+ cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to download for: iphone (iOS), ipad (iPadOS), appletv (tvOS), watchos, visionos, or macos")
cmd.Flags().BoolVar(&acquireLicense, "purchase", false, "Obtain a license for the app if needed")
return cmd
diff --git a/cmd/download_test.go b/cmd/download_test.go
index f2d98757..1ee78f7b 100644
--- a/cmd/download_test.go
+++ b/cmd/download_test.go
@@ -130,25 +130,28 @@ var _ = Describe("Download command", func() {
Expect(err).To(MatchError("replication failed"))
})
- It("propagates macOS when automatic purchase retries the download", func() {
+ DescribeTable("preserves the platform when automatic purchase retries the download", func(platform appstore.Platform) {
store := &fakeDownloadAppStore{downloadErrors: []error{appstore.ErrLicenseRequired, nil}}
previousDependencies := dependencies
DeferCleanup(func() { dependencies = previousDependencies })
dependencies.Logger = log.NewLogger(log.Args{})
cmd := downloadCmdWithAppStore(func() appstore.AppStore { return store })
- cmd.SetArgs([]string{"--app-id", "42", "--platform", "macos", "--purchase"})
+ cmd.SetArgs([]string{"--app-id", "42", "--platform", string(platform), "--purchase"})
cmd.SetContext(context.WithValue(context.Background(), interactiveKey, false))
Expect(cmd.Execute()).To(Succeed())
Expect(store.accountInfoCalls).To(Equal(1))
Expect(store.purchaseInputs).To(HaveLen(1))
- Expect(store.purchaseInputs[0].Platform).To(Equal(appstore.PlatformMacOS))
+ Expect(store.purchaseInputs[0].Platform).To(Equal(platform))
Expect(store.purchaseInputs[0].App).To(Equal(appstore.App{ID: 42}))
Expect(store.downloadInputs).To(HaveLen(2))
- Expect(store.downloadInputs[0].Platform).To(Equal(appstore.PlatformMacOS))
- Expect(store.downloadInputs[1].Platform).To(Equal(appstore.PlatformMacOS))
- })
+ Expect(store.downloadInputs[0].Platform).To(Equal(platform))
+ Expect(store.downloadInputs[1].Platform).To(Equal(platform))
+ },
+ Entry("macOS", appstore.PlatformMacOS),
+ Entry("watchOS", appstore.PlatformWatchOS),
+ )
It("retains the pending automatic purchase after refreshing authentication", func() {
store := &fakeDownloadAppStore{
diff --git a/cmd/get_version_metadata.go b/cmd/get_version_metadata.go
index 3b4e516c..5f307ca2 100644
--- a/cmd/get_version_metadata.go
+++ b/cmd/get_version_metadata.go
@@ -101,7 +101,7 @@ func getVersionMetadataCmd() *cobra.Command {
cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target app (overrides the app ID)")
cmd.Flags().StringVar(&externalVersionID, "external-version-id", "", "External version identifier of the target app (required)")
- cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to retrieve metadata for: iphone (iOS), ipad (iPadOS), appletv (tvOS), visionos, or macos")
+ cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to retrieve metadata for: iphone (iOS), ipad (iPadOS), appletv (tvOS), watchos, visionos, or macos")
_ = cmd.MarkFlagRequired("external-version-id")
diff --git a/cmd/list_versions.go b/cmd/list_versions.go
index 460ad8ea..fc763a7e 100644
--- a/cmd/list_versions.go
+++ b/cmd/list_versions.go
@@ -92,7 +92,7 @@ func ListVersionsCmd() *cobra.Command {
cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target app (required)")
cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target app (overrides the app ID)")
- cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to list versions for: iphone (iOS), ipad (iPadOS), appletv (tvOS), visionos, or macos")
+ cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to list versions for: iphone (iOS), ipad (iPadOS), appletv (tvOS), watchos, visionos, or macos")
return cmd
}
diff --git a/cmd/mcp_test.go b/cmd/mcp_test.go
index 5586703f..dc214e0f 100644
--- a/cmd/mcp_test.go
+++ b/cmd/mcp_test.go
@@ -92,7 +92,7 @@ var _ = Describe("MCP server", func() {
Expect(result.StructuredContent).To(HaveKeyWithValue("platform", ""))
})
- It("passes search platform aliases and limits to the App Store", func() {
+ DescribeTable("passes search platform aliases and limits to the App Store", func(value string, platform appstore.Platform) {
var input appstore.SearchInput
store.search = func(in appstore.SearchInput) (appstore.SearchOutput, error) {
@@ -100,10 +100,13 @@ var _ = Describe("MCP server", func() {
return appstore.SearchOutput{}, nil
}
- Expect(call("search_apps", map[string]any{"term": "example", "platform": "tvos", "limit": 7}).IsError).To(BeFalse())
- Expect(input.Platform).To(Equal(appstore.PlatformAppleTV))
+ Expect(call("search_apps", map[string]any{"term": "example", "platform": value, "limit": 7}).IsError).To(BeFalse())
+ Expect(input.Platform).To(Equal(platform))
Expect(input.Limit).To(Equal(int64(7)))
- })
+ },
+ Entry("tvOS", "tvos", appstore.PlatformAppleTV),
+ Entry("watchOS", "watch", appstore.PlatformWatchOS),
+ )
DescribeTable("rejects invalid inputs before accessing the account", func(name string, args map[string]any) {
accountCalls := 0
diff --git a/cmd/mcp_tools.go b/cmd/mcp_tools.go
index 68a5f284..cf2e66e4 100644
--- a/cmd/mcp_tools.go
+++ b/cmd/mcp_tools.go
@@ -13,7 +13,7 @@ import (
type mcpSearchInput struct {
Term string `json:"term" jsonschema:"Search term"`
Limit *int64 `json:"limit,omitempty" jsonschema:"Maximum results; defaults to 5. visionOS supports up to 12."`
- Platform string `json:"platform,omitempty" jsonschema:"iphone (ios), ipad (ipados), appletv (tvos), visionos, or macos; defaults to iOS and iPadOS"`
+ Platform string `json:"platform,omitempty" jsonschema:"iphone (ios), ipad (ipados), appletv (tvos), watchos, visionos, or macos; defaults to iOS and iPadOS"`
}
type mcpSearchOutput struct {
@@ -27,7 +27,7 @@ type mcpSearchOutput struct {
type mcpAppInput struct {
AppID int64 `json:"app_id,omitempty" jsonschema:"Numeric app ID; required unless bundle_identifier is provided"`
BundleID string `json:"bundle_identifier,omitempty" jsonschema:"Bundle identifier; overrides app_id when found"`
- Platform string `json:"platform,omitempty" jsonschema:"iphone (ios), ipad (ipados), appletv (tvos), visionos, or macos; uses the CLI default when omitted"`
+ Platform string `json:"platform,omitempty" jsonschema:"iphone (ios), ipad (ipados), appletv (tvos), watchos, visionos, or macos; uses the CLI default when omitted"`
}
type mcpDownloadInput struct {
diff --git a/cmd/purchase.go b/cmd/purchase.go
index cc7d5526..cb0654f3 100644
--- a/cmd/purchase.go
+++ b/cmd/purchase.go
@@ -104,7 +104,7 @@ func purchaseCmdWithAppStore(appStore func() appstore.AppStore) *cobra.Command {
cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target app")
cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target app (overrides the app ID)")
- cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to purchase for: iphone (iOS), ipad (iPadOS), appletv (tvOS), visionos, or macos")
+ cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to purchase for: iphone (iOS), ipad (iPadOS), appletv (tvOS), watchos, visionos, or macos")
return cmd
}
diff --git a/cmd/search.go b/cmd/search.go
index 5bd6c8dc..7ddd2ad9 100644
--- a/cmd/search.go
+++ b/cmd/search.go
@@ -14,7 +14,7 @@ func searchCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "search ",
- Short: "Search for iOS, iPadOS, tvOS, visionOS, and macOS apps available on the App Store",
+ Short: "Search for iOS, iPadOS, tvOS, watchOS, visionOS, and macOS apps available on the App Store",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
infoResult, err := dependencies.AppStore.AccountInfo()
@@ -47,7 +47,7 @@ func searchCmd() *cobra.Command {
}
cmd.Flags().Int64VarP(&limit, "limit", "l", 5, "maximum amount of search results to retrieve; visionOS supports up to 12")
- cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to search: iphone (iOS), ipad (iPadOS), appletv (tvOS), visionos, or macos")
+ cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to search: iphone (iOS), ipad (iPadOS), appletv (tvOS), watchos, visionos, or macos")
return cmd
}
diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go
index 0ed99684..f1c38867 100644
--- a/pkg/appstore/appstore_download.go
+++ b/pkg/appstore/appstore_download.go
@@ -52,7 +52,7 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) {
}
externalVersionID := input.ExternalVersionID
- if externalVersionID == "" && (input.Platform == PlatformAppleTV || input.Platform == PlatformVisionOS) {
+ if externalVersionID == "" && (input.Platform == PlatformAppleTV || input.Platform == PlatformWatchOS || input.Platform == PlatformVisionOS) {
externalVersionID, err = t.lookupLatestExternalVersionID(input.Account, input.App, input.Platform)
// Delisted tvOS apps may have no catalog offer but still be available
// for redownload. Validate the returned package's platform below.
@@ -159,6 +159,8 @@ func (*appstore) validatePackagePlatform(path string, platform Platform) error {
expectedPlatform = "iPhoneOS"
case PlatformAppleTV:
expectedPlatform = "AppleTVOS"
+ case PlatformWatchOS:
+ expectedPlatform = "WatchOS"
case PlatformVisionOS:
expectedPlatform = "XROS"
default:
diff --git a/pkg/appstore/appstore_download_product.go b/pkg/appstore/appstore_download_product.go
index 9aef9ff7..72b83fff 100644
--- a/pkg/appstore/appstore_download_product.go
+++ b/pkg/appstore/appstore_download_product.go
@@ -112,7 +112,7 @@ func (t *appstore) sendDownloadProduct(ctx context.Context, acc Account, app App
redownloadRes, err := t.downloadClient.Send(t.downloadProductRequest(redownload, acc, app, guid, externalVersionID))
if bag.UpdateEndpoint != "" && externalVersionID != "" &&
- (platform == "" || platform == PlatformIPhone || platform == PlatformIPad || platform == PlatformMacOS || platform == PlatformAppleTV) &&
+ (platform == "" || platform == PlatformIPhone || platform == PlatformIPad || platform == PlatformMacOS || platform == PlatformAppleTV || platform == PlatformWatchOS) &&
(isEmptyRedownloadError(err) || (err == nil && isUnavailableDownloadProductResponse(redownloadRes))) {
updateRes, updateErr := t.sendUpdateProduct(bag.UpdateEndpoint, acc, app, guid, externalVersionID)
@@ -143,7 +143,7 @@ func (t *appstore) sendUpdateProduct(endpoint string, acc Account, app App, guid
return http.Result[downloadResult]{}, err
}
- // The bag's updateProduct can serve pinned iOS, macOS, and tvOS versions when redownload
+ // The bag's updateProduct can serve pinned app versions when redownload
// returns an empty HTTP 500 or a message-only availability error. Keep
// the same session and version selection.
res, err := t.downloadClient.Send(t.downloadProductRequest(update, acc, app, guid, externalVersionID))
diff --git a/pkg/appstore/appstore_download_test.go b/pkg/appstore/appstore_download_test.go
index af122ae2..2e4b0673 100644
--- a/pkg/appstore/appstore_download_test.go
+++ b/pkg/appstore/appstore_download_test.go
@@ -196,6 +196,29 @@ var _ = Describe("AppStore (Download)", func() {
})
})
+ It("pins the latest watchOS version before downloading", func() {
+ mockMachine.EXPECT().MacAddress().Return("00:11:22:33:44:55", nil)
+ gomock.InOrder(
+ mockPlatformClient.EXPECT().Send(gomock.Any()).Do(func(req http.Request) {
+ parsedURL, err := url.Parse(req.URL)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(parsedURL.Query().Get("platform")).To(Equal("enterprisestore"))
+ Expect(parsedURL.Query().Get("id")).To(Equal("6764448634"))
+ }).Return(http.Result[platformVersionLookupResult]{StatusCode: 200, Data: platformVersionLookupResult{
+ Results: map[string]platformVersionLookupItem{"6764448634": {Offers: []platformVersionLookupOffer{
+ {Version: platformVersionLookupVersion{ExternalID: "885050618"}},
+ }}},
+ }}, nil),
+ mockDownloadClient.EXPECT().Send(gomock.Any()).Do(func(req http.Request) {
+ Expect(req.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("externalVersionId", "885050618"))
+ }).Return(http.Result[downloadResult]{Data: downloadResult{FailureType: FailureTypeLicenseNotFound}}, nil),
+ )
+ _, err := as.Download(DownloadInput{
+ Account: Account{StoreFront: "143441"}, App: App{ID: 6764448634}, Platform: PlatformWatchOS,
+ })
+ Expect(err).To(MatchError(ErrLicenseRequired))
+ })
+
When("platform is visionOS", func() {
It("resolves and sends the visionOS external version id", func() {
mockMachine.EXPECT().
@@ -831,6 +854,13 @@ var _ = Describe("AppStore (Download)", func() {
Entry("tvOS update accepts tvOS", PlatformAppleTV, "818970197", "update", "AppleTVOS", true),
Entry("tvOS update rejects iOS", PlatformAppleTV, "818970197", "update", "iPhoneOS", false),
Entry("tvOS update rejects visionOS", PlatformAppleTV, "818970197", "update", "XROS", false),
+ Entry("watchOS accepts WatchOS", PlatformWatchOS, "818970197", "", "WatchOS", true),
+ Entry("watchOS rejects iOS", PlatformWatchOS, "818970197", "", "iPhoneOS", false),
+ Entry("watchOS rejects tvOS", PlatformWatchOS, "818970197", "", "AppleTVOS", false),
+ Entry("watchOS redownload preserves the platform", PlatformWatchOS, "818970197", "redownload", "WatchOS", true),
+ Entry("watchOS update accepts WatchOS", PlatformWatchOS, "818970197", "update", "WatchOS", true),
+ Entry("watchOS update rejects iOS", PlatformWatchOS, "818970197", "update", "iPhoneOS", false),
+ Entry("iPhone still rejects WatchOS", PlatformIPhone, "", "", "WatchOS", false),
)
Describe("macOS packages", func() {
@@ -1143,6 +1173,17 @@ var _ = Describe("AppStore (Download)", func() {
Expect(err.Error()).To(ContainSubstring("AppleTVOS"))
})
+ It("does not accept watchOS support declared only by an embedded app", func() {
+ path := writePackageWithInfoPlists(map[string][]string{
+ "Payload/Test.app/Info.plist": {"iPhoneOS"},
+ "Payload/Test.app/Watch/Watch.app/Info.plist": {"WatchOS"},
+ })
+ defer os.Remove(path)
+
+ err := (&appstore{}).validatePackagePlatform(path, PlatformWatchOS)
+ Expect(err).To(MatchError("downloaded package does not declare WatchOS support"))
+ })
+
It("accepts XROS packages", func() {
path := writePackage([]string{"XROS"})
defer os.Remove(path)
diff --git a/pkg/appstore/appstore_get_version_metadata.go b/pkg/appstore/appstore_get_version_metadata.go
index f0a66cbd..e81877e4 100644
--- a/pkg/appstore/appstore_get_version_metadata.go
+++ b/pkg/appstore/appstore_get_version_metadata.go
@@ -28,7 +28,7 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion
}
switch platform {
- case PlatformIPhone, PlatformIPad, PlatformAppleTV, PlatformVisionOS, PlatformMacOS:
+ case PlatformIPhone, PlatformIPad, PlatformAppleTV, PlatformWatchOS, PlatformVisionOS, PlatformMacOS:
default:
return GetVersionMetadataOutput{}, fmt.Errorf("invalid platform %q", platform)
}
diff --git a/pkg/appstore/appstore_get_version_metadata_test.go b/pkg/appstore/appstore_get_version_metadata_test.go
index 18b7a2a9..cb71e17c 100644
--- a/pkg/appstore/appstore_get_version_metadata_test.go
+++ b/pkg/appstore/appstore_get_version_metadata_test.go
@@ -549,7 +549,7 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() {
server.Close()
})
- It("returns version metadata", func() {
+ DescribeTable("returns version metadata", func(platform Platform) {
output, err := as.GetVersionMetadata(GetVersionMetadataInput{
Account: Account{
DirectoryServicesID: "test-dsid",
@@ -558,6 +558,7 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() {
ID: 1234567890,
},
VersionID: "test-version",
+ Platform: platform,
})
Expect(err).NotTo(HaveOccurred())
@@ -565,6 +566,9 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() {
Expect(output.ReleaseDate).To(Equal(releaseDate))
Expect(atomic.LoadInt64(wholeGetCount)).To(BeZero())
Expect(atomic.LoadInt64(servedBytes)).To(BeNumerically("<", int64(len(ipa)/2)))
- })
+ },
+ Entry("default platform", Platform("")),
+ Entry("watchOS", PlatformWatchOS),
+ )
})
})
diff --git a/pkg/appstore/appstore_kbsync_test.go b/pkg/appstore/appstore_kbsync_test.go
index 5f43294b..137d5942 100644
--- a/pkg/appstore/appstore_kbsync_test.go
+++ b/pkg/appstore/appstore_kbsync_test.go
@@ -228,6 +228,7 @@ var _ = Describe("AppStore (preferred kbsync download)", func() {
},
Entry("iPhone", PlatformIPhone), Entry("iPad", PlatformIPad), Entry("macOS", PlatformMacOS),
Entry("tvOS", PlatformAppleTV), Entry("visionOS", PlatformVisionOS),
+ Entry("watchOS", PlatformWatchOS),
)
It("pins the iOS offer before trying ent/download", func() {
diff --git a/pkg/appstore/appstore_list_versions.go b/pkg/appstore/appstore_list_versions.go
index bedf9461..bbe03b00 100644
--- a/pkg/appstore/appstore_list_versions.go
+++ b/pkg/appstore/appstore_list_versions.go
@@ -26,7 +26,7 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er
}
switch platform {
- case PlatformIPhone, PlatformIPad, PlatformAppleTV, PlatformVisionOS, PlatformMacOS:
+ case PlatformIPhone, PlatformIPad, PlatformAppleTV, PlatformWatchOS, PlatformVisionOS, PlatformMacOS:
default:
return ListVersionsOutput{}, fmt.Errorf("invalid platform %q", platform)
}
@@ -43,7 +43,7 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er
switch platform {
case PlatformMacOS:
externalVersionID, err = t.lookupLatestMacOSExternalVersionID(input.Account, input.App)
- case PlatformAppleTV, PlatformVisionOS:
+ case PlatformAppleTV, PlatformWatchOS, PlatformVisionOS:
externalVersionID, err = t.lookupLatestExternalVersionID(input.Account, input.App, platform)
}
diff --git a/pkg/appstore/appstore_list_versions_test.go b/pkg/appstore/appstore_list_versions_test.go
index 8c00ad6f..1c806583 100644
--- a/pkg/appstore/appstore_list_versions_test.go
+++ b/pkg/appstore/appstore_list_versions_test.go
@@ -70,6 +70,28 @@ var _ = Describe("AppStore (ListVersions)", func() {
Expect(err).To(MatchError(ContainSubstring("failed to resolve platform version")))
})
+ It("pins the watchOS version before requesting version history", func() {
+ mockMachine.EXPECT().MacAddress().Return("00:11:22:33:44:55", nil)
+ gomock.InOrder(
+ mockPlatformClient.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{
+ StatusCode: gohttp.StatusOK,
+ Data: platformVersionLookupResult{Results: map[string]platformVersionLookupItem{
+ "42": {Offers: []platformVersionLookupOffer{{Version: platformVersionLookupVersion{ExternalID: "123456"}}}},
+ }},
+ }, nil),
+ mockDownloadClient.EXPECT().Send(gomock.Any()).Do(func(req http.Request) {
+ Expect(req.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("externalVersionId", "123456"))
+ }).Return(http.Result[downloadResult]{StatusCode: gohttp.StatusOK, Data: downloadResult{Items: []downloadItemResult{{Metadata: map[string]interface{}{
+ "softwareVersionExternalIdentifiers": []interface{}{uint64(123455), uint64(123456)},
+ "softwareVersionExternalIdentifier": uint64(123456),
+ }}}}}, nil),
+ )
+ out, err := as.ListVersions(ListVersionsInput{Account: Account{StoreFront: "143441"}, App: App{ID: 42}, Platform: PlatformWatchOS})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(out.ExternalVersionIdentifiers).To(Equal([]string{"123455", "123456"}))
+ Expect(out.LatestExternalVersionID).To(Equal("123456"))
+ })
+
It("rejects unsupported platforms before making requests", func() {
_, err := as.ListVersions(ListVersionsInput{Platform: PlatformUnknown})
Expect(err).To(MatchError(`invalid platform "unknown"`))
diff --git a/pkg/appstore/appstore_lookup.go b/pkg/appstore/appstore_lookup.go
index e53c1447..4fde3ef7 100644
--- a/pkg/appstore/appstore_lookup.go
+++ b/pkg/appstore/appstore_lookup.go
@@ -5,6 +5,7 @@ import (
"fmt"
gohttp "net/http"
"net/url"
+ "slices"
"strconv"
"github.com/majd/ipatool/v2/pkg/http"
@@ -53,6 +54,10 @@ func (t *appstore) Lookup(input LookupInput) (LookupOutput, error) {
return LookupOutput{}, ErrAppNotFound
}
+ if input.Platform == PlatformWatchOS && !slices.Contains(res.Data.Results[0].Platforms, PlatformWatchOS) {
+ return LookupOutput{}, ErrAppNotFound
+ }
+
return LookupOutput{
App: res.Data.Results[0],
}, nil
diff --git a/pkg/appstore/appstore_lookup_test.go b/pkg/appstore/appstore_lookup_test.go
index 574b7c82..27713da6 100644
--- a/pkg/appstore/appstore_lookup_test.go
+++ b/pkg/appstore/appstore_lookup_test.go
@@ -159,6 +159,28 @@ var _ = Describe("AppStore (Lookup)", func() {
})
})
+ DescribeTable("checks watchOS support in lookup results", func(platforms []Platform, found bool) {
+ mockClient.EXPECT().Send(gomock.Any()).Do(func(req http.Request) {
+ parsedURL, err := url.Parse(req.URL)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(parsedURL.Query().Get("entity")).To(Equal("watchSoftware"))
+ }).Return(http.Result[searchResult]{StatusCode: 200, Data: searchResult{
+ Count: 1, Results: []App{{ID: 42, Platforms: platforms}},
+ }}, nil)
+ out, err := as.Lookup(LookupInput{Account: Account{StoreFront: "143441"}, AppID: 42, Platform: PlatformWatchOS})
+ if found {
+ Expect(err).ToNot(HaveOccurred())
+ Expect(out.App.ID).To(Equal(int64(42)))
+ } else {
+ Expect(err).To(MatchError(ErrAppNotFound))
+ }
+ },
+ Entry("Watch app", []Platform{PlatformWatchOS}, true),
+ Entry("iPhone app with Watch support", []Platform{PlatformIPhone, PlatformWatchOS}, true),
+ Entry("iPhone only", []Platform{PlatformIPhone}, false),
+ Entry("unknown platforms", []Platform{PlatformUnknown}, false),
+ )
+
When("store front is invalid", func() {
It("returns error", func() {
_, err := as.Lookup(LookupInput{
diff --git a/pkg/appstore/appstore_platform_version_lookup.go b/pkg/appstore/appstore_platform_version_lookup.go
index 0d7d9fbc..c2b583f9 100644
--- a/pkg/appstore/appstore_platform_version_lookup.go
+++ b/pkg/appstore/appstore_platform_version_lookup.go
@@ -82,7 +82,7 @@ func (t *appstore) lookupLatestExternalVersionID(acc Account, app App, platform
}
catalogs := []string{metadataPlatform}
- if platform == PlatformIPhone || platform == PlatformIPad {
+ if platform == PlatformIPhone || platform == PlatformIPad || platform == PlatformWatchOS {
// Some storefronts have no enterprise listing even when the consumer
// catalogs contain the app. Keep the account's country for each lookup.
catalogs = append(catalogs, "iphone", "ipad")
diff --git a/pkg/appstore/appstore_platform_version_lookup_test.go b/pkg/appstore/appstore_platform_version_lookup_test.go
index c23d0861..36a89d1e 100644
--- a/pkg/appstore/appstore_platform_version_lookup_test.go
+++ b/pkg/appstore/appstore_platform_version_lookup_test.go
@@ -61,6 +61,8 @@ var _ = Describe("Platform version catalog fallback", func() {
Entry("empty offers until iPad", PlatformIPad, noOffers, 2, valid),
Entry("iPad request uses iPhone fallback first", PlatformIPad, missing, 1, valid),
Entry("consumer buy params version", PlatformIPhone, missing, 1, withOffer(platformVersionLookupOffer{BuyParams: "appExtVrsId=891116578"})),
+ Entry("watchOS shares the enterprise catalog", PlatformWatchOS, missing, 0, valid),
+ Entry("watchOS falls back to the consumer catalog", PlatformWatchOS, missing, 1, valid),
)
DescribeTable("reports exhausted catalogs", func(empty platformVersionLookupResult, message string) {
diff --git a/pkg/appstore/appstore_search.go b/pkg/appstore/appstore_search.go
index 94727432..3c4111bd 100644
--- a/pkg/appstore/appstore_search.go
+++ b/pkg/appstore/appstore_search.go
@@ -49,6 +49,14 @@ func (t *appstore) Search(input SearchInput) (SearchOutput, error) {
return SearchOutput{}, NewErrorWithMetadata(errors.New("request failed"), res)
}
+ if input.Platform == PlatformWatchOS {
+ // Watch apps share the software search catalog with iOS apps.
+ res.Data.Results = slices.DeleteFunc(res.Data.Results, func(app App) bool {
+ return !slices.Contains(app.Platforms, PlatformWatchOS)
+ })
+ res.Data.Count = len(res.Data.Results)
+ }
+
return SearchOutput{
Count: res.Data.Count,
Results: res.Data.Results,
@@ -164,6 +172,7 @@ func (r *searchResult) UnmarshalJSON(data []byte) error {
{"iPod", PlatformIPhone},
{"iPad", PlatformIPad},
{"AppleTV", PlatformAppleTV},
+ {"Watch", PlatformWatchOS},
{"RealityDevice", PlatformVisionOS},
{"Mac", PlatformMacOS},
} {
diff --git a/pkg/appstore/appstore_search_test.go b/pkg/appstore/appstore_search_test.go
index 5473dc74..4276b762 100644
--- a/pkg/appstore/appstore_search_test.go
+++ b/pkg/appstore/appstore_search_test.go
@@ -47,6 +47,8 @@ var _ = Describe("AppStore (Search)", func() {
Entry("iPad only", `"supportedDevices":["iPadAir-iPadAir"]`, []Platform{PlatformIPad}),
Entry("iPod", `"supportedDevices":["iPodTouchSixthGen-iPodTouchSixthGen"]`, []Platform{PlatformIPhone}),
Entry("TV", `"supportedDevices":["AppleTV4-AppleTV4"]`, []Platform{PlatformAppleTV}),
+ Entry("Watch", `"supportedDevices":["Watch4-Watch4","Watch5-Watch5"]`, []Platform{PlatformWatchOS}),
+ Entry("iPhone with Watch", `"supportedDevices":["Watch4-Watch4","iPhone6-iPhone6"]`, []Platform{PlatformIPhone, PlatformWatchOS}),
Entry("vision", `"supportedDevices":["RealityDevice-RealityDevice"]`, []Platform{PlatformVisionOS}),
Entry("Mac", `"kind":"mac-software"`, []Platform{PlatformMacOS}),
Entry("multiple families", `"supportedDevices":["MacDesktop-MacDesktop","RealityDevice-RealityDevice","iPadAir-iPadAir"]`, []Platform{PlatformIPad, PlatformVisionOS, PlatformMacOS}),
@@ -54,6 +56,27 @@ var _ = Describe("AppStore (Search)", func() {
Entry("unrecognized devices", `"supportedDevices":["FutureDevice"]`, []Platform{PlatformUnknown}),
)
+ It("filters watchOS searches to apps supporting Apple Watch", func() {
+ mockClient.EXPECT().Send(gomock.Any()).Do(func(req http.Request) {
+ parsedURL, err := url.Parse(req.URL)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(parsedURL.Query().Get("entity")).To(Equal("software,watchSoftware"))
+ }).Return(http.Result[searchResult]{StatusCode: 200, Data: searchResult{
+ Count: 3,
+ Results: []App{
+ {ID: 1, Platforms: []Platform{PlatformIPhone}},
+ {ID: 2, Platforms: []Platform{PlatformWatchOS}},
+ {ID: 3, Platforms: []Platform{PlatformIPhone, PlatformWatchOS}},
+ },
+ }}, nil)
+ out, err := as.Search(SearchInput{Account: Account{StoreFront: "143441"}, Platform: PlatformWatchOS})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(out.Count).To(Equal(2))
+ Expect(out.Results).To(HaveLen(2))
+ Expect(out.Results[0].ID).To(Equal(int64(2)))
+ Expect(out.Results[1].ID).To(Equal(int64(3)))
+ })
+
When("request is successful", func() {
const (
testID = 0
diff --git a/pkg/appstore/appstore_update_product_test.go b/pkg/appstore/appstore_update_product_test.go
index 4ea890b9..17e22ead 100644
--- a/pkg/appstore/appstore_update_product_test.go
+++ b/pkg/appstore/appstore_update_product_test.go
@@ -118,6 +118,7 @@ var _ = Describe("AppStore (Update Product)", func() {
Entry("explicit historical version", testVersionID, PlatformIPhone),
Entry("explicit macOS version", testVersionID, PlatformMacOS),
Entry("explicit tvOS version", testVersionID, PlatformAppleTV),
+ Entry("explicit watchOS version", testVersionID, PlatformWatchOS),
Entry("explicit version without a platform", testVersionID, Platform("")),
)
diff --git a/pkg/appstore/platform.go b/pkg/appstore/platform.go
index f4462930..5e06011a 100644
--- a/pkg/appstore/platform.go
+++ b/pkg/appstore/platform.go
@@ -13,6 +13,7 @@ const (
PlatformIPhone Platform = "iphone"
PlatformIPad Platform = "ipad"
PlatformAppleTV Platform = "appletv"
+ PlatformWatchOS Platform = "watchos"
PlatformVisionOS Platform = "visionos"
PlatformMacOS Platform = "macos"
)
@@ -27,6 +28,8 @@ func ParsePlatform(value string) (Platform, error) {
return PlatformIPad, nil
case "appletv", "apple-tv", "tvos":
return PlatformAppleTV, nil
+ case "watch", "watchos", "applewatch", "apple-watch":
+ return PlatformWatchOS, nil
case "vision", "visionos", "visionpro", "xros", "realitydevice":
return PlatformVisionOS, nil
case "mac", "macos", "osx":
@@ -46,6 +49,8 @@ func (p Platform) lookupEntity() (string, error) {
return "iPadSoftware", nil
case PlatformAppleTV:
return "tvSoftware", nil
+ case PlatformWatchOS:
+ return "watchSoftware", nil
case PlatformVisionOS:
return "xrosSoftware", nil
case PlatformMacOS:
@@ -65,6 +70,8 @@ func (p Platform) searchEntity() (string, error) {
return "iPadSoftware", nil
case PlatformAppleTV:
return "software,tvSoftware", nil
+ case PlatformWatchOS:
+ return "software,watchSoftware", nil
case PlatformVisionOS:
return "xrosSoftware", nil
case PlatformMacOS:
@@ -76,7 +83,7 @@ func (p Platform) searchEntity() (string, error) {
func (p Platform) metadataPlatform() (string, error) {
switch p {
- case PlatformIPhone, PlatformIPad:
+ case PlatformIPhone, PlatformIPad, PlatformWatchOS:
return "enterprisestore", nil
case PlatformAppleTV:
return "atv9", nil
diff --git a/pkg/appstore/platform_test.go b/pkg/appstore/platform_test.go
index 1f9269cb..9f8dadd9 100644
--- a/pkg/appstore/platform_test.go
+++ b/pkg/appstore/platform_test.go
@@ -19,6 +19,10 @@ var _ = Describe("Platform", func() {
Entry("iPadOS", "iPadOS", PlatformIPad),
Entry("AppleTV", "appletv", PlatformAppleTV),
Entry("tvOS", "tvos", PlatformAppleTV),
+ Entry("watchOS", "watchOS", PlatformWatchOS),
+ Entry("Watch", "watch", PlatformWatchOS),
+ Entry("Apple Watch", "applewatch", PlatformWatchOS),
+ Entry("Apple Watch hyphenated", "apple-watch", PlatformWatchOS),
Entry("visionOS", "visionOS", PlatformVisionOS),
Entry("Vision Pro", "visionpro", PlatformVisionOS),
Entry("xrOS", "xrOS", PlatformVisionOS),
@@ -38,6 +42,7 @@ var _ = Describe("Platform", func() {
Entry("iPhone", PlatformIPhone, "software"),
Entry("iPad", PlatformIPad, "iPadSoftware"),
Entry("Apple TV", PlatformAppleTV, "tvSoftware"),
+ Entry("watchOS", PlatformWatchOS, "watchSoftware"),
Entry("visionOS", PlatformVisionOS, "xrosSoftware"),
Entry("macOS", PlatformMacOS, "macSoftware"),
)
@@ -52,6 +57,7 @@ var _ = Describe("Platform", func() {
Entry("iPhone", PlatformIPhone, "software"),
Entry("iPad", PlatformIPad, "iPadSoftware"),
Entry("Apple TV", PlatformAppleTV, "software,tvSoftware"),
+ Entry("watchOS", PlatformWatchOS, "software,watchSoftware"),
Entry("visionOS", PlatformVisionOS, "xrosSoftware"),
Entry("macOS", PlatformMacOS, "macSoftware"),
)
@@ -65,16 +71,17 @@ var _ = Describe("Platform", func() {
Entry("iPhone", PlatformIPhone, "enterprisestore"),
Entry("iPad", PlatformIPad, "enterprisestore"),
Entry("Apple TV", PlatformAppleTV, "atv9"),
+ Entry("watchOS", PlatformWatchOS, "enterprisestore"),
Entry("visionOS", PlatformVisionOS, "realityDevice"),
)
It("returns an error for invalid platforms", func() {
- _, err := ParsePlatform("watch")
+ _, err := ParsePlatform("unsupported")
Expect(err).To(HaveOccurred())
})
It("returns errors when an unknown platform is mapped", func() {
- platform := Platform("watch")
+ platform := Platform("unsupported")
_, lookupErr := platform.lookupEntity()
_, searchErr := platform.searchEntity()