@@ -367,7 +367,7 @@ final class Engine: ObservableObject {
367367 log ( " ⛔️ iOS \( osVersionText) isn't supported — SideInstaller needs iOS \( Engine . minimumOSText) or later. " )
368368 return
369369 }
370- guard !appleID . isEmpty, !applePassword. isEmpty else {
370+ guard !normalizedAppleID . isEmpty, !applePassword. isEmpty else {
371371 log ( " Enter your Apple ID email + password first. " )
372372 return
373373 }
@@ -558,14 +558,23 @@ final class Engine: ObservableObject {
558558
559559 // MARK: Step 4 — Apple ID sign-in
560560
561+ /// The Apple ID as it should be sent to Apple. iOS keyboards happily leave a
562+ /// trailing space behind autocomplete or a paste, and the field gives no
563+ /// visual hint that it's there — but the username is mixed into the SRP
564+ /// proof, so that invisible space comes back as a wrong-password error.
565+ /// (The password is deliberately *not* trimmed: spaces can be part of it.)
566+ var normalizedAppleID : String {
567+ appleID. trimmingCharacters ( in: . whitespacesAndNewlines)
568+ }
569+
561570 @MainActor
562571 private func signIn( ) async throws {
563572 if signSession != nil {
564573 log ( " Already signed in this session — skipping. " )
565574 setStep ( . signIn, . done)
566575 return
567576 }
568- guard !appleID . isEmpty, !applePassword. isEmpty else {
577+ guard !normalizedAppleID . isEmpty, !applePassword. isEmpty else {
569578 throw EngineError . message ( L ( " Enter your Apple ID email + password. " ) )
570579 }
571580 setStep ( . signIn, . active)
@@ -576,7 +585,7 @@ final class Engine: ObservableObject {
576585 // could fix, like a wrong password or a cancelled 2FA prompt, stop the
577586 // loop early — see below.)
578587 let servers = anisetteCandidates ( )
579- let id = appleID , pw = applePassword, dir = storageDir
588+ let id = normalizedAppleID , pw = applePassword, dir = storageDir
580589 twoFactorWasCancelled = false
581590 var lastError = " no anisette servers configured "
582591
@@ -670,15 +679,28 @@ final class Engine: ObservableObject {
670679 anisetteServers. first { $0. address == address } ? . name ?? address
671680 }
672681
682+ /// GrandSlam status codes that mean the credentials themselves are the
683+ /// problem. Apple localises the accompanying message, so the numeric code is
684+ /// the only reliable signal — match on it first and treat the wording as a
685+ /// fallback for servers that don't echo the code.
686+ private static let credentialErrorCodes = [
687+ " -20101 " , // invalid username/password
688+ " -22406 " , // "Enter the correct password for this Apple Account."
689+ ]
690+
673691 /// Detect a definitive Apple ID credential failure (vs. a flaky anisette
674692 /// server). Switching anisette servers can't fix these, so the sign-in loop
675693 /// stops on them instead of trying every server.
676694 static func isCredentialError( _ raw: String ) -> Bool {
677695 let m = raw. lowercased ( )
696+ if credentialErrorCodes. contains ( where: m. contains) { return true }
697+ // Wording fallbacks. Apple now brands the account an "Apple Account"
698+ // rather than an "Apple ID", so both spellings have to be covered.
678699 return m. contains ( " apple id or password " )
700+ || m. contains ( " apple account or password " )
679701 || m. contains ( " password was incorrect " )
680702 || m. contains ( " incorrect apple id " )
681- || m. contains ( " -20101 " ) // GSA: invalid username/ password
703+ || m. contains ( " correct password" )
682704 || ( m. contains ( " password " ) && m. contains ( " incorrect " ) )
683705 }
684706
0 commit comments