From a97d5c2d5ee71bc00d3548dc30c3bf364729a344 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 30 Jul 2025 23:14:17 +0200 Subject: [PATCH] Implement Nostr NIP-17 for offline messaging and performance optimizations (#358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement Nostr NIP-17 integration for offline mutual favorite messaging - Add Nostr relay connectivity and NIP-17 gift-wrapped private messages - Implement dual transport system: Bluetooth mesh + Nostr relays - Add favorites persistence with mutual detection and Nostr key exchange - Support offline messaging for mutual favorites via Nostr relays - Handle peer identity rotation with automatic favorite key updates - Fix UI to show all favorites (online and offline) in peer list - Add proper message routing based on peer availability - Update peer list icons: 📶 for mesh, 🌐 for Nostr, 🌙 for one-sided - Fix toolbar display for offline peers in private chat view - Add network entitlements for macOS and iOS - Implement automatic noise key updates when peers reconnect * Implement Nostr NIP-17 for private messaging between mutual favorites - Add support for NIP-17 gift-wrapped private messages with double encryption - Enable private messaging via Nostr when mutual favorites are offline - Fix peer reconnection issues: users now stay in private chat when peer reconnects - Fix read receipt delivery: send pending receipts when peer comes back online - Add message ID tracking through Nostr transport for proper delivery acknowledgments - Update peer noise key mapping when peers reconnect with different IDs - Check for Nostr messages when app becomes active - Implement 7-day message retrieval window for better reliability * Fix private message UI refresh and adjust PEOPLE header spacing - Fix UI not updating when receiving private messages on mesh - Add immediate batch processing for messages in active chat - Force UI update when viewing current chat peer - Ensure real-time message display without navigation - Reduce PEOPLE header spacing from 16 to 12 points for tighter UI * Fix build errors and unused value warnings in Nostr favorites integration * Implement read receipts via Nostr - Added sendReadReceipt method to MessageRouter to send receipts via mesh or Nostr - Added handleReadReceipt to process incoming read receipts from Nostr - Made ReadReceipt.readerID mutable to allow updates - Added missing notification names and error cases - Uncommented and enabled read receipt handling in ChatViewModel - Read receipts now work seamlessly via both mesh and Nostr transports * Fix ReadReceipt initialization - use correct constructor * Implement persistent message deduplication for Nostr - Added ProcessedMessagesService to track messages across app restarts - Store processed message IDs and last timestamp in UserDefaults - Skip already processed messages when receiving from Nostr - Adjust subscription filter to use smart timestamp (last processed or 24h) - Prevents duplicate messages when reconnecting to Nostr relays * Fix peer list UI not updating to Nostr mode on disconnect - Remove peer from peerNicknames when connection state changes to disconnected - Ensures UI properly reflects peer disconnection state - Peer list now correctly shows Nostr mode (🌐) when peer walks out of range * Update peer count to include Nostr peers and improve UI indicators - Peer count now shows total peers including those available via Nostr - Count appears purple when only Nostr peers are connected - Private message header shows purple globe icon for Nostr transport - Consistent visual language for Nostr connectivity across the app * Improve RSSI real-time updates and fix UI flashing - Reduce RSSI update timer from 10s to 5s and per-peripheral from 5s to 3s - Add RSSI change detection with 2 dBm threshold for responsive updates - Always update previous RSSI values to fix gradual change detection bug - Trigger RSSI read on peer authentication for immediate status - Fix UI flashing 'nobody around' by removing array clearing on updates - Add proper cleanup of RSSI tracking on disconnect and peer rotation * Fix favorite nickname updates and peer list filtering - Add updateNickname method to FavoritesPersistenceService to update nicknames while preserving favorite status - Update announce handler to check for existing favorites and update their nicknames - Remove dead BluetoothMeshService+PublicAPI.swift file - Move sendFavoriteNotification to main BluetoothMeshService - Fix peer list to only show connected peers and user's favorites (not peers who favorite the user) - Remove UI logic for showing peers who favorite us but we don't favorite back * Remove dead code and fix ghost connections Phase 1 - Remove abandoned peer ID rotation code: - Remove previousPeerID property and rotationGracePeriod constant - Remove grace period logic from isPeerIDOurs() - Remove previousPeerID handling from announce packets - Pass nil for previousPeerID in identity announcements Phase 2 - Fix ghost connections from relayed packets: - CRITICAL FIX: Only add peers to activePeers if they have a peripheral connection - Check for peripheral connection before marking peer as active - Prevents ghost connections when announce packets are relayed - Log warning when rejecting relayed announce without peripheral Phase 3 - Begin consolidating redundant peer tracking: - Create new PeerSession class to unify peer data in one place - Add helper methods for PeerSession management - Integrate PeerSession into announce packet handling - Update authentication state changes to use PeerSession - Update peripheral mapping and RSSI to sync with PeerSession - Update disconnect and leave handling to update PeerSession - Add consolidated getter methods for peer info This fixes the issue where peers appeared connected without actually having a Bluetooth connection, and begins the migration to a cleaner single-source-of-truth peer tracking system. * Fix multiple connect messages on peer restart - Move hasPeripheralConnection check outside sync block to fix scope issue - Add debug logging to track connect message conditions - Ensure connect messages only show on first connection or reconnection with peripheral * Optimize RSSI updates for better battery life - Add app state tracking to BluetoothMeshService - Only update RSSI when app is in foreground and peer list is visible - Add setPeerListVisible method to control RSSI updates - Remove individual periodic RSSI updates in favor of centralized timer - Update ContentView to notify mesh service of peer list visibility changes - Improve battery efficiency by avoiding unnecessary RSSI reads * Initialize peer list visibility state on view appear - Ensure RSSI timer state is properly initialized when view loads - Call setPeerListVisible with initial showSidebar value * Fix duplicate peers and multiple disconnect messages - Fixed duplicate peer entries when relay-connected by adding relay-connected peers to connectedNicknames set - Added deduplication logic for disconnect messages with 2-second window to prevent multiple disconnect notifications for same peer - Added cleanup for old disconnect notification tracking to prevent memory growth * Fix peer count indicator color logic - Show green for any mesh peer (direct Bluetooth or relay connected) - Show purple only for Nostr-only peers (no mesh connections) - Show red only when no peers are reachable at all - Fixed to use meshPeerCount instead of viewModel.isConnected which only checked direct connections * Fix relay connection issues and peripheral mapping cleanup - Fixed relay-connected peers being marked as directly connected when receiving identity announce - Added proper cleanup of temp peripheral mappings when discovering real peer ID - Fixed disconnect notification deduplication cleanup - Improved debug logging to show actual connection state (direct/relay/nostr/offline) - Added debug logging for relay connection detection - Fixed compiler warning about unused variable * Fix Unknown peer disconnect notifications and disable faulty relay detection - Add check to prevent disconnect notifications for Unknown peers that never announced - Disable relay connection detection until proper relay tracking is implemented - In a 2-peer network, peers should never show as relay-connected * Fix RSSI updates and peer visibility after reconnection - Add updatePeers() call in didUpdatePeerList to refresh RSSI values in UI - Track version hello times to better detect direct connections - Allow peers to be marked active if recent version hello received - Fix thread safety for version hello tracking - Clean up old version hello times to prevent memory leaks * Remove RSSI tracking completely and replace with radio icon for mesh connections * Fix build errors after RSSI removal - Add missing peripheralID declaration in didDiscover delegate method - Remove obsolete setPeerListVisible calls from ContentView * Center private message header elements using ZStack layout - Replace HStack with ZStack for perfect centering - Globe/nick/lock cluster now always centered regardless of button sizes - Back and favorite buttons positioned in overlay HStack * Fix private chat view showing Unknown when peer reconnects with new ID - Update FavoritesPersistenceService to notify with both old and new keys - Handle peer ID changes in ChatViewModel to migrate private chat data - Update selectedPrivateChatPeer when favorite's noise key changes - Maintain chat history and unread status across peer ID changes * Fix read receipts after peer reconnection and replace nos.lol relay - Updated sendReadReceipt to resolve current peer ID when peers reconnect with new IDs - Enhanced MessageRouter to check favorites for current noise keys - Replaced nos.lol relay with relay.snort.social to avoid PoW requirements * Fix message routing to use Nostr when peers are disconnected Changed message routing logic to check actual peer connection status using isPeerConnected() instead of just checking if peer exists in nickname list. This ensures that offline mutual favorites correctly route messages through Nostr instead of attempting Bluetooth handshakes. Also added safety check to prevent starting private chat with ourselves. * Add debug logging for Nostr timestamp randomization Added logging to track the random offset being applied to Nostr event timestamps to debug why messages appear 8-9 minutes in the future. * Fix Nostr timestamp issue by reducing randomization range Temporarily reduced the timestamp randomization from +/-15 minutes to +/-1 minute to address messages appearing 8-9 minutes in the future. Added detailed UTC/local time logging to help debug the issue. The random offset should have been evenly distributed but was consistently showing positive offsets. This change mitigates the issue while we investigate the root cause. * Fix message routing for offline favorites and reduce Nostr timestamp randomization - Fix transport selection to properly detect disconnected peers using isPeerConnected() - Change from checking peer nicknames to checking actual connection status - Reduce Nostr timestamp randomization from ±15 minutes to ±1 minute - Add detailed timestamp logging for debugging * Improve PM header UI and encryption status display - Show transport icons (radio/link/globe) in PM header matching peer list - Always show lock icon if noise session ever established (no handshake icon) - Change verified icon from shield to checkmark seal - Use consistent green color (textColor) for PM header and encryption icons * Update AI_CONTEXT.md with comprehensive Nostr implementation details - Add Nostr and MessageRouter to architecture diagram - Document NIP-17 gift wrap implementation - Explain favorites integration and mutual requirement - Detail message routing logic and transport selection - Add security considerations and debugging tips - Update common tasks with Nostr-specific guidance * Fix data consistency issues in favorites, chat migration, and bloom filter - Fix favorites deduplication to use public key instead of nickname Prevents losing favorites when multiple peers use same nickname - Fix private chat migration to use fingerprints instead of nicknames Prevents merging unrelated conversations that share nicknames Fallback to nickname matching only for legacy data without fingerprints - Fix bloom filter reset to preserve messages from last 10 minutes Prevents duplicate message processing after bloom filter resets Keeps processedMessages for 10 minutes while bloom filter resets every 5 * Add mutual favorites internet messaging to app info * Remove excessive debug/info logging for production readiness - Removed ~140 debug/info level logs across core services - Preserved critical logs: errors, warnings, security events, state changes - Kept logs for: peer join/leave, favorite status, mutual relationships - Cleaned up verbose logging in: Bluetooth mesh, Nostr, message routing - Improved performance by reducing log I/O overhead 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Implement performance optimizations and fix build warnings - Add UI update debouncing (50ms) to prevent excessive SwiftUI refreshes - Implement memory bounds for processedMessages with LRU eviction - Add encryption queue cleanup for disconnected peers - Optimize peer lookups from O(n) to O(1) with indexed dictionary - Fix multiple compiler warnings (unused variables, missing break statements) - Optimize peer counting with single-pass reduce operation - Fix ViewBuilder control flow issue in ContentView - Fix Dictionary initialization type mismatches with Array wrapper * Add TTL-based cleanup for Noise handshake sessions - Add session TTL (5 minutes) and max session limit (50) to NoiseHandshakeCoordinator - Clean up old established sessions to prevent unbounded memory growth - Move handshake cleanup timer out of DEBUG conditional for production use - Run cleanup every 60 seconds in production (vs 30s in debug) - Clean up crypto state immediately on peer disconnect - Prevents memory leaks from accumulating Noise sessions * Pre-compute and store fingerprints in PeerSession for O(1) lookups - Store fingerprint in PeerSession when peer authenticates - Update getPeerFingerprint() and getFingerprint() to check PeerSession first - Replace all noiseService.getPeerFingerprint() calls with optimized version - Eliminates repeated SHA256 calculations during message processing - Improves performance for favorite checks and encryption status updates * Implement exponential backoff for Nostr relay connections - Add reconnection tracking fields to Relay struct (attempts, timing) - Replace fixed 5-second delay with exponential backoff (1s → 2s → 4s... max 5min) - Stop reconnection attempts after 10 failures to prevent infinite retries - Reset attempt counter on successful connection - Add utility methods: retryConnection(), getRelayStatuses(), resetAllConnections() - DNS failures still bypass retry logic as before - Improves battery life and reduces server load from constant reconnection attempts --------- Co-authored-by: jack Co-authored-by: Claude --- AI_CONTEXT.md | 218 +- bitchat.xcodeproj/project.pbxproj | 154 +- bitchat/BitchatApp.swift | 7 + bitchat/Info.plist | 13 + bitchat/Models/BitchatPeer.swift | 336 +++ bitchat/Models/PeerSession.swift | 91 + bitchat/Noise/NoiseHandshakeCoordinator.swift | 31 +- bitchat/Nostr/NostrIdentity.swift | 265 +++ bitchat/Nostr/NostrProtocol.swift | 560 +++++ bitchat/Nostr/NostrRelayManager.swift | 552 +++++ bitchat/Protocols/BitchatProtocol.swift | 8 +- bitchat/Services/BluetoothMeshService.swift | 1807 ++++++++++++----- .../FavoritesPersistenceService.swift | 415 ++++ bitchat/Services/MessageRouter.swift | 671 ++++++ bitchat/Services/NoiseEncryptionService.swift | 2 +- .../Services/ProcessedMessagesService.swift | 108 + bitchat/ViewModels/ChatViewModel.swift | 1156 +++++++++-- bitchat/Views/AppInfoView.swift | 7 +- bitchat/Views/ContentView.swift | 277 ++- bitchat/bitchat-macOS.entitlements | 2 + .../EndToEnd/PrivateChatE2ETests.swift | 3 +- .../EndToEnd/PublicChatE2ETests.swift | 6 +- .../Integration/IntegrationTests.swift | 9 +- .../Mocks/MockBluetoothMeshService.swift | 6 +- bitchatTests/NostrProtocolTests.swift | 111 + bitchatTests/TestUtilities/TestHelpers.swift | 6 +- build.log | 180 ++ 27 files changed, 6173 insertions(+), 828 deletions(-) create mode 100644 bitchat/Models/BitchatPeer.swift create mode 100644 bitchat/Models/PeerSession.swift create mode 100644 bitchat/Nostr/NostrIdentity.swift create mode 100644 bitchat/Nostr/NostrProtocol.swift create mode 100644 bitchat/Nostr/NostrRelayManager.swift create mode 100644 bitchat/Services/FavoritesPersistenceService.swift create mode 100644 bitchat/Services/MessageRouter.swift create mode 100644 bitchat/Services/ProcessedMessagesService.swift create mode 100644 bitchatTests/NostrProtocolTests.swift create mode 100644 build.log diff --git a/AI_CONTEXT.md b/AI_CONTEXT.md index ef139d70..fcb41a47 100644 --- a/AI_CONTEXT.md +++ b/AI_CONTEXT.md @@ -13,6 +13,8 @@ BitChat is a decentralized, peer-to-peer messaging application that works over B - **Store & Forward**: Messages cached for offline peers - **IRC-Style Commands**: Familiar `/msg`, `/who` interface - **Cross-Platform**: Native iOS and macOS support +- **Nostr Integration**: Seamless fallback for mutual favorites when out of Bluetooth range +- **Hybrid Transport**: Automatic switching between Bluetooth and Nostr ## Architecture Overview @@ -28,17 +30,24 @@ BitChat is a decentralized, peer-to-peer messaging application that works over B └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────┐ -│ Security Layer │ -│ (NoiseEncryptionService, SecureIdentityStateManager) │ +│ Message Router │ +│ (Transport selection, Favorites integration) │ └─────────────────────────────────────────────────────────────────┘ - │ + │ │ +┌───────────────────────────────┐ ┌────────────────────────────────┐ +│ Security Layer │ │ Nostr Protocol Layer │ +│ (NoiseEncryptionService, │ │ (NostrProtocol, NIP-17, │ +│ SecureIdentityStateManager) │ │ NostrRelayManager) │ +└───────────────────────────────┘ └────────────────────────────────┘ + │ │ +┌───────────────────────────────┐ ┌────────────────────────────────┐ +│ Protocol Layer │ │ Transport │ +│ (BitchatProtocol, Binary- │ │ (WebSocket to Nostr │ +│ Protocol, NoiseProtocol) │ │ relay servers) │ +└───────────────────────────────┘ └────────────────────────────────┘ + │ ┌─────────────────────────────────────────────────────────────────┐ -│ Protocol Layer │ -│ (BitchatProtocol, BinaryProtocol, NoiseProtocol) │ -└─────────────────────────────────────────────────────────────────┘ - │ -┌─────────────────────────────────────────────────────────────────┐ -│ Transport Layer │ +│ Bluetooth Transport Layer │ │ (BluetoothMeshService) │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -94,6 +103,32 @@ BitChat is a decentralized, peer-to-peer messaging application that works over B - UI state management - Private chat coordination +### 6. Nostr Integration +- **Locations**: + - `bitchat/Nostr/NostrProtocol.swift` - NIP-17 private message implementation + - `bitchat/Nostr/NostrRelayManager.swift` - WebSocket relay connections + - `bitchat/Nostr/NostrIdentity.swift` - Nostr key management + - `bitchat/Services/MessageRouter.swift` - Transport selection logic +- **Purpose**: Enables communication with mutual favorites when out of Bluetooth range +- **Key Features**: + - NIP-17 gift-wrapped private messages for metadata privacy + - Automatic relay connection management + - Seamless transport switching between Bluetooth and Nostr + - Integrated with favorites system for mutual authentication + +### 7. MessageRouter +- **Location**: `bitchat/Services/MessageRouter.swift` +- **Purpose**: Intelligent routing between Bluetooth mesh and Nostr transports +- **Transport Selection Logic**: + 1. Always prefer Bluetooth mesh when peer is connected + 2. Use Nostr for mutual favorites when peer is offline + 3. Fail gracefully when no transport is available +- **Message Types Routed**: + - Regular chat messages + - Favorite/unfavorite notifications + - Delivery acknowledgments + - Read receipts + ## Key Design Decisions ### 1. Protocol Design @@ -117,6 +152,12 @@ BitChat is a decentralized, peer-to-peer messaging application that works over B - **Timing Obfuscation**: Randomized delays - **Emergency Wipe**: Triple-tap to clear all data +### 5. Nostr Integration +- **NIP-17 Gift Wraps**: Maximum metadata privacy +- **Ephemeral Keys**: Each message uses unique ephemeral keys +- **Mutual Favorites Only**: Requires bidirectional trust +- **Transport Abstraction**: Users don't need to know about Nostr + ## Code Organization ### Services (`/bitchat/Services/`) @@ -146,6 +187,147 @@ MVVM architecture for UI: - `ChatViewModel`: Business logic and state - Supporting views for settings, identity, etc. +## Nostr Protocol Implementation + +### Overview +BitChat integrates Nostr as a secondary transport for communicating with mutual favorites when Bluetooth connectivity is unavailable. This integration is transparent to users - messages automatically route through Nostr when needed. + +### NIP-17 Private Direct Messages +BitChat implements NIP-17 (Private Direct Messages) for metadata-private communication: + +1. **Gift Wrap Structure**: + ``` + Gift Wrap (kind 1059) → Seal (kind 13) → Rumor (kind 1) + ``` + - **Rumor**: The actual message content (unsigned) + - **Seal**: Encrypted rumor, hides sender identity + - **Gift Wrap**: Double-encrypted, tagged for recipient + +2. **Ephemeral Keys**: + - Each message uses TWO ephemeral key pairs + - Seal uses one ephemeral key + - Gift wrap uses a different ephemeral key + - Provides sender anonymity and forward secrecy + +3. **Timestamp Randomization**: + - ±1 minute randomization (reduced from NIP-17's ±15 minutes) + - Prevents timing correlation attacks + - Configurable in `NostrProtocol.randomizedTimestamp()` + +### Favorites Integration + +The Nostr transport is only available for mutual favorites: + +1. **Favorite Establishment**: + - User favorites a peer via `/fav` command + - Favorite notification sent via Bluetooth (if connected) + - Peer's Nostr public key exchanged during favorite process + - Stored in `FavoritesPersistenceService` + +2. **Mutual Requirement**: + - Both peers must favorite each other + - Prevents spam and unwanted Nostr messages + - Enforced by `MessageRouter` transport selection + +3. **Nostr Key Management**: + - Derived from Noise static key using BIP-32 + - Path: `m/44'/1237'/0'/0/0` (1237 = "NOSTR" in decimal) + - Consistent npub across app reinstalls + - Keys never leave the device + +### Message Routing Logic + +`MessageRouter` automatically selects transport: + +```swift +if peerAvailableOnMesh { + transport = .bluetoothMesh // Always prefer mesh +} else if isMutualFavorite { + transport = .nostr // Use Nostr for offline favorites +} else { + throw MessageRouterError.peerNotReachable +} +``` + +### Relay Configuration + +Default relays (hardcoded for reliability): +- `wss://relay.damus.io` +- `wss://relay.primal.net` +- `wss://offchain.pub` +- `wss://nostr21.com` + +Relay selection criteria: +- Geographic distribution +- High uptime +- No authentication required +- Support for ephemeral events + +### Message Format + +Structured content for different message types: +- Chat: `MSG::` +- Favorite: `FAVORITED:` or `UNFAVORITED:` +- Delivery ACK: `DELIVERED:` +- Read Receipt: `READ:` + +### Implementation Details + +1. **NostrRelayManager**: + - Manages WebSocket connections to relays + - Handles reconnection logic + - Processes EVENT, EOSE, OK, NOTICE messages + - Implements NIP-01 relay protocol + +2. **NostrProtocol**: + - Implements NIP-17 encryption/decryption + - Handles gift wrap creation/unwrapping + - Manages ephemeral key generation + - Provides Schnorr signatures + +3. **ProcessedMessagesService**: + - Prevents duplicate message processing + - Tracks last subscription timestamp + - Persists across app launches + - 30-day retention window + +### Security Considerations + +1. **Metadata Protection**: + - Sender identity hidden via ephemeral keys + - Recipient only visible in gift wrap p-tag + - Timing correlation prevented via randomization + - Message content double-encrypted + +2. **Relay Trust**: + - Relays cannot read message content + - Relays can see recipient pubkey (gift wrap) + - Relays cannot determine sender + - Multiple relays used for redundancy + +3. **Key Hygiene**: + - Ephemeral keys used once and discarded + - Static Nostr key derived from Noise key + - No key reuse between messages + - Keys cleared from memory after use + +### Debugging Nostr Issues + +1. **Check relay connections**: + - Look for "Connected to Nostr relay" in logs + - Verify WebSocket state in NostrRelayManager + - Check for relay errors/notices + +2. **Verify gift wrap creation**: + - Enable debug logging in NostrProtocol + - Check ephemeral key generation + - Verify encryption steps + +3. **Message delivery**: + - Check ProcessedMessagesService for duplicates + - Verify subscription filters + - Look for EVENT messages in relay responses + ## Development Guidelines ### 1. Security First @@ -194,6 +376,20 @@ MVVM architecture for UI: 3. Monitor characteristic updates 4. Use Bluetooth debugging tools +### Working with Nostr Transport +1. Verify mutual favorite status in `FavoritesPersistenceService` +2. Check Nostr key derivation in `NostrIdentity` +3. Monitor relay connections in `NostrRelayManager` +4. Test gift wrap encryption/decryption +5. Verify transport selection in `MessageRouter` + +### Adding Nostr Features +1. Understand NIP-17 gift wrap structure +2. Maintain ephemeral key hygiene +3. Test with multiple relays +4. Preserve metadata privacy +5. Handle relay disconnections gracefully + ## Security Threat Model ### Assumptions @@ -266,9 +462,11 @@ MVVM architecture for UI: ## Quick Start for AI Assistants 1. **Understand the layers**: Transport → Protocol → Security → Services → UI -2. **Follow the data flow**: BLE → Binary → Protocol → ViewModel → View +2. **Follow the data flow**: BLE/Nostr → Binary/JSON → Protocol → ViewModel → View 3. **Respect security boundaries**: Never mix trusted and untrusted data 4. **Test thoroughly**: This is critical infrastructure for users 5. **Ask about design decisions**: Many choices have non-obvious reasons +6. **Dual Transport**: Remember that messages can flow over Bluetooth OR Nostr +7. **Favorites System**: Nostr only works between mutual favorites When in doubt, prioritize security and privacy over features. BitChat users depend on this app in situations where traditional communication has failed them. \ No newline at end of file diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 9e25ba80..02a57ea1 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -46,6 +46,26 @@ 04B6BA4F2E2038A70090FE39 /* NoiseEncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BA4D2E2038A70090FE39 /* NoiseEncryptionService.swift */; }; 04B6BA552E203D6C0090FE39 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BA532E203D6C0090FE39 /* FingerprintView.swift */; }; 04B6BA572E203D6C0090FE39 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BA532E203D6C0090FE39 /* FingerprintView.swift */; }; + 04E363362E3800310048E624 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 04F127E72E37EBCD00FFBA8D /* P256K */; }; + 04E363372E3800310048E624 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 04F127E82E37EBDB00FFBA8D /* P256K */; }; + 04F127E42E37EBAA00FFBA8D /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127E22E37EBAA00FFBA8D /* NostrIdentity.swift */; }; + 04F127E52E37EBAA00FFBA8D /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127E22E37EBAA00FFBA8D /* NostrIdentity.swift */; }; + 04F127F22E37EEB800FFBA8D /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F02E37EEB800FFBA8D /* NostrProtocol.swift */; }; + 04F127F32E37EEB800FFBA8D /* NostrRelayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F12E37EEB800FFBA8D /* NostrRelayManager.swift */; }; + 04F127F42E37EEB800FFBA8D /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F02E37EEB800FFBA8D /* NostrProtocol.swift */; }; + 04F127F52E37EEB800FFBA8D /* NostrRelayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F12E37EEB800FFBA8D /* NostrRelayManager.swift */; }; + 04F127F82E37EEEE00FFBA8D /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F72E37EEEE00FFBA8D /* MessageRouter.swift */; }; + 04F127F92E37EEEE00FFBA8D /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F62E37EEEE00FFBA8D /* FavoritesPersistenceService.swift */; }; + 04F127FA2E37EEEE00FFBA8D /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F72E37EEEE00FFBA8D /* MessageRouter.swift */; }; + 04F127FB2E37EEEE00FFBA8D /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F62E37EEEE00FFBA8D /* FavoritesPersistenceService.swift */; }; + 04F127FE2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127FC2E37EF3D00FFBA8D /* BitchatPeer.swift */; }; + 04F127FF2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127FC2E37EF3D00FFBA8D /* BitchatPeer.swift */; }; + 04F128062E37F10000FFBA8D /* PeerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F128072E37F10000FFBA8D /* PeerSession.swift */; }; + 04F128082E37F10000FFBA8D /* PeerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F128072E37F10000FFBA8D /* PeerSession.swift */; }; + 04F128012E37FF4E00FFBA8D /* libsecp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 04F128002E37FF4E00FFBA8D /* libsecp256k1 */; }; + 04F128032E37FFB900FFBA8D /* libsecp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 04F128022E37FFB900FFBA8D /* libsecp256k1 */; }; + 04F128042E37F00000FFBA8D /* ProcessedMessagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127FF2E37F00000FFBA8D /* ProcessedMessagesService.swift */; }; + 04F128052E37F00000FFBA8D /* ProcessedMessagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127FF2E37F00000FFBA8D /* ProcessedMessagesService.swift */; }; 0FBC81FF78CF4711B78E092A /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2446380E7A44E49A35B664 /* IdentityModels.swift */; }; 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; }; 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -142,6 +162,14 @@ 04B6BA432E2035530090FE39 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = ""; }; 04B6BA4D2E2038A70090FE39 /* NoiseEncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseEncryptionService.swift; sourceTree = ""; }; 04B6BA532E203D6C0090FE39 /* FingerprintView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FingerprintView.swift; sourceTree = ""; }; + 04F127E22E37EBAA00FFBA8D /* NostrIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrIdentity.swift; sourceTree = ""; }; + 04F127F02E37EEB800FFBA8D /* NostrProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrProtocol.swift; sourceTree = ""; }; + 04F127F12E37EEB800FFBA8D /* NostrRelayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrRelayManager.swift; sourceTree = ""; }; + 04F127F62E37EEEE00FFBA8D /* FavoritesPersistenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesPersistenceService.swift; sourceTree = ""; }; + 04F127F72E37EEEE00FFBA8D /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = ""; }; + 04F127FC2E37EF3D00FFBA8D /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = ""; }; + 04F128072E37F10000FFBA8D /* PeerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerSession.swift; sourceTree = ""; }; + 04F127FF2E37F00000FFBA8D /* ProcessedMessagesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessedMessagesService.swift; sourceTree = ""; }; 12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryTracker.swift; sourceTree = ""; }; 136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = ""; }; @@ -174,6 +202,27 @@ FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFrameworksBuildPhase section */ + 04F127E92E37EBEF00FFBA8D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 04E363362E3800310048E624 /* P256K in Frameworks */, + 04F128012E37FF4E00FFBA8D /* libsecp256k1 in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 04F127ED2E37EBFF00FFBA8D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 04E363372E3800310048E624 /* P256K in Frameworks */, + 04F128032E37FFB900FFBA8D /* libsecp256k1 in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + /* Begin PBXGroup section */ 04636BC62E30BE5100FBCFA8 /* EndToEnd */ = { isa = PBXGroup; @@ -237,6 +286,32 @@ path = Noise; sourceTree = ""; }; + 04F127E32E37EBAA00FFBA8D /* Nostr */ = { + isa = PBXGroup; + children = ( + 04F127F02E37EEB800FFBA8D /* NostrProtocol.swift */, + 04F127F12E37EEB800FFBA8D /* NostrRelayManager.swift */, + 04F127E22E37EBAA00FFBA8D /* NostrIdentity.swift */, + ); + path = Nostr; + sourceTree = ""; + }; + 04F127EA2E37EBF300FFBA8D /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + 04F127FD2E37EF3D00FFBA8D /* Models */ = { + isa = PBXGroup; + children = ( + 04F127FC2E37EF3D00FFBA8D /* BitchatPeer.swift */, + 04F128072E37F10000FFBA8D /* PeerSession.swift */, + ); + path = Models; + sourceTree = ""; + }; 18198ED912AAF495D8AF7763 = { isa = PBXGroup; children = ( @@ -244,6 +319,7 @@ A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */, C3D98EB3E1B455E321F519F4 /* bitchatTests */, 9F37F9F2C353B58AC809E93B /* Products */, + 04F127EA2E37EBF300FFBA8D /* Frameworks */, ); sourceTree = ""; }; @@ -258,8 +334,10 @@ 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */, ADD53BCDA233C02E53458926 /* Protocols */, 04B6BA442E2035530090FE39 /* Noise */, + 04F127E32E37EBAA00FFBA8D /* Nostr */, D98A3186D7E4C72E35BDF7FE /* Services */, 6078981E5A3646BC84CC6DB4 /* Identity */, + 04F127FD2E37EF3D00FFBA8D /* Models */, 9A78348821A7D3374607D4E3 /* Utils */, 45BB7D87CAE42A8C0447D909 /* ViewModels */, A55126E93155456CAA8D6656 /* Views */, @@ -356,6 +434,9 @@ D98A3186D7E4C72E35BDF7FE /* Services */ = { isa = PBXGroup; children = ( + 04F127F62E37EEEE00FFBA8D /* FavoritesPersistenceService.swift */, + 04F127F72E37EEEE00FFBA8D /* MessageRouter.swift */, + 04F127FF2E37F00000FFBA8D /* ProcessedMessagesService.swift */, 04B6BA4D2E2038A70090FE39 /* NoiseEncryptionService.swift */, D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */, 12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */, @@ -375,6 +456,7 @@ buildPhases = ( 137ABE739BF20ACDDF8CC605 /* Sources */, 0214973A876129753D39EB47 /* Resources */, + 04F127ED2E37EBFF00FFBA8D /* Frameworks */, ); buildRules = ( ); @@ -382,6 +464,8 @@ ); name = bitchat_macOS; packageProductDependencies = ( + 04F127E82E37EBDB00FFBA8D /* P256K */, + 04F128022E37FFB900FFBA8D /* libsecp256k1 */, ); productName = bitchat_macOS; productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */; @@ -447,6 +531,7 @@ 4E49E34F00154C051AE90FED /* Sources */, CD6E8F32BC38357473954F97 /* Resources */, B6C356449BAE4E0F650565D1 /* Embed Foundation Extensions */, + 04F127E92E37EBEF00FFBA8D /* Frameworks */, ); buildRules = ( ); @@ -455,6 +540,8 @@ ); name = bitchat_iOS; packageProductDependencies = ( + 04F127E72E37EBCD00FFBA8D /* P256K */, + 04F128002E37FF4E00FFBA8D /* libsecp256k1 */, ); productName = bitchat_iOS; productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */; @@ -501,6 +588,9 @@ ); mainGroup = 18198ED912AAF495D8AF7763; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 04F127E62E37EBCD00FFBA8D /* XCRemoteSwiftPackageReference "swift-secp256k1" */, + ); projectDirPath = ""; projectRoot = ""; targets = ( @@ -556,22 +646,30 @@ 04B6BA462E2035530090FE39 /* NoiseSession.swift in Sources */, 04B6BA472E2035530090FE39 /* NoiseProtocol.swift in Sources */, 7A50E2F04A3515A7E90EEAE4 /* BluetoothMeshService.swift in Sources */, + 04F127F42E37EEB800FFBA8D /* NostrProtocol.swift in Sources */, + 04F127F52E37EEB800FFBA8D /* NostrRelayManager.swift in Sources */, D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */, B0CA7796B2B2AC2B33F84548 /* CompressionUtil.swift in Sources */, 92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */, 04B6BA4F2E2038A70090FE39 /* NoiseEncryptionService.swift in Sources */, 8DCEEA289EF7C49E7CD38B08 /* DeliveryTracker.swift in Sources */, FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */, + 04F127E52E37EBAA00FFBA8D /* NostrIdentity.swift in Sources */, 04636BBA2E2FAA1700FBCFA8 /* BinaryEncodingUtils.swift in Sources */, 04AD0B4E2E25B9580002A40A /* IdentityModels.swift in Sources */, 04AD0B4F2E25B9580002A40A /* SecureIdentityStateManager.swift in Sources */, 31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */, + 04F127FE2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */, + 04F128062E37F10000FFBA8D /* PeerSession.swift in Sources */, 04B6BA572E203D6C0090FE39 /* FingerprintView.swift in Sources */, 04636BED2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */, C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */, 749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */, 04636BBF2E2FCA8A00FBCFA8 /* SecureLogger.swift in Sources */, 0245710AEAA58AD0A1425234 /* OptimizedBloomFilter.swift in Sources */, + 04F127F82E37EEEE00FFBA8D /* MessageRouter.swift in Sources */, + 04F127F92E37EEEE00FFBA8D /* FavoritesPersistenceService.swift in Sources */, + 04F128042E37F00000FFBA8D /* ProcessedMessagesService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -589,22 +687,30 @@ 04B6BA4A2E2035530090FE39 /* NoiseSession.swift in Sources */, 04B6BA4B2E2035530090FE39 /* NoiseProtocol.swift in Sources */, D948085736ED8E736C1DE3B0 /* BluetoothMeshService.swift in Sources */, + 04F127F22E37EEB800FFBA8D /* NostrProtocol.swift in Sources */, + 04F127F32E37EEB800FFBA8D /* NostrRelayManager.swift in Sources */, 7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */, 7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */, 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */, 04B6BA4E2E2038A70090FE39 /* NoiseEncryptionService.swift in Sources */, CD0AE423F03AC52BAFC16834 /* DeliveryTracker.swift in Sources */, 8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */, + 04F127E42E37EBAA00FFBA8D /* NostrIdentity.swift in Sources */, 04636BBC2E2FAA1700FBCFA8 /* BinaryEncodingUtils.swift in Sources */, 0FBC81FF78CF4711B78E092A /* IdentityModels.swift in Sources */, 1AF9F9036DEE42408D557A87 /* SecureIdentityStateManager.swift in Sources */, 7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */, + 04F127FF2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */, + 04F128082E37F10000FFBA8D /* PeerSession.swift in Sources */, 04B6BA552E203D6C0090FE39 /* FingerprintView.swift in Sources */, 04636BEE2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */, CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */, 61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */, 04636BC02E2FCA8A00FBCFA8 /* SecureLogger.swift in Sources */, 1F48A8CEEE9399D1EBD08F0C /* OptimizedBloomFilter.swift in Sources */, + 04F127FA2E37EEEE00FFBA8D /* MessageRouter.swift in Sources */, + 04F127FB2E37EEEE00FFBA8D /* FavoritesPersistenceService.swift in Sources */, + 04F128052E37F00000FFBA8D /* ProcessedMessagesService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -744,7 +850,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -776,7 +882,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -832,7 +938,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -858,11 +964,14 @@ DEAD_CODE_STRIPPING = YES; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = bitchat; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -945,11 +1054,14 @@ DEAD_CODE_STRIPPING = YES; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = bitchat; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -1042,7 +1154,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1112,6 +1224,40 @@ defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 04F127E62E37EBCD00FFBA8D /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.21.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 04F127E72E37EBCD00FFBA8D /* P256K */ = { + isa = XCSwiftPackageProductDependency; + package = 04F127E62E37EBCD00FFBA8D /* XCRemoteSwiftPackageReference "swift-secp256k1" */; + productName = P256K; + }; + 04F127E82E37EBDB00FFBA8D /* P256K */ = { + isa = XCSwiftPackageProductDependency; + package = 04F127E62E37EBCD00FFBA8D /* XCRemoteSwiftPackageReference "swift-secp256k1" */; + productName = P256K; + }; + 04F128002E37FF4E00FFBA8D /* libsecp256k1 */ = { + isa = XCSwiftPackageProductDependency; + package = 04F127E62E37EBCD00FFBA8D /* XCRemoteSwiftPackageReference "swift-secp256k1" */; + productName = libsecp256k1; + }; + 04F128022E37FFB900FFBA8D /* libsecp256k1 */ = { + isa = XCSwiftPackageProductDependency; + package = 04F127E62E37EBCD00FFBA8D /* XCRemoteSwiftPackageReference "swift-secp256k1" */; + productName = libsecp256k1; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 475D96681D0EA0AE57A4E06E /* Project object */; } diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index e611a2c1..e03ac6a1 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -43,6 +43,13 @@ struct BitchatApp: App { .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in // Check for shared content when app becomes active checkForSharedContent() + // Notify MessageRouter to check for Nostr messages + NotificationCenter.default.post(name: .appDidBecomeActive, object: nil) + } + #elseif os(macOS) + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + // Notify MessageRouter to check for Nostr messages + NotificationCenter.default.post(name: .appDidBecomeActive, object: nil) } #endif } diff --git a/bitchat/Info.plist b/bitchat/Info.plist index fd87f8b5..37b0d941 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -54,5 +54,18 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsForMedia + + NSAllowsArbitraryLoadsInWebContent + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + bitchat uses local network access to discover and connect with other bitchat users on your network. diff --git a/bitchat/Models/BitchatPeer.swift b/bitchat/Models/BitchatPeer.swift new file mode 100644 index 00000000..b3ee9f46 --- /dev/null +++ b/bitchat/Models/BitchatPeer.swift @@ -0,0 +1,336 @@ +import Foundation +import CoreBluetooth + +/// Represents a peer in the BitChat network with all associated metadata +struct BitchatPeer: Identifiable, Equatable { + let id: String // Hex-encoded peer ID + let noisePublicKey: Data + let nickname: String + let lastSeen: Date + let isConnected: Bool + + // Favorite-related properties + var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship? + + // Nostr identity (if known) + var nostrPublicKey: String? + + // Connection state + enum ConnectionState { + case bluetoothConnected + case relayConnected // Connected via mesh relay (another peer) + case nostrAvailable // Mutual favorite, reachable via Nostr + case offline // Not connected via any transport + } + + var connectionState: ConnectionState { + if isConnected { + return .bluetoothConnected + } else if isRelayConnected { + return .relayConnected + } else if favoriteStatus?.isMutual == true { + // Mutual favorites can communicate via Nostr when offline + return .nostrAvailable + } else { + return .offline + } + } + + var isRelayConnected: Bool = false // Set by PeerManager based on session state + + var isFavorite: Bool { + favoriteStatus?.isFavorite ?? false + } + + var isMutualFavorite: Bool { + favoriteStatus?.isMutual ?? false + } + + var theyFavoritedUs: Bool { + favoriteStatus?.theyFavoritedUs ?? false + } + + // Display helpers + var displayName: String { + nickname.isEmpty ? String(id.prefix(8)) : nickname + } + + var statusIcon: String { + switch connectionState { + case .bluetoothConnected: + return "📻" // Radio icon for mesh connection + case .relayConnected: + return "🔗" // Chain link for relay connection + case .nostrAvailable: + return "🌐" // Purple globe for Nostr + case .offline: + if theyFavoritedUs && !isFavorite { + return "🌙" // Crescent moon - they favorited us but we didn't reciprocate + } else { + return "" + } + } + } + + // Initialize from mesh service data + init( + id: String, + noisePublicKey: Data, + nickname: String, + lastSeen: Date = Date(), + isConnected: Bool = false, + isRelayConnected: Bool = false + ) { + self.id = id + self.noisePublicKey = noisePublicKey + self.nickname = nickname + self.lastSeen = lastSeen + self.isConnected = isConnected + self.isRelayConnected = isRelayConnected + + // Load favorite status - will be set later by the manager + self.favoriteStatus = nil + self.nostrPublicKey = nil + } + + static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool { + lhs.id == rhs.id + } +} + +// MARK: - Peer Manager + +/// Manages the collection of peers and their states +@MainActor +class PeerManager: ObservableObject { + @Published var peers: [BitchatPeer] = [] + @Published var favorites: [BitchatPeer] = [] + @Published var mutualFavorites: [BitchatPeer] = [] + + private let meshService: BluetoothMeshService + private let favoritesService = FavoritesPersistenceService.shared + + init(meshService: BluetoothMeshService) { + self.meshService = meshService + updatePeers() + + // Listen for updates + NotificationCenter.default.addObserver( + self, + selector: #selector(handleFavoriteChanged), + name: .favoriteStatusChanged, + object: nil + ) + } + + @objc private func handleFavoriteChanged() { + SecureLogger.log("⭐ Favorite status changed notification received, updating peers", + category: SecureLogger.session, level: .debug) + updatePeers() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + func updatePeers() { + // Reduce log verbosity - only log when count changes + let previousCount = peers.count + + // Get current mesh peers + let meshPeers = meshService.getPeerNicknames() + + // Build peer list + var allPeers: [BitchatPeer] = [] + var connectedNicknames: Set = [] + + // Add connected mesh peers (only if actually connected or relay connected) + for (peerID, nickname) in meshPeers { + guard let noiseKey = Data(hexString: peerID) else { continue } + + // Safety check: Never add our own peer ID + if peerID == meshService.myPeerID { + SecureLogger.log("⚠️ Skipping self peer ID \(peerID) in peer list", + category: SecureLogger.session, level: .warning) + continue + } + + // Check if this peer is actually connected (not just known via relay) + let isConnected = meshService.isPeerConnected(peerID) + let isKnown = meshService.isPeerKnown(peerID) + // In a mesh network, a peer can only be relay-connected if: + // 1. We know about them (have received announce) + // 2. We're not directly connected + // 3. There are other peers that could relay (mesh peer count > 2) + // For now, disable relay detection until we have proper relay tracking + let isRelayConnected = false + + // Debug logging for relay connection detection + if isKnown && !isConnected { + SecureLogger.log("Peer \(nickname) (\(peerID)): isConnected=\(isConnected), isKnown=\(isKnown), isRelayConnected=\(isRelayConnected)", + category: SecureLogger.session, level: .debug) + } + + // Skip disconnected peers unless they're favorites (handled later) + if !isConnected && !isRelayConnected { + continue + } + + if isConnected || isRelayConnected { + connectedNicknames.insert(nickname) + } + + var peer = BitchatPeer( + id: peerID, + noisePublicKey: noiseKey, + nickname: nickname, + isConnected: isConnected, + isRelayConnected: isRelayConnected + ) + // Set favorite status - check both by current noise key and by nickname + if let favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey) { + peer.favoriteStatus = favoriteStatus + peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey + } else { + // Check if we have a favorite for this nickname (peer may have reconnected with new ID) + let favoriteByNickname = favoritesService.favorites.values.first { $0.peerNickname == nickname } + if let favorite = favoriteByNickname { + SecureLogger.log("🔄 Found favorite for '\(nickname)' by nickname, updating noise key", + category: SecureLogger.session, level: .info) + // Update the favorite's noise key to match the current connection + favoritesService.updateNoisePublicKey(from: favorite.peerNoisePublicKey, to: noiseKey, peerNickname: nickname) + // Get the updated favorite with the new key + peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey) + peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey + } + } + allPeers.append(peer) + } + + // Add offline favorites (only those not currently connected/relay-connected AND that we actively favorite) + SecureLogger.log("📋 Processing \(favoritesService.favorites.count) favorite relationships (connected/relay nicknames: \(connectedNicknames))", + category: SecureLogger.session, level: .info) + + for (favoriteKey, favorite) in favoritesService.favorites { + let favoriteID = favorite.peerNoisePublicKey.hexEncodedString() + + // Skip if this peer is already connected or relay-connected (by nickname) + if connectedNicknames.contains(favorite.peerNickname) { + SecureLogger.log(" - Skipping '\(favorite.peerNickname)' (key: \(favoriteKey.hexEncodedString())) - already connected/relay-connected", + category: SecureLogger.session, level: .debug) + continue + } + + // Only add peers that WE favorite (not just ones who favorite us) + if !favorite.isFavorite { + SecureLogger.log(" - Skipping '\(favorite.peerNickname)' - we don't favorite them (they favorite us: \(favorite.theyFavoritedUs))", + category: SecureLogger.session, level: .debug) + continue + } + + // Add this favorite as an offline peer + SecureLogger.log(" - Adding offline favorite '\(favorite.peerNickname)' (key: \(favoriteKey.hexEncodedString()), ID: \(favoriteID), mutual: \(favorite.isMutual))", + category: SecureLogger.session, level: .info) + + var peer = BitchatPeer( + id: favoriteID, + noisePublicKey: favorite.peerNoisePublicKey, + nickname: favorite.peerNickname, + isConnected: false + ) + // Set favorite status + peer.favoriteStatus = favorite + peer.nostrPublicKey = favorite.peerNostrPublicKey + allPeers.append(peer) + } + + // Filter out "Unknown" peers unless they are favorites or have a favorite relationship + allPeers = allPeers.filter { peer in + !(peer.displayName == "Unknown" && peer.favoriteStatus == nil) + } + + // Sort: Connected first (direct then relay), then favorites, then alphabetical + allPeers.sort { lhs, rhs in + // Direct connections first + if lhs.isConnected != rhs.isConnected { + return lhs.isConnected + } + // Then relay connections + if lhs.isRelayConnected != rhs.isRelayConnected { + return lhs.isRelayConnected + } + // Then favorites + if lhs.isFavorite != rhs.isFavorite { + return lhs.isFavorite + } + // Finally alphabetical + return lhs.displayName < rhs.displayName + } + + // Single pass to compute all subsets and counts + var favorites: [BitchatPeer] = [] + var mutualFavorites: [BitchatPeer] = [] + var connectedCount = 0 + var offlineCount = 0 + + for peer in allPeers { + if peer.isFavorite { + favorites.append(peer) + } + if peer.isMutualFavorite { + mutualFavorites.append(peer) + } + if peer.isConnected { + connectedCount += 1 + } else { + offlineCount += 1 + } + } + + self.peers = allPeers + self.favorites = favorites + self.mutualFavorites = mutualFavorites + + // Always log favorites debug info when there are favorites + 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) + + // Log each peer's status + for peer in allPeers { + // Use the actual statusIcon from the peer which accounts for relay connections + let statusIcon: String + switch peer.connectionState { + case .bluetoothConnected: + statusIcon = "🟢" + case .relayConnected: + statusIcon = "🔗" + case .nostrAvailable: + statusIcon = "🌐" + case .offline: + statusIcon = "🔴" + } + let favoriteIcon = peer.isMutualFavorite ? "💕" : (peer.isFavorite ? "⭐" : (peer.theyFavoritedUs ? "🌙" : "")) + SecureLogger.log(" \(statusIcon) \(peer.displayName) (ID: \(peer.id.prefix(8))...) \(favoriteIcon)", + 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) + } + } + + func toggleFavorite(_ peer: BitchatPeer) { + if peer.isFavorite { + favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey) + } else { + favoritesService.addFavorite( + peerNoisePublicKey: peer.noisePublicKey, + peerNostrPublicKey: peer.nostrPublicKey, + peerNickname: peer.nickname + ) + } + updatePeers() + } +} \ No newline at end of file diff --git a/bitchat/Models/PeerSession.swift b/bitchat/Models/PeerSession.swift new file mode 100644 index 00000000..76982000 --- /dev/null +++ b/bitchat/Models/PeerSession.swift @@ -0,0 +1,91 @@ +import Foundation +import CoreBluetooth + +/// Unified model for tracking all peer session data +/// Consolidates multiple redundant data structures into a single source of truth +class PeerSession { + // Core identification + let peerID: String + var nickname: String + + // Bluetooth connection + var peripheral: CBPeripheral? + var peripheralID: String? + var characteristic: CBCharacteristic? + + // Authentication and encryption + var isAuthenticated: Bool = false + var hasEstablishedNoiseSession: Bool = false + var fingerprint: String? + + // Connection state + var isConnected: Bool = false + var lastSeen: Date + + // Protocol state + var hasAnnounced: Bool = false + var hasReceivedAnnounce: Bool = false + var isActivePeer: Bool = false + + // Message tracking + var lastMessageSent: Date? + var lastMessageReceived: Date? + var pendingMessages: [String] = [] + + // Connection timing + var lastConnectionTime: Date? + var lastSuccessfulMessageTime: Date? + var lastHeardFromPeer: Date? + + // Availability tracking + var isAvailable: Bool = false + + // Identity binding + var identityBinding: PeerIdentityBinding? + + init(peerID: String, nickname: String = "Unknown") { + self.peerID = peerID + self.nickname = nickname + self.lastSeen = Date() + } + + /// Update Bluetooth connection info + func updateBluetoothConnection(peripheral: CBPeripheral?, characteristic: CBCharacteristic?) { + self.peripheral = peripheral + self.peripheralID = peripheral?.identifier.uuidString + self.characteristic = characteristic + self.isConnected = (peripheral?.state == .connected) + if isConnected { + self.lastSeen = Date() + } + } + + /// Update authentication state + func updateAuthenticationState(authenticated: Bool, noiseSession: Bool) { + self.isAuthenticated = authenticated + self.hasEstablishedNoiseSession = noiseSession + if authenticated { + self.isActivePeer = true + } + } + + + /// Check if session is stale + var isStale: Bool { + // Consider stale if not seen for more than 5 minutes and not connected + return !isConnected && Date().timeIntervalSince(lastSeen) > 300 + } + + /// Get display status for UI + var displayStatus: String { + if isConnected { + if isAuthenticated { + return "🟢" // Connected and authenticated + } else { + return "🟡" // Connected but not authenticated + } + } else { + return "🔴" // Not connected + } + } +} \ No newline at end of file diff --git a/bitchat/Noise/NoiseHandshakeCoordinator.swift b/bitchat/Noise/NoiseHandshakeCoordinator.swift index 34a265e5..0d7e104a 100644 --- a/bitchat/Noise/NoiseHandshakeCoordinator.swift +++ b/bitchat/Noise/NoiseHandshakeCoordinator.swift @@ -42,6 +42,8 @@ class NoiseHandshakeCoordinator { private let handshakeTimeout: TimeInterval = 10.0 private let retryDelay: TimeInterval = 2.0 private let minTimeBetweenHandshakes: TimeInterval = 1.0 // Reduced from 5.0 for faster recovery + private let establishedSessionTTL: TimeInterval = 300.0 // 5 minutes - sessions older than this can be cleaned up + private let maxEstablishedSessions = 50 // Limit total established sessions // Track handshake messages to detect duplicates private var processedHandshakeMessages: Set = [] @@ -220,11 +222,12 @@ class NoiseHandshakeCoordinator { } } - /// Clean up stale handshake states + /// Clean up stale handshake states and old established sessions func cleanupStaleHandshakes(staleTimeout: TimeInterval = 30.0) -> [String] { return handshakeQueue.sync { let now = Date() var stalePeerIDs: [String] = [] + var establishedSessions: [(peerID: String, since: Date)] = [] for (peerID, state) in handshakeStates { var isStale = false @@ -242,6 +245,13 @@ class NoiseHandshakeCoordinator { if now > timeout { isStale = true } + case .established(let since): + // Track established sessions for potential cleanup + establishedSessions.append((peerID, since)) + // Clean up very old established sessions + if now.timeIntervalSince(since) > establishedSessionTTL { + isStale = true + } default: break } @@ -253,11 +263,30 @@ class NoiseHandshakeCoordinator { } } + // If we have too many established sessions, clean up the oldest ones + if establishedSessions.count > maxEstablishedSessions { + // Sort by age (oldest first) + let sortedSessions = establishedSessions.sorted { $0.since < $1.since } + let sessionsToRemove = sortedSessions.count - maxEstablishedSessions + + for i in 0.. Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { return nil } + return result as? Data + } + + static func delete(key: String, service: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + + SecItemDelete(query as CFDictionary) + } +} + +/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging +struct NostrIdentity: Codable { + let privateKey: Data + let publicKey: Data + let npub: String // Bech32-encoded public key + let createdAt: Date + + /// Memberwise initializer + init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) { + self.privateKey = privateKey + self.publicKey = publicKey + self.npub = npub + self.createdAt = createdAt + } + + /// Generate a new Nostr identity + static func generate() throws -> NostrIdentity { + // Generate Schnorr key for Nostr + let schnorrKey = try P256K.Schnorr.PrivateKey() + let xOnlyPubkey = Data(schnorrKey.xonly.bytes) + let npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey) + + return NostrIdentity( + privateKey: schnorrKey.dataRepresentation, + publicKey: xOnlyPubkey, // Store x-only public key + npub: npub, + createdAt: Date() + ) + } + + /// Initialize from existing private key data + init(privateKeyData: Data) throws { + let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyData) + let xOnlyPubkey = Data(schnorrKey.xonly.bytes) + + self.privateKey = privateKeyData + self.publicKey = xOnlyPubkey + self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey) + self.createdAt = Date() + } + + /// Get signing key for event signatures + func signingKey() throws -> P256K.Signing.PrivateKey { + try P256K.Signing.PrivateKey(dataRepresentation: privateKey) + } + + /// Get Schnorr signing key for Nostr event signatures + func schnorrSigningKey() throws -> P256K.Schnorr.PrivateKey { + try P256K.Schnorr.PrivateKey(dataRepresentation: privateKey) + } + + /// Get hex-encoded public key (for Nostr events) + var publicKeyHex: String { + // Public key is already stored as x-only (32 bytes) + return publicKey.hexEncodedString() + } +} + +/// Bridge between Noise and Nostr identities +struct NostrIdentityBridge { + private static let keychainService = "chat.bitchat.nostr" + private static let currentIdentityKey = "nostr-current-identity" + + /// Get or create the current Nostr identity + static func getCurrentNostrIdentity() throws -> NostrIdentity? { + // Check if we already have a Nostr identity + if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService), + let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) { + return identity + } + + // Generate new Nostr identity + let nostrIdentity = try NostrIdentity.generate() + + // Store it + let data = try JSONEncoder().encode(nostrIdentity) + KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService) + + return nostrIdentity + } + + /// Associate a Nostr identity with a Noise public key (for favorites) + static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) { + let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" + if let data = nostrPubkey.data(using: .utf8) { + KeychainHelper.save(key: key, data: data, service: keychainService) + } + } + + /// Get Nostr public key associated with a Noise public key + static func getNostrPublicKey(for noisePublicKey: Data) -> String? { + let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" + guard let data = KeychainHelper.load(key: key, service: keychainService), + let pubkey = String(data: data, encoding: .utf8) else { + return nil + } + return pubkey + } +} + +// Bech32 encoding for Nostr (minimal implementation) +enum Bech32 { + private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + + static func encode(hrp: String, data: Data) throws -> String { + let values = convertBits(from: 8, to: 5, pad: true, data: Array(data)) + let checksum = createChecksum(hrp: hrp, values: values) + let combined = values + checksum + + return hrp + "1" + combined.map { + let index = charset.index(charset.startIndex, offsetBy: Int($0)) + return String(charset[index]) + }.joined() + } + + static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) { + // Find the last occurrence of '1' + guard let separatorIndex = bech32String.lastIndex(of: "1") else { + throw Bech32Error.invalidFormat + } + + let hrp = String(bech32String[..= 6 else { + throw Bech32Error.invalidChecksum + } + + let payloadValues = Array(values.dropLast(6)) + let checksum = Array(values.suffix(6)) + let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues) + + guard checksum == expectedChecksum else { + throw Bech32Error.invalidChecksum + } + + // Convert back to bytes + let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues) + return (hrp: hrp, data: Data(bytes)) + } + + enum Bech32Error: Error { + case invalidFormat + case invalidCharacter + case invalidChecksum + } + + private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] { + var acc = 0 + var bits = 0 + var result = [UInt8]() + let maxv = (1 << to) - 1 + + for value in data { + acc = (acc << from) | Int(value) + bits += from + + while bits >= to { + bits -= to + result.append(UInt8((acc >> bits) & maxv)) + } + } + + if pad && bits > 0 { + result.append(UInt8((acc << (to - bits)) & maxv)) + } + + return result + } + + private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] { + let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0] + let polymod = polymod(checksumValues) ^ 1 + var checksum = [UInt8]() + + for i in 0..<6 { + checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31)) + } + + return checksum + } + + private static func hrpExpand(_ hrp: String) -> [UInt8] { + var result = [UInt8]() + for c in hrp { + result.append(UInt8(c.asciiValue! >> 5)) + } + result.append(0) + for c in hrp { + result.append(UInt8(c.asciiValue! & 31)) + } + return result + } + + private static func polymod(_ values: [UInt8]) -> Int { + var chk = 1 + for value in values { + let b = chk >> 25 + chk = (chk & 0x1ffffff) << 5 ^ Int(value) + for i in 0..<5 { + if (b >> i) & 1 == 1 { + chk ^= generator[i] + } + } + } + return chk + } +} + +// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift new file mode 100644 index 00000000..bdf5dd1b --- /dev/null +++ b/bitchat/Nostr/NostrProtocol.swift @@ -0,0 +1,560 @@ +import Foundation +import CryptoKit +import P256K + +// Note: This file depends on Data extension from BinaryEncodingUtils.swift +// Make sure BinaryEncodingUtils.swift is included in the target + +/// NIP-17 Protocol Implementation for Private Direct Messages +struct NostrProtocol { + + /// Nostr event kinds + enum EventKind: Int { + case metadata = 0 + case textNote = 1 + case seal = 13 // NIP-17 sealed event + case giftWrap = 1059 // NIP-17 gift wrap + case ephemeralEvent = 20000 + } + + /// Create a NIP-17 private message + static func createPrivateMessage( + content: String, + recipientPubkey: String, + senderIdentity: NostrIdentity + ) throws -> NostrEvent { + + // Creating private message + + // 1. Create the rumor (unsigned event) + let rumor = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .textNote, + tags: [], + content: content + ) + + // 2. Create ephemeral key for this message + let ephemeralKey = try P256K.Schnorr.PrivateKey() + let ephemeralPubkey = Data(ephemeralKey.xonly.bytes).hexEncodedString() + // Created ephemeral key for seal + + // 3. Seal the rumor (encrypt to recipient) + let sealedEvent = try createSeal( + rumor: rumor, + recipientPubkey: recipientPubkey, + senderKey: ephemeralKey + ) + + // 4. Gift wrap the sealed event (encrypt to recipient again) + let giftWrap = try createGiftWrap( + seal: sealedEvent, + recipientPubkey: recipientPubkey, + senderKey: ephemeralKey + ) + + // Created gift wrap + + return giftWrap + } + + /// Decrypt a received NIP-17 message + static func decryptPrivateMessage( + giftWrap: NostrEvent, + recipientIdentity: NostrIdentity + ) throws -> (content: String, senderPubkey: String) { + + // Starting decryption + + // 1. Unwrap the gift wrap + let seal: NostrEvent + do { + seal = try unwrapGiftWrap( + giftWrap: giftWrap, + recipientKey: recipientIdentity.schnorrSigningKey() + ) + // Successfully unwrapped gift wrap + } catch { + SecureLogger.log("❌ Failed to unwrap gift wrap: \(error)", + category: SecureLogger.session, level: .error) + throw error + } + + // 2. Open the seal + let rumor: NostrEvent + do { + rumor = try openSeal( + seal: seal, + recipientKey: recipientIdentity.schnorrSigningKey() + ) + // Successfully opened seal + } catch { + SecureLogger.log("❌ Failed to open seal: \(error)", + category: SecureLogger.session, level: .error) + throw error + } + + return (content: rumor.content, senderPubkey: rumor.pubkey) + } + + // MARK: - Private Methods + + private static func createSeal( + rumor: NostrEvent, + recipientPubkey: String, + senderKey: P256K.Schnorr.PrivateKey + ) throws -> NostrEvent { + + let rumorJSON = try rumor.jsonString() + let encrypted = try encrypt( + plaintext: rumorJSON, + recipientPubkey: recipientPubkey, + senderKey: senderKey + ) + + let seal = NostrEvent( + pubkey: Data(senderKey.xonly.bytes).hexEncodedString(), + createdAt: randomizedTimestamp(), + kind: .seal, + tags: [], + content: encrypted + ) + + // Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method) + let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: senderKey.dataRepresentation) + return try seal.sign(with: signingKey) + } + + private static func createGiftWrap( + seal: NostrEvent, + recipientPubkey: String, + senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal + ) throws -> NostrEvent { + + let sealJSON = try seal.jsonString() + + // Create new ephemeral key for gift wrap + let wrapKey = try P256K.Schnorr.PrivateKey() + // Creating gift wrap with ephemeral key + + // Encrypt the seal with the new ephemeral key (not the seal's key) + let encrypted = try encrypt( + plaintext: sealJSON, + recipientPubkey: recipientPubkey, + senderKey: wrapKey // Use the gift wrap ephemeral key + ) + + let giftWrap = NostrEvent( + pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(), + createdAt: randomizedTimestamp(), + kind: .giftWrap, + tags: [["p", recipientPubkey]], // Tag recipient + content: encrypted + ) + + // Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method) + let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: wrapKey.dataRepresentation) + return try giftWrap.sign(with: signingKey) + } + + private static func unwrapGiftWrap( + giftWrap: NostrEvent, + recipientKey: P256K.Schnorr.PrivateKey + ) throws -> NostrEvent { + + // Unwrapping gift wrap + + let decrypted = try decrypt( + ciphertext: giftWrap.content, + senderPubkey: giftWrap.pubkey, + recipientKey: recipientKey + ) + + guard let data = decrypted.data(using: .utf8), + let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw NostrError.invalidEvent + } + + let seal = try NostrEvent(from: sealDict) + // Unwrapped seal + + return seal + } + + private static func openSeal( + seal: NostrEvent, + recipientKey: P256K.Schnorr.PrivateKey + ) throws -> NostrEvent { + + let decrypted = try decrypt( + ciphertext: seal.content, + senderPubkey: seal.pubkey, + recipientKey: recipientKey + ) + + guard let data = decrypted.data(using: .utf8), + let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw NostrError.invalidEvent + } + + return try NostrEvent(from: rumorDict) + } + + // MARK: - Encryption (NIP-44 style) + + private static func encrypt( + plaintext: String, + recipientPubkey: String, + senderKey: P256K.Schnorr.PrivateKey + ) throws -> String { + + guard let recipientPubkeyData = Data(hexString: recipientPubkey) else { + throw NostrError.invalidPublicKey + } + + let senderPubkey = Data(senderKey.xonly.bytes).hexEncodedString() + // Encrypting message + + // Derive shared secret + let sharedSecret = try deriveSharedSecret( + privateKey: senderKey, + publicKey: recipientPubkeyData + ) + + // Derived shared secret + + // Generate nonce + let nonce = AES.GCM.Nonce() + + // Encrypt + let sealed = try AES.GCM.seal( + plaintext.data(using: .utf8)!, + using: SymmetricKey(data: sharedSecret), + nonce: nonce + ) + + // Combine nonce + ciphertext + tag + var result = Data() + result.append(nonce.withUnsafeBytes { Data($0) }) + result.append(sealed.ciphertext) + result.append(sealed.tag) + + return result.base64EncodedString() + } + + private static func decrypt( + ciphertext: String, + senderPubkey: String, + recipientKey: P256K.Schnorr.PrivateKey + ) throws -> String { + + // Decrypting message + + guard let data = Data(base64Encoded: ciphertext), + let senderPubkeyData = Data(hexString: senderPubkey) else { + SecureLogger.log("❌ Invalid ciphertext or sender pubkey format", + category: SecureLogger.session, level: .error) + throw NostrError.invalidCiphertext + } + + // Ciphertext data parsed + + // Extract components + let nonceData = data.prefix(12) + let ciphertextData = data.dropFirst(12).dropLast(16) + let tagData = data.suffix(16) + + // Components parsed + + // Derive shared secret - try with default Y coordinate first + var sharedSecret: Data + var decrypted: Data? = nil + + do { + sharedSecret = try deriveSharedSecret( + privateKey: recipientKey, + publicKey: senderPubkeyData + ) + // Derived shared secret with first Y coordinate + + // Try to decrypt + let sealedBox = try AES.GCM.SealedBox( + nonce: AES.GCM.Nonce(data: nonceData), + ciphertext: ciphertextData, + tag: tagData + ) + + do { + decrypted = try AES.GCM.open( + sealedBox, + using: SymmetricKey(data: sharedSecret) + ) + // AES-GCM decryption successful + } catch { + // AES-GCM decryption failed, trying alternate + + // If the sender pubkey is x-only (32 bytes), try the other Y coordinate + if senderPubkeyData.count == 32 { + // Trying alternate Y coordinate + + // Force deriveSharedSecret to use odd Y by manipulating the data + var altPubkey = Data() + altPubkey.append(0x03) // Force odd Y + altPubkey.append(senderPubkeyData) + + sharedSecret = try deriveSharedSecretDirect( + privateKey: recipientKey, + publicKey: altPubkey + ) + + decrypted = try AES.GCM.open( + sealedBox, + using: SymmetricKey(data: sharedSecret) + ) + // AES-GCM decryption successful with alternate Y + } else { + throw error + } + } + } catch { + SecureLogger.log("❌ Failed to derive shared secret or decrypt: \(error)", + category: SecureLogger.session, level: .error) + throw error + } + + guard let finalDecrypted = decrypted else { + throw NostrError.encryptionFailed + } + + return String(data: finalDecrypted, encoding: .utf8) ?? "" + } + + private static func deriveSharedSecret( + privateKey: P256K.Schnorr.PrivateKey, + publicKey: Data + ) throws -> Data { + // Deriving shared secret + + // Convert Schnorr private key to KeyAgreement private key + let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey( + dataRepresentation: privateKey.dataRepresentation + ) + + // Create KeyAgreement public key from the public key data + // For ECDH, we need the full 33-byte compressed public key (with 0x02 or 0x03 prefix) + var fullPublicKey = Data() + if publicKey.count == 32 { // X-only key, need to add prefix + // For x-only keys in Nostr/Bitcoin, we need to try both possible Y coordinates + // First try with even Y (0x02 prefix) + fullPublicKey.append(0x02) + fullPublicKey.append(publicKey) + // Trying with even Y coordinate + } else { + fullPublicKey = publicKey + } + + // Try to create public key, if it fails with even Y, try odd Y + let keyAgreementPublicKey: P256K.KeyAgreement.PublicKey + do { + keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( + dataRepresentation: fullPublicKey, + format: .compressed + ) + } catch { + if publicKey.count == 32 { + // Try with odd Y (0x03 prefix) + // Even Y failed, trying odd Y + fullPublicKey = Data() + fullPublicKey.append(0x03) + fullPublicKey.append(publicKey) + keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( + dataRepresentation: fullPublicKey, + format: .compressed + ) + } else { + throw error + } + } + + // Perform ECDH + let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement( + with: keyAgreementPublicKey, + format: .compressed + ) + + // Convert SharedSecret to Data + let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } + // ECDH shared secret derived + + // Derive key using HKDF for NIP-44 v2 + let derivedKey = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: sharedSecretData), + salt: "nip44-v2".data(using: .utf8)!, + info: Data(), + outputByteCount: 32 + ) + + let result = derivedKey.withUnsafeBytes { Data($0) } + // Final derived key ready + return result + } + + // Direct version that doesn't try to add prefixes + private static func deriveSharedSecretDirect( + privateKey: P256K.Schnorr.PrivateKey, + publicKey: Data + ) throws -> Data { + // Direct shared secret calculation + + // Convert Schnorr private key to KeyAgreement private key + let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey( + dataRepresentation: privateKey.dataRepresentation + ) + + // Use the public key as-is (should already have prefix) + let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( + dataRepresentation: publicKey, + format: .compressed + ) + + // Perform ECDH + let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement( + with: keyAgreementPublicKey, + format: .compressed + ) + + // Convert SharedSecret to Data + let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } + + // Derive key using HKDF for NIP-44 v2 + let derivedKey = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: sharedSecretData), + salt: "nip44-v2".data(using: .utf8)!, + info: Data(), + outputByteCount: 32 + ) + + return derivedKey.withUnsafeBytes { Data($0) } + } + + private static func randomizedTimestamp() -> Date { + // Add random offset to current time for privacy + // TEMPORARY: Reduced range to debug timestamp issue + let offset = TimeInterval.random(in: -60...60) // +/- 1 minute (was +/- 15 minutes) + let now = Date() + let randomized = now.addingTimeInterval(offset) + + // Log with explicit UTC and local time for debugging + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + formatter.timeZone = TimeZone(abbreviation: "UTC") + let nowUTC = formatter.string(from: now) + let randomizedUTC = formatter.string(from: randomized) + + formatter.timeZone = TimeZone.current + let nowLocal = formatter.string(from: now) + let randomizedLocal = formatter.string(from: randomized) + + // Timestamp randomized for privacy + + return randomized + } +} + +/// Nostr Event structure +struct NostrEvent: Codable { + var id: String + let pubkey: String + let created_at: Int + let kind: Int + let tags: [[String]] + let content: String + var sig: String? + + init( + pubkey: String, + createdAt: Date, + kind: NostrProtocol.EventKind, + tags: [[String]], + content: String + ) { + self.pubkey = pubkey + self.created_at = Int(createdAt.timeIntervalSince1970) + self.kind = kind.rawValue + self.tags = tags + self.content = content + self.sig = nil + self.id = "" // Will be set during signing + } + + init(from dict: [String: Any]) throws { + guard let pubkey = dict["pubkey"] as? String, + let createdAt = dict["created_at"] as? Int, + let kind = dict["kind"] as? Int, + let tags = dict["tags"] as? [[String]], + let content = dict["content"] as? String else { + throw NostrError.invalidEvent + } + + self.id = dict["id"] as? String ?? "" + self.pubkey = pubkey + self.created_at = createdAt + self.kind = kind + self.tags = tags + self.content = content + self.sig = dict["sig"] as? String + } + + func sign(with key: P256K.Signing.PrivateKey) throws -> NostrEvent { + let (eventId, eventIdHash) = try calculateEventId() + + // Convert to Schnorr key for Nostr signing + let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: key.dataRepresentation) + + // Sign with Schnorr + var messageBytes = [UInt8](eventIdHash) + var auxRand = [UInt8](repeating: 0, count: 32) // Zero auxiliary randomness for deterministic signing + let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand) + + let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString() + + var signed = self + signed.id = eventId + signed.sig = signatureHex + return signed + } + + private func calculateEventId() throws -> (String, Data) { + let serialized = [ + 0, + pubkey, + created_at, + kind, + tags, + content + ] as [Any] + + let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) + let hash = CryptoKit.SHA256.hash(data: data) + let hashData = Data(hash) + let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined() + return (hashHex, hashData) + } + + func jsonString() throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + let data = try encoder.encode(self) + return String(data: data, encoding: .utf8) ?? "" + } +} + +enum NostrError: Error { + case invalidPublicKey + case invalidPrivateKey + case invalidEvent + case invalidCiphertext + case signingFailed + case encryptionFailed +} \ No newline at end of file diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift new file mode 100644 index 00000000..c3f6c8e8 --- /dev/null +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -0,0 +1,552 @@ +import Foundation +import Network +import Combine + +/// Manages WebSocket connections to Nostr relays +@MainActor +class NostrRelayManager: ObservableObject { + static let shared = NostrRelayManager() + + struct Relay: Identifiable { + let id = UUID() + let url: String + var isConnected: Bool = false + var lastError: Error? + var lastConnectedAt: Date? + var messagesSent: Int = 0 + var messagesReceived: Int = 0 + var reconnectAttempts: Int = 0 + var lastDisconnectedAt: Date? + var nextReconnectTime: Date? + } + + // Default relay list (can be customized) + private static let defaultRelays = [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://offchain.pub", + "wss://nostr21.com" + // For local testing, you can add: "ws://localhost:8080" + ] + + @Published private(set) var relays: [Relay] = [] + @Published private(set) var isConnected = false + + private var connections: [String: URLSessionWebSocketTask] = [:] + private var subscriptions: [String: Set] = [:] // relay URL -> subscription IDs + private var messageHandlers: [String: (NostrEvent) -> Void] = [:] + private var cancellables = Set() + + // Message queue for reliability + private var messageQueue: [(event: NostrEvent, relayUrls: [String])] = [] + private let messageQueueLock = NSLock() + + // Exponential backoff configuration + private let initialBackoffInterval: TimeInterval = 1.0 // Start with 1 second + private let maxBackoffInterval: TimeInterval = 300.0 // Max 5 minutes + private let backoffMultiplier: Double = 2.0 // Double each time + private let maxReconnectAttempts = 10 // Stop after 10 attempts + + // Reconnection timer + private var reconnectionTimer: Timer? + + init() { + // Initialize with default relays + self.relays = Self.defaultRelays.map { Relay(url: $0) } + } + + /// Connect to all configured relays + func connect() { + for relay in relays { + connectToRelay(relay.url) + } + } + + /// Disconnect from all relays + func disconnect() { + for (_, task) in connections { + task.cancel(with: .goingAway, reason: nil) + } + connections.removeAll() + updateConnectionStatus() + } + + /// Send an event to specified relays (or all if none specified) + func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { + let targetRelays = relayUrls ?? relays.map { $0.url } + + // Add to queue for reliability + messageQueueLock.lock() + messageQueue.append((event, targetRelays)) + messageQueueLock.unlock() + + // Attempt immediate send + for relayUrl in targetRelays { + if let connection = connections[relayUrl] { + sendToRelay(event: event, connection: connection, relayUrl: relayUrl) + } + } + } + + /// Subscribe to events matching a filter + func subscribe( + filter: NostrFilter, + id: String = UUID().uuidString, + handler: @escaping (NostrEvent) -> Void + ) { + messageHandlers[id] = handler + + let req = NostrRequest.subscribe(id: id, filters: [filter]) + + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys // For consistent output + let message = try encoder.encode(req) + guard let messageString = String(data: message, encoding: .utf8) else { + SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error) + return + } + + // Sending subscription to relays + // Filter JSON prepared + // Full filter JSON logged + + // Send subscription to all connected relays + for (relayUrl, connection) in connections { + connection.send(.string(messageString)) { error in + if let error = error { + SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)", + category: SecureLogger.session, level: .error) + } else { + // Subscription sent successfully + Task { @MainActor in + var subs = self.subscriptions[relayUrl] ?? Set() + subs.insert(id) + self.subscriptions[relayUrl] = subs + } + } + } + } + + if connections.isEmpty { + SecureLogger.log("⚠️ No relay connections available for subscription", + category: SecureLogger.session, level: .warning) + } + } catch { + SecureLogger.log("❌ Failed to encode subscription request: \(error)", + category: SecureLogger.session, level: .error) + } + } + + /// Unsubscribe from a subscription + func unsubscribe(id: String) { + messageHandlers.removeValue(forKey: id) + + let req = NostrRequest.close(id: id) + let message = try? JSONEncoder().encode(req) + + guard let messageData = message, + let messageString = String(data: messageData, encoding: .utf8) else { return } + + // Send unsubscribe to all relays + for (relayUrl, connection) in connections { + if subscriptions[relayUrl]?.contains(id) == true { + connection.send(.string(messageString)) { _ in + Task { @MainActor in + self.subscriptions[relayUrl]?.remove(id) + } + } + } + } + } + + // MARK: - Private Methods + + private func connectToRelay(_ urlString: String) { + guard let url = URL(string: urlString) else { + SecureLogger.log("Invalid relay URL: \(urlString)", category: SecureLogger.session, level: .warning) + return + } + + // Attempting to connect to Nostr relay + + let session = URLSession(configuration: .default) + let task = session.webSocketTask(with: url) + + connections[urlString] = task + task.resume() + + // Start receiving messages + receiveMessage(from: task, relayUrl: urlString) + + // Send initial ping to verify connection + task.sendPing { [weak self] error in + DispatchQueue.main.async { + if error == nil { + // Successfully connected to Nostr relay + self?.updateRelayStatus(urlString, isConnected: true) + SecureLogger.log("Successfully connected to Nostr relay \(urlString)", + category: SecureLogger.session, level: .info) + } else { + SecureLogger.log("Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", + category: SecureLogger.session, level: .error) + self?.updateRelayStatus(urlString, isConnected: false, error: error) + // Trigger disconnection handler for proper backoff + self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil)) + } + } + } + } + + private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) { + task.receive { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let message): + switch message { + case .string(let text): + Task { @MainActor in + self.handleMessage(text, from: relayUrl) + } + case .data(let data): + if let text = String(data: data, encoding: .utf8) { + Task { @MainActor in + self.handleMessage(text, from: relayUrl) + } + } + @unknown default: + break + } + + // Continue receiving + Task { @MainActor in + self.receiveMessage(from: task, relayUrl: relayUrl) + } + + case .failure(let error): + DispatchQueue.main.async { + self.handleDisconnection(relayUrl: relayUrl, error: error) + } + } + } + } + + private func handleMessage(_ message: String, from relayUrl: String) { + guard let data = message.data(using: .utf8) else { return } + + do { + // Try to decode as an array first + if let array = try JSONSerialization.jsonObject(with: data) as? [Any], + array.count >= 2, + let type = array[0] as? String { + + // Received message from relay + + switch type { + case "EVENT": + if array.count >= 3, + let subId = array[1] as? String, + let eventDict = array[2] as? [String: Any] { + + let event = try NostrEvent(from: eventDict) + + // Processing event + + DispatchQueue.main.async { + // Update relay stats + if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { + self.relays[index].messagesReceived += 1 + } + + // Call handler + if let handler = self.messageHandlers[subId] { + handler(event) + } else { + SecureLogger.log("⚠️ No handler for subscription \(subId)", + category: SecureLogger.session, level: .warning) + } + } + } + + case "EOSE": + if array.count >= 2, + let _ = array[1] as? String { + // End of stored events + } + + case "OK": + if array.count >= 3, + let eventId = array[1] as? String, + let success = array[2] as? Bool { + let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given" + if !success { + SecureLogger.log("📮 Event \(eventId) rejected by relay: \(reason)", + category: SecureLogger.session, level: .error) + } + } + + case "NOTICE": + if array.count >= 2, + let notice = array[1] as? String { + SecureLogger.log("📢 Relay notice: \(notice)", + category: SecureLogger.session, level: .info) + } + + default: + break // Unknown message type + } + } + } catch { + SecureLogger.log("Failed to parse Nostr message: \(error)", category: SecureLogger.session, level: .error) + } + } + + private func sendToRelay(event: NostrEvent, connection: URLSessionWebSocketTask, relayUrl: String) { + let req = NostrRequest.event(event) + + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(req) + let message = String(data: data, encoding: .utf8) ?? "" + + // Sending event to relay + // Event JSON prepared + + connection.send(.string(message)) { [weak self] error in + DispatchQueue.main.async { + if let error = error { + SecureLogger.log("❌ Failed to send event to \(relayUrl): \(error)", + category: SecureLogger.session, level: .error) + } else { + // Update relay stats + if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) { + self?.relays[index].messagesSent += 1 + } + } + } + } + } catch { + SecureLogger.log("Failed to encode event: \(error)", category: SecureLogger.session, level: .error) + } + } + + private func updateRelayStatus(_ url: String, isConnected: Bool, error: Error? = nil) { + if let index = relays.firstIndex(where: { $0.url == url }) { + relays[index].isConnected = isConnected + relays[index].lastError = error + if isConnected { + relays[index].lastConnectedAt = Date() + relays[index].reconnectAttempts = 0 // Reset on successful connection + relays[index].nextReconnectTime = nil + } else { + relays[index].lastDisconnectedAt = Date() + } + } + updateConnectionStatus() + } + + private func updateConnectionStatus() { + isConnected = relays.contains { $0.isConnected } + } + + private func handleDisconnection(relayUrl: String, error: Error) { + connections.removeValue(forKey: relayUrl) + subscriptions.removeValue(forKey: relayUrl) + updateRelayStatus(relayUrl, isConnected: false, error: error) + + // Check if this is a DNS error + let errorDescription = error.localizedDescription.lowercased() + if errorDescription.contains("hostname could not be found") || + errorDescription.contains("dns") { + // Only log once for DNS failures + if relays.first(where: { $0.url == relayUrl })?.lastError == nil { + SecureLogger.log("Nostr relay DNS failure for \(relayUrl) - not retrying", category: SecureLogger.session, level: .warning) + } + // Mark relay as permanently failed + if let index = relays.firstIndex(where: { $0.url == relayUrl }) { + relays[index].lastError = error + } + return + } + + // Implement exponential backoff for non-DNS errors + guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return } + + relays[index].reconnectAttempts += 1 + + // Stop attempting after max attempts + if relays[index].reconnectAttempts >= maxReconnectAttempts { + SecureLogger.log("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", + category: SecureLogger.session, level: .warning) + return + } + + // Calculate backoff interval + let backoffInterval = min( + initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)), + maxBackoffInterval + ) + + let nextReconnectTime = Date().addingTimeInterval(backoffInterval) + relays[index].nextReconnectTime = nextReconnectTime + + SecureLogger.log("Scheduling reconnection to \(relayUrl) in \(Int(backoffInterval))s (attempt \(relays[index].reconnectAttempts))", + category: SecureLogger.session, level: .info) + + // Schedule reconnection with exponential backoff + DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in + guard let self = self else { return } + + // Check if we should still reconnect (relay might have been removed) + if self.relays.contains(where: { $0.url == relayUrl }) { + self.connectToRelay(relayUrl) + } + } + } + + // MARK: - Public Utility Methods + + /// Manually retry connection to a specific relay + func retryConnection(to relayUrl: String) { + guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return } + + // Reset reconnection attempts + relays[index].reconnectAttempts = 0 + relays[index].nextReconnectTime = nil + + // Disconnect if connected + if let connection = connections[relayUrl] { + connection.cancel(with: .goingAway, reason: nil) + connections.removeValue(forKey: relayUrl) + } + + // Attempt immediate reconnection + connectToRelay(relayUrl) + } + + /// Get detailed status for all relays + func getRelayStatuses() -> [(url: String, isConnected: Bool, reconnectAttempts: Int, nextReconnectTime: Date?)] { + return relays.map { relay in + (url: relay.url, + isConnected: relay.isConnected, + reconnectAttempts: relay.reconnectAttempts, + nextReconnectTime: relay.nextReconnectTime) + } + } + + /// Reset all relay connections + func resetAllConnections() { + disconnect() + + // Reset all relay states + for index in relays.indices { + relays[index].reconnectAttempts = 0 + relays[index].nextReconnectTime = nil + relays[index].lastError = nil + } + + // Reconnect + connect() + } +} + +// MARK: - Nostr Protocol Types + +enum NostrRequest: Encodable { + case event(NostrEvent) + case subscribe(id: String, filters: [NostrFilter]) + case close(id: String) + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + + switch self { + case .event(let event): + try container.encode("EVENT") + try container.encode(event) + + case .subscribe(let id, let filters): + try container.encode("REQ") + try container.encode(id) + for filter in filters { + try container.encode(filter) + } + + case .close(let id): + try container.encode("CLOSE") + try container.encode(id) + } + } +} + +struct NostrFilter: Encodable { + var ids: [String]? + var authors: [String]? + var kinds: [Int]? + var since: Int? + var until: Int? + var limit: Int? + + // Tag filters - stored internally but encoded specially + fileprivate var tagFilters: [String: [String]]? + + init() { + // Default initializer + } + + // Custom encoding to handle tag filters properly + enum CodingKeys: String, CodingKey { + case ids, authors, kinds, since, until, limit + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: DynamicCodingKey.self) + + // Encode standard fields + if let ids = ids { try container.encode(ids, forKey: DynamicCodingKey(stringValue: "ids")) } + if let authors = authors { try container.encode(authors, forKey: DynamicCodingKey(stringValue: "authors")) } + if let kinds = kinds { try container.encode(kinds, forKey: DynamicCodingKey(stringValue: "kinds")) } + if let since = since { try container.encode(since, forKey: DynamicCodingKey(stringValue: "since")) } + if let until = until { try container.encode(until, forKey: DynamicCodingKey(stringValue: "until")) } + if let limit = limit { try container.encode(limit, forKey: DynamicCodingKey(stringValue: "limit")) } + + // Encode tag filters with # prefix + if let tagFilters = tagFilters { + for (tag, values) in tagFilters { + try container.encode(values, forKey: DynamicCodingKey(stringValue: "#\(tag)")) + } + } + } + + // For NIP-17 gift wraps + static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter { + var filter = NostrFilter() + filter.kinds = [1059] // Gift wrap kind + filter.since = since?.timeIntervalSince1970.toInt() + filter.tagFilters = ["p": [pubkey]] + filter.limit = 100 // Add a reasonable limit + return filter + } +} + +// Dynamic coding key for tag filters +private struct DynamicCodingKey: CodingKey { + var stringValue: String + var intValue: Int? { nil } + + init(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue: Int) { + return nil + } +} + +private extension TimeInterval { + func toInt() -> Int { + return Int(self) + } +} diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index e2ff956b..57a7ff39 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -162,6 +162,10 @@ enum MessageType: UInt8 { case systemValidation = 0x24 // Session validation ping case handshakeRequest = 0x25 // Request handshake for pending messages + // Favorite system messages + case favorited = 0x30 // Peer favorited us + case unfavorited = 0x31 // Peer unfavorited us + var description: String { switch self { case .announce: return "announce" @@ -183,6 +187,8 @@ enum MessageType: UInt8 { case .protocolNack: return "protocolNack" case .systemValidation: return "systemValidation" case .handshakeRequest: return "handshakeRequest" + case .favorited: return "favorited" + case .unfavorited: return "unfavorited" } } } @@ -369,7 +375,7 @@ struct DeliveryAck: Codable { struct ReadReceipt: Codable { let originalMessageID: String let receiptID: String - let readerID: String // Who read it + var readerID: String // Who read it let readerNickname: String let timestamp: Date diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 9ad84e1f..e7414dd1 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -149,19 +149,129 @@ class BluetoothMeshService: NSObject { private var centralManager: CBCentralManager? private var peripheralManager: CBPeripheralManager? private var discoveredPeripherals: [CBPeripheral] = [] - private var connectedPeripherals: [String: CBPeripheral] = [:] + private var connectedPeripherals: [String: CBPeripheral] = [:] // Still needed for peripheral management private var peripheralCharacteristics: [CBPeripheral: CBCharacteristic] = [:] - // MARK: - Connection Tracking + // MARK: - Unified Peer Session Tracking - private var lastConnectionTime: [String: Date] = [:] // Track when peers last connected - private var lastSuccessfulMessageTime: [String: Date] = [:] // Track last successful message exchange - private var lastHeardFromPeer: [String: Date] = [:] // Track last time we received ANY packet from peer + /// Single source of truth for all peer data + private var peerSessions: [String: PeerSession] = [:] // peerID -> PeerSession + + // MARK: - Migration Helpers + + /// Update peripheral connection - safe to call from within collectionsQueue + private func updatePeripheralConnection(_ peerID: String, peripheral: CBPeripheral?, characteristic: CBCharacteristic? = nil) { + // Check if we're already on the collections queue + if DispatchQueue.getSpecific(key: collectionsQueueKey) != nil { + // Already on collections queue, update directly + if let session = peerSessions[peerID] { + session.updateBluetoothConnection(peripheral: peripheral, characteristic: characteristic) + } else if let peripheral = peripheral { + // Create session if needed + let session = PeerSession(peerID: peerID) + session.updateBluetoothConnection(peripheral: peripheral, characteristic: characteristic) + peerSessions[peerID] = session + } + + // Still need to update these legacy mappings for peripheral management + if let peripheral = peripheral { + connectedPeripherals[peerID] = peripheral + if let characteristic = characteristic { + peripheralCharacteristics[peripheral] = characteristic + } + } else { + connectedPeripherals.removeValue(forKey: peerID) + } + } else { + // Not on collections queue, dispatch sync + collectionsQueue.sync(flags: .barrier) { + if let session = peerSessions[peerID] { + session.updateBluetoothConnection(peripheral: peripheral, characteristic: characteristic) + } else if let peripheral = peripheral { + // Create session if needed + let session = PeerSession(peerID: peerID) + session.updateBluetoothConnection(peripheral: peripheral, characteristic: characteristic) + peerSessions[peerID] = session + } + + // Still need to update these legacy mappings for peripheral management + if let peripheral = peripheral { + connectedPeripherals[peerID] = peripheral + if let characteristic = characteristic { + peripheralCharacteristics[peripheral] = characteristic + } + } else { + connectedPeripherals.removeValue(forKey: peerID) + } + } + } + } + + /// Update last heard time for a peer - safe to call from within collectionsQueue + private func updateLastHeardFromPeer(_ peerID: String) { + let now = Date() + + // Check if we're already on the collections queue + if DispatchQueue.getSpecific(key: collectionsQueueKey) != nil { + // Already on collections queue, update directly + if let session = peerSessions[peerID] { + session.lastHeardFromPeer = now + } + } else { + // Not on collections queue, dispatch sync + collectionsQueue.sync(flags: .barrier) { + if let session = peerSessions[peerID] { + session.lastHeardFromPeer = now + } + } + } + } + + /// Update last successful message time for a peer - safe to call from within collectionsQueue + private func updateLastSuccessfulMessageTime(_ peerID: String) { + let now = Date() + + // Check if we're already on the collections queue + if DispatchQueue.getSpecific(key: collectionsQueueKey) != nil { + // Already on collections queue, update directly + if let session = peerSessions[peerID] { + session.lastSuccessfulMessageTime = now + } + } else { + // Not on collections queue, dispatch sync + collectionsQueue.sync(flags: .barrier) { + if let session = peerSessions[peerID] { + session.lastSuccessfulMessageTime = now + } + } + } + } + + /// Update last connection time for a peer - safe to call from within collectionsQueue + private func updateLastConnectionTime(_ peerID: String) { + let now = Date() + + // Check if we're already on the collections queue + if DispatchQueue.getSpecific(key: collectionsQueueKey) != nil { + // Already on collections queue, update directly + if let session = peerSessions[peerID] { + session.lastConnectionTime = now + } + } else { + // Not on collections queue, dispatch sync + collectionsQueue.sync(flags: .barrier) { + if let session = peerSessions[peerID] { + session.lastConnectionTime = now + } + } + } + } + + // MARK: - Connection Tracking // MARK: - Peer Availability // Peer availability tracking - private var peerAvailabilityState: [String: Bool] = [:] // true = available, false = unavailable private let peerAvailabilityTimeout: TimeInterval = 30.0 // Mark unavailable after 30s of no response private var availabilityCheckTimer: Timer? @@ -173,11 +283,7 @@ class BluetoothMeshService: NSObject { // MARK: - Thread-Safe Collections private let collectionsQueue = DispatchQueue(label: "bitchat.collections", attributes: .concurrent) - private var peerNicknames: [String: String] = [:] - private var activePeers: Set = [] // Track all active peers - private var peerRSSI: [String: NSNumber] = [:] // Track RSSI values for peers - private var peripheralRSSI: [String: NSNumber] = [:] // Track RSSI by peripheral ID during discovery - private var rssiRetryCount: [String: Int] = [:] // Track RSSI retry attempts per peripheral + private let collectionsQueueKey = DispatchSpecificKey() // MARK: - Encryption Queues @@ -190,9 +296,7 @@ class BluetoothMeshService: NSObject { private var peerIDToFingerprint: [String: String] = [:] // PeerID -> Fingerprint private var fingerprintToPeerID: [String: String] = [:] // Fingerprint -> Current PeerID private var peerIdentityBindings: [String: PeerIdentityBinding] = [:] // Fingerprint -> Full binding - private var previousPeerID: String? // Our previous peer ID for grace period private var rotationTimestamp: Date? // When we last rotated - private let rotationGracePeriod: TimeInterval = 60.0 // 1 minute grace period private var rotationLocked = false // Prevent rotation during critical operations private var rotationTimer: Timer? // Timer for scheduled rotations @@ -412,8 +516,6 @@ class BluetoothMeshService: NSObject { // MARK: - Network State Management private let maxTTL: UInt8 = 7 // Maximum hops for long-distance delivery - private var announcedToPeers = Set() // Track which peers we've announced to - private var announcedPeers = Set() // Track peers who have already been announced private var hasNotifiedNetworkAvailable = false // Track if we've notified about network availability private var lastNetworkNotificationTime: Date? // Track when we last sent a network notification private var networkBecameEmptyTime: Date? // Track when the network became empty @@ -423,6 +525,8 @@ class BluetoothMeshService: NSObject { private var gracefullyLeftPeers = Set() // Track peers that sent leave messages private var gracefulLeaveTimestamps: [String: Date] = [:] // Track when peers left private let gracefulLeaveExpirationTime: TimeInterval = 300.0 // 5 minutes + private var recentDisconnectNotifications: [String: Date] = [:] // Track recent disconnect notifications to prevent duplicates + private let disconnectNotificationDedupeWindow: TimeInterval = 2.0 // 2 seconds window for deduplication private var peerLastSeenTimestamps = LRUCache(maxSize: 100) // Bounded cache for peer timestamps private var cleanupTimer: Timer? // Timer to clean up stale peers @@ -454,10 +558,12 @@ class BluetoothMeshService: NSObject { private var isActivelyScanning = true private var activeScanDuration: TimeInterval = 5.0 // will be adjusted based on battery private var scanPauseDuration: TimeInterval = 10.0 // will be adjusted based on battery - private var lastRSSIUpdate: [String: Date] = [:] // Throttle RSSI updates private var batteryMonitorTimer: Timer? private var currentBatteryLevel: Float = 1.0 // Default to full battery + // App state tracking + private var isAppInForeground = true + // Battery optimizer integration private let batteryOptimizer = BatteryOptimizer.shared private var batteryOptimizerCancellables = Set() @@ -512,20 +618,6 @@ class BluetoothMeshService: NSObject { private let minMessageDelay: TimeInterval = 0.01 // 10ms minimum for faster sync private let maxMessageDelay: TimeInterval = 0.1 // 100ms maximum for faster sync - // MARK: - RSSI Management - - // Dynamic RSSI threshold based on network size (smart compromise) - private var dynamicRSSIThreshold: Int { - let peerCount = activePeers.count - if peerCount < 5 { - return -95 // Maximum range when network is small - } else if peerCount < 10 { - return -92 // Slightly reduced range - } else { - return -90 // Conservative for larger networks - } - } - // MARK: - Fragment Handling // Fragment handling with security limits @@ -537,7 +629,14 @@ class BluetoothMeshService: NSObject { // MARK: - Peer Identity - var myPeerID: String + var myPeerID: String { + didSet { + if oldValue != "" && oldValue != myPeerID { + SecureLogger.log("📍 MY PEER ID CHANGED: \(oldValue) -> \(myPeerID)", + category: SecureLogger.security, level: .warning) + } + } + } // MARK: - Scaling Optimizations @@ -547,6 +646,7 @@ class BluetoothMeshService: NSObject { private var connectionBackoff: [String: TimeInterval] = [:] private var lastActivityByPeripheralID: [String: Date] = [:] // Track last activity for LRU private var peerIDByPeripheralID: [String: String] = [:] // Map peripheral ID to peer ID + private var lastVersionHelloTime: [String: Date] = [:] // Track when version hello received from peer private let maxConnectionAttempts = 3 private let baseBackoffInterval: TimeInterval = 1.0 @@ -556,7 +656,6 @@ class BluetoothMeshService: NSObject { private struct PeripheralMapping { let peripheral: CBPeripheral var peerID: String? // nil until we receive announce - var rssi: NSNumber? var lastActivity: Date var isIdentified: Bool { peerID != nil } @@ -569,7 +668,6 @@ class BluetoothMeshService: NSObject { peripheralMappings[peripheralID] = PeripheralMapping( peripheral: peripheral, peerID: nil, - rssi: nil, lastActivity: Date() ) } @@ -589,23 +687,15 @@ class BluetoothMeshService: NSObject { // Update legacy mappings peerIDByPeripheralID[peripheralID] = peerID - connectedPeripherals[peerID] = mapping.peripheral + updatePeripheralConnection(peerID, peripheral: mapping.peripheral) - // Transfer RSSI - if let rssi = mapping.rssi { - peerRSSI[peerID] = rssi - } - } - - private func updatePeripheralRSSI(peripheralID: String, rssi: NSNumber) { - guard var mapping = peripheralMappings[peripheralID] else { return } - mapping.rssi = rssi - mapping.lastActivity = Date() - peripheralMappings[peripheralID] = mapping - - // Update peer RSSI if we have the peer ID - if let peerID = mapping.peerID { - peerRSSI[peerID] = rssi + // Update PeerSession + if let session = peerSessions[peerID] { + session.updateBluetoothConnection(peripheral: mapping.peripheral, characteristic: nil) + } else { + let session = PeerSession(peerID: peerID) + session.updateBluetoothConnection(peripheral: mapping.peripheral, characteristic: nil) + peerSessions[peerID] = session } } @@ -642,7 +732,10 @@ class BluetoothMeshService: NSObject { // Network size estimation private var estimatedNetworkSize: Int { - return max(activePeers.count, connectedPeripherals.count) + return collectionsQueue.sync { + let activePeerCount = peerSessions.values.filter { $0.isActivePeer }.count + return max(activePeerCount, connectedPeripherals.count) + } } // Adaptive parameters based on network size @@ -662,7 +755,7 @@ class BluetoothMeshService: NSObject { private var adaptiveRelayProbability: Double { // Adjust relay probability based on network size - // Small networks don't need 100% relay - RSSI will handle edge nodes + // Small networks don't need 100% relay let networkSize = estimatedNetworkSize if networkSize <= 2 { return 0.0 // 0% for 2 nodes - no relay needed with only 2 peers @@ -791,13 +884,27 @@ class BluetoothMeshService: NSObject { /// - Returns: The peer's Noise static key fingerprint if known, nil otherwise /// - Note: This fingerprint remains stable across peer ID rotations func getPeerFingerprint(_ peerID: String) -> String? { + // Check PeerSession first for O(1) lookup + if let fingerprint = collectionsQueue.sync(execute: { + peerSessions[peerID]?.fingerprint + }) { + return fingerprint + } + + // Fallback to noise service return noiseService.getPeerFingerprint(peerID) } // Get fingerprint for a peer ID func getFingerprint(for peerID: String) -> String? { return collectionsQueue.sync { - peerIDToFingerprint[peerID] + // Check PeerSession first for O(1) lookup + if let fingerprint = peerSessions[peerID]?.fingerprint { + return fingerprint + } + + // Fallback to peerIDToFingerprint map + return peerIDToFingerprint[peerID] } } @@ -810,17 +917,137 @@ class BluetoothMeshService: NSObject { return true } - // Check if it's our previous ID within grace period - if let previousID = previousPeerID, - peerID == previousID, - let rotationTime = rotationTimestamp, - Date().timeIntervalSince(rotationTime) < rotationGracePeriod { - return true - } - return false } + // MARK: - Peer Session Management + + /// Get or create a peer session + private func getPeerSession(_ peerID: String) -> PeerSession { + return collectionsQueue.sync(flags: .barrier) { + if let session = peerSessions[peerID] { + return session + } + let newSession = PeerSession(peerID: peerID) + peerSessions[peerID] = newSession + return newSession + } + } + + /// Get peer session if it exists + private func getExistingPeerSession(_ peerID: String) -> PeerSession? { + return collectionsQueue.sync { + return peerSessions[peerID] + } + } + + /// Update peer session with nickname + private func updatePeerSessionNickname(_ peerID: String, nickname: String) { + collectionsQueue.async(flags: .barrier) { + if let session = self.peerSessions[peerID] { + session.nickname = nickname + } else { + let session = PeerSession(peerID: peerID, nickname: nickname) + self.peerSessions[peerID] = session + } + } + } + + /// Create a peer session only if we have a valid connection + /// This prevents ghost sessions from being created + private func createPeerSessionIfValid(_ peerID: String, peripheral: CBPeripheral? = nil, nickname: String = "Unknown") -> PeerSession? { + return collectionsQueue.sync(flags: .barrier) { + // Check if we already have a session + if let existingSession = self.peerSessions[peerID] { + return existingSession + } + + // Only create new sessions if we have a peripheral connection or it's for authenticated purposes + guard peripheral != nil && peripheral?.state == .connected else { + SecureLogger.log("Refusing to create session for \(peerID) without peripheral connection", + category: SecureLogger.session, level: .debug) + return nil + } + + let session = PeerSession(peerID: peerID, nickname: nickname) + if let peripheral = peripheral { + session.updateBluetoothConnection(peripheral: peripheral, characteristic: nil) + } + self.peerSessions[peerID] = session + return session + } + } + + /// Clean up Unknown peers that haven't announced themselves and old disconnected sessions + private func cleanupUnknownPeers() { + collectionsQueue.async(flags: .barrier) { + let sessionsToRemove = self.peerSessions.filter { (peerID, session) in + // Remove if it's an Unknown peer without a proper announcement + if session.nickname == "Unknown" && + !session.hasReceivedAnnounce && + session.peripheral == nil && + Date().timeIntervalSince(session.lastSeen) > 3.0 { + return true + } + + // Remove disconnected non-favorite sessions after 5 minutes + if !session.isConnected && !session.isActivePeer && + Date().timeIntervalSince(session.lastSeen) > 300.0 { + // Non-favorites will be cleaned up + // Favorites are kept indefinitely + return true + } + + return false + } + + for (peerID, session) in sessionsToRemove { + if session.nickname == "Unknown" { + SecureLogger.log("Removing Unknown peer \(peerID) - no announce received", + category: SecureLogger.session, level: .debug) + } else { + SecureLogger.log("Removing disconnected non-favorite peer \(peerID) (\(session.nickname)) - disconnected too long", + category: SecureLogger.session, level: .debug) + } + self.peerSessions.removeValue(forKey: peerID) + } + + if !sessionsToRemove.isEmpty { + DispatchQueue.main.async { + self.notifyPeerListUpdate(immediate: true) + } + } + } + } + + /// Clean up stale peer sessions + private func cleanupStaleSessions() { + collectionsQueue.async(flags: .barrier) { + // Remove stale sessions and ghost sessions without proper connections + let sessionsToRemove = self.peerSessions.filter { (peerID, session) in + // Remove if stale + if session.isStale { + return true + } + // Remove if it's a ghost session (no peripheral, not authenticated, and has default nickname) + if session.peripheral == nil && + !session.isAuthenticated && + !session.hasEstablishedNoiseSession && + !session.hasReceivedAnnounce && + (session.nickname == "Unknown" || session.nickname.isEmpty) { + return true + } + return false + } + + for (peerID, session) in sessionsToRemove { + SecureLogger.log("Removing stale/ghost session for \(peerID) (nickname: \(session.nickname))", + category: SecureLogger.session, level: .debug) + self.peerSessions.removeValue(forKey: peerID) + } + } + } + /// Updates the identity binding for a peer when they rotate their ephemeral ID. /// - Parameters: /// - newPeerID: The peer's new ephemeral ID @@ -849,35 +1076,40 @@ class BluetoothMeshService: NSObject { self.peerIDToFingerprint.removeValue(forKey: existingPeerID) - // Transfer nickname if known - if let nickname = self.peerNicknames[existingPeerID] { - self.peerNicknames[newPeerID] = nickname - self.peerNicknames.removeValue(forKey: existingPeerID) + // Transfer session data from old to new peer ID + if let oldSession = self.peerSessions[existingPeerID] { + // Extract all data from old session + let nickname = oldSession.nickname + let lastHeard = oldSession.lastHeardFromPeer + let peripheral = oldSession.peripheral + + // Create or update session for new peer ID + if let newSession = self.peerSessions[newPeerID] { + newSession.nickname = nickname + newSession.lastHeardFromPeer = lastHeard + if let peripheral = peripheral { + newSession.updateBluetoothConnection(peripheral: peripheral, characteristic: nil) + } + } else { + let newSession = PeerSession(peerID: newPeerID, nickname: nickname) + newSession.lastHeardFromPeer = lastHeard + if let peripheral = peripheral { + newSession.updateBluetoothConnection(peripheral: peripheral, characteristic: nil) + } + self.peerSessions[newPeerID] = newSession + } + + // Mark old session as inactive + oldSession.isActivePeer = false + oldSession.isConnected = false } - // Update active peers set - always remove old peer ID - self.activePeers.remove(existingPeerID) - // Don't pre-insert the new peer ID - let the announce packet handle it - // This ensures the connect message logic works properly - - // Transfer any connected peripherals + // Transfer any connected peripherals in the legacy mapping if let peripheral = self.connectedPeripherals[existingPeerID] { self.connectedPeripherals.removeValue(forKey: existingPeerID) self.connectedPeripherals[newPeerID] = peripheral } - // Transfer RSSI data - if let rssi = self.peerRSSI[existingPeerID] { - self.peerRSSI.removeValue(forKey: existingPeerID) - self.peerRSSI[newPeerID] = rssi - } - - // Transfer lastHeardFromPeer tracking - if let lastHeard = self.lastHeardFromPeer[existingPeerID] { - self.lastHeardFromPeer.removeValue(forKey: existingPeerID) - self.lastHeardFromPeer[newPeerID] = lastHeard - } - // Clean up connection state for old peer ID self.peerConnectionStates.removeValue(forKey: existingPeerID) // Don't transfer connection state - let it be re-established naturally @@ -898,7 +1130,12 @@ class BluetoothMeshService: NSObject { self.identityCacheTimestamps[newPeerID] = Date() // Also update nickname from binding - self.peerNicknames[newPeerID] = binding.nickname + if let session = self.peerSessions[newPeerID] { + session.nickname = binding.nickname + } else { + let session = PeerSession(peerID: newPeerID, nickname: binding.nickname) + self.peerSessions[newPeerID] = session + } // Notify about the change if it's a rotation if let oldID = oldPeerID { @@ -913,6 +1150,9 @@ class BluetoothMeshService: NSObject { self.notifyPeerIDChange(oldPeerID: oldID, newPeerID: newPeerID, fingerprint: fingerprint) + // Remove the old peer session immediately to prevent "Unknown" peers + self.peerSessions.removeValue(forKey: oldID) + // Lazy handshake: No longer initiate handshake on peer ID rotation // Handshake will be initiated when first private message is sent } @@ -938,13 +1178,17 @@ class BluetoothMeshService: NSObject { DispatchQueue.main.async { [weak self] in // Remove old peer ID from active peers and announcedPeers self?.collectionsQueue.sync(flags: .barrier) { - _ = self?.activePeers.remove(oldPeerID) + if let session = self?.peerSessions[oldPeerID] { + session.isActivePeer = false + } // Don't pre-insert the new peer ID - let the announce packet handle it // This ensures the connect message logic works properly } - // Also remove from announcedPeers so the new ID can trigger a connect message - self?.announcedPeers.remove(oldPeerID) + // Update PeerSession for old peer ID + if let session = self?.peerSessions[oldPeerID] { + session.hasReceivedAnnounce = false + } // Update peer list self?.notifyPeerListUpdate(immediate: true) @@ -992,21 +1236,52 @@ class BluetoothMeshService: NSObject { SecureLogger.log("Peer \(peerID) connection state: \(previousState?.description ?? "nil") -> \(state)", category: SecureLogger.session, level: .debug) - // Update activePeers based on authentication state + // Update PeerSession based on authentication state self.collectionsQueue.async(flags: .barrier) { switch state { case .authenticated: - if !self.activePeers.contains(peerID) { - self.activePeers.insert(peerID) - SecureLogger.log("Added \(peerID) to activePeers (authenticated)", + if self.peerSessions[peerID]?.isActivePeer != true { + // Update PeerSession + if let session = self.peerSessions[peerID] { + session.isActivePeer = true + } + SecureLogger.log("Marked \(peerID) as active (authenticated)", category: SecureLogger.session, level: .info) + + // Update PeerSession authentication state + if let session = self.peerSessions[peerID] { + session.updateAuthenticationState(authenticated: true, noiseSession: true) + } else { + let session = PeerSession(peerID: peerID) + session.updateAuthenticationState(authenticated: true, noiseSession: true) + self.peerSessions[peerID] = session + } + } case .disconnected: - if self.activePeers.contains(peerID) { - self.activePeers.remove(peerID) - SecureLogger.log("Removed \(peerID) from activePeers (disconnected)", + if self.peerSessions[peerID]?.isActivePeer == true { + // Update PeerSession + if let session = self.peerSessions[peerID] { + session.isActivePeer = false + } + SecureLogger.log("Marked \(peerID) as inactive (disconnected)", category: SecureLogger.session, level: .info) } + + // Update PeerSession to disconnected state + if let session = self.peerSessions[peerID] { + session.updateAuthenticationState(authenticated: false, noiseSession: false) + session.updateBluetoothConnection(peripheral: nil, characteristic: nil) + session.isConnected = false + session.isActivePeer = false + // Keep the session but mark it as disconnected + // This allows us to detect reconnections + SecureLogger.log("Marked \(peerID) (\(session.nickname)) as disconnected in peerSessions", + category: SecureLogger.session, level: .info) + } + + // Note: PeerSession is already updated in the disconnect case above + // Clean up old mappings default: break } @@ -1062,9 +1337,8 @@ class BluetoothMeshService: NSObject { collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - // Save current peer ID as previous + // Save current peer ID let oldID = self.myPeerID - self.previousPeerID = oldID self.rotationTimestamp = Date() // Generate new peer ID @@ -1141,9 +1415,15 @@ class BluetoothMeshService: NSObject { super.init() self.myPeerID = generateNewPeerID() + // Set up queue-specific key for deadlock prevention + collectionsQueue.setSpecific(key: collectionsQueueKey, value: ()) + centralManager = CBCentralManager(delegate: self, queue: nil) peripheralManager = CBPeripheralManager(delegate: self, queue: nil) + // Setup app state notifications + setupAppStateNotifications() + // Start bloom filter reset timer (reset every 5 minutes) bloomFilterResetTimer = Timer.scheduledTimer(withTimeInterval: 300.0, repeats: true) { [weak self] _ in self?.messageQueue.async(flags: .barrier) { @@ -1153,19 +1433,42 @@ class BluetoothMeshService: NSObject { let networkSize = self.estimatedNetworkSize self.messageBloomFilter = OptimizedBloomFilter.adaptive(for: networkSize) - // Clear other duplicate detection sets + // Clean up old processed messages (keep last 10 minutes of messages) self.processedMessagesLock.lock() - self.processedMessages.removeAll() + let cutoffTime = Date().addingTimeInterval(-600) // 10 minutes ago + let originalCount = self.processedMessages.count + self.processedMessages = self.processedMessages.filter { _, state in + state.firstSeen > cutoffTime + } + + // Also enforce max size limit during cleanup + if self.processedMessages.count > self.maxProcessedMessages { + let targetSize = Int(Double(self.maxProcessedMessages) * 0.8) + let sortedByFirstSeen = self.processedMessages.sorted { $0.value.firstSeen < $1.value.firstSeen } + let toKeep = Array(sortedByFirstSeen.suffix(targetSize)) + self.processedMessages = Dictionary(uniqueKeysWithValues: toKeep) + } + + let removedCount = originalCount - self.processedMessages.count self.processedMessagesLock.unlock() + if removedCount > 0 { + SecureLogger.log("🧹 Cleaned up \(removedCount) old processed messages (kept \(self.processedMessages.count) from last 10 minutes)", + category: SecureLogger.session, level: .debug) + } } } // Start stale peer cleanup timer (every 30 seconds) - cleanupTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in + cleanupTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in self?.cleanupStalePeers() } + // Start more aggressive cleanup for Unknown peers (every 2 seconds) + Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + self?.cleanupUnknownPeers() + } + // Start ACK timeout checking timer (every 2 seconds for timely retries) Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in self?.checkAckTimeouts() @@ -1176,11 +1479,6 @@ class BluetoothMeshService: NSObject { self?.checkPeerAvailability() } - // Start RSSI update timer (every 10 seconds) - Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in - self?.updateAllPeripheralRSSI() - } - // Start write queue cleanup timer (every 30 seconds) writeQueueTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in self?.cleanExpiredWriteQueues() @@ -1199,9 +1497,8 @@ class BluetoothMeshService: NSObject { self?.sendKeepAlivePings() } - // Log handshake states periodically for debugging and clean up stale states - #if DEBUG - Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in + // Clean up stale handshake states periodically + Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in guard let self = self else { return } // Clean up stale handshakes @@ -1210,27 +1507,44 @@ class BluetoothMeshService: NSObject { for peerID in stalePeerIDs { // Also remove from noise service self.cleanupPeerCryptoState(peerID) - SecureLogger.log("Cleaned up stale handshake for \(peerID)", category: SecureLogger.handshake, level: .info) + SecureLogger.log("Cleaned up stale handshake for peer: \(peerID)", + category: SecureLogger.handshake, level: .info) } } + #if DEBUG self.handshakeCoordinator.logHandshakeStates() + #endif } - #endif // Schedule first peer ID rotation scheduleNextRotation() // Setup noise callbacks noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in + guard let self = self else { return } + + // Store fingerprint in PeerSession for fast lookups + self.collectionsQueue.async(flags: .barrier) { + if let session = self.peerSessions[peerID] { + session.fingerprint = fingerprint + session.updateAuthenticationState(authenticated: true, noiseSession: true) + } else { + let session = PeerSession(peerID: peerID) + session.fingerprint = fingerprint + session.updateAuthenticationState(authenticated: true, noiseSession: true) + self.peerSessions[peerID] = session + } + } + // Get peer's public key data from noise service - if let publicKeyData = self?.noiseService.getPeerPublicKeyData(peerID) { + if let publicKeyData = self.noiseService.getPeerPublicKeyData(peerID) { // Register with ChatViewModel for verification tracking DispatchQueue.main.async { - (self?.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: peerID, publicKeyData: publicKeyData) + (self.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: peerID, publicKeyData: publicKeyData) // Force UI to update encryption status for this specific peer - (self?.delegate as? ChatViewModel)?.updateEncryptionStatusForPeer(peerID) + (self.delegate as? ChatViewModel)?.updateEncryptionStatusForPeer(peerID) } } @@ -1273,12 +1587,55 @@ class BluetoothMeshService: NSObject { memoryCleanupTimer?.invalidate() connectionKeepAliveTimer?.invalidate() availabilityCheckTimer?.invalidate() + NotificationCenter.default.removeObserver(self) } @objc private func appWillTerminate() { cleanup() } + // MARK: - App State Management + + private func setupAppStateNotifications() { + #if os(iOS) + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + #elseif os(macOS) + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidBecomeActive), + name: NSApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(appWillResignActive), + name: NSApplication.willResignActiveNotification, + object: nil + ) + #endif + } + + @objc private func appDidBecomeActive() { + isAppInForeground = true + // App became active + } + + @objc private func appWillResignActive() { + isAppInForeground = false + // App will resign active + } + private func cleanup() { // Send leave announcement before disconnecting sendLeaveAnnouncement() @@ -1308,14 +1665,21 @@ class BluetoothMeshService: NSObject { connectedPeripherals.removeAll() subscribedCentrals.removeAll() collectionsQueue.sync(flags: .barrier) { - activePeers.removeAll() + // Clear isActivePeer flag for all sessions + for session in self.peerSessions.values { + session.isActivePeer = false + } } - announcedPeers.removeAll() + // Note: PeerSession hasReceivedAnnounce states are cleared when sessions are removed // For normal disconnect, respect the timing networkBecameEmptyTime = Date() - // Clear announcement tracking - announcedToPeers.removeAll() + // Clear announcement tracking in PeerSessions + collectionsQueue.sync(flags: .barrier) { + for session in self.peerSessions.values { + session.hasAnnounced = false + } + } // Clear last seen timestamps peerLastSeenTimestamps.removeAll() @@ -1326,7 +1690,8 @@ class BluetoothMeshService: NSObject { encryptionQueuesLock.unlock() // Clear peer tracking - lastHeardFromPeer.removeAll() + // Time tracking removed - now in PeerSession + } // MARK: - Service Management @@ -1336,6 +1701,10 @@ class BluetoothMeshService: NSObject { /// enabling the device to both discover peers and be discovered. /// Call this method after setting the delegate and localUserID. func startServices() { + // Clean up any stale/ghost sessions from previous runs + cleanupStaleSessions() + cleanupUnknownPeers() + // Starting services // Start both central and peripheral services if centralManager?.state == .poweredOn { @@ -1410,7 +1779,7 @@ class BluetoothMeshService: NSObject { return } - // Enable duplicate detection for RSSI tracking + // Enable duplicate detection let scanOptions: [String: Any] = [ CBCentralManagerScanOptionAllowDuplicatesKey: true ] @@ -1427,6 +1796,17 @@ class BluetoothMeshService: NSObject { scheduleScanDutyCycle() } + func triggerRescan() { + guard centralManager?.state == .poweredOn else { return } + + // Stop and restart scanning to trigger new discoveries + centralManager?.stopScan() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.startScanning() + } + } + private func scheduleScanDutyCycle() { guard scanDutyCycleTimer == nil else { return } @@ -1611,8 +1991,7 @@ class BluetoothMeshService: NSObject { // Try direct delivery first for delivery ACKs if !self.sendDirectToRecipient(outerPacket, recipientPeerID: recipientID) { // Recipient not directly connected, use selective relay - SecureLogger.log("Recipient \(recipientID) not directly connected for delivery ACK, using relay", - category: SecureLogger.session, level: .info) + // Using relay for delivery ACK self.sendViaSelectiveRelay(outerPacket, recipientPeerID: recipientID) } } catch { @@ -1620,8 +1999,7 @@ class BluetoothMeshService: NSObject { } } else { // Lazy handshake: No session available, drop the ACK - SecureLogger.log("No Noise session with \(recipientID) for delivery ACK - dropping (lazy handshake mode)", - category: SecureLogger.noise, level: .info) + // No session for delivery ACK, dropping } } } @@ -1687,7 +2065,7 @@ class BluetoothMeshService: NSObject { signature: nil, ttl: 3 ) - SecureLogger.log("Sending encrypted read receipt for message \(receipt.originalMessageID) to \(recipientID)", category: SecureLogger.noise, level: .info) + // Sending encrypted read receipt // Try direct delivery first for read receipts if !self.sendDirectToRecipient(outerPacket, recipientPeerID: recipientID) { @@ -1707,9 +2085,32 @@ class BluetoothMeshService: NSObject { } } - - - + /// Send a favorite/unfavorite notification to a specific peer + func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { + // Create notification payload with Nostr public key + var content = isFavorite ? "SYSTEM:FAVORITED" : "SYSTEM:UNFAVORITED" + + // Add our Nostr public key if we have one + if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() { + // Include our Nostr npub in the message + content += ":" + myNostrIdentity.npub + SecureLogger.log("📝 Including our Nostr npub in favorite notification: \(myNostrIdentity.npub)", + category: SecureLogger.session, level: .info) + } + + SecureLogger.log("📤 Sending \(isFavorite ? "favorite" : "unfavorite") notification to \(peerID) via mesh", + category: SecureLogger.session, level: .info) + + // Use existing message infrastructure + if let recipientNickname = getPeerNicknames()[peerID] { + sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname) + SecureLogger.log("✅ Sent favorite notification as private message", + category: SecureLogger.session, level: .info) + } else { + SecureLogger.log("❌ Failed to send favorite notification - peer not found", + category: SecureLogger.session, level: .error) + } + } private func sendAnnouncementToPeer(_ peerID: String) { guard let vm = delegate as? ChatViewModel else { return } @@ -1738,7 +2139,17 @@ class BluetoothMeshService: NSObject { } else { } - announcedToPeers.insert(peerID) + // Update PeerSession + collectionsQueue.sync(flags: .barrier) { + if let session = self.peerSessions[peerID] { + session.hasAnnounced = true + } else { + // Create session if it doesn't exist + let session = PeerSession(peerID: peerID) + session.hasAnnounced = true + self.peerSessions[peerID] = session + } + } } private func sendLeaveAnnouncement() { @@ -1773,11 +2184,11 @@ class BluetoothMeshService: NSObject { // Trigger handshake with a peer (for UI) func triggerHandshake(with peerID: String) { - SecureLogger.log("UI triggered handshake with \(peerID)", category: SecureLogger.noise, level: .info) + // UI triggered handshake // Check if we already have a session if noiseService.hasEstablishedSession(with: peerID) { - SecureLogger.log("Already have session with \(peerID), skipping handshake", category: SecureLogger.noise, level: .debug) + // Already have session, skipping handshake return } @@ -1793,58 +2204,65 @@ class BluetoothMeshService: NSObject { func getPeerNicknames() -> [String: String] { return collectionsQueue.sync { - return peerNicknames + var nicknames: [String: String] = [:] + + // Get nicknames from PeerSessions only (including disconnected ones) + // This allows proper nickname resolution for reconnecting peers + for (peerID, session) in self.peerSessions { + nicknames[peerID] = session.nickname + } + + return nicknames } } - func getPeerRSSI() -> [String: NSNumber] { - var rssiValues = peerRSSI - - - // Log connectedPeripherals state - SecureLogger.log("connectedPeripherals has \(connectedPeripherals.count) entries:", category: SecureLogger.session, level: .debug) - for (peerID, peripheral) in connectedPeripherals { - SecureLogger.log(" connectedPeripherals[\(peerID)] = \(peripheral.identifier.uuidString)", category: SecureLogger.session, level: .debug) + func isPeerConnected(_ peerID: String) -> Bool { + return collectionsQueue.sync { + guard let session = self.peerSessions[peerID] else { return false } + return session.isConnected && session.isActivePeer } - - - // Also check peripheralRSSI for any connected peripherals - // This handles cases where RSSI is stored under temp IDs - for (peerID, peripheral) in connectedPeripherals { - // Skip temp IDs when iterating - if peerID.count != 16 { - continue + } + + func isPeerKnown(_ peerID: String) -> Bool { + return collectionsQueue.sync { + guard let session = self.peerSessions[peerID] else { return false } + return session.hasReceivedAnnounce + } + } + + // MARK: - Consolidated Peer Info Methods + + /// Get comprehensive peer info from PeerSession (replaces multiple dictionary lookups) + func getPeerInfo(_ peerID: String) -> (nickname: String?, isActive: Bool, isAuthenticated: Bool) { + return collectionsQueue.sync { + if let session = peerSessions[peerID] { + return ( + nickname: session.nickname.isEmpty ? nil : session.nickname, + isActive: session.isActivePeer, + isAuthenticated: session.isAuthenticated + ) + } + // No session exists + return ( + nickname: nil, + isActive: false, + isAuthenticated: false + ) + } + } + + /// Get all connected peers with their info + func getAllConnectedPeers() -> [(peerID: String, nickname: String)] { + return collectionsQueue.sync { + var peers: [(String, String)] = [] + + // Get all connected peers from PeerSessions + for (peerID, session) in peerSessions where session.isConnected { + peers.append((peerID, session.nickname)) } - // If we don't have RSSI for this peer ID - if rssiValues[peerID] == nil { - - // Check if we have RSSI stored by peripheral ID - let peripheralID = peripheral.identifier.uuidString - if let rssi = peripheralRSSI[peripheralID] { - rssiValues[peerID] = rssi - } - // Also check if RSSI is stored under the peer ID as a temp ID - else if let rssi = peripheralRSSI[peerID] { - rssiValues[peerID] = rssi - } - } + return peers } - - // Also check for any known peers that might have RSSI in peerRSSI but not in connectedPeripherals - for (peerID, _) in peerNicknames { - if rssiValues[peerID] == nil && peerID.count == 16 { - // Check if we have RSSI stored directly - if let rssi = peerRSSI[peerID] { - rssiValues[peerID] = rssi - } else if let rssi = peripheralRSSI[peerID] { - rssiValues[peerID] = rssi - } - } - } - - - return rssiValues } // Emergency disconnect for panic situations @@ -1872,13 +2290,11 @@ class BluetoothMeshService: NSObject { peripheralCharacteristics.removeAll() discoveredPeripherals.removeAll() subscribedCentrals.removeAll() - peerNicknames.removeAll() - activePeers.removeAll() - peerRSSI.removeAll() - peripheralRSSI.removeAll() - rssiRetryCount.removeAll() - announcedToPeers.removeAll() - announcedPeers.removeAll() + // Clear all peer sessions on reset + // Clear PeerSession states + collectionsQueue.sync(flags: .barrier) { + self.peerSessions.removeAll() + } // For emergency/panic, reset immediately hasNotifiedNetworkAvailable = false networkBecameEmptyTime = nil @@ -1903,7 +2319,7 @@ class BluetoothMeshService: NSObject { pendingRelaysLock.unlock() // Clear peer tracking - lastHeardFromPeer.removeAll() + // Time tracking removed - now in PeerSession // Clear persistent identity noiseService.clearPersistentIdentity() @@ -1923,7 +2339,9 @@ class BluetoothMeshService: NSObject { private func getAllConnectedPeerIDs() -> [String] { // Return all valid active peers let peersCopy = collectionsQueue.sync { - return activePeers + return Array(self.peerSessions.compactMap { (peerID, session) in + session.isActivePeer ? peerID : nil + }) } @@ -1971,6 +2389,9 @@ class BluetoothMeshService: NSObject { // Clean up stale peers that haven't been seen in a while private func cleanupStalePeers() { + // First clean up stale/ghost sessions + cleanupStaleSessions() + let staleThreshold: TimeInterval = 180.0 // 3 minutes - increased for better stability let now = Date() @@ -1986,12 +2407,47 @@ class BluetoothMeshService: NSObject { category: SecureLogger.session, level: .debug) } + // Clean up old disconnect notifications + let oldDisconnectNotifications = recentDisconnectNotifications.filter { (_, timestamp) in + now.timeIntervalSince(timestamp) > 60.0 // Clean up after 1 minute + }.map { $0.key } + + for peerID in oldDisconnectNotifications { + recentDisconnectNotifications.removeValue(forKey: peerID) + } + + // Clean up old version hello times + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + + let oldVersionHellos = self.lastVersionHelloTime.filter { (_, timestamp) in + now.timeIntervalSince(timestamp) > 60.0 // Clean up after 1 minute + }.map { $0.key } + + for peerID in oldVersionHellos { + self.lastVersionHelloTime.removeValue(forKey: peerID) + } + } + + // Clean up encryption queues for disconnected peers + encryptionQueuesLock.lock() + let disconnectedPeerQueues = peerEncryptionQueues.filter { peerID, _ in + // Remove queue if peer is not connected + let isConnected = connectedPeripherals[peerID]?.state == .connected + return !isConnected + } + for (peerID, _) in disconnectedPeerQueues { + peerEncryptionQueues.removeValue(forKey: peerID) + } + encryptionQueuesLock.unlock() + let peersToRemove = collectionsQueue.sync(flags: .barrier) { - let toRemove = activePeers.filter { peerID in + let toRemove = self.peerSessions.compactMap { (peerID, session) -> String? in + guard session.isActivePeer else { return nil } if let lastSeen = peerLastSeenTimestamps.get(peerID) { - return now.timeIntervalSince(lastSeen) > staleThreshold + return now.timeIntervalSince(lastSeen) > staleThreshold ? peerID : nil } - return false // Keep peers we haven't tracked yet + return nil // Keep peers we haven't tracked yet } var actuallyRemoved: [String] = [] @@ -2005,18 +2461,23 @@ class BluetoothMeshService: NSObject { continue } - let nickname = peerNicknames[peerID] ?? "unknown" - activePeers.remove(peerID) + let nickname = self.peerSessions[peerID]?.nickname ?? "unknown" + // Mark as inactive in PeerSession + if let session = self.peerSessions[peerID] { + session.isActivePeer = false + } peerLastSeenTimestamps.remove(peerID) SecureLogger.log("📴 Removed stale peer from network: \(peerID) (\(nickname))", category: SecureLogger.session, level: .info) // Clean up all associated data connectedPeripherals.removeValue(forKey: peerID) - peerRSSI.removeValue(forKey: peerID) - announcedPeers.remove(peerID) - announcedToPeers.remove(peerID) - peerNicknames.removeValue(forKey: peerID) - lastHeardFromPeer.removeValue(forKey: peerID) + // Update PeerSession + if let session = self.peerSessions[peerID] { + session.hasReceivedAnnounce = false + session.hasAnnounced = false + } + // hasAnnounced already updated in PeerSession above + // Time tracking removed - now in PeerSession actuallyRemoved.append(peerID) // Removed stale peer @@ -2028,7 +2489,9 @@ class BluetoothMeshService: NSObject { notifyPeerListUpdate() // Mark when network became empty, but don't reset flag immediately - let currentNetworkSize = collectionsQueue.sync { activePeers.count } + let currentNetworkSize = collectionsQueue.sync { + peerSessions.values.filter { $0.isActivePeer }.count + } if currentNetworkSize == 0 && networkBecameEmptyTime == nil { networkBecameEmptyTime = Date() } @@ -2036,7 +2499,9 @@ class BluetoothMeshService: NSObject { // Check if we should reset the notification flag if let emptyTime = networkBecameEmptyTime { - let currentNetworkSize = collectionsQueue.sync { activePeers.count } + let currentNetworkSize = collectionsQueue.sync { + peerSessions.values.filter { $0.isActivePeer }.count + } if currentNetworkSize == 0 { // Network is still empty, check if enough time has passed let timeSinceEmpty = Date().timeIntervalSince(emptyTime) @@ -2079,7 +2544,7 @@ class BluetoothMeshService: NSObject { let recipientID = packet.recipientID { let recipientPeerID = recipientID.hexEncodedString() // Check if recipient is a favorite via their public key fingerprint - if let fingerprint = self.noiseService.getPeerFingerprint(recipientPeerID) { + if let fingerprint = self.getPeerFingerprint(recipientPeerID) { isForFavorite = self.delegate?.isFavorite(fingerprint: fingerprint) ?? false } } @@ -2233,18 +2698,6 @@ class BluetoothMeshService: NSObject { } } - private func estimateDistance(rssi: Int) -> Int { - // Rough distance estimation based on RSSI - // Using path loss formula: RSSI = TxPower - 10 * n * log10(distance) - // Assuming TxPower = -59 dBm at 1m, n = 2.0 (free space) - let txPower = -59.0 - let pathLossExponent = 2.0 - - let ratio = (txPower - Double(rssi)) / (10.0 * pathLossExponent) - let distance = pow(10.0, ratio) - - return Int(distance) - } private func broadcastPacket(_ packet: BitchatPacket) { // CRITICAL CHECK: Never send unencrypted JSON @@ -2381,27 +2834,41 @@ class BluetoothMeshService: NSObject { // Remove any temp ID mapping if it exists let tempIDToRemove = connectedPeripherals.first(where: { $0.value == peripheral && $0.key != senderID })?.key if let tempID = tempIDToRemove { + SecureLogger.log("Removing temp ID mapping: \(tempID) -> \(peripheralID)", + category: SecureLogger.session, level: .debug) connectedPeripherals.removeValue(forKey: tempID) + // Also remove any peer session created with temp ID + if peerSessions[tempID] != nil { + SecureLogger.log("Removing temp peer session for \(tempID)", + category: SecureLogger.session, level: .debug) + peerSessions.removeValue(forKey: tempID) + } } - connectedPeripherals[senderID] = peripheral + updatePeripheralConnection(senderID, peripheral: peripheral) + + // Update the peripheral mapping to use the real peer ID + updatePeripheralMapping(peripheralID: peripheralID, peerID: senderID) } } // Check if this is a reconnection after a long silence let wasReconnection: Bool - if let lastHeard = self.lastHeardFromPeer[senderID] { - let timeSinceLastHeard = Date().timeIntervalSince(lastHeard) - wasReconnection = timeSinceLastHeard > 30.0 + if let session = self.peerSessions[senderID] { + if let lastHeard = session.lastHeardFromPeer { + let timeSinceLastHeard = Date().timeIntervalSince(lastHeard) + wasReconnection = timeSinceLastHeard > 30.0 + } else { + wasReconnection = true + } + session.lastHeardFromPeer = Date() } else { - // First time hearing from this peer - wasReconnection = true + // Don't create a session just from hearing a packet - wait for proper connection + wasReconnection = false } - self.lastHeardFromPeer[senderID] = Date() - // If this is a reconnection, send our identity announcement if wasReconnection && packet.type != MessageType.noiseIdentityAnnounce.rawValue { - SecureLogger.log("Detected reconnection from \(senderID) after silence, sending identity announcement", category: SecureLogger.noise, level: .info) + // Detected reconnection, sending identity DispatchQueue.main.async { [weak self] in self?.sendNoiseIdentityAnnounce(to: senderID) } @@ -2506,7 +2973,7 @@ class BluetoothMeshService: NSObject { processedMessages[messageID] = updatedState processedMessagesLock.unlock() - SecureLogger.log("Dropped duplicate message from \(senderID) (seen \(updatedState.seenCount) times)", category: SecureLogger.security, level: .debug) + // Dropped duplicate message // Cancel any pending relay for this message cancelPendingRelay(messageID: messageID) return @@ -2520,7 +2987,7 @@ class BluetoothMeshService: NSObject { let isProcessed = processedMessages[messageID] != nil processedMessagesLock.unlock() if !isProcessed { - SecureLogger.log("Bloom filter false positive for message: \(messageID)", category: SecureLogger.security, level: .debug) + // Bloom filter false positive } } @@ -2535,13 +3002,22 @@ class BluetoothMeshService: NSObject { acknowledged: false ) - // Prune old entries if needed + // Prune old entries if needed (more efficient approach) if processedMessages.count > maxProcessedMessages { - // Remove oldest entries - let sortedByFirstSeen = processedMessages.sorted { $0.value.firstSeen < $1.value.firstSeen } - let toRemove = sortedByFirstSeen.prefix(processedMessages.count - maxProcessedMessages + 1000) - for (key, _) in toRemove { - processedMessages.removeValue(forKey: key) + // Remove oldest 20% to avoid frequent pruning + let targetSize = Int(Double(maxProcessedMessages) * 0.8) + let cutoffTime = Date().addingTimeInterval(-3600) // 1 hour ago + + // First try removing old messages + processedMessages = processedMessages.filter { _, state in + state.firstSeen > cutoffTime + } + + // If still too many, remove oldest entries + if processedMessages.count > targetSize { + let sortedByFirstSeen = processedMessages.sorted { $0.value.firstSeen < $1.value.firstSeen } + let toKeep = Array(sortedByFirstSeen.suffix(targetSize)) + processedMessages = Dictionary(uniqueKeysWithValues: toKeep) } } processedMessagesLock.unlock() @@ -2586,7 +3062,13 @@ class BluetoothMeshService: NSObject { // Store nickname mapping collectionsQueue.sync(flags: .barrier) { - self.peerNicknames[senderID] = message.sender + // Update PeerSession + if let session = self.peerSessions[senderID] { + session.nickname = message.sender + } else { + let session = PeerSession(peerID: senderID, nickname: message.sender) + self.peerSessions[senderID] = session + } } let finalContent = message.content @@ -2618,9 +3100,8 @@ class BluetoothMeshService: NSObject { var relayPacket = packet relayPacket.ttl -= 1 if relayPacket.ttl > 0 { - // RSSI-based relay probability - let rssiValue = peerRSSI[senderID]?.intValue ?? -70 - let relayProb = self.calculateRelayProbability(baseProb: self.adaptiveRelayProbability, rssi: rssiValue) + // Use adaptive relay probability directly + let relayProb = self.adaptiveRelayProbability // Relay based on probability only - no TTL boost if base probability is 0 let effectiveProb = relayProb > 0 ? relayProb : 0.0 @@ -2658,6 +3139,43 @@ class BluetoothMeshService: NSObject { return // Silently discard dummy messages } + // Check if this is a favorite/unfavorite notification + if message.content.hasPrefix("SYSTEM:FAVORITED") || message.content.hasPrefix("SYSTEM:UNFAVORITED") { + let parts = message.content.split(separator: ":") + let isFavorite = parts.count >= 2 && parts[0] == "SYSTEM" && parts[1] == "FAVORITED" + let action = isFavorite ? "favorited" : "unfavorited" + let nostrNpub = parts.count > 2 ? String(parts[2]) : nil + + SecureLogger.log("📨 Received \(action) notification from \(senderID)", category: SecureLogger.session, level: .info) + if nostrNpub != nil { + // Peer's Nostr npub recorded + } + + // Handle favorite notification + DispatchQueue.main.async { + Task { @MainActor in + let vm = self.delegate as? ChatViewModel + let nickname = message.sender + + if isFavorite { + vm?.handlePeerFavoritedUs(peerID: senderID, favorited: true, nickname: nickname, nostrNpub: nostrNpub) + } else { + vm?.handlePeerFavoritedUs(peerID: senderID, favorited: false, nickname: nickname, nostrNpub: nostrNpub) + } + + // Send system message to user + let systemMessage = BitchatMessage( + sender: "system", + content: "\(nickname) \(action) you.", + timestamp: Date(), + isRelay: false + ) + self.delegate?.didReceiveMessage(systemMessage) + } + } + return + } + // Check if we've seen this exact message recently (within 5 seconds) let messageKey = "\(senderID)-\(message.content)-\(message.timestamp)" if let lastReceived = self.receivedMessageTimestamps.get(messageKey) { @@ -2670,8 +3188,21 @@ class BluetoothMeshService: NSObject { // LRU cache handles cleanup automatically collectionsQueue.sync(flags: .barrier) { - if self.peerNicknames[senderID] == nil { - self.peerNicknames[senderID] = message.sender + if self.peerSessions[senderID] == nil { + let session = PeerSession(peerID: senderID, nickname: message.sender) + self.peerSessions[senderID] = session + } else if self.peerSessions[senderID]?.nickname == "Unknown" { + self.peerSessions[senderID]?.nickname = message.sender + } + + // PeerSession already updated if nickname not set + if let session = self.peerSessions[senderID] { + if session.nickname.isEmpty || session.nickname == "Unknown" { + session.nickname = message.sender + } + } else { + let session = PeerSession(peerID: senderID, nickname: message.sender) + self.peerSessions[senderID] = session } } @@ -2719,17 +3250,17 @@ class BluetoothMeshService: NSObject { // Check if this message is for an offline favorite and cache it let recipientIDString = recipientID.hexEncodedString() - if let fingerprint = self.noiseService.getPeerFingerprint(recipientIDString) { + if let fingerprint = self.getPeerFingerprint(recipientIDString) { // Only cache if recipient is a favorite AND is currently offline - if (self.delegate?.isFavorite(fingerprint: fingerprint) ?? false) && !self.activePeers.contains(recipientIDString) { + let isActive = self.peerSessions[recipientIDString]?.isActivePeer ?? false + if (self.delegate?.isFavorite(fingerprint: fingerprint) ?? false) && !isActive { self.cacheMessage(relayPacket, messageID: messageID) } } - // Private messages are important - use RSSI-based relay with boost - let rssiValue = peerRSSI[senderID]?.intValue ?? -70 + // Private messages are important - use relay with boost let baseProb = min(self.adaptiveRelayProbability + 0.15, 1.0) // Boost by 15% - let relayProb = self.calculateRelayProbability(baseProb: baseProb, rssi: rssiValue) + let relayProb = baseProb // Relay based on probability only - no forced relay for small networks let shouldRelay = Double.random(in: 0...1) < relayProb @@ -2760,7 +3291,7 @@ class BluetoothMeshService: NSObject { let nickname = rawNickname.trimmingCharacters(in: .whitespacesAndNewlines) let senderID = packet.senderID.hexEncodedString() - SecureLogger.log("handleReceivedPacket: Received announce from \(senderID), peripheral is \(peripheral != nil ? "present" : "nil")", category: SecureLogger.session, level: .debug) + // Received announce from peer // Ignore if it's from ourselves (including previous peer IDs) if isPeerIDOurs(senderID) { @@ -2768,13 +3299,27 @@ class BluetoothMeshService: NSObject { } // Check if we've already announced this peer - let isFirstAnnounce = !announcedPeers.contains(senderID) + let isFirstAnnounce = collectionsQueue.sync { + if let session = self.peerSessions[senderID] { + return !session.hasReceivedAnnounce + } else { + return true + } + } + + // Check if this is a reconnection after disconnect + let wasDisconnected = collectionsQueue.sync { + if let session = self.peerSessions[senderID] { + return !session.isConnected && !session.isActivePeer && session.hasReceivedAnnounce + } + return false + } // Clean up stale peer IDs with the same nickname collectionsQueue.sync(flags: .barrier) { var stalePeerIDs: [String] = [] - for (existingPeerID, existingNickname) in self.peerNicknames { - if existingNickname == nickname && existingPeerID != senderID { + for (existingPeerID, existingSession) in self.peerSessions { + if existingSession.nickname == nickname && existingPeerID != senderID { // Check if this peer was seen very recently (within 10 seconds) let wasRecentlySeen = self.peerLastSeenTimestamps.get(existingPeerID).map { Date().timeIntervalSince($0) < 10.0 } ?? false if !wasRecentlySeen { @@ -2790,17 +3335,21 @@ class BluetoothMeshService: NSObject { // Remove stale peer IDs for stalePeerID in stalePeerIDs { // Removing stale peer - self.peerNicknames.removeValue(forKey: stalePeerID) + self.peerSessions.removeValue(forKey: stalePeerID) - // Also remove from active peers - self.activePeers.remove(stalePeerID) + // Mark as inactive in PeerSession + if let session = self.peerSessions[stalePeerID] { + session.isActivePeer = false + } // Remove from announced peers - self.announcedPeers.remove(stalePeerID) - self.announcedToPeers.remove(stalePeerID) + // Update PeerSession for stale peer + if let session = self.peerSessions[stalePeerID] { + session.hasReceivedAnnounce = false + session.hasAnnounced = false + } - // Clear tracking data - self.lastHeardFromPeer.removeValue(forKey: stalePeerID) + // Clear tracking data - now handled by removing PeerSession // Disconnect any peripherals associated with stale ID if let peripheral = self.connectedPeripherals[stalePeerID] { @@ -2810,9 +3359,6 @@ class BluetoothMeshService: NSObject { self.peripheralCharacteristics.removeValue(forKey: peripheral) } - // Remove RSSI data - self.peerRSSI.removeValue(forKey: stalePeerID) - // Clear cached messages tracking self.cachedMessagesSentToPeer.remove(stalePeerID) @@ -2829,8 +3375,21 @@ class BluetoothMeshService: NSObject { } } - // Now add the new peer ID with the nickname - self.peerNicknames[senderID] = nickname + // Only update peer session if we already have one (from a real connection) + // Don't create sessions from relayed announces + if let session = self.peerSessions[senderID] { + session.nickname = nickname + session.hasReceivedAnnounce = true + session.lastSeen = Date() + + // Check if this peer's noise public key has an existing favorite with a different nickname + if let noisePublicKey = Data(hexString: senderID) { + DispatchQueue.main.async { + FavoritesPersistenceService.shared.updateNickname(for: noisePublicKey, newNickname: nickname) + } + } + } + // Note: We'll create the session later if the peer passes the peripheral connection check } // Update peripheral mapping if we have it @@ -2842,45 +3401,31 @@ class BluetoothMeshService: NSObject { peripheralToUpdate = self.connectedPeripherals[senderID] if peripheralToUpdate == nil { - SecureLogger.log("handleReceivedPacket: No peripheral passed and no existing mapping for \(senderID)", category: SecureLogger.session, level: .debug) + // No peripheral mapping found // Try to find an unidentified peripheral that might be this peer - // This handles case where announce is relayed and we need to update RSSI mapping + // This handles case where announce is relayed and we need to update peripheral mapping var unmappedPeripherals: [(String, CBPeripheral)] = [] for (tempID, peripheral) in self.connectedPeripherals { // Check if this is a temp ID (UUID format, not a peer ID) if tempID.count == 36 && tempID.contains("-") { // UUID length with dashes unmappedPeripherals.append((tempID, peripheral)) - SecureLogger.log("handleReceivedPacket: Found unmapped peripheral with temp ID \(tempID)", category: SecureLogger.session, level: .debug) + // Found unmapped peripheral } } // If we have exactly one unmapped peripheral, it's likely this one if unmappedPeripherals.count == 1 { let (tempID, peripheral) = unmappedPeripherals[0] - SecureLogger.log("handleReceivedPacket: Single unmapped peripheral \(tempID), mapping to \(senderID)", category: SecureLogger.session, level: .info) + // Mapping peripheral to sender peripheralToUpdate = peripheral // Remove temp mapping and add real mapping self.connectedPeripherals.removeValue(forKey: tempID) - self.connectedPeripherals[senderID] = peripheral + self.updatePeripheralConnection(senderID, peripheral: peripheral) - // Transfer RSSI if available - if let rssi = self.peripheralRSSI[tempID] { - self.peripheralRSSI.removeValue(forKey: tempID) - self.peripheralRSSI[senderID] = rssi - self.peerRSSI[senderID] = rssi - } - - // Also check the peripheral's UUID-based RSSI - let peripheralUUID = peripheral.identifier.uuidString - if peripheralUUID == tempID && peripheralRSSI[peripheralUUID] != nil { - // Already handled above - } else if let rssi = self.peripheralRSSI[peripheralUUID] { - self.peerRSSI[senderID] = rssi - } } else if unmappedPeripherals.count > 1 { - SecureLogger.log("handleReceivedPacket: Multiple unmapped peripherals (\(unmappedPeripherals.count)), cannot determine which is \(senderID)", category: SecureLogger.session, level: .debug) + // Multiple unmapped peripherals // TODO: Could use timing heuristics or other methods to match } } @@ -2904,27 +3449,29 @@ class BluetoothMeshService: NSObject { } if let tempID = tempIDToRemove { - // RSSI transfer is handled by updatePeripheralMapping - self.peripheralRSSI.removeValue(forKey: tempID) - // IMPORTANT: Remove old peer ID from activePeers to prevent duplicates + // IMPORTANT: Mark old peer ID as inactive to prevent duplicates collectionsQueue.sync(flags: .barrier) { - if self.activePeers.contains(tempID) { - _ = self.activePeers.remove(tempID) + if let session = self.peerSessions[tempID], session.isActivePeer { + session.isActivePeer = false } } // Don't notify about disconnect - this is just cleanup of temporary ID } else { - SecureLogger.log("handleReceivedPacket: No temp ID found for peripheral, directly mapping \(senderID)", category: SecureLogger.session, level: .debug) + // Direct mapping peripheral // No temp ID found, just add the mapping - self.connectedPeripherals[senderID] = peripheral + self.updatePeripheralConnection(senderID, peripheral: peripheral) self.peerIDByPeripheralID[peripheral.identifier.uuidString] = senderID - // Check if RSSI is stored under peripheral UUID and transfer it - let peripheralUUID = peripheral.identifier.uuidString - if let peripheralStoredRSSI = self.peripheralRSSI[peripheralUUID] { - self.peerRSSI[senderID] = peripheralStoredRSSI + + // If peer was previously relay-connected, update to direct connection + if let session = self.peerSessions[senderID], !session.isConnected && session.hasReceivedAnnounce { + SecureLogger.log("Upgrading peer \(senderID) from relay to direct connection", + category: SecureLogger.session, level: .info) + session.isConnected = true + session.updateBluetoothConnection(peripheral: peripheral, characteristic: nil) + } } } @@ -2935,9 +3482,9 @@ class BluetoothMeshService: NSObject { collectionsQueue.sync(flags: .barrier) { // Find any existing peers with the same nickname var oldPeerIDsToRemove: [String] = [] - for existingPeerID in self.activePeers { - if existingPeerID != senderID { - let existingNickname = self.peerNicknames[existingPeerID] ?? "" + for (existingPeerID, session) in self.peerSessions { + if existingPeerID != senderID && session.isActivePeer { + let existingNickname = session.nickname if existingNickname == nickname && !existingNickname.isEmpty && existingNickname != "unknown" { oldPeerIDsToRemove.append(existingPeerID) } @@ -2946,40 +3493,149 @@ class BluetoothMeshService: NSObject { // Remove old peer IDs with same nickname for oldPeerID in oldPeerIDsToRemove { - self.activePeers.remove(oldPeerID) - self.peerNicknames.removeValue(forKey: oldPeerID) + // Remove the entire session for duplicate nicknames + self.peerSessions.removeValue(forKey: oldPeerID) self.connectedPeripherals.removeValue(forKey: oldPeerID) // Don't notify about disconnect - this is just cleanup of duplicate } } + var wasUpgradedFromRelay = false + + // Check if we have a peripheral connection before sync block + let hasPeripheralConnection = self.connectedPeripherals[senderID] != nil || + (peripheral != nil && peripheral?.state == .connected) + let wasInserted = collectionsQueue.sync(flags: .barrier) { // Final safety check if senderID == self.myPeerID { - SecureLogger.log("Blocked self from being added to activePeers", category: SecureLogger.noise, level: .error) + SecureLogger.log("Blocked self from being marked as active", category: SecureLogger.noise, level: .error) return false } - let result = self.activePeers.insert(senderID).inserted + + // Check if we should mark as active despite no peripheral + // This can happen during reconnection when announces arrive before peripheral mapping + let shouldMarkActive: Bool + + if hasPeripheralConnection { + shouldMarkActive = true + } else { + // Check if we recently had activity suggesting a direct connection + let recentVersionHello = (self.lastVersionHelloTime[senderID] != nil && + Date().timeIntervalSince(self.lastVersionHelloTime[senderID]!) < 5.0) + + // Check for unmapped peripherals + var hasUnmappedPeripheral = false + for (tempID, _) in self.connectedPeripherals { + if tempID.count == 36 && tempID.contains("-") { // UUID format + hasUnmappedPeripheral = true + break + } + } + + if recentVersionHello || hasUnmappedPeripheral { + SecureLogger.log("Marking \(senderID) as active despite no peripheral - recent version hello or unmapped peripheral exists", + category: SecureLogger.session, level: .info) + shouldMarkActive = true + + // Trigger a rescan to establish peripheral mapping + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + SecureLogger.log("Triggering rescan to find peripheral for \(senderID)", + category: SecureLogger.session, level: .info) + self?.triggerRescan() + } + } else { + SecureLogger.log("⚠️ Not marking \(senderID) as active - no peripheral connection and no recent activity", + category: SecureLogger.session, level: .warning) + // Still update/create session to track the peer, but don't mark as connected + if let session = self.peerSessions[senderID] { + session.nickname = nickname // Update nickname from announce + session.hasReceivedAnnounce = true + session.lastSeen = Date() + // Don't set isConnected or isActivePeer without peripheral + } else { + // Create new session but not connected + let session = PeerSession(peerID: senderID, nickname: nickname) + session.hasReceivedAnnounce = true + session.lastSeen = Date() + // Don't set isConnected or isActivePeer without peripheral + self.peerSessions[senderID] = session + } + shouldMarkActive = false + } + } + + if !shouldMarkActive { + return false + } + + let result: Bool + + if let session = self.peerSessions[senderID] { + // Check if we're upgrading from relay-only to direct connection + wasUpgradedFromRelay = !session.isConnected && session.hasReceivedAnnounce + + result = !session.isActivePeer + session.isActivePeer = true + session.isConnected = true + session.nickname = nickname // Update nickname from announce + session.hasReceivedAnnounce = true + session.lastSeen = Date() + if let peripheral = peripheral { + session.updateBluetoothConnection(peripheral: peripheral, characteristic: nil) + } + } else { + // Create new session with the nickname from the announce + let session = PeerSession(peerID: senderID, nickname: nickname) + session.isActivePeer = true + session.isConnected = true + session.hasReceivedAnnounce = true + session.lastSeen = Date() + if let peripheral = peripheral { + session.updateBluetoothConnection(peripheral: peripheral, characteristic: nil) + } + self.peerSessions[senderID] = session + result = true + } + return result } if wasInserted { SecureLogger.log("📡 Peer joined network: \(senderID) (\(nickname))", category: SecureLogger.session, level: .info) } - // Show join message only for first announce AND if we actually added the peer - if isFirstAnnounce && wasInserted { - announcedPeers.insert(senderID) + // Show join message only once per peer connection session + // For direct connections: show on first announce with peripheral + // For relay connections: show on first announce when no peripheral available + let shouldShowConnectMessage = (isFirstAnnounce && wasInserted) || + (wasDisconnected && hasPeripheralConnection) + + SecureLogger.log("🔍 Connect message check for \(senderID): isFirstAnnounce=\(isFirstAnnounce), wasInserted=\(wasInserted), wasDisconnected=\(wasDisconnected), hasPeripheralConnection=\(hasPeripheralConnection), shouldShow=\(shouldShowConnectMessage)", + category: SecureLogger.session, level: .debug) + + if shouldShowConnectMessage { + if wasUpgradedFromRelay { + SecureLogger.log("📡 Peer upgraded from relay to direct: \(senderID) (\(nickname))", + category: SecureLogger.session, level: .info) + } else if wasDisconnected { + SecureLogger.log("📡 Peer reconnected: \(senderID) (\(nickname))", + category: SecureLogger.session, level: .info) + } // Delay the connect message slightly to allow identity announcement to be processed // This helps ensure fingerprint mappings are available for nickname resolution DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + SecureLogger.log("🔔 Sending connected notification for \(senderID)", + category: SecureLogger.session, level: .info) self.delegate?.didConnectToPeer(senderID) } self.notifyPeerListUpdate(immediate: true) // Send network available notification if appropriate - let currentNetworkSize = collectionsQueue.sync { self.activePeers.count } + let currentNetworkSize = collectionsQueue.sync { + self.peerSessions.values.filter { $0.isActivePeer }.count + } if currentNetworkSize > 0 { // Clear empty time since network is active networkBecameEmptyTime = nil @@ -3012,7 +3668,7 @@ class BluetoothMeshService: NSObject { guard let self = self else { return } // Check if this is a favorite using their public key fingerprint - if let fingerprint = self.noiseService.getPeerFingerprint(senderID) { + if let fingerprint = self.getPeerFingerprint(senderID) { if self.delegate?.isFavorite(fingerprint: fingerprint) ?? false { NotificationService.shared.sendFavoriteOnlineNotification(nickname: nickname) @@ -3045,19 +3701,34 @@ class BluetoothMeshService: NSObject { if String(data: packet.payload, encoding: .utf8) != nil { // Remove from active peers with proper locking collectionsQueue.sync(flags: .barrier) { - let wasRemoved = self.activePeers.remove(senderID) != nil - let nickname = self.peerNicknames.removeValue(forKey: senderID) ?? "unknown" + let wasRemoved = self.peerSessions[senderID]?.isActivePeer == true + let nickname = self.peerSessions.removeValue(forKey: senderID)?.nickname ?? "unknown" if wasRemoved { // Mark as gracefully left to prevent duplicate disconnect message self.gracefullyLeftPeers.insert(senderID) self.gracefulLeaveTimestamps[senderID] = Date() + // Update PeerSession to reflect disconnect + if let session = self.peerSessions[senderID] { + session.isActivePeer = false + session.isConnected = false + session.updateBluetoothConnection(peripheral: nil, characteristic: nil) + session.updateAuthenticationState(authenticated: false, noiseSession: false) + } + SecureLogger.log("📴 Peer left network: \(senderID) (\(nickname)) - marked as gracefully left", category: SecureLogger.session, level: .info) } } - announcedPeers.remove(senderID) + // Update PeerSession + collectionsQueue.sync(flags: .barrier) { + if let session = self.peerSessions[senderID] { + session.hasReceivedAnnounce = false + session.isActivePeer = false + session.isConnected = false + } + } // Show disconnect message immediately when peer leaves DispatchQueue.main.async { @@ -3281,34 +3952,30 @@ class BluetoothMeshService: NSObject { SecureLogger.log("Creating identity binding for \(announcement.peerID) -> \(fingerprint)", category: SecureLogger.security, level: .info) - // Handle peer ID rotation if previousPeerID is provided - if let previousID = announcement.previousPeerID, previousID != announcement.peerID { - SecureLogger.log("Peer announced rotation from \(previousID) to \(announcement.peerID)", - category: SecureLogger.security, level: .info) - - // Transfer states from previous ID - collectionsQueue.sync(flags: .barrier) { - // Transfer gracefullyLeft state - if self.gracefullyLeftPeers.contains(previousID) { - self.gracefullyLeftPeers.remove(previousID) - self.gracefullyLeftPeers.insert(announcement.peerID) - } - - // Clean up the old peer ID from active peers - if self.activePeers.contains(previousID) { - self.activePeers.remove(previousID) - // Don't add new ID yet - let normal flow handle it - } - } - } - // Update our mappings updatePeerBinding(announcement.peerID, fingerprint: fingerprint, binding: binding) // Update connection state only if we're not already authenticated let currentState = peerConnectionStates[announcement.peerID] ?? .disconnected if currentState != .authenticated { - updatePeerConnectionState(announcement.peerID, state: .connected) + // Check if we have a direct peripheral connection or if this is relayed + let hasDirectConnection = collectionsQueue.sync { + // Check if any connected peripheral maps to this peer + for (_, mapping) in self.peripheralMappings where mapping.peerID == announcement.peerID { + if self.connectedPeripherals[announcement.peerID] != nil { + return true + } + } + return false + } + + if hasDirectConnection { + updatePeerConnectionState(announcement.peerID, state: .connected) + } else { + // This is a relayed identity announce - don't mark as directly connected + SecureLogger.log("Received relayed identity announce from \(announcement.peerID) - not marking as directly connected", + category: SecureLogger.noise, level: .debug) + } } // Register the peer's public key with ChatViewModel for verification tracking @@ -3363,11 +4030,11 @@ class BluetoothMeshService: NSObject { cleanupPeerCryptoState(senderID) } else { // Check if we've heard from this peer recently - let lastHeard = lastHeardFromPeer[senderID] ?? Date.distantPast + let lastHeard = self.peerSessions[senderID]?.lastHeardFromPeer ?? Date.distantPast let timeSinceLastHeard = Date().timeIntervalSince(lastHeard) // Check session validity before clearing - let lastSuccess = lastSuccessfulMessageTime[senderID] ?? Date.distantPast + let lastSuccess = self.peerSessions[senderID]?.lastSuccessfulMessageTime ?? Date.distantPast let sessionAge = Date().timeIntervalSince(lastSuccess) // If the peer is initiating a handshake despite us having a valid session, @@ -3407,7 +4074,7 @@ class BluetoothMeshService: NSObject { // Check if this handshake response is for us if let recipientID = packet.recipientID { let recipientIDStr = recipientID.hexEncodedString() - SecureLogger.log("Response targeted to: \(recipientIDStr), is us: \(isPeerIDOurs(recipientIDStr))", category: SecureLogger.noise, level: .debug) + // Response targeted check if !isPeerIDOurs(recipientIDStr) { // Not for us, relay if TTL > 0 if packet.ttl > 0 { @@ -3422,8 +4089,8 @@ class BluetoothMeshService: NSObject { if !isPeerIDOurs(senderID) { // Check our current handshake state - let currentState = handshakeCoordinator.getHandshakeState(for: senderID) - SecureLogger.log("Processing handshake response from \(senderID), current state: \(currentState)", category: SecureLogger.noise, level: .info) + let _ = handshakeCoordinator.getHandshakeState(for: senderID) + // Processing handshake response // Process the response - this could be message 2 or message 3 in the XX pattern handleNoiseHandshakeMessage(from: senderID, message: packet.payload, isInitiation: false) @@ -3523,7 +4190,12 @@ class BluetoothMeshService: NSObject { category: SecureLogger.session, level: .debug) // Session is valid, update last successful message time - lastSuccessfulMessageTime[senderID] = Date() + updateLastSuccessfulMessageTime(senderID) + + // Note: PeerSession already updated in helper + if let session = self.peerSessions[senderID] { + session.lastSuccessfulMessageTime = Date() + } } catch { // Validation failed - session is out of sync SecureLogger.log("Session validation failed with \(senderID): \(error)", @@ -3543,6 +4215,16 @@ class BluetoothMeshService: NSObject { handleHandshakeRequest(from: senderID, data: packet.payload) } + case .favorited: + // Now handled as private messages with "SYSTEM:FAVORITED" content + // See handleReceivedPacket for MESSAGE type handling + break + + case .unfavorited: + // Now handled as private messages with "SYSTEM:UNFAVORITED" content + // See handleReceivedPacket for MESSAGE type handling + break + default: break } @@ -3793,47 +4475,33 @@ extension BluetoothMeshService: CBCentralManagerDelegate { } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { - // Optimize for 300m range - only connect to strong enough signals - let rssiValue = RSSI.intValue - // Dynamic RSSI threshold based on peer count (smart compromise) - let rssiThreshold = dynamicRSSIThreshold - guard rssiValue > rssiThreshold else { - // Ignoring peripheral due to weak signal (threshold: \(rssiThreshold) dBm) - return - } - - // Throttle RSSI updates to save CPU let peripheralID = peripheral.identifier.uuidString - if let lastUpdate = lastRSSIUpdate[peripheralID], - Date().timeIntervalSince(lastUpdate) < 1.0 { - return // Skip update if less than 1 second since last update - } - lastRSSIUpdate[peripheralID] = Date() - - // Store RSSI by peripheral ID for later use - peripheralRSSI[peripheralID] = RSSI // Extract peer ID from name (no prefix for stealth) // Peer IDs are 8 bytes = 16 hex characters if let name = peripheral.name, name.count == 16 { // Assume 16-character hex names are peer IDs let peerID = name - SecureLogger.log("Discovery: Found peer ID \(peerID) from peripheral name", category: SecureLogger.session, level: .debug) + // Found peer ID from peripheral name // Don't process our own advertisements (including previous peer IDs) if isPeerIDOurs(peerID) { - SecureLogger.log("Discovery: Ignoring our own peer ID \(peerID)", category: SecureLogger.session, level: .debug) + // Ignoring our own peer ID return } - // Validate RSSI before storing - let rssiValue = RSSI.intValue - if rssiValue != 127 && rssiValue >= -100 && rssiValue <= 0 { - peerRSSI[peerID] = RSSI - } // Discovered potential peer - SecureLogger.log("Discovered peer with ID: \(peerID), self ID: \(myPeerID)", category: SecureLogger.noise, level: .debug) + // Discovered peer + + // Check if we have a relay-only session for this peer that needs upgrading + collectionsQueue.sync { + if let session = self.peerSessions[peerID], + !session.isConnected && session.hasReceivedAnnounce { + SecureLogger.log("Found relay-only session for \(peerID), will upgrade to direct connection", + category: SecureLogger.session, level: .info) + } + } } // Connection pooling with exponential backoff @@ -3916,7 +4584,7 @@ extension BluetoothMeshService: CBCentralManagerDelegate { registerPeripheral(peripheral) // Store peripheral temporarily until we get the real peer ID - connectedPeripherals[peripheralID] = peripheral + updatePeripheralConnection(peripheralID, peripheral: peripheral) SecureLogger.log("Connected to peripheral \(peripheralID) - awaiting peer ID", category: SecureLogger.session, level: .debug) @@ -3927,8 +4595,6 @@ extension BluetoothMeshService: CBCentralManagerDelegate { // Don't show connected message yet - wait for key exchange // This prevents the connect/disconnect/connect pattern - // Request RSSI reading - peripheral.readRSSI() // iOS 11+ BLE 5.0: Request 2M PHY for better range and speed if #available(iOS 11.0, macOS 10.14, *) { @@ -3940,13 +4606,11 @@ extension BluetoothMeshService: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { let peripheralID = peripheral.identifier.uuidString - // Clean up RSSI retry count - rssiRetryCount.removeValue(forKey: peripheralID) // Check if this was an intentional disconnect if intentionalDisconnects.contains(peripheralID) { intentionalDisconnects.remove(peripheralID) - SecureLogger.log("Intentional disconnect: \(peripheralID)", category: SecureLogger.session, level: .debug) + // Intentional disconnect // Don't process this disconnect further return } @@ -3955,7 +4619,7 @@ extension BluetoothMeshService: CBCentralManagerDelegate { if let error = error { SecureLogger.logError(error, context: "Peripheral disconnected: \(peripheralID)", category: SecureLogger.session) } else { - SecureLogger.log("Peripheral disconnected normally: \(peripheralID)", category: SecureLogger.session, level: .info) + // Peripheral disconnected normally } // Find the real peer ID using simplified mapping @@ -3996,6 +4660,15 @@ extension BluetoothMeshService: CBCentralManagerDelegate { // Update peer connection state updatePeerConnectionState(peerID, state: .disconnected) + // Update PeerSession to reflect disconnect + collectionsQueue.async(flags: .barrier) { [weak self] in + if let session = self?.peerSessions[peerID] { + session.updateBluetoothConnection(peripheral: nil, characteristic: nil) + session.isConnected = false + session.updateAuthenticationState(authenticated: false, noiseSession: false) + } + } + // Clear pending messages for disconnected peer to prevent retry loops collectionsQueue.async(flags: .barrier) { [weak self] in if let pendingCount = self?.pendingPrivateMessages[peerID]?.count, pendingCount > 0 { @@ -4005,18 +4678,40 @@ extension BluetoothMeshService: CBCentralManagerDelegate { } } - // Reset handshake state to prevent stuck handshakes - handshakeCoordinator.resetHandshakeState(for: peerID) + // Clean up crypto state and handshake state on disconnect + cleanupPeerCryptoState(peerID) // Check if peer gracefully left and notify delegate let shouldNotifyDisconnect = collectionsQueue.sync { let isGracefullyLeft = self.gracefullyLeftPeers.contains(peerID) + + // Check if we recently sent a disconnect notification for this peer + let now = Date() + if let lastNotification = self.recentDisconnectNotifications[peerID] { + let timeSinceLastNotification = now.timeIntervalSince(lastNotification) + if timeSinceLastNotification < self.disconnectNotificationDedupeWindow { + // Duplicate disconnect, not notifying + return false + } + } + + // Check if this is an Unknown peer that never properly connected + if let session = self.peerSessions[peerID] { + if session.nickname == "Unknown" && !session.hasReceivedAnnounce { + // Unknown peer disconnect, not notifying + return false + } + } + if isGracefullyLeft { - SecureLogger.log("Physical disconnect for \(peerID) - was gracefully left, NOT notifying delegate", category: SecureLogger.session, level: .info) + // Graceful disconnect, not notifying + return false } else { - SecureLogger.log("Physical disconnect for \(peerID) - was NOT gracefully left, will notify delegate", category: SecureLogger.session, level: .info) + // Ungraceful disconnect, will notify + // Track this notification + self.recentDisconnectNotifications[peerID] = now + return true } - return !isGracefullyLeft } if shouldNotifyDisconnect { @@ -4063,35 +4758,42 @@ extension BluetoothMeshService: CBCentralManagerDelegate { // Only clear sessions on authentication failure if peerID.count == 16 { // Real peer ID // Clear connection time and last heard tracking on disconnect to properly detect stale sessions - lastConnectionTime.removeValue(forKey: peerID) - lastHeardFromPeer.removeValue(forKey: peerID) + // Time tracking removed - now in PeerSession + // Time tracking removed - now in PeerSession // Keep lastSuccessfulMessageTime to validate session on reconnect - let lastSuccess = lastSuccessfulMessageTime[peerID] ?? Date.distantPast - let sessionAge = Date().timeIntervalSince(lastSuccess) - SecureLogger.log("Peer disconnected: \(peerID), keeping Noise session (age: \(Int(sessionAge))s)", category: SecureLogger.noise, level: .info) + let lastSuccess = self.peerSessions[peerID]?.lastSuccessfulMessageTime ?? Date.distantPast + let _ = Date().timeIntervalSince(lastSuccess) + // Keeping Noise session on disconnect } // Only remove from active peers if it's not a temp ID - // Temp IDs shouldn't be in activePeers anyway + // Temp IDs shouldn't be marked as active anyway let (removed, _) = collectionsQueue.sync(flags: .barrier) { var removed = false if peerID.count == 16 { // Real peer ID (8 bytes = 16 hex chars) - removed = activePeers.remove(peerID) != nil + removed = self.peerSessions[peerID]?.isActivePeer == true + if removed, let session = self.peerSessions[peerID] { + session.isActivePeer = false + } if removed { // Only log disconnect if peer didn't gracefully leave if !self.gracefullyLeftPeers.contains(peerID) { - let nickname = self.peerNicknames[peerID] ?? "unknown" + let nickname = self.peerSessions[peerID]?.nickname ?? "unknown" SecureLogger.log("📴 Peer disconnected from network: \(peerID) (\(nickname))", category: SecureLogger.session, level: .info) } else { // Peer gracefully left, just clean up the tracking self.gracefullyLeftPeers.remove(peerID) self.gracefulLeaveTimestamps.removeValue(forKey: peerID) - SecureLogger.log("Cleaning up gracefullyLeftPeers for \(peerID)", category: SecureLogger.session, level: .debug) + // Cleaning up gracefullyLeftPeers } } - _ = announcedPeers.remove(peerID) - _ = announcedToPeers.remove(peerID) + // Update PeerSession + if let session = self.peerSessions[peerID] { + session.hasReceivedAnnounce = false + session.hasAnnounced = false + } + // hasAnnounced already updated in PeerSession above } else { } @@ -4104,16 +4806,18 @@ extension BluetoothMeshService: CBCentralManagerDelegate { // Peer disconnected - return (removed, peerNicknames[peerID]) + return (removed, self.peerSessions[peerID]?.nickname) } - // Always notify peer list update on disconnect, regardless of whether peer was in activePeers + // Always notify peer list update on disconnect, regardless of whether peer was active // This ensures UI stays in sync even if there was a state mismatch self.notifyPeerListUpdate(immediate: true) if removed { // Mark when network became empty, but don't reset flag immediately - let currentNetworkSize = collectionsQueue.sync { activePeers.count } + let currentNetworkSize = collectionsQueue.sync { + peerSessions.values.filter { $0.isActivePeer }.count + } if currentNetworkSize == 0 && networkBecameEmptyTime == nil { networkBecameEmptyTime = Date() } @@ -4246,70 +4950,6 @@ extension BluetoothMeshService: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { // Handle notification state updates if needed } - - func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { - let peripheralID = peripheral.identifier.uuidString - - if error != nil { - return - } - - // Validate RSSI value - 127 means no RSSI available - let rssiValue = RSSI.intValue - - // Only store valid RSSI values - if rssiValue == 127 || rssiValue < -100 || rssiValue > 0 { - // Track retry count - let retryCount = rssiRetryCount[peripheralID] ?? 0 - rssiRetryCount[peripheralID] = retryCount + 1 - - - // Retry up to 5 times with exponential backoff - if retryCount < 5 { - let delay = min(0.2 * pow(2.0, Double(retryCount)), 2.0) // 0.2s, 0.4s, 0.8s, 1.6s, 2.0s max - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self, weak peripheral] in - guard let peripheral = peripheral, peripheral.state == .connected else { - self?.rssiRetryCount.removeValue(forKey: peripheralID) - return - } - peripheral.readRSSI() - } - } else { - // Give up after 5 retries, reset counter - rssiRetryCount.removeValue(forKey: peripheralID) - } - return - } - - // Valid RSSI received, reset retry count - rssiRetryCount.removeValue(forKey: peripheralID) - - // Update RSSI in simplified mapping - updatePeripheralRSSI(peripheralID: peripheralID, rssi: RSSI) - - // Also store in legacy mapping for compatibility - peripheralRSSI[peripheralID] = RSSI - - // If we have a peer ID, update peer RSSI (handled in updatePeripheralRSSI) - if let mapping = peripheralMappings[peripheralID], mapping.isIdentified { - // Force UI update when we have a real peer ID - DispatchQueue.main.async { [weak self] in - self?.notifyPeerListUpdate() - } - } else { - // Keep trying to read RSSI until we get real peer ID - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak peripheral] in - guard let peripheral = peripheral, peripheral.state == .connected else { return } - peripheral.readRSSI() - } - } - - // Periodically update RSSI - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak peripheral] in - guard let peripheral = peripheral, peripheral.state == .connected else { return } - peripheral.readRSSI() - } - } } extension BluetoothMeshService: CBPeripheralManagerDelegate { @@ -4553,17 +5193,6 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // MARK: - Range Optimization Methods - // Calculate relay probability based on RSSI (edge nodes relay more) - private func calculateRelayProbability(baseProb: Double, rssi: Int) -> Double { - // RSSI typically ranges from -30 (very close) to -90 (far) - // We want edge nodes (weak RSSI) to relay more aggressively - let rssiFactor = Double(100 + rssi) / 100.0 // -90 = 0.1, -70 = 0.3, -50 = 0.5 - - // Invert the factor so weak signals relay more - let edgeFactor = max(0.5, 2.0 - rssiFactor) - - return min(1.0, baseProb * edgeFactor) - } // Exponential delay distribution to prevent synchronized collision storms private func exponentialRelayDelay() -> TimeInterval { @@ -4588,7 +5217,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { return true case .message, .announce, .leave, .readReceipt, .deliveryStatusRequest, .fragmentStart, .fragmentContinue, .fragmentEnd, - .noiseIdentityAnnounce, .noiseEncrypted, .protocolNack, .none: + .noiseIdentityAnnounce, .noiseEncrypted, .protocolNack, + .favorited, .unfavorited, .none: return false } } @@ -4959,7 +5589,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Send as a private message so it's encrypted let recipientNickname = collectionsQueue.sync { - return peerNicknames[randomPeer] ?? "unknown" + return self.peerSessions[randomPeer]?.nickname ?? "unknown" } sendPrivateMessage(dummyContent, to: randomPeer, recipientNickname: recipientNickname) @@ -5006,7 +5636,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { guard let messages = pendingMessages else { return } - SecureLogger.log("Sending \(messages.count) pending private messages to \(peerID)", category: SecureLogger.session, level: .info) + // Sending pending private messages // Clear pending messages for this peer self.collectionsQueue.sync(flags: .barrier) { @@ -5019,7 +5649,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { if content.hasPrefix("READ_RECEIPT:") { // Extract the original message ID let originalMessageID = String(content.dropFirst("READ_RECEIPT:".count)) - SecureLogger.log("Sending queued read receipt for message \(originalMessageID) to \(peerID)", category: SecureLogger.session, level: .debug) + // Sending queued read receipt // Create and send the actual read receipt let receipt = ReadReceipt( @@ -5034,7 +5664,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { } } else { // Regular message - SecureLogger.log("Sending pending message \(messageID) to \(peerID)", category: SecureLogger.session, level: .debug) + // Sending pending message // Use async to avoid blocking the queue DispatchQueue.global().async { [weak self] in self?.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) @@ -5072,7 +5702,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Send keep-alive pings to all connected peers to prevent iOS BLE timeouts private func sendKeepAlivePings() { let connectedPeers = collectionsQueue.sync { - return Array(activePeers).filter { peerID in + return self.peerSessions.compactMap { (peerID, session) -> String? in + guard session.isActivePeer else { return nil } + // Only send keepalive to authenticated peers that still exist // Check if this peer ID is still current (not rotated) guard let fingerprint = peerIDToFingerprint[peerID], @@ -5081,17 +5713,17 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // This peer ID has rotated, skip it SecureLogger.log("Skipping keepalive for rotated peer ID: \(peerID)", category: SecureLogger.session, level: .debug) - return false + return nil } // Check if we actually have a Noise session with this peer guard noiseService.hasEstablishedSession(with: peerID) else { SecureLogger.log("Skipping keepalive for \(peerID) - no established session", category: SecureLogger.session, level: .debug) - return false + return nil } - return peerConnectionStates[peerID] == .authenticated + return peerConnectionStates[peerID] == .authenticated ? peerID : nil } } @@ -5100,7 +5732,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { for peerID in connectedPeers { // Don't spam if we recently heard from them - if let lastHeard = lastHeardFromPeer[peerID], + let lastHeard = self.peerSessions[peerID]?.lastHeardFromPeer + if let lastHeard = lastHeard, Date().timeIntervalSince(lastHeard) < keepAliveInterval / 2 { SecureLogger.log("Skipping keepalive for \(peerID) - heard recently", category: SecureLogger.session, level: .debug) @@ -5164,11 +5797,18 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { private func initiateNoiseHandshake(with peerID: String) { // Use noiseService directly - SecureLogger.log("Initiating Noise handshake with \(peerID)", category: SecureLogger.noise, level: .info) + // Initiating Noise handshake + + // Safety check: Don't handshake with ourselves + if peerID == myPeerID { + SecureLogger.log("⚠️ CRITICAL: Attempted to handshake with self! peerID=\(peerID), myPeerID=\(myPeerID)", + category: SecureLogger.handshake, level: .error) + return + } // Check if we already have an established session if noiseService.hasEstablishedSession(with: peerID) { - SecureLogger.log("Already have established session with \(peerID)", category: SecureLogger.noise, level: .debug) + // Already have established session // Clear any lingering handshake attempt time handshakeAttemptTimes.removeValue(forKey: peerID) handshakeCoordinator.recordHandshakeSuccess(peerID: peerID) @@ -5200,8 +5840,10 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { } // Check with coordinator if we should initiate + SecureLogger.log("Checking handshake coordinator - myPeerID: \(myPeerID), remotePeerID: \(peerID), hasPending: \(hasPendingMessages)", + category: SecureLogger.handshake, level: .debug) if !handshakeCoordinator.shouldInitiateHandshake(myPeerID: myPeerID, remotePeerID: peerID, forceIfStale: hasPendingMessages) { - SecureLogger.log("Coordinator says we should not initiate handshake with \(peerID)", category: SecureLogger.handshake, level: .debug) + // Coordinator says no handshake if hasPendingMessages { // Check if peer is still connected before retrying @@ -5209,7 +5851,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { if connectionState == .disconnected { // Peer is disconnected - clear pending messages and stop retrying - SecureLogger.log("Peer \(peerID) is disconnected, clearing pending messages", category: SecureLogger.handshake, level: .info) + // Peer disconnected, clearing pending collectionsQueue.async(flags: .barrier) { [weak self] in self?.pendingPrivateMessages[peerID]?.removeAll() } @@ -5217,7 +5859,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { } else { // Peer is still connected but handshake is stuck // Send identity announce to prompt them to initiate if they have lower ID - SecureLogger.log("Handshake stuck with connected peer \(peerID), sending identity announce", category: SecureLogger.handshake, level: .info) + // Handshake stuck, sending identity sendNoiseIdentityAnnounce(to: peerID) // Only retry if we haven't retried too many times @@ -5241,7 +5883,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Check if there's a retry delay if let retryDelay = handshakeCoordinator.getRetryDelay(for: peerID), retryDelay > 0 { - SecureLogger.log("Waiting \(retryDelay)s before retrying handshake with \(peerID)", category: SecureLogger.handshake, level: .debug) + // Waiting before retry DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { [weak self] in self?.initiateNoiseHandshake(with: peerID) } @@ -5310,20 +5952,20 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { SecureLogger.logHandshake("processing \(isInitiation ? "init" : "response")", peerID: peerID, success: true) // Get current handshake state before processing - let currentState = handshakeCoordinator.getHandshakeState(for: peerID) - let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID) - SecureLogger.log("Current handshake state for \(peerID): \(currentState), hasEstablishedSession: \(hasEstablishedSession)", category: SecureLogger.noise, level: .info) + let _ = handshakeCoordinator.getHandshakeState(for: peerID) + let _ = noiseService.hasEstablishedSession(with: peerID) + // Current handshake state check // Check for duplicate handshake messages if handshakeCoordinator.isDuplicateHandshakeMessage(message) { - SecureLogger.log("Duplicate handshake message from \(peerID), ignoring", category: SecureLogger.handshake, level: .debug) + // Duplicate handshake message, ignoring return } // If this is an initiation, check if we should accept it if isInitiation { if !handshakeCoordinator.shouldAcceptHandshakeInitiation(myPeerID: myPeerID, remotePeerID: peerID) { - SecureLogger.log("Coordinator says we should not accept handshake from \(peerID)", category: SecureLogger.handshake, level: .debug) + // Coordinator says no accept return } // Record that we're responding @@ -5360,7 +6002,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { broadcastPacket(packet) } } else { - SecureLogger.log("No response needed from processHandshakeMessage (isInitiation: \(isInitiation))", category: SecureLogger.noise, level: .debug) + // No response needed } // Check if handshake is complete @@ -5388,8 +6030,17 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { handshakeAttemptTimes.removeValue(forKey: peerID) // Initialize last successful message time - lastSuccessfulMessageTime[peerID] = Date() - SecureLogger.log("Initialized lastSuccessfulMessageTime for \(peerID)", category: SecureLogger.noise, level: .debug) + updateLastSuccessfulMessageTime(peerID) + // Initialized message time + + // Update PeerSession + if let session = self.peerSessions[peerID] { + session.lastSuccessfulMessageTime = Date() + } else { + let session = PeerSession(peerID: peerID) + session.lastSuccessfulMessageTime = Date() + self.peerSessions[peerID] = session + } // Send identity announcement to this specific peer sendNoiseIdentityAnnounce(to: peerID) @@ -5412,7 +6063,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { } } catch NoiseSessionError.alreadyEstablished { // Session already established, ignore handshake - SecureLogger.log("Handshake already established with \(peerID)", category: SecureLogger.noise, level: .info) + // Handshake already established handshakeCoordinator.recordHandshakeSuccess(peerID: peerID) // Update session state to established @@ -5468,16 +6119,33 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Successfully decrypted message // Update last successful message time - lastSuccessfulMessageTime[peerID] = Date() + updateLastSuccessfulMessageTime(peerID) + + // Note: PeerSession already updated in helper + if let session = self.peerSessions[peerID] { + session.lastSuccessfulMessageTime = Date() + } // Send protocol ACK after successful decryption (only once per encrypted packet) sendProtocolAck(for: originalPacket, to: peerID) - // If we can decrypt messages from this peer, they should be in activePeers + // If we can decrypt messages from this peer, they should be marked as active let wasAdded = collectionsQueue.sync(flags: .barrier) { - if !self.activePeers.contains(peerID) { - SecureLogger.log("Adding \(peerID) to activePeers after successful decryption", category: SecureLogger.noise, level: .info) - return self.activePeers.insert(peerID).inserted + let isActive = self.peerSessions[peerID]?.isActivePeer ?? false + if !isActive { + // Marking peer as active + // Update PeerSession + if let session = self.peerSessions[peerID] { + session.isActivePeer = true + } else { + let session = PeerSession(peerID: peerID) + session.isActivePeer = true + self.peerSessions[peerID] = session + } + // Mark as active and return true if newly activated + let wasActive = self.peerSessions[peerID]?.isActivePeer ?? false + self.peerSessions[peerID]?.isActivePeer = true + return !wasActive } return false } @@ -5498,7 +6166,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Decode the delivery ACK - try binary first, then JSON if let ack = DeliveryAck.fromBinaryData(ackData) { - SecureLogger.log("Received binary delivery ACK via Noise: \(ack.originalMessageID) from \(ack.recipientNickname)", category: SecureLogger.session, level: .debug) + // Received binary delivery ACK // Process the ACK DeliveryTracker.shared.processDeliveryAck(ack) @@ -5509,7 +6177,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { } return } else if let ack = DeliveryAck.decode(from: ackData) { - SecureLogger.log("Received JSON delivery ACK via Noise: \(ack.originalMessageID) from \(ack.recipientNickname)", category: SecureLogger.session, level: .debug) + // Received JSON delivery ACK // Process the ACK DeliveryTracker.shared.processDeliveryAck(ack) @@ -5531,7 +6199,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Decode the read receipt from binary if let receipt = ReadReceipt.fromBinaryData(receiptData) { - SecureLogger.log("Received binary read receipt via Noise: \(receipt.originalMessageID) from \(receipt.readerNickname)", category: SecureLogger.session, level: .debug) + // Received binary read receipt // Process the read receipt DispatchQueue.main.async { @@ -5546,7 +6214,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Try to parse as a full inner packet (for backward compatibility and other message types) if let innerPacket = BitchatPacket.from(decryptedData) { - SecureLogger.log("Successfully parsed inner packet - type: \(MessageType(rawValue: innerPacket.type)?.description ?? "unknown"), from: \(innerPacket.senderID.hexEncodedString()), to: \(innerPacket.recipientID?.hexEncodedString() ?? "broadcast")", category: SecureLogger.session, level: .debug) + // Successfully parsed inner packet // Process the decrypted inner packet // The packet will be handled according to its recipient ID @@ -5560,7 +6228,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Failed to decrypt - might need to re-establish session SecureLogger.log("Failed to decrypt Noise message from \(peerID): \(error)", category: SecureLogger.encryption, level: .error) if !noiseService.hasEstablishedSession(with: peerID) { - SecureLogger.log("No Noise session with \(peerID), attempting handshake", category: SecureLogger.noise, level: .info) + // No Noise session, attempting handshake attemptHandshakeIfNeeded(with: peerID, forceIfStale: true) } else { SecureLogger.log("Have session with \(peerID) but decryption failed", category: SecureLogger.encryption, level: .warning) @@ -5597,23 +6265,32 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { } // Check if this peer is reconnecting after disconnect - if let lastConnected = lastConnectionTime[peerID] { + if let session = peerSessions[peerID], let lastConnected = session.lastConnectionTime { let timeSinceLastConnection = Date().timeIntervalSince(lastConnected) // Only clear truly stale sessions, not on every reconnect if timeSinceLastConnection > 86400.0 { // More than 24 hours since last connection // Clear any stale Noise session if noiseService.hasEstablishedSession(with: peerID) { - SecureLogger.log("Peer \(peerID) reconnecting after \(Int(timeSinceLastConnection))s - clearing stale session", category: SecureLogger.noise, level: .info) + // Clearing stale session on reconnect cleanupPeerCryptoState(peerID) } } else if timeSinceLastConnection > 5.0 { // Just log the reconnection, don't clear the session - SecureLogger.log("Peer \(peerID) reconnecting after \(Int(timeSinceLastConnection))s - keeping existing session", category: SecureLogger.noise, level: .info) + // Keeping existing session on reconnect } } // Update last connection time - lastConnectionTime[peerID] = Date() + updateLastConnectionTime(peerID) + + // Note: PeerSession already updated in helper + if let session = peerSessions[peerID] { + session.lastConnectionTime = Date() + } else { + let session = PeerSession(peerID: peerID) + session.lastConnectionTime = Date() + peerSessions[peerID] = session + } // Check if we've already negotiated version with this peer if let existingVersion = negotiatedVersions[peerID] { @@ -5629,10 +6306,10 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Try JSON first if it looks like JSON let hello: VersionHello? if let firstByte = dataCopy.first, firstByte == 0x7B { // '{' character - SecureLogger.log("Version hello from \(peerID) appears to be JSON (size: \(dataCopy.count))", category: SecureLogger.session, level: .debug) + // Version hello is JSON hello = VersionHello.decode(from: dataCopy) ?? VersionHello.fromBinaryData(dataCopy) } else { - SecureLogger.log("Version hello from \(peerID) appears to be binary (size: \(dataCopy.count), first byte: \(dataCopy.first?.description ?? "nil"))", category: SecureLogger.session, level: .debug) + // Version hello is binary hello = VersionHello.fromBinaryData(dataCopy) ?? VersionHello.decode(from: dataCopy) } @@ -5644,11 +6321,16 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { SecureLogger.log("Received version hello from \(peerID): supported versions \(hello.supportedVersions), preferred \(hello.preferredVersion)", category: SecureLogger.session, level: .debug) + // Track when we received version hello from this peer + collectionsQueue.async(flags: .barrier) { [weak self] in + self?.lastVersionHelloTime[peerID] = Date() + } + // Find the best common version let ourVersions = Array(ProtocolVersion.supportedVersions) if let agreedVersion = ProtocolVersion.negotiateVersion(clientVersions: hello.supportedVersions, serverVersions: ourVersions) { // We can communicate! Send ACK - SecureLogger.log("Version negotiation agreed with \(peerID): v\(agreedVersion) (client: \(hello.clientVersion), platform: \(hello.platform))", category: SecureLogger.session, level: .info) + // Version negotiation agreed negotiatedVersions[peerID] = agreedVersion versionNegotiationState[peerID] = .ackReceived(version: agreedVersion) @@ -5725,11 +6407,12 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Clean up state for incompatible peer collectionsQueue.sync(flags: .barrier) { - _ = self.activePeers.remove(peerID) - _ = self.peerNicknames.removeValue(forKey: peerID) - _ = self.lastHeardFromPeer.removeValue(forKey: peerID) + _ = self.peerSessions.removeValue(forKey: peerID) + } + // Update PeerSession + if let session = self.peerSessions[peerID] { + session.hasReceivedAnnounce = false } - announcedPeers.remove(peerID) // Clean up any Noise session cleanupPeerCryptoState(peerID) @@ -5840,7 +6523,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { if let messageType = MessageType(rawValue: ack.packetType) { switch messageType { case .noiseHandshakeInit, .noiseHandshakeResp: - SecureLogger.log("Handshake confirmed by \(peerID)", category: SecureLogger.handshake, level: .info) + // Handshake confirmed + break case .noiseEncrypted: // Encrypted message confirmed break @@ -5946,18 +6630,18 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Check if we already have a session if noiseService.hasEstablishedSession(with: peerID) { // We already have a session, no action needed - SecureLogger.log("Already have session with \(peerID), ignoring handshake request", category: SecureLogger.noise, level: .debug) + // Already have session, ignoring request return } // Apply tie-breaker logic for handshake initiation if myPeerID < peerID { // We have lower ID, initiate handshake - SecureLogger.log("Initiating handshake with \(peerID) in response to handshake request", category: SecureLogger.noise, level: .info) + // Initiating handshake in response initiateNoiseHandshake(with: peerID) } else { // We have higher ID, send identity announce to prompt them - SecureLogger.log("Sending identity announce to \(peerID) in response to handshake request", category: SecureLogger.noise, level: .info) + // Sending identity announce sendNoiseIdentityAnnounce(to: peerID) } } @@ -6166,17 +6850,6 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { } } - // Update RSSI for all connected peripherals - private func updateAllPeripheralRSSI() { - - // Read RSSI for all connected peripherals - for (_, peripheral) in connectedPeripherals { - if peripheral.state == .connected { - peripheral.readRSSI() - } - } - } - // Check peer availability based on last heard time private func checkPeerAvailability() { let now = Date() @@ -6184,10 +6857,17 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { collectionsQueue.sync(flags: .barrier) { // Check all active peers - for peerID in activePeers { - let lastHeard = lastHeardFromPeer[peerID] ?? Date.distantPast + for (peerID, session) in peerSessions where session.isActivePeer { + // Get last heard time from PeerSession or legacy tracking + let lastHeard: Date + if let session = peerSessions[peerID] { + lastHeard = session.lastHeardFromPeer ?? Date.distantPast + } else { + lastHeard = self.peerSessions[peerID]?.lastHeardFromPeer ?? Date.distantPast + } + let timeSinceLastHeard = now.timeIntervalSince(lastHeard) - let wasAvailable = peerAvailabilityState[peerID] ?? true + let wasAvailable = peerSessions[peerID]?.isAvailable ?? true // Check connection state let connectionState = peerConnectionStates[peerID] ?? .disconnected @@ -6200,16 +6880,24 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { (connectionState == .authenticated && timeSinceLastHeard < peerAvailabilityTimeout) if wasAvailable != isAvailable { - peerAvailabilityState[peerID] = isAvailable + // Availability is now tracked in PeerSession + + // Update PeerSession availability + if let session = peerSessions[peerID] { + session.isAvailable = isAvailable + } else { + // Create session if needed + let session = PeerSession(peerID: peerID) + session.isAvailable = isAvailable + peerSessions[peerID] = session + } + stateChanges.append((peerID: peerID, available: isAvailable)) } } // Remove availability state for peers no longer active - let inactivePeers = peerAvailabilityState.keys.filter { !activePeers.contains($0) } - for peerID in inactivePeers { - peerAvailabilityState.removeValue(forKey: peerID) - } + // Availability state cleanup no longer needed - tracked in PeerSession } // Notify about availability changes @@ -6226,13 +6914,28 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Update peer availability when we hear from them private func updatePeerAvailability(_ peerID: String) { + updateLastHeardFromPeer(peerID) + collectionsQueue.sync(flags: .barrier) { - lastHeardFromPeer[peerID] = Date() + // Note: lastHeardFromPeer already updated in helper above + var needsNotification = false + if let session = self.peerSessions[peerID] { + session.lastHeardFromPeer = Date() + if !session.isAvailable { + session.isAvailable = true + needsNotification = true + } + } + // Don't create sessions without peripheral connections + // No notification needed if we're not tracking this peer yet - // If peer wasn't available, mark as available now - if peerAvailabilityState[peerID] != true { - peerAvailabilityState[peerID] = true - + // Update legacy state + if peerSessions[peerID]?.isAvailable != true { + // Availability updated in PeerSession already + needsNotification = true + } + + if needsNotification { SecureLogger.log("Peer \(peerID) marked as available after hearing from them", category: SecureLogger.session, level: .info) @@ -6246,7 +6949,12 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Check if a peer is currently available func isPeerAvailable(_ peerID: String) -> Bool { return collectionsQueue.sync { - return peerAvailabilityState[peerID] ?? false + // Check PeerSession first + if let session = peerSessions[peerID] { + return session.isAvailable + } + // Fallback to legacy state + return false // If no session exists, peer is not available } } @@ -6292,7 +7000,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { signingPublicKey: signingKey, nickname: nickname, timestamp: now, - previousPeerID: previousPeerID, + previousPeerID: nil, signature: signature ) @@ -6347,12 +7055,12 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Check if session is stale (no successful communication for a while) var sessionIsStale = false if hasSession { - let lastSuccess = lastSuccessfulMessageTime[recipientPeerID] ?? Date.distantPast + let lastSuccess = self.peerSessions[recipientPeerID]?.lastSuccessfulMessageTime ?? Date.distantPast let sessionAge = Date().timeIntervalSince(lastSuccess) // Increase session validity to 24 hours - sessions should persist across temporary disconnects if sessionAge > 86400.0 { // More than 24 hours since last successful message sessionIsStale = true - SecureLogger.log("Session with \(recipientPeerID) is stale (last success: \(Int(sessionAge))s ago), will re-establish", category: SecureLogger.noise, level: .info) + // Session stale, will re-establish } } @@ -6367,7 +7075,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { self.noiseSessionStates[recipientPeerID] = .handshakeQueued } - SecureLogger.log("No valid Noise session with \(recipientPeerID), initiating handshake (lazy mode)", category: SecureLogger.noise, level: .info) + // No valid session, initiating handshake // Notify UI that we're establishing encryption DispatchQueue.main.async { [weak self] in @@ -6384,8 +7092,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { self.pendingPrivateMessages[recipientPeerID] = [] } self.pendingPrivateMessages[recipientPeerID]?.append((content, recipientNickname, messageID ?? UUID().uuidString)) - let count = self.pendingPrivateMessages[recipientPeerID]?.count ?? 0 - SecureLogger.log("Queued private message for \(recipientPeerID), \(count) messages pending", category: SecureLogger.noise, level: .info) + let _ = self.pendingPrivateMessages[recipientPeerID]?.count ?? 0 + // Queued private message } // Send handshake request to notify recipient of pending messages @@ -6463,12 +7171,17 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { do { // Encrypt with Noise - SecureLogger.log("Encrypting private message \(msgID) for \(recipientPeerID)", category: SecureLogger.encryption, level: .debug) + // Encrypting private message let encryptedData = try noiseService.encrypt(innerData, for: recipientPeerID) - SecureLogger.log("Successfully encrypted message, size: \(encryptedData.count)", category: SecureLogger.encryption, level: .debug) + // Successfully encrypted // Update last successful message time - lastSuccessfulMessageTime[recipientPeerID] = Date() + updateLastSuccessfulMessageTime(recipientPeerID) + + // Note: PeerSession already updated in helper + if let session = peerSessions[recipientPeerID] { + session.lastSuccessfulMessageTime = Date() + } // Send as Noise encrypted message let outerPacket = BitchatPacket( @@ -6480,7 +7193,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { signature: nil, ttl: adaptiveTTL ) - SecureLogger.log("Sending encrypted private message \(msgID) to \(recipientPeerID)", category: SecureLogger.session, level: .info) + // Sending encrypted private message // Track packet for ACK trackPacketForAck(outerPacket) @@ -6511,7 +7224,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Send only to the intended recipient writeToPeripheral(data, peripheral: peripheral, characteristic: characteristic, peerID: recipientPeerID) - SecureLogger.log("Sent private message directly to \(recipientPeerID)", category: SecureLogger.session, level: .info) + // Sent message directly return true } @@ -6541,28 +7254,26 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Try direct delivery first if sendDirectToRecipient(packet, recipientPeerID: targetPeerID) { - SecureLogger.log("Sent handshake request directly to \(targetPeerID)", category: SecureLogger.noise, level: .info) + // Sent handshake directly } else { // Use selective relay if direct delivery fails sendViaSelectiveRelay(packet, recipientPeerID: targetPeerID) - SecureLogger.log("Sent handshake request via relay to \(targetPeerID)", category: SecureLogger.noise, level: .info) + // Sent handshake via relay } } private func selectBestRelayPeers(excluding: String, maxPeers: Int = 3) -> [String] { - // Select peers with best RSSI for relay, excluding the target recipient - var candidates: [(peerID: String, rssi: Int)] = [] + // Select random peers for relay, excluding the target recipient + var candidates: [String] = [] for (peerID, _) in connectedPeripherals { if peerID != excluding && peerID != myPeerID { - let rssiValue = peerRSSI[peerID]?.intValue ?? -80 - candidates.append((peerID: peerID, rssi: rssiValue)) + candidates.append(peerID) } } - // Sort by RSSI (strongest first) and take top N - candidates.sort { $0.rssi > $1.rssi } - return candidates.prefix(maxPeers).map { $0.peerID } + // Randomly select up to maxPeers peers + return Array(candidates.shuffled().prefix(maxPeers)) } private func sendViaSelectiveRelay(_ packet: BitchatPacket, recipientPeerID: String) { @@ -6617,7 +7328,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { // Skip if this peripheral has an active peer connection if let peerID = peerIDByPeripheralID[peripheralID], - activePeers.contains(peerID) { + self.peerSessions[peerID]?.isActivePeer == true { continue } diff --git a/bitchat/Services/FavoritesPersistenceService.swift b/bitchat/Services/FavoritesPersistenceService.swift new file mode 100644 index 00000000..b95ce068 --- /dev/null +++ b/bitchat/Services/FavoritesPersistenceService.swift @@ -0,0 +1,415 @@ +import Foundation +import Combine + +/// Manages persistent favorite relationships between peers +@MainActor +class FavoritesPersistenceService: ObservableObject { + + struct FavoriteRelationship: Codable { + let peerNoisePublicKey: Data + let peerNostrPublicKey: String? + let peerNickname: String + let isFavorite: Bool + let theyFavoritedUs: Bool + let favoritedAt: Date + let lastUpdated: Date + + var isMutual: Bool { + isFavorite && theyFavoritedUs + } + } + + private static let storageKey = "chat.bitchat.favorites" + private static let keychainService = "chat.bitchat.favorites" + + @Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship + @Published private(set) var mutualFavorites: Set = [] + + private let userDefaults = UserDefaults.standard + private var cancellables = Set() + + static let shared = FavoritesPersistenceService() + + private init() { + loadFavorites() + + // Update mutual favorites when favorites change + $favorites + .map { favorites in + Set(favorites.compactMap { $0.value.isMutual ? $0.key : nil }) + } + .assign(to: &$mutualFavorites) + } + + /// Add or update a favorite + func addFavorite( + peerNoisePublicKey: Data, + peerNostrPublicKey: String? = nil, + peerNickname: String + ) { + SecureLogger.log("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", + category: SecureLogger.session, level: .info) + + let existing = favorites[peerNoisePublicKey] + + let relationship = FavoriteRelationship( + peerNoisePublicKey: peerNoisePublicKey, + peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey, + peerNickname: peerNickname, + isFavorite: true, + theyFavoritedUs: existing?.theyFavoritedUs ?? false, + favoritedAt: existing?.favoritedAt ?? Date(), + lastUpdated: Date() + ) + + // Log if this creates a mutual favorite + if relationship.isMutual { + SecureLogger.log("💕 Mutual favorite relationship established with \(peerNickname)!", + category: SecureLogger.session, level: .info) + } + + favorites[peerNoisePublicKey] = relationship + saveFavorites() + + // Notify observers + NotificationCenter.default.post( + name: .favoriteStatusChanged, + object: nil, + userInfo: ["peerPublicKey": peerNoisePublicKey] + ) + } + + /// Remove a favorite + func removeFavorite(peerNoisePublicKey: Data) { + guard let existing = favorites[peerNoisePublicKey] else { return } + + SecureLogger.log("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", + category: SecureLogger.session, level: .info) + + // If they still favorite us, keep the record but mark us as not favoriting + if existing.theyFavoritedUs { + let updated = FavoriteRelationship( + peerNoisePublicKey: existing.peerNoisePublicKey, + peerNostrPublicKey: existing.peerNostrPublicKey, + peerNickname: existing.peerNickname, + isFavorite: false, + theyFavoritedUs: true, + favoritedAt: existing.favoritedAt, + lastUpdated: Date() + ) + favorites[peerNoisePublicKey] = updated + // Keeping record - they still favorite us + } else { + // Neither side favorites, remove completely + favorites.removeValue(forKey: peerNoisePublicKey) + // Completely removed from favorites + } + + saveFavorites() + + // Notify observers + NotificationCenter.default.post( + name: .favoriteStatusChanged, + object: nil, + userInfo: ["peerPublicKey": peerNoisePublicKey] + ) + } + + /// Update when we learn a peer favorited/unfavorited us + func updatePeerFavoritedUs( + peerNoisePublicKey: Data, + favorited: Bool, + peerNickname: String? = nil, + peerNostrPublicKey: String? = nil + ) { + let existing = favorites[peerNoisePublicKey] + let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown" + + SecureLogger.log("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", + category: SecureLogger.session, level: .info) + + let relationship = FavoriteRelationship( + peerNoisePublicKey: peerNoisePublicKey, + peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey, + peerNickname: displayName, + isFavorite: existing?.isFavorite ?? false, + theyFavoritedUs: favorited, + favoritedAt: existing?.favoritedAt ?? Date(), + lastUpdated: Date() + ) + + if !relationship.isFavorite && !relationship.theyFavoritedUs { + // Neither side favorites, remove completely + favorites.removeValue(forKey: peerNoisePublicKey) + // Removed - neither side favorites anymore + } else { + favorites[peerNoisePublicKey] = relationship + + // Check if this creates a mutual favorite + if relationship.isMutual { + SecureLogger.log("💕 Mutual favorite relationship established with \(displayName)!", + category: SecureLogger.session, level: .info) + } + } + + saveFavorites() + + // Notify observers + NotificationCenter.default.post( + name: .favoriteStatusChanged, + object: nil, + userInfo: ["peerPublicKey": peerNoisePublicKey] + ) + } + + /// Check if a peer is favorited by us + func isFavorite(_ peerNoisePublicKey: Data) -> Bool { + favorites[peerNoisePublicKey]?.isFavorite ?? false + } + + /// Check if we have a mutual favorite relationship + func isMutualFavorite(_ peerNoisePublicKey: Data) -> Bool { + favorites[peerNoisePublicKey]?.isMutual ?? false + } + + /// Get favorite status for a peer + func getFavoriteStatus(for peerNoisePublicKey: Data) -> FavoriteRelationship? { + favorites[peerNoisePublicKey] + } + + /// Update Nostr public key for a peer + func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) { + guard let existing = favorites[peerNoisePublicKey] else { return } + + let updated = FavoriteRelationship( + peerNoisePublicKey: existing.peerNoisePublicKey, + peerNostrPublicKey: nostrPubkey, + peerNickname: existing.peerNickname, + isFavorite: existing.isFavorite, + theyFavoritedUs: existing.theyFavoritedUs, + favoritedAt: existing.favoritedAt, + lastUpdated: Date() + ) + + favorites[peerNoisePublicKey] = updated + saveFavorites() + } + + /// Update nickname for an existing favorite + func updateNickname(for peerNoisePublicKey: Data, newNickname: String) { + guard let existing = favorites[peerNoisePublicKey] else { return } + + // Skip if nickname hasn't changed + if existing.peerNickname == newNickname { return } + + // Updating nickname for favorite + + let updated = FavoriteRelationship( + peerNoisePublicKey: existing.peerNoisePublicKey, + peerNostrPublicKey: existing.peerNostrPublicKey, + peerNickname: newNickname, + isFavorite: existing.isFavorite, + theyFavoritedUs: existing.theyFavoritedUs, + favoritedAt: existing.favoritedAt, + lastUpdated: Date() + ) + + favorites[peerNoisePublicKey] = updated + saveFavorites() + + // Notify observers + NotificationCenter.default.post( + name: .favoriteStatusChanged, + object: nil, + userInfo: ["peerPublicKey": peerNoisePublicKey] + ) + } + + /// Update noise public key when peer reconnects with new ID + func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) { + guard let existing = favorites[oldKey] else { + SecureLogger.log("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", + category: SecureLogger.session, level: .warning) + return + } + + // Check if we already have a favorite with the new key + if favorites[newKey] != nil { + SecureLogger.log("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", + category: SecureLogger.session, level: .warning) + favorites.removeValue(forKey: oldKey) + saveFavorites() + return + } + + // Updating noise public key + + // Remove old entry + favorites.removeValue(forKey: oldKey) + + // Add with new key + let updated = FavoriteRelationship( + peerNoisePublicKey: newKey, + peerNostrPublicKey: existing.peerNostrPublicKey, + peerNickname: peerNickname, + isFavorite: existing.isFavorite, + theyFavoritedUs: existing.theyFavoritedUs, + favoritedAt: existing.favoritedAt, + lastUpdated: Date() + ) + + favorites[newKey] = updated + saveFavorites() + + // Notify observers with both old and new keys + NotificationCenter.default.post( + name: .favoriteStatusChanged, + object: nil, + userInfo: [ + "peerPublicKey": newKey, + "oldPeerPublicKey": oldKey, + "isKeyUpdate": true + ] + ) + } + + /// Get all favorites (including non-mutual) + func getAllFavorites() -> [FavoriteRelationship] { + favorites.values.filter { $0.isFavorite } + } + + /// Get only mutual favorites + func getMutualFavorites() -> [FavoriteRelationship] { + favorites.values.filter { $0.isMutual } + } + + /// Get all favorite relationships (including where they favorited us) + func getAllRelationships() -> [FavoriteRelationship] { + Array(favorites.values) + } + + /// Clear all favorites - used for panic mode + func clearAllFavorites() { + SecureLogger.log("🧹 Clearing all favorites (panic mode)", category: SecureLogger.session, level: .warning) + + favorites.removeAll() + saveFavorites() + + // Delete from keychain directly + KeychainHelper.delete( + key: Self.storageKey, + service: Self.keychainService + ) + + // Post notification for UI update + NotificationCenter.default.post(name: .favoriteStatusChanged, object: nil) + } + + // MARK: - Persistence + + private func saveFavorites() { + let relationships = Array(favorites.values) + // Saving favorite relationships to keychain + + do { + let encoder = JSONEncoder() + let data = try encoder.encode(relationships) + + // Store in keychain for security + KeychainHelper.save( + key: Self.storageKey, + data: data, + service: Self.keychainService + ) + + // Successfully saved favorites + } catch { + SecureLogger.log("Failed to save favorites: \(error)", category: SecureLogger.session, level: .error) + } + } + + private func loadFavorites() { + // Loading favorites from keychain + + guard let data = KeychainHelper.load( + key: Self.storageKey, + service: Self.keychainService + ) else { + SecureLogger.log("📭 No existing favorites found in keychain", category: SecureLogger.session, level: .info) + return + } + + do { + let decoder = JSONDecoder() + let relationships = try decoder.decode([FavoriteRelationship].self, from: data) + + SecureLogger.log("✅ Loaded \(relationships.count) favorite relationships", + category: SecureLogger.session, level: .info) + + // Log Nostr public key info + for relationship in relationships { + if relationship.peerNostrPublicKey == nil { + SecureLogger.log("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", + category: SecureLogger.session, level: .warning) + } + } + + // Convert to dictionary, cleaning up duplicates by public key (not nickname) + var seenPublicKeys: [Data: FavoriteRelationship] = [:] + var cleanedRelationships: [FavoriteRelationship] = [] + + for relationship in relationships { + // Check for duplicates by public key (the actual unique identifier) + if let existing = seenPublicKeys[relationship.peerNoisePublicKey] { + SecureLogger.log("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", + category: SecureLogger.session, level: .warning) + + // Keep the most recent or most complete relationship + if relationship.lastUpdated > existing.lastUpdated || + (relationship.peerNostrPublicKey != nil && existing.peerNostrPublicKey == nil) { + // Replace with newer/more complete entry + seenPublicKeys[relationship.peerNoisePublicKey] = relationship + cleanedRelationships.removeAll { $0.peerNoisePublicKey == relationship.peerNoisePublicKey } + cleanedRelationships.append(relationship) + } + } else { + seenPublicKeys[relationship.peerNoisePublicKey] = relationship + cleanedRelationships.append(relationship) + } + } + + // If we cleaned up duplicates, save the cleaned list + if cleanedRelationships.count < relationships.count { + // Cleaned up duplicates + + // Clear and rebuild favorites dictionary + favorites.removeAll() + for relationship in cleanedRelationships { + favorites[relationship.peerNoisePublicKey] = relationship + } + + // Save cleaned favorites + saveFavorites() + + // Notify that favorites have been cleaned up (synchronously since we're already on main actor) + NotificationCenter.default.post(name: .favoriteStatusChanged, object: nil) + } else { + // No duplicates, just populate normally + for relationship in cleanedRelationships { + favorites[relationship.peerNoisePublicKey] = relationship + } + } + + // Log loaded relationships + // Loaded relationships successfully + } catch { + SecureLogger.log("Failed to load favorites: \(error)", category: SecureLogger.session, level: .error) + } + } +} + +// MARK: - Notification Names + +extension Notification.Name { + static let favoriteStatusChanged = Notification.Name("FavoriteStatusChanged") +} diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift new file mode 100644 index 00000000..94eff33b --- /dev/null +++ b/bitchat/Services/MessageRouter.swift @@ -0,0 +1,671 @@ +import Foundation +import Combine + +/// Routes messages through the appropriate transport (Bluetooth mesh or Nostr) +@MainActor +class MessageRouter: ObservableObject { + + enum Transport { + case bluetoothMesh + case nostr + } + + enum DeliveryStatus { + case pending + case sent + case delivered + case failed(Error) + } + + struct RoutedMessage { + let id: String + let content: String + let recipientNoisePublicKey: Data + let transport: Transport + let timestamp: Date + var status: DeliveryStatus + } + + @Published private(set) var pendingMessages: [String: RoutedMessage] = [:] + + private let meshService: BluetoothMeshService + private let nostrRelay: NostrRelayManager + private let favoritesService: FavoritesPersistenceService + private let processedMessagesService = ProcessedMessagesService.shared + + private var cancellables = Set() + private let messageDeduplication = LRUCache(maxSize: 1000) + + init( + meshService: BluetoothMeshService, + nostrRelay: NostrRelayManager + ) { + self.meshService = meshService + self.nostrRelay = nostrRelay + self.favoritesService = FavoritesPersistenceService.shared + + setupBindings() + } + + /// Send a message to a peer, automatically selecting the best transport + func sendMessage( + _ content: String, + to recipientNoisePublicKey: Data, + preferredTransport: Transport? = nil, + messageId: String? = nil + ) async throws { + + let finalMessageId = messageId ?? UUID().uuidString + + // Check if peer is available on mesh (actually connected, not just known) + let recipientHexID = recipientNoisePublicKey.hexEncodedString() + let peerAvailableOnMesh = meshService.isPeerConnected(recipientHexID) + + // Check if this is a mutual favorite + let isMutualFavorite = favoritesService.isMutualFavorite(recipientNoisePublicKey) + + // Determine transport + let transport: Transport + if let preferred = preferredTransport { + transport = preferred + } else if peerAvailableOnMesh { + // Always prefer mesh when available + transport = .bluetoothMesh + } else if isMutualFavorite { + // Use Nostr for mutual favorites when not on mesh + transport = .nostr + } else { + throw MessageRouterError.peerNotReachable + } + + // Create routed message + let routedMessage = RoutedMessage( + id: finalMessageId, + content: content, + recipientNoisePublicKey: recipientNoisePublicKey, + transport: transport, + timestamp: Date(), + status: .pending + ) + + pendingMessages[finalMessageId] = routedMessage + + // Route based on transport + switch transport { + case .bluetoothMesh: + try await sendViaMesh(routedMessage) + + case .nostr: + try await sendViaNostr(routedMessage) + } + } + + /// Send a favorite/unfavorite notification + func sendFavoriteNotification( + to recipientNoisePublicKey: Data, + isFavorite: Bool + ) async throws { + + // messageType is used for logging below + // let messageType: MessageType = isFavorite ? .favorited : .unfavorited + let recipientHexID = recipientNoisePublicKey.hexEncodedString() + let action = isFavorite ? "favorite" : "unfavorite" + + SecureLogger.log("📤 Sending \(action) notification to \(recipientHexID)", + category: SecureLogger.session, level: .info) + + // Try mesh first + if meshService.getPeerNicknames()[recipientHexID] != nil { + SecureLogger.log("📡 Sending \(action) notification via Bluetooth mesh", + category: SecureLogger.session, level: .info) + + // Send via mesh as a system message + meshService.sendFavoriteNotification(to: recipientHexID, isFavorite: isFavorite) + + } else if let favoriteStatus = favoritesService.getFavoriteStatus(for: recipientNoisePublicKey), + let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey { + + SecureLogger.log("🌐 Sending \(action) notification via Nostr to \(favoriteStatus.peerNickname)", + category: SecureLogger.session, level: .info) + + // Send via Nostr as a special message + guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { + throw MessageRouterError.noNostrIdentity + } + + // Include our npub in the content + let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)" + let event = try NostrProtocol.createPrivateMessage( + content: content, + recipientPubkey: recipientNostrPubkey, + senderIdentity: senderIdentity + ) + + nostrRelay.sendEvent(event) + } else { + SecureLogger.log("⚠️ Cannot send \(action) notification - peer not reachable via mesh or Nostr", + category: SecureLogger.session, level: .warning) + } + } + + // MARK: - Private Methods + + private func sendViaMesh(_ message: RoutedMessage) async throws { + // Send the message through mesh - using sendPrivateMessage for now + let recipientHexID = message.recipientNoisePublicKey.hexEncodedString() + if let recipientNickname = meshService.getPeerNicknames()[recipientHexID] { + meshService.sendPrivateMessage(message.content, to: recipientHexID, recipientNickname: recipientNickname, messageID: message.id) + } + + // Update status + pendingMessages[message.id]?.status = .sent + } + + private func sendViaNostr(_ message: RoutedMessage) async throws { + // Get recipient's Nostr public key + let favoriteStatus = favoritesService.getFavoriteStatus(for: message.recipientNoisePublicKey) + + // Looking up Nostr key for recipient + + if favoriteStatus != nil { + // Found favorite relationship + } else { + SecureLogger.log("❌ No favorite relationship found", + category: SecureLogger.session, level: .error) + } + + guard let favoriteStatus = favoriteStatus, + let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey else { + throw MessageRouterError.noNostrPublicKey + } + + // Get sender's Nostr identity + guard let senderIdentity = try NostrIdentityBridge.getCurrentNostrIdentity() else { + throw MessageRouterError.noNostrIdentity + } + + // Create NIP-17 encrypted message with structured content + let structuredContent = "MSG:\(message.id):\(message.content)" + let event = try NostrProtocol.createPrivateMessage( + content: structuredContent, + recipientPubkey: recipientNostrPubkey, + senderIdentity: senderIdentity + ) + + // Created gift wrap event + + // Send via relay + nostrRelay.sendEvent(event) + + // Update status + pendingMessages[message.id]?.status = .sent + } + + private func setupBindings() { + // Monitor Nostr messages + setupNostrMessageHandling() + + // Clean up old pending messages periodically + Timer.publish(every: 60, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.cleanupOldMessages() + } + .store(in: &cancellables) + + // Listen for app becoming active to check for messages + NotificationCenter.default.publisher(for: .appDidBecomeActive) + .sink { [weak self] _ in + self?.checkForNostrMessages() + } + .store(in: &cancellables) + } + + private func setupNostrMessageHandling() { + guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { + SecureLogger.log("⚠️ No Nostr identity available for initial setup", category: SecureLogger.session, level: .warning) + return + } + + SecureLogger.log("🚀 Setting up Nostr message handling for \(currentIdentity.npub)", + category: SecureLogger.session, level: .info) + + // Connect to relays if not already connected + if !nostrRelay.isConnected { + nostrRelay.connect() + + // Wait for connections to establish before subscribing + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.subscribeToNostrMessages() + } + } else { + // Already connected, subscribe immediately + subscribeToNostrMessages() + } + } + + /// Check for Nostr messages when app becomes active + func checkForNostrMessages() { + // Checking for Nostr messages + + guard (try? NostrIdentityBridge.getCurrentNostrIdentity()) != nil else { + SecureLogger.log("⚠️ No Nostr identity available for message check", category: SecureLogger.session, level: .warning) + return + } + + // Ensure we're connected to relays first + if !nostrRelay.isConnected { + // Connecting to Nostr relays + nostrRelay.connect() + + // Wait a bit for connections to establish before subscribing + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.subscribeToNostrMessages() + } + } else { + // Already connected, subscribe immediately + subscribeToNostrMessages() + } + } + + private func subscribeToNostrMessages() { + guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } + + // Subscribing to Nostr messages + // Full pubkey recorded + // Pubkey length verified + + // Unsubscribe existing subscription to refresh + nostrRelay.unsubscribe(id: "router-messages") + + // Create a new subscription for recent messages + let sinceDate = processedMessagesService.getSubscriptionSinceDate() + let filter = NostrFilter.giftWrapsFor( + pubkey: currentIdentity.publicKeyHex, + since: sinceDate + ) + + // Subscribing to messages since date + + // Subscribing to gift wraps + + nostrRelay.subscribe(filter: filter, id: "router-messages") { [weak self] event in + // Received Nostr event + self?.handleNostrMessage(event) + } + } + + private func handleNostrMessage(_ giftWrap: NostrEvent) { + // Check if we've already processed this event + if processedMessagesService.isMessageProcessed(giftWrap.id) { + // Skipping already processed event + return + } + + // Attempting to decrypt gift wrap + // Full event ID recorded + // Event timestamp recorded + // Event tags recorded + + // Decrypt the message + guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { + SecureLogger.log("❌ No current Nostr identity available", + category: SecureLogger.session, level: .error) + return + } + + // Check if this event is actually tagged for us + let ourPubkey = currentIdentity.publicKeyHex + let isTaggedForUs = giftWrap.tags.contains { tag in + tag.count >= 2 && tag[0] == "p" && tag[1] == ourPubkey + } + + if !isTaggedForUs { + SecureLogger.log("⚠️ Gift wrap not tagged for us! Our pubkey: \(ourPubkey.prefix(8))..., Event tags: \(giftWrap.tags)", + category: SecureLogger.session, level: .warning) + return + } + + do { + let (content, senderPubkey) = try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: currentIdentity + ) + + SecureLogger.log("✅ Successfully decrypted message from \(senderPubkey.prefix(8))...: \(content)", + category: SecureLogger.session, level: .info) + + // Mark this event as processed to avoid duplicates on app restart + let eventTimestamp = Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at)) + processedMessagesService.markMessageAsProcessed(giftWrap.id, timestamp: eventTimestamp) + + // Check for deduplication within current session + let messageHash = "\(senderPubkey)-\(content)-\(giftWrap.created_at)" + if messageDeduplication.get(messageHash) != nil { + return // Already processed in this session + } + messageDeduplication.set(messageHash, value: Date()) + + // Handle special messages + if content.hasPrefix("FAVORITED") || content.hasPrefix("UNFAVORITED") { + let parts = content.split(separator: ":") + let isFavorite = parts.first == "FAVORITED" + let nostrNpub = parts.count > 1 ? String(parts[1]) : nil + handleFavoriteNotification(from: senderPubkey, isFavorite: isFavorite, nostrNpub: nostrNpub) + return + } + + // Handle delivery acknowledgments + if content.hasPrefix("DELIVERED:") { + let parts = content.split(separator: ":") + if parts.count > 1 { + let messageId = String(parts[1]) + handleDeliveryAcknowledgment(messageId: messageId, from: senderPubkey) + } + return + } + + // Handle read receipts + if content.hasPrefix("READ:") { + let parts = content.split(separator: ":", maxSplits: 1) + if parts.count > 1 { + let receiptDataString = String(parts[1]) + if let receiptData = Data(base64Encoded: receiptDataString), + let receipt = ReadReceipt.fromBinaryData(receiptData) { + handleReadReceipt(receipt, from: senderPubkey) + } + } + return + } + + // Find the sender's Noise public key + guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return } + + // Parse structured message content + var messageId = UUID().uuidString + var messageContent = content + + if content.hasPrefix("MSG:") { + let parts = content.split(separator: ":", maxSplits: 2) + if parts.count >= 3 { + messageId = String(parts[1]) + messageContent = String(parts[2]) + } + } + + // Create a BitchatMessage and inject into the stream + let chatMessage = BitchatMessage( + id: messageId, + sender: favoritesService.getFavoriteStatus(for: senderNoiseKey)?.peerNickname ?? "Unknown", + content: messageContent, + timestamp: Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at)), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: nil, + senderPeerID: senderNoiseKey.hexEncodedString(), + mentions: nil, + deliveryStatus: .delivered(to: "nostr", at: Date()) + ) + + // Post notification for ChatViewModel to handle + NotificationCenter.default.post( + name: .nostrMessageReceived, + object: nil, + userInfo: ["message": chatMessage] + ) + + // Send delivery acknowledgment back to sender + sendDeliveryAcknowledgment(for: chatMessage.id, to: senderPubkey) + + } catch { + SecureLogger.log("❌ Failed to decrypt gift wrap: \(error)", + category: SecureLogger.session, level: .error) + } + } + + private func handleFavoriteNotification(from nostrPubkey: String, isFavorite: Bool, nostrNpub: String? = nil) { + // Find the sender's Noise public key + guard let senderNoiseKey = findNoisePublicKey(for: nostrPubkey) else { return } + + // Update favorites service - nostrPubkey is already the hex public key + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: senderNoiseKey, + favorited: isFavorite, + peerNostrPublicKey: nostrPubkey + ) + + // Post notification for UI update + NotificationCenter.default.post( + name: .favoriteStatusChanged, + object: nil, + userInfo: [ + "peerPublicKey": senderNoiseKey, + "isFavorite": isFavorite + ] + ) + } + + private func findNoisePublicKey(for nostrPubkey: String) -> Data? { + // Search through favorites for matching Nostr pubkey + for (noiseKey, relationship) in favoritesService.favorites { + if relationship.peerNostrPublicKey == nostrPubkey { + return noiseKey + } + } + return nil + } + + private func handleDeliveryAcknowledgment(messageId: String, from senderPubkey: String) { + SecureLogger.log("✅ Received delivery acknowledgment for message \(messageId) from \(senderPubkey)", + category: SecureLogger.session, level: .info) + + // Find the sender's Noise public key + guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return } + + // Post notification for ChatViewModel to update delivery status + NotificationCenter.default.post( + name: .messageDeliveryAcknowledged, + object: nil, + userInfo: [ + "messageId": messageId, + "senderNoiseKey": senderNoiseKey + ] + ) + } + + private func handleReadReceipt(_ receipt: ReadReceipt, from senderPubkey: String) { + SecureLogger.log("📖 Received read receipt for message \(receipt.originalMessageID) from \(senderPubkey)", + category: SecureLogger.session, level: .info) + + // Find the sender's Noise public key + guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return } + let senderHexID = senderNoiseKey.hexEncodedString() + + // Update the receipt with the correct sender ID + var updatedReceipt = receipt + updatedReceipt.readerID = senderHexID + + // Post notification for ChatViewModel to process + NotificationCenter.default.post( + name: .readReceiptReceived, + object: nil, + userInfo: ["receipt": updatedReceipt] + ) + } + + func sendReadReceipt( + for originalMessageID: String, + to recipientNoisePublicKey: Data, + preferredTransport: Transport? = nil + ) async throws { + SecureLogger.log("📖 Sending read receipt for message \(originalMessageID)", + category: SecureLogger.session, level: .info) + + // Get nickname from delegate or use default + let nickname = (meshService.delegate as? ChatViewModel)?.nickname ?? "Anonymous" + + // Create read receipt + let receipt = ReadReceipt( + originalMessageID: originalMessageID, + readerID: meshService.myPeerID, + readerNickname: nickname + ) + + // Encode receipt + let receiptData = receipt.toBinaryData() + let content = "READ:\(receiptData.base64EncodedString())" + + // Check if peer is connected via mesh (mesh takes precedence) + let recipientHexID = recipientNoisePublicKey.hexEncodedString() + + // First check if the peer is currently connected with the given ID + var actualRecipientHexID = recipientHexID + var actualRecipientNoiseKey = recipientNoisePublicKey + + // Always check if they reconnected with a new ID, even if preferredTransport is specified + if let favoriteStatus = favoritesService.getFavoriteStatus(for: recipientNoisePublicKey) { + let peerNickname = favoriteStatus.peerNickname + + // Search through all current peers to find one with the same nickname + for (currentPeerID, currentNickname) in meshService.getPeerNicknames() { + if currentNickname == peerNickname, + currentPeerID != recipientHexID, + let currentNoiseKey = Data(hexString: currentPeerID) { + SecureLogger.log("🔄 Found updated peer ID for \(peerNickname): \(recipientHexID) -> \(currentPeerID)", + category: SecureLogger.session, level: .info) + actualRecipientHexID = currentPeerID + actualRecipientNoiseKey = currentNoiseKey + break + } + } + + // If still not found in connected peers, check all favorites for the current key + if meshService.getPeerNicknames()[actualRecipientHexID] == nil { + // Search through all favorites to find the current noise key for this nickname + for (noiseKey, relationship) in favoritesService.favorites { + if relationship.peerNickname == peerNickname && relationship.peerNostrPublicKey != nil { + SecureLogger.log("🔄 Using current favorite key for \(peerNickname): \(recipientHexID) -> \(noiseKey.hexEncodedString())", + category: SecureLogger.session, level: .info) + actualRecipientHexID = noiseKey.hexEncodedString() + actualRecipientNoiseKey = noiseKey + break + } + } + } + } + + let isConnectedOnMesh = meshService.isPeerConnected(actualRecipientHexID) + + if isConnectedOnMesh && preferredTransport != .nostr { + // Send via mesh + SecureLogger.log("📡 Sending read receipt via mesh to \(actualRecipientHexID)", + category: SecureLogger.session, level: .debug) + meshService.sendReadReceipt(receipt, to: actualRecipientHexID) + } else { + // Send via Nostr + SecureLogger.log("🌐 Sending read receipt via Nostr to \(actualRecipientHexID)", + category: SecureLogger.session, level: .debug) + + // Get recipient's Nostr public key using the actual current noise key + let favoriteStatus = favoritesService.getFavoriteStatus(for: actualRecipientNoiseKey) + guard let recipientNostrPubkey = favoriteStatus?.peerNostrPublicKey else { + SecureLogger.log("❌ Cannot send read receipt - no Nostr key for recipient", + category: SecureLogger.session, level: .error) + throw MessageRouterError.noNostrKey + } + + guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { + SecureLogger.log("⚠️ No Nostr identity available for read receipt", + category: SecureLogger.session, level: .warning) + throw MessageRouterError.noIdentity + } + + // Create read receipt message + guard let event = try? NostrProtocol.createPrivateMessage( + content: content, + recipientPubkey: recipientNostrPubkey, + senderIdentity: senderIdentity + ) else { + SecureLogger.log("❌ Failed to create read receipt", + category: SecureLogger.session, level: .error) + throw MessageRouterError.encryptionFailed + } + + // Send via relay + nostrRelay.sendEvent(event) + } + } + + private func sendDeliveryAcknowledgment(for messageId: String, to recipientNostrPubkey: String) { + SecureLogger.log("📤 Sending delivery acknowledgment for message \(messageId)", + category: SecureLogger.session, level: .debug) + + guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { + SecureLogger.log("⚠️ No Nostr identity available for acknowledgment", + category: SecureLogger.session, level: .warning) + return + } + + // Create acknowledgment message + let content = "DELIVERED:\(messageId)" + guard let event = try? NostrProtocol.createPrivateMessage( + content: content, + recipientPubkey: recipientNostrPubkey, + senderIdentity: senderIdentity + ) else { + SecureLogger.log("❌ Failed to create delivery acknowledgment", + category: SecureLogger.session, level: .error) + return + } + + // Send via relay + nostrRelay.sendEvent(event) + } + + private func cleanupOldMessages() { + let cutoff = Date().addingTimeInterval(-300) // 5 minutes + pendingMessages = pendingMessages.filter { $0.value.timestamp > cutoff } + } +} + +// MARK: - Errors + +enum MessageRouterError: LocalizedError { + case peerNotReachable + case noNostrPublicKey + case noNostrIdentity + case transportFailed + case noNostrKey + case noIdentity + case encryptionFailed + + var errorDescription: String? { + switch self { + case .peerNotReachable: + return "Peer is not reachable via mesh or Nostr" + case .noNostrPublicKey: + return "Peer's Nostr public key is unknown" + case .noNostrIdentity: + return "No Nostr identity available" + case .transportFailed: + return "Failed to send message" + case .noNostrKey: + return "No Nostr key available for recipient" + case .noIdentity: + return "No identity available" + case .encryptionFailed: + return "Failed to encrypt message" + } + } +} + +// MARK: - Notification Names + +extension Notification.Name { + static let nostrMessageReceived = Notification.Name("NostrMessageReceived") + static let messageDeliveryAcknowledged = Notification.Name("MessageDeliveryAcknowledged") + static let readReceiptReceived = Notification.Name("ReadReceiptReceived") + static let appDidBecomeActive = Notification.Name("AppDidBecomeActive") +} + diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 6f842a63..09c9fe70 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -109,7 +109,7 @@ enum EncryptionStatus: Equatable { case .noiseSecured: return "lock.fill" // Changed from "lock" to "lock.fill" for filled lock case .noiseVerified: - return "lock.shield.fill" // Changed to filled version for consistency + return "checkmark.seal.fill" // Verified badge } } diff --git a/bitchat/Services/ProcessedMessagesService.swift b/bitchat/Services/ProcessedMessagesService.swift new file mode 100644 index 00000000..41db3de6 --- /dev/null +++ b/bitchat/Services/ProcessedMessagesService.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Service to track processed messages across app restarts to prevent duplicates +@MainActor +final class ProcessedMessagesService { + static let shared = ProcessedMessagesService() + + private let userDefaults = UserDefaults.standard + private let processedMessagesKey = "ProcessedNostrMessages" + private let lastProcessedTimestampKey = "LastProcessedNostrTimestamp" + private let maxStoredMessages = 1000 // Keep last 1000 message IDs + + private var processedMessageIDs: Set = [] + private var lastProcessedTimestamp: Date? + + private init() { + loadProcessedMessages() + } + + /// Check if a message has already been processed + func isMessageProcessed(_ messageID: String) -> Bool { + return processedMessageIDs.contains(messageID) + } + + /// Mark a message as processed + func markMessageAsProcessed(_ messageID: String, timestamp: Date) { + processedMessageIDs.insert(messageID) + + // Update last processed timestamp if this message is newer + if let lastTimestamp = lastProcessedTimestamp { + if timestamp > lastTimestamp { + lastProcessedTimestamp = timestamp + } + } else { + lastProcessedTimestamp = timestamp + } + + // Trim if we have too many stored IDs + if processedMessageIDs.count > maxStoredMessages { + trimOldestMessages() + } + + saveProcessedMessages() + } + + /// Get the timestamp to use for Nostr subscription filters + func getSubscriptionSinceDate() -> Date { + // If we have a last processed timestamp, use it minus a small buffer + if let lastTimestamp = lastProcessedTimestamp { + // Go back 1 hour before last processed message for safety + return lastTimestamp.addingTimeInterval(-3600) + } + + // Default: look back 24 hours on first run + return Date().addingTimeInterval(-86400) + } + + /// Clear all processed messages (useful for debugging) + func clearProcessedMessages() { + processedMessageIDs.removeAll() + lastProcessedTimestamp = nil + saveProcessedMessages() + } + + // MARK: - Private Methods + + private func loadProcessedMessages() { + if let data = userDefaults.data(forKey: processedMessagesKey), + let decoded = try? JSONDecoder().decode([String].self, from: data) { + processedMessageIDs = Set(decoded) + } + + if let timestampInterval = userDefaults.object(forKey: lastProcessedTimestampKey) as? TimeInterval { + lastProcessedTimestamp = Date(timeIntervalSince1970: timestampInterval) + } + + SecureLogger.log("📋 Loaded \(processedMessageIDs.count) processed message IDs, last timestamp: \(lastProcessedTimestamp?.description ?? "nil")", + category: SecureLogger.session, level: .info) + } + + private func saveProcessedMessages() { + // Convert Set to Array for encoding + let messageArray = Array(processedMessageIDs) + if let encoded = try? JSONEncoder().encode(messageArray) { + userDefaults.set(encoded, forKey: processedMessagesKey) + } + + if let timestamp = lastProcessedTimestamp { + userDefaults.set(timestamp.timeIntervalSince1970, forKey: lastProcessedTimestampKey) + } + + userDefaults.synchronize() + } + + private func trimOldestMessages() { + // Since we don't track insertion order, we'll just keep the most recent N messages + // In a production app, you might want to track timestamps for each message + let excess = processedMessageIDs.count - maxStoredMessages + if excess > 0 { + // Remove random excess messages (not ideal, but simple) + for _ in 0.. messages private var messageBatchTimer: Timer? private let messageBatchInterval: TimeInterval = 0.1 // 100ms batching window + + // UI update debouncing for performance + private var uiUpdateTimer: Timer? + private let uiUpdateInterval: TimeInterval = 0.05 // 50ms debounce window + private var pendingUIUpdate = false @Published var nickname: String = "" { didSet { // Trim whitespace whenever nickname is set @@ -135,13 +142,15 @@ class ChatViewModel: ObservableObject { // MARK: - Services and Storage var meshService = BluetoothMeshService() + private var nostrRelayManager: NostrRelayManager? + private var messageRouter: MessageRouter? + private var peerManager: PeerManager? private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" // MARK: - Caches // Caches for expensive computations - private var rssiColorCache: [String: Color] = [:] // key: "\(rssi)_\(isDark)" private var encryptionStatusCache: [String: EncryptionStatus] = [:] // key: peerID // MARK: - Social Features @@ -163,10 +172,14 @@ class ChatViewModel: ObservableObject { // Delivery tracking private var deliveryTrackerCancellable: AnyCancellable? + private var cancellables = Set() // Track sent read receipts to avoid duplicates private var sentReadReceipts: Set = [] // messageID set + // Track Nostr pubkey mappings for unknown senders + private var nostrKeyMapping: [String: String] = [:] // senderPeerID -> nostrPubkey + // MARK: - Initialization init() { @@ -191,6 +204,47 @@ class ChatViewModel: ObservableObject { // Set up message retry service MessageRetryService.shared.meshService = meshService + // Initialize Nostr services + Task { @MainActor in + nostrRelayManager = NostrRelayManager.shared + nostrRelayManager?.connect() + + messageRouter = MessageRouter( + meshService: meshService, + nostrRelay: nostrRelayManager! + ) + + // Initialize peer manager + peerManager = PeerManager(meshService: meshService) + peerManager?.updatePeers() + + // Bind peer manager's peer list to our published property + peerManager?.$peers + .receive(on: DispatchQueue.main) + .sink { [weak self] peers in + SecureLogger.log("📱 UI: Received \(peers.count) peers from PeerManager", + category: SecureLogger.session, level: .info) + for peer in peers { + SecureLogger.log(" - \(peer.displayName): connected=\(peer.isConnected), state=\(peer.connectionState)", + category: SecureLogger.session, level: .debug) + } + // Update peers directly + self?.allPeers = peers + // Update peer index for O(1) lookups + self?.peerIndex = Dictionary(uniqueKeysWithValues: peers.map { ($0.id, $0) }) + // Schedule UI update if peers changed + if peers.count > 0 || self?.allPeers.count ?? 0 > 0 { + self?.scheduleUIUpdate() + } + + // Update private chat peer ID if needed when peers change + if self?.selectedPrivateChatFingerprint != nil { + self?.updatePrivateChatPeerIfNeeded() + } + } + .store(in: &cancellables) + } + // Set up Noise encryption callbacks setupNoiseCallbacks() @@ -211,6 +265,45 @@ class ChatViewModel: ObservableObject { name: Notification.Name("bitchat.retryMessage"), object: nil ) + + // Listen for Nostr messages + NotificationCenter.default.addObserver( + self, + selector: #selector(handleNostrMessage), + name: .nostrMessageReceived, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleNostrReadReceipt), + name: .readReceiptReceived, + object: nil + ) + + // Listen for favorite status changes + NotificationCenter.default.addObserver( + self, + selector: #selector(handleFavoriteStatusChanged), + name: .favoriteStatusChanged, + object: nil + ) + + // Listen for peer status updates to refresh UI + NotificationCenter.default.addObserver( + self, + selector: #selector(handlePeerStatusUpdate), + name: Notification.Name("peerStatusUpdated"), + object: nil + ) + + // Listen for delivery acknowledgments + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDeliveryAcknowledgment), + name: .messageDeliveryAcknowledged, + object: nil + ) // When app becomes active, send read receipts for visible messages #if os(macOS) @@ -269,8 +362,9 @@ class ChatViewModel: ObservableObject { // MARK: - Deinitialization deinit { - // Clean up timer + // Clean up timers messageBatchTimer?.invalidate() + uiUpdateTimer?.invalidate() // Force immediate save userDefaults.synchronize() @@ -336,43 +430,94 @@ class ChatViewModel: ObservableObject { func toggleFavorite(peerID: String) { - // First try to get fingerprint from mesh service (supports peer ID rotation) - var fingerprint: String? = meshService.getFingerprint(for: peerID) - // Fallback to local mapping if not found in mesh service - if fingerprint == nil { - fingerprint = peerIDToPublicKeyFingerprint[peerID] - } - - guard let fp = fingerprint else { - return - } - - let isFavorite = SecureIdentityStateManager.shared.isFavorite(fingerprint: fp) - SecureIdentityStateManager.shared.setFavorite(fp, isFavorite: !isFavorite) - - // Update local set for UI - if isFavorite { - favoritePeers.remove(fp) - } else { - favoritePeers.insert(fp) + Task { @MainActor [weak self] in + guard let self = self else { return } + + // Try to find the favorite relationship first by Noise key, then by Nostr key + var noisePublicKey: Data? + var currentStatus: FavoritesPersistenceService.FavoriteRelationship? + + // First try as Noise key + if let noiseKey = Data(hexString: peerID) { + currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) + noisePublicKey = noiseKey + } + + // getFavoriteStatusByNostrKey not implemented + // If not found, try as Nostr key + // if currentStatus == nil { + // currentStatus = FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(peerID) + // noisePublicKey = currentStatus?.peerNoisePublicKey + // } + + // If still no noise key, we can't proceed + guard let finalNoiseKey = noisePublicKey else { + SecureLogger.log("❌ Could not find noise key for peer: \(peerID)", category: SecureLogger.session, level: .error) + return + } + + let isFavorite = currentStatus?.isFavorite ?? false + + SecureLogger.log("📊 Current favorite status for \(peerID): isFavorite=\(isFavorite), isMutual=\(currentStatus?.isMutual ?? false)", + category: SecureLogger.session, level: .info) + + if isFavorite { + // Remove from favorites + FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: finalNoiseKey) + + // Send unfavorite notification via mesh or Nostr + Task { + try? await self.messageRouter?.sendFavoriteNotification(to: finalNoiseKey, isFavorite: false) + } + } else { + // Add to favorites + let peers = self.allPeers + let nickname = self.meshService.getPeerNicknames()[peerID] ?? peers.first(where: { $0.id == peerID })?.displayName ?? "Unknown" + let nostrPublicKey = peers.first(where: { $0.id == peerID })?.nostrPublicKey + + // Ensure we have a Nostr identity created + if (try? NostrIdentityBridge.getCurrentNostrIdentity()) != nil { + } + + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: finalNoiseKey, + peerNostrPublicKey: nostrPublicKey ?? currentStatus?.peerNostrPublicKey, + peerNickname: nickname + ) + + // Send favorite notification via mesh or Nostr + Task { + try? await self.messageRouter?.sendFavoriteNotification(to: finalNoiseKey, isFavorite: true) + } + } + + // Update the peer list to reflect the change + self.peerManager?.updatePeers() + + // Check if we now have a mutual favorite relationship + if let updatedStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey), + updatedStatus.isMutual { + } } } + @MainActor func isFavorite(peerID: String) -> Bool { - // First try to get fingerprint from mesh service (supports peer ID rotation) - var fingerprint: String? = meshService.getFingerprint(for: peerID) - - // Fallback to local mapping if not found in mesh service - if fingerprint == nil { - fingerprint = peerIDToPublicKeyFingerprint[peerID] + // First try as Noise public key + if let noisePublicKey = Data(hexString: peerID) { + if let status = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) { + return status.isFavorite + } } - guard let fp = fingerprint else { - return false - } + // getFavoriteStatusByNostrKey not implemented + // If not found, try as Nostr public key + // if let status = FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(peerID) { + // return status.isFavorite + // } - return SecureIdentityStateManager.shared.isFavorite(fingerprint: fp) + return false } // MARK: - Public Key and Identity Management @@ -418,6 +563,16 @@ class ChatViewModel: ObservableObject { // Check if this peer is the one we're in a private chat with updatePrivateChatPeerIfNeeded() + + // If we're in a private chat with this peer (by fingerprint), send pending read receipts + if let chatFingerprint = selectedPrivateChatFingerprint, + chatFingerprint == fingerprintStr { + // Send read receipts for any unread messages from this peer + // Use a small delay to ensure the connection is fully established + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.markPrivateMessagesAsRead(from: peerID) + } + } } private func isPeerBlocked(_ peerID: String) -> Bool { @@ -454,12 +609,26 @@ class ChatViewModel: ObservableObject { if let currentPeerID = getCurrentPeerIDForFingerprint(chatFingerprint) { // Update the selected peer if it's different if let oldPeerID = selectedPrivateChatPeer, oldPeerID != currentPeerID { + SecureLogger.log("📱 Updating private chat peer from \(oldPeerID) to \(currentPeerID)", + category: SecureLogger.session, level: .debug) + // Migrate messages from old peer ID to new peer ID if let oldMessages = privateChats[oldPeerID] { if privateChats[currentPeerID] == nil { privateChats[currentPeerID] = [] } privateChats[currentPeerID]?.append(contentsOf: oldMessages) + // Sort by timestamp + privateChats[currentPeerID]?.sort { $0.timestamp < $1.timestamp } + // Remove duplicates + var seen = Set() + privateChats[currentPeerID] = privateChats[currentPeerID]?.filter { msg in + if seen.contains(msg.id) { + return false + } + seen.insert(msg.id) + return true + } trimPrivateChatMessagesIfNeeded(for: currentPeerID) privateChats.removeValue(forKey: oldPeerID) } @@ -471,9 +640,22 @@ class ChatViewModel: ObservableObject { } selectedPrivateChatPeer = currentPeerID + + // Schedule UI update for encryption status change + DispatchQueue.main.async { [weak self] in + self?.scheduleUIUpdate() + } + + // Also refresh the peer list to update encryption status + Task { @MainActor in + peerManager?.updatePeers() + } } else if selectedPrivateChatPeer == nil { // Just set the peer ID if we don't have one selectedPrivateChatPeer = currentPeerID + DispatchQueue.main.async { [weak self] in + self?.scheduleUIUpdate() + } } // Clear unread messages for the current peer ID @@ -487,12 +669,15 @@ class ChatViewModel: ObservableObject { /// - Parameter content: The message content to send /// - Note: Automatically handles command processing if content starts with '/' /// Routes to private chat if one is selected, otherwise broadcasts + @MainActor func sendMessage(_ content: String) { guard !content.isEmpty else { return } // Check for commands if content.hasPrefix("/") { - handleCommand(content) + Task { @MainActor in + handleCommand(content) + } return } @@ -539,10 +724,24 @@ class ChatViewModel: ObservableObject { /// - content: The message content to encrypt and send /// - peerID: The recipient's peer ID /// - Note: Automatically establishes Noise encryption if not already active + @MainActor func sendPrivateMessage(_ content: String, to peerID: String) { guard !content.isEmpty else { return } - guard let recipientNickname = meshService.getPeerNicknames()[peerID] else { - return + + // Check if peer is available on mesh (actually connected, not just known) + let meshPeers = meshService.getPeerNicknames() + let peerAvailableOnMesh = meshService.isPeerConnected(peerID) + let recipientNickname: String + + if let meshNickname = meshPeers[peerID] { + recipientNickname = meshNickname + } else { + // Try to get from favorites + guard let noiseKey = Data(hexString: peerID), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) else { + return + } + recipientNickname = favoriteStatus.peerNickname } // Check if the recipient is blocked @@ -588,8 +787,41 @@ class ChatViewModel: ObservableObject { // Immediate UI update for user's own messages objectWillChange.send() - // Send via mesh with the same message ID - meshService.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: message.id) + // Determine how to send the message + guard let noiseKey = Data(hexString: peerID) else { return } + + if peerAvailableOnMesh { + // Send via mesh with the same message ID + meshService.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: message.id) + } else if let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + favoriteStatus.isMutual { + // Mutual favorite offline - send via Nostr + SecureLogger.log("🌐 Sending private message to offline mutual favorite \(recipientNickname) via Nostr", + category: SecureLogger.session, level: .info) + + Task { + do { + try await messageRouter?.sendMessage(content, to: noiseKey, messageId: message.id) + } catch { + SecureLogger.log("Failed to send message via Nostr: \(error)", + category: SecureLogger.session, level: .error) + // DeliveryTracker will handle timeout automatically + } + } + } else { + // Not reachable - show error to user + SecureLogger.log("⚠️ Cannot send message to \(recipientNickname) - not available on mesh or Nostr", + category: SecureLogger.session, level: .warning) + + // Add system message to inform user + let systemMessage = BitchatMessage( + sender: "system", + content: "Cannot send message to \(recipientNickname) - peer is not reachable via mesh or Nostr.", + timestamp: Date(), + isRelay: false + ) + addMessageToBatch(systemMessage) + } } // MARK: - Private Chat Management @@ -597,8 +829,19 @@ class ChatViewModel: ObservableObject { /// Initiates a private chat session with a peer. /// - Parameter peerID: The peer's ID to start chatting with /// - Note: Switches the UI to private chat mode and loads message history + @MainActor func startPrivateChat(with peerID: String) { - let peerNickname = meshService.getPeerNicknames()[peerID] ?? "unknown" + // Safety check: Don't allow starting chat with ourselves + if peerID == meshService.myPeerID { + SecureLogger.log("⚠️ Attempted to start private chat with self, ignoring", + category: SecureLogger.session, level: .warning) + return + } + + // Try to get nickname from mesh first, then from peer list + let peerNickname = meshService.getPeerNicknames()[peerID] ?? + peerIndex[peerID]?.displayName ?? + "unknown" // Check if the peer is blocked if isPeerBlocked(peerID) { @@ -612,6 +855,20 @@ class ChatViewModel: ObservableObject { return } + // Check if this is a moon peer (we favorite them but they don't favorite us) AND they're offline + // Only require mutual favorites for offline Nostr messaging + if let peer = peerIndex[peerID], + peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected && !peer.isRelayConnected { + let systemMessage = BitchatMessage( + sender: "system", + content: "cannot start chat with \(peerNickname): mutual favorite required for offline messaging.", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + return + } + // Trigger handshake if we don't have a session yet let sessionState = meshService.getNoiseSessionState(for: peerID) switch sessionState { @@ -631,37 +888,56 @@ class ChatViewModel: ObservableObject { // This happens when peer IDs change between sessions if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true { - // Look for messages from this nickname under other peer IDs + // Get the fingerprint for this peer + let currentFingerprint = getFingerprint(for: peerID) + + // Look for messages under other peer IDs with the same fingerprint var migratedMessages: [BitchatMessage] = [] var oldPeerIDsToRemove: [String] = [] for (oldPeerID, messages) in privateChats { if oldPeerID != peerID { - // Check if any messages in this chat are from the peer's nickname - // Check if this chat contains messages with this peer - let messagesWithPeer = messages.filter { msg in - // Message is FROM the peer to us - (msg.sender == peerNickname && msg.sender != nickname) || - // OR message is FROM us TO the peer - (msg.sender == nickname && (msg.recipientNickname == peerNickname || - // Also check if this was a private message in a chat that only has us and one other person - (msg.isPrivate && messages.allSatisfy { m in - m.sender == nickname || m.sender == peerNickname - }))) - } + // Check if this old peer ID has the same fingerprint (same actual peer) + let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID] - if !messagesWithPeer.isEmpty { + if let currentFp = currentFingerprint, + let oldFp = oldFingerprint, + currentFp == oldFp { + // Same peer with different ID - migrate all messages + migratedMessages.append(contentsOf: messages) + oldPeerIDsToRemove.append(oldPeerID) - // Check if ALL messages in this chat are between us and this peer - let allMessagesAreWithPeer = messages.allSatisfy { msg in - (msg.sender == peerNickname || msg.sender == nickname) && - (msg.recipientNickname == nil || msg.recipientNickname == peerNickname || msg.recipientNickname == nickname) + SecureLogger.log("📦 Migrating \(messages.count) messages from old peer ID \(oldPeerID) to \(peerID) based on fingerprint match", + category: SecureLogger.session, level: .info) + } else if currentFingerprint == nil || oldFingerprint == nil { + // Fallback: use nickname matching only if we don't have fingerprints + // This is less reliable but handles legacy data + let messagesWithPeer = messages.filter { msg in + // Message is FROM the peer to us + (msg.sender == peerNickname && msg.sender != nickname) || + // OR message is FROM us TO the peer + (msg.sender == nickname && (msg.recipientNickname == peerNickname || + // Also check if this was a private message in a chat that only has us and one other person + (msg.isPrivate && messages.allSatisfy { m in + m.sender == nickname || m.sender == peerNickname + }))) } - if allMessagesAreWithPeer { - // This entire chat history belongs to this peer, migrate it all - migratedMessages.append(contentsOf: messages) - oldPeerIDsToRemove.append(oldPeerID) + if !messagesWithPeer.isEmpty { + // Check if ALL messages in this chat are between us and this peer + let allMessagesAreWithPeer = messages.allSatisfy { msg in + (msg.sender == peerNickname || msg.sender == nickname) && + (msg.recipientNickname == nil || msg.recipientNickname == peerNickname || msg.recipientNickname == nickname) + } + + if allMessagesAreWithPeer { + // This entire chat history likely belongs to this peer, migrate it all + migratedMessages.append(contentsOf: messages) + oldPeerIDsToRemove.append(oldPeerID) + + SecureLogger.log("📦 Migrating \(messages.count) messages from old peer ID \(oldPeerID) to \(peerID) based on nickname match (no fingerprints available)", + category: SecureLogger.session, level: .warning) + } } } } @@ -725,8 +1001,172 @@ class ChatViewModel: ObservableObject { } } + // MARK: - Nostr Message Handling + + @objc private func handleNostrMessage(_ notification: Notification) { + guard let message = notification.userInfo?["message"] as? BitchatMessage else { return } + + // Store the Nostr pubkey if provided (for messages from unknown senders) + if let nostrPubkey = notification.userInfo?["nostrPubkey"] as? String, + let senderPeerID = message.senderPeerID { + // Store mapping for read receipts + nostrKeyMapping[senderPeerID] = nostrPubkey + } + + // Process the Nostr message through the same flow as Bluetooth messages + didReceiveMessage(message) + } + + @objc private func handleDeliveryAcknowledgment(_ notification: Notification) { + guard let messageId = notification.userInfo?["messageId"] as? String, + let senderNoiseKey = notification.userInfo?["senderNoiseKey"] as? Data else { return } + + let senderHexId = senderNoiseKey.hexEncodedString() + + SecureLogger.log("✅ Handling delivery acknowledgment for message \(messageId) from \(senderHexId)", + category: SecureLogger.session, level: .info) + + // Update the delivery status for the message + if let index = messages.firstIndex(where: { $0.id == messageId }) { + // Update delivery status to delivered + messages[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date()) + + // Schedule UI update for delivery status + scheduleUIUpdate() + } + + // Also update in private chats if it's a private message + for (peerID, chatMessages) in privateChats { + if let index = chatMessages.firstIndex(where: { $0.id == messageId }) { + privateChats[peerID]?[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date()) + scheduleUIUpdate() + break + } + } + } + + @objc private func handleNostrReadReceipt(_ notification: Notification) { + guard let receipt = notification.userInfo?["receipt"] as? ReadReceipt else { return } + + SecureLogger.log("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr", + category: SecureLogger.session, level: .info) + + // Process the read receipt through the same flow as Bluetooth read receipts + didReceiveReadReceipt(receipt) + } + + @objc private func handlePeerStatusUpdate(_ notification: Notification) { + // Update private chat peer if needed when peer status changes + updatePrivateChatPeerIfNeeded() + } + + @objc private func handleFavoriteStatusChanged(_ notification: Notification) { + guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return } + + Task { @MainActor in + // Handle noise key updates + if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool, + isKeyUpdate, + let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data { + let oldPeerID = oldKey.hexEncodedString() + let newPeerID = peerPublicKey.hexEncodedString() + + // If we have a private chat open with the old peer ID, update it to the new one + if selectedPrivateChatPeer == oldPeerID { + SecureLogger.log("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", + category: SecureLogger.session, level: .info) + + // Transfer private chat messages to new peer ID + if let messages = privateChats[oldPeerID] { + privateChats[newPeerID] = messages + privateChats.removeValue(forKey: oldPeerID) + } + + // Transfer unread status + if unreadPrivateMessages.contains(oldPeerID) { + unreadPrivateMessages.remove(oldPeerID) + unreadPrivateMessages.insert(newPeerID) + } + + // Update selected peer + selectedPrivateChatPeer = newPeerID + + // Update fingerprint tracking if needed + if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] { + peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID) + peerIDToPublicKeyFingerprint[newPeerID] = fingerprint + selectedPrivateChatFingerprint = fingerprint + } + + // Schedule UI refresh + scheduleUIUpdate() + } else { + // Even if the chat isn't open, migrate any existing private chat data + if let messages = privateChats[oldPeerID] { + SecureLogger.log("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", + category: SecureLogger.session, level: .debug) + privateChats[newPeerID] = messages + privateChats.removeValue(forKey: oldPeerID) + } + + // Transfer unread status + if unreadPrivateMessages.contains(oldPeerID) { + unreadPrivateMessages.remove(oldPeerID) + unreadPrivateMessages.insert(newPeerID) + } + + // Update fingerprint mapping + if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] { + peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID) + peerIDToPublicKeyFingerprint[newPeerID] = fingerprint + } + } + } + + // First check if this is a peer ID update for our current chat + updatePrivateChatPeerIfNeeded() + + // Then handle favorite/unfavorite messages if applicable + if let isFavorite = notification.userInfo?["isFavorite"] as? Bool { + let peerID = peerPublicKey.hexEncodedString() + let action = isFavorite ? "favorited" : "unfavorited" + + // Find peer nickname + let peerNickname: String + if let nickname = meshService.getPeerNicknames()[peerID] { + peerNickname = nickname + } else if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) { + peerNickname = favorite.peerNickname + } else { + peerNickname = "Unknown" + } + + // Create system message + let systemMessage = BitchatMessage( + id: UUID().uuidString, + sender: "System", + content: "\(peerNickname) \(action) you", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: nil, + mentions: nil + ) + + // Add to message stream + addMessageToBatch(systemMessage) + + // Update peer manager to refresh UI + peerManager?.updatePeers() + } + } + } + // MARK: - App Lifecycle + @MainActor @objc private func appDidBecomeActive() { // When app becomes active, send read receipts for visible private chat if let peerID = selectedPrivateChatPeer { @@ -796,6 +1236,9 @@ class ChatViewModel: ObservableObject { // Flush any pending messages when app goes to background flushMessageBatchImmediately() + // Flush any pending UI updates + flushUIUpdateImmediately() + userDefaults.synchronize() } @@ -819,9 +1262,72 @@ class ChatViewModel: ObservableObject { // Flush any pending messages immediately flushMessageBatchImmediately() + // Flush any pending UI updates + flushUIUpdateImmediately() + userDefaults.synchronize() } + @MainActor + private func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String, originalTransport: String? = nil) { + // First, try to resolve the current peer ID in case they reconnected with a new ID + var actualPeerID = peerID + + // Check if this peer ID exists in current nicknames + if meshService.getPeerNicknames()[peerID] == nil { + // Peer not found with this ID, try to find by fingerprint or nickname + if let oldNoiseKey = Data(hexString: peerID), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: oldNoiseKey) { + let peerNickname = favoriteStatus.peerNickname + + // Search for the current peer ID with the same nickname + for (currentPeerID, currentNickname) in meshService.getPeerNicknames() { + if currentNickname == peerNickname { + SecureLogger.log("📖 Resolved updated peer ID for read receipt: \(peerID) -> \(currentPeerID)", + category: SecureLogger.session, level: .info) + actualPeerID = currentPeerID + break + } + } + } + } + + // If we know the original transport, use it for the read receipt + if originalTransport == "nostr" { + // Message was received via Nostr, send read receipt via Nostr + if let noiseKey = Data(hexString: actualPeerID) { + Task { @MainActor in + try? await messageRouter?.sendReadReceipt(for: receipt.originalMessageID, to: noiseKey, preferredTransport: .nostr) + } + } else if let nostrPubkey = nostrKeyMapping[actualPeerID] { + // This is a Nostr-only peer we haven't mapped to a Noise key yet + Task { @MainActor in + if let tempNoiseKey = Data(hexString: nostrPubkey) { + try? await messageRouter?.sendReadReceipt(for: receipt.originalMessageID, to: tempNoiseKey, preferredTransport: .nostr) + } + } + } + } else if meshService.getPeerNicknames()[actualPeerID] != nil { + // Use mesh for connected peers (default behavior) + meshService.sendReadReceipt(receipt, to: actualPeerID) + } else { + // Try Nostr for offline peers + if let noiseKey = Data(hexString: actualPeerID) { + Task { @MainActor in + try? await messageRouter?.sendReadReceipt(for: receipt.originalMessageID, to: noiseKey) + } + } else if let nostrPubkey = nostrKeyMapping[actualPeerID] { + // This is a Nostr-only peer we haven't mapped to a Noise key yet + Task { @MainActor in + if let tempNoiseKey = Data(hexString: nostrPubkey) { + try? await messageRouter?.sendReadReceipt(for: receipt.originalMessageID, to: tempNoiseKey) + } + } + } + } + } + + @MainActor func markPrivateMessagesAsRead(from peerID: String) { // Get the nickname for this peer let peerNickname = meshService.getPeerNicknames()[peerID] ?? "" @@ -852,7 +1358,33 @@ class ChatViewModel: ObservableObject { // Check multiple conditions to ensure we catch all messages from the peer let isOurMessage = message.sender == nickname let isFromPeerByNickname = !peerNickname.isEmpty && message.sender == peerNickname - let isFromPeerByID = message.senderPeerID == peerID + + // Check if message is from this peer by comparing IDs + // Handle both Noise and Nostr keys + var isFromPeerByID = false + if let msgSenderID = message.senderPeerID { + // Direct match + isFromPeerByID = msgSenderID == peerID + + // If no direct match, check if we're comparing Nostr keys + if !isFromPeerByID { + // Check if the peerID has a favorite entry with a Nostr key that matches + if let noiseKey = Data(hexString: peerID), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + let nostrKey = favoriteStatus.peerNostrPublicKey { + isFromPeerByID = msgSenderID == nostrKey + } + + // getFavoriteStatusByNostrKey not implemented + // Or check if the message sender ID maps to this peer's Noise key + // if !isFromPeerByID, + // let senderFavorite = FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(msgSenderID), + // senderFavorite.peerNoisePublicKey.hexEncodedString() == peerID { + // isFromPeerByID = true + // } + } + } + let isPrivateToUs = message.isPrivate && message.recipientNickname == nickname // This is a message FROM the peer if it's not from us AND (matches nickname OR peer ID OR is private to us) @@ -868,13 +1400,29 @@ class ChatViewModel: ObservableObject { // Create and send read receipt for sent or delivered messages // Check if we've already sent a receipt for this message if !sentReadReceipts.contains(message.id) { - // Send to the CURRENT peer ID, not the old senderPeerID which may have changed let receipt = ReadReceipt( originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname ) - meshService.sendReadReceipt(receipt, to: peerID) + + // Send read receipt to the message sender + // Use the message's senderPeerID if available (for Nostr messages) + // Otherwise use the current peerID + let recipientID = message.senderPeerID ?? peerID + + // Check if message was delivered via Nostr + var originalTransport: String? = nil + if let status = message.deliveryStatus { + switch status { + case .delivered(let transport, _): + originalTransport = transport + default: + break + } + } + + sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport) sentReadReceipts.insert(message.id) readReceiptsSent += 1 } else { @@ -895,7 +1443,25 @@ class ChatViewModel: ObservableObject { readerID: meshService.myPeerID, readerNickname: nickname ) - meshService.sendReadReceipt(receipt, to: peerID) + // Use the message's senderPeerID if available (for Nostr messages) + let recipientID = message.senderPeerID ?? peerID + + // For backwards compatibility, check if this might be a Nostr message + // by checking if the sender has a Nostr key + let receiptToSend = receipt + let recipientToSend = recipientID + Task { @MainActor in + var originalTransport: String? = nil + if let noiseKey = Data(hexString: recipientToSend), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + favoriteStatus.peerNostrPublicKey != nil, + self.meshService.getPeerNicknames()[recipientToSend] == nil { + // Peer has Nostr key and is not on mesh, likely received via Nostr + originalTransport = "nostr" + } + + self.sendReadReceipt(receiptToSend, to: recipientToSend, originalTransport: originalTransport) + } sentReadReceipts.insert(message.id) readReceiptsSent += 1 } else { @@ -923,6 +1489,7 @@ class ChatViewModel: ObservableObject { // MARK: - Emergency Functions // PANIC: Emergency data clearing for activist safety + @MainActor func panicClearAllData() { // Flush any pending messages immediately before clearing flushMessageBatchImmediately() @@ -958,6 +1525,9 @@ class ChatViewModel: ObservableObject { favoritePeers.removeAll() peerIDToPublicKeyFingerprint.removeAll() + // Clear persistent favorites from keychain + FavoritesPersistenceService.shared.clearAllFavorites() + // Clear identity data from secure storage SecureIdentityStateManager.shared.clearAllIdentityData() @@ -976,7 +1546,6 @@ class ChatViewModel: ObservableObject { // Clear all caches invalidateEncryptionCache() - invalidateRSSIColorCache() // Disconnect from all peers and clear persistent identity // This will force creation of a new identity (new fingerprint) on next launch @@ -985,8 +1554,8 @@ class ChatViewModel: ObservableObject { // Force immediate UserDefaults synchronization userDefaults.synchronize() - // Force UI update - objectWillChange.send() + // Force immediate UI update for panic mode + flushUIUpdateImmediately() } @@ -1000,40 +1569,6 @@ class ChatViewModel: ObservableObject { return formatter.string(from: date) } - func getRSSIColor(rssi: Int, colorScheme: ColorScheme) -> Color { - let isDark = colorScheme == .dark - let cacheKey = "\(rssi)_\(isDark)" - - // Check cache first - if let cachedColor = rssiColorCache[cacheKey] { - return cachedColor - } - - // RSSI typically ranges from -30 (excellent) to -90 (poor) - // We'll map this to colors from green (strong) to red (weak) - - let color: Color - if rssi >= -50 { - // Excellent signal: bright green - color = isDark ? Color(red: 0.0, green: 1.0, blue: 0.0) : Color(red: 0.0, green: 0.7, blue: 0.0) - } else if rssi >= -60 { - // Good signal: green-yellow - color = isDark ? Color(red: 0.5, green: 1.0, blue: 0.0) : Color(red: 0.3, green: 0.7, blue: 0.0) - } else if rssi >= -70 { - // Fair signal: yellow - color = isDark ? Color(red: 1.0, green: 1.0, blue: 0.0) : Color(red: 0.7, green: 0.7, blue: 0.0) - } else if rssi >= -80 { - // Weak signal: orange - color = isDark ? Color(red: 1.0, green: 0.6, blue: 0.0) : Color(red: 0.8, green: 0.4, blue: 0.0) - } else { - // Poor signal: red - color = isDark ? Color(red: 1.0, green: 0.2, blue: 0.2) : Color(red: 0.8, green: 0.0, blue: 0.0) - } - - // Cache the result - rssiColorCache[cacheKey] = color - return color - } // MARK: - Autocomplete @@ -1135,7 +1670,6 @@ class ChatViewModel: ObservableObject { let isDark = colorScheme == .dark let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - // Always use the same color for all senders - no RSSI-based coloring return primaryColor } @@ -1451,9 +1985,9 @@ class ChatViewModel: ObservableObject { // Invalidate cache when encryption status changes invalidateEncryptionCache(for: peerID) - // Force UI update + // Schedule UI update DispatchQueue.main.async { [weak self] in - self?.objectWillChange.send() + self?.scheduleUIUpdate() } } @@ -1466,6 +2000,9 @@ class ChatViewModel: ObservableObject { // This must be a pure function - no state mutations allowed // to avoid SwiftUI update loops + // Check if we've ever established a session by looking for a fingerprint + let hasEverEstablishedSession = getFingerprint(for: peerID) != nil + let sessionState = meshService.getNoiseSessionState(for: peerID) let storedStatus = peerEncryptionStatus[peerID] @@ -1486,14 +2023,47 @@ class ChatViewModel: ObservableObject { status = .noiseSecured } case .handshaking, .handshakeQueued: - // Currently establishing encryption - status = .noiseHandshaking + // If we've ever established a session, show secured instead of handshaking + if hasEverEstablishedSession { + // Check if it was verified before + if let fingerprint = getFingerprint(for: peerID), + verifiedFingerprints.contains(fingerprint) { + status = .noiseVerified + } else { + status = .noiseSecured + } + } else { + // First time establishing - show handshaking + status = .noiseHandshaking + } case .none: - // No handshake attempted - status = .noHandshake + // If we've ever established a session, show secured instead of no handshake + if hasEverEstablishedSession { + // Check if it was verified before + if let fingerprint = getFingerprint(for: peerID), + verifiedFingerprints.contains(fingerprint) { + status = .noiseVerified + } else { + status = .noiseSecured + } + } else { + // Never established - show no handshake + status = .noHandshake + } case .failed: - // Handshake failed - show broken lock - status = .none + // If we've ever established a session, show secured instead of failed + if hasEverEstablishedSession { + // Check if it was verified before + if let fingerprint = getFingerprint(for: peerID), + verifiedFingerprints.contains(fingerprint) { + status = .noiseVerified + } else { + status = .noiseSecured + } + } else { + // Never established - show failed + status = .none + } } // Cache the result @@ -1501,7 +2071,7 @@ class ChatViewModel: ObservableObject { // Only log occasionally to avoid spam if Int.random(in: 0..<100) == 0 { - SecureLogger.log("getEncryptionStatus for \(peerID): sessionState=\(sessionState), stored=\(String(describing: storedStatus)), final=\(status)", category: SecureLogger.security, level: .debug) + SecureLogger.log("getEncryptionStatus for \(peerID): sessionState=\(sessionState), stored=\(String(describing: storedStatus)), hasEverEstablished=\(hasEverEstablishedSession), final=\(status)", category: SecureLogger.security, level: .debug) } return status @@ -1516,9 +2086,6 @@ class ChatViewModel: ObservableObject { } } - private func invalidateRSSIColorCache() { - rssiColorCache.removeAll() - } // MARK: - Message Batching @@ -1599,8 +2166,8 @@ class ChatViewModel: ObservableObject { } } - // Single UI update for all changes - self.objectWillChange.send() + // Single scheduled UI update for all changes + self.scheduleUIUpdate() } } @@ -1610,6 +2177,40 @@ class ChatViewModel: ObservableObject { flushMessageBatch() } + // MARK: - UI Update Debouncing + + // Schedule a debounced UI update + private func scheduleUIUpdate() { + guard !pendingUIUpdate else { return } // Already scheduled + + pendingUIUpdate = true + + // Cancel existing timer + uiUpdateTimer?.invalidate() + + // Schedule new update + uiUpdateTimer = Timer.scheduledTimer(withTimeInterval: uiUpdateInterval, repeats: false) { [weak self] _ in + self?.flushUIUpdate() + } + } + + // Execute the pending UI update + private func flushUIUpdate() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + self.pendingUIUpdate = false + self.objectWillChange.send() + } + } + + // Force immediate UI update for critical user actions + private func flushUIUpdateImmediately() { + uiUpdateTimer?.invalidate() + pendingUIUpdate = false + objectWillChange.send() + } + // Update encryption status in appropriate places, not during view updates private func updateEncryptionStatus(for peerID: String) { let noiseService = meshService.getNoiseService() @@ -1634,9 +2235,9 @@ class ChatViewModel: ObservableObject { // Invalidate cache when encryption status changes invalidateEncryptionCache(for: peerID) - // Trigger UI update + // Schedule UI update DispatchQueue.main.async { [weak self] in - self?.objectWillChange.send() + self?.scheduleUIUpdate() } } @@ -1646,6 +2247,12 @@ class ChatViewModel: ObservableObject { showingFingerprintFor = peerID } + // MARK: - Peer Lookup Helpers + + func getPeer(byID peerID: String) -> BitchatPeer? { + return peerIndex[peerID] + } + func getFingerprint(for peerID: String) -> String? { // Remove debug logging to prevent console spam during view updates @@ -1757,8 +2364,8 @@ class ChatViewModel: ObservableObject { // Invalidate cache when encryption status changes self.invalidateEncryptionCache(for: peerID) - // Force UI update - self.objectWillChange.send() + // Schedule UI update + self.scheduleUIUpdate() } } @@ -1771,22 +2378,20 @@ class ChatViewModel: ObservableObject { // Invalidate cache when encryption status changes self.invalidateEncryptionCache(for: peerID) - // Force UI update - self.objectWillChange.send() + // Schedule UI update + self.scheduleUIUpdate() } } } -} - -// MARK: - BitchatDelegate - -extension ChatViewModel: BitchatDelegate { + + // MARK: - BitchatDelegate Methods // MARK: - Command Handling /// Processes IRC-style commands starting with '/'. /// - Parameter command: The full command string including the leading slash /// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help + @MainActor private func handleCommand(_ command: String) { let parts = command.split(separator: " ") guard let cmd = parts.first else { return } @@ -2129,6 +2734,147 @@ extension ChatViewModel: BitchatDelegate { messages.append(systemMessage) } + case "/fav": + if parts.count > 1 { + let targetName = String(parts[1]) + // Remove @ if present + let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName + + // Find peer ID for this nickname + if let peerID = getPeerIDForNickname(nickname) { + // Add to favorites using the Nostr integration + if let noisePublicKey = Data(hexString: peerID) { + // Get or set Nostr public key + let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: noisePublicKey, + peerNostrPublicKey: existingFavorite?.peerNostrPublicKey, + peerNickname: nickname + ) + + // Toggle favorite in identity manager for UI + toggleFavorite(peerID: peerID) + + // Send favorite notification + Task { [weak self] in + try? await self?.messageRouter?.sendFavoriteNotification(to: noisePublicKey, isFavorite: true) + } + + let systemMessage = BitchatMessage( + sender: "system", + content: "added \(nickname) to favorites.", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + } + } else { + let systemMessage = BitchatMessage( + sender: "system", + content: "can't find peer: \(nickname)", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + } + } else { + let systemMessage = BitchatMessage( + sender: "system", + content: "usage: /fav ", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + } + + case "/unfav": + if parts.count > 1 { + let targetName = String(parts[1]) + // Remove @ if present + let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName + + // Find peer ID for this nickname + if let peerID = getPeerIDForNickname(nickname) { + // Remove from favorites + if let noisePublicKey = Data(hexString: peerID) { + FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) + + // Toggle favorite in identity manager for UI + toggleFavorite(peerID: peerID) + + // Send unfavorite notification + Task { [weak self] in + try? await self?.messageRouter?.sendFavoriteNotification(to: noisePublicKey, isFavorite: false) + } + + let systemMessage = BitchatMessage( + sender: "system", + content: "removed \(nickname) from favorites.", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + } + } else { + let systemMessage = BitchatMessage( + sender: "system", + content: "can't find peer: \(nickname)", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + } + } else { + let systemMessage = BitchatMessage( + sender: "system", + content: "usage: /unfav ", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + } + + case "/testnostr": + let systemMessage = BitchatMessage( + sender: "system", + content: "testing nostr relay connectivity...", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + + Task { @MainActor in + if let relayManager = self.nostrRelayManager { + // Simple connectivity test + relayManager.connect() + + // Wait a moment for connections + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + + let statusMessage = if relayManager.isConnected { + "nostr relays connected successfully!" + } else { + "failed to connect to nostr relays - check console for details" + } + + let completeMessage = BitchatMessage( + sender: "system", + content: statusMessage, + timestamp: Date(), + isRelay: false + ) + self.messages.append(completeMessage) + } else { + let errorMessage = BitchatMessage( + sender: "system", + content: "nostr relay manager not initialized", + timestamp: Date(), + isRelay: false + ) + self.messages.append(errorMessage) + } + } + default: // Unknown command let systemMessage = BitchatMessage( @@ -2208,22 +2954,41 @@ extension ChatViewModel: BitchatDelegate { // First check if we need to migrate existing messages from this sender let senderNickname = message.sender + let currentFingerprint = getFingerprint(for: peerID) + if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true { - // Check if we have messages from this nickname under a different peer ID + // Check if we have messages under a different peer ID with same fingerprint var migratedMessages: [BitchatMessage] = [] var oldPeerIDsToRemove: [String] = [] for (oldPeerID, messages) in privateChats { if oldPeerID != peerID { - // Check if this chat contains messages with this sender - let isRelevantChat = messages.contains { msg in - (msg.sender == senderNickname && msg.sender != nickname) || - (msg.sender == nickname && msg.recipientNickname == senderNickname) - } + // Check fingerprint match first (most reliable) + let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID] - if isRelevantChat { + if let currentFp = currentFingerprint, + let oldFp = oldFingerprint, + currentFp == oldFp { + // Same peer with different ID - migrate all messages migratedMessages.append(contentsOf: messages) oldPeerIDsToRemove.append(oldPeerID) + + SecureLogger.log("📦 Migrating \(messages.count) messages from old peer ID \(oldPeerID) to \(peerID) for incoming message (fingerprint match)", + category: SecureLogger.session, level: .info) + } else if currentFingerprint == nil || oldFingerprint == nil { + // Fallback: Check if this chat contains messages with this sender by nickname + let isRelevantChat = messages.contains { msg in + (msg.sender == senderNickname && msg.sender != nickname) || + (msg.sender == nickname && msg.recipientNickname == senderNickname) + } + + if isRelevantChat { + migratedMessages.append(contentsOf: messages) + oldPeerIDsToRemove.append(oldPeerID) + + SecureLogger.log("📦 Migrating \(messages.count) messages from old peer ID \(oldPeerID) to \(peerID) for incoming message (nickname match)", + category: SecureLogger.session, level: .warning) + } } } } @@ -2287,6 +3052,47 @@ extension ChatViewModel: BitchatDelegate { chatFingerprint == senderFingerprint && selectedPrivateChatPeer != peerID { // Update our private chat peer to the new ID selectedPrivateChatPeer = peerID + // Schedule UI update when peer ID changes + scheduleUIUpdate() + } + + // Also check if we need to update the private chat peer for any reason + updatePrivateChatPeerIfNeeded() + + // If we're currently viewing this chat, force immediate UI update + if selectedPrivateChatPeer == peerID { + // Immediately flush the batch for the current chat to ensure UI updates + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if let pendingMessages = self.pendingPrivateMessages[peerID], !pendingMessages.isEmpty { + // Process this peer's messages immediately + self.pendingPrivateMessages.removeValue(forKey: peerID) + + if self.privateChats[peerID] == nil { + self.privateChats[peerID] = [] + } + + // Add messages and sort + self.privateChats[peerID]?.append(contentsOf: pendingMessages) + self.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } + + // Remove duplicates + var seen = Set() + self.privateChats[peerID] = self.privateChats[peerID]?.filter { msg in + if seen.contains(msg.id) { + return false + } + seen.insert(msg.id) + return true + } + + // Trim if needed + self.trimPrivateChatMessagesIfNeeded(for: peerID) + + // Schedule UI update + self.scheduleUIUpdate() + } + } } // Mark as unread if not currently viewing this chat @@ -2298,14 +3104,31 @@ extension ChatViewModel: BitchatDelegate { unreadPrivateMessages.remove(peerID) // Send read receipt immediately since we're viewing the chat - // Send to the current peer ID since peer IDs change between sessions if !sentReadReceipts.contains(message.id) { let receipt = ReadReceipt( originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname ) - meshService.sendReadReceipt(receipt, to: peerID) + // Use the message's senderPeerID if available (for Nostr messages) + let recipientID = message.senderPeerID ?? peerID + + // For backwards compatibility, check if this might be a Nostr message + // by checking if the sender has a Nostr key + let receiptToSend = receipt + let recipientToSend = recipientID + Task { @MainActor in + var originalTransport: String? = nil + if let noiseKey = Data(hexString: recipientToSend), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + favoriteStatus.peerNostrPublicKey != nil, + self.meshService.getPeerNicknames()[recipientToSend] == nil { + // Peer has Nostr key and is not on mesh, likely received via Nostr + originalTransport = "nostr" + } + + self.sendReadReceipt(receiptToSend, to: recipientToSend, originalTransport: originalTransport) + } sentReadReceipts.insert(message.id) } @@ -2545,34 +3368,29 @@ extension ChatViewModel: BitchatDelegate { self.connectedPeers = peers self.isConnected = !peers.isEmpty + self.peerManager?.updatePeers() + // Register ephemeral sessions for all connected peers for peerID in peers { SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID) } + // Schedule UI refresh to ensure offline favorites are shown + self.scheduleUIUpdate() + // Update encryption status for all peers self.updateEncryptionStatusForPeers() - - // Invalidate RSSI cache since peer data may have changed - self.invalidateRSSIColorCache() - // Explicitly notify SwiftUI that the object has changed. - self.objectWillChange.send() + // Schedule UI update for peer list change + self.scheduleUIUpdate() // Check if we need to update private chat peer after reconnection if self.selectedPrivateChatFingerprint != nil { self.updatePrivateChatPeerIfNeeded() } - // Only end private chat if we can't find the peer by fingerprint - if let currentChatPeer = self.selectedPrivateChatPeer, - !peers.contains(currentChatPeer), - self.selectedPrivateChatFingerprint != nil { - // Try one more time to find by fingerprint - if self.getCurrentPeerIDForFingerprint(self.selectedPrivateChatFingerprint!) == nil { - self.endPrivateChat() - } - } + // Don't end private chat when peer temporarily disconnects + // The fingerprint tracking will allow us to reconnect when they come back } } @@ -2600,6 +3418,36 @@ extension ChatViewModel: BitchatDelegate { return Array(Set(mentions)) // Remove duplicates } + @MainActor + func handlePeerFavoritedUs(peerID: String, favorited: Bool, nickname: String, nostrNpub: String? = nil) { + // Get peer's noise public key + guard let noisePublicKey = Data(hexString: peerID) else { return } + + // Decode npub to hex if provided + var nostrPublicKey: String? = nil + if let npub = nostrNpub { + do { + let (hrp, data) = try Bech32.decode(npub) + if hrp == "npub" { + nostrPublicKey = data.hexEncodedString() + } + } catch { + SecureLogger.log("Failed to decode Nostr npub: \(error)", category: SecureLogger.session, level: .error) + } + } + + // Update favorite status in persistence service + FavoritesPersistenceService.shared.updatePeerFavoritedUs( + peerNoisePublicKey: noisePublicKey, + favorited: favorited, + peerNickname: nickname, + peerNostrPublicKey: nostrPublicKey + ) + + // Update peer list to reflect the change + peerManager?.updatePeers() + } + func isFavorite(fingerprint: String) -> Bool { return SecureIdentityStateManager.shared.isFavorite(fingerprint: fingerprint) } @@ -2625,7 +3473,6 @@ extension ChatViewModel: BitchatDelegate { } private func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { - SecureLogger.log("Updating UI delivery status for message \(messageID): \(status)", category: SecureLogger.session, level: .debug) // Helper function to check if we should skip this update func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool { @@ -2666,10 +3513,9 @@ extension ChatViewModel: BitchatDelegate { DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.privateChats = updatedPrivateChats - self.objectWillChange.send() + self.scheduleUIUpdate() } } - -} +} // End of ChatViewModel class diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index 33cdf946..dbf59fe3 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -26,8 +26,9 @@ struct AppInfoView: View { static let offlineComm = ("wifi.slash", "offline communication", "works without internet using Bluetooth low energy") static let encryption = ("lock.shield", "end-to-end encryption", "private messages encrypted with noise protocol") static let extendedRange = ("antenna.radiowaves.left.and.right", "extended range", "messages relay through peers, going the distance") - static let favorites = ("star.fill", "favorites", "get notified when your favorite people join") static let mentions = ("at", "mentions", "use @nickname to notify specific people") + static let favorites = ("star.fill", "favorites", "get notified when your favorite people join") + static let mutualFavorites = ("globe", "mutual favorites", "private message each other via nostr when out of mesh range") } enum Privacy { @@ -130,6 +131,10 @@ struct AppInfoView: View { title: Strings.Features.favorites.1, description: Strings.Features.favorites.2) + FeatureRow(icon: Strings.Features.mutualFavorites.0, + title: Strings.Features.mutualFavorites.1, + description: Strings.Features.mutualFavorites.2) + FeatureRow(icon: Strings.Features.mentions.0, title: Strings.Features.mentions.1, description: Strings.Features.mentions.2) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 893d46a7..622035ce 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -14,11 +14,12 @@ import SwiftUI struct PeerDisplayData: Identifiable { let id: String let displayName: String - let rssi: Int? let isFavorite: Bool let isMe: Bool let hasUnreadMessages: Bool let encryptionStatus: EncryptionStatus + let connectionState: BitchatPeer.ConnectionState + let isMutualFavorite: Bool } // MARK: - Lazy Link Preview @@ -593,11 +594,11 @@ struct ContentView: View { // Rooms and People list ScrollView { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { // People section - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { // Show appropriate header based on context - if !viewModel.connectedPeers.isEmpty { + if !viewModel.allPeers.isEmpty { HStack(spacing: 4) { Image(systemName: "person.2.fill") .font(.system(size: 10)) @@ -607,40 +608,33 @@ struct ContentView: View { } .foregroundColor(secondaryTextColor) .padding(.horizontal, 12) + .padding(.top, 12) } - if viewModel.connectedPeers.isEmpty { + if viewModel.allPeers.isEmpty { Text("nobody around...") .font(.system(size: 14, design: .monospaced)) .foregroundColor(secondaryTextColor) .padding(.horizontal) + .padding(.top, 12) } else { // Extract peer data for display let peerNicknames = viewModel.meshService.getPeerNicknames() - let peerRSSI = viewModel.meshService.getPeerRSSI() - let myPeerID = viewModel.meshService.myPeerID - - // Show all connected peers - let peersToShow: [String] = viewModel.connectedPeers - let _ = print("ContentView: Showing \(peersToShow.count) peers: \(peersToShow.joined(separator: ", "))") + // Show all peers (connected and favorites) // Pre-compute peer data outside ForEach to reduce overhead - let peerData = peersToShow.map { peerID in - let rssiValue = peerRSSI[peerID]?.intValue - if rssiValue == nil { - print("ContentView: No RSSI for peer \(peerID) in dictionary with \(peerRSSI.count) entries") - print("ContentView: peerRSSI keys: \(peerRSSI.keys.joined(separator: ", "))") - } else { - print("ContentView: RSSI for peer \(peerID) is \(rssiValue!)") - } + let peerData = viewModel.allPeers.map { peer in + // Get current myPeerID for each peer to avoid stale values + let currentMyPeerID = viewModel.meshService.myPeerID return PeerDisplayData( - id: peerID, - displayName: peerID == myPeerID ? viewModel.nickname : (peerNicknames[peerID] ?? "anon\(peerID.prefix(4))"), - rssi: rssiValue, - isFavorite: viewModel.isFavorite(peerID: peerID), - isMe: peerID == myPeerID, - hasUnreadMessages: viewModel.unreadPrivateMessages.contains(peerID), - encryptionStatus: viewModel.getEncryptionStatus(for: peerID) + id: peer.id, + displayName: peer.id == currentMyPeerID ? viewModel.nickname : peer.nickname, + isFavorite: peer.favoriteStatus?.isFavorite ?? false, + isMe: peer.id == currentMyPeerID, + hasUnreadMessages: viewModel.unreadPrivateMessages.contains(peer.id), + encryptionStatus: viewModel.getEncryptionStatus(for: peer.id), + connectionState: peer.connectionState, + isMutualFavorite: peer.favoriteStatus?.isMutual ?? false ) }.sorted { (peer1: PeerDisplayData, peer2: PeerDisplayData) in // Sort: favorites first, then alphabetically by nickname @@ -651,7 +645,7 @@ struct ContentView: View { } ForEach(peerData) { peer in - HStack(spacing: 8) { + HStack(spacing: 4) { // Signal strength indicator or unread message icon if peer.isMe { Image(systemName: "person.fill") @@ -663,17 +657,42 @@ struct ContentView: View { .font(.system(size: 12)) .foregroundColor(Color.orange) .accessibilityLabel("Unread message from \(peer.displayName)") - } else if let rssi = peer.rssi { - Image(systemName: "circle.fill") - .font(.system(size: 8)) - .foregroundColor(viewModel.getRSSIColor(rssi: rssi, colorScheme: colorScheme)) - .accessibilityLabel("Signal strength: \(rssi > -60 ? "excellent" : rssi > -70 ? "good" : rssi > -80 ? "fair" : "poor")") } else { - // No RSSI data available - Image(systemName: "circle") - .font(.system(size: 8)) - .foregroundColor(Color.secondary.opacity(0.5)) - .accessibilityLabel("Signal strength: unknown") + // Connection state indicator + switch peer.connectionState { + case .bluetoothConnected: + // Radio icon for mesh connection + Image(systemName: "dot.radiowaves.left.and.right") + .font(.system(size: 10)) + .foregroundColor(textColor) + .accessibilityLabel("Connected via mesh") + case .relayConnected: + // Chain link for relay connection + Image(systemName: "link") + .font(.system(size: 10)) + .foregroundColor(Color.blue) + .accessibilityLabel("Connected via relay") + case .nostrAvailable: + // Purple globe for mutual favorites reachable via Nostr + Image(systemName: "globe") + .font(.system(size: 10)) + .foregroundColor(.purple) + .accessibilityLabel("Available via Nostr") + case .offline: + if peer.isFavorite { + // Crescent moon for non-mutual favorites + Image(systemName: "moon.fill") + .font(.system(size: 10)) + .foregroundColor(Color.secondary.opacity(0.5)) + .accessibilityLabel("Favorite - Offline") + } else { + // Offline indicator for non-favorites (shouldn't happen since we only show favorites when offline) + Image(systemName: "circle") + .font(.system(size: 8)) + .foregroundColor(Color.secondary.opacity(0.3)) + .accessibilityLabel("Offline") + } + } } // Peer name @@ -688,13 +707,13 @@ struct ContentView: View { } else { Text(peer.displayName) .font(.system(size: 14, design: .monospaced)) - .foregroundColor(peerNicknames[peer.id] != nil ? textColor : secondaryTextColor) + .foregroundColor(peer.isFavorite || peerNicknames[peer.id] != nil ? textColor : secondaryTextColor) // Encryption status icon (after peer name) if let icon = peer.encryptionStatus.icon { Image(systemName: icon) .font(.system(size: 10)) - .foregroundColor(peer.encryptionStatus == .noiseVerified ? Color.green : + .foregroundColor(peer.encryptionStatus == .noiseVerified ? textColor : peer.encryptionStatus == .noiseSecured ? textColor : peer.encryptionStatus == .noiseHandshaking ? Color.orange : Color.red) @@ -716,10 +735,11 @@ struct ContentView: View { } } .padding(.horizontal) - .padding(.vertical, 8) + .padding(.vertical, 4) .contentShape(Rectangle()) .onTapGesture { - if !peer.isMe && peerNicknames[peer.id] != nil { + if !peer.isMe { + // Allow tapping on any peer (connected or offline favorite) viewModel.startPrivateChat(with: peer.id) withAnimation(.easeInOut(duration: 0.2)) { showSidebar = false @@ -737,7 +757,7 @@ struct ContentView: View { } } } - .padding(.vertical, 8) + .id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined()) } Spacer() @@ -863,18 +883,35 @@ struct ContentView: View { .accessibilityLabel("Unread private messages") } - let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count + // Single pass to count both metrics + let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in + guard peer.id != viewModel.meshService.myPeerID else { return } + + let isMeshConnected = peer.isConnected || peer.isRelayConnected + if isMeshConnected { + counts.mesh += 1 + counts.others += 1 + } else if peer.isMutualFavorite { + counts.others += 1 + } + } + + let otherPeersCount = peerCounts.others + let meshPeerCount = peerCounts.mesh + + // Purple only if we have peers but none are reachable via mesh (only via Nostr) + let isNostrOnly = otherPeersCount > 0 && meshPeerCount == 0 HStack(spacing: 4) { // People icon with count Image(systemName: "person.2.fill") .font(.system(size: 11)) - .accessibilityLabel("\(otherPeersCount) connected \(otherPeersCount == 1 ? "person" : "people")") + .accessibilityLabel("\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")") Text("\(otherPeersCount)") .font(.system(size: 12, design: .monospaced)) .accessibilityHidden(true) } - .foregroundColor(viewModel.isConnected ? textColor : Color.red) + .foregroundColor(isNostrOnly ? Color.purple : (meshPeerCount > 0 ? textColor : Color.red)) } .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { @@ -890,73 +927,139 @@ struct ContentView: View { private var privateHeaderView: some View { Group { - if let privatePeerID = viewModel.selectedPrivateChatPeer, - let privatePeerNick = viewModel.meshService.getPeerNicknames()[privatePeerID] { - HStack { - Button(action: { - withAnimation(.easeInOut(duration: 0.2)) { - showPrivateChat = false - viewModel.endPrivateChat() - } - }) { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.system(size: 12)) - Text("back") - .font(.system(size: 14, design: .monospaced)) - } - .foregroundColor(textColor) - } - .buttonStyle(.plain) - .accessibilityLabel("Back to main chat") - - Spacer() - + if let privatePeerID = viewModel.selectedPrivateChatPeer { + privateHeaderContent(for: privatePeerID) + } + } + } + + @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 + + let peer = viewModel.getPeer(byID: currentPeerID) + let privatePeerNick = peer?.displayName ?? + viewModel.meshService.getPeerNicknames()[currentPeerID] ?? + FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: privatePeerID) ?? 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), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + favoriteStatus.isMutual { + return true + } + return false + } + switch connectionState { + case .nostrAvailable: + return true + default: + return false + } + }() + + ZStack { + // Center content - always perfectly centered Button(action: { viewModel.showFingerprint(for: privatePeerID) }) { HStack(spacing: 6) { + // Show transport icon based on connection state (like peer list) + if let connectionState = peer?.connectionState { + switch connectionState { + case .bluetoothConnected: + // Radio icon for mesh connection + Image(systemName: "dot.radiowaves.left.and.right") + .font(.system(size: 14)) + .foregroundColor(textColor) + .accessibilityLabel("Connected via mesh") + case .relayConnected: + // Chain link for relay connection + Image(systemName: "link") + .font(.system(size: 14)) + .foregroundColor(Color.blue) + .accessibilityLabel("Connected via relay") + case .nostrAvailable: + // Purple globe for Nostr + Image(systemName: "globe") + .font(.system(size: 14)) + .foregroundColor(.purple) + .accessibilityLabel("Available via Nostr") + case .offline: + // Should not happen for PM header, but handle gracefully + EmptyView() + } + } else if isNostrAvailable { + // Fallback to Nostr if peer not in list but is mutual favorite + Image(systemName: "globe") + .font(.system(size: 14)) + .foregroundColor(.purple) + .accessibilityLabel("Available via Nostr") + } + Text("\(privatePeerNick)") .font(.system(size: 16, weight: .medium, design: .monospaced)) - .foregroundColor(Color.orange) + .foregroundColor(textColor) + // Dynamic encryption status icon let encryptionStatus = viewModel.getEncryptionStatus(for: privatePeerID) if let icon = encryptionStatus.icon { Image(systemName: icon) .font(.system(size: 14)) - .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : - encryptionStatus == .noiseSecured ? Color.orange : + .foregroundColor(encryptionStatus == .noiseVerified ? textColor : + encryptionStatus == .noiseSecured ? textColor : Color.red) .accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")") } } - .frame(maxWidth: .infinity) .accessibilityLabel("Private chat with \(privatePeerNick)") .accessibilityHint("Tap to view encryption fingerprint") } .buttonStyle(.plain) - Spacer() - - // Favorite button - Button(action: { - viewModel.toggleFavorite(peerID: privatePeerID) - }) { - Image(systemName: viewModel.isFavorite(peerID: privatePeerID) ? "star.fill" : "star") - .font(.system(size: 16)) - .foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor) + // Left and right buttons positioned with HStack + HStack { + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + showPrivateChat = false + viewModel.endPrivateChat() + } + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 12)) + Text("back") + .font(.system(size: 14, design: .monospaced)) + } + .foregroundColor(textColor) + } + .buttonStyle(.plain) + .accessibilityLabel("Back to main chat") + + Spacer() + + // Favorite button + Button(action: { + viewModel.toggleFavorite(peerID: privatePeerID) + }) { + Image(systemName: viewModel.isFavorite(peerID: privatePeerID) ? "star.fill" : "star") + .font(.system(size: 16)) + .foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor) + } + .buttonStyle(.plain) + .accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites") + .accessibilityHint("Double tap to toggle favorite status") } - .buttonStyle(.plain) - .accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites") - .accessibilityHint("Double tap to toggle favorite status") } .frame(height: 44) .padding(.horizontal, 12) .background(backgroundColor.opacity(0.95)) - } else { - EmptyView() - } - } } } diff --git a/bitchat/bitchat-macOS.entitlements b/bitchat/bitchat-macOS.entitlements index 610b08cb..346f374c 100644 --- a/bitchat/bitchat-macOS.entitlements +++ b/bitchat/bitchat-macOS.entitlements @@ -10,5 +10,7 @@ com.apple.security.device.bluetooth + com.apple.security.network.client + \ No newline at end of file diff --git a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift index bd7b2c93..171d47b7 100644 --- a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift @@ -365,8 +365,7 @@ final class PrivateChatE2ETests: XCTestCase { timestamp: packet.timestamp, payload: encrypted, signature: packet.signature, - ttl: packet.ttl, - sequenceNumber: 1 + ttl: packet.ttl ) self.bob.simulateIncomingPacket(encryptedPacket) } catch { diff --git a/bitchatTests/EndToEnd/PublicChatE2ETests.swift b/bitchatTests/EndToEnd/PublicChatE2ETests.swift index 7f8e9887..c1994d50 100644 --- a/bitchatTests/EndToEnd/PublicChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PublicChatE2ETests.swift @@ -122,8 +122,7 @@ final class PublicChatE2ETests: XCTestCase { timestamp: packet.timestamp, payload: relayPayload, signature: packet.signature, - ttl: packet.ttl - 1, - sequenceNumber: 1 + ttl: packet.ttl - 1 ) // Simulate relay to Charlie @@ -451,8 +450,7 @@ final class PublicChatE2ETests: XCTestCase { timestamp: packet.timestamp, payload: relayPayload, signature: packet.signature, - ttl: packet.ttl - 1, - sequenceNumber: 1 + ttl: packet.ttl - 1 ) // Relay to next hops diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index bcdd04bd..078c8c95 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -197,8 +197,7 @@ final class IntegrationTests: XCTestCase { timestamp: packet.timestamp, payload: encrypted, signature: packet.signature, - ttl: packet.ttl, - sequenceNumber: 1 + ttl: packet.ttl ) self.nodes["Bob"]!.simulateIncomingPacket(encPacket) } @@ -699,8 +698,7 @@ final class IntegrationTests: XCTestCase { timestamp: packet.timestamp, payload: encrypted, signature: packet.signature, - ttl: packet.ttl, - sequenceNumber: 1 + ttl: packet.ttl ) self.nodes["Bob"]!.simulateIncomingPacket(encPacket) } @@ -807,8 +805,7 @@ final class IntegrationTests: XCTestCase { timestamp: packet.timestamp, payload: relayPayload, signature: packet.signature, - ttl: packet.ttl - 1, - sequenceNumber: 1 + ttl: packet.ttl - 1 ) for hop in nextHops { diff --git a/bitchatTests/Mocks/MockBluetoothMeshService.swift b/bitchatTests/Mocks/MockBluetoothMeshService.swift index 6c4a2071..b2ec7bbf 100644 --- a/bitchatTests/Mocks/MockBluetoothMeshService.swift +++ b/bitchatTests/Mocks/MockBluetoothMeshService.swift @@ -73,8 +73,7 @@ class MockBluetoothMeshService: BluetoothMeshService { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, - ttl: 3, - sequenceNumber: 1 + ttl: 3 ) sentMessages.append((message, packet)) @@ -112,8 +111,7 @@ class MockBluetoothMeshService: BluetoothMeshService { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, - ttl: 3, - sequenceNumber: 1 + ttl: 3 ) sentMessages.append((message, packet)) diff --git a/bitchatTests/NostrProtocolTests.swift b/bitchatTests/NostrProtocolTests.swift new file mode 100644 index 00000000..355b11bd --- /dev/null +++ b/bitchatTests/NostrProtocolTests.swift @@ -0,0 +1,111 @@ +// +// NostrProtocolTests.swift +// bitchatTests +// +// Tests for NIP-17 gift-wrapped private messages +// + +import XCTest +import CryptoKit +@testable import bitchat + +final class NostrProtocolTests: XCTestCase { + + override func setUp() { + super.setUp() + // Enable secure logging for tests + SecureLogger.enableLogging() + } + + func testNIP17MessageRoundTrip() throws { + // Create sender and recipient identities + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + + print("Sender pubkey: \(sender.publicKeyHex)") + print("Recipient pubkey: \(recipient.publicKeyHex)") + + // Create a test message + let originalContent = "Hello from NIP-17 test!" + + // Create encrypted gift wrap + let giftWrap = try NostrProtocol.createPrivateMessage( + content: originalContent, + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + print("Gift wrap created with ID: \(giftWrap.id)") + print("Gift wrap pubkey: \(giftWrap.pubkey)") + + // Decrypt the gift wrap + let (decryptedContent, senderPubkey) = try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: recipient + ) + + // Verify + XCTAssertEqual(decryptedContent, originalContent) + XCTAssertEqual(senderPubkey, sender.publicKeyHex) + + print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey)") + } + + func testGiftWrapUsesUniqueEphemeralKeys() throws { + // Create identities + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + + // Create two messages + let message1 = try NostrProtocol.createPrivateMessage( + content: "Message 1", + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + let message2 = try NostrProtocol.createPrivateMessage( + content: "Message 2", + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + // Gift wrap pubkeys should be different (unique ephemeral keys) + XCTAssertNotEqual(message1.pubkey, message2.pubkey) + print("Message 1 gift wrap pubkey: \(message1.pubkey)") + print("Message 2 gift wrap pubkey: \(message2.pubkey)") + + // Both should decrypt successfully + let (content1, _) = try NostrProtocol.decryptPrivateMessage( + giftWrap: message1, + recipientIdentity: recipient + ) + let (content2, _) = try NostrProtocol.decryptPrivateMessage( + giftWrap: message2, + recipientIdentity: recipient + ) + + XCTAssertEqual(content1, "Message 1") + XCTAssertEqual(content2, "Message 2") + } + + func testDecryptionFailsWithWrongRecipient() throws { + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let wrongRecipient = try NostrIdentity.generate() + + // Create message for recipient + let giftWrap = try NostrProtocol.createPrivateMessage( + content: "Secret message", + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + // Try to decrypt with wrong recipient + XCTAssertThrowsError(try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: wrongRecipient + )) { error in + print("Expected error when decrypting with wrong key: \(error)") + } + } +} \ No newline at end of file diff --git a/bitchatTests/TestUtilities/TestHelpers.swift b/bitchatTests/TestUtilities/TestHelpers.swift index be1bc588..41aa4e2d 100644 --- a/bitchatTests/TestUtilities/TestHelpers.swift +++ b/bitchatTests/TestUtilities/TestHelpers.swift @@ -55,8 +55,7 @@ class TestHelpers { recipientID: String? = nil, payload: Data = "test payload".data(using: .utf8)!, signature: Data? = nil, - ttl: UInt8 = 3, - sequenceNumber: UInt32 = 1 + ttl: UInt8 = 3 ) -> BitchatPacket { return BitchatPacket( type: type, @@ -65,8 +64,7 @@ class TestHelpers { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: signature, - ttl: ttl, - sequenceNumber: sequenceNumber + ttl: ttl ) } diff --git a/build.log b/build.log new file mode 100644 index 00000000..68b94403 --- /dev/null +++ b/build.log @@ -0,0 +1,180 @@ +Command line invocation: + /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -scheme "bitchat (macOS)" -configuration Debug build + +Resolve Package Graph + + +Resolved source packages: + swift-secp256k1: https://github.com/21-DOT-DEV/swift-secp256k1 @ 0.21.1 + +ComputePackagePrebuildTargetDependencyGraph + +Prepare packages + +CreateBuildRequest + +SendProjectDescription + +CreateBuildOperation + +ComputeTargetDependencyGraph +note: Building targets in dependency order +note: Target dependency graph (5 targets) + Target 'bitchat_macOS' in project 'bitchat' + ➜ Explicit dependency on target 'P256K' in project 'swift-secp256k1' + ➜ Explicit dependency on target 'libsecp256k1' in project 'swift-secp256k1' + Target 'libsecp256k1' in project 'swift-secp256k1' + ➜ Explicit dependency on target 'libsecp256k1' in project 'swift-secp256k1' + Target 'P256K' in project 'swift-secp256k1' + ➜ Explicit dependency on target 'P256K' in project 'swift-secp256k1' + ➜ Explicit dependency on target 'libsecp256k1' in project 'swift-secp256k1' + Target 'P256K' in project 'swift-secp256k1' + ➜ Explicit dependency on target 'libsecp256k1' in project 'swift-secp256k1' + Target 'libsecp256k1' in project 'swift-secp256k1' (no dependencies) + +GatherProvisioningInputs + +CreateBuildDescription + +ClangStatCache /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk /Users/jack/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/macosx15.5-24F74-8254517eb8b97d462ccbc072ba9094c9.sdkstatcache + cd /Users/jack/vibe/bitchat/bitchat.xcodeproj + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk -o /Users/jack/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/macosx15.5-24F74-8254517eb8b97d462ccbc072ba9094c9.sdkstatcache + +ProcessProductPackaging /Users/jack/vibe/bitchat/bitchat/bitchat-macOS.entitlements /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat.app.xcent (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + + Entitlements: + + { + "com.apple.application-identifier" = "L3N5LHJD5Y.chat.bitchat"; + "com.apple.developer.team-identifier" = L3N5LHJD5Y; + "com.apple.security.app-sandbox" = 1; + "com.apple.security.application-groups" = ( + "group.chat.bitchat" + ); + "com.apple.security.device.bluetooth" = 1; + "com.apple.security.get-task-allow" = 1; + "com.apple.security.network.client" = 1; +} + + builtin-productPackagingUtility /Users/jack/vibe/bitchat/bitchat/bitchat-macOS.entitlements -entitlements -format xml -o /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat.app.xcent + +ProcessProductPackagingDER /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat.app.xcent /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat.app.xcent.der (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + /usr/bin/derq query -f xml -i /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat.app.xcent -o /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat.app.xcent.der --raw + +SwiftDriver bitchat_macOS normal arm64 com.apple.xcode.tools.swift.compiler (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name bitchat -Onone -enforce-exclusivity\=checked @/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.SwiftFileList -DDEBUG -Xcc -fmodule-map-file\=/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/GeneratedModuleMaps/libsecp256k1.modulemap -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/jack/Library/Developer/Xcode/DerivedData/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Index.noindex/DataStore -swift-version 5 -I /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug -F /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/PackageFrameworks -F /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/PackageFrameworks -F /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug -c -j16 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/jack/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/macosx15.5-24F74-8254517eb8b97d462ccbc072ba9094c9.sdkstatcache -output-file-map /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat_macOS-OutputFileMap.json -use-frontend-parseable-output -save-temps -no-color-diagnostics -serialize-diagnostics -emit-dependencies -emit-module -emit-module-path /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.swiftmodule -validate-clang-modules-once -clang-build-session-file /Users/jack/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/swift-overrides.hmap -emit-const-values -Xfrontend -const-gather-protocols-file -Xfrontend /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat_macOS_const_extract_protocols.json -Xcc -iquote -Xcc /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-generated-files.hmap -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-own-target-headers.hmap -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-all-non-framework-target-headers.hmap -Xcc -ivfsoverlay -Xcc /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat-0a14b109deca4fd2c405538600cea2bf-VFS/all-product-headers.yaml -Xcc -iquote -Xcc /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-project-headers.hmap -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/SourcePackages/checkouts/swift-secp256k1/Sources/libsecp256k1/include -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/include -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/DerivedSources-normal/arm64 -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/DerivedSources/arm64 -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/DerivedSources -Xcc -DDEBUG\=1 -emit-objc-header -emit-objc-header-path /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat-Swift.h -working-directory /Users/jack/vibe/bitchat -experimental-emit-module-separately -disable-cmo + +ProcessInfoPlistFile /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/Info.plist /Users/jack/vibe/bitchat/bitchat/Info.plist (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + builtin-infoPlistUtility /Users/jack/vibe/bitchat/bitchat/Info.plist -producttype com.apple.product-type.application -genpkginfo /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/PkgInfo -expandbuildsettings -platform macosx -additionalcontentfile /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/assetcatalog_generated_info.plist -o /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/Info.plist +removing value "bluetooth-central" for "UIBackgroundModes" - not supported on macOS +removing value "bluetooth-peripheral" for "UIBackgroundModes" - only supported on iOS +removing value "remote-notification" for "UIBackgroundModes" - not supported on macOS + +SwiftEmitModule normal arm64 Emitting\ module\ for\ bitchat (in target 'bitchat_macOS' from project 'bitchat') + +EmitSwiftModule normal arm64 (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + + +SwiftCompile normal arm64 Compiling\ BluetoothMeshService.swift /Users/jack/vibe/bitchat/bitchat/Services/BluetoothMeshService.swift (in target 'bitchat_macOS' from project 'bitchat') +SwiftCompile normal arm64 /Users/jack/vibe/bitchat/bitchat/Services/BluetoothMeshService.swift (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + +/Users/jack/vibe/bitchat/bitchat/Services/BluetoothMeshService.swift:2923:36: warning: value 'tempSession' was defined but never used; consider replacing with boolean test + if let tempSession = peerSessions[tempID] { + ~~~~^~~~~~~~~~~~~~ + != nil + +SwiftCompile normal arm64 Compiling\ BitchatPeer.swift /Users/jack/vibe/bitchat/bitchat/Models/BitchatPeer.swift (in target 'bitchat_macOS' from project 'bitchat') + +SwiftCompile normal arm64 /Users/jack/vibe/bitchat/bitchat/Models/BitchatPeer.swift (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + + +SwiftDriverJobDiscovery normal arm64 Compiling BitchatPeer.swift (in target 'bitchat_macOS' from project 'bitchat') + +SwiftDriverJobDiscovery normal arm64 Emitting module for bitchat (in target 'bitchat_macOS' from project 'bitchat') + +SwiftDriver\ Compilation\ Requirements bitchat_macOS normal arm64 com.apple.xcode.tools.swift.compiler (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name bitchat -Onone -enforce-exclusivity\=checked @/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.SwiftFileList -DDEBUG -Xcc -fmodule-map-file\=/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/GeneratedModuleMaps/libsecp256k1.modulemap -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/jack/Library/Developer/Xcode/DerivedData/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Index.noindex/DataStore -swift-version 5 -I /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug -F /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/PackageFrameworks -F /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/PackageFrameworks -F /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug -c -j16 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/jack/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/macosx15.5-24F74-8254517eb8b97d462ccbc072ba9094c9.sdkstatcache -output-file-map /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat_macOS-OutputFileMap.json -use-frontend-parseable-output -save-temps -no-color-diagnostics -serialize-diagnostics -emit-dependencies -emit-module -emit-module-path /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.swiftmodule -validate-clang-modules-once -clang-build-session-file /Users/jack/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/swift-overrides.hmap -emit-const-values -Xfrontend -const-gather-protocols-file -Xfrontend /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat_macOS_const_extract_protocols.json -Xcc -iquote -Xcc /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-generated-files.hmap -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-own-target-headers.hmap -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-all-non-framework-target-headers.hmap -Xcc -ivfsoverlay -Xcc /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat-0a14b109deca4fd2c405538600cea2bf-VFS/all-product-headers.yaml -Xcc -iquote -Xcc /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-project-headers.hmap -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/SourcePackages/checkouts/swift-secp256k1/Sources/libsecp256k1/include -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/include -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/DerivedSources-normal/arm64 -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/DerivedSources/arm64 -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/DerivedSources -Xcc -DDEBUG\=1 -emit-objc-header -emit-objc-header-path /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat-Swift.h -working-directory /Users/jack/vibe/bitchat -experimental-emit-module-separately -disable-cmo + +Copy /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.swiftsourceinfo (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -resolve-src-symlinks -rename /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.swiftsourceinfo /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo + +Copy /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.swiftmodule/arm64-apple-macos.abi.json /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.abi.json (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -resolve-src-symlinks -rename /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.abi.json /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.swiftmodule/arm64-apple-macos.abi.json + +SwiftDriverJobDiscovery normal arm64 Compiling BluetoothMeshService.swift (in target 'bitchat_macOS' from project 'bitchat') + +SwiftDriver\ Compilation bitchat_macOS normal arm64 com.apple.xcode.tools.swift.compiler (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name bitchat -Onone -enforce-exclusivity\=checked @/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.SwiftFileList -DDEBUG -Xcc -fmodule-map-file\=/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/GeneratedModuleMaps/libsecp256k1.modulemap -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/jack/Library/Developer/Xcode/DerivedData/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Index.noindex/DataStore -swift-version 5 -I /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug -F /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/PackageFrameworks -F /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/PackageFrameworks -F /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug -c -j16 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/jack/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/macosx15.5-24F74-8254517eb8b97d462ccbc072ba9094c9.sdkstatcache -output-file-map /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat_macOS-OutputFileMap.json -use-frontend-parseable-output -save-temps -no-color-diagnostics -serialize-diagnostics -emit-dependencies -emit-module -emit-module-path /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.swiftmodule -validate-clang-modules-once -clang-build-session-file /Users/jack/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/swift-overrides.hmap -emit-const-values -Xfrontend -const-gather-protocols-file -Xfrontend /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat_macOS_const_extract_protocols.json -Xcc -iquote -Xcc /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-generated-files.hmap -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-own-target-headers.hmap -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-all-non-framework-target-headers.hmap -Xcc -ivfsoverlay -Xcc /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat-0a14b109deca4fd2c405538600cea2bf-VFS/all-product-headers.yaml -Xcc -iquote -Xcc /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-project-headers.hmap -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/SourcePackages/checkouts/swift-secp256k1/Sources/libsecp256k1/include -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/include -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/DerivedSources-normal/arm64 -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/DerivedSources/arm64 -Xcc -I/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/DerivedSources -Xcc -DDEBUG\=1 -emit-objc-header -emit-objc-header-path /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat-Swift.h -working-directory /Users/jack/vibe/bitchat -experimental-emit-module-separately -disable-cmo + +Ld /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/MacOS/bitchat.debug.dylib normal (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-macos13.0 -dynamiclib -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk -O0 -L/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -L/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug -F/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -F/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/PackageFrameworks -F/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/PackageFrameworks -F/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug -filelist /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.LinkFileList -install_name @rpath/bitchat.debug.dylib -Xlinker -rpath -Xlinker /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/PackageFrameworks -Xlinker -rpath -Xlinker @executable_path/../Frameworks -dead_strip -Xlinker -object_path_lto -Xlinker /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat_lto.o -rdynamic -Xlinker -no_deduplicate -Xlinker -dependency_info -Xlinker /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.swiftmodule -Wl,-no_warn_duplicate_libraries -Wl,-no_warn_duplicate_libraries -Xlinker -alias -Xlinker _main -Xlinker ___debug_main_executable_dylib_entry_point -Xlinker -no_adhoc_codesign -o /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/MacOS/bitchat.debug.dylib -Xlinker -add_ast_path -Xlinker /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/swift-secp256k1.build/Debug/P256K.build/Objects-normal/arm64/P256K.swiftmodule + +ConstructStubExecutorLinkFileList /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-ExecutorLinkFileList-normal-arm64.txt (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + construct-stub-executor-link-file-list /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/MacOS/bitchat.debug.dylib /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/libPreviewsJITStubExecutor_no_swift_entry_point.a /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/libPreviewsJITStubExecutor.a --output /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-ExecutorLinkFileList-normal-arm64.txt +note: Using stub executor library with Swift entry point. (in target 'bitchat_macOS' from project 'bitchat') + +Ld /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/MacOS/bitchat normal (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-macos13.0 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk -O0 -L/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug -F/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/PackageFrameworks -F/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/PackageFrameworks -F/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug -Xlinker -rpath -Xlinker @executable_path -Xlinker -rpath -Xlinker /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/PackageFrameworks -Xlinker -rpath -Xlinker @executable_path/../Frameworks -rdynamic -Xlinker -no_deduplicate -e ___debug_blank_executor_main -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __debug_dylib -Xlinker /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-DebugDylibPath-normal-arm64.txt -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __debug_instlnm -Xlinker /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-DebugDylibInstallName-normal-arm64.txt -Xlinker -filelist -Xlinker /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat-ExecutorLinkFileList-normal-arm64.txt /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/MacOS/bitchat.debug.dylib -Xlinker -no_adhoc_codesign -o /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/MacOS/bitchat + +CopySwiftLibs /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + builtin-swiftStdLibTool --copy --verbose --sign AFB905538DFD5A78A97C49E383404761A7B3076C --scan-executable /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/MacOS/bitchat.debug.dylib --scan-folder /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/Frameworks --scan-folder /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/PlugIns --scan-folder /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/Library/SystemExtensions --scan-folder /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/Extensions --platform macosx --toolchain /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --destination /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/Frameworks --strip-bitcode --strip-bitcode-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/bitcode_strip --emit-dependency-info /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/SwiftStdLibToolInputDependencies.dep --filter-for-swift-os + +ExtractAppIntentsMetadata (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/appintentsmetadataprocessor --toolchain-dir /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --module-name bitchat --sdk-root /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk --xcode-version 16F6 --platform-family macOS --deployment-target 13.0 --bundle-identifier chat.bitchat --output /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/Resources --target-triple arm64-apple-macos13.0 --binary-file /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/MacOS/bitchat --dependency-file /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat_dependency_info.dat --stringsdata-file /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/ExtractedAppShortcutsMetadata.stringsdata --source-file-list /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.SwiftFileList --metadata-file-list /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat.DependencyMetadataFileList --static-metadata-file-list /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat.DependencyStaticMetadataFileList --swift-const-vals-list /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/Objects-normal/arm64/bitchat.SwiftConstValuesFileList --compile-time-extraction --deployment-aware-processing --validate-assistant-intents --no-app-shortcuts-localization +2025-07-30 12:19:57.513 appintentsmetadataprocessor[20150:10336894] Starting appintentsmetadataprocessor export +2025-07-30 12:19:57.513 appintentsmetadataprocessor[20150:10336894] warning: Metadata extraction skipped. No AppIntents.framework dependency found. + +CodeSign /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/MacOS/bitchat.debug.dylib (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + + Signing Identity: "Apple Development: Jack Dorsey (TWC6M78PFL)" + Provisioning Profile: "Mac Team Provisioning Profile: chat.bitchat" + (44c6af2f-ada2-49f3-b72b-40d0bdb4e3ec) + + /usr/bin/codesign --force --sign AFB905538DFD5A78A97C49E383404761A7B3076C --timestamp\=none --generate-entitlement-der /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/MacOS/bitchat.debug.dylib + +CodeSign /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/MacOS/__preview.dylib (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + + Signing Identity: "Apple Development: Jack Dorsey (TWC6M78PFL)" + Provisioning Profile: "Mac Team Provisioning Profile: chat.bitchat" + (44c6af2f-ada2-49f3-b72b-40d0bdb4e3ec) + + /usr/bin/codesign --force --sign AFB905538DFD5A78A97C49E383404761A7B3076C --timestamp\=none --generate-entitlement-der /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/MacOS/__preview.dylib +/Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app/Contents/MacOS/__preview.dylib: replacing existing signature + +CodeSign /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + + Signing Identity: "Apple Development: Jack Dorsey (TWC6M78PFL)" + Provisioning Profile: "Mac Team Provisioning Profile: chat.bitchat" + (44c6af2f-ada2-49f3-b72b-40d0bdb4e3ec) + + /usr/bin/codesign --force --sign AFB905538DFD5A78A97C49E383404761A7B3076C --entitlements /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Intermediates.noindex/bitchat.build/Debug/bitchat_macOS.build/bitchat.app.xcent --timestamp\=none --generate-entitlement-der /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app + +Validate /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + builtin-validationUtility /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app -no-validate-extension -infoplist-subpath Contents/Info.plist + +RegisterWithLaunchServices /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app (in target 'bitchat_macOS' from project 'bitchat') + cd /Users/jack/vibe/bitchat + /System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f -R -trusted /Users/jack/Library/Developer/Xcode/DerivedData/bitchat-fccgrhrjilfihpcztbckqyghwohi/Build/Products/Debug/bitchat.app + +** BUILD SUCCEEDED ** +