├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── 01-bug-report.yaml │ └── 02-feature-request.yaml ├── dependabot.yaml ├── release.yaml └── workflows │ ├── build-test.yaml │ ├── codeql.yaml │ ├── release-published.yaml │ └── tag-pushed.yaml ├── .gitignore ├── .markdownlint-cli2.yaml ├── .periphery.yaml ├── .swift-version ├── .swiftformat ├── .swiftlint.yml ├── .yamllint.yaml ├── Brewfile ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Documentation ├── sample.swift └── style.md ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Scripts ├── _setup_script ├── bootstrap ├── build ├── clean ├── format ├── generate_package_swift ├── generate_token ├── lint ├── package ├── release_cancel ├── release_start ├── setup_workflow_repo ├── test ├── update_headers └── version ├── Sources ├── PrivateFrameworks │ ├── CommerceKit │ │ ├── CKDownloadDirectory.h │ │ ├── CKDownloadQueue.h │ │ ├── CKDownloadQueueObserver-Protocol.h │ │ ├── CKPurchaseController.h │ │ ├── CKServiceInterface.h │ │ ├── CommerceKit.h │ │ └── module.modulemap │ └── StoreFoundation │ │ ├── ISAccountService-Protocol.h │ │ ├── ISServiceProxy.h │ │ ├── ISStoreAccount.h │ │ ├── SSDownload.h │ │ ├── SSDownloadMetadata.h │ │ ├── SSDownloadPhase.h │ │ ├── SSDownloadStatus.h │ │ ├── SSPurchase.h │ │ ├── SSPurchaseResponse.h │ │ ├── StoreFoundation.h │ │ └── module.modulemap └── mas │ ├── .swiftlint.yml │ ├── AppStore │ ├── AppleAccount.swift │ ├── Downloader.swift │ ├── ISORegion.swift │ ├── PurchaseDownloadObserver.swift │ └── SSPurchase.swift │ ├── Commands │ ├── Account.swift │ ├── Config.swift │ ├── Home.swift │ ├── Info.swift │ ├── Install.swift │ ├── List.swift │ ├── Lucky.swift │ ├── Open.swift │ ├── OptionGroups │ │ ├── AppIDsOptionGroup.swift │ │ ├── ForceOptionGroup.swift │ │ ├── SearchTermOptionGroup.swift │ │ └── VerboseOptionGroup.swift │ ├── Outdated.swift │ ├── Purchase.swift │ ├── Region.swift │ ├── Reset.swift │ ├── Search.swift │ ├── SignIn.swift │ ├── SignOut.swift │ ├── Uninstall.swift │ ├── Upgrade.swift │ ├── Vendor.swift │ └── Version.swift │ ├── Controllers │ ├── AppStoreSearcher.swift │ ├── ITunesSearchAppStoreSearcher.swift │ └── SpotlightInstalledApps.swift │ ├── Errors │ └── MASError.swift │ ├── Formatters │ ├── AppInfoFormatter.swift │ ├── AppListFormatter.swift │ ├── Printer.swift │ └── SearchResultFormatter.swift │ ├── MAS.swift │ ├── Models │ ├── AppID.swift │ ├── InstalledApp.swift │ ├── SearchResult.swift │ └── SearchResultList.swift │ ├── Network │ ├── NetworkSession.swift │ ├── URL.swift │ └── URLSession+NetworkSession.swift │ └── Utilities │ ├── Finder.swift │ └── ProcessInfo.swift ├── Tests └── masTests │ ├── .swiftlint.yml │ ├── Commands │ ├── AccountSpec.swift │ ├── HomeSpec.swift │ ├── InfoSpec.swift │ ├── InstallSpec.swift │ ├── ListSpec.swift │ ├── LuckySpec.swift │ ├── OpenSpec.swift │ ├── OutdatedSpec.swift │ ├── PurchaseSpec.swift │ ├── SearchSpec.swift │ ├── SignInSpec.swift │ ├── SignOutSpec.swift │ ├── UninstallSpec.swift │ ├── UpgradeSpec.swift │ ├── VendorSpec.swift │ └── VersionSpec.swift │ ├── Controllers │ ├── ITunesSearchAppStoreSearcherSpec.swift │ └── MockAppStoreSearcher.swift │ ├── Extensions │ └── Data.swift │ ├── Formatters │ ├── AppListFormatterSpec.swift │ └── SearchResultFormatterSpec.swift │ ├── Models │ ├── InstalledAppSpec.swift │ ├── SearchResultListSpec.swift │ └── SearchResultSpec.swift │ ├── Network │ └── MockNetworkSession.swift │ ├── Resources │ ├── lookup │ │ └── slack.json │ └── search │ │ ├── bbedit.json │ │ ├── slack.json │ │ ├── things-that-go-bump.json │ │ └── things.json │ └── Utilities │ ├── UnvaluedConsequences.swift │ └── ValuedConsequences.swift ├── audit_exceptions └── github_prerelease_allowlist.json ├── contrib └── completion │ ├── mas-completion.bash │ └── mas.fish └── mas-cli.png /.editorconfig: -------------------------------------------------------------------------------- 1 | # 2 | # .editorconfig 3 | # mas 4 | # 5 | # EditorConfig 0.17.2 6 | # 7 | 8 | root = true 9 | 10 | [*] 11 | charset = utf-8 12 | continuation_indent_size = 0 13 | end_of_line = lf 14 | indent_size = tab 15 | indent_style = tab 16 | insert_final_newline = true 17 | max_line_length = 120 18 | quote_type = single 19 | spelling_language = en_US 20 | tab_width = 2 21 | trim_trailing_whitespace = true 22 | 23 | [*.md] 24 | # Trailing spaces have meaning in Markdown 25 | trim_trailing_whitespace = false 26 | 27 | [{*.yaml,*.yml}] 28 | max_line_length = 80 29 | 30 | [Scripts/*] 31 | max_line_length = 80 32 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Trailing spaces may be intentional in Markdown documents, so these should not 2 | # be removed. 3 | **/*.md whitespace=-blank-at-eol 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # 2 | # .github/CODEOWNERS 3 | # 4 | 5 | /.github/ @mas-cli/admins 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01-bug-report.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | description: Report a bug. 4 | labels: [\U0001F41B bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | # Configuration 10 | - type: textarea 11 | id: config 12 | attributes: 13 | label: mas config 14 | description: Output of `mas config --markdown` 15 | value: | 16 | 21 | 22 | ### mas version (output of `mas version`) 23 | 24 | 25 | 26 | ### macOS version (output of `sw_vers -productVersion`) 27 | 28 | 29 | 30 | ### macOS build (output of `sw_vers -buildVersion`) 31 | 32 | 33 | 34 | ### CPU (output of `sysctl -n machdep.cpu.brand_string`) 35 | 36 | 37 | 38 | ### Installation method 39 | 40 | 41 | Homebrew core (via `brew install mas`) 42 | Homebrew custom tap (via `brew install mas-cli/tap/mas`) 43 | GitHub Releases (from ) 44 | Built from source (provide info about build) 45 | Other 46 | validations: 47 | required: true 48 | - type: markdown 49 | attributes: 50 | value: | 51 | # Issue 52 | - type: textarea 53 | id: description 54 | attributes: 55 | label: Bug description 56 | description: Expected & actual output; other pertinent info 57 | validations: 58 | required: true 59 | - type: textarea 60 | id: reproduction 61 | attributes: 62 | label: Steps to reproduce 63 | description: | 64 | Copied, pasted & formatted commands & output in console blocks (as instructed below); instructions; screenshots 65 | value: | 66 | ```console 67 | 68 | ``` 69 | validations: 70 | required: true 71 | - type: markdown 72 | attributes: 73 | value: | 74 | # Console command & output formatting instructions 75 | 76 | Provide console commands & output as copied, pasted & formatted text, instead of as screenshots. 77 | 78 | If long descriptive text or screenshots of dialogs or apps are necessary, provide them between console blocks. 79 | 80 | Format commands & output as follows (where `…` is a placeholder): 81 | 82 | - Use a console block: start with ```` ```console ````, end with ```` ``` ````, each on its own line 83 | - Prefix each non-console step (or comment) with two hashes & a space: `## …` 84 | - Remove custom shell prompts; instead, prefix each console command with a dollar sign & a space: `$ …` 85 | - Prefix each output line beginning with `#`, `$`, `%`, or `>` with an additional instance of that character: `##…`, `$$…`, `%%…`, or `>>…` 87 | - Write all other output lines without any prefix: `…` 88 | 89 | e.g.: 90 | 91 | ````text 92 | ```console 93 | ## In the Mac App Store GUI, click on… 94 | $ mas list 95 | 123 App 1 (4.5.6) 96 | 124 App 2 (10.2) 97 | $ mas outdated 98 | 123 App 1 (4.5.6 -> 4.5.7) 99 | ``` 100 | ```` 101 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02-feature-request.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | description: Request a feature. 4 | labels: [\U0001F195 feature request] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | # Console command & output formatting 10 | 11 | When providing commands & output, please use the following format (where `…` is a placeholder): 12 | 13 | - Use a multiline console block: start with ```` ```console ````, end with ```` ``` ````, each on its own line 14 | - Prefix each non-console step (or comment) with two hashes & a space: `## …` 15 | - Remove shell prompts; instead, prefix each console command with a dollar sign & a space: `$ …` 16 | - Prefix each output line beginning with `#`, `$`, `%`, or `>` with an additional instance of that character: `##…`, `$$…`, `%%…`, or `>>…` 18 | - Write all other output lines without any prefix: `…` 19 | 20 | e.g.: 21 | 22 | ````text 23 | ```console 24 | ## In the Mac App Store GUI, … 25 | $ mas list 26 | 123 App 1 (4.5.6) 27 | 124 App 2 (10.2) 28 | ``` 29 | ```` 30 | 31 | # Feature 32 | - type: textarea 33 | id: problems 34 | attributes: 35 | label: Problem(s) addressed 36 | placeholder: 37 | Prefer copied, pasted & formatted commands & output in a multiline console block (as instructed 38 | above) instead of screenshots 39 | validations: 40 | required: true 41 | - type: textarea 42 | id: proposals 43 | attributes: 44 | label: Proposed solution(s) 45 | placeholder: 46 | Prefer copied, pasted & formatted commands & output in a multiline console block (as instructed 47 | above) instead of screenshots 48 | validations: 49 | required: true 50 | - type: textarea 51 | id: alternatives 52 | attributes: 53 | label: Alternative solution(s) 54 | placeholder: 55 | Prefer copied, pasted & formatted commands & output in a multiline console block (as instructed 56 | above) instead of screenshots 57 | validations: 58 | required: false 59 | - type: textarea 60 | id: context 61 | attributes: 62 | label: Additional context 63 | placeholder: 64 | Prefer copied, pasted & formatted commands & output in a multiline console block (as instructed 65 | above) instead of screenshots 66 | validations: 67 | required: false 68 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: github-actions 5 | schedule: 6 | interval: daily 7 | directory: / 8 | labels: [📚 dependencies] 9 | commit-message: 10 | prefix: ⬆️ 11 | include: scope 12 | - package-ecosystem: swift 13 | schedule: 14 | interval: daily 15 | directory: / 16 | labels: [📚 dependencies] 17 | commit-message: 18 | prefix: ⬆️ 19 | include: scope 20 | -------------------------------------------------------------------------------- /.github/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | changelog: 3 | categories: 4 | - title: 🚀 Features 5 | labels: [🆕 feature request] 6 | - title: 🐛 Bug Fixes 7 | labels: [🐛 bug] 8 | - title: Changes 9 | labels: ['*'] 10 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # .github/workflows/build-test.yaml 3 | # 4 | --- 5 | name: Build, Test, and Lint 6 | on: 7 | pull_request: 8 | branches: [main] 9 | push: 10 | branches: [main] 11 | concurrency: 12 | group: ${{github.workflow}}-${{github.ref}} 13 | cancel-in-progress: true 14 | permissions: {} 15 | jobs: 16 | build-test: 17 | name: Build, Test, and Lint 18 | runs-on: macos-15 19 | defaults: 20 | run: 21 | # Force all run commands to not use Rosetta 2 22 | shell: arch -arm64 /bin/zsh -Negku {0} 23 | steps: 24 | - name: 🛒 Checkout repo 25 | env: 26 | GIT_CONFIG_COUNT: 1 27 | GIT_CONFIG_KEY_0: init.defaultBranch 28 | GIT_CONFIG_VALUE_0: ${{github.event.repository.default_branch}} 29 | uses: actions/checkout@v4 30 | with: 31 | # Include all history & tags for Scripts/version 32 | fetch-depth: 0 33 | 34 | - name: 🔧 Setup repo 35 | run: Scripts/setup_workflow_repo 36 | 37 | - name: 🛠 Select Xcode 16.3 38 | run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer 39 | 40 | - name: 👢 Bootstrap 41 | run: Scripts/bootstrap 42 | 43 | - name: 🏗 Build 44 | run: Scripts/build build-test 45 | 46 | - name: 🧪 Test 47 | run: Scripts/test 48 | 49 | - name: 🚨 Lint 50 | run: Scripts/lint 51 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # .github/workflows/codeql.yaml 3 | # 4 | --- 5 | name: CodeQL 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | schedule: 12 | - cron: 44 14 * * 4 13 | workflow_dispatch: {} 14 | jobs: 15 | analyze: 16 | name: Analyze ${{matrix.language}} 17 | runs-on: macos-15 18 | permissions: 19 | security-events: write 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | include: 24 | - language: actions 25 | build-mode: none 26 | 27 | - language: swift 28 | build-mode: manual 29 | steps: 30 | - name: 🛒 Checkout repo 31 | env: 32 | GIT_CONFIG_COUNT: 1 33 | GIT_CONFIG_KEY_0: init.defaultBranch 34 | GIT_CONFIG_VALUE_0: ${{github.event.repository.default_branch}} 35 | uses: actions/checkout@v4 36 | with: 37 | # Include all history & tags for Scripts/version 38 | fetch-depth: 0 39 | 40 | - name: 🔧 Setup repo 41 | run: Scripts/setup_workflow_repo 42 | 43 | - name: 🔩 Initialize CodeQL 44 | uses: github/codeql-action/init@v3 45 | with: 46 | languages: ${{matrix.language}} 47 | build-mode: ${{matrix.build-mode}} 48 | queries: ${{matrix.language == 'swift' && '+security-and-quality' || ''}} 49 | 50 | - name: 🏗 Build Swift 51 | if: matrix.language == 'swift' 52 | shell: bash 53 | run: | 54 | Scripts/build codeql 55 | 56 | - name: 🔍 Perform CodeQL analysis 57 | uses: github/codeql-action/analyze@v3 58 | with: 59 | category: /language:${{matrix.language}} 60 | -------------------------------------------------------------------------------- /.github/workflows/release-published.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # .github/workflows/release-published.yaml 3 | # 4 | --- 5 | name: release-published 6 | on: 7 | release: 8 | types: [published] 9 | permissions: 10 | actions: read 11 | contents: write 12 | pull-requests: write 13 | defaults: 14 | run: 15 | # Force all run commands to not use Rosetta 2 16 | shell: arch -arm64 /bin/zsh -Negku {0} 17 | jobs: 18 | release-published: 19 | if: ${{!github.event.repository.fork}} 20 | runs-on: macos-15 21 | steps: 22 | - name: 🛒 Checkout repo 23 | env: 24 | GIT_CONFIG_COUNT: 1 25 | GIT_CONFIG_KEY_0: init.defaultBranch 26 | GIT_CONFIG_VALUE_0: ${{github.event.repository.default_branch}} 27 | uses: actions/checkout@v4 28 | with: 29 | # Include all history & tags for Scripts/version 30 | fetch-depth: 0 31 | 32 | - name: 🔧 Setup repo 33 | run: Scripts/setup_workflow_repo 34 | 35 | - name: 🚰 Apply pr-pull label to custom tap formula bump PR 36 | env: 37 | TOKEN_APP_ID: ${{secrets.TOKEN_APP_ID}} 38 | TOKEN_APP_INSTALLATION_ID: ${{secrets.TOKEN_APP_INSTALLATION_ID}} 39 | TOKEN_APP_PRIVATE_KEY: ${{secrets.TOKEN_APP_PRIVATE_KEY}} 40 | run: | 41 | export GH_TOKEN="$(Scripts/generate_token)" 42 | 43 | unsetopt errexit 44 | bump_url="$(gh release -R "${GITHUB_REPOSITORY}" download "${GITHUB_REF_NAME}" -p bump.url -O - 2>/dev/null)" 45 | found_bump_url="${?}" 46 | setopt errexit 47 | 48 | if [[ "${found_bump_url}" -eq 0 ]]; then 49 | [[ -n "${bump_url}" ]] && gh pr edit "${bump_url}" --add-label pr-pull 50 | gh release -R "${GITHUB_REPOSITORY}" delete-asset "${GITHUB_REF_NAME}" bump.url -y 51 | else 52 | printf $'No custom tap formula bump PR URL found for tag\'%s\'\n' "${GITHUB_REF_NAME}" 53 | fi 54 | -------------------------------------------------------------------------------- /.github/workflows/tag-pushed.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # .github/workflows/tag-pushed.yaml 3 | # 4 | --- 5 | name: tag-pushed 6 | on: 7 | push: 8 | tags: ['**'] 9 | permissions: 10 | contents: write 11 | defaults: 12 | run: 13 | # Force all run commands to not use Rosetta 2 14 | shell: arch -arm64 /bin/zsh -Negku {0} 15 | jobs: 16 | tag-pushed: 17 | if: ${{!github.event.repository.fork}} 18 | runs-on: macos-15 19 | steps: 20 | - name: 🛒 Checkout repo 21 | env: 22 | GIT_CONFIG_COUNT: 1 23 | GIT_CONFIG_KEY_0: init.defaultBranch 24 | GIT_CONFIG_VALUE_0: ${{github.event.repository.default_branch}} 25 | uses: actions/checkout@v4 26 | with: 27 | # Include all history & tags for Scripts/version 28 | fetch-depth: 0 29 | 30 | - name: 🔧 Setup repo 31 | run: Scripts/setup_workflow_repo 32 | 33 | - name: 🖋 Delete tag lacking valid signature 34 | run: | 35 | git fetch --force origin "${GITHUB_REF}:${GITHUB_REF}" 36 | if [[\ 37 | "$(git cat-file tag "${GITHUB_REF_NAME}")" != *'-----BEGIN SSH SIGNATURE-----'*'-----END SSH SIGNATURE-----'\ 38 | ]]; then 39 | printf $'Error: Deleting tag %s because it does not have a valid signature\n' "${GITHUB_REF_NAME}" >&2 40 | git push -d origin "${GITHUB_REF_NAME}" 41 | exit 1 42 | fi 43 | 44 | - name: 🏷 Exit if not a version tag 45 | run: | 46 | if [[ ! "${GITHUB_REF_NAME}" =~ '^v[[:digit:]]+(\.[[:digit:]]+)*(-(alpha|beta|rc)\.[[:digit:]]+)?$' ]]; then 47 | printf $'Exiting because %s is not a version tag\n' "${GITHUB_REF_NAME}" 48 | exit 2 49 | fi 50 | 51 | - name: 🌳 Delete version tag not on main 52 | env: 53 | DEFAULT_BRANCH_NAME: ${{github.event.repository.default_branch}} 54 | run: | 55 | git fetch --force origin "${DEFAULT_BRANCH_NAME}:${DEFAULT_BRANCH_NAME}" 56 | if ! git merge-base --is-ancestor "${GITHUB_REF_NAME}" "${DEFAULT_BRANCH_NAME}"; then 57 | printf $'Error: Deleting version tag %s because it is not on the %s branch\n' "${GITHUB_REF_NAME}"\ 58 | "${DEFAULT_BRANCH_NAME}" >&2 59 | git push -d origin "${GITHUB_REF_NAME}" 60 | exit 3 61 | fi 62 | 63 | - name: 🛠 Select Xcode 16.3 64 | run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer 65 | 66 | - name: 📦 Build universal executable & package it in an installer 67 | run: Scripts/package package 68 | 69 | - name: 🚰 Bump custom tap formula 70 | env: 71 | TOKEN_APP_ID: ${{secrets.TOKEN_APP_ID}} 72 | TOKEN_APP_INSTALLATION_ID: ${{secrets.TOKEN_APP_INSTALLATION_ID}} 73 | TOKEN_APP_PRIVATE_KEY: ${{secrets.TOKEN_APP_PRIVATE_KEY}} 74 | run: | 75 | export HOMEBREW_GITHUB_API_TOKEN="$(Scripts/generate_token)" 76 | 77 | brew tap "${GITHUB_REPOSITORY_OWNER}/tap" 78 | 79 | unsetopt errexit 80 | bump_output="$(brew bump-formula-pr\ 81 | --tag "${GITHUB_REF_NAME}"\ 82 | --revision "${GITHUB_SHA}"\ 83 | --no-fork\ 84 | --no-browse\ 85 | --online\ 86 | --strict\ 87 | --verbose\ 88 | "${GITHUB_REPOSITORY_OWNER}/tap/mas"\ 89 | 2>&1)" 90 | exit_code="${?}" 91 | setopt errexit 92 | 93 | printf %s "${bump_output}" 94 | printf %s "${${(f)bump_output}[-1]}" > .build/bump.url 95 | 96 | exit "${exit_code}" 97 | 98 | - name: 📝 Create draft release 99 | env: 100 | GH_TOKEN: ${{github.token}} 101 | run: | 102 | gh release create\ 103 | "${GITHUB_REF_NAME}"\ 104 | ".build/mas-${GITHUB_REF_NAME#v}.pkg"\ 105 | .build/bump.url\ 106 | -d\ 107 | ${"${GITHUB_REF_NAME//[^-]}":+-p}\ 108 | -t "${GITHUB_REF_NAME}: ${$(git tag -l "${GITHUB_REF_NAME}" --format='%(contents)')%%$'\n'*}"\ 109 | --generate-notes 110 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.build/ 2 | /.idea/ 3 | /.swiftpm/ 4 | /Sources/mas/Package.swift 5 | -------------------------------------------------------------------------------- /.markdownlint-cli2.yaml: -------------------------------------------------------------------------------- 1 | # yamllint disable-line rule:line-length 2 | # yaml-language-server: $schema=https://raw.githubusercontent.com/DavidAnson/markdownlint-cli2/main/schema/markdownlint-cli2-config-schema.json 3 | # 4 | # .markdownlint-cli2.yaml 5 | # mas 6 | # 7 | # markdownlint-cli2 0.18.1 / markdownlint 0.38.0 8 | # 9 | --- 10 | gitignore: true 11 | noBanner: true 12 | noProgress: true 13 | config: 14 | default: true 15 | extends: null 16 | heading-increment: true 17 | heading-style: 18 | style: consistent 19 | ul-style: 20 | style: consistent 21 | list-indent: true 22 | ul-indent: 23 | indent: 2 24 | start_indented: false 25 | start_indent: 2 26 | no-trailing-spaces: 27 | br_spaces: 2 28 | list_item_empty_lines: false 29 | strict: true 30 | no-hard-tabs: 31 | code_blocks: true 32 | ignore_code_languages: [] 33 | spaces_per_tab: 2 34 | no-reversed-links: true 35 | no-multiple-blanks: 36 | maximum: 1 37 | line-length: 38 | line_length: 120 39 | heading_line_length: 100 40 | code_block_line_length: 80 41 | code_blocks: true 42 | tables: true 43 | headings: true 44 | strict: false 45 | stern: true 46 | commands-show-output: true 47 | no-missing-space-atx: true 48 | no-multiple-space-atx: true 49 | no-missing-space-closed-atx: true 50 | no-multiple-space-closed-atx: true 51 | blanks-around-headings: 52 | lines_above: 1 53 | lines_below: 1 54 | heading-start-left: true 55 | no-duplicate-heading: 56 | siblings_only: false 57 | single-h1: 58 | front_matter_title: ^\\s*title\\s*[:=] 59 | level: 1 60 | no-trailing-punctuation: 61 | punctuation: .,;:!。,;:! 62 | no-multiple-space-blockquote: 63 | list_items: true 64 | no-blanks-blockquote: true 65 | ol-prefix: 66 | style: one_or_ordered 67 | list-marker-space: 68 | ul_single: 1 69 | ol_single: 1 70 | ul_multi: 1 71 | ol_multi: 1 72 | blanks-around-fences: 73 | list_items: true 74 | blanks-around-lists: true 75 | no-inline-html: 76 | allowed_elements: [h1, img] 77 | no-bare-urls: true 78 | hr-style: 79 | style: consistent 80 | no-emphasis-as-heading: 81 | punctuation: .,;:!?。,;:!? 82 | no-space-in-emphasis: true 83 | no-space-in-code: true 84 | no-space-in-links: true 85 | fenced-code-language: 86 | allowed_languages: [] 87 | language_only: false 88 | first-line-h1: 89 | allow_preamble: false 90 | front_matter_title: ^\\s*title\\s*[:=] 91 | level: 1 92 | no-empty-links: true 93 | required-headings: true 94 | proper-names: 95 | names: [] 96 | code_blocks: true 97 | html_elements: true 98 | no-alt-text: true 99 | code-block-style: 100 | style: consistent 101 | single-trailing-newline: true 102 | code-fence-style: 103 | style: consistent 104 | emphasis-style: 105 | style: consistent 106 | strong-style: 107 | style: consistent 108 | link-fragments: 109 | ignore_case: false 110 | ignored_pattern: '' 111 | reference-links-images: 112 | ignored_labels: 113 | - x 114 | shortcut_syntax: false 115 | link-image-reference-definitions: 116 | ignored_definitions: 117 | - // 118 | link-image-style: 119 | autolink: true 120 | inline: true 121 | full: true 122 | collapsed: true 123 | shortcut: true 124 | url_inline: true 125 | table-pipe-style: 126 | style: consistent 127 | table-column-count: true 128 | blanks-around-tables: true 129 | descriptive-link-text: 130 | prohibited_texts: 131 | - click here 132 | - here 133 | - link 134 | - more 135 | -------------------------------------------------------------------------------- /.periphery.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # .periphery.yaml 3 | # mas 4 | # 5 | # Periphery 3.1.0 6 | # 7 | --- 8 | disable_update_check: true 9 | external_test_case_classes: 10 | - AsyncSpec 11 | - QuickSpec 12 | quiet: true 13 | relative_results: true 14 | retain_assign_only_properties: false 15 | strict: true 16 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.9 2 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # 2 | # .swiftformat 3 | # mas 4 | # 5 | # SwiftFormat 0.56.2 6 | # 7 | 8 | # Disabled rules 9 | --disable hoistAwait 10 | --disable hoistTry 11 | 12 | # Enabled rules (disabled by default) 13 | #--enable acronyms 14 | #--enable blankLineAfterSwitchCase 15 | --enable blankLinesAfterGuardStatements 16 | --enable blankLinesBetweenImports 17 | --enable blockComments 18 | --enable docComments 19 | --enable emptyExtensions 20 | --enable environmentEntry 21 | --enable isEmpty 22 | #--enable markTypes 23 | --enable noExplicitOwnership 24 | --enable organizeDeclarations 25 | --enable preferSwiftTesting 26 | --enable privateStateVariables 27 | --enable propertyTypes 28 | --enable redundantEquatable 29 | --enable redundantProperty 30 | --enable sortSwitchCases 31 | --enable unusedPrivateDeclarations 32 | --enable wrapConditionalBodies 33 | --enable wrapEnumCases 34 | --enable wrapMultilineConditionalAssignment 35 | --enable wrapMultilineFunctionChains 36 | --enable wrapSwitchCases 37 | 38 | # Rule options 39 | --commas always 40 | --hexliteralcase lowercase 41 | --ifdef no-indent 42 | --importgrouping testable-last 43 | --indent tab 44 | --indentstrings true 45 | --inferredtypes always 46 | --lineaftermarks false 47 | --markcategories false 48 | --organizationmode type 49 | --propertytypes inferred 50 | --ranges no-space 51 | --tabwidth 2 52 | --wrapternary before-operators 53 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # 2 | # .swiftlint.yml 3 | # mas 4 | # 5 | # SwiftLint 0.59.1 6 | # 7 | --- 8 | opt_in_rules: 9 | - all 10 | analyzer_rules: 11 | - all 12 | disabled_rules: 13 | # Never enable 14 | - contrasted_opening_brace 15 | - explicit_acl 16 | - explicit_enum_raw_value 17 | - explicit_self 18 | - explicit_top_level_acl 19 | - explicit_type_interface 20 | - no_extension_access_modifier 21 | - no_grouping_extension 22 | - no_magic_numbers 23 | - prefixed_toplevel_constant 24 | - sorted_enum_cases 25 | - strict_fileprivate 26 | - vertical_whitespace_between_cases 27 | - void_function_in_ternary 28 | attributes: 29 | always_on_line_above: ['@MainActor', '@OptionGroup'] 30 | closure_body_length: 31 | warning: 40 32 | cyclomatic_complexity: 33 | warning: 11 34 | file_types_order: 35 | order: 36 | - main_type 37 | - supporting_type 38 | - extension 39 | - preview_provider 40 | - library_content_provider 41 | function_body_length: 42 | warning: 70 43 | indentation_width: 44 | include_multiline_strings: false 45 | number_separator: 46 | minimum_length: 6 47 | opening_brace: 48 | ignore_multiline_statement_conditions: true 49 | trailing_comma: 50 | mandatory_comma: true 51 | type_contents_order: 52 | order: 53 | - case 54 | - associated_type 55 | - type_alias 56 | - subtype 57 | - type_property 58 | - instance_property 59 | - ib_inspectable 60 | - ib_outlet 61 | - initializer 62 | - deinitializer 63 | - type_method 64 | - view_life_cycle_method 65 | - ib_action 66 | - other_method 67 | - subscript 68 | -------------------------------------------------------------------------------- /.yamllint.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # .yamllint.yaml 3 | # mas 4 | # 5 | # yamllint 1.37.1 6 | # 7 | --- 8 | extends: default 9 | locale: en_US.UTF-8 10 | ignore-from-file: .gitignore 11 | rules: 12 | anchors: 13 | forbid-duplicated-anchors: true 14 | forbid-undeclared-aliases: true 15 | forbid-unused-anchors: true 16 | braces: 17 | forbid: non-empty 18 | max-spaces-inside-empty: 0 19 | brackets: 20 | forbid: false 21 | max-spaces-inside: 0 22 | max-spaces-inside-empty: 0 23 | colons: 24 | max-spaces-before: 0 25 | max-spaces-after: 1 26 | commas: 27 | max-spaces-before: 0 28 | min-spaces-after: 1 29 | max-spaces-after: 1 30 | comments: 31 | require-starting-space: true 32 | min-spaces-from-content: 1 33 | comments-indentation: enable 34 | document-end: 35 | present: false 36 | document-start: 37 | present: true 38 | empty-lines: 39 | max: 2 40 | max-start: 0 41 | max-end: 0 42 | empty-values: 43 | forbid-in-block-mappings: true 44 | forbid-in-block-sequences: true 45 | forbid-in-flow-mappings: true 46 | float-values: 47 | require-numeral-before-decimal: true 48 | hyphens: 49 | max-spaces-after: 1 50 | indentation: 51 | spaces: 2 52 | indent-sequences: false 53 | check-multi-line-strings: false 54 | key-duplicates: 55 | forbid-duplicated-merge-keys: true 56 | key-ordering: disable 57 | line-length: 58 | max: 120 59 | allow-non-breakable-inline-mappings: true 60 | new-line-at-end-of-file: enable 61 | new-lines: 62 | type: unix 63 | octal-values: 64 | forbid-implicit-octal: true 65 | forbid-explicit-octal: true 66 | quoted-strings: 67 | check-keys: true 68 | required: only-when-needed 69 | quote-type: single 70 | trailing-spaces: enable 71 | truthy: 72 | check-keys: false 73 | allowed-values: ['true', 'false'] 74 | -------------------------------------------------------------------------------- /Brewfile: -------------------------------------------------------------------------------- 1 | brew "git" 2 | brew "markdownlint-cli2" 3 | brew "shellcheck" 4 | brew "swiftformat" 5 | brew "swiftlint" 6 | brew "yamllint" 7 | 8 | if OS.mac? 9 | if MacOS.version == :ventura 10 | tap "peripheryapp/periphery" 11 | cask "periphery" 12 | elsif MacOS.version >= :sonoma 13 | brew "periphery" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open & welcoming environment, we as contributors 6 | & maintainers pledge to making participation in our project & our community a 7 | harassment-free experience for everyone, regardless of age, body size, 8 | disability, ethnicity, sex characteristics, gender identity & expression, level 9 | of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity & orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming & inclusive language 18 | * Being respectful of differing viewpoints & experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery & unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, & personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior & are expected to take appropriate & fair corrective action in response 38 | to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right & responsibility to remove, edit, or reject 41 | comments, commits, code, wiki edits, issues, & other contributions that are not 42 | aligned to this Code of Conduct, or to temporarily or permanently ban any 43 | contributor for other behaviors that they deem inappropriate, threatening, 44 | offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces & in public spaces when 49 | an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined & clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at @mas-cli/admins. All complaints will 59 | be reviewed & investigated & will result in a response that is deemed necessary 60 | & appropriate to the circumstances. The project team is obligated to maintain 61 | confidentiality with regard to the reporter of an incident. Further details of 62 | specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the 71 | [Contributor Covenant version 1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct/). 72 | 73 | For answers to common questions about this Code of Conduct, see 74 | the [Contributor Covenant FAQ](https://www.contributor-covenant.org/faq/). 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Pull requests (PRs) are welcome from everyone. By participating in this project, you agree to abide by the 4 | [code of conduct](CODE_OF_CONDUCT.md). 5 | 6 | ## Getting Started 7 | 8 | - Ensure you have a [GitHub account](https://github.com/signup). 9 | - [Search for similar issues](https://github.com/mas-cli/mas/issues). 10 | - If one doesn't exist, [open a new issue](https://github.com/mas-cli/mas/issues/new/choose). 11 | - Select the appropriate issue template. 12 | - Follow the instructions in the issue template. 13 | 14 | ## Making Changes 15 | 16 | - [Fork the repository](https://github.com/mas-cli/mas#fork-destination-box) on GitHub. 17 | - Clone your fork: `git clone git@github.com:your-username/mas.git` 18 | - This project follows [trunk-based development](https://trunkbaseddevelopment.com), where `main` is the trunk. 19 | - Instead of [working directly on `main`](https://softwareengineering.stackexchange.com/questions/223400), create a 20 | topic branch from where you want to base your work (usually from `main`). 21 | - To quickly create a topic branch based on `main` named, e.g., `new-feature`, run: `git checkout -b new-feature main` 22 | - Make commits of logical units. 23 | - Follow the [style guide](Documentation/style.md). 24 | - Run `Scripts/format` before committing. 25 | - Run `Scripts/lint` before committing. Fix all lint violations. 26 | - Write tests. 27 | - If you need help with tests, feel free to open a PR, then ask for testing help. 28 | - Write a [good commit message](https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 29 | - Push your topic branch to your fork & submit a pull request (PR). 30 | - If your PR is not ready to be merged, create a draft PR. 31 | 32 | ## Releases 33 | 34 | - Release commits are tagged in the format of `v1.2.3`. 35 | - Releases (including release notes) are published on the [Releases page](https://github.com/mas-cli/mas/releases). 36 | 37 | ## Becoming a Contributor 38 | 39 | Once you have had a few PRs accepted, if you are interested in joining the 40 | [team](https://github.com/orgs/mas-cli/teams/contributors), [open an issue](https://github.com/mas-cli/mas/issues/new) 41 | titled "Add Contributor: @YourGitHubUsername" with a brief message asking to become a member. 42 | 43 | This project was created by [@argon](https://github.com/argon), who is unable to continue contributing to this project, 44 | but must remain an owner. 45 | 46 | By becoming a contributor, you agree to the following terms: 47 | 48 | - Do not claim to be the original author. 49 | - Retain @argon's name in the license; others' names may be added to it once they have made substantial contributions. 50 | - Retain @argon's name & [X handle](https://x.com/argon) in the README, but they may be moved around as deemed suitable. 51 | 52 | ## Project Lead Responsibilities 53 | 54 | Project leads agree to the following terms: 55 | 56 | - [@argon](https://github.com/argon) must continue to be one of the project owners. 57 | - Project leads have full control, however, over the project's future direction. 58 | - If you are the sole project lead, if you can no longer lead the project, either: 59 | - Find someone else to assume the project leadership who agrees to adhere to, & propagate, the existing terms. 60 | - If you cannot find a new project lead: 61 | - Message @argon via X, GitHub, or email. 62 | - Add an [unmaintained badge](https://unmaintained.tech) to the README. 63 | - Transfer the project back to [@argon](https://github.com/argon). 64 | -------------------------------------------------------------------------------- /Documentation/sample.swift: -------------------------------------------------------------------------------- 1 | // Don't include generated header comments. 2 | 3 | // MARK: Types & naming 4 | 5 | /// Types begin with a capital letter. 6 | struct User { 7 | let name: String 8 | 9 | /// If the first letter of an acronym is lowercase, the entire thing should 10 | /// be lowercase. 11 | let json: Any 12 | 13 | /// If the first letter of an acronym is uppercase, the entire thing should 14 | /// be uppercase. 15 | static func decode(from json: JSON) -> Self { 16 | Self(json: json) 17 | } 18 | } 19 | 20 | /// Use `()` for void arguments & `Void` for void return types. 21 | let closure: () -> Void = { 22 | // Do nothing 23 | } 24 | 25 | /// When using classes, default to marking them as `final`. 26 | final class MyClass { 27 | // Empty class 28 | } 29 | 30 | /// Use `typealias` when closures are referenced in multiple places. 31 | typealias CoolClosure = (Int) -> Bool 32 | 33 | /// Use aliased parameter names when function parameters are ambiguous. 34 | func yTown(some: Int, withCallback callback: CoolClosure) -> Bool { 35 | callback(some) 36 | } 37 | 38 | /// Use `$` variable references if the closure fits on one line. 39 | let cool = yTown(5) { $0 == 6 } 40 | 41 | /// Use explicit variable names if the closure is on multiple lines. 42 | let cool = yTown(5) { foo in 43 | max(foo, 0) 44 | // … 45 | } 46 | 47 | // Strongify weak references in async closures. 48 | APIClient.getAwesomeness { [weak self] result in 49 | guard let self else { 50 | return 51 | } 52 | stopLoadingSpinner() 53 | show(result) 54 | } 55 | 56 | /// Use if-let to check for not `nil` (even if using an implicitly unwrapped variable from an API). 57 | func someUnauditedAPI(thing: String?) { 58 | if let thing { 59 | printer.info(thing) 60 | } 61 | } 62 | 63 | /// When the type is known, let the compiler infer. 64 | let response: Response = .success(NSData()) 65 | 66 | func doSomeWork() -> Response { 67 | let data = Data("", .utf8) 68 | return .success(data) 69 | } 70 | 71 | switch response { 72 | case .success(let data): 73 | printer.info("The response returned successfully", data) 74 | case .failure(let error): 75 | printer.error("An error occurred:", error: error) 76 | } 77 | 78 | // MARK: Organization 79 | 80 | /// Group methods into specific extensions for each level of access control. 81 | private extension MyClass { 82 | func doSomethingPrivate() { 83 | // Do something 84 | } 85 | } 86 | 87 | // MARK: Breaking up long lines 88 | 89 | // If a guard clause requires multiple lines, chop it down, then start the else 90 | // clause on a new line. 91 | guard 92 | let oneItem = somethingFailable(), 93 | let secondItem = somethingFailable2() 94 | else { 95 | return 96 | } 97 | -------------------------------------------------------------------------------- /Documentation/style.md: -------------------------------------------------------------------------------- 1 | # All Files 2 | 3 | - Use `Scripts/format` to automatically fix a number of style violations. 4 | - Remove unnecessary whitespace from the end of lines. 5 | - Use `Scripts/lint` to look for these before committing. 6 | - Note that two trailing spaces is valid Markdown to create a line break like `
`, 7 | so those should _not_ be removed. 8 | - End each file with a [newline character]( 9 | https://unix.stackexchange.com/questions/18743/whats-the-point-in-adding-a-new-line-to-the-end-of-a-file#18789 10 | ). 11 | 12 | ## Swift 13 | 14 | [Sample](sample.swift) 15 | 16 | - Avoid [force unwrapping optionals](https://blog.timac.org/2017/0628-swift-banning-force-unwrapping-optionals) 17 | with `!` in production code 18 | - Production code is what gets shipped with the app. Basically, everything under the 19 | [`Sources/mas`](https://github.com/mas-cli/mas/tree/main/Sources/mas) folder. 20 | - However, force unwrapping is **encouraged** in tests for less code & tests 21 | _should_ break when any expected conditions aren't met. 22 | - Prefer `struct`s over `class`es wherever possible 23 | - Default to marking classes as `final` 24 | - Prefer protocol conformance to class inheritance 25 | - Break lines at 120 characters 26 | - Use 4 spaces for indentation 27 | - Use `let` whenever possible to make immutable variables 28 | - Name all parameters in functions & enum cases 29 | - Use trailing closures 30 | - Let the compiler infer the type whenever possible 31 | - Group computed properties below stored properties 32 | - Use a blank line above & below computed properties 33 | - Group functions into separate extensions for each level of access control 34 | - When capitalizing acronyms or initialisms, follow the capitalization of the first letter. 35 | - When using `Void` in function signatures, prefer `()` for arguments & `Void` for return types. 36 | - Avoid evaluating a weak reference multiple times in the same scope. Strongify first, then use the strong reference. 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Andrew Naylor, Ross Goldberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "cwlcatchexception", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/mattgallagher/CwlCatchException.git", 7 | "state" : { 8 | "revision" : "07b2ba21d361c223e25e3c1e924288742923f08c", 9 | "version" : "2.2.1" 10 | } 11 | }, 12 | { 13 | "identity" : "cwlpreconditiontesting", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", 16 | "state" : { 17 | "revision" : "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071", 18 | "version" : "2.2.2" 19 | } 20 | }, 21 | { 22 | "identity" : "isocountrycodes", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/funky-monkey/IsoCountryCodes.git", 25 | "state" : { 26 | "revision" : "f6672549f8d99bb6a8c7bc5233bc8630bb92c172", 27 | "version" : "1.0.3" 28 | } 29 | }, 30 | { 31 | "identity" : "nimble", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/Quick/Nimble.git", 34 | "state" : { 35 | "revision" : "7795df4fff1a9cd231fe4867ae54f4dc5f5734f9", 36 | "version" : "13.7.1" 37 | } 38 | }, 39 | { 40 | "identity" : "quick", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/Quick/Quick.git", 43 | "state" : { 44 | "revision" : "26529ff2209c40ae50fd642b031f930d9d68ea02", 45 | "version" : "7.5.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-argument-parser", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/apple/swift-argument-parser.git", 52 | "state" : { 53 | "branch" : "main", 54 | "revision" : "d8a9695190c81a95ce7a59a65891b186939c31bf" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-atomics", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/apple/swift-atomics.git", 61 | "state" : { 62 | "branch" : "main", 63 | "revision" : "d61ca105d6fb41e00dae7d9f0c8db44a715516f8" 64 | } 65 | }, 66 | { 67 | "identity" : "version", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/mxcl/Version.git", 70 | "state" : { 71 | "revision" : "303a0f916772545e1e8667d3104f83be708a723c", 72 | "version" : "2.1.0" 73 | } 74 | } 75 | ], 76 | "version" : 2 77 | } 78 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "mas", 7 | platforms: [ 8 | .macOS(.v10_15), 9 | ], 10 | products: [ 11 | .executable( 12 | name: "mas", 13 | targets: ["mas"] 14 | ), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/Quick/Nimble.git", from: "13.7.1"), 18 | .package(url: "https://github.com/Quick/Quick.git", exact: "7.5.0"), 19 | .package(url: "https://github.com/apple/swift-argument-parser.git", branch: "main"), 20 | .package(url: "https://github.com/apple/swift-atomics.git", branch: "main"), 21 | .package(url: "https://github.com/funky-monkey/IsoCountryCodes.git", from: "1.0.3"), 22 | .package(url: "https://github.com/mxcl/Version.git", from: "2.1.0"), 23 | ], 24 | targets: [ 25 | .executableTarget( 26 | name: "mas", 27 | dependencies: [ 28 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 29 | .product(name: "Atomics", package: "swift-atomics"), 30 | "IsoCountryCodes", 31 | "Version", 32 | ], 33 | swiftSettings: [ 34 | .enableExperimentalFeature("AccessLevelOnImport"), 35 | .enableExperimentalFeature("StrictConcurrency"), 36 | .unsafeFlags([ 37 | "-I", "Sources/PrivateFrameworks/CommerceKit", 38 | "-I", "Sources/PrivateFrameworks/StoreFoundation", 39 | ]), 40 | ], 41 | linkerSettings: [ 42 | .linkedFramework("CommerceKit"), 43 | .linkedFramework("StoreFoundation"), 44 | .unsafeFlags(["-F", "/System/Library/PrivateFrameworks"]), 45 | ] 46 | ), 47 | .testTarget( 48 | name: "masTests", 49 | dependencies: ["mas", "Nimble", "Quick"], 50 | resources: [.copy("Resources")], 51 | swiftSettings: [ 52 | .enableExperimentalFeature("AccessLevelOnImport"), 53 | .enableExperimentalFeature("StrictConcurrency"), 54 | .unsafeFlags([ 55 | "-I", "Sources/PrivateFrameworks/CommerceKit", 56 | "-I", "Sources/PrivateFrameworks/StoreFoundation", 57 | ]), 58 | ] 59 | ), 60 | ], 61 | swiftLanguageVersions: [.v5] 62 | ) 63 | -------------------------------------------------------------------------------- /Scripts/_setup_script: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/_setup_script 4 | # mas 5 | # 6 | # Boilerplate setup for scripts. 7 | # 8 | 9 | builtin unalias -as 10 | setopt\ 11 | autopushd\ 12 | combiningchars\ 13 | extendedglob\ 14 | extendedhistory\ 15 | no_globalrcs\ 16 | histexpiredupsfirst\ 17 | histignorespace\ 18 | histverify\ 19 | incappendhistorytime\ 20 | interactivecomments\ 21 | pipefail\ 22 | no_rcs\ 23 | no_unset 24 | export HISTCHARS='!^#' 25 | export IFS=$' \t\n\0' 26 | export NULLCMD=cat 27 | export PAGER=cat 28 | export READNULLCMD=cat 29 | export TMPDIR="${"${TMPDIR:-/tmp/}"/%(#b)([^\/])/"${match[1]}"/}" 30 | export TMPPREFIX="${TMPPREFIX:-"${TMPDIR}"zsh}" 31 | unset CDPATH 32 | unset ENV 33 | unset KEYBOARD_HACK 34 | unset TMPSUFFIX 35 | unset WORDCHARS 36 | 37 | mas_dir="${0:a:h:h}" 38 | 39 | if ! cd -- "${mas_dir}"; then 40 | printf $'Error: Failed to cd into mas directory: %s\n' "${mas_dir}" >&2 41 | exit 1 42 | fi 43 | 44 | print_header() { 45 | if [[ -t 1 ]]; then 46 | printf $'\e[1;34m==>\e[0m' 47 | else 48 | printf $'==>' 49 | fi 50 | printf $' %s' "${@}" 51 | printf $'\n' 52 | } 53 | -------------------------------------------------------------------------------- /Scripts/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/bootstrap 4 | # mas 5 | # 6 | # Installs dependencies for Scripts/format & Scripts/lint. 7 | # 8 | # Usage: bootstrap [...] 9 | # 10 | 11 | . "${0:a:h}/_setup_script" 12 | 13 | print_header '👢 Bootstrapping mas' "$(Scripts/version)" 14 | 15 | if ! whence brew >/dev/null; then 16 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 17 | fi 18 | 19 | brew update 20 | brew bundle install -q "${@}" 21 | -------------------------------------------------------------------------------- /Scripts/build: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/build 4 | # mas 5 | # 6 | # Builds the Swift Package. 7 | # 8 | 9 | . "${0:a:h}/_setup_script" 10 | 11 | print_header '🏗​ Building mas' "$(Scripts/version)" 12 | 13 | Scripts/generate_package_swift "${1:-}" 14 | 15 | swift build -c release "${@:2}" 16 | -------------------------------------------------------------------------------- /Scripts/clean: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/clean 4 | # mas 5 | # 6 | # Deletes the build directory & other generated files. 7 | # 8 | 9 | . "${0:a:h}/_setup_script" 10 | 11 | print_header '🗑​ Cleaning mas' "$(Scripts/version)" 12 | 13 | swift package clean 14 | swift package reset 15 | rm -f Sources/mas/Package.swift 16 | -------------------------------------------------------------------------------- /Scripts/format: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/format 4 | # mas 5 | # 6 | # Automatically formats & fixes style violations using various tools. 7 | # 8 | # Please keep in sync with Scripts/lint. 9 | # 10 | 11 | . "${0:a:h}/_setup_script" 12 | 13 | print_header '🧹 Formatting mas' "$(Scripts/version)" 14 | 15 | for formatter in markdownlint-cli2 swiftformat swiftlint; do 16 | if ! whence "${formatter}" >/dev/null; then 17 | printf $'error: %s is not installed. Run \'Scripts/bootstrap\' or \'brew install %s\'.\n' "${formatter}" "${formatter}" >&2 18 | exit 1 19 | fi 20 | done 21 | 22 | for source in Package.swift Sources Tests; do 23 | printf -- $'--> 🦅 %s swiftformat\n' "${source}" 24 | script -q /dev/null swiftformat --strict "${source}" | 25 | (grep -vxE '(?:\^D\x08{2})?Running SwiftFormat\.{3}\r|Reading (?:config|swift-version) file at .*|\x1b\[32mSwiftFormat completed in \d+(?:\.\d+)?s\.\x1b\[0m\r|0/\d+ files formatted\.\r' || true) 26 | printf -- $'--> 🦅 %s swiftlint\n' "${source}" 27 | swiftlint --fix --quiet --reporter relative-path "${source}" 28 | done 29 | 30 | printf -- $'--> 〽️ Markdown\n' 31 | markdownlint-cli2 --fix '**/*.md' 32 | -------------------------------------------------------------------------------- /Scripts/generate_package_swift: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/generate_package_swift 4 | # mas 5 | # 6 | # Generates a file to provide the mas version to Swift code. 7 | # 8 | 9 | . "${0:a:h}/_setup_script" 10 | 11 | # Write version to Swift singleton 12 | cat <Sources/mas/Package.swift 13 | // 14 | // Package.swift 15 | // mas 16 | // 17 | // Copyright © $(date '+%Y') mas-cli. All rights reserved. 18 | // 19 | 20 | enum Package { 21 | static let version = "$(Scripts/version)" 22 | static let installMethod = "${1:-unknown}" 23 | static let gitOrigin = "$(git remote get-url origin)" 24 | static let gitRevision = "$(git rev-parse HEAD)" 25 | static let swiftVersion = "$(printf %s "${${$(swift --version 2>/dev/null)#Apple Swift version }%%$'\n'*}")" 26 | static let swiftDriverVersion = "$(printf %s "${${$((swift --version 3>&2 2>&1 1>&3) 2>/dev/null)#swift-driver version: }% }")" 27 | } 28 | EOF 29 | -------------------------------------------------------------------------------- /Scripts/generate_token: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/generate_token 4 | # mas 5 | # 6 | # Generates a GitHub App installation access token for GitHub Workflows. 7 | # 8 | 9 | . "${0:a:h}/_setup_script" 10 | 11 | header=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 12 | payload="${${$(printf '{"iss":%s,"iat":%s,"exp":%s}' "${TOKEN_APP_ID}" "$(("$(date +%s)" - 60))"\ 13 | "$(("$(date +%s)" + 540))" | base64)//[=$'\n']}//\/+/_-}" 14 | 15 | 16 | # shellcheck disable=SC1009,SC1036,SC1072,SC1073 17 | curl\ 18 | -sX POST\ 19 | -H "Authorization: Bearer ${header}.${payload}.${${$(printf %s "${header}.${payload}" | 20 | openssl dgst -sha256 -sign =(printf %s "${TOKEN_APP_PRIVATE_KEY}") | base64)//[=$'\n']}//\/+/_-}"\ 21 | -H 'Accept: application/vnd.github+json'\ 22 | "https://api.github.com/app/installations/${TOKEN_APP_INSTALLATION_ID}/access_tokens" | 23 | jq -r .token 24 | -------------------------------------------------------------------------------- /Scripts/lint: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndfgku 2 | # 3 | # Scripts/lint 4 | # mas 5 | # 6 | # Linting checks for development & CI. 7 | # 8 | # Reports style violations without making any modifications to the code. 9 | # 10 | # Please keep in sync with Scripts/format. 11 | # 12 | 13 | . "${0:a:h}/_setup_script" 14 | 15 | print_header '🚨 Linting mas' "$(Scripts/version)" 16 | 17 | Scripts/generate_package_swift lint 18 | 19 | for linter in git markdownlint-cli2 periphery shellcheck swiftformat swiftlint yamllint; do 20 | if ! whence "${linter}" >/dev/null; then 21 | printf $'error: %s is not installed. Run \'Scripts/bootstrap\' or \'brew install %s\'.\n' "${linter}" "${linter}" >&2 22 | exit 1 23 | fi 24 | done 25 | 26 | exit_code=0 27 | for source in Package.swift Sources Tests; do 28 | printf -- $'--> 🦅 %s swiftformat\n' "${source}" 29 | script -q /dev/null swiftformat --lint "${source}" | 30 | (grep -vxE '(?:\^D\x08{2})?Running SwiftFormat\.{3}\r|\(lint mode - no files will be changed\.\)\r|Reading (?:config|swift-version) file at .*|\x1b\[32mSwiftFormat completed in \d+(?:\.\d+)?s\.\x1b\[0m\r|0/\d+ files require formatting\.\r|Source input did not pass lint check\.\r' || true) 31 | ((exit_code |= ${?})) 32 | printf -- $'--> 🦅 %s swiftlint\n' "${source}" 33 | swiftlint --strict --quiet --reporter relative-path "${source}" 34 | ((exit_code |= ${?})) 35 | done 36 | 37 | printf -- $'--> 🐚 ShellCheck\n' 38 | shellcheck -s bash -o all -e SC1088,SC1102,SC2066,SC2296,SC2299,SC2300,SC2301,SC2312 -a -P SCRIPTDIR Scripts/**/*(.) 39 | ((exit_code |= ${?})) 40 | 41 | printf -- $'--> 💤 zsh syntax\n' 42 | for script in Scripts/**/*(.); do 43 | /bin/zsh -n "${script}" 44 | ((exit_code |= ${?})) 45 | done 46 | 47 | printf -- $'--> 〽️ Markdown\n' 48 | markdownlint-cli2 '**/*.md' 49 | ((exit_code |= ${?})) 50 | 51 | printf -- $'--> 📝 YAML\n' 52 | yamllint -s . 53 | ((exit_code |= ${?})) 54 | 55 | printf -- $'--> 🌳 Git\n' 56 | git diff --check 57 | ((exit_code |= ${?})) 58 | 59 | printf -- $'--> 🌀 Periphery\n' 60 | script -q /dev/null periphery scan | 61 | (grep -vxE '(?:\x1b\[0;1;32m|\^D\x08{2})\* (?:\x1b\[0;0m\x1b\[0;1m)?No unused code detected\.(?:\x1b\[0;0m)?\r' || true) 62 | ((exit_code |= ${?})) 63 | 64 | exit "${exit_code}" 65 | -------------------------------------------------------------------------------- /Scripts/package: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/package 4 | # mas 5 | # 6 | # Builds .pkg installer. 7 | # 8 | 9 | . "${0:a:h}/_setup_script" 10 | 11 | Scripts/build "${1:-}" --arch arm64 --arch x86_64 12 | 13 | build_dir=.build 14 | destination_root="${build_dir}/destination" 15 | version="$(Scripts/version)" 16 | 17 | print_header '📦 Packaging installer for mas' "${version}" 18 | 19 | ditto -v "${build_dir}/apple/Products/Release/mas" "${destination_root}/mas" 20 | 21 | pkgbuild\ 22 | --identifier io.github.mas-cli\ 23 | --install-location /usr/local/bin\ 24 | --version "${version}"\ 25 | --root "${destination_root}"\ 26 | "${build_dir}/mas_components.pkg" 27 | 28 | # shellcheck disable=SC1036 29 | productbuild\ 30 | --distribution =(cat <<'END' 31 | 32 | 33 | 34 | 35 | 36 | #mas_components.pkg 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | END 55 | )\ 56 | --package-path "${build_dir}"\ 57 | "${build_dir}/mas-${version}.pkg" 58 | -------------------------------------------------------------------------------- /Scripts/release_cancel: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/release_cancel 4 | # mas 5 | # 6 | # Cancel a GitHub draft release. 7 | # 8 | # Usage: release_cancel 9 | # 10 | 11 | . "${0:a:h}/_setup_script" 12 | 13 | tag="${1}" 14 | 15 | print_header '❌ Canceling release for tag' "${tag}" 16 | printf $'\n' 17 | 18 | bump_url="$(gh release -R https://github.com/mas-cli/mas download "${tag}" -p bump.url -O - 2>/dev/null || true)" 19 | if [[ -n "${bump_url}" ]]; then 20 | gh pr close "${bump_url}" -d 21 | printf $'\n' 22 | else 23 | printf $'No custom tap formula bump PR URL found for draft release tag \'%s\'\n\n' "${tag}" 24 | fi 25 | 26 | gh release -R https://github.com/mas-cli/mas delete "${tag}" --cleanup-tag -y 27 | -------------------------------------------------------------------------------- /Scripts/release_start: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/release_start 4 | # mas 5 | # 6 | # Start the release process by creating & pushing a signed annotated version tag to the GitHub mas-cli/mas repo. 7 | # 8 | # Usage: release_start [] 9 | # 10 | # must match the following zsh pattern: 11 | # 12 | # ^v[[:digit:]]+(\.[[:digit:]]+)*(-(alpha|beta|rc)\.[[:digit:]]+)?$ 13 | # 14 | # must may contain at most 64 characters, which may be only visible characters or spaces 15 | # 16 | # if optional value supplied, must be on the main branch; defaults to HEAD 17 | # 18 | 19 | . "${0:a:h}/_setup_script" 20 | 21 | tag="${1}" 22 | title="${2}" 23 | ref="${3:-HEAD}" 24 | 25 | print_header '🚀 Starting release for mas' "${tag#v}" 26 | printf $'\n' 27 | 28 | if [[ ! "${tag}" =~ '^v[[:digit:]]+(\.[[:digit:]]+)*(-(alpha|beta|rc)\.[[:digit:]]+)?$' ]]; then 29 | printf $'\'%s\' is not a valid version tag\n' "${tag}" >&2 30 | exit 1 31 | fi 32 | 33 | if [[ "${#title}" -gt 64 ]]; then 34 | printf $'\'%s\' is too long for a version title, which may contain at most 64 characters\n' "${title}" >&2 35 | exit 2 36 | fi 37 | 38 | if [[ "${title}" =~ [[:cntrl:]$'\t\n\r'] ]]; then 39 | printf $'\'%s\' is not a valid version title, which may contain only visible characters or spaces\n' "${title}" >&2 40 | exit 3 41 | fi 42 | 43 | # shellcheck disable=SC1009,SC1027,SC1036,SC1072,SC1073 44 | if [[ "${title}" = (' '*|*' ') ]]; then 45 | printf $'\'%s\' is not a valid version title, which may not begin or end with a space\n' "${title}" >&2 46 | exit 4 47 | fi 48 | 49 | if ! git merge-base --is-ancestor "${ref}" upstream/main; then 50 | printf $'\'%s\' is not a valid reference for a version, which must be on the upstream/main branch\n' "${ref}" >&2 51 | exit 5 52 | fi 53 | 54 | git tag -s "${tag}" -m "${title}" "${ref}" 55 | 56 | printf $'Created version tag \'%s\' with title \'%s\' for \'%s\'\n\n' "${tag}" "${title}" "${ref}" 57 | 58 | git push upstream tag "${tag}" 59 | -------------------------------------------------------------------------------- /Scripts/setup_workflow_repo: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/setup_workflow_repo 4 | # mas 5 | # 6 | # Sets up the repo for use in a GitHub workflow. 7 | # 8 | 9 | . "${0:a:h}/_setup_script" 10 | 11 | for branch in "${(f)"$(git for-each-ref refs/remotes/origin --format='%(if)%(symref)%(then)%(else)%(refname:strip=-1)%(end)')":#}"; do 12 | git branch --track "${branch}" "origin/${branch}" >/dev/null 2>&1 || true 13 | done 14 | -------------------------------------------------------------------------------- /Scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/test 4 | # mas 5 | # 6 | # Runs mas tests. 7 | # 8 | 9 | . "${0:a:h}/_setup_script" 10 | 11 | print_header '🧪 Testing mas' "$(Scripts/version)" 12 | 13 | Scripts/generate_package_swift test 14 | 15 | script -q /dev/null swift test "${@}" | 16 | (grep -vxE $'Test Suite \'.+\' (?:started|passed) at \\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3}\\.?\\r|Test Case \'-\\[.+\\]\' (?:started|(?:passed|skipped) \\(\\d+\\.\\d+ seconds\\))\\.\\r|.+:\\d+: -\\[.+\\] : Test was filtered out\\.\\r|\\s*Executed \\d+ tests?, with (?:\\d+ tests? skipped and )?0 failures \\(0 unexpected\\) in \\d+\\.\\d+ \\(\\d+\\.\\d+\\) seconds\\r' || true) 17 | -------------------------------------------------------------------------------- /Scripts/update_headers: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/update_headers 4 | # mas 5 | # 6 | # Runs class-dump to generate headers for Apple private frameworks. 7 | # 8 | 9 | . "${0:a:h}/_setup_script" 10 | 11 | if ! whence class-dump >/dev/null; then 12 | printf $'class-dump is not installed.\n\nDownload, build & install mas fork of class-dump from https://github.com/mas-cli/class-dump\n' >&2 13 | exit 1 14 | fi 15 | 16 | extract_private_framework_headers() { 17 | local framework_name="${1}" 18 | local directory="Sources/PrivateFrameworks/${framework_name}" 19 | mkdir -p "${directory}" 20 | class-dump -Ho "${directory}" "/System/Library/PrivateFrameworks/${framework_name}.framework" 21 | } 22 | 23 | extract_private_framework_headers CommerceKit 24 | extract_private_framework_headers StoreFoundation 25 | -------------------------------------------------------------------------------- /Scripts/version: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -Ndefgku 2 | # 3 | # Scripts/version 4 | # mas 5 | # 6 | # Outputs the mas version. 7 | # 8 | 9 | . "${0:a:h}/_setup_script" 10 | 11 | branch="${"$(git rev-parse --abbrev-ref HEAD)":/main}" 12 | 13 | if [[ "${branch}" = HEAD ]]; then 14 | if ! git show-ref --verify --quiet refs/heads/main || git merge-base --is-ancestor HEAD main; then 15 | branch= 16 | else 17 | branch="${"${(@)"${(fnO)"$(git branch --contains HEAD --format '%(ahead-behind:HEAD) %(refname:short)')"}"[1]}"##* }" 18 | fi 19 | fi 20 | 21 | printf $'%s%s%s\n'\ 22 | "${branch:+"${branch}-"}"\ 23 | "${"$(git describe --tags 2>/dev/null)"#v}"\ 24 | "${"$(git diff-index HEAD --;git ls-files --exclude-standard --others)":+"${MAS_DIRTY_INDICATOR-+}"}" 25 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/CommerceKit/CKDownloadDirectory.h: -------------------------------------------------------------------------------- 1 | // 2 | // CKDownloadDirectory.h 3 | // mas 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | NSString *CKDownloadDirectory(NSString * _Nullable target); 11 | 12 | NS_ASSUME_NONNULL_END 13 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/CommerceKit/CKDownloadQueue.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated by https://github.com/blacktop/ipsw (Version: 3.1.603, BuildCommit: Homebrew) 3 | // 4 | // - LC_BUILD_VERSION: Platform: macOS, MinOS: 15.5, SDK: 15.5, Tool: ld (1167.3) 5 | // - LC_SOURCE_VERSION: 715.5.1.0.0 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | @interface CKDownloadQueue : CKServiceInterface { 11 | NSMutableDictionary *_downloadsByItemID; 12 | NSLock *_downloadsLock; 13 | id _observerToken NS_AVAILABLE_MAC(13); 14 | NSLock *_tokenLock NS_AVAILABLE_MAC(13); 15 | } 16 | 17 | + (instancetype)sharedDownloadQueue; 18 | 19 | @property(retain, nonatomic) NSMutableDictionary *downloadQueueObservers; 20 | @property(readonly, nonatomic) NSArray *downloads; 21 | @property(retain, nonatomic) CKDownloadQueueClient *sharedObserver; 22 | 23 | - (void)addDownload:(SSDownload *)download; 24 | - (id)addObserver:(id)observer; 25 | - (id)addObserver:(id)observer forDownloadTypes:(long long)downloadTypes; 26 | - (id)addObserverForDownloadTypes:(long long)downloadTypes withBlock:(UnknownBlock *)block; 27 | - (BOOL)cacheReceiptDataForDownload:(SSDownload *)download; 28 | - (void)cancelDownload:(SSDownload *)download promptToConfirm:(BOOL)promptToConfirm askToDelete:(BOOL)askToDelete; 29 | - (void)checkStoreDownloadQueueForAccount:(ISStoreAccount *)account; 30 | - (void)connectionWasInterrupted; 31 | - (SSDownload *)downloadForItemIdentifier:(unsigned long long)identifier; 32 | - (void)fetchIconForItemIdentifier:(unsigned long long)identifier atURL:(NSURL *)url replyBlock:(UnknownBlock *)block; 33 | - (instancetype)initWithStoreClient:(ISStoreClient *)client; 34 | - (void)lockApplicationsForBundleID:(NSString *)bundleID; 35 | - (void)lockedApplicationTriedToLaunchAtPath:(NSString *)path; 36 | - (void)pauseDownloadWithItemIdentifier:(unsigned long long)identifier; 37 | - (void)performedIconAnimationForDownloadWithIdentifier:(unsigned long long)identifier; 38 | - (void)removeDownloadWithItemIdentifier:(unsigned long long)identifier; 39 | - (void)removeObserver:(id)observer; 40 | - (void)resumeDownloadWithItemIdentifier:(unsigned long long)identifier; 41 | - (void)unlockApplicationsWithBundleIdentifier:(NSString *)bundleID; 42 | 43 | @end 44 | 45 | NS_ASSUME_NONNULL_END 46 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/CommerceKit/CKDownloadQueueObserver-Protocol.h: -------------------------------------------------------------------------------- 1 | // 2 | // CKDownloadQueueObserver-Protocol.h 3 | // mas 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | @protocol CKDownloadQueueObserver 11 | 12 | @required 13 | 14 | - (void)downloadQueue:(CKDownloadQueue *)downloadQueue changedWithAddition:(SSDownload *)download; 15 | - (void)downloadQueue:(CKDownloadQueue *)downloadQueue changedWithRemoval:(SSDownload *)download; 16 | - (void)downloadQueue:(CKDownloadQueue *)downloadQueue statusChangedForDownload:(SSDownload *)download; 17 | 18 | @optional 19 | 20 | @end 21 | 22 | NS_ASSUME_NONNULL_END 23 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/CommerceKit/CKPurchaseController.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated by https://github.com/blacktop/ipsw (Version: 3.1.603, BuildCommit: Homebrew) 3 | // 4 | // - LC_BUILD_VERSION: Platform: macOS, MinOS: 15.5, SDK: 15.5, Tool: ld (1167.3) 5 | // - LC_SOURCE_VERSION: 715.5.1.0.0 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | typedef void (^SSPurchaseCompletion)(SSPurchase * _Nullable purchase, BOOL completed, NSError * _Nullable error, SSPurchaseResponse * _Nullable response); 11 | 12 | @interface CKPurchaseController : CKServiceInterface { 13 | NSArray *_adoptionEligibleItems; 14 | NSNumber *_adoptionErrorNumber; 15 | NSNumber *_adoptionServerStatus; 16 | NSMutableArray *_purchases; 17 | NSMutableArray *_rejectedPurchases; 18 | } 19 | 20 | + (void)setNeedsSilentMachineAuthorization:(BOOL)needsSilentMachineAuthorization; 21 | + (instancetype)sharedPurchaseController; 22 | 23 | @property(copy) UnknownBlock *dialogHandler; 24 | 25 | - (void)_performVPPReceiptRenewal; 26 | - (BOOL)adoptionCompletedForBundleID:(NSString *)bundleID; 27 | - (void)cancelPurchaseWithProductID:(NSNumber *)productID; 28 | - (void)checkServerDownloadQueue; 29 | - (void)performPurchase:(SSPurchase *)purchase withOptions:(unsigned long long)options completionHandler:(nullable SSPurchaseCompletion)handler; 30 | - (SSPurchase *)purchaseInProgressForProductID:(NSNumber *)productID; 31 | - (NSArray *)purchasesInProgress; 32 | - (void)resumeDownloadForPurchasedProductID:(NSNumber *)productID; 33 | - (void)startPurchases:(NSArray *)purchases shouldStartDownloads:(BOOL)shouldStartDownloads eventHandler:(UnknownBlock *)handler; 34 | - (void)startPurchases:(NSArray *)purchases withOptions:(unsigned long long)options completionHandler:(UnknownBlock *)handler; 35 | 36 | @end 37 | 38 | NS_ASSUME_NONNULL_END 39 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/CommerceKit/CKServiceInterface.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated by https://github.com/blacktop/ipsw (Version: 3.1.603, BuildCommit: Homebrew) 3 | // 4 | // - LC_BUILD_VERSION: Platform: macOS, MinOS: 15.5, SDK: 15.5, Tool: ld (1167.3) 5 | // - LC_SOURCE_VERSION: 715.5.1.0.0 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | @interface CKServiceInterface : ISServiceProxy 11 | 12 | @end 13 | 14 | NS_ASSUME_NONNULL_END 15 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/CommerceKit/CommerceKit.h: -------------------------------------------------------------------------------- 1 | // 2 | // CommerceKit.h 3 | // mas 4 | // 5 | // Copyright © 2025 mas-cli. All rights reserved. 6 | // 7 | 8 | @import StoreFoundation; 9 | 10 | @class CKDownloadQueueClient; 11 | 12 | @protocol CKDownloadQueueObserver; 13 | 14 | #import 15 | #import 16 | #import 17 | #import 18 | #import 19 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/CommerceKit/module.modulemap: -------------------------------------------------------------------------------- 1 | module CommerceKit [no_undeclared_includes] { 2 | requires macos, objc 3 | use StoreFoundation 4 | private header "CommerceKit.h" 5 | export * 6 | } 7 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/StoreFoundation/ISAccountService-Protocol.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated by https://github.com/blacktop/ipsw (Version: 3.1.603, BuildCommit: Homebrew) 3 | // 4 | // - LC_BUILD_VERSION: Platform: macOS, MinOS: 15.5, SDK: 15.5, Tool: ld (1167.3) 5 | // - LC_SOURCE_VERSION: 715.5.1.0.0 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | @protocol ISAccountService 11 | 12 | @required 13 | 14 | - (void)accountWithAppleID:(NSString *)appleID replyBlock:(void (^)(ISStoreAccount *))block; 15 | - (void)accountWithDSID:(NSNumber *)dsID replyBlock:(void (^)(ISStoreAccount *))block; 16 | - (void)addAccount:(ISStoreAccount *)account; 17 | - (void)addAccountStoreObserver:(id)observer; 18 | - (void)addAccountWithAuthenticationResponse:(ISAuthenticationResponse *)authenticationResponse makePrimary:(BOOL)makePrimary replyBlock:(void (^)(ISStoreAccount *))block NS_DEPRECATED_MAC(10_9, 12); 19 | - (void)addURLBagObserver:(id)observer; 20 | - (void)authIsExpiredWithReplyBlock:(void (^)(BOOL))block; 21 | - (void)dictionaryForDSID:(NSNumber *)dsID withReplyBlock:(void (^)(NSDictionary *))block NS_AVAILABLE_MAC(13); 22 | - (void)dictionaryWithReplyBlock:(void (^)(NSDictionary *))block; 23 | - (void)generateTouchIDHeadersForDSID:(NSNumber *)dsID challenge:(NSString *)challenge caller:(id)caller replyBlock:(void (^)(NSDictionary *, NSError *))block; 24 | - (void)getTouchIDPreferenceWithReplyBlock:(void (^)(BOOL, ISStoreAccount *, NSError *))block; 25 | - (void)httpHeadersForURL:(NSURL *)url forDSID:(NSNumber *)dsID includeADIHeaders:(BOOL)includeADIHeaders withReplyBlock:(void (^)(NSDictionary *))block; 26 | - (void)iCloudDSIDReplyBlock:(void (^)(NSString *))block; 27 | - (void)invalidateAllBags; 28 | - (void)isValidWithReplyBlock:(void (^)(BOOL))block; 29 | - (void)loadURLBagWithType:(unsigned long long)type replyBlock:(void (^)(BOOL, BOOL, NSError *))block; 30 | - (void)needsSilentADIActionForURL:(NSURL *)url dsID:(NSNumber *)dsID withReplyBlock:(void (^)(BOOL))block NS_AVAILABLE_MAC(13); 31 | - (void)needsSilentADIActionForURL:(NSURL *)url withReplyBlock:(void (^)(BOOL))block; 32 | - (void)parseCreditStringForProtocol:(NSDictionary *)dictionary NS_DEPRECATED_MAC(10_9, 12); 33 | - (void)primaryAccountWithReplyBlock:(void (^)(ISStoreAccount *))block; 34 | - (void)processURLResponse:(NSURLResponse *)urlResponse forRequest:(NSURLRequest *)request; 35 | - (void)processURLResponse:(NSURLResponse *)urlResponse forRequest:(NSURLRequest *)request dsID:(NSNumber *)dsID NS_AVAILABLE_MAC(13); 36 | - (void)recommendedAppleIDForAccountSignIn:(void (^)(NSString *))accountSignIn NS_DEPRECATED_MAC(10_9, 12); 37 | - (void)regexWithKey:(NSString *)key dsID:(NSNumber *)dsID matchesString:(NSString *)string replyBlock:(void (^)(BOOL))block NS_AVAILABLE_MAC(13); 38 | - (void)regexWithKey:(NSString *)key matchesString:(NSString *)string replyBlock:(void (^)(BOOL))block; 39 | - (void)removeAccountStoreObserver:(id)observer; 40 | - (void)removeURLBagObserver:(id)observer; 41 | - (void)retailStoreDemoModeReplyBlock:(void (^)(BOOL, NSString *, NSString *, BOOL))block; 42 | - (void)setStoreFrontID:(NSString *)storefrontID; 43 | - (void)setTouchIDState:(long long)touchIDState forDSID:(NSNumber *)dsID replyBlock:(void (^)(BOOL, NSError *))block; 44 | - (void)shouldSendGUIDWithRequestForURL:(NSURL *)url withReplyBlock:(void (^)(BOOL))block; 45 | - (void)signInWithContext:(ISAuthenticationContext *)context replyBlock:(void (^)(BOOL, ISStoreAccount * _Nullable, NSError * _Nullable))block NS_DEPRECATED_MAC(10_9, 10_12); 46 | - (void)signOut; 47 | - (void)storeFrontWithReplyBlock:(void (^)(NSString *))block; 48 | - (void)updateTouchIDSettingsForDSID:(NSNumber *)dsID replyBlock:(void (^)(BOOL, NSError *))block; 49 | - (void)urlIsTrustedByURLBag:(NSURL *)urlBag dsID:(NSNumber *)dsID withReplyBlock:(void (^)(BOOL))block NS_AVAILABLE_MAC(13); 50 | - (void)urlIsTrustedByURLBag:(NSURL *)urlBag withReplyBlock:(void (^)(BOOL))block; 51 | - (void)valueForURLBagKey:(NSString *)bagKey dsID:(NSNumber *)dsID withReplyBlock:(void (^)(NSURL *))block NS_AVAILABLE_MAC(13); 52 | - (void)valueForURLBagKey:(NSString *)bagKey withReplyBlock:(void (^)(NSURL *))block; 53 | 54 | @optional 55 | 56 | @end 57 | 58 | NS_ASSUME_NONNULL_END 59 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/StoreFoundation/ISServiceProxy.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated by https://github.com/blacktop/ipsw (Version: 3.1.603, BuildCommit: Homebrew) 3 | // 4 | // - LC_BUILD_VERSION: Platform: macOS, MinOS: 15.5, SDK: 15.5, Tool: ld (1167.3) 5 | // - LC_SOURCE_VERSION: 715.5.1.0.0 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | @interface ISServiceProxy : NSObject 11 | 12 | + (instancetype)genericSharedProxy; 13 | + (void)initialize; 14 | 15 | @property(readonly, nonatomic) id accountService; 16 | @property(readonly, nonatomic) id assetService; 17 | @property(readonly, nonatomic) id downloadService; 18 | @property(readonly, nonatomic, weak) id exportedObject; 19 | @property(readonly, nonatomic) Protocol *exportedProtocol; 20 | @property(readonly, nonatomic) id inAppService NS_DEPRECATED_MAC(10_9, 12); 21 | @property(retain, nonatomic) ISStoreClient *storeClient; 22 | @property(readonly, nonatomic) id transactionService; 23 | @property(readonly, nonatomic) id uiService; 24 | 25 | - (void)accountServiceSynchronousBlock:(UnknownBlock *)block; 26 | - (id)accountServiceWithErrorHandler:(UnknownBlock *)handler; 27 | - (void)assetServiceSynchronousBlock:(UnknownBlock *)block; 28 | - (id)assetServiceWithErrorHandler:(UnknownBlock *)handler; 29 | - (void)connectionWasInterrupted; 30 | - (id)connectionWithServiceName:(NSString *)serviceName protocol:(id)protocol isMachService:(BOOL)isMachService; 31 | - (void)downloadServiceSynchronousBlock:(UnknownBlock *)block; 32 | - (id)downloadServiceWithErrorHandler:(UnknownBlock *)handler; 33 | - (void)inAppServiceSynchronousBlock:(UnknownBlock *)block NS_DEPRECATED_MAC(10_9, 12); 34 | - (id)inAppServiceWithErrorHandler:(UnknownBlock *)handler NS_DEPRECATED_MAC(10_9, 12); 35 | - (instancetype)initWithStoreClient:(ISStoreClient *)client; 36 | - (id)objectProxyForServiceName:(NSString *)serviceName protocol:(id)protocol interfaceClassName:(NSString *)interfaceClassName isMachService:(BOOL)isMachService errorHandler:(UnknownBlock *)handler; 37 | - (void)performSynchronousBlock:(UnknownBlock *)block withServiceName:(NSString *)serviceName protocol:(id)protocol isMachService:(BOOL)isMachService interfaceClassName:(NSString *)interfaceClassName; 38 | - (void)registerForInterrptionNotification; 39 | - (void)transactionServiceSynchronousBlock:(UnknownBlock *)block; 40 | - (id)transactionServiceWithErrorHandler:(UnknownBlock *)handler; 41 | - (void)uiServiceSynchronousBlock:(UnknownBlock *)block; 42 | - (id)uiServiceWithErrorHandler:(UnknownBlock *)handler; 43 | 44 | @end 45 | 46 | NS_ASSUME_NONNULL_END 47 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/StoreFoundation/ISStoreAccount.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated by https://github.com/blacktop/ipsw (Version: 3.1.603, BuildCommit: Homebrew) 3 | // 4 | // - LC_BUILD_VERSION: Platform: macOS, MinOS: 15.5, SDK: 15.5, Tool: ld (1167.3) 5 | // - LC_SOURCE_VERSION: 715.5.1.0.0 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | @interface ISStoreAccount : NSObject { 11 | NSTimer *_tokenInvalidTimer; 12 | } 13 | 14 | + (NSNumber *)dsidFromPlistValue:(id)value; 15 | + (NSDictionary *)migratePersistedStoreDictionary:(NSDictionary *)dictionary; 16 | + (BOOL)supportsSecureCoding; 17 | 18 | @property long long URLBagType; 19 | @property(readonly, getter=isAuthenticated) BOOL authenticated; 20 | @property(copy) NSString *creditString; 21 | @property(copy) NSNumber *dsID; 22 | @property(copy) NSString *identifier; 23 | @property BOOL isManagedStudent; 24 | @property BOOL isSignedIn; 25 | @property long long kind; 26 | @property(copy) NSString *password; 27 | @property(readonly, getter=isPrimary) BOOL primary; 28 | @property(retain) NSString *storeFront; 29 | @property(copy) NSString *token; 30 | @property(retain) NSTimer *tokenExpirationTimer; 31 | @property(retain) NSDate *tokenIssuedDate; 32 | @property long long touchIDState; 33 | 34 | - (NSString *)description; 35 | - (void)encodeWithCoder:(NSCoder *)coder; 36 | - (long long)getTouchIDState; 37 | - (BOOL)hasValidStrongToken; 38 | - (instancetype)initWithCoder:(NSCoder *)coder; 39 | - (instancetype)initWithPersistedStoreDictionary:(NSDictionary *)dictionary; 40 | - (void)mergeValuesFromAuthenticationResponse:(ISAuthenticationResponse *)response; 41 | - (NSDictionary *)persistedStoreDictionary; 42 | - (void)resetTouchIDState; 43 | - (double)strongTokenValidForSecond; 44 | 45 | @end 46 | 47 | NS_ASSUME_NONNULL_END 48 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/StoreFoundation/SSDownload.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated by https://github.com/blacktop/ipsw (Version: 3.1.603, BuildCommit: Homebrew) 3 | // 4 | // - LC_BUILD_VERSION: Platform: macOS, MinOS: 15.5, SDK: 15.5, Tool: ld (1167.3) 5 | // - LC_SOURCE_VERSION: 715.5.1.0.0 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | @interface SSDownload : NSObject { 11 | BOOL _needsPreInstallValidation; 12 | } 13 | 14 | + (BOOL)supportsSecureCoding; 15 | 16 | @property(copy) NSNumber *accountDSID; 17 | @property(copy, nonatomic) NSArray *assets; 18 | @property(copy) NSString *cancelURLString; 19 | @property(copy) NSString *customDownloadPath; 20 | @property BOOL didAutoUpdate; 21 | @property unsigned long long downloadType; 22 | @property BOOL installAfterLogout; 23 | @property(copy) NSString *installPath; 24 | @property BOOL isInServerQueue; 25 | @property(copy, nonatomic) SSDownloadMetadata *metadata; 26 | @property BOOL needsDisplayInDock; 27 | @property(copy) NSURL *relaunchAppWithBundleURL; 28 | @property BOOL skipAssetDownloadIfNotAlreadyOnDisk; 29 | @property BOOL skipInstallPhase; 30 | @property(retain, nonatomic) SSDownloadStatus *status; 31 | 32 | - (void)cancel; 33 | - (void)cancelWithPrompt:(BOOL)prompt; 34 | - (void)cancelWithPrompt:(BOOL)prompt storeClient:(ISStoreClient *)client; 35 | - (void)cancelWithStoreClient:(ISStoreClient *)client; 36 | - (void)encodeWithCoder:(NSCoder *)coder; 37 | - (instancetype)init; 38 | - (instancetype)initWithAssets:(NSArray *)assets metadata:(SSDownloadMetadata *)metadata; 39 | - (instancetype)initWithCoder:(NSCoder *)coder; 40 | - (BOOL)isEqual:(nullable id)object; 41 | - (void)pause; 42 | - (void)pauseWithStoreClient:(ISStoreClient *)client; 43 | - (ISAsset *)primaryAsset; 44 | - (void)resume; 45 | - (void)resumeWithStoreClient:(ISStoreClient *)client; 46 | - (void)setUseUniqueDownloadFolder:(BOOL)useUniqueDownloadFolder; 47 | 48 | @end 49 | 50 | NS_ASSUME_NONNULL_END 51 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/StoreFoundation/SSDownloadMetadata.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated by https://github.com/blacktop/ipsw (Version: 3.1.603, BuildCommit: Homebrew) 3 | // 4 | // - LC_BUILD_VERSION: Platform: macOS, MinOS: 15.5, SDK: 15.5, Tool: ld (1167.3) 5 | // - LC_SOURCE_VERSION: 715.5.1.0.0 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | @interface SSDownloadMetadata : NSObject { 11 | NSLock *_lock; 12 | } 13 | 14 | + (BOOL)supportsSecureCoding; 15 | 16 | @property(readonly, nullable) NSNumber *ageRestriction; 17 | @property BOOL animationExpected; 18 | @property(retain, nullable) NSString *appleID; 19 | @property(readonly, nullable) NSString *applicationIdentifier; 20 | @property BOOL artworkIsPrerendered; 21 | @property(readonly, nullable) NSArray *assets; 22 | @property(readonly, nullable) NSString *bundleDisplayName; 23 | @property(retain, nullable) NSString *bundleIdentifier; 24 | @property(readonly, nullable) NSString *bundleShortVersionString; 25 | @property(retain, nullable) NSString *bundleVersion; 26 | @property(retain, nullable) NSString *buyParameters; 27 | @property(readonly) NSNumber *collectionID NS_AVAILABLE_MAC(13); 28 | @property(retain, nullable) NSString *collectionName; 29 | @property(retain, nullable) NSDictionary *dictionary; 30 | @property(retain, nullable) NSString *downloadKey; 31 | @property(retain, nullable) NSNumber *durationInMilliseconds; 32 | @property(retain, nullable) NSData *epubRightsData; 33 | @property(readonly) BOOL extractionCanBeStreamed; 34 | @property(retain, nullable) NSString *fileExtension; 35 | @property(retain, nullable) NSString *genre; 36 | @property(readonly, nullable) NSNumber *iapContentSize; 37 | @property(readonly, nullable) NSString *iapContentVersion; 38 | @property(retain, nullable) NSString *iapInstallPath; 39 | @property(retain, nullable) NSData *ipaInstallBookmarkData NS_AVAILABLE_MAC(14); 40 | @property(retain, nullable) NSString *ipaInstallPath; 41 | @property(readonly) BOOL isExplicitContents; 42 | @property BOOL isMDMProvided; 43 | @property unsigned long long itemIdentifier; 44 | @property(retain, nullable) NSString *kind; 45 | @property(retain) NSString *managedAppUUIDString NS_AVAILABLE_MAC(13); 46 | @property(readonly) BOOL needsSoftwareInstallOperation; 47 | @property(retain, nullable) NSURL *preflightPackageURL; 48 | @property(retain, nullable) NSString *productType; 49 | @property(readonly, nullable) NSString *purchaseDate; 50 | @property(getter=isRental) BOOL rental; 51 | @property(readonly, getter=isSample) BOOL sample; 52 | @property(retain, nullable) NSArray *sinfs; 53 | @property(readonly, nullable) NSString *sortArtist NS_AVAILABLE_MAC(13); 54 | @property(readonly, nullable) NSString *sortName NS_AVAILABLE_MAC(13); 55 | @property(retain, nullable) NSString *subtitle; 56 | @property(retain, nullable) NSURL *thumbnailImageURL; 57 | @property(retain) NSString *title; 58 | @property(retain, nullable) NSString *transactionIdentifier; 59 | @property(readonly, nullable) NSNumber *uncompressedSize; 60 | @property(retain) NSNumber *version NS_AVAILABLE_MAC(13); 61 | 62 | - (nullable id)_valueForFirstAvailableKey:(NSString *)key; 63 | - (instancetype)copyWithZone:(nullable struct _NSZone *)zone; 64 | - (nullable id)deltaPackages; 65 | - (void)encodeWithCoder:(NSCoder *)coder; 66 | - (instancetype)init; 67 | - (instancetype)initWithCoder:(NSCoder *)coder; 68 | - (nullable instancetype)initWithDictionary:(NSDictionary *)dictionary; 69 | - (nullable instancetype)initWithKind:(NSString *)kind; 70 | - (nullable id)localServerInfo; 71 | - (void)setExtractionCanBeStreamed:(BOOL)extractionCanBeStreamed NS_DEPRECATED_MAC(10_9, 12); 72 | - (void)setUncompressedSize:(NSNumber *)uncompressedSize NS_DEPRECATED_MAC(10_9, 12); 73 | - (void)setValue:(nullable id)value forMetadataKey:(NSString *)key; 74 | 75 | @end 76 | 77 | NS_ASSUME_NONNULL_END 78 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/StoreFoundation/SSDownloadPhase.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated by https://github.com/blacktop/ipsw (Version: 3.1.603, BuildCommit: Homebrew) 3 | // 4 | // - LC_BUILD_VERSION: Platform: macOS, MinOS: 15.5, SDK: 15.5, Tool: ld (1167.3) 5 | // - LC_SOURCE_VERSION: 715.5.1.0.0 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | @interface SSDownloadPhase : NSObject 11 | 12 | + (BOOL)supportsSecureCoding; 13 | 14 | @property(readonly) double estimatedSecondsRemaining; 15 | @property(readonly) SSOperationProgress *operationProgress; 16 | @property(readonly) long long phaseType; 17 | @property(readonly) float progressChangeRate; 18 | @property(readonly) long long progressUnits; 19 | @property(readonly) long long progressValue; 20 | @property(readonly) long long totalProgressValue; 21 | 22 | - (instancetype)copyWithZone:(nullable struct _NSZone *)zone; 23 | - (void)encodeWithCoder:(NSCoder *)coder; 24 | - (instancetype)init; 25 | - (instancetype)initWithCoder:(NSCoder *)coder; 26 | - (instancetype)initWithOperationProgress:(SSOperationProgress *)progress; 27 | 28 | @end 29 | 30 | NS_ASSUME_NONNULL_END 31 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/StoreFoundation/SSDownloadStatus.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated by https://github.com/blacktop/ipsw (Version: 3.1.603, BuildCommit: Homebrew) 3 | // 4 | // - LC_BUILD_VERSION: Platform: macOS, MinOS: 15.5, SDK: 15.5, Tool: ld (1167.3) 5 | // - LC_SOURCE_VERSION: 715.5.1.0.0 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | @interface SSDownloadStatus : NSObject 11 | 12 | + (BOOL)supportsSecureCoding; 13 | 14 | @property(readonly, nonatomic) SSDownloadPhase *activePhase; 15 | @property(nonatomic, getter=isCancelled) BOOL cancelled; 16 | @property(retain, nonatomic) NSError *error; 17 | @property(nonatomic, getter=isFailed) BOOL failed; 18 | @property(readonly, nonatomic, getter=isPausable) BOOL pausable; 19 | @property(nonatomic, getter=isPaused) BOOL paused; 20 | @property(readonly, nonatomic) float percentComplete; 21 | @property(readonly, nonatomic) float phasePercentComplete; 22 | @property(readonly, nonatomic) long long phaseTimeRemaining; 23 | @property BOOL waiting; 24 | 25 | - (instancetype)copyWithZone:(nullable struct _NSZone *)zone; 26 | - (void)encodeWithCoder:(NSCoder *)coder; 27 | - (instancetype)initWithCoder:(NSCoder *)coder; 28 | - (void)setOperationProgress:(SSOperationProgress *)progress; 29 | 30 | @end 31 | 32 | NS_ASSUME_NONNULL_END 33 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/StoreFoundation/SSPurchase.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated by https://github.com/blacktop/ipsw (Version: 3.1.603, BuildCommit: Homebrew) 3 | // 4 | // - LC_BUILD_VERSION: Platform: macOS, MinOS: 15.5, SDK: 15.5, Tool: ld (1167.3) 5 | // - LC_SOURCE_VERSION: 715.5.1.0.0 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | @interface SSPurchase : NSObject 11 | 12 | + (instancetype)purchaseWithBuyParameters:(NSString *)buyParameters; 13 | + (NSDictionary *)purchasesGroupedByAccountIdentifierWithPurchases:(NSArray *)purchases; 14 | + (BOOL)supportsSecureCoding; 15 | 16 | @property(retain, nonatomic) NSNumber *accountIdentifier; 17 | @property(retain, nonatomic) NSString *appleID; 18 | @property(copy) UnknownBlock *authFallbackHandler; 19 | @property(copy, nonatomic) NSString *buyParameters; 20 | @property BOOL checkPreflightAterPurchase; 21 | @property(copy, nonatomic) SSDownloadMetadata *downloadMetadata; 22 | @property(retain) NSDictionary *dsidLessOptions; 23 | @property BOOL isCancelled; 24 | @property BOOL isDSIDLessPurchase; 25 | @property BOOL isRecoveryPurchase; 26 | @property BOOL isRedownload; 27 | @property BOOL isUpdate; 28 | @property BOOL isVPP; 29 | @property unsigned long long itemIdentifier; 30 | @property(retain, nonatomic) NSString *managedAppUUIDString NS_AVAILABLE_MAC(13); 31 | @property(readonly) BOOL needsAuthentication; 32 | @property(retain, nonatomic) NSString *parentalControls; 33 | @property(weak) ISOperation *purchaseOperation; 34 | @property(nonatomic) long long purchaseType; 35 | @property(retain, nonatomic) NSData *receiptData; 36 | @property(copy) NSDictionary *responseDialog; 37 | @property BOOL shouldBeInstalledAfterLogout; 38 | @property(readonly, nonatomic) NSString *sortableAccountIdentifier; 39 | @property(readonly, nonatomic) NSString *uniqueIdentifier; 40 | 41 | - (NSString *)_sortableAccountIdentifier; 42 | - (instancetype)copyWithZone:(nullable struct _NSZone *)zone; 43 | - (NSString *)description; 44 | - (void)encodeWithCoder:(NSCoder *)coder; 45 | - (instancetype)initWithCoder:(NSCoder *)coder; 46 | - (NSNumber *)productID; 47 | - (BOOL)purchaseDSIDMatchesPrimaryAccount; 48 | 49 | @end 50 | 51 | NS_ASSUME_NONNULL_END 52 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/StoreFoundation/SSPurchaseResponse.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated by https://github.com/blacktop/ipsw (Version: 3.1.603, BuildCommit: Homebrew) 3 | // 4 | // - LC_BUILD_VERSION: Platform: macOS, MinOS: 15.5, SDK: 15.5, Tool: ld (1167.3) 5 | // - LC_SOURCE_VERSION: 715.5.1.0.0 6 | // 7 | 8 | NS_ASSUME_NONNULL_BEGIN 9 | 10 | @interface SSPurchaseResponse : NSObject { 11 | NSDictionary *_rawResponse; 12 | } 13 | 14 | + (BOOL)supportsSecureCoding; 15 | 16 | @property(retain) NSArray *downloads; 17 | @property(retain) NSDictionary *metrics; 18 | 19 | - (NSArray *)_newDownloadsFromItems:(NSArray *)items withDSID:(NSNumber *)dsID; 20 | - (void)encodeWithCoder:(NSCoder *)coder; 21 | - (instancetype)initWithCoder:(NSCoder *)coder; 22 | - (instancetype)initWithDictionary:(NSDictionary *)dictionary userIdentifier:(NSString *)userIdentifier; 23 | 24 | @end 25 | 26 | NS_ASSUME_NONNULL_END 27 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/StoreFoundation/StoreFoundation.h: -------------------------------------------------------------------------------- 1 | // 2 | // StoreFoundation.h 3 | // mas 4 | // 5 | // Copyright © 2025 mas-cli. All rights reserved. 6 | // 7 | 8 | @import Foundation; 9 | 10 | @class ISAsset, ISAuthenticationContext, ISAuthenticationResponse, ISOperation, ISStoreClient, SSOperationProgress, UnknownBlock; 11 | 12 | @protocol ISAccountStoreObserver, ISAssetService, ISDownloadService, ISInAppService, ISServiceRemoteObject, ISTransactionService, ISUIService, ISURLBagObserver; 13 | 14 | #import 15 | #import 16 | #import 17 | #import 18 | #import 19 | #import 20 | #import 21 | #import 22 | #import 23 | -------------------------------------------------------------------------------- /Sources/PrivateFrameworks/StoreFoundation/module.modulemap: -------------------------------------------------------------------------------- 1 | module StoreFoundation [no_undeclared_includes] { 2 | requires macos, objc 3 | use Foundation 4 | private header "StoreFoundation.h" 5 | export * 6 | } 7 | -------------------------------------------------------------------------------- /Sources/mas/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # 2 | # .swiftlint.yml 3 | # mas 4 | # 5 | # SwiftLint 0.59.1 6 | # 7 | --- 8 | opt_in_rules: 9 | - file_header 10 | - file_name 11 | file_header: 12 | required_pattern: | 13 | \/\/ 14 | \/\/ SWIFTLINT_CURRENT_FILENAME 15 | \/\/ mas 16 | \/\/ 17 | \/\/ Copyright © 20\d{2} mas-cli. All rights reserved\. 18 | \/\/ 19 | file_name: 20 | excluded: [Finder.swift, SpotlightInstalledApps.swift] 21 | -------------------------------------------------------------------------------- /Sources/mas/AppStore/AppleAccount.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppleAccount.swift 3 | // mas 4 | // 5 | // Copyright © 2025 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import Foundation 9 | private import StoreFoundation 10 | 11 | struct AppleAccount: Sendable { 12 | let emailAddress: String 13 | let dsID: NSNumber // swiftlint:disable:this legacy_objc_type 14 | } 15 | 16 | @MainActor 17 | var appleAccount: AppleAccount { 18 | get async throws { 19 | if #available(macOS 12, *) { 20 | // Account information is no longer available on macOS 12+. 21 | // https://github.com/mas-cli/mas/issues/417 22 | throw MASError.notSupported 23 | } 24 | return await withCheckedContinuation { continuation in 25 | ISServiceProxy.genericShared().accountService.primaryAccount { account in 26 | continuation.resume(returning: AppleAccount(emailAddress: account.identifier, dsID: account.dsID)) 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/mas/AppStore/Downloader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Downloader.swift 3 | // mas 4 | // 5 | // Copyright © 2015 mas-cli. All rights reserved. 6 | // 7 | 8 | private import CommerceKit 9 | 10 | struct Downloader { 11 | let printer: Printer 12 | 13 | func downloadApp( 14 | withAppID appID: AppID, 15 | purchasing: Bool = false, 16 | withAttemptCount attemptCount: UInt32 = 3 17 | ) async throws { 18 | do { 19 | let purchase = await SSPurchase(appID: appID, purchasing: purchasing) 20 | _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in 21 | CKPurchaseController.shared().perform(purchase, withOptions: 0) { _, _, error, response in 22 | if let error { 23 | continuation.resume(throwing: error) 24 | } else if response?.downloads.isEmpty == false { 25 | Task { 26 | do { 27 | try await PurchaseDownloadObserver(appID: appID, printer: printer).observeDownloadQueue() 28 | continuation.resume() 29 | } catch { 30 | continuation.resume(throwing: error) 31 | } 32 | } 33 | } else { 34 | continuation.resume(throwing: MASError.noDownloads) 35 | } 36 | } 37 | } 38 | } catch { 39 | guard attemptCount > 1 else { 40 | throw error 41 | } 42 | 43 | // If the download failed due to network issues, try again. Otherwise, fail immediately. 44 | guard (error as NSError).domain == NSURLErrorDomain else { 45 | throw error 46 | } 47 | 48 | let attemptCount = attemptCount - 1 49 | printer.warning( 50 | "Network error (", 51 | attemptCount, 52 | attemptCount == 1 ? " attempt remaining):\n" : " attempts remaining):\n", 53 | error, 54 | separator: "" 55 | ) 56 | try await downloadApp(withAppID: appID, purchasing: purchasing, withAttemptCount: attemptCount) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/mas/AppStore/ISORegion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ISORegion.swift 3 | // mas 4 | // 5 | // Copyright © 2024 mas-cli. All rights reserved. 6 | // 7 | 8 | private import IsoCountryCodes 9 | private import StoreKit 10 | 11 | // periphery:ignore 12 | protocol ISORegion { 13 | // swiftlint:disable unused_declaration 14 | var name: String { get } 15 | var numeric: String { get } 16 | var alpha2: String { get } 17 | var alpha3: String { get } 18 | var calling: String { get } 19 | var currency: String { get } 20 | var continent: String { get } 21 | var flag: String? { get } 22 | var fractionDigits: Int { get } 23 | // swiftlint:enable unused_declaration 24 | } 25 | 26 | extension IsoCountryInfo: ISORegion {} 27 | 28 | var isoRegion: ISORegion? { 29 | get async { 30 | if let alpha3 = await alpha3 { 31 | findISORegion(forAlpha3Code: alpha3) 32 | } else if let alpha2 { 33 | findISORegion(forAlpha2Code: alpha2) 34 | } else { 35 | nil 36 | } 37 | } 38 | } 39 | 40 | private var alpha3: String? { 41 | get async { 42 | if #available(macOS 12, *) { 43 | await Storefront.current?.countryCode 44 | } else { 45 | SKPaymentQueue.default().storefront?.countryCode 46 | } 47 | } 48 | } 49 | 50 | private var alpha2: String? { 51 | if #available(macOS 13, *) { 52 | Locale.autoupdatingCurrent.region?.identifier 53 | } else { 54 | Locale.autoupdatingCurrent.regionCode 55 | } 56 | } 57 | 58 | func findISORegion(forAlpha2Code alpha2Code: String) -> ISORegion? { 59 | let alpha2Code = alpha2Code.uppercased() 60 | return IsoCountries.allCountries.first { $0.alpha2 == alpha2Code } 61 | } 62 | 63 | func findISORegion(forAlpha3Code alpha3Code: String) -> ISORegion? { 64 | let alpha3Code = alpha3Code.uppercased() 65 | return IsoCountries.allCountries.first { $0.alpha3 == alpha3Code } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/mas/AppStore/PurchaseDownloadObserver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PurchaseDownloadObserver.swift 3 | // mas 4 | // 5 | // Copyright © 2015 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import CommerceKit 9 | 10 | private var downloadingPhaseType: Int64 { 0 } 11 | private var installingPhaseType: Int64 { 1 } 12 | private var initialPhaseType: Int64 { 4 } 13 | private var downloadedPhaseType: Int64 { 5 } 14 | 15 | class PurchaseDownloadObserver: CKDownloadQueueObserver { 16 | private let appID: AppID 17 | private let printer: Printer 18 | 19 | private var completionHandler: (() -> Void)? 20 | private var errorHandler: ((Error) -> Void)? 21 | private var prevPhaseType: Int64? 22 | 23 | init(appID: AppID, printer: Printer) { 24 | self.appID = appID 25 | self.printer = printer 26 | } 27 | 28 | deinit { 29 | // Do nothing 30 | } 31 | 32 | func downloadQueue(_ queue: CKDownloadQueue, statusChangedFor download: SSDownload) { 33 | guard download.metadata.itemIdentifier == appID else { 34 | return 35 | } 36 | 37 | let status = download.status 38 | if status.isFailed || status.isCancelled { 39 | queue.removeDownload(withItemIdentifier: download.metadata.itemIdentifier) 40 | } else { 41 | prevPhaseType = printer.progress(of: download, prevPhaseType: prevPhaseType) 42 | } 43 | } 44 | 45 | func downloadQueue(_: CKDownloadQueue, changedWithAddition _: SSDownload) { 46 | // Do nothing 47 | } 48 | 49 | func downloadQueue(_: CKDownloadQueue, changedWithRemoval download: SSDownload) { 50 | guard download.metadata.itemIdentifier == appID else { 51 | return 52 | } 53 | 54 | printer.terminateEphemeral() 55 | let status = download.status 56 | if status.isFailed { 57 | errorHandler?(status.error) 58 | } else if status.isCancelled { 59 | errorHandler?(MASError.cancelled) 60 | } else { 61 | printer.notice("Installed", download.progressDescription) 62 | completionHandler?() 63 | } 64 | } 65 | } 66 | 67 | // swiftlint:disable:next one_declaration_per_file 68 | private struct ProgressState { 69 | let percentComplete: Float 70 | let phase: String 71 | 72 | var percentage: String { 73 | String(format: "%.1f%%", floor(percentComplete * 1000) / 10) 74 | } 75 | } 76 | 77 | private extension SSDownload { 78 | var progressDescription: String { 79 | "\(metadata.title) (\(metadata.bundleVersion ?? "unknown version"))" 80 | } 81 | } 82 | 83 | private extension Printer { 84 | func progress(of download: SSDownload, prevPhaseType: Int64?) -> Int64 { 85 | let currPhaseType = download.status.activePhase.phaseType 86 | if prevPhaseType != currPhaseType { 87 | switch currPhaseType { 88 | case downloadingPhaseType: 89 | if prevPhaseType == initialPhaseType { 90 | progressHeader(for: download) 91 | } 92 | case downloadedPhaseType: 93 | if prevPhaseType == downloadingPhaseType { 94 | progressHeader(for: download) 95 | } 96 | case installingPhaseType: 97 | progressHeader(for: download) 98 | default: 99 | break 100 | } 101 | } 102 | 103 | if isatty(fileno(stdout)) != 0 { 104 | // Only output the progress bar if connected to a terminal 105 | let progressState = download.status.progressState 106 | let totalLength = 60 107 | let completedLength = Int(progressState.percentComplete * Float(totalLength)) 108 | ephemeral( 109 | String(repeating: "#", count: completedLength), 110 | String(repeating: "-", count: totalLength - completedLength), 111 | " ", 112 | progressState.percentage, 113 | " ", 114 | progressState.phase, 115 | separator: "", 116 | terminator: "" 117 | ) 118 | } 119 | 120 | return currPhaseType 121 | } 122 | 123 | private func progressHeader(for download: SSDownload) { 124 | terminateEphemeral() 125 | notice(download.status.activePhase.phaseDescription, download.progressDescription) 126 | } 127 | } 128 | 129 | private extension SSDownloadStatus { 130 | var progressState: ProgressState { 131 | ProgressState(percentComplete: percentComplete, phase: activePhase.phaseDescription) 132 | } 133 | } 134 | 135 | private extension SSDownloadPhase { 136 | var phaseDescription: String { 137 | switch phaseType { 138 | case downloadedPhaseType: 139 | "Downloaded" 140 | case downloadingPhaseType: 141 | "Downloading" 142 | case installingPhaseType: 143 | "Installing" 144 | default: 145 | "Waiting" 146 | } 147 | } 148 | } 149 | 150 | extension PurchaseDownloadObserver { 151 | func observeDownloadQueue(_ queue: CKDownloadQueue = .shared()) async throws { 152 | let observerID = queue.add(self) 153 | defer { 154 | queue.remove(observerID) 155 | } 156 | 157 | try await withCheckedThrowingContinuation { continuation in 158 | completionHandler = { 159 | self.completionHandler = nil 160 | self.errorHandler = nil 161 | continuation.resume() 162 | } 163 | errorHandler = { error in 164 | self.completionHandler = nil 165 | self.errorHandler = nil 166 | continuation.resume(throwing: error) 167 | } 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /Sources/mas/AppStore/SSPurchase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SSPurchase.swift 3 | // mas 4 | // 5 | // Copyright © 2015 mas-cli. All rights reserved. 6 | // 7 | 8 | private import StoreFoundation 9 | 10 | extension SSPurchase { 11 | convenience init(appID: AppID, purchasing: Bool) async { 12 | self.init() 13 | 14 | var parameters = 15 | [ 16 | "productType": "C", 17 | "price": 0, 18 | "salableAdamId": appID, 19 | "pg": "default", 20 | "appExtVrsId": 0, 21 | ] as [String: Any] 22 | 23 | if purchasing { 24 | parameters["macappinstalledconfirmed"] = 1 25 | parameters["pricingParameters"] = "STDQ" 26 | // Possibly unnecessary… 27 | isRedownload = false 28 | } else { 29 | parameters["pricingParameters"] = "STDRDL" 30 | } 31 | 32 | buyParameters = parameters.map { "\($0)=\($1)" }.joined(separator: "&") 33 | 34 | itemIdentifier = appID 35 | 36 | downloadMetadata = SSDownloadMetadata() 37 | downloadMetadata.kind = "software" 38 | downloadMetadata.itemIdentifier = appID 39 | 40 | do { 41 | let appleAccount = try await appleAccount 42 | accountIdentifier = appleAccount.dsID 43 | appleID = appleAccount.emailAddress 44 | } catch { 45 | // Do nothing 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Account.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Account.swift 3 | // mas 4 | // 5 | // Copyright © 2015 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | extension MAS { 11 | /// Outputs the Apple Account signed in to the Mac App Store. 12 | struct Account: AsyncParsableCommand { 13 | static let configuration = CommandConfiguration( 14 | abstract: "Output the Apple Account signed in to the Mac App Store" 15 | ) 16 | 17 | /// Runs the command. 18 | func run() async throws { 19 | try await mas.run { try await run(printer: $0) } 20 | } 21 | 22 | func run(printer: Printer) async throws { 23 | printer.info(try await appleAccount.emailAddress) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Config.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Config.swift 3 | // mas 4 | // 5 | // Copyright © 2025 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | private import Foundation 10 | 11 | private var unknown: String { "unknown" } 12 | private var sysCtlByName: String { "sysctlbyname" } 13 | 14 | extension MAS { 15 | /// Outputs mas config & related system info. 16 | struct Config: AsyncParsableCommand { 17 | static let configuration = CommandConfiguration( 18 | abstract: "Output mas config & related system info" 19 | ) 20 | 21 | @Flag(help: "Output as Markdown") 22 | var markdown = false 23 | 24 | /// Runs the command. 25 | func run() async throws { 26 | try await mas.run { await run(printer: $0) } 27 | } 28 | 29 | func run(printer: Printer) async { 30 | if markdown { 31 | printer.info("```text") 32 | } 33 | printer.info( 34 | """ 35 | mas ▁▁▁▁ \(Package.version) 36 | arch ▁▁▁ \(configStringValue("hw.machine")) 37 | from ▁▁▁ \(Package.installMethod) 38 | origin ▁ \(Package.gitOrigin) 39 | rev ▁▁▁▁ \(Package.gitRevision) 40 | driver ▁ \(Package.swiftDriverVersion) 41 | swift ▁▁ \(Package.swiftVersion) 42 | region ▁ \(await isoRegion?.alpha2 ?? unknown) 43 | macos ▁▁ \( 44 | ProcessInfo.processInfo.operatingSystemVersionString.dropFirst(8).replacingOccurrences(of: "Build ", with: "") 45 | ) 46 | mac ▁▁▁▁ \(configStringValue("hw.product")) 47 | cpu ▁▁▁▁ \(configStringValue("machdep.cpu.brand_string")) 48 | """ 49 | ) 50 | if markdown { 51 | printer.info("```") 52 | } 53 | } 54 | } 55 | } 56 | 57 | private func configStringValue(_ name: String) -> String { 58 | var size = 0 59 | guard sysctlbyname(name, nil, &size, nil, 0) == 0 else { 60 | perror(sysCtlByName) 61 | return unknown 62 | } 63 | 64 | var buffer = [CChar](repeating: 0, count: size) 65 | guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { 66 | perror(sysCtlByName) 67 | return unknown 68 | } 69 | 70 | return String(cString: buffer, encoding: .utf8) ?? unknown 71 | } 72 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Home.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Home.swift 3 | // mas 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | private import Foundation 10 | 11 | extension MAS { 12 | /// Opens Mac App Store app pages in the default web browser. 13 | /// 14 | /// Uses the iTunes Lookup API: 15 | /// 16 | /// https://performance-partners.apple.com/search-api 17 | struct Home: AsyncParsableCommand { 18 | static let configuration = CommandConfiguration( 19 | abstract: "Open Mac App Store app pages in the default web browser" 20 | ) 21 | 22 | @OptionGroup 23 | var appIDsOptionGroup: AppIDsOptionGroup 24 | 25 | /// Runs the command. 26 | func run() async throws { 27 | try await run(searcher: ITunesSearchAppStoreSearcher()) 28 | } 29 | 30 | func run(searcher: AppStoreSearcher) async throws { 31 | try await mas.run { await run(printer: $0, searcher: searcher) } 32 | } 33 | 34 | private func run(printer: Printer, searcher: AppStoreSearcher) async { 35 | for appID in appIDsOptionGroup.appIDs { 36 | do { 37 | let result = try await searcher.lookup(appID: appID) 38 | guard let url = URL(string: result.trackViewUrl) else { 39 | throw MASError.urlParsing(result.trackViewUrl) 40 | } 41 | 42 | try await url.open() 43 | } catch { 44 | printer.error(error: error) 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Info.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Info.swift 3 | // mas 4 | // 5 | // Copyright © 2016 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | private import Foundation 10 | 11 | extension MAS { 12 | /// Outputs app information from the Mac App Store. 13 | /// 14 | /// Uses the iTunes Lookup API: 15 | /// 16 | /// https://performance-partners.apple.com/search-api 17 | struct Info: AsyncParsableCommand { 18 | static let configuration = CommandConfiguration( 19 | abstract: "Output app information from the Mac App Store" 20 | ) 21 | 22 | @OptionGroup 23 | var appIDsOptionGroup: AppIDsOptionGroup 24 | 25 | /// Runs the command. 26 | func run() async throws { 27 | try await run(searcher: ITunesSearchAppStoreSearcher()) 28 | } 29 | 30 | func run(searcher: AppStoreSearcher) async throws { 31 | try await mas.run { await run(printer: $0, searcher: searcher) } 32 | } 33 | 34 | private func run(printer: Printer, searcher: AppStoreSearcher) async { 35 | var spacing = "" 36 | for appID in appIDsOptionGroup.appIDs { 37 | do { 38 | printer.info("", AppInfoFormatter.format(app: try await searcher.lookup(appID: appID)), separator: spacing) 39 | } catch { 40 | printer.log(spacing, to: .standardError) 41 | printer.error(error: error) 42 | } 43 | spacing = "\n" 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Install.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Install.swift 3 | // mas 4 | // 5 | // Copyright © 2015 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | extension MAS { 11 | /// Installs previously purchased apps from the Mac App Store. 12 | struct Install: AsyncParsableCommand { 13 | static let configuration = CommandConfiguration( 14 | abstract: "Install previously purchased apps from the Mac App Store" 15 | ) 16 | 17 | @OptionGroup 18 | var forceOptionGroup: ForceOptionGroup 19 | @OptionGroup 20 | var appIDsOptionGroup: AppIDsOptionGroup 21 | 22 | /// Runs the command. 23 | func run() async throws { 24 | try await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) 25 | } 26 | 27 | func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { 28 | try await mas.run { printer in 29 | await run(downloader: Downloader(printer: printer), installedApps: installedApps, searcher: searcher) 30 | } 31 | } 32 | 33 | private func run(downloader: Downloader, installedApps: [InstalledApp], searcher: AppStoreSearcher) async { 34 | for appID in appIDsOptionGroup.appIDs.filter({ appID in 35 | if let installedApp = installedApps.first(where: { $0.id == appID }), !forceOptionGroup.force { 36 | downloader.printer.warning("Already installed:", installedApp.idAndName) 37 | return false 38 | } 39 | return true 40 | }) { 41 | do { 42 | _ = try await searcher.lookup(appID: appID) 43 | try await downloader.downloadApp(withAppID: appID) 44 | } catch { 45 | downloader.printer.error(error: error) 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/mas/Commands/List.swift: -------------------------------------------------------------------------------- 1 | // 2 | // List.swift 3 | // mas 4 | // 5 | // Copyright © 2015 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | extension MAS { 11 | /// Lists all apps installed from the Mac App Store. 12 | struct List: AsyncParsableCommand { 13 | static let configuration = CommandConfiguration( 14 | abstract: "List all apps installed from the Mac App Store" 15 | ) 16 | 17 | /// Runs the command. 18 | func run() async throws { 19 | try run(installedApps: await installedApps) 20 | } 21 | 22 | func run(installedApps: [InstalledApp]) throws { 23 | try mas.run { run(printer: $0, installedApps: installedApps) } 24 | } 25 | 26 | private func run(printer: Printer, installedApps: [InstalledApp]) { 27 | if installedApps.isEmpty { 28 | printer.error( 29 | """ 30 | No installed apps found 31 | 32 | If this is unexpected, the following command line should fix it by 33 | (re)creating the Spotlight index (which might take some time): 34 | 35 | sudo mdutil -Eai on 36 | """ 37 | ) 38 | } else { 39 | printer.info(AppListFormatter.format(installedApps)) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Lucky.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Lucky.swift 3 | // mas 4 | // 5 | // Copyright © 2017 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | extension MAS { 11 | /// Installs the first app returned from searching the Mac App Store (app must 12 | /// have been previously purchased). 13 | /// 14 | /// Uses the iTunes Search API: 15 | /// 16 | /// https://performance-partners.apple.com/search-api 17 | struct Lucky: AsyncParsableCommand { 18 | static let configuration = CommandConfiguration( 19 | abstract: """ 20 | Install the first app returned from searching the Mac App Store 21 | (app must have been previously purchased) 22 | """ 23 | ) 24 | 25 | @OptionGroup 26 | var forceOptionGroup: ForceOptionGroup 27 | @OptionGroup 28 | var searchTermOptionGroup: SearchTermOptionGroup 29 | 30 | /// Runs the command. 31 | func run() async throws { 32 | try await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) 33 | } 34 | 35 | func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { 36 | try await mas.run { printer in 37 | try await run(downloader: Downloader(printer: printer), installedApps: installedApps, searcher: searcher) 38 | } 39 | } 40 | 41 | private func run(downloader: Downloader, installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { 42 | let searchTerm = searchTermOptionGroup.searchTerm 43 | let results = try await searcher.search(for: searchTerm) 44 | guard let result = results.first else { 45 | throw MASError.noSearchResultsFound(for: searchTerm) 46 | } 47 | 48 | try await install(appID: result.trackId, installedApps: installedApps, downloader: downloader) 49 | } 50 | 51 | /// Installs an app. 52 | /// 53 | /// - Parameters: 54 | /// - appID: App ID. 55 | /// - installedApps: List of installed apps. 56 | /// - downloader: `Downloader`. 57 | /// - Throws: Any error that occurs while attempting to install the app. 58 | private func install(appID: AppID, installedApps: [InstalledApp], downloader: Downloader) async throws { 59 | if let installedApp = installedApps.first(where: { $0.id == appID }), !forceOptionGroup.force { 60 | downloader.printer.warning("Already installed:", installedApp.idAndName) 61 | } else { 62 | try await downloader.downloadApp(withAppID: appID) 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Open.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Open.swift 3 | // mas 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | private import AppKit 9 | internal import ArgumentParser 10 | 11 | private var masScheme: String { "macappstore" } 12 | 13 | extension MAS { 14 | /// Opens app page in 'App Store.app'. 15 | /// 16 | /// Uses the iTunes Lookup API: 17 | /// 18 | /// https://performance-partners.apple.com/search-api 19 | struct Open: AsyncParsableCommand { 20 | static let configuration = CommandConfiguration( 21 | abstract: "Open app page in 'App Store.app'" 22 | ) 23 | 24 | @Argument(help: "App ID") 25 | var appID: AppID? 26 | 27 | /// Runs the command. 28 | func run() async throws { 29 | try await run(searcher: ITunesSearchAppStoreSearcher()) 30 | } 31 | 32 | func run(searcher: AppStoreSearcher) async throws { 33 | try await mas.run { try await run(printer: $0, searcher: searcher) } 34 | } 35 | 36 | private func run(printer _: Printer, searcher: AppStoreSearcher) async throws { 37 | guard let appID else { 38 | // If no app ID was given, just open the MAS GUI app 39 | try await openMacAppStore() 40 | return 41 | } 42 | 43 | try await openInMacAppStore(pageForAppID: appID, searcher: searcher) 44 | } 45 | } 46 | } 47 | 48 | private func openMacAppStore() async throws { 49 | guard let macappstoreSchemeURL = URL(string: "macappstore:") else { 50 | throw MASError.runtimeError("Failed to create URL from macappstore scheme") 51 | } 52 | guard let appURL = NSWorkspace.shared.urlForApplication(toOpen: macappstoreSchemeURL) else { 53 | throw MASError.runtimeError("Failed to find app to open macappstore URLs") 54 | } 55 | 56 | try await NSWorkspace.shared.openApplication(at: appURL, configuration: NSWorkspace.OpenConfiguration()) 57 | } 58 | 59 | private func openInMacAppStore(pageForAppID appID: AppID, searcher: AppStoreSearcher) async throws { 60 | let result = try await searcher.lookup(appID: appID) 61 | 62 | guard var urlComponents = URLComponents(string: result.trackViewUrl) else { 63 | throw MASError.urlParsing(result.trackViewUrl) 64 | } 65 | 66 | urlComponents.scheme = masScheme 67 | 68 | guard let url = urlComponents.url else { 69 | throw MASError.urlParsing(String(describing: urlComponents)) 70 | } 71 | 72 | try await url.open() 73 | } 74 | -------------------------------------------------------------------------------- /Sources/mas/Commands/OptionGroups/AppIDsOptionGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppIDsOptionGroup.swift 3 | // mas 4 | // 5 | // Copyright © 2025 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | struct AppIDsOptionGroup: ParsableArguments { 11 | @Argument(help: ArgumentHelp("App ID", valueName: "app-id")) 12 | var appIDs: [AppID] 13 | } 14 | -------------------------------------------------------------------------------- /Sources/mas/Commands/OptionGroups/ForceOptionGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ForceOptionGroup.swift 3 | // mas 4 | // 5 | // Copyright © 2025 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | struct ForceOptionGroup: ParsableArguments { 11 | @Flag(help: "Force reinstall") 12 | var force = false 13 | } 14 | -------------------------------------------------------------------------------- /Sources/mas/Commands/OptionGroups/SearchTermOptionGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchTermOptionGroup.swift 3 | // mas 4 | // 5 | // Copyright © 2025 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | struct SearchTermOptionGroup: ParsableArguments { 11 | @Argument(help: ArgumentHelp("Search terms are concatenated into a single search", valueName: "search-term")) 12 | var searchTermElements: [String] 13 | 14 | var searchTerm: String { 15 | searchTermElements.joined(separator: " ") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/mas/Commands/OptionGroups/VerboseOptionGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerboseOptionGroup.swift 3 | // mas 4 | // 5 | // Copyright © 2025 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | struct VerboseOptionGroup: ParsableArguments { 11 | @Flag(help: "Output warnings about app IDs unknown to the Mac App Store") 12 | var verbose = false 13 | 14 | func printProblem(forError error: Error, expectedAppName appName: String, printer: Printer) { 15 | guard case MASError.unknownAppID = error else { 16 | printer.error(error: error) 17 | return 18 | } 19 | 20 | if verbose { 21 | printer.warning(error, "; was expected to identify: ", appName, separator: "") 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Outdated.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Outdated.swift 3 | // mas 4 | // 5 | // Copyright © 2015 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | extension MAS { 11 | /// Outputs a list of installed apps which have updates available to be 12 | /// installed from the Mac App Store. 13 | struct Outdated: AsyncParsableCommand { 14 | static let configuration = CommandConfiguration( 15 | abstract: "List pending app updates from the Mac App Store" 16 | ) 17 | 18 | @OptionGroup 19 | var verboseOptionGroup: VerboseOptionGroup 20 | 21 | /// Runs the command. 22 | func run() async throws { 23 | try await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) 24 | } 25 | 26 | func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { 27 | try await mas.run { await run(printer: $0, installedApps: installedApps, searcher: searcher) } 28 | } 29 | 30 | private func run(printer: Printer, installedApps: [InstalledApp], searcher: AppStoreSearcher) async { 31 | for installedApp in installedApps { 32 | do { 33 | let storeApp = try await searcher.lookup(appID: installedApp.id) 34 | if installedApp.isOutdated(comparedTo: storeApp) { 35 | printer.info( 36 | installedApp.id, 37 | " ", 38 | installedApp.name, 39 | " (", 40 | installedApp.version, 41 | " -> ", 42 | storeApp.version, 43 | ")", 44 | separator: "" 45 | ) 46 | } 47 | } catch { 48 | verboseOptionGroup.printProblem(forError: error, expectedAppName: installedApp.name, printer: printer) 49 | } 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Purchase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Purchase.swift 3 | // mas 4 | // 5 | // Copyright © 2017 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | extension MAS { 11 | /// "Purchases" & installs free apps from the Mac App Store. 12 | struct Purchase: AsyncParsableCommand { 13 | static let configuration = CommandConfiguration( 14 | abstract: "\"Purchase\" & install free apps from the Mac App Store" 15 | ) 16 | 17 | @OptionGroup 18 | var appIDsOptionGroup: AppIDsOptionGroup 19 | 20 | /// Runs the command. 21 | func run() async throws { 22 | try await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) 23 | } 24 | 25 | func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { 26 | try await mas.run { printer in 27 | await run(downloader: Downloader(printer: printer), installedApps: installedApps, searcher: searcher) 28 | } 29 | } 30 | 31 | private func run(downloader: Downloader, installedApps: [InstalledApp], searcher: AppStoreSearcher) async { 32 | for appID in appIDsOptionGroup.appIDs.filter({ appID in 33 | if let installedApp = installedApps.first(where: { $0.id == appID }) { 34 | downloader.printer.warning("Already purchased:", installedApp.idAndName) 35 | return false 36 | } 37 | return true 38 | }) { 39 | do { 40 | _ = try await searcher.lookup(appID: appID) 41 | try await downloader.downloadApp(withAppID: appID, purchasing: true) 42 | } catch { 43 | downloader.printer.error(error: error) 44 | } 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Region.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Region.swift 3 | // mas 4 | // 5 | // Copyright © 2024 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | extension MAS { 11 | /// Outputs the region of the Mac App Store. 12 | struct Region: AsyncParsableCommand { 13 | static let configuration = CommandConfiguration( 14 | abstract: "Output the region of the Mac App Store" 15 | ) 16 | 17 | /// Runs the command. 18 | func run() async throws { 19 | try await mas.run { try await run(printer: $0) } 20 | } 21 | 22 | func run(printer: Printer) async throws { 23 | guard let region = await isoRegion else { 24 | throw MASError.runtimeError("Failed to obtain the region of the Mac App Store") 25 | } 26 | 27 | printer.info(region.alpha2) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Reset.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Reset.swift 3 | // mas 4 | // 5 | // Copyright © 2016 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | private import CommerceKit 10 | 11 | extension MAS { 12 | /// Kills several macOS processes as a means to reset the app store. 13 | struct Reset: ParsableCommand { 14 | static let configuration = CommandConfiguration( 15 | abstract: "Reset Mac App Store running processes" 16 | ) 17 | 18 | @Flag(help: "Output debug information") 19 | var debug = false 20 | 21 | /// Runs the command. 22 | func run() throws { 23 | try mas.run { run(printer: $0) } 24 | } 25 | 26 | func run(printer: Printer) { 27 | // The "Reset Application" command in the Mac App Store debug menu performs 28 | // the following steps 29 | // 30 | // - killall Dock 31 | // - killall storeagent (storeagent no longer exists) 32 | // - rm com.apple.appstore download directory 33 | // - clear cookies (appears to be a no-op) 34 | // 35 | // As storeagent no longer exists we will implement a slight variant & kill all 36 | // App Store-associated processes 37 | // - storeaccountd 38 | // - storeassetd 39 | // - storedownloadd 40 | // - storeinstalld 41 | // - storelegacy 42 | 43 | // Kill processes 44 | let killProcs = [ 45 | "Dock", 46 | "storeaccountd", 47 | "storeassetd", 48 | "storedownloadd", 49 | "storeinstalld", 50 | "storelegacy", 51 | ] 52 | 53 | let kill = Process() 54 | let stdout = Pipe() 55 | let stderr = Pipe() 56 | 57 | kill.launchPath = "/usr/bin/killall" 58 | kill.arguments = killProcs 59 | kill.standardOutput = stdout 60 | kill.standardError = stderr 61 | 62 | kill.launch() 63 | kill.waitUntilExit() 64 | 65 | if kill.terminationStatus != 0 { 66 | let output = stderr.fileHandleForReading.readDataToEndOfFile() 67 | printer.error( 68 | "killall failed:", 69 | String(data: output, encoding: .utf8) ?? "Error info not available", 70 | separator: "\n" 71 | ) 72 | } 73 | 74 | // Wipe Download Directory 75 | let directory = CKDownloadDirectory(nil) 76 | do { 77 | try FileManager.default.removeItem(atPath: directory) 78 | } catch { 79 | printer.error("Failed to delete download directory", directory, error: error) 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Search.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Search.swift 3 | // mas 4 | // 5 | // Copyright © 2016 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | extension MAS { 11 | /// Searches for apps in the Mac App Store. 12 | /// 13 | /// Uses the iTunes Search API: 14 | /// 15 | /// https://performance-partners.apple.com/search-api 16 | struct Search: AsyncParsableCommand { 17 | static let configuration = CommandConfiguration( 18 | abstract: "Search for apps in the Mac App Store" 19 | ) 20 | 21 | @Flag(help: "Output the price of each app") 22 | var price = false 23 | @OptionGroup 24 | var searchTermOptionGroup: SearchTermOptionGroup 25 | 26 | /// Runs the command. 27 | func run() async throws { 28 | try await run(searcher: ITunesSearchAppStoreSearcher()) 29 | } 30 | 31 | func run(searcher: AppStoreSearcher) async throws { 32 | try await mas.run { try await run(printer: $0, searcher: searcher) } 33 | } 34 | 35 | private func run(printer: Printer, searcher: AppStoreSearcher) async throws { 36 | let searchTerm = searchTermOptionGroup.searchTerm 37 | let results = try await searcher.search(for: searchTerm) 38 | guard !results.isEmpty else { 39 | throw MASError.noSearchResultsFound(for: searchTerm) 40 | } 41 | 42 | printer.info(SearchResultFormatter.format(results, includePrice: price)) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/mas/Commands/SignIn.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignIn.swift 3 | // mas 4 | // 5 | // Copyright © 2016 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | extension MAS { 11 | /// Signs in to an Apple Account in the Mac App Store. 12 | struct SignIn: ParsableCommand { 13 | static let configuration = CommandConfiguration( 14 | commandName: "signin", 15 | abstract: "Sign in to an Apple Account in the Mac App Store" 16 | ) 17 | 18 | @Flag(help: "Provide password via graphical dialog") 19 | var dialog = false // swiftlint:disable:this unused_declaration 20 | // periphery:ignore 21 | @Argument(help: "Apple Account") 22 | var appleAccount: String // swiftlint:disable:this unused_declaration 23 | @Argument(help: "Password") 24 | var password = "" // swiftlint:disable:this unused_declaration 25 | 26 | /// Runs the command. 27 | func run() throws { 28 | try mas.run { run(printer: $0) } 29 | } 30 | 31 | func run(printer: Printer) { 32 | // Signing in is no longer possible as of High Sierra. 33 | // https://github.com/mas-cli/mas/issues/164 34 | printer.error(error: MASError.notSupported) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/mas/Commands/SignOut.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignOut.swift 3 | // mas 4 | // 5 | // Copyright © 2016 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | private import StoreFoundation 10 | 11 | extension MAS { 12 | /// Signs out of the Apple Account currently signed in to the Mac App Store. 13 | struct SignOut: ParsableCommand { 14 | static let configuration = CommandConfiguration( 15 | commandName: "signout", 16 | abstract: "Sign out of the Apple Account currently signed in to the Mac App Store" 17 | ) 18 | 19 | /// Runs the command. 20 | func run() throws { 21 | try mas.run { run(printer: $0) } 22 | } 23 | 24 | func run(printer _: Printer) { 25 | ISServiceProxy.genericShared().accountService.signOut() 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Uninstall.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Uninstall.swift 3 | // mas 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | private import ScriptingBridge 10 | 11 | extension MAS { 12 | /// Uninstalls apps installed from the Mac App Store. 13 | struct Uninstall: AsyncParsableCommand { 14 | static let configuration = CommandConfiguration( 15 | abstract: "Uninstall apps installed from the Mac App Store" 16 | ) 17 | 18 | /// Flag indicating that removal shouldn't be performed. 19 | @Flag(help: "Perform dry run") 20 | var dryRun = false 21 | @OptionGroup 22 | var appIDsOptionGroup: AppIDsOptionGroup 23 | 24 | /// Runs the command. 25 | func run() async throws { 26 | try run(installedApps: await installedApps) 27 | } 28 | 29 | func run(installedApps: [InstalledApp]) throws { 30 | try mas.run { try run(printer: $0, installedApps: installedApps) } 31 | } 32 | 33 | private func run(printer: Printer, installedApps: [InstalledApp]) throws { 34 | guard NSUserName() == "root" else { 35 | throw MASError.runtimeError("Apps installed from the Mac App Store require root permission to remove") 36 | } 37 | 38 | let uninstallingAppSet = try uninstallingAppSet(fromInstalledApps: installedApps, printer: printer) 39 | guard !uninstallingAppSet.isEmpty else { 40 | return 41 | } 42 | 43 | if dryRun { 44 | for installedApp in uninstallingAppSet { 45 | printer.notice("'", installedApp.name, "' '", installedApp.path, "'", separator: "") 46 | } 47 | printer.notice("(not removed, dry run)") 48 | } else { 49 | try uninstallApps(atPaths: uninstallingAppSet.map(\.path), printer: printer) 50 | } 51 | } 52 | 53 | private func uninstallingAppSet( 54 | fromInstalledApps installedApps: [InstalledApp], 55 | printer: Printer 56 | ) throws -> Set { 57 | guard let sudoGroupName = ProcessInfo.processInfo.sudoGroupName else { 58 | throw MASError.runtimeError("Failed to get original group name") 59 | } 60 | guard let sudoGID = ProcessInfo.processInfo.sudoGID else { 61 | throw MASError.runtimeError("Failed to get original gid") 62 | } 63 | guard setegid(sudoGID) == 0 else { 64 | throw MASError.runtimeError("Failed to switch effective group from 'wheel' to '\(sudoGroupName)'") 65 | } 66 | 67 | defer { 68 | if setegid(0) != 0 { 69 | printer.warning("Failed to revert effective group from '", sudoGroupName, "' back to 'wheel'", separator: "") 70 | } 71 | } 72 | 73 | guard let sudoUserName = ProcessInfo.processInfo.sudoUserName else { 74 | throw MASError.runtimeError("Failed to get original user name") 75 | } 76 | guard let sudoUID = ProcessInfo.processInfo.sudoUID else { 77 | throw MASError.runtimeError("Failed to get original uid") 78 | } 79 | guard seteuid(sudoUID) == 0 else { 80 | throw MASError.runtimeError("Failed to switch effective user from 'root' to '\(sudoUserName)'") 81 | } 82 | 83 | defer { 84 | if seteuid(0) != 0 { 85 | printer.warning("Failed to revert effective user from '", sudoUserName, "' back to 'root'", separator: "") 86 | } 87 | } 88 | 89 | var uninstallingAppSet = Set() 90 | for appID in appIDsOptionGroup.appIDs { 91 | let apps = installedApps.filter { $0.id == appID } 92 | apps.isEmpty // swiftformat:disable:next indent 93 | ? printer.error(appID.notInstalledMessage) 94 | : uninstallingAppSet.formUnion(apps) 95 | } 96 | return uninstallingAppSet 97 | } 98 | } 99 | } 100 | 101 | /// Uninstalls all apps located at any of the elements of `appPaths`. 102 | /// 103 | /// - Parameters: 104 | /// - appPaths: Paths to apps to be uninstalled. 105 | /// - printer: `Printer`. 106 | /// - Throws: An `Error` if any problem occurs. 107 | private func uninstallApps(atPaths appPaths: [String], printer: Printer) throws { 108 | let finderItems = try finderItems() 109 | 110 | guard let uid = ProcessInfo.processInfo.sudoUID else { 111 | throw MASError.runtimeError("Failed to get original uid") 112 | } 113 | guard let gid = ProcessInfo.processInfo.sudoGID else { 114 | throw MASError.runtimeError("Failed to get original gid") 115 | } 116 | 117 | for appPath in appPaths { 118 | let attributes = try FileManager.default.attributesOfItem(atPath: appPath) 119 | guard let appUID = attributes[.ownerAccountID] as? uid_t else { 120 | printer.error("Failed to determine uid of", appPath) 121 | continue 122 | } 123 | guard let appGID = attributes[.groupOwnerAccountID] as? gid_t else { 124 | printer.error("Failed to determine gid of", appPath) 125 | continue 126 | } 127 | guard chown(appPath, uid, gid) == 0 else { 128 | printer.error("Failed to change ownership of '", appPath, "' to uid ", uid, " & gid ", gid, separator: "") 129 | continue 130 | } 131 | 132 | var chownPath = appPath 133 | defer { 134 | if chown(chownPath, appUID, appGID) != 0 { 135 | printer.warning( 136 | "Failed to revert ownership of '", 137 | chownPath, 138 | "' back to uid ", 139 | appUID, 140 | " & gid ", 141 | appGID, 142 | separator: "" 143 | ) 144 | } 145 | } 146 | 147 | let object = finderItems.object(atLocation: URL(fileURLWithPath: appPath)) 148 | guard let item = object as? FinderItem else { 149 | printer.error( 150 | """ 151 | Failed to obtain Finder access: finderItems.object(atLocation: URL(fileURLWithPath:\ 152 | \"\(appPath)\") is a \(type(of: object)) that does not conform to FinderItem 153 | """ 154 | ) 155 | continue 156 | } 157 | guard let delete = item.delete else { 158 | printer.error("Failed to obtain Finder access: FinderItem.delete does not exist") 159 | continue 160 | } 161 | guard let deletedURLString = (delete() as FinderItem).URL else { 162 | printer.error( 163 | """ 164 | Failed to revert ownership of deleted '\(appPath)' back to uid \(appUID) & gid \(appGID):\ 165 | delete result did not have a URL 166 | """ 167 | ) 168 | continue 169 | } 170 | guard let deletedURL = URL(string: deletedURLString) else { 171 | printer.error( 172 | """ 173 | Failed to revert ownership of deleted '\(appPath)' back to uid \(appUID) & gid \(appGID):\ 174 | delete result URL is invalid: \(deletedURLString) 175 | """ 176 | ) 177 | continue 178 | } 179 | 180 | chownPath = deletedURL.path 181 | printer.info("Deleted '", appPath, "' to '", chownPath, "'", separator: "") 182 | } 183 | } 184 | 185 | private func finderItems() throws -> SBElementArray { 186 | guard let finder = SBApplication(bundleIdentifier: "com.apple.finder") as FinderApplication? else { 187 | throw MASError.runtimeError("Failed to obtain Finder access: com.apple.finder does not exist") 188 | } 189 | guard let items = finder.items else { 190 | throw MASError.runtimeError("Failed to obtain Finder access: finder.items does not exist") 191 | } 192 | 193 | return items() 194 | } 195 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Upgrade.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Upgrade.swift 3 | // mas 4 | // 5 | // Copyright © 2015 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | extension MAS { 11 | /// Upgrades outdated apps installed from the Mac App Store. 12 | struct Upgrade: AsyncParsableCommand { 13 | static let configuration = CommandConfiguration( 14 | abstract: "Upgrade outdated apps installed from the Mac App Store" 15 | ) 16 | 17 | @OptionGroup 18 | var verboseOptionGroup: VerboseOptionGroup 19 | @Argument(help: ArgumentHelp("App ID/name", valueName: "app-id-or-name")) 20 | var appIDOrNames = [String]() 21 | 22 | /// Runs the command. 23 | func run() async throws { 24 | try await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) 25 | } 26 | 27 | func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { 28 | try await mas.run { printer in 29 | await run(downloader: Downloader(printer: printer), installedApps: installedApps, searcher: searcher) 30 | } 31 | } 32 | 33 | private func run(downloader: Downloader, installedApps: [InstalledApp], searcher: AppStoreSearcher) async { 34 | let apps = await findOutdatedApps(printer: downloader.printer, installedApps: installedApps, searcher: searcher) 35 | 36 | guard !apps.isEmpty else { 37 | return 38 | } 39 | 40 | downloader.printer.info( 41 | "Upgrading ", 42 | apps.count, 43 | " outdated application", 44 | apps.count > 1 ? "s:\n" : ":\n", 45 | apps.map { installedApp, storeApp in 46 | "\(storeApp.trackName) (\(installedApp.version)) -> (\(storeApp.version))" 47 | } 48 | .joined(separator: "\n"), 49 | separator: "" 50 | ) 51 | 52 | for appID in apps.map(\.storeApp.trackId) { 53 | do { 54 | try await downloader.downloadApp(withAppID: appID) 55 | } catch { 56 | downloader.printer.error(error: error) 57 | } 58 | } 59 | } 60 | 61 | private func findOutdatedApps( 62 | printer: Printer, 63 | installedApps: [InstalledApp], 64 | searcher: AppStoreSearcher 65 | ) async -> [(installedApp: InstalledApp, storeApp: SearchResult)] { 66 | let apps = appIDOrNames.isEmpty // swiftformat:disable:next indent 67 | ? installedApps 68 | : appIDOrNames.flatMap { appIDOrName in 69 | if let appID = AppID(appIDOrName) { 70 | // Find installed apps by app ID argument 71 | let installedApps = installedApps.filter { $0.id == appID } 72 | if installedApps.isEmpty { 73 | printer.error(appID.notInstalledMessage) 74 | } 75 | return installedApps 76 | } 77 | 78 | // Find installed apps by name argument 79 | let installedApps = installedApps.filter { $0.name == appIDOrName } 80 | if installedApps.isEmpty { 81 | printer.error("No installed apps named", appIDOrName) 82 | } 83 | return installedApps 84 | } 85 | 86 | var outdatedApps = [(InstalledApp, SearchResult)]() 87 | for installedApp in apps { 88 | do { 89 | let storeApp = try await searcher.lookup(appID: installedApp.id) 90 | if installedApp.isOutdated(comparedTo: storeApp) { 91 | outdatedApps.append((installedApp, storeApp)) 92 | } 93 | } catch { 94 | verboseOptionGroup.printProblem(forError: error, expectedAppName: installedApp.name, printer: printer) 95 | } 96 | } 97 | return outdatedApps 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Vendor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Vendor.swift 3 | // mas 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | private import Foundation 10 | 11 | extension MAS { 12 | /// Opens apps' vendor pages in the default web browser. 13 | /// 14 | /// Uses the iTunes Lookup API: 15 | /// 16 | /// https://performance-partners.apple.com/search-api 17 | struct Vendor: AsyncParsableCommand { 18 | static let configuration = CommandConfiguration( 19 | abstract: "Open apps' vendor pages in the default web browser" 20 | ) 21 | 22 | @OptionGroup 23 | var appIDsOptionGroup: AppIDsOptionGroup 24 | 25 | /// Runs the command. 26 | func run() async throws { 27 | try await run(searcher: ITunesSearchAppStoreSearcher()) 28 | } 29 | 30 | func run(searcher: AppStoreSearcher) async throws { 31 | try await mas.run { await run(printer: $0, searcher: searcher) } 32 | } 33 | 34 | private func run(printer: Printer, searcher: AppStoreSearcher) async { 35 | for appID in appIDsOptionGroup.appIDs { 36 | do { 37 | let result = try await searcher.lookup(appID: appID) 38 | guard let urlString = result.sellerUrl else { 39 | throw MASError.noVendorWebsite(forAppID: appID) 40 | } 41 | guard let url = URL(string: urlString) else { 42 | throw MASError.urlParsing(urlString) 43 | } 44 | 45 | try await url.open() 46 | } catch { 47 | printer.error(error: error) 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/mas/Commands/Version.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Version.swift 3 | // mas 4 | // 5 | // Copyright © 2015 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | extension MAS { 11 | /// Outputs the version of the mas tool. 12 | struct Version: ParsableCommand { 13 | static let configuration = CommandConfiguration( 14 | abstract: "Output version number" 15 | ) 16 | 17 | /// Runs the command. 18 | func run() throws { 19 | try mas.run { run(printer: $0) } 20 | } 21 | 22 | func run(printer: Printer) { 23 | printer.info(Package.version) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/mas/Controllers/AppStoreSearcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppStoreSearcher.swift 3 | // mas 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | /// Protocol for searching the MAS catalog. 9 | protocol AppStoreSearcher { 10 | /// Looks up app details. 11 | /// 12 | /// - Parameters: 13 | /// - appID: App ID. 14 | /// - region: The `ISORegion` of the storefront in which to lookup apps. 15 | /// - Returns: A `SearchResult` for the given `appID` if `appID` is valid. 16 | /// - Throws: A `MASError.unknownAppID(appID)` if `appID` is invalid. 17 | /// Some other `Error` if any other problem occurs. 18 | func lookup(appID: AppID, inRegion region: ISORegion?) async throws -> SearchResult 19 | 20 | /// Searches for apps. 21 | /// 22 | /// - Parameters: 23 | /// - searchTerm: Term for which to search. 24 | /// - region: The `ISORegion` of the storefront in which to search for apps. 25 | /// - Returns: An `Array` of `SearchResult`s matching `searchTerm`. 26 | /// - Throws: An `Error` if any problem occurs. 27 | func search(for searchTerm: String, inRegion region: ISORegion?) async throws -> [SearchResult] 28 | } 29 | 30 | extension AppStoreSearcher { 31 | /// Looks up app details. 32 | /// 33 | /// - Parameter appID: App ID. 34 | /// - Returns: A `SearchResult` for the given `appID` if `appID` is valid. 35 | /// - Throws: A `MASError.unknownAppID(appID)` if `appID` is invalid. 36 | /// Some other `Error` if any other problem occurs. 37 | func lookup(appID: AppID) async throws -> SearchResult { 38 | try await lookup(appID: appID, inRegion: isoRegion) 39 | } 40 | 41 | /// Searches for apps. 42 | /// 43 | /// - Parameter searchTerm: Term for which to search. 44 | /// - Returns: An `Array` of `SearchResult`s matching `searchTerm`. 45 | /// - Throws: An `Error` if any problem occurs. 46 | func search(for searchTerm: String) async throws -> [SearchResult] { 47 | try await search(for: searchTerm, inRegion: isoRegion) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ITunesSearchAppStoreSearcher.swift 3 | // mas 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | private import Foundation 9 | 10 | /// Manages searching the MAS catalog. 11 | /// 12 | /// Uses the iTunes Search & Lookup APIs: 13 | /// 14 | /// https://performance-partners.apple.com/search-api 15 | struct ITunesSearchAppStoreSearcher: AppStoreSearcher { 16 | enum Entity: String { 17 | case desktopSoftware 18 | case macSoftware 19 | case iPadSoftware 20 | case iPhoneSoftware = "software" 21 | } 22 | 23 | private let networkSession: NetworkSession 24 | 25 | /// Designated initializer. 26 | init(networkSession: NetworkSession = URLSession(configuration: .ephemeral)) { 27 | self.networkSession = networkSession 28 | } 29 | 30 | /// Looks up app details. 31 | /// 32 | /// - Parameters: 33 | /// - appID: App ID. 34 | /// - region: The `ISORegion` of the storefront in which to lookup apps. 35 | /// - Returns: A `SearchResult` for the given `appID` if `appID` is valid. 36 | /// - Throws: A `MASError.unknownAppID(appID)` if `appID` is invalid. 37 | /// Some other `Error` if any other problem occurs. 38 | func lookup(appID: AppID, inRegion region: ISORegion?) async throws -> SearchResult { 39 | let results = try await getSearchResults(from: try lookupURL(forAppID: appID, inRegion: region)) 40 | guard let result = results.first else { 41 | throw MASError.unknownAppID(appID) 42 | } 43 | 44 | return result 45 | } 46 | 47 | /// Searches for apps. 48 | /// 49 | /// - Parameters: 50 | /// - searchTerm: Term for which to search. 51 | /// - region: The `ISORegion` of the storefront in which to search for apps. 52 | /// - Returns: An `Array` of `SearchResult`s matching `searchTerm`. 53 | /// - Throws: An `Error` if any problem occurs. 54 | func search(for searchTerm: String, inRegion region: ISORegion?) async throws -> [SearchResult] { 55 | // Search for apps for compatible platforms, in order of preference. 56 | // Macs with Apple Silicon can run iPad & iPhone apps. 57 | #if arch(arm64) 58 | let entities = [Entity.desktopSoftware, .iPadSoftware, .iPhoneSoftware] 59 | #else 60 | let entities = [Entity.desktopSoftware] 61 | #endif 62 | 63 | var appSet = Set() 64 | for entity in entities { 65 | appSet.formUnion( 66 | try await getSearchResults(from: try searchURL(for: searchTerm, inRegion: region, ofEntity: entity)) 67 | ) 68 | } 69 | 70 | return Array(appSet) 71 | } 72 | 73 | /// Builds the lookup URL for an app. 74 | /// 75 | /// - Parameters: 76 | /// - appID: App ID. 77 | /// - region: The `ISORegion` of the storefront in which to lookup apps. 78 | /// - entity: OS platform of apps for which to search. 79 | /// - Returns: URL for the lookup service. 80 | /// - Throws: An `MASError.urlParsing` if `appID` can't be encoded. 81 | private func lookupURL( 82 | forAppID appID: AppID, 83 | inRegion region: ISORegion?, 84 | ofEntity entity: Entity = .desktopSoftware 85 | ) throws -> URL { 86 | try url("lookup", URLQueryItem(name: "id", value: String(appID)), inRegion: region, ofEntity: entity) 87 | } 88 | 89 | /// Builds the search URL for an app. 90 | /// 91 | /// - Parameters: 92 | /// - searchTerm: term for which to search in MAS. 93 | /// - region: The `ISORegion` of the storefront in which to search for apps. 94 | /// - entity: OS platform of apps for which to search. 95 | /// - Returns: URL for the search service. 96 | /// - Throws: An `MASError.urlParsing` if `searchTerm` can't be encoded. 97 | private func searchURL( 98 | for searchTerm: String, 99 | inRegion region: ISORegion?, 100 | ofEntity entity: Entity = .desktopSoftware 101 | ) throws -> URL { 102 | try url("search", URLQueryItem(name: "term", value: searchTerm), inRegion: region, ofEntity: entity) 103 | } 104 | 105 | private func url( 106 | _ action: String, 107 | _ queryItem: URLQueryItem, 108 | inRegion region: ISORegion?, 109 | ofEntity entity: Entity = .desktopSoftware 110 | ) throws -> URL { 111 | let urlBase = "https://itunes.apple.com/\(action)" 112 | guard var urlComponents = URLComponents(string: urlBase) else { 113 | throw MASError.urlParsing(urlBase) 114 | } 115 | 116 | var queryItems = [ 117 | URLQueryItem(name: "media", value: "software"), 118 | URLQueryItem(name: "entity", value: entity.rawValue), 119 | ] 120 | 121 | if let region { 122 | queryItems.append(URLQueryItem(name: "country", value: region.alpha2)) 123 | } 124 | 125 | queryItems.append(queryItem) 126 | 127 | urlComponents.queryItems = queryItems 128 | 129 | guard let url = urlComponents.url else { 130 | throw MASError.urlParsing("\(urlBase)?\(queryItems.map(\.description).joined(separator: "&"))") 131 | } 132 | 133 | return url 134 | } 135 | 136 | private func getSearchResults(from url: URL) async throws -> [SearchResult] { 137 | let (data, _) = try await networkSession.data(from: url) 138 | do { 139 | return try JSONDecoder().decode(SearchResultList.self, from: data).results 140 | } catch { 141 | throw MASError.jsonParsing(data: data) 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Sources/mas/Controllers/SpotlightInstalledApps.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpotlightInstalledApps.swift 3 | // mas 4 | // 5 | // Copyright © 2025 mas-cli. All rights reserved. 6 | // 7 | 8 | private import Foundation 9 | 10 | private var applicationsFolder: String { "/Applications" } 11 | 12 | @MainActor 13 | var installedApps: [InstalledApp] { 14 | get async { 15 | var observer: NSObjectProtocol? 16 | defer { 17 | if let observer { 18 | NotificationCenter.default.removeObserver(observer) 19 | } 20 | } 21 | 22 | let query = NSMetadataQuery() 23 | query.predicate = NSPredicate(format: "kMDItemAppStoreAdamID LIKE '*'") 24 | if let volume = UserDefaults(suiteName: "com.apple.appstored")?.dictionary(forKey: "PreferredVolume")?["name"] { 25 | query.searchScopes = [applicationsFolder, "/Volumes/\(volume)\(applicationsFolder)"] 26 | } else { 27 | query.searchScopes = [applicationsFolder] 28 | } 29 | 30 | return await withCheckedContinuation { continuation in 31 | observer = NotificationCenter.default.addObserver( 32 | forName: .NSMetadataQueryDidFinishGathering, 33 | object: query, 34 | queue: nil 35 | ) { notification in 36 | guard let query = notification.object as? NSMetadataQuery else { 37 | continuation.resume(returning: []) 38 | return 39 | } 40 | 41 | query.stop() 42 | 43 | continuation.resume( 44 | returning: query.results // swiftformat:disable indent 45 | .compactMap { result in 46 | if let item = result as? NSMetadataItem { 47 | InstalledApp( 48 | id: item.value(forAttribute: "kMDItemAppStoreAdamID") as? AppID ?? 0, 49 | bundleID: item.value(forAttribute: NSMetadataItemCFBundleIdentifierKey) as? String ?? "", 50 | name: (item.value(forAttribute: "_kMDItemDisplayNameWithExtensions") as? String ?? "").removingSuffix( 51 | ".app" 52 | ), 53 | path: item.value(forAttribute: NSMetadataItemPathKey) as? String ?? "", 54 | version: item.value(forAttribute: NSMetadataItemVersionKey) as? String ?? "" 55 | ) 56 | } else { 57 | nil 58 | } 59 | } 60 | .sorted { $0.name.caseInsensitiveCompare($1.name) == .orderedAscending } // swiftformat:enable indent 61 | ) 62 | } 63 | 64 | query.start() 65 | } 66 | } 67 | } 68 | 69 | private extension String { 70 | func removingSuffix(_ suffix: Self) -> Self { 71 | hasSuffix(suffix) // swiftformat:disable:next indent 72 | ? Self(dropLast(suffix.count)) 73 | : self 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/mas/Errors/MASError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MASError.swift 3 | // mas 4 | // 5 | // Copyright © 2015 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import Foundation 9 | 10 | enum MASError: Error, Equatable { 11 | case cancelled 12 | case jsonParsing(data: Data) 13 | case noDownloads 14 | case noSearchResultsFound(for: String) 15 | case noVendorWebsite(forAppID: AppID) 16 | case notSupported 17 | case runtimeError(String) 18 | case unknownAppID(AppID) 19 | case urlParsing(String) 20 | } 21 | 22 | extension MASError: CustomStringConvertible { 23 | var description: String { 24 | switch self { 25 | case .cancelled: 26 | "Download cancelled" 27 | case let .jsonParsing(data): 28 | if let unparsable = String(data: data, encoding: .utf8) { 29 | "Unable to parse response as JSON:\n\(unparsable)" 30 | } else { 31 | "Unable to parse response as JSON" 32 | } 33 | case .noDownloads: 34 | "No downloads began" 35 | case let .noSearchResultsFound(searchTerm): 36 | "No apps found in the Mac App Store for search term: \(searchTerm)" 37 | case let .noVendorWebsite(appID): 38 | "No vendor website available for app ID \(appID)" 39 | case .notSupported: 40 | """ 41 | This command is not supported on this macOS version due to changes in macOS 42 | See https://github.com/mas-cli/mas#known-issues 43 | """ 44 | case let .runtimeError(message): 45 | message 46 | case let .unknownAppID(appID): 47 | "No apps found in the Mac App Store for app ID \(appID)" 48 | case let .urlParsing(string): 49 | "Unable to parse URL from \(string)" 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/mas/Formatters/AppInfoFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppInfoFormatter.swift 3 | // mas 4 | // 5 | // Copyright © 2019 mas-cli. All rights reserved. 6 | // 7 | 8 | private import Foundation 9 | 10 | /// Formats text output for the info command. 11 | enum AppInfoFormatter { 12 | /// Formats text output with app info. 13 | /// 14 | /// - Parameter app: Search result with app data. 15 | /// - Returns: Multiline text output. 16 | static func format(app: SearchResult) -> String { 17 | """ 18 | \(app.trackName) \(app.version) [\(app.outputPrice)] 19 | By: \(app.sellerName) 20 | Released: \(humanReadableDate(app.currentVersionReleaseDate)) 21 | Minimum OS: \(app.minimumOsVersion) 22 | Size: \(humanReadableSize(app.fileSizeBytes)) 23 | From: \(app.trackViewUrl) 24 | """ 25 | } 26 | 27 | /// Formats a file size. 28 | /// 29 | /// - Parameter size: Numeric string. 30 | /// - Returns: Formatted file size description. 31 | private static func humanReadableSize(_ size: String) -> String { 32 | ByteCountFormatter.string(fromByteCount: Int64(size) ?? 0, countStyle: .file) 33 | } 34 | 35 | /// Formats a date in ISO-8601 date-only format. 36 | /// 37 | /// - Parameter serverDate: String containing a date in ISO-8601 format. 38 | /// - Returns: Simple date format. 39 | private static func humanReadableDate(_ serverDate: String) -> String { 40 | ISO8601DateFormatter().date(from: serverDate).map { date in 41 | ISO8601DateFormatter.string(from: date, timeZone: .current, formatOptions: [.withFullDate]) 42 | } // swiftformat:disable:next indent 43 | ?? "" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/mas/Formatters/AppListFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppListFormatter.swift 3 | // mas 4 | // 5 | // Copyright © 2020 mas-cli. All rights reserved. 6 | // 7 | 8 | /// Formats text output for the search command. 9 | enum AppListFormatter { 10 | static let idColumnMinWidth = 10 11 | 12 | /// Formats text output with list results. 13 | /// 14 | /// - Parameter installedApps: List of installed apps. 15 | /// - Returns: Multiline text output. 16 | static func format(_ installedApps: [InstalledApp]) -> String { 17 | guard let maxAppNameLength = installedApps.map(\.name.count).max() else { 18 | return "" 19 | } 20 | 21 | return 22 | installedApps.map { installedApp in 23 | """ 24 | \(installedApp.id.description.padding(toLength: idColumnMinWidth, withPad: " ", startingAt: 0))\ 25 | \(installedApp.name.padding(toLength: maxAppNameLength, withPad: " ", startingAt: 0))\ 26 | (\(installedApp.version)) 27 | """ 28 | } 29 | .joined(separator: "\n") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/mas/Formatters/Printer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Printer.swift 3 | // mas 4 | // 5 | // Copyright © 2025 mas-cli. All rights reserved. 6 | // 7 | 8 | private import ArgumentParser 9 | private import Atomics 10 | internal import Foundation 11 | 12 | /// Prints to `stdout` and `stderr` with ANSI color codes when connected to a terminal. 13 | /// 14 | /// Can only be initialized by the `run` global functions, which throw an `ExitCode(1)` iff any errors were printed. 15 | struct Printer { 16 | private let errorCounter = ManagedAtomic(0) 17 | 18 | var errorCount: UInt64 { errorCounter.load(ordering: .acquiring) } 19 | 20 | func log(_ message: String, to fileHandle: FileHandle) { 21 | if let data = message.data(using: .utf8) { 22 | fileHandle.write(data) 23 | } 24 | } 25 | 26 | /// Prints to `stdout`. 27 | func info(_ items: Any..., separator: String = " ", terminator: String = "\n") { 28 | print(items, separator: separator, terminator: terminator) 29 | } 30 | 31 | /// Clears current line from `stdout`, then prints to `stdout`, then flushes `stdout`. 32 | func ephemeral(_ items: Any..., separator: String = " ", terminator: String = "\n") { 33 | clearCurrentLine(fromStream: stdout) 34 | print(items, separator: separator, terminator: terminator) 35 | fflush(stdout) 36 | } 37 | 38 | /// Clears current line from `stdout`. 39 | func terminateEphemeral() { 40 | clearCurrentLine(fromStream: stdout) 41 | } 42 | 43 | /// Prints to `stdout`; if connected to a terminal, prefixes a blue arrow. 44 | func notice(_ items: Any..., separator: String = " ", terminator: String = "\n") { 45 | if isatty(fileno(stdout)) != 0 { 46 | // Blue bold arrow, Bold text 47 | print( 48 | "\(csi)1;34m==>\(csi)0m \(csi)1m\(message(items, separator: separator, terminator: terminator))\(csi)0m", 49 | terminator: "" 50 | ) 51 | } else { 52 | print("==> \(message(items, separator: separator, terminator: terminator))", terminator: "") 53 | } 54 | } 55 | 56 | /// Prints to `stderr`; if connected to a terminal, prefixes "Warning:" underlined in yellow. 57 | func warning(_ items: Any..., separator: String = " ", terminator: String = "\n") { 58 | if isatty(fileno(stderr)) != 0 { 59 | // Yellow, underlined "Warning:" prefix 60 | log( 61 | "\(csi)4;33mWarning:\(csi)0m \(message(items, separator: separator, terminator: terminator))", 62 | to: .standardError 63 | ) 64 | } else { 65 | log("Warning: \(message(items, separator: separator, terminator: terminator))", to: .standardError) 66 | } 67 | } 68 | 69 | /// Prints to `stderr`; if connected to a terminal, prefixes "Error:" underlined in red. 70 | func error(_ items: Any..., error: Error? = nil, separator: String = " ", terminator: String = "\n") { 71 | errorCounter.wrappingIncrement(ordering: .relaxed) 72 | 73 | let terminator = 74 | if let error { 75 | "\(items.isEmpty ? "" : "\n")\(error)\(terminator)" 76 | } else { 77 | terminator 78 | } 79 | 80 | if isatty(fileno(stderr)) != 0 { 81 | // Red, underlined "Error:" prefix 82 | log( 83 | "\(csi)4;31mError:\(csi)0m \(message(items, separator: separator, terminator: terminator))", 84 | to: .standardError 85 | ) 86 | } else { 87 | log("Error: \(message(items, separator: separator, terminator: terminator))", to: .standardError) 88 | } 89 | } 90 | 91 | func clearCurrentLine(fromStream stream: UnsafeMutablePointer) { 92 | if isatty(fileno(stream)) != 0 { 93 | print(csi, "2K", csi, "0G", separator: "", terminator: "") 94 | fflush(stream) 95 | } 96 | } 97 | 98 | private func message(_ items: Any..., separator: String = " ", terminator: String = "\n") -> String { 99 | items.map { String(describing: $0) }.joined(separator: separator).appending(terminator) 100 | } 101 | } 102 | 103 | func run(_ expression: (Printer) throws -> Void) throws { 104 | let printer = Printer() 105 | do { 106 | try expression(printer) 107 | } catch { 108 | printer.error(error: error) 109 | } 110 | if printer.errorCount > 0 { 111 | throw ExitCode(1) 112 | } 113 | } 114 | 115 | func run(_ expression: (Printer) async throws -> Void) async throws { 116 | let printer = Printer() 117 | do { 118 | try await expression(printer) 119 | } catch { 120 | printer.error(error: error) 121 | } 122 | if printer.errorCount > 0 { 123 | throw ExitCode(1) 124 | } 125 | } 126 | 127 | /// Terminal Control Sequence Indicator. 128 | private var csi: String { "\u{001B}[" } 129 | -------------------------------------------------------------------------------- /Sources/mas/Formatters/SearchResultFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchResultFormatter.swift 3 | // mas 4 | // 5 | // Copyright © 2019 mas-cli. All rights reserved. 6 | // 7 | 8 | /// Formats text output for the search command. 9 | enum SearchResultFormatter { 10 | /// Formats search results as text. 11 | /// 12 | /// - Parameters: 13 | /// - results: Search results containing app data 14 | /// - includePrice: Indicates whether to include prices in the output 15 | /// - Returns: Multiline text output. 16 | static func format(_ results: [SearchResult], includePrice: Bool = false) -> String { 17 | guard let maxAppNameLength = results.map(\.trackName.count).max() else { 18 | return "" 19 | } 20 | 21 | return 22 | results.map { result in 23 | String( 24 | format: includePrice ? "%12lu %@ (%@) %@" : "%12lu %@ (%@)", 25 | result.trackId, 26 | result.trackName.padding(toLength: maxAppNameLength, withPad: " ", startingAt: 0), 27 | result.version, 28 | result.outputPrice 29 | ) 30 | } 31 | .joined(separator: "\n") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/mas/MAS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MAS.swift 3 | // mas 4 | // 5 | // Copyright © 2021 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import ArgumentParser 9 | 10 | @main 11 | struct MAS: AsyncParsableCommand { 12 | static let configuration = CommandConfiguration( 13 | abstract: "Mac App Store command-line interface", 14 | subcommands: [ 15 | Account.self, 16 | Config.self, 17 | Home.self, 18 | Info.self, 19 | Install.self, 20 | List.self, 21 | Lucky.self, 22 | Open.self, 23 | Outdated.self, 24 | Purchase.self, 25 | Region.self, 26 | Reset.self, 27 | Search.self, 28 | SignIn.self, 29 | SignOut.self, 30 | Uninstall.self, 31 | Upgrade.self, 32 | Vendor.self, 33 | Version.self, 34 | ] 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /Sources/mas/Models/AppID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppID.swift 3 | // mas 4 | // 5 | // Copyright © 2024 mas-cli. All rights reserved. 6 | // 7 | 8 | typealias AppID = UInt64 9 | 10 | extension AppID { 11 | var notInstalledMessage: String { 12 | "No installed apps with app ID \(self)" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/mas/Models/InstalledApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstalledApp.swift 3 | // mas 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | private import Foundation 9 | private import Version 10 | 11 | struct InstalledApp: Hashable, Sendable { 12 | let id: AppID 13 | let bundleID: String 14 | let name: String 15 | let path: String 16 | let version: String 17 | } 18 | 19 | extension InstalledApp { 20 | var idAndName: String { 21 | "app ID \(id) (\(name))" 22 | } 23 | 24 | /// Determines whether the app is considered outdated. 25 | /// 26 | /// Updates that require a higher OS version are excluded. 27 | /// 28 | /// - Parameter storeApp: App from search result. 29 | /// - Returns: true if the app is outdated; false otherwise. 30 | func isOutdated(comparedTo storeApp: SearchResult) -> Bool { 31 | // If storeApp requires a version of macOS newer than the running version, do not consider self outdated. 32 | if let osVersion = Version(tolerant: storeApp.minimumOsVersion) { 33 | let requiredVersion = OperatingSystemVersion( 34 | majorVersion: osVersion.major, 35 | minorVersion: osVersion.minor, 36 | patchVersion: osVersion.patch 37 | ) 38 | guard ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion) else { 39 | return false 40 | } 41 | } 42 | 43 | // The App Store does not enforce semantic versioning, but we assume most apps follow versioning 44 | // schemes that increase numerically over time. 45 | return if 46 | let semanticBundleVersion = Version(tolerant: version), 47 | let semanticAppStoreVersion = Version(tolerant: storeApp.version) 48 | { 49 | semanticBundleVersion < semanticAppStoreVersion 50 | } else { 51 | // If a version string can't be parsed as a Semantic Version, our best effort is to 52 | // check for equality. The only version that matters is the one in the App Store. 53 | // https://semver.org 54 | version != storeApp.version 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/mas/Models/SearchResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchResult.swift 3 | // mas 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | struct SearchResult: Decodable { 9 | var currentVersionReleaseDate = "" 10 | var fileSizeBytes = "0" 11 | var formattedPrice = "0" as String? 12 | var minimumOsVersion = "" 13 | var sellerName = "" 14 | var sellerUrl = "" as String? 15 | var trackId = 0 as AppID 16 | var trackName = "" 17 | var trackViewUrl = "" 18 | var version = "" 19 | } 20 | 21 | extension SearchResult { 22 | var outputPrice: String { 23 | formattedPrice ?? "?" 24 | } 25 | } 26 | 27 | extension SearchResult: Hashable { 28 | func hash(into hasher: inout Hasher) { 29 | hasher.combine(trackId) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/mas/Models/SearchResultList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchResultList.swift 3 | // mas 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | struct SearchResultList: Decodable { 9 | var resultCount: Int // swiftlint:disable:this unused_declaration 10 | var results: [SearchResult] 11 | } 12 | -------------------------------------------------------------------------------- /Sources/mas/Network/NetworkSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkSession.swift 3 | // mas 4 | // 5 | // Copyright © 2019 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import Foundation 9 | 10 | protocol NetworkSession { 11 | func data(from url: URL) async throws -> (Data, URLResponse) 12 | } 13 | -------------------------------------------------------------------------------- /Sources/mas/Network/URL.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL.swift 3 | // mas 4 | // 5 | // Copyright © 2024 mas-cli. All rights reserved. 6 | // 7 | 8 | private import AppKit 9 | 10 | extension URL { 11 | func open() async throws { 12 | try await NSWorkspace.shared.open(self, configuration: NSWorkspace.OpenConfiguration()) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/mas/Network/URLSession+NetworkSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSession+NetworkSession.swift 3 | // mas 4 | // 5 | // Copyright © 2019 mas-cli. All rights reserved. 6 | // 7 | 8 | private import Foundation 9 | 10 | extension URLSession: NetworkSession {} 11 | -------------------------------------------------------------------------------- /Sources/mas/Utilities/ProcessInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProcessInfo.swift 3 | // mas 4 | // 5 | // Copyright © 2024 mas-cli. All rights reserved. 6 | // 7 | 8 | internal import Foundation 9 | 10 | extension ProcessInfo { 11 | var sudoUserName: String? { 12 | environment["SUDO_USER"] 13 | } 14 | 15 | var sudoGroupName: String? { 16 | guard 17 | let sudoGID, 18 | let group = getgrgid(sudoGID) 19 | else { 20 | return nil 21 | } 22 | 23 | return String(validatingCString: group.pointee.gr_name) 24 | } 25 | 26 | var sudoUID: uid_t? { 27 | if let uid = environment["SUDO_UID"] { 28 | uid_t(uid) 29 | } else { 30 | nil 31 | } 32 | } 33 | 34 | var sudoGID: gid_t? { 35 | if let gid = environment["SUDO_GID"] { 36 | gid_t(gid) 37 | } else { 38 | nil 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/masTests/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # 2 | # .swiftlint.yml 3 | # masTests 4 | # 5 | # SwiftLint 0.59.1 6 | # 7 | --- 8 | opt_in_rules: 9 | - closure_body_length 10 | - file_header 11 | disabled_rules: 12 | - force_try 13 | - force_unwrapping 14 | - quick_discouraged_pending_test 15 | - required_deinit 16 | closure_body_length: 17 | warning: 85 18 | file_header: 19 | required_pattern: | 20 | \/\/ 21 | \/\/ SWIFTLINT_CURRENT_FILENAME 22 | \/\/ masTests 23 | \/\/ 24 | \/\/ Copyright © 20\d{2} mas-cli\. All rights reserved\. 25 | \/\/ 26 | function_body_length: 27 | warning: 85 28 | large_tuple: 29 | warning: 5 30 | error: 10 31 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/AccountSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | private import ArgumentParser 9 | @testable private import mas 10 | private import Nimble 11 | import Quick 12 | 13 | final class AccountSpec: AsyncSpec { 14 | override static func spec() { 15 | describe("account command") { 16 | it("outputs not supported warning") { 17 | await expecta(await consequencesOf(try await MAS.Account.parse([]).run())) 18 | == UnvaluedConsequences(ExitCode(1), "", "Error: \(MASError.notSupported)\n") 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/HomeSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | private import ArgumentParser 9 | @testable private import mas 10 | private import Nimble 11 | import Quick 12 | 13 | final class HomeSpec: AsyncSpec { 14 | override static func spec() { 15 | describe("home command") { 16 | it("can't find app with unknown ID") { 17 | await expecta( 18 | await consequencesOf(try await MAS.Home.parse(["999"]).run(searcher: MockAppStoreSearcher())) 19 | ) 20 | == UnvaluedConsequences(ExitCode(1), "", "Error: No apps found in the Mac App Store for app ID 999\n") 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/InfoSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfoSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | private import ArgumentParser 9 | @testable private import mas 10 | private import Nimble 11 | import Quick 12 | 13 | final class InfoSpec: AsyncSpec { 14 | override static func spec() { 15 | describe("Info command") { 16 | it("can't find app with unknown ID") { 17 | await expecta( 18 | await consequencesOf(try await MAS.Info.parse(["999"]).run(searcher: MockAppStoreSearcher())) 19 | ) 20 | == UnvaluedConsequences(ExitCode(1), "", "Error: No apps found in the Mac App Store for app ID 999\n") 21 | } 22 | it("outputs app details") { 23 | let mockResult = SearchResult( 24 | currentVersionReleaseDate: "2019-01-07T18:53:13Z", 25 | fileSizeBytes: "1024", 26 | formattedPrice: "$2.00", 27 | minimumOsVersion: "10.14", 28 | sellerName: "Awesome Dev", 29 | trackId: 1111, 30 | trackName: "Awesome App", 31 | trackViewUrl: "https://awesome.app", 32 | version: "1.0" 33 | ) 34 | await expecta( 35 | await consequencesOf( 36 | try await MAS.Info.parse([String(mockResult.trackId)]).run( 37 | searcher: MockAppStoreSearcher([mockResult.trackId: mockResult]) 38 | ) 39 | ) 40 | ) 41 | == UnvaluedConsequences( 42 | nil, 43 | """ 44 | Awesome App 1.0 [$2.00] 45 | By: Awesome Dev 46 | Released: 2019-01-07 47 | Minimum OS: 10.14 48 | Size: 1 KB 49 | From: https://awesome.app 50 | 51 | """ 52 | ) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/InstallSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstallSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | @testable private import mas 9 | private import Nimble 10 | import Quick 11 | 12 | final class InstallSpec: AsyncSpec { 13 | override static func spec() { 14 | xdescribe("install command") { 15 | it("installs apps") { 16 | await expecta( 17 | await consequencesOf( 18 | try await MAS.Install.parse([]).run(installedApps: [], searcher: MockAppStoreSearcher()) 19 | ) 20 | ) 21 | == UnvaluedConsequences() 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/ListSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | private import ArgumentParser 9 | @testable private import mas 10 | private import Nimble 11 | import Quick 12 | 13 | final class ListSpec: QuickSpec { 14 | override static func spec() { 15 | describe("list command") { 16 | it("lists apps") { 17 | expect(consequencesOf(try MAS.List.parse([]).run(installedApps: []))) 18 | == UnvaluedConsequences( 19 | ExitCode(1), 20 | "", 21 | """ 22 | Error: No installed apps found 23 | 24 | If this is unexpected, the following command line should fix it by 25 | (re)creating the Spotlight index (which might take some time): 26 | 27 | sudo mdutil -Eai on 28 | 29 | """ 30 | ) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/LuckySpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LuckySpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | @testable private import mas 9 | private import Nimble 10 | import Quick 11 | 12 | final class LuckySpec: AsyncSpec { 13 | override static func spec() { 14 | let searcher = 15 | ITunesSearchAppStoreSearcher(networkSession: MockNetworkSession(responseResource: "search/slack.json")) 16 | 17 | xdescribe("lucky command") { 18 | it("installs the first app matching a search") { 19 | await expecta( 20 | await consequencesOf( 21 | try await MAS.Lucky.parse(["Slack"]).run(installedApps: [], searcher: searcher) 22 | ) 23 | ) 24 | == UnvaluedConsequences() 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/OpenSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2019 mas-cli. All rights reserved. 6 | // 7 | 8 | private import ArgumentParser 9 | @testable private import mas 10 | private import Nimble 11 | import Quick 12 | 13 | final class OpenSpec: AsyncSpec { 14 | override static func spec() { 15 | describe("open command") { 16 | it("can't find app with unknown ID") { 17 | await expecta( 18 | await consequencesOf(try await MAS.Open.parse(["999"]).run(searcher: MockAppStoreSearcher())) 19 | ) 20 | == UnvaluedConsequences(ExitCode(1), "", "Error: \(MASError.unknownAppID(999))\n") 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/OutdatedSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OutdatedSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | @testable private import mas 9 | private import Nimble 10 | import Quick 11 | 12 | final class OutdatedSpec: AsyncSpec { 13 | override static func spec() { 14 | describe("outdated command") { 15 | it("outputs apps with pending updates") { 16 | let mockSearchResult = 17 | SearchResult( 18 | currentVersionReleaseDate: "2024-09-02T00:27:00Z", 19 | fileSizeBytes: "998130", 20 | minimumOsVersion: "10.13", 21 | sellerName: "Harold Chu", 22 | sellerUrl: "https://example.com", 23 | trackId: 490_461_369, 24 | trackName: "Bandwidth+", 25 | trackViewUrl: "https://apps.apple.com/us/app/bandwidth/id490461369?mt=12&uo=4", 26 | version: "1.28" 27 | ) 28 | 29 | await expecta( 30 | await consequencesOf( 31 | try await MAS.Outdated.parse([]).run( 32 | installedApps: [ 33 | InstalledApp( 34 | id: mockSearchResult.trackId, 35 | bundleID: "au.id.haroldchu.mac.Bandwidth", 36 | name: mockSearchResult.trackName, 37 | path: "/Applications/Bandwidth+.app", 38 | version: "1.27" 39 | ), 40 | ], 41 | searcher: MockAppStoreSearcher([mockSearchResult.trackId: mockSearchResult]) 42 | ) 43 | ) 44 | ) 45 | == UnvaluedConsequences(nil, "490461369 Bandwidth+ (1.27 -> 1.28)\n") 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/PurchaseSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PurchaseSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2020 mas-cli. All rights reserved. 6 | // 7 | 8 | @testable private import mas 9 | private import Nimble 10 | import Quick 11 | 12 | final class PurchaseSpec: AsyncSpec { 13 | override static func spec() { 14 | xdescribe("purchase command") { 15 | it("purchases apps") { 16 | await expecta( 17 | await consequencesOf( 18 | try await MAS.Purchase.parse(["999"]).run(installedApps: [], searcher: MockAppStoreSearcher()) 19 | ) 20 | ) 21 | == UnvaluedConsequences() 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/SearchSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | private import ArgumentParser 9 | @testable private import mas 10 | private import Nimble 11 | import Quick 12 | 13 | final class SearchSpec: AsyncSpec { 14 | override static func spec() { 15 | describe("search command") { 16 | it("can find slack") { 17 | let mockResult = SearchResult( 18 | trackId: 1111, 19 | trackName: "slack", 20 | trackViewUrl: "mas preview url", 21 | version: "0.0" 22 | ) 23 | await expecta( 24 | await consequencesOf( 25 | try await MAS.Search.parse(["slack"]).run(searcher: MockAppStoreSearcher([mockResult.trackId: mockResult])) 26 | ) 27 | ) 28 | == UnvaluedConsequences(nil, " 1111 slack (0.0)\n") 29 | } 30 | it("fails when searching for nonexistent app") { 31 | let searchTerm = "nonexistent" 32 | await expecta( 33 | await consequencesOf( 34 | try await MAS.Search.parse([searchTerm]).run(searcher: MockAppStoreSearcher()) 35 | ) 36 | ) 37 | == UnvaluedConsequences( 38 | ExitCode(1), 39 | "", 40 | "Error: No apps found in the Mac App Store for search term: \(searchTerm)\n" 41 | ) 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/SignInSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | private import ArgumentParser 9 | @testable private import mas 10 | private import Nimble 11 | import Quick 12 | 13 | final class SignInSpec: QuickSpec { 14 | override static func spec() { 15 | describe("signin command") { 16 | it("signs in") { 17 | expect(consequencesOf(try MAS.SignIn.parse(["", ""]).run())) 18 | == UnvaluedConsequences(ExitCode(1), "", "Error: \(MASError.notSupported)\n") 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/SignOutSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignOutSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | @testable private import mas 9 | private import Nimble 10 | import Quick 11 | 12 | final class SignOutSpec: QuickSpec { 13 | override static func spec() { 14 | describe("signout command") { 15 | it("signs out") { 16 | expect(consequencesOf(try MAS.SignOut.parse([]).run())) == UnvaluedConsequences() 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/UninstallSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UninstallSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | @testable private import mas 9 | private import Nimble 10 | import Quick 11 | 12 | final class UninstallSpec: QuickSpec { 13 | override static func spec() { 14 | let appID = 12345 as AppID 15 | let app = InstalledApp( 16 | id: appID, 17 | bundleID: "com.some.app", 18 | name: "Some App", 19 | path: "/tmp/Some.app", 20 | version: "1.0" 21 | ) 22 | 23 | xdescribe("uninstall command") { 24 | context("dry run") { 25 | it("can't remove a missing app") { 26 | expect( 27 | consequencesOf( 28 | try MAS.Uninstall.parse(["--dry-run", String(appID)]).run(installedApps: []) 29 | ) 30 | ) 31 | == UnvaluedConsequences(nil, "No installed apps with app ID \(appID)") 32 | } 33 | it("finds an app") { 34 | expect( 35 | consequencesOf( 36 | try MAS.Uninstall.parse(["--dry-run", String(appID)]).run(installedApps: [app]) 37 | ) 38 | ) 39 | == UnvaluedConsequences(nil, "==> 'Some App' '/tmp/Some.app'\n==> (not removed, dry run)\n") 40 | } 41 | } 42 | context("wet run") { 43 | it("can't remove a missing app") { 44 | expect( 45 | consequencesOf( 46 | try MAS.Uninstall.parse([String(appID)]).run(installedApps: []) 47 | ) 48 | ) 49 | == UnvaluedConsequences(nil, "No installed apps with app ID \(appID)") 50 | } 51 | it("removes an app") { 52 | expect( 53 | consequencesOf( 54 | try MAS.Uninstall.parse([String(appID)]).run(installedApps: [app]) 55 | ) 56 | ) 57 | == UnvaluedConsequences() 58 | } 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/UpgradeSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpgradeSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | @testable private import mas 9 | private import Nimble 10 | import Quick 11 | 12 | final class UpgradeSpec: AsyncSpec { 13 | override static func spec() { 14 | describe("upgrade command") { 15 | it("finds no upgrades") { 16 | await expecta( 17 | await consequencesOf( 18 | try await MAS.Upgrade.parse([]).run(installedApps: [], searcher: MockAppStoreSearcher()) 19 | ) 20 | ) 21 | == UnvaluedConsequences() 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/VendorSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VendorSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2019 mas-cli. All rights reserved. 6 | // 7 | 8 | private import ArgumentParser 9 | @testable private import mas 10 | private import Nimble 11 | import Quick 12 | 13 | final class VendorSpec: AsyncSpec { 14 | override static func spec() { 15 | describe("vendor command") { 16 | it("can't find app with unknown ID") { 17 | await expecta( 18 | await consequencesOf(try await MAS.Vendor.parse(["999"]).run(searcher: MockAppStoreSearcher())) 19 | ) 20 | == UnvaluedConsequences(ExitCode(1), "", "Error: No apps found in the Mac App Store for app ID 999\n") 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/masTests/Commands/VersionSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VersionSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2018 mas-cli. All rights reserved. 6 | // 7 | 8 | @testable private import mas 9 | private import Nimble 10 | import Quick 11 | 12 | final class VersionSpec: QuickSpec { 13 | override static func spec() { 14 | describe("version command") { 15 | it("outputs the current version") { 16 | expect(consequencesOf(try MAS.Version.parse([]).run())) == UnvaluedConsequences(nil, "\(Package.version)\n") 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ITunesSearchAppStoreSearcherSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2019 mas-cli. All rights reserved. 6 | // 7 | 8 | @testable private import mas 9 | private import Nimble 10 | import Quick 11 | 12 | final class ITunesSearchAppStoreSearcherSpec: AsyncSpec { 13 | override static func spec() { 14 | describe("store") { 15 | it("can search for slack") { 16 | let searcher = 17 | ITunesSearchAppStoreSearcher(networkSession: MockNetworkSession(responseResource: "search/slack.json")) 18 | 19 | let consequences = await consequencesOf(try await searcher.search(for: "slack")) 20 | expect(consequences.value).to(haveCount(39)) 21 | expect(consequences.error) == nil 22 | expect(consequences.stdout).to(beEmpty()) 23 | expect(consequences.stderr).to(beEmpty()) 24 | } 25 | it("can lookup slack") { 26 | let appID = 803_453_959 as AppID 27 | let searcher = 28 | ITunesSearchAppStoreSearcher(networkSession: MockNetworkSession(responseResource: "lookup/slack.json")) 29 | 30 | let consequences = await consequencesOf(try await searcher.lookup(appID: appID)) 31 | expect(consequences.error) == nil 32 | expect(consequences.stdout).to(beEmpty()) 33 | expect(consequences.stderr).to(beEmpty()) 34 | 35 | guard let result = consequences.value else { 36 | expect(consequences.value) != nil 37 | return 38 | } 39 | 40 | expect(result.trackId) == appID 41 | expect(result.sellerName) == "Slack Technologies, Inc." 42 | expect(result.sellerUrl) == "https://slack.com" 43 | expect(result.trackName) == "Slack" 44 | expect(result.trackViewUrl) == "https://itunes.apple.com/us/app/slack/id803453959?mt=12&uo=4" 45 | expect(result.version) == "3.3.3" 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/masTests/Controllers/MockAppStoreSearcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockAppStoreSearcher.swift 3 | // masTests 4 | // 5 | // Copyright © 2019 mas-cli. All rights reserved. 6 | // 7 | 8 | @testable internal import mas 9 | 10 | struct MockAppStoreSearcher: AppStoreSearcher { 11 | let apps: [AppID: SearchResult] 12 | 13 | init(_ apps: [AppID: SearchResult] = [:]) { 14 | self.apps = apps 15 | } 16 | 17 | func lookup(appID: AppID, inRegion _: ISORegion?) throws -> SearchResult { 18 | guard let result = apps[appID] else { 19 | throw MASError.unknownAppID(appID) 20 | } 21 | 22 | return result 23 | } 24 | 25 | func search(for searchTerm: String, inRegion _: ISORegion?) -> [SearchResult] { 26 | apps.filter { $1.trackName.contains(searchTerm) }.map { $1 } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/masTests/Extensions/Data.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Data.swift 3 | // masTests 4 | // 5 | // Copyright © 2019 mas-cli. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Data { 11 | /// Unsafe initializer for loading data from string paths. 12 | /// 13 | /// - Parameters: 14 | /// - resourcePath: Relative path of resource within subfolderPath 15 | /// - ext: Extension of the resource 16 | /// - subfolderPath: Relative path of folder within the module 17 | init( 18 | fromResource resourcePath: String?, 19 | withExtension ext: String? = nil, 20 | inSubfolderPath subfolderPath: String? = "Resources" 21 | ) { 22 | try! self.init( 23 | contentsOf: Bundle.module.url(forResource: resourcePath, withExtension: ext, subdirectory: subfolderPath)!, 24 | options: .mappedIfSafe 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/masTests/Formatters/AppListFormatterSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppListFormatterSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2020 mas-cli. All rights reserved. 6 | // 7 | 8 | @testable private import mas 9 | private import Nimble 10 | import Quick 11 | 12 | final class AppListFormatterSpec: QuickSpec { 13 | override static func spec() { 14 | // Static func reference 15 | let format = AppListFormatter.format(_:) 16 | 17 | describe("app list formatter") { 18 | it("formats nothing as empty string") { 19 | expect(consequencesOf(format([]))) == ValuedConsequences("") 20 | } 21 | it("can format a single installed app") { 22 | let installedApp = InstalledApp( 23 | id: 12345, 24 | bundleID: "", 25 | name: "Awesome App", 26 | path: "", 27 | version: "19.2.1" 28 | ) 29 | expect(consequencesOf(format([installedApp]))) == ValuedConsequences("12345 Awesome App (19.2.1)") 30 | } 31 | it("can format two installed apps") { 32 | expect( 33 | consequencesOf( 34 | format( 35 | [ 36 | InstalledApp( 37 | id: 12345, 38 | bundleID: "", 39 | name: "Awesome App", 40 | path: "", 41 | version: "19.2.1" 42 | ), 43 | InstalledApp( 44 | id: 67890, 45 | bundleID: "", 46 | name: "Even Better App", 47 | path: "", 48 | version: "1.2.0" 49 | ), 50 | ] 51 | ) 52 | ) 53 | ) 54 | == ValuedConsequences("12345 Awesome App (19.2.1)\n67890 Even Better App (1.2.0)") 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/masTests/Formatters/SearchResultFormatterSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchResultFormatterSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2019 mas-cli. All rights reserved. 6 | // 7 | 8 | @testable private import mas 9 | private import Nimble 10 | import Quick 11 | 12 | final class SearchResultFormatterSpec: QuickSpec { 13 | override static func spec() { 14 | // Static func reference 15 | let format = SearchResultFormatter.format(_:includePrice:) 16 | 17 | describe("search results formatter") { 18 | it("formats nothing as empty string") { 19 | expect(consequencesOf(format([], false))) == ValuedConsequences("") 20 | } 21 | it("can format a single result") { 22 | let result = SearchResult( 23 | formattedPrice: "$9.87", 24 | trackId: 12345, 25 | trackName: "Awesome App", 26 | version: "19.2.1" 27 | ) 28 | expect(consequencesOf(format([result], false))) == ValuedConsequences(" 12345 Awesome App (19.2.1)") 29 | } 30 | it("can format a single result with price") { 31 | let result = SearchResult( 32 | formattedPrice: "$9.87", 33 | trackId: 12345, 34 | trackName: "Awesome App", 35 | version: "19.2.1" 36 | ) 37 | expect(consequencesOf(format([result], true))) 38 | == ValuedConsequences(" 12345 Awesome App (19.2.1) $9.87") 39 | } 40 | it("can format a two results") { 41 | expect( 42 | consequencesOf( 43 | format( 44 | [ 45 | SearchResult( 46 | formattedPrice: "$9.87", 47 | trackId: 12345, 48 | trackName: "Awesome App", 49 | version: "19.2.1" 50 | ), 51 | SearchResult( 52 | formattedPrice: "$0.01", 53 | trackId: 67890, 54 | trackName: "Even Better App", 55 | version: "1.2.0" 56 | ), 57 | ], 58 | false 59 | ) 60 | ) 61 | ) 62 | == ValuedConsequences(" 12345 Awesome App (19.2.1)\n 67890 Even Better App (1.2.0)") 63 | } 64 | it("can format a two results with prices") { 65 | expect( 66 | consequencesOf( 67 | format( 68 | [ 69 | SearchResult( 70 | formattedPrice: "$9.87", 71 | trackId: 12345, 72 | trackName: "Awesome App", 73 | version: "19.2.1" 74 | ), 75 | SearchResult( 76 | formattedPrice: "$0.01", 77 | trackId: 67890, 78 | trackName: "Even Better App", 79 | version: "1.2.0" 80 | ), 81 | ], 82 | true 83 | ) 84 | ) 85 | ) 86 | == ValuedConsequences( 87 | " 12345 Awesome App (19.2.1) $9.87\n 67890 Even Better App (1.2.0) $0.01" 88 | ) 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Tests/masTests/Models/InstalledAppSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstalledAppSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2021 mas-cli. All rights reserved. 6 | // 7 | 8 | @testable private import mas 9 | private import Nimble 10 | import Quick 11 | 12 | final class InstalledAppSpec: QuickSpec { 13 | override static func spec() { 14 | let app = InstalledApp( 15 | id: 111, 16 | bundleID: "", 17 | name: "App", 18 | path: "", 19 | version: "1.0.0" 20 | ) 21 | 22 | describe("installed app") { 23 | it("is not outdated when there is no new version available") { 24 | expect(consequencesOf(app.isOutdated(comparedTo: SearchResult(version: "1.0.0")))) == ValuedConsequences(false) 25 | } 26 | it("is outdated when there is a new version available") { 27 | expect(consequencesOf(app.isOutdated(comparedTo: SearchResult(version: "2.0.0")))) == ValuedConsequences(true) 28 | } 29 | it("is not outdated when the new version of mac-software requires a higher OS version") { 30 | expect( 31 | consequencesOf( 32 | app.isOutdated(comparedTo: SearchResult(minimumOsVersion: "99.0.0", version: "3.0.0")) 33 | ) 34 | ) 35 | == ValuedConsequences(false) 36 | } 37 | it("is not outdated when the new version of software requires a higher OS version") { 38 | expect( 39 | consequencesOf( 40 | app.isOutdated(comparedTo: SearchResult(minimumOsVersion: "99.0.0", version: "3.0.0")) 41 | ) 42 | ) 43 | == ValuedConsequences(false) 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/masTests/Models/SearchResultListSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchResultListSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2020 mas-cli. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | @testable private import mas 10 | private import Nimble 11 | import Quick 12 | 13 | final class SearchResultListSpec: QuickSpec { 14 | override static func spec() { 15 | describe("search result list") { 16 | it("can parse bbedit") { 17 | expect( 18 | consequencesOf( 19 | try JSONDecoder().decode(SearchResultList.self, from: Data(fromResource: "search/bbedit.json")).resultCount 20 | ) 21 | ) 22 | == ValuedConsequences(1) 23 | } 24 | it("can parse things") { 25 | expect( 26 | consequencesOf( 27 | try JSONDecoder().decode(SearchResultList.self, from: Data(fromResource: "search/things.json")).resultCount 28 | ) 29 | ) 30 | == ValuedConsequences(50) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/masTests/Models/SearchResultSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchResultSpec.swift 3 | // masTests 4 | // 5 | // Copyright © 2020 mas-cli. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | @testable private import mas 10 | private import Nimble 11 | import Quick 12 | 13 | final class SearchResultSpec: QuickSpec { 14 | override static func spec() { 15 | describe("search result") { 16 | it("can parse things") { 17 | expect( 18 | consequencesOf( 19 | try JSONDecoder() // swiftformat:disable indent 20 | .decode(SearchResult.self, from: Data(fromResource: "search/things-that-go-bump.json")) 21 | .trackId // swiftformat:enable indent 22 | ) 23 | ) 24 | == ValuedConsequences(1_472_954_003) 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/masTests/Network/MockNetworkSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockNetworkSession.swift 3 | // masTests 4 | // 5 | // Copyright © 2019 mas-cli. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | @testable private import mas 10 | 11 | /// Mock NetworkSession for testing with a saved response payload file. 12 | struct MockNetworkSession: NetworkSession { 13 | private let data: (Data, URLResponse) 14 | 15 | /// Initializes a mock URL session with a resource for the response. 16 | /// 17 | /// - Parameter responseResource: Resource containing response body. 18 | init(responseResource: String) { 19 | data = (Data(fromResource: responseResource), URLResponse()) 20 | } 21 | 22 | func data(from _: URL) -> (Data, URLResponse) { 23 | data 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/masTests/Resources/lookup/slack.json: -------------------------------------------------------------------------------- 1 | { 2 | "resultCount": 1, 3 | "results": [ 4 | { 5 | "screenshotUrls": [ 6 | "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/8d/07/74/8d0774c5-90aa-611c-3701-35f6158fb77e/source/800x500bb.jpg", 7 | "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/6d/86/a7/6d86a74d-5c45-1a61-7828-ff3251360271/source/800x500bb.jpg", 8 | "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/18/c1/38/18c138de-5bf2-586b-34de-c81e05568ea3/source/800x500bb.jpg", 9 | "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/4a/92/50/4a925081-61d0-ff32-eb2a-4b57db03aaab/source/800x500bb.jpg" 10 | ], 11 | "artworkUrl60": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/84/94/74/84947420-25dc-35e2-2761-9fa2b583c0a5/source/60x60bb.png", 12 | "artworkUrl512": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/84/94/74/84947420-25dc-35e2-2761-9fa2b583c0a5/source/512x512bb.png", 13 | "artworkUrl100": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/84/94/74/84947420-25dc-35e2-2761-9fa2b583c0a5/source/100x100bb.png", 14 | "artistViewUrl": "https://itunes.apple.com/us/developer/slack-technologies-inc/id453420243?mt=12&uo=4", 15 | "kind": "mac-software", 16 | "averageUserRatingForCurrentVersion": 4, 17 | "trackCensoredName": "Slack", 18 | "languageCodesISO2A": [ 19 | "EN", 20 | "FR", 21 | "DE", 22 | "JA", 23 | "ES" 24 | ], 25 | "fileSizeBytes": "74398324", 26 | "sellerUrl": "https://slack.com", 27 | "contentAdvisoryRating": "4+", 28 | "userRatingCountForCurrentVersion": 106, 29 | "trackViewUrl": "https://itunes.apple.com/us/app/slack/id803453959?mt=12&uo=4", 30 | "trackContentRating": "4+", 31 | "releaseNotes": "All updates are important, of course. This one contains security updates, and as we know, they’re the most important kind of all.", 32 | "currentVersionReleaseDate": "2018-10-02T23:28:05Z", 33 | "isVppDeviceBasedLicensingEnabled": true, 34 | "sellerName": "Slack Technologies, Inc.", 35 | "primaryGenreId": 12001, 36 | "primaryGenreName": "Business", 37 | "genreIds": [ 38 | "12001", 39 | "12014" 40 | ], 41 | "wrapperType": "software", 42 | "version": "3.3.3", 43 | "releaseDate": "2014-01-23T02:46:20Z", 44 | "minimumOsVersion": "10.9", 45 | "artistId": 453420243, 46 | "artistName": "Slack Technologies, Inc.", 47 | "genres": [ 48 | "Business", 49 | "Productivity" 50 | ], 51 | "price": 0, 52 | "currency": "USD", 53 | "description": "Slack brings team communication and collaboration into one place so you can get more work done, whether you belong to a large enterprise or a small business. Check off your to-do list and move your projects forward by bringing the right people, conversations, tools, and information you need together. Slack is available on any device, so you can find and access your team and your work, whether you’re at your desk or on the go.\n\nUse Slack to: \n• Communicate with your team and organize your conversations by topics, projects, or anything else that matters to your work\n• Message or call any person or group within your team\n• Share and edit documents and collaborate with the right people all in Slack \n• Integrate into your workflow, the tools and services you already use including Google Drive, Salesforce, Dropbox, Asana, Twitter, Zendesk, and more\n• Easily search a central knowledge base that automatically indexes and archives your team’s past conversations and files\n• Customize your notifications so you stay focused on what matters\n\nScientifically proven (or at least rumored) to make your working life simpler, more pleasant, and more productive. We hope you’ll give Slack a try.\n\nStop by and learn more at: https://slack.com/", 54 | "bundleId": "com.tinyspeck.slackmacgap", 55 | "trackId": 803453959, 56 | "trackName": "Slack", 57 | "formattedPrice": "Free", 58 | "userRatingCount": 1467, 59 | "averageUserRating": 4 60 | } 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /Tests/masTests/Resources/search/things-that-go-bump.json: -------------------------------------------------------------------------------- 1 | { 2 | "screenshotUrls": [ 3 | "https://is2-ssl.mzstatic.com/image/thumb/Purple123/v4/36/fe/ff/36feffbc-a07b-e61e-f0e5-88dcc4455871/pr_source.png/800x500bb.jpg", 4 | "https://is2-ssl.mzstatic.com/image/thumb/Purple113/v4/c6/85/09/c68509b2-c2c8-3000-bf85-4ead056b26f3/pr_source.png/800x500bb.jpg", 5 | "https://is4-ssl.mzstatic.com/image/thumb/Purple113/v4/18/42/aa/1842aab5-0500-b08b-b9a5-fc364f83fbdb/pr_source.png/800x500bb.jpg", 6 | "https://is3-ssl.mzstatic.com/image/thumb/Purple113/v4/de/b9/99/deb99962-f1d0-a7ad-0fc8-ef4bf906515b/pr_source.png/800x500bb.jpg", 7 | "https://is2-ssl.mzstatic.com/image/thumb/Purple123/v4/41/70/7d/41707d88-8ba1-5a28-1f2f-0f2e43a73706/pr_source.png/800x500bb.jpg", 8 | "https://is1-ssl.mzstatic.com/image/thumb/Purple124/v4/be/a3/a2/bea3a233-d82f-34bf-b0cd-38f262b04939/pr_source.png/800x500bb.jpg", 9 | "https://is2-ssl.mzstatic.com/image/thumb/Purple113/v4/e5/41/b4/e541b49d-06ed-9ec6-1544-3df88c8dc340/pr_source.png/800x500bb.jpg", 10 | "https://is3-ssl.mzstatic.com/image/thumb/Purple124/v4/8f/08/49/8f0849f4-7d20-567f-47e6-ef1bfb901619/pr_source.png/800x500bb.jpg", 11 | "https://is5-ssl.mzstatic.com/image/thumb/Purple123/v4/7d/74/8a/7d748af9-50fa-e009-39a8-b5eb7774b2be/pr_source.png/800x500bb.jpg" 12 | ], 13 | "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Purple124/v4/d4/b9/74/d4b974d7-0c4c-1515-49ec-ecedec84c5a0/source/60x60bb.png", 14 | "artworkUrl512": "https://is1-ssl.mzstatic.com/image/thumb/Purple124/v4/d4/b9/74/d4b974d7-0c4c-1515-49ec-ecedec84c5a0/source/512x512bb.png", 15 | "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Purple124/v4/d4/b9/74/d4b974d7-0c4c-1515-49ec-ecedec84c5a0/source/100x100bb.png", 16 | "artistViewUrl": "https://apps.apple.com/us/developer/tinybop-inc/id682046582?mt=12&uo=4", 17 | "kind": "mac-software", 18 | "minimumOsVersion": "10.15.0", 19 | "trackName": "Things That Go Bump", 20 | "trackId": 1472954003, 21 | "sellerName": "Tinybop Inc.", 22 | "price": 0.99, 23 | "fileSizeBytes": "12345678", 24 | "formattedPrice": "$0.99", 25 | "releaseNotes": "* BOOM *, this is a BIG update. The house spawns a game room, complete with video games you can ENTER INTO. It's fun and a little bit weird! Try it! \n»-(¯`·.·´¯)->", 26 | "primaryGenreId": 6014, 27 | "primaryGenreName": "Games", 28 | "isVppDeviceBasedLicensingEnabled": true, 29 | "releaseDate": "2019-10-18T07:00:00Z", 30 | "genreIds": [ 31 | "6014", 32 | "7001", 33 | "7009" 34 | ], 35 | "currentVersionReleaseDate": "2020-03-18T17:39:23Z", 36 | "trackCensoredName": "Things That Go Bump", 37 | "languageCodesISO2A": [ 38 | "EN" 39 | ], 40 | "sellerUrl": "http://tinybop.com", 41 | "contentAdvisoryRating": "4+", 42 | "averageUserRatingForCurrentVersion": 0, 43 | "userRatingCountForCurrentVersion": 0, 44 | "averageUserRating": 0, 45 | "trackViewUrl": "https://apps.apple.com/us/app/things-that-go-bump/id1472954003?mt=12&uo=4", 46 | "trackContentRating": "4+", 47 | "description": "Have you ever heard something go bump in the night? \nPerhaps you’ve caught wind of a spirit or sprite. \nWhen the house is asleep,\nand there’s dark all around, \nspirits from objects awake and abound. \n\nThe spirits are crafty and like to cause trouble. \nThey're called yōkai and together, they double. \nMixing and mashing, they join to fight. \nCan you help them conquer this mysterious night? \n\nPlay with one person, two, three or four. \nFirst you’ll need to escape the dark junk drawer. \n\n. . . . . . . . . . . . . . . . . . . .\nIn Things That go Bump, familiar objects and rooms come to life every night, and nothing looks quite as does in the day. Create your creature, and battle your friends, but beware the house spirits! They can destroy and they can give life. Battle, create, and make your way through the rooms of the house, and slowly you will unravel the secret of Things that Go Bump. \n\nFeatures:\n * Spirits wake up objects and create yōkai (spirit creatures)\n * Combine everyday objects like umbrellas, staplers, cheese graters and more to create everchanging characters \n * Connect to other players via Game Center and face-off against other spirit creatures and house spirits\n * Add or swap objects to give your spirit creature new skills\n * Gain energy by making mischief, defeating other yōkai, and conquering the house spirits\n * Advance through the house (new rooms will be added throughout the year)\n * Test your curiosity and creativity with new challenges in every room\n * Play with 1-4 players across iPads, iPhones, iPods, AppleTVs and Macs\n * Fun and challenging for the whole family\n * Intuitive, safe, hilarious kid-friendly design\n * New levels introduced roughly every 2 months\n * Original artwork by Adrian Fernandez\n * Original sound design\n\nTinybop, Inc. is a Brooklyn-based studio of designers, engineers, and artists. We make toys for tomorrow. We’re all over the internet.\n\n Visit us: www.tinybop.com\n Follow us: twitter.com/tinybop\n Like us: facebook.com/tinybop\n Peek behind the scenes: instagram.com/tinybop\n\nWe love hearing your stories! If you have ideas, or something isn’t working as you expect it to, please contact us: support@tinybop.com.\n\nPsst! It's not Tiny Bop, or Tiny Bob, or Tiny Pop. It's Tinybop. :)", 48 | "currency": "USD", 49 | "artistId": 682046582, 50 | "artistName": "Tinybop Inc.", 51 | "genres": [ 52 | "Games", 53 | "Action", 54 | "Family" 55 | ], 56 | "bundleId": "uikitformac.com.tinybop.thingamabops", 57 | "version": "1.3.0", 58 | "wrapperType": "software", 59 | "userRatingCount": 0 60 | } 61 | -------------------------------------------------------------------------------- /Tests/masTests/Utilities/UnvaluedConsequences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnvaluedConsequences.swift 3 | // masTests 4 | // 5 | // Copyright © 2024 mas-cli. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | struct UnvaluedConsequences: Equatable { 11 | let error: Error? 12 | let stdout: String 13 | let stderr: String 14 | 15 | init(_ error: Error? = nil, _ stdout: String = "", _ stderr: String = "") { 16 | self.error = error 17 | self.stdout = stdout 18 | self.stderr = stderr 19 | } 20 | 21 | static func == (lhs: Self, rhs: Self) -> Bool { 22 | guard lhs.stdout == rhs.stdout, lhs.stderr == rhs.stderr else { 23 | return false 24 | } 25 | 26 | return switch (lhs.error, rhs.error) { 27 | case (nil, nil): 28 | true 29 | case let (lhsError?, rhsError?): 30 | (lhsError as NSError) == (rhsError as NSError) 31 | default: 32 | false 33 | } 34 | } 35 | } 36 | 37 | func consequencesOf( 38 | streamEncoding: String.Encoding = .utf8, 39 | _ expression: @autoclosure () throws -> Void 40 | ) -> UnvaluedConsequences { 41 | consequences(streamEncoding: streamEncoding, expression) 42 | } 43 | 44 | func consequencesOf( 45 | streamEncoding: String.Encoding = .utf8, 46 | _ expression: @autoclosure () async throws -> Void 47 | ) async -> UnvaluedConsequences { 48 | await consequences(streamEncoding: streamEncoding, expression) 49 | } 50 | 51 | // periphery:ignore 52 | func consequencesOf( 53 | streamEncoding: String.Encoding = .utf8, 54 | _ body: () throws -> Void 55 | ) -> UnvaluedConsequences { 56 | consequences(streamEncoding: streamEncoding, body) 57 | } 58 | 59 | // periphery:ignore 60 | func consequencesOf( 61 | streamEncoding: String.Encoding = .utf8, 62 | _ body: () async throws -> Void 63 | ) async -> UnvaluedConsequences { 64 | await consequences(streamEncoding: streamEncoding, body) 65 | } 66 | 67 | private func consequences( 68 | streamEncoding: String.Encoding = .utf8, 69 | _ body: () throws -> Void 70 | ) -> UnvaluedConsequences { 71 | let outOriginalFD = fileno(stdout) 72 | let errOriginalFD = fileno(stderr) 73 | 74 | let outDuplicateFD = dup(outOriginalFD) 75 | defer { 76 | close(outDuplicateFD) 77 | } 78 | 79 | let errDuplicateFD = dup(errOriginalFD) 80 | defer { 81 | close(errDuplicateFD) 82 | } 83 | 84 | let outPipe = Pipe() 85 | let errPipe = Pipe() 86 | 87 | dup2(outPipe.fileHandleForWriting.fileDescriptor, outOriginalFD) 88 | dup2(errPipe.fileHandleForWriting.fileDescriptor, errOriginalFD) 89 | 90 | var thrownError: Error? 91 | do { 92 | defer { 93 | fflush(stdout) 94 | fflush(stderr) 95 | dup2(outDuplicateFD, outOriginalFD) 96 | dup2(errDuplicateFD, errOriginalFD) 97 | outPipe.fileHandleForWriting.closeFile() 98 | errPipe.fileHandleForWriting.closeFile() 99 | } 100 | 101 | try body() 102 | } catch { 103 | thrownError = error 104 | } 105 | 106 | return UnvaluedConsequences( 107 | thrownError, 108 | String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: streamEncoding) ?? "", 109 | String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: streamEncoding) ?? "" 110 | ) 111 | } 112 | 113 | private func consequences( 114 | streamEncoding: String.Encoding = .utf8, 115 | _ body: () async throws -> Void 116 | ) async -> UnvaluedConsequences { 117 | let outOriginalFD = fileno(stdout) 118 | let errOriginalFD = fileno(stderr) 119 | 120 | let outDuplicateFD = dup(outOriginalFD) 121 | defer { 122 | close(outDuplicateFD) 123 | } 124 | 125 | let errDuplicateFD = dup(errOriginalFD) 126 | defer { 127 | close(errDuplicateFD) 128 | } 129 | 130 | let outPipe = Pipe() 131 | let errPipe = Pipe() 132 | 133 | dup2(outPipe.fileHandleForWriting.fileDescriptor, outOriginalFD) 134 | dup2(errPipe.fileHandleForWriting.fileDescriptor, errOriginalFD) 135 | 136 | var thrownError: Error? 137 | do { 138 | defer { 139 | fflush(stdout) 140 | fflush(stderr) 141 | dup2(outDuplicateFD, outOriginalFD) 142 | dup2(errDuplicateFD, errOriginalFD) 143 | outPipe.fileHandleForWriting.closeFile() 144 | errPipe.fileHandleForWriting.closeFile() 145 | } 146 | 147 | try await body() 148 | } catch { 149 | thrownError = error 150 | } 151 | 152 | return UnvaluedConsequences( 153 | thrownError, 154 | String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: streamEncoding) ?? "", 155 | String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: streamEncoding) ?? "" 156 | ) 157 | } 158 | -------------------------------------------------------------------------------- /Tests/masTests/Utilities/ValuedConsequences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ValuedConsequences.swift 3 | // masTests 4 | // 5 | // Copyright © 2024 mas-cli. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ValuedConsequences: Equatable { 11 | let value: E? 12 | let error: Error? 13 | let stdout: String 14 | let stderr: String 15 | 16 | init(_ value: E? = nil, _ error: Error? = nil, _ stdout: String = "", _ stderr: String = "") { 17 | self.value = value 18 | self.error = error 19 | self.stdout = stdout 20 | self.stderr = stderr 21 | } 22 | 23 | static func == (lhs: ValuedConsequences, rhs: ValuedConsequences) -> Bool { 24 | guard lhs.value == rhs.value, lhs.stdout == rhs.stdout, lhs.stderr == rhs.stderr else { 25 | return false 26 | } 27 | 28 | return switch (lhs.error, rhs.error) { 29 | case (nil, nil): 30 | true 31 | case let (lhsError?, rhsError?): 32 | (lhsError as NSError) == (rhsError as NSError) 33 | default: 34 | false 35 | } 36 | } 37 | } 38 | 39 | func consequencesOf( 40 | streamEncoding: String.Encoding = .utf8, 41 | _ expression: @autoclosure () throws -> E 42 | ) -> ValuedConsequences { 43 | consequences(streamEncoding: streamEncoding, expression) 44 | } 45 | 46 | func consequencesOf( 47 | streamEncoding: String.Encoding = .utf8, 48 | _ expression: @autoclosure () async throws -> E 49 | ) async -> ValuedConsequences { 50 | await consequences(streamEncoding: streamEncoding, expression) 51 | } 52 | 53 | // periphery:ignore 54 | func consequencesOf( 55 | streamEncoding: String.Encoding = .utf8, 56 | _ body: () throws -> E 57 | ) -> ValuedConsequences { 58 | consequences(streamEncoding: streamEncoding, body) 59 | } 60 | 61 | // periphery:ignore 62 | func consequencesOf( 63 | streamEncoding: String.Encoding = .utf8, 64 | _ body: () async throws -> E 65 | ) async -> ValuedConsequences { 66 | await consequences(streamEncoding: streamEncoding, body) 67 | } 68 | 69 | private func consequences( 70 | streamEncoding: String.Encoding = .utf8, 71 | _ body: () throws -> E 72 | ) -> ValuedConsequences { 73 | let outOriginalFD = fileno(stdout) 74 | let errOriginalFD = fileno(stderr) 75 | 76 | let outDuplicateFD = dup(outOriginalFD) 77 | defer { 78 | close(outDuplicateFD) 79 | } 80 | 81 | let errDuplicateFD = dup(errOriginalFD) 82 | defer { 83 | close(errDuplicateFD) 84 | } 85 | 86 | let outPipe = Pipe() 87 | let errPipe = Pipe() 88 | 89 | dup2(outPipe.fileHandleForWriting.fileDescriptor, outOriginalFD) 90 | dup2(errPipe.fileHandleForWriting.fileDescriptor, errOriginalFD) 91 | 92 | var value: E? 93 | var thrownError: Error? 94 | do { 95 | defer { 96 | fflush(stdout) 97 | fflush(stderr) 98 | dup2(outDuplicateFD, outOriginalFD) 99 | dup2(errDuplicateFD, errOriginalFD) 100 | outPipe.fileHandleForWriting.closeFile() 101 | errPipe.fileHandleForWriting.closeFile() 102 | } 103 | 104 | value = try body() 105 | } catch { 106 | thrownError = error 107 | } 108 | 109 | return ValuedConsequences( 110 | value, 111 | thrownError, 112 | String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: streamEncoding) ?? "", 113 | String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: streamEncoding) ?? "" 114 | ) 115 | } 116 | 117 | private func consequences( 118 | streamEncoding: String.Encoding = .utf8, 119 | _ body: () async throws -> E 120 | ) async -> ValuedConsequences { 121 | let outOriginalFD = fileno(stdout) 122 | let errOriginalFD = fileno(stderr) 123 | 124 | let outDuplicateFD = dup(outOriginalFD) 125 | defer { 126 | close(outDuplicateFD) 127 | } 128 | 129 | let errDuplicateFD = dup(errOriginalFD) 130 | defer { 131 | close(errDuplicateFD) 132 | } 133 | 134 | let outPipe = Pipe() 135 | let errPipe = Pipe() 136 | 137 | dup2(outPipe.fileHandleForWriting.fileDescriptor, outOriginalFD) 138 | dup2(errPipe.fileHandleForWriting.fileDescriptor, errOriginalFD) 139 | 140 | var value: E? 141 | var thrownError: Error? 142 | do { 143 | defer { 144 | fflush(stdout) 145 | fflush(stderr) 146 | dup2(outDuplicateFD, outOriginalFD) 147 | dup2(errDuplicateFD, errOriginalFD) 148 | outPipe.fileHandleForWriting.closeFile() 149 | errPipe.fileHandleForWriting.closeFile() 150 | } 151 | 152 | value = try await body() 153 | } catch { 154 | thrownError = error 155 | } 156 | 157 | return ValuedConsequences( 158 | value, 159 | thrownError, 160 | String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: streamEncoding) ?? "", 161 | String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: streamEncoding) ?? "" 162 | ) 163 | } 164 | -------------------------------------------------------------------------------- /audit_exceptions/github_prerelease_allowlist.json: -------------------------------------------------------------------------------- 1 | { 2 | "mas": "all" 3 | } 4 | -------------------------------------------------------------------------------- /contrib/completion/mas-completion.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | _mas() { 4 | local cur prev words cword 5 | if declare -F _init_completions >/dev/null 2>&1; then 6 | _init_completion 7 | else 8 | COMPREPLY=() 9 | _get_comp_words_by_ref cur prev words cword 10 | fi 11 | if [[ "${cword}" -eq 1 ]]; then 12 | local -r ifs_old="${IFS}" 13 | IFS=$'\n' 14 | local -a mas_help=($(mas help)) 15 | mas_help=("${mas_help[@]:5:${#mas_help[@]}-6}") 16 | mas_help=("${mas_help[@]# }") 17 | local -a commands=(help) 18 | for line in "${mas_help[@]}"; do 19 | if [[ ! "${line}" =~ ^\ ]]; then 20 | commands+=("${line%% *}") 21 | fi 22 | done 23 | COMPREPLY=($(compgen -W "${commands[*]}" -- "${cur}")) 24 | IFS="${ifs_old}" 25 | return 0 26 | fi 27 | } 28 | 29 | complete -F _mas mas 30 | -------------------------------------------------------------------------------- /contrib/completion/mas.fish: -------------------------------------------------------------------------------- 1 | # fish completions for mas 2 | 3 | function __fish_mas_list_available -d "Lists applications available to install from the Mac App Store" 4 | set query (commandline -ct) 5 | if set results (command mas search "$query" 2>/dev/null) 6 | for res in $results 7 | echo "$res" 8 | end | string trim --left | string replace -r '\s+' '\t' 9 | end 10 | end 11 | 12 | function __fish_mas_list_installed -d "Lists installed applications from the Mac App Store" 13 | command mas list 2>/dev/null | string replace -r '\s+' '\t' 14 | end 15 | 16 | function __fish_mas_outdated_installed -d "Lists outdated installed applications from the Mac App Store" 17 | command mas outdated 2>/dev/null | string replace -r '\s+' '\t' 18 | end 19 | 20 | # no file completions in mas 21 | complete -c mas -f 22 | 23 | ### account 24 | complete -c mas -n "__fish_use_subcommand" -f -a account -d "Output the Apple Account signed in to the Mac App Store" 25 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "account" 26 | ### config 27 | complete -c mas -n "__fish_use_subcommand" -f -a config -d "Output mas config & related system info" 28 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "config" 29 | complete -c mas -n "__fish_seen_subcommand_from config" -l markdown -d "Output as Markdown" 30 | ### help 31 | complete -c mas -n "__fish_use_subcommand" -f -a help -d "Output general or command-specific help" 32 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "help" 33 | ### home 34 | complete -c mas -n "__fish_use_subcommand" -f -a home -d "Open Mac App Store app pages in the default web browser" 35 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "home" 36 | complete -c mas -n "__fish_seen_subcommand_from home info install open purchase vendor" -xa "(__fish_mas_list_available)" 37 | ### info 38 | complete -c mas -n "__fish_use_subcommand" -f -a info -d "Output app information from the Mac App Store" 39 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "info" 40 | ### install 41 | complete -c mas -n "__fish_use_subcommand" -f -a install -d "Install previously purchased apps from the Mac App Store" 42 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "install" 43 | complete -c mas -n "__fish_seen_subcommand_from install lucky" -l force -d "Force reinstall" 44 | ### list 45 | complete -c mas -n "__fish_use_subcommand" -f -a list -d "List all apps installed from the Mac App Store" 46 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "list" 47 | ### lucky 48 | complete -c mas -n "__fish_use_subcommand" -f -a lucky -d "Install the first app returned from searching the Mac App Store (app must have been previously purchased)" 49 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "lucky" 50 | ### open 51 | complete -c mas -n "__fish_use_subcommand" -f -a open -d "Open app page in 'App Store.app'" 52 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "open" 53 | ### outdated 54 | complete -c mas -n "__fish_use_subcommand" -f -a outdated -d "List pending app updates from the Mac App Store" 55 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "outdated" 56 | complete -c mas -n "__fish_seen_subcommand_from outdated" -l verbose -d "Output warnings about app IDs unknown to the Mac App Store" 57 | ### purchase 58 | complete -c mas -n "__fish_use_subcommand" -f -a purchase -d "\"Purchase\" & install free apps from the Mac App Store" 59 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "purchase" 60 | ### region 61 | complete -c mas -n "__fish_use_subcommand" -f -a region -d "Output the region of the Mac App Store" 62 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "region" 63 | ### reset 64 | complete -c mas -n "__fish_use_subcommand" -f -a reset -d "Reset Mac App Store running processes" 65 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "reset" 66 | complete -c mas -n "__fish_seen_subcommand_from reset" -l debug -d "Output debug information" 67 | ### search 68 | complete -c mas -n "__fish_use_subcommand" -f -a search -d "Search for apps in the Mac App Store" 69 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "search" 70 | complete -c mas -n "__fish_seen_subcommand_from search" -l price -d "Output the price of each app" 71 | ### signin 72 | complete -c mas -n "__fish_use_subcommand" -f -a signin -d "Sign in to an Apple Account in the Mac App Store" 73 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "signin" 74 | complete -c mas -n "__fish_seen_subcommand_from signin" -l dialog -d "Provide password via graphical dialog" 75 | ### signout 76 | complete -c mas -n "__fish_use_subcommand" -f -a signout -d "Sign out of the Apple Account currently signed in to the Mac App Store" 77 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "signout" 78 | ### uninstall 79 | complete -c mas -n "__fish_use_subcommand" -f -a uninstall -d "Uninstall apps installed from the Mac App Store" 80 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "uninstall" 81 | complete -c mas -n "__fish_seen_subcommand_from uninstall" -l dry-run -d "Perform dry run" 82 | complete -c mas -n "__fish_seen_subcommand_from uninstall" -x -a "(__fish_mas_list_installed)" 83 | ### upgrade 84 | complete -c mas -n "__fish_use_subcommand" -f -a upgrade -d "Upgrade outdated apps installed from the Mac App Store" 85 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "upgrade" 86 | complete -c mas -n "__fish_seen_subcommand_from upgrade" -x -a "(__fish_mas_outdated_installed)" 87 | ### vendor 88 | complete -c mas -n "__fish_use_subcommand" -f -a vendor -d "Open apps' vendor pages in the default web browser" 89 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "vendor" 90 | ### version 91 | complete -c mas -n "__fish_use_subcommand" -f -a version -d "Output version number" 92 | complete -c mas -n "__fish_seen_subcommand_from help" -xa "version" 93 | -------------------------------------------------------------------------------- /mas-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mas-cli/mas/8a72828062ab54371331896126eba3d6aa8ddf45/mas-cli.png --------------------------------------------------------------------------------