diff --git a/Clave.xcodeproj/project.pbxproj b/Clave.xcodeproj/project.pbxproj index ac7ca69..8552baf 100644 --- a/Clave.xcodeproj/project.pbxproj +++ b/Clave.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 006B17A84C114EDF9B129CAE /* NostrConnectParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */; }; 6D2C503B9EF64C8EA5101CDB /* NostrConnectParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */; }; + B0EBA01E2F90AB01000A0001 /* PendingApprovalBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0EBA01E2F90AB01000A0002 /* PendingApprovalBanner.swift */; }; BD247C1AD3A7497E8DF29530 /* ClientPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */; }; DD90DE0C2D8944D08B23C606 /* ClientPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */; }; DE7E10B2DE7E10B2DE7E10B2 /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */; }; @@ -82,6 +83,7 @@ /* Begin PBXFileReference section */ 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrConnectParser.swift; sourceTree = ""; }; 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientPermissions.swift; sourceTree = ""; }; + B0EBA01E2F90AB01000A0002 /* PendingApprovalBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingApprovalBanner.swift; sourceTree = ""; }; DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = ""; }; DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExporter.swift; sourceTree = ""; }; EF3D7A0F2F8BCAE3005A6545 /* Clave.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Clave.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -212,6 +214,7 @@ 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */, DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */, F60FCE002F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift */, + B0EBA01E2F90AB01000A0002 /* PendingApprovalBanner.swift */, DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */, 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */, EF3D7A592F8BD020005A6545 /* LightCrypto.swift */, @@ -427,6 +430,7 @@ EF85F076F6C02095C4B6D9C4 /* LightSigner.swift in Sources */, DE7E10B2DE7E10B2DE7E10B2 /* DeveloperSettings.swift in Sources */, F60FCE012F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift in Sources */, + B0EBA01E2F90AB01000A0001 /* PendingApprovalBanner.swift in Sources */, DE7E10C2DE7E10C2DE7E10C2 /* LogExporter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Clave/AppState.swift b/Clave/AppState.swift index b24aba9..4f9f5a8 100644 --- a/Clave/AppState.swift +++ b/Clave/AppState.swift @@ -45,6 +45,18 @@ final class AppState { ) { [weak self] _ in self?.drainPendingPairOps() } + + // Refresh the pending-requests list whenever any code path mutates + // it (L1 foreground sub queue, approve/deny, future code). NSE-side + // writes don't cross the process boundary; the MainTabView scenePhase + // observer handles those by refreshing on app foreground. + NotificationCenter.default.addObserver( + forName: .pendingRequestsUpdated, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshPendingRequests() + } } // MARK: - Foreground subscription bridge @@ -307,6 +319,7 @@ final class AppState { responseRelayUrl: request.responseRelayUrl ) SharedStorage.removePendingRequest(id: request.id) + PendingApprovalBanner.clear(requestId: request.id) refreshPendingRequests() return result.status == "signed" } catch { @@ -316,6 +329,7 @@ final class AppState { func denyPendingRequest(_ request: PendingRequest) { SharedStorage.removePendingRequest(id: request.id) + PendingApprovalBanner.clear(requestId: request.id) refreshPendingRequests() } diff --git a/Clave/ClaveApp.swift b/Clave/ClaveApp.swift index 2237d30..f670347 100644 --- a/Clave/ClaveApp.swift +++ b/Clave/ClaveApp.swift @@ -68,14 +68,46 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - logger.notice("[App] Foreground push received — processing signing request") let userInfo = notification.request.content.userInfo + let title = notification.request.content.title + + // Two flavors of notification reach this delegate while the app is + // foreground: + // + // 1. Locally-scheduled UNNotificationRequest from + // PendingApprovalBanner (identifier prefix "pending-approval-"). + // These have a meaningful title set by us and userInfo is empty. + // Show them — that's the whole point of scheduling them. + // + // 2. APNs-delivered pushes for sign requests (userInfo contains the + // proxy's `aps`/`event_id`/`relay_url` keys). NSE has already + // modified their content: empty title for silent success, + // "Approve Signing Request" for pending, "Signing Failed" for + // error. We process the request again locally for L1-style + // handling, AND let iOS display the NSE-modified content if it + // has a real title (pending/error). Suppress for empty title + // (the silent-success case). + let identifier = notification.request.identifier + let isLocalPendingBanner = identifier.hasPrefix("pending-approval-") + + if isLocalPendingBanner { + // Don't re-process — this is our own scheduled banner, no APNs payload to handle. + completionHandler([.banner, .sound, .list]) + return + } + logger.notice("[App] Foreground push received — processing signing request") Task { await handleForegroundSigningRequest(userInfo: userInfo) } - completionHandler([]) // suppress display + if !title.isEmpty { + // NSE marked this as pending or error — surface it. + completionHandler([.banner, .sound, .list]) + } else { + // NSE marked this as silent success — suppress. + completionHandler([]) + } } private func handleForegroundSigningRequest(userInfo: [AnyHashable: Any]) async { @@ -176,6 +208,19 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele continue } handledCount += 1 + // Same reason as ForegroundRelaySubscription: when this + // foreground push handler queues a pending approval, NSE + // for the same event will dedupe and produce no banner. + // Schedule one here so the user gets the alert. + if result.status == "pending", let requestId = result.pendingRequestId { + await MainActor.run { + PendingApprovalBanner.schedule( + requestId: requestId, + clientPubkey: result.clientPubkey, + eventKind: result.eventKind + ) + } + } } catch { logger.notice("[App] Skipping event: \(error.localizedDescription)") } diff --git a/Clave/Views/Components/AvatarView.swift b/Clave/Views/Components/AvatarView.swift index 7336b2d..2284c7f 100644 --- a/Clave/Views/Components/AvatarView.swift +++ b/Clave/Views/Components/AvatarView.swift @@ -2,6 +2,10 @@ import SwiftUI struct AvatarView: View { let pubkeyHex: String + /// Optional human-readable name. When non-empty, the first 1-2 letters of + /// the name are shown instead of the first two hex chars of the pubkey. + /// The gradient stays pubkey-derived so renames don't change the color. + var name: String? = nil var size: CGFloat = 48 private var gradient: LinearGradient { @@ -18,13 +22,39 @@ struct AvatarView: View { ) } + /// Up to two letters. Prefers initials of the first two whitespace- + /// separated words of `name` (e.g. "Joe Bloggs" → "JB"), falls back to + /// the first two letters of a single-word name, then to the pubkey + /// prefix if name is nil/blank. + private var initials: String { + if let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty { + let words = trimmed.split(whereSeparator: { $0.isWhitespace }) + if words.count >= 2, + let first = words[0].first, + let second = words[1].first { + return String([first, second]).uppercased() + } + return String(trimmed.prefix(2)).uppercased() + } + return String(pubkeyHex.prefix(2)).uppercased() + } + + /// Use a monospaced design only for the pubkey-prefix fallback (which is + /// hex characters); proportional for actual name initials. + private var initialsFont: Font { + let isPubkeyFallback = (name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + return isPubkeyFallback + ? .system(size: size * 0.35, weight: .bold, design: .monospaced) + : .system(size: size * 0.4, weight: .bold) + } + var body: some View { Circle() .fill(gradient) .frame(width: size, height: size) .overlay { - Text(String(pubkeyHex.prefix(2)).uppercased()) - .font(.system(size: size * 0.35, weight: .bold, design: .monospaced)) + Text(initials) + .font(initialsFont) .foregroundStyle(.white) } } diff --git a/Clave/Views/Components/QRCodeView.swift b/Clave/Views/Components/QRCodeView.swift index 833ff76..3d612cb 100644 --- a/Clave/Views/Components/QRCodeView.swift +++ b/Clave/Views/Components/QRCodeView.swift @@ -33,6 +33,7 @@ struct QRCodeView: View { } } } + .snapshotProtected() } private var qrImage: Image { diff --git a/Clave/Views/Components/SnapshotProtected.swift b/Clave/Views/Components/SnapshotProtected.swift new file mode 100644 index 0000000..e4ddb9c --- /dev/null +++ b/Clave/Views/Components/SnapshotProtected.swift @@ -0,0 +1,49 @@ +import SwiftUI + +/// Privacy overlay that covers a view when the app loses active focus +/// (`.inactive` or `.background` scenePhase). iOS captures the app-switcher +/// snapshot during `.inactive`, so any view rendering sensitive material +/// (nsec, bunker secret, QR code, incoming approval request) wraps itself +/// with `.snapshotProtected()` to prevent the snapshot from leaking it. +/// +/// Audit ref: A10.1 in `~/hq/clave/security-audits/2026-04-17-pre-external-testflight.md`. +private struct SnapshotProtectedModifier: ViewModifier { + @Environment(\.scenePhase) private var scenePhase + + func body(content: Content) -> some View { + ZStack { + content + if scenePhase != .active { + privacyOverlay + } + } + } + + private var privacyOverlay: some View { + ZStack { + Color(.systemBackground) + .ignoresSafeArea() + VStack(spacing: 12) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 56)) + .foregroundStyle(.tint) + Text("Clave") + .font(.title2.weight(.semibold)) + .foregroundStyle(.primary) + Text("Hidden while inactive") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} + +extension View { + /// Hides the receiver behind a privacy overlay whenever scenePhase is not + /// `.active` — primarily to prevent iOS app-switcher snapshots from + /// capturing sensitive content. Apply to sheets that show secret keys, + /// bunker URIs, QR codes, or incoming approval requests. + func snapshotProtected() -> some View { + modifier(SnapshotProtectedModifier()) + } +} diff --git a/Clave/Views/Home/ApprovalSheet.swift b/Clave/Views/Home/ApprovalSheet.swift index 1cdc4d1..0576195 100644 --- a/Clave/Views/Home/ApprovalSheet.swift +++ b/Clave/Views/Home/ApprovalSheet.swift @@ -37,6 +37,7 @@ struct ApprovalSheet: View { Text("You've paired the maximum 5 clients. Unpair one from Settings → Clients to continue.") } } + .snapshotProtected() } // MARK: - Client Identity Header @@ -54,11 +55,11 @@ struct ApprovalSheet: View { .frame(width: 64, height: 64) .clipShape(Circle()) default: - AvatarView(pubkeyHex: parsedURI.clientPubkey, size: 64) + AvatarView(pubkeyHex: parsedURI.clientPubkey, name: parsedURI.name, size: 64) } } } else { - AvatarView(pubkeyHex: parsedURI.clientPubkey, size: 64) + AvatarView(pubkeyHex: parsedURI.clientPubkey, name: parsedURI.name, size: 64) } Text(parsedURI.name ?? truncatedPubkey) diff --git a/Clave/Views/Home/ClientDetailView.swift b/Clave/Views/Home/ClientDetailView.swift index 077163f..0f86896 100644 --- a/Clave/Views/Home/ClientDetailView.swift +++ b/Clave/Views/Home/ClientDetailView.swift @@ -13,6 +13,7 @@ struct ClientDetailView: View { @State private var showOverrideAlert = false @State private var pendingTrustLevel: TrustLevel? @State private var showPermissions = false + @State private var showConnectionInfo = false @Environment(\.dismiss) private var dismiss private let protectedKinds: Set = SharedStorage.getProtectedKinds() @@ -25,7 +26,6 @@ struct ClientDetailView: View { trustLevelSection permissionsSection recentActivitySection - actionsSection } else { ContentUnavailableView( "Client Not Found", @@ -38,7 +38,39 @@ struct ClientDetailView: View { } .navigationTitle(permissions?.name ?? "Client") .navigationBarTitleDisplayMode(.inline) + .toolbar { + if permissions != nil { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + showConnectionInfo = true + } label: { + Label("Connection Info", systemImage: "info.circle") + } + Button { + renameText = permissions?.name ?? "" + showRename = true + } label: { + Label("Rename", systemImage: "pencil") + } + Divider() + Button(role: .destructive) { + showUnpairConfirm = true + } label: { + Label("Unpair Client", systemImage: "link.badge.plus") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + } .onAppear(perform: loadPermissions) + .sheet(isPresented: $showConnectionInfo) { + if let perms = permissions { + ConnectionInfoSheet(perms: perms) + } + } .alert("Rename Client", isPresented: $showRename) { TextField("Client name", text: $renameText) Button("Save") { performRename() } @@ -94,15 +126,28 @@ struct ClientDetailView: View { .frame(width: 72, height: 72) .clipShape(Circle()) default: - AvatarView(pubkeyHex: pubkey, size: 72) + AvatarView(pubkeyHex: pubkey, name: perms.name, size: 72) } } } else { - AvatarView(pubkeyHex: pubkey, size: 72) + AvatarView(pubkeyHex: pubkey, name: perms.name, size: 72) } - Text(perms.name ?? truncatedPubkey) - .font(.title3.weight(.semibold)) + Button { + renameText = perms.name ?? "" + showRename = true + } label: { + HStack(spacing: 6) { + Text(perms.name ?? truncatedPubkey) + .font(.title3.weight(.semibold)) + .foregroundStyle(.primary) + Image(systemName: "pencil") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + .accessibilityHint("Tap to rename") if let url = perms.url { Text(url) @@ -364,31 +409,6 @@ struct ClientDetailView: View { .font(.body) } - // MARK: - Actions - - private var actionsSection: some View { - VStack(spacing: 12) { - Button { - renameText = permissions?.name ?? "" - showRename = true - } label: { - Label("Rename", systemImage: "pencil") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - - Button(role: .destructive) { - showUnpairConfirm = true - } label: { - Label("Unpair Client", systemImage: "link.badge.plus") - .symbolRenderingMode(.multicolor) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - } - .padding(.top, 8) - } - // MARK: - Persistence private func saveChanges() { diff --git a/Clave/Views/Home/ConnectSheet.swift b/Clave/Views/Home/ConnectSheet.swift index 551df69..dc103e5 100644 --- a/Clave/Views/Home/ConnectSheet.swift +++ b/Clave/Views/Home/ConnectSheet.swift @@ -84,6 +84,7 @@ struct ConnectSheet: View { Text(connectionError ?? "Unknown error") } } + .snapshotProtected() } // MARK: - Bunker URI Section diff --git a/Clave/Views/Home/ConnectionInfoSheet.swift b/Clave/Views/Home/ConnectionInfoSheet.swift new file mode 100644 index 0000000..8974ea0 --- /dev/null +++ b/Clave/Views/Home/ConnectionInfoSheet.swift @@ -0,0 +1,107 @@ +import SwiftUI +import NostrSDK + +/// Detailed view of a paired client connection: name, pubkey (hex + npub), +/// origin URL, connect/last-seen timestamps, total requests handled, and +/// the relay set the proxy watches on this client's behalf. Reachable from +/// the ClientDetailView toolbar overflow menu. +struct ConnectionInfoSheet: View { + let perms: ClientPermissions + @Environment(\.dismiss) private var dismiss + + private var connectedClient: ConnectedClient? { + SharedStorage.getConnectedClients().first { $0.pubkey == perms.pubkey } + } + + private var npub: String { + guard let pk = try? PublicKey.parse(publicKey: perms.pubkey) else { return "" } + return (try? pk.toBech32()) ?? "" + } + + var body: some View { + NavigationStack { + Form { + Section("Identity") { + if let name = perms.name { + labeled("Name", value: name) + } + if let url = perms.url { + labeled("Origin", value: url, monospaced: false) + } + if !npub.isEmpty { + labeled("npub", value: npub, monospaced: true, copyable: true) + } + labeled("Pubkey (hex)", value: perms.pubkey, monospaced: true, copyable: true) + } + + Section("Activity") { + labeled("Trust level", value: trustLabel(perms.trustLevel)) + labeled("First connected", value: absoluteDate(perms.connectedAt)) + labeled("Last seen", value: absoluteDate(perms.lastSeen)) + if let cc = connectedClient { + labeled("Total requests", value: "\(cc.requestCount)") + } + } + + if let cc = connectedClient, !cc.relayUrls.isEmpty { + Section("Paired relays") { + ForEach(cc.relayUrls, id: \.self) { url in + Text(url) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + } + } + .navigationTitle("Connection Info") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } + + @ViewBuilder + private func labeled(_ label: String, value: String, monospaced: Bool = false, copyable: Bool = false) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + HStack(alignment: .top, spacing: 8) { + Text(value) + .font(monospaced ? .system(.footnote, design: .monospaced) : .footnote) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + if copyable { + Button { + UIPasteboard.general.string = value + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } label: { + Image(systemName: "doc.on.doc") + .font(.caption) + } + .buttonStyle(.plain) + } + } + } + .padding(.vertical, 2) + } + + private func trustLabel(_ level: TrustLevel) -> String { + switch level { + case .full: return "Full" + case .medium: return "Medium" + case .low: return "Low" + } + } + + private func absoluteDate(_ timestamp: Double) -> String { + let date = Date(timeIntervalSince1970: timestamp) + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } +} diff --git a/Clave/Views/Home/HomeView.swift b/Clave/Views/Home/HomeView.swift index 51e24ab..d8d84eb 100644 --- a/Clave/Views/Home/HomeView.swift +++ b/Clave/Views/Home/HomeView.swift @@ -176,7 +176,7 @@ struct HomeView: View { .frame(width: 48, height: 48) .clipShape(Circle()) } else { - AvatarView(pubkeyHex: appState.signerPubkeyHex) + AvatarView(pubkeyHex: appState.signerPubkeyHex, name: appState.profile?.displayName) } } @@ -253,12 +253,12 @@ struct HomeView: View { AsyncImage(url: url) { image in image.resizable().scaledToFill() } placeholder: { - AvatarView(pubkeyHex: client.pubkey, size: 32) + AvatarView(pubkeyHex: client.pubkey, name: client.name, size: 32) } .frame(width: 32, height: 32) .clipShape(Circle()) } else { - AvatarView(pubkeyHex: client.pubkey, size: 32) + AvatarView(pubkeyHex: client.pubkey, name: client.name, size: 32) } VStack(alignment: .leading, spacing: 2) { diff --git a/Clave/Views/Home/PendingApprovalsView.swift b/Clave/Views/Home/PendingApprovalsView.swift index 325330c..0f99fee 100644 --- a/Clave/Views/Home/PendingApprovalsView.swift +++ b/Clave/Views/Home/PendingApprovalsView.swift @@ -29,6 +29,10 @@ struct PendingApprovalsView: View { .fill(Color.orange.opacity(0.08)) .strokeBorder(Color.orange.opacity(0.3), lineWidth: 1) } + // The wrapping Section in HomeView strips list-row insets so other + // cards (identityBar, statsRow) can self-pad. This mirrors that + // pattern so the orange border doesn't touch screen edges. + .padding(.horizontal) } private func requestRow(_ request: PendingRequest) -> some View { diff --git a/Clave/Views/MainTabView.swift b/Clave/Views/MainTabView.swift index 7101475..b95986e 100644 --- a/Clave/Views/MainTabView.swift +++ b/Clave/Views/MainTabView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UserNotifications struct MainTabView: View { @Environment(AppState.self) private var appState @@ -39,6 +40,19 @@ struct MainTabView: View { Task { @MainActor in appState.startForegroundSubscription() } + // Pull cross-process pending-requests writes (NSE while we were + // backgrounded). The in-process .pendingRequestsUpdated observer + // in AppState handles the L1 path; this catches NSE-side queues. + appState.refreshPendingRequests() + // Sweep blank Notification Center entries from NSE silent-success + // wakes. NSE calls removeDeliveredNotifications immediately after + // contentHandler, but the NSE process often exits before iOS has + // committed the notification, so the remove no-ops. The L1 dedupe + // makes this much more frequent (every NSE wake for an event L1 + // already processed returns .noEvents → blank entry). Cleaning + // here works because the main app process lives long enough for + // the async API to actually complete. + sweepBlankNotifications() case .inactive: // 2s grace window for app-switcher peeks / control-center swipes. pendingStopTask?.cancel() @@ -58,4 +72,21 @@ struct MainTabView: View { break } } + + /// Removes any delivered notification with an empty title — these are NSE + /// silent-success / .noEvents wakes that should never have appeared in + /// Notification Center but did, due to the NSE-exit-before-iOS-commit + /// race. Locally-scheduled pending-approval banners ("Approve Signing + /// Request"), sign-failure banners ("Signing Failed"), and any other + /// real notification keep their title and are preserved. + private func sweepBlankNotifications() { + let center = UNUserNotificationCenter.current() + center.getDeliveredNotifications { delivered in + let blankIds = delivered + .filter { $0.request.content.title.isEmpty } + .map { $0.request.identifier } + guard !blankIds.isEmpty else { return } + center.removeDeliveredNotifications(withIdentifiers: blankIds) + } + } } diff --git a/Clave/Views/Settings/ExportKeySheet.swift b/Clave/Views/Settings/ExportKeySheet.swift index 6e29e1c..08dc421 100644 --- a/Clave/Views/Settings/ExportKeySheet.swift +++ b/Clave/Views/Settings/ExportKeySheet.swift @@ -84,6 +84,7 @@ struct ExportKeySheet: View { } .onAppear { authenticate() } } + .snapshotProtected() } private func authenticate() { diff --git a/Shared/ForegroundRelaySubscription.swift b/Shared/ForegroundRelaySubscription.swift index 8e05e91..4c32a65 100644 --- a/Shared/ForegroundRelaySubscription.swift +++ b/Shared/ForegroundRelaySubscription.swift @@ -369,6 +369,22 @@ final class ForegroundRelaySubscription { self.eventsFailed += 1 } self.recordLatency(elapsedMs) + + // When L1 catches a request that needs user approval, NSE won't + // banner-pop for it — NSE will see the markEventProcessed dedupe + // and return .noEvents. Schedule the banner here so the user + // sees the same alert they'd get pre-L1 (when NSE was the only + // path). Identifier matches PendingRequest.id so approve/deny + // can clear the delivered banner. + if let result = result, + result.status == "pending", + let requestId = result.pendingRequestId { + PendingApprovalBanner.schedule( + requestId: requestId, + clientPubkey: result.clientPubkey, + eventKind: result.eventKind + ) + } } } } diff --git a/Shared/LightSigner.swift b/Shared/LightSigner.swift index b1caa36..da58f6b 100644 --- a/Shared/LightSigner.swift +++ b/Shared/LightSigner.swift @@ -9,8 +9,12 @@ enum LightSigner { let method: String let eventKind: Int? let clientPubkey: String - let status: String // "signed", "blocked", "pending", "error" + let status: String // "signed", "blocked", "pending", "error", "skipped-duplicate" let errorMessage: String? + /// Set when status == "pending" so callers can schedule a UNNotificationRequest + /// with a stable identifier matching the queued PendingRequest.id. + /// nil for all other statuses. + var pendingRequestId: String? = nil } static func handleRequest( @@ -209,6 +213,7 @@ enum LightSigner { logger.notice("[LightSigner] Permission denied for \(method, privacy: .public) — queuing for approval") // Serialize the full request event so the app can process it later + var queuedRequestId: String? = nil if let eventData = try? JSONSerialization.data(withJSONObject: requestEvent), let eventJSON = String(data: eventData, encoding: .utf8) { let pending = PendingRequest( @@ -221,10 +226,12 @@ enum LightSigner { responseRelayUrl: responseRelayUrl ) SharedStorage.queuePendingRequest(pending) + queuedRequestId = pending.id } let result = RequestResult(method: method, eventKind: eventKind, clientPubkey: senderPubkey, - status: "pending", errorMessage: "Queued for approval") + status: "pending", errorMessage: "Queued for approval", + pendingRequestId: queuedRequestId) logAndTrack(result: result, clientName: clientName) try await sendErrorResponse( requestId: requestId, error: "Permission denied — open Clave to approve", diff --git a/Shared/PendingApprovalBanner.swift b/Shared/PendingApprovalBanner.swift new file mode 100644 index 0000000..0655d25 --- /dev/null +++ b/Shared/PendingApprovalBanner.swift @@ -0,0 +1,65 @@ +import Foundation +import UserNotifications +import os.log + +private let logger = Logger(subsystem: "dev.nostr.clave", category: "banner") + +/// Schedules a local notification when a sign request is queued for user approval +/// and the request was processed *in the main app process* (L1 foreground sub or +/// foreground APNs push handler). NSE doesn't call this — it modifies the APNs +/// content via `contentHandler` directly (see `ClaveNSE/NotificationService.swift` +/// `deliverContent` `.pending` case). Calling from both would double-notify. +/// +/// Why this exists: pre-L1, every sign request reached Clave via APNs → NSE, +/// and NSE's pending banner was the user-visible signal. After L1 (PR #11), +/// when Clave is foregrounded or in the 2s `.inactive` grace window, L1 catches +/// the request first and marks it processed via `SharedStorage.markEventProcessed`. +/// NSE then runs from the same APNs push, sees the dedupe, returns +/// `.noEvents`, and produces a silent passive notification (correct — L1 already +/// handled it). The banner the user expects has to come from the L1 path itself, +/// which this helper provides. +enum PendingApprovalBanner { + /// Schedules a local notification matching the format NSE uses for + /// pending-approval pushes (title "Approve Signing Request", body + /// " wants to sign ", `.active` interruption). + /// + /// Idempotent on identifier collisions — UNUserNotificationCenter + /// replaces an existing pending request with the same identifier. + /// We pass the request id so denying/approving the same request won't + /// stack banners. + static func schedule(requestId: String, clientPubkey: String, eventKind: Int?) { + let clientName = SharedStorage.getClientPermissions(for: clientPubkey)?.name + ?? String(clientPubkey.prefix(8)) + let kindDesc = eventKind.map { KnownKinds.label(for: $0) } ?? "event" + + let content = UNMutableNotificationContent() + content.title = "Approve Signing Request" + content.body = "\(clientName) wants to sign \(kindDesc)" + content.sound = .default + content.interruptionLevel = .active + + // No trigger → deliver immediately. + let request = UNNotificationRequest( + identifier: "pending-approval-\(requestId)", + content: content, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error { + logger.error("[Banner] Failed to schedule pending-approval banner: \(error.localizedDescription, privacy: .public)") + } else { + logger.notice("[Banner] Scheduled pending-approval banner client=\(clientPubkey.prefix(8), privacy: .public) kind=\(eventKind ?? -1, privacy: .public)") + } + } + } + + /// Removes the delivered/pending banner for a given request id. Called when + /// the user approves or denies a pending request via the UI so the banner + /// doesn't linger in Notification Center. + static func clear(requestId: String) { + let identifier = "pending-approval-\(requestId)" + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier]) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier]) + } +} diff --git a/Shared/SharedStorage.swift b/Shared/SharedStorage.swift index b93d638..ddae5da 100644 --- a/Shared/SharedStorage.swift +++ b/Shared/SharedStorage.swift @@ -98,6 +98,7 @@ enum SharedStorage { pending.append(request) if pending.count > 20 { pending = Array(pending.suffix(20)) } save(pending, forKey: SharedConstants.pendingRequestsKey) + postPendingRequestsUpdated() } static func getPendingRequests() -> [PendingRequest] { @@ -108,10 +109,12 @@ enum SharedStorage { var pending = getPendingRequests() pending.removeAll { $0.id == id } save(pending, forKey: SharedConstants.pendingRequestsKey) + postPendingRequestsUpdated() } static func clearPendingRequests() { defaults.removeObject(forKey: SharedConstants.pendingRequestsKey) + postPendingRequestsUpdated() } // MARK: - Pending Pair Ops (HTTP failure retry queue) @@ -351,6 +354,24 @@ enum SharedStorage { return .markedNew } + // MARK: - Pending-requests change broadcast + + /// Posts an in-process NotificationCenter event so the main app's UI can + /// refresh without waiting for scenePhase or onAppear. Posted from + /// queue/remove/clear so any code path that mutates the pending-requests + /// list triggers a UI update. + /// + /// In-process only: NSE and the main app are separate processes, so this + /// notification does NOT cross between them. The main app picks up + /// NSE-side writes via the MainTabView scenePhase observer when the app + /// foregrounds (UserDefaults state is persistent across the process + /// boundary; only the wake-up signal is missing). Within the main app + /// process (L1 foreground sub, ApprovalSheet approve/deny, AppState), + /// this notification gives the UI an immediate refresh signal. + private static func postPendingRequestsUpdated() { + NotificationCenter.default.post(name: .pendingRequestsUpdated, object: nil) + } + // MARK: - Helpers private static func save(_ value: T, forKey key: String) { @@ -377,3 +398,9 @@ enum SharedStorage { } } } + +// Defined in Shared/ so both NSE and the main app reference the same name. +// Currently only posted in-process (see SharedStorage.postPendingRequestsUpdated). +extension Notification.Name { + static let pendingRequestsUpdated = Notification.Name("pendingRequestsUpdated") +}