Showing preview only (209K chars total). Download the full file or copy to clipboard to get everything.
Repository: majd/ipatool
Branch: main
Commit: e91415603665
Files: 93
Total size: 189.3 KB
Directory structure:
gitextract_bcs5uo8x/
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug-report.yaml
│ │ └── feature-request.yaml
│ └── workflows/
│ ├── dry-build.yml
│ ├── integration-tests.yml
│ ├── lint.yml
│ ├── release.yml
│ └── unit-tests.yml
├── .gitignore
├── .golangci.yml
├── AGENTS.md
├── LICENSE
├── README.md
├── cmd/
│ ├── auth.go
│ ├── common.go
│ ├── constants.go
│ ├── download.go
│ ├── get_version_metadata.go
│ ├── list_versions.go
│ ├── output_format.go
│ ├── purchase.go
│ ├── root.go
│ └── search.go
├── go.mod
├── go.sum
├── main.go
├── pkg/
│ ├── appstore/
│ │ ├── account.go
│ │ ├── app.go
│ │ ├── app_test.go
│ │ ├── appstore.go
│ │ ├── appstore_account_info.go
│ │ ├── appstore_account_info_test.go
│ │ ├── appstore_bag.go
│ │ ├── appstore_bag_test.go
│ │ ├── appstore_download.go
│ │ ├── appstore_download_test.go
│ │ ├── appstore_get_version_metadata.go
│ │ ├── appstore_get_version_metadata_test.go
│ │ ├── appstore_list_versions.go
│ │ ├── appstore_list_versions_test.go
│ │ ├── appstore_login.go
│ │ ├── appstore_login_test.go
│ │ ├── appstore_lookup.go
│ │ ├── appstore_lookup_test.go
│ │ ├── appstore_purchase.go
│ │ ├── appstore_purchase_test.go
│ │ ├── appstore_replicate_sinf.go
│ │ ├── appstore_replicate_sinf_test.go
│ │ ├── appstore_revoke.go
│ │ ├── appstore_revoke_test.go
│ │ ├── appstore_search.go
│ │ ├── appstore_search_test.go
│ │ ├── appstore_test.go
│ │ ├── constants.go
│ │ ├── error.go
│ │ └── storefront.go
│ ├── http/
│ │ ├── client.go
│ │ ├── client_test.go
│ │ ├── constants.go
│ │ ├── cookiejar.go
│ │ ├── http_test.go
│ │ ├── method.go
│ │ ├── payload.go
│ │ ├── payload_test.go
│ │ ├── request.go
│ │ └── result.go
│ ├── keychain/
│ │ ├── keychain.go
│ │ ├── keychain_get.go
│ │ ├── keychain_get_test.go
│ │ ├── keychain_remove.go
│ │ ├── keychain_remove_test.go
│ │ ├── keychain_set.go
│ │ ├── keychain_set_test.go
│ │ ├── keychain_test.go
│ │ └── keyring.go
│ ├── log/
│ │ ├── log_test.go
│ │ ├── logger.go
│ │ ├── logger_test.go
│ │ ├── writer.go
│ │ └── writer_test.go
│ └── util/
│ ├── machine/
│ │ ├── machine.go
│ │ └── machine_test.go
│ ├── must.go
│ ├── must_test.go
│ ├── operatingsystem/
│ │ ├── operatingsystem.go
│ │ └── operatingsystem_test.go
│ ├── string.go
│ ├── string_test.go
│ ├── util_test.go
│ ├── zip.go
│ └── zip_test.go
├── tools/
│ └── sha256sum.sh
└── tools.go
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
# github: [majd]
patreon: majd_dev
================================================
FILE: .github/ISSUE_TEMPLATE/bug-report.yaml
================================================
name: Bug Report
description: File a bug report
labels:
- bug
body:
- type: textarea
id: description
attributes:
label: What happened?
description: Also share, what did you expect to happen?
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: What version of ipatool are you running?
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
================================================
FILE: .github/ISSUE_TEMPLATE/feature-request.yaml
================================================
name: Feature Request
description: Submit a feature request
labels:
- feature request
body:
- type: textarea
id: description
attributes:
label: Description
description: Please provide details about the desired feature.
validations:
required: true
================================================
FILE: .github/workflows/dry-build.yml
================================================
name: Dry Build
on:
pull_request:
branches:
- main
jobs:
build_windows:
name: Build for Windows
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
arch: [arm64, amd64]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v3
with:
go-version: "1.23.0"
cache: true
- run: go build -o ipatool-$GOOS-$GOARCH.exe
env:
GOOS: windows
GOARCH: ${{ matrix.arch }}
build_linux:
name: Build for Linux
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
arch: [arm64, amd64]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v3
with:
go-version: "1.23.0"
cache: true
- run: go build -o ipatool-$GOOS-$GOARCH
env:
GOOS: linux
GOARCH: ${{ matrix.arch }}
build_macos:
name: Build for macOS
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
arch: [arm64, amd64]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v3
with:
go-version: "1.23.0"
cache: true
- run: go build -o ipatool-$GOOS-$GOARCH
env:
GOOS: darwin
GOARCH: ${{ matrix.arch }}
CGO_CFLAGS: -mmacosx-version-min=10.15
CGO_LDFLAGS: -mmacosx-version-min=10.15
================================================
FILE: .github/workflows/integration-tests.yml
================================================
name: Integration Tests
on:
pull_request:
branches:
- main
jobs:
build:
name: Build
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v3
with:
go-version: "1.23.0"
cache: true
- run: go build -o ipatool
env:
CGO_CFLAGS: -mmacosx-version-min=10.15
CGO_LDFLAGS: -mmacosx-version-min=10.15
- uses: actions/upload-artifact@v4
with:
name: ipatool
path: ipatool
if-no-files-found: error
test:
name: Test
runs-on: macos-latest
needs: [build]
strategy:
fail-fast: false
matrix:
command: [auth, download, purchase, search]
steps:
- uses: actions/download-artifact@v4
with:
name: ipatool
path: build
- run: chmod +x ./build/ipatool
- run: ./build/ipatool ${{ matrix.command }} --help
================================================
FILE: .github/workflows/lint.yml
================================================
name: Lint
on:
pull_request:
branches:
- main
jobs:
lint:
name: Lint
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v3
with:
go-version: "1.23.0"
cache: true
- run: go generate github.com/majd/ipatool/...
- uses: golangci/golangci-lint-action@v8
with:
version: v2.1
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
push:
tags:
- "v*"
jobs:
get_version:
name: Get version
runs-on: ubuntu-latest
steps:
- id: set_output
run: echo ::set-output name=version::${GITHUB_REF#refs/tags/v}
outputs:
version: ${{ steps.set_output.outputs.version }}
test:
name: Run tests
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v3
with:
go-version: "1.23.0"
cache: true
- run: go generate github.com/majd/ipatool/...
- run: go test -v github.com/majd/ipatool/...
build:
name: Build
runs-on: macos-latest
needs: [get_version, test]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v3
with:
go-version: "1.23.0"
cache: true
- run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-windows-arm64.exe
env:
GOOS: windows
GOARCH: arm64
VERSION: ${{ needs.get_version.outputs.version }}
- run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-windows-amd64.exe
env:
GOOS: windows
GOARCH: amd64
VERSION: ${{ needs.get_version.outputs.version }}
- run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-linux-arm64
env:
GOOS: linux
GOARCH: arm64
VERSION: ${{ needs.get_version.outputs.version }}
- run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-linux-amd64
env:
GOOS: linux
GOARCH: amd64
VERSION: ${{ needs.get_version.outputs.version }}
- run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-macos-arm64
env:
GOOS: darwin
GOARCH: arm64
VERSION: ${{ needs.get_version.outputs.version }}
CGO_CFLAGS: -mmacosx-version-min=10.15
CGO_LDFLAGS: -mmacosx-version-min=10.15
CGO_ENABLED: 1
- run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-macos-amd64
env:
GOOS: darwin
GOARCH: amd64
VERSION: ${{ needs.get_version.outputs.version }}
CGO_CFLAGS: -mmacosx-version-min=10.15
CGO_LDFLAGS: -mmacosx-version-min=10.15
CGO_ENABLED: 1
- uses: actions/upload-artifact@v4
with:
name: ipatool-${{ needs.get_version.outputs.version }}-windows-arm64.exe
path: ipatool-${{ needs.get_version.outputs.version }}-windows-arm64.exe
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: ipatool-${{ needs.get_version.outputs.version }}-windows-amd64.exe
path: ipatool-${{ needs.get_version.outputs.version }}-windows-amd64.exe
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: ipatool-${{ needs.get_version.outputs.version }}-linux-arm64
path: ipatool-${{ needs.get_version.outputs.version }}-linux-arm64
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: ipatool-${{ needs.get_version.outputs.version }}-linux-amd64
path: ipatool-${{ needs.get_version.outputs.version }}-linux-amd64
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64
path: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64
path: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64
if-no-files-found: error
release_windows:
name: Release for Windows
runs-on: ubuntu-latest
needs: [get_version, build]
strategy:
fail-fast: false
matrix:
arch: [arm64, amd64]
steps:
- uses: actions/checkout@v2
- uses: actions/download-artifact@v4
with:
name: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}.exe
path: bin
- run: tar -czvf $FILE.tar.gz bin/$FILE.exe
env:
FILE: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}
- run: ./tools/sha256sum.sh $TARBALL > $TARBALL.sha256sum
env:
TARBALL: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}.tar.gz
- uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}.*
tag: ${{ github.ref }}
overwrite: false
file_glob: true
release_linux:
name: Release for Linux
runs-on: ubuntu-latest
needs: [get_version, build, release_windows]
strategy:
fail-fast: false
matrix:
arch: [arm64, amd64]
steps:
- uses: actions/checkout@v2
- uses: actions/download-artifact@v4
with:
name: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}
path: bin
- run: chmod +x bin/$FILE && tar -czvf $FILE.tar.gz bin/$FILE
env:
FILE: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}
- run: ./tools/sha256sum.sh $TARBALL > $TARBALL.sha256sum
env:
TARBALL: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}.tar.gz
- uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}.*
tag: ${{ github.ref }}
overwrite: false
file_glob: true
release_macos:
name: Release for macOS
runs-on: ubuntu-latest
needs: [get_version, build, release_windows, release_linux]
steps:
- uses: actions/checkout@v2
with:
path: ./ipatool
- uses: actions/download-artifact@v4
with:
name: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64
path: bin
- run: chmod +x bin/$BIN && tar -czvf $BIN.tar.gz bin/$BIN && rm -rf bin/
env:
BIN: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64
- uses: actions/download-artifact@v4
with:
name: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64
path: bin
- run: chmod +x bin/$FILE && tar -czvf $FILE.tar.gz bin/$FILE && rm -rf bin/
env:
FILE: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64
- id: sha256
run: |
SHA256_ARM64=$(./ipatool/tools/sha256sum.sh ipatool-${{ needs.get_version.outputs.version }}-macos-arm64.tar.gz)
SHA256_AMD64=$(./ipatool/tools/sha256sum.sh ipatool-${{ needs.get_version.outputs.version }}-macos-amd64.tar.gz)
echo $SHA256_ARM64 > ipatool-${{ needs.get_version.outputs.version }}-macos-arm64.tar.gz.sha256sum
echo $SHA256_AMD64 > ipatool-${{ needs.get_version.outputs.version }}-macos-amd64.tar.gz.sha256sum
echo ::set-output name=sha256_arm64::$SHA256_ARM64
echo ::set-output name=sha256_amd64::$SHA256_AMD64
- uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ipatool-${{ needs.get_version.outputs.version }}-macos-*
tag: ${{ github.ref }}
overwrite: false
file_glob: true
- uses: actions/checkout@v2
with:
repository: ${{ secrets.HOMEBREW_REPO }}
ref: main
token: ${{ secrets.GH_TOKEN }}
path: homebrew-repo
- run: |
cd homebrew-repo
sed -i "3s/.*/ sha256 \"$SHA256_ARM64\"/" Casks/ipatool.rb
sed -i "4s/.*/ url \"https:\/\/github.com\/majd\/ipatool\/releases\/download\/v${{ needs.get_version.outputs.version }}\/ipatool-${{ needs.get_version.outputs.version }}-macos-arm64.tar.gz\"/" Casks/ipatool.rb
sed -i "5s/.*/ binary \"bin\/ipatool-${{ needs.get_version.outputs.version }}-macos-arm64\", target: \"ipatool\"/" Casks/ipatool.rb
sed -i "7s/.*/ sha256 \"$SHA256_AMD64\"/" Casks/ipatool.rb
sed -i "8s/.*/ url \"https:\/\/github.com\/majd\/ipatool\/releases\/download\/v${{ needs.get_version.outputs.version }}\/ipatool-${{ needs.get_version.outputs.version }}-macos-amd64.tar.gz\"/" Casks/ipatool.rb
sed -i "9s/.*/ binary \"bin\/ipatool-${{ needs.get_version.outputs.version }}-macos-amd64\", target: \"ipatool\"/" Casks/ipatool.rb
sed -i "12s/.*/ version \"${{ needs.get_version.outputs.version }}\"/" Casks/ipatool.rb
git config --local user.name ${{ secrets.GH_NAME }}
git config --local user.email ${{ secrets.GH_EMAIL }}
git add Casks/ipatool.rb
git commit -m "Update ipatool to v${{ needs.get_version.outputs.version }}"
git push "https://${{ secrets.GH_TOKEN }}@github.com/${{ secrets.HOMEBREW_REPO }}.git" --set-upstream "main"
env:
SHA256_ARM64: ${{ steps.sha256.outputs.sha256_arm64 }}
SHA256_AMD64: ${{ steps.sha256.outputs.sha256_amd64 }}
================================================
FILE: .github/workflows/unit-tests.yml
================================================
name: Unit Tests
on:
pull_request:
branches:
- main
jobs:
run_tests:
name: Run tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v3
with:
go-version: "1.23.0"
cache: true
- run: go generate github.com/majd/ipatool/...
- run: go test -v github.com/majd/ipatool/...
================================================
FILE: .gitignore
================================================
.DS_Store
.AppleDouble
.LSOverride
.vscode/
.idea/
**/*_mock.go
exp/
================================================
FILE: .golangci.yml
================================================
version: "2"
linters:
enable:
- ginkgolinter
- godot
- godox
- importas
- nlreturn
- nonamedreturns
- prealloc
- predeclared
- unconvert
- unparam
- usestdlibvars
- wastedassign
- wrapcheck
- wsl
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
================================================
FILE: AGENTS.md
================================================
# AGENTS.md
This file provides guidance for coding agents working in this repository.
## Repository overview
- Project: `ipatool`
- Language: Go
- Entry point: `main.go`
- CLI command implementations: `cmd/`
## Development workflow
1. Keep changes focused and minimal.
2. Prefer idiomatic Go and keep command behavior consistent with existing commands in `cmd/`.
3. Run formatting and tests before finalizing changes.
## Local checks
Use these commands from the repository root:
```bash
go generate ./...
go test ./...
go build ./...
```
## Coding conventions
- Follow standard Go formatting (`gofmt`).
- Avoid introducing new dependencies unless necessary.
- Keep user-facing text consistent with existing CLI help/output tone.
- Preserve backward compatibility for CLI flags and output formats unless explicitly asked to change them.
## Commit/PR guidance
- Write clear, scoped commit messages.
- Summarize what changed and why in PR descriptions.
- Include test/build results in your handoff.
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2021 Majd Alfhaily
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# IPATool
[](https://GitHub.com/majd/ipatool/releases/)
[](https://github.com/majd/ipatool/blob/main/LICENSE)
`ipatool` is a command line tool that allows you to search for iOS apps on the [App Store](https://apps.apple.com) and download a copy of the app package, known as an _ipa_ file.

- [Requirements](#requirements)
- [Installation](#installation)
- [Manual](#manual)
- [Package Manager (macOS)](#package-manager-macos)
- [Usage](#usage)
- [Compiling](#compiling)
- [License](#license)
- [Releases](https://github.com/majd/ipatool/releases)
- [FAQ](https://github.com/majd/ipatool/wiki/FAQ)
## Requirements
- Supported operating system (Windows, Linux or macOS).
- Apple ID set up to use the App Store.
## Installation
### Manual
You can grab the latest version of `ipatool` from [GitHub releases](https://github.com/majd/ipatool/releases).
### Package Manager (macOS)
You can install `ipatool` using [Homebrew](https://brew.sh).
```shell
$ brew install ipatool
```
## Usage
To authenticate with the App Store, use the `auth` command.
```
Authenticate with the App Store
Usage:
ipatool auth [command]
Available Commands:
info Show current account info
login Login to the App Store
revoke Revoke your App Store credentials
Flags:
-h, --help help for auth
Global Flags:
--format format sets output format for command; can be 'text', 'json' (default text)
--non-interactive run in non-interactive session
--verbose enables verbose logs
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
Usage:
ipatool search <term> [flags]
Flags:
-h, --help help for search
-l, --limit int maximum amount of search results to retrieve (default 5)
Global Flags:
--format format sets output format for command; can be 'text', 'json' (default text)
--non-interactive run in non-interactive session
--verbose enables verbose logs
```
To obtain a license for an app, use the `purchase` command.
```
Obtain a license for the app from the App Store
Usage:
ipatool purchase [flags]
Flags:
-b, --bundle-identifier string Bundle identifier of the target iOS app (required)
-h, --help help for purchase
Global Flags:
--format format sets output format for command; can be 'text', 'json' (default text)
--non-interactive run in non-interactive session
--verbose enables verbose logs
```
To obtain a list of availble app versions to download, use the `list-versions` command.
```
List the available versions of an iOS app
Usage:
ipatool list-versions [flags]
Flags:
-i, --app-id int ID of the target iOS app (required)
-b, --bundle-identifier string The bundle identifier of the target iOS app (overrides the app ID)
-h, --help help for list-versions
Global Flags:
--format format sets output format for command; can be 'text', 'json' (default text)
--keychain-passphrase string passphrase for unlocking keychain
--non-interactive run in non-interactive session
--verbose enables verbose logs
```
To download a copy of the ipa file, use the `download` command.
```
Download (encrypted) iOS app packages from the App Store
Usage:
ipatool download [flags]
Flags:
-i, --app-id int ID of the target iOS app (required)
-b, --bundle-identifier string The bundle identifier of the target iOS app (overrides the app ID)
--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
--purchase Obtain a license for the app if needed
Global Flags:
--format format sets output format for command; can be 'text', 'json' (default text)
--keychain-passphrase string passphrase for unlocking keychain
--non-interactive run in non-interactive session
--verbose enables verbose logs
```
To resolve an external version identifier, returned by the `list-versions` command, use the `get-version-metadata` command.
```
Retrieves the metadata for a specific version of an app
Usage:
ipatool get-version-metadata [flags]
Flags:
-i, --app-id int ID of the target iOS app (required)
-b, --bundle-identifier string The bundle identifier of the target iOS app (overrides the app ID)
--external-version-id string External version identifier of the target iOS app (required)
-h, --help help for get-version-metadata
Global Flags:
--format format sets output format for command; can be 'text', 'json' (default text)
--keychain-passphrase string passphrase for unlocking keychain
--non-interactive run in non-interactive session
--verbose enables verbose logs
```
**Note:** the tool runs in interactive mode by default. Use the `--non-interactive` flag
if running in an automated environment.
## Compiling
The tool can be compiled using the Go toolchain.
```shell
$ go build -o ipatool
```
Unit tests can be executed with the following commands.
```shell
$ go generate github.com/majd/ipatool/...
$ go test -v github.com/majd/ipatool/...
```
## License
IPATool is released under the [MIT license](https://github.com/majd/ipatool/blob/main/LICENSE).
================================================
FILE: cmd/auth.go
================================================
package cmd
import (
"bufio"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/avast/retry-go"
"github.com/majd/ipatool/v2/pkg/appstore"
"github.com/majd/ipatool/v2/pkg/util"
"github.com/spf13/cobra"
"golang.org/x/term"
)
func authCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Authenticate with the App Store",
}
cmd.AddCommand(loginCmd())
cmd.AddCommand(infoCmd())
cmd.AddCommand(revokeCmd())
return cmd
}
func loginCmd() *cobra.Command {
promptForAuthCode := func() (string, error) {
authCode, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return "", fmt.Errorf("failed to read string: %w", err)
}
authCode = strings.Trim(authCode, "\n")
authCode = strings.Trim(authCode, "\r")
return authCode, nil
}
var email, password, authCode string
cmd := &cobra.Command{
Use: "login",
Short: "Login to the App Store",
RunE: func(cmd *cobra.Command, args []string) error {
interactive := cmd.Context().Value("interactive").(bool)
if password == "" && !interactive {
return errors.New("password is required when not running in interactive mode; use the \"--password\" flag")
}
if password == "" && interactive {
dependencies.Logger.Log().Msg("enter password:")
bytes, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
password = string(bytes)
}
var lastErr error
// nolint:wrapcheck
return retry.Do(func() error {
if errors.Is(lastErr, appstore.ErrAuthCodeRequired) && interactive {
dependencies.Logger.Log().Msg("enter 2FA code:")
var err error
authCode, err = promptForAuthCode()
if err != nil {
return fmt.Errorf("failed to read auth code: %w", err)
}
}
dependencies.Logger.Verbose().
Str("password", password).
Str("email", email).
Str("authCode", util.IfEmpty(authCode, "<nil>")).
Msg("logging in")
bag, err := dependencies.AppStore.Bag(appstore.BagInput{})
if err != nil {
return fmt.Errorf("failed to get bag: %w", err)
}
output, err := dependencies.AppStore.Login(appstore.LoginInput{
Email: email,
Password: password,
AuthCode: authCode,
Endpoint: bag.AuthEndpoint,
})
if err != nil {
if errors.Is(err, appstore.ErrAuthCodeRequired) && !interactive {
dependencies.Logger.Log().Msg("2FA code is required; run the command again and supply a code using the `--auth-code` flag")
return nil
}
return err
}
dependencies.Logger.Log().
Str("name", output.Account.Name).
Str("email", output.Account.Email).
Bool("success", true).
Send()
return nil
},
retry.LastErrorOnly(true),
retry.DelayType(retry.FixedDelay),
retry.Delay(time.Millisecond),
retry.Attempts(2),
retry.RetryIf(func(err error) bool {
lastErr = err
return errors.Is(err, appstore.ErrAuthCodeRequired)
}),
)
},
}
cmd.Flags().StringVarP(&email, "email", "e", "", "email address for the Apple ID (required)")
cmd.Flags().StringVarP(&password, "password", "p", "", "password for the Apple ID (required)")
cmd.Flags().StringVar(&authCode, "auth-code", "", "2FA code for the Apple ID")
_ = cmd.MarkFlagRequired("email")
return cmd
}
// nolint:wrapcheck
func infoCmd() *cobra.Command {
return &cobra.Command{
Use: "info",
Short: "Show current account info",
RunE: func(cmd *cobra.Command, args []string) error {
output, err := dependencies.AppStore.AccountInfo()
if err != nil {
return err
}
dependencies.Logger.Log().
Str("name", output.Account.Name).
Str("email", output.Account.Email).
Bool("success", true).
Send()
return nil
},
}
}
// nolint:wrapcheck
func revokeCmd() *cobra.Command {
return &cobra.Command{
Use: "revoke",
Short: "Revoke your App Store credentials",
RunE: func(cmd *cobra.Command, args []string) error {
err := dependencies.AppStore.Revoke()
if err != nil {
return err
}
dependencies.Logger.Log().Bool("success", true).Send()
return nil
},
}
}
================================================
FILE: cmd/common.go
================================================
package cmd
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/99designs/keyring"
cookiejar "github.com/juju/persistent-cookiejar"
"github.com/majd/ipatool/v2/pkg/appstore"
"github.com/majd/ipatool/v2/pkg/http"
"github.com/majd/ipatool/v2/pkg/keychain"
"github.com/majd/ipatool/v2/pkg/log"
"github.com/majd/ipatool/v2/pkg/util"
"github.com/majd/ipatool/v2/pkg/util/machine"
"github.com/majd/ipatool/v2/pkg/util/operatingsystem"
"github.com/rs/zerolog"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var dependencies = Dependencies{}
var keychainPassphrase string
type Dependencies struct {
Logger log.Logger
OS operatingsystem.OperatingSystem
Machine machine.Machine
CookieJar http.CookieJar
Keychain keychain.Keychain
AppStore appstore.AppStore
}
// newLogger returns a new logger instance.
func newLogger(format OutputFormat, verbose bool) log.Logger {
var writer io.Writer
switch format {
case OutputFormatJSON:
writer = zerolog.SyncWriter(os.Stdout)
case OutputFormatText:
writer = log.NewWriter()
}
return log.NewLogger(log.Args{
Verbose: verbose,
Writer: writer,
},
)
}
// newCookieJar returns a new cookie jar instance.
func newCookieJar(machine machine.Machine) http.CookieJar {
return util.Must(cookiejar.New(&cookiejar.Options{
Filename: filepath.Join(machine.HomeDirectory(), ConfigDirectoryName, CookieJarFileName),
}))
}
// newKeychain returns a new keychain instance.
func newKeychain(machine machine.Machine, logger log.Logger, interactive bool) keychain.Keychain {
ring := util.Must(keyring.Open(keyring.Config{
AllowedBackends: []keyring.BackendType{
keyring.KeychainBackend,
keyring.SecretServiceBackend,
keyring.FileBackend,
},
ServiceName: KeychainServiceName,
FileDir: filepath.Join(machine.HomeDirectory(), ConfigDirectoryName),
FilePasswordFunc: func(s string) (string, error) {
if keychainPassphrase == "" && !interactive {
return "", errors.New("keychain passphrase is required when not running in interactive mode; use the \"--keychain-passphrase\" flag")
}
if keychainPassphrase != "" {
return keychainPassphrase, nil
}
path := strings.Split(s, " unlock ")[1]
logger.Log().Msgf("enter passphrase to unlock %s (this is separate from your Apple ID password): ", path)
bytes, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
return "", fmt.Errorf("failed to read password: %w", err)
}
password := string(bytes)
password = strings.Trim(password, "\n")
password = strings.Trim(password, "\r")
return password, nil
},
}))
return keychain.New(keychain.Args{Keyring: ring})
}
// initWithCommand initializes the dependencies of the command.
func initWithCommand(cmd *cobra.Command) {
verbose := cmd.Flag("verbose").Value.String() == "true"
interactive, _ := cmd.Context().Value("interactive").(bool)
format := util.Must(OutputFormatFromString(cmd.Flag("format").Value.String()))
dependencies.Logger = newLogger(format, verbose)
dependencies.OS = operatingsystem.New()
dependencies.Machine = machine.New(machine.Args{OS: dependencies.OS})
dependencies.CookieJar = newCookieJar(dependencies.Machine)
dependencies.Keychain = newKeychain(dependencies.Machine, dependencies.Logger, interactive)
dependencies.AppStore = appstore.NewAppStore(appstore.Args{
CookieJar: dependencies.CookieJar,
OperatingSystem: dependencies.OS,
Keychain: dependencies.Keychain,
Machine: dependencies.Machine,
})
util.Must("", createConfigDirectory(dependencies.OS, dependencies.Machine))
}
// createConfigDirectory creates the configuration directory for the CLI tool, if needed.
func createConfigDirectory(os operatingsystem.OperatingSystem, machine machine.Machine) error {
configDirectoryPath := filepath.Join(machine.HomeDirectory(), ConfigDirectoryName)
_, err := os.Stat(configDirectoryPath)
if err != nil && os.IsNotExist(err) {
err = os.MkdirAll(configDirectoryPath, 0700)
if err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
} else if err != nil {
return fmt.Errorf("could not read metadata: %w", err)
}
return nil
}
================================================
FILE: cmd/constants.go
================================================
package cmd
const (
ConfigDirectoryName = ".ipatool"
CookieJarFileName = "cookies"
KeychainServiceName = "ipatool-auth.service"
)
================================================
FILE: cmd/download.go
================================================
package cmd
import (
"errors"
"os"
"time"
"github.com/avast/retry-go"
"github.com/majd/ipatool/v2/pkg/appstore"
"github.com/schollz/progressbar/v3"
"github.com/spf13/cobra"
)
// nolint:wrapcheck
func downloadCmd() *cobra.Command {
var (
acquireLicense bool
outputPath string
appID int64
bundleID string
externalVersionID string
)
cmd := &cobra.Command{
Use: "download",
Short: "Download (encrypted) iOS 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")
}
var lastErr error
var acc appstore.Account
purchased := false
return retry.Do(func() error {
infoResult, err := dependencies.AppStore.AccountInfo()
if err != nil {
return err
}
acc = infoResult.Account
if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) {
loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password})
if err != nil {
return err
}
acc = loginResult.Account
}
app := appstore.App{ID: appID}
if bundleID != "" {
lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID})
if err != nil {
return err
}
app = lookupResult.App
}
if errors.Is(lastErr, appstore.ErrLicenseRequired) {
err := dependencies.AppStore.Purchase(appstore.PurchaseInput{Account: acc, App: app})
if err != nil {
return err
}
purchased = true
dependencies.Logger.Verbose().
Bool("success", true).
Msg("purchase")
}
interactive, _ := cmd.Context().Value("interactive").(bool)
var progress *progressbar.ProgressBar
if interactive {
progress = progressbar.NewOptions64(1,
progressbar.OptionSetDescription("downloading"),
progressbar.OptionSetWriter(os.Stdout),
progressbar.OptionShowBytes(true),
progressbar.OptionSetWidth(20),
progressbar.OptionFullWidth(),
progressbar.OptionThrottle(65*time.Millisecond),
progressbar.OptionShowCount(),
progressbar.OptionClearOnFinish(),
progressbar.OptionSpinnerType(14),
progressbar.OptionSetRenderBlankState(true),
progressbar.OptionSetElapsedTime(false),
progressbar.OptionSetPredictTime(false),
)
}
out, err := dependencies.AppStore.Download(appstore.DownloadInput{
Account: acc, App: app, OutputPath: outputPath, Progress: progress, ExternalVersionID: externalVersionID})
if err != nil {
return err
}
err = dependencies.AppStore.ReplicateSinf(appstore.ReplicateSinfInput{Sinfs: out.Sinfs, PackagePath: out.DestinationPath})
if err != nil {
return err
}
dependencies.Logger.Log().
Str("output", out.DestinationPath).
Bool("purchased", purchased).
Bool("success", true).
Send()
return nil
},
retry.LastErrorOnly(true),
retry.DelayType(retry.FixedDelay),
retry.Delay(time.Millisecond),
retry.Attempts(2),
retry.RetryIf(func(err error) bool {
lastErr = err
if errors.Is(err, appstore.ErrPasswordTokenExpired) {
return true
}
if errors.Is(err, appstore.ErrLicenseRequired) && acquireLicense {
return true
}
return false
}),
)
},
}
cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target iOS app (required)")
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().BoolVar(&acquireLicense, "purchase", false, "Obtain a license for the app if needed")
return cmd
}
================================================
FILE: cmd/get_version_metadata.go
================================================
package cmd
import (
"errors"
"time"
"github.com/avast/retry-go"
"github.com/majd/ipatool/v2/pkg/appstore"
"github.com/spf13/cobra"
)
// nolint:wrapcheck
func getVersionMetadataCmd() *cobra.Command {
var (
appID int64
bundleID string
externalVersionID string
)
cmd := &cobra.Command{
Use: "get-version-metadata",
Short: "Retrieves the metadata for a specific version of an app",
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")
}
var lastErr error
var acc appstore.Account
return retry.Do(func() error {
infoResult, err := dependencies.AppStore.AccountInfo()
if err != nil {
return err
}
acc = infoResult.Account
if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) {
loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password})
if err != nil {
return err
}
acc = loginResult.Account
}
app := appstore.App{ID: appID}
if bundleID != "" {
lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID})
if err != nil {
return err
}
app = lookupResult.App
}
out, err := dependencies.AppStore.GetVersionMetadata(appstore.GetVersionMetadataInput{
Account: acc,
App: app,
VersionID: externalVersionID,
})
if err != nil {
return err
}
dependencies.Logger.Log().
Str("externalVersionID", externalVersionID).
Str("displayVersion", out.DisplayVersion).
Time("releaseDate", out.ReleaseDate).
Bool("success", true).
Send()
return nil
},
retry.LastErrorOnly(true),
retry.DelayType(retry.FixedDelay),
retry.Delay(time.Millisecond),
retry.Attempts(2),
retry.RetryIf(func(err error) bool {
lastErr = err
return errors.Is(err, appstore.ErrPasswordTokenExpired)
}),
)
},
}
cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target iOS app (required)")
cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)")
cmd.Flags().StringVar(&externalVersionID, "external-version-id", "", "External version identifier of the target iOS app (required)")
_ = cmd.MarkFlagRequired("external-version-id")
return cmd
}
================================================
FILE: cmd/list_versions.go
================================================
package cmd
import (
"errors"
"time"
"github.com/avast/retry-go"
"github.com/majd/ipatool/v2/pkg/appstore"
"github.com/spf13/cobra"
)
// nolint:wrapcheck
func ListVersionsCmd() *cobra.Command {
var (
appID int64
bundleID string
)
cmd := &cobra.Command{
Use: "list-versions",
Short: "List the available versions of an iOS app",
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")
}
var lastErr error
var acc appstore.Account
return retry.Do(func() error {
infoResult, err := dependencies.AppStore.AccountInfo()
if err != nil {
return err
}
acc = infoResult.Account
if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) {
loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password})
if err != nil {
return err
}
acc = loginResult.Account
}
app := appstore.App{ID: appID}
if bundleID != "" {
lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID})
if err != nil {
return err
}
app = lookupResult.App
}
out, err := dependencies.AppStore.ListVersions(appstore.ListVersionsInput{Account: acc, App: app})
if err != nil {
return err
}
dependencies.Logger.Log().
Interface("externalVersionIdentifiers", out.ExternalVersionIdentifiers).
Str("bundleID", app.BundleID).
Bool("success", true).
Send()
return nil
},
retry.LastErrorOnly(true),
retry.DelayType(retry.FixedDelay),
retry.Delay(time.Millisecond),
retry.Attempts(2),
retry.RetryIf(func(err error) bool {
lastErr = err
return errors.Is(err, appstore.ErrPasswordTokenExpired)
}),
)
},
}
cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target iOS app (required)")
cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)")
return cmd
}
================================================
FILE: cmd/output_format.go
================================================
package cmd
import (
"fmt"
"github.com/thediveo/enumflag/v2"
)
type OutputFormat enumflag.Flag
const (
OutputFormatText OutputFormat = iota
OutputFormatJSON
)
func OutputFormatFromString(value string) (OutputFormat, error) {
switch value {
case "json":
return OutputFormatJSON, nil
case "text":
return OutputFormatText, nil
default:
return OutputFormatJSON, fmt.Errorf("invalid output format '%s'", value)
}
}
================================================
FILE: cmd/purchase.go
================================================
package cmd
import (
"errors"
"time"
"github.com/avast/retry-go"
"github.com/majd/ipatool/v2/pkg/appstore"
"github.com/spf13/cobra"
)
// nolint:wrapcheck
func purchaseCmd() *cobra.Command {
var bundleID string
cmd := &cobra.Command{
Use: "purchase",
Short: "Obtain a license for the app from the App Store",
RunE: func(cmd *cobra.Command, args []string) error {
var lastErr error
var acc appstore.Account
return retry.Do(func() error {
infoResult, err := dependencies.AppStore.AccountInfo()
if err != nil {
return err
}
acc = infoResult.Account
if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) {
loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password})
if err != nil {
return err
}
acc = loginResult.Account
}
lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID})
if err != nil {
return err
}
err = dependencies.AppStore.Purchase(appstore.PurchaseInput{Account: acc, App: lookupResult.App})
if err != nil {
return err
}
dependencies.Logger.Log().Bool("success", true).Send()
return nil
},
retry.LastErrorOnly(true),
retry.DelayType(retry.FixedDelay),
retry.Delay(time.Millisecond),
retry.Attempts(2),
retry.RetryIf(func(err error) bool {
lastErr = err
return errors.Is(err, appstore.ErrPasswordTokenExpired)
}),
)
},
}
cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "Bundle identifier of the target iOS app (required)")
_ = cmd.MarkFlagRequired("bundle-identifier")
return cmd
}
================================================
FILE: cmd/root.go
================================================
package cmd
import (
"errors"
"reflect"
"github.com/majd/ipatool/v2/pkg/appstore"
"github.com/spf13/cobra"
"github.com/thediveo/enumflag/v2"
"golang.org/x/net/context"
)
var version = "dev"
func rootCmd() *cobra.Command {
var (
verbose bool
nonInteractive bool
format OutputFormat
)
cmd := &cobra.Command{
Use: "ipatool",
Short: "A cli tool for interacting with Apple's ipa files",
SilenceErrors: true,
SilenceUsage: true,
Version: version,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
ctx := context.WithValue(context.Background(), "interactive", !nonInteractive)
cmd.SetContext(ctx)
initWithCommand(cmd)
},
}
cmd.PersistentFlags().VarP(
enumflag.New(&format, "format", map[OutputFormat][]string{
OutputFormatText: {"text"},
OutputFormatJSON: {"json"},
}, enumflag.EnumCaseSensitive), "format", "", "sets output format for command; can be 'text', 'json'")
cmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "enables verbose logs")
cmd.PersistentFlags().BoolVarP(&nonInteractive, "non-interactive", "", false, "run in non-interactive session")
cmd.PersistentFlags().StringVar(&keychainPassphrase, "keychain-passphrase", "", "passphrase for unlocking keychain")
cmd.AddCommand(authCmd())
cmd.AddCommand(downloadCmd())
cmd.AddCommand(purchaseCmd())
cmd.AddCommand(searchCmd())
cmd.AddCommand(ListVersionsCmd())
cmd.AddCommand(getVersionMetadataCmd())
return cmd
}
// Execute runs the program and returns the appropriate exit status code.
func Execute() int {
cmd := rootCmd()
err := cmd.Execute()
if err != nil {
if reflect.ValueOf(dependencies).IsZero() {
initWithCommand(cmd)
}
var appstoreErr *appstore.Error
if errors.As(err, &appstoreErr) {
dependencies.Logger.Verbose().Stack().
Err(err).
Interface("metadata", appstoreErr.Metadata).
Send()
} else {
dependencies.Logger.Verbose().Stack().Err(err).Send()
}
dependencies.Logger.Error().
Err(err).
Bool("success", false).
Send()
return 1
}
return 0
}
================================================
FILE: cmd/search.go
================================================
package cmd
import (
"github.com/majd/ipatool/v2/pkg/appstore"
"github.com/spf13/cobra"
)
// nolint:wrapcheck
func searchCmd() *cobra.Command {
var limit int64
cmd := &cobra.Command{
Use: "search <term>",
Short: "Search for iOS 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
}
output, err := dependencies.AppStore.Search(appstore.SearchInput{
Account: infoResult.Account,
Term: args[0],
Limit: limit,
})
if err != nil {
return err
}
dependencies.Logger.Log().
Int("count", output.Count).
Array("apps", appstore.Apps(output.Results)).
Send()
return nil
},
}
cmd.Flags().Int64VarP(&limit, "limit", "l", 5, "maximum amount of search results to retrieve")
return cmd
}
================================================
FILE: go.mod
================================================
module github.com/majd/ipatool/v2
go 1.23.0
toolchain go1.23.2
require (
github.com/99designs/keyring v1.2.1
github.com/avast/retry-go v3.0.0+incompatible
github.com/juju/persistent-cookiejar v1.0.0
github.com/onsi/ginkgo/v2 v2.5.0
github.com/onsi/gomega v1.24.0
github.com/rs/zerolog v1.28.0
github.com/schollz/progressbar/v3 v3.13.1
github.com/spf13/cobra v1.6.1
github.com/thediveo/enumflag/v2 v2.0.1
go.uber.org/mock v0.4.0
golang.org/x/net v0.38.0
golang.org/x/term v0.30.0
howett.net/plist v1.0.0
)
require (
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/danieljoos/wincred v1.1.2 // indirect
github.com/dvsekhvalnov/jose2go v1.7.0 // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/mtibben/percent v0.2.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/errgo.v1 v1.0.1 // indirect
gopkg.in/retry.v1 v1.0.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
================================================
FILE: go.sum
================================================
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
github.com/99designs/keyring v1.2.1 h1:tYLp1ULvO7i3fI5vE21ReQuj99QFSs7lGm0xWyJo87o=
github.com/99designs/keyring v1.2.1/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA=
github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo=
github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
github.com/frankban/quicktest v1.2.2 h1:xfmOhhoH5fGPgbEAlhLpJH9p0z/0Qizio9osmvn9IUY=
github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20=
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a h1:45JtCyuNYE+QN9aPuR1ID9++BQU+NMTMudHSuaK0Las=
github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a/go.mod h1:RVHtZuvrpETIepiNUrNlih2OynoFf1eM6DGC6dloXzk=
github.com/juju/persistent-cookiejar v1.0.0 h1:Ag7+QLzqC2m+OYXy2QQnRjb3gTkEBSZagZ6QozwT3EQ=
github.com/juju/persistent-cookiejar v1.0.0/go.mod h1:zrbmo4nBKaiP/Ez3F67ewkMbzGYfXyMvRtbOfuAwG0w=
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/ginkgo/v2 v2.5.0 h1:TRtrvv2vdQqzkwrQ1ke6vtXf7IK34RBUJafIy1wMwls=
github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw=
github.com/onsi/gomega v1.24.0 h1:+0glovB9Jd6z3VR+ScSwQqXVTIfJcGA9UBM8yzQxhqg=
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a h1:3QH7VyOaaiUHNrA9Se4YQIRkDTCw1EJls9xTUCaCeRM=
github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a/go.mod h1:4r5QyqhjIWCcK8DO4KMclc5Iknq5qVBAlbYYzAbUScQ=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE=
github.com/schollz/progressbar/v3 v3.13.1/go.mod h1:xvrbki8kfT1fzWzBT/UZd9L6GA+jdL7HAgq2RFnO6fQ=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As=
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/thediveo/enumflag/v2 v2.0.1 h1:2bmWZPD2uSARDsOjXIdLRlNcYBFNF9xX0RNUNF2vKic=
github.com/thediveo/enumflag/v2 v2.0.1/go.mod h1:SyxyCNvv0QeRtZ7fjuaUz4FRLC3cWuDiD7QdORU0MGg=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA=
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v1 v1.0.1 h1:oQFRXzZ7CkBGdm1XZm/EbQYaYNNEElNBOd09M6cqNso=
gopkg.in/errgo.v1 v1.0.1/go.mod h1:3NjfXwocQRYAPTq4/fzX+CwUhPRcR/azYRhj8G+LqMo=
gopkg.in/retry.v1 v1.0.3 h1:a9CArYczAVv6Qs6VGoLMio99GEs7kY9UzSF9+LD+iGs=
gopkg.in/retry.v1 v1.0.3/go.mod h1:FJkXmWiMaAo7xB+xhvDF59zhfjDWyzmyAxiT4dB688g=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
================================================
FILE: main.go
================================================
package main
import (
"os"
"github.com/majd/ipatool/v2/cmd"
)
func main() {
os.Exit(cmd.Execute())
}
================================================
FILE: pkg/appstore/account.go
================================================
package appstore
type Account struct {
Email string `json:"email,omitempty"`
PasswordToken string `json:"passwordToken,omitempty"`
DirectoryServicesID string `json:"directoryServicesIdentifier,omitempty"`
Name string `json:"name,omitempty"`
StoreFront string `json:"storeFront,omitempty"`
Password string `json:"password,omitempty"`
Pod string `json:"pod,omitempty"`
}
================================================
FILE: pkg/appstore/app.go
================================================
package appstore
import (
"github.com/rs/zerolog"
)
type App struct {
ID int64 `json:"trackId,omitempty"`
BundleID string `json:"bundleId,omitempty"`
Name string `json:"trackName,omitempty"`
Version string `json:"version,omitempty"`
Price float64 `json:"price,omitempty"`
}
type VersionHistoryInfo struct {
App App
LatestVersion string
VersionIdentifiers []string
}
type VersionDetails struct {
VersionID string
VersionString string
Success bool
Error string
}
type Apps []App
func (apps Apps) MarshalZerologArray(a *zerolog.Array) {
for _, app := range apps {
a.Object(app)
}
}
func (a App) MarshalZerologObject(event *zerolog.Event) {
event.
Int64("id", a.ID).
Str("bundleID", a.BundleID).
Str("name", a.Name).
Str("version", a.Version).
Float64("price", a.Price)
}
================================================
FILE: pkg/appstore/app_test.go
================================================
package appstore
import (
"bytes"
"encoding/json"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/rs/zerolog"
)
var _ = Describe("App", func() {
It("marshals apps array", func() {
apps := Apps{
{
ID: 42,
BundleID: "app.bundle.id",
Name: "app name",
Version: "1.0",
Price: 0,
},
{
ID: 1,
BundleID: "app.bundle.id2",
Name: "app name2",
Version: "2.0",
Price: 0.99,
},
}
buffer := bytes.NewBuffer([]byte{})
logger := zerolog.New(buffer)
event := logger.Log().Array("apps", apps)
event.Send()
var out map[string]interface{}
err := json.Unmarshal(buffer.Bytes(), &out)
Expect(err).ToNot(HaveOccurred())
Expect(out["apps"]).To(HaveLen(2))
})
It("marshalls app object", func() {
app := App{
ID: 42,
BundleID: "app.bundle.id",
Name: "app name",
Version: "1.0",
Price: 0,
}
buffer := bytes.NewBuffer([]byte{})
logger := zerolog.New(buffer)
event := logger.Log()
app.MarshalZerologObject(event)
event.Send()
var out map[string]interface{}
err := json.Unmarshal(buffer.Bytes(), &out)
Expect(err).ToNot(HaveOccurred())
Expect(out["id"]).To(Equal(float64(42)))
Expect(out["bundleID"]).To(Equal("app.bundle.id"))
Expect(out["name"]).To(Equal("app name"))
Expect(out["version"]).To(Equal("1.0"))
Expect(out["price"]).To(Equal(float64(0)))
})
It("formats ipa name correctly", func() {
app := App{
ID: 42,
BundleID: "app.bundle-id1",
Name: " some app&symb.ols2 !!!",
Version: "1.0",
Price: 0,
}
Expect(fileName(app, "1.0")).To(Equal("app.bundle-id1_42_1.0.ipa"))
})
})
================================================
FILE: pkg/appstore/appstore.go
================================================
package appstore
import (
"github.com/majd/ipatool/v2/pkg/http"
"github.com/majd/ipatool/v2/pkg/keychain"
"github.com/majd/ipatool/v2/pkg/util/machine"
"github.com/majd/ipatool/v2/pkg/util/operatingsystem"
)
type AppStore interface {
// Login authenticates with the App Store.
Login(input LoginInput) (LoginOutput, error)
// AccountInfo returns the information of the authenticated account.
AccountInfo() (AccountInfoOutput, error)
// Revoke revokes the active credentials.
Revoke() error
// Lookup looks apps up based on the specified bundle identifier.
Lookup(input LookupInput) (LookupOutput, error)
// Search searches the App Store for apps matching the specified term.
Search(input SearchInput) (SearchOutput, error)
// Purchase acquires a license for the desired app.
// Note: only free apps are supported.
Purchase(input PurchaseInput) error
// Download downloads the IPA package from the App Store to the desired location.
Download(input DownloadInput) (DownloadOutput, error)
// ReplicateSinf replicates the sinf for the IPA package.
ReplicateSinf(input ReplicateSinfInput) error
// VersionHistory lists the available versions of the specified app.
ListVersions(input ListVersionsInput) (ListVersionsOutput, error)
// GetVersionMetadata returns the metadata for the specified version.
GetVersionMetadata(input GetVersionMetadataInput) (GetVersionMetadataOutput, error)
// Bag fetches the bag which contains endpoint definitions.
Bag(input BagInput) (BagOutput, error)
}
type appstore struct {
keychain keychain.Keychain
loginClient http.Client[loginResult]
searchClient http.Client[searchResult]
purchaseClient http.Client[purchaseResult]
downloadClient http.Client[downloadResult]
bagClient http.Client[bagResult]
httpClient http.Client[interface{}]
machine machine.Machine
os operatingsystem.OperatingSystem
}
type Args struct {
Keychain keychain.Keychain
CookieJar http.CookieJar
OperatingSystem operatingsystem.OperatingSystem
Machine machine.Machine
}
func NewAppStore(args Args) AppStore {
clientArgs := http.Args{
CookieJar: args.CookieJar,
}
return &appstore{
keychain: args.Keychain,
loginClient: http.NewClient[loginResult](clientArgs),
searchClient: http.NewClient[searchResult](clientArgs),
purchaseClient: http.NewClient[purchaseResult](clientArgs),
downloadClient: http.NewClient[downloadResult](clientArgs),
bagClient: http.NewClient[bagResult](clientArgs),
httpClient: http.NewClient[interface{}](clientArgs),
machine: args.Machine,
os: args.OperatingSystem,
}
}
================================================
FILE: pkg/appstore/appstore_account_info.go
================================================
package appstore
import (
"encoding/json"
"fmt"
)
type AccountInfoOutput struct {
Account Account
}
func (t *appstore) AccountInfo() (AccountInfoOutput, error) {
data, err := t.keychain.Get("account")
if err != nil {
return AccountInfoOutput{}, fmt.Errorf("failed to get account: %w", err)
}
var acc Account
err = json.Unmarshal(data, &acc)
if err != nil {
return AccountInfoOutput{}, fmt.Errorf("failed to unmarshal json: %w", err)
}
return AccountInfoOutput{
Account: acc,
}, nil
}
================================================
FILE: pkg/appstore/appstore_account_info_test.go
================================================
package appstore
import (
"errors"
"fmt"
"github.com/majd/ipatool/v2/pkg/keychain"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
)
var _ = Describe("AppStore (AccountInfo)", func() {
var (
ctrl *gomock.Controller
appstore AppStore
mockKeychain *keychain.MockKeychain
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockKeychain = keychain.NewMockKeychain(ctrl)
appstore = NewAppStore(Args{
Keychain: mockKeychain,
})
})
AfterEach(func() {
ctrl.Finish()
})
When("keychain returns valid data", func() {
const (
testEmail = "test-email"
testName = "test-name"
)
BeforeEach(func() {
mockKeychain.EXPECT().
Get("account").
Return([]byte(fmt.Sprintf("{\"email\": \"%s\", \"name\": \"%s\"}", testEmail, testName)), nil)
})
It("returns output", func() {
out, err := appstore.AccountInfo()
Expect(err).ToNot(HaveOccurred())
Expect(out.Account.Email).To(Equal(testEmail))
Expect(out.Account.Name).To(Equal(testName))
})
})
When("keychain returns error", func() {
BeforeEach(func() {
mockKeychain.EXPECT().
Get("account").
Return([]byte{}, errors.New(""))
})
It("returns wrapped error", func() {
_, err := appstore.AccountInfo()
Expect(err).To(HaveOccurred())
})
})
When("keychain returns invalid data", func() {
BeforeEach(func() {
mockKeychain.EXPECT().
Get("account").
Return([]byte("..."), nil)
})
It("fails to unmarshall JSON data", func() {
_, err := appstore.AccountInfo()
Expect(err).To(HaveOccurred())
})
})
})
================================================
FILE: pkg/appstore/appstore_bag.go
================================================
package appstore
import (
"fmt"
gohttp "net/http"
"strings"
"github.com/majd/ipatool/v2/pkg/http"
)
type BagInput struct{}
type BagOutput struct {
AuthEndpoint string
}
func (t *appstore) Bag(input BagInput) (BagOutput, error) {
macAddr, err := t.machine.MacAddress()
if err != nil {
return BagOutput{}, fmt.Errorf("failed to get mac address: %w", err)
}
guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")
req := t.bagRequest(guid)
res, err := t.bagClient.Send(req)
if err != nil {
return BagOutput{}, fmt.Errorf("failed to send http request: %w", err)
}
if res.StatusCode != gohttp.StatusOK {
return BagOutput{}, fmt.Errorf("received unexpected status code: %d", res.StatusCode)
}
return BagOutput{
AuthEndpoint: res.Data.URLBag.AuthEndpoint,
}, nil
}
type bagResult struct {
URLBag urlBag `plist:"urlBag,omitempty"`
}
type urlBag struct {
AuthEndpoint string `plist:"authenticateAccount,omitempty"`
}
func (*appstore) bagRequest(guid string) http.Request {
return http.Request{
URL: fmt.Sprintf("https://%s%s?guid=%s", PrivateInitDomain, PrivateInitPath, guid),
Method: http.MethodGET,
ResponseFormat: http.ResponseFormatXML,
Headers: map[string]string{
"Accept": "application/xml",
},
}
}
================================================
FILE: pkg/appstore/appstore_bag_test.go
================================================
package appstore
import (
"errors"
gohttp "net/http"
"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"
)
var _ = Describe("AppStore (Bag)", func() {
var (
ctrl *gomock.Controller
mockBagClient *http.MockClient[bagResult]
mockMachine *machine.MockMachine
as AppStore
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockBagClient = http.NewMockClient[bagResult](ctrl)
mockMachine = machine.NewMockMachine(ctrl)
as = &appstore{
bagClient: mockBagClient,
machine: mockMachine,
}
})
AfterEach(func() {
ctrl.Finish()
})
When("fails to read machine MAC address", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("", errors.New("mac error"))
})
It("returns error", func() {
_, err := as.Bag(BagInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get mac address"))
})
})
When("request fails", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:11:22:33:44:55", nil)
mockBagClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[bagResult]{}, errors.New("request error"))
})
It("returns wrapped error", func() {
_, err := as.Bag(BagInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to send http request"))
})
})
When("request returns non-200 status code", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:11:22:33:44:55", nil)
mockBagClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[bagResult]{
StatusCode: gohttp.StatusForbidden,
}, nil)
})
It("returns error", func() {
_, err := as.Bag(BagInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("received unexpected status code"))
})
})
When("request is successful", func() {
const testAuthEndpoint = "https://example.com"
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("aa:bb:cc:dd:ee:ff", nil)
mockBagClient.EXPECT().
Send(gomock.Any()).
Do(func(req http.Request) {
Expect(req.Method).To(Equal(http.MethodGET))
Expect(req.URL).To(Equal("https://init.itunes.apple.com/bag.xml?guid=AABBCCDDEEFF"))
Expect(req.ResponseFormat).To(Equal(http.ResponseFormatXML))
Expect(req.Headers).To(HaveKeyWithValue("Accept", "application/xml"))
}).
Return(http.Result[bagResult]{
StatusCode: gohttp.StatusOK,
Data: bagResult{
URLBag: urlBag{
AuthEndpoint: testAuthEndpoint,
},
},
}, nil)
})
It("returns output", func() {
out, err := as.Bag(BagInput{})
Expect(err).ToNot(HaveOccurred())
Expect(out.AuthEndpoint).To(Equal(testAuthEndpoint))
})
})
})
================================================
FILE: pkg/appstore/appstore_download.go
================================================
package appstore
import (
"archive/zip"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/majd/ipatool/v2/pkg/http"
"github.com/schollz/progressbar/v3"
"howett.net/plist"
)
var (
ErrLicenseRequired = errors.New("license is required")
)
type DownloadInput struct {
Account Account
App App
OutputPath string
Progress *progressbar.ProgressBar
ExternalVersionID string
}
type DownloadOutput struct {
DestinationPath string
Sinfs []Sinf
}
func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) {
macAddr, err := t.machine.MacAddress()
if err != nil {
return DownloadOutput{}, fmt.Errorf("failed to get mac address: %w", err)
}
guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")
req := t.downloadRequest(input.Account, input.App, guid, input.ExternalVersionID)
res, err := t.downloadClient.Send(req)
if err != nil {
return DownloadOutput{}, fmt.Errorf("failed to send http request: %w", err)
}
if res.Data.FailureType == FailureTypePasswordTokenExpired {
return DownloadOutput{}, ErrPasswordTokenExpired
}
if res.Data.FailureType == FailureTypeLicenseNotFound {
return DownloadOutput{}, ErrLicenseRequired
}
if res.Data.FailureType != "" && res.Data.CustomerMessage != "" {
return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res)
}
if res.Data.FailureType != "" {
return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res)
}
if len(res.Data.Items) == 0 {
return DownloadOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res)
}
item := res.Data.Items[0]
version := "unknown"
// Read the version from the item metadata
if itemVersion, ok := item.Metadata["bundleShortVersionString"]; ok {
version = fmt.Sprintf("%v", itemVersion)
}
destination, err := t.resolveDestinationPath(input.App, version, input.OutputPath)
if err != nil {
return DownloadOutput{}, fmt.Errorf("failed to resolve destination path: %w", err)
}
err = t.downloadFile(item.URL, fmt.Sprintf("%s.tmp", destination), input.Progress)
if err != nil {
return DownloadOutput{}, fmt.Errorf("failed to download file: %w", err)
}
err = t.applyPatches(item, input.Account, fmt.Sprintf("%s.tmp", destination), destination)
if err != nil {
return DownloadOutput{}, fmt.Errorf("failed to apply patches: %w", err)
}
err = t.os.Remove(fmt.Sprintf("%s.tmp", destination))
if err != nil {
return DownloadOutput{}, fmt.Errorf("failed to remove file: %w", err)
}
return DownloadOutput{
DestinationPath: destination,
Sinfs: item.Sinfs,
}, nil
}
type downloadItemResult struct {
HashMD5 string `plist:"md5,omitempty"`
URL string `plist:"URL,omitempty"`
Sinfs []Sinf `plist:"sinfs,omitempty"`
Metadata map[string]interface{} `plist:"metadata,omitempty"`
}
type downloadResult struct {
FailureType string `plist:"failureType,omitempty"`
CustomerMessage string `plist:"customerMessage,omitempty"`
Items []downloadItemResult `plist:"songList,omitempty"`
}
func (t *appstore) downloadFile(src, dst string, progress *progressbar.ProgressBar) error {
req, err := t.httpClient.NewRequest("GET", src, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
file, err := t.os.OpenFile(dst, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
stat, err := t.os.Stat(dst)
if err != nil {
return fmt.Errorf("failed to get file info: %w", err)
}
if req != nil && stat != nil {
req.Header.Add("range", fmt.Sprintf("bytes=%d-", stat.Size()))
}
res, err := t.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer res.Body.Close()
if progress != nil {
progress.ChangeMax64(res.ContentLength + stat.Size())
err = progress.Set64(stat.Size())
if err != nil {
return fmt.Errorf("can not set bar progress: %w", err)
}
_, err = file.Seek(0, io.SeekEnd)
if err != nil {
return fmt.Errorf("can not seek file: %w", err)
}
_, err = io.Copy(io.MultiWriter(file, progress), res.Body)
} else {
_, err = io.Copy(file, res.Body)
}
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
func (*appstore) downloadRequest(acc Account, app App, guid string, externalVersionID string) http.Request {
payload := map[string]interface{}{
"creditDisplay": "",
"guid": guid,
"salableAdamId": app.ID,
}
if externalVersionID != "" {
payload["externalVersionId"] = externalVersionID
}
podPrefix := ""
if acc.Pod != "" {
podPrefix = "p" + acc.Pod + "-"
}
return http.Request{
URL: fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid),
Method: http.MethodPOST,
ResponseFormat: http.ResponseFormatXML,
Headers: map[string]string{
"Content-Type": "application/x-apple-plist",
"iCloud-DSID": acc.DirectoryServicesID,
"X-Dsid": acc.DirectoryServicesID,
},
Payload: &http.XMLPayload{
Content: payload,
},
}
}
func fileName(app App, version string) string {
var parts []string
if app.BundleID != "" {
parts = append(parts, app.BundleID)
}
if app.ID != 0 {
parts = append(parts, strconv.FormatInt(app.ID, 10))
}
if version != "" {
parts = append(parts, version)
}
return fmt.Sprintf("%s.ipa", strings.Join(parts, "_"))
}
func (t *appstore) resolveDestinationPath(app App, version string, path string) (string, error) {
file := fileName(app, version)
if path == "" {
workdir, err := t.os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get current directory: %w", err)
}
return fmt.Sprintf("%s/%s", workdir, file), nil
}
isDir, err := t.isDirectory(path)
if err != nil {
return "", fmt.Errorf("failed to determine whether path is a directory: %w", err)
}
if isDir {
return fmt.Sprintf("%s/%s", path, file), nil
}
return path, nil
}
func (t *appstore) isDirectory(path string) (bool, error) {
info, err := t.os.Stat(path)
if err != nil && !os.IsNotExist(err) {
return false, fmt.Errorf("failed to read file metadata: %w", err)
}
if info == nil {
return false, nil
}
return info.IsDir(), nil
}
func (t *appstore) applyPatches(item downloadItemResult, acc Account, src, dst string) error {
srcZip, err := zip.OpenReader(src)
if err != nil {
return fmt.Errorf("failed to open zip reader: %w", err)
}
defer srcZip.Close()
dstFile, err := t.os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
dstZip := zip.NewWriter(dstFile)
defer dstZip.Close()
err = t.replicateZip(srcZip, dstZip)
if err != nil {
return fmt.Errorf("failed to replicate zip: %w", err)
}
err = t.writeMetadata(item.Metadata, acc, dstZip)
if err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
return nil
}
func (t *appstore) writeMetadata(metadata map[string]interface{}, acc Account, zip *zip.Writer) error {
metadata["apple-id"] = acc.Email
metadata["userName"] = acc.Email
metadataFile, err := zip.Create("iTunesMetadata.plist")
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
data, err := plist.Marshal(metadata, plist.BinaryFormat)
if err != nil {
return fmt.Errorf("failed to marshal data: %w", err)
}
_, err = metadataFile.Write(data)
if err != nil {
return fmt.Errorf("failed to write data: %w", err)
}
return nil
}
================================================
FILE: pkg/appstore/appstore_download_test.go
================================================
package appstore
import (
"archive/zip"
"errors"
"fmt"
"io"
"io/fs"
gohttp "net/http"
"os"
"strings"
"time"
"github.com/majd/ipatool/v2/pkg/http"
"github.com/majd/ipatool/v2/pkg/keychain"
"github.com/majd/ipatool/v2/pkg/util/machine"
"github.com/majd/ipatool/v2/pkg/util/operatingsystem"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
"howett.net/plist"
)
type dummyFileInfo struct{}
func (d *dummyFileInfo) Name() string { return "dummy" }
func (d *dummyFileInfo) Size() int64 { return 0 }
func (d *dummyFileInfo) Mode() fs.FileMode { return 0 }
func (d *dummyFileInfo) ModTime() time.Time { return time.Time{} }
func (d *dummyFileInfo) IsDir() bool { return false }
func (d *dummyFileInfo) Sys() interface{} { return nil }
var _ = Describe("AppStore (Download)", func() {
var (
ctrl *gomock.Controller
mockKeychain *keychain.MockKeychain
mockDownloadClient *http.MockClient[downloadResult]
mockPurchaseClient *http.MockClient[purchaseResult]
mockLoginClient *http.MockClient[loginResult]
mockHTTPClient *http.MockClient[interface{}]
mockOS *operatingsystem.MockOperatingSystem
mockMachine *machine.MockMachine
as AppStore
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockKeychain = keychain.NewMockKeychain(ctrl)
mockDownloadClient = http.NewMockClient[downloadResult](ctrl)
mockLoginClient = http.NewMockClient[loginResult](ctrl)
mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl)
mockHTTPClient = http.NewMockClient[interface{}](ctrl)
mockOS = operatingsystem.NewMockOperatingSystem(ctrl)
mockMachine = machine.NewMockMachine(ctrl)
as = &appstore{
keychain: mockKeychain,
loginClient: mockLoginClient,
purchaseClient: mockPurchaseClient,
downloadClient: mockDownloadClient,
httpClient: mockHTTPClient,
machine: mockMachine,
os: mockOS,
}
})
AfterEach(func() {
ctrl.Finish()
})
When("fails to read MAC address", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("", errors.New(""))
})
It("returns error", func() {
_, err := as.Download(DownloadInput{})
Expect(err).To(HaveOccurred())
})
})
When("request fails", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{}, errors.New(""))
})
It("returns error", func() {
_, err := as.Download(DownloadInput{})
Expect(err).To(HaveOccurred())
})
})
When("request uses a custom pod", func() {
const (
testPod = "42"
testGUID = "001122334455"
)
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:11:22:33:44:55", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Do(func(req http.Request) {
expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathDownload + "?guid=" + testGUID
Expect(req.URL).To(Equal(expectedURL))
}).
Return(http.Result[downloadResult]{}, errors.New(""))
})
It("sends the download request to the pod-specific host", func() {
_, err := as.Download(DownloadInput{
Account: Account{
Pod: testPod,
},
})
Expect(err).To(HaveOccurred())
})
})
When("password token is expired", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
FailureType: FailureTypePasswordTokenExpired,
},
}, nil)
})
It("returns error", func() {
_, err := as.Download(DownloadInput{})
Expect(err).To(HaveOccurred())
})
})
When("license is missing", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
FailureType: FailureTypeLicenseNotFound,
},
}, nil)
})
It("returns error", func() {
_, err := as.Download(DownloadInput{})
Expect(err).To(HaveOccurred())
})
})
When("store API returns error", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("", nil)
})
When("response contains customer message", func() {
BeforeEach(func() {
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
FailureType: "test-failure",
CustomerMessage: errors.New("").Error(),
},
}, nil)
})
It("returns customer message as error", func() {
_, err := as.Download(DownloadInput{})
Expect(err).To(HaveOccurred())
})
})
When("response does not contain customer message", func() {
BeforeEach(func() {
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
FailureType: "test-failure",
},
}, nil)
})
It("returns generic error", func() {
_, err := as.Download(DownloadInput{})
Expect(err).To(HaveOccurred())
})
})
})
When("store API returns no items", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
Items: []downloadItemResult{},
},
}, nil)
})
It("returns error", func() {
_, err := as.Download(DownloadInput{})
Expect(err).To(HaveOccurred())
})
})
When("fails to resolve output path", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
Items: []downloadItemResult{{}},
},
}, nil)
mockOS.EXPECT().
Stat(gomock.Any()).
Return(nil, errors.New(""))
})
It("returns error", func() {
_, err := as.Download(DownloadInput{
OutputPath: "test-out",
})
Expect(err).To(HaveOccurred())
})
})
When("fails to download file", func() {
BeforeEach(func() {
mockOS.EXPECT().
Getwd().
Return("", nil)
mockMachine.EXPECT().
MacAddress().
Return("", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
Items: []downloadItemResult{{}},
},
}, nil)
})
When("fails to create download request", func() {
BeforeEach(func() {
mockHTTPClient.EXPECT().
NewRequest("GET", gomock.Any(), nil).
Return(nil, errors.New(""))
})
It("returns error", func() {
_, err := as.Download(DownloadInput{})
Expect(err).To(HaveOccurred())
})
})
When("fails to open file", func() {
BeforeEach(func() {
mockHTTPClient.EXPECT().
NewRequest("GET", gomock.Any(), nil).
Return(nil, nil)
mockOS.EXPECT().
OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, errors.New(""))
})
It("returns error", func() {
_, err := as.Download(DownloadInput{})
Expect(err).To(HaveOccurred())
})
})
When("fails to get file info", func() {
BeforeEach(func() {
mockHTTPClient.EXPECT().
NewRequest("GET", gomock.Any(), nil).
Return(nil, nil)
mockOS.EXPECT().
OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, nil)
mockOS.EXPECT().
Stat(gomock.Any()).
Return(&dummyFileInfo{}, errors.New(""))
})
It("returns error", func() {
_, err := as.Download(DownloadInput{})
Expect(err).To(HaveOccurred())
})
})
When("request fails", func() {
BeforeEach(func() {
mockHTTPClient.EXPECT().
NewRequest("GET", gomock.Any(), nil).
Return(&gohttp.Request{Header: map[string][]string{}}, nil)
mockOS.EXPECT().
OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, nil)
mockOS.EXPECT().
Stat(gomock.Any()).
Return(&dummyFileInfo{}, nil)
mockHTTPClient.EXPECT().
Do(gomock.Any()).
Return(&gohttp.Response{Body: io.NopCloser(strings.NewReader(""))}, errors.New(""))
})
It("returns error", func() {
_, err := as.Download(DownloadInput{})
Expect(err).To(HaveOccurred())
})
})
When("fails to write data to file", func() {
BeforeEach(func() {
mockHTTPClient.EXPECT().
NewRequest("GET", gomock.Any(), nil).
Return(&gohttp.Request{Header: map[string][]string{}}, nil)
mockOS.EXPECT().
OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, nil)
mockOS.EXPECT().
Stat(gomock.Any()).
Return(&dummyFileInfo{}, nil)
mockHTTPClient.EXPECT().
Do(gomock.Any()).
Return(&gohttp.Response{
Body: io.NopCloser(strings.NewReader("ping")),
}, nil)
})
It("returns error", func() {
_, err := as.Download(DownloadInput{})
Expect(err).To(HaveOccurred())
})
})
})
When("successfully downloads file", func() {
var testFile *os.File
BeforeEach(func() {
var err error
testFile, err = os.CreateTemp("", "test_file")
Expect(err).ToNot(HaveOccurred())
mockMachine.EXPECT().
MacAddress().
Return("", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
Items: []downloadItemResult{
{
Metadata: map[string]interface{}{
"bundleShortVersionString": "xyz",
},
Sinfs: []Sinf{
{
ID: 0,
Data: []byte("test-sinf-data"),
},
},
},
},
},
}, nil)
mockHTTPClient.EXPECT().
NewRequest("GET", gomock.Any(), nil).
Return(&gohttp.Request{Header: map[string][]string{}}, nil)
mockOS.EXPECT().
OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
Return(testFile, nil)
mockOS.EXPECT().
Stat(gomock.Any()).
Return(&dummyFileInfo{}, nil)
mockHTTPClient.EXPECT().
Do(gomock.Any()).
Return(&gohttp.Response{
Body: io.NopCloser(strings.NewReader("ping")),
}, nil)
})
AfterEach(func() {
err := os.Remove(testFile.Name())
Expect(err).ToNot(HaveOccurred())
})
It("writes data to file", func() {
mockOS.EXPECT().
Getwd().
Return("", nil)
_, err := as.Download(DownloadInput{})
Expect(err).To(HaveOccurred())
testData, err := os.ReadFile(testFile.Name())
Expect(err).ToNot(HaveOccurred())
Expect(string(testData)).To(Equal("ping"))
})
When("successfully applies patches", func() {
var (
tmpFile *os.File
outputPath string
)
BeforeEach(func() {
var err error
tmpFile, err = os.OpenFile(fmt.Sprintf("%s.tmp", testFile.Name()), os.O_CREATE|os.O_WRONLY, 0644)
Expect(err).ToNot(HaveOccurred())
outputPath = strings.TrimSuffix(tmpFile.Name(), ".tmp")
mockOS.EXPECT().
OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(os.OpenFile)
mockOS.EXPECT().
Stat(gomock.Any()).
Return(nil, nil)
mockOS.EXPECT().
Remove(tmpFile.Name()).
Return(nil)
zipFile := zip.NewWriter(tmpFile)
w, err := zipFile.Create("Payload/Test.app/Info.plist")
Expect(err).ToNot(HaveOccurred())
info, err := plist.Marshal(map[string]interface{}{
"CFBundleExecutable": "Test",
}, plist.BinaryFormat)
Expect(err).ToNot(HaveOccurred())
_, err = w.Write(info)
Expect(err).ToNot(HaveOccurred())
err = zipFile.Close()
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
err := os.Remove(tmpFile.Name())
Expect(err).ToNot(HaveOccurred())
})
It("succeeds", func() {
out, err := as.Download(DownloadInput{
OutputPath: outputPath,
})
Expect(err).ToNot(HaveOccurred())
Expect(out.DestinationPath).ToNot(BeEmpty())
})
})
})
})
================================================
FILE: pkg/appstore/appstore_get_version_metadata.go
================================================
package appstore
import (
"errors"
"fmt"
"strings"
"time"
"github.com/majd/ipatool/v2/pkg/http"
)
type GetVersionMetadataInput struct {
Account Account
App App
VersionID string
}
type GetVersionMetadataOutput struct {
DisplayVersion string
ReleaseDate time.Time
}
func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersionMetadataOutput, error) {
macAddr, err := t.machine.MacAddress()
if err != nil {
return GetVersionMetadataOutput{}, fmt.Errorf("failed to get mac address: %w", err)
}
guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")
req := t.getVersionMetadataRequest(input.Account, input.App, guid, input.VersionID)
res, err := t.downloadClient.Send(req)
if err != nil {
return GetVersionMetadataOutput{}, fmt.Errorf("failed to send http request: %w", err)
}
if res.Data.FailureType == FailureTypePasswordTokenExpired {
return GetVersionMetadataOutput{}, ErrPasswordTokenExpired
}
if res.Data.FailureType == FailureTypeLicenseNotFound {
return GetVersionMetadataOutput{}, ErrLicenseRequired
}
if res.Data.FailureType != "" && res.Data.CustomerMessage != "" {
return GetVersionMetadataOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res)
}
if res.Data.FailureType != "" {
return GetVersionMetadataOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res)
}
if len(res.Data.Items) == 0 {
return GetVersionMetadataOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res)
}
item := res.Data.Items[0]
releaseDate, err := time.Parse(time.RFC3339, fmt.Sprintf("%v", item.Metadata["releaseDate"]))
if err != nil {
return GetVersionMetadataOutput{}, fmt.Errorf("failed to parse release date: %w", err)
}
return GetVersionMetadataOutput{
DisplayVersion: fmt.Sprintf("%v", item.Metadata["bundleShortVersionString"]),
ReleaseDate: releaseDate,
}, nil
}
func (t *appstore) getVersionMetadataRequest(acc Account, app App, guid string, version string) http.Request {
payload := map[string]interface{}{
"creditDisplay": "",
"guid": guid,
"salableAdamId": app.ID,
"externalVersionId": version,
}
podPrefix := ""
if acc.Pod != "" {
podPrefix = "p" + acc.Pod + "-"
}
return http.Request{
URL: fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid),
Method: http.MethodPOST,
ResponseFormat: http.ResponseFormatXML,
Headers: map[string]string{
"Content-Type": "application/x-apple-plist",
"iCloud-DSID": acc.DirectoryServicesID,
"X-Dsid": acc.DirectoryServicesID,
},
Payload: &http.XMLPayload{
Content: payload,
},
}
}
================================================
FILE: pkg/appstore/appstore_get_version_metadata_test.go
================================================
package appstore
import (
"errors"
"time"
"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"
)
var _ = Describe("AppStore (GetVersionMetadata)", func() {
var (
ctrl *gomock.Controller
mockMachine *machine.MockMachine
mockDownloadClient *http.MockClient[downloadResult]
as AppStore
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockMachine = machine.NewMockMachine(ctrl)
mockDownloadClient = http.NewMockClient[downloadResult](ctrl)
as = &appstore{
machine: mockMachine,
downloadClient: mockDownloadClient,
}
})
AfterEach(func() {
ctrl.Finish()
})
When("fails to get MAC address", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("", errors.New("mac error"))
})
It("returns error", func() {
_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get mac address"))
})
})
When("request fails", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:11:22:33:44:55", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{}, errors.New("request error"))
})
It("returns error", func() {
_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to send http request"))
})
})
When("request uses a custom pod", func() {
const (
testPod = "42"
testGUID = "001122334455"
)
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:11:22:33:44:55", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Do(func(req http.Request) {
expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathDownload + "?guid=" + testGUID
Expect(req.URL).To(Equal(expectedURL))
}).
Return(http.Result[downloadResult]{}, errors.New("request error"))
})
It("sends the request to the pod-specific host", func() {
_, err := as.GetVersionMetadata(GetVersionMetadataInput{
Account: Account{
Pod: testPod,
},
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to send http request"))
})
})
When("password token is expired", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:11:22:33:44:55", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
FailureType: FailureTypePasswordTokenExpired,
},
}, nil)
})
It("returns error", func() {
_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
Expect(err).To(Equal(ErrPasswordTokenExpired))
})
})
When("license is missing", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:11:22:33:44:55", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
FailureType: FailureTypeLicenseNotFound,
},
}, nil)
})
It("returns error", func() {
_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
Expect(err).To(Equal(ErrLicenseRequired))
})
})
When("store API returns error", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:11:22:33:44:55", nil)
})
When("response contains customer message", func() {
BeforeEach(func() {
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
FailureType: "SOME_ERROR",
CustomerMessage: "Customer error message",
},
}, nil)
})
It("returns customer message as error", func() {
_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Customer error message"))
})
})
When("response does not contain customer message", func() {
BeforeEach(func() {
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
FailureType: "SOME_ERROR",
},
}, nil)
})
It("returns generic error", func() {
_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("SOME_ERROR"))
})
})
})
When("store API returns no items", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:11:22:33:44:55", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
Items: []downloadItemResult{},
},
}, nil)
})
It("returns error", func() {
_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid response"))
})
})
When("fails to parse release date", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:11:22:33:44:55", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
Items: []downloadItemResult{
{
Metadata: map[string]interface{}{
"releaseDate": "invalid-date",
},
},
},
},
}, nil)
})
It("returns error", func() {
_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to parse release date"))
})
})
When("successfully gets version metadata", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:11:22:33:44:55", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
Items: []downloadItemResult{
{
Metadata: map[string]interface{}{
"releaseDate": "2024-03-20T12:00:00Z",
"bundleShortVersionString": "1.0.0",
},
},
},
},
}, nil)
})
It("returns version metadata", func() {
output, err := as.GetVersionMetadata(GetVersionMetadataInput{
Account: Account{
DirectoryServicesID: "test-dsid",
},
App: App{
ID: 1234567890,
},
VersionID: "test-version",
})
Expect(err).NotTo(HaveOccurred())
Expect(output.DisplayVersion).To(Equal("1.0.0"))
Expect(output.ReleaseDate).To(Equal(time.Date(2024, 3, 20, 12, 0, 0, 0, time.UTC)))
})
})
})
================================================
FILE: pkg/appstore/appstore_list_versions.go
================================================
package appstore
import (
"errors"
"fmt"
"strings"
"github.com/majd/ipatool/v2/pkg/http"
)
type ListVersionsInput struct {
Account Account
App App
}
type ListVersionsOutput struct {
ExternalVersionIdentifiers []string
LatestExternalVersionID string
}
func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, error) {
macAddr, err := t.machine.MacAddress()
if err != nil {
return ListVersionsOutput{}, fmt.Errorf("failed to get mac address: %w", err)
}
guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")
req := t.listVersionsRequest(input.Account, input.App, guid)
res, err := t.downloadClient.Send(req)
if err != nil {
return ListVersionsOutput{}, fmt.Errorf("failed to send http request: %w", err)
}
if res.Data.FailureType == FailureTypePasswordTokenExpired {
return ListVersionsOutput{}, ErrPasswordTokenExpired
}
if res.Data.FailureType == FailureTypeLicenseNotFound {
return ListVersionsOutput{}, ErrLicenseRequired
}
if res.Data.FailureType != "" && res.Data.CustomerMessage != "" {
return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res)
}
if res.Data.FailureType != "" {
return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res)
}
if len(res.Data.Items) == 0 {
return ListVersionsOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res)
}
item := res.Data.Items[0]
rawIdentifiers, ok := item.Metadata["softwareVersionExternalIdentifiers"].([]interface{})
if !ok {
return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("failed to get version identifiers from item metadata"), item.Metadata)
}
externalVersionIdentifiers := make([]string, len(rawIdentifiers))
for i, val := range rawIdentifiers {
externalVersionIdentifiers[i] = fmt.Sprintf("%v", val)
}
latestExternalVersionID := item.Metadata["softwareVersionExternalIdentifier"]
if latestExternalVersionID == nil {
return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("failed to get latest version from item metadata"), item.Metadata)
}
return ListVersionsOutput{
ExternalVersionIdentifiers: externalVersionIdentifiers,
LatestExternalVersionID: fmt.Sprintf("%v", latestExternalVersionID),
}, nil
}
func (t *appstore) listVersionsRequest(acc Account, app App, guid string) http.Request {
payload := map[string]interface{}{
"creditDisplay": "",
"guid": guid,
"salableAdamId": app.ID,
}
podPrefix := ""
if acc.Pod != "" {
podPrefix = "p" + acc.Pod + "-"
}
return http.Request{
URL: fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid),
Method: http.MethodPOST,
ResponseFormat: http.ResponseFormatXML,
Headers: map[string]string{
"Content-Type": "application/x-apple-plist",
"iCloud-DSID": acc.DirectoryServicesID,
"X-Dsid": acc.DirectoryServicesID,
},
Payload: &http.XMLPayload{
Content: payload,
},
}
}
================================================
FILE: pkg/appstore/appstore_list_versions_test.go
================================================
package appstore
import (
"errors"
"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"
)
var _ = Describe("AppStore (ListVersions)", func() {
var (
ctrl *gomock.Controller
mockDownloadClient *http.MockClient[downloadResult]
mockMachine *machine.MockMachine
as AppStore
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockDownloadClient = http.NewMockClient[downloadResult](ctrl)
mockMachine = machine.NewMockMachine(ctrl)
as = &appstore{
downloadClient: mockDownloadClient,
machine: mockMachine,
}
})
AfterEach(func() {
ctrl.Finish()
})
When("fails to get MAC address", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("", errors.New(""))
})
It("returns error", func() {
_, err := as.ListVersions(ListVersionsInput{})
Expect(err).To(HaveOccurred())
})
})
When("request fails", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{}, errors.New(""))
})
It("returns error", func() {
_, err := as.ListVersions(ListVersionsInput{})
Expect(err).To(HaveOccurred())
})
})
When("request uses a custom pod", func() {
const (
testPod = "42"
testGUID = "001122334455"
)
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:11:22:33:44:55", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Do(func(req http.Request) {
expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathDownload + "?guid=" + testGUID
Expect(req.URL).To(Equal(expectedURL))
}).
Return(http.Result[downloadResult]{}, errors.New(""))
})
It("sends the request to the pod-specific host", func() {
_, err := as.ListVersions(ListVersionsInput{
Account: Account{
Pod: testPod,
},
})
Expect(err).To(HaveOccurred())
})
})
When("password token is expired", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
FailureType: FailureTypePasswordTokenExpired,
},
}, nil)
})
It("returns error", func() {
_, err := as.ListVersions(ListVersionsInput{})
Expect(err).To(HaveOccurred())
})
})
When("license is required", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
FailureType: FailureTypeLicenseNotFound,
},
}, nil)
})
It("returns error", func() {
_, err := as.ListVersions(ListVersionsInput{})
Expect(err).To(HaveOccurred())
})
})
When("store API returns error with customer message", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
FailureType: "test-failure",
CustomerMessage: "test error message",
},
}, nil)
})
It("returns error with customer message", func() {
_, err := as.ListVersions(ListVersionsInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("test error message"))
})
})
When("store API returns error without customer message", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
FailureType: "test-failure",
},
}, nil)
})
It("returns error with failure type", func() {
_, err := as.ListVersions(ListVersionsInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("test-failure"))
})
})
When("store API returns no items", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
Items: []downloadItemResult{},
},
}, nil)
})
It("returns error", func() {
_, err := as.ListVersions(ListVersionsInput{})
Expect(err).To(HaveOccurred())
})
})
When("version identifiers not found in metadata", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
Items: []downloadItemResult{
{
Metadata: map[string]interface{}{
"someOtherKey": "someValue",
},
},
},
},
}, nil)
})
It("returns error", func() {
_, err := as.ListVersions(ListVersionsInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get version identifiers from item metadata"))
})
})
When("latest version not found in metadata", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
Items: []downloadItemResult{
{
Metadata: map[string]interface{}{
"softwareVersionExternalIdentifiers": []interface{}{"12345678", "87654321"},
},
},
},
},
}, nil)
})
It("returns error", func() {
_, err := as.ListVersions(ListVersionsInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get latest version from item metadata"))
})
})
When("successfully lists versions", func() {
const (
testVersion1 = "12345678"
testVersion2 = "87654321"
testLatest = "87654321"
)
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockDownloadClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[downloadResult]{
Data: downloadResult{
Items: []downloadItemResult{
{
Metadata: map[string]interface{}{
"softwareVersionExternalIdentifiers": []interface{}{testVersion1, testVersion2},
"softwareVersionExternalIdentifier": testLatest,
},
},
},
},
}, nil)
})
It("returns versions", func() {
out, err := as.ListVersions(ListVersionsInput{})
Expect(err).ToNot(HaveOccurred())
Expect(out.ExternalVersionIdentifiers).To(Equal([]string{testVersion1, testVersion2}))
Expect(out.LatestExternalVersionID).To(Equal(testLatest))
})
})
})
================================================
FILE: pkg/appstore/appstore_login.go
================================================
package appstore
import (
"encoding/json"
"errors"
"fmt"
gohttp "net/http"
"strconv"
"strings"
"github.com/majd/ipatool/v2/pkg/http"
"github.com/majd/ipatool/v2/pkg/util"
)
var (
ErrAuthCodeRequired = errors.New("auth code is required")
)
type LoginInput struct {
Email string
Password string
AuthCode string
Endpoint string
}
type LoginOutput struct {
Account Account
}
func (t *appstore) Login(input LoginInput) (LoginOutput, error) {
macAddr, err := t.machine.MacAddress()
if err != nil {
return LoginOutput{}, fmt.Errorf("failed to get mac address: %w", err)
}
guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")
acc, err := t.login(input.Email, input.Password, input.AuthCode, guid, input.Endpoint)
if err != nil {
return LoginOutput{}, err
}
return LoginOutput{
Account: acc,
}, nil
}
type loginAddressResult struct {
FirstName string `plist:"firstName,omitempty"`
LastName string `plist:"lastName,omitempty"`
}
type loginAccountResult struct {
Email string `plist:"appleId,omitempty"`
Address loginAddressResult `plist:"address,omitempty"`
}
type loginResult struct {
FailureType string `plist:"failureType,omitempty"`
CustomerMessage string `plist:"customerMessage,omitempty"`
Account loginAccountResult `plist:"accountInfo,omitempty"`
DirectoryServicesID string `plist:"dsPersonId,omitempty"`
PasswordToken string `plist:"passwordToken,omitempty"`
}
func (t *appstore) login(email, password, authCode, guid, endpoint string) (Account, error) {
redirect := ""
var (
err error
res http.Result[loginResult]
)
retry := true
for attempt := 1; retry && attempt <= 4; attempt++ {
request := t.loginRequest(email, password, authCode, guid, endpoint, attempt)
request.URL, _ = util.IfEmpty(redirect, request.URL), ""
res, err = t.loginClient.Send(request)
if err != nil {
return Account{}, fmt.Errorf("request failed: %w", err)
}
if retry, redirect, err = t.parseLoginResponse(&res, attempt, authCode); err != nil {
return Account{}, err
}
}
if retry {
return Account{}, NewErrorWithMetadata(errors.New("too many attempts"), res)
}
sf, err := res.GetHeader(HTTPHeaderStoreFront)
if err != nil {
return Account{}, NewErrorWithMetadata(fmt.Errorf("failed to get storefront header: %w", err), res)
}
pod, err := res.GetHeader(HTTPHeaderPod)
if err != nil && !errors.Is(err, http.ErrHeaderNotFound) {
return Account{}, NewErrorWithMetadata(fmt.Errorf("failed to get pod header: %w", err), res)
}
addr := res.Data.Account.Address
acc := Account{
Name: strings.Join([]string{addr.FirstName, addr.LastName}, " "),
Email: res.Data.Account.Email,
PasswordToken: res.Data.PasswordToken,
DirectoryServicesID: res.Data.DirectoryServicesID,
StoreFront: sf,
Password: password,
Pod: pod,
}
data, err := json.Marshal(acc)
if err != nil {
return Account{}, fmt.Errorf("failed to marshal json: %w", err)
}
err = t.keychain.Set("account", data)
if err != nil {
return Account{}, fmt.Errorf("failed to save account in keychain: %w", err)
}
return acc, nil
}
func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode string) (bool, string, error) {
var (
retry bool
redirect string
err error
)
if res.StatusCode == gohttp.StatusFound {
if redirect, err = res.GetHeader("location"); err != nil {
err = fmt.Errorf("failed to retrieve redirect location: %w", err)
} else {
retry = true
}
} else if attempt == 1 && res.Data.FailureType == FailureTypeInvalidCredentials {
retry = true
} else if res.Data.FailureType == "" && authCode == "" && res.Data.CustomerMessage == CustomerMessageBadLogin {
err = ErrAuthCodeRequired
} else if res.Data.FailureType == "" && res.Data.CustomerMessage == CustomerMessageAccountDisabled {
err = NewErrorWithMetadata(errors.New("account is disabled"), res)
} else if res.Data.FailureType != "" {
if res.Data.CustomerMessage != "" {
err = NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res)
} else {
err = NewErrorWithMetadata(errors.New("something went wrong"), res)
}
} else if res.StatusCode != gohttp.StatusOK || res.Data.PasswordToken == "" || res.Data.DirectoryServicesID == "" {
err = NewErrorWithMetadata(errors.New("something went wrong"), res)
}
return retry, redirect, err
}
func (t *appstore) loginRequest(email, password, authCode, guid, endpoint string, attempt int) http.Request {
return http.Request{
Method: http.MethodPOST,
URL: endpoint,
ResponseFormat: http.ResponseFormatXML,
Headers: map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
},
Payload: &http.XMLPayload{
Content: map[string]interface{}{
"appleId": email,
"attempt": strconv.Itoa(attempt),
"guid": guid,
"password": fmt.Sprintf("%s%s", password, strings.ReplaceAll(authCode, " ", "")),
"rmp": "0",
"why": "signIn",
},
},
}
}
================================================
FILE: pkg/appstore/appstore_login_test.go
================================================
package appstore
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/majd/ipatool/v2/pkg/http"
"github.com/majd/ipatool/v2/pkg/keychain"
"github.com/majd/ipatool/v2/pkg/util/machine"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
)
var _ = Describe("AppStore (Login)", func() {
const (
testPassword = "test-password"
testEmail = "test-email"
testFirstName = "test-first-name"
testLastName = "test-last-name"
testPod = "42"
)
var (
ctrl *gomock.Controller
as AppStore
mockKeychain *keychain.MockKeychain
mockClient *http.MockClient[loginResult]
mockMachine *machine.MockMachine
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockKeychain = keychain.NewMockKeychain(ctrl)
mockClient = http.NewMockClient[loginResult](ctrl)
mockMachine = machine.NewMockMachine(ctrl)
as = &appstore{
keychain: mockKeychain,
loginClient: mockClient,
machine: mockMachine,
}
})
AfterEach(func() {
ctrl.Finish()
})
When("fails to read Machine's MAC address", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("", errors.New(""))
})
It("returns error", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).To(HaveOccurred())
})
})
When("successfully reads machine's MAC address", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
})
When("client returns error", func() {
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[loginResult]{}, errors.New(""))
})
It("returns wrapped error", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).To(HaveOccurred())
})
})
When("store API returns invalid first response", func() {
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[loginResult]{
Data: loginResult{
FailureType: FailureTypeInvalidCredentials,
CustomerMessage: "test",
},
}, nil).
Times(2)
})
It("retries one more time", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).To(HaveOccurred())
})
})
When("store API returns error", func() {
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[loginResult]{
Data: loginResult{
FailureType: "random-error",
},
}, nil)
})
It("returns error", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).To(HaveOccurred())
})
})
When("store API indicates account is disabled", func() {
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[loginResult]{
Data: loginResult{
CustomerMessage: CustomerMessageAccountDisabled,
},
}, nil)
})
It("returns account disabled error", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("account is disabled"))
})
})
When("store API requires 2FA code", func() {
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[loginResult]{
Data: loginResult{
FailureType: "",
CustomerMessage: CustomerMessageBadLogin,
},
}, nil)
})
It("returns ErrAuthCodeRequired error", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).To(Equal(ErrAuthCodeRequired))
})
})
When("store API redirects", func() {
const (
testRedirectLocation = "https://test-redirect-url.com"
)
BeforeEach(func() {
firstCall := mockClient.EXPECT().
Send(gomock.Any()).
Do(func(req http.Request) {
Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{}))
x := req.Payload.(*http.XMLPayload)
Expect(x.Content).To(HaveKeyWithValue("attempt", "1"))
}).
Return(http.Result[loginResult]{
StatusCode: 302,
Headers: map[string]string{"Location": testRedirectLocation},
}, nil)
secondCall := mockClient.EXPECT().
Send(gomock.Any()).
Do(func(req http.Request) {
Expect(req.URL).To(Equal(testRedirectLocation))
Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{}))
x := req.Payload.(*http.XMLPayload)
Expect(x.Content).To(HaveKeyWithValue("attempt", "2"))
}).
Return(http.Result[loginResult]{}, errors.New("test complete"))
gomock.InOrder(firstCall, secondCall)
})
It("follows the redirect and increments attempt", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).To(MatchError("request failed: test complete"))
})
})
When("store API redirects too much", func() {
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[loginResult]{
StatusCode: 302,
Headers: map[string]string{"Location": "hello"},
}, nil).
Times(4)
})
It("bails out", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).To(MatchError("too many attempts"))
})
})
When("store API returns valid response", func() {
const (
testPasswordToken = "test-password-token"
testDirectoryServicesID = "directory-services-id"
testStoreFront = "test-storefront"
)
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[loginResult]{
StatusCode: 200,
Headers: map[string]string{
HTTPHeaderStoreFront: testStoreFront,
HTTPHeaderPod: testPod,
},
Data: loginResult{
PasswordToken: testPasswordToken,
DirectoryServicesID: testDirectoryServicesID,
Account: loginAccountResult{
Email: testEmail,
Address: loginAddressResult{
FirstName: testFirstName,
LastName: testLastName,
},
},
},
}, nil)
})
When("fails to save account in keychain", func() {
BeforeEach(func() {
mockKeychain.EXPECT().
Set("account", gomock.Any()).
Do(func(key string, data []byte) {
want := Account{
Name: fmt.Sprintf("%s %s", testFirstName, testLastName),
Email: testEmail,
PasswordToken: testPasswordToken,
Password: testPassword,
DirectoryServicesID: testDirectoryServicesID,
StoreFront: testStoreFront,
Pod: testPod,
}
var got Account
err := json.Unmarshal(data, &got)
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(want))
}).
Return(errors.New(""))
})
It("returns error", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).To(HaveOccurred())
})
})
When("successfully saves account in keychain", func() {
BeforeEach(func() {
mockKeychain.EXPECT().
Set("account", gomock.Any()).
Do(func(key string, data []byte) {
want := Account{
Name: fmt.Sprintf("%s %s", testFirstName, testLastName),
Email: testEmail,
PasswordToken: testPasswordToken,
Password: testPassword,
DirectoryServicesID: testDirectoryServicesID,
StoreFront: testStoreFront,
Pod: testPod,
}
var got Account
err := json.Unmarshal(data, &got)
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(want))
}).
Return(nil)
})
It("returns nil", func() {
out, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).ToNot(HaveOccurred())
Expect(out.Account.Email).To(Equal(testEmail))
Expect(out.Account.Name).To(Equal(strings.Join([]string{testFirstName, testLastName}, " ")))
})
})
})
})
})
================================================
FILE: pkg/appstore/appstore_lookup.go
================================================
package appstore
import (
"errors"
"fmt"
gohttp "net/http"
"net/url"
"github.com/majd/ipatool/v2/pkg/http"
)
type LookupInput struct {
Account Account
BundleID string
}
type LookupOutput struct {
App App
}
func (t *appstore) Lookup(input LookupInput) (LookupOutput, error) {
countryCode, err := countryCodeFromStoreFront(input.Account.StoreFront)
if err != nil {
return LookupOutput{}, fmt.Errorf("failed to resolve the country code: %w", err)
}
request := t.lookupRequest(input.BundleID, countryCode)
res, err := t.searchClient.Send(request)
if err != nil {
return LookupOutput{}, fmt.Errorf("request failed: %w", err)
}
if res.StatusCode != gohttp.StatusOK {
return LookupOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res)
}
if len(res.Data.Results) == 0 {
return LookupOutput{}, errors.New("app not found")
}
return LookupOutput{
App: res.Data.Results[0],
}, nil
}
func (t *appstore) lookupRequest(bundleID, countryCode string) http.Request {
return http.Request{
URL: t.lookupURL(bundleID, countryCode),
Method: http.MethodGET,
ResponseFormat: http.ResponseFormatJSON,
}
}
func (t *appstore) lookupURL(bundleID, countryCode string) string {
params := url.Values{}
params.Add("entity", "software,iPadSoftware")
params.Add("limit", "1")
params.Add("media", "software")
params.Add("bundleId", bundleID)
params.Add("country", countryCode)
return fmt.Sprintf("https://%s%s?%s", iTunesAPIDomain, iTunesAPIPathLookup, params.Encode())
}
================================================
FILE: pkg/appstore/appstore_lookup_test.go
================================================
package appstore
import (
"errors"
"github.com/majd/ipatool/v2/pkg/http"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
)
var _ = Describe("AppStore (Lookup)", func() {
var (
ctrl *gomock.Controller
mockClient *http.MockClient[searchResult]
as AppStore
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockClient = http.NewMockClient[searchResult](ctrl)
as = &appstore{
searchClient: mockClient,
}
})
AfterEach(func() {
ctrl.Finish()
})
When("request is successful", func() {
When("does not find app", func() {
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[searchResult]{
StatusCode: 200,
Data: searchResult{
Count: 0,
Results: []App{},
},
}, nil)
})
It("returns error", func() {
_, err := as.Lookup(LookupInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).To(HaveOccurred())
})
})
When("finds app", func() {
var testApp = App{
ID: 1,
BundleID: "app.bundle.id",
Name: "app name",
Version: "1.0",
Price: 0.99,
}
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[searchResult]{
StatusCode: 200,
Data: searchResult{
Count: 1,
Results: []App{testApp},
},
}, nil)
})
It("returns app", func() {
app, err := as.Lookup(LookupInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).ToNot(HaveOccurred())
Expect(app).To(Equal(LookupOutput{App: testApp}))
})
})
})
When("store front is invalid", func() {
It("returns error", func() {
_, err := as.Lookup(LookupInput{
Account: Account{
StoreFront: "xyz",
},
})
Expect(err).To(HaveOccurred())
})
})
When("request fails", func() {
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[searchResult]{}, errors.New(""))
})
It("returns error", func() {
_, err := as.Lookup(LookupInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).To(HaveOccurred())
})
})
When("request returns bad status code", func() {
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[searchResult]{
StatusCode: 400,
}, nil)
})
It("returns error", func() {
_, err := as.Lookup(LookupInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).To(HaveOccurred())
})
})
})
================================================
FILE: pkg/appstore/appstore_purchase.go
================================================
package appstore
import (
"errors"
"fmt"
gohttp "net/http"
"strings"
"github.com/majd/ipatool/v2/pkg/http"
)
var (
ErrPasswordTokenExpired = errors.New("password token is expired")
ErrSubscriptionRequired = errors.New("subscription required")
ErrTemporarilyUnavailable = errors.New("item is temporarily unavailable")
)
type PurchaseInput struct {
Account Account
App App
}
func (t *appstore) Purchase(input PurchaseInput) error {
macAddr, err := t.machine.MacAddress()
if err != nil {
return fmt.Errorf("failed to get mac address: %w", err)
}
guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")
if input.App.Price > 0 {
return errors.New("purchasing paid apps is not supported")
}
err = t.purchaseWithParams(input.Account, input.App, guid, PricingParameterAppStore)
if err != nil {
if err == ErrTemporarilyUnavailable {
err = t.purchaseWithParams(input.Account, input.App, guid, PricingParameterAppleArcade)
if err != nil {
return fmt.Errorf("failed to purchase item with param '%s': %w", PricingParameterAppleArcade, err)
}
return nil
}
return fmt.Errorf("failed to purchase item with param '%s': %w", PricingParameterAppStore, err)
}
return nil
}
type purchaseResult struct {
FailureType string `plist:"failureType,omitempty"`
CustomerMessage string `plist:"customerMessage,omitempty"`
JingleDocType string `plist:"jingleDocType,omitempty"`
Status int `plist:"status,omitempty"`
}
func (t *appstore) purchaseWithParams(acc Account, app App, guid string, pricingParameters string) error {
req := t.purchaseRequest(acc, app, acc.StoreFront, guid, pricingParameters)
res, err := t.purchaseClient.Send(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
if res.Data.FailureType == FailureTypeTemporarilyUnavailable {
return ErrTemporarilyUnavailable
}
if res.Data.CustomerMessage == CustomerMessageSubscriptionRequired {
return ErrSubscriptionRequired
}
if res.Data.FailureType == FailureTypePasswordTokenExpired {
return ErrPasswordTokenExpired
}
if res.Data.FailureType != "" && res.Data.CustomerMessage != "" {
return NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res)
}
if res.Data.FailureType != "" {
return NewErrorWithMetadata(errors.New("something went wrong"), res)
}
if res.StatusCode == gohttp.StatusInternalServerError {
return fmt.Errorf("license already exists")
}
if res.Data.JingleDocType != "purchaseSuccess" || res.Data.Status != 0 {
return NewErrorWithMetadata(errors.New("failed to purchase app"), res)
}
return nil
}
func (t *appstore) purchaseRequest(acc Account, app App, storeFront, guid string, pricingParameters string) http.Request {
podPrefix := ""
if acc.Pod != "" {
podPrefix = "p" + acc.Pod + "-"
}
return http.Request{
URL: fmt.Sprintf("https://%s%s%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathPurchase),
Method: http.MethodPOST,
ResponseFormat: http.ResponseFormatXML,
Headers: map[string]string{
"Content-Type": "application/x-apple-plist",
"iCloud-DSID": acc.DirectoryServicesID,
"X-Dsid": acc.DirectoryServicesID,
"X-Apple-Store-Front": storeFront,
"X-Token": acc.PasswordToken,
},
Payload: &http.XMLPayload{
Content: map[string]interface{}{
"appExtVrsId": "0",
"hasAskedToFulfillPreorder": "true",
"buyWithoutAuthorization": "true",
"hasDoneAgeCheck": "true",
"guid": guid,
"needDiv": "0",
"origPage": fmt.Sprintf("Software-%d", app.ID),
"origPageLocation": "Buy",
"price": "0",
"pricingParameters": pricingParameters,
"productType": "C",
"salableAdamId": app.ID,
},
},
}
}
================================================
FILE: pkg/appstore/appstore_purchase_test.go
================================================
package appstore
import (
"errors"
"github.com/majd/ipatool/v2/pkg/http"
"github.com/majd/ipatool/v2/pkg/keychain"
"github.com/majd/ipatool/v2/pkg/util/machine"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
)
var _ = Describe("AppStore (Purchase)", func() {
var (
ctrl *gomock.Controller
mockKeychain *keychain.MockKeychain
mockMachine *machine.MockMachine
mockPurchaseClient *http.MockClient[purchaseResult]
mockLoginClient *http.MockClient[loginResult]
as *appstore
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl)
mockLoginClient = http.NewMockClient[loginResult](ctrl)
mockKeychain = keychain.NewMockKeychain(ctrl)
mockMachine = machine.NewMockMachine(ctrl)
as = &appstore{
keychain: mockKeychain,
purchaseClient: mockPurchaseClient,
loginClient: mockLoginClient,
machine: mockMachine,
}
})
AfterEach(func() {
ctrl.Finish()
})
When("fails to read MAC address", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("", errors.New(""))
})
It("returns error", func() {
err := as.Purchase(PurchaseInput{})
Expect(err).To(HaveOccurred())
})
})
When("app is paid", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
})
It("returns error", func() {
err := as.Purchase(PurchaseInput{
Account: Account{
StoreFront: "143441",
},
App: App{
Price: 0.99,
},
})
Expect(err).To(HaveOccurred())
})
})
When("purchase request fails", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockPurchaseClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[purchaseResult]{}, errors.New(""))
})
It("returns error", func() {
err := as.Purchase(PurchaseInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).To(HaveOccurred())
})
})
When("request uses a custom pod", func() {
const (
testPod = "42"
testGUID = "001122334455"
)
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:11:22:33:44:55", nil)
mockPurchaseClient.EXPECT().
Send(gomock.Any()).
Do(func(req http.Request) {
expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathPurchase
Expect(req.URL).To(Equal(expectedURL))
Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{}))
payload := req.Payload.(*http.XMLPayload)
Expect(payload.Content["guid"]).To(Equal(testGUID))
}).
Return(http.Result[purchaseResult]{}, errors.New(""))
})
It("sends the request to the pod-specific host", func() {
err := as.Purchase(PurchaseInput{
Account: Account{
StoreFront: "143441",
Pod: testPod,
},
})
Expect(err).To(HaveOccurred())
})
})
When("password token is expired", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockPurchaseClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[purchaseResult]{
Data: purchaseResult{
FailureType: FailureTypePasswordTokenExpired,
},
}, nil)
})
It("returns error", func() {
err := as.Purchase(PurchaseInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).To(HaveOccurred())
})
})
When("store API returns customer error message", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockPurchaseClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[purchaseResult]{
Data: purchaseResult{
FailureType: "failure",
CustomerMessage: CustomerMessageBadLogin,
},
}, nil)
})
It("returns error", func() {
err := as.Purchase(PurchaseInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).To(HaveOccurred())
})
})
When("store API returns unknown error", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockPurchaseClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[purchaseResult]{
Data: purchaseResult{
FailureType: "failure",
},
}, nil)
})
It("returns error", func() {
err := as.Purchase(PurchaseInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).To(HaveOccurred())
})
})
When("account already has a license for the app", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockPurchaseClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[purchaseResult]{
StatusCode: 500,
Data: purchaseResult{},
}, nil)
})
It("returns error", func() {
err := as.Purchase(PurchaseInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).To(HaveOccurred())
})
})
When("subscription is required", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockPurchaseClient.EXPECT().
Send(pricingParametersMatcher{"STDQ"}).
Return(http.Result[purchaseResult]{
StatusCode: 200,
Data: purchaseResult{
CustomerMessage: "This item is temporarily unavailable.",
FailureType: FailureTypeTemporarilyUnavailable,
},
}, nil)
mockPurchaseClient.EXPECT().
Send(pricingParametersMatcher{"GAME"}).
Return(http.Result[purchaseResult]{
StatusCode: 200,
Data: purchaseResult{
CustomerMessage: CustomerMessageSubscriptionRequired,
},
}, nil)
})
It("returns error", func() {
err := as.Purchase(PurchaseInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).To(HaveOccurred())
})
})
When("successfully purchases the app", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockPurchaseClient.EXPECT().
Send(pricingParametersMatcher{"STDQ"}).
Return(http.Result[purchaseResult]{
StatusCode: 200,
Data: purchaseResult{
CustomerMessage: "This item is temporarily unavailable.",
FailureType: FailureTypeTemporarilyUnavailable,
},
}, nil)
mockPurchaseClient.EXPECT().
Send(pricingParametersMatcher{"GAME"}).
Return(http.Result[purchaseResult]{
StatusCode: 200,
Data: purchaseResult{
JingleDocType: "purchaseSuccess",
Status: 0,
},
}, nil)
})
It("returns nil", func() {
err := as.Purchase(PurchaseInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).ToNot(HaveOccurred())
})
})
When("purchasing the app fails", func() {
BeforeEach(func() {
mockMachine.EXPECT().
MacAddress().
Return("00:00:00:00:00:00", nil)
mockPurchaseClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[purchaseResult]{
StatusCode: 200,
Data: purchaseResult{
JingleDocType: "failure",
Status: -1,
},
}, nil)
})
It("returns nil", func() {
err := as.Purchase(PurchaseInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).To(HaveOccurred())
})
})
})
type pricingParametersMatcher struct {
pricingParameters string
}
func (p pricingParametersMatcher) Matches(in interface{}) bool {
return in.(http.Request).Payload.(*http.XMLPayload).Content["pricingParameters"] == p.pricingParameters
}
func (p pricingParametersMatcher) String() string {
return "payload pricingParameters is " + p.pricingParameters
}
================================================
FILE: pkg/appstore/appstore_replicate_sinf.go
================================================
package appstore
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/majd/ipatool/v2/pkg/util"
"howett.net/plist"
)
type Sinf struct {
ID int64 `plist:"id,omitempty"`
Data []byte `plist:"sinf,omitempty"`
}
type ReplicateSinfInput struct {
Sinfs []Sinf
PackagePath string
}
func (t *appstore) ReplicateSinf(input ReplicateSinfInput) error {
zipReader, err := zip.OpenReader(input.PackagePath)
if err != nil {
return errors.New("failed to open zip reader")
}
tmpPath := fmt.Sprintf("%s.tmp", input.PackagePath)
tmpFile, err := t.os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
zipWriter := zip.NewWriter(tmpFile)
err = t.replicateZip(zipReader, zipWriter)
if err != nil {
return fmt.Errorf("failed to replicate zip: %w", err)
}
bundleName, err := t.readBundleName(zipReader)
if err != nil {
return fmt.Errorf("failed to read bundle name: %w", err)
}
manifest, err := t.readManifestPlist(zipReader)
if err != nil {
return fmt.Errorf("failed to read manifest plist: %w", err)
}
info, err := t.readInfoPlist(zipReader)
if err != nil {
return fmt.Errorf("failed to read info plist: %w", err)
}
if manifest != nil {
err = t.replicateSinfFromManifest(*manifest, zipWriter, input.Sinfs, bundleName)
} else {
err = t.replicateSinfFromInfo(*info, zipWriter, input.Sinfs, bundleName)
}
if err != nil {
return fmt.Errorf("failed to replicate sinf: %w", err)
}
zipReader.Close()
zipWriter.Close()
tmpFile.Close()
err = t.os.Remove(input.PackagePath)
if err != nil {
return fmt.Errorf("failed to remove original file: %w", err)
}
err = t.os.Rename(tmpPath, input.PackagePath)
if err != nil {
return fmt.Errorf("failed to remove original file: %w", err)
}
return nil
}
type packageManifest struct {
SinfPaths []string `plist:"SinfPaths,omitempty"`
}
type packageInfo struct {
BundleExecutable string `plist:"CFBundleExecutable,omitempty"`
}
func (*appstore) replicateSinfFromManifest(manifest packageManifest, zip *zip.Writer, sinfs []Sinf, bundleName string) error {
zipped, err := util.Zip(sinfs, manifest.SinfPaths)
if err != nil {
return fmt.Errorf("failed to zip sinfs: %w", err)
}
for _, pair := range zipped {
sp := fmt.Sprintf("Payload/%s.app/%s", bundleName, pair.Second)
file, err := zip.Create(sp)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
_, err = file.Write(pair.First.Data)
if err != nil {
return fmt.Errorf("failed to write data: %w", err)
}
}
return nil
}
func (t *appstore) replicateSinfFromInfo(info packageInfo, zip *zip.Writer, sinfs []Sinf, bundleName string) error {
sp := fmt.Sprintf("Payload/%s.app/SC_Info/%s.sinf", bundleName, info.BundleExecutable)
file, err := zip.Create(sp)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
_, err = file.Write(sinfs[0].Data)
if err != nil {
return fmt.Errorf("failed to write data: %w", err)
}
return nil
}
func (t *appstore) replicateZip(src *zip.ReadCloser, dst *zip.Writer) error {
for _, file := range src.File {
srcFile, err := file.OpenRaw()
if err != nil {
return fmt.Errorf("failed to open raw file: %w", err)
}
header := file.FileHeader
dstFile, err := dst.CreateRaw(&header)
if err != nil {
return fmt.Errorf("failed to create raw file: %w", err)
}
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return fmt.Errorf("failed to copy file: %w", err)
}
}
return nil
}
func (*appstore) readInfoPlist(reader *zip.ReadCloser) (*packageInfo, error) {
for _, file := range reader.File {
if strings.Contains(file.Name, ".app/Info.plist") {
src, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
data := new(bytes.Buffer)
_, err = io.Copy(data, src)
if err != nil {
return nil, fmt.Errorf("failed to copy data: %w", err)
}
var info packageInfo
_, err = plist.Unmarshal(data.Bytes(), &info)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal data: %w", err)
}
return &info, nil
}
}
return nil, nil
}
func (*appstore) readManifestPlist(reader *zip.ReadCloser) (*packageManifest, error) {
for _, file := range reader.File {
if strings.HasSuffix(file.Name, ".app/SC_Info/Manifest.plist") {
src, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
data := new(bytes.Buffer)
_, err = io.Copy(data, src)
if err != nil {
return nil, fmt.Errorf("failed to copy data: %w", err)
}
var manifest packageManifest
_, err = plist.Unmarshal(data.Bytes(), &manifest)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal data: %w", err)
}
return &manifest, nil
}
}
return nil, nil
}
func (*appstore) readBundleName(reader *zip.ReadCloser) (string, error) {
var bundleName string
for _, file := range reader.File {
if strings.Contains(file.Name, ".app/Info.plist") && !strings.Contains(file.Name, "/Watch/") {
bundleName = filepath.Base(strings.TrimSuffix(file.Name, ".app/Info.plist"))
break
}
}
if bundleName == "" {
return "", errors.New("could not read bundle name")
}
return bundleName, nil
}
================================================
FILE: pkg/appstore/appstore_replicate_sinf_test.go
================================================
package appstore
import (
"archive/zip"
"errors"
"fmt"
"os"
"github.com/majd/ipatool/v2/pkg/http"
"github.com/majd/ipatool/v2/pkg/keychain"
"github.com/majd/ipatool/v2/pkg/util/machine"
"github.com/majd/ipatool/v2/pkg/util/operatingsystem"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
"howett.net/plist"
)
var _ = Describe("AppStore (ReplicateSinf)", func() {
var (
ctrl *gomock.Controller
mockKeychain *keychain.MockKeychain
mockDownloadClient *http.MockClient[downloadResult]
mockPurchaseClient *http.MockClient[purchaseResult]
mockLoginClient *http.MockClient[loginResult]
mockHTTPClient *http.MockClient[interface{}]
mockOS *operatingsystem.MockOperatingSystem
mockMachine *machine.MockMachine
as AppStore
testFile *os.File
testZip *zip.Writer
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockKeychain = keychain.NewMockKeychain(ctrl)
mockDownloadClient = http.NewMockClient[downloadResult](ctrl)
mockLoginClient = http.NewMockClient[loginResult](ctrl)
mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl)
mockHTTPClient = http.NewMockClient[interface{}](ctrl)
mockOS = operatingsystem.NewMockOperatingSystem(ctrl)
mockMachine = machine.NewMockMachine(ctrl)
as = &appstore{
keychain: mockKeychain,
loginClient: mockLoginClient,
purchaseClient: mockPurchaseClient,
downloadClient: mockDownloadClient,
httpClient: mockHTTPClient,
machine: mockMachine,
os: mockOS,
}
var err error
testFile, err = os.CreateTemp("", "test_file")
Expect(err).ToNot(HaveOccurred())
testZip = zip.NewWriter(testFile)
})
JustBeforeEach(func() {
testZip.Close()
})
AfterEach(func() {
err := os.Remove(testFile.Name())
Expect(err).ToNot(HaveOccurred())
ctrl.Finish()
})
When("app includes codesign manifest", func() {
BeforeEach(func() {
mockOS.EXPECT().
OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(os.OpenFile)
mockOS.EXPECT().
Remove(testFile.Name()).
Return(nil)
mockOS.EXPECT().
Rename(fmt.Sprintf("%s.tmp", testFile.Name()), testFile.Name()).
Return(nil)
manifest, err := plist.Marshal(packageManifest{
SinfPaths: []string{
"SC_Info/TestApp.sinf",
},
}, plist.BinaryFormat)
Expect(err).ToNot(HaveOccurred())
w, err := testZip.Create("Payload/Test.app/SC_Info/Manifest.plist")
Expect(err).ToNot(HaveOccurred())
_, err = w.Write(manifest)
Expect(err).ToNot(HaveOccurred())
w, err = testZip.Create("Payload/Test.app/Info.plist")
Expect(err).ToNot(HaveOccurred())
info, err := plist.Marshal(map[string]interface{}{
"CFBundleExecutable": "Test",
}, plist.BinaryFormat)
Expect(err).ToNot(HaveOccurred())
_, err = w.Write(info)
Expect(err).ToNot(HaveOccurred())
w, err = testZip.Create("Payload/Test.app/Watch/Test.app/Info.plist")
Expect(err).ToNot(HaveOccurred())
watchInfo, err := plist.Marshal(map[string]interface{}{
"WKWatchKitApp": true,
}, plist.BinaryFormat)
Expect(err).ToNot(HaveOccurred())
_, err = w.Write(watchInfo)
Expect(err).ToNot(HaveOccurred())
})
It("replicates sinf from manifest plist", func() {
err := as.ReplicateSinf(ReplicateSinfInput{
PackagePath: testFile.Name(),
Sinfs: []Sinf{
{
ID: 0,
Data: []byte(""),
},
},
})
Expect(err).ToNot(HaveOccurred())
})
})
When("app does not include codesign manifest", func() {
BeforeEach(func() {
mockOS.EXPECT().
OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(os.OpenFile)
mockOS.EXPECT().
Remove(testFile.Name()).
Return(nil)
mockOS.EXPECT().
Rename(fmt.Sprintf("%s.tmp", testFile.Name()), testFile.Name()).
Return(nil)
w, err := testZip.Create("Payload/Test.app/Info.plist")
Expect(err).ToNot(HaveOccurred())
info, err := plist.Marshal(map[string]interface{}{
"CFBundleExecutable": "Test",
}, plist.BinaryFormat)
Expect(err).ToNot(HaveOccurred())
_, err = w.Write(info)
Expect(err).ToNot(HaveOccurred())
w, err = testZip.Create("Payload/Test.app/Watch/Test.app/Info.plist")
Expect(err).ToNot(HaveOccurred())
watchInfo, err := plist.Marshal(map[string]interface{}{
"WKWatchKitApp": true,
}, plist.BinaryFormat)
Expect(err).ToNot(HaveOccurred())
_, err = w.Write(watchInfo)
Expect(err).ToNot(HaveOccurred())
})
It("replicates sinf", func() {
err := as.ReplicateSinf(ReplicateSinfInput{
PackagePath: testFile.Name(),
Sinfs: []Sinf{
{
ID: 0,
Data: []byte(""),
},
},
})
Expect(err).ToNot(HaveOccurred())
})
})
When("fails to open file", func() {
BeforeEach(func() {
mockOS.EXPECT().
OpenFile(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, errors.New(""))
})
It("returns error", func() {
err := as.ReplicateSinf(ReplicateSinfInput{
PackagePath: testFile.Name(),
})
Expect(err).To(HaveOccurred())
})
})
})
================================================
FILE: pkg/appstore/appstore_revoke.go
================================================
package appstore
import (
"fmt"
)
func (t *appstore) Revoke() error {
err := t.keychain.Remove("account")
if err != nil {
return fmt.Errorf("failed to remove account from keychain: %w", err)
}
return nil
}
================================================
FILE: pkg/appstore/appstore_revoke_test.go
================================================
package appstore
import (
"errors"
"github.com/majd/ipatool/v2/pkg/keychain"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
)
var _ = Describe("AppStore (Revoke)", func() {
var (
ctrl *gomock.Controller
appstore AppStore
mockKeychain *keychain.MockKeychain
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockKeychain = keychain.NewMockKeychain(ctrl)
appstore = NewAppStore(Args{
Keychain: mockKeychain,
})
})
AfterEach(func() {
ctrl.Finish()
})
When("keychain removes item", func() {
BeforeEach(func() {
mockKeychain.EXPECT().
Remove("account").
Return(nil)
})
It("returns data", func() {
err := appstore.Revoke()
Expect(err).ToNot(HaveOccurred())
})
})
When("keychain returns error", func() {
BeforeEach(func() {
mockKeychain.EXPECT().
Remove("account").
Return(errors.New(""))
})
It("returns wrapped error", func() {
err := appstore.Revoke()
Expect(err).To(HaveOccurred())
})
})
})
================================================
FILE: pkg/appstore/appstore_search.go
================================================
package appstore
import (
"errors"
"fmt"
gohttp "net/http"
"net/url"
"strconv"
"github.com/majd/ipatool/v2/pkg/http"
)
type SearchInput struct {
Account Account
Term string
Limit int64
}
type SearchOutput struct {
Count int
Results []App
}
func (t *appstore) Search(input SearchInput) (SearchOutput, error) {
countryCode, err := countryCodeFromStoreFront(input.Account.StoreFront)
if err != nil {
return SearchOutput{}, fmt.Errorf("country code is invalid: %w", err)
}
request := t.searchRequest(input.Term, countryCode, input.Limit)
res, err := t.searchClient.Send(request)
if err != nil {
return SearchOutput{}, fmt.Errorf("request failed: %w", err)
}
if res.StatusCode != gohttp.StatusOK {
return SearchOutput{}, NewErrorWithMetadata(errors.New("request failed"), res)
}
return SearchOutput{
Count: res.Data.Count,
Results: res.Data.Results,
}, nil
}
type searchResult struct {
Count int `json:"resultCount,omitempty"`
Results []App `json:"results,omitempty"`
}
func (t *appstore) searchRequest(term, countryCode string, limit int64) http.Request {
return http.Request{
URL: t.searchURL(term, countryCode, limit),
Method: http.MethodGET,
ResponseFormat: http.ResponseFormatJSON,
}
}
func (t *appstore) searchURL(term, countryCode string, limit int64) string {
params := url.Values{}
params.Add("entity", "software,iPadSoftware")
params.Add("limit", strconv.Itoa(int(limit)))
params.Add("media", "software")
params.Add("term", term)
params.Add("country", countryCode)
return fmt.Sprintf("https://%s%s?%s", iTunesAPIDomain, iTunesAPIPathSearch, params.Encode())
}
================================================
FILE: pkg/appstore/appstore_search_test.go
================================================
package appstore
import (
"errors"
"github.com/majd/ipatool/v2/pkg/http"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
)
var _ = Describe("AppStore (Search)", func() {
var (
ctrl *gomock.Controller
mockClient *http.MockClient[searchResult]
as AppStore
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockClient = http.NewMockClient[searchResult](ctrl)
as = &appstore{
searchClient: mockClient,
}
})
AfterEach(func() {
ctrl.Finish()
})
When("request is successful", func() {
const (
testID = 0
testBundleID = "test-bundle-id"
testName = "test-name"
testVersion = "test-version"
testPrice = 0.0
)
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[searchResult]{
StatusCode: 200,
Data: searchResult{
Count: 1,
Results: []App{
{
ID: testID,
BundleID: testBundleID,
Name: testName,
Version: testVersion,
Price: testPrice,
},
},
},
}, nil)
})
It("returns output", func() {
out, err := as.Search(SearchInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).ToNot(HaveOccurred())
Expect(out.Count).To(Equal(1))
Expect(out.Results).To(HaveLen(1))
Expect(out.Results[0]).To(Equal(App{
ID: testID,
BundleID: testBundleID,
Name: testName,
Version: testVersion,
Price: testPrice,
}))
})
})
When("store front is invalid", func() {
It("returns error", func() {
_, err := as.Search(SearchInput{
Account: Account{
StoreFront: "xyz",
},
})
Expect(err).To(HaveOccurred())
})
})
When("request fails", func() {
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[searchResult]{}, errors.New(""))
})
It("returns error", func() {
_, err := as.Search(SearchInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).To(HaveOccurred())
})
})
When("request returns bad status code", func() {
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[searchResult]{
StatusCode: 400,
}, nil)
})
It("returns error", func() {
_, err := as.Search(SearchInput{
Account: Account{
StoreFront: "143441",
},
})
Expect(err).To(HaveOccurred())
})
})
})
================================================
FILE: pkg/appstore/appstore_test.go
================================================
package appstore
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestAppStore(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "App Store Suite")
}
================================================
FILE: pkg/appstore/constants.go
================================================
package appstore
const (
FailureTypeInvalidCredentials = "-5000"
FailureTypePasswordTokenExpired = "2034"
FailureTypeLicenseNotFound = "9610"
FailureTypeTemporarilyUnavailable = "2059"
CustomerMessageBadLogin = "MZFinance.BadLogin.Configurator_message"
CustomerMessageAccountDisabled = "Your account is disabled."
CustomerMessageSubscriptionRequired = "Subscription Required"
iTunesAPIDomain = "itunes.apple.com"
iTunesAPIPathSearch = "/search"
iTunesAPIPathLookup = "/lookup"
PrivateInitDomain = "init." + iTunesAPIDomain
PrivateInitPath = "/bag.xml"
PrivateAppStoreAPIDomain = "buy." + iTunesAPIDomain
PrivateAppStoreAPIPathPurchase = "/WebObjects/MZFinance.woa/wa/buyProduct"
PrivateAppStoreAPIPathDownload = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct"
HTTPHeaderStoreFront = "X-Set-Apple-Store-Front"
HTTPHeaderPod = "pod"
PricingParameterAppStore = "STDQ"
PricingParameterAppleArcade = "GAME"
)
================================================
FILE: pkg/appstore/error.go
================================================
package appstore
type Error struct {
Metadata interface{}
underlyingError error
}
func (t Error) Error() string {
return t.underlyingError.Error()
}
func NewErrorWithMetadata(err error, metadata interface{}) *Error {
return &Error{
underlyingError: err,
Metadata: metadata,
}
}
================================================
FILE: pkg/appstore/storefront.go
================================================
package appstore
import (
"fmt"
"strings"
)
func countryCodeFromStoreFront(storeFront string) (string, error) {
for key, val := range storeFronts {
parts := strings.Split(storeFront, "-")
if len(parts) >= 1 && parts[0] == val {
return key, nil
}
}
return "", fmt.Errorf("country code mapping for store front (%s) was not found", storeFront)
}
var storeFronts = map[string]string{
"AE": "143481",
"AG": "143540",
"AI": "143538",
"AL": "143575",
"AM": "143524",
"AO": "143564",
"AR": "143505",
"AT": "143445",
"AU": "143460",
"AZ": "143568",
"BB": "143541",
"BD": "143490",
"BE": "143446",
"BG": "143526",
"BH": "143559",
"BM": "143542",
"BN": "143560",
"BO": "143556",
"BR": "143503",
"BS": "143539",
"BW": "143525",
"BY": "143565",
"BZ": "143555",
"CA": "143455",
"CH": "143459",
"CI": "143527",
"CL": "143483",
"CN": "143465",
"CO": "143501",
"CR": "143495",
"CY": "143557",
"CZ": "143489",
"DE": "143443",
"DK": "143458",
"DM": "143545",
"DO": "143508",
"DZ": "143563",
"EC": "143509",
"EE": "143518",
"EG": "143516",
"ES": "143454",
"FI": "143447",
"FR": "143442",
"GB": "143444",
"GD": "143546",
"GE": "143615",
"GH": "143573",
"GR": "143448",
"GT": "143504",
"GY": "143553",
"HK": "143463",
"HN": "143510",
"HR": "143494",
"HU": "143482",
"ID": "143476",
"IE": "143449",
"IL": "143491",
"IN": "143467",
"IS": "143558",
"IT": "143450",
"IQ": "143617",
"JM": "143511",
"JO": "143528",
"JP": "143462",
"KE": "143529",
"KN": "143548",
"KR": "143466",
"KW": "143493",
"KY": "143544",
"KZ": "143517",
"LB": "143497",
"LC": "143549",
"LI": "143522",
"LK": "143486",
"LT": "143520",
"LU": "143451",
"LV": "143519",
"MD": "143523",
"MG": "143531",
"MK": "143530",
"ML": "143532",
"MN": "143592",
"MO": "143515",
"MS": "143547",
"MT": "143521",
"MU": "143533",
"MV": "143488",
"MX": "143468",
"MY": "143473",
"NE": "143534",
"NG": "143561",
"NI": "143512",
"NL": "143452",
"NO": "143457",
"NP": "143484",
"NZ": "143461",
"OM": "143562",
"PA": "143485",
"PE": "143507",
"PH": "143474",
"PK": "143477",
"PL": "143478",
"PT": "143453",
"PY": "143513",
"QA": "143498",
"RO": "143487",
"RS": "143500",
"RU": "143469",
"SA": "143479",
"SE": "143456",
"SG": "143464",
"SI": "143499",
"SK": "143496",
"SN": "143535",
"SR": "143554",
"SV": "143506",
"TC": "143552",
"TH": "143475",
"TN": "143536",
"TR": "143480",
"TT": "143551",
"TW": "143470",
"TZ": "143572",
"UA": "143492",
"UG": "143537",
"US": "143441",
"UY": "143514",
"UZ": "143566",
"VC": "143550",
"VE": "143502",
"VG": "143543",
"VN": "143471",
"YE": "143571",
"ZA": "143472",
}
================================================
FILE: pkg/http/client.go
================================================
package http
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"howett.net/plist"
)
const (
appStoreAuthURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate"
)
var (
documentXMLPattern = regexp.MustCompile(`(?is)<Document\b[^>]*>(.*)</Document>`)
plistXMLPattern = regexp.MustCompile(`(?is)<plist\b[^>]*>.*?</plist>`)
dictXMLPattern = regexp.MustCompile(`(?is)<dict\b[^>]*>.*</dict>`)
)
//go:generate go run go.uber.org/mock/mockgen -source=client.go -destination=client_mock.go -package=http
type Client[R interface{}] interface {
Send(request Request) (Result[R], error)
Do(req *http.Request) (*http.Response, error)
NewRequest(method, url string, body io.Reader) (*http.Request, error)
}
type client[R interface{}] struct {
internalClient http.Client
cookieJar CookieJar
}
type Args struct {
CookieJar CookieJar
}
type AddHeaderTransport struct {
T http.RoundTripper
}
func (t *AddHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", DefaultUserAgent)
}
res, err := t.T.RoundTrip(req)
if err != nil {
return nil, fmt.Errorf("failed to make round trip: %w", err)
}
return res, nil
}
func NewClient[R interface{}](args Args) Client[R] {
return &client[R]{
internalClient: http.Client{
Timeout: 0,
Jar: args.CookieJar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if req.Referer() == appStoreAuthURL {
return http.ErrUseLastResponse
}
return nil
},
Transport: &AddHeaderTransport{http.DefaultTransport},
},
cookieJar: args.CookieJar,
}
}
func (c *client[R]) Send(req Request) (Result[R], error) {
var (
data []byte
err error
)
if req.Payload != nil {
data, err = req.Payload.data()
if err != nil {
return Result[R]{}, fmt.Errorf("failed to get payload data: %w", err)
}
}
request, err := http.NewRequest(req.Method, req.URL, bytes.NewReader(data))
if err != nil {
return Result[R]{}, fmt.Errorf("failed to create request: %w", err)
}
for key, val := range req.Headers {
request.Header.Set(key, val)
}
res, err := c.internalClient.Do(request)
if err != nil {
return Result[R]{}, fmt.Errorf("request failed: %w", err)
}
defer res.Body.Close()
err = c.cookieJar.Save()
if err != nil {
return Result[R]{}, fmt.Errorf("failed to save cookies: %w", err)
}
if req.ResponseFormat == ResponseFormatJSON {
return c.handleJSONResponse(res)
}
if req.ResponseFormat == ResponseFormatXML {
return c.handleXMLResponse(res)
}
return Result[R]{}, fmt.Errorf("content type is not supported (%s)", req.ResponseFormat)
}
func (c *client[R]) Do(req *http.Request) (*http.Response, error) {
res, err := c.internalClient.Do(req)
if err != nil {
return nil, fmt.Errorf("received error: %w", err)
}
return res, nil
}
func (*client[R]) NewRequest(method, url string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
return req, nil
}
func (c *client[R]) handleJSONResponse(res *http.Response) (Result[R], error) {
body, err := io.ReadAll(res.Body)
if err != nil {
return Result[R]{}, fmt.Errorf("failed to read response body: %w", err)
}
var data R
err = json.Unmarshal(body, &data)
if err != nil {
return Result[R]{}, fmt.Errorf("failed to unmarshal json: %w", err)
}
return Result[R]{
StatusCode: res.StatusCode,
Data: data,
}, nil
}
func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], error) {
body, err := io.ReadAll(res.Body)
if err != nil {
return Result[R]{}, fmt.Errorf("failed to read response body: %w", err)
}
var data R
normalizedBody := normalizeXMLPlistBody(body)
_, err = plist.Unmarshal(normalizedBody, &data)
if err != nil {
return Result[R]{}, fmt.Errorf("failed to unmarshal xml: %w", err)
}
headers := map[string]string{}
for key, val := range res.Header {
headers[key] = strings.Join(val, "; ")
}
return Result[R]{
StatusCode: res.StatusCode,
Headers: headers,
Data: data,
}, nil
}
func normalizeXMLPlistBody(body []byte) []byte {
normalized := bytes.TrimSpace(body)
if len(normalized) == 0 {
return normalized
}
if documentBody := extractDocumentInnerBody(normalized); len(documentBody) > 0 {
normalized = documentBody
}
if embeddedPlist := extractEmbeddedPlist(normalized); len(embeddedPlist) > 0 {
normalized = embeddedPlist
}
if dictBody := extractEmbeddedDict(normalized); len(dictBody) > 0 {
return dictBody
}
if bytes.Contains(normalized, []byte("<key>")) {
return []byte("<dict>" + string(normalized) + "</dict>")
}
return normalized
}
func extractEmbeddedPlist(body []byte) []byte {
plistMatch := plistXMLPattern.Find(body)
if len(plistMatch) == 0 {
return nil
}
return bytes.TrimSpace(plistMatch)
}
func extractEmbeddedDict(body []byte) []byte {
dictMatch := dictXMLPattern.Find(body)
if len(dictMatch) == 0 {
return nil
}
return bytes.TrimSpace(dictMatch)
}
func extractDocumentInnerBody(body []byte) []byte {
documentMatch := documentXMLPattern.FindSubmatch(body)
if len(documentMatch) < 2 {
return nil
}
documentBody := bytes.TrimSpace(documentMatch[1])
if len(documentBody) == 0 {
return nil
}
return documentBody
}
================================================
FILE: pkg/http/client_test.go
================================================
package http
import (
"errors"
"net/http"
"net/http/httptest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
)
var _ = Describe("Client", Ordered, func() {
type jsonResult struct {
Foo string `json:"foo"`
}
type xmlResult struct {
Foo string `plist:"foo"`
}
var (
ctrl *gomock.Controller
srv *httptest.Server
mockHandler func(w http.ResponseWriter, r *http.Request)
mockCookieJar *MockCookieJar
)
BeforeAll(func() {
ctrl = gomock.NewController(GinkgoT())
mockCookieJar = NewMockCookieJar(ctrl)
mockHandler = func(w http.ResponseWriter, r *http.Request) {}
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mockHandler(w, r)
}))
})
BeforeEach(func() {
mockCookieJar.EXPECT().
Cookies(gomock.Any()).
Return(nil).
MaxTimes(1)
})
It("returns request", func() {
sut := NewClient[xmlResult](Args{})
req, err := sut.NewRequest("GET", srv.URL, nil)
Expect(err).ToNot(HaveOccurred())
Expect(req).ToNot(BeNil())
})
It("returns response", func() {
mockHandler = func(_w http.ResponseWriter, r *http.Request) {
defer GinkgoRecover()
Expect(r.Header.Get("User-Agent")).To(Equal(DefaultUserAgent))
}
sut := NewClient[xmlResult](Args{})
req, err := sut.NewRequest("GET", srv.URL, nil)
Expect(err).ToNot(HaveOccurred())
Expect(req).ToNot(BeNil())
res, err := sut.Do(req)
Expect(err).ToNot(HaveOccurred())
Expect(res).ToNot(BeNil())
})
When("payload decodes successfully", func() {
When("cookie jar fails to save", func() {
BeforeEach(func() {
mockCookieJar.EXPECT().
Save().
Return(errors.New(""))
})
It("returns error", func() {
sut := NewClient[jsonResult](Args{
CookieJar: mockCookieJar,
})
_, err := sut.Send(Request{
URL: srv.URL,
Method: MethodGET,
})
Expect(err).To(HaveOccurred())
})
})
When("cookie jar saves new cookies", func() {
BeforeEach(func() {
mockCookieJar.EXPECT().
Save().
Return(nil)
})
It("decodes JSON response", func() {
mockHandler = func(w http.ResponseWriter, _r *http.Request) {
w.Header().Add("Content-Type", "application/json")
_, err := w.Write([]byte("{\"foo\":\"bar\"}"))
Expect(err).ToNot(HaveOccurred())
}
sut := NewClient[jsonResult](Args{
CookieJar: mockCookieJar,
})
res, err := sut.Send(Request{
URL: srv.URL,
Method: MethodGET,
ResponseFormat: ResponseFormatJSON,
Headers: map[string]string{
"foo": "bar",
},
Payload: &URLPayload{
Content: map[string]interface{}{
"data": "test",
},
},
})
Expect(err).ToNot(HaveOccurred())
Expect(res.Data.Foo).To(Equal("bar"))
})
It("decodes XML response", func() {
mockHandler = func(w http.ResponseWriter, _r *http.Request) {
w.Header().Add("Content-Type", "application/xml")
_, err := w.Write([]byte("<dict><key>foo</key><string>bar</string></dict>"))
Expect(err).ToNot(HaveOccurred())
}
sut := NewClient[xmlResult](Args{
CookieJar: mockCookieJar,
})
res, err := sut.Send(Request{
URL: srv.URL,
Method: MethodPOST,
ResponseFormat: ResponseFormatXML,
})
Expect(err).ToNot(HaveOccurred())
Expect(res.Data.Foo).To(Equal("bar"))
})
It("decodes XML response wrapped in an Apple Document envelope", func() {
mockHandler = func(w http.ResponseWriter, _r *http.Request) {
w.Header().Add("Content-Type", "application/xml")
_, err := w.Write([]byte("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<Document xmlns=\"http://www.apple.com/itms/\"><key>foo</key><string>bar</string></Document>"))
Expect(err).ToNot(HaveOccurred())
}
sut := NewClient[xmlResult](Args{
CookieJar: mockCookieJar,
})
res, err := sut.Send(Request{
URL: srv.URL,
Method: MethodPOST,
ResponseFormat: ResponseFormatXML,
})
Expect(err).ToNot(HaveOccurred())
Expect(res.Data.Foo).To(Equal("bar"))
})
It("decodes XML response wrapped in Document/Protocol/plist with nested dict", func() {
mockHandler = func(w http.ResponseWriter, _r *http.Request) {
w.Header().Add("Content-Type", "application/xml")
_, err := w.Write([]byte("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<Document xmlns=\"http://www.apple.com/itms/\"><Protocol><plist version=\"1.0\"><dict><key>nested</key><dict><key>a</key><string>b</string></dict><key>foo</key><string>bar</string></dict></plist></Protocol></Document>"))
Expect(err).ToNot(HaveOccurred())
}
sut := NewClient[xmlResult](Args{
CookieJar: mockCookieJar,
})
res, err := sut.Send(Request{
URL: srv.URL,
Method: MethodPOST,
ResponseFormat: ResponseFormatXML,
})
Expect(err).ToNot(HaveOccurred())
Expect(res.Data.Foo).To(Equal("bar"))
})
It("returns error when content type is not supported", func() {
mockHandler = func(w http.ResponseWriter, _r *http.Request) {
w.Header().Add("Content-Type", "application/xyz")
}
sut := NewClient[xmlResult](Args{
CookieJar: mockCookieJar,
})
_, err := sut.Send(Request{
URL: srv.URL,
Method: MethodPOST,
ResponseFormat: "random",
})
Expect(err).To(HaveOccurred())
})
})
})
When("payload fails to decode", func() {
It("returns error", func() {
sut := NewClient[xmlResult](Args{
CookieJar: mockCookieJar,
})
_, err := sut.Send(Request{
URL: srv.URL,
Method: MethodPOST,
ResponseFormat: ResponseFormatXML,
Payload: &URLPayload{
Content: map[string]interface{}{
"data": func() {},
},
},
})
Expect(err).To(HaveOccurred())
})
})
})
================================================
FILE: pkg/http/constants.go
================================================
package http
type ResponseFormat string
const (
ResponseFormatJSON ResponseFormat = "json"
ResponseFormatXML ResponseFormat = "xml"
)
const (
DefaultUserAgent = "Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) AppleWebKit/0620.1.16.11.6"
)
================================================
FILE: pkg/http/cookiejar.go
================================================
package http
import "net/http"
//go:generate go run go.uber.org/mock/mockgen -source=cookiejar.go -destination=cookiejar_mock.go -package=http
type CookieJar interface {
http.CookieJar
Save() error
}
================================================
FILE: pkg/http/http_test.go
================================================
package http
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestHTTP(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "HTTP Suite")
}
================================================
FILE: pkg/http/method.go
================================================
package http
const (
MethodGET = "GET"
MethodPOST = "POST"
)
================================================
FILE: pkg/http/payload.go
================================================
package http
import (
"bytes"
"fmt"
"net/url"
"strconv"
"howett.net/plist"
)
type Payload interface {
data() ([]byte, error)
}
type XMLPayload struct {
Content map[string]interface{}
}
type URLPayload struct {
Content map[string]interface{}
}
func (p *XMLPayload) data() ([]byte, error) {
buffer := new(bytes.Buffer)
err := plist.NewEncoder(buffer).Encode(p.Content)
if err != nil {
return nil, fmt.Errorf("failed to encode plist: %w", err)
}
return buffer.Bytes(), nil
}
func (p *URLPayload) data() ([]byte, error) {
params := url.Values{}
for key, val := range p.Content {
switch t := val.(type) {
case string:
params.Add(key, val.(string))
case int:
params.Add(key, strconv.Itoa(val.(int)))
default:
return nil, fmt.Errorf("value type is not supported (%s)", t)
}
}
return []byte(params.Encode()), nil
}
================================================
FILE: pkg/http/payload_test.go
================================================
package http
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Payload", func() {
var sut Payload
Context("URL Payload", func() {
It("returns encoded URL data", func() {
sut = &URLPayload{
Content: map[string]interface{}{
"foo": "bar",
"num": 3,
},
}
data, err := sut.data()
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]byte("foo=bar&num=3")))
})
It("returns error if URL data is invalid", func() {
sut = &URLPayload{
Content: map[string]interface{}{
"foo": func() {},
},
}
data, err := sut.data()
Expect(err).To(HaveOccurred())
Expect(data).To(BeNil())
})
})
Context("XML Payload", func() {
It("returns encoded XML data", func() {
sut = &XMLPayload{
Content: map[string]interface{}{
"foo": "bar",
"lorem": "ipsum",
},
}
data, err := sut.data()
Expect(err).ToNot(HaveOccurred())
Expect(data).To(ContainSubstring("<dict><key>foo</key><string>bar</string><key>lorem</key><string>ipsum</string></dict>"))
})
It("returns error if XML data is invalid", func() {
sut = &XMLPayload{
Content: map[string]interface{}{
"foo": func() {},
},
}
data, err := sut.data()
Expect(err).To(HaveOccurred())
Expect(data).To(BeNil())
})
})
})
================================================
FILE: pkg/http/request.go
================================================
package http
type Request struct {
Method string
URL string
Headers map[string]string
Payload Payload
ResponseFormat ResponseFormat
}
================================================
FILE: pkg/http/result.go
================================================
package http
import (
"errors"
"strings"
)
var (
ErrHeaderNotFound = errors.New("header not found")
)
type Result[R interface{}] struct {
StatusCode int
Headers map[string]string
Data R
}
func (c *Result[R]) GetHeader(key string) (string, error) {
key = strings.ToLower(key)
for k, v := range c.Headers {
if strings.ToLower(k) == key {
return v, nil
}
}
return "", ErrHeaderNotFound
}
================================================
FILE: pkg/keychain/keychain.go
================================================
package keychain
//go:generate go run go.uber.org/mock/mockgen -source=keychain.go -destination=keychain_mock.go -package keychain
type Keychain interface {
Get(key string) ([]byte, error)
Set(key string, data []byte) error
Remove(key string) error
}
type keychain struct {
keyring Keyring
}
type Args struct {
Keyring Keyring
}
func New(args Args) Keychain {
return &keychain{
keyring: args.Keyring,
}
}
================================================
FILE: pkg/keychain/keychain_get.go
================================================
package keychain
import (
"fmt"
)
func (k *keychain) Get(key string) ([]byte, error) {
item, err := k.keyring.Get(key)
if err != nil {
return nil, fmt.Errorf("failed to get item: %w", err)
}
return item.Data, nil
}
================================================
FILE: pkg/keychain/keychain_get_test.go
================================================
package keychain
import (
"errors"
"github.com/99designs/keyring"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
)
var _ = Describe("Keychain (Get)", func() {
var (
ctrl *gomock.Controller
keychain Keychain
mockKeyring *MockKeyring
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockKeyring = NewMockKeyring(ctrl)
keychain = New(Args{
Keyring: mockKeyring,
})
})
AfterEach(func() {
ctrl.Finish()
})
When("keyring returns error", func() {
const testKey = "test-key"
BeforeEach(func() {
mockKeyring.EXPECT().
Get(testKey).
Return(keyring.Item{}, errors.New(""))
})
It("returns wrapped error", func() {
data, err := keychain.Get(testKey)
Expect(err).To(HaveOccurred())
Expect(data).To(BeNil())
})
})
When("keyring returns item", func() {
const testKey = "test-key"
var testData = []byte("test")
BeforeEach(func() {
mockKeyring.EXPECT().
Get(testKey).
Return(keyring.Item{
Data: testData,
}, nil)
})
It("returns data", func() {
data, err := keychain.Get(testKey)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal(testData))
})
})
})
================================================
FILE: pkg/keychain/keychain_remove.go
================================================
package keychain
import (
"fmt"
)
func (k *keychain) Remove(key string) error {
err := k.keyring.Remove(key)
if err != nil {
return fmt.Errorf("failed to remove item: %w", err)
}
return nil
}
================================================
FILE: pkg/keychain/keychain_remove_test.go
================================================
package keychain
import (
"errors"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
)
var _ = Describe("Keychain (Remove)", func() {
var (
ctrl *gomock.Controller
keychain Keychain
mockKeyring *MockKeyring
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockKeyring = NewMockKeyring(ctrl)
keychain = New(Args{
Keyring: mockKeyring,
})
})
AfterEach(func() {
ctrl.Finish()
})
When("keyring returns error", func() {
const testKey = "test-key"
BeforeEach(func() {
mockKeyring.EXPECT().
Remove(testKey).
Return(errors.New(""))
})
It("returns wrapped error", func() {
err := keychain.Remove(testKey)
Expect(err).To(HaveOccurred())
})
})
When("keyring does not return error", func() {
const testKey = "test-key"
BeforeEach(func() {
mockKeyring.EXPECT().
Remove(testKey).
Return(nil)
})
It("returns data", func() {
err := keychain.Remove(testKey)
Expect(err).ToNot(HaveOccurred())
})
})
})
================================================
FILE: pkg/keychain/keychain_set.go
================================================
package keychain
import (
"fmt"
"github.com/99designs/keyring"
)
func (k *keychain) Set(key string, data []byte) error {
err := k.keyring.Set(keyring.Item{
Key: key,
Data: data,
})
if err != nil {
return fmt.Errorf("failed to set item: %w", err)
}
return nil
}
================================================
FILE: pkg/keychain/keychain_set_test.go
================================================
package keychain
import (
"errors"
"github.com/99designs/keyring"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
)
var _ = Describe("Keychain (Set)", func() {
var (
ctrl *gomock.Controller
keychain Keychain
mockKeyring *MockKeyring
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockKeyring = NewMockKeyring(ctrl)
keychain = New(Args{
Keyring: mockKeyring,
})
})
AfterEach(func() {
ctrl.Finish()
})
When("keyring returns error", func() {
const testKey = "test-key"
var testData = []byte("test")
BeforeEach(func() {
mockKeyring.EXPECT().
Set(keyring.Item{
Key: testKey,
Data: testData,
}).
Return(errors.New(""))
})
It("returns wrapped error", func() {
err := keychain.Set(testKey, testData)
Expect(err).To(HaveOccurred())
})
})
When("keyring does not return error", func() {
const testKey = "test-key"
var testData = []byte("test")
BeforeEach(func() {
mockKeyring.EXPECT().
Set(keyring.Item{
Key: testKey,
Data: testData,
}).
Return(nil)
})
It("returns nil", func() {
err := keychain.Set(testKey, testData)
Expect(err).ToNot(HaveOccurred())
})
})
})
================================================
FILE: pkg/keychain/keychain_test.go
================================================
package keychain
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestKeychain(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Keychain Suite")
}
================================================
FILE: pkg/keychain/keyring.go
================================================
package keychain
import "github.com/99designs/keyring"
//go:generate go run go.uber.org/mock/mockgen -source=keyring.go -destination=keyring_mock.go -package keychain
type Keyring interface {
Get(key string) (keyring.Item, error)
Set(item keyring.Item) error
Remove(key string) error
}
================================================
FILE: pkg/log/log_test.go
================================================
package log
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestLog(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Log Suite")
}
================================================
FILE: pkg/log/logger.go
================================================
package log
import (
"io"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/rs/zerolog/pkgerrors"
)
//go:generate go run go.uber.org/mock/mockgen -source=logger.go -destination=logger_mock.go -package log
type Logger interface {
Verbose() *zerolog.Event
Log() *zerolog.Event
Error() *zerolog.Event
}
type logger struct {
internalLogger zerolog.Logger
verbose bool
}
type Args struct {
Verbose bool
Writer io.Writer
}
func NewLogger(args Args) Logger {
internalLogger := log.Logger
level := zerolog.InfoLevel
if args.Verbose {
level = zerolog.DebugLevel
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
}
internalLogger = internalLogger.Output(args.Writer).Level(level)
return &logger{
verbose: args.Verbose,
internalLogger: internalLogger,
}
}
func (l *logger) Log() *zerolog.Event {
return l.internalLogger.Info()
}
func (l *logger) Verbose() *zerolog.Event {
if !l.verbose {
return nil
}
return l.internalLogger.Debug()
}
func (l *logger) Error() *zerolog.Event {
return l.internalLogger.Error()
}
================================================
FILE: pkg/log/logger_test.go
================================================
package log
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/rs/zerolog"
"go.uber.org/mock/gomock"
)
var _ = Describe("Logger", func() {
var (
ctrl *gomock.Controller
mockWriter *MockWriter
logger Logger
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockWriter = NewMockWriter(ctrl)
})
Context("Verbose logger", func() {
BeforeEach(func() {
logger = NewLogger(Args{
Verbose: true,
Writer: mockWriter,
})
})
When("logging with verbose level", func() {
It("writes output", func() {
mockWriter.EXPECT().
WriteLevel(zerolog.DebugLevel, gomock.Any()).
Do(func(level zerolog.Level, p []byte) {
Expect(p).To(ContainSubstring("\"message\":\"verbose\""))
}).
Return(0, nil)
logger.Verbose().Msg("verbose")
})
})
})
Context("Non-verbose logger", func() {
BeforeEach(func() {
logger = NewLogger(Args{
Verbose: false,
Writer: mockWriter,
})
})
When("logging messsage", func() {
It("writes output", func() {
mockWriter.EXPECT().
WriteLevel(zerolog.InfoLevel, gomock.Any()).
Do(func(level zerolog.Level, p []byte) {
Expect(p).To(ContainSubstring("\"message\":\"info\""))
}).
Return(0, nil)
logger.Log().Msg("info")
})
})
When("logging error", func() {
It("writes output", func() {
mockWriter.EXPECT().
WriteLevel(zerolog.ErrorLevel, gomock.Any()).
Do(func(level zerolog.Level, p []byte) {
Expect(p).To(ContainSubstring("\"message\":\"error\""))
}).
Return(0, nil)
logger.Error().Msg("error")
})
})
When("logging with verbose level", func() {
It("returns nil", func() {
res := logger.Verbose()
Expect(res).To(BeNil())
})
})
})
})
================================================
FILE: pkg/log/writer.go
================================================
package log
import (
"fmt"
"io"
"os"
"github.com/rs/zerolog"
)
//go:generate go run go.uber.org/mock/mockgen -source=writer.go -destination=writer_mock.go -package log
type Writer interface {
Write(p []byte) (n int, err error)
WriteLevel(level zerolog.Level, p []byte) (n int, err error)
}
type writer struct {
stdOutWriter io.Writer
stdErrWriter io.Writer
}
func NewWriter() Writer {
return &writer{
stdOutWriter: zerolog.ConsoleWriter{Out: os.Stdout},
stdErrWriter: zerolog.ConsoleWriter{Out: os.Stderr},
}
}
func (l *writer) Write(p []byte) (int, error) {
n, err := l.stdOutWriter.Write(p)
if err != nil {
return 0, fmt.Errorf("failed to write data: %w", err)
}
return n, nil
}
func (l *writer) WriteLevel(level zerolog.Level, p []byte) (int, error) {
switch level {
case zerolog.DebugLevel, zerolog.InfoLevel, zerolog.WarnLevel:
n, err := l.stdOutWriter.Write(p)
if err != nil {
return 0, fmt.Errorf("failed to write data: %w", err)
}
return n, nil
case zerolog.ErrorLevel:
n, err := l.stdErrWriter.Write(p)
if err != nil {
return 0, fmt.Errorf("failed to write data: %w", err)
}
return n, nil
default:
return len(p), nil
}
}
================================================
FILE: pkg/log/writer_test.go
================================================
package log
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/rs/zerolog"
"go.uber.org/mock/gomock"
)
var _ = Describe("Writer", func() {
var (
ctrl *gomock.Controller
mockStdoutWriter *MockWriter
mockStderrWriter *MockWriter
sut *writer
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockStdoutWriter = NewMockWriter(ctrl)
mockStderrWriter = NewMockWriter(ctrl)
sut = &writer{
stdOutWriter: mockStdoutWriter,
stdErrWriter: mockStderrWriter,
}
})
It("returns valid writer", func() {
out := NewWriter()
Expect(out).ToNot(BeNil())
})
When("writing logs", func() {
It("writes debug logs to stdout", func() {
mockStdoutWriter.EXPECT().Write([]byte("debug")).Return(0, nil)
_, err := sut.WriteLevel(zerolog.DebugLevel, []byte("debug"))
Expect(err).ToNot(HaveOccurred())
})
It("writes info logs to stdout", func() {
mockStdoutWriter.EXPECT().Write([]byte("info")).Return(0, nil).Times(2)
_, err := sut.Write([]byte("info"))
Expect(err).ToNot(HaveOccurred())
_, err = sut.WriteLevel(zerolog.InfoLevel, []byte("info"))
Expect(err).ToNot(HaveOccurred())
})
It("writes warn logs to stdout", func() {
mockStdoutWriter.EXPECT().Write([]byte("warning")).Return(0, nil)
_, err := sut.WriteLevel(zerolog.WarnLevel, []byte("warning"))
Expect(err).ToNot(HaveOccurred())
})
It("writes error logs to stderr", func() {
mockStderrWriter.EXPECT().Write([]byte("error")).Return(0, nil)
_, err := sut.WriteLevel(zerolog.ErrorLevel, []byte("error"))
Expect(err).ToNot(HaveOccurred())
})
})
When("log level is not supported", func() {
It("returns the length of the passed log", func() {
length, err := sut.WriteLevel(zerolog.PanicLevel, []byte("panic"))
Expect(err).ToNot(HaveOccurred())
Expect(length).To(Equal(5))
})
})
})
================================================
FILE: pkg/util/machine/machine.go
================================================
package machine
import (
"fmt"
"net"
"path/filepath"
"runtime"
"github.com/majd/ipatool/v2/pkg/util/operatingsystem"
"golang.org/x/term"
)
//go:generate go run go.uber.org/mock/mockgen -source=machine.go -destination=machine_mock.go -package machine
type Machine interface {
MacAddress() (string, error)
HomeDirectory() string
ReadPassword(fd int) ([]byte, error)
}
type machine struct {
os operatingsystem.OperatingSystem
}
type Args struct {
OS operatingsystem.OperatingSystem
}
func New(args Args) Machine {
return &machine{
os: args.OS,
}
}
func (*machine) MacAddress() (string, error) {
interfaces, err := net.Interfaces()
if err != nil {
return "", fmt.Errorf("failed to get network interfaces: %w", err)
}
if len(interfaces) == 0 {
return "", fmt.Errorf("could not find network interfaces: %w", err)
}
for _, netInterface := range interfaces {
addr := netInterface.HardwareAddr.String()
if addr != "" {
return addr, nil
}
}
return "", fmt.Errorf("could not find network interfaces with a valid mac address: %w", err)
}
func (m *machine) HomeDirectory() string {
if runtime.GOOS == "windows" {
return filepath.Join(m.os.Getenv("HOMEDRIVE"), m.os.Getenv("HOMEPATH"))
}
return m.os.Getenv("HOME")
}
func (*machine) ReadPassword(fd int) ([]byte, error) {
data, err := term.ReadPassword(fd)
if err != nil {
return nil, fmt.Errorf("failed to read password: %w", err)
}
return data, nil
}
================================================
FILE: pkg/util/machine/machine_test.go
================================================
package machine
import (
"syscall"
"testing"
"github.com/majd/ipatool/v2/pkg/util/operatingsystem"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
)
func TestMachine(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Machine Suite")
}
var _ = Describe("Machine", func() {
var (
ctrl *gomock.Controller
machine Machine
mockOS *operatingsystem.MockOperatingSystem
)
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
mockOS = operatingsystem.NewMockOperatingSystem(ctrl)
machine = New(Args{
OS: mockOS,
})
})
When("OperatingSystem is darwin", func() {
BeforeEach(func() {
mockOS.EXPECT().
Getenv("HOME").
Return("/home/test")
})
It("returns home directory from HOME", func() {
dir := machine.HomeDirectory()
Expect(dir).To(Equal("/home/test"))
})
})
When("machine has network interfaces", func() {
It("returns MAC address of the first interface", func() {
res, err := machine.MacAddress()
Expect(err).ToNot(HaveOccurred())
Expect(res).To(ContainSubstring(":"))
})
})
When("reading password from stdout", func() {
It("returns error", func() {
_, err := machine.ReadPassword(syscall.Stdout)
Expect(err).To(HaveOccurred())
})
})
})
================================================
FILE: pkg/util/must.go
================================================
package util
// Must is a helper that wraps a call to a function returning (T, error) and panics if the error is non-nil.
func Must[T any](val T, err error) T {
if err != nil {
panic(err.Error())
}
return val
}
================================================
FILE: pkg/util/must_test.go
================================================
package util
import (
"errors"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Must", func() {
It("returns current value", func() {
res := Must("value", nil)
Expect(res).To(Equal("value"))
})
It("panics", func() {
defer func() {
r := recover()
Expect(r).To(Equal("test"))
}()
_ = Must("value", errors.New("test"))
})
})
================================================
FILE: pkg/util/operatingsystem/operatingsystem.go
================================================
package operatingsystem
import (
"os"
)
//go:generate go run go.uber.org/mock/mockgen -source=operatingsystem.go -destination=operatingsystem_mock.go -package operatingsystem
type OperatingSystem interface {
Getenv(key string) string
Stat(name string) (os.FileInfo, error)
Getwd() (string, error)
OpenFile(name string, flag int, perm os.FileMode) (*os.File, error)
Remove(name string) error
IsNotExist(err error) bool
MkdirAll(path string, perm os.FileMode) error
Rename(oldPath, newPath string) erro
gitextract_bcs5uo8x/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.yaml │ │ └── feature-request.yaml │ └── workflows/ │ ├── dry-build.yml │ ├── integration-tests.yml │ ├── lint.yml │ ├── release.yml │ └── unit-tests.yml ├── .gitignore ├── .golangci.yml ├── AGENTS.md ├── LICENSE ├── README.md ├── cmd/ │ ├── auth.go │ ├── common.go │ ├── constants.go │ ├── download.go │ ├── get_version_metadata.go │ ├── list_versions.go │ ├── output_format.go │ ├── purchase.go │ ├── root.go │ └── search.go ├── go.mod ├── go.sum ├── main.go ├── pkg/ │ ├── appstore/ │ │ ├── account.go │ │ ├── app.go │ │ ├── app_test.go │ │ ├── appstore.go │ │ ├── appstore_account_info.go │ │ ├── appstore_account_info_test.go │ │ ├── appstore_bag.go │ │ ├── appstore_bag_test.go │ │ ├── appstore_download.go │ │ ├── appstore_download_test.go │ │ ├── appstore_get_version_metadata.go │ │ ├── appstore_get_version_metadata_test.go │ │ ├── appstore_list_versions.go │ │ ├── appstore_list_versions_test.go │ │ ├── appstore_login.go │ │ ├── appstore_login_test.go │ │ ├── appstore_lookup.go │ │ ├── appstore_lookup_test.go │ │ ├── appstore_purchase.go │ │ ├── appstore_purchase_test.go │ │ ├── appstore_replicate_sinf.go │ │ ├── appstore_replicate_sinf_test.go │ │ ├── appstore_revoke.go │ │ ├── appstore_revoke_test.go │ │ ├── appstore_search.go │ │ ├── appstore_search_test.go │ │ ├── appstore_test.go │ │ ├── constants.go │ │ ├── error.go │ │ └── storefront.go │ ├── http/ │ │ ├── client.go │ │ ├── client_test.go │ │ ├── constants.go │ │ ├── cookiejar.go │ │ ├── http_test.go │ │ ├── method.go │ │ ├── payload.go │ │ ├── payload_test.go │ │ ├── request.go │ │ └── result.go │ ├── keychain/ │ │ ├── keychain.go │ │ ├── keychain_get.go │ │ ├── keychain_get_test.go │ │ ├── keychain_remove.go │ │ ├── keychain_remove_test.go │ │ ├── keychain_set.go │ │ ├── keychain_set_test.go │ │ ├── keychain_test.go │ │ └── keyring.go │ ├── log/ │ │ ├── log_test.go │ │ ├── logger.go │ │ ├── logger_test.go │ │ ├── writer.go │ │ └── writer_test.go │ └── util/ │ ├── machine/ │ │ ├── machine.go │ │ └── machine_test.go │ ├── must.go │ ├── must_test.go │ ├── operatingsystem/ │ │ ├── operatingsystem.go │ │ └── operatingsystem_test.go │ ├── string.go │ ├── string_test.go │ ├── util_test.go │ ├── zip.go │ └── zip_test.go ├── tools/ │ └── sha256sum.sh └── tools.go
SYMBOL INDEX (214 symbols across 56 files)
FILE: cmd/auth.go
function authCmd (line 18) | func authCmd() *cobra.Command {
function loginCmd (line 31) | func loginCmd() *cobra.Command {
function infoCmd (line 138) | func infoCmd() *cobra.Command {
function revokeCmd (line 160) | func revokeCmd() *cobra.Command {
FILE: cmd/common.go
type Dependencies (line 28) | type Dependencies struct
function newLogger (line 38) | func newLogger(format OutputFormat, verbose bool) log.Logger {
function newCookieJar (line 56) | func newCookieJar(machine machine.Machine) http.CookieJar {
function newKeychain (line 63) | func newKeychain(machine machine.Machine, logger log.Logger, interactive...
function initWithCommand (line 100) | func initWithCommand(cmd *cobra.Command) {
function createConfigDirectory (line 121) | func createConfigDirectory(os operatingsystem.OperatingSystem, machine m...
FILE: cmd/constants.go
constant ConfigDirectoryName (line 4) | ConfigDirectoryName = ".ipatool"
constant CookieJarFileName (line 5) | CookieJarFileName = "cookies"
constant KeychainServiceName (line 6) | KeychainServiceName = "ipatool-auth.service"
FILE: cmd/download.go
function downloadCmd (line 15) | func downloadCmd() *cobra.Command {
FILE: cmd/get_version_metadata.go
function getVersionMetadataCmd (line 13) | func getVersionMetadataCmd() *cobra.Command {
FILE: cmd/list_versions.go
function ListVersionsCmd (line 13) | func ListVersionsCmd() *cobra.Command {
FILE: cmd/output_format.go
type OutputFormat (line 9) | type OutputFormat
constant OutputFormatText (line 12) | OutputFormatText OutputFormat = iota
constant OutputFormatJSON (line 13) | OutputFormatJSON
function OutputFormatFromString (line 16) | func OutputFormatFromString(value string) (OutputFormat, error) {
FILE: cmd/purchase.go
function purchaseCmd (line 13) | func purchaseCmd() *cobra.Command {
FILE: cmd/root.go
function rootCmd (line 15) | func rootCmd() *cobra.Command {
function Execute (line 55) | func Execute() int {
FILE: cmd/search.go
function searchCmd (line 9) | func searchCmd() *cobra.Command {
FILE: main.go
function main (line 9) | func main() {
FILE: pkg/appstore/account.go
type Account (line 3) | type Account struct
FILE: pkg/appstore/app.go
type App (line 7) | type App struct
method MarshalZerologObject (line 36) | func (a App) MarshalZerologObject(event *zerolog.Event) {
type VersionHistoryInfo (line 15) | type VersionHistoryInfo struct
type VersionDetails (line 21) | type VersionDetails struct
type Apps (line 28) | type Apps
method MarshalZerologArray (line 30) | func (apps Apps) MarshalZerologArray(a *zerolog.Array) {
FILE: pkg/appstore/appstore.go
type AppStore (line 10) | type AppStore interface
type appstore (line 36) | type appstore struct
type Args (line 48) | type Args struct
function NewAppStore (line 55) | func NewAppStore(args Args) AppStore {
FILE: pkg/appstore/appstore_account_info.go
type AccountInfoOutput (line 8) | type AccountInfoOutput struct
method AccountInfo (line 12) | func (t *appstore) AccountInfo() (AccountInfoOutput, error) {
FILE: pkg/appstore/appstore_bag.go
type BagInput (line 11) | type BagInput struct
type BagOutput (line 13) | type BagOutput struct
method Bag (line 17) | func (t *appstore) Bag(input BagInput) (BagOutput, error) {
type bagResult (line 40) | type bagResult struct
type urlBag (line 44) | type urlBag struct
method bagRequest (line 48) | func (*appstore) bagRequest(guid string) http.Request {
FILE: pkg/appstore/appstore_download.go
type DownloadInput (line 21) | type DownloadInput struct
type DownloadOutput (line 29) | type DownloadOutput struct
method Download (line 34) | func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) {
type downloadItemResult (line 104) | type downloadItemResult struct
type downloadResult (line 111) | type downloadResult struct
method downloadFile (line 117) | func (t *appstore) downloadFile(src, dst string, progress *progressbar.P...
method downloadRequest (line 170) | func (*appstore) downloadRequest(acc Account, app App, guid string, exte...
function fileName (line 201) | func fileName(app App, version string) string {
method resolveDestinationPath (line 219) | func (t *appstore) resolveDestinationPath(app App, version string, path ...
method isDirectory (line 243) | func (t *appstore) isDirectory(path string) (bool, error) {
method applyPatches (line 256) | func (t *appstore) applyPatches(item downloadItemResult, acc Account, sr...
method writeMetadata (line 284) | func (t *appstore) writeMetadata(metadata map[string]interface{}, acc Ac...
FILE: pkg/appstore/appstore_download_test.go
type dummyFileInfo (line 24) | type dummyFileInfo struct
method Name (line 26) | func (d *dummyFileInfo) Name() string { return "dummy" }
method Size (line 27) | func (d *dummyFileInfo) Size() int64 { return 0 }
method Mode (line 28) | func (d *dummyFileInfo) Mode() fs.FileMode { return 0 }
method ModTime (line 29) | func (d *dummyFileInfo) ModTime() time.Time { return time.Time{} }
method IsDir (line 30) | func (d *dummyFileInfo) IsDir() bool { return false }
method Sys (line 31) | func (d *dummyFileInfo) Sys() interface{} { return nil }
FILE: pkg/appstore/appstore_get_version_metadata.go
type GetVersionMetadataInput (line 12) | type GetVersionMetadataInput struct
type GetVersionMetadataOutput (line 18) | type GetVersionMetadataOutput struct
method GetVersionMetadata (line 23) | func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (Ge...
method getVersionMetadataRequest (line 71) | func (t *appstore) getVersionMetadataRequest(acc Account, app App, guid ...
FILE: pkg/appstore/appstore_list_versions.go
type ListVersionsInput (line 11) | type ListVersionsInput struct
type ListVersionsOutput (line 16) | type ListVersionsOutput struct
method ListVersions (line 21) | func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOu...
method listVersionsRequest (line 79) | func (t *appstore) listVersionsRequest(acc Account, app App, guid string...
FILE: pkg/appstore/appstore_login.go
type LoginInput (line 19) | type LoginInput struct
type LoginOutput (line 26) | type LoginOutput struct
method Login (line 30) | func (t *appstore) Login(input LoginInput) (LoginOutput, error) {
type loginAddressResult (line 48) | type loginAddressResult struct
type loginAccountResult (line 53) | type loginAccountResult struct
type loginResult (line 58) | type loginResult struct
method login (line 66) | func (t *appstore) login(email, password, authCode, guid, endpoint strin...
method parseLoginResponse (line 128) | func (t *appstore) parseLoginResponse(res *http.Result[loginResult], att...
method loginRequest (line 160) | func (t *appstore) loginRequest(email, password, authCode, guid, endpoin...
FILE: pkg/appstore/appstore_lookup.go
type LookupInput (line 12) | type LookupInput struct
type LookupOutput (line 17) | type LookupOutput struct
method Lookup (line 21) | func (t *appstore) Lookup(input LookupInput) (LookupOutput, error) {
method lookupRequest (line 47) | func (t *appstore) lookupRequest(bundleID, countryCode string) http.Requ...
method lookupURL (line 55) | func (t *appstore) lookupURL(bundleID, countryCode string) string {
FILE: pkg/appstore/appstore_purchase.go
type PurchaseInput (line 18) | type PurchaseInput struct
method Purchase (line 23) | func (t *appstore) Purchase(input PurchaseInput) error {
type purchaseResult (line 52) | type purchaseResult struct
method purchaseWithParams (line 59) | func (t *appstore) purchaseWithParams(acc Account, app App, guid string,...
method purchaseRequest (line 98) | func (t *appstore) purchaseRequest(acc Account, app App, storeFront, gui...
FILE: pkg/appstore/appstore_purchase_test.go
type pricingParametersMatcher (line 331) | type pricingParametersMatcher struct
method Matches (line 335) | func (p pricingParametersMatcher) Matches(in interface{}) bool {
method String (line 339) | func (p pricingParametersMatcher) String() string {
FILE: pkg/appstore/appstore_replicate_sinf.go
type Sinf (line 17) | type Sinf struct
type ReplicateSinfInput (line 22) | type ReplicateSinfInput struct
method ReplicateSinf (line 27) | func (t *appstore) ReplicateSinf(input ReplicateSinfInput) error {
type packageManifest (line 89) | type packageManifest struct
type packageInfo (line 93) | type packageInfo struct
method replicateSinfFromManifest (line 97) | func (*appstore) replicateSinfFromManifest(manifest packageManifest, zip...
method replicateSinfFromInfo (line 120) | func (t *appstore) replicateSinfFromInfo(info packageInfo, zip *zip.Writ...
method replicateZip (line 136) | func (t *appstore) replicateZip(src *zip.ReadCloser, dst *zip.Writer) er...
method readInfoPlist (line 159) | func (*appstore) readInfoPlist(reader *zip.ReadCloser) (*packageInfo, er...
method readManifestPlist (line 188) | func (*appstore) readManifestPlist(reader *zip.ReadCloser) (*packageMani...
method readBundleName (line 217) | func (*appstore) readBundleName(reader *zip.ReadCloser) (string, error) {
FILE: pkg/appstore/appstore_revoke.go
method Revoke (line 7) | func (t *appstore) Revoke() error {
FILE: pkg/appstore/appstore_search.go
type SearchInput (line 13) | type SearchInput struct
type SearchOutput (line 19) | type SearchOutput struct
method Search (line 24) | func (t *appstore) Search(input SearchInput) (SearchOutput, error) {
type searchResult (line 47) | type searchResult struct
method searchRequest (line 52) | func (t *appstore) searchRequest(term, countryCode string, limit int64) ...
method searchURL (line 60) | func (t *appstore) searchURL(term, countryCode string, limit int64) stri...
FILE: pkg/appstore/appstore_test.go
function TestAppStore (line 10) | func TestAppStore(t *testing.T) {
FILE: pkg/appstore/constants.go
constant FailureTypeInvalidCredentials (line 4) | FailureTypeInvalidCredentials = "-5000"
constant FailureTypePasswordTokenExpired (line 5) | FailureTypePasswordTokenExpired = "2034"
constant FailureTypeLicenseNotFound (line 6) | FailureTypeLicenseNotFound = "9610"
constant FailureTypeTemporarilyUnavailable (line 7) | FailureTypeTemporarilyUnavailable = "2059"
constant CustomerMessageBadLogin (line 9) | CustomerMessageBadLogin = "MZFinance.BadLogin.Configurator_m...
constant CustomerMessageAccountDisabled (line 10) | CustomerMessageAccountDisabled = "Your account is disabled."
constant CustomerMessageSubscriptionRequired (line 11) | CustomerMessageSubscriptionRequired = "Subscription Required"
constant iTunesAPIDomain (line 13) | iTunesAPIDomain = "itunes.apple.com"
constant iTunesAPIPathSearch (line 14) | iTunesAPIPathSearch = "/search"
constant iTunesAPIPathLookup (line 15) | iTunesAPIPathLookup = "/lookup"
constant PrivateInitDomain (line 17) | PrivateInitDomain = "init." + iTunesAPIDomain
constant PrivateInitPath (line 18) | PrivateInitPath = "/bag.xml"
constant PrivateAppStoreAPIDomain (line 20) | PrivateAppStoreAPIDomain = "buy." + iTunesAPIDomain
constant PrivateAppStoreAPIPathPurchase (line 21) | PrivateAppStoreAPIPathPurchase = "/WebObjects/MZFinance.woa/wa/buyProduct"
constant PrivateAppStoreAPIPathDownload (line 22) | PrivateAppStoreAPIPathDownload = "/WebObjects/MZFinance.woa/wa/volumeSto...
constant HTTPHeaderStoreFront (line 24) | HTTPHeaderStoreFront = "X-Set-Apple-Store-Front"
constant HTTPHeaderPod (line 25) | HTTPHeaderPod = "pod"
constant PricingParameterAppStore (line 27) | PricingParameterAppStore = "STDQ"
constant PricingParameterAppleArcade (line 28) | PricingParameterAppleArcade = "GAME"
FILE: pkg/appstore/error.go
type Error (line 3) | type Error struct
method Error (line 8) | func (t Error) Error() string {
function NewErrorWithMetadata (line 12) | func NewErrorWithMetadata(err error, metadata interface{}) *Error {
FILE: pkg/appstore/storefront.go
function countryCodeFromStoreFront (line 8) | func countryCodeFromStoreFront(storeFront string) (string, error) {
FILE: pkg/http/client.go
constant appStoreAuthURL (line 16) | appStoreAuthURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa...
type Client (line 26) | type Client interface
type client (line 32) | type client struct
type Args (line 37) | type Args struct
type AddHeaderTransport (line 41) | type AddHeaderTransport struct
method RoundTrip (line 45) | func (t *AddHeaderTransport) RoundTrip(req *http.Request) (*http.Respo...
function NewClient (line 58) | func NewClient[R interface{}](args Args) Client[R] {
method Send (line 76) | func (c *client[R]) Send(req Request) (Result[R], error) {
method Do (line 120) | func (c *client[R]) Do(req *http.Request) (*http.Response, error) {
method NewRequest (line 129) | func (*client[R]) NewRequest(method, url string, body io.Reader) (*http....
method handleJSONResponse (line 138) | func (c *client[R]) handleJSONResponse(res *http.Response) (Result[R], e...
method handleXMLResponse (line 157) | func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], er...
function normalizeXMLPlistBody (line 184) | func normalizeXMLPlistBody(body []byte) []byte {
function extractEmbeddedPlist (line 209) | func extractEmbeddedPlist(body []byte) []byte {
function extractEmbeddedDict (line 218) | func extractEmbeddedDict(body []byte) []byte {
function extractDocumentInnerBody (line 227) | func extractDocumentInnerBody(body []byte) []byte {
FILE: pkg/http/constants.go
type ResponseFormat (line 3) | type ResponseFormat
constant ResponseFormatJSON (line 6) | ResponseFormatJSON ResponseFormat = "json"
constant ResponseFormatXML (line 7) | ResponseFormatXML ResponseFormat = "xml"
constant DefaultUserAgent (line 11) | DefaultUserAgent = "Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) A...
FILE: pkg/http/cookiejar.go
type CookieJar (line 6) | type CookieJar interface
FILE: pkg/http/http_test.go
function TestHTTP (line 10) | func TestHTTP(t *testing.T) {
FILE: pkg/http/method.go
constant MethodGET (line 4) | MethodGET = "GET"
constant MethodPOST (line 5) | MethodPOST = "POST"
FILE: pkg/http/payload.go
type Payload (line 12) | type Payload interface
type XMLPayload (line 16) | type XMLPayload struct
method data (line 24) | func (p *XMLPayload) data() ([]byte, error) {
type URLPayload (line 20) | type URLPayload struct
method data (line 35) | func (p *URLPayload) data() ([]byte, error) {
FILE: pkg/http/request.go
type Request (line 3) | type Request struct
FILE: pkg/http/result.go
type Result (line 12) | type Result struct
method GetHeader (line 18) | func (c *Result[R]) GetHeader(key string) (string, error) {
FILE: pkg/keychain/keychain.go
type Keychain (line 4) | type Keychain interface
type keychain (line 10) | type keychain struct
type Args (line 14) | type Args struct
function New (line 18) | func New(args Args) Keychain {
FILE: pkg/keychain/keychain_get.go
method Get (line 7) | func (k *keychain) Get(key string) ([]byte, error) {
FILE: pkg/keychain/keychain_remove.go
method Remove (line 7) | func (k *keychain) Remove(key string) error {
FILE: pkg/keychain/keychain_set.go
method Set (line 9) | func (k *keychain) Set(key string, data []byte) error {
FILE: pkg/keychain/keychain_test.go
function TestKeychain (line 10) | func TestKeychain(t *testing.T) {
FILE: pkg/keychain/keyring.go
type Keyring (line 6) | type Keyring interface
FILE: pkg/log/log_test.go
function TestLog (line 10) | func TestLog(t *testing.T) {
FILE: pkg/log/logger.go
type Logger (line 12) | type Logger interface
type logger (line 18) | type logger struct
method Log (line 45) | func (l *logger) Log() *zerolog.Event {
method Verbose (line 49) | func (l *logger) Verbose() *zerolog.Event {
method Error (line 57) | func (l *logger) Error() *zerolog.Event {
type Args (line 23) | type Args struct
function NewLogger (line 28) | func NewLogger(args Args) Logger {
FILE: pkg/log/writer.go
type Writer (line 12) | type Writer interface
type writer (line 17) | type writer struct
method Write (line 29) | func (l *writer) Write(p []byte) (int, error) {
method WriteLevel (line 38) | func (l *writer) WriteLevel(level zerolog.Level, p []byte) (int, error) {
function NewWriter (line 22) | func NewWriter() Writer {
FILE: pkg/util/machine/machine.go
type Machine (line 14) | type Machine interface
type machine (line 20) | type machine struct
method MacAddress (line 34) | func (*machine) MacAddress() (string, error) {
method HomeDirectory (line 54) | func (m *machine) HomeDirectory() string {
method ReadPassword (line 62) | func (*machine) ReadPassword(fd int) ([]byte, error) {
type Args (line 24) | type Args struct
function New (line 28) | func New(args Args) Machine {
FILE: pkg/util/machine/machine_test.go
function TestMachine (line 13) | func TestMachine(t *testing.T) {
FILE: pkg/util/must.go
function Must (line 4) | func Must[T any](val T, err error) T {
FILE: pkg/util/operatingsystem/operatingsystem.go
type OperatingSystem (line 8) | type OperatingSystem interface
type operatingSystem (line 19) | type operatingSystem struct
method Getenv (line 25) | func (operatingSystem) Getenv(key string) string {
method Stat (line 30) | func (operatingSystem) Stat(name string) (os.FileInfo, error) {
method Getwd (line 35) | func (operatingSystem) Getwd() (string, error) {
method OpenFile (line 40) | func (operatingSystem) OpenFile(name string, flag int, perm os.FileMod...
method Remove (line 45) | func (operatingSystem) Remove(name string) error {
method IsNotExist (line 50) | func (operatingSystem) IsNotExist(err error) bool {
method MkdirAll (line 55) | func (operatingSystem) MkdirAll(path string, perm os.FileMode) error {
method Rename (line 60) | func (operatingSystem) Rename(oldPath, newPath string) error {
function New (line 21) | func New() OperatingSystem {
FILE: pkg/util/operatingsystem/operatingsystem_test.go
function TestOS (line 16) | func TestOS(t *testing.T) {
FILE: pkg/util/string.go
function IfEmpty (line 3) | func IfEmpty(value, fallback string) string {
FILE: pkg/util/util_test.go
function TestUtil (line 10) | func TestUtil(t *testing.T) {
FILE: pkg/util/zip.go
type Pair (line 5) | type Pair struct
function Zip (line 10) | func Zip[T, U any](ts []T, us []U) ([]Pair[T, U], error) {
Condensed preview — 93 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (223K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 35,
"preview": "# github: [majd]\npatreon: majd_dev\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug-report.yaml",
"chars": 661,
"preview": "name: Bug Report\ndescription: File a bug report\nlabels:\n - bug\nbody:\n - type: textarea\n id: description\n attribu"
},
{
"path": ".github/ISSUE_TEMPLATE/feature-request.yaml",
"chars": 281,
"preview": "name: Feature Request\ndescription: Submit a feature request\nlabels:\n - feature request\nbody:\n - type: textarea\n id:"
},
{
"path": ".github/workflows/dry-build.yml",
"chars": 1417,
"preview": "name: Dry Build\n\non:\n pull_request:\n branches:\n - main\n\njobs:\n build_windows:\n name: Build for Windows\n "
},
{
"path": ".github/workflows/integration-tests.yml",
"chars": 940,
"preview": "name: Integration Tests\n\non:\n pull_request:\n branches:\n - main\n\njobs:\n build:\n name: Build\n runs-on: mac"
},
{
"path": ".github/workflows/lint.yml",
"chars": 399,
"preview": "name: Lint\n\non:\n pull_request:\n branches:\n - main\n\njobs:\n lint:\n name: Lint\n runs-on: macos-latest\n s"
},
{
"path": ".github/workflows/release.yml",
"chars": 9559,
"preview": "name: Release\n\non:\n push:\n tags:\n - \"v*\"\n\njobs:\n get_version:\n name: Get version\n runs-on: ubuntu-latest"
},
{
"path": ".github/workflows/unit-tests.yml",
"chars": 383,
"preview": "name: Unit Tests\n\non:\n pull_request:\n branches:\n - main\n\njobs:\n run_tests:\n name: Run tests\n runs-on: ub"
},
{
"path": ".gitignore",
"chars": 68,
"preview": ".DS_Store\n.AppleDouble\n.LSOverride\n.vscode/\n.idea/\n**/*_mock.go\nexp/"
},
{
"path": ".golangci.yml",
"chars": 607,
"preview": "version: \"2\"\nlinters:\n enable:\n - ginkgolinter\n - godot\n - godox\n - importas\n - nlreturn\n - nonamedre"
},
{
"path": "AGENTS.md",
"chars": 1003,
"preview": "# AGENTS.md\n\nThis file provides guidance for coding agents working in this repository.\n\n## Repository overview\n- Project"
},
{
"path": "LICENSE",
"chars": 1069,
"preview": "MIT License\n\nCopyright (c) 2021 Majd Alfhaily\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
},
{
"path": "README.md",
"chars": 5944,
"preview": "# IPATool\n\n[](https://GitHub.com/majd/ip"
},
{
"path": "cmd/auth.go",
"chars": 4158,
"preview": "package cmd\n\nimport (\n\t\"bufio\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/avast/retry-go\"\n\t\"github.com/majd"
},
{
"path": "cmd/common.go",
"chars": 4195,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/99designs/keyring\"\n\tcookiej"
},
{
"path": "cmd/constants.go",
"chars": 136,
"preview": "package cmd\n\nconst (\n\tConfigDirectoryName = \".ipatool\"\n\tCookieJarFileName = \"cookies\"\n\tKeychainServiceName = \"ipatool-"
},
{
"path": "cmd/download.go",
"chars": 4059,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/avast/retry-go\"\n\t\"github.com/majd/ipatool/v2/pkg/appstore\"\n\t"
},
{
"path": "cmd/get_version_metadata.go",
"chars": 2484,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/avast/retry-go\"\n\t\"github.com/majd/ipatool/v2/pkg/appstore\"\n\t\"githu"
},
{
"path": "cmd/list_versions.go",
"chars": 2129,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/avast/retry-go\"\n\t\"github.com/majd/ipatool/v2/pkg/appstore\"\n\t\"githu"
},
{
"path": "cmd/output_format.go",
"chars": 430,
"preview": "package cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/thediveo/enumflag/v2\"\n)\n\ntype OutputFormat enumflag.Flag\n\nconst (\n\tOutputForm"
},
{
"path": "cmd/purchase.go",
"chars": 1691,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/avast/retry-go\"\n\t\"github.com/majd/ipatool/v2/pkg/appstore\"\n\t\"githu"
},
{
"path": "cmd/root.go",
"chars": 2089,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"reflect\"\n\n\t\"github.com/majd/ipatool/v2/pkg/appstore\"\n\t\"github.com/spf13/cobra\"\n\t\"githu"
},
{
"path": "cmd/search.go",
"chars": 906,
"preview": "package cmd\n\nimport (\n\t\"github.com/majd/ipatool/v2/pkg/appstore\"\n\t\"github.com/spf13/cobra\"\n)\n\n// nolint:wrapcheck\nfunc s"
},
{
"path": "go.mod",
"chars": 1852,
"preview": "module github.com/majd/ipatool/v2\n\ngo 1.23.0\n\ntoolchain go1.23.2\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.1\n\tgithub"
},
{
"path": "go.sum",
"chars": 11609,
"preview": "github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=\ngith"
},
{
"path": "main.go",
"chars": 107,
"preview": "package main\n\nimport (\n\t\"os\"\n\n\t\"github.com/majd/ipatool/v2/cmd\"\n)\n\nfunc main() {\n\tos.Exit(cmd.Execute())\n}\n"
},
{
"path": "pkg/appstore/account.go",
"chars": 448,
"preview": "package appstore\n\ntype Account struct {\n\tEmail string `json:\"email,omitempty\"`\n\tPasswordToken string"
},
{
"path": "pkg/appstore/app.go",
"chars": 860,
"preview": "package appstore\n\nimport (\n\t\"github.com/rs/zerolog\"\n)\n\ntype App struct {\n\tID int64 `json:\"trackId,omitempty\"`\n\tB"
},
{
"path": "pkg/appstore/app_test.go",
"chars": 1696,
"preview": "package appstore\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\t\"githu"
},
{
"path": "pkg/appstore/appstore.go",
"chars": 2657,
"preview": "package appstore\n\nimport (\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github.com/majd/ipatool/v2/pkg/keychain\"\n\t\"github.co"
},
{
"path": "pkg/appstore/appstore_account_info.go",
"chars": 508,
"preview": "package appstore\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n)\n\ntype AccountInfoOutput struct {\n\tAccount Account\n}\n\nfunc (t *appst"
},
{
"path": "pkg/appstore/appstore_account_info_test.go",
"chars": 1612,
"preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/majd/ipatool/v2/pkg/keychain\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t"
},
{
"path": "pkg/appstore/appstore_bag.go",
"chars": 1276,
"preview": "package appstore\n\nimport (\n\t\"fmt\"\n\tgohttp \"net/http\"\n\t\"strings\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n)\n\ntype BagInput"
},
{
"path": "pkg/appstore/appstore_bag_test.go",
"chars": 2915,
"preview": "package appstore\n\nimport (\n\t\"errors\"\n\tgohttp \"net/http\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github.com/majd/ipatoo"
},
{
"path": "pkg/appstore/appstore_download.go",
"chars": 7683,
"preview": "package appstore\n\nimport (\n\t\"archive/zip\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/majd/ipatool/"
},
{
"path": "pkg/appstore/appstore_download_test.go",
"chars": 12074,
"preview": "package appstore\n\nimport (\n\t\"archive/zip\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\tgohttp \"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t"
},
{
"path": "pkg/appstore/appstore_get_version_metadata.go",
"chars": 2761,
"preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n)\n\ntype GetVersio"
},
{
"path": "pkg/appstore/appstore_get_version_metadata_test.go",
"chars": 6811,
"preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github.com/majd/ipatool/v2/pkg/ut"
},
{
"path": "pkg/appstore/appstore_list_versions.go",
"chars": 3059,
"preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n)\n\ntype ListVersionsInput"
},
{
"path": "pkg/appstore/appstore_list_versions_test.go",
"chars": 7088,
"preview": "package appstore\n\nimport (\n\t\"errors\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github.com/majd/ipatool/v2/pkg/util/machi"
},
{
"path": "pkg/appstore/appstore_login.go",
"chars": 5137,
"preview": "package appstore\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\tgohttp \"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/majd"
},
{
"path": "pkg/appstore/appstore_login_test.go",
"chars": 8187,
"preview": "package appstore\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github"
},
{
"path": "pkg/appstore/appstore_lookup.go",
"chars": 1530,
"preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\tgohttp \"net/http\"\n\t\"net/url\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n)\n\ntyp"
},
{
"path": "pkg/appstore/appstore_lookup_test.go",
"chars": 2605,
"preview": "package appstore\n\nimport (\n\t\"errors\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.c"
},
{
"path": "pkg/appstore/appstore_purchase.go",
"chars": 3896,
"preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\tgohttp \"net/http\"\n\t\"strings\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n)\n\nvar"
},
{
"path": "pkg/appstore/appstore_purchase_test.go",
"chars": 7815,
"preview": "package appstore\n\nimport (\n\t\"errors\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github.com/majd/ipatool/v2/pkg/keychain\"\n"
},
{
"path": "pkg/appstore/appstore_replicate_sinf.go",
"chars": 5323,
"preview": "package appstore\n\nimport (\n\t\"archive/zip\"\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.co"
},
{
"path": "pkg/appstore/appstore_replicate_sinf_test.go",
"chars": 5139,
"preview": "package appstore\n\nimport (\n\t\"archive/zip\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t\"github.com/ma"
},
{
"path": "pkg/appstore/appstore_revoke.go",
"chars": 216,
"preview": "package appstore\n\nimport (\n\t\"fmt\"\n)\n\nfunc (t *appstore) Revoke() error {\n\terr := t.keychain.Remove(\"account\")\n\tif err !="
},
{
"path": "pkg/appstore/appstore_revoke_test.go",
"chars": 1039,
"preview": "package appstore\n\nimport (\n\t\"errors\"\n\n\t\"github.com/majd/ipatool/v2/pkg/keychain\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"gith"
},
{
"path": "pkg/appstore/appstore_search.go",
"chars": 1659,
"preview": "package appstore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\tgohttp \"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\n\t\"github.com/majd/ipatool/v2/pkg/h"
},
{
"path": "pkg/appstore/appstore_search_test.go",
"chars": 2468,
"preview": "package appstore\n\nimport (\n\t\"errors\"\n\n\t\"github.com/majd/ipatool/v2/pkg/http\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.c"
},
{
"path": "pkg/appstore/appstore_test.go",
"chars": 196,
"preview": "package appstore\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestAppStore(t"
},
{
"path": "pkg/appstore/constants.go",
"chars": 992,
"preview": "package appstore\n\nconst (\n\tFailureTypeInvalidCredentials = \"-5000\"\n\tFailureTypePasswordTokenExpired = \"2034\"\n\tFail"
},
{
"path": "pkg/appstore/error.go",
"chars": 304,
"preview": "package appstore\n\ntype Error struct {\n\tMetadata interface{}\n\tunderlyingError error\n}\n\nfunc (t Error) Error() stri"
},
{
"path": "pkg/appstore/storefront.go",
"chars": 2679,
"preview": "package appstore\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\nfunc countryCodeFromStoreFront(storeFront string) (string, error) {\n\tfor"
},
{
"path": "pkg/http/client.go",
"chars": 5414,
"preview": "package http\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"howett.net/plist\"\n)\n\nc"
},
{
"path": "pkg/http/client_test.go",
"chars": 5928,
"preview": "package http\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/go"
},
{
"path": "pkg/http/constants.go",
"chars": 250,
"preview": "package http\n\ntype ResponseFormat string\n\nconst (\n\tResponseFormatJSON ResponseFormat = \"json\"\n\tResponseFormatXML Respon"
},
{
"path": "pkg/http/cookiejar.go",
"chars": 205,
"preview": "package http\n\nimport \"net/http\"\n\n//go:generate go run go.uber.org/mock/mockgen -source=cookiejar.go -destination=cookiej"
},
{
"path": "pkg/http/http_test.go",
"chars": 183,
"preview": "package http\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestHTTP(t *testin"
},
{
"path": "pkg/http/method.go",
"chars": 65,
"preview": "package http\n\nconst (\n\tMethodGET = \"GET\"\n\tMethodPOST = \"POST\"\n)\n"
},
{
"path": "pkg/http/payload.go",
"chars": 856,
"preview": "package http\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"strconv\"\n\n\t\"howett.net/plist\"\n)\n\ntype Payload interface {\n\tdata() (["
},
{
"path": "pkg/http/payload_test.go",
"chars": 1327,
"preview": "package http\n\nimport (\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nvar _ = Describe(\"Payload\", func() "
},
{
"path": "pkg/http/request.go",
"chars": 173,
"preview": "package http\n\ntype Request struct {\n\tMethod string\n\tURL string\n\tHeaders map[string]string\n\tPay"
},
{
"path": "pkg/http/result.go",
"chars": 416,
"preview": "package http\n\nimport (\n\t\"errors\"\n\t\"strings\"\n)\n\nvar (\n\tErrHeaderNotFound = errors.New(\"header not found\")\n)\n\ntype Result["
},
{
"path": "pkg/keychain/keychain.go",
"chars": 418,
"preview": "package keychain\n\n//go:generate go run go.uber.org/mock/mockgen -source=keychain.go -destination=keychain_mock.go -packa"
},
{
"path": "pkg/keychain/keychain_get.go",
"chars": 225,
"preview": "package keychain\n\nimport (\n\t\"fmt\"\n)\n\nfunc (k *keychain) Get(key string) ([]byte, error) {\n\titem, err := k.keyring.Get(ke"
},
{
"path": "pkg/keychain/keychain_get_test.go",
"chars": 1211,
"preview": "package keychain\n\nimport (\n\t\"errors\"\n\n\t\"github.com/99designs/keyring\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi"
},
{
"path": "pkg/keychain/keychain_remove.go",
"chars": 202,
"preview": "package keychain\n\nimport (\n\t\"fmt\"\n)\n\nfunc (k *keychain) Remove(key string) error {\n\terr := k.keyring.Remove(key)\n\tif err"
},
{
"path": "pkg/keychain/keychain_remove_test.go",
"chars": 1037,
"preview": "package keychain\n\nimport (\n\t\"errors\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\t\"go.uber.org/mock/gomo"
},
{
"path": "pkg/keychain/keychain_set.go",
"chars": 279,
"preview": "package keychain\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/99designs/keyring\"\n)\n\nfunc (k *keychain) Set(key string, data []byte) er"
},
{
"path": "pkg/keychain/keychain_set_test.go",
"chars": 1243,
"preview": "package keychain\n\nimport (\n\t\"errors\"\n\n\t\"github.com/99designs/keyring\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi"
},
{
"path": "pkg/keychain/keychain_test.go",
"chars": 195,
"preview": "package keychain\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestKeychain(t"
},
{
"path": "pkg/keychain/keyring.go",
"chars": 291,
"preview": "package keychain\n\nimport \"github.com/99designs/keyring\"\n\n//go:generate go run go.uber.org/mock/mockgen -source=keyring.g"
},
{
"path": "pkg/log/log_test.go",
"chars": 180,
"preview": "package log\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestLog(t *testing."
},
{
"path": "pkg/log/logger.go",
"chars": 1079,
"preview": "package log\n\nimport (\n\t\"io\"\n\n\t\"github.com/rs/zerolog\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/rs/zerolog/pkgerrors\"\n)\n"
},
{
"path": "pkg/log/logger_test.go",
"chars": 1792,
"preview": "package log\n\nimport (\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\n\t\"github.com/rs/zerolog\"\n\t\"go.uber.org"
},
{
"path": "pkg/log/writer.go",
"chars": 1189,
"preview": "package log\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\n\t\"github.com/rs/zerolog\"\n)\n\n//go:generate go run go.uber.org/mock/mockgen -sou"
},
{
"path": "pkg/log/writer_test.go",
"chars": 1893,
"preview": "package log\n\nimport (\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\t\"github.com/rs/zerolog\"\n\t\"go.uber.org/"
},
{
"path": "pkg/util/machine/machine.go",
"chars": 1450,
"preview": "package machine\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"path/filepath\"\n\t\"runtime\"\n\n\t\"github.com/majd/ipatool/v2/pkg/util/operatingsyst"
},
{
"path": "pkg/util/machine/machine_test.go",
"chars": 1270,
"preview": "package machine\n\nimport (\n\t\"syscall\"\n\t\"testing\"\n\n\t\"github.com/majd/ipatool/v2/pkg/util/operatingsystem\"\n\t. \"github.com/o"
},
{
"path": "pkg/util/must.go",
"chars": 218,
"preview": "package util\n\n// Must is a helper that wraps a call to a function returning (T, error) and panics if the error is non-ni"
},
{
"path": "pkg/util/must_test.go",
"chars": 377,
"preview": "package util\n\nimport (\n\t\"errors\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nvar _ = Describe(\"Must\","
},
{
"path": "pkg/util/operatingsystem/operatingsystem.go",
"chars": 1484,
"preview": "package operatingsystem\n\nimport (\n\t\"os\"\n)\n\n//go:generate go run go.uber.org/mock/mockgen -source=operatingsystem.go -des"
},
{
"path": "pkg/util/operatingsystem/operatingsystem_test.go",
"chars": 2198,
"preview": "package operatingsystem\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"math/rand\"\n\t\"os\"\n\t\"path\"\n\t\"testing\"\n\t\"time\"\n\n\t. \"github.com/onsi/gin"
},
{
"path": "pkg/util/string.go",
"chars": 116,
"preview": "package util\n\nfunc IfEmpty(value, fallback string) string {\n\tif value == \"\" {\n\t\treturn fallback\n\t}\n\n\treturn value\n}\n"
},
{
"path": "pkg/util/string_test.go",
"chars": 354,
"preview": "package util\n\nimport (\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nvar _ = Describe(\"String\", func() {"
},
{
"path": "pkg/util/util_test.go",
"chars": 183,
"preview": "package util\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestUtil(t *testin"
},
{
"path": "pkg/util/zip.go",
"chars": 389,
"preview": "package util\n\nimport \"errors\"\n\ntype Pair[T, U any] struct {\n\tFirst T\n\tSecond U\n}\n\nfunc Zip[T, U any](ts []T, us []U) (["
},
{
"path": "pkg/util/zip_test.go",
"chars": 717,
"preview": "package util\n\nimport (\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nvar _ = Describe(\"Zip\", func() {\n\tW"
},
{
"path": "tools/sha256sum.sh",
"chars": 141,
"preview": "#!/bin/sh -e\n\nif which sha256sum >/dev/null 2>&1; then\n sha256sum \"$1\" | awk '{ print $1 }'\nelse\n shasum -a256 \"$1\" | "
},
{
"path": "tools.go",
"chars": 73,
"preview": "//go:build tools\n\npackage main\n\nimport (\n\t_ \"go.uber.org/mock/mockgen\"\n)\n"
}
]
About this extraction
This page contains the full source code of the majd/ipatool GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 93 files (189.3 KB), approximately 59.2k tokens, and a symbol index with 214 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.