1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
import AuthenticationServices
import CryptoKit
import Foundation
import os
private let logger = Logger(subsystem: "social.grain.grain", category: "Auth")
private let authSignposter = OSSignposter(subsystem: "social.grain.grain", category: "Auth")
/// Signs accounts in at their own PDS with atproto OAuth, as a public native
/// client, and holds their sessions.
///
/// Several accounts can be signed in at once. Their credentials live side by
/// side in the Keychain, keyed by DID; this class tracks which one is active
/// and swaps the app over to another on request.
@Observable
@MainActor
final class AuthManager {
var isAuthenticated = false
var userDID: String?
var userHandle: String?
var userAvatar: String?
var avatarImage: UIImage?
/// Every account signed in on this device, active one included.
var accounts: [StoredAccount] = []
/// Set when a launch-time session migration forced the user to sign out.
/// LoginView reads this to display an explanation above the sign-in form.
var reauthReason: String?
private(set) var dpop: DPoP?
private var refreshTask: Task<Void, Error>?
/// The OAuth flow currently on screen, if any. At most one at a time.
private var loginTask: Task<Void, Error>?
#if NEXT_API
/// next.grain.social, the grain-serve deploy beside production.
nonisolated static let serverURL = URL(string: "https://next.grain.social")!
nonisolated static let signUpPDS = URL(string: "https://selfhosted.social")!
nonisolated static let callbackScheme = "social.grain.next"
#elseif PRODUCTION_API || !targetEnvironment(simulator)
nonisolated static let serverURL = URL(string: "https://grain.social")!
/// Where the sign-up button makes an account.
nonisolated static let signUpPDS = URL(string: "https://selfhosted.social")!
/// The server's host reversed, the private-use scheme atproto OAuth
/// asks of a native client. Info.plist registers it as
/// `GRAIN_OAUTH_SCHEME`, which project.yml and the justfile set to
/// match.
nonisolated static let callbackScheme = "social.grain"
#else
nonisolated static let serverURL = URL(string: "http://127.0.0.1:3000")!
nonisolated static let signUpPDS = URL(string: "http://localhost:2583")!
nonisolated static let callbackScheme = "social.grain"
#endif
/// The client metadata document grain-serve publishes for the apps. A PDS
/// fetches it, so it must be https: a dev build signing in against a
/// local server needs that server behind an https tunnel.
nonisolated static let clientID = serverURL.appendingPathComponent("oauth/app-client-metadata.json").absoluteString
/// The callback scheme with one slash, as atproto OAuth asks of a
/// native client.
nonisolated static let redirectURI = "\(callbackScheme):/oauth/callback"
/// The server's `did:web`, port and all (`:` as `%3A`).
nonisolated static let appviewDID: String = {
var host = serverURL.host ?? "grain.social"
if let port = serverURL.port {
host += "%3A\(port)"
}
return "did:web:\(host)"
}()
/// The AppView service a PDS proxies Grain's calls to.
nonisolated static let appviewService = "\(appviewDID)#grain_appview"
/// OAuth scopes the app requests at sign-in. Update here, then bump
/// `sessionMigrationKey` if existing installs should be forced through a
/// fresh sign-in to pick the new scope up.
nonisolated static let requiredScopes: [String] = [
"atproto",
"blob:image/*",
"repo:social.grain.gallery",
"repo:social.grain.gallery.item",
"repo:social.grain.photo",
"repo:social.grain.photo.exif",
"repo:social.grain.actor.profile",
"repo:social.grain.graph.follow",
"repo:social.grain.favorite",
"repo:social.grain.comment",
"repo:social.grain.story",
"repo:social.grain.graph.block",
// Both actions: the cross-post uses putRecord so a resumed publish
// overwrites its own post rather than posting twice, and the PDS
// asserts create *and* update for that call.
"repo:app.bsky.feed.post?action=create&action=update",
// Every Grain method, through the PDS's proxy to the AppView.
rpcScope(audience: appviewService),
]
/// `rpc:*` for the service `audience`, which goes into the scope as a
/// query value: the PDS URL-decodes it, so `%` (a `did:web` port) is
/// escaped first, then `#`.
nonisolated static func rpcScope(audience: String) -> String {
let escaped = audience
.replacingOccurrences(of: "%", with: "%25")
.replacingOccurrences(of: "#", with: "%23")
return "rpc:*?aud=\(escaped)"
}
nonisolated static var scope: String {
requiredScopes.joined(separator: " ")
}
/// Version-tagged UserDefaults key marking that the one-shot session
/// migration has already run for this install. Prevents re-auth loops
/// when a re-login still yields a token without the newly added scopes.
/// To force another migration, bump the suffix.
private static let sessionMigrationKey = "scopeMigrationDone_v3"
nonisolated static let reauthMessage = "Grain has been updated. Please sign in again to enable new features."
/// The session every OAuth call goes through. Injectable so tests can serve
/// discovery and the PAR/token/refresh endpoints.
private let session: URLSession
/// Shows the authorization page and returns the URL the browser came back
/// with. Injectable so tests can stand in for the user.
var presentAuthorization: @MainActor (URL) async throws -> URL = AuthManager.webAuthentication
var oauth: OAuthClient {
OAuthClient(session: session, clientID: Self.clientID, redirectURI: Self.redirectURI)
}
init(session: URLSession = NetworkEnvironment.session) {
self.session = session
let spid = authSignposter.makeSignpostID()
let state = authSignposter.beginInterval("SessionRestore", id: spid)
logger.debug("[SessionRestore] begin")
// Installs that predate multi-account keep one unsuffixed credential
// set; fold it into the account list before reading anything back.
if let migrated = TokenStorage.migrateLegacyAccountIfNeeded() {
DPoP.migrateLegacyKey(to: migrated)
AccountScopedStorage.migrateLegacyState(to: migrated)
}
runSessionMigrationIfNeeded()
accounts = TokenStorage.accounts
AccountScopedStorage.activeAccountID = TokenStorage.activeDID
// Restore session from Keychain; allow expired tokens since we can refresh
if let did = TokenStorage.activeDID, TokenStorage.hasSession(for: did) {
isAuthenticated = true
userDID = did
userHandle = TokenStorage.handle(for: did)
userAvatar = TokenStorage.avatar(for: did)
authSignposter.emitEvent("KeychainRead", id: spid, "authenticated=true")
logger.debug("[KeychainRead] authenticated=true")
let dpopSpid = authSignposter.makeSignpostID()
let dpopState = authSignposter.beginInterval("DPoPLoad", id: dpopSpid)
logger.debug("[DPoPLoad] begin")
dpop = try? DPoP.loadOrCreate(for: did)
authSignposter.endInterval("DPoPLoad", dpopState)
logger.debug("[DPoPLoad] end")
} else {
authSignposter.emitEvent("KeychainRead", id: spid, "authenticated=false")
logger.debug("[KeychainRead] authenticated=false")
}
authSignposter.endInterval("SessionRestore", state)
logger.debug("[SessionRestore] end")
}
/// One-shot check at launch: drop every account that can't make a
/// signed-in call as this build makes them (no PDS and authorization
/// server on record) or whose grant lacks a required scope, so it signs
/// in again. The UserDefaults flag guarantees this fires at most once per
/// install per version: even if a re-login somehow still returns an
/// insufficient grant, it doesn't loop.
private func runSessionMigrationIfNeeded() {
guard !StorageEnvironment.defaults.bool(forKey: Self.sessionMigrationKey) else { return }
StorageEnvironment.defaults.set(true, forKey: Self.sessionMigrationKey)
let required = Set(Self.requiredScopes)
let stale = TokenStorage.accounts.filter { account in
let granted = TokenStorage.grantedScope(for: account.did).map { Set($0.split(separator: " ").map(String.init)) } ?? []
return TokenStorage.hasCredentials(for: account.did)
&& (TokenStorage.server(for: account.did) == nil || !required.isSubset(of: granted))
}
guard !stale.isEmpty else { return }
logger.info("[SessionMigration] signing out \(stale.count) account(s)")
let active = TokenStorage.activeDID
for account in stale {
forget(did: account.did)
}
if let active, stale.contains(where: { $0.did == active }) {
let next = TokenStorage.accounts.first { TokenStorage.hasSession(for: $0.did) }
setActiveDID(next?.did)
if next == nil {
reauthReason = Self.reauthMessage
}
}
}
/// Start the OAuth login flow, making the resulting account active. Set
/// `createAccount` to show the sign-up page.
///
/// Safe to call while another account is signed in: the new grant is kept
/// in memory until the token exchange succeeds, so cancelling or failing
/// leaves the current account exactly as it was.
///
/// Concurrent calls join the flow already running rather than starting a
/// rival one. Every sign-in screen has more than one way to submit (a
/// button, the keyboard's Go key, tapping a suggestion) and discovery and
/// PAR happen before the web sheet appears, so a second trigger during
/// that gap would otherwise stack a second sheet onto the PDS.
func login(handle: String = "", createAccount: Bool = false) async throws {
if let existing = loginTask {
return try await existing.value
}
let task = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.loginTask = nil }
try await performLogin(handle: handle, createAccount: createAccount)
}
loginTask = task
try await task.value
}
private func performLogin(handle: String, createAccount: Bool) async throws {
let oauth = oauth
let pending: PendingAuthorization
if createAccount {
pending = try await oauth.signUp(at: Self.signUpPDS, scope: Self.scope)
} else {
let identity = try await oauth.resolver.resolve(handle)
let hint = identity.handle ?? IdentityResolver.normalize(handle)
pending = try await oauth.authorize(identity, hint: hint, scope: Self.scope)
}
let callback = try await presentAuthorization(pending.url)
let signedIn = try await oauth.redeem(pending, callback: callback)
store(signedIn)
if let auth = await authContext() {
let client = makeClient()
Task.detached { await ProfileSeed.run(client: client, auth: auth) }
}
await fetchAndStoreAvatar()
}
/// The system's web authentication sheet, coming back on the app's
/// OAuth scheme.
static func webAuthentication(_ url: URL) async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
let session = ASWebAuthenticationSession(
url: url,
callback: .customScheme(callbackScheme)
) { url, error in
if let error {
continuation.resume(throwing: error); return
}
guard let url else { continuation.resume(throwing: XRPCError.invalidURL); return }
continuation.resume(returning: url)
}
session.prefersEphemeralWebBrowserSession = false
session.presentationContextProvider = WebAuthContextProvider.shared
session.start()
}
}
/// Refresh the access token only if it expires within 60 seconds.
func refreshIfNeeded() async throws {
guard let did = userDID,
let expiresAt = TokenStorage.tokenExpiresAt(for: did),
expiresAt.timeIntervalSinceNow < 60 else { return }
try await refresh()
}
/// Refresh the active account's access token. Coalesces concurrent calls.
func refresh() async throws {
if let existing = refreshTask {
return try await existing.value
}
guard let did = userDID else { throw XRPCError.unauthorized }
let task = Task { @MainActor [weak self] in
guard let self else { throw XRPCError.unauthorized }
defer { self.refreshTask = nil }
try await performRefresh(did: did)
}
refreshTask = task
try await task.value
}
/// `did` is captured when the refresh starts: the user may switch accounts
/// while it's in flight, and the result belongs to the account that asked
/// for it, not to whoever is active when it lands.
private func performRefresh(did: String) async throws {
guard let dpop = try? DPoP.loadOrCreate(for: did),
let refreshToken = TokenStorage.refreshToken(for: did),
let server = TokenStorage.server(for: did)
else {
throw XRPCError.unauthorized
}
let tokens: OAuthTokens
do {
tokens = try await oauth.refresh(refreshToken: refreshToken, tokenEndpoint: server.tokenEndpoint, dpop: dpop)
} catch let OAuthError.refused(status, error, _) {
logger.error("Token refresh failed (\(status)): \(error ?? "no error", privacy: .public)")
if (400 ... 499).contains(status) {
// The grant is gone: drop this account. If it's the one on
// screen, fall through to whichever account is left.
forget(did: did)
if userDID == did {
activateNextAccountOrSignOut()
}
}
throw XRPCError.unauthorized
} catch {
logger.error("Token refresh failed: \(error)")
throw XRPCError.unauthorized
}
TokenStorage.storeTokens(
did: did,
accessToken: tokens.accessToken,
// A server that doesn't rotate keeps the one it was given.
refreshToken: tokens.refreshToken ?? refreshToken,
handle: nil,
expiresAt: tokens.expiresAt,
scope: tokens.scope
)
}
/// Build an AuthContext for making authenticated requests.
/// Proactively refreshes if the token expires within 60 seconds.
func authContext() async -> AuthContext? {
guard dpop != nil, let did = userDID else { return nil }
if let expiresAt = TokenStorage.tokenExpiresAt(for: did), expiresAt.timeIntervalSinceNow < 60 {
try? await refresh()
}
// Re-read after the await: the active account may have changed.
guard let currentDpop = dpop,
let currentDID = userDID,
let token = TokenStorage.accessToken(for: currentDID),
let server = TokenStorage.server(for: currentDID) else { return nil }
return AuthContext(accessToken: token, dpop: currentDpop, did: currentDID, pds: server.pds)
}
/// Create an XRPCClient with automatic token refresh on 401.
func makeClient() -> XRPCClient {
XRPCClient(baseURL: Self.serverURL) { [weak self] in
try await self?.refresh()
return await self?.authContext()
}
}
// MARK: - Account switching
/// Called with the outgoing account's auth context, while it's still valid,
/// before that account stops being active. Push registration unwinds here.
var onAccountWillDeactivate: ((AuthContext?) async -> Void)?
/// Called with the new active DID once it has taken over, or nil when the
/// last account signed out. Per-account caches re-point themselves here.
var onAccountDidActivate: ((String?) -> Void)?
// MARK: - Private
/// Persist a fresh sign-in and make it the active account.
private func store(_ signedIn: OAuthSession) {
let did = signedIn.identity.did
try? signedIn.dpop.persist(for: did)
TokenStorage.storeTokens(
did: did,
accessToken: signedIn.tokens.accessToken,
refreshToken: signedIn.tokens.refreshToken,
handle: signedIn.identity.handle,
expiresAt: signedIn.tokens.expiresAt,
scope: signedIn.scope
)
TokenStorage.storeServer(
AccountServer(
pds: signedIn.identity.pds,
issuer: signedIn.server.issuer,
tokenEndpoint: signedIn.server.tokenEndpoint,
revocationEndpoint: signedIn.server.revocationEndpoint
),
for: did
)
TokenStorage.upsertAccount(StoredAccount(did: did, handle: signedIn.identity.handle, avatar: nil))
accounts = TokenStorage.accounts
setActiveDID(did)
refreshTask = nil
dpop = signedIn.dpop
// Guard each @Observable assignment: the macro's setter always fires the
// observation registrar even when the value is unchanged.
if !isAuthenticated {
isAuthenticated = true
}
if userDID != did {
userDID = did
userAvatar = TokenStorage.avatar(for: did)
avatarImage = nil
}
let storedHandle = TokenStorage.handle(for: did)
if userHandle != storedHandle {
userHandle = storedHandle
}
if reauthReason != nil {
reauthReason = nil
}
onAccountDidActivate?(did)
}
func fetchAvatarIfNeeded() async {
if userAvatar != nil, avatarImage == nil {
await downloadAvatarImage()
}
if userAvatar == nil, userDID != nil {
await fetchAndStoreAvatar()
}
}
func refreshAvatar() async {
await fetchAndStoreAvatar()
}
private func fetchAndStoreAvatar() async {
guard let did = userDID else { return }
let client = XRPCClient(baseURL: Self.serverURL)
do {
let profile = try await client.getActorProfile(actor: did)
// Keep the switcher's row for this account current, not just the
// active-account fields.
TokenStorage.upsertAccount(StoredAccount(did: did, handle: profile.handle, avatar: profile.avatar))
accounts = TokenStorage.accounts
guard did == userDID else { return }
if userAvatar != profile.avatar {
userAvatar = profile.avatar
TokenStorage.setAvatar(profile.avatar, for: did)
}
} catch {
logger.error("Avatar fetch failed: \(error)")
}
await downloadAvatarImage()
}
private func downloadAvatarImage() async {
guard let urlString = userAvatar, let url = URL(string: urlString) else { return }
do {
let (data, _) = try await session.data(from: url)
if let image = UIImage(data: data) {
avatarImage = image
}
} catch {
logger.error("Avatar download failed: \(error)")
}
}
}
// MARK: - Account switching
/// Split out of the class body so the switching logic reads as its own unit —
/// and stays out of `AuthManager`'s already-long body.
@MainActor
extension AuthManager {
enum AccountError: LocalizedError {
/// The account is in the list but its credentials are gone — the only
/// way back in is a fresh sign-in.
case signInRequired(handle: String?)
var errorDescription: String? {
switch self {
case let .signInRequired(handle):
"Sign in again to use \(handle.map { "@\($0)" } ?? "this account")."
}
}
}
/// Make an already-signed-in account the active one.
func switchTo(did: String) async throws {
guard did != userDID else { return }
guard TokenStorage.hasSession(for: did), let newDpop = try? DPoP.loadOrCreate(for: did) else {
throw AccountError.signInRequired(handle: accounts.first { $0.did == did }?.handle)
}
await deactivateCurrentAccount()
// Renew the incoming account's token *before* handing the app over. A
// dead grant then surfaces as an error on the switcher rather than as a
// wall of empty views, and everything after this point is synchronous:
// flipping `userDID` tears down the view tree that's awaiting this call,
// so an await past here can be cancelled mid-switch.
if let expiresAt = TokenStorage.tokenExpiresAt(for: did), expiresAt.timeIntervalSinceNow < 60 {
do {
try await performRefresh(did: did)
} catch {
logger.error("[Switch] refresh failed for \(did, privacy: .public): \(error)")
// performRefresh drops the account when the grant is terminally
// gone; anything else (offline, 5xx) is worth switching through,
// since the 401 path retries.
if !TokenStorage.hasSession(for: did) {
throw AccountError.signInRequired(handle: accounts.first { $0.did == did }?.handle)
}
}
}
setActiveDID(did)
refreshTask = nil
dpop = newDpop
userDID = did
userHandle = TokenStorage.handle(for: did)
userAvatar = TokenStorage.avatar(for: did)
avatarImage = nil
accounts = TokenStorage.accounts
isAuthenticated = true
onAccountDidActivate?(did)
Task { await fetchAndStoreAvatar() }
}
/// Sign out of one account. Signing out of the active account falls back to
/// another signed-in account when there is one, so the switcher never
/// bounces a multi-account user out to the login screen unnecessarily.
func signOut(did: String) async {
if did == userDID {
await deactivateCurrentAccount()
await revoke(did: did)
forget(did: did)
activateNextAccountOrSignOut()
} else {
await revoke(did: did)
forget(did: did)
}
}
/// Ask the account's authorization server to revoke its refresh token.
/// Best effort: signing out locally never waits on the answer's success.
private func revoke(did: String) async {
guard let server = TokenStorage.server(for: did),
let endpoint = server.revocationEndpoint,
let refreshToken = TokenStorage.refreshToken(for: did),
let dpop = try? DPoP.loadOrCreate(for: did)
else { return }
do {
try await oauth.revoke(refreshToken: refreshToken, revocationEndpoint: endpoint, dpop: dpop)
} catch {
logger.error("[SignOut] revocation failed: \(error)")
}
}
/// Sign out of the account currently on screen.
func logout() async {
guard let did = userDID else { return }
await signOut(did: did)
}
/// Let the outgoing account clean up server-side state (push tokens) while
/// its credentials still work.
private func deactivateCurrentAccount() async {
guard userDID != nil, let hook = onAccountWillDeactivate else { return }
await hook(authContext())
}
/// Erase one account's credentials, keys, and cached content. Purely local
/// — no observable state changes, no server calls.
private func forget(did: String) {
TokenStorage.removeAccount(did)
try? DPoP.clearKey(for: did)
AccountScopedStorage.purge(did: did)
accounts = TokenStorage.accounts
}
/// Move to whichever account is left, or drop to the signed-out state.
private func activateNextAccountOrSignOut() {
refreshTask = nil
guard let next = TokenStorage.accounts.first(where: { TokenStorage.hasSession(for: $0.did) }),
let nextDpop = try? DPoP.loadOrCreate(for: next.did)
else {
applySignedOutState()
return
}
setActiveDID(next.did)
dpop = nextDpop
userDID = next.did
userHandle = TokenStorage.handle(for: next.did)
userAvatar = TokenStorage.avatar(for: next.did)
avatarImage = nil
isAuthenticated = true
onAccountDidActivate?(next.did)
Task { await fetchAndStoreAvatar() }
}
/// The Keychain holds the authoritative active DID; UserDefaults carries a
/// copy for code that can't afford a Keychain read. Always move both.
private func setActiveDID(_ did: String?) {
TokenStorage.activeDID = did
AccountScopedStorage.activeAccountID = did
}
private func applySignedOutState() {
setActiveDID(nil)
refreshTask = nil
isAuthenticated = false
userDID = nil
userHandle = nil
userAvatar = nil
avatarImage = nil
dpop = nil
accounts = TokenStorage.accounts
onAccountDidActivate?(nil)
}
}
// MARK: - ASWebAuthenticationSession Context
import UIKit
final class WebAuthContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
static let shared = WebAuthContextProvider()
func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor {
let scene = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
if let scene {
return ASPresentationAnchor(windowScene: scene)
}
preconditionFailure("No window scene available for ASPresentationAnchor")
}
}