mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:25:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bd8a69c0 | ||
|
|
fb43a8b0f5 | ||
|
|
eb35608fa1 | ||
|
|
629bea7fd0 | ||
|
|
1cf4ea9e57 | ||
|
|
b81ae0b4c0 | ||
|
|
3b96d8590d | ||
|
|
6cbfe8ecfb | ||
|
|
8481e1552d | ||
|
|
9ebaa9108c | ||
|
|
2b3264b581 | ||
|
|
830f7ee4b8 | ||
|
|
c8768c32b8 | ||
|
|
1352484491 | ||
|
|
ef396936a3 | ||
|
|
c3ce32ad07 | ||
|
|
d7ca99ae5d | ||
|
|
b25f0d9f63 | ||
|
|
abb998d809 | ||
|
|
d71befd8b7 | ||
|
|
78c4bed1ad | ||
|
|
c52a2a7772 | ||
|
|
3df0aa0cc4 | ||
|
|
46caded099 | ||
|
|
a04ac9dabd | ||
|
|
1825d115bc | ||
|
|
9754881af8 | ||
|
|
59cc857fde | ||
|
|
f0ca1b27c2 | ||
|
|
5beff8b4dc | ||
|
|
ba49b885bb | ||
|
|
ac10e906c0 | ||
|
|
119e882574 | ||
|
|
0acbbdf2e3 | ||
|
|
40fe0c8ba5 | ||
|
|
7593053137 | ||
|
|
4dde063027 | ||
|
|
8c4fead945 | ||
|
|
2543277755 | ||
|
|
a8ce4cbf64 | ||
|
|
93aa714ed4 | ||
|
|
9c7bf26e13 | ||
|
|
c29c7d83fe | ||
|
|
7f743d48fe | ||
|
|
27c39332d5 | ||
|
|
53c08d6807 | ||
|
|
7efe0a23b1 | ||
|
|
8dedc81512 | ||
|
|
5cbe9d4aa7 | ||
|
|
515fae9e25 | ||
|
|
0946d3921f | ||
|
|
1d40950118 | ||
|
|
b404d48ba6 | ||
|
|
2f1ba27d51 | ||
|
|
23124b36b7 | ||
|
|
3e8d05fa2e | ||
|
|
c36b582209 | ||
|
|
3c66ca7499 | ||
|
|
a042576652 | ||
|
|
3c120042a4 | ||
|
|
4e3603d5e4 | ||
|
|
f7ad970a83 | ||
|
|
4cc87633e1 | ||
|
|
2d565ad918 | ||
|
|
b4cb4d36f1 | ||
|
|
7fcef2a9cb | ||
|
|
e7dc0e0c5a | ||
|
|
3b2c7d2f73 | ||
|
|
209f926990 | ||
|
|
ef8b509ea5 | ||
|
|
06b5bdb6da | ||
|
|
a5a3efebcb | ||
|
|
cd39d8c8c0 | ||
|
|
1046bfec0f | ||
|
|
d8c83e25f4 | ||
|
|
a9fa614416 | ||
|
|
ecbbc23862 | ||
|
|
b6d42261d0 | ||
|
|
3d914dcf46 | ||
|
|
b3ec5eeda0 | ||
|
|
47d75ab9d8 | ||
|
|
3479c7d5df | ||
|
|
8a0727fcf7 | ||
|
|
6588861e34 | ||
|
|
a1647901e5 | ||
|
|
23249f3e41 | ||
|
|
ad4103bacc | ||
|
|
e3149fa098 | ||
|
|
987ba2e694 | ||
|
|
615273a63e | ||
|
|
4563c22d2d | ||
|
|
9f74266527 | ||
|
|
239064e4eb | ||
|
|
d371131ad5 | ||
|
|
d994ccf012 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,12 +0,0 @@
|
|||||||
---
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
dependencies:
|
|
||||||
- mtime: 1757258659000000000
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
size: 14166264
|
|
||||||
- mtime: 1754189697000000000
|
|
||||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 2261306
|
|
||||||
sdk_relative: true
|
|
||||||
version: 1
|
|
||||||
...
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
---
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
dependencies:
|
|
||||||
- mtime: 1757258662000000000
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
size: 18068
|
|
||||||
- mtime: 1754189697000000000
|
|
||||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 2261306
|
|
||||||
sdk_relative: true
|
|
||||||
- mtime: 1754191141000000000
|
|
||||||
path: 'usr/lib/swift/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 1224
|
|
||||||
sdk_relative: true
|
|
||||||
version: 1
|
|
||||||
...
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
---
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
dependencies:
|
|
||||||
- mtime: 1757258669000000000
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
size: 699544
|
|
||||||
- mtime: 1754189697000000000
|
|
||||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 2261306
|
|
||||||
sdk_relative: true
|
|
||||||
- mtime: 1754192470000000000
|
|
||||||
path: 'usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 364219
|
|
||||||
sdk_relative: true
|
|
||||||
version: 1
|
|
||||||
...
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
---
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
dependencies:
|
|
||||||
- mtime: 1757258664000000000
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
size: 83568
|
|
||||||
- mtime: 1754189697000000000
|
|
||||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 2261306
|
|
||||||
sdk_relative: true
|
|
||||||
- mtime: 1754192532000000000
|
|
||||||
path: 'usr/lib/swift/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 24507
|
|
||||||
sdk_relative: true
|
|
||||||
version: 1
|
|
||||||
...
|
|
||||||
@@ -66,6 +66,9 @@ __pycache__/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
|
## Cache
|
||||||
|
.cache/
|
||||||
|
|
||||||
# Local build results
|
# Local build results
|
||||||
.Result*/
|
.Result*/
|
||||||
.Result*.xcresult/
|
.Result*.xcresult/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.4.4
|
MARKETING_VERSION = 1.5.0
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ default:
|
|||||||
# Check prerequisites
|
# Check prerequisites
|
||||||
check:
|
check:
|
||||||
@echo "Checking prerequisites..."
|
@echo "Checking prerequisites..."
|
||||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
|
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild 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)
|
@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)
|
||||||
@echo "✅ All prerequisites met"
|
@echo "✅ All prerequisites met"
|
||||||
|
|
||||||
# Backup original files
|
# Backup original files
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ let package = Package(
|
|||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
defaultLocalization: "en",
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v17),
|
.iOS(.v16),
|
||||||
.macOS(.v13)
|
.macOS(.v13)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
|
|||||||
Generated
+1
@@ -312,6 +312,7 @@
|
|||||||
ne,
|
ne,
|
||||||
"pt-BR",
|
"pt-BR",
|
||||||
ru,
|
ru,
|
||||||
|
tr,
|
||||||
uk,
|
uk,
|
||||||
"zh-Hans",
|
"zh-Hans",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename" : "image-1024 1.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@@ -25,7 +24,6 @@
|
|||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename" : "image-1024 2.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 11 KiB |
@@ -26,11 +26,15 @@ struct BitchatApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private let idBridge = NostrIdentityBridge()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let keychain = KeychainManager()
|
let keychain = KeychainManager()
|
||||||
|
let idBridge = self.idBridge
|
||||||
_chatViewModel = StateObject(
|
_chatViewModel = StateObject(
|
||||||
wrappedValue: ChatViewModel(
|
wrappedValue: ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -50,7 +54,7 @@ struct BitchatApp: App {
|
|||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -217,7 +221,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
// Get peer ID from userInfo
|
// Get peer ID from userInfo
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
if let peerID = userInfo["peerID"] as? String {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.chatViewModel?.startPrivateChat(with: peerID)
|
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import Foundation
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
#endif
|
||||||
|
|
||||||
|
enum ImageUtilsError: Error {
|
||||||
|
case invalidImage
|
||||||
|
case encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ImageUtils {
|
||||||
|
private static let compressionQuality: CGFloat = 0.85
|
||||||
|
private static let targetImageBytes: Int = 60_000
|
||||||
|
|
||||||
|
static func processImage(at url: URL, maxDimension: CGFloat = 512) 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 = 512) throws -> URL {
|
||||||
|
return try autoreleasepool {
|
||||||
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
|
var quality = compressionQuality
|
||||||
|
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
|
quality -= 0.1
|
||||||
|
autoreleasepool {
|
||||||
|
if let next = scaled.jpegData(compressionQuality: 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)
|
||||||
|
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||||
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||||
|
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||||
|
UIGraphicsEndImageContext()
|
||||||
|
return rendered ?? image
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 H2: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
|
// Don't add any metadata dictionary keys - fresh CGContext ensures clean image
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||||
|
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
||||||
|
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||||
|
enum RecorderError: Error {
|
||||||
|
case microphoneAccessDenied
|
||||||
|
case recorderInitializationFailed
|
||||||
|
case recordingInProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = VoiceRecorder()
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||||
|
private let paddingInterval: TimeInterval = 0.5
|
||||||
|
|
||||||
|
private var recorder: AVAudioRecorder?
|
||||||
|
private var currentURL: URL?
|
||||||
|
private var stopWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permissions
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
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
|
||||||
|
|
||||||
|
func startRecording() throws -> URL {
|
||||||
|
try queue.sync {
|
||||||
|
if recorder?.isRecording == true {
|
||||||
|
throw RecorderError.recordingInProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
guard session.recordPermission == .granted else {
|
||||||
|
throw RecorderError.microphoneAccessDenied
|
||||||
|
}
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .default,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||||
|
)
|
||||||
|
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: 20_000
|
||||||
|
]
|
||||||
|
|
||||||
|
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||||
|
audioRecorder.delegate = self
|
||||||
|
audioRecorder.isMeteringEnabled = true
|
||||||
|
audioRecorder.prepareToRecord()
|
||||||
|
audioRecorder.record()
|
||||||
|
|
||||||
|
recorder = audioRecorder
|
||||||
|
currentURL = outputURL
|
||||||
|
stopWorkItem?.cancel()
|
||||||
|
stopWorkItem = nil
|
||||||
|
return outputURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRecording(completion: @escaping (URL?) -> Void) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
||||||
|
completion(self?.currentURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
recorder.stop()
|
||||||
|
self.cleanupSession()
|
||||||
|
let url = self.currentURL
|
||||||
|
self.recorder = nil
|
||||||
|
self.currentURL = url
|
||||||
|
completion(url)
|
||||||
|
}
|
||||||
|
self.stopWorkItem = item
|
||||||
|
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelRecording() {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.stopWorkItem?.cancel()
|
||||||
|
self.stopWorkItem = nil
|
||||||
|
if let recorder = self.recorder, recorder.isRecording {
|
||||||
|
recorder.stop()
|
||||||
|
}
|
||||||
|
self.cleanupSession()
|
||||||
|
if let url = self.currentURL {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
self.recorder = nil
|
||||||
|
self.currentURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metering
|
||||||
|
|
||||||
|
func currentAveragePower() -> Float {
|
||||||
|
queue.sync {
|
||||||
|
recorder?.updateMeters()
|
||||||
|
return recorder?.averagePower(forChannel: 0) ?? -160
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@
|
|||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|||||||
+15408
-14476
File diff suppressed because it is too large
Load Diff
@@ -335,20 +335,6 @@ extension BitchatMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - System Message Factory
|
|
||||||
|
|
||||||
extension BitchatMessage {
|
|
||||||
/// Creates a system message with default values
|
|
||||||
static func system(_ content: String, timestamp: Date = Date()) -> BitchatMessage {
|
|
||||||
return BitchatMessage(
|
|
||||||
sender: "system",
|
|
||||||
content: content,
|
|
||||||
timestamp: timestamp,
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Array where Element == BitchatMessage {
|
extension Array where Element == BitchatMessage {
|
||||||
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
||||||
func cleanedAndDeduped() -> [Element] {
|
func cleanedAndDeduped() -> [Element] {
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ struct BitchatPacket: Codable {
|
|||||||
var signature: Data?
|
var signature: Data?
|
||||||
var ttl: UInt8
|
var ttl: UInt8
|
||||||
|
|
||||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) {
|
||||||
self.version = 1
|
self.version = version
|
||||||
self.type = type
|
self.type = type
|
||||||
self.senderID = senderID
|
self.senderID = senderID
|
||||||
self.recipientID = recipientID
|
self.recipientID = recipientID
|
||||||
@@ -80,7 +80,8 @@ struct BitchatPacket: Codable {
|
|||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil, // Remove signature for signing
|
signature: nil, // Remove signature for signing
|
||||||
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
|
version: version
|
||||||
)
|
)
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
//
|
|
||||||
// GeoPerson.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Model representing a participant in a geohash channel
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Represents a person participating in a geohash-based location channel
|
|
||||||
struct GeoPerson: Identifiable, Equatable {
|
|
||||||
let id: String // pubkey hex (lowercased)
|
|
||||||
let displayName: String
|
|
||||||
let lastSeen: Date
|
|
||||||
}
|
|
||||||
@@ -161,9 +161,14 @@ extension PeerID {
|
|||||||
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if the `bare` id is all hex
|
||||||
|
var isHex: Bool {
|
||||||
|
bare.allSatisfy { $0.isHexDigit }
|
||||||
|
}
|
||||||
|
|
||||||
/// Short routing IDs (exact 16-hex)
|
/// Short routing IDs (exact 16-hex)
|
||||||
var isShort: Bool {
|
var isShort: Bool {
|
||||||
bare.count == Constants.hexIDLength && Data(hexString: bare) != nil
|
bare.count == Constants.hexIDLength && isHex
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full Noise key hex (exact 64-hex)
|
/// Full Noise key hex (exact 64-hex)
|
||||||
|
|||||||
@@ -767,7 +767,7 @@ final class NoiseHandshakeState {
|
|||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
|
|
||||||
default:
|
case .e, .s:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// NoiseRateLimiter.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class NoiseRateLimiter {
|
||||||
|
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
|
||||||
|
// Global rate limiting
|
||||||
|
private var globalHandshakeTimestamps: [Date] = []
|
||||||
|
private var globalMessageTimestamps: [Date] = []
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||||
|
|
||||||
|
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||||
|
return queue.sync(flags: .barrier) {
|
||||||
|
let now = Date()
|
||||||
|
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||||
|
|
||||||
|
// Check global rate limit first
|
||||||
|
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||||
|
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||||
|
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-peer rate limit
|
||||||
|
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||||
|
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||||
|
|
||||||
|
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||||
|
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record new handshake
|
||||||
|
timestamps.append(now)
|
||||||
|
handshakeTimestamps[peerID] = timestamps
|
||||||
|
globalHandshakeTimestamps.append(now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowMessage(from peerID: PeerID) -> Bool {
|
||||||
|
return queue.sync(flags: .barrier) {
|
||||||
|
let now = Date()
|
||||||
|
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||||
|
|
||||||
|
// Check global rate limit first
|
||||||
|
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||||
|
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||||
|
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-peer rate limit
|
||||||
|
var timestamps = messageTimestamps[peerID] ?? []
|
||||||
|
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||||
|
|
||||||
|
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||||
|
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record new message
|
||||||
|
timestamps.append(now)
|
||||||
|
messageTimestamps[peerID] = timestamps
|
||||||
|
globalMessageTimestamps.append(now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset(for peerID: PeerID) {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||||
|
self.messageTimestamps.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAll() {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.handshakeTimestamps.removeAll()
|
||||||
|
self.messageTimestamps.removeAll()
|
||||||
|
self.globalHandshakeTimestamps.removeAll()
|
||||||
|
self.globalMessageTimestamps.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
//
|
|
||||||
// NoiseSecurityConsiderations.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - Security Constants
|
|
||||||
|
|
||||||
enum NoiseSecurityConstants {
|
|
||||||
// Maximum message size to prevent memory exhaustion
|
|
||||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
|
||||||
|
|
||||||
// Maximum handshake message size
|
|
||||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
|
||||||
|
|
||||||
// Session timeout - sessions older than this should be renegotiated
|
|
||||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
|
||||||
|
|
||||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
|
||||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
|
||||||
|
|
||||||
// Handshake timeout - abandon incomplete handshakes
|
|
||||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
|
||||||
|
|
||||||
// Maximum concurrent sessions per peer
|
|
||||||
static let maxSessionsPerPeer = 3
|
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
static let maxHandshakesPerMinute = 10
|
|
||||||
static let maxMessagesPerSecond = 100
|
|
||||||
|
|
||||||
// Global rate limiting (across all peers)
|
|
||||||
static let maxGlobalHandshakesPerMinute = 30
|
|
||||||
static let maxGlobalMessagesPerSecond = 500
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Security Validations
|
|
||||||
|
|
||||||
struct NoiseSecurityValidator {
|
|
||||||
|
|
||||||
/// Validate message size
|
|
||||||
static func validateMessageSize(_ data: Data) -> Bool {
|
|
||||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate handshake message size
|
|
||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
|
||||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Enhanced Noise Session with Security
|
|
||||||
|
|
||||||
final class SecureNoiseSession: NoiseSession {
|
|
||||||
private(set) var messageCount: UInt64 = 0
|
|
||||||
private let sessionStartTime = Date()
|
|
||||||
private(set) var lastActivityTime = Date()
|
|
||||||
|
|
||||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
|
||||||
// Check session age
|
|
||||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
|
||||||
throw NoiseSecurityError.sessionExpired
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check message count
|
|
||||||
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
|
||||||
throw NoiseSecurityError.sessionExhausted
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate message size
|
|
||||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
|
||||||
throw NoiseSecurityError.messageTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
let encrypted = try super.encrypt(plaintext)
|
|
||||||
messageCount += 1
|
|
||||||
lastActivityTime = Date()
|
|
||||||
|
|
||||||
return encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
|
||||||
// Check session age
|
|
||||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
|
||||||
throw NoiseSecurityError.sessionExpired
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate message size
|
|
||||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
|
||||||
throw NoiseSecurityError.messageTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
let decrypted = try super.decrypt(ciphertext)
|
|
||||||
lastActivityTime = Date()
|
|
||||||
|
|
||||||
return decrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
func needsRenegotiation() -> Bool {
|
|
||||||
// Check if we've used more than 90% of message limit
|
|
||||||
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
|
||||||
if messageCount >= messageThreshold {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if last activity was more than 30 minutes ago
|
|
||||||
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Testing Support
|
|
||||||
#if DEBUG
|
|
||||||
func setLastActivityTimeForTesting(_ date: Date) {
|
|
||||||
lastActivityTime = date
|
|
||||||
}
|
|
||||||
|
|
||||||
func setMessageCountForTesting(_ count: UInt64) {
|
|
||||||
messageCount = count
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Rate Limiter
|
|
||||||
|
|
||||||
final class NoiseRateLimiter {
|
|
||||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
|
||||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
|
||||||
|
|
||||||
// Global rate limiting
|
|
||||||
private var globalHandshakeTimestamps: [Date] = []
|
|
||||||
private var globalMessageTimestamps: [Date] = []
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
|
||||||
|
|
||||||
func allowHandshake(from peerID: PeerID) -> Bool {
|
|
||||||
return queue.sync(flags: .barrier) {
|
|
||||||
let now = Date()
|
|
||||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
|
||||||
|
|
||||||
// Check global rate limit first
|
|
||||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
|
||||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
|
||||||
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check per-peer rate limit
|
|
||||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
|
||||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
|
||||||
|
|
||||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
|
||||||
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record new handshake
|
|
||||||
timestamps.append(now)
|
|
||||||
handshakeTimestamps[peerID] = timestamps
|
|
||||||
globalHandshakeTimestamps.append(now)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func allowMessage(from peerID: PeerID) -> Bool {
|
|
||||||
return queue.sync(flags: .barrier) {
|
|
||||||
let now = Date()
|
|
||||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
|
||||||
|
|
||||||
// Check global rate limit first
|
|
||||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
|
||||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
|
||||||
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check per-peer rate limit
|
|
||||||
var timestamps = messageTimestamps[peerID] ?? []
|
|
||||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
|
||||||
|
|
||||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
|
||||||
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record new message
|
|
||||||
timestamps.append(now)
|
|
||||||
messageTimestamps[peerID] = timestamps
|
|
||||||
globalMessageTimestamps.append(now)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset(for peerID: PeerID) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
|
||||||
self.messageTimestamps.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetAll() {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
self.handshakeTimestamps.removeAll()
|
|
||||||
self.messageTimestamps.removeAll()
|
|
||||||
self.globalHandshakeTimestamps.removeAll()
|
|
||||||
self.globalMessageTimestamps.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Security Errors
|
|
||||||
|
|
||||||
enum NoiseSecurityError: Error {
|
|
||||||
case sessionExpired
|
|
||||||
case sessionExhausted
|
|
||||||
case messageTooLarge
|
|
||||||
case invalidPeerID
|
|
||||||
case rateLimitExceeded
|
|
||||||
case handshakeTimeout
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// NoiseSecurityConstants.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NoiseSecurityConstants {
|
||||||
|
// Maximum message size to prevent memory exhaustion
|
||||||
|
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||||
|
|
||||||
|
// Maximum handshake message size
|
||||||
|
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||||
|
|
||||||
|
// Session timeout - sessions older than this should be renegotiated
|
||||||
|
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||||
|
|
||||||
|
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||||
|
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||||
|
|
||||||
|
// Handshake timeout - abandon incomplete handshakes
|
||||||
|
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||||
|
|
||||||
|
// Maximum concurrent sessions per peer
|
||||||
|
static let maxSessionsPerPeer = 3
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
static let maxHandshakesPerMinute = 10
|
||||||
|
static let maxMessagesPerSecond = 100
|
||||||
|
|
||||||
|
// Global rate limiting (across all peers)
|
||||||
|
static let maxGlobalHandshakesPerMinute = 30
|
||||||
|
static let maxGlobalMessagesPerSecond = 500
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// NoiseSecurityError.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NoiseSecurityError: Error {
|
||||||
|
case sessionExpired
|
||||||
|
case sessionExhausted
|
||||||
|
case messageTooLarge
|
||||||
|
case invalidPeerID
|
||||||
|
case rateLimitExceeded
|
||||||
|
case handshakeTimeout
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// NoiseSecurityValidator.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NoiseSecurityValidator {
|
||||||
|
|
||||||
|
/// Validate message size
|
||||||
|
static func validateMessageSize(_ data: Data) -> Bool {
|
||||||
|
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate handshake message size
|
||||||
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
|
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,12 +196,6 @@ class NoiseSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHandshakeHash() -> Data? {
|
|
||||||
return sessionQueue.sync {
|
|
||||||
return handshakeHash
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
sessionQueue.sync(flags: .barrier) {
|
sessionQueue.sync(flags: .barrier) {
|
||||||
let wasEstablished = state == .established
|
let wasEstablished = state == .established
|
||||||
|
|||||||
@@ -6,10 +6,9 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
enum NoiseSessionError: Error {
|
enum NoiseSessionError: Error, Equatable {
|
||||||
case invalidState
|
case invalidState
|
||||||
case notEstablished
|
case notEstablished
|
||||||
case sessionNotFound
|
case sessionNotFound
|
||||||
case handshakeFailed(Error)
|
|
||||||
case alreadyEstablished
|
case alreadyEstablished
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,19 +27,6 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
// MARK: - Session Management
|
// MARK: - Session Management
|
||||||
|
|
||||||
func createSession(for peerID: PeerID, role: NoiseRole) -> NoiseSession {
|
|
||||||
return managerQueue.sync(flags: .barrier) {
|
|
||||||
let session = SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: role,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = session
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSession(for peerID: PeerID) -> NoiseSession? {
|
func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||||
return managerQueue.sync {
|
return managerQueue.sync {
|
||||||
return sessions[peerID]
|
return sessions[peerID]
|
||||||
@@ -48,14 +35,9 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
func removeSession(for peerID: PeerID) {
|
func removeSession(for peerID: PeerID) {
|
||||||
managerQueue.sync(flags: .barrier) {
|
managerQueue.sync(flags: .barrier) {
|
||||||
if let session = sessions[peerID] {
|
if let session = sessions.removeValue(forKey: peerID) {
|
||||||
if session.isEstablished() {
|
session.reset() // Clear sensitive data before removing
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
|
||||||
}
|
|
||||||
// Clear sensitive data before removing
|
|
||||||
session.reset()
|
|
||||||
}
|
}
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,12 +50,6 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEstablishedSessions() -> [PeerID: NoiseSession] {
|
|
||||||
return managerQueue.sync {
|
|
||||||
return sessions.filter { $0.value.isEstablished() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Handshake Helpers
|
// MARK: - Handshake Helpers
|
||||||
|
|
||||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||||
@@ -207,10 +183,6 @@ final class NoiseSessionManager {
|
|||||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHandshakeHash(for peerID: PeerID) -> Data? {
|
|
||||||
return getSession(for: peerID)?.getHandshakeHash()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session Rekeying
|
// MARK: - Session Rekeying
|
||||||
|
|
||||||
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//
|
||||||
|
// SecureNoiseSession.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class SecureNoiseSession: NoiseSession {
|
||||||
|
private(set) var messageCount: UInt64 = 0
|
||||||
|
private let sessionStartTime = Date()
|
||||||
|
private(set) var lastActivityTime = Date()
|
||||||
|
|
||||||
|
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||||
|
// Check session age
|
||||||
|
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
throw NoiseSecurityError.sessionExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check message count
|
||||||
|
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||||
|
throw NoiseSecurityError.sessionExhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message size
|
||||||
|
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
let encrypted = try super.encrypt(plaintext)
|
||||||
|
messageCount += 1
|
||||||
|
lastActivityTime = Date()
|
||||||
|
|
||||||
|
return encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||||
|
// Check session age
|
||||||
|
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
throw NoiseSecurityError.sessionExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message size
|
||||||
|
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
let decrypted = try super.decrypt(ciphertext)
|
||||||
|
lastActivityTime = Date()
|
||||||
|
|
||||||
|
return decrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsRenegotiation() -> Bool {
|
||||||
|
// Check if we've used more than 90% of message limit
|
||||||
|
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||||
|
if messageCount >= messageThreshold {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if last activity was more than 30 minutes ago
|
||||||
|
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Testing Support
|
||||||
|
#if DEBUG
|
||||||
|
func setLastActivityTimeForTesting(_ date: Date) {
|
||||||
|
lastActivityTime = date
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMessageCountForTesting(_ count: UInt64) {
|
||||||
|
messageCount = count
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Bech32 encoding for Nostr (minimal implementation)
|
||||||
|
enum Bech32 {
|
||||||
|
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||||
|
|
||||||
|
static func encode(hrp: String, data: Data) throws -> String {
|
||||||
|
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||||
|
let checksum = createChecksum(hrp: hrp, values: values)
|
||||||
|
let combined = values + checksum
|
||||||
|
|
||||||
|
return hrp + "1" + combined.map {
|
||||||
|
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||||
|
return String(charset[index])
|
||||||
|
}.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||||
|
// Find the last occurrence of '1'
|
||||||
|
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||||
|
throw Bech32Error.invalidFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
let hrp = String(bech32String[..<separatorIndex])
|
||||||
|
|
||||||
|
// Validate HRP contains only ASCII characters
|
||||||
|
for char in hrp {
|
||||||
|
guard char.asciiValue != nil else {
|
||||||
|
throw Bech32Error.invalidCharacter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||||
|
|
||||||
|
// Convert characters to values
|
||||||
|
var values = [UInt8]()
|
||||||
|
for char in dataString {
|
||||||
|
guard let index = charset.firstIndex(of: char) else {
|
||||||
|
throw Bech32Error.invalidCharacter
|
||||||
|
}
|
||||||
|
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checksum
|
||||||
|
guard values.count >= 6 else {
|
||||||
|
throw Bech32Error.invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
let payloadValues = Array(values.dropLast(6))
|
||||||
|
let checksum = Array(values.suffix(6))
|
||||||
|
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||||
|
|
||||||
|
guard checksum == expectedChecksum else {
|
||||||
|
throw Bech32Error.invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert back to bytes
|
||||||
|
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||||
|
return (hrp: hrp, data: Data(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Bech32Error: Error {
|
||||||
|
case invalidFormat
|
||||||
|
case invalidCharacter
|
||||||
|
case invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||||
|
var acc = 0
|
||||||
|
var bits = 0
|
||||||
|
var result = [UInt8]()
|
||||||
|
let maxv = (1 << to) - 1
|
||||||
|
|
||||||
|
for value in data {
|
||||||
|
acc = (acc << from) | Int(value)
|
||||||
|
bits += from
|
||||||
|
|
||||||
|
while bits >= to {
|
||||||
|
bits -= to
|
||||||
|
result.append(UInt8((acc >> bits) & maxv))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pad && bits > 0 {
|
||||||
|
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||||
|
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||||
|
let polymod = polymod(checksumValues) ^ 1
|
||||||
|
var checksum = [UInt8]()
|
||||||
|
|
||||||
|
for i in 0..<6 {
|
||||||
|
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||||
|
}
|
||||||
|
|
||||||
|
return checksum
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||||
|
var result = [UInt8]()
|
||||||
|
for c in hrp {
|
||||||
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue >> 5))
|
||||||
|
}
|
||||||
|
result.append(0)
|
||||||
|
for c in hrp {
|
||||||
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue & 31))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func polymod(_ values: [UInt8]) -> Int {
|
||||||
|
var chk = 1
|
||||||
|
for value in values {
|
||||||
|
let b = chk >> 25
|
||||||
|
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||||
|
for i in 0..<5 {
|
||||||
|
if (b >> i) & 1 == 1 {
|
||||||
|
chk ^= generator[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,13 +33,32 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||||
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||||
guard !entries.isEmpty else { return [] }
|
guard !entries.isEmpty, count > 0 else { return [] }
|
||||||
let sorted = entries
|
|
||||||
.sorted { a, b in
|
if entries.count <= count {
|
||||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
return entries
|
||||||
|
.sorted { a, b in
|
||||||
|
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||||
|
}
|
||||||
|
.map { "wss://\($0.host)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
var best: [(entry: Entry, distance: Double)] = []
|
||||||
|
best.reserveCapacity(count)
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
|
||||||
|
if best.count < count {
|
||||||
|
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||||
|
best.insert((entry, distance), at: idx)
|
||||||
|
} else if let worstDistance = best.last?.distance, distance < worstDistance {
|
||||||
|
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||||
|
best.insert((entry, distance), at: idx)
|
||||||
|
best.removeLast()
|
||||||
}
|
}
|
||||||
.prefix(count)
|
}
|
||||||
return sorted.map { "wss://\($0.host)" }
|
|
||||||
|
return best.map { "wss://\($0.entry.host)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
// MARK: - Remote Fetch
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
import P256K
|
import P256K
|
||||||
import Security
|
|
||||||
|
|
||||||
// Keychain helper for secure storage
|
|
||||||
struct KeychainHelper {
|
|
||||||
static 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
static 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
|
|
||||||
}
|
|
||||||
|
|
||||||
static func delete(key: String, service: String) {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key
|
|
||||||
]
|
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||||
struct NostrIdentity: Codable {
|
struct NostrIdentity: Codable {
|
||||||
@@ -103,266 +58,3 @@ struct NostrIdentity: Codable {
|
|||||||
return publicKey.hexEncodedString()
|
return publicKey.hexEncodedString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bridge between Noise and Nostr identities
|
|
||||||
struct NostrIdentityBridge {
|
|
||||||
private static let keychainService = "chat.bitchat.nostr"
|
|
||||||
private static let currentIdentityKey = "nostr-current-identity"
|
|
||||||
private static let deviceSeedKey = "nostr-device-seed"
|
|
||||||
// In-memory cache to avoid transient keychain access issues
|
|
||||||
private static var deviceSeedCache: Data?
|
|
||||||
|
|
||||||
/// Get or create the current Nostr identity
|
|
||||||
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
|
||||||
// Check if we already have a Nostr identity
|
|
||||||
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
|
|
||||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new Nostr identity
|
|
||||||
let nostrIdentity = try NostrIdentity.generate()
|
|
||||||
|
|
||||||
// Store it
|
|
||||||
let data = try JSONEncoder().encode(nostrIdentity)
|
|
||||||
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
|
|
||||||
|
|
||||||
return nostrIdentity
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
|
||||||
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
if let data = nostrPubkey.data(using: .utf8) {
|
|
||||||
KeychainHelper.save(key: key, data: data, service: keychainService)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get Nostr public key associated with a Noise public key
|
|
||||||
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
guard let data = KeychainHelper.load(key: key, service: keychainService),
|
|
||||||
let pubkey = String(data: data, encoding: .utf8) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return pubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
|
||||||
static func clearAllAssociations() {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
|
||||||
kSecReturnAttributes as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
|
||||||
for item in items {
|
|
||||||
var deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService
|
|
||||||
]
|
|
||||||
if let account = item[kSecAttrAccount as String] as? String {
|
|
||||||
deleteQuery[kSecAttrAccount as String] = account
|
|
||||||
}
|
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
}
|
|
||||||
} else if status == errSecItemNotFound {
|
|
||||||
// nothing persisted; no action needed
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceSeedCache = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Per-Geohash Identities (Location Channels)
|
|
||||||
|
|
||||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
|
||||||
/// Stored only on device keychain.
|
|
||||||
private static func getOrCreateDeviceSeed() -> Data {
|
|
||||||
if let cached = deviceSeedCache { return cached }
|
|
||||||
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
|
||||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
|
||||||
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
||||||
deviceSeedCache = existing
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
var seed = Data(count: 32)
|
|
||||||
_ = seed.withUnsafeMutableBytes { ptr in
|
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
|
||||||
}
|
|
||||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
|
||||||
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
||||||
deviceSeedCache = seed
|
|
||||||
return seed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
|
||||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
|
||||||
/// if the candidate is not a valid secp256k1 private key.
|
|
||||||
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
|
||||||
let seed = getOrCreateDeviceSeed()
|
|
||||||
guard let msg = geohash.data(using: .utf8) else {
|
|
||||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func candidateKey(iteration: UInt32) -> Data {
|
|
||||||
var input = Data(msg)
|
|
||||||
var iterBE = iteration.bigEndian
|
|
||||||
withUnsafeBytes(of: &iterBE) { bytes in
|
|
||||||
input.append(contentsOf: bytes)
|
|
||||||
}
|
|
||||||
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
|
||||||
return Data(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try a few iterations to ensure a valid key can be formed
|
|
||||||
for i in 0..<10 {
|
|
||||||
let keyData = candidateKey(iteration: UInt32(i))
|
|
||||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// As a final fallback, hash the seed+msg and try again
|
|
||||||
let fallback = (seed + msg).sha256Hash()
|
|
||||||
return try NostrIdentity(privateKeyData: fallback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bech32 encoding for Nostr (minimal implementation)
|
|
||||||
enum Bech32 {
|
|
||||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
||||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
|
||||||
|
|
||||||
static func encode(hrp: String, data: Data) throws -> String {
|
|
||||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
|
||||||
let checksum = createChecksum(hrp: hrp, values: values)
|
|
||||||
let combined = values + checksum
|
|
||||||
|
|
||||||
return hrp + "1" + combined.map {
|
|
||||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
|
||||||
return String(charset[index])
|
|
||||||
}.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
|
||||||
// Find the last occurrence of '1'
|
|
||||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
|
||||||
throw Bech32Error.invalidFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
let hrp = String(bech32String[..<separatorIndex])
|
|
||||||
|
|
||||||
// Validate HRP contains only ASCII characters
|
|
||||||
for char in hrp {
|
|
||||||
guard char.asciiValue != nil else {
|
|
||||||
throw Bech32Error.invalidCharacter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
|
||||||
|
|
||||||
// Convert characters to values
|
|
||||||
var values = [UInt8]()
|
|
||||||
for char in dataString {
|
|
||||||
guard let index = charset.firstIndex(of: char) else {
|
|
||||||
throw Bech32Error.invalidCharacter
|
|
||||||
}
|
|
||||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify checksum
|
|
||||||
guard values.count >= 6 else {
|
|
||||||
throw Bech32Error.invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
let payloadValues = Array(values.dropLast(6))
|
|
||||||
let checksum = Array(values.suffix(6))
|
|
||||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
|
||||||
|
|
||||||
guard checksum == expectedChecksum else {
|
|
||||||
throw Bech32Error.invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert back to bytes
|
|
||||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
|
||||||
return (hrp: hrp, data: Data(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Bech32Error: Error {
|
|
||||||
case invalidFormat
|
|
||||||
case invalidCharacter
|
|
||||||
case invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
|
||||||
var acc = 0
|
|
||||||
var bits = 0
|
|
||||||
var result = [UInt8]()
|
|
||||||
let maxv = (1 << to) - 1
|
|
||||||
|
|
||||||
for value in data {
|
|
||||||
acc = (acc << from) | Int(value)
|
|
||||||
bits += from
|
|
||||||
|
|
||||||
while bits >= to {
|
|
||||||
bits -= to
|
|
||||||
result.append(UInt8((acc >> bits) & maxv))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if pad && bits > 0 {
|
|
||||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
|
||||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
|
||||||
let polymod = polymod(checksumValues) ^ 1
|
|
||||||
var checksum = [UInt8]()
|
|
||||||
|
|
||||||
for i in 0..<6 {
|
|
||||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
|
||||||
}
|
|
||||||
|
|
||||||
return checksum
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
|
||||||
var result = [UInt8]()
|
|
||||||
for c in hrp {
|
|
||||||
guard let asciiValue = c.asciiValue else {
|
|
||||||
return [] // Return empty array for invalid input
|
|
||||||
}
|
|
||||||
result.append(UInt8(asciiValue >> 5))
|
|
||||||
}
|
|
||||||
result.append(0)
|
|
||||||
for c in hrp {
|
|
||||||
guard let asciiValue = c.asciiValue else {
|
|
||||||
return [] // Return empty array for invalid input
|
|
||||||
}
|
|
||||||
result.append(UInt8(asciiValue & 31))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func polymod(_ values: [UInt8]) -> Int {
|
|
||||||
var chk = 1
|
|
||||||
for value in values {
|
|
||||||
let b = chk >> 25
|
|
||||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
|
||||||
for i in 0..<5 {
|
|
||||||
if (b >> i) & 1 == 1 {
|
|
||||||
chk ^= generator[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chk
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Bridge between Noise and Nostr identities
|
||||||
|
final class NostrIdentityBridge {
|
||||||
|
private let keychainService = "chat.bitchat.nostr"
|
||||||
|
private let currentIdentityKey = "nostr-current-identity"
|
||||||
|
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: KeychainHelperProtocol
|
||||||
|
|
||||||
|
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||||
|
self.keychain = keychain
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get or create the current Nostr identity
|
||||||
|
func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||||
|
// Check if we already have a Nostr identity
|
||||||
|
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
|
||||||
|
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new Nostr identity
|
||||||
|
let nostrIdentity = try NostrIdentity.generate()
|
||||||
|
|
||||||
|
// Store it
|
||||||
|
let data = try JSONEncoder().encode(nostrIdentity)
|
||||||
|
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
|
||||||
|
|
||||||
|
return nostrIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||||
|
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||||
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
|
if let data = nostrPubkey.data(using: .utf8) {
|
||||||
|
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Nostr public key associated with a Noise public key
|
||||||
|
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||||
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
|
guard let data = keychain.load(key: key, service: keychainService),
|
||||||
|
let pubkey = String(data: data, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all Nostr identity associations and current identity
|
||||||
|
func clearAllAssociations() {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||||
|
kSecReturnAttributes as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||||
|
for item in items {
|
||||||
|
var deleteQuery: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService
|
||||||
|
]
|
||||||
|
if let account = item[kSecAttrAccount as String] as? String {
|
||||||
|
deleteQuery[kSecAttrAccount as String] = account
|
||||||
|
}
|
||||||
|
SecItemDelete(deleteQuery as CFDictionary)
|
||||||
|
}
|
||||||
|
} else if status == errSecItemNotFound {
|
||||||
|
// nothing persisted; no action needed
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceSeedCache = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-Geohash Identities (Location Channels)
|
||||||
|
|
||||||
|
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||||
|
/// Stored only on device keychain.
|
||||||
|
private func getOrCreateDeviceSeed() -> Data {
|
||||||
|
if let cached = deviceSeedCache { return cached }
|
||||||
|
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
|
||||||
|
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||||
|
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = existing
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
var seed = Data(count: 32)
|
||||||
|
_ = seed.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||||
|
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = seed
|
||||||
|
return seed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||||
|
/// 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"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateKey(iteration: UInt32) -> Data {
|
||||||
|
var input = Data(msg)
|
||||||
|
var iterBE = iteration.bigEndian
|
||||||
|
withUnsafeBytes(of: &iterBE) { bytes in
|
||||||
|
input.append(contentsOf: bytes)
|
||||||
|
}
|
||||||
|
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||||
|
return Data(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try a few iterations to ensure a valid key can be formed
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,20 +109,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Clean up timers and active connections
|
|
||||||
reconnectionTimer?.invalidate()
|
|
||||||
for (_, tracker) in eoseTrackers {
|
|
||||||
tracker.timer?.invalidate()
|
|
||||||
}
|
|
||||||
for (_, task) in connections {
|
|
||||||
task.cancel(with: .goingAway, reason: nil)
|
|
||||||
}
|
|
||||||
cancellables.removeAll()
|
|
||||||
SecureLogger.debug("NostrRelayManager deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect to all configured relays
|
/// Connect to all configured relays
|
||||||
func connect() {
|
func connect() {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
// MARK: - Hex Encoding/Decoding
|
// MARK: - Hex Encoding/Decoding
|
||||||
|
|
||||||
@@ -16,6 +17,11 @@ extension Data {
|
|||||||
}
|
}
|
||||||
return self.map { String(format: "%02x", $0) }.joined()
|
return self.map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sha256Hex() -> String {
|
||||||
|
let digest = SHA256.hash(data: self)
|
||||||
|
return digest.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
init?(hexString: String) {
|
init?(hexString: String) {
|
||||||
let len = hexString.count / 2
|
let len = hexString.count / 2
|
||||||
|
|||||||
@@ -22,11 +22,11 @@
|
|||||||
///
|
///
|
||||||
/// ## Wire Format
|
/// ## Wire Format
|
||||||
/// ```
|
/// ```
|
||||||
/// Header (Fixed 13 bytes):
|
/// Header (Fixed 14 bytes for v1, 16 bytes for v2):
|
||||||
/// +--------+------+-----+-----------+-------+----------------+
|
/// +--------+------+-----+-----------+-------+------------------+
|
||||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 or 4 bytes |
|
||||||
/// +--------+------+-----+-----------+-------+----------------+
|
/// +--------+------+-----+-----------+-------+------------------+
|
||||||
///
|
///
|
||||||
/// Variable sections:
|
/// Variable sections:
|
||||||
/// +----------+-------------+---------+------------+
|
/// +----------+-------------+---------+------------+
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
/// ## Flag Bits
|
/// ## Flag Bits
|
||||||
/// - Bit 0: Has recipient ID (directed message)
|
/// - Bit 0: Has recipient ID (directed message)
|
||||||
/// - Bit 1: Has signature (authenticated message)
|
/// - Bit 1: Has signature (authenticated message)
|
||||||
/// - Bit 2: Is compressed (LZ4 compression applied)
|
/// - Bit 2: Is compressed (zlib compression applied)
|
||||||
/// - Bits 3-7: Reserved for future use
|
/// - Bits 3-7: Reserved for future use
|
||||||
///
|
///
|
||||||
/// ## Size Constraints
|
/// ## Size Constraints
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
extension Data {
|
extension Data {
|
||||||
func trimmingNullBytes() -> Data {
|
func trimmingNullBytes() -> Data {
|
||||||
@@ -105,10 +106,32 @@ extension Data {
|
|||||||
/// their binary wire format representation.
|
/// their binary wire format representation.
|
||||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||||
struct BinaryProtocol {
|
struct BinaryProtocol {
|
||||||
static let headerSize = 13
|
static let v1HeaderSize = 14
|
||||||
|
static let v2HeaderSize = 16
|
||||||
static let senderIDSize = 8
|
static let senderIDSize = 8
|
||||||
static let recipientIDSize = 8
|
static let recipientIDSize = 8
|
||||||
static let signatureSize = 64
|
static let signatureSize = 64
|
||||||
|
|
||||||
|
// Field offsets within packet header
|
||||||
|
struct Offsets {
|
||||||
|
static let version = 0
|
||||||
|
static let type = 1
|
||||||
|
static let ttl = 2
|
||||||
|
static let timestamp = 3
|
||||||
|
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func headerSize(for version: UInt8) -> Int? {
|
||||||
|
switch version {
|
||||||
|
case 1: return v1HeaderSize
|
||||||
|
case 2: return v2HeaderSize
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lengthFieldSize(for version: UInt8) -> Int {
|
||||||
|
return version == 2 ? 4 : 2
|
||||||
|
}
|
||||||
|
|
||||||
struct Flags {
|
struct Flags {
|
||||||
static let hasRecipient: UInt8 = 0x01
|
static let hasRecipient: UInt8 = 0x01
|
||||||
@@ -118,70 +141,69 @@ struct BinaryProtocol {
|
|||||||
|
|
||||||
// Encode BitchatPacket to binary format
|
// Encode BitchatPacket to binary format
|
||||||
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||||
var data = Data()
|
let version = packet.version
|
||||||
|
guard version == 1 || version == 2 else { return nil }
|
||||||
|
|
||||||
// Try to compress payload if beneficial
|
// Try to compress payload when beneficial, keeping original size for later decoding
|
||||||
var payload = packet.payload
|
var payload = packet.payload
|
||||||
var originalPayloadSize: UInt16? = nil
|
|
||||||
var isCompressed = false
|
var isCompressed = false
|
||||||
|
var originalPayloadSize: Int?
|
||||||
if CompressionUtil.shouldCompress(payload) {
|
if CompressionUtil.shouldCompress(payload) {
|
||||||
if let compressedPayload = CompressionUtil.compress(payload) {
|
// Only compress when we can represent the original length in the outbound frame
|
||||||
// Store original size for decompression (2 bytes after payload)
|
let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max)
|
||||||
originalPayloadSize = UInt16(payload.count)
|
if payload.count <= maxRepresentable,
|
||||||
|
let compressedPayload = CompressionUtil.compress(payload) {
|
||||||
|
originalPayloadSize = payload.count
|
||||||
payload = compressedPayload
|
payload = compressedPayload
|
||||||
isCompressed = true
|
isCompressed = true
|
||||||
|
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header
|
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||||
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
||||||
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
let payloadDataSize = payload.count + originalSizeFieldBytes
|
||||||
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
|
||||||
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
||||||
data.reserveCapacity(estimated)
|
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
||||||
data.append(packet.version)
|
|
||||||
|
guard let headerSize = headerSize(for: version) else { return nil }
|
||||||
|
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
|
||||||
|
let estimatedPayload = payloadDataSize
|
||||||
|
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
||||||
|
var data = Data()
|
||||||
|
data.reserveCapacity(estimatedHeader + estimatedPayload + estimatedSignature + 255)
|
||||||
|
|
||||||
|
data.append(version)
|
||||||
data.append(packet.type)
|
data.append(packet.type)
|
||||||
data.append(packet.ttl)
|
data.append(packet.ttl)
|
||||||
|
|
||||||
// Timestamp (8 bytes, big-endian)
|
for shift in stride(from: 56, through: 0, by: -8) {
|
||||||
for i in (0..<8).reversed() {
|
data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF))
|
||||||
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flags
|
|
||||||
var flags: UInt8 = 0
|
var flags: UInt8 = 0
|
||||||
if packet.recipientID != nil {
|
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
||||||
flags |= Flags.hasRecipient
|
if packet.signature != nil { flags |= Flags.hasSignature }
|
||||||
}
|
if isCompressed { flags |= Flags.isCompressed }
|
||||||
if packet.signature != nil {
|
|
||||||
flags |= Flags.hasSignature
|
|
||||||
}
|
|
||||||
if isCompressed {
|
|
||||||
flags |= Flags.isCompressed
|
|
||||||
}
|
|
||||||
data.append(flags)
|
data.append(flags)
|
||||||
|
|
||||||
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
if version == 2 {
|
||||||
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
let length = UInt32(payloadDataSize)
|
||||||
let payloadLength = UInt16(payloadDataSize)
|
for shift in stride(from: 24, through: 0, by: -8) {
|
||||||
|
data.append(UInt8((length >> UInt32(shift)) & 0xFF))
|
||||||
|
}
|
||||||
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
} else {
|
||||||
data.append(UInt8(payloadLength & 0xFF))
|
let length = UInt16(payloadDataSize)
|
||||||
|
data.append(UInt8((length >> 8) & 0xFF))
|
||||||
// SenderID (exactly 8 bytes)
|
data.append(UInt8(length & 0xFF))
|
||||||
|
}
|
||||||
|
|
||||||
let senderBytes = packet.senderID.prefix(senderIDSize)
|
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||||
data.append(senderBytes)
|
data.append(senderBytes)
|
||||||
if senderBytes.count < senderIDSize {
|
if senderBytes.count < senderIDSize {
|
||||||
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecipientID (if present)
|
|
||||||
if let recipientID = packet.recipientID {
|
if let recipientID = packet.recipientID {
|
||||||
let recipientBytes = recipientID.prefix(recipientIDSize)
|
let recipientBytes = recipientID.prefix(recipientIDSize)
|
||||||
data.append(recipientBytes)
|
data.append(recipientBytes)
|
||||||
@@ -189,30 +211,30 @@ struct BinaryProtocol {
|
|||||||
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
|
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload (with original size prepended if compressed)
|
|
||||||
if isCompressed, let originalSize = originalPayloadSize {
|
if isCompressed, let originalSize = originalPayloadSize {
|
||||||
// Prepend original size (2 bytes, big-endian)
|
if version == 2 {
|
||||||
data.append(UInt8((originalSize >> 8) & 0xFF))
|
let value = UInt32(originalSize)
|
||||||
data.append(UInt8(originalSize & 0xFF))
|
for shift in stride(from: 24, through: 0, by: -8) {
|
||||||
|
data.append(UInt8((value >> UInt32(shift)) & 0xFF))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let value = UInt16(originalSize)
|
||||||
|
data.append(UInt8((value >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(value & 0xFF))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
data.append(payload)
|
data.append(payload)
|
||||||
|
|
||||||
// Signature (if present)
|
|
||||||
if let signature = packet.signature {
|
if let signature = packet.signature {
|
||||||
data.append(signature.prefix(signatureSize))
|
data.append(signature.prefix(signatureSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Apply padding to standard block sizes for traffic analysis resistance
|
|
||||||
if padding {
|
if padding {
|
||||||
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||||
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
|
return MessagePadding.pad(data, toSize: optimalSize)
|
||||||
return paddedData
|
|
||||||
} else {
|
|
||||||
// Caller explicitly requested no padding (e.g., BLE write path)
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode binary data to BitchatPacket
|
// Decode binary data to BitchatPacket
|
||||||
@@ -227,87 +249,112 @@ struct BinaryProtocol {
|
|||||||
|
|
||||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||||
// Minimum size: header + senderID
|
guard raw.count >= v1HeaderSize + senderIDSize else { return nil }
|
||||||
guard raw.count >= headerSize + senderIDSize else { return nil }
|
|
||||||
|
|
||||||
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||||
guard let base = buf.baseAddress else { return nil }
|
guard let base = buf.baseAddress else { return nil }
|
||||||
var offset = 0
|
var offset = 0
|
||||||
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
||||||
// Read single byte
|
|
||||||
func read8() -> UInt8? {
|
func read8() -> UInt8? {
|
||||||
guard require(1) else { return nil }
|
guard require(1) else { return nil }
|
||||||
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
let value = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
||||||
offset += 1
|
offset += 1
|
||||||
return v
|
return value
|
||||||
}
|
}
|
||||||
// Read big-endian 16-bit
|
|
||||||
func read16() -> UInt16? {
|
func read16() -> UInt16? {
|
||||||
guard require(2) else { return nil }
|
guard require(2) else { return nil }
|
||||||
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||||
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
|
let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1])
|
||||||
offset += 2
|
offset += 2
|
||||||
return v
|
return value
|
||||||
|
}
|
||||||
|
func read32() -> UInt32? {
|
||||||
|
guard require(4) else { return nil }
|
||||||
|
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||||
|
let value = (UInt32(ptr[0]) << 24) | (UInt32(ptr[1]) << 16) | (UInt32(ptr[2]) << 8) | UInt32(ptr[3])
|
||||||
|
offset += 4
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
// Copy N bytes into Data
|
|
||||||
func readData(_ n: Int) -> Data? {
|
func readData(_ n: Int) -> Data? {
|
||||||
guard require(n) else { return nil }
|
guard require(n) else { return nil }
|
||||||
let ptr = base.advanced(by: offset)
|
let ptr = base.advanced(by: offset)
|
||||||
let d = Data(bytes: ptr, count: n)
|
let data = Data(bytes: ptr, count: n)
|
||||||
offset += n
|
offset += n
|
||||||
return d
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version
|
guard let version = read8(), version == 1 || version == 2 else { return nil }
|
||||||
guard let version = read8(), version == 1 else { return nil }
|
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||||
guard let type = read8() else { return nil }
|
guard let headerSize = headerSize(for: version) else { return nil }
|
||||||
guard let ttl = read8() else { return nil }
|
let minimumRequired = headerSize + senderIDSize
|
||||||
|
guard raw.count >= minimumRequired else { return nil }
|
||||||
|
|
||||||
// Timestamp 8 bytes BE
|
guard let type = read8(), let ttl = read8() else { return nil }
|
||||||
guard require(8) else { return nil }
|
|
||||||
var ts: UInt64 = 0
|
var timestamp: UInt64 = 0
|
||||||
for _ in 0..<8 {
|
for _ in 0..<8 {
|
||||||
guard let b = read8() else { return nil }
|
guard let byte = read8() else { return nil }
|
||||||
ts = (ts << 8) | UInt64(b)
|
timestamp = (timestamp << 8) | UInt64(byte)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flags
|
|
||||||
guard let flags = read8() else { return nil }
|
guard let flags = read8() else { return nil }
|
||||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||||
|
|
||||||
// Payload length
|
let payloadLength: Int
|
||||||
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
if version == 2 {
|
||||||
|
guard let len = read32() else { return nil }
|
||||||
|
payloadLength = Int(len)
|
||||||
|
} else {
|
||||||
|
guard let len = read16() else { return nil }
|
||||||
|
payloadLength = Int(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard payloadLength >= 0 else { return nil }
|
||||||
|
|
||||||
// SenderID
|
|
||||||
guard let senderID = readData(senderIDSize) else { return nil }
|
guard let senderID = readData(senderIDSize) else { return nil }
|
||||||
|
|
||||||
// Recipient
|
|
||||||
var recipientID: Data? = nil
|
var recipientID: Data? = nil
|
||||||
if hasRecipient {
|
if hasRecipient {
|
||||||
recipientID = readData(recipientIDSize)
|
recipientID = readData(recipientIDSize)
|
||||||
if recipientID == nil { return nil }
|
if recipientID == nil { return nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload
|
|
||||||
let payload: Data
|
let payload: Data
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
// Need original size (2 bytes)
|
guard payloadLength >= lengthFieldBytes else { return nil }
|
||||||
guard let origSize16 = read16() else { return nil }
|
let originalSize: Int
|
||||||
let originalSize = Int(origSize16)
|
if version == 2 {
|
||||||
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
|
guard let rawSize = read32() else { return nil }
|
||||||
let compSize = Int(payloadLen) - 2
|
originalSize = Int(rawSize)
|
||||||
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
|
} else {
|
||||||
|
guard let rawSize = read16() else { return nil }
|
||||||
|
originalSize = Int(rawSize)
|
||||||
|
}
|
||||||
|
// Guard to keep decompression bounded to sane BLE payload limits
|
||||||
|
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxPayloadBytes else { return nil }
|
||||||
|
let compressedSize = payloadLength - lengthFieldBytes
|
||||||
|
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
|
||||||
|
|
||||||
|
// Validate compression ratio to prevent zip bomb attacks
|
||||||
|
// Primary protection: originalSize capped at 1MB (line 336)
|
||||||
|
// Defense-in-depth: reject extreme ratios (prevents DoS via memory allocation)
|
||||||
|
guard compressedSize > 0 else { return nil }
|
||||||
|
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
||||||
|
guard compressionRatio <= 50_000.0 else {
|
||||||
|
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
||||||
decompressed.count == originalSize else { return nil }
|
decompressed.count == originalSize else { return nil }
|
||||||
payload = decompressed
|
payload = decompressed
|
||||||
} else {
|
} else {
|
||||||
guard let p = readData(Int(payloadLen)) else { return nil }
|
guard let rawPayload = readData(payloadLength) else { return nil }
|
||||||
payload = p
|
payload = rawPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signature
|
|
||||||
var signature: Data? = nil
|
var signature: Data? = nil
|
||||||
if hasSignature {
|
if hasSignature {
|
||||||
signature = readData(signatureSize)
|
signature = readData(signatureSize)
|
||||||
@@ -320,10 +367,11 @@ struct BinaryProtocol {
|
|||||||
type: type,
|
type: type,
|
||||||
senderID: senderID,
|
senderID: senderID,
|
||||||
recipientID: recipientID,
|
recipientID: recipientID,
|
||||||
timestamp: ts,
|
timestamp: timestamp,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
ttl: ttl
|
ttl: ttl,
|
||||||
|
version: version
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//
|
||||||
|
// BitchatFilePacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ enum MessageType: UInt8 {
|
|||||||
|
|
||||||
// Fragmentation (simplified)
|
// Fragmentation (simplified)
|
||||||
case fragment = 0x20 // Single fragment type for large messages
|
case fragment = 0x20 // Single fragment type for large messages
|
||||||
|
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -89,6 +90,7 @@ enum MessageType: UInt8 {
|
|||||||
case .noiseHandshake: return "noiseHandshake"
|
case .noiseHandshake: return "noiseHandshake"
|
||||||
case .noiseEncrypted: return "noiseEncrypted"
|
case .noiseEncrypted: return "noiseEncrypted"
|
||||||
case .fragment: return "fragment"
|
case .fragment: return "fragment"
|
||||||
|
case .fileTransfer: return "fileTransfer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1558
-462
File diff suppressed because it is too large
Load Diff
@@ -1,328 +0,0 @@
|
|||||||
//
|
|
||||||
// ColorPaletteService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Manages consistent color assignment for peers using minimal-distance algorithm
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// Service that assigns consistent, visually distinct colors to peers
|
|
||||||
/// Uses a minimal-distance hue assignment algorithm to maximize color separation
|
|
||||||
final class ColorPaletteService {
|
|
||||||
|
|
||||||
// MARK: - Palette State
|
|
||||||
|
|
||||||
private var peerPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
|
||||||
private var peerPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
|
||||||
private var peerPaletteSeeds: [String: String] = [:] // peerID -> seed used
|
|
||||||
|
|
||||||
private var nostrPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
|
||||||
private var nostrPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
|
||||||
private var nostrPaletteSeeds: [String: String] = [:] // pubkey -> seed used
|
|
||||||
|
|
||||||
// MARK: - Configuration
|
|
||||||
|
|
||||||
private let slotCount: Int
|
|
||||||
private let avoidCenter: Double // Hue to avoid (typically orange for self)
|
|
||||||
private let avoidDelta: Double
|
|
||||||
private let saturationDark: Double
|
|
||||||
private let saturationLight: Double
|
|
||||||
private let baseBrightnessDark: Double
|
|
||||||
private let baseBrightnessLight: Double
|
|
||||||
private let ringDeltaDark: Double
|
|
||||||
private let ringDeltaLight: Double
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
|
|
||||||
init(
|
|
||||||
slotCount: Int = max(8, TransportConfig.uiPeerPaletteSlots),
|
|
||||||
avoidCenter: Double = 30.0 / 360.0, // Orange hue
|
|
||||||
avoidDelta: Double = TransportConfig.uiColorHueAvoidanceDelta,
|
|
||||||
saturationDark: Double = 0.80,
|
|
||||||
saturationLight: Double = 0.70,
|
|
||||||
baseBrightnessDark: Double = 0.75,
|
|
||||||
baseBrightnessLight: Double = 0.45,
|
|
||||||
ringDeltaDark: Double = TransportConfig.uiPeerPaletteRingBrightnessDeltaDark,
|
|
||||||
ringDeltaLight: Double = TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
|
|
||||||
) {
|
|
||||||
self.slotCount = slotCount
|
|
||||||
self.avoidCenter = avoidCenter
|
|
||||||
self.avoidDelta = avoidDelta
|
|
||||||
self.saturationDark = saturationDark
|
|
||||||
self.saturationLight = saturationLight
|
|
||||||
self.baseBrightnessDark = baseBrightnessDark
|
|
||||||
self.baseBrightnessLight = baseBrightnessLight
|
|
||||||
self.ringDeltaDark = ringDeltaDark
|
|
||||||
self.ringDeltaLight = ringDeltaLight
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API
|
|
||||||
|
|
||||||
/// Get color for a mesh peer
|
|
||||||
func colorForMeshPeer(
|
|
||||||
peerID: String,
|
|
||||||
isDark: Bool,
|
|
||||||
myPeerID: String,
|
|
||||||
allPeers: [BitchatPeer],
|
|
||||||
getNoiseKeyForShortID: (String) -> String?
|
|
||||||
) -> Color {
|
|
||||||
// Ensure palette is up to date
|
|
||||||
rebuildPeerPaletteIfNeeded(
|
|
||||||
myPeerID: myPeerID,
|
|
||||||
allPeers: allPeers,
|
|
||||||
getNoiseKeyForShortID: getNoiseKeyForShortID
|
|
||||||
)
|
|
||||||
|
|
||||||
let entry = (isDark ? peerPaletteDark[peerID] : peerPaletteLight[peerID])
|
|
||||||
let orange = Color.orange
|
|
||||||
if peerID == myPeerID { return orange }
|
|
||||||
|
|
||||||
let saturation: Double = isDark ? saturationDark : saturationLight
|
|
||||||
let baseBrightness: Double = isDark ? baseBrightnessDark : baseBrightnessLight
|
|
||||||
let ringDelta = isDark ? ringDeltaDark : ringDeltaLight
|
|
||||||
|
|
||||||
if let e = entry {
|
|
||||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
|
||||||
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to seed color if not in palette
|
|
||||||
let seed = meshSeed(for: peerID, getNoiseKeyForShortID: getNoiseKeyForShortID)
|
|
||||||
return Color(peerSeed: seed, isDark: isDark)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get color for a Nostr participant
|
|
||||||
func colorForNostrPubkey(
|
|
||||||
pubkeyHexLowercased: String,
|
|
||||||
isDark: Bool,
|
|
||||||
myNostrPubkey: String?,
|
|
||||||
geohashPeople: [(id: String, seed: String)]
|
|
||||||
) -> Color {
|
|
||||||
rebuildNostrPaletteIfNeeded(
|
|
||||||
myNostrPubkey: myNostrPubkey,
|
|
||||||
geohashPeople: geohashPeople
|
|
||||||
)
|
|
||||||
|
|
||||||
let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased])
|
|
||||||
if let me = myNostrPubkey, pubkeyHexLowercased == me { return .orange }
|
|
||||||
|
|
||||||
let saturation: Double = isDark ? saturationDark : saturationLight
|
|
||||||
let baseBrightness: Double = isDark ? baseBrightnessDark : baseBrightnessLight
|
|
||||||
let ringDelta = isDark ? ringDeltaDark : ringDeltaLight
|
|
||||||
|
|
||||||
if let e = entry {
|
|
||||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
|
||||||
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to seed color
|
|
||||||
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get color for a message sender (auto-detects type)
|
|
||||||
func peerColor(
|
|
||||||
for message: BitchatMessage,
|
|
||||||
isDark: Bool,
|
|
||||||
myPeerID: String,
|
|
||||||
myNostrPubkey: String?,
|
|
||||||
nostrKeyMapping: [String: String],
|
|
||||||
allPeers: [BitchatPeer],
|
|
||||||
geohashPeople: [(id: String, seed: String)],
|
|
||||||
getNoiseKeyForShortID: (String) -> String?
|
|
||||||
) -> Color {
|
|
||||||
if let spid = message.senderPeerID?.id {
|
|
||||||
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
|
|
||||||
let bare: String = {
|
|
||||||
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
|
|
||||||
if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) }
|
|
||||||
return spid
|
|
||||||
}()
|
|
||||||
let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased()
|
|
||||||
return colorForNostrPubkey(
|
|
||||||
pubkeyHexLowercased: full,
|
|
||||||
isDark: isDark,
|
|
||||||
myNostrPubkey: myNostrPubkey,
|
|
||||||
geohashPeople: geohashPeople
|
|
||||||
)
|
|
||||||
} else if spid.count == 16 {
|
|
||||||
return colorForMeshPeer(
|
|
||||||
peerID: spid,
|
|
||||||
isDark: isDark,
|
|
||||||
myPeerID: myPeerID,
|
|
||||||
allPeers: allPeers,
|
|
||||||
getNoiseKeyForShortID: getNoiseKeyForShortID
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return colorForMeshPeer(
|
|
||||||
peerID: spid.lowercased(),
|
|
||||||
isDark: isDark,
|
|
||||||
myPeerID: myPeerID,
|
|
||||||
allPeers: allPeers,
|
|
||||||
getNoiseKeyForShortID: getNoiseKeyForShortID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback when we only have a display name
|
|
||||||
return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset all palette state (useful for testing)
|
|
||||||
func reset() {
|
|
||||||
peerPaletteLight.removeAll()
|
|
||||||
peerPaletteDark.removeAll()
|
|
||||||
peerPaletteSeeds.removeAll()
|
|
||||||
nostrPaletteLight.removeAll()
|
|
||||||
nostrPaletteDark.removeAll()
|
|
||||||
nostrPaletteSeeds.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
|
|
||||||
private func meshSeed(for peerID: String, getNoiseKeyForShortID: (String) -> String?) -> String {
|
|
||||||
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
|
|
||||||
return "noise:" + full
|
|
||||||
}
|
|
||||||
return peerID.lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func rebuildPeerPaletteIfNeeded(
|
|
||||||
myPeerID: String,
|
|
||||||
allPeers: [BitchatPeer],
|
|
||||||
getNoiseKeyForShortID: (String) -> String?
|
|
||||||
) {
|
|
||||||
// Build current peer->seed map (excluding self)
|
|
||||||
var currentSeeds: [String: String] = [:]
|
|
||||||
for p in allPeers where p.peerID.id != myPeerID {
|
|
||||||
currentSeeds[p.peerID.id] = meshSeed(for: p.peerID.id, getNoiseKeyForShortID: getNoiseKeyForShortID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If seeds unchanged and palette exists for both themes, skip
|
|
||||||
if currentSeeds == peerPaletteSeeds,
|
|
||||||
peerPaletteLight.keys.count == currentSeeds.count,
|
|
||||||
peerPaletteDark.keys.count == currentSeeds.count {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
peerPaletteSeeds = currentSeeds
|
|
||||||
|
|
||||||
// Generate palette
|
|
||||||
let mapping = assignColorsMinimalDistance(seeds: currentSeeds, previousMapping: peerPaletteLight)
|
|
||||||
peerPaletteLight = mapping
|
|
||||||
peerPaletteDark = mapping
|
|
||||||
}
|
|
||||||
|
|
||||||
private func rebuildNostrPaletteIfNeeded(
|
|
||||||
myNostrPubkey: String?,
|
|
||||||
geohashPeople: [(id: String, seed: String)]
|
|
||||||
) {
|
|
||||||
// Build seeds map from currently visible geohash people (excluding self)
|
|
||||||
var currentSeeds: [String: String] = [:]
|
|
||||||
for p in geohashPeople where p.id != myNostrPubkey {
|
|
||||||
currentSeeds[p.id] = p.seed
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentSeeds == nostrPaletteSeeds,
|
|
||||||
nostrPaletteLight.keys.count == currentSeeds.count,
|
|
||||||
nostrPaletteDark.keys.count == currentSeeds.count {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
nostrPaletteSeeds = currentSeeds
|
|
||||||
|
|
||||||
let mapping = assignColorsMinimalDistance(seeds: currentSeeds, previousMapping: nostrPaletteLight)
|
|
||||||
nostrPaletteLight = mapping
|
|
||||||
nostrPaletteDark = mapping
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Minimal-Distance Color Assignment Algorithm
|
|
||||||
|
|
||||||
private func assignColorsMinimalDistance(
|
|
||||||
seeds: [String: String],
|
|
||||||
previousMapping: [String: (slot: Int, ring: Int, hue: Double)]
|
|
||||||
) -> [String: (slot: Int, ring: Int, hue: Double)] {
|
|
||||||
// Generate evenly spaced hue slots avoiding self-orange range
|
|
||||||
var slots: [Double] = []
|
|
||||||
for i in 0..<slotCount {
|
|
||||||
let hue = Double(i) / Double(slotCount)
|
|
||||||
if abs(hue - avoidCenter) < avoidDelta { continue }
|
|
||||||
slots.append(hue)
|
|
||||||
}
|
|
||||||
if slots.isEmpty {
|
|
||||||
// Safety: if avoidance consumed all (shouldn't happen), fall back to full slots
|
|
||||||
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to compute circular distance
|
|
||||||
func circDist(_ a: Double, _ b: Double) -> Double {
|
|
||||||
let d = abs(a - b)
|
|
||||||
return d > 0.5 ? 1.0 - d : d
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign slots to peers to maximize minimal distance, deterministically
|
|
||||||
let peers = seeds.keys.sorted() // stable order
|
|
||||||
|
|
||||||
// Preferred slot index by seed (wrapping to available slots)
|
|
||||||
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
|
|
||||||
let h = (seeds[id] ?? id).djb2()
|
|
||||||
let idx = Int(h % UInt64(slots.count))
|
|
||||||
return (id, idx)
|
|
||||||
})
|
|
||||||
|
|
||||||
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
|
||||||
var usedSlots = Set<Int>()
|
|
||||||
var usedHues: [Double] = []
|
|
||||||
|
|
||||||
// Keep previous assignments if still valid to minimize churn
|
|
||||||
for (id, entry) in previousMapping {
|
|
||||||
if seeds.keys.contains(id), entry.slot < slots.count { // slot index still valid
|
|
||||||
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
|
|
||||||
usedSlots.insert(entry.slot)
|
|
||||||
usedHues.append(slots[entry.slot])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// First ring assignment using free slots
|
|
||||||
let unassigned = peers.filter { mapping[$0] == nil }
|
|
||||||
for id in unassigned {
|
|
||||||
// If a preferred slot free, take it
|
|
||||||
let preferred = prefIndex[id] ?? 0
|
|
||||||
if !usedSlots.contains(preferred) && preferred < slots.count {
|
|
||||||
mapping[id] = (preferred, 0, slots[preferred])
|
|
||||||
usedSlots.insert(preferred)
|
|
||||||
usedHues.append(slots[preferred])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Choose free slot maximizing minimal distance to used hues
|
|
||||||
var bestSlot: Int? = nil
|
|
||||||
var bestScore: Double = -1
|
|
||||||
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
|
|
||||||
let hue = slots[sIdx]
|
|
||||||
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
|
|
||||||
// Bias toward preferred index for stability
|
|
||||||
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
|
||||||
let score = minDist + 0.05 * bias
|
|
||||||
if score > bestScore { bestScore = score; bestSlot = sIdx }
|
|
||||||
}
|
|
||||||
if let s = bestSlot {
|
|
||||||
mapping[id] = (s, 0, slots[s])
|
|
||||||
usedSlots.insert(s)
|
|
||||||
usedHues.append(slots[s])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overflow peers: assign additional rings by reusing slots with stable preference
|
|
||||||
let stillUnassigned = peers.filter { mapping[$0] == nil }
|
|
||||||
if !stillUnassigned.isEmpty {
|
|
||||||
for (idx, id) in stillUnassigned.enumerated() {
|
|
||||||
let preferred = prefIndex[id] ?? 0
|
|
||||||
// Spread over slots by rotating from preferred with a golden-step
|
|
||||||
let goldenStep = 7 // small prime step for dispersion
|
|
||||||
let s = (preferred + idx * goldenStep) % slots.count
|
|
||||||
mapping[id] = (s, 1, slots[s])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapping
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,7 +42,7 @@ final class CommandProcessor {
|
|||||||
case .location: return true
|
case .location: return true
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
|
||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "/m", "/msg":
|
case "/m", "/msg":
|
||||||
@@ -65,14 +65,11 @@ final class CommandProcessor {
|
|||||||
case "/unfav":
|
case "/unfav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
return handleFavorite(args, add: false)
|
return handleFavorite(args, add: false)
|
||||||
//
|
|
||||||
case "/help", "/h":
|
|
||||||
return .error(message: "unknown command: \(cmd)")
|
|
||||||
default:
|
default:
|
||||||
return .error(message: "unknown command: \(cmd)")
|
return .error(message: "unknown command: \(cmd)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Command Handlers
|
// MARK: - Command Handlers
|
||||||
|
|
||||||
private func handleMessage(_ args: String) -> CommandResult {
|
private func handleMessage(_ args: String) -> CommandResult {
|
||||||
@@ -104,7 +101,7 @@ final class CommandProcessor {
|
|||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Geohash context: show visible geohash participants (exclude self)
|
// Geohash context: show visible geohash participants (exclude self)
|
||||||
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||||
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||||
let people = vm.visibleGeohashPeople().filter { person in
|
let people = vm.visibleGeohashPeople().filter { person in
|
||||||
if let me = myHex { return person.id.lowercased() != me }
|
if let me = myHex { return person.id.lowercased() != me }
|
||||||
return true
|
return true
|
||||||
@@ -148,9 +145,9 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
if chatViewModel?.selectedPrivateChatPeer != nil {
|
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||||
// In private chat
|
// In private chat
|
||||||
if let peerNickname = meshService?.peerNickname(peerID: PeerID(str: targetPeerID)) {
|
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
||||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||||
meshService?.sendPrivateMessage(personalMessage, to: PeerID(str: targetPeerID),
|
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||||
recipientNickname: peerNickname,
|
recipientNickname: peerNickname,
|
||||||
messageID: UUID().uuidString)
|
messageID: UUID().uuidString)
|
||||||
// Also add a local system message so the sender sees a natural-language confirmation
|
// Also add a local system message so the sender sees a natural-language confirmation
|
||||||
@@ -214,7 +211,7 @@ final class CommandProcessor {
|
|||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is already blocked")
|
return .success(message: "\(nickname) is already blocked")
|
||||||
}
|
}
|
||||||
@@ -258,7 +255,7 @@ final class CommandProcessor {
|
|||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is not blocked")
|
return .success(message: "\(nickname) is not blocked")
|
||||||
}
|
}
|
||||||
@@ -285,7 +282,7 @@ final class CommandProcessor {
|
|||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let noisePublicKey = Data(hexString: peerID) else {
|
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||||
return .error(message: "can't find peer: \(nickname)")
|
return .error(message: "can't find peer: \(nickname)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,19 +308,4 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,73 +0,0 @@
|
|||||||
//
|
|
||||||
// DeliveryTrackingService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Service for tracking message delivery and read status
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Service that manages delivery status updates for messages
|
|
||||||
/// Prevents status downgrades (e.g., read → delivered) and maintains consistency
|
|
||||||
final class DeliveryTrackingService {
|
|
||||||
|
|
||||||
// MARK: - Public API
|
|
||||||
|
|
||||||
/// Update delivery status for a message, preventing downgrades
|
|
||||||
/// - Parameters:
|
|
||||||
/// - messageID: The message ID to update
|
|
||||||
/// - status: The new delivery status
|
|
||||||
/// - messages: Array of public messages (inout for mutation)
|
|
||||||
/// - privateChats: Dictionary of private chats (inout for mutation)
|
|
||||||
/// - notifyChange: Closure to trigger UI update
|
|
||||||
func updateStatus(
|
|
||||||
messageID: String,
|
|
||||||
status: DeliveryStatus,
|
|
||||||
messages: inout [BitchatMessage],
|
|
||||||
privateChats: inout [String: [BitchatMessage]],
|
|
||||||
notifyChange: @escaping () -> Void
|
|
||||||
) {
|
|
||||||
// Update in main messages
|
|
||||||
if let index = messages.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
let currentStatus = messages[index].deliveryStatus
|
|
||||||
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
|
||||||
messages[index].deliveryStatus = status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update in private chats
|
|
||||||
for (peerID, chatMessages) in privateChats {
|
|
||||||
guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue }
|
|
||||||
|
|
||||||
let currentStatus = chatMessages[index].deliveryStatus
|
|
||||||
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
|
|
||||||
|
|
||||||
// Update delivery status
|
|
||||||
privateChats[peerID]?[index].deliveryStatus = status
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger UI update
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
notifyChange()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
|
|
||||||
/// Check if we should skip a status update to prevent downgrades
|
|
||||||
private func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
|
|
||||||
guard let current = currentStatus else { return false }
|
|
||||||
|
|
||||||
// Don't downgrade from read to delivered or sent
|
|
||||||
switch (current, newStatus) {
|
|
||||||
case (.read, .delivered):
|
|
||||||
return true
|
|
||||||
case (.read, .sent):
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
private static let storageKey = "chat.bitchat.favorites"
|
private static let storageKey = "chat.bitchat.favorites"
|
||||||
private static let keychainService = "chat.bitchat.favorites"
|
private static let keychainService = "chat.bitchat.favorites"
|
||||||
|
private let keychain: KeychainHelperProtocol
|
||||||
|
|
||||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||||
@@ -35,7 +36,8 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
private init() {
|
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||||
|
self.keychain = keychain
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
||||||
// Update mutual favorites when favorites change
|
// Update mutual favorites when favorites change
|
||||||
@@ -45,13 +47,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
.assign(to: &$mutualFavorites)
|
.assign(to: &$mutualFavorites)
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Clean up Combine subscriptions
|
|
||||||
cancellables.removeAll()
|
|
||||||
SecureLogger.debug("FavoritesPersistenceService deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add or update a favorite
|
/// Add or update a favorite
|
||||||
func addFavorite(
|
func addFavorite(
|
||||||
peerNoisePublicKey: Data,
|
peerNoisePublicKey: Data,
|
||||||
@@ -202,7 +198,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
saveFavorites()
|
saveFavorites()
|
||||||
|
|
||||||
// Delete from keychain directly
|
// Delete from keychain directly
|
||||||
KeychainHelper.delete(
|
keychain.delete(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
)
|
)
|
||||||
@@ -222,10 +218,11 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
let data = try encoder.encode(relationships)
|
let data = try encoder.encode(relationships)
|
||||||
|
|
||||||
// Store in keychain for security
|
// Store in keychain for security
|
||||||
KeychainHelper.save(
|
keychain.save(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
data: data,
|
data: data,
|
||||||
service: Self.keychainService
|
service: Self.keychainService,
|
||||||
|
accessible: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
// Successfully saved favorites
|
// Successfully saved favorites
|
||||||
@@ -237,7 +234,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
private func loadFavorites() {
|
private func loadFavorites() {
|
||||||
// Loading favorites from keychain
|
// Loading favorites from keychain
|
||||||
|
|
||||||
guard let data = KeychainHelper.load(
|
guard let data = keychain.load(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
) else {
|
) else {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||||
/// - Persistence: UserDefaults (JSON string array)
|
/// - Persistence: UserDefaults (JSON string array)
|
||||||
@@ -15,8 +16,10 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
private let storeKey = "locationChannel.bookmarks"
|
private let storeKey = "locationChannel.bookmarks"
|
||||||
private let namesStoreKey = "locationChannel.bookmarkNames"
|
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||||
private var membership: Set<String> = []
|
private var membership: Set<String> = []
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
private let geocoder = CLGeocoder()
|
private let geocoder = CLGeocoder()
|
||||||
private var resolving: Set<String> = []
|
private var resolving: Set<String> = []
|
||||||
|
#endif
|
||||||
|
|
||||||
private let storage: UserDefaults
|
private let storage: UserDefaults
|
||||||
|
|
||||||
@@ -25,12 +28,6 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Cancel any pending geocoding operations
|
|
||||||
geocoder.cancelGeocode()
|
|
||||||
SecureLogger.debug("GeohashBookmarksStore deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
func isBookmarked(_ geohash: String) -> Bool {
|
func isBookmarked(_ geohash: String) -> Bool {
|
||||||
return membership.contains(Self.normalize(geohash))
|
return membership.contains(Self.normalize(geohash))
|
||||||
@@ -121,6 +118,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
let gh = Self.normalize(geohash)
|
let gh = Self.normalize(geohash)
|
||||||
guard !gh.isEmpty else { return }
|
guard !gh.isEmpty else { return }
|
||||||
if bookmarkNames[gh] != nil { return }
|
if bookmarkNames[gh] != nil { return }
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
if resolving.contains(gh) { return }
|
if resolving.contains(gh) { return }
|
||||||
resolving.insert(gh)
|
resolving.insert(gh)
|
||||||
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
||||||
@@ -151,8 +149,10 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||||
var uniqueAdmins = OrderedSet<String>()
|
var uniqueAdmins = OrderedSet<String>()
|
||||||
var idx = 0
|
var idx = 0
|
||||||
@@ -215,15 +215,5 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
/// Testing-only reset helper
|
|
||||||
func _resetForTesting() {
|
|
||||||
bookmarks.removeAll()
|
|
||||||
membership.removeAll()
|
|
||||||
bookmarkNames.removeAll()
|
|
||||||
persist()
|
|
||||||
persistNames()
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
//
|
|
||||||
// GeohashParticipantsService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Manages tracking of participants in geohash-based location channels
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// Service for tracking and managing participants in geohash channels
|
|
||||||
/// Handles automatic expiration, refresh timers, and participant list management
|
|
||||||
final class GeohashParticipantsService: ObservableObject {
|
|
||||||
|
|
||||||
// MARK: - Published Properties
|
|
||||||
|
|
||||||
@Published private(set) var geohashPeople: [GeoPerson] = []
|
|
||||||
|
|
||||||
// MARK: - Private State
|
|
||||||
|
|
||||||
private var geoParticipants: [String: [String: Date]] = [:] // geohash -> [pubkeyHex -> lastSeen]
|
|
||||||
private var geoParticipantsTimer: Timer? = nil
|
|
||||||
private var currentGeohash: String? = nil
|
|
||||||
|
|
||||||
// MARK: - Dependencies
|
|
||||||
|
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
|
||||||
private let displayNameProvider: (String) -> String
|
|
||||||
|
|
||||||
// MARK: - Configuration
|
|
||||||
|
|
||||||
private let activityWindowSeconds: TimeInterval
|
|
||||||
private let refreshIntervalSeconds: TimeInterval
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
|
|
||||||
init(
|
|
||||||
identityManager: SecureIdentityStateManagerProtocol,
|
|
||||||
displayNameProvider: @escaping (String) -> String,
|
|
||||||
activityWindowSeconds: TimeInterval = TransportConfig.uiRecentCutoffFiveMinutesSeconds,
|
|
||||||
refreshIntervalSeconds: TimeInterval = 30.0
|
|
||||||
) {
|
|
||||||
self.identityManager = identityManager
|
|
||||||
self.displayNameProvider = displayNameProvider
|
|
||||||
self.activityWindowSeconds = activityWindowSeconds
|
|
||||||
self.refreshIntervalSeconds = refreshIntervalSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Note: deinit cannot call @MainActor methods
|
|
||||||
// Timer cleanup will happen automatically when service is deallocated
|
|
||||||
SecureLogger.debug("GeohashParticipantsService deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API
|
|
||||||
|
|
||||||
/// Set the current geohash being tracked (starts/stops timer accordingly)
|
|
||||||
func setCurrentGeohash(_ geohash: String?) {
|
|
||||||
if currentGeohash != geohash {
|
|
||||||
currentGeohash = geohash
|
|
||||||
refreshPeopleList()
|
|
||||||
|
|
||||||
if geohash != nil {
|
|
||||||
startTimer()
|
|
||||||
} else {
|
|
||||||
stopTimer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a participant activity in the current geohash
|
|
||||||
func recordParticipant(pubkeyHex: String) {
|
|
||||||
guard let gh = currentGeohash else { return }
|
|
||||||
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a participant activity in a specific geohash
|
|
||||||
func recordParticipant(pubkeyHex: String, geohash: String) {
|
|
||||||
let key = pubkeyHex.lowercased()
|
|
||||||
var map = geoParticipants[geohash] ?? [:]
|
|
||||||
map[key] = Date()
|
|
||||||
geoParticipants[geohash] = map
|
|
||||||
|
|
||||||
// Only refresh list if this geohash is currently selected
|
|
||||||
if currentGeohash == geohash {
|
|
||||||
refreshPeopleList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get visible people for the current geohash (without mutating state)
|
|
||||||
func visiblePeople() -> [GeoPerson] {
|
|
||||||
guard let gh = currentGeohash else { return [] }
|
|
||||||
return visiblePeople(for: gh)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get visible people for a specific geohash
|
|
||||||
func visiblePeople(for geohash: String) -> [GeoPerson] {
|
|
||||||
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
|
||||||
let map = (geoParticipants[geohash] ?? [:])
|
|
||||||
.filter { $0.value >= cutoff }
|
|
||||||
.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
|
||||||
let people = map
|
|
||||||
.map { (pub, seen) in
|
|
||||||
GeoPerson(id: pub, displayName: displayNameProvider(pub), lastSeen: seen)
|
|
||||||
}
|
|
||||||
.sorted { $0.lastSeen > $1.lastSeen }
|
|
||||||
return people
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get participant count for a specific geohash (using activity window)
|
|
||||||
func participantCount(for geohash: String) -> Int {
|
|
||||||
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
|
||||||
let map = geoParticipants[geohash] ?? [:]
|
|
||||||
return map.values.filter { $0 >= cutoff }.count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a participant from all geohashes (e.g., when blocked)
|
|
||||||
func removeParticipant(pubkeyHexLowercased: String) {
|
|
||||||
let hex = pubkeyHexLowercased.lowercased()
|
|
||||||
for (gh, var map) in geoParticipants {
|
|
||||||
map.removeValue(forKey: hex)
|
|
||||||
geoParticipants[gh] = map
|
|
||||||
}
|
|
||||||
refreshPeopleList()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all participant data (for testing or reset)
|
|
||||||
func reset() {
|
|
||||||
stopTimer()
|
|
||||||
geoParticipants.removeAll()
|
|
||||||
geohashPeople.removeAll()
|
|
||||||
currentGeohash = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
|
|
||||||
private func refreshPeopleList() {
|
|
||||||
guard let gh = currentGeohash else {
|
|
||||||
geohashPeople = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
|
||||||
var map = geoParticipants[gh] ?? [:]
|
|
||||||
|
|
||||||
// Prune expired entries
|
|
||||||
map = map.filter { $0.value >= cutoff }
|
|
||||||
|
|
||||||
// Remove blocked Nostr pubkeys
|
|
||||||
map = map.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
|
||||||
|
|
||||||
// Update cleaned map
|
|
||||||
geoParticipants[gh] = map
|
|
||||||
|
|
||||||
// Build display list
|
|
||||||
let people = map
|
|
||||||
.map { (pub, seen) in
|
|
||||||
GeoPerson(id: pub, displayName: displayNameProvider(pub), lastSeen: seen)
|
|
||||||
}
|
|
||||||
.sorted { $0.lastSeen > $1.lastSeen }
|
|
||||||
|
|
||||||
geohashPeople = people
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startTimer() {
|
|
||||||
stopTimer()
|
|
||||||
geoParticipantsTimer = Timer.scheduledTimer(withTimeInterval: refreshIntervalSeconds, repeats: true) { [weak self] _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.refreshPeopleList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopTimer() {
|
|
||||||
geoParticipantsTimer?.invalidate()
|
|
||||||
geoParticipantsTimer = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,34 +27,6 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.\(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
|
// MARK: - Identity Keys
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
switch status {
|
switch status {
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
break // will compute from location
|
break // will compute from location
|
||||||
default:
|
case .notDetermined, .restricted, .denied:
|
||||||
|
fallthrough
|
||||||
|
@unknown default:
|
||||||
if case .location(let ch) = selectedChannel {
|
if case .location(let ch) = selectedChannel {
|
||||||
teleported = teleportedSet.contains(ch.geohash)
|
teleported = teleportedSet.contains(ch.geohash)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,12 +51,6 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
self.dependencies = testDependencies
|
self.dependencies = testDependencies
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Note: deinit cannot call @MainActor functions
|
|
||||||
// Subscription cleanup will happen automatically when counter is deallocated
|
|
||||||
SecureLogger.debug("LocationNotesCounter deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscribe(geohash gh: String) {
|
func subscribe(geohash gh: String) {
|
||||||
let norm = gh.lowercased()
|
let norm = gh.lowercased()
|
||||||
if geohash == norm, subscriptionID != nil { return }
|
if geohash == norm, subscriptionID != nil { return }
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ struct LocationNotesDependencies {
|
|||||||
var sendEvent: SendEvent
|
var sendEvent: SendEvent
|
||||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||||
var now: () -> Date
|
var now: () -> Date
|
||||||
|
|
||||||
|
private static let idBridge = NostrIdentityBridge()
|
||||||
|
|
||||||
static let live = LocationNotesDependencies(
|
static let live = LocationNotesDependencies(
|
||||||
relayLookup: { geohash, count in
|
relayLookup: { geohash, count in
|
||||||
@@ -35,7 +37,7 @@ struct LocationNotesDependencies {
|
|||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
},
|
},
|
||||||
deriveIdentity: { geohash in
|
deriveIdentity: { geohash in
|
||||||
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
try idBridge.deriveIdentity(forGeohash: geohash)
|
||||||
},
|
},
|
||||||
now: { Date() }
|
now: { Date() }
|
||||||
)
|
)
|
||||||
@@ -101,12 +103,6 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Note: deinit cannot call @MainActor functions
|
|
||||||
// Subscription cleanup will happen automatically when manager is deallocated
|
|
||||||
SecureLogger.debug("LocationNotesManager deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setGeohash(_ newGeohash: String) {
|
func setGeohash(_ newGeohash: String) {
|
||||||
let norm = newGeohash.lowercased()
|
let norm = newGeohash.lowercased()
|
||||||
guard norm != geohash else { return }
|
guard norm != geohash else { return }
|
||||||
|
|||||||
@@ -1,618 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageFormattingService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Service for formatting chat messages with syntax highlighting
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// Service that formats BitchatMessages into styled AttributedStrings
|
|
||||||
/// Handles hashtags, mentions, links, payment tokens, and more
|
|
||||||
final class MessageFormattingService {
|
|
||||||
|
|
||||||
// MARK: - Precompiled Regexes
|
|
||||||
|
|
||||||
private enum Regexes {
|
|
||||||
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: [])
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Dependencies
|
|
||||||
|
|
||||||
private let colorPalette: ColorPaletteService
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
|
|
||||||
init(colorPalette: ColorPaletteService) {
|
|
||||||
self.colorPalette = colorPalette
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API
|
|
||||||
|
|
||||||
/// Format a message with full syntax highlighting (hashtags, mentions, links, payments)
|
|
||||||
/// This is the primary formatter used in the main chat view
|
|
||||||
func formatMessageAsText(
|
|
||||||
_ message: BitchatMessage,
|
|
||||||
colorScheme: ColorScheme,
|
|
||||||
nickname: String,
|
|
||||||
myPeerID: String,
|
|
||||||
myNostrPubkey: String?,
|
|
||||||
activeChannel: ChannelID,
|
|
||||||
nostrKeyMapping: [String: String],
|
|
||||||
allPeers: [BitchatPeer],
|
|
||||||
geohashPeople: [GeoPerson],
|
|
||||||
getNoiseKeyForShortID: @escaping (String) -> String?
|
|
||||||
) -> AttributedString {
|
|
||||||
// Determine if this message was sent by self
|
|
||||||
let isSelf = isSelfMessage(
|
|
||||||
message,
|
|
||||||
nickname: nickname,
|
|
||||||
myPeerID: myPeerID,
|
|
||||||
myNostrPubkey: myNostrPubkey,
|
|
||||||
activeChannel: activeChannel
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
let isDark = colorScheme == .dark
|
|
||||||
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
|
|
||||||
return cachedText
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not cached, format the message
|
|
||||||
var result = AttributedString()
|
|
||||||
|
|
||||||
let baseColor: Color = isSelf ? .orange : colorPalette.peerColor(
|
|
||||||
for: message,
|
|
||||||
isDark: isDark,
|
|
||||||
myPeerID: myPeerID,
|
|
||||||
myNostrPubkey: myNostrPubkey,
|
|
||||||
nostrKeyMapping: nostrKeyMapping,
|
|
||||||
allPeers: allPeers,
|
|
||||||
geohashPeople: geohashPeople.map { (id: $0.id, seed: "nostr:" + $0.id) },
|
|
||||||
getNoiseKeyForShortID: getNoiseKeyForShortID
|
|
||||||
)
|
|
||||||
|
|
||||||
if message.sender != "system" {
|
|
||||||
// Sender (at the beginning) with light-gray suffix styling if present
|
|
||||||
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: encode senderPeerID into a custom URL
|
|
||||||
if let spid = message.senderPeerID?.id,
|
|
||||||
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
|
|
||||||
senderStyle.link = url
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format: <@name#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))
|
|
||||||
|
|
||||||
// Process content with syntax highlighting
|
|
||||||
let content = message.content
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let nsLen = nsContent.length
|
|
||||||
|
|
||||||
// Check for Cashu presence early to decide rendering strategy
|
|
||||||
let containsCashuEarly = Regexes.quickCashuPresence.numberOfMatches(
|
|
||||||
in: content,
|
|
||||||
options: [],
|
|
||||||
range: NSRange(location: 0, length: nsLen)
|
|
||||||
) > 0
|
|
||||||
|
|
||||||
// For extremely long content, render as plain text (unless has Cashu)
|
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
|
||||||
var plainStyle = AttributeContainer()
|
|
||||||
plainStyle.foregroundColor = baseColor
|
|
||||||
plainStyle.font = isSelf
|
|
||||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
|
||||||
: .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
|
||||||
} else {
|
|
||||||
// Full syntax highlighting
|
|
||||||
result.append(formatContent(
|
|
||||||
content,
|
|
||||||
nsContent: nsContent,
|
|
||||||
nsLen: nsLen,
|
|
||||||
message: message,
|
|
||||||
baseColor: baseColor,
|
|
||||||
isSelf: isSelf,
|
|
||||||
isDark: isDark,
|
|
||||||
nickname: nickname,
|
|
||||||
myPeerID: myPeerID,
|
|
||||||
myNostrPubkey: myNostrPubkey,
|
|
||||||
activeChannel: activeChannel
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add timestamp
|
|
||||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
|
||||||
var timestampStyle = AttributeContainer()
|
|
||||||
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
|
||||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
|
||||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
|
||||||
} else {
|
|
||||||
// System message
|
|
||||||
var contentStyle = AttributeContainer()
|
|
||||||
contentStyle.foregroundColor = Color.gray
|
|
||||||
let content = AttributedString("* \(message.content) *")
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the formatted text
|
|
||||||
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simpler message formatter (used in legacy contexts)
|
|
||||||
func formatMessage(
|
|
||||||
_ message: BitchatMessage,
|
|
||||||
colorScheme: ColorScheme,
|
|
||||||
nickname: String
|
|
||||||
) -> AttributedString {
|
|
||||||
var result = AttributedString()
|
|
||||||
|
|
||||||
let isDark = colorScheme == .dark
|
|
||||||
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
|
||||||
|
|
||||||
if message.sender == "system" {
|
|
||||||
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))
|
|
||||||
} else {
|
|
||||||
let sender = AttributedString("<@\(message.sender)> ")
|
|
||||||
var senderStyle = AttributeContainer()
|
|
||||||
senderStyle.foregroundColor = primaryColor
|
|
||||||
let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium
|
|
||||||
senderStyle.font = .bitchatSystem(size: 12, weight: fontWeight, design: .monospaced)
|
|
||||||
result.append(sender.mergingAttributes(senderStyle))
|
|
||||||
|
|
||||||
// Process content to highlight mentions
|
|
||||||
let contentText = message.content
|
|
||||||
let pattern = "@([\\p{L}0-9_]+)"
|
|
||||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
|
||||||
let nsContent = contentText as NSString
|
|
||||||
let nsLen = nsContent.length
|
|
||||||
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
|
||||||
|
|
||||||
var processedContent = AttributedString()
|
|
||||||
var lastEndIndex = contentText.startIndex
|
|
||||||
|
|
||||||
for match in matches {
|
|
||||||
if let range = Range(match.range(at: 0), in: contentText) {
|
|
||||||
// Add text before mention
|
|
||||||
if lastEndIndex < range.lowerBound {
|
|
||||||
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
|
||||||
if !beforeText.isEmpty {
|
|
||||||
var normalStyle = AttributeContainer()
|
|
||||||
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
|
||||||
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the mention with highlight
|
|
||||||
let mentionText = String(contentText[range])
|
|
||||||
var mentionStyle = AttributeContainer()
|
|
||||||
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
|
||||||
mentionStyle.foregroundColor = Color.orange
|
|
||||||
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
|
|
||||||
|
|
||||||
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining text
|
|
||||||
if lastEndIndex < contentText.endIndex {
|
|
||||||
let remainingText = String(contentText[lastEndIndex...])
|
|
||||||
var normalStyle = AttributeContainer()
|
|
||||||
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
|
||||||
processedContent.append(AttributedString(remainingText).mergingAttributes(normalStyle))
|
|
||||||
}
|
|
||||||
|
|
||||||
result.append(processedContent)
|
|
||||||
|
|
||||||
if message.isRelay, let originalSender = message.originalSender {
|
|
||||||
let relay = AttributedString(" (via \(originalSender))")
|
|
||||||
var relayStyle = AttributeContainer()
|
|
||||||
relayStyle.foregroundColor = primaryColor.opacity(0.7)
|
|
||||||
relayStyle.font = .bitchatSystem(size: 11, design: .monospaced)
|
|
||||||
result.append(relay.mergingAttributes(relayStyle))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add timestamp
|
|
||||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
|
||||||
var timestampStyle = AttributeContainer()
|
|
||||||
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
|
||||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
|
||||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
|
|
||||||
private func isSelfMessage(
|
|
||||||
_ message: BitchatMessage,
|
|
||||||
nickname: String,
|
|
||||||
myPeerID: String,
|
|
||||||
myNostrPubkey: String?,
|
|
||||||
activeChannel: ChannelID
|
|
||||||
) -> Bool {
|
|
||||||
if let spid = message.senderPeerID?.id {
|
|
||||||
// In geohash channels, compare against our per-geohash nostr short ID
|
|
||||||
if case .location = activeChannel, spid.hasPrefix("nostr:"),
|
|
||||||
let myGeo = myNostrPubkey {
|
|
||||||
return spid == "nostr:\(myGeo.prefix(TransportConfig.nostrShortKeyDisplayLength))"
|
|
||||||
}
|
|
||||||
return spid == myPeerID
|
|
||||||
}
|
|
||||||
// Fallback by nickname
|
|
||||||
if message.sender == nickname { return true }
|
|
||||||
if message.sender.hasPrefix(nickname + "#") { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatContent(
|
|
||||||
_ content: String,
|
|
||||||
nsContent: NSString,
|
|
||||||
nsLen: Int,
|
|
||||||
message: BitchatMessage,
|
|
||||||
baseColor: Color,
|
|
||||||
isSelf: Bool,
|
|
||||||
isDark: Bool,
|
|
||||||
nickname: String,
|
|
||||||
myPeerID: String,
|
|
||||||
myNostrPubkey: String?,
|
|
||||||
activeChannel: ChannelID
|
|
||||||
) -> AttributedString {
|
|
||||||
// Extract all matches
|
|
||||||
let hasMentionsHint = content.contains("@")
|
|
||||||
let hasHashtagsHint = content.contains("#")
|
|
||||||
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
|
||||||
let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
|
||||||
let hasCashuHint = content.lowercased().contains("cashu")
|
|
||||||
|
|
||||||
let hashtagMatches = hasHashtagsHint ? Regexes.hashtag.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
|
||||||
let mentionMatches = hasMentionsHint ? Regexes.mention.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
|
||||||
let urlMatches = hasURLHint ? (Regexes.linkDetector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : []
|
|
||||||
let cashuMatches = hasCashuHint ? Regexes.cashu.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
|
||||||
let lightningMatches = hasLightningHint ? Regexes.lightningScheme.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
|
||||||
let bolt11Matches = hasLightningHint ? Regexes.bolt11.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
|
||||||
let lnurlMatches = hasLightningHint ? Regexes.lnurl.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
|
||||||
|
|
||||||
// Combine and sort matches, excluding hashtags/URLs overlapping mentions
|
|
||||||
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
|
||||||
|
|
||||||
func overlapsMention(_ r: NSRange) -> Bool {
|
|
||||||
for mr in mentionRanges {
|
|
||||||
if NSIntersectionRange(r, mr).length > 0 { return true }
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func attachedToMention(_ r: NSRange) -> Bool {
|
|
||||||
if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex {
|
|
||||||
var i = content.index(before: nsRange.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
|
|
||||||
}
|
|
||||||
|
|
||||||
func isStandaloneHashtag(_ r: NSRange) -> Bool {
|
|
||||||
guard let nsRange = Range(r, in: content) else { return false }
|
|
||||||
if nsRange.lowerBound == content.startIndex { return true }
|
|
||||||
let prev = content.index(before: nsRange.lowerBound)
|
|
||||||
return content[prev].isWhitespace || content[prev].isNewline
|
|
||||||
}
|
|
||||||
|
|
||||||
var allMatches: [(range: NSRange, type: String)] = []
|
|
||||||
for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) {
|
|
||||||
allMatches.append((match.range(at: 0), "hashtag"))
|
|
||||||
}
|
|
||||||
for match in mentionMatches {
|
|
||||||
allMatches.append((match.range(at: 0), "mention"))
|
|
||||||
}
|
|
||||||
for match in urlMatches where !overlapsMention(match.range) {
|
|
||||||
allMatches.append((match.range, "url"))
|
|
||||||
}
|
|
||||||
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
|
|
||||||
allMatches.append((match.range(at: 0), "cashu"))
|
|
||||||
}
|
|
||||||
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
|
|
||||||
allMatches.append((match.range(at: 0), "lightning"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exclude overlaps with lightning/url for bolt11/lnurl
|
|
||||||
let occupied: [NSRange] = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
|
|
||||||
func overlapsOccupied(_ r: NSRange) -> Bool {
|
|
||||||
for or in occupied {
|
|
||||||
if NSIntersectionRange(r, or).length > 0 { return true }
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
|
||||||
allMatches.append((match.range(at: 0), "bolt11"))
|
|
||||||
}
|
|
||||||
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
|
||||||
allMatches.append((match.range(at: 0), "lnurl"))
|
|
||||||
}
|
|
||||||
allMatches.sort { $0.range.location < $1.range.location }
|
|
||||||
|
|
||||||
// Build content with styling
|
|
||||||
var processedContent = AttributedString()
|
|
||||||
var lastEnd = content.startIndex
|
|
||||||
let isMentioned = message.mentions?.contains(nickname) ?? false
|
|
||||||
|
|
||||||
for (range, type) in allMatches {
|
|
||||||
if let nsRange = Range(range, in: content) {
|
|
||||||
// Add text before match
|
|
||||||
if lastEnd < nsRange.lowerBound {
|
|
||||||
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
|
||||||
if !beforeText.isEmpty {
|
|
||||||
var beforeStyle = AttributeContainer()
|
|
||||||
beforeStyle.foregroundColor = baseColor
|
|
||||||
beforeStyle.font = isSelf
|
|
||||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
|
||||||
: .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
if isMentioned {
|
|
||||||
beforeStyle.font = beforeStyle.font?.bold()
|
|
||||||
}
|
|
||||||
processedContent.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add styled match
|
|
||||||
let matchText = String(content[nsRange])
|
|
||||||
processedContent.append(formatMatch(
|
|
||||||
matchText,
|
|
||||||
type: type,
|
|
||||||
baseColor: baseColor,
|
|
||||||
isSelf: isSelf,
|
|
||||||
isDark: isDark,
|
|
||||||
nickname: nickname,
|
|
||||||
myPeerID: myPeerID,
|
|
||||||
myNostrPubkey: myNostrPubkey,
|
|
||||||
activeChannel: activeChannel
|
|
||||||
))
|
|
||||||
|
|
||||||
lastEnd = nsRange.upperBound
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining text after last match
|
|
||||||
if lastEnd < content.endIndex {
|
|
||||||
let remainingText = String(content[lastEnd...])
|
|
||||||
var remainingStyle = AttributeContainer()
|
|
||||||
remainingStyle.foregroundColor = baseColor
|
|
||||||
remainingStyle.font = isSelf
|
|
||||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
|
||||||
: .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
if isMentioned {
|
|
||||||
remainingStyle.font = remainingStyle.font?.bold()
|
|
||||||
}
|
|
||||||
processedContent.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
|
|
||||||
}
|
|
||||||
|
|
||||||
return processedContent
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatMatch(
|
|
||||||
_ matchText: String,
|
|
||||||
type: String,
|
|
||||||
baseColor: Color,
|
|
||||||
isSelf: Bool,
|
|
||||||
isDark: Bool,
|
|
||||||
nickname: String,
|
|
||||||
myPeerID: String,
|
|
||||||
myNostrPubkey: String?,
|
|
||||||
activeChannel: ChannelID
|
|
||||||
) -> AttributedString {
|
|
||||||
switch type {
|
|
||||||
case "mention":
|
|
||||||
return formatMention(
|
|
||||||
matchText,
|
|
||||||
baseColor: baseColor,
|
|
||||||
isSelf: isSelf,
|
|
||||||
nickname: nickname,
|
|
||||||
myPeerID: myPeerID,
|
|
||||||
myNostrPubkey: myNostrPubkey,
|
|
||||||
activeChannel: activeChannel
|
|
||||||
)
|
|
||||||
case "hashtag":
|
|
||||||
return formatHashtag(matchText, isDark: isDark, baseColor: baseColor, activeChannel: activeChannel)
|
|
||||||
case "url":
|
|
||||||
return formatURL(matchText, baseColor: baseColor, isSelf: isSelf)
|
|
||||||
case "cashu", "bolt11", "lnurl", "lightning":
|
|
||||||
return formatPayment(matchText, type: type, baseColor: baseColor, isSelf: isSelf)
|
|
||||||
default:
|
|
||||||
return AttributedString(matchText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatMention(
|
|
||||||
_ matchText: String,
|
|
||||||
baseColor: Color,
|
|
||||||
isSelf: Bool,
|
|
||||||
nickname: String,
|
|
||||||
myPeerID: String,
|
|
||||||
myNostrPubkey: String?,
|
|
||||||
activeChannel: ChannelID
|
|
||||||
) -> AttributedString {
|
|
||||||
// Split optional '#abcd' suffix and color suffix light grey
|
|
||||||
let (mBase, mSuffix) = matchText.splitSuffix()
|
|
||||||
|
|
||||||
// Determine if this mention targets me
|
|
||||||
let mySuffix: String? = {
|
|
||||||
if case .location = activeChannel, let myGeo = myNostrPubkey {
|
|
||||||
return String(myGeo.suffix(4))
|
|
||||||
}
|
|
||||||
return String(myPeerID.prefix(4))
|
|
||||||
}()
|
|
||||||
|
|
||||||
let isMentionToMe: Bool = {
|
|
||||||
if mBase == nickname {
|
|
||||||
if let suf = mySuffix, !mSuffix.isEmpty {
|
|
||||||
return mSuffix == "#\(suf)"
|
|
||||||
}
|
|
||||||
return mSuffix.isEmpty
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
|
|
||||||
var mentionStyle = AttributeContainer()
|
|
||||||
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
|
||||||
mentionStyle.foregroundColor = isMentionToMe ? .orange : baseColor
|
|
||||||
|
|
||||||
var result = AttributedString()
|
|
||||||
result.append(AttributedString(mBase).mergingAttributes(mentionStyle))
|
|
||||||
|
|
||||||
if !mSuffix.isEmpty {
|
|
||||||
var suffixStyle = mentionStyle
|
|
||||||
suffixStyle.foregroundColor = (isMentionToMe ? Color.orange : baseColor).opacity(0.5)
|
|
||||||
result.append(AttributedString(mSuffix).mergingAttributes(suffixStyle))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatHashtag(
|
|
||||||
_ matchText: String,
|
|
||||||
isDark: Bool,
|
|
||||||
baseColor: Color,
|
|
||||||
activeChannel: ChannelID
|
|
||||||
) -> AttributedString {
|
|
||||||
var hashtagStyle = AttributeContainer()
|
|
||||||
hashtagStyle.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
|
||||||
|
|
||||||
// Determine if this hashtag represents the active channel
|
|
||||||
let isActiveChannel: Bool = {
|
|
||||||
if matchText.count > 1 {
|
|
||||||
let tag = String(matchText.dropFirst()) // Remove '#'
|
|
||||||
switch activeChannel {
|
|
||||||
case .mesh:
|
|
||||||
return tag.lowercased() == "mesh"
|
|
||||||
case .location(let ch):
|
|
||||||
return tag.lowercased() == ch.geohash.lowercased()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
|
|
||||||
if isActiveChannel {
|
|
||||||
// Highlight active channel hashtag in green
|
|
||||||
hashtagStyle.foregroundColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
|
||||||
hashtagStyle.font = .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
|
||||||
} else {
|
|
||||||
// Link to geohash if valid
|
|
||||||
if matchText.count > 1 {
|
|
||||||
let tag = String(matchText.dropFirst())
|
|
||||||
if tag.count >= 2, tag.count <= 12,
|
|
||||||
tag.allSatisfy({ "0123456789bcdefghjkmnpqrstuvwxyz".contains($0) }) {
|
|
||||||
if let url = URL(string: "bitchat://geohash/\(tag)") {
|
|
||||||
hashtagStyle.link = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hashtagStyle.foregroundColor = baseColor.opacity(0.8)
|
|
||||||
}
|
|
||||||
|
|
||||||
return AttributedString(matchText).mergingAttributes(hashtagStyle)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatURL(_ matchText: String, baseColor: Color, isSelf: Bool) -> AttributedString {
|
|
||||||
var urlStyle = AttributeContainer()
|
|
||||||
if let url = URL(string: matchText) {
|
|
||||||
urlStyle.link = url
|
|
||||||
}
|
|
||||||
urlStyle.foregroundColor = baseColor
|
|
||||||
urlStyle.font = isSelf
|
|
||||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
|
||||||
: .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
urlStyle.underlineStyle = .single
|
|
||||||
return AttributedString(matchText).mergingAttributes(urlStyle)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatPayment(
|
|
||||||
_ matchText: String,
|
|
||||||
type: String,
|
|
||||||
baseColor: Color,
|
|
||||||
isSelf: Bool
|
|
||||||
) -> AttributedString {
|
|
||||||
var paymentStyle = AttributeContainer()
|
|
||||||
paymentStyle.foregroundColor = baseColor
|
|
||||||
paymentStyle.font = isSelf
|
|
||||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
|
||||||
: .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
|
|
||||||
// Make payment tokens tappable
|
|
||||||
if type == "cashu", let url = URL(string: "cashu:\(matchText)") {
|
|
||||||
paymentStyle.link = url
|
|
||||||
} else if type == "lightning" || type == "bolt11" || type == "lnurl" {
|
|
||||||
if let url = URL(string: matchText.lowercased().hasPrefix("lightning:") ? matchText : "lightning:\(matchText)") {
|
|
||||||
paymentStyle.link = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AttributedString(matchText).mergingAttributes(paymentStyle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,12 +20,6 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Clean up Combine subscriptions
|
|
||||||
cancellables.removeAll()
|
|
||||||
SecureLogger.debug("NetworkActivationService deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard !started else { return }
|
guard !started else { return }
|
||||||
started = true
|
started = true
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ final class NostrTransport: Transport {
|
|||||||
private var isSendingReadAcks = false
|
private var isSendingReadAcks = false
|
||||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
self.idBridge = idBridge
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transport Protocol Conformance
|
// MARK: - Transport Protocol Conformance
|
||||||
@@ -65,7 +67,7 @@ final class NostrTransport: Transport {
|
|||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() 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)
|
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)
|
// Convert recipient npub -> hex (x-only)
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
@@ -102,7 +104,7 @@ final class NostrTransport: Transport {
|
|||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
// Convert recipient npub -> hex
|
// Convert recipient npub -> hex
|
||||||
@@ -129,7 +131,7 @@ final class NostrTransport: Transport {
|
|||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() 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)
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
do {
|
do {
|
||||||
@@ -212,7 +214,7 @@ extension NostrTransport {
|
|||||||
let item = readQueue.removeFirst()
|
let item = readQueue.removeFirst()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() 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)
|
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
// Convert recipient npub -> hex
|
// Convert recipient npub -> hex
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
|
|||||||
@@ -6,10 +6,19 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct NotificationStreamAssembler {
|
struct NotificationStreamAssembler {
|
||||||
private var buffer = Data()
|
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) {
|
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||||
guard !chunk.isEmpty else { return ([], [], false) }
|
guard !chunk.isEmpty else { return ([], [], false) }
|
||||||
@@ -18,64 +27,107 @@ struct NotificationStreamAssembler {
|
|||||||
|
|
||||||
var frames: [Data] = []
|
var frames: [Data] = []
|
||||||
var dropped: [UInt8] = []
|
var dropped: [UInt8] = []
|
||||||
var reset = false
|
var didReset = false
|
||||||
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
|
let now = DispatchTime.now()
|
||||||
|
let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes
|
||||||
|
let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize
|
||||||
|
|
||||||
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
|
if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes {
|
||||||
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
|
SecureLogger.error("❌ Notification assembler overflow (\(buffer.count) bytes); dropping partial frame", category: .session)
|
||||||
|
resetState()
|
||||||
|
return ([], [], true)
|
||||||
|
}
|
||||||
|
|
||||||
while buffer.count >= minFramePrefix {
|
while buffer.count >= minimumFramePrefix {
|
||||||
guard let first = buffer.first else { break }
|
guard let version = buffer.first else { break }
|
||||||
if first != 1 {
|
guard version == 1 || version == 2 else {
|
||||||
dropped.append(buffer.removeFirst())
|
dropped.append(buffer.removeFirst())
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
guard buffer.count >= minHeaderBytes else { break }
|
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 }
|
||||||
|
|
||||||
let headerBytes = Array(buffer.prefix(minFramePrefix))
|
let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags
|
||||||
guard headerBytes.count == minFramePrefix else { break }
|
guard flagsIndex < buffer.endIndex else { break }
|
||||||
|
let flags = buffer[flagsIndex]
|
||||||
let flags = headerBytes[11]
|
|
||||||
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||||
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
|
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
||||||
|
|
||||||
var frameLength = minFramePrefix + payloadLen
|
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
|
||||||
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||||
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||||
|
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 {
|
guard frameLength > 0, frameLength <= maxFrameLength else {
|
||||||
buffer.removeAll()
|
SecureLogger.error("❌ Notification frame length \(frameLength) invalid (cap=\(maxFrameLength)); resetting stream", category: .session)
|
||||||
reset = true
|
resetState()
|
||||||
|
didReset = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if buffer.count < frameLength {
|
if buffer.count < frameLength {
|
||||||
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
|
let remaining = frameLength - buffer.count
|
||||||
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
|
if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength {
|
||||||
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
|
pendingFrameStartedAt = now
|
||||||
if dropCount > 0 {
|
pendingFrameExpectedLength = frameLength
|
||||||
buffer.removeFirst(dropCount)
|
} else if let started = pendingFrameStartedAt {
|
||||||
dropped.append(1) // treat as dropped partial start
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
|
||||||
let frame = Data(buffer.prefix(frameLength))
|
let frame = Data(buffer.prefix(frameLength))
|
||||||
frames.append(frame)
|
frames.append(frame)
|
||||||
buffer.removeFirst(frameLength)
|
buffer.removeFirst(frameLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||||
buffer.removeAll(keepingCapacity: false)
|
resetState()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (frames, dropped, reset)
|
return (frames, dropped, didReset)
|
||||||
}
|
|
||||||
|
|
||||||
mutating func reset() {
|
|
||||||
buffer.removeAll(keepingCapacity: false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import SwiftUI
|
|||||||
|
|
||||||
/// Manages all private chat functionality
|
/// Manages all private chat functionality
|
||||||
final class PrivateChatManager: ObservableObject {
|
final class PrivateChatManager: ObservableObject {
|
||||||
@Published var privateChats: [String: [BitchatMessage]] = [:]
|
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||||
@Published var selectedPeer: String? = nil
|
@Published var selectedPeer: PeerID? = nil
|
||||||
@Published var unreadMessages: Set<String> = []
|
@Published var unreadMessages: Set<PeerID> = []
|
||||||
|
|
||||||
private var selectedPeerFingerprint: String? = nil
|
private var selectedPeerFingerprint: String? = nil
|
||||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||||
@@ -27,19 +27,15 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
SecureLogger.debug("PrivateChatManager deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap for messages stored per private chat
|
// Cap for messages stored per private chat
|
||||||
private let privateChatCap = TransportConfig.privateChatCap
|
private let privateChatCap = TransportConfig.privateChatCap
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer
|
||||||
func startChat(with peerID: String) {
|
func startChat(with peerID: PeerID) {
|
||||||
selectedPeer = peerID
|
selectedPeer = peerID
|
||||||
|
|
||||||
// Store fingerprint for persistence across reconnections
|
// Store fingerprint for persistence across reconnections
|
||||||
if let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
selectedPeerFingerprint = fingerprint
|
selectedPeerFingerprint = fingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +55,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove duplicate messages by ID and keep chronological order
|
/// Remove duplicate messages by ID and keep chronological order
|
||||||
func sanitizeChat(for peerID: String) {
|
func sanitizeChat(for peerID: PeerID) {
|
||||||
guard let arr = privateChats[peerID] else { return }
|
guard let arr = privateChats[peerID] else { return }
|
||||||
if arr.count <= 1 {
|
if arr.count <= 1 {
|
||||||
return
|
return
|
||||||
@@ -83,7 +79,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mark messages from a peer as read
|
/// Mark messages from a peer as read
|
||||||
func markAsRead(from peerID: String) {
|
func markAsRead(from peerID: PeerID) {
|
||||||
unreadMessages.remove(peerID)
|
unreadMessages.remove(peerID)
|
||||||
|
|
||||||
// Send read receipts for unread messages that haven't been sent yet
|
// Send read receipts for unread messages that haven't been sent yet
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,9 @@ protocol Transport: AnyObject {
|
|||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
func sendBroadcastAnnounce()
|
func sendBroadcastAnnounce()
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
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)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
@@ -59,6 +62,9 @@ protocol Transport: AnyObject {
|
|||||||
extension Transport {
|
extension Transport {
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendVerifyResponse(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) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ enum TransportConfig {
|
|||||||
static let bleDynamicRSSIThresholdDefault: Int = -90
|
static let bleDynamicRSSIThresholdDefault: Int = -90
|
||||||
static let bleConnectionCandidatesMax: Int = 100
|
static let bleConnectionCandidatesMax: Int = 100
|
||||||
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
||||||
static let blePendingNotificationsCapCount: Int = 20
|
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
|
||||||
|
|
||||||
// Nostr
|
// Nostr
|
||||||
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||||
@@ -41,6 +45,12 @@ enum TransportConfig {
|
|||||||
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
|
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
|
||||||
static let uiProcessedNostrEventsCap: Int = 2000
|
static let uiProcessedNostrEventsCap: Int = 2000
|
||||||
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
||||||
|
|
||||||
|
// UI rate limiters (token buckets)
|
||||||
|
static let uiSenderRateBucketCapacity: Double = 5
|
||||||
|
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
||||||
|
static let uiContentRateBucketCapacity: Double = 3
|
||||||
|
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
||||||
|
|
||||||
// UI sleeps/delays
|
// UI sleeps/delays
|
||||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||||
private var fingerprintCache: [PeerID: String] = [:]
|
private var fingerprintCache: [PeerID: String] = [:]
|
||||||
private let meshService: Transport
|
private let meshService: Transport
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
private let favoritesService = FavoritesPersistenceService.shared
|
private let favoritesService = FavoritesPersistenceService.shared
|
||||||
@@ -34,8 +35,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) {
|
init(
|
||||||
|
meshService: Transport,
|
||||||
|
idBridge: NostrIdentityBridge,
|
||||||
|
identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
) {
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
|
|
||||||
// Subscribe to changes from both services
|
// Subscribe to changes from both services
|
||||||
@@ -46,17 +52,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
updatePeers()
|
updatePeers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Clean up NotificationCenter observers
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
|
|
||||||
// Clean up Combine subscriptions
|
|
||||||
cancellables.removeAll()
|
|
||||||
|
|
||||||
SecureLogger.debug("UnifiedPeerService deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
|
|
||||||
private func setupSubscriptions() {
|
private func setupSubscriptions() {
|
||||||
@@ -239,10 +235,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer ID for nickname
|
/// Get peer ID for nickname
|
||||||
func getPeerID(for nickname: String) -> String? {
|
func getPeerID(for nickname: String) -> PeerID? {
|
||||||
for peer in peers {
|
for peer in peers {
|
||||||
if peer.displayName == nickname || peer.nickname == nickname {
|
if peer.displayName == nickname || peer.nickname == nickname {
|
||||||
return peer.peerID.id
|
return peer.peerID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -295,7 +291,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
var peerNostrKey = peer.nostrPublicKey
|
var peerNostrKey = peer.nostrPublicKey
|
||||||
if peerNostrKey == nil {
|
if peerNostrKey == nil {
|
||||||
// Try to get from NostrIdentityBridge association
|
// Try to get from NostrIdentityBridge association
|
||||||
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add favorite
|
// Add favorite
|
||||||
@@ -351,7 +347,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Compatibility Methods (for easy migration)
|
// MARK: - Compatibility Methods (for easy migration)
|
||||||
|
|
||||||
var allPeers: [BitchatPeer] { peers }
|
var allPeers: [BitchatPeer] { peers }
|
||||||
var connectedPeers: [PeerID] { Array(connectedPeerIDs) }
|
var connectedPeers: Set<PeerID> { connectedPeerIDs }
|
||||||
var favoritePeers: Set<String> {
|
var favoritePeers: Set<String> {
|
||||||
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ final class GossipSyncManager {
|
|||||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
var gcsTargetFpr: Double = 0.01 // 1%
|
var gcsTargetFpr: Double = 0.01 // 1%
|
||||||
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||||
|
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||||
|
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||||
|
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||||
}
|
}
|
||||||
|
|
||||||
private let myPeerID: PeerID
|
private let myPeerID: PeerID
|
||||||
@@ -27,6 +30,7 @@ final class GossipSyncManager {
|
|||||||
// Timer
|
// Timer
|
||||||
private var periodicTimer: DispatchSourceTimer?
|
private var periodicTimer: DispatchSourceTimer?
|
||||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||||
|
private var lastStalePeerCleanup: Date = .distantPast
|
||||||
|
|
||||||
init(myPeerID: PeerID, config: Config = Config()) {
|
init(myPeerID: PeerID, config: Config = Config()) {
|
||||||
self.myPeerID = myPeerID
|
self.myPeerID = myPeerID
|
||||||
@@ -36,10 +40,10 @@ final class GossipSyncManager {
|
|||||||
func start() {
|
func start() {
|
||||||
stop()
|
stop()
|
||||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||||
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
let interval = max(0.1, config.maintenanceIntervalSeconds)
|
||||||
|
timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1))
|
||||||
timer.setEventHandler { [weak self] in
|
timer.setEventHandler { [weak self] in
|
||||||
self?.cleanupExpiredMessages()
|
self?.performPeriodicMaintenance()
|
||||||
self?.sendRequestSync()
|
|
||||||
}
|
}
|
||||||
timer.resume()
|
timer.resume()
|
||||||
periodicTimer = timer
|
periodicTimer = timer
|
||||||
@@ -73,6 +77,15 @@ final class GossipSyncManager {
|
|||||||
return packet.timestamp >= cutoffMs
|
return packet.timestamp >= cutoffMs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isAnnouncementFresh(_ packet: BitchatPacket) -> Bool {
|
||||||
|
guard config.stalePeerTimeoutSeconds > 0 else { return true }
|
||||||
|
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
||||||
|
guard nowMs >= timeoutMs else { return true }
|
||||||
|
let cutoffMs = nowMs - timeoutMs
|
||||||
|
return packet.timestamp >= cutoffMs
|
||||||
|
}
|
||||||
|
|
||||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
let mt = MessageType(rawValue: packet.type)
|
let mt = MessageType(rawValue: packet.type)
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
@@ -86,6 +99,14 @@ final class GossipSyncManager {
|
|||||||
// Reject expired packets to prevent ghost peers and old messages
|
// Reject expired packets to prevent ghost peers and old messages
|
||||||
guard isPacketFresh(packet) else { return }
|
guard isPacketFresh(packet) else { return }
|
||||||
|
|
||||||
|
if isAnnounce {
|
||||||
|
guard isAnnouncementFresh(packet) else {
|
||||||
|
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||||
|
removeState(forNormalizedPeerID: sender)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
|
||||||
if isBroadcastMessage {
|
if isBroadcastMessage {
|
||||||
@@ -100,7 +121,7 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if isAnnounce {
|
} else if isAnnounce {
|
||||||
let sender = packet.senderID.hexEncodedString()
|
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||||
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,6 +251,34 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||||
|
cleanupExpiredMessages()
|
||||||
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
|
sendRequestSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
|
guard now.timeIntervalSince(lastStalePeerCleanup) >= config.stalePeerCleanupIntervalSeconds else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastStalePeerCleanup = now
|
||||||
|
cleanupStaleAnnouncements(now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupStaleAnnouncements(now: Date) {
|
||||||
|
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
||||||
|
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||||
|
guard nowMs >= timeoutMs else { return }
|
||||||
|
let cutoff = nowMs - timeoutMs
|
||||||
|
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(forNormalizedPeerID: peerKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Explicit removal hook for LEAVE/stale peer
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
@@ -239,8 +288,11 @@ final class GossipSyncManager {
|
|||||||
|
|
||||||
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
let normalizedPeerID = peerID.id.lowercased()
|
let normalizedPeerID = peerID.id.lowercased()
|
||||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
removeState(forNormalizedPeerID: normalizedPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
|
||||||
|
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||||
// Remove messages from this peer
|
// Remove messages from this peer
|
||||||
// Collect IDs to remove first to avoid concurrent modification
|
// Collect IDs to remove first to avoid concurrent modification
|
||||||
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
||||||
@@ -254,3 +306,25 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension GossipSyncManager {
|
||||||
|
func _performMaintenanceSynchronously(now: Date = Date()) {
|
||||||
|
queue.sync {
|
||||||
|
performPeriodicMaintenance(now: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
||||||
|
queue.sync {
|
||||||
|
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func _messageCount(for peerID: PeerID) -> Int {
|
||||||
|
queue.sync {
|
||||||
|
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
|
||||||
|
enum FileTransferLimits {
|
||||||
|
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||||
|
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
|
/// Voice notes stay small for low-latency relays.
|
||||||
|
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
|
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||||
|
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
|
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
||||||
|
static let maxFramedFileBytes: Int = {
|
||||||
|
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
||||||
|
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
|
||||||
|
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
|
||||||
|
+ BinaryProtocol.senderIDSize
|
||||||
|
+ BinaryProtocol.recipientIDSize
|
||||||
|
+ BinaryProtocol.signatureSize
|
||||||
|
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func isValidPayload(_ size: Int) -> Bool {
|
||||||
|
size <= maxPayloadBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
+1856
-639
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,7 @@ struct TextMessageView: View {
|
|||||||
.environmentObject(
|
.environmentObject(
|
||||||
ChatViewModel(
|
ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
|
idBridge: NostrIdentityBridge(),
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+1363
-779
File diff suppressed because it is too large
Load Diff
@@ -66,12 +66,12 @@ struct FingerprintView: View {
|
|||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Prefer short mesh ID for session/encryption status
|
// Prefer short mesh ID for session/encryption status
|
||||||
let statusPeerID: String = {
|
let statusPeerID: String = {
|
||||||
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short.id }
|
||||||
return peerID
|
return peerID
|
||||||
}()
|
}()
|
||||||
// Resolve a friendly name
|
// Resolve a friendly name
|
||||||
let peerNickname: String = {
|
let peerNickname: String = {
|
||||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
if let p = viewModel.getPeer(byID: PeerID(str: statusPeerID)) { return p.displayName }
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
||||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
if peerID.count == 64, let data = Data(hexString: peerID) {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
@@ -84,7 +84,7 @@ struct FingerprintView: View {
|
|||||||
return Strings.unknownPeer()
|
return Strings.unknownPeer()
|
||||||
}()
|
}()
|
||||||
// Accurate encryption state based on short ID session
|
// Accurate encryption state based on short ID session
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
@@ -115,7 +115,7 @@ struct FingerprintView: View {
|
|||||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
if let fingerprint = viewModel.getFingerprint(for: PeerID(str: statusPeerID)) {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -176,6 +176,7 @@ struct FingerprintView: View {
|
|||||||
// Verification status
|
// Verification status
|
||||||
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
||||||
let isVerified = encryptionStatus == .noiseVerified
|
let isVerified = encryptionStatus == .noiseVerified
|
||||||
|
let peerID = PeerID(str: peerID)
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ struct GeohashPeopleList: View {
|
|||||||
} else {
|
} else {
|
||||||
let myHex: String? = {
|
let myHex: String? = {
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||||
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
let id = try? viewModel.idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||||
return id.publicKeyHex.lowercased()
|
return id.publicKeyHex.lowercased()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -599,7 +599,7 @@ extension LocationChannelsSheet {
|
|||||||
switch level {
|
switch level {
|
||||||
case .region:
|
case .region:
|
||||||
return ""
|
return ""
|
||||||
default:
|
case .building, .block, .neighborhood, .city, .province:
|
||||||
return "~"
|
return "~"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
private typealias PlatformImage = UIImage
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
private typealias PlatformImage = NSImage
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct BlockRevealImageView: View {
|
||||||
|
private let url: URL
|
||||||
|
private let revealProgress: Double?
|
||||||
|
private let isSending: Bool
|
||||||
|
private let onCancel: (() -> Void)?
|
||||||
|
private let initiallyBlurred: Bool
|
||||||
|
private let onOpen: (() -> Void)?
|
||||||
|
private let onDelete: (() -> Void)?
|
||||||
|
|
||||||
|
@State private var platformImage: PlatformImage?
|
||||||
|
@State private var aspectRatio: CGFloat = 1
|
||||||
|
@State private var isBlurred: Bool = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
url: URL,
|
||||||
|
revealProgress: Double?,
|
||||||
|
isSending: Bool,
|
||||||
|
onCancel: (() -> Void)?,
|
||||||
|
initiallyBlurred: Bool = false,
|
||||||
|
onOpen: (() -> Void)? = nil,
|
||||||
|
onDelete: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.revealProgress = revealProgress
|
||||||
|
self.isSending = isSending
|
||||||
|
self.onCancel = onCancel
|
||||||
|
self.initiallyBlurred = initiallyBlurred
|
||||||
|
self.onOpen = onOpen
|
||||||
|
self.onDelete = onDelete
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fraction: Double {
|
||||||
|
guard let revealProgress = revealProgress else { return 1 }
|
||||||
|
return max(0, min(1, revealProgress))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
if let image = platformImage {
|
||||||
|
Image(platformImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(aspectRatio, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.mask(
|
||||||
|
BlockRevealMask(
|
||||||
|
fraction: fraction,
|
||||||
|
columns: 24,
|
||||||
|
rows: 16
|
||||||
|
)
|
||||||
|
.animation(.easeOut(duration: 0.2), value: fraction)
|
||||||
|
)
|
||||||
|
.blur(radius: isBlurred ? 20 : 0)
|
||||||
|
.overlay {
|
||||||
|
if isBlurred {
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.fill(Color.black.opacity(0.35))
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: "eye.slash.fill")
|
||||||
|
.font(.bitchatSystem(size: 24, weight: .semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.85))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.fill(Color.gray.opacity(0.2))
|
||||||
|
.frame(height: 200)
|
||||||
|
.overlay(
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let onCancel = onCancel, isSending {
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.bitchatSystem(size: 12, weight: .bold))
|
||||||
|
.padding(8)
|
||||||
|
.background(Circle().fill(Color.black.opacity(0.7)))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
isBlurred = initiallyBlurred
|
||||||
|
loadImage()
|
||||||
|
}
|
||||||
|
.onChange(of: url) { _ in
|
||||||
|
isBlurred = initiallyBlurred
|
||||||
|
loadImage()
|
||||||
|
}
|
||||||
|
.gesture(mainGesture)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadImage() {
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
#if os(iOS)
|
||||||
|
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
||||||
|
#else
|
||||||
|
guard let image = NSImage(contentsOf: url) else { return }
|
||||||
|
#endif
|
||||||
|
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.platformImage = image
|
||||||
|
self.aspectRatio = ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainGesture: some Gesture {
|
||||||
|
let doubleTap = TapGesture(count: 2).onEnded {
|
||||||
|
guard !isSending else { return }
|
||||||
|
onDelete?()
|
||||||
|
}
|
||||||
|
let singleTap = TapGesture().onEnded {
|
||||||
|
guard !isSending else { return }
|
||||||
|
if isBlurred {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
isBlurred = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onOpen?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in
|
||||||
|
guard !isSending else { return }
|
||||||
|
let horizontal = value.translation.width
|
||||||
|
let vertical = value.translation.height
|
||||||
|
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }
|
||||||
|
if !isBlurred {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
isBlurred = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BlockRevealMask: Shape {
|
||||||
|
let fraction: Double
|
||||||
|
let columns: Int
|
||||||
|
let rows: Int
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
guard fraction > 0, columns > 0, rows > 0 else { return path }
|
||||||
|
let totalBlocks = columns * rows
|
||||||
|
let revealCount = max(0, min(totalBlocks, Int(ceil(fraction * Double(totalBlocks)))))
|
||||||
|
guard revealCount > 0 else { return path }
|
||||||
|
let blockWidth = rect.width / CGFloat(columns)
|
||||||
|
let blockHeight = rect.height / CGFloat(rows)
|
||||||
|
var remaining = revealCount
|
||||||
|
for row in 0..<rows {
|
||||||
|
for column in 0..<columns {
|
||||||
|
if remaining <= 0 { return path }
|
||||||
|
let x = CGFloat(column) * blockWidth
|
||||||
|
let y = CGFloat(row) * blockHeight
|
||||||
|
path.addRect(CGRect(x: x, y: y, width: blockWidth, height: blockHeight))
|
||||||
|
remaining -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Image {
|
||||||
|
init(platformImage: PlatformImage) {
|
||||||
|
#if os(iOS)
|
||||||
|
self.init(uiImage: platformImage)
|
||||||
|
#else
|
||||||
|
self.init(nsImage: platformImage)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FileAttachmentView: View {
|
||||||
|
private let url: URL
|
||||||
|
private let isSending: Bool
|
||||||
|
private let progress: Double?
|
||||||
|
private let onCancel: (() -> Void)?
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
#if os(iOS)
|
||||||
|
@State private var showExporter = false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
init(url: URL, isSending: Bool, progress: Double?, onCancel: (() -> Void)?) {
|
||||||
|
self.url = url
|
||||||
|
self.isSending = isSending
|
||||||
|
self.progress = progress
|
||||||
|
self.onCancel = onCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fileName: String {
|
||||||
|
url.lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
private var normalizedProgress: Double? {
|
||||||
|
guard let progress = progress else { return nil }
|
||||||
|
return max(0, min(1, progress))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Image(systemName: "doc.fill")
|
||||||
|
.foregroundColor(Color.blue)
|
||||||
|
.font(.bitchatSystem(size: 24))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(fileName)
|
||||||
|
.font(.bitchatSystem(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text(url.lastPathComponent)
|
||||||
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
if let progress = normalizedProgress {
|
||||||
|
ProgressView(value: progress)
|
||||||
|
.progressViewStyle(.linear)
|
||||||
|
.tint(Color.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: openFile) {
|
||||||
|
Text("open", comment: "Button to open attached file")
|
||||||
|
.font(.bitchatSystem(size: 13, weight: .semibold))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(Color.blue.opacity(0.15))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if let onCancel = onCancel, isSending {
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.bitchatSystem(size: 11, weight: .bold))
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.background(Circle().fill(Color.red.opacity(0.9)))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(colorScheme == .dark ? Color.black.opacity(0.6) : Color.white)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
#if os(iOS)
|
||||||
|
.sheet(isPresented: $showExporter) {
|
||||||
|
FileExportController(url: url)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openFile() {
|
||||||
|
#if os(iOS)
|
||||||
|
showExporter = true
|
||||||
|
#else
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
private struct FileExportController: UIViewControllerRepresentable {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||||
|
let controller = UIDocumentPickerViewController(forExporting: [url])
|
||||||
|
controller.shouldShowFileExtensions = true
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
struct VoiceNoteView: View {
|
||||||
|
private let url: URL
|
||||||
|
private let isSending: Bool
|
||||||
|
private let sendProgress: Double?
|
||||||
|
private let onCancel: (() -> Void)?
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@StateObject private var playback: VoiceNotePlaybackController
|
||||||
|
@State private var waveform: [Float] = []
|
||||||
|
|
||||||
|
init(url: URL, isSending: Bool, sendProgress: Double?, onCancel: (() -> Void)?) {
|
||||||
|
self.url = url
|
||||||
|
self.isSending = isSending
|
||||||
|
self.sendProgress = sendProgress
|
||||||
|
self.onCancel = onCancel
|
||||||
|
_playback = StateObject(wrappedValue: VoiceNotePlaybackController(url: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var samples: [Float] {
|
||||||
|
if waveform.isEmpty {
|
||||||
|
return Array(repeating: 0.25, count: 64)
|
||||||
|
}
|
||||||
|
return waveform
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
|
||||||
|
}
|
||||||
|
|
||||||
|
private var borderColor: Color {
|
||||||
|
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var durationText: String {
|
||||||
|
let duration = playback.duration
|
||||||
|
guard duration.isFinite, duration > 0 else { return "--:--" }
|
||||||
|
let minutes = Int(duration) / 60
|
||||||
|
let seconds = Int(duration) % 60
|
||||||
|
return String(format: "%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentText: String {
|
||||||
|
let current = playback.currentTime
|
||||||
|
guard current.isFinite, current > 0 else { return "00:00" }
|
||||||
|
let minutes = Int(current) / 60
|
||||||
|
let seconds = Int(current) % 60
|
||||||
|
return String(format: "%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var playbackLabel: String {
|
||||||
|
playback.isPlaying ? currentText + "/" + durationText : durationText
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: playback.togglePlayback) {
|
||||||
|
Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(Circle().fill(Color.green))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
WaveformView(
|
||||||
|
samples: samples,
|
||||||
|
playbackProgress: playback.progress,
|
||||||
|
sendProgress: sendProgress,
|
||||||
|
onSeek: { fraction in
|
||||||
|
playback.seek(to: fraction)
|
||||||
|
},
|
||||||
|
isInteractive: playback.isPlaying
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(playbackLabel)
|
||||||
|
.font(.bitchatSystem(size: 13, design: .monospaced))
|
||||||
|
.foregroundColor(Color.secondary)
|
||||||
|
|
||||||
|
if let onCancel = onCancel, isSending {
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.bitchatSystem(size: 12, weight: .bold))
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(Circle().fill(Color.red.opacity(0.9)))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(backgroundColor)
|
||||||
|
.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.3 : 0.1), radius: 6, x: 0, y: 2)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(borderColor, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.task {
|
||||||
|
// Defer loading to let UI settle after view appears
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
|
||||||
|
playback.loadDuration()
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
WaveformCache.shared.waveform(for: url, completion: { bins in
|
||||||
|
waveform = bins
|
||||||
|
continuation.resume()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: url) { newValue in
|
||||||
|
WaveformCache.shared.waveform(for: newValue, completion: { bins in
|
||||||
|
self.waveform = bins
|
||||||
|
})
|
||||||
|
playback.replaceURL(newValue)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
playback.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WaveformView: View {
|
||||||
|
let samples: [Float]
|
||||||
|
let playbackProgress: Double
|
||||||
|
let sendProgress: Double?
|
||||||
|
let onSeek: ((Double) -> Void)?
|
||||||
|
let isInteractive: Bool
|
||||||
|
|
||||||
|
private var clampedPlayback: Double {
|
||||||
|
max(0, min(1, playbackProgress))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clampedSend: Double? {
|
||||||
|
guard let sendProgress = sendProgress else { return nil }
|
||||||
|
return max(0, min(1, sendProgress))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
Canvas { context, size in
|
||||||
|
guard !samples.isEmpty else { return }
|
||||||
|
let width = max(size.width, 1)
|
||||||
|
let height = max(size.height, 1)
|
||||||
|
let barWidth = max(width / CGFloat(samples.count), 1)
|
||||||
|
for (index, sample) in samples.enumerated() {
|
||||||
|
let normalized = max(0, min(sample, 1))
|
||||||
|
let barHeight = CGFloat(normalized) * height
|
||||||
|
let originX = CGFloat(index) * barWidth
|
||||||
|
let rect = CGRect(
|
||||||
|
x: originX,
|
||||||
|
y: (height - barHeight) / 2,
|
||||||
|
width: max(barWidth * 0.7, 1),
|
||||||
|
height: barHeight
|
||||||
|
)
|
||||||
|
let binPosition = Double(index) / Double(samples.count)
|
||||||
|
let color: Color
|
||||||
|
if binPosition <= clampedPlayback {
|
||||||
|
color = Color.green
|
||||||
|
} else if let send = clampedSend, binPosition <= send {
|
||||||
|
color = Color.blue
|
||||||
|
} else {
|
||||||
|
color = Color.gray.opacity(0.35)
|
||||||
|
}
|
||||||
|
context.fill(Path(rect), with: .color(color))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||||
|
|
||||||
|
if isInteractive, let onSeek = onSeek {
|
||||||
|
Color.clear
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onEnded { value in
|
||||||
|
guard geometry.size.width > 0 else { return }
|
||||||
|
let fraction = max(0, min(1, value.location.x / geometry.size.width))
|
||||||
|
onSeek(fraction)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 48)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,8 @@ struct MeshPeerList: View {
|
|||||||
let myPeerID = viewModel.meshService.myPeerID
|
let myPeerID = viewModel.meshService.myPeerID
|
||||||
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
||||||
let isMe = peer.peerID == myPeerID
|
let isMe = peer.peerID == myPeerID
|
||||||
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID.id)
|
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID)
|
||||||
let enc = viewModel.getEncryptionStatus(for: peer.peerID.id)
|
let enc = viewModel.getEncryptionStatus(for: peer.peerID)
|
||||||
return (peer, isMe, hasUnread, enc)
|
return (peer, isMe, hasUnread, enc)
|
||||||
}
|
}
|
||||||
// Stable visual order without mutating state here
|
// Stable visual order without mutating state here
|
||||||
@@ -47,7 +47,7 @@ struct MeshPeerList: View {
|
|||||||
let peer = item.peer
|
let peer = item.peer
|
||||||
let isMe = item.isMe
|
let isMe = item.isMe
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
let assigned = viewModel.colorForMeshPeer(id: peer.peerID.id, isDark: colorScheme == .dark)
|
let assigned = viewModel.colorForMeshPeer(id: peer.peerID, isDark: colorScheme == .dark)
|
||||||
let baseColor = isMe ? Color.orange : assigned
|
let baseColor = isMe ? Color.orange : assigned
|
||||||
if isMe {
|
if isMe {
|
||||||
Image(systemName: "person.fill")
|
Image(systemName: "person.fill")
|
||||||
@@ -89,7 +89,7 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMe, viewModel.isPeerBlocked(peer.peerID.id) {
|
if !isMe, viewModel.isPeerBlocked(peer.peerID) {
|
||||||
Image(systemName: "nosign")
|
Image(systemName: "nosign")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
@@ -105,7 +105,7 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Offline: prefer showing verified badge from persisted fingerprints
|
// Offline: prefer showing verified badge from persisted fingerprints
|
||||||
if let fp = viewModel.getFingerprint(for: peer.peerID.id),
|
if let fp = viewModel.getFingerprint(for: peer.peerID),
|
||||||
viewModel.verifiedFingerprints.contains(fp) {
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
Image(systemName: "checkmark.seal.fill")
|
Image(systemName: "checkmark.seal.fill")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ struct VerificationSheetView: View {
|
|||||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||||
|
|
||||||
private func myQRString() -> String {
|
private func myQRString() -> String {
|
||||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? viewModel.idBridge.getCurrentNostrIdentity()?.npub
|
||||||
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
|
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,19 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.device.bluetooth</key>
|
<key>com.apple.security.device.bluetooth</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.microphone</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.personal-information.location</key>
|
<key>com.apple.security.personal-information.location</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.assets.pictures.read-only</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -108,6 +108,13 @@
|
|||||||
"value": "공유된 링크",
|
"value": "공유된 링크",
|
||||||
"comment": "Fallback title when saving a shared link"
|
"comment": "Fallback title when saving a shared link"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "paylaşılan bağlantı",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -218,6 +225,13 @@
|
|||||||
"value": "링크를 인코딩하는 데 실패했습니다",
|
"value": "링크를 인코딩하는 데 실패했습니다",
|
||||||
"comment": "Shown when the share payload cannot be encoded"
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "bağlantı kodlanamadı",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -328,6 +342,13 @@
|
|||||||
"value": "공유할 수 있는 내용이 없습니다",
|
"value": "공유할 수 있는 내용이 없습니다",
|
||||||
"comment": "Shown when provided content cannot be shared"
|
"comment": "Shown when provided content cannot be shared"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "paylaşılabilir içerik yok",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -438,6 +459,13 @@
|
|||||||
"value": "공유할 내용이 없습니다",
|
"value": "공유할 내용이 없습니다",
|
||||||
"comment": "Shown when the share extension receives no content"
|
"comment": "Shown when the share extension receives no content"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "paylaşılacak bir şey yok",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -548,6 +576,13 @@
|
|||||||
"value": "✓ bitchat으로 링크를 공유했습니다",
|
"value": "✓ bitchat으로 링크를 공유했습니다",
|
||||||
"comment": "Confirmation after successfully sharing a link"
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ bitchat'e bağlantı paylaşıldı",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -658,9 +693,16 @@
|
|||||||
"value": "✓ bitchat으로 텍스트를 공유했습니다",
|
"value": "✓ bitchat으로 텍스트를 공유했습니다",
|
||||||
"comment": "Confirmation after successfully sharing text"
|
"comment": "Confirmation after successfully sharing text"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ bitchat'e metin paylaşıldı",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "1.0"
|
"version": "1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+213
-203
@@ -6,265 +6,275 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Testing
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class BLEServiceTests: XCTestCase {
|
struct BLEServiceTests {
|
||||||
|
private let service: MockBLEService
|
||||||
|
private let myUUID = UUID()
|
||||||
|
private let bus = MockBLEBus()
|
||||||
|
|
||||||
var service: MockBLEService!
|
init() {
|
||||||
|
service = MockBLEService.init(bus: bus)
|
||||||
override func setUp() {
|
service.myPeerID = PeerID(str: myUUID.uuidString)
|
||||||
super.setUp()
|
|
||||||
service = MockBLEService()
|
|
||||||
service.myPeerID = "TEST1234"
|
|
||||||
service.mockNickname = "TestUser"
|
service.mockNickname = "TestUser"
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
service = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Basic Functionality Tests
|
// MARK: - Basic Functionality Tests
|
||||||
|
|
||||||
func testServiceInitialization() {
|
@Test func serviceInitialization() {
|
||||||
XCTAssertNotNil(service)
|
#expect(service.myPeerID == PeerID(str: myUUID.uuidString))
|
||||||
XCTAssertEqual(service.myPeerID, "TEST1234")
|
#expect(service.myNickname == "TestUser")
|
||||||
XCTAssertEqual(service.myNickname, "TestUser")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPeerConnection() {
|
@Test func peerConnection() {
|
||||||
// Test connecting a peer
|
let somePeerID = PeerID(str: UUID().uuidString)
|
||||||
service.simulateConnectedPeer("PEER5678")
|
|
||||||
XCTAssertTrue(service.isPeerConnected("PEER5678"))
|
|
||||||
XCTAssertEqual(service.getConnectedPeers().count, 1)
|
|
||||||
|
|
||||||
// Test disconnecting a peer
|
service.simulateConnectedPeer(somePeerID)
|
||||||
service.simulateDisconnectedPeer("PEER5678")
|
#expect(service.isPeerConnected(somePeerID))
|
||||||
XCTAssertFalse(service.isPeerConnected("PEER5678"))
|
#expect(service.getConnectedPeers().count == 1)
|
||||||
XCTAssertEqual(service.getConnectedPeers().count, 0)
|
|
||||||
|
service.simulateDisconnectedPeer(somePeerID)
|
||||||
|
#expect(!service.isPeerConnected(somePeerID))
|
||||||
|
#expect(service.getConnectedPeers().count == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMultiplePeerConnections() {
|
@Test func multiplePeerConnections() {
|
||||||
service.simulateConnectedPeer("PEER1")
|
let peerID1 = PeerID(str: UUID().uuidString)
|
||||||
service.simulateConnectedPeer("PEER2")
|
let peerID2 = PeerID(str: UUID().uuidString)
|
||||||
service.simulateConnectedPeer("PEER3")
|
let peerID3 = PeerID(str: UUID().uuidString)
|
||||||
|
|
||||||
|
service.simulateConnectedPeer(peerID1)
|
||||||
|
service.simulateConnectedPeer(peerID2)
|
||||||
|
service.simulateConnectedPeer(peerID3)
|
||||||
|
|
||||||
XCTAssertEqual(service.getConnectedPeers().count, 3)
|
#expect(service.getConnectedPeers().count == 3)
|
||||||
XCTAssertTrue(service.isPeerConnected("PEER1"))
|
#expect(service.isPeerConnected(peerID1))
|
||||||
XCTAssertTrue(service.isPeerConnected("PEER2"))
|
#expect(service.isPeerConnected(peerID2))
|
||||||
XCTAssertTrue(service.isPeerConnected("PEER3"))
|
#expect(service.isPeerConnected(peerID3))
|
||||||
|
|
||||||
service.simulateDisconnectedPeer("PEER2")
|
service.simulateDisconnectedPeer(peerID2)
|
||||||
XCTAssertEqual(service.getConnectedPeers().count, 2)
|
#expect(service.getConnectedPeers().count == 2)
|
||||||
XCTAssertFalse(service.isPeerConnected("PEER2"))
|
#expect(!service.isPeerConnected(peerID2))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Sending Tests
|
// MARK: - Message Sending Tests
|
||||||
|
|
||||||
func testSendPublicMessage() {
|
@Test func sendPublicMessage() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Message sent")
|
try await confirmation { receivedPublicMessage in
|
||||||
|
let delegate = MockBitchatDelegate { message in
|
||||||
let delegate = MockBitchatDelegate { message in
|
#expect(message.content == "Hello, world!")
|
||||||
XCTAssertEqual(message.content, "Hello, world!")
|
#expect(message.sender == "TestUser")
|
||||||
XCTAssertEqual(message.sender, "TestUser")
|
#expect(!message.isPrivate)
|
||||||
XCTAssertFalse(message.isPrivate)
|
receivedPublicMessage()
|
||||||
expectation.fulfill()
|
}
|
||||||
|
service.delegate = delegate
|
||||||
|
service.sendMessage("Hello, world!")
|
||||||
|
|
||||||
|
// Allow async processing
|
||||||
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
service.delegate = delegate
|
#expect(service.sentMessages.count == 1)
|
||||||
|
|
||||||
service.sendMessage("Hello, world!")
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
|
||||||
XCTAssertEqual(service.sentMessages.count, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSendPrivateMessage() {
|
@Test func sendPrivateMessage() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Private message sent")
|
try await confirmation { receivedPrivateMessage in
|
||||||
|
let delegate = MockBitchatDelegate { message in
|
||||||
let delegate = MockBitchatDelegate { message in
|
#expect(message.content == "Secret message")
|
||||||
XCTAssertEqual(message.content, "Secret message")
|
#expect(message.sender == "TestUser")
|
||||||
XCTAssertEqual(message.sender, "TestUser")
|
#expect(message.senderPeerID == PeerID(str: myUUID.uuidString))
|
||||||
XCTAssertTrue(message.isPrivate)
|
#expect(message.isPrivate)
|
||||||
XCTAssertEqual(message.recipientNickname, "Bob")
|
#expect(message.recipientNickname == "Bob")
|
||||||
expectation.fulfill()
|
receivedPrivateMessage()
|
||||||
|
}
|
||||||
|
service.delegate = delegate
|
||||||
|
service.sendPrivateMessage(
|
||||||
|
"Secret message",
|
||||||
|
to: PeerID(str: UUID().uuidString),
|
||||||
|
recipientNickname: "Bob",
|
||||||
|
messageID: "MSG123"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Allow async processing
|
||||||
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
service.delegate = delegate
|
#expect(service.sentMessages.count == 1)
|
||||||
|
|
||||||
service.sendPrivateMessage("Secret message", to: "PEER5678", recipientNickname: "Bob", messageID: "MSG123")
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
|
||||||
XCTAssertEqual(service.sentMessages.count, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSendMessageWithMentions() {
|
@Test func sendMessageWithMentions() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Message with mentions sent")
|
try await confirmation { receivedMessageWithMentions in
|
||||||
|
let delegate = MockBitchatDelegate { message in
|
||||||
let delegate = MockBitchatDelegate { message in
|
#expect(message.content == "@alice @bob check this out")
|
||||||
XCTAssertEqual(message.content, "@alice @bob check this out")
|
#expect(message.mentions == ["alice", "bob"])
|
||||||
XCTAssertEqual(message.mentions, ["alice", "bob"])
|
receivedMessageWithMentions()
|
||||||
expectation.fulfill()
|
}
|
||||||
|
service.delegate = delegate
|
||||||
|
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
|
||||||
|
|
||||||
|
// Allow async processing
|
||||||
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
service.delegate = delegate
|
|
||||||
|
|
||||||
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Reception Tests
|
// MARK: - Message Reception Tests
|
||||||
|
|
||||||
func testSimulateIncomingMessage() {
|
@Test func simulateIncomingMessage() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Message received")
|
try await confirmation { receiveMessage in
|
||||||
|
let peerID = PeerID(str: UUID().uuidString)
|
||||||
let delegate = MockBitchatDelegate { message in
|
|
||||||
XCTAssertEqual(message.content, "Incoming message")
|
let delegate = MockBitchatDelegate { message in
|
||||||
XCTAssertEqual(message.sender, "RemoteUser")
|
#expect(message.content == "Incoming message")
|
||||||
expectation.fulfill()
|
#expect(message.sender == "RemoteUser")
|
||||||
|
#expect(message.senderPeerID == peerID)
|
||||||
|
receiveMessage()
|
||||||
|
}
|
||||||
|
service.delegate = delegate
|
||||||
|
|
||||||
|
let incomingMessage = BitchatMessage(
|
||||||
|
id: "MSG456",
|
||||||
|
sender: "RemoteUser",
|
||||||
|
content: "Incoming message",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: false,
|
||||||
|
recipientNickname: nil,
|
||||||
|
senderPeerID: peerID,
|
||||||
|
mentions: nil
|
||||||
|
)
|
||||||
|
service.simulateIncomingMessage(incomingMessage)
|
||||||
|
|
||||||
|
// Allow async processing
|
||||||
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
service.delegate = delegate
|
|
||||||
|
|
||||||
let incomingMessage = BitchatMessage(
|
|
||||||
id: "MSG456",
|
|
||||||
sender: "RemoteUser",
|
|
||||||
content: "Incoming message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: "REMOTE123",
|
|
||||||
mentions: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
service.simulateIncomingMessage(incomingMessage)
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSimulateIncomingPacket() {
|
@Test func simulateIncomingPacket() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Packet processed")
|
try await confirmation { processPacket in
|
||||||
|
let peerID = PeerID(str: UUID().uuidString)
|
||||||
let delegate = MockBitchatDelegate { message in
|
|
||||||
XCTAssertEqual(message.content, "Packet message")
|
let delegate = MockBitchatDelegate { message in
|
||||||
expectation.fulfill()
|
#expect(message.content == "Packet message")
|
||||||
|
#expect(message.senderPeerID == peerID)
|
||||||
|
processPacket()
|
||||||
|
}
|
||||||
|
service.delegate = delegate
|
||||||
|
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "MSG789",
|
||||||
|
sender: "PacketSender",
|
||||||
|
content: "Packet message",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: false,
|
||||||
|
recipientNickname: nil,
|
||||||
|
senderPeerID: peerID,
|
||||||
|
mentions: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let payload = try #require(message.toBinaryPayload(), "Failed to create binary payload")
|
||||||
|
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: 0x01,
|
||||||
|
senderID: peerID.id.data(using: .utf8)!,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 3
|
||||||
|
)
|
||||||
|
|
||||||
|
service.simulateIncomingPacket(packet)
|
||||||
|
|
||||||
|
// Allow async processing
|
||||||
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
service.delegate = delegate
|
|
||||||
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "MSG789",
|
|
||||||
sender: "PacketSender",
|
|
||||||
content: "Packet message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: "PACKET123",
|
|
||||||
mentions: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload() else {
|
|
||||||
XCTFail("Failed to create binary payload")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
|
||||||
type: 0x01,
|
|
||||||
senderID: "PACKET123".data(using: .utf8)!,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
||||||
payload: payload,
|
|
||||||
signature: nil,
|
|
||||||
ttl: 3
|
|
||||||
)
|
|
||||||
|
|
||||||
service.simulateIncomingPacket(packet)
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Peer Nickname Tests
|
// MARK: - Peer Nickname Tests
|
||||||
|
|
||||||
func testGetPeerNicknames() {
|
@Test func getPeerNicknames() {
|
||||||
service.simulateConnectedPeer("PEER1")
|
let peerID1 = PeerID(str: UUID().uuidString)
|
||||||
service.simulateConnectedPeer("PEER2")
|
let peerID2 = PeerID(str: UUID().uuidString)
|
||||||
|
|
||||||
|
service.simulateConnectedPeer(peerID1)
|
||||||
|
service.simulateConnectedPeer(peerID2)
|
||||||
|
|
||||||
let nicknames = service.getPeerNicknames()
|
let nicknames = service.getPeerNicknames()
|
||||||
XCTAssertEqual(nicknames.count, 2)
|
#expect(nicknames.count == 2)
|
||||||
XCTAssertEqual(nicknames["PEER1"], "MockPeer_PEER1")
|
#expect(nicknames[peerID1] == "MockPeer_\(peerID1)")
|
||||||
XCTAssertEqual(nicknames["PEER2"], "MockPeer_PEER2")
|
#expect(nicknames[peerID2] == "MockPeer_\(peerID2)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Service State Tests
|
// MARK: - Service State Tests
|
||||||
|
|
||||||
func testStartStopServices() {
|
@Test func startStopServices() {
|
||||||
// These are mock implementations, just ensure they don't crash
|
|
||||||
service.startServices()
|
service.startServices()
|
||||||
service.stopServices()
|
service.stopServices()
|
||||||
|
let somePeerID = PeerID(str: UUID().uuidString)
|
||||||
// Service should still be functional after start/stop
|
service.simulateConnectedPeer(somePeerID)
|
||||||
service.simulateConnectedPeer("PEER999")
|
#expect(service.isPeerConnected(somePeerID))
|
||||||
XCTAssertTrue(service.isPeerConnected("PEER999"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Delivery Handler Tests
|
// MARK: - Message Delivery Handler Tests
|
||||||
|
|
||||||
func testMessageDeliveryHandler() {
|
@Test func messageDeliveryHandler() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Delivery handler called")
|
try await confirmation { deliveryHandler in
|
||||||
|
service.packetDeliveryHandler = { packet in
|
||||||
service.packetDeliveryHandler = { packet in
|
if let msg = BitchatMessage(packet.payload) {
|
||||||
if let msg = BitchatMessage(packet.payload) {
|
#expect(msg.content == "Test delivery")
|
||||||
XCTAssertEqual(msg.content, "Test delivery")
|
deliveryHandler()
|
||||||
expectation.fulfill()
|
}
|
||||||
}
|
}
|
||||||
|
service.sendMessage("Test delivery")
|
||||||
|
|
||||||
|
// Allow async processing
|
||||||
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
service.sendMessage("Test delivery")
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPacketDeliveryHandler() {
|
@Test func packetDeliveryHandler() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Packet handler called")
|
try await confirmation("Packet handler called") { packetHandler in
|
||||||
|
let peerID = PeerID(str: UUID().uuidString)
|
||||||
service.packetDeliveryHandler = { packet in
|
|
||||||
XCTAssertEqual(packet.type, 0x01)
|
service.packetDeliveryHandler = { packet in
|
||||||
expectation.fulfill()
|
#expect(packet.type == 0x01)
|
||||||
|
#expect(packet.senderID == Data(peerID.id.utf8))
|
||||||
|
packetHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "PKT123",
|
||||||
|
sender: "TestSender",
|
||||||
|
content: "Test packet",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: false,
|
||||||
|
recipientNickname: nil,
|
||||||
|
senderPeerID: peerID,
|
||||||
|
mentions: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let payload = try #require(message.toBinaryPayload(), "Failed to create payload")
|
||||||
|
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: 0x01,
|
||||||
|
senderID: peerID.id.data(using: .utf8)!,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 3
|
||||||
|
)
|
||||||
|
|
||||||
|
service.simulateIncomingPacket(packet)
|
||||||
|
|
||||||
|
// Allow async processing
|
||||||
|
try await sleep(0.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "PKT123",
|
|
||||||
sender: "TestSender",
|
|
||||||
content: "Test packet",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: "TEST123",
|
|
||||||
mentions: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload() else {
|
|
||||||
XCTFail("Failed to create payload")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
|
||||||
type: 0x01,
|
|
||||||
senderID: "TEST123".data(using: .utf8)!,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
||||||
payload: payload,
|
|
||||||
signature: nil,
|
|
||||||
ttl: 3
|
|
||||||
)
|
|
||||||
|
|
||||||
service.simulateIncomingPacket(packet)
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,42 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class CommandProcessorTests: XCTestCase {
|
struct CommandProcessorTests {
|
||||||
|
private var identityManager = MockIdentityManager(MockKeychain())
|
||||||
var identityManager: MockIdentityManager!
|
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
// Provide a minimal identity manager for commands that query identity/block lists
|
|
||||||
identityManager = MockIdentityManager(MockKeychain())
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
identityManager = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func test_slap_notFoundGrammar() {
|
@Test func slapNotFoundGrammar() {
|
||||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/slap @system")
|
let result = processor.process("/slap @system")
|
||||||
switch result {
|
switch result {
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
XCTAssertEqual(message, "cannot slap system: not found")
|
#expect(message == "cannot slap system: not found")
|
||||||
default:
|
default:
|
||||||
XCTFail("Expected error result")
|
Issue.record("Expected error result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func test_hug_notFoundGrammar() {
|
@Test func hugNotFoundGrammar() {
|
||||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/hug @system")
|
let result = processor.process("/hug @system")
|
||||||
switch result {
|
switch result {
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
XCTAssertEqual(message, "cannot hug system: not found")
|
#expect(message == "cannot hug system: not found")
|
||||||
default:
|
default:
|
||||||
XCTFail("Expected error result")
|
Issue.record("Expected error result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func test_slap_usageMessage() {
|
@Test func slapUsageMessage() {
|
||||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/slap")
|
let result = processor.process("/slap")
|
||||||
switch result {
|
switch result {
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
XCTAssertEqual(message, "usage: /slap <nickname>")
|
#expect(message == "usage: /slap <nickname>")
|
||||||
default:
|
default:
|
||||||
XCTFail("Expected error result for usage message")
|
Issue.record("Expected error result for usage message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,19 @@ import CryptoKit
|
|||||||
import struct Foundation.UUID
|
import struct Foundation.UUID
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
// TODO: Remove once MockBLEService is refactored to fix race condition
|
|
||||||
@Suite(.serialized)
|
|
||||||
struct PrivateChatE2ETests {
|
struct PrivateChatE2ETests {
|
||||||
|
|
||||||
private let alice: MockBLEService
|
private let alice: MockBLEService
|
||||||
private let bob: MockBLEService
|
private let bob: MockBLEService
|
||||||
private let charlie: MockBLEService
|
private let charlie: MockBLEService
|
||||||
private let mockKeychain: MockKeychain
|
private let mockKeychain = MockKeychain()
|
||||||
|
private let bus = MockBLEBus()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Create services with unique peer IDs to avoid any collision
|
// Create services with unique peer IDs to avoid any collision
|
||||||
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus)
|
||||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)
|
||||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)
|
||||||
mockKeychain = MockKeychain()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Basic Private Messaging Tests
|
// MARK: - Basic Private Messaging Tests
|
||||||
@@ -53,7 +51,7 @@ struct PrivateChatE2ETests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Wait a bit to ensure message would have been delivered if it was going to be
|
// Wait a bit to ensure message would have been delivered if it was going to be
|
||||||
try? await Task.sleep(nanoseconds: UInt64(TestConstants.shortTimeout * 1_000_000_000))
|
try? await sleep(0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(!bobReceivedMessage, "Bob should not have received the message")
|
#expect(!bobReceivedMessage, "Bob should not have received the message")
|
||||||
@@ -171,7 +169,7 @@ struct PrivateChatE2ETests {
|
|||||||
// Send encrypted private message
|
// Send encrypted private message
|
||||||
alice.sendPrivateMessage(
|
alice.sendPrivateMessage(
|
||||||
TestConstants.testMessage1,
|
TestConstants.testMessage1,
|
||||||
to: TestConstants.testPeerID2,
|
to: bob.peerID,
|
||||||
recipientNickname: TestConstants.testNickname2
|
recipientNickname: TestConstants.testNickname2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -235,7 +233,7 @@ struct PrivateChatE2ETests {
|
|||||||
for i in 0..<messageCount {
|
for i in 0..<messageCount {
|
||||||
alice.sendPrivateMessage(
|
alice.sendPrivateMessage(
|
||||||
"Private message \(i)",
|
"Private message \(i)",
|
||||||
to: TestConstants.testPeerID2,
|
to: bob.peerID,
|
||||||
recipientNickname: TestConstants.testNickname2
|
recipientNickname: TestConstants.testNickname2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -254,7 +252,7 @@ struct PrivateChatE2ETests {
|
|||||||
|
|
||||||
alice.sendPrivateMessage(
|
alice.sendPrivateMessage(
|
||||||
TestConstants.testLongMessage,
|
TestConstants.testLongMessage,
|
||||||
to: TestConstants.testPeerID2,
|
to: bob.peerID,
|
||||||
recipientNickname: TestConstants.testNickname2
|
recipientNickname: TestConstants.testNickname2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,22 @@ import Testing
|
|||||||
import struct Foundation.UUID
|
import struct Foundation.UUID
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@Suite(.serialized)
|
|
||||||
struct PublicChatE2ETests {
|
struct PublicChatE2ETests {
|
||||||
|
|
||||||
private let alice: MockBLEService
|
private let alice: MockBLEService
|
||||||
private let bob: MockBLEService
|
private let bob: MockBLEService
|
||||||
private let charlie: MockBLEService
|
private let charlie: MockBLEService
|
||||||
private let david: MockBLEService
|
private let david: MockBLEService
|
||||||
|
private let bus = MockBLEBus()
|
||||||
|
|
||||||
private var receivedMessages: [String: [BitchatMessage]] = [:]
|
private var receivedMessages: [String: [BitchatMessage]] = [:]
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Create mock services with unique peer IDs to avoid any collision
|
// Create mock services with unique peer IDs to avoid any collision
|
||||||
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus)
|
||||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)
|
||||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)
|
||||||
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4)
|
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4, bus: bus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Basic Broadcasting Tests
|
// MARK: - Basic Broadcasting Tests
|
||||||
|
|||||||
@@ -15,20 +15,26 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
private let mockKeychain: MockKeychain
|
private let mockKeychain: MockKeychain
|
||||||
private let mockIdentityManager: MockIdentityManager
|
private let mockIdentityManager: MockIdentityManager
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
mockKeychain = MockKeychain()
|
mockKeychain = MockKeychain()
|
||||||
mockIdentityManager = MockIdentityManager(mockKeychain)
|
mockIdentityManager = MockIdentityManager(mockKeychain)
|
||||||
|
idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Reassembly from fragments delivers a public message")
|
@Test("Reassembly from fragments delivers a public message")
|
||||||
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
||||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
let ble = BLEService(
|
||||||
|
keychain: mockKeychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: mockIdentityManager
|
||||||
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
||||||
let remoteShortID: PeerID = "1122334455667788"
|
let remoteShortID = PeerID(str: "1122334455667788")
|
||||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
||||||
|
|
||||||
// Use a small fragment size to ensure multiple pieces
|
// Use a small fragment size to ensure multiple pieces
|
||||||
@@ -39,15 +45,15 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
// Inject fragments spaced out to avoid concurrent mutation inside BLEService
|
// Inject fragments spaced out to avoid concurrent mutation inside BLEService
|
||||||
for (i, fragment) in shuffled.enumerated() {
|
for (i, fragment) in shuffled.enumerated() {
|
||||||
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
let delay = 5 * Double(i) * 0.001
|
||||||
Task {
|
Task {
|
||||||
try await Task.sleep(nanoseconds: delay)
|
try await sleep(delay)
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
try await sleep(0.5)
|
||||||
|
|
||||||
#expect(capture.publicMessages.count == 1)
|
#expect(capture.publicMessages.count == 1)
|
||||||
#expect(capture.publicMessages.first?.content.count == 3_000)
|
#expect(capture.publicMessages.first?.content.count == 3_000)
|
||||||
@@ -55,11 +61,15 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
@Test("Duplicate fragment does not break reassembly")
|
@Test("Duplicate fragment does not break reassembly")
|
||||||
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
||||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
let ble = BLEService(
|
||||||
|
keychain: mockKeychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: mockIdentityManager
|
||||||
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
let remoteShortID: PeerID = "A1B2C3D4E5F60708"
|
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
|
||||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
||||||
var frags = fragmentPacket(original, fragmentSize: 300)
|
var frags = fragmentPacket(original, fragmentSize: 300)
|
||||||
|
|
||||||
@@ -69,27 +79,87 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (i, fragment) in frags.enumerated() {
|
for (i, fragment) in frags.enumerated() {
|
||||||
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
let delay = 5 * Double(i) * 0.001
|
||||||
Task {
|
Task {
|
||||||
try await Task.sleep(nanoseconds: delay)
|
try await sleep(delay)
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
try await sleep(0.5)
|
||||||
|
|
||||||
#expect(capture.publicMessages.count == 1)
|
#expect(capture.publicMessages.count == 1)
|
||||||
#expect(capture.publicMessages.first?.content.count == 2048)
|
#expect(capture.publicMessages.first?.content.count == 2048)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Max-sized file transfer survives reassembly")
|
||||||
|
func maxSizedFileTransferSurvivesReassembly() async throws {
|
||||||
|
let ble = BLEService(
|
||||||
|
keychain: mockKeychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: mockIdentityManager
|
||||||
|
)
|
||||||
|
let capture = CaptureDelegate()
|
||||||
|
ble.delegate = capture
|
||||||
|
|
||||||
|
let remoteID = PeerID(str: "CAFEBABECAFEBABE")
|
||||||
|
let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes)
|
||||||
|
let filePacket = BitchatFilePacket(
|
||||||
|
fileName: "limit.bin",
|
||||||
|
fileSize: UInt64(fileContent.count),
|
||||||
|
mimeType: "application/octet-stream",
|
||||||
|
content: fileContent
|
||||||
|
)
|
||||||
|
let encoded = try #require(filePacket.encode(), "File packet encoding failed")
|
||||||
|
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.fileTransfer.rawValue,
|
||||||
|
senderID: Data(hexString: remoteID.id) ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: encoded,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 7,
|
||||||
|
version: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false)
|
||||||
|
#expect(!fragments.isEmpty)
|
||||||
|
|
||||||
|
for (i, fragment) in fragments.enumerated() {
|
||||||
|
let delay = 5 * Double(i) * 0.001
|
||||||
|
Task {
|
||||||
|
try await sleep(delay)
|
||||||
|
ble._test_handlePacket(fragment, fromPeerID: remoteID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try await sleep(1.0)
|
||||||
|
|
||||||
|
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
||||||
|
#expect(message.content.hasPrefix("[file]"))
|
||||||
|
|
||||||
|
if let fileName = message.content.split(separator: " ").last {
|
||||||
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let filesRoot = base.appendingPathComponent("files", isDirectory: true)
|
||||||
|
let incoming = filesRoot.appendingPathComponent("files/incoming", isDirectory: true)
|
||||||
|
let url = incoming.appendingPathComponent(String(fileName))
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Invalid fragment header is ignored")
|
@Test("Invalid fragment header is ignored")
|
||||||
func invalidFragmentHeaderIsIgnored() async throws {
|
func invalidFragmentHeaderIsIgnored() async throws {
|
||||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
let ble = BLEService(
|
||||||
|
keychain: mockKeychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: mockIdentityManager
|
||||||
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
let remoteShortID: PeerID = "0011223344556677"
|
let remoteShortID = PeerID(str: "0011223344556677")
|
||||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)
|
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)
|
||||||
let fragments = fragmentPacket(original, fragmentSize: 250)
|
let fragments = fragmentPacket(original, fragmentSize: 250)
|
||||||
|
|
||||||
@@ -110,16 +180,16 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (i, fragment) in corrupted.enumerated() {
|
for (i, fragment) in corrupted.enumerated() {
|
||||||
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
let delay = 5 * Double(i) * 0.001
|
||||||
Task {
|
Task {
|
||||||
try await Task.sleep(nanoseconds: delay)
|
try await sleep(delay)
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
try await sleep(0.5)
|
||||||
|
|
||||||
// Should not deliver since one fragment is invalid and reassembly can't complete
|
// Should not deliver since one fragment is invalid and reassembly can't complete
|
||||||
#expect(capture.publicMessages.isEmpty)
|
#expect(capture.publicMessages.isEmpty)
|
||||||
}
|
}
|
||||||
@@ -128,7 +198,10 @@ struct FragmentationTests {
|
|||||||
extension FragmentationTests {
|
extension FragmentationTests {
|
||||||
private final class CaptureDelegate: BitchatDelegate {
|
private final class CaptureDelegate: BitchatDelegate {
|
||||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
var receivedMessages: [BitchatMessage] = []
|
||||||
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
|
receivedMessages.append(message)
|
||||||
|
}
|
||||||
func didConnectToPeer(_ peerID: PeerID) {}
|
func didConnectToPeer(_ peerID: PeerID) {}
|
||||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||||
@@ -159,8 +232,8 @@ extension FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper: fragment a packet using the same header format BLEService expects
|
// Helper: fragment a packet using the same header format BLEService expects
|
||||||
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil) -> [BitchatPacket] {
|
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil, pad: Bool = true) -> [BitchatPacket] {
|
||||||
let fullData = packet.toBinaryData() ?? Data()
|
guard let fullData = packet.toBinaryData(padding: pad) else { return [] }
|
||||||
let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
||||||
let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in
|
let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in
|
||||||
Data(fullData[off..<min(off + fragmentSize, fullData.count)])
|
Data(fullData[off..<min(off + fragmentSize, fullData.count)])
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
|
import struct Foundation.Data
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class GCSFilterTests: XCTestCase {
|
struct GCSFilterTests {
|
||||||
func testBuildFilterWithDuplicateIdsProducesStableEncoding() {
|
@Test func buildFilterWithDuplicateIdsProducesStableEncoding() {
|
||||||
let id = Data(repeating: 0xAB, count: 16)
|
let id = Data(repeating: 0xAB, count: 16)
|
||||||
let ids = Array(repeating: id, count: 64)
|
let ids = Array(repeating: id, count: 64)
|
||||||
|
|
||||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
|
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
|
||||||
XCTAssertGreaterThanOrEqual(params.m, 1)
|
#expect(params.m >= 1)
|
||||||
|
|
||||||
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
|
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
|
||||||
XCTAssertLessThanOrEqual(decoded.count, 1)
|
#expect(decoded.count <= 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBucketAvoidsZeroCandidate() {
|
@Test func bucketAvoidsZeroCandidate() {
|
||||||
let id = Data(repeating: 0x01, count: 16)
|
let id = Data(repeating: 0x01, count: 16)
|
||||||
let bucket = GCSFilter.bucket(for: id, modulus: 2)
|
let bucket = GCSFilter.bucket(for: id, modulus: 2)
|
||||||
XCTAssertNotEqual(bucket, 0)
|
#expect(bucket != 0)
|
||||||
XCTAssertLessThan(bucket, 2)
|
#expect(bucket < 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,38 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class GeohashBookmarksStoreTests: XCTestCase {
|
struct GeohashBookmarksStoreTests {
|
||||||
let storeKey = "locationChannel.bookmarks"
|
private let storeKey = "locationChannel.bookmarks"
|
||||||
var storage: UserDefaults!
|
private let storage = UserDefaults(suiteName: UUID().uuidString)!
|
||||||
var store: GeohashBookmarksStore!
|
private let store: GeohashBookmarksStore
|
||||||
|
|
||||||
override func setUp() {
|
init() {
|
||||||
super.setUp()
|
store = GeohashBookmarksStore(storage: storage)
|
||||||
// Unique instance for each test to avoid race condition
|
|
||||||
storage = UserDefaults(suiteName: UUID().uuidString)
|
|
||||||
store = GeohashBookmarksStore(storage: storage!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
@Test func toggleAndNormalize() {
|
||||||
storage.removeObject(forKey: storeKey)
|
|
||||||
store._resetForTesting()
|
|
||||||
store = nil
|
|
||||||
storage = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
func testToggleAndNormalize() {
|
|
||||||
// Start clean
|
// Start clean
|
||||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
#expect(store.bookmarks.isEmpty)
|
||||||
|
|
||||||
// Add with mixed case and hash prefix
|
// Add with mixed case and hash prefix
|
||||||
store.toggle("#U4PRUY")
|
store.toggle("#U4PRUY")
|
||||||
XCTAssertTrue(store.isBookmarked("u4pruy"))
|
#expect(store.isBookmarked("u4pruy"))
|
||||||
XCTAssertEqual(store.bookmarks.first, "u4pruy")
|
#expect(store.bookmarks.first == "u4pruy")
|
||||||
|
|
||||||
// Toggling again removes
|
// Toggling again removes
|
||||||
store.toggle("u4pruy")
|
store.toggle("u4pruy")
|
||||||
XCTAssertFalse(store.isBookmarked("u4pruy"))
|
#expect(!store.isBookmarked("u4pruy"))
|
||||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
#expect(store.bookmarks.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPersistenceWritten() throws {
|
@Test func persistenceWritten() throws {
|
||||||
store.toggle("ezs42")
|
store.toggle("ezs42")
|
||||||
store.toggle("u4pruy")
|
store.toggle("u4pruy")
|
||||||
|
|
||||||
// Verify persisted JSON contains both (order not enforced here)
|
// Verify persisted JSON contains both (order not enforced here)
|
||||||
guard let data = storage.data(forKey: storeKey) else {
|
let data = try #require(storage.data(forKey: storeKey), "No persisted data found")
|
||||||
XCTFail("No persisted data found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let arr = try JSONDecoder().decode([String].self, from: data)
|
let arr = try JSONDecoder().decode([String].self, from: data)
|
||||||
XCTAssertTrue(arr.contains("ezs42"))
|
#expect(arr.contains("ezs42"))
|
||||||
XCTAssertTrue(arr.contains("u4pruy"))
|
#expect(arr.contains("u4pruy"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class GossipSyncManagerTests: XCTestCase {
|
struct GossipSyncManagerTests {
|
||||||
func testConcurrentPacketIntakeAndSyncRequest() {
|
|
||||||
let manager = GossipSyncManager(myPeerID: "0102030405060708")
|
private let myPeerID = PeerID(str: "0102030405060708")
|
||||||
|
|
||||||
|
@Test func concurrentPacketIntakeAndSyncRequest() async throws {
|
||||||
|
let manager = GossipSyncManager(myPeerID: myPeerID)
|
||||||
let delegate = RecordingDelegate()
|
let delegate = RecordingDelegate()
|
||||||
let sendExpectation = expectation(description: "sync request sent")
|
|
||||||
delegate.onSend = { sendExpectation.fulfill() }
|
|
||||||
manager.delegate = delegate
|
manager.delegate = delegate
|
||||||
|
|
||||||
let iterations = 200
|
try await confirmation("sync request sent") { sent in
|
||||||
let group = DispatchGroup()
|
delegate.onSend = {
|
||||||
|
sent()
|
||||||
|
}
|
||||||
|
|
||||||
for i in 0..<iterations {
|
let iterations = 200
|
||||||
group.enter()
|
let senderID = try #require(Data(hexString: "1122334455667788"))
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
|
||||||
|
for i in 0..<iterations {
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.message.rawValue,
|
type: MessageType.message.rawValue,
|
||||||
senderID: Data(hexString: "1122334455667788") ?? Data(),
|
senderID: senderID,
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: 1_000_000 + UInt64(i),
|
timestamp: 1_000_000 + UInt64(i),
|
||||||
payload: Data([UInt8(truncatingIfNeeded: i)]),
|
payload: Data([UInt8(truncatingIfNeeded: i)]),
|
||||||
@@ -26,25 +30,100 @@ final class GossipSyncManagerTests: XCTestCase {
|
|||||||
ttl: 1
|
ttl: 1
|
||||||
)
|
)
|
||||||
manager.onPublicPacketSeen(packet)
|
manager.onPublicPacketSeen(packet)
|
||||||
Thread.sleep(forTimeInterval: 0.001)
|
try await sleep(0.001)
|
||||||
group.leave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
||||||
|
try await sleep(0.002)
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.002) {
|
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
||||||
manager.scheduleInitialSyncToPeer("FFFFFFFFFFFFFFFF", delaySeconds: 0.0)
|
#expect(lastPacket.type == MessageType.requestSync.rawValue)
|
||||||
}
|
#expect(RequestSyncPacket.decode(from: lastPacket.payload) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
group.wait()
|
@Test func staleAnnouncementsArePurgedWithMessages() throws {
|
||||||
wait(for: [sendExpectation], timeout: 2.0)
|
var config = GossipSyncManager.Config()
|
||||||
|
config.stalePeerCleanupIntervalSeconds = 0
|
||||||
|
config.stalePeerTimeoutSeconds = 5
|
||||||
|
|
||||||
guard let lastPacket = delegate.lastPacket else {
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||||
XCTFail("Expected sync packet to be sent")
|
let peerHex = "0011223344556677"
|
||||||
return
|
let senderData = try #require(Data(hexString: peerHex))
|
||||||
}
|
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
|
||||||
XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue)
|
let announcePacket = BitchatPacket(
|
||||||
XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload))
|
type: MessageType.announce.rawValue,
|
||||||
|
senderID: senderData,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: initialTimestampMs,
|
||||||
|
payload: Data(),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
let messagePacket = BitchatPacket(
|
||||||
|
type: MessageType.message.rawValue,
|
||||||
|
senderID: senderData,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: initialTimestampMs,
|
||||||
|
payload: Data([0x01]),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.onPublicPacketSeen(announcePacket)
|
||||||
|
manager.onPublicPacketSeen(messagePacket)
|
||||||
|
|
||||||
|
// Flush queue without triggering stale cleanup yet
|
||||||
|
manager._performMaintenanceSynchronously(now: Date())
|
||||||
|
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)))
|
||||||
|
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 1)
|
||||||
|
|
||||||
|
// Run cleanup past the timeout
|
||||||
|
let future = Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1)
|
||||||
|
manager._performMaintenanceSynchronously(now: future)
|
||||||
|
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
||||||
|
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func ignoresAnnounceOlderThanStaleTimeout() throws {
|
||||||
|
var config = GossipSyncManager.Config()
|
||||||
|
config.stalePeerTimeoutSeconds = 5
|
||||||
|
config.maxMessageAgeSeconds = 100
|
||||||
|
|
||||||
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||||
|
let peerHex = "8899aabbccddeeff"
|
||||||
|
let senderData = try #require(Data(hexString: peerHex))
|
||||||
|
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
|
||||||
|
|
||||||
|
let freshMessage = BitchatPacket(
|
||||||
|
type: MessageType.message.rawValue,
|
||||||
|
senderID: senderData,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: Data([0xAA]),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
manager.onPublicPacketSeen(freshMessage)
|
||||||
|
|
||||||
|
let announcePacket = BitchatPacket(
|
||||||
|
type: MessageType.announce.rawValue,
|
||||||
|
senderID: senderData,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: staleTimestampMs,
|
||||||
|
payload: Data(),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.onPublicPacketSeen(announcePacket)
|
||||||
|
|
||||||
|
manager._performMaintenanceSynchronously()
|
||||||
|
|
||||||
|
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
||||||
|
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,52 +6,31 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import Testing
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class IntegrationTests: XCTestCase {
|
struct IntegrationTests {
|
||||||
|
|
||||||
var nodes: [String: MockBluetoothMeshService] = [:]
|
private var helper = TestNetworkHelper()
|
||||||
var noiseManagers: [String: NoiseSessionManager] = [:]
|
|
||||||
private var mockKeychain: MockKeychain!
|
|
||||||
|
|
||||||
override func setUp() {
|
init() {
|
||||||
super.setUp()
|
helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString))
|
||||||
// Use the in-memory test bus with autoFlood enabled to simulate
|
helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString))
|
||||||
// broadcast propagation across a larger mesh. Integration-only.
|
helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString))
|
||||||
MockBLEService.resetTestBus()
|
helper.createNode("David", peerID: PeerID(str: UUID().uuidString))
|
||||||
MockBLEService.autoFloodEnabled = true
|
|
||||||
mockKeychain = MockKeychain()
|
|
||||||
|
|
||||||
// Create a network of nodes
|
|
||||||
createNode("Alice", peerID: TestConstants.testPeerID1)
|
|
||||||
createNode("Bob", peerID: TestConstants.testPeerID2)
|
|
||||||
createNode("Charlie", peerID: TestConstants.testPeerID3)
|
|
||||||
createNode("David", peerID: TestConstants.testPeerID4)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
// Disable flooding to avoid cross-test interference
|
|
||||||
MockBLEService.autoFloodEnabled = false
|
|
||||||
nodes.removeAll()
|
|
||||||
noiseManagers.removeAll()
|
|
||||||
mockKeychain = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Multi-Peer Scenarios
|
// MARK: - Multi-Peer Scenarios
|
||||||
|
|
||||||
func testFullMeshCommunication() {
|
@Test func fullMeshCommunication() async throws {
|
||||||
// Create full mesh - everyone connected to everyone
|
helper.connectFullMesh()
|
||||||
connectFullMesh()
|
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "All nodes communicate")
|
|
||||||
var messageMatrix: [String: Set<String>] = [:]
|
var messageMatrix: [String: Set<String>] = [:]
|
||||||
|
for (senderName, _) in helper.nodes { messageMatrix[senderName] = [] }
|
||||||
|
|
||||||
// Track all receivers; parse sender name from message content "Hello from <Name>"
|
for (receiverName, receiver) in helper.nodes {
|
||||||
for (senderName, _) in nodes { messageMatrix[senderName] = [] }
|
|
||||||
for (receiverName, receiver) in nodes {
|
|
||||||
receiver.messageDeliveryHandler = { message in
|
receiver.messageDeliveryHandler = { message in
|
||||||
let parts = message.content.components(separatedBy: " ")
|
let parts = message.content.components(separatedBy: " ")
|
||||||
if let last = parts.last, message.content.contains("Hello from") {
|
if let last = parts.last, message.content.contains("Hello from") {
|
||||||
@@ -62,370 +41,336 @@ final class IntegrationTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each node sends a message
|
for (name, node) in helper.nodes {
|
||||||
for (name, node) in nodes {
|
node.sendMessage("Hello from \(name)")
|
||||||
node.sendMessage("Hello from \(name)", mentions: [], to: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait and verify
|
// Each sender should have reached all other nodes
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
for (sender, receivers) in messageMatrix {
|
||||||
// Each sender should have reached all other nodes
|
let expectedReceivers = Set(helper.nodes.keys.filter { $0 != sender })
|
||||||
for (sender, receivers) in messageMatrix {
|
#expect(receivers == expectedReceivers, "\(sender) didn't reach all nodes")
|
||||||
let expectedReceivers = Set(self.nodes.keys.filter { $0 != sender })
|
|
||||||
XCTAssertEqual(receivers, expectedReceivers, "\(sender) didn't reach all nodes")
|
|
||||||
}
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDynamicTopologyChanges() {
|
@Test func dynamicTopologyChanges() async throws {
|
||||||
// Start with Alice -> Bob -> Charlie
|
// Start with Alice -> Bob -> Charlie
|
||||||
connect("Alice", "Bob")
|
helper.connect("Alice", "Bob")
|
||||||
connect("Bob", "Charlie")
|
helper.connect("Bob", "Charlie")
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Topology changes handled")
|
try await confirmation("Topology changes handled") { receiveMessage in
|
||||||
var phase = 1
|
var phase = 1
|
||||||
|
|
||||||
// Phase 1: Test initial topology
|
helper.nodes["Charlie"]!.messageDeliveryHandler = { message in
|
||||||
nodes["Charlie"]!.messageDeliveryHandler = { message in
|
if phase == 1 && message.sender == "Alice" {
|
||||||
if phase == 1 && message.sender == "Alice" {
|
// Now change topology: disconnect Bob, connect Alice-Charlie
|
||||||
// Now change topology: disconnect Bob, connect Alice-Charlie
|
helper.disconnect("Alice", "Bob")
|
||||||
self.disconnect("Alice", "Bob")
|
helper.disconnect("Bob", "Charlie")
|
||||||
self.disconnect("Bob", "Charlie")
|
helper.connect("Alice", "Charlie")
|
||||||
self.connect("Alice", "Charlie")
|
phase = 2
|
||||||
phase = 2
|
|
||||||
|
// Send another message
|
||||||
// Send another message
|
helper.nodes["Alice"]!.sendMessage("Direct message")
|
||||||
self.nodes["Alice"]!.sendMessage("Direct message", mentions: [], to: nil)
|
} else if phase == 2 && message.content == "Direct message" {
|
||||||
} else if phase == 2 && message.content == "Direct message" {
|
receiveMessage()
|
||||||
expectation.fulfill()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow relay handler to be set before first send
|
||||||
|
try await sleep(0.05)
|
||||||
|
helper.nodes["Alice"]!.sendMessage("Relayed message")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial message through relay
|
|
||||||
// Allow relay handler to be set before first send
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
||||||
self.nodes["Alice"]!.sendMessage("Relayed message", mentions: [], to: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNetworkPartitionRecovery() {
|
@Test func networkPartitionRecovery() async throws {
|
||||||
// Create two partitions
|
// Create two partitions
|
||||||
connect("Alice", "Bob")
|
helper.connect("Alice", "Bob")
|
||||||
connect("Charlie", "David")
|
helper.connect("Charlie", "David")
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Partitions merge and communicate")
|
|
||||||
let messagesBeforeMerge = 0
|
let messagesBeforeMerge = 0
|
||||||
var messagesAfterMerge = 0
|
var messagesAfterMerge = 0
|
||||||
|
|
||||||
// Monitor cross-partition messages
|
try await confirmation("Partitions merge and communicate") { receiveMessage in
|
||||||
nodes["David"]!.messageDeliveryHandler = { message in
|
// Monitor cross-partition messages
|
||||||
if message.sender == "Alice" {
|
helper.nodes["David"]!.messageDeliveryHandler = { message in
|
||||||
messagesAfterMerge += 1
|
if message.sender == "Alice" {
|
||||||
if messagesAfterMerge == 1 {
|
messagesAfterMerge += 1
|
||||||
expectation.fulfill()
|
if messagesAfterMerge == 1 {
|
||||||
|
receiveMessage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Try to send across partition (should fail)
|
||||||
// Try to send across partition (should fail)
|
helper.nodes["Alice"]!.sendMessage("Before merge")
|
||||||
nodes["Alice"]!.sendMessage("Before merge", mentions: [], to: nil)
|
|
||||||
|
// Merge partitions after delay
|
||||||
// Merge partitions after delay
|
try await sleep(0.05)
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
// Connect partitions
|
// Connect partitions
|
||||||
self.connect("Bob", "Charlie")
|
helper.connect("Bob", "Charlie")
|
||||||
|
|
||||||
// Enable relay
|
// Enable relay
|
||||||
self.setupRelay("Bob", nextHops: ["Charlie"])
|
helper.setupRelay("Bob", nextHops: ["Charlie"])
|
||||||
self.setupRelay("Charlie", nextHops: ["David"])
|
helper.setupRelay("Charlie", nextHops: ["David"])
|
||||||
|
|
||||||
// Send message across merged network
|
// Send message across merged network
|
||||||
self.nodes["Alice"]!.sendMessage("After merge", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("After merge")
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
#expect(messagesBeforeMerge == 0)
|
||||||
XCTAssertEqual(messagesBeforeMerge, 0)
|
#expect(messagesAfterMerge == 1)
|
||||||
XCTAssertEqual(messagesAfterMerge, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mixed Message Type Scenarios
|
// MARK: - Mixed Message Type Scenarios
|
||||||
|
|
||||||
func testMixedPublicPrivateMessages() throws {
|
@Test func mixedPublicPrivateMessages() async throws {
|
||||||
connectFullMesh()
|
helper.connectFullMesh()
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Mixed messages handled correctly")
|
|
||||||
var publicCount = 0
|
var publicCount = 0
|
||||||
var privateCount = 0
|
var privateCount = 0
|
||||||
|
|
||||||
// Bob monitors messages
|
await confirmation("Mixed messages handled correctly") { completion in
|
||||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
// Bob monitors messages
|
||||||
if message.isPrivate && message.recipientNickname == "Bob" {
|
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||||
privateCount += 1
|
if message.isPrivate && message.recipientNickname == "Bob" {
|
||||||
} else if !message.isPrivate {
|
privateCount += 1
|
||||||
publicCount += 1
|
} else if !message.isPrivate {
|
||||||
|
publicCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if publicCount == 2 && privateCount == 1 {
|
||||||
|
completion()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if publicCount == 2 && privateCount == 1 {
|
// Alice sends mixed messages
|
||||||
expectation.fulfill()
|
helper.nodes["Alice"]!.sendMessage("Public 1")
|
||||||
}
|
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||||
|
helper.nodes["Alice"]!.sendMessage("Public 2")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alice sends mixed messages
|
#expect(publicCount == 2)
|
||||||
nodes["Alice"]!.sendMessage("Public 1", mentions: [], to: nil)
|
#expect(privateCount == 1)
|
||||||
nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
|
||||||
nodes["Alice"]!.sendMessage("Public 2", mentions: [], to: nil)
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
XCTAssertEqual(publicCount, 2)
|
|
||||||
XCTAssertEqual(privateCount, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncryptedAndUnencryptedMix() throws {
|
@Test func encryptedAndUnencryptedMix() async throws {
|
||||||
connect("Alice", "Bob")
|
helper.connect("Alice", "Bob")
|
||||||
|
|
||||||
// Setup Noise session
|
// Setup Noise session
|
||||||
try establishNoiseSession("Alice", "Bob")
|
try helper.establishNoiseSession("Alice", "Bob")
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Both encrypted and plain messages work")
|
|
||||||
var plainCount = 0
|
var plainCount = 0
|
||||||
var encryptedCount = 0
|
var encryptedCount = 0
|
||||||
|
|
||||||
// Setup handlers
|
try await confirmation("Both encrypted and plain messages work") { completion in
|
||||||
// Plain path: send public message and count at Bob
|
// Plain path: send public message and count at Bob
|
||||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||||
if message.content == "Plain message" { plainCount += 1 }
|
if message.content == "Plain message" {
|
||||||
if plainCount == 1 && encryptedCount == 1 { expectation.fulfill() }
|
plainCount += 1
|
||||||
}
|
}
|
||||||
|
if plainCount == 1 && encryptedCount == 1 {
|
||||||
// Encrypted path: use NoiseSessionManager explicitly
|
completion()
|
||||||
let plaintext = "Encrypted message".data(using: .utf8)!
|
|
||||||
let ciphertext = try noiseManagers["Alice"]!.encrypt(plaintext, for: TestConstants.testPeerID2)
|
|
||||||
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
|
||||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
|
||||||
if let data = try? self.noiseManagers["Bob"]!.decrypt(ciphertext, from: TestConstants.testPeerID1),
|
|
||||||
data == plaintext {
|
|
||||||
encryptedCount = 1
|
|
||||||
if plainCount == 1 { expectation.fulfill() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encrypted path: use NoiseSessionManager explicitly
|
||||||
|
let plaintext = "Encrypted message".data(using: .utf8)!
|
||||||
|
let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
|
||||||
|
|
||||||
|
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||||
|
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||||
|
if let data = try? helper.noiseManagers["Bob"]!.decrypt(ciphertext, from: helper.nodes["Alice"]!.peerID),
|
||||||
|
data == plaintext {
|
||||||
|
encryptedCount = 1
|
||||||
|
if plainCount == 1 {
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
helper.nodes["Alice"]!.sendMessage("Plain message")
|
||||||
|
// Deliver encrypted packet directly
|
||||||
|
let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||||
|
helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes["Alice"]!.sendMessage("Plain message", mentions: [], to: nil)
|
|
||||||
// Deliver encrypted packet directly
|
|
||||||
let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
|
||||||
nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Network Resilience Tests
|
// MARK: - Network Resilience Tests
|
||||||
|
|
||||||
func testMessageDeliveryUnderChurn() {
|
@Test func messageDeliveryUnderChurn() async throws {
|
||||||
// Start with stable network
|
// Start with stable network
|
||||||
connectFullMesh()
|
helper.connectFullMesh()
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Messages delivered despite churn")
|
|
||||||
var receivedMessages = Set<String>()
|
|
||||||
let totalMessages = 10
|
let totalMessages = 10
|
||||||
|
|
||||||
// David tracks received messages
|
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
|
||||||
nodes["David"]!.messageDeliveryHandler = { message in
|
// David tracks received messages
|
||||||
receivedMessages.insert(message.content)
|
helper.nodes["David"]!.messageDeliveryHandler = { message in
|
||||||
if receivedMessages.count == totalMessages {
|
completion()
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Send messages while churning network
|
|
||||||
for i in 0..<totalMessages {
|
|
||||||
nodes["Alice"]!.sendMessage("Message \(i)", mentions: [], to: nil)
|
|
||||||
|
|
||||||
// Simulate churn
|
// Send messages while churning network
|
||||||
if i % 3 == 0 {
|
for i in 0..<totalMessages {
|
||||||
// Disconnect and reconnect random connection
|
helper.nodes["Alice"]!.sendMessage("Message \(i)")
|
||||||
let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")]
|
|
||||||
let randomPair = pairs.randomElement()!
|
|
||||||
disconnect(randomPair.0, randomPair.1)
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
// Simulate churn
|
||||||
self.connect(randomPair.0, randomPair.1)
|
if i % 3 == 0 {
|
||||||
|
// Disconnect and reconnect random connection
|
||||||
|
let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")]
|
||||||
|
let randomPair = pairs.randomElement()!
|
||||||
|
helper.disconnect(randomPair.0, randomPair.1)
|
||||||
|
try await sleep(0.01)
|
||||||
|
helper.connect(randomPair.0, randomPair.1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func peerPresenceTrackingAndReconnection() async throws {
|
||||||
|
helper.connect("Alice", "Bob")
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
await confirmation("Delivery after reconnection") { delivered in
|
||||||
XCTAssertEqual(receivedMessages.count, totalMessages)
|
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||||
}
|
if message.content == "After reconnect" {
|
||||||
|
delivered()
|
||||||
func testPeerPresenceTrackingAndReconnection() {
|
}
|
||||||
// Test that after disconnect/reconnect, message delivery resumes
|
|
||||||
connect("Alice", "Bob")
|
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Delivery after reconnection")
|
|
||||||
var delivered = false
|
|
||||||
|
|
||||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
|
||||||
if message.content == "After reconnect" && !delivered {
|
|
||||||
delivered = true
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simulate disconnect (out of range)
|
||||||
|
helper.disconnect("Alice", "Bob")
|
||||||
|
// Reconnect
|
||||||
|
helper.connect("Alice", "Bob")
|
||||||
|
|
||||||
|
// Send after reconnection
|
||||||
|
helper.nodes["Alice"]!.sendMessage("After reconnect")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate disconnect (out of range)
|
|
||||||
disconnect("Alice", "Bob")
|
|
||||||
// Reconnect
|
|
||||||
connect("Alice", "Bob")
|
|
||||||
|
|
||||||
// Send after reconnection
|
|
||||||
nodes["Alice"]!.sendMessage("After reconnect", mentions: [], to: nil)
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
XCTAssertTrue(delivered)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncryptedMessageAfterPeerRestart() {
|
@Test func encryptedMessageAfterPeerRestart() async throws {
|
||||||
// Test that encrypted messages work after one peer restarts
|
helper.connect("Alice", "Bob")
|
||||||
connect("Alice", "Bob")
|
|
||||||
do {
|
do {
|
||||||
try establishNoiseSession("Alice", "Bob")
|
try helper.establishNoiseSession("Alice", "Bob")
|
||||||
} catch {
|
} catch {
|
||||||
XCTFail("Failed to establish Noise session: \(error)")
|
Issue.record("Failed to establish Noise session: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange an encrypted message
|
// Exchange an encrypted message
|
||||||
let firstExpectation = XCTestExpectation(description: "First message received")
|
await confirmation("First message received") { received in
|
||||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||||
if message.content == "Before restart" && message.isPrivate {
|
if message.content == "Before restart" && message.isPrivate {
|
||||||
firstExpectation.fulfill()
|
received()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
helper.nodes["Alice"]!.sendPrivateMessage("Before restart", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes["Alice"]!.sendPrivateMessage("Before restart", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
|
||||||
wait(for: [firstExpectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
|
|
||||||
// Simulate Bob restart by recreating his Noise manager
|
// Simulate Bob restart by recreating his Noise manager
|
||||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain)
|
||||||
|
|
||||||
// Re-establish Noise handshake explicitly via managers
|
// Re-establish Noise handshake explicitly via managers
|
||||||
do {
|
do {
|
||||||
let m1 = try noiseManagers["Bob"]!.initiateHandshake(with: TestConstants.testPeerID1)
|
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
|
||||||
let m2 = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m1)!
|
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
|
||||||
let m3 = try noiseManagers["Bob"]!.handleIncomingHandshake(from: TestConstants.testPeerID1, message: m2)!
|
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
|
||||||
_ = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m3)
|
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
|
||||||
} catch {
|
} catch {
|
||||||
XCTFail("Failed to re-establish Noise session after restart: \(error)")
|
Issue.record("Failed to re-establish Noise session after restart: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now messages should work again
|
// Now messages should work again - simulate encrypted packet
|
||||||
let secondExpectation = XCTestExpectation(description: "Message after restart received")
|
await confirmation("Message after restart received") { received in
|
||||||
nodes["Alice"]!.messageDeliveryHandler = { message in
|
helper.nodes["Alice"]!.messageDeliveryHandler = { message in
|
||||||
if message.content == "After restart success" && message.isPrivate {
|
if message.content == "After restart success" && message.isPrivate {
|
||||||
secondExpectation.fulfill()
|
received()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate encrypted message using managers
|
|
||||||
do {
|
|
||||||
let plaintext = "After restart success".data(using: .utf8)!
|
|
||||||
let ciphertext = try noiseManagers["Bob"]!.encrypt(plaintext, for: TestConstants.testPeerID1)
|
|
||||||
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
|
||||||
nodes["Alice"]!.packetDeliveryHandler = { pkt in
|
|
||||||
if pkt.type == MessageType.noiseEncrypted.rawValue {
|
|
||||||
if let data = try? self.noiseManagers["Alice"]!.decrypt(pkt.payload, from: TestConstants.testPeerID2),
|
|
||||||
String(data: data, encoding: .utf8) == "After restart success" {
|
|
||||||
secondExpectation.fulfill()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
nodes["Alice"]!.simulateIncomingPacket(packet)
|
|
||||||
} catch {
|
do {
|
||||||
XCTFail("Encryption after restart failed: \(error)")
|
let plaintext = "After restart success".data(using: .utf8)!
|
||||||
|
let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
|
||||||
|
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||||
|
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
|
||||||
|
if pkt.type == MessageType.noiseEncrypted.rawValue {
|
||||||
|
if let data = try? helper.noiseManagers["Alice"]!.decrypt(pkt.payload, from: helper.nodes["Bob"]!.peerID),
|
||||||
|
String(data: data, encoding: .utf8) == "After restart success" {
|
||||||
|
received()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
helper.nodes["Alice"]!.simulateIncomingPacket(packet)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Encryption after restart failed: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
wait(for: [secondExpectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLargeScaleNetwork() {
|
@Test func largeScaleNetwork() async throws {
|
||||||
// Create larger network
|
// Create larger network
|
||||||
for i in 5...10 {
|
for i in 5...10 {
|
||||||
createNode("Node\(i)", peerID: "PEER\(i)")
|
helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(i)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect in ring topology with cross-connections
|
// Connect in ring topology with cross-connections
|
||||||
let allNodes = Array(nodes.keys).sorted()
|
let allNodes = Array(helper.nodes.keys).sorted()
|
||||||
for i in 0..<allNodes.count {
|
for i in 0..<allNodes.count {
|
||||||
// Ring connection
|
// Ring connection
|
||||||
connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
|
helper.connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
|
||||||
|
|
||||||
// Cross connection
|
// Cross connection
|
||||||
if i + 3 < allNodes.count {
|
if i + 3 < allNodes.count {
|
||||||
connect(allNodes[i], allNodes[i + 3])
|
helper.connect(allNodes[i], allNodes[i + 3])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Large network handles broadcast")
|
await confirmation("Large network handles broadcast", expectedCount: helper.nodes.count - 1) { nodeReaced in
|
||||||
var nodesReached = Set<String>()
|
// All nodes except Alice listen
|
||||||
|
for (name, node) in helper.nodes where name != "Alice" {
|
||||||
// All nodes except Alice listen
|
node.messageDeliveryHandler = { message in
|
||||||
for (name, node) in nodes where name != "Alice" {
|
if message.content == "Broadcast test" {
|
||||||
node.messageDeliveryHandler = { message in
|
nodeReaced()
|
||||||
if message.content == "Broadcast test" {
|
|
||||||
nodesReached.insert(name)
|
|
||||||
if nodesReached.count == self.nodes.count - 1 {
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alice broadcasts
|
||||||
|
helper.nodes["Alice"]!.sendMessage("Broadcast test")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alice broadcasts
|
|
||||||
nodes["Alice"]!.sendMessage("Broadcast test", mentions: [], to: nil)
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
|
||||||
XCTAssertEqual(nodesReached.count, nodes.count - 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Stress Tests
|
// MARK: - Stress Tests
|
||||||
|
|
||||||
func testHighLoadScenario() {
|
@Test func highLoadScenario() async throws {
|
||||||
connectFullMesh()
|
helper.connectFullMesh()
|
||||||
|
|
||||||
let messagesPerNode = 25
|
let messagesPerNode = 25
|
||||||
let expectedTotal = messagesPerNode * nodes.count * (nodes.count - 1)
|
let expectedTotal = messagesPerNode * helper.nodes.count * (helper.nodes.count - 1)
|
||||||
var receivedTotal = 0
|
|
||||||
let expectation = XCTestExpectation(description: "High load handled")
|
|
||||||
|
|
||||||
// Each node tracks messages
|
await confirmation("High load handled", expectedCount: expectedTotal) { received in
|
||||||
for (_, node) in nodes {
|
// Each node tracks messages
|
||||||
node.messageDeliveryHandler = { _ in
|
for (_, node) in helper.nodes {
|
||||||
receivedTotal += 1
|
node.messageDeliveryHandler = { _ in
|
||||||
if receivedTotal >= (expectedTotal - 2) {
|
received()
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// All nodes send many messages simultaneously
|
||||||
// All nodes send many messages simultaneously
|
await withTaskGroup(of: Void.self) { group in
|
||||||
DispatchQueue.concurrentPerform(iterations: nodes.count) { index in
|
for (name, node) in helper.nodes {
|
||||||
let nodeName = Array(nodes.keys).sorted()[index]
|
group.addTask {
|
||||||
for i in 0..<messagesPerNode {
|
for i in 0..<messagesPerNode {
|
||||||
nodes[nodeName]!.sendMessage("\(nodeName) message \(i)", mentions: [], to: nil)
|
node.sendMessage("\(name) message \(i)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await group.waitForAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
|
||||||
XCTAssertGreaterThanOrEqual(receivedTotal, expectedTotal - 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMixedTrafficPatterns() {
|
@Test func mixedTrafficPatterns() async throws {
|
||||||
connectFullMesh()
|
helper.connectFullMesh()
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Mixed traffic handled")
|
|
||||||
var metrics = [
|
var metrics = [
|
||||||
"public": 0,
|
"public": 0,
|
||||||
"private": 0,
|
"private": 0,
|
||||||
@@ -434,7 +379,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
]
|
]
|
||||||
|
|
||||||
// Setup complex handlers
|
// Setup complex handlers
|
||||||
for (name, node) in nodes {
|
for (name, node) in helper.nodes {
|
||||||
node.messageDeliveryHandler = { message in
|
node.messageDeliveryHandler = { message in
|
||||||
if message.isPrivate {
|
if message.isPrivate {
|
||||||
metrics["private"]! += 1
|
metrics["private"]! += 1
|
||||||
@@ -453,222 +398,119 @@ final class IntegrationTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate mixed traffic
|
// Generate mixed traffic
|
||||||
nodes["Alice"]!.sendMessage("Public broadcast", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("Public broadcast")
|
||||||
nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||||
nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"], to: nil)
|
helper.nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"])
|
||||||
|
|
||||||
// Disconnect to force relay
|
// Disconnect to force relay
|
||||||
disconnect("Alice", "David")
|
helper.disconnect("Alice", "David")
|
||||||
nodes["Alice"]!.sendMessage("Needs relay to David", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("Needs relay to David")
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
#expect(metrics["public", default: 0] > 0)
|
||||||
XCTAssertGreaterThan(metrics["public"]!, 0)
|
#expect(metrics["private", default: 0] > 0)
|
||||||
XCTAssertGreaterThan(metrics["private"]!, 0)
|
#expect(metrics["mentions", default: 0] > 0)
|
||||||
XCTAssertGreaterThan(metrics["mentions"]!, 0)
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Security Integration Tests
|
// MARK: - Security Integration Tests
|
||||||
// Replacement for the legacy NACK test: verifies that after a
|
// Replacement for the legacy NACK test: verifies that after a
|
||||||
// decryption failure, peers can rehandshake via NoiseSessionManager
|
// decryption failure, peers can rehandshake via NoiseSessionManager
|
||||||
// and resume secure communication.
|
// and resume secure communication.
|
||||||
func testRehandshakeAfterDecryptionFailure() throws {
|
@Test func rehandshakeAfterDecryptionFailure() throws {
|
||||||
// Alice <-> Bob connected
|
// Alice <-> Bob connected
|
||||||
connect("Alice", "Bob")
|
helper.connect("Alice", "Bob")
|
||||||
|
|
||||||
// Establish initial Noise session
|
// Establish initial Noise session
|
||||||
try establishNoiseSession("Alice", "Bob")
|
try helper.establishNoiseSession("Alice", "Bob")
|
||||||
|
|
||||||
guard let aliceManager = noiseManagers["Alice"],
|
guard let aliceManager = helper.noiseManagers["Alice"],
|
||||||
let bobManager = noiseManagers["Bob"],
|
let bobManager = helper.noiseManagers["Bob"],
|
||||||
let alicePeerID = nodes["Alice"]?.peerID,
|
let alicePeerID = helper.nodes["Alice"]?.peerID,
|
||||||
let bobPeerID = nodes["Bob"]?.peerID else {
|
let bobPeerID = helper.nodes["Bob"]?.peerID
|
||||||
return XCTFail("Missing managers or peer IDs")
|
else {
|
||||||
|
Issue.record("Missing managers or peer IDs")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Baseline: encrypt from Alice, decrypt at Bob
|
// Baseline: encrypt from Alice, decrypt at Bob
|
||||||
let plaintext1 = Data("hello-secure".utf8)
|
let plaintext1 = Data("hello-secure".utf8)
|
||||||
let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID)
|
let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID)
|
||||||
let decrypted1 = try bobManager.decrypt(encrypted1, from: alicePeerID)
|
let decrypted1 = try bobManager.decrypt(encrypted1, from: alicePeerID)
|
||||||
XCTAssertEqual(decrypted1, plaintext1)
|
#expect(decrypted1 == plaintext1)
|
||||||
|
|
||||||
// Simulate decryption failure by corrupting ciphertext
|
// Simulate decryption failure by corrupting ciphertext
|
||||||
var corrupted = encrypted1
|
let corrupted = encrypted1.prefix(15)
|
||||||
if !corrupted.isEmpty { corrupted[corrupted.count - 1] ^= 0xFF }
|
#expect(throws: NoiseError.invalidCiphertext) {
|
||||||
do {
|
|
||||||
_ = try bobManager.decrypt(corrupted, from: alicePeerID)
|
_ = try bobManager.decrypt(corrupted, from: alicePeerID)
|
||||||
XCTFail("Corrupted ciphertext should not decrypt")
|
|
||||||
} catch {
|
|
||||||
// Expected: treat as session desync and rehandshake
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bob initiates a new handshake; clear Bob's session first so initiateHandshake won't throw
|
// Bob initiates a new handshake; clear Bob's session first so initiateHandshake won't throw
|
||||||
bobManager.removeSession(for: alicePeerID)
|
bobManager.removeSession(for: alicePeerID)
|
||||||
try establishNoiseSession("Bob", "Alice")
|
try helper.establishNoiseSession("Bob", "Alice")
|
||||||
|
|
||||||
// After rehandshake, encryption/decryption works again
|
// After rehandshake, encryption/decryption works again
|
||||||
let plaintext2 = Data("hello-again".utf8)
|
let plaintext2 = Data("hello-again".utf8)
|
||||||
let encrypted2 = try aliceManager.encrypt(plaintext2, for: bobPeerID)
|
let encrypted2 = try aliceManager.encrypt(plaintext2, for: bobPeerID)
|
||||||
let decrypted2 = try bobManager.decrypt(encrypted2, from: alicePeerID)
|
let decrypted2 = try bobManager.decrypt(encrypted2, from: alicePeerID)
|
||||||
XCTAssertEqual(decrypted2, plaintext2)
|
#expect(decrypted2 == plaintext2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func testEndToEndSecurityScenario() throws {
|
@Test func endToEndSecurityScenario() async throws {
|
||||||
connect("Alice", "Bob")
|
helper.connect("Alice", "Bob")
|
||||||
connect("Bob", "Charlie") // Charlie will try to eavesdrop
|
helper.connect("Bob", "Charlie") // Charlie will try to eavesdrop
|
||||||
|
|
||||||
// Establish secure session between Alice and Bob only
|
// Establish secure session between Alice and Bob only
|
||||||
try establishNoiseSession("Alice", "Bob")
|
try helper.establishNoiseSession("Alice", "Bob")
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Secure communication maintained")
|
await confirmation("Secure communication maintained", expectedCount: 2) { receivedPacket in
|
||||||
var bobDecrypted = false
|
|
||||||
var charlieIntercepted = false
|
|
||||||
|
|
||||||
// Setup encryption at Alice
|
|
||||||
nodes["Alice"]!.packetDeliveryHandler = { packet in
|
|
||||||
if packet.type == 0x01,
|
|
||||||
let message = BitchatMessage(packet.payload),
|
|
||||||
message.isPrivate && packet.recipientID != nil {
|
|
||||||
// Encrypt private messages
|
|
||||||
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) {
|
|
||||||
let encPacket = BitchatPacket(
|
|
||||||
type: 0x02,
|
|
||||||
senderID: packet.senderID,
|
|
||||||
recipientID: packet.recipientID,
|
|
||||||
timestamp: packet.timestamp,
|
|
||||||
payload: encrypted,
|
|
||||||
signature: packet.signature,
|
|
||||||
ttl: packet.ttl
|
|
||||||
)
|
|
||||||
self.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bob can decrypt
|
|
||||||
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
|
||||||
if packet.type == 0x02 {
|
|
||||||
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1),
|
|
||||||
let message = BitchatMessage(decrypted) {
|
|
||||||
bobDecrypted = message.content == "Secret message"
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Relay encrypted packet to Charlie
|
|
||||||
self.nodes["Charlie"]!.simulateIncomingPacket(packet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Charlie cannot decrypt
|
|
||||||
nodes["Charlie"]!.packetDeliveryHandler = { packet in
|
|
||||||
if packet.type == 0x02 {
|
|
||||||
charlieIntercepted = true
|
|
||||||
// Try to decrypt (should fail)
|
|
||||||
do {
|
|
||||||
_ = try self.noiseManagers["Charlie"]?.decrypt(packet.payload, from: TestConstants.testPeerID1)
|
|
||||||
XCTFail("Charlie should not be able to decrypt")
|
|
||||||
} catch {
|
|
||||||
// Expected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send encrypted private message
|
|
||||||
nodes["Alice"]!.sendPrivateMessage("Secret message", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
XCTAssertTrue(bobDecrypted)
|
|
||||||
XCTAssertTrue(charlieIntercepted)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
|
||||||
|
|
||||||
private func createNode(_ name: String, peerID: PeerID) {
|
|
||||||
let node = MockBluetoothMeshService()
|
|
||||||
node.myPeerID = peerID
|
|
||||||
node.mockNickname = name
|
|
||||||
nodes[name] = node
|
|
||||||
|
|
||||||
// Create Noise manager
|
|
||||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connect(_ node1: String, _ node2: String) {
|
|
||||||
guard let n1 = nodes[node1], let n2 = nodes[node2] else { return }
|
|
||||||
n1.simulateConnectedPeer(n2.peerID)
|
|
||||||
n2.simulateConnectedPeer(n1.peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func disconnect(_ node1: String, _ node2: String) {
|
|
||||||
guard let n1 = nodes[node1], let n2 = nodes[node2] else { return }
|
|
||||||
n1.simulateDisconnectedPeer(n2.peerID)
|
|
||||||
n2.simulateDisconnectedPeer(n1.peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connectFullMesh() {
|
|
||||||
let nodeNames = Array(nodes.keys)
|
|
||||||
for i in 0..<nodeNames.count {
|
|
||||||
for j in i+1..<nodeNames.count {
|
|
||||||
connect(nodeNames[i], nodeNames[j])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupRelay(_ nodeName: String, nextHops: [String]) {
|
|
||||||
guard let node = nodes[nodeName] else { return }
|
|
||||||
|
|
||||||
node.packetDeliveryHandler = { packet in
|
|
||||||
guard packet.ttl > 1 else { return }
|
|
||||||
|
|
||||||
if let message = BitchatMessage(packet.payload) {
|
// Setup encryption at Alice
|
||||||
guard message.senderPeerID != node.peerID else { return }
|
helper.nodes["Alice"]!.packetDeliveryHandler = { packet in
|
||||||
|
if packet.type == 0x01,
|
||||||
let relayMessage = BitchatMessage(
|
let message = BitchatMessage(packet.payload),
|
||||||
id: message.id,
|
message.isPrivate && packet.recipientID != nil {
|
||||||
sender: message.sender,
|
// Encrypt private messages
|
||||||
content: message.content,
|
if let encrypted = try? helper.noiseManagers["Alice"]!.encrypt(packet.payload, for: helper.nodes["Bob"]!.peerID) {
|
||||||
timestamp: message.timestamp,
|
let encPacket = BitchatPacket(
|
||||||
isRelay: true,
|
type: 0x02,
|
||||||
originalSender: message.isRelay ? message.originalSender : message.sender,
|
senderID: packet.senderID,
|
||||||
isPrivate: message.isPrivate,
|
recipientID: packet.recipientID,
|
||||||
recipientNickname: message.recipientNickname,
|
timestamp: packet.timestamp,
|
||||||
senderPeerID: message.senderPeerID,
|
payload: encrypted,
|
||||||
mentions: message.mentions
|
signature: packet.signature,
|
||||||
)
|
ttl: packet.ttl
|
||||||
|
)
|
||||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
||||||
let relayPacket = BitchatPacket(
|
|
||||||
type: packet.type,
|
|
||||||
senderID: packet.senderID,
|
|
||||||
recipientID: packet.recipientID,
|
|
||||||
timestamp: packet.timestamp,
|
|
||||||
payload: relayPayload,
|
|
||||||
signature: packet.signature,
|
|
||||||
ttl: packet.ttl - 1
|
|
||||||
)
|
|
||||||
|
|
||||||
for hop in nextHops {
|
|
||||||
self.nodes[hop]?.simulateIncomingPacket(relayPacket)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bob can decrypt
|
||||||
|
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||||
|
if packet.type == 0x02 {
|
||||||
|
receivedPacket()
|
||||||
|
if let decrypted = try? helper.noiseManagers["Bob"]!.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID) {
|
||||||
|
#expect(BitchatMessage(decrypted)?.content == "Secret message")
|
||||||
|
} else {
|
||||||
|
Issue.record("Bob was unable to decrypt the message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relay encrypted packet to Charlie
|
||||||
|
helper.nodes["Charlie"]!.simulateIncomingPacket(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charlie cannot decrypt
|
||||||
|
helper.nodes["Charlie"]!.packetDeliveryHandler = { packet in
|
||||||
|
if packet.type == 0x02 {
|
||||||
|
receivedPacket()
|
||||||
|
#expect(throws: NoiseSessionError.sessionNotFound, "Charlie should not be able to decrypt") {
|
||||||
|
_ = try helper.noiseManagers["Charlie"]?.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send encrypted private message
|
||||||
|
helper.nodes["Alice"]!.sendPrivateMessage("Secret message", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func establishNoiseSession(_ node1: String, _ node2: String) throws {
|
|
||||||
guard let manager1 = noiseManagers[node1],
|
|
||||||
let manager2 = noiseManagers[node2],
|
|
||||||
let peer1ID = nodes[node1]?.peerID,
|
|
||||||
let peer2ID = nodes[node2]?.peerID else { return }
|
|
||||||
|
|
||||||
let msg1 = try manager1.initiateHandshake(with: peer2ID)
|
|
||||||
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
|
|
||||||
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
|
|
||||||
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//
|
||||||
|
// TestNetworkHelper.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// Extracted shared, mutable integration state for nodes and noise sessions.
|
||||||
|
// Keeps test containers nonmutating (Swift Testing-friendly).
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class TestNetworkHelper {
|
||||||
|
// Public, read-only views for tests; mutation only through methods
|
||||||
|
var nodes: [String: MockBLEService] = [:]
|
||||||
|
var noiseManagers: [String: NoiseSessionManager] = [:]
|
||||||
|
let mockKeychain = MockKeychain()
|
||||||
|
private let bus = MockBLEBus(autoFloodEnabled: true)
|
||||||
|
|
||||||
|
// MARK: - Node/Manager management
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func createNode(_ name: String, peerID: PeerID) -> MockBLEService {
|
||||||
|
let node = MockBLEService(bus: bus)
|
||||||
|
node.myPeerID = peerID
|
||||||
|
node.mockNickname = name
|
||||||
|
nodes[name] = node
|
||||||
|
|
||||||
|
// Create/replace Noise manager for this node
|
||||||
|
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||||
|
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNode(_ name: String) -> MockBLEService? {
|
||||||
|
nodes[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func getManager(_ name: String) -> NoiseSessionManager? {
|
||||||
|
noiseManagers[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Topology
|
||||||
|
|
||||||
|
func connect(_ a: String, _ b: String) {
|
||||||
|
guard let n1 = nodes[a], let n2 = nodes[b] else { return }
|
||||||
|
n1.simulateConnectedPeer(n2.peerID)
|
||||||
|
n2.simulateConnectedPeer(n1.peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect(_ a: String, _ b: String) {
|
||||||
|
guard let n1 = nodes[a], let n2 = nodes[b] else { return }
|
||||||
|
n1.simulateDisconnectedPeer(n2.peerID)
|
||||||
|
n2.simulateDisconnectedPeer(n1.peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectFullMesh() {
|
||||||
|
let names = Array(nodes.keys)
|
||||||
|
for i in 0..<names.count {
|
||||||
|
for j in (i+1)..<names.count {
|
||||||
|
connect(names[i], names[j])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Relay
|
||||||
|
|
||||||
|
func setupRelay(_ nodeName: String, nextHops: [String]) {
|
||||||
|
guard let node = nodes[nodeName] else { return }
|
||||||
|
node.packetDeliveryHandler = { [weak self] packet in
|
||||||
|
guard let self else { return }
|
||||||
|
guard packet.ttl > 1 else { return }
|
||||||
|
|
||||||
|
if let message = BitchatMessage(packet.payload) {
|
||||||
|
guard message.senderPeerID != node.peerID else { return }
|
||||||
|
|
||||||
|
let relayMessage = BitchatMessage(
|
||||||
|
id: message.id,
|
||||||
|
sender: message.sender,
|
||||||
|
content: message.content,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
isRelay: true,
|
||||||
|
originalSender: message.isRelay ? message.originalSender : message.sender,
|
||||||
|
isPrivate: message.isPrivate,
|
||||||
|
recipientNickname: message.recipientNickname,
|
||||||
|
senderPeerID: message.senderPeerID,
|
||||||
|
mentions: message.mentions
|
||||||
|
)
|
||||||
|
|
||||||
|
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||||
|
let relayPacket = BitchatPacket(
|
||||||
|
type: packet.type,
|
||||||
|
senderID: packet.senderID,
|
||||||
|
recipientID: packet.recipientID,
|
||||||
|
timestamp: packet.timestamp,
|
||||||
|
payload: relayPayload,
|
||||||
|
signature: packet.signature,
|
||||||
|
ttl: packet.ttl - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
for hop in nextHops {
|
||||||
|
self.nodes[hop]?.simulateIncomingPacket(relayPacket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise sessions
|
||||||
|
|
||||||
|
func establishNoiseSession(_ node1: String, _ node2: String) throws {
|
||||||
|
guard let manager1 = noiseManagers[node1],
|
||||||
|
let manager2 = noiseManagers[node2],
|
||||||
|
let peer1ID = nodes[node1]?.peerID,
|
||||||
|
let peer2ID = nodes[node2]?.peerID else { return }
|
||||||
|
|
||||||
|
let msg1 = try manager1.initiateHandshake(with: peer2ID)
|
||||||
|
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
|
||||||
|
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
|
||||||
|
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class LocationChannelsTests: XCTestCase {
|
struct LocationChannelsTests {
|
||||||
func testGeohashEncoderPrecisionMapping() {
|
@Test func geohashEncoderPrecisionMapping() {
|
||||||
// Sanity: known coords (Statue of Liberty approx)
|
// Sanity: known coords (Statue of Liberty approx)
|
||||||
let lat = 40.6892
|
let lat = 40.6892
|
||||||
let lon = -74.0445
|
let lon = -74.0445
|
||||||
@@ -12,34 +13,35 @@ final class LocationChannelsTests: XCTestCase {
|
|||||||
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision)
|
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision)
|
||||||
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
|
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
|
||||||
|
|
||||||
XCTAssertEqual(block.count, 7)
|
#expect(block.count == 7)
|
||||||
XCTAssertEqual(neighborhood.count, 6)
|
#expect(neighborhood.count == 6)
|
||||||
XCTAssertEqual(city.count, 5)
|
#expect(city.count == 5)
|
||||||
XCTAssertEqual(region.count, 4)
|
#expect(region.count == 4)
|
||||||
XCTAssertEqual(country.count, 2)
|
#expect(country.count == 2)
|
||||||
|
|
||||||
// All prefixes must match progressively
|
// All prefixes must match progressively
|
||||||
XCTAssertTrue(block.hasPrefix(neighborhood))
|
#expect(block.hasPrefix(neighborhood))
|
||||||
XCTAssertTrue(neighborhood.hasPrefix(city))
|
#expect(neighborhood.hasPrefix(city))
|
||||||
XCTAssertTrue(city.hasPrefix(region))
|
#expect(city.hasPrefix(region))
|
||||||
XCTAssertTrue(region.hasPrefix(country))
|
#expect(region.hasPrefix(country))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNostrGeohashFilterEncoding() throws {
|
@Test func nostrGeohashFilterEncoding() throws {
|
||||||
let gh = "u4pruy"
|
let gh = "u4pruy"
|
||||||
let filter = NostrFilter.geohashEphemeral(gh)
|
let filter = NostrFilter.geohashEphemeral(gh)
|
||||||
let data = try JSONEncoder().encode(filter)
|
let data = try JSONEncoder().encode(filter)
|
||||||
let json = String(data: data, encoding: .utf8) ?? ""
|
let json = String(data: data, encoding: .utf8) ?? ""
|
||||||
// Expect kinds includes 20000 and tag filter '#g':[gh]
|
// Expect kinds includes 20000 and tag filter '#g':[gh]
|
||||||
XCTAssertTrue(json.contains("20000"))
|
#expect(json.contains("20000"))
|
||||||
XCTAssertTrue(json.contains("\"#g\":[\"\(gh)\"]"))
|
#expect(json.contains("\"#g\":[\"\(gh)\"]"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPerGeohashIdentityDeterministic() throws {
|
@Test func perGeohashIdentityDeterministic() throws {
|
||||||
// Derive twice for same geohash; should be identical
|
// Derive twice for same geohash; should be identical
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||||
let gh = "u4pruy"
|
let gh = "u4pruy"
|
||||||
let id1 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
|
let id1 = try idBridge.deriveIdentity(forGeohash: gh)
|
||||||
let id2 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
|
let id2 = try idBridge.deriveIdentity(forGeohash: gh)
|
||||||
XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex)
|
#expect(id1.publicKeyHex == id2.publicKeyHex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationNotesManagerTests: XCTestCase {
|
struct LocationNotesManagerTests {
|
||||||
// func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
// func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
||||||
// var subscribeCalled = false
|
// var subscribeCalled = false
|
||||||
// let deps = LocationNotesDependencies(
|
// let deps = LocationNotesDependencies(
|
||||||
@@ -47,15 +48,15 @@ final class LocationNotesManagerTests: XCTestCase {
|
|||||||
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
||||||
// }
|
// }
|
||||||
|
|
||||||
func testSubscribeUsesGeoRelaysAndAppendsNotes() {
|
@Test func subscribeUsesGeoRelaysAndAppendsNotes() {
|
||||||
var relaysCaptured: [String] = []
|
var relaysCaptured: [String] = []
|
||||||
var storedHandler: ((NostrEvent) -> Void)?
|
var storedHandler: ((NostrEvent) -> Void)?
|
||||||
var storedEOSE: (() -> Void)?
|
var storedEOSE: (() -> Void)?
|
||||||
let deps = LocationNotesDependencies(
|
let deps = LocationNotesDependencies(
|
||||||
relayLookup: { _, _ in ["wss://relay.one"] },
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||||
subscribe: { filter, id, relays, handler, eose in
|
subscribe: { filter, id, relays, handler, eose in
|
||||||
XCTAssertEqual(filter.kinds, [1])
|
#expect(filter.kinds == [1])
|
||||||
XCTAssertFalse(id.isEmpty)
|
#expect(!id.isEmpty)
|
||||||
relaysCaptured = relays
|
relaysCaptured = relays
|
||||||
storedHandler = handler
|
storedHandler = handler
|
||||||
storedEOSE = eose
|
storedEOSE = eose
|
||||||
@@ -67,8 +68,8 @@ final class LocationNotesManagerTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||||
XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
|
#expect(relaysCaptured == ["wss://relay.one"])
|
||||||
XCTAssertEqual(manager.state, .loading)
|
#expect(manager.state == .loading)
|
||||||
|
|
||||||
var event = NostrEvent(
|
var event = NostrEvent(
|
||||||
pubkey: "pub",
|
pubkey: "pub",
|
||||||
@@ -81,9 +82,9 @@ final class LocationNotesManagerTests: XCTestCase {
|
|||||||
storedHandler?(event)
|
storedHandler?(event)
|
||||||
storedEOSE?()
|
storedEOSE?()
|
||||||
|
|
||||||
XCTAssertEqual(manager.state, .ready)
|
#expect(manager.state == .ready)
|
||||||
XCTAssertEqual(manager.notes.count, 1)
|
#expect(manager.notes.count == 1)
|
||||||
XCTAssertEqual(manager.notes.first?.content, "hi")
|
#expect(manager.notes.first?.content == "hi")
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum TestError: Error {
|
private enum TestError: Error {
|
||||||
@@ -92,8 +93,8 @@ final class LocationNotesManagerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationNotesCounterTests: XCTestCase {
|
struct LocationNotesCounterTests {
|
||||||
func testSubscribeWithoutRelaysMarksUnavailable() {
|
@Test func subscribeWithoutRelaysMarksUnavailable() {
|
||||||
var subscribeCalled = false
|
var subscribeCalled = false
|
||||||
let deps = LocationNotesCounterDependencies(
|
let deps = LocationNotesCounterDependencies(
|
||||||
relayLookup: { _, _ in [] },
|
relayLookup: { _, _ in [] },
|
||||||
@@ -104,21 +105,21 @@ final class LocationNotesCounterTests: XCTestCase {
|
|||||||
let counter = LocationNotesCounter(testDependencies: deps)
|
let counter = LocationNotesCounter(testDependencies: deps)
|
||||||
counter.subscribe(geohash: "u4pruydq")
|
counter.subscribe(geohash: "u4pruydq")
|
||||||
|
|
||||||
XCTAssertFalse(subscribeCalled)
|
#expect(!subscribeCalled)
|
||||||
XCTAssertFalse(counter.relayAvailable)
|
#expect(!counter.relayAvailable)
|
||||||
XCTAssertTrue(counter.initialLoadComplete)
|
#expect(counter.initialLoadComplete)
|
||||||
XCTAssertEqual(counter.count, 0)
|
#expect(counter.count == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSubscribeCountsUniqueNotes() {
|
@Test func subscribeCountsUniqueNotes() {
|
||||||
var storedHandler: ((NostrEvent) -> Void)?
|
var storedHandler: ((NostrEvent) -> Void)?
|
||||||
var storedEOSE: (() -> Void)?
|
var storedEOSE: (() -> Void)?
|
||||||
let deps = LocationNotesCounterDependencies(
|
let deps = LocationNotesCounterDependencies(
|
||||||
relayLookup: { _, _ in ["wss://relay.geo"] },
|
relayLookup: { _, _ in ["wss://relay.geo"] },
|
||||||
subscribe: { filter, id, relays, handler, eose in
|
subscribe: { filter, id, relays, handler, eose in
|
||||||
XCTAssertEqual(relays, ["wss://relay.geo"])
|
#expect(relays == ["wss://relay.geo"])
|
||||||
XCTAssertEqual(filter.kinds, [1])
|
#expect(filter.kinds == [1])
|
||||||
XCTAssertFalse(id.isEmpty)
|
#expect(!id.isEmpty)
|
||||||
storedHandler = handler
|
storedHandler = handler
|
||||||
storedEOSE = eose
|
storedEOSE = eose
|
||||||
},
|
},
|
||||||
@@ -143,8 +144,8 @@ final class LocationNotesCounterTests: XCTestCase {
|
|||||||
|
|
||||||
storedEOSE?()
|
storedEOSE?()
|
||||||
|
|
||||||
XCTAssertTrue(counter.relayAvailable)
|
#expect(counter.relayAvailable)
|
||||||
XCTAssertEqual(counter.count, 1)
|
#expect(counter.count == 1)
|
||||||
XCTAssertTrue(counter.initialLoadComplete)
|
#expect(counter.initialLoadComplete)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//
|
||||||
|
// MockBLEBus.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class MockBLEBus {
|
||||||
|
private var registry: [PeerID: MockBLEService] = [:]
|
||||||
|
private var adjacency: [PeerID: Set<PeerID>] = [:]
|
||||||
|
|
||||||
|
// Enable automatic flooding for public messages in integration tests only
|
||||||
|
let autoFloodEnabled: Bool
|
||||||
|
|
||||||
|
init(autoFloodEnabled: Bool = false) {
|
||||||
|
self.autoFloodEnabled = autoFloodEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(_ service: MockBLEService, for peerID: PeerID) {
|
||||||
|
registry[peerID] = service
|
||||||
|
if adjacency[peerID] == nil { adjacency[peerID] = [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect(_ a: PeerID, _ b: PeerID) {
|
||||||
|
var setA = adjacency[a] ?? []
|
||||||
|
setA.insert(b)
|
||||||
|
adjacency[a] = setA
|
||||||
|
var setB = adjacency[b] ?? []
|
||||||
|
setB.insert(a)
|
||||||
|
adjacency[b] = setB
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect(_ a: PeerID, _ b: PeerID) {
|
||||||
|
if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }
|
||||||
|
if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }
|
||||||
|
}
|
||||||
|
|
||||||
|
func neighbors(of peerID: PeerID) -> [MockBLEService] {
|
||||||
|
let ids = adjacency[peerID] ?? []
|
||||||
|
let result = ids.compactMap { registry[$0] }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDirectNeighbor(_ a: PeerID, _ b: PeerID) -> Bool {
|
||||||
|
let res = adjacency[a]?.contains(b) ?? false
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func service(for peerID: PeerID) -> MockBLEService? {
|
||||||
|
let svc = registry[peerID]
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,13 +26,12 @@ import CoreBluetooth
|
|||||||
/// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit
|
/// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit
|
||||||
/// relays when needed.
|
/// relays when needed.
|
||||||
final class MockBLEService: NSObject {
|
final class MockBLEService: NSObject {
|
||||||
// Enable automatic flooding for public messages in integration tests only
|
private let bus: MockBLEBus
|
||||||
static var autoFloodEnabled: Bool = false
|
|
||||||
|
|
||||||
// MARK: - Properties matching BLEService
|
// MARK: - Properties matching BLEService
|
||||||
|
|
||||||
weak var delegate: BitchatDelegate?
|
weak var delegate: BitchatDelegate?
|
||||||
var myPeerID: PeerID = "MOCK1234"
|
var myPeerID = PeerID(str: "MOCK1234")
|
||||||
var myNickname: String = "MockUser"
|
var myNickname: String = "MockUser"
|
||||||
|
|
||||||
private let mockKeychain = MockKeychain()
|
private let mockKeychain = MockKeychain()
|
||||||
@@ -60,8 +59,8 @@ final class MockBLEService: NSObject {
|
|||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
override init() {
|
init(bus: MockBLEBus) {
|
||||||
super.init()
|
self.bus = bus
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Methods matching BLEService
|
// MARK: - Methods matching BLEService
|
||||||
@@ -71,42 +70,15 @@ final class MockBLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - In-memory test bus (for E2E/Integration)
|
// MARK: - In-memory test bus (for E2E/Integration)
|
||||||
/// Global per-process bus for deterministic routing in tests.
|
|
||||||
private static var registry: [PeerID: MockBLEService] = [:]
|
|
||||||
private static var adjacency: [PeerID: Set<PeerID>] = [:]
|
|
||||||
|
|
||||||
/// Clears global bus state. Call from test `setUp()`.
|
|
||||||
static func resetTestBus() {
|
|
||||||
registry.removeAll()
|
|
||||||
adjacency.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers this instance on first use.
|
/// Registers this instance on first use.
|
||||||
private func registerIfNeeded() {
|
private func registerIfNeeded() {
|
||||||
MockBLEService.registry[myPeerID] = self
|
bus.register(self, for: myPeerID)
|
||||||
if MockBLEService.adjacency[myPeerID] == nil { MockBLEService.adjacency[myPeerID] = [] }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns adjacent neighbors based on the current simulated topology.
|
/// Returns adjacent neighbors based on the current simulated topology.
|
||||||
private func neighbors() -> [MockBLEService] {
|
private func neighbors() -> [MockBLEService] {
|
||||||
guard let ids = MockBLEService.adjacency[myPeerID] else { return [] }
|
bus.neighbors(of: myPeerID)
|
||||||
return ids.compactMap { MockBLEService.registry[$0] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds an undirected edge between two peerIDs.
|
|
||||||
private static func connectPeers(_ a: PeerID, _ b: PeerID) {
|
|
||||||
var setA = adjacency[a] ?? []
|
|
||||||
setA.insert(b)
|
|
||||||
adjacency[a] = setA
|
|
||||||
var setB = adjacency[b] ?? []
|
|
||||||
setB.insert(a)
|
|
||||||
adjacency[b] = setB
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes an undirected edge between two peerIDs.
|
|
||||||
private static func disconnectPeers(_ a: PeerID, _ b: PeerID) {
|
|
||||||
if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }
|
|
||||||
if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startServices() {
|
func startServices() {
|
||||||
@@ -173,7 +145,7 @@ final class MockBLEService: NSObject {
|
|||||||
// Surface raw packet to tests that intercept/relay/encrypt
|
// Surface raw packet to tests that intercept/relay/encrypt
|
||||||
packetDeliveryHandler?(packet)
|
packetDeliveryHandler?(packet)
|
||||||
|
|
||||||
// Deliver public messages to adjacent peers via test bus
|
// Deliver public messages to adjacent peers via bus
|
||||||
if recipientID == nil {
|
if recipientID == nil {
|
||||||
for neighbor in neighbors() {
|
for neighbor in neighbors() {
|
||||||
neighbor.simulateIncomingPacket(packet)
|
neighbor.simulateIncomingPacket(packet)
|
||||||
@@ -181,7 +153,15 @@ final class MockBLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||||
|
// Tests currently ignore file transfer flows; keep stub for protocol conformance.
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||||
|
// Tests currently ignore file transfer flows; keep stub for protocol conformance.
|
||||||
|
}
|
||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
@@ -219,20 +199,16 @@ final class MockBLEService: NSObject {
|
|||||||
packetDeliveryHandler?(packet)
|
packetDeliveryHandler?(packet)
|
||||||
|
|
||||||
// If directly connected to recipient, deliver only to them.
|
// If directly connected to recipient, deliver only to them.
|
||||||
if let neighbors = MockBLEService.adjacency[myPeerID], neighbors.contains(recipientPeerID),
|
if bus.isDirectNeighbor(myPeerID, recipientPeerID),
|
||||||
let target = MockBLEService.registry[recipientPeerID] {
|
let target = bus.service(for: recipientPeerID) {
|
||||||
target.simulateIncomingPacket(packet)
|
target.simulateIncomingPacket(packet)
|
||||||
} else {
|
} else {
|
||||||
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known
|
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known
|
||||||
if let target = MockBLEService.registry[recipientPeerID] {
|
if let target = bus.service(for: recipientPeerID) {
|
||||||
target.simulateIncomingPacket(packet)
|
target.simulateIncomingPacket(packet)
|
||||||
}
|
}
|
||||||
if let neighbors = MockBLEService.adjacency[myPeerID] {
|
for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
|
||||||
for peer in neighbors where peer != recipientPeerID {
|
neighbor.simulateIncomingPacket(packet)
|
||||||
if let neighbor = MockBLEService.registry[peer] {
|
|
||||||
neighbor.simulateIncomingPacket(packet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,14 +255,14 @@ final class MockBLEService: NSObject {
|
|||||||
|
|
||||||
func simulateConnectedPeer(_ peerID: PeerID) {
|
func simulateConnectedPeer(_ peerID: PeerID) {
|
||||||
registerIfNeeded()
|
registerIfNeeded()
|
||||||
MockBLEService.connectPeers(myPeerID, peerID)
|
bus.connect(myPeerID, peerID)
|
||||||
connectedPeers.insert(peerID)
|
connectedPeers.insert(peerID)
|
||||||
delegate?.didConnectToPeer(peerID)
|
delegate?.didConnectToPeer(peerID)
|
||||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||||
}
|
}
|
||||||
|
|
||||||
func simulateDisconnectedPeer(_ peerID: PeerID) {
|
func simulateDisconnectedPeer(_ peerID: PeerID) {
|
||||||
MockBLEService.disconnectPeers(myPeerID, peerID)
|
bus.disconnect(myPeerID, peerID)
|
||||||
connectedPeers.remove(peerID)
|
connectedPeers.remove(peerID)
|
||||||
delegate?.didDisconnectFromPeer(peerID)
|
delegate?.didDisconnectFromPeer(peerID)
|
||||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||||
@@ -319,7 +295,7 @@ final class MockBLEService: NSObject {
|
|||||||
// When enabled, propagate a public broadcast across the entire connected
|
// When enabled, propagate a public broadcast across the entire connected
|
||||||
// component regardless of the original TTL to better emulate large-network
|
// component regardless of the original TTL to better emulate large-network
|
||||||
// broadcast expectations. De-duplication via seenMessageIDs prevents loops.
|
// broadcast expectations. De-duplication via seenMessageIDs prevents loops.
|
||||||
if MockBLEService.autoFloodEnabled,
|
if bus.autoFloodEnabled,
|
||||||
packet.recipientID == nil,
|
packet.recipientID == nil,
|
||||||
!message.isPrivate {
|
!message.isPrivate {
|
||||||
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
|
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
|
||||||
@@ -353,8 +329,8 @@ typealias MockSimplifiedBluetoothService = MockBLEService
|
|||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
extension MockBLEService {
|
extension MockBLEService {
|
||||||
convenience init(peerID: PeerID, nickname: String) {
|
convenience init(peerID: PeerID, nickname: String, bus: MockBLEBus) {
|
||||||
self.init()
|
self.init(bus: bus)
|
||||||
myPeerID = peerID
|
myPeerID = peerID
|
||||||
mockNickname = nickname
|
mockNickname = nickname
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
//
|
|
||||||
// MockBluetoothMeshService.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreBluetooth
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// Compatibility wrapper for old tests - please use MockBLEService directly
|
|
||||||
typealias MockBluetoothMeshService = MockBLEService
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user