Compare commits

..
37 changed files with 1204 additions and 2187 deletions
-4
View File
@@ -71,7 +71,3 @@ __pycache__/
# Local build results # Local build results
.Result*/ .Result*/
.Result*.xcresult/ .Result*.xcresult/
TestResult.xcresult/
*.xcresult/
build.log
*.log
-35
View File
@@ -29,9 +29,6 @@
047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B22E55FED60083520F /* GeohashPeopleList.swift */; }; 047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B22E55FED60083520F /* GeohashPeopleList.swift */; };
047502B92E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; }; 047502B92E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
047502BA2E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; }; 047502BA2E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; }; 049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; }; 049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; }; 049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
@@ -59,8 +56,6 @@
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; }; 0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; 0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; }; 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */; }; 132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */; };
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; }; 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
@@ -107,17 +102,11 @@
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B378C16594575FCC7F9C75 /* ShareViewController.swift */; }; 9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B378C16594575FCC7F9C75 /* ShareViewController.swift */; };
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; }; 9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; }; A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; };
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; }; A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; };
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; }; A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; };
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; }; AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; }; AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; }; ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; }; ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; };
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; }; AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
@@ -204,7 +193,6 @@
047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = "<group>"; }; 047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = "<group>"; };
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; }; 047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; }; 047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; }; 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; }; 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; }; 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
@@ -220,7 +208,6 @@
05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; }; 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; };
0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; }; 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; }; 11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; };
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDisplayNameResolver.swift; sourceTree = "<group>"; };
136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; }; 136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; };
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; }; 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; };
2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
@@ -251,11 +238,8 @@
980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = "<group>"; }; 980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = "<group>"; };
9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; }; 9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; };
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; }; A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; }; AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; };
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = "<group>"; };
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = "<group>"; };
B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; }; B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; }; C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
@@ -328,7 +312,6 @@
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */, A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
C3D98EB3E1B455E321F519F4 /* bitchatTests */, C3D98EB3E1B455E321F519F4 /* bitchatTests */,
9F37F9F2C353B58AC809E93B /* Products */, 9F37F9F2C353B58AC809E93B /* Products */,
048A4BE52E5CCC5C00162C4A /* Recovered References */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -411,7 +394,6 @@
9A78348821A7D3374607D4E3 /* Utils */ = { 9A78348821A7D3374607D4E3 /* Utils */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */,
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */, 049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */,
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */, 049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */,
32F149C43D1915831B60FE09 /* CompressionUtil.swift */, 32F149C43D1915831B60FE09 /* CompressionUtil.swift */,
@@ -446,7 +428,6 @@
A55126E93155456CAA8D6656 /* Views */ = { A55126E93155456CAA8D6656 /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
047502B22E55FED60083520F /* GeohashPeopleList.swift */, 047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */, 047502B32E55FED60083520F /* MeshPeerList.swift */,
0475028E2E5417660083520F /* LocationChannelsSheet.swift */, 0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
@@ -520,8 +501,6 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = { D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */, 047502B82E560F690083520F /* RelayController.swift */,
0475028B2E54171C0083520F /* LocationChannelManager.swift */, 0475028B2E54171C0083520F /* LocationChannelManager.swift */,
049BD3B02E51F319001A566B /* MessageRouter.swift */, 049BD3B02E51F319001A566B /* MessageRouter.swift */,
@@ -543,7 +522,6 @@
E78C7F4B6769C0A72F5DE544 /* Nostr */ = { E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */, 049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */, 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */, 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
@@ -734,7 +712,6 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */,
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */, 9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -743,14 +720,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */, AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */, 9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */, 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */,
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */, 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
@@ -803,14 +774,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */, ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */, AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */, 049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */,
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */, F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
+16 -11
View File
@@ -31,13 +31,6 @@ struct BitchatApp: App {
.environmentObject(chatViewModel) .environmentObject(chatViewModel)
.onAppear { .onAppear {
NotificationDelegate.shared.chatViewModel = chatViewModel NotificationDelegate.shared.chatViewModel = chatViewModel
// Inject live Noise service into VerificationService to avoid creating new BLE instances
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
DispatchQueue.global(qos: .utility).async {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
}
#if os(iOS) #if os(iOS)
appDelegate.chatViewModel = chatViewModel appDelegate.chatViewModel = chatViewModel
#elseif os(macOS) #elseif os(macOS)
@@ -99,18 +92,30 @@ struct BitchatApp: App {
return return
} }
// Only process if shared within configured window // Only process if shared within last 30 seconds
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds { if Date().timeIntervalSince(sharedDate) < 30 {
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text" let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
// Clear the shared content // Clear the shared content
userDefaults.removeObject(forKey: "sharedContent") userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType") userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate") userDefaults.removeObject(forKey: "sharedContentDate")
// No need to force synchronize here userDefaults.synchronize()
// Send the shared content immediately on the main queue // Show notification about shared content
DispatchQueue.main.async { DispatchQueue.main.async {
// Add system message about sharing
let systemMessage = BitchatMessage(
sender: "system",
content: "preparing to share \(contentType)...",
timestamp: Date(),
isRelay: false
)
self.chatViewModel.messages.append(systemMessage)
}
// Send the shared content after a short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
if contentType == "url" { if contentType == "url" {
// Try to parse as JSON first // Try to parse as JSON first
if let data = sharedContent.data(using: .utf8), if let data = sharedContent.data(using: .utf8),
+51 -2
View File
@@ -173,7 +173,56 @@ struct PendingActions {
var setPetname: String? var setPetname: String?
} }
// // MARK: - Privacy Settings
struct PrivacySettings: Codable {
// Level 1: Maximum privacy (default)
var persistIdentityCache = false
var showLastSeen = false
// Level 2: Convenience
var autoAcceptKnownFingerprints = false
var rememberNicknameHistory = false
// Level 3: Social
var shareTrustNetworkHints = false // "3 mutual contacts trust this person"
}
// MARK: - Conflict Resolution
/// Strategies for resolving identity conflicts in the decentralized network.
/// Handles cases where multiple peers claim the same nickname or when
/// identity mappings become ambiguous due to network partitions.
enum ConflictResolution {
case acceptNew(petname: String) // "John (2)"
case rejectNew
case blockFingerprint(String)
case alertUser(message: String)
}
// MARK: - UI State
struct PeerUIState {
let peerID: String
let nickname: String
var identityState: IdentityState
var connectionQuality: ConnectionQuality
enum IdentityState {
case unknown // Gray - No identity info
case unverifiedKnown(String) // Blue - Handshake done, matches cache
case verified(String) // Green - Cryptographically verified
case conflict(String, String) // Red - Nickname doesn't match fingerprint
case pending // Yellow - Handshake in progress
}
}
enum ConnectionQuality {
case excellent
case good
case poor
case disconnected
}
// MARK: - Migration Support // MARK: - Migration Support
// // Removed LegacyFavorite - no longer needed
-2
View File
@@ -37,8 +37,6 @@
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string> <string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string> <string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>bluetooth-central</string> <string>bluetooth-central</string>
+216 -1
View File
@@ -89,4 +89,219 @@ struct BitchatPeer: Identifiable, Equatable {
} }
} }
// // 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: Transport
private let favoritesService = FavoritesPersistenceService.shared
init(meshService: Transport) {
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<String> = []
var addedPeerIDs: Set<String> = []
// 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 {
continue
}
// Check if this peer is actually connected
let isConnected = meshService.isPeerConnected(peerID)
// Skip disconnected peers unless they're favorites (handled later)
if !isConnected {
continue
}
if isConnected {
connectedNicknames.insert(nickname)
}
// Track that we've added this peer ID
addedPeerIDs.insert(peerID)
var peer = BitchatPeer(
id: peerID,
noisePublicKey: noiseKey,
nickname: nickname,
isConnected: isConnected
)
// 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 AND that we actively favorite)
for (favoriteKey, favorite) in favoritesService.favorites {
let favoriteID = favorite.peerNoisePublicKey.hexEncodedString()
// Skip if this peer is already connected (by nickname)
if connectedNicknames.contains(favorite.peerNickname) {
// Skipping favorite - already connected
continue
}
// Skip if we already added a peer with this ID (prevents duplicates)
if addedPeerIDs.contains(favoriteID) {
// Skipping favorite - peer ID already added
continue
}
// Only add peers that WE favorite (not just ones who favorite us)
if !favorite.isFavorite {
// Skipping - we don't favorite them
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
addedPeerIDs.insert(favoriteID) // Track that we've added this ID
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, then favorites, then alphabetical
allPeers.sort { lhs, rhs in
// Direct connections first
if lhs.isConnected != rhs.isConnected {
return lhs.isConnected
}
// 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
}
}
// Final safety check: ensure no duplicate IDs
var finalPeers: [BitchatPeer] = []
var seenIDs: Set<String> = []
for peer in allPeers {
if !seenIDs.contains(peer.id) {
seenIDs.insert(peer.id)
finalPeers.append(peer)
} else {
SecureLogger.log("⚠️ Removing duplicate peer ID in final check: \(peer.id) (\(peer.displayName))",
category: SecureLogger.session, level: .warning)
}
}
self.peers = finalPeers
self.favorites = favorites
self.mutualFavorites = mutualFavorites
// Log peer list summary sparingly at debug level
if favoritesService.favorites.count > 0 {
SecureLogger.log("📊 Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual",
category: SecureLogger.session, level: .debug)
} else if previousCount != allPeers.count {
SecureLogger.log("✅ Updated peer list: \(allPeers.count) total peers",
category: SecureLogger.session, level: .debug)
}
}
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()
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ final class GeoRelayDirectory {
private let cacheFileName = "georelays_cache.csv" private let cacheFileName = "georelays_cache.csv"
private let lastFetchKey = "georelay.lastFetchAt" private let lastFetchKey = "georelay.lastFetchAt"
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")! private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h private let fetchInterval: TimeInterval = 60 * 60 * 24 // 24h
private init() { private init() {
// Load cached or bundled data synchronously // Load cached or bundled data synchronously
+128 -98
View File
@@ -1,7 +1,6 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import P256K import P256K
import Security
// Note: This file depends on Data extension from BinaryEncodingUtils.swift // Note: This file depends on Data extension from BinaryEncodingUtils.swift
// Make sure BinaryEncodingUtils.swift is included in the target // Make sure BinaryEncodingUtils.swift is included in the target
@@ -13,9 +12,8 @@ struct NostrProtocol {
enum EventKind: Int { enum EventKind: Int {
case metadata = 0 case metadata = 0
case textNote = 1 case textNote = 1
case dm = 14 // NIP-17 DM rumor kind
case seal = 13 // NIP-17 sealed event case seal = 13 // NIP-17 sealed event
case giftWrap = 1059 // NIP-59 gift wrap case giftWrap = 1059 // NIP-17 gift wrap
case ephemeralEvent = 20000 case ephemeralEvent = 20000
} }
@@ -32,7 +30,7 @@ struct NostrProtocol {
let rumor = NostrEvent( let rumor = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .dm, // NIP-17: DM rumor kind 14 kind: .textNote,
tags: [], tags: [],
content: content content: content
) )
@@ -122,8 +120,8 @@ struct NostrProtocol {
tags: tags, tags: tags,
content: content content: content
) )
let schnorrKey = try senderIdentity.schnorrSigningKey() let signingKey = try senderIdentity.signingKey()
return try event.sign(with: schnorrKey) return try event.sign(with: signingKey)
} }
// MARK: - Private Methods // MARK: - Private Methods
@@ -149,8 +147,9 @@ struct NostrProtocol {
content: encrypted content: encrypted
) )
// Sign the seal with the sender's Schnorr private key // Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
return try seal.sign(with: senderKey) let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: senderKey.dataRepresentation)
return try seal.sign(with: signingKey)
} }
private static func createGiftWrap( private static func createGiftWrap(
@@ -180,8 +179,9 @@ struct NostrProtocol {
content: encrypted content: encrypted
) )
// Sign the gift wrap with the wrap Schnorr private key // Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
return try giftWrap.sign(with: wrapKey) let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: wrapKey.dataRepresentation)
return try giftWrap.sign(with: signingKey)
} }
private static func unwrapGiftWrap( private static func unwrapGiftWrap(
@@ -227,7 +227,7 @@ struct NostrProtocol {
return try NostrEvent(from: rumorDict) return try NostrEvent(from: rumorDict)
} }
// MARK: - Encryption (NIP-44 v2) // MARK: - Encryption (NIP-44 style)
private static func encrypt( private static func encrypt(
plaintext: String, plaintext: String,
@@ -239,31 +239,33 @@ struct NostrProtocol {
throw NostrError.invalidPublicKey throw NostrError.invalidPublicKey
} }
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned) // Encrypting message
// Derive shared secret // Derive shared secret
let sharedSecret = try deriveSharedSecret( let sharedSecret = try deriveSharedSecret(
privateKey: senderKey, privateKey: senderKey,
publicKey: recipientPubkeyData publicKey: recipientPubkeyData
) )
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
let key = try deriveNIP44V2Key(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305 // Derived shared secret
var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
}
let pt = Data(plaintext.utf8) // Generate nonce
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) let nonce = AES.GCM.Nonce()
// v2: base64url(nonce24 || ciphertext || tag) // Encrypt
var combined = Data() let sealed = try AES.GCM.seal(
combined.append(nonce24) plaintext.data(using: .utf8)!,
combined.append(sealed.ciphertext) using: SymmetricKey(data: sharedSecret),
combined.append(sealed.tag) nonce: nonce
return "v2:" + base64URLEncode(combined) )
// 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( private static func decrypt(
@@ -271,45 +273,86 @@ struct NostrProtocol {
senderPubkey: String, senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey recipientKey: P256K.Schnorr.PrivateKey
) throws -> String { ) throws -> String {
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } // Decrypting message
let encoded = String(ciphertext.dropFirst(3))
guard let data = base64URLDecode(encoded), guard let data = Data(base64Encoded: ciphertext),
data.count > (24 + 16),
let senderPubkeyData = Data(hexString: senderPubkey) else { let senderPubkeyData = Data(hexString: senderPubkey) else {
SecureLogger.log("❌ Invalid ciphertext or sender pubkey format",
category: SecureLogger.session, level: .error)
throw NostrError.invalidCiphertext throw NostrError.invalidCiphertext
} }
let nonce24 = data.prefix(24) // Ciphertext data parsed
let rest = data.dropFirst(24)
let tag = rest.suffix(16)
let ct = rest.dropLast(16)
// Try decryption with even-Y then odd-Y when sender pubkey is x-only // Extract components
func attemptDecrypt(using pubKeyData: Data) throws -> Data { let nonceData = data.prefix(12)
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData) let ciphertextData = data.dropFirst(12).dropLast(16)
let key = try deriveNIP44V2Key(from: ss) let tagData = data.suffix(16)
return try XChaCha20Poly1305Compat.open(
ciphertext: Data(ct), // Components parsed
tag: Data(tag),
key: key, // Derive shared secret - try with default Y coordinate first
nonce24: Data(nonce24) 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
} }
// If 32 bytes (x-only) try both parities, otherwise single try guard let finalDecrypted = decrypted else {
if senderPubkeyData.count == 32 { throw NostrError.encryptionFailed
let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) {
return String(data: pt, encoding: .utf8) ?? ""
}
let odd = Data([0x03]) + senderPubkeyData
let pt = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
} else {
let pt = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
} }
return String(data: finalDecrypted, encoding: .utf8) ?? ""
} }
private static func deriveSharedSecret( private static func deriveSharedSecret(
@@ -369,8 +412,17 @@ struct NostrProtocol {
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// ECDH shared secret derived // ECDH shared secret derived
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key // Derive key using HKDF for NIP-44 v2
return sharedSecretData let derivedKey = HKDF<CryptoKit.SHA256>.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 // Direct version that doesn't try to add prefixes
@@ -400,8 +452,15 @@ struct NostrProtocol {
// Convert SharedSecret to Data // Convert SharedSecret to Data
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key // Derive key using HKDF for NIP-44 v2
return sharedSecretData let derivedKey = HKDF<CryptoKit.SHA256>.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 { private static func randomizedTimestamp() -> Date {
@@ -414,7 +473,7 @@ struct NostrProtocol {
// Log with explicit UTC and local time for debugging // Log with explicit UTC and local time for debugging
let formatter = DateFormatter() let formatter = DateFormatter()
// // Removed unnecessary date formatting operations
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeZone = TimeZone(abbreviation: "UTC") formatter.timeZone = TimeZone(abbreviation: "UTC")
@@ -470,16 +529,16 @@ struct NostrEvent: Codable {
self.sig = dict["sig"] as? String self.sig = dict["sig"] as? String
} }
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent { func sign(with key: P256K.Signing.PrivateKey) throws -> NostrEvent {
let (eventId, eventIdHash) = try calculateEventId() let (eventId, eventIdHash) = try calculateEventId()
// Sign with Schnorr (BIP-340) // Convert to Schnorr key for Nostr signing
let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: key.dataRepresentation)
// Sign with Schnorr
var messageBytes = [UInt8](eventIdHash) var messageBytes = [UInt8](eventIdHash)
var auxRand = [UInt8](repeating: 0, count: 32) var auxRand = [UInt8](repeating: 0, count: 32) // Zero auxiliary randomness for deterministic signing
_ = auxRand.withUnsafeMutableBytes { ptr in let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString() let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
@@ -522,32 +581,3 @@ enum NostrError: Error {
case signingFailed case signingFailed
case encryptionFailed case encryptionFailed
} }
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
private extension NostrProtocol {
static func base64URLEncode(_ data: Data) -> String {
return data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
static func base64URLDecode(_ s: String) -> Data? {
var str = s
let pad = (4 - (str.count % 4)) % 4
if pad > 0 { str += String(repeating: "=", count: pad) }
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
return Data(base64Encoded: str)
}
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(),
info: "nip44-v2".data(using: .utf8)!,
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }
}
}
+9 -9
View File
@@ -48,10 +48,10 @@ class NostrRelayManager: ObservableObject {
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
// Exponential backoff configuration // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds private let initialBackoffInterval: TimeInterval = 1.0 // Start with 1 second
private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds private let maxBackoffInterval: TimeInterval = 300.0 // Max 5 minutes
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier private let backoffMultiplier: Double = 2.0 // Double each time
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts private let maxReconnectAttempts = 10 // Stop after 10 attempts
// Reconnection timer // Reconnection timer
private var reconnectionTimer: Timer? private var reconnectionTimer: Timer?
@@ -289,7 +289,7 @@ class NostrRelayManager: ObservableObject {
// Only log non-gift-wrap events to reduce noise // Only log non-gift-wrap events to reduce noise
if event.kind != 1059 { if event.kind != 1059 {
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16)) relay=\(relayUrl)", SecureLogger.log("📥 Received Nostr event (kind: \(event.kind)) from relay: \(relayUrl)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
} }
@@ -321,11 +321,11 @@ class NostrRelayManager: ObservableObject {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given" let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
if success { if success {
_ = Self.pendingGiftWrapIDs.remove(eventId) _ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.log("Accepted id=\(eventId.prefix(16)) relay=\(relayUrl)", SecureLogger.log("Event accepted id=\(eventId.prefix(16))... by relay: \(relayUrl)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
} else { } else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", SecureLogger.log("📮 Event \(eventId.prefix(16))... rejected by relay: \(reason)",
category: SecureLogger.session, level: isGiftWrap ? .warning : .error) category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
} }
} }
@@ -353,7 +353,7 @@ class NostrRelayManager: ObservableObject {
let data = try encoder.encode(req) let data = try encoder.encode(req)
let message = String(data: data, encoding: .utf8) ?? "" let message = String(data: data, encoding: .utf8) ?? ""
SecureLogger.log("📤 Send kind=\(event.kind) id=\(event.id.prefix(16)) relay=\(relayUrl)", SecureLogger.log("📤 Sending Nostr event (kind: \(event.kind)) to relay: \(relayUrl)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
connection.send(.string(message)) { [weak self] error in connection.send(.string(message)) { [weak self] error in
@@ -568,7 +568,7 @@ struct NostrFilter: Encodable {
filter.kinds = [1059] // Gift wrap kind filter.kinds = [1059] // Gift wrap kind
filter.since = since?.timeIntervalSince1970.toInt() filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["p": [pubkey]] filter.tagFilters = ["p": [pubkey]]
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit filter.limit = 100 // Add a reasonable limit
return filter return filter
} }
-116
View File
@@ -1,116 +0,0 @@
import Foundation
import CryptoKit
/// Minimal XChaCha20-Poly1305 compatibility wrapper using CryptoKit's ChaChaPoly.
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
/// as per XChaCha20 construction.
enum XChaCha20Poly1305Compat {
struct SealBox {
let ciphertext: Data
let tag: Data
}
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
let nonce12 = derive12ByteNonce(from24: nonce24)
let chachaKey = SymmetricKey(data: subkey)
let nonce = try ChaChaPoly.Nonce(data: nonce12)
let sealed = try ChaChaPoly.seal(plaintext, using: chachaKey, nonce: nonce, authenticating: aad ?? Data())
return SealBox(ciphertext: sealed.ciphertext, tag: sealed.tag)
}
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
let nonce12 = derive12ByteNonce(from24: nonce24)
let chachaKey = SymmetricKey(data: subkey)
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
return try ChaChaPoly.open(box, using: chachaKey, authenticating: aad ?? Data())
}
// MARK: - Internals
private static func derive12ByteNonce(from24 nonce24: Data) -> Data {
// XChaCha20-Poly1305: 12-byte nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce
var out = Data(count: 12)
out.replaceSubrange(0..<4, with: [0, 0, 0, 0])
out.replaceSubrange(4..<12, with: nonce24.suffix(8))
return out
}
private static func hchacha20(key: Data, nonce16: Data) -> Data {
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
precondition(key.count == 32)
precondition(nonce16.count == 16)
// Constants "expand 32-byte k"
var state: [UInt32] = [
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
// key (8 words)
key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12),
key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28),
// nonce (4 words)
nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12)
]
// 20 rounds (10 double rounds)
for _ in 0..<10 {
// Column rounds
quarterRound(&state, 0, 4, 8, 12)
quarterRound(&state, 1, 5, 9, 13)
quarterRound(&state, 2, 6, 10, 14)
quarterRound(&state, 3, 7, 11, 15)
// Diagonal rounds
quarterRound(&state, 0, 5, 10, 15)
quarterRound(&state, 1, 6, 11, 12)
quarterRound(&state, 2, 7, 8, 13)
quarterRound(&state, 3, 4, 9, 14)
}
// Output subkey: state[0..3] and state[12..15]
var out = Data(count: 32)
out.storeLEWord(state[0], at: 0)
out.storeLEWord(state[1], at: 4)
out.storeLEWord(state[2], at: 8)
out.storeLEWord(state[3], at: 12)
out.storeLEWord(state[12], at: 16)
out.storeLEWord(state[13], at: 20)
out.storeLEWord(state[14], at: 24)
out.storeLEWord(state[15], at: 28)
return out
}
private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 16) | (s[d] >> 16)
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 12) | (s[b] >> 20)
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 8) | (s[d] >> 24)
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 7) | (s[b] >> 25)
}
}
private extension Data {
func loadLEWord(_ offset: Int) -> UInt32 {
let range = offset..<(offset+4)
let bytes = self[range]
return bytes.withUnsafeBytes { ptr -> UInt32 in
let b = ptr.bindMemory(to: UInt8.self)
return UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)
}
}
mutating func storeLEWord(_ value: UInt32, at offset: Int) {
let bytes: [UInt8] = [
UInt8(value & 0xff),
UInt8((value >> 8) & 0xff),
UInt8((value >> 16) & 0xff),
UInt8((value >> 24) & 0xff)
]
replaceSubrange(offset..<(offset+4), with: bytes)
}
}
+5 -8
View File
@@ -155,17 +155,12 @@ enum NoisePayloadType: UInt8 {
case privateMessage = 0x01 // Private chat message case privateMessage = 0x01 // Private chat message
case readReceipt = 0x02 // Message was read case readReceipt = 0x02 // Message was read
case delivered = 0x03 // Message was delivered case delivered = 0x03 // Message was delivered
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
var description: String { var description: String {
switch self { switch self {
case .privateMessage: return "privateMessage" case .privateMessage: return "privateMessage"
case .readReceipt: return "readReceipt" case .readReceipt: return "readReceipt"
case .delivered: return "delivered" case .delivered: return "delivered"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
} }
} }
} }
@@ -181,7 +176,8 @@ enum LazyHandshakeState {
case failed(Error) // Handshake failed case failed(Error) // Handshake failed
} }
// // MARK: - Special Recipients (removed)
// Previously defined broadcast identifiers were unused; removed for simplicity.
// MARK: - Core Protocol Structures // MARK: - Core Protocol Structures
@@ -267,7 +263,8 @@ struct BitchatPacket: Codable {
} }
} }
// // MARK: - Delivery Acknowledgments (removed)
// Legacy DeliveryAck structures are no longer used; delivery status flows via Noise payloads.
// MARK: - Read Receipts // MARK: - Read Receipts
@@ -359,7 +356,7 @@ struct ReadReceipt: Codable {
} }
// // PeerIdentityBinding removed (unused).
// MARK: - Delivery Status // MARK: - Delivery Status
+2 -2
View File
@@ -14,7 +14,7 @@ class AutocompleteService {
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: []) private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
private let commands = [ private let commands = [
"/msg", "/who", "/clear", "/msg", "/who", "/clear", "/help",
"/hug", "/slap", "/fav", "/unfav", "/hug", "/slap", "/fav", "/unfav",
"/block", "/unblock" "/block", "/unblock"
] ]
@@ -95,7 +95,7 @@ class AutocompleteService {
private func needsArgument(command: String) -> Bool { private func needsArgument(command: String) -> Bool {
switch command { switch command {
case "/who", "/clear": case "/who", "/clear", "/help":
return false return false
default: default:
return true return true
+92 -322
View File
@@ -22,12 +22,12 @@ final class BLEService: NSObject {
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
// Default per-fragment chunk size when link limits are unknown // Default per-fragment chunk size when link limits are unknown
private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize private let defaultFragmentSize = 469 // ~512 MTU minus protocol overhead
private let maxMessageLength = InputValidator.Limits.maxMessageLength private let maxMessageLength = 10_000
private let messageTTL: UInt8 = TransportConfig.messageTTLDefault private let messageTTL: UInt8 = 7
// Flood/battery controls // Flood/battery controls
private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies private let maxInFlightAssemblies = 128 // cap concurrent fragment assemblies
private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays private let highDegreeThreshold = 6 // for adaptive TTL/probabilistic relays
// MARK: - Core State (5 Essential Collections) // MARK: - Core State (5 Essential Collections)
@@ -70,7 +70,7 @@ final class BLEService: NSObject {
// Simple announce throttling // Simple announce throttling
private var lastAnnounceSent = Date.distantPast private var lastAnnounceSent = Date.distantPast
private let announceMinInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval private let announceMinInterval: TimeInterval = 1.0
// Application state tracking (thread-safe) // Application state tracking (thread-safe)
#if os(iOS) #if os(iOS)
@@ -113,24 +113,14 @@ final class BLEService: NSObject {
// Track short-lived traffic bursts to adapt announces/scanning under load // Track short-lived traffic bursts to adapt announces/scanning under load
private var recentPacketTimestamps: [Date] = [] private var recentPacketTimestamps: [Date] = []
// Ingress link tracking for last-hop suppression
private enum LinkID: Hashable {
case peripheral(String)
case central(String)
}
private var ingressByMessageID: [String: (link: LinkID, timestamp: Date)] = [:]
// Backpressure-aware write queue per peripheral
private var pendingPeripheralWrites: [String: [Data]] = [:]
// MARK: - Maintenance Timer // MARK: - Maintenance Timer
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
private var maintenanceCounter = 0 // Track maintenance cycles private var maintenanceCounter = 0 // Track maintenance cycles
// MARK: - Connection budget & scheduling (central role) // MARK: - Connection budget & scheduling (central role)
private let maxCentralLinks = TransportConfig.bleMaxCentralLinks private let maxCentralLinks = 6
private let connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval private let connectRateLimitInterval: TimeInterval = 0.5
private var lastGlobalConnectAttempt: Date = .distantPast private var lastGlobalConnectAttempt: Date = .distantPast
private struct ConnectionCandidate { private struct ConnectionCandidate {
let peripheral: CBPeripheral let peripheral: CBPeripheral
@@ -142,13 +132,13 @@ final class BLEService: NSObject {
private var connectionCandidates: [ConnectionCandidate] = [] private var connectionCandidates: [ConnectionCandidate] = []
private var failureCounts: [String: Int] = [:] // Peripheral UUID -> failures private var failureCounts: [String: Int] = [:] // Peripheral UUID -> failures
private var lastIsolatedAt: Date? = nil private var lastIsolatedAt: Date? = nil
private var dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault private var dynamicRSSIThreshold: Int = -90
// MARK: - Adaptive scanning duty-cycle // MARK: - Adaptive scanning duty-cycle
private var scanDutyTimer: DispatchSourceTimer? private var scanDutyTimer: DispatchSourceTimer?
private var dutyEnabled: Bool = true private var dutyEnabled: Bool = true
private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration private var dutyOnDuration: TimeInterval = 5
private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration private var dutyOffDuration: TimeInterval = 10
private var dutyActive: Bool = false private var dutyActive: Bool = false
// MARK: - Link capability snapshots (thread-safe via bleQueue) // MARK: - Link capability snapshots (thread-safe via bleQueue)
@@ -167,88 +157,6 @@ final class BLEService: NSObject {
} }
} }
// MARK: - Helpers: IDs, selection, and write backpressure
private func makeMessageID(for packet: BitchatPacket) -> String {
let senderID = packet.senderID.hexEncodedString()
return "\(senderID)-\(packet.timestamp)-\(packet.type)"
}
private func subsetSizeForFanout(_ n: Int) -> Int {
guard n > 0 else { return 0 }
if n <= 2 { return n }
// approx ceil(log2(n)) + 1 without floating point
var v = n - 1
var bits = 0
while v > 0 { v >>= 1; bits += 1 }
return min(n, max(1, bits + 1))
}
private func selectDeterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
guard k > 0 && ids.count > k else { return Set(ids) }
// Stable order by SHA256(seed || "::" || id)
var scored: [(score: [UInt8], id: String)] = []
for id in ids {
let msg = (seed + "::" + id).data(using: .utf8) ?? Data()
let digest = Array(SHA256.hash(data: msg))
scored.append((digest, id))
}
scored.sort { a, b in
for i in 0..<min(a.score.count, b.score.count) {
if a.score[i] != b.score[i] { return a.score[i] < b.score[i] }
}
return a.id < b.id
}
return Set(scored.prefix(k).map { $0.id })
}
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
// BLE operations run on bleQueue; keep queue affinity
bleQueue.async { [weak self] in
guard let self = self else { return }
let uuid = peripheral.identifier.uuidString
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} else {
self.collectionsQueue.async(flags: .barrier) {
self.pendingPeripheralWrites[uuid, default: []].append(data)
}
}
}
}
private func drainPendingWrites(for peripheral: CBPeripheral) {
let uuid = peripheral.identifier.uuidString
bleQueue.async { [weak self] in
guard let self = self else { return }
guard let state = self.peripherals[uuid], let ch = state.characteristic else { return }
var queueCopy: [Data] = []
self.collectionsQueue.sync {
queueCopy = self.pendingPeripheralWrites[uuid] ?? []
}
guard !queueCopy.isEmpty else { return }
var sent = 0
for item in queueCopy {
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(item, for: ch, type: .withoutResponse)
sent += 1
} else {
break
}
}
if sent > 0 {
self.collectionsQueue.async(flags: .barrier) {
var q = self.pendingPeripheralWrites[uuid] ?? []
if sent <= q.count {
q.removeFirst(sent)
} else {
q.removeAll()
}
self.pendingPeripheralWrites[uuid] = q.isEmpty ? nil : q
}
}
}
}
// MARK: - Peer snapshots publisher (non-UI convenience) // MARK: - Peer snapshots publisher (non-UI convenience)
private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>() private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
@@ -257,15 +165,20 @@ final class BLEService: NSObject {
func currentPeerSnapshots() -> [TransportPeerSnapshot] { func currentPeerSnapshots() -> [TransportPeerSnapshot] {
collectionsQueue.sync { collectionsQueue.sync {
let snapshot = Array(peers.values) // Compute nickname collision counts for connected peers
let resolvedNames = PeerDisplayNameResolver.resolve( let connected = peers.values.filter { $0.isConnected }
snapshot.map { ($0.id, $0.nickname, $0.isConnected) }, var counts: [String: Int] = [:]
selfNickname: myNickname for p in connected { counts[p.nickname, default: 0] += 1 }
) // Include our own nickname in collision counts so remote matching ours gets suffixed
return snapshot.map { info in counts[myNickname, default: 0] += 1
TransportPeerSnapshot( return peers.values.map { info in
var display = info.nickname
if info.isConnected, (counts[info.nickname] ?? 0) > 1 {
display += "#" + String(info.id.prefix(4))
}
return TransportPeerSnapshot(
id: info.id, id: info.id,
nickname: resolvedNames[info.id] ?? info.nickname, nickname: display,
isConnected: info.isConnected, isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey, noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen lastSeen: info.lastSeen
@@ -346,9 +259,7 @@ final class BLEService: NSObject {
// Single maintenance timer for all periodic tasks (dispatch-based for determinism) // Single maintenance timer for all periodic tasks (dispatch-based for determinism)
let timer = DispatchSource.makeTimerSource(queue: bleQueue) let timer = DispatchSource.makeTimerSource(queue: bleQueue)
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval, timer.schedule(deadline: .now() + 10.0, repeating: 10.0, leeway: .seconds(1))
repeating: TransportConfig.bleMaintenanceInterval,
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
timer.setEventHandler { [weak self] in timer.setEventHandler { [weak self] in
self?.performMaintenance() self?.performMaintenance()
} }
@@ -430,7 +341,7 @@ final class BLEService: NSObject {
// Send initial announce after services are ready // Send initial announce after services are ready
// Use longer delay to avoid conflicts with other announces // Use longer delay to avoid conflicts with other announces
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in messageQueue.asyncAfter(deadline: .now() + 2.0) { [weak self] in
self?.sendAnnounce(forceSend: true) self?.sendAnnounce(forceSend: true)
} }
} }
@@ -458,7 +369,7 @@ final class BLEService: NSObject {
// Send to peripherals we're connected to as central // Send to peripherals we're connected to as central
for state in peripherals.values where state.isConnected { for state in peripherals.values where state.isConnected {
if let characteristic = state.characteristic { if let characteristic = state.characteristic {
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic) state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} }
} }
@@ -469,7 +380,7 @@ final class BLEService: NSObject {
} }
// Give leave message a moment to send // Give leave message a moment to send
Thread.sleep(forTimeInterval: TransportConfig.bleThreadSleepWriteShortDelaySeconds) Thread.sleep(forTimeInterval: 0.05)
// Clear pending notifications // Clear pending notifications
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
@@ -511,9 +422,22 @@ final class BLEService: NSObject {
func getPeerNicknames() -> [String: String] { func getPeerNicknames() -> [String: String] {
return collectionsQueue.sync { return collectionsQueue.sync {
// Only connected peers
let connected = peers.filter { $0.value.isConnected } let connected = peers.filter { $0.value.isConnected }
let tuples = connected.map { ($0.key, $0.value.nickname, true) } // Count collisions by nickname (include our own nickname)
return PeerDisplayNameResolver.resolve(tuples, selfNickname: myNickname) var counts: [String: Int] = [:]
for (_, info) in connected { counts[info.nickname, default: 0] += 1 }
counts[myNickname, default: 0] += 1
// Build map with suffix for collisions
var result: [String: String] = [:]
for (id, info) in connected {
var name = info.nickname
if (counts[info.nickname] ?? 0) > 1 {
name += "#" + String(id.prefix(4))
}
result[id] = name
}
return result
} }
} }
@@ -585,50 +509,10 @@ final class BLEService: NSObject {
sendAnnounce() sendAnnounce()
} }
// MARK: - QR Verification over Noise
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
sendNoisePayload(payload, to: peerID)
}
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {
guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return }
sendNoisePayload(payload, to: peerID)
}
private func sendNoisePayload(_ typedPayload: Data, to peerID: String) {
guard noiseService.hasSession(with: peerID) else {
// Lazy-handshake path: queue? For now, initiate handshake and drop
initiateNoiseHandshake(with: peerID)
return
}
do {
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted,
signature: nil,
ttl: messageTTL
)
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet)
} else {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
} catch {
SecureLogger.log("Failed to send verification payload: \(error)", category: SecureLogger.noise, level: .error)
}
}
func getPeerFingerprint(_ peerID: String) -> String? { func getPeerFingerprint(_ peerID: String) -> String? {
return collectionsQueue.sync { return collectionsQueue.sync {
if let publicKey = peers[peerID]?.noisePublicKey { if let publicKey = peers[peerID]?.noisePublicKey {
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key) return publicKey.hexEncodedString()
let hash = SHA256.hash(data: publicKey)
return hash.map { String(format: "%02x", $0) }.joined()
} }
return nil return nil
} }
@@ -714,7 +598,7 @@ final class BLEService: NSObject {
} }
} }
// // Removed unused getPeers(): use getPeerNicknames() from Transport
// MARK: - Private Message Handling // MARK: - Private Message Handling
@@ -953,7 +837,7 @@ final class BLEService: NSObject {
let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }), let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }),
state.isConnected, state.isConnected,
let characteristic = state.characteristic { let characteristic = state.characteristic {
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic) state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
sentEncrypted = true sentEncrypted = true
} }
@@ -965,7 +849,7 @@ final class BLEService: NSObject {
if success { sentEncrypted = true; break } if success { sentEncrypted = true; break }
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount { if self.pendingNotifications.count < 20 {
self.pendingNotifications.append((data: data, centrals: [central])) self.pendingNotifications.append((data: data, centrals: [central]))
SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)", category: SecureLogger.session, level: .debug) SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)", category: SecureLogger.session, level: .debug)
} }
@@ -984,10 +868,6 @@ final class BLEService: NSObject {
} }
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: String?) { private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: String?) {
// Determine last-hop link for this message to avoid echoing back
let messageID = makeMessageID(for: packet)
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
let states = snapshotPeripheralStates() let states = snapshotPeripheralStates()
var minCentralWriteLen: Int? var minCentralWriteLen: Int?
for s in states where s.isConnected { for s in states where s.isConnected {
@@ -1012,54 +892,15 @@ final class BLEService: NSObject {
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer) sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)
return return
} }
// Build link lists and apply K-of-N fanout for broadcasts; always exclude ingress link // Writes to connected peripherals
let connectedPeripheralIDs: [String] = states.filter { $0.isConnected }.map { $0.peripheral.identifier.uuidString }
let subscribedCentrals: [CBCentral]
var centralIDs: [String] = []
if let _ = characteristic {
let (centrals, _) = snapshotSubscribedCentrals()
subscribedCentrals = centrals
centralIDs = centrals.map { $0.identifier.uuidString }
} else {
subscribedCentrals = []
}
// Exclude ingress link
var allowedPeripheralIDs = connectedPeripheralIDs
var allowedCentralIDs = centralIDs
if let ingress = ingressLink {
switch ingress {
case .peripheral(let id):
allowedPeripheralIDs.removeAll { $0 == id }
case .central(let id):
allowedCentralIDs.removeAll { $0 == id }
}
}
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
var selectedCentralIDs = Set(allowedCentralIDs)
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue {
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
let kc = subsetSizeForFanout(allowedCentralIDs.count)
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID)
}
// Writes to selected connected peripherals
for s in states where s.isConnected { for s in states where s.isConnected {
let pid = s.peripheral.identifier.uuidString
guard selectedPeripheralIDs.contains(pid) else { continue }
if let ch = s.characteristic { if let ch = s.characteristic {
writeOrEnqueue(data, to: s.peripheral, characteristic: ch) s.peripheral.writeValue(data, for: ch, type: .withoutResponse)
} }
} }
// Notify selected subscribed centrals // Notify all subscribed centrals
if let ch = characteristic { if let ch = characteristic {
let targets = subscribedCentrals.filter { selectedCentralIDs.contains($0.identifier.uuidString) } _ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil)
if !targets.isEmpty {
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets)
}
} }
} }
@@ -1073,7 +914,7 @@ final class BLEService: NSObject {
// Fire-and-forget principle: always use .withoutResponse for speed // Fire-and-forget principle: always use .withoutResponse for speed
// CoreBluetooth will handle fragmentation at L2CAP layer // CoreBluetooth will handle fragmentation at L2CAP layer
writeOrEnqueue(data, to: peripheral, characteristic: characteristic) peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} }
// MARK: - Fragmentation (Required for messages > BLE MTU) // MARK: - Fragmentation (Required for messages > BLE MTU)
@@ -1096,7 +937,7 @@ final class BLEService: NSObject {
guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return } guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }
if c.isScanning { c.stopScan() } if c.isScanning { c.stopScan() }
// Resume scanning after we expect last fragment to be sent // Resume scanning after we expect last fragment to be sent
let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs) // ~8ms per fragment let expectedMs = min(2000, totalFragments * 8) // ~8ms per fragment
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in
self?.startScanning() self?.startScanning()
} }
@@ -1127,7 +968,7 @@ final class BLEService: NSObject {
ttl: packet.ttl ttl: packet.ttl
) )
// Pace fragments with small jitter to avoid bursts // Pace fragments with small jitter to avoid bursts
let delayMs = index * TransportConfig.bleFragmentSpacingMs // ~6ms spacing per fragment let delayMs = index * 6 // ~6ms spacing per fragment
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
self?.broadcastPacket(fragmentPacket) self?.broadcastPacket(fragmentPacket)
} }
@@ -1239,10 +1080,10 @@ final class BLEService: NSObject {
guard let self = self else { return } guard let self = self else { return }
let now = Date() let now = Date()
self.recentPacketTimestamps.append(now) self.recentPacketTimestamps.append(now)
// keep last N timestamps within window // keep last 100 timestamps within 30s window
let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds) let cutoff = now.addingTimeInterval(-30)
if self.recentPacketTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount { if self.recentPacketTimestamps.count > 100 {
self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount) self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - 100)
} }
self.recentPacketTimestamps.removeAll { $0 < cutoff } self.recentPacketTimestamps.removeAll { $0 < cutoff }
} }
@@ -1281,7 +1122,6 @@ final class BLEService: NSObject {
ttl: packet.ttl, ttl: packet.ttl,
senderIsSelf: senderID == myPeerID, senderIsSelf: senderID == myPeerID,
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue, isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil),
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue, isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
degree: degree, degree: degree,
@@ -1543,16 +1383,6 @@ final class BLEService: NSObject {
notifyUI { [weak self] in notifyUI { [weak self] in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts) self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
} }
case .verifyChallenge:
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
}
case .verifyResponse:
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
}
default: default:
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning) SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
} }
@@ -1606,7 +1436,7 @@ final class BLEService: NSObject {
let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent) let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent)
// Even forced sends should respect a minimum interval to avoid overwhelming BLE // Even forced sends should respect a minimum interval to avoid overwhelming BLE
let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval let minInterval = forceSend ? 0.2 : announceMinInterval
if timeSinceLastAnnounce < minInterval { if timeSinceLastAnnounce < minInterval {
// Skipping announce (rate limited) // Skipping announce (rate limited)
@@ -1737,14 +1567,12 @@ final class BLEService: NSObject {
let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
let elapsed = now.timeIntervalSince(lastAnnounceSent) let elapsed = now.timeIntervalSince(lastAnnounceSent)
if connectedCount == 0 { if connectedCount == 0 {
// Discovery mode: keep frequent announces // Discovery mode: keep frequent announces (~10s)
if elapsed >= TransportConfig.bleAnnounceIntervalSeconds { sendAnnounce(forceSend: true) } if elapsed >= 10.0 { sendAnnounce(forceSend: true) }
} else { } else {
// Connected mode: announce less often; much less in dense networks // Connected mode: announce less often; much less in dense networks
let base = connectedCount >= TransportConfig.bleHighDegreeThreshold ? let base = connectedCount >= 6 ? 90.0 : 45.0
TransportConfig.bleConnectedAnnounceBaseSecondsDense : TransportConfig.bleConnectedAnnounceBaseSecondsSparse let jitter = connectedCount >= 6 ? 20.0 : 7.5
let jitter = connectedCount >= TransportConfig.bleHighDegreeThreshold ?
TransportConfig.bleConnectedAnnounceJitterDense : TransportConfig.bleConnectedAnnounceJitterSparse
let target = base + Double.random(in: -jitter...jitter) let target = base + Double.random(in: -jitter...jitter)
if elapsed >= target { sendAnnounce(forceSend: true) } if elapsed >= target { sendAnnounce(forceSend: true) }
} }
@@ -1785,7 +1613,7 @@ final class BLEService: NSObject {
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
for (peerID, peer) in peers { for (peerID, peer) in peers {
if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > TransportConfig.blePeerInactivityTimeoutSeconds { if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > 20 {
// Check if we still have an active BLE connection to this peer // Check if we still have an active BLE connection to this peer
let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil && let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil &&
peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true
@@ -1825,9 +1653,9 @@ final class BLEService: NSObject {
// Clean old processed messages efficiently // Clean old processed messages efficiently
messageDeduplicator.cleanup() messageDeduplicator.cleanup()
// Clean old fragments (> configured seconds old) // Clean old fragments (> 30 seconds old)
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds) let cutoff = now.addingTimeInterval(-30)
let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key } let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key }
for fragmentID in oldFragments { for fragmentID in oldFragments {
incomingFragments.removeValue(forKey: fragmentID) incomingFragments.removeValue(forKey: fragmentID)
@@ -1835,8 +1663,8 @@ final class BLEService: NSObject {
} }
} }
// Clean old connection timeout backoff entries (> window) // Clean old connection timeout backoff entries (> 2 minutes)
let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds) let timeoutCutoff = now.addingTimeInterval(-120)
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff } recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff }
// Clean up stale scheduled relays that somehow persisted (> 2s) // Clean up stale scheduled relays that somehow persisted (> 2s)
@@ -1849,15 +1677,6 @@ final class BLEService: NSObject {
} }
} }
} }
// Clean ingress link records older than configured seconds
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds)
if !self.ingressByMessageID.isEmpty {
self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff }
}
}
} }
private func updateScanningDutyCycle(connectedCount: Int) { private func updateScanningDutyCycle(connectedCount: Int) {
@@ -1877,12 +1696,12 @@ final class BLEService: NSObject {
if !central.isScanning { startScanning() } if !central.isScanning { startScanning() }
dutyActive = true dutyActive = true
// Adjust duty cycle under dense networks to save battery // Adjust duty cycle under dense networks to save battery
if connectedCount >= TransportConfig.bleHighDegreeThreshold { if connectedCount >= 6 {
dutyOnDuration = TransportConfig.bleDutyOnDurationDense dutyOnDuration = 3
dutyOffDuration = TransportConfig.bleDutyOffDurationDense dutyOffDuration = 15
} else { } else {
dutyOnDuration = TransportConfig.bleDutyOnDuration dutyOnDuration = 5
dutyOffDuration = TransportConfig.bleDutyOffDuration dutyOffDuration = 10
} }
t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
t.setEventHandler { [weak self] in t.setEventHandler { [weak self] in
@@ -1916,25 +1735,25 @@ final class BLEService: NSObject {
if lastIsolatedAt == nil { lastIsolatedAt = Date() } if lastIsolatedAt == nil { lastIsolatedAt = Date() }
let iso = lastIsolatedAt ?? Date() let iso = lastIsolatedAt ?? Date()
let elapsed = Date().timeIntervalSince(iso) let elapsed = Date().timeIntervalSince(iso)
if elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds { if elapsed > 60 {
dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedRelaxed dynamicRSSIThreshold = -92
} else { } else {
dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedBase dynamicRSSIThreshold = -90
} }
return return
} }
lastIsolatedAt = nil lastIsolatedAt = nil
// Base threshold when connected // Base threshold when connected
var threshold = TransportConfig.bleDynamicRSSIThresholdDefault var threshold = -90
// If we're at budget or queue is large, prefer closer peers // If we're at budget or queue is large, prefer closer peers
let linkCount = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count let linkCount = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
if linkCount >= maxCentralLinks || connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax { if linkCount >= maxCentralLinks || connectionCandidates.count > 20 {
threshold = TransportConfig.bleRSSIConnectedThreshold threshold = -85
} }
// If we have many recent timeouts, raise further // If we have many recent timeouts, raise further
let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < TransportConfig.bleRecentTimeoutWindowSeconds }.count let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < 60 }.count
if recentTimeouts >= TransportConfig.bleRecentTimeoutCountThreshold { if recentTimeouts >= 3 {
threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold) threshold = max(threshold, -80)
} }
dynamicRSSIThreshold = threshold dynamicRSSIThreshold = threshold
} }
@@ -1988,9 +1807,7 @@ extension BLEService: CBCentralManagerDelegate {
if a.rssi != b.rssi { return a.rssi > b.rssi } if a.rssi != b.rssi { return a.rssi > b.rssi }
return a.discoveredAt < b.discoveredAt return a.discoveredAt < b.discoveredAt
} }
if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax { if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) }
connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax)
}
return return
} }
@@ -2004,9 +1821,7 @@ extension BLEService: CBCentralManagerDelegate {
if a.rssi != b.rssi { return a.rssi > b.rssi } if a.rssi != b.rssi { return a.rssi > b.rssi }
return a.discoveredAt < b.discoveredAt return a.discoveredAt < b.discoveredAt
} }
if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax { if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) }
connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax)
}
return return
} }
@@ -2083,7 +1898,7 @@ extension BLEService: CBCentralManagerDelegate {
// Set a timeout for the connection attempt (slightly longer for reliability) // Set a timeout for the connection attempt (slightly longer for reliability)
// Use BLE queue to mutate BLE-related state consistently // Use BLE queue to mutate BLE-related state consistently
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in bleQueue.asyncAfter(deadline: .now() + 8.0) { [weak self] in
guard let self = self, guard let self = self,
let state = self.peripherals[peripheralID], let state = self.peripherals[peripheralID],
state.isConnecting && !state.isConnected else { return } state.isConnecting && !state.isConnected else { return }
@@ -2155,7 +1970,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
if centralManager?.state == .poweredOn { if centralManager?.state == .poweredOn {
// Stop and restart scanning to ensure we get fresh discovery events // Stop and restart scanning to ensure we get fresh discovery events
centralManager?.stopScan() centralManager?.stopScan()
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in bleQueue.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.startScanning() self?.startScanning()
} }
} }
@@ -2252,27 +2067,6 @@ extension BLEService {
// Test-only helper to inject packets into the receive pipeline // Test-only helper to inject packets into the receive pipeline
extension BLEService { extension BLEService {
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: String) { func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: String) {
// Ensure the synthetic peer is known and marked verified for public-message tests
let normalizedID = packet.senderID.hexEncodedString()
collectionsQueue.sync(flags: .barrier) {
if peers[normalizedID] == nil {
peers[normalizedID] = PeerInfo(
id: normalizedID,
nickname: "TestPeer_\(fromPeerID.prefix(4))",
isConnected: true,
noisePublicKey: packet.senderID,
signingPublicKey: nil,
isVerifiedNickname: true,
lastSeen: Date()
)
} else {
var p = peers[normalizedID]!
p.isConnected = true
p.isVerifiedNickname = true
p.lastSeen = Date()
peers[normalizedID] = p
}
}
if DispatchQueue.getSpecific(key: messageQueueKey) != nil { if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
handleReceivedPacket(packet, from: fromPeerID) handleReceivedPacket(packet, from: fromPeerID)
} else { } else {
@@ -2353,7 +2147,7 @@ extension BLEService: CBPeripheralDelegate {
SecureLogger.log("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .debug) SecureLogger.log("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .debug)
// Send announce after subscription is confirmed (force send for new connection) // Send announce after subscription is confirmed (force send for new connection)
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in messageQueue.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.sendAnnounce(forceSend: true) self?.sendAnnounce(forceSend: true)
} }
} else { } else {
@@ -2376,9 +2170,7 @@ extension BLEService: CBPeripheralDelegate {
// Process directly on main thread to avoid deadlocks (matches original implementation) // Process directly on main thread to avoid deadlocks (matches original implementation)
guard let packet = BinaryProtocol.decode(data) else { guard let packet = BinaryProtocol.decode(data) else {
// Avoid dumping entire payload; log size and short prefix for diagnostics SecureLogger.log("❌ Failed to decode notification packet, full data: \(data.map { String(format: "%02x", $0) }.joined(separator: " "))",
let prefix = data.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.log("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))",
category: SecureLogger.session, level: .error) category: SecureLogger.session, level: .error)
return return
} }
@@ -2403,22 +2195,12 @@ extension BLEService: CBPeripheralDelegate {
peerToPeripheralUUID[senderID] = peripheralUUID peerToPeripheralUUID[senderID] = peripheralUUID
// Mapping update - direct announce from peer // Mapping update - direct announce from peer
} }
// Record ingress link for last-hop suppression and process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
}
// Process the announce packet regardless of whether we updated the mapping // Process the announce packet regardless of whether we updated the mapping
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: senderID)
} else { } else {
// For non-announce packets, DO NOT update mappings // For non-announce packets, DO NOT update mappings
// These could be relayed packets from other peers // These could be relayed packets from other peers
// Always use the packet's original senderID // Always use the packet's original senderID
// Record ingress link for last-hop suppression and process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
}
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: senderID)
} }
} }
@@ -2433,8 +2215,7 @@ extension BLEService: CBPeripheralDelegate {
} }
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
// Resume queued writes for this peripheral // Suppress verbose ready logs
drainPendingWrites(for: peripheral)
} }
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
@@ -2515,7 +2296,7 @@ extension BLEService: CBPeripheralManagerDelegate {
SecureLogger.log("📥 Central subscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug) SecureLogger.log("📥 Central subscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug)
subscribedCentrals.append(central) subscribedCentrals.append(central)
// Send announce to the newly subscribed central after a small delay to avoid overwhelming // Send announce to the newly subscribed central after a small delay to avoid overwhelming
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in messageQueue.asyncAfter(deadline: .now() + 0.4) { [weak self] in
self?.sendAnnounce(forceSend: true) self?.sendAnnounce(forceSend: true)
} }
} }
@@ -2658,30 +2439,19 @@ extension BLEService: CBPeripheralManagerDelegate {
} }
if packet.type == MessageType.announce.rawValue { if packet.type == MessageType.announce.rawValue {
if packet.ttl == messageTTL { centralToPeerID[centralUUID] = senderID } if packet.ttl == messageTTL { centralToPeerID[centralUUID] = senderID }
// Record ingress link for last-hop suppression then process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
}
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: senderID)
} else { } else {
// Record ingress link for last-hop suppression then process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
}
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: senderID)
} }
} else { } else {
// If buffer grows suspiciously large, reset to avoid memory leak // If buffer grows suspiciously large, reset to avoid memory leak
if combined.count > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety if combined.count > 1_000_000 { // 1MB cap for safety
pendingWriteBuffers.removeValue(forKey: centralUUID) pendingWriteBuffers.removeValue(forKey: centralUUID)
SecureLogger.log("⚠️ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: SecureLogger.session, level: .warning) SecureLogger.log("⚠️ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: SecureLogger.session, level: .warning)
} }
// If this was a single short write and still failed, log the raw chunk for debugging // If this was a single short write and still failed, log the raw chunk for debugging
if !hasMultiple, let only = sorted.first, let raw = only.value { if !hasMultiple, let only = sorted.first, let raw = only.value {
let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") SecureLogger.log("❌ Failed to decode packet from central, full data: \(raw.map { String(format: "%02x", $0) }.joined(separator: " "))", category: SecureLogger.session, level: .error)
SecureLogger.log("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: SecureLogger.session, level: .error)
} }
} }
} }
+7 -34
View File
@@ -33,15 +33,6 @@ class CommandProcessor {
guard let cmd = parts.first else { return .error(message: "Invalid command") } guard let cmd = parts.first else { return .error(message: "Invalid command") }
let args = parts.count > 1 ? String(parts[1]) : "" let args = parts.count > 1 ? String(parts[1]) : ""
// Geohash context: disable favoriting in public geohash or GeoDM
let inGeoPublic: Bool = {
switch LocationChannelManager.shared.selectedChannel {
case .mesh: return false
case .location: return true
}
}()
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
switch cmd { switch cmd {
case "/m", "/msg": case "/m", "/msg":
return handleMessage(args) return handleMessage(args)
@@ -58,14 +49,11 @@ class CommandProcessor {
case "/unblock": case "/unblock":
return handleUnblock(args) return handleUnblock(args)
case "/fav": case "/fav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: true) return handleFavorite(args, add: true)
case "/unfav": case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false) return handleFavorite(args, add: false)
//
case "/help", "/h": case "/help", "/h":
return .error(message: "unknown command: \(cmd)") return handleHelp()
default: default:
return .error(message: "unknown command: \(cmd)") return .error(message: "unknown command: \(cmd)")
} }
@@ -97,34 +85,19 @@ class CommandProcessor {
} }
private func handleWho() -> CommandResult { private func handleWho() -> CommandResult {
// Show geohash participants when in a geohash channel; otherwise mesh peers guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
switch LocationChannelManager.shared.selectedChannel { return .success(message: "no one else is online right now")
case .location(let ch):
// Geohash context: show visible geohash participants (exclude self)
guard let vm = chatViewModel else { return .success(message: "nobody around") }
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
let people = vm.visibleGeohashPeople().filter { person in
if let me = myHex { return person.id.lowercased() != me }
return true
}
let names = people.map { $0.displayName }
if names.isEmpty { return .success(message: "no one else is online right now") }
return .success(message: "online: " + names.sorted().joined(separator: ", "))
case .mesh:
// Mesh context: show connected peer nicknames
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
return .success(message: "no one else is online right now")
}
let onlineList = peers.values.sorted().joined(separator: ", ")
return .success(message: "online: \(onlineList)")
} }
let onlineList = peers.values.sorted().joined(separator: ", ")
return .success(message: "online: \(onlineList)")
} }
private func handleClear() -> CommandResult { private func handleClear() -> CommandResult {
if let peerID = chatViewModel?.selectedPrivateChatPeer { if let peerID = chatViewModel?.selectedPrivateChatPeer {
chatViewModel?.privateChats[peerID]?.removeAll() chatViewModel?.privateChats[peerID]?.removeAll()
} else { } else {
chatViewModel?.clearCurrentPublicTimeline() chatViewModel?.messages.removeAll()
} }
return .handled return .handled
} }
+11 -10
View File
@@ -1,8 +1,8 @@
import Foundation import Foundation
import Combine
#if os(iOS) || os(macOS) #if os(iOS)
import CoreLocation import CoreLocation
import Combine
/// Manages location permissions, one-shot location retrieval, and computing geohash channels. /// Manages location permissions, one-shot location retrieval, and computing geohash channels.
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor. /// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
@@ -39,7 +39,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
super.init() super.init()
cl.delegate = self cl.delegate = self
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously cl.distanceFilter = 1000 // meters; we're not tracking continuously
// Load selection // Load selection
if let data = UserDefaults.standard.data(forKey: userDefaultsKey), if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) { let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
@@ -55,7 +55,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
teleported = teleportedSet.contains(ch.geohash) teleported = teleportedSet.contains(ch.geohash)
} }
let status: CLAuthorizationStatus let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) { if #available(iOS 14.0, *) {
status = cl.authorizationStatus status = cl.authorizationStatus
} else { } else {
status = CLLocationManager.authorizationStatus() status = CLLocationManager.authorizationStatus()
@@ -66,7 +66,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
// MARK: - Public API // MARK: - Public API
func enableLocationChannels() { func enableLocationChannels() {
let status: CLAuthorizationStatus let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) { if #available(iOS 14.0, *) {
status = cl.authorizationStatus status = cl.authorizationStatus
} else { } else {
status = CLLocationManager.authorizationStatus() status = CLLocationManager.authorizationStatus()
@@ -78,7 +78,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
Task { @MainActor in self.permissionState = .restricted } Task { @MainActor in self.permissionState = .restricted }
case .denied: case .denied:
Task { @MainActor in self.permissionState = .denied } Task { @MainActor in self.permissionState = .denied }
case .authorizedAlways, .authorizedWhenInUse, .authorized: case .authorizedAlways, .authorizedWhenInUse:
Task { @MainActor in self.permissionState = .authorized } Task { @MainActor in self.permissionState = .authorized }
requestOneShotLocation() requestOneShotLocation()
@unknown default: @unknown default:
@@ -93,7 +93,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} }
/// Begin periodic one-shot location refreshes while a selector UI is visible. /// Begin periodic one-shot location refreshes while a selector UI is visible.
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) { func beginLiveRefresh(interval: TimeInterval = 5.0) {
guard permissionState == .authorized else { return } guard permissionState == .authorized else { return }
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open // Switch to a lightweight periodic one-shot request (polling) while the sheet is open
refreshTimer?.invalidate() refreshTimer?.invalidate()
@@ -151,8 +151,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} }
} }
// iOS 14+ / macOS 11+ // iOS 14+
@available(iOS 14.0, macOS 11.0, *) @available(iOS 14.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
updatePermissionState(from: manager.authorizationStatus) updatePermissionState(from: manager.authorizationStatus)
if case .authorized = permissionState { if case .authorized = permissionState {
@@ -180,7 +180,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
case .notDetermined: newState = .notDetermined case .notDetermined: newState = .notDetermined
case .restricted: newState = .restricted case .restricted: newState = .restricted
case .denied: newState = .denied case .denied: newState = .denied
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized case .authorizedAlways, .authorizedWhenInUse: newState = .authorized
@unknown default: newState = .restricted @unknown default: newState = .restricted
} }
Task { @MainActor in self.permissionState = newState } Task { @MainActor in self.permissionState = newState }
@@ -256,4 +256,5 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
return dict return dict
} }
} }
#endif #endif
+38 -21
View File
@@ -20,7 +20,7 @@ final class NostrTransport: Transport {
} }
private var readQueue: [QueuedRead] = [] private var readQueue: [QueuedRead] = []
private var isSendingReadAcks = false private var isSendingReadAcks = false
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval private let readAckInterval: TimeInterval = 0.35 // ~3 per second
var myPeerID: String { senderPeerID } var myPeerID: String { senderPeerID }
var myNickname: String { "" } var myNickname: String { "" }
@@ -48,7 +48,16 @@ final class NostrTransport: Transport {
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } // Resolve favorite by full noise key or by short peerID fallback
var recipientNostrPubkey: String?
if let noiseKey = Data(hexString: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
recipientNostrPubkey = fav.peerNostrPublicKey
}
if recipientNostrPubkey == nil, peerID.count == 16 {
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
}
guard let recipientNpub = recipientNostrPubkey else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
@@ -96,7 +105,15 @@ final class NostrTransport: Transport {
guard !readQueue.isEmpty else { isSendingReadAcks = false; return } guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
let item = readQueue.removeFirst() let item = readQueue.removeFirst()
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return } var recipientNostrPubkey: String?
if let noiseKey = Data(hexString: item.peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
recipientNostrPubkey = fav.peerNostrPublicKey
}
if recipientNostrPubkey == nil, item.peerID.count == 16 {
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: item.peerID)?.peerNostrPublicKey
}
guard let recipientNpub = recipientNostrPubkey else { scheduleNextReadAck(); return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
@@ -132,7 +149,15 @@ final class NostrTransport: Transport {
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } var recipientNostrPubkey: String?
if let noiseKey = Data(hexString: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
recipientNostrPubkey = fav.peerNostrPublicKey
}
if recipientNostrPubkey == nil, peerID.count == 16 {
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
}
guard let recipientNpub = recipientNostrPubkey else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))",
@@ -158,26 +183,18 @@ final class NostrTransport: Transport {
} }
} }
// MARK: - Helpers
@MainActor
private func resolveRecipientNpub(for peerID: String) -> String? {
if let noiseKey = Data(hexString: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let npub = fav.peerNostrPublicKey {
return npub
}
if peerID.count == 16,
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let npub = fav.peerNostrPublicKey {
return npub
}
return nil
}
func sendBroadcastAnnounce() { /* no-op for Nostr */ } func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: String) { func sendDeliveryAck(for messageID: String, to peerID: String) {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } var recipientNostrPubkey: String?
if let noiseKey = Data(hexString: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
recipientNostrPubkey = fav.peerNostrPublicKey
}
if recipientNostrPubkey == nil, peerID.count == 16 {
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
}
guard let recipientNpub = recipientNostrPubkey else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
+1 -1
View File
@@ -27,7 +27,7 @@ class PrivateChatManager: ObservableObject {
} }
// Cap for messages stored per private chat // Cap for messages stored per private chat
private let privateChatCap = TransportConfig.privateChatCap private let privateChatCap = 1337
/// Start a private chat with a peer /// Start a private chat with a peer
func startChat(with peerID: String) { func startChat(with peerID: String) {
+9 -22
View File
@@ -12,7 +12,6 @@ struct RelayController {
static func decide(ttl: UInt8, static func decide(ttl: UInt8,
senderIsSelf: Bool, senderIsSelf: Bool,
isEncrypted: Bool, isEncrypted: Bool,
isDirectedEncrypted: Bool,
isDirectedFragment: Bool, isDirectedFragment: Bool,
isHandshake: Bool, isHandshake: Bool,
degree: Int, degree: Int,
@@ -20,17 +19,7 @@ struct RelayController {
// Suppress obvious non-relays // Suppress obvious non-relays
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) } if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
// For session-critical or directed traffic, be deterministic and reliable // Degree-aware probability to reduce floods in dense graphs
if isHandshake || isDirectedFragment || isDirectedEncrypted {
// Always relay with no TTL cap for these types
let newTTL = (ttl &- 1)
// Slight jitter to desynchronize without adding too much latency
let delayRange: ClosedRange<Int> = isHandshake ? 20...60 : 40...120
let delayMs = Int.random(in: delayRange)
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
}
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
let baseProb: Double let baseProb: Double
switch degree { switch degree {
case 0...2: baseProb = 1.0 case 0...2: baseProb = 1.0
@@ -39,22 +28,20 @@ struct RelayController {
case 7...9: baseProb = 0.55 case 7...9: baseProb = 0.55
default: baseProb = 0.45 default: baseProb = 0.45
} }
let prob = baseProb var prob = baseProb
if isHandshake { prob = max(0.3, baseProb - 0.2) }
// Sample a forwarding decision
let shouldRelay = Double.random(in: 0...1) <= prob let shouldRelay = Double.random(in: 0...1) <= prob
// TTL clamping in dense graphs (only for broadcast) // TTL clamping in dense graphs
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5 let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
let clamped = max(1, min(ttl, ttlCap)) let clamped = max(1, min(ttl, ttlCap))
let newTTL = clamped &- 1 let newTTL = clamped &- 1
// Wider jitter window to allow duplicate suppression to win more often // Short jitter to desynchronize rebroadcasts
let delayMs: Int let delayMs = Int.random(in: 20...80)
switch degree {
case 0...2: delayMs = Int.random(in: 40...100)
case 3...5: delayMs = Int.random(in: 60...150)
case 6...9: delayMs = Int.random(in: 80...180)
default: delayMs = Int.random(in: 100...220)
}
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs) return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
} }
} }
+1 -10
View File
@@ -3,7 +3,7 @@ import Combine
/// Abstract transport interface used by ChatViewModel and services. /// Abstract transport interface used by ChatViewModel and services.
/// BLEService implements this protocol; a future Nostr transport can too. /// BLEService implements this protocol; a future Nostr transport can too.
struct TransportPeerSnapshot: Equatable, Hashable { struct TransportPeerSnapshot {
let id: String let id: String
let nickname: String let nickname: String
let isConnected: Bool let isConnected: Bool
@@ -46,20 +46,11 @@ protocol Transport: AnyObject {
func sendBroadcastAnnounce() func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: String) func sendDeliveryAck(for messageID: String, to peerID: String)
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
// Peer snapshots (for non-UI services) // Peer snapshots (for non-UI services)
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get } var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
func currentPeerSnapshots() -> [TransportPeerSnapshot] func currentPeerSnapshots() -> [TransportPeerSnapshot]
} }
extension Transport {
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
}
protocol TransportPeerEventsDelegate: AnyObject { protocol TransportPeerEventsDelegate: AnyObject {
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) @MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
} }
-161
View File
@@ -1,161 +0,0 @@
import Foundation
/// Centralized knobs for transport- and UI-related limits.
/// Keep values aligned with existing behavior when replacing magic numbers.
enum TransportConfig {
// BLE / Protocol
static let bleDefaultFragmentSize: Int = 469 // ~512 MTU minus protocol overhead
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
// UI / Storage Caps
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337
static let contentLRUCap: Int = 2000
// Timers
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
// BLE duty/announce/connect
static let bleConnectRateLimitInterval: TimeInterval = 0.5
static let bleMaxCentralLinks: Int = 6
static let bleDutyOnDuration: TimeInterval = 5.0
static let bleDutyOffDuration: TimeInterval = 10.0
static let bleAnnounceMinInterval: TimeInterval = 1.0
// BLE discovery/quality thresholds
static let bleDynamicRSSIThresholdDefault: Int = -90
static let bleConnectionCandidatesMax: Int = 100
static let blePendingWriteBufferCapBytes: Int = 1_000_000
static let blePendingNotificationsCapCount: Int = 20
// Nostr
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
// UI thresholds
static let uiLateInsertThreshold: TimeInterval = 15.0
static let uiProcessedNostrEventsCap: Int = 2000
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
// UI rate limiters (token buckets)
static let uiSenderRateBucketCapacity: Double = 5
static let uiSenderRateBucketRefillPerSec: Double = 1.0
static let uiContentRateBucketCapacity: Double = 3
static let uiContentRateBucketRefillPerSec: Double = 0.5
// UI sleeps/delays
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
static let uiStartupShortSleepNs: UInt64 = 200_000_000
static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0
static let uiAsyncShortSleepNs: UInt64 = 100_000_000
static let uiAsyncMediumSleepNs: UInt64 = 500_000_000
static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1
static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5
static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15
static let uiScrollThrottleSeconds: TimeInterval = 0.5
static let uiAnimationShortSeconds: TimeInterval = 0.15
static let uiAnimationMediumSeconds: TimeInterval = 0.2
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
// BLE maintenance & thresholds
static let bleMaintenanceInterval: TimeInterval = 10.0
static let bleMaintenanceLeewaySeconds: Int = 1
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60
static let bleRecentTimeoutCountThreshold: Int = 3
static let bleRSSIIsolatedBase: Int = -90
static let bleRSSIIsolatedRelaxed: Int = -92
static let bleRSSIConnectedThreshold: Int = -85
static let bleRSSIHighTimeoutThreshold: Int = -80
static let blePeerInactivityTimeoutSeconds: TimeInterval = 20.0
static let bleFragmentLifetimeSeconds: TimeInterval = 30.0
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
static let bleRecentPacketWindowSeconds: TimeInterval = 30.0
static let bleRecentPacketWindowMaxCount: Int = 100
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
static let bleExpectedWritePerFragmentMs: Int = 8
static let bleExpectedWriteMaxMs: Int = 2000
static let bleFragmentSpacingMs: Int = 6
static let bleAnnounceIntervalSeconds: TimeInterval = 10.0
static let bleDutyOnDurationDense: TimeInterval = 3.0
static let bleDutyOffDurationDense: TimeInterval = 15.0
static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 90.0
static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 45.0
static let bleConnectedAnnounceJitterDense: TimeInterval = 20.0
static let bleConnectedAnnounceJitterSparse: TimeInterval = 7.5
// Location
static let locationDistanceFilterMeters: Double = 1000
static let locationLiveRefreshInterval: TimeInterval = 5.0
// Nostr geohash
static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600
static let nostrGeohashInitialLimit: Int = 200
static let nostrGeoRelayCount: Int = 5
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
static let nostrGeohashSampleLimit: Int = 100
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
// Nostr helpers
static let nostrShortKeyDisplayLength: Int = 8
static let nostrConvKeyPrefixLength: Int = 16
// Compression
static let compressionThresholdBytes: Int = 100
// Message deduplication
static let messageDedupMaxAgeSeconds: TimeInterval = 300
static let messageDedupMaxCount: Int = 1000
// Verification QR
static let verificationQRMaxAgeSeconds: TimeInterval = 5 * 60
// Nostr relay backoff
static let nostrRelayInitialBackoffSeconds: TimeInterval = 1.0
static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0
static let nostrRelayBackoffMultiplier: Double = 2.0
static let nostrRelayMaxReconnectAttempts: Int = 10
static let nostrRelayDefaultFetchLimit: Int = 100
// Geo relay directory
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
// BLE operational delays
static let bleInitialAnnounceDelaySeconds: TimeInterval = 2.0
static let bleConnectTimeoutSeconds: TimeInterval = 8.0
static let bleRestartScanDelaySeconds: TimeInterval = 0.1
static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.1
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.2
// Content hashing / formatting
static let contentKeyPrefixLength: Int = 256
static let uiLongMessageLengthThreshold: Int = 2000
static let uiVeryLongTokenThreshold: Int = 512
static let uiLongMessageLineLimit: Int = 30
static let uiFingerprintSampleCount: Int = 3
// UI swipe/gesture thresholds
static let uiBackSwipeTranslationLarge: CGFloat = 50
static let uiBackSwipeTranslationSmall: CGFloat = 30
static let uiBackSwipeVelocityThreshold: CGFloat = 300
// UI color tuning
static let uiColorHueAvoidanceDelta: Double = 0.05
static let uiColorHueOffset: Double = 0.12
// UI windowing (infinite scroll)
static let uiWindowInitialCountPublic: Int = 300
static let uiWindowInitialCountPrivate: Int = 300
static let uiWindowStepCount: Int = 200
// Share extension
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
}
-185
View File
@@ -1,185 +0,0 @@
import Foundation
import CryptoKit
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
final class VerificationService {
static let shared = VerificationService()
// Injected Noise service from the running transport (do NOT create new BLEService)
private var noise: NoiseEncryptionService?
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
/// Encapsulates the data encoded into a verification QR
struct VerificationQR: Codable {
let v: Int
let noiseKeyHex: String
let signKeyHex: String
let npub: String?
let nickname: String
let ts: Int64
let nonceB64: String
var sigHex: String
static let context = "bitchat-verify-v1"
/// Canonical bytes used for signature (deterministic ordering)
func canonicalBytes() -> Data {
var out = Data()
func appendField(_ s: String) {
let d = s.data(using: .utf8) ?? Data()
out.append(UInt8(min(d.count, 255)))
out.append(d.prefix(255))
}
appendField(Self.context)
appendField(String(v))
appendField(noiseKeyHex.lowercased())
appendField(signKeyHex.lowercased())
appendField(npub ?? "")
appendField(nickname)
appendField(String(ts))
appendField(nonceB64)
return out
}
func toURLString() -> String {
var comps = URLComponents()
comps.scheme = "bitchat"
comps.host = "verify"
comps.queryItems = [
URLQueryItem(name: "v", value: String(v)),
URLQueryItem(name: "noise", value: noiseKeyHex),
URLQueryItem(name: "sign", value: signKeyHex),
URLQueryItem(name: "nick", value: nickname),
URLQueryItem(name: "ts", value: String(ts)),
URLQueryItem(name: "nonce", value: nonceB64),
URLQueryItem(name: "sig", value: sigHex)
] + (npub != nil ? [URLQueryItem(name: "npub", value: npub)] : [])
return comps.string ?? ""
}
static func fromURL(_ url: URL) -> VerificationQR? {
guard url.scheme == "bitchat", url.host == "verify",
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil }
func val(_ name: String) -> String? { items.first(where: { $0.name == name })?.value }
guard let vStr = val("v"), let v = Int(vStr),
let noise = val("noise"), let sign = val("sign"),
let nick = val("nick"), let tsStr = val("ts"), let ts = Int64(tsStr),
let nonce = val("nonce"), let sig = val("sig") else { return nil }
return VerificationQR(v: v, noiseKeyHex: noise, signKeyHex: sign, npub: val("npub"), nickname: nick, ts: ts, nonceB64: nonce, sigHex: sig)
}
}
// MARK: - Public API
/// Build a signed QR string for the current identity
func buildMyQRString(nickname: String, npub: String?) -> String? {
// Simple short-lived cache to speed up sheet opening
struct Cache { static var last: (nick: String, npub: String?, builtAt: Date, value: String)? }
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
return c.value
}
guard let noise = noise else { return nil }
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
let ts = Int64(Date().timeIntervalSince1970)
var nonce = Data(count: 16)
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
let msg = payload.canonicalBytes()
guard let sig = noise.signData(msg) else { return nil }
let signed = VerificationQR(v: payload.v,
noiseKeyHex: payload.noiseKeyHex,
signKeyHex: payload.signKeyHex,
npub: payload.npub,
nickname: payload.nickname,
ts: payload.ts,
nonceB64: payload.nonceB64,
sigHex: sig.map { String(format: "%02x", $0) }.joined())
let out = signed.toURLString()
Cache.last = (nickname, npub, Date(), out)
return out
}
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? {
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
// Freshness
let now = Date().timeIntervalSince1970
if now - Double(qr.ts) > maxAge { return nil }
// Verify signature using embedded ed25519 signKey
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
guard let noise = noise else { return nil }
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
return ok ? qr : nil
}
// MARK: - Noise payloads (scaffold only)
func buildVerifyChallenge(noiseKeyHex: String, nonceA: Data) -> Data {
// TLV: [0x01 len noiseKeyHex ascii] [0x02 len nonceA]
var tlv = Data()
let n0: [UInt8] = [0x01, UInt8(min(noiseKeyHex.count, 255))]
tlv.append(contentsOf: n0)
tlv.append(noiseKeyHex.data(using: .utf8)!.prefix(255))
tlv.append(0x02)
tlv.append(UInt8(min(nonceA.count, 255)))
tlv.append(nonceA.prefix(255))
return NoisePayload(type: .verifyChallenge, data: tlv).encode()
}
func buildVerifyResponse(noiseKeyHex: String, nonceA: Data) -> Data? {
// Sign context: verify-response | noiseKeyHex | nonceA
var msg = Data("bitchat-verify-resp-v1".utf8)
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA)
guard let noise = noise, let sig = noise.signData(msg) else { return nil }
var tlv = Data()
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
tlv.append(0x03); tlv.append(UInt8(min(sig.count, 255))); tlv.append(sig.prefix(255))
return NoisePayload(type: .verifyResponse, data: tlv).encode()
}
func parseVerifyChallenge(_ data: Data) -> (noiseKeyHex: String, nonceA: Data)? {
var idx = 0
func take(_ n: Int) -> Data? {
guard idx + n <= data.count else { return nil }
let d = data[idx..<(idx+n)]
idx += n
return Data(d)
}
// Expect type already stripped; we receive only TLV here
// TLV 0x01 noiseKeyHex
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
// TLV 0x02 nonceA
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
return (noiseStr, nA)
}
func parseVerifyResponse(_ data: Data) -> (noiseKeyHex: String, nonceA: Data, signature: Data)? {
var idx = 0
func take(_ n: Int) -> Data? {
guard idx + n <= data.count else { return nil }
let d = data[idx..<(idx+n)]
idx += n
return Data(d)
}
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
guard let t3 = take(1), t3[0] == 0x03, let l3 = take(1), let sig = take(Int(l3[0])) else { return nil }
return (noiseStr, nA, sig)
}
func verifyResponseSignature(noiseKeyHex: String, nonceA: Data, signature: Data, signerPublicKeyHex: String) -> Bool {
var msg = Data("bitchat-verify-resp-v1".utf8)
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA)
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
return noise.verifySignature(signature, for: msg, publicKey: pub)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ import Compression
struct CompressionUtil { struct CompressionUtil {
// Compression threshold - don't compress if data is smaller than this // Compression threshold - don't compress if data is smaller than this
static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes static let compressionThreshold = 100 // bytes
// Compress data using zlib algorithm (most compatible) // Compress data using zlib algorithm (most compatible)
static func compress(_ data: Data) -> Data? { static func compress(_ data: Data) -> Data? {
+4 -8
View File
@@ -18,18 +18,14 @@ struct InputValidator {
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64) /// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
static func validatePeerID(_ peerID: String) -> Bool { static func validatePeerID(_ peerID: String) -> Bool {
// Accept short routing IDs (exact 16-hex) // Accept short routing IDs (16-hex)
if PeerIDResolver.isShortID(peerID) { return true } if PeerIDResolver.isShortID(peerID) { return true }
// If length equals short-hex length but isn't valid hex, reject // Accept full Noise key hex (64-hex)
if peerID.count == Limits.hexPeerIDLength { return false }
// Accept full Noise key hex (exact 64-hex)
if PeerIDResolver.isNoiseKeyHex(peerID) { return true } if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
// If length equals full key length but isn't valid hex, reject // Internal format: alphanumeric + dash/underscore up to 64
if peerID.count == Limits.maxPeerIDLength { return false }
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
return !peerID.isEmpty && return !peerID.isEmpty &&
peerID.count < Limits.maxPeerIDLength && peerID.count <= Limits.maxPeerIDLength &&
peerID.rangeOfCharacter(from: validCharset.inverted) == nil peerID.rangeOfCharacter(from: validCharset.inverted) == nil
} }
+3 -2
View File
@@ -11,8 +11,8 @@ final class MessageDeduplicator {
private var entries: [Entry] = [] private var entries: [Entry] = []
private var lookup = Set<String>() private var lookup = Set<String>()
private let lock = NSLock() private let lock = NSLock()
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes private let maxAge: TimeInterval = 300 // 5 minutes
private let maxCount = TransportConfig.messageDedupMaxCount private let maxCount = 1000
/// Check if message is duplicate and add if not /// Check if message is duplicate and add if not
func isDuplicate(_ messageID: String) -> Bool { func isDuplicate(_ messageID: String) -> Bool {
@@ -84,3 +84,4 @@ final class MessageDeduplicator {
} }
} }
} }
@@ -1,29 +0,0 @@
import Foundation
/// Resolves a stable display name for peers, adding a short suffix when collisions exist.
struct PeerDisplayNameResolver {
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
/// - Parameters:
/// - peers: Array of tuples (id, nickname, isConnected).
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
/// - Returns: Map of peerID -> displayName.
static func resolve(_ peers: [(id: String, nickname: String, isConnected: Bool)], selfNickname: String) -> [String: String] {
// Count collisions among connected peers and include our own nickname
var counts: [String: Int] = [:]
for p in peers where p.isConnected {
counts[p.nickname, default: 0] += 1
}
counts[selfNickname, default: 0] += 1
var result: [String: String] = [:]
for p in peers {
var name = p.nickname
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
name += "#" + String(p.id.prefix(4))
}
result[p.id] = name
}
return result
}
}
File diff suppressed because it is too large Load Diff
+181 -100
View File
@@ -13,9 +13,19 @@ import UIKit
// MARK: - Supporting Types // MARK: - Supporting Types
// // Pre-computed peer data for performance
struct PeerDisplayData: Identifiable {
let id: String
let displayName: String
let isFavorite: Bool
let isMe: Bool
let hasUnreadMessages: Bool
let encryptionStatus: EncryptionStatus
let connectionState: BitchatPeer.ConnectionState
let isMutualFavorite: Bool
}
// // (Link previews removed; URLs are now clickable inline)
// MARK: - Main Content View // MARK: - Main Content View
@@ -23,7 +33,9 @@ struct ContentView: View {
// MARK: - Properties // MARK: - Properties
@EnvironmentObject var viewModel: ChatViewModel @EnvironmentObject var viewModel: ChatViewModel
#if os(iOS)
@ObservedObject private var locationManager = LocationChannelManager.shared @ObservedObject private var locationManager = LocationChannelManager.shared
#endif
@State private var messageText = "" @State private var messageText = ""
@State private var textFieldSelection: NSRange? = nil @State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool @FocusState private var isTextFieldFocused: Bool
@@ -46,7 +58,6 @@ struct ContentView: View {
@State private var scrollThrottleTimer: Timer? @State private var scrollThrottleTimer: Timer?
@State private var autocompleteDebounceTimer: Timer? @State private var autocompleteDebounceTimer: Timer?
@State private var showLocationChannelsSheet = false @State private var showLocationChannelsSheet = false
@State private var showVerifySheet = false
@State private var expandedMessageIDs: Set<String> = [] @State private var expandedMessageIDs: Set<String> = []
// Window sizes for rendering (infinite scroll up) // Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300 @State private var windowCountPublic: Int = 300
@@ -100,14 +111,14 @@ struct ContentView: View {
.onEnded { value in .onEnded { value in
let translation = value.translation.width.isNaN ? 0 : value.translation.width let translation = value.translation.width.isNaN ? 0 : value.translation.width
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
if translation > TransportConfig.uiBackSwipeTranslationLarge || (translation > TransportConfig.uiBackSwipeTranslationSmall && velocity > TransportConfig.uiBackSwipeVelocityThreshold) { if translation > 50 || (translation > 30 && velocity > 300) {
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeOut(duration: 0.2)) {
showPrivateChat = false showPrivateChat = false
backSwipeOffset = 0 backSwipeOffset = 0
viewModel.endPrivateChat() viewModel.endPrivateChat()
} }
} else { } else {
withAnimation(.easeOut(duration: TransportConfig.uiAnimationShortSeconds)) { withAnimation(.easeOut(duration: 0.15)) {
backSwipeOffset = 0 backSwipeOffset = 0
} }
} }
@@ -121,7 +132,7 @@ struct ContentView: View {
Color.clear Color.clear
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false showSidebar = false
sidebarDragOffset = 0 sidebarDragOffset = 0
} }
@@ -151,14 +162,14 @@ struct ContentView: View {
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width) let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
return showSidebar ? -dragOffset : width - dragOffset return showSidebar ? -dragOffset : width - dragOffset
}()) }())
.animation(.easeInOut(duration: TransportConfig.uiAnimationSidebarSeconds), value: showSidebar) .animation(.easeInOut(duration: 0.25), value: showSidebar)
} }
} }
#if os(macOS) #if os(macOS)
.frame(minWidth: 600, minHeight: 400) .frame(minWidth: 600, minHeight: 400)
#endif #endif
.onChange(of: viewModel.selectedPrivateChatPeer) { newValue in .onChange(of: viewModel.selectedPrivateChatPeer) { newValue in
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeInOut(duration: 0.2)) {
showPrivateChat = newValue != nil showPrivateChat = newValue != nil
} }
} }
@@ -188,6 +199,7 @@ struct ContentView: View {
Button("direct message") { Button("direct message") {
if let peerID = selectedMessageSenderID { if let peerID = selectedMessageSenderID {
#if os(iOS)
if peerID.hasPrefix("nostr:") { if peerID.hasPrefix("nostr:") {
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) { if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
viewModel.startGeohashDM(withPubkeyHex: full) viewModel.startGeohashDM(withPubkeyHex: full)
@@ -195,7 +207,10 @@ struct ContentView: View {
} else { } else {
viewModel.startPrivateChat(with: peerID) viewModel.startPrivateChat(with: peerID)
} }
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { #else
viewModel.startPrivateChat(with: peerID)
#endif
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false showSidebar = false
sidebarDragOffset = 0 sidebarDragOffset = 0
} }
@@ -216,6 +231,7 @@ struct ContentView: View {
Button("BLOCK", role: .destructive) { Button("BLOCK", role: .destructive) {
// Prefer direct geohash block when we have a Nostr sender ID // Prefer direct geohash block when we have a Nostr sender ID
#if os(iOS)
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"), if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
let full = viewModel.fullNostrHex(forSenderPeerID: peerID), let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
let sender = selectedMessageSender { let sender = selectedMessageSender {
@@ -223,6 +239,9 @@ struct ContentView: View {
} else if let sender = selectedMessageSender { } else if let sender = selectedMessageSender {
viewModel.sendMessage("/block \(sender)") viewModel.sendMessage("/block \(sender)")
} }
#else
if let sender = selectedMessageSender { viewModel.sendMessage("/block \(sender)") }
#endif
} }
Button("cancel", role: .cancel) {} Button("cancel", role: .cancel) {}
@@ -264,19 +283,22 @@ struct ContentView: View {
// Implement windowing with adjustable window count per chat // Implement windowing with adjustable window count per chat
let currentWindowCount: Int = { let currentWindowCount: Int = {
if let peer = privatePeer { return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate } if let peer = privatePeer { return windowCountPrivate[peer] ?? 300 }
return windowCountPublic return windowCountPublic
}() }()
let windowedMessages = messages.suffix(currentWindowCount) let windowedMessages = messages.suffix(currentWindowCount)
// Build stable UI IDs with a context key to avoid ID collisions when switching channels // Build stable UI IDs with a context key to avoid ID collisions when switching channels
#if os(iOS)
let contextKey: String = { let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: return "mesh" case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)" case .location(let ch): return "geo:\(ch.geohash)"
} }
}() }()
#else
let contextKey: String = "mesh"
#endif
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) } let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
ForEach(items, id: \.uiID) { item in ForEach(items, id: \.uiID) { item in
@@ -296,11 +318,11 @@ struct ContentView: View {
let cashuTokens = message.content.extractCashuTokens() let cashuTokens = message.content.extractCashuTokens()
let lightningLinks = message.content.extractLightningLinks() let lightningLinks = message.content.extractLightningLinks()
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .lineLimit(isLong && !isExpanded ? 30 : nil)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages // Delivery status indicator for private messages
@@ -312,7 +334,7 @@ struct ContentView: View {
} }
// Expand/Collapse for very long messages // Expand/Collapse for very long messages
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty { if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty {
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
Button(isExpanded ? "show less" : "show more") { Button(isExpanded ? "show less" : "show more") {
if isExpanded { expandedMessageIDs.remove(message.id) } if isExpanded { expandedMessageIDs.remove(message.id) }
@@ -371,17 +393,20 @@ struct ContentView: View {
} }
// Infinite scroll up: when top row appears, increase window and preserve anchor // Infinite scroll up: when top row appears, increase window and preserve anchor
if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count { if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count {
let step = TransportConfig.uiWindowStepCount let step = 200
#if os(iOS)
let contextKey: String = { let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: return "mesh" case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)" case .location(let ch): return "geo:\(ch.geohash)"
} }
}() }()
#else
let contextKey: String = "mesh"
#endif
let preserveID = "\(contextKey)|\(message.id)" let preserveID = "\(contextKey)|\(message.id)"
if let peer = privatePeer { if let peer = privatePeer {
let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate let current = windowCountPrivate[peer] ?? 300
let newCount = min(messages.count, current + step) let newCount = min(messages.count, current + step)
if newCount != current { if newCount != current {
windowCountPrivate[peer] = newCount windowCountPrivate[peer] = newCount
@@ -447,6 +472,7 @@ struct ContentView: View {
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return } guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
#if os(iOS)
func levelForLength(_ len: Int) -> GeohashChannelLevel { func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len { switch len {
case 0...2: return .region case 0...2: return .region
@@ -461,6 +487,7 @@ struct ContentView: View {
let ch = GeohashChannel(level: level, geohash: gh) let ch = GeohashChannel(level: level, geohash: gh)
LocationChannelManager.shared.markTeleported(for: gh, true) LocationChannelManager.shared.markTeleported(for: gh, true)
LocationChannelManager.shared.select(ChannelID.location(ch)) LocationChannelManager.shared.select(ChannelID.location(ch))
#endif
} }
.onTapGesture(count: 3) { .onTapGesture(count: 3) {
// Triple-tap to clear current chat // Triple-tap to clear current chat
@@ -473,12 +500,16 @@ struct ContentView: View {
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)" return "dm:\(peer)|\(last)"
} }
#if os(iOS)
let contextKey: String = { let contextKey: String = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: return "mesh" case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)" case .location(let ch): return "geo:\(ch.geohash)"
} }
}() }()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil return nil
}() }()
@@ -493,12 +524,16 @@ struct ContentView: View {
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)" return "dm:\(peer)|\(last)"
} }
#if os(iOS)
let contextKey: String = { let contextKey: String = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: return "mesh" case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)" case .location(let ch): return "geo:\(ch.geohash)"
} }
}() }()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil return nil
}() }()
@@ -512,12 +547,16 @@ struct ContentView: View {
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)" return "dm:\(peer)|\(last)"
} }
#if os(iOS)
let contextKey: String = { let contextKey: String = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: return "mesh" case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)" case .location(let ch): return "geo:\(ch.geohash)"
} }
}() }()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil return nil
}() }()
@@ -540,15 +579,19 @@ struct ContentView: View {
} }
// Throttle scroll animations to prevent excessive UI updates // Throttle scroll animations to prevent excessive UI updates
let now = Date() let now = Date()
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds { if now.timeIntervalSince(lastScrollTime) > 0.5 {
// Immediate scroll if enough time has passed // Immediate scroll if enough time has passed
lastScrollTime = now lastScrollTime = now
#if os(iOS)
let contextKey: String = { let contextKey: String = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: return "mesh" case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)" case .location(let ch): return "geo:\(ch.geohash)"
} }
}() }()
#else
let contextKey: String = "mesh"
#endif
let count = windowCountPublic let count = windowCountPublic
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -557,14 +600,18 @@ struct ContentView: View {
} else { } else {
// Schedule a delayed scroll // Schedule a delayed scroll
scrollThrottleTimer?.invalidate() scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
lastScrollTime = Date() lastScrollTime = Date()
let contextKey: String = { #if os(iOS)
switch locationManager.selectedChannel { let contextKey: String = {
case .mesh: return "mesh" switch locationManager.selectedChannel {
case .location(let ch): return "geo:\(ch.geohash)" case .mesh: return "mesh"
} case .location(let ch): return "geo:\(ch.geohash)"
}() }
}()
#else
let contextKey: String = "mesh"
#endif
let count = windowCountPublic let count = windowCountPublic
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -589,7 +636,7 @@ struct ContentView: View {
} }
// Same throttling for private chats // Same throttling for private chats
let now = Date() let now = Date()
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds { if now.timeIntervalSince(lastScrollTime) > 0.5 {
lastScrollTime = now lastScrollTime = now
let contextKey = "dm:\(peerID)" let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300 let count = windowCountPrivate[peerID] ?? 300
@@ -599,7 +646,7 @@ struct ContentView: View {
} }
} else { } else {
scrollThrottleTimer?.invalidate() scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
lastScrollTime = Date() lastScrollTime = Date()
let contextKey = "dm:\(peerID)" let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300 let count = windowCountPrivate[peerID] ?? 300
@@ -611,6 +658,7 @@ struct ContentView: View {
} }
} }
} }
#if os(iOS)
.onChange(of: locationManager.selectedChannel) { newChannel in .onChange(of: locationManager.selectedChannel) { newChannel in
// When switching to a new geohash channel, scroll to the bottom // When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return } guard privatePeer == nil else { return }
@@ -619,7 +667,7 @@ struct ContentView: View {
break break
case .location(let ch): case .location(let ch):
// Reset window size // Reset window size
windowCountPublic = TransportConfig.uiWindowInitialCountPublic windowCountPublic = 300
let contextKey = "geo:\(ch.geohash)" let contextKey = "geo:\(ch.geohash)"
let last = viewModel.messages.suffix(windowCountPublic).last?.id let last = viewModel.messages.suffix(windowCountPublic).last?.id
let target = last.map { "\(contextKey)|\($0)" } let target = last.map { "\(contextKey)|\($0)" }
@@ -629,17 +677,18 @@ struct ContentView: View {
} }
} }
} }
#endif
.onAppear { .onAppear {
// Also check when view appears // Also check when view appears
if let peerID = privatePeer { if let peerID = privatePeer {
// Try multiple times to ensure read receipts are sent // Try multiple times to ensure read receipts are sent
viewModel.markPrivateMessagesAsRead(from: peerID) viewModel.markPrivateMessagesAsRead(from: peerID)
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
viewModel.markPrivateMessagesAsRead(from: peerID) viewModel.markPrivateMessagesAsRead(from: peerID)
} }
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
viewModel.markPrivateMessagesAsRead(from: peerID) viewModel.markPrivateMessagesAsRead(from: peerID)
} }
} }
@@ -698,22 +747,18 @@ struct ContentView: View {
if showCommandSuggestions && !commandSuggestions.isEmpty { if showCommandSuggestions && !commandSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Define commands with aliases and syntax // Define commands with aliases and syntax
let baseInfo: [(commands: [String], syntax: String?, description: String)] = [ let commandInfo: [(commands: [String], syntax: String?, description: String)] = [
(["/block"], "[nickname]", "block or list blocked peers"), (["/block"], "[nickname]", "block or list blocked peers"),
(["/clear"], nil, "clear chat messages"), (["/clear"], nil, "clear chat messages"),
(["/fav"], "<nickname>", "add to favorites"),
(["/help"], nil, "show this help"),
(["/hug"], "<nickname>", "send someone a warm hug"), (["/hug"], "<nickname>", "send someone a warm hug"),
(["/m", "/msg"], "<nickname> [message]", "send private message"), (["/m", "/msg"], "<nickname> [message]", "send private message"),
(["/slap"], "<nickname>", "slap someone with a trout"), (["/slap"], "<nickname>", "slap someone with a trout"),
(["/unblock"], "<nickname>", "unblock a peer"), (["/unblock"], "<nickname>", "unblock a peer"),
(["/unfav"], "<nickname>", "remove from favorites"),
(["/w"], nil, "see who's online") (["/w"], nil, "see who's online")
] ]
let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }()
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
let favInfo: [(commands: [String], syntax: String?, description: String)] = [
(["/fav"], "<nickname>", "add to favorites"),
(["/unfav"], "<nickname>", "remove from favorites")
]
let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo)
// Build the display // Build the display
let allCommands = commandInfo let allCommands = commandInfo
@@ -788,25 +833,18 @@ struct ContentView: View {
// Check for command autocomplete (instant, no debounce needed) // Check for command autocomplete (instant, no debounce needed)
if newValue.hasPrefix("/") && newValue.count >= 1 { if newValue.hasPrefix("/") && newValue.count >= 1 {
// Build context-aware command list // Build context-aware command list
let isGeoPublic: Bool = { let commandDescriptions = [
if case .location = locationManager.selectedChannel { return true }
return false
}()
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
var commandDescriptions = [
("/block", "block or list blocked peers"), ("/block", "block or list blocked peers"),
("/clear", "clear chat messages"), ("/clear", "clear chat messages"),
("/fav", "add to favorites"),
("/help", "show this help"),
("/hug", "send someone a warm hug"), ("/hug", "send someone a warm hug"),
("/m", "send private message"), ("/m", "send private message"),
("/slap", "slap someone with a trout"), ("/slap", "slap someone with a trout"),
("/unblock", "unblock a peer"), ("/unblock", "unblock a peer"),
("/unfav", "remove from favorites"),
("/w", "see who's online") ("/w", "see who's online")
] ]
// Only show favorites commands when not in geohash context
if !(isGeoPublic || isGeoDM) {
commandDescriptions.append(("/fav", "add to favorites"))
commandDescriptions.append(("/unfav", "remove from favorites"))
}
let input = newValue.lowercased() let input = newValue.lowercased()
@@ -859,7 +897,7 @@ struct ContentView: View {
} }
.onAppear { .onAppear {
// Delay keyboard focus to avoid iOS constraint warnings // Delay keyboard focus to avoid iOS constraint warnings
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isTextFieldFocused = true isTextFieldFocused = true
} }
} }
@@ -888,15 +926,6 @@ struct ContentView: View {
.font(.system(size: 16, weight: .bold, design: .monospaced)) .font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Spacer() Spacer()
// Show QR in mesh on all platforms
if case .mesh = locationManager.selectedChannel {
Button(action: { showVerifySheet = true }) {
Image(systemName: "qrcode")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.help("Verification: show my QR or scan a friend")
}
} }
.frame(height: 44) // Match header height .frame(height: 44) // Match header height
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -909,34 +938,53 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
// People section // People section
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
if case .location = locationManager.selectedChannel { #if os(iOS)
GeohashPeopleList(viewModel: viewModel, if case .location = locationManager.selectedChannel {
textColor: textColor, GeohashPeopleList(viewModel: viewModel,
secondaryTextColor: secondaryTextColor, textColor: textColor,
onTapPerson: { secondaryTextColor: secondaryTextColor,
withAnimation(.easeInOut(duration: 0.2)) { onTapPerson: {
showSidebar = false withAnimation(.easeInOut(duration: 0.2)) {
sidebarDragOffset = 0 showSidebar = false
} sidebarDragOffset = 0
}) }
} else { })
MeshPeerList(viewModel: viewModel, } else {
textColor: textColor, MeshPeerList(viewModel: viewModel,
secondaryTextColor: secondaryTextColor, textColor: textColor,
onTapPeer: { peerID in secondaryTextColor: secondaryTextColor,
viewModel.startPrivateChat(with: peerID) onTapPeer: { peerID in
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { viewModel.startPrivateChat(with: peerID)
showSidebar = false withAnimation(.easeInOut(duration: 0.2)) {
sidebarDragOffset = 0 showSidebar = false
} sidebarDragOffset = 0
}, }
onToggleFavorite: { peerID in },
viewModel.toggleFavorite(peerID: peerID) onToggleFavorite: { peerID in
}, viewModel.toggleFavorite(peerID: peerID)
onShowFingerprint: { peerID in },
viewModel.showFingerprint(for: peerID) onShowFingerprint: { peerID in
}) viewModel.showFingerprint(for: peerID)
} })
}
#else
MeshPeerList(viewModel: viewModel,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in
viewModel.startPrivateChat(with: peerID)
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
},
onToggleFavorite: { peerID in
viewModel.toggleFavorite(peerID: peerID)
},
onShowFingerprint: { peerID in
viewModel.showFingerprint(for: peerID)
})
#endif
} }
} }
.id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined()) .id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined())
@@ -973,7 +1021,7 @@ struct ContentView: View {
.onEnded { value in .onEnded { value in
let translation = value.translation.width.isNaN ? 0 : value.translation.width let translation = value.translation.width.isNaN ? 0 : value.translation.width
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeOut(duration: 0.2)) {
if !showSidebar { if !showSidebar {
if translation < -100 || (translation < -50 && velocity < -500) { if translation < -100 || (translation < -50 && velocity < -500) {
showSidebar = true showSidebar = true
@@ -1026,11 +1074,13 @@ struct ContentView: View {
return (name, "") return (name, "")
} }
// Compute channel-aware people count and color for toolbar (cross-platform) #if os(iOS)
// Compute channel-aware people count and color for toolbar
private func channelPeopleCountAndColor() -> (Int, Color) { private func channelPeopleCountAndColor() -> (Int, Color) {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .location: case .location:
let n = viewModel.geohashPeople.count let n = viewModel.geohashPeople.count
// Use standard green (dark: system green; light: custom darker green)
let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
return (n, n > 0 ? standardGreen : Color.secondary) return (n, n > 0 ? standardGreen : Color.secondary)
case .mesh: case .mesh:
@@ -1040,11 +1090,13 @@ struct ContentView: View {
if isMeshConnected { counts.mesh += 1; counts.others += 1 } if isMeshConnected { counts.mesh += 1; counts.others += 1 }
else if peer.isMutualFavorite { counts.others += 1 } else if peer.isMutualFavorite { counts.others += 1 }
} }
// Darker, more neutral blue (less purple hue)
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
return (counts.others, color) return (counts.others, color)
} }
} }
#endif
private var mainHeaderView: some View { private var mainHeaderView: some View {
@@ -1091,6 +1143,7 @@ struct ContentView: View {
// Channel badge + dynamic spacing + people counter // Channel badge + dynamic spacing + people counter
// Precompute header count and color outside the ViewBuilder expressions // Precompute header count and color outside the ViewBuilder expressions
#if os(iOS)
let cc = channelPeopleCountAndColor() let cc = channelPeopleCountAndColor()
let headerCountColor: Color = cc.1 let headerCountColor: Color = cc.1
let headerOtherPeersCount: Int = { let headerOtherPeersCount: Int = {
@@ -1099,11 +1152,24 @@ struct ContentView: View {
} }
return cc.0 return cc.0
}() }()
#else
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
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
else if peer.isMutualFavorite { counts.others += 1 }
}
let headerOtherPeersCount = peerCounts.others
// Darker, more neutral blue (less purple hue)
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let headerCountColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
#endif
HStack(spacing: 10) { HStack(spacing: 10) {
// Unread icon immediately to the left of the channel badge (independent from channel button) // Unread icon immediately to the left of the channel badge (independent from channel button)
// Unread indicator (now shown on iOS and macOS) // Unread indicator
#if os(iOS)
if viewModel.hasAnyUnreadMessages { if viewModel.hasAnyUnreadMessages {
Button(action: { viewModel.openMostRelevantPrivateChat() }) { Button(action: { viewModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill") Image(systemName: "envelope.fill")
@@ -1138,6 +1204,7 @@ struct ContentView: View {
.accessibilityLabel("location channels") .accessibilityLabel("location channels")
} }
.buttonStyle(.plain) .buttonStyle(.plain)
#endif
HStack(spacing: 4) { HStack(spacing: 4) {
// People icon with count // People icon with count
@@ -1149,25 +1216,21 @@ struct ContentView: View {
.accessibilityHidden(true) .accessibilityHidden(true)
} }
.foregroundColor(headerCountColor) .foregroundColor(headerCountColor)
// QR moved to the PEOPLE header in the sidebar when on mesh channel
} }
.onTapGesture { .onTapGesture {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeInOut(duration: 0.2)) {
showSidebar.toggle() showSidebar.toggle()
sidebarDragOffset = 0 sidebarDragOffset = 0
} }
} }
.sheet(isPresented: $showVerifySheet) {
VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(viewModel)
}
} }
.frame(height: 44) .frame(height: 44)
.padding(.horizontal, 12) .padding(.horizontal, 12)
#if os(iOS)
.sheet(isPresented: $showLocationChannelsSheet) { .sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet) LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
} }
#endif
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
} }
@@ -1203,11 +1266,13 @@ struct ContentView: View {
let peer = viewModel.getPeer(byID: headerPeerID) let peer = viewModel.getPeer(byID: headerPeerID)
let privatePeerNick: String = { let privatePeerNick: String = {
if privatePeerID.hasPrefix("nostr_") { if privatePeerID.hasPrefix("nostr_") {
#if os(iOS)
// Build geohash DM header: "#<ghash>/@name#abcd" // Build geohash DM header: "#<ghash>/@name#abcd"
if case .location(let ch) = locationManager.selectedChannel { if case .location(let ch) = locationManager.selectedChannel {
let disp = viewModel.geohashDisplayName(for: privatePeerID) let disp = viewModel.geohashDisplayName(for: privatePeerID)
return "#\(ch.geohash)/@\(disp)" return "#\(ch.geohash)/@\(disp)"
} }
#endif
} }
return peer?.displayName ?? return peer?.displayName ??
viewModel.meshService.peerNickname(peerID: headerPeerID) ?? viewModel.meshService.peerNickname(peerID: headerPeerID) ??
@@ -1294,7 +1359,7 @@ struct ContentView: View {
// Left and right buttons positioned with HStack // Left and right buttons positioned with HStack
HStack { HStack {
Button(action: { Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeInOut(duration: 0.2)) {
showPrivateChat = false showPrivateChat = false
viewModel.endPrivateChat() viewModel.endPrivateChat()
} }
@@ -1372,7 +1437,23 @@ private struct PaymentChipView: View {
} }
} }
// // Helper view for rendering message content (plain, no hashtag/mention formatting)
struct MessageContentView: View {
let message: BitchatMessage
let viewModel: ChatViewModel
let colorScheme: ColorScheme
let isMentioned: Bool
var body: some View {
Text(message.content)
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular)
}
// MARK: - Helper Methods
// buildTextSegments removed: content is rendered plain.
}
// Delivery status indicator view // Delivery status indicator view
struct DeliveryStatusView: View { struct DeliveryStatusView: View {
+2 -17
View File
@@ -32,9 +32,8 @@ struct FingerprintView: View {
Spacer() Spacer()
Button(action: { dismiss() }) { Button("DONE") {
Image(systemName: "xmark") dismiss()
.font(.system(size: 14, weight: .semibold))
} }
.foregroundColor(textColor) .foregroundColor(textColor)
} }
@@ -166,20 +165,6 @@ struct FingerprintView: View {
.cornerRadius(8) .cornerRadius(8)
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
} else {
Button(action: {
viewModel.unverifyFingerprint(for: peerID)
dismiss()
}) {
Text("REMOVE VERIFICATION")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.red)
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
} }
} }
.padding(.top) .padding(.top)
+12
View File
@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
#if os(iOS)
struct GeohashPeopleList: View { struct GeohashPeopleList: View {
@ObservedObject var viewModel: ChatViewModel @ObservedObject var viewModel: ChatViewModel
let textColor: Color let textColor: Color
@@ -28,12 +29,16 @@ struct GeohashPeopleList: View {
let people = viewModel.visibleGeohashPeople() let people = viewModel.visibleGeohashPeople()
let currentIDs = people.map { $0.id } let currentIDs = people.map { $0.id }
#if os(iOS)
let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() }) let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() })
let isTeleportedID: (String) -> Bool = { id in let isTeleportedID: (String) -> Bool = { id in
if teleportedSet.contains(id.lowercased()) { return true } if teleportedSet.contains(id.lowercased()) { return true }
if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true } if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true }
return false return false
} }
#else
let isTeleportedID: (String) -> Bool = { _ in false }
#endif
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
let nonTele = displayIDs.filter { !isTeleportedID($0) } let nonTele = displayIDs.filter { !isTeleportedID($0) }
@@ -47,7 +52,11 @@ struct GeohashPeopleList: View {
let person = personByID[pid]! let person = personByID[pid]!
HStack(spacing: 4) { HStack(spacing: 4) {
let isMe = (person.id == myHex) let isMe = (person.id == myHex)
#if os(iOS)
let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported) let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported)
#else
let teleported = false
#endif
let icon = teleported ? "face.dashed" : "mappin.and.ellipse" let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark) let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
let rowColor: Color = isMe ? .orange : assignedColor let rowColor: Color = isMe ? .orange : assignedColor
@@ -118,8 +127,10 @@ struct GeohashPeopleList: View {
} }
} }
} }
#endif
// Helper to split a trailing #abcd suffix // Helper to split a trailing #abcd suffix
#if os(iOS)
private func splitSuffix(from name: String) -> (String, String) { private func splitSuffix(from name: String) -> (String, String) {
guard name.count >= 5 else { return (name, "") } guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5)) let suffix = String(name.suffix(5))
@@ -131,3 +142,4 @@ private func splitSuffix(from name: String) -> (String, String) {
} }
return (name, "") return (name, "")
} }
#endif
+13 -39
View File
@@ -1,10 +1,7 @@
import SwiftUI import SwiftUI
import CoreLocation
#if os(iOS) #if os(iOS)
import UIKit import UIKit
#else
import AppKit
#endif
struct LocationChannelsSheet: View { struct LocationChannelsSheet: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@ObservedObject private var manager = LocationChannelManager.shared @ObservedObject private var manager = LocationChannelManager.shared
@@ -40,7 +37,11 @@ struct LocationChannelsSheet: View {
Text("location permission denied. enable in settings to use location channels.") Text("location permission denied. enable in settings to use location channels.")
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button("open settings") { openSystemLocationSettings() } Button("open settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.buttonStyle(.plain) .buttonStyle(.plain)
} }
case LocationChannelManager.PermissionState.authorized: case LocationChannelManager.PermissionState.authorized:
@@ -53,7 +54,6 @@ struct LocationChannelsSheet: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
@@ -61,21 +61,8 @@ struct LocationChannelsSheet: View {
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
} }
} }
#else
.toolbar {
ToolbarItem(placement: .automatic) {
Button("close") { isPresented = false }
.font(.system(size: 14, design: .monospaced))
}
}
#endif
} }
#if os(iOS)
.presentationDetents([.large]) .presentationDetents([.large])
#endif
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
.onAppear { .onAppear {
// Refresh channels when opening // Refresh channels when opening
if manager.permissionState == LocationChannelManager.PermissionState.authorized { if manager.permissionState == LocationChannelManager.PermissionState.authorized {
@@ -141,12 +128,10 @@ struct LocationChannelsSheet: View {
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
TextField("geohash", text: $customGeohash) TextField("geohash", text: $customGeohash)
#if os(iOS)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled(true) .autocorrectionDisabled(true)
.keyboardType(.asciiCapable)
#endif
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.keyboardType(.asciiCapable)
.onChange(of: customGeohash) { newValue in .onChange(of: customGeohash) { newValue in
// Allow only geohash base32 characters, strip '#', limit length // Allow only geohash base32 characters, strip '#', limit length
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
@@ -198,7 +183,9 @@ struct LocationChannelsSheet: View {
// Footer action inside the list // Footer action inside the list
if manager.permissionState == LocationChannelManager.PermissionState.authorized { if manager.permissionState == LocationChannelManager.PermissionState.authorized {
Button(action: { Button(action: {
openSystemLocationSettings() if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}) { }) {
Text("remove location access") Text("remove location access")
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
@@ -356,7 +343,7 @@ extension LocationChannelsSheet {
}() }()
let usesMetric: Bool = { let usesMetric: Bool = {
if #available(iOS 16.0, macOS 13.0, *) { if #available(iOS 16.0, *) {
return Locale.current.measurementSystem == .metric return Locale.current.measurementSystem == .metric
} else { } else {
return Locale.current.usesMetricSystem return Locale.current.usesMetricSystem
@@ -379,7 +366,7 @@ extension LocationChannelsSheet {
private func bluetoothRangeString() -> String { private func bluetoothRangeString() -> String {
let usesMetric: Bool = { let usesMetric: Bool = {
if #available(iOS 16.0, macOS 13.0, *) { if #available(iOS 16.0, *) {
return Locale.current.measurementSystem == .metric return Locale.current.measurementSystem == .metric
} else { } else {
return Locale.current.usesMetricSystem return Locale.current.usesMetricSystem
@@ -403,17 +390,4 @@ extension LocationChannelsSheet {
} }
} }
// MARK: - Open Settings helper #endif
private func openSystemLocationSettings() {
#if os(iOS)
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
#else
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") {
NSWorkspace.shared.open(url)
} else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") {
NSWorkspace.shared.open(url)
}
#endif
}
+6 -38
View File
@@ -44,24 +44,9 @@ struct MeshPeerList: View {
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark) let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
let baseColor = isMe ? Color.orange : assigned let baseColor = isMe ? Color.orange : assigned
if isMe { if isMe {
Image(systemName: "person.fill") Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(baseColor)
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if peer.isConnected {
// Mesh-connected peer: radio icon
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe")
.font(.system(size: 10))
.foregroundColor(.purple)
} else { } else {
// Fallback icon for others (dimmed) Image(systemName: "mappin.and.ellipse").font(.system(size: 10)).foregroundColor(baseColor)
Image(systemName: "person")
.font(.system(size: 10))
.foregroundColor(secondaryTextColor)
} }
let displayName = isMe ? viewModel.nickname : peer.nickname let displayName = isMe ? viewModel.nickname : peer.nickname
@@ -85,27 +70,10 @@ struct MeshPeerList: View {
.help("Blocked") .help("Blocked")
} }
if !isMe { if let icon = item.enc.icon, !isMe {
if peer.isConnected { Image(systemName: icon)
if let icon = item.enc.icon { .font(.system(size: 10))
Image(systemName: icon) .foregroundColor(baseColor)
.font(.system(size: 10))
.foregroundColor(baseColor)
}
} else {
// Offline: prefer showing verified badge from persisted fingerprints
if let fp = viewModel.getFingerprint(for: peer.id),
viewModel.verifiedFingerprints.contains(fp) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if let icon = item.enc.icon {
// Fallback to whatever status says (likely lock if we had a past session)
Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(baseColor)
}
}
} }
Spacer() Spacer()
-347
View File
@@ -1,347 +0,0 @@
import SwiftUI
import CoreImage
import CoreImage.CIFilterBuiltins
#if os(iOS)
import UIKit
#else
import AppKit
#endif
/// Placeholder view to display the user's verification QR payload as text.
struct MyQRView: View {
let qrString: String
@Environment(\.colorScheme) var colorScheme
private var boxColor: Color { Color.gray.opacity(0.1) }
var body: some View {
VStack(spacing: 12) {
Text("scan to verify me")
.font(.system(size: 16, weight: .bold, design: .monospaced))
VStack(spacing: 10) {
QRCodeImage(data: qrString, size: 240)
.accessibilityLabel("verification qr code")
// Non-scrolling, fully visible URL (wraps across lines)
Text(qrString)
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.padding(8)
.background(boxColor)
.cornerRadius(8)
}
.padding()
.frame(maxWidth: .infinity)
.background(boxColor)
.cornerRadius(8)
}
.padding()
}
}
// Render a QR code image for a given string using CoreImage
struct QRCodeImage: View {
let data: String
let size: CGFloat
private let context = CIContext()
private let filter = CIFilter.qrCodeGenerator()
var body: some View {
Group {
if let image = generateImage() {
ImageWrapper(image: image)
.frame(width: size, height: size)
} else {
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
.frame(width: size, height: size)
.overlay(
Text("qr unavailable")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.gray)
)
}
}
}
private func generateImage() -> CGImage? {
let inputData = Data(data.utf8)
filter.message = inputData
filter.correctionLevel = "M"
guard let outputImage = filter.outputImage else { return nil }
let scale = max(1, Int(size / 32))
let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale)))
return context.createCGImage(transformed, from: transformed.extent)
}
}
// Platform-specific wrapper to display CGImage in SwiftUI
struct ImageWrapper: View {
let image: CGImage
var body: some View {
#if os(iOS)
let ui = UIImage(cgImage: image)
return Image(uiImage: ui)
.interpolation(.none)
.resizable()
#else
let ns = NSImage(cgImage: image, size: .zero)
return Image(nsImage: ns)
.interpolation(.none)
.resizable()
#endif
}
}
/// Placeholder scanner UI; real camera scanning will be added later.
struct QRScanView: View {
@EnvironmentObject var viewModel: ChatViewModel
var isActive: Bool = true
@State private var input = ""
@State private var result: String = "" // not shown for iOS scanner
@State private var lastValid: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 12) {
#if os(iOS)
CameraScannerView(isActive: isActive) { code in
if let qr = VerificationService.shared.verifyScannedQR(code) {
let ok = viewModel.beginQRVerification(with: qr)
if !ok { /* already pending; continue scanning */ }
lastValid = code
} else {
// ignore invalid reads; continue scanning
}
}
.frame(height: 260)
.clipShape(RoundedRectangle(cornerRadius: 8))
#else
Text("paste qr content to validate:")
.font(.system(size: 14, weight: .medium, design: .monospaced))
TextEditor(text: $input)
.frame(height: 100)
.border(Color.gray.opacity(0.4))
Button("validate") {
if let qr = VerificationService.shared.verifyScannedQR(input) {
let ok = viewModel.beginQRVerification(with: qr)
result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer"
} else {
result = "invalid or expired qr payload"
}
}
.buttonStyle(.bordered)
#endif
// No status text under camera per design
Spacer()
}
.padding()
}
}
#if os(iOS)
import AVFoundation
struct CameraScannerView: UIViewRepresentable {
typealias UIViewType = PreviewView
var isActive: Bool
var onCode: (String) -> Void
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
context.coordinator.setup(sessionOwner: view, onCode: onCode)
context.coordinator.setActive(isActive)
return view
}
func updateUIView(_ uiView: PreviewView, context: Context) {
context.coordinator.setActive(isActive)
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
private var onCode: ((String) -> Void)?
private weak var owner: PreviewView?
private let session = AVCaptureSession()
private var isRunning = false
private var permissionGranted = false
private var desiredActive = false
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
self.owner = sessionOwner
self.onCode = onCode
session.beginConfiguration()
session.sessionPreset = .high
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else { return }
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { return }
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
if output.availableMetadataObjectTypes.contains(.qr) {
output.metadataObjectTypes = [.qr]
}
session.commitConfiguration()
sessionOwner.videoPreviewLayer.session = session
// Request permission and start
AVCaptureDevice.requestAccess(for: .video) { granted in
self.permissionGranted = granted
if granted && self.desiredActive && !self.isRunning {
self.setActive(true)
}
}
}
func setActive(_ active: Bool) {
desiredActive = active
guard permissionGranted else { return }
if active && !isRunning {
isRunning = true
DispatchQueue.global(qos: .userInitiated).async {
if !self.session.isRunning { self.session.startRunning() }
}
} else if !active && isRunning {
isRunning = false
DispatchQueue.global(qos: .userInitiated).async {
if self.session.isRunning { self.session.stopRunning() }
}
}
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
for obj in metadataObjects {
guard let m = obj as? AVMetadataMachineReadableCodeObject,
m.type == .qr,
let str = m.stringValue else { continue }
onCode?(str)
}
}
}
final class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
override init(frame: CGRect) {
super.init(frame: frame)
videoPreviewLayer.videoGravity = .resizeAspectFill
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
}
#endif
// Combined sheet: shows my QR by default with a button to scan instead
struct VerificationSheetView: View {
@EnvironmentObject var viewModel: ChatViewModel
@Binding var isPresented: Bool
@State private var showingScanner = false
@Environment(\.colorScheme) var colorScheme
private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white }
private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }
private var boxColor: Color { Color.gray.opacity(0.1) }
private func myQRString() -> String {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
}
var body: some View {
VStack(spacing: 0) {
// Top header (always at top)
HStack {
Text("VERIFY")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(accentColor)
Spacer()
Button(action: {
showingScanner = false
isPresented = false
}) {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(accentColor)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 8)
Divider()
// Content area
Group {
if showingScanner {
VStack(alignment: .leading, spacing: 12) {
Text("scan a friend's qr")
.font(.system(size: 16, weight: .bold, design: .monospaced))
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.foregroundColor(accentColor)
#if os(iOS)
QRScanView(isActive: showingScanner)
.environmentObject(viewModel)
.frame(height: 280)
.clipShape(RoundedRectangle(cornerRadius: 10))
#else
QRScanView()
.environmentObject(viewModel)
#endif
}
.padding()
.frame(maxWidth: .infinity)
.background(boxColor)
.cornerRadius(8)
} else {
let qr = myQRString()
MyQRView(qrString: qr)
}
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
// Centered controls moved up
VStack(spacing: 10) {
if showingScanner {
Button(action: { showingScanner = false }) {
Label("show my qr", systemImage: "qrcode")
.font(.system(size: 13, design: .monospaced))
}
.buttonStyle(.bordered)
} else {
Button(action: { showingScanner = true }) {
Label("scan someone else's qr", systemImage: "camera.viewfinder")
.font(.system(size: 13, weight: .medium, design: .monospaced))
}
.buttonStyle(.bordered)
.tint(.gray)
}
// Optional: Remove verification for selected peer (if verified)
if let pid = viewModel.selectedPrivateChatPeer,
let fp = viewModel.getFingerprint(for: pid),
viewModel.verifiedFingerprints.contains(fp) {
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
Label("remove verification", systemImage: "minus.circle")
.font(.system(size: 12, design: .monospaced))
}
.buttonStyle(.bordered)
.tint(.gray)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.background(backgroundColor)
#if os(iOS)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
#endif
.onDisappear { showingScanner = false }
}
}
-2
View File
@@ -10,8 +10,6 @@
</array> </array>
<key>com.apple.security.device.bluetooth</key> <key>com.apple.security.device.bluetooth</key>
<true/> <true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
</dict> </dict>
+118 -118
View File
@@ -7,160 +7,160 @@
// //
import UIKit import UIKit
import Social
import UniformTypeIdentifiers import UniformTypeIdentifiers
/// Modern share extension using UIKit + UTTypes. class ShareViewController: SLComposeServiceViewController {
/// Avoids deprecated Social framework and SLComposeServiceViewController.
final class ShareViewController: UIViewController {
private let statusLabel: UILabel = {
let l = UILabel()
l.translatesAutoresizingMaskIntoConstraints = false
l.font = .systemFont(ofSize: 15, weight: .semibold)
l.textAlignment = .center
l.numberOfLines = 0
l.textColor = .label
return l
}()
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .systemBackground // Set placeholder text
view.addSubview(statusLabel) placeholder = "Share to bitchat..."
NSLayoutConstraint.activate([ // Set character limit (optional)
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), charactersRemaining = 500
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
])
processShare()
} }
// MARK: - Processing override func isContentValid() -> Bool {
private func processShare() { // Validate that we have text content or attachments
guard let ctx = self.extensionContext, if let text = contentText, !text.isEmpty {
let item = ctx.inputItems.first as? NSExtensionItem else { return true
finishWithMessage("Nothing to share") }
// Check if we have attachments
if let item = extensionContext?.inputItems.first as? NSExtensionItem,
let attachments = item.attachments,
!attachments.isEmpty {
return true
}
return false
}
override func didSelectPost() {
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem else {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
return return
} }
// Try content from attributed text first (Safari often passes URL here)
if let url = detectURL(in: item.attributedContentText?.string ?? "") {
saveAndFinish(url: url, title: item.attributedTitle?.string)
return
}
// Scan attachments for URL/text // Get the page title from the compose view or extension item
let providers = item.attachments ?? [] let pageTitle = self.contentText ?? extensionItem.attributedContentText?.string ?? extensionItem.attributedTitle?.string
if providers.isEmpty {
// Fallback: use attributed title as plain text var foundURL: URL? = nil
if let title = item.attributedTitle?.string, !title.isEmpty { let group = DispatchGroup()
saveAndFinish(text: title)
} else { // IMPORTANT: Check if the NSExtensionItem itself has a URL
finishWithMessage("No shareable content") // Safari often provides the URL as an attributedString with a link
if let attributedText = extensionItem.attributedContentText {
let text = attributedText.string
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let matches = detector?.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
if let firstMatch = matches?.first, let url = firstMatch.url {
foundURL = url
} }
return
} }
// Load URL or text asynchronously // Only check attachments if we haven't found a URL yet
loadFirstURL(from: providers) { [weak self] url in if foundURL == nil {
guard let self = self else { return } for (_, itemProvider) in (extensionItem.attachments ?? []).enumerated() {
if let url = url {
self.saveAndFinish(url: url, title: item.attributedTitle?.string) // Try multiple URL type identifiers that Safari might use
} else { let urlTypes = [
self.loadFirstPlainText(from: providers) { text in UTType.url.identifier,
if let t = text, !t.isEmpty { "public.url",
// Treat as URL if parseable http(s), else plain text "public.file-url"
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") { ]
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
} else { for urlType in urlTypes {
self.saveAndFinish(text: t) if itemProvider.hasItemConformingToTypeIdentifier(urlType) {
group.enter()
itemProvider.loadItem(forTypeIdentifier: urlType, options: nil) { (item, error) in
defer { group.leave() }
if let url = item as? URL {
foundURL = url
} else if let data = item as? Data,
let urlString = String(data: data, encoding: .utf8),
let url = URL(string: urlString) {
foundURL = url
} else if let string = item as? String,
let url = URL(string: string) {
foundURL = url
}
}
break // Found a URL type, no need to check other types
}
}
// Also check for plain text that might be a URL
if foundURL == nil && itemProvider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
group.enter()
itemProvider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { (item, error) in
defer { group.leave() }
if let text = item as? String {
// Check if the text is actually a URL
if let url = URL(string: text),
(url.scheme == "http" || url.scheme == "https") {
foundURL = url
} }
} else {
self.finishWithMessage("No shareable content")
} }
} }
} }
} }
} } // End of if foundURL == nil
private func detectURL(in text: String) -> URL? { // Process after all checks complete
guard !text.isEmpty else { return nil } group.notify(queue: .main) { [weak self] in
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) if let url = foundURL {
let range = NSRange(location: 0, length: (text as NSString).length) // We have a URL! Create the JSON data
let match = detector?.matches(in: text, options: [], range: range).first let urlData: [String: String] = [
return match?.url "url": url.absoluteString,
} "title": pageTitle ?? url.host ?? "Shared Link"
]
private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {
let identifiers = [UTType.url.identifier, "public.url", "public.file-url"]
let grp = DispatchGroup()
var found: URL?
for p in providers where found == nil { if let jsonData = try? JSONSerialization.data(withJSONObject: urlData),
for id in identifiers where p.hasItemConformingToTypeIdentifier(id) { let jsonString = String(data: jsonData, encoding: .utf8) {
grp.enter() self?.saveToSharedDefaults(content: jsonString, type: "url")
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
defer { grp.leave() }
if let u = item as? URL { found = u; return }
if let s = item as? String, let u = URL(string: s) { found = u; return }
if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return }
} }
break } else if let title = pageTitle, !title.isEmpty {
// No URL found, just share the text
self?.saveToSharedDefaults(content: title, type: "text")
} }
}
grp.notify(queue: .main) { completion(found) }
}
private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) { self?.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
let id = UTType.plainText.identifier
let grp = DispatchGroup()
var text: String?
for p in providers where p.hasItemConformingToTypeIdentifier(id) {
grp.enter()
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
defer { grp.leave() }
if let s = item as? String { text = s }
else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s }
}
break
}
grp.notify(queue: .main) { completion(text) }
}
// MARK: - Save + Finish
private func saveAndFinish(url: URL, title: String?) {
let payload: [String: String] = [
"url": url.absoluteString,
"title": title ?? url.host ?? "Shared Link"
]
if let json = try? JSONSerialization.data(withJSONObject: payload),
let s = String(data: json, encoding: .utf8) {
saveToSharedDefaults(content: s, type: "url")
finishWithMessage("✓ Shared link to bitchat")
} else {
finishWithMessage("Failed to encode link")
} }
} }
private func saveAndFinish(text: String) { override func configurationItems() -> [Any]! {
saveToSharedDefaults(content: text, type: "text") // No configuration items needed
finishWithMessage("✓ Shared text to bitchat") return []
} }
// MARK: - Helper Methods
private func saveToSharedDefaults(content: String, type: String) { private func saveToSharedDefaults(content: String, type: String) {
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else { return } // Use app groups to share data between extension and main app
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
return
}
userDefaults.set(content, forKey: "sharedContent") userDefaults.set(content, forKey: "sharedContent")
userDefaults.set(type, forKey: "sharedContentType") userDefaults.set(type, forKey: "sharedContentType")
userDefaults.set(Date(), forKey: "sharedContentDate") userDefaults.set(Date(), forKey: "sharedContentDate")
// No need to force synchronize; the system persists changes userDefaults.synchronize()
// Force open the main app
self.openMainApp()
} }
private func finishWithMessage(_ msg: String) { private func openMainApp() {
statusLabel.text = msg // Share extensions cannot directly open the containing app
// Complete shortly after showing status // The app will check for shared content when it becomes active
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) { // Show success feedback to user
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) DispatchQueue.main.async {
self.textView.text = "✓ Shared to bitchat"
self.textView.isEditable = false
} }
} }
} }
-115
View File
@@ -21,10 +21,8 @@ final class NostrProtocolTests: XCTestCase {
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
#if DEBUG
print("Sender pubkey: \(sender.publicKeyHex)") print("Sender pubkey: \(sender.publicKeyHex)")
print("Recipient pubkey: \(recipient.publicKeyHex)") print("Recipient pubkey: \(recipient.publicKeyHex)")
#endif
// Create a test message // Create a test message
let originalContent = "Hello from NIP-17 test!" let originalContent = "Hello from NIP-17 test!"
@@ -36,10 +34,8 @@ final class NostrProtocolTests: XCTestCase {
senderIdentity: sender senderIdentity: sender
) )
#if DEBUG
print("Gift wrap created with ID: \(giftWrap.id)") print("Gift wrap created with ID: \(giftWrap.id)")
print("Gift wrap pubkey: \(giftWrap.pubkey)") print("Gift wrap pubkey: \(giftWrap.pubkey)")
#endif
// Decrypt the gift wrap // Decrypt the gift wrap
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage( let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
@@ -56,9 +52,7 @@ final class NostrProtocolTests: XCTestCase {
let timeDiff = abs(messageDate.timeIntervalSinceNow) let timeDiff = abs(messageDate.timeIntervalSinceNow)
XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent") XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent")
#if DEBUG
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)") print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
#endif
} }
func testGiftWrapUsesUniqueEphemeralKeys() throws { func testGiftWrapUsesUniqueEphemeralKeys() throws {
@@ -81,10 +75,8 @@ final class NostrProtocolTests: XCTestCase {
// Gift wrap pubkeys should be different (unique ephemeral keys) // Gift wrap pubkeys should be different (unique ephemeral keys)
XCTAssertNotEqual(message1.pubkey, message2.pubkey) XCTAssertNotEqual(message1.pubkey, message2.pubkey)
#if DEBUG
print("Message 1 gift wrap pubkey: \(message1.pubkey)") print("Message 1 gift wrap pubkey: \(message1.pubkey)")
print("Message 2 gift wrap pubkey: \(message2.pubkey)") print("Message 2 gift wrap pubkey: \(message2.pubkey)")
#endif
// Both should decrypt successfully // Both should decrypt successfully
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage( let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
@@ -117,114 +109,7 @@ final class NostrProtocolTests: XCTestCase {
giftWrap: giftWrap, giftWrap: giftWrap,
recipientIdentity: wrongRecipient recipientIdentity: wrongRecipient
)) { error in )) { error in
#if DEBUG
print("Expected error when decrypting with wrong key: \(error)") print("Expected error when decrypting with wrong key: \(error)")
#endif
} }
} }
func testAckRoundTripNIP44V2_Delivered() throws {
// Identities
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
let messageID = "TEST-MSG-DELIVERED-1"
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else {
XCTFail("Failed to embed delivered ack")
return
}
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
// Ensure v2 format was used for ciphertext
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
// Decrypt as recipient
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: recipient
)
// Verify sender is correct
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
// Parse BitChat payload
XCTAssertTrue(content.hasPrefix("bitchat1:"))
let base64url = String(content.dropFirst("bitchat1:".count))
guard let packetData = Self.base64URLDecode(base64url),
let packet = BitchatPacket.from(packetData) else {
return XCTFail("Failed to decode bitchat packet")
}
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
guard let payload = NoisePayload.decode(packet.payload) else {
return XCTFail("Failed to decode NoisePayload")
}
switch payload.type {
case .delivered:
let mid = String(data: payload.data, encoding: .utf8)
XCTAssertEqual(mid, messageID)
default:
XCTFail("Unexpected payload type: \(payload.type)")
}
}
func testAckRoundTripNIP44V2_ReadReceipt() throws {
// Identities
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let messageID = "TEST-MSG-READ-1"
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else {
XCTFail("Failed to embed read ack")
return
}
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: recipient
)
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
XCTAssertTrue(content.hasPrefix("bitchat1:"))
let base64url = String(content.dropFirst("bitchat1:".count))
guard let packetData = Self.base64URLDecode(base64url),
let packet = BitchatPacket.from(packetData) else {
return XCTFail("Failed to decode bitchat packet")
}
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
guard let payload = NoisePayload.decode(packet.payload) else {
return XCTFail("Failed to decode NoisePayload")
}
switch payload.type {
case .readReceipt:
let mid = String(data: payload.data, encoding: .utf8)
XCTAssertEqual(mid, messageID)
default:
XCTFail("Unexpected payload type: \(payload.type)")
}
}
// MARK: - Helpers
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let rem = str.count % 4
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
return Data(base64Encoded: str)
}
} }
@@ -0,0 +1,45 @@
//
// LegacyTestProtocolTypes.swift
// bitchatTests
//
// Minimal legacy protocol types used only by tests to simulate old flows.
// These are not part of production code anymore.
import Foundation
struct ProtocolNack {
let originalPacketID: String
let nackID: String
let senderID: String
let receiverID: String
let packetType: UInt8
let reason: String
let errorCode: UInt8
enum ErrorCode: UInt8 {
case unknown = 0
case decryptionFailed = 2
}
init(originalPacketID: String, senderID: String, receiverID: String, packetType: UInt8, reason: String, errorCode: ErrorCode = .unknown) {
self.originalPacketID = originalPacketID
self.nackID = UUID().uuidString
self.senderID = senderID
self.receiverID = receiverID
self.packetType = packetType
self.reason = reason
self.errorCode = errorCode.rawValue
}
func toBinaryData() -> Data {
// Tests don't parse the payload; return a compact encoding for completeness
var data = Data()
data.appendUUID(originalPacketID)
data.appendUUID(nackID)
data.append(UInt8(packetType))
data.append(UInt8(errorCode))
data.appendString(reason)
return data
}
}
+13
View File
@@ -0,0 +1,13 @@
Command line invocation:
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -project bitchat.xcodeproj -scheme bitchat -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=YES
Build settings from command line:
CODE_SIGNING_ALLOWED = NO
ONLY_ACTIVE_ARCH = YES
SDKROOT = iphonesimulator18.5
Resolve Package Graph
/Users/jack/Library/org.swift.swiftpm/configuration is not accessible or not writable, disabling user-level cache features./Users/jack/Library/org.swift.swiftpm/security is not accessible or not writable, disabling user-level cache features./Users/jack/Library/Caches/org.swift.swiftpm is not accessible or not writable, disabling user-level cache features.
Package: swift-secp256k1
fatalError