From f20456a7a2154033fcd50d52893f51461a29257d Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 27 Apr 2026 23:17:53 -0400 Subject: [PATCH 1/3] fix(L1): pending-approval UI refresh + banner notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-L1, every sign request reached Clave via APNs → NSE, and NSE was the sole producer of "Approve Signing Request" banners. Post-L1 (PR #11), when Clave is foregrounded or in the 2s `.inactive` grace, L1 catches kind:24133 events directly via WebSocket and `SharedStorage.markEventProcessed` marks the event id. NSE then runs from the same APNs push, sees the dedupe, returns `.noEvents`, and produces a silent passive notification — correct, since L1 already handled it. But L1 itself never emitted any user-facing signal: no banner, and the in-process `SharedStorage.queuePendingRequest` write didn't broadcast a refresh signal, so the pending-approvals list only repopulated on tab-switch (HomeView's `.onAppear`) or full restart. Two wires fix both symptoms: 1. Refresh signal (`.pendingRequestsUpdated`) - `SharedStorage.queuePendingRequest` / `removePendingRequest` / `clearPendingRequests` post the notification in-process. - `AppState` observes it and refreshes `pendingRequests` on main. `@Observable` propagates to any subscribed view automatically. - `MainTabView` also refreshes on scenePhase `.active` so cross-process NSE writes (while we were backgrounded) get picked up — the in-process notification doesn't cross the NSE↔main-app boundary. 2. Banner emission (`PendingApprovalBanner`) - New `Shared/PendingApprovalBanner.swift` schedules a `UNNotificationRequest` matching the format NSE already uses for pending pushes (title "Approve Signing Request", `.active` interruption). Identifier is `pending-approval-` so approve/deny can `clear()` the delivered banner. - `LightSigner.RequestResult` gains optional `pendingRequestId` so callers can schedule with the same id used in the queued `PendingRequest`. - L1 `ForegroundRelaySubscription.processEvent` schedules on `status == "pending"`. Foreground APNs push handler in `ClaveApp` also schedules (defensive — covers the rare case where NSE didn't process and main-app marked the event first; in normal operation NSE wins the race and this path returns "skipped-duplicate"). - `AppState.approvePendingRequest` / `denyPendingRequest` call `PendingApprovalBanner.clear` so the banner doesn't linger after the user has acted on it. - `ClaveApp.willPresent` now distinguishes: - Locally-scheduled banners (id prefix `pending-approval-`) → display directly, no re-processing. - APNs pushes with non-empty NSE-modified title → display (was being suppressed by unconditional `completionHandler([])`, hiding NSE's own pending banner when the app was foreground). - APNs pushes with empty title (NSE silent-success case) → suppress. NSE's own `deliverContent` `.pending` path is unchanged — it still produces a banner via `contentHandler` for the pure-background case (NSE only, no L1, no foreground push handler). The dedupe ensures only one of {NSE banner, L1-scheduled banner, foreground-handler-scheduled banner} actually fires per event. Verification: - xcodebuild -scheme Clave -destination 'generic/platform=iOS' build → BUILD SUCCEEDED - xcodebuild test on iPhone 17 Pro Max sim (iOS 26.4) → TEST SUCCEEDED - Device test: needs build 25 archive + TestFlight install (next step). Co-Authored-By: Claude Opus 4.7 (1M context) --- Clave.xcodeproj/project.pbxproj | 4 ++ Clave/AppState.swift | 14 +++++ Clave/ClaveApp.swift | 49 +++++++++++++++++- Clave/Views/MainTabView.swift | 4 ++ Shared/ForegroundRelaySubscription.swift | 16 ++++++ Shared/LightSigner.swift | 11 +++- Shared/PendingApprovalBanner.swift | 65 ++++++++++++++++++++++++ Shared/SharedStorage.swift | 27 ++++++++++ 8 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 Shared/PendingApprovalBanner.swift diff --git a/Clave.xcodeproj/project.pbxproj b/Clave.xcodeproj/project.pbxproj index ac7ca69..b6f661f 100644 --- a/Clave.xcodeproj/project.pbxproj +++ b/Clave.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ EFC7FD75109694C0A7F86D26 /* SharedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFCA55B463A619311A35AF3C /* SharedModels.swift */; }; EFE12D1EC4CE48622767D427 /* SharedStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFEE4316AC522CDDA35AFAC1 /* SharedStorage.swift */; }; F60FCE012F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60FCE002F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift */; }; + B0EBA01E2F90AB01000A0001 /* PendingApprovalBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0EBA01E2F90AB01000A0002 /* PendingApprovalBanner.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -99,6 +100,7 @@ EFCA55B463A619311A35AF3C /* SharedModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedModels.swift; sourceTree = ""; }; EFEE4316AC522CDDA35AFAC1 /* SharedStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedStorage.swift; sourceTree = ""; }; F60FCE002F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForegroundRelaySubscription.swift; sourceTree = ""; }; + B0EBA01E2F90AB01000A0002 /* PendingApprovalBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingApprovalBanner.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -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/MainTabView.swift b/Clave/Views/MainTabView.swift index 7101475..3a2ad4d 100644 --- a/Clave/Views/MainTabView.swift +++ b/Clave/Views/MainTabView.swift @@ -39,6 +39,10 @@ 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() case .inactive: // 2s grace window for app-switcher peeks / control-center swipes. pendingStopTask?.cancel() 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") +} From 3401c091f5c226c2402f48841021506ef25a742d Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 27 Apr 2026 23:37:26 -0400 Subject: [PATCH 2/3] fix(ui): pending-card padding, snapshot protection, ClientDetailView UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled UI/UX improvements going out alongside the L1 pending-approval refresh + banner fix in this PR so they all ride one TestFlight. **1. Pending approvals card edge padding** [Clave/Views/Home/PendingApprovalsView.swift] HomeView wraps it in a Section with `.listRowInsets(EdgeInsets())`, so the card has to self-pad to match its sibling rows (identityBar, statsRow). Added the missing `.padding(.horizontal)` after `.background`. Pre-existing since v1.0 UX sprint, not an L1 regression — only surfaced now because pending approvals weren't refreshing reliably enough for users to notice. **2. App-switcher snapshot protection (audit A10.1)** New `Clave/Views/Components/SnapshotProtected.swift` — ViewModifier that overlays the receiver with an opaque privacy view whenever scenePhase is not `.active`. Applied via `.snapshotProtected()` on the four sheets that render sensitive material: - ExportKeySheet (nsec) - ConnectSheet (bunker URI containing the bunker secret) - QRCodeView (QR code of bunker URI) - ApprovalSheet (incoming approval request, including client identity) iOS captures the app-switcher snapshot during the `.inactive` transition, so a per-sheet overlay is sufficient and avoids blanking the rest of the app on benign control-center swipes. **3. AvatarView prefers name initials over pubkey prefix** [Clave/Views/Components/AvatarView.swift] gains an optional `name: String?` param. When set and non-blank, shows up to two letters derived from the name (e.g. "Joe Bloggs" → "JB"; "Yakihonne" → "YA") in a proportional font. Pubkey-prefix fallback unchanged (monospaced). Gradient remains pubkey-derived so renames don't change the avatar color — only the text inside the circle. Updated four call sites to pass the available name: ClientDetailView header, HomeView clientRow + identityBar, ApprovalSheet. **4. ClientDetailView header + toolbar overhaul** - Tap the client name (or pencil affordance next to it) → existing Rename alert. The bottom-of-screen Rename button is removed. - Toolbar overflow menu (`ellipsis.circle`, top-right) now houses: - Connection Info → opens new `ConnectionInfoSheet` - Rename → same alert as the header tap - Unpair Client (destructive) → existing confirm alert - Bottom `actionsSection` removed entirely. The Unpair button used to live underneath the recent-activity list, which buried it. - New `ConnectionInfoSheet` shows: name, origin URL, npub, hex pubkey (both copyable), trust level, first-connected/last-seen timestamps, total request count, and the relay set the proxy watches for this client (when available via `ConnectedClient.relayUrls`). Verification: - xcodebuild -scheme Clave -destination 'generic/platform=iOS' build → BUILD SUCCEEDED - xcodebuild test on iPhone 17 Pro Max sim (iOS 26.4) → TEST SUCCEEDED - Device test: build 25 archive (next step). Co-Authored-By: Claude Opus 4.7 (1M context) --- Clave.xcodeproj/project.pbxproj | 4 +- Clave/Views/Components/AvatarView.swift | 34 +++++- Clave/Views/Components/QRCodeView.swift | 1 + .../Views/Components/SnapshotProtected.swift | 49 ++++++++ Clave/Views/Home/ApprovalSheet.swift | 5 +- Clave/Views/Home/ClientDetailView.swift | 80 ++++++++----- Clave/Views/Home/ConnectSheet.swift | 1 + Clave/Views/Home/ConnectionInfoSheet.swift | 107 ++++++++++++++++++ Clave/Views/Home/HomeView.swift | 6 +- Clave/Views/Home/PendingApprovalsView.swift | 4 + Clave/Views/Settings/ExportKeySheet.swift | 1 + 11 files changed, 253 insertions(+), 39 deletions(-) create mode 100644 Clave/Views/Components/SnapshotProtected.swift create mode 100644 Clave/Views/Home/ConnectionInfoSheet.swift diff --git a/Clave.xcodeproj/project.pbxproj b/Clave.xcodeproj/project.pbxproj index b6f661f..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 */; }; @@ -39,7 +40,6 @@ EFC7FD75109694C0A7F86D26 /* SharedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFCA55B463A619311A35AF3C /* SharedModels.swift */; }; EFE12D1EC4CE48622767D427 /* SharedStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFEE4316AC522CDDA35AFAC1 /* SharedStorage.swift */; }; F60FCE012F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60FCE002F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift */; }; - B0EBA01E2F90AB01000A0001 /* PendingApprovalBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0EBA01E2F90AB01000A0002 /* PendingApprovalBanner.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -83,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; }; @@ -100,7 +101,6 @@ EFCA55B463A619311A35AF3C /* SharedModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedModels.swift; sourceTree = ""; }; EFEE4316AC522CDDA35AFAC1 /* SharedStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedStorage.swift; sourceTree = ""; }; F60FCE002F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForegroundRelaySubscription.swift; sourceTree = ""; }; - B0EBA01E2F90AB01000A0002 /* PendingApprovalBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingApprovalBanner.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 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/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() { From 7342fc2f4b48b4f907720b2a3e82d2fc8d3de3e0 Mon Sep 17 00:00:00 2001 From: DocNR Date: Tue, 28 Apr 2026 06:15:55 -0400 Subject: [PATCH 3/3] fix(notifications): sweep blank NC entries on app foreground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reports: blank Notification Center entries accumulating again, much worse with L1 than pre-L1. Cause: every NSE wake for an event L1 already processed returns "skipped-duplicate" → SigningResult(.noEvents) → NSE delivers content with empty title + .passive interruption, then calls removeDeliveredNotifications. The remove is racy — NSE process often exits before iOS commits the notification, so the remove no-ops and the blank entry sticks. L1 amplifies this because most APNs wakes are now "L1 already handled it" duplicates rather than real work for NSE. iOS doesn't expose a "deliver but don't add to NC" hint — `.passive` just suppresses banner+sound, the NC entry is mandatory once the push arrives. So we have to clean up after the fact. Fix: on every scenePhase → .active in MainTabView, query getDeliveredNotifications and remove any with empty title. Locally- scheduled pending-approval banners ("Approve Signing Request"), sign-failure banners ("Signing Failed"), and other real notifications keep their title and are preserved. The main app process lives long enough for the async UNUserNotificationCenter API to actually complete, which the short-lived NSE process does not. Co-Authored-By: Claude Opus 4.7 (1M context) --- Clave/Views/MainTabView.swift | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Clave/Views/MainTabView.swift b/Clave/Views/MainTabView.swift index 3a2ad4d..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 @@ -43,6 +44,15 @@ struct MainTabView: View { // 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() @@ -62,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) + } + } }