mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
QR verification: speed + persistence + UX
- Inject live Noise into VerificationService; prewarm QR on app start - Keep camera active; remove intermediate responder toast - One-shot/dupe guards and deferred send on handshake - Persist verified status immediately; standardize fingerprint (SHA-256) - Show verified badge for offline favorites; mutual verification toast - VERIFY sheet styling to match peer sheet; UI polish - Logs to diagnose verified load + favorites mapping
This commit is contained in:
@@ -31,6 +31,13 @@ struct BitchatApp: App {
|
|||||||
.environmentObject(chatViewModel)
|
.environmentObject(chatViewModel)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
NotificationDelegate.shared.chatViewModel = chatViewModel
|
||||||
|
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||||
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||||
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
|
|||||||
@@ -509,10 +509,50 @@ final class BLEService: NSObject {
|
|||||||
sendAnnounce()
|
sendAnnounce()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - QR Verification over Noise
|
||||||
|
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {
|
||||||
|
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||||||
|
sendNoisePayload(payload, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {
|
||||||
|
guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return }
|
||||||
|
sendNoisePayload(payload, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendNoisePayload(_ typedPayload: Data, to peerID: String) {
|
||||||
|
guard noiseService.hasSession(with: peerID) else {
|
||||||
|
// Lazy-handshake path: queue? For now, initiate handshake and drop
|
||||||
|
initiateNoiseHandshake(with: peerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||||
|
recipientID: Data(hexString: peerID),
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: encrypted,
|
||||||
|
signature: nil,
|
||||||
|
ttl: messageTTL
|
||||||
|
)
|
||||||
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
|
broadcastPacket(packet)
|
||||||
|
} else {
|
||||||
|
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.log("Failed to send verification payload: \(error)", category: SecureLogger.noise, level: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getPeerFingerprint(_ peerID: String) -> String? {
|
func getPeerFingerprint(_ peerID: String) -> String? {
|
||||||
return collectionsQueue.sync {
|
return collectionsQueue.sync {
|
||||||
if let publicKey = peers[peerID]?.noisePublicKey {
|
if let publicKey = peers[peerID]?.noisePublicKey {
|
||||||
return publicKey.hexEncodedString()
|
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
|
||||||
|
let hash = SHA256.hash(data: publicKey)
|
||||||
|
return hash.map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1383,6 +1423,16 @@ final class BLEService: NSObject {
|
|||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
||||||
}
|
}
|
||||||
|
case .verifyChallenge:
|
||||||
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
|
notifyUI { [weak self] in
|
||||||
|
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
|
||||||
|
}
|
||||||
|
case .verifyResponse:
|
||||||
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
|
notifyUI { [weak self] in
|
||||||
|
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
|
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,8 +165,12 @@ class CommandProcessor {
|
|||||||
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
|
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
|
||||||
var geoNames: [String] = []
|
var geoNames: [String] = []
|
||||||
if let vm = chatViewModel {
|
if let vm = chatViewModel {
|
||||||
|
#if os(iOS)
|
||||||
let visible = vm.visibleGeohashPeople()
|
let visible = vm.visibleGeohashPeople()
|
||||||
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
||||||
|
#else
|
||||||
|
let visibleIndex: [String: String] = [:]
|
||||||
|
#endif
|
||||||
for pk in geoBlocked {
|
for pk in geoBlocked {
|
||||||
if let name = visibleIndex[pk.lowercased()] {
|
if let name = visibleIndex[pk.lowercased()] {
|
||||||
geoNames.append(name)
|
geoNames.append(name)
|
||||||
|
|||||||
@@ -46,11 +46,20 @@ protocol Transport: AnyObject {
|
|||||||
func sendBroadcastAnnounce()
|
func sendBroadcastAnnounce()
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
func sendDeliveryAck(for messageID: String, to peerID: String)
|
||||||
|
|
||||||
|
// QR verification (optional for transports)
|
||||||
|
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||||
|
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||||
|
|
||||||
// Peer snapshots (for non-UI services)
|
// Peer snapshots (for non-UI services)
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Transport {
|
||||||
|
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||||
|
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||||
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ import Foundation
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
||||||
@MainActor
|
|
||||||
final class VerificationService {
|
final class VerificationService {
|
||||||
static let shared = VerificationService()
|
static let shared = VerificationService()
|
||||||
|
|
||||||
private let noise: NoiseEncryptionService = {
|
// Injected Noise service from the running transport (do NOT create new BLEService)
|
||||||
// We reuse the singleton inside BLEService normally; for scaffolding we access a cached instance.
|
private var noise: NoiseEncryptionService?
|
||||||
// In production, inject the transport's Noise service.
|
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
|
||||||
return BLEService().getNoiseService()
|
|
||||||
}()
|
|
||||||
|
|
||||||
/// Encapsulates the data encoded into a verification QR
|
/// Encapsulates the data encoded into a verification QR
|
||||||
struct VerificationQR: Codable {
|
struct VerificationQR: Codable {
|
||||||
@@ -76,6 +73,12 @@ final class VerificationService {
|
|||||||
|
|
||||||
/// Build a signed QR string for the current identity
|
/// Build a signed QR string for the current identity
|
||||||
func buildMyQRString(nickname: String, npub: String?) -> String? {
|
func buildMyQRString(nickname: String, npub: String?) -> String? {
|
||||||
|
// Simple short-lived cache to speed up sheet opening
|
||||||
|
struct Cache { static var last: (nick: String, npub: String?, builtAt: Date, value: String)? }
|
||||||
|
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
|
||||||
|
return c.value
|
||||||
|
}
|
||||||
|
guard let noise = noise else { return nil }
|
||||||
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
|
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
|
||||||
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
|
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
|
||||||
let ts = Int64(Date().timeIntervalSince1970)
|
let ts = Int64(Date().timeIntervalSince1970)
|
||||||
@@ -93,7 +96,9 @@ final class VerificationService {
|
|||||||
ts: payload.ts,
|
ts: payload.ts,
|
||||||
nonceB64: payload.nonceB64,
|
nonceB64: payload.nonceB64,
|
||||||
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
||||||
return signed.toURLString()
|
let out = signed.toURLString()
|
||||||
|
Cache.last = (nickname, npub, Date(), out)
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
|
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
|
||||||
@@ -104,6 +109,7 @@ final class VerificationService {
|
|||||||
if now - Double(qr.ts) > maxAge { return nil }
|
if now - Double(qr.ts) > maxAge { return nil }
|
||||||
// Verify signature using embedded ed25519 signKey
|
// Verify signature using embedded ed25519 signKey
|
||||||
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
|
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
|
||||||
|
guard let noise = noise else { return nil }
|
||||||
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
|
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
|
||||||
return ok ? qr : nil
|
return ok ? qr : nil
|
||||||
}
|
}
|
||||||
@@ -128,11 +134,52 @@ final class VerificationService {
|
|||||||
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||||
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||||
msg.append(nonceA)
|
msg.append(nonceA)
|
||||||
guard let sig = noise.signData(msg) else { return nil }
|
guard let noise = noise, let sig = noise.signData(msg) else { return nil }
|
||||||
var tlv = Data()
|
var tlv = Data()
|
||||||
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
|
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
|
||||||
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
|
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
|
||||||
tlv.append(0x03); tlv.append(UInt8(min(sig.count, 255))); tlv.append(sig.prefix(255))
|
tlv.append(0x03); tlv.append(UInt8(min(sig.count, 255))); tlv.append(sig.prefix(255))
|
||||||
return NoisePayload(type: .verifyResponse, data: tlv).encode()
|
return NoisePayload(type: .verifyResponse, data: tlv).encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseVerifyChallenge(_ data: Data) -> (noiseKeyHex: String, nonceA: Data)? {
|
||||||
|
var idx = 0
|
||||||
|
func take(_ n: Int) -> Data? {
|
||||||
|
guard idx + n <= data.count else { return nil }
|
||||||
|
let d = data[idx..<(idx+n)]
|
||||||
|
idx += n
|
||||||
|
return Data(d)
|
||||||
|
}
|
||||||
|
// Expect type already stripped; we receive only TLV here
|
||||||
|
// TLV 0x01 noiseKeyHex
|
||||||
|
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
|
||||||
|
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
|
||||||
|
// TLV 0x02 nonceA
|
||||||
|
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
|
||||||
|
return (noiseStr, nA)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVerifyResponse(_ data: Data) -> (noiseKeyHex: String, nonceA: Data, signature: Data)? {
|
||||||
|
var idx = 0
|
||||||
|
func take(_ n: Int) -> Data? {
|
||||||
|
guard idx + n <= data.count else { return nil }
|
||||||
|
let d = data[idx..<(idx+n)]
|
||||||
|
idx += n
|
||||||
|
return Data(d)
|
||||||
|
}
|
||||||
|
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
|
||||||
|
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
|
||||||
|
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
|
||||||
|
guard let t3 = take(1), t3[0] == 0x03, let l3 = take(1), let sig = take(Int(l3[0])) else { return nil }
|
||||||
|
return (noiseStr, nA, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyResponseSignature(noiseKeyHex: String, nonceA: Data, signature: Data, signerPublicKeyHex: String) -> Bool {
|
||||||
|
var msg = Data("bitchat-verify-resp-v1".utf8)
|
||||||
|
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||||
|
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||||
|
msg.append(nonceA)
|
||||||
|
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
|
||||||
|
return noise.verifySignature(signature, for: msg, publicKey: pub)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,6 +412,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Delivery tracking
|
// Delivery tracking
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// MARK: - QR Verification (pending state)
|
||||||
|
private struct PendingVerification {
|
||||||
|
let noiseKeyHex: String
|
||||||
|
let signKeyHex: String
|
||||||
|
let nonceA: Data
|
||||||
|
let startedAt: Date
|
||||||
|
var sent: Bool
|
||||||
|
}
|
||||||
|
private var pendingQRVerifications: [String: PendingVerification] = [:] // peerID -> pending
|
||||||
|
// Last handled challenge nonce per peer to avoid duplicate responses
|
||||||
|
private var lastVerifyNonceByPeer: [String: Data] = [:]
|
||||||
|
// Track when we last received a verify challenge from a peer (fingerprint-keyed)
|
||||||
|
private var lastInboundVerifyChallengeAt: [String: Date] = [:] // key: fingerprint
|
||||||
|
// Throttle mutual verification toasts per fingerprint
|
||||||
|
private var lastMutualToastAt: [String: Date] = [:] // key: fingerprint
|
||||||
|
|
||||||
// MARK: - Public message batching (UI perf)
|
// MARK: - Public message batching (UI perf)
|
||||||
// Buffer incoming public messages and flush in small batches to reduce UI invalidations
|
// Buffer incoming public messages and flush in small batches to reduce UI invalidations
|
||||||
private var publicBuffer: [BitchatMessage] = []
|
private var publicBuffer: [BitchatMessage] = []
|
||||||
@@ -451,6 +467,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Throttle verification response toasts per peer to avoid spam
|
||||||
|
private var lastVerifyToastAt: [String: Date] = [:]
|
||||||
|
|
||||||
// Track processed Nostr ACKs to avoid duplicate processing
|
// Track processed Nostr ACKs to avoid duplicate processing
|
||||||
private var processedNostrAcks: Set<String> = [] // "messageId:ackType:senderPubkey" format
|
private var processedNostrAcks: Set<String> = [] // "messageId:ackType:senderPubkey" format
|
||||||
// Track which GeoDM messages we've already sent a delivery ACK for (by messageID)
|
// Track which GeoDM messages we've already sent a delivery ACK for (by messageID)
|
||||||
@@ -3730,9 +3749,34 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
updateEncryptionStatus(for: peerID)
|
updateEncryptionStatus(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func unverifyFingerprint(for peerID: String) {
|
||||||
|
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
||||||
|
SecureIdentityStateManager.shared.setVerified(fingerprint: fingerprint, verified: false)
|
||||||
|
SecureIdentityStateManager.shared.forceSave()
|
||||||
|
verifiedFingerprints.remove(fingerprint)
|
||||||
|
updateEncryptionStatus(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func loadVerifiedFingerprints() {
|
func loadVerifiedFingerprints() {
|
||||||
// Load verified fingerprints directly from secure storage
|
// Load verified fingerprints directly from secure storage
|
||||||
verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints()
|
verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints()
|
||||||
|
// Log snapshot for debugging persistence
|
||||||
|
let sample = Array(verifiedFingerprints.prefix(3)).map { $0.prefix(8) }.joined(separator: ", ")
|
||||||
|
SecureLogger.log("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: SecureLogger.security, level: .info)
|
||||||
|
// Also log any offline favorites and whether we consider them verified
|
||||||
|
let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected }
|
||||||
|
for fav in offlineFavorites {
|
||||||
|
let fp = unifiedPeerService.getFingerprint(for: fav.id)
|
||||||
|
let isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false
|
||||||
|
let fpShort = fp?.prefix(8) ?? "nil"
|
||||||
|
SecureLogger.log("⭐️ Favorite offline: \(fav.nickname) fp=\(fpShort) verified=\(isVer)", category: SecureLogger.security, level: .info)
|
||||||
|
}
|
||||||
|
// Invalidate cached encryption statuses so offline favorites can show verified badges immediately
|
||||||
|
invalidateEncryptionCache()
|
||||||
|
// Trigger UI refresh of peer list
|
||||||
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNoiseCallbacks() {
|
private func setupNoiseCallbacks() {
|
||||||
@@ -3766,6 +3810,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a QR verification is pending but not sent yet, send it now that session is authenticated
|
||||||
|
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
|
||||||
|
self.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: pending.noiseKeyHex, nonceA: pending.nonceA)
|
||||||
|
pending.sent = true
|
||||||
|
self.pendingQRVerifications[peerID] = pending
|
||||||
|
SecureLogger.log("📤 Sent deferred verify challenge to \(peerID) after handshake", category: SecureLogger.security, level: .debug)
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule UI update
|
// Schedule UI update
|
||||||
// UI will update automatically
|
// UI will update automatically
|
||||||
}
|
}
|
||||||
@@ -3869,9 +3921,72 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .verifyChallenge, .verifyResponse:
|
case .verifyChallenge:
|
||||||
// Not yet handled over BLE; ignore
|
// Parse and respond
|
||||||
break
|
guard let tlv = VerificationService.shared.parseVerifyChallenge(payload) else { return }
|
||||||
|
// Ensure intended for our noise key
|
||||||
|
let myNoiseHex = meshService.getNoiseService().getStaticPublicKeyData().hexEncodedString().lowercased()
|
||||||
|
guard tlv.noiseKeyHex.lowercased() == myNoiseHex else { return }
|
||||||
|
// Deduplicate: ignore if we've already responded to this nonce for this peer
|
||||||
|
if let last = lastVerifyNonceByPeer[peerID], last == tlv.nonceA { return }
|
||||||
|
lastVerifyNonceByPeer[peerID] = tlv.nonceA
|
||||||
|
// Record inbound challenge time keyed by stable fingerprint if available
|
||||||
|
if let fp = getFingerprint(for: peerID) {
|
||||||
|
lastInboundVerifyChallengeAt[fp] = Date()
|
||||||
|
// If we've already verified this fingerprint locally, treat this as mutual and toast immediately (responder side)
|
||||||
|
if verifiedFingerprints.contains(fp) {
|
||||||
|
let now = Date()
|
||||||
|
let last = lastMutualToastAt[fp] ?? .distantPast
|
||||||
|
if now.timeIntervalSince(last) > 60 { // 1-minute throttle
|
||||||
|
lastMutualToastAt[fp] = now
|
||||||
|
let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID)
|
||||||
|
NotificationService.shared.sendLocalNotification(
|
||||||
|
title: "Mutual verification",
|
||||||
|
body: "You and \(name) verified each other",
|
||||||
|
identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meshService.sendVerifyResponse(to: peerID, noiseKeyHex: tlv.noiseKeyHex, nonceA: tlv.nonceA)
|
||||||
|
// Silent response: no toast needed on responder
|
||||||
|
case .verifyResponse:
|
||||||
|
guard let resp = VerificationService.shared.parseVerifyResponse(payload) else { return }
|
||||||
|
// Check pending for this peer
|
||||||
|
guard let pending = pendingQRVerifications[peerID] else { return }
|
||||||
|
guard resp.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), resp.nonceA == pending.nonceA else { return }
|
||||||
|
// Verify signature with expected sign key
|
||||||
|
let ok = VerificationService.shared.verifyResponseSignature(noiseKeyHex: resp.noiseKeyHex, nonceA: resp.nonceA, signature: resp.signature, signerPublicKeyHex: pending.signKeyHex)
|
||||||
|
if ok {
|
||||||
|
pendingQRVerifications.removeValue(forKey: peerID)
|
||||||
|
if let fp = getFingerprint(for: peerID) {
|
||||||
|
let short = fp.prefix(8)
|
||||||
|
SecureLogger.log("🔐 Marking verified fingerprint: \(short)", category: SecureLogger.security, level: .info)
|
||||||
|
SecureIdentityStateManager.shared.setVerified(fingerprint: fp, verified: true)
|
||||||
|
SecureIdentityStateManager.shared.forceSave()
|
||||||
|
verifiedFingerprints.insert(fp)
|
||||||
|
let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID)
|
||||||
|
NotificationService.shared.sendLocalNotification(
|
||||||
|
title: "Verified",
|
||||||
|
body: "You verified \(name)",
|
||||||
|
identifier: "verify-success-\(peerID)-\(UUID().uuidString)"
|
||||||
|
)
|
||||||
|
// If we also recently responded to their challenge, flag mutual and toast (initiator side)
|
||||||
|
if let t = lastInboundVerifyChallengeAt[fp], Date().timeIntervalSince(t) < 600 {
|
||||||
|
let now = Date()
|
||||||
|
let lastToast = lastMutualToastAt[fp] ?? .distantPast
|
||||||
|
if now.timeIntervalSince(lastToast) > 60 {
|
||||||
|
lastMutualToastAt[fp] = now
|
||||||
|
NotificationService.shared.sendLocalNotification(
|
||||||
|
title: "Mutual verification",
|
||||||
|
body: "You and \(name) verified each other",
|
||||||
|
identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateEncryptionStatus(for: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3897,6 +4012,36 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - QR Verification API
|
||||||
|
@MainActor
|
||||||
|
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||||
|
// Find a matching peer by Noise key
|
||||||
|
let targetNoise = qr.noiseKeyHex.lowercased()
|
||||||
|
guard let peer = unifiedPeerService.peers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let peerID = peer.id
|
||||||
|
// If we already have a pending verification with this peer, don't send another
|
||||||
|
if pendingQRVerifications[peerID] != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Generate nonceA
|
||||||
|
var nonce = Data(count: 16)
|
||||||
|
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||||
|
var pending = PendingVerification(noiseKeyHex: qr.noiseKeyHex, signKeyHex: qr.signKeyHex, nonceA: nonce, startedAt: Date(), sent: false)
|
||||||
|
pendingQRVerifications[peerID] = pending
|
||||||
|
// If Noise session is established, send immediately; otherwise trigger handshake and send on auth
|
||||||
|
let noise = meshService.getNoiseService()
|
||||||
|
if noise.hasEstablishedSession(with: peerID) {
|
||||||
|
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
|
||||||
|
pending.sent = true
|
||||||
|
pendingQRVerifications[peerID] = pending
|
||||||
|
} else {
|
||||||
|
meshService.triggerHandshake(with: peerID)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// Mention parsing moved from BLE – use the existing non-optional helper below
|
// Mention parsing moved from BLE – use the existing non-optional helper below
|
||||||
// MARK: - Peer Connection Events
|
// MARK: - Peer Connection Events
|
||||||
|
|
||||||
@@ -4913,10 +5058,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
||||||
@MainActor
|
@MainActor
|
||||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||||
// Look up current visible geohash participants for an exact displayName match
|
// Look up current visible geohash participants for an exact displayName match (iOS only)
|
||||||
|
#if os(iOS)
|
||||||
for p in visibleGeohashPeople() {
|
for p in visibleGeohashPeople() {
|
||||||
if p.displayName == name { return p.id }
|
if p.displayName == name { return p.id }
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ struct ContentView: View {
|
|||||||
@State private var scrollThrottleTimer: Timer?
|
@State private var scrollThrottleTimer: Timer?
|
||||||
@State private var autocompleteDebounceTimer: Timer?
|
@State private var autocompleteDebounceTimer: Timer?
|
||||||
@State private var showLocationChannelsSheet = false
|
@State private var showLocationChannelsSheet = false
|
||||||
@State private var showMyQRSheet = false
|
@State private var showVerifySheet = false
|
||||||
@State private var showScanQRSheet = false
|
|
||||||
@State private var expandedMessageIDs: Set<String> = []
|
@State private var expandedMessageIDs: Set<String> = []
|
||||||
// Window sizes for rendering (infinite scroll up)
|
// Window sizes for rendering (infinite scroll up)
|
||||||
@State private var windowCountPublic: Int = 300
|
@State private var windowCountPublic: Int = 300
|
||||||
@@ -936,6 +935,24 @@ struct ContentView: View {
|
|||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
// Show QR only on mesh channel's peer list
|
||||||
|
#if os(iOS)
|
||||||
|
if case .mesh = locationManager.selectedChannel {
|
||||||
|
Button(action: { showVerifySheet = true }) {
|
||||||
|
Image(systemName: "qrcode")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Verification: show my QR or scan a friend")
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
Button(action: { showVerifySheet = true }) {
|
||||||
|
Image(systemName: "qrcode")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Verification: show my QR or scan a friend")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.frame(height: 44) // Match header height
|
.frame(height: 44) // Match header height
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -1227,20 +1244,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.foregroundColor(headerCountColor)
|
.foregroundColor(headerCountColor)
|
||||||
|
|
||||||
// QR actions
|
// QR moved to the PEOPLE header in the sidebar when on mesh channel
|
||||||
Button(action: { showMyQRSheet = true }) {
|
|
||||||
Image(systemName: "qrcode")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.help("Show my verification QR")
|
|
||||||
|
|
||||||
Button(action: { showScanQRSheet = true }) {
|
|
||||||
Image(systemName: "qrcode.viewfinder")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.help("Scan a friend's QR to verify")
|
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
@@ -1248,13 +1252,9 @@ struct ContentView: View {
|
|||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showMyQRSheet) {
|
.sheet(isPresented: $showVerifySheet) {
|
||||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
VerificationSheetView(isPresented: $showVerifySheet)
|
||||||
let qr = VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub)
|
.environmentObject(viewModel)
|
||||||
MyQRView(qrString: qr ?? "")
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showScanQRSheet) {
|
|
||||||
QRScanView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button("DONE") {
|
Button(action: { dismiss() }) {
|
||||||
dismiss()
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
}
|
}
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
}
|
}
|
||||||
@@ -165,6 +166,20 @@ struct FingerprintView: View {
|
|||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
} else {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.unverifyFingerprint(for: peerID)
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
Text("REMOVE VERIFICATION")
|
||||||
|
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.red)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
|
|||||||
@@ -85,10 +85,27 @@ struct MeshPeerList: View {
|
|||||||
.help("Blocked")
|
.help("Blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let icon = item.enc.icon, !isMe {
|
if !isMe {
|
||||||
Image(systemName: icon)
|
if peer.isConnected {
|
||||||
.font(.system(size: 10))
|
if let icon = item.enc.icon {
|
||||||
.foregroundColor(baseColor)
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(baseColor)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Offline: prefer showing verified badge from persisted fingerprints
|
||||||
|
if let fp = viewModel.getFingerprint(for: peer.id),
|
||||||
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
|
Image(systemName: "checkmark.seal.fill")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(baseColor)
|
||||||
|
} else if let icon = item.enc.icon {
|
||||||
|
// Fallback to whatever status says (likely lock if we had a past session)
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(baseColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@@ -10,36 +10,32 @@ import AppKit
|
|||||||
/// Placeholder view to display the user's verification QR payload as text.
|
/// Placeholder view to display the user's verification QR payload as text.
|
||||||
struct MyQRView: View {
|
struct MyQRView: View {
|
||||||
let qrString: String
|
let qrString: String
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text("Scan to verify me")
|
Text("scan to verify me")
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||||
|
|
||||||
QRCodeImage(data: qrString, size: 240)
|
VStack(spacing: 10) {
|
||||||
.accessibilityLabel("verification QR code")
|
QRCodeImage(data: qrString, size: 240)
|
||||||
|
.accessibilityLabel("verification qr code")
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
// Non-scrolling, fully visible URL (wraps across lines)
|
||||||
Button("Copy Link") {
|
|
||||||
#if os(iOS)
|
|
||||||
UIPasteboard.general.string = qrString
|
|
||||||
#else
|
|
||||||
NSPasteboard.general.clearContents()
|
|
||||||
NSPasteboard.general.setString(qrString, forType: .string)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
}
|
|
||||||
|
|
||||||
ScrollView {
|
|
||||||
Text(qrString)
|
Text(qrString)
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.system(size: 11, design: .monospaced))
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(Color.gray.opacity(0.1))
|
.background(boxColor)
|
||||||
.cornerRadius(6)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
.frame(maxHeight: 120)
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(boxColor)
|
||||||
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
@@ -63,7 +59,7 @@ struct QRCodeImage: View {
|
|||||||
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.overlay(
|
.overlay(
|
||||||
Text("QR unavailable")
|
Text("qr unavailable")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
)
|
)
|
||||||
@@ -102,49 +98,42 @@ struct ImageWrapper: View {
|
|||||||
|
|
||||||
/// Placeholder scanner UI; real camera scanning will be added later.
|
/// Placeholder scanner UI; real camera scanning will be added later.
|
||||||
struct QRScanView: View {
|
struct QRScanView: View {
|
||||||
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
|
var isActive: Bool = true
|
||||||
@State private var input = ""
|
@State private var input = ""
|
||||||
@State private var result: String = ""
|
@State private var result: String = "" // not shown for iOS scanner
|
||||||
@State private var lastValid: String = ""
|
@State private var lastValid: String = ""
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
Text("Scan a friend's QR")
|
CameraScannerView(isActive: isActive) { code in
|
||||||
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
|
||||||
CameraScannerView { code in
|
|
||||||
if let qr = VerificationService.shared.verifyScannedQR(code) {
|
if let qr = VerificationService.shared.verifyScannedQR(code) {
|
||||||
result = "Valid QR: \(qr.nickname)"
|
let ok = viewModel.beginQRVerification(with: qr)
|
||||||
|
if !ok { /* already pending; continue scanning */ }
|
||||||
lastValid = code
|
lastValid = code
|
||||||
} else {
|
} else {
|
||||||
result = "Invalid or expired QR"
|
// ignore invalid reads; continue scanning
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 260)
|
.frame(height: 260)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray.opacity(0.3), lineWidth: 1))
|
|
||||||
#else
|
#else
|
||||||
Text("Paste QR content to validate:")
|
Text("paste qr content to validate:")
|
||||||
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
||||||
TextEditor(text: $input)
|
TextEditor(text: $input)
|
||||||
.frame(height: 100)
|
.frame(height: 100)
|
||||||
.border(Color.gray.opacity(0.4))
|
.border(Color.gray.opacity(0.4))
|
||||||
Button("Validate") {
|
Button("validate") {
|
||||||
if let _ = VerificationService.shared.verifyScannedQR(input) {
|
if let qr = VerificationService.shared.verifyScannedQR(input) {
|
||||||
result = "Valid QR payload"
|
let ok = viewModel.beginQRVerification(with: qr)
|
||||||
|
result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer"
|
||||||
} else {
|
} else {
|
||||||
result = "Invalid or expired QR payload"
|
result = "invalid or expired qr payload"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
#endif
|
#endif
|
||||||
Text(result)
|
// No status text under camera per design
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(result.contains("Valid") ? .green : .orange)
|
|
||||||
if !lastValid.isEmpty {
|
|
||||||
Text(lastValid)
|
|
||||||
.font(.system(size: 10, design: .monospaced))
|
|
||||||
.textSelection(.enabled)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -156,15 +145,19 @@ import AVFoundation
|
|||||||
|
|
||||||
struct CameraScannerView: UIViewRepresentable {
|
struct CameraScannerView: UIViewRepresentable {
|
||||||
typealias UIViewType = PreviewView
|
typealias UIViewType = PreviewView
|
||||||
|
var isActive: Bool
|
||||||
var onCode: (String) -> Void
|
var onCode: (String) -> Void
|
||||||
|
|
||||||
func makeUIView(context: Context) -> PreviewView {
|
func makeUIView(context: Context) -> PreviewView {
|
||||||
let view = PreviewView()
|
let view = PreviewView()
|
||||||
context.coordinator.setup(sessionOwner: view, onCode: onCode)
|
context.coordinator.setup(sessionOwner: view, onCode: onCode)
|
||||||
|
context.coordinator.setActive(isActive)
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: PreviewView, context: Context) {}
|
func updateUIView(_ uiView: PreviewView, context: Context) {
|
||||||
|
context.coordinator.setActive(isActive)
|
||||||
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||||
|
|
||||||
@@ -172,6 +165,9 @@ struct CameraScannerView: UIViewRepresentable {
|
|||||||
private var onCode: ((String) -> Void)?
|
private var onCode: ((String) -> Void)?
|
||||||
private weak var owner: PreviewView?
|
private weak var owner: PreviewView?
|
||||||
private let session = AVCaptureSession()
|
private let session = AVCaptureSession()
|
||||||
|
private var isRunning = false
|
||||||
|
private var permissionGranted = false
|
||||||
|
private var desiredActive = false
|
||||||
|
|
||||||
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
|
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
|
||||||
self.owner = sessionOwner
|
self.owner = sessionOwner
|
||||||
@@ -193,8 +189,25 @@ struct CameraScannerView: UIViewRepresentable {
|
|||||||
sessionOwner.videoPreviewLayer.session = session
|
sessionOwner.videoPreviewLayer.session = session
|
||||||
// Request permission and start
|
// Request permission and start
|
||||||
AVCaptureDevice.requestAccess(for: .video) { granted in
|
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||||
DispatchQueue.main.async {
|
self.permissionGranted = granted
|
||||||
if granted { self.session.startRunning() }
|
if granted && self.desiredActive && !self.isRunning {
|
||||||
|
self.setActive(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setActive(_ active: Bool) {
|
||||||
|
desiredActive = active
|
||||||
|
guard permissionGranted else { return }
|
||||||
|
if active && !isRunning {
|
||||||
|
isRunning = true
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
if !self.session.isRunning { self.session.startRunning() }
|
||||||
|
}
|
||||||
|
} else if !active && isRunning {
|
||||||
|
isRunning = false
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
if self.session.isRunning { self.session.stopRunning() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,3 +233,115 @@ struct CameraScannerView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Combined sheet: shows my QR by default with a button to scan instead
|
||||||
|
struct VerificationSheetView: View {
|
||||||
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
@State private var showingScanner = false
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white }
|
||||||
|
private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }
|
||||||
|
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||||
|
|
||||||
|
private func myQRString() -> String {
|
||||||
|
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||||
|
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Top header (always at top)
|
||||||
|
HStack {
|
||||||
|
Text("VERIFY")
|
||||||
|
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
showingScanner = false
|
||||||
|
isPresented = false
|
||||||
|
}) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Content area
|
||||||
|
Group {
|
||||||
|
if showingScanner {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("scan a friend's qr")
|
||||||
|
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
#if os(iOS)
|
||||||
|
QRScanView(isActive: showingScanner)
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
.frame(height: 280)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
#else
|
||||||
|
QRScanView()
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(boxColor)
|
||||||
|
.cornerRadius(8)
|
||||||
|
} else {
|
||||||
|
let qr = myQRString()
|
||||||
|
MyQRView(qrString: qr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
|
||||||
|
// Centered controls moved up
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
if showingScanner {
|
||||||
|
Button(action: { showingScanner = false }) {
|
||||||
|
Label("show my qr", systemImage: "qrcode")
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
} else {
|
||||||
|
Button(action: { showingScanner = true }) {
|
||||||
|
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
||||||
|
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.gray)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Remove verification for selected peer (if verified)
|
||||||
|
if let pid = viewModel.selectedPrivateChatPeer,
|
||||||
|
let fp = viewModel.getFingerprint(for: pid),
|
||||||
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
|
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||||
|
Label("remove verification", systemImage: "minus.circle")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
#if os(iOS)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
#endif
|
||||||
|
.onDisappear { showingScanner = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user