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()