From f039b44dd1bdddcbbff7472ea0b8a0705c890831 Mon Sep 17 00:00:00 2001 From: Lakr Date: Tue, 12 May 2026 12:07:37 +0900 Subject: [PATCH 1/3] add tvOS app download support --- README.md | 10 +- cmd/auth.go | 2 +- cmd/download.go | 25 +++- cmd/search.go | 16 +- pkg/appstore/appstore.go | 2 + pkg/appstore/appstore_download.go | 66 +++++++- pkg/appstore/appstore_download_test.go | 100 +++++++++++++ pkg/appstore/appstore_lookup.go | 28 +++- pkg/appstore/appstore_lookup_test.go | 24 +++ .../appstore_platform_version_lookup.go | 141 ++++++++++++++++++ pkg/appstore/appstore_search.go | 34 +++-- pkg/appstore/appstore_search_test.go | 24 +++ pkg/appstore/platform.go | 67 +++++++++ pkg/appstore/platform_test.go | 27 ++++ 14 files changed, 535 insertions(+), 31 deletions(-) create mode 100644 pkg/appstore/appstore_platform_version_lookup.go create mode 100644 pkg/appstore/platform.go create mode 100644 pkg/appstore/platform_test.go diff --git a/README.md b/README.md index 2768dcd6..eb85ed1c 100644 --- a/README.md +++ b/README.md @@ -65,14 +65,15 @@ Use "ipatool auth [command] --help" for more information about a command. To search for apps on the App Store, use the `search` command. ``` -Search for iOS apps available on the App Store +Search for iOS and tvOS apps available on the App Store Usage: ipatool search [flags] Flags: - -h, --help help for search - -l, --limit int maximum amount of search results to retrieve (default 5) + -h, --help help for search + -l, --limit int maximum amount of search results to retrieve (default 5) + --platform string Platform to search: iphone, ipad, or appletv Global Flags: --format format sets output format for command; can be 'text', 'json' (default text) @@ -121,7 +122,7 @@ Global Flags: To download a copy of the ipa file, use the `download` command. ``` -Download (encrypted) iOS app packages from the App Store +Download (encrypted) iOS and tvOS app packages from the App Store Usage: ipatool download [flags] @@ -132,6 +133,7 @@ Flags: --external-version-id string External version identifier of the target iOS app (defaults to latest version when not specified) -h, --help help for download -o, --output string The destination path of the downloaded app package + --platform string Platform to download for: iphone, ipad, or appletv --purchase Obtain a license for the app if needed Global Flags: diff --git a/cmd/auth.go b/cmd/auth.go index 34e1d4a2..0adf2e78 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -47,7 +47,7 @@ func loginCmd() *cobra.Command { Use: "login", Short: "Login to the App Store", RunE: func(cmd *cobra.Command, args []string) error { - interactive := cmd.Context().Value("interactive").(bool) + interactive, _ := cmd.Context().Value(interactiveKey).(bool) if password == "" && !interactive { return errors.New("password is required when not running in interactive mode; use the \"--password\" flag") diff --git a/cmd/download.go b/cmd/download.go index 0df1d366..b1e2108f 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -20,11 +20,12 @@ func downloadCmd() *cobra.Command { appID int64 bundleID string externalVersionID string + platformValue string ) cmd := &cobra.Command{ Use: "download", - Short: "Download (encrypted) iOS app packages from the App Store", + Short: "Download (encrypted) iOS and tvOS 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") @@ -61,8 +62,17 @@ func downloadCmd() *cobra.Command { } app := appstore.App{ID: appID} + platform, err := appstore.ParsePlatform(platformValue) + if err != nil { + return err + } + if bundleID != "" { - lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID}) + lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{ + Account: acc, + BundleID: bundleID, + Platform: platform, + }) if err != nil { return err } @@ -81,7 +91,7 @@ func downloadCmd() *cobra.Command { Msg("purchase") } - interactive, _ := cmd.Context().Value("interactive").(bool) + interactive, _ := cmd.Context().Value(interactiveKey).(bool) var progress *progressbar.ProgressBar if interactive { progress = progressbar.NewOptions64(1, @@ -101,7 +111,13 @@ func downloadCmd() *cobra.Command { } out, err := dependencies.AppStore.Download(appstore.DownloadInput{ - Account: acc, App: app, OutputPath: outputPath, Progress: progress, ExternalVersionID: externalVersionID}) + Account: acc, + App: app, + OutputPath: outputPath, + Progress: progress, + ExternalVersionID: externalVersionID, + Platform: platform, + }) if err != nil { return err } @@ -144,6 +160,7 @@ func downloadCmd() *cobra.Command { cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)") 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 iOS app (defaults to latest version when not specified)") + cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to download for: iphone, ipad, or appletv") cmd.Flags().BoolVar(&acquireLicense, "purchase", false, "Obtain a license for the app if needed") return cmd diff --git a/cmd/search.go b/cmd/search.go index 0f0ed5df..6ebc1aa4 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -8,10 +8,11 @@ import ( // nolint:wrapcheck func searchCmd() *cobra.Command { var limit int64 + var platformValue string cmd := &cobra.Command{ Use: "search ", - Short: "Search for iOS apps available on the App Store", + Short: "Search for iOS and tvOS apps available on the App Store", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { infoResult, err := dependencies.AppStore.AccountInfo() @@ -19,10 +20,16 @@ func searchCmd() *cobra.Command { return err } + platform, err := appstore.ParsePlatform(platformValue) + if err != nil { + return err + } + output, err := dependencies.AppStore.Search(appstore.SearchInput{ - Account: infoResult.Account, - Term: args[0], - Limit: limit, + Account: infoResult.Account, + Term: args[0], + Limit: limit, + Platform: platform, }) if err != nil { return err @@ -38,6 +45,7 @@ func searchCmd() *cobra.Command { } cmd.Flags().Int64VarP(&limit, "limit", "l", 5, "maximum amount of search results to retrieve") + cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to search: iphone, ipad, or appletv") return cmd } diff --git a/pkg/appstore/appstore.go b/pkg/appstore/appstore.go index 49b23809..e91ddb7e 100644 --- a/pkg/appstore/appstore.go +++ b/pkg/appstore/appstore.go @@ -39,6 +39,7 @@ type appstore struct { searchClient http.Client[searchResult] purchaseClient http.Client[purchaseResult] downloadClient http.Client[downloadResult] + platformClient http.Client[platformVersionLookupResult] bagClient http.Client[bagResult] httpClient http.Client[interface{}] machine machine.Machine @@ -63,6 +64,7 @@ func NewAppStore(args Args) AppStore { searchClient: http.NewClient[searchResult](clientArgs), purchaseClient: http.NewClient[purchaseResult](clientArgs), downloadClient: http.NewClient[downloadResult](clientArgs), + platformClient: http.NewClient[platformVersionLookupResult](clientArgs), bagClient: http.NewClient[bagResult](clientArgs), httpClient: http.NewClient[interface{}](clientArgs), machine: args.Machine, diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index 4e714dab..78779c57 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -24,6 +24,7 @@ type DownloadInput struct { OutputPath string Progress *progressbar.ProgressBar ExternalVersionID string + Platform Platform } type DownloadOutput struct { @@ -39,7 +40,15 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") - req := t.downloadRequest(input.Account, input.App, guid, input.ExternalVersionID) + externalVersionID := input.ExternalVersionID + if externalVersionID == "" && input.Platform != "" { + externalVersionID, err = t.lookupLatestExternalVersionID(input.Account, input.App, input.Platform) + if err != nil { + return DownloadOutput{}, fmt.Errorf("failed to resolve platform version: %w", err) + } + } + + req := t.downloadRequest(input.Account, input.App, guid, externalVersionID) res, err := t.downloadClient.Send(req) if err != nil { @@ -95,6 +104,11 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { return DownloadOutput{}, fmt.Errorf("failed to apply patches: %w", err) } + err = t.validatePackagePlatform(destination, input.Platform) + if err != nil { + return DownloadOutput{}, fmt.Errorf("failed to validate package platform: %w", err) + } + err = t.os.Remove(fmt.Sprintf("%s.tmp", destination)) if err != nil { return DownloadOutput{}, fmt.Errorf("failed to remove file: %w", err) @@ -106,6 +120,56 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { }, nil } +type platformPackageInfo struct { + SupportedPlatforms []string `plist:"CFBundleSupportedPlatforms,omitempty"` +} + +func (*appstore) validatePackagePlatform(path string, platform Platform) error { + if platform != PlatformAppleTV { + return nil + } + + reader, err := zip.OpenReader(path) + if err != nil { + return fmt.Errorf("failed to open zip reader: %w", err) + } + defer reader.Close() + + for _, file := range reader.File { + if !strings.HasPrefix(file.Name, "Payload/") || !strings.HasSuffix(file.Name, ".app/Info.plist") { + continue + } + + infoFile, err := file.Open() + if err != nil { + return fmt.Errorf("failed to open info plist: %w", err) + } + + data, err := io.ReadAll(infoFile) + closeErr := infoFile.Close() + if err != nil { + return fmt.Errorf("failed to read info plist: %w", err) + } + if closeErr != nil { + return fmt.Errorf("failed to close info plist: %w", closeErr) + } + + var info platformPackageInfo + _, err = plist.Unmarshal(data, &info) + if err != nil { + return fmt.Errorf("failed to decode info plist: %w", err) + } + + for _, supportedPlatform := range info.SupportedPlatforms { + if supportedPlatform == "AppleTVOS" { + return nil + } + } + } + + return errors.New("downloaded package does not declare AppleTVOS support") +} + type downloadItemResult struct { HashMD5 string `plist:"md5,omitempty"` URL string `plist:"URL,omitempty"` diff --git a/pkg/appstore/appstore_download_test.go b/pkg/appstore/appstore_download_test.go index f5fb8f90..a57d1b9c 100644 --- a/pkg/appstore/appstore_download_test.go +++ b/pkg/appstore/appstore_download_test.go @@ -7,6 +7,7 @@ import ( "io" "io/fs" gohttp "net/http" + "net/url" "os" "strings" "time" @@ -35,6 +36,7 @@ var _ = Describe("AppStore (Download)", func() { ctrl *gomock.Controller mockKeychain *keychain.MockKeychain mockDownloadClient *http.MockClient[downloadResult] + mockPlatformClient *http.MockClient[platformVersionLookupResult] mockPurchaseClient *http.MockClient[purchaseResult] mockLoginClient *http.MockClient[loginResult] mockHTTPClient *http.MockClient[interface{}] @@ -47,6 +49,7 @@ var _ = Describe("AppStore (Download)", func() { ctrl = gomock.NewController(GinkgoT()) mockKeychain = keychain.NewMockKeychain(ctrl) mockDownloadClient = http.NewMockClient[downloadResult](ctrl) + mockPlatformClient = http.NewMockClient[platformVersionLookupResult](ctrl) mockLoginClient = http.NewMockClient[loginResult](ctrl) mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl) mockHTTPClient = http.NewMockClient[interface{}](ctrl) @@ -57,6 +60,7 @@ var _ = Describe("AppStore (Download)", func() { loginClient: mockLoginClient, purchaseClient: mockPurchaseClient, downloadClient: mockDownloadClient, + platformClient: mockPlatformClient, httpClient: mockHTTPClient, machine: mockMachine, os: mockOS, @@ -127,6 +131,62 @@ var _ = Describe("AppStore (Download)", func() { }) }) + When("platform is AppleTV", func() { + BeforeEach(func() { + mockMachine.EXPECT(). + MacAddress(). + Return("00:11:22:33:44:55", nil) + + mockPlatformClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + parsedURL, err := url.Parse(req.URL) + Expect(err).ToNot(HaveOccurred()) + Expect(parsedURL.Host).To(Equal("uclient-api.itunes.apple.com")) + Expect(parsedURL.Query().Get("platform")).To(Equal("atv9")) + Expect(parsedURL.Query().Get("cc")).To(Equal("us")) + }). + Return(http.Result[platformVersionLookupResult]{ + StatusCode: 200, + Data: platformVersionLookupResult{ + Results: map[string]platformVersionLookupItem{ + "42": { + Offers: []platformVersionLookupOffer{ + { + Version: platformVersionLookupVersion{ + ExternalID: platformVersionExternalID("123456"), + }, + }, + }, + }, + }, + }, + }, nil) + + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + payload, ok := req.Payload.(*http.XMLPayload) + Expect(ok).To(BeTrue()) + Expect(payload.Content["externalVersionId"]).To(Equal("123456")) + }). + Return(http.Result[downloadResult]{}, errors.New("request error")) + }) + + It("resolves and sends the tvOS external version id", func() { + _, err := as.Download(DownloadInput{ + Account: Account{ + StoreFront: "143441", + }, + App: App{ + ID: 42, + }, + Platform: PlatformAppleTV, + }) + Expect(err).To(HaveOccurred()) + }) + }) + When("password token is expired", func() { BeforeEach(func() { mockMachine.EXPECT(). @@ -533,4 +593,44 @@ var _ = Describe("AppStore (Download)", func() { }) }) }) + + Describe("package platform validation", func() { + writePackage := func(platforms []string) string { + file, err := os.CreateTemp("", "ipatool-platform-*.ipa") + Expect(err).ToNot(HaveOccurred()) + defer file.Close() + + zipFile := zip.NewWriter(file) + w, err := zipFile.Create("Payload/Test.app/Info.plist") + Expect(err).ToNot(HaveOccurred()) + + info, err := plist.Marshal(map[string]interface{}{ + "CFBundleSupportedPlatforms": platforms, + }, plist.BinaryFormat) + Expect(err).ToNot(HaveOccurred()) + + _, err = w.Write(info) + Expect(err).ToNot(HaveOccurred()) + Expect(zipFile.Close()).To(Succeed()) + + return file.Name() + } + + It("accepts AppleTVOS packages", func() { + path := writePackage([]string{"AppleTVOS"}) + defer os.Remove(path) + + err := (&appstore{}).validatePackagePlatform(path, PlatformAppleTV) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns an error for packages without AppleTVOS support", func() { + path := writePackage([]string{"iPhoneOS"}) + defer os.Remove(path) + + err := (&appstore{}).validatePackagePlatform(path, PlatformAppleTV) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("AppleTVOS")) + }) + }) }) diff --git a/pkg/appstore/appstore_lookup.go b/pkg/appstore/appstore_lookup.go index dc755827..66f86970 100644 --- a/pkg/appstore/appstore_lookup.go +++ b/pkg/appstore/appstore_lookup.go @@ -12,6 +12,7 @@ import ( type LookupInput struct { Account Account BundleID string + Platform Platform } type LookupOutput struct { @@ -24,7 +25,10 @@ func (t *appstore) Lookup(input LookupInput) (LookupOutput, error) { return LookupOutput{}, fmt.Errorf("failed to resolve the country code: %w", err) } - request := t.lookupRequest(input.BundleID, countryCode) + request, err := t.lookupRequest(input.BundleID, countryCode, input.Platform) + if err != nil { + return LookupOutput{}, fmt.Errorf("failed to create lookup request: %w", err) + } res, err := t.searchClient.Send(request) if err != nil { @@ -44,21 +48,31 @@ func (t *appstore) Lookup(input LookupInput) (LookupOutput, error) { }, nil } -func (t *appstore) lookupRequest(bundleID, countryCode string) http.Request { +func (t *appstore) lookupRequest(bundleID, countryCode string, platform Platform) (http.Request, error) { + url, err := t.lookupURL(bundleID, countryCode, platform) + if err != nil { + return http.Request{}, err + } + return http.Request{ - URL: t.lookupURL(bundleID, countryCode), + URL: url, Method: http.MethodGET, ResponseFormat: http.ResponseFormatJSON, - } + }, nil } -func (t *appstore) lookupURL(bundleID, countryCode string) string { +func (t *appstore) lookupURL(bundleID, countryCode string, platform Platform) (string, error) { + entity, err := platform.lookupEntity() + if err != nil { + return "", err + } + params := url.Values{} - params.Add("entity", "software,iPadSoftware") + params.Add("entity", entity) params.Add("limit", "1") params.Add("media", "software") params.Add("bundleId", bundleID) params.Add("country", countryCode) - return fmt.Sprintf("https://%s%s?%s", iTunesAPIDomain, iTunesAPIPathLookup, params.Encode()) + return fmt.Sprintf("https://%s%s?%s", iTunesAPIDomain, iTunesAPIPathLookup, params.Encode()), nil } diff --git a/pkg/appstore/appstore_lookup_test.go b/pkg/appstore/appstore_lookup_test.go index be84bc7c..c0b4526c 100644 --- a/pkg/appstore/appstore_lookup_test.go +++ b/pkg/appstore/appstore_lookup_test.go @@ -2,6 +2,7 @@ package appstore import ( "errors" + "net/url" "github.com/majd/ipatool/v2/pkg/http" . "github.com/onsi/ginkgo/v2" @@ -85,6 +86,29 @@ var _ = Describe("AppStore (Lookup)", func() { }) }) + When("platform is AppleTV", func() { + BeforeEach(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("tvSoftware")) + }). + Return(http.Result[searchResult]{}, errors.New("request error")) + }) + + It("uses the tvOS lookup entity", func() { + _, err := as.Lookup(LookupInput{ + Account: Account{ + StoreFront: "143441", + }, + Platform: PlatformAppleTV, + }) + Expect(err).To(HaveOccurred()) + }) + }) + 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 new file mode 100644 index 00000000..1638fa8c --- /dev/null +++ b/pkg/appstore/appstore_platform_version_lookup.go @@ -0,0 +1,141 @@ +package appstore + +import ( + "encoding/json" + "errors" + "fmt" + gohttp "net/http" + "net/url" + "strconv" + "strings" + + "github.com/majd/ipatool/v2/pkg/http" +) + +type platformVersionLookupResult struct { + Results map[string]platformVersionLookupItem `json:"results,omitempty"` +} + +type platformVersionLookupItem struct { + BundleID string `json:"bundleId,omitempty"` + Name string `json:"name,omitempty"` + Offers []platformVersionLookupOffer `json:"offers,omitempty"` +} + +type platformVersionLookupOffer struct { + BuyParams string `json:"buyParams,omitempty"` + Version platformVersionLookupVersion `json:"version,omitempty"` +} + +type platformVersionLookupVersion struct { + Display string `json:"display,omitempty"` + ExternalID platformVersionExternalID `json:"externalId,omitempty"` +} + +type platformVersionExternalID string + +func (id *platformVersionExternalID) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + + var stringID string + if err := json.Unmarshal(data, &stringID); err == nil { + *id = platformVersionExternalID(stringID) + + return nil + } + + var numberID json.Number + if err := json.Unmarshal(data, &numberID); err == nil { + *id = platformVersionExternalID(numberID.String()) + + return nil + } + + return fmt.Errorf("invalid external version id %s", string(data)) +} + +func (t *appstore) lookupLatestExternalVersionID(acc Account, app App, platform Platform) (string, error) { + if app.ID == 0 { + return "", errors.New("app ID is required for platform version lookup") + } + + countryCode, err := countryCodeFromStoreFront(acc.StoreFront) + if err != nil { + return "", fmt.Errorf("failed to resolve the country code: %w", err) + } + + request, err := t.platformVersionLookupRequest(app.ID, countryCode, platform) + if err != nil { + return "", fmt.Errorf("failed to create platform version lookup request: %w", err) + } + + res, err := t.platformClient.Send(request) + if err != nil { + return "", fmt.Errorf("platform version lookup request failed: %w", err) + } + + if res.StatusCode != gohttp.StatusOK { + return "", NewErrorWithMetadata(errors.New("platform version lookup request failed"), res) + } + + item, ok := res.Data.Results[strconv.FormatInt(app.ID, 10)] + if !ok { + return "", NewErrorWithMetadata(errors.New("platform version lookup returned no app"), res) + } + + if len(item.Offers) == 0 { + return "", NewErrorWithMetadata(errors.New("platform version lookup returned no offers"), res) + } + + offer := item.Offers[0] + externalVersionID := string(offer.Version.ExternalID) + if externalVersionID == "" { + externalVersionID, err = externalVersionIDFromBuyParams(offer.BuyParams) + if err != nil { + return "", fmt.Errorf("failed to parse buy params: %w", err) + } + } + + if externalVersionID == "" { + return "", NewErrorWithMetadata(errors.New("platform version lookup returned no external version id"), res) + } + + return externalVersionID, nil +} + +func (*appstore) platformVersionLookupRequest(appID int64, countryCode string, platform Platform) (http.Request, error) { + metadataPlatform, err := platform.metadataPlatform() + if err != nil { + return http.Request{}, err + } + + params := url.Values{} + params.Add("version", "2") + params.Add("id", strconv.FormatInt(appID, 10)) + params.Add("p", "mdm-lockup") + params.Add("caller", "MDM") + params.Add("platform", metadataPlatform) + params.Add("cc", strings.ToLower(countryCode)) + params.Add("l", "en") + + return http.Request{ + URL: fmt.Sprintf("https://uclient-api.itunes.apple.com/WebObjects/MZStorePlatform.woa/wa/lookup?%s", params.Encode()), + Method: http.MethodGET, + ResponseFormat: http.ResponseFormatJSON, + }, nil +} + +func externalVersionIDFromBuyParams(buyParams string) (string, error) { + if buyParams == "" { + return "", nil + } + + values, err := url.ParseQuery(buyParams) + if err != nil { + return "", err + } + + return values.Get("appExtVrsId"), nil +} diff --git a/pkg/appstore/appstore_search.go b/pkg/appstore/appstore_search.go index 9b48c5b8..015bfd12 100644 --- a/pkg/appstore/appstore_search.go +++ b/pkg/appstore/appstore_search.go @@ -11,9 +11,10 @@ import ( ) type SearchInput struct { - Account Account - Term string - Limit int64 + Account Account + Term string + Limit int64 + Platform Platform } type SearchOutput struct { @@ -27,7 +28,10 @@ func (t *appstore) Search(input SearchInput) (SearchOutput, error) { return SearchOutput{}, fmt.Errorf("country code is invalid: %w", err) } - request := t.searchRequest(input.Term, countryCode, input.Limit) + request, err := t.searchRequest(input.Term, countryCode, input.Limit, input.Platform) + if err != nil { + return SearchOutput{}, fmt.Errorf("failed to create search request: %w", err) + } res, err := t.searchClient.Send(request) if err != nil { @@ -49,21 +53,31 @@ type searchResult struct { Results []App `json:"results,omitempty"` } -func (t *appstore) searchRequest(term, countryCode string, limit int64) http.Request { +func (t *appstore) searchRequest(term, countryCode string, limit int64, platform Platform) (http.Request, error) { + url, err := t.searchURL(term, countryCode, limit, platform) + if err != nil { + return http.Request{}, err + } + return http.Request{ - URL: t.searchURL(term, countryCode, limit), + URL: url, Method: http.MethodGET, ResponseFormat: http.ResponseFormatJSON, - } + }, nil } -func (t *appstore) searchURL(term, countryCode string, limit int64) string { +func (t *appstore) searchURL(term, countryCode string, limit int64, platform Platform) (string, error) { + entity, err := platform.searchEntity() + if err != nil { + return "", err + } + params := url.Values{} - params.Add("entity", "software,iPadSoftware") + params.Add("entity", entity) params.Add("limit", strconv.Itoa(int(limit))) params.Add("media", "software") params.Add("term", term) params.Add("country", countryCode) - return fmt.Sprintf("https://%s%s?%s", iTunesAPIDomain, iTunesAPIPathSearch, params.Encode()) + return fmt.Sprintf("https://%s%s?%s", iTunesAPIDomain, iTunesAPIPathSearch, params.Encode()), nil } diff --git a/pkg/appstore/appstore_search_test.go b/pkg/appstore/appstore_search_test.go index fdedd4c2..635d5676 100644 --- a/pkg/appstore/appstore_search_test.go +++ b/pkg/appstore/appstore_search_test.go @@ -2,6 +2,7 @@ package appstore import ( "errors" + "net/url" "github.com/majd/ipatool/v2/pkg/http" . "github.com/onsi/ginkgo/v2" @@ -76,6 +77,29 @@ var _ = Describe("AppStore (Search)", func() { }) }) + When("platform is AppleTV", func() { + BeforeEach(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,tvSoftware")) + }). + Return(http.Result[searchResult]{}, errors.New("request error")) + }) + + It("uses the tvOS search entity", func() { + _, err := as.Search(SearchInput{ + Account: Account{ + StoreFront: "143441", + }, + Platform: PlatformAppleTV, + }) + Expect(err).To(HaveOccurred()) + }) + }) + When("store front is invalid", func() { It("returns error", func() { _, err := as.Search(SearchInput{ diff --git a/pkg/appstore/platform.go b/pkg/appstore/platform.go new file mode 100644 index 00000000..e76ee42c --- /dev/null +++ b/pkg/appstore/platform.go @@ -0,0 +1,67 @@ +package appstore + +import "fmt" + +type Platform string + +const ( + PlatformIPhone Platform = "iphone" + PlatformIPad Platform = "ipad" + PlatformAppleTV Platform = "appletv" +) + +func ParsePlatform(value string) (Platform, error) { + switch value { + case "": + return "", nil + case "iphone", "iPhone", "ios", "iOS": + return PlatformIPhone, nil + case "ipad", "iPad": + return PlatformIPad, nil + case "appletv", "AppleTV", "apple-tv", "tvos", "tvOS": + return PlatformAppleTV, nil + default: + return "", fmt.Errorf("invalid platform %q", value) + } +} + +func (p Platform) lookupEntity() (string, error) { + switch p { + case "": + return "software,iPadSoftware", nil + case PlatformIPhone: + return "software", nil + case PlatformIPad: + return "iPadSoftware", nil + case PlatformAppleTV: + return "tvSoftware", nil + default: + return "", fmt.Errorf("invalid platform %q", p) + } +} + +func (p Platform) searchEntity() (string, error) { + switch p { + case "": + return "software,iPadSoftware", nil + case PlatformIPhone: + return "software", nil + case PlatformIPad: + return "iPadSoftware", nil + case PlatformAppleTV: + return "software,tvSoftware", nil + default: + return "", fmt.Errorf("invalid platform %q", p) + } +} + +func (p Platform) metadataPlatform() (string, error) { + switch p { + case PlatformIPhone, PlatformIPad: + return "enterprisestore", nil + case PlatformAppleTV: + return "atv9", nil + default: + return "", fmt.Errorf("invalid platform %q", p) + } +} diff --git a/pkg/appstore/platform_test.go b/pkg/appstore/platform_test.go new file mode 100644 index 00000000..388f530d --- /dev/null +++ b/pkg/appstore/platform_test.go @@ -0,0 +1,27 @@ +package appstore + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Platform", func() { + DescribeTable("parses aliases", + func(value string, expected Platform) { + platform, err := ParsePlatform(value) + Expect(err).ToNot(HaveOccurred()) + Expect(platform).To(Equal(expected)) + }, + Entry("default", "", Platform("")), + Entry("iPhone", "iphone", PlatformIPhone), + Entry("iOS", "ios", PlatformIPhone), + Entry("iPad", "ipad", PlatformIPad), + Entry("AppleTV", "appletv", PlatformAppleTV), + Entry("tvOS", "tvos", PlatformAppleTV), + ) + + It("returns an error for invalid platforms", func() { + _, err := ParsePlatform("watch") + Expect(err).To(HaveOccurred()) + }) +}) From 5d45605a6505828d2a502a7091dd95900389c7ac Mon Sep 17 00:00:00 2001 From: Lakr Date: Mon, 18 May 2026 15:56:39 +0800 Subject: [PATCH 2/3] Restrict platform version lookup to tvOS --- pkg/appstore/appstore_download.go | 2 +- pkg/appstore/appstore_download_test.go | 30 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index 78779c57..59d5d517 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -41,7 +41,7 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") externalVersionID := input.ExternalVersionID - if externalVersionID == "" && input.Platform != "" { + if externalVersionID == "" && input.Platform == PlatformAppleTV { externalVersionID, err = t.lookupLatestExternalVersionID(input.Account, input.App, input.Platform) if err != nil { return DownloadOutput{}, fmt.Errorf("failed to resolve platform version: %w", err) diff --git a/pkg/appstore/appstore_download_test.go b/pkg/appstore/appstore_download_test.go index a57d1b9c..3432a4ce 100644 --- a/pkg/appstore/appstore_download_test.go +++ b/pkg/appstore/appstore_download_test.go @@ -187,6 +187,36 @@ var _ = Describe("AppStore (Download)", func() { }) }) + DescribeTable("platform uses the standard download request", + func(platform Platform) { + mockMachine.EXPECT(). + MacAddress(). + Return("00:11:22:33:44:55", nil) + + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + payload, ok := req.Payload.(*http.XMLPayload) + Expect(ok).To(BeTrue()) + Expect(payload.Content).ToNot(HaveKey("externalVersionId")) + }). + Return(http.Result[downloadResult]{}, errors.New("request error")) + + _, err := as.Download(DownloadInput{ + Account: Account{ + StoreFront: "143441", + }, + App: App{ + ID: 42, + }, + Platform: platform, + }) + Expect(err).To(HaveOccurred()) + }, + Entry("iPhone", PlatformIPhone), + Entry("iPad", PlatformIPad), + ) + When("password token is expired", func() { BeforeEach(func() { mockMachine.EXPECT(). From ae5ac03db3f84236a88b2e2cf5637110ba47cd84 Mon Sep 17 00:00:00 2001 From: Lakr Date: Mon, 18 May 2026 16:01:48 +0800 Subject: [PATCH 3/3] fix lint issues --- cmd/search.go | 6 ++++-- pkg/appstore/appstore_download.go | 9 ++++++--- pkg/appstore/appstore_platform_version_lookup.go | 3 ++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/cmd/search.go b/cmd/search.go index 6ebc1aa4..3f962457 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -7,8 +7,10 @@ import ( // nolint:wrapcheck func searchCmd() *cobra.Command { - var limit int64 - var platformValue string + var ( + limit int64 + platformValue string + ) cmd := &cobra.Command{ Use: "search ", diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index 59d5d517..f86ee77a 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -145,16 +145,19 @@ func (*appstore) validatePackagePlatform(path string, platform Platform) error { return fmt.Errorf("failed to open info plist: %w", err) } - data, err := io.ReadAll(infoFile) + data, readErr := io.ReadAll(infoFile) closeErr := infoFile.Close() - if err != nil { - return fmt.Errorf("failed to read info plist: %w", err) + + if readErr != nil { + return fmt.Errorf("failed to read info plist: %w", readErr) } + if closeErr != nil { return fmt.Errorf("failed to close info plist: %w", closeErr) } var info platformPackageInfo + _, err = plist.Unmarshal(data, &info) if err != nil { return fmt.Errorf("failed to decode info plist: %w", err) diff --git a/pkg/appstore/appstore_platform_version_lookup.go b/pkg/appstore/appstore_platform_version_lookup.go index 1638fa8c..3334fe96 100644 --- a/pkg/appstore/appstore_platform_version_lookup.go +++ b/pkg/appstore/appstore_platform_version_lookup.go @@ -91,6 +91,7 @@ func (t *appstore) lookupLatestExternalVersionID(acc Account, app App, platform offer := item.Offers[0] externalVersionID := string(offer.Version.ExternalID) + if externalVersionID == "" { externalVersionID, err = externalVersionIDFromBuyParams(offer.BuyParams) if err != nil { @@ -134,7 +135,7 @@ func externalVersionIDFromBuyParams(buyParams string) (string, error) { values, err := url.ParseQuery(buyParams) if err != nil { - return "", err + return "", fmt.Errorf("failed to parse query: %w", err) } return values.Get("appExtVrsId"), nil