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
2515 changed files with 386691 additions and 63157 deletions
+1 -3
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 -23
View File
@@ -5,25 +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
- name: Noise
path: localPackages/Noise
steps:
- name: Checkout code
uses: actions/checkout@v5
@@ -31,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
+4 -13
View File
@@ -16,9 +16,7 @@ let package = Package(
),
],
dependencies:[
.package(path: "localPackages/Arti"),
.package(path: "localPackages/Noise"),
.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")
],
@@ -27,20 +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: "Noise", package: "Noise"),
.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")
@@ -48,10 +42,7 @@ let package = Package(
),
.testTarget(
name: "bitchatTests",
dependencies: [
"bitchat",
.product(name: "BitFoundation", package: "BitFoundation")
],
dependencies: ["bitchat"],
path: "bitchatTests",
exclude: [
"Info.plist",
+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 -85
View File
@@ -10,14 +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 */; };
A63163B62F80CB2500B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B52F80CB2500B8B128 /* Noise */; };
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B72F80CB2D00B8B128 /* Noise */; };
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 */
@@ -98,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 */
@@ -139,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,20 +133,17 @@
isa = PBXFrameworksBuildPhase;
files = (
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
A63163B82F80CB2D00B8B128 /* Noise 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 */,
A63163B62F80CB2500B8B128 /* Noise in Frameworks */,
885BBED78092484A5B069461 /* P256K in Frameworks */,
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
);
};
/* End PBXFrameworksBuildPhase section */
@@ -231,8 +203,6 @@
B1D9136AA0083366353BFA2F /* P256K */,
A6E3E5712E7703760032EA8A /* BitLogger */,
A6E3EA802E7706A80032EA8A /* Tor */,
A6BCF9492F809550001CF9B9 /* BitFoundation */,
A63163B72F80CB2D00B8B128 /* Noise */,
);
productName = bitchat_macOS;
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
@@ -243,7 +213,6 @@
buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */;
buildPhases = (
5C22AA7B9ACC5A861445C769 /* Sources */,
C5E027A42ECCDFD700BD6012 /* Resources */,
);
buildRules = (
);
@@ -276,7 +245,6 @@
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
buildPhases = (
865C8403EF02C089369A9FCB /* Sources */,
C5E027A72ECCDFE200BD6012 /* Resources */,
);
buildRules = (
);
@@ -313,8 +281,6 @@
4EB6BA1B8464F1EA38F4E286 /* P256K */,
A6E3E56F2E77036A0032EA8A /* BitLogger */,
A6E3EA7E2E7706720032EA8A /* Tor */,
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
A63163B52F80CB2500B8B128 /* Noise */,
);
productName = bitchat_iOS;
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
@@ -355,9 +321,7 @@
packageReferences = (
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
);
preferredProjectObjectVersion = 90;
projectDirPath = "";
@@ -379,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 = (
@@ -562,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;
@@ -573,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;
@@ -623,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;
@@ -634,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;
@@ -670,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;
@@ -762,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;
@@ -923,21 +875,13 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Noise;
};
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 */
@@ -946,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 */
@@ -958,24 +902,6 @@
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
productName = P256K;
};
A63163B52F80CB2500B8B128 /* Noise */ = {
isa = XCSwiftPackageProductDependency;
productName = Noise;
};
A63163B72F80CB2D00B8B128 /* Noise */ = {
isa = XCSwiftPackageProductDependency;
package = A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */;
productName = Noise;
};
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>
+12 -20
View File
@@ -8,7 +8,6 @@
import Tor
import SwiftUI
import BitFoundation
import UserNotifications
@main
@@ -54,20 +53,17 @@ struct BitchatApp: App {
// 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
let nickname = chatViewModel.nickname
DispatchQueue.global(qos: .utility).async {
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: 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()
// Start presence service (will wait for Tor readiness)
GeohashPresenceService.shared.start()
// Check for shared content
checkForSharedContent()
}
@@ -193,10 +189,6 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true
}
func applicationWillTerminate(_ application: UIApplication) {
chatViewModel?.applicationWillTerminate()
}
}
#endif
@@ -254,17 +246,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
// Don't show notification if the private chat is already open
// Access main-actor-isolated property via Task
Task { @MainActor in
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
if chatViewModel?.selectedPrivateChatPeer == peerID {
completionHandler([])
} else {
completionHandler([.banner, .sound])
}
}
return
}
}
}
// Suppress geohash activity notification if we're already in that geohash channel
if identifier.hasPrefix("geo-activity-"),
let deep = userInfo["deeplink"] as? String,
@@ -280,3 +267,8 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
}
}
extension String {
var nilIfEmpty: String? {
self.isEmpty ? nil : self
}
}
-203
View File
@@ -1,203 +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
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
// 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 else {
throw ImageUtilsError.invalidImage
}
// Allow up to 10MB source images (will be scaled down)
guard fileSize <= 10 * 1024 * 1024 else {
throw ImageUtilsError.invalidImage
}
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)
#else
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
return try processImage(image, maxDimension: maxDimension)
#endif
}
#if os(iOS)
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) 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()
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) 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()
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() throws -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "img_\(formatter.string(from: Date())).jpg"
let 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
@@ -332,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)
}
}
}
@@ -532,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>
+14606 -15826
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:"`
@@ -36,32 +24,32 @@ public struct PeerID: Equatable, Hashable, Sendable {
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
@@ -88,17 +76,11 @@ public extension PeerID {
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))
@@ -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,7 +110,7 @@ extension PeerID: Codable {
// MARK: - Helpers
public extension PeerID {
extension PeerID {
var isEmpty: Bool {
id.isEmpty
}
@@ -148,23 +130,14 @@ public extension PeerID {
}
}
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 {
@@ -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),
+1 -40
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,16 +25,6 @@ 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
}
@@ -55,9 +33,6 @@ struct RequestSyncPacket {
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)
}
}
@@ -78,7 +78,6 @@
///
import BitLogger
import BitFoundation
import Foundation
import CryptoKit
@@ -166,12 +165,8 @@ 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
}
@@ -353,19 +348,15 @@ 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)
}
}
}
@@ -614,13 +563,8 @@ final class NoiseHandshakeState {
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 {
// 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:
@@ -779,10 +711,7 @@ 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 {
@@ -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)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
case .e, .s:
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
@@ -924,7 +832,7 @@ extension NoisePattern {
// MARK: - Errors
public enum NoiseError: Error {
enum NoiseError: Error {
case uninitializedCipher
case invalidCiphertext
case handshakeComplete
@@ -938,44 +846,19 @@ public 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
}
@@ -1000,16 +883,8 @@ extension NoiseHandshakeState {
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
}
@@ -7,10 +7,9 @@
//
import BitLogger
import BitFoundation
import Foundation
public final class NoiseRateLimiter {
final class NoiseRateLimiter {
private var handshakeTimestamps: [PeerID: [Date]] = [:]
private var messageTimestamps: [PeerID: [Date]] = [:]
@@ -20,9 +19,7 @@ public final class NoiseRateLimiter {
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
public init() {}
public func allowHandshake(from peerID: PeerID) -> Bool {
func allowHandshake(from peerID: PeerID) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneMinuteAgo = now.addingTimeInterval(-60)
@@ -51,7 +48,7 @@ public final class NoiseRateLimiter {
}
}
public func allowMessage(from peerID: PeerID) -> Bool {
func allowMessage(from peerID: PeerID) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneSecondAgo = now.addingTimeInterval(-1)
@@ -87,7 +84,7 @@ public final class NoiseRateLimiter {
}
}
public func resetAll() {
func resetAll() {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeAll()
self.messageTimestamps.removeAll()
@@ -8,7 +8,7 @@
import Foundation
public enum NoiseSecurityConstants {
enum NoiseSecurityConstants {
// Maximum message size to prevent memory exhaustion
static let maxMessageSize = 65535 // 64KB as per Noise spec
@@ -28,10 +28,10 @@ public enum NoiseSecurityConstants {
static let maxSessionsPerPeer = 3
// Rate limiting
public static let maxHandshakesPerMinute = 10
public static let maxMessagesPerSecond = 100
static let maxHandshakesPerMinute = 10
static let maxMessagesPerSecond = 100
// Global rate limiting (across all peers)
public static let maxGlobalHandshakesPerMinute = 30
public static let maxGlobalMessagesPerSecond = 500
static let maxGlobalHandshakesPerMinute = 30
static let maxGlobalMessagesPerSecond = 500
}
@@ -8,7 +8,7 @@
import Foundation
public enum NoiseSecurityError: Error {
enum NoiseSecurityError: Error {
case sessionExpired
case sessionExhausted
case messageTooLarge
@@ -8,15 +8,15 @@
import Foundation
public struct NoiseSecurityValidator {
struct NoiseSecurityValidator {
/// Validate message size
public static func validateMessageSize(_ data: Data) -> Bool {
static func validateMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxMessageSize
}
/// Validate handshake message size
public static func validateHandshakeMessageSize(_ data: Data) -> Bool {
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
}
}
@@ -9,9 +9,8 @@
import BitLogger
import Foundation
import CryptoKit
import BitFoundation
public class NoiseSession {
class NoiseSession {
let peerID: PeerID
let role: NoiseRole
private let keychain: KeychainManagerProtocol
@@ -103,8 +102,8 @@ public 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
@@ -112,7 +111,7 @@ public class NoiseSession {
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
// Store handshake hash for channel binding
handshakeHash = hash
handshakeHash = handshake.getHandshakeHash()
state = .established
handshakeState = nil // Clear handshake state
@@ -129,8 +128,8 @@ public 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
@@ -138,7 +137,7 @@ public class NoiseSession {
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
// Store handshake hash for channel binding
handshakeHash = hash
handshakeHash = handshake.getHandshakeHash()
state = .established
handshakeState = nil // Clear handshake state
@@ -182,7 +181,7 @@ public class NoiseSession {
}
}
public func isEstablished() -> Bool {
func isEstablished() -> Bool {
return sessionQueue.sync {
if case .established = state {
return true
@@ -217,8 +216,8 @@ public class NoiseSession {
sentHandshakeMessages.removeAll()
// Clear handshake hash
if handshakeHash != nil {
keychain.secureClear(&handshakeHash!)
if var hash = handshakeHash {
keychain.secureClear(&hash)
}
handshakeHash = nil
@@ -6,7 +6,7 @@
// For more information, see <https://unlicense.org>
//
public enum NoiseSessionError: Error, Equatable {
enum NoiseSessionError: Error, Equatable {
case invalidState
case notEstablished
case sessionNotFound
@@ -9,53 +9,31 @@
import BitLogger
import CryptoKit
import Foundation
import BitFoundation
public final class NoiseSessionManager {
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
public var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
var onSessionFailed: ((PeerID, Error) -> Void)?
public init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
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
public func getSession(for peerID: PeerID) -> NoiseSession? {
func getSession(for peerID: PeerID) -> NoiseSession? {
return managerQueue.sync {
return sessions[peerID]
}
}
public func removeSession(for peerID: PeerID) {
func removeSession(for peerID: PeerID) {
managerQueue.sync(flags: .barrier) {
if let session = sessions.removeValue(forKey: peerID) {
session.reset() // Clear sensitive data before removing
@@ -63,7 +41,7 @@ public final class NoiseSessionManager {
}
}
public func removeAllSessions() {
func removeAllSessions() {
managerQueue.sync(flags: .barrier) {
for (_, session) in sessions {
session.reset()
@@ -74,7 +52,7 @@ public final class NoiseSessionManager {
// MARK: - Handshake Helpers
public func initiateHandshake(with peerID: PeerID) throws -> Data {
func initiateHandshake(with peerID: PeerID) throws -> Data {
return try managerQueue.sync(flags: .barrier) {
// Check if we already have an established session
if let existingSession = sessions[peerID], existingSession.isEstablished() {
@@ -82,18 +60,24 @@ public 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)
@@ -103,7 +87,7 @@ public final class NoiseSessionManager {
}
}
public func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
// Process everything within the synchronized block to prevent race conditions
return try managerQueue.sync(flags: .barrier) {
var shouldCreateNew = false
@@ -134,7 +118,12 @@ public 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 {
@@ -173,7 +162,7 @@ public final class NoiseSessionManager {
// MARK: - Encryption/Decryption
public func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
@@ -181,7 +170,7 @@ public final class NoiseSessionManager {
return try session.encrypt(plaintext)
}
public func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
@@ -191,13 +180,13 @@ public final class NoiseSessionManager {
// MARK: - Key Management
public func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
return getSession(for: peerID)?.getRemoteStaticPublicKey()
}
// MARK: - Session Rekeying
public func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
return managerQueue.sync {
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
@@ -213,7 +202,7 @@ public final class NoiseSessionManager {
}
}
public func initiateRekey(for peerID: PeerID) throws {
func initiateRekey(for peerID: PeerID) throws {
// Remove old session
removeSession(for: peerID)
@@ -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
}
+58 -351
View File
@@ -1,162 +1,29 @@
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()
// Load cached or bundled data synchronously
self.entries = self.loadLocalEntries()
// Fire-and-forget remote refresh if stale
prefetchIfNeeded()
}
}
internal init(dependencies: GeoRelayDirectoryDependencies) {
self.dependencies = dependencies
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
entries = loadLocalEntries()
if dependencies.autoStart {
registerObservers()
startRefreshTimer()
prefetchIfNeeded()
}
}
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
@@ -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
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)
}
.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()
}
}
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 parsed = Self.parseCSV(text)
guard !parsed.isEmpty else {
return .invalidData
}
return .success(entries: parsed, csv: text)
} catch {
return .network(error.localizedDescription)
}
}.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)
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)
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)
}
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)
return
}
}
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
}
task.resume()
}
@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,10 +122,12 @@ 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 }
var host = parts[0]
host = host.replacingOccurrences(of: "https://", with: "")
@@ -393,56 +141,15 @@ final class GeoRelayDirectory {
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)
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 }
}
}
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 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
private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {
+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
+3 -26
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
private let keychain: KeychainHelperProtocol
init(keychain: KeychainManagerProtocol = KeychainManager()) {
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)
}
}
+2 -41
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(
@@ -529,26 +510,6 @@ struct NostrEvent: Codable {
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 = [
0,
+58 -195
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 {
@@ -135,11 +41,10 @@ final class NostrRelayManager: ObservableObject {
@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] = [:]
@@ -164,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
@@ -172,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 }
@@ -190,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 }
@@ -232,11 +113,12 @@ final class NostrRelayManager: ObservableObject {
/// Connect to all configured relays
func connect() {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
guard networkService.activationAllowed else { return }
if shouldUseTor {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
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
@@ -246,6 +128,7 @@ final class NostrRelayManager: ObservableObject {
self.connectToRelay(relay.url)
}
}
}
} else {
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
for relay in self.relays {
@@ -270,14 +153,15 @@ final class NostrRelayManager: ObservableObject {
/// 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 && dependencies.torEnforced() && !dependencies.torIsReady() {
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer until Tor is fully ready; avoid queuing connection attempts early
dependencies.awaitTorReady { [weak self] ready in
Task.detached { [weak self] in
guard let self = self else { return }
if ready { self.ensureConnections(to: relayUrls) }
let ready = await TorManager.shared.awaitReady()
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
}
return
}
@@ -294,12 +178,13 @@ final class NostrRelayManager: ObservableObject {
/// 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
}
@@ -371,21 +256,24 @@ final class NostrRelayManager: ObservableObject {
onEOSE: (() -> Void)? = nil
) {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
guard networkService.activationAllowed else { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = dependencies.now()
let now = Date()
if messageHandlers[id] != nil {
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
return
}
}
subscribeCoalesce[id] = now
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer subscription setup until Tor is ready; avoid queuing subs early
dependencies.awaitTorReady { [weak self] ready in
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, onEOSE: onEOSE)
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler)
}
}
}
return
@@ -412,7 +300,7 @@ final class NostrRelayManager: ObservableObject {
for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url))
}
for url in urls {
for url in candidateUrls {
var map = self.pendingSubscriptions[url] ?? [:]
map[id] = messageString
self.pendingSubscriptions[url] = map
@@ -461,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 {
@@ -471,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() {
@@ -516,9 +403,10 @@ 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)
}
}
}
}
@@ -528,14 +416,14 @@ final class NostrRelayManager: ObservableObject {
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
}
@@ -550,16 +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 shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
dependencies.awaitTorReady { [weak self] ready in
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
@@ -607,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 }
@@ -617,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)
}
}
@@ -681,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 {
@@ -713,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()
@@ -733,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)
@@ -778,14 +669,13 @@ 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
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 }
@@ -795,7 +685,6 @@ final class NostrRelayManager: ObservableObject {
}
}
}
}
// MARK: - Public Utility Methods
@@ -806,7 +695,6 @@ final class NostrRelayManager: ObservableObject {
// Reset reconnection attempts
relays[index].reconnectAttempts = 0
relays[index].nextReconnectTime = nil
relays[index].lastError = nil
// Disconnect if connected
if let connection = connections[relayUrl] {
@@ -828,20 +716,6 @@ final class NostrRelayManager: ObservableObject {
}
}
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() {
disconnect()
@@ -893,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
}
@@ -1014,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
@@ -1033,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
+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? {
@@ -38,16 +36,6 @@ struct AnnouncementPacket {
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
)
}
}
-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
+53 -73
View File
@@ -7,7 +7,6 @@
//
import Foundation
import BitFoundation
/// Result of command processing
enum CommandResult {
@@ -16,52 +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 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
}
@@ -80,7 +42,7 @@ final class CommandProcessor {
case .location: return true
}
}()
let inGeoDM = contextProvider?.selectedPrivateChatPeer?.isGeoDM == true
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
switch cmd {
case "/m", "/msg":
@@ -103,6 +65,9 @@ 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)")
}
@@ -119,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)")
@@ -138,9 +103,9 @@ final class CommandProcessor {
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
}
@@ -158,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) *"
@@ -197,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 {
@@ -228,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()] {
@@ -248,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")
@@ -273,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")
}
@@ -285,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")
@@ -301,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")
}
@@ -312,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)")
}
@@ -332,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 = []
}
}
}
@@ -1,264 +0,0 @@
//
// GeohashPresenceService.swift
// bitchat
//
// Manages the broadcasting of ephemeral presence heartbeats (Kind 20001)
// to geohash location channels.
//
// This is free and unencumbered software released into the public domain.
//
import Foundation
import Combine
import BitLogger
import Tor
protocol GeohashPresenceTimerProtocol: AnyObject {
var isValid: Bool { get }
func invalidate()
}
private final class GeohashPresenceTimerAdapter: GeohashPresenceTimerProtocol {
private let base: Timer
init(base: Timer) {
self.base = base
}
var isValid: Bool { base.isValid }
func invalidate() {
base.invalidate()
}
}
/// Service that coordinates the broadcasting of presence heartbeats.
///
/// Behavior:
/// - Monitors location changes via LocationStateManager
/// - Broadcasts Kind 20001 events to low-precision geohash channels
/// - Uses randomized timing (40-80s loop) and decorrelated bursts
/// - Respects privacy by NOT broadcasting to Neighborhood/Block/Building levels
@MainActor
final class GeohashPresenceService: ObservableObject {
static let shared = GeohashPresenceService()
private var subscriptions = Set<AnyCancellable>()
private var heartbeatTimer: GeohashPresenceTimerProtocol?
private let availableChannelsProvider: () -> [GeohashChannel]
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
private let torReadyPublisher: AnyPublisher<Void, Never>
private let torIsReady: () -> Bool
private let torIsForeground: () -> Bool
private let deriveIdentity: (String) throws -> NostrIdentity
private let relayLookup: (String, Int) -> [String]
private let relaySender: (NostrEvent, [String]) -> Void
private let sleeper: (UInt64) async -> Void
private let scheduleTimer: (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol
// MARK: - Constants
// Loop interval range in seconds
private let loopMinInterval: TimeInterval
private let loopMaxInterval: TimeInterval
// Per-broadcast decorrelation delay range in seconds
private let burstMinDelay: TimeInterval
private let burstMaxDelay: TimeInterval
// Privacy: Only broadcast to these levels
private let allowedPrecisions: Set<Int> = [
GeohashChannelLevel.region.precision, // 2
GeohashChannelLevel.province.precision, // 4
GeohashChannelLevel.city.precision // 5
]
private init() {
let idBridge = NostrIdentityBridge()
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
.map { _ in () }
.eraseToAnyPublisher()
self.torIsReady = { TorManager.shared.isReady }
self.torIsForeground = { TorManager.shared.isForeground() }
self.deriveIdentity = { try idBridge.deriveIdentity(forGeohash: $0) }
self.relayLookup = { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
}
self.relaySender = { event, relays in
NostrRelayManager.shared.sendEvent(event, to: relays)
}
self.sleeper = { nanoseconds in
try? await Task.sleep(nanoseconds: nanoseconds)
}
self.scheduleTimer = { interval, action in
GeohashPresenceTimerAdapter(
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
action()
}
)
}
self.loopMinInterval = 40.0
self.loopMaxInterval = 80.0
self.burstMinDelay = 2.0
self.burstMaxDelay = 5.0
setupObservers()
}
internal init(
availableChannelsProvider: @escaping () -> [GeohashChannel],
locationChanges: AnyPublisher<[GeohashChannel], Never>,
torReadyPublisher: AnyPublisher<Void, Never>,
torIsReady: @escaping () -> Bool,
torIsForeground: @escaping () -> Bool,
deriveIdentity: @escaping (String) throws -> NostrIdentity,
relayLookup: @escaping (String, Int) -> [String],
relaySender: @escaping (NostrEvent, [String]) -> Void,
sleeper: @escaping (UInt64) async -> Void = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) },
scheduleTimer: @escaping (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol = { interval, action in
GeohashPresenceTimerAdapter(
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
action()
}
)
},
loopMinInterval: TimeInterval = 40.0,
loopMaxInterval: TimeInterval = 80.0,
burstMinDelay: TimeInterval = 2.0,
burstMaxDelay: TimeInterval = 5.0
) {
self.availableChannelsProvider = availableChannelsProvider
self.locationChanges = locationChanges
self.torReadyPublisher = torReadyPublisher
self.torIsReady = torIsReady
self.torIsForeground = torIsForeground
self.deriveIdentity = deriveIdentity
self.relayLookup = relayLookup
self.relaySender = relaySender
self.sleeper = sleeper
self.scheduleTimer = scheduleTimer
self.loopMinInterval = loopMinInterval
self.loopMaxInterval = loopMaxInterval
self.burstMinDelay = burstMinDelay
self.burstMaxDelay = burstMaxDelay
setupObservers()
}
/// Start the service (safe to call multiple times)
func start() {
SecureLogger.info("Presence: service starting...", category: .session)
scheduleNextHeartbeat()
}
private func setupObservers() {
// Monitor location channel changes
locationChanges
.dropFirst()
.sink { [weak self] _ in
self?.handleLocationChange()
}
.store(in: &subscriptions)
// Monitor Tor readiness to kick off heartbeat if it was stalled
torReadyPublisher
.sink { [weak self] _ in
self?.handleConnectivityChange()
}
.store(in: &subscriptions)
}
func handleLocationChange() {
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
// to announce presence in the new zone, then reset the loop.
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
heartbeatTimer?.invalidate()
// Small delay to allow location state to settle
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
Task { @MainActor [weak self] in
self?.performHeartbeat()
}
}
}
func handleConnectivityChange() {
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
// If we were waiting for network, do it now
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
scheduleNextHeartbeat()
}
}
func scheduleNextHeartbeat() {
heartbeatTimer?.invalidate()
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
heartbeatTimer = scheduleTimer(interval) { [weak self] in
Task { @MainActor [weak self] in
self?.performHeartbeat()
}
}
}
func performHeartbeat() {
// Always schedule next loop first ensures continuity even if this one fails/skips
defer { scheduleNextHeartbeat() }
// 1. Check preconditions
guard torIsReady() else {
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
return
}
// App must be active (or at least we shouldn't broadcast if in background, usually)
if !torIsForeground() {
return
}
// 2. Get channels
let channels = availableChannelsProvider()
guard !channels.isEmpty else { return }
// 3. Filter and broadcast
// We use Task + sleep for decorrelation to allow the main runloop to proceed
for channel in channels {
// Check privacy restriction
if !self.allowedPrecisions.contains(channel.geohash.count) {
continue
}
// Launch independent task for each channel's delay
Task { @MainActor in
// Random delay for decorrelation
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
let nanoseconds = UInt64(delay * 1_000_000_000)
await self.sleeper(nanoseconds)
self.broadcastPresence(for: channel.geohash)
}
}
}
func broadcastPresence(for geohash: String) {
do {
guard let identity = try? deriveIdentity(geohash) else {
return
}
let event = try NostrProtocol.createGeohashPresenceEvent(
geohash: geohash,
senderIdentity: identity
)
// Send via RelayManager
let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
if !targetRelays.isEmpty {
relaySender(event, targetRelays)
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
}
} catch {
SecureLogger.error("Presence: failed to create event for \(geohash): \(error)", category: .session)
}
}
}
+40 -220
View File
@@ -7,15 +7,54 @@
//
import BitLogger
import BitFoundation
import Foundation
import Security
protocol KeychainManagerProtocol {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool
func deleteAllKeychainData() -> Bool
func secureClear(_ data: inout Data)
func secureClear(_ string: inout String)
func verifyIdentityKeyExists() -> Bool
}
final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items
private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)"
private func isSandboxed() -> Bool {
#if os(macOS)
// More robust sandbox detection using multiple methods
// Method 1: Check environment variable (can be spoofed)
let environment = ProcessInfo.processInfo.environment
let hasEnvVar = environment["APP_SANDBOX_CONTAINER_ID"] != nil
// Method 2: Check if we can access a path outside sandbox
let homeDir = FileManager.default.homeDirectoryForCurrentUser
let testPath = homeDir.appendingPathComponent("../../../tmp/bitchat_sandbox_test_\(UUID().uuidString)")
let canWriteOutsideSandbox = FileManager.default.createFile(atPath: testPath.path, contents: nil, attributes: nil)
if canWriteOutsideSandbox {
try? FileManager.default.removeItem(at: testPath)
}
// Method 3: Check container path
let containerPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? ""
let hasContainerPath = containerPath.contains("/Containers/")
// If any method indicates sandbox, we consider it sandboxed
return hasEnvVar || !canWriteOutsideSandbox || hasContainerPath
#else
// iOS is always sandboxed
return true
#endif
}
// MARK: - Identity Keys
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
@@ -36,180 +75,6 @@ final class KeychainManager: KeychainManagerProtocol {
return result
}
// MARK: - BCH-01-009: Methods with Proper Error Classification
/// Get identity key with detailed result for proper error handling
/// Distinguishes between missing keys (expected) and critical failures
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
let fullKey = "identity_\(key)"
return retrieveDataWithResult(forKey: fullKey)
}
/// Save identity key with detailed result and retry logic for transient errors
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
let fullKey = "identity_\(key)"
return saveDataWithResult(keyData, forKey: fullKey)
}
/// Internal method to save data with detailed result and retry for transient errors
private func saveDataWithResult(_ data: Data, forKey key: String, retryCount: Int = 2) -> KeychainSaveResult {
// Delete any existing item first to ensure clean state
_ = delete(forKey: key)
// Build base query
var base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrService as String: service,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
kSecAttrLabel as String: "bitchat-\(key)"
]
#if os(macOS)
base[kSecAttrSynchronizable as String] = false
#endif
func attempt(addAccessGroup: Bool) -> OSStatus {
var query = base
if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup }
return SecItemAdd(query as CFDictionary, nil)
}
#if os(iOS)
var status = attempt(addAccessGroup: true)
if status == -34018 { // Missing entitlement, retry without access group
status = attempt(addAccessGroup: false)
}
#else
let status = attempt(addAccessGroup: false)
#endif
// Classify the result
let result = classifySaveStatus(status)
// Log all outcomes consistently
switch result {
case .success:
SecureLogger.debug("Keychain save succeeded for key: \(key)", category: .keychain)
case .duplicateItem:
SecureLogger.warning("Keychain save found duplicate for key: \(key)", category: .keychain)
case .accessDenied:
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Keychain access denied for key: \(key)", category: .keychain)
case .deviceLocked:
SecureLogger.warning("Device locked during keychain save for key: \(key)", category: .keychain)
case .storageFull:
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Keychain storage full for key: \(key)", category: .keychain)
case .otherError(let code):
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
context: "Keychain save failed for key: \(key)", category: .keychain)
}
// Retry transient errors with exponential backoff
if result.isRecoverableError && retryCount > 0 {
let delayMs = UInt32((3 - retryCount) * 100) // 100ms, 200ms backoff
usleep(delayMs * 1000)
SecureLogger.debug("Retrying keychain save for key: \(key), attempts remaining: \(retryCount)", category: .keychain)
return saveDataWithResult(data, forKey: key, retryCount: retryCount - 1)
}
return result
}
/// Internal method to retrieve data with detailed result
private func retrieveDataWithResult(forKey key: String) -> KeychainReadResult {
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: service,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
func attempt(withAccessGroup: Bool) -> OSStatus {
var q = base
if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }
return SecItemCopyMatching(q as CFDictionary, &result)
}
#if os(iOS)
var status = attempt(withAccessGroup: true)
if status == -34018 { status = attempt(withAccessGroup: false) }
#else
let status = attempt(withAccessGroup: false)
#endif
// Classify the result
let readResult = classifyReadStatus(status, data: result as? Data)
// Log all outcomes consistently
switch readResult {
case .success:
SecureLogger.debug("Keychain read succeeded for key: \(key)", category: .keychain)
case .itemNotFound:
// Expected case - no logging needed for missing keys
break
case .accessDenied:
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Keychain access denied for key: \(key)", category: .keychain)
case .deviceLocked:
SecureLogger.warning("Device locked during keychain read for key: \(key)", category: .keychain)
case .authenticationFailed:
SecureLogger.warning("Authentication failed for keychain read of key: \(key)", category: .keychain)
case .otherError(let code):
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
context: "Keychain read failed for key: \(key)", category: .keychain)
}
return readResult
}
/// Classify keychain read status into meaningful categories
private func classifyReadStatus(_ status: OSStatus, data: Data?) -> KeychainReadResult {
switch status {
case errSecSuccess:
if let data = data {
return .success(data)
}
return .otherError(status)
case errSecItemNotFound:
return .itemNotFound
case errSecInteractionNotAllowed:
// Device is locked or in a state that doesn't allow keychain access
return .deviceLocked
case errSecAuthFailed:
return .authenticationFailed
case -34018: // errSecMissingEntitlement
return .accessDenied
case errSecNotAvailable:
return .accessDenied
default:
return .otherError(status)
}
}
/// Classify keychain save status into meaningful categories
private func classifySaveStatus(_ status: OSStatus) -> KeychainSaveResult {
switch status {
case errSecSuccess:
return .success
case errSecDuplicateItem:
return .duplicateItem
case errSecInteractionNotAllowed:
return .deviceLocked
case -34018: // errSecMissingEntitlement
return .accessDenied
case errSecNotAvailable:
return .accessDenied
case errSecDiskFull:
return .storageFull
default:
return .otherError(status)
}
}
// MARK: - Generic Operations
private func save(_ value: String, forKey key: String) -> Bool {
@@ -477,49 +342,4 @@ final class KeychainManager: KeychainManagerProtocol {
let key = "identity_noiseStaticKey"
return retrieveData(forKey: key) != nil
}
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
/// Save data with a custom service name
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
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)
}
/// Load data from a custom service
func load(key: String, service customService: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
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
}
/// Delete data from a custom service
func delete(key: String, service customService: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
@@ -0,0 +1,304 @@
import BitLogger
import Foundation
import Combine
#if os(iOS) || os(macOS)
import CoreLocation
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
static let shared = LocationChannelManager()
enum PermissionState: Equatable {
case notDetermined
case denied
case restricted
case authorized
}
private let cl = CLLocationManager()
private let geocoder = CLGeocoder()
private var lastLocation: CLLocation?
private var refreshTimer: Timer?
private let userDefaultsKey = "locationChannel.selected"
private let teleportedStoreKey = "locationChannel.teleportedSet"
private var isGeocoding: Bool = false
// Published state for UI bindings
@Published private(set) var permissionState: PermissionState = .notDetermined
@Published private(set) var availableChannels: [GeohashChannel] = []
@Published private(set) var selectedChannel: ChannelID = .mesh
// True when the current location channel was selected via manual teleport
@Published var teleported: Bool = false
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
// Persisted set of geohashes that were selected via teleport
private var teleportedSet: Set<String> = []
private override init() {
super.init()
cl.delegate = self
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
// Load selection
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
selectedChannel = channel
}
// Load persisted teleported set
if let data = UserDefaults.standard.data(forKey: teleportedStoreKey),
let arr = try? JSONDecoder().decode([String].self, from: data) {
teleportedSet = Set(arr)
}
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
// This avoids showing teleported for in-region channels during cold start.
let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
}
updatePermissionState(from: status)
// If we don't have location authorization at startup, fall back to persisted teleport state
switch status {
case .authorizedAlways, .authorizedWhenInUse, .authorized:
break // will compute from location
default:
if case .location(let ch) = selectedChannel {
teleported = teleportedSet.contains(ch.geohash)
}
}
}
// MARK: - Public API
func enableLocationChannels() {
let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
}
switch status {
case .notDetermined:
cl.requestWhenInUseAuthorization()
case .restricted:
Task { @MainActor in self.permissionState = .restricted }
case .denied:
Task { @MainActor in self.permissionState = .denied }
case .authorizedAlways, .authorizedWhenInUse, .authorized:
Task { @MainActor in self.permissionState = .authorized }
requestOneShotLocation()
@unknown default:
Task { @MainActor in self.permissionState = .restricted }
}
}
func refreshChannels() {
if permissionState == .authorized {
requestOneShotLocation()
}
}
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
guard permissionState == .authorized else { return }
// Stop any previous polling timer
refreshTimer?.invalidate()
refreshTimer = nil
// Tighten accuracy and distance filter for live view
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
// Start continuous updates
cl.startUpdatingLocation()
// Request an immediate fix to populate UI without waiting for movement
requestOneShotLocation()
}
/// Stop continuous refreshes when selector UI is dismissed.
func endLiveRefresh() {
refreshTimer?.invalidate()
refreshTimer = nil
cl.stopUpdatingLocation()
// Restore more relaxed defaults for background/idle state
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
}
func select(_ channel: ChannelID) {
Task { @MainActor in
self.selectedChannel = channel
if let data = try? JSONEncoder().encode(channel) {
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
}
// Update teleported flag based on persisted state for immediate UI behavior
switch channel {
case .mesh:
self.teleported = false
case .location(let ch):
// If this geohash is in our current regional set, do NOT mark teleported.
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
// Clear persisted teleport for this geohash to keep future selections clean
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
}
}
} else {
// Fall back to persisted mark (set by deep link or manual teleport)
self.teleported = self.teleportedSet.contains(ch.geohash)
}
}
}
}
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
func markTeleported(for geohash: String, _ flag: Bool) {
if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) }
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
UserDefaults.standard.set(data, forKey: teleportedStoreKey)
}
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
Task { @MainActor in self.teleported = flag }
}
}
// MARK: - CoreLocation
private func requestOneShotLocation() {
cl.requestLocation()
}
// iOS < 14
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
updatePermissionState(from: status)
if case .authorized = permissionState {
requestOneShotLocation()
}
}
// iOS 14+ / macOS 11+
@available(iOS 14.0, macOS 11.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
updatePermissionState(from: manager.authorizationStatus)
if case .authorized = permissionState {
requestOneShotLocation()
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let loc = locations.last else { return }
lastLocation = loc
computeChannels(from: loc.coordinate)
reverseGeocodeIfNeeded(location: loc)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Surface as denied/restricted if relevant; otherwise keep previous state
SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session)
}
// MARK: - Helpers
private func updatePermissionState(from status: CLAuthorizationStatus) {
let newState: PermissionState
switch status {
case .notDetermined: newState = .notDetermined
case .restricted: newState = .restricted
case .denied: newState = .denied
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
@unknown default: newState = .restricted
}
Task { @MainActor in self.permissionState = newState }
}
private func computeChannels(from coord: CLLocationCoordinate2D) {
let levels = GeohashChannelLevel.allCases
var result: [GeohashChannel] = []
for level in levels {
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
result.append(GeohashChannel(level: level, geohash: gh))
}
Task { @MainActor in
self.availableChannels = result
// Recompute teleported status based on whether the selected geohash is in our regional set
switch self.selectedChannel {
case .mesh:
self.teleported = false
case .location(let ch):
// Membership check using freshly computed regional channels; avoids precision/rename drift
let inRegional = result.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
// Clear persisted teleport flag if present
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
}
}
} else {
self.teleported = true
}
}
}
}
private func reverseGeocodeIfNeeded(location: CLLocation) {
// Always cancel previous to keep latest fresh while user moves
geocoder.cancelGeocode()
isGeocoding = true
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
guard let self = self else { return }
self.isGeocoding = false
if let pm = placemarks?.first {
let names = self.namesByLevel(from: pm)
Task { @MainActor in self.locationNames = names }
}
}
}
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
var dict: [GeohashChannelLevel: String] = [:]
// Region (country)
if let country = pm.country, !country.isEmpty {
dict[.region] = country
}
// Province (state/province or county)
if let admin = pm.administrativeArea, !admin.isEmpty {
dict[.province] = admin
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
dict[.province] = subAdmin
}
// City (locality)
if let locality = pm.locality, !locality.isEmpty {
dict[.city] = locality
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
dict[.city] = subAdmin
} else if let admin = pm.administrativeArea, !admin.isEmpty {
dict[.city] = admin
}
// Neighborhood
if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.neighborhood] = subLocality
} else if let locality = pm.locality, !locality.isEmpty {
dict[.neighborhood] = locality
}
// Block: reuse neighborhood/locality granularity
if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.block] = subLocality
} else if let locality = pm.locality, !locality.isEmpty {
dict[.block] = locality
}
// Building: prefer place name/street/venue when available
if let name = pm.name, !name.isEmpty {
dict[.building] = name
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
dict[.building] = thoroughfare
}
return dict
}
}
#endif
+104
View File
@@ -0,0 +1,104 @@
import BitLogger
import Foundation
struct LocationNotesCounterDependencies {
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
typealias Unsubscribe = @MainActor (_ id: String) -> Void
var relayLookup: RelayLookup
var subscribe: Subscribe
var unsubscribe: Unsubscribe
static let live = LocationNotesCounterDependencies(
relayLookup: { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
},
subscribe: { filter, id, relays, handler, onEOSE in
NostrRelayManager.shared.subscribe(
filter: filter,
id: id,
relayUrls: relays,
handler: handler,
onEOSE: onEOSE
)
},
unsubscribe: { id in
NostrRelayManager.shared.unsubscribe(id: id)
}
)
}
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
@MainActor
final class LocationNotesCounter: ObservableObject {
static let shared = LocationNotesCounter()
@Published private(set) var geohash: String? = nil
@Published private(set) var count: Int? = 0
@Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var relayAvailable: Bool = true
private var subscriptionID: String? = nil
private var noteIDs = Set<String>()
private let dependencies: LocationNotesCounterDependencies
private init(dependencies: LocationNotesCounterDependencies = .live) {
self.dependencies = dependencies
}
init(testDependencies: LocationNotesCounterDependencies) {
self.dependencies = testDependencies
}
func subscribe(geohash gh: String) {
let norm = gh.lowercased()
if geohash == norm, subscriptionID != nil { return }
// Validate geohash (building-level precision: 8 chars)
guard Geohash.isValidBuildingGeohash(norm) else {
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
return
}
// Unsubscribe previous without clearing count to avoid flicker
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
subscriptionID = nil
geohash = norm
noteIDs.removeAll()
initialLoadComplete = false
relayAvailable = true
// Subscribe only to the building geohash (precision 8)
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
relayAvailable = false
initialLoadComplete = true
count = 0
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
return
}
subscriptionID = subID
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
dependencies.subscribe(filter, subID, relays, { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
if !self.noteIDs.contains(event.id) {
self.noteIDs.insert(event.id)
self.count = self.noteIDs.count
}
}, { [weak self] in
self?.initialLoadComplete = true
})
}
func cancel() {
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
subscriptionID = nil
geohash = nil
count = 0
noteIDs.removeAll()
relayAvailable = true
}
}
+7 -14
View File
@@ -63,7 +63,7 @@ final class LocationNotesManager: ObservableObject {
var displayName: String {
let suffix = String(pubkey.suffix(4))
if let nick = nickname?.trimmedOrNilIfEmpty {
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "\(nick)#\(suffix)"
}
return "anon#\(suffix)"
@@ -163,22 +163,14 @@ final class LocationNotesManager: ObservableObject {
subscriptionID = subID
initialLoadComplete = false
// Subscribe to center + 8 neighbors (± 1 grid)
let neighbors = Geohash.neighbors(of: geohash)
let allGeohashes = [geohash] + neighbors
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
// Build a set of valid geohashes for tag matching (includes all 9 cells)
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
dependencies.subscribe(filter, subID, relays, { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag - accept any of our 9 geohashes
guard event.tags.contains(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
}) else { return }
// Ensure matching tag
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
guard !self.noteIDs.contains(event.id) else { return }
self.noteIDs.insert(event.id)
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
@@ -199,7 +191,8 @@ final class LocationNotesManager: ObservableObject {
/// Send a location note for the current geohash using the per-geohash identity.
func send(content: String, nickname: String) {
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
state = .noRelays
-630
View File
@@ -1,630 +0,0 @@
import BitLogger
import Foundation
import Combine
#if os(iOS) || os(macOS)
import CoreLocation
protocol LocationStateManaging: AnyObject {
var delegate: CLLocationManagerDelegate? { get set }
var desiredAccuracy: CLLocationAccuracy { get set }
var distanceFilter: CLLocationDistance { get set }
var authorizationStatus: CLAuthorizationStatus { get }
func requestWhenInUseAuthorization()
func requestLocation()
func startUpdatingLocation()
func stopUpdatingLocation()
}
protocol LocationStateGeocoding: AnyObject {
func cancelGeocode()
func reverseGeocodeLocation(
_ location: CLLocation,
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
)
}
private final class CLLocationManagerAdapter: NSObject, LocationStateManaging {
private let base = CLLocationManager()
var delegate: CLLocationManagerDelegate? {
get { base.delegate }
set { base.delegate = newValue }
}
var desiredAccuracy: CLLocationAccuracy {
get { base.desiredAccuracy }
set { base.desiredAccuracy = newValue }
}
var distanceFilter: CLLocationDistance {
get { base.distanceFilter }
set { base.distanceFilter = newValue }
}
var authorizationStatus: CLAuthorizationStatus {
base.authorizationStatus
}
func requestWhenInUseAuthorization() {
base.requestWhenInUseAuthorization()
}
func requestLocation() {
base.requestLocation()
}
func startUpdatingLocation() {
base.startUpdatingLocation()
}
func stopUpdatingLocation() {
base.stopUpdatingLocation()
}
}
private final class CLGeocoderAdapter: LocationStateGeocoding {
private let base = CLGeocoder()
func cancelGeocode() {
base.cancelGeocode()
}
func reverseGeocodeLocation(
_ location: CLLocation,
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
) {
base.reverseGeocodeLocation(location, completionHandler: completionHandler)
}
}
/// Unified manager for location-based channel state including:
/// - CoreLocation permissions and one-shot location retrieval
/// - Geohash channel computation from coordinates
/// - Channel selection and teleport state
/// - Bookmark persistence and friendly name resolution
///
/// Consolidates LocationChannelManager + GeohashBookmarksStore into a single source of truth.
final class LocationStateManager: NSObject, CLLocationManagerDelegate, ObservableObject {
static let shared = LocationStateManager()
// MARK: - Permission State
enum PermissionState: Equatable {
case notDetermined
case denied
case restricted
case authorized
}
// MARK: - Private Properties (CoreLocation)
private let cl: LocationStateManaging
private let geocoder: LocationStateGeocoding
private var lastLocation: CLLocation?
private var refreshTimer: Timer?
private var isGeocoding: Bool = false
// MARK: - Persistence Keys
private let selectedChannelKey = "locationChannel.selected"
private let teleportedStoreKey = "locationChannel.teleportedSet"
private let bookmarksKey = "locationChannel.bookmarks"
private let bookmarkNamesKey = "locationChannel.bookmarkNames"
// MARK: - Published State (Channel)
@Published private(set) var permissionState: PermissionState = .notDetermined
@Published private(set) var availableChannels: [GeohashChannel] = []
@Published private(set) var selectedChannel: ChannelID = .mesh
@Published var teleported: Bool = false
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
// MARK: - Published State (Bookmarks)
@Published private(set) var bookmarks: [String] = []
@Published private(set) var bookmarkNames: [String: String] = [:]
// MARK: - Private State
private var teleportedSet: Set<String> = []
private var bookmarkMembership: Set<String> = []
private var resolvingNames: Set<String> = []
private let storage: UserDefaults
/// Returns true if running in test environment
private static var isRunningTests: Bool {
let env = ProcessInfo.processInfo.environment
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil ||
env["GITHUB_ACTIONS"] != nil ||
env["CI"] != nil
}
// MARK: - Initialization
private override init() {
self.storage = .standard
self.cl = CLLocationManagerAdapter()
self.geocoder = CLGeocoderAdapter()
super.init()
// Skip CoreLocation setup in test environments
guard !Self.isRunningTests else {
loadPersistedState()
return
}
cl.delegate = self
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
loadPersistedState()
initializePermissionState()
}
/// Internal initializer for testing with custom storage
init(storage: UserDefaults) {
self.storage = storage
self.cl = CLLocationManagerAdapter()
self.geocoder = CLGeocoderAdapter()
super.init()
loadPersistedState()
}
internal init(
storage: UserDefaults,
locationManager: LocationStateManaging,
geocoder: LocationStateGeocoding,
shouldInitializeCoreLocation: Bool
) {
self.storage = storage
self.cl = locationManager
self.geocoder = geocoder
super.init()
loadPersistedState()
guard shouldInitializeCoreLocation else { return }
cl.delegate = self
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
initializePermissionState()
}
private func loadPersistedState() {
// Load selected channel
if let data = storage.data(forKey: selectedChannelKey),
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
selectedChannel = channel
}
// Load teleported set
if let data = storage.data(forKey: teleportedStoreKey),
let arr = try? JSONDecoder().decode([String].self, from: data) {
teleportedSet = Set(arr)
}
// Load bookmarks
if let data = storage.data(forKey: bookmarksKey),
let arr = try? JSONDecoder().decode([String].self, from: data) {
var seen = Set<String>()
var list: [String] = []
for raw in arr {
let gh = Self.normalizeGeohash(raw)
guard !gh.isEmpty, !seen.contains(gh) else { continue }
seen.insert(gh)
list.append(gh)
}
bookmarks = list
bookmarkMembership = seen
}
// Load bookmark names
if let data = storage.data(forKey: bookmarkNamesKey),
let dict = try? JSONDecoder().decode([String: String].self, from: data) {
bookmarkNames = dict
}
}
private func initializePermissionState() {
let status = cl.authorizationStatus
updatePermissionState(from: status)
// Fall back to persisted teleport state if no location authorization
switch status {
case .authorizedAlways, .authorizedWhenInUse, .authorized:
break
case .notDetermined, .restricted, .denied:
fallthrough
@unknown default:
if case .location(let ch) = selectedChannel {
teleported = teleportedSet.contains(ch.geohash)
}
}
}
// MARK: - Public API (Permissions & Location)
func enableLocationChannels() {
let status = cl.authorizationStatus
switch status {
case .notDetermined:
cl.requestWhenInUseAuthorization()
case .restricted:
Task { @MainActor in self.permissionState = .restricted }
case .denied:
Task { @MainActor in self.permissionState = .denied }
case .authorizedAlways, .authorizedWhenInUse, .authorized:
Task { @MainActor in self.permissionState = .authorized }
requestOneShotLocation()
@unknown default:
Task { @MainActor in self.permissionState = .restricted }
}
}
func refreshChannels() {
if permissionState == .authorized {
requestOneShotLocation()
}
}
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
guard permissionState == .authorized else { return }
refreshTimer?.invalidate()
refreshTimer = nil
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
cl.startUpdatingLocation()
requestOneShotLocation()
}
func endLiveRefresh() {
refreshTimer?.invalidate()
refreshTimer = nil
cl.stopUpdatingLocation()
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
}
// MARK: - Public API (Channel Selection)
func select(_ channel: ChannelID) {
Task { @MainActor in
self.selectedChannel = channel
if let data = try? JSONEncoder().encode(channel) {
self.storage.set(data, forKey: self.selectedChannelKey)
}
switch channel {
case .mesh:
self.teleported = false
case .location(let ch):
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
self.persistTeleportedSet()
}
} else {
self.teleported = self.teleportedSet.contains(ch.geohash)
}
}
}
}
func markTeleported(for geohash: String, _ flag: Bool) {
if flag {
teleportedSet.insert(geohash)
} else {
teleportedSet.remove(geohash)
}
persistTeleportedSet()
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
Task { @MainActor in self.teleported = flag }
}
}
// MARK: - Public API (Bookmarks)
func isBookmarked(_ geohash: String) -> Bool {
bookmarkMembership.contains(Self.normalizeGeohash(geohash))
}
func toggleBookmark(_ geohash: String) {
let gh = Self.normalizeGeohash(geohash)
if bookmarkMembership.contains(gh) {
removeBookmark(gh)
} else {
addBookmark(gh)
}
}
func addBookmark(_ geohash: String) {
let gh = Self.normalizeGeohash(geohash)
guard !gh.isEmpty, !bookmarkMembership.contains(gh) else { return }
bookmarks.insert(gh, at: 0)
bookmarkMembership.insert(gh)
persistBookmarks()
resolveBookmarkNameIfNeeded(for: gh)
}
func removeBookmark(_ geohash: String) {
let gh = Self.normalizeGeohash(geohash)
guard bookmarkMembership.contains(gh) else { return }
if let idx = bookmarks.firstIndex(of: gh) {
bookmarks.remove(at: idx)
}
bookmarkMembership.remove(gh)
if bookmarkNames.removeValue(forKey: gh) != nil {
persistBookmarkNames()
}
persistBookmarks()
}
// MARK: - CLLocationManagerDelegate
private func requestOneShotLocation() {
cl.requestLocation()
}
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
updatePermissionState(from: status)
if case .authorized = permissionState {
requestOneShotLocation()
}
}
@available(iOS 14.0, macOS 11.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
updatePermissionState(from: manager.authorizationStatus)
if case .authorized = permissionState {
requestOneShotLocation()
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let loc = locations.last else { return }
lastLocation = loc
computeChannels(from: loc.coordinate)
reverseGeocodeLocation(loc)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
SecureLogger.error("LocationStateManager: location error: \(error.localizedDescription)", category: .session)
}
// MARK: - Private Helpers (Permission)
private func updatePermissionState(from status: CLAuthorizationStatus) {
let newState: PermissionState
switch status {
case .notDetermined: newState = .notDetermined
case .restricted: newState = .restricted
case .denied: newState = .denied
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
@unknown default: newState = .restricted
}
Task { @MainActor in self.permissionState = newState }
}
// MARK: - Private Helpers (Channel Computation)
private func computeChannels(from coord: CLLocationCoordinate2D) {
let levels = GeohashChannelLevel.allCases
var result: [GeohashChannel] = []
for level in levels {
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
result.append(GeohashChannel(level: level, geohash: gh))
}
Task { @MainActor in
self.availableChannels = result
switch self.selectedChannel {
case .mesh:
self.teleported = false
case .location(let ch):
let inRegional = result.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
self.persistTeleportedSet()
}
} else {
self.teleported = true
}
}
}
}
// MARK: - Private Helpers (Geocoding)
private func reverseGeocodeLocation(_ location: CLLocation) {
geocoder.cancelGeocode()
isGeocoding = true
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in
guard let self = self else { return }
self.isGeocoding = false
if let pm = placemarks?.first {
let names = self.locationNamesByLevel(from: pm)
Task { @MainActor in self.locationNames = names }
}
}
}
private func locationNamesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
var dict: [GeohashChannelLevel: String] = [:]
if let country = pm.country, !country.isEmpty {
dict[.region] = country
}
if let admin = pm.administrativeArea, !admin.isEmpty {
dict[.province] = admin
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
dict[.province] = subAdmin
}
if let locality = pm.locality, !locality.isEmpty {
dict[.city] = locality
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
dict[.city] = subAdmin
} else if let admin = pm.administrativeArea, !admin.isEmpty {
dict[.city] = admin
}
if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.neighborhood] = subLocality
} else if let locality = pm.locality, !locality.isEmpty {
dict[.neighborhood] = locality
}
if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.block] = subLocality
} else if let locality = pm.locality, !locality.isEmpty {
dict[.block] = locality
}
if let name = pm.name, !name.isEmpty {
dict[.building] = name
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
dict[.building] = thoroughfare
}
return dict
}
func resolveBookmarkNameIfNeeded(for geohash: String) {
let gh = Self.normalizeGeohash(geohash)
guard !gh.isEmpty, bookmarkNames[gh] == nil, !resolvingNames.contains(gh) else { return }
resolvingNames.insert(gh)
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),
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.resolvingNames.remove(gh) }
if let pm = placemarks?.first,
let name = Self.nameForGeohashLength(gh.count, from: pm),
!name.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = name
self.persistBookmarkNames()
}
}
}
}
}
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
var uniqueAdmins: [String] = []
var seenAdmins = Set<String>()
var idx = 0
func step() {
if idx >= points.count {
let finalName: String? = {
if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] }
return uniqueAdmins.first
}()
if let finalName = finalName, !finalName.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = finalName
self.persistBookmarkNames()
}
}
self.resolvingNames.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, !seenAdmins.contains(admin) {
seenAdmins.insert(admin)
uniqueAdmins.append(admin)
} else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) {
seenAdmins.insert(country)
uniqueAdmins.append(country)
}
}
step()
}
}
step()
}
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
switch len {
case 0...2:
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
}
}
// MARK: - Private Helpers (Persistence)
private func persistTeleportedSet() {
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
storage.set(data, forKey: teleportedStoreKey)
}
}
private func persistBookmarks() {
if let data = try? JSONEncoder().encode(bookmarks) {
storage.set(data, forKey: bookmarksKey)
}
}
private func persistBookmarkNames() {
if let data = try? JSONEncoder().encode(bookmarkNames) {
storage.set(data, forKey: bookmarkNamesKey)
}
}
private static func normalizeGeohash(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s
.trimmed
.lowercased()
.replacingOccurrences(of: "#", with: "")
.filter { allowed.contains($0) }
}
}
// MARK: - Backward Compatibility Typealiases
typealias LocationChannelManager = LocationStateManager
typealias GeohashBookmarksStore = LocationStateManager
// MARK: - Backward Compatibility Extensions
extension LocationStateManager {
/// Backward compatibility: toggle bookmark (was GeohashBookmarksStore.toggle)
func toggle(_ geohash: String) {
toggleBookmark(geohash)
}
/// Backward compatibility: add bookmark (was GeohashBookmarksStore.add)
func add(_ geohash: String) {
addBookmark(geohash)
}
/// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove)
func remove(_ geohash: String) {
removeBookmark(geohash)
}
}
#endif
-130
View File
@@ -1,130 +0,0 @@
import Foundation
/// Tracks observed mesh topology and computes hop-by-hop routes.
final class MeshTopologyTracker {
private typealias RoutingID = Data
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
private let hopSize = 8
// Directed claims: Key claims to see Value (neighbors)
private var claims: [RoutingID: Set<RoutingID>] = [:]
// Last time we received an update from a node
private var lastSeen: [RoutingID: Date] = [:]
// Maximum age for topology claims to be considered fresh for routing
// Routes computed using stale topology can fail when the network has changed
private static let routeFreshnessThreshold: TimeInterval = 60 // 60 seconds
func reset() {
queue.sync(flags: .barrier) {
self.claims.removeAll()
self.lastSeen.removeAll()
}
}
/// Update the topology with a node's self-reported neighbor list
func updateNeighbors(for sourceData: Data?, neighbors: [Data]) {
guard let source = sanitize(sourceData) else { return }
// Sanitize neighbors and exclude self-loops
let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])
queue.sync(flags: .barrier) {
self.claims[source] = validNeighbors
self.lastSeen[source] = Date()
}
}
func removePeer(_ data: Data?) {
guard let peer = sanitize(data) else { return }
queue.sync(flags: .barrier) {
self.claims.removeValue(forKey: peer)
self.lastSeen.removeValue(forKey: peer)
}
}
/// Prune nodes that haven't updated their topology in `age` seconds
func prune(olderThan age: TimeInterval) {
let deadline = Date().addingTimeInterval(-age)
queue.sync(flags: .barrier) {
let stale = self.lastSeen.filter { $0.value < deadline }
for (peer, _) in stale {
self.claims.removeValue(forKey: peer)
self.lastSeen.removeValue(forKey: peer)
}
}
}
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? {
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
if source == target { return [] } // Direct connection, no intermediate hops
return queue.sync {
let now = Date()
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
// BFS
var visited: Set<RoutingID> = [source]
// Queue stores paths: [Start, Hop1, Hop2, ..., Current]
var queuePaths: [[RoutingID]] = [[source]]
while !queuePaths.isEmpty {
let path = queuePaths.removeFirst()
// Limit path length (path contains source + maxHops + target) -> maxHops intermediate
// If maxHops = 10, max edges = 11, max nodes = 12.
if path.count > maxHops + 1 { continue }
guard let last = path.last else { continue }
// Get neighbors that 'last' claims to see
guard let neighbors = claims[last] else { continue }
// Check if 'last' node's topology info is fresh
guard let lastSeenTime = lastSeen[last], lastSeenTime > freshnessDeadline else {
continue // Skip stale nodes
}
for neighbor in neighbors {
if visited.contains(neighbor) { continue }
// CONFIRMED EDGE CHECK:
// 'last' claims 'neighbor' (checked above)
// Does 'neighbor' claim 'last'?
guard let neighborClaims = claims[neighbor],
neighborClaims.contains(last) else {
continue
}
// Check if 'neighbor' node's topology info is fresh
guard let neighborSeenTime = lastSeen[neighbor], neighborSeenTime > freshnessDeadline else {
continue // Skip edges to stale nodes
}
var nextPath = path
nextPath.append(neighbor)
if neighbor == target {
// Return only intermediate hops
// Path: [Source, I1, I2, Target] -> [I1, I2]
return Array(nextPath.dropFirst().dropLast())
}
visited.insert(neighbor)
queuePaths.append(nextPath)
}
}
return nil
}
}
// MARK: - Helpers
private func sanitize(_ data: Data?) -> Data? {
guard var value = data, !value.isEmpty else { return nil }
if value.count > hopSize {
value = Data(value.prefix(hopSize))
} else if value.count < hopSize {
value.append(Data(repeating: 0, count: hopSize - value.count))
}
return value
}
}
@@ -1,278 +0,0 @@
//
// MessageDeduplicationService.swift
// bitchat
//
// Handles message deduplication using LRU caches.
// This is free and unencumbered software released into the public domain.
//
import Foundation
// MARK: - LRU Deduplication Cache
/// Generic LRU (Least Recently Used) cache for deduplication.
/// Uses an efficient O(1) lookup with periodic compaction.
/// Thread-safe via @MainActor - all callers are already on main actor.
@MainActor
final class LRUDeduplicationCache<Value> {
private var map: [String: Value] = [:]
private var order: [String] = []
private var head: Int = 0
private let capacity: Int
/// Creates a new LRU cache with the specified capacity.
/// - Parameter capacity: Maximum number of entries before eviction
init(capacity: Int) {
precondition(capacity > 0, "LRU cache capacity must be positive")
self.capacity = capacity
}
/// Number of active entries in the cache
var count: Int {
order.count - head
}
/// Checks if a key exists in the cache
func contains(_ key: String) -> Bool {
map[key] != nil
}
/// Gets the value for a key, or nil if not present
func value(for key: String) -> Value? {
map[key]
}
/// Records a key-value pair, updating if exists or inserting if new
func record(_ key: String, value: Value) {
if map[key] == nil {
order.append(key)
}
map[key] = value
trimIfNeeded()
}
/// Removes a specific key from the cache
func remove(_ key: String) {
map.removeValue(forKey: key)
// Note: key remains in order array but will be skipped during eviction
}
/// Clears all entries from the cache
func clear() {
map.removeAll()
order.removeAll()
head = 0
}
// MARK: - Private
private func trimIfNeeded() {
let activeCount = order.count - head
guard activeCount > capacity else { return }
let overflow = activeCount - capacity
for _ in 0..<overflow {
guard let victim = popOldest() else { break }
map.removeValue(forKey: victim)
}
}
private func popOldest() -> String? {
// Skip keys that were already removed from map
while head < order.count {
let key = order[head]
head += 1
// Periodically compact the backing storage
if head >= 32 && head * 2 >= order.count {
order.removeFirst(head)
head = 0
}
// Only return if key is still in map
if map[key] != nil {
return key
}
}
return nil
}
}
// MARK: - Content Normalizer
/// Normalizes message content for near-duplicate detection.
enum ContentNormalizer {
/// Regex to simplify HTTP URLs by stripping query strings and fragments
private static let simplifyHTTPURL: NSRegularExpression = {
try! NSRegularExpression(
pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?",
options: [.caseInsensitive]
)
}()
/// Normalizes content for deduplication comparison.
/// - Parameters:
/// - content: The raw message content
/// - prefixLength: Maximum characters to consider (default from TransportConfig)
/// - Returns: A hash-based key for comparison
static func normalizedKey(
_ content: String,
prefixLength: Int = TransportConfig.contentKeyPrefixLength
) -> String {
// Lowercase for case-insensitive comparison
let lowered = content.lowercased()
let ns = lowered as NSString
let range = NSRange(location: 0, length: ns.length)
// Simplify URLs by stripping query/fragment
var simplified = ""
var last = 0
for match in simplifyHTTPURL.matches(in: lowered, options: [], range: range) {
if match.range.location > last {
simplified += ns.substring(with: NSRange(location: last, length: match.range.location - last))
}
let url = ns.substring(with: match.range)
if let queryIndex = url.firstIndex(where: { $0 == "?" || $0 == "#" }) {
simplified += String(url[..<queryIndex])
} else {
simplified += url
}
last = match.range.location + match.range.length
}
if last < ns.length {
simplified += ns.substring(with: NSRange(location: last, length: ns.length - last))
}
// Trim and collapse whitespace
let trimmed = simplified.trimmed
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
// Take prefix and hash
let prefix = String(collapsed.prefix(prefixLength))
let hash = prefix.djb2()
return String(format: "h:%016llx", hash)
}
}
// MARK: - Message Deduplication Service
/// Service that manages message deduplication using LRU caches.
/// Provides separate caches for content-based dedup and Nostr event ID dedup.
/// Thread-safe via @MainActor - all callers are already on main actor.
@MainActor
final class MessageDeduplicationService {
/// Cache for content-based near-duplicate detection
private let contentCache: LRUDeduplicationCache<Date>
/// Cache for Nostr event ID deduplication
private let nostrEventCache: LRUDeduplicationCache<Bool>
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
private let nostrAckCache: LRUDeduplicationCache<Bool>
/// Creates a new deduplication service with specified capacities.
/// - Parameters:
/// - contentCapacity: Max entries for content cache
/// - nostrEventCapacity: Max entries for Nostr event cache
init(
contentCapacity: Int = TransportConfig.contentLRUCap,
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap
) {
self.contentCache = LRUDeduplicationCache(capacity: contentCapacity)
self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
}
// MARK: - Content Deduplication
/// Records content with its timestamp for near-duplicate detection.
/// - Parameters:
/// - content: The message content
/// - timestamp: When the content was received
func recordContent(_ content: String, timestamp: Date) {
let key = ContentNormalizer.normalizedKey(content)
contentCache.record(key, value: timestamp)
}
/// Records a pre-normalized content key with its timestamp.
/// - Parameters:
/// - key: The normalized content key
/// - timestamp: When the content was received
func recordContentKey(_ key: String, timestamp: Date) {
contentCache.record(key, value: timestamp)
}
/// Gets the timestamp for previously seen content.
/// - Parameter content: The message content
/// - Returns: The timestamp when first seen, or nil if not seen
func contentTimestamp(for content: String) -> Date? {
let key = ContentNormalizer.normalizedKey(content)
return contentCache.value(for: key)
}
/// Gets the timestamp for a pre-normalized content key.
/// - Parameter key: The normalized content key
/// - Returns: The timestamp when first seen, or nil if not seen
func contentTimestamp(forKey key: String) -> Date? {
contentCache.value(for: key)
}
/// Normalizes content to a deduplication key.
/// - Parameter content: The raw content
/// - Returns: A normalized hash key
func normalizedContentKey(_ content: String) -> String {
ContentNormalizer.normalizedKey(content)
}
// MARK: - Nostr Event Deduplication
/// Checks if a Nostr event has already been processed.
/// - Parameter eventId: The event ID
/// - Returns: true if already processed
func hasProcessedNostrEvent(_ eventId: String) -> Bool {
nostrEventCache.contains(eventId)
}
/// Records a Nostr event as processed.
/// - Parameter eventId: The event ID
func recordNostrEvent(_ eventId: String) {
nostrEventCache.record(eventId, value: true)
}
// MARK: - Nostr ACK Deduplication
/// Checks if a Nostr ACK has already been processed.
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
/// - Returns: true if already processed
func hasProcessedNostrAck(_ ackKey: String) -> Bool {
nostrAckCache.contains(ackKey)
}
/// Records a Nostr ACK as processed.
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
func recordNostrAck(_ ackKey: String) {
nostrAckCache.record(ackKey, value: true)
}
/// Creates an ACK key from components.
static func ackKey(messageId: String, ackType: String, senderPubkey: String) -> String {
"\(messageId):\(ackType):\(senderPubkey)"
}
// MARK: - Clear
/// Clears all caches
func clearAll() {
contentCache.clear()
nostrEventCache.clear()
nostrAckCache.clear()
}
/// Clears only the Nostr caches (events and ACKs)
func clearNostrCaches() {
nostrEventCache.clear()
nostrAckCache.clear()
}
}
@@ -1,472 +0,0 @@
//
// MessageFormattingEngine.swift
// bitchat
//
// Handles message text formatting, including mentions, hashtags, URLs, and tokens.
// This is free and unencumbered software released into the public domain.
//
import BitFoundation
import Foundation
import SwiftUI
// MARK: - Formatting Context Protocol
/// Protocol defining the context needed for message formatting.
/// Implemented by ChatViewModel to provide runtime state.
@MainActor
protocol MessageFormattingContext: AnyObject {
/// The user's current nickname
var nickname: String { get }
/// Determines if a message was sent by the current user
func isSelfMessage(_ message: BitchatMessage) -> Bool
/// Gets the color for a message's sender
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color
/// Resolves a peer ID to a clickable URL
func peerURL(for peerID: PeerID) -> URL?
}
// MARK: - Formatting Engine
/// Handles rich text formatting for chat messages.
/// Extracts mentions, hashtags, URLs, Lightning invoices, and Cashu tokens.
final class MessageFormattingEngine {
// MARK: - Precompiled Regexes
/// Precompiled regex patterns for message content parsing
enum Patterns {
static let hashtag: NSRegularExpression = {
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
}()
static let mention: NSRegularExpression = {
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
}()
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let linkDetector: NSDataDetector? = {
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
}()
static let quickCashuPresence: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let simplifyHTTPURL: NSRegularExpression = {
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
}()
}
// MARK: - Match Types
/// Types of matches found in message content
enum MatchType: String {
case hashtag
case mention
case url
case cashu
case lightning
case bolt11
case lnurl
}
/// A match found in message content
struct ContentMatch {
let range: NSRange
let type: MatchType
}
// MARK: - Public API
/// Formats a message with rich text styling
@MainActor
static func formatMessage(
_ message: BitchatMessage,
context: MessageFormattingContext,
colorScheme: ColorScheme
) -> AttributedString {
let isDark = colorScheme == .dark
let isSelf = context.isSelfMessage(message)
// Check cache first
if let cached = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
return cached
}
var result = AttributedString()
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
// Format system messages differently
if message.sender == "system" {
result = formatSystemMessage(message, isDark: isDark)
} else {
// Format sender header
result = formatSenderHeader(
message: message,
baseColor: baseColor,
isSelf: isSelf,
context: context
)
// Format content
let contentResult = formatContent(
message.content,
baseColor: baseColor,
isSelf: isSelf,
isMentioned: message.mentions?.contains(context.nickname) ?? false
)
result.append(contentResult)
// Add timestamp
result.append(formatTimestamp(message.formattedTimestamp))
}
// Cache the result
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
return result
}
/// Formats just the message header (sender portion)
@MainActor
static func formatHeader(
_ message: BitchatMessage,
context: MessageFormattingContext,
colorScheme: ColorScheme
) -> AttributedString {
let isDark = colorScheme == .dark
let isSelf = context.isSelfMessage(message)
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
if message.sender == "system" {
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
return AttributedString(message.sender).mergingAttributes(style)
}
return formatSenderHeader(
message: message,
baseColor: baseColor,
isSelf: isSelf,
context: context
)
}
/// Extracts mentions from message content
static func extractMentions(from content: String) -> [String] {
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = Patterns.mention.matches(in: content, options: [], range: range)
return matches.compactMap { match -> String? in
guard match.numberOfRanges > 1 else { return nil }
let captureRange = match.range(at: 1)
guard let swiftRange = Range(captureRange, in: content) else { return nil }
return String(content[swiftRange])
}
}
/// Checks if content contains a Cashu token
static func containsCashuToken(_ content: String) -> Bool {
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
return Patterns.quickCashuPresence.numberOfMatches(in: content, options: [], range: range) > 0
}
// MARK: - Private Helpers
private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString {
var result = AttributedString()
let content = AttributedString("* \(message.content) *")
var contentStyle = AttributeContainer()
contentStyle.foregroundColor = Color.gray
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
result.append(content.mergingAttributes(contentStyle))
// Add timestamp
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
return result
}
@MainActor
private static func formatSenderHeader(
message: BitchatMessage,
baseColor: Color,
isSelf: Bool,
context: MessageFormattingContext
) -> AttributedString {
var result = AttributedString()
let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
let fontWeight: Font.Weight = isSelf ? .bold : .medium
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
// Make sender clickable
if let spid = message.senderPeerID, let url = context.peerURL(for: spid) {
senderStyle.link = url
}
// Build: "<@baseName#suffix> "
result.append(AttributedString("<@").mergingAttributes(senderStyle))
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
if !suffix.isEmpty {
var suffixStyle = senderStyle
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
return result
}
private static func formatContent(
_ content: String,
baseColor: Color,
isSelf: Bool,
isMentioned: Bool
) -> AttributedString {
// For very long content without special tokens, use plain formatting
let containsCashu = containsCashuToken(content)
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
}
// Find all matches
let matches = findAllMatches(in: content)
// Build formatted content
var result = AttributedString()
var lastEnd = content.startIndex
for match in matches {
guard let swiftRange = Range(match.range, in: content) else { continue }
// Add text before match
if lastEnd < swiftRange.lowerBound {
let beforeText = String(content[lastEnd..<swiftRange.lowerBound])
result.append(formatPlainText(beforeText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
}
// Add styled match
let matchText = String(content[swiftRange])
result.append(formatMatch(matchText, type: match.type, baseColor: baseColor, isSelf: isSelf))
lastEnd = swiftRange.upperBound
}
// Add remaining text
if lastEnd < content.endIndex {
let remainingText = String(content[lastEnd...])
result.append(formatPlainText(remainingText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
}
return result
}
private static func findAllMatches(in content: String) -> [ContentMatch] {
let nsContent = content as NSString
let nsLen = nsContent.length
let fullRange = NSRange(location: 0, length: nsLen)
// Quick hints to avoid unnecessary regex work
let hasMentions = content.contains("@")
let hasHashtags = content.contains("#")
let hasURLs = content.contains("://") || content.contains("www.") || content.contains("http")
let hasLightning = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
let hasCashu = content.lowercased().contains("cashu")
// Collect matches
let mentionMatches = hasMentions ? Patterns.mention.matches(in: content, options: [], range: fullRange) : []
let hashtagMatches = hasHashtags ? Patterns.hashtag.matches(in: content, options: [], range: fullRange) : []
let urlMatches = hasURLs ? (Patterns.linkDetector?.matches(in: content, options: [], range: fullRange) ?? []) : []
let cashuMatches = hasCashu ? Patterns.cashu.matches(in: content, options: [], range: fullRange) : []
let lightningMatches = hasLightning ? Patterns.lightningScheme.matches(in: content, options: [], range: fullRange) : []
let bolt11Matches = hasLightning ? Patterns.bolt11.matches(in: content, options: [], range: fullRange) : []
let lnurlMatches = hasLightning ? Patterns.lnurl.matches(in: content, options: [], range: fullRange) : []
// Build mention ranges for overlap checking
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
func overlapsMention(_ r: NSRange) -> Bool {
mentionRanges.contains { NSIntersectionRange(r, $0).length > 0 }
}
func isStandaloneHashtag(_ r: NSRange) -> Bool {
guard let swiftRange = Range(r, in: content) else { return false }
if swiftRange.lowerBound == content.startIndex { return true }
let prev = content.index(before: swiftRange.lowerBound)
return content[prev].isWhitespace || content[prev].isNewline
}
func attachedToMention(_ r: NSRange) -> Bool {
guard let swiftRange = Range(r, in: content), swiftRange.lowerBound > content.startIndex else { return false }
var i = content.index(before: swiftRange.lowerBound)
while true {
let ch = content[i]
if ch.isWhitespace || ch.isNewline { break }
if ch == "@" { return true }
if i == content.startIndex { break }
i = content.index(before: i)
}
return false
}
var allMatches: [ContentMatch] = []
// Add hashtags (excluding those attached to mentions)
for match in hashtagMatches {
let range = match.range(at: 0)
if !overlapsMention(range) && !attachedToMention(range) && isStandaloneHashtag(range) {
allMatches.append(ContentMatch(range: range, type: .hashtag))
}
}
// Add mentions
for match in mentionMatches {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .mention))
}
// Add URLs
for match in urlMatches where !overlapsMention(match.range) {
allMatches.append(ContentMatch(range: match.range, type: .url))
}
// Add Cashu tokens
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .cashu))
}
// Add Lightning scheme URLs
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lightning))
}
// Add bolt11/lnurl (avoiding overlaps with lightning scheme and URLs)
let occupied = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
func overlapsOccupied(_ r: NSRange) -> Bool {
occupied.contains { NSIntersectionRange(r, $0).length > 0 }
}
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .bolt11))
}
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lnurl))
}
// Sort by position
return allMatches.sorted { $0.range.location < $1.range.location }
}
private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString {
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
return AttributedString(content).mergingAttributes(style)
}
private static func formatPlainText(_ text: String, baseColor: Color, isSelf: Bool, isMentioned: Bool) -> AttributedString {
guard !text.isEmpty else { return AttributedString() }
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
if isMentioned {
style.font = style.font?.bold()
}
return AttributedString(text).mergingAttributes(style)
}
private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString {
var style = AttributeContainer()
switch type {
case .mention:
// Split optional '#abcd' suffix
let (baseName, suffix) = text.splitSuffix()
var result = AttributedString()
var mentionStyle = AttributeContainer()
mentionStyle.foregroundColor = .blue
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
result.append(AttributedString(baseName).mergingAttributes(mentionStyle))
if !suffix.isEmpty {
var suffixStyle = mentionStyle
suffixStyle.foregroundColor = Color.gray.opacity(0.7)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
return result
case .hashtag:
style.foregroundColor = .purple
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
case .url:
style.foregroundColor = .blue
style.font = .bitchatSystem(size: 14, design: .monospaced)
style.underlineStyle = .single
if let url = URL(string: text) {
style.link = url
}
case .cashu:
style.foregroundColor = .green
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
style.backgroundColor = Color.green.opacity(0.1)
case .lightning, .bolt11, .lnurl:
style.foregroundColor = .yellow
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
style.backgroundColor = Color.yellow.opacity(0.1)
}
return AttributedString(text).mergingAttributes(style)
}
private static func formatTimestamp(_ timestamp: String) -> AttributedString {
let text = AttributedString(" [\(timestamp)]")
var style = AttributeContainer()
style.foregroundColor = Color.gray.opacity(0.5)
style.font = .bitchatSystem(size: 10, design: .monospaced)
return text.mergingAttributes(style)
}
}
+65 -85
View File
@@ -1,28 +1,17 @@
import BitLogger
import BitFoundation
import Foundation
/// Routes messages using available transports (Mesh, Nostr, etc.)
/// Routes messages between BLE and Nostr transports
@MainActor
final class MessageRouter {
private let transports: [Transport]
private let mesh: Transport
private let nostr: NostrTransport
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
// Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage {
let content: String
let nickname: String
let messageID: String
let timestamp: Date
}
private var outbox: [PeerID: [QueuedMessage]] = [:]
// Outbox limits to prevent unbounded memory growth
private static let maxMessagesPerPeer = 100
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
init(transports: [Transport]) {
self.transports = transports
init(mesh: Transport, nostr: NostrTransport) {
self.mesh = mesh
self.nostr = nostr
self.nostr.senderPeerID = mesh.myPeerID
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -48,87 +37,89 @@ final class MessageRouter {
}
}
// MARK: - Transport Selection
private func reachableTransport(for peerID: PeerID) -> Transport? {
transports.first { $0.isPeerReachable(peerID) }
}
private func connectedTransport(for peerID: PeerID) -> Transport? {
transports.first { $0.isPeerConnected(peerID) }
}
// MARK: - Message Sending
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
let reachableMesh = mesh.isPeerReachable(peerID)
if reachableMesh {
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
// BLEService will initiate a handshake if needed and queue the message
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) {
SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else {
// Queue for later with timestamp for TTL tracking
// Queue for later (when mesh connects or Nostr mapping appears)
if outbox[peerID] == nil { outbox[peerID] = [] }
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
outbox[peerID]?.append(message)
// Enforce per-peer size limit with FIFO eviction
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
let evicted = outbox[peerID]?.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")", category: .session)
}
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
outbox[peerID]?.append((content, recipientNickname, messageID))
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", category: .session)
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
transport.sendReadReceipt(receipt, to: peerID)
} else if !transports.isEmpty {
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))", category: .session)
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
mesh.sendReadReceipt(receipt, to: peerID)
} else {
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
nostr.sendReadReceipt(receipt, to: peerID)
}
}
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendDeliveryAck(for: messageID, to: peerID)
if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
mesh.sendDeliveryAck(for: messageID, to: peerID)
} else {
nostr.sendDeliveryAck(for: messageID, to: peerID)
}
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
if let transport = connectedTransport(for: peerID) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else if let transport = reachableTransport(for: peerID) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
// Route via mesh when connected; else use Nostr
if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else {
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
}
// MARK: - Outbox Management
private func canSendViaNostr(peerID: PeerID) -> Bool {
// Two forms are supported:
// - 64-hex Noise public key (32 bytes)
// - 16-hex short peer ID (derived from Noise pubkey)
if let noiseKey = peerID.noiseKey {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
fav.peerNostrPublicKey != nil {
return true
}
} else if peerID.isShort {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
fav.peerNostrPublicKey != nil {
return true
}
}
return false
}
func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
let now = Date()
var remaining: [QueuedMessage] = []
for message in queued {
// Skip expired messages (TTL exceeded)
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8)) (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
continue
}
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
var remaining: [(content: String, nickname: String, messageID: String)] = []
// Prefer mesh if connected; else try Nostr if mapping exists
for (content, nickname, messageID) in queued {
if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) {
SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else {
remaining.append(message)
// Keep unsent items queued
remaining.append((content, nickname, messageID))
}
}
// Persist only items we could not send
if remaining.isEmpty {
outbox.removeValue(forKey: peerID)
} else {
@@ -139,15 +130,4 @@ final class MessageRouter {
func flushAllOutbox() {
for key in Array(outbox.keys) { flushOutbox(for: key) }
}
/// Periodically clean up expired messages from all outboxes
func cleanupExpiredMessages() {
let now = Date()
for peerID in Array(outbox.keys) {
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
if outbox[peerID]?.isEmpty == true {
outbox.removeValue(forKey: peerID)
}
}
}
}
+18 -80
View File
@@ -3,27 +3,6 @@ import BitLogger
import Combine
import Tor
@MainActor
protocol NetworkActivationTorControlling: AnyObject {
func setAutoStartAllowed(_ allowed: Bool)
func startIfNeeded()
func shutdownCompletely()
}
@MainActor
protocol NetworkActivationRelayControlling: AnyObject {
func connect()
func disconnect()
}
protocol NetworkActivationProxyControlling: AnyObject {
func setProxyMode(useTor: Bool)
}
extension TorManager: NetworkActivationTorControlling {}
extension NostrRelayManager: NetworkActivationRelayControlling {}
extension TorURLSession: NetworkActivationProxyControlling {}
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
/// Policy: permit start when either location permissions are authorized OR
/// there exists at least one mutual favorite. Otherwise, do not start.
@@ -38,55 +17,14 @@ final class NetworkActivationService: ObservableObject {
private var started = false
private let torPreferenceKey = "networkActivationService.userTorEnabled"
private var torAutoStartDesired: Bool = false
private let storage: UserDefaults
private let locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
private let permissionProvider: () -> LocationChannelManager.PermissionState
private let mutualFavoritesProvider: () -> Set<Data>
private let torController: NetworkActivationTorControlling
private let relayController: NetworkActivationRelayControlling
private let proxyController: NetworkActivationProxyControlling
private let notificationCenter: NotificationCenter
private init() {
storage = .standard
locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher()
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
permissionProvider = { LocationChannelManager.shared.permissionState }
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
torController = TorManager.shared
relayController = NostrRelayManager.shared
proxyController = TorURLSession.shared
notificationCenter = .default
}
internal init(
storage: UserDefaults,
locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>,
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
mutualFavoritesProvider: @escaping () -> Set<Data>,
torController: NetworkActivationTorControlling,
relayController: NetworkActivationRelayControlling,
proxyController: NetworkActivationProxyControlling,
notificationCenter: NotificationCenter = .default
) {
self.storage = storage
self.locationPermissionPublisher = locationPermissionPublisher
self.mutualFavoritesPublisher = mutualFavoritesPublisher
self.permissionProvider = permissionProvider
self.mutualFavoritesProvider = mutualFavoritesProvider
self.torController = torController
self.relayController = relayController
self.proxyController = proxyController
self.notificationCenter = notificationCenter
}
private init() {}
func start() {
guard !started else { return }
started = true
if let stored = storage.object(forKey: torPreferenceKey) as? Bool {
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
userTorEnabled = stored
} else {
userTorEnabled = true
@@ -96,16 +34,16 @@ final class NetworkActivationService: ObservableObject {
let allowed = basePolicyAllowed()
activationAllowed = allowed
torAutoStartDesired = allowed && userTorEnabled
torController.setAutoStartAllowed(torAutoStartDesired)
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
applyTorState(torDesired: torAutoStartDesired)
if allowed {
relayController.connect()
NostrRelayManager.shared.connect()
} else {
relayController.disconnect()
NostrRelayManager.shared.disconnect()
}
// React to location permission changes
locationPermissionPublisher
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reevaluate()
@@ -113,7 +51,7 @@ final class NetworkActivationService: ObservableObject {
.store(in: &cancellables)
// React to mutual favorites changes
mutualFavoritesPublisher
FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reevaluate()
@@ -124,8 +62,8 @@ final class NetworkActivationService: ObservableObject {
func setUserTorEnabled(_ enabled: Bool) {
guard enabled != userTorEnabled else { return }
userTorEnabled = enabled
storage.set(enabled, forKey: torPreferenceKey)
notificationCenter.post(
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
NotificationCenter.default.post(
name: .TorUserPreferenceChanged,
object: nil,
userInfo: ["enabled": enabled]
@@ -144,33 +82,33 @@ final class NetworkActivationService: ObservableObject {
}
if statusChanged || torChanged {
torAutoStartDesired = torDesired
torController.setAutoStartAllowed(torDesired)
TorManager.shared.setAutoStartAllowed(torDesired)
applyTorState(torDesired: torDesired)
}
if allowed {
if torChanged {
// Reset relay sockets when switching transport path (Tor direct)
relayController.disconnect()
NostrRelayManager.shared.disconnect()
}
relayController.connect()
NostrRelayManager.shared.connect()
} else if statusChanged {
relayController.disconnect()
NostrRelayManager.shared.disconnect()
}
}
private func basePolicyAllowed() -> Bool {
let permOK = permissionProvider() == .authorized
let hasMutual = !mutualFavoritesProvider().isEmpty
let permOK = LocationChannelManager.shared.permissionState == .authorized
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
return permOK || hasMutual
}
private func applyTorState(torDesired: Bool) {
proxyController.setProxyMode(useTor: torDesired)
TorURLSession.shared.setProxyMode(useTor: torDesired)
if torDesired {
torController.startIfNeeded()
TorManager.shared.startIfNeeded()
} else {
torController.shutdownCompletely()
TorManager.shared.shutdownCompletely()
}
}
}
+24 -126
View File
@@ -83,8 +83,6 @@
///
import BitLogger
import BitFoundation
import Noise
import Foundation
import CryptoKit
@@ -179,18 +177,18 @@ final class NoiseEncryptionService {
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
// Callbacks
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
// Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
serviceQueue.async(flags: .barrier) { [weak self] in
self?.onPeerAuthenticatedHandlers.append(handler)
}
}
// Legacy support - setting this will add to the handlers array
var onPeerAuthenticated: ((PeerID, String) -> Void)? {
var onPeerAuthenticated: ((String, String) -> Void)? {
get { nil } // Always return nil for backward compatibility
set {
if let handler = newValue {
@@ -202,85 +200,46 @@ final class NoiseEncryptionService {
init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
// BCH-01-009: Load or create static identity key with proper error handling
// Load or create static identity key (ONLY from keychain)
let loadedKey: Curve25519.KeyAgreement.PrivateKey
// Try to load from keychain with proper error classification
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
switch noiseKeyResult {
case .success(let identityData):
if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
// Try to load from keychain
if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"),
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
loadedKey = key
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
} else {
// Data corrupted, regenerate
SecureLogger.warning("Noise static key data corrupted, regenerating", category: .keychain)
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
}
case .itemNotFound:
// Expected case: no key exists yet, create new one
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
case .accessDenied:
// Critical error - log but proceed with ephemeral key (will be lost on restart)
SecureLogger.error(NSError(domain: "Keychain", code: -1),
context: "Keychain access denied - using ephemeral identity", category: .keychain)
// If no identity exists, create new one
else {
loadedKey = Curve25519.KeyAgreement.PrivateKey()
let keyData = loadedKey.rawRepresentation
case .deviceLocked, .authenticationFailed:
// Recoverable error - use ephemeral key and warn
SecureLogger.warning("Device locked or auth failed - using ephemeral identity until unlocked", category: .keychain)
loadedKey = Curve25519.KeyAgreement.PrivateKey()
case .otherError(let status):
// Unexpected error - log and use ephemeral key
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Unexpected keychain error - using ephemeral identity", category: .keychain)
loadedKey = Curve25519.KeyAgreement.PrivateKey()
// Save to keychain
let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey")
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
}
// Now assign the final value
self.staticIdentityKey = loadedKey
self.staticIdentityPublicKey = staticIdentityKey.publicKey
// BCH-01-009: Load or create signing key pair with proper error handling
// Load or create signing key pair
let loadedSigningKey: Curve25519.Signing.PrivateKey
let signingKeyResult = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey")
switch signingKeyResult {
case .success(let signingData):
if let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
// Try to load from keychain
if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"),
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
loadedSigningKey = key
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
} else {
// Data corrupted, regenerate
SecureLogger.warning("Ed25519 signing key data corrupted, regenerating", category: .keychain)
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
}
case .itemNotFound:
// Expected case: no key exists yet, create new one
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
case .accessDenied:
// Critical error - log but proceed with ephemeral key
SecureLogger.error(NSError(domain: "Keychain", code: -1),
context: "Keychain access denied - using ephemeral signing key", category: .keychain)
// If no signing key exists, create new one
else {
loadedSigningKey = Curve25519.Signing.PrivateKey()
let keyData = loadedSigningKey.rawRepresentation
case .deviceLocked, .authenticationFailed:
// Recoverable error - use ephemeral key and warn
SecureLogger.warning("Device locked or auth failed - using ephemeral signing key until unlocked", category: .keychain)
loadedSigningKey = Curve25519.Signing.PrivateKey()
case .otherError(let status):
// Unexpected error - log and use ephemeral key
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Unexpected keychain error - using ephemeral signing key", category: .keychain)
loadedSigningKey = Curve25519.Signing.PrivateKey()
// Save to keychain
let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
}
// Now assign the signing keys
@@ -299,56 +258,6 @@ final class NoiseEncryptionService {
startRekeyTimer()
}
// MARK: - BCH-01-009: Key Generation Helpers with Save Verification
/// Generate and save a new Noise static key, verifying the save succeeds
private static func generateAndSaveNoiseKey(keychain: KeychainManagerProtocol) -> Curve25519.KeyAgreement.PrivateKey {
let newKey = Curve25519.KeyAgreement.PrivateKey()
let keyData = newKey.rawRepresentation
// Save to keychain and verify success
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "noiseStaticKey")
switch saveResult {
case .success:
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: true)
case .duplicateItem:
// This shouldn't happen since we just tried to load, but handle it
SecureLogger.warning("Noise key already exists (race condition?)", category: .keychain)
default:
// Save failed - log but continue with the key (it will be ephemeral)
SecureLogger.error(NSError(domain: "Keychain", code: -1),
context: "Failed to persist noise static key - identity will be lost on restart",
category: .keychain)
}
return newKey
}
/// Generate and save a new Ed25519 signing key, verifying the save succeeds
private static func generateAndSaveSigningKey(keychain: KeychainManagerProtocol) -> Curve25519.Signing.PrivateKey {
let newKey = Curve25519.Signing.PrivateKey()
let keyData = newKey.rawRepresentation
// Save to keychain and verify success
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "ed25519SigningKey")
switch saveResult {
case .success:
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: true)
case .duplicateItem:
// This shouldn't happen since we just tried to load, but handle it
SecureLogger.warning("Signing key already exists (race condition?)", category: .keychain)
default:
// Save failed - log but continue with the key (it will be ephemeral)
SecureLogger.error(NSError(domain: "Keychain", code: -1),
context: "Failed to persist signing key - identity will be lost on restart",
category: .keychain)
}
return newKey
}
// MARK: - Public Interface
/// Get our static public key for sharing
@@ -619,17 +528,6 @@ final class NoiseEncryptionService {
rateLimiter.resetAll()
}
/// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake)
func clearSession(for peerID: PeerID) {
sessionManager.removeSession(for: peerID)
serviceQueue.sync(flags: .barrier) {
if let fingerprint = peerFingerprints.removeValue(forKey: peerID) {
fingerprintToPeerID.removeValue(forKey: fingerprint)
}
}
SecureLogger.debug("🔓 Cleared Noise session for \(peerID)", category: .session)
}
// MARK: - Private Helpers
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
@@ -648,7 +546,7 @@ final class NoiseEncryptionService {
// Notify all handlers about authentication
serviceQueue.async { [weak self] in
self?.onPeerAuthenticatedHandlers.forEach { handler in
handler(peerID, fingerprint)
handler(peerID.id, fingerprint)
}
}
}
+109 -172
View File
@@ -1,36 +1,9 @@
import BitLogger
import BitFoundation
import Foundation
import Combine
// Minimal Nostr transport conforming to Transport for offline sending
final class NostrTransport: Transport, @unchecked Sendable {
struct Dependencies {
let notificationCenter: NotificationCenter
let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
let currentIdentity: @MainActor () throws -> NostrIdentity?
let registerPendingGiftWrap: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
Dependencies(
notificationCenter: .default,
loadFavorites: { FavoritesPersistenceService.shared.favorites },
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
}
)
}
}
final class NostrTransport: Transport {
// Provide BLE short peer ID for BitChat embedding
var senderPeerID = PeerID(str: "")
@@ -44,63 +17,10 @@ final class NostrTransport: Transport, @unchecked Sendable {
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge
private let dependencies: Dependencies
private var favoriteStatusObserver: NSObjectProtocol?
// Reachability Cache (thread-safe)
private var reachablePeers: Set<PeerID> = []
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
@MainActor
init(
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
dependencies: Dependencies? = nil
) {
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
self.keychain = keychain
self.idBridge = idBridge
self.dependencies = dependencies ?? .live(idBridge: idBridge)
setupObservers()
// Synchronously warm the cache to avoid startup race
let favorites = self.dependencies.loadFavorites()
let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil }
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
queue.sync(flags: .barrier) {
self.reachablePeers = Set(reachable)
}
}
deinit {
if let favoriteStatusObserver {
dependencies.notificationCenter.removeObserver(favoriteStatusObserver)
}
}
private func setupObservers() {
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
forName: .favoriteStatusChanged,
object: nil,
queue: nil
) { [weak self] _ in
self?.refreshReachablePeers()
}
}
private func refreshReachablePeers() {
Task { @MainActor in
let favorites = dependencies.loadFavorites()
let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil }
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
self.queue.async(flags: .barrier) { [weak self] in
self?.reachablePeers = Set(reachable)
}
}
}
// MARK: - Transport Protocol Conformance
@@ -122,19 +42,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
func emergencyDisconnectAll() { /* no-op */ }
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
func isPeerReachable(_ peerID: PeerID) -> Bool {
queue.sync {
// Check if exact match
if reachablePeers.contains(peerID) { return true }
// Check for short ID match
if peerID.isShort {
return reachablePeers.contains(where: { $0.toShort() == peerID })
}
return false
}
}
func isPeerReachable(_ peerID: PeerID) -> Bool { false }
func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID : String] { [:] }
@@ -158,54 +66,89 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? dependencies.currentIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
// Convert recipient npub -> hex (x-only)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else {
SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
return
}
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return
}
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
return
}
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Enqueue and process with throttling to avoid relay rate limits
// Use barrier to synchronize access to readQueue
queue.async(flags: .barrier) { [weak self] in
self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
self?.processReadQueueIfNeeded()
}
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
processReadQueueIfNeeded()
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? dependencies.currentIdentity() else { return }
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
// Convert recipient npub -> hex
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch { return }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return
}
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? dependencies.currentIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))", category: .session)
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch { return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
}
@@ -217,17 +160,21 @@ extension NostrTransport {
// MARK: Geohash ACK helpers
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
@@ -235,12 +182,19 @@ extension NostrTransport {
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
Task { @MainActor in
guard !recipientHex.isEmpty else { return }
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
// Build embedded BitChat packet without recipient peer ID
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return
}
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
}
@@ -248,75 +202,58 @@ extension NostrTransport {
// MARK: - Private Helpers
extension NostrTransport {
/// Converts npub bech32 string to hex pubkey
@MainActor
private func npubToHex(_ npub: String) -> String? {
do {
let (hrp, data) = try Bech32.decode(npub)
guard hrp == "npub" else { return nil }
return data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return nil
}
}
/// Creates and sends a gift-wrapped private message event
@MainActor
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
return
}
if registerPending {
dependencies.registerPendingGiftWrap(event.id)
}
dependencies.sendEvent(event)
}
/// Must be called within a barrier on `queue`
private func processReadQueueIfNeeded() {
guard !isSendingReadAcks else { return }
guard !readQueue.isEmpty else { return }
isSendingReadAcks = true
let item = readQueue.removeFirst()
sendReadAckItem(item)
sendNextReadAck()
}
/// Sends a single read ack item (called after extraction from queue within barrier)
private func sendReadAckItem(_ item: QueuedRead) {
private func sendNextReadAck() {
guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
let item = readQueue.removeFirst()
Task { @MainActor in
defer { scheduleNextReadAck() }
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? dependencies.currentIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))", category: .session)
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { scheduleNextReadAck(); return }
recipientHex = data.hexEncodedString()
} catch { scheduleNextReadAck(); return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
return
scheduleNextReadAck(); return
}
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
scheduleNextReadAck(); return
}
SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
scheduleNextReadAck()
}
}
private func scheduleNextReadAck() {
dependencies.scheduleAfter(readAckInterval) { [weak self] in
self?.queue.async(flags: .barrier) { [weak self] in
self?.isSendingReadAcks = false
self?.processReadQueueIfNeeded()
}
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
guard let self = self else { return }
self.isSendingReadAcks = false
self.processReadQueueIfNeeded()
}
}
@MainActor
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
if let noiseKey = Data(hexString: peerID.id),
let fav = dependencies.favoriteStatusForNoiseKey(noiseKey),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let npub = fav.peerNostrPublicKey {
return npub
}
if peerID.id.count == 16,
let fav = dependencies.favoriteStatusForPeerID(peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let npub = fav.peerNostrPublicKey {
return npub
}
+30 -112
View File
@@ -6,7 +6,6 @@
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import UserNotifications
#if os(iOS)
@@ -15,103 +14,13 @@ import UIKit
import AppKit
#endif
protocol NotificationAuthorizing {
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
)
}
protocol NotificationRequestDelivering {
func add(_ request: UNNotificationRequest)
}
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter) {
self.center = center
}
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
) {
center.requestAuthorization(options: options, completionHandler: completionHandler)
}
}
private final class NotificationCenterRequestDelivererAdapter: NotificationRequestDelivering {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter) {
self.center = center
}
func add(_ request: UNNotificationRequest) {
Task {
try? await center.add(request)
}
}
}
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
) {
completionHandler(false, nil)
}
}
private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {
func add(_ request: UNNotificationRequest) {}
}
final class NotificationService {
static let shared = NotificationService()
private let isRunningTestsProvider: () -> Bool
private let authorizer: NotificationAuthorizing
private let requestDeliverer: NotificationRequestDelivering
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
private var isRunningTests: Bool {
isRunningTestsProvider()
}
private init() {
self.isRunningTestsProvider = {
let env = ProcessInfo.processInfo.environment
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil ||
env["GITHUB_ACTIONS"] != nil ||
env["CI"] != nil
}
if isRunningTestsProvider() {
self.authorizer = NoopNotificationAuthorizer()
self.requestDeliverer = NoopNotificationRequestDeliverer()
} else {
let center = UNUserNotificationCenter.current()
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
}
}
internal init(
isRunningTestsProvider: @escaping () -> Bool,
authorizer: NotificationAuthorizing,
requestDeliverer: NotificationRequestDelivering
) {
self.isRunningTestsProvider = isRunningTestsProvider
self.authorizer = authorizer
self.requestDeliverer = requestDeliverer
}
private init() {}
func requestAuthorization() {
guard !isRunningTests else { return }
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if granted {
// Permission granted
} else {
@@ -120,20 +29,14 @@ final class NotificationService {
}
}
func sendLocalNotification(
title: String,
body: String,
identifier: String,
userInfo: [String: Any]? = nil,
interruptionLevel: UNNotificationInterruptionLevel = .active
) {
guard !isRunningTests else { return }
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
// For now, skip app state check entirely to avoid thread issues
// The NotificationDelegate will handle foreground presentation
DispatchQueue.main.async {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.interruptionLevel = interruptionLevel
if let userInfo = userInfo {
content.userInfo = userInfo
}
@@ -144,7 +47,10 @@ final class NotificationService {
trigger: nil // Deliver immediately
)
requestDeliverer.add(request)
UNUserNotificationCenter.current().add(request) { _ in
// Notification added
}
}
}
func sendMentionNotification(from sender: String, message: String) {
@@ -155,11 +61,11 @@ final class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier)
}
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
let title = "🔒 DM from \(sender)"
let body = message
let identifier = "private-\(UUID().uuidString)"
let userInfo = ["peerID": peerID.id, "senderName": sender]
let userInfo = ["peerID": peerID, "senderName": sender]
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
}
@@ -176,14 +82,26 @@ final class NotificationService {
func sendNetworkAvailableNotification(peerCount: Int) {
let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
// Fixed identifier so iOS updates the existing notification instead of creating new ones
let identifier = "network-available"
let identifier = "network-available-\(Date().timeIntervalSince1970)"
sendLocalNotification(
title: title,
body: body,
// For network notifications, we want to show them even in foreground
// No app state check - let the notification delegate handle presentation
DispatchQueue.main.async {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.interruptionLevel = .timeSensitive // Make it more prominent
let request = UNNotificationRequest(
identifier: identifier,
interruptionLevel: .timeSensitive
content: content,
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request) { _ in
// Notification added
}
}
}
}
@@ -6,20 +6,10 @@
// For more information, see <https://unlicense.org>
//
import BitLogger
import BitFoundation
import Foundation
struct NotificationStreamAssembler {
private var buffer = Data()
private var pendingFrameStartedAt: DispatchTime?
private var pendingFrameExpectedLength: Int = 0
private mutating func resetState() {
buffer.removeAll(keepingCapacity: false)
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
}
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
guard !chunk.isEmpty else { return ([], [], false) }
@@ -28,117 +18,64 @@ struct NotificationStreamAssembler {
var frames: [Data] = []
var dropped: [UInt8] = []
var didReset = false
let now = DispatchTime.now()
let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes
let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize
var reset = false
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes {
SecureLogger.error("❌ Notification assembler overflow (\(buffer.count) bytes); dropping partial frame", category: .session)
resetState()
return ([], [], true)
}
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
while buffer.count >= minimumFramePrefix {
guard let version = buffer.first else { break }
guard version == 1 || version == 2 else {
while buffer.count >= minFramePrefix {
guard let first = buffer.first else { break }
if first != 1 {
dropped.append(buffer.removeFirst())
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
continue
}
guard let headerSize = BinaryProtocol.headerSize(for: version) else {
dropped.append(buffer.removeFirst())
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
continue
}
let framePrefix = headerSize + BinaryProtocol.senderIDSize
guard buffer.count >= framePrefix else { break }
guard buffer.count >= minHeaderBytes else { break }
let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags
guard flagsIndex < buffer.endIndex else { break }
let flags = buffer[flagsIndex]
let headerBytes = Array(buffer.prefix(minFramePrefix))
guard headerBytes.count == minFramePrefix else { break }
let flags = headerBytes[11]
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
let hasRoute = (version >= 2) && (flags & BinaryProtocol.Flags.hasRoute) != 0
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
let lengthOffset = 12
let payloadLength: Int
if version == 2 {
let lengthIndex = buffer.startIndex + lengthOffset
payloadLength =
(Int(buffer[lengthIndex]) << 24) |
(Int(buffer[lengthIndex + 1]) << 16) |
(Int(buffer[lengthIndex + 2]) << 8) |
Int(buffer[lengthIndex + 3])
} else {
let lengthIndex = buffer.startIndex + lengthOffset
payloadLength = (Int(buffer[lengthIndex]) << 8) | Int(buffer[lengthIndex + 1])
}
var frameLength = framePrefix + payloadLength
var frameLength = minFramePrefix + payloadLen
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
if hasSignature { frameLength += BinaryProtocol.signatureSize }
if hasRoute {
let routeCountOffset = framePrefix + (hasRecipient ? BinaryProtocol.recipientIDSize : 0)
let routeCountIndex = buffer.startIndex + routeCountOffset
guard buffer.count > routeCountOffset else { break }
let routeCount = Int(buffer[routeCountIndex])
frameLength += 1 + (routeCount * BinaryProtocol.senderIDSize)
}
if isCompressed {
let rawLengthFieldBytes = (version == 2) ? 4 : 2
if payloadLength < rawLengthFieldBytes {
SecureLogger.error("❌ Invalid compressed payload length (\(payloadLength))", category: .session)
resetState()
didReset = true
break
}
}
guard frameLength > 0, frameLength <= maxFrameLength else {
SecureLogger.error("❌ Notification frame length \(frameLength) invalid (cap=\(maxFrameLength)); resetting stream", category: .session)
resetState()
didReset = true
buffer.removeAll()
reset = true
break
}
if buffer.count < frameLength {
let remaining = frameLength - buffer.count
if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength {
pendingFrameStartedAt = now
pendingFrameExpectedLength = frameLength
} else if let started = pendingFrameStartedAt {
let elapsed = now.uptimeNanoseconds - started.uptimeNanoseconds
let threshold = UInt64(TransportConfig.bleAssemblerStallResetMs) * 1_000_000
if elapsed >= threshold {
SecureLogger.debug("📉 Resetting notification assembler after waiting \(remaining)B for \(TransportConfig.bleAssemblerStallResetMs)ms", category: .session)
resetState()
didReset = true
} else {
SecureLogger.debug("⌛ Waiting for remaining \(remaining)B to complete BLE frame", category: .session)
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
if dropCount > 0 {
buffer.removeFirst(dropCount)
dropped.append(1) // treat as dropped partial start
}
}
break
}
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
let frame = Data(buffer.prefix(frameLength))
frames.append(frame)
buffer.removeFirst(frameLength)
}
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
resetState()
buffer.removeAll(keepingCapacity: false)
}
return (frames, dropped, didReset)
return (frames, dropped, reset)
}
mutating func reset() {
buffer.removeAll(keepingCapacity: false)
}
}
+1 -156
View File
@@ -7,7 +7,6 @@
//
import BitLogger
import BitFoundation
import Foundation
import SwiftUI
@@ -23,8 +22,6 @@ final class PrivateChatManager: ObservableObject {
weak var meshService: Transport?
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
weak var messageRouter: MessageRouter?
// Peer service for looking up peer info during consolidation
weak var unifiedPeerService: UnifiedPeerService?
init(meshService: Transport? = nil) {
self.meshService = meshService
@@ -33,158 +30,6 @@ final class PrivateChatManager: ObservableObject {
// Cap for messages stored per private chat
private let privateChatCap = TransportConfig.privateChatCap
// MARK: - Message Consolidation
/// Consolidates messages from different peer ID representations into a single chat.
/// This ensures messages from stable Noise keys and temporary Nostr peer IDs are merged.
/// - Parameters:
/// - peerID: The target peer ID to consolidate messages into
/// - peerNickname: The peer's display name (lowercased for matching)
/// - persistedReadReceipts: The persisted read receipts set from ChatViewModel (UserDefaults-backed)
/// - Returns: True if any unread messages were found during consolidation
@MainActor
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {
guard let meshService = meshService else { return false }
var hasUnreadMessages = false
// 1. Consolidate from stable Noise key (64-char hex)
if let peer = unifiedPeerService?.getPeer(by: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
for message in nostrMessages {
if !existingMessageIds.contains(message.id) {
// Update senderPeerID for correct read receipts
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
privateChats[peerID]?.append(updatedMessage)
// Check for recent unread messages (< 60s, not sent by us, not already read)
// Use persistedReadReceipts to correctly identify already-read messages after app restart
if message.senderPeerID != meshService.myPeerID {
let messageAge = Date().timeIntervalSince(message.timestamp)
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
hasUnreadMessages = true
}
}
}
}
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
if hasUnreadMessages {
unreadMessages.insert(peerID)
} else if unreadMessages.contains(noiseKeyHex) {
unreadMessages.remove(noiseKeyHex)
}
privateChats.removeValue(forKey: noiseKeyHex)
}
}
// 2. Consolidate from temporary Nostr peer IDs (nostr_* prefixed)
let normalizedNickname = peerNickname.lowercased()
var tempPeerIDsToConsolidate: [PeerID] = []
for (storedPeerID, messages) in privateChats {
if storedPeerID.isGeoDM && storedPeerID != peerID {
let nicknamesMatch = messages.allSatisfy { $0.sender.lowercased() == normalizedNickname }
if nicknamesMatch && !messages.isEmpty {
tempPeerIDsToConsolidate.append(storedPeerID)
}
}
}
if !tempPeerIDsToConsolidate.isEmpty {
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
var consolidatedCount = 0
var hadUnreadTemp = false
for tempPeerID in tempPeerIDsToConsolidate {
if unreadMessages.contains(tempPeerID) {
hadUnreadTemp = true
}
if let tempMessages = privateChats[tempPeerID] {
for message in tempMessages {
if !existingMessageIds.contains(message.id) {
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
privateChats[peerID]?.append(updatedMessage)
consolidatedCount += 1
}
}
privateChats.removeValue(forKey: tempPeerID)
unreadMessages.remove(tempPeerID)
}
}
if hadUnreadTemp {
unreadMessages.insert(peerID)
hasUnreadMessages = true
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
}
if consolidatedCount > 0 {
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
}
}
return hasUnreadMessages
}
/// Syncs the read receipt tracking between manager and view model for sent messages
@MainActor
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
guard let messages = privateChats[peerID] else { return }
for message in messages {
if message.sender == nickname {
if let status = message.deliveryStatus {
switch status {
case .read, .delivered:
externalReceipts.insert(message.id)
sentReadReceipts.insert(message.id)
case .failed, .partiallyDelivered, .sending, .sent:
break
}
}
}
}
}
/// Start a private chat with a peer
func startChat(with peerID: PeerID) {
selectedPeer = peerID
@@ -260,7 +105,7 @@ final class PrivateChatManager: ObservableObject {
// Create read receipt using the simplified method
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: meshService?.myPeerID ?? PeerID(str: ""),
readerID: meshService?.myPeerID.id ?? "",
readerNickname: meshService?.myNickname ?? ""
)
-11
View File
@@ -13,7 +13,6 @@ struct RelayController {
senderIsSelf: Bool,
isEncrypted: Bool,
isDirectedEncrypted: Bool,
isFragment: Bool,
isDirectedFragment: Bool,
isHandshake: Bool,
isAnnounce: Bool,
@@ -37,16 +36,6 @@ struct RelayController {
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
}
if isFragment {
let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)
guard ttlLimit > 1 else {
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
}
let newTTL = ttlLimit &- 1
let delayMs = Int.random(in: TransportConfig.bleFragmentRelayMinDelayMs...TransportConfig.bleFragmentRelayMaxDelayMs)
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
}
// TTL clamping for broadcast
// - Dense graphs: keep lower but still allow multi-hop bridging
// - Announces get a bit more headroom
@@ -1,65 +0,0 @@
import Foundation
import Combine
/// Centralized progress bus for Bluetooth file transfers.
/// Emits Combine events consumed by ChatViewModel to update UI progress indicators.
final class TransferProgressManager {
static let shared = TransferProgressManager()
enum Event {
case started(id: String, totalFragments: Int)
case updated(id: String, sentFragments: Int, totalFragments: Int)
case completed(id: String, totalFragments: Int)
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
}
private let subject = PassthroughSubject<Event, Never>()
private let queue = DispatchQueue(label: "com.bitchat.transfer-progress", attributes: .concurrent)
private var states: [String: (sent: Int, total: Int)] = [:]
var publisher: AnyPublisher<Event, Never> {
subject.eraseToAnyPublisher()
}
func start(id: String, totalFragments: Int) {
queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self.states[id] = (sent: 0, total: totalFragments)
self.subject.send(.started(id: id, totalFragments: totalFragments))
}
}
func recordFragmentSent(id: String) {
queue.async(flags: .barrier) { [weak self] in
guard let self = self, var state = self.states[id] else { return }
state.sent = min(state.sent + 1, state.total)
self.states[id] = state
self.subject.send(.updated(id: id, sentFragments: state.sent, totalFragments: state.total))
if state.sent >= state.total {
self.states.removeValue(forKey: id)
self.subject.send(.completed(id: id, totalFragments: state.total))
}
}
}
func cancel(id: String) {
queue.async(flags: .barrier) { [weak self] in
guard let self = self, let state = self.states.removeValue(forKey: id) else { return }
self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total))
}
}
func reset(id: String) {
queue.async(flags: .barrier) { [weak self] in
self?.states.removeValue(forKey: id)
}
}
func snapshot(id: String) -> (sent: Int, total: Int)? {
var result: (sent: Int, total: Int)?
queue.sync {
result = states[id]
}
return result
}
}
-19
View File
@@ -1,4 +1,3 @@
import BitFoundation
import Foundation
import Combine
@@ -46,38 +45,20 @@ protocol Transport: AnyObject {
// Messaging
func sendMessage(_ content: String, mentions: [String])
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func cancelTransfer(_ transferId: String)
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// Pending file management (BCH-01-002: files held in memory until user accepts)
func acceptPendingFile(id: String) -> URL?
func declinePendingFile(id: String)
}
extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sendMessage(content, mentions: mentions)
}
func acceptPendingFile(id: String) -> URL? { nil }
func declinePendingFile(id: String) {}
}
protocol TransportPeerEventsDelegate: AnyObject {
+9 -42
View File
@@ -8,10 +8,6 @@ enum TransportConfig {
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods
// UI / Storage Caps
static let privateChatCap: Int = 1337
@@ -21,7 +17,6 @@ enum TransportConfig {
// Timers
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
static let networkNotificationCooldownSeconds: TimeInterval = 300 // 5 minutes
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
// BLE duty/announce/connect
@@ -35,11 +30,7 @@ enum TransportConfig {
static let bleDynamicRSSIThresholdDefault: Int = -90
static let bleConnectionCandidatesMax: Int = 100
static let blePendingWriteBufferCapBytes: Int = 1_000_000
static let bleNotificationAssemblerHardCapBytes: Int = 8 * 1024 * 1024
static let bleAssemblerStallResetMs: Int = 250
static let blePendingNotificationsCapCount: Int = 128
static let bleNotificationRetryDelayMs: Int = 25
static let bleNotificationRetryMaxAttempts: Int = 80
static let blePendingNotificationsCapCount: Int = 20
// Nostr
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
@@ -71,7 +62,6 @@ enum TransportConfig {
static let uiAnimationMediumSeconds: TimeInterval = 0.2
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0
// BLE maintenance & thresholds
static let bleMaintenanceInterval: TimeInterval = 5.0
@@ -97,12 +87,11 @@ enum TransportConfig {
// Keep scanning fully ON when we saw traffic very recently
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
static let bleExpectedWritePerFragmentMs: Int = 20
static let bleExpectedWriteMaxMs: Int = 5000
// Fragment pacing: Conservative spacing to prevent BLE buffer overflow
// Aggressive pacing causes packet loss; needs 25-30ms between fragments for reliable delivery
static let bleFragmentSpacingMs: Int = 30
static let bleFragmentSpacingDirectedMs: Int = 25
static let bleExpectedWritePerFragmentMs: Int = 8
static let bleExpectedWriteMaxMs: Int = 2000
// Faster fragment pacing; use slightly tighter spacing for directed trains
static let bleFragmentSpacingMs: Int = 5
static let bleFragmentSpacingDirectedMs: Int = 4
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
static let bleDutyOnDurationDense: TimeInterval = 3.0
static let bleDutyOffDurationDense: TimeInterval = 15.0
@@ -133,6 +122,9 @@ enum TransportConfig {
static let nostrShortKeyDisplayLength: Int = 8
static let nostrConvKeyPrefixLength: Int = 16
// Compression
static let compressionThresholdBytes: Int = 100
// Message deduplication
static let messageDedupMaxAgeSeconds: TimeInterval = 300
static let messageDedupMaxCount: Int = 1000
@@ -149,9 +141,6 @@ enum TransportConfig {
// Geo relay directory
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
static let geoRelayRefreshCheckIntervalSeconds: TimeInterval = 60 * 60
static let geoRelayRetryInitialSeconds: TimeInterval = 60
static let geoRelayRetryMaxSeconds: TimeInterval = 60 * 60
// BLE operational delays
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
@@ -161,14 +150,6 @@ enum TransportConfig {
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
// BCH-01-004: Rate-limiting for subscription-triggered announces
// Prevents rapid enumeration attacks by rate-limiting announce responses
static let bleSubscriptionRateLimitMinSeconds: TimeInterval = 2.0 // Minimum interval between announces per central
static let bleSubscriptionRateLimitBackoffFactor: Double = 2.0 // Exponential backoff multiplier
static let bleSubscriptionRateLimitMaxBackoffSeconds: TimeInterval = 30.0 // Maximum backoff period
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
// Store-and-forward for directed packets at relays
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
@@ -210,18 +191,4 @@ enum TransportConfig {
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
// Gossip Sync Configuration
static let syncSeenCapacity: Int = 1000
static let syncGCSMaxBytes: Int = 400
static let syncGCSTargetFpr: Double = 0.01
static let syncMaxMessageAgeSeconds: TimeInterval = 900
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
static let syncFragmentCapacity: Int = 600
static let syncFileTransferCapacity: Int = 200
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
static let syncMessageIntervalSeconds: TimeInterval = 15.0
}
@@ -7,7 +7,6 @@
//
import BitLogger
import BitFoundation
import Foundation
import Combine
import SwiftUI
+83 -215
View File
@@ -1,6 +1,4 @@
import Foundation
import BitLogger
import BitFoundation
// Gossip-based sync manager using on-demand GCS filters
final class GossipSyncManager {
@@ -8,56 +6,6 @@ final class GossipSyncManager {
func sendPacket(_ packet: BitchatPacket)
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
func getConnectedPeers() -> [PeerID]
}
private struct PacketStore {
private(set) var packets: [String: BitchatPacket] = [:]
private(set) var order: [String] = []
mutating func insert(idHex: String, packet: BitchatPacket, capacity: Int) {
guard capacity > 0 else { return }
if packets[idHex] != nil {
packets[idHex] = packet
return
}
packets[idHex] = packet
order.append(idHex)
while order.count > capacity {
let victim = order.removeFirst()
packets.removeValue(forKey: victim)
}
}
func allPackets(isFresh: (BitchatPacket) -> Bool) -> [BitchatPacket] {
order.compactMap { key in
guard let packet = packets[key], isFresh(packet) else { return nil }
return packet
}
}
mutating func remove(where shouldRemove: (BitchatPacket) -> Bool) {
var nextOrder: [String] = []
for key in order {
guard let packet = packets[key] else { continue }
if shouldRemove(packet) {
packets.removeValue(forKey: key)
} else {
nextOrder.append(key)
}
}
order = nextOrder
}
mutating func removeExpired(isFresh: (BitchatPacket) -> Bool) {
remove { !isFresh($0) }
}
}
private struct SyncSchedule {
let types: SyncTypeFlags
let interval: TimeInterval
var lastSent: Date
}
struct Config {
@@ -68,45 +16,25 @@ final class GossipSyncManager {
var maintenanceIntervalSeconds: TimeInterval = 30.0
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
var stalePeerTimeoutSeconds: TimeInterval = 60.0
var fragmentCapacity: Int = 600
var fileTransferCapacity: Int = 200
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
var messageSyncIntervalSeconds: TimeInterval = 15.0
}
private let myPeerID: PeerID
private let config: Config
private let requestSyncManager: RequestSyncManager
weak var delegate: Delegate?
// Storage: broadcast packets by type, and latest announce per sender
private var messages = PacketStore()
private var fragments = PacketStore()
private var fileTransfers = PacketStore()
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
// Storage: broadcast messages (ordered by insert), and latest announce per sender
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
private var messageOrder: [String] = []
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
// Timer
private var periodicTimer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
private var lastStalePeerCleanup: Date = .distantPast
private var syncSchedules: [SyncSchedule] = []
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
init(myPeerID: PeerID, config: Config = Config()) {
self.myPeerID = myPeerID
self.config = config
self.requestSyncManager = requestSyncManager
var schedules: [SyncSchedule] = []
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
}
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
}
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
}
syncSchedules = schedules
}
func start() {
@@ -127,18 +55,7 @@ final class GossipSyncManager {
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
guard let self = self else { return }
self.sendRequestSync(to: peerID, types: .publicMessages)
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.sendRequestSync(to: peerID, types: .fragment)
}
}
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.sendRequestSync(to: peerID, types: .fileTransfer)
}
}
self?.sendRequestSync(to: peerID)
}
}
@@ -170,58 +87,47 @@ final class GossipSyncManager {
}
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
guard let messageType = MessageType(rawValue: packet.type) else { return }
let mt = MessageType(rawValue: packet.type)
let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true }
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
}()
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
let isAnnounce = (mt == .announce)
guard isBroadcastMessage || isAnnounce else { return }
switch messageType {
case .announce:
// Reject expired packets to prevent ghost peers and old messages
guard isPacketFresh(packet) else { return }
if isAnnounce {
guard isAnnouncementFresh(packet) else {
let sender = PeerID(hexData: packet.senderID)
removeState(for: sender)
let sender = packet.senderID.hexEncodedString().lowercased()
removeState(forNormalizedPeerID: sender)
return
}
}
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
let sender = PeerID(hexData: packet.senderID)
if isBroadcastMessage {
if messages[idHex] == nil {
messages[idHex] = packet
messageOrder.append(idHex)
// Enforce capacity
let cap = max(1, config.seenCapacity)
while messageOrder.count > cap {
let victim = messageOrder.removeFirst()
messages.removeValue(forKey: victim)
}
}
} else if isAnnounce {
let sender = packet.senderID.hexEncodedString().lowercased()
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
case .message:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
case .fragment:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
fragments.insert(idHex: idHex, packet: packet, capacity: max(1, config.fragmentCapacity))
case .fileTransfer:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
default:
break
}
}
private func sendPeriodicSync(for types: SyncTypeFlags) {
// Unicast sync to connected peers to allow RSR attribution
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
for peerID in connectedPeers {
sendRequestSync(to: peerID, types: types)
}
} else {
// Fallback to broadcast (discovery phase)
sendRequestSync(for: types)
}
}
private func sendRequestSync(for types: SyncTypeFlags) {
let payload = buildGcsPayload(for: types)
private func sendRequestSync() {
let payload = buildGcsPayload()
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID.id) ?? Data(),
@@ -235,11 +141,8 @@ final class GossipSyncManager {
delegate?.sendPacket(signed)
}
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
// Register the request for RSR validation
requestSyncManager.registerRequest(to: peerID)
let payload = buildGcsPayload(for: types)
private func sendRequestSync(to peerID: PeerID) {
let payload = buildGcsPayload()
var recipient = Data()
var temp = peerID.id
while temp.count >= 2 && recipient.count < 8 {
@@ -267,7 +170,6 @@ final class GossipSyncManager {
}
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
let requestedTypes = (request.types ?? .publicMessages)
// Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool {
@@ -275,7 +177,7 @@ final class GossipSyncManager {
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
}
if requestedTypes.contains(.announce) {
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
@@ -283,96 +185,52 @@ final class GossipSyncManager {
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
if requestedTypes.contains(.message) {
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
// 2) Broadcast messages: send all missing (and not expired)
let toSendMsgs = messageOrder.compactMap { messages[$0] }
for pkt in toSendMsgs {
guard isPacketFresh(pkt) else { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
if requestedTypes.contains(.fragment) {
let frags = fragments.allPackets(isFresh: isPacketFresh)
for pkt in frags {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
if requestedTypes.contains(.fileTransfer) {
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
for pkt in files {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
}
// Build REQUEST_SYNC payload using current candidates and GCS params
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
private func buildGcsPayload() -> Data {
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
var candidates: [BitchatPacket] = []
if types.contains(.announce) {
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
for (_, pair) in latestAnnouncementByPeer {
if isPacketFresh(pair.packet) {
candidates.append(pair.packet)
}
}
if types.contains(.message) {
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
for id in messageOrder {
if let p = messages[id], isPacketFresh(p) {
candidates.append(p)
}
if types.contains(.fragment) {
candidates.append(contentsOf: fragments.allPackets(isFresh: isPacketFresh))
}
if types.contains(.fileTransfer) {
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
}
if candidates.isEmpty {
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
return req.encode()
}
// Sort by timestamp desc
candidates.sort { $0.timestamp > $1.timestamp }
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
let cap: Int
if types == .fragment {
cap = max(1, config.fragmentCapacity)
} else if types == .fileTransfer {
cap = max(1, config.fileTransferCapacity)
} else {
cap = max(1, config.seenCapacity)
}
let cap = max(1, config.seenCapacity)
let takeN = min(candidates.count, min(nMax, cap))
if takeN <= 0 {
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
let req = RequestSyncPacket(p: p, m: 1, data: Data())
return req.encode()
}
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
return req.encode()
}
@@ -383,23 +241,20 @@ final class GossipSyncManager {
isPacketFresh(pair.packet)
}
messages.removeExpired(isFresh: isPacketFresh)
fragments.removeExpired(isFresh: isPacketFresh)
fileTransfers.removeExpired(isFresh: isPacketFresh)
// Remove expired messages
let expiredMessageIds = messages.compactMap { id, pkt in
isPacketFresh(pkt) ? nil : id
}
for id in expiredMessageIds {
messages.removeValue(forKey: id)
messageOrder.removeAll { $0 == id }
}
}
private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now)
requestSyncManager.cleanup() // Cleanup expired sync requests
for index in syncSchedules.indices {
guard syncSchedules[index].interval > 0 else { continue }
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
syncSchedules[index].lastSent = now
sendPeriodicSync(for: syncSchedules[index].types)
}
}
sendRequestSync()
}
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
@@ -415,27 +270,40 @@ final class GossipSyncManager {
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
guard nowMs >= timeoutMs else { return }
let cutoff = nowMs - timeoutMs
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
pair.packet.timestamp < cutoff ? peerID : nil
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
}
guard !stalePeerIDs.isEmpty else { return }
for peerKey in stalePeerIDs {
removeState(for: peerKey)
removeState(forNormalizedPeerID: peerKey)
}
}
// Explicit removal hook for LEAVE/stale peer
func removeAnnouncementForPeer(_ peerID: PeerID) {
queue.async { [weak self] in
self?.removeState(for: peerID)
self?._removeAnnouncementForPeer(peerID)
}
}
private func removeState(for peerID: PeerID) {
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
messages.remove { PeerID(hexData: $0.senderID) == peerID }
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
let normalizedPeerID = peerID.id.lowercased()
removeState(forNormalizedPeerID: normalizedPeerID)
}
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
// Remove messages from this peer
// Collect IDs to remove first to avoid concurrent modification
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
message.senderID.hexEncodedString().lowercased() == normalizedPeerID ? id : nil
}
// Remove messages and update messageOrder
for id in messageIdsToRemove {
messages.removeValue(forKey: id)
messageOrder.removeAll { $0 == id }
}
}
}
@@ -449,13 +317,13 @@ extension GossipSyncManager {
func _hasAnnouncement(for peerID: PeerID) -> Bool {
queue.sync {
latestAnnouncementByPeer[peerID] != nil
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
}
}
func _messageCount(for peerID: PeerID) -> Int {
queue.sync {
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
}
}
}
-1
View File
@@ -1,4 +1,3 @@
import struct BitFoundation.BitchatPacket
import Foundation
import CryptoKit
-86
View File
@@ -1,86 +0,0 @@
//
// RequestSyncManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import BitLogger
import BitFoundation
/// Manages outgoing sync requests and validates incoming responses.
///
/// Allows attributing RSR (Request-Sync Response) packets to specific peers
/// that we have actively requested sync from.
final class RequestSyncManager {
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
private var pendingRequests: [PeerID: TimeInterval] = [:]
private let responseWindow: TimeInterval
private let now: () -> TimeInterval
init(
responseWindow: TimeInterval = 30.0,
now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }
) {
self.responseWindow = responseWindow
self.now = now
}
/// Register that we are sending a sync request to a peer.
/// - Parameter peerID: The peer we are requesting sync from
func registerRequest(to peerID: PeerID) {
let now = self.now()
queue.async(flags: .barrier) {
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))", category: .sync)
self.pendingRequests[peerID] = now
}
}
/// Check if a packet from a peer is a valid response to a sync request.
///
/// - Parameters:
/// - peerID: The sender of the packet
/// - isRSR: Whether the packet is marked as a Request-Sync Response
/// - Returns: true if we have a pending request for this peer and the window is open
func isValidResponse(from peerID: PeerID, isRSR: Bool) -> Bool {
guard isRSR else { return false }
return queue.sync {
guard let requestTime = pendingRequests[peerID] else {
SecureLogger.warning("Received unsolicited RSR packet from \(peerID.id.prefix(8))", category: .security)
return false
}
let now = self.now()
if now - requestTime > responseWindow {
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
// We don't remove here because we might receive multiple packets for one request
return false
}
return true
}
}
/// Periodic cleanup of expired requests
func cleanup() {
let now = self.now()
queue.async(flags: .barrier) {
let originalCount = self.pendingRequests.count
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
now - timestamp <= self.responseWindow
}
let removed = originalCount - self.pendingRequests.count
if removed > 0 {
SecureLogger.debug("Cleaned up \(removed) expired sync requests", category: .sync)
}
}
}
var debugPendingRequestCount: Int {
queue.sync { pendingRequests.count }
}
}
-105
View File
@@ -1,105 +0,0 @@
import BitFoundation
import Foundation
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
/// Matches the Android mapping (bit index -> message type).
struct SyncTypeFlags: OptionSet {
let rawValue: UInt64
init(rawValue: UInt64) {
self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes
}
private static func bitIndex(for type: MessageType) -> Int? {
switch type {
case .announce: return 0
case .message: return 1
case .leave: return 2
case .noiseHandshake: return 3
case .noiseEncrypted: return 4
case .fragment: return 5
case .requestSync: return 6
case .fileTransfer: return 7
}
}
private static func type(forBit index: Int) -> MessageType? {
switch index {
case 0: return .announce
case 1: return .message
case 2: return .leave
case 3: return .noiseHandshake
case 4: return .noiseEncrypted
case 5: return .fragment
case 6: return .requestSync
case 7: return .fileTransfer
default:
return nil
}
}
static let announce = SyncTypeFlags(messageTypes: [.announce])
static let message = SyncTypeFlags(messageTypes: [.message])
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
init(messageTypes: [MessageType]) {
var raw: UInt64 = 0
for type in messageTypes {
guard let bit = SyncTypeFlags.bitIndex(for: type) else { continue }
raw |= (1 << UInt64(bit))
}
self.init(rawValue: raw)
}
func contains(_ type: MessageType) -> Bool {
guard let bit = SyncTypeFlags.bitIndex(for: type) else { return false }
return contains(SyncTypeFlags(rawValue: 1 << UInt64(bit)))
}
func union(_ other: SyncTypeFlags) -> SyncTypeFlags {
SyncTypeFlags(rawValue: rawValue | other.rawValue)
}
func intersection(_ other: SyncTypeFlags) -> SyncTypeFlags {
SyncTypeFlags(rawValue: rawValue & other.rawValue)
}
func toMessageTypes() -> [MessageType] {
guard rawValue != 0 else { return [] }
var types: [MessageType] = []
for bit in 0..<64 {
guard (rawValue & (1 << UInt64(bit))) != 0 else { continue }
if let type = SyncTypeFlags.type(forBit: bit) {
types.append(type)
}
}
return types
}
func toData() -> Data? {
guard rawValue != 0 else { return nil }
var value = rawValue
var bytes: [UInt8] = []
while value > 0 && bytes.count < 8 {
bytes.append(UInt8(value & 0xFF))
value >>= 8
}
while let last = bytes.last, last == 0 {
bytes.removeLast()
}
guard !bytes.isEmpty, bytes.count <= 8 else { return nil }
return Data(bytes)
}
static func decode(_ data: Data) -> SyncTypeFlags? {
guard (1...8).contains(data.count) else { return nil }
var raw: UInt64 = 0
for (index, byte) in data.enumerated() {
raw |= UInt64(byte) << UInt64(index * 8)
}
return SyncTypeFlags(rawValue: raw)
}
}
@@ -6,12 +6,12 @@
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
private import Compression
import Foundation
import Compression
struct CompressionUtil {
// Compression threshold - don't compress if data is smaller than this
static let compressionThreshold = Constants.compressionThresholdBytes // bytes
static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes
// Compress data using zlib algorithm (most compatible)
static func compress(_ data: Data) -> Data? {
@@ -62,12 +62,14 @@ struct CompressionUtil {
// 2. Data appears to be already compressed (high entropy)
guard data.count >= compressionThreshold else { return false }
// Quick uniqueness check a high diversity of bytes usually means the
// payload is already compressed. We only need to know how many unique
// values exist rather than keeping full frequency counts.
let uniqueByteCount = Set(data).count
let sampleSize = min(data.count, 256)
let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize)
// Simple entropy check - count unique bytes
var byteFrequency = [UInt8: Int]()
for byte in data {
byteFrequency[byte, default: 0] += 1
}
// If we have very high byte diversity, data is likely already compressed
let uniqueByteRatio = Double(byteFrequency.count) / Double(min(data.count, 256))
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
}
}
+22
View File
@@ -0,0 +1,22 @@
//
// Data+SHA256.swift
// bitchat
//
// Created by Islam on 26/09/2025.
//
import struct Foundation.Data
import struct CryptoKit.SHA256
extension Data {
/// Returns the hex representation of SHA256 hash
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
sha256Hash().hexEncodedString()
}
/// Returns the SHA256 hash wrapped in Data
func sha256Hash() -> Data {
Data(SHA256.hash(data: self))
}
}
+22 -22
View File
@@ -1,5 +1,4 @@
import Foundation
import BitLogger
/// Comprehensive input validation for BitChat protocol
/// Prevents injection attacks, buffer overflows, and malformed data
@@ -17,26 +16,29 @@ struct InputValidator {
// MARK: - String Content Validation
/// Validates and sanitizes user-provided strings used in UI
///
/// Rejects strings containing control characters to prevent potential security issues
/// and UI rendering problems. This strict approach ensures data integrity at input time.
static func validateUserString(_ string: String, maxLength: Int) -> String? {
guard let trimmed = string.trimmedOrNilIfEmpty, trimmed.count <= maxLength else { return nil }
// Check empty
guard !string.isEmpty else { return nil }
// Reject control characters outright instead of rewriting the string.
// This prevents injection attacks and ensures consistent UI rendering.
// Trim whitespace
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
// Check length
guard trimmed.count <= maxLength else { return nil }
// Remove control characters
let controlChars = CharacterSet.controlCharacters
if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) {
// Log rejection for monitoring, without exposing actual content for privacy
let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count
SecureLogger.debug(
"Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))",
category: .security
)
return nil
}
let cleaned = trimmed.components(separatedBy: controlChars).joined()
return trimmed
// Ensure valid UTF-8 (should already be, but double-check)
guard cleaned.data(using: .utf8) != nil else { return nil }
// Prevent zero-width characters and other invisible unicode
let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}")
let visible = cleaned.components(separatedBy: invisibleChars).joined()
return visible.isEmpty ? nil : visible
}
/// Validates nickname
@@ -50,13 +52,11 @@ struct InputValidator {
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
/// Validates timestamp is reasonable (not too far in past or future)
/// BCH-01-011: Reduced from ±1 hour to ±5 minutes to limit replay attack window
static func validateTimestamp(_ timestamp: Date) -> Bool {
let now = Date()
// 5 minutes = 300 seconds (industry standard for replay protection)
let fiveMinutesAgo = now.addingTimeInterval(-300)
let fiveMinutesFromNow = now.addingTimeInterval(300)
return timestamp >= fiveMinutesAgo && timestamp <= fiveMinutesFromNow
let oneHourAgo = now.addingTimeInterval(-3600)
let oneHourFromNow = now.addingTimeInterval(3600)
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
}
}
+43 -90
View File
@@ -2,114 +2,68 @@ import Foundation
// MARK: - Message Deduplicator (shared)
/// Thread-safe deduplicator with LRU eviction and time-based expiry.
/// Used for both message ID deduplication (network layer) and content key deduplication (UI layer).
final class MessageDeduplicator {
private struct Entry: Equatable {
let id: String
private struct Entry {
let messageID: String
let timestamp: Date
}
private var entries: [Entry] = []
private var head: Int = 0
private var lookup: [String: Date] = [:] // id -> timestamp for O(1) lookup
private var lookup = Set<String>()
private let lock = NSLock()
private let maxAge: TimeInterval
private let maxCount: Int
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
private let maxCount = TransportConfig.messageDedupMaxCount
/// Initialize with default config from TransportConfig
convenience init() {
self.init(
maxAge: TransportConfig.messageDedupMaxAgeSeconds,
maxCount: TransportConfig.messageDedupMaxCount
)
}
/// Initialize with custom config for content deduplication
init(maxAge: TimeInterval, maxCount: Int) {
self.maxAge = maxAge
self.maxCount = maxCount
}
/// Check if message is duplicate and add if not.
/// - Parameter id: The message identifier to check.
/// - Returns: `true` if the message was already seen, `false` otherwise.
func isDuplicate(_ id: String) -> Bool {
/// Check if message is duplicate and add if not
func isDuplicate(_ messageID: String) -> Bool {
lock.lock()
defer { lock.unlock() }
let now = Date()
cleanupOldEntries(before: now.addingTimeInterval(-maxAge))
cleanupOldEntries()
if lookup[id] != nil {
if lookup.contains(messageID) {
return true
}
entries.append(Entry(id: id, timestamp: now))
lookup[id] = now
trimIfNeeded()
return false
}
/// Record an ID with a specific timestamp (for content key tracking)
func record(_ id: String, timestamp: Date) {
lock.lock()
defer { lock.unlock() }
if lookup[id] == nil {
entries.append(Entry(id: id, timestamp: timestamp))
}
lookup[id] = timestamp
trimIfNeeded()
}
/// Add an ID without checking (for announce-back tracking)
func markProcessed(_ id: String) {
lock.lock()
defer { lock.unlock() }
if lookup[id] == nil {
let now = Date()
entries.append(Entry(id: id, timestamp: now))
lookup[id] = now
}
}
/// Check if ID exists without adding
func contains(_ id: String) -> Bool {
lock.lock()
defer { lock.unlock() }
return lookup[id] != nil
}
/// Get timestamp for an ID (for content deduplication time-window checks)
func timestampFor(_ id: String) -> Date? {
lock.lock()
defer { lock.unlock() }
return lookup[id]
}
private func trimIfNeeded() {
let activeCount = entries.count - head
guard activeCount > maxCount else { return }
// Remove down to 75% of maxCount for better amortization
let targetCount = (maxCount * 3) / 4
let removeCount = activeCount - targetCount
entries.append(Entry(messageID: messageID, timestamp: Date()))
lookup.insert(messageID)
// Soft-cap and advance head by a chunk to avoid O(n) shifting
if (entries.count - head) > maxCount {
let removeCount = min(100, entries.count - head)
for i in head..<(head + removeCount) {
lookup.removeValue(forKey: entries[i].id)
lookup.remove(entries[i].messageID)
}
head += removeCount
// Compact when head exceeds half the array to reclaim memory
// Periodically compact to reclaim memory
if head > entries.count / 2 {
entries.removeFirst(head)
head = 0
}
}
return false
}
/// Add an ID without checking (for announce-back tracking)
func markProcessed(_ messageID: String) {
lock.lock()
defer { lock.unlock() }
if !lookup.contains(messageID) {
entries.append(Entry(messageID: messageID, timestamp: Date()))
lookup.insert(messageID)
}
}
/// Check if ID exists without adding
func contains(_ messageID: String) -> Bool {
lock.lock()
defer { lock.unlock() }
return lookup.contains(messageID)
}
/// Clear all entries
func reset() {
lock.lock()
@@ -120,25 +74,24 @@ final class MessageDeduplicator {
lookup.removeAll()
}
/// Periodic cleanup of expired entries and memory optimization.
/// Periodic cleanup
func cleanup() {
lock.lock()
defer { lock.unlock() }
cleanupOldEntries(before: Date().addingTimeInterval(-maxAge))
cleanupOldEntries()
// Shrink capacity if significantly oversized
if entries.capacity > maxCount * 2 && entries.count < maxCount {
if entries.capacity > maxCount * 2 {
entries.reserveCapacity(maxCount)
}
}
private func cleanupOldEntries(before cutoff: Date) {
private func cleanupOldEntries() {
let cutoff = Date().addingTimeInterval(-maxAge)
while head < entries.count, entries[head].timestamp < cutoff {
lookup.removeValue(forKey: entries[head].id)
lookup.remove(entries[head].messageID)
head += 1
}
// Compact when head exceeds half the array
if head > 0 && head > entries.count / 2 {
entries.removeFirst(head)
head = 0
@@ -1,5 +1,4 @@
import Foundation
import BitFoundation
/// Resolves a stable display name for peers, adding a short suffix when collisions exist.
struct PeerDisplayNameResolver {
-44
View File
@@ -1,44 +0,0 @@
//
// SystemSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
enum SystemSettings {
case bluetooth
case location
case microphone
#if os(macOS)
private static let baseURL = "x-apple.systempreferences:com.apple.preference.security"
private var macPrivacyAnchor: String {
switch self {
case .bluetooth: "Privacy_Bluetooth"
case .location: "Privacy_LocationServices"
case .microphone: "Privacy_Microphone"
}
}
#endif
func open() {
#if os(iOS)
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
#elseif os(macOS)
let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
#endif
}
}
File diff suppressed because it is too large Load Diff
@@ -1,850 +0,0 @@
//
// ChatViewModel+Nostr.swift
// bitchat
//
// Geohash and Nostr logic for ChatViewModel
//
import Foundation
import Combine
import BitLogger
import BitFoundation
import SwiftUI
import Tor
extension ChatViewModel {
// MARK: - Geohash Subscription
// Resubscribe to the active geohash channel without clearing timeline
@MainActor
func resubscribeCurrentGeohash() {
guard case .location(let ch) = activeChannel else { return }
guard let subID = geoSubscriptionID else {
// No existing subscription; set it up
switchLocationChannel(to: activeChannel)
return
}
// Ensure participant decay timer is running
participantTracker.startRefreshTimer()
// Unsubscribe + resubscribe
NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral(
ch.geohash,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
limit: TransportConfig.nostrGeohashInitialLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: ch.geohash,
count: TransportConfig.nostrGeoRelayCount
)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
self?.subscribeNostrEvent(event)
}
// Resubscribe geohash DMs for this identity
if let dmSub = geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil
}
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
let dmSub = "geo-dm-\(ch.geohash)"
geoDmSubscriptionID = dmSub
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
self?.subscribeGiftWrap(giftWrap, id: id)
}
}
}
func subscribeNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!deduplicationService.hasProcessedNostrEvent(event.id)
else {
return
}
deduplicationService.recordNostrEvent(event.id)
if let gh = currentGeohash,
let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 {
return
}
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmed
geoNicknames[event.pubkey.lowercased()] = nick
}
// Store mapping for geohash sender IDs used in messages (ensures consistent colors)
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
// Update participants last-seen for this pubkey
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
// If presence heartbeat (Kind 20001), stop here - no content to display
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
}
// Track teleported tag (only our format ["t","teleport"]) for icon state
let hasTeleportTag = event.tags.contains(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
})
if hasTeleportTag {
let key = event.pubkey.lowercased()
// Do not mark our own key from historical events; rely on manager.teleported for self
let isSelf: Bool = {
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == key
}
return false
}()
if !isSelf {
Task { @MainActor in
teleportedGeo = teleportedGeo.union([key])
}
}
}
let senderName = displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmed
// Clamp future timestamps to now to avoid future-dated messages skewing order
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
let mentions = parseMentions(from: content)
let msg = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: timestamp,
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor in
// BCH-01-012: Check blocking before any notifications
// handlePublicMessage has its own blocking check but returns silently,
// so we must also guard checkForMentions to prevent notification bypass
let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
handlePublicMessage(msg)
// Only check mentions and send haptic if sender is not blocked
if !isBlocked {
checkForMentions(msg)
sendHapticFeedback(for: msg)
}
}
}
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
deduplicationService.recordNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload)
else {
return
}
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = PeerID(nostr_: senderPubkey)
nostrKeyMapping[convKey] = senderPubkey
switch noisePayload.type {
case .privateMessage:
handlePrivateMessage(noisePayload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
case .delivered:
handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
// QR verification payloads over Nostr are not supported; ignore in geohash DMs
break
}
}
// MARK: - Geohash Channel Handling
@MainActor
func switchLocationChannel(to channel: ChannelID) {
// Reset pending public batches to avoid cross-channel bleed
publicMessagePipeline.reset()
activeChannel = channel
publicMessagePipeline.updateActiveChannel(channel)
// Reset deduplication set and optionally hydrate timeline for mesh
deduplicationService.clearNostrCaches()
switch channel {
case .mesh:
refreshVisibleMessages(from: .mesh)
// Debug: log if any empty messages are present
let emptyMesh = messages.filter { $0.content.trimmed.isEmpty }.count
if emptyMesh > 0 {
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
}
participantTracker.stopRefreshTimer()
participantTracker.setActiveGeohash(nil)
teleportedGeo.removeAll()
case .location:
refreshVisibleMessages(from: channel)
}
// If switching to a location channel, flush any pending geohash-only system messages
if case .location = channel {
for content in timelineStore.drainPendingGeohashSystemMessages() {
addPublicSystemMessage(content)
}
}
// Unsubscribe previous
if let sub = geoSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub)
geoSubscriptionID = nil
}
if let dmSub = geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
geoDmSubscriptionID = nil
}
currentGeohash = nil
participantTracker.setActiveGeohash(nil)
// Reset nickname cache for geochat participants
geoNicknames.removeAll()
guard case .location(let ch) = channel else { return }
currentGeohash = ch.geohash
participantTracker.setActiveGeohash(ch.geohash)
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
let key = id.publicKeyHex.lowercased()
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
teleportedGeo = teleportedGeo.union([key])
SecureLogger.info("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
} else {
teleportedGeo.remove(key)
}
}
let subID = "geo-\(ch.geohash)"
geoSubscriptionID = subID
participantTracker.startRefreshTimer()
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
self?.handleNostrEvent(event)
}
subscribeToGeoChat(ch)
}
func handleNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return }
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
// Deduplicate
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
deduplicationService.recordNostrEvent(event.id)
// Log incoming tags for diagnostics
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
// If this pubkey is blocked, skip mapping, participants, and timeline
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
return
}
// Track teleport tag for participants only our format ["t", "teleport"]
let hasTeleportTag: Bool = event.tags.contains { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
}
let isSelf: Bool = {
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
}
return false
}()
if hasTeleportTag {
// Avoid marking our own key from historical events; rely on manager.teleported for self
if !isSelf {
let key = event.pubkey.lowercased()
Task { @MainActor in
teleportedGeo = teleportedGeo.union([key])
SecureLogger.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
}
}
}
// Update participants last-seen for this pubkey
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
// Skip only very recent self-echo from relay; include older self events for hydration
if isSelf {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 {
return
}
}
// Cache nickname from tag if present
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed
}
// Store mapping for geohash DM initiation
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
// If presence heartbeat (Kind 20001), stop here - no content to display
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
}
let senderName = displayNameForNostrPubkey(event.pubkey)
let content = event.content
// If this is a teleport presence event (no content), don't add to timeline
if let teleTag = event.tags.first(where: { $0.first == "t" }),
teleTag.count >= 2,
teleTag[1] == "teleport",
content.trimmed.isEmpty {
return
}
// Clamp future timestamps
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let mentions = parseMentions(from: content)
let msg = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: min(rawTs, Date()),
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor in
handlePublicMessage(msg)
checkForMentions(msg)
sendHapticFeedback(for: msg)
}
}
@MainActor
func subscribeToGeoChat(_ ch: GeohashChannel) {
guard let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
let dmSub = "geo-dm-\(ch.geohash)"
geoDmSubscriptionID = dmSub
// pared back logging: subscribe debug only
// Log GeoDM subscribe only when Tor is ready to avoid early noise
if TorManager.shared.isReady {
SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
}
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
self?.handleGiftWrap(giftWrap, id: id)
}
}
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
return
}
deduplicationService.recordNostrEvent(giftWrap.id)
// Decrypt with per-geohash identity
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
return
}
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
let convKey = PeerID(nostr_: senderPubkey)
nostrKeyMapping[convKey] = senderPubkey
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
case .delivered:
handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
// Explicitly list other cases so we get compile-time check if a new case is added in the future
case .verifyChallenge, .verifyResponse:
break
}
}
@MainActor
func sendGeohash(context: GeoOutgoingContext) {
let ch = context.channel
let event = context.event
let identity = context.identity
let targetRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: ch.geohash,
count: TransportConfig.nostrGeoRelayCount
)
if targetRelays.isEmpty {
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
// Track ourselves as active participant
participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session)
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
// Only when not in our regional set (and regional list is known)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
if context.teleported && hasRegional && !inRegional {
let key = identity.publicKeyHex.lowercased()
teleportedGeo = teleportedGeo.union([key])
SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
}
deduplicationService.recordNostrEvent(event.id)
}
// MARK: - Sampling
/// Begin sampling multiple geohashes (used by channel sheet) without changing active channel.
@MainActor
func beginGeohashSampling(for geohashes: [String]) {
// Disable sampling when app is backgrounded (Tor is stopped there)
if !TorManager.shared.isForeground() {
endGeohashSampling()
return
}
// Determine which to add and which to remove
let desired = Set(geohashes)
let current = Set(geoSamplingSubs.values)
let toAdd = desired.subtracting(current)
let toRemove = current.subtracting(desired)
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
NostrRelayManager.shared.unsubscribe(id: subID)
geoSamplingSubs.removeValue(forKey: subID)
}
for gh in toAdd {
subscribe(gh)
}
}
@MainActor
func subscribe(_ gh: String) {
let subID = "geo-sample-\(gh)"
geoSamplingSubs[subID] = gh
let filter = NostrFilter.geohashEphemeral(
gh,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
limit: TransportConfig.nostrGeohashSampleLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
self?.subscribeNostrEvent(event, gh: gh)
}
}
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
// Compute current participant count (5-minute window) BEFORE updating with this event
let existingCount = participantTracker.participantCount(for: gh)
// Update participants for this specific geohash
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
// Notify only on rising-edge: previously zero people, now someone sends a chat
guard let content = event.content.trimmedOrNilIfEmpty else { return }
// Respect geohash blocks
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
// Skip self identity for this geohash
if let my = try? idBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
// Only trigger when there were zero participants in this geohash recently
guard existingCount == 0 else { return }
// Avoid notifications for old sampled events when launching or (re)subscribing
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) > 30 { return }
// Foreground-only notifications: app must be active, and not already viewing this geohash
#if os(iOS)
guard UIApplication.shared.applicationState == .active else { return }
if case .location(let ch) = activeChannel, ch.geohash == gh { return }
#elseif os(macOS)
guard NSApplication.shared.isActive else { return }
if case .location(let ch) = activeChannel, ch.geohash == gh { return }
#endif
cooldownPerGeohash(gh, content: content, event: event)
}
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
let now = Date()
let last = lastGeoNotificationAt[gh] ?? .distantPast
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
// Compose a short preview
let preview: String = {
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
if content.count <= maxLen { return content }
let idx = content.index(content.startIndex, offsetBy: maxLen)
return String(content[..<idx]) + ""
}()
Task { @MainActor in
lastGeoNotificationAt[gh] = now
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
let senderSuffix = String(event.pubkey.suffix(4))
let nick = geoNicknames[event.pubkey.lowercased()]
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
// Clamp future timestamps
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let ts = min(rawTs, Date())
let mentions = self.parseMentions(from: content)
let msg = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: ts,
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
if timelineStore.appendIfAbsent(msg, toGeohash: gh) {
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
}
}
}
/// Stop sampling all extra geohashes.
@MainActor
func endGeohashSampling() {
for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) }
geoSamplingSubs.removeAll()
}
// MARK: - Nostr DM Handling
func setupNostrMessageHandling() {
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else {
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
return
}
SecureLogger.debug("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", category: .session)
// Subscribe to Nostr messages
let filter = NostrFilter.giftWrapsFor(
pubkey: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours
)
nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
self?.handleNostrMessage(event)
}
}
func handleNostrMessage(_ giftWrap: NostrEvent) {
// Deduplicate messages by ID
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return }
deduplicationService.recordNostrEvent(giftWrap.id)
// Ensure we're on a background queue for decryption
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processNostrMessage(giftWrap)
}
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
do {
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: currentIdentity
)
// Handle verification payloads first
if content.hasPrefix("verify:") {
// Ignore verification payloads arriving via Nostr path for now
// Verification should ideally happen over mesh for security binding
return
}
// Check if it's a BitChat packet embedded in the content (bitchat1:...)
if content.hasPrefix("bitchat1:") {
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return
}
// Map sender by Nostr pubkey to Noise key when possible
let actualSenderNoiseKey = findNoiseKey(for: senderPubkey)
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
if packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
// Store Nostr mapping
await MainActor.run {
nostrKeyMapping[targetPeerID] = senderPubkey
// Handle packet types
switch payload.type {
case .privateMessage:
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
case .delivered:
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse:
break
}
}
}
} else {
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
}
} catch {
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
}
}
func findNoiseKey(for nostrPubkey: String) -> Data? {
// Check favorites for this Nostr key
let favorites = FavoritesPersistenceService.shared.favorites.values
var npubToMatch = nostrPubkey
// Convert hex to npub if needed for comparison
if !nostrPubkey.hasPrefix("npub") {
if let pubkeyData = Data(hexString: nostrPubkey),
let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) {
npubToMatch = encoded
} else {
SecureLogger.warning("⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", category: .session)
}
}
for relationship in favorites {
// Search through favorites for matching Nostr pubkey
if let storedNostrKey = relationship.peerNostrPublicKey {
// Compare against stored key (could be hex or npub)
if storedNostrKey == npubToMatch {
// SecureLogger.debug(" Found Noise key for Nostr sender (npub match)", category: .session)
return relationship.peerNoisePublicKey
}
// Also try comparing raw hex if stored key is hex
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
return relationship.peerNoisePublicKey
}
}
}
SecureLogger.debug("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", category: .session)
return nil
}
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) {
// If we have a Noise key, try to route securely if possible, otherwise fallback to direct
if let _ = key {
// Ideally we would use MessageRouter here, but for simplicity in this direct callback:
// check if we have an identity
if let id = try? idBridge.getCurrentNostrIdentity() {
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
}
} else if let id = try? idBridge.getCurrentNostrIdentity() {
// Fallback: no Noise mapping yet send directly to sender's Nostr pubkey
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))", category: .session)
}
// Same for READ receipt if viewing
if !wasReadBefore && selectedPrivateChatPeer == message.senderPeerID {
if let _ = key {
if let id = try? idBridge.getCurrentNostrIdentity() {
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
}
} else if let id = try? idBridge.getCurrentNostrIdentity() {
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))", category: .session)
}
}
}
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
// Try to find Noise key associated with this Nostr pubkey
guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return }
let isFavorite = content.contains("FAVORITE:TRUE")
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
// Update favorite status
if isFavorite {
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: senderNoiseKey,
peerNostrPublicKey: nostrPubkey,
peerNickname: senderNickname
)
} else {
// Only remove if we don't have it set locally
// Logic handled by persistence service usually, here we just update remote state
// Actually for now we just process the notification
}
// Extract Nostr public key if included
var extractedNostrPubkey: String? = nil
if let range = content.range(of: "NPUB:") {
let suffix = content[range.upperBound...]
let parts = suffix.components(separatedBy: "|")
if let key = parts.first {
extractedNostrPubkey = String(key)
}
} else if content.contains(":") {
// Fallback: simple format FAVORITE:TRUE:npub...
let parts = content.components(separatedBy: ":")
if parts.count >= 3 {
extractedNostrPubkey = String(parts[2])
}
}
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
// If they favorited us and provided their Nostr key, ensure it's stored
if isFavorite && extractedNostrPubkey != nil {
SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", category: .session)
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: senderNoiseKey,
peerNostrPublicKey: extractedNostrPubkey,
peerNickname: senderNickname
)
}
// Show notification
NotificationService.shared.sendLocalNotification(
title: isFavorite ? "New Favorite" : "Favorite Removed",
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
identifier: "fav-\(UUID().uuidString)"
)
}
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
// Find peer Nostr key
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
relationship.peerNostrPublicKey != nil else {
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session)
return
}
let peerID = PeerID(hexData: noisePublicKey)
// Route via message router
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
guard content.hasPrefix("bitchat1:") else { return nil }
let encoded = String(content.dropFirst("bitchat1:".count))
let maxBytes = FileTransferLimits.maxFramedFileBytes
// Base64url length upper bound for maxBytes (padded length; unpadded is <= this).
let maxEncoded = ((maxBytes + 2) / 3) * 4
guard encoded.count <= maxEncoded else { return nil }
guard let packetData = Self.base64URLDecode(encoded),
packetData.count <= maxBytes
else { return nil }
return BitchatPacket.from(packetData)
}
// MARK: - Geohash Nickname Resolution (for /block in geohash)
func nostrPubkeyForDisplayName(_ name: String) -> String? {
// Look up current visible geohash participants for an exact displayName match
for p in visibleGeohashPeople() {
if p.displayName == name {
return p.id
}
}
// Also check nickname cache directly
for (pub, nick) in geoNicknames {
if nick == name { return pub }
}
return nil
}
func startGeohashDM(withPubkeyHex hex: String) {
let convKey = PeerID(nostr_: hex)
nostrKeyMapping[convKey] = hex
startPrivateChat(with: convKey)
}
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
return nostrKeyMapping[senderID]
}
func geohashDisplayName(for convKey: PeerID) -> String {
guard let full = nostrKeyMapping[convKey] else {
return convKey.bare
}
return displayNameForNostrPubkey(full)
}
}
File diff suppressed because it is too large Load Diff
@@ -1,64 +0,0 @@
//
// ChatViewModel+Tor.swift
// bitchat
//
// Tor lifecycle handling for ChatViewModel
//
import Foundation
import Combine
import Tor
extension ChatViewModel {
// MARK: - Tor notifications
@objc func handleTorWillStart() {
Task { @MainActor in
if !self.torStatusAnnounced && TorManager.shared.torEnforced {
self.torStatusAnnounced = true
// Post only in geohash channels (queue if not active)
self.addGeohashOnlySystemMessage(
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
)
}
}
}
@objc func handleTorWillRestart() {
Task { @MainActor in
self.torRestartPending = true
// Post only in geohash channels (queue if not active)
self.addGeohashOnlySystemMessage(
String(localized: "system.tor.restarting", comment: "System message when Tor is restarting")
)
}
}
@objc func handleTorDidBecomeReady() {
Task { @MainActor in
// Only announce "restarted" if we actually restarted this session
if self.torRestartPending {
// Post only in geohash channels (queue if not active)
self.addGeohashOnlySystemMessage(
String(localized: "system.tor.restarted", comment: "System message when Tor has restarted")
)
self.torRestartPending = false
} else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced {
// Initial start completed
self.addGeohashOnlySystemMessage(
String(localized: "system.tor.started", comment: "System message when Tor has started")
)
self.torInitialReadyAnnounced = true
}
}
}
@objc func handleTorPreferenceChanged(_ notification: Notification) {
Task { @MainActor in
self.torStatusAnnounced = false
self.torInitialReadyAnnounced = false
self.torRestartPending = false
}
}
}
-9
View File
@@ -1,9 +0,0 @@
# ChatViewModel Extensions
This directory contains extensions to `ChatViewModel` to modularize its functionality.
- `ChatViewModel+Tor.swift`: Handles Tor lifecycle events and notifications.
- `ChatViewModel+PrivateChat.swift`: Manages private chat logic, media transfers (images, voice notes), and file handling.
- `ChatViewModel+Nostr.swift`: Contains all logic related to Nostr integration, Geohash channels, and Nostr identity management.
The main `ChatViewModel.swift` retains core state, initialization, and coordination logic.
@@ -1,118 +0,0 @@
//
// GeoChannelCoordinator.swift
// bitchat
//
// Centralizes Combine wiring for location channel selection and sampling.
//
import Combine
import Foundation
import Tor
@MainActor
final class GeoChannelCoordinator {
private let locationManager: LocationChannelManager
private let bookmarksStore: GeohashBookmarksStore
private let torManager: TorManager
private let onChannelSwitch: (ChannelID) -> Void
private let beginSampling: ([String]) -> Void
private let endSampling: () -> Void
private var cancellables = Set<AnyCancellable>()
private var regionalGeohashes: [String] = []
private var bookmarkedGeohashes: [String] = []
init(
locationManager: LocationChannelManager? = nil,
bookmarksStore: GeohashBookmarksStore? = nil,
torManager: TorManager? = nil,
onChannelSwitch: @escaping (ChannelID) -> Void,
beginSampling: @escaping ([String]) -> Void,
endSampling: @escaping () -> Void
) {
self.locationManager = locationManager ?? Self.defaultLocationManager()
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
self.torManager = torManager ?? Self.defaultTorManager()
self.onChannelSwitch = onChannelSwitch
self.beginSampling = beginSampling
self.endSampling = endSampling
start()
}
func start() {
regionalGeohashes = locationManager.availableChannels.map { $0.geohash }
bookmarkedGeohashes = bookmarksStore.bookmarks
locationManager.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
guard let self else { return }
Task { @MainActor in
self.onChannelSwitch(channel)
}
}
.store(in: &cancellables)
locationManager.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] channels in
guard let self else { return }
self.regionalGeohashes = channels.map { $0.geohash }
self.updateSampling()
}
.store(in: &cancellables)
bookmarksStore.$bookmarks
.receive(on: DispatchQueue.main)
.sink { [weak self] bookmarks in
guard let self else { return }
self.bookmarkedGeohashes = bookmarks
self.updateSampling()
}
.store(in: &cancellables)
locationManager.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self, state == .authorized else { return }
Task { @MainActor [weak self] in
self?.locationManager.refreshChannels()
}
}
.store(in: &cancellables)
Task { @MainActor in
self.onChannelSwitch(self.locationManager.selectedChannel)
}
updateSampling()
}
private func updateSampling() {
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
Task { @MainActor in
guard !union.isEmpty else {
endSampling()
return
}
if torManager.isForeground() {
beginSampling(union)
} else {
endSampling()
}
}
}
func refreshSampling() {
updateSampling()
}
private static func defaultLocationManager() -> LocationChannelManager {
LocationChannelManager.shared
}
@MainActor
private static func defaultTorManager() -> TorManager {
TorManager.shared
}
}
@@ -1,77 +0,0 @@
//
// MessageRateLimiter.swift
// bitchat
//
// Handles per-sender and per-content token buckets for public message intake.
//
import Foundation
struct MessageRateLimiter {
private struct TokenBucket {
var capacity: Double
var tokens: Double
var refillPerSec: Double
var lastRefill: Date
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
let dt = now.timeIntervalSince(lastRefill)
if dt > 0 {
tokens = min(capacity, tokens + dt * refillPerSec)
lastRefill = now
}
if tokens >= cost {
tokens -= cost
return true
}
return false
}
}
private var senderBuckets: [String: TokenBucket] = [:]
private var contentBuckets: [String: TokenBucket] = [:]
private let senderCapacity: Double
private let senderRefill: Double
private let contentCapacity: Double
private let contentRefill: Double
init(
senderCapacity: Double,
senderRefillPerSec: Double,
contentCapacity: Double,
contentRefillPerSec: Double
) {
self.senderCapacity = senderCapacity
self.senderRefill = senderRefillPerSec
self.contentCapacity = contentCapacity
self.contentRefill = contentRefillPerSec
}
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill,
lastRefill: now
)
let senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
capacity: contentCapacity,
tokens: contentCapacity,
refillPerSec: contentRefill,
lastRefill: now
)
let contentAllowed = contentBucket.allow(now: now)
contentBuckets[contentKey] = contentBucket
return senderAllowed && contentAllowed
}
mutating func reset() {
senderBuckets.removeAll()
contentBuckets.removeAll()
}
}
@@ -1,210 +0,0 @@
//
// MinimalDistancePalette.swift
// bitchat
//
// Lightweight palette generator that keeps peer colors evenly spaced.
//
import Foundation
import SwiftUI
final class MinimalDistancePalette {
struct Config {
let slotCount: Int
let avoidCenterHue: Double
let avoidHueDelta: Double
let saturationLight: Double
let saturationDark: Double
let baseBrightnessLight: Double
let baseBrightnessDark: Double
let ringBrightnessDeltaLight: Double
let ringBrightnessDeltaDark: Double
let preferredBiasWeight: Double
let goldenStep: Int
init(
slotCount: Int,
avoidCenterHue: Double,
avoidHueDelta: Double,
saturationLight: Double,
saturationDark: Double,
baseBrightnessLight: Double,
baseBrightnessDark: Double,
ringBrightnessDeltaLight: Double,
ringBrightnessDeltaDark: Double,
preferredBiasWeight: Double = 0.05,
goldenStep: Int = 7
) {
self.slotCount = slotCount
self.avoidCenterHue = avoidCenterHue
self.avoidHueDelta = avoidHueDelta
self.saturationLight = saturationLight
self.saturationDark = saturationDark
self.baseBrightnessLight = baseBrightnessLight
self.baseBrightnessDark = baseBrightnessDark
self.ringBrightnessDeltaLight = ringBrightnessDeltaLight
self.ringBrightnessDeltaDark = ringBrightnessDeltaDark
self.preferredBiasWeight = preferredBiasWeight
self.goldenStep = goldenStep
}
}
private struct Entry {
let slot: Int
let ring: Int
let hue: Double
}
private let config: Config
private var currentSeeds: [String: String] = [:]
private var entries: [String: Entry] = [:]
private var previousEntries: [String: Entry] = [:]
init(config: Config) {
self.config = config
}
@MainActor
func ensurePalette(for seeds: [String: String]) {
guard seeds != currentSeeds || entries.count != seeds.count else { return }
previousEntries = entries
currentSeeds = seeds
rebuildEntries()
}
@MainActor
func color(for identifier: String, isDark: Bool) -> Color? {
guard let entry = entries[identifier] else { return nil }
let saturation = isDark ? config.saturationDark : config.saturationLight
let baseBrightness = isDark ? config.baseBrightnessDark : config.baseBrightnessLight
let ringDelta = isDark ? config.ringBrightnessDeltaDark : config.ringBrightnessDeltaLight
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(entry.ring)))
return Color(hue: entry.hue, saturation: saturation, brightness: brightness)
}
@MainActor
func reset() {
currentSeeds.removeAll()
entries.removeAll()
previousEntries.removeAll()
}
@MainActor
private func rebuildEntries() {
guard !currentSeeds.isEmpty else {
entries.removeAll()
return
}
let slotCount = max(8, config.slotCount)
var slots: [Double] = []
for idx in 0..<slotCount {
let hue = Double(idx) / Double(slotCount)
if abs(hue - config.avoidCenterHue) < config.avoidHueDelta {
continue
}
slots.append(hue)
}
if slots.isEmpty {
for idx in 0..<slotCount {
slots.append(Double(idx) / Double(slotCount))
}
}
func circularDistance(_ a: Double, _ b: Double) -> Double {
let diff = abs(a - b)
return diff > 0.5 ? 1.0 - diff : diff
}
let peerIDs = currentSeeds.keys.sorted()
let preferredIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peerIDs.map { id in
let seed = currentSeeds[id] ?? id
let hash = seed.djb2()
let index = Int(hash % UInt64(slots.count))
return (id, index)
})
var mapping: [String: Entry] = [:]
var usedSlots = Set<Int>()
var usedHues: [Double] = []
let prior = entries.isEmpty ? previousEntries : entries
for (id, entry) in prior {
guard currentSeeds.keys.contains(id), entry.slot < slots.count else { continue }
let hue = slots[entry.slot]
mapping[id] = Entry(slot: entry.slot, ring: entry.ring, hue: hue)
usedSlots.insert(entry.slot)
usedHues.append(hue)
}
let unassigned = peerIDs.filter { mapping[$0] == nil }
for id in unassigned {
let preferred = preferredIndex[id] ?? 0
if !usedSlots.contains(preferred), preferred < slots.count {
let hue = slots[preferred]
mapping[id] = Entry(slot: preferred, ring: 0, hue: hue)
usedSlots.insert(preferred)
usedHues.append(hue)
continue
}
var bestSlot: Int?
var bestScore = -Double.infinity
for slot in 0..<slots.count where !usedSlots.contains(slot) {
let hue = slots[slot]
let minDistance = usedHues.isEmpty ? 1.0 : usedHues.map { circularDistance(hue, $0) }.min() ?? 1.0
let bias = 1.0 - (Double((abs(slot - (preferredIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
let score = minDistance + config.preferredBiasWeight * bias
if score > bestScore {
bestScore = score
bestSlot = slot
}
}
if let slot = bestSlot {
let hue = slots[slot]
mapping[id] = Entry(slot: slot, ring: 0, hue: hue)
usedSlots.insert(slot)
usedHues.append(hue)
}
}
let remaining = peerIDs.filter { mapping[$0] == nil }
if !remaining.isEmpty {
for (index, id) in remaining.enumerated() {
let preferred = preferredIndex[id] ?? 0
let slot = (preferred + index * config.goldenStep) % slots.count
let hue = slots[slot]
mapping[id] = Entry(slot: slot, ring: 1, hue: hue)
}
}
entries = mapping
}
}
extension MinimalDistancePalette.Config {
static let mesh = MinimalDistancePalette.Config(
slotCount: TransportConfig.uiPeerPaletteSlots,
avoidCenterHue: 30.0 / 360.0,
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
saturationLight: 0.70,
saturationDark: 0.80,
baseBrightnessLight: 0.45,
baseBrightnessDark: 0.75,
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
)
static let nostr = MinimalDistancePalette.Config(
slotCount: TransportConfig.uiPeerPaletteSlots,
avoidCenterHue: 30.0 / 360.0,
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
saturationLight: 0.70,
saturationDark: 0.80,
baseBrightnessLight: 0.45,
baseBrightnessDark: 0.75,
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
)
}
@@ -1,191 +0,0 @@
//
// PublicMessagePipeline.swift
// bitchat
//
// Handles batching and deduplication of public chat messages before surfacing them to the UI.
//
import BitFoundation
import Foundation
@MainActor
protocol PublicMessagePipelineDelegate: AnyObject {
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage]
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage])
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date?
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date)
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline)
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage)
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool)
}
@MainActor
final class PublicMessagePipeline {
weak var delegate: PublicMessagePipelineDelegate?
private var buffer: [BitchatMessage] = []
private var timer: Timer?
private let baseFlushInterval: TimeInterval
private var dynamicFlushInterval: TimeInterval
private var recentBatchSizes: [Int] = []
private let maxRecentBatchSamples: Int
private let dedupWindow: TimeInterval
private var activeChannel: ChannelID = .mesh
init(
baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval,
maxRecentBatchSamples: Int = 10,
dedupWindow: TimeInterval = 1.0
) {
self.baseFlushInterval = baseFlushInterval
self.dynamicFlushInterval = baseFlushInterval
self.maxRecentBatchSamples = maxRecentBatchSamples
self.dedupWindow = dedupWindow
}
deinit {
timer?.invalidate()
}
func updateActiveChannel(_ channel: ChannelID) {
activeChannel = channel
}
func enqueue(_ message: BitchatMessage) {
buffer.append(message)
scheduleFlush()
}
func flushIfNeeded() {
flushBuffer()
}
func reset() {
timer?.invalidate()
timer = nil
buffer.removeAll(keepingCapacity: false)
}
}
private extension PublicMessagePipeline {
func scheduleFlush() {
guard timer == nil else { return }
timer = Timer.scheduledTimer(withTimeInterval: dynamicFlushInterval, repeats: false) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.flushBuffer()
}
}
}
func flushBuffer() {
timer?.invalidate()
timer = nil
guard !buffer.isEmpty else { return }
guard let delegate = delegate else {
buffer.removeAll(keepingCapacity: false)
return
}
delegate.pipelineSetBatchingState(self, isBatching: true)
var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id })
var pending: [(message: BitchatMessage, contentKey: String)] = []
var batchContentLatest: [String: Date] = [:]
for message in buffer {
if existingIDs.contains(message.id) { continue }
let contentKey = delegate.pipeline(self, normalizeContent: message.content)
if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey),
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
continue
}
if let ts = batchContentLatest[contentKey],
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
continue
}
existingIDs.insert(message.id)
pending.append((message, contentKey))
batchContentLatest[contentKey] = message.timestamp
}
buffer.removeAll(keepingCapacity: true)
guard !pending.isEmpty else {
delegate.pipelineSetBatchingState(self, isBatching: false)
if !buffer.isEmpty { scheduleFlush() }
return
}
pending.sort { $0.message.timestamp < $1.message.timestamp }
var messages = delegate.pipelineCurrentMessages(self)
let threshold = lateInsertThreshold(for: activeChannel)
let lastTimestamp = messages.last?.timestamp ?? .distantPast
for item in pending {
let message = item.message
if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) {
let index = insertionIndex(for: message.timestamp, in: messages)
if index >= messages.count {
messages.append(message)
} else {
messages.insert(message, at: index)
}
} else {
messages.append(message)
}
delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: message.timestamp)
}
delegate.pipeline(self, setMessages: messages)
delegate.pipelineTrimMessages(self)
updateFlushInterval(withBatchSize: pending.count)
for item in pending {
delegate.pipelinePrewarmMessage(self, message: item.message)
}
delegate.pipelineSetBatchingState(self, isBatching: false)
if !buffer.isEmpty {
scheduleFlush()
}
}
func updateFlushInterval(withBatchSize size: Int) {
recentBatchSizes.append(size)
if recentBatchSizes.count > maxRecentBatchSamples {
recentBatchSizes.removeFirst(recentBatchSizes.count - maxRecentBatchSamples)
}
let avg = recentBatchSizes.isEmpty
? 0.0
: Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval
}
func lateInsertThreshold(for channel: ChannelID) -> TimeInterval {
switch channel {
case .mesh:
return TransportConfig.uiLateInsertThreshold
case .location:
return TransportConfig.uiLateInsertThresholdGeo
}
}
func insertionIndex(for timestamp: Date, in messages: [BitchatMessage]) -> Int {
var low = 0
var high = messages.count
while low < high {
let mid = (low + high) / 2
if messages[mid].timestamp < timestamp {
low = mid + 1
} else {
high = mid
}
}
return low
}
}
@@ -1,125 +0,0 @@
//
// PublicTimelineStore.swift
// bitchat
//
// Maintains mesh and geohash public timelines with simple caps and helpers.
//
import BitFoundation
import Foundation
struct PublicTimelineStore {
private var meshTimeline: [BitchatMessage] = []
private var geohashTimelines: [String: [BitchatMessage]] = [:]
private var pendingGeohashSystemMessages: [String] = []
private let meshCap: Int
private let geohashCap: Int
init(meshCap: Int, geohashCap: Int) {
self.meshCap = meshCap
self.geohashCap = geohashCap
}
mutating func append(_ message: BitchatMessage, to channel: ChannelID) {
switch channel {
case .mesh:
guard !meshTimeline.contains(where: { $0.id == message.id }) else { return }
meshTimeline.append(message)
trimMeshTimelineIfNeeded()
case .location(let channel):
append(message, toGeohash: channel.geohash)
}
}
mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {
var timeline = geohashTimelines[geohash] ?? []
guard !timeline.contains(where: { $0.id == message.id }) else { return }
timeline.append(message)
trimGeohashTimelineIfNeeded(&timeline)
geohashTimelines[geohash] = timeline
}
/// Append message if absent, returning true when stored.
mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
var timeline = geohashTimelines[geohash] ?? []
guard !timeline.contains(where: { $0.id == message.id }) else { return false }
timeline.append(message)
trimGeohashTimelineIfNeeded(&timeline)
geohashTimelines[geohash] = timeline
return true
}
mutating func messages(for channel: ChannelID) -> [BitchatMessage] {
switch channel {
case .mesh:
return meshTimeline
case .location(let channel):
let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []
geohashTimelines[channel.geohash] = cleaned
return cleaned
}
}
mutating func clear(channel: ChannelID) {
switch channel {
case .mesh:
meshTimeline.removeAll()
case .location(let channel):
geohashTimelines[channel.geohash] = []
}
}
@discardableResult
mutating func removeMessage(withID id: String) -> BitchatMessage? {
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
return meshTimeline.remove(at: index)
}
for key in Array(geohashTimelines.keys) {
var timeline = geohashTimelines[key] ?? []
if let index = timeline.firstIndex(where: { $0.id == id }) {
let removed = timeline.remove(at: index)
geohashTimelines[key] = timeline.isEmpty ? nil : timeline
return removed
}
}
return nil
}
mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
var timeline = geohashTimelines[geohash] ?? []
timeline.removeAll(where: predicate)
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
}
mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {
var timeline = geohashTimelines[geohash] ?? []
transform(&timeline)
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
}
mutating func queueGeohashSystemMessage(_ content: String) {
pendingGeohashSystemMessages.append(content)
}
mutating func drainPendingGeohashSystemMessages() -> [String] {
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
return pendingGeohashSystemMessages
}
func geohashKeys() -> [String] {
Array(geohashTimelines.keys)
}
private mutating func trimMeshTimelineIfNeeded() {
guard meshTimeline.count > meshCap else { return }
meshTimeline = Array(meshTimeline.suffix(meshCap))
}
private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) {
guard timeline.count > geohashCap else { return }
timeline = Array(timeline.suffix(geohashCap))
}
}
@@ -1,142 +0,0 @@
//
// VoiceRecordingViewModel.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
@MainActor
final class VoiceRecordingViewModel: ObservableObject {
enum State: Equatable {
case idle
case requestingPermission
case permissionDenied
case preparing
case recording(startDate: Date)
case error(message: String)
var isActive: Bool {
switch self {
case .preparing, .recording: true
case .idle, .requestingPermission, .permissionDenied, .error: false
}
}
var alertMessage: String {
switch self {
case .error(let message): message
case .permissionDenied: "Microphone access is required to record voice notes."
case .idle, .requestingPermission, .preparing, .recording: ""
}
}
fileprivate func duration(for date: Date) -> TimeInterval {
switch self {
case .idle, .requestingPermission, .preparing, .permissionDenied, .error: 0
case .recording(let startDate): date.timeIntervalSince(startDate)
}
}
}
var showAlert: Bool {
get {
switch state {
case .permissionDenied, .error: true
case .idle, .requestingPermission, .preparing, .recording: false
}
}
set {
if !newValue { state = .idle }
}
}
@Published private(set) var state = State.idle
func formattedDuration(for date: Date) -> String {
let clamped = max(0, state.duration(for: date))
let totalMilliseconds = Int(clamped * 1000)
let minutes = totalMilliseconds / 60_000
let seconds = (totalMilliseconds % 60_000) / 1_000
let centiseconds = (totalMilliseconds % 1_000) / 10
return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds)
}
func start(shouldShow: Bool) {
guard shouldShow, state == .idle else { return }
state = .requestingPermission
Task {
let granted = await VoiceRecorder.shared.requestPermission()
guard state == .requestingPermission else { return }
guard granted else {
state = .permissionDenied
return
}
state = .preparing
do {
try await VoiceRecorder.shared.startRecording()
guard state == .preparing else {
cancel()
return
}
state = .recording(startDate: Date())
} catch {
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
await VoiceRecorder.shared.cancelRecording()
guard state == .preparing else { return }
state = .error(message: "Could not start recording.")
}
}
}
func finish(completion: ((URL) -> Void)?) {
let previousState = state
switch previousState {
case .permissionDenied, .error:
return
case .idle, .requestingPermission, .preparing, .recording:
break
}
state = .idle
guard case .recording(let startDate) = previousState, let completion else {
Task { await VoiceRecorder.shared.cancelRecording() }
return
}
Task {
let finalDuration = Date().timeIntervalSince(startDate)
if let url = await VoiceRecorder.shared.stopRecording(),
isValidRecording(at: url, duration: finalDuration) {
completion(url)
} else {
guard state == .idle else { return }
state = .error(
message: finalDuration < VoiceRecorder.minRecordingDuration
? "Recording is too short."
: "Recording failed to save."
)
}
}
}
func cancel() {
finish(completion: nil)
}
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let fileSize = attributes[.size] as? NSNumber,
fileSize.intValue > 0,
duration >= VoiceRecorder.minRecordingDuration {
return true
}
try? FileManager.default.removeItem(at: url)
return false
}
}
+22
View File
@@ -86,6 +86,10 @@ struct AppInfoView: View {
]
}
enum Warning {
static let title: LocalizedStringKey = "app_info.warning.title"
static let message: LocalizedStringKey = "app_info.warning.message"
}
}
var body: some View {
@@ -188,6 +192,24 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Privacy.panic)
}
// Warning
VStack(alignment: .leading, spacing: 6) {
SectionHeader(Strings.Warning.title)
.foregroundColor(Color.red)
Text(Strings.Warning.message)
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(Color.red)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.top, 6)
.padding(.bottom, 16)
.padding(.horizontal)
.background(Color.red.opacity(0.1))
.cornerRadius(8)
.padding(.top)
}
.padding()
}

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