Skip to content

Commit

Permalink
Merge pull request #505 from mas-cli/search
Browse files Browse the repository at this point in the history
🖥️ Only search, outdated and update macOS apps
  • Loading branch information
phatblat committed Feb 18, 2024
2 parents 85a31ed + 521df64 commit 21ed1e6
Show file tree
Hide file tree
Showing 22 changed files with 216 additions and 199 deletions.
45 changes: 18 additions & 27 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -56,48 +56,39 @@
}
},
{
"package": "swift-argument-parser",
"repositoryURL": "https://github.com/apple/swift-argument-parser.git",
"package": "Regex",
"repositoryURL": "https://github.com/sharplet/Regex.git",
"state": {
"branch": null,
"revision": "9f39744e025c7d377987f30b03770805dcb0bcd1",
"version": "1.1.4"
}
},
{
"package": "swift-format",
"repositoryURL": "https://github.com/apple/swift-format",
"state": {
"branch": "release/5.7",
"revision": "3dd9b517b9e9846435aa782d769ef5825e7c2d65",
"version": null
"revision": "76c2b73d4281d77fc3118391877efd1bf972f515",
"version": "2.1.1"
}
},
{
"package": "SwiftSyntax",
"repositoryURL": "https://github.com/apple/swift-syntax",
"package": "swift-argument-parser",
"repositoryURL": "https://github.com/apple/swift-argument-parser.git",
"state": {
"branch": null,
"revision": "72d3da66b085c2299dd287c2be3b92b5ebd226de",
"version": "0.50700.1"
"revision": "c8ed701b513cf5177118a175d85fbbbcd707ab41",
"version": "1.3.0"
}
},
{
"package": "swift-system",
"repositoryURL": "https://github.com/apple/swift-system.git",
"package": "swift-format",
"repositoryURL": "https://github.com/apple/swift-format",
"state": {
"branch": null,
"revision": "836bc4557b74fe6d2660218d56e3ce96aff76574",
"version": "1.1.1"
"branch": "release/5.9",
"revision": "1323e87eced56bdcfed1bb78af1f16f39274d032",
"version": null
}
},
{
"package": "swift-tools-support-core",
"repositoryURL": "https://github.com/apple/swift-tools-support-core.git",
"package": "swift-syntax",
"repositoryURL": "https://github.com/apple/swift-syntax.git",
"state": {
"branch": null,
"revision": "4f07be3dc201f6e2ee85b6942d0c220a16926811",
"version": "0.2.7"
"branch": "release/5.9",
"revision": "9a101b70eee2a9dec04f92d2d47b22ebe57a1aae",
"version": null
}
},
{
Expand Down
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

0 comments on commit 21ed1e6

Please sign in to comment.