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 ** +