Merge pull request #272 from mas-cli/purchase-cleanup

🧹 Purchase cleanup
This commit is contained in:
Ben Chatelain 2020-05-14 22:05:50 -06:00 committed by GitHub
commit 38f20a8607
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 141 additions and 136 deletions

View file

@ -8,4 +8,3 @@
excluded:
- Carthage
- docs
- MasKitTests

View file

@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
## [Unreleased]
- ✨ New `purchase` command for purchasing free apps #264 (resolves #2, #145)
thanks, [@blochberger](https://github.com/blochberger)!
- 🐟 Seriously more interactive fish completions #242
thanks, [@lwolfsonkin](https://github.com/lwolfsonkin)!
- 🧹 Purchase cleanup #272
- ♻️ SoftwareMap Protocol #271
- 🕊 Swift 5 #255
- ⚒️ Xcode 10.2 and macOS 10.14 required to build
@ -14,10 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- ⬆️ Nimble (8.0.4) #255
- ⬆️ Quick (2.2.0) #255
- Result #255
- Seriously more interactive fish completions #242
thanks, [@lwolfsonkin](https://github.com/lwolfsonkin)!
- 💡 Update readme with simpler tap usage #241
- Added support for purchasing apps (#2, #145)
## [v1.6.4] 🔎 Search Fix - 2020-05-11

View file

@ -12,15 +12,17 @@ import StoreFoundation
/// Monitors app download progress.
///
/// - Parameter adamId: An app ID?
/// - Parameter purchase: Flag indicating whether the app needs to be purchased.
/// Only works for free apps. Defaults to false.
/// - Returns: An error, if one occurred.
func download(_ adamId: UInt64, isPurchase: Bool) -> MASError? {
func download(_ adamId: UInt64, purchase: Bool = false) -> MASError? {
guard let account = ISStoreAccount.primaryAccount else {
return .notSignedIn
}
guard let storeAccount = account as? ISStoreAccount
else { fatalError("Unable to cast StoreAccount to ISStoreAccount") }
let purchase = SSPurchase(adamId: adamId, account: storeAccount, isPurchase: isPurchase)
else { fatalError("Unable to cast StoreAccount to ISStoreAccount") }
let purchase = SSPurchase(adamId: adamId, account: storeAccount, purchase: purchase)
var purchaseError: MASError?
var observerIdentifier: CKDownloadQueueObserver?

View file

@ -21,7 +21,7 @@ import StoreFoundation
func downloadQueue(_ queue: CKDownloadQueue, statusChangedFor download: SSDownload) {
guard download.metadata.itemIdentifier == purchase.itemIdentifier,
let status = download.status else {
return
return
}
if status.isFailed || status.isCancelled {
@ -42,7 +42,7 @@ import StoreFoundation
func downloadQueue(_: CKDownloadQueue, changedWithRemoval download: SSDownload) {
guard download.metadata.itemIdentifier == purchase.itemIdentifier,
let status = download.status else {
return
return
}
clearLine()

View file

@ -13,36 +13,36 @@ typealias SSPurchaseCompletion =
(_ purchase: SSPurchase?, _ completed: Bool, _ error: Error?, _ response: SSPurchaseResponse?) -> Void
extension SSPurchase {
convenience init(adamId: UInt64, account: ISStoreAccount, isPurchase: Bool) {
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
]
var parameters: [String: Any] = [
"productType": "C",
"price": 0,
"salableAdamId": adamId,
"pg": "default",
"appExtVrsId": 0
]
if isPurchase {
parameters["macappinstalledconfirmed"] = 1
parameters["pricingParameters"] = "STDQ"
if purchase {
parameters["macappinstalledconfirmed"] = 1
parameters["pricingParameters"] = "STDQ"
} else {
// is redownload, use existing functionality
parameters["pricingParameters"] = "STDRDL"
parameters["pricingParameters"] = "STDRDL"
}
buyParameters = parameters.map { key, value in
return "\(key)=\(value)"
}.joined(separator: "&")
buyParameters = parameters.map { key, value in
return "\(key)=\(value)"
}.joined(separator: "&")
itemIdentifier = adamId
accountIdentifier = account.dsID
appleID = account.identifier
// Not sure if this is needed, but lets use it here.
if isPurchase {
if purchase {
isRedownload = false
}

View file

@ -29,8 +29,7 @@ public struct HomeCommand: CommandProtocol {
/// Runs the command.
public func run(_ options: HomeOptions) -> Result<(), MASError> {
do {
guard let result = try storeSearch.lookup(app: options.appId)
else {
guard let result = try storeSearch.lookup(app: options.appId) else {
print("No results found")
return .failure(.noSearchResultsFound)
}

View file

@ -25,8 +25,7 @@ public struct InfoCommand: CommandProtocol {
/// Runs the command.
public func run(_ options: InfoOptions) -> Result<(), MASError> {
do {
guard let result = try storeSearch.lookup(app: options.appId)
else {
guard let result = try storeSearch.lookup(app: options.appId) else {
print("No results found")
return .failure(.noSearchResultsFound)
}

View file

@ -18,7 +18,6 @@ public struct InstallCommand: CommandProtocol {
private let appLibrary: AppLibrary
/// Public initializer.
/// - Parameter appLibrary: AppLibrary manager.
public init() {
self.init(appLibrary: MasAppLibrary())
}
@ -38,7 +37,7 @@ public struct InstallCommand: CommandProtocol {
return nil
}
return download(appId, isPurchase: false)
return download(appId)
}
switch downloadResults.count {

View file

@ -30,8 +30,8 @@ public struct LuckyCommand: CommandProtocol {
/// - Parameter storeSearch: Search manager.
init(appLibrary: AppLibrary = MasAppLibrary(),
storeSearch: StoreSearch = MasStoreSearch()) {
self.appLibrary = appLibrary
self.storeSearch = storeSearch
self.appLibrary = appLibrary
self.storeSearch = storeSearch
}
/// Runs the command.
@ -73,7 +73,7 @@ public struct LuckyCommand: CommandProtocol {
return nil
}
return download(appId, isPurchase: false)
return download(appId)
}
switch downloadResults.count {

View file

@ -39,20 +39,20 @@ public struct OpenCommand: CommandProtocol {
}
guard let appId = Int(options.appId)
else {
print("Invalid app ID")
return .failure(.noSearchResultsFound)
else {
print("Invalid app ID")
return .failure(.noSearchResultsFound)
}
guard let result = try storeSearch.lookup(app: appId)
else {
print("No results found")
return .failure(.noSearchResultsFound)
else {
print("No results found")
return .failure(.noSearchResultsFound)
}
guard var url = URLComponents(string: result.trackViewUrl)
else {
return .failure(.searchFailed)
else {
return .failure(.searchFailed)
}
url.scheme = masScheme

View file

@ -38,8 +38,8 @@ public struct OutdatedCommand: CommandProtocol {
if let installed = appLibrary.installedApp(forBundleId: update.bundleID) {
// Display version of installed app compared to available update.
print("""
\(update.itemIdentifier) \(update.title) (\(installed.bundleVersion) -> \(update.bundleVersion))
""")
\(update.itemIdentifier) \(update.title) (\(installed.bundleVersion) -> \(update.bundleVersion))
""")
} else {
print("\(update.itemIdentifier) \(update.title) (unknown -> \(update.bundleVersion))")
}

View file

@ -10,53 +10,55 @@ 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"
public typealias Options = PurchaseOptions
public let verb = "purchase"
public let function = "Purchase and download free apps from the Mac App Store"
/// Designated initializer.
public init() {
}
private let appLibrary: AppLibrary
/// Runs the command.
public func run(_ options: Options) -> Result<(), MASError> {
// Try to download applications with given identifiers and collect results
let downloadResults = options.appIds.compactMap { (appId) -> MASError? in
if let product = installedApp(appId) {
printWarning("\(product.appName) has already been purchased.")
return nil
}
/// Public initializer.
public init() {
self.init(appLibrary: MasAppLibrary())
}
return download(appId, isPurchase: true)
}
/// Internal initializer.
/// - Parameter appLibrary: AppLibrary manager.
init(appLibrary: AppLibrary = MasAppLibrary()) {
self.appLibrary = appLibrary
}
switch downloadResults.count {
case 0:
return .success(())
case 1:
return .failure(downloadResults[0])
default:
return .failure(.downloadFailed(error: nil))
}
}
/// Runs the command.
public func run(_ options: Options) -> Result<(), MASError> {
// Try to download applications with given identifiers and collect results
let downloadResults = options.appIds.compactMap { (appId) -> MASError? in
if let product = appLibrary.installedApp(forId: appId) {
printWarning("\(product.appName) has already been purchased.")
return nil
}
fileprivate func installedApp(_ appId: UInt64) -> CKSoftwareProduct? {
let appId = NSNumber(value: appId)
return download(appId, purchase: true)
}
let softwareMap = CKSoftwareMap.shared()
return softwareMap.allProducts()?.first { $0.itemIdentifier == appId }
}
switch downloadResults.count {
case 0:
return .success(())
case 1:
return .failure(downloadResults[0])
default:
return .failure(.downloadFailed(error: nil))
}
}
}
public struct PurchaseOptions: OptionsProtocol {
let appIds: [UInt64]
let appIds: [UInt64]
public static func create(_ appIds: [Int]) -> PurchaseOptions {
return PurchaseOptions(appIds: appIds.map { UInt64($0) })
}
public static func create(_ appIds: [Int]) -> PurchaseOptions {
return PurchaseOptions(appIds: appIds.map { UInt64($0) })
}
public static func evaluate(_ mode: CommandMode) -> Result<PurchaseOptions, CommandantError<MASError>> {
return create
<*> mode <| Argument(usage: "app ID(s) to install")
}
public static func evaluate(_ mode: CommandMode) -> Result<PurchaseOptions, CommandantError<MASError>> {
return create
<*> mode <| Argument(usage: "app ID(s) to install")
}
}

View file

@ -56,7 +56,7 @@ public struct SignInOptions: OptionsProtocol {
static func create(username: String) -> (_ password: String) -> (_ dialog: Bool) -> SignInOptions {
return { password in { dialog in
SignInOptions(username: username, password: password, dialog: dialog)
} }
} }
}
public static func evaluate(_ mode: CommandMode) -> Result<SignInOptions, CommandantError<MASError>> {

View file

@ -71,7 +71,7 @@ public struct UpgradeCommand: CommandProtocol {
print(updates.map({ "\($0.title) (\($0.bundleVersion))" }).joined(separator: ", "))
let updateResults = updates.compactMap {
download($0.itemIdentifier.uint64Value, isPurchase: false)
download($0.itemIdentifier.uint64Value)
}
switch updateResults.count {

View file

@ -30,13 +30,13 @@ public struct VendorCommand: CommandProtocol {
public func run(_ options: VendorOptions) -> Result<(), MASError> {
do {
guard let result = try storeSearch.lookup(app: options.appId)
else {
print("No results found")
return .failure(.noSearchResultsFound)
else {
print("No results found")
return .failure(.noSearchResultsFound)
}
guard let vendorWebsite = result.sellerUrl
else { throw MASError.noVendorWebsite }
else { throw MASError.noVendorWebsite }
do {
try openCommand.run(arguments: vendorWebsite)

View file

@ -22,17 +22,17 @@ public class MasStoreSearch: StoreSearch {
/// - Throws: Error if there is a problem with the network request.
public func search(for appName: String) throws -> SearchResultList {
guard let url = searchURL(for: appName)
else { throw MASError.urlEncoding }
else { throw MASError.urlEncoding }
let result = networkManager.loadDataSync(from: url)
// Unwrap network result
guard case let .success(data) = result
else {
if case let .failure(error) = result {
throw error
}
throw MASError.noData
else {
if case let .failure(error) = result {
throw error
}
throw MASError.noData
}
do {
@ -50,24 +50,24 @@ public class MasStoreSearch: StoreSearch {
/// - Throws: Error if there is a problem with the network request.
public func lookup(app appId: Int) throws -> SearchResult? {
guard let url = lookupURL(forApp: appId)
else { throw MASError.urlEncoding }
else { throw MASError.urlEncoding }
let result = networkManager.loadDataSync(from: url)
// Unwrap network result
guard case let .success(data) = result
else {
if case let .failure(error) = result {
throw error
}
throw MASError.noData
else {
if case let .failure(error) = result {
throw error
}
throw MASError.noData
}
do {
let results = try JSONDecoder().decode(SearchResultList.self, from: data)
guard let searchResult = results.results.first
else { return nil }
else { return nil }
return searchResult
} catch {

View file

@ -42,7 +42,7 @@ extension MASError: CustomStringConvertible {
return "The 'signin' command has been disabled on this macOS version. " +
"Please sign into the Mac App Store app manually." +
"\nFor more info see: " +
"https://github.com/mas-cli/mas/issues/164"
"https://github.com/mas-cli/mas/issues/164"
case let .signInFailed(error):
if let error = error {

View file

@ -19,7 +19,7 @@ struct AppInfoFormatter {
"\(app.trackName)",
"\(app.version)",
"[\(app.price)]"
].joined(separator: " ")
].joined(separator: " ")
return [
headline,
@ -28,7 +28,7 @@ struct AppInfoFormatter {
"Minimum OS: \(app.minimumOsVersion)",
"Size: \(humanReadableSize(app.fileSizeBytes))",
"From: \(app.trackViewUrl)"
].joined(separator: "\n")
].joined(separator: "\n")
}
/// Formats a file size.

View file

@ -20,9 +20,6 @@ extension NetworkResult: Equatable {
case let (.failure(error1), .failure(error2)):
return error1.localizedDescription == error2.localizedDescription
// case (.none, .none):
// return true
default:
return false
}

View file

@ -0,0 +1,10 @@
#
# .swiftlint.yml
# MasKitTests
#
# https://github.com/realm/SwiftLint#configuration
#
---
disabled_rules:
- force_cast
- function_body_length

View file

@ -17,7 +17,7 @@ class AccountCommandSpec: QuickSpec {
let cmd = AccountCommand()
let result = cmd.run(AccountCommand.Options())
print(result)
// expect(result).to(beSuccess())
// expect(result).to(beSuccess())
}
}
}

View file

@ -17,7 +17,7 @@ class InstallCommandSpec: QuickSpec {
let cmd = InstallCommand()
let result = cmd.run(InstallCommand.Options(appIds: [], forceInstall: false))
print(result)
// expect(result).to(beSuccess())
// expect(result).to(beSuccess())
}
}
}

View file

@ -17,7 +17,7 @@ class ListCommandSpec: QuickSpec {
let list = ListCommand()
let result = list.run(ListCommand.Options())
print(result)
// expect(result).to(beSuccess())
// expect(result).to(beSuccess())
}
}
}

View file

@ -17,7 +17,7 @@ class LuckyCommandSpec: QuickSpec {
let cmd = LuckyCommand()
let result = cmd.run(LuckyCommand.Options(appName: "", forceInstall: false))
print(result)
// expect(result).to(beSuccess())
// expect(result).to(beSuccess())
}
}
}

View file

@ -17,7 +17,7 @@ class OutdatedCommandSpec: QuickSpec {
let cmd = OutdatedCommand()
let result = cmd.run(OutdatedCommand.Options())
print(result)
// expect(result).to(beSuccess())
// expect(result).to(beSuccess())
}
}
}

View file

@ -17,7 +17,7 @@ class PurchaseCommandSpec: QuickSpec {
let cmd = PurchaseCommand()
let result = cmd.run(PurchaseCommand.Options(appIds: []))
print(result)
// expect(result).to(beSuccess())
// expect(result).to(beSuccess())
}
}
}

View file

@ -17,7 +17,7 @@ class ResetCommandSpec: QuickSpec {
let cmd = ResetCommand()
let result = cmd.run(ResetCommand.Options(debug: false))
print(result)
// expect(result).to(beSuccess())
// expect(result).to(beSuccess())
}
}
}

View file

@ -17,7 +17,7 @@ class SignInCommandSpec: QuickSpec {
let cmd = SignInCommand()
let result = cmd.run(SignInCommand.Options(username: "", password: "", dialog: false))
print(result)
// expect(result).to(beSuccess())
// expect(result).to(beSuccess())
}
}
}

View file

@ -17,7 +17,7 @@ class SignOutCommandSpec: QuickSpec {
let cmd = SignOutCommand()
let result = cmd.run(SignOutCommand.Options())
print(result)
// expect(result).to(beSuccess())
// expect(result).to(beSuccess())
}
}
}

View file

@ -17,7 +17,7 @@ class UpgradeCommandSpec: QuickSpec {
let cmd = UpgradeCommand()
let result = cmd.run(UpgradeCommand.Options(apps: [""]))
print(result)
// expect(result).to(beSuccess())
// expect(result).to(beSuccess())
}
}
}

View file

@ -17,7 +17,7 @@ class VersionCommandSpec: QuickSpec {
let cmd = VersionCommand()
let result = cmd.run(VersionCommand.Options())
print(result)
// expect(result).to(beSuccess())
// expect(result).to(beSuccess())
}
}
}

View file

@ -48,15 +48,9 @@ struct SoftwareMapMock: SoftwareMap {
}
func product(for bundleIdentifier: String) -> SoftwareProduct? {
for product in products {
if product.bundleIdentifier == bundleIdentifier {
return product
}
for product in products where product.bundleIdentifier == bundleIdentifier {
return product
}
return nil
}
}
//public func == (lhs: Expectation<[SoftwareProduct]>, rhs: [SoftwareProduct]) {
// lhs.to(beCloseTo(rhs))
//}

View file

@ -23,7 +23,7 @@ class StoreSearchMock: StoreSearch {
}
guard let result = apps[appId]
else { throw MASError.noSearchResultsFound }
else { throw MASError.noSearchResultsFound }
return result
}

View file

@ -24,14 +24,14 @@ class StoreSearchSpec: QuickSpec {
let appName = "myapp"
let urlString = storeSearch.searchURLString(forApp: appName)
expect(urlString) ==
"https://itunes.apple.com/search?media=software&entity=macSoftware&term=\(appName)"
"https://itunes.apple.com/search?media=software&entity=macSoftware&term=\(appName)"
}
it("contains the encoded app name") {
let appName = "My App"
let appNameEncoded = "My%20App"
let urlString = storeSearch.searchURLString(forApp: appName)
expect(urlString) ==
"https://itunes.apple.com/search?media=software&entity=macSoftware&term=\(appNameEncoded)"
"https://itunes.apple.com/search?media=software&entity=macSoftware&term=\(appNameEncoded)"
}
// Find a character that causes addingPercentEncoding(withAllowedCharacters to return nil
xit("is nil when app name cannot be url encoded") {

View file

@ -25,7 +25,7 @@ extension Bundle {
guard let path = self.path(forResource: fileName.fileNameWithoutExtension,
ofType: fileName.fileExtension,
inDirectory: "JSON")
else { fatalError("Unable to load file \(fileName)") }
else { fatalError("Unable to load file \(fileName)") }
return URL(fileURLWithPath: path)
}

View file

@ -30,7 +30,7 @@ class SearchResultsFormatterSpec: QuickSpec {
trackId: 12345,
trackName: "Awesome App",
version: "19.2.1"
)]
)]
let output = format(results, false)
expect(output) == " 12345 Awesome App (19.2.1)"
}
@ -40,7 +40,7 @@ class SearchResultsFormatterSpec: QuickSpec {
trackId: 12345,
trackName: "Awesome App",
version: "19.2.1"
)]
)]
let output = format(results, true)
expect(output) == " 12345 Awesome App $ 9.87 (19.2.1)"
}
@ -61,7 +61,7 @@ class SearchResultsFormatterSpec: QuickSpec {
]
let output = format(results, false)
expect(output) ==
" 12345 Awesome App (19.2.1)\n 67890 Even Better App (1.2.0)"
" 12345 Awesome App (19.2.1)\n 67890 Even Better App (1.2.0)"
}
it("can format a two results with prices") {
results = [
@ -80,7 +80,7 @@ class SearchResultsFormatterSpec: QuickSpec {
]
let output = format(results, true)
expect(output) ==
" 12345 Awesome App $ 9.87 (19.2.1)\n 67890 Even Better App $ 0.01 (1.2.0)"
" 12345 Awesome App $ 9.87 (19.2.1)\n 67890 Even Better App $ 0.01 (1.2.0)"
}
}
}

View file

@ -27,7 +27,7 @@ class NetworkSessionMockFromFile: NetworkSessionMock {
/// - completionHandler: Closure which is delivered either data or an error.
@objc override func loadData(from _: URL, completionHandler: @escaping (Data?, Error?) -> Void) {
guard let fileURL = Bundle.jsonResponse(fileName: responseFile)
else { fatalError("Unable to load file \(responseFile)") }
else { fatalError("Unable to load file \(responseFile)") }
do {
let data = try Data(contentsOf: fileURL, options: .mappedIfSafe)

View file

@ -13,7 +13,8 @@ class TestURLSessionDelegate: NSObject, URLSessionDelegate {
func urlSession(_: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: (URLSession.AuthChallengeDisposition,
URLCredential?) -> Void) {
URLCredential?) -> Void) {
// For example, you may want to override this to accept some self-signed certs here.
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
Constants.selfSignedHosts.contains(challenge.protectionSpace.host) {

View file

@ -19,6 +19,7 @@ fi
# : command: usage: command [-pVv] command [arg ...]
if command -v swiftlint > /dev/null; then
swiftlint autocorrect --format
swiftlint lint --quiet
else
echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint"