Merge pull request #505 from mas-cli/search

🖥️ Only search, outdated and update macOS apps
This commit is contained in:
Ben Chatelain 2024-02-18 00:02:56 -07:00 committed by GitHub
commit 21ed1e641f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 216 additions and 199 deletions

View file

@ -55,49 +55,40 @@
"version": "5.0.1"
}
},
{
"package": "Regex",
"repositoryURL": "https://github.com/sharplet/Regex.git",
"state": {
"branch": null,
"revision": "76c2b73d4281d77fc3118391877efd1bf972f515",
"version": "2.1.1"
}
},
{
"package": "swift-argument-parser",
"repositoryURL": "https://github.com/apple/swift-argument-parser.git",
"state": {
"branch": null,
"revision": "9f39744e025c7d377987f30b03770805dcb0bcd1",
"version": "1.1.4"
"revision": "c8ed701b513cf5177118a175d85fbbbcd707ab41",
"version": "1.3.0"
}
},
{
"package": "swift-format",
"repositoryURL": "https://github.com/apple/swift-format",
"state": {
"branch": "release/5.7",
"revision": "3dd9b517b9e9846435aa782d769ef5825e7c2d65",
"branch": "release/5.9",
"revision": "1323e87eced56bdcfed1bb78af1f16f39274d032",
"version": null
}
},
{
"package": "SwiftSyntax",
"repositoryURL": "https://github.com/apple/swift-syntax",
"package": "swift-syntax",
"repositoryURL": "https://github.com/apple/swift-syntax.git",
"state": {
"branch": null,
"revision": "72d3da66b085c2299dd287c2be3b92b5ebd226de",
"version": "0.50700.1"
}
},
{
"package": "swift-system",
"repositoryURL": "https://github.com/apple/swift-system.git",
"state": {
"branch": null,
"revision": "836bc4557b74fe6d2660218d56e3ce96aff76574",
"version": "1.1.1"
}
},
{
"package": "swift-tools-support-core",
"repositoryURL": "https://github.com/apple/swift-tools-support-core.git",
"state": {
"branch": null,
"revision": "4f07be3dc201f6e2ee85b6942d0c220a16926811",
"version": "0.2.7"
"branch": "release/5.9",
"revision": "9a101b70eee2a9dec04f92d2d47b22ebe57a1aae",
"version": null
}
},
{

View file

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

View file

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

View file

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

View file

@ -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
}
@ -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]
}
return loadSearchResults(url)
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)
}
// 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.
@ -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)
}
}
}

View file

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

View file

@ -27,7 +27,6 @@ public enum MASError: Error, Equatable {
case notInstalled
case uninstallFailed
case urlEncoding
case noData
case jsonParsing(error: NSError?)
}
@ -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"

View file

@ -14,6 +14,7 @@ protocol ExternalCommand {
var process: Process { get }
var stdout: String { get }
var stderr: String { get }
var stdoutPipe: Pipe { get }
var stderrPipe: Pipe { get }
@ -28,6 +29,11 @@ protocol ExternalCommand {
/// Common implementation
extension ExternalCommand {
var stdout: String {
let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? ""
}
var stderr: String {
let data = stderrPipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? ""

View file

@ -0,0 +1,40 @@
//
// SysCtlSystemCommand.swift
// MasKit
//
// Created by Chris Araman on 6/3/21.
// Copyright © 2021 mas-cli. All rights reserved.
//
import Foundation
/// Wrapper for the external sysctl system command.
/// 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()
do {
// Returns 1 on Apple Silicon even when run in an Intel context in Rosetta.
try sysctl.run(arguments: "-in", "hw.optional.arm64")
} catch {
fatalError("sysctl failed")
}
guard sysctl.succeeded else {
fatalError("sysctl failed")
}
return sysctl.stdout.trimmingCharacters(in: .newlines) == "1"
}()
}

View file

@ -11,17 +11,18 @@ import Quick
@testable import MasKit
// Deprecated test
public class AccountCommandSpec: QuickSpec {
override public func spec() {
beforeSuite {
MasKit.initialize()
}
describe("Account command") {
it("displays active account") {
// account command disabled since macOS 12 Monterey https://github.com/mas-cli/mas#%EF%B8%8F-known-issues
xdescribe("Account command") {
xit("displays active account") {
let cmd = AccountCommand()
let result = cmd.run(AccountCommand.Options())
print(result)
// expect(result).to(beSuccess())
expect(result).to(beSuccess())
}
}
}

View file

@ -20,8 +20,7 @@ public class InstallCommandSpec: QuickSpec {
it("installs apps") {
let cmd = InstallCommand()
let result = cmd.run(InstallCommand.Options(appIds: [], forceInstall: false))
print(result)
// expect(result).to(beSuccess())
expect(result).to(beSuccess())
}
}
}

View file

@ -17,11 +17,10 @@ public class ListCommandSpec: QuickSpec {
MasKit.initialize()
}
describe("list command") {
it("lists stuff") {
it("lists apps") {
let list = ListCommand()
let result = list.run(ListCommand.Options())
print(result)
// expect(result).to(beSuccess())
expect(result).to(beSuccess())
}
}
}

View file

@ -13,15 +13,17 @@ import Quick
public class LuckyCommandSpec: QuickSpec {
override public func spec() {
let networkSession = NetworkSessionMockFromFile(responseFile: "search/slack.json")
let storeSearch = MasStoreSearch(networkManager: NetworkManager(session: networkSession))
beforeSuite {
MasKit.initialize()
}
describe("lucky command") {
it("installs the first app matching a search") {
let cmd = LuckyCommand()
let result = cmd.run(LuckyCommand.Options(appName: "", forceInstall: false))
print(result)
// expect(result).to(beSuccess())
xit("installs the first app matching a search") {
let cmd = LuckyCommand(storeSearch: storeSearch)
let result = cmd.run(LuckyCommand.Options(appName: "Slack", forceInstall: false))
expect(result).to(beSuccess())
}
}
}

View file

@ -20,8 +20,11 @@ public class PurchaseCommandSpec: QuickSpec {
it("purchases apps") {
let cmd = PurchaseCommand()
let result = cmd.run(PurchaseCommand.Options(appIds: []))
print(result)
// expect(result).to(beSuccess())
expect(result)
.to(
beFailure { error in
expect(error) == .notSupported
})
}
}
}

View file

@ -17,11 +17,10 @@ public class ResetCommandSpec: QuickSpec {
MasKit.initialize()
}
describe("reset command") {
it("updates stuff") {
it("resets the App Store state") {
let cmd = ResetCommand()
let result = cmd.run(ResetCommand.Options(debug: false))
print(result)
// expect(result).to(beSuccess())
expect(result).to(beSuccess())
}
}
}

View file

@ -11,17 +11,18 @@ import Quick
@testable import MasKit
// Deprecated test
public class SignInCommandSpec: QuickSpec {
override public func spec() {
beforeSuite {
MasKit.initialize()
}
describe("signn command") {
it("updates stuff") {
// account command disabled since macOS 10.13 High Sierra https://github.com/mas-cli/mas#%EF%B8%8F-known-issues
xdescribe("signin command") {
xit("signs in") {
let cmd = SignInCommand()
let result = cmd.run(SignInCommand.Options(username: "", password: "", dialog: false))
print(result)
// expect(result).to(beSuccess())
expect(result).to(beSuccess())
}
}
}

View file

@ -17,11 +17,10 @@ public class SignOutCommandSpec: QuickSpec {
MasKit.initialize()
}
describe("signout command") {
it("updates stuff") {
it("signs out") {
let cmd = SignOutCommand()
let result = cmd.run(SignOutCommand.Options())
print(result)
// expect(result).to(beSuccess())
expect(result).to(beSuccess())
}
}
}

View file

@ -17,11 +17,10 @@ public class UpgradeCommandSpec: QuickSpec {
MasKit.initialize()
}
describe("upgrade command") {
it("updates stuff") {
it("upgrades stuff") {
let cmd = UpgradeCommand()
let result = cmd.run(UpgradeCommand.Options(apps: [""]))
print(result)
// expect(result).to(beSuccess())
expect(result).to(beSuccess())
}
}
}

View file

@ -20,8 +20,7 @@ public class VersionCommandSpec: QuickSpec {
it("displays the current version") {
let cmd = VersionCommand()
let result = cmd.run(VersionCommand.Options())
print(result)
// expect(result).to(beSuccess())
expect(result).to(beSuccess())
}
}
}

View file

@ -16,6 +16,23 @@ public class MasStoreSearchSpec: QuickSpec {
beforeSuite {
MasKit.initialize()
}
describe("url string") {
it("contains the app name") {
let appName = "myapp"
let urlString = MasStoreSearch().searchURL(for: appName, inCountry: "US")?.absoluteString
expect(urlString) == """
https://itunes.apple.com/search?media=software&entity=macSoftware&term=\(appName)&country=US
"""
}
it("contains the encoded app name") {
let appName = "My App"
let appNameEncoded = "My%20App"
let urlString = MasStoreSearch().searchURL(for: appName, inCountry: "US")?.absoluteString
expect(urlString) == """
https://itunes.apple.com/search?media=software&entity=macSoftware&term=\(appNameEncoded)&country=US
"""
}
}
describe("store") {
context("when searched") {
it("can find slack") {

View file

@ -1,54 +0,0 @@
//
// StoreSearchSpec.swift
// MasKitTests
//
// Created by Ben Chatelain on 1/11/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
import Nimble
import PromiseKit
import Quick
@testable import MasKit
/// Protocol minimal implementation
struct StoreSearchForTesting: StoreSearch {
func lookup(app _: Int) -> Promise<SearchResult?> {
.value(nil)
}
func search(for _: String) -> Promise<[SearchResult]> {
.value([])
}
}
public class StoreSearchSpec: QuickSpec {
override public func spec() {
let storeSearch = StoreSearchForTesting()
let region = Locale.autoupdatingCurrent.regionCode!
describe("url string") {
it("contains the app name") {
let appName = "myapp"
let urlString = storeSearch.searchURL(for: appName)?.absoluteString
expect(urlString) == "https://itunes.apple.com/search?"
+ "media=software&entity=macSoftware&term=\(appName)&country=\(region)"
}
it("contains the encoded app name") {
let appName = "My App"
let appNameEncoded = "My%20App"
let urlString = storeSearch.searchURL(for: appName)?.absoluteString
expect(urlString) == "https://itunes.apple.com/search?"
+ "media=software&entity=macSoftware&term=\(appNameEncoded)&country=\(region)"
}
// Find a character that causes addingPercentEncoding(withAllowedCharacters to return nil
xit("is nil when app name cannot be url encoded") {
let appName = "`~!@#$%^&*()_+ 💩"
let urlString = storeSearch.searchURL(for: appName)?.absoluteString
expect(urlString).to(beNil())
}
}
}
}

View file

@ -116,11 +116,6 @@ class MASErrorTestCase: XCTestCase {
XCTAssertEqual(error.description, "Uninstall failed")
}
func testUrlEncoding() {
error = .urlEncoding
XCTAssertEqual(error.description, "Unable to encode service URL")
}
func testNoData() {
error = .noData
XCTAssertEqual(error.description, "Service did not return data")