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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Clave.xcodeproj/project.pbxproj
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 */; };
Expand DownExpand Up@@ -82,6 +83,7 @@
/* Begin PBXFileReference section */
34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrConnectParser.swift; sourceTree = "<group>"; };
894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientPermissions.swift; sourceTree = "<group>"; };
B0EBA01E2F90AB01000A0002 /* PendingApprovalBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingApprovalBanner.swift; sourceTree = "<group>"; };
DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = "<group>"; };
DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExporter.swift; sourceTree = "<group>"; };
EF3D7A0F2F8BCAE3005A6545 /* Clave.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Clave.app; sourceTree = BUILT_PRODUCTS_DIR; };
Expand DownExpand Up@@ -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 */,
Expand DownExpand Up@@ -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;
Expand Down
14 changes: 14 additions & 0 deletions Clave/AppState.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 {
Expand All@@ -316,6 +329,7 @@ final class AppState {

func denyPendingRequest(_ request: PendingRequest) {
SharedStorage.removePendingRequest(id: request.id)
PendingApprovalBanner.clear(requestId: request.id)
refreshPendingRequests()
}

Expand Down
49 changes: 47 additions & 2 deletions Clave/ClaveApp.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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)")
}
Expand Down
34 changes: 32 additions & 2 deletions Clave/Views/Components/AvatarView.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand All@@ -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)
}
}
Expand Down
1 change: 1 addition & 0 deletions Clave/Views/Components/QRCodeView.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,7 @@ struct QRCodeView: View {
}
}
}
.snapshotProtected()
}

private var qrImage: Image {
Expand Down
49 changes: 49 additions & 0 deletions Clave/Views/Components/SnapshotProtected.swift
Original file line numberDiff line numberDiff line change
@@ -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())
}
}
5 changes: 3 additions & 2 deletions Clave/Views/Home/ApprovalSheet.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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)
Expand Down
80 changes: 50 additions & 30 deletions Clave/Views/Home/ClientDetailView.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Int> = SharedStorage.getProtectedKinds()
Expand All@@ -25,7 +26,6 @@ struct ClientDetailView: View {
trustLevelSection
permissionsSection
recentActivitySection
actionsSection
} else {
ContentUnavailableView(
"Client Not Found",
Expand All@@ -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() }
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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() {
Expand Down
1 change: 1 addition & 0 deletions Clave/Views/Home/ConnectSheet.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@ struct ConnectSheet: View {
Text(connectionError ?? "Unknown error")
}
}
.snapshotProtected()
}

// MARK: - Bunker URI Section
Expand Down
Loading