Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 18 additions & 27 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ let package = Package(
.package(url: "https://github.com/Quick/Quick.git", from: "5.0.0"),
.package(url: "https://github.com/mxcl/PromiseKit.git", from: "6.16.2"),
.package(url: "https://github.com/mxcl/Version.git", from: "2.0.1"),
.package(url: "https://github.com/sharplet/Regex.git", from: "2.1.1"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
Expand All @@ -42,7 +43,12 @@ let package = Package(
),
.target(
name: "MasKit",
dependencies: ["Commandant", "PromiseKit", "Version"],
dependencies: [
"Commandant",
"PromiseKit",
"Regex",
"Version",
],
swiftSettings: [
.unsafeFlags([
"-I", "Sources/PrivateFrameworks/CommerceKit",
Expand Down
2 changes: 1 addition & 1 deletion Sources/MasKit/Commands/Outdated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,6 @@ public struct OutdatedOptions: OptionsProtocol {

public static func evaluate(_ mode: CommandMode) -> Result<OutdatedOptions, CommandantError<MASError>> {
create
<*> mode <| Switch(flag: nil, key: "verbose", usage: "Show warnings about apps")
<*> mode <| Switch(flag: nil, key: "verbose", usage: "Show warnings about apps")
}
}
20 changes: 10 additions & 10 deletions Sources/MasKit/Commands/Upgrade.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,18 @@ public struct UpgradeCommand: CommandProtocol {
}

private func findOutdatedApps(_ options: Options) throws -> [(SoftwareProduct, SearchResult)] {
let apps: [SoftwareProduct] = options.apps.isEmpty
let apps: [SoftwareProduct] =
options.apps.isEmpty
? appLibrary.installedApps
:
options.apps.compactMap {
if let appId = UInt64($0) {
// if argument a UInt64, lookup app by id using argument
return appLibrary.installedApp(forId: appId)
} else {
// if argument not a UInt64, lookup app by name using argument
return appLibrary.installedApp(named: $0)
}
: options.apps.compactMap {
if let appId = UInt64($0) {
// if argument a UInt64, lookup app by id using argument
return appLibrary.installedApp(forId: appId)
} else {
// if argument not a UInt64, lookup app by name using argument
return appLibrary.installedApp(named: $0)
}
}

let promises = apps.map { installedApp in
// only upgrade apps whose local version differs from the store version
Expand Down
122 changes: 74 additions & 48 deletions Sources/MasKit/Controllers/MasStoreSearch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,27 @@

import Foundation
import PromiseKit
import Regex
import Version

/// Manages searching the MAS catalog through the iTunes Search and Lookup APIs.
class MasStoreSearch: StoreSearch {
private static let appVersionExpression = Regex(#"\"versionDisplay\"\:\"([^\"]+)\""#)

// CommerceKit and StoreFoundation don't seem to expose the region of the Apple ID signed
// into the App Store. Instead, we'll make an educated guess that it matches the currently
// selected locale in macOS. This obviously isn't always going to match, but it's probably
// better than passing no "country" at all to the iTunes Search API.
// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/
private let country: String?
private let networkManager: NetworkManager
private static let versionExpression: NSRegularExpression = {
do {
return try NSRegularExpression(pattern: #"\"versionDisplay\"\:\"([^\"]+)\""#)
} catch {
fatalError("Unexpected error initializing NSRegularExpression: \(error.localizedDescription)")
}
}()

/// Designated initializer.
init(networkManager: NetworkManager = NetworkManager()) {
init(
country: String? = Locale.autoupdatingCurrent.regionCode,
networkManager: NetworkManager = NetworkManager()
) {
self.country = country
self.networkManager = networkManager
}

Expand All @@ -32,12 +38,25 @@ class MasStoreSearch: StoreSearch {
/// - 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]> {
guard let url = searchURL(for: appName)
else {
return Promise(error: MASError.urlEncoding)
// Search for apps for compatible platforms, in order of preference.
// Macs with Apple Silicon can run iPad and iPhone apps.
var entities = [Entity.macSoftware]
if SysCtlSystemCommand.isAppleSilicon {
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)")
}
return loadSearchResults(url)
}

return loadSearchResults(url)
// Combine the results, removing any duplicates.
var seenAppIDs = Set<Int>()
return when(fulfilled: results).flatMapValues { $0 }.filterValues { result in
seenAppIDs.insert(result.trackId).inserted
}
}

/// Looks up app details.
Expand All @@ -46,64 +65,71 @@ class MasStoreSearch: StoreSearch {
/// - Returns: A Promise for the search result record of app, or nil if no apps match the ID,
/// or an Error if there is a problem with the network request.
func lookup(app appId: Int) -> Promise<SearchResult?> {
guard let url = lookupURL(forApp: appId)
else {
return Promise(error: MASError.urlEncoding)
guard let url = lookupURL(forApp: appId, inCountry: country) else {
fatalError("Failed to build URL for \(appId)")
}
return firstly {
loadSearchResults(url)
}.then { results -> Guarantee<SearchResult?> in
guard let result = results.first else {
return .value(nil)
}

return loadSearchResults(url).map { results in results.first }
guard let pageUrl = URL(string: result.trackViewUrl)
else {
return .value(result)
}

return firstly {
self.scrapeAppStoreVersion(pageUrl)
}.map { pageVersion in
guard let pageVersion,
let searchVersion = Version(tolerant: result.version),
pageVersion > searchVersion
else {
return result
}

// Update the search result with the version from the App Store page.
var result = result
result.version = pageVersion.description
return result
}.recover { _ in
// If we were unable to scrape the App Store page, assume compatibility.
.value(result)
}
}
}

private func loadSearchResults(_ url: URL) -> Promise<[SearchResult]> {
firstly {
networkManager.loadData(from: url)
}.map { data -> SearchResultList in
}.map { data -> [SearchResult] in
do {
return try JSONDecoder().decode(SearchResultList.self, from: data)
return try JSONDecoder().decode(SearchResultList.self, from: data).results
} catch {
throw MASError.jsonParsing(error: error as NSError)
}
}.then { list -> Promise<[SearchResult]> in
var results = list.results
let scraping = results.indices.compactMap { index -> Guarantee<Void>? in
let result = results[index]
guard let searchVersion = Version(tolerant: result.version),
let pageUrl = URL(string: result.trackViewUrl)
else {
return nil
}

return firstly {
self.scrapeVersionFromPage(pageUrl)
}.done { pageVersion in
if let pageVersion, pageVersion > searchVersion {
results[index].version = pageVersion.description
}
}
}

return when(fulfilled: scraping).map { results }
}
}

// The App Store often lists a newer version available in an app's page than in
// the search results. We attempt to scrape it here.
private func scrapeVersionFromPage(_ pageUrl: URL) -> Guarantee<Version?> {
// 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.
private func scrapeAppStoreVersion(_ pageUrl: URL) -> Promise<Version?> {
firstly {
networkManager.loadData(from: pageUrl)
}.map { data in
let html = String(decoding: data, as: UTF8.self)
let fullRange = NSRange(html.startIndex..<html.endIndex, in: html)
guard let match = MasStoreSearch.versionExpression.firstMatch(in: html, range: fullRange),
let range = Range(match.range(at: 1), in: html),
let version = Version(tolerant: html[range])
guard let capture = MasStoreSearch.appVersionExpression.firstMatch(in: html)?.captures[0],
let version = Version(tolerant: capture)
else {
throw MASError.noData
return nil
}

return version
}.recover { _ in
.value(nil)
}
}
}
29 changes: 11 additions & 18 deletions Sources/MasKit/Controllers/StoreSearch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,31 @@ protocol StoreSearch {
func search(for appName: String) -> Promise<[SearchResult]>
}

enum Entity: String {
case macSoftware
case iPadSoftware
case iPhoneSoftware = "software"
}

// MARK: - Common methods
extension StoreSearch {
/// Builds the search URL for an app.
///
/// - Parameter appName: MAS app identifier.
/// - Returns: URL for the search service or nil if appName can't be encoded.
func searchURL(for appName: String) -> URL? {
func searchURL(for appName: String, inCountry country: String?, ofEntity entity: Entity = .macSoftware) -> URL? {
guard var components = URLComponents(string: "https://itunes.apple.com/search") else {
return nil
}

components.queryItems = [
URLQueryItem(name: "media", value: "software"),
URLQueryItem(name: "entity", value: "macSoftware"),
URLQueryItem(name: "entity", value: entity.rawValue),
URLQueryItem(name: "term", value: appName),
]

if let country {
components.queryItems!.append(country)
components.queryItems!.append(URLQueryItem(name: "country", value: country))
}

return components.url
Expand All @@ -43,7 +49,7 @@ extension StoreSearch {
///
/// - Parameter appId: MAS app identifier.
/// - Returns: URL for the lookup service or nil if appId can't be encoded.
func lookupURL(forApp appId: Int) -> URL? {
func lookupURL(forApp appId: Int, inCountry country: String?) -> URL? {
guard var components = URLComponents(string: "https://itunes.apple.com/lookup") else {
return nil
}
Expand All @@ -54,22 +60,9 @@ extension StoreSearch {
]

if let country {
components.queryItems!.append(country)
components.queryItems!.append(URLQueryItem(name: "country", value: country))
}

return components.url
}

private var country: URLQueryItem? {
// CommerceKit and StoreFoundation don't seem to expose the region of the Apple ID signed
// into the App Store. Instead, we'll make an educated guess that it matches the currently
// selected locale in macOS. This obviously isn't always going to match, but it's probably
// better than passing no "country" at all to the iTunes Search API.
// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/
guard let region = Locale.autoupdatingCurrent.regionCode else {
return nil
}

return URLQueryItem(name: "country", value: region)
}
}
4 changes: 0 additions & 4 deletions Sources/MasKit/Errors/MASError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ public enum MASError: Error, Equatable {
case notInstalled
case uninstallFailed

case urlEncoding
case noData
case jsonParsing(error: NSError?)
}
Expand Down Expand Up @@ -91,9 +90,6 @@ extension MASError: CustomStringConvertible {
case .uninstallFailed:
return "Uninstall failed"

case .urlEncoding:
return "Unable to encode service URL"

case .noData:
return "Service did not return data"

Expand Down
Loading