Feature/nostr embedded bitchat (#448)

* UI: prefer mesh radio icon when connected; map short peer ID to full Noise key; rename ephemeral mapping to shortIDToNoiseKey; ensure header flips to purple globe on disconnect and keeps name.

* chore: stop tracking build artifacts; ignore .DerivedData and .Result*

* logging: add global threshold via BITCHAT_LOG_LEVEL and demote chatty logs to debug; keep critical errors/warnings and key state transitions

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-17 11:25:42 +02:00
committed by GitHub
co-authored by jack
parent fb1988ac27
commit 3ebfa85e90
9 changed files with 510 additions and 308 deletions
+5
View File
@@ -18,6 +18,7 @@ xcuserdata/
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
.DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
@@ -65,3 +66,7 @@ __pycache__/
## Temporary files
*.tmp
*.temp
# Local build results
.Result*/
.Result*.xcresult/
+4 -7
View File
@@ -282,16 +282,13 @@ class PeerManager: ObservableObject {
self.favorites = favorites
self.mutualFavorites = mutualFavorites
// Always log favorites debug info when there are favorites
// Log peer list summary sparingly at debug level
if favoritesService.favorites.count > 0 {
SecureLogger.log("📊 Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual",
category: SecureLogger.session, level: .info)
// Detailed peer status logging removed for brevity
category: SecureLogger.session, level: .debug)
} else if previousCount != allPeers.count {
// Only log non-favorite updates if count changed
SecureLogger.log("✅ Updated peer list: \(allPeers.count) total peers",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
}
}
@@ -307,4 +304,4 @@ class PeerManager: ObservableObject {
}
updatePeers()
}
}
}
+7 -7
View File
@@ -92,7 +92,7 @@ class NoiseSession {
func processHandshakeMessage(_ message: Data) throws -> Data? {
return try sessionQueue.sync(flags: .barrier) {
SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .info)
SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .debug)
// Initialize handshake state if needed (for responders)
if state == .uninitialized && role == .responder {
@@ -103,7 +103,7 @@ class NoiseSession {
remoteStaticKey: nil
)
state = .handshaking
SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .info)
SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .debug)
}
guard case .handshaking = state, let handshake = handshakeState else {
@@ -112,7 +112,7 @@ class NoiseSession {
// Process incoming message
_ = try handshake.readMessage(message)
SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .info)
SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .debug)
// Check if handshake is complete
if handshake.isHandshakeComplete() {
@@ -130,7 +130,7 @@ class NoiseSession {
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .info)
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .debug)
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
return nil
@@ -138,7 +138,7 @@ class NoiseSession {
// Generate response
let response = try handshake.writeMessage()
sentHandshakeMessages.append(response)
SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .info)
SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .debug)
// Check if handshake is complete after writing
if handshake.isHandshakeComplete() {
@@ -156,7 +156,7 @@ class NoiseSession {
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .info)
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .debug)
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
}
@@ -474,4 +474,4 @@ enum NoiseSessionError: Error {
case sessionNotFound
case handshakeFailed(Error)
case alreadyEstablished
}
}
@@ -485,7 +485,7 @@ class NoiseEncryptionService {
// Attempt to rekey the session
do {
try sessionManager.initiateRekey(for: peerID)
SecureLogger.log("Key rotation initiated for peer: \(peerID)", category: SecureLogger.security, level: .info)
SecureLogger.log("Key rotation initiated for peer: \(peerID)", category: SecureLogger.security, level: .debug)
// Signal that handshake is needed
onHandshakeRequired?(peerID)
+120 -30
View File
@@ -152,7 +152,7 @@ final class SimplifiedBluetoothService: NSObject {
// This ensures we send pending messages only when session is truly established
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
SecureLogger.log("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...",
category: SecureLogger.noise, level: .info)
category: SecureLogger.noise, level: .debug)
// Send any messages that were queued during handshake
self?.messageQueue.async { [weak self] in
self?.sendPendingMessagesAfterHandshake(for: peerID)
@@ -341,7 +341,7 @@ final class SimplifiedBluetoothService: NSObject {
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
SecureLogger.log("🔔 sendFavoriteNotification called - peerID: \(peerID), isFavorite: \(isFavorite)",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
// Include Nostr public key in the notification
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
@@ -350,11 +350,11 @@ final class SimplifiedBluetoothService: NSObject {
if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() {
content += ":" + myNostrIdentity.npub
SecureLogger.log("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
}
SecureLogger.log("📤 Sending favorite notification to \(peerID): \(content)",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString)
}
@@ -366,7 +366,7 @@ final class SimplifiedBluetoothService: NSObject {
}
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
// Create read receipt payload: [type byte] + [message ID]
var receiptPayload = Data([NoisePayloadType.readReceipt.rawValue])
@@ -496,7 +496,7 @@ final class SimplifiedBluetoothService: NSObject {
// MARK: - Private Message Handling
private func sendPrivateMessage(_ content: String, to recipientID: String, messageID: String) {
SecureLogger.log("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: SecureLogger.session, level: .info)
SecureLogger.log("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: SecureLogger.session, level: .debug)
// Check if we have an established Noise session
if noiseService.hasEstablishedSession(with: recipientID) {
@@ -558,7 +558,7 @@ final class SimplifiedBluetoothService: NSObject {
}
} else {
// Queue message for sending after handshake completes
SecureLogger.log("🤝 No session with \(recipientID), initiating handshake and queueing message", category: SecureLogger.session, level: .info)
SecureLogger.log("🤝 No session with \(recipientID), initiating handshake and queueing message", category: SecureLogger.session, level: .debug)
// Queue the message (especially important for favorite notifications)
collectionsQueue.sync(flags: .barrier) {
@@ -618,7 +618,7 @@ final class SimplifiedBluetoothService: NSObject {
guard let messages = pendingMessages, !messages.isEmpty else { return }
SecureLogger.log("📤 Sending \(messages.count) pending messages after handshake to \(peerID)",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
// Send each pending message directly (we know session is established)
for (content, messageID) in messages {
@@ -650,7 +650,7 @@ final class SimplifiedBluetoothService: NSObject {
}
SecureLogger.log("✅ Sent pending message \(messageID) to \(peerID) after handshake",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
} catch {
SecureLogger.log("Failed to send pending message after handshake: \(error)",
category: SecureLogger.noise, level: .error)
@@ -675,7 +675,7 @@ final class SimplifiedBluetoothService: NSObject {
// Log encrypted and relayed packets for debugging
if packet.type == MessageType.noiseEncrypted.rawValue {
SecureLogger.log("📡 Encrypted packet to \(packet.recipientID?.hexEncodedString() ?? "unknown")",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
} else if packet.ttl < messageTTL {
// Relayed packet
}
@@ -1042,11 +1042,11 @@ final class SimplifiedBluetoothService: NSObject {
// Log connection status
if existingPeer == nil {
SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .info)
SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug)
} else if wasDisconnected {
SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .info)
SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug)
} else if existingPeer?.nickname != announcement.nickname {
SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .info)
SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug)
}
}
@@ -1117,7 +1117,7 @@ final class SimplifiedBluetoothService: NSObject {
}
let senderNickname = peers[peerID]?.nickname ?? "Unknown"
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .info)
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug)
// Parse mentions from the message content
let mentions = parseMentions(from: content)
@@ -1231,7 +1231,7 @@ final class SimplifiedBluetoothService: NSObject {
mentions: mentions.isEmpty ? nil : mentions
)
SecureLogger.log("🔓 Decrypted TLV PM from \(message.sender): \(messageContent.prefix(30))...", category: SecureLogger.session, level: .info)
SecureLogger.log("🔓 Decrypted TLV PM from \(message.sender): \(messageContent.prefix(30))...", category: SecureLogger.session, level: .debug)
// Send on main thread
notifyUI { [weak self] in
@@ -1262,7 +1262,7 @@ final class SimplifiedBluetoothService: NSObject {
guard let messageID = String(data: payloadData, encoding: .utf8) else { return }
SecureLogger.log("📖 Received READ receipt for message \(messageID) from \(peerID)",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
// Update read status
notifyUI { [weak self] in
@@ -1277,7 +1277,7 @@ final class SimplifiedBluetoothService: NSObject {
// We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted.
SecureLogger.log("🔑 Encrypted message from \(peerID) without session; initiating handshake",
category: SecureLogger.noise, level: .info)
category: SecureLogger.noise, level: .debug)
if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID)
}
@@ -1308,7 +1308,7 @@ final class SimplifiedBluetoothService: NSObject {
// MARK: - Helper Functions
private func sendLeave() {
SecureLogger.log("👋 Sending leave announcement", category: SecureLogger.session, level: .info)
SecureLogger.log("👋 Sending leave announcement", category: SecureLogger.session, level: .debug)
let packet = BitchatPacket(
type: MessageType.leave.rawValue,
ttl: messageTTL,
@@ -1460,7 +1460,7 @@ final class SimplifiedBluetoothService: NSObject {
if !hasPeripheralConnection && !hasCentralConnection {
// Remove the peer completely (they'll be re-added when they reconnect)
SecureLogger.log("⏱️ Peer timed out (no packets for 20s): \(peerID) (\(peer.nickname))",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
peers.removeValue(forKey: peerID)
disconnectedPeers.append(peerID)
}
@@ -1584,7 +1584,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate {
// Connect to the peripheral with options for faster connection
SecureLogger.log("📱 Connect: \(advertisedName) [RSSI:\(rssiValue)]",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
// Use connection options for faster reconnection
let options: [String: Any] = [
@@ -1627,7 +1627,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate {
)
}
SecureLogger.log("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: SecureLogger.session, level: .info)
SecureLogger.log("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: SecureLogger.session, level: .debug)
// Discover services
peripheral.discoverServices([SimplifiedBluetoothService.serviceUUID])
@@ -1640,7 +1640,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate {
let peerID = peripherals[peripheralID]?.peerID
SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
// Clean up references
peripherals.removeValue(forKey: peripheralID)
@@ -1895,13 +1895,13 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
return
}
SecureLogger.log("✅ Service added successfully, starting advertising", category: SecureLogger.session, level: .info)
SecureLogger.log("✅ Service added successfully, starting advertising", category: SecureLogger.session, level: .debug)
// Start advertising after service is confirmed added
let adData = buildAdvertisementData()
peripheral.startAdvertising(adData)
SecureLogger.log("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.prefix(8))…)", category: SecureLogger.session, level: .info)
SecureLogger.log("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.prefix(8))…)", category: SecureLogger.session, level: .debug)
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
@@ -1913,12 +1913,12 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
SecureLogger.log("📤 Central unsubscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .info)
SecureLogger.log("📤 Central unsubscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug)
subscribedCentrals.removeAll { $0.identifier == central.identifier }
// Ensure we're still advertising for other devices to find us
if peripheral.isAdvertising == false {
SecureLogger.log("📡 Restarting advertising after central unsubscribed", category: SecureLogger.session, level: .info)
SecureLogger.log("📡 Restarting advertising after central unsubscribed", category: SecureLogger.session, level: .debug)
peripheral.startAdvertising(buildAdvertisementData())
}
@@ -1948,7 +1948,7 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
}
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
SecureLogger.log("📤 Peripheral manager ready to send more notifications", category: SecureLogger.session, level: .info)
SecureLogger.log("📤 Peripheral manager ready to send more notifications", category: SecureLogger.session, level: .debug)
// Retry pending notifications now that queue has space
collectionsQueue.async(flags: .barrier) { [weak self] in
@@ -1995,7 +1995,7 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
// Suppress logs for single write requests to reduce noise
if requests.count > 1 {
SecureLogger.log("📥 Received \(requests.count) write requests from central", category: SecureLogger.session, level: .info)
SecureLogger.log("📥 Received \(requests.count) write requests from central", category: SecureLogger.session, level: .debug)
}
// IMPORTANT: Respond immediately to prevent timeouts!
@@ -2022,7 +2022,7 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
let senderID = dataToHexString(packet.senderID)
// Only log non-announce packets
if packet.type != MessageType.announce.rawValue {
SecureLogger.log("📦 Decoded packet type: \(packet.type) from sender: \(senderID)", category: SecureLogger.session, level: .info)
SecureLogger.log("📦 Decoded packet type: \(packet.type) from sender: \(senderID)", category: SecureLogger.session, level: .debug)
}
// Store central in our list if not already there
@@ -2089,7 +2089,7 @@ extension SimplifiedBluetoothService {
}
private func buildAdvertisementData() -> [String: Any] {
var data: [String: Any] = [
let data: [String: Any] = [
CBAdvertisementDataServiceUUIDsKey: [SimplifiedBluetoothService.serviceUUID]
]
// No Local Name for privacy
@@ -2099,6 +2099,96 @@ extension SimplifiedBluetoothService {
// No alias rotation or advertising restarts required.
}
// MARK: - Nostr Embedding Helpers
extension SimplifiedBluetoothService {
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message
/// for transport over Nostr DMs. The payload is a plaintext typed NoisePayload
/// (no inner Noise encryption; NIP-17 provides transport-layer E2E).
func buildNostrEmbeddedPrivateMessageContent(content: String, to recipientPeerID: String, messageID: String) -> String? {
// TLV-encode the private message
let pm = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlv = pm.encode() else { return nil }
// Prefix with NoisePayloadType
var payload = Data([NoisePayloadType.privateMessage.rawValue])
payload.append(tlv)
// Build BitChat packet (noiseEncrypted type used as a typed envelope)
// Determine correct 8-byte recipient ID (peerID) to embed
let recipientIDHex: String = {
if let maybeData = Data(hexString: recipientPeerID) {
if maybeData.count == 32 {
// Treat as Noise static public key; derive peerID from fingerprint
return Self.derivePeerID(fromPublicKey: maybeData)
} else if maybeData.count == 8 {
// Already an 8-byte peer ID
return recipientPeerID
}
}
// Fallback (should not happen): use myPeerID to avoid dropping
return recipientPeerID.count == 16 ? recipientPeerID : myPeerID
}()
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: hexStringToData(myPeerID),
recipientID: hexStringToData(recipientIDHex),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
guard let data = packet.toBinaryData() else { return nil }
return "bitchat1:" + Self.base64URLEncode(data)
}
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack
/// for transport over Nostr DMs. Payload is plaintext typed NoisePayload.
func buildNostrEmbeddedAckContent(type: NoisePayloadType, messageID: String, to recipientPeerID: String) -> String? {
guard type == .delivered || type == .readReceipt else { return nil }
var payload = Data([type.rawValue])
payload.append(Data(messageID.utf8))
// Determine correct 8-byte recipient ID (peerID) to embed
let recipientIDHex: String = {
if let maybeData = Data(hexString: recipientPeerID) {
if maybeData.count == 32 {
return Self.derivePeerID(fromPublicKey: maybeData)
} else if maybeData.count == 8 {
return recipientPeerID
}
}
return recipientPeerID.count == 16 ? recipientPeerID : myPeerID
}()
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: hexStringToData(myPeerID),
recipientID: hexStringToData(recipientIDHex),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
guard let data = packet.toBinaryData() else { return nil }
return "bitchat1:" + Self.base64URLEncode(data)
}
/// Base64url encode without padding
private static func base64URLEncode(_ data: Data) -> String {
let b64 = data.base64EncodedString()
let urlSafe = b64
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return urlSafe
}
}
// MARK: - Message Deduplicator
/// Efficient message deduplication with time-based cleanup
+4 -4
View File
@@ -190,7 +190,7 @@ class UnifiedPeerService: ObservableObject {
SecureLogger.log(
"🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key",
category: SecureLogger.session,
level: .info
level: .debug
)
// Update the favorite's key in persistence
@@ -277,7 +277,7 @@ class UnifiedPeerService: ObservableObject {
// Debug logging to understand the issue
SecureLogger.log("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
if actualNickname.isEmpty {
// Try to get from mesh service's current peer list
@@ -312,7 +312,7 @@ class UnifiedPeerService: ObservableObject {
// Log the final nickname being saved
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
category: SecureLogger.session, level: .info)
category: SecureLogger.session, level: .debug)
// Send favorite notification to the peer
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
@@ -402,4 +402,4 @@ extension Data {
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}
}
+30
View File
@@ -58,6 +58,16 @@ class SecureLogger {
case error
case fault
fileprivate var order: Int {
switch self {
case .debug: return 0
case .info: return 1
case .warning: return 2
case .error: return 3
case .fault: return 4
}
}
var osLogType: OSLogType {
switch self {
case .debug: return .debug
@@ -68,6 +78,24 @@ class SecureLogger {
}
}
}
// MARK: - Global Threshold
/// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL.
private static let minimumLevel: LogLevel = {
let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased()
switch env {
case "debug": return .debug
case "warning": return .warning
case "error": return .error
case "fault": return .fault
default: return .info
}
}()
private static func shouldLog(_ level: LogLevel) -> Bool {
return level.order >= minimumLevel.order
}
// MARK: - Security Event Types
@@ -99,6 +127,7 @@ class SecureLogger {
/// Log a security event
static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
file: String = #file, line: Int = #line, function: String = #function) {
guard shouldLog(level) else { return }
let location = formatLocation(file: file, line: line, function: function)
let message = "\(location) \(event.message)"
@@ -113,6 +142,7 @@ class SecureLogger {
/// Log general messages with automatic sensitive data filtering
static func log(_ message: String, category: OSLog = noise, level: LogLevel = .debug,
file: String = #file, line: Int = #line, function: String = #function) {
guard shouldLog(level) else { return }
let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize("\(location) \(message)")
+313 -247
View File
@@ -151,6 +151,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Missing properties that were removed during refactoring
private var peerIDToPublicKeyFingerprint: [String: String] = [:]
private var selectedPrivateChatFingerprint: String? = nil
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
private var shortIDToNoiseKey: [String: String] = [:]
// Resolve full Noise key for a peer's short ID (used by UI header rendering)
@MainActor
func getNoiseKeyForShortID(_ shortPeerID: String) -> String? {
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
// Fallback: derive from active Noise session if available
if shortPeerID.count == 16,
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
let stable = key.hexEncodedString()
shortIDToNoiseKey[shortPeerID] = stable
return stable
}
return nil
}
private var peerIndex: [String: BitchatPeer] = [:]
// MARK: - Autocomplete Properties
@@ -848,7 +864,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else if isMutualFavorite && hasNostrKey,
let recipientNostrPubkey = favoriteStatus?.peerNostrPublicKey {
// Mutual favorite offline - send via Nostr
sendViaNostr(content, to: recipientNostrPubkey, messageId: messageID)
sendViaNostr(content, to: recipientNostrPubkey, recipientPeerID: peerID, messageId: messageID)
} else {
// Update delivery status to failed
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
@@ -1441,7 +1457,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay {
// Skip if we already sent an ACK for this message
if !sentReadReceipts.contains(message.id) {
sendNostrAcknowledgment(messageId: message.id, to: nostrPubkey, type: "READ")
// Use stable Noise key hex if available; else fall back to peerID
let recipPeer = (Data(hexString: peerID) != nil) ? peerID : (unifiedPeerService.getPeer(by: peerID)?.noisePublicKey.hexEncodedString() ?? peerID)
sendNostrAcknowledgment(messageId: message.id, to: nostrPubkey, type: "READ", recipientPeerID: recipPeer)
sentReadReceipts.insert(message.id)
}
}
@@ -2221,9 +2239,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
DispatchQueue.main.async {
guard let self = self else { return }
SecureLogger.log("🔐 Authenticated: \(peerID)", category: SecureLogger.security, level: .info)
SecureLogger.log("🔐 Authenticated: \(peerID)", category: SecureLogger.security, level: .debug)
// Update encryption status
if self.verifiedFingerprints.contains(fingerprint) {
self.peerEncryptionStatus[peerID] = .noiseVerified
@@ -2232,10 +2250,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.peerEncryptionStatus[peerID] = .noiseSecured
// Encryption: noiseSecured
}
// Invalidate cache when encryption status changes
self.invalidateEncryptionCache(for: peerID)
// Cache shortID -> full Noise key mapping as soon as session authenticates
if self.shortIDToNoiseKey[peerID] == nil,
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let stable = keyData.hexEncodedString()
self.shortIDToNoiseKey[peerID] = stable
SecureLogger.log("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))",
category: SecureLogger.session, level: .debug)
}
// Schedule UI update
// UI will update automatically
}
@@ -2323,6 +2350,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Force UI refresh
objectWillChange.send()
// Cache mapping to full Noise key for session continuity on disconnect
if let peer = unifiedPeerService.getPeer(by: peerID) {
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
shortIDToNoiseKey[peerID] = noiseKeyHex
}
}
// Connection messages removed to reduce chat noise
@@ -2333,6 +2366,47 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Remove ephemeral session from identity manager
SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID)
// If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite)
var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID]
if derivedStableKeyHex == nil,
let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {
derivedStableKeyHex = key.hexEncodedString()
shortIDToNoiseKey[peerID] = derivedStableKeyHex
}
if let current = selectedPrivateChatPeer, current == peerID,
let stableKeyHex = derivedStableKeyHex {
// Migrate messages view context to stable key so header shows favorite + Nostr globe
if let messages = privateChats[peerID] {
if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] }
let existing = Set(privateChats[stableKeyHex]!.map { $0.id })
for msg in messages where !existing.contains(msg.id) {
let updated = BitchatMessage(
id: msg.id,
sender: msg.sender,
content: msg.content,
timestamp: msg.timestamp,
isRelay: msg.isRelay,
originalSender: msg.originalSender,
isPrivate: msg.isPrivate,
recipientNickname: msg.recipientNickname,
senderPeerID: (msg.senderPeerID == meshService.myPeerID) ? meshService.myPeerID : stableKeyHex,
mentions: msg.mentions,
deliveryStatus: msg.deliveryStatus
)
privateChats[stableKeyHex]?.append(updated)
}
privateChats[stableKeyHex]?.sort { $0.timestamp < $1.timestamp }
privateChats.removeValue(forKey: peerID)
}
if unreadPrivateMessages.contains(peerID) {
unreadPrivateMessages.remove(peerID)
unreadPrivateMessages.insert(stableKeyHex)
}
selectedPrivateChatPeer = stableKeyHex
objectWillChange.send()
}
// Update peer list immediately and force UI refresh
DispatchQueue.main.async { [weak self] in
@@ -2615,7 +2689,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
@MainActor
private func sendViaNostr(_ content: String, to recipientNostrPubkey: String, messageId: String) {
private func sendViaNostr(_ content: String, to recipientNostrPubkey: String, recipientPeerID: String, messageId: String) {
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("No Nostr identity available", category: SecureLogger.session, level: .error)
return
@@ -2636,11 +2710,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
// Include sender nickname in message format for better identification
let structuredContent = "MSG:\(messageId):\(nickname):\(content)"
// Build embedded BitChat packet content (bitchat1:...)
guard let embeddedContent = meshService.buildNostrEmbeddedPrivateMessageContent(
content: content,
to: recipientPeerID,
messageID: messageId
) else {
SecureLogger.log("Failed to build embedded BitChat content for Nostr", category: SecureLogger.session, level: .error)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(
content: structuredContent,
content: embeddedContent,
recipientPubkey: recipientHexPubkey,
senderIdentity: senderIdentity
) else {
@@ -2653,7 +2734,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("Sent Nostr message via relay", category: SecureLogger.session, level: .info)
SecureLogger.log("Sent Nostr message via relay", category: SecureLogger.session, level: .debug)
// Update delivery status to sent for Nostr messages
// Need to find which peerID (ephemeral or Noise key) contains this message
@@ -2681,7 +2762,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
@MainActor
private func sendNostrAcknowledgment(messageId: String, to recipientNostrPubkey: String, type: String) {
private func sendNostrAcknowledgment(messageId: String, to recipientNostrPubkey: String, type: String, recipientPeerID: String) {
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("No Nostr identity for sending ACK", category: SecureLogger.session, level: .error)
return
@@ -2702,10 +2783,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
// Create ACK message format
let ackContent = "ACK:\(type):\(messageId)"
guard let event = try? NostrProtocol.createPrivateMessage(
// Build embedded BitChat ACK content
let ackType: NoisePayloadType? = (type == "DELIVERED") ? .delivered : (type == "READ" ? .readReceipt : nil)
guard let ackTypeUnwrapped = ackType,
let ackContent = meshService.buildNostrEmbeddedAckContent(type: ackTypeUnwrapped, messageID: messageId, to: recipientPeerID),
let event = try? NostrProtocol.createPrivateMessage(
content: ackContent,
recipientPubkey: recipientHexPubkey,
senderIdentity: senderIdentity
@@ -2769,213 +2851,171 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
recipientIdentity: currentIdentity
)
// Log timestamp difference for debugging
// Using rumor timestamp instead of gift wrap timestamp
// Handle favorite notifications
if content.hasPrefix("FAVORITED") || content.hasPrefix("UNFAVORITED") {
handleFavoriteNotification(content: content, from: senderPubkey)
// Expect embedded BitChat packet content
guard content.hasPrefix("bitchat1:") else {
SecureLogger.log("Ignoring non-embedded Nostr DM content", category: SecureLogger.session, level: .debug)
return
}
// Handle acknowledgments
if content.hasPrefix("ACK:") {
handleNostrAcknowledgment(content: content, from: senderPubkey)
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData) else {
SecureLogger.log("Failed to decode embedded BitChat packet from Nostr DM", category: SecureLogger.session, level: .error)
return
}
// Handle regular messages
var messageId = UUID().uuidString
var messageContent = content
var extractedNickname: String? = nil
if content.hasPrefix("MSG:") {
let parts = content.split(separator: ":", maxSplits: 3)
if parts.count >= 4 {
// New format with nickname: MSG:ID:NICKNAME:CONTENT
messageId = String(parts[1])
extractedNickname = String(parts[2])
messageContent = String(parts[3])
} else if parts.count >= 3 {
// Old format without nickname: MSG:ID:CONTENT
messageId = String(parts[1])
messageContent = String(parts[2])
// Only process typed noiseEncrypted envelope for private messages/receipts
guard packet.type == MessageType.noiseEncrypted.rawValue else {
SecureLogger.log("Unsupported embedded packet type: \(packet.type)", category: SecureLogger.session, level: .warning)
return
}
// Validate recipient
if let rid = packet.recipientID {
let ridHex = rid.map { String(format: "%02x", $0) }.joined()
if ridHex != meshService.myPeerID {
return
}
}
// Try to find sender's Noise key
// Parse plaintext typed payload
guard let noisePayload = NoisePayload.decode(packet.payload) else {
SecureLogger.log("Failed to parse embedded NoisePayload", category: SecureLogger.session, level: .error)
return
}
// Map sender by Nostr pubkey to Noise key when possible
let senderNoiseKey = findNoiseKey(for: senderPubkey)
// If we can't find the Noise key, try to match by nickname from the message content
// This can happen when receiving messages from someone not yet in favorites
if senderNoiseKey == nil {
SecureLogger.log("⚠️ Cannot find Noise key for Nostr sender: \(senderPubkey.prefix(16))..., will try nickname matching",
category: SecureLogger.session, level: .debug)
// Use extracted nickname if available
handleNostrMessageFromUnknownSender(
messageId: messageId,
content: messageContent,
senderPubkey: senderPubkey,
senderNickname: extractedNickname,
timestamp: Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
)
return
}
// We have the sender's Noise key, proceed normally
guard let actualSenderNoiseKey = senderNoiseKey else { return }
let senderNoiseKeyHex = actualSenderNoiseKey.hexEncodedString()
let senderNickname = FavoritesPersistenceService.shared.getFavoriteStatus(for: actualSenderNoiseKey)?.peerNickname ?? "Unknown"
let actualSenderNoiseKey = senderNoiseKey // may be nil
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
// For Nostr messages, always use the stable Noise key hex as the peer ID
// This ensures messages persist across ephemeral peer ID changes
let targetPeerID = senderNoiseKeyHex
// Processing message from sender
// Check if message already exists in local storage
var messageExistsLocally = false
// Check stable key location
if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
messageExistsLocally = true
}
// Check all ephemeral peer locations
if !messageExistsLocally {
for (_, messages) in privateChats {
if messages.contains(where: { $0.id == messageId }) {
messageExistsLocally = true
break
let senderNickname = (actualSenderNoiseKey != nil) ? (FavoritesPersistenceService.shared.getFavoriteStatus(for: actualSenderNoiseKey!)?.peerNickname ?? "Unknown") : "Unknown"
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
let targetPeerID = actualSenderNoiseKey?.hexEncodedString() ?? ("nostr_" + senderPubkey.prefix(16))
switch noisePayload.type {
case .privateMessage:
guard let (messageId, messageContent) = Self.decodePrivateMessageTLV(noisePayload.data) else { return }
// Favorite/unfavorite notifications embedded as private messages
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
if let key = actualSenderNoiseKey {
handleFavoriteNotificationFromMesh(messageContent, from: key.hexEncodedString(), senderNickname: senderNickname)
}
return
}
// Check for duplicate
var messageExistsLocally = false
if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
messageExistsLocally = true
}
if !messageExistsLocally {
for (_, messages) in privateChats {
if messages.contains(where: { $0.id == messageId }) { messageExistsLocally = true; break }
}
}
}
// If message already exists locally, skip entirely
if messageExistsLocally {
return // Skipping duplicate message
}
// Check if we've read this message before (in a previous session)
let wasReadBefore = sentReadReceipts.contains(messageId)
// Check if message is recent (for notification purposes)
let messageAgeSeconds = Date().timeIntervalSince(messageTimestamp)
let isRecentMessage = messageAgeSeconds < 30 // Message must be less than 30 seconds old
// Check if we're viewing this chat BEFORE adding the message
var isViewingThisChat = false
if selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true
} else if let selectedPeer = selectedPrivateChatPeer,
let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer),
selectedPeerData.noisePublicKey == actualSenderNoiseKey {
// We're viewing this chat via ephemeral ID
isViewingThisChat = true
}
// Determine if this should be marked as unread BEFORE adding to chats
// Only mark as unread if: not previously read AND not currently viewing
// During startup phase, only block OLD messages (>30s) from being marked as unread
// Recent messages should always be marked as unread if not previously read
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase)
// Handle based on read status
// Create message
let message = BitchatMessage(
id: messageId,
sender: senderNickname,
content: messageContent,
timestamp: messageTimestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nickname, // We are the recipient
senderPeerID: targetPeerID,
mentions: nil,
deliveryStatus: .delivered(to: nickname, at: Date())
)
// Add to private chat (not public messages!)
if privateChats[targetPeerID] == nil {
privateChats[targetPeerID] = []
}
privateChats[targetPeerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: targetPeerID)
// IMPORTANT: Also add to ephemeral peer chat if one is open
// Find any ephemeral peer ID that maps to this stable Noise key
if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in
peer.noisePublicKey == actualSenderNoiseKey
})?.id, ephemeralPeerID != targetPeerID {
// Also add the message to the ephemeral peer's chat
if privateChats[ephemeralPeerID] == nil {
privateChats[ephemeralPeerID] = []
if messageExistsLocally { return }
let wasReadBefore = sentReadReceipts.contains(messageId)
// Is viewing?
var isViewingThisChat = false
if selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true
} else if let selectedPeer = selectedPrivateChatPeer,
let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer),
let key = actualSenderNoiseKey,
selectedPeerData.noisePublicKey == key {
isViewingThisChat = true
}
// Check if message doesn't already exist to avoid duplicates
if !privateChats[ephemeralPeerID]!.contains(where: { $0.id == messageId }) {
privateChats[ephemeralPeerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID)
}
}
// Send delivery acknowledgment via Nostr only if we haven't read it before
// If we already read it, we've already sent DELIVERED (and probably READ) ACKs
if !wasReadBefore {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED")
}
// Handle notifications and read receipts
if wasReadBefore {
// Message was read in a previous session - don't mark as unread or notify
// Just add to chat history silently
// Not marking previously-read message as unread
} else if isViewingThisChat {
// We're viewing this chat - mark as read and send read receipt
unreadPrivateMessages.remove(targetPeerID)
// Also remove ephemeral ID from unread if present
if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in
peer.noisePublicKey == actualSenderNoiseKey
})?.id {
unreadPrivateMessages.remove(ephemeralPeerID)
}
// Send read acknowledgment
if !sentReadReceipts.contains(messageId) {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ")
sentReadReceipts.insert(messageId)
}
} else {
// Not viewing and not previously read
// Use pre-calculated shouldMarkAsUnread to avoid UI flicker
if shouldMarkAsUnread {
unreadPrivateMessages.insert(targetPeerID)
// Also mark ephemeral peer ID as unread if present
if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in
peer.noisePublicKey == actualSenderNoiseKey
})?.id, ephemeralPeerID != targetPeerID {
unreadPrivateMessages.insert(ephemeralPeerID)
}
// Only notify if it's a recent message
if isRecentMessage {
NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname,
message: messageContent,
peerID: targetPeerID
)
} else {
// Not notifying for old message
// Recency check
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase)
let message = BitchatMessage(
id: messageId,
sender: senderNickname,
content: messageContent,
timestamp: messageTimestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: targetPeerID,
mentions: nil,
deliveryStatus: .delivered(to: nickname, at: Date())
)
if privateChats[targetPeerID] == nil { privateChats[targetPeerID] = [] }
privateChats[targetPeerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: targetPeerID)
// Mirror to ephemeral if applicable
if let key = actualSenderNoiseKey,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id,
ephemeralPeerID != targetPeerID {
if privateChats[ephemeralPeerID] == nil { privateChats[ephemeralPeerID] = [] }
if !privateChats[ephemeralPeerID]!.contains(where: { $0.id == messageId }) {
privateChats[ephemeralPeerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID)
}
}
// Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key
if !wasReadBefore, let key = actualSenderNoiseKey {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED", recipientPeerID: key.hexEncodedString())
}
if wasReadBefore {
// do nothing
} else if isViewingThisChat {
unreadPrivateMessages.remove(targetPeerID)
if let key = actualSenderNoiseKey,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id {
unreadPrivateMessages.remove(ephemeralPeerID)
}
if !sentReadReceipts.contains(messageId), let key = actualSenderNoiseKey {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ", recipientPeerID: key.hexEncodedString())
sentReadReceipts.insert(messageId)
}
} else {
if shouldMarkAsUnread {
unreadPrivateMessages.insert(targetPeerID)
if let key = actualSenderNoiseKey,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id,
ephemeralPeerID != targetPeerID {
unreadPrivateMessages.insert(ephemeralPeerID)
}
if isRecentMessage {
NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname,
message: messageContent,
peerID: targetPeerID
)
}
}
}
objectWillChange.send()
case .delivered:
guard let messageID = String(data: noisePayload.data, encoding: .utf8) else { return }
let peerName = senderNickname
// Update status to delivered
if let messages = privateChats[targetPeerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[targetPeerID]?[idx].deliveryStatus = .delivered(to: peerName, at: Date())
objectWillChange.send()
}
case .readReceipt:
guard let messageID = String(data: noisePayload.data, encoding: .utf8) else { return }
let peerName = senderNickname
if let messages = privateChats[targetPeerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[targetPeerID]?[idx].deliveryStatus = .read(by: peerName, at: Date())
objectWillChange.send()
}
}
objectWillChange.send()
} catch {
SecureLogger.log("Failed to decrypt Nostr message: \(error)", category: SecureLogger.session, level: .error)
}
@@ -3050,6 +3090,37 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
category: SecureLogger.session, level: .warning)
}
}
// MARK: - Base64URL utils
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Add padding if needed
let rem = str.count % 4
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
return Data(base64Encoded: str)
}
// Decode PrivateMessagePacket TLV (local minimal decoder)
private static func decodePrivateMessageTLV(_ data: Data) -> (String, String)? {
var offset = 0
var messageID: String?
var content: String?
while offset + 2 <= data.count {
let type = data[offset]; offset += 1
let length = Int(data[offset]); offset += 1
guard offset + length <= data.count else { return nil }
let value = data[offset..<offset+length]
offset += length
if type == 0x00 {
messageID = String(data: value, encoding: .utf8)
} else if type == 0x01 {
content = String(data: value, encoding: .utf8)
}
}
if let id = messageID, let c = content { return (id, c) }
return nil
}
@MainActor
private func handleFavoriteNotificationFromMesh(_ content: String, from peerID: String, senderNickname: String) {
@@ -3178,10 +3249,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
privateChats[tempPeerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: tempPeerID)
// Send delivery acknowledgment only if we haven't read it before
if !wasReadBefore {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED")
}
// For unknown senders (no Noise key), skip sending Nostr ACKs
// Handle based on read status
if wasReadBefore {
@@ -3189,10 +3257,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Not marking previously-read message as unread
} else if isViewingThisChat {
// Viewing this chat - mark as read
if !sentReadReceipts.contains(messageId) {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ")
sentReadReceipts.insert(messageId)
}
// No read ACKs for unknown senders
} else {
// Not viewing and not previously read
// Use pre-calculated shouldMarkAsUnread to avoid UI flicker
@@ -3272,17 +3337,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)"
if let event = try? NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) {
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("Sent favorite notification via Nostr", category: SecureLogger.session, level: .debug)
}
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
let recipientPeerID = noisePublicKey.hexEncodedString()
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
let event = try? NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) else { return }
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("Sent favorite notification via Nostr", category: SecureLogger.session, level: .debug)
}
}
}
@@ -3305,7 +3370,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Try mesh first for connected peers
if meshService.isPeerConnected(peerID) {
meshService.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
SecureLogger.log("📤 Sent favorite notification via BLE to \(peerID)", category: SecureLogger.session, level: .info)
SecureLogger.log("📤 Sent favorite notification via BLE to \(peerID)", category: SecureLogger.session, level: .debug)
} else if let key = noiseKey,
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: key),
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
@@ -3315,21 +3380,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
return
}
let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)"
if let event = try? NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) {
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("📤 Sent favorite notification via Nostr to \(favoriteStatus.peerNickname)", category: SecureLogger.session, level: .info)
} else {
SecureLogger.log("❌ NostrRelayManager is nil - cannot send favorite notification", category: SecureLogger.session, level: .error)
}
} else {
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
let recipientPeerID = key.hexEncodedString()
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
let event = try? NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) else {
SecureLogger.log("❌ Failed to create Nostr message for favorite notification", category: SecureLogger.session, level: .error)
return
}
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("📤 Sent favorite notification via Nostr to \(favoriteStatus.peerNickname)", category: SecureLogger.session, level: .debug)
} else {
SecureLogger.log("❌ NostrRelayManager is nil - cannot send favorite notification", category: SecureLogger.session, level: .error)
}
} else {
SecureLogger.log("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: SecureLogger.session, level: .warning)
@@ -3471,7 +3537,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
/// Handle incoming private message
@MainActor
private func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.log("📥 handlePrivateMessage called for message from \(message.sender)", category: SecureLogger.session, level: .info)
SecureLogger.log("📥 handlePrivateMessage called for message from \(message.sender)", category: SecureLogger.session, level: .debug)
let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender)
guard let peerID = senderPeerID else {
@@ -3685,5 +3751,5 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
#endif
}
} // End of ChatViewModel class
}
// End of ChatViewModel class
+26 -12
View File
@@ -949,21 +949,29 @@ struct ContentView: View {
@ViewBuilder
private func privateHeaderContent(for privatePeerID: String) -> some View {
// Try to resolve to current peer ID if this is an old one
// resolveCurrentPeerID not implemented
let currentPeerID: String = privatePeerID
// Prefer short (mesh) ID when mesh-connected (radio). Only use full Noise key when not connected (globe).
let headerPeerID: String = {
if privatePeerID.count == 16 {
let isMeshConnected = viewModel.meshService.isPeerConnected(privatePeerID) || viewModel.connectedPeers.contains(privatePeerID)
if !isMeshConnected, let stable = viewModel.getNoiseKeyForShortID(privatePeerID) {
return stable
}
}
return privatePeerID
}()
let peer = viewModel.getPeer(byID: currentPeerID)
// Resolve peer object for header context (may be offline favorite)
let peer = viewModel.getPeer(byID: headerPeerID)
let privatePeerNick = peer?.displayName ??
viewModel.meshService.getPeerNicknames()[currentPeerID] ??
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: privatePeerID) ?? Data())?.peerNickname ??
viewModel.meshService.getPeerNicknames()[headerPeerID] ??
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ??
// getFavoriteStatusByNostrKey not implemented
// FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(privatePeerID)?.peerNickname ??
"Unknown"
let isNostrAvailable: Bool = {
guard let connectionState = peer?.connectionState else {
// Check if we can reach this peer via Nostr even if not in allPeers
if let noiseKey = Data(hexString: privatePeerID),
if let noiseKey = Data(hexString: headerPeerID),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
favoriteStatus.isMutual {
return true
@@ -981,7 +989,7 @@ struct ContentView: View {
ZStack {
// Center content - always perfectly centered
Button(action: {
viewModel.showFingerprint(for: privatePeerID)
viewModel.showFingerprint(for: headerPeerID)
}) {
HStack(spacing: 6) {
// Show transport icon based on connection state (like peer list)
@@ -1009,6 +1017,12 @@ struct ContentView: View {
.font(.system(size: 14))
.foregroundColor(.purple)
.accessibilityLabel("Available via Nostr")
} else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) {
// Fallback: if peer lookup is missing but mesh reports connected, show radio
Image(systemName: "dot.radiowaves.left.and.right")
.font(.system(size: 14))
.foregroundColor(textColor)
.accessibilityLabel("Connected via mesh")
}
Text("\(privatePeerNick)")
@@ -1016,7 +1030,7 @@ struct ContentView: View {
.foregroundColor(textColor)
// Dynamic encryption status icon
let encryptionStatus = viewModel.getEncryptionStatus(for: privatePeerID)
let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID)
if let icon = encryptionStatus.icon {
Image(systemName: icon)
.font(.system(size: 14))
@@ -1052,11 +1066,11 @@ struct ContentView: View {
// Favorite button
Button(action: {
viewModel.toggleFavorite(peerID: privatePeerID)
viewModel.toggleFavorite(peerID: headerPeerID)
}) {
Image(systemName: viewModel.isFavorite(peerID: privatePeerID) ? "star.fill" : "star")
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
.font(.system(size: 16))
.foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor)
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
}
.buttonStyle(.plain)
.accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites")