Compare commits

..
Author SHA1 Message Date
jackandGitHub 7d487e5bdb Merge branch 'main' into avoid-recreating-session 2025-10-15 01:04:51 +02:00
islam 4acb2cf540 Reuse an existing session instead of recreating 2025-10-14 23:45:03 +01:00
2615 changed files with 392948 additions and 79032 deletions
+2 -4
View File
@@ -7,7 +7,6 @@ on:
permissions:
contents: write
pull-requests: write
jobs:
update-relay-data:
@@ -18,11 +17,10 @@ jobs:
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Fetch GeoRelays
run: |
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
mv nostr_relays.csv ./relays/online_relays_gps.csv
- name: Check for changes
+6 -21
View File
@@ -5,23 +5,14 @@ on:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
name: Run Swift Tests (${{ matrix.name }})
name: Run Swift Tests
runs-on: macos-latest
strategy:
fail-fast: false # Don't cancel other matrix jobs when one fails
matrix:
include:
- name: app
path: .
- name: BitLogger
path: localPackages/BitLogger
- name: BitFoundation
path: localPackages/BitFoundation
steps:
- name: Checkout code
uses: actions/checkout@v5
@@ -29,14 +20,8 @@ jobs:
- name: Set up Swift
uses: swift-actions/setup-swift@v2
- name: Cache build artifacts
uses: actions/cache@v4
with:
path: ${{ matrix.path }}/.build
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
restore-keys: |
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ matrix.name }}-
- name: Build the package
run: swift build
- name: Run Tests
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
run: swift test --parallel
-1
View File
@@ -8,7 +8,6 @@ plans/
## AI
CLAUDE.md
AGENTS.md
.claude/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
+1 -2
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.5.1
MARKETING_VERSION = 1.4.4
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
@@ -9,4 +9,3 @@ DEVELOPMENT_TEAM = L3N5LHJD5Y
CODE_SIGN_STYLE = Automatic
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
APP_GROUP_ID = group.chat.bitchat
+3 -6
View File
@@ -14,11 +14,8 @@ default:
# Check prerequisites
check:
@echo "Checking prerequisites..."
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met"
# Backup original files
@@ -47,7 +44,7 @@ patch-for-macos: backup
# Build the macOS app
build: #check generate
@echo "Building BitChat for macOS..."
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
# Run the macOS app
run: build
+5 -13
View File
@@ -16,8 +16,7 @@ let package = Package(
),
],
dependencies:[
.package(path: "localPackages/Arti"),
.package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/Tor"),
.package(path: "localPackages/BitLogger"),
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
],
@@ -26,19 +25,16 @@ let package = Package(
name: "bitchat",
dependencies: [
.product(name: "P256K", package: "swift-secp256k1"),
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "BitLogger", package: "BitLogger"),
.product(name: "Tor", package: "Arti")
.product(name: "Tor", package: "Tor")
],
path: "bitchat",
exclude: [
"Info.plist",
"Assets.xcassets",
"_PreviewHelpers/PreviewAssets.xcassets",
"bitchat.entitlements",
"bitchat-macOS.entitlements",
"LaunchScreen.storyboard",
"ViewModels/Extensions/README.md"
"LaunchScreen.storyboard"
],
resources: [
.process("Localizable.xcstrings")
@@ -46,18 +42,14 @@ let package = Package(
),
.testTarget(
name: "bitchatTests",
dependencies: [
"bitchat",
.product(name: "BitFoundation", package: "BitFoundation")
],
dependencies: ["bitchat"],
path: "bitchatTests",
exclude: [
"Info.plist",
"README.md"
],
resources: [
.process("Localization"),
.process("Noise")
.process("Localization")
]
)
]
+3
View File
@@ -8,6 +8,9 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
> [!WARNING]
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
## License
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
+11 -65
View File
@@ -10,12 +10,11 @@
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
/* End PBXBuildFile section */
@@ -96,24 +95,6 @@
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
};
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
Localization/PrimaryLocalizationKeys.json,
README.md,
);
target = 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */;
};
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
Localization/PrimaryLocalizationKeys.json,
README.md,
);
target = 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -137,10 +118,6 @@
};
A6E32D412E762EAE0032EA8A /* bitchatTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */,
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */,
);
path = bitchatTests;
sourceTree = "<group>";
};
@@ -158,16 +135,15 @@
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
3EE336D150427F736F32B56C /* P256K in Frameworks */,
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
);
};
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
files = (
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
885BBED78092484A5B069461 /* P256K in Frameworks */,
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
);
};
/* End PBXFrameworksBuildPhase section */
@@ -227,7 +203,6 @@
B1D9136AA0083366353BFA2F /* P256K */,
A6E3E5712E7703760032EA8A /* BitLogger */,
A6E3EA802E7706A80032EA8A /* Tor */,
A6BCF9492F809550001CF9B9 /* BitFoundation */,
);
productName = bitchat_macOS;
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
@@ -238,7 +213,6 @@
buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */;
buildPhases = (
5C22AA7B9ACC5A861445C769 /* Sources */,
C5E027A42ECCDFD700BD6012 /* Resources */,
);
buildRules = (
);
@@ -271,7 +245,6 @@
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
buildPhases = (
865C8403EF02C089369A9FCB /* Sources */,
C5E027A72ECCDFE200BD6012 /* Resources */,
);
buildRules = (
);
@@ -308,7 +281,6 @@
4EB6BA1B8464F1EA38F4E286 /* P256K */,
A6E3E56F2E77036A0032EA8A /* BitLogger */,
A6E3EA7E2E7706720032EA8A /* Tor */,
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
);
productName = bitchat_iOS;
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
@@ -349,8 +321,7 @@
packageReferences = (
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
);
preferredProjectObjectVersion = 90;
projectDirPath = "";
@@ -372,16 +343,6 @@
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
);
};
C5E027A42ECCDFD700BD6012 /* Resources */ = {
isa = PBXResourcesBuildPhase;
files = (
);
};
C5E027A72ECCDFE200BD6012 /* Resources */ = {
isa = PBXResourcesBuildPhase;
files = (
);
};
CD6E8F32BC38357473954F97 /* Resources */ = {
isa = PBXResourcesBuildPhase;
files = (
@@ -555,7 +516,6 @@
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist;
@@ -566,7 +526,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -616,7 +576,6 @@
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist;
@@ -627,7 +586,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -663,7 +622,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -755,7 +714,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -916,17 +875,13 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitFoundation;
};
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitLogger;
};
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */ = {
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Arti;
relativePath = localPackages/Tor;
};
/* End XCLocalSwiftPackageReference section */
@@ -935,8 +890,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
requirement = {
kind = exactVersion;
version = 0.21.1;
kind = upToNextMajorVersion;
minimumVersion = 0.21.1;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -947,15 +902,6 @@
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
productName = P256K;
};
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
productName = BitFoundation;
};
A6BCF9492F809550001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
package = A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */;
productName = BitFoundation;
};
A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
isa = XCSwiftPackageProductDependency;
productName = BitLogger;
@@ -98,8 +98,8 @@
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "BITCHAT_LOG_LEVEL"
value = "debug"
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
-363
View File
@@ -1,363 +0,0 @@
import BitFoundation
import Combine
import Foundation
enum SharedContentKind: String, Sendable, Equatable {
case text
case url
}
enum RuntimeScenePhase: String, Sendable, Equatable {
case active
case inactive
case background
}
enum TorLifecycleEvent: String, Sendable, Equatable {
case willStart
case willRestart
case didBecomeReady
case preferenceChanged
}
enum AppEvent: Sendable, Equatable {
case launched
case startupCompleted
case scenePhaseChanged(RuntimeScenePhase)
case openedURL(String)
case sharedContentAccepted(SharedContentKind)
case notificationOpened(peerID: PeerID?)
case deepLinkOpened(String)
case torLifecycleChanged(TorLifecycleEvent)
case nostrRelayConnectionChanged(Bool)
case terminationRequested
}
actor AppEventStream {
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
func stream() -> AsyncStream<AppEvent> {
let id = UUID()
return AsyncStream { continuation in
continuations[id] = continuation
continuation.onTermination = { [id] _ in
Task {
await self.removeContinuation(id)
}
}
}
}
func emit(_ event: AppEvent) {
for continuation in continuations.values {
continuation.yield(event)
}
}
func finish() {
for continuation in continuations.values {
continuation.finish()
}
continuations.removeAll()
}
private func removeContinuation(_ id: UUID) {
continuations.removeValue(forKey: id)
}
}
struct PeerHandle: Sendable, Identifiable {
let id: String
let routingPeerID: PeerID
let displayName: String?
let noisePublicKeyHex: String?
let nostrPublicKey: String?
}
extension PeerHandle: Equatable {
static func == (lhs: PeerHandle, rhs: PeerHandle) -> Bool {
lhs.id == rhs.id
}
}
extension PeerHandle: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
enum ConversationID: Hashable, Sendable {
case mesh
case geohash(String)
case direct(PeerHandle)
init(channelID: ChannelID) {
switch channelID {
case .mesh:
self = .mesh
case .location(let channel):
self = .geohash(channel.geohash.lowercased())
}
}
}
@MainActor
final class IdentityResolver {
private var handlesByRoutingPeerID: [PeerID: PeerHandle] = [:]
private var handlesByNoiseKey: [String: PeerHandle] = [:]
private var handlesByNostrKey: [String: PeerHandle] = [:]
func register(peers: [BitchatPeer]) {
for peer in peers {
_ = register(peer: peer)
}
}
@discardableResult
func register(peer: BitchatPeer) -> PeerHandle {
let handle = buildHandle(
routingPeerID: peer.peerID,
displayName: peer.displayName,
noisePublicKeyHex: peer.noisePublicKey.isEmpty ? nil : peer.noisePublicKey.hexEncodedString().lowercased(),
nostrPublicKey: normalizedNostrKey(peer.nostrPublicKey)
)
cache(handle)
return handle
}
func canonicalHandle(for peerID: PeerID, displayName: String? = nil) -> PeerHandle {
if let handle = handlesByRoutingPeerID[peerID] {
return handle
}
if peerID.isNoiseKeyHex, let handle = handlesByNoiseKey[peerID.bare] {
return handle
}
if (peerID.isGeoDM || peerID.isGeoChat), let handle = handlesByNostrKey[peerID.bare] {
return handle
}
let handle = buildHandle(
routingPeerID: peerID,
displayName: displayName,
noisePublicKeyHex: peerID.isNoiseKeyHex ? peerID.bare : nil,
nostrPublicKey: (peerID.isGeoDM || peerID.isGeoChat) ? peerID.bare : nil
)
cache(handle)
return handle
}
private func buildHandle(
routingPeerID: PeerID,
displayName: String?,
noisePublicKeyHex: String?,
nostrPublicKey: String?
) -> PeerHandle {
let canonicalID: String
if let noisePublicKeyHex {
canonicalID = "noise:\(noisePublicKeyHex)"
} else if let nostrPublicKey {
canonicalID = "nostr:\(nostrPublicKey)"
} else {
canonicalID = "mesh:\(routingPeerID.id)"
}
let normalizedDisplayName: String?
if let displayName, !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
normalizedDisplayName = displayName
} else {
normalizedDisplayName = nil
}
return PeerHandle(
id: canonicalID,
routingPeerID: routingPeerID,
displayName: normalizedDisplayName,
noisePublicKeyHex: noisePublicKeyHex,
nostrPublicKey: nostrPublicKey
)
}
private func normalizedNostrKey(_ nostrPublicKey: String?) -> String? {
guard let nostrPublicKey else { return nil }
let trimmed = nostrPublicKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
private func cache(_ handle: PeerHandle) {
handlesByRoutingPeerID[handle.routingPeerID] = handle
if let noisePublicKeyHex = handle.noisePublicKeyHex {
handlesByNoiseKey[noisePublicKeyHex] = handle
}
if let nostrPublicKey = handle.nostrPublicKey {
handlesByNostrKey[nostrPublicKey] = handle
}
}
}
@MainActor
final class ConversationStore: ObservableObject {
@Published private(set) var activeChannel: ChannelID = .mesh
@Published private(set) var selectedPrivatePeerID: PeerID?
@Published private(set) var selectedConversationID: ConversationID = .mesh
@Published private(set) var unreadConversations: Set<ConversationID> = []
@Published private(set) var messagesByConversation: [ConversationID: [BitchatMessage]] = [:]
private var directHandlesByConversation: [ConversationID: PeerHandle] = [:]
func setActiveChannel(_ channelID: ChannelID) {
activeChannel = channelID
if selectedPrivatePeerID == nil {
selectedConversationID = ConversationID(channelID: channelID)
}
}
func setSelectedPeerID(
_ peerID: PeerID?,
activeChannel: ChannelID,
identityResolver: IdentityResolver
) {
self.activeChannel = activeChannel
selectedPrivatePeerID = peerID
if let peerID {
selectedConversationID = directConversationID(
for: peerID,
identityResolver: identityResolver
)
} else {
selectedConversationID = ConversationID(channelID: activeChannel)
}
}
func replaceMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) {
messagesByConversation[conversationID] = normalized(messages)
}
func replaceMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
replaceMessages(messages, for: ConversationID(channelID: channelID))
}
func synchronizePublicConversation(_ messages: [BitchatMessage], activeChannel: ChannelID) {
setActiveChannel(activeChannel)
replaceMessages(messages, for: activeChannel)
}
func messages(for conversationID: ConversationID) -> [BitchatMessage] {
messagesByConversation[conversationID] ?? []
}
func directMessages(
for peerID: PeerID,
identityResolver: IdentityResolver
) -> [BitchatMessage] {
messages(for: directConversationID(for: peerID, identityResolver: identityResolver))
}
func directMessagesByPeerID() -> [PeerID: [BitchatMessage]] {
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
for (conversationID, handle) in directHandlesByConversation {
messagesByPeerID[handle.routingPeerID] = messages(for: conversationID)
}
return messagesByPeerID
}
func unreadDirectPeerIDs() -> Set<PeerID> {
unreadConversations.reduce(into: Set<PeerID>()) { result, conversationID in
guard case .direct(let handle) = conversationID else { return }
result.insert(directHandlesByConversation[conversationID]?.routingPeerID ?? handle.routingPeerID)
}
}
func synchronizeSelection(
activeChannel: ChannelID,
selectedPeerID: PeerID?,
identityResolver: IdentityResolver
) {
setSelectedPeerID(
selectedPeerID,
activeChannel: activeChannel,
identityResolver: identityResolver
)
}
func synchronizePrivateChats(
_ privateChats: [PeerID: [BitchatMessage]],
unreadPeerIDs: Set<PeerID>,
identityResolver: IdentityResolver
) {
var liveConversations = Set<ConversationID>()
for (peerID, messages) in privateChats {
let handle = identityResolver.canonicalHandle(for: peerID, displayName: messages.last?.sender)
let conversationID = ConversationID.direct(handle)
liveConversations.insert(conversationID)
directHandlesByConversation[conversationID] = handle
messagesByConversation[conversationID] = normalized(messages)
}
let staleDirectConversations = messagesByConversation.keys.filter { conversationID in
guard case .direct = conversationID else { return false }
return !liveConversations.contains(conversationID)
}
for conversationID in staleDirectConversations {
messagesByConversation.removeValue(forKey: conversationID)
unreadConversations.remove(conversationID)
directHandlesByConversation.removeValue(forKey: conversationID)
}
let publicUnread = unreadConversations.filter { conversationID in
switch conversationID {
case .mesh, .geohash:
return true
case .direct:
return false
}
}
unreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in
let handle = identityResolver.canonicalHandle(for: peerID)
result.insert(.direct(handle))
}
}
func markRead(_ conversationID: ConversationID) {
unreadConversations.remove(conversationID)
}
func markRead(
peerID: PeerID,
identityResolver: IdentityResolver
) {
markRead(directConversationID(for: peerID, identityResolver: identityResolver))
}
private func normalized(_ messages: [BitchatMessage]) -> [BitchatMessage] {
var uniqueMessages: [String: BitchatMessage] = [:]
for message in messages {
uniqueMessages[message.id] = message
}
return uniqueMessages.values.sorted { lhs, rhs in
if lhs.timestamp != rhs.timestamp {
return lhs.timestamp < rhs.timestamp
}
return lhs.id < rhs.id
}
}
private func directConversationID(
for peerID: PeerID,
identityResolver: IdentityResolver
) -> ConversationID {
let handle = identityResolver.canonicalHandle(for: peerID)
let conversationID = ConversationID.direct(handle)
directHandlesByConversation[conversationID] = handle
return conversationID
}
}
-100
View File
@@ -1,100 +0,0 @@
import BitFoundation
import Combine
import CoreBluetooth
import Foundation
@MainActor
final class AppChromeModel: ObservableObject {
@Published private(set) var hasUnreadPrivateMessages = false
@Published var nickname: String
@Published var showingFingerprintFor: PeerID?
@Published var isAppInfoPresented = false
@Published var isLocationChannelsSheetPresented = false
@Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
@Published var showScreenshotPrivacyWarning = false
private let chatViewModel: ChatViewModel
private var cancellables = Set<AnyCancellable>()
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
self.chatViewModel = chatViewModel
self.nickname = chatViewModel.nickname
bind(privateInboxModel: privateInboxModel)
}
var shouldSuppressScreenshotNotification: Bool {
isLocationChannelsSheetPresented || isAppInfoPresented
}
func setNickname(_ nickname: String) {
self.nickname = nickname
if chatViewModel.nickname != nickname {
chatViewModel.nickname = nickname
}
}
func validateAndSaveNickname() {
chatViewModel.validateAndSaveNickname()
if nickname != chatViewModel.nickname {
nickname = chatViewModel.nickname
}
}
func openMostRelevantPrivateChat() {
chatViewModel.openMostRelevantPrivateChat()
}
func showFingerprint(for peerID: PeerID) {
showingFingerprintFor = peerID
}
func clearFingerprint() {
showingFingerprintFor = nil
}
func presentAppInfo() {
isAppInfoPresented = true
}
func triggerScreenshotPrivacyWarning() {
showScreenshotPrivacyWarning = true
}
func panicClearAllData() {
chatViewModel.panicClearAllData()
}
private func bind(privateInboxModel: PrivateInboxModel) {
privateInboxModel.$unreadPeerIDs
.receive(on: DispatchQueue.main)
.sink { [weak self] unreadPeerIDs in
self?.hasUnreadPrivateMessages = !unreadPeerIDs.isEmpty
}
.store(in: &cancellables)
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.sink { [weak self] nickname in
guard let self, self.nickname != nickname else { return }
self.nickname = nickname
}
.store(in: &cancellables)
chatViewModel.$showBluetoothAlert
.receive(on: DispatchQueue.main)
.assign(to: &$showBluetoothAlert)
chatViewModel.$bluetoothAlertMessage
.receive(on: DispatchQueue.main)
.assign(to: &$bluetoothAlertMessage)
chatViewModel.$bluetoothState
.receive(on: DispatchQueue.main)
.assign(to: &$bluetoothState)
hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty
}
}
-387
View File
@@ -1,387 +0,0 @@
import BitFoundation
import Combine
import Foundation
import SwiftUI
import Tor
import UserNotifications
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@MainActor
final class AppRuntime: ObservableObject {
let chatViewModel: ChatViewModel
let events = AppEventStream()
let conversationStore: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let publicChatModel: PublicChatModel
let privateInboxModel: PrivateInboxModel
let privateConversationModel: PrivateConversationModel
let verificationModel: VerificationModel
let conversationUIModel: ConversationUIModel
let locationChannelsModel: LocationChannelsModel
let peerListModel: PeerListModel
let appChromeModel: AppChromeModel
private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>()
private var started = false
private var lastNostrRelayConnectedState = false
private var didHandleInitialNostrConnection = false
#if os(iOS)
private var didHandleInitialActive = false
private var didEnterBackground = false
#endif
init(
keychain: KeychainManagerProtocol = KeychainManager(),
idBridge: NostrIdentityBridge = NostrIdentityBridge()
) {
self.idBridge = idBridge
let identityResolver = IdentityResolver()
let conversationStore = ConversationStore()
let peerIdentityStore = PeerIdentityStore()
let locationPresenceStore = LocationPresenceStore()
let locationManager = LocationChannelManager.shared
self.conversationStore = conversationStore
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.chatViewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain),
conversationStore: conversationStore,
identityResolver: identityResolver,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore,
locationManager: locationManager
)
self.publicChatModel = PublicChatModel(conversationStore: conversationStore)
self.privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
self.privateConversationModel = PrivateConversationModel(
chatViewModel: self.chatViewModel,
conversationStore: conversationStore,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore
)
self.verificationModel = VerificationModel(
chatViewModel: self.chatViewModel,
privateConversationModel: self.privateConversationModel,
peerIdentityStore: peerIdentityStore
)
self.conversationUIModel = ConversationUIModel(
chatViewModel: self.chatViewModel,
privateConversationModel: self.privateConversationModel,
conversationStore: conversationStore
)
self.peerListModel = PeerListModel(
chatViewModel: self.chatViewModel,
conversationStore: conversationStore,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore
)
self.appChromeModel = AppChromeModel(
chatViewModel: self.chatViewModel,
privateInboxModel: self.privateInboxModel
)
GeoRelayDirectory.shared.prefetchIfNeeded()
bindRuntimeObservers()
NotificationDelegate.shared.runtime = self
}
func start() {
guard !started else {
checkForSharedContent()
return
}
started = true
NotificationDelegate.shared.runtime = self
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
announceInitialTorStatusIfNeeded()
Task(priority: .utility) { [weak self] in
guard let self else { return }
let nickname = await MainActor.run { self.chatViewModel.nickname }
let npub = await MainActor.run {
try? self.idBridge.getCurrentNostrIdentity()?.npub
}
await MainActor.run {
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
}
}
NetworkActivationService.shared.start()
GeohashPresenceService.shared.start()
checkForSharedContent()
record(.launched)
record(.startupCompleted)
}
func handleOpenURL(_ url: URL) {
record(.openedURL(url.absoluteString))
if url.scheme == "bitchat", url.host == "share" {
checkForSharedContent()
}
}
func handleDidBecomeActiveNotification() {
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
}
#if os(macOS)
func handleMacDidBecomeActiveNotification() {
record(.scenePhaseChanged(.active))
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
}
#endif
#if os(iOS)
func handleScenePhaseChange(_ newPhase: ScenePhase) {
switch newPhase {
case .background:
record(.scenePhaseChanged(.background))
TorManager.shared.setAppForeground(false)
TorManager.shared.goDormantOnBackground()
chatViewModel.endGeohashSampling()
NostrRelayManager.shared.disconnect()
didEnterBackground = true
case .active:
record(.scenePhaseChanged(.active))
chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true)
let shouldRefreshNostrConnections = didHandleInitialActive && didEnterBackground
if didHandleInitialActive && didEnterBackground {
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
TorManager.shared.ensureRunningOnForeground()
}
} else {
didHandleInitialActive = true
}
didEnterBackground = false
if shouldRefreshNostrConnections && TorManager.shared.isAutoStartAllowed() {
Task.detached {
let _ = await TorManager.shared.awaitReady(timeout: 60)
await MainActor.run {
TorURLSession.shared.rebuild()
NostrRelayManager.shared.resetAllConnections()
}
}
}
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
case .inactive:
record(.scenePhaseChanged(.inactive))
@unknown default:
break
}
}
#endif
func applicationWillTerminate() {
record(.terminationRequested)
chatViewModel.applicationWillTerminate()
}
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
record(.notificationOpened(peerID: peerID))
chatViewModel.startPrivateChat(with: peerID)
}
if let deepLink = userInfo["deeplink"] as? String, let url = URL(string: deepLink) {
record(.deepLinkOpened(deepLink))
openExternalURL(url)
}
}
func presentationOptions(
forNotificationIdentifier identifier: String,
userInfo: [AnyHashable: Any]
) async -> UNNotificationPresentationOptions {
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
if conversationStore.selectedPrivatePeerID == peerID {
return []
}
return [.banner, .sound]
}
if identifier.hasPrefix("geo-activity-"),
let deepLink = userInfo["deeplink"] as? String,
let geohash = deepLink.components(separatedBy: "/").last,
case .location(let channel) = locationChannelsModel.selectedChannel,
channel.geohash == geohash {
return []
}
return [.banner, .sound]
}
}
private extension AppRuntime {
func bindRuntimeObservers() {
NostrRelayManager.shared.$isConnected
.receive(on: DispatchQueue.main)
.sink { [weak self] isConnected in
self?.handleNostrRelayConnectionChanged(isConnected)
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorWillRestart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.record(.torLifecycleChanged(.willRestart))
self?.chatViewModel.handleTorWillRestart()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.record(.torLifecycleChanged(.didBecomeReady))
self?.chatViewModel.handleTorDidBecomeReady()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorWillStart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.record(.torLifecycleChanged(.willStart))
self?.chatViewModel.handleTorWillStart()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in
self?.record(.torLifecycleChanged(.preferenceChanged))
self?.chatViewModel.handleTorPreferenceChanged(notification)
}
.store(in: &cancellables)
#if os(iOS)
NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.handleScreenshotCaptured()
}
.store(in: &cancellables)
#endif
}
func checkForSharedContent() {
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
return
}
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
return
}
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
switch contentKind {
case .url:
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"] {
chatViewModel.sendMessage(url)
} else {
chatViewModel.sendMessage(sharedContent)
}
case .text:
chatViewModel.sendMessage(sharedContent)
}
record(.sharedContentAccepted(contentKind))
}
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
record(.nostrRelayConnectionChanged(isConnected))
let becameConnected = isConnected && !lastNostrRelayConnectedState
lastNostrRelayConnectedState = isConnected
guard started, becameConnected else { return }
let isInitialConnection = !didHandleInitialNostrConnection
didHandleInitialNostrConnection = true
if !chatViewModel.nostrHandlersSetup {
chatViewModel.setupNostrMessageHandling()
chatViewModel.nostrHandlersSetup = true
}
guard !isInitialConnection else { return }
chatViewModel.resubscribeCurrentGeohash()
chatViewModel.geoChannelCoordinator?.refreshSampling()
}
func announceInitialTorStatusIfNeeded() {
if TorManager.shared.torEnforced &&
!chatViewModel.torStatusAnnounced &&
TorManager.shared.isAutoStartAllowed() {
chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
)
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
)
}
}
func handleScreenshotCaptured() {
if appChromeModel.isLocationChannelsSheetPresented {
appChromeModel.triggerScreenshotPrivacyWarning()
return
}
if appChromeModel.isAppInfoPresented {
return
}
chatViewModel.handleScreenshotCaptured()
}
func openExternalURL(_ url: URL) {
#if os(iOS)
UIApplication.shared.open(url)
#else
NSWorkspace.shared.open(url)
#endif
}
func record(_ event: AppEvent) {
Task {
await events.emit(event)
}
}
}
-183
View File
@@ -1,183 +0,0 @@
import BitFoundation
import Combine
import SwiftUI
#if os(iOS)
import UIKit
#endif
@MainActor
final class ConversationUIModel: ObservableObject {
@Published private(set) var showAutocomplete = false
@Published private(set) var autocompleteSuggestions: [String] = []
@Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true
private let chatViewModel: ChatViewModel
private let privateConversationModel: PrivateConversationModel
private let conversationStore: ConversationStore
private var activeChannel: ChannelID
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
privateConversationModel: PrivateConversationModel,
conversationStore: ConversationStore
) {
self.chatViewModel = chatViewModel
self.privateConversationModel = privateConversationModel
self.conversationStore = conversationStore
self.activeChannel = conversationStore.activeChannel
self.currentNickname = chatViewModel.nickname
self.isBatchingPublic = chatViewModel.isBatchingPublic
self.showAutocomplete = chatViewModel.showAutocomplete
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
bind()
}
func setCurrentColorScheme(_ colorScheme: ColorScheme) {
chatViewModel.currentColorScheme = colorScheme
}
func sendMessage(_ message: String) {
chatViewModel.sendMessage(message)
}
func clearCurrentConversation() {
chatViewModel.sendMessage("/clear")
}
func sendHug(to sender: String) {
chatViewModel.sendMessage("/hug @\(sender)")
}
func sendSlap(to sender: String) {
chatViewModel.sendMessage("/slap @\(sender)")
}
func block(peerID: PeerID?, displayName: String?) {
guard let displayName else { return }
if let peerID, peerID.isGeoChat,
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
} else {
chatViewModel.sendMessage("/block \(displayName)")
}
}
func updateAutocomplete(for text: String, cursorPosition: Int) {
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
}
func completeNickname(_ nickname: String, in text: inout String) -> Int {
chatViewModel.completeNickname(nickname, in: &text)
}
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme)
}
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme)
}
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
message.mediaAttachment(for: currentNickname)
}
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
chatViewModel.isSelfSender(peerID: peerID, displayName: displayName)
}
func isSentByCurrentUser(_ message: BitchatMessage) -> Bool {
message.sender == currentNickname || message.sender.hasPrefix(currentNickname + "#")
}
func isMediaMessageFromCurrentUser(_ message: BitchatMessage) -> Bool {
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
}
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
if peerID.isGeoDM || peerID.isGeoChat {
return chatViewModel.geohashDisplayName(for: peerID)
}
if let nickname = chatViewModel.meshService.peerNickname(peerID: peerID) {
return nickname
}
return fallbackMessages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
}
#if os(iOS)
func processSelectedImage(_ image: UIImage?) {
chatViewModel.processThenSendImage(image)
}
#endif
func processSelectedImage(from url: URL?) {
#if os(macOS)
chatViewModel.processThenSendImage(from: url)
#endif
}
func sendVoiceNote(at url: URL) {
chatViewModel.sendVoiceNote(at: url)
}
func cancelMediaSend(messageID: String) {
chatViewModel.cancelMediaSend(messageID: messageID)
}
func deleteMediaMessage(messageID: String) {
chatViewModel.deleteMediaMessage(messageID: messageID)
}
private func bind() {
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.assign(to: &$currentNickname)
chatViewModel.$showAutocomplete
.receive(on: DispatchQueue.main)
.assign(to: &$showAutocomplete)
chatViewModel.$autocompleteSuggestions
.receive(on: DispatchQueue.main)
.assign(to: &$autocompleteSuggestions)
chatViewModel.$isBatchingPublic
.receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic)
conversationStore.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
self?.activeChannel = channel
self?.refreshComputedState()
}
.store(in: &cancellables)
privateConversationModel.$selectedPeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshComputedState()
}
.store(in: &cancellables)
}
private func refreshComputedState() {
if let selectedPeerID = privateConversationModel.selectedPeerID {
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
return
}
switch activeChannel {
case .mesh:
canSendMediaInCurrentContext = true
case .location:
canSendMediaInCurrentContext = false
}
}
}
-176
View File
@@ -1,176 +0,0 @@
import BitFoundation
import Combine
import Foundation
@MainActor
final class LocationChannelsModel: ObservableObject {
@Published private(set) var permissionState: LocationChannelManager.PermissionState
@Published private(set) var availableChannels: [GeohashChannel]
@Published private(set) var selectedChannel: ChannelID
@Published private(set) var teleported: Bool
@Published private(set) var bookmarks: [String]
@Published private(set) var bookmarkNames: [String: String]
@Published private(set) var locationNames: [GeohashChannelLevel: String]
@Published private(set) var userTorEnabled: Bool
private let manager: LocationChannelManager
private let network: NetworkActivationService
private var cancellables = Set<AnyCancellable>()
init(
manager: LocationChannelManager? = nil,
network: NetworkActivationService? = nil
) {
let manager = manager ?? .shared
let network = network ?? .shared
self.manager = manager
self.network = network
self.permissionState = manager.permissionState
self.availableChannels = manager.availableChannels
self.selectedChannel = manager.selectedChannel
self.teleported = manager.teleported
self.bookmarks = manager.bookmarks
self.bookmarkNames = manager.bookmarkNames
self.locationNames = manager.locationNames
self.userTorEnabled = network.userTorEnabled
bind()
}
var currentBuildingGeohash: String? {
availableChannels.first(where: { $0.level == .building })?.geohash
}
func isSelected(_ channel: GeohashChannel) -> Bool {
guard case .location(let selected) = selectedChannel else { return false }
return selected == channel
}
func isBookmarked(_ geohash: String) -> Bool {
manager.isBookmarked(geohash)
}
func enableLocationChannels() {
manager.enableLocationChannels()
}
func refreshChannels() {
manager.refreshChannels()
}
func enableAndRefresh() {
manager.enableLocationChannels()
manager.refreshChannels()
}
func beginLiveRefresh() {
manager.beginLiveRefresh()
}
func endLiveRefresh() {
manager.endLiveRefresh()
}
func select(_ channel: ChannelID) {
manager.select(channel)
}
func markTeleported(for geohash: String, _ flag: Bool) {
manager.markTeleported(for: geohash, flag)
}
func toggleBookmark(_ geohash: String) {
manager.toggleBookmark(geohash)
}
func resolveBookmarkNameIfNeeded(for geohash: String) {
manager.resolveBookmarkNameIfNeeded(for: geohash)
}
func locationName(for level: GeohashChannelLevel) -> String? {
locationNames[level]
}
func setUserTorEnabled(_ enabled: Bool) {
network.setUserTorEnabled(enabled)
}
func refreshMeshChannelsIfNeeded() {
guard case .mesh = selectedChannel,
permissionState == .authorized,
availableChannels.isEmpty else {
return
}
refreshChannels()
}
func openLocationChannel(for geohash: String) {
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(normalized.count),
normalized.allSatisfy({ allowed.contains($0) }) else {
return
}
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
let isRegional = availableChannels.contains { $0.geohash == normalized }
if !isRegional && !availableChannels.isEmpty {
markTeleported(for: normalized, true)
}
select(.location(channel))
}
func teleport(to geohash: String) {
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
markTeleported(for: normalized, true)
select(.location(channel))
}
private func bind() {
manager.$permissionState
.receive(on: DispatchQueue.main)
.assign(to: &$permissionState)
manager.$availableChannels
.receive(on: DispatchQueue.main)
.assign(to: &$availableChannels)
manager.$selectedChannel
.receive(on: DispatchQueue.main)
.assign(to: &$selectedChannel)
manager.$teleported
.receive(on: DispatchQueue.main)
.assign(to: &$teleported)
manager.$bookmarks
.receive(on: DispatchQueue.main)
.assign(to: &$bookmarks)
manager.$bookmarkNames
.receive(on: DispatchQueue.main)
.assign(to: &$bookmarkNames)
manager.$locationNames
.receive(on: DispatchQueue.main)
.assign(to: &$locationNames)
network.$userTorEnabled
.receive(on: DispatchQueue.main)
.assign(to: &$userTorEnabled)
}
private func level(forLength length: Int) -> GeohashChannelLevel {
switch length {
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
case 8...12: return .building
default: return .block
}
}
}
-51
View File
@@ -1,51 +0,0 @@
import Combine
import Foundation
@MainActor
final class LocationPresenceStore: ObservableObject {
@Published private(set) var currentGeohash: String?
@Published private(set) var geoNicknames: [String: String] = [:]
@Published private(set) var teleportedGeo: Set<String> = []
func setCurrentGeohash(_ geohash: String?) {
currentGeohash = geohash?.lowercased()
}
func setNickname(_ nickname: String, for pubkeyHex: String) {
geoNicknames[pubkeyHex.lowercased()] = nickname
}
func replaceGeoNicknames(_ nicknames: [String: String]) {
geoNicknames = Dictionary(
uniqueKeysWithValues: nicknames.map { key, value in
(key.lowercased(), value)
}
)
}
func clearGeoNicknames() {
geoNicknames.removeAll()
}
func markTeleported(_ pubkeyHex: String) {
teleportedGeo.insert(pubkeyHex.lowercased())
}
func clearTeleported(_ pubkeyHex: String) {
teleportedGeo.remove(pubkeyHex.lowercased())
}
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
}
func clearTeleportedGeo() {
teleportedGeo.removeAll()
}
func reset() {
currentGeohash = nil
geoNicknames.removeAll()
teleportedGeo.removeAll()
}
}
-125
View File
@@ -1,125 +0,0 @@
import BitFoundation
import Combine
import Foundation
@MainActor
final class PeerIdentityStore: ObservableObject {
@Published private(set) var encryptionStatuses: [PeerID: EncryptionStatus] = [:]
@Published private(set) var verifiedFingerprints: Set<String> = []
private(set) var peerFingerprintsByPeerID: [PeerID: String] = [:]
private(set) var selectedPrivateChatFingerprint: String?
private var stablePeerIDsByShortID: [PeerID: PeerID] = [:]
private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:]
func stablePeerID(forShortID peerID: PeerID) -> PeerID? {
stablePeerIDsByShortID[peerID]
}
func shortPeerID(forStablePeerID stablePeerID: PeerID) -> PeerID? {
stablePeerIDsByShortID.first(where: { $0.value == stablePeerID })?.key
}
func setStablePeerID(_ stablePeerID: PeerID, forShortID peerID: PeerID) {
stablePeerIDsByShortID[peerID] = stablePeerID
}
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
stablePeerIDsByShortID = mappings
}
func fingerprint(for peerID: PeerID) -> String? {
peerFingerprintsByPeerID[peerID]
}
func setFingerprint(_ fingerprint: String?, for peerID: PeerID) {
if let fingerprint {
peerFingerprintsByPeerID[peerID] = fingerprint
} else {
peerFingerprintsByPeerID.removeValue(forKey: peerID)
}
}
func replaceFingerprintMappings(_ mappings: [PeerID: String]) {
peerFingerprintsByPeerID = mappings
}
@discardableResult
func migrateFingerprintMapping(
from oldPeerID: PeerID,
to newPeerID: PeerID,
fallback: String? = nil
) -> String? {
let fingerprint = peerFingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback
if let fingerprint {
peerFingerprintsByPeerID[newPeerID] = fingerprint
if selectedPrivateChatFingerprint == nil {
selectedPrivateChatFingerprint = fingerprint
}
}
return fingerprint
}
func setSelectedPrivateChatFingerprint(_ fingerprint: String?) {
selectedPrivateChatFingerprint = fingerprint
}
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
encryptionStatusCache[peerID]
}
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
encryptionStatusCache[peerID] = status
}
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
if let peerID {
encryptionStatusCache.removeValue(forKey: peerID)
} else {
encryptionStatusCache.removeAll()
}
}
func encryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
encryptionStatuses[peerID]
}
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
if let status {
encryptionStatuses[peerID] = status
} else {
encryptionStatuses.removeValue(forKey: peerID)
}
invalidateEncryptionCache(for: peerID)
}
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
encryptionStatuses = statuses
}
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
verifiedFingerprints = fingerprints
}
func setVerified(_ fingerprint: String, verified: Bool) {
if verified {
verifiedFingerprints.insert(fingerprint)
} else {
verifiedFingerprints.remove(fingerprint)
}
}
func isVerified(_ fingerprint: String) -> Bool {
verifiedFingerprints.contains(fingerprint)
}
func clearAll() {
encryptionStatuses.removeAll()
verifiedFingerprints.removeAll()
peerFingerprintsByPeerID.removeAll()
selectedPrivateChatFingerprint = nil
stablePeerIDsByShortID.removeAll()
encryptionStatusCache.removeAll()
}
}
-260
View File
@@ -1,260 +0,0 @@
import BitFoundation
import Combine
import SwiftUI
struct MeshPeerRow: Identifiable, Equatable {
let peerID: PeerID
let displayName: String
let isMe: Bool
let hasUnread: Bool
let isBlocked: Bool
let isFavorite: Bool
let isConnected: Bool
let isReachable: Bool
let isMutualFavorite: Bool
let encryptionStatus: EncryptionStatus
let showsVerifiedBadgeWhenOffline: Bool
var id: String { peerID.id }
}
struct GeohashPersonRow: Identifiable, Equatable {
let id: String
let displayName: String
let isMe: Bool
let isTeleported: Bool
let isBlocked: Bool
}
@MainActor
final class PeerListModel: ObservableObject {
@Published private(set) var allPeers: [BitchatPeer] = []
@Published private(set) var meshRows: [MeshPeerRow] = []
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
@Published private(set) var reachableMeshPeerCount = 0
@Published private(set) var connectedMeshPeerCount = 0
@Published private(set) var visibleGeohashPeerCount = 0
@Published private(set) var renderID = ""
private let chatViewModel: ChatViewModel
private let conversationStore: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private let locationPresenceStore: LocationPresenceStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
conversationStore: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversationStore = conversationStore
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
self.allPeers = chatViewModel.allPeers
bind()
refresh()
}
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark)
}
func colorForGeohashPerson(id: String, isDark: Bool) -> Color {
chatViewModel.colorForNostrPubkey(id, isDark: isDark)
}
func participantCount(for geohash: String) -> Int {
chatViewModel.geohashParticipantCount(for: geohash)
}
func startConversation(with peerID: PeerID) {
chatViewModel.startPrivateChat(with: peerID)
}
func toggleFavorite(peerID: PeerID) {
chatViewModel.toggleFavorite(peerID: peerID)
}
func openGeohashDirectMessage(with pubkeyHex: String) {
chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex)
}
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
chatViewModel.blockGeohashUser(
pubkeyHexLowercased: pubkeyHexLowercased,
displayName: displayName
)
}
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
chatViewModel.unblockGeohashUser(
pubkeyHexLowercased: pubkeyHexLowercased,
displayName: displayName
)
}
private func bind() {
chatViewModel.$allPeers
.receive(on: DispatchQueue.main)
.sink { [weak self] peers in
self?.allPeers = peers
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationPresenceStore.$teleportedGeo
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
conversationStore.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
peerIdentityStore.$verifiedFingerprints
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.participantTracker.$visiblePeople
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$teleported
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
}
private func refresh() {
let myPeerID = chatViewModel.meshService.myPeerID
let meshRows = allPeers.map { peer in
let isMe = peer.peerID == myPeerID
let verifiedBadge: Bool
if !isMe && !peer.isConnected,
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
} else {
verifiedBadge = false
}
return MeshPeerRow(
peerID: peer.peerID,
displayName: isMe ? chatViewModel.nickname : peer.nickname,
isMe: isMe,
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
isConnected: peer.isConnected,
isReachable: peer.isReachable,
isMutualFavorite: peer.isMutualFavorite,
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
showsVerifiedBadgeWhenOffline: verifiedBadge
)
}
let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in
guard !row.isMe else { return }
if row.isConnected {
counts.connected += 1
counts.reachable += 1
} else if row.isReachable {
counts.reachable += 1
}
}
let geohashPeople = buildGeohashPeople()
self.meshRows = meshRows
reachableMeshPeerCount = meshCounts.reachable
connectedMeshPeerCount = meshCounts.connected
self.geohashPeople = geohashPeople
visibleGeohashPeerCount = geohashPeople.count
renderID = (
meshRows.map {
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
} +
geohashPeople.map {
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
}
).joined(separator: "|")
}
private func buildGeohashPeople() -> [GeohashPersonRow] {
let myHex = currentGeohashIdentityHex()
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
return chatViewModel.visibleGeohashPeople().map { person in
let isMe = person.id == myHex
return GeohashPersonRow(
id: person.id,
displayName: person.displayName,
isMe: isMe,
isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported),
isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
)
}
}
private func currentGeohashIdentityHex() -> String? {
guard case .location(let channel) = locationChannelsModel.selectedChannel,
let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else {
return nil
}
return identity.publicKeyHex.lowercased()
}
}
-298
View File
@@ -1,298 +0,0 @@
import BitFoundation
import Combine
import Foundation
@MainActor
final class PrivateInboxModel: ObservableObject {
@Published private(set) var selectedPeerID: PeerID?
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
@Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
private let conversationStore: ConversationStore
private var cancellables = Set<AnyCancellable>()
init(conversationStore: ConversationStore) {
self.conversationStore = conversationStore
bind()
refreshMessages()
}
func messages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return [] }
return messagesByPeerID[peerID] ?? []
}
private func bind() {
conversationStore.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] peerID in
self?.selectedPeerID = peerID
self?.refreshMessages()
}
.store(in: &cancellables)
conversationStore.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.unreadPeerIDs = self?.conversationStore.unreadDirectPeerIDs() ?? []
self?.refreshMessages()
}
.store(in: &cancellables)
conversationStore.$messagesByConversation
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshMessages()
}
.store(in: &cancellables)
selectedPeerID = conversationStore.selectedPrivatePeerID
unreadPeerIDs = conversationStore.unreadDirectPeerIDs()
}
private func refreshMessages() {
var nextMessagesByPeerID = conversationStore.directMessagesByPeerID()
var peerIDs = Set(nextMessagesByPeerID.keys)
peerIDs.formUnion(conversationStore.unreadDirectPeerIDs())
if let selectedPeerID = conversationStore.selectedPrivatePeerID {
peerIDs.insert(selectedPeerID)
}
for peerID in peerIDs where nextMessagesByPeerID[peerID] == nil {
nextMessagesByPeerID[peerID] = []
}
messagesByPeerID = nextMessagesByPeerID
}
}
enum PrivateConversationAvailability: Equatable {
case bluetoothConnected
case meshReachable
case nostrAvailable
case offline
}
struct PrivateConversationHeaderState: Equatable {
let conversationPeerID: PeerID
let headerPeerID: PeerID
let displayName: String
let availability: PrivateConversationAvailability
let isFavorite: Bool
let encryptionStatus: EncryptionStatus?
var supportsFavoriteToggle: Bool {
!conversationPeerID.isGeoDM
}
}
@MainActor
final class PrivateConversationModel: ObservableObject {
@Published private(set) var selectedPeerID: PeerID?
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
private let chatViewModel: ChatViewModel
private let conversationStore: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
conversationStore: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversationStore = conversationStore
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
let initialPeerID = conversationStore.selectedPrivatePeerID
self.selectedPeerID = initialPeerID
self.selectedHeaderState = initialPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
}
bind()
}
func startConversation(with peerID: PeerID) {
chatViewModel.startPrivateChat(with: peerID)
refreshSelectedConversation()
}
func openConversation(for peerID: PeerID) {
if peerID.isGeoChat {
guard let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) else { return }
chatViewModel.startGeohashDM(withPubkeyHex: full)
} else {
chatViewModel.startPrivateChat(with: peerID)
}
refreshSelectedConversation()
}
func endConversation() {
chatViewModel.endPrivateChat()
refreshSelectedConversation()
}
func toggleFavorite(peerID: PeerID) {
chatViewModel.toggleFavorite(peerID: peerID)
refreshSelectedConversation()
}
func toggleFavoriteForSelectedConversation() {
guard let headerPeerID = selectedHeaderState?.headerPeerID else { return }
toggleFavorite(peerID: headerPeerID)
}
func markMessagesAsRead(from peerID: PeerID) {
chatViewModel.markPrivateMessagesAsRead(from: peerID)
}
private func bind() {
conversationStore.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
chatViewModel.$allPeers
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
locationChannelsModel.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
}
private func refreshSelectedConversation() {
selectedPeerID = conversationStore.selectedPrivatePeerID
selectedHeaderState = selectedPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
}
}
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
let availability = resolveAvailability(for: headerPeerID, peer: peer)
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
? nil
: chatViewModel.getEncryptionStatus(for: headerPeerID)
return PrivateConversationHeaderState(
conversationPeerID: conversationPeerID,
headerPeerID: headerPeerID,
displayName: displayName,
availability: availability,
isFavorite: chatViewModel.isFavorite(peerID: headerPeerID),
encryptionStatus: encryptionStatus
)
}
private func resolveDisplayName(
for conversationPeerID: PeerID,
headerPeerID: PeerID,
peer: BitchatPeer?
) -> String {
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
}
if let displayName = peer?.displayName {
return displayName
}
if let nickname = chatViewModel.meshService.peerNickname(peerID: headerPeerID) {
return nickname
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(
for: Data(hexString: headerPeerID.id) ?? Data()
), !favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
if headerPeerID.id.count == 16 {
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let identity = candidates.first,
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
}
} else if let noiseKey = headerPeerID.noiseKey {
let fingerprint = noiseKey.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
}
}
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
}
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
if let connectionState = peer?.connectionState {
switch connectionState {
case .bluetoothConnected:
return .bluetoothConnected
case .meshReachable:
return .meshReachable
case .nostrAvailable:
return .nostrAvailable
case .offline:
return .offline
}
}
if chatViewModel.meshService.isPeerReachable(headerPeerID) {
return .meshReachable
}
if let noiseKey = Data(hexString: headerPeerID.id),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
favoriteStatus.isMutual {
return .nostrAvailable
}
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
return .bluetoothConnected
}
return .offline
}
}
-41
View File
@@ -1,41 +0,0 @@
import BitFoundation
import Combine
import SwiftUI
@MainActor
final class PublicChatModel: ObservableObject {
@Published private(set) var activeChannel: ChannelID
@Published private(set) var messages: [BitchatMessage] = []
private let conversationStore: ConversationStore
private var cancellables = Set<AnyCancellable>()
init(conversationStore: ConversationStore) {
self.activeChannel = conversationStore.activeChannel
self.conversationStore = conversationStore
bind()
refreshMessages()
}
private func bind() {
conversationStore.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
self?.activeChannel = channel
self?.refreshMessages()
}
.store(in: &cancellables)
conversationStore.$messagesByConversation
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshMessages()
}
.store(in: &cancellables)
}
private func refreshMessages() {
messages = conversationStore.messages(for: ConversationID(channelID: activeChannel))
}
}
-152
View File
@@ -1,152 +0,0 @@
import BitFoundation
import Combine
import Foundation
struct FingerprintPresentationState: Equatable {
let statusPeerID: PeerID
let peerNickname: String
let encryptionStatus: EncryptionStatus
let theirFingerprint: String?
let myFingerprint: String
let isVerified: Bool
var canToggleVerification: Bool {
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
}
}
enum VerificationScanOutcome: Equatable {
case requested(String)
case notFound
case invalid
}
@MainActor
final class VerificationModel: ObservableObject {
@Published private(set) var currentNickname: String
@Published private(set) var selectedPeerID: PeerID?
private let chatViewModel: ChatViewModel
private let peerIdentityStore: PeerIdentityStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
privateConversationModel: PrivateConversationModel,
peerIdentityStore: PeerIdentityStore? = nil
) {
self.chatViewModel = chatViewModel
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
self.currentNickname = chatViewModel.nickname
self.selectedPeerID = privateConversationModel.selectedPeerID
bind(privateConversationModel: privateConversationModel)
}
func myQRString() -> String {
let npub = try? chatViewModel.idBridge.getCurrentNostrIdentity()?.npub
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
}
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
chatViewModel.beginQRVerification(with: qr)
}
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
return .invalid
}
guard chatViewModel.beginQRVerification(with: qr) else {
return .notFound
}
return .requested(qr.nickname)
}
func verifyFingerprint(for peerID: PeerID) {
chatViewModel.verifyFingerprint(for: peerID)
}
func unverifyFingerprint(for peerID: PeerID) {
chatViewModel.unverifyFingerprint(for: peerID)
}
func isVerified(peerID: PeerID) -> Bool {
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
return peerIdentityStore.isVerified(fingerprint)
}
func fingerprintPresentation(for peerID: PeerID) -> FingerprintPresentationState {
let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID)
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
return FingerprintPresentationState(
statusPeerID: statusPeerID,
peerNickname: peerNickname,
encryptionStatus: encryptionStatus,
theirFingerprint: theirFingerprint,
myFingerprint: chatViewModel.getMyFingerprint(),
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
)
}
private func bind(privateConversationModel: PrivateConversationModel) {
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.assign(to: &$currentNickname)
privateConversationModel.$selectedPeerID
.receive(on: DispatchQueue.main)
.assign(to: &$selectedPeerID)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
peerIdentityStore.$verifiedFingerprints
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
chatViewModel.$allPeers
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
if let peer = chatViewModel.getPeer(byID: statusPeerID) {
return peer.displayName
}
if let name = chatViewModel.meshService.peerNickname(peerID: statusPeerID) {
return name
}
if let data = peerID.noiseKey {
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: data),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
let fingerprint = data.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
}
}
return String(localized: "common.unknown", comment: "Label for an unknown peer")
}
}
+194 -44
View File
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org>
//
import Tor
import SwiftUI
import UserNotifications
@@ -13,48 +14,116 @@ import UserNotifications
struct BitchatApp: App {
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
static let groupID = "group.\(bundleID)"
@StateObject private var runtime: AppRuntime
@StateObject private var chatViewModel: ChatViewModel
#if os(iOS)
@Environment(\.scenePhase) var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
// Skip the very first .active-triggered Tor restart on cold launch
@State private var didHandleInitialActive: Bool = false
@State private var didEnterBackground: Bool = false
#elseif os(macOS)
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
#endif
private let idBridge = NostrIdentityBridge()
init() {
_runtime = StateObject(wrappedValue: AppRuntime())
let keychain = KeychainManager()
let idBridge = self.idBridge
_chatViewModel = StateObject(
wrappedValue: ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain)
)
)
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
// Warm up georelay directory and refresh if stale (once/day)
GeoRelayDirectory.shared.prefetchIfNeeded()
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(runtime.publicChatModel)
.environmentObject(runtime.privateInboxModel)
.environmentObject(runtime.privateConversationModel)
.environmentObject(runtime.verificationModel)
.environmentObject(runtime.conversationUIModel)
.environmentObject(runtime.locationChannelsModel)
.environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel)
.environmentObject(chatViewModel)
.onAppear {
appDelegate.runtime = runtime
runtime.start()
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? idBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
}
#if os(iOS)
appDelegate.chatViewModel = chatViewModel
#elseif os(macOS)
appDelegate.chatViewModel = chatViewModel
#endif
// Initialize network activation policy; will start Tor/Nostr only when allowed
NetworkActivationService.shared.start()
// Check for shared content
checkForSharedContent()
}
.onOpenURL { url in
runtime.handleOpenURL(url)
handleURL(url)
}
#if os(iOS)
.onChange(of: scenePhase) { newPhase in
runtime.handleScenePhaseChange(newPhase)
switch newPhase {
case .background:
// Keep BLE mesh running in background; BLEService adapts scanning automatically
// Always send Tor to dormant on background for a clean restart later.
TorManager.shared.setAppForeground(false)
TorManager.shared.goDormantOnBackground()
// Stop geohash sampling while backgrounded
Task { @MainActor in
chatViewModel.endGeohashSampling()
}
// Proactively disconnect Nostr to avoid spurious socket errors while Tor is down
NostrRelayManager.shared.disconnect()
didEnterBackground = true
case .active:
// Restart services when becoming active
chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true)
// On initial cold launch, Tor was just started in onAppear.
// Skip the deterministic restart the first time we become active.
if didHandleInitialActive && didEnterBackground {
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
TorManager.shared.ensureRunningOnForeground()
}
} else {
didHandleInitialActive = true
}
didEnterBackground = false
if TorManager.shared.isAutoStartAllowed() {
Task.detached {
let _ = await TorManager.shared.awaitReady(timeout: 60)
await MainActor.run {
// Rebuild proxied sessions to bind to the live Tor after readiness
TorURLSession.shared.rebuild()
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
NostrRelayManager.shared.resetAllConnections()
}
}
}
checkForSharedContent()
case .inactive:
break
@unknown default:
break
}
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
runtime.handleDidBecomeActiveNotification()
// Check for shared content when app becomes active
checkForSharedContent()
}
#elseif os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
runtime.handleMacDidBecomeActiveNotification()
// App became active
}
#endif
}
@@ -63,18 +132,62 @@ struct BitchatApp: App {
.windowResizability(.contentSize)
#endif
}
private func handleURL(_ url: URL) {
if url.scheme == "bitchat" && url.host == "share" {
// Handle shared content
checkForSharedContent()
}
}
private func checkForSharedContent() {
// Check app group for shared content from extension
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
return
}
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
return
}
// Only process if shared within configured window
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
// Clear the shared content
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
// No need to force synchronize here
// Send the shared content immediately on the main queue
DispatchQueue.main.async {
if contentType == "url" {
// Try to parse as JSON first
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"] {
// Send plain URL
self.chatViewModel.sendMessage(url)
} else {
// Fallback to simple URL
self.chatViewModel.sendMessage(sharedContent)
}
} else {
self.chatViewModel.sendMessage(sharedContent)
}
}
}
}
}
#if os(iOS)
final class AppDelegate: NSObject, UIApplicationDelegate {
weak var runtime: AppRuntime?
weak var chatViewModel: ChatViewModel?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
true
}
func applicationWillTerminate(_ application: UIApplication) {
runtime?.applicationWillTerminate()
return true
}
}
#endif
@@ -83,42 +196,79 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
import AppKit
final class MacAppDelegate: NSObject, NSApplicationDelegate {
weak var runtime: AppRuntime?
weak var chatViewModel: ChatViewModel?
func applicationWillTerminate(_ notification: Notification) {
runtime?.applicationWillTerminate()
chatViewModel?.applicationWillTerminate()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
return true
}
}
#endif
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate()
weak var runtime: AppRuntime?
weak var chatViewModel: ChatViewModel?
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let identifier = response.notification.request.identifier
let userInfo = response.notification.request.content.userInfo
Task { @MainActor in
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
// Check if this is a private message notification
if identifier.hasPrefix("private-") {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
DispatchQueue.main.async {
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
}
}
}
// Handle deeplink (e.g., geohash activity)
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
#if os(iOS)
DispatchQueue.main.async { UIApplication.shared.open(url) }
#else
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
#endif
}
completionHandler()
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
let identifier = notification.request.identifier
let userInfo = notification.request.content.userInfo
Task {
let options = await self.runtime?.presentationOptions(
forNotificationIdentifier: identifier,
userInfo: userInfo
) ?? [.banner, .sound]
completionHandler(options)
// Check if this is a private message notification
if identifier.hasPrefix("private-") {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
// Don't show notification if the private chat is already open
if chatViewModel?.selectedPrivateChatPeer == peerID {
completionHandler([])
return
}
}
}
// Suppress geohash activity notification if we're already in that geohash channel
if identifier.hasPrefix("geo-activity-"),
let deep = userInfo["deeplink"] as? String,
let gh = deep.components(separatedBy: "/").last {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
completionHandler([])
return
}
}
// Show notification in all other cases
completionHandler([.banner, .sound])
}
}
extension String {
var nilIfEmpty: String? {
self.isEmpty ? nil : self
}
}
-217
View File
@@ -1,217 +0,0 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
#if os(iOS)
import UIKit
#else
import AppKit
#endif
enum ImageUtilsError: Error {
case invalidImage
case encodingFailed
}
enum ImageUtils {
private static let compressionQuality: CGFloat = 0.82
private static let targetImageBytes: Int = 45_000
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
try validateImageSource(at: url)
let data = try Data(contentsOf: url)
#if os(iOS)
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
#else
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
#endif
}
static func validateImageSource(at url: URL) throws {
// Security H1: Check file size BEFORE reading into memory.
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
guard let fileSize = attrs[.size] as? Int,
fileSize > 0,
fileSize <= maxSourceImageBytes else {
throw ImageUtilsError.invalidImage
}
let options = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, options),
CGImageSourceGetType(source) != nil else {
throw ImageUtilsError.invalidImage
}
}
#if os(iOS)
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
return try autoreleasepool {
// Scale the image first
let scaled = scaledImage(image, maxDimension: maxDimension)
// Get CGImage from UIImage - this is the key to stripping metadata
guard let cgImage = scaled.cgImage else {
throw ImageUtilsError.encodingFailed
}
// Use CGImageDestination to encode without metadata (same as macOS)
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed
}
// Compress to target size
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
try jpegData.write(to: outputURL, options: .atomic)
return outputURL
}
}
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
let size = image.size
let maxSide = max(size.width, size.height)
guard maxSide > maxDimension else { return image }
let scale = maxDimension / maxSide
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
// Draw into a new context to get a clean CGImage without metadata
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
image.draw(in: CGRect(origin: .zero, size: newSize))
let rendered = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return rendered ?? image
}
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
guard let data = CFDataCreateMutable(nil, 0) else {
return nil
}
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
return nil
}
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
// By only specifying compression quality and no metadata keys,
// we ensure a clean JPEG with no privacy-leaking information
let options: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: quality
]
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
return nil
}
return data as Data
}
#else
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
return try autoreleasepool {
let scaled = scaledImage(image, maxDimension: maxDimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
throw ImageUtilsError.encodingFailed
}
let width = inputCG.width
let height = inputCG.height
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
throw ImageUtilsError.encodingFailed
}
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
guard let cgImage = context.makeImage() else {
throw ImageUtilsError.encodingFailed
}
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed
}
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
try jpegData.write(to: outputURL, options: .atomic)
return outputURL
}
}
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
let size = image.size
let maxSide = max(size.width, size.height)
guard maxSide > maxDimension else { return image }
let scale = maxDimension / maxSide
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
let scaledImage = NSImage(size: newSize)
scaledImage.lockFocus()
image.draw(in: NSRect(origin: .zero, size: newSize),
from: NSRect(origin: .zero, size: size),
operation: .copy,
fraction: 1.0)
scaledImage.unlockFocus()
return scaledImage
}
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
guard let data = CFDataCreateMutable(nil, 0) else {
return nil
}
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
return nil
}
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
// By only specifying compression quality and no metadata keys,
// we ensure a clean JPEG with no privacy-leaking information
let options: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: quality
]
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
return nil
}
return data as Data
}
#endif
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
let directory: URL
if let outputDirectory {
directory = outputDirectory
} else {
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory.appendingPathComponent(fileName)
}
private static func applicationFilesDirectory() throws -> URL {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
return base.appendingPathComponent("files", isDirectory: true)
}
}
@@ -1,205 +0,0 @@
import Foundation
import AVFoundation
import BitLogger
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
@Published private(set) var isPlaying: Bool = false
@Published private(set) var currentTime: TimeInterval = 0
@Published private(set) var duration: TimeInterval = 0
@Published private(set) var progress: Double = 0
/// rounded so 4.9s shows "00:05"
var roundedDuration: Int {
guard duration.isFinite else { return 0 }
return Int(duration.rounded())
}
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
var remainingSeconds: Int {
let remaining = max(0, duration - currentTime)
return min(roundedDuration, Int(ceil(remaining)))
}
private var player: AVAudioPlayer?
private var timer: Timer?
private var url: URL
init(url: URL) {
self.url = url
super.init()
// Don't load anything eagerly - wait until user interaction or view is fully displayed
}
func loadDuration() {
guard duration == 0 else { return }
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self = self else { return }
do {
let player = try AVAudioPlayer(contentsOf: self.url)
let loadedDuration = player.duration
DispatchQueue.main.async { [weak self] in
guard let self = self, self.duration == 0 else { return }
self.duration = loadedDuration
}
} catch {
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
}
}
}
deinit {
timer?.invalidate()
}
func replaceURL(_ url: URL) {
guard url != self.url else { return }
stop()
self.url = url
player = nil
duration = 0
// Duration will be loaded on demand when needed
}
func togglePlayback() {
isPlaying ? pause() : play()
}
func play() {
guard ensurePlayerReady() else { return }
VoiceNotePlaybackCoordinator.shared.activate(self)
player?.play()
startTimer()
updateProgress()
isPlaying = true
}
func pause() {
player?.pause()
stopTimer()
updateProgress()
isPlaying = false
}
func stop() {
player?.stop()
player?.currentTime = 0
stopTimer()
updateProgress()
isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
}
func seek(to fraction: Double) {
guard ensurePlayerReady() else { return }
let clamped = max(0, min(1, fraction))
if let player = player {
player.currentTime = clamped * player.duration
if isPlaying {
player.play()
}
updateProgress()
}
}
// MARK: - AVAudioPlayerDelegate
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
// Delegate callback may be on background thread - ensure main thread for UI updates
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.stopTimer()
self.updateProgress()
self.isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
}
}
// MARK: - Private Helpers
private func preparePlayer(for url: URL) {
// Prepare player synchronously (only called when playback is requested)
do {
let player = try AVAudioPlayer(contentsOf: url)
player.delegate = self
player.prepareToPlay()
self.player = player
duration = player.duration
currentTime = player.currentTime
progress = duration > 0 ? currentTime / duration : 0
} catch {
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
player = nil
duration = 0
currentTime = 0
progress = 0
}
}
private func ensurePlayerReady() -> Bool {
if player == nil {
preparePlayer(for: url)
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
try session.setActive(true, options: [])
} catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
}
#endif
return player != nil
}
private func startTimer() {
if timer != nil { return }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
self?.updateProgress()
}
if let timer = timer {
RunLoop.main.add(timer, forMode: .common)
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func updateProgress() {
guard let player = player else {
currentTime = 0
duration = 0
progress = 0
return
}
currentTime = player.currentTime
duration = player.duration
progress = duration > 0 ? currentTime / duration : 0
}
}
/// Ensures only one voice note plays at a time.
final class VoiceNotePlaybackCoordinator {
static let shared = VoiceNotePlaybackCoordinator()
private weak var activeController: VoiceNotePlaybackController?
private init() {}
func activate(_ controller: VoiceNotePlaybackController) {
if activeController === controller {
return
}
activeController?.pause()
activeController = controller
}
func deactivate(_ controller: VoiceNotePlaybackController) {
if activeController === controller {
activeController = nil
}
}
}
-155
View File
@@ -1,155 +0,0 @@
import Foundation
import AVFoundation
/// Manages audio capture for mesh voice notes with predictable encoding settings.
actor VoiceRecorder {
enum RecorderError: Error {
case microphoneAccessDenied
case recorderInitializationFailed
case recordingInProgress
}
static let shared = VoiceRecorder()
private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120
static let minRecordingDuration: TimeInterval = 1
private var recorder: AVAudioRecorder?
private var currentURL: URL?
// MARK: - Permissions
nonisolated
func requestPermission() async -> Bool {
#if os(iOS)
return await withCheckedContinuation { continuation in
AVAudioSession.sharedInstance().requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
#elseif os(macOS)
return await withCheckedContinuation { continuation in
AVCaptureDevice.requestAccess(for: .audio) { granted in
continuation.resume(returning: granted)
}
}
#else
return true
#endif
}
// MARK: - Recording Lifecycle
@discardableResult
func startRecording() throws -> URL {
if recorder?.isRecording == true {
throw RecorderError.recordingInProgress
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
throw RecorderError.microphoneAccessDenied
}
#if targetEnvironment(simulator)
// allowBluetoothHFP is not available on iOS Simulator
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
)
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif
#if os(macOS)
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
}
#endif
let outputURL = try makeOutputURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record(forDuration: maxRecordingDuration)
recorder = audioRecorder
currentURL = outputURL
return outputURL
}
func stopRecording() async -> URL? {
guard let recorder, recorder.isRecording else {
return currentURL
}
let sessionURL = currentURL
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
recorder.stop()
// A new session may have started during the sleep don't touch its state
if self.recorder === recorder {
cleanupSession()
self.recorder = nil
currentURL = nil
}
return sessionURL
}
func cancelRecording() {
if let recorder, recorder.isRecording {
recorder.stop()
}
cleanupSession()
if let currentURL {
try? FileManager.default.removeItem(at: currentURL)
}
recorder = nil
currentURL = nil
}
// MARK: - Helpers
private func makeOutputURL() throws -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "voice_\(formatter.string(from: Date())).m4a"
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
return baseDirectory.appendingPathComponent(fileName)
}
private func applicationFilesDirectory() throws -> URL {
#if os(iOS)
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
.appendingPathComponent("files", isDirectory: true)
#else
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
return base.appendingPathComponent("files", isDirectory: true)
#endif
}
private func cleanupSession() {
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
}
-113
View File
@@ -1,113 +0,0 @@
import AVFoundation
import Foundation
import BitLogger
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
final class WaveformCache {
static let shared = WaveformCache()
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
private init() {}
func cachedWaveform(for url: URL) -> [Float]? {
queue.sync {
guard let entry = cache[url] else { return nil }
return entry.waveform
}
}
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
queue.async { [weak self] in
guard let self = self else { return }
// Check cache (read-only, no update needed on cache hit for performance)
if let entry = self.cache[url] {
DispatchQueue.main.async { completion(entry.waveform) }
return
}
guard let computed = self.computeWaveform(url: url, bins: bins) else {
DispatchQueue.main.async { completion([]) }
return
}
self.queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
// Evict oldest entry if cache is full
if self.cache.count >= self.maxCacheSize {
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
self.cache.removeValue(forKey: oldest.key)
}
}
self.cache[url] = (computed, Date())
}
DispatchQueue.main.async { completion(computed) }
}
}
func purge(url: URL) {
queue.async(flags: .barrier) { [weak self] in
self?.cache.removeValue(forKey: url)
}
}
func purgeAll() {
queue.async(flags: .barrier) { [weak self] in
self?.cache.removeAll()
}
}
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
guard bins > 0 else { return nil }
// Use autoreleasepool to manage memory from audio buffer allocations
return autoreleasepool {
do {
let audioFile = try AVAudioFile(forReading: url)
let length = Int(audioFile.length)
guard length > 0 else { return nil }
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
return nil
}
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
guard let channelData = buffer.floatChannelData else { return nil }
let channelCount = Int(audioFile.processingFormat.channelCount)
let frameLength = Int(buffer.frameLength)
let samplesPerBin = max(1, frameLength / bins)
var magnitudes: [Float] = Array(repeating: 0, count: bins)
for bin in 0..<bins {
let start = bin * samplesPerBin
let end = min(frameLength, start + samplesPerBin)
if start >= end { break }
var sum: Float = 0
var sampleCount = 0
for frame in start..<end {
var sampleValue: Float = 0
for channel in 0..<channelCount {
sampleValue += fabsf(channelData[channel][frame])
}
sum += sampleValue / Float(channelCount)
sampleCount += 1
}
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
}
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
}
return magnitudes
} catch {
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
return nil
}
}
}
}
-1
View File
@@ -81,7 +81,6 @@
///
import Foundation
import BitFoundation
// MARK: - Three-Layer Identity Model
@@ -91,7 +91,6 @@
///
import BitLogger
import BitFoundation
import Foundation
import CryptoKit
@@ -202,12 +201,8 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
} catch {
cache = IdentityCache()
let deleted = keychain.deleteIdentityKey(forKey: cacheKey)
SecureLogger.warning(
"Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))",
category: .security
)
// Log error but continue with empty cache
SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
}
}
@@ -336,15 +331,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
self.cache.socialIdentities[identity.fingerprint] = identity
// Update nickname index
if let previousClaimedNickname,
previousClaimedNickname != identity.claimedNickname {
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
// Remove old nickname from index if changed
if existingIdentity.claimedNickname != identity.claimedNickname {
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
}
}
}
@@ -536,16 +532,4 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
return cache.verifiedFingerprints
}
}
var debugNicknameIndex: [String: Set<String>] {
queue.sync { cache.nicknameIndex }
}
func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? {
queue.sync { ephemeralSessions[peerID] }
}
func debugLastInteraction(for fingerprint: String) -> Date? {
queue.sync { cache.lastInteractions[fingerprint] }
}
}
-6
View File
@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupID</key>
<string>$(APP_GROUP_ID)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@@ -41,10 +39,6 @@
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
+23348 -24568
View File
File diff suppressed because it is too large Load Diff
-69
View File
@@ -1,69 +0,0 @@
//
// BitchatMessage+Media.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
extension BitchatMessage {
enum Media {
case voice(URL)
case image(URL)
var url: URL {
switch self {
case .voice(let url), .image(let url):
return url
}
}
}
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
private struct Cache {
let filesDir: URL?
static let shared = Cache()
private init() {
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
self.filesDir = filesDir
} catch {
filesDir = nil
}
}
}
func mediaAttachment(for nickname: String) -> Media? {
guard let baseDirectory = Cache.shared.filesDir else { return nil }
func url(for category: MimeType.Category) -> URL? {
guard content.hasPrefix(category.messagePrefix),
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
else {
return nil
}
// Check outgoing first for sent messages, incoming for received
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
// Files are checked during playback/display, so missing files fail gracefully
let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true)
return directory.appendingPathComponent(filename)
}
if let url = url(for: .audio) {
return .voice(url)
}
if let url = url(for: .image) {
return .image(url)
}
return nil
}
}
@@ -6,39 +6,33 @@
// For more information, see <https://unlicense.org>
//
import class Foundation.DateFormatter
import struct Foundation.AttributedString
import struct Foundation.Data
import struct Foundation.Date
import struct Foundation.TimeInterval
import struct Foundation.UUID
import Foundation
/// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages
public final class BitchatMessage: Codable {
public let id: String
public let sender: String
public let content: String
public let timestamp: Date
public let isRelay: Bool
public let originalSender: String?
public let isPrivate: Bool
public let recipientNickname: String?
public let senderPeerID: PeerID?
public let mentions: [String]? // Array of mentioned nicknames
public var deliveryStatus: DeliveryStatus? // Delivery tracking
final class BitchatMessage: Codable {
let id: String
let sender: String
let content: String
let timestamp: Date
let isRelay: Bool
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeerID: PeerID?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
public func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
}
public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
@@ -48,7 +42,7 @@ public final class BitchatMessage: Codable {
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
}
public init(
init(
id: String? = nil,
sender: String,
content: String,
@@ -78,7 +72,7 @@ public final class BitchatMessage: Codable {
// MARK: - Equatable Conformance
extension BitchatMessage: Equatable {
public static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id &&
lhs.sender == rhs.sender &&
lhs.content == rhs.content &&
@@ -336,15 +330,15 @@ extension BitchatMessage {
return formatter
}()
public var formattedTimestamp: String {
var formattedTimestamp: String {
Self.timestampFormatter.string(from: timestamp)
}
}
extension Array where Element == BitchatMessage {
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
public func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmed.isEmpty == false }
func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
guard arr.count > 1 else {
return arr
}
@@ -6,27 +6,24 @@
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
import struct Foundation.Date
import Foundation
/// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network,
/// including TTL for hop limiting and optional encryption.
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
public struct BitchatPacket: Codable {
struct BitchatPacket: Codable {
let version: UInt8
public let type: UInt8
public let senderID: Data
public let recipientID: Data?
public let timestamp: UInt64
public let payload: Data
public var signature: Data?
public var ttl: UInt8
public var route: [Data]?
public var isRSR: Bool
let type: UInt8
let senderID: Data
let recipientID: Data?
let timestamp: UInt64
let payload: Data
var signature: Data?
var ttl: UInt8
public init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
self.version = version
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = 1
self.type = type
self.senderID = senderID
self.recipientID = recipientID
@@ -34,12 +31,10 @@ public struct BitchatPacket: Codable {
self.payload = payload
self.signature = signature
self.ttl = ttl
self.route = route
self.isRSR = isRSR
}
// Convenience initializer for new binary format
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) {
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
self.version = 1
self.type = type
// Convert hex string peer ID to binary data (8 bytes)
@@ -58,26 +53,24 @@ public struct BitchatPacket: Codable {
self.payload = payload
self.signature = nil
self.ttl = ttl
self.route = nil
self.isRSR = isRSR
}
var data: Data? {
BinaryProtocol.encode(self)
}
public func toBinaryData(padding: Bool = true) -> Data? {
func toBinaryData(padding: Bool = true) -> Data? {
BinaryProtocol.encode(self, padding: padding)
}
// Backward-compatible helper (defaults to padded encoding)
public func toBinaryData() -> Data? {
func toBinaryData() -> Data? {
toBinaryData(padding: true)
}
/// Create binary representation for signing (without signature and TTL fields)
/// TTL is excluded because it changes during packet relay operations
public func toBinaryDataForSigning() -> Data? {
func toBinaryDataForSigning() -> Data? {
// Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket(
@@ -87,15 +80,12 @@ public struct BitchatPacket: Codable {
timestamp: timestamp,
payload: payload,
signature: nil, // Remove signature for signing
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
version: version,
route: route,
isRSR: false // RSR flag is mutable and not part of the signature
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
)
return BinaryProtocol.encode(unsignedPacket)
}
public static func from(_ data: Data) -> BitchatPacket? {
static func from(_ data: Data) -> BitchatPacket? {
BinaryProtocol.decode(data)
}
}
-1
View File
@@ -1,6 +1,5 @@
import Foundation
import CoreBluetooth
import BitFoundation
/// Represents a peer in the BitChat network with all associated metadata
struct BitchatPeer: Equatable {
-58
View File
@@ -1,58 +0,0 @@
//
// CommandsInfo.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
// MARK: - CommandInfo Enum
enum CommandInfo: String, Identifiable {
case block
case clear
case hug
case message = "dm"
case slap
case unblock
case who
case favorite
case unfavorite
var id: String { rawValue }
var alias: String { "/" + rawValue }
var placeholder: String? {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .who:
return nil
}
}
var description: String {
switch self {
case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
case .slap: String(localized: "content.commands.slap")
case .unblock: String(localized: "content.commands.unblock")
case .who: String(localized: "content.commands.who")
case .favorite: String(localized: "content.commands.favorite")
case .unfavorite: String(localized: "content.commands.unfavorite")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
if isGeoPublic || isGeoDM {
return baseCommands + [.favorite, .unfavorite]
}
return baseCommands
}
}
@@ -6,7 +6,7 @@
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
import Foundation
/// Provides privacy-preserving message padding to obscure actual content length.
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
@@ -1,27 +1,15 @@
//
// PeerID.swift
// BitFoundation
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
import struct Foundation.CharacterSet
import Foundation
public struct PeerID: Equatable, Hashable, Sendable {
enum Constants {
/// 16
static let nostrConvKeyPrefixLength = 16
/// 8
static let nostrShortKeyDisplayLength = 8
/// 64
fileprivate static let maxIDLength = 64
/// 16
fileprivate static let hexIDLength = 16 // 8 bytes = 16 hex chars
}
public enum Prefix: String, CaseIterable, Sendable {
struct PeerID: Equatable, Hashable {
enum Prefix: String, CaseIterable {
/// When no prefix is provided
case empty = ""
/// `"mesh:"`
@@ -35,35 +23,35 @@ public struct PeerID: Equatable, Hashable, Sendable {
/// `"nostr:"` (+ 8 characters hex)
case geoChat = "nostr:"
}
public let prefix: Prefix
let prefix: Prefix
/// Returns the actual value without any prefix
public let bare: String
let bare: String
/// Returns the full `id` value by combining `(prefix + bare)`
public var id: String { prefix.rawValue + bare }
var id: String { prefix.rawValue + bare }
// Private so the callers have to go through a convenience init
private init(prefix: Prefix, bare: any StringProtocol) {
self.prefix = prefix
self.bare = String(bare).lowercased()
self.bare = String(bare)
}
}
// MARK: - Convenience Inits
public extension PeerID {
extension PeerID {
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
init(nostr_ pubKey: String) {
self.init(prefix: .geoDM, bare: pubKey.prefix(Constants.nostrConvKeyPrefixLength))
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
}
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
init(nostr pubKey: String) {
self.init(prefix: .geoChat, bare: pubKey.prefix(Constants.nostrShortKeyDisplayLength))
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
}
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
init(str: any StringProtocol) {
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
@@ -72,38 +60,32 @@ public extension PeerID {
self.init(prefix: .empty, bare: str)
}
}
/// Convenience init to handle `Optional<String>`
init?(str: (any StringProtocol)?) {
guard let str else { return nil }
self.init(str: str)
}
/// Convenience init to create PeerID by converting Data to String
init?(data: Data) {
self.init(str: String(data: data, encoding: .utf8))
}
/// Convenience init to "hide" hex-encoding implementation detail
init(hexData: Data) {
self.init(str: hexData.hexEncodedString())
}
/// Convenience init to "hide" hex-encoding implementation detail
init?(hexData: Data?) {
guard let hexData else { return nil }
self.init(hexData: hexData)
}
}
// MARK: - Noise Public Key Helpers
public extension PeerID {
extension PeerID {
/// Derive the stable 16-hex peer ID from a Noise static public key
init(publicKey: Data) {
self.init(str: publicKey.sha256Fingerprint().prefix(16))
}
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
func toShort() -> PeerID {
if let noiseKey {
@@ -116,11 +98,11 @@ public extension PeerID {
// MARK: - Codable
extension PeerID: Codable {
public init(from decoder: any Decoder) throws {
init(from decoder: any Decoder) throws {
self.init(str: try decoder.singleValueContainer().decode(String.self))
}
public func encode(to encoder: any Encoder) throws {
func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(id)
}
@@ -128,81 +110,72 @@ extension PeerID: Codable {
// MARK: - Helpers
public extension PeerID {
extension PeerID {
var isEmpty: Bool {
id.isEmpty
}
/// Returns true if `id` starts with "`nostr:`"
var isGeoChat: Bool {
prefix == .geoChat
}
/// Returns true if `id` starts with "`nostr_`"
var isGeoDM: Bool {
prefix == .geoDM
}
func toPercentEncoded() -> String {
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
}
}
public extension PeerID {
var routingData: Data? {
if let direct = Data(hexString: id), direct.count == 8 { return direct }
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
let short = toShort()
return Data(hexString: short.id)
}
init?(routingData: Data) {
guard routingData.count == 8 else { return nil }
self.init(hexData: routingData)
}
}
// MARK: - Validation
public extension PeerID {
extension PeerID {
private enum Constants {
static let maxIDLength = 64
static let hexIDLength = 16 // 8 bytes = 16 hex chars
}
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
var isValid: Bool {
if prefix != .empty {
return PeerID(str: bare).isValid
}
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
if isShort || isNoiseKeyHex {
return true
}
// If length equals short or full but isn't valid hex, reject
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
return false
}
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
return !id.isEmpty &&
id.count < Constants.maxIDLength &&
id.rangeOfCharacter(from: validCharset.inverted) == nil
}
/// Returns true if the `bare` id is all hex
var isHex: Bool {
bare.allSatisfy { $0.isHexDigit }
}
/// Short routing IDs (exact 16-hex)
var isShort: Bool {
bare.count == Constants.hexIDLength && isHex
}
/// Full Noise key hex (exact 64-hex)
var isNoiseKeyHex: Bool {
noiseKey != nil
}
/// Full Noise key (exact 64-hex) as Data
var noiseKey: Data? {
guard bare.count == Constants.maxIDLength else { return nil }
@@ -213,16 +186,32 @@ public extension PeerID {
// MARK: - Comparable
extension PeerID: Comparable {
public static func < (lhs: PeerID, rhs: PeerID) -> Bool {
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
lhs.id < rhs.id
}
}
// MARK: - CustomStringConvertible
// MARK: - String Interop Helpers
// MARK: CustomStringConvertible
extension PeerID: CustomStringConvertible {
/// So it returns the actual `id` like before even inside another String
public var description: String {
var description: String {
id
}
}
// MARK: Custom Equatable w/ String & Optionality
// PeerID <> String
extension Optional where Wrapped == PeerID {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
}
// String <> PeerID
extension Optional where Wrapped == String {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
}
+6 -7
View File
@@ -7,16 +7,15 @@
//
import Foundation
import BitFoundation
struct ReadReceipt: Codable {
let originalMessageID: String
let receiptID: String
var readerID: PeerID // Who read it
var readerID: String // Who read it
let readerNickname: String
let timestamp: Date
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
init(originalMessageID: String, readerID: String, readerNickname: String) {
self.originalMessageID = originalMessageID
self.receiptID = UUID().uuidString
self.readerID = readerID
@@ -25,7 +24,7 @@ struct ReadReceipt: Codable {
}
// For binary decoding
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
self.originalMessageID = originalMessageID
self.receiptID = receiptID
self.readerID = readerID
@@ -49,7 +48,7 @@ struct ReadReceipt: Codable {
data.appendUUID(receiptID)
// ReaderID as 8-byte hex string
var readerData = Data()
var tempID = readerID.id
var tempID = readerID
while tempID.count >= 2 && readerData.count < 8 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
@@ -79,8 +78,8 @@ struct ReadReceipt: Codable {
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let readerID = PeerID(hexData: readerIDData)
guard readerID.isValid else { return nil }
let readerID = readerIDData.hexEncodedString()
guard PeerID(str: readerID).isValid else { return nil }
guard let timestamp = dataCopy.readDate(at: &offset),
InputValidator.validateTimestamp(timestamp),
+2 -41
View File
@@ -8,18 +8,6 @@ struct RequestSyncPacket {
let p: Int
let m: UInt32
let data: Data
let types: SyncTypeFlags?
let sinceTimestamp: UInt64?
let fragmentIdFilter: String?
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
self.p = p
self.m = m
self.data = data
self.types = types
self.sinceTimestamp = sinceTimestamp
self.fragmentIdFilter = fragmentIdFilter
}
func encode() -> Data {
var out = Data()
@@ -37,27 +25,14 @@ struct RequestSyncPacket {
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
// data
putTLV(0x03, data)
if let typesData = types?.toData() {
putTLV(0x04, typesData)
}
if let ts = sinceTimestamp {
var tsBE = ts.bigEndian
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
}
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
putTLV(0x06, fidData)
}
return out
}
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
var off = 0
var p: Int? = nil
var m: UInt32? = nil
var payload: Data? = nil
var types: SyncTypeFlags? = nil
var sinceTimestamp: UInt64? = nil
var fragmentIdFilter: String? = nil
while off + 3 <= data.count {
let t = Int(data[off]); off += 1
@@ -77,26 +52,12 @@ struct RequestSyncPacket {
case 0x03:
if v.count > maxAcceptBytes { return nil }
payload = v
case 0x04:
if let decoded = SyncTypeFlags.decode(v) {
types = decoded
}
case 0x05:
if v.count == 8 {
var ts: UInt64 = 0
for b in v { ts = (ts << 8) | UInt64(b) }
sinceTimestamp = ts
}
case 0x06:
if let fid = String(data: v, encoding: .utf8) {
fragmentIdFilter = fid
}
default:
break // forward compatible; ignore unknown TLVs
}
}
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
return RequestSyncPacket(p: pp, m: mm, data: dd)
}
}
+51 -176
View File
@@ -78,7 +78,6 @@
///
import BitLogger
import BitFoundation
import Foundation
import CryptoKit
@@ -166,23 +165,19 @@ final class NoiseCipherState {
// MARK: - Sliding Window Replay Protection
/// Check if nonce is valid for replay protection
/// BCH-01-010: Use safe arithmetic to prevent integer overflow
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
// Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest)
// use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)
let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)
if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {
if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce {
return false // Too old, outside window
}
if receivedNonce > highestReceivedNonce {
return true // Always accept newer nonces
}
let offset = Int(highestReceivedNonce - receivedNonce)
let byteIndex = offset / 8
let bitIndex = offset % 8
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
}
@@ -227,7 +222,7 @@ final class NoiseCipherState {
guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else {
return nil
}
// Extract 4-byte nonce (big-endian)
let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES)
let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in
@@ -238,18 +233,18 @@ final class NoiseCipherState {
}
return result
}
// Extract ciphertext (remaining bytes)
let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)
return (nonce: extractedNonce, ciphertext: Data(ciphertext))
}
/// Convert nonce to 4-byte array (big-endian)
private func nonceToBytes(_ nonce: UInt64) -> Data {
var bytes = Data(count: Self.NONCE_SIZE_BYTES)
withUnsafeBytes(of: nonce.bigEndian) { ptr in
// Copy only the last 4 bytes from the 8-byte UInt64
// Copy only the last 4 bytes from the 8-byte UInt64
let sourceBytes = ptr.bindMemory(to: UInt8.self)
bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))
}
@@ -278,7 +273,7 @@ final class NoiseCipherState {
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)
// increment local nonce
nonce += 1
// Create combined payload: <nonce><ciphertext>
let combinedPayload: Data
if (useExtractedNonce) {
@@ -292,7 +287,7 @@ final class NoiseCipherState {
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
}
return combinedPayload
}
@@ -321,7 +316,7 @@ final class NoiseCipherState {
SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
throw NoiseError.replayDetected
}
// Split ciphertext and tag
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
tag = actualCiphertext.suffix(16)
@@ -352,20 +347,16 @@ final class NoiseCipherState {
do {
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
// BCH-01-010: Atomic nonce state update
// Both replay window marking and nonce increment must complete together
// to prevent state desynchronization. We perform both after successful
// decryption only, ensuring state consistency on any failure path.
if useExtractedNonce {
// Mark nonce as seen after successful decryption
markNonceAsSeen(decryptionNonce)
}
nonce += 1
return plaintext
} catch {
// Decryption failed - nonce state remains unchanged (atomic rollback)
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
// Log authentication failures with nonce info
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
throw error
}
@@ -385,16 +376,6 @@ final class NoiseCipherState {
replayWindow[i] = 0
}
}
#if DEBUG
func setNonceForTesting(_ nonce: UInt64) {
self.nonce = nonce
}
func extractNonceFromCiphertextPayloadForTesting(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? {
try extractNonceFromCiphertextPayload(combinedPayload)
}
#endif
}
// MARK: - Symmetric State
@@ -470,40 +451,17 @@ final class NoiseSymmetricState {
}
}
func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) {
func split() -> (NoiseCipherState, NoiseCipherState) {
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
let tempKey1 = SymmetricKey(data: output[0])
let tempKey2 = SymmetricKey(data: output[1])
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
// BCH-01-010: Clear symmetric state after split per Noise spec
// The chaining key and hash should not be retained after handshake completes
clearSensitiveData()
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true)
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true)
return (c1, c2)
}
/// BCH-01-010: Securely clear sensitive cryptographic state
/// Called after split() to clear chaining key and hash per Noise spec
func clearSensitiveData() {
// Clear chaining key by overwriting with zeros
let chainingKeyCount = chainingKey.count
chainingKey = Data(repeating: 0, count: chainingKeyCount)
// Clear hash by overwriting with zeros
let hashCount = hash.count
hash = Data(repeating: 0, count: hashCount)
// Clear the internal cipher state
cipherState.clearSensitiveData()
}
deinit {
clearSensitiveData()
}
// HKDF implementation
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
@@ -549,24 +507,16 @@ final class NoiseHandshakeState {
private var messagePatterns: [[NoiseMessagePattern]] = []
private var currentPattern = 0
// Test support: predetermined ephemeral keys for test vectors
private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey?
private var prologueData: Data
init(
role: NoiseRole,
pattern: NoisePattern,
keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,
prologue: Data = Data(),
predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
) {
self.role = role
self.pattern = pattern
self.keychain = keychain
self.prologueData = prologue
self.predeterminedEphemeralKey = predeterminedEphemeralKey
// Initialize static keys
if let localKey = localStaticKey {
@@ -587,8 +537,8 @@ final class NoiseHandshakeState {
}
private func mixPreMessageKeys() {
// Mix prologue
symmetricState.mixHash(self.prologueData)
// Mix prologue (empty for XX pattern normally)
symmetricState.mixHash(Data()) // Empty prologue for XX pattern
// For XX pattern, no pre-message keys
// For IK/NK patterns, we'd mix the responder's static key here
switch pattern {
@@ -596,9 +546,8 @@ final class NoiseHandshakeState {
break // No pre-message keys
case .IK, .NK:
if role == .initiator, let remoteStatic = remoteStaticPublic {
_ = symmetricState.getHandshakeHash()
symmetricState.mixHash(remoteStatic.rawRepresentation)
} else if role == .responder, let localStatic = localStaticPublic {
symmetricState.mixHash(localStatic.rawRepresentation)
}
}
}
@@ -607,20 +556,15 @@ final class NoiseHandshakeState {
guard currentPattern < messagePatterns.count else {
throw NoiseError.handshakeComplete
}
var messageBuffer = Data()
let patterns = messagePatterns[currentPattern]
for pattern in patterns {
switch pattern {
case .e:
// Generate ephemeral key (or use predetermined key for tests)
if let predetermined = predeterminedEphemeralKey {
localEphemeralPrivate = predetermined
predeterminedEphemeralKey = nil
} else {
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
}
// Generate ephemeral key
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
localEphemeralPublic = localEphemeralPrivate!.publicKey
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
@@ -653,20 +597,14 @@ final class NoiseHandshakeState {
throw NoiseError.missingKeys
}
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
} else {
guard let localStatic = localStaticPrivate,
let remoteEphemeral = remoteEphemeralPublic else {
throw NoiseError.missingKeys
}
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
}
case .se:
@@ -677,20 +615,14 @@ final class NoiseHandshakeState {
throw NoiseError.missingKeys
}
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
} else {
guard let localEphemeral = localEphemeralPrivate,
let remoteStatic = remoteStaticPublic else {
throw NoiseError.missingKeys
}
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
}
case .ss:
@@ -720,7 +652,7 @@ final class NoiseHandshakeState {
guard currentPattern < messagePatterns.count else {
throw NoiseError.handshakeComplete
}
var buffer = message
let patterns = messagePatterns[currentPattern]
@@ -779,11 +711,8 @@ final class NoiseHandshakeState {
throw NoiseError.missingKeys
}
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
case .es:
if role == .initiator {
guard let localEphemeral = localEphemeralPrivate,
@@ -836,12 +765,9 @@ final class NoiseHandshakeState {
throw NoiseError.missingKeys
}
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
case .e, .s:
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
default:
break
}
}
@@ -850,20 +776,16 @@ final class NoiseHandshakeState {
return currentPattern >= messagePatterns.count
}
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) {
func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
guard isHandshakeComplete() else {
throw NoiseError.handshakeNotComplete
}
// BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state
let finalHandshakeHash = symmetricState.getHandshakeHash()
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
let (c1, c2) = symmetricState.split()
// Initiator uses c1 for sending, c2 for receiving
// Responder uses c2 for sending, c1 for receiving
let ciphers = role == .initiator ? (c1, c2) : (c2, c1)
return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)
return role == .initiator ? (c1, c2) : (c2, c1)
}
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
@@ -873,20 +795,6 @@ final class NoiseHandshakeState {
func getHandshakeHash() -> Data {
return symmetricState.getHandshakeHash()
}
#if DEBUG
func performDHOperationForTesting(_ pattern: NoiseMessagePattern) throws {
try performDHOperation(pattern)
}
func setCurrentPatternForTesting(_ currentPattern: Int) {
self.currentPattern = currentPattern
}
func setRemoteEphemeralPublicKeyForTesting(_ key: Curve25519.KeyAgreement.PublicKey?) {
self.remoteEphemeralPublic = key
}
#endif
}
// MARK: - Pattern Extensions
@@ -938,47 +846,22 @@ enum NoiseError: Error {
case nonceExceeded
}
// MARK: - Constant-Time Operations
/// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks
/// This function compares two Data objects in constant time, preventing
/// information leakage via timing analysis.
private func constantTimeCompare(_ a: Data, _ b: Data) -> Bool {
guard a.count == b.count else { return false }
var result: UInt8 = 0
for i in 0..<a.count {
result |= a[a.startIndex.advanced(by: i)] ^ b[b.startIndex.advanced(by: i)]
}
return result == 0
}
/// BCH-01-010: Constant-time check if all bytes are zero
private func constantTimeIsZero(_ data: Data) -> Bool {
var result: UInt8 = 0
for byte in data {
result |= byte
}
return result == 0
}
// MARK: - Key Validation
extension NoiseHandshakeState {
/// Validate a Curve25519 public key
/// Checks for weak/invalid keys that could compromise security
/// BCH-01-010: Uses constant-time operations to prevent timing side-channels
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
// Check key length
guard keyData.count == 32 else {
throw NoiseError.invalidPublicKey
}
// BCH-01-010: Constant-time check for all-zero key (point at infinity)
if constantTimeIsZero(keyData) {
// Check for all-zero key (point at infinity)
if keyData.allSatisfy({ $0 == 0 }) {
throw NoiseError.invalidPublicKey
}
// Check for low-order points that could enable small subgroup attacks
// These are the known bad points for Curve25519
let lowOrderPoints: [Data] = [
@@ -999,21 +882,13 @@ extension NoiseHandshakeState {
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
]
// BCH-01-010: Constant-time check against known bad points
// We check all points and accumulate matches to avoid early exit timing leaks
var foundBadPoint = false
for badPoint in lowOrderPoints {
if constantTimeCompare(keyData, badPoint) {
foundBadPoint = true
}
}
if foundBadPoint {
// Check against known bad points
if lowOrderPoints.contains(keyData) {
SecureLogger.warning("Low-order point detected", category: .security)
throw NoiseError.invalidPublicKey
}
// Try to create the key - CryptoKit will validate curve points internally
do {
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
-1
View File
@@ -7,7 +7,6 @@
//
import BitLogger
import BitFoundation
import Foundation
final class NoiseRateLimiter {
+15 -16
View File
@@ -9,7 +9,6 @@
import BitLogger
import Foundation
import CryptoKit
import BitFoundation
class NoiseSession {
let peerID: PeerID
@@ -103,23 +102,23 @@ class NoiseSession {
// Check if handshake is complete
if handshake.isHandshakeComplete() {
// Get transport ciphers and handshake hash (hash captured before split clears state)
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
// Get transport ciphers
let (send, receive) = try handshake.getTransportCiphers()
sendCipher = send
receiveCipher = receive
// Store remote static key
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
// Store handshake hash for channel binding
handshakeHash = hash
handshakeHash = handshake.getHandshakeHash()
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
return nil
} else {
// Generate response
@@ -129,20 +128,20 @@ class NoiseSession {
// Check if handshake is complete after writing
if handshake.isHandshakeComplete() {
// Get transport ciphers and handshake hash (hash captured before split clears state)
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
// Get transport ciphers
let (send, receive) = try handshake.getTransportCiphers()
sendCipher = send
receiveCipher = receive
// Store remote static key
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
// Store handshake hash for channel binding
handshakeHash = hash
handshakeHash = handshake.getHandshakeHash()
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
}
+21 -32
View File
@@ -9,13 +9,11 @@
import BitLogger
import CryptoKit
import Foundation
import BitFoundation
final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: KeychainManagerProtocol
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks
@@ -25,27 +23,7 @@ final class NoiseSessionManager {
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey
self.keychain = keychain
self.sessionFactory = { peerID, role in
SecureNoiseSession(
peerID: peerID,
role: role,
keychain: keychain,
localStaticKey: localStaticKey
)
}
}
#if DEBUG
init(
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
keychain: KeychainManagerProtocol,
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
) {
self.localStaticKey = localStaticKey
self.keychain = keychain
self.sessionFactory = sessionFactory
}
#endif
// MARK: - Session Management
@@ -82,18 +60,24 @@ final class NoiseSessionManager {
throw NoiseSessionError.alreadyEstablished
}
// Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID)
let session: NoiseSession
if let existingSession = sessions[peerID] {
session = existingSession
session.reset()
} else {
// Create new initiator session
session = SecureNoiseSession(
peerID: peerID,
role: .initiator,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = session
}
// Create new initiator session
let session = sessionFactory(peerID, .initiator)
sessions[peerID] = session
do {
let handshakeData = try session.startHandshake()
return handshakeData
return try session.startHandshake()
} catch {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
@@ -134,7 +118,12 @@ final class NoiseSessionManager {
// Get or create session
let session: NoiseSession
if shouldCreateNew {
let newSession = sessionFactory(peerID, .responder)
let newSession = SecureNoiseSession(
peerID: peerID,
role: .responder,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = newSession
session = newSession
} else {
+1 -5
View File
@@ -10,7 +10,7 @@ import Foundation
final class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0
private var sessionStartTime = Date()
private let sessionStartTime = Date()
private(set) var lastActivityTime = Date()
override func encrypt(_ plaintext: Data) throws -> Data {
@@ -77,9 +77,5 @@ final class SecureNoiseSession: NoiseSession {
func setMessageCountForTesting(_ count: UInt64) {
messageCount = count
}
func setSessionStartTimeForTesting(_ date: Date) {
sessionStartTime = date
}
#endif
}
-22
View File
@@ -1,22 +0,0 @@
import Foundation
enum Base64URLCoding {
static func encode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
static func decode(_ string: String) -> Data? {
var base64 = string
let padding = (4 - (base64.count % 4)) % 4
if padding > 0 {
base64 += String(repeating: "=", count: padding)
}
base64 = base64
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
return Data(base64Encoded: base64)
}
}
+69 -357
View File
@@ -1,161 +1,28 @@
import BitLogger
import Foundation
import Tor
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
struct GeoRelayDirectoryDependencies {
var userDefaults: UserDefaults
var notificationCenter: NotificationCenter
var now: () -> Date
var remoteURL: URL
var fetchInterval: TimeInterval
var refreshCheckInterval: TimeInterval
var retryInitialSeconds: TimeInterval
var retryMaxSeconds: TimeInterval
var awaitTorReady: @Sendable () async -> Bool
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
var readData: (URL) -> Data?
var writeData: (Data, URL) throws -> Void
var cacheURL: () -> URL?
var bundledCSVURLs: () -> [URL]
var currentDirectoryPath: () -> String?
var retrySleep: (TimeInterval) async -> Void
var activeNotificationName: Notification.Name?
var autoStart: Bool
}
private extension GeoRelayDirectoryDependencies {
@MainActor
static func live() -> Self {
#if os(iOS)
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
#elseif os(macOS)
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
#else
let activeNotificationName: Notification.Name? = nil
#endif
return Self(
userDefaults: .standard,
notificationCenter: .default,
now: Date.init,
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
awaitTorReady: { await TorManager.shared.awaitReady() },
makeFetchData: {
let session = TorURLSession.shared.session
return { request in
let (data, _) = try await session.data(for: request)
return data
}
},
readData: { try? Data(contentsOf: $0) },
writeData: { data, url in
try data.write(to: url, options: .atomic)
},
cacheURL: {
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("georelays_cache.csv")
} catch {
return nil
}
},
bundledCSVURLs: {
[
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
},
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
retrySleep: { delay in
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
},
activeNotificationName: activeNotificationName,
autoStart: true
)
}
}
@MainActor
final class GeoRelayDirectory {
private final class CleanupState {
let notificationCenter: NotificationCenter
var observers: [NSObjectProtocol] = []
var refreshTimer: Timer?
var retryTask: Task<Void, Never>?
init(notificationCenter: NotificationCenter) {
self.notificationCenter = notificationCenter
}
deinit {
observers.forEach { notificationCenter.removeObserver($0) }
refreshTimer?.invalidate()
retryTask?.cancel()
}
}
struct Entry: Hashable, Sendable {
struct Entry: Hashable {
let host: String
let lat: Double
let lon: Double
}
private enum DetachedFetchOutcome: Sendable {
case success(entries: [Entry], csv: String)
case torNotReady
case invalidData
case network(String)
}
static let shared = GeoRelayDirectory()
private(set) var entries: [Entry] = []
private let cacheFileName = "georelays_cache.csv"
private let lastFetchKey = "georelay.lastFetchAt"
private let dependencies: GeoRelayDirectoryDependencies
private let cleanupState: CleanupState
private var retryAttempt: Int = 0
private var isFetching: Bool = false
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 init() {
self.dependencies = .live()
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
entries = loadLocalEntries()
if dependencies.autoStart {
registerObservers()
startRefreshTimer()
prefetchIfNeeded()
}
}
internal init(dependencies: GeoRelayDirectoryDependencies) {
self.dependencies = dependencies
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
entries = loadLocalEntries()
if dependencies.autoStart {
registerObservers()
startRefreshTimer()
prefetchIfNeeded()
}
// Load cached or bundled data synchronously
self.entries = self.loadLocalEntries()
// Fire-and-forget remote refresh if stale
prefetchIfNeeded()
}
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
@@ -166,176 +33,56 @@ final class GeoRelayDirectory {
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty, count > 0 else { return [] }
if entries.count <= count {
return entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
.map { "wss://\($0.host)" }
}
var best: [(entry: Entry, distance: Double)] = []
best.reserveCapacity(count)
for entry in entries {
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
if best.count < count {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
} else if let worstDistance = best.last?.distance, distance < worstDistance {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
best.removeLast()
guard !entries.isEmpty else { return [] }
let sorted = entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
}
return best.map { "wss://\($0.entry.host)" }
.prefix(count)
return sorted.map { "wss://\($0.host)" }
}
// MARK: - Remote Fetch
func prefetchIfNeeded(force: Bool = false) {
guard !isFetching else { return }
let now = dependencies.now()
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
if !force {
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
} else if last != .distantPast,
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
// Skip forced fetches if we just refreshed moments ago.
return
}
cancelRetry()
func prefetchIfNeeded() {
let now = Date()
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
guard now.timeIntervalSince(last) >= fetchInterval else { return }
fetchRemote()
}
private func fetchRemote() {
guard !isFetching else { return }
isFetching = true
let request = URLRequest(
url: dependencies.remoteURL,
cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 15
)
let awaitTorReady = dependencies.awaitTorReady
let fetchData = dependencies.makeFetchData()
Task { [weak self] in
guard let self else { return }
let outcome = await Self.fetchRemoteOutcome(
request: request,
awaitTorReady: awaitTorReady,
fetchData: fetchData
)
switch outcome {
case .success(let parsed, let csv):
self.handleFetchSuccess(entries: parsed, csv: csv)
case .torNotReady:
self.handleFetchFailure(.torNotReady)
case .invalidData:
self.handleFetchFailure(.invalidData)
case .network(let description):
self.handleFetchFailure(.network(description))
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
// Ensure Tor readiness before fetching (fail-closed by default)
Task.detached {
let ready = await TorManager.shared.awaitReady()
if !ready {
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
return
}
}
}
nonisolated private static func fetchRemoteOutcome(
request: URLRequest,
awaitTorReady: @escaping @Sendable () async -> Bool,
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
) async -> DetachedFetchOutcome {
await Task.detached(priority: .utility) {
let ready = await awaitTorReady()
guard ready else { return .torNotReady }
do {
let data = try await fetchData(request)
guard let text = String(data: data, encoding: .utf8) else {
return .invalidData
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
guard let self = self else { return }
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
let parsed = GeoRelayDirectory.parseCSV(text)
if !parsed.isEmpty {
Task { @MainActor in
self.entries = parsed
self.persistCache(text)
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
}
return
}
}
let parsed = Self.parseCSV(text)
guard !parsed.isEmpty else {
return .invalidData
}
return .success(entries: parsed, csv: text)
} catch {
return .network(error.localizedDescription)
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
}
}.value
}
private enum FetchFailure {
case torNotReady
case invalidData
case network(String)
}
@MainActor
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
entries = parsed
persistCache(csv)
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
isFetching = false
retryAttempt = 0
cancelRetry()
}
@MainActor
private func handleFetchFailure(_ reason: FetchFailure) {
switch reason {
case .torNotReady:
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
case .invalidData:
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
case .network(let errorDescription):
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
task.resume()
}
isFetching = false
scheduleRetry()
}
@MainActor
private func scheduleRetry() {
retryAttempt = min(retryAttempt + 1, 10)
let base = dependencies.retryInitialSeconds
let maxDelay = dependencies.retryMaxSeconds
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
let calculated = base * multiplier
let delay = min(maxDelay, max(base, calculated))
cancelRetry()
cleanupState.retryTask = Task { [weak self] in
guard let self else { return }
await self.dependencies.retrySleep(delay)
guard !Task.isCancelled else { return }
await MainActor.run {
self.prefetchIfNeeded(force: true)
}
}
}
@MainActor
private func cancelRetry() {
cleanupState.retryTask?.cancel()
cleanupState.retryTask = nil
}
private func persistCache(_ text: String) {
guard let url = dependencies.cacheURL() else { return }
guard let data = text.data(using: .utf8) else { return }
guard let url = cacheURL() else { return }
do {
try dependencies.writeData(data, url)
try text.data(using: .utf8)?.write(to: url, options: .atomic)
} catch {
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
}
@@ -344,31 +91,30 @@ final class GeoRelayDirectory {
// MARK: - Loading
private func loadLocalEntries() -> [Entry] {
// Prefer cached file if present
if let cache = dependencies.cacheURL(),
let data = dependencies.readData(cache),
if let cache = self.cacheURL(),
let data = try? Data(contentsOf: cache),
let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr }
}
// Try bundled resource(s)
let bundleCandidates = dependencies.bundledCSVURLs()
let bundleCandidates = [
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
for url in bundleCandidates {
if let data = dependencies.readData(url),
let text = String(data: data, encoding: .utf8) {
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr }
}
}
// Try filesystem path (development/test)
if let cwd = dependencies.currentDirectoryPath(),
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
if let cwd = FileManager.default.currentDirectoryPath as String?,
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
let text = String(data: data, encoding: .utf8) {
return Self.parseCSV(text)
}
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
return []
}
@@ -376,67 +122,33 @@ final class GeoRelayDirectory {
nonisolated static func parseCSV(_ text: String) -> [Entry] {
var result: Set<Entry> = []
let lines = text.split(whereSeparator: { $0.isNewline })
// Skip header if present
for (idx, raw) in lines.enumerated() {
guard let line = raw.trimmedOrNilIfEmpty else { continue }
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if line.isEmpty { continue }
if idx == 0 && line.lowercased().contains("relay url") { continue }
let parts = line.split(separator: ",").map { $0.trimmed }
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
guard parts.count >= 3 else { continue }
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
var host = parts[0]
host = host.replacingOccurrences(of: "https://", with: "")
host = host.replacingOccurrences(of: "http://", with: "")
host = host.replacingOccurrences(of: "wss://", with: "")
host = host.replacingOccurrences(of: "ws://", with: "")
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
result.insert(Entry(host: host, lat: lat, lon: lon))
}
return Array(result)
}
// MARK: - Observers & Timers
private func registerObservers() {
let center = dependencies.notificationCenter
let torReady = center.addObserver(
forName: .TorDidBecomeReady,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded(force: true)
}
}
cleanupState.observers.append(torReady)
if let activeNotificationName = dependencies.activeNotificationName {
let didBecomeActive = center.addObserver(
forName: activeNotificationName,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
}
cleanupState.observers.append(didBecomeActive)
}
private func cacheURL() -> URL? {
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent(cacheFileName)
} catch { return nil }
}
private func startRefreshTimer() {
cleanupState.refreshTimer?.invalidate()
let interval = dependencies.refreshCheckInterval
guard interval > 0 else { return }
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
}
cleanupState.refreshTimer = timer
RunLoop.main.add(timer, forMode: .common)
}
var debugRetryAttempt: Int { retryAttempt }
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
var debugObserverCount: Int { cleanupState.observers.count }
}
// MARK: - Distance
+50
View File
@@ -0,0 +1,50 @@
import Foundation
protocol KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString?)
func load(key: String, service: String) -> Data?
func delete(key: String, service: String)
}
/// Keychain helper for secure storage
struct KeychainHelper: KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func load(key: String, service: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
func delete(key: String, service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
+15 -16
View File
@@ -1,11 +1,10 @@
import Foundation
import BitFoundation
// MARK: - BitChat-over-Nostr Adapter
struct NostrEmbeddedBitChat {
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
// TLV-encode the private message
let pm = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlv = pm.encode() else { return nil }
@@ -15,12 +14,12 @@ struct NostrEmbeddedBitChat {
payload.append(tlv)
// Determine 8-byte recipient ID to embed
let recipientID = normalizeRecipientPeerID(recipientPeerID)
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: Data(hexString: recipientID.id),
senderID: Data(hexString: senderPeerID) ?? Data(),
recipientID: Data(hexString: recipientIDHex),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
@@ -32,18 +31,18 @@ struct NostrEmbeddedBitChat {
}
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
guard type == .delivered || type == .readReceipt else { return nil }
var payload = Data([type.rawValue])
payload.append(Data(messageID.utf8))
let recipientID = normalizeRecipientPeerID(recipientPeerID)
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: Data(hexString: recipientID.id),
senderID: Data(hexString: senderPeerID) ?? Data(),
recipientID: Data(hexString: recipientIDHex),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
@@ -55,7 +54,7 @@ struct NostrEmbeddedBitChat {
}
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
guard type == .delivered || type == .readReceipt else { return nil }
var payload = Data([type.rawValue])
@@ -63,7 +62,7 @@ struct NostrEmbeddedBitChat {
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID.id) ?? Data(),
senderID: Data(hexString: senderPeerID) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
@@ -76,7 +75,7 @@ struct NostrEmbeddedBitChat {
}
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
let pm = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlv = pm.encode() else { return nil }
@@ -85,7 +84,7 @@ struct NostrEmbeddedBitChat {
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID.id) ?? Data(),
senderID: Data(hexString: senderPeerID) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
@@ -97,11 +96,11 @@ struct NostrEmbeddedBitChat {
return "bitchat1:" + base64URLEncode(data)
}
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
if let maybeData = Data(hexString: recipientPeerID.id) {
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
if let maybeData = Data(hexString: recipientPeerID) {
if maybeData.count == 32 {
// Treat as Noise static public key; derive peerID from fingerprint
return PeerID(publicKey: maybeData)
return PeerID(publicKey: maybeData).id
} else if maybeData.count == 8 {
// Already an 8-byte peer ID
return recipientPeerID
+5 -28
View File
@@ -1,4 +1,3 @@
import BitFoundation
import Foundation
import CryptoKit
@@ -9,13 +8,10 @@ final class NostrIdentityBridge {
private let deviceSeedKey = "nostr-device-seed"
// In-memory cache to avoid transient keychain access issues
private var deviceSeedCache: Data?
// Cache derived identities to avoid repeated crypto during view rendering
private var derivedIdentityCache: [String: NostrIdentity] = [:]
private let cacheLock = NSLock()
private let keychain: KeychainManagerProtocol
init(keychain: KeychainManagerProtocol = KeychainManager()) {
private let keychain: KeychainHelperProtocol
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
self.keychain = keychain
}
@@ -110,14 +106,6 @@ final class NostrIdentityBridge {
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
cacheLock.lock()
if let cached = derivedIdentityCache[geohash] {
cacheLock.unlock()
return cached
}
cacheLock.unlock()
let seed = getOrCreateDeviceSeed()
guard let msg = geohash.data(using: .utf8) else {
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
@@ -137,22 +125,11 @@ final class NostrIdentityBridge {
for i in 0..<10 {
let keyData = candidateKey(iteration: UInt32(i))
if let identity = try? NostrIdentity(privateKeyData: keyData) {
// Cache the result
cacheLock.lock()
derivedIdentityCache[geohash] = identity
cacheLock.unlock()
return identity
}
}
// As a final fallback, hash the seed+msg and try again
let fallback = (seed + msg).sha256Hash()
let identity = try NostrIdentity(privateKeyData: fallback)
// Cache the result
cacheLock.lock()
derivedIdentityCache[geohash] = identity
cacheLock.unlock()
return identity
return try NostrIdentity(privateKeyData: fallback)
}
}
+20 -44
View File
@@ -18,7 +18,6 @@ struct NostrProtocol {
case seal = 13 // NIP-17 sealed event
case giftWrap = 1059 // NIP-59 gift wrap
case ephemeralEvent = 20000
case geohashPresence = 20001
}
/// Create a NIP-17 private message
@@ -109,7 +108,7 @@ struct NostrProtocol {
teleported: Bool = false
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname])
}
if teleported {
@@ -126,24 +125,6 @@ struct NostrProtocol {
return try event.sign(with: schnorrKey)
}
/// Create a geohash presence heartbeat (kind 20001)
/// Must contain empty content and NO nickname tag
static func createGeohashPresenceEvent(
geohash: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let tags = [["g", geohash]]
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: tags,
content: ""
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
static func createGeohashTextNote(
content: String,
@@ -152,7 +133,7 @@ struct NostrProtocol {
nickname: String? = nil
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname])
}
let event = NostrEvent(
@@ -303,7 +284,7 @@ struct NostrProtocol {
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return "v2:" + Base64URLCoding.encode(combined)
return "v2:" + base64URLEncode(combined)
}
private static func decrypt(
@@ -314,7 +295,7 @@ struct NostrProtocol {
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3))
guard let data = Base64URLCoding.decode(encoded),
guard let data = base64URLDecode(encoded),
data.count > (24 + 16),
let senderPubkeyData = Data(hexString: senderPubkey) else {
throw NostrError.invalidCiphertext
@@ -528,26 +509,6 @@ struct NostrEvent: Codable {
signed.sig = signatureHex
return signed
}
/// Validate that the event ID and Schnorr signature match the content and pubkey.
/// Returns false when the signature is missing, malformed, or does not verify.
func isValidSignature() -> Bool {
guard let sig = sig,
let sigData = Data(hexString: sig),
let pubData = Data(hexString: pubkey),
sigData.count == 64,
pubData.count == 32,
let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),
let (expectedId, eventHash) = try? calculateEventId(),
expectedId == id
else {
return false
}
var messageBytes = [UInt8](eventHash)
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
return xonly.isValid(signature, for: &messageBytes)
}
private func calculateEventId() throws -> (String, Data) {
let serialized = [
@@ -580,9 +541,24 @@ enum NostrError: Error {
case encryptionFailed
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
// 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),
+136 -354
View File
@@ -4,100 +4,6 @@ import Network
import Combine
import Tor
protocol NostrRelayConnectionProtocol: AnyObject {
func resume()
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void)
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)
}
protocol NostrRelaySessionProtocol {
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol
}
private final class URLSessionWebSocketTaskAdapter: NostrRelayConnectionProtocol {
private let base: URLSessionWebSocketTask
init(base: URLSessionWebSocketTask) {
self.base = base
}
func resume() {
base.resume()
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
base.cancel(with: closeCode, reason: reason)
}
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
base.send(message, completionHandler: completionHandler)
}
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
base.receive(completionHandler: completionHandler)
}
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
base.sendPing(pongReceiveHandler: pongReceiveHandler)
}
}
private struct URLSessionAdapter: NostrRelaySessionProtocol {
let base: URLSession
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
}
}
struct NostrRelayManagerDependencies {
var activationAllowed: () -> Bool
var userTorEnabled: () -> Bool
var hasMutualFavorites: () -> Bool
var hasLocationPermission: () -> Bool
var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
var torEnforced: () -> Bool
var torIsReady: () -> Bool
var torIsForeground: () -> Bool
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
var makeSession: () -> NostrRelaySessionProtocol
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
var now: () -> Date
}
private extension NostrRelayManagerDependencies {
@MainActor
static func live() -> Self {
Self(
activationAllowed: { NetworkActivationService.shared.activationAllowed },
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
torEnforced: { TorManager.shared.torEnforced },
torIsReady: { TorManager.shared.isReady },
torIsForeground: { TorManager.shared.isForeground() },
awaitTorReady: { completion in
Task.detached {
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
completion(ready)
}
}
},
makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) },
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
now: Date.init
)
}
}
/// Manages WebSocket connections to Nostr relays
@MainActor
final class NostrRelayManager: ObservableObject {
@@ -121,40 +27,31 @@ final class NostrRelayManager: ObservableObject {
var nextReconnectTime: Date?
}
// Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059.
// Default relay list (can be customized)
private static let defaultRelays = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"wss://offchain.pub"
"wss://offchain.pub",
"wss://nostr21.com"
// For local testing, you can add: "ws://localhost:8080"
]
private static let defaultRelaySet = Set(defaultRelays.compactMap { NostrRelayURL.normalized($0) })
private static let defaultRelaySet = Set(defaultRelays)
@Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false
private let dependencies: NostrRelayManagerDependencies
private var allowDefaultRelays: Bool = false
private var hasMutualFavorites: Bool = false
private var hasLocationPermission: Bool = false
private var connections: [String: NostrRelayConnectionProtocol] = [:]
private var connections: [String: URLSessionWebSocketTask] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
// Coalesce duplicate subscribe requests for the same id within a short window.
private let subscribeCoalesceInterval: TimeInterval = 1.0
// Coalesce duplicate subscribe requests for the same id within a short window
private var subscribeCoalesce: [String: Date] = [:]
private var pendingTorConnectionURLs = Set<String>()
private var awaitingTorForConnections = false
private var cancellables = Set<AnyCancellable>()
private struct SubscriptionRequestState: Equatable {
let messageString: String
let relayURLs: Set<String>
}
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
// Track EOSE per subscription to signal when initial stored events are done
private struct EOSETracker {
var pendingRelays: Set<String>
@@ -162,7 +59,6 @@ final class NostrRelayManager: ObservableObject {
var timer: Timer?
}
private var eoseTrackers: [String: EOSETracker] = [:]
private var pendingEOSECallbacks: [String: () -> Void] = [:]
// Message queue for reliability
// Pending sends held only for relays that are not yet connected.
@@ -173,7 +69,9 @@ final class NostrRelayManager: ObservableObject {
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock()
private let encoder = JSONEncoder()
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
private let decoder = JSONDecoder()
private var networkService: NetworkActivationService { NetworkActivationService.shared }
private var shouldUseTor: Bool { networkService.userTorEnabled }
// Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -181,17 +79,18 @@ final class NostrRelayManager: ObservableObject {
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
// Reconnection timer
private var reconnectionTimer: Timer?
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0
init() {
self.dependencies = .live()
hasMutualFavorites = dependencies.hasMutualFavorites()
hasLocationPermission = dependencies.hasLocationPermission()
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys
dependencies.mutualFavoritesPublisher
FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main)
.sink { [weak self] favorites in
guard let self = self else { return }
@@ -199,34 +98,7 @@ final class NostrRelayManager: ObservableObject {
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
dependencies.locationPermissionPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
let authorized = (state == .authorized)
if authorized == self.hasLocationPermission { return }
self.hasLocationPermission = authorized
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
}
internal init(dependencies: NostrRelayManagerDependencies) {
self.dependencies = dependencies
hasMutualFavorites = dependencies.hasMutualFavorites()
hasLocationPermission = dependencies.hasLocationPermission()
applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys
dependencies.mutualFavoritesPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] favorites in
guard let self = self else { return }
self.hasMutualFavorites = !favorites.isEmpty
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
dependencies.locationPermissionPublisher
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
@@ -241,8 +113,28 @@ final class NostrRelayManager: ObservableObject {
/// Connect to all configured relays
func connect() {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
connectToRelays(relays.map(\.url), shouldLog: true)
guard networkService.activationAllowed else { return }
if shouldUseTor {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
Task.detached {
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
if !ready {
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
return
}
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
for relay in self.relays {
self.connectToRelay(relay.url)
}
}
}
} else {
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
for relay in self.relays {
connectToRelay(relay.url)
}
}
}
/// Disconnect from all relays
@@ -255,40 +147,44 @@ final class NostrRelayManager: ObservableObject {
// Clear known subscriptions and any queued subs since connections are gone
subscriptions.removeAll()
pendingSubscriptions.removeAll()
subscriptionRequestState.removeAll()
pendingEOSECallbacks.removeAll()
for (_, tracker) in eoseTrackers {
tracker.timer?.invalidate()
}
eoseTrackers.removeAll()
pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false
updateConnectionStatus()
}
/// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
guard networkService.activationAllowed else { return }
let targets = allowedRelayList(from: relayUrls)
guard !targets.isEmpty else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer until Tor is fully ready; avoid queuing connection attempts early
Task.detached { [weak self] in
guard let self = self else { return }
let ready = await TorManager.shared.awaitReady()
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
}
return
}
var existing = Set(relays.map { $0.url })
for url in targets where !existing.contains(url) {
relays.append(Relay(url: url))
existing.insert(url)
}
connectToRelays(targets)
for url in targets where connections[url] == nil {
connectToRelay(url)
}
}
/// Send an event to specified relays (or all if none specified)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
guard networkService.activationAllowed else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer sends until Tor is ready to avoid premature queueing
dependencies.awaitTorReady { [weak self] ready in
Task.detached { [weak self] in
guard let self = self else { return }
if ready { self.sendEvent(event, to: relayUrls) }
let ready = await TorManager.shared.awaitReady()
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
}
return
}
@@ -300,7 +196,7 @@ final class NostrRelayManager: ObservableObject {
// Attempt immediate send to relays with active connections; queue the rest
var stillPending = Set<String>()
for relayUrl in targetRelays {
if let connection = connectedConnection(for: relayUrl) {
if let connection = connections[relayUrl] {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
} else {
stillPending.insert(relayUrl)
@@ -322,7 +218,7 @@ final class NostrRelayManager: ObservableObject {
// Flush only for a specific relay
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
if item.pendingRelays.contains(target), let conn = connectedConnection(for: target) {
if item.pendingRelays.contains(target), let conn = connections[target] {
sendToRelay(event: item.event, connection: conn, relayUrl: target)
item.pendingRelays.remove(target)
if item.pendingRelays.isEmpty {
@@ -337,7 +233,7 @@ final class NostrRelayManager: ObservableObject {
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
for url in item.pendingRelays {
if let conn = connectedConnection(for: url) {
if let conn = connections[url] {
sendToRelay(event: item.event, connection: conn, relayUrl: url)
item.pendingRelays.remove(url)
}
@@ -350,14 +246,6 @@ final class NostrRelayManager: ObservableObject {
}
}
}
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
guard let connection = connections[relayUrl],
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
return nil
}
return connection
}
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
func subscribe(
@@ -368,13 +256,28 @@ final class NostrRelayManager: ObservableObject {
onEOSE: (() -> Void)? = nil
) {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
let now = dependencies.now()
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
return
guard networkService.activationAllowed else { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = Date()
if messageHandlers[id] != nil {
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
return
}
}
subscribeCoalesce[id] = now
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer subscription setup until Tor is ready; avoid queuing subs early
Task.detached { [weak self] in
guard let self = self else { return }
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
if ready {
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler)
}
}
}
return
}
messageHandlers[id] = handler
let req = NostrRequest.subscribe(id: id, filters: [filter])
@@ -390,20 +293,14 @@ final class NostrRelayManager: ObservableObject {
// Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? Self.defaultRelays
let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) }
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
return
}
subscriptionRequestState[id] = requestState
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
let urls = allowedRelayList(from: candidateUrls)
// Always queue subscriptions; sending happens when a relay reports connected
var existingSet = Set(relays.map { $0.url })
let existingSet = Set(relays.map { $0.url })
for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url))
existingSet.insert(url)
}
for url in urls {
for url in candidateUrls {
var map = self.pendingSubscriptions[url] ?? [:]
map[id] = messageString
self.pendingSubscriptions[url] = map
@@ -412,10 +309,20 @@ final class NostrRelayManager: ObservableObject {
if let onEOSE = onEOSE {
if urls.isEmpty {
onEOSE()
} else if shouldWaitForTorBeforeConnecting {
pendingEOSECallbacks[id] = onEOSE
} else {
startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE)
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self = self else { return }
if let t = self.eoseTrackers[id] {
t.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
onEOSE()
}
}
}
eoseTrackers[id] = tracker
}
}
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
@@ -442,7 +349,7 @@ final class NostrRelayManager: ObservableObject {
relays.append(Relay(url: url))
existing.insert(url)
}
if dependencies.activationAllowed() {
if networkService.activationAllowed {
ensureConnections(to: Self.defaultRelays)
}
} else {
@@ -452,7 +359,6 @@ final class NostrRelayManager: ObservableObject {
}
connections.removeValue(forKey: url)
subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url)
}
messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() {
@@ -473,8 +379,7 @@ final class NostrRelayManager: ObservableObject {
private func allowedRelayList(from urls: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for rawURL in urls {
guard let url = NostrRelayURL.normalized(rawURL) else { continue }
for url in urls {
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
if seen.insert(url).inserted {
result.append(url)
@@ -488,13 +393,6 @@ final class NostrRelayManager: ObservableObject {
messageHandlers.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id)
subscriptionRequestState.removeValue(forKey: id)
pendingEOSECallbacks.removeValue(forKey: id)
eoseTrackers[id]?.timer?.invalidate()
eoseTrackers.removeValue(forKey: id)
for url in Array(pendingSubscriptions.keys) {
pendingSubscriptions[url]?.removeValue(forKey: id)
}
let req = NostrRequest.close(id: id)
let message = try? encoder.encode(req)
@@ -505,120 +403,27 @@ final class NostrRelayManager: ObservableObject {
// Send unsubscribe to all relays
for (relayUrl, connection) in connections {
if subscriptions[relayUrl]?.contains(id) == true {
subscriptions[relayUrl]?.remove(id)
connection.send(.string(messageString)) { _ in
// Local state is cleared before sending so callers can re-subscribe immediately.
Task { @MainActor in
self.subscriptions[relayUrl]?.remove(id)
}
}
}
}
}
// MARK: - Private Methods
private var shouldWaitForTorBeforeConnecting: Bool {
shouldUseTor && !dependencies.torIsReady()
}
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
guard dependencies.activationAllowed() else { return }
let targets = allowedRelayList(from: relayUrls).filter {
connections[$0] == nil && !isPermanentlyFailed($0)
}
guard !targets.isEmpty else { return }
if shouldWaitForTorBeforeConnecting {
queueConnectionsUntilTorReady(targets)
return
}
if shouldLog {
let route = shouldUseTor ? "via Tor" : "direct"
SecureLogger.debug("🌐 Connecting to \(targets.count) Nostr relay(s) (\(route))", category: .session)
}
for url in targets {
connectToRelay(url)
}
}
private func queueConnectionsUntilTorReady(_ relayUrls: [String]) {
let targets = allowedRelayList(from: relayUrls).filter {
connections[$0] == nil && !isPermanentlyFailed($0)
}
guard !targets.isEmpty else { return }
pendingTorConnectionURLs.formUnion(targets)
guard !awaitingTorForConnections else { return }
awaitingTorForConnections = true
let generation = connectionGeneration
dependencies.awaitTorReady { [weak self] ready in
guard let self else { return }
guard generation == self.connectionGeneration else { return }
let pending = Array(self.pendingTorConnectionURLs)
self.pendingTorConnectionURLs.removeAll()
self.awaitingTorForConnections = false
guard ready else {
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
return
}
self.connectToRelays(pending, shouldLog: true)
}
}
private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool {
guard !requestState.relayURLs.isEmpty else { return true }
return requestState.relayURLs.allSatisfy { url in
pendingSubscriptions[url]?[id] == requestState.messageString ||
subscriptions[url]?.contains(id) == true
}
}
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
eoseTrackers[id]?.timer?.invalidate()
var tracker = EOSETracker(pendingRelays: relayURLs, callback: callback, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE.
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self else { return }
if let tracker = self.eoseTrackers[id] {
tracker.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
callback()
}
}
}
eoseTrackers[id] = tracker
}
private func startPendingEOSETrackingIfNeeded(id: String) {
guard eoseTrackers[id] == nil,
let callback = pendingEOSECallbacks.removeValue(forKey: id),
let requestState = subscriptionRequestState[id]
else {
return
}
if requestState.relayURLs.isEmpty {
callback()
} else {
startEOSETracking(id: id, relayURLs: requestState.relayURLs, callback: callback)
}
}
private func connectToRelay(_ urlString: String) {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
guard networkService.activationAllowed else { return }
guard let url = URL(string: urlString) else {
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
return
}
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() {
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
return
}
@@ -633,12 +438,19 @@ final class NostrRelayManager: ObservableObject {
// Attempting to connect to Nostr relay via the proxied session
// If Tor is enforced but not ready, delay connection until it is.
if shouldWaitForTorBeforeConnecting {
queueConnectionsUntilTorReady([urlString])
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
Task.detached { [weak self] in
guard let self = self else { return }
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
if ready { self.connectToRelay(urlString) }
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
}
}
return
}
let session = dependencies.makeSession()
let session = TorURLSession.shared.session
let task = session.webSocketTask(with: url)
connections[urlString] = task
@@ -671,7 +483,6 @@ final class NostrRelayManager: ObservableObject {
guard let connection = connections[relayUrl] else { return }
for (id, messageString) in map {
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
startPendingEOSETrackingIfNeeded(id: id)
connection.send(.string(messageString)) { error in
if let error = error {
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
@@ -687,7 +498,7 @@ final class NostrRelayManager: ObservableObject {
pendingSubscriptions[relayUrl] = nil
}
private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) {
task.receive { [weak self] result in
guard let self = self else { return }
@@ -697,7 +508,7 @@ final class NostrRelayManager: ObservableObject {
Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return }
await MainActor.run {
self.handleParsedMessage(parsed, from: relayUrl)
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
}
}
@@ -751,9 +562,9 @@ final class NostrRelayManager: ObservableObject {
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
if isGiftWrap {
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16)) relay=\(relayUrl) reason=\(reason)", category: .session)
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
} else {
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16)) relay=\(relayUrl) reason=\(reason)", category: .session)
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
}
}
case .notice:
@@ -761,7 +572,7 @@ final class NostrRelayManager: ObservableObject {
}
}
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
private func sendToRelay(event: NostrEvent, connection: URLSessionWebSocketTask, relayUrl: String) {
let req = NostrRequest.event(event)
do {
@@ -793,11 +604,11 @@ final class NostrRelayManager: ObservableObject {
relays[index].isConnected = isConnected
relays[index].lastError = error
if isConnected {
relays[index].lastConnectedAt = dependencies.now()
relays[index].lastConnectedAt = Date()
relays[index].reconnectAttempts = 0 // Reset on successful connection
relays[index].nextReconnectTime = nil
} else {
relays[index].lastDisconnectedAt = dependencies.now()
relays[index].lastDisconnectedAt = Date()
}
}
updateConnectionStatus()
@@ -813,7 +624,7 @@ final class NostrRelayManager: ObservableObject {
private func handleDisconnection(relayUrl: String, error: Error) {
// If networking is disallowed, do not schedule reconnection
if !dependencies.activationAllowed() {
if !networkService.activationAllowed {
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
@@ -858,21 +669,19 @@ final class NostrRelayManager: ObservableObject {
maxBackoffInterval
)
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
let nextReconnectTime = Date().addingTimeInterval(backoffInterval)
relays[index].nextReconnectTime = nextReconnectTime
// Schedule reconnection with exponential backoff
let gen = connectionGeneration
dependencies.scheduleAfter(backoffInterval) { [weak self] in
Task { @MainActor [weak self] in
guard let self = self else { return }
// Ignore stale scheduled reconnects from a previous generation
guard gen == self.connectionGeneration else { return }
// Check if we should still reconnect (relay might have been removed)
if self.relays.contains(where: { $0.url == relayUrl }) {
self.connectToRelay(relayUrl)
}
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
guard let self = self else { return }
// Ignore stale scheduled reconnects from a previous generation
guard gen == self.connectionGeneration else { return }
// Check if we should still reconnect (relay might have been removed)
if self.relays.contains(where: { $0.url == relayUrl }) {
self.connectToRelay(relayUrl)
}
}
}
@@ -881,22 +690,20 @@ final class NostrRelayManager: ObservableObject {
/// Manually retry connection to a specific relay
func retryConnection(to relayUrl: String) {
let normalizedRelayUrl = NostrRelayURL.normalized(relayUrl) ?? relayUrl
guard let index = relays.firstIndex(where: { $0.url == normalizedRelayUrl }) else { return }
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
// Reset reconnection attempts
relays[index].reconnectAttempts = 0
relays[index].nextReconnectTime = nil
relays[index].lastError = nil
// Disconnect if connected
if let connection = connections[normalizedRelayUrl] {
if let connection = connections[relayUrl] {
connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: normalizedRelayUrl)
connections.removeValue(forKey: relayUrl)
}
// Attempt immediate reconnection
connectToRelay(normalizedRelayUrl)
connectToRelay(relayUrl)
}
/// Get detailed status for all relays
@@ -908,20 +715,6 @@ final class NostrRelayManager: ObservableObject {
nextReconnectTime: relay.nextReconnectTime)
}
}
var debugPendingMessageQueueCount: Int {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
return messageQueue.count
}
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
pendingSubscriptions[relayUrl]?.count ?? 0
}
func debugFlushMessageQueue() {
flushMessageQueue(for: nil)
}
/// Reset all relay connections
func resetAllConnections() {
@@ -974,8 +767,7 @@ private enum ParsedInbound {
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any],
let event = try? NostrEvent(from: eventDict),
event.isValidSignature() {
let event = try? NostrEvent(from: eventDict) {
self = .event(subId: subId, event: event)
return
}
@@ -1095,10 +887,10 @@ struct NostrFilter: Encodable {
return filter
}
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
// For location channels: geohash-scoped ephemeral events (kind 20000)
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [20000, 20001]
filter.kinds = [20000]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": [geohash]]
filter.limit = limit
@@ -1114,16 +906,6 @@ struct NostrFilter: Encodable {
filter.limit = limit
return filter
}
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": geohashes]
filter.limit = limit
return filter
}
}
// Dynamic coding key for tag filters
-51
View File
@@ -1,51 +0,0 @@
import Foundation
enum NostrRelayURL {
static func normalized(_ rawValue: String, defaultScheme: String? = nil) -> String? {
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return nil }
if !value.contains("://"), let defaultScheme {
value = "\(defaultScheme)://\(value)"
}
guard var components = URLComponents(string: value),
let rawScheme = components.scheme?.lowercased(),
let rawHost = components.host?.lowercased(),
!rawHost.isEmpty else {
return nil
}
switch rawScheme {
case "wss", "https":
components.scheme = "wss"
if components.port == 443 {
components.port = nil
}
case "ws", "http":
components.scheme = "ws"
if components.port == 80 {
components.port = nil
}
default:
return nil
}
components.host = rawHost
if components.path == "/" {
components.path = ""
}
components.fragment = nil
return components.string
}
static func directoryAddress(_ rawValue: String) -> String? {
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
normalized.removeFirst(prefix.count)
break
}
return normalized
}
}
+9 -28
View File
@@ -5,27 +5,16 @@ import CryptoKit
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
/// as per XChaCha20 construction.
enum XChaCha20Poly1305Compat {
/// Errors that can occur during XChaCha20-Poly1305 operations
enum Error: Swift.Error {
case invalidKeyLength(expected: Int, got: Int)
case invalidNonceLength(expected: Int, got: Int)
}
struct SealBox {
let ciphertext: Data
let tag: Data
}
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
guard key.count == 32 else {
throw Error.invalidKeyLength(expected: 32, got: key.count)
}
guard nonce24.count == 24 else {
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
}
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
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)
@@ -34,14 +23,10 @@ enum XChaCha20Poly1305Compat {
}
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
guard key.count == 32 else {
throw Error.invalidKeyLength(expected: 32, got: key.count)
}
guard nonce24.count == 24 else {
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
}
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
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)
@@ -58,14 +43,10 @@ enum XChaCha20Poly1305Compat {
return out
}
private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
private static func hchacha20(key: Data, nonce16: Data) -> Data {
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
guard key.count == 32 else {
throw Error.invalidKeyLength(expected: 32, got: key.count)
}
guard nonce16.count == 16 else {
throw Error.invalidNonceLength(expected: 16, got: nonce16.count)
}
precondition(key.count == 32)
precondition(nonce16.count == 16)
// Constants "expand 32-byte k"
var state: [UInt32] = [
@@ -5,15 +5,42 @@
// Binary encoding utilities for efficient protocol messages
//
import struct Foundation.Data
import struct Foundation.Date
import Foundation
// MARK: - Hex Encoding/Decoding
extension Data {
func hexEncodedString() -> String {
if self.isEmpty {
return ""
}
return self.map { String(format: "%02x", $0) }.joined()
}
init?(hexString: String) {
let len = hexString.count / 2
var data = Data(capacity: len)
var index = hexString.startIndex
for _ in 0..<len {
let nextIndex = hexString.index(index, offsetBy: 2)
guard let byte = UInt8(String(hexString[index..<nextIndex]), radix: 16) else {
return nil
}
data.append(byte)
index = nextIndex
}
self = data
}
}
// MARK: - Binary Encoding Utilities
extension Data {
// MARK: Writing
@inlinable public mutating func appendUInt8(_ value: UInt8) {
@inlinable mutating func appendUInt8(_ value: UInt8) {
self.append(value)
}
@@ -35,7 +62,7 @@ extension Data {
}
}
public mutating func appendString(_ string: String, maxLength: Int = 255) {
mutating func appendString(_ string: String, maxLength: Int = 255) {
guard let data = string.data(using: .utf8) else { return }
let length = Swift.min(data.count, maxLength)
@@ -48,7 +75,7 @@ extension Data {
self.append(data.prefix(length))
}
public mutating func appendData(_ data: Data, maxLength: Int = 65535) {
mutating func appendData(_ data: Data, maxLength: Int = 65535) {
let length = Swift.min(data.count, maxLength)
if maxLength <= 255 {
@@ -60,12 +87,12 @@ extension Data {
self.append(data.prefix(length))
}
public mutating func appendDate(_ date: Date) {
mutating func appendDate(_ date: Date) {
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
self.appendUInt64(timestamp)
}
public mutating func appendUUID(_ uuid: String) {
mutating func appendUUID(_ uuid: String) {
// Convert UUID string to 16 bytes
var uuidData = Data(count: 16)
@@ -86,7 +113,7 @@ extension Data {
// MARK: Reading
@inlinable public func readUInt8(at offset: inout Int) -> UInt8? {
@inlinable func readUInt8(at offset: inout Int) -> UInt8? {
guard offset >= 0 && offset < self.count else { return nil }
let value = self[offset]
offset += 1
@@ -120,7 +147,7 @@ extension Data {
return value
}
public func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
let length: Int
if maxLength <= 255 {
@@ -139,7 +166,7 @@ extension Data {
return String(data: stringData, encoding: .utf8)
}
public func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
let length: Int
if maxLength <= 255 {
@@ -158,12 +185,12 @@ extension Data {
return data
}
public func readDate(at offset: inout Int) -> Date? {
func readDate(at offset: inout Int) -> Date? {
guard let timestamp = readUInt64(at: &offset) else { return nil }
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
}
public func readUUID(at offset: inout Int) -> String? {
func readUUID(at offset: inout Int) -> String? {
guard offset + 16 <= self.count else { return nil }
let uuidData = self[offset..<offset + 16]
@@ -184,7 +211,7 @@ extension Data {
return result.uppercased()
}
public func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
guard offset + count <= self.count else { return nil }
let data = self[offset..<offset + count]
+330
View File
@@ -0,0 +1,330 @@
//
// BinaryProtocol.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
///
/// # BinaryProtocol
///
/// Low-level binary encoding and decoding for BitChat protocol messages.
/// Optimized for Bluetooth LE's limited bandwidth and MTU constraints.
///
/// ## Overview
/// BinaryProtocol implements an efficient binary wire format that minimizes
/// overhead while maintaining extensibility. It handles:
/// - Compact binary encoding with fixed headers
/// - Optional field support via flags
/// - Automatic compression for large payloads
/// - Endianness handling for cross-platform compatibility
///
/// ## Wire Format
/// ```
/// Header (Fixed 13 bytes):
/// +--------+------+-----+-----------+-------+----------------+
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
/// +--------+------+-----+-----------+-------+----------------+
///
/// Variable sections:
/// +----------+-------------+---------+------------+
/// | SenderID | RecipientID | Payload | Signature |
/// | 8 bytes | 8 bytes* | Variable| 64 bytes* |
/// +----------+-------------+---------+------------+
/// * Optional fields based on flags
/// ```
///
/// ## Design Rationale
/// The protocol is designed for:
/// - **Efficiency**: Minimal overhead for small messages
/// - **Flexibility**: Optional fields via flag bits
/// - **Compatibility**: Network byte order (big-endian)
/// - **Performance**: Zero-copy where possible
///
/// ## Compression Strategy
/// - Automatic compression for payloads > 256 bytes
/// - zlib compression for broad compatibility on Apple platforms
/// - Original size stored for decompression
/// - Flag bit indicates compressed payload
///
/// ## Flag Bits
/// - Bit 0: Has recipient ID (directed message)
/// - Bit 1: Has signature (authenticated message)
/// - Bit 2: Is compressed (LZ4 compression applied)
/// - Bits 3-7: Reserved for future use
///
/// ## Size Constraints
/// - Maximum packet size: 65,535 bytes (16-bit length field)
/// - Typical packet size: < 512 bytes (BLE MTU)
/// - Minimum packet size: 21 bytes (header + sender ID)
///
/// ## Encoding Process
/// 1. Construct header with fixed fields
/// 2. Set appropriate flags
/// 3. Compress payload if beneficial
/// 4. Append variable-length fields
/// 5. Calculate and append signature if needed
///
/// ## Decoding Process
/// 1. Validate minimum packet size
/// 2. Parse fixed header
/// 3. Extract flags and determine field presence
/// 4. Parse variable fields based on flags
/// 5. Decompress payload if compressed
/// 6. Verify signature if present
///
/// ## Error Handling
/// - Graceful handling of malformed packets
/// - Clear error messages for debugging
/// - No crashes on invalid input
/// - Logging of protocol violations
///
/// ## Performance Notes
/// - Allocation-free for small messages
/// - Streaming support for large payloads
/// - Efficient bit manipulation
/// - Platform-optimized byte swapping
///
import Foundation
extension Data {
func trimmingNullBytes() -> Data {
// Find the first null byte
if let nullIndex = self.firstIndex(of: 0) {
return self.prefix(nullIndex)
}
return self
}
}
/// Implements binary encoding and decoding for BitChat protocol messages.
/// Provides static methods for converting between BitchatPacket objects and
/// their binary wire format representation.
/// - Note: All multi-byte values use network byte order (big-endian)
struct BinaryProtocol {
static let headerSize = 13
static let senderIDSize = 8
static let recipientIDSize = 8
static let signatureSize = 64
struct Flags {
static let hasRecipient: UInt8 = 0x01
static let hasSignature: UInt8 = 0x02
static let isCompressed: UInt8 = 0x04
}
// Encode BitchatPacket to binary format
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
var data = Data()
// Try to compress payload if beneficial
var payload = packet.payload
var originalPayloadSize: UInt16? = nil
var isCompressed = false
if CompressionUtil.shouldCompress(payload) {
if let compressedPayload = CompressionUtil.compress(payload) {
// Store original size for decompression (2 bytes after payload)
originalPayloadSize = UInt16(payload.count)
payload = compressedPayload
isCompressed = true
} else {
}
} else {
}
// Header
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
data.reserveCapacity(estimated)
data.append(packet.version)
data.append(packet.type)
data.append(packet.ttl)
// Timestamp (8 bytes, big-endian)
for i in (0..<8).reversed() {
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
}
// Flags
var flags: UInt8 = 0
if packet.recipientID != nil {
flags |= Flags.hasRecipient
}
if packet.signature != nil {
flags |= Flags.hasSignature
}
if isCompressed {
flags |= Flags.isCompressed
}
data.append(flags)
// Payload length (2 bytes, big-endian) - includes original size if compressed
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
let payloadLength = UInt16(payloadDataSize)
data.append(UInt8((payloadLength >> 8) & 0xFF))
data.append(UInt8(payloadLength & 0xFF))
// SenderID (exactly 8 bytes)
let senderBytes = packet.senderID.prefix(senderIDSize)
data.append(senderBytes)
if senderBytes.count < senderIDSize {
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
}
// RecipientID (if present)
if let recipientID = packet.recipientID {
let recipientBytes = recipientID.prefix(recipientIDSize)
data.append(recipientBytes)
if recipientBytes.count < recipientIDSize {
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
}
}
// Payload (with original size prepended if compressed)
if isCompressed, let originalSize = originalPayloadSize {
// Prepend original size (2 bytes, big-endian)
data.append(UInt8((originalSize >> 8) & 0xFF))
data.append(UInt8(originalSize & 0xFF))
}
data.append(payload)
// Signature (if present)
if let signature = packet.signature {
data.append(signature.prefix(signatureSize))
}
// Apply padding to standard block sizes for traffic analysis resistance
if padding {
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
return paddedData
} else {
// Caller explicitly requested no padding (e.g., BLE write path)
return data
}
}
// Decode binary data to BitchatPacket
static func decode(_ data: Data) -> BitchatPacket? {
// Try decode as-is first (robust when padding wasn't applied)
if let pkt = decodeCore(data) { return pkt }
// If that fails, try after removing padding
let unpadded = MessagePadding.unpad(data)
if unpadded as NSData === data as NSData { return nil }
return decodeCore(unpadded)
}
// Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
// Minimum size: header + senderID
guard raw.count >= headerSize + senderIDSize else { return nil }
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
guard let base = buf.baseAddress else { return nil }
var offset = 0
func require(_ n: Int) -> Bool { offset + n <= buf.count }
// Read single byte
func read8() -> UInt8? {
guard require(1) else { return nil }
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
offset += 1
return v
}
// Read big-endian 16-bit
func read16() -> UInt16? {
guard require(2) else { return nil }
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
offset += 2
return v
}
// Copy N bytes into Data
func readData(_ n: Int) -> Data? {
guard require(n) else { return nil }
let ptr = base.advanced(by: offset)
let d = Data(bytes: ptr, count: n)
offset += n
return d
}
// Version
guard let version = read8(), version == 1 else { return nil }
guard let type = read8() else { return nil }
guard let ttl = read8() else { return nil }
// Timestamp 8 bytes BE
guard require(8) else { return nil }
var ts: UInt64 = 0
for _ in 0..<8 {
guard let b = read8() else { return nil }
ts = (ts << 8) | UInt64(b)
}
// Flags
guard let flags = read8() else { return nil }
let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0
// Payload length
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
// SenderID
guard let senderID = readData(senderIDSize) else { return nil }
// Recipient
var recipientID: Data? = nil
if hasRecipient {
recipientID = readData(recipientIDSize)
if recipientID == nil { return nil }
}
// Payload
let payload: Data
if isCompressed {
// Need original size (2 bytes)
guard let origSize16 = read16() else { return nil }
let originalSize = Int(origSize16)
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
let compSize = Int(payloadLen) - 2
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
decompressed.count == originalSize else { return nil }
payload = decompressed
} else {
guard let p = readData(Int(payloadLen)) else { return nil }
payload = p
}
// Signature
var signature: Data? = nil
if hasSignature {
signature = readData(signatureSize)
if signature == nil { return nil }
}
guard offset <= buf.count else { return nil }
return BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: ts,
payload: payload,
signature: signature,
ttl: ttl
)
}
}
}
-156
View File
@@ -1,156 +0,0 @@
//
// BitchatFilePacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import BitFoundation
import BitLogger
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
/// Mirrors the Android client specification to ensure cross-platform interoperability.
struct BitchatFilePacket {
var fileName: String?
var fileSize: UInt64?
var mimeType: String?
var content: Data
/// Canonical TLV tags defined by the Android implementation.
private enum TLVType: UInt8 {
case fileName = 0x01
case fileSize = 0x02
case mimeType = 0x03
case content = 0x04
}
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
func encode() -> Data? {
let resolvedSize = fileSize ?? UInt64(content.count)
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
guard content.count <= Int(UInt32.max) else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
var big = value.bigEndian
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
}
var encoded = Data()
if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {
encoded.append(TLVType.fileName.rawValue)
appendBE(UInt16(nameData.count), into: &encoded)
encoded.append(nameData)
}
encoded.append(TLVType.fileSize.rawValue)
appendBE(UInt16(4), into: &encoded)
appendBE(UInt32(resolvedSize), into: &encoded)
if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {
encoded.append(TLVType.mimeType.rawValue)
appendBE(UInt16(mimeData.count), into: &encoded)
encoded.append(mimeData)
}
encoded.append(TLVType.content.rawValue)
appendBE(UInt32(content.count), into: &encoded)
encoded.append(content)
return encoded
}
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
static func decode(_ data: Data) -> BitchatFilePacket? {
var cursor = data.startIndex
let end = data.endIndex
var fileName: String?
var fileSize: UInt64?
var mimeType: String?
var content = Data()
while cursor < end {
let typeRaw = data[cursor]
cursor = data.index(after: cursor)
guard cursor <= end else { return nil }
let tlvType = TLVType(rawValue: typeRaw)
func readBigEndianLength(bytes: Int) -> Int? {
guard data.distance(from: cursor, to: end) >= bytes else { return nil }
// Use UInt64 to prevent integer overflow during shift operations
var result: UInt64 = 0
for _ in 0..<bytes {
result = (result << 8) | UInt64(data[cursor])
cursor = data.index(after: cursor)
}
// Safely convert to Int with overflow check
guard result <= Int.max else { return nil }
return Int(result)
}
let length: Int?
if tlvType == .content {
let snapshot = cursor
let canonical = readBigEndianLength(bytes: 4)
if let canonical = canonical,
canonical <= data.distance(from: cursor, to: end) {
length = canonical
} else {
cursor = snapshot
length = readBigEndianLength(bytes: 2)
}
} else {
length = readBigEndianLength(bytes: 2)
}
guard let tlvLength = length, tlvLength >= 0 else { return nil }
guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }
let valueStart = cursor
cursor = data.index(cursor, offsetBy: tlvLength)
let value = data[valueStart..<cursor]
switch tlvType {
case .fileName:
fileName = String(data: Data(value), encoding: .utf8)
case .fileSize:
if tlvLength == 4 || tlvLength == 8 {
var size: UInt64 = 0
for byte in value {
size = (size << 8) | UInt64(byte)
}
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
return nil
}
fileSize = size
}
case .mimeType:
mimeType = String(data: Data(value), encoding: .utf8)
case .content:
let proposedSize = content.count + value.count
if proposedSize > FileTransferLimits.maxPayloadBytes {
return nil
}
content.append(contentsOf: value)
case nil:
continue
}
}
guard !content.isEmpty else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
return BitchatFilePacket(
fileName: fileName,
fileSize: fileSize ?? UInt64(content.count),
mimeType: mimeType,
content: content
)
}
}
+63 -3
View File
@@ -60,7 +60,38 @@
import Foundation
import CoreBluetooth
import BitFoundation
// MARK: - Message Types
/// Simplified BitChat protocol message types.
/// Reduced from 24 types to just 6 essential ones.
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
enum MessageType: UInt8 {
// Public messages (unencrypted)
case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving"
case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
// Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages
var description: String {
switch self {
case .announce: return "announce"
case .message: return "message"
case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment"
}
}
}
// MARK: - Noise Payload Types
@@ -98,6 +129,35 @@ enum LazyHandshakeState {
case failed(Error) // Handshake failed
}
// MARK: - Delivery Status
// Delivery status for messages
enum DeliveryStatus: Codable, Equatable, Hashable {
case sending
case sent // Left our device
case delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient
case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms
var displayText: String {
switch self {
case .sending:
return "Sending..."
case .sent:
return "Sent"
case .delivered(let nickname, _):
return "Delivered to \(nickname)"
case .read(let nickname, _):
return "Read by \(nickname)"
case .failed(let reason):
return "Failed: \(reason)"
case .partiallyDelivered(let reached, let total):
return "Delivered to \(reached)/\(total)"
}
}
}
// MARK: - Delegate Protocol
protocol BitchatDelegate: AnyObject {
@@ -116,7 +176,7 @@ protocol BitchatDelegate: AnyObject {
// Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
}
// Provide default implementation to make it effectively optional
@@ -133,7 +193,7 @@ extension BitchatDelegate {
// Default empty implementation
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
// Default empty implementation
}
}
-53
View File
@@ -119,57 +119,4 @@ enum Geohash {
}
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
}
/// Returns all 8 neighboring geohash cells at the same precision.
/// - Parameter geohash: Base32 geohash string.
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
static func neighbors(of geohash: String) -> [String] {
guard !geohash.isEmpty else { return [] }
let precision = geohash.count
let bounds = decodeBounds(geohash)
let center = decodeCenter(geohash)
// Calculate cell dimensions
let latHeight = bounds.latMax - bounds.latMin
let lonWidth = bounds.lonMax - bounds.lonMin
// Helper to wrap longitude around ±180
func wrapLongitude(_ lon: Double) -> Double {
var wrapped = lon
while wrapped > 180.0 { wrapped -= 360.0 }
while wrapped < -180.0 { wrapped += 360.0 }
return wrapped
}
// Helper to clamp latitude to ±90
func clampLatitude(_ lat: Double) -> Double {
return max(-90.0, min(90.0, lat))
}
// Calculate 8 neighbor centers
let neighbors: [(lat: Double, lon: Double)] = [
(center.lat + latHeight, center.lon), // N
(center.lat + latHeight, center.lon + lonWidth), // NE
(center.lat, center.lon + lonWidth), // E
(center.lat - latHeight, center.lon + lonWidth), // SE
(center.lat - latHeight, center.lon), // S
(center.lat - latHeight, center.lon - lonWidth), // SW
(center.lat, center.lon - lonWidth), // W
(center.lat + latHeight, center.lon - lonWidth) // NW
]
// Encode each neighbor, handling boundary conditions
return neighbors.compactMap { neighbor in
let lat = clampLatitude(neighbor.lat)
let lon = wrapLongitude(neighbor.lon)
// Skip if we've crossed a pole (latitude clamped to boundary)
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
return nil
}
return encode(latitude: lat, longitude: lon, precision: precision)
}
}
}
-14
View File
@@ -116,18 +116,4 @@ enum ChannelID: Equatable, Codable {
case .location(let ch): return ch.geohash
}
}
var isMesh: Bool {
switch self {
case .mesh: true
case .location: false
}
}
var isLocation: Bool {
switch self {
case .mesh: false
case .location: true
}
}
}
+1 -26
View File
@@ -6,13 +6,11 @@ struct AnnouncementPacket {
let nickname: String
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
let signingPublicKey: Data // Ed25519 public key for signing
let directNeighbors: [Data]? // 8-byte peer IDs
private enum TLVType: UInt8 {
case nickname = 0x01
case noisePublicKey = 0x02
case signingPublicKey = 0x03
case directNeighbors = 0x04
}
func encode() -> Data? {
@@ -37,16 +35,6 @@ struct AnnouncementPacket {
data.append(TLVType.signingPublicKey.rawValue)
data.append(UInt8(signingPublicKey.count))
data.append(signingPublicKey)
// TLV for direct neighbors (optional)
if let neighbors = directNeighbors, !neighbors.isEmpty {
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
data.append(TLVType.directNeighbors.rawValue)
data.append(UInt8(neighborsData.count))
data.append(neighborsData)
}
}
return data
}
@@ -56,7 +44,6 @@ struct AnnouncementPacket {
var nickname: String?
var noisePublicKey: Data?
var signingPublicKey: Data?
var directNeighbors: [Data]?
while offset + 2 <= data.count {
let typeRaw = data[offset]
@@ -76,17 +63,6 @@ struct AnnouncementPacket {
noisePublicKey = Data(value)
case .signingPublicKey:
signingPublicKey = Data(value)
case .directNeighbors:
if length > 0 && length % 8 == 0 {
var neighbors = [Data]()
let count = length / 8
for i in 0..<count {
let start = value.startIndex + i * 8
let end = start + 8
neighbors.append(Data(value[start..<end]))
}
directNeighbors = neighbors
}
}
} else {
// Unknown TLV; skip (tolerant decoder for forward compatibility)
@@ -98,8 +74,7 @@ struct AnnouncementPacket {
return AnnouncementPacket(
nickname: nickname,
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
directNeighbors: directNeighbors
signingPublicKey: signingPublicKey
)
}
}
@@ -1,116 +0,0 @@
import BitFoundation
import Foundation
struct BLEAnnouncePreflightAcceptance {
let announcement: AnnouncementPacket
let derivedPeerID: PeerID
}
enum BLEAnnouncePreflightRejection: Equatable {
case malformed
case senderMismatch(derivedPeerID: PeerID)
case selfAnnounce
case stale(ageSeconds: Double)
}
enum BLEAnnouncePreflightDecision {
case accept(BLEAnnouncePreflightAcceptance)
case reject(BLEAnnouncePreflightRejection)
}
enum BLEAnnouncePreflightPolicy {
static func evaluate(
packet: BitchatPacket,
from peerID: PeerID,
localPeerID: PeerID,
now: Date
) -> BLEAnnouncePreflightDecision {
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
return .reject(.malformed)
}
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
guard derivedPeerID == peerID else {
return .reject(.senderMismatch(derivedPeerID: derivedPeerID))
}
guard peerID != localPeerID else {
return .reject(.selfAnnounce)
}
guard !BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) else {
return .reject(.stale(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
timestampMilliseconds: packet.timestamp,
now: now
)))
}
return .accept(BLEAnnouncePreflightAcceptance(
announcement: announcement,
derivedPeerID: derivedPeerID
))
}
}
enum BLEAnnounceTrustRejection: Equatable {
case missingSignature
case invalidSignature
case keyMismatch
}
enum BLEAnnounceTrustDecision: Equatable {
case verified
case reject(BLEAnnounceTrustRejection)
var isVerified: Bool {
self == .verified
}
}
enum BLEAnnounceTrustPolicy {
static func evaluate(
hasSignature: Bool,
signatureValid: Bool,
existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data
) -> BLEAnnounceTrustDecision {
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
return .reject(.keyMismatch)
}
guard hasSignature else {
return .reject(.missingSignature)
}
guard signatureValid else {
return .reject(.invalidSignature)
}
return .verified
}
}
struct BLEAnnounceResponsePlan: Equatable {
let shouldNotifyPeerConnected: Bool
let shouldScheduleInitialSync: Bool
let shouldSendAnnounceBack: Bool
let shouldScheduleAfterglow: Bool
}
enum BLEAnnounceResponsePolicy {
static func plan(
isDirectAnnounce: Bool,
isNewPeer: Bool,
isReconnectedPeer: Bool,
shouldSendAnnounceBack: Bool
) -> BLEAnnounceResponsePlan {
let shouldNotifyPeerConnected = isDirectAnnounce && (isNewPeer || isReconnectedPeer)
return BLEAnnounceResponsePlan(
shouldNotifyPeerConnected: shouldNotifyPeerConnected,
shouldScheduleInitialSync: shouldNotifyPeerConnected,
shouldSendAnnounceBack: shouldSendAnnounceBack,
shouldScheduleAfterglow: isNewPeer
)
}
}
@@ -1,31 +0,0 @@
import Foundation
struct BLEAnnounceThrottle {
private var lastSent: Date
private let normalMinimumInterval: TimeInterval
private let forcedMinimumInterval: TimeInterval
init(
lastSent: Date = .distantPast,
normalMinimumInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval,
forcedMinimumInterval: TimeInterval = TransportConfig.bleForceAnnounceMinIntervalSeconds
) {
self.lastSent = lastSent
self.normalMinimumInterval = normalMinimumInterval
self.forcedMinimumInterval = forcedMinimumInterval
}
func elapsed(since now: Date) -> TimeInterval {
now.timeIntervalSince(lastSent)
}
mutating func shouldSend(force: Bool, now: Date) -> Bool {
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
guard elapsed(since: now) >= minimumInterval else {
return false
}
lastSent = now
return true
}
}
@@ -1,271 +0,0 @@
import Foundation
struct BLEConnectionCandidate<Peripheral> {
let peripheral: Peripheral
let peripheralID: String
let rssi: Int
let name: String
let isConnectable: Bool
let discoveredAt: Date
}
struct BLEExistingConnectionState {
let isConnecting: Bool
let isConnected: Bool
let lastConnectionAttempt: Date?
}
enum BLEPeripheralConnectionState {
case disconnected
case connecting
case connected
}
enum BLEDiscoveryDecision: Equatable {
case ignore
case queued
case scheduleRetry(after: TimeInterval)
case cancelStaleConnection
case connectNow
}
enum BLEConnectionQueueDecision<Peripheral> {
case none
case retryAfter(TimeInterval)
case connect(BLEConnectionCandidate<Peripheral>)
}
final class BLEConnectionScheduler<Peripheral> {
private let maxCentralLinks: Int
private let connectRateLimitInterval: TimeInterval
private let candidateCap: Int
private let weakLinkCooldownSeconds: TimeInterval
private let weakLinkRSSICutoff: Int
private let recentTimeoutWindowSeconds: TimeInterval
private let recentTimeoutCountThreshold: Int
private var lastGlobalConnectAttempt: Date = .distantPast
private var candidates: [BLEConnectionCandidate<Peripheral>] = []
private var failureCounts: [String: Int] = [:]
private var recentConnectTimeouts: [String: Date] = [:]
private var lastIsolatedAt: Date?
private let initialDynamicRSSIThreshold: Int
private(set) var dynamicRSSIThreshold: Int
var candidateCount: Int {
candidates.count
}
init(
maxCentralLinks: Int = TransportConfig.bleMaxCentralLinks,
connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval,
candidateCap: Int = TransportConfig.bleConnectionCandidatesMax,
weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds,
weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff,
recentTimeoutWindowSeconds: TimeInterval = TransportConfig.bleRecentTimeoutWindowSeconds,
recentTimeoutCountThreshold: Int = TransportConfig.bleRecentTimeoutCountThreshold,
dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
) {
self.maxCentralLinks = maxCentralLinks
self.connectRateLimitInterval = connectRateLimitInterval
self.candidateCap = candidateCap
self.weakLinkCooldownSeconds = weakLinkCooldownSeconds
self.weakLinkRSSICutoff = weakLinkRSSICutoff
self.recentTimeoutWindowSeconds = recentTimeoutWindowSeconds
self.recentTimeoutCountThreshold = recentTimeoutCountThreshold
self.initialDynamicRSSIThreshold = dynamicRSSIThreshold
self.dynamicRSSIThreshold = dynamicRSSIThreshold
}
func handleDiscovery(
_ candidate: BLEConnectionCandidate<Peripheral>,
connectedOrConnectingCount: Int,
existingState: BLEExistingConnectionState?,
peripheralState: BLEPeripheralConnectionState,
now: Date
) -> BLEDiscoveryDecision {
guard candidate.isConnectable else { return .ignore }
if candidate.rssi <= dynamicRSSIThreshold {
enqueue(candidate)
return .queued
}
if connectedOrConnectingCount >= maxCentralLinks {
enqueue(candidate)
return .queued
}
if let retryDelay = rateLimitRetryDelay(now: now) {
enqueue(candidate)
return .scheduleRetry(after: retryDelay)
}
if let existingState {
if existingState.isConnected || existingState.isConnecting {
return .ignore
}
if let lastAttempt = existingState.lastConnectionAttempt,
now.timeIntervalSince(lastAttempt) < 2.0 {
return .ignore
}
}
if let lastTimeout = recentConnectTimeouts[candidate.peripheralID],
now.timeIntervalSince(lastTimeout) < 15 {
return .ignore
}
switch peripheralState {
case .disconnected:
return .connectNow
case .connecting, .connected:
return .cancelStaleConnection
}
}
func enqueue(_ candidate: BLEConnectionCandidate<Peripheral>) {
if let existingIndex = candidates.firstIndex(where: { $0.peripheralID == candidate.peripheralID }) {
candidates[existingIndex] = candidate
} else {
candidates.append(candidate)
}
candidates.sort {
if $0.rssi != $1.rssi { return $0.rssi > $1.rssi }
return $0.discoveredAt < $1.discoveredAt
}
if candidates.count > candidateCap {
candidates.removeLast(candidates.count - candidateCap)
}
}
func nextCandidate(
connectedOrConnectingCount: Int,
isAlreadyConnectingOrConnected: (String) -> Bool,
now: Date
) -> BLEConnectionQueueDecision<Peripheral> {
guard connectedOrConnectingCount < maxCentralLinks else { return .none }
if let retryDelay = rateLimitRetryDelay(now: now) {
return .retryAfter(retryDelay)
}
while !candidates.isEmpty {
candidates.sort { score($0, now: now) > score($1, now: now) }
let candidate = candidates.removeFirst()
guard candidate.isConnectable else { continue }
if let delay = weakLinkRetryDelay(for: candidate, now: now) {
enqueue(candidate)
return .retryAfter(delay)
}
if isAlreadyConnectingOrConnected(candidate.peripheralID) {
continue
}
return .connect(candidate)
}
return .none
}
func recordConnectionAttempt(at now: Date) {
lastGlobalConnectAttempt = now
}
func recordConnectionSuccess(peripheralID: String) {
failureCounts[peripheralID] = 0
recentConnectTimeouts.removeValue(forKey: peripheralID)
}
func recordConnectionFailure(peripheralID: String) {
failureCounts[peripheralID, default: 0] += 1
}
func recordDisconnectError(peripheralID: String, at now: Date) {
recentConnectTimeouts[peripheralID] = now
}
func recordConnectionTimeout(peripheralID: String, at now: Date) {
recentConnectTimeouts[peripheralID] = now
recordConnectionFailure(peripheralID: peripheralID)
}
func pruneConnectionTimeouts(before cutoff: Date) {
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff }
}
func reset() {
lastGlobalConnectAttempt = .distantPast
candidates.removeAll()
failureCounts.removeAll()
recentConnectTimeouts.removeAll()
lastIsolatedAt = nil
dynamicRSSIThreshold = initialDynamicRSSIThreshold
}
@discardableResult
func updateRSSIThreshold(
connectedCount: Int,
connectedOrConnectingLinkCount: Int,
now: Date
) -> Int {
if connectedCount == 0 {
if lastIsolatedAt == nil { lastIsolatedAt = now }
let isolatedAt = lastIsolatedAt ?? now
let elapsed = now.timeIntervalSince(isolatedAt)
dynamicRSSIThreshold = elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds
? TransportConfig.bleRSSIIsolatedRelaxed
: TransportConfig.bleRSSIIsolatedBase
return dynamicRSSIThreshold
}
lastIsolatedAt = nil
var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
if connectedOrConnectingLinkCount >= maxCentralLinks || candidates.count >= candidateCap {
threshold = TransportConfig.bleRSSIConnectedThreshold
}
let recentTimeouts = recentConnectTimeouts.filter {
now.timeIntervalSince($0.value) < recentTimeoutWindowSeconds
}.count
if recentTimeouts >= recentTimeoutCountThreshold {
threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold)
}
dynamicRSSIThreshold = threshold
return threshold
}
private func rateLimitRetryDelay(now: Date) -> TimeInterval? {
let elapsed = now.timeIntervalSince(lastGlobalConnectAttempt)
guard elapsed < connectRateLimitInterval else { return nil }
return connectRateLimitInterval - elapsed + 0.05
}
private func weakLinkRetryDelay(
for candidate: BLEConnectionCandidate<Peripheral>,
now: Date
) -> TimeInterval? {
guard let lastTimeout = recentConnectTimeouts[candidate.peripheralID] else { return nil }
let elapsed = now.timeIntervalSince(lastTimeout)
guard elapsed < weakLinkCooldownSeconds && candidate.rssi <= weakLinkRSSICutoff else { return nil }
let remaining = weakLinkCooldownSeconds - elapsed
return min(max(2.0, remaining), 15.0)
}
private func score(_ candidate: BLEConnectionCandidate<Peripheral>, now: Date) -> Int {
let failures = failureCounts[candidate.peripheralID] ?? 0
let penalty = min(20, 1 << min(4, failures))
let timeoutBias = recentConnectTimeouts[candidate.peripheralID].map {
now.timeIntervalSince($0) < 60 ? 10 : 0
} ?? 0
let base = (candidate.isConnectable ? 1000 : 0) + (candidate.rssi + 100) * 2
let recency = -Int(now.timeIntervalSince(candidate.discoveredAt) * 10)
return base + recency - penalty - timeoutBias
}
}
@@ -1,71 +0,0 @@
import BitFoundation
import Foundation
struct BLEDirectedRelaySpoolEntry {
let recipient: PeerID
let packet: BitchatPacket
}
struct BLEDirectedRelaySpool {
private struct StoredPacket {
let packet: BitchatPacket
let enqueuedAt: Date
}
private var packetsByRecipient: [PeerID: [String: StoredPacket]] = [:]
var isEmpty: Bool {
packetsByRecipient.isEmpty
}
var count: Int {
packetsByRecipient.values.reduce(0) { $0 + $1.count }
}
@discardableResult
mutating func enqueue(
packet: BitchatPacket,
recipient: PeerID,
messageID: String,
enqueuedAt: Date
) -> Bool {
var packets = packetsByRecipient[recipient] ?? [:]
guard packets[messageID] == nil else {
return false
}
packets[messageID] = StoredPacket(packet: packet, enqueuedAt: enqueuedAt)
packetsByRecipient[recipient] = packets
return true
}
mutating func drainUnexpired(now: Date, window: TimeInterval) -> [BLEDirectedRelaySpoolEntry] {
var entries: [BLEDirectedRelaySpoolEntry] = []
for (recipient, packets) in packetsByRecipient {
for stored in packets.values where now.timeIntervalSince(stored.enqueuedAt) <= window {
entries.append(BLEDirectedRelaySpoolEntry(recipient: recipient, packet: stored.packet))
}
}
packetsByRecipient.removeAll()
return entries
}
mutating func pruneExpired(now: Date, window: TimeInterval) {
guard !packetsByRecipient.isEmpty else { return }
var pruned: [PeerID: [String: StoredPacket]] = [:]
for (recipient, packets) in packetsByRecipient {
let freshPackets = packets.filter { now.timeIntervalSince($0.value.enqueuedAt) <= window }
if !freshPackets.isEmpty {
pruned[recipient] = freshPackets
}
}
packetsByRecipient = pruned
}
mutating func removeAll() {
packetsByRecipient.removeAll()
}
}
@@ -1,109 +0,0 @@
import BitFoundation
import CryptoKit
import Foundation
struct BLEFanoutSelection: Equatable {
let peripheralIDs: Set<String>
let centralIDs: Set<String>
}
enum BLEFanoutSelector {
static func selectLinks(
peripheralIDs: [String],
centralIDs: [String],
ingressLink: BLEIngressLinkID?,
excludedLinks: Set<BLEIngressLinkID> = [],
directedPeerHint: PeerID?,
packetType: UInt8,
messageID: String
) -> BLEFanoutSelection {
let allowed = allowedLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
)
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
return BLEFanoutSelection(
peripheralIDs: Set(allowed.peripheralIDs),
centralIDs: Set(allowed.centralIDs)
)
}
return BLEFanoutSelection(
peripheralIDs: deterministicSubset(
ids: allowed.peripheralIDs,
k: subsetSize(for: allowed.peripheralIDs.count),
seed: messageID
),
centralIDs: deterministicSubset(
ids: allowed.centralIDs,
k: subsetSize(for: allowed.centralIDs.count),
seed: messageID
)
)
}
private static func allowedLinks(
peripheralIDs: [String],
centralIDs: [String],
ingressLink: BLEIngressLinkID?,
excludedLinks: Set<BLEIngressLinkID>
) -> (peripheralIDs: [String], centralIDs: [String]) {
var allowedPeripheralIDs = peripheralIDs
var allowedCentralIDs = centralIDs
var blockedLinks = excludedLinks
if let ingressLink {
blockedLinks.insert(ingressLink)
}
allowedPeripheralIDs.removeAll { blockedLinks.contains(.peripheral($0)) }
allowedCentralIDs.removeAll { blockedLinks.contains(.central($0)) }
return (allowedPeripheralIDs, allowedCentralIDs)
}
private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool {
directedPeerHint == nil
&& packetType != MessageType.fragment.rawValue
&& packetType != MessageType.announce.rawValue
&& packetType != MessageType.requestSync.rawValue
}
private static func subsetSize(for count: Int) -> Int {
guard count > 0 else { return 0 }
if count <= 2 { return count }
var value = count - 1
var bits = 0
while value > 0 {
value >>= 1
bits += 1
}
return min(count, max(1, bits + 1))
}
private static func deterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
guard k > 0 && ids.count > k else { return Set(ids) }
var scored: [(score: [UInt8], id: String)] = []
for id in ids {
let data = (seed + "::" + id).data(using: .utf8) ?? Data()
let digest = Array(SHA256.hash(data: data))
scored.append((digest, id))
}
scored.sort { lhs, rhs in
for index in 0..<min(lhs.score.count, rhs.score.count) {
if lhs.score[index] != rhs.score[index] {
return lhs.score[index] < rhs.score[index]
}
}
return lhs.id < rhs.id
}
return Set(scored.prefix(k).map(\.id))
}
}
@@ -1,71 +0,0 @@
import BitFoundation
import Foundation
struct BLEFileTransferDeliveryPlan: Equatable {
let isPrivateMessage: Bool
let shouldTrackForSync: Bool
}
enum BLEFileTransferPolicy {
static func isSelfEcho(packet: BitchatPacket, from peerID: PeerID, localPeerID: PeerID) -> Bool {
peerID == localPeerID && packet.ttl != 0
}
static func deliveryPlan(packet: BitchatPacket, localPeerID: PeerID) -> BLEFileTransferDeliveryPlan? {
guard let recipientID = packet.recipientID else {
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
}
let isBroadcast = recipientID.allSatisfy { $0 == 0xFF }
if isBroadcast {
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
}
guard PeerID(hexData: recipientID) == localPeerID else {
return nil
}
return BLEFileTransferDeliveryPlan(isPrivateMessage: true, shouldTrackForSync: false)
}
}
struct BLEIncomingFileAcceptance {
let filePacket: BitchatFilePacket
let mime: MimeType
}
enum BLEIncomingFileRejection: Error, Equatable {
case malformedPayload
case payloadTooLarge(bytes: Int)
case unsupportedMime(mimeType: String?, bytes: Int)
case magicMismatch(mime: MimeType, bytes: Int, prefixHex: String)
}
enum BLEIncomingFileValidator {
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
guard let filePacket = BitchatFilePacket.decode(payload) else {
return .failure(.malformedPayload)
}
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
}
guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else {
return .failure(.unsupportedMime(
mimeType: filePacket.mimeType,
bytes: filePacket.content.count
))
}
guard mime.matches(data: filePacket.content) else {
return .failure(.magicMismatch(
mime: mime,
bytes: filePacket.content.count,
prefixHex: filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ")
))
}
return .success(BLEIncomingFileAcceptance(filePacket: filePacket, mime: mime))
}
}
@@ -1,153 +0,0 @@
import BitFoundation
import Foundation
struct BLEFragmentKey: Hashable, Equatable {
let sender: UInt64
let id: UInt64
}
struct BLEFragmentHeader: Equatable {
let key: BLEFragmentKey
let index: Int
let total: Int
let originalType: UInt8
let fragmentData: Data
let isBroadcastFragment: Bool
var idLogString: String {
String(format: "%016llx", key.id)
}
init?(packet: BitchatPacket) {
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type.
guard packet.payload.count >= 13 else { return nil }
var senderU64: UInt64 = 0
for byte in packet.senderID.prefix(8) {
senderU64 = (senderU64 << 8) | UInt64(byte)
}
var fragmentU64: UInt64 = 0
for byte in packet.payload.prefix(8) {
fragmentU64 = (fragmentU64 << 8) | UInt64(byte)
}
let index = Int((UInt16(packet.payload[8]) << 8) | UInt16(packet.payload[9]))
let total = Int((UInt16(packet.payload[10]) << 8) | UInt16(packet.payload[11]))
guard total > 0 && total <= 10_000 && index >= 0 && index < total else {
return nil
}
let isBroadcastFragment: Bool = {
guard let recipient = packet.recipientID else { return true }
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
}()
self.key = BLEFragmentKey(sender: senderU64, id: fragmentU64)
self.index = index
self.total = total
self.originalType = packet.payload[12]
self.fragmentData = Data(packet.payload.suffix(from: 13))
self.isBroadcastFragment = isBroadcastFragment
}
}
struct BLEFragmentAssemblyBuffer {
enum AppendResult: Equatable {
case stored(header: BLEFragmentHeader, started: Bool)
case complete(header: BLEFragmentHeader, reassembledData: Data, started: Bool)
case oversized(header: BLEFragmentHeader, projectedSize: Int, limit: Int, started: Bool)
}
private struct Metadata {
let type: UInt8
let total: Int
let timestamp: Date
}
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
private var metadataByKey: [BLEFragmentKey: Metadata] = [:]
mutating func removeAll() {
fragmentsByKey.removeAll()
metadataByKey.removeAll()
}
@discardableResult
mutating func removeExpired(before cutoff: Date) -> Int {
let expiredKeys = metadataByKey
.filter { $0.value.timestamp < cutoff }
.map(\.key)
for key in expiredKeys {
fragmentsByKey.removeValue(forKey: key)
metadataByKey.removeValue(forKey: key)
}
return expiredKeys.count
}
mutating func append(
_ header: BLEFragmentHeader,
maxInFlightAssemblies: Int,
now: Date = Date()
) -> AppendResult {
let started = startAssemblyIfNeeded(for: header, maxInFlightAssemblies: maxInFlightAssemblies, now: now)
let currentSize = fragmentsByKey[header.key]?.values.reduce(0) { $0 + $1.count } ?? 0
let limit = Self.assemblyLimit(for: header.originalType)
let projectedSize = currentSize + header.fragmentData.count
guard projectedSize <= limit else {
fragmentsByKey.removeValue(forKey: header.key)
metadataByKey.removeValue(forKey: header.key)
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
}
fragmentsByKey[header.key]?[header.index] = header.fragmentData
guard let fragments = fragmentsByKey[header.key],
fragments.count == header.total else {
return .stored(header: header, started: started)
}
let reassembled = (0..<header.total).reduce(into: Data()) { data, index in
if let fragment = fragments[index] {
data.append(fragment)
}
}
fragmentsByKey.removeValue(forKey: header.key)
metadataByKey.removeValue(forKey: header.key)
return .complete(header: header, reassembledData: reassembled, started: started)
}
private mutating func startAssemblyIfNeeded(
for header: BLEFragmentHeader,
maxInFlightAssemblies: Int,
now: Date
) -> Bool {
guard fragmentsByKey[header.key] == nil else { return false }
if fragmentsByKey.count >= maxInFlightAssemblies,
let oldest = metadataByKey.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
fragmentsByKey.removeValue(forKey: oldest)
metadataByKey.removeValue(forKey: oldest)
}
fragmentsByKey[header.key] = [:]
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
return true
}
private static func assemblyLimit(for originalType: UInt8) -> Int {
if originalType == MessageType.fileTransfer.rawValue {
// Allow headroom for TLV metadata and binary framing overhead.
return FileTransferLimits.maxFramedFileBytes
}
return FileTransferLimits.maxPayloadBytes
}
}
@@ -1,70 +0,0 @@
import BitFoundation
import Foundation
struct BLEInboundWriteChunk: Equatable {
let offset: Int
let data: Data
}
struct BLEInboundWriteAppendMetadata: Equatable {
let accumulatedBytes: Int
let appendedBytes: Int
let offsets: [Int]
let packetType: UInt8?
}
struct BLEInboundWriteBuffer {
enum AppendResult {
case decoded(packet: BitchatPacket, metadata: BLEInboundWriteAppendMetadata)
case waiting(metadata: BLEInboundWriteAppendMetadata)
case oversized(metadata: BLEInboundWriteAppendMetadata)
}
private var buffersByCentralID: [String: Data] = [:]
mutating func removeAll() {
buffersByCentralID.removeAll()
}
mutating func append(
chunks: [BLEInboundWriteChunk],
for centralID: String,
capBytes: Int
) -> AppendResult {
var combined = buffersByCentralID[centralID] ?? Data()
var appendedBytes = 0
var offsets: [Int] = []
for chunk in chunks where !chunk.data.isEmpty {
offsets.append(chunk.offset)
let end = chunk.offset + chunk.data.count
if combined.count < end {
combined.append(Data(repeating: 0, count: end - combined.count))
}
combined.replaceSubrange(chunk.offset..<end, with: chunk.data)
appendedBytes += chunk.data.count
}
let metadata = BLEInboundWriteAppendMetadata(
accumulatedBytes: combined.count,
appendedBytes: appendedBytes,
offsets: offsets,
packetType: combined.count >= 2 ? combined[1] : nil
)
if let packet = BinaryProtocol.decode(combined) {
buffersByCentralID.removeValue(forKey: centralID)
return .decoded(packet: packet, metadata: metadata)
}
guard combined.count <= capBytes else {
buffersByCentralID.removeValue(forKey: centralID)
return .oversized(metadata: metadata)
}
buffersByCentralID[centralID] = combined
return .waiting(metadata: metadata)
}
}
@@ -1,168 +0,0 @@
import BitLogger
import BitFoundation
import Foundation
struct BLEIncomingFileStore {
private static let quotaBytes: Int64 = 100 * 1024 * 1024
private let fileManager: FileManager
private let baseDirectory: URL?
private let dateProvider: () -> Date
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
self.fileManager = fileManager
self.baseDirectory = baseDirectory
self.dateProvider = dateProvider
}
func save(
data: Data,
preferredName: String?,
subdirectory: String,
fallbackExtension: String?,
defaultPrefix: String
) -> URL? {
do {
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
let sanitized = sanitizedFileName(
preferredName,
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
fallbackExtension: fallbackExtension
)
let destination = uniqueFileURL(in: base, fileName: sanitized)
try data.write(to: destination, options: .atomic)
return destination
} catch {
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
return nil
}
}
func enforceQuota(reservingBytes: Int) {
do {
let base = try filesDirectory()
let incomingDirs = [
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
base.appendingPathComponent("images/incoming", isDirectory: true),
base.appendingPathComponent("files/incoming", isDirectory: true)
]
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) {
guard let contents = try? fileManager.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
) else { continue }
for fileURL in contents {
guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
let size = attrs.fileSize,
let modified = attrs.contentModificationDate else { continue }
allFiles.append((url: fileURL, size: Int64(size), modified: modified))
}
}
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
guard currentUsage > targetUsage else { return }
let needToFree = currentUsage - targetUsage
var freedSpace: Int64 = 0
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
guard freedSpace < needToFree else { break }
do {
try fileManager.removeItem(at: file.url)
freedSpace += file.size
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
} catch {
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
}
}
if freedSpace > 0 {
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
}
} catch {
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
}
}
private func filesDirectory() throws -> URL {
let root = try baseDirectory ?? fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let filesDir = root.appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
var candidate = (name ?? "")
.replacingOccurrences(of: "\0", with: "")
.precomposedStringWithCanonicalMapping
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "\\", with: "_")
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
candidate = candidate.components(separatedBy: invalid).joined(separator: "_").trimmed
if candidate.isEmpty { candidate = defaultName }
if candidate.hasPrefix(".") { candidate = "_" + candidate }
if candidate.count > 120 {
let ext = (candidate as NSString).pathExtension
let base = (candidate as NSString).deletingPathExtension
candidate = ext.isEmpty
? String(candidate.prefix(120))
: String(base.prefix(max(10, 120 - ext.count - 1))) + "." + ext
}
if let fallbackExtension, (candidate as NSString).pathExtension.isEmpty {
candidate += ".\(fallbackExtension)"
}
return candidate.isEmpty ? defaultName : candidate
}
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
let directoryPath = directory.standardizedFileURL.path
func isInsideDirectory(_ url: URL) -> Bool {
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
}
var candidate = directory.appendingPathComponent(fileName)
guard isInsideDirectory(candidate) else {
SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security)
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
}
if !fileManager.fileExists(atPath: candidate.path) {
return candidate
}
let baseName = (fileName as NSString).deletingPathExtension
let ext = (fileName as NSString).pathExtension
for counter in 1..<100 {
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
candidate = directory.appendingPathComponent(newName)
guard isInsideDirectory(candidate) else {
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
}
if !fileManager.fileExists(atPath: candidate.path) {
return candidate
}
}
return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)")
}
private static func timestampString(from date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
return formatter.string(from: date)
}
}
@@ -1,108 +0,0 @@
import BitFoundation
import Foundation
enum BLEIngressLinkID: Hashable, Equatable {
case peripheral(String)
case central(String)
}
struct BLEIngressPacketContext: Equatable {
let receivedFromPeerID: PeerID
let validationPeerID: PeerID
}
struct BLEIngressLinkRecord: Equatable {
let link: BLEIngressLinkID
let peerID: PeerID
let timestamp: Date
}
enum BLEIngressRejection: Error, Equatable {
case selfLoopback(packetType: UInt8)
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
}
struct BLEIngressLinkRegistry {
private var ingressByMessageID: [String: BLEIngressLinkRecord] = [:]
var isEmpty: Bool {
ingressByMessageID.isEmpty
}
mutating func removeAll() {
ingressByMessageID.removeAll()
}
func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? {
ingressByMessageID[Self.messageID(for: packet)]
}
func link(for packet: BitchatPacket) -> BLEIngressLinkID? {
record(for: packet)?.link
}
func peerID(for packet: BitchatPacket) -> PeerID? {
record(for: packet)?.peerID
}
mutating func recordIfNew(
_ packet: BitchatPacket,
link: BLEIngressLinkID,
peerID: PeerID,
now: Date = Date(),
lifetime: TimeInterval
) -> Bool {
let messageID = Self.messageID(for: packet)
if let existing = ingressByMessageID[messageID],
now.timeIntervalSince(existing.timestamp) <= lifetime {
return false
}
ingressByMessageID[messageID] = BLEIngressLinkRecord(link: link, peerID: peerID, timestamp: now)
return true
}
mutating func prune(before cutoff: Date) {
ingressByMessageID = ingressByMessageID.filter { $0.value.timestamp >= cutoff }
}
static func packetContext(
for packet: BitchatPacket,
claimedSenderID: PeerID,
boundPeerID: PeerID?,
localPeerID: PeerID,
directAnnounceTTL: UInt8
) -> Result<BLEIngressPacketContext, BLEIngressRejection> {
if claimedSenderID == localPeerID,
!isSelfAuthoredSyncResponse(packet) {
return .failure(.selfLoopback(packetType: packet.type))
}
if let boundPeerID,
boundPeerID != claimedSenderID,
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
}
let receivedFromPeerID = boundPeerID ?? claimedSenderID
let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID
return .success(BLEIngressPacketContext(
receivedFromPeerID: receivedFromPeerID,
validationPeerID: validationPeerID
))
}
static func messageID(for packet: BitchatPacket) -> String {
let senderID = packet.senderID.hexEncodedString()
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
}
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
}
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
packet.isRSR && packet.ttl == 0
}
}
@@ -1,76 +0,0 @@
import BitFoundation
import Foundation
enum BLEIngressPacketGuard {
enum Rejection: Error, Equatable {
case selfLoopback(packetType: UInt8)
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
case invalidRSR(peerID: PeerID)
case timestampSkew(peerID: PeerID, skewMs: UInt64, maxSkewMs: UInt64)
}
static func evaluate(
packet: BitchatPacket,
claimedSenderID: PeerID,
boundPeerID: PeerID?,
localPeerID: PeerID,
directAnnounceTTL: UInt8,
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
maxTimestampSkewMs: UInt64 = 120_000,
isValidSyncResponse: (PeerID) -> Bool
) -> Result<BLEIngressPacketContext, Rejection> {
let contextResult = BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: claimedSenderID,
boundPeerID: boundPeerID,
localPeerID: localPeerID,
directAnnounceTTL: directAnnounceTTL
)
let context: BLEIngressPacketContext
switch contextResult {
case .success(let acceptedContext):
context = acceptedContext
case .failure(.selfLoopback(let packetType)):
return .failure(.selfLoopback(packetType: packetType))
case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)):
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
}
switch validatePayload(
packet,
from: context.validationPeerID,
nowMs: nowMs,
maxTimestampSkewMs: maxTimestampSkewMs,
isValidSyncResponse: isValidSyncResponse
) {
case .success:
return .success(context)
case .failure(let rejection):
return .failure(rejection)
}
}
static func validatePayload(
_ packet: BitchatPacket,
from peerID: PeerID,
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
maxTimestampSkewMs: UInt64 = 120_000,
isValidSyncResponse: (PeerID) -> Bool
) -> Result<Void, Rejection> {
if packet.isRSR {
guard isValidSyncResponse(peerID) else {
return .failure(.invalidRSR(peerID: peerID))
}
return .success(())
}
let packetTime = packet.timestamp
let skew = packetTime > nowMs ? packetTime - nowMs : nowMs - packetTime
guard skew <= maxTimestampSkewMs else {
return .failure(.timestampSkew(peerID: peerID, skewMs: skew, maxSkewMs: maxTimestampSkewMs))
}
return .success(())
}
}
@@ -1,196 +0,0 @@
import BitFoundation
import CoreBluetooth
import Foundation
struct BLEPeripheralLinkState {
let peripheral: CBPeripheral
var characteristic: CBCharacteristic?
var peerID: PeerID?
var isConnecting: Bool
var isConnected: Bool
var lastConnectionAttempt: Date?
var assembler: NotificationStreamAssembler
}
struct BLEDirectLinkState: Equatable {
let hasPeripheral: Bool
let hasCentral: Bool
}
struct BLESubscribedCentralSnapshot {
let centrals: [CBCentral]
let peerIDsByCentralUUID: [String: PeerID]
func central(for peerID: PeerID) -> CBCentral? {
centrals.first { peerIDsByCentralUUID[$0.identifier.uuidString] == peerID }
}
}
final class BLELinkStateStore {
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
private(set) var subscribedCentrals: [CBCentral] = []
private(set) var centralToPeerID: [String: PeerID] = [:]
var peripheralStates: [BLEPeripheralLinkState] {
Array(peripherals.values)
}
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
BLESubscribedCentralSnapshot(
centrals: subscribedCentrals,
peerIDsByCentralUUID: centralToPeerID
)
}
var subscribedCentralCount: Int {
subscribedCentrals.count
}
var connectedOrConnectingPeripheralCount: Int {
peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
}
func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? {
peripherals[peripheralID]
}
func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) {
peripherals[peripheralID] = state
}
@discardableResult
func updatePeripheral(
_ peripheralID: String,
_ update: (inout BLEPeripheralLinkState) -> Void
) -> BLEPeripheralLinkState? {
guard var state = peripherals[peripheralID] else { return nil }
update(&state)
peripherals[peripheralID] = state
return state
}
func beginConnecting(to peripheral: CBPeripheral, at date: Date) {
setPeripheralState(
BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: date,
assembler: NotificationStreamAssembler()
),
for: peripheral.identifier.uuidString
)
}
func markConnected(_ peripheral: CBPeripheral) {
let peripheralID = peripheral.identifier.uuidString
if updatePeripheral(peripheralID, {
$0.isConnecting = false
$0.isConnected = true
}) == nil {
setPeripheralState(
BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: false,
isConnected: true,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
),
for: peripheralID
)
}
}
func updateCharacteristic(_ characteristic: CBCharacteristic, forPeripheralID peripheralID: String) {
updatePeripheral(peripheralID) {
$0.characteristic = characteristic
}
}
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
}
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
let peripheralUUID = peerToPeripheralUUID[peerID]
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
let hasCentral = centralToPeerID.values.contains(peerID)
return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral)
}
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
guard let peerID else { return [] }
var links: Set<BLEIngressLinkID> = []
if let peripheralUUID = peerToPeripheralUUID[peerID] {
links.insert(.peripheral(peripheralUUID))
}
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
links.insert(.central(centralUUID))
}
return links
}
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
peripherals[peripheralID]?.peerID
}
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
centralToPeerID[centralUUID]
}
func addSubscribedCentral(_ central: CBCentral) {
guard !subscribedCentrals.contains(central) else { return }
subscribedCentrals.append(central)
}
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
let centralUUID = central.identifier.uuidString
subscribedCentrals.removeAll { $0.identifier == central.identifier }
return centralToPeerID.removeValue(forKey: centralUUID)
}
func bindCentral(_ centralUUID: String, to peerID: PeerID) {
centralToPeerID[centralUUID] = peerID
}
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
peerToPeripheralUUID[peerID] = peripheralUUID
}
}
func removePeripheral(_ peripheralID: String) -> PeerID? {
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
if let peerID {
peerToPeripheralUUID.removeValue(forKey: peerID)
}
return peerID
}
func clearPeripherals() -> [PeerID] {
let peerIDs = peripherals.compactMap { $0.value.peerID }
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
return peerIDs
}
func clearCentrals() -> [PeerID] {
let peerIDs = Array(centralToPeerID.values)
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
return peerIDs
}
func clearAll() {
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
}
}
@@ -1,33 +0,0 @@
import Foundation
final class BLELogRateLimiter {
private let defaultMinimumInterval: TimeInterval
private let queue = DispatchQueue(label: "chat.bitchat.ble.log-rate-limiter")
private var lastLogTimeByKey: [String: Date] = [:]
init(defaultMinimumInterval: TimeInterval) {
self.defaultMinimumInterval = defaultMinimumInterval
}
func shouldLog(
key: String,
now: Date = Date(),
minimumInterval: TimeInterval? = nil
) -> Bool {
queue.sync {
let interval = minimumInterval ?? defaultMinimumInterval
if let lastLogTime = lastLogTimeByKey[key],
now.timeIntervalSince(lastLogTime) < interval {
return false
}
lastLogTimeByKey[key] = now
return true
}
}
func removeAll() {
queue.sync {
lastLogTimeByKey.removeAll()
}
}
}
@@ -1,62 +0,0 @@
import Foundation
struct BLEMaintenancePlan: Equatable {
let shouldSendAnnounce: Bool
let shouldEnsureAdvertising: Bool
let shouldRunCleanup: Bool
let shouldFlushDirectedSpool: Bool
let shouldResetCounter: Bool
}
enum BLEMaintenancePolicy {
static func plan(
cycle: Int,
connectedCount: Int,
peerRegistryIsEmpty: Bool,
elapsedSinceLastAnnounce: TimeInterval,
hasRecentTraffic: Bool,
connectedAnnounceJitterOffset: TimeInterval? = nil,
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
) -> BLEMaintenancePlan {
BLEMaintenancePlan(
shouldSendAnnounce: shouldSendAnnounce(
connectedCount: connectedCount,
elapsedSinceLastAnnounce: elapsedSinceLastAnnounce,
hasRecentTraffic: hasRecentTraffic,
connectedAnnounceJitterOffset: connectedAnnounceJitterOffset,
highDegreeThreshold: highDegreeThreshold
),
shouldEnsureAdvertising: peerRegistryIsEmpty,
shouldRunCleanup: cycle.isMultiple(of: 3),
shouldFlushDirectedSpool: !cycle.isMultiple(of: 2),
shouldResetCounter: cycle >= 6
)
}
static func shouldSendAnnounce(
connectedCount: Int,
elapsedSinceLastAnnounce: TimeInterval,
hasRecentTraffic: Bool,
connectedAnnounceJitterOffset: TimeInterval? = nil,
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
) -> Bool {
if hasRecentTraffic && elapsedSinceLastAnnounce >= 10.0 {
return true
}
guard connectedCount > 0 else {
return elapsedSinceLastAnnounce >= TransportConfig.bleAnnounceIntervalSeconds
}
let highDegree = connectedCount >= highDegreeThreshold
let base = highDegree ?
TransportConfig.bleConnectedAnnounceBaseSecondsDense :
TransportConfig.bleConnectedAnnounceBaseSecondsSparse
let jitter = highDegree ?
TransportConfig.bleConnectedAnnounceJitterDense :
TransportConfig.bleConnectedAnnounceJitterSparse
let jitterOffset = connectedAnnounceJitterOffset ?? Double.random(in: -jitter...jitter)
return elapsedSinceLastAnnounce >= base + jitterOffset
}
}
@@ -1,25 +0,0 @@
import Foundation
enum BLENoisePayloadFactory {
static func privateMessage(content: String, messageID: String) -> Data? {
guard let payload = PrivateMessagePacket(messageID: messageID, content: content).encode() else {
return nil
}
return typedPayload(.privateMessage, payload: payload)
}
static func readReceipt(originalMessageID: String) -> Data {
typedPayload(.readReceipt, payload: Data(originalMessageID.utf8))
}
static func delivered(messageID: String) -> Data {
typedPayload(.delivered, payload: Data(messageID.utf8))
}
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
var typed = Data([type.rawValue])
typed.append(payload)
return typed
}
}
@@ -1,46 +0,0 @@
import BitFoundation
import Foundation
struct BLEPendingPrivateMessage: Equatable {
let content: String
let messageID: String
}
struct BLENoiseSessionQueues {
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
var isEmpty: Bool {
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
}
mutating func removeAll() {
privateMessagesByPeerID.removeAll()
typedPayloadsByPeerID.removeAll()
}
mutating func appendPrivateMessage(content: String, messageID: String, for peerID: PeerID) {
privateMessagesByPeerID[peerID, default: []].append(BLEPendingPrivateMessage(content: content, messageID: messageID))
}
mutating func takePrivateMessages(for peerID: PeerID) -> [BLEPendingPrivateMessage] {
let messages = privateMessagesByPeerID[peerID] ?? []
privateMessagesByPeerID.removeValue(forKey: peerID)
return messages
}
mutating func prependPrivateMessages(_ messages: [BLEPendingPrivateMessage], for peerID: PeerID) {
guard !messages.isEmpty else { return }
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
}
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
typedPayloadsByPeerID[peerID, default: []].append(payload)
}
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
let payloads = typedPayloadsByPeerID[peerID] ?? []
typedPayloadsByPeerID.removeValue(forKey: peerID)
return payloads
}
}
@@ -1,137 +0,0 @@
import BitFoundation
import Foundation
struct BLEOutboundFragmentPlan {
let fragmentPackets: [BitchatPacket]
let fragmentVersion: UInt8
let chunkSize: Int
let spacingMs: Int
var totalFragments: Int {
fragmentPackets.count
}
var shouldPauseScanning: Bool {
totalFragments > 4
}
}
enum BLEOutboundFragmentPlanner {
private static let minimumChunkSize = 64
private static let fragmentIDLength = 8
static func makePlan(
for request: BLEOutboundFragmentTransferRequest,
defaultChunkSize: Int,
bleMaxMTU: Int,
fragmentID: Data = randomFragmentID()
) -> BLEOutboundFragmentPlan? {
guard fragmentID.count == fragmentIDLength,
let fullData = request.packet.toBinaryData(padding: request.pad) else {
return nil
}
let sizing = sizingPolicy(
for: request.packet,
requestedMaxChunk: request.maxChunk,
defaultChunkSize: defaultChunkSize,
bleMaxMTU: bleMaxMTU
)
let chunks = stride(from: 0, to: fullData.count, by: sizing.chunkSize).map { offset in
Data(fullData[offset..<min(offset + sizing.chunkSize, fullData.count)])
}
guard !chunks.isEmpty else { return nil }
let fragmentRecipient: Data? = {
if let directedPeer = request.directedPeer {
return Data(hexString: directedPeer.id)
}
return request.packet.recipientID
}()
let fragmentPackets = chunks.enumerated().map { index, chunk in
makeFragmentPacket(
original: request.packet,
fragmentID: fragmentID,
index: index,
total: chunks.count,
fragmentData: chunk,
fragmentRecipient: fragmentRecipient,
fragmentVersion: sizing.fragmentVersion
)
}
return BLEOutboundFragmentPlan(
fragmentPackets: fragmentPackets,
fragmentVersion: sizing.fragmentVersion,
chunkSize: sizing.chunkSize,
spacingMs: spacingMs(for: request)
)
}
private static func sizingPolicy(
for packet: BitchatPacket,
requestedMaxChunk: Int?,
defaultChunkSize: Int,
bleMaxMTU: Int
) -> (fragmentVersion: UInt8, chunkSize: Int) {
var fragmentVersion: UInt8 = 1
var calculatedChunk = defaultChunkSize
if let route = packet.route, !route.isEmpty {
fragmentVersion = 2
let routeSize = 1 + (route.count * 8)
let overhead = 16 + 8 + 8 + routeSize + 13 + 16
calculatedChunk = max(minimumChunkSize, bleMaxMTU - overhead)
}
return (
fragmentVersion: fragmentVersion,
chunkSize: max(minimumChunkSize, requestedMaxChunk ?? calculatedChunk)
)
}
private static func makeFragmentPacket(
original packet: BitchatPacket,
fragmentID: Data,
index: Int,
total: Int,
fragmentData: Data,
fragmentRecipient: Data?,
fragmentVersion: UInt8
) -> BitchatPacket {
var payload = Data()
payload.append(fragmentID)
payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })
payload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) })
payload.append(packet.type)
payload.append(fragmentData)
return BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: packet.senderID,
recipientID: fragmentRecipient,
timestamp: packet.timestamp,
payload: payload,
signature: nil,
ttl: packet.ttl,
version: fragmentVersion,
route: packet.route,
isRSR: packet.isRSR
)
}
private static func spacingMs(for request: BLEOutboundFragmentTransferRequest) -> Int {
if request.directedPeer != nil || request.packet.recipientID != nil {
return TransportConfig.bleFragmentSpacingDirectedMs
}
return TransportConfig.bleFragmentSpacingMs
}
private static func randomFragmentID() -> Data {
Data((0..<fragmentIDLength).map { _ in UInt8.random(in: 0...255) })
}
}
@@ -1,181 +0,0 @@
import BitFoundation
import Foundation
struct BLEOutboundFragmentTransferRequest {
let packet: BitchatPacket
let pad: Bool
let maxChunk: Int?
let directedPeer: PeerID?
let transferId: String?
var resolvedTransferId: String? {
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
return transferId ?? packet.payload.sha256Hex()
}
}
struct BLEOutboundFragmentTransferScheduler {
enum QueuePosition {
case front
case back
}
enum SubmitResult {
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
}
enum CancelResult {
case active(transferId: String, workItems: [DispatchWorkItem])
case pending(transferId: String)
case missing
}
enum SentResult: Equatable {
case progress(sentFragments: Int, totalFragments: Int)
case complete(sentFragments: Int, totalFragments: Int)
case missing
}
private struct ActiveTransferState {
let totalFragments: Int
var sentFragments: Int
var workItems: [DispatchWorkItem]
}
private var activeTransfers: [String: ActiveTransferState] = [:]
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = []
var activeCount: Int {
activeTransfers.count
}
var pendingCount: Int {
pendingTransfers.count
}
mutating func removeAll() -> [(id: String, workItems: [DispatchWorkItem])] {
let active = activeTransfers.map { ($0.key, $0.value.workItems) }
activeTransfers.removeAll()
pendingTransfers.removeAll()
return active
}
mutating func submit(
_ request: BLEOutboundFragmentTransferRequest,
maxConcurrentTransfers: Int
) -> SubmitResult {
guard let transferId = request.resolvedTransferId else {
return .start(request: request, reservedTransferId: nil)
}
guard activeTransfers.count < maxConcurrentTransfers else {
pendingTransfers.append(request)
return .queued(request: request, transferId: transferId, position: .back)
}
guard activeTransfers[transferId] == nil else {
pendingTransfers.insert(request, at: 0)
return .queued(request: request, transferId: transferId, position: .front)
}
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
return .start(request: request, reservedTransferId: transferId)
}
mutating func activateReservedTransfer(
id transferId: String,
totalFragments: Int,
workItems: [DispatchWorkItem]
) -> Bool {
guard activeTransfers[transferId] != nil else { return false }
activeTransfers[transferId] = ActiveTransferState(
totalFragments: totalFragments,
sentFragments: 0,
workItems: workItems
)
return true
}
mutating func updateWorkItems(_ workItems: [DispatchWorkItem], for transferId: String) -> Bool {
guard var state = activeTransfers[transferId] else { return false }
state.workItems = workItems
activeTransfers[transferId] = state
return true
}
mutating func releaseReservation(_ transferId: String) -> [DispatchWorkItem]? {
activeTransfers.removeValue(forKey: transferId)?.workItems
}
func isActive(_ transferId: String) -> Bool {
activeTransfers[transferId] != nil
}
mutating func cancelTransfer(_ transferId: String) -> CancelResult {
if let active = activeTransfers.removeValue(forKey: transferId) {
return .active(transferId: transferId, workItems: active.workItems)
}
if let pendingIndex = pendingTransfers.firstIndex(where: { $0.resolvedTransferId == transferId || $0.transferId == transferId }) {
pendingTransfers.remove(at: pendingIndex)
return .pending(transferId: transferId)
}
return .missing
}
mutating func markFragmentSent(transferId: String) -> SentResult {
guard var state = activeTransfers[transferId] else { return .missing }
state.sentFragments = min(state.sentFragments + 1, state.totalFragments)
let isComplete = state.sentFragments >= state.totalFragments
if isComplete {
activeTransfers.removeValue(forKey: transferId)
return .complete(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
}
activeTransfers[transferId] = state
return .progress(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
}
mutating func reservePendingStarts(maxConcurrentTransfers: Int) -> [SubmitResult] {
var availableSlots = max(0, maxConcurrentTransfers - activeTransfers.count)
guard availableSlots > 0, !pendingTransfers.isEmpty else { return [] }
var results: [SubmitResult] = []
var blockedFront: [BLEOutboundFragmentTransferRequest] = []
while availableSlots > 0, !pendingTransfers.isEmpty {
let request = pendingTransfers.removeFirst()
availableSlots -= 1
guard let transferId = request.resolvedTransferId else {
results.append(.start(request: request, reservedTransferId: nil))
continue
}
guard activeTransfers.count < maxConcurrentTransfers else {
pendingTransfers.insert(request, at: 0)
results.append(.queued(request: request, transferId: transferId, position: .front))
break
}
guard activeTransfers[transferId] == nil else {
blockedFront.append(request)
results.append(.queued(request: request, transferId: transferId, position: .front))
continue
}
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
results.append(.start(request: request, reservedTransferId: transferId))
}
if !blockedFront.isEmpty {
pendingTransfers.insert(contentsOf: blockedFront, at: 0)
}
return results
}
}
@@ -1,87 +0,0 @@
import BitFoundation
import Foundation
struct BLEOutboundLinkPlan: Equatable {
let directedPeerHint: PeerID?
let fragmentChunkSize: Int?
let selectedLinks: BLEFanoutSelection
let shouldSpoolDirectedPacket: Bool
}
enum BLEOutboundLinkPlanner {
static func plan(
packet: BitchatPacket,
dataCount: Int,
peripheralIDs: [String],
peripheralWriteLimits: [Int],
centralIDs: [String],
centralNotifyLimits: [Int],
ingressRecord: BLEIngressLinkRecord?,
excludedLinks: Set<BLEIngressLinkID>,
directedOnlyPeer: PeerID?
) -> BLEOutboundLinkPlan {
if let minLimit = minimumLinkLimit(
peripheralWriteLimits: peripheralWriteLimits,
centralNotifyLimits: centralNotifyLimits
), packet.type != MessageType.fragment.rawValue,
dataCount > minLimit {
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer),
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []),
shouldSpoolDirectedPacket: false
)
}
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
let selectedLinks = BLEFanoutSelector.selectLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressRecord?.link,
excludedLinks: excludedLinks,
directedPeerHint: directedPeerHint,
packetType: packet.type,
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
)
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint,
fragmentChunkSize: nil,
selectedLinks: selectedLinks,
shouldSpoolDirectedPacket: shouldSpoolDirectedPacket(
directedPeerHint: directedPeerHint,
selectedLinks: selectedLinks,
packetType: packet.type
)
)
}
static func directedPeerHint(for packet: BitchatPacket, explicitPeer: PeerID?) -> PeerID? {
if let explicitPeer { return explicitPeer }
if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty {
return recipient
}
return nil
}
static func minimumLinkLimit(peripheralWriteLimits: [Int], centralNotifyLimits: [Int]) -> Int? {
[peripheralWriteLimits.min(), centralNotifyLimits.min()]
.compactMap { $0 }
.min()
}
static func shouldSpoolDirectedPacket(
directedPeerHint: PeerID?,
selectedLinks: BLEFanoutSelection,
packetType: UInt8
) -> Bool {
guard directedPeerHint != nil,
selectedLinks.peripheralIDs.isEmpty,
selectedLinks.centralIDs.isEmpty else {
return false
}
return packetType == MessageType.noiseEncrypted.rawValue ||
packetType == MessageType.noiseHandshake.rawValue
}
}
@@ -1,47 +0,0 @@
import Foundation
struct BLEPendingNotification<Target> {
let data: Data
let targets: [Target]?
}
struct BLEOutboundNotificationBuffer<Target> {
enum EnqueueResult {
case enqueued(count: Int)
case full(count: Int)
}
private var notifications: [BLEPendingNotification<Target>] = []
var count: Int {
notifications.count
}
var isEmpty: Bool {
notifications.isEmpty
}
mutating func removeAll() {
notifications.removeAll()
}
mutating func enqueue(data: Data, targets: [Target]?, capCount: Int) -> EnqueueResult {
guard notifications.count < capCount else {
return .full(count: notifications.count)
}
notifications.append(BLEPendingNotification(data: data, targets: targets))
return .enqueued(count: notifications.count)
}
mutating func takeAll() -> [BLEPendingNotification<Target>] {
let pending = notifications
notifications.removeAll()
return pending
}
mutating func prepend(_ pending: [BLEPendingNotification<Target>]) {
guard !pending.isEmpty else { return }
notifications.insert(contentsOf: pending, at: 0)
}
}
@@ -1,43 +0,0 @@
import BitFoundation
import Foundation
enum BLEOutboundPacketPolicy {
private static let fragmentFrameOverhead = 13 + 8 + 8 + 13
static func messageID(for packet: BitchatPacket) -> String {
BLEIngressLinkRegistry.messageID(for: packet)
}
static func padsBLEFrame(for packetType: UInt8) -> Bool {
switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake:
return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
return false
}
}
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
switch messageType {
case .fragment:
return .fragment(totalFragments: fragmentTotalCount(from: packet.payload))
case .fileTransfer:
return .fileTransfer
default:
return .high
}
}
static func fragmentChunkSize(forLinkLimit limit: Int) -> Int {
max(64, limit - fragmentFrameOverhead)
}
private static func fragmentTotalCount(from payload: Data) -> Int {
guard payload.count >= 12 else { return Int(UInt16.max) }
let totalHigh = Int(payload[10])
let totalLow = Int(payload[11])
let total = (totalHigh << 8) | totalLow
return max(total, 1)
}
}
@@ -1,83 +0,0 @@
import Foundation
struct BLEOutboundWritePriority: Comparable {
let level: Int
let suborder: Int
static let high = BLEOutboundWritePriority(level: 0, suborder: 0)
static func fragment(totalFragments: Int) -> BLEOutboundWritePriority {
BLEOutboundWritePriority(level: 1, suborder: max(1, min(totalFragments, Int(UInt16.max))))
}
static let fileTransfer = BLEOutboundWritePriority(level: 2, suborder: Int.max - 1)
static let low = BLEOutboundWritePriority(level: 2, suborder: Int.max)
static func < (lhs: BLEOutboundWritePriority, rhs: BLEOutboundWritePriority) -> Bool {
if lhs.level != rhs.level { return lhs.level < rhs.level }
return lhs.suborder < rhs.suborder
}
}
struct BLEPendingWrite {
let priority: BLEOutboundWritePriority
let data: Data
}
struct BLEOutboundWriteBuffer {
enum EnqueueResult {
case enqueued(trimmedBytes: Int, remainingBytes: Int)
case oversized(bytes: Int)
}
private var writesByPeripheralID: [String: [BLEPendingWrite]] = [:]
var peripheralIDs: [String] {
Array(writesByPeripheralID.keys)
}
mutating func removeAll() {
writesByPeripheralID.removeAll()
}
mutating func enqueue(
data: Data,
for peripheralID: String,
priority: BLEOutboundWritePriority,
capBytes: Int
) -> EnqueueResult {
guard data.count <= capBytes else {
return .oversized(bytes: data.count)
}
var queue = writesByPeripheralID[peripheralID] ?? []
let item = BLEPendingWrite(priority: priority, data: data)
let insertIndex = queue.firstIndex { item.priority < $0.priority } ?? queue.count
queue.insert(item, at: insertIndex)
var total = queue.reduce(0) { $0 + $1.data.count }
var trimmedBytes = 0
while total > capBytes && !queue.isEmpty {
let removed = queue.removeLast()
trimmedBytes += removed.data.count
total -= removed.data.count
}
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
return .enqueued(trimmedBytes: trimmedBytes, remainingBytes: total)
}
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
let items = writesByPeripheralID[peripheralID] ?? []
writesByPeripheralID[peripheralID] = nil
return items
}
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
guard !items.isEmpty else { return }
var existing = writesByPeripheralID[peripheralID] ?? []
existing.insert(contentsOf: items, at: 0)
writesByPeripheralID[peripheralID] = existing
}
}
@@ -1,27 +0,0 @@
import Foundation
enum BLEPacketFreshnessPolicy {
static let defaultMaxAgeSeconds: TimeInterval = 900
static func isBroadcastRecipient(_ recipientID: Data?) -> Bool {
guard let recipientID else { return true }
return recipientID.count == 8 && recipientID.allSatisfy { $0 == 0xFF }
}
static func isStale(
timestampMilliseconds: UInt64,
now: Date,
maxAgeSeconds: TimeInterval = defaultMaxAgeSeconds
) -> Bool {
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
let maxAgeMilliseconds = UInt64(maxAgeSeconds * 1000)
guard nowMilliseconds >= maxAgeMilliseconds else { return false }
return timestampMilliseconds < nowMilliseconds - maxAgeMilliseconds
}
static func ageSeconds(timestampMilliseconds: UInt64, now: Date) -> Double {
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
guard nowMilliseconds >= timestampMilliseconds else { return 0 }
return Double(nowMilliseconds - timestampMilliseconds) / 1000.0
}
}
@@ -1,25 +0,0 @@
import BitFoundation
import Foundation
struct BLEPeerEventDebouncer {
private var lastEmitByPeer: [PeerID: Date] = [:]
var count: Int {
lastEmitByPeer.count
}
@discardableResult
mutating func shouldEmit(peerID: PeerID, now: Date, minimumInterval: TimeInterval) -> Bool {
if let lastEmit = lastEmitByPeer[peerID],
now.timeIntervalSince(lastEmit) < minimumInterval {
return false
}
lastEmitByPeer[peerID] = now
return true
}
mutating func removeAll() {
lastEmitByPeer.removeAll()
}
}
@@ -1,43 +0,0 @@
import Foundation
enum BLEPeerPublishDecision: Equatable {
case publishNow
case schedule(delay: TimeInterval)
case skip
}
struct BLEPeerPublishCoalescer {
private var lastPublishAt: Date
private var publishPending: Bool
private let minimumInterval: TimeInterval
init(
lastPublishAt: Date = .distantPast,
publishPending: Bool = false,
minimumInterval: TimeInterval = 0.1
) {
self.lastPublishAt = lastPublishAt
self.publishPending = publishPending
self.minimumInterval = minimumInterval
}
mutating func requestPublish(now: Date) -> BLEPeerPublishDecision {
let elapsed = now.timeIntervalSince(lastPublishAt)
if elapsed >= minimumInterval {
lastPublishAt = now
return .publishNow
}
guard !publishPending else {
return .skip
}
publishPending = true
return .schedule(delay: minimumInterval - elapsed)
}
mutating func scheduledPublishFired(now: Date) {
lastPublishAt = now
publishPending = false
}
}
-211
View File
@@ -1,211 +0,0 @@
import BitFoundation
import Foundation
struct BLEPeerInfo: Equatable {
let peerID: PeerID
var nickname: String
var isConnected: Bool
var noisePublicKey: Data?
var signingPublicKey: Data?
var isVerifiedNickname: Bool
var lastSeen: Date
}
struct BLEPeerAnnounceUpdate: Equatable {
let isNewPeer: Bool
let wasDisconnected: Bool
let previousNickname: String?
}
struct BLEPeerLinkPresence: Equatable {
var hasPeripheral: Bool
var hasCentral: Bool
}
struct BLERemovedPeer: Equatable {
let peerID: PeerID
let nickname: String
}
struct BLEPeerConnectivityChanges: Equatable {
var disconnectedPeerIDs: [PeerID] = []
var removedPeers: [BLERemovedPeer] = []
}
struct BLEPeerRegistry {
private var peers: [PeerID: BLEPeerInfo] = [:]
var isEmpty: Bool {
peers.isEmpty
}
var count: Int {
peers.count
}
var peerIDs: [PeerID] {
Array(peers.keys)
}
var connectedCount: Int {
peers.values.filter(\.isConnected).count
}
var connectedPeerIDs: [PeerID] {
peers.values.compactMap { $0.isConnected ? $0.peerID : nil }
}
var connectedRoutingData: [Data] {
peers.values.filter(\.isConnected).compactMap { $0.peerID.routingData }
}
var snapshotByID: [PeerID: BLEPeerInfo] {
peers
}
mutating func removeAll() {
peers.removeAll()
}
func info(for peerID: PeerID) -> BLEPeerInfo? {
peers[peerID]
}
mutating func upsert(_ info: BLEPeerInfo) {
peers[info.peerID] = info
}
@discardableResult
mutating func remove(_ peerID: PeerID) -> BLEPeerInfo? {
peers.removeValue(forKey: peerID)
}
func isConnected(_ peerID: PeerID) -> Bool {
peers[peerID.toShort()]?.isConnected ?? false
}
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
let shortID = peerID.toShort()
let meshAttached = connectedCount > 0
guard let info = peers[shortID] else { return false }
if info.isConnected { return true }
guard meshAttached else { return false }
let retention: TimeInterval = info.isVerifiedNickname
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
return now.timeIntervalSince(info.lastSeen) <= retention
}
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
guard let peer = peers[peerID] else { return nil }
if connectedOnly && !peer.isConnected { return nil }
return peer.nickname
}
func fingerprint(for peerID: PeerID) -> String? {
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
}
func displayNicknames(selfNickname: String) -> [PeerID: String] {
let connected = peers.filter { $0.value.isConnected }
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
return PeerDisplayNameResolver.resolve(tuples, selfNickname: selfNickname)
}
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
let snapshot = Array(peers.values)
let resolvedNames = PeerDisplayNameResolver.resolve(
snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) },
selfNickname: selfNickname
)
return snapshot.map { info in
TransportPeerSnapshot(
peerID: info.peerID,
nickname: resolvedNames[info.peerID] ?? info.nickname,
isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen
)
}
}
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
let hasCollision = peers.values.contains {
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
} || selfNickname == info.nickname
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
}
mutating func markDisconnected(_ peerID: PeerID) {
guard var info = peers[peerID] else { return }
info.isConnected = false
peers[peerID] = info
}
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
guard var peer = peers[peerID] else { return }
peer.lastSeen = date
peers[peerID] = peer
}
mutating func upsertVerifiedAnnounce(
peerID: PeerID,
nickname: String,
noisePublicKey: Data,
signingPublicKey: Data?,
isConnected: Bool,
now: Date
) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID]
let update = BLEPeerAnnounceUpdate(
isNewPeer: existing == nil,
wasDisconnected: existing?.isConnected == false,
previousNickname: existing?.nickname
)
peers[peerID] = BLEPeerInfo(
peerID: existing?.peerID ?? peerID,
nickname: nickname,
isConnected: isConnected,
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: now
)
return update
}
mutating func reconcileConnectivity(
now: Date,
linkStates: [PeerID: BLEPeerLinkPresence]
) -> BLEPeerConnectivityChanges {
var changes = BLEPeerConnectivityChanges()
for (peerID, peer) in Array(peers) {
let age = now.timeIntervalSince(peer.lastSeen)
let retention: TimeInterval = peer.isVerifiedNickname
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
let state = linkStates[peerID] ?? BLEPeerLinkPresence(hasPeripheral: false, hasCentral: false)
if !state.hasPeripheral && !state.hasCentral {
var updated = peer
updated.isConnected = false
peers[peerID] = updated
changes.disconnectedPeerIDs.append(peerID)
}
}
if !peer.isConnected && age > retention {
peers.removeValue(forKey: peerID)
changes.removedPeers.append(BLERemovedPeer(peerID: peerID, nickname: peer.nickname))
}
}
return changes
}
}
@@ -1,60 +0,0 @@
import BitFoundation
import Foundation
enum BLEPeerSenderDisplayName {
static func resolveKnownPeer(
peerID: PeerID,
localPeerID: PeerID,
localNickname: String,
peers: [PeerID: BLEPeerInfo],
allowConnectedUnverified: Bool
) -> String? {
if peerID == localPeerID {
return localNickname
}
guard let info = peers[peerID] else { return nil }
if info.isVerifiedNickname {
return collisionResolvedName(
displayName: info.nickname,
collisionNickname: info.nickname,
peerID: peerID,
localNickname: localNickname,
peers: peers
)
}
if allowConnectedUnverified, info.isConnected {
let displayName = info.nickname.isEmpty ? anonymousNickname(for: peerID) : info.nickname
return collisionResolvedName(
displayName: displayName,
collisionNickname: info.nickname,
peerID: peerID,
localNickname: localNickname,
peers: peers
)
}
return nil
}
static func anonymousNickname(for peerID: PeerID) -> String {
"anon" + String(peerID.id.prefix(4))
}
private static func collisionResolvedName(
displayName: String,
collisionNickname: String,
peerID: PeerID,
localNickname: String,
peers: [PeerID: BLEPeerInfo]
) -> String {
let hasCollision = peers.values.contains {
$0.isConnected && $0.nickname == collisionNickname && $0.peerID != peerID
} || localNickname == collisionNickname
guard hasCollision else { return displayName }
return displayName + "#" + String(peerID.id.prefix(4))
}
}
@@ -1,42 +0,0 @@
import BitFoundation
import Foundation
struct BLEPublicMessageAcceptance: Equatable {
let shouldTrackForSync: Bool
}
enum BLEPublicMessageRejection: Equatable {
case selfEcho
case staleBroadcast(ageSeconds: Double)
}
enum BLEPublicMessageDecision: Equatable {
case accept(BLEPublicMessageAcceptance)
case reject(BLEPublicMessageRejection)
}
enum BLEPublicMessagePolicy {
static func evaluate(
packet: BitchatPacket,
from peerID: PeerID,
localPeerID: PeerID,
now: Date
) -> BLEPublicMessageDecision {
if peerID == localPeerID && packet.ttl != 0 {
return .reject(.selfEcho)
}
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
if isBroadcast,
BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) {
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
timestampMilliseconds: packet.timestamp,
now: now
)))
}
return .accept(BLEPublicMessageAcceptance(
shouldTrackForSync: isBroadcast && packet.type == MessageType.message.rawValue
))
}
}
@@ -1,84 +0,0 @@
import BitFoundation
import Foundation
struct BLEReceivedPacketContext: Equatable {
let senderID: PeerID
let messageID: String
let messageType: MessageType?
let shouldDeduplicate: Bool
let logsHandlingDetails: Bool
}
struct BLEReceivePipeline {
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
let senderID = PeerID(hexData: packet.senderID)
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
let messageType = MessageType(rawValue: packet.type)
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
return BLEReceivedPacketContext(
senderID: senderID,
messageID: messageID,
messageType: messageType,
shouldDeduplicate: shouldDeduplicate,
logsHandlingDetails: messageType != .announce
)
}
static func shouldCancelScheduledRelayForDuplicate(connectedPeerCount: Int) -> Bool {
connectedPeerCount > 2
}
static func relayDecision(
for packet: BitchatPacket,
senderID: PeerID,
localPeerID: PeerID,
degree: Int,
highDegreeThreshold: Int
) -> RelayDecision {
RelayController.decide(
ttl: packet.ttl,
senderIsSelf: senderID == localPeerID,
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
isFragment: packet.type == MessageType.fragment.rawValue,
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
isAnnounce: packet.type == MessageType.announce.rawValue,
degree: degree,
highDegreeThreshold: highDegreeThreshold
)
}
}
struct BLERecentTrafficTracker: Equatable {
private var packetTimestamps: [Date] = []
var count: Int {
packetTimestamps.count
}
mutating func removeAll() {
packetTimestamps.removeAll()
}
mutating func recordPacket(at now: Date) {
packetTimestamps.append(now)
prune(at: now)
}
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
let cutoff = now.addingTimeInterval(-seconds)
return packetTimestamps.contains { $0 >= cutoff }
}
private mutating func prune(at now: Date) {
let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)
if packetTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {
packetTimestamps.removeFirst(packetTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)
}
packetTimestamps.removeAll { $0 < cutoff }
}
}
@@ -1,93 +0,0 @@
import BitFoundation
import Foundation
struct BLERouteForwardingPlan {
let shouldSuppressFloodRelay: Bool
let forwardPacket: BitchatPacket?
let nextHop: PeerID?
static let allowFloodRelay = BLERouteForwardingPlan(
shouldSuppressFloodRelay: false,
forwardPacket: nil,
nextHop: nil
)
static let suppressFloodRelay = BLERouteForwardingPlan(
shouldSuppressFloodRelay: true,
forwardPacket: nil,
nextHop: nil
)
static func forward(_ packet: BitchatPacket, to nextHop: PeerID) -> BLERouteForwardingPlan {
BLERouteForwardingPlan(
shouldSuppressFloodRelay: true,
forwardPacket: packet,
nextHop: nextHop
)
}
}
struct BLERouteForwardingPolicy {
static func plan(
for packet: BitchatPacket,
localPeerID: PeerID,
localRoutingData: Data?,
routingPeer: (Data) -> PeerID?,
isPeerConnected: (PeerID) -> Bool
) -> BLERouteForwardingPlan {
if PeerID(hexData: packet.recipientID) == localPeerID {
return .suppressFloodRelay
}
guard let route = packet.route, !route.isEmpty else {
return .allowFloodRelay
}
guard packet.ttl > 1 else {
return .suppressFloodRelay
}
guard let localRoutingData else {
return .allowFloodRelay
}
guard let localIndex = route.firstIndex(of: localRoutingData) else {
return forward(packet, toRouteData: route[0], routingPeer: routingPeer, isPeerConnected: isPeerConnected)
}
if localIndex == route.count - 1 {
guard let destinationPeer = PeerID(hexData: packet.recipientID),
isPeerConnected(destinationPeer) else {
return .allowFloodRelay
}
return .forward(relayed(packet), to: destinationPeer)
}
return forward(
packet,
toRouteData: route[localIndex + 1],
routingPeer: routingPeer,
isPeerConnected: isPeerConnected
)
}
private static func forward(
_ packet: BitchatPacket,
toRouteData routeData: Data,
routingPeer: (Data) -> PeerID?,
isPeerConnected: (PeerID) -> Bool
) -> BLERouteForwardingPlan {
guard let nextPeer = routingPeer(routeData),
isPeerConnected(nextPeer) else {
return .allowFloodRelay
}
return .forward(relayed(packet), to: nextPeer)
}
private static func relayed(_ packet: BitchatPacket) -> BitchatPacket {
var relayPacket = packet
relayPacket.ttl = packet.ttl - 1
return relayPacket
}
}
@@ -1,35 +0,0 @@
import Foundation
enum BLEScanDutyPlan: Equatable {
case continuous
case dutyCycle(onDuration: TimeInterval, offDuration: TimeInterval)
}
enum BLEScanDutyPolicy {
static func plan(
dutyEnabled: Bool,
appIsActive: Bool,
connectedCount: Int,
hasRecentTraffic: Bool,
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
) -> BLEScanDutyPlan {
let forceContinuousScan = connectedCount <= 2 || hasRecentTraffic
let shouldDutyCycle = dutyEnabled && appIsActive && connectedCount > 0 && !forceContinuousScan
guard shouldDutyCycle else {
return .continuous
}
if connectedCount >= highDegreeThreshold {
return .dutyCycle(
onDuration: TransportConfig.bleDutyOnDurationDense,
offDuration: TransportConfig.bleDutyOffDurationDense
)
}
return .dutyCycle(
onDuration: TransportConfig.bleDutyOnDuration,
offDuration: TransportConfig.bleDutyOffDuration
)
}
}
@@ -1,43 +0,0 @@
import Foundation
struct BLEScheduledRelayStore {
private var relays: [String: DispatchWorkItem] = [:]
var count: Int {
relays.count
}
var isEmpty: Bool {
relays.isEmpty
}
mutating func schedule(_ workItem: DispatchWorkItem, messageID: String) {
relays[messageID] = workItem
}
@discardableResult
mutating func remove(messageID: String) -> DispatchWorkItem? {
relays.removeValue(forKey: messageID)
}
@discardableResult
mutating func cancel(messageID: String) -> Bool {
guard let workItem = relays.removeValue(forKey: messageID) else {
return false
}
workItem.cancel()
return true
}
mutating func cancelAll() {
relays.values.forEach { $0.cancel() }
relays.removeAll()
}
mutating func removeAllIfOverCapacity(_ maxCount: Int) {
if relays.count > maxCount {
relays.removeAll()
}
}
}
@@ -1,40 +0,0 @@
import BitFoundation
import Foundation
struct BLESelfBroadcastTracker {
private struct Entry {
let messageID: String
let sentAt: Date
}
private var entriesByDedupID: [String: Entry] = [:]
var isEmpty: Bool {
entriesByDedupID.isEmpty
}
var count: Int {
entriesByDedupID.count
}
mutating func record(messageID: String, packet: BitchatPacket, sentAt: Date) {
entriesByDedupID[Self.dedupID(for: packet)] = Entry(messageID: messageID, sentAt: sentAt)
}
mutating func takeMessageID(for packet: BitchatPacket) -> String? {
entriesByDedupID.removeValue(forKey: Self.dedupID(for: packet))?.messageID
}
mutating func prune(before cutoff: Date) {
guard !entriesByDedupID.isEmpty else { return }
entriesByDedupID = entriesByDedupID.filter { cutoff <= $0.value.sentAt }
}
mutating func removeAll() {
entriesByDedupID.removeAll()
}
static func dedupID(for packet: BitchatPacket) -> String {
"\(packet.senderID.hexEncodedString())-\(packet.timestamp)-\(packet.type)"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,71 +0,0 @@
import Foundation
enum BLESubscriptionAnnounceDecision: Equatable {
case allowed
case rateLimited(backoffSeconds: TimeInterval, attemptCount: Int, suppressAnnounce: Bool)
}
struct BLESubscriptionAnnounceLimiter {
private struct State {
var lastAnnounceTime: Date
var attemptCount: Int
var currentBackoffSeconds: TimeInterval
}
private var states: [String: State] = [:]
var trackedCentralCount: Int {
states.count
}
mutating func removeAll() {
states.removeAll()
}
mutating func decision(for centralID: String, now: Date) -> BLESubscriptionAnnounceDecision {
pruneStaleEntries(now: now)
guard let existing = states[centralID] else {
recordAllowedAttempt(for: centralID, now: now)
return .allowed
}
let timeSinceLastAnnounce = now.timeIntervalSince(existing.lastAnnounceTime)
guard timeSinceLastAnnounce < existing.currentBackoffSeconds else {
recordAllowedAttempt(for: centralID, now: now)
return .allowed
}
let newAttemptCount = existing.attemptCount + 1
let newBackoff = min(
existing.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor,
TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds
)
states[centralID] = State(
lastAnnounceTime: now,
attemptCount: newAttemptCount,
currentBackoffSeconds: newBackoff
)
return .rateLimited(
backoffSeconds: existing.currentBackoffSeconds,
attemptCount: existing.attemptCount,
suppressAnnounce: newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts
)
}
private mutating func recordAllowedAttempt(for centralID: String, now: Date) {
states[centralID] = State(
lastAnnounceTime: now,
attemptCount: 1,
currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds
)
}
private mutating func pruneStaleEntries(now: Date) {
let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds
states = states.filter { _, state in
now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds
}
}
}
-212
View File
@@ -1,212 +0,0 @@
//
// MimeType.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import UniformTypeIdentifiers
// MARK: - Extensions for missing UTTypes
extension UTType {
static let webP = UTType(importedAs: "image/webp")
static let aac = UTType(importedAs: "audio/aac")
static let m4a = UTType(importedAs: "audio/m4a")
static let ogg = UTType(importedAs: "audio/ogg")
}
// MARK: - MimeType Enum
enum MimeType: CaseIterable, Hashable {
case jpeg
case jpg
case png
case gif
case webp
case mp4Audio
case m4a
case aac
case mpeg
case mp3
case wav
case xWav
case ogg
case pdf
case octetStream
var utType: UTType {
switch self {
case .jpeg, .jpg: .jpeg
case .png: .png
case .gif: .gif
case .webp: .webP
case .aac: .aac
case .m4a: .m4a
case .mp4Audio: .mpeg4Audio
case .mp3, .mpeg: .mp3
case .wav, .xWav: .wav
case .ogg: .ogg
case .pdf: .pdf
case .octetStream: .data
}
}
var category: Category {
switch self {
case .jpeg, .jpg, .png, .gif, .webp:
return .image
case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:
return .audio
case .pdf, .octetStream:
return .file
}
}
var mimeString: String {
switch self {
case .jpeg, .jpg: "image/jpeg"
case .png: "image/png"
case .gif: "image/gif"
case .webp: "image/webp"
case .mp4Audio: "audio/mp4"
case .m4a: "audio/m4a"
case .aac: "audio/aac"
case .mpeg: "audio/mpeg"
case .mp3: "audio/mp3"
case .wav: "audio/wav"
case .xWav: "audio/x-wav"
case .ogg: "audio/ogg"
case .pdf: "application/pdf"
case .octetStream: "application/octet-stream"
}
}
var defaultExtension: String {
switch self {
case .jpeg, .jpg: "jpg"
case .png: "png"
case .webp: "webp"
case .gif: "gif"
case .mp4Audio, .m4a, .aac: "m4a"
case .mpeg, .mp3: "mp3"
case .wav, .xWav: "wav"
case .ogg: "ogg"
case .pdf: "pdf"
case .octetStream: "bin"
}
}
static var allowed: Set<MimeType> = [
.jpeg, .jpg, .png, .gif, .webp,
.mp4Audio, .m4a, .aac, .mpeg, .mp3,
.wav, .xWav, .ogg,
.pdf, .octetStream
]
var isAllowed: Bool {
Self.allowed.contains(self)
}
// MARK: - Byte signature validation
func matches(data: Data) -> Bool {
guard !data.isEmpty else { return false }
// Generic type skip validation
if self == .octetStream { return true }
switch self {
case .jpeg, .jpg:
return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
case .png:
return data.count >= 8 &&
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A
case .gif:
return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61
case .webp:
return data.count >= 12 &&
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50
case .m4a, .mp4Audio, .aac:
// AVAudioRecorder output varies by platform - be lenient
// Security: size already capped + sandboxed execution
return data.count > 100
case .mpeg, .mp3:
if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
return true // ID3 header
}
return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0
case .wav, .xWav:
return data.count >= 12 &&
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45
case .ogg:
return data.count >= 4 &&
data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53
case .pdf:
return data.count >= 4 &&
data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46
default:
return false
}
}
// MARK: - Convenience Initializers
init?(_ mimeString: String?) {
guard let mimeString else { return nil }
let normalized = mimeString.lowercased()
// Direct match with our canonical list
if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {
self = match
return
}
// Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc.
if let type = UTType(mimeType: normalized),
let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {
self = match
return
}
return nil
}
}
extension MimeType {
enum Category: String {
case audio, image, file
/// Ends with a space
var messagePrefix: String {
switch self {
case .audio: "[voice] "
case .image: "[image] "
case .file: "[file] "
}
}
var mediaDir: String {
switch self {
case .audio: "voicenotes"
case .image: "images"
case .file: "files"
}
}
}
}
File diff suppressed because it is too large Load Diff
+59 -80
View File
@@ -7,7 +7,6 @@
//
import Foundation
import BitFoundation
/// Result of command processing
enum CommandResult {
@@ -16,53 +15,15 @@ enum CommandResult {
case handled // Command handled, no message needed
}
/// Simple struct for geo participant info used by CommandProcessor
struct CommandGeoParticipant {
let id: String // pubkey hex (lowercased)
let displayName: String
}
/// Protocol defining what CommandProcessor needs from its context.
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
@MainActor
protocol CommandContextProvider: AnyObject {
// MARK: - State Properties
var nickname: String { get }
var activeChannel: ChannelID { get }
var selectedPrivateChatPeer: PeerID? { get }
var blockedUsers: Set<String> { get }
var privateChats: [PeerID: [BitchatMessage]] { get set }
var idBridge: NostrIdentityBridge { get }
// MARK: - Peer Lookup
func getPeerIDForNickname(_ nickname: String) -> PeerID?
func getVisibleGeoParticipants() -> [CommandGeoParticipant]
func nostrPubkeyForDisplayName(_ displayName: String) -> String?
// MARK: - Chat Actions
func startPrivateChat(with peerID: PeerID)
func sendPrivateMessage(_ content: String, to peerID: PeerID)
func clearCurrentPublicTimeline()
func sendPublicRaw(_ content: String)
// MARK: - System Messages
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
func addPublicSystemMessage(_ content: String)
// MARK: - Favorites
func toggleFavorite(peerID: PeerID)
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
}
/// Processes chat commands in a focused, efficient way
@MainActor
final class CommandProcessor {
weak var contextProvider: CommandContextProvider?
weak var chatViewModel: ChatViewModel?
weak var meshService: Transport?
private let identityManager: SecureIdentityStateManagerProtocol
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
self.contextProvider = contextProvider
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
self.chatViewModel = chatViewModel
self.meshService = meshService
self.identityManager = identityManager
}
@@ -76,12 +37,12 @@ final class CommandProcessor {
// Geohash context: disable favoriting in public geohash or GeoDM
let inGeoPublic: Bool = {
switch contextProvider?.activeChannel ?? .mesh {
switch LocationChannelManager.shared.selectedChannel {
case .mesh: return false
case .location: return true
}
}()
let inGeoDM = contextProvider?.selectedPrivateChatPeer?.isGeoDM == true
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
switch cmd {
case "/m", "/msg":
@@ -104,11 +65,14 @@ final class CommandProcessor {
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false)
//
case "/help", "/h":
return .error(message: "unknown command: \(cmd)")
default:
return .error(message: "unknown command: \(cmd)")
}
}
// MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult {
@@ -120,15 +84,15 @@ final class CommandProcessor {
let targetName = String(parts[0])
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname) else {
return .error(message: "'\(nickname)' not found")
}
contextProvider?.startPrivateChat(with: peerID)
chatViewModel?.startPrivateChat(with: peerID)
if parts.count > 1 {
let message = String(parts[1])
contextProvider?.sendPrivateMessage(message, to: peerID)
chatViewModel?.sendPrivateMessage(message, to: peerID)
}
return .success(message: "started private chat with \(nickname)")
@@ -136,12 +100,12 @@ final class CommandProcessor {
private func handleWho() -> CommandResult {
// Show geohash participants when in a geohash channel; otherwise mesh peers
switch contextProvider?.activeChannel ?? .mesh {
switch LocationChannelManager.shared.selectedChannel {
case .location(let ch):
// Geohash context: show visible geohash participants (exclude self)
guard let vm = contextProvider else { return .success(message: "nobody around") }
let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
let people = vm.getVisibleGeoParticipants().filter { person in
guard let vm = chatViewModel else { return .success(message: "nobody around") }
let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
let people = vm.visibleGeohashPeople().filter { person in
if let me = myHex { return person.id.lowercased() != me }
return true
}
@@ -159,30 +123,30 @@ final class CommandProcessor {
}
private func handleClear() -> CommandResult {
if let peerID = contextProvider?.selectedPrivateChatPeer {
contextProvider?.privateChats[peerID]?.removeAll()
if let peerID = chatViewModel?.selectedPrivateChatPeer {
chatViewModel?.privateChats[peerID]?.removeAll()
} else {
contextProvider?.clearCurrentPublicTimeline()
chatViewModel?.clearCurrentPublicTimeline()
}
return .handled
}
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
let targetName = args.trimmed
let targetName = args.trimmingCharacters(in: .whitespaces)
guard !targetName.isEmpty else {
return .error(message: "usage: /\(command) <nickname>")
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname),
let myNickname = contextProvider?.nickname else {
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
let myNickname = chatViewModel?.nickname else {
return .error(message: "cannot \(command) \(nickname): not found")
}
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
if contextProvider?.selectedPrivateChatPeer != nil {
if chatViewModel?.selectedPrivateChatPeer != nil {
// In private chat
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
@@ -198,24 +162,24 @@ final class CommandProcessor {
}
}()
let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)"
contextProvider?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
}
} else {
// In public chat: send to active public channel (mesh or geohash)
contextProvider?.sendPublicRaw(emoteContent)
chatViewModel?.sendPublicRaw(emoteContent)
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
contextProvider?.addPublicSystemMessage(publicEcho)
chatViewModel?.addPublicSystemMessage(publicEcho)
}
return .handled
}
private func handleBlock(_ args: String) -> CommandResult {
let targetName = args.trimmed
let targetName = args.trimmingCharacters(in: .whitespaces)
if targetName.isEmpty {
// List blocked users (mesh) and geohash (Nostr) blocks
let meshBlocked = contextProvider?.blockedUsers ?? []
let meshBlocked = chatViewModel?.blockedUsers ?? []
var blockedNicknames: [String] = []
if let peers = meshService?.getPeerNicknames() {
for (peerID, nickname) in peers {
@@ -229,8 +193,8 @@ final class CommandProcessor {
// Geohash blocked names (prefer visible display names; fallback to #suffix)
let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
var geoNames: [String] = []
if let vm = contextProvider {
let visible = vm.getVisibleGeoParticipants()
if let vm = chatViewModel {
let visible = vm.visibleGeohashPeople()
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
for pk in geoBlocked {
if let name = visibleIndex[pk.lowercased()] {
@@ -249,7 +213,7 @@ final class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) {
if identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is already blocked")
@@ -274,7 +238,7 @@ final class CommandProcessor {
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
}
// Mesh lookup failed; try geohash (Nostr) participant by display name
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is already blocked")
}
@@ -286,14 +250,14 @@ final class CommandProcessor {
}
private func handleUnblock(_ args: String) -> CommandResult {
let targetName = args.trimmed
let targetName = args.trimmingCharacters(in: .whitespaces)
guard !targetName.isEmpty else {
return .error(message: "usage: /unblock <nickname>")
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) {
if !identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is not blocked")
@@ -302,7 +266,7 @@ final class CommandProcessor {
return .success(message: "unblocked \(nickname)")
}
// Try geohash unblock
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is not blocked")
}
@@ -313,14 +277,14 @@ final class CommandProcessor {
}
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
let targetName = args.trimmed
let targetName = args.trimmingCharacters(in: .whitespaces)
guard !targetName.isEmpty else {
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let noisePublicKey = Data(hexString: peerID.id) else {
return .error(message: "can't find peer: \(nickname)")
}
@@ -333,18 +297,33 @@ final class CommandProcessor {
peerNickname: nickname
)
contextProvider?.toggleFavorite(peerID: peerID)
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
chatViewModel?.toggleFavorite(peerID: peerID)
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: true)
return .success(message: "added \(nickname) to favorites")
} else {
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
contextProvider?.toggleFavorite(peerID: peerID)
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
chatViewModel?.toggleFavorite(peerID: peerID)
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: false)
return .success(message: "removed \(nickname) from favorites")
}
}
private func handleHelp() -> CommandResult {
let helpText = """
commands:
/msg @name - start private chat
/who - list who's online
/clear - clear messages
/hug @name - send a hug
/slap @name - slap with a trout
/fav @name - add to favorites
/unfav @name - remove from favorites
/block @name - block
/unblock @name - unblock
"""
return .success(message: helpText)
}
}
@@ -1,5 +1,4 @@
import BitLogger
import BitFoundation
import Foundation
import Combine
@@ -27,14 +26,17 @@ final class FavoritesPersistenceService: ObservableObject {
private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites"
private let keychain: KeychainManagerProtocol
private let keychain: KeychainHelperProtocol
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
@Published private(set) var mutualFavorites: Set<Data> = []
private let userDefaults = UserDefaults.standard
private var cancellables = Set<AnyCancellable>()
static let shared = FavoritesPersistenceService()
init(keychain: KeychainManagerProtocol = KeychainManager()) {
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
self.keychain = keychain
loadFavorites()
@@ -0,0 +1,219 @@
import Foundation
import Combine
#if os(iOS) || os(macOS)
import CoreLocation
#endif
/// Stores a user-maintained list of bookmarked geohash channels.
/// - Persistence: UserDefaults (JSON string array)
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
final class GeohashBookmarksStore: ObservableObject {
static let shared = GeohashBookmarksStore()
@Published private(set) var bookmarks: [String] = []
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
private let storeKey = "locationChannel.bookmarks"
private let namesStoreKey = "locationChannel.bookmarkNames"
private var membership: Set<String> = []
#if os(iOS) || os(macOS)
private let geocoder = CLGeocoder()
private var resolving: Set<String> = []
#endif
private let storage: UserDefaults
init(storage: UserDefaults = .standard) {
self.storage = storage
load()
}
// MARK: - Public API
func isBookmarked(_ geohash: String) -> Bool {
return membership.contains(Self.normalize(geohash))
}
func toggle(_ geohash: String) {
let gh = Self.normalize(geohash)
if membership.contains(gh) {
remove(gh)
} else {
add(gh)
}
}
func add(_ geohash: String) {
let gh = Self.normalize(geohash)
guard !gh.isEmpty else { return }
guard !membership.contains(gh) else { return }
bookmarks.insert(gh, at: 0)
membership.insert(gh)
persist()
// Resolve and persist a friendly name once when added
resolveNameIfNeeded(for: gh)
}
func remove(_ geohash: String) {
let gh = Self.normalize(geohash)
guard membership.contains(gh) else { return }
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
membership.remove(gh)
// Clean up stored name to avoid stale cache growth
if bookmarkNames.removeValue(forKey: gh) != nil {
persistNames()
}
persist()
}
// MARK: - Persistence
private func load() {
guard let data = storage.data(forKey: storeKey) else { return }
if let arr = try? JSONDecoder().decode([String].self, from: data) {
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
var seen = Set<String>()
var list: [String] = []
for raw in arr {
let gh = Self.normalize(raw)
guard !gh.isEmpty else { continue }
if !seen.contains(gh) {
seen.insert(gh)
list.append(gh)
}
}
bookmarks = list
membership = seen
}
// Load any saved names
if let namesData = storage.data(forKey: namesStoreKey),
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
bookmarkNames = dict
}
}
private func persist() {
if let data = try? JSONEncoder().encode(bookmarks) {
storage.set(data, forKey: storeKey)
}
}
private func persistNames() {
if let data = try? JSONEncoder().encode(bookmarkNames) {
storage.set(data, forKey: namesStoreKey)
}
}
// MARK: - Helpers
private static func normalize(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: "#", with: "")
.filter { allowed.contains($0) }
}
// MARK: - Name Resolution
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
func resolveNameIfNeeded(for geohash: String) {
let gh = Self.normalize(geohash)
guard !gh.isEmpty else { return }
if bookmarkNames[gh] != nil { return }
#if os(iOS) || os(macOS)
if resolving.contains(gh) { return }
resolving.insert(gh)
// For very coarse geohashes, sample multiple points to capture multiple admin areas
if gh.count <= 2 {
let b = Geohash.decodeBounds(gh)
let pts: [CLLocation] = [
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
CLLocation(latitude: b.latMin, longitude: b.lonMin),
CLLocation(latitude: b.latMin, longitude: b.lonMax),
CLLocation(latitude: b.latMax, longitude: b.lonMin),
CLLocation(latitude: b.latMax, longitude: b.lonMax)
]
resolveCompositeAdminName(geohash: gh, points: pts)
} else {
let center = Geohash.decodeCenter(gh)
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard let self = self else { return }
defer { self.resolving.remove(gh) }
if let pm = placemarks?.first {
let name = Self.nameForGeohashLength(gh.count, from: pm)
if let name = name, !name.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = name
self.persistNames()
}
}
}
}
}
#endif
}
#if os(iOS) || os(macOS)
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
var uniqueAdmins = OrderedSet<String>()
var idx = 0
func step() {
if idx >= points.count {
// Compose up to 2 names joined by ' and '
let finalName: String? = {
let names = uniqueAdmins.array
if names.count >= 2 { return names[0] + " and " + names[1] }
return names.first
}()
if let finalName = finalName, !finalName.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = finalName
self.persistNames()
}
}
self.resolving.remove(gh)
return
}
let loc = points[idx]
idx += 1
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard self != nil else { return }
if let pm = placemarks?.first {
if let admin = pm.administrativeArea, !admin.isEmpty {
uniqueAdmins.insert(admin)
} else if let country = pm.country, !country.isEmpty {
uniqueAdmins.insert(country)
}
}
// Proceed to next point
step()
}
}
step()
}
// Minimal ordered-set for stable joining
private struct OrderedSet<Element: Hashable> {
private var set: Set<Element> = []
private(set) var array: [Element] = []
mutating func insert(_ element: Element) {
if set.insert(element).inserted { array.append(element) }
}
}
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
switch len {
case 0...2:
// Prefer administrative area if available at this coarse level
return pm.administrativeArea ?? pm.country
case 3...4:
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
case 5:
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
case 6...7:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
default:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
}
}
#endif
}
@@ -1,162 +0,0 @@
//
// GeohashParticipantTracker.swift
// bitchat
//
// Tracks participants in geohash-based location channels.
// This is free and unencumbered software released into the public domain.
//
import Foundation
/// Represents a participant in a geohash channel
public struct GeoPerson: Identifiable, Equatable, Sendable {
public let id: String // pubkey hex (lowercased)
public let displayName: String
public let lastSeen: Date
public init(id: String, displayName: String, lastSeen: Date) {
self.id = id
self.displayName = displayName
self.lastSeen = lastSeen
}
}
/// Protocol for resolving display names and checking block status
@MainActor
public protocol GeohashParticipantContext: AnyObject {
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
func displayNameForPubkey(_ pubkeyHex: String) -> String
/// Returns true if the pubkey is blocked
func isBlocked(_ pubkeyHexLowercased: String) -> Bool
}
/// Tracks participants across multiple geohash channels
@MainActor
public final class GeohashParticipantTracker: ObservableObject {
/// Activity cutoff duration (defaults to 5 minutes)
public let activityCutoff: TimeInterval
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
private var participants: [String: [String: Date]] = [:]
/// Currently visible people for the active geohash
@Published public private(set) var visiblePeople: [GeoPerson] = []
/// The currently active geohash (if any)
private var activeGeohash: String?
/// Context for display name resolution and block checking
private weak var context: GeohashParticipantContext?
/// Timer for periodic refresh
private var refreshTimer: Timer?
public init(activityCutoff: TimeInterval = -300) { // default 5 minutes
self.activityCutoff = activityCutoff
}
/// Configure with a context provider
public func configure(context: GeohashParticipantContext) {
self.context = context
}
/// Set the currently active geohash
public func setActiveGeohash(_ geohash: String?) {
activeGeohash = geohash
if geohash == nil {
visiblePeople = []
} else {
refresh()
}
}
/// Record activity from a participant in the current active geohash
public func recordParticipant(pubkeyHex: String) {
guard let gh = activeGeohash else { return }
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
}
/// Record activity from a participant in a specific geohash
public func recordParticipant(pubkeyHex: String, geohash: String) {
let key = pubkeyHex.lowercased()
var map = participants[geohash] ?? [:]
map[key] = Date()
participants[geohash] = map
// Always notify observers that state has changed so counts in UI update
objectWillChange.send()
// Only refresh visible list if this geohash is currently active
if activeGeohash == geohash {
refresh()
}
}
/// Remove a participant from all geohashes (used when blocking)
public func removeParticipant(pubkeyHex: String) {
let key = pubkeyHex.lowercased()
for (gh, var map) in participants {
map.removeValue(forKey: key)
participants[gh] = map
}
refresh()
}
/// Get participant count for a specific geohash
public func participantCount(for geohash: String) -> Int {
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = participants[geohash] ?? [:]
return map.values.filter { $0 >= cutoff }.count
}
/// Get the visible people list for the active geohash (read-only query)
public func getVisiblePeople() -> [GeoPerson] {
guard let gh = activeGeohash, let context = context else { return [] }
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = (participants[gh] ?? [:])
.filter { $0.value >= cutoff }
.filter { !context.isBlocked($0.key) }
return map
.map { (pub, seen) in
GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen)
}
.sorted { $0.lastSeen > $1.lastSeen }
}
/// Refresh the visible people list
public func refresh() {
visiblePeople = getVisiblePeople()
}
/// Start the periodic refresh timer
public func startRefreshTimer(interval: TimeInterval = 30.0) {
stopRefreshTimer()
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.refresh()
}
}
}
/// Stop the periodic refresh timer
public func stopRefreshTimer() {
refreshTimer?.invalidate()
refreshTimer = nil
}
/// Clear all participant data
public func clear() {
participants.removeAll()
visiblePeople = []
}
/// Clear participant data for a specific geohash
public func clear(geohash: String) {
participants.removeValue(forKey: geohash)
if activeGeohash == geohash {
visiblePeople = []
}
}
}

Some files were not shown because too many files have changed in this diff Show More