From 9def48342057d925a125759aec2f8e93fdff9329 Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Tue, 29 Sep 2026 23:56:48 +0200 Subject: [PATCH] fix: allow downloads of delisted tvos apps --- cmd/download.go | 11 +- cmd/download_test.go | 68 +++++++- pkg/appstore/appstore_download.go | 6 +- pkg/appstore/appstore_download_test.go | 145 ++++++++++++++++++ pkg/appstore/appstore_lookup.go | 4 +- pkg/appstore/appstore_lookup_test.go | 2 +- .../appstore_platform_version_lookup.go | 9 +- pkg/appstore/error.go | 4 + 8 files changed, 239 insertions(+), 10 deletions(-) diff --git a/cmd/download.go b/cmd/download.go index 4e2f6bc0..b0bada90 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -64,7 +64,7 @@ func downloadCmdWithAppStore(appStore func() appstore.AppStore) *cobra.Command { acc = loginResult.Account } - app := appstore.App{ID: appID} + app := appstore.App{ID: appID, BundleID: bundleID} if bundleID != "" { lookupResult, err := store.Lookup(appstore.LookupInput{ @@ -72,11 +72,14 @@ func downloadCmdWithAppStore(appStore func() appstore.AppStore) *cobra.Command { BundleID: bundleID, Platform: platform, }) - if err != nil { + if err != nil && (appID == 0 || !errors.Is(err, appstore.ErrAppNotFound)) { return err } - app = lookupResult.App + // Delisted apps may still be downloadable by their numeric ID. + if err == nil { + app = lookupResult.App + } } if errors.Is(lastErr, appstore.ErrLicenseRequired) { @@ -165,7 +168,7 @@ func downloadCmdWithAppStore(appStore func() appstore.AppStore) *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().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") diff --git a/cmd/download_test.go b/cmd/download_test.go index 72424d65..f2d98757 100644 --- a/cmd/download_test.go +++ b/cmd/download_test.go @@ -3,6 +3,7 @@ package cmd import ( "context" "errors" + "fmt" "github.com/majd/ipatool/v2/pkg/appstore" "github.com/majd/ipatool/v2/pkg/log" @@ -11,6 +12,69 @@ import ( ) var _ = Describe("Download command", func() { + Describe("app resolution", func() { + var store *fakeDownloadAppStore + + BeforeEach(func() { + store = &fakeDownloadAppStore{account: appstore.Account{StoreFront: "143441"}} + previousDependencies := dependencies + DeferCleanup(func() { dependencies = previousDependencies }) + dependencies.Logger = log.NewLogger(log.Args{}) + }) + + execute := func(args ...string) error { + cmd := downloadCmdWithAppStore(func() appstore.AppStore { return store }) + cmd.SetArgs(args) + cmd.SetContext(context.WithValue(context.Background(), interactiveKey, false)) + + return cmd.Execute() + } + + It("uses the app ID when the bundle is absent from the catalog", func() { + store.lookupError = fmt.Errorf("lookup: %w", appstore.ErrAppNotFound) + Expect(execute("-i", "42", "-b", "com.example.delisted", "--platform", "appletv")).To(Succeed()) + Expect(store.lookupInputs).To(Equal([]appstore.LookupInput{{ + Account: appstore.Account{StoreFront: "143441"}, + BundleID: "com.example.delisted", + Platform: appstore.PlatformAppleTV, + }})) + Expect(store.downloadInputs).To(HaveLen(1)) + Expect(store.downloadInputs[0].App).To(Equal(appstore.App{ID: 42, BundleID: "com.example.delisted"})) + Expect(store.downloadInputs[0].Platform).To(Equal(appstore.PlatformAppleTV)) + }) + + It("preserves bundle identifier precedence when lookup succeeds", func() { + store.lookupOutput.App = appstore.App{ID: 43, BundleID: "com.example.listed"} + Expect(execute("-i", "42", "-b", "com.example.listed")).To(Succeed()) + Expect(store.downloadInputs).To(HaveLen(1)) + Expect(store.downloadInputs[0].App).To(Equal(store.lookupOutput.App)) + }) + + It("resolves a bundle identifier without an app ID", func() { + store.lookupOutput.App = appstore.App{ID: 43, BundleID: "com.example.listed"} + Expect(execute("-b", "com.example.listed")).To(Succeed()) + Expect(store.downloadInputs).To(HaveLen(1)) + Expect(store.downloadInputs[0].App).To(Equal(store.lookupOutput.App)) + }) + + It("does not look up the bundle for an explicit app ID and version", func() { + Expect(execute("-i", "42", "--platform", "appletv", "--external-version-id", "123456")).To(Succeed()) + Expect(store.lookupInputs).To(BeEmpty()) + Expect(store.downloadInputs).To(HaveLen(1)) + Expect(store.downloadInputs[0].App).To(Equal(appstore.App{ID: 42})) + Expect(store.downloadInputs[0].ExternalVersionID).To(Equal("123456")) + }) + + DescribeTable("preserves lookup errors", func(args []string, lookupError error) { + store.lookupError = lookupError + Expect(execute(args...)).To(MatchError(lookupError)) + Expect(store.downloadInputs).To(BeEmpty()) + }, + Entry("missing bundle without an app ID", []string{"-b", "com.example.delisted"}, appstore.ErrAppNotFound), + Entry("request failure with an app ID", []string{"-i", "42", "-b", "com.example.app"}, errors.New("request failed")), + ) + }) + It("exposes macOS in platform help", func() { cmd := downloadCmd() Expect(cmd.Flag("platform").Usage).To(ContainSubstring("macos")) @@ -192,6 +256,8 @@ type fakeDownloadAppStore struct { loginAccount appstore.Account loginInputs []appstore.LoginInput lookupInputs []appstore.LookupInput + lookupOutput appstore.LookupOutput + lookupError error purchaseErrors []error purchaseInputs []appstore.PurchaseInput } @@ -213,7 +279,7 @@ func (*fakeDownloadAppStore) Revoke() error { return nil } func (f *fakeDownloadAppStore) Lookup(input appstore.LookupInput) (appstore.LookupOutput, error) { f.lookupInputs = append(f.lookupInputs, input) - return appstore.LookupOutput{}, nil + return f.lookupOutput, f.lookupError } func (*fakeDownloadAppStore) Search(input appstore.SearchInput) (appstore.SearchOutput, error) { diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index 4dda7ed3..2c1e512b 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -54,7 +54,11 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { externalVersionID := input.ExternalVersionID if externalVersionID == "" && (input.Platform == PlatformAppleTV || input.Platform == PlatformVisionOS) { externalVersionID, err = t.lookupLatestExternalVersionID(input.Account, input.App, input.Platform) - if err != nil { + // Delisted tvOS apps may have no catalog offer but still be available + // for redownload. Validate the returned package's platform below. + missingTVOffer := input.Platform == PlatformAppleTV && + (errors.Is(err, errPlatformAppNotFound) || errors.Is(err, errPlatformOffersNotFound)) + if err != nil && !missingTVOffer { 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 c488f682..af122ae2 100644 --- a/pkg/appstore/appstore_download_test.go +++ b/pkg/appstore/appstore_download_test.go @@ -1173,3 +1173,148 @@ var _ = Describe("AppStore (Download)", func() { }) }) }) + +var _ = Describe("Downloading delisted tvOS apps", func() { + var ( + platformClient *http.MockClient[platformVersionLookupResult] + downloadClient *http.MockClient[downloadResult] + bagClient *http.MockClient[bagResult] + httpClient *http.MockClient[interface{}] + store *appstore + input DownloadInput + ) + + missingApp := platformVersionLookupResult{} + missingOffers := platformVersionLookupResult{Results: map[string]platformVersionLookupItem{"42": {}}} + missingVersion := platformVersionLookupResult{Results: map[string]platformVersionLookupItem{ + "42": {Offers: []platformVersionLookupOffer{{}}}, + }} + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + platformClient = http.NewMockClient[platformVersionLookupResult](ctrl) + downloadClient = http.NewMockClient[downloadResult](ctrl) + bagClient = http.NewMockClient[bagResult](ctrl) + httpClient = http.NewMockClient[interface{}](ctrl) + mockMachine := machine.NewMockMachine(ctrl) + mockMachine.EXPECT().MacAddress().Return("00:11:22:33:44:55", nil) + store = &appstore{ + platformClient: platformClient, + downloadClient: downloadClient, + bagClient: bagClient, + httpClient: httpClient, + machine: mockMachine, + os: operatingsystem.New(), + } + input = DownloadInput{ + Account: Account{StoreFront: "143441"}, + App: App{ID: 42}, + Platform: PlatformAppleTV, + OutputPath: filepath.Join(GinkgoT().TempDir(), "app.ipa"), + } + }) + + DescribeTable("attempts downloads without a catalog offer and validates the package", + func(catalog platformVersionLookupResult, redownload bool, supportedPlatform string) { + previousOutput := []byte("previous output") + Expect(os.WriteFile(input.OutputPath, previousOutput, 0600)).To(Succeed()) + packageBuffer := new(bytes.Buffer) + writer := zip.NewWriter(packageBuffer) + infoWriter, err := writer.Create("Payload/Test.app/Info.plist") + Expect(err).ToNot(HaveOccurred()) + info, err := plist.Marshal(map[string]interface{}{ + "CFBundleSupportedPlatforms": []string{supportedPlatform}, + }, plist.BinaryFormat) + Expect(err).ToNot(HaveOccurred()) + _, err = infoWriter.Write(info) + Expect(err).ToNot(HaveOccurred()) + Expect(writer.Close()).To(Succeed()) + + previous := platformClient.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{ + StatusCode: gohttp.StatusOK, Data: catalog, + }, nil) + checkRequest := func(req http.Request) { + payload := req.Payload.(*http.XMLPayload).Content + Expect(payload).To(HaveKeyWithValue("salableAdamId", input.App.ID)) + Expect(payload).ToNot(HaveKey("externalVersionId")) + Expect(payload).ToNot(HaveKey("appExtVrsId")) + } + if redownload { + previous = downloadClient.EXPECT().Send(gomock.Any()).After(previous).Do(checkRequest). + Return(http.Result[downloadResult]{StatusCode: gohttp.StatusOK}, nil) + previous = bagClient.EXPECT().Send(gomock.Any()).After(previous).Return(http.Result[bagResult]{ + StatusCode: gohttp.StatusOK, + Data: bagResult{URLBag: urlBag{RedownloadEndpoint: testRedownloadEndpoint}}, + }, nil) + } + downloadClient.EXPECT().Send(gomock.Any()).After(previous).Do(checkRequest).Return(http.Result[downloadResult]{ + StatusCode: gohttp.StatusOK, + Data: downloadResult{Items: []downloadItemResult{{ + URL: "https://example.test/app.ipa", + Metadata: map[string]interface{}{"bundleShortVersionString": "1.0"}, + }}}, + }, nil) + httpClient.EXPECT().NewRequest("GET", "https://example.test/app.ipa", nil). + Return(&gohttp.Request{Header: gohttp.Header{}}, nil) + httpClient.EXPECT().Do(gomock.Any()).Return(&gohttp.Response{ + StatusCode: gohttp.StatusOK, + Body: io.NopCloser(bytes.NewReader(packageBuffer.Bytes())), + ContentLength: int64(packageBuffer.Len()), + }, nil) + + out, err := store.Download(input) + if supportedPlatform == "AppleTVOS" { + Expect(err).ToNot(HaveOccurred()) + Expect(out.DestinationPath).To(Equal(input.OutputPath)) + Expect(store.validatePackagePlatform(out.DestinationPath, PlatformAppleTV)).To(Succeed()) + } else { + Expect(err).To(MatchError(ContainSubstring("does not declare AppleTVOS support"))) + Expect(os.ReadFile(input.OutputPath)).To(Equal(previousOutput)) + } + Expect(input.OutputPath + ".tmp").ToNot(BeAnExistingFile()) + }, + Entry("missing app", missingApp, false, "AppleTVOS"), + Entry("missing offers", missingOffers, false, "AppleTVOS"), + Entry("missing app with redownload", missingApp, true, "AppleTVOS"), + Entry("wrong platform from volumeStore", missingApp, false, "iPhoneOS"), + Entry("wrong platform from redownload", missingApp, true, "iPhoneOS"), + ) + + DescribeTable("preserves catalog failures", func(status int, catalog platformVersionLookupResult, sendErr error, message string) { + platformClient.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{ + StatusCode: status, Data: catalog, + }, sendErr) + + _, err := store.Download(input) + Expect(err).To(MatchError(ContainSubstring(message))) + }, + Entry("network failure", 0, missingApp, errors.New("connection reset"), "connection reset"), + Entry("invalid response", 200, missingApp, errors.New("invalid JSON"), "invalid JSON"), + Entry("HTTP failure", 503, missingApp, nil, "platform version lookup request failed"), + Entry("offer without version", 200, missingVersion, nil, "no external version id"), + ) + + It("preserves license errors after a missing catalog entry", func() { + platformClient.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{StatusCode: gohttp.StatusOK}, nil) + downloadClient.EXPECT().Send(gomock.Any()).Return(http.Result[downloadResult]{ + StatusCode: gohttp.StatusOK, + Data: downloadResult{FailureType: FailureTypeLicenseNotFound}, + }, nil) + + _, err := store.Download(input) + Expect(err).To(MatchError(ErrLicenseRequired)) + }) + + It("uses an explicit tvOS version without consulting the catalog", func() { + input.ExternalVersionID = "123456" + downloadClient.EXPECT().Send(gomock.Any()).Do(func(req http.Request) { + Expect(req.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("externalVersionId", input.ExternalVersionID)) + }).Return(http.Result[downloadResult]{ + StatusCode: gohttp.StatusOK, + Data: downloadResult{FailureType: FailureTypeLicenseNotFound}, + }, nil) + + _, err := store.Download(input) + Expect(err).To(MatchError(ErrLicenseRequired)) + }) +}) diff --git a/pkg/appstore/appstore_lookup.go b/pkg/appstore/appstore_lookup.go index 66f86970..3645f15b 100644 --- a/pkg/appstore/appstore_lookup.go +++ b/pkg/appstore/appstore_lookup.go @@ -9,6 +9,8 @@ import ( "github.com/majd/ipatool/v2/pkg/http" ) +var ErrAppNotFound = errors.New("app not found") + type LookupInput struct { Account Account BundleID string @@ -40,7 +42,7 @@ func (t *appstore) Lookup(input LookupInput) (LookupOutput, error) { } if len(res.Data.Results) == 0 { - return LookupOutput{}, errors.New("app not found") + return LookupOutput{}, ErrAppNotFound } return LookupOutput{ diff --git a/pkg/appstore/appstore_lookup_test.go b/pkg/appstore/appstore_lookup_test.go index 88e1d8cf..765921a5 100644 --- a/pkg/appstore/appstore_lookup_test.go +++ b/pkg/appstore/appstore_lookup_test.go @@ -49,7 +49,7 @@ var _ = Describe("AppStore (Lookup)", func() { StoreFront: "143441", }, }) - Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, ErrAppNotFound)).To(BeTrue()) }) }) diff --git a/pkg/appstore/appstore_platform_version_lookup.go b/pkg/appstore/appstore_platform_version_lookup.go index 3631bd3a..f8588c0f 100644 --- a/pkg/appstore/appstore_platform_version_lookup.go +++ b/pkg/appstore/appstore_platform_version_lookup.go @@ -12,6 +12,11 @@ import ( "github.com/majd/ipatool/v2/pkg/http" ) +var ( + errPlatformAppNotFound = errors.New("platform version lookup returned no app") + errPlatformOffersNotFound = errors.New("platform version lookup returned no offers") +) + type platformVersionLookupResult struct { Results map[string]platformVersionLookupItem `json:"results,omitempty"` } @@ -98,13 +103,13 @@ func (t *appstore) lookupLatestExternalVersionID(acc Account, app App, platform item, ok := res.Data.Results[strconv.FormatInt(app.ID, 10)] if !ok { - lastErr = NewErrorWithMetadata(errors.New("platform version lookup returned no app"), res) + lastErr = NewErrorWithMetadata(errPlatformAppNotFound, res) continue } if len(item.Offers) == 0 { - lastErr = NewErrorWithMetadata(errors.New("platform version lookup returned no offers"), res) + lastErr = NewErrorWithMetadata(errPlatformOffersNotFound, res) continue } diff --git a/pkg/appstore/error.go b/pkg/appstore/error.go index f35542af..e920fefd 100644 --- a/pkg/appstore/error.go +++ b/pkg/appstore/error.go @@ -9,6 +9,10 @@ func (t Error) Error() string { return t.underlyingError.Error() } +func (t Error) Unwrap() error { + return t.underlyingError +} + func NewErrorWithMetadata(err error, metadata interface{}) *Error { return &Error{ underlyingError: err,