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
11 changes: 7 additions & 4 deletions cmd/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,22 @@ 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{
Account: acc,
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) {
Expand Down Expand Up @@ -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")
Expand Down
68 changes: 67 additions & 1 deletion cmd/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"context"
"errors"
"fmt"

"github.com/majd/ipatool/v2/pkg/appstore"
"github.com/majd/ipatool/v2/pkg/log"
Expand All @@ -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"))
Expand Down Expand Up @@ -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
}
Expand All @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion pkg/appstore/appstore_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
145 changes: 145 additions & 0 deletions pkg/appstore/appstore_download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
})
4 changes: 3 additions & 1 deletion pkg/appstore/appstore_lookup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand Down
2 changes: 1 addition & 1 deletion pkg/appstore/appstore_lookup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ var _ = Describe("AppStore (Lookup)", func() {
StoreFront: "143441",
},
})
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrAppNotFound)).To(BeTrue())
})
})

Expand Down
9 changes: 7 additions & 2 deletions pkg/appstore/appstore_platform_version_lookup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions pkg/appstore/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading