Files
OSGKeyboard/OSGKeyboardMac/MacAccountAppleButton.swift
T
rockyandClaude Opus 5 5d51c5bf77 chore: snapshot uncommitted local work before rebasing onto cloud-agent
Safety commit so the 94 uncommitted files in the working tree (in-flight
Account/Mac/Tip work plus the feature-preview recording tooling) survive the
move onto cursor/cloud-agent-1787824905651-vxuwc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:28:07 -07:00

142 lines
5.6 KiB
Swift

// MacAccountAppleButton.swift
// OSGKeyboard · Mac
//
// Sign in with Apple for the menu-bar app. Sign-in and delete-account
// reauthentication share one button, exactly like the iOS account center.
// Nonce generation and credential validation come from the cross-platform
// `AccountAppleNonce` / `AccountAppleAuthorizationPayload` factories, so the
// security-relevant path is identical on both platforms — only the labels,
// which resolve from Shared.strings here instead of the iOS app bundle, differ.
//
// This works in a non-sandboxed Developer ID build: Sign in with Apple needs
// only the `com.apple.developer.applesignin` entitlement, never the Mac App
// Store. The Mac App ID must be grouped under the iOS primary App ID or the
// same person authenticates as a different Apple `sub` here than on iPhone.
import AuthenticationServices
import SwiftUI
struct MacAccountAppleButton: View {
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject private var coordinator: AccountSessionCoordinator
let purpose: AccountAppleAuthorizationPurpose
let language: AppUILanguage
var onSignedIn: () -> Void = {}
var onDeleted: () -> Void = {}
@State private var rawNonce: String?
var body: some View {
Group {
if purpose == .signIn, coordinator.operation == .signingIn {
loadingButton
} else {
SignInWithAppleButton(
purpose == .signIn ? .signIn : .continue,
onRequest: prepareRequest,
onCompletion: completeAuthorization
)
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
// 系统按钮的文字/图标随按钮高度缩放,40pt 时文字明显偏小。
.frame(height: MacMetrics.appleButtonHeight)
.clipShape(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.accessibilityIdentifier(
purpose == .signIn
? "mac.account.signIn.apple"
: "mac.account.delete.reauthenticate"
)
}
}
// Fixed, not `maxWidth`: inside a settings row the label column is also
// flexible, and two flexible siblings split the row evenly — which
// shrank the button and squeezed the copy next to it.
.frame(width: 260)
}
private var loadingButton: some View {
HStack(spacing: Spacing.sm) {
ProgressView()
.controlSize(.small)
.tint(foreground)
Text(MacL10n.string("mac.account.signIn.loading", language: language))
.font(TypeStyle.bodyEmph)
}
.foregroundStyle(foreground)
.frame(maxWidth: .infinity)
.frame(height: MacMetrics.appleButtonHeight)
.background(background)
.clipShape(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.accessibilityElement(children: .combine)
.accessibilityIdentifier("mac.account.signIn.loading")
}
private var background: Color { colorScheme == .dark ? .white : .black }
private var foreground: Color { colorScheme == .dark ? .black : .white }
private func prepareRequest(_ request: ASAuthorizationAppleIDRequest) {
guard let nonce = AccountAppleNonce.make() else {
rawNonce = nil
request.nonce = nil
return
}
rawNonce = nonce.rawValue
request.requestedScopes = purpose == .signIn ? [.fullName] : []
request.nonce = nonce.sha256Hex
}
private func completeAuthorization(_ result: Result<ASAuthorization, Error>) {
guard let rawNonce else {
OSGDiag.log(
"appleAuthorization failed stage=nonce-unavailable",
category: "account"
)
coordinator.recordAppleAuthorizationFailure(detail: "nonce-unavailable")
return
}
self.rawNonce = nil
switch result {
case let .success(authorization):
guard let payload = AccountAppleAuthorizationPayload.make(
from: authorization,
rawNonce: rawNonce
) else {
OSGDiag.log(
"appleAuthorization failed stage=credential-payload",
category: "account"
)
coordinator.recordAppleAuthorizationFailure(detail: "credential-payload")
return
}
Task {
switch purpose {
case .signIn:
await coordinator.signIn(with: payload)
if coordinator.isSignedIn {
ProviderConfig.shared.reloadFromPersistedStorage()
onSignedIn()
}
case .deleteAccount:
await coordinator.deleteAccount(with: payload)
if !coordinator.isSignedIn {
onDeleted()
}
}
}
case let .failure(error):
let diagnostic = error as NSError
OSGDiag.log(
"appleAuthorization failed stage=system "
+ "domain=\(diagnostic.domain) code=\(diagnostic.code)",
category: "account"
)
if (error as? ASAuthorizationError)?.code != .canceled {
coordinator.recordAppleAuthorizationFailure(
detail: "as-\(diagnostic.domain)-\(diagnostic.code)"
)
}
}
}
}