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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,15 @@ Use "ipatool auth [command] --help" for more information about a command.
To search for apps on the App Store, use the `search` command.

```
Search for iOS apps available on the App Store
Search for iOS and tvOS apps available on the App Store

Usage:
ipatool search <term> [flags]

Flags:
-h, --help help for search
-l, --limit int maximum amount of search results to retrieve (default 5)
-h, --help help for search
-l, --limit int maximum amount of search results to retrieve (default 5)
--platform string Platform to search: iphone, ipad, or appletv

Global Flags:
--format format sets output format for command; can be 'text', 'json' (default text)
Expand Down Expand Up @@ -121,7 +122,7 @@ Global Flags:
To download a copy of the ipa file, use the `download` command.

```
Download (encrypted) iOS app packages from the App Store
Download (encrypted) iOS and tvOS app packages from the App Store

Usage:
ipatool download [flags]
Expand All @@ -132,6 +133,7 @@ Flags:
--external-version-id string External version identifier of the target iOS app (defaults to latest version when not specified)
-h, --help help for download
-o, --output string The destination path of the downloaded app package
--platform string Platform to download for: iphone, ipad, or appletv
--purchase Obtain a license for the app if needed

Global Flags:
Expand Down
2 changes: 1 addition & 1 deletion cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func loginCmd() *cobra.Command {
Use: "login",
Short: "Login to the App Store",
RunE: func(cmd *cobra.Command, args []string) error {
interactive := cmd.Context().Value("interactive").(bool)
interactive, _ := cmd.Context().Value(interactiveKey).(bool)

if password == "" && !interactive {
return errors.New("password is required when not running in interactive mode; use the \"--password\" flag")
Expand Down
25 changes: 21 additions & 4 deletions cmd/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ func downloadCmd() *cobra.Command {
appID int64
bundleID string
externalVersionID string
platformValue string
)

cmd := &cobra.Command{
Use: "download",
Short: "Download (encrypted) iOS app packages from the App Store",
Short: "Download (encrypted) iOS and tvOS 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 @@ -61,8 +62,17 @@ func downloadCmd() *cobra.Command {
}

app := appstore.App{ID: appID}
platform, err := appstore.ParsePlatform(platformValue)
if err != nil {
return err
}

if bundleID != "" {
lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID})
lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{
Account: acc,
BundleID: bundleID,
Platform: platform,
})
if err != nil {
return err
}
Expand All @@ -81,7 +91,7 @@ func downloadCmd() *cobra.Command {
Msg("purchase")
}

interactive, _ := cmd.Context().Value("interactive").(bool)
interactive, _ := cmd.Context().Value(interactiveKey).(bool)
var progress *progressbar.ProgressBar
if interactive {
progress = progressbar.NewOptions64(1,
Expand All @@ -101,7 +111,13 @@ func downloadCmd() *cobra.Command {
}

out, err := dependencies.AppStore.Download(appstore.DownloadInput{
Account: acc, App: app, OutputPath: outputPath, Progress: progress, ExternalVersionID: externalVersionID})
Account: acc,
App: app,
OutputPath: outputPath,
Progress: progress,
ExternalVersionID: externalVersionID,
Platform: platform,
})
if err != nil {
return err
}
Expand Down Expand Up @@ -144,6 +160,7 @@ func downloadCmd() *cobra.Command {
cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)")
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 iOS app (defaults to latest version when not specified)")
cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to download for: iphone, ipad, or appletv")
cmd.Flags().BoolVar(&acquireLicense, "purchase", false, "Obtain a license for the app if needed")

return cmd
Expand Down
20 changes: 15 additions & 5 deletions cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,31 @@ import (

// nolint:wrapcheck
func searchCmd() *cobra.Command {
var limit int64
var (
limit int64
platformValue string
)

cmd := &cobra.Command{
Use: "search <term>",
Short: "Search for iOS apps available on the App Store",
Short: "Search for iOS and tvOS apps available on the App Store",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
infoResult, err := dependencies.AppStore.AccountInfo()
if err != nil {
return err
}

platform, err := appstore.ParsePlatform(platformValue)
if err != nil {
return err
}

output, err := dependencies.AppStore.Search(appstore.SearchInput{
Account: infoResult.Account,
Term: args[0],
Limit: limit,
Account: infoResult.Account,
Term: args[0],
Limit: limit,
Platform: platform,
})
if err != nil {
return err
Expand All @@ -38,6 +47,7 @@ func searchCmd() *cobra.Command {
}

cmd.Flags().Int64VarP(&limit, "limit", "l", 5, "maximum amount of search results to retrieve")
cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to search: iphone, ipad, or appletv")

return cmd
}
2 changes: 2 additions & 0 deletions pkg/appstore/appstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type appstore struct {
searchClient http.Client[searchResult]
purchaseClient http.Client[purchaseResult]
downloadClient http.Client[downloadResult]
platformClient http.Client[platformVersionLookupResult]
bagClient http.Client[bagResult]
httpClient http.Client[interface{}]
machine machine.Machine
Expand All @@ -63,6 +64,7 @@ func NewAppStore(args Args) AppStore {
searchClient: http.NewClient[searchResult](clientArgs),
purchaseClient: http.NewClient[purchaseResult](clientArgs),
downloadClient: http.NewClient[downloadResult](clientArgs),
platformClient: http.NewClient[platformVersionLookupResult](clientArgs),
bagClient: http.NewClient[bagResult](clientArgs),
httpClient: http.NewClient[interface{}](clientArgs),
machine: args.Machine,
Expand Down
69 changes: 68 additions & 1 deletion pkg/appstore/appstore_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type DownloadInput struct {
OutputPath string
Progress *progressbar.ProgressBar
ExternalVersionID string
Platform Platform
}

type DownloadOutput struct {
Expand All @@ -39,7 +40,15 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) {

guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")

req := t.downloadRequest(input.Account, input.App, guid, input.ExternalVersionID)
externalVersionID := input.ExternalVersionID
if externalVersionID == "" && input.Platform == PlatformAppleTV {
externalVersionID, err = t.lookupLatestExternalVersionID(input.Account, input.App, input.Platform)
if err != nil {
return DownloadOutput{}, fmt.Errorf("failed to resolve platform version: %w", err)
}
}

req := t.downloadRequest(input.Account, input.App, guid, externalVersionID)

res, err := t.downloadClient.Send(req)
if err != nil {
Expand Down Expand Up @@ -95,6 +104,11 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) {
return DownloadOutput{}, fmt.Errorf("failed to apply patches: %w", err)
}

err = t.validatePackagePlatform(destination, input.Platform)
if err != nil {
return DownloadOutput{}, fmt.Errorf("failed to validate package platform: %w", err)
}

err = t.os.Remove(fmt.Sprintf("%s.tmp", destination))
if err != nil {
return DownloadOutput{}, fmt.Errorf("failed to remove file: %w", err)
Expand All @@ -106,6 +120,59 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) {
}, nil
}

type platformPackageInfo struct {
SupportedPlatforms []string `plist:"CFBundleSupportedPlatforms,omitempty"`
}

func (*appstore) validatePackagePlatform(path string, platform Platform) error {
if platform != PlatformAppleTV {
return nil
}

reader, err := zip.OpenReader(path)
if err != nil {
return fmt.Errorf("failed to open zip reader: %w", err)
}
defer reader.Close()

for _, file := range reader.File {
if !strings.HasPrefix(file.Name, "Payload/") || !strings.HasSuffix(file.Name, ".app/Info.plist") {
continue
}

infoFile, err := file.Open()
if err != nil {
return fmt.Errorf("failed to open info plist: %w", err)
}

data, readErr := io.ReadAll(infoFile)
closeErr := infoFile.Close()

if readErr != nil {
return fmt.Errorf("failed to read info plist: %w", readErr)
}

if closeErr != nil {
return fmt.Errorf("failed to close info plist: %w", closeErr)
}

var info platformPackageInfo

_, err = plist.Unmarshal(data, &info)
if err != nil {
return fmt.Errorf("failed to decode info plist: %w", err)
}

for _, supportedPlatform := range info.SupportedPlatforms {
if supportedPlatform == "AppleTVOS" {
return nil
}
}
}

return errors.New("downloaded package does not declare AppleTVOS support")
}

type downloadItemResult struct {
HashMD5 string `plist:"md5,omitempty"`
URL string `plist:"URL,omitempty"`
Expand Down
Loading
Loading