Merge pull request #563 from rgoldberg/562-apple-id

Improve `Account`, `SignIn`, `SignOut` & `ISStoreAccount` extension & associated code:
This commit is contained in:
Ross Goldberg 2024-10-13 22:26:44 -04:00 committed by GitHub
commit 611d4a7fb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 165 additions and 201 deletions

View file

@ -11,6 +11,7 @@
--disable blankLinesAroundMark
--disable consecutiveSpaces
--disable hoistPatternLet
--disable hoistTry
--disable indent
--disable trailingCommas

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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() {