Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions pkg/appstore/appstore_search.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package appstore

import (
"encoding/json"
"errors"
"fmt"
gohttp "net/http"
"net/url"
"slices"
"strconv"
"strings"

Expand Down Expand Up @@ -106,6 +108,15 @@ func (t *appstore) searchVisionOS(input SearchInput, countryCode string) (Search
if metadata, ok := hydrated[app.ID]; ok {
storefrontApps[index] = metadata
}

platforms := slices.DeleteFunc(storefrontApps[index].Platforms, func(platform Platform) bool {
return platform == PlatformUnknown
})
if !slices.Contains(platforms, PlatformVisionOS) {
platforms = append(platforms, PlatformVisionOS)
}

storefrontApps[index].Platforms = platforms
}

return SearchOutput{
Expand All @@ -119,6 +130,65 @@ type searchResult struct {
Results []App `json:"results,omitempty"`
}

// UnmarshalJSON derives platforms from catalog metadata without exposing the
// catalog's device list in app output.
func (r *searchResult) UnmarshalJSON(data []byte) error {
var result struct {
Count int `json:"resultCount"`
Results []struct {
App
Kind string `json:"kind"`
SupportedDevices []string `json:"supportedDevices"`
} `json:"results"`
}

if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("failed to decode search results: %w", err)
}

r.Count = result.Count
r.Results = nil

if result.Results != nil {
r.Results = make([]App, 0, len(result.Results))
}

for _, item := range result.Results {
platforms := item.Platforms

for _, family := range []struct {
prefix string
platform Platform
}{
{"iPhone", PlatformIPhone},
{"iPod", PlatformIPhone},
{"iPad", PlatformIPad},
{"AppleTV", PlatformAppleTV},
{"RealityDevice", PlatformVisionOS},
{"Mac", PlatformMacOS},
} {
for _, device := range item.SupportedDevices {
if strings.HasPrefix(device, family.prefix) && !slices.Contains(platforms, family.platform) {
platforms = append(platforms, family.platform)
}
}
}

if item.Kind == "mac-software" && !slices.Contains(platforms, PlatformMacOS) {
platforms = append(platforms, PlatformMacOS)
}

if len(platforms) == 0 {
platforms = []Platform{PlatformUnknown}
}

item.Platforms = platforms
r.Results = append(r.Results, item.App)
}

return nil
}

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 {
Expand Down
42 changes: 40 additions & 2 deletions pkg/appstore/appstore_search_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package appstore

import (
"encoding/json"
"errors"
"net/url"

Expand Down Expand Up @@ -32,6 +33,27 @@ var _ = Describe("AppStore (Search)", func() {
ctrl.Finish()
})

DescribeTable("decodes catalog platforms",
func(metadata string, expected []Platform) {
var result searchResult
Expect(json.Unmarshal([]byte(`{"resultCount":1,"results":[{"trackId":42,`+metadata+`}]}`), &result)).To(Succeed())
mockClient.EXPECT().Send(gomock.Any()).Return(http.Result[searchResult]{StatusCode: 200, Data: result}, nil)
out, err := as.Search(SearchInput{Account: Account{StoreFront: "143441"}})
Expect(err).ToNot(HaveOccurred())
Expect(out.Count).To(Equal(1))
Expect(out.Results).To(Equal([]App{{ID: 42, Platforms: expected}}))
},
Entry("universal app with duplicate devices", `"supportedDevices":["iPadAir-iPadAir","iPhone5s-iPhone5s","iPhone6-iPhone6","iPodTouchSixthGen-iPodTouchSixthGen"]`, []Platform{PlatformIPhone, PlatformIPad}),
Entry("iPad only", `"supportedDevices":["iPadAir-iPadAir"]`, []Platform{PlatformIPad}),
Entry("iPod", `"supportedDevices":["iPodTouchSixthGen-iPodTouchSixthGen"]`, []Platform{PlatformIPhone}),
Entry("TV", `"supportedDevices":["AppleTV4-AppleTV4"]`, []Platform{PlatformAppleTV}),
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}),
Entry("missing metadata", `"kind":"software"`, []Platform{PlatformUnknown}),
Entry("unrecognized devices", `"supportedDevices":["FutureDevice"]`, []Platform{PlatformUnknown}),
)

When("request is successful", func() {
const (
testID = 0
Expand Down Expand Up @@ -184,12 +206,28 @@ var _ = Describe("AppStore (Search)", func() {
Expect(err).ToNot(HaveOccurred())
Expect(out.Count).To(Equal(2))
Expect(out.Results).To(Equal([]App{
{ID: 200, BundleID: "hydrated.two", Name: "Hydrated Two", Version: "2.0"},
{ID: 100, BundleID: "hydrated.one", Name: "Hydrated One", Version: "1.0"},
{ID: 200, BundleID: "hydrated.two", Name: "Hydrated Two", Version: "2.0", Platforms: []Platform{PlatformVisionOS}},
{ID: 100, BundleID: "hydrated.one", Name: "Hydrated One", Version: "1.0", Platforms: []Platform{PlatformVisionOS}},
}))
})
})

DescribeTable("preserves confirmed visionOS support",
func(metadata []App, expected []Platform) {
page := `<script type="application/json" id="serialized-server-data">{"data":[{"data":{"shelves":[{"items":[{"$kind":"AppSearchResult","lockup":{"adamId":"42"},"purchaseConfiguration":{"metricsPlatformDisplayStyle":"vision","appPlatforms":["vision"],"buyParams":"salableAdamId=42&appExtVrsId=420"}}]}]}}]}</script>`
mockStorefrontClient.EXPECT().Send(gomock.Any()).Return(http.Result[[]byte]{StatusCode: 200, Data: []byte(page)}, nil)
mockClient.EXPECT().Send(gomock.Any()).Return(http.Result[searchResult]{StatusCode: 200, Data: searchResult{Results: metadata}}, nil)
out, err := as.Search(SearchInput{Account: Account{StoreFront: "143441"}, Platform: PlatformVisionOS, Limit: 1})
Expect(err).ToNot(HaveOccurred())
Expect(out.Results).To(HaveLen(1))
Expect(out.Results[0].Platforms).To(Equal(expected))
},
Entry("missing lookup result", nil, []Platform{PlatformVisionOS}),
Entry("unknown lookup platforms", []App{{ID: 42, Platforms: []Platform{PlatformUnknown}}}, []Platform{PlatformVisionOS}),
Entry("additional lookup platforms", []App{{ID: 42, Platforms: []Platform{PlatformIPhone, PlatformIPad}}}, []Platform{PlatformIPhone, PlatformIPad, PlatformVisionOS}),
Entry("vision already present", []App{{ID: 42, Platforms: []Platform{PlatformVisionOS}}}, []Platform{PlatformVisionOS}),
)

When("store front is invalid", func() {
It("returns error", func() {
_, err := as.Search(SearchInput{
Expand Down
Loading