Merge pull request #593 from rgoldberg/592-lint-cleanup

Additional linting / cleanup code
This commit is contained in:
Ross Goldberg 2024-10-23 06:23:46 -04:00 committed by GitHub
commit bb67ea5e48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 411 additions and 256 deletions

View file

@ -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
}

View file

@ -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

View file

@ -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]
]

View file

@ -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
}

View file

@ -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))
}
)
}
)
}
}
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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")

View file

@ -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 {

View file

@ -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(

View file

@ -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

View file

@ -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"

View file

@ -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")

View file

@ -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
}

View file

@ -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)

View file

@ -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 }

View file

@ -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

View file

@ -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

View file

@ -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?

View file

@ -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
}

View file

@ -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"
}
}
}

View file

@ -8,7 +8,7 @@
import Foundation
/// CLI command
/// Represents a CLI command.
protocol ExternalCommand {
var binaryPath: String { get set }

View file

@ -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

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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

View file

@ -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 {

View file

@ -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)

View 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
}

View file

@ -8,4 +8,5 @@
disabled_rules:
- force_cast
- force_try
- function_body_length
- implicitly_unwrapped_optional
- no_magic_numbers

View file

@ -11,7 +11,7 @@ import Quick
@testable import mas
// Deprecated test
/// Deprecated test.
public class AccountSpec: QuickSpec {
override public func spec() {
beforeSuite {

View file

@ -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 {

View file

@ -11,7 +11,7 @@ import Quick
@testable import mas
// Deprecated test
/// Deprecated test.
public class SignInSpec: QuickSpec {
override public func spec() {
beforeSuite {

View file

@ -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 {

View file

@ -24,7 +24,7 @@ public class VersionSpec: QuickSpec {
try Mas.Version.parse([]).run()
}
}
== Package.version + "\n"
== "\(Package.version)\n"
}
}
}

View file

@ -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

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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)

View file

@ -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(

View file

@ -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(

View file

@ -11,7 +11,7 @@ import XCTest
@testable import mas
class NetworkManagerTests: XCTestCase {
override public func setUp() {
override func setUp() {
super.setUp()
Mas.initialize()
}

View file

@ -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)

View file

@ -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))

View file

@ -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:`)

View file

@ -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}"
}