Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1914,7 +1914,7 @@ struct AppScene: View {

private func retryPendingPaykitEndpointRemoval() async {
guard let session = pubkyProfile.automaticPaykitSession else { return }
await ContactPaymentsService.reconcilePendingEndpoints {
await ContactPaymentsService.reconcilePendingEndpoints(reconcilePrivate: {
guard pubkyProfile.automaticPaykitSession == session else { return }
let privateCleanupPending = UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey)
await PrivatePaykitService.shared.retryPendingEndpointReconciliation(
Expand All @@ -1924,7 +1924,7 @@ struct AppScene: View {
if privateCleanupPending, !UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) {
PublicPaykitService.setCleanupPending(true)
}

}) {
if PublicPaykitService.isCleanupPending, pubkyProfile.automaticPaykitSession == session {
do {
switch PublicPaykitService.pendingReconciliationMode() {
Expand Down
119 changes: 90 additions & 29 deletions Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class PubkyProfileManager: ObservableObject {
typealias RemoteProfileResolver = @Sendable (String) async throws -> PubkyProfile
typealias ProfilePublisher = @Sendable (_ profile: PubkyProfileData, _ expectedIdentity: String?) async throws -> Void
typealias AvatarUploader = @Sendable (_ jpegData: Data, _ expectedIdentity: String?) async throws -> String
typealias ProfileDeleter = @Sendable (_ expectedIdentity: String) async throws -> Void

private enum SessionInitializationMode {
case userVisible
Expand Down Expand Up @@ -114,9 +115,11 @@ class PubkyProfileManager: ObservableObject {
private static var isRingAdoptionInFlight = false
private static var sessionRevision = UUID()
private static var sessionMutationCount = 0
private static var isProfileDeletionInFlight = false
private let remoteProfileResolver: RemoteProfileResolver
private let profilePublisher: ProfilePublisher
private let avatarUploader: AvatarUploader
private let profileDeleter: ProfileDeleter
/// Bumped whenever a profile save starts, `profile` is written or the identity changes, so a remote read that started
/// earlier is dropped.
private var profileWriteGeneration = 0
Expand All @@ -134,7 +137,8 @@ class PubkyProfileManager: ObservableObject {
private typealias RingIdentityLookup = (id: UUID, task: Task<Void, Never>, loaders: RingIdentityLookupLoaders)
private var ringIdentityLookups: [String: RingIdentityLookup] = [:]

private static func beginSessionMutation() {
private static func beginSessionMutation(allowDuringProfileDeletion: Bool = false) throws {
guard !isProfileDeletionInFlight || allowDuringProfileDeletion else { throw PubkyServiceError.identityChanged }
sessionRevision = UUID()
sessionMutationCount += 1
}
Expand All @@ -150,11 +154,15 @@ class PubkyProfileManager: ObservableObject {
},
avatarUploader: @escaping AvatarUploader = {
try await PubkyService.uploadProfileAvatar(bytes: $0, contentType: "image/jpeg", expectedIdentity: $1)
},
profileDeleter: @escaping ProfileDeleter = {
try await PubkyService.deletePaykitProfile(expectedIdentity: $0)
}
) {
self.remoteProfileResolver = remoteProfileResolver
self.profilePublisher = profilePublisher
self.avatarUploader = avatarUploader
self.profileDeleter = profileDeleter
cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey)
cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey)
cachedProfileOwner = UserDefaults.standard.string(forKey: Self.cachedProfileOwnerKey)
Expand Down Expand Up @@ -209,15 +217,15 @@ class PubkyProfileManager: ObservableObject {
await initializationTask.value
return
}
guard Self.sessionMutationCount == 0 else { return }
guard Self.sessionMutationCount == 0, !Self.isProfileDeletionInFlight else { return }
isRestoringSession = true
let revision = Self.sessionRevision
let task = Task {
defer {
initializationTask = nil
isRestoringSession = false
}
guard Self.sessionMutationCount == 0 else { return }
guard Self.sessionMutationCount == 0, !Self.isProfileDeletionInFlight else { return }
await initializeSessionState(mode: mode, initializeSession: initializeSession)
if case .automaticRecovery = mode, revision == Self.sessionRevision {
completedRecoveryVersion += 1
Expand Down Expand Up @@ -364,7 +372,7 @@ class PubkyProfileManager: ObservableObject {

/// Drops the adopted identity when its owner app has provably removed the shared record.
func checkAdoptedSource() async {
guard let adopted = AdoptedPubkyReference.current,
guard !Self.isProfileDeletionInFlight, let adopted = AdoptedPubkyReference.current,
SharedPubkyKeychain.isDefinitelyMissing(sourceApp: adopted.sourceApp, pubky: adopted.pubky)
else { return }

Expand Down Expand Up @@ -457,7 +465,7 @@ class PubkyProfileManager: ObservableObject {
await Task.detached { PubkyProfileManager.activeSecretKeyHex() }.value
}
) async throws {
Self.beginSessionMutation()
try Self.beginSessionMutation()
defer { Self.endSessionMutation() }
if let publicKey, isProfileSetupPending || AdoptedPubkyReference.current != nil {
try await createProfile(
Expand Down Expand Up @@ -560,8 +568,8 @@ class PubkyProfileManager: ObservableObject {
guard !Self.isRingAdoptionInFlight else {
throw PubkyServiceError.authFailed("Pubky Ring sign-in already in progress")
}
try Self.beginSessionMutation()
Self.isRingAdoptionInFlight = true
Self.beginSessionMutation()
defer {
Self.isRingAdoptionInFlight = false
isAdoptingRingIdentity = false
Expand Down Expand Up @@ -785,8 +793,8 @@ class PubkyProfileManager: ObservableObject {
authorizationTimeout: Duration = .seconds(30)
) async throws {
guard !isSignupInFlight else { throw PubkySignupError.inProgress }
try Self.beginSessionMutation()
isSignupInFlight = true
Self.beginSessionMutation()
let revision = Self.sessionRevision
defer {
isSignupInFlight = false
Expand Down Expand Up @@ -979,10 +987,17 @@ class PubkyProfileManager: ObservableObject {
return true
}

func performProfileDeletion(_ operation: () async throws -> Void) async rethrows {
guard !isDeletingProfile else { return }
func performProfileDeletion(_ operation: () async throws -> Void) async throws {
try Task.checkCancellation()
guard !Self.isProfileDeletionInFlight, Self.sessionMutationCount == 0, initializationTask == nil else {
throw PubkyServiceError.identityChanged
}
Self.isProfileDeletionInFlight = true
isDeletingProfile = true
defer { isDeletingProfile = false }
defer {
isDeletingProfile = false
Self.isProfileDeletionInFlight = false
}
await PrivatePaykitService.shared.beginProfileDeletion()
do {
try await operation()
Expand All @@ -993,13 +1008,33 @@ class PubkyProfileManager: ObservableObject {
await PrivatePaykitService.shared.endProfileDeletion()
}

func deleteProfile(cleanPrivatePaykitEndpoints: Bool = true) async throws {
func deleteProfile(cleanPrivatePaykitEndpoints: Bool = true, expectedSession: SignedInSession? = nil) async throws {
guard let session = expectedSession ?? currentSession else { throw PubkyServiceError.sessionNotActive }
try requireCurrentSession(session)
if expectedSession != nil {
// The UI supplies its captured session from inside performProfileDeletion.
guard isDeletingProfile else { throw PubkyServiceError.identityChanged }
try await deleteProfile(for: session, cleanPrivatePaykitEndpoints: cleanPrivatePaykitEndpoints)
} else {
try await performProfileDeletion {
try await deleteProfile(for: session, cleanPrivatePaykitEndpoints: cleanPrivatePaykitEndpoints)
}
}
}

private func deleteProfile(for session: SignedInSession, cleanPrivatePaykitEndpoints: Bool) async throws {
try requireCurrentSession(session)
if cleanPrivatePaykitEndpoints {
await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile")
await Self.removePrivatePaykitEndpointsBestEffort(
context: "PubkyProfileManager.deleteProfile", deferAppSync: true,
isSessionCurrent: { self.currentSession == session }
)
}
try requireCurrentSession(session)
let delete = profileDeleter
do {
try await Task.detached {
try await PubkyService.deletePaykitProfile()
try await delete(session.publicKey)
}.value
} catch {
guard Self.isMissingBitkitProfileStorageError(error) else {
Expand All @@ -1009,9 +1044,10 @@ class PubkyProfileManager: ObservableObject {
Logger.info("Bitkit profile storage already missing, continuing sign out", context: "PubkyProfileManager")
}

try requireCurrentSession(session)
invalidateProfileLoads()
Self.clearPaykitSharingAfterProfileDeletion()
try await signOut(cleanPrivatePaykitEndpoints: false)
try await signOut(cleanPrivatePaykitEndpoints: false, allowDuringProfileDeletion: true)
}

private func writeProfile(
Expand Down Expand Up @@ -1346,7 +1382,8 @@ class PubkyProfileManager: ObservableObject {
// MARK: - Sign Out

static func clearLocalState() async {
beginSessionMutation()
// A wallet reset may revoke deletion's owner, but no replacement identity can start until deletion drains.
try? beginSessionMutation(allowDuringProfileDeletion: true)
defer { endSessionMutation() }
do {
try await PubkyService.forgetSessionAccess()
Expand Down Expand Up @@ -1412,32 +1449,45 @@ class PubkyProfileManager: ObservableObject {
}
}

static func removePrivatePaykitEndpoints(context: String) async throws {
static func removePrivatePaykitEndpoints(
context: String, deferAppSync: Bool = false, isSessionCurrent: (@MainActor () -> Bool)? = nil
) async throws {
try await removePrivatePaykitEndpoints(context: context, isSessionCurrent: isSessionCurrent) {
try await PrivatePaykitService.shared.removePublishedEndpoints(isSessionCurrent: isSessionCurrent, deferAppSync: deferAppSync)
}
}

static func removePrivatePaykitEndpoints(
context: String, isSessionCurrent: (@MainActor () -> Bool)?, operation: () async throws -> Void
) async throws {
guard isSessionCurrent?() != false else { throw PubkyServiceError.identityChanged }
do {
try await PrivatePaykitService.shared.removePublishedEndpoints()
try Task.checkCancellation()
try await operation()
guard isSessionCurrent?() != false else { throw PubkyServiceError.identityChanged }
try Task.checkCancellation()
PrivatePaykitService.setContactSharingCleanupPending(false)
} catch {
PrivatePaykitService.setContactSharingCleanupPending(true)
if isSessionCurrent?() != false {
PrivatePaykitService.setContactSharingCleanupPending(true)
}
Logger.warn("Failed to remove private Paykit endpoints before clearing session: \(error)", context: context)
throw error
}
}

static func removePrivatePaykitEndpointsBestEffort(context: String) async {
do {
try await removePrivatePaykitEndpoints(context: context)
PrivatePaykitService.setContactSharingCleanupPending(false)
} catch {
PrivatePaykitService.setContactSharingCleanupPending(true)
}
static func removePrivatePaykitEndpointsBestEffort(
context: String, deferAppSync: Bool = false, isSessionCurrent: (@MainActor () -> Bool)? = nil
) async {
try? await removePrivatePaykitEndpoints(context: context, deferAppSync: deferAppSync, isSessionCurrent: isSessionCurrent)
}

func signOut() async throws {
try await signOut(cleanPrivatePaykitEndpoints: true)
}

private func signOut(cleanPrivatePaykitEndpoints: Bool) async throws {
try await signOut {
private func signOut(cleanPrivatePaykitEndpoints: Bool, allowDuringProfileDeletion: Bool = false) async throws {
try await signOut(allowDuringProfileDeletion: allowDuringProfileDeletion) {
if cleanPrivatePaykitEndpoints {
try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut")
}
Expand All @@ -1447,8 +1497,14 @@ class PubkyProfileManager: ObservableObject {
}

func signOut(performSessionCleanup: @escaping @Sendable () async throws -> Void) async throws {
try await signOut(allowDuringProfileDeletion: false, performSessionCleanup: performSessionCleanup)
}

private func signOut(
allowDuringProfileDeletion: Bool, performSessionCleanup: @escaping @Sendable () async throws -> Void
) async throws {
let retainedProfile = hasReadOnlyProfileIdentity ? readOnlyProfile : nil
Self.beginSessionMutation()
try Self.beginSessionMutation(allowDuringProfileDeletion: allowDuringProfileDeletion)
if let retainedProfile {
readOnlySignOutRevision = Self.sessionRevision
readOnlyProfile = (retainedProfile.publicKey, retainedProfile.profile, Self.sessionRevision)
Expand Down Expand Up @@ -1653,6 +1709,11 @@ class PubkyProfileManager: ObservableObject {
return SignedInSession(publicKey: publicKey, revision: Self.sessionRevision)
}

func requireCurrentSession(_ session: SignedInSession) throws {
try Task.checkCancellation()
guard currentSession == session else { throw PubkyServiceError.identityChanged }
}

var automaticPaykitSession: SignedInSession? {
guard authState == .authenticated, !isDeletingProfile else { return nil }
return currentSession
Expand Down Expand Up @@ -1782,7 +1843,7 @@ class PubkyProfileManager: ObservableObject {
try await PubkyService.signIn(secretKeyHex: $0)
}
) async throws {
await beginSessionMutation()
try await beginSessionMutation()
do {
do {
try await forgetSessionAccess()
Expand Down
4 changes: 3 additions & 1 deletion Bitkit/Services/ContactPaymentsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,12 @@ enum ContactPaymentsService {
}

@MainActor
static func reconcilePendingEndpoints(_ reconcile: () async -> Void) async {
static func reconcilePendingEndpoints(reconcilePrivate: () async -> Void = {}, _ reconcile: () async -> Void) async {
guard !isOperationActive, !Task.isCancelled else { return }
isOperationActive = true
defer { releaseOperation() }
await reconcilePrivate()
guard await !PrivatePaykitService.shared.isDeletingProfile, !Task.isCancelled else { return }
await reconcile()
}

Expand Down
36 changes: 25 additions & 11 deletions Bitkit/Services/PrivatePaykitService+Contacts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -344,19 +344,27 @@ extension PrivatePaykitService {
)
}

func removePublishedEndpoints(for publicKeys: [String]? = nil, isSessionCurrent: (@MainActor () -> Bool)? = nil) async throws {
try await removePublishedEndpoints(for: publicKeys, isSessionCurrent: isSessionCurrent, operations: EndpointCleanupOperations(
linkedPeers: { try await PaykitSdkService.shared.linkedPeers() },
clearPaymentLists: { try await PaykitSdkService.shared.clearPrivatePaymentLists(to: $0, isSessionCurrent: isSessionCurrent) },
drainMessages: { await self.drainPendingPrivateMessages(reason: "cleanup", advancing: $0) },
pendingDrainKeys: { await self.pendingPrivateMessageDrainKeys($0) },
syncApp: { try await PublicPaykitService.syncPaykitApp() }
))
func removePublishedEndpoints(
for publicKeys: [String]? = nil, isSessionCurrent: (@MainActor () -> Bool)? = nil, deferAppSync: Bool = false
) async throws {
try await removePublishedEndpoints(
for: publicKeys,
isSessionCurrent: isSessionCurrent,
deferAppSync: deferAppSync,
operations: EndpointCleanupOperations(
linkedPeers: { try await PaykitSdkService.shared.linkedPeers() },
clearPaymentLists: { try await PaykitSdkService.shared.clearPrivatePaymentLists(to: $0, isSessionCurrent: isSessionCurrent) },
drainMessages: { await self.drainPendingPrivateMessages(reason: "cleanup", advancing: $0) },
pendingDrainKeys: { await self.pendingPrivateMessageDrainKeys($0) },
syncApp: { try await PublicPaykitService.syncPaykitApp() }
)
)
}

func removePublishedEndpoints(
for publicKeys: [String]? = nil,
isSessionCurrent: (@MainActor () -> Bool)? = nil,
deferAppSync: Bool = false,
operations: EndpointCleanupOperations
) async throws {
let revision = publicKeys != nil && isSessionCurrent != nil ? savedContactsRevision : nil
Expand All @@ -376,7 +384,8 @@ extension PrivatePaykitService {
throw PubkyServiceError.sessionNotActive
}
try await removePublishedEndpointsLocked(
for: publicKeys, isSessionCurrent: isSessionCurrent, revision: revision, generation: generation, operations: operations
for: publicKeys, isSessionCurrent: isSessionCurrent, revision: revision, generation: generation,
deferAppSync: deferAppSync, operations: operations
)
}
} catch {
Expand All @@ -391,7 +400,7 @@ extension PrivatePaykitService {

private func removePublishedEndpointsLocked(
for publicKeys: [String]?, isSessionCurrent: (@MainActor () -> Bool)?, revision: Int?, generation: Int,
operations: EndpointCleanupOperations
deferAppSync: Bool, operations: EndpointCleanupOperations
) async throws {
let peers = try await operations.linkedPeers()
let linkedPublicKeys = Set(peers
Expand Down Expand Up @@ -491,7 +500,12 @@ extension PrivatePaykitService {
if let firstError {
throw firstError
}
try await operations.syncApp()
if deferAppSync {
// Final profile cleanup synchronizes the registry; retain repair if deletion stops before it.
PublicPaykitService.setCleanupPending(true)
} else {
try await operations.syncApp()
}
}

func privatePaymentListCleanupKeys(_ publicKeys: [String], linkedPublicKeys: Set<String>) -> [String] {
Expand Down
Loading
Loading