From ae1557058c23415001c94c7b14e5fd253bd0db33 Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Thu, 8 Oct 2026 00:57:44 +0200 Subject: [PATCH] fix: resolve apple arcade versions through the catalog api --- .../appstore_download_product_test.go | 2 +- pkg/appstore/appstore_ios_version_lookup.go | 67 ++++++++ .../appstore_ios_version_lookup_test.go | 152 ++++++++++++++++++ .../appstore_platform_version_lookup.go | 12 ++ .../appstore_platform_version_lookup_test.go | 7 +- 5 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 pkg/appstore/appstore_ios_version_lookup.go create mode 100644 pkg/appstore/appstore_ios_version_lookup_test.go diff --git a/pkg/appstore/appstore_download_product_test.go b/pkg/appstore/appstore_download_product_test.go index 683eb652..14469d35 100644 --- a/pkg/appstore/appstore_download_product_test.go +++ b/pkg/appstore/appstore_download_product_test.go @@ -367,7 +367,7 @@ var _ = Describe("AppStore (Download Product)", func() { It("does not send an unpinned request when the app is absent from the catalog", func() { mockPlatformClient.EXPECT().Send(gomock.Any()). - Return(http.Result[platformVersionLookupResult]{StatusCode: gohttp.StatusOK}, nil).Times(3) + Return(http.Result[platformVersionLookupResult]{StatusCode: gohttp.StatusOK}, nil).Times(4) _, _, err := store.sendDownloadProduct(context.Background(), account, app, testGUID, "", PlatformIPhone) Expect(err).To(MatchError(ContainSubstring("platform version lookup returned no app"))) diff --git a/pkg/appstore/appstore_ios_version_lookup.go b/pkg/appstore/appstore_ios_version_lookup.go new file mode 100644 index 00000000..2f2f459c --- /dev/null +++ b/pkg/appstore/appstore_ios_version_lookup.go @@ -0,0 +1,67 @@ +package appstore + +import ( + "errors" + "fmt" + gohttp "net/http" + "slices" + "strconv" + "strings" + + "github.com/majd/ipatool/v2/pkg/http" +) + +type catalogVersionLookupItem struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Attributes struct { + DeviceFamilies []string `json:"deviceFamilies,omitempty"` + PlatformAttributes map[string]catalogVersionPlatformAttributes `json:"platformAttributes,omitempty"` + } `json:"attributes,omitempty"` +} + +type catalogVersionPlatformAttributes struct { + BundleID string `json:"bundleId,omitempty"` + ExternalVersionID platformVersionExternalID `json:"externalVersionId,omitempty"` +} + +func (t *appstore) lookupLatestIOSExternalVersionID(app App, countryCode string, platform Platform) (string, error) { + // This is the JSON catalog API used by the public App Store client. + res, err := t.platformClient.Send(http.Request{ + URL: fmt.Sprintf("https://apps.apple.com/api/apps/v1/catalog/%s/apps/%d?platform=%s", strings.ToLower(countryCode), app.ID, platform), + Method: http.MethodGET, + ResponseFormat: http.ResponseFormatJSON, + }) + if err != nil { + return "", fmt.Errorf("iOS catalog lookup request failed: %w", err) + } + + if res.StatusCode != gohttp.StatusOK { + return "", fmt.Errorf("iOS catalog lookup returned HTTP %d", res.StatusCode) + } + + if len(res.Data.Data) != 1 { + return "", errors.New("iOS catalog lookup must return exactly one app") + } + + item := res.Data.Data[0] + if item.ID != strconv.FormatInt(app.ID, 10) || item.Type != "apps" { + return "", errors.New("iOS catalog lookup returned a different app") + } + + attributes, ok := item.Attributes.PlatformAttributes["ios"] + if !ok || !slices.Contains(item.Attributes.DeviceFamilies, string(platform)) { + return "", errors.New("iOS catalog lookup returned no metadata for the requested platform") + } + + if app.BundleID != "" && attributes.BundleID != app.BundleID { + return "", errors.New("iOS catalog lookup returned a different bundle identifier") + } + + version := string(attributes.ExternalVersionID) + if id, err := strconv.ParseUint(version, 10, 64); err != nil || id == 0 { + return "", errors.New("iOS catalog lookup returned no valid external version id") + } + + return version, nil +} diff --git a/pkg/appstore/appstore_ios_version_lookup_test.go b/pkg/appstore/appstore_ios_version_lookup_test.go new file mode 100644 index 00000000..a68fdd54 --- /dev/null +++ b/pkg/appstore/appstore_ios_version_lookup_test.go @@ -0,0 +1,152 @@ +package appstore + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/majd/ipatool/v2/pkg/http" + "github.com/majd/ipatool/v2/pkg/util/machine" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +// Synthetic catalog API response for an example app. +const arcadeCatalogResponse = `{"data":[{"id":"42","type":"apps","attributes":{"deviceFamilies":["tvos","iphone","ipad","ipod"],"platformAttributes":{"ios":{"bundleId":"com.example.arcade","externalVersionId":1002}}}}]}` + +func decodeCatalogResponse(body string) platformVersionLookupResult { + var result platformVersionLookupResult + + Expect(json.Unmarshal([]byte(body), &result)).To(Succeed()) + + return result +} + +var _ = Describe("iOS catalog version fallback", func() { + var client *http.MockClient[platformVersionLookupResult] + var store *appstore + app := App{ID: 42, BundleID: "com.example.arcade"} + account := Account{StoreFront: "143443-2,34"} + + BeforeEach(func() { + client = http.NewMockClient[platformVersionLookupResult](gomock.NewController(GinkgoT())) + store = &appstore{platformClient: client} + }) + + DescribeTable("resolves Arcade versions after exhausting the MDM catalogs", func(platform Platform, noOffers bool, quotedVersion bool) { + empty := platformVersionLookupResult{} + if noOffers { + empty.Results = map[string]platformVersionLookupItem{"42": {}} + } + body := arcadeCatalogResponse + if quotedVersion { + body = strings.ReplaceAll(body, "1002", `"1002"`) + } + calls := make([]any, 0, 4) + for _, catalog := range []string{"enterprisestore", "iphone", "ipad"} { + calls = append(calls, client.EXPECT().Send(store.platformVersionLookupRequest(app.ID, "DE", catalog)).Return(http.Result[platformVersionLookupResult]{StatusCode: 200, Data: empty}, nil)) + } + calls = append(calls, client.EXPECT().Send(http.Request{ + URL: "https://apps.apple.com/api/apps/v1/catalog/de/apps/42?platform=" + string(platform), + Method: http.MethodGET, + ResponseFormat: http.ResponseFormatJSON, + }).Return(http.Result[platformVersionLookupResult]{StatusCode: 200, Data: decodeCatalogResponse(body)}, nil)) + gomock.InOrder(calls...) + version, err := store.lookupLatestExternalVersionID(account, app, platform) + Expect(err).ToNot(HaveOccurred()) + Expect(version).To(Equal("1002")) + }, + Entry("missing iPhone app", PlatformIPhone, false, false), + Entry("missing iPad offers", PlatformIPad, true, false), + Entry("string version identifier", PlatformIPhone, false, true), + ) + + DescribeTable("preserves catalog and API failures", func(status int, requestErr error, message string) { + gomock.InOrder( + client.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{StatusCode: 200}, nil).Times(3), + client.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{StatusCode: status}, requestErr), + ) + _, err := store.lookupLatestExternalVersionID(account, app, PlatformIPhone) + Expect(err).To(MatchError(And(ContainSubstring("iOS catalog fallback failed"), ContainSubstring(message), ContainSubstring("storefront DE")))) + Expect(errors.Is(err, errPlatformAppNotFound)).To(BeTrue()) + if requestErr != nil { + Expect(errors.Is(err, requestErr)).To(BeTrue()) + } + }, + Entry("HTTP failure", 503, nil, "HTTP 503"), + Entry("network failure", 0, errors.New("connection reset"), "connection reset"), + Entry("decoding failure", 200, errors.New("invalid JSON"), "invalid JSON"), + Entry("empty response", 200, nil, "exactly one app"), + ) + + DescribeTable("rejects unusable API metadata", func(body string, message string) { + client.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{StatusCode: 200, Data: decodeCatalogResponse(body)}, nil) + _, err := store.lookupLatestIOSExternalVersionID(app, "US", PlatformIPhone) + Expect(err).To(MatchError(ContainSubstring(message))) + }, + Entry("no app", `{"data":[]}`, "exactly one app"), + Entry("wrong app", strings.ReplaceAll(arcadeCatalogResponse, "42", "1"), "different app"), + Entry("wrong resource type", strings.ReplaceAll(arcadeCatalogResponse, `"apps"`, `"app-bundles"`), "different app"), + Entry("wrong bundle", strings.ReplaceAll(arcadeCatalogResponse, "com.example.arcade", "other"), "different bundle"), + Entry("Mac version only", strings.ReplaceAll(arcadeCatalogResponse, `"ios"`, `"macos"`), "requested platform"), + Entry("TV version only", strings.ReplaceAll(arcadeCatalogResponse, `"ios"`, `"tvos"`), "requested platform"), + Entry("iPad only", strings.ReplaceAll(arcadeCatalogResponse, `"iphone",`, ""), "requested platform"), + Entry("missing version", strings.ReplaceAll(arcadeCatalogResponse, "1002", "null"), "no valid external version id"), + Entry("zero version", strings.ReplaceAll(arcadeCatalogResponse, "1002", "0"), "no valid external version id"), + Entry("negative version", strings.ReplaceAll(arcadeCatalogResponse, "1002", "-1"), "no valid external version id"), + Entry("fractional version", strings.ReplaceAll(arcadeCatalogResponse, "1002", "1.5"), "no valid external version id"), + Entry("nonnumeric version", strings.ReplaceAll(arcadeCatalogResponse, "1002", `"invalid"`), "no valid external version id"), + ) + + It("supports requests made with only an app ID", func() { + client.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{StatusCode: 200, Data: decodeCatalogResponse(arcadeCatalogResponse)}, nil) + version, err := store.lookupLatestIOSExternalVersionID(App{ID: app.ID}, "US", PlatformIPhone) + Expect(err).ToNot(HaveOccurred()) + Expect(version).To(Equal("1002")) + }) +}) + +var _ = Describe("ListVersions for apps missing from the MDM catalogs", func() { + DescribeTable("uses the API version for redownload and retains license handling", func(licensed bool) { + ctrl := gomock.NewController(GinkgoT()) + defer ctrl.Finish() + catalog := http.NewMockClient[platformVersionLookupResult](ctrl) + downloads := http.NewMockClient[downloadResult](ctrl) + bags := http.NewMockClient[bagResult](ctrl) + device := machine.NewMockMachine(ctrl) + store := &appstore{platformClient: catalog, downloadClient: downloads, bagClient: bags, machine: device} + device.EXPECT().MacAddress().Return("00:11:22:33:44:55", nil) + result := downloadResult{FailureType: FailureTypeLicenseNotFound} + if licensed { + result = downloadResult{Items: []downloadItemResult{{Metadata: map[string]interface{}{ + "softwareVersionExternalIdentifiers": []interface{}{uint64(1001), uint64(1002)}, + "softwareVersionExternalIdentifier": uint64(1002), + }}}} + } + gomock.InOrder( + downloads.EXPECT().Send(gomock.Any()).Return(http.Result[downloadResult]{StatusCode: 200}, nil), + bags.EXPECT().Send(gomock.Any()).Return(http.Result[bagResult]{StatusCode: 200, Data: bagResult{URLBag: urlBag{RedownloadEndpoint: testRedownloadEndpoint}}}, nil), + catalog.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{StatusCode: 200}, nil).Times(3), + catalog.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{StatusCode: 200, Data: decodeCatalogResponse(arcadeCatalogResponse)}, nil), + downloads.EXPECT().Send(gomock.Any()).Do(func(req http.Request) { + Expect(req.URL).To(Equal(testRedownloadEndpoint + "?guid=001122334455")) + Expect(req.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("appExtVrsId", "1002")) + }).Return(http.Result[downloadResult]{StatusCode: 200, Data: result}, nil), + ) + out, err := store.ListVersions(ListVersionsInput{ + Account: Account{StoreFront: "143441-1,34"}, + App: App{ID: 42, BundleID: "com.example.arcade"}, + }) + if licensed { + Expect(err).ToNot(HaveOccurred()) + Expect(out.LatestExternalVersionID).To(Equal("1002")) + Expect(out.ExternalVersionIdentifiers).To(Equal([]string{"1001", "1002"})) + } else { + Expect(err).To(MatchError(ErrLicenseRequired)) + } + }, + Entry("licensed app", true), + Entry("missing license", false), + ) +}) diff --git a/pkg/appstore/appstore_platform_version_lookup.go b/pkg/appstore/appstore_platform_version_lookup.go index f8588c0f..0d7d9fbc 100644 --- a/pkg/appstore/appstore_platform_version_lookup.go +++ b/pkg/appstore/appstore_platform_version_lookup.go @@ -19,6 +19,7 @@ var ( type platformVersionLookupResult struct { Results map[string]platformVersionLookupItem `json:"results,omitempty"` + Data []catalogVersionLookupItem `json:"data,omitempty"` } type platformVersionLookupItem struct { @@ -131,6 +132,17 @@ func (t *appstore) lookupLatestExternalVersionID(acc Account, app App, platform return externalVersionID, nil } + if platform == PlatformIPhone || platform == PlatformIPad { + // Apple Arcade apps can be absent from every MDM catalog. The App + // Store's catalog API still exposes their current iOS version. + version, err := t.lookupLatestIOSExternalVersionID(app, countryCode, platform) + if err == nil { + return version, nil + } + + lastErr = fmt.Errorf("%w; iOS catalog fallback failed: %w", lastErr, err) + } + return "", fmt.Errorf("app %d in storefront %s (catalogs: %s): %w", app.ID, countryCode, strings.Join(catalogs, ", "), lastErr) } diff --git a/pkg/appstore/appstore_platform_version_lookup_test.go b/pkg/appstore/appstore_platform_version_lookup_test.go index 9ad18e3b..c23d0861 100644 --- a/pkg/appstore/appstore_platform_version_lookup_test.go +++ b/pkg/appstore/appstore_platform_version_lookup_test.go @@ -64,7 +64,12 @@ var _ = Describe("Platform version catalog fallback", func() { ) DescribeTable("reports exhausted catalogs", func(empty platformVersionLookupResult, message string) { - gomock.InOrder(expectLookup("enterprisestore", 200, empty, nil), expectLookup("iphone", 200, empty, nil), expectLookup("ipad", 200, empty, nil)) + gomock.InOrder( + expectLookup("enterprisestore", 200, empty, nil), + expectLookup("iphone", 200, empty, nil), + expectLookup("ipad", 200, empty, nil), + client.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{StatusCode: 200}, nil), + ) version, err := store.lookupLatestExternalVersionID(account, app, PlatformIPhone) Expect(version).To(BeEmpty()) Expect(err).To(MatchError(And(ContainSubstring(message), ContainSubstring("6472431552"), ContainSubstring("RU"), ContainSubstring("enterprisestore, iphone, ipad"))))