From 3575e688a860cfc152b061cb3157742569b87c7b Mon Sep 17 00:00:00 2001 From: Jacob Clayden Date: Sun, 23 Aug 2026 19:59:13 +0100 Subject: [PATCH] fix: restored settings state - restored Apple Account sessions without repeated credential reads - refreshed notification permissions while settings remained visible - updated account terminology, accessibility, and translated settings copy - advanced maintained build metadata to `48` --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- CONTRIBUTING.md | 2 +- README.md | 2 +- Scripts/check_ci_release_workflows.rb | 4 +- Scripts/check_fork_identity.sh | 4 +- Scripts/check_localizations.rb | 69 +- Scripts/test_ci_release_workflows.sh | 2 +- Scripts/test_localization_contract.sh | 4 + Xcodes.xcodeproj/project.pbxproj | 6 +- Xcodes/Backend/AppState+Update.swift | 10 +- Xcodes/Backend/AppState.swift | 251 +++- Xcodes/Backend/Environment.swift | 16 +- Xcodes/Backend/NotificationManager.swift | 39 +- Xcodes/Frontend/MainWindow.swift | 15 +- .../Preferences/AdvancedPreferencePane.swift | 2 +- .../Preferences/GeneralPreferencePane.swift | 39 +- .../Preferences/NotificationsView.swift | 70 +- Xcodes/Frontend/SignIn/SignIn2FAView.swift | 4 +- .../SignIn/SignInCredentialsView.swift | 9 +- .../Frontend/SignIn/SignInFederatedView.swift | 7 +- .../Frontend/SignIn/SignInPhoneListView.swift | 5 +- Xcodes/Frontend/SignIn/SignInSMSView.swift | 4 +- .../SignIn/SignInSecurityKeyPinView.swift | 6 +- .../SignIn/SignInSecurityKeyTouchView.swift | 4 +- Xcodes/Frontend/SignIn/SignedInView.swift | 8 +- Xcodes/Resources/Localizable.xcstrings | 1275 ++++++++++++++--- Xcodes/XcodesApp.swift | 2 +- XcodesTests/AppStateTests.swift | 487 ++++++- XcodesTests/Environment+Mock.swift | 3 +- docs/RELEASING.md | 14 +- 30 files changed, 2010 insertions(+), 355 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ce7400fe..26d12c24 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,4 +26,4 @@ If applicable, add screenshots to help explain your problem. - Xcodes fork: - Xcode involved: -Remove Apple ID details, cookies, signing keys, and other secrets from logs and screenshots. +Remove Apple Account details, cookies, signing keys, and other secrets from logs and screenshots. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc5b87e8..cd5847f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,7 @@ Include: - expected and actual behaviour - relevant redacted logs or screenshots -Never include Apple ID credentials, cookies, Keychain contents, signing keys, or other secrets. +Never include Apple Account credentials, cookies, Keychain contents, signing keys, or other secrets. ## Licence diff --git a/README.md b/README.md index e2445c03..88b91dca 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ If you need a command-line tool, see the upstream [`xcodes`](https://github.com/ - Shows release notes, OS compatibility, SDKs, and compilers. - Installs supported simulator platforms and runtimes. - Supports Apple silicon and universal Xcode variants. -- Supports Apple ID and security-key authentication where Apple requires sign-in. +- Supports Apple Account and security-key authentication where Apple requires sign-in. - Supports stable and prerelease update channels through Sparkle. The optional unxip experiment builds on [saagarjha/unxip](https://github.com/saagarjha/unxip). It can reduce extraction time, at the cost of higher memory use on some systems. diff --git a/Scripts/check_ci_release_workflows.rb b/Scripts/check_ci_release_workflows.rb index b77436e6..4ef5499a 100755 --- a/Scripts/check_ci_release_workflows.rb +++ b/Scripts/check_ci_release_workflows.rb @@ -475,9 +475,9 @@ releasing_docs = File.file?(releasing_docs_path) ? File.read(releasing_docs_path) : "" check.call(releasing_docs.include?("environment protection rule must allow only protected tags matching `v*`"), "Release guide must require exact environment tag restrictions") -check.call(releasing_docs.include?("`workflow_dispatch` reruns must use `--ref v4.0.5b47`"), "Release guide must document tag-ref manual dispatch") +check.call(releasing_docs.include?("`workflow_dispatch` reruns must use `--ref v4.0.5b48`"), "Release guide must document tag-ref manual dispatch") check.call( - releasing_docs.include?("gh workflow run appcast.yml --ref v4.0.5b47 -f tag=v4.0.5b47"), + releasing_docs.include?("gh workflow run appcast.yml --ref v4.0.5b48 -f tag=v4.0.5b48"), "Release guide must document exact tag-bound appcast dispatch" ) check.call( diff --git a/Scripts/check_fork_identity.sh b/Scripts/check_fork_identity.sh index 91654772..4f5b4098 100755 --- a/Scripts/check_fork_identity.sh +++ b/Scripts/check_fork_identity.sh @@ -44,7 +44,7 @@ readonly tests_id="dev.jacobcx.Xcodes.Tests" readonly helper_id="dev.jacobcx.Xcodes.Helper" readonly team_id="K2648T24P4" readonly marketing_version="4.0.5" -readonly build_number="47" +readonly build_number="48" readonly app_copyright="Fork contributions © 2026 JacobCXDev. Upstream contributors retain their copyrights." # shellcheck disable=SC2016 # Xcode expands this build-setting literal, not the shell. readonly app_requirement='identifier "dev.jacobcx.Xcodes" and info [CFBundleShortVersionString] >= "1.0.0" and anchor apple generic and certificate leaf[subject.OU] = "$(CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT)"' @@ -183,7 +183,7 @@ if [[ -e "$repo_root/Xcodes/Frontend/XcodeList/BottomStatusBar.swift" ]] \ fail "Obsolete main-window footer remains" fi require_literal "DEVELOPER_ID_APPLICATION_P12_BASE64" "$release_documentation" -require_literal "v4.0.5b47" "$release_documentation" +require_literal "v4.0.5b48" "$release_documentation" if grep -n -F -- 'Jacob Clayden' "$readme" "$about_source" "$app_info_plist"; then fail "Legal name leaked into public-facing fork branding" diff --git a/Scripts/check_localizations.rb b/Scripts/check_localizations.rb index 64f8a02a..4220d820 100755 --- a/Scripts/check_localizations.rb +++ b/Scripts/check_localizations.rb @@ -19,7 +19,6 @@ "Open Browser", "Paste redirected URL", "Signing out...", - "This Apple ID uses federated authentication via %@.", ].freeze allowed_missing = ( baseline_keys.product(established_languages) + @@ -55,11 +54,79 @@ end end.sort +legacy_terms = /Apple(?:\s+|-)?ID\b|\bID Apple\b|\bID de Apple\b|\bIdentifiant Apple\b/i +legacy_terminology = catalog.fetch("strings").flat_map do |key, value| + value.fetch("localizations", {}).filter_map do |language, localization| + values = [] + visit = lambda do |node| + case node + when Hash + values << node["value"] if node["value"].is_a?(String) + node.each_value { |child| visit.call(child) } + when Array + node.each { |child| visit.call(child) } + end + end + visit.call(localization) + "#{key}:#{language}" if values.any? { |text| text.match?(legacy_terms) } + end +end.sort + +forbidden_keys = catalog.fetch("strings").keys.grep(legacy_terms) + +required_keys = [ + "AppBehaviour", + "AppleAccount", + "AuthError.NotAuthorized", + "AuthError.PasswordRequired", + "AuthError.PrivacyAcknowledgementRequired", + "CheckingAppleAccount", + "CheckingNotificationSettings", + "ManageAppleAccount", + "SignInToAppleAccount", + "SignedIn", + "This Apple Account uses federated authentication via %@.", +].reject { |key| catalog.fetch("strings").key?(key) } + +required_grouping_values = { + "ar" => "تجميع إصدارات Xcode في القائمة", + "ca" => "Agrupa les versions d’Xcode a la llista", + "de" => "Xcode-Versionen in der Liste gruppieren", + "el" => "Ομαδοποίηση εκδόσεων Xcode στη λίστα", + "en" => "Group Xcode versions in the list", + "es" => "Agrupar las versiones de Xcode en la lista", + "fi" => "Ryhmittele Xcode-versiot luettelossa", + "fr" => "Regrouper les versions de Xcode dans la liste", + "hi" => "सूची में Xcode संस्करणों को समूहित करें", + "it" => "Raggruppa le versioni di Xcode nell’elenco", + "ja" => "リスト内のXcodeバージョンをグループ化", + "ko" => "목록에서 Xcode 버전 그룹화", + "nl" => "Xcode-versies in de lijst groeperen", + "pl" => "Grupuj wersje Xcode na liście", + "pt-BR" => "Agrupar versões do Xcode na lista", + "ru" => "Группировать версии Xcode в списке", + "th" => "จัดกลุ่มเวอร์ชัน Xcode ในรายการ", + "tr" => "Listedeki Xcode sürümlerini grupla", + "uk" => "Групувати версії Xcode у списку", + "zh-Hans" => "在列表中将 Xcode 版本分组", + "zh-Hant" => "將列表中的 Xcode 版本分組", +}.freeze + +grouping_localizations = catalog.fetch("strings").fetch("GroupXcodeVersionsInList").fetch("localizations") +incorrect_grouping_values = required_grouping_values.filter_map do |language, expected_value| + actual_value = grouping_localizations.dig(language, "stringUnit", "value") + "GroupXcodeVersionsInList:#{language} expected #{expected_value.inspect}, got #{actual_value.inspect}" unless actual_value == expected_value +end + unexpected = missing - allowed_missing resolved = allowed_missing - missing errors = [] errors << "New missing or unreviewed translations:\n #{unexpected.join("\n ")}" unless unexpected.empty? errors << "Localization baseline is stale; remove resolved entries:\n #{resolved.join("\n ")}" unless resolved.empty? +errors << "Legacy Apple ID terminology remains:\n #{legacy_terminology.join("\n ")}" unless legacy_terminology.empty? +errors << "Legacy localization keys remain:\n #{forbidden_keys.join("\n ")}" unless forbidden_keys.empty? +errors << "Required localization keys are missing:\n #{required_keys.join("\n ")}" unless required_keys.empty? +errors << "Grouped-version translations are mapped to the wrong locales:\n #{incorrect_grouping_values.join("\n ")}" unless incorrect_grouping_values.empty? unless errors.empty? warn errors.join("\n") diff --git a/Scripts/test_ci_release_workflows.sh b/Scripts/test_ci_release_workflows.sh index 76138e94..ef6415f6 100755 --- a/Scripts/test_ci_release_workflows.sh +++ b/Scripts/test_ci_release_workflows.sh @@ -135,7 +135,7 @@ mutate_and_reject publish_unexpected_control \ mutate_and_reject missing_release_drafter_timeout \ 'path = File.join(ARGV.fetch(0), ".github/workflows/release-drafter.yml"); data = YAML.safe_load_file(path, aliases: false); data["jobs"]["update_release_draft"].delete("timeout-minutes"); File.write(path, YAML.dump(data) + "# bounded timeout\n")' mutate_and_reject missing_appcast_dispatch_docs \ - 'path = File.join(ARGV.fetch(0), "docs/RELEASING.md"); text = File.read(path).sub("gh workflow run appcast.yml --ref v4.0.5b47 -f tag=v4.0.5b47", "gh workflow run appcast.yml --ref main -f tag=latest"); File.write(path, text)' + 'path = File.join(ARGV.fetch(0), "docs/RELEASING.md"); text = File.read(path).sub("gh workflow run appcast.yml --ref v4.0.5b48 -f tag=v4.0.5b48", "gh workflow run appcast.yml --ref main -f tag=latest"); File.write(path, text)' # shellcheck disable=SC2016 # Documentation code spans must remain literal in the mutation. mutate_and_reject missing_reusable_ref_docs \ 'path = File.join(ARGV.fetch(0), "docs/RELEASING.md"); text = File.read(path).sub("Reusable workflows receive the caller'\''s `github.ref`; the appcast build requires that ref to equal `refs/tags/`", "Reusable workflows are called after release publication"); File.write(path, text)' diff --git a/Scripts/test_localization_contract.sh b/Scripts/test_localization_contract.sh index 30f413c6..69b66398 100755 --- a/Scripts/test_localization_contract.sh +++ b/Scripts/test_localization_contract.sh @@ -43,6 +43,10 @@ expect_failure new_thai_gap \ 'path = ARGV.fetch(0); data = JSON.parse(File.read(path)); data["strings"]["AutomaticallyCreateSymbolicLink"]["localizations"].delete("th"); File.write(path, JSON.pretty_generate(data))' expect_failure stale_baseline \ 'path = ARGV.fetch(0); data = JSON.parse(File.read(path)); data["strings"]["AutomaticallyCreateBetaSymbolicLink"]["localizations"]["de"] = { "stringUnit" => { "state" => "translated", "value" => "Beta-Link" } }; File.write(path, JSON.pretty_generate(data))' +expect_failure legacy_apple_id_copy \ + 'path = ARGV.fetch(0); data = JSON.parse(File.read(path)); data["strings"]["AccessGranted"]["localizations"]["en"]["stringUnit"]["value"] = "Signed in with your Apple ID."; File.write(path, JSON.pretty_generate(data))' +expect_failure shifted_grouping_translation \ + 'path = ARGV.fetch(0); data = JSON.parse(File.read(path)); data["strings"]["GroupXcodeVersionsInList"]["localizations"]["en"]["stringUnit"]["value"] = data["strings"]["GroupXcodeVersionsInList"]["localizations"]["es"]["stringUnit"]["value"]; File.write(path, JSON.pretty_generate(data))' checker_fixture="$test_root/omitted-language-checker.rb" cp "$checker" "$checker_fixture" diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 279f6ba6..ab12b1d3 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -1072,7 +1072,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 47; + CURRENT_PROJECT_VERSION = 48; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = K2648T24P4; ENABLE_HARDENED_RUNTIME = NO; @@ -1325,7 +1325,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 47; + CURRENT_PROJECT_VERSION = 48; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = K2648T24P4; ENABLE_HARDENED_RUNTIME = YES; @@ -1354,7 +1354,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 47; + CURRENT_PROJECT_VERSION = 48; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = K2648T24P4; ENABLE_HARDENED_RUNTIME = YES; diff --git a/Xcodes/Backend/AppState+Update.swift b/Xcodes/Backend/AppState+Update.swift index cd2952c9..de392aba 100644 --- a/Xcodes/Backend/AppState+Update.swift +++ b/Xcodes/Backend/AppState+Update.swift @@ -60,8 +60,9 @@ extension AppState { } catch { // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead if error as? AuthenticationError != .invalidSession { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Update.Error.Title"), message: error.legibleLocalizedDescription) + let presentedError = Self.userFacingAuthenticationError(error) + self.error = presentedError + self.presentedAlert = .generic(title: localizeString("Alert.Update.Error.Title"), message: presentedError.legibleLocalizedDescription) } } } @@ -79,9 +80,8 @@ extension AppState { private func updateAvailableXcodes(from dataSource: DataSource) async throws -> [AvailableXcode] { if dataSource == .apple { - try await signInIfNeededAsync() - // This checks whether the Apple ID is a valid Apple Developer account. - try await validateSessionAsync() + _ = try await signInIfNeededAsync() + try await waitForAuthenticationTerminalState() } let service = XcodeListService(urlSession: Current.network.session) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index a1e5bdbb..1ca20e76 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -22,6 +22,37 @@ enum AuthenticationRequestError: LocalizedError, Equatable { } } +enum AppleAccountAuthenticationError: LocalizedError, Equatable { + case privacyAcknowledgementRequired + case notAuthorized + case passwordRequired + + init?(_ error: Error) { + guard let authenticationError = error as? AuthenticationError else { return nil } + switch authenticationError { + case .appleIDAndPrivacyAcknowledgementRequired: + self = .privacyAcknowledgementRequired + case .notAuthorized: + self = .notAuthorized + case .missingPasswordForNonFederatedAccount: + self = .passwordRequired + default: + return nil + } + } + + var errorDescription: String? { + switch self { + case .privacyAcknowledgementRequired: + return localizeString("AuthError.PrivacyAcknowledgementRequired") + case .notAuthorized: + return localizeString("AuthError.NotAuthorized") + case .passwordRequired: + return localizeString("AuthError.PasswordRequired") + } + } +} + struct AuthenticationRequestPolicy: Sendable { let maximumAttemptCount: Int let delayBeforeRetry: Duration @@ -61,6 +92,16 @@ struct AuthenticationRequestPolicy: Sendable { return false } + static func requiresCredentialSignIn(after error: Error) -> Bool { + guard let authenticationError = error as? AuthenticationError else { return false } + switch authenticationError { + case .invalidSession, .notAuthorized: + return true + default: + return false + } + } + private static func isTransient(_ error: Error) -> Bool { guard let networkError = error as? NetworkError, @@ -212,9 +253,14 @@ class AppState: ObservableObject { var isUpdating: Bool { updateTask != nil } @Published var presentedSheet: XcodesSheet? = nil @Published var isProcessingAuthRequest = false + @Published private(set) var isRestoringAuthenticationState = false private var authenticationRequestID: UUID? private var authenticationTask: Task? private var authenticationTaskID: UUID? + private var authenticationRestoreTask: Task? + private var authenticationRestoreTaskID: UUID? + private var signInIfNeededTask: Task? + private var signInIfNeededTaskID: UUID? @Published var xcodeBeingConfirmedForUninstallation: Xcode? @Published var presentedAlert: XcodesAlert? @Published var presentedPlatformAlert: XcodesPlatformAlert? @@ -363,6 +409,18 @@ class AppState: ObservableObject { savedUsername != nil } + var appleAccountDisplayName: String { + if let savedUsername, savedUsername.isEmpty == false { + return savedUsername + } + if case let .authenticated(session) = authenticationState, + let fullName = session.user.fullName, + fullName.isEmpty == false { + return fullName + } + return localizeString("SignedIn") + } + // MARK: - Init init(runtimeService: RuntimeService = RuntimeService()) { @@ -382,6 +440,7 @@ class AppState: ObservableObject { } setupAutoInstallTimer() setupDefaults() + restoreAuthenticationState() } func setupDefaults() { @@ -416,26 +475,70 @@ class AppState: ObservableObject { loadData: { request in try await Current.network.dataTaskAsync(with: request) }, - unauthorizedError: { AuthenticationError.notAuthorized } + unauthorizedError: { AppleAccountAuthenticationError.notAuthorized } ).validateADCSession(path: path) } + @discardableResult func validateSessionAsync( authenticationRequestPolicy: AuthenticationRequestPolicy = AuthenticationRequestPolicy() - ) async throws { + ) async throws -> AuthenticationState { do { - try await authenticationRequestPolicy.perform { - try await Current.network.validateSessionAsync() + return try await performAuthenticationRequest(reportsErrors: false) { + try await authenticationRequestPolicy.perform { + try await Current.network.validateSessionAsync() + } } } catch { throw AuthenticationRequestPolicy.mapSessionValidationError(error) } } - func signInIfNeededAsync() async throws { + @discardableResult + func signInIfNeededAsync( + authenticationRequestPolicy: AuthenticationRequestPolicy = AuthenticationRequestPolicy() + ) async throws -> AuthenticationState { + switch authenticationState { + case .waitingForFederatedAuthentication, .waitingForSecondFactor: + return authenticationState + case .authenticated, .unauthenticated, .notAppleDeveloper: + break + } + + if let signInIfNeededTask { + return try await signInIfNeededTask.value + } + + let taskID = UUID() + let task = Task { @MainActor [weak self] in + guard let self else { throw CancellationError() } + return try await performSignInIfNeededAsync( + authenticationRequestPolicy: authenticationRequestPolicy + ) + } + signInIfNeededTaskID = taskID + signInIfNeededTask = task + defer { + if signInIfNeededTaskID == taskID { + signInIfNeededTask = nil + signInIfNeededTaskID = nil + } + } + return try await task.value + } + + private func performSignInIfNeededAsync( + authenticationRequestPolicy: AuthenticationRequestPolicy + ) async throws -> AuthenticationState { do { - try await validateSessionAsync() + return try await validateSessionAsync( + authenticationRequestPolicy: authenticationRequestPolicy + ) } catch { + try Task.checkCancellation() + guard AuthenticationRequestPolicy.requiresCredentialSignIn(after: error) else { + throw error + } guard let username = savedUsername, let password = try? Current.keychain.getString(username) @@ -443,26 +546,102 @@ class AppState: ObservableObject { throw error } - _ = try await signInAsync(username: username, password: password) + return try await signInAsync( + username: username, + password: password, + persistsCredentials: false + ) + } + } + + func restoreAuthenticationState() { + guard authenticationRestoreTask == nil else { return } + guard authenticationTask == nil, isProcessingAuthRequest == false else { return } + switch authenticationState { + case .waitingForFederatedAuthentication(_), .waitingForSecondFactor(_, _, _): + return + case .authenticated, .unauthenticated, .notAppleDeveloper: + break + } + + let restoreID = beginAuthenticationRestore() + authenticationRestoreTask = Task { @MainActor [weak self] in + await self?.performAuthenticationRestore( + id: restoreID, + authenticationRequestPolicy: AuthenticationRequestPolicy() + ) + } + } + + func restoreAuthenticationStateAsync( + authenticationRequestPolicy: AuthenticationRequestPolicy = AuthenticationRequestPolicy() + ) async { + authenticationRestoreTask?.cancel() + authenticationRestoreTask = nil + let restoreID = beginAuthenticationRestore() + await performAuthenticationRestore( + id: restoreID, + authenticationRequestPolicy: authenticationRequestPolicy + ) + } + + private func beginAuthenticationRestore() -> UUID { + let restoreID = UUID() + authenticationRestoreTaskID = restoreID + isRestoringAuthenticationState = true + return restoreID + } + + private func performAuthenticationRestore( + id restoreID: UUID, + authenticationRequestPolicy: AuthenticationRequestPolicy + ) async { + defer { + if authenticationRestoreTaskID == restoreID { + authenticationRestoreTask = nil + authenticationRestoreTaskID = nil + isRestoringAuthenticationState = false + } + } + + do { + _ = try await signInIfNeededAsync( + authenticationRequestPolicy: authenticationRequestPolicy + ) + } catch is CancellationError { + } catch { + guard authenticationRestoreTaskID == restoreID else { return } + if AuthenticationRequestPolicy.requiresCredentialSignIn(after: error) + || AuthenticationRequestPolicy.shouldClearCredentials(after: error) { + authenticationState = .unauthenticated + } + Logger.appState.error("Restoring Apple Account session failed: \(error.legibleDescription)") } } func signIn(username: String, password: String?) { + cancelAuthenticationRestore() authError = nil startAuthenticationTask { _ = try await self.signInAsync(username: username.lowercased(), password: password) } } - func signInAsync(username: String, password: String?) async throws -> AuthenticationState { - if let password, !password.isEmpty { + func signInAsync( + username: String, + password: String?, + persistsCredentials: Bool = true + ) async throws -> AuthenticationState { + if persistsCredentials, let password, !password.isEmpty { try? Current.keychain.set(password, key: username) } - Current.defaults.set(username, forKey: "username") + if persistsCredentials { + Current.defaults.set(username, forKey: "username") + } return try await performAuthenticationRequest { try await AuthenticationRequestPolicy().perform { - try await self.client.authenticationState(accountName: username, password: password) + try await Current.network.authenticationStateAsync(username, password) } } } @@ -538,6 +717,7 @@ class AppState: ObservableObject { func cancelSecurityKeyAssertationRequest() { self.client.cancelSecurityKeyAssertationRequest() + cancelAuthentication() } private func handleAuthenticationFlowFailure(_ error: Error) { @@ -545,7 +725,11 @@ class AppState: ObservableObject { clearLoginCredentials() } Logger.appState.error("Authentication error: \(error.legibleDescription)") - self.authError = error + self.authError = Self.userFacingAuthenticationError(error) + } + + static func userFacingAuthenticationError(_ error: Error) -> Error { + AppleAccountAuthenticationError(error) ?? error } private func handleAuthenticationFlowSuccess() { @@ -560,8 +744,10 @@ class AppState: ObservableObject { } private func performAuthenticationRequest( + reportsErrors: Bool = true, _ operation: () async throws -> AuthenticationState ) async throws -> AuthenticationState { + try Task.checkCancellation() let requestID = UUID() authenticationRequestID = requestID isProcessingAuthRequest = true @@ -574,12 +760,14 @@ class AppState: ObservableObject { do { let authenticationState = try await operation() + try Task.checkCancellation() guard authenticationRequestID == requestID else { return authenticationState } self.authenticationState = authenticationState + authError = nil handleAuthenticationFlowSuccess() return authenticationState } catch { - if authenticationRequestID == requestID { + if authenticationRequestID == requestID, reportsErrors { handleAuthenticationFlowFailure(error) } throw error @@ -606,10 +794,35 @@ class AppState: ObservableObject { } } + func cancelAuthentication() { + cancelAuthenticationRestore() + authenticationTask?.cancel() + authenticationTask = nil + authenticationTaskID = nil + authenticationRequestID = nil + isProcessingAuthRequest = false + signInIfNeededTask?.cancel() + signInIfNeededTask = nil + signInIfNeededTaskID = nil + authError = nil + presentedSheet = nil + authenticationState = .unauthenticated + } + func signOut() { + cancelAuthentication() clearLoginCredentials() Current.network.signout() - authenticationState = .unauthenticated + } + + private func cancelAuthenticationRestore() { + authenticationRestoreTask?.cancel() + authenticationRestoreTask = nil + authenticationRestoreTaskID = nil + isRestoringAuthenticationState = false + signInIfNeededTask?.cancel() + signInIfNeededTask = nil + signInIfNeededTaskID = nil } // MARK: - Helper @@ -1063,7 +1276,7 @@ class AppState: ObservableObject { ).uninstall(xcode, emptyTrash: false) } - private func waitForAuthenticationTerminalState() async throws { + func waitForAuthenticationTerminalState() async throws { func validate(_ state: AuthenticationState) throws -> Bool { switch state { case .authenticated: @@ -1086,17 +1299,19 @@ class AppState: ObservableObject { try Task.checkCancellation() if try validate(state) { return } } + try Task.checkCancellation() } private func handleInstallError(_ error: Error, id: XcodeID) { + let presentedError = Self.userFacingAuthenticationError(error) // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead if let error = error as? AuthenticationError, case .notAuthorized = error { - self.error = error + self.error = presentedError self.presentedAlert = .unauthenticated } else if error as? AuthenticationError != .invalidSession { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) + self.error = presentedError + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: presentedError.legibleLocalizedDescription) } if let index = self.allXcodes.firstIndex(where: { $0.id == id }) { self.allXcodes[index].installState = .notInstalled diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index fc6b89c2..003de731 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -412,7 +412,8 @@ public struct Network: Sendable { downloadTaskAsync(url, saveLocation, resumeData) } - public var validateSessionAsync: @Sendable () async throws -> Void + public var validateSessionAsync: @Sendable () async throws -> AuthenticationState + public var authenticationStateAsync: @Sendable (String, String?) async throws -> AuthenticationState public var signout: @Sendable () -> Void @@ -420,7 +421,8 @@ public struct Network: Sendable { session: URLSession? = nil, loadData: (@Sendable (URLRequest) async throws -> (Data, URLResponse))? = nil, downloadTaskAsync: (@Sendable (URL, URL, Data?) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>))? = nil, - validateSessionAsync: (@Sendable () async throws -> Void)? = nil, + validateSessionAsync: (@Sendable () async throws -> AuthenticationState)? = nil, + authenticationStateAsync: (@Sendable (String, String?) async throws -> AuthenticationState)? = nil, signout: (@Sendable () -> Void)? = nil ) { let loginClient: XcodesLoginKit.Client @@ -437,7 +439,10 @@ public struct Network: Sendable { loginClient.urlSession.downloadTaskAsync(with: url, to: saveLocation, resumingWith: resumeData) } self.validateSessionAsync = validateSessionAsync ?? { - _ = try await loginClient.validateSession() + try await loginClient.validateSession() + } + self.authenticationStateAsync = authenticationStateAsync ?? { username, password in + try await loginClient.authenticationState(accountName: username, password: password) } self.signout = signout ?? { loginClient.signout() @@ -452,7 +457,10 @@ public struct Network: Sendable { loginClient.urlSession.downloadTaskAsync(with: url, to: saveLocation, resumingWith: resumeData) } self.validateSessionAsync = { - _ = try await loginClient.validateSession() + try await loginClient.validateSession() + } + self.authenticationStateAsync = { username, password in + try await loginClient.authenticationState(accountName: username, password: password) } self.signout = { loginClient.signout() diff --git a/Xcodes/Backend/NotificationManager.swift b/Xcodes/Backend/NotificationManager.swift index 4b5fb8c3..0786e026 100644 --- a/Xcodes/Backend/NotificationManager.swift +++ b/Xcodes/Backend/NotificationManager.swift @@ -4,7 +4,7 @@ import UserNotifications /// Representation of the 3 states of the Notifications permission prompt which may either have not been shown, or was shown and denied or accepted /// Unknown is value to indicate that we have not yet determined the status and should not be used other than as a default value before determining the actual status -public enum NotificationPermissionPromptStatus: Int { +public enum NotificationPermissionPromptStatus: Int, Equatable, Sendable { case unknown, notShown, shownAndDenied, shownAndAccepted } @@ -31,7 +31,10 @@ public enum XcodesNotificationType: String, Identifiable, CaseIterable, CustomSt @MainActor public final class NotificationManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { + typealias NotificationStatusLoader = @MainActor @Sendable () async -> NotificationPermissionPromptStatus + private let notificationCenter = UNUserNotificationCenter.current() + private let notificationStatusLoader: NotificationStatusLoader private var notificationStatusTask: Task? private var notificationStatusTaskID: UUID? private var requestAccessTask: Task? @@ -40,6 +43,20 @@ public final class NotificationManager: NSObject, UNUserNotificationCenterDelega @Published var notificationStatus = NotificationPermissionPromptStatus.unknown nonisolated public override init() { + self.notificationStatusLoader = { + let settings = await UNUserNotificationCenter.current().notificationSettings() + return NotificationManager.systemPromptStatusFromSettings(settings) + } + super.init() + Task { @MainActor [weak self] in + guard let self else { return } + loadNotificationStatus() + notificationCenter.delegate = self + } + } + + nonisolated init(notificationStatusLoader: @escaping NotificationStatusLoader) { + self.notificationStatusLoader = notificationStatusLoader super.init() Task { @MainActor [weak self] in guard let self else { return } @@ -58,15 +75,15 @@ public final class NotificationManager: NSObject, UNUserNotificationCenterDelega let taskID = UUID() notificationStatusTaskID = taskID notificationStatusTask = Task { [weak self] in - let settings = await UNUserNotificationCenter.current().notificationSettings() + guard let self else { return } + let status = await notificationStatusLoader() guard !Task.isCancelled else { - await self?.clearNotificationStatusTask(id: taskID) + clearNotificationStatusTask(id: taskID) return } - let status = NotificationManager.systemPromptStatusFromSettings(settings) - await self?.setNotificationStatus(status, ifNotificationStatusTaskID: taskID) - await self?.clearNotificationStatusTask(id: taskID) + setNotificationStatus(status, ifNotificationStatusTaskID: taskID) + clearNotificationStatusTask(id: taskID) } } @@ -94,14 +111,14 @@ public final class NotificationManager: NSObject, UNUserNotificationCenterDelega do { let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) guard !Task.isCancelled else { - await self?.clearRequestAccessTask(id: taskID) + self?.clearRequestAccessTask(id: taskID) return } Logger.appState.log("User has \(granted ? "Granted" : "NOT GRANTED") notification permission") } catch { guard !Task.isCancelled else { - await self?.clearRequestAccessTask(id: taskID) + self?.clearRequestAccessTask(id: taskID) return } @@ -110,13 +127,13 @@ public final class NotificationManager: NSObject, UNUserNotificationCenterDelega let settings = await UNUserNotificationCenter.current().notificationSettings() guard !Task.isCancelled else { - await self?.clearRequestAccessTask(id: taskID) + self?.clearRequestAccessTask(id: taskID) return } let status = NotificationManager.systemPromptStatusFromSettings(settings) - await self?.setNotificationStatus(status, ifRequestAccessTaskID: taskID) - await self?.clearRequestAccessTask(id: taskID) + self?.setNotificationStatus(status, ifRequestAccessTaskID: taskID) + self?.clearRequestAccessTask(id: taskID) } } diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index 7f8e16b0..85774f0b 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -46,9 +46,10 @@ struct MainWindow: View { .toolbar { ToolbarItemGroup { Button(action: { appState.presentedSheet = .signIn }, label: { - Label("Login", systemImage: "person.circle") + Label("AppleAccount", systemImage: "person.circle") }) - .help("LoginDescription") + .help("ManageAppleAccount") + .disabled(appState.isRestoringAuthenticationState) if #available(macOS 14, *) { SettingsLink(label: { Label("Preferences", systemImage: "gearshape") @@ -78,7 +79,7 @@ struct MainWindow: View { secondFactorView(secondFactorData) .environmentObject(appState) case .securityKeyTouchToConfirm: - SignInSecurityKeyTouchView(isPresented: $appState.presentedSheet.isNotNil) + SignInSecurityKeyTouchView() .environmentObject(appState) } } @@ -106,13 +107,13 @@ struct MainWindow: View { private func secondFactorView(_ secondFactorData: XcodesSheet.SecondFactorData) -> some View { switch secondFactorData.option { case .codeSent: - SignIn2FAView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) + SignIn2FAView(authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) case .smsSent(let trustedPhoneNumber): - SignInSMSView(isPresented: $appState.presentedSheet.isNotNil, trustedPhoneNumber: trustedPhoneNumber, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) + SignInSMSView(trustedPhoneNumber: trustedPhoneNumber, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) case .smsPendingChoice: - SignInPhoneListView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) + SignInPhoneListView(authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) case .securityKey: - SignInSecurityKeyPinView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) + SignInSecurityKeyPinView(authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) } } diff --git a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift index 36734a32..b92d9d95 100644 --- a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift @@ -179,7 +179,7 @@ struct AdvancedPreferencePane_Previews: PreviewProvider { // A group style for the preferences struct PreferencesGroupBoxStyle: GroupBoxStyle { func makeBody(configuration: Configuration) -> some View { - HStack(alignment: .top, spacing: 20) { + HStack(alignment: .firstTextBaseline, spacing: 20) { configuration.label .frame(width: 180, alignment: .trailing) diff --git a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift index 2ccfe346..c565a5af 100644 --- a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift @@ -1,14 +1,43 @@ import SwiftUI +import XcodesLoginKit + +enum AppleAccountPreferencePresentation: Equatable { + case checking + case signedIn + case signedOut + + init(isRestoring: Bool, authenticationState: AuthenticationState) { + if isRestoring { + self = .checking + } else { + switch authenticationState { + case .authenticated: + self = .signedIn + case .waitingForFederatedAuthentication, .waitingForSecondFactor: + self = .checking + case .unauthenticated, .notAppleDeveloper: + self = .signedOut + } + } + } +} struct GeneralPreferencePane: View { @EnvironmentObject var appState: AppState var body: some View { VStack(alignment: .leading) { - GroupBox(label: Text("AppleID")) { - if case .authenticated = appState.authenticationState { + GroupBox(label: Text("AppleAccount")) { + switch AppleAccountPreferencePresentation( + isRestoring: appState.isRestoringAuthenticationState, + authenticationState: appState.authenticationState + ) { + case .checking: + ProgressView("CheckingAppleAccount") + .controlSize(.small) + case .signedIn: SignedInView() - } else { + case .signedOut: Button("SignIn", action: { self.appState.presentedSheet = .signIn }) } } @@ -16,12 +45,12 @@ struct GeneralPreferencePane: View { Divider() GroupBox(label: Text("Notifications")) { - NotificationsView().environmentObject(appState) + NotificationsView() } .groupBoxStyle(PreferencesGroupBoxStyle()) Divider() - GroupBox(label: Text("Misc")) { + GroupBox(label: Text("AppBehaviour")) { Toggle("TerminateAfterLastWindowClosed", isOn: $appState.terminateAfterLastWindowClosed) Toggle("GroupXcodeVersionsInList", isOn: $appState.enableGroupedXcodeList) } diff --git a/Xcodes/Frontend/Preferences/NotificationsView.swift b/Xcodes/Frontend/Preferences/NotificationsView.swift index b878a06c..473af406 100644 --- a/Xcodes/Frontend/Preferences/NotificationsView.swift +++ b/Xcodes/Frontend/Preferences/NotificationsView.swift @@ -1,31 +1,65 @@ import SwiftUI +enum NotificationPreferencePresentation: Equatable { + case checking + case canEnable + case disabled + case enabled + + init(_ status: NotificationPermissionPromptStatus) { + switch status { + case .unknown: + self = .checking + case .notShown: + self = .canEnable + case .shownAndDenied: + self = .disabled + case .shownAndAccepted: + self = .enabled + } + } +} + struct NotificationsView: View { - @EnvironmentObject var appState: AppState + @SwiftUI.Environment(\.scenePhase) private var scenePhase + @ObservedObject var notificationManager: NotificationManager + + init(notificationManager: NotificationManager = Current.notificationManager) { + self.notificationManager = notificationManager + } var body: some View { VStack(alignment: .leading) { - - switch Current.notificationManager.notificationStatus { - case .shownAndAccepted: - Text("AccessGranted") - .fixedSize(horizontal: false, vertical: true) - case .shownAndDenied: - Text("AccessDenied") - .fixedSize(horizontal: false, vertical: true) - Button("NotificationSettings", action: { - NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.notifications")!) - }) - - default: - Button("EnableNotifications", action: { - Current.notificationManager.requestAccess() - }) + switch NotificationPreferencePresentation(notificationManager.notificationStatus) { + case .checking: + ProgressView("CheckingNotificationSettings") + .controlSize(.small) + case .canEnable: + Button("EnableNotifications") { + notificationManager.requestAccess() + } + case .disabled: + Label("AccessDenied", systemImage: "exclamationmark.triangle") + .fixedSize(horizontal: false, vertical: true) + Button("NotificationSettings") { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else { return } + NSWorkspace.shared.open(url) + } + case .enabled: + Label("AccessGranted", systemImage: "checkmark.circle") + .fixedSize(horizontal: false, vertical: true) } } .frame(maxWidth: .infinity, alignment: .leading) + .task { + notificationManager.loadNotificationStatus() + } + .onChange(of: scenePhase) { _, newScenePhase in + if case .active = newScenePhase { + notificationManager.loadNotificationStatus() + } + } } - } struct NotificationsView_Previews: PreviewProvider { diff --git a/Xcodes/Frontend/SignIn/SignIn2FAView.swift b/Xcodes/Frontend/SignIn/SignIn2FAView.swift index 1cc88701..34684337 100644 --- a/Xcodes/Frontend/SignIn/SignIn2FAView.swift +++ b/Xcodes/Frontend/SignIn/SignIn2FAView.swift @@ -3,7 +3,6 @@ import XcodesLoginKit struct SignIn2FAView: View { @EnvironmentObject var appState: AppState - @Binding var isPresented: Bool @State private var code: String = "" let authOptions: AuthOptionsResponse let sessionData: AppleSessionData @@ -30,7 +29,7 @@ struct SignIn2FAView: View { .padding() HStack { - Button("Cancel", action: { isPresented = false }) + Button("Cancel", action: appState.cancelAuthentication) .keyboardShortcut(.cancelAction) Button("SendSMS", action: { appState.choosePhoneNumberForSMS(authOptions: authOptions, sessionData: sessionData) }) Spacer() @@ -52,7 +51,6 @@ struct SignIn2FAView_Previews: PreviewProvider { @MainActor static var previews: some View { SignIn2FAView( - isPresented: .constant(true), authOptions: AuthOptionsResponse( trustedPhoneNumbers: nil, trustedDevices: nil, diff --git a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift index 15f2ac21..c638f958 100644 --- a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift +++ b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift @@ -12,16 +12,17 @@ struct SignInCredentialsView: View { var body: some View { VStack(alignment: .leading) { - Text("SignInWithApple") + Text("SignInToAppleAccount") .bold() .padding(.vertical) HStack { - Text("AppleID") + Text("AppleAccount") .frame(minWidth: 100, alignment: .trailing) TextField(text: $username) { Text(verbatim: "example@icloud.com") } .textContentType(.username) + .accessibilityLabel(Text("AppleAccount")) .focused($focusedField, equals: .username) } HStack { @@ -29,6 +30,7 @@ struct SignInCredentialsView: View { .frame(minWidth: 100, alignment: .trailing) SecureField("Required", text: $password) .textContentType(.password) + .accessibilityLabel(Text("Password")) .focused($focusedField, equals: .password) } if appState.authError != nil { @@ -44,8 +46,7 @@ struct SignInCredentialsView: View { HStack { Spacer() Button("Cancel") { - appState.authError = nil - appState.presentedSheet = nil + appState.cancelAuthentication() } .keyboardShortcut(.cancelAction) ProgressButton( diff --git a/Xcodes/Frontend/SignIn/SignInFederatedView.swift b/Xcodes/Frontend/SignIn/SignInFederatedView.swift index 1c702e2f..36943bfc 100644 --- a/Xcodes/Frontend/SignIn/SignInFederatedView.swift +++ b/Xcodes/Frontend/SignIn/SignInFederatedView.swift @@ -8,11 +8,11 @@ struct SignInFederatedView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - Text("SignInWithApple") + Text("SignInToAppleAccount") .bold() .padding(.vertical) - Text("This Apple ID uses federated authentication via \(organizationName).") + Text("This Apple Account uses federated authentication via \(organizationName).") .fixedSize(horizontal: false, vertical: true) Button("Open Browser") { @@ -33,8 +33,7 @@ struct SignInFederatedView: View { HStack { Spacer() Button("Cancel") { - appState.authError = nil - appState.presentedSheet = nil + appState.cancelAuthentication() } .keyboardShortcut(.cancelAction) diff --git a/Xcodes/Frontend/SignIn/SignInPhoneListView.swift b/Xcodes/Frontend/SignIn/SignInPhoneListView.swift index f907c6f2..54f9355a 100644 --- a/Xcodes/Frontend/SignIn/SignInPhoneListView.swift +++ b/Xcodes/Frontend/SignIn/SignInPhoneListView.swift @@ -3,7 +3,6 @@ import SwiftUI struct SignInPhoneListView: View { @EnvironmentObject var appState: AppState - @Binding var isPresented: Bool @State private var selectedPhoneNumberID: AuthOptionsResponse.TrustedPhoneNumber.ID? let authOptions: AuthOptionsResponse let sessionData: AppleSessionData @@ -28,7 +27,7 @@ struct SignInPhoneListView: View { } HStack { - Button("Cancel", action: { isPresented = false }) + Button("Cancel", action: appState.cancelAuthentication) .keyboardShortcut(.cancelAction) Spacer() ProgressButton(isInProgress: appState.isProcessingAuthRequest, @@ -52,7 +51,6 @@ struct SignInPhoneListView_Previews: PreviewProvider { static var previews: some View { Group { SignInPhoneListView( - isPresented: .constant(true), authOptions: AuthOptionsResponse( trustedPhoneNumbers: [.init(id: 0, numberWithDialCode: "(•••) •••-••90")], trustedDevices: nil, @@ -63,7 +61,6 @@ struct SignInPhoneListView_Previews: PreviewProvider { .environmentObject(AppState()) SignInPhoneListView( - isPresented: .constant(true), authOptions: AuthOptionsResponse( trustedPhoneNumbers: [], trustedDevices: nil, diff --git a/Xcodes/Frontend/SignIn/SignInSMSView.swift b/Xcodes/Frontend/SignIn/SignInSMSView.swift index e298a8b5..73f4960d 100644 --- a/Xcodes/Frontend/SignIn/SignInSMSView.swift +++ b/Xcodes/Frontend/SignIn/SignInSMSView.swift @@ -3,7 +3,6 @@ import XcodesLoginKit struct SignInSMSView: View { @EnvironmentObject var appState: AppState - @Binding var isPresented: Bool @State private var code: String = "" let trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber let authOptions: AuthOptionsResponse @@ -31,7 +30,7 @@ struct SignInSMSView: View { .padding() HStack { - Button("Cancel", action: { isPresented = false }) + Button("Cancel", action: appState.cancelAuthentication) .keyboardShortcut(.cancelAction) Spacer() ProgressButton(isInProgress: appState.isProcessingAuthRequest, @@ -52,7 +51,6 @@ struct SignInSMSView_Previews: PreviewProvider { @MainActor static var previews: some View { SignInSMSView( - isPresented: .constant(true), trustedPhoneNumber: .init(id: 0, numberWithDialCode: "(•••) •••-••90"), authOptions: AuthOptionsResponse( trustedPhoneNumbers: nil, diff --git a/Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift b/Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift index 6f5b2c2c..98cebe36 100644 --- a/Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift +++ b/Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift @@ -11,7 +11,6 @@ import XcodesLoginKit struct SignInSecurityKeyPinView: View { @EnvironmentObject var appState: AppState - @Binding var isPresented: Bool @State private var pin: String = "" let authOptions: AuthOptionsResponse let sessionData: AppleSessionData @@ -29,7 +28,7 @@ struct SignInSecurityKeyPinView: View { .padding() HStack { - Button("Cancel", action: { isPresented = false }) + Button("Cancel", action: appState.cancelAuthentication) .keyboardShortcut(.cancelAction) Spacer() @@ -60,8 +59,7 @@ struct SignInSecurityKeyPinView: View { } #Preview { @MainActor in - SignInSecurityKeyPinView(isPresented: .constant(true), - authOptions: AuthOptionsResponse( + SignInSecurityKeyPinView(authOptions: AuthOptionsResponse( trustedPhoneNumbers: nil, trustedDevices: nil, securityCode: .init(length: 6) diff --git a/Xcodes/Frontend/SignIn/SignInSecurityKeyTouchView.swift b/Xcodes/Frontend/SignIn/SignInSecurityKeyTouchView.swift index 1cdd2e64..aafd179a 100644 --- a/Xcodes/Frontend/SignIn/SignInSecurityKeyTouchView.swift +++ b/Xcodes/Frontend/SignIn/SignInSecurityKeyTouchView.swift @@ -11,7 +11,6 @@ import XcodesLoginKit struct SignInSecurityKeyTouchView: View { @EnvironmentObject var appState: AppState - @Binding var isPresented: Bool var body: some View { VStack(alignment: .center) { @@ -44,11 +43,10 @@ struct SignInSecurityKeyTouchView: View { func cancel() { appState.cancelSecurityKeyAssertationRequest() - isPresented = false } } #Preview { @MainActor in - SignInSecurityKeyTouchView(isPresented: .constant(true)) + SignInSecurityKeyTouchView() .environmentObject(AppState()) } diff --git a/Xcodes/Frontend/SignIn/SignedInView.swift b/Xcodes/Frontend/SignIn/SignedInView.swift index 4b3bbf1a..cec54f36 100644 --- a/Xcodes/Frontend/SignIn/SignedInView.swift +++ b/Xcodes/Frontend/SignIn/SignedInView.swift @@ -3,13 +3,9 @@ import SwiftUI struct SignedInView: View { @EnvironmentObject var appState: AppState - private var username: String { - appState.savedUsername ?? "" - } - var body: some View { - HStack(alignment:.top, spacing: 10) { - Text(username) + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(appState.appleAccountDisplayName) Button("SignOut", action: appState.signOut) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index ce6c49fc..f973f248 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -379,132 +379,787 @@ } } }, + "AuthError.PrivacyAcknowledgementRequired" : { + "comment" : "Apple Account authentication", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "سجّل الدخول إلى App Store Connect واقبل اتفاقية حساب Apple والخصوصية." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicia la sessió a App Store Connect i accepta l’acord Compte d’Apple i privadesa." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Melde dich bei App Store Connect an und akzeptiere die Vereinbarung „Apple Account & Datenschutz“." + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Συνδεθείτε στο App Store Connect και αποδεχτείτε τη συμφωνία Λογαριασμού Apple και απορρήτου." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in to App Store Connect and accept the Apple Account & Privacy agreement." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicia sesión en App Store Connect y acepta el acuerdo de Cuenta de Apple y privacidad." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kirjaudu App Store Connectiin ja hyväksy Apple-tili ja tietosuoja -sopimus." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connectez-vous à App Store Connect et acceptez l’accord Compte Apple et confidentialité." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Store Connect में साइन इन करें और Apple अकाउंट व गोपनीयता समझौता स्वीकार करें।" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accedi ad App Store Connect e accetta l’accordo Account Apple e privacy." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Store Connectにサインインし、Apple Accountとプライバシーに関する契約に同意してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Store Connect에 로그인하고 Apple 계정 및 개인정보 보호 계약에 동의하십시오." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log in bij App Store Connect en accepteer de overeenkomst Apple Account en privacy." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zaloguj się do App Store Connect i zaakceptuj umowę Konto Apple i prywatność." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicie sessão no App Store Connect e aceite o contrato de Conta Apple e Privacidade." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Войдите в App Store Connect и примите соглашение «Учётная запись Apple и конфиденциальность»." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ลงชื่อเข้า App Store Connect และยอมรับข้อตกลงบัญชี Apple และความเป็นส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Store Connect'e giriş yapın ve Apple Hesabı ve Gizlilik sözleşmesini kabul edin." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Увійдіть в App Store Connect і прийміть угоду «Обліковий запис Apple і конфіденційність»." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录 App Store Connect 并接受“Apple 账户与隐私”协议。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "登入 App Store Connect 並接受「Apple 帳號與隱私權」協議。" + } + } + } + }, + "AuthError.NotAuthorized" : { + "comment" : "Apple Account authentication", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أنت غير مخوّل. سجّل الدخول إلى حساب Apple أولاً." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "No tens autorització. Inicia primer la sessió al compte d’Apple." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du bist nicht autorisiert. Melde dich zuerst bei deinem Apple Account an." + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Δεν έχετε εξουσιοδότηση. Συνδεθείτε πρώτα στον Λογαριασμό Apple." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You are not authorised. Sign in to your Apple Account first." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No tienes autorización. Inicia primero sesión en tu Cuenta de Apple." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sinulla ei ole käyttöoikeutta. Kirjaudu ensin Apple-tilillesi." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vous n’êtes pas autorisé. Connectez-vous d’abord à votre compte Apple." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आप अधिकृत नहीं हैं। पहले अपने Apple अकाउंट में साइन इन करें।" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non disponi dell’autorizzazione. Accedi prima al tuo Account Apple." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "認証されていません。最初にApple Accountにサインインしてください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "승인되지 않았습니다. 먼저 Apple 계정에 로그인하십시오." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je bent niet gemachtigd. Log eerst in bij je Apple Account." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak autoryzacji. Najpierw zaloguj się na Konto Apple." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Você não tem autorização. Inicie primeiro sessão na sua Conta Apple." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "У вас нет авторизации. Сначала войдите в Учётную запись Apple." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "คุณไม่ได้รับอนุญาต โปรดลงชื่อเข้าบัญชี Apple ก่อน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yetkiniz yok. Önce Apple Hesabınıza giriş yapın." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "У вас немає авторизації. Спочатку увійдіть в Обліковий запис Apple." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你未获授权。请先登录你的 Apple 账户。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "你未獲授權。請先登入你的 Apple 帳號。" + } + } + } + }, + "AuthError.PasswordRequired" : { + "comment" : "Apple Account authentication", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا يستخدم حساب Apple هذا المصادقة الموحدة. أدخل كلمة السر للمتابعة." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aquest compte d’Apple no utilitza l’autenticació federada. Introdueix la contrasenya per continuar." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Apple Account verwendet keine föderierte Authentifizierung. Gib zum Fortfahren dein Passwort ein." + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Αυτός ο Λογαριασμός Apple δεν χρησιμοποιεί ομοσπονδιακό έλεγχο ταυτότητας. Εισαγάγετε το συνθηματικό σας για να συνεχίσετε." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This Apple Account does not use federated authentication. Enter your password to continue." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esta Cuenta de Apple no usa autenticación federada. Introduce tu contraseña para continuar." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tämä Apple-tili ei käytä yhdistettyä todennusta. Jatka antamalla salasanasi." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ce compte Apple n’utilise pas l’authentification fédérée. Saisissez votre mot de passe pour continuer." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यह Apple अकाउंट फ़ेडरेटेड प्रमाणीकरण का उपयोग नहीं करता। जारी रखने के लिए अपना पासवर्ड डालें।" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questo Account Apple non usa l’autenticazione federata. Inserisci la password per continuare." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このApple Accountはフェデレーション認証を使用しません。続けるにはパスワードを入力してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 Apple 계정은 페더레이션 인증을 사용하지 않습니다. 계속하려면 암호를 입력하십시오." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deze Apple Account gebruikt geen gefedereerde verificatie. Voer je wachtwoord in om door te gaan." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "To Konto Apple nie używa uwierzytelniania federacyjnego. Wprowadź hasło, aby kontynuować." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esta Conta Apple não usa autenticação federada. Digite sua senha para continuar." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эта Учётная запись Apple не использует федеративную аутентификацию. Введите пароль, чтобы продолжить." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บัญชี Apple นี้ไม่ได้ใช้การตรวจสอบสิทธิ์แบบรวมศูนย์ ป้อนรหัสผ่านเพื่อดำเนินการต่อ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu Apple Hesabı birleşik kimlik doğrulaması kullanmıyor. Devam etmek için parolanızı girin." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цей Обліковий запис Apple не використовує федеративну автентифікацію. Введіть пароль, щоб продовжити." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此 Apple 账户不使用联合认证。请输入密码以继续。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此 Apple 帳號不使用同盟認證。請輸入密碼以繼續。" + } + } + } + }, + "CheckingAppleAccount" : { + "comment" : "Apple Account authentication", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جارٍ التحقق من حساب Apple…" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "S’està comprovant el compte d’Apple…" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Account wird geprüft…" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Έλεγχος Λογαριασμού Apple…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking Apple Account…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comprobando Cuenta de Apple…" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarkistetaan Apple-tiliä…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérification du compte Apple…" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple अकाउंट की जाँच हो रही है…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifica dell’Account Apple…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Accountを確認中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple 계정 확인 중…" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Account controleren…" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprawdzanie Konta Apple…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verificando Conta Apple…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверка Учётной записи Apple…" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "กำลังตรวจสอบบัญชี Apple…" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Hesabı kontrol ediliyor…" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перевірка Облікового запису Apple…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在检查 Apple 账户…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在檢查 Apple 帳號…" + } + } + } + }, + "CheckingNotificationSettings" : { + "comment" : "Notifications", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جارٍ التحقق من إعدادات الإشعارات…" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "S’està comprovant la configuració de notificacions…" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mitteilungseinstellungen werden geprüft…" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Έλεγχος ρυθμίσεων γνωστοποιήσεων…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking Notification Settings…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comprobando ajustes de notificaciones…" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarkistetaan ilmoitusasetuksia…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérification des réglages des notifications…" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचना सेटिंग्स की जाँच हो रही है…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifica delle impostazioni delle notifiche…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知設定を確認中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알림 설정 확인 중…" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meldingsinstellingen controleren…" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprawdzanie ustawień powiadomień…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verificando Ajustes de Notificações…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверка настроек уведомлений…" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "กำลังตรวจสอบการตั้งค่าการแจ้งเตือน…" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildirim Ayarları kontrol ediliyor…" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перевірка параметрів сповіщень…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在检查通知设置…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在檢查通知設定…" + } + } + } + }, "AccessDenied" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ تم الرفض ⚠️

فضلا قوم بفتح اعدادات الاشعارات
واختار تحديث الXcode الذي تريده
ثم اعطي اذن بالسماح" + "value" : "تم إيقاف الإشعارات في إعدادات النظام." } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Accés denegat ⚠️\n\nSi us plau, obre les preferències de les notificacions i selecciona Xcodes si vols garantir-li l'accés." + "value" : "Les notificacions estan desactivades a la configuració del sistema." } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Zugriff verweigert ⚠️\n\nBitte öffne Deine Benachrichtigungs-Einstellungen und wähle Xcodes aus, wenn Du den Zugriff erlauben möchtest." + "value" : "Mitteilungen sind in den Systemeinstellungen deaktiviert." } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Άρνηση Πρόσβασης ⚠️\n\nΑνοίξτε τις Ρυθμίσεις Γνωστοποιήσεων και επιλέξτε το Xcodes αν επιθυμείτε να επιτρέψετε την πρόσβαση." + "value" : "Οι γνωστοποιήσεις είναι απενεργοποιημένες στις Ρυθμίσεις συστήματος." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Access Denied ⚠️\n\nPlease open your Notification Settings and select Xcodes if you wish to allow access." + "value" : "Notifications are disabled in System Settings." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Acceso Denegado ⚠️\n\nPor favor abra su Configuración de notificaciones y seleccione Xcodes si desea permitir el acceso." + "value" : "Las notificaciones están desactivadas en Ajustes del Sistema." } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Pääsy kielletty ⚠️\n\nAvaa ilmoitusasetukset ja valitse Xcodes, jos haluat sallia pääsyn." + "value" : "Ilmoitukset on poistettu käytöstä järjestelmäasetuksissa." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Accès refusé ⚠️\n\nVeuillez ouvrir vos paramètres de notifications et sélectionnez Xcodes si vous souhaitez autoriser l'accès." + "value" : "Les notifications sont désactivées dans Réglages Système." } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ प्रवेश निषेध ⚠️\n\nकृपया अपनी सूचना सेटिंग्स खोलें और यदि आप प्रवेश की अनुमति देना चाहते हैं तो Xcodes का चयन करें." + "value" : "सिस्टम सेटिंग्स में सूचनाएँ बंद हैं।" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Accesso Negato ⚠️\n\nApri le Impostazioni delle Notifiche e seleziona Xcodes se desideri garantirgli accesso." + "value" : "Le notifiche sono disattivate in Impostazioni di Sistema." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ アクセス拒否 ⚠️\n\nアクセス許可する場合は、通知設定を開き、Xcodesを選択してください。" + "value" : "システム設定で通知が無効になっています。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ 접근 권한이 부여되지 않았습니다 ⚠️\n\n 접근을 허용하려면 알림 설정을 열고 Xcodes를 선택하여 알림을 허용해주세요." + "value" : "시스템 설정에서 알림이 꺼져 있습니다." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Toegang Geweigerd ⚠️\n\nOpen je Notificatie Instellingen en selecteer Xcodes om toegang te verlenen." + "value" : "Meldingen zijn uitgeschakeld in Systeeminstellingen." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Dostęp zabroniony ⚠️\n\nOtwórz ustawienia powiadomień i wybierz Xcodes, jeśli chcesz zezwolić na dostęp." + "value" : "Powiadomienia są wyłączone w Ustawieniach systemowych." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Acesso negado ⚠️\n\nPor favor, abra suas configurações de notificação e selecione Xcodes se você deseja autorizar o acesso." + "value" : "As notificações estão desativadas nos Ajustes do Sistema." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Отказано в доступе ⚠️\n\nПожалуйста, откройте настройки уведомлений и выберите Xcodes, чтобы разрешить доступ." + "value" : "Уведомления отключены в Системных настройках." } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ การเข้าถึงถูกปฏิเสธ ⚠️\n\nโปรดเปิดการตั้งค่าการแจ้งเตือนและเลือก Xcodes หากคุณต้องการอนุญาตการเข้าถึง" + "value" : "การแจ้งเตือนถูกปิดอยู่ในการตั้งค่าระบบ" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Erişim Reddedildi ⚠️\n\nEğer bildirimler için erişim vermek istiyorsanız lütfen bildirim ayarlarını açın ve Xcodes uygulamasını seçin." + "value" : "Bildirimler Sistem Ayarları'nda kapalı." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ Сповіщення Заборонено ⚠️\n\nВідкрийте будь ласка Налаштування Сповіщень та надайте дозвіл для Xcodes, якщо ви бажаєте отримувати сповіщення." + "value" : "Сповіщення вимкнено в Системних параметрах." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ 没有权限 ⚠️\n\n请打开您的通知偏好设置并允许Xcodes发送通知。" + "value" : "通知已在“系统设置”中关闭。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "⚠️ 權限不足⚠️\n\n如果你想要啟用通知,請打開系統的通知設定並選擇 Xcodes。" + "value" : "通知已在「系統設定」中關閉。" } } } @@ -515,127 +1170,127 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "لقد تم منح الوصول. سوف تتلقى إشعارات من Xcodes." + "value" : "تم تمكين الإشعارات." } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Accés garantit. Rebràs notificacions d'Xcodes." + "value" : "Les notificacions estan activades." } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Zugriff erlaubt. Du empfängst jetzt Benachrichtigungen von Xcodes." + "value" : "Mitteilungen sind aktiviert." } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Η πρόσβαση εκχωρήθηκε. Θα λαμβάνετε γνωστοποιήσεις από το Xcode's." + "value" : "Οι γνωστοποιήσεις είναι ενεργοποιημένες." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Access Granted. You will receive notifications from Xcodes." + "value" : "Notifications are enabled." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Acceso Permitido. Recibirás notificaciones de Xcodes." + "value" : "Las notificaciones están activadas." } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Pääsy myönnetty. Saat ilmoituksia Xcodesilta." + "value" : "Ilmoitukset ovat käytössä." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Accès autorisé. Vous recevrez des notifications de Xcodes." + "value" : "Les notifications sont activées." } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "प्रवेश करने की अनुमति है। आपको Xcodes से सूचनाएं प्राप्त होंगी।" + "value" : "सूचनाएँ चालू हैं।" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Accesso Garantito. Riceverai notifiche da Xcodes." + "value" : "Le notifiche sono attive." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "アクセスが許可されました。Xcodesから通知が届きます。" + "value" : "通知は有効です。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "접근 권한이 부여되었습니다. Xcodes에서 알림을 받게 됩니다." + "value" : "알림이 켜져 있습니다." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Toegang Verleend. Je krijgt notificaties van Xcodes." + "value" : "Meldingen zijn ingeschakeld." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Dostęp przyznany. Będziesz otrzymywać powiadomienia z Xcodes." + "value" : "Powiadomienia są włączone." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Acesso autorizado. Você receberá notificações de Xcodes." + "value" : "As notificações estão ativadas." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Доступ разрешен. Вы будете получать уведомления от Xcodes." + "value" : "Уведомления включены." } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ได้รับอนุญาตการเข้าถึงแล้ว คุณจะได้รับการแจ้งเตือนจาก Xcodes" + "value" : "เปิดการแจ้งเตือนแล้ว" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Erişim Tanımlandı. Xcodes'dan bildirimler alacaksınız." + "value" : "Bildirimler açık." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Сповіщення Дозволено.\nВи будете отримувати сповіщення від Xcodes." + "value" : "Сповіщення ввімкнено." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已授权。您将会收到Xcodes的通知。" + "value" : "通知已启用。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "已核准權限。你可以收到來自 Xcodes 的通知。" + "value" : "通知已啟用。" } } } @@ -2059,127 +2714,127 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "سجّل الدخول إلى حساب Apple قبل تثبيت Xcode." } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Inicia la sessió al teu compte d’Apple abans d’instal·lar l’Xcode." } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Melde dich vor der Installation von Xcode bei deinem Apple Account an." } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Συνδεθείτε στον Λογαριασμό Apple πριν εγκαταστήσετε το Xcode." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Sign in to your Apple Account before installing Xcode." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Inicia sesión en tu Cuenta de Apple antes de instalar Xcode." } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Kirjaudu Apple-tilillesi ennen Xcoden asentamista." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Connectez-vous à votre compte Apple avant d’installer Xcode." } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Xcode इंस्टॉल करने से पहले अपने Apple अकाउंट में साइन इन करें।" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Accedi al tuo Account Apple prima di installare Xcode." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Xcodeをインストールする前にApple Accountにサインインしてください。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Xcode를 설치하기 전에 Apple 계정에 로그인하십시오." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Log in bij je Apple Account voordat je Xcode installeert." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Zaloguj się na Konto Apple przed zainstalowaniem Xcode." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Você não está autorizado. Por favor, entre com o seu ID Apple primeiro." + "value" : "Inicie sessão na sua Conta Apple antes de instalar o Xcode." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Войдите в Учётную запись Apple перед установкой Xcode." } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "คุณไม่ได้รับอนุญาต กรุณาลงชื่อเข้าใช้ด้วย Apple ID ของคุณก่อน" + "value" : "ลงชื่อเข้าบัญชี Apple ก่อนติดตั้ง Xcode" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Xcode'u yüklemeden önce Apple Hesabınıza giriş yapın." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "Увійдіть в Обліковий запис Apple перед інсталяцією Xcode." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "安装 Xcode 前,请登录你的 Apple 账户。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "You are not authorized. Please Sign in with your Apple ID first." + "value" : "安裝 Xcode 前,請登入你的 Apple 帳號。" } } } @@ -4906,132 +5561,132 @@ } } }, - "AppleID" : { + "AppleAccount" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "ايميل ابل :" + "value" : "حساب Apple" } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "AppleID:" + "value" : "Compte d’Apple" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Apple-ID:" + "value" : "Apple Account" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "AppleID:" + "value" : "Λογαριασμός Apple" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "AppleID:" + "value" : "Apple Account" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "AppleID:" + "value" : "Cuenta de Apple" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "AppleID:" + "value" : "Apple-tili" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Identifiant Apple :" + "value" : "Compte Apple" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "AppleID:" + "value" : "Apple अकाउंट" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "AppleID:" + "value" : "Account Apple" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Apple ID:" + "value" : "Apple Account" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "AppleID:" + "value" : "Apple 계정" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "AppleID:" + "value" : "Apple Account" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "AppleID:" + "value" : "Konto Apple" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "AppleID:" + "value" : "Conta Apple" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Apple ID:" + "value" : "Учётная запись Apple" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "AppleID:" + "value" : "บัญชี Apple" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "AppleID:" + "value" : "Apple Hesabı" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Apple ID:" + "value" : "Обліковий запис Apple" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Apple ID:" + "value" : "Apple 账户" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "Apple ID:" + "value" : "Apple 帳號" } } } @@ -10921,25 +11576,25 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "تجميع إصدارات Xcode في القائمة" } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "Agrupa les versions d’Xcode a la llista" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "Xcode-Versionen in der Liste gruppieren" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "Ομαδοποίηση εκδόσεων Xcode στη λίστα" } }, "en" : { @@ -10951,97 +11606,97 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "Agrupar las versiones de Xcode en la lista" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "Ryhmittele Xcode-versiot luettelossa" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "Regrouper les versions de Xcode dans la liste" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "सूची में Xcode संस्करणों को समूहित करें" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "Raggruppa le versioni di Xcode nell’elenco" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "リスト内のXcodeバージョンをグループ化" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "목록에서 Xcode 버전 그룹화" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "Xcode-versies in de lijst groeperen" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "Grupuj wersje Xcode na liście" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "Agrupar versões do Xcode na lista" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "Группировать версии Xcode в списке" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "จัดกลุ่มเวอร์ชัน Xcode ในรายการ" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "Listedeki Xcode sürümlerini grupla" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "Групувати версії Xcode у списку" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "在列表中将 Xcode 版本分组" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "Group Xcode versions in the list" + "value" : "將列表中的 Xcode 版本分組" } } } @@ -15741,6 +16396,12 @@ "value" : "Είσοδος" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign In" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -15822,7 +16483,7 @@ "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Apple ID" + "value" : "Увійти" } }, "zh-Hans" : { @@ -15839,132 +16500,132 @@ } } }, - "LoginDescription" : { + "ManageAppleAccount" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "Open Login" + "value" : "إدارة حساب Apple" } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Obrir l'inici de sessió" + "value" : "Gestiona el compte d’Apple" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Login öffnen" + "value" : "Apple Account verwalten" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Άνοιγμα Εισόδου" + "value" : "Διαχείριση Λογαριασμού Apple" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Open Login" + "value" : "Manage Apple Account" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Abrir Inicio de Sesión" + "value" : "Gestionar Cuenta de Apple" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Avaa kirjautuminen" + "value" : "Hallitse Apple-tiliä" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Ouvrir une connexion" + "value" : "Gérer le compte Apple" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "लॉगिन खोलें" + "value" : "Apple अकाउंट प्रबंधित करें" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Apri Login" + "value" : "Gestisci Account Apple" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ログイン画面を開く" + "value" : "Apple Accountを管理" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "로그인 창 열기" + "value" : "Apple 계정 관리" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Open Inloggen" + "value" : "Apple Account beheren" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Otwórz okno logowania" + "value" : "Zarządzaj Kontem Apple" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Abrir login" + "value" : "Gerenciar Conta Apple" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Открыть окно логина" + "value" : "Управлять Учётной записью Apple" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "เปิดหน้าเข้าสู่ระบบ" + "value" : "จัดการบัญชี Apple" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Giriş Yap" + "value" : "Apple Hesabını Yönet" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Показати Apple ID" + "value" : "Керувати Обліковим записом Apple" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "打开登录窗口" + "value" : "管理 Apple 账户" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "打開登入" + "value" : "管理 Apple 帳號" } } } @@ -17142,132 +17803,132 @@ } } }, - "Misc" : { + "AppBehaviour" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "سلوك التطبيق" } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "Comportament de l’app" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "App-Verhalten" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "Συμπεριφορά εφαρμογής" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "App Behaviour" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "Comportamiento de la app" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "Apin toiminta" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "Comportement de l’app" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "ऐप का व्यवहार" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "Comportamento dell’app" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "アプリの動作" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "앱 동작" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "Appgedrag" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "Działanie aplikacji" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Diversos" + "value" : "Comportamento do app" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Другое" + "value" : "Поведение приложения" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "อื่น ๆ" + "value" : "ลักษณะการทำงานของแอป" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "Uygulama Davranışı" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "Поведінка програми" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "杂项" + "value" : "App 行为" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "雜項" + "value" : "App 行為" } } } @@ -18313,127 +18974,127 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "إعدادات الإشعار" + "value" : "فتح إعدادات الإشعارات…" } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Preferències de notificacions" + "value" : "Obre la configuració de notificacions…" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Benachrichtigungs-Einstellungen" + "value" : "Mitteilungseinstellungen öffnen…" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Ρυθμίσεις γνωστοποιήσεων" + "value" : "Άνοιγμα ρυθμίσεων γνωστοποιήσεων…" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Notification Settings" + "value" : "Open Notification Settings…" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Configuración de las notificaciones" + "value" : "Abrir ajustes de notificaciones…" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Ilmoitusasetukset" + "value" : "Avaa ilmoitusasetukset…" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Paramètres de Notification" + "value" : "Ouvrir les réglages des notifications…" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "सूचना सेटिंग्स" + "value" : "सूचना सेटिंग्स खोलें…" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Impostazioni delle Notifiche" + "value" : "Apri impostazioni notifiche…" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "通知設定" + "value" : "通知設定を開く…" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "알림 설정" + "value" : "알림 설정 열기…" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Notificatie Instellingen" + "value" : "Meldingsinstellingen openen…" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Ustawienia powiadomień" + "value" : "Otwórz ustawienia powiadomień…" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Configurações de notificação" + "value" : "Abrir Ajustes de Notificações…" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Настройки уведомлений" + "value" : "Открыть настройки уведомлений…" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "การตั้งค่าการแจ้งเตือน" + "value" : "เปิดการตั้งค่าการแจ้งเตือน…" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Bildirim Ayarları" + "value" : "Bildirim Ayarları'nı Aç…" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Налаштування Сповіщень" + "value" : "Відкрити параметри сповіщень…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "通知设置" + "value" : "打开通知设置…" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "通知偏好設定" + "value" : "打開通知設定…" } } } @@ -23807,133 +24468,263 @@ } } }, - "SignInWithApple" : { + "SignInToAppleAccount" : { "comment" : "SignIn", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "تسجيل الدخول بواسطة ابل " + "value" : "سجّل الدخول إلى حساب Apple." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicia la sessió al teu compte d’Apple." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Melde dich bei deinem Apple Account an." + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Συνδεθείτε στον Λογαριασμό Apple." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in to your Apple Account." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicia sesión en tu Cuenta de Apple." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kirjaudu Apple-tilillesi." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connectez-vous à votre compte Apple." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अपने Apple अकाउंट में साइन इन करें।" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accedi al tuo Account Apple." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Accountにサインインしてください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple 계정에 로그인하십시오." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log in bij je Apple Account." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zaloguj się na Konto Apple." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicie sessão na sua Conta Apple." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Войдите в Учётную запись Apple." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ลงชื่อเข้าบัญชี Apple" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Hesabınıza giriş yapın." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Увійдіть в Обліковий запис Apple." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录你的 Apple 账户。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "登入你的 Apple 帳號。" + } + } + } + }, + "SignedIn" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم تسجيل الدخول" } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Inicia sessió amb el teu Apple ID." + "value" : "Sessió iniciada" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Mit Deiner Apple-ID anmelden." + "value" : "Angemeldet" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Συνδεθείτε με το Apple ID σας." + "value" : "Έγινε σύνδεση" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Sign in with your Apple ID." + "value" : "Signed In" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Inicia sesión con tu ID de Apple." + "value" : "Sesión iniciada" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Kirjaudu sisään Apple ID:lläsi." + "value" : "Kirjauduttu sisään" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Connectez-vous avec votre identifiant Apple." + "value" : "Connecté" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "अपनी Apple ID से साइन इन करें." + "value" : "साइन इन किया हुआ" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Entra con il tuo Apple ID." + "value" : "Accesso eseguito" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Apple IDでサインインしてください。" + "value" : "サインイン済み" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Apple ID로 로그인하세요." + "value" : "로그인됨" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Log in met je Apple ID." + "value" : "Ingelogd" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Zaloguj się przy użyciu Apple ID." + "value" : "Zalogowano" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Entrar com o seu Apple ID." + "value" : "Sessão iniciada" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Войти со своим Apple ID." + "value" : "Вход выполнен" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "เข้าสู่ระบบด้วย Apple ID" + "value" : "ลงชื่อเข้าแล้ว" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Apple ID ile Giriş Yap." + "value" : "Giriş yapıldı" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Вхід з вашим Apple ID." + "value" : "Вхід виконано" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用您的Apple ID登录。" + "value" : "已登录" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "使用您的 Apple ID 登入。" + "value" : "已登入" } } } @@ -24334,239 +25125,257 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "إنهاء Xcodes عند إغلاق آخر نافذة" } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "Surt de Xcodes en tancar l’última finestra" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "Xcodes nach Schließen des letzten Fensters beenden" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "Τερματισμός του Xcodes όταν κλείσει το τελευταίο παράθυρο" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "Quit Xcodes when the last window is closed" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "Salir de Xcodes al cerrar la última ventana" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "Lopeta Xcodes, kun viimeinen ikkuna suljetaan" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "Quitter Xcodes à la fermeture de la dernière fenêtre" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "आखिरी विंडो बंद होने पर Xcodes छोड़ें" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "Esci da Xcodes alla chiusura dell’ultima finestra" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "最後のウインドウを閉じたらXcodesを終了" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "마지막 윈도우를 닫으면 Xcodes 종료" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "Stop Xcodes wanneer het laatste venster wordt gesloten" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "Zamknij Xcodes po zamknięciu ostatniego okna" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Encerrar o aplicativo quando a ultima janela for fechada" + "value" : "Encerrar o Xcodes ao fechar a última janela" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Завершить приложение после того, как будет закрыто последнее окно" + "value" : "Завершать Xcodes после закрытия последнего окна" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ยุติการทำงานของแอปเมื่อหน้าต่างสุดท้ายถูกปิดลง" + "value" : "ออกจาก Xcodes เมื่อปิดหน้าต่างสุดท้าย" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "Son pencere kapanınca Xcodes'dan çık" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "Завершувати Xcodes після закриття останнього вікна" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在最后一个窗口关闭后终止App" + "value" : "关闭最后一个窗口时退出 Xcodes" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "在最後一個窗口關閉後終止 App" + "value" : "關閉最後一個視窗時結束 Xcodes" } } } }, - "This Apple ID uses federated authentication via %@." : { + "This Apple Account uses federated authentication via %@." : { "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يستخدم حساب Apple هذا المصادقة الموحدة عبر %@." + } + }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Aquest Apple ID utilitza autenticació federada mitjançant %@." + "value" : "Aquest compte d’Apple utilitza l’autenticació federada mitjançant %@." } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Diese Apple-ID verwendet föderierte Authentifizierung über %@." + "value" : "Dieser Apple Account verwendet föderierte Authentifizierung über %@." } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Αυτό το Apple ID χρησιμοποιεί ομοσπονδιακό έλεγχο ταυτότητας μέσω %@." + "value" : "Αυτός ο Λογαριασμός Apple χρησιμοποιεί ομοσπονδιακό έλεγχο ταυτότητας μέσω %@." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This Apple Account uses federated authentication via %@." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Este Apple ID usa autenticación federada mediante %@." + "value" : "Esta Cuenta de Apple usa autenticación federada mediante %@." } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Tämä Apple ID käyttää yhdistettyä todennusta palvelun %@ kautta." + "value" : "Tämä Apple-tili käyttää yhdistettyä todennusta palvelun %@ kautta." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Cet identifiant Apple utilise l’authentification fédérée via %@." + "value" : "Ce compte Apple utilise l’authentification fédérée via %@." } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "यह Apple ID %@ के माध्यम से फ़ेडरेटेड प्रमाणीकरण का उपयोग करता है." + "value" : "यह Apple अकाउंट %@ के माध्यम से फ़ेडरेटेड प्रमाणीकरण का उपयोग करता है।" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Questo ID Apple usa l’autenticazione federata tramite %@." + "value" : "Questo Account Apple usa l’autenticazione federata tramite %@." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "このApple IDは%@経由のフェデレーション認証を使用しています。" + "value" : "このApple Accountは%@経由のフェデレーション認証を使用しています。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 Apple ID는 %@을(를) 통한 페더레이션 인증을 사용합니다." + "value" : "이 Apple 계정은 %@을(를) 통한 페더레이션 인증을 사용합니다." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Deze Apple ID gebruikt gefedereerde verificatie via %@." + "value" : "Deze Apple Account gebruikt gefedereerde verificatie via %@." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Ten Apple ID używa uwierzytelniania federacyjnego przez %@." + "value" : "To Konto Apple używa uwierzytelniania federacyjnego przez %@." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Este Apple ID usa autenticação federada via %@." + "value" : "Esta Conta Apple usa autenticação federada via %@." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Этот Apple ID использует федеративную аутентификацию через %@." + "value" : "Эта Учётная запись Apple использует федеративную аутентификацию через %@." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บัญชี Apple นี้ใช้การตรวจสอบสิทธิ์แบบรวมศูนย์ผ่าน %@" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Bu Apple ID, %@ üzerinden birleşik kimlik doğrulaması kullanıyor." + "value" : "Bu Apple Hesabı, %@ üzerinden birleşik kimlik doğrulaması kullanıyor." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Цей Apple ID використовує федеративну автентифікацію через %@." + "value" : "Цей Обліковий запис Apple використовує федеративну автентифікацію через %@." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此 Apple ID 使用通过 %@ 的联合认证。" + "value" : "此 Apple 账户使用通过 %@ 的联合认证。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此 Apple ID 使用透過 %@ 的同盟認證。" + "value" : "此 Apple 帳號使用透過 %@ 的同盟認證。" } } } diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index 9a7583d5..b19534e6 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -20,7 +20,7 @@ struct XcodesApp: App { // so that it's triggered when an individual window's phase changes instead of all window phases. // When used on a View it's also invoked on launch, which doesn't occur with a WindowGroup. // FB8954581 ScenePhase read from App doesn't return a value on launch - .onChange(of: scenePhase) { newScenePhase in + .onChange(of: scenePhase) { _, newScenePhase in guard !isTesting else { return } if case .active = newScenePhase { appState.updateIfNeeded() diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index 5ae3d249..6849810a 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -207,6 +207,475 @@ class AppStateTests: XCTestCase { ) } + func test_AuthenticationPolicy_OnlyRetriesCredentialsForInvalidSession() { + XCTAssertTrue( + AuthenticationRequestPolicy.requiresCredentialSignIn( + after: AuthenticationError.notAuthorized + ) + ) + XCTAssertTrue( + AuthenticationRequestPolicy.requiresCredentialSignIn( + after: AuthenticationError.invalidSession + ) + ) + XCTAssertFalse( + AuthenticationRequestPolicy.requiresCredentialSignIn( + after: AuthenticationRequestError.serviceTemporarilyUnavailable(statusCode: 503) + ) + ) + } + + func test_ValidateSession_PublishesAuthenticatedState() async throws { + let session = try makeAppleSession() + Current.network.validateSessionAsync = { .authenticated(session) } + + let result = try await subject.validateSessionAsync() + + XCTAssertEqual(result, .authenticated(session)) + XCTAssertEqual(subject.authenticationState, .authenticated(session)) + } + + func test_RestoreAuthenticationState_DoesNotReadKeychainForValidSession() async throws { + let session = try makeAppleSession() + let keychainReads = TestLockedBox(0) + Current.network.validateSessionAsync = { .authenticated(session) } + Current.keychain.getString = { _ in + keychainReads.withValue { $0 += 1 } + return "unused" + } + + await subject.restoreAuthenticationStateAsync() + + XCTAssertEqual(subject.authenticationState, .authenticated(session)) + XCTAssertEqual(keychainReads.read { $0 }, 0) + XCTAssertFalse(subject.isRestoringAuthenticationState) + } + + func test_RestoreAuthenticationState_UsesSavedCredentialWithoutRewritingKeychain() async throws { + let session = try makeAppleSession() + let keychainReads = TestLockedBox(0) + let keychainWrites = TestLockedBox(0) + let receivedCredentials = TestLockedBox<(String, String?)?>(nil) + Current.defaults.string = { key in + key == "username" ? "saved@example.com" : nil + } + Current.network.validateSessionAsync = { + throw NetworkError.non200StatusCode(statusCode: 401, data: nil) + } + Current.network.authenticationStateAsync = { username, password in + receivedCredentials.withValue { $0 = (username, password) } + return .authenticated(session) + } + Current.keychain.getString = { key in + keychainReads.withValue { $0 += 1 } + return key == "saved@example.com" ? "saved-password" : nil + } + Current.keychain.set = { _, _ in + keychainWrites.withValue { $0 += 1 } + } + + await subject.restoreAuthenticationStateAsync( + authenticationRequestPolicy: AuthenticationRequestPolicy( + maximumAttemptCount: 1, + delayBeforeRetry: .zero + ) + ) + + XCTAssertEqual(subject.authenticationState, .authenticated(session)) + XCTAssertEqual(keychainReads.read { $0 }, 1) + XCTAssertEqual(keychainWrites.read { $0 }, 0) + XCTAssertEqual(receivedCredentials.read { $0 }?.0, "saved@example.com") + XCTAssertEqual(receivedCredentials.read { $0 }?.1, "saved-password") + } + + func test_SignInIfNeeded_SharesConcurrentRestoration() async throws { + let session = try makeAppleSession() + let validationCalls = TestLockedBox(0) + let keychainReads = TestLockedBox(0) + let authenticationCalls = TestLockedBox(0) + let firstValidationStarted = expectation(description: "first validation started") + let firstValidation = TestLockedBox?>(nil) + Current.defaults.string = { key in + key == "username" ? "saved@example.com" : nil + } + Current.network.validateSessionAsync = { + let call = validationCalls.withValue { value in + value += 1 + return value + } + if call == 1 { + return try await withCheckedThrowingContinuation { continuation in + firstValidation.withValue { $0 = continuation } + firstValidationStarted.fulfill() + } + } + throw NetworkError.non200StatusCode(statusCode: 401, data: nil) + } + Current.keychain.getString = { _ in + keychainReads.withValue { $0 += 1 } + return "saved-password" + } + Current.network.authenticationStateAsync = { _, _ in + authenticationCalls.withValue { $0 += 1 } + return .authenticated(session) + } + + let first = Task { @MainActor in + try await subject.signInIfNeededAsync() + } + await fulfillment(of: [firstValidationStarted]) + let second = Task { @MainActor in + try await subject.signInIfNeededAsync() + } + for _ in 0..<20 { + await Task.yield() + } + firstValidation.withValue { continuation in + continuation?.resume( + throwing: NetworkError.non200StatusCode(statusCode: 401, data: nil) + ) + continuation = nil + } + + _ = try await first.value + _ = try await second.value + + XCTAssertEqual(validationCalls.read { $0 }, 1) + XCTAssertEqual(keychainReads.read { $0 }, 1) + XCTAssertEqual(authenticationCalls.read { $0 }, 1) + } + + func test_WaitForAuthenticationTerminalState_WaitsForFederatedAuthentication() async throws { + let session = try makeAppleSession() + subject.authenticationState = .waitingForFederatedAuthentication( + FederationResponse(federated: true) + ) + let completed = TestLockedBox(false) + let task = Task { @MainActor in + try await subject.waitForAuthenticationTerminalState() + completed.withValue { $0 = true } + } + + for _ in 0..<20 { + await Task.yield() + } + XCTAssertFalse(completed.read { $0 }) + + subject.authenticationState = .authenticated(session) + try await task.value + + XCTAssertTrue(completed.read { $0 }) + } + + func test_WaitForAuthenticationTerminalState_ThrowsWhenCancelled() async { + subject.authenticationState = .waitingForFederatedAuthentication( + FederationResponse(federated: true) + ) + let started = expectation(description: "waiter started") + let task = Task { @MainActor in + started.fulfill() + try await subject.waitForAuthenticationTerminalState() + } + await fulfillment(of: [started]) + await Task.yield() + + task.cancel() + + do { + try await task.value + XCTFail("Expected cancellation") + } catch is CancellationError { + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + } + + func test_SignInIfNeeded_PreservesPendingAuthenticationWithoutRestarting() async throws { + let pending = AuthenticationState.waitingForFederatedAuthentication( + FederationResponse(federated: true) + ) + let validationCalls = TestLockedBox(0) + let keychainReads = TestLockedBox(0) + let authenticationCalls = TestLockedBox(0) + subject.authenticationState = pending + Current.defaults.string = { key in + key == "username" ? "saved@example.com" : nil + } + Current.network.validateSessionAsync = { + validationCalls.withValue { $0 += 1 } + throw NetworkError.non200StatusCode(statusCode: 401, data: nil) + } + Current.keychain.getString = { _ in + keychainReads.withValue { $0 += 1 } + return "saved-password" + } + Current.network.authenticationStateAsync = { _, _ in + authenticationCalls.withValue { $0 += 1 } + return .unauthenticated + } + + let result = try await subject.signInIfNeededAsync() + + XCTAssertEqual(result, pending) + XCTAssertEqual(validationCalls.read { $0 }, 0) + XCTAssertEqual(keychainReads.read { $0 }, 0) + XCTAssertEqual(authenticationCalls.read { $0 }, 0) + } + + func test_CancelAuthentication_PreventsLateCredentialFallback() async throws { + let validationStarted = expectation(description: "validation started") + let continuation = TestLockedBox?>(nil) + let keychainReads = TestLockedBox(0) + let authenticationCalls = TestLockedBox(0) + Current.defaults.string = { key in + key == "username" ? "saved@example.com" : nil + } + Current.network.validateSessionAsync = { + try await withCheckedThrowingContinuation { pending in + continuation.withValue { $0 = pending } + validationStarted.fulfill() + } + } + Current.keychain.getString = { _ in + keychainReads.withValue { $0 += 1 } + return "saved-password" + } + Current.network.authenticationStateAsync = { _, _ in + authenticationCalls.withValue { $0 += 1 } + return .unauthenticated + } + + let task = Task { @MainActor in + try await subject.signInIfNeededAsync() + } + await fulfillment(of: [validationStarted]) + subject.cancelAuthentication() + continuation.withValue { pending in + pending?.resume( + throwing: NetworkError.non200StatusCode(statusCode: 401, data: nil) + ) + pending = nil + } + + do { + _ = try await task.value + XCTFail("Expected cancellation") + } catch is CancellationError { + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + XCTAssertEqual(keychainReads.read { $0 }, 0) + XCTAssertEqual(authenticationCalls.read { $0 }, 0) + XCTAssertEqual(subject.authenticationState, .unauthenticated) + } + + func test_CancelAuthentication_ReleasesTerminalStateWaiter() async throws { + subject.authenticationState = .waitingForFederatedAuthentication( + FederationResponse(federated: true) + ) + subject.presentedSheet = .signIn + let task = Task { @MainActor in + try await subject.waitForAuthenticationTerminalState() + } + for _ in 0..<20 { + await Task.yield() + } + + subject.cancelAuthentication() + + do { + try await task.value + XCTFail("Expected invalid session") + } catch { + XCTAssertEqual(error as? AuthenticationError, .invalidSession) + } + XCTAssertEqual(subject.authenticationState, .unauthenticated) + XCTAssertNil(subject.presentedSheet) + XCTAssertNil(subject.authError) + } + + func test_RestoreAuthenticationState_PreservesStateDuringTemporaryFailure() async throws { + let session = try makeAppleSession() + let keychainReads = TestLockedBox(0) + subject.authenticationState = .authenticated(session) + Current.network.validateSessionAsync = { + throw NetworkError.non200StatusCode(statusCode: 503, data: nil) + } + Current.keychain.getString = { _ in + keychainReads.withValue { $0 += 1 } + return "unused" + } + + await subject.restoreAuthenticationStateAsync( + authenticationRequestPolicy: AuthenticationRequestPolicy( + maximumAttemptCount: 1, + delayBeforeRetry: .zero + ) + ) + + XCTAssertEqual(subject.authenticationState, .authenticated(session)) + XCTAssertEqual(keychainReads.read { $0 }, 0) + XCTAssertFalse(subject.isRestoringAuthenticationState) + } + + func test_RestoreAuthenticationState_ShowsSignedOutAfterInvalidSessionWithoutCredential() async throws { + let session = try makeAppleSession() + subject.authenticationState = .authenticated(session) + Current.network.validateSessionAsync = { + throw NetworkError.non200StatusCode(statusCode: 401, data: nil) + } + + await subject.restoreAuthenticationStateAsync( + authenticationRequestPolicy: AuthenticationRequestPolicy( + maximumAttemptCount: 1, + delayBeforeRetry: .zero + ) + ) + + XCTAssertEqual(subject.authenticationState, .unauthenticated) + XCTAssertNil(subject.authError) + XCTAssertFalse(subject.isRestoringAuthenticationState) + } + + func test_SignOutPreventsLateSessionRestore() async throws { + let session = try makeAppleSession() + let started = expectation(description: "session validation started") + let continuation = TestLockedBox?>(nil) + Current.network.validateSessionAsync = { + await withCheckedContinuation { pending in + continuation.withValue { $0 = pending } + started.fulfill() + } + } + + let restoreTask = Task { @MainActor in + await subject.restoreAuthenticationStateAsync() + } + await fulfillment(of: [started]) + subject.signOut() + continuation.withValue { pending in + pending?.resume(returning: .authenticated(session)) + pending = nil + } + await restoreTask.value + + XCTAssertEqual(subject.authenticationState, .unauthenticated) + XCTAssertFalse(subject.isRestoringAuthenticationState) + } + + func test_NotificationPreferencePresentation_DistinguishesUnknownAndNotShown() { + XCTAssertEqual(NotificationPreferencePresentation(.unknown), .checking) + XCTAssertEqual(NotificationPreferencePresentation(.notShown), .canEnable) + XCTAssertEqual(NotificationPreferencePresentation(.shownAndDenied), .disabled) + XCTAssertEqual(NotificationPreferencePresentation(.shownAndAccepted), .enabled) + } + + func test_NotificationManagerPublishesLoadedPermissionStatus() async { + let manager = NotificationManager( + notificationStatusLoader: { .shownAndAccepted } + ) + let statusChanged = expectation(description: "notification status changed") + var cancellable: AnyCancellable? + cancellable = manager.$notificationStatus + .dropFirst() + .sink { status in + if status == .shownAndAccepted { + statusChanged.fulfill() + } + } + + manager.loadNotificationStatus() + await fulfillment(of: [statusChanged]) + + XCTAssertEqual(manager.notificationStatus, .shownAndAccepted) + withExtendedLifetime(cancellable) {} + } + + func test_AppleAccountPreferencePresentation_DoesNotFlashSignInWhileChecking() { + XCTAssertEqual( + AppleAccountPreferencePresentation( + isRestoring: true, + authenticationState: .unauthenticated + ), + .checking + ) + XCTAssertEqual( + AppleAccountPreferencePresentation( + isRestoring: false, + authenticationState: .unauthenticated + ), + .signedOut + ) + XCTAssertEqual( + AppleAccountPreferencePresentation( + isRestoring: false, + authenticationState: .waitingForFederatedAuthentication( + FederationResponse(federated: true) + ) + ), + .checking + ) + } + + func test_AppleAccountDisplayName_UsesAvailableAccountIdentity() throws { + let session = try makeAppleSession(fullName: "Jacob Clayden") + subject.authenticationState = .authenticated(session) + Current.defaults.string = { key in + key == "username" ? "jacob@example.com" : nil + } + XCTAssertEqual(subject.appleAccountDisplayName, "jacob@example.com") + + Current.defaults.string = { _ in nil } + XCTAssertEqual(subject.appleAccountDisplayName, "Jacob Clayden") + + subject.authenticationState = .authenticated(try makeAppleSession()) + XCTAssertEqual(subject.appleAccountDisplayName, localizeString("SignedIn")) + } + + func test_AppleAccountAuthenticationError_ReplacesLegacyTerminology() { + XCTAssertEqual( + AppleAccountAuthenticationError(AuthenticationError.notAuthorized), + .notAuthorized + ) + XCTAssertEqual( + AppleAccountAuthenticationError(AuthenticationError.missingPasswordForNonFederatedAccount), + .passwordRequired + ) + XCTAssertFalse( + AppleAccountAuthenticationError(AuthenticationError.notAuthorized)?.localizedDescription.contains("Apple ID") == true + ) + XCTAssertFalse( + AppleAccountAuthenticationError(AuthenticationError.missingPasswordForNonFederatedAccount)?.localizedDescription.contains("Apple ID") == true + ) + XCTAssertFalse( + AppState.userFacingAuthenticationError(AuthenticationError.notAuthorized) + .localizedDescription + .contains("Apple ID") + ) + } + + func test_ValidateADCSession_UsesAppleAccountTerminology() async throws { + Current.network.loadData = { request in + ( + Data(), + HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: nil, + headerFields: nil + )! + ) + } + + do { + try await subject.validateADCSession(path: "runtime-download") + XCTFail("Expected authorization failure") + } catch { + XCTAssertEqual(error as? AppleAccountAuthenticationError, .notAuthorized) + XCTAssertFalse(error.localizedDescription.contains("Apple ID")) + } + } + func test_ValidateSession_RetriesTransientServiceFailure() async { let attempts = TestLockedBox(0) Current.network.validateSessionAsync = { @@ -227,6 +696,22 @@ class AppStateTests: XCTestCase { XCTAssertEqual(attempts.read { $0 }, 3) } } + + private func makeAppleSession(fullName: String? = nil) throws -> AppleSession { + var user: [String: Any] = [:] + if let fullName { + user["fullName"] = fullName + } else { + user["fullName"] = NSNull() + } + let data = try JSONSerialization.data( + withJSONObject: ["user": user] + ) + return try JSONDecoder().decode( + AppleSession.self, + from: data + ) + } func test_ParseCertificateInfo_Succeeds() throws { let sampleRawInfo = """ @@ -1507,7 +1992,7 @@ class AppStateTests: XCTestCase { return true } } - Xcodes.Current.network.validateSessionAsync = { } + Xcodes.Current.network.validateSessionAsync = { .unauthenticated } Xcodes.Current.network.loadData = { urlRequest in if urlRequest.url! == URLRequest.developerDownloads.url! { let downloads = Downloads(resultCode: 0, resultsString: nil, downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip", fileSize: 9484444)], dateModified: Date())]) diff --git a/XcodesTests/Environment+Mock.swift b/XcodesTests/Environment+Mock.swift index b097b0b8..43e12c9e 100644 --- a/XcodesTests/Environment+Mock.swift +++ b/XcodesTests/Environment+Mock.swift @@ -91,7 +91,8 @@ extension Network { } ) }, - validateSessionAsync: { }, + validateSessionAsync: { .unauthenticated }, + authenticationStateAsync: { _, _ in .unauthenticated }, signout: { } ) } diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 2a1162d7..65646cb0 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -46,8 +46,8 @@ Configure GitHub Actions to allow selected pinned actions. Protect the `v*` tag 3. Commit the version change. Create an annotated tag whose values exactly match the project, for example: ```sh - git tag -a v4.0.5b47 -m 'Xcodes 4.0.5 build 47' - git push origin v4.0.5b47 + git tag -a v4.0.5b48 -m 'Xcodes 4.0.5 build 48' + git push origin v4.0.5b48 ``` 4. Approve the protected `release` environment deployment after confirming the tag and commit. @@ -56,16 +56,16 @@ Configure GitHub Actions to allow selected pinned actions. Protect the `v*` tag Tags using this contract are stable releases. The workflow does not infer prerelease status from the build-number suffix. Add an explicit, reviewed tag grammar and matching appcast policy before publishing prereleases. -For a manual rerun, `workflow_dispatch` reruns must use `--ref v4.0.5b47` and the same `release_tag`; selecting a branch is rejected before credential files are written: +For a manual rerun, `workflow_dispatch` reruns must use `--ref v4.0.5b48` and the same `release_tag`; selecting a branch is rejected before credential files are written: ```sh -gh workflow run release.yml --ref v4.0.5b47 -f release_tag=v4.0.5b47 +gh workflow run release.yml --ref v4.0.5b48 -f release_tag=v4.0.5b48 ``` A manual appcast rerun must likewise use the published tag for both the workflow ref and input (`--ref -f tag=`): ```sh -gh workflow run appcast.yml --ref v4.0.5b47 -f tag=v4.0.5b47 +gh workflow run appcast.yml --ref v4.0.5b48 -f tag=v4.0.5b48 ``` ## Local packaging dry run @@ -77,8 +77,8 @@ export NOTARY_KEY_ID='' export NOTARY_ISSUER_ID='' export NOTARY_KEY_PATH='/absolute/path/AuthKey_ID.p8' export SPARKLE_PRIVATE_KEY_FILE='/absolute/path/sparkle-private-key' -bash Scripts/package_release.sh v4.0.5b47 -bash Scripts/validate_release_artifacts.sh Product/v4.0.5b47 v4.0.5b47 +bash Scripts/package_release.sh v4.0.5b48 +bash Scripts/validate_release_artifacts.sh Product/v4.0.5b48 v4.0.5b48 ``` Do not paste secret values into shell history on shared machines. Prefer a local secret manager or a short-lived protected shell environment.