mirror of
https://github.com/mas-cli/mas
synced 2025-02-16 12:38:30 +00:00
Merge pull request #563 from rgoldberg/562-apple-id
Improve `Account`, `SignIn`, `SignOut` & `ISStoreAccount` extension & associated code:
This commit is contained in:
commit
611d4a7fb7
11 changed files with 165 additions and 201 deletions
|
@ -11,6 +11,7 @@
|
|||
--disable blankLinesAroundMark
|
||||
--disable consecutiveSpaces
|
||||
--disable hoistPatternLet
|
||||
--disable hoistTry
|
||||
--disable indent
|
||||
--disable trailingCommas
|
||||
|
||||
|
|
|
@ -162,7 +162,6 @@ To sign into the Mac App Store for the first time run `mas signin`.
|
|||
|
||||
```bash
|
||||
$ mas signin mas@example.com
|
||||
==> Signing in to Apple ID: mas@example.com
|
||||
Password:
|
||||
```
|
||||
|
||||
|
@ -170,15 +169,13 @@ If you experience issues signing in this way, you can ask to sign in using a gra
|
|||
(provided by Mac App Store application):
|
||||
|
||||
```bash
|
||||
$ mas signin --dialog mas@example.com
|
||||
==> Signing in to Apple ID: mas@example.com
|
||||
mas signin --dialog mas@example.com
|
||||
```
|
||||
|
||||
You can also embed your password in the command.
|
||||
|
||||
```bash
|
||||
$ mas signin mas@example.com 'ZdkM4f$gzF;gX3ABXNLf8KcCt.x.np'
|
||||
==> Signing in to Apple ID: mas@example.com
|
||||
mas signin mas@example.com 'ZdkM4f$gzF;gX3ABXNLf8KcCt.x.np'
|
||||
```
|
||||
|
||||
Use `mas signout` to sign out from the Mac App Store.
|
||||
|
|
|
@ -34,77 +34,23 @@ func downloadAll(_ appIDs: [UInt64], purchase: Bool = false) -> Promise<Void> {
|
|||
}
|
||||
}
|
||||
|
||||
private func downloadWithRetries(
|
||||
_ appID: UInt64, purchase: Bool = false, attempts: Int = 3
|
||||
) -> Promise<Void> {
|
||||
download(appID, purchase: purchase).recover { error -> Promise<Void> in
|
||||
guard attempts > 1 else {
|
||||
throw error
|
||||
}
|
||||
|
||||
// If the download failed due to network issues, try again. Otherwise, fail immediately.
|
||||
guard case MASError.downloadFailed(let downloadError) = error,
|
||||
case NSURLErrorDomain = downloadError?.domain
|
||||
else {
|
||||
throw error
|
||||
}
|
||||
|
||||
let attempts = attempts - 1
|
||||
printWarning((downloadError ?? error).localizedDescription)
|
||||
printWarning("Trying again up to \(attempts) more \(attempts == 1 ? "time" : "times").")
|
||||
return downloadWithRetries(appID, purchase: purchase, attempts: attempts)
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads an app, printing progress to the console.
|
||||
///
|
||||
/// - Parameter appID: The ID of the app to be downloaded
|
||||
/// - Parameter purchase: Flag indicating whether the app needs to be purchased.
|
||||
/// Only works for free apps. Defaults to false.
|
||||
/// - Returns: A promise the completes when the download is complete.
|
||||
private func download(_ appID: UInt64, purchase: Bool = false) -> Promise<Void> {
|
||||
var storeAccount: ISStoreAccount?
|
||||
if #unavailable(macOS 12) {
|
||||
// Monterey obscured the user's account information, but still allows
|
||||
// redownloads without passing it to SSPurchase.
|
||||
// https://github.com/mas-cli/mas/issues/417
|
||||
guard let account = ISStoreAccount.primaryAccount else {
|
||||
return Promise(error: MASError.notSignedIn)
|
||||
}
|
||||
|
||||
storeAccount = account as? ISStoreAccount
|
||||
guard storeAccount != nil else {
|
||||
fatalError("Unable to cast StoreAccount to ISStoreAccount")
|
||||
}
|
||||
}
|
||||
|
||||
return Promise<SSPurchase> { seal in
|
||||
let purchase = SSPurchase(adamId: appID, account: storeAccount, purchase: purchase)
|
||||
purchase.perform { purchase, _, error, response in
|
||||
if let error {
|
||||
seal.reject(MASError.purchaseFailed(error: error as NSError?))
|
||||
return
|
||||
private func downloadWithRetries(_ appID: UInt64, purchase: Bool = false, attempts: Int = 3) -> Promise<Void> {
|
||||
SSPurchase().perform(adamId: appID, purchase: purchase)
|
||||
.recover { error -> Promise<Void> in
|
||||
guard attempts > 1 else {
|
||||
throw error
|
||||
}
|
||||
|
||||
guard response?.downloads.isEmpty == false, let purchase else {
|
||||
print("No downloads")
|
||||
seal.reject(MASError.noDownloads)
|
||||
return
|
||||
// If the download failed due to network issues, try again. Otherwise, fail immediately.
|
||||
guard case MASError.downloadFailed(let downloadError) = error,
|
||||
case NSURLErrorDomain = downloadError?.domain
|
||||
else {
|
||||
throw error
|
||||
}
|
||||
|
||||
seal.fulfill(purchase)
|
||||
let attempts = attempts - 1
|
||||
printWarning((downloadError ?? error).localizedDescription)
|
||||
printWarning("Trying again up to \(attempts) more \(attempts == 1 ? "time" : "times").")
|
||||
return downloadWithRetries(appID, purchase: purchase, attempts: attempts)
|
||||
}
|
||||
}.then { purchase -> Promise<Void> in
|
||||
let observer = PurchaseDownloadObserver(purchase: purchase)
|
||||
let download = Promise<Void> { seal in
|
||||
observer.errorHandler = seal.reject
|
||||
observer.completionHandler = seal.fulfill_
|
||||
}
|
||||
|
||||
let downloadQueue = CKDownloadQueue.shared()
|
||||
let observerID = downloadQueue.add(observer)
|
||||
return download.ensure {
|
||||
downloadQueue.remove(observerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,75 +7,81 @@
|
|||
//
|
||||
|
||||
import CommerceKit
|
||||
import PromiseKit
|
||||
import StoreFoundation
|
||||
|
||||
extension ISStoreAccount: StoreAccount {
|
||||
static var primaryAccount: StoreAccount? {
|
||||
var account: ISStoreAccount?
|
||||
|
||||
static var primaryAccount: Promise<ISStoreAccount> {
|
||||
if #available(macOS 10.13, *) {
|
||||
let group = DispatchGroup()
|
||||
group.enter()
|
||||
|
||||
let accountService: ISAccountService = ISServiceProxy.genericShared().accountService
|
||||
accountService.primaryAccount { (storeAccount: ISStoreAccount) in
|
||||
account = storeAccount
|
||||
group.leave()
|
||||
}
|
||||
|
||||
_ = group.wait(timeout: .now() + 30)
|
||||
return race(
|
||||
Promise<ISStoreAccount> { seal in
|
||||
ISServiceProxy.genericShared().accountService.primaryAccount { storeAccount in
|
||||
seal.fulfill(storeAccount)
|
||||
}
|
||||
},
|
||||
after(seconds: 30).then {
|
||||
Promise(error: MASError.notSignedIn)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// macOS 10.9-10.12
|
||||
let accountStore = CKAccountStore.shared()
|
||||
account = accountStore.primaryAccount
|
||||
return .value(CKAccountStore.shared().primaryAccount)
|
||||
}
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
static func signIn(username: String, password: String, systemDialog: Bool = false) throws -> StoreAccount {
|
||||
var storeAccount: ISStoreAccount?
|
||||
var maserror: MASError?
|
||||
|
||||
let accountService: ISAccountService = ISServiceProxy.genericShared().accountService
|
||||
let client = ISStoreClient(storeClientType: 0)
|
||||
accountService.setStoreClient(client)
|
||||
|
||||
let context = ISAuthenticationContext(accountID: 0)
|
||||
context.appleIDOverride = username
|
||||
|
||||
if systemDialog {
|
||||
context.appleIDOverride = username
|
||||
static func signIn(username: String, password: String, systemDialog: Bool) -> Promise<ISStoreAccount> {
|
||||
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)
|
||||
} else {
|
||||
context.demoMode = true
|
||||
context.demoAccountName = username
|
||||
context.demoAccountPassword = password
|
||||
context.demoAutologinMode = true
|
||||
return
|
||||
primaryAccount
|
||||
.then { account -> Promise<ISStoreAccount> in
|
||||
if account.isSignedIn {
|
||||
return Promise(error: MASError.alreadySignedIn(asAccountId: account.identifier))
|
||||
}
|
||||
|
||||
let password =
|
||||
password.isEmpty && !systemDialog
|
||||
? String(validatingUTF8: getpass("Password: "))!
|
||||
: password
|
||||
|
||||
guard !password.isEmpty || systemDialog else {
|
||||
return Promise(error: MASError.noPasswordProvided)
|
||||
}
|
||||
|
||||
let context = ISAuthenticationContext(accountID: 0)
|
||||
context.appleIDOverride = username
|
||||
|
||||
let signInPromise =
|
||||
Promise<ISStoreAccount> { seal in
|
||||
let accountService = ISServiceProxy.genericShared().accountService
|
||||
accountService.setStoreClient(ISStoreClient(storeClientType: 0))
|
||||
accountService.signIn(with: context) { success, storeAccount, error in
|
||||
if success, let storeAccount {
|
||||
seal.fulfill(storeAccount)
|
||||
} else {
|
||||
seal.reject(MASError.signInFailed(error: error as NSError?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if systemDialog {
|
||||
return signInPromise
|
||||
} else {
|
||||
context.demoMode = true
|
||||
context.demoAccountName = username
|
||||
context.demoAccountPassword = password
|
||||
context.demoAutologinMode = true
|
||||
|
||||
return race(
|
||||
signInPromise,
|
||||
after(seconds: 30).then {
|
||||
Promise(error: MASError.signInFailed(error: nil))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let group = DispatchGroup()
|
||||
group.enter()
|
||||
|
||||
// Only works on macOS Sierra and below
|
||||
accountService.signIn(with: context) { success, account, error in
|
||||
if success {
|
||||
storeAccount = account
|
||||
} else {
|
||||
maserror = .signInFailed(error: error as NSError?)
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
if systemDialog {
|
||||
group.wait()
|
||||
} else {
|
||||
_ = group.wait(timeout: .now() + 30)
|
||||
}
|
||||
|
||||
if let account = storeAccount {
|
||||
return account
|
||||
}
|
||||
|
||||
throw maserror ?? MASError.signInFailed(error: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,15 +7,11 @@
|
|||
//
|
||||
|
||||
import CommerceKit
|
||||
import PromiseKit
|
||||
import StoreFoundation
|
||||
|
||||
typealias SSPurchaseCompletion =
|
||||
(_ purchase: SSPurchase?, _ completed: Bool, _ error: Error?, _ response: SSPurchaseResponse?) -> Void
|
||||
|
||||
extension SSPurchase {
|
||||
convenience init(adamId: UInt64, account: ISStoreAccount?, purchase: Bool = false) {
|
||||
self.init()
|
||||
|
||||
func perform(adamId: UInt64, purchase: Bool) -> Promise<Void> {
|
||||
var parameters: [String: Any] = [
|
||||
"productType": "C",
|
||||
"price": 0,
|
||||
|
@ -29,7 +25,6 @@ extension SSPurchase {
|
|||
parameters["pricingParameters"] = "STDQ"
|
||||
|
||||
} else {
|
||||
// is redownload, use existing functionality
|
||||
parameters["pricingParameters"] = "STDRDL"
|
||||
}
|
||||
|
||||
|
@ -41,24 +36,59 @@ extension SSPurchase {
|
|||
|
||||
itemIdentifier = adamId
|
||||
|
||||
if let account {
|
||||
accountIdentifier = account.dsID
|
||||
appleID = account.identifier
|
||||
}
|
||||
|
||||
// Not sure if this is needed, but lets use it here.
|
||||
// Not sure if this is needed…
|
||||
if purchase {
|
||||
isRedownload = false
|
||||
}
|
||||
|
||||
let downloadMetadata = SSDownloadMetadata()
|
||||
downloadMetadata = SSDownloadMetadata()
|
||||
downloadMetadata.kind = "software"
|
||||
downloadMetadata.itemIdentifier = adamId
|
||||
|
||||
self.downloadMetadata = downloadMetadata
|
||||
// Monterey obscures the user's App Store account, but allows
|
||||
// redownloads without passing any account IDs to SSPurchase.
|
||||
// https://github.com/mas-cli/mas/issues/417
|
||||
if #available(macOS 12, *) {
|
||||
return perform()
|
||||
}
|
||||
|
||||
return
|
||||
ISStoreAccount.primaryAccount
|
||||
.then { storeAccount in
|
||||
self.accountIdentifier = storeAccount.dsID
|
||||
self.appleID = storeAccount.identifier
|
||||
return self.perform()
|
||||
}
|
||||
}
|
||||
|
||||
func perform(_ completion: @escaping SSPurchaseCompletion) {
|
||||
CKPurchaseController.shared().perform(self, withOptions: 0, completionHandler: completion)
|
||||
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
|
||||
}
|
||||
|
||||
guard response?.downloads.isEmpty == false, let purchase else {
|
||||
seal.reject(MASError.noDownloads)
|
||||
return
|
||||
}
|
||||
|
||||
seal.fulfill(purchase)
|
||||
}
|
||||
}
|
||||
.then { purchase in
|
||||
let observer = PurchaseDownloadObserver(purchase: purchase)
|
||||
let downloadQueue = CKDownloadQueue.shared()
|
||||
let observerID = downloadQueue.add(observer)
|
||||
|
||||
return Promise<Void> { seal in
|
||||
observer.errorHandler = seal.reject
|
||||
observer.completionHandler = seal.fulfill_
|
||||
}
|
||||
.ensure {
|
||||
downloadQueue.remove(observerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,10 @@
|
|||
// Copyright © 2018 Andrew Naylor. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// periphery:ignore - save for future use in testing
|
||||
protocol StoreAccount {
|
||||
var identifier: String { get set }
|
||||
var dsID: NSNumber { get set }
|
||||
}
|
||||
|
|
|
@ -24,12 +24,11 @@ public struct AccountCommand: CommandProtocol {
|
|||
return .failure(.notSupported)
|
||||
}
|
||||
|
||||
if let account = ISStoreAccount.primaryAccount {
|
||||
print(String(describing: account.identifier))
|
||||
} else {
|
||||
printError("Not signed in")
|
||||
return .failure(.notSignedIn)
|
||||
do {
|
||||
print(try ISStoreAccount.primaryAccount.wait().identifier)
|
||||
return .success(())
|
||||
} catch {
|
||||
return .failure(error as? MASError ?? .failed(error: error as NSError))
|
||||
}
|
||||
return .success(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,32 +19,17 @@ public struct SignInCommand: CommandProtocol {
|
|||
|
||||
/// Runs the command.
|
||||
public func run(_ options: Options) -> Result<Void, MASError> {
|
||||
if #available(macOS 10.13, *) {
|
||||
// Signing in is no longer possible as of High Sierra.
|
||||
// https://github.com/mas-cli/mas/issues/164
|
||||
return .failure(.notSupported)
|
||||
}
|
||||
|
||||
guard ISStoreAccount.primaryAccount == nil else {
|
||||
return .failure(.alreadySignedIn)
|
||||
}
|
||||
|
||||
do {
|
||||
printInfo("Signing in to Apple ID: \(options.username)")
|
||||
|
||||
let password: String = {
|
||||
if options.password.isEmpty, !options.dialog {
|
||||
return String(validatingUTF8: getpass("Password: "))!
|
||||
}
|
||||
return options.password
|
||||
}()
|
||||
|
||||
_ = try ISStoreAccount.signIn(username: options.username, password: password, systemDialog: options.dialog)
|
||||
} catch let error as NSError {
|
||||
return .failure(.signInFailed(error: error))
|
||||
_ = try ISStoreAccount.signIn(
|
||||
username: options.username,
|
||||
password: options.password,
|
||||
systemDialog: options.dialog
|
||||
)
|
||||
.wait()
|
||||
return .success(())
|
||||
} catch {
|
||||
return .failure(error as? MASError ?? .signInFailed(error: error as NSError))
|
||||
}
|
||||
|
||||
return .success(())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,8 +19,7 @@ public struct SignOutCommand: CommandProtocol {
|
|||
/// Runs the command.
|
||||
public func run(_: Options) -> Result<Void, MASError> {
|
||||
if #available(macOS 10.13, *) {
|
||||
let accountService: ISAccountService = ISServiceProxy.genericShared().accountService
|
||||
accountService.signOut()
|
||||
ISServiceProxy.genericShared().accountService.signOut()
|
||||
} else {
|
||||
// Using CKAccountStore to sign out does nothing on High Sierra
|
||||
// https://github.com/mas-cli/mas/issues/129
|
||||
|
|
|
@ -11,9 +11,12 @@ import Foundation
|
|||
public enum MASError: Error, Equatable {
|
||||
case notSupported
|
||||
|
||||
case failed(error: NSError?)
|
||||
|
||||
case notSignedIn
|
||||
case noPasswordProvided
|
||||
case signInFailed(error: NSError?)
|
||||
case alreadySignedIn
|
||||
case alreadySignedIn(asAccountId: String)
|
||||
|
||||
case purchaseFailed(error: NSError?)
|
||||
case downloadFailed(error: NSError?)
|
||||
|
@ -37,62 +40,56 @@ extension MASError: CustomStringConvertible {
|
|||
switch self {
|
||||
case .notSignedIn:
|
||||
return "Not signed in"
|
||||
|
||||
case .noPasswordProvided:
|
||||
return "No password provided"
|
||||
case .notSupported:
|
||||
return """
|
||||
This command is not supported on this macOS version due to changes in macOS. \
|
||||
For more information see: \
|
||||
https://github.com/mas-cli/mas#%EF%B8%8F-known-issues
|
||||
"""
|
||||
|
||||
case .failed(let error):
|
||||
if let error {
|
||||
return "Failed: \(error.localizedDescription)"
|
||||
} else {
|
||||
return "Failed"
|
||||
}
|
||||
case .signInFailed(let error):
|
||||
if let error {
|
||||
return "Sign in failed: \(error.localizedDescription)"
|
||||
} else {
|
||||
return "Sign in failed"
|
||||
}
|
||||
|
||||
case .alreadySignedIn:
|
||||
return "Already signed in"
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
case .downloadFailed(let error):
|
||||
if let error {
|
||||
return "Download failed: \(error.localizedDescription)"
|
||||
} else {
|
||||
return "Download failed"
|
||||
}
|
||||
|
||||
case .noDownloads:
|
||||
return "No downloads began"
|
||||
|
||||
case .cancelled:
|
||||
return "Download cancelled"
|
||||
|
||||
case .searchFailed:
|
||||
return "Search failed"
|
||||
|
||||
case .noSearchResultsFound:
|
||||
return "No results found"
|
||||
|
||||
case .noVendorWebsite:
|
||||
return "App does not have a vendor website"
|
||||
|
||||
case .notInstalled:
|
||||
return "Not installed"
|
||||
|
||||
case .uninstallFailed:
|
||||
return "Uninstall failed"
|
||||
|
||||
case .noData:
|
||||
return "Service did not return data"
|
||||
|
||||
case .jsonParsing(let data):
|
||||
if let data {
|
||||
if let unparsable = String(data: data, encoding: .utf8) {
|
||||
|
|
|
@ -57,8 +57,8 @@ class MASErrorTestCase: XCTestCase {
|
|||
}
|
||||
|
||||
func testAlreadySignedIn() {
|
||||
error = .alreadySignedIn
|
||||
XCTAssertEqual(error.description, "Already signed in")
|
||||
error = .alreadySignedIn(asAccountId: "person@example.com")
|
||||
XCTAssertEqual(error.description, "Already signed in as person@example.com")
|
||||
}
|
||||
|
||||
func testPurchaseFailed() {
|
||||
|
|
Loading…
Add table
Reference in a new issue