Merge pull request #198 from mas-cli/network-refactor

♻️🌐 Network refactor
This commit is contained in:
Ben Chatelain 2019-01-11 20:04:39 -07:00 committed by GitHub
commit 893c528a2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
83 changed files with 1653 additions and 745 deletions

9
.hound.yml Normal file
View file

@ -0,0 +1,9 @@
#
# .hound.yml
# mas
#
# http://help.houndci.com/configuration/swiftlint
#
---
swiftlint:
config_file: .swiftlint.yml

11
.swiftlint.yml Normal file
View file

@ -0,0 +1,11 @@
#
# .swiftlint.yml
# mas
#
# https://github.com/realm/SwiftLint#configuration
#
---
excluded:
- Carthage/
- docs/
- MasKitTests/

View file

@ -7,10 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
## [Unreleased]
- ♻️🌐 Network refactor #198
- 👷🏻‍♀️ Jenkins Pipeline #197
- ✨ New `home`, `open` and `vendor` commands #196
- 🐛📦 Fix paths building installer package #195
- ♻️ AppLibrary refactor #193
- ♻️📚 AppLibrary refactor #193
## [v1.5.0] 🗑 Uninstall - 2018-12-27

View file

@ -28,11 +28,11 @@ struct LogOptions: OptionsProtocol {
return { verbose in { logName in LogOptions(lines: lines, verbose: verbose, logName: logName) } }
}
static func evaluate(_ m: CommandMode) -> Result<LogOptions, CommandantError<YourErrorType>> {
static func evaluate(_ mode: CommandMode) -> Result<LogOptions, CommandantError<YourErrorType>> {
return create
<*> m <| Option(key: "lines", defaultValue: 0, usage: "the number of lines to read from the logs")
<*> m <| Option(key: "verbose", defaultValue: false, usage: "show verbose output")
<*> m <| Argument(usage: "the log to read")
<*> mode <| Option(key: "lines", defaultValue: 0, usage: "the number of lines to read from the logs")
<*> mode <| Option(key: "verbose", defaultValue: false, usage: "show verbose output")
<*> mode <| Argument(usage: "the log to read")
}
}
```

View file

@ -69,8 +69,8 @@ public struct HelpOptions<ClientError: Error>: OptionsProtocol {
return self.init(verb: (verb == "" ? nil : verb))
}
public static func evaluate(_ m: CommandMode) -> Result<HelpOptions, CommandantError<ClientError>> {
public static func evaluate(_ mode: CommandMode) -> Result<HelpOptions, CommandantError<ClientError>> {
return create
<*> m <| Argument(defaultValue: "", usage: "the command to display help for")
<*> mode <| Argument(defaultValue: "", usage: "the command to display help for")
}
}

View file

@ -27,12 +27,12 @@ import Result
/// return { outputFilename in { shouldDelete in { logName in LogOptions(verbosity: verbosity, outputFilename: outputFilename, shouldDelete: shouldDelete, logName: logName) } } }
/// }
///
/// static func evaluate(_ m: CommandMode) -> Result<LogOptions, CommandantError<YourErrorType>> {
/// static func evaluate(_ mode: CommandMode) -> Result<LogOptions, CommandantError<YourErrorType>> {
/// return create
/// <*> m <| Option(key: "verbose", defaultValue: 0, usage: "the verbosity level with which to read the logs")
/// <*> m <| Option(key: "outputFilename", defaultValue: "", usage: "a file to print output to, instead of stdout")
/// <*> m <| Switch(flag: "d", key: "delete", usage: "delete the logs when finished")
/// <*> m <| Argument(usage: "the log to read")
/// <*> mode <| Option(key: "verbose", defaultValue: 0, usage: "the verbosity level with which to read the logs")
/// <*> mode <| Option(key: "outputFilename", defaultValue: "", usage: "a file to print output to, instead of stdout")
/// <*> mode <| Switch(flag: "d", key: "delete", usage: "delete the logs when finished")
/// <*> mode <| Argument(usage: "the log to read")
/// }
/// }
public protocol OptionsProtocol {
@ -41,14 +41,14 @@ public protocol OptionsProtocol {
/// Evaluates this set of options in the given mode.
///
/// Returns the parsed options or a `UsageError`.
static func evaluate(_ m: CommandMode) -> Result<Self, CommandantError<ClientError>>
static func evaluate(_ mode: CommandMode) -> Result<Self, CommandantError<ClientError>>
}
/// An `OptionsProtocol` that has no options.
public struct NoOptions<ClientError: Error>: OptionsProtocol {
public init() {}
public static func evaluate(_ m: CommandMode) -> Result<NoOptions, CommandantError<ClientError>> {
public static func evaluate(_ mode: CommandMode) -> Result<NoOptions, CommandantError<ClientError>> {
return .success(NoOptions())
}
}

View file

@ -157,19 +157,19 @@ struct TestOptions: OptionsProtocol, Equatable {
} } } } } } } } } }
}
static func evaluate(_ m: CommandMode) -> Result<TestOptions, CommandantError<NoError>> {
static func evaluate(_ mode: CommandMode) -> Result<TestOptions, CommandantError<NoError>> {
return create
<*> m <| Option(key: "intValue", defaultValue: 42, usage: "Some integer value")
<*> m <| Option(key: "stringValue", defaultValue: "foobar", usage: "Some string value")
<*> m <| Option<[String]>(key: "stringsArray", defaultValue: [], usage: "Some array of arguments")
<*> m <| Option<[String]?>(key: "optionalStringsArray", defaultValue: nil, usage: "Some array of arguments")
<*> m <| Option<String?>(key: "optionalStringValue", defaultValue: nil, usage: "Some string value")
<*> m <| Argument(usage: "A name you're required to specify")
<*> m <| Argument(defaultValue: "filename", usage: "A filename that you can optionally specify")
<*> m <| Option(key: "enabled", defaultValue: false, usage: "Whether to be enabled")
<*> m <| Switch(flag: "f", key: "force", usage: "Whether to force")
<*> m <| Switch(flag: "g", key: "glob", usage: "Whether to glob")
<*> m <| Argument(defaultValue: [], usage: "An argument list that consumes the rest of positional arguments")
<*> mode <| Option(key: "intValue", defaultValue: 42, usage: "Some integer value")
<*> mode <| Option(key: "stringValue", defaultValue: "foobar", usage: "Some string value")
<*> mode <| Option<[String]>(key: "stringsArray", defaultValue: [], usage: "Some array of arguments")
<*> mode <| Option<[String]?>(key: "optionalStringsArray", defaultValue: nil, usage: "Some array of arguments")
<*> mode <| Option<String?>(key: "optionalStringValue", defaultValue: nil, usage: "Some string value")
<*> mode <| Argument(usage: "A name you're required to specify")
<*> mode <| Argument(defaultValue: "filename", usage: "A filename that you can optionally specify")
<*> mode <| Option(key: "enabled", defaultValue: false, usage: "Whether to be enabled")
<*> mode <| Switch(flag: "f", key: "force", usage: "Whether to force")
<*> mode <| Switch(flag: "g", key: "glob", usage: "Whether to glob")
<*> mode <| Argument(defaultValue: [], usage: "An argument list that consumes the rest of positional arguments")
}
}

View file

@ -132,16 +132,16 @@ struct TestEnumOptions: OptionsProtocol, Equatable {
} } } } } } }
}
static func evaluate(_ m: CommandMode) -> Result<TestEnumOptions, CommandantError<NoError>> {
static func evaluate(_ mode: CommandMode) -> Result<TestEnumOptions, CommandantError<NoError>> {
return create
<*> m <| Option(key: "strictIntValue", defaultValue: .theAnswerToTheUltimateQuestionOfLifeTheUniverseAndEverything, usage: "`0` - zero, `255` - max, `3` - three, `5` - five or `42` - The Answer")
<*> m <| Option(key: "strictStringValue", defaultValue: .foobar, usage: "`foobar`, `bazbuzzz`, `a`, `b`, `c`, `one`, `two`, `c`")
<*> m <| Option<[StrictStringValue]>(key: "strictStringsArray", defaultValue: [], usage: "Some array of arguments")
<*> m <| Option<[StrictStringValue]?>(key: "optionalStrictStringsArray", defaultValue: nil, usage: "Some array of arguments")
<*> m <| Option<StrictStringValue?>(key: "optionalStrictStringValue", defaultValue: nil, usage: "Some string value")
<*> m <| Argument(usage: "A name you're required to specify")
<*> m <| Argument(defaultValue: .min, usage: "A number that you can optionally specify")
<*> m <| Argument(defaultValue: [], usage: "An argument list that consumes the rest of positional arguments")
<*> mode <| Option(key: "strictIntValue", defaultValue: .theAnswerToTheUltimateQuestionOfLifeTheUniverseAndEverything, usage: "`0` - zero, `255` - max, `3` - three, `5` - five or `42` - The Answer")
<*> mode <| Option(key: "strictStringValue", defaultValue: .foobar, usage: "`foobar`, `bazbuzzz`, `a`, `b`, `c`, `one`, `two`, `c`")
<*> mode <| Option<[StrictStringValue]>(key: "strictStringsArray", defaultValue: [], usage: "Some array of arguments")
<*> mode <| Option<[StrictStringValue]?>(key: "optionalStrictStringsArray", defaultValue: nil, usage: "Some array of arguments")
<*> mode <| Option<StrictStringValue?>(key: "optionalStrictStringValue", defaultValue: nil, usage: "Some string value")
<*> mode <| Argument(usage: "A name you're required to specify")
<*> mode <| Argument(defaultValue: .min, usage: "A number that you can optionally specify")
<*> mode <| Argument(defaultValue: [], usage: "An argument list that consumes the rest of positional arguments")
}
}

View file

@ -9,18 +9,23 @@
import CommerceKit
import StoreFoundation
/// Monitors app download progress.
///
/// - Parameter adamId: An app ID?
/// - Returns: An error, if one occurred.
func download(_ adamId: UInt64) -> MASError? {
guard let account = ISStoreAccount.primaryAccount else {
return .notSignedIn
}
let group = DispatchGroup()
let purchase = SSPurchase(adamId: adamId, account: account as! ISStoreAccount)
guard let storeAccount = account as? ISStoreAccount
else { fatalError("Unable to cast StoreAccount to ISStoreAccount") }
let purchase = SSPurchase(adamId: adamId, account: storeAccount)
var purchaseError: MASError?
var observerIdentifier: CKDownloadQueueObserver? = nil
var observerIdentifier: CKDownloadQueueObserver?
let group = DispatchGroup()
group.enter()
purchase.perform { purchase, _, error, response in
if let error = error {
@ -28,7 +33,7 @@ func download(_ adamId: UInt64) -> MASError? {
group.leave()
return
}
if let downloads = response?.downloads, downloads.count > 0, let purchase = purchase {
let observer = PurchaseDownloadObserver(purchase: purchase)
@ -36,27 +41,25 @@ func download(_ adamId: UInt64) -> MASError? {
purchaseError = error
group.leave()
}
observer.completionHandler = {
group.leave()
}
let downloadQueue = CKDownloadQueue.shared()
observerIdentifier = downloadQueue.add(observer)
}
else {
} else {
print("No downloads")
purchaseError = .noDownloads
group.leave()
}
}
let _ = group.wait(timeout: .distantFuture)
_ = group.wait(timeout: .distantFuture)
if let observerIdentifier = observerIdentifier {
CKDownloadQueue.shared().remove(observerIdentifier)
}
return purchaseError
}

View file

@ -38,8 +38,8 @@ extension ISStoreAccount: StoreAccount {
}
static func signIn(username: String, password: String, systemDialog: Bool = false) throws -> StoreAccount {
var account: ISStoreAccount? = nil
var error: MASError? = nil
var storeAccount: ISStoreAccount?
var maserror: MASError?
let accountService: ISAccountService = ISServiceProxy.genericShared().accountService
let client = ISStoreClient(storeClientType: 0)
@ -61,11 +61,11 @@ extension ISStoreAccount: StoreAccount {
group.enter()
// Only works on macOS Sierra and below
accountService.signIn(with: context) { success, _account, _error in
accountService.signIn(with: context) { success, account, error in
if success {
account = _account
storeAccount = account
} else {
error = .signInFailed(error: _error as NSError?)
maserror = .signInFailed(error: error as NSError?)
}
group.leave()
}
@ -73,13 +73,13 @@ extension ISStoreAccount: StoreAccount {
if systemDialog {
group.wait()
} else {
let _ = group.wait(timeout: .now() + 30)
_ = group.wait(timeout: .now() + 30)
}
if let account = account {
if let account = storeAccount {
return account
}
throw error ?? MASError.signInFailed(error: nil)
throw maserror ?? MASError.signInFailed(error: nil)
}
}

View file

@ -11,27 +11,26 @@ import StoreFoundation
@objc class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver {
let purchase: SSPurchase
var completionHandler: (() -> ())?
var errorHandler: ((MASError) -> ())?
var completionHandler: (() -> Void)?
var errorHandler: ((MASError) -> Void)?
init(purchase: SSPurchase) {
self.purchase = purchase
}
func downloadQueue(_ queue: CKDownloadQueue, statusChangedFor download: SSDownload) {
guard download.metadata.itemIdentifier == purchase.itemIdentifier,
let status = download.status else {
return
}
if status.isFailed || status.isCancelled {
queue.removeDownload(withItemIdentifier: download.metadata.itemIdentifier)
}
else {
} else {
progress(status.progressState)
}
}
func downloadQueue(_ queue: CKDownloadQueue, changedWithAddition download: SSDownload) {
guard download.metadata.itemIdentifier == purchase.itemIdentifier else {
return
@ -39,21 +38,19 @@ import StoreFoundation
clearLine()
printInfo("Downloading \(download.metadata.title)")
}
func downloadQueue(_ queue: CKDownloadQueue, changedWithRemoval download: SSDownload) {
guard download.metadata.itemIdentifier == purchase.itemIdentifier,
let status = download.status else {
return
}
clearLine()
if status.isFailed {
errorHandler?(.downloadFailed(error: status.error as NSError?))
}
else if status.isCancelled {
} else if status.isCancelled {
errorHandler?(.cancelled)
}
else {
} else {
printInfo("Installed \(download.metadata.title)")
completionHandler?()
}
@ -63,7 +60,7 @@ import StoreFoundation
struct ProgressState {
let percentComplete: Float
let phase: String
var percentage: String {
return String(format: "%.1f%%", arguments: [floor(percentComplete * 100)])
}
@ -74,16 +71,15 @@ func progress(_ state: ProgressState) {
guard isatty(fileno(stdout)) != 0 else {
return
}
let barLength = 60
let completeLength = Int(state.percentComplete * Float(barLength))
var bar = ""
for i in 0..<barLength {
if i < completeLength {
for index in 0..<barLength {
if index < completeLength {
bar += "#"
}
else {
} else {
bar += "-"
}
}

View file

@ -9,23 +9,25 @@
import CommerceKit
import StoreFoundation
typealias SSPurchaseCompletion = (_ purchase: SSPurchase?, _ completed: Bool, _ error: Error?, _ response: SSPurchaseResponse?) -> ()
typealias SSPurchaseCompletion =
(_ purchase: SSPurchase?, _ completed: Bool, _ error: Error?, _ response: SSPurchaseResponse?) -> Void
extension SSPurchase {
convenience init(adamId: UInt64, account: ISStoreAccount) {
self.init()
self.buyParameters = "productType=C&price=0&salableAdamId=\(adamId)&pricingParameters=STDRDL&pg=default&appExtVrsId=0"
self.buyParameters =
"productType=C&price=0&salableAdamId=\(adamId)&pricingParameters=STDRDL&pg=default&appExtVrsId=0"
self.itemIdentifier = adamId
self.accountIdentifier = account.dsID
self.appleID = account.identifier
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

@ -16,12 +16,12 @@ public struct AccountCommand: CommandProtocol {
public let function = "Prints the primary account Apple ID"
public init() {}
/// Runs the command.
public func run(_ options: Options) -> Result<(), MASError> {
if let account = ISStoreAccount.primaryAccount {
print(String(describing: account.identifier))
}
else {
} else {
print("Not signed in")
return .failure(.notSignedIn)
}

View file

@ -47,8 +47,7 @@ public struct HomeCommand: CommandProtocol {
printError("Open failed: (\(reason)) \(openCommand.stderr)")
return .failure(.searchFailed)
}
}
catch {
} catch {
// Bubble up MASErrors
if let error = error as? MASError {
return .failure(error)
@ -67,8 +66,8 @@ public struct HomeOptions: OptionsProtocol {
return HomeOptions(appId: appId)
}
public static func evaluate(_ m: CommandMode) -> Result<HomeOptions, CommandantError<MASError>> {
public static func evaluate(_ mode: CommandMode) -> Result<HomeOptions, CommandantError<MASError>> {
return create
<*> m <| Argument(usage: "ID of app to show on MAS Preview")
<*> mode <| Argument(usage: "ID of app to show on MAS Preview")
}
}

View file

@ -16,36 +16,33 @@ public struct InfoCommand: CommandProtocol {
public let verb = "info"
public let function = "Display app information from the Mac App Store"
private let urlSession: URLSession
public init(urlSession: URLSession = URLSession.shared) {
self.urlSession = urlSession
private let storeSearch: StoreSearch
/// Designated initializer.
public init(storeSearch: StoreSearch = MasStoreSearch()) {
self.storeSearch = storeSearch
}
/// Runs the command.
public func run(_ options: InfoOptions) -> Result<(), MASError> {
guard let infoURLString = infoURLString(options.appId),
let searchJson = urlSession.requestSynchronousJSONWithURLString(infoURLString) as? [String: Any] else {
return .failure(.searchFailed)
}
do {
guard let result = try storeSearch.lookup(app: options.appId)
else {
print("No results found")
return .failure(.noSearchResultsFound)
}
guard let resultCount = searchJson[ResultKeys.ResultCount] as? Int, resultCount > 0,
let results = searchJson[ResultKeys.Results] as? [[String: Any]],
let result = results.first else {
print("No results found")
return .failure(.noSearchResultsFound)
print(AppInfoFormatter.format(app: result))
} catch {
// Bubble up MASErrors
if let error = error as? MASError {
return .failure(error)
}
return .failure(.searchFailed)
}
print(AppInfoFormatter.format(appInfo: result))
return .success(())
}
private func infoURLString(_ appId: String) -> String? {
if let urlEncodedAppId = appId.URLEncodedString {
return "https://itunes.apple.com/lookup?id=\(urlEncodedAppId)"
}
return nil
}
}
public struct InfoOptions: OptionsProtocol {
@ -55,60 +52,8 @@ public struct InfoOptions: OptionsProtocol {
return InfoOptions(appId: appId)
}
public static func evaluate(_ m: CommandMode) -> Result<InfoOptions, CommandantError<MASError>> {
public static func evaluate(_ mode: CommandMode) -> Result<InfoOptions, CommandantError<MASError>> {
return create
<*> m <| Argument(usage: "the app id to show info")
}
}
private struct AppInfoFormatter {
private enum Keys {
static let Name = "trackCensoredName"
static let Version = "version"
static let Price = "formattedPrice"
static let Seller = "sellerName"
static let VersionReleaseDate = "currentVersionReleaseDate"
static let MinimumOS = "minimumOsVersion"
static let FileSize = "fileSizeBytes"
static let AppStoreUrl = "trackViewUrl"
}
static func format(appInfo: [String: Any]) -> String {
let headline = [
"\(appInfo.stringOrEmpty(key: Keys.Name))",
"\(appInfo.stringOrEmpty(key: Keys.Version))",
"[\(appInfo.stringOrEmpty(key: Keys.Price))]",
].joined(separator: " ")
return [
headline,
"By: \(appInfo.stringOrEmpty(key: Keys.Seller))",
"Released: \(humaReadableDate(appInfo.stringOrEmpty(key: Keys.VersionReleaseDate)))",
"Minimum OS: \(appInfo.stringOrEmpty(key: Keys.MinimumOS))",
"Size: \(humanReadableSize(appInfo.stringOrEmpty(key: Keys.FileSize)))",
"From: \(appInfo.stringOrEmpty(key: Keys.AppStoreUrl))",
].joined(separator: "\n")
}
private static func humanReadableSize(_ size: String) -> String {
let bytesSize = Int64(size) ?? 0
return ByteCountFormatter.string(fromByteCount: bytesSize, countStyle: .file)
}
private static func humaReadableDate(_ serverDate: String) -> String {
let serverDateFormatter = DateFormatter()
serverDateFormatter.locale = Locale(identifier: "en_US_POSIX")
serverDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
let humanDateFormatter = DateFormatter()
humanDateFormatter.timeStyle = .none
humanDateFormatter.dateStyle = .medium
return serverDateFormatter.date(from: serverDate).flatMap(humanDateFormatter.string(from:)) ?? ""
}
}
extension Dictionary {
fileprivate func stringOrEmpty(key: Key) -> String {
return self[key] as? String ?? ""
<*> mode <| Argument(usage: "the app id to show info")
}
}

View file

@ -25,6 +25,7 @@ public struct InstallCommand: CommandProtocol {
self.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
@ -53,13 +54,13 @@ public struct InstallOptions: OptionsProtocol {
public static func create(_ appIds: [Int]) -> (_ forceInstall: Bool) -> InstallOptions {
return { forceInstall in
return InstallOptions(appIds: appIds.map{UInt64($0)}, forceInstall: forceInstall)
return InstallOptions(appIds: appIds.map {UInt64($0)}, forceInstall: forceInstall)
}
}
public static func evaluate(_ m: CommandMode) -> Result<InstallOptions, CommandantError<MASError>> {
public static func evaluate(_ mode: CommandMode) -> Result<InstallOptions, CommandantError<MASError>> {
return create
<*> m <| Argument(usage: "app ID(s) to install")
<*> m <| Switch(flag: nil, key: "force", usage: "force reinstall")
<*> mode <| Argument(usage: "app ID(s) to install")
<*> mode <| Switch(flag: nil, key: "force", usage: "force reinstall")
}
}

View file

@ -24,6 +24,7 @@ public struct ListCommand: CommandProtocol {
self.appLibrary = appLibrary
}
/// Runs the command.
public func run(_ options: Options) -> Result<(), MASError> {
let products = appLibrary.installedApps
if products.isEmpty {

View file

@ -18,34 +18,49 @@ public struct LuckyCommand: CommandProtocol {
public let function = "Install the first result from the Mac App Store"
private let appLibrary: AppLibrary
private let urlSession: URLSession
private let storeSearch: StoreSearch
/// Designated initializer.
///
/// - Parameter appLibrary: AppLibrary manager.
/// - Parameter urlSession: URL session for network communication.
public init(appLibrary: AppLibrary = MasAppLibrary(), urlSession: URLSession = URLSession.shared) {
/// - Parameter storeSearch: Search manager.
public init(appLibrary: AppLibrary = MasAppLibrary(),
storeSearch: StoreSearch = MasStoreSearch()) {
self.appLibrary = appLibrary
self.urlSession = urlSession
self.storeSearch = storeSearch
}
/// Runs the command.
public func run(_ options: Options) -> Result<(), MASError> {
guard let searchURLString = searchURLString(options.appName),
let searchJson = urlSession.requestSynchronousJSONWithURLString(searchURLString) as? [String: Any] else {
var appId: Int?
do {
guard let result = try storeSearch.lookup(app: options.appName)
else {
print("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 resultCount = searchJson[ResultKeys.ResultCount] as? Int, resultCount > 0,
let results = searchJson[ResultKeys.Results] as? [[String: Any]] else {
print("No results found")
return .failure(.noSearchResultsFound)
}
guard let identifier = appId else { fatalError() }
let appId = results[0][ResultKeys.TrackId] as! UInt64
return install(appId, options: options)
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<(), MASError> {
// Try to download applications with given identifiers and collect results
let downloadResults = [appId].compactMap { (appId) -> MASError? in
@ -66,13 +81,6 @@ public struct LuckyCommand: CommandProtocol {
return .failure(.downloadFailed(error: nil))
}
}
func searchURLString(_ appName: String) -> String? {
if let urlEncodedAppName = appName.URLEncodedString {
return "https://itunes.apple.com/search?entity=macSoftware&term=\(urlEncodedAppName)&attribute=allTrackTerm"
}
return nil
}
}
public struct LuckyOptions: OptionsProtocol {
@ -85,9 +93,9 @@ public struct LuckyOptions: OptionsProtocol {
}
}
public static func evaluate(_ m: CommandMode) -> Result<LuckyOptions, CommandantError<MASError>> {
public static func evaluate(_ mode: CommandMode) -> Result<LuckyOptions, CommandantError<MASError>> {
return create
<*> m <| Argument(usage: "the app name to install")
<*> m <| Switch(flag: nil, key: "force", usage: "force reinstall")
<*> mode <| Argument(usage: "the app name to install")
<*> mode <| Switch(flag: nil, key: "force", usage: "force reinstall")
}
}

View file

@ -53,8 +53,7 @@ public struct OpenCommand: CommandProtocol {
printError("Open failed: (\(reason)) \(systemOpen.stderr)")
return .failure(.searchFailed)
}
}
catch {
} catch {
// Bubble up MASErrors
if let error = error as? MASError {
return .failure(error)
@ -73,8 +72,8 @@ public struct OpenOptions: OptionsProtocol {
return OpenOptions(appId: appId)
}
public static func evaluate(_ m: CommandMode) -> Result<OpenOptions, CommandantError<MASError>> {
public static func evaluate(_ mode: CommandMode) -> Result<OpenOptions, CommandantError<MASError>> {
return create
<*> m <| Argument(usage: "the app id")
<*> mode <| Argument(usage: "the app id")
}
}

View file

@ -26,13 +26,16 @@ public struct OutdatedCommand: CommandProtocol {
self.appLibrary = appLibrary
}
/// Runs the command.
public func run(_ options: Options) -> Result<(), MASError> {
let updateController = CKUpdateController.shared()
let updates = updateController?.availableUpdates()
for update in updates! {
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))")
print("""
\(update.itemIdentifier) \(update.title) (\(installed.bundleVersion) -> \(update.bundleVersion))
""")
} else {
print("\(update.itemIdentifier) \(update.title) (unknown -> \(update.bundleVersion))")
}

View file

@ -10,6 +10,7 @@ import Commandant
import Result
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"
@ -17,6 +18,7 @@ public struct ResetCommand: CommandProtocol {
public init() {}
/// Runs the command.
public func run(_ options: Options) -> Result<(), MASError> {
/*
The "Reset Application" command in the Mac App Store debug menu performs
@ -43,7 +45,7 @@ public struct ResetCommand: CommandProtocol {
"storeassetd",
"storedownloadd",
"storeinstalld",
"storelegacy",
"storelegacy"
]
let kill = Process()
@ -85,8 +87,8 @@ public struct ResetOptions: OptionsProtocol {
return ResetOptions(debug: debug)
}
public static func evaluate(_ m: CommandMode) -> Result<ResetOptions, CommandantError<MASError>> {
public static func evaluate(_ mode: CommandMode) -> Result<ResetOptions, CommandantError<MASError>> {
return create
<*> m <| Switch(flag: nil, key: "debug", usage: "Enable debug mode")
<*> mode <| Switch(flag: nil, key: "debug", usage: "Enable debug mode")
}
}

View file

@ -9,15 +9,6 @@
import Commandant
import Result
struct ResultKeys {
static let ResultCount = "resultCount"
static let Results = "results"
static let TrackName = "trackName"
static let TrackId = "trackId"
static let Version = "version"
static let Price = "price"
}
/// 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 {
@ -25,66 +16,34 @@ public struct SearchCommand: CommandProtocol {
public let verb = "search"
public let function = "Search for apps from the Mac App Store"
private let urlSession: URLSession
private let storeSearch: StoreSearch
public init(urlSession: URLSession = URLSession.shared) {
self.urlSession = urlSession
/// Designated initializer.
///
/// - Parameter storeSearch: Search manager.
public init(storeSearch: StoreSearch = MasStoreSearch()) {
self.storeSearch = storeSearch
}
public func run(_ options: Options) -> Result<(), MASError> {
guard let searchURLString = searchURLString(options.appName),
let searchJson = urlSession.requestSynchronousJSONWithURLString(searchURLString) as? [String: Any] else {
do {
let resultList = try storeSearch.search(for: options.appName)
if resultList.resultCount <= 0 || resultList.results.isEmpty {
print("No results found")
return .failure(.noSearchResultsFound)
}
let output = SearchResultFormatter.format(results: resultList.results, includePrice: options.price)
print(output)
return .success(())
} catch {
// Bubble up MASErrors
if let error = error as? MASError {
return .failure(error)
}
return .failure(.searchFailed)
}
guard let resultCount = searchJson[ResultKeys.ResultCount] as? Int, resultCount > 0,
let results = searchJson[ResultKeys.Results] as? [[String: Any]] else {
print("No results found")
return .failure(.noSearchResultsFound)
}
// find out longest appName for formatting
var appNameMaxLength = 0
for result in results {
if let appName = result[ResultKeys.TrackName] as? String {
if appName.count > appNameMaxLength {
appNameMaxLength = appName.count
}
}
}
if appNameMaxLength > 50 {
appNameMaxLength = 50
}
for result in results {
if let appName = result[ResultKeys.TrackName] as? String,
let appVersion = result[ResultKeys.Version] as? String,
let appId = result[ResultKeys.TrackId] as? Int,
let appPrice = result[ResultKeys.Price] as? Double {
// add empty spaces to app name that every app name has the same length
let countedAppName = String((appName + String(repeating: " ", count: appNameMaxLength)).prefix(appNameMaxLength))
if options.price {
print(String(format:"%12d %@ $%5.2f (%@)", appId, countedAppName, appPrice, appVersion))
} else {
print(String(format:"%12d %@ (%@)", appId, countedAppName, appVersion))
}
}
}
return .success(())
}
/// Builds a URL to search the MAS for an app
///
/// - Parameter appName: Name of the app to find.
/// - Returns: String URL for app search or nil if the app name could not be encoded.
func searchURLString(_ appName: String) -> String? {
guard let urlEncodedAppName = appName.URLEncodedString else { return nil }
return "https://itunes.apple.com/search?entity=macSoftware&term=\(urlEncodedAppName)&attribute=allTrackTerm"
}
}
@ -98,9 +57,9 @@ public struct SearchOptions: OptionsProtocol {
}
}
public static func evaluate(_ m: CommandMode) -> Result<SearchOptions, CommandantError<MASError>> {
public static func evaluate(_ mode: CommandMode) -> Result<SearchOptions, CommandantError<MASError>> {
return create
<*> m <| Argument(usage: "the app name to search")
<*> m <| Option(key: "price", defaultValue: false, usage: "Show price of found apps")
<*> mode <| Argument(usage: "the app name to search")
<*> mode <| Option(key: "price", defaultValue: false, usage: "Show price of found apps")
}
}

View file

@ -17,8 +17,8 @@ public struct SignInCommand: CommandProtocol {
public init() {}
/// Runs the command.
public func run(_ options: Options) -> Result<(), MASError> {
if #available(macOS 10.13, *) {
return .failure(.signInDisabled)
}
@ -29,7 +29,7 @@ public struct SignInCommand: CommandProtocol {
do {
printInfo("Signing in to Apple ID: \(options.username)")
let password: String = {
if options.password == "" && !options.dialog {
return String(validatingUTF8: getpass("Password: "))!
@ -37,11 +37,11 @@ public struct SignInCommand: CommandProtocol {
return options.password
}()
let _ = try ISStoreAccount.signIn(username: options.username, password: password, systemDialog: options.dialog)
_ = try ISStoreAccount.signIn(username: options.username, password: password, systemDialog: options.dialog)
} catch let error as NSError {
return .failure(.signInFailed(error: error))
}
return .success(())
}
}
@ -60,10 +60,10 @@ public struct SignInOptions: OptionsProtocol {
}}
}
public static func evaluate(_ m: CommandMode) -> Result<SignInOptions, CommandantError<MASError>> {
public static func evaluate(_ mode: CommandMode) -> Result<SignInOptions, CommandantError<MASError>> {
return create
<*> m <| Argument(usage: "Apple ID")
<*> m <| Argument(defaultValue: "", usage: "Password")
<*> m <| Option(key: "dialog", defaultValue: false, usage: "Complete login with graphical dialog")
<*> mode <| Argument(usage: "Apple ID")
<*> mode <| Argument(defaultValue: "", usage: "Password")
<*> mode <| Option(key: "dialog", defaultValue: false, usage: "Complete login with graphical dialog")
}
}

View file

@ -16,13 +16,13 @@ public struct SignOutCommand: CommandProtocol {
public let function = "Sign out of the Mac App Store"
public init() {}
/// Runs the command.
public func run(_ options: Options) -> Result<(), MASError> {
if #available(macOS 10.13, *) {
let accountService: ISAccountService = ISServiceProxy.genericShared().accountService
accountService.signOut()
}
else {
} else {
// Using CKAccountStore to sign out does nothing on High Sierra
// https://github.com/mas-cli/mas/issues/129
CKAccountStore.shared().signOut()

View file

@ -46,8 +46,7 @@ public struct UninstallCommand: CommandProtocol {
do {
try appLibrary.uninstallApp(app: product)
}
catch {
} catch {
return .failure(.uninstallFailed)
}
@ -69,9 +68,9 @@ public struct UninstallOptions: OptionsProtocol {
}
}
public static func evaluate(_ m: CommandMode) -> Result<UninstallOptions, CommandantError<MASError>> {
public static func evaluate(_ mode: CommandMode) -> Result<UninstallOptions, CommandantError<MASError>> {
return create
<*> m <| Argument(usage: "ID of app to uninstall")
<*> m <| Switch(flag: nil, key: "dry-run", usage: "dry run")
<*> mode <| Argument(usage: "ID of app to uninstall")
<*> mode <| Switch(flag: nil, key: "dry-run", usage: "dry run")
}
}

View file

@ -20,11 +20,12 @@ public struct UpgradeCommand: CommandProtocol {
/// Designated initializer.
///
/// - Parameter appLibrary: <#appLibrary description#>
/// - Parameter appLibrary: Instance of the app library.
public init(appLibrary: AppLibrary = MasAppLibrary()) {
self.appLibrary = appLibrary
}
/// Runs the command.
public func run(_ options: Options) -> Result<(), MASError> {
let updateController = CKUpdateController.shared()
let updates: [CKUpdate]
@ -47,7 +48,7 @@ public struct UpgradeCommand: CommandProtocol {
updates = appIds.compactMap {
updateController?.availableUpdate(withItemIdentifier: $0)
}
guard updates.count > 0 else {
printWarning("Nothing found to upgrade")
return .success(())
@ -61,10 +62,10 @@ public struct UpgradeCommand: CommandProtocol {
return .success(())
}
}
print("Upgrading \(updates.count) outdated application\(updates.count > 1 ? "s" : ""):")
print(updates.map({ "\($0.title) (\($0.bundleVersion))" }).joined(separator: ", "))
let updateResults = updates.compactMap {
download($0.itemIdentifier.uint64Value)
}
@ -82,13 +83,13 @@ public struct UpgradeCommand: CommandProtocol {
public struct UpgradeOptions: OptionsProtocol {
let apps: [String]
static func create(_ apps: [String]) -> UpgradeOptions {
return UpgradeOptions(apps: apps)
}
public static func evaluate(_ m: CommandMode) -> Result<UpgradeOptions, CommandantError<MASError>> {
public static func evaluate(_ mode: CommandMode) -> Result<UpgradeOptions, CommandantError<MASError>> {
return create
<*> m <| Argument(defaultValue: [], usage: "app(s) to upgrade")
<*> mode <| Argument(defaultValue: [], usage: "app(s) to upgrade")
}
}

View file

@ -36,8 +36,11 @@ public struct VendorCommand: CommandProtocol {
return .failure(.noSearchResultsFound)
}
guard let vendorWebsite = result.sellerUrl
else { throw MASError.noVendorWebsite }
do {
try openCommand.run(arguments: result.sellerUrl)
try openCommand.run(arguments: vendorWebsite)
} catch {
printError("Unable to launch open command")
return .failure(.searchFailed)
@ -47,8 +50,7 @@ public struct VendorCommand: CommandProtocol {
printError("Open failed: (\(reason)) \(openCommand.stderr)")
return .failure(.searchFailed)
}
}
catch {
} catch {
// Bubble up MASErrors
if let error = error as? MASError {
return .failure(error)
@ -67,8 +69,8 @@ public struct VendorOptions: OptionsProtocol {
return VendorOptions(appId: appId)
}
public static func evaluate(_ m: CommandMode) -> Result<VendorOptions, CommandantError<MASError>> {
public static func evaluate(_ mode: CommandMode) -> Result<VendorOptions, CommandantError<MASError>> {
return create
<*> m <| Argument(usage: "the app id to show the vendor's website")
<*> mode <| Argument(usage: "the app id to show the vendor's website")
}
}

View file

@ -17,6 +17,7 @@ public struct VersionCommand: CommandProtocol {
public init() {}
/// Runs the command.
public func run(_ options: Options) -> Result<(), MASError> {
let plist = Bundle.main.infoDictionary
if let versionString = plist?["CFBundleShortVersionString"] {

View file

@ -43,21 +43,19 @@ public protocol AppLibrary {
extension AppLibrary {
/// Map of app name to ID.
public var appIdsByName: [String: UInt64] {
get {
var destMap = [String: UInt64]()
for product in installedApps {
destMap[product.appName] = product.itemIdentifier.uint64Value
}
return destMap
var destMap = [String: UInt64]()
for product in installedApps {
destMap[product.appName] = product.itemIdentifier.uint64Value
}
return destMap
}
/// Finds an app by name.
///
/// - Parameter id: MAS ID for app.
/// - Returns: Software Product of app if found; nil otherwise.
public func installedApp(forId id: UInt64) -> SoftwareProduct? {
let appId = NSNumber(value: id)
public func installedApp(forId identifier: UInt64) -> SoftwareProduct? {
let appId = NSNumber(value: identifier)
return installedApps.first { $0.itemIdentifier == appId }
}

View file

@ -0,0 +1,77 @@
//
// MasStoreSearch.swift
// MasKit
//
// Created by Ben Chatelain on 12/29/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
/// Manages searching the MAS catalog through the iTunes Search and Lookup APIs.
public class MasStoreSearch: StoreSearch {
private let networkManager: NetworkManager
/// Designated initializer.
public init(networkManager: NetworkManager = NetworkManager()) {
self.networkManager = networkManager
}
/// Searches for an app.
///
/// - Parameter appName: MAS ID of app
/// - Returns: Search results list of app. List will have no records if there were no matches. Never nil.
/// - 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 }
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
}
do {
let results = try JSONDecoder().decode(SearchResultList.self, from: data)
return results
} catch {
throw MASError.jsonParsing(error: error as NSError)
}
}
/// Looks up app details.
///
/// - Parameter appId: MAS ID of app
/// - Returns: Search result record of app or nil if no apps match the ID.
/// - Throws: Error if there is a problem with the network request.
public func lookup(app appId: String) throws -> SearchResult? {
guard let url = lookupURL(forApp: appId)
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
}
do {
let results = try JSONDecoder().decode(SearchResultList.self, from: data)
guard let searchResult = results.results.first
else { return nil }
return searchResult
} catch {
throw MASError.jsonParsing(error: error as NSError)
}
}
}

View file

@ -0,0 +1,54 @@
//
// StoreSearch.swift
// MasKit
//
// Created by Ben Chatelain on 12/29/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
/// Protocol for searching the MAS catalog.
public protocol StoreSearch {
func lookup(app appId: String) throws -> SearchResult?
func search(for appName: String) throws -> SearchResultList
}
// 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.
public func searchURL(for appName: String) -> URL? {
guard let urlString = searchURLString(forApp: appName) else { return nil }
return URL(string: urlString)
}
/// Builds the search URL for an app.
///
/// - Parameter appName: Name of app to find.
/// - Returns: String URL for the search service or nil if appName can't be encoded.
func searchURLString(forApp appName: String) -> String? {
if let urlEncodedAppName = appName.URLEncodedString {
return "https://itunes.apple.com/search?entity=macSoftware&term=\(urlEncodedAppName)&attribute=allTrackTerm"
}
return nil
}
/// 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.
public func lookupURL(forApp appId: String) -> URL? {
guard let urlString = lookupURLString(forApp: appId) else { return nil }
return URL(string: urlString)
}
/// Builds the lookup URL for an app.
///
/// - Parameter appId: MAS app identifier.
/// - Returns: String URL for the lookup service or nil if appId can't be encoded.
func lookupURLString(forApp appId: String) -> String? {
guard let urlEncodedAppId = appId.URLEncodedString else { return nil }
return "https://itunes.apple.com/lookup?id=\(urlEncodedAppId)"
}
}

View file

@ -21,10 +21,15 @@ public enum MASError: Error, CustomStringConvertible, Equatable {
case searchFailed
case noSearchResultsFound
case noVendorWebsite
case notInstalled
case uninstallFailed
case urlEncoding
case noData
case jsonParsing(error: NSError?)
public var description: String {
switch self {
case .notSignedIn:
@ -72,11 +77,23 @@ public enum MASError: Error, CustomStringConvertible, Equatable {
case .noSearchResultsFound:
return "No results found"
case .noVendorWebsite:
return "App does not have a vendor website"
case .notInstalled:
return "Not installed"
case .uninstallFailed:
return "Uninstall failed"
case .urlEncoding:
return "Unable to encode service URL"
case .noData:
return "Service did not return data"
case .jsonParsing:
return "Unable to parse response JSON"
}
}
}

View file

@ -0,0 +1,15 @@
//
// Dictionary+StringOrEmpty.swift
// MasKit
//
// Created by Ben Chatelain on 1/7/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
extension Dictionary {
func stringOrEmpty(key: Key) -> String {
return self[key] as? String ?? ""
}
}

View file

@ -0,0 +1,16 @@
//
// String+PercentEncoding.swift
// MasKit
//
// Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
public extension String {
/// Return an URL encoded string
var URLEncodedString: String? {
return addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
}
}

View file

@ -27,27 +27,27 @@ public protocol ExternalCommand {
/// Common implementation
extension ExternalCommand {
public var stdout: String { get {
public var stdout: String {
let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? ""
}}
}
public var stderr: String { get {
public var stderr: String {
let data = stderrPipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? ""
}}
}
public var exitCode: Int? { get {
public var exitCode: Int? {
return Int(process.terminationStatus)
}}
}
public var succeeded: Bool { get {
public var succeeded: Bool {
return exitCode == 0
}}
}
public var failed: Bool { get {
public var failed: Bool {
return !succeeded
}}
}
/// Runs the command.
public func run(arguments: String...) throws {

View file

@ -0,0 +1,57 @@
//
// AppInfoFormatter.swift
// MasKit
//
// Created by Ben Chatelain on 1/7/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
/// Formats text output for the info command.
struct AppInfoFormatter {
/// Formats text output with app info.
///
/// - Parameter app: Search result with app data.
/// - Returns: Multiline text output.
static func format(app: SearchResult) -> String {
let headline = [
"\(app.trackName)",
"\(app.version)",
"[\(app.price)]"
].joined(separator: " ")
return [
headline,
"By: \(app.sellerName)",
"Released: \(humanReadableDate(app.currentVersionReleaseDate))",
"Minimum OS: \(app.minimumOsVersion)",
"Size: \(humanReadableSize(app.fileSizeBytes))",
"From: \(app.trackViewUrl)"
].joined(separator: "\n")
}
/// Formats a file size.
///
/// - Parameter size: Numeric string.
/// - Returns: Formatted file size description.
private static func humanReadableSize(_ size: String) -> String {
let bytesSize = Int64(size) ?? 0
return ByteCountFormatter.string(fromByteCount: bytesSize, countStyle: .file)
}
/// Formats a date in format.
///
/// - Parameter serverDate: String containing a date in ISO-8601 format.
/// - Returns: Simple date format.
private static func humanReadableDate(_ serverDate: String) -> String {
let serverDateFormatter = DateFormatter()
serverDateFormatter.locale = Locale(identifier: "en_US_POSIX")
serverDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
let humanDateFormatter = DateFormatter()
humanDateFormatter.timeStyle = .none
humanDateFormatter.dateStyle = .medium
return serverDateFormatter.date(from: serverDate).flatMap(humanDateFormatter.string(from:)) ?? ""
}
}

View file

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

View file

@ -8,7 +8,6 @@
/// A collection of output formatting helpers
/// Terminal Control Sequence Indicator
let csi = "\u{001B}["
@ -17,7 +16,7 @@ func printInfo(_ message: String) {
print("==> \(message)")
return
}
// Blue bold arrow, Bold text
print("\(csi)1;34m==>\(csi)0m \(csi)1m\(message)\(csi)0m")
}
@ -27,7 +26,7 @@ func printWarning(_ message: String) {
print("Warning: \(message)")
return
}
// Yellow, underlined "Warning:" prefix
print("\(csi)4;33mWarning:\(csi)0m \(message)")
}
@ -37,7 +36,7 @@ public func printError(_ message: String) {
print("Warning: \(message)")
return
}
// Red, underlined "Error:" prefix
print("\(csi)4;31mError:\(csi)0m \(message)")
}

View file

@ -1,42 +0,0 @@
//
// MasStoreSearch.swift
// MasKit
//
// Created by Ben Chatelain on 12/29/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
/// Manages searching the MAS catalog through the iTunes Search and Lookup APIs.
public class MasStoreSearch: StoreSearch {
private let urlSession: URLSession
/// Designated initializer.
public init(urlSession: URLSession = URLSession.shared) {
self.urlSession = urlSession
}
/// Looks up app details.
///
/// - Parameter appId: MAS ID of app
/// - Returns: Search result record of app or nil if no apps match the ID.
/// - Throws: Error if there is a problem with the network request.
public func lookup(app appId: String) throws -> SearchResult? {
guard let lookupURLString = lookupURLString(forApp: appId),
let jsonData = urlSession.requestSynchronousDataWithURLString(lookupURLString)
else {
// network error
throw MASError.searchFailed
}
guard let results = try? JSONDecoder().decode(SearchResultList.self, from: jsonData)
else {
// parse error
throw MASError.searchFailed
}
guard let result = results.results.first
else { return nil }
return result
}
}

View file

@ -0,0 +1,52 @@
//
// SearchResult.swift
// MasKit
//
// Created by Ben Chatelain on 12/29/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
public struct SearchResult: Decodable {
public var bundleId: String
public var currentVersionReleaseDate: String
public var fileSizeBytes: String
public var formattedPrice: String
public var minimumOsVersion: String
public var price: Double
public var sellerName: String
public var sellerUrl: String?
public var trackId: Int
public var trackCensoredName: String
public var trackName: String
public var trackViewUrl: String
public var version: String
init(bundleId: String = "",
currentVersionReleaseDate: String = "",
fileSizeBytes: String = "",
formattedPrice: String = "",
minimumOsVersion: String = "",
price: Double = 0.0,
sellerName: String = "",
sellerUrl: String = "",
trackId: Int = 0,
trackCensoredName: String = "",
trackName: String = "",
trackViewUrl: String = "",
version: String = ""
) {
self.bundleId = bundleId
self.currentVersionReleaseDate = currentVersionReleaseDate
self.fileSizeBytes = fileSizeBytes
self.formattedPrice = formattedPrice
self.minimumOsVersion = minimumOsVersion
self.price = price
self.sellerName = sellerName
self.sellerUrl = sellerUrl
self.trackId = trackId
self.trackCensoredName = trackCensoredName
self.trackName = trackName
self.trackViewUrl = trackViewUrl
self.version = version
}
}

View file

@ -6,7 +6,7 @@
// Copyright © 2018 mas-cli. All rights reserved.
//
struct SearchResultList: Decodable {
public struct SearchResultList: Decodable {
var resultCount: Int
var results: [SearchResult]
}

View file

@ -1,59 +0,0 @@
//
// NSURLSession+Synchronous.swift
// mas-cli
//
// Created by Michael Schneider on 4/14/16.
// Copyright © 2016 Andrew Naylor. All rights reserved.
//
// Synchronous NSURLSession code found at: http://ericasadun.com/2015/11/12/more-bad-things-synchronous-nsurlsessions/
import Foundation
/// NSURLSession synchronous behavior
/// Particularly for playground sessions that need to run sequentially
public extension URLSession {
/// Return data from synchronous URL request
public func requestSynchronousData(_ request: URLRequest) -> Data? {
var data: Data? = nil
let semaphore = DispatchSemaphore(value: 0)
let task = URLSession.shared.dataTask(with: request) {
taskData, _, error -> () in
data = taskData
if data == nil, let error = error {print(error)}
semaphore.signal()
}
task.resume()
let _ = semaphore.wait(timeout: .distantFuture)
return data
}
/// Return data synchronous from specified endpoint
public func requestSynchronousDataWithURLString(_ requestString: String) -> Data? {
guard let url = URL(string:requestString) else {return nil}
let request = URLRequest(url: url)
return requestSynchronousData(request)
}
/// Return JSON synchronous from URL request
public func requestSynchronousJSON(_ request: URLRequest) -> Any? {
guard let data = requestSynchronousData(request) else {return nil}
return try! JSONSerialization.jsonObject(with: data, options: [])
}
/// Return JSON synchronous from specified endpoint
@objc public func requestSynchronousJSONWithURLString(_ requestString: String) -> Any? {
guard let url = URL(string: requestString) else {return nil}
var request = URLRequest(url:url)
request.httpMethod = "GET"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
return requestSynchronousJSON(request)
}
}
public extension String {
/// Return an URL encoded string
var URLEncodedString: String? {
return addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
}
}

View file

@ -0,0 +1,60 @@
//
// NetworkManager.swift
// MasKit
//
// Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
/// Network abstraction
public class NetworkManager {
enum NetworkError: Error {
case timeout
}
private let session: NetworkSession
/// Designated initializer
///
/// - Parameter session: A networking session.
public init(session: NetworkSession = URLSession.shared) {
self.session = session
}
/// Loads data asynchronously.
///
/// - Parameters:
/// - url: URL to load data from.
/// - completionHandler: Closure where result is delivered.
func loadData(from url: URL, completionHandler: @escaping (NetworkResult) -> Void) {
session.loadData(from: url) { (data: Data?, error: Error?) in
let result: NetworkResult = data != nil
? .success(data!)
: .failure(error!)
completionHandler(result)
}
}
/// Loads data synchronously.
///
/// - Parameter url: URL to load data from.
/// - Returns: Network result containing either Data or an Error.
func loadDataSync(from url: URL) -> NetworkResult {
var syncResult: NetworkResult?
let semaphore = DispatchSemaphore(value: 0)
loadData(from: url) { asyncResult in
syncResult = asyncResult
semaphore.signal()
}
_ = semaphore.wait(timeout: .distantFuture)
guard let result = syncResult else {
return .failure(NetworkError.timeout)
}
return result
}
}

View file

@ -0,0 +1,30 @@
//
// NetworkResult.swift
// MasKit
//
// Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
enum NetworkResult {
case success(Data)
case failure(Error)
}
extension NetworkResult: Equatable {
static func == (lhs: NetworkResult, rhs: NetworkResult) -> Bool {
switch (lhs, rhs) {
case (let .success(data1), let .success(data2)):
return data1 == data2
case (let .failure(error1), let .failure(error2)):
return error1.localizedDescription == error2.localizedDescription
// case (.none, .none):
// return true
default:
return false
}
}
}

View file

@ -0,0 +1,11 @@
//
// NetworkSession.swift
// MasKit
//
// Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
@objc public protocol NetworkSession {
@objc func loadData(from url: URL, completionHandler: @escaping (Data?, Error?) -> Void)
}

View file

@ -0,0 +1,18 @@
//
// URLSession+NetworkSession.swift
// MasKit
//
// Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
extension URLSession: NetworkSession {
@objc open func loadData(from url: URL, completionHandler: @escaping (Data?, Error?) -> Void) {
let task = dataTask(with: url) { (data, _, error) in
completionHandler(data, error)
}
task.resume()
}
}

View file

@ -1,18 +0,0 @@
//
// SearchResult.swift
// MasKit
//
// Created by Ben Chatelain on 12/29/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
public struct SearchResult: Decodable {
public var bundleId: String
public var price: Double
public var sellerName: String
public var sellerUrl: String
public var trackId: Int
public var trackName: String
public var trackViewUrl: String
public var version: String
}

View file

@ -1,26 +0,0 @@
//
// StoreSearch.swift
// MasKit
//
// Created by Ben Chatelain on 12/29/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
/// Protocol for searching the MAS catalog.
public protocol StoreSearch {
func lookupURLString(forApp: String) -> String?
func lookup(app appId: String) throws -> SearchResult?
}
extension StoreSearch {
/// Builds the lookup URL for an app.
///
/// - Parameter appId: MAS app identifier.
/// - Returns: A string URL for the lookup service or nil if the appId can't be encoded.
public func lookupURLString(forApp appId: String) -> String? {
if let urlEncodedAppId = appId.URLEncodedString {
return "https://itunes.apple.com/lookup?id=\(urlEncodedAppId)"
}
return nil
}
}

View file

@ -14,17 +14,12 @@ import Nimble
class HomeCommandSpec: QuickSpec {
override func spec() {
let result = SearchResult(
bundleId: "",
price: 0.0,
sellerName: "",
sellerUrl: "",
trackId: 1111,
trackName: "",
trackViewUrl: "mas preview url",
version: "0.0"
)
let storeSearch = MockStoreSearch()
let openCommand = MockOpenSystemCommand()
let storeSearch = StoreSearchMock()
let openCommand = OpenSystemCommandMock()
let cmd = HomeCommand(storeSearch: storeSearch, openCommand: openCommand)
describe("home command") {

View file

@ -13,12 +13,58 @@ import Nimble
class InfoCommandSpec: QuickSpec {
override 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: Jan 7, 2019
Minimum OS: 10.14
Size: 1 KB
From: https://awesome.app
"""
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") {
let cmd = InfoCommand()
let result = cmd.run(InfoCommand.Options(appId: ""))
print(result)
// expect(result).to(beSuccess())
storeSearch.apps[result.trackId] = result
let output = OutputListener()
output.openConsolePipe()
let result = cmd.run(InfoCommand.Options(appId: result.trackId.description))
expect(result).to(beSuccess())
// output is async so need to wait for contents to be updated
expect(output.contents).toEventuallyNot(beEmpty())
expect(output.contents) == expectedOutput
output.closeConsolePipe()
}
}
}

View file

@ -14,17 +14,12 @@ import Nimble
class OpenCommandSpec: QuickSpec {
override func spec() {
let result = SearchResult(
bundleId: "",
price: 0.0,
sellerName: "",
sellerUrl: "",
trackId: 1111,
trackName: "",
trackViewUrl: "fakescheme://some/url",
version: "0.0"
)
let storeSearch = MockStoreSearch()
let openCommand = MockOpenSystemCommand()
let storeSearch = StoreSearchMock()
let openCommand = OpenSystemCommandMock()
let cmd = OpenCommand(storeSearch: storeSearch, openCommand: openCommand)
describe("open command") {

View file

@ -13,12 +13,33 @@ import Nimble
class SearchCommandSpec: QuickSpec {
override func spec() {
let result = SearchResult(
trackId: 1111,
trackName: "slack",
trackViewUrl: "mas preview url",
version: "0.0"
)
let storeSearch = StoreSearchMock()
describe("search command") {
it("updates stuff") {
let cmd = SearchCommand()
let result = cmd.run(SearchCommand.Options(appName: "", price: false))
print(result)
// expect(result).to(beSuccess())
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,62 +0,0 @@
//
// SearchSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 11/12/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
@testable import MasKit
import Result
import Quick
import Nimble
class SearchSpec: QuickSpec {
override func spec() {
describe("search") {
context("for slack") {
it("succeeds") {
let search = SearchCommand(urlSession: MockURLSession(responseFile: "search/slack.json"))
let searchOptions = SearchOptions(appName: "slack", price: false)
let result = search.run(searchOptions)
expect(result).to(beSuccess())
}
}
context("for nonexistent") {
it("fails") {
let search = SearchCommand(urlSession: MockURLSession(responseFile: "search/nonexistent.json"))
let searchOptions = SearchOptions(appName: "nonexistent", price: false)
let result = search.run(searchOptions)
expect(result).to(beFailure { error in
expect(error) == .noSearchResultsFound
})
}
}
describe("url string") {
it("contains the app name") {
let appName = "myapp"
let search = SearchCommand()
let urlString = search.searchURLString(appName)
expect(urlString) ==
"https://itunes.apple.com/search?entity=macSoftware&term=\(appName)&attribute=allTrackTerm"
}
it("contains the encoded app name") {
let appName = "My App"
let appNameEncoded = "My%20App"
let search = SearchCommand()
let urlString = search.searchURLString(appName)
expect(urlString) ==
"https://itunes.apple.com/search?entity=macSoftware&term=\(appNameEncoded)&attribute=allTrackTerm"
}
// FIXME: Find a character that causes addingPercentEncoding(withAllowedCharacters to return nil
xit("is nil when app name cannot be url encoded") {
let appName = "`~!@#$%^&*()_+ 💩"
let search = SearchCommand()
let urlString = search.searchURLString(appName)
expect(urlString).to(beNil())
}
}
}
}
}

View file

@ -15,13 +15,13 @@ class UninstallCommandSpec: QuickSpec {
override func spec() {
describe("uninstall command") {
let appId = 12345
let app = MockSoftwareProduct(
let app = SoftwareProductMock(
appName: "Some App",
bundlePath: "/tmp/Some.app",
bundleVersion: "1.0",
itemIdentifier: NSNumber(value: appId)
)
let mockLibrary = MockAppLibrary()
let mockLibrary = AppLibraryMock()
let uninstall = UninstallCommand(appLibrary: mockLibrary)
context("dry run") {

View file

@ -14,17 +14,12 @@ import Nimble
class VendorCommandSpec: QuickSpec {
override func spec() {
let result = SearchResult(
bundleId: "",
price: 0.0,
sellerName: "",
sellerUrl: "",
trackId: 1111,
trackName: "",
trackViewUrl: "https://awesome.app",
version: "0.0"
)
let storeSearch = MockStoreSearch()
let openCommand = MockOpenSystemCommand()
let storeSearch = StoreSearchMock()
let openCommand = OpenSystemCommandMock()
let cmd = VendorCommand(storeSearch: storeSearch, openCommand: openCommand)
describe("vendor command") {

View file

@ -1,5 +1,5 @@
//
// MockAppLibrary.swift
// AppLibraryMock.swift
// MasKitTests
//
// Created by Ben Chatelain on 12/27/18.
@ -8,7 +8,7 @@
@testable import MasKit
class MockAppLibrary: AppLibrary {
class AppLibraryMock: AppLibrary {
var installedApps = [SoftwareProduct]()
/// Finds an app using a bundle identifier.
@ -34,7 +34,7 @@ class MockAppLibrary: AppLibrary {
}
/// Members not part of the AppLibrary protocol that are only for test state managment.
extension MockAppLibrary {
extension AppLibraryMock {
/// Clears out the list of installed apps.
func reset() {
installedApps = []

View file

@ -0,0 +1,63 @@
//
// MasStoreSearchSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/4/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
@testable import MasKit
import Result
import Quick
import Nimble
class MasStoreSearchSpec: QuickSpec {
override func spec() {
describe("store search") {
it("can find slack") {
let networkSession = NetworkSessionMockFromFile(responseFile: "search/slack.json")
let storeSearch = MasStoreSearch(networkManager: NetworkManager(session: networkSession))
var searchList: SearchResultList
do {
searchList = try storeSearch.search(for: "slack")
expect(searchList.resultCount) == 6
expect(searchList.results.count) == 6
} catch {
let maserror = error as! MASError
if case .jsonParsing(let nserror) = maserror {
fail("\(maserror) \(nserror!)")
}
}
}
}
describe("store lookup") {
it("can find slack") {
let appId = 803453959
let networkSession = NetworkSessionMockFromFile(responseFile: "lookup/slack.json")
let storeSearch = MasStoreSearch(networkManager: NetworkManager(session: networkSession))
var lookup: SearchResult?
do {
lookup = try storeSearch.lookup(app: appId.description)
} catch {
let maserror = error as! MASError
if case .jsonParsing(let nserror) = maserror {
fail("\(maserror) \(nserror!)")
}
}
guard let result = lookup else { fatalError("lookup result was nil") }
expect(result.trackId) == appId
expect(result.bundleId) == "com.tinyspeck.slackmacgap"
expect(result.price) == 0
expect(result.sellerName) == "Slack Technologies, Inc."
expect(result.sellerUrl) == "https://slack.com"
expect(result.trackName) == "Slack"
expect(result.trackViewUrl) == "https://itunes.apple.com/us/app/slack/id803453959?mt=12&uo=4"
expect(result.version) == "3.3.3"
}
}
}
}

View file

@ -1,5 +1,5 @@
//
// MockStoreSearch.swift
// StoreSearchMock.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/4/19.
@ -8,9 +8,14 @@
@testable import MasKit
class MockStoreSearch: StoreSearch {
class StoreSearchMock: StoreSearch {
var apps: [Int: SearchResult] = [:]
func search(for appName: String) throws -> SearchResultList {
let filtered = apps.filter { $1.trackName.contains(appName) }
return SearchResultList(resultCount: filtered.count, results: filtered.map { $1 })
}
func lookup(app appId: String) throws -> SearchResult? {
guard let number = Int(appId)
else { throw MASError.searchFailed }

View file

@ -0,0 +1,45 @@
//
// StoreSearchSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/11/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
@testable import MasKit
import Result
import Quick
import Nimble
/// Protocol minimal implementation
struct StoreSearchForTesting: StoreSearch {
func lookup(app appId: String) throws -> SearchResult? { return nil }
func search(for appName: String) throws -> SearchResultList { return SearchResultList(resultCount: 0, results: []) }
}
class StoreSearchSpec: QuickSpec {
override func spec() {
let storeSearch = StoreSearchForTesting()
describe("url string") {
it("contains the app name") {
let appName = "myapp"
let urlString = storeSearch.searchURLString(forApp: appName)
expect(urlString) ==
"https://itunes.apple.com/search?entity=macSoftware&term=\(appName)&attribute=allTrackTerm"
}
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?entity=macSoftware&term=\(appNameEncoded)&attribute=allTrackTerm"
}
// Find a character that causes addingPercentEncoding(withAllowedCharacters to return nil
xit("is nil when app name cannot be url encoded") {
let appName = "`~!@#$%^&*()_+ 💩"
let urlString = storeSearch.searchURLString(forApp: appName)
expect(urlString).to(beNil())
}
}
}
}

View file

@ -99,6 +99,11 @@ class MASErrorTestCase: XCTestCase {
XCTAssertEqual(error.description, "No results found")
}
func testNoVendorWebsite() {
error = .noVendorWebsite
XCTAssertEqual(error.description, "App does not have a vendor website")
}
func testNotInstalled() {
error = .notInstalled
XCTAssertEqual(error.description, "Not installed")
@ -108,4 +113,19 @@ class MASErrorTestCase: XCTestCase {
error = .uninstallFailed
XCTAssertEqual(error.description, "Uninstall failed")
}
func testUrlEncoding() {
error = .urlEncoding
XCTAssertEqual(error.description, "Unable to encode service URL")
}
func testNoData() {
error = .noData
XCTAssertEqual(error.description, "Service did not return data")
}
func testJsonParsing() {
error = .jsonParsing(error: nil)
XCTAssertEqual(error.description, "Unable to parse response JSON")
}
}

View file

@ -0,0 +1,32 @@
//
// Bundle+JSON.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
extension Bundle {
/// Locates a JSON response file from the test bundle.
///
/// - Parameter fileName: Name of file to locate.
/// - Returns: URL to file.
static func jsonResponse(fileName: String) -> URL? {
return Bundle(for: NetworkSessionMock.self).fileURL(fileName: fileName)
}
/// Builds a URL for a file in the JSON directory of the current bundle.
///
/// - Parameter fileName: Name of file to locate.
/// - Returns: URL to file.
func fileURL(fileName: String) -> URL? {
guard let path = self.path(forResource: fileName.fileNameWithoutExtension,
ofType: fileName.fileExtension,
inDirectory: "JSON")
else { fatalError("Unable to load file \(fileName)") }
return URL(fileURLWithPath: path)
}
}

View file

@ -0,0 +1,21 @@
//
// String+FileExtension.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
extension String {
/// Returns the file name before the extension.
var fileNameWithoutExtension: String {
return (self as NSString).deletingPathExtension
}
/// Returns the file extension.
var fileExtension: String {
return (self as NSString).pathExtension
}
}

View file

@ -1,5 +1,5 @@
//
// MockOpenSystemCommand.swift
// OpenSystemCommandMock.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/4/19.
@ -8,7 +8,7 @@
@testable import MasKit
class MockOpenSystemCommand: ExternalCommand {
class OpenSystemCommandMock: ExternalCommand {
// Stub out protocol logic
var succeeded = true
var arguments: [String]?

View file

@ -1,40 +0,0 @@
//
// MasStoreSearchSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/4/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
@testable import MasKit
import Result
import Quick
import Nimble
class MasStoreSearchSpec: QuickSpec {
override func spec() {
let appId = 803453959
let urlSession = MockURLSession(responseFile: "lookup/slack.json")
let storeSearch = MasStoreSearch(urlSession: urlSession)
describe("store search") {
it("can find slack") {
// FIXME: Doesn't work offline
// 2019-01-05 08:37:33.764724-0700 xctest[76864:1854467] TIC TCP Conn Failed [1:0x100c90f90]: 1:50 Err(50)
// 2019-01-05 08:37:33.774861-0700 xctest[76864:1854467] Task <8D1421BF-F9A3-4716-BCB0-803438C7E3E8>.<1> HTTP load failed (error code: -1009 [1:50])
// 2019-01-05 08:37:33.774983-0700 xctest[76864:1854467] Task <8D1421BF-F9A3-4716-BCB0-803438C7E3E8>.<1> finished with error - code: -1009
// Error Domain=NSURLErrorDomain Code=-1009 "The Internet connection appears to be offline."
let result = try! storeSearch.lookup(app: appId.description)
expect(result).toNot(beNil())
expect(result!.trackId) == appId
expect(result!.bundleId) == "com.tinyspeck.slackmacgap"
expect(result!.price) == 0
expect(result!.sellerName) == "Slack Technologies, Inc."
expect(result!.sellerUrl) == "https://slack.com"
expect(result!.trackName) == "Slack"
expect(result!.trackViewUrl) == "https://itunes.apple.com/us/app/slack/id803453959?mt=12&uo=4"
expect(result!.version) == "3.3.3"
}
}
}
}

View file

@ -1,82 +0,0 @@
//
// MockURLSession.swift
// MasKitTests
//
// Created by Ben Chatelain on 11/13/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
@testable import MasKit
/// Mock URLSession for testing.
// FIXME: allow mock url session to operate offline
//2019-01-04 17:20:41.741632-0800 xctest[76410:1817605] TIC TCP Conn Failed [3:0x100a67420]: 1:50 Err(50)
//2019-01-04 17:20:41.741849-0800 xctest[76410:1817605] Task <0C05E774-1CDE-48FB-9408-AFFCD12F3F60>.<3> HTTP load failed (error code: -1009 [1:50])
//2019-01-04 17:20:41.741903-0800 xctest[76410:1817605] Task <0C05E774-1CDE-48FB-9408-AFFCD12F3F60>.<3> finished with error - code: -1009
//Error Domain=NSURLErrorDomain Code=-1009 "The Internet connection appears to be offline." UserInfo={NSUnderlyingError=0x100a692f0 {Error Domain=kCFErrorDomainCFNetwork Code=-1009 "(null)" UserInfo={_kCFStreamErrorCodeKey=50, _kCFStreamErrorDomainKey=1}}, NSErrorFailingURLStringKey=https://itunes.apple.com/lookup?id=803453959, NSErrorFailingURLKey=https://itunes.apple.com/lookup?id=803453959, _kCFStreamErrorDomainKey=1, _kCFStreamErrorCodeKey=50, NSLocalizedDescription=The Internet connection appears to be offline.}
// Fatal error: 'try!' expression unexpectedly raised an error: Search failed: file /BuildRoot/Library/Caches/com.apple.xbs/Sources/swiftlang_Fall2018/swiftlang_Fall2018-1000.11.42/src/swift/stdlib/public/core/ErrorType.swift, line 184
// 2019-01-04 17:20:41.818432-0800 xctest[76410:1817499] Fatal error: 'try!' expression unexpectedly raised an error: Search failed: file /BuildRoot/Library/Caches/com.apple.xbs/Sources/swiftlang_Fall2018/swiftlang_Fall2018-1000.11.42/src/swift/stdlib/public/core/ErrorType.swift, line 184
class MockURLSession: URLSession {
private let responseFile: String
/// Initializes a mock URL session with a file for the response.
///
/// - Parameter responseFile: Name of file containing JSON response body.
init(responseFile: String) {
self.responseFile = responseFile
}
/// Override which returns JSON contents from a file.
///
/// - Parameter requestString: Ignored URL string
/// - Returns: Contents of responseFile
@objc override func requestSynchronousJSONWithURLString(_ requestString: String) -> Any? {
guard let fileURL = Bundle.jsonResponse(fileName: responseFile)
else { fatalError("Unable to load file \(responseFile)") }
do {
let data = try Data(contentsOf: fileURL, options: .mappedIfSafe)
let jsonResult = try JSONSerialization.jsonObject(with: data, options: .mutableLeaves)
if let jsonResult = jsonResult as? Dictionary<String, AnyObject> {
return jsonResult
}
} catch {
print("Error parsing JSON: \(error)")
}
return nil
}
}
extension Bundle {
/// Locates a JSON response file from the test bundle.
///
/// - Parameter fileName: Name of file to locate.
/// - Returns: URL to file.
static func jsonResponse(fileName: String) -> URL? {
return Bundle(for: MockURLSession.self).fileURL(fileName: fileName)
}
/// Builds a URL for a file in the JSON directory of the current bundle.
///
/// - Parameter fileName: Name of file to locate.
/// - Returns: URL to file.
func fileURL(fileName: String) -> URL? {
guard let path = self.path(forResource: fileName.fileNameWithoutExtension, ofType: fileName.fileExtension, inDirectory: "JSON")
else { fatalError("Unable to load file \(fileName)") }
return URL(fileURLWithPath: path)
}
}
extension String {
/// Returns the file name before the extension.
var fileNameWithoutExtension: String {
return (self as NSString).deletingPathExtension
}
/// Returns the file extension.
var fileExtension: String {
return (self as NSString).pathExtension
}
}

View file

@ -1,5 +1,5 @@
//
// MockSoftwareProduct.swift
// SoftwareProductMock.swift
// MasKitTests
//
// Created by Ben Chatelain on 12/27/18.
@ -8,7 +8,7 @@
@testable import MasKit
struct MockSoftwareProduct: SoftwareProduct {
struct SoftwareProductMock: SoftwareProduct {
var appName: String
var bundlePath: String
var bundleVersion: String

View file

@ -0,0 +1,78 @@
//
// NetworkManagerTests.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
@testable import MasKit
import XCTest
class NetworkManagerTests: XCTestCase {
func testSuccessfulAsyncResponse() {
// Setup our objects
let session = NetworkSessionMock()
let manager = NetworkManager(session: session)
// Create data and tell the session to always return it
let data = Data(bytes: [0, 1, 0, 1])
session.data = data
// Create a URL (using the file path API to avoid optionals)
let url = URL(fileURLWithPath: "url")
// Perform the request and verify the result
var result: NetworkResult!
manager.loadData(from: url) { result = $0 }
XCTAssertEqual(result, NetworkResult.success(data))
}
func testSuccessfulSyncResponse() {
// Setup our objects
let session = NetworkSessionMock()
let manager = NetworkManager(session: session)
// Create data and tell the session to always return it
let data = Data(bytes: [0, 1, 0, 1])
session.data = data
// Create a URL (using the file path API to avoid optionals)
let url = URL(fileURLWithPath: "url")
// Perform the request and verify the result
let result = manager.loadDataSync(from: url)
XCTAssertEqual(result, NetworkResult.success(data))
}
func testFailureAsyncResponse() {
// Setup our objects
let session = NetworkSessionMock()
let manager = NetworkManager(session: session)
session.error = NetworkManager.NetworkError.timeout
// Create a URL (using the file path API to avoid optionals)
let url = URL(fileURLWithPath: "url")
// Perform the request and verify the result
var result: NetworkResult!
manager.loadData(from: url) { result = $0 }
XCTAssertEqual(result, NetworkResult.failure(NetworkManager.NetworkError.timeout))
}
func testFailureSyncResponse() {
// Setup our objects
let session = NetworkSessionMock()
let manager = NetworkManager(session: session)
session.error = NetworkManager.NetworkError.timeout
// Create a URL (using the file path API to avoid optionals)
let url = URL(fileURLWithPath: "url")
// Perform the request and verify the result
let result = manager.loadDataSync(from: url)
XCTAssertEqual(result, NetworkResult.failure(NetworkManager.NetworkError.timeout))
}
}

View file

@ -0,0 +1,43 @@
//
// NetworkSessionMock
// MasKitTests
//
// Created by Ben Chatelain on 11/13/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
import MasKit
/// Mock NetworkSession for testing.
class NetworkSessionMock: NetworkSession {
typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void
// Properties that enable us to set exactly what data or error
// we want our mocked URLSession to return for any request.
var data: Data?
var error: Error?
/// Creates a mock data task
///
/// - Parameters:
/// - url: unused
/// - completionHandler: Closure which is delivered both data and error properties (only one should be non-nil)
/// - Returns: Mock data task
func dataTask(with url: URL, completionHandler: @escaping CompletionHandler) -> URLSessionDataTask {
let data = self.data
let error = self.error
return URLSessionDataTaskMock {
completionHandler(data, nil, error)
}
}
/// Immediately passes data and error to completion handler.
///
/// - Parameters:
/// - url: unused
/// - completionHandler: Closure which is delivered either data or an error.
@objc func loadData(from url: URL, completionHandler: @escaping (Data?, Error?) -> Void) {
completionHandler(data, error)
}
}

View file

@ -0,0 +1,40 @@
//
// NetworkSessionMockFromFile.swift
// MasKitTests
//
// Created by Ben Chatelain on 2019-01-05.
// Copyright © 2019 mas-cli. All rights reserved.
//
import MasKit
/// Mock NetworkSession for testing with saved JSON response payload files.
class NetworkSessionMockFromFile: NetworkSessionMock {
/// Path to response payload file relative to test bundle.
private let responseFile: String
/// Initializes a mock URL session with a file for the response.
///
/// - Parameter responseFile: Name of file containing JSON response body.
init(responseFile: String) {
self.responseFile = responseFile
}
/// Loads data from a file.
///
/// - Parameters:
/// - url: unused
/// - completionHandler: Closure which is delivered either data or an error.
@objc override func loadData(from url: URL, completionHandler: @escaping (Data?, Error?) -> Void) {
guard let fileURL = Bundle.jsonResponse(fileName: responseFile)
else { fatalError("Unable to load file \(responseFile)") }
do {
let data = try Data(contentsOf: fileURL, options: .mappedIfSafe)
completionHandler(data, nil)
} catch {
print("Error opening file: \(error)")
completionHandler(nil, error)
}
}
}

View file

@ -0,0 +1,38 @@
//
// TestURLSessionDelegate.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
/// Delegate for network requests initiated from tests.
class TestURLSessionDelegate: NSObject, URLSessionDelegate {
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: (URLSession.AuthChallengeDisposition,
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) {
// Allow the self-signed cert.
let credential = URLCredential(trust: challenge.protectionSpace.serverTrust!)
completionHandler(.useCredential, credential)
} else {
// You *have* to call completionHandler, so call
// it to do the default action.
completionHandler(.performDefaultHandling, nil)
}
}
struct Constants {
// A list of hosts you allow self-signed certificates on.
// You'd likely have your dev/test servers here.
// Please don't put your production server here!
static let selfSignedHosts: Set<String> =
["dev.example.com", "test.example.com"]
}
}

View file

@ -0,0 +1,31 @@
//
// URLSessionConfiguration+Test.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
/// Configuration for network requests initiated from tests.
extension URLSessionConfiguration {
/// Just like defaultSessionConfiguration, returns a
/// newly created session configuration object, customised
/// from the default to your requirements.
class func testSessionConfiguration() -> URLSessionConfiguration {
let config = self.default
// Eg we think 60s is too long a timeout time.
config.timeoutIntervalForRequest = 20
// Some headers that are common to all reqeuests.
// Eg my backend needs to be explicitly asked for JSON.
config.httpAdditionalHeaders = ["MyResponseType": "JSON"]
// Eg we want to use pipelining.
config.httpShouldUsePipelining = true
return config
}
}

View file

@ -0,0 +1,24 @@
//
// URLSessionDataTaskMock .swift
// MasKitTests
//
// Created by Ben Chatelain on 1/5/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
// Partial mock subclassing the original class
class URLSessionDataTaskMock: URLSessionDataTask {
private let closure: () -> Void
init(closure: @escaping () -> Void) {
self.closure = closure
}
// We override the 'resume' method and simply call our closure
// instead of actually resuming any task.
override func resume() {
closure()
}
}

View file

@ -0,0 +1,84 @@
//
// OutputListener.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/7/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
/// Test helper for monitoring strings written to stdout. Modified from:
/// https://medium.com/@thesaadismail/eavesdropping-on-swifts-print-statements-57f0215efb42
class OutputListener {
/// consumes the messages on STDOUT
let inputPipe = Pipe()
/// outputs messages back to STDOUT
let outputPipe = Pipe()
/// Buffers strings written to stdout
var contents = ""
init() {
// Set up a read handler which fires when data is written to our inputPipe
inputPipe.fileHandleForReading.readabilityHandler = { [weak self] fileHandle in
strongify(self) { context in
let data = fileHandle.availableData
if let string = String(data: data, encoding: String.Encoding.utf8) {
context.contents += string
}
// Write input back to stdout
context.outputPipe.fileHandleForWriting.write(data)
}
}
}
}
extension OutputListener {
/// Sets up the "tee" of piped output, intercepting stdout then passing it through.
///
/// ## [dup2 documentation](https://linux.die.net/man/2/dup2)
/// `int dup2(int oldfd, int newfd);`
/// `dup2()` makes `newfd` be the copy of `oldfd`, closing `newfd` first if necessary.
func openConsolePipe() {
var dupStatus: Int32
// Copy STDOUT file descriptor to outputPipe for writing strings back to STDOUT
dupStatus = dup2(stdoutFileDescriptor, outputPipe.fileHandleForWriting.fileDescriptor)
// Status should equal newfd
assert(dupStatus == outputPipe.fileHandleForWriting.fileDescriptor)
// Intercept STDOUT with inputPipe
// newFileDescriptor is the pipe's file descriptor and the old file descriptor is STDOUT_FILENO
dupStatus = dup2(inputPipe.fileHandleForWriting.fileDescriptor, stdoutFileDescriptor)
// Status should equal newfd
assert(dupStatus == stdoutFileDescriptor)
// Don't have any tests on stderr yet
// dup2(inputPipe.fileHandleForWriting.fileDescriptor, stderr)
}
/// Tears down the "tee" of piped output.
func closeConsolePipe() {
// Restore stdout
freopen("/dev/stdout", "a", stdout)
[inputPipe.fileHandleForReading, outputPipe.fileHandleForWriting].forEach { file in
file.closeFile()
}
}
}
extension OutputListener {
/// File descriptor for stdout (aka STDOUT_FILENO)
var stdoutFileDescriptor: Int32 {
return FileHandle.standardOutput.fileDescriptor
}
/// File descriptor for stderr (aka STDERR_FILENO)
var stderrFileDescriptor: Int32 {
return FileHandle.standardError.fileDescriptor
}
}

View file

@ -0,0 +1,48 @@
//
// OutputListenerSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/8/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Quick
import Nimble
class OutputListenerSpec: QuickSpec {
override func spec() {
xdescribe("output listener") {
it("can intercept a single line written stdout") {
let output = OutputListener()
output.openConsolePipe()
let expectedOutput = "hi there"
print("hi there", terminator: "")
// output is async so need to wait for contents to be updated
expect(output.contents).toEventuallyNot(beEmpty())
expect(output.contents) == expectedOutput
output.closeConsolePipe()
}
it("can intercept multiple lines written stdout") {
let output = OutputListener()
output.openConsolePipe()
let expectedOutput = """
hi there
"""
print("hi there")
// output is async so need to wait for contents to be updated
expect(output.contents).toEventuallyNot(beEmpty())
expect(output.contents) == expectedOutput
output.closeConsolePipe()
}
}
}
}

View file

@ -0,0 +1,22 @@
//
// Strongify.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/8/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
// https://medium.com/@merowing_/stop-weak-strong-dance-in-swift-3aec6d3563d4
func strongify<Context: AnyObject, Arguments>(_ context: Context?,
closure: @escaping (Context, Arguments) -> Void) -> (Arguments) -> Void {
return { [weak context] arguments in
guard let strongContext = context else { return }
closure(strongContext, arguments)
}
}
func strongify<Context: AnyObject>(_ context: Context?, closure: @escaping (Context) -> Void) {
guard let strongContext = context else { return }
closure(strongContext)
}

View file

@ -4,6 +4,10 @@
A simple 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/master/LICENSE)
[![Swift 4.2](https://img.shields.io/badge/Language-Swift_4.2-orange.svg)](https://swift.org)
[![GitHub Release](https://img.shields.io/github/release/mas-cli/mas.svg)](https://github.com/mas-cli/mas/releases)
[![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com)
[![Pipeline Status](https://jenkins.log-g.co/buildStatus/icon?job=mas-cli/mas/master)](https://jenkins.log-g.co/job/mas-cli/job/mas/job/master/)
## 📲 Install

View file

@ -13,7 +13,6 @@
B537017621A0F94200538F78 /* Commandant.framework in Copy Carthage Frameworks */ = {isa = PBXBuildFile; fileRef = 90CB406B213F4DDD0044E445 /* Commandant.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
B5552928219A1BB900ACB4CA /* CommerceKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F83213A62173EF75008BA8A0 /* CommerceKit.framework */; };
B5552929219A1BC700ACB4CA /* StoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F83213A52173EF75008BA8A0 /* StoreFoundation.framework */; };
B555292D219A1FE700ACB4CA /* SearchSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B555292C219A1FE700ACB4CA /* SearchSpec.swift */; };
B555292E219A218E00ACB4CA /* Quick.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90CB406A213F4DDD0044E445 /* Quick.framework */; };
B555292F219A219100ACB4CA /* Nimble.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90CB406C213F4DDD0044E445 /* Nimble.framework */; };
B5552936219A23FF00ACB4CA /* Nimble.framework in Copy Carthage Frameworks */ = {isa = PBXBuildFile; fileRef = 90CB406C213F4DDD0044E445 /* Nimble.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@ -21,10 +20,29 @@
B576FDF321E03B780016B39D /* MasStoreSearchSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FDF221E03B780016B39D /* MasStoreSearchSpec.swift */; };
B576FDF521E1078F0016B39D /* MASErrorTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FDF421E1078F0016B39D /* MASErrorTestCase.swift */; };
B576FDF721E107AA0016B39D /* OpenSystemCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FDF621E107AA0016B39D /* OpenSystemCommand.swift */; };
B576FDF921E107CA0016B39D /* MockSoftwareProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FDF821E107CA0016B39D /* MockSoftwareProduct.swift */; };
B576FDF921E107CA0016B39D /* SoftwareProductMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FDF821E107CA0016B39D /* SoftwareProductMock.swift */; };
B576FDFA21E1081C0016B39D /* SearchResultList.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B15521D89F5200F3AC59 /* SearchResultList.swift */; };
B576FDFC21E10A610016B39D /* URLSessionConfiguration+Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FDFB21E10A610016B39D /* URLSessionConfiguration+Tests.swift */; };
B576FDFE21E10B660016B39D /* TestURLSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FDFD21E10B660016B39D /* TestURLSessionDelegate.swift */; };
B576FE0021E113610016B39D /* NetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FDFF21E113610016B39D /* NetworkSession.swift */; };
B576FE0221E1139E0016B39D /* URLSession+NetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE0121E1139E0016B39D /* URLSession+NetworkSession.swift */; };
B576FE0421E113E90016B39D /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE0321E113E90016B39D /* NetworkManager.swift */; };
B576FE0821E114A80016B39D /* NetworkResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE0721E114A80016B39D /* NetworkResult.swift */; };
B576FE0C21E116590016B39D /* NetworkManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE0B21E116590016B39D /* NetworkManagerTests.swift */; };
B576FE0E21E1D6310016B39D /* String+PercentEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE0D21E1D6310016B39D /* String+PercentEncoding.swift */; };
B576FE1221E1D82D0016B39D /* NetworkSessionMockFromFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE1121E1D82D0016B39D /* NetworkSessionMockFromFile.swift */; };
B576FE1421E1D8A90016B39D /* Bundle+JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE1321E1D8A90016B39D /* Bundle+JSON.swift */; };
B576FE1621E1D8CB0016B39D /* String+FileExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE1521E1D8CB0016B39D /* String+FileExtension.swift */; };
B576FE1B21E28E8A0016B39D /* NetworkSessionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE1A21E28E8A0016B39D /* NetworkSessionMock.swift */; };
B576FE1D21E28EF70016B39D /* URLSessionDataTaskMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE1C21E28EF70016B39D /* URLSessionDataTaskMock.swift */; };
B576FE2821E423E60016B39D /* Dictionary+StringOrEmpty.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE2721E423E60016B39D /* Dictionary+StringOrEmpty.swift */; };
B576FE2A21E4240B0016B39D /* AppInfoFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE2921E4240B0016B39D /* AppInfoFormatter.swift */; };
B576FE2C21E42A230016B39D /* OutputListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE2B21E42A230016B39D /* OutputListener.swift */; };
B576FE2E21E5A8010016B39D /* Strongify.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE2D21E5A8010016B39D /* Strongify.swift */; };
B576FE3021E5BD130016B39D /* OutputListenerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE2F21E5BD130016B39D /* OutputListenerSpec.swift */; };
B576FE3321E985250016B39D /* SearchResultFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE3221E985250016B39D /* SearchResultFormatter.swift */; };
B576FE3521E98AAE0016B39D /* StoreSearchSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576FE3421E98AAE0016B39D /* StoreSearchSpec.swift */; };
B5793E29219BDD4800135B39 /* JSON in Resources */ = {isa = PBXBuildFile; fileRef = B5793E28219BDD4800135B39 /* JSON */; };
B5793E2B219BE0CD00135B39 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5793E2A219BE0CD00135B39 /* MockURLSession.swift */; };
B588CE0221DC89490047D305 /* ExternalCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = B588CE0121DC89490047D305 /* ExternalCommand.swift */; };
B588CE0421DC8AFB0047D305 /* TrashCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = B588CE0321DC8AFB0047D305 /* TrashCommand.swift */; };
B594B12021D53A8200F3AC59 /* Uninstall.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B11F21D53A8200F3AC59 /* Uninstall.swift */; };
@ -33,7 +51,7 @@
B594B12721D5825800F3AC59 /* AppLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B12621D5825800F3AC59 /* AppLibrary.swift */; };
B594B12921D5831D00F3AC59 /* SoftwareProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B12821D5831D00F3AC59 /* SoftwareProduct.swift */; };
B594B12B21D5837200F3AC59 /* CKSoftwareProduct+SoftwareProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B12A21D5837200F3AC59 /* CKSoftwareProduct+SoftwareProduct.swift */; };
B594B12E21D5850700F3AC59 /* MockAppLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B12D21D5850700F3AC59 /* MockAppLibrary.swift */; };
B594B12E21D5850700F3AC59 /* AppLibraryMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B12D21D5850700F3AC59 /* AppLibraryMock.swift */; };
B594B13021D5855D00F3AC59 /* MasAppLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B12F21D5855D00F3AC59 /* MasAppLibrary.swift */; };
B594B13221D5876200F3AC59 /* ResultPredicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B13121D5876200F3AC59 /* ResultPredicates.swift */; };
B594B13621D6D68600F3AC59 /* VersionCommandSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B13521D6D68600F3AC59 /* VersionCommandSpec.swift */; };
@ -56,8 +74,8 @@
B5DBF80F21DEEB7B00F3B151 /* Vendor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DBF80E21DEEB7B00F3B151 /* Vendor.swift */; };
B5DBF81121DEEC4200F3B151 /* VendorCommandSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DBF81021DEEC4200F3B151 /* VendorCommandSpec.swift */; };
B5DBF81321DEEC7C00F3B151 /* OpenCommandSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DBF81221DEEC7C00F3B151 /* OpenCommandSpec.swift */; };
B5DBF81521E02BA900F3B151 /* MockStoreSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DBF81421E02BA900F3B151 /* MockStoreSearch.swift */; };
B5DBF81721E02E3400F3B151 /* MockOpenSystemCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DBF81621E02E3400F3B151 /* MockOpenSystemCommand.swift */; };
B5DBF81521E02BA900F3B151 /* StoreSearchMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DBF81421E02BA900F3B151 /* StoreSearchMock.swift */; };
B5DBF81721E02E3400F3B151 /* OpenSystemCommandMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DBF81621E02E3400F3B151 /* OpenSystemCommandMock.swift */; };
ED031A7C1B5127C00097692E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED031A7B1B5127C00097692E /* main.swift */; };
F83213892173D3E1008BA8A0 /* CKAccountStore.h in Headers */ = {isa = PBXBuildFile; fileRef = F8FB719B20F2EC4500F56FDC /* CKAccountStore.h */; };
F832138A2173D3E1008BA8A0 /* CKDownloadQueue.h in Headers */ = {isa = PBXBuildFile; fileRef = F8FB719C20F2EC4500F56FDC /* CKDownloadQueue.h */; };
@ -109,7 +127,6 @@
F8FB717920F2B4DD00F56FDC /* Upgrade.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD3B3621C34709400B56B88 /* Upgrade.swift */; };
F8FB717A20F2B4DD00F56FDC /* Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDB6CE8B1BAEC3D400648B4D /* Version.swift */; };
F8FB717B20F2B4DD00F56FDC /* MASError.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED0F238C1B8756E600AE40CD /* MASError.swift */; };
F8FB717C20F2B4DD00F56FDC /* NSURLSession+Synchronous.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693A989A1CBFFAAA0004D3B4 /* NSURLSession+Synchronous.swift */; };
F8FB717D20F2B4DD00F56FDC /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDCBF9541D89CFC7000039C6 /* Utilities.swift */; };
/* End PBXBuildFile section */
@ -180,20 +197,37 @@
/* Begin PBXFileReference section */
693A98981CBFFA760004D3B4 /* Search.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = "<group>"; };
693A989A1CBFFAAA0004D3B4 /* NSURLSession+Synchronous.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSURLSession+Synchronous.swift"; sourceTree = "<group>"; };
8078FAA71EC4F2FB004B5B3F /* Lucky.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Lucky.swift; sourceTree = "<group>"; };
900A1E801DBAC8CB0069B1A8 /* Info.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Info.swift; sourceTree = "<group>"; };
90CB4069213F4DDD0044E445 /* Result.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Result.framework; sourceTree = "<group>"; };
90CB406A213F4DDD0044E445 /* Quick.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Quick.framework; sourceTree = "<group>"; };
90CB406B213F4DDD0044E445 /* Commandant.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Commandant.framework; sourceTree = "<group>"; };
90CB406C213F4DDD0044E445 /* Nimble.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Nimble.framework; sourceTree = "<group>"; };
B555292C219A1FE700ACB4CA /* SearchSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSpec.swift; sourceTree = "<group>"; };
B576FDF221E03B780016B39D /* MasStoreSearchSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MasStoreSearchSpec.swift; sourceTree = "<group>"; };
B576FDF421E1078F0016B39D /* MASErrorTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MASErrorTestCase.swift; sourceTree = "<group>"; };
B576FDF621E107AA0016B39D /* OpenSystemCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenSystemCommand.swift; sourceTree = "<group>"; };
B576FDF821E107CA0016B39D /* MockSoftwareProduct.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockSoftwareProduct.swift; sourceTree = "<group>"; };
B576FDF821E107CA0016B39D /* SoftwareProductMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SoftwareProductMock.swift; sourceTree = "<group>"; };
B576FDFB21E10A610016B39D /* URLSessionConfiguration+Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSessionConfiguration+Tests.swift"; sourceTree = "<group>"; };
B576FDFD21E10B660016B39D /* TestURLSessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestURLSessionDelegate.swift; sourceTree = "<group>"; };
B576FDFF21E113610016B39D /* NetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSession.swift; sourceTree = "<group>"; };
B576FE0121E1139E0016B39D /* URLSession+NetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+NetworkSession.swift"; sourceTree = "<group>"; };
B576FE0321E113E90016B39D /* NetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = "<group>"; };
B576FE0721E114A80016B39D /* NetworkResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkResult.swift; sourceTree = "<group>"; };
B576FE0B21E116590016B39D /* NetworkManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManagerTests.swift; sourceTree = "<group>"; };
B576FE0D21E1D6310016B39D /* String+PercentEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+PercentEncoding.swift"; sourceTree = "<group>"; };
B576FE1121E1D82D0016B39D /* NetworkSessionMockFromFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkSessionMockFromFile.swift; sourceTree = "<group>"; };
B576FE1321E1D8A90016B39D /* Bundle+JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+JSON.swift"; sourceTree = "<group>"; };
B576FE1521E1D8CB0016B39D /* String+FileExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+FileExtension.swift"; sourceTree = "<group>"; };
B576FE1A21E28E8A0016B39D /* NetworkSessionMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkSessionMock.swift; sourceTree = "<group>"; };
B576FE1C21E28EF70016B39D /* URLSessionDataTaskMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSessionDataTaskMock.swift; sourceTree = "<group>"; };
B576FE2721E423E60016B39D /* Dictionary+StringOrEmpty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+StringOrEmpty.swift"; sourceTree = "<group>"; };
B576FE2921E4240B0016B39D /* AppInfoFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoFormatter.swift; sourceTree = "<group>"; };
B576FE2B21E42A230016B39D /* OutputListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputListener.swift; sourceTree = "<group>"; };
B576FE2D21E5A8010016B39D /* Strongify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strongify.swift; sourceTree = "<group>"; };
B576FE2F21E5BD130016B39D /* OutputListenerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputListenerSpec.swift; sourceTree = "<group>"; };
B576FE3221E985250016B39D /* SearchResultFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultFormatter.swift; sourceTree = "<group>"; };
B576FE3421E98AAE0016B39D /* StoreSearchSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreSearchSpec.swift; sourceTree = "<group>"; };
B5793E28219BDD4800135B39 /* JSON */ = {isa = PBXFileReference; lastKnownFileType = folder; path = JSON; sourceTree = "<group>"; };
B5793E2A219BE0CD00135B39 /* MockURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLSession.swift; sourceTree = "<group>"; };
B588CE0121DC89490047D305 /* ExternalCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalCommand.swift; sourceTree = "<group>"; };
B588CE0321DC8AFB0047D305 /* TrashCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrashCommand.swift; sourceTree = "<group>"; };
B594B11F21D53A8200F3AC59 /* Uninstall.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Uninstall.swift; sourceTree = "<group>"; };
@ -202,7 +236,7 @@
B594B12621D5825800F3AC59 /* AppLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLibrary.swift; sourceTree = "<group>"; };
B594B12821D5831D00F3AC59 /* SoftwareProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftwareProduct.swift; sourceTree = "<group>"; };
B594B12A21D5837200F3AC59 /* CKSoftwareProduct+SoftwareProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKSoftwareProduct+SoftwareProduct.swift"; sourceTree = "<group>"; };
B594B12D21D5850700F3AC59 /* MockAppLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAppLibrary.swift; sourceTree = "<group>"; };
B594B12D21D5850700F3AC59 /* AppLibraryMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLibraryMock.swift; sourceTree = "<group>"; };
B594B12F21D5855D00F3AC59 /* MasAppLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasAppLibrary.swift; sourceTree = "<group>"; };
B594B13121D5876200F3AC59 /* ResultPredicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultPredicates.swift; sourceTree = "<group>"; };
B594B13521D6D68600F3AC59 /* VersionCommandSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VersionCommandSpec.swift; sourceTree = "<group>"; };
@ -226,8 +260,8 @@
B5DBF80E21DEEB7B00F3B151 /* Vendor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Vendor.swift; sourceTree = "<group>"; };
B5DBF81021DEEC4200F3B151 /* VendorCommandSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VendorCommandSpec.swift; sourceTree = "<group>"; };
B5DBF81221DEEC7C00F3B151 /* OpenCommandSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenCommandSpec.swift; sourceTree = "<group>"; };
B5DBF81421E02BA900F3B151 /* MockStoreSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreSearch.swift; sourceTree = "<group>"; };
B5DBF81621E02E3400F3B151 /* MockOpenSystemCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOpenSystemCommand.swift; sourceTree = "<group>"; };
B5DBF81421E02BA900F3B151 /* StoreSearchMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreSearchMock.swift; sourceTree = "<group>"; };
B5DBF81621E02E3400F3B151 /* OpenSystemCommandMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSystemCommandMock.swift; sourceTree = "<group>"; };
ED031A781B5127C00097692E /* mas */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = mas; sourceTree = BUILT_PRODUCTS_DIR; };
ED031A7B1B5127C00097692E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
ED0F237E1B87522400AE40CD /* Install.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Install.swift; sourceTree = "<group>"; };
@ -331,6 +365,139 @@
path = Carthage/Build/Mac;
sourceTree = "<group>";
};
B576FE0921E114BD0016B39D /* Network */ = {
isa = PBXGroup;
children = (
B576FE0321E113E90016B39D /* NetworkManager.swift */,
B576FE0721E114A80016B39D /* NetworkResult.swift */,
B576FDFF21E113610016B39D /* NetworkSession.swift */,
B576FE0121E1139E0016B39D /* URLSession+NetworkSession.swift */,
);
path = Network;
sourceTree = "<group>";
};
B576FE0A21E116470016B39D /* Network */ = {
isa = PBXGroup;
children = (
B576FE0B21E116590016B39D /* NetworkManagerTests.swift */,
B576FE1A21E28E8A0016B39D /* NetworkSessionMock.swift */,
B576FE1121E1D82D0016B39D /* NetworkSessionMockFromFile.swift */,
B576FDFD21E10B660016B39D /* TestURLSessionDelegate.swift */,
B576FDFB21E10A610016B39D /* URLSessionConfiguration+Tests.swift */,
B576FE1C21E28EF70016B39D /* URLSessionDataTaskMock.swift */,
);
path = Network;
sourceTree = "<group>";
};
B576FE1721E28E1F0016B39D /* ExternalCommands */ = {
isa = PBXGroup;
children = (
B5DBF81621E02E3400F3B151 /* OpenSystemCommandMock.swift */,
);
path = ExternalCommands;
sourceTree = "<group>";
};
B576FE1821E28E460016B39D /* Nimble */ = {
isa = PBXGroup;
children = (
B594B13121D5876200F3AC59 /* ResultPredicates.swift */,
);
path = Nimble;
sourceTree = "<group>";
};
B576FE1921E28E530016B39D /* Extensions */ = {
isa = PBXGroup;
children = (
B576FE1321E1D8A90016B39D /* Bundle+JSON.swift */,
B576FE1521E1D8CB0016B39D /* String+FileExtension.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
B576FE1E21E2904E0016B39D /* Models */ = {
isa = PBXGroup;
children = (
B594B15321D89DF400F3AC59 /* SearchResult.swift */,
B594B15521D89F5200F3AC59 /* SearchResultList.swift */,
B594B12821D5831D00F3AC59 /* SoftwareProduct.swift */,
);
path = Models;
sourceTree = "<group>";
};
B576FE1F21E290720016B39D /* Extensions */ = {
isa = PBXGroup;
children = (
B576FE2721E423E60016B39D /* Dictionary+StringOrEmpty.swift */,
B576FE0D21E1D6310016B39D /* String+PercentEncoding.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
B576FE2021E2908D0016B39D /* Formatters */ = {
isa = PBXGroup;
children = (
B576FE2921E4240B0016B39D /* AppInfoFormatter.swift */,
B576FE3221E985250016B39D /* SearchResultFormatter.swift */,
EDCBF9541D89CFC7000039C6 /* Utilities.swift */,
);
path = Formatters;
sourceTree = "<group>";
};
B576FE2121E292F80016B39D /* Controllers */ = {
isa = PBXGroup;
children = (
B594B12621D5825800F3AC59 /* AppLibrary.swift */,
B594B12F21D5855D00F3AC59 /* MasAppLibrary.swift */,
B594B15121D89A8B00F3AC59 /* MasStoreSearch.swift */,
B594B14F21D8998000F3AC59 /* StoreSearch.swift */,
);
path = Controllers;
sourceTree = "<group>";
};
B576FE2221E2932B0016B39D /* Controllers */ = {
isa = PBXGroup;
children = (
B594B12D21D5850700F3AC59 /* AppLibraryMock.swift */,
B576FDF221E03B780016B39D /* MasStoreSearchSpec.swift */,
B5DBF81421E02BA900F3B151 /* StoreSearchMock.swift */,
B576FE3421E98AAE0016B39D /* StoreSearchSpec.swift */,
);
path = Controllers;
sourceTree = "<group>";
};
B576FE2321E29CAC0016B39D /* Errors */ = {
isa = PBXGroup;
children = (
ED0F238C1B8756E600AE40CD /* MASError.swift */,
);
path = Errors;
sourceTree = "<group>";
};
B576FE2421E29CE80016B39D /* Errors */ = {
isa = PBXGroup;
children = (
B576FDF421E1078F0016B39D /* MASErrorTestCase.swift */,
);
path = Errors;
sourceTree = "<group>";
};
B576FE2521E29D2D0016B39D /* SupportingFiles */ = {
isa = PBXGroup;
children = (
F8FB715520F2B41400F56FDC /* Info.plist */,
F8FB715420F2B41400F56FDC /* MasKit.h */,
);
path = SupportingFiles;
sourceTree = "<group>";
};
B576FE2621E29DD90016B39D /* SupportingFiles */ = {
isa = PBXGroup;
children = (
F8FB716120F2B41400F56FDC /* Info.plist */,
);
path = SupportingFiles;
sourceTree = "<group>";
};
B588CE0021DC89250047D305 /* ExternalCommands */ = {
isa = PBXGroup;
children = (
@ -354,7 +521,6 @@
B594B14121D6D8EC00F3AC59 /* OutdatedCommandSpec.swift */,
B594B13F21D6D8BF00F3AC59 /* ResetCommandSpec.swift */,
B594B13D21D6D78900F3AC59 /* SearchCommandSpec.swift */,
B555292C219A1FE700ACB4CA /* SearchSpec.swift */,
B594B13B21D6D72E00F3AC59 /* SignInCommandSpec.swift */,
B594B13921D6D70400F3AC59 /* SignOutCommandSpec.swift */,
B594B12421D580BB00F3AC59 /* UninstallCommandSpec.swift */,
@ -365,16 +531,12 @@
path = Commands;
sourceTree = "<group>";
};
B594B12C21D584E800F3AC59 /* Mocks */ = {
B594B12C21D584E800F3AC59 /* Models */ = {
isa = PBXGroup;
children = (
B594B12D21D5850700F3AC59 /* MockAppLibrary.swift */,
B5DBF81621E02E3400F3B151 /* MockOpenSystemCommand.swift */,
B576FDF821E107CA0016B39D /* MockSoftwareProduct.swift */,
B5DBF81421E02BA900F3B151 /* MockStoreSearch.swift */,
B5793E2A219BE0CD00135B39 /* MockURLSession.swift */,
B576FDF821E107CA0016B39D /* SoftwareProductMock.swift */,
);
path = Mocks;
path = Models;
sourceTree = "<group>";
};
ED031A6F1B5127C00097692E = {
@ -460,19 +622,14 @@
children = (
ED0F238E1B87A54700AE40CD /* AppStore */,
ED0F23801B87524700AE40CD /* Commands */,
B576FE2121E292F80016B39D /* Controllers */,
B576FE2321E29CAC0016B39D /* Errors */,
B576FE1F21E290720016B39D /* Extensions */,
B588CE0021DC89250047D305 /* ExternalCommands */,
B594B12621D5825800F3AC59 /* AppLibrary.swift */,
F8FB715520F2B41400F56FDC /* Info.plist */,
B594B12F21D5855D00F3AC59 /* MasAppLibrary.swift */,
ED0F238C1B8756E600AE40CD /* MASError.swift */,
F8FB715420F2B41400F56FDC /* MasKit.h */,
B594B15121D89A8B00F3AC59 /* MasStoreSearch.swift */,
693A989A1CBFFAAA0004D3B4 /* NSURLSession+Synchronous.swift */,
B594B15321D89DF400F3AC59 /* SearchResult.swift */,
B594B15521D89F5200F3AC59 /* SearchResultList.swift */,
B594B12821D5831D00F3AC59 /* SoftwareProduct.swift */,
B594B14F21D8998000F3AC59 /* StoreSearch.swift */,
EDCBF9541D89CFC7000039C6 /* Utilities.swift */,
B576FE2021E2908D0016B39D /* Formatters */,
B576FE1E21E2904E0016B39D /* Models */,
B576FE0921E114BD0016B39D /* Network */,
B576FE2521E29D2D0016B39D /* SupportingFiles */,
);
path = MasKit;
sourceTree = "<group>";
@ -481,12 +638,18 @@
isa = PBXGroup;
children = (
B594B12321D57FF300F3AC59 /* Commands */,
F8FB716120F2B41400F56FDC /* Info.plist */,
B576FE2221E2932B0016B39D /* Controllers */,
B576FE2421E29CE80016B39D /* Errors */,
B576FE1921E28E530016B39D /* Extensions */,
B576FE1721E28E1F0016B39D /* ExternalCommands */,
B5793E28219BDD4800135B39 /* JSON */,
B576FDF421E1078F0016B39D /* MASErrorTestCase.swift */,
B576FDF221E03B780016B39D /* MasStoreSearchSpec.swift */,
B594B12C21D584E800F3AC59 /* Mocks */,
B594B13121D5876200F3AC59 /* ResultPredicates.swift */,
B594B12C21D584E800F3AC59 /* Models */,
B576FE0A21E116470016B39D /* Network */,
B576FE1821E28E460016B39D /* Nimble */,
B576FE2B21E42A230016B39D /* OutputListener.swift */,
B576FE2F21E5BD130016B39D /* OutputListenerSpec.swift */,
B576FE2D21E5A8010016B39D /* Strongify.swift */,
B576FE2621E29DD90016B39D /* SupportingFiles */,
);
path = MasKitTests;
sourceTree = "<group>";
@ -609,6 +772,7 @@
F8FB714F20F2B41400F56FDC /* Frameworks */,
F83213A72173F58B008BA8A0 /* Copy Frameworks */,
F8FB715020F2B41400F56FDC /* Resources */,
B576FE3121E96E6C0016B39D /* SwiftLint */,
);
buildRules = (
);
@ -708,6 +872,27 @@
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
B576FE3121E96E6C0016B39D /* SwiftLint */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = SwiftLint;
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if which -s swiftlint; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
ED031A741B5127C00097692E /* Sources */ = {
isa = PBXSourcesBuildPhase;
@ -733,26 +918,33 @@
F8FB717220F2B4DD00F56FDC /* List.swift in Sources */,
F8FB717320F2B4DD00F56FDC /* Lucky.swift in Sources */,
B594B13021D5855D00F3AC59 /* MasAppLibrary.swift in Sources */,
B576FDF721E107AA0016B39D /* OpenSystemCommand.swift in Sources */,
F8FB717B20F2B4DD00F56FDC /* MASError.swift in Sources */,
B594B15221D89A8B00F3AC59 /* MasStoreSearch.swift in Sources */,
F8FB717C20F2B4DD00F56FDC /* NSURLSession+Synchronous.swift in Sources */,
B576FE0421E113E90016B39D /* NetworkManager.swift in Sources */,
B576FE0821E114A80016B39D /* NetworkResult.swift in Sources */,
B576FE0021E113610016B39D /* NetworkSession.swift in Sources */,
B5DBF80D21DEE4E600F3B151 /* Open.swift in Sources */,
B576FDF721E107AA0016B39D /* OpenSystemCommand.swift in Sources */,
F8FB717420F2B4DD00F56FDC /* Outdated.swift in Sources */,
B576FE2A21E4240B0016B39D /* AppInfoFormatter.swift in Sources */,
F8FB716C20F2B4DD00F56FDC /* PurchaseDownloadObserver.swift in Sources */,
F8FB717520F2B4DD00F56FDC /* Reset.swift in Sources */,
F8FB717620F2B4DD00F56FDC /* Search.swift in Sources */,
B594B15421D89DF400F3AC59 /* SearchResult.swift in Sources */,
B576FDFA21E1081C0016B39D /* SearchResultList.swift in Sources */,
B576FE2821E423E60016B39D /* Dictionary+StringOrEmpty.swift in Sources */,
F8FB717720F2B4DD00F56FDC /* SignIn.swift in Sources */,
F8FB717820F2B4DD00F56FDC /* SignOut.swift in Sources */,
B576FE3321E985250016B39D /* SearchResultFormatter.swift in Sources */,
B594B12921D5831D00F3AC59 /* SoftwareProduct.swift in Sources */,
F8FB716D20F2B4DD00F56FDC /* SSPurchase.swift in Sources */,
F8FB716E20F2B4DD00F56FDC /* StoreAccount.swift in Sources */,
B594B15021D8998000F3AC59 /* StoreSearch.swift in Sources */,
B576FE0E21E1D6310016B39D /* String+PercentEncoding.swift in Sources */,
B588CE0421DC8AFB0047D305 /* TrashCommand.swift in Sources */,
B594B12021D53A8200F3AC59 /* Uninstall.swift in Sources */,
F8FB717920F2B4DD00F56FDC /* Upgrade.swift in Sources */,
B576FE0221E1139E0016B39D /* URLSession+NetworkSession.swift in Sources */,
F8FB717D20F2B4DD00F56FDC /* Utilities.swift in Sources */,
B5DBF80F21DEEB7B00F3B151 /* Vendor.swift in Sources */,
F8FB717A20F2B4DD00F56FDC /* Version.swift in Sources */,
@ -764,29 +956,39 @@
buildActionMask = 2147483647;
files = (
B594B14A21D6D9AE00F3AC59 /* AccountCommandSpec.swift in Sources */,
B594B12E21D5850700F3AC59 /* AppLibraryMock.swift in Sources */,
B576FE1421E1D8A90016B39D /* Bundle+JSON.swift in Sources */,
B594B14E21D8984500F3AC59 /* HomeCommandSpec.swift in Sources */,
B594B14821D6D98400F3AC59 /* InfoCommandSpec.swift in Sources */,
B594B14621D6D95700F3AC59 /* InstallCommandSpec.swift in Sources */,
B594B12221D5416100F3AC59 /* ListCommandSpec.swift in Sources */,
B594B14421D6D91800F3AC59 /* LuckyCommandSpec.swift in Sources */,
B576FDF521E1078F0016B39D /* MASErrorTestCase.swift in Sources */,
B576FDF321E03B780016B39D /* MasStoreSearchSpec.swift in Sources */,
B594B12E21D5850700F3AC59 /* MockAppLibrary.swift in Sources */,
B5DBF81721E02E3400F3B151 /* MockOpenSystemCommand.swift in Sources */,
B5DBF81521E02BA900F3B151 /* MockStoreSearch.swift in Sources */,
B5793E2B219BE0CD00135B39 /* MockURLSession.swift in Sources */,
B576FE0C21E116590016B39D /* NetworkManagerTests.swift in Sources */,
B576FE1B21E28E8A0016B39D /* NetworkSessionMock.swift in Sources */,
B576FE2C21E42A230016B39D /* OutputListener.swift in Sources */,
B576FE1221E1D82D0016B39D /* NetworkSessionMockFromFile.swift in Sources */,
B5DBF81321DEEC7C00F3B151 /* OpenCommandSpec.swift in Sources */,
B5DBF81721E02E3400F3B151 /* OpenSystemCommandMock.swift in Sources */,
B594B14221D6D8EC00F3AC59 /* OutdatedCommandSpec.swift in Sources */,
B594B14021D6D8BF00F3AC59 /* ResetCommandSpec.swift in Sources */,
B576FDF921E107CA0016B39D /* MockSoftwareProduct.swift in Sources */,
B594B13221D5876200F3AC59 /* ResultPredicates.swift in Sources */,
B594B13E21D6D78900F3AC59 /* SearchCommandSpec.swift in Sources */,
B555292D219A1FE700ACB4CA /* SearchSpec.swift in Sources */,
B594B13C21D6D72E00F3AC59 /* SignInCommandSpec.swift in Sources */,
B594B13A21D6D70400F3AC59 /* SignOutCommandSpec.swift in Sources */,
B576FE3021E5BD130016B39D /* OutputListenerSpec.swift in Sources */,
B576FE3521E98AAE0016B39D /* StoreSearchSpec.swift in Sources */,
B576FDF921E107CA0016B39D /* SoftwareProductMock.swift in Sources */,
B5DBF81521E02BA900F3B151 /* StoreSearchMock.swift in Sources */,
B576FE1621E1D8CB0016B39D /* String+FileExtension.swift in Sources */,
B576FDFE21E10B660016B39D /* TestURLSessionDelegate.swift in Sources */,
B594B12521D580BB00F3AC59 /* UninstallCommandSpec.swift in Sources */,
B594B13821D6D6C100F3AC59 /* UpgradeCommandSpec.swift in Sources */,
B576FDFC21E10A610016B39D /* URLSessionConfiguration+Tests.swift in Sources */,
B576FE1D21E28EF70016B39D /* URLSessionDataTaskMock.swift in Sources */,
B576FE2E21E5A8010016B39D /* Strongify.swift in Sources */,
B5DBF81121DEEC4200F3B151 /* VendorCommandSpec.swift in Sources */,
B576FDF521E1078F0016B39D /* MASErrorTestCase.swift in Sources */,
B594B13621D6D68600F3AC59 /* VersionCommandSpec.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -998,7 +1200,7 @@
);
FRAMEWORK_VERSION = A;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = MasKit/Info.plist;
INFOPLIST_FILE = MasKit/SupportingFiles/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks";
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@ -1036,7 +1238,7 @@
);
FRAMEWORK_VERSION = A;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = MasKit/Info.plist;
INFOPLIST_FILE = MasKit/SupportingFiles/Info.plist;
INSTALL_PATH = /Frameworks;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks";
MTL_FAST_MATH = YES;
@ -1068,7 +1270,7 @@
"$(PROJECT_DIR)/Carthage/Build/Mac",
);
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = MasKitTests/Info.plist;
INFOPLIST_FILE = MasKitTests/SupportingFiles/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks";
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@ -1098,7 +1300,7 @@
"$(PROJECT_DIR)/Carthage/Build/Mac",
);
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = MasKitTests/Info.plist;
INFOPLIST_FILE = MasKitTests/SupportingFiles/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks";
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = at.phatbl.MasKitTests;