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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</p>

<p align="center">
<code>ipatool</code> is a command line tool that allows you to search for iOS, iPadOS, tvOS, visionOS, and macOS apps on the <a href="https://apps.apple.com">App Store</a>, and download <code>.ipa</code> or macOS <code>.pkg</code> app packages.
<code>ipatool</code> is a command line tool that allows you to search for iOS, iPadOS, tvOS, watchOS, visionOS, and macOS apps on the <a href="https://apps.apple.com">App Store</a>, and download <code>.ipa</code> or macOS <code>.pkg</code> app packages.
</p>

<p align="center">
Expand Down Expand Up @@ -45,13 +45,13 @@ Usage:
Available Commands:
auth Authenticate with the App Store
completion Generate the autocompletion script for the specified shell
download Download iOS, iPadOS, tvOS, visionOS, and macOS app packages from the App Store
download Download iOS, iPadOS, tvOS, watchOS, visionOS, and macOS app packages from the App Store
get-version-metadata Retrieves the metadata for a specific version of an app
help Help about any command
list-purchases List apps owned by the authenticated App Store account
list-versions List the available versions of an App Store app
purchase Obtain a license for the app from the App Store
search Search for iOS, iPadOS, tvOS, visionOS, and macOS apps available on the App Store
search Search for iOS, iPadOS, tvOS, watchOS, visionOS, and macOS apps available on the App Store

Flags:
--format format sets output format for command; can be 'text', 'json' (default text)
Expand Down
4 changes: 2 additions & 2 deletions cmd/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func downloadCmdWithAppStore(appStore func() appstore.AppStore) *cobra.Command {

cmd := &cobra.Command{
Use: "download",
Short: "Download iOS, iPadOS, tvOS, visionOS, and macOS app packages from the App Store",
Short: "Download iOS, iPadOS, tvOS, watchOS, visionOS, and macOS 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")
Expand Down Expand Up @@ -171,7 +171,7 @@ func downloadCmdWithAppStore(appStore func() appstore.AppStore) *cobra.Command {
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")
cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to download for: iphone (iOS), ipad (iPadOS), appletv (tvOS), watchos, visionos, or macos")
cmd.Flags().BoolVar(&acquireLicense, "purchase", false, "Obtain a license for the app if needed")

return cmd
Expand Down
15 changes: 9 additions & 6 deletions cmd/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,25 +130,28 @@ var _ = Describe("Download command", func() {
Expect(err).To(MatchError("replication failed"))
})

It("propagates macOS when automatic purchase retries the download", func() {
DescribeTable("preserves the platform when automatic purchase retries the download", func(platform appstore.Platform) {
store := &fakeDownloadAppStore{downloadErrors: []error{appstore.ErrLicenseRequired, nil}}
previousDependencies := dependencies
DeferCleanup(func() { dependencies = previousDependencies })
dependencies.Logger = log.NewLogger(log.Args{})

cmd := downloadCmdWithAppStore(func() appstore.AppStore { return store })
cmd.SetArgs([]string{"--app-id", "42", "--platform", "macos", "--purchase"})
cmd.SetArgs([]string{"--app-id", "42", "--platform", string(platform), "--purchase"})
cmd.SetContext(context.WithValue(context.Background(), interactiveKey, false))

Expect(cmd.Execute()).To(Succeed())
Expect(store.accountInfoCalls).To(Equal(1))
Expect(store.purchaseInputs).To(HaveLen(1))
Expect(store.purchaseInputs[0].Platform).To(Equal(appstore.PlatformMacOS))
Expect(store.purchaseInputs[0].Platform).To(Equal(platform))
Expect(store.purchaseInputs[0].App).To(Equal(appstore.App{ID: 42}))
Expect(store.downloadInputs).To(HaveLen(2))
Expect(store.downloadInputs[0].Platform).To(Equal(appstore.PlatformMacOS))
Expect(store.downloadInputs[1].Platform).To(Equal(appstore.PlatformMacOS))
})
Expect(store.downloadInputs[0].Platform).To(Equal(platform))
Expect(store.downloadInputs[1].Platform).To(Equal(platform))
},
Entry("macOS", appstore.PlatformMacOS),
Entry("watchOS", appstore.PlatformWatchOS),
)

It("retains the pending automatic purchase after refreshing authentication", func() {
store := &fakeDownloadAppStore{
Expand Down
2 changes: 1 addition & 1 deletion cmd/get_version_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func getVersionMetadataCmd() *cobra.Command {
cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target app (overrides the app ID)")
cmd.Flags().StringVar(&externalVersionID, "external-version-id", "", "External version identifier of the target app (required)")

cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to retrieve metadata for: iphone (iOS), ipad (iPadOS), appletv (tvOS), visionos, or macos")
cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to retrieve metadata for: iphone (iOS), ipad (iPadOS), appletv (tvOS), watchos, visionos, or macos")

_ = cmd.MarkFlagRequired("external-version-id")

Expand Down
2 changes: 1 addition & 1 deletion cmd/list_versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func ListVersionsCmd() *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().StringVar(&platformValue, "platform", "", "Platform to list versions for: iphone (iOS), ipad (iPadOS), appletv (tvOS), visionos, or macos")
cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to list versions for: iphone (iOS), ipad (iPadOS), appletv (tvOS), watchos, visionos, or macos")

return cmd
}
11 changes: 7 additions & 4 deletions cmd/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,18 +92,21 @@ var _ = Describe("MCP server", func() {
Expect(result.StructuredContent).To(HaveKeyWithValue("platform", ""))
})

It("passes search platform aliases and limits to the App Store", func() {
DescribeTable("passes search platform aliases and limits to the App Store", func(value string, platform appstore.Platform) {
var input appstore.SearchInput

store.search = func(in appstore.SearchInput) (appstore.SearchOutput, error) {
input = in

return appstore.SearchOutput{}, nil
}
Expect(call("search_apps", map[string]any{"term": "example", "platform": "tvos", "limit": 7}).IsError).To(BeFalse())
Expect(input.Platform).To(Equal(appstore.PlatformAppleTV))
Expect(call("search_apps", map[string]any{"term": "example", "platform": value, "limit": 7}).IsError).To(BeFalse())
Expect(input.Platform).To(Equal(platform))
Expect(input.Limit).To(Equal(int64(7)))
})
},
Entry("tvOS", "tvos", appstore.PlatformAppleTV),
Entry("watchOS", "watch", appstore.PlatformWatchOS),
)

DescribeTable("rejects invalid inputs before accessing the account", func(name string, args map[string]any) {
accountCalls := 0
Expand Down
4 changes: 2 additions & 2 deletions cmd/mcp_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
type mcpSearchInput struct {
Term string `json:"term" jsonschema:"Search term"`
Limit *int64 `json:"limit,omitempty" jsonschema:"Maximum results; defaults to 5. visionOS supports up to 12."`
Platform string `json:"platform,omitempty" jsonschema:"iphone (ios), ipad (ipados), appletv (tvos), visionos, or macos; defaults to iOS and iPadOS"`
Platform string `json:"platform,omitempty" jsonschema:"iphone (ios), ipad (ipados), appletv (tvos), watchos, visionos, or macos; defaults to iOS and iPadOS"`
}

type mcpSearchOutput struct {
Expand All @@ -27,7 +27,7 @@ type mcpSearchOutput struct {
type mcpAppInput struct {
AppID int64 `json:"app_id,omitempty" jsonschema:"Numeric app ID; required unless bundle_identifier is provided"`
BundleID string `json:"bundle_identifier,omitempty" jsonschema:"Bundle identifier; overrides app_id when found"`
Platform string `json:"platform,omitempty" jsonschema:"iphone (ios), ipad (ipados), appletv (tvos), visionos, or macos; uses the CLI default when omitted"`
Platform string `json:"platform,omitempty" jsonschema:"iphone (ios), ipad (ipados), appletv (tvos), watchos, visionos, or macos; uses the CLI default when omitted"`
}

type mcpDownloadInput struct {
Expand Down
2 changes: 1 addition & 1 deletion cmd/purchase.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func purchaseCmdWithAppStore(appStore func() appstore.AppStore) *cobra.Command {

cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target app")
cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target app (overrides the app ID)")
cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to purchase for: iphone (iOS), ipad (iPadOS), appletv (tvOS), visionos, or macos")
cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to purchase for: iphone (iOS), ipad (iPadOS), appletv (tvOS), watchos, visionos, or macos")

return cmd
}
4 changes: 2 additions & 2 deletions cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func searchCmd() *cobra.Command {

cmd := &cobra.Command{
Use: "search <term>",
Short: "Search for iOS, iPadOS, tvOS, visionOS, and macOS apps available on the App Store",
Short: "Search for iOS, iPadOS, tvOS, watchOS, visionOS, and macOS apps available on the App Store",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
infoResult, err := dependencies.AppStore.AccountInfo()
Expand Down Expand Up @@ -47,7 +47,7 @@ func searchCmd() *cobra.Command {
}

cmd.Flags().Int64VarP(&limit, "limit", "l", 5, "maximum amount of search results to retrieve; visionOS supports up to 12")
cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to search: iphone (iOS), ipad (iPadOS), appletv (tvOS), visionos, or macos")
cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to search: iphone (iOS), ipad (iPadOS), appletv (tvOS), watchos, visionos, or macos")

return cmd
}
4 changes: 3 additions & 1 deletion pkg/appstore/appstore_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) {
}

externalVersionID := input.ExternalVersionID
if externalVersionID == "" && (input.Platform == PlatformAppleTV || input.Platform == PlatformVisionOS) {
if externalVersionID == "" && (input.Platform == PlatformAppleTV || input.Platform == PlatformWatchOS || input.Platform == PlatformVisionOS) {
externalVersionID, err = t.lookupLatestExternalVersionID(input.Account, input.App, input.Platform)
// Delisted tvOS apps may have no catalog offer but still be available
// for redownload. Validate the returned package's platform below.
Expand Down Expand Up @@ -159,6 +159,8 @@ func (*appstore) validatePackagePlatform(path string, platform Platform) error {
expectedPlatform = "iPhoneOS"
case PlatformAppleTV:
expectedPlatform = "AppleTVOS"
case PlatformWatchOS:
expectedPlatform = "WatchOS"
case PlatformVisionOS:
expectedPlatform = "XROS"
default:
Expand Down
4 changes: 2 additions & 2 deletions pkg/appstore/appstore_download_product.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func (t *appstore) sendDownloadProduct(ctx context.Context, acc Account, app App

redownloadRes, err := t.downloadClient.Send(t.downloadProductRequest(redownload, acc, app, guid, externalVersionID))
if bag.UpdateEndpoint != "" && externalVersionID != "" &&
(platform == "" || platform == PlatformIPhone || platform == PlatformIPad || platform == PlatformMacOS || platform == PlatformAppleTV) &&
(platform == "" || platform == PlatformIPhone || platform == PlatformIPad || platform == PlatformMacOS || platform == PlatformAppleTV || platform == PlatformWatchOS) &&
(isEmptyRedownloadError(err) || (err == nil && isUnavailableDownloadProductResponse(redownloadRes))) {
updateRes, updateErr := t.sendUpdateProduct(bag.UpdateEndpoint, acc, app, guid, externalVersionID)

Expand Down Expand Up @@ -143,7 +143,7 @@ func (t *appstore) sendUpdateProduct(endpoint string, acc Account, app App, guid
return http.Result[downloadResult]{}, err
}

// The bag's updateProduct can serve pinned iOS, macOS, and tvOS versions when redownload
// The bag's updateProduct can serve pinned app versions when redownload
// returns an empty HTTP 500 or a message-only availability error. Keep
// the same session and version selection.
res, err := t.downloadClient.Send(t.downloadProductRequest(update, acc, app, guid, externalVersionID))
Expand Down
41 changes: 41 additions & 0 deletions pkg/appstore/appstore_download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,29 @@ var _ = Describe("AppStore (Download)", func() {
})
})

It("pins the latest watchOS version before downloading", func() {
mockMachine.EXPECT().MacAddress().Return("00:11:22:33:44:55", nil)
gomock.InOrder(
mockPlatformClient.EXPECT().Send(gomock.Any()).Do(func(req http.Request) {
parsedURL, err := url.Parse(req.URL)
Expect(err).ToNot(HaveOccurred())
Expect(parsedURL.Query().Get("platform")).To(Equal("enterprisestore"))
Expect(parsedURL.Query().Get("id")).To(Equal("6764448634"))
}).Return(http.Result[platformVersionLookupResult]{StatusCode: 200, Data: platformVersionLookupResult{
Results: map[string]platformVersionLookupItem{"6764448634": {Offers: []platformVersionLookupOffer{
{Version: platformVersionLookupVersion{ExternalID: "885050618"}},
}}},
}}, nil),
mockDownloadClient.EXPECT().Send(gomock.Any()).Do(func(req http.Request) {
Expect(req.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("externalVersionId", "885050618"))
}).Return(http.Result[downloadResult]{Data: downloadResult{FailureType: FailureTypeLicenseNotFound}}, nil),
)
_, err := as.Download(DownloadInput{
Account: Account{StoreFront: "143441"}, App: App{ID: 6764448634}, Platform: PlatformWatchOS,
})
Expect(err).To(MatchError(ErrLicenseRequired))
})

When("platform is visionOS", func() {
It("resolves and sends the visionOS external version id", func() {
mockMachine.EXPECT().
Expand Down Expand Up @@ -831,6 +854,13 @@ var _ = Describe("AppStore (Download)", func() {
Entry("tvOS update accepts tvOS", PlatformAppleTV, "818970197", "update", "AppleTVOS", true),
Entry("tvOS update rejects iOS", PlatformAppleTV, "818970197", "update", "iPhoneOS", false),
Entry("tvOS update rejects visionOS", PlatformAppleTV, "818970197", "update", "XROS", false),
Entry("watchOS accepts WatchOS", PlatformWatchOS, "818970197", "", "WatchOS", true),
Entry("watchOS rejects iOS", PlatformWatchOS, "818970197", "", "iPhoneOS", false),
Entry("watchOS rejects tvOS", PlatformWatchOS, "818970197", "", "AppleTVOS", false),
Entry("watchOS redownload preserves the platform", PlatformWatchOS, "818970197", "redownload", "WatchOS", true),
Entry("watchOS update accepts WatchOS", PlatformWatchOS, "818970197", "update", "WatchOS", true),
Entry("watchOS update rejects iOS", PlatformWatchOS, "818970197", "update", "iPhoneOS", false),
Entry("iPhone still rejects WatchOS", PlatformIPhone, "", "", "WatchOS", false),
)

Describe("macOS packages", func() {
Expand Down Expand Up @@ -1143,6 +1173,17 @@ var _ = Describe("AppStore (Download)", func() {
Expect(err.Error()).To(ContainSubstring("AppleTVOS"))
})

It("does not accept watchOS support declared only by an embedded app", func() {
path := writePackageWithInfoPlists(map[string][]string{
"Payload/Test.app/Info.plist": {"iPhoneOS"},
"Payload/Test.app/Watch/Watch.app/Info.plist": {"WatchOS"},
})
defer os.Remove(path)

err := (&appstore{}).validatePackagePlatform(path, PlatformWatchOS)
Expect(err).To(MatchError("downloaded package does not declare WatchOS support"))
})

It("accepts XROS packages", func() {
path := writePackage([]string{"XROS"})
defer os.Remove(path)
Expand Down
2 changes: 1 addition & 1 deletion pkg/appstore/appstore_get_version_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion
}

switch platform {
case PlatformIPhone, PlatformIPad, PlatformAppleTV, PlatformVisionOS, PlatformMacOS:
case PlatformIPhone, PlatformIPad, PlatformAppleTV, PlatformWatchOS, PlatformVisionOS, PlatformMacOS:
default:
return GetVersionMetadataOutput{}, fmt.Errorf("invalid platform %q", platform)
}
Expand Down
8 changes: 6 additions & 2 deletions pkg/appstore/appstore_get_version_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() {
server.Close()
})

It("returns version metadata", func() {
DescribeTable("returns version metadata", func(platform Platform) {
output, err := as.GetVersionMetadata(GetVersionMetadataInput{
Account: Account{
DirectoryServicesID: "test-dsid",
Expand All @@ -558,13 +558,17 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() {
ID: 1234567890,
},
VersionID: "test-version",
Platform: platform,
})

Expect(err).NotTo(HaveOccurred())
Expect(output.DisplayVersion).To(Equal(displayVersion))
Expect(output.ReleaseDate).To(Equal(releaseDate))
Expect(atomic.LoadInt64(wholeGetCount)).To(BeZero())
Expect(atomic.LoadInt64(servedBytes)).To(BeNumerically("<", int64(len(ipa)/2)))
})
},
Entry("default platform", Platform("")),
Entry("watchOS", PlatformWatchOS),
)
})
})
1 change: 1 addition & 0 deletions pkg/appstore/appstore_kbsync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ var _ = Describe("AppStore (preferred kbsync download)", func() {
},
Entry("iPhone", PlatformIPhone), Entry("iPad", PlatformIPad), Entry("macOS", PlatformMacOS),
Entry("tvOS", PlatformAppleTV), Entry("visionOS", PlatformVisionOS),
Entry("watchOS", PlatformWatchOS),
)

It("pins the iOS offer before trying ent/download", func() {
Expand Down
4 changes: 2 additions & 2 deletions pkg/appstore/appstore_list_versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er
}

switch platform {
case PlatformIPhone, PlatformIPad, PlatformAppleTV, PlatformVisionOS, PlatformMacOS:
case PlatformIPhone, PlatformIPad, PlatformAppleTV, PlatformWatchOS, PlatformVisionOS, PlatformMacOS:
default:
return ListVersionsOutput{}, fmt.Errorf("invalid platform %q", platform)
}
Expand All @@ -43,7 +43,7 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er
switch platform {
case PlatformMacOS:
externalVersionID, err = t.lookupLatestMacOSExternalVersionID(input.Account, input.App)
case PlatformAppleTV, PlatformVisionOS:
case PlatformAppleTV, PlatformWatchOS, PlatformVisionOS:
externalVersionID, err = t.lookupLatestExternalVersionID(input.Account, input.App, platform)
}

Expand Down
22 changes: 22 additions & 0 deletions pkg/appstore/appstore_list_versions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,28 @@ var _ = Describe("AppStore (ListVersions)", func() {
Expect(err).To(MatchError(ContainSubstring("failed to resolve platform version")))
})

It("pins the watchOS version before requesting version history", func() {
mockMachine.EXPECT().MacAddress().Return("00:11:22:33:44:55", nil)
gomock.InOrder(
mockPlatformClient.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{
StatusCode: gohttp.StatusOK,
Data: platformVersionLookupResult{Results: map[string]platformVersionLookupItem{
"42": {Offers: []platformVersionLookupOffer{{Version: platformVersionLookupVersion{ExternalID: "123456"}}}},
}},
}, nil),
mockDownloadClient.EXPECT().Send(gomock.Any()).Do(func(req http.Request) {
Expect(req.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("externalVersionId", "123456"))
}).Return(http.Result[downloadResult]{StatusCode: gohttp.StatusOK, Data: downloadResult{Items: []downloadItemResult{{Metadata: map[string]interface{}{
"softwareVersionExternalIdentifiers": []interface{}{uint64(123455), uint64(123456)},
"softwareVersionExternalIdentifier": uint64(123456),
}}}}}, nil),
)
out, err := as.ListVersions(ListVersionsInput{Account: Account{StoreFront: "143441"}, App: App{ID: 42}, Platform: PlatformWatchOS})
Expect(err).ToNot(HaveOccurred())
Expect(out.ExternalVersionIdentifiers).To(Equal([]string{"123455", "123456"}))
Expect(out.LatestExternalVersionID).To(Equal("123456"))
})

It("rejects unsupported platforms before making requests", func() {
_, err := as.ListVersions(ListVersionsInput{Platform: PlatformUnknown})
Expect(err).To(MatchError(`invalid platform "unknown"`))
Expand Down
Loading
Loading