mirror of
https://github.com/mas-cli/mas
synced 2024-11-24 20:43:10 +00:00
Merge pull request #593 from rgoldberg/592-lint-cleanup
Additional linting / cleanup code
This commit is contained in:
commit
bb67ea5e48
47 changed files with 411 additions and 256 deletions
|
@ -1,41 +1,62 @@
|
|||
{
|
||||
"indentation" : {
|
||||
"spaces" : 4
|
||||
"indentConditionalCompilationBlocks": false,
|
||||
"indentation": {
|
||||
"spaces": 4
|
||||
},
|
||||
"lineLength" : 120,
|
||||
"rules" : {
|
||||
"AllPublicDeclarationsHaveDocumentation" : false,
|
||||
"AlwaysUseLowerCamelCase" : true,
|
||||
"AmbiguousTrailingClosureOverload" : true,
|
||||
"BeginDocumentationCommentWithOneLineSummary" : false,
|
||||
"DoNotUseSemicolons" : true,
|
||||
"DontRepeatTypeInStaticProperties" : true,
|
||||
"FileScopedDeclarationPrivacy" : true,
|
||||
"FullyIndirectEnum" : true,
|
||||
"GroupNumericLiterals" : true,
|
||||
"IdentifiersMustBeASCII" : true,
|
||||
"NeverForceUnwrap" : false,
|
||||
"NeverUseForceTry" : false,
|
||||
"NeverUseImplicitlyUnwrappedOptionals" : false,
|
||||
"NoAccessLevelOnExtensionDeclaration" : false,
|
||||
"NoBlockComments" : true,
|
||||
"NoCasesWithOnlyFallthrough" : true,
|
||||
"NoEmptyTrailingClosureParentheses" : true,
|
||||
"NoLabelsInCasePatterns" : true,
|
||||
"NoLeadingUnderscores" : false,
|
||||
"NoParensAroundConditions" : true,
|
||||
"NoVoidReturnOnFunctionSignature" : true,
|
||||
"OneCasePerLine" : true,
|
||||
"OneVariableDeclarationPerLine" : true,
|
||||
"OnlyOneTrailingClosureArgument" : true,
|
||||
"OrderedImports" : true,
|
||||
"ReturnVoidInsteadOfEmptyTuple" : true,
|
||||
"UseLetInEveryBoundCaseVariable" : true,
|
||||
"UseShorthandTypeNames" : true,
|
||||
"UseSingleLinePropertyGetter" : true,
|
||||
"UseSynthesizedInitializer" : true,
|
||||
"UseTripleSlashForDocumentationComments" : true,
|
||||
"ValidateDocumentationComments" : false
|
||||
"lineBreakAroundMultilineExpressionChainComponents": true,
|
||||
"lineBreakBeforeControlFlowKeywords": false,
|
||||
"lineBreakBeforeEachArgument": true,
|
||||
"lineBreakBeforeEachGenericRequirement": true,
|
||||
"lineBreakBetweenDeclarationAttributes": true,
|
||||
"lineLength": 120,
|
||||
"maximumBlankLines": 1,
|
||||
"multiElementCollectionTrailingCommas": true,
|
||||
"prioritizeKeepingFunctionOutputTogether": true,
|
||||
"respectsExistingLineBreaks": true,
|
||||
"rules": {
|
||||
"AllPublicDeclarationsHaveDocumentation": true,
|
||||
"AlwaysUseLiteralForEmptyCollectionInit": true,
|
||||
"AlwaysUseLowerCamelCase": true,
|
||||
"AmbiguousTrailingClosureOverload": true,
|
||||
"BeginDocumentationCommentWithOneLineSummary": true,
|
||||
"DoNotUseSemicolons": true,
|
||||
"DontRepeatTypeInStaticProperties": true,
|
||||
"FileScopedDeclarationPrivacy": true,
|
||||
"FullyIndirectEnum": true,
|
||||
"GroupNumericLiterals": true,
|
||||
"IdentifiersMustBeASCII": true,
|
||||
"NeverForceUnwrap": true,
|
||||
"NeverUseForceTry": true,
|
||||
"NeverUseImplicitlyUnwrappedOptionals": true,
|
||||
"NoAccessLevelOnExtensionDeclaration": true,
|
||||
"NoAssignmentInExpressions": true,
|
||||
"NoBlockComments": true,
|
||||
"NoCasesWithOnlyFallthrough": true,
|
||||
"NoEmptyTrailingClosureParentheses": true,
|
||||
"NoLabelsInCasePatterns": true,
|
||||
"NoLeadingUnderscores": true,
|
||||
"NoParensAroundConditions": true,
|
||||
"NoPlaygroundLiterals": true,
|
||||
"NoVoidReturnOnFunctionSignature": true,
|
||||
"OmitExplicitReturns": true,
|
||||
"OneCasePerLine": true,
|
||||
"OneVariableDeclarationPerLine": true,
|
||||
"OnlyOneTrailingClosureArgument": true,
|
||||
"OrderedImports": true,
|
||||
"ReplaceForEachWithForLoop": true,
|
||||
"ReturnVoidInsteadOfEmptyTuple": true,
|
||||
"TypeNamesShouldBeCapitalized": true,
|
||||
"UseEarlyExits": true,
|
||||
"UseLetInEveryBoundCaseVariable": true,
|
||||
"UseShorthandTypeNames": true,
|
||||
"UseSingleLinePropertyGetter": true,
|
||||
"UseSynthesizedInitializer": true,
|
||||
"UseTripleSlashForDocumentationComments": true,
|
||||
"UseWhereClausesInForLoops": true,
|
||||
"ValidateDocumentationComments": true
|
||||
},
|
||||
"version" : 1
|
||||
"spacesAroundRangeFormationOperators": false,
|
||||
"spacesBeforeEndOfLineComments": 1,
|
||||
"TrailingComma": false,
|
||||
"version": 1
|
||||
}
|
||||
|
|
21
.swiftformat
21
.swiftformat
|
@ -5,22 +5,33 @@
|
|||
# https://github.com/nicklockwood/SwiftFormat#config-file
|
||||
#
|
||||
|
||||
--exclude docs/
|
||||
|
||||
# Disabled rules
|
||||
--disable blankLinesAroundMark
|
||||
--disable consecutiveSpaces
|
||||
--disable hoistAwait
|
||||
--disable hoistPatternLet
|
||||
--disable hoistTry
|
||||
|
||||
# Enable later
|
||||
--disable indent
|
||||
--disable trailingCommas
|
||||
|
||||
# Enabled rules (disabled by default)
|
||||
--enable trailingClosures
|
||||
#--enable acronyms
|
||||
#--enable blankLinesBetweenImports
|
||||
--enable blockComments
|
||||
--enable docComments
|
||||
--enable isEmpty
|
||||
--enable noExplicitOwnership
|
||||
#--enable organizeDeclarations
|
||||
--enable redundantProperty
|
||||
--enable sortSwitchCases
|
||||
--enable wrapConditionalBodies
|
||||
--enable wrapEnumCases
|
||||
--enable wrapMultilineConditionalAssignment
|
||||
--enable wrapSwitchCases
|
||||
|
||||
# Rule options
|
||||
--commas always
|
||||
--extensionacl on-declarations
|
||||
--importgrouping testable-last
|
||||
--lineaftermarks false
|
||||
--ranges no-space
|
||||
|
|
|
@ -5,11 +5,40 @@
|
|||
# https://github.com/realm/SwiftLint#configuration
|
||||
#
|
||||
---
|
||||
opt_in_rules:
|
||||
- all
|
||||
disabled_rules:
|
||||
- non_optional_string_data_conversion
|
||||
- balanced_xctest_lifecycle
|
||||
- closure_body_length
|
||||
- contrasted_opening_brace
|
||||
- explicit_acl
|
||||
- explicit_enum_raw_value
|
||||
- explicit_top_level_acl
|
||||
- explicit_type_interface
|
||||
- file_header
|
||||
- file_name
|
||||
- final_test_case
|
||||
- force_unwrapping
|
||||
- function_body_length
|
||||
- inert_defer
|
||||
- legacy_objc_type
|
||||
- no_grouping_extension
|
||||
- number_separator
|
||||
- one_declaration_per_file
|
||||
- prefer_nimble
|
||||
- prefixed_toplevel_constant
|
||||
- quick_discouraged_call
|
||||
- quick_discouraged_pending_test
|
||||
- required_deinit
|
||||
- sorted_enum_cases
|
||||
- trailing_comma
|
||||
excluded:
|
||||
- docs
|
||||
opening_brace:
|
||||
ignore_multiline_function_signatures: true
|
||||
ignore_multiline_statement_conditions: true
|
||||
- unused_capture_list
|
||||
- vertical_whitespace_between_cases
|
||||
file_types_order:
|
||||
order: [
|
||||
[main_type],
|
||||
[supporting_type],
|
||||
[extension],
|
||||
[preview_provider],
|
||||
[library_content_provider]
|
||||
]
|
||||
|
|
|
@ -12,31 +12,36 @@ import StoreFoundation
|
|||
|
||||
/// Downloads a list of apps, one after the other, printing progress to the console.
|
||||
///
|
||||
/// - Parameter appIDs: The IDs of the apps to be downloaded
|
||||
/// - Parameter purchase: Flag indicating whether the apps needs to be purchased.
|
||||
/// Only works for free apps. Defaults to false.
|
||||
/// - Parameters:
|
||||
/// - appIDs: The IDs of the apps to be downloaded
|
||||
/// - purchase: Flag indicating whether the apps needs to be purchased.
|
||||
/// Only works for free apps. Defaults to false.
|
||||
/// - Returns: A promise that completes when the downloads are complete. If any fail,
|
||||
/// the promise is rejected with the first error, after all remaining downloads are attempted.
|
||||
/// the promise is rejected with the first error, after all remaining downloads are attempted.
|
||||
func downloadAll(_ appIDs: [AppID], purchase: Bool = false) -> Promise<Void> {
|
||||
var firstError: Error?
|
||||
return appIDs.reduce(Guarantee<Void>.value(())) { previous, appID in
|
||||
previous.then {
|
||||
downloadWithRetries(appID, purchase: purchase).recover { error in
|
||||
if firstError == nil {
|
||||
firstError = error
|
||||
}
|
||||
return
|
||||
appIDs
|
||||
.reduce(Guarantee.value(())) { previous, appID in
|
||||
previous.then {
|
||||
downloadWithRetries(appID, purchase: purchase)
|
||||
.recover { error in
|
||||
if firstError == nil {
|
||||
firstError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.done {
|
||||
if let error = firstError {
|
||||
throw error
|
||||
.done {
|
||||
if let error = firstError {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadWithRetries(_ appID: AppID, purchase: Bool = false, attempts: Int = 3) -> Promise<Void> {
|
||||
SSPurchase().perform(appID: appID, purchase: purchase)
|
||||
.recover { error -> Promise<Void> in
|
||||
.recover { error in
|
||||
guard attempts > 1 else {
|
||||
throw error
|
||||
}
|
||||
|
|
|
@ -10,29 +10,35 @@ import CommerceKit
|
|||
import PromiseKit
|
||||
import StoreFoundation
|
||||
|
||||
private let timeout = 30.0
|
||||
|
||||
extension ISStoreAccount: StoreAccount {
|
||||
static var primaryAccount: Promise<ISStoreAccount> {
|
||||
if #available(macOS 10.13, *) {
|
||||
return race(
|
||||
Promise<ISStoreAccount> { seal in
|
||||
ISServiceProxy.genericShared().accountService.primaryAccount { storeAccount in
|
||||
seal.fulfill(storeAccount)
|
||||
}
|
||||
Promise { seal in
|
||||
ISServiceProxy.genericShared().accountService
|
||||
.primaryAccount { storeAccount in
|
||||
seal.fulfill(storeAccount)
|
||||
}
|
||||
},
|
||||
after(seconds: 30).then {
|
||||
Promise(error: MASError.notSignedIn)
|
||||
}
|
||||
after(seconds: timeout)
|
||||
.then {
|
||||
Promise(error: MASError.notSignedIn)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
return .value(CKAccountStore.shared().primaryAccount)
|
||||
}
|
||||
|
||||
return .value(CKAccountStore.shared().primaryAccount)
|
||||
}
|
||||
|
||||
static func signIn(username: String, password: String, systemDialog: Bool) -> Promise<ISStoreAccount> {
|
||||
// swift-format-ignore: UseEarlyExits
|
||||
if #available(macOS 10.13, *) {
|
||||
// Signing in is no longer possible as of High Sierra.
|
||||
// https://github.com/mas-cli/mas/issues/164
|
||||
return Promise(error: MASError.notSupported)
|
||||
// swiftlint:disable:next superfluous_else
|
||||
} else {
|
||||
return
|
||||
primaryAccount
|
||||
|
@ -43,7 +49,7 @@ extension ISStoreAccount: StoreAccount {
|
|||
|
||||
let password =
|
||||
password.isEmpty && !systemDialog
|
||||
? String(validatingUTF8: getpass("Password: "))!
|
||||
? String(validatingUTF8: getpass("Password: ")) ?? ""
|
||||
: password
|
||||
|
||||
guard !password.isEmpty || systemDialog else {
|
||||
|
@ -68,19 +74,20 @@ extension ISStoreAccount: StoreAccount {
|
|||
|
||||
if systemDialog {
|
||||
return signInPromise
|
||||
} else {
|
||||
context.demoMode = true
|
||||
context.demoAccountName = username
|
||||
context.demoAccountPassword = password
|
||||
context.demoAutologinMode = true
|
||||
}
|
||||
|
||||
return race(
|
||||
signInPromise,
|
||||
after(seconds: 30).then {
|
||||
context.demoMode = true
|
||||
context.demoAccountName = username
|
||||
context.demoAccountPassword = password
|
||||
context.demoAutologinMode = true
|
||||
|
||||
return race(
|
||||
signInPromise,
|
||||
after(seconds: timeout)
|
||||
.then {
|
||||
Promise(error: MASError.signInFailed(error: nil))
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,8 @@
|
|||
import CommerceKit
|
||||
import StoreFoundation
|
||||
|
||||
@objc class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver {
|
||||
@objc
|
||||
class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver {
|
||||
let purchase: SSPurchase
|
||||
var completionHandler: (() -> Void)?
|
||||
var errorHandler: ((MASError) -> Void)?
|
||||
|
@ -64,6 +65,7 @@ struct ProgressState {
|
|||
let phase: String
|
||||
|
||||
var percentage: String {
|
||||
// swiftlint:disable:next no_magic_numbers
|
||||
String(format: "%.1f%%", floor(percentComplete * 1000) / 10)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,6 @@ extension SSPurchase {
|
|||
if purchase {
|
||||
parameters["macappinstalledconfirmed"] = 1
|
||||
parameters["pricingParameters"] = "STDQ"
|
||||
|
||||
} else {
|
||||
parameters["pricingParameters"] = "STDRDL"
|
||||
}
|
||||
|
@ -63,19 +62,20 @@ extension SSPurchase {
|
|||
|
||||
private func perform() -> Promise<Void> {
|
||||
Promise<SSPurchase> { seal in
|
||||
CKPurchaseController.shared().perform(self, withOptions: 0) { purchase, _, error, response in
|
||||
if let error {
|
||||
seal.reject(MASError.purchaseFailed(error: error as NSError?))
|
||||
return
|
||||
}
|
||||
CKPurchaseController.shared()
|
||||
.perform(self, withOptions: 0) { purchase, _, error, response in
|
||||
if let error {
|
||||
seal.reject(MASError.purchaseFailed(error: error as NSError?))
|
||||
return
|
||||
}
|
||||
|
||||
guard response?.downloads.isEmpty == false, let purchase else {
|
||||
seal.reject(MASError.noDownloads)
|
||||
return
|
||||
}
|
||||
guard response?.downloads.isEmpty == false, let purchase else {
|
||||
seal.reject(MASError.noDownloads)
|
||||
return
|
||||
}
|
||||
|
||||
seal.fulfill(purchase)
|
||||
}
|
||||
seal.fulfill(purchase)
|
||||
}
|
||||
}
|
||||
.then { purchase in
|
||||
let observer = PurchaseDownloadObserver(purchase: purchase)
|
||||
|
|
|
@ -10,8 +10,9 @@ import ArgumentParser
|
|||
import CommerceKit
|
||||
|
||||
extension Mas {
|
||||
/// Command which installs the first search result. This is handy as many MAS titles
|
||||
/// can be long with embedded keywords.
|
||||
/// Command which installs the first search result.
|
||||
///
|
||||
/// This is handy as many MAS titles can be long with embedded keywords.
|
||||
struct Lucky: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
abstract: "Install the first result from the Mac App Store"
|
||||
|
@ -43,7 +44,7 @@ extension Mas {
|
|||
}
|
||||
|
||||
guard let appID else {
|
||||
fatalError()
|
||||
fatalError("app ID returned from Apple is null")
|
||||
}
|
||||
|
||||
try install(appID: appID, appLibrary: appLibrary)
|
||||
|
@ -54,7 +55,8 @@ extension Mas {
|
|||
/// - Parameters:
|
||||
/// - appID: App identifier
|
||||
/// - appLibrary: Library of installed apps
|
||||
fileprivate func install(appID: AppID, appLibrary: AppLibrary) throws {
|
||||
/// - Throws: Any error that occurs while attempting to install the app.
|
||||
private func install(appID: AppID, appLibrary: AppLibrary) throws {
|
||||
// Try to download applications with given identifiers and collect results
|
||||
if let product = appLibrary.installedApp(withAppID: appID), !force {
|
||||
printWarning("\(product.appName) is already installed")
|
||||
|
|
|
@ -35,26 +35,27 @@ extension Mas {
|
|||
return
|
||||
}
|
||||
|
||||
guard let result = try storeSearch.lookup(appID: appID).wait()
|
||||
else {
|
||||
guard let result = try storeSearch.lookup(appID: appID).wait() else {
|
||||
throw MASError.noSearchResultsFound
|
||||
}
|
||||
|
||||
guard var url = URLComponents(string: result.trackViewUrl)
|
||||
else {
|
||||
guard var url = URLComponents(string: result.trackViewUrl) else {
|
||||
throw MASError.searchFailed
|
||||
}
|
||||
url.scheme = masScheme
|
||||
|
||||
guard let urlString = url.string else {
|
||||
printError("Unable to construct URL")
|
||||
throw MASError.searchFailed
|
||||
}
|
||||
do {
|
||||
try openCommand.run(arguments: url.string!)
|
||||
try openCommand.run(arguments: urlString)
|
||||
} catch {
|
||||
printError("Unable to launch open command")
|
||||
throw MASError.searchFailed
|
||||
}
|
||||
if openCommand.failed {
|
||||
let reason = openCommand.process.terminationReason
|
||||
printError("Open failed: (\(reason)) \(openCommand.stderr)")
|
||||
printError("Open failed: (\(openCommand.process.terminationReason)) \(openCommand.stderr)")
|
||||
throw MASError.searchFailed
|
||||
}
|
||||
} catch {
|
||||
|
|
|
@ -10,8 +10,6 @@ import ArgumentParser
|
|||
import Foundation
|
||||
import PromiseKit
|
||||
|
||||
import enum Swift.Result
|
||||
|
||||
extension Mas {
|
||||
/// Command which displays a list of installed apps which have available updates
|
||||
/// ready to be installed from the Mac App Store.
|
||||
|
@ -34,7 +32,8 @@ extension Mas {
|
|||
appLibrary.installedApps.map { installedApp in
|
||||
firstly {
|
||||
storeSearch.lookup(appID: installedApp.itemIdentifier.appIDValue)
|
||||
}.done { storeApp in
|
||||
}
|
||||
.done { storeApp in
|
||||
guard let storeApp else {
|
||||
if verbose {
|
||||
printWarning(
|
||||
|
|
|
@ -61,7 +61,7 @@ extension Mas {
|
|||
|
||||
if kill.terminationStatus != 0, debug {
|
||||
let output = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
printInfo("killall failed:\r\n\(String(data: output, encoding: String.Encoding.utf8)!)")
|
||||
printError("killall failed:\n\(String(data: output, encoding: .utf8) ?? "Error info not available")")
|
||||
}
|
||||
|
||||
// Wipe Download Directory
|
||||
|
|
|
@ -9,8 +9,9 @@
|
|||
import ArgumentParser
|
||||
|
||||
extension Mas {
|
||||
/// Search the Mac App Store using the iTunes Search API:
|
||||
/// https://performance-partners.apple.com/search-api
|
||||
/// Search the Mac App Store using the iTunes Search API.
|
||||
///
|
||||
/// See - https://performance-partners.apple.com/search-api
|
||||
struct Search: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
abstract: "Search for apps from the Mac App Store"
|
||||
|
|
|
@ -17,7 +17,7 @@ extension Mas {
|
|||
abstract: "Uninstall app installed from the Mac App Store"
|
||||
)
|
||||
|
||||
/// Flag indicating that removal shouldn't be performed
|
||||
/// Flag indicating that removal shouldn't be performed.
|
||||
@Flag(help: "dry run")
|
||||
var dryRun = false
|
||||
@Argument(help: "ID of app to uninstall")
|
||||
|
|
|
@ -33,7 +33,7 @@ extension Mas {
|
|||
throw error as? MASError ?? .searchFailed
|
||||
}
|
||||
|
||||
guard apps.count > 0 else {
|
||||
guard !apps.isEmpty else {
|
||||
printWarning("Nothing found to upgrade")
|
||||
return
|
||||
}
|
||||
|
@ -41,7 +41,8 @@ extension Mas {
|
|||
print("Upgrading \(apps.count) outdated application\(apps.count > 1 ? "s" : ""):")
|
||||
print(
|
||||
apps.map { "\($0.installedApp.appName) (\($0.installedApp.bundleVersion)) -> (\($0.storeApp.version))" }
|
||||
.joined(separator: "\n"))
|
||||
.joined(separator: "\n")
|
||||
)
|
||||
|
||||
do {
|
||||
try downloadAll(apps.map(\.installedApp.itemIdentifier.appIDValue)).wait()
|
||||
|
@ -54,24 +55,25 @@ extension Mas {
|
|||
appLibrary: AppLibrary,
|
||||
storeSearch: StoreSearch
|
||||
) throws -> [(SoftwareProduct, SearchResult)] {
|
||||
let apps: [SoftwareProduct] =
|
||||
let apps =
|
||||
appIDs.isEmpty
|
||||
? appLibrary.installedApps
|
||||
: appIDs.compactMap {
|
||||
if let appID = AppID($0) {
|
||||
// if argument an AppID, lookup app by id using argument
|
||||
: appIDs.compactMap { appID in
|
||||
if let appID = AppID(appID) {
|
||||
// argument is an AppID, lookup app by id using argument
|
||||
return appLibrary.installedApp(withAppID: appID)
|
||||
} else {
|
||||
// if argument not an AppID, lookup app by name using argument
|
||||
return appLibrary.installedApp(named: $0)
|
||||
}
|
||||
|
||||
// argument is not an AppID, lookup app by name using argument
|
||||
return appLibrary.installedApp(named: appID)
|
||||
}
|
||||
|
||||
let promises = apps.map { installedApp in
|
||||
// only upgrade apps whose local version differs from the store version
|
||||
firstly {
|
||||
storeSearch.lookup(appID: installedApp.itemIdentifier.appIDValue)
|
||||
}.map { result -> (SoftwareProduct, SearchResult)? in
|
||||
}
|
||||
.map { result -> (SoftwareProduct, SearchResult)? in
|
||||
guard let storeApp = result, installedApp.isOutdatedWhenComparedTo(storeApp) else {
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -26,13 +26,13 @@ extension Mas {
|
|||
|
||||
func run(storeSearch: StoreSearch, openCommand: ExternalCommand) throws {
|
||||
do {
|
||||
guard let result = try storeSearch.lookup(appID: appID).wait()
|
||||
else {
|
||||
guard let result = try storeSearch.lookup(appID: appID).wait() else {
|
||||
throw MASError.noSearchResultsFound
|
||||
}
|
||||
|
||||
guard let vendorWebsite = result.sellerUrl
|
||||
else { throw MASError.noVendorWebsite }
|
||||
guard let vendorWebsite = result.sellerUrl else {
|
||||
throw MASError.noVendorWebsite
|
||||
}
|
||||
|
||||
do {
|
||||
try openCommand.run(arguments: vendorWebsite)
|
||||
|
|
|
@ -13,10 +13,10 @@ protocol AppLibrary {
|
|||
/// Entire set of installed apps.
|
||||
var installedApps: [SoftwareProduct] { get }
|
||||
|
||||
/// Finds an app by ID.
|
||||
/// Finds an app for appID.
|
||||
///
|
||||
/// - Parameter withAppID: MAS ID for app.
|
||||
/// - Returns: Software Product of app if found; nil otherwise.
|
||||
/// - Parameter appID: app ID for app.
|
||||
/// - Returns: SoftwareProduct of app if found; nil otherwise.
|
||||
func installedApp(withAppID appID: AppID) -> SoftwareProduct?
|
||||
|
||||
/// Uninstalls an app.
|
||||
|
@ -28,10 +28,10 @@ protocol AppLibrary {
|
|||
|
||||
/// Common logic
|
||||
extension AppLibrary {
|
||||
/// Finds an app by ID.
|
||||
/// Finds an app for appID.
|
||||
///
|
||||
/// - Parameter withAppID: MAS ID for app.
|
||||
/// - Returns: Software Product of app if found; nil otherwise.
|
||||
/// - Parameter appID: app ID for app.
|
||||
/// - Returns: SoftwareProduct of app if found; nil otherwise.
|
||||
func installedApp(withAppID appID: AppID) -> SoftwareProduct? {
|
||||
let appID = NSNumber(value: appID)
|
||||
return installedApps.first { $0.itemIdentifier == appID }
|
||||
|
|
|
@ -14,9 +14,10 @@ class MasAppLibrary: AppLibrary {
|
|||
private let softwareMap: SoftwareMap
|
||||
|
||||
/// Array of installed software products.
|
||||
lazy var installedApps: [SoftwareProduct] = softwareMap.allSoftwareProducts().filter { product in
|
||||
product.bundlePath.starts(with: "/Applications/")
|
||||
}
|
||||
lazy var installedApps: [SoftwareProduct] = softwareMap.allSoftwareProducts()
|
||||
.filter { product in
|
||||
product.bundlePath.starts(with: "/Applications/")
|
||||
}
|
||||
|
||||
/// Internal initializer for providing a mock software map.
|
||||
/// - Parameter softwareMap: SoftwareMap to use
|
||||
|
|
|
@ -34,10 +34,9 @@ class MasStoreSearch: StoreSearch {
|
|||
|
||||
/// Searches for an app.
|
||||
///
|
||||
/// - Parameter appName: MAS ID of app
|
||||
/// - Parameter completion: A closure that receives the search results or an Error if there is a
|
||||
/// problem with the network request. Results array will be empty if there were no matches.
|
||||
func search(for appName: String) -> Promise<[SearchResult]> {
|
||||
/// - Parameter searchTerm: a search term matched against app names
|
||||
/// - Returns: A Promise of an Array of SearchResults matching searchTerm
|
||||
func search(for searchTerm: String) -> Promise<[SearchResult]> {
|
||||
// Search for apps for compatible platforms, in order of preference.
|
||||
// Macs with Apple Silicon can run iPad and iPhone apps.
|
||||
var entities = [Entity.desktopSoftware]
|
||||
|
@ -45,18 +44,20 @@ class MasStoreSearch: StoreSearch {
|
|||
entities += [.iPadSoftware, .iPhoneSoftware]
|
||||
}
|
||||
|
||||
let results = entities.map { entity -> Promise<[SearchResult]> in
|
||||
guard let url = searchURL(for: appName, inCountry: country, ofEntity: entity) else {
|
||||
fatalError("Failed to build URL for \(appName)")
|
||||
let results = entities.map { entity in
|
||||
guard let url = searchURL(for: searchTerm, inCountry: country, ofEntity: entity) else {
|
||||
fatalError("Failed to build URL for \(searchTerm)")
|
||||
}
|
||||
return loadSearchResults(url)
|
||||
}
|
||||
|
||||
// Combine the results, removing any duplicates.
|
||||
var seenAppIDs = Set<AppID>()
|
||||
return when(fulfilled: results).flatMapValues { $0 }.filterValues { result in
|
||||
seenAppIDs.insert(result.trackId).inserted
|
||||
}
|
||||
return when(fulfilled: results)
|
||||
.flatMapValues { $0 }
|
||||
.filterValues { result in
|
||||
seenAppIDs.insert(result.trackId).inserted
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks up app details.
|
||||
|
@ -70,19 +71,20 @@ class MasStoreSearch: StoreSearch {
|
|||
}
|
||||
return firstly {
|
||||
loadSearchResults(url)
|
||||
}.then { results -> Guarantee<SearchResult?> in
|
||||
}
|
||||
.then { results -> Guarantee<SearchResult?> in
|
||||
guard let result = results.first else {
|
||||
return .value(nil)
|
||||
}
|
||||
|
||||
guard let pageUrl = URL(string: result.trackViewUrl)
|
||||
else {
|
||||
guard let pageUrl = URL(string: result.trackViewUrl) else {
|
||||
return .value(result)
|
||||
}
|
||||
|
||||
return firstly {
|
||||
self.scrapeAppStoreVersion(pageUrl)
|
||||
}.map { pageVersion in
|
||||
}
|
||||
.map { pageVersion in
|
||||
guard let pageVersion,
|
||||
let searchVersion = Version(tolerant: result.version),
|
||||
pageVersion > searchVersion
|
||||
|
@ -94,7 +96,8 @@ class MasStoreSearch: StoreSearch {
|
|||
var result = result
|
||||
result.version = pageVersion.description
|
||||
return result
|
||||
}.recover { _ in
|
||||
}
|
||||
.recover { _ in
|
||||
// If we were unable to scrape the App Store page, assume compatibility.
|
||||
.value(result)
|
||||
}
|
||||
|
@ -104,7 +107,8 @@ class MasStoreSearch: StoreSearch {
|
|||
private func loadSearchResults(_ url: URL) -> Promise<[SearchResult]> {
|
||||
firstly {
|
||||
networkManager.loadData(from: url)
|
||||
}.map { data -> [SearchResult] in
|
||||
}
|
||||
.map { data in
|
||||
do {
|
||||
return try JSONDecoder().decode(SearchResultList.self, from: data).results
|
||||
} catch {
|
||||
|
@ -113,17 +117,16 @@ class MasStoreSearch: StoreSearch {
|
|||
}
|
||||
}
|
||||
|
||||
// App Store pages indicate:
|
||||
// - compatibility with Macs with Apple Silicon
|
||||
// - (often) a version that is newer than what is listed in search results
|
||||
//
|
||||
// We attempt to scrape this information here.
|
||||
/// Scrape the app version from the App Store webpage at the given URL.
|
||||
///
|
||||
/// App Store webpages frequently report a version that is newer than what is reported by the iTunes Search API.
|
||||
private func scrapeAppStoreVersion(_ pageUrl: URL) -> Promise<Version?> {
|
||||
firstly {
|
||||
networkManager.loadData(from: pageUrl)
|
||||
}.map { data in
|
||||
}
|
||||
.map { data in
|
||||
guard let html = String(data: data, encoding: .utf8),
|
||||
let capture = MasStoreSearch.appVersionExpression.firstMatch(in: html)?.captures[0],
|
||||
let capture = Self.appVersionExpression.firstMatch(in: html)?.captures[0],
|
||||
let version = Version(tolerant: capture)
|
||||
else {
|
||||
return nil
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
// Copyright © 2020 mas-cli. All rights reserved.
|
||||
//
|
||||
|
||||
/// Somewhat analogous to CKSoftwareMap
|
||||
/// Somewhat analogous to CKSoftwareMap.
|
||||
protocol SoftwareMap {
|
||||
func allSoftwareProducts() -> [SoftwareProduct]
|
||||
func product(for bundleIdentifier: String) -> SoftwareProduct?
|
||||
|
|
|
@ -40,7 +40,10 @@ private enum URLAction {
|
|||
extension StoreSearch {
|
||||
/// Builds the search URL for an app.
|
||||
///
|
||||
/// - Parameter searchTerm: term for which to search in MAS.
|
||||
/// - Parameters:
|
||||
/// - searchTerm: term for which to search in MAS.
|
||||
/// - country: 2-letter ISO region code of the MAS in which to search.
|
||||
/// - entity: OS platform of apps for which to search.
|
||||
/// - Returns: URL for the search service or nil if searchTerm can't be encoded.
|
||||
func searchURL(
|
||||
for searchTerm: String,
|
||||
|
@ -52,7 +55,10 @@ extension StoreSearch {
|
|||
|
||||
/// Builds the lookup URL for an app.
|
||||
///
|
||||
/// - Parameter appID: MAS app identifier.
|
||||
/// - Parameters:
|
||||
/// - appID: MAS app identifier.
|
||||
/// - country: 2-letter ISO region code of the MAS in which to search.
|
||||
/// - entity: OS platform of apps for which to search.
|
||||
/// - Returns: URL for the lookup service or nil if appID can't be encoded.
|
||||
func lookupURL(
|
||||
forAppID appID: AppID,
|
||||
|
@ -72,16 +78,18 @@ extension StoreSearch {
|
|||
return nil
|
||||
}
|
||||
|
||||
components.queryItems = [
|
||||
var queryItems = [
|
||||
URLQueryItem(name: "media", value: "software"),
|
||||
URLQueryItem(name: "entity", value: entity.rawValue),
|
||||
]
|
||||
|
||||
if let country {
|
||||
components.queryItems!.append(URLQueryItem(name: "country", value: country))
|
||||
queryItems.append(URLQueryItem(name: "country", value: country))
|
||||
}
|
||||
|
||||
components.queryItems!.append(URLQueryItem(name: action.queryItemName, value: queryItemValue))
|
||||
queryItems.append(URLQueryItem(name: action.queryItemName, value: queryItemValue))
|
||||
|
||||
components.queryItems = queryItems
|
||||
|
||||
return components.url
|
||||
}
|
||||
|
|
|
@ -51,29 +51,25 @@ extension MASError: CustomStringConvertible {
|
|||
case .failed(let error):
|
||||
if let error {
|
||||
return "Failed: \(error.localizedDescription)"
|
||||
} else {
|
||||
return "Failed"
|
||||
}
|
||||
return "Failed"
|
||||
case .signInFailed(let error):
|
||||
if let error {
|
||||
return "Sign in failed: \(error.localizedDescription)"
|
||||
} else {
|
||||
return "Sign in failed"
|
||||
}
|
||||
return "Sign in failed"
|
||||
case .alreadySignedIn(let accountId):
|
||||
return "Already signed in as \(accountId)"
|
||||
case .purchaseFailed(let error):
|
||||
if let error {
|
||||
return "Download request failed: \(error.localizedDescription)"
|
||||
} else {
|
||||
return "Download request failed"
|
||||
}
|
||||
return "Download request failed"
|
||||
case .downloadFailed(let error):
|
||||
if let error {
|
||||
return "Download failed: \(error.localizedDescription)"
|
||||
} else {
|
||||
return "Download failed"
|
||||
}
|
||||
return "Download failed"
|
||||
case .noDownloads:
|
||||
return "No downloads began"
|
||||
case .cancelled:
|
||||
|
@ -94,12 +90,10 @@ extension MASError: CustomStringConvertible {
|
|||
if let data {
|
||||
if let unparsable = String(data: data, encoding: .utf8) {
|
||||
return "Unable to parse response as JSON: \n\(unparsable)"
|
||||
} else {
|
||||
return "Received defective response"
|
||||
}
|
||||
} else {
|
||||
return "Received empty response"
|
||||
return "Received defective response"
|
||||
}
|
||||
return "Received empty response"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
/// CLI command
|
||||
/// Represents a CLI command.
|
||||
protocol ExternalCommand {
|
||||
var binaryPath: String { get set }
|
||||
|
||||
|
|
|
@ -8,8 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
/// Wrapper for the external open system command.
|
||||
/// https://ss64.com/osx/open.html
|
||||
/// Wrapper for the external 'open' system command (https://ss64.com/osx/open.html).
|
||||
struct OpenSystemCommand: ExternalCommand {
|
||||
var binaryPath: String
|
||||
|
||||
|
|
|
@ -8,22 +8,12 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
/// Wrapper for the external sysctl system command.
|
||||
/// https://ss64.com/osx/sysctl.html
|
||||
/// Wrapper for the external 'sysctl' system command.
|
||||
///
|
||||
/// See - https://ss64.com/osx/sysctl.html
|
||||
struct SysCtlSystemCommand: ExternalCommand {
|
||||
var binaryPath: String
|
||||
|
||||
let process = Process()
|
||||
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
|
||||
init(binaryPath: String = "/usr/sbin/sysctl") {
|
||||
self.binaryPath = binaryPath
|
||||
}
|
||||
|
||||
static var isAppleSilicon: Bool = {
|
||||
let sysctl = SysCtlSystemCommand()
|
||||
let sysctl = Self()
|
||||
do {
|
||||
// Returns 1 on Apple Silicon even when run in an Intel context in Rosetta.
|
||||
try sysctl.run(arguments: "-in", "hw.optional.arm64")
|
||||
|
@ -37,4 +27,15 @@ struct SysCtlSystemCommand: ExternalCommand {
|
|||
|
||||
return sysctl.stdout.trimmingCharacters(in: .newlines) == "1"
|
||||
}()
|
||||
|
||||
let process = Process()
|
||||
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
|
||||
var binaryPath: String
|
||||
|
||||
init(binaryPath: String = "/usr/sbin/sysctl") {
|
||||
self.binaryPath = binaryPath
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,13 +10,17 @@ import Foundation
|
|||
|
||||
/// Formats text output for the search command.
|
||||
enum SearchResultFormatter {
|
||||
/// Formats text output with search results.
|
||||
/// Formats search results as text.
|
||||
///
|
||||
/// - Parameter results: Search results with app data
|
||||
/// - Parameters:
|
||||
/// - results: Search results containing app data
|
||||
/// - includePrice: Indicates whether to include prices in the output
|
||||
/// - Returns: Multiline text output.
|
||||
static func format(results: [SearchResult], includePrice: Bool = false) -> String {
|
||||
// find longest appName for formatting, default 50
|
||||
let maxLength = results.map(\.trackName.count).max() ?? 50
|
||||
guard let maxLength = results.map(\.trackName.count).max() else {
|
||||
return ""
|
||||
}
|
||||
|
||||
var output = ""
|
||||
|
||||
for result in results {
|
||||
|
|
|
@ -8,16 +8,19 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
/// A collection of output formatting helpers
|
||||
// A collection of output formatting helpers
|
||||
|
||||
/// Terminal Control Sequence Indicator
|
||||
/// Terminal Control Sequence Indicator.
|
||||
let csi = "\u{001B}["
|
||||
|
||||
private var standardError = FileHandle.standardError
|
||||
|
||||
extension FileHandle: TextOutputStream {
|
||||
/// Appends the given string to the stream.
|
||||
public func write(_ string: String) {
|
||||
guard let data = string.data(using: .utf8) else { return }
|
||||
guard let data = string.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
write(data)
|
||||
}
|
||||
}
|
||||
|
@ -69,7 +72,7 @@ func captureStream(
|
|||
_ stream: UnsafeMutablePointer<FILE>,
|
||||
encoding: String.Encoding = .utf8,
|
||||
_ block: @escaping () throws -> Void
|
||||
) throws -> String {
|
||||
) rethrows -> String {
|
||||
let originalFd = fileno(stream)
|
||||
let duplicateFd = dup(originalFd)
|
||||
defer {
|
||||
|
|
|
@ -35,10 +35,6 @@ struct Mas: ParsableCommand {
|
|||
]
|
||||
)
|
||||
|
||||
func validate() throws {
|
||||
Mas.initialize()
|
||||
}
|
||||
|
||||
static func initialize() {
|
||||
PromiseKit.conf.Q.map = .global()
|
||||
PromiseKit.conf.Q.return = .global()
|
||||
|
@ -54,6 +50,10 @@ struct Mas: ParsableCommand {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validate() throws {
|
||||
Self.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
typealias AppID = UInt64
|
||||
|
|
|
@ -19,12 +19,15 @@ protocol SoftwareProduct {
|
|||
}
|
||||
|
||||
extension SoftwareProduct {
|
||||
/// Returns bundleIdentifier if appName is empty string.
|
||||
/// - Returns: bundleIdentifier if appName is empty string.
|
||||
var appNameOrBundleIdentifier: String {
|
||||
appName.isEmpty ? bundleIdentifier : appName
|
||||
}
|
||||
|
||||
/// Determines whether the app is considered outdated. Updates that require a higher OS version are excluded.
|
||||
/// Determines whether the app is considered outdated.
|
||||
///
|
||||
/// Updates that require a higher OS version are excluded.
|
||||
///
|
||||
/// - Parameter storeApp: App from search result.
|
||||
/// - Returns: true if the app is outdated; false otherwise.
|
||||
func isOutdatedWhenComparedTo(_ storeApp: SearchResult) -> Bool {
|
||||
|
|
|
@ -9,11 +9,11 @@
|
|||
import Foundation
|
||||
import PromiseKit
|
||||
|
||||
/// Network abstraction
|
||||
/// Network abstraction.
|
||||
class NetworkManager {
|
||||
private let session: NetworkSession
|
||||
|
||||
/// Designated initializer
|
||||
/// Designated initializer.
|
||||
///
|
||||
/// - Parameter session: A networking session.
|
||||
init(session: NetworkSession = URLSession(configuration: .ephemeral)) {
|
||||
|
@ -23,13 +23,14 @@ class NetworkManager {
|
|||
do {
|
||||
let url = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Caches/com.mphys.mas-cli")
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch {}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads data asynchronously.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: URL to load data from.
|
||||
/// - Parameter url: URL from which to load data.
|
||||
/// - Returns: A Promise for the Data of the response.
|
||||
func loadData(from url: URL) -> Promise<Data> {
|
||||
session.loadData(from: url)
|
||||
|
|
62
Tests/masTests/.swift-format
Normal file
62
Tests/masTests/.swift-format
Normal file
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"indentConditionalCompilationBlocks": false,
|
||||
"indentation": {
|
||||
"spaces": 4
|
||||
},
|
||||
"lineBreakAroundMultilineExpressionChainComponents": true,
|
||||
"lineBreakBeforeControlFlowKeywords": false,
|
||||
"lineBreakBeforeEachArgument": true,
|
||||
"lineBreakBeforeEachGenericRequirement": true,
|
||||
"lineBreakBetweenDeclarationAttributes": true,
|
||||
"lineLength": 120,
|
||||
"maximumBlankLines": 1,
|
||||
"multiElementCollectionTrailingCommas": true,
|
||||
"prioritizeKeepingFunctionOutputTogether": true,
|
||||
"respectsExistingLineBreaks": true,
|
||||
"rules": {
|
||||
"AllPublicDeclarationsHaveDocumentation": false,
|
||||
"AlwaysUseLiteralForEmptyCollectionInit": true,
|
||||
"AlwaysUseLowerCamelCase": true,
|
||||
"AmbiguousTrailingClosureOverload": true,
|
||||
"BeginDocumentationCommentWithOneLineSummary": true,
|
||||
"DoNotUseSemicolons": true,
|
||||
"DontRepeatTypeInStaticProperties": true,
|
||||
"FileScopedDeclarationPrivacy": true,
|
||||
"FullyIndirectEnum": true,
|
||||
"GroupNumericLiterals": true,
|
||||
"IdentifiersMustBeASCII": true,
|
||||
"NeverForceUnwrap": false,
|
||||
"NeverUseForceTry": false,
|
||||
"NeverUseImplicitlyUnwrappedOptionals": true,
|
||||
"NoAccessLevelOnExtensionDeclaration": true,
|
||||
"NoAssignmentInExpressions": true,
|
||||
"NoBlockComments": true,
|
||||
"NoCasesWithOnlyFallthrough": true,
|
||||
"NoEmptyTrailingClosureParentheses": true,
|
||||
"NoLabelsInCasePatterns": true,
|
||||
"NoLeadingUnderscores": true,
|
||||
"NoParensAroundConditions": true,
|
||||
"NoPlaygroundLiterals": true,
|
||||
"NoVoidReturnOnFunctionSignature": true,
|
||||
"OmitExplicitReturns": true,
|
||||
"OneCasePerLine": true,
|
||||
"OneVariableDeclarationPerLine": true,
|
||||
"OnlyOneTrailingClosureArgument": true,
|
||||
"OrderedImports": true,
|
||||
"ReplaceForEachWithForLoop": true,
|
||||
"ReturnVoidInsteadOfEmptyTuple": true,
|
||||
"TypeNamesShouldBeCapitalized": true,
|
||||
"UseEarlyExits": true,
|
||||
"UseLetInEveryBoundCaseVariable": true,
|
||||
"UseShorthandTypeNames": true,
|
||||
"UseSingleLinePropertyGetter": true,
|
||||
"UseSynthesizedInitializer": true,
|
||||
"UseTripleSlashForDocumentationComments": true,
|
||||
"UseWhereClausesInForLoops": true,
|
||||
"ValidateDocumentationComments": true
|
||||
},
|
||||
"spacesAroundRangeFormationOperators": false,
|
||||
"spacesBeforeEndOfLineComments": 1,
|
||||
"TrailingComma": false,
|
||||
"version": 1
|
||||
}
|
|
@ -8,4 +8,5 @@
|
|||
disabled_rules:
|
||||
- force_cast
|
||||
- force_try
|
||||
- function_body_length
|
||||
- implicitly_unwrapped_optional
|
||||
- no_magic_numbers
|
||||
|
|
|
@ -11,7 +11,7 @@ import Quick
|
|||
|
||||
@testable import mas
|
||||
|
||||
// Deprecated test
|
||||
/// Deprecated test.
|
||||
public class AccountSpec: QuickSpec {
|
||||
override public func spec() {
|
||||
beforeSuite {
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
// Copyright © 2018 mas-cli. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Nimble
|
||||
import Quick
|
||||
|
||||
|
@ -31,9 +32,11 @@ public class SearchSpec: QuickSpec {
|
|||
)
|
||||
storeSearch.apps[mockResult.trackId] = mockResult
|
||||
expect {
|
||||
try Mas.Search.parse(["slack"]).run(storeSearch: storeSearch)
|
||||
try captureStream(stdout) {
|
||||
try Mas.Search.parse(["slack"]).run(storeSearch: storeSearch)
|
||||
}
|
||||
}
|
||||
.toNot(throwError())
|
||||
== " 1111 slack (0.0)\n"
|
||||
}
|
||||
it("fails when searching for nonexistent app") {
|
||||
expect {
|
||||
|
|
|
@ -11,7 +11,7 @@ import Quick
|
|||
|
||||
@testable import mas
|
||||
|
||||
// Deprecated test
|
||||
/// Deprecated test.
|
||||
public class SignInSpec: QuickSpec {
|
||||
override public func spec() {
|
||||
beforeSuite {
|
||||
|
|
|
@ -67,10 +67,10 @@ public class UninstallSpec: QuickSpec {
|
|||
try uninstall.run(appLibrary: mockLibrary)
|
||||
}
|
||||
}
|
||||
== " 1111 slack (0.0)\n==> Some App /tmp/Some.app\n==> (not removed, dry run)\n"
|
||||
== "==> Some App /tmp/Some.app\n==> (not removed, dry run)\n"
|
||||
}
|
||||
it("fails if there is a problem with the trash command") {
|
||||
var brokenUninstall = app // make mutable copy
|
||||
var brokenUninstall = app
|
||||
brokenUninstall.bundlePath = "/dev/null"
|
||||
mockLibrary.installedApps.append(brokenUninstall)
|
||||
expect {
|
||||
|
|
|
@ -24,7 +24,7 @@ public class VersionSpec: QuickSpec {
|
|||
try Mas.Version.parse([]).run()
|
||||
}
|
||||
}
|
||||
== Package.version + "\n"
|
||||
== "\(Package.version)\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
@testable import mas
|
||||
|
||||
class AppLibraryMock: AppLibrary {
|
||||
var installedApps = [SoftwareProduct]()
|
||||
var installedApps: [SoftwareProduct] = []
|
||||
|
||||
func uninstallApp(app: SoftwareProduct) throws {
|
||||
if !installedApps.contains(where: { product -> Bool in
|
||||
|
|
|
@ -18,8 +18,7 @@ class StoreSearchMock: StoreSearch {
|
|||
}
|
||||
|
||||
func lookup(appID: AppID) -> Promise<SearchResult?> {
|
||||
guard let result = apps[appID]
|
||||
else {
|
||||
guard let result = apps[appID] else {
|
||||
return Promise(error: MASError.noSearchResultsFound)
|
||||
}
|
||||
|
||||
|
|
|
@ -13,16 +13,18 @@ import XCTest
|
|||
|
||||
class MASErrorTestCase: XCTestCase {
|
||||
private let errorDomain = "MAS"
|
||||
var error: MASError!
|
||||
var nserror: NSError!
|
||||
private var error: MASError!
|
||||
private var nserror: NSError!
|
||||
|
||||
/// Convenience property for setting the value which will be use for the localized description
|
||||
/// value of the next NSError created. Only used when the NSError does not have a user info
|
||||
/// value of the next NSError created.
|
||||
///
|
||||
/// Only used when the NSError does not have a user info
|
||||
/// entry for localized description.
|
||||
var localizedDescription: String {
|
||||
private var localizedDescription: String {
|
||||
get { "dummy value" }
|
||||
set {
|
||||
NSError.setUserInfoValueProvider(forDomain: errorDomain) { (_: Error, _: String) -> Any? in
|
||||
NSError.setUserInfoValueProvider(forDomain: errorDomain) { _, _ in
|
||||
newValue
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,8 @@ import Foundation
|
|||
|
||||
extension Data {
|
||||
/// Unsafe initializer for loading data from string paths.
|
||||
/// - Parameter file: Relative path within the JSON folder
|
||||
///
|
||||
/// - Parameter fileName: Relative path within the JSON folder
|
||||
init(from fileName: String) {
|
||||
let fileURL = Bundle.url(for: fileName)!
|
||||
try! self.init(contentsOf: fileURL, options: .mappedIfSafe)
|
||||
|
|
|
@ -11,7 +11,7 @@ import Quick
|
|||
|
||||
@testable import mas
|
||||
|
||||
public class AppListsFormatterSpec: QuickSpec {
|
||||
public class AppListFormatterSpec: QuickSpec {
|
||||
override public func spec() {
|
||||
// static func reference
|
||||
let format = AppListFormatter.format(products:)
|
||||
|
@ -25,7 +25,7 @@ public class AppListsFormatterSpec: QuickSpec {
|
|||
products = []
|
||||
}
|
||||
it("formats nothing as empty string") {
|
||||
expect(format(products)) == ""
|
||||
expect(format(products)).to(beEmpty())
|
||||
}
|
||||
it("can format a single product") {
|
||||
let product = SoftwareProductMock(
|
||||
|
|
|
@ -11,7 +11,7 @@ import Quick
|
|||
|
||||
@testable import mas
|
||||
|
||||
public class SearchResultsFormatterSpec: QuickSpec {
|
||||
public class SearchResultFormatterSpec: QuickSpec {
|
||||
override public func spec() {
|
||||
// static func reference
|
||||
let format = SearchResultFormatter.format(results:includePrice:)
|
||||
|
@ -25,7 +25,7 @@ public class SearchResultsFormatterSpec: QuickSpec {
|
|||
results = []
|
||||
}
|
||||
it("formats nothing as empty string") {
|
||||
expect(format(results, false)) == ""
|
||||
expect(format(results, false)).to(beEmpty())
|
||||
}
|
||||
it("can format a single result") {
|
||||
let result = SearchResult(
|
||||
|
|
|
@ -11,7 +11,7 @@ import XCTest
|
|||
@testable import mas
|
||||
|
||||
class NetworkManagerTests: XCTestCase {
|
||||
override public func setUp() {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
Mas.initialize()
|
||||
}
|
||||
|
|
|
@ -18,11 +18,6 @@ class NetworkSessionMock: NetworkSession {
|
|||
var data: Data?
|
||||
var error: Error?
|
||||
|
||||
/// Immediately passes data and error to completion handler.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: unused
|
||||
/// - completionHandler: Closure which is delivered either data or an error.
|
||||
func loadData(from _: URL) -> Promise<Data> {
|
||||
guard let data else {
|
||||
return Promise(error: error ?? MASError.noData)
|
||||
|
|
|
@ -21,14 +21,10 @@ class NetworkSessionMockFromFile: NetworkSessionMock {
|
|||
self.responseFile = responseFile
|
||||
}
|
||||
|
||||
/// Loads data from a file.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: unused
|
||||
/// - completionHandler: Closure which is delivered either data or an error.
|
||||
override func loadData(from _: URL) -> Promise<Data> {
|
||||
guard let fileURL = Bundle.url(for: responseFile)
|
||||
else { fatalError("Unable to load file \(responseFile)") }
|
||||
guard let fileURL = Bundle.url(for: responseFile) else {
|
||||
fatalError("Unable to load file \(responseFile)")
|
||||
}
|
||||
|
||||
do {
|
||||
return .value(try Data(contentsOf: fileURL, options: .mappedIfSafe))
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
- Use `script/lint` to look for these before committing.
|
||||
- Note that [two trailing spaces](https://gist.github.com/shaunlebron/746476e6e7a4d698b373)
|
||||
is intentional in markdown documents to create a line break like `<br>`, so these should _not_ be removed.
|
||||
- End each file with a [newline character](https://unix.stackexchange.com/questions/18743/whats-the-point-in-adding-a-new-line-to-the-end-of-a-file#18789).
|
||||
- End each file with a [newline character](
|
||||
https://unix.stackexchange.com/questions/18743/whats-the-point-in-adding-a-new-line-to-the-end-of-a-file#18789
|
||||
).
|
||||
|
||||
## Swift
|
||||
|
||||
|
@ -14,13 +16,13 @@
|
|||
- Avoid [force unwrapping optionals](https://blog.timac.org/2017/0628-swift-banning-force-unwrapping-optionals/)
|
||||
with `!` in production code
|
||||
- Production code is what gets shipped with the app. Basically, everything under the
|
||||
[`mas-cli/`](https://github.com/mas-cli/mas/tree/main/mas-cli) folder.
|
||||
[`Sources/mas`](https://github.com/mas-cli/mas/tree/main/Sources/mas) folder.
|
||||
- However, force unwrapping is **encouraged** in tests for less code and tests
|
||||
_should_ break when any expected conditions aren't met.
|
||||
- Prefer `struct`s over `class`es wherever possible
|
||||
- Default to marking classes as `final`
|
||||
- Prefer protocol conformance to class inheritance
|
||||
- Break long lines after 120 characters
|
||||
- Break lines at 120 characters
|
||||
- Use 4 spaces for indentation
|
||||
- Use `let` whenever possible to make immutable variables
|
||||
- Name all parameters in functions and enum cases
|
||||
|
@ -28,10 +30,7 @@ with `!` in production code
|
|||
- Let the compiler infer the type whenever possible
|
||||
- Group computed properties below stored properties
|
||||
- Use a blank line above and below computed properties
|
||||
- Group methods into specific extensions for each level of access control
|
||||
- Group functions into separate extensions for each level of access control
|
||||
- When capitalizing acronyms or initialisms, follow the capitalization of the first letter.
|
||||
- When using `Void` in function signatures, prefer `()` for arguments and `Void` for return types.
|
||||
- Prefer strong `IBOutlet` references.
|
||||
- Avoid evaluating a weak reference multiple times in the same scope. Strongify first, then use the strong reference.
|
||||
- Prefer to name `IBAction` and target/action methods using a verb describing the action it will trigger, instead
|
||||
of the user action (e.g., `edit:` instead of `editTapped:`)
|
||||
|
|
|
@ -20,7 +20,7 @@ VERSION=$(git describe --abbrev=0 --tags)
|
|||
VERSION=${VERSION#v}
|
||||
|
||||
cat <<EOF >"Sources/mas/Package.swift"
|
||||
// Generated by: script/version
|
||||
/// Generated by \`script/version\`.
|
||||
enum Package {
|
||||
static let version = "${VERSION}"
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue