diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 8e8916213..a6cc9f347 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -334,19 +334,25 @@ struct AppScene: View { app?.handleLdkNodeEvent(lightningEvent) } - if wallet.isRestoringWallet { - Task { + Task { + if wallet.isRestoringWallet { await restoreFromMostRecentBackup() await MainActor.run { widgets.loadSavedWidgets() widgets.objectWillChange.send() } - + await pubkyProfile.initialize() await startWallet() + return } - } else { - Task { await startWallet() } + + let initializePubkyTask = Task { + await pubkyProfile.initialize() + } + + await startWallet() + await initializePubkyTask.value } } @@ -424,10 +430,6 @@ struct AppScene: View { // Handle orphaned keychain before anything else handleOrphanedKeychain() - // Start Pubky/Paykit initialization after keychain cleanup so - // session restoration never races orphaned-keychain wiping. - Task { await pubkyProfile.initialize() } - await checkAndPerformRNMigration() try wallet.setWalletExistsState() diff --git a/Bitkit/Components/ProfileEditFormView.swift b/Bitkit/Components/ProfileEditFormView.swift index 285bfb011..ca2bcc3ff 100644 --- a/Bitkit/Components/ProfileEditFormView.swift +++ b/Bitkit/Components/ProfileEditFormView.swift @@ -1,6 +1,11 @@ import SwiftUI struct ProfileEditFormView: View { + enum DeleteActionStyle { + case buttonWithIcon + case textOnly + } + @Binding var name: String @Binding var bio: String @Binding var links: [ProfileLinkInput] @@ -8,9 +13,11 @@ struct ProfileEditFormView: View { let publicKey: String let publicKeyLabel: String + let bioPlaceholder: String let isSaving: Bool let footerNote: String? let deleteLabel: String? + let deleteActionStyle: DeleteActionStyle let onSave: () async -> Void let onCancel: () -> Void let onDelete: (() -> Void)? @@ -60,6 +67,14 @@ struct ProfileEditFormView: View { tagsSection .padding(.bottom, 24) + if let footerNote { + CustomDivider(color: .white16) + .padding(.bottom, 16) + + footnoteSection(footerNote) + .padding(.bottom, 24) + } + if let deleteLabel, let onDelete { CustomDivider(color: .white16) .padding(.bottom, 16) @@ -75,17 +90,17 @@ struct ProfileEditFormView: View { } .scrollDismissesKeyboard(.interactively) .onTapGesture { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + dismissKeyboard() } .safeAreaInset(edge: .bottom, spacing: 0) { footerBar } - .sheet(isPresented: $showAddLinkSheet) { + .sheet(isPresented: $showAddLinkSheet, onDismiss: dismissKeyboard) { AddLinkSheet { label, url in links.append(ProfileLinkInput(label: label, url: url)) } } - .sheet(isPresented: $showAddTagSheet) { + .sheet(isPresented: $showAddTagSheet, onDismiss: dismissKeyboard) { AddProfileTagSheet { tag in tags.append(tag) } @@ -117,7 +132,7 @@ struct ProfileEditFormView: View { CaptionMText(t("profile__create_bio_label"), textColor: .white64) TextField( - t("profile__create_bio_placeholder"), + bioPlaceholder, text: $bio, backgroundColor: .gray6, font: .custom(Fonts.regular, size: 17), @@ -142,6 +157,7 @@ struct ProfileEditFormView: View { title: t("profile__create_add_link"), accessibilityId: "ProfileEditAddLink" ) { + dismissKeyboard() showAddLinkSheet = true } } @@ -158,7 +174,7 @@ struct ProfileEditFormView: View { ZStack(alignment: .leading) { if link.url.isEmpty { SwiftUI.Text(t("profile__add_link_url_placeholder")) - .foregroundColor(.secondary) + .foregroundColor(.white32) .font(.custom(Fonts.regular, size: 17)) } @@ -173,10 +189,16 @@ struct ProfileEditFormView: View { .foregroundColor(.textPrimary) .textInputAutocapitalization(.never) .autocorrectionDisabled() + .accessibilityIdentifier("ProfileEditLink_\(index)") } Spacer() + Image(systemName: "pencil") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white50) + .accessibilityHidden(true) + Button { links.remove(at: index) } label: { @@ -186,13 +208,17 @@ struct ProfileEditFormView: View { .foregroundColor(.white50) .frame(width: 18, height: 18) } + .accessibilityIdentifier("ProfileEditLinkRemove_\(index)") .accessibilityLabel(t("common__delete")) } .padding(.horizontal, 16) .padding(.vertical, 12) .background(Color.gray6) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.white10, lineWidth: 1) + ) .cornerRadius(8) - .accessibilityIdentifier("ProfileEditLink_\(index)") } } @@ -203,22 +229,43 @@ struct ProfileEditFormView: View { VStack(alignment: .leading, spacing: 8) { CaptionMText(t("profile__edit_delete_section"), textColor: .white64) - CustomButton( - title: label, - size: .small, - icon: Image("trash") - .resizable() - .scaledToFit() - .foregroundColor(.red) - .frame(width: 16, height: 16), - shouldExpand: false - ) { - action() + switch deleteActionStyle { + case .buttonWithIcon: + CustomButton( + title: label, + size: .small, + icon: Image("trash") + .resizable() + .scaledToFit() + .foregroundColor(.redAccent) + .frame(width: 16, height: 16), + shouldExpand: false + ) { + action() + } + case .textOnly: + Button(action: action) { + HStack { + BodySSBText(label, textColor: .redAccent) + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } } .accessibilityIdentifier("ProfileEditDelete") } + // MARK: - Footnote Section + + @ViewBuilder + private func footnoteSection(_ note: String) -> some View { + BodySText(note, textColor: .white64) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + // MARK: - Tags Section @ViewBuilder @@ -241,6 +288,7 @@ struct ProfileEditFormView: View { title: t("profile__create_add_tag"), accessibilityId: "ProfileEditAddTag" ) { + dismissKeyboard() showAddTagSheet = true } } @@ -254,13 +302,9 @@ struct ProfileEditFormView: View { startPoint: .top, endPoint: .bottom ) - .frame(height: 32) - - VStack(alignment: .leading, spacing: 16) { - if let footerNote { - BodySText(footerNote, textColor: .white64) - } + .frame(height: 24) + VStack(alignment: .leading, spacing: 0) { HStack(spacing: 16) { CustomButton(title: t("common__cancel"), variant: .secondary) { onCancel() @@ -282,4 +326,8 @@ struct ProfileEditFormView: View { .background(Color.customBlack) } } + + private func dismissKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 911bcca45..967ce388e 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -491,6 +491,16 @@ struct MainNavView: View { Task { @MainActor in do { + if let route = resolvePastedPubkyRoute( + input: uri, + ownPublicKey: pubkyProfile.publicKey, + contacts: contactsManager.contacts + ) { + navigation.navigate(route) + clipboardUri = nil + return + } + await wallet.waitForNodeToRun() try await Task.sleep(nanoseconds: Self.nodeReadyDelayNanoseconds) try await app.handleScannedData(uri) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 429567ffe..067f6420e 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -47,6 +47,42 @@ enum PubkyPublicKeyFormat { } } +enum AddContactValidationResult: Equatable { + case empty + case invalidKey + case ownKey + case valid(normalizedKey: String) + + var localizedMessage: String? { + switch self { + case .empty, .valid: + nil + case .invalidKey: + t("contacts__add_error_invalid_key") + case .ownKey: + t("contacts__add_error_self") + } + } +} + +func resolveAddContactValidation(input: String, ownPublicKey: String?) -> AddContactValidationResult { + let trimmedInput = input.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedInput.isEmpty else { + return .empty + } + + if PubkyPublicKeyFormat.matches(trimmedInput, ownPublicKey) { + return .ownKey + } + + guard let normalizedKey = PubkyPublicKeyFormat.normalized(trimmedInput) else { + return .invalidKey + } + + return .valid(normalizedKey: normalizedKey) +} + enum ContactsManagerError: LocalizedError { case invalidPublicKey case cannotAddYourself @@ -54,9 +90,9 @@ enum ContactsManagerError: LocalizedError { var errorDescription: String? { switch self { case .invalidPublicKey: - return t("slashtags__contact_error_key") + return t("contacts__add_error_invalid_key") case .cannotAddYourself: - return t("slashtags__contact_error_yourself") + return t("contacts__add_error_self") } } } @@ -387,16 +423,9 @@ class ContactsManager: ObservableObject { contacts.removeAll { $0.publicKey == prefixedKey } } - /// Delete all contacts from the homeserver and clear the local list. - func deleteAllContacts() async { - let sessionSecret: String - do { - sessionSecret = try getSessionSecret() - } catch { - Logger.warn("No active session, clearing local contacts only", context: "ContactsManager") - contacts.removeAll() - return - } + /// Delete all contacts from the homeserver and keep local state in sync with completed deletions. + func deleteAllContacts() async throws { + let sessionSecret = try getSessionSecret() let basePath = contactsBasePath @@ -410,11 +439,13 @@ class ContactsManager: ObservableObject { contacts.removeAll() return } - Logger.warn("Failed to list contacts for deletion: \(error)", context: "ContactsManager") - contacts.removeAll() - return + Logger.error("Failed to list contacts for deletion: \(error)", context: "ContactsManager") + throw error } + var deletedKeys = Set() + var firstError: Error? + for path in contactPaths { let contactKey = extractPublicKey(from: path) guard !contactKey.isEmpty else { continue } @@ -425,11 +456,21 @@ class ContactsManager: ObservableObject { path: "\(basePath)\(contactKey)" ) }.value + deletedKeys.insert(ensurePubkyPrefix(contactKey)) } catch { + firstError = firstError ?? error Logger.warn("Failed to delete contact '\(contactKey)': \(error)", context: "ContactsManager") } } + if !deletedKeys.isEmpty { + contacts.removeAll { deletedKeys.contains($0.publicKey) } + } + + if let firstError { + throw firstError + } + contacts.removeAll() Logger.info("Deleted all contacts", context: "ContactsManager") } diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index e93d8155a..330057cee 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -22,6 +22,12 @@ private enum PubkyProfileManagerError: LocalizedError { @MainActor class PubkyProfileManager: ObservableObject { + enum SessionInitializationResult: Equatable, Sendable { + case noSession + case restored(publicKey: String) + case restorationFailed + } + @Published var authState: PubkyAuthState = .idle @Published var profile: PubkyProfile? @Published var publicKey: String? @@ -39,59 +45,16 @@ class PubkyProfileManager: ObservableObject { // MARK: - Initialization & Session Restoration - private enum InitResult: Sendable { - case noSession - case restored(publicKey: String) - case restorationFailed - } - - /// Initializes Paykit and restores any persisted session in a single off-main-actor pass. + /// Initializes Paykit and restores any persisted session. func initialize() async { isInitialized = false initializationErrorMessage = nil sessionRestorationFailed = false - let result: InitResult + let result: SessionInitializationResult do { result = try await Task.detached { - try await PubkyService.initialize() - - guard let savedSecret = try? Keychain.loadString(key: .paykitSession), - !savedSecret.isEmpty - else { - return InitResult.noSession - } - - // Try to import the saved session - do { - let pk = try await PubkyService.importSession(secret: savedSecret) - return InitResult.restored(publicKey: pk) - } catch { - Logger.warn("Failed to import saved session, attempting re-sign-in: \(error)", context: "PubkyProfileManager") - } - - // Session import failed — try to recover using stored secret key - if let secretKeyHex = try? Keychain.loadString(key: .pubkySecretKey), - !secretKeyHex.isEmpty - { - do { - let newSession = try await PubkyService.signIn(secretKeyHex: secretKeyHex) - try Keychain.upsert(key: .paykitSession, data: Data(newSession.utf8)) - let pk = try await PubkyService.importSession(secret: newSession) - Logger.info("Re-signed in and restored session for \(pk)", context: "PubkyProfileManager") - return InitResult.restored(publicKey: pk) - } catch { - // Both import and re-sign-in failed — session is invalid - Logger.error("Re-sign-in failed, clearing session: \(error)", context: "PubkyProfileManager") - try? Keychain.delete(key: .paykitSession) - return InitResult.restorationFailed - } - } - - // No secret key available (Ring-managed) — keep session for next attempt - // Could be a transient network issue; user gets a toast to reconnect if needed - Logger.warn("No secret key to recover session", context: "PubkyProfileManager") - return InitResult.restorationFailed + try await Self.initializePersistedSession() }.value } catch { Logger.error("Failed to initialize paykit: \(error)", context: "PubkyProfileManager") @@ -102,6 +65,7 @@ class PubkyProfileManager: ObservableObject { switch result { case .noSession: + clearAuthenticatedState() Logger.debug("No saved paykit session found", context: "PubkyProfileManager") case let .restored(pk): publicKey = pk @@ -109,9 +73,8 @@ class PubkyProfileManager: ObservableObject { Logger.info("Paykit session restored for \(pk)", context: "PubkyProfileManager") Task { await loadProfile() } case .restorationFailed: - authState = .idle + clearAuthenticatedState() sessionRestorationFailed = true - clearCachedProfileMetadata() } isInitialized = true @@ -123,13 +86,7 @@ class PubkyProfileManager: ObservableObject { /// Returns (publicKeyZ32, secretKeyHex). func deriveKeys() async throws -> (String, String) { return try await Task.detached { - guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) else { - throw PubkyServiceError.authFailed("Mnemonic not found") - } - let passphrase = try Keychain.loadString(key: .bip39Passphrase(index: 0)) - - let seed = try PubkyService.mnemonicToSeed(mnemonic: mnemonic, passphrase: passphrase) - let secretKeyHex = try PubkyService.derivePubkySecretKey(seed: seed) + let secretKeyHex = try Self.deriveLocalSecretKeyFromWalletSeed() let rawKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex) let publicKeyZ32 = rawKey.hasPrefix("pubky") ? rawKey : "pubky\(rawKey)" return (publicKeyZ32, secretKeyHex) @@ -283,9 +240,10 @@ class PubkyProfileManager: ObservableObject { ) do { - try Keychain.upsert(key: .pubkySecretKey, data: Data(secretKeyHex.utf8)) - try Keychain.upsert(key: .paykitSession, data: Data(sessionSecret.utf8)) + try Self.upsertKeychainString(.pubkySecretKey, value: secretKeyHex) + try Self.upsertKeychainString(.paykitSession, value: sessionSecret) _ = try await PubkyService.importSession(secret: sessionSecret) + Self.notifyAppStateBackupChanged() } catch { try? Keychain.delete(key: .pubkySecretKey) try? Keychain.delete(key: .paykitSession) @@ -351,12 +309,20 @@ class PubkyProfileManager: ObservableObject { let sessionSecret = try activeSessionSecret() let path = Self.profilePath - try await Task.detached { - try await PubkyService.sessionDelete( - sessionSecret: sessionSecret, - path: path - ) - }.value + do { + try await Task.detached { + try await PubkyService.sessionDelete( + sessionSecret: sessionSecret, + path: path + ) + }.value + } catch { + guard Self.isMissingBitkitProfileStorageError(error) else { + throw error + } + + Logger.info("Bitkit profile storage already missing, continuing sign out", context: "PubkyProfileManager") + } await signOut() } @@ -460,7 +426,7 @@ class PubkyProfileManager: ObservableObject { } } - /// Long-polls the relay, persists + imports the session, and loads the profile in a single off-main-actor pass. + /// Long-polls the relay, persists + imports the session, then loads the profile. @discardableResult func completeAuthentication() async throws -> String { do { @@ -468,13 +434,9 @@ class PubkyProfileManager: ObservableObject { let sessionSecret = try await PubkyService.completeAuth() let pk = try await PubkyService.importSession(secret: sessionSecret) - guard let data = sessionSecret.data(using: .utf8) else { - await PubkyService.forceSignOut() - throw PubkyServiceError.authFailed("Failed to encode session secret") - } - do { - try Keychain.upsert(key: .paykitSession, data: data) + try Self.upsertKeychainString(.paykitSession, value: sessionSecret) + Self.notifyAppStateBackupChanged() } catch { await PubkyService.forceSignOut() throw error @@ -535,8 +497,12 @@ class PubkyProfileManager: ObservableObject { nonisolated static func resolveRemoteProfile(publicKey: String) async throws -> PubkyProfile { try await resolveRemoteProfile( publicKey: publicKey, - fetchBitkitProfile: fetchBitkitProfile, - fetchPubkyProfile: fetchPubkyProfile + fetchBitkitProfile: { key in + await fetchBitkitProfile(publicKey: key) + }, + fetchPubkyProfile: { key in + try await fetchPubkyProfile(publicKey: key) + } ) } @@ -586,6 +552,7 @@ class PubkyProfileManager: ObservableObject { await PubkyImageCache.shared.clear() UserDefaults.standard.removeObject(forKey: cachedNameKey) UserDefaults.standard.removeObject(forKey: cachedImageUriKey) + notifyAppStateBackupChanged() } func signOut() async { @@ -598,11 +565,16 @@ class PubkyProfileManager: ObservableObject { await Self.clearLocalState() }.value - cachedName = nil - cachedImageUri = nil - publicKey = nil - profile = nil - authState = .idle + clearAuthenticatedState() + } + + func refreshSessionIfPossible(after error: Error) async -> Bool { + await Self.refreshSessionIfPossible( + after: error, + loadKeychainString: { try Keychain.loadString(key: $0) }, + signInWithSecretKey: { try await PubkyService.signIn(secretKeyHex: $0) }, + persistSessionSecret: { try Self.upsertKeychainString(.paykitSession, value: $0) } + ) } // MARK: - Cached Profile Metadata @@ -632,6 +604,13 @@ class PubkyProfileManager: ObservableObject { UserDefaults.standard.removeObject(forKey: Self.cachedImageUriKey) } + private func clearAuthenticatedState() { + publicKey = nil + profile = nil + authState = .idle + clearCachedProfileMetadata() + } + private func activeSessionSecret() throws -> String { guard let sessionSecret = try? Keychain.loadString(key: .paykitSession), !sessionSecret.isEmpty @@ -641,12 +620,69 @@ class PubkyProfileManager: ObservableObject { return sessionSecret } - // MARK: - Helpers + // MARK: - Session & Backup Helpers var isAuthenticated: Bool { authState == .authenticated } + nonisolated static func snapshotSessionBackupState( + loadKeychainString: (KeychainEntryType) throws -> String? = { + try Keychain.loadString(key: $0) + } + ) throws -> PubkySessionBackupV1? { + if let secretKeyHex = try loadKeychainString(.pubkySecretKey), + !secretKeyHex.isEmpty + { + return PubkySessionBackupV1(kind: .localSeed, sessionSecret: nil) + } + + if let sessionSecret = try loadKeychainString(.paykitSession), + !sessionSecret.isEmpty + { + return PubkySessionBackupV1(kind: .externalSession, sessionSecret: sessionSecret) + } + + return nil + } + + nonisolated static func restoreSessionBackupState( + _ backup: PubkySessionBackupV1?, + loadKeychainString: (KeychainEntryType) throws -> String? = { + try Keychain.loadString(key: $0) + }, + persistKeychainString: (KeychainEntryType, String) throws -> Void = { key, value in + try PubkyProfileManager.upsertKeychainString(key, value: value) + }, + deleteKeychainValue: (KeychainEntryType) throws -> Void = { + try Keychain.delete(key: $0) + }, + forceSignOut: @escaping () async -> Void = { + await PubkyService.forceSignOut() + } + ) async throws { + await forceSignOut() + + switch backup?.kind { + case .none: + // Missing pubky backup state clears restored pubky credentials, including legacy backups without this field. + try? deleteKeychainValue(.paykitSession) + try? deleteKeychainValue(.pubkySecretKey) + case .localSeed: + let secretKeyHex = try deriveLocalSecretKeyFromWalletSeed(loadKeychainString: loadKeychainString) + try persistKeychainString(.pubkySecretKey, secretKeyHex) + try? deleteKeychainValue(.paykitSession) + case .externalSession: + guard let sessionSecret = backup?.sessionSecret, + !sessionSecret.isEmpty + else { + throw PubkyServiceError.authFailed("Missing session secret in backup") + } + try persistKeychainString(.paykitSession, sessionSecret) + try? deleteKeychainValue(.pubkySecretKey) + } + } + private func cancelPendingAuthSetup() async { do { try await Task.detached { @@ -656,4 +692,164 @@ class PubkyProfileManager: ObservableObject { Logger.warn("Cancel pending auth setup failed: \(error)", context: "PubkyProfileManager") } } + + private nonisolated static func upsertKeychainString(_ key: KeychainEntryType, value: String) throws { + try Keychain.upsert(key: key, data: Data(value.utf8)) + } + + private nonisolated static func initializePersistedSession() async throws -> SessionInitializationResult { + try await PubkyService.initialize() + + let savedSecret = try Keychain.loadString(key: .paykitSession) + let secretKeyHex = try Keychain.loadString(key: .pubkySecretKey) + return await resolveSessionInitialization( + savedSessionSecret: savedSecret, + storedSecretKeyHex: secretKeyHex, + importSession: { try await PubkyService.importSession(secret: $0) }, + signInWithSecretKey: { try await PubkyService.signIn(secretKeyHex: $0) }, + persistSessionSecret: { secret in + try upsertKeychainString(.paykitSession, value: secret) + }, + deleteSessionSecret: { + try? Keychain.delete(key: .paykitSession) + } + ) + } + + private nonisolated static func notifyAppStateBackupChanged() { + Task { @MainActor in + SettingsViewModel.shared.notifyAppStateChanged() + } + } + + private nonisolated static func deriveLocalSecretKeyFromWalletSeed( + loadKeychainString: (KeychainEntryType) throws -> String? = { + try Keychain.loadString(key: $0) + } + ) throws -> String { + guard let mnemonic = try loadKeychainString(.bip39Mnemonic(index: 0)), + !mnemonic.isEmpty + else { + throw PubkyServiceError.authFailed("Mnemonic not found") + } + + let passphrase = try loadKeychainString(.bip39Passphrase(index: 0)) + let seed = try PubkyService.mnemonicToSeed(mnemonic: mnemonic, passphrase: passphrase) + return try PubkyService.derivePubkySecretKey(seed: seed) + } + + nonisolated static func isMissingBitkitProfileStorageError(_ error: Error) -> Bool { + if case .profileNotFound = error as? PubkyServiceError { + return true + } + + let errorText = [ + (error as? AppError)?.debugMessage, + error.localizedDescription, + String(describing: error), + ] + .compactMap { $0?.lowercased() } + + if errorText.contains(where: { $0.contains("404 not found") || $0.contains("directory not found") }) { + return true + } + + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain { + let cocoaCode = CocoaError.Code(rawValue: nsError.code) + return cocoaCode == .fileNoSuchFile || cocoaCode == .fileReadNoSuchFile + } + + return false + } + + nonisolated static func isSessionRefreshableError(_ error: Error) -> Bool { + let errorText = [ + (error as? AppError)?.debugMessage, + error.localizedDescription, + String(describing: error), + ] + .compactMap { $0?.lowercased() } + + return errorText.contains { + ($0.contains("authfailed") || $0.contains("authentication failed") || $0.contains("sessionnotactive")) + || ($0.contains("transport error") && $0.contains("/session")) + } + } + + nonisolated static func refreshSessionIfPossible( + after error: Error, + loadKeychainString: (KeychainEntryType) throws -> String? = { + try Keychain.loadString(key: $0) + }, + signInWithSecretKey: (String) async throws -> String, + persistSessionSecret: (String) throws -> Void + ) async -> Bool { + guard isSessionRefreshableError(error) else { + return false + } + + guard let secretKeyHex = try? loadKeychainString(.pubkySecretKey), + !secretKeyHex.isEmpty + else { + Logger.warn("Cannot refresh pubky session without a local secret key", context: "PubkyProfileManager") + return false + } + + do { + let newSessionSecret = try await signInWithSecretKey(secretKeyHex) + try persistSessionSecret(newSessionSecret) + Logger.info("Refreshed pubky session from local secret key", context: "PubkyProfileManager") + return true + } catch { + Logger.warn("Failed to refresh pubky session: \(error)", context: "PubkyProfileManager") + return false + } + } + + nonisolated static func resolveSessionInitialization( + savedSessionSecret: String?, + storedSecretKeyHex: String?, + importSession: (String) async throws -> String, + signInWithSecretKey: (String) async throws -> String, + persistSessionSecret: (String) throws -> Void, + deleteSessionSecret: () -> Void + ) async -> SessionInitializationResult { + if let savedSessionSecret, + !savedSessionSecret.isEmpty + { + do { + let publicKey = try await importSession(savedSessionSecret) + return .restored(publicKey: publicKey) + } catch { + Logger.warn("Failed to import saved session, attempting re-sign-in: \(error)", context: "PubkyProfileManager") + } + } + + guard let storedSecretKeyHex, + !storedSecretKeyHex.isEmpty + else { + if let savedSessionSecret, + !savedSessionSecret.isEmpty + { + // External sessions cannot recover without a secret key, so keep the saved session for a later retry. + Logger.warn("No secret key to recover session", context: "PubkyProfileManager") + return .restorationFailed + } + + return .noSession + } + + do { + let newSession = try await signInWithSecretKey(storedSecretKeyHex) + try persistSessionSecret(newSession) + let publicKey = try await importSession(newSession) + Logger.info("Re-signed in and restored session for \(publicKey)", context: "PubkyProfileManager") + return .restored(publicKey: publicKey) + } catch { + Logger.error("Re-sign-in failed, clearing session: \(error)", context: "PubkyProfileManager") + deleteSessionSecret() + return .restorationFailed + } + } } diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index 755255cc8..67d8f14ea 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -11,22 +11,28 @@ enum ScannerContext { @MainActor class ScannerManager: ObservableObject { private var app: AppViewModel? + private var contactsManager: ContactsManager? private var currency: CurrencyViewModel? private var settings: SettingsViewModel? private var navigation: NavigationViewModel? + private var pubkyProfile: PubkyProfileManager? private var sheets: SheetViewModel? func configure( app: AppViewModel, + contactsManager: ContactsManager? = nil, currency: CurrencyViewModel? = nil, settings: SettingsViewModel? = nil, navigation: NavigationViewModel? = nil, + pubkyProfile: PubkyProfileManager? = nil, sheets: SheetViewModel? = nil ) { self.app = app + self.contactsManager = contactsManager self.currency = currency self.settings = settings self.navigation = navigation + self.pubkyProfile = pubkyProfile self.sheets = sheets } @@ -47,6 +53,10 @@ class ScannerManager: ObservableObject { guard let app else { return } do { + if handlePubkyRouteIfNeeded(uri) { + return + } + try await app.handleScannedData(uri) if let currency, let settings, let sheets { @@ -67,6 +77,22 @@ class ScannerManager: ObservableObject { } } + private func handlePubkyRouteIfNeeded(_ input: String) -> Bool { + guard let navigation, + let route = resolvePastedPubkyRoute( + input: input, + ownPublicKey: pubkyProfile?.publicKey, + contacts: contactsManager?.contacts ?? [] + ) + else { + return false + } + + sheets?.hideSheetIfActive(.scanner, reason: "Scanner routed pubky key") + navigation.navigate(route) + return true + } + func handleSendScan(_ uri: String, completion: @escaping (SendRoute?) -> Void) async { guard let app, let currency, let settings else { completion(nil) diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index f12592895..b0c68c411 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -14,6 +14,17 @@ struct MetadataBackupV1: Codable { let createdAt: UInt64 let tagMetadata: [PreActivityMetadata] let cache: AppCacheData + let pubkySession: PubkySessionBackupV1? +} + +struct PubkySessionBackupV1: Codable, Equatable { + enum Kind: String, Codable { + case localSeed + case externalSession + } + + let kind: Kind + let sessionSecret: String? } struct AppCacheData: Codable { diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index e3fba4dae..766d3dfda 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -960,6 +960,7 @@ "contacts__detail_title" = "Contact"; "contacts__detail_empty_state" = "Unable to load contact."; "contacts__empty_state" = "You don't have any contacts yet."; +"contacts__intro_add_contact" = "Add Contact"; "contacts__intro_description" = "Get automatic updates from contacts, pay them, and follow their public profiles."; "contacts__intro_title" = "Dynamic\ncontacts"; "contacts__my_profile" = "MY PROFILE"; @@ -969,8 +970,11 @@ "contacts__add_title" = "Add Contact"; "contacts__add_description" = "Add a new contact by scanning their QR or pasting their pubky below."; "contacts__add_pubky_label" = "PUBKY"; +"contacts__add_pubky_placeholder" = "Paste a pubky"; "contacts__add_scan_qr" = "Scan QR"; "contacts__add_button" = "Add"; +"contacts__add_error_invalid_key" = "Invalid pubky key format. Please check and try again."; +"contacts__add_error_self" = "You can't add your own pubky as a contact."; "contacts__add_retrieving" = "Retrieving\ncontact info"; "contacts__add_success" = "Contact added"; "contacts__add_error" = "Failed to add contact"; @@ -988,13 +992,15 @@ "contacts__import_friends_count" = "{count} friends"; "contacts__import_selected_count" = "{count} selected"; "contacts__delete_title" = "Delete {name}?"; -"contacts__delete_description" = "This contact will be removed from your list."; +"contacts__delete_description" = "Are you sure you want to delete {name} from your contacts?"; "contacts__delete_confirm" = "Yes, Delete"; "contacts__delete_success" = "Contact deleted"; "contacts__delete_error" = "Failed to delete contact"; "contacts__delete_label" = "Delete Contact"; "contacts__edit_title" = "Edit Contact"; -"contacts__edit_saved" = "Contact saved"; +"contacts__edit_bio_placeholder" = "Short note about this contact."; +"contacts__edit_public_note" = "Please note contact information is stored in public files. Changes you make to a contact in Bitkit will not update their profile."; +"contacts__edit_saved" = "Contact updated"; "contacts__edit_error" = "Failed to save contact"; "contacts__error_saving" = "Failed to save changes"; "contacts__error_loading_detail" = "Failed to load contact"; @@ -1076,10 +1082,12 @@ "profile__edit_nav_title" = "Edit Profile"; "profile__edit_saved" = "Profile updated"; "profile__edit_delete_section" = "DELETE"; -"profile__edit_public_note" = "Please note that all your profile information will be publicly available and visible."; +"profile__edit_public_note" = "Please note profile information is stored in public files. Changes you make in Bitkit will not update your pubky.app profile."; "profile__delete_title" = "Delete Profile?"; -"profile__delete_description" = "This will delete your Pubky profile."; +"profile__delete_description" = "Are you sure you want to delete all of your profile information for your pubky?"; "profile__delete_confirm" = "Yes, Delete"; +"profile__delete_error_title" = "Unable to Delete Profile"; +"profile__delete_error_description" = "We couldn't delete your profile data. Retry, or disconnect this pubky from Bitkit."; "profile__delete_label" = "Delete Profile"; "profile__edit" = "Edit"; "profile__pay_contacts_nav_title" = "Pay Contacts"; diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 54873bd82..c6077bfdb 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -227,6 +227,12 @@ class BackupService { await SettingsViewModel.shared.restoreAppCacheData(payload.cache) + do { + try await PubkyProfileManager.restoreSessionBackupState(payload.pubkySession) + } catch { + Logger.warn("Failed to restore pubky session backup state: \(error)", context: "BackupService") + } + // Force address rotation by clearing onchain address UserDefaults.standard.set("", forKey: "onchainAddress") @@ -657,6 +663,7 @@ class BackupService { case .metadata: let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) let cache = await SettingsViewModel.shared.getAppCacheData() + let pubkySession = try PubkyProfileManager.snapshotSessionBackupState() let preActivityMetadata = try await CoreService.shared.activity.getAllPreActivityMetadata() @@ -664,7 +671,8 @@ class BackupService { version: 1, createdAt: currentTime, tagMetadata: preActivityMetadata, - cache: cache + cache: cache, + pubkySession: pubkySession ) return try JSONEncoder().encode(payload) diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 29a7c7c60..c26ea2e44 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -125,6 +125,22 @@ func fallbackRouteForMissingPendingImport(hasPendingImport: Bool) -> Route? { hasPendingImport ? nil : .payContacts } +func resolvePastedPubkyRoute(input: String, ownPublicKey: String?, contacts: [PubkyContact]) -> Route? { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(input) else { + return nil + } + + if PubkyPublicKeyFormat.matches(normalizedKey, ownPublicKey) { + return .profile + } + + if contacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, normalizedKey) }) { + return .contactDetail(publicKey: normalizedKey) + } + + return .addContact(publicKey: normalizedKey) +} + @MainActor class NavigationViewModel: ObservableObject { @Published var path: [Route] = [] diff --git a/Bitkit/Views/Contacts/AddContactSheet.swift b/Bitkit/Views/Contacts/AddContactSheet.swift index 763da064f..e89bd8fce 100644 --- a/Bitkit/Views/Contacts/AddContactSheet.swift +++ b/Bitkit/Views/Contacts/AddContactSheet.swift @@ -9,28 +9,20 @@ struct AddContactSheet: View { @State private var pubkyInput: String = "" - private var trimmedInput: String { - pubkyInput.trimmingCharacters(in: .whitespacesAndNewlines) + private var validationResult: AddContactValidationResult { + resolveAddContactValidation(input: pubkyInput, ownPublicKey: currentPublicKey) } private var validationMessage: String? { - guard !trimmedInput.isEmpty else { - return nil - } - - if PubkyPublicKeyFormat.matches(trimmedInput, currentPublicKey) { - return t("slashtags__contact_error_yourself") - } - - guard PubkyPublicKeyFormat.normalized(trimmedInput) != nil else { - return t("slashtags__contact_error_key") - } - - return nil + validationResult.localizedMessage } private var canAdd: Bool { - validationMessage == nil && !trimmedInput.isEmpty + if case .valid = validationResult { + return true + } + + return false } var body: some View { @@ -47,7 +39,7 @@ struct AddContactSheet: View { HStack(spacing: 8) { TextField( - "", + t("contacts__add_pubky_placeholder"), text: $pubkyInput, backgroundColor: .clear, font: .custom(Fonts.regular, size: 17), @@ -83,7 +75,7 @@ struct AddContactSheet: View { .cornerRadius(8) if let validationMessage { - BodySText(validationMessage, textColor: .red) + BodySText(validationMessage, textColor: .redAccent) .fixedSize(horizontal: false, vertical: true) } } @@ -102,7 +94,7 @@ struct AddContactSheet: View { .accessibilityIdentifier("AddContactScanQR") CustomButton(title: t("contacts__add_button"), isDisabled: !canAdd) { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(trimmedInput) else { + guard case let .valid(normalizedKey) = validationResult else { return } diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index eee6425ea..8a8ba0a30 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -15,8 +15,17 @@ struct AddContactView: View { @State private var canRetry = true private var truncatedPublicKey: String { - guard publicKey.count > 10 else { return publicKey } - return "\(publicKey.prefix(4))...\(publicKey.suffix(4))" + let displayKey = normalizedPublicKey ?? publicKey + guard displayKey.count > 10 else { return displayKey } + return "\(displayKey.prefix(4))...\(displayKey.suffix(4))" + } + + private var normalizedPublicKey: String? { + if case let .valid(normalizedKey) = resolveAddContactValidation(input: publicKey, ownPublicKey: pubkyProfile.publicKey) { + return normalizedKey + } + + return nil } var body: some View { @@ -189,24 +198,23 @@ struct AddContactView: View { errorMessage = nil canRetry = true - guard PubkyPublicKeyFormat.normalized(publicKey) != nil else { - errorMessage = t("slashtags__contact_error_key") + switch resolveAddContactValidation(input: publicKey, ownPublicKey: pubkyProfile.publicKey) { + case .empty, .invalidKey: + errorMessage = t("contacts__add_error_invalid_key") canRetry = false isLoading = false return - } - - if PubkyPublicKeyFormat.matches(publicKey, pubkyProfile.publicKey) { - errorMessage = t("slashtags__contact_error_yourself") + case .ownKey: + errorMessage = t("contacts__add_error_self") canRetry = false isLoading = false return - } - - if let profile = await contactsManager.fetchContactProfile(publicKey: publicKey, includePlaceholder: true) { - fetchedProfile = profile - } else { - errorMessage = t("contacts__add_error") + case let .valid(normalizedKey): + if let profile = await contactsManager.fetchContactProfile(publicKey: normalizedKey, includePlaceholder: true) { + fetchedProfile = profile + } else { + errorMessage = t("contacts__add_error") + } } isLoading = false @@ -219,12 +227,17 @@ struct AddContactView: View { defer { isSaving = false } do { + guard let normalizedPublicKey else { + app.toast(type: .error, title: t("contacts__add_error_invalid_key")) + return + } + try await contactsManager.addContact( - publicKey: publicKey, + publicKey: normalizedPublicKey, existingProfile: fetchedProfile, ownPublicKey: pubkyProfile.publicKey ) - app.toast(type: .success, title: t("contacts__add_success")) + app.toast(type: .success, title: t("contacts__add_success"), accessibilityIdentifier: "ContactSavedToast") navigation.navigateBack() } catch { Logger.error("Failed to save contact: \(error)", context: "AddContactView") diff --git a/Bitkit/Views/Contacts/ContactImportOverviewView.swift b/Bitkit/Views/Contacts/ContactImportOverviewView.swift index d5264c5d7..bf1a14790 100644 --- a/Bitkit/Views/Contacts/ContactImportOverviewView.swift +++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift @@ -104,7 +104,7 @@ struct ContactImportOverviewView: View { ZStack(alignment: .leading) { ForEach(Array(displayContacts.enumerated()), id: \.element.id) { index, contact in contactImportAvatar(contact) - .offset(x: CGFloat(index * 24)) + .offset(x: CGFloat(index * 22)) } if overflow > 0 { @@ -116,10 +116,14 @@ struct ContactImportOverviewView: View { .font(Fonts.bold(size: 12)) .foregroundColor(.textPrimary) } - .offset(x: CGFloat(displayContacts.count * 24)) + .overlay( + Circle() + .stroke(Color.customBlack, lineWidth: 2) + ) + .offset(x: CGFloat(displayContacts.count * 22)) } } - .frame(width: CGFloat(max(displayContacts.count - 1, 0) * 24 + 36), height: 36, alignment: .leading) + .frame(width: CGFloat(max(displayContacts.count - 1, 0) * 22 + 36), height: 36, alignment: .leading) .accessibilityHidden(true) } @@ -127,6 +131,10 @@ struct ContactImportOverviewView: View { private func contactImportAvatar(_ contact: PubkyContact) -> some View { if let imageUrl = contact.profile.imageUrl { PubkyImage(uri: imageUrl, size: 36) + .overlay( + Circle() + .stroke(Color.customBlack, lineWidth: 2) + ) } else { Circle() .fill(Color.white.opacity(0.1)) @@ -136,6 +144,10 @@ struct ContactImportOverviewView: View { .font(Fonts.bold(size: 13)) .foregroundColor(.textPrimary) } + .overlay( + Circle() + .stroke(Color.customBlack, lineWidth: 2) + ) } } diff --git a/Bitkit/Views/Contacts/ContactsListView.swift b/Bitkit/Views/Contacts/ContactsListView.swift index 5aa9d0858..8a703f310 100644 --- a/Bitkit/Views/Contacts/ContactsListView.swift +++ b/Bitkit/Views/Contacts/ContactsListView.swift @@ -142,7 +142,7 @@ struct ContactsListView: View { private var contactsList: some View { if !filteredContacts.isEmpty { VStack(alignment: .leading, spacing: 0) { - sectionHeader(t("contacts__nav_title").uppercased()) + sectionHeader(t("contacts__nav_title").localizedUppercase) CustomDivider() ForEach(filteredContacts) { contact in @@ -271,20 +271,19 @@ struct ContactsListView: View { .frame(maxWidth: .infinity, alignment: .leading) } - Spacer() - VStack(spacing: 16) { + CustomButton(title: t("contacts__intro_add_contact")) { + showAddContactSheet = true + } + .accessibilityIdentifier("ContactsEmptyAddButton") + BodyMText(t("contacts__empty_state"), textColor: .white64) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity) - - CustomButton(title: t("contacts__add_button")) { - showAddContactSheet = true - } - .accessibilityIdentifier("ContactsEmptyAddButton") } .padding(.horizontal, 32) + .padding(.top, 48) Spacer() } diff --git a/Bitkit/Views/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift index 36d30bc17..5cc685af2 100644 --- a/Bitkit/Views/Contacts/EditContactView.swift +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -31,9 +31,11 @@ struct EditContactView: View { tags: $tags, publicKey: publicKey, publicKeyLabel: t("profile__create_pubky_label"), + bioPlaceholder: t("contacts__edit_bio_placeholder"), isSaving: isSaving, - footerNote: nil, + footerNote: t("contacts__edit_public_note"), deleteLabel: t("contacts__delete_label"), + deleteActionStyle: .buttonWithIcon, onSave: { await saveContact() }, onCancel: { navigation.navigateBack() }, onDelete: { showDeleteConfirmation = true } @@ -54,7 +56,7 @@ struct EditContactView: View { } Button(t("common__dialog_cancel"), role: .cancel) {} } message: { - Text(t("contacts__delete_description")) + Text(t("contacts__delete_description", variables: ["name": name])) } } @@ -125,7 +127,11 @@ struct EditContactView: View { private func deleteContact() async { do { try await contactsManager.removeContact(publicKey: publicKey) - app.toast(type: .success, title: t("contacts__delete_success")) + app.toast( + type: .success, + title: t("contacts__delete_success"), + accessibilityIdentifier: "ContactDeletedToast" + ) navigation.path = [.contacts] } catch { Logger.error("Failed to delete contact: \(error)", context: "EditContactView") @@ -158,7 +164,11 @@ struct EditContactView: View { tags: tags ) imageUrl = uploadedImageUrl - app.toast(type: .success, title: t("contacts__edit_saved")) + app.toast( + type: .success, + title: t("contacts__edit_saved"), + accessibilityIdentifier: "ContactUpdatedToast" + ) navigation.navigateBack() } catch { Logger.error("Failed to save contact: \(error)", context: "EditContactView") diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index dc53ede02..cc3d495fd 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -12,38 +12,54 @@ struct EditProfileView: View { @State private var links: [ProfileLinkInput] = [] @State private var tags: [String] = [] @State private var isSaving = false + @State private var isDeleting = false @State private var showDeleteConfirmation = false + @State private var showDeleteFailureOptions = false @State private var selectedPhotoItem: PhotosPickerItem? @State private var avatarImage: UIImage? var body: some View { - VStack(spacing: 0) { - NavigationBar( - title: t("profile__edit_nav_title") - ) - .padding(.horizontal, 16) - - ProfileEditFormView( - name: $username, - bio: $bio, - links: $links, - tags: $tags, - publicKey: pubkyProfile.publicKey ?? "...", - publicKeyLabel: t("profile__create_pubky_display_label"), - isSaving: isSaving, - footerNote: t("profile__edit_public_note"), - deleteLabel: t("profile__delete_label"), - onSave: { await saveProfile() }, - onCancel: { navigation.navigateBack() }, - onDelete: { showDeleteConfirmation = true } - ) { - avatarPicker + ZStack { + VStack(spacing: 0) { + NavigationBar( + title: t("profile__edit_nav_title") + ) + .padding(.horizontal, 16) + + ProfileEditFormView( + name: $username, + bio: $bio, + links: $links, + tags: $tags, + publicKey: pubkyProfile.publicKey ?? "...", + publicKeyLabel: t("profile__create_pubky_display_label"), + bioPlaceholder: t("profile__create_bio_placeholder"), + isSaving: isSaving, + footerNote: t("profile__edit_public_note"), + deleteLabel: t("profile__delete_label"), + deleteActionStyle: .buttonWithIcon, + onSave: { await saveProfile() }, + onCancel: { navigation.navigateBack() }, + onDelete: { showDeleteConfirmation = true } + ) { + avatarPicker + } + } + + if isDeleting { + Color.customBlack.opacity(0.72) + .ignoresSafeArea() + + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.2) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .bottomSafeAreaPadding() .background(Color.customBlack) .navigationBarHidden(true) + .disabled(isDeleting) .task { loadProfileData() } @@ -55,6 +71,17 @@ struct EditProfileView: View { } message: { Text(t("profile__delete_description")) } + .alert(t("profile__delete_error_title"), isPresented: $showDeleteFailureOptions) { + Button(t("common__retry")) { + Task { await deleteProfile() } + } + Button(t("profile__sign_out"), role: .destructive) { + Task { await disconnectAfterFailedDelete() } + } + Button(t("common__dialog_cancel"), role: .cancel) {} + } message: { + Text(t("profile__delete_error_description")) + } } // MARK: - Avatar Picker @@ -125,16 +152,40 @@ struct EditProfileView: View { // MARK: - Delete Profile private func deleteProfile() async { + guard !isDeleting else { return } + + isDeleting = true + defer { isDeleting = false } + do { - await contactsManager.deleteAllContacts() - try await pubkyProfile.deleteProfile() - navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] + try await performDeleteProfile() } catch { - Logger.error("Failed to delete profile: \(error)", context: "EditProfileView") - app.toast(type: .error, title: t("profile__edit_error_title"), description: error.localizedDescription) + if await pubkyProfile.refreshSessionIfPossible(after: error) { + do { + try await performDeleteProfile() + return + } catch { + Logger.error("Failed to delete profile after session refresh: \(error)", context: "EditProfileView") + } + } else { + Logger.error("Failed to delete profile: \(error)", context: "EditProfileView") + } + + showDeleteFailureOptions = true } } + private func performDeleteProfile() async throws { + try await contactsManager.deleteAllContacts() + try await pubkyProfile.deleteProfile() + navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] + } + + private func disconnectAfterFailedDelete() async { + await pubkyProfile.signOut() + navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] + } + // MARK: - Save Profile private func saveProfile() async { diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index cf364160e..49cbb336f 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -113,22 +113,30 @@ struct ProfileView: View { @ViewBuilder private func profileQRCode(_ profile: PubkyProfile) -> some View { - VStack(spacing: 12) { - ZStack { - QR(content: profile.publicKey) - - if let imageUrl = profile.imageUrl { - ZStack { - Circle() - .fill(Color.white) - .frame(width: 68, height: 68) - - PubkyImage(uri: imageUrl, size: 50) + Button { + UIPasteboard.general.string = profile.publicKey + app.toast(type: .success, title: t("common__copied")) + } label: { + VStack(spacing: 12) { + ZStack { + QR(content: profile.publicKey) + + if let imageUrl = profile.imageUrl { + ZStack { + Circle() + .fill(Color.white) + .frame(width: 68, height: 68) + + PubkyImage(uri: imageUrl, size: 50) + } } } } } .frame(maxWidth: .infinity) + .buttonStyle(.plain) + .accessibilityLabel(t("common__copy")) + .accessibilityIdentifier("ProfileQRCode") } // MARK: - Links / Metadata diff --git a/Bitkit/Views/Scanner/ScannerScreen.swift b/Bitkit/Views/Scanner/ScannerScreen.swift index 1a6609050..c79684ac7 100644 --- a/Bitkit/Views/Scanner/ScannerScreen.swift +++ b/Bitkit/Views/Scanner/ScannerScreen.swift @@ -2,8 +2,10 @@ import SwiftUI struct ScannerScreen: View { @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager @EnvironmentObject private var currency: CurrencyViewModel @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var pubkyProfile: PubkyProfileManager @EnvironmentObject private var scanner: ScannerManager @EnvironmentObject private var settings: SettingsViewModel @EnvironmentObject private var sheets: SheetViewModel @@ -61,9 +63,11 @@ struct ScannerScreen: View { .onAppear { scanner.configure( app: app, + contactsManager: contactsManager, currency: currency, settings: settings, navigation: navigation, + pubkyProfile: pubkyProfile, sheets: sheets ) } diff --git a/Bitkit/Views/Scanner/ScannerSheet.swift b/Bitkit/Views/Scanner/ScannerSheet.swift index 511287b8f..01dd312a9 100644 --- a/Bitkit/Views/Scanner/ScannerSheet.swift +++ b/Bitkit/Views/Scanner/ScannerSheet.swift @@ -7,8 +7,10 @@ struct ScannerSheetItem: SheetItem { struct ScannerSheet: View { @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager @EnvironmentObject private var currency: CurrencyViewModel @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var pubkyProfile: PubkyProfileManager @EnvironmentObject private var scanner: ScannerManager @EnvironmentObject private var settings: SettingsViewModel @EnvironmentObject private var sheets: SheetViewModel @@ -63,9 +65,11 @@ struct ScannerSheet: View { .onAppear { scanner.configure( app: app, + contactsManager: contactsManager, currency: currency, settings: settings, navigation: navigation, + pubkyProfile: pubkyProfile, sheets: sheets ) } diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index ccf64a4d2..e81c490e1 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -24,6 +24,36 @@ final class ContactsManagerTests: XCTestCase { XCTAssertFalse(PubkyPublicKeyFormat.matches(prefixedKey, "pubkyinvalid")) } + func testResolveAddContactValidationReturnsEmptyForBlankInput() { + XCTAssertEqual(resolveAddContactValidation(input: " ", ownPublicKey: nil), .empty) + } + + func testResolveAddContactValidationReturnsInvalidKeyForBadInput() { + XCTAssertEqual( + resolveAddContactValidation(input: "pubkyinvalid", ownPublicKey: nil), + .invalidKey + ) + } + + func testResolveAddContactValidationReturnsOwnKeyForSelf() { + let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let ownPublicKey = "pubky\(rawKey)" + + XCTAssertEqual( + resolveAddContactValidation(input: rawKey, ownPublicKey: ownPublicKey), + .ownKey + ) + } + + func testResolveAddContactValidationReturnsNormalizedKeyForValidInput() { + let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + XCTAssertEqual( + resolveAddContactValidation(input: rawKey, ownPublicKey: nil), + .valid(normalizedKey: "pubky\(rawKey)") + ) + } + func testClearPendingImportOnlyClearsTemporaryImportState() { let manager = ContactsManager() let profile = makeProfile(publicKey: "pubky_profile") @@ -106,16 +136,19 @@ final class ContactsManagerTests: XCTestCase { XCTAssertFalse(shouldDiscardPendingImport(currentRoute: .contacts, destination: .profile)) } - func testDeleteAllContactsClearsLocalList() async { + func testDeleteAllContactsThrowsWithoutActiveSession() async { let manager = ContactsManager() manager.contacts = [ makeContact(publicKey: "pubkyaaa"), makeContact(publicKey: "pubkybbb"), ] - await manager.deleteAllContacts() - - XCTAssertTrue(manager.contacts.isEmpty) + do { + try await manager.deleteAllContacts() + XCTFail("Expected deleteAllContacts to throw without an active session") + } catch { + XCTAssertFalse(manager.contacts.isEmpty) + } } func testFallbackRouteForMissingPendingImportUsesPayContacts() { @@ -123,6 +156,51 @@ final class ContactsManagerTests: XCTestCase { XCTAssertNil(fallbackRouteForMissingPendingImport(hasPendingImport: true)) } + func testResolvePastedPubkyRouteReturnsProfileForOwnKey() { + let ownPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + XCTAssertEqual( + resolvePastedPubkyRoute(input: ownPublicKey, ownPublicKey: ownPublicKey, contacts: []), + .profile + ) + } + + func testResolvePastedPubkyRouteReturnsContactDetailForExistingContact() { + let contactKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + XCTAssertEqual( + resolvePastedPubkyRoute( + input: contactKey, + ownPublicKey: "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", + contacts: [makeContact(publicKey: contactKey)] + ), + .contactDetail(publicKey: contactKey) + ) + } + + func testResolvePastedPubkyRouteReturnsAddContactForUnknownKey() { + let contactKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + XCTAssertEqual( + resolvePastedPubkyRoute( + input: contactKey, + ownPublicKey: "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", + contacts: [] + ), + .addContact(publicKey: contactKey) + ) + } + + func testResolvePastedPubkyRouteReturnsNilForInvalidInput() { + XCTAssertNil( + resolvePastedPubkyRoute( + input: "not-a-pubky", + ownPublicKey: nil, + contacts: [] + ) + ) + } + private func makeProfile(publicKey: String) -> PubkyProfile { PubkyProfile( publicKey: publicKey, diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 55ecce146..34c6b784b 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -113,6 +113,321 @@ final class PubkyProfileManagerTests: XCTestCase { } } + func testIsMissingBitkitProfileStorageErrorRecognizes404DeleteFailure() { + let error = AppError( + message: "App Error", + debugMessage: #"BitkitCore.PubkyError.WriteFailed(reason: "delete failed: Request failed: Server responded with an error: 404 Not Found - Not Found")"# + ) + + XCTAssertTrue(PubkyProfileManager.isMissingBitkitProfileStorageError(error)) + } + + func testIsMissingBitkitProfileStorageErrorRejectsNonMissingErrors() { + let error = AppError( + message: "App Error", + debugMessage: #"BitkitCore.PubkyError.AuthFailed(reason: "Request failed: HTTP transport error")"# + ) + + XCTAssertFalse(PubkyProfileManager.isMissingBitkitProfileStorageError(error)) + } + + func testIsSessionRefreshableErrorRecognizesSessionTransportFailure() { + let error = AppError( + message: "App Error", + debugMessage: #"BitkitCore.PubkyError.AuthFailed(reason: "Request failed: HTTP transport error: error sending request for url (https://example.com/session)")"# + ) + + XCTAssertTrue(PubkyProfileManager.isSessionRefreshableError(error)) + } + + func testRefreshSessionIfPossibleRefreshesSessionFromLocalSecret() async { + let error = AppError( + message: "App Error", + debugMessage: #"BitkitCore.PubkyError.AuthFailed(reason: "Request failed: HTTP transport error: error sending request for url (https://example.com/session)")"# + ) + var persistedSession: String? + + let refreshed = await PubkyProfileManager.refreshSessionIfPossible( + after: error, + loadKeychainString: { key in + switch key { + case .pubkySecretKey: + return "local-secret" + default: + return nil + } + }, + signInWithSecretKey: { secretKey in + XCTAssertEqual(secretKey, "local-secret") + return "fresh-session" + }, + persistSessionSecret: { persistedSession = $0 } + ) + + XCTAssertTrue(refreshed) + XCTAssertEqual(persistedSession, "fresh-session") + } + + func testRefreshSessionIfPossibleReturnsFalseWithoutLocalSecret() async { + let error = AppError( + message: "App Error", + debugMessage: #"BitkitCore.PubkyError.AuthFailed(reason: "Request failed: HTTP transport error: error sending request for url (https://example.com/session)")"# + ) + + let refreshed = await PubkyProfileManager.refreshSessionIfPossible( + after: error, + loadKeychainString: { _ in nil }, + signInWithSecretKey: { _ in + XCTFail("Expected refresh to stop when no local secret key exists") + return "fresh-session" + }, + persistSessionSecret: { _ in + XCTFail("No refreshed session should be persisted") + } + ) + + XCTAssertFalse(refreshed) + } + + // MARK: - Session backup state + + func testSnapshotSessionBackupStatePrefersLocalSeedOverSessionSecret() throws { + let store = makeKeychainStore( + paykitSession: "session-secret", + pubkySecretKey: "local-secret" + ) + + let snapshot = try PubkyProfileManager.snapshotSessionBackupState { key in + store[key.storageKey] + } + + XCTAssertEqual(snapshot, PubkySessionBackupV1(kind: .localSeed, sessionSecret: nil)) + } + + func testSnapshotSessionBackupStateUsesExternalSessionWhenNoLocalSeed() throws { + let store = makeKeychainStore(paykitSession: "external-session") + + let snapshot = try PubkyProfileManager.snapshotSessionBackupState { key in + store[key.storageKey] + } + + XCTAssertEqual(snapshot, PubkySessionBackupV1(kind: .externalSession, sessionSecret: "external-session")) + } + + func testSnapshotSessionBackupStateReturnsNilWhenNoPubkyCredentialsExist() throws { + let snapshot = try PubkyProfileManager.snapshotSessionBackupState { _ in nil } + + XCTAssertNil(snapshot) + } + + func testResolveSessionInitializationRestoresSavedSessionWithoutReSigningIn() async { + var persistedSession: String? + + let result = await PubkyProfileManager.resolveSessionInitialization( + savedSessionSecret: "saved-session", + storedSecretKeyHex: "local-secret", + importSession: { secret in + XCTAssertEqual(secret, "saved-session") + return "pubky_saved" + }, + signInWithSecretKey: { _ in + XCTFail("Expected saved session import to succeed without re-sign-in") + return "new-session" + }, + persistSessionSecret: { persistedSession = $0 }, + deleteSessionSecret: { + XCTFail("Session should not be deleted after successful import") + } + ) + + XCTAssertEqual(result, .restored(publicKey: "pubky_saved")) + XCTAssertNil(persistedSession) + } + + func testResolveSessionInitializationSignsInWhenOnlySecretKeyExists() async { + var persistedSession: String? + + let result = await PubkyProfileManager.resolveSessionInitialization( + savedSessionSecret: nil, + storedSecretKeyHex: "local-secret", + importSession: { secret in + XCTAssertEqual(secret, "new-session") + return "pubky_test" + }, + signInWithSecretKey: { secretKey in + XCTAssertEqual(secretKey, "local-secret") + return "new-session" + }, + persistSessionSecret: { persistedSession = $0 }, + deleteSessionSecret: { + XCTFail("Session should not be deleted after successful re-sign-in") + } + ) + + XCTAssertEqual(result, .restored(publicKey: "pubky_test")) + XCTAssertEqual(persistedSession, "new-session") + } + + func testResolveSessionInitializationDeletesSavedSessionWhenReSignInFails() async { + var deletedSavedSession = false + + let result = await PubkyProfileManager.resolveSessionInitialization( + savedSessionSecret: "stale-session", + storedSecretKeyHex: "local-secret", + importSession: { _ in + throw PubkyServiceError.authFailed("stale session") + }, + signInWithSecretKey: { _ in + throw PubkyServiceError.authFailed("sign in failed") + }, + persistSessionSecret: { _ in + XCTFail("No session should be persisted when re-sign-in fails") + }, deleteSessionSecret: { + deletedSavedSession = true + } + ) + + XCTAssertEqual(result, .restorationFailed) + XCTAssertTrue(deletedSavedSession) + } + + func testResolveSessionInitializationReturnsNoSessionWhenNoCredentialsExist() async { + let result = await PubkyProfileManager.resolveSessionInitialization( + savedSessionSecret: nil, + storedSecretKeyHex: nil, + importSession: { _ in + XCTFail("No session should be imported without credentials") + return "pubky_unused" + }, + signInWithSecretKey: { _ in + XCTFail("No sign-in should occur without credentials") + return "unused-session" + }, + persistSessionSecret: { _ in + XCTFail("No session should be persisted without credentials") + }, deleteSessionSecret: { + XCTFail("No saved session exists to delete") + } + ) + + XCTAssertEqual(result, .noSession) + } + + func testRestoreSessionBackupStateForExternalSessionClearsLocalSecret() async throws { + var store = makeKeychainStore( + paykitSession: "stale-session", + pubkySecretKey: "local-secret" + ) + var didForceSignOut = false + + try await PubkyProfileManager.restoreSessionBackupState( + PubkySessionBackupV1(kind: .externalSession, sessionSecret: "external-session"), + loadKeychainString: { key in + store[key.storageKey] + }, + persistKeychainString: { key, value in + store[key.storageKey] = value + }, + deleteKeychainValue: { key in + store.removeValue(forKey: key.storageKey) + }, + forceSignOut: { + didForceSignOut = true + } + ) + + XCTAssertTrue(didForceSignOut) + XCTAssertEqual(store[KeychainEntryType.paykitSession.storageKey], "external-session") + XCTAssertNil(store[KeychainEntryType.pubkySecretKey.storageKey]) + } + + func testRestoreSessionBackupStateClearsCredentialsWhenBackupHasNoPubkyState() async throws { + var store = makeKeychainStore( + paykitSession: "stale-session", + pubkySecretKey: "local-secret" + ) + + try await PubkyProfileManager.restoreSessionBackupState( + nil, + loadKeychainString: { key in + store[key.storageKey] + }, + persistKeychainString: { key, value in + store[key.storageKey] = value + }, + deleteKeychainValue: { key in + store.removeValue(forKey: key.storageKey) + }, + forceSignOut: {} + ) + + XCTAssertNil(store[KeychainEntryType.paykitSession.storageKey]) + XCTAssertNil(store[KeychainEntryType.pubkySecretKey.storageKey]) + } + + func testRestoreSessionBackupStateForLocalSeedDerivesSecretAndClearsSession() async throws { + var store = makeKeychainStore( + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + paykitSession: "stale-session" + ) + + try await PubkyProfileManager.restoreSessionBackupState( + PubkySessionBackupV1(kind: .localSeed, sessionSecret: nil), + loadKeychainString: { key in + store[key.storageKey] + }, + persistKeychainString: { key, value in + store[key.storageKey] = value + }, + deleteKeychainValue: { key in + store.removeValue(forKey: key.storageKey) + }, + forceSignOut: {} + ) + + XCTAssertNil(store[KeychainEntryType.paykitSession.storageKey]) + XCTAssertFalse(store[KeychainEntryType.pubkySecretKey.storageKey, default: ""].isEmpty) + } + + // MARK: - Metadata backup payload + + func testMetadataBackupV1RoundTripsPubkySession() throws { + let payload = MetadataBackupV1( + version: 1, + createdAt: 123, + tagMetadata: [], + cache: makeAppCacheData(), + pubkySession: PubkySessionBackupV1(kind: .externalSession, sessionSecret: "session-secret") + ) + + let encoded = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(MetadataBackupV1.self, from: encoded) + + XCTAssertEqual(decoded.version, payload.version) + XCTAssertEqual(decoded.createdAt, payload.createdAt) + XCTAssertEqual(decoded.pubkySession, payload.pubkySession) + XCTAssertEqual(decoded.cache.hasSeenProfileIntro, payload.cache.hasSeenProfileIntro) + } + + func testMetadataBackupV1DecodesWithoutPubkySessionField() throws { + let payload = MetadataBackupV1( + version: 1, + createdAt: 123, + tagMetadata: [], + cache: makeAppCacheData(), + pubkySession: nil + ) + + let encoded = try JSONEncoder().encode(payload) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + let legacyJson = json.filter { $0.key != "pubkySession" } + let legacyData = try JSONSerialization.data(withJSONObject: legacyJson) + let decoded = try JSONDecoder().decode(MetadataBackupV1.self, from: legacyData) + + XCTAssertNil(decoded.pubkySession) + XCTAssertEqual(decoded.cache.dismissedSuggestions, []) + } + // MARK: - Profile Link Input Model func testProfileLinkInputHasUniqueIds() { @@ -133,6 +448,49 @@ final class PubkyProfileManagerTests: XCTestCase { status: nil ) } + + private func makeKeychainStore( + mnemonic: String? = nil, + paykitSession: String? = nil, + pubkySecretKey: String? = nil + ) -> [String: String] { + var store: [String: String] = [:] + + if let mnemonic { + store[KeychainEntryType.bip39Mnemonic(index: 0).storageKey] = mnemonic + } + + if let paykitSession { + store[KeychainEntryType.paykitSession.storageKey] = paykitSession + } + + if let pubkySecretKey { + store[KeychainEntryType.pubkySecretKey.storageKey] = pubkySecretKey + } + + return store + } + + private func makeAppCacheData() -> AppCacheData { + AppCacheData( + hasSeenContactsIntro: false, + hasSeenProfileIntro: true, + hasSeenNotificationsIntro: false, + hasSeenQuickpayIntro: false, + hasSeenShopIntro: false, + hasSeenTransferIntro: false, + hasSeenTransferToSpendingIntro: false, + hasSeenTransferToSavingsIntro: false, + hasSeenWidgetsIntro: false, + hasDismissedWidgetsOnboardingHint: false, + appUpdateIgnoreTimestamp: 0, + backupIgnoreTimestamp: 0, + highBalanceIgnoreCount: 0, + highBalanceIgnoreTimestamp: 0, + dismissedSuggestions: [], + lastUsedTags: [] + ) + } } private func XCTAssertThrowsErrorAsync( diff --git a/CHANGELOG.md b/CHANGELOG.md index c071de730..33db3ede8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Restore pubky sessions from wallet backups and improve iOS pubky profile, contacts, and clipboard flows #527 - Pubky profile onboarding with contact sync, import, and editing #476 - Add transfer from savings button on empty spending wallet when user has on-chain balance #523