Merge branch 'main' into upstream/releases/release-1.8.7

Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com>

# Manual Merge Cleanup:
#	.actrc
#	.github/workflows/pr-checks.yml
#	.github/workflows/release.yml
#	Brewfile
#	script/test
#	script/version_bump

# Conflicts:
#	.github/workflows/build-test.yml
#	.gitignore
#	.swiftlint.yml
#	Brewfile.lock.json
#	Package.resolved
#	Sources/mas/Package.swift
#	Tests/masTests/.swiftlint.yml
#	script/bootstrap
#	script/build
#	script/format
#	script/lint
#	script/uninstall
#	script/version
This commit is contained in:
Ross Goldberg 2024-10-26 23:48:05 -04:00
commit e9fcf2b254
No known key found for this signature in database
173 changed files with 5215 additions and 6973 deletions

2
.actrc
View file

@ -2,7 +2,7 @@
--eventpath .github/event.json --eventpath .github/event.json
--container-architecture linux/amd64 --container-architecture linux/amd64
--log-prefix-job-id --log-prefix-job-id
--platform macos-14=-self-hosted --platform macos-15=-self-hosted
--pull=false --pull=false
--reuse --reuse
--secret-file .secrets --secret-file .secrets

View file

@ -14,7 +14,7 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
build-test: build-test:
runs-on: macos-14 runs-on: macos-15
defaults: defaults:
run: run:
# Prefixes all `run` commands with the following command to force them to run outside Rosetta. # Prefixes all `run` commands with the following command to force them to run outside Rosetta.

View file

@ -12,7 +12,7 @@ on:
types: [published] types: [published]
jobs: jobs:
start: start:
runs-on: macos-14 runs-on: macos-15
outputs: outputs:
dry_run: ${{ steps.dry_run.outputs.dry_run }} dry_run: ${{ steps.dry_run.outputs.dry_run }}
mas_version: ${{ steps.mas_version.outputs.mas_version }} mas_version: ${{ steps.mas_version.outputs.mas_version }}
@ -44,7 +44,7 @@ jobs:
echo "RELEASE_BRANCH=releases/release-${{ github.event.release.tag_name }}" >>"$GITHUB_OUTPUT" echo "RELEASE_BRANCH=releases/release-${{ github.event.release.tag_name }}" >>"$GITHUB_OUTPUT"
prepare-release: prepare-release:
runs-on: macos-14 runs-on: macos-15
needs: [start] needs: [start]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -98,7 +98,7 @@ jobs:
--body "This PR contains the changes from releasing version [${MAS_VERSION}](https://github.com/mas-cli/mas/releases/tag/${MAS_VERSION})." --body "This PR contains the changes from releasing version [${MAS_VERSION}](https://github.com/mas-cli/mas/releases/tag/${MAS_VERSION})."
pkg-installer: pkg-installer:
runs-on: macos-14 runs-on: macos-15
needs: [start, prepare-release] needs: [start, prepare-release]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -132,7 +132,7 @@ jobs:
.build/mas.pkg .build/mas.pkg
homebrew-tap: homebrew-tap:
runs-on: macos-14 runs-on: macos-15
needs: [start, prepare-release] needs: [start, prepare-release]
steps: steps:
- name: 📺 Checkout mas repo - name: 📺 Checkout mas repo
@ -223,7 +223,7 @@ jobs:
.build/bottles/mas-*.bottle.tar.gz .build/bottles/mas-*.bottle.tar.gz
homebrew-core: homebrew-core:
runs-on: macos-14 runs-on: macos-15
needs: [start, prepare-release, homebrew-tap] needs: [start, prepare-release, homebrew-tap]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -244,4 +244,3 @@ jobs:
run: | run: |
DRY_RUN=${DRY_RUN} \ DRY_RUN=${DRY_RUN} \
script/brew_core_update ${MAS_VERSION} script/brew_core_update ${MAS_VERSION}

3
.gitignore vendored
View file

@ -25,6 +25,7 @@
.build/ .build/
.envrc .envrc
.fseventsd .fseventsd
.idea/
.rubygems/ .rubygems/
.secrets .secrets
.swiftpm/ .swiftpm/
@ -32,6 +33,8 @@
Carthage/ Carthage/
DerivedData DerivedData
Pods/ Pods/
Sources/mas/Package.swift
Sources/MasKit/Package.swift
Temporary Items Temporary Items
bin/ bin/
build/ build/

View file

@ -1,41 +1,62 @@
{ {
"indentation" : { "indentConditionalCompilationBlocks": false,
"spaces" : 4 "indentation": {
"spaces": 4
}, },
"lineLength" : 120, "lineBreakAroundMultilineExpressionChainComponents": true,
"rules" : { "lineBreakBeforeControlFlowKeywords": false,
"AllPublicDeclarationsHaveDocumentation" : false, "lineBreakBeforeEachArgument": true,
"AlwaysUseLowerCamelCase" : true, "lineBreakBeforeEachGenericRequirement": true,
"AmbiguousTrailingClosureOverload" : true, "lineBreakBetweenDeclarationAttributes": true,
"BeginDocumentationCommentWithOneLineSummary" : false, "lineLength": 120,
"DoNotUseSemicolons" : true, "maximumBlankLines": 1,
"DontRepeatTypeInStaticProperties" : true, "multiElementCollectionTrailingCommas": true,
"FileScopedDeclarationPrivacy" : true, "prioritizeKeepingFunctionOutputTogether": true,
"FullyIndirectEnum" : true, "respectsExistingLineBreaks": true,
"GroupNumericLiterals" : true, "rules": {
"IdentifiersMustBeASCII" : true, "AllPublicDeclarationsHaveDocumentation": true,
"NeverForceUnwrap" : false, "AlwaysUseLiteralForEmptyCollectionInit": true,
"NeverUseForceTry" : false, "AlwaysUseLowerCamelCase": true,
"NeverUseImplicitlyUnwrappedOptionals" : false, "AmbiguousTrailingClosureOverload": true,
"NoAccessLevelOnExtensionDeclaration" : false, "BeginDocumentationCommentWithOneLineSummary": true,
"NoBlockComments" : true, "DoNotUseSemicolons": true,
"NoCasesWithOnlyFallthrough" : true, "DontRepeatTypeInStaticProperties": true,
"NoEmptyTrailingClosureParentheses" : true, "FileScopedDeclarationPrivacy": true,
"NoLabelsInCasePatterns" : true, "FullyIndirectEnum": true,
"NoLeadingUnderscores" : false, "GroupNumericLiterals": true,
"NoParensAroundConditions" : true, "IdentifiersMustBeASCII": true,
"NoVoidReturnOnFunctionSignature" : true, "NeverForceUnwrap": true,
"OneCasePerLine" : true, "NeverUseForceTry": true,
"OneVariableDeclarationPerLine" : true, "NeverUseImplicitlyUnwrappedOptionals": true,
"OnlyOneTrailingClosureArgument" : true, "NoAccessLevelOnExtensionDeclaration": true,
"OrderedImports" : true, "NoAssignmentInExpressions": true,
"ReturnVoidInsteadOfEmptyTuple" : true, "NoBlockComments": true,
"UseLetInEveryBoundCaseVariable" : true, "NoCasesWithOnlyFallthrough": true,
"UseShorthandTypeNames" : true, "NoEmptyTrailingClosureParentheses": true,
"UseSingleLinePropertyGetter" : true, "NoLabelsInCasePatterns": true,
"UseSynthesizedInitializer" : true, "NoLeadingUnderscores": true,
"UseTripleSlashForDocumentationComments" : true, "NoParensAroundConditions": true,
"ValidateDocumentationComments" : false "NoPlaygroundLiterals": true,
"NoVoidReturnOnFunctionSignature": true,
"OmitExplicitReturns": true,
"OneCasePerLine": true,
"OneVariableDeclarationPerLine": true,
"OnlyOneTrailingClosureArgument": true,
"OrderedImports": true,
"ReplaceForEachWithForLoop": true,
"ReturnVoidInsteadOfEmptyTuple": true,
"TypeNamesShouldBeCapitalized": true,
"UseEarlyExits": true,
"UseLetInEveryBoundCaseVariable": true,
"UseShorthandTypeNames": true,
"UseSingleLinePropertyGetter": true,
"UseSynthesizedInitializer": true,
"UseTripleSlashForDocumentationComments": true,
"UseWhereClausesInForLoops": true,
"ValidateDocumentationComments": true
}, },
"version" : 1 "spacesAroundRangeFormationOperators": false,
"spacesBeforeEndOfLineComments": 1,
"TrailingComma": false,
"version": 1
} }

View file

@ -5,20 +5,34 @@
# https://github.com/nicklockwood/SwiftFormat#config-file # https://github.com/nicklockwood/SwiftFormat#config-file
# #
--exclude docs/
# Disabled rules # Disabled rules
--disable blankLinesAroundMark --disable hoistAwait
--disable consecutiveSpaces
--disable hoistPatternLet --disable hoistPatternLet
--disable hoistTry
# Enable later
--disable indent --disable indent
--disable trailingCommas --disable trailingCommas
# Enabled rules (disabled by default) # Enabled rules (disabled by default)
--enable trailingClosures #--enable acronyms
#--enable blankLinesBetweenImports
--enable blockComments
--enable docComments
--enable isEmpty
--enable noExplicitOwnership
#--enable organizeDeclarations
--enable redundantProperty
--enable sortSwitchCases
--enable wrapConditionalBodies
--enable wrapEnumCases
--enable wrapMultilineConditionalAssignment
--enable wrapSwitchCases
# Rule options # Rule options
--commas always --commas always
--extensionacl on-declarations --extensionacl on-declarations
--hexliteralcase lowercase
--importgrouping testable-last --importgrouping testable-last
--lineaftermarks false
--ranges no-space --ranges no-space

View file

@ -5,10 +5,40 @@
# https://github.com/realm/SwiftLint#configuration # https://github.com/realm/SwiftLint#configuration
# #
--- ---
opt_in_rules:
- all
disabled_rules: disabled_rules:
- non_optional_string_data_conversion - balanced_xctest_lifecycle
- closure_body_length
- contrasted_opening_brace
- explicit_acl
- explicit_enum_raw_value
- explicit_top_level_acl
- explicit_type_interface
- file_header
- file_name
- final_test_case
- force_unwrapping
- function_body_length
- inert_defer
- legacy_objc_type
- no_grouping_extension
- number_separator
- one_declaration_per_file
- prefer_nimble
- prefixed_toplevel_constant
- quick_discouraged_call
- quick_discouraged_pending_test
- required_deinit
- sorted_enum_cases
- trailing_comma - trailing_comma
excluded: - unused_capture_list
- docs - vertical_whitespace_between_cases
opening_brace: file_types_order:
allow_multiline_func: true order: [
[main_type],
[supporting_type],
[extension],
[preview_provider],
[library_content_provider]
]

View file

@ -3,10 +3,13 @@ brew "mise"
brew "sd" brew "sd"
brew "shellcheck" brew "shellcheck"
brew "shfmt" brew "shfmt"
brew "swift-format"
brew "swiftformat" brew "swiftformat"
brew "trash"
brew "yamllint"
# Already installed on GitHub Actions runner. if OS.mac? && MacOS.version >= :ventura
# brew "swiftlint" brew "swiftlint"
tap "peripheryapp/periphery"
tap "peripheryapp/periphery" cask "periphery"
cask "periphery" end

View file

@ -2,89 +2,79 @@
"entries": { "entries": {
"brew": { "brew": {
"markdownlint-cli": { "markdownlint-cli": {
"version": "0.39.0", "version": "0.42.0",
"bottle": { "bottle": {
"rebuild": 0, "rebuild": 0,
"root_url": "https://ghcr.io/v2/homebrew/core", "root_url": "https://ghcr.io/v2/homebrew/core",
"files": { "files": {
"arm64_sequoia": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/markdownlint-cli/blobs/sha256:a3e5181a75b8fb131ac22c9c63635573d403a3d720ef0c72bd58cecfb3c2fe3e",
"sha256": "a3e5181a75b8fb131ac22c9c63635573d403a3d720ef0c72bd58cecfb3c2fe3e"
},
"arm64_sonoma": { "arm64_sonoma": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/markdownlint-cli/blobs/sha256:7bc1310bfbeff34386c2b309988569bc6494b333edf9bf26d027fbecc1342c2f", "url": "https://ghcr.io/v2/homebrew/core/markdownlint-cli/blobs/sha256:a3e5181a75b8fb131ac22c9c63635573d403a3d720ef0c72bd58cecfb3c2fe3e",
"sha256": "7bc1310bfbeff34386c2b309988569bc6494b333edf9bf26d027fbecc1342c2f" "sha256": "a3e5181a75b8fb131ac22c9c63635573d403a3d720ef0c72bd58cecfb3c2fe3e"
}, },
"arm64_ventura": { "arm64_ventura": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/markdownlint-cli/blobs/sha256:7bc1310bfbeff34386c2b309988569bc6494b333edf9bf26d027fbecc1342c2f", "url": "https://ghcr.io/v2/homebrew/core/markdownlint-cli/blobs/sha256:a3e5181a75b8fb131ac22c9c63635573d403a3d720ef0c72bd58cecfb3c2fe3e",
"sha256": "7bc1310bfbeff34386c2b309988569bc6494b333edf9bf26d027fbecc1342c2f" "sha256": "a3e5181a75b8fb131ac22c9c63635573d403a3d720ef0c72bd58cecfb3c2fe3e"
},
"arm64_monterey": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/markdownlint-cli/blobs/sha256:7bc1310bfbeff34386c2b309988569bc6494b333edf9bf26d027fbecc1342c2f",
"sha256": "7bc1310bfbeff34386c2b309988569bc6494b333edf9bf26d027fbecc1342c2f"
}, },
"sonoma": { "sonoma": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/markdownlint-cli/blobs/sha256:0af1b50fb5bef4a0c47e1bd233e39262586885b48bc4c5a60592c5f42c2edf9f", "url": "https://ghcr.io/v2/homebrew/core/markdownlint-cli/blobs/sha256:758ba29dba62e69b33801d519cbf6186a84320adf3e8e08bda862c9b057c22e5",
"sha256": "0af1b50fb5bef4a0c47e1bd233e39262586885b48bc4c5a60592c5f42c2edf9f" "sha256": "758ba29dba62e69b33801d519cbf6186a84320adf3e8e08bda862c9b057c22e5"
}, },
"ventura": { "ventura": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/markdownlint-cli/blobs/sha256:0af1b50fb5bef4a0c47e1bd233e39262586885b48bc4c5a60592c5f42c2edf9f", "url": "https://ghcr.io/v2/homebrew/core/markdownlint-cli/blobs/sha256:758ba29dba62e69b33801d519cbf6186a84320adf3e8e08bda862c9b057c22e5",
"sha256": "0af1b50fb5bef4a0c47e1bd233e39262586885b48bc4c5a60592c5f42c2edf9f" "sha256": "758ba29dba62e69b33801d519cbf6186a84320adf3e8e08bda862c9b057c22e5"
},
"monterey": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/markdownlint-cli/blobs/sha256:0af1b50fb5bef4a0c47e1bd233e39262586885b48bc4c5a60592c5f42c2edf9f",
"sha256": "0af1b50fb5bef4a0c47e1bd233e39262586885b48bc4c5a60592c5f42c2edf9f"
}, },
"x86_64_linux": { "x86_64_linux": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/markdownlint-cli/blobs/sha256:7bc1310bfbeff34386c2b309988569bc6494b333edf9bf26d027fbecc1342c2f", "url": "https://ghcr.io/v2/homebrew/core/markdownlint-cli/blobs/sha256:a3e5181a75b8fb131ac22c9c63635573d403a3d720ef0c72bd58cecfb3c2fe3e",
"sha256": "7bc1310bfbeff34386c2b309988569bc6494b333edf9bf26d027fbecc1342c2f" "sha256": "a3e5181a75b8fb131ac22c9c63635573d403a3d720ef0c72bd58cecfb3c2fe3e"
} }
} }
} }
}, },
"mise": { "mise": {
"version": "2024.3.11", "version": "2024.10.11",
"bottle": { "bottle": {
"rebuild": 0, "rebuild": 0,
"root_url": "https://ghcr.io/v2/homebrew/core", "root_url": "https://ghcr.io/v2/homebrew/core",
"files": { "files": {
"arm64_sequoia": {
"cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/mise/blobs/sha256:3d1ce9664736b5039466ceeb8286f87150a220d76cf62e5c5538ed4c42c01ff0",
"sha256": "3d1ce9664736b5039466ceeb8286f87150a220d76cf62e5c5538ed4c42c01ff0"
},
"arm64_sonoma": { "arm64_sonoma": {
"cellar": ":any_skip_relocation", "cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/mise/blobs/sha256:2ccc2946ac8d9af9fdde8ce0ef8d9271010eee24f8824086134533480b95b69f", "url": "https://ghcr.io/v2/homebrew/core/mise/blobs/sha256:d28afbc2017aa2d5904da9ca14e1fc9d01d341bfd0adc5168ace961e326b5b1c",
"sha256": "2ccc2946ac8d9af9fdde8ce0ef8d9271010eee24f8824086134533480b95b69f" "sha256": "d28afbc2017aa2d5904da9ca14e1fc9d01d341bfd0adc5168ace961e326b5b1c"
}, },
"arm64_ventura": { "arm64_ventura": {
"cellar": ":any_skip_relocation", "cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/mise/blobs/sha256:8e87fe7f49fa24545c99bcefc1fd1a22edfa6426b8c59e349bd445ced8544dd2", "url": "https://ghcr.io/v2/homebrew/core/mise/blobs/sha256:c4005d63d073861cef944841aa88925b1b929bc150d94bebe65322a4c7a6886f",
"sha256": "8e87fe7f49fa24545c99bcefc1fd1a22edfa6426b8c59e349bd445ced8544dd2" "sha256": "c4005d63d073861cef944841aa88925b1b929bc150d94bebe65322a4c7a6886f"
},
"arm64_monterey": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/mise/blobs/sha256:2c0deb90ea8214e22f4f10fa53ef8d90b66c769f4784807ce99614c6c3a97b43",
"sha256": "2c0deb90ea8214e22f4f10fa53ef8d90b66c769f4784807ce99614c6c3a97b43"
}, },
"sonoma": { "sonoma": {
"cellar": ":any_skip_relocation", "cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/mise/blobs/sha256:458438873c3a621d77849261f7ef57173670bdf0a64e018592e1682685be28ce", "url": "https://ghcr.io/v2/homebrew/core/mise/blobs/sha256:d392eae34949295556f9c3598ce97bf44f5392b996b02ea31956169332a41f1c",
"sha256": "458438873c3a621d77849261f7ef57173670bdf0a64e018592e1682685be28ce" "sha256": "d392eae34949295556f9c3598ce97bf44f5392b996b02ea31956169332a41f1c"
}, },
"ventura": { "ventura": {
"cellar": ":any_skip_relocation", "cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/mise/blobs/sha256:55196d84f5fc7d1b56dd03943dd8744a23e064b78928ebc4de85d8659a71c9ca", "url": "https://ghcr.io/v2/homebrew/core/mise/blobs/sha256:fd1afe999715d971c78f4bfa90b74fae7b7f8688477b07ea89c67ee36ea8463a",
"sha256": "55196d84f5fc7d1b56dd03943dd8744a23e064b78928ebc4de85d8659a71c9ca" "sha256": "fd1afe999715d971c78f4bfa90b74fae7b7f8688477b07ea89c67ee36ea8463a"
},
"monterey": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/mise/blobs/sha256:31c0c0ca68d7b2d3d12492c97a7e36547c88cddc8c6e195a3a4fb7b443a74f31",
"sha256": "31c0c0ca68d7b2d3d12492c97a7e36547c88cddc8c6e195a3a4fb7b443a74f31"
}, },
"x86_64_linux": { "x86_64_linux": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/mise/blobs/sha256:97e003f7841847029e38986685ec809b9a6eab9b66409e6baad4e568860a23aa", "url": "https://ghcr.io/v2/homebrew/core/mise/blobs/sha256:22601cf4faf8764cf29d6cea72ef0e0789b10da581d899a229aace4e0069b2c4",
"sha256": "97e003f7841847029e38986685ec809b9a6eab9b66409e6baad4e568860a23aa" "sha256": "22601cf4faf8764cf29d6cea72ef0e0789b10da581d899a229aace4e0069b2c4"
} }
} }
} }
@ -95,6 +85,11 @@
"rebuild": 0, "rebuild": 0,
"root_url": "https://ghcr.io/v2/homebrew/core", "root_url": "https://ghcr.io/v2/homebrew/core",
"files": { "files": {
"arm64_sequoia": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/sd/blobs/sha256:3cf7ab4495f622a4f245bb1c7c30225ef881dc390ee5edc59a1d3c4381cecca1",
"sha256": "3cf7ab4495f622a4f245bb1c7c30225ef881dc390ee5edc59a1d3c4381cecca1"
},
"arm64_sonoma": { "arm64_sonoma": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/sd/blobs/sha256:6bc773a70934364157591cd888e617601a42ed1f615fda8f77364fa45631d08d", "url": "https://ghcr.io/v2/homebrew/core/sd/blobs/sha256:6bc773a70934364157591cd888e617601a42ed1f615fda8f77364fa45631d08d",
@ -139,6 +134,11 @@
"rebuild": 0, "rebuild": 0,
"root_url": "https://ghcr.io/v2/homebrew/core", "root_url": "https://ghcr.io/v2/homebrew/core",
"files": { "files": {
"arm64_sequoia": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/shellcheck/blobs/sha256:5045be1e530288251353848343322f5a423617d061830b7ea7465fe550787364",
"sha256": "5045be1e530288251353848343322f5a423617d061830b7ea7465fe550787364"
},
"arm64_sonoma": { "arm64_sonoma": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/shellcheck/blobs/sha256:ef742b6992cfcdcd7289718ac64b27174e421d29ce3ad9b81e1856349059b117", "url": "https://ghcr.io/v2/homebrew/core/shellcheck/blobs/sha256:ef742b6992cfcdcd7289718ac64b27174e421d29ce3ad9b81e1856349059b117",
@ -178,117 +178,241 @@
} }
}, },
"shfmt": { "shfmt": {
"version": "3.8.0", "version": "3.10.0",
"bottle": { "bottle": {
"rebuild": 0, "rebuild": 0,
"root_url": "https://ghcr.io/v2/homebrew/core", "root_url": "https://ghcr.io/v2/homebrew/core",
"files": { "files": {
"arm64_sequoia": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/shfmt/blobs/sha256:88d60bca61406671618ecf94f2d81104882f9dd8db838a70d0c2cd6c0fa46863",
"sha256": "88d60bca61406671618ecf94f2d81104882f9dd8db838a70d0c2cd6c0fa46863"
},
"arm64_sonoma": { "arm64_sonoma": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/shfmt/blobs/sha256:78a5017f23e2d4b9fd9312ce1e4e06c09cfb838d47e78cfb02ddb4190acb6b34", "url": "https://ghcr.io/v2/homebrew/core/shfmt/blobs/sha256:88d60bca61406671618ecf94f2d81104882f9dd8db838a70d0c2cd6c0fa46863",
"sha256": "78a5017f23e2d4b9fd9312ce1e4e06c09cfb838d47e78cfb02ddb4190acb6b34" "sha256": "88d60bca61406671618ecf94f2d81104882f9dd8db838a70d0c2cd6c0fa46863"
}, },
"arm64_ventura": { "arm64_ventura": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/shfmt/blobs/sha256:78a5017f23e2d4b9fd9312ce1e4e06c09cfb838d47e78cfb02ddb4190acb6b34", "url": "https://ghcr.io/v2/homebrew/core/shfmt/blobs/sha256:88d60bca61406671618ecf94f2d81104882f9dd8db838a70d0c2cd6c0fa46863",
"sha256": "78a5017f23e2d4b9fd9312ce1e4e06c09cfb838d47e78cfb02ddb4190acb6b34" "sha256": "88d60bca61406671618ecf94f2d81104882f9dd8db838a70d0c2cd6c0fa46863"
},
"arm64_monterey": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/shfmt/blobs/sha256:78a5017f23e2d4b9fd9312ce1e4e06c09cfb838d47e78cfb02ddb4190acb6b34",
"sha256": "78a5017f23e2d4b9fd9312ce1e4e06c09cfb838d47e78cfb02ddb4190acb6b34"
}, },
"sonoma": { "sonoma": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/shfmt/blobs/sha256:0d7952151f13e850fa40b03d6ba3f39daa8ec9401735aa91d6cd8e950f880d62", "url": "https://ghcr.io/v2/homebrew/core/shfmt/blobs/sha256:788b7ecff02bbff7fa1563a4937999972799361b4a0c49b1ed8545983d6ff989",
"sha256": "0d7952151f13e850fa40b03d6ba3f39daa8ec9401735aa91d6cd8e950f880d62" "sha256": "788b7ecff02bbff7fa1563a4937999972799361b4a0c49b1ed8545983d6ff989"
}, },
"ventura": { "ventura": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/shfmt/blobs/sha256:0d7952151f13e850fa40b03d6ba3f39daa8ec9401735aa91d6cd8e950f880d62", "url": "https://ghcr.io/v2/homebrew/core/shfmt/blobs/sha256:788b7ecff02bbff7fa1563a4937999972799361b4a0c49b1ed8545983d6ff989",
"sha256": "0d7952151f13e850fa40b03d6ba3f39daa8ec9401735aa91d6cd8e950f880d62" "sha256": "788b7ecff02bbff7fa1563a4937999972799361b4a0c49b1ed8545983d6ff989"
},
"monterey": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/shfmt/blobs/sha256:0d7952151f13e850fa40b03d6ba3f39daa8ec9401735aa91d6cd8e950f880d62",
"sha256": "0d7952151f13e850fa40b03d6ba3f39daa8ec9401735aa91d6cd8e950f880d62"
}, },
"x86_64_linux": { "x86_64_linux": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/shfmt/blobs/sha256:772a5dfe3e281fc51f6200313fb62b454314bf4978a8fe70ba2026a4fe5af5c4", "url": "https://ghcr.io/v2/homebrew/core/shfmt/blobs/sha256:0b15af30edec238edf607c38a95bd45249cdd6f48f30ab33bdd0a9c2ae2da956",
"sha256": "772a5dfe3e281fc51f6200313fb62b454314bf4978a8fe70ba2026a4fe5af5c4" "sha256": "0b15af30edec238edf607c38a95bd45249cdd6f48f30ab33bdd0a9c2ae2da956"
}
}
}
},
"swift-format": {
"version": "510.1.0",
"bottle": {
"rebuild": 0,
"root_url": "https://ghcr.io/v2/homebrew/core",
"files": {
"arm64_sequoia": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swift-format/blobs/sha256:4bee0106201ba2a3036576610e61832b97fb65292c194f52fc15d62e1bdb2243",
"sha256": "4bee0106201ba2a3036576610e61832b97fb65292c194f52fc15d62e1bdb2243"
},
"arm64_sonoma": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swift-format/blobs/sha256:ac50e5269ecc0bffb70a6c5077f97954e2e51c9158a3bfa36b86d89f9d6c5e43",
"sha256": "ac50e5269ecc0bffb70a6c5077f97954e2e51c9158a3bfa36b86d89f9d6c5e43"
},
"arm64_ventura": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swift-format/blobs/sha256:7fb047e8f80a72e5d8d7ae50c496d0cf59dd3ab654ce6048e4b7fa7b85afe69a",
"sha256": "7fb047e8f80a72e5d8d7ae50c496d0cf59dd3ab654ce6048e4b7fa7b85afe69a"
},
"sonoma": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swift-format/blobs/sha256:a99a19c9fc177a57b2577e3c1b30feb70f13388fc9c4e4ea7968f783058e09a0",
"sha256": "a99a19c9fc177a57b2577e3c1b30feb70f13388fc9c4e4ea7968f783058e09a0"
},
"ventura": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swift-format/blobs/sha256:a652f68cc4bed9c3186b66c8ee68e79b7387d37943aaff0a0c2d4197367b73fe",
"sha256": "a652f68cc4bed9c3186b66c8ee68e79b7387d37943aaff0a0c2d4197367b73fe"
},
"x86_64_linux": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swift-format/blobs/sha256:b1949fcb7f1b943fa5b0216bc6f18e12dc369c0538b093786332f851f22b0b03",
"sha256": "b1949fcb7f1b943fa5b0216bc6f18e12dc369c0538b093786332f851f22b0b03"
} }
} }
} }
}, },
"swiftformat": { "swiftformat": {
"version": "0.53.5", "version": "0.54.6_1",
"bottle": { "bottle": {
"rebuild": 0, "rebuild": 0,
"root_url": "https://ghcr.io/v2/homebrew/core", "root_url": "https://ghcr.io/v2/homebrew/core",
"files": { "files": {
"arm64_sequoia": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:03eb08eb7de0e697e574b5d5c94104a88c9548ee880b942f1916536fe7ff897a",
"sha256": "03eb08eb7de0e697e574b5d5c94104a88c9548ee880b942f1916536fe7ff897a"
},
"arm64_sonoma": { "arm64_sonoma": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:04e089d4b1ae1217dd6c8133b3c661add56d7c4f4f24ee67becd3cf8f54e6e80", "url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:0ff9c3c154fea61303bd060da1aecebb025a3a33460b24910cf55e6ae366574e",
"sha256": "04e089d4b1ae1217dd6c8133b3c661add56d7c4f4f24ee67becd3cf8f54e6e80" "sha256": "0ff9c3c154fea61303bd060da1aecebb025a3a33460b24910cf55e6ae366574e"
}, },
"arm64_ventura": { "arm64_ventura": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:19a6ce102e7df1cdee150dee619025aa3b2a4980070bee4f8cdd6976c0936d46", "url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:52200577da57cebd27e7d4b6a9ed84f6d3475b7f91e28ec4f5947fc2992cd943",
"sha256": "19a6ce102e7df1cdee150dee619025aa3b2a4980070bee4f8cdd6976c0936d46" "sha256": "52200577da57cebd27e7d4b6a9ed84f6d3475b7f91e28ec4f5947fc2992cd943"
},
"arm64_monterey": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:745ba037da0e1fe62f2f22faa45a17655b89d8870bacd9db32597ce1fd779509",
"sha256": "745ba037da0e1fe62f2f22faa45a17655b89d8870bacd9db32597ce1fd779509"
}, },
"sonoma": { "sonoma": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:6830f0bd5d06dca19d2bcd614e6d0c87e7a3d703d33bce90d0448a83310dddcc", "url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:9f79e28a0a5c7172be8bfcf23fca47de08f8bc03a3ddcdfbf52704445b9d8b18",
"sha256": "6830f0bd5d06dca19d2bcd614e6d0c87e7a3d703d33bce90d0448a83310dddcc" "sha256": "9f79e28a0a5c7172be8bfcf23fca47de08f8bc03a3ddcdfbf52704445b9d8b18"
}, },
"ventura": { "ventura": {
"cellar": ":any_skip_relocation", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:dacbfeca6cbe99fc73448f08c0289f135e807bc220ac1dcb61952410f1b43535", "url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:416528899d45dc25edc2f14c857239a2c922b4be548345423857f140c6b90f0f",
"sha256": "dacbfeca6cbe99fc73448f08c0289f135e807bc220ac1dcb61952410f1b43535" "sha256": "416528899d45dc25edc2f14c857239a2c922b4be548345423857f140c6b90f0f"
},
"monterey": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:a5e30f5378aca201ca8bc7a350ebac28b3202366be1b37cf254f77c27761753a",
"sha256": "a5e30f5378aca201ca8bc7a350ebac28b3202366be1b37cf254f77c27761753a"
}, },
"x86_64_linux": { "x86_64_linux": {
"cellar": "/home/linuxbrew/.linuxbrew/Cellar", "cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:909ae79dbe735c9377355e202d07a58aeff1af1707ba7a3c843cf7c3b10f68a9", "url": "https://ghcr.io/v2/homebrew/core/swiftformat/blobs/sha256:86c47e1a74da98ff5646c8d510ea5e6de45e9dc97bc59f151bd2a8848b5bc9f8",
"sha256": "909ae79dbe735c9377355e202d07a58aeff1af1707ba7a3c843cf7c3b10f68a9" "sha256": "86c47e1a74da98ff5646c8d510ea5e6de45e9dc97bc59f151bd2a8848b5bc9f8"
} }
} }
} }
} },
}, "trash": {
"tap": { "version": "0.9.2",
"peripheryapp/periphery": { "bottle": {
"revision": "4f73aefe6e01ba2543b9ee50f7653d866784fd61" "rebuild": 1,
} "root_url": "https://ghcr.io/v2/homebrew/core",
}, "files": {
"cask": { "arm64_sequoia": {
"periphery": { "cellar": ":any_skip_relocation",
"version": "2.18.0", "url": "https://ghcr.io/v2/homebrew/core/trash/blobs/sha256:f3b7a766bcc683b339c145ab7d8b484f2bbd65aac6903fd952dec7f4521efe5f",
"options": { "sha256": "f3b7a766bcc683b339c145ab7d8b484f2bbd65aac6903fd952dec7f4521efe5f"
"full_name": "periphery" },
"arm64_sonoma": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/trash/blobs/sha256:90cffd3d151720b768c48a8874f7b8dfaaf6f7a9e9000ffe23cfa3f9e4aa6b76",
"sha256": "90cffd3d151720b768c48a8874f7b8dfaaf6f7a9e9000ffe23cfa3f9e4aa6b76"
},
"arm64_ventura": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/trash/blobs/sha256:60186a8823abc9dd734475e3f787edd7c2d6a2254fff25b7289de2db15447099",
"sha256": "60186a8823abc9dd734475e3f787edd7c2d6a2254fff25b7289de2db15447099"
},
"arm64_monterey": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/trash/blobs/sha256:541af91d1cb128aa743460a529a3dcab5bac63b61ccde0a60d73aee23ab7d5c0",
"sha256": "541af91d1cb128aa743460a529a3dcab5bac63b61ccde0a60d73aee23ab7d5c0"
},
"arm64_big_sur": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/trash/blobs/sha256:11c0c85ec692ea6d4a125070f0a6ca576aff991608a6c9632b984cbf983e2481",
"sha256": "11c0c85ec692ea6d4a125070f0a6ca576aff991608a6c9632b984cbf983e2481"
},
"sonoma": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/trash/blobs/sha256:c26e06202022f708790f22f4477b65e3337d611c42e9a814ada1526bda03d923",
"sha256": "c26e06202022f708790f22f4477b65e3337d611c42e9a814ada1526bda03d923"
},
"ventura": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/trash/blobs/sha256:539093ca74c72ed8be974fd9042b14f55cde0ef2c1fadbedc7343099a394593e",
"sha256": "539093ca74c72ed8be974fd9042b14f55cde0ef2c1fadbedc7343099a394593e"
},
"monterey": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/trash/blobs/sha256:09b8ac7ade28ca59bd578b90680ece838a507b90b35e44d06a16f4d8ab9ae6e6",
"sha256": "09b8ac7ade28ca59bd578b90680ece838a507b90b35e44d06a16f4d8ab9ae6e6"
},
"big_sur": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/trash/blobs/sha256:403ba52ce97d38535e1d127ca227afd4ea2d0e0c8b414118dbc5376c9ed8f094",
"sha256": "403ba52ce97d38535e1d127ca227afd4ea2d0e0c8b414118dbc5376c9ed8f094"
},
"catalina": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/trash/blobs/sha256:b452d67cdeeb52db0aaadd258bc3e214a5ea5ed37da698b45017b01457115ea6",
"sha256": "b452d67cdeeb52db0aaadd258bc3e214a5ea5ed37da698b45017b01457115ea6"
},
"mojave": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/trash/blobs/sha256:d8ad5460b24a51a4a12b31ebf1a2887e9e86e029d061f6994c3c1caea7bf0551",
"sha256": "d8ad5460b24a51a4a12b31ebf1a2887e9e86e029d061f6994c3c1caea7bf0551"
},
"high_sierra": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/trash/blobs/sha256:0ef5ea924ba8d01398686657a839ad270796f3f10eee86d6522980d32038df9a",
"sha256": "0ef5ea924ba8d01398686657a839ad270796f3f10eee86d6522980d32038df9a"
}
}
}
},
"yamllint": {
"version": "1.35.1",
"bottle": {
"rebuild": 2,
"root_url": "https://ghcr.io/v2/homebrew/core",
"files": {
"arm64_sequoia": {
"cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/yamllint/blobs/sha256:cb74d6cc51501733531acd25b26fd474557d19374b98eb7de16271ff2c257860",
"sha256": "cb74d6cc51501733531acd25b26fd474557d19374b98eb7de16271ff2c257860"
},
"arm64_sonoma": {
"cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/yamllint/blobs/sha256:e36b43d6b87028fe2005878cb15c78edec6ddb898e9a86ff7b901fe093cf9c0e",
"sha256": "e36b43d6b87028fe2005878cb15c78edec6ddb898e9a86ff7b901fe093cf9c0e"
},
"arm64_ventura": {
"cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/yamllint/blobs/sha256:3591f98aaaebba5e9360926f5ca756dbd85c6a46de0554042376ac83548c7fb3",
"sha256": "3591f98aaaebba5e9360926f5ca756dbd85c6a46de0554042376ac83548c7fb3"
},
"sonoma": {
"cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/yamllint/blobs/sha256:3889369233f5f342b73cc70625748a52d72117603b92f352af00a9ebd27cb1c4",
"sha256": "3889369233f5f342b73cc70625748a52d72117603b92f352af00a9ebd27cb1c4"
},
"ventura": {
"cellar": ":any",
"url": "https://ghcr.io/v2/homebrew/core/yamllint/blobs/sha256:209883378df0edf4a0691fd2dbf6f2e8da7776bd9c0de0fc70fa04dd0fc51c8d",
"sha256": "209883378df0edf4a0691fd2dbf6f2e8da7776bd9c0de0fc70fa04dd0fc51c8d"
},
"x86_64_linux": {
"cellar": ":any_skip_relocation",
"url": "https://ghcr.io/v2/homebrew/core/yamllint/blobs/sha256:993514320174f1147d538719552131a73d34cf66dc9f82c38f6ed28b16cea287",
"sha256": "993514320174f1147d538719552131a73d34cf66dc9f82c38f6ed28b16cea287"
}
}
} }
} }
} }
}, },
"system": { "system": {
"macos": { "macos": {
"sonoma": { "monterey": {
"HOMEBREW_VERSION": "4.2.15-75-g221fde4", "HOMEBREW_VERSION": "4.4.2-62-g59d56f8",
"HOMEBREW_PREFIX": "/opt/homebrew", "HOMEBREW_PREFIX": "/usr/local",
"Homebrew/homebrew-core": "api", "Homebrew/homebrew-core": "api",
"CLT": "15.3.0.0.1.1708646388", "CLT": "14.2.0.0.1.1668646533",
"Xcode": "15.3", "Xcode": "14.2",
"macOS": "14.4.1" "macOS": "12.7.6"
} }
} }
} }

View file

@ -8,7 +8,7 @@ We love pull requests from everyone. By participating in this project, you agree
- [Open an issue](https://github.com/mas-cli/mas/issues/new) to simply ask a question or request a new feature. - [Open an issue](https://github.com/mas-cli/mas/issues/new) to simply ask a question or request a new feature.
- Search for similar issues with the - Search for similar issues with the
[ERROR MESSAGE](https://github.com/mas-cli/mas/issues?utf8=%E2%9C%93&q=is%3Aopen+ERROR+MESSAGE) [ERROR MESSAGE](https://github.com/mas-cli/mas/issues?utf8=%E2%9C%93&q=is%3Aopen+ERROR+MESSAGE)
you are exeriencing. you are experiencing.
- If one doesn't exist, [open a new issue](https://github.com/mas-cli/mas/issues/new) - If one doesn't exist, [open a new issue](https://github.com/mas-cli/mas/issues/new)
- Clearly describe the issue including steps to reproduce when it is a bug. - Clearly describe the issue including steps to reproduce when it is a bug.
- Include the earliest version of `mas` that you know has the issue. - Include the earliest version of `mas` that you know has the issue.

View file

@ -1,106 +1,77 @@
{ {
"object": { "pins" : [
"pins": [ {
{ "identity" : "cwlcatchexception",
"package": "Commandant", "kind" : "remoteSourceControl",
"repositoryURL": "https://github.com/Carthage/Commandant.git", "location" : "https://github.com/mattgallagher/CwlCatchException.git",
"state": { "state" : {
"branch": null, "revision" : "07b2ba21d361c223e25e3c1e924288742923f08c",
"revision": "a1671cf728db837cf5ec1980a80d276bbba748f6", "version" : "2.2.1"
"version": "0.18.0"
}
},
{
"package": "CwlCatchException",
"repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git",
"state": {
"branch": null,
"revision": "3b123999de19bf04905bc1dfdb76f817b0f2cc00",
"version": "2.1.2"
}
},
{
"package": "CwlPreconditionTesting",
"repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git",
"state": {
"branch": null,
"revision": "dc9af4781f2afdd1e68e90f80b8603be73ea7abc",
"version": "2.2.0"
}
},
{
"package": "Nimble",
"repositoryURL": "https://github.com/Quick/Nimble.git",
"state": {
"branch": null,
"revision": "1f3bde57bde12f5e7b07909848c071e9b73d6edc",
"version": "10.0.0"
}
},
{
"package": "PromiseKit",
"repositoryURL": "https://github.com/mxcl/PromiseKit.git",
"state": {
"branch": null,
"revision": "8a98e31a47854d3180882c8068cc4d9381bf382d",
"version": "6.22.1"
}
},
{
"package": "Quick",
"repositoryURL": "https://github.com/Quick/Quick.git",
"state": {
"branch": null,
"revision": "f9d519828bb03dfc8125467d8f7b93131951124c",
"version": "5.0.1"
}
},
{
"package": "Regex",
"repositoryURL": "https://github.com/sharplet/Regex.git",
"state": {
"branch": null,
"revision": "76c2b73d4281d77fc3118391877efd1bf972f515",
"version": "2.1.1"
}
},
{
"package": "swift-argument-parser",
"repositoryURL": "https://github.com/apple/swift-argument-parser.git",
"state": {
"branch": null,
"revision": "c8ed701b513cf5177118a175d85fbbbcd707ab41",
"version": "1.3.0"
}
},
{
"package": "swift-format",
"repositoryURL": "https://github.com/apple/swift-format",
"state": {
"branch": "release/5.9",
"revision": "1323e87eced56bdcfed1bb78af1f16f39274d032",
"version": null
}
},
{
"package": "swift-syntax",
"repositoryURL": "https://github.com/apple/swift-syntax.git",
"state": {
"branch": "release/5.9",
"revision": "9a101b70eee2a9dec04f92d2d47b22ebe57a1aae",
"version": null
}
},
{
"package": "Version",
"repositoryURL": "https://github.com/mxcl/Version.git",
"state": {
"branch": null,
"revision": "1fe824b80d89201652e7eca7c9252269a1d85e25",
"version": "2.0.1"
}
} }
] },
}, {
"version": 1 "identity" : "cwlpreconditiontesting",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git",
"state" : {
"revision" : "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071",
"version" : "2.2.2"
}
},
{
"identity" : "nimble",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Quick/Nimble.git",
"state" : {
"revision" : "1f3bde57bde12f5e7b07909848c071e9b73d6edc",
"version" : "10.0.0"
}
},
{
"identity" : "promisekit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mxcl/PromiseKit.git",
"state" : {
"revision" : "8a98e31a47854d3180882c8068cc4d9381bf382d",
"version" : "6.22.1"
}
},
{
"identity" : "quick",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Quick/Quick.git",
"state" : {
"revision" : "f9d519828bb03dfc8125467d8f7b93131951124c",
"version" : "5.0.1"
}
},
{
"identity" : "regex",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sharplet/Regex.git",
"state" : {
"revision" : "76c2b73d4281d77fc3118391877efd1bf972f515",
"version" : "2.1.1"
}
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser.git",
"state" : {
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
"version" : "1.5.0"
}
},
{
"identity" : "version",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mxcl/Version.git",
"state" : {
"revision" : "303a0f916772545e1e8667d3104f83be708a723c",
"version" : "2.1.0"
}
}
],
"version" : 2
} }

View file

@ -1,4 +1,4 @@
// swift-tools-version:5.3 // swift-tools-version:5.6.1
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -13,38 +13,24 @@ let package = Package(
.executable( .executable(
name: "mas", name: "mas",
targets: ["mas"] targets: ["mas"]
), )
.library(
name: "MasKit",
targets: ["MasKit"]
),
], ],
dependencies: [ dependencies: [
// Dependencies declare other packages that this package depends on. // Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/Carthage/Commandant.git", from: "0.18.0"),
.package(url: "https://github.com/Quick/Nimble.git", from: "10.0.0"), .package(url: "https://github.com/Quick/Nimble.git", from: "10.0.0"),
.package(url: "https://github.com/Quick/Quick.git", from: "5.0.0"), .package(url: "https://github.com/Quick/Quick.git", from: "5.0.1"),
.package(url: "https://github.com/mxcl/PromiseKit.git", from: "6.16.2"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"),
.package(url: "https://github.com/mxcl/Version.git", from: "2.0.1"), .package(url: "https://github.com/mxcl/PromiseKit.git", from: "6.22.1"),
.package(url: "https://github.com/mxcl/Version.git", from: "2.1.0"),
.package(url: "https://github.com/sharplet/Regex.git", from: "2.1.1"), .package(url: "https://github.com/sharplet/Regex.git", from: "2.1.1"),
], ],
targets: [ targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .executableTarget(
name: "mas", name: "mas",
dependencies: ["MasKit"],
swiftSettings: [
.unsafeFlags([
"-I", "Sources/PrivateFrameworks/CommerceKit",
"-I", "Sources/PrivateFrameworks/StoreFoundation",
])
]
),
.target(
name: "MasKit",
dependencies: [ dependencies: [
"Commandant", .product(name: "ArgumentParser", package: "swift-argument-parser"),
"PromiseKit", "PromiseKit",
"Regex", "Regex",
"Version", "Version",
@ -62,8 +48,8 @@ let package = Package(
] ]
), ),
.testTarget( .testTarget(
name: "MasKitTests", name: "masTests",
dependencies: ["MasKit", "Nimble", "Quick"], dependencies: ["mas", "Nimble", "Quick"],
resources: [.copy("JSON")], resources: [.copy("JSON")],
swiftSettings: [ swiftSettings: [
.unsafeFlags([ .unsafeFlags([
@ -75,30 +61,3 @@ let package = Package(
], ],
swiftLanguageVersions: [.v5] swiftLanguageVersions: [.v5]
) )
// https://github.com/apple/swift-format#matching-swift-format-to-your-swift-version-swift-57-and-earlier
#if compiler(>=5.8)
package.dependencies += [
.package(url: "https://github.com/apple/swift-format", .branch("release/5.9"))
]
#elseif compiler(>=5.7)
package.dependencies += [
.package(url: "https://github.com/apple/swift-format", .branch("release/5.7"))
]
#elseif compiler(>=5.6)
package.dependencies += [
.package(url: "https://github.com/apple/swift-format", .branch("release/5.6"))
]
#elseif compiler(>=5.5)
package.dependencies += [
.package(url: "https://github.com/apple/swift-format", .branch("swift-5.5-branch"))
]
#elseif compiler(>=5.4)
package.dependencies += [
.package(url: "https://github.com/apple/swift-format", .branch("swift-5.4-branch"))
]
#elseif compiler(>=5.3)
package.dependencies += [
.package(url: "https://github.com/apple/swift-format", .branch("swift-5.3-branch"))
]
#endif

312
README.md
View file

@ -1,16 +1,20 @@
<h1 align="center"><img src="mas-cli.png" alt="mas-cli" width="450" height="auto"></h1> <h1 align="center"><img src="mas-cli.png" alt="mas-cli" width="450" height="138"></h1>
# mas-cli # mas
A simple command line interface for the Mac App Store. Designed for scripting and automation. A command-line interface for the Mac App Store. Designed for scripting and automation.
[![Software License](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://github.com/mas-cli/mas/blob/main/LICENSE)
[![Swift 5](https://img.shields.io/badge/Language-Swift_5-orange.svg)](https://swift.org)
[![GitHub Release](https://img.shields.io/github/release/mas-cli/mas.svg)](https://github.com/mas-cli/mas/releases) [![GitHub Release](https://img.shields.io/github/release/mas-cli/mas.svg)](https://github.com/mas-cli/mas/releases)
[![Software License](https://img.shields.io/badge/license-MIT-lightgrey.svg)](
https://github.com/mas-cli/mas/blob/main/LICENSE
)
[![Swift 5](https://img.shields.io/badge/Language-Swift_5-orange.svg)](https://swift.org)
[![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com) [![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com)
[![Build, Test, & Lint](https://github.com/mas-cli/mas/actions/workflows/build-test.yml/badge.svg?branch=main)](https://github.com/mas-cli/mas/actions/workflows/build-test.yml?query=branch%3Amain) [![Build, Test, & Lint](https://github.com/mas-cli/mas/actions/workflows/build-test.yml/badge.svg?branch=main)](
https://github.com/mas-cli/mas/actions/workflows/build-test.yml?query=branch%3Amain
)
## 📲 Install ## 📲 Installation
### 🍺 Homebrew ### 🍺 Homebrew
@ -20,20 +24,24 @@ A simple command line interface for the Mac App Store. Designed for scripting an
brew install mas brew install mas
``` ```
### MacPorts ⚠️ macOS 10.15 (Catalina) or newer is required to install mas from the Homebrew core formula.
[MacPorts](https://www.macports.org/install.php) works as well: ### 🔌 MacPorts
[MacPorts](https://www.macports.org/install.php) is an alternative way to install:
```bash ```bash
sudo port install mas sudo port install mas
``` ```
⚠️ Note that macOS 10.15 (Catalina) is required to install mas from MacPorts or the core Homebrew formula. ⚠️ macOS 10.15 (Catalina) or newer is required to install mas from MacPorts.
### ☎️ Older macOS Versions ### ☎️ Older macOS Versions
#### 🍻 Custom Homebrew tap
We provide a [custom Homebrew tap](https://github.com/mas-cli/homebrew-tap) with pre-built bottles We provide a [custom Homebrew tap](https://github.com/mas-cli/homebrew-tap) with pre-built bottles
for all macOS versions since 10.11. for all macOS versions since 10.11 (El Capitan).
To install mas from our tap: To install mas from our tap:
@ -41,38 +49,48 @@ To install mas from our tap:
brew install mas-cli/tap/mas brew install mas-cli/tap/mas
``` ```
#### Swift 5 Runtime Support #### 🐙 GitHub Releases
mas requires Swift 5 runtime support. macOS 10.14.4 and later include it, but earlier releases did not. Alternatively, binaries and sources are available from the [GitHub Releases](https://github.com/mas-cli/mas/releases).
Without it, running `mas` may report an error similar to this:
#### 🕊 Swift 5 Runtime Support
mas requires Swift 5 runtime support. macOS 10.14.4 (Mojave) and newer include it, but earlier releases do not.
Without it, running mas might report errors similar to:
> dyld: Symbol not found: _$s11SubSequenceSlTl > dyld: Symbol not found: _$s11SubSequenceSlTl
To get Swift 5 support, you have a few options: To get Swift 5 support on macOS versions older than 10.14.4 (Mojave), you can:
- Upgrade to macOS 10.14.4 (Mojave) or newer.
- Install the [Swift 5 Runtime Support for Command Line Tools](https://support.apple.com/kb/DL1998). - Install the [Swift 5 Runtime Support for Command Line Tools](https://support.apple.com/kb/DL1998).
- Update to macOS 10.14.4 or later. - Install Xcode 10.2 or newer to `/Applications/Xcode.app`.
- Install Xcode 10.2 or later to `/Applications/Xcode.app`.
### 🐙 GitHub Releases
Alternatively, binaries are available in the [GitHub Releases](https://github.com/mas-cli/mas/releases).
## 🤳🏻 Usage ## 🤳🏻 Usage
Each application in the Mac App Store has a product identifier which is also ### 🪪 App IDs
used for mas-cli commands. Using `mas list` will show all installed
applications and their product identifiers.
```bash Each application in the Mac App Store has an integer app identifier (app ID).
$ mas list mas commands accept app IDs as arguments and output App IDs to uniquely identify apps.
446107677 Screens
407963104 Pixelmator
497799835 Xcode
```
It is possible to search for applications by name using `mas search` which `mas search` and `mas list` can be used to find the app IDs of relevant apps.
will search the Mac App Store and return matching identifiers.
Include the `--price` flag to include prices in the result. Alternatively, to find an app's app ID:
1. Find the app in the Mac App Store
2. Select `Share` > `Copy Link`
3. Extract the app ID from the URL. e.g., the Mac App Store URL for Xcode,
[https://apps.apple.com/us/app/xcode/id497799835?mt=12](https://apps.apple.com/us/app/xcode/id497799835?mt=12),
has app ID `497799835`
### 🛍 Info from the Mac App Store
None of the commands in this section require you to be logged into an Apple ID,
neither for your macOS user, nor in the Mac App Store.
#### `mas search`
`mas search <search-term>` searches by name for applications available from the Mac App Store.
Providing the `--price` flag includes each app's price in the output.
```bash ```bash
$ mas search Xcode $ mas search Xcode
@ -82,151 +100,200 @@ $ mas search Xcode
[...] [...]
``` ```
Another way to find the identifier for an app is to #### `mas info`
1. Find the app in the Mac App Store `mas info <app-id>` displays more detailed information about an application available from the Mac App Store.
2. Select `Share` > `Copy Link`
3. Grab the identifier from the string, e.g. for Xcode,
[https://apps.apple.com/us/app/xcode/id497799835?mt=12](https://apps.apple.com/us/app/xcode/id497799835?mt=12)
has identifier `497799835`
To install or update an application simply run `mas install` with an
application identifier:
```bash ```bash
$ mas install 808809998 $ mas info 497799835
==> Downloading PaintCode 2 Xcode 16.0 [0.0]
==> Installed PaintCode 2 By: Apple Inc.
Released: 2024-09-16
Minimum OS: 14.5
Size: 2.98 GB
From: https://apps.apple.com/us/app/xcode/id497799835?mt=12&uo=4
``` ```
If you want to install the first result that the `search` command returns, use the `lucky` command. ### 📚 Info from Your Local App Library
All the commands in this section require you to be logged into an Apple ID for your macOS user.
#### `mas list`
`mas list` displays all the applications on your Mac that were installed from the Mac App Store.
```bash ```bash
$ mas lucky twitter $ mas list
==> Downloading Twitter 497799835 Xcode (15.4)
==> Installed Twitter 640199958 Developer (10.6.5)
899247664 TestFlight (3.5.2)
``` ```
> Please note that this command will not allow you to install (or even purchase) an app for the first time: #### `mas outdated`
use the `purchase` command in that case.
> ⛔ The `purchase` command is not supported as of macOS 10.15 Catalina. Please see [Known Issues](#%EF%B8%8F-known-issues).
```bash `mas outdated` displays all applications installed from the Mac App Store on your computer that have pending upgrades.
$ mas purchase 768053424
==> Downloading Gapplin
==> Installed Gapplin
```
> Please note that you may have to re-authenticate yourself in the App Store to complete the purchase.
This is the case if the application is not free or if you configured your account not to remember the
credentials for free purchases.
Use `mas outdated` to list all applications with pending updates.
```bash ```bash
$ mas outdated $ mas outdated
497799835 Xcode (7.0) 497799835 Xcode (15.4 -> 16.0)
446107677 Screens VNC - Access Your Computer From Anywhere (3.6.7) 640199958 Developer (10.6.5 -> 10.6.6)
``` ```
> `mas` is only able to install/update applications that are listed in the Mac App Store itself. Run [`mas upgrade`](#mas-upgrade) to install pending upgrades.
Use [`softwareupdate(8)`] utility for downloading system updates (e.g. Xcode Command Line Tools)
To install all pending updates run `mas upgrade`. ### ⬇️ Installing Apps
All the commands in this section require you to be logged into an Apple ID in the Mac App Store.
> Depending on your Apple ID settings, you might need to re-authenticate yourself in the Mac App Store to perform a
> purchase, install, lucky, or upgrade, even if you are already signed in to an Apple ID in the Mac App Store.
#### `mas purchase`
`mas purchase <app-id>…` installs free applications that you haven't yet gotten/"purchased" from the Mac App Store.
> `purchase` is currently a misnomer, because it currently can only "purchase" free
> apps. To purchase apps that cost money, please purchase them directly in the Mac App Store.
```bash
$ mas purchase 497799835
==> Downloading Xcode
==> Installed Xcode
```
#### `mas install`
`mas install <app-id>…` installs apps that you have already gotten/"purchased" from the Mac App Store.
Providing the `--force` flag re-installs the app even if it is already installed on your computer.
```bash
$ mas install 497799835
==> Downloading Xcode
==> Installed Xcode
```
#### `mas lucky`
`mas lucky <search-term>` installs the first result that would be returned by `mas search <search-term>`.
Like `mas install`, `mas lucky` can only install apps that have previously been gotten/"purchased".
```bash
$ mas lucky Xcode
==> Downloading Xcode
==> Installed Xcode
```
### 🆕 Upgrading Apps
All the commands in this section require you to be logged into an Apple ID in the Mac App Store.
> mas only installs/upgrades applications from the Mac App Store.
Use [`softwareupdate(8)`] to install system updates (e.g., Xcode Command Line Tools, Safari, etc.)
#### `mas upgrade`
`mas upgrade` upgrades outdated apps installed from the Mac App Store. Without any arguments, it upgrades all such apps.
```bash ```bash
$ mas upgrade $ mas upgrade
Upgrading 2 outdated applications: Upgrading 2 outdated applications:
Xcode (7.0), Screens VNC - Access Your Computer From Anywhere (3.6.7) Xcode (15.4) -> (16.0)
Developer (10.6.5) -> (10.6.6)
==> Downloading Xcode ==> Downloading Xcode
==> Installed Xcode ==> Installed Xcode
==> Downloading iFlicks ==> Downloading Developer
==> Installed iFlicks ==> Installed Developer
``` ```
Updates can be performed selectively by providing the app identifier(s) to Upgrades can be performed selectively by providing app IDs to `mas upgrade`.
`mas upgrade`
```bash ```bash
$ mas upgrade 715768417 $ mas upgrade 715768417
Upgrading 1 outdated application: Upgrading 1 outdated application:
Xcode (8.0) Xcode (15.4) -> (16.0)
==> Downloading Xcode ==> Downloading Xcode
==> Installed Xcode ==> Installed Xcode
``` ```
### 🚏📥 Sign-in ### Mac App Store Account Management
> ⛔ The `signin` command is not supported as of macOS 10.13 High Sierra. Please see [Known Issues](#%EF%B8%8F-known-issues). All the commands in this section interact with the Apple ID for which you are signed in in the Mac App Store.
These commands do not interact with the Apple ID for which your macOS user is signed in.
To sign into the Mac App Store for the first time run `mas signin`. #### `mas signin`
> ⛔ The `signin` command is not supported on macOS 10.13 (High Sierra) or newer. On those macOS versions, please
> sign in via the Mac App Store instead. Please see [Known Issues](#known-issues).
On macOS 10.12 (Sierra) or older, `mas signin <apple-id>` signs in to the specified Apple ID in the Mac App Store.
```bash ```bash
$ mas signin mas@example.com $ mas signin mas@example.com
==> Signing in to Apple ID: mas@example.com
Password: Password:
``` ```
If you experience issues signing in this way, you can ask to sign in using a graphical dialog Providing the `--dialog` flag signs in using a graphical dialog provided by Mac App Store.
(provided by Mac App Store application):
```bash ```bash
$ mas signin --dialog mas@example.com mas signin --dialog mas@example.com
==> Signing in to Apple ID: mas@example.com
``` ```
You can also embed your password in the command. You can also embed your password in the command.
```bash ```bash
$ mas signin mas@example.com 'ZdkM4f$gzF;gX3ABXNLf8KcCt.x.np' mas signin mas@example.com MyPassword
==> Signing in to Apple ID: mas@example.com
``` ```
Use `mas signout` to sign out from the Mac App Store. #### `mas signout`
`mas signout` signs out from the current Apple ID in the Mac App Store.
## 🍺 Homebrew integration ## 🍺 Homebrew integration
`mas` is integrated with [homebrew-bundle]. If `mas` is installed, and you run `brew bundle dump`, mas integrates with [homebrew-bundle]. If mas is installed, when you run `brew bundle dump`,
then your Mac App Store apps will be included in the Brewfile created. See the [homebrew-bundle] your Mac App Store apps will be included in the created Brewfile. See the [homebrew-bundle]
docs for more details. docs for more details.
## ⚠️ Known Issues <!-- markdownlint-disable-next-line MD033 -->
## <a name="known-issues"></a> ⚠️ Known Issues
Over time, Apple has changed the APIs used by `mas` to manage App Store apps, limiting its capabilities. Please sign in ### 💥 Changed Apple Private Frameworks
or purchase apps using the App Store app instead. Subsequent redownloads can be performed with `mas install`.
- ⛔️ The `signin` command is not supported as of macOS 10.13 High Sierra. [#164](https://github.com/mas-cli/mas/issues/164) mas uses multiple undocumented Apple private frameworks to implement much of its functionality.
- ⛔️ The `purchase` command is not supported as of macOS 10.15 Catalina. [#289](https://github.com/mas-cli/mas/issues/289) Over time, Apple has silently changed these frameworks, breaking some functionality. Known issues include:
- ⛔️ The `account` command is not supported as of macOS 12 Monterey. [#417](https://github.com/mas-cli/mas/issues/417)
The versions `mas` sees from the app bundles on your Mac don't always match the versions reported by the App Store for - ⛔️ The `signin` command is not supported on macOS 10.13 (High Sierra) or newer. [#164](
the same app bundles. This leads to some confusion when the `outdated` and `upgrade` commands differ in behavior from https://github.com/mas-cli/mas/issues/164
what is shown as outdated in the App Store app. Further confusing matters, there is often some delay due to CDN )
propagation and caching between the time a new app version is released to the App Store, and the time it appears - ⛔️ The `account` command is not supported on macOS 12 (Monterey) or newer. [#417](
available in the App Store app or via the `mas` command. These issues cause symptoms like https://github.com/mas-cli/mas/issues/417
)
### 👀 Version Consistency
mas might be using suboptimal app version sources to compare local app versions with Mac App Store app versions.
That current sources are frequently consistent with the Mac App Store, but are infrequently inconsistent.
This might cause symptoms like [#384](https://github.com/mas-cli/mas/issues/384) and
[#387](https://github.com/mas-cli/mas/issues/387). mas will be updated soon to fix any such problems, if possible.
### ⏳ Eventual Consistency
The Mac App Store operates on eventual consistency, so the versions seen by various parts of mas or the Mac App Store
might be inconsistent for some period of time. This might cause symptoms like
[#384](https://github.com/mas-cli/mas/issues/384) and [#387](https://github.com/mas-cli/mas/issues/387). [#384](https://github.com/mas-cli/mas/issues/384) and [#387](https://github.com/mas-cli/mas/issues/387).
Macs with Apple silicon can install and run iOS and iPadOS apps from the App Store. `mas` is not yet aware of these ### 📱 iOS and iPadOS Apps
apps, and is not yet able to install or update them. [#321](https://github.com/mas-cli/mas/issues/321)
## 💥 When something doesn't work Macs with Apple Silicon can install and run iOS and iPadOS apps from the Mac App Store. mas is not yet aware of these
apps, and is not yet able to install or upgrade them. [#321](https://github.com/mas-cli/mas/issues/321)
If you see this error, it's probably because you haven't installed the app through the App Store yet. ### 📺 Using `tmux`
See [#46](https://github.com/mas-cli/mas/issues/46#issuecomment-248581233).
> This redownload is not available for this Apple ID either because it was bought by a different user or the
> item was refunded or cancelled.
If `mas` doesn't work for you as expected (e.g. you can't update/download apps), run `mas reset` and try again. mas operates via the same system services as the Mac App Store. These exist as
If the issue persists, please [file a bug](https://github.com/mas-cli/mas/issues/new). separate processes with communication through XPC. As a result of this, mas
All your feedback is much appreciated! ✨
## 📺 Using `tmux`
`mas` operates via the same system services as the Mac App Store. These exist as
separate processes with communication through XPC. As a result of this, `mas`
experiences similar problems as the pasteboard when running inside `tmux`. A experiences similar problems as the pasteboard when running inside `tmux`. A
[wrapper tool exists](https://github.com/ChrisJohnsen/tmux-MacOSX-pasteboard) to [wrapper tool exists](https://github.com/ChrisJohnsen/tmux-MacOSX-pasteboard) to
fix pasteboard behaviour which also works for `mas`. fix pasteboard behaviour which also works for mas.
You should consider configuring `tmux` to use the wrapper but if you do not wish You should consider configuring `tmux` to use the wrapper but if you do not wish
to do this it can be used on a one-off basis as follows: to do this it can be used on a one-off basis as follows:
@ -236,9 +303,20 @@ brew install reattach-to-user-namespace
reattach-to-user-namespace mas install reattach-to-user-namespace mas install
``` ```
## 🚫 When something doesn't work
If you see the following error, it's probably because you haven't yet "purchased" the app through the Mac App Store.
See [#46](https://github.com/mas-cli/mas/issues/46#issuecomment-248581233).
> This redownload is not available for this Apple ID either because it was bought by a different user or the
> item was refunded or cancelled.
If mas doesn't work for you as expected (e.g. you can't install/upgrade apps), run `mas reset`, then try again.
If the issue persists, please [file a bug](https://github.com/mas-cli/mas/issues/new).
All feedback is much appreciated! ✨
## Build from source ## Build from source
You can build from Xcode by opening the root `mas` directory, or from the Terminal: You can build from Xcode by opening the root mas directory, or from the Terminal:
```bash ```bash
script/bootstrap script/bootstrap
@ -251,7 +329,7 @@ Build output can be found in the `.build/` directory within the project.
The tests in this project are a recent work-in-progress. The tests in this project are a recent work-in-progress.
Since Xcode does not officially support tests for command-line tool targets, Since Xcode does not officially support tests for command-line tool targets,
all logic is part of the MasKit target with tests in MasKitTests. all logic is part of the mas target with tests in masTests.
Tests are written using [Quick]. Tests are written using [Quick].
```bash ```bash
@ -260,7 +338,7 @@ script/test
## 📄 License ## 📄 License
mas-cli was created by [@argon](https://github.com/argon). mas was created by [@argon](https://github.com/argon).
Code is under the [MIT license](LICENSE). Code is under the [MIT license](LICENSE).
[homebrew-bundle]: https://github.com/Homebrew/homebrew-bundle [homebrew-bundle]: https://github.com/Homebrew/homebrew-bundle

View file

@ -1,110 +0,0 @@
//
// Downloader.swift
// mas-cli
//
// Created by Andrew Naylor on 21/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import CommerceKit
import PromiseKit
import StoreFoundation
/// Downloads a list of apps, one after the other, printing progress to the console.
///
/// - Parameter appIDs: The IDs of the apps to be downloaded
/// - Parameter purchase: Flag indicating whether the apps needs to be purchased.
/// Only works for free apps. Defaults to false.
/// - Returns: A promise that completes when the downloads are complete. If any fail,
/// the promise is rejected with the first error, after all remaining downloads are attempted.
func downloadAll(_ appIDs: [UInt64], purchase: Bool = false) -> Promise<Void> {
var firstError: Error?
return appIDs.reduce(Guarantee<Void>.value(())) { previous, appID in
previous.then {
downloadWithRetries(appID, purchase: purchase).recover { error in
if firstError == nil {
firstError = error
}
}
}
}.done {
if let error = firstError {
throw error
}
}
}
private func downloadWithRetries(
_ appID: UInt64, purchase: Bool = false, attempts: Int = 3
) -> Promise<Void> {
download(appID, purchase: purchase).recover { error -> Promise<Void> in
guard attempts > 1 else {
throw error
}
// If the download failed due to network issues, try again. Otherwise, fail immediately.
guard case MASError.downloadFailed(let downloadError) = error,
case NSURLErrorDomain = downloadError?.domain
else {
throw error
}
let attempts = attempts - 1
printWarning((downloadError ?? error).localizedDescription)
printWarning("Trying again up to \(attempts) more \(attempts == 1 ? "time" : "times").")
return downloadWithRetries(appID, purchase: purchase, attempts: attempts)
}
}
/// Downloads an app, printing progress to the console.
///
/// - Parameter appID: The ID of the app to be downloaded
/// - Parameter purchase: Flag indicating whether the app needs to be purchased.
/// Only works for free apps. Defaults to false.
/// - Returns: A promise the completes when the download is complete.
private func download(_ appID: UInt64, purchase: Bool = false) -> Promise<Void> {
var storeAccount: ISStoreAccount?
if #unavailable(macOS 12) {
// Monterey obscured the user's account information, but still allows
// redownloads without passing it to SSPurchase.
// https://github.com/mas-cli/mas/issues/417
guard let account = ISStoreAccount.primaryAccount else {
return Promise(error: MASError.notSignedIn)
}
storeAccount = account as? ISStoreAccount
guard storeAccount != nil else {
fatalError("Unable to cast StoreAccount to ISStoreAccount")
}
}
return Promise<SSPurchase> { seal in
let purchase = SSPurchase(adamId: appID, account: storeAccount, purchase: purchase)
purchase.perform { purchase, _, error, response in
if let error {
seal.reject(MASError.purchaseFailed(error: error as NSError?))
return
}
guard response?.downloads.isEmpty == false, let purchase else {
print("No downloads")
seal.reject(MASError.noDownloads)
return
}
seal.fulfill(purchase)
}
}.then { purchase -> Promise<Void> in
let observer = PurchaseDownloadObserver(purchase: purchase)
let download = Promise<Void> { seal in
observer.errorHandler = seal.reject
observer.completionHandler = seal.fulfill_
}
let downloadQueue = CKDownloadQueue.shared()
let observerID = downloadQueue.add(observer)
return download.ensure {
downloadQueue.remove(observerID)
}
}
}

View file

@ -1,81 +0,0 @@
//
// ISStoreAccount.swift
// mas-cli
//
// Created by Andrew Naylor on 22/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import CommerceKit
import StoreFoundation
extension ISStoreAccount: StoreAccount {
static var primaryAccount: StoreAccount? {
var account: ISStoreAccount?
if #available(macOS 10.13, *) {
let group = DispatchGroup()
group.enter()
let accountService: ISAccountService = ISServiceProxy.genericShared().accountService
accountService.primaryAccount { (storeAccount: ISStoreAccount) in
account = storeAccount
group.leave()
}
_ = group.wait(timeout: .now() + 30)
} else {
// macOS 10.9-10.12
let accountStore = CKAccountStore.shared()
account = accountStore.primaryAccount
}
return account
}
static func signIn(username: String, password: String, systemDialog: Bool = false) throws -> StoreAccount {
var storeAccount: ISStoreAccount?
var maserror: MASError?
let accountService: ISAccountService = ISServiceProxy.genericShared().accountService
let client = ISStoreClient(storeClientType: 0)
accountService.setStoreClient(client)
let context = ISAuthenticationContext(accountID: 0)
context.appleIDOverride = username
if systemDialog {
context.appleIDOverride = username
} else {
context.demoMode = true
context.demoAccountName = username
context.demoAccountPassword = password
context.demoAutologinMode = true
}
let group = DispatchGroup()
group.enter()
// Only works on macOS Sierra and below
accountService.signIn(with: context) { success, account, error in
if success {
storeAccount = account
} else {
maserror = .signInFailed(error: error as NSError?)
}
group.leave()
}
if systemDialog {
group.wait()
} else {
_ = group.wait(timeout: .now() + 30)
}
if let account = storeAccount {
return account
}
throw maserror ?? MASError.signInFailed(error: nil)
}
}

View file

@ -1,64 +0,0 @@
//
// SSPurchase.swift
// mas-cli
//
// Created by Andrew Naylor on 25/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import CommerceKit
import StoreFoundation
typealias SSPurchaseCompletion =
(_ purchase: SSPurchase?, _ completed: Bool, _ error: Error?, _ response: SSPurchaseResponse?) -> Void
extension SSPurchase {
convenience init(adamId: UInt64, account: ISStoreAccount?, purchase: Bool = false) {
self.init()
var parameters: [String: Any] = [
"productType": "C",
"price": 0,
"salableAdamId": adamId,
"pg": "default",
"appExtVrsId": 0,
]
if purchase {
parameters["macappinstalledconfirmed"] = 1
parameters["pricingParameters"] = "STDQ"
} else {
// is redownload, use existing functionality
parameters["pricingParameters"] = "STDRDL"
}
buyParameters =
parameters.map { key, value in
"\(key)=\(value)"
}
.joined(separator: "&")
itemIdentifier = adamId
if let account {
accountIdentifier = account.dsID
appleID = account.identifier
}
// Not sure if this is needed, but lets use it here.
if purchase {
isRedownload = false
}
let downloadMetadata = SSDownloadMetadata()
downloadMetadata.kind = "software"
downloadMetadata.itemIdentifier = adamId
self.downloadMetadata = downloadMetadata
}
func perform(_ completion: @escaping SSPurchaseCompletion) {
CKPurchaseController.shared().perform(self, withOptions: 0, completionHandler: completion)
}
}

View file

@ -1,35 +0,0 @@
//
// Account.swift
// mas-cli
//
// Created by Andrew Naylor on 21/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import Commandant
import StoreFoundation
public struct AccountCommand: CommandProtocol {
public typealias Options = NoOptions<MASError>
public let verb = "account"
public let function = "Prints the primary account Apple ID"
public init() {}
/// Runs the command.
public func run(_: Options) -> Result<Void, MASError> {
if #available(macOS 12, *) {
// Account information is no longer available as of Monterey.
// https://github.com/mas-cli/mas/issues/417
return .failure(.notSupported)
}
if let account = ISStoreAccount.primaryAccount {
print(String(describing: account.identifier))
} else {
printError("Not signed in")
return .failure(.notSignedIn)
}
return .success(())
}
}

View file

@ -1,79 +0,0 @@
//
// Home.swift
// mas-cli
//
// Created by Ben Chatelain on 2018-12-29.
// Copyright © 2016 mas-cli. All rights reserved.
//
import Commandant
/// Opens app page on MAS Preview. Uses the iTunes Lookup API:
/// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/#lookup
public struct HomeCommand: CommandProtocol {
public typealias Options = HomeOptions
public let verb = "home"
public let function = "Opens MAS Preview app page in a browser"
private let storeSearch: StoreSearch
private var openCommand: ExternalCommand
public init() {
self.init(
storeSearch: MasStoreSearch(),
openCommand: OpenSystemCommand()
)
}
/// Designated initializer.
init(
storeSearch: StoreSearch = MasStoreSearch(),
openCommand: ExternalCommand = OpenSystemCommand()
) {
self.storeSearch = storeSearch
self.openCommand = openCommand
}
/// Runs the command.
public func run(_ options: HomeOptions) -> Result<Void, MASError> {
do {
guard let result = try storeSearch.lookup(app: options.appId).wait() else {
return .failure(.noSearchResultsFound)
}
do {
try openCommand.run(arguments: result.trackViewUrl)
} catch {
printError("Unable to launch open command")
return .failure(.searchFailed)
}
if openCommand.failed {
let reason = openCommand.process.terminationReason
printError("Open failed: (\(reason)) \(openCommand.stderr)")
return .failure(.searchFailed)
}
} catch {
// Bubble up MASErrors
if let error = error as? MASError {
return .failure(error)
}
return .failure(.searchFailed)
}
return .success(())
}
}
public struct HomeOptions: OptionsProtocol {
let appId: Int
static func create(_ appId: Int) -> HomeOptions {
HomeOptions(appId: appId)
}
public static func evaluate(_ mode: CommandMode) -> Result<HomeOptions, CommandantError<MASError>> {
create
<*> mode <| Argument(usage: "ID of app to show on MAS Preview")
}
}

View file

@ -1,60 +0,0 @@
//
// Info.swift
// mas-cli
//
// Created by Denis Lebedev on 21/10/2016.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import Commandant
import Foundation
/// Displays app details. Uses the iTunes Lookup API:
/// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/#lookup
public struct InfoCommand: CommandProtocol {
public let verb = "info"
public let function = "Display app information from the Mac App Store"
private let storeSearch: StoreSearch
public init() {
self.init(storeSearch: MasStoreSearch())
}
/// Designated initializer.
init(storeSearch: StoreSearch = MasStoreSearch()) {
self.storeSearch = storeSearch
}
/// Runs the command.
public func run(_ options: InfoOptions) -> Result<Void, MASError> {
do {
guard let result = try storeSearch.lookup(app: options.appId).wait() else {
return .failure(.noSearchResultsFound)
}
print(AppInfoFormatter.format(app: result))
} catch {
// Bubble up MASErrors
if let error = error as? MASError {
return .failure(error)
}
return .failure(.searchFailed)
}
return .success(())
}
}
public struct InfoOptions: OptionsProtocol {
let appId: Int
static func create(_ appId: Int) -> InfoOptions {
InfoOptions(appId: appId)
}
public static func evaluate(_ mode: CommandMode) -> Result<InfoOptions, CommandantError<MASError>> {
create
<*> mode <| Argument(usage: "ID of app to show info")
}
}

View file

@ -1,68 +0,0 @@
//
// Install.swift
// mas-cli
//
// Created by Andrew Naylor on 21/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import Commandant
import CommerceKit
/// Installs previously purchased apps from the Mac App Store.
public struct InstallCommand: CommandProtocol {
public typealias Options = InstallOptions
public let verb = "install"
public let function = "Install from the Mac App Store"
private let appLibrary: AppLibrary
/// Public initializer.
public init() {
self.init(appLibrary: MasAppLibrary())
}
/// Internal initializer.
/// - Parameter appLibrary: AppLibrary manager.
init(appLibrary: AppLibrary = MasAppLibrary()) {
self.appLibrary = appLibrary
}
/// Runs the command.
public func run(_ options: Options) -> Result<Void, MASError> {
// Try to download applications with given identifiers and collect results
let appIds = options.appIds.filter { appId in
if let product = appLibrary.installedApp(forId: appId), !options.forceInstall {
printWarning("\(product.appName) is already installed")
return false
}
return true
}
do {
try downloadAll(appIds).wait()
} catch {
return .failure(error as? MASError ?? .downloadFailed(error: error as NSError))
}
return .success(())
}
}
public struct InstallOptions: OptionsProtocol {
let appIds: [UInt64]
let forceInstall: Bool
public static func create(_ appIds: [Int]) -> (_ forceInstall: Bool) -> InstallOptions {
{ forceInstall in
InstallOptions(appIds: appIds.map { UInt64($0) }, forceInstall: forceInstall)
}
}
public static func evaluate(_ mode: CommandMode) -> Result<InstallOptions, CommandantError<MASError>> {
create
<*> mode <| Argument(usage: "app ID(s) to install")
<*> mode <| Switch(flag: nil, key: "force", usage: "force reinstall")
}
}

View file

@ -1,44 +0,0 @@
//
// List.swift
// mas-cli
//
// Created by Andrew Naylor on 21/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import Commandant
/// Command which lists all installed apps.
public struct ListCommand: CommandProtocol {
public typealias Options = NoOptions<MASError>
public let verb = "list"
public let function = "Lists apps from the Mac App Store which are currently installed"
private let appLibrary: AppLibrary
/// Public initializer.
/// - Parameter appLibrary: AppLibrary manager.
public init() {
self.init(appLibrary: MasAppLibrary())
}
/// Internal initializer.
/// - Parameter appLibrary: AppLibrary manager.
init(appLibrary: AppLibrary = MasAppLibrary()) {
self.appLibrary = appLibrary
}
/// Runs the command.
public func run(_: Options) -> Result<Void, MASError> {
let products = appLibrary.installedApps
if products.isEmpty {
printError("No installed apps found")
return .success(())
}
let output = AppListFormatter.format(products: products)
print(output)
return .success(())
}
}

View file

@ -1,106 +0,0 @@
//
// Lucky.swift
// mas-cli
//
// Created by Pablo Varela on 05/11/17.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import Commandant
import CommerceKit
/// Command which installs the first search result. This is handy as many MAS titles
/// can be long with embedded keywords.
public struct LuckyCommand: CommandProtocol {
public typealias Options = LuckyOptions
public let verb = "lucky"
public let function = "Install the first result from the Mac App Store"
private let appLibrary: AppLibrary
private let storeSearch: StoreSearch
public init() {
self.init(storeSearch: MasStoreSearch())
}
/// Designated initializer.
/// - Parameter storeSearch: Search manager.
init(storeSearch: StoreSearch = MasStoreSearch()) {
self.init(appLibrary: MasAppLibrary(), storeSearch: storeSearch)
}
/// Internal initializer.
/// - Parameter appLibrary: AppLibrary manager.
/// - Parameter storeSearch: Search manager.
init(
appLibrary: AppLibrary = MasAppLibrary(),
storeSearch: StoreSearch = MasStoreSearch()
) {
self.appLibrary = appLibrary
self.storeSearch = storeSearch
}
/// Runs the command.
public func run(_ options: Options) -> Result<Void, MASError> {
var appId: Int?
do {
let results = try storeSearch.search(for: options.appName).wait()
guard let result = results.first else {
printError("No results found")
return .failure(.noSearchResultsFound)
}
appId = result.trackId
} catch {
// Bubble up MASErrors
if let error = error as? MASError {
return .failure(error)
}
return .failure(.searchFailed)
}
guard let identifier = appId else { fatalError() }
return install(UInt64(identifier), options: options)
}
/// Installs an app.
///
/// - Parameters:
/// - appId: App identifier
/// - options: command opetions.
/// - Returns: Result of the operation.
fileprivate func install(_ appId: UInt64, options: Options) -> Result<Void, MASError> {
// Try to download applications with given identifiers and collect results
if let product = appLibrary.installedApp(forId: appId), !options.forceInstall {
printWarning("\(product.appName) is already installed")
return .success(())
}
do {
try downloadAll([appId]).wait()
} catch {
return .failure(error as? MASError ?? .downloadFailed(error: error as NSError))
}
return .success(())
}
}
public struct LuckyOptions: OptionsProtocol {
let appName: String
let forceInstall: Bool
public static func create(_ appName: String) -> (_ forceInstall: Bool) -> LuckyOptions {
{ forceInstall in
LuckyOptions(appName: appName, forceInstall: forceInstall)
}
}
public static func evaluate(_ mode: CommandMode) -> Result<LuckyOptions, CommandantError<MASError>> {
create
<*> mode <| Argument(usage: "the app name to install")
<*> mode <| Switch(flag: nil, key: "force", usage: "force reinstall")
}
}

View file

@ -1,102 +0,0 @@
//
// Open.swift
// mas-cli
//
// Created by Ben Chatelain on 2018-12-29.
// Copyright © 2016 mas-cli. All rights reserved.
//
import Commandant
import Foundation
private let markerValue = "appstore"
private let masScheme = "macappstore"
/// Opens app page in MAS app. Uses the iTunes Lookup API:
/// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/#lookup
public struct OpenCommand: CommandProtocol {
public typealias Options = OpenOptions
public let verb = "open"
public let function = "Opens app page in AppStore.app"
private let storeSearch: StoreSearch
private var systemOpen: ExternalCommand
public init() {
self.init(
storeSearch: MasStoreSearch(),
openCommand: OpenSystemCommand()
)
}
/// Designated initializer.
init(
storeSearch: StoreSearch = MasStoreSearch(),
openCommand: ExternalCommand = OpenSystemCommand()
) {
self.storeSearch = storeSearch
systemOpen = openCommand
}
/// Runs the command.
public func run(_ options: OpenOptions) -> Result<Void, MASError> {
do {
if options.appId == markerValue {
// If no app ID is given, just open the MAS GUI app
try systemOpen.run(arguments: masScheme + "://")
return .success(())
}
guard let appId = Int(options.appId)
else {
printError("Invalid app ID")
return .failure(.noSearchResultsFound)
}
guard let result = try storeSearch.lookup(app: appId).wait()
else {
return .failure(.noSearchResultsFound)
}
guard var url = URLComponents(string: result.trackViewUrl)
else {
return .failure(.searchFailed)
}
url.scheme = masScheme
do {
try systemOpen.run(arguments: url.string!)
} catch {
printError("Unable to launch open command")
return .failure(.searchFailed)
}
if systemOpen.failed {
let reason = systemOpen.process.terminationReason
printError("Open failed: (\(reason)) \(systemOpen.stderr)")
return .failure(.searchFailed)
}
} catch {
// Bubble up MASErrors
if let error = error as? MASError {
return .failure(error)
}
return .failure(.searchFailed)
}
return .success(())
}
}
public struct OpenOptions: OptionsProtocol {
var appId: String
static func create(_ appId: String) -> OpenOptions {
OpenOptions(appId: appId)
}
public static func evaluate(_ mode: CommandMode) -> Result<OpenOptions, CommandantError<MASError>> {
create
<*> mode <| Argument(defaultValue: markerValue, usage: "the app ID")
}
}

View file

@ -1,89 +0,0 @@
//
// Outdated.swift
// mas-cli
//
// Created by Andrew Naylor on 21/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import Commandant
import Foundation
import PromiseKit
import enum Swift.Result
/// Command which displays a list of installed apps which have available updates
/// ready to be installed from the Mac App Store.
public struct OutdatedCommand: CommandProtocol {
public typealias Options = OutdatedOptions
public let verb = "outdated"
public let function = "Lists pending updates from the Mac App Store"
private let appLibrary: AppLibrary
private let storeSearch: StoreSearch
/// Public initializer.
public init() {
self.init(appLibrary: MasAppLibrary())
}
/// Internal initializer.
/// - Parameter appLibrary: AppLibrary manager.
/// - Parameter storeSearch: StoreSearch manager.
init(appLibrary: AppLibrary = MasAppLibrary(), storeSearch: StoreSearch = MasStoreSearch()) {
self.appLibrary = appLibrary
self.storeSearch = storeSearch
}
/// Runs the command.
public func run(_ options: Options) -> Result<Void, MASError> {
let promises = appLibrary.installedApps.map { installedApp in
firstly {
storeSearch.lookup(app: installedApp.itemIdentifier.intValue)
}.done { storeApp in
guard let storeApp else {
if options.verbose {
printWarning(
"""
Identifier \(installedApp.itemIdentifier) not found in store. \
Was expected to identify \(installedApp.appName).
""")
}
return
}
if installedApp.isOutdatedWhenComparedTo(storeApp) {
print(
"""
\(installedApp.itemIdentifier) \(installedApp.appName) \
(\(installedApp.bundleVersion) -> \(storeApp.version))
""")
}
}
}
return firstly {
when(fulfilled: promises)
}.map {
Result<Void, MASError>.success(())
}.recover { error in
// Bubble up MASErrors
.value(Result<Void, MASError>.failure(error as? MASError ?? .searchFailed))
}.wait()
}
}
public struct OutdatedOptions: OptionsProtocol {
public typealias ClientError = MASError
let verbose: Bool
static func create(verbose: Bool) -> OutdatedOptions {
OutdatedOptions(verbose: verbose)
}
public static func evaluate(_ mode: CommandMode) -> Result<OutdatedOptions, CommandantError<MASError>> {
create
<*> mode <| Switch(flag: nil, key: "verbose", usage: "Show warnings about apps")
}
}

View file

@ -1,69 +0,0 @@
//
// Purchase.swift
// mas-cli
//
// Created by Jakob Rieck on 24/10/2017.
// Copyright (c) 2017 Jakob Rieck. All rights reserved.
//
import Commandant
import CommerceKit
public struct PurchaseCommand: CommandProtocol {
public typealias Options = PurchaseOptions
public let verb = "purchase"
public let function = "Purchase and download free apps from the Mac App Store"
private let appLibrary: AppLibrary
/// Public initializer.
public init() {
self.init(appLibrary: MasAppLibrary())
}
/// Internal initializer.
/// - Parameter appLibrary: AppLibrary manager.
init(appLibrary: AppLibrary = MasAppLibrary()) {
self.appLibrary = appLibrary
}
/// Runs the command.
public func run(_ options: Options) -> Result<Void, MASError> {
if #available(macOS 10.15, *) {
// Purchases are no longer possible as of Catalina.
// https://github.com/mas-cli/mas/issues/289
return .failure(.notSupported)
}
// Try to download applications with given identifiers and collect results
let appIds = options.appIds.filter { appId in
if let product = appLibrary.installedApp(forId: appId) {
printWarning("\(product.appName) has already been purchased.")
return false
}
return true
}
do {
try downloadAll(appIds, purchase: true).wait()
} catch {
return .failure(error as? MASError ?? .downloadFailed(error: error as NSError))
}
return .success(())
}
}
public struct PurchaseOptions: OptionsProtocol {
let appIds: [UInt64]
public static func create(_ appIds: [Int]) -> PurchaseOptions {
PurchaseOptions(appIds: appIds.map { UInt64($0) })
}
public static func evaluate(_ mode: CommandMode) -> Result<PurchaseOptions, CommandantError<MASError>> {
create
<*> mode <| Argument(usage: "app ID(s) to install")
}
}

View file

@ -1,91 +0,0 @@
//
// Reset.swift
// mas-cli
//
// Created by Andrew Naylor on 14/09/2016.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import Commandant
import CommerceKit
/// Kills several macOS processes as a means to reset the app store.
public struct ResetCommand: CommandProtocol {
public typealias Options = ResetOptions
public let verb = "reset"
public let function = "Resets the Mac App Store"
public init() {}
/// Runs the command.
public func run(_ options: Options) -> Result<Void, MASError> {
// The "Reset Application" command in the Mac App Store debug menu performs
// the following steps
//
// - killall Dock
// - killall storeagent (storeagent no longer exists)
// - rm com.apple.appstore download directory
// - clear cookies (appears to be a no-op)
//
// As storeagent no longer exists we will implement a slight variant and kill all
// App Store-associated processes
// - storeaccountd
// - storeassetd
// - storedownloadd
// - storeinstalld
// - storelegacy
// Kill processes
let killProcs = [
"Dock",
"storeaccountd",
"storeassetd",
"storedownloadd",
"storeinstalld",
"storelegacy",
]
let kill = Process()
let stdout = Pipe()
let stderr = Pipe()
kill.launchPath = "/usr/bin/killall"
kill.arguments = killProcs
kill.standardOutput = stdout
kill.standardError = stderr
kill.launch()
kill.waitUntilExit()
if kill.terminationStatus != 0, options.debug {
let output = stderr.fileHandleForReading.readDataToEndOfFile()
printInfo("killall failed:\r\n\(String(data: output, encoding: String.Encoding.utf8)!)")
}
// Wipe Download Directory
if let directory = CKDownloadDirectory(nil) {
do {
try FileManager.default.removeItem(atPath: directory)
} catch {
if options.debug {
printError("removeItemAtPath:\"\(directory)\" failed, \(error)")
}
}
}
return .success(())
}
}
public struct ResetOptions: OptionsProtocol {
let debug: Bool
public static func create(debug: Bool) -> ResetOptions {
ResetOptions(debug: debug)
}
public static func evaluate(_ mode: CommandMode) -> Result<ResetOptions, CommandantError<MASError>> {
create
<*> mode <| Switch(flag: nil, key: "debug", usage: "Enable debug mode")
}
}

View file

@ -1,67 +0,0 @@
//
// Search.swift
// mas-cli
//
// Created by Michael Schneider on 4/14/16.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import Commandant
/// Search the Mac App Store using the iTunes Search API:
/// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/
public struct SearchCommand: CommandProtocol {
public typealias Options = SearchOptions
public let verb = "search"
public let function = "Search for apps from the Mac App Store"
private let storeSearch: StoreSearch
public init() {
self.init(storeSearch: MasStoreSearch())
}
/// Designated initializer.
///
/// - Parameter storeSearch: Search manager.
init(storeSearch: StoreSearch = MasStoreSearch()) {
self.storeSearch = storeSearch
}
public func run(_ options: Options) -> Result<Void, MASError> {
do {
let results = try storeSearch.search(for: options.appName).wait()
if results.isEmpty {
return .failure(.noSearchResultsFound)
}
let output = SearchResultFormatter.format(results: results, includePrice: options.price)
print(output)
return .success(())
} catch {
// Bubble up MASErrors
if let error = error as? MASError {
return .failure(error)
}
return .failure(.searchFailed)
}
}
}
public struct SearchOptions: OptionsProtocol {
let appName: String
let price: Bool
public static func create(_ appName: String) -> (_ price: Bool) -> SearchOptions {
{ price in
SearchOptions(appName: appName, price: price)
}
}
public static func evaluate(_ mode: CommandMode) -> Result<SearchOptions, CommandantError<MASError>> {
create
<*> mode <| Argument(usage: "the app name to search")
<*> mode <| Option(key: "price", defaultValue: false, usage: "Show price of found apps")
}
}

View file

@ -1,68 +0,0 @@
//
// SignIn.swift
// mas-cli
//
// Created by Andrew Naylor on 14/02/2016.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import Commandant
import StoreFoundation
public struct SignInCommand: CommandProtocol {
public typealias Options = SignInOptions
public let verb = "signin"
public let function = "Sign in to the Mac App Store"
public init() {}
/// Runs the command.
public func run(_ options: Options) -> Result<Void, MASError> {
if #available(macOS 10.13, *) {
// Signing in is no longer possible as of High Sierra.
// https://github.com/mas-cli/mas/issues/164
return .failure(.notSupported)
}
guard ISStoreAccount.primaryAccount == nil else {
return .failure(.alreadySignedIn)
}
do {
printInfo("Signing in to Apple ID: \(options.username)")
let password: String = {
if options.password.isEmpty, !options.dialog {
return String(validatingUTF8: getpass("Password: "))!
}
return options.password
}()
_ = try ISStoreAccount.signIn(username: options.username, password: password, systemDialog: options.dialog)
} catch let error as NSError {
return .failure(.signInFailed(error: error))
}
return .success(())
}
}
public struct SignInOptions: OptionsProtocol {
public typealias ClientError = MASError
let username: String
let password: String
let dialog: Bool
static func create(username: String) -> (_ password: String) -> (_ dialog: Bool) -> SignInOptions {
{ password in { dialog in SignInOptions(username: username, password: password, dialog: dialog) } }
}
public static func evaluate(_ mode: CommandMode) -> Result<SignInOptions, CommandantError<MASError>> {
create
<*> mode <| Argument(usage: "Apple ID")
<*> mode <| Argument(defaultValue: "", usage: "Password")
<*> mode <| Option(key: "dialog", defaultValue: false, usage: "Complete login with graphical dialog")
}
}

View file

@ -1,32 +0,0 @@
//
// SignOut.swift
// mas-cli
//
// Created by Andrew Naylor on 14/02/2016.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import Commandant
import CommerceKit
public struct SignOutCommand: CommandProtocol {
public typealias Options = NoOptions<MASError>
public let verb = "signout"
public let function = "Sign out of the Mac App Store"
public init() {}
/// Runs the command.
public func run(_: Options) -> Result<Void, MASError> {
if #available(macOS 10.13, *) {
let accountService: ISAccountService = ISServiceProxy.genericShared().accountService
accountService.signOut()
} else {
// Using CKAccountStore to sign out does nothing on High Sierra
// https://github.com/mas-cli/mas/issues/129
CKAccountStore.shared().signOut()
}
return .success(())
}
}

View file

@ -1,80 +0,0 @@
//
// Uninstall.swift
// mas-cli
//
// Created by Ben Chatelain on 2018-12-27.
// Copyright © 2015 Andrew Naylor. All rights reserved.
//
import Commandant
import CommerceKit
import StoreFoundation
/// Command which uninstalls apps managed by the Mac App Store.
public struct UninstallCommand: CommandProtocol {
public typealias Options = UninstallOptions
public let verb = "uninstall"
public let function = "Uninstall app installed from the Mac App Store"
private let appLibrary: AppLibrary
/// Public initializer.
/// - Parameter appLibrary: AppLibrary manager.
public init() {
self.init(appLibrary: MasAppLibrary())
}
/// Internal initializer.
/// - Parameter appLibrary: AppLibrary manager.
init(appLibrary: AppLibrary = MasAppLibrary()) {
self.appLibrary = appLibrary
}
/// Runs the uninstall command.
///
/// - Parameter options: UninstallOptions (arguments) for this command
/// - Returns: Success or an error.
public func run(_ options: Options) -> Result<Void, MASError> {
let appId = UInt64(options.appId)
guard let product = appLibrary.installedApp(forId: appId) else {
return .failure(.notInstalled)
}
if options.dryRun {
printInfo("\(product.appName) \(product.bundlePath)")
printInfo("(not removed, dry run)")
return .success(())
}
do {
try appLibrary.uninstallApp(app: product)
} catch {
return .failure(.uninstallFailed)
}
return .success(())
}
}
/// Options for the uninstall command.
public struct UninstallOptions: OptionsProtocol {
/// Numeric app ID
let appId: Int
/// Flag indicating that removal shouldn't be performed
let dryRun: Bool
static func create(_ appId: Int) -> (_ dryRun: Bool) -> UninstallOptions {
{ dryRun in
UninstallOptions(appId: appId, dryRun: dryRun)
}
}
public static func evaluate(_ mode: CommandMode) -> Result<UninstallOptions, CommandantError<MASError>> {
create
<*> mode <| Argument(usage: "ID of app to uninstall")
<*> mode <| Switch(flag: nil, key: "dry-run", usage: "dry run")
}
}

View file

@ -1,109 +0,0 @@
//
// Upgrade.swift
// mas-cli
//
// Created by Andrew Naylor on 30/12/2015.
// Copyright © 2015 Andrew Naylor. All rights reserved.
//
import Commandant
import Foundation
import PromiseKit
import enum Swift.Result
/// Command which upgrades apps with new versions available in the Mac App Store.
public struct UpgradeCommand: CommandProtocol {
public typealias Options = UpgradeOptions
public let verb = "upgrade"
public let function = "Upgrade outdated apps from the Mac App Store"
private let appLibrary: AppLibrary
private let storeSearch: StoreSearch
/// Public initializer.
public init() {
self.init(appLibrary: MasAppLibrary())
}
/// Internal initializer.
/// - Parameter appLibrary: AppLibrary manager.
/// - Parameter storeSearch: StoreSearch manager.
init(appLibrary: AppLibrary = MasAppLibrary(), storeSearch: StoreSearch = MasStoreSearch()) {
self.appLibrary = appLibrary
self.storeSearch = storeSearch
}
/// Runs the command.
public func run(_ options: Options) -> Result<Void, MASError> {
let apps: [(installedApp: SoftwareProduct, storeApp: SearchResult)]
do {
apps = try findOutdatedApps(options)
} catch {
// Bubble up MASErrors
return .failure(error as? MASError ?? .searchFailed)
}
guard apps.count > 0 else {
printWarning("Nothing found to upgrade")
return .success(())
}
print("Upgrading \(apps.count) outdated application\(apps.count > 1 ? "s" : ""):")
print(
apps.map { "\($0.installedApp.appName) (\($0.installedApp.bundleVersion)) -> (\($0.storeApp.version))" }
.joined(separator: "\n"))
let appIds = apps.map(\.installedApp.itemIdentifier.uint64Value)
do {
try downloadAll(appIds).wait()
} catch {
return .failure(error as? MASError ?? .downloadFailed(error: error as NSError))
}
return .success(())
}
private func findOutdatedApps(_ options: Options) throws -> [(SoftwareProduct, SearchResult)] {
let apps: [SoftwareProduct] =
options.apps.isEmpty
? appLibrary.installedApps
: options.apps.compactMap {
if let appId = UInt64($0) {
// if argument a UInt64, lookup app by id using argument
return appLibrary.installedApp(forId: appId)
} else {
// if argument not a UInt64, lookup app by name using argument
return appLibrary.installedApp(named: $0)
}
}
let promises = apps.map { installedApp in
// only upgrade apps whose local version differs from the store version
firstly {
storeSearch.lookup(app: installedApp.itemIdentifier.intValue)
}.map { result -> (SoftwareProduct, SearchResult)? in
guard let storeApp = result, installedApp.isOutdatedWhenComparedTo(storeApp) else {
return nil
}
return (installedApp, storeApp)
}
}
return try when(fulfilled: promises).wait().compactMap { $0 }
}
}
public struct UpgradeOptions: OptionsProtocol {
let apps: [String]
static func create(_ apps: [String]) -> UpgradeOptions {
UpgradeOptions(apps: apps)
}
public static func evaluate(_ mode: CommandMode) -> Result<UpgradeOptions, CommandantError<MASError>> {
create
<*> mode <| Argument(defaultValue: [], usage: "app(s) to upgrade")
}
}

View file

@ -1,83 +0,0 @@
//
// Vendor.swift
// mas-cli
//
// Created by Ben Chatelain on 2018-12-29.
// Copyright © 2016 mas-cli. All rights reserved.
//
import Commandant
/// Opens vendor's app page in a browser. Uses the iTunes Lookup API:
/// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/#lookup
public struct VendorCommand: CommandProtocol {
public typealias Options = VendorOptions
public let verb = "vendor"
public let function = "Opens vendor's app page in a browser"
private let storeSearch: StoreSearch
private var openCommand: ExternalCommand
public init() {
self.init(
storeSearch: MasStoreSearch(),
openCommand: OpenSystemCommand()
)
}
/// Designated initializer.
init(
storeSearch: StoreSearch = MasStoreSearch(),
openCommand: ExternalCommand = OpenSystemCommand()
) {
self.storeSearch = storeSearch
self.openCommand = openCommand
}
/// Runs the command.
public func run(_ options: VendorOptions) -> Result<Void, MASError> {
do {
guard let result = try storeSearch.lookup(app: options.appId).wait()
else {
return .failure(.noSearchResultsFound)
}
guard let vendorWebsite = result.sellerUrl
else { throw MASError.noVendorWebsite }
do {
try openCommand.run(arguments: vendorWebsite)
} catch {
printError("Unable to launch open command")
return .failure(.searchFailed)
}
if openCommand.failed {
let reason = openCommand.process.terminationReason
printError("Open failed: (\(reason)) \(openCommand.stderr)")
return .failure(.searchFailed)
}
} catch {
// Bubble up MASErrors
if let error = error as? MASError {
return .failure(error)
}
return .failure(.searchFailed)
}
return .success(())
}
}
public struct VendorOptions: OptionsProtocol {
let appId: Int
static func create(_ appId: Int) -> VendorOptions {
VendorOptions(appId: appId)
}
public static func evaluate(_ mode: CommandMode) -> Result<VendorOptions, CommandantError<MASError>> {
create
<*> mode <| Argument(usage: "the app ID to show the vendor's website")
}
}

View file

@ -1,24 +0,0 @@
//
// Version.swift
// mas-cli
//
// Created by Andrew Naylor on 20/09/2015.
// Copyright © 2015 Andrew Naylor. All rights reserved.
//
import Commandant
/// Command which displays the version of the mas tool.
public struct VersionCommand: CommandProtocol {
public typealias Options = NoOptions<MASError>
public let verb = "version"
public let function = "Print version number"
public init() {}
/// Runs the command.
public func run(_: Options) -> Result<Void, MASError> {
print(Package.version)
return .success(())
}
}

View file

@ -1,47 +0,0 @@
//
// AppLibrary.swift
// MasKit
//
// Created by Ben Chatelain on 12/27/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Foundation
/// Utility for managing installed apps.
protocol AppLibrary {
/// Entire set of installed apps.
var installedApps: [SoftwareProduct] { get }
/// Finds an app by ID.
///
/// - Parameter forId: MAS ID for app.
/// - Returns: Software Product of app if found; nil otherwise.
func installedApp(forId: UInt64) -> SoftwareProduct?
/// Uninstalls an app.
///
/// - Parameter app: App to be removed.
/// - Throws: Error if there is a problem.
func uninstallApp(app: SoftwareProduct) throws
}
/// Common logic
extension AppLibrary {
/// Finds an app by name.
///
/// - Parameter id: MAS ID for app.
/// - Returns: Software Product of app if found; nil otherwise.
func installedApp(forId identifier: UInt64) -> SoftwareProduct? {
let appId = NSNumber(value: identifier)
return installedApps.first { $0.itemIdentifier == appId }
}
/// Finds an app by name.
///
/// - Parameter appName: Full title of an app.
/// - Returns: Software Product of app if found; nil otherwise.
func installedApp(named appName: String) -> SoftwareProduct? {
installedApps.first { $0.appName == appName }
}
}

View file

@ -1,64 +0,0 @@
//
// MasAppLibrary.swift
// MasKit
//
// Created by Ben Chatelain on 12/27/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
import CommerceKit
/// Utility for managing installed apps.
class MasAppLibrary: AppLibrary {
/// CommerceKit's singleton manager of installed software.
private let softwareMap: SoftwareMap
/// Array of installed software products.
lazy var installedApps: [SoftwareProduct] = softwareMap.allSoftwareProducts().filter { product in
product.bundlePath.starts(with: "/Applications/")
}
/// Internal initializer for providing a mock software map.
/// - Parameter softwareMap: SoftwareMap to use
init(softwareMap: SoftwareMap = CKSoftwareMap.shared()) {
self.softwareMap = softwareMap
}
/// Finds an app using a bundle identifier.
///
/// - Parameter bundleId: Bundle identifier of app.
/// - Returns: Software Product of app if found; nil otherwise.
func installedApp(forBundleId bundleId: String) -> SoftwareProduct? {
softwareMap.product(for: bundleId)
}
/// Uninstalls an app.
///
/// - Parameter app: App to be removed.
/// - Throws: Error if there is a problem.
func uninstallApp(app: SoftwareProduct) throws {
if !userIsRoot() {
printWarning("Apps installed from the Mac App Store require root permission to remove.")
}
let appUrl = URL(fileURLWithPath: app.bundlePath)
do {
// Move item to trash
var trashUrl: NSURL?
try FileManager().trashItem(at: appUrl, resultingItemURL: &trashUrl)
if let path = trashUrl?.path {
printInfo("App moved to trash: \(path)")
}
} catch {
printError("Unable to move app to trash.")
throw MASError.uninstallFailed
}
}
/// Detects whether the current user is root.
///
/// - Returns: true if the current user is root; false otherwise
private func userIsRoot() -> Bool {
NSUserName() == "root"
}
}

View file

@ -1,68 +0,0 @@
//
// StoreSearch.swift
// MasKit
//
// Created by Ben Chatelain on 12/29/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Foundation
import PromiseKit
/// Protocol for searching the MAS catalog.
protocol StoreSearch {
func lookup(app appId: Int) -> Promise<SearchResult?>
func search(for appName: String) -> Promise<[SearchResult]>
}
enum Entity: String {
case macSoftware
case iPadSoftware
case iPhoneSoftware = "software"
}
// MARK: - Common methods
extension StoreSearch {
/// Builds the search URL for an app.
///
/// - Parameter appName: MAS app identifier.
/// - Returns: URL for the search service or nil if appName can't be encoded.
func searchURL(for appName: String, inCountry country: String?, ofEntity entity: Entity = .macSoftware) -> URL? {
guard var components = URLComponents(string: "https://itunes.apple.com/search") else {
return nil
}
components.queryItems = [
URLQueryItem(name: "media", value: "software"),
URLQueryItem(name: "entity", value: entity.rawValue),
URLQueryItem(name: "term", value: appName),
]
if let country {
components.queryItems!.append(URLQueryItem(name: "country", value: country))
}
return components.url
}
/// Builds the lookup URL for an app.
///
/// - Parameter appId: MAS app identifier.
/// - Returns: URL for the lookup service or nil if appId can't be encoded.
func lookupURL(forApp appId: Int, inCountry country: String?) -> URL? {
guard var components = URLComponents(string: "https://itunes.apple.com/lookup") else {
return nil
}
components.queryItems = [
URLQueryItem(name: "id", value: "\(appId)"),
URLQueryItem(name: "entity", value: "desktopSoftware"),
]
if let country {
components.queryItems!.append(URLQueryItem(name: "country", value: country))
}
return components.url
}
}

View file

@ -1,123 +0,0 @@
//
// Utilities.swift
// mas-cli
//
// Created by Andrew Naylor on 14/09/2016.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import Foundation
/// A collection of output formatting helpers
/// Terminal Control Sequence Indicator
let csi = "\u{001B}["
#if DEBUG
var printObserver: ((String) -> Void)?
// Override global print for testability.
// See MasKitTests/OutputListener.swift.
func print(
_ items: Any...,
separator: String = " ",
terminator: String = "\n"
) {
if let observer = printObserver {
let output =
items
.map { "\($0)" }
.joined(separator: separator)
.appending(terminator)
observer(output)
}
var prefix = ""
for item in items {
Swift.print(prefix, terminator: "")
Swift.print(item, terminator: "")
prefix = separator
}
Swift.print(terminator, terminator: "")
}
func print(
_ items: Any...,
separator: String = " ",
terminator: String = "\n",
to output: inout some TextOutputStream
) {
if let observer = printObserver {
let output =
items
.map { "\($0)" }
.joined(separator: separator)
.appending(terminator)
observer(output)
}
var prefix = ""
for item in items {
Swift.print(prefix, terminator: "", to: &output)
Swift.print(item, terminator: "", to: &output)
prefix = separator
}
Swift.print(terminator, terminator: "", to: &output)
}
#endif
private var standardError = FileHandle.standardError
extension FileHandle: TextOutputStream {
public func write(_ string: String) {
guard let data = string.data(using: .utf8) else { return }
write(data)
}
}
/// Prints a message to stdout prefixed with a blue arrow.
func printInfo(_ message: String) {
guard isatty(fileno(stdout)) != 0 else {
print("==> \(message)")
return
}
// Blue bold arrow, Bold text
print("\(csi)1;34m==>\(csi)0m \(csi)1m\(message)\(csi)0m")
}
/// Prints a message to stderr prefixed with "Warning:" underlined in yellow.
public func printWarning(_ message: String) {
guard isatty(fileno(stderr)) != 0 else {
print("Warning: \(message)", to: &standardError)
return
}
// Yellow, underlined "Warning:" prefix
print("\(csi)4;33mWarning:\(csi)0m \(message)", to: &standardError)
}
/// Prints a message to stderr prefixed with "Error:" underlined in red.
public func printError(_ message: String) {
guard isatty(fileno(stderr)) != 0 else {
print("Error: \(message)", to: &standardError)
return
}
// Red, underlined "Error:" prefix
print("\(csi)4;31mError:\(csi)0m \(message)", to: &standardError)
}
/// Flushes stdout.
func clearLine() {
guard isatty(fileno(stdout)) != 0 else {
return
}
print("\(csi)2K\(csi)0G", terminator: "")
fflush(stdout)
}

View file

@ -1,27 +0,0 @@
//
// MasKit.swift
// MasKit
//
// Created by Chris Araman on 4/22/21.
// Copyright © 2021 mas-cli. All rights reserved.
//
import PromiseKit
public enum MasKit {
public static func initialize() {
PromiseKit.conf.Q.map = .global()
PromiseKit.conf.Q.return = .global()
PromiseKit.conf.logHandler = { event in
switch event {
case .waitOnMainThread:
// Ignored. This is a console app that waits on the main thread for
// promises to be processed on the global DispatchQueue.
break
default:
// Other events indicate a programming error.
fatalError("PromiseKit event: \(event)")
}
}
}
}

View file

@ -1,4 +0,0 @@
// Generated by: script/version
enum Package {
static let version = "1.8.6"
}

View file

@ -1,6 +1,6 @@
// //
// CKSoftwareMap+SoftwareMap.swift // CKSoftwareMap+SoftwareMap.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 12/27/18. // Created by Ben Chatelain on 12/27/18.
// Copyright © 2018 mas-cli. All rights reserved. // Copyright © 2018 mas-cli. All rights reserved.

View file

@ -1,6 +1,6 @@
// //
// CKSoftwareProduct+SoftwareProduct.swift // CKSoftwareProduct+SoftwareProduct.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 12/27/18. // Created by Ben Chatelain on 12/27/18.
// Copyright © 2018 mas-cli. All rights reserved. // Copyright © 2018 mas-cli. All rights reserved.

View file

@ -0,0 +1,62 @@
//
// Downloader.swift
// mas
//
// Created by Andrew Naylor on 21/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import CommerceKit
import PromiseKit
import StoreFoundation
/// Downloads a list of apps, one after the other, printing progress to the console.
///
/// - Parameters:
/// - appIDs: The IDs of the apps to be downloaded
/// - purchase: Flag indicating whether the apps needs to be purchased.
/// Only works for free apps. Defaults to false.
/// - Returns: A promise that completes when the downloads are complete. If any fail,
/// the promise is rejected with the first error, after all remaining downloads are attempted.
func downloadAll(_ appIDs: [AppID], purchase: Bool = false) -> Promise<Void> {
var firstError: Error?
return
appIDs
.reduce(Guarantee.value(())) { previous, appID in
previous.then {
downloadWithRetries(appID, purchase: purchase)
.recover { error in
if firstError == nil {
firstError = error
}
}
}
}
.done {
if let error = firstError {
throw error
}
}
}
private func downloadWithRetries(_ appID: AppID, purchase: Bool = false, attempts: Int = 3) -> Promise<Void> {
SSPurchase().perform(appID: appID, purchase: purchase)
.recover { error in
guard attempts > 1 else {
throw error
}
// If the download failed due to network issues, try again. Otherwise, fail immediately.
guard
case MASError.downloadFailed(let downloadError) = error,
case NSURLErrorDomain = downloadError?.domain
else {
throw error
}
let attempts = attempts - 1
printWarning((downloadError ?? error).localizedDescription)
printWarning("Trying again up to \(attempts) more \(attempts == 1 ? "time" : "times").")
return downloadWithRetries(appID, purchase: purchase, attempts: attempts)
}
}

View file

@ -0,0 +1,94 @@
//
// ISStoreAccount.swift
// mas
//
// Created by Andrew Naylor on 22/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import CommerceKit
import PromiseKit
import StoreFoundation
private let timeout = 30.0
extension ISStoreAccount: StoreAccount {
static var primaryAccount: Promise<ISStoreAccount> {
if #available(macOS 10.13, *) {
return race(
Promise { seal in
ISServiceProxy.genericShared().accountService
.primaryAccount { storeAccount in
seal.fulfill(storeAccount)
}
},
after(seconds: timeout)
.then {
Promise(error: MASError.notSignedIn)
}
)
}
return .value(CKAccountStore.shared().primaryAccount)
}
static func signIn(appleID: String, password: String, systemDialog: Bool) -> Promise<ISStoreAccount> {
// swift-format-ignore: UseEarlyExits
if #available(macOS 10.13, *) {
// Signing in is no longer possible as of High Sierra.
// https://github.com/mas-cli/mas/issues/164
return Promise(error: MASError.notSupported)
// swiftlint:disable:next superfluous_else
} else {
return
primaryAccount
.then { account -> Promise<ISStoreAccount> in
if account.isSignedIn {
return Promise(error: MASError.alreadySignedIn(asAppleID: account.identifier))
}
let password =
password.isEmpty && !systemDialog
? String(validatingUTF8: getpass("Password: ")) ?? ""
: password
guard !password.isEmpty || systemDialog else {
return Promise(error: MASError.noPasswordProvided)
}
let context = ISAuthenticationContext(accountID: 0)
context.appleIDOverride = appleID
let signInPromise =
Promise<ISStoreAccount> { seal in
let accountService = ISServiceProxy.genericShared().accountService
accountService.setStoreClient(ISStoreClient(storeClientType: 0))
accountService.signIn(with: context) { success, storeAccount, error in
if success, let storeAccount {
seal.fulfill(storeAccount)
} else {
seal.reject(MASError.signInFailed(error: error as NSError?))
}
}
}
if systemDialog {
return signInPromise
}
context.demoMode = true
context.demoAccountName = appleID
context.demoAccountPassword = password
context.demoAutologinMode = true
return race(
signInPromise,
after(seconds: timeout)
.then {
Promise(error: MASError.signInFailed(error: nil))
}
)
}
}
}
}

View file

@ -1,6 +1,6 @@
// //
// PurchaseDownloadObserver.swift // PurchaseDownloadObserver.swift
// mas-cli // mas
// //
// Created by Andrew Naylor on 21/08/2015. // Created by Andrew Naylor on 21/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved. // Copyright (c) 2015 Andrew Naylor. All rights reserved.
@ -9,7 +9,8 @@
import CommerceKit import CommerceKit
import StoreFoundation import StoreFoundation
@objc class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver { @objc
class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver {
let purchase: SSPurchase let purchase: SSPurchase
var completionHandler: (() -> Void)? var completionHandler: (() -> Void)?
var errorHandler: ((MASError) -> Void)? var errorHandler: ((MASError) -> Void)?
@ -19,7 +20,8 @@ import StoreFoundation
} }
func downloadQueue(_ queue: CKDownloadQueue, statusChangedFor download: SSDownload) { func downloadQueue(_ queue: CKDownloadQueue, statusChangedFor download: SSDownload) {
guard download.metadata.itemIdentifier == purchase.itemIdentifier, guard
download.metadata.itemIdentifier == purchase.itemIdentifier,
let status = download.status let status = download.status
else { else {
return return
@ -41,7 +43,8 @@ import StoreFoundation
} }
func downloadQueue(_: CKDownloadQueue, changedWithRemoval download: SSDownload) { func downloadQueue(_: CKDownloadQueue, changedWithRemoval download: SSDownload) {
guard download.metadata.itemIdentifier == purchase.itemIdentifier, guard
download.metadata.itemIdentifier == purchase.itemIdentifier,
let status = download.status let status = download.status
else { else {
return return
@ -64,6 +67,7 @@ struct ProgressState {
let phase: String let phase: String
var percentage: String { var percentage: String {
// swiftlint:disable:next no_magic_numbers
String(format: "%.1f%%", floor(percentComplete * 1000) / 10) String(format: "%.1f%%", floor(percentComplete * 1000) / 10)
} }
} }

View file

@ -0,0 +1,94 @@
//
// SSPurchase.swift
// mas
//
// Created by Andrew Naylor on 25/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import CommerceKit
import PromiseKit
import StoreFoundation
extension SSPurchase {
func perform(appID: AppID, purchase: Bool) -> Promise<Void> {
var parameters: [String: Any] = [
"productType": "C",
"price": 0,
"salableAdamId": appID,
"pg": "default",
"appExtVrsId": 0,
]
if purchase {
parameters["macappinstalledconfirmed"] = 1
parameters["pricingParameters"] = "STDQ"
} else {
parameters["pricingParameters"] = "STDRDL"
}
buyParameters =
parameters.map { key, value in
"\(key)=\(value)"
}
.joined(separator: "&")
itemIdentifier = appID
// Not sure if this is needed
if purchase {
isRedownload = false
}
downloadMetadata = SSDownloadMetadata()
downloadMetadata.kind = "software"
downloadMetadata.itemIdentifier = appID
// Monterey obscures the user's App Store account, but allows
// redownloads without passing any account IDs to SSPurchase.
// https://github.com/mas-cli/mas/issues/417
if #available(macOS 12, *) {
return perform()
}
return
ISStoreAccount.primaryAccount
.then { storeAccount in
self.accountIdentifier = storeAccount.dsID
self.appleID = storeAccount.identifier
return self.perform()
}
}
private func perform() -> Promise<Void> {
Promise<SSPurchase> { seal in
CKPurchaseController.shared()
.perform(self, withOptions: 0) { purchase, _, error, response in
if let error {
seal.reject(MASError.purchaseFailed(error: error as NSError?))
return
}
guard response?.downloads.isEmpty == false, let purchase else {
seal.reject(MASError.noDownloads)
return
}
seal.fulfill(purchase)
}
}
.then { purchase in
let observer = PurchaseDownloadObserver(purchase: purchase)
let downloadQueue = CKDownloadQueue.shared()
let observerID = downloadQueue.add(observer)
return Promise<Void> { seal in
observer.errorHandler = seal.reject
observer.completionHandler = seal.fulfill_
}
.ensure {
downloadQueue.remove(observerID)
}
}
}
}

View file

@ -1,11 +1,15 @@
// //
// StoreAccount.swift // StoreAccount.swift
// mas-cli // mas
// //
// Created by Ben Chatelain on 4/3/18. // Created by Ben Chatelain on 4/3/18.
// Copyright © 2018 Andrew Naylor. All rights reserved. // Copyright © 2018 Andrew Naylor. All rights reserved.
// //
import Foundation
// periphery:ignore - save for future use in testing
protocol StoreAccount { protocol StoreAccount {
var identifier: String { get set } var identifier: String { get set }
var dsID: NSNumber { get set }
} }

View file

@ -0,0 +1,33 @@
//
// Account.swift
// mas
//
// Created by Andrew Naylor on 21/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import ArgumentParser
import StoreFoundation
extension MAS {
struct Account: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Prints the primary account Apple ID"
)
/// Runs the command.
func run() throws {
if #available(macOS 12, *) {
// Account information is no longer available as of Monterey.
// https://github.com/mas-cli/mas/issues/417
throw MASError.notSupported
}
do {
print(try ISStoreAccount.primaryAccount.wait().identifier)
} catch {
throw error as? MASError ?? MASError.failed(error: error as NSError)
}
}
}
}

View file

@ -0,0 +1,49 @@
//
// Home.swift
// mas
//
// Created by Ben Chatelain on 2018-12-29.
// Copyright © 2016 mas-cli. All rights reserved.
//
import ArgumentParser
extension MAS {
/// Opens app page on MAS Preview. Uses the iTunes Lookup API:
/// https://performance-partners.apple.com/search-api
struct Home: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Opens MAS Preview app page in a browser"
)
@Argument(help: "ID of app to show on MAS Preview")
var appID: AppID
/// Runs the command.
func run() throws {
try run(searcher: ITunesSearchAppStoreSearcher(), openCommand: OpenSystemCommand())
}
func run(searcher: AppStoreSearcher, openCommand: ExternalCommand) throws {
do {
guard let result = try searcher.lookup(appID: appID).wait() else {
throw MASError.noSearchResultsFound
}
do {
try openCommand.run(arguments: result.trackViewUrl)
} catch {
printError("Unable to launch open command")
throw MASError.searchFailed
}
if openCommand.failed {
let reason = openCommand.process.terminationReason
printError("Open failed: (\(reason)) \(openCommand.stderr)")
throw MASError.searchFailed
}
} catch {
throw error as? MASError ?? .searchFailed
}
}
}
}

View file

@ -0,0 +1,40 @@
//
// Info.swift
// mas
//
// Created by Denis Lebedev on 21/10/2016.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import ArgumentParser
import Foundation
extension MAS {
/// Displays app details. Uses the iTunes Lookup API:
/// https://performance-partners.apple.com/search-api
struct Info: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Display app information from the Mac App Store"
)
@Argument(help: "ID of app to show info")
var appID: AppID
/// Runs the command.
func run() throws {
try run(searcher: ITunesSearchAppStoreSearcher())
}
func run(searcher: AppStoreSearcher) throws {
do {
guard let result = try searcher.lookup(appID: appID).wait() else {
throw MASError.noSearchResultsFound
}
print(AppInfoFormatter.format(app: result))
} catch {
throw error as? MASError ?? .searchFailed
}
}
}
}

View file

@ -0,0 +1,47 @@
//
// Install.swift
// mas
//
// Created by Andrew Naylor on 21/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import ArgumentParser
import CommerceKit
extension MAS {
/// Installs previously purchased apps from the Mac App Store.
struct Install: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Install from the Mac App Store"
)
@Flag(help: "force reinstall")
var force = false
@Argument(help: "app ID(s) to install")
var appIDs: [AppID]
/// Runs the command.
func run() throws {
try run(appLibrary: SoftwareMapAppLibrary())
}
func run(appLibrary: AppLibrary) throws {
// Try to download applications with given identifiers and collect results
let appIDs = appIDs.filter { appID in
if let appName = appLibrary.installedApps(withAppID: appID).first?.appName, !force {
printWarning("\(appName) is already installed")
return false
}
return true
}
do {
try downloadAll(appIDs).wait()
} catch {
throw error as? MASError ?? .downloadFailed(error: error as NSError)
}
}
}
}

View file

@ -0,0 +1,32 @@
//
// List.swift
// mas
//
// Created by Andrew Naylor on 21/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import ArgumentParser
extension MAS {
/// Command which lists all installed apps.
struct List: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Lists apps from the Mac App Store which are currently installed"
)
/// Runs the command.
func run() throws {
try run(appLibrary: SoftwareMapAppLibrary())
}
func run(appLibrary: AppLibrary) throws {
let products = appLibrary.installedApps
if products.isEmpty {
printError("No installed apps found")
} else {
print(AppListFormatter.format(products: products))
}
}
}
}

View file

@ -0,0 +1,72 @@
//
// Lucky.swift
// mas
//
// Created by Pablo Varela on 05/11/17.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import ArgumentParser
import CommerceKit
extension MAS {
/// Command which installs the first search result.
///
/// This is handy as many MAS titles can be long with embedded keywords.
struct Lucky: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Install the first result from the Mac App Store"
)
@Flag(help: "force reinstall")
var force = false
@Argument(help: "the app name to install")
var searchTerm: String
/// Runs the command.
func run() throws {
try run(appLibrary: SoftwareMapAppLibrary(), searcher: ITunesSearchAppStoreSearcher())
}
func run(appLibrary: AppLibrary, searcher: AppStoreSearcher) throws {
var appID: AppID?
do {
let results = try searcher.search(for: searchTerm).wait()
guard let result = results.first else {
printError("No results found")
throw MASError.noSearchResultsFound
}
appID = result.trackId
} catch {
throw error as? MASError ?? .searchFailed
}
guard let appID else {
fatalError("app ID returned from Apple is null")
}
try install(appID: appID, appLibrary: appLibrary)
}
/// Installs an app.
///
/// - Parameters:
/// - appID: App identifier
/// - appLibrary: Library of installed apps
/// - Throws: Any error that occurs while attempting to install the app.
private func install(appID: AppID, appLibrary: AppLibrary) throws {
// Try to download applications with given identifiers and collect results
if let appName = appLibrary.installedApps(withAppID: appID).first?.appName, !force {
printWarning("\(appName) is already installed")
} else {
do {
try downloadAll([appID]).wait()
} catch {
throw error as? MASError ?? .downloadFailed(error: error as NSError)
}
}
}
}
}

View file

@ -0,0 +1,66 @@
//
// Open.swift
// mas
//
// Created by Ben Chatelain on 2018-12-29.
// Copyright © 2016 mas-cli. All rights reserved.
//
import ArgumentParser
import Foundation
private let masScheme = "macappstore"
extension MAS {
/// Opens app page in MAS app. Uses the iTunes Lookup API:
/// https://performance-partners.apple.com/search-api
struct Open: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Opens app page in 'App Store.app'"
)
@Argument(help: "the app ID")
var appID: AppID?
/// Runs the command.
func run() throws {
try run(searcher: ITunesSearchAppStoreSearcher(), openCommand: OpenSystemCommand())
}
func run(searcher: AppStoreSearcher, openCommand: ExternalCommand) throws {
do {
guard let appID else {
// If no app ID is given, just open the MAS GUI app
try openCommand.run(arguments: masScheme + "://")
return
}
guard let result = try searcher.lookup(appID: appID).wait() else {
throw MASError.noSearchResultsFound
}
guard var url = URLComponents(string: result.trackViewUrl) else {
throw MASError.searchFailed
}
url.scheme = masScheme
guard let urlString = url.string else {
printError("Unable to construct URL")
throw MASError.searchFailed
}
do {
try openCommand.run(arguments: urlString)
} catch {
printError("Unable to launch open command")
throw MASError.searchFailed
}
if openCommand.failed {
printError("Open failed: (\(openCommand.process.terminationReason)) \(openCommand.stderr)")
throw MASError.searchFailed
}
} catch {
throw error as? MASError ?? .searchFailed
}
}
}
}

View file

@ -0,0 +1,63 @@
//
// Outdated.swift
// mas
//
// Created by Andrew Naylor on 21/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved.
//
import ArgumentParser
import Foundation
import PromiseKit
extension MAS {
/// Command which displays a list of installed apps which have available updates
/// ready to be installed from the Mac App Store.
struct Outdated: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Lists pending updates from the Mac App Store"
)
@Flag(help: "Show warnings about apps")
var verbose = false
/// Runs the command.
func run() throws {
try run(appLibrary: SoftwareMapAppLibrary(), searcher: ITunesSearchAppStoreSearcher())
}
func run(appLibrary: AppLibrary, searcher: AppStoreSearcher) throws {
_ = try when(
fulfilled:
appLibrary.installedApps.map { installedApp in
firstly {
searcher.lookup(appID: installedApp.itemIdentifier.appIDValue)
}
.done { storeApp in
guard let storeApp else {
if verbose {
printWarning(
"""
Identifier \(installedApp.itemIdentifier) not found in store. \
Was expected to identify \(installedApp.appName).
"""
)
}
return
}
if installedApp.isOutdatedWhenComparedTo(storeApp) {
print(
"""
\(installedApp.itemIdentifier) \(installedApp.appName) \
(\(installedApp.bundleVersion) -> \(storeApp.version))
"""
)
}
}
}
)
.wait()
}
}
}

View file

@ -0,0 +1,44 @@
//
// Purchase.swift
// mas
//
// Created by Jakob Rieck on 24/10/2017.
// Copyright (c) 2017 Jakob Rieck. All rights reserved.
//
import ArgumentParser
import CommerceKit
extension MAS {
struct Purchase: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Purchase and download free apps from the Mac App Store"
)
@Argument(help: "app ID(s) to install")
var appIDs: [AppID]
/// Runs the command.
func run() throws {
try run(appLibrary: SoftwareMapAppLibrary())
}
func run(appLibrary: AppLibrary) throws {
// Try to download applications with given identifiers and collect results
let appIDs = appIDs.filter { appID in
if let appName = appLibrary.installedApps(withAppID: appID).first?.appName {
printWarning("\(appName) has already been purchased.")
return false
}
return true
}
do {
try downloadAll(appIDs, purchase: true).wait()
} catch {
throw error as? MASError ?? .downloadFailed(error: error as NSError)
}
}
}
}

View file

@ -0,0 +1,79 @@
//
// Reset.swift
// mas
//
// Created by Andrew Naylor on 14/09/2016.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import ArgumentParser
import CommerceKit
extension MAS {
/// Kills several macOS processes as a means to reset the app store.
struct Reset: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Resets the Mac App Store"
)
@Flag(help: "Enable debug mode")
var debug = false
/// Runs the command.
func run() throws {
// The "Reset Application" command in the Mac App Store debug menu performs
// the following steps
//
// - killall Dock
// - killall storeagent (storeagent no longer exists)
// - rm com.apple.appstore download directory
// - clear cookies (appears to be a no-op)
//
// As storeagent no longer exists we will implement a slight variant and kill all
// App Store-associated processes
// - storeaccountd
// - storeassetd
// - storedownloadd
// - storeinstalld
// - storelegacy
// Kill processes
let killProcs = [
"Dock",
"storeaccountd",
"storeassetd",
"storedownloadd",
"storeinstalld",
"storelegacy",
]
let kill = Process()
let stdout = Pipe()
let stderr = Pipe()
kill.launchPath = "/usr/bin/killall"
kill.arguments = killProcs
kill.standardOutput = stdout
kill.standardError = stderr
kill.launch()
kill.waitUntilExit()
if kill.terminationStatus != 0, debug {
let output = stderr.fileHandleForReading.readDataToEndOfFile()
printError("killall failed:\n\(String(data: output, encoding: .utf8) ?? "Error info not available")")
}
// Wipe Download Directory
if let directory = CKDownloadDirectory(nil) {
do {
try FileManager.default.removeItem(atPath: directory)
} catch {
if debug {
printError("removeItemAtPath:\"\(directory)\" failed, \(error)")
}
}
}
}
}
}

View file

@ -0,0 +1,43 @@
//
// Search.swift
// mas
//
// Created by Michael Schneider on 4/14/16.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import ArgumentParser
extension MAS {
/// Search the Mac App Store using the iTunes Search API.
///
/// See - https://performance-partners.apple.com/search-api
struct Search: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Search for apps from the Mac App Store"
)
@Flag(help: "Show price of found apps")
var price = false
@Argument(help: "the app name to search")
var searchTerm: String
func run() throws {
try run(searcher: ITunesSearchAppStoreSearcher())
}
func run(searcher: AppStoreSearcher) throws {
do {
let results = try searcher.search(for: searchTerm).wait()
if results.isEmpty {
throw MASError.noSearchResultsFound
}
let output = SearchResultFormatter.format(results: results, includePrice: price)
print(output)
} catch {
throw error as? MASError ?? .searchFailed
}
}
}
}

View file

@ -0,0 +1,35 @@
//
// SignIn.swift
// mas
//
// Created by Andrew Naylor on 14/02/2016.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import ArgumentParser
import StoreFoundation
extension MAS {
struct SignIn: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "signin",
abstract: "Sign in to the Mac App Store"
)
@Flag(help: "Complete login with graphical dialog")
var dialog = false
@Argument(help: "Apple ID")
var appleID: String
@Argument(help: "Password")
var password: String = ""
/// Runs the command.
func run() throws {
do {
_ = try ISStoreAccount.signIn(appleID: appleID, password: password, systemDialog: dialog).wait()
} catch {
throw error as? MASError ?? MASError.signInFailed(error: error as NSError)
}
}
}
}

View file

@ -0,0 +1,30 @@
//
// SignOut.swift
// mas
//
// Created by Andrew Naylor on 14/02/2016.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import ArgumentParser
import CommerceKit
extension MAS {
struct SignOut: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "signout",
abstract: "Sign out of the Mac App Store"
)
/// Runs the command.
func run() throws {
if #available(macOS 10.13, *) {
ISServiceProxy.genericShared().accountService.signOut()
} else {
// Using CKAccountStore to sign out does nothing on High Sierra
// https://github.com/mas-cli/mas/issues/129
CKAccountStore.shared().signOut()
}
}
}
}

View file

@ -0,0 +1,65 @@
//
// Uninstall.swift
// mas
//
// Created by Ben Chatelain on 2018-12-27.
// Copyright © 2015 Andrew Naylor. All rights reserved.
//
import ArgumentParser
import Foundation
extension MAS {
/// Command which uninstalls apps managed by the Mac App Store.
struct Uninstall: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Uninstall app installed from the Mac App Store"
)
/// Flag indicating that removal shouldn't be performed.
@Flag(help: "dry run")
var dryRun = false
@Argument(help: "ID of app to uninstall")
var appID: AppID
/// Runs the uninstall command.
func run() throws {
try run(appLibrary: SoftwareMapAppLibrary())
}
func run(appLibrary: AppLibrary) throws {
guard NSUserName() == "root" else {
throw MASError.macOSUserMustBeRoot
}
guard let username = getSudoUsername() else {
throw MASError.runtimeError("Could not determine the original username")
}
guard
let uid = getSudoUID(),
seteuid(uid) == 0
else {
throw MASError.runtimeError("Failed to switch effective user from 'root' to '\(username)'")
}
let installedApps = appLibrary.installedApps(withAppID: appID)
guard !installedApps.isEmpty else {
throw MASError.notInstalled(appID: appID)
}
if dryRun {
for installedApp in installedApps {
printInfo("'\(installedApp.appName)' '\(installedApp.bundlePath)'")
}
printInfo("(not removed, dry run)")
} else {
guard seteuid(0) == 0 else {
throw MASError.runtimeError("Failed to revert effective user from '\(username)' back to 'root'")
}
try appLibrary.uninstallApps(atPaths: installedApps.map(\.bundlePath))
}
}
}
}

View file

@ -0,0 +1,88 @@
//
// Upgrade.swift
// mas
//
// Created by Andrew Naylor on 30/12/2015.
// Copyright © 2015 Andrew Naylor. All rights reserved.
//
import ArgumentParser
import Foundation
import PromiseKit
extension MAS {
/// Command which upgrades apps with new versions available in the Mac App Store.
struct Upgrade: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Upgrade outdated apps from the Mac App Store"
)
@Argument(help: "app(s) to upgrade")
var appIDs: [String] = []
/// Runs the command.
func run() throws {
try run(appLibrary: SoftwareMapAppLibrary(), searcher: ITunesSearchAppStoreSearcher())
}
func run(appLibrary: AppLibrary, searcher: AppStoreSearcher) throws {
let apps: [(installedApp: SoftwareProduct, storeApp: SearchResult)]
do {
apps = try findOutdatedApps(appLibrary: appLibrary, searcher: searcher)
} catch {
throw error as? MASError ?? .searchFailed
}
guard !apps.isEmpty else {
printWarning("Nothing found to upgrade")
return
}
print("Upgrading \(apps.count) outdated application\(apps.count > 1 ? "s" : ""):")
print(
apps.map { "\($0.installedApp.appName) (\($0.installedApp.bundleVersion)) -> (\($0.storeApp.version))" }
.joined(separator: "\n")
)
do {
try downloadAll(apps.map(\.installedApp.itemIdentifier.appIDValue)).wait()
} catch {
throw error as? MASError ?? .downloadFailed(error: error as NSError)
}
}
private func findOutdatedApps(
appLibrary: AppLibrary,
searcher: AppStoreSearcher
) throws -> [(SoftwareProduct, SearchResult)] {
let apps =
appIDs.isEmpty
? appLibrary.installedApps
: appIDs.flatMap { appID in
if let appID = AppID(appID) {
// argument is an AppID, lookup apps by id using argument
return appLibrary.installedApps(withAppID: appID)
}
// argument is not an AppID, lookup apps by name using argument
return appLibrary.installedApps(named: appID)
}
let promises = apps.map { installedApp in
// only upgrade apps whose local version differs from the store version
firstly {
searcher.lookup(appID: installedApp.itemIdentifier.appIDValue)
}
.map { result -> (SoftwareProduct, SearchResult)? in
guard let storeApp = result, installedApp.isOutdatedWhenComparedTo(storeApp) else {
return nil
}
return (installedApp, storeApp)
}
}
return try when(fulfilled: promises).wait().compactMap { $0 }
}
}
}

View file

@ -0,0 +1,53 @@
//
// Vendor.swift
// mas
//
// Created by Ben Chatelain on 2018-12-29.
// Copyright © 2016 mas-cli. All rights reserved.
//
import ArgumentParser
extension MAS {
/// Opens vendor's app page in a browser. Uses the iTunes Lookup API:
/// https://performance-partners.apple.com/search-api
struct Vendor: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Opens vendor's app page in a browser"
)
@Argument(help: "the app ID to show the vendor's website")
var appID: AppID
/// Runs the command.
func run() throws {
try run(searcher: ITunesSearchAppStoreSearcher(), openCommand: OpenSystemCommand())
}
func run(searcher: AppStoreSearcher, openCommand: ExternalCommand) throws {
do {
guard let result = try searcher.lookup(appID: appID).wait() else {
throw MASError.noSearchResultsFound
}
guard let vendorWebsite = result.sellerUrl else {
throw MASError.noVendorWebsite
}
do {
try openCommand.run(arguments: vendorWebsite)
} catch {
printError("Unable to launch open command")
throw MASError.searchFailed
}
if openCommand.failed {
let reason = openCommand.process.terminationReason
printError("Open failed: (\(reason)) \(openCommand.stderr)")
throw MASError.searchFailed
}
} catch {
throw error as? MASError ?? .searchFailed
}
}
}
}

View file

@ -0,0 +1,23 @@
//
// Version.swift
// mas
//
// Created by Andrew Naylor on 20/09/2015.
// Copyright © 2015 Andrew Naylor. All rights reserved.
//
import ArgumentParser
extension MAS {
/// Command which displays the version of the mas tool.
struct Version: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Print version number"
)
/// Runs the command.
func run() throws {
print(Package.version)
}
}
}

View file

@ -0,0 +1,41 @@
//
// AppLibrary.swift
// mas
//
// Created by Ben Chatelain on 12/27/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Foundation
/// Utility for managing installed apps.
protocol AppLibrary {
/// Entire set of installed apps.
var installedApps: [SoftwareProduct] { get }
/// Uninstalls all apps located at any of the elements of `appPaths`.
///
/// - Parameter appPaths: Paths to apps to be uninstalled.
/// - Throws: Error if any problem occurs.
func uninstallApps(atPaths appPaths: [String]) throws
}
/// Common logic
extension AppLibrary {
/// Finds all installed instances of apps whose app ID is `appID`.
///
/// - Parameter appID: app ID for app(s).
/// - Returns: [SoftwareProduct] of matching apps.
func installedApps(withAppID appID: AppID) -> [SoftwareProduct] {
let appID = NSNumber(value: appID)
return installedApps.filter { $0.itemIdentifier == appID }
}
/// Finds all installed instances of apps whose name is `appName`.
///
/// - Parameter appName: Full name of app(s).
/// - Returns: [SoftwareProduct] of matching apps.
func installedApps(named appName: String) -> [SoftwareProduct] {
installedApps.filter { $0.appName == appName }
}
}

View file

@ -0,0 +1,96 @@
//
// AppStoreSearcher.swift
// mas
//
// Created by Ben Chatelain on 12/29/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Foundation
import PromiseKit
/// Protocol for searching the MAS catalog.
protocol AppStoreSearcher {
func lookup(appID: AppID) -> Promise<SearchResult?>
func search(for searchTerm: String) -> Promise<[SearchResult]>
}
enum Entity: String {
case desktopSoftware
case macSoftware
case iPadSoftware
case iPhoneSoftware = "software"
}
private enum URLAction {
case lookup
case search
var queryItemName: String {
switch self {
case .lookup:
return "id"
case .search:
return "term"
}
}
}
// MARK: - Common methods
extension AppStoreSearcher {
/// Builds the search URL for an app.
///
/// - Parameters:
/// - searchTerm: term for which to search in MAS.
/// - country: 2-letter ISO region code of the MAS in which to search.
/// - entity: OS platform of apps for which to search.
/// - Returns: URL for the search service or nil if searchTerm can't be encoded.
func searchURL(
for searchTerm: String,
inCountry country: String?,
ofEntity entity: Entity = .desktopSoftware
) -> URL? {
url(.search, searchTerm, inCountry: country, ofEntity: entity)
}
/// Builds the lookup URL for an app.
///
/// - Parameters:
/// - appID: MAS app identifier.
/// - country: 2-letter ISO region code of the MAS in which to search.
/// - entity: OS platform of apps for which to search.
/// - Returns: URL for the lookup service or nil if appID can't be encoded.
func lookupURL(
forAppID appID: AppID,
inCountry country: String?,
ofEntity entity: Entity = .desktopSoftware
) -> URL? {
url(.lookup, String(appID), inCountry: country, ofEntity: entity)
}
private func url(
_ action: URLAction,
_ queryItemValue: String,
inCountry country: String?,
ofEntity entity: Entity = .desktopSoftware
) -> URL? {
guard var components = URLComponents(string: "https://itunes.apple.com/\(action)") else {
return nil
}
var queryItems = [
URLQueryItem(name: "media", value: "software"),
URLQueryItem(name: "entity", value: entity.rawValue),
]
if let country {
queryItems.append(URLQueryItem(name: "country", value: country))
}
queryItems.append(URLQueryItem(name: action.queryItemName, value: queryItemValue))
components.queryItems = queryItems
return components.url
}
}

View file

@ -0,0 +1,733 @@
// swift-format-ignore-file
// swiftlint:disable:next blanket_disable_command
// swiftlint:disable attributes discouraged_none_name file_length file_types_order identifier_name
// swiftlint:disable:next blanket_disable_command
// swiftlint:disable implicitly_unwrapped_optional line_length missing_docs
import AppKit
import ScriptingBridge
// MARK: FinderEdfm
@objc
public enum FinderEdfm: AEKeyword {
case macOSFormat = 0x6466_6866 // 'dfhf'
case macOSExtendedFormat = 0x6466_682b // 'dfh+'
case ufsFormat = 0x6466_7566 // 'dfuf'
case nfsFormat = 0x6466_6e66 // 'dfnf'
case audioFormat = 0x6466_6175 // 'dfau'
case proDOSFormat = 0x6466_7072 // 'dfpr'
case msdosFormat = 0x6466_6d73 // 'dfms'
case ntfsFormat = 0x6466_6e74 // 'dfnt'
case iso9660Format = 0x6466_3936 // 'df96'
case highSierraFormat = 0x6466_6873 // 'dfhs'
case quickTakeFormat = 0x6466_7174 // 'dfqt'
case applePhotoFormat = 0x6466_7068 // 'dfph'
case appleShareFormat = 0x6466_6173 // 'dfas'
case udfFormat = 0x6466_7564 // 'dfud'
case webDAVFormat = 0x6466_7764 // 'dfwd'
case ftpFormat = 0x6466_6674 // 'dfft'
case packetWrittenUDFFormat = 0x6466_7075 // 'dfpu'
case xsanFormat = 0x6466_6163 // 'dfac'
case apfsFormat = 0x6466_6170 // 'dfap'
case exFATFormat = 0x6466_7866 // 'dfxf'
case smbFormat = 0x6466_736d // 'dfsm'
case unknownFormat = 0x6466_3f3f // 'df??'
}
// MARK: FinderIpnl
@objc
public enum FinderIpnl: AEKeyword {
case generalInformationPanel = 0x6770_6e6c // 'gpnl'
case sharingPanel = 0x7370_6e6c // 'spnl'
case memoryPanel = 0x6d70_6e6c // 'mpnl'
case previewPanel = 0x7670_6e6c // 'vpnl'
case applicationPanel = 0x6170_6e6c // 'apnl'
case languagesPanel = 0x706b_6c67 // 'pklg'
case pluginsPanel = 0x706b_7067 // 'pkpg'
case nameExtensionPanel = 0x6e70_6e6c // 'npnl'
case commentsPanel = 0x6370_6e6c // 'cpnl'
case contentIndexPanel = 0x6369_6e6c // 'cinl'
case burningPanel = 0x6270_6e6c // 'bpnl'
case moreInfoPanel = 0x6d69_6e6c // 'minl'
case simpleHeaderPanel = 0x7368_6e6c // 'shnl'
}
// MARK: FinderPple
@objc
public enum FinderPple: AEKeyword {
case generalPreferencesPanel = 0x7067_6e70 // 'pgnp'
case labelPreferencesPanel = 0x706c_6270 // 'plbp'
case sidebarPreferencesPanel = 0x7073_6964 // 'psid'
case advancedPreferencesPanel = 0x7061_6476 // 'padv'
}
// MARK: FinderPriv
@objc
public enum FinderPriv: AEKeyword {
case readOnly = 0x7265_6164 // 'read'
case readWrite = 0x7264_7772 // 'rdwr'
case writeOnly = 0x7772_6974 // 'writ'
case none = 0x6e6f_6e65 // 'none'
}
// MARK: FinderEcvw
@objc
public enum FinderEcvw: AEKeyword {
case iconView = 0x6963_6e76 // 'icnv'
case listView = 0x6c73_7677 // 'lsvw'
case columnView = 0x636c_7677 // 'clvw'
case groupView = 0x6772_7677 // 'grvw'
case flowView = 0x666c_7677 // 'flvw'
}
// MARK: FinderEarr
@objc
public enum FinderEarr: AEKeyword {
case notArranged = 0x6e61_7272 // 'narr'
case snapToGrid = 0x6772_6461 // 'grda'
case arrangedByName = 0x6e61_6d61 // 'nama'
case arrangedByModificationDate = 0x6d64_7461 // 'mdta'
case arrangedByCreationDate = 0x6364_7461 // 'cdta'
case arrangedBySize = 0x7369_7a61 // 'siza'
case arrangedByKind = 0x6b69_6e61 // 'kina'
case arrangedByLabel = 0x6c61_6261 // 'laba'
}
// MARK: FinderEpos
@objc
public enum FinderEpos: AEKeyword {
case right = 0x6c72_6774 // 'lrgt'
case bottom = 0x6c62_6f74 // 'lbot'
}
// MARK: FinderSodr
@objc
public enum FinderSodr: AEKeyword {
case normal = 0x736e_726d // 'snrm'
case reversed = 0x7372_7673 // 'srvs'
}
// MARK: FinderElsv
@objc
public enum FinderElsv: AEKeyword {
case nameColumn = 0x656c_736e // 'elsn'
case modificationDateColumn = 0x656c_736d // 'elsm'
case creationDateColumn = 0x656c_7363 // 'elsc'
case sizeColumn = 0x656c_7373 // 'elss'
case kindColumn = 0x656c_736b // 'elsk'
case labelColumn = 0x656c_736c // 'elsl'
case versionColumn = 0x656c_7376 // 'elsv'
case commentColumn = 0x656c_7343 // 'elsC'
}
// MARK: FinderLvic
@objc
public enum FinderLvic: AEKeyword {
case smallIcon = 0x736d_6963 // 'smic'
case largeIcon = 0x6c67_6963 // 'lgic'
}
@objc
public protocol SBObjectProtocol: NSObjectProtocol {
func get() -> Any!
}
@objc
public protocol SBApplicationProtocol: SBObjectProtocol {
var delegate: SBApplicationDelegate! { get set }
var isRunning: Bool { get }
func activate()
}
// MARK: FinderGenericMethods
@objc
public protocol FinderGenericMethods {
@objc optional func openUsing(_ using_: SBObject!, withProperties: [AnyHashable: Any]!) // Open the specified object(s)
@objc optional func printWithProperties(_ withProperties: [AnyHashable: Any]!) // Print the specified object(s)
@objc optional func activate() // Activate the specified window (or the Finder)
@objc optional func close() // Close an object
@objc optional func dataSizeAs(_ as: NSNumber!) -> Int // Return the size in bytes of an object
@objc optional func delete() -> SBObject // Move an item from its container to the trash
@objc optional func duplicateTo(_ to: SBObject!, replacing: Bool, routingSuppressed: Bool, exactCopy: Bool) -> SBObject // Duplicate one or more object(s)
@objc optional func exists() -> Bool // Verify if an object exists
@objc optional func moveTo(_ to: SBObject!, replacing: Bool, positionedAt: [Any]!, routingSuppressed: Bool) -> SBObject // Move object(s) to a new location
@objc optional func select() // Select the specified object(s)
@objc optional func sortBy(_ by: Selector) -> SBObject // Return the specified object(s) in a sorted list
@objc optional func cleanUpBy(_ by: Selector) // Arrange items in window nicely (only applies to open windows in icon view that are not kept arranged)
@objc optional func eject() // Eject the specified disk(s)
@objc optional func emptySecurity(_ security: Bool) // Empty the trash
@objc optional func erase() // (NOT AVAILABLE) Erase the specified disk(s)
@objc optional func reveal() // Bring the specified object(s) into view
@objc optional func updateNecessity(_ necessity: Bool, registeringApplications: Bool) // Update the display of the specified object(s) to match their on-disk representation
}
// MARK: FinderApplication
@objc
public protocol FinderApplication: SBApplicationProtocol {
@objc optional var clipboard: SBObject { get } // (NOT AVAILABLE YET) the Finders clipboard window (copy)
@objc optional var name: String { get } // the Finders name (copy)
@objc optional var visible: Bool { get } // Is the Finders layer visible?
@objc optional var frontmost: Bool { get } // Is the Finder the frontmost process?
@objc optional var selection: SBObject { get } // the selection in the frontmost Finder window (copy)
@objc optional var insertionLocation: SBObject { get } // the container in which a new folder would appear if New Folder was selected (copy)
@objc optional var productVersion: String { get } // the version of the System software running on this computer (copy)
@objc optional var version: String { get } // the version of the Finder (copy)
@objc optional var startupDisk: FinderDisk { get } // the startup disk (copy)
@objc optional var desktop: FinderDesktopObject { get } // the desktop (copy)
@objc optional var trash: FinderTrashObject { get } // the trash (copy)
@objc optional var home: FinderFolder { get } // the home directory (copy)
@objc optional var computerContainer: FinderComputerObject { get } // the computer location (as in Go > Computer) (copy)
@objc optional var FinderPreferences: FinderPreferences { get } // Various preferences that apply to the Finder as a whole (copy)
@objc optional var desktopPicture: FinderFile { get } // the desktop picture of the main monitor
@objc optional func setDesktopPicture(_ desktopPicture: FinderFile!) // the desktop picture of the main monitor
@objc optional func items() -> SBElementArray
@objc optional func containers() -> SBElementArray
@objc optional func disks() -> SBElementArray
@objc optional func folders() -> SBElementArray
@objc optional func files() -> SBElementArray
@objc optional func aliasFiles() -> SBElementArray
@objc optional func applicationFiles() -> SBElementArray
@objc optional func documentFiles() -> SBElementArray
@objc optional func internetLocationFiles() -> SBElementArray
@objc optional func clippings() -> SBElementArray
@objc optional func packages() -> SBElementArray
@objc optional func windows() -> SBElementArray
@objc optional func FinderWindows() -> SBElementArray
@objc optional func clippingWindows() -> SBElementArray
@objc optional func quit() // Quit the Finder
@objc optional func activate() // Activate the specified window (or the Finder)
@objc optional func copy() // (NOT AVAILABLE YET) Copy the selected items to the clipboard (the Finder must be the front application)
@objc optional func eject() // Eject the specified disk(s)
@objc optional func emptySecurity(_ security: Bool) // Empty the trash
@objc optional func restart() // Restart the computer
@objc optional func shutDown() // Shut Down the computer
@objc optional func sleep() // Put the computer to sleep
@objc optional func setVisible(_ visible: Bool) // Is the Finders layer visible?
@objc optional func setFrontmost(_ frontmost: Bool) // Is the Finder the frontmost process?
@objc optional func setSelection(_ selection: SBObject!) // the selection in the frontmost Finder window
}
extension SBApplication: FinderApplication {}
// MARK: FinderItem
@objc
public protocol FinderItem: SBObjectProtocol, FinderGenericMethods {
@objc optional var name: String { get } // the name of the item (copy)
@objc optional var displayedName: String { get } // the user-visible name of the item (copy)
@objc optional var nameExtension: String { get } // the name extension of the item (such as txt) (copy)
@objc optional var extensionHidden: Bool { get } // Is the item's extension hidden from the user?
@objc optional var index: Int { get } // the index in the front-to-back ordering within its container
@objc optional var container: SBObject { get } // the container of the item (copy)
@objc optional var disk: SBObject { get } // the disk on which the item is stored (copy)
@objc optional var position: NSPoint { get } // the position of the item within its parent window (can only be set for an item in a window viewed as icons or buttons)
@objc optional var desktopPosition: NSPoint { get } // the position of the item on the desktop
@objc optional var bounds: NSRect { get } // the bounding rectangle of the item (can only be set for an item in a window viewed as icons or buttons)
@objc optional var labelIndex: Int { get } // the label of the item
@objc optional var locked: Bool { get } // Is the file locked?
@objc optional var kind: String { get } // the kind of the item (copy)
@objc optional var objectDescription: String { get } // a description of the item (copy)
@objc optional var comment: String { get } // the comment of the item, displayed in the Get Info window (copy)
@objc optional var size: Int64 { get } // the logical size of the item
@objc optional var physicalSize: Int64 { get } // the actual space used by the item on disk
@objc optional var creationDate: Date { get } // the date on which the item was created (copy)
@objc optional var modificationDate: Date { get } // the date on which the item was last modified (copy)
@objc optional var icon: FinderIconFamily { get } // the icon bitmap of the item (copy)
@objc optional var URL: String { get } // the URL of the item (copy)
@objc optional var owner: String { get } // the user that owns the container (copy)
@objc optional var group: String { get } // the user or group that has special access to the container (copy)
@objc optional var ownerPrivileges: FinderPriv { get }
@objc optional var groupPrivileges: FinderPriv { get }
@objc optional var everyonesPrivileges: FinderPriv { get }
@objc optional var informationWindow: SBObject { get } // the information window for the item (copy)
@objc optional var properties: [AnyHashable: Any] { get } // every property of an item (copy)
@objc optional func setName(_ name: String!) // the name of the item
@objc optional func setNameExtension(_ nameExtension: String!) // the name extension of the item (such as txt)
@objc optional func setExtensionHidden(_ extensionHidden: Bool) // Is the item's extension hidden from the user?
@objc optional func setPosition(_ position: NSPoint) // the position of the item within its parent window (can only be set for an item in a window viewed as icons or buttons)
@objc optional func setDesktopPosition(_ desktopPosition: NSPoint) // the position of the item on the desktop
@objc optional func setBounds(_ bounds: NSRect) // the bounding rectangle of the item (can only be set for an item in a window viewed as icons or buttons)
@objc optional func setLabelIndex(_ labelIndex: Int) // the label of the item
@objc optional func setLocked(_ locked: Bool) // Is the file locked?
@objc optional func setComment(_ comment: String!) // the comment of the item, displayed in the Get Info window
@objc optional func setModificationDate(_ modificationDate: Date!) // the date on which the item was last modified
@objc optional func setIcon(_ icon: FinderIconFamily!) // the icon bitmap of the item
@objc optional func setOwner(_ owner: String!) // the user that owns the container
@objc optional func setGroup(_ group: String!) // the user or group that has special access to the container
@objc optional func setOwnerPrivileges(_ ownerPrivileges: FinderPriv)
@objc optional func setGroupPrivileges(_ groupPrivileges: FinderPriv)
@objc optional func setEveryonesPrivileges(_ everyonesPrivileges: FinderPriv)
@objc optional func setProperties(_ properties: [AnyHashable: Any]!) // every property of an item
}
extension SBObject: FinderItem {}
// MARK: FinderContainer
@objc
public protocol FinderContainer: FinderItem {
@objc optional var entireContents: SBObject { get } // the entire contents of the container, including the contents of its children (copy)
@objc optional var expandable: Bool { get } // (NOT AVAILABLE YET) Is the container capable of being expanded as an outline?
@objc optional var expanded: Bool { get } // (NOT AVAILABLE YET) Is the container opened as an outline? (can only be set for containers viewed as lists)
@objc optional var completelyExpanded: Bool { get } // (NOT AVAILABLE YET) Are the container and all of its children opened as outlines? (can only be set for containers viewed as lists)
@objc optional var containerWindow: SBObject { get } // the container window for this folder (copy)
@objc optional func setExpanded(_ expanded: Bool) // (NOT AVAILABLE YET) Is the container opened as an outline? (can only be set for containers viewed as lists)
@objc optional func setCompletelyExpanded(_ completelyExpanded: Bool) // (NOT AVAILABLE YET) Are the container and all of its children opened as outlines? (can only be set for containers viewed as lists)
@objc optional func items() -> SBElementArray
@objc optional func containers() -> SBElementArray
@objc optional func folders() -> SBElementArray
@objc optional func files() -> SBElementArray
@objc optional func aliasFiles() -> SBElementArray
@objc optional func applicationFiles() -> SBElementArray
@objc optional func documentFiles() -> SBElementArray
@objc optional func internetLocationFiles() -> SBElementArray
@objc optional func clippings() -> SBElementArray
@objc optional func packages() -> SBElementArray
}
extension SBObject: FinderContainer {}
// MARK: FinderComputerObject
@objc
public protocol FinderComputerObject: FinderItem {}
extension SBObject: FinderComputerObject {}
// MARK: FinderDisk
@objc
public protocol FinderDisk: FinderContainer {
@objc optional var capacity: Int64 { get } // the total number of bytes (free or used) on the disk
@objc optional var freeSpace: Int64 { get } // the number of free bytes left on the disk
@objc optional var ejectable: Bool { get } // Can the media be ejected (floppies, CDs, and so on)?
@objc optional var localVolume: Bool { get } // Is the media a local volume (as opposed to a file server)?
@objc optional var startup: Bool { get } // Is this disk the boot disk?
@objc optional var format: FinderEdfm { get } // the filesystem format of this disk
@objc optional var journalingEnabled: Bool { get } // Does this disk do file system journaling?
@objc optional var ignorePrivileges: Bool { get } // Ignore permissions on this disk?
@objc optional func setIgnorePrivileges(_ ignorePrivileges: Bool) // Ignore permissions on this disk?
@objc optional func id() -> Int // the unique id for this disk (unchanged while disk remains connected and Finder remains running)
@objc optional func items() -> SBElementArray
@objc optional func containers() -> SBElementArray
@objc optional func folders() -> SBElementArray
@objc optional func files() -> SBElementArray
@objc optional func aliasFiles() -> SBElementArray
@objc optional func applicationFiles() -> SBElementArray
@objc optional func documentFiles() -> SBElementArray
@objc optional func internetLocationFiles() -> SBElementArray
@objc optional func clippings() -> SBElementArray
@objc optional func packages() -> SBElementArray
}
extension SBObject: FinderDisk {}
// MARK: FinderFolder
@objc
public protocol FinderFolder: FinderContainer {
@objc optional func items() -> SBElementArray
@objc optional func containers() -> SBElementArray
@objc optional func folders() -> SBElementArray
@objc optional func files() -> SBElementArray
@objc optional func aliasFiles() -> SBElementArray
@objc optional func applicationFiles() -> SBElementArray
@objc optional func documentFiles() -> SBElementArray
@objc optional func internetLocationFiles() -> SBElementArray
@objc optional func clippings() -> SBElementArray
@objc optional func packages() -> SBElementArray
}
extension SBObject: FinderFolder {}
// MARK: FinderDesktopObject
@objc
public protocol FinderDesktopObject: FinderContainer {
@objc optional func items() -> SBElementArray
@objc optional func containers() -> SBElementArray
@objc optional func disks() -> SBElementArray
@objc optional func folders() -> SBElementArray
@objc optional func files() -> SBElementArray
@objc optional func aliasFiles() -> SBElementArray
@objc optional func applicationFiles() -> SBElementArray
@objc optional func documentFiles() -> SBElementArray
@objc optional func internetLocationFiles() -> SBElementArray
@objc optional func clippings() -> SBElementArray
@objc optional func packages() -> SBElementArray
}
extension SBObject: FinderDesktopObject {}
// MARK: FinderTrashObject
@objc
public protocol FinderTrashObject: FinderContainer {
@objc optional var warnsBeforeEmptying: Bool { get } // Display a dialog when emptying the trash?
@objc optional func setWarnsBeforeEmptying(_ warnsBeforeEmptying: Bool) // Display a dialog when emptying the trash?
@objc optional func items() -> SBElementArray
@objc optional func containers() -> SBElementArray
@objc optional func folders() -> SBElementArray
@objc optional func files() -> SBElementArray
@objc optional func aliasFiles() -> SBElementArray
@objc optional func applicationFiles() -> SBElementArray
@objc optional func documentFiles() -> SBElementArray
@objc optional func internetLocationFiles() -> SBElementArray
@objc optional func clippings() -> SBElementArray
@objc optional func packages() -> SBElementArray
}
extension SBObject: FinderTrashObject {}
// MARK: FinderFile
@objc
public protocol FinderFile: FinderItem {
@objc optional var fileType: NSNumber { get } // the OSType identifying the type of data contained in the item (copy)
@objc optional var creatorType: NSNumber { get } // the OSType identifying the application that created the item (copy)
@objc optional var stationery: Bool { get } // Is the file a stationery pad?
@objc optional var productVersion: String { get } // the version of the product (visible at the top of the Get Info window) (copy)
@objc optional var version: String { get } // the version of the file (visible at the bottom of the Get Info window) (copy)
@objc optional func setFileType(_ fileType: NSNumber!) // the OSType identifying the type of data contained in the item
@objc optional func setCreatorType(_ creatorType: NSNumber!) // the OSType identifying the application that created the item
@objc optional func setStationery(_ stationery: Bool) // Is the file a stationery pad?
}
extension SBObject: FinderFile {}
// MARK: FinderAliasFile
@objc
public protocol FinderAliasFile: FinderFile {
@objc optional var originalItem: SBObject { get } // the original item pointed to by the alias (copy)
@objc optional func setOriginalItem(_ originalItem: SBObject!) // the original item pointed to by the alias
}
extension SBObject: FinderAliasFile {}
// MARK: FinderApplicationFile
@objc
public protocol FinderApplicationFile: FinderFile {
@objc optional var suggestedSize: Int { get } // (AVAILABLE IN 10.1 TO 10.4) the memory size with which the developer recommends the application be launched
@objc optional var minimumSize: Int { get } // (AVAILABLE IN 10.1 TO 10.4) the smallest memory size with which the application can be launched
@objc optional var preferredSize: Int { get } // (AVAILABLE IN 10.1 TO 10.4) the memory size with which the application will be launched
@objc optional var acceptsHighLevelEvents: Bool { get } // Is the application high-level event aware? (OBSOLETE: always returns true)
@objc optional var hasScriptingTerminology: Bool { get } // Does the process have a scripting terminology, i.e., can it be scripted?
@objc optional var opensInClassic: Bool { get } // (AVAILABLE IN 10.1 TO 10.4) Should the application launch in the Classic environment?
@objc optional func setMinimumSize(_ minimumSize: Int) // (AVAILABLE IN 10.1 TO 10.4) the smallest memory size with which the application can be launched
@objc optional func setPreferredSize(_ preferredSize: Int) // (AVAILABLE IN 10.1 TO 10.4) the memory size with which the application will be launched
@objc optional func setOpensInClassic(_ opensInClassic: Bool) // (AVAILABLE IN 10.1 TO 10.4) Should the application launch in the Classic environment?
@objc optional func id() -> String // the bundle identifier or creator type of the application
}
extension SBObject: FinderApplicationFile {}
// MARK: FinderDocumentFile
@objc
public protocol FinderDocumentFile: FinderFile {}
extension SBObject: FinderDocumentFile {}
// MARK: FinderInternetLocationFile
@objc
public protocol FinderInternetLocationFile: FinderFile {
@objc optional var location: String { get } // the internet location (copy)
}
extension SBObject: FinderInternetLocationFile {}
// MARK: FinderClipping
@objc
public protocol FinderClipping: FinderFile {
@objc optional var clippingWindow: SBObject { get } // (NOT AVAILABLE YET) the clipping window for this clipping (copy)
}
extension SBObject: FinderClipping {}
// MARK: FinderPackage
@objc
public protocol FinderPackage: FinderItem {}
extension SBObject: FinderPackage {}
// MARK: FinderWindow
@objc
public protocol FinderWindow: SBObjectProtocol, FinderGenericMethods {
@objc optional var position: NSPoint { get } // the upper left position of the window
@objc optional var bounds: NSRect { get } // the boundary rectangle for the window
@objc optional var titled: Bool { get } // Does the window have a title bar?
@objc optional var name: String { get } // the name of the window (copy)
@objc optional var index: Int { get } // the number of the window in the front-to-back layer ordering
@objc optional var closeable: Bool { get } // Does the window have a close box?
@objc optional var floating: Bool { get } // Does the window have a title bar?
@objc optional var modal: Bool { get } // Is the window modal?
@objc optional var resizable: Bool { get } // Is the window resizable?
@objc optional var zoomable: Bool { get } // Is the window zoomable?
@objc optional var zoomed: Bool { get } // Is the window zoomed?
@objc optional var visible: Bool { get } // Is the window visible (always true for open Finder windows)?
@objc optional var collapsed: Bool { get } // Is the window collapsed
@objc optional var properties: [AnyHashable: Any] { get } // every property of a window (copy)
@objc optional func setPosition(_ position: NSPoint) // the upper left position of the window
@objc optional func setBounds(_ bounds: NSRect) // the boundary rectangle for the window
@objc optional func setIndex(_ index: Int) // the number of the window in the front-to-back layer ordering
@objc optional func setZoomed(_ zoomed: Bool) // Is the window zoomed?
@objc optional func setCollapsed(_ collapsed: Bool) // Is the window collapsed
@objc optional func setProperties(_ properties: [AnyHashable: Any]!) // every property of a window
@objc optional func id() -> Int // the unique id for this window
}
extension SBObject: FinderWindow {}
// MARK: FinderFinderWindow
@objc
public protocol FinderFinderWindow: FinderWindow {
@objc optional var target: SBObject { get } // the container at which this file viewer is targeted (copy)
@objc optional var currentView: FinderEcvw { get } // the current view for the container window
@objc optional var iconViewOptions: FinderIconViewOptions { get } // the icon view options for the container window (copy)
@objc optional var listViewOptions: FinderListViewOptions { get } // the list view options for the container window (copy)
@objc optional var columnViewOptions: FinderColumnViewOptions { get } // the column view options for the container window (copy)
@objc optional var toolbarVisible: Bool { get } // Is the window's toolbar visible?
@objc optional var statusbarVisible: Bool { get } // Is the window's status bar visible?
@objc optional var sidebarWidth: Int { get } // the width of the sidebar for the container window
@objc optional func setTarget(_ target: SBObject!) // the container at which this file viewer is targeted
@objc optional func setCurrentView(_ currentView: FinderEcvw) // the current view for the container window
@objc optional func setToolbarVisible(_ toolbarVisible: Bool) // Is the window's toolbar visible?
@objc optional func setStatusbarVisible(_ statusbarVisible: Bool) // Is the window's status bar visible?
@objc optional func setSidebarWidth(_ sidebarWidth: Int) // the width of the sidebar for the container window
}
extension SBObject: FinderFinderWindow {}
// MARK: FinderDesktopWindow
@objc
public protocol FinderDesktopWindow: FinderFinderWindow {}
extension SBObject: FinderDesktopWindow {}
// MARK: FinderInformationWindow
@objc
public protocol FinderInformationWindow: FinderWindow {
@objc optional var item: SBObject { get } // the item from which this window was opened (copy)
@objc optional var currentPanel: FinderIpnl { get } // the current panel in the information window
@objc optional func setCurrentPanel(_ currentPanel: FinderIpnl) // the current panel in the information window
}
extension SBObject: FinderInformationWindow {}
// MARK: FinderPreferencesWindow
@objc
public protocol FinderPreferencesWindow: FinderWindow {
@objc optional var currentPanel: FinderPple { get } // The current panel in the Finder preferences window
@objc optional func setCurrentPanel(_ currentPanel: FinderPple) // The current panel in the Finder preferences window
}
extension SBObject: FinderPreferencesWindow {}
// MARK: FinderClippingWindow
@objc
public protocol FinderClippingWindow: FinderWindow {}
extension SBObject: FinderClippingWindow {}
// MARK: FinderProcess
@objc
public protocol FinderProcess: SBObjectProtocol, FinderGenericMethods {
@objc optional var name: String { get } // the name of the process (copy)
@objc optional var visible: Bool { get } // Is the process' layer visible?
@objc optional var frontmost: Bool { get } // Is the process the frontmost process?
@objc optional var file: SBObject { get } // the file from which the process was launched (copy)
@objc optional var fileType: NSNumber { get } // the OSType of the file type of the process (copy)
@objc optional var creatorType: NSNumber { get } // the OSType of the creator of the process (the signature) (copy)
@objc optional var acceptsHighLevelEvents: Bool { get } // Is the process high-level event aware (accepts open application, open document, print document, and quit)?
@objc optional var acceptsRemoteEvents: Bool { get } // Does the process accept remote events?
@objc optional var hasScriptingTerminology: Bool { get } // Does the process have a scripting terminology, i.e., can it be scripted?
@objc optional var totalPartitionSize: Int { get } // the size of the partition with which the process was launched
@objc optional var partitionSpaceUsed: Int { get } // the number of bytes currently used in the process' partition
@objc optional func setVisible(_ visible: Bool) // Is the process' layer visible?
@objc optional func setFrontmost(_ frontmost: Bool) // Is the process the frontmost process?
}
extension SBObject: FinderProcess {}
// MARK: FinderApplicationProcess
@objc
public protocol FinderApplicationProcess: FinderProcess {
@objc optional var applicationFile: FinderApplicationFile { get } // the application file from which this process was launched (copy)
}
extension SBObject: FinderApplicationProcess {}
// MARK: FinderDeskAccessoryProcess
@objc
public protocol FinderDeskAccessoryProcess: FinderProcess {
@objc optional var deskAccessoryFile: SBObject { get } // the desk accessory file from which this process was launched (copy)
}
extension SBObject: FinderDeskAccessoryProcess {}
// MARK: FinderPreferences
@objc
public protocol FinderPreferences: SBObjectProtocol, FinderGenericMethods {
@objc optional var window: FinderPreferencesWindow { get } // the window that would open if Finder preferences was opened (copy)
@objc optional var iconViewOptions: FinderIconViewOptions { get } // the default icon view options (copy)
@objc optional var listViewOptions: FinderListViewOptions { get } // the default list view options (copy)
@objc optional var columnViewOptions: FinderColumnViewOptions { get } // the column view options for all windows (copy)
@objc optional var foldersSpringOpen: Bool { get } // Spring open folders after the specified delay?
@objc optional var delayBeforeSpringing: Double { get } // the delay before springing open a container in seconds (from 0.167 to 1.169)
@objc optional var desktopShowsHardDisks: Bool { get } // Hard disks appear on the desktop?
@objc optional var desktopShowsExternalHardDisks: Bool { get } // External hard disks appear on the desktop?
@objc optional var desktopShowsRemovableMedia: Bool { get } // CDs, DVDs, and iPods appear on the desktop?
@objc optional var desktopShowsConnectedServers: Bool { get } // Connected servers appear on the desktop?
@objc optional var newWindowTarget: SBObject { get } // target location for a newly-opened Finder window (copy)
@objc optional var foldersOpenInNewWindows: Bool { get } // Folders open into new windows?
@objc optional var foldersOpenInNewTabs: Bool { get } // Folders open into new tabs?
@objc optional var newWindowsOpenInColumnView: Bool { get } // Open new windows in column view?
@objc optional var allNameExtensionsShowing: Bool { get } // Show name extensions, even for items whose extension hidden is true?
@objc optional func setFoldersSpringOpen(_ foldersSpringOpen: Bool) // Spring open folders after the specified delay?
@objc optional func setDelayBeforeSpringing(_ delayBeforeSpringing: Double) // the delay before springing open a container in seconds (from 0.167 to 1.169)
@objc optional func setDesktopShowsHardDisks(_ desktopShowsHardDisks: Bool) // Hard disks appear on the desktop?
@objc optional func setDesktopShowsExternalHardDisks(_ desktopShowsExternalHardDisks: Bool) // External hard disks appear on the desktop?
@objc optional func setDesktopShowsRemovableMedia(_ desktopShowsRemovableMedia: Bool) // CDs, DVDs, and iPods appear on the desktop?
@objc optional func setDesktopShowsConnectedServers(_ desktopShowsConnectedServers: Bool) // Connected servers appear on the desktop?
@objc optional func setNewWindowTarget(_ newWindowTarget: SBObject!) // target location for a newly-opened Finder window
@objc optional func setFoldersOpenInNewWindows(_ foldersOpenInNewWindows: Bool) // Folders open into new windows?
@objc optional func setFoldersOpenInNewTabs(_ foldersOpenInNewTabs: Bool) // Folders open into new tabs?
@objc optional func setNewWindowsOpenInColumnView(_ newWindowsOpenInColumnView: Bool) // Open new windows in column view?
@objc optional func setAllNameExtensionsShowing(_ allNameExtensionsShowing: Bool) // Show name extensions, even for items whose extension hidden is true?
}
extension SBObject: FinderPreferences {}
// MARK: FinderLabel
@objc
public protocol FinderLabel: SBObjectProtocol, FinderGenericMethods {
@objc optional var name: String { get } // the name associated with the label (copy)
@objc optional var index: Int { get } // the index in the front-to-back ordering within its container
@objc optional var color: NSColor { get } // the color associated with the label (copy)
@objc optional func setName(_ name: String!) // the name associated with the label
@objc optional func setIndex(_ index: Int) // the index in the front-to-back ordering within its container
@objc optional func setColor(_ color: NSColor!) // the color associated with the label
}
extension SBObject: FinderLabel {}
// MARK: FinderIconFamily
@objc
public protocol FinderIconFamily: SBObjectProtocol, FinderGenericMethods {
@objc optional var largeMonochromeIconAndMask: Any { get } // the large black-and-white icon and the mask for large icons (copy)
@objc optional var large8BitMask: Any { get } // the large 8-bit mask for large 32-bit icons (copy)
@objc optional var large32BitIcon: Any { get } // the large 32-bit color icon (copy)
@objc optional var large8BitIcon: Any { get } // the large 8-bit color icon (copy)
@objc optional var large4BitIcon: Any { get } // the large 4-bit color icon (copy)
@objc optional var smallMonochromeIconAndMask: Any { get } // the small black-and-white icon and the mask for small icons (copy)
@objc optional var small8BitMask: Any { get } // the small 8-bit mask for small 32-bit icons (copy)
@objc optional var small32BitIcon: Any { get } // the small 32-bit color icon (copy)
@objc optional var small8BitIcon: Any { get } // the small 8-bit color icon (copy)
@objc optional var small4BitIcon: Any { get } // the small 4-bit color icon (copy)
}
extension SBObject: FinderIconFamily {}
// MARK: FinderIconViewOptions
@objc
public protocol FinderIconViewOptions: SBObjectProtocol, FinderGenericMethods {
@objc optional var arrangement: FinderEarr { get } // the property by which to keep icons arranged
@objc optional var iconSize: Int { get } // the size of icons displayed in the icon view
@objc optional var showsItemInfo: Bool { get } // additional info about an item displayed in icon view
@objc optional var showsIconPreview: Bool { get } // displays a preview of the item in icon view
@objc optional var textSize: Int { get } // the size of the text displayed in the icon view
@objc optional var labelPosition: FinderEpos { get } // the location of the label in reference to the icon
@objc optional var backgroundPicture: FinderFile { get } // the background picture of the icon view (copy)
@objc optional var backgroundColor: NSColor { get } // the background color of the icon view (copy)
@objc optional func setArrangement(_ arrangement: FinderEarr) // the property by which to keep icons arranged
@objc optional func setIconSize(_ iconSize: Int) // the size of icons displayed in the icon view
@objc optional func setShowsItemInfo(_ showsItemInfo: Bool) // additional info about an item displayed in icon view
@objc optional func setShowsIconPreview(_ showsIconPreview: Bool) // displays a preview of the item in icon view
@objc optional func setTextSize(_ textSize: Int) // the size of the text displayed in the icon view
@objc optional func setLabelPosition(_ labelPosition: FinderEpos) // the location of the label in reference to the icon
@objc optional func setBackgroundPicture(_ backgroundPicture: FinderFile!) // the background picture of the icon view
@objc optional func setBackgroundColor(_ backgroundColor: NSColor!) // the background color of the icon view
}
extension SBObject: FinderIconViewOptions {}
// MARK: FinderColumnViewOptions
@objc
public protocol FinderColumnViewOptions: SBObjectProtocol, FinderGenericMethods {
@objc optional var textSize: Int { get } // the size of the text displayed in the column view
@objc optional var showsIcon: Bool { get } // displays an icon next to the label in column view
@objc optional var showsIconPreview: Bool { get } // displays a preview of the item in column view
@objc optional var showsPreviewColumn: Bool { get } // displays the preview column in column view
@objc optional var disclosesPreviewPane: Bool { get } // discloses the preview pane of the preview column in column view
@objc optional func setTextSize(_ textSize: Int) // the size of the text displayed in the column view
@objc optional func setShowsIcon(_ showsIcon: Bool) // displays an icon next to the label in column view
@objc optional func setShowsIconPreview(_ showsIconPreview: Bool) // displays a preview of the item in column view
@objc optional func setShowsPreviewColumn(_ showsPreviewColumn: Bool) // displays the preview column in column view
@objc optional func setDisclosesPreviewPane(_ disclosesPreviewPane: Bool) // discloses the preview pane of the preview column in column view
}
extension SBObject: FinderColumnViewOptions {}
// MARK: FinderListViewOptions
@objc
public protocol FinderListViewOptions: SBObjectProtocol, FinderGenericMethods {
@objc optional var calculatesFolderSizes: Bool { get } // Are folder sizes calculated and displayed in the window?
@objc optional var showsIconPreview: Bool { get } // displays a preview of the item in list view
@objc optional var iconSize: FinderLvic { get } // the size of icons displayed in the list view
@objc optional var textSize: Int { get } // the size of the text displayed in the list view
@objc optional var sortColumn: FinderColumn { get } // the column that the list view is sorted on (copy)
@objc optional var usesRelativeDates: Bool { get } // Are relative dates (e.g., today, yesterday) shown in the list view?
@objc optional func setCalculatesFolderSizes(_ calculatesFolderSizes: Bool) // Are folder sizes calculated and displayed in the window?
@objc optional func setShowsIconPreview(_ showsIconPreview: Bool) // displays a preview of the item in list view
@objc optional func setIconSize(_ iconSize: FinderLvic) // the size of icons displayed in the list view
@objc optional func setTextSize(_ textSize: Int) // the size of the text displayed in the list view
@objc optional func setSortColumn(_ sortColumn: FinderColumn!) // the column that the list view is sorted on
@objc optional func setUsesRelativeDates(_ usesRelativeDates: Bool) // Are relative dates (e.g., today, yesterday) shown in the list view?
@objc optional func columns() -> SBElementArray
}
extension SBObject: FinderListViewOptions {}
// MARK: FinderColumn
@objc
public protocol FinderColumn: SBObjectProtocol, FinderGenericMethods {
@objc optional var index: Int { get } // the index in the front-to-back ordering within its container
@objc optional var name: FinderElsv { get } // the column name
@objc optional var sortDirection: FinderSodr { get } // The direction in which the window is sorted
@objc optional var width: Int { get } // the width of this column
@objc optional var minimumWidth: Int { get } // the minimum allowed width of this column
@objc optional var maximumWidth: Int { get } // the maximum allowed width of this column
@objc optional var visible: Bool { get } // is this column visible
@objc optional func setIndex(_ index: Int) // the index in the front-to-back ordering within its container
@objc optional func setSortDirection(_ sortDirection: FinderSodr) // The direction in which the window is sorted
@objc optional func setWidth(_ width: Int) // the width of this column
@objc optional func setVisible(_ visible: Bool) // is this column visible
}
extension SBObject: FinderColumn {}
// MARK: FinderAliasList
@objc
public protocol FinderAliasList: SBObjectProtocol, FinderGenericMethods {}
extension SBObject: FinderAliasList {}

View file

@ -1,6 +1,6 @@
// //
// MasStoreSearch.swift // ITunesSearchAppStoreSearcher.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 12/29/18. // Created by Ben Chatelain on 12/29/18.
// Copyright © 2018 mas-cli. All rights reserved. // Copyright © 2018 mas-cli. All rights reserved.
@ -12,14 +12,14 @@ import Regex
import Version import Version
/// Manages searching the MAS catalog through the iTunes Search and Lookup APIs. /// Manages searching the MAS catalog through the iTunes Search and Lookup APIs.
class MasStoreSearch: StoreSearch { class ITunesSearchAppStoreSearcher: AppStoreSearcher {
private static let appVersionExpression = Regex(#"\"versionDisplay\"\:\"([^\"]+)\""#) private static let appVersionExpression = Regex(#"\"versionDisplay\"\:\"([^\"]+)\""#)
// CommerceKit and StoreFoundation don't seem to expose the region of the Apple ID signed // CommerceKit and StoreFoundation don't seem to expose the region of the Apple ID signed
// into the App Store. Instead, we'll make an educated guess that it matches the currently // into the App Store. Instead, we'll make an educated guess that it matches the currently
// selected locale in macOS. This obviously isn't always going to match, but it's probably // selected locale in macOS. This obviously isn't always going to match, but it's probably
// better than passing no "country" at all to the iTunes Search API. // better than passing no "country" at all to the iTunes Search API.
// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/ // https://performance-partners.apple.com/search-api
private let country: String? private let country: String?
private let networkManager: NetworkManager private let networkManager: NetworkManager
@ -34,56 +34,59 @@ class MasStoreSearch: StoreSearch {
/// Searches for an app. /// Searches for an app.
/// ///
/// - Parameter appName: MAS ID of app /// - Parameter searchTerm: a search term matched against app names
/// - Parameter completion: A closure that receives the search results or an Error if there is a /// - Returns: A Promise of an Array of SearchResults matching searchTerm
/// problem with the network request. Results array will be empty if there were no matches. func search(for searchTerm: String) -> Promise<[SearchResult]> {
func search(for appName: String) -> Promise<[SearchResult]> {
// Search for apps for compatible platforms, in order of preference. // Search for apps for compatible platforms, in order of preference.
// Macs with Apple Silicon can run iPad and iPhone apps. // Macs with Apple Silicon can run iPad and iPhone apps.
var entities = [Entity.macSoftware] var entities = [Entity.desktopSoftware]
if SysCtlSystemCommand.isAppleSilicon { if SysCtlSystemCommand.isAppleSilicon {
entities += [.iPadSoftware, .iPhoneSoftware] entities += [.iPadSoftware, .iPhoneSoftware]
} }
let results = entities.map { entity -> Promise<[SearchResult]> in let results = entities.map { entity in
guard let url = searchURL(for: appName, inCountry: country, ofEntity: entity) else { guard let url = searchURL(for: searchTerm, inCountry: country, ofEntity: entity) else {
fatalError("Failed to build URL for \(appName)") fatalError("Failed to build URL for \(searchTerm)")
} }
return loadSearchResults(url) return loadSearchResults(url)
} }
// Combine the results, removing any duplicates. // Combine the results, removing any duplicates.
var seenAppIDs = Set<Int>() var seenAppIDs = Set<AppID>()
return when(fulfilled: results).flatMapValues { $0 }.filterValues { result in return when(fulfilled: results)
seenAppIDs.insert(result.trackId).inserted .flatMapValues { $0 }
} .filterValues { result in
seenAppIDs.insert(result.trackId).inserted
}
} }
/// Looks up app details. /// Looks up app details.
/// ///
/// - Parameter appId: MAS ID of app /// - Parameter appID: MAS ID of app
/// - Returns: A Promise for the search result record of app, or nil if no apps match the ID, /// - Returns: A Promise for the search result record of app, or nil if no apps match the ID,
/// or an Error if there is a problem with the network request. /// or an Error if there is a problem with the network request.
func lookup(app appId: Int) -> Promise<SearchResult?> { func lookup(appID: AppID) -> Promise<SearchResult?> {
guard let url = lookupURL(forApp: appId, inCountry: country) else { guard let url = lookupURL(forAppID: appID, inCountry: country) else {
fatalError("Failed to build URL for \(appId)") fatalError("Failed to build URL for \(appID)")
} }
return firstly { return firstly {
loadSearchResults(url) loadSearchResults(url)
}.then { results -> Guarantee<SearchResult?> in }
.then { results -> Guarantee<SearchResult?> in
guard let result = results.first else { guard let result = results.first else {
return .value(nil) return .value(nil)
} }
guard let pageUrl = URL(string: result.trackViewUrl) guard let pageURL = URL(string: result.trackViewUrl) else {
else {
return .value(result) return .value(result)
} }
return firstly { return firstly {
self.scrapeAppStoreVersion(pageUrl) self.scrapeAppStoreVersion(pageURL)
}.map { pageVersion in }
guard let pageVersion, .map { pageVersion in
guard
let pageVersion,
let searchVersion = Version(tolerant: result.version), let searchVersion = Version(tolerant: result.version),
pageVersion > searchVersion pageVersion > searchVersion
else { else {
@ -94,7 +97,8 @@ class MasStoreSearch: StoreSearch {
var result = result var result = result
result.version = pageVersion.description result.version = pageVersion.description
return result return result
}.recover { _ in }
.recover { _ in
// If we were unable to scrape the App Store page, assume compatibility. // If we were unable to scrape the App Store page, assume compatibility.
.value(result) .value(result)
} }
@ -104,26 +108,27 @@ class MasStoreSearch: StoreSearch {
private func loadSearchResults(_ url: URL) -> Promise<[SearchResult]> { private func loadSearchResults(_ url: URL) -> Promise<[SearchResult]> {
firstly { firstly {
networkManager.loadData(from: url) networkManager.loadData(from: url)
}.map { data -> [SearchResult] in }
.map { data in
do { do {
return try JSONDecoder().decode(SearchResultList.self, from: data).results return try JSONDecoder().decode(SearchResultList.self, from: data).results
} catch { } catch {
throw MASError.jsonParsing(error: error as NSError) throw MASError.jsonParsing(data: data)
} }
} }
} }
// App Store pages indicate: /// Scrape the app version from the App Store webpage at the given URL.
// - compatibility with Macs with Apple Silicon ///
// - (often) a version that is newer than what is listed in search results /// App Store webpages frequently report a version that is newer than what is reported by the iTunes Search API.
// private func scrapeAppStoreVersion(_ pageURL: URL) -> Promise<Version?> {
// We attempt to scrape this information here.
private func scrapeAppStoreVersion(_ pageUrl: URL) -> Promise<Version?> {
firstly { firstly {
networkManager.loadData(from: pageUrl) networkManager.loadData(from: pageURL)
}.map { data in }
let html = String(decoding: data, as: UTF8.self) .map { data in
guard let capture = MasStoreSearch.appVersionExpression.firstMatch(in: html)?.captures[0], guard
let html = String(data: data, encoding: .utf8),
let capture = Self.appVersionExpression.firstMatch(in: html)?.captures[0],
let version = Version(tolerant: capture) let version = Version(tolerant: capture)
else { else {
return nil return nil

View file

@ -1,12 +1,12 @@
// //
// SoftwareMap.swift // SoftwareMap.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 3/1/20. // Created by Ben Chatelain on 3/1/20.
// Copyright © 2020 mas-cli. All rights reserved. // Copyright © 2020 mas-cli. All rights reserved.
// //
/// Somewhat analygous to CKSoftwareMap /// Somewhat analogous to CKSoftwareMap.
protocol SoftwareMap { protocol SoftwareMap {
func allSoftwareProducts() -> [SoftwareProduct] func allSoftwareProducts() -> [SoftwareProduct]
func product(for bundleIdentifier: String) -> SoftwareProduct? func product(for bundleIdentifier: String) -> SoftwareProduct?

View file

@ -0,0 +1,159 @@
//
// SoftwareMapAppLibrary.swift
// mas
//
// Created by Ben Chatelain on 12/27/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
import CommerceKit
import ScriptingBridge
/// Utility for managing installed apps.
class SoftwareMapAppLibrary: AppLibrary {
/// CommerceKit's singleton manager of installed software.
private let softwareMap: SoftwareMap
/// Array of installed software products.
lazy var installedApps: [SoftwareProduct] = softwareMap.allSoftwareProducts()
.filter { product in
product.bundlePath.starts(with: "/Applications/")
}
/// Internal initializer for providing a mock software map.
/// - Parameter softwareMap: SoftwareMap to use
init(softwareMap: SoftwareMap = CKSoftwareMap.shared()) {
self.softwareMap = softwareMap
}
/// Finds an app using a bundle identifier.
///
/// - Parameter bundleID: Bundle identifier of app.
/// - Returns: `SoftwareProduct` for app if found; `nil` otherwise.
func installedApp(forBundleID bundleID: String) -> SoftwareProduct? {
softwareMap.product(for: bundleID)
}
/// Uninstalls all apps located at any of the elements of `appPaths`.
///
/// - Parameter appPaths: Paths to apps to be uninstalled.
/// - Throws: Error if any problem occurs.
func uninstallApps(atPaths appPaths: [String]) throws {
try delete(pathsFromOwnerIDsByPath: try chown(paths: appPaths))
}
}
func getSudoUsername() -> String? {
ProcessInfo.processInfo.environment["SUDO_USER"]
}
func getSudoUID() -> uid_t? {
guard let uid = ProcessInfo.processInfo.environment["SUDO_UID"] else {
return nil
}
return uid_t(uid)
}
func getSudoGID() -> gid_t? {
guard let gid = ProcessInfo.processInfo.environment["SUDO_GID"] else {
return nil
}
return gid_t(gid)
}
private func getOwnerAndGroupOfItem(atPath path: String) throws -> (uid_t, gid_t) {
do {
let attributes = try FileManager.default.attributesOfItem(atPath: path)
guard
let uid = attributes[.ownerAccountID] as? uid_t,
let gid = attributes[.groupOwnerAccountID] as? gid_t
else {
throw MASError.runtimeError("Failed to determine running user's uid & gid")
}
return (uid, gid)
}
}
private func chown(paths: [String]) throws -> [String: (uid_t, gid_t)] {
guard let sudoUID = getSudoUID() else {
throw MASError.runtimeError("Failed to get original uid")
}
guard let sudoGID = getSudoGID() else {
throw MASError.runtimeError("Failed to get original gid")
}
let ownerIDsByPath = try paths.reduce(into: [String: (uid_t, gid_t)]()) { dict, path in
dict[path] = try getOwnerAndGroupOfItem(atPath: path)
}
var chownedIDsByPath: [String: (uid_t, gid_t)] = [:]
for (path, ownerIDs) in ownerIDsByPath {
guard chown(path, sudoUID, sudoGID) == 0 else {
for (chownedPath, chownedIDs) in chownedIDsByPath
where chown(chownedPath, chownedIDs.0, chownedIDs.1) != 0 {
printError("Failed to revert ownership of '\(path)' back to uid \(chownedIDs.0) & gid \(chownedIDs.1)")
}
throw MASError.runtimeError("Failed to change ownership of '\(path)' to uid \(sudoUID) & gid \(sudoGID)")
}
chownedIDsByPath[path] = ownerIDs
}
return ownerIDsByPath
}
private func delete(pathsFromOwnerIDsByPath ownerIDsByPath: [String: (uid_t, gid_t)]) throws {
guard let finder: FinderApplication = SBApplication(bundleIdentifier: "com.apple.finder") else {
throw MASError.runtimeError("Failed to obtain Finder access: com.apple.finder does not exist")
}
guard let items = finder.items else {
throw MASError.runtimeError("Failed to obtain Finder access: finder.items does not exist")
}
for (path, ownerIDs) in ownerIDsByPath {
let object = items().object(atLocation: URL(fileURLWithPath: path))
guard let item = object as? FinderItem else {
throw MASError.runtimeError(
"""
Failed to obtain Finder access: finder.items().object(atLocation: URL(fileURLWithPath: \
\"\(path)\") is a '\(type(of: object))' that does not conform to 'FinderItem'
"""
)
}
guard let delete = item.delete else {
throw MASError.runtimeError("Failed to obtain Finder access: FinderItem.delete does not exist")
}
let uid = ownerIDs.0
let gid = ownerIDs.1
guard let deletedURLString = (delete() as FinderItem).URL else {
throw MASError.runtimeError(
"""
Failed to revert ownership of deleted '\(path)' back to uid \(uid) & gid \(gid): \
delete result did not have a URL
"""
)
}
guard let deletedURL = URL(string: deletedURLString) else {
throw MASError.runtimeError(
"""
Failed to revert ownership of deleted '\(path)' back to uid \(uid) & gid \(gid): \
delete result URL is invalid: \(deletedURLString)
"""
)
}
let deletedPath = deletedURL.path
print("Deleted '\(path)' to '\(deletedPath)'")
guard chown(deletedPath, uid, gid) == 0 else {
throw MASError.runtimeError(
"Failed to revert ownership of deleted '\(deletedPath)' back to uid \(uid) & gid \(gid)"
)
}
}
}

View file

@ -1,6 +1,6 @@
// //
// MASError.swift // MASError.swift
// mas-cli // mas
// //
// Created by Andrew Naylor on 21/08/2015. // Created by Andrew Naylor on 21/08/2015.
// Copyright (c) 2015 Andrew Naylor. All rights reserved. // Copyright (c) 2015 Andrew Naylor. All rights reserved.
@ -8,12 +8,17 @@
import Foundation import Foundation
public enum MASError: Error, Equatable { enum MASError: Error, Equatable {
case notSupported case notSupported
case failed(error: NSError?)
case runtimeError(String)
case notSignedIn case notSignedIn
case noPasswordProvided
case signInFailed(error: NSError?) case signInFailed(error: NSError?)
case alreadySignedIn case alreadySignedIn(asAppleID: String)
case purchaseFailed(error: NSError?) case purchaseFailed(error: NSError?)
case downloadFailed(error: NSError?) case downloadFailed(error: NSError?)
@ -24,77 +29,81 @@ public enum MASError: Error, Equatable {
case noSearchResultsFound case noSearchResultsFound
case noVendorWebsite case noVendorWebsite
case notInstalled case notInstalled(appID: AppID)
case uninstallFailed case uninstallFailed(error: NSError?)
case macOSUserMustBeRoot
case noData case noData
case jsonParsing(error: NSError?) case jsonParsing(data: Data?)
} }
// MARK: - CustomStringConvertible // MARK: - CustomStringConvertible
extension MASError: CustomStringConvertible { extension MASError: CustomStringConvertible {
public var description: String { var description: String {
switch self { switch self {
case .notSignedIn: case .notSignedIn:
return "Not signed in" return "Not signed in"
case .noPasswordProvided:
return "No password provided"
case .notSupported: case .notSupported:
return """ return """
This command is not supported on this macOS version due to changes in macOS. \ This command is not supported on this macOS version due to changes in macOS. \
For more information see: \ For more information see: \
https://github.com/mas-cli/mas#%EF%B8%8F-known-issues https://github.com/mas-cli/mas#known-issues
""" """
case .failed(let error):
if let error {
return "Failed: \(error.localizedDescription)"
}
return "Failed"
case .runtimeError(let message):
return "Runtime Error: \(message)"
case .signInFailed(let error): case .signInFailed(let error):
if let error { if let error {
return "Sign in failed: \(error.localizedDescription)" return "Sign in failed: \(error.localizedDescription)"
} else {
return "Sign in failed"
} }
return "Sign in failed"
case .alreadySignedIn: case .alreadySignedIn(let appleID):
return "Already signed in" return "Already signed in as \(appleID)"
case .purchaseFailed(let error): case .purchaseFailed(let error):
if let error { if let error {
return "Download request failed: \(error.localizedDescription)" return "Download request failed: \(error.localizedDescription)"
} else {
return "Download request failed"
} }
return "Download request failed"
case .downloadFailed(let error): case .downloadFailed(let error):
if let error { if let error {
return "Download failed: \(error.localizedDescription)" return "Download failed: \(error.localizedDescription)"
} else {
return "Download failed"
} }
return "Download failed"
case .noDownloads: case .noDownloads:
return "No downloads began" return "No downloads began"
case .cancelled: case .cancelled:
return "Download cancelled" return "Download cancelled"
case .searchFailed: case .searchFailed:
return "Search failed" return "Search failed"
case .noSearchResultsFound: case .noSearchResultsFound:
return "No results found" return "No results found"
case .noVendorWebsite: case .noVendorWebsite:
return "App does not have a vendor website" return "App does not have a vendor website"
case .notInstalled(let appID):
case .notInstalled: return "No apps installed with app ID \(appID)"
return "Not installed" case .uninstallFailed(let error):
if let error {
case .uninstallFailed: return "Uninstall failed: \(error.localizedDescription)"
}
return "Uninstall failed" return "Uninstall failed"
case .macOSUserMustBeRoot:
return "Apps installed from the Mac App Store require root permission to remove."
case .noData: case .noData:
return "Service did not return data" return "Service did not return data"
case .jsonParsing(let data):
case .jsonParsing: if let data {
return "Unable to parse response JSON" if let unparsable = String(data: data, encoding: .utf8) {
return "Unable to parse response as JSON: \n\(unparsable)"
}
return "Received defective response"
}
return "Received empty response"
} }
} }
} }

View file

@ -1,6 +1,6 @@
// //
// ExternalCommand.swift // ExternalCommand.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 1/1/19. // Created by Ben Chatelain on 1/1/19.
// Copyright © 2019 mas-cli. All rights reserved. // Copyright © 2019 mas-cli. All rights reserved.
@ -8,7 +8,7 @@
import Foundation import Foundation
/// CLI command /// Represents a CLI command.
protocol ExternalCommand { protocol ExternalCommand {
var binaryPath: String { get set } var binaryPath: String { get set }

View file

@ -1,6 +1,6 @@
// //
// OpenSystemCommand.swift // OpenSystemCommand.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 1/2/19. // Created by Ben Chatelain on 1/2/19.
// Copyright © 2019 mas-cli. All rights reserved. // Copyright © 2019 mas-cli. All rights reserved.
@ -8,8 +8,7 @@
import Foundation import Foundation
/// Wrapper for the external open system command. /// Wrapper for the external 'open' system command (https://ss64.com/osx/open.html).
/// https://ss64.com/osx/open.html
struct OpenSystemCommand: ExternalCommand { struct OpenSystemCommand: ExternalCommand {
var binaryPath: String var binaryPath: String

View file

@ -1,6 +1,6 @@
// //
// SysCtlSystemCommand.swift // SysCtlSystemCommand.swift
// MasKit // mas
// //
// Created by Chris Araman on 6/3/21. // Created by Chris Araman on 6/3/21.
// Copyright © 2021 mas-cli. All rights reserved. // Copyright © 2021 mas-cli. All rights reserved.
@ -8,22 +8,12 @@
import Foundation import Foundation
/// Wrapper for the external sysctl system command. /// Wrapper for the external 'sysctl' system command.
/// https://ss64.com/osx/sysctl.html ///
/// See - https://ss64.com/osx/sysctl.html
struct SysCtlSystemCommand: ExternalCommand { struct SysCtlSystemCommand: ExternalCommand {
var binaryPath: String
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
init(binaryPath: String = "/usr/sbin/sysctl") {
self.binaryPath = binaryPath
}
static var isAppleSilicon: Bool = { static var isAppleSilicon: Bool = {
let sysctl = SysCtlSystemCommand() let sysctl = Self()
do { do {
// Returns 1 on Apple Silicon even when run in an Intel context in Rosetta. // Returns 1 on Apple Silicon even when run in an Intel context in Rosetta.
try sysctl.run(arguments: "-in", "hw.optional.arm64") try sysctl.run(arguments: "-in", "hw.optional.arm64")
@ -37,4 +27,15 @@ struct SysCtlSystemCommand: ExternalCommand {
return sysctl.stdout.trimmingCharacters(in: .newlines) == "1" return sysctl.stdout.trimmingCharacters(in: .newlines) == "1"
}() }()
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
var binaryPath: String
init(binaryPath: String = "/usr/sbin/sysctl") {
self.binaryPath = binaryPath
}
} }

View file

@ -1,6 +1,6 @@
// //
// AppInfoFormatter.swift // AppInfoFormatter.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 1/7/19. // Created by Ben Chatelain on 1/7/19.
// Copyright © 2019 mas-cli. All rights reserved. // Copyright © 2019 mas-cli. All rights reserved.
@ -18,7 +18,7 @@ enum AppInfoFormatter {
let headline = [ let headline = [
"\(app.trackName)", "\(app.trackName)",
"\(app.version)", "\(app.version)",
"[\(app.price ?? 0)]", "[\(app.formattedPrice)]",
] ]
.joined(separator: " ") .joined(separator: " ")
@ -27,7 +27,7 @@ enum AppInfoFormatter {
"By: \(app.sellerName)", "By: \(app.sellerName)",
"Released: \(humanReadableDate(app.currentVersionReleaseDate))", "Released: \(humanReadableDate(app.currentVersionReleaseDate))",
"Minimum OS: \(app.minimumOsVersion)", "Minimum OS: \(app.minimumOsVersion)",
"Size: \(humanReadableSize(app.fileSizeBytes ?? "0"))", "Size: \(humanReadableSize(app.fileSizeBytes))",
"From: \(app.trackViewUrl)", "From: \(app.trackViewUrl)",
] ]
.joined(separator: "\n") .joined(separator: "\n")

View file

@ -1,6 +1,6 @@
// //
// AppListFormatter.swift // AppListFormatter.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 6/7/20. // Created by Ben Chatelain on 6/7/20.
// Copyright © 2019 mas-cli. All rights reserved. // Copyright © 2019 mas-cli. All rights reserved.
@ -15,8 +15,8 @@ enum AppListFormatter {
/// Formats text output with list results. /// Formats text output with list results.
/// ///
/// - Parameter products: List of sortware products app data. /// - Parameter products: List of software products app data.
/// - Returns: Multiliune text outoutp. /// - Returns: Multiline text output.
static func format(products: [SoftwareProduct]) -> String { static func format(products: [SoftwareProduct]) -> String {
// find longest appName for formatting, default 50 // find longest appName for formatting, default 50
let maxLength = products.map(\.appNameOrBundleIdentifier.count).max() ?? nameColumnMinWidth let maxLength = products.map(\.appNameOrBundleIdentifier.count).max() ?? nameColumnMinWidth
@ -24,12 +24,12 @@ enum AppListFormatter {
var output = "" var output = ""
for product in products { for product in products {
let appId = product.itemIdentifier.stringValue let appID = product.itemIdentifier.stringValue
.padding(toLength: idColumnMinWidth, withPad: " ", startingAt: 0) .padding(toLength: idColumnMinWidth, withPad: " ", startingAt: 0)
let appName = product.appNameOrBundleIdentifier.padding(toLength: maxLength, withPad: " ", startingAt: 0) let appName = product.appNameOrBundleIdentifier.padding(toLength: maxLength, withPad: " ", startingAt: 0)
let version = product.bundleVersion let version = product.bundleVersion
output += "\(appId) \(appName) (\(version))\n" output += "\(appID) \(appName) (\(version))\n"
} }
return output.trimmingCharacters(in: .newlines) return output.trimmingCharacters(in: .newlines)

View file

@ -1,6 +1,6 @@
// //
// SearchResultFormatter.swift // SearchResultFormatter.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 1/11/19. // Created by Ben Chatelain on 1/11/19.
// Copyright © 2019 mas-cli. All rights reserved. // Copyright © 2019 mas-cli. All rights reserved.
@ -10,25 +10,28 @@ import Foundation
/// Formats text output for the search command. /// Formats text output for the search command.
enum SearchResultFormatter { enum SearchResultFormatter {
/// Formats text output with search results. /// Formats search results as text.
/// ///
/// - Parameter results: Search results with app data /// - Parameters:
/// - Returns: Multiliune text outoutp. /// - results: Search results containing app data
/// - includePrice: Indicates whether to include prices in the output
/// - Returns: Multiline text output.
static func format(results: [SearchResult], includePrice: Bool = false) -> String { static func format(results: [SearchResult], includePrice: Bool = false) -> String {
// find longest appName for formatting, default 50 guard let maxLength = results.map(\.trackName.count).max() else {
let maxLength = results.map(\.trackName.count).max() ?? 50 return ""
}
var output = "" var output = ""
for result in results { for result in results {
let appId = result.trackId let appID = result.trackId
let appName = result.trackName.padding(toLength: maxLength, withPad: " ", startingAt: 0) let appName = result.trackName.padding(toLength: maxLength, withPad: " ", startingAt: 0)
let version = result.version let version = result.version
let price = result.price ?? 0.0
if includePrice { if includePrice {
output += String(format: "%12d %@ $%5.2f (%@)\n", appId, appName, price, version) output += String(format: "%12lu %@ (%@) %@\n", appID, appName, version, result.formattedPrice)
} else { } else {
output += String(format: "%12d %@ (%@)\n", appId, appName, version) output += String(format: "%12lu %@ (%@)\n", appID, appName, version)
} }
} }

View file

@ -0,0 +1,96 @@
//
// Utilities.swift
// mas
//
// Created by Andrew Naylor on 14/09/2016.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
import Foundation
// A collection of output formatting helpers
/// Terminal Control Sequence Indicator.
let csi = "\u{001B}["
private var standardError = FileHandle.standardError
extension FileHandle: TextOutputStream {
/// Appends the given string to the stream.
public func write(_ string: String) {
guard let data = string.data(using: .utf8) else {
return
}
write(data)
}
}
/// Prints a message to stdout prefixed with a blue arrow.
func printInfo(_ message: String) {
guard isatty(fileno(stdout)) != 0 else {
print("==> \(message)")
return
}
// Blue bold arrow, Bold text
print("\(csi)1;34m==>\(csi)0m \(csi)1m\(message)\(csi)0m")
}
/// Prints a message to stderr prefixed with "Warning:" underlined in yellow.
func printWarning(_ message: String) {
guard isatty(fileno(stderr)) != 0 else {
print("Warning: \(message)", to: &standardError)
return
}
// Yellow, underlined "Warning:" prefix
print("\(csi)4;33mWarning:\(csi)0m \(message)", to: &standardError)
}
/// Prints a message to stderr prefixed with "Error:" underlined in red.
func printError(_ message: String) {
guard isatty(fileno(stderr)) != 0 else {
print("Error: \(message)", to: &standardError)
return
}
// Red, underlined "Error:" prefix
print("\(csi)4;31mError:\(csi)0m \(message)", to: &standardError)
}
/// Flushes stdout.
func clearLine() {
guard isatty(fileno(stdout)) != 0 else {
return
}
print("\(csi)2K\(csi)0G", terminator: "")
fflush(stdout)
}
func captureStream(
_ stream: UnsafeMutablePointer<FILE>,
encoding: String.Encoding = .utf8,
_ block: @escaping () throws -> Void
) rethrows -> String {
let originalFd = fileno(stream)
let duplicateFd = dup(originalFd)
defer {
close(duplicateFd)
}
let pipe = Pipe()
dup2(pipe.fileHandleForWriting.fileDescriptor, originalFd)
do {
defer {
fflush(stream)
dup2(duplicateFd, originalFd)
pipe.fileHandleForWriting.closeFile()
}
try block()
}
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: encoding) ?? ""
}

65
Sources/mas/MAS.swift Normal file
View file

@ -0,0 +1,65 @@
//
// MAS.swift
// mas
//
// Created by Chris Araman on 4/22/21.
// Copyright © 2021 mas-cli. All rights reserved.
//
import ArgumentParser
import Foundation
import PromiseKit
@main
struct MAS: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Mac App Store command-line interface",
subcommands: [
Account.self,
Home.self,
Info.self,
Install.self,
List.self,
Lucky.self,
Open.self,
Outdated.self,
Purchase.self,
Reset.self,
Search.self,
SignIn.self,
SignOut.self,
Uninstall.self,
Upgrade.self,
Vendor.self,
Version.self,
]
)
static func initialize() {
PromiseKit.conf.Q.map = .global()
PromiseKit.conf.Q.return = .global()
PromiseKit.conf.logHandler = { event in
switch event {
case .waitOnMainThread:
// Ignored. This is a console app that waits on the main thread for
// promises to be processed on the global DispatchQueue.
break
default:
// Other events indicate a programming error.
fatalError("PromiseKit event: \(event)")
}
}
}
func validate() throws {
Self.initialize()
}
}
typealias AppID = UInt64
extension NSNumber {
var appIDValue: AppID {
uint64Value
}
}

View file

@ -1,6 +1,6 @@
// //
// SearchResult.swift // SearchResult.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 12/29/18. // Created by Ben Chatelain on 12/29/18.
// Copyright © 2018 mas-cli. All rights reserved. // Copyright © 2018 mas-cli. All rights reserved.
@ -9,13 +9,13 @@
struct SearchResult: Decodable { struct SearchResult: Decodable {
var bundleId: String var bundleId: String
var currentVersionReleaseDate: String var currentVersionReleaseDate: String
var fileSizeBytes: String? var fileSizeBytes: String
var kind: String var formattedPrice: String
var minimumOsVersion: String var minimumOsVersion: String
var price: Double? var price: Double
var sellerName: String var sellerName: String
var sellerUrl: String? var sellerUrl: String?
var trackId: Int var trackId: AppID
var trackName: String var trackName: String
var trackViewUrl: String var trackViewUrl: String
var version: String var version: String
@ -24,12 +24,12 @@ struct SearchResult: Decodable {
bundleId: String = "", bundleId: String = "",
currentVersionReleaseDate: String = "", currentVersionReleaseDate: String = "",
fileSizeBytes: String = "0", fileSizeBytes: String = "0",
kind: String = "", formattedPrice: String = "0",
minimumOsVersion: String = "", minimumOsVersion: String = "",
price: Double = 0.0, price: Double = 0.0,
sellerName: String = "", sellerName: String = "",
sellerUrl: String = "", sellerUrl: String = "",
trackId: Int = 0, trackId: AppID = 0,
trackName: String = "", trackName: String = "",
trackViewUrl: String = "", trackViewUrl: String = "",
version: String = "" version: String = ""
@ -37,7 +37,7 @@ struct SearchResult: Decodable {
self.bundleId = bundleId self.bundleId = bundleId
self.currentVersionReleaseDate = currentVersionReleaseDate self.currentVersionReleaseDate = currentVersionReleaseDate
self.fileSizeBytes = fileSizeBytes self.fileSizeBytes = fileSizeBytes
self.kind = kind self.formattedPrice = formattedPrice
self.minimumOsVersion = minimumOsVersion self.minimumOsVersion = minimumOsVersion
self.price = price self.price = price
self.sellerName = sellerName self.sellerName = sellerName

View file

@ -1,6 +1,6 @@
// //
// SearchResultList.swift // SearchResultList.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 12/29/18. // Created by Ben Chatelain on 12/29/18.
// Copyright © 2018 mas-cli. All rights reserved. // Copyright © 2018 mas-cli. All rights reserved.

View file

@ -1,6 +1,6 @@
// //
// SoftwareProduct.swift // SoftwareProduct.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 12/27/18. // Created by Ben Chatelain on 12/27/18.
// Copyright © 2018 mas-cli. All rights reserved. // Copyright © 2018 mas-cli. All rights reserved.
@ -9,7 +9,7 @@
import Foundation import Foundation
import Version import Version
/// Protocol describing the members of CKSoftwareProduct used throughout MasKit. /// Protocol describing the members of CKSoftwareProduct used throughout mas.
protocol SoftwareProduct { protocol SoftwareProduct {
var appName: String { get } var appName: String { get }
var bundleIdentifier: String { get set } var bundleIdentifier: String { get set }
@ -19,24 +19,25 @@ protocol SoftwareProduct {
} }
extension SoftwareProduct { extension SoftwareProduct {
/// Returns bundleIdentifier if appName is empty string. /// - Returns: bundleIdentifier if appName is empty string.
var appNameOrBundleIdentifier: String { var appNameOrBundleIdentifier: String {
appName.isEmpty ? bundleIdentifier : appName appName.isEmpty ? bundleIdentifier : appName
} }
/// Determines whether the app is considered outdated. Updates that require a higher OS version are excluded. /// Determines whether the app is considered outdated.
///
/// Updates that require a higher OS version are excluded.
///
/// - Parameter storeApp: App from search result. /// - Parameter storeApp: App from search result.
/// - Returns: true if the app is outdated; false otherwise. /// - Returns: true if the app is outdated; false otherwise.
func isOutdatedWhenComparedTo(_ storeApp: SearchResult) -> Bool { func isOutdatedWhenComparedTo(_ storeApp: SearchResult) -> Bool {
// Only look at min OS version if we have one, also only consider macOS apps // If storeApp requires a version of macOS newer than the running version, do not consider self outdated.
// Replace string literal with MasStoreSearch.Entity once `search` branch is merged. if let osVersion = Version(tolerant: storeApp.minimumOsVersion) {
if let osVersion = Version(tolerant: storeApp.minimumOsVersion), storeApp.kind == "mac-software" {
let requiredVersion = OperatingSystemVersion( let requiredVersion = OperatingSystemVersion(
majorVersion: osVersion.major, majorVersion: osVersion.major,
minorVersion: osVersion.minor, minorVersion: osVersion.minor,
patchVersion: osVersion.patch patchVersion: osVersion.patch
) )
// Don't consider an app outdated if the version in the app store requires a higher OS version.
guard ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion) else { guard ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion) else {
return false return false
} }
@ -44,7 +45,8 @@ extension SoftwareProduct {
// The App Store does not enforce semantic versioning, but we assume most apps follow versioning // The App Store does not enforce semantic versioning, but we assume most apps follow versioning
// schemes that increase numerically over time. // schemes that increase numerically over time.
guard let semanticBundleVersion = Version(tolerant: bundleVersion), guard
let semanticBundleVersion = Version(tolerant: bundleVersion),
let semanticAppStoreVersion = Version(tolerant: storeApp.version) let semanticAppStoreVersion = Version(tolerant: storeApp.version)
else { else {
// If a version string can't be parsed as a Semantic Version, our best effort is to check for // If a version string can't be parsed as a Semantic Version, our best effort is to check for

View file

@ -1,6 +1,6 @@
// //
// NetworkManager.swift // NetworkManager.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 1/5/19. // Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved. // Copyright © 2019 mas-cli. All rights reserved.
@ -9,11 +9,11 @@
import Foundation import Foundation
import PromiseKit import PromiseKit
/// Network abstraction /// Network abstraction.
class NetworkManager { class NetworkManager {
private let session: NetworkSession private let session: NetworkSession
/// Designated initializer /// Designated initializer.
/// ///
/// - Parameter session: A networking session. /// - Parameter session: A networking session.
init(session: NetworkSession = URLSession(configuration: .ephemeral)) { init(session: NetworkSession = URLSession(configuration: .ephemeral)) {
@ -23,13 +23,14 @@ class NetworkManager {
do { do {
let url = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Caches/com.mphys.mas-cli") let url = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Caches/com.mphys.mas-cli")
try FileManager.default.removeItem(at: url) try FileManager.default.removeItem(at: url)
} catch {} } catch {
// Ignore
}
} }
/// Loads data asynchronously. /// Loads data asynchronously.
/// ///
/// - Parameters: /// - Parameter url: URL from which to load data.
/// - url: URL to load data from.
/// - Returns: A Promise for the Data of the response. /// - Returns: A Promise for the Data of the response.
func loadData(from url: URL) -> Promise<Data> { func loadData(from url: URL) -> Promise<Data> {
session.loadData(from: url) session.loadData(from: url)

View file

@ -1,6 +1,6 @@
// //
// NetworkSession.swift // NetworkSession.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 1/5/19. // Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved. // Copyright © 2019 mas-cli. All rights reserved.

View file

@ -1,6 +1,6 @@
// //
// URLSession+NetworkSession.swift // URLSession+NetworkSession.swift
// MasKit // mas
// //
// Created by Ben Chatelain on 1/5/19. // Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved. // Copyright © 2019 mas-cli. All rights reserved.
@ -10,7 +10,7 @@ import Foundation
import PromiseKit import PromiseKit
extension URLSession: NetworkSession { extension URLSession: NetworkSession {
public func loadData(from url: URL) -> Promise<Data> { func loadData(from url: URL) -> Promise<Data> {
Promise { seal in Promise { seal in
dataTask(with: url) { data, _, error in dataTask(with: url) { data, _, error in
if let data { if let data {

View file

@ -1,38 +0,0 @@
//
// main.swift
// mas-cli
//
// Created by Andrew Naylor on 11/07/2015.
// Copyright © 2015 Andrew Naylor. All rights reserved.
//
import Commandant
import MasKit
MasKit.initialize()
let registry = CommandRegistry<MASError>()
let helpCommand = HelpCommand(registry: registry)
registry.register(AccountCommand())
registry.register(HomeCommand())
registry.register(InfoCommand())
registry.register(InstallCommand())
registry.register(PurchaseCommand())
registry.register(ListCommand())
registry.register(LuckyCommand())
registry.register(OpenCommand())
registry.register(OutdatedCommand())
registry.register(ResetCommand())
registry.register(SearchCommand())
registry.register(SignInCommand())
registry.register(SignOutCommand())
registry.register(UninstallCommand())
registry.register(UpgradeCommand())
registry.register(VendorCommand())
registry.register(VersionCommand())
registry.register(helpCommand)
registry.main(defaultVerb: helpCommand.verb) { error in
printError(String(describing: error))
}

View file

@ -1,29 +0,0 @@
//
// AccountCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2018-12-28.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import MasKit
// Deprecated test
public class AccountCommandSpec: QuickSpec {
override public func spec() {
beforeSuite {
MasKit.initialize()
}
// account command disabled since macOS 12 Monterey https://github.com/mas-cli/mas#%EF%B8%8F-known-issues
xdescribe("Account command") {
xit("displays active account") {
let cmd = AccountCommand()
let result = cmd.run(AccountCommand.Options())
expect(result).to(beSuccess())
}
}
}
}

View file

@ -1,58 +0,0 @@
//
// HomeCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2018-12-29.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import MasKit
public class HomeCommandSpec: QuickSpec {
override public func spec() {
let result = SearchResult(
trackId: 1111,
trackViewUrl: "mas preview url",
version: "0.0"
)
let storeSearch = StoreSearchMock()
let openCommand = OpenSystemCommandMock()
let cmd = HomeCommand(storeSearch: storeSearch, openCommand: openCommand)
beforeSuite {
MasKit.initialize()
}
describe("home command") {
beforeEach {
storeSearch.reset()
}
it("fails to open app with invalid ID") {
let result = cmd.run(HomeCommand.Options(appId: -999))
expect(result)
.to(
beFailure { error in
expect(error) == .searchFailed
})
}
it("can't find app with unknown ID") {
let result = cmd.run(HomeCommand.Options(appId: 999))
expect(result)
.to(
beFailure { error in
expect(error) == .noSearchResultsFound
})
}
it("opens app on MAS Preview") {
storeSearch.apps[result.trackId] = result
let cmdResult = cmd.run(HomeCommand.Options(appId: result.trackId))
expect(cmdResult).to(beSuccess())
expect(openCommand.arguments).toNot(beNil())
expect(openCommand.arguments!.first!) == result.trackViewUrl
}
}
}
}

View file

@ -1,73 +0,0 @@
//
// InfoCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2018-12-28.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import MasKit
public class InfoCommandSpec: QuickSpec {
override public func spec() {
let result = SearchResult(
currentVersionReleaseDate: "2019-01-07T18:53:13Z",
fileSizeBytes: "1024",
minimumOsVersion: "10.14",
price: 2.0,
sellerName: "Awesome Dev",
trackId: 1111,
trackName: "Awesome App",
trackViewUrl: "https://awesome.app",
version: "1.0"
)
let storeSearch = StoreSearchMock()
let cmd = InfoCommand(storeSearch: storeSearch)
let expectedOutput = """
Awesome App 1.0 [2.0]
By: Awesome Dev
Released: 2019-01-07
Minimum OS: 10.14
Size: 1 KB
From: https://awesome.app
"""
beforeSuite {
MasKit.initialize()
}
describe("Info command") {
beforeEach {
storeSearch.reset()
}
it("fails to open app with invalid ID") {
let result = cmd.run(InfoCommand.Options(appId: -999))
expect(result)
.to(
beFailure { error in
expect(error) == .searchFailed
})
}
it("can't find app with unknown ID") {
let result = cmd.run(InfoCommand.Options(appId: 999))
expect(result)
.to(
beFailure { error in
expect(error) == .noSearchResultsFound
})
}
it("displays app details") {
storeSearch.apps[result.trackId] = result
let output = OutputListener()
let result = cmd.run(InfoCommand.Options(appId: result.trackId))
expect(result).to(beSuccess())
expect(output.contents) == expectedOutput
}
}
}
}

View file

@ -1,27 +0,0 @@
//
// InstallCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2018-12-28.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import MasKit
public class InstallCommandSpec: QuickSpec {
override public func spec() {
beforeSuite {
MasKit.initialize()
}
describe("install command") {
it("installs apps") {
let cmd = InstallCommand()
let result = cmd.run(InstallCommand.Options(appIds: [], forceInstall: false))
expect(result).to(beSuccess())
}
}
}
}

View file

@ -1,27 +0,0 @@
//
// ListCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2018-12-27.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import MasKit
public class ListCommandSpec: QuickSpec {
override public func spec() {
beforeSuite {
MasKit.initialize()
}
describe("list command") {
it("lists apps") {
let list = ListCommand()
let result = list.run(ListCommand.Options())
expect(result).to(beSuccess())
}
}
}
}

View file

@ -1,30 +0,0 @@
//
// LuckyCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2018-12-28.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import MasKit
public class LuckyCommandSpec: QuickSpec {
override public func spec() {
let networkSession = NetworkSessionMockFromFile(responseFile: "search/slack.json")
let storeSearch = MasStoreSearch(networkManager: NetworkManager(session: networkSession))
beforeSuite {
MasKit.initialize()
}
describe("lucky command") {
xit("installs the first app matching a search") {
let cmd = LuckyCommand(storeSearch: storeSearch)
let result = cmd.run(LuckyCommand.Options(appName: "Slack", forceInstall: false))
expect(result).to(beSuccess())
}
}
}
}

View file

@ -1,69 +0,0 @@
//
// OpenCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2019-01-03.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
import Nimble
import Quick
@testable import MasKit
public class OpenCommandSpec: QuickSpec {
override public func spec() {
let result = SearchResult(
trackId: 1111,
trackViewUrl: "fakescheme://some/url",
version: "0.0"
)
let storeSearch = StoreSearchMock()
let openCommand = OpenSystemCommandMock()
let cmd = OpenCommand(storeSearch: storeSearch, openCommand: openCommand)
beforeSuite {
MasKit.initialize()
}
describe("open command") {
beforeEach {
storeSearch.reset()
}
it("fails to open app with invalid ID") {
let result = cmd.run(OpenCommand.Options(appId: "-999"))
expect(result)
.to(
beFailure { error in
expect(error) == .searchFailed
})
}
it("can't find app with unknown ID") {
let result = cmd.run(OpenCommand.Options(appId: "999"))
expect(result)
.to(
beFailure { error in
expect(error) == .noSearchResultsFound
})
}
it("opens app in MAS") {
storeSearch.apps[result.trackId] = result
let cmdResult = cmd.run(OpenCommand.Options(appId: result.trackId.description))
expect(cmdResult).to(beSuccess())
expect(openCommand.arguments).toNot(beNil())
let url = URL(string: openCommand.arguments!.first!)
expect(url).toNot(beNil())
expect(url?.scheme) == "macappstore"
}
it("just opens MAS if no app specified") {
let cmdResult = cmd.run(OpenCommand.Options(appId: "appstore"))
expect(cmdResult).to(beSuccess())
expect(openCommand.arguments).toNot(beNil())
let url = URL(string: openCommand.arguments!.first!)
expect(url).toNot(beNil())
expect(url) == URL(string: "macappstore://")
}
}
}
}

View file

@ -1,28 +0,0 @@
//
// OutdatedCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2018-12-28.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import MasKit
public class OutdatedCommandSpec: QuickSpec {
override public func spec() {
beforeSuite {
MasKit.initialize()
}
describe("outdated command") {
it("displays apps with pending updates") {
let cmd = OutdatedCommand()
let result = cmd.run(OutdatedCommand.Options(verbose: true))
print(result)
expect(result).to(beSuccess())
}
}
}
}

View file

@ -1,31 +0,0 @@
//
// PurchaseCommandSpec.swift
// MasKitTests
//
// Created by Maximilian Blochberger on 2020-03-21.
// Copyright © 2020 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import MasKit
public class PurchaseCommandSpec: QuickSpec {
override public func spec() {
beforeSuite {
MasKit.initialize()
}
describe("purchase command") {
it("purchases apps") {
let cmd = PurchaseCommand()
let result = cmd.run(PurchaseCommand.Options(appIds: []))
expect(result)
.to(
beFailure { error in
expect(error) == .notSupported
})
}
}
}
}

View file

@ -1,51 +0,0 @@
//
// SearchCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2018-12-28.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import MasKit
public class SearchCommandSpec: QuickSpec {
override public func spec() {
let result = SearchResult(
trackId: 1111,
trackName: "slack",
trackViewUrl: "mas preview url",
version: "0.0"
)
let storeSearch = StoreSearchMock()
beforeSuite {
MasKit.initialize()
}
describe("search command") {
beforeEach {
storeSearch.reset()
}
it("can find slack") {
storeSearch.apps[result.trackId] = result
let search = SearchCommand(storeSearch: storeSearch)
let searchOptions = SearchOptions(appName: "slack", price: false)
let result = search.run(searchOptions)
expect(result).to(beSuccess())
}
it("fails when searching for nonexistent app") {
let search = SearchCommand(storeSearch: storeSearch)
let searchOptions = SearchOptions(appName: "nonexistent", price: false)
let result = search.run(searchOptions)
expect(result)
.to(
beFailure { error in
expect(error) == .noSearchResultsFound
})
}
}
}
}

View file

@ -1,29 +0,0 @@
//
// SignInCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2018-12-28.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import MasKit
// Deprecated test
public class SignInCommandSpec: QuickSpec {
override public func spec() {
beforeSuite {
MasKit.initialize()
}
// account command disabled since macOS 10.13 High Sierra https://github.com/mas-cli/mas#%EF%B8%8F-known-issues
xdescribe("signin command") {
xit("signs in") {
let cmd = SignInCommand()
let result = cmd.run(SignInCommand.Options(username: "", password: "", dialog: false))
expect(result).to(beSuccess())
}
}
}
}

View file

@ -1,88 +0,0 @@
//
// UninstallCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2018-12-27.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Foundation
import Nimble
import Quick
@testable import MasKit
public class UninstallCommandSpec: QuickSpec {
override public func spec() {
beforeSuite {
MasKit.initialize()
}
describe("uninstall command") {
let appId = 12345
let app = SoftwareProductMock(
appName: "Some App",
bundleIdentifier: "com.some.app",
bundlePath: "/tmp/Some.app",
bundleVersion: "1.0",
itemIdentifier: NSNumber(value: appId)
)
let mockLibrary = AppLibraryMock()
let uninstall = UninstallCommand(appLibrary: mockLibrary)
context("dry run") {
let options = UninstallCommand.Options(appId: appId, dryRun: true)
beforeEach {
mockLibrary.reset()
}
it("can't remove a missing app") {
let result = uninstall.run(options)
expect(result)
.to(
beFailure { error in
expect(error) == .notInstalled
})
}
it("finds an app") {
mockLibrary.installedApps.append(app)
let result = uninstall.run(options)
expect(result).to(beSuccess())
}
}
context("wet run") {
let options = UninstallCommand.Options(appId: appId, dryRun: false)
beforeEach {
mockLibrary.reset()
}
it("can't remove a missing app") {
let result = uninstall.run(options)
expect(result)
.to(
beFailure { error in
expect(error) == .notInstalled
})
}
it("removes an app") {
mockLibrary.installedApps.append(app)
let result = uninstall.run(options)
expect(result).to(beSuccess())
}
it("fails if there is a problem with the trash command") {
var brokenUninstall = app // make mutable copy
brokenUninstall.bundlePath = "/dev/null"
mockLibrary.installedApps.append(brokenUninstall)
let result = uninstall.run(options)
expect(result)
.to(
beFailure { error in
expect(error) == .uninstallFailed
})
}
}
}
}
}

View file

@ -1,27 +0,0 @@
//
// UpgradeCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2018-12-28.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import MasKit
public class UpgradeCommandSpec: QuickSpec {
override public func spec() {
beforeSuite {
MasKit.initialize()
}
describe("upgrade command") {
it("upgrades stuff") {
let cmd = UpgradeCommand()
let result = cmd.run(UpgradeCommand.Options(apps: [""]))
expect(result).to(beSuccess())
}
}
}
}

View file

@ -1,58 +0,0 @@
//
// VendorCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2019-01-03.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import MasKit
public class VendorCommandSpec: QuickSpec {
override public func spec() {
let result = SearchResult(
trackId: 1111,
trackViewUrl: "https://awesome.app",
version: "0.0"
)
let storeSearch = StoreSearchMock()
let openCommand = OpenSystemCommandMock()
let cmd = VendorCommand(storeSearch: storeSearch, openCommand: openCommand)
beforeSuite {
MasKit.initialize()
}
describe("vendor command") {
beforeEach {
storeSearch.reset()
}
it("fails to open app with invalid ID") {
let result = cmd.run(VendorCommand.Options(appId: -999))
expect(result)
.to(
beFailure { error in
expect(error) == .searchFailed
})
}
it("can't find app with unknown ID") {
let result = cmd.run(VendorCommand.Options(appId: 999))
expect(result)
.to(
beFailure { error in
expect(error) == .noSearchResultsFound
})
}
it("opens vendor app page in browser") {
storeSearch.apps[result.trackId] = result
let cmdResult = cmd.run(VendorCommand.Options(appId: result.trackId))
expect(cmdResult).to(beSuccess())
expect(openCommand.arguments).toNot(beNil())
expect(openCommand.arguments!.first!) == result.sellerUrl
}
}
}
}

View file

@ -1,27 +0,0 @@
//
// VersionCommandSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 2018-12-28.
// Copyright © 2018 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import MasKit
public class VersionCommandSpec: QuickSpec {
override public func spec() {
beforeSuite {
MasKit.initialize()
}
describe("version command") {
it("displays the current version") {
let cmd = VersionCommand()
let result = cmd.run(VersionCommand.Options())
expect(result).to(beSuccess())
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more