Replace clunky ExternalCommand code that starts new processes with Apple library calls.

Resolve #620

Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com>
This commit is contained in:
Ross Goldberg 2024-10-28 11:26:11 -04:00
parent a3dbbde513
commit 9ebb01805d
No known key found for this signature in database
12 changed files with 83 additions and 274 deletions

View file

@ -7,6 +7,7 @@
//
import ArgumentParser
import Foundation
extension MAS {
/// Opens app page on MAS Preview. Uses the iTunes Lookup API:
@ -21,29 +22,19 @@ extension MAS {
/// Runs the command.
func run() throws {
try run(searcher: ITunesSearchAppStoreSearcher(), openCommand: OpenSystemCommand())
try run(searcher: ITunesSearchAppStoreSearcher())
}
func run(searcher: AppStoreSearcher, openCommand: ExternalCommand) throws {
do {
guard let result = try searcher.lookup(appID: appID).wait() else {
throw MASError.noSearchResultsFound
}
do {
try openCommand.run(arguments: result.trackViewUrl)
} catch {
printError("Unable to launch open command")
throw MASError.searchFailed
}
if openCommand.failed {
let reason = openCommand.process.terminationReason
printError("Open failed: (\(reason)) \(openCommand.stderr)")
throw MASError.searchFailed
}
} catch {
throw error as? MASError ?? .searchFailed
func run(searcher: AppStoreSearcher) throws {
guard let result = try searcher.lookup(appID: appID).wait() else {
throw MASError.noSearchResultsFound
}
guard let url = URL(string: result.trackViewUrl) else {
throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)")
}
try url.open().wait()
}
}
}

View file

@ -8,7 +8,6 @@
import AppKit
import ArgumentParser
import Foundation
import PromiseKit
private let masScheme = "macappstore"
@ -35,7 +34,7 @@ extension MAS {
try openMacAppStore().wait()
return
}
try openInMacAppStore(pageForAppID: appID, searcher: searcher).wait()
try openInMacAppStore(pageForAppID: appID, searcher: searcher)
}
}
}
@ -63,32 +62,20 @@ private func openMacAppStore() -> Promise<Void> {
}
}
private func openInMacAppStore(pageForAppID appID: AppID, searcher: AppStoreSearcher) -> Promise<Void> {
Promise { seal in
guard let result = try searcher.lookup(appID: appID).wait() else {
throw MASError.runtimeError("Unknown app ID \(appID)")
}
guard var urlComponents = URLComponents(string: result.trackViewUrl) else {
throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)")
}
urlComponents.scheme = masScheme
guard let url = urlComponents.url else {
throw MASError.runtimeError("Unable to construct URL from: \(urlComponents)")
}
if #available(macOS 10.15, *) {
NSWorkspace.shared.open(url, configuration: NSWorkspace.OpenConfiguration()) { _, error in
if let error {
seal.reject(error)
}
seal.fulfill(())
}
} else {
NSWorkspace.shared.open(url)
seal.fulfill(())
}
private func openInMacAppStore(pageForAppID appID: AppID, searcher: AppStoreSearcher) throws {
guard let result = try searcher.lookup(appID: appID).wait() else {
throw MASError.runtimeError("Unknown app ID \(appID)")
}
guard var urlComponents = URLComponents(string: result.trackViewUrl) else {
throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)")
}
urlComponents.scheme = masScheme
guard let url = urlComponents.url else {
throw MASError.runtimeError("Unable to construct URL from: \(urlComponents)")
}
try url.open().wait()
}

View file

@ -7,6 +7,7 @@
//
import ArgumentParser
import Foundation
extension MAS {
/// Opens vendor's app page in a browser. Uses the iTunes Lookup API:
@ -21,33 +22,23 @@ extension MAS {
/// Runs the command.
func run() throws {
try run(searcher: ITunesSearchAppStoreSearcher(), openCommand: OpenSystemCommand())
try run(searcher: ITunesSearchAppStoreSearcher())
}
func run(searcher: AppStoreSearcher, openCommand: ExternalCommand) throws {
do {
guard let result = try searcher.lookup(appID: appID).wait() else {
throw MASError.noSearchResultsFound
}
guard let vendorWebsite = result.sellerUrl else {
throw MASError.noVendorWebsite
}
do {
try openCommand.run(arguments: vendorWebsite)
} catch {
printError("Unable to launch open command")
throw MASError.searchFailed
}
if openCommand.failed {
let reason = openCommand.process.terminationReason
printError("Open failed: (\(reason)) \(openCommand.stderr)")
throw MASError.searchFailed
}
} catch {
throw error as? MASError ?? .searchFailed
func run(searcher: AppStoreSearcher) throws {
guard let result = try searcher.lookup(appID: appID).wait() else {
throw MASError.noSearchResultsFound
}
guard let urlString = result.sellerUrl else {
throw MASError.noVendorWebsite
}
guard let url = URL(string: urlString) else {
throw MASError.runtimeError("Unable to construct URL from: \(urlString)")
}
try url.open().wait()
}
}
}

View file

@ -85,9 +85,9 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher {
// Search for apps for compatible platforms, in order of preference.
// Macs with Apple Silicon can run iPad and iPhone apps.
var entities = [Entity.desktopSoftware]
if SysCtlSystemCommand.isAppleSilicon {
entities += [.iPadSoftware, .iPhoneSoftware]
}
#if arch(arm64)
entities += [.iPadSoftware, .iPhoneSoftware]
#endif
let results = entities.map { entity in
guard let url = searchURL(for: searchTerm, inCountry: country, ofEntity: entity) else {

View file

@ -1,63 +0,0 @@
//
// ExternalCommand.swift
// mas
//
// Created by Ben Chatelain on 1/1/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
/// Represents a CLI command.
protocol ExternalCommand {
var binaryPath: String { get set }
var process: Process { get }
var stdout: String { get }
var stderr: String { get }
var stdoutPipe: Pipe { get }
var stderrPipe: Pipe { get }
var exitCode: Int32 { get }
var succeeded: Bool { get }
var failed: Bool { get }
/// Runs the command.
func run(arguments: String...) throws
}
/// 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) ?? ""
}
var exitCode: Int32 {
process.terminationStatus
}
var succeeded: Bool {
process.terminationReason == .exit && exitCode == 0
}
var failed: Bool {
!succeeded
}
/// Runs the command.
func run(arguments: String...) throws {
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.arguments = arguments
process.executableURL = URL(fileURLWithPath: binaryPath)
try process.run()
process.waitUntilExit()
}
}

View file

@ -1,23 +0,0 @@
//
// OpenSystemCommand.swift
// mas
//
// Created by Ben Chatelain on 1/2/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
/// Wrapper for the external 'open' system command (https://ss64.com/osx/open.html).
struct OpenSystemCommand: ExternalCommand {
var binaryPath: String
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
init(binaryPath: String = "/usr/bin/open") {
self.binaryPath = binaryPath
}
}

View file

@ -1,41 +0,0 @@
//
// SysCtlSystemCommand.swift
// mas
//
// Created by Chris Araman on 6/3/21.
// Copyright © 2021 mas-cli. All rights reserved.
//
import Foundation
/// Wrapper for the external 'sysctl' system command.
///
/// See - https://ss64.com/osx/sysctl.html
struct SysCtlSystemCommand: ExternalCommand {
static var isAppleSilicon: Bool = {
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")
} catch {
fatalError("sysctl failed")
}
guard sysctl.succeeded else {
fatalError("sysctl failed")
}
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

@ -0,0 +1,32 @@
//
// URL.swift
// mas
//
// Created by Ross Goldberg on 2024-10-28.
// Copyright © 2024 mas-cli. All rights reserved.
//
import AppKit
import Foundation
import PromiseKit
extension URL {
func open() -> Promise<Void> {
Promise { seal in
if #available(macOS 10.15, *) {
NSWorkspace.shared.open(self, configuration: NSWorkspace.OpenConfiguration()) { _, error in
if let error {
seal.reject(error)
}
seal.fulfill(())
}
} else {
if NSWorkspace.shared.open(self) {
seal.fulfill(())
} else {
seal.reject(MASError.runtimeError("Failed to open \(self)"))
}
}
}
}
}

View file

@ -14,7 +14,6 @@ import Quick
public class HomeSpec: QuickSpec {
override public func spec() {
let searcher = MockAppStoreSearcher()
let openCommand = MockOpenSystemCommand()
beforeSuite {
MAS.initialize()
@ -25,13 +24,13 @@ public class HomeSpec: QuickSpec {
}
it("fails to open app with invalid ID") {
expect {
try MAS.Home.parse(["--", "-999"]).run(searcher: searcher, openCommand: openCommand)
try MAS.Home.parse(["--", "-999"]).run(searcher: searcher)
}
.to(throwError())
}
it("can't find app with unknown ID") {
expect {
try MAS.Home.parse(["999"]).run(searcher: searcher, openCommand: openCommand)
try MAS.Home.parse(["999"]).run(searcher: searcher)
}
.to(throwError(MASError.noSearchResultsFound))
}
@ -43,11 +42,8 @@ public class HomeSpec: QuickSpec {
)
searcher.apps[mockResult.trackId] = mockResult
expect {
try MAS.Home.parse([String(mockResult.trackId)])
.run(searcher: searcher, openCommand: openCommand)
return openCommand.arguments
try MAS.Home.parse([String(mockResult.trackId)]).run(searcher: searcher)
}
== [mockResult.trackViewUrl]
}
}
}

View file

@ -14,7 +14,6 @@ import Quick
public class VendorSpec: QuickSpec {
override public func spec() {
let searcher = MockAppStoreSearcher()
let openCommand = MockOpenSystemCommand()
beforeSuite {
MAS.initialize()
@ -25,13 +24,13 @@ public class VendorSpec: QuickSpec {
}
it("fails to open app with invalid ID") {
expect {
try MAS.Vendor.parse(["--", "-999"]).run(searcher: searcher, openCommand: openCommand)
try MAS.Vendor.parse(["--", "-999"]).run(searcher: searcher)
}
.to(throwError())
}
it("can't find app with unknown ID") {
expect {
try MAS.Vendor.parse(["999"]).run(searcher: searcher, openCommand: openCommand)
try MAS.Vendor.parse(["999"]).run(searcher: searcher)
}
.to(throwError(MASError.noSearchResultsFound))
}
@ -44,11 +43,8 @@ public class VendorSpec: QuickSpec {
)
searcher.apps[mockResult.trackId] = mockResult
expect {
try MAS.Vendor.parse([String(mockResult.trackId)])
.run(searcher: searcher, openCommand: openCommand)
return openCommand.arguments
try MAS.Vendor.parse([String(mockResult.trackId)]).run(searcher: searcher)
}
== [mockResult.sellerUrl]
}
}
}

View file

@ -1,27 +0,0 @@
//
// MockOpenSystemCommand.swift
// masTests
//
// Created by Ben Chatelain on 1/4/19.
// Copyright © 2019 mas-cli. All rights reserved.
//
import Foundation
@testable import mas
class MockOpenSystemCommand: ExternalCommand {
// Stub out protocol logic
var succeeded = true
var arguments: [String] = []
// unused
var binaryPath = "/dev/null"
var process = Process()
var stdoutPipe = Pipe()
var stderrPipe = Pipe()
func run(arguments: String...) throws {
self.arguments = arguments
}
}

View file

@ -1,30 +0,0 @@
//
// OpenSystemCommandSpec.swift
// masTests
//
// Created by Ben Chatelain on 2/24/20.
// Copyright © 2020 mas-cli. All rights reserved.
//
import Nimble
import Quick
@testable import mas
public class OpenSystemCommandSpec: QuickSpec {
override public func spec() {
beforeSuite {
MAS.initialize()
}
describe("open system command") {
context("binary path") {
it("defaults to the macOS open command") {
expect(OpenSystemCommand().binaryPath) == "/usr/bin/open"
}
it("can be overridden") {
expect(OpenSystemCommand(binaryPath: "/dev/null").binaryPath) == "/dev/null"
}
}
}
}
}