mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 21:05:20 +00:00
Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1207ac2af5 | ||
|
|
826c7537bf | ||
|
|
76dbc98e5b | ||
|
|
7f7ea05fbc | ||
|
|
c1430eaeb9 | ||
|
|
d76472999d | ||
|
|
f9a218d68b | ||
|
|
8eb4fb60e6 | ||
|
|
ca9748ede0 | ||
|
|
946abce1b2 | ||
|
|
326ff628f7 | ||
|
|
2040e94b83 | ||
|
|
8f62dd1776 | ||
|
|
7722009f11 | ||
|
|
da0474680c | ||
|
|
0a525be57a | ||
|
|
16e9271570 | ||
|
|
747551f35a | ||
|
|
367addf138 | ||
|
|
aa35200c6f | ||
|
|
e09de446fc | ||
|
|
0714b09a89 | ||
|
|
adb2626898 | ||
|
|
20435d55e8 | ||
|
|
a0187fb430 | ||
|
|
9c55a2e1fd | ||
|
|
eb37aa8046 | ||
|
|
2edf29033f | ||
|
|
b6cb287991 | ||
|
|
c9be273750 | ||
|
|
c7280284ea | ||
|
|
24cc307a0e | ||
|
|
177642ac4d | ||
|
|
ef6309c08f | ||
|
|
7ab7fbfd1b | ||
|
|
2b5505a20d | ||
|
|
ccce384a90 | ||
|
|
e2da5e2ef9 | ||
|
|
a71b8cd545 | ||
|
|
1d4bf96f7a | ||
|
|
c55c19e738 | ||
|
|
5cafa4d5b4 | ||
|
|
567e1dbbbf | ||
|
|
9e0542df73 | ||
|
|
32a8e558ed | ||
|
|
5209a6cfcf | ||
|
|
d290fd4670 | ||
|
|
cb53a3b48e | ||
|
|
8001486a2b | ||
|
|
0d6c1a0b44 | ||
|
|
d8e8703a5f | ||
|
|
c75f32da2c | ||
|
|
dcd26c19d7 | ||
|
|
eccec2f27d | ||
|
|
de7a496af9 | ||
|
|
ee19d9c948 | ||
|
|
74414c369a | ||
|
|
7935857dae | ||
|
|
d3e32bdbee | ||
|
|
77aaa3c0d1 | ||
|
|
6183501285 | ||
|
|
89e20738c8 | ||
|
|
84c89d38d3 | ||
|
|
4ec6590b23 | ||
|
|
b619c4259d | ||
|
|
f7859f7b04 |
@@ -7,7 +7,6 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-relay-data:
|
update-relay-data:
|
||||||
@@ -18,54 +17,24 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Fetch GeoRelays
|
- name: Fetch GeoRelays
|
||||||
run: |
|
run: |
|
||||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||||
|
|
||||||
- name: Configure git
|
- name: Check for changes
|
||||||
|
id: git-check
|
||||||
run: |
|
run: |
|
||||||
git config user.email "action@github.com"
|
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||||
git config user.name "GitHub Action"
|
|
||||||
|
- name: Commit and push changes
|
||||||
- name: Create update branch if changes
|
if: steps.git-check.outputs.changes == 'true'
|
||||||
id: create_branch
|
|
||||||
run: |
|
run: |
|
||||||
# exit early if no changes
|
git config --local user.email "action@github.com"
|
||||||
if git diff --quiet --relays/online_relays_gps.csv; then
|
git config --local user.name "GitHub Action"
|
||||||
echo "changed=false" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# branch name with timestamp
|
|
||||||
BRANCH="update-georelays-$(date -u +%Y%m%dT%H%M%SZ)"
|
|
||||||
git checkout -b "$BRANCH"
|
|
||||||
|
|
||||||
git add relays/online_relays_gps.csv
|
git add relays/online_relays_gps.csv
|
||||||
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
|
git commit -m "Automated update of relay data - $(date -u)"
|
||||||
echo "changed=true" >> $GITHUB_OUTPUT
|
git push
|
||||||
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Push branch
|
|
||||||
if: steps.create_branch.outputs.changed == 'true'
|
|
||||||
run: |
|
|
||||||
git push --set-upstream origin "${{ steps.create_branch.outputs.branch }}"
|
|
||||||
|
|
||||||
- name: Create pull request
|
|
||||||
if: steps.create_branch.outputs.changed == 'true'
|
|
||||||
uses: peter-evans/create-pull-request@v5
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
commit-message: Automated update of relay data
|
|
||||||
branch: ${{ steps.create_branch.outputs.branch }}
|
|
||||||
base: main
|
|
||||||
title: Automated update of relay data
|
|
||||||
body: |
|
|
||||||
This PR was created automatically by the scheduled workflow. It updates relays/online_relays_gps.csv from the GeoRelays source.
|
|
||||||
labels: automated, georelays
|
|
||||||
|
|
||||||
- name: No changes
|
|
||||||
if: steps.create_branch.outputs.changed != 'true'
|
|
||||||
run: echo "No changes to relays/online_relays_gps.csv"
|
|
||||||
@@ -14,11 +14,8 @@ default:
|
|||||||
# Check prerequisites
|
# Check prerequisites
|
||||||
check:
|
check:
|
||||||
@echo "Checking prerequisites..."
|
@echo "Checking prerequisites..."
|
||||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
|
||||||
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||||
@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
|
||||||
@@ -47,7 +44,7 @@ patch-for-macos: backup
|
|||||||
# Build the macOS app
|
# Build the macOS app
|
||||||
build: #check generate
|
build: #check generate
|
||||||
@echo "Building BitChat for macOS..."
|
@echo "Building BitChat for macOS..."
|
||||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||||
|
|
||||||
# Run the macOS app
|
# Run the macOS app
|
||||||
run: build
|
run: build
|
||||||
|
|||||||
+2
-4
@@ -34,8 +34,7 @@ let package = Package(
|
|||||||
"Assets.xcassets",
|
"Assets.xcassets",
|
||||||
"bitchat.entitlements",
|
"bitchat.entitlements",
|
||||||
"bitchat-macOS.entitlements",
|
"bitchat-macOS.entitlements",
|
||||||
"LaunchScreen.storyboard",
|
"LaunchScreen.storyboard"
|
||||||
"ViewModels/Extensions/README.md"
|
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Localizable.xcstrings")
|
.process("Localizable.xcstrings")
|
||||||
@@ -50,8 +49,7 @@ let package = Package(
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Localization"),
|
.process("Localization")
|
||||||
.process("Noise")
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
-34
@@ -95,24 +95,6 @@
|
|||||||
);
|
);
|
||||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||||
};
|
};
|
||||||
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
Localization/PrimaryLocalizationKeys.json,
|
|
||||||
README.md,
|
|
||||||
);
|
|
||||||
target = 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */;
|
|
||||||
};
|
|
||||||
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
Localization/PrimaryLocalizationKeys.json,
|
|
||||||
README.md,
|
|
||||||
);
|
|
||||||
target = 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */;
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -136,10 +118,6 @@
|
|||||||
};
|
};
|
||||||
A6E32D412E762EAE0032EA8A /* bitchatTests */ = {
|
A6E32D412E762EAE0032EA8A /* bitchatTests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */,
|
|
||||||
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */,
|
|
||||||
);
|
|
||||||
path = bitchatTests;
|
path = bitchatTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -235,7 +213,6 @@
|
|||||||
buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */;
|
buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
5C22AA7B9ACC5A861445C769 /* Sources */,
|
5C22AA7B9ACC5A861445C769 /* Sources */,
|
||||||
C5E027A42ECCDFD700BD6012 /* Resources */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -268,7 +245,6 @@
|
|||||||
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
865C8403EF02C089369A9FCB /* Sources */,
|
865C8403EF02C089369A9FCB /* Sources */,
|
||||||
C5E027A72ECCDFE200BD6012 /* Resources */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -367,16 +343,6 @@
|
|||||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
C5E027A42ECCDFD700BD6012 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
};
|
|
||||||
C5E027A72ECCDFE200BD6012 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
};
|
|
||||||
CD6E8F32BC38357473954F97 /* Resources */ = {
|
CD6E8F32BC38357473954F97 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
|
|||||||
@@ -98,8 +98,8 @@
|
|||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<EnvironmentVariables>
|
<EnvironmentVariables>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "BITCHAT_LOG_LEVEL"
|
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||||
value = "debug"
|
value = ""
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
|
|||||||
@@ -53,14 +53,15 @@ struct BitchatApp: App {
|
|||||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||||
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
|
||||||
let nickname = chatViewModel.nickname
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
|
#elseif os(macOS)
|
||||||
|
appDelegate.chatViewModel = chatViewModel
|
||||||
|
#endif
|
||||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||||
NetworkActivationService.shared.start()
|
NetworkActivationService.shared.start()
|
||||||
// Check for shared content
|
// Check for shared content
|
||||||
@@ -188,10 +189,6 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
|
||||||
chatViewModel?.applicationWillTerminate()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -224,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(str: peerID))
|
self.chatViewModel?.startPrivateChat(with: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,15 +246,10 @@ 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 {
|
||||||
// Don't show notification if the private chat is already open
|
// Don't show notification if the private chat is already open
|
||||||
// Access main-actor-isolated property via Task
|
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||||
Task { @MainActor in
|
completionHandler([])
|
||||||
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
return
|
||||||
completionHandler([])
|
|
||||||
} else {
|
|
||||||
completionHandler([.banner, .sound])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Suppress geohash activity notification if we're already in that geohash channel
|
// Suppress geohash activity notification if we're already in that geohash channel
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#else
|
#else
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
enum ImageUtilsError: Error {
|
enum ImageUtilsError: Error {
|
||||||
@@ -13,10 +13,10 @@ enum ImageUtilsError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ImageUtils {
|
enum ImageUtils {
|
||||||
private static let compressionQuality: CGFloat = 0.82
|
private static let compressionQuality: CGFloat = 0.85
|
||||||
private static let targetImageBytes: Int = 45_000
|
private static let targetImageBytes: Int = 60_000
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
// Security H1: Check file size BEFORE reading into memory
|
// Security H1: Check file size BEFORE reading into memory
|
||||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
guard let fileSize = attrs[.size] as? Int else {
|
guard let fileSize = attrs[.size] as? Int else {
|
||||||
@@ -38,32 +38,21 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
// Scale the image first
|
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
|
|
||||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
|
||||||
guard let cgImage = scaled.cgImage else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use CGImageDestination to encode without metadata (same as macOS)
|
|
||||||
var quality = compressionQuality
|
var quality = compressionQuality
|
||||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
|
||||||
throw ImageUtilsError.encodingFailed
|
throw ImageUtilsError.encodingFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compress to target size
|
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
quality -= 0.1
|
quality -= 0.1
|
||||||
autoreleasepool {
|
autoreleasepool {
|
||||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
if let next = scaled.jpegData(compressionQuality: quality) {
|
||||||
jpegData = next
|
jpegData = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
let outputURL = try makeOutputURL()
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
@@ -76,37 +65,14 @@ enum ImageUtils {
|
|||||||
guard maxSide > maxDimension else { return image }
|
guard maxSide > maxDimension else { return image }
|
||||||
let scale = maxDimension / maxSide
|
let scale = maxDimension / maxSide
|
||||||
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||||||
|
|
||||||
// Draw into a new context to get a clean CGImage without metadata
|
|
||||||
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||||
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||||
UIGraphicsEndImageContext()
|
UIGraphicsEndImageContext()
|
||||||
return rendered ?? image
|
return rendered ?? image
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
|
||||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
|
||||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
|
||||||
// By only specifying compression quality and no metadata keys,
|
|
||||||
// we ensure a clean JPEG with no privacy-leaking information
|
|
||||||
let options: [CFString: Any] = [
|
|
||||||
kCGImageDestinationLossyCompressionQuality: quality
|
|
||||||
]
|
|
||||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
|
||||||
guard CGImageDestinationFinalize(destination) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return data as Data
|
|
||||||
}
|
|
||||||
#else
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
@@ -164,7 +130,6 @@ enum ImageUtils {
|
|||||||
return scaledImage
|
return scaledImage
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
|
||||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||||
return nil
|
return nil
|
||||||
@@ -172,9 +137,8 @@ enum ImageUtils {
|
|||||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
// Security H2: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
// By only specifying compression quality and no metadata keys,
|
// Don't add any metadata dictionary keys - fresh CGContext ensures clean image
|
||||||
// we ensure a clean JPEG with no privacy-leaking information
|
|
||||||
let options: [CFString: Any] = [
|
let options: [CFString: Any] = [
|
||||||
kCGImageDestinationLossyCompressionQuality: quality
|
kCGImageDestinationLossyCompressionQuality: quality
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||||
private let paddingInterval: TimeInterval = 0.5
|
private let paddingInterval: TimeInterval = 0.5
|
||||||
private let maxRecordingDuration: TimeInterval = 120
|
|
||||||
|
|
||||||
private var recorder: AVAudioRecorder?
|
private var recorder: AVAudioRecorder?
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
@@ -76,14 +75,14 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
AVSampleRateKey: 16_000,
|
AVSampleRateKey: 16_000,
|
||||||
AVNumberOfChannelsKey: 1,
|
AVNumberOfChannelsKey: 1,
|
||||||
AVEncoderBitRateKey: 16_000
|
AVEncoderBitRateKey: 20_000
|
||||||
]
|
]
|
||||||
|
|
||||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||||
audioRecorder.delegate = self
|
audioRecorder.delegate = self
|
||||||
audioRecorder.isMeteringEnabled = true
|
audioRecorder.isMeteringEnabled = true
|
||||||
audioRecorder.prepareToRecord()
|
audioRecorder.prepareToRecord()
|
||||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
audioRecorder.record()
|
||||||
|
|
||||||
recorder = audioRecorder
|
recorder = audioRecorder
|
||||||
currentURL = outputURL
|
currentURL = outputURL
|
||||||
|
|||||||
+23926
-24248
File diff suppressed because it is too large
Load Diff
@@ -21,9 +21,8 @@ struct BitchatPacket: Codable {
|
|||||||
let payload: Data
|
let payload: Data
|
||||||
var signature: Data?
|
var signature: Data?
|
||||||
var ttl: UInt8
|
var ttl: UInt8
|
||||||
var route: [Data]?
|
|
||||||
|
|
||||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) {
|
||||||
self.version = version
|
self.version = version
|
||||||
self.type = type
|
self.type = type
|
||||||
self.senderID = senderID
|
self.senderID = senderID
|
||||||
@@ -32,7 +31,6 @@ struct BitchatPacket: Codable {
|
|||||||
self.payload = payload
|
self.payload = payload
|
||||||
self.signature = signature
|
self.signature = signature
|
||||||
self.ttl = ttl
|
self.ttl = ttl
|
||||||
self.route = route
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience initializer for new binary format
|
// Convenience initializer for new binary format
|
||||||
@@ -55,7 +53,6 @@ struct BitchatPacket: Codable {
|
|||||||
self.payload = payload
|
self.payload = payload
|
||||||
self.signature = nil
|
self.signature = nil
|
||||||
self.ttl = ttl
|
self.ttl = ttl
|
||||||
self.route = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var data: Data? {
|
var data: Data? {
|
||||||
@@ -84,8 +81,7 @@ struct BitchatPacket: Codable {
|
|||||||
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,
|
version: version
|
||||||
route: route
|
|
||||||
)
|
)
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
//
|
|
||||||
// CommandsInfo.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - CommandInfo Enum
|
|
||||||
|
|
||||||
enum CommandInfo: String, Identifiable {
|
|
||||||
case block
|
|
||||||
case clear
|
|
||||||
case hug
|
|
||||||
case message = "dm"
|
|
||||||
case slap
|
|
||||||
case unblock
|
|
||||||
case who
|
|
||||||
case favorite
|
|
||||||
case unfavorite
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
var alias: String { "/" + rawValue }
|
|
||||||
|
|
||||||
var placeholder: String? {
|
|
||||||
switch self {
|
|
||||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
|
||||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
|
||||||
case .clear, .who:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .block: String(localized: "content.commands.block")
|
|
||||||
case .clear: String(localized: "content.commands.clear")
|
|
||||||
case .hug: String(localized: "content.commands.hug")
|
|
||||||
case .message: String(localized: "content.commands.message")
|
|
||||||
case .slap: String(localized: "content.commands.slap")
|
|
||||||
case .unblock: String(localized: "content.commands.unblock")
|
|
||||||
case .who: String(localized: "content.commands.who")
|
|
||||||
case .favorite: String(localized: "content.commands.favorite")
|
|
||||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
|
||||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
|
||||||
if isGeoPublic || isGeoDM {
|
|
||||||
return baseCommands + [.favorite, .unfavorite]
|
|
||||||
}
|
|
||||||
return baseCommands
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+18
-22
@@ -35,7 +35,7 @@ struct PeerID: Equatable, Hashable {
|
|||||||
// Private so the callers have to go through a convenience init
|
// Private so the callers have to go through a convenience init
|
||||||
private init(prefix: Prefix, bare: any StringProtocol) {
|
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||||
self.prefix = prefix
|
self.prefix = prefix
|
||||||
self.bare = String(bare).lowercased()
|
self.bare = String(bare)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,12 +76,6 @@ extension PeerID {
|
|||||||
init(hexData: Data) {
|
init(hexData: Data) {
|
||||||
self.init(str: hexData.hexEncodedString())
|
self.init(str: hexData.hexEncodedString())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience init to "hide" hex-encoding implementation detail
|
|
||||||
init?(hexData: Data?) {
|
|
||||||
guard let hexData else { return nil }
|
|
||||||
self.init(hexData: hexData)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Noise Public Key Helpers
|
// MARK: - Noise Public Key Helpers
|
||||||
@@ -136,20 +130,6 @@ extension PeerID {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PeerID {
|
|
||||||
var routingData: Data? {
|
|
||||||
if let direct = Data(hexString: id), direct.count == 8 { return direct }
|
|
||||||
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
|
|
||||||
let short = toShort()
|
|
||||||
return Data(hexString: short.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(routingData: Data) {
|
|
||||||
guard routingData.count == 8 else { return nil }
|
|
||||||
self.init(hexData: routingData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Validation
|
// MARK: - Validation
|
||||||
|
|
||||||
extension PeerID {
|
extension PeerID {
|
||||||
@@ -211,7 +191,9 @@ extension PeerID: Comparable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CustomStringConvertible
|
// MARK: - String Interop Helpers
|
||||||
|
|
||||||
|
// MARK: CustomStringConvertible
|
||||||
|
|
||||||
extension PeerID: CustomStringConvertible {
|
extension PeerID: CustomStringConvertible {
|
||||||
/// So it returns the actual `id` like before even inside another String
|
/// So it returns the actual `id` like before even inside another String
|
||||||
@@ -219,3 +201,17 @@ extension PeerID: CustomStringConvertible {
|
|||||||
id
|
id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Custom Equatable w/ String & Optionality
|
||||||
|
|
||||||
|
// PeerID <> String
|
||||||
|
extension Optional where Wrapped == PeerID {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
|
||||||
|
}
|
||||||
|
|
||||||
|
// String <> PeerID
|
||||||
|
extension Optional where Wrapped == String {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import Foundation
|
|||||||
struct ReadReceipt: Codable {
|
struct ReadReceipt: Codable {
|
||||||
let originalMessageID: String
|
let originalMessageID: String
|
||||||
let receiptID: String
|
let receiptID: String
|
||||||
var readerID: PeerID // Who read it
|
var readerID: String // Who read it
|
||||||
let readerNickname: String
|
let readerNickname: String
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
|
|
||||||
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
|
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
||||||
self.originalMessageID = originalMessageID
|
self.originalMessageID = originalMessageID
|
||||||
self.receiptID = UUID().uuidString
|
self.receiptID = UUID().uuidString
|
||||||
self.readerID = readerID
|
self.readerID = readerID
|
||||||
@@ -24,7 +24,7 @@ struct ReadReceipt: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For binary decoding
|
// For binary decoding
|
||||||
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
|
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
||||||
self.originalMessageID = originalMessageID
|
self.originalMessageID = originalMessageID
|
||||||
self.receiptID = receiptID
|
self.receiptID = receiptID
|
||||||
self.readerID = readerID
|
self.readerID = readerID
|
||||||
@@ -48,7 +48,7 @@ struct ReadReceipt: Codable {
|
|||||||
data.appendUUID(receiptID)
|
data.appendUUID(receiptID)
|
||||||
// ReaderID as 8-byte hex string
|
// ReaderID as 8-byte hex string
|
||||||
var readerData = Data()
|
var readerData = Data()
|
||||||
var tempID = readerID.id
|
var tempID = readerID
|
||||||
while tempID.count >= 2 && readerData.count < 8 {
|
while tempID.count >= 2 && readerData.count < 8 {
|
||||||
let hexByte = String(tempID.prefix(2))
|
let hexByte = String(tempID.prefix(2))
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
@@ -78,8 +78,8 @@ struct ReadReceipt: Codable {
|
|||||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||||
|
|
||||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||||
let readerID = PeerID(hexData: readerIDData)
|
let readerID = readerIDData.hexEncodedString()
|
||||||
guard readerID.isValid else { return nil }
|
guard PeerID(str: readerID).isValid else { return nil }
|
||||||
|
|
||||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||||
InputValidator.validateTimestamp(timestamp),
|
InputValidator.validateTimestamp(timestamp),
|
||||||
|
|||||||
@@ -8,14 +8,6 @@ struct RequestSyncPacket {
|
|||||||
let p: Int
|
let p: Int
|
||||||
let m: UInt32
|
let m: UInt32
|
||||||
let data: Data
|
let data: Data
|
||||||
let types: SyncTypeFlags?
|
|
||||||
|
|
||||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
|
|
||||||
self.p = p
|
|
||||||
self.m = m
|
|
||||||
self.data = data
|
|
||||||
self.types = types
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode() -> Data {
|
func encode() -> Data {
|
||||||
var out = Data()
|
var out = Data()
|
||||||
@@ -33,9 +25,6 @@ struct RequestSyncPacket {
|
|||||||
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||||
// data
|
// data
|
||||||
putTLV(0x03, data)
|
putTLV(0x03, data)
|
||||||
if let typesData = types?.toData() {
|
|
||||||
putTLV(0x04, typesData)
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +33,6 @@ struct RequestSyncPacket {
|
|||||||
var p: Int? = nil
|
var p: Int? = nil
|
||||||
var m: UInt32? = nil
|
var m: UInt32? = nil
|
||||||
var payload: Data? = nil
|
var payload: Data? = nil
|
||||||
var types: SyncTypeFlags? = nil
|
|
||||||
|
|
||||||
while off + 3 <= data.count {
|
while off + 3 <= data.count {
|
||||||
let t = Int(data[off]); off += 1
|
let t = Int(data[off]); off += 1
|
||||||
@@ -64,16 +52,12 @@ struct RequestSyncPacket {
|
|||||||
case 0x03:
|
case 0x03:
|
||||||
if v.count > maxAcceptBytes { return nil }
|
if v.count > maxAcceptBytes { return nil }
|
||||||
payload = v
|
payload = v
|
||||||
case 0x04:
|
|
||||||
if let decoded = SyncTypeFlags.decode(v) {
|
|
||||||
types = decoded
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
break // forward compatible; ignore unknown TLVs
|
break // forward compatible; ignore unknown TLVs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
return RequestSyncPacket(p: pp, m: mm, data: dd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ final class NoiseCipherState {
|
|||||||
guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else {
|
guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract 4-byte nonce (big-endian)
|
// Extract 4-byte nonce (big-endian)
|
||||||
let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES)
|
let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES)
|
||||||
let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in
|
let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in
|
||||||
@@ -233,18 +233,18 @@ final class NoiseCipherState {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract ciphertext (remaining bytes)
|
// Extract ciphertext (remaining bytes)
|
||||||
let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)
|
let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)
|
||||||
|
|
||||||
return (nonce: extractedNonce, ciphertext: Data(ciphertext))
|
return (nonce: extractedNonce, ciphertext: Data(ciphertext))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert nonce to 4-byte array (big-endian)
|
/// Convert nonce to 4-byte array (big-endian)
|
||||||
private func nonceToBytes(_ nonce: UInt64) -> Data {
|
private func nonceToBytes(_ nonce: UInt64) -> Data {
|
||||||
var bytes = Data(count: Self.NONCE_SIZE_BYTES)
|
var bytes = Data(count: Self.NONCE_SIZE_BYTES)
|
||||||
withUnsafeBytes(of: nonce.bigEndian) { ptr in
|
withUnsafeBytes(of: nonce.bigEndian) { ptr in
|
||||||
// Copy only the last 4 bytes from the 8-byte UInt64
|
// Copy only the last 4 bytes from the 8-byte UInt64
|
||||||
let sourceBytes = ptr.bindMemory(to: UInt8.self)
|
let sourceBytes = ptr.bindMemory(to: UInt8.self)
|
||||||
bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))
|
bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))
|
||||||
}
|
}
|
||||||
@@ -273,7 +273,7 @@ final class NoiseCipherState {
|
|||||||
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)
|
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)
|
||||||
// increment local nonce
|
// increment local nonce
|
||||||
nonce += 1
|
nonce += 1
|
||||||
|
|
||||||
// Create combined payload: <nonce><ciphertext>
|
// Create combined payload: <nonce><ciphertext>
|
||||||
let combinedPayload: Data
|
let combinedPayload: Data
|
||||||
if (useExtractedNonce) {
|
if (useExtractedNonce) {
|
||||||
@@ -287,7 +287,7 @@ final class NoiseCipherState {
|
|||||||
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
|
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
|
||||||
SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
|
SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
|
||||||
}
|
}
|
||||||
|
|
||||||
return combinedPayload
|
return combinedPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +316,7 @@ final class NoiseCipherState {
|
|||||||
SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
|
SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
|
||||||
throw NoiseError.replayDetected
|
throw NoiseError.replayDetected
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split ciphertext and tag
|
// Split ciphertext and tag
|
||||||
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||||
tag = actualCiphertext.suffix(16)
|
tag = actualCiphertext.suffix(16)
|
||||||
@@ -451,13 +451,13 @@ final class NoiseSymmetricState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) {
|
func split() -> (NoiseCipherState, NoiseCipherState) {
|
||||||
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
||||||
let tempKey1 = SymmetricKey(data: output[0])
|
let tempKey1 = SymmetricKey(data: output[0])
|
||||||
let tempKey2 = SymmetricKey(data: output[1])
|
let tempKey2 = SymmetricKey(data: output[1])
|
||||||
|
|
||||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true)
|
||||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true)
|
||||||
|
|
||||||
return (c1, c2)
|
return (c1, c2)
|
||||||
}
|
}
|
||||||
@@ -507,24 +507,16 @@ final class NoiseHandshakeState {
|
|||||||
private var messagePatterns: [[NoiseMessagePattern]] = []
|
private var messagePatterns: [[NoiseMessagePattern]] = []
|
||||||
private var currentPattern = 0
|
private var currentPattern = 0
|
||||||
|
|
||||||
// Test support: predetermined ephemeral keys for test vectors
|
|
||||||
private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey?
|
|
||||||
private var prologueData: Data
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
role: NoiseRole,
|
role: NoiseRole,
|
||||||
pattern: NoisePattern,
|
pattern: NoisePattern,
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
||||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,
|
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
|
||||||
prologue: Data = Data(),
|
|
||||||
predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil
|
|
||||||
) {
|
) {
|
||||||
self.role = role
|
self.role = role
|
||||||
self.pattern = pattern
|
self.pattern = pattern
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.prologueData = prologue
|
|
||||||
self.predeterminedEphemeralKey = predeterminedEphemeralKey
|
|
||||||
|
|
||||||
// Initialize static keys
|
// Initialize static keys
|
||||||
if let localKey = localStaticKey {
|
if let localKey = localStaticKey {
|
||||||
@@ -545,8 +537,8 @@ final class NoiseHandshakeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func mixPreMessageKeys() {
|
private func mixPreMessageKeys() {
|
||||||
// Mix prologue
|
// Mix prologue (empty for XX pattern normally)
|
||||||
symmetricState.mixHash(self.prologueData)
|
symmetricState.mixHash(Data()) // Empty prologue for XX pattern
|
||||||
// For XX pattern, no pre-message keys
|
// For XX pattern, no pre-message keys
|
||||||
// For IK/NK patterns, we'd mix the responder's static key here
|
// For IK/NK patterns, we'd mix the responder's static key here
|
||||||
switch pattern {
|
switch pattern {
|
||||||
@@ -564,20 +556,15 @@ final class NoiseHandshakeState {
|
|||||||
guard currentPattern < messagePatterns.count else {
|
guard currentPattern < messagePatterns.count else {
|
||||||
throw NoiseError.handshakeComplete
|
throw NoiseError.handshakeComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
var messageBuffer = Data()
|
var messageBuffer = Data()
|
||||||
let patterns = messagePatterns[currentPattern]
|
let patterns = messagePatterns[currentPattern]
|
||||||
|
|
||||||
for pattern in patterns {
|
for pattern in patterns {
|
||||||
switch pattern {
|
switch pattern {
|
||||||
case .e:
|
case .e:
|
||||||
// Generate ephemeral key (or use predetermined key for tests)
|
// Generate ephemeral key
|
||||||
if let predetermined = predeterminedEphemeralKey {
|
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||||
localEphemeralPrivate = predetermined
|
|
||||||
predeterminedEphemeralKey = nil
|
|
||||||
} else {
|
|
||||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
}
|
|
||||||
localEphemeralPublic = localEphemeralPrivate!.publicKey
|
localEphemeralPublic = localEphemeralPrivate!.publicKey
|
||||||
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
|
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
|
||||||
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
|
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
|
||||||
@@ -665,7 +652,7 @@ final class NoiseHandshakeState {
|
|||||||
guard currentPattern < messagePatterns.count else {
|
guard currentPattern < messagePatterns.count else {
|
||||||
throw NoiseError.handshakeComplete
|
throw NoiseError.handshakeComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
var buffer = message
|
var buffer = message
|
||||||
let patterns = messagePatterns[currentPattern]
|
let patterns = messagePatterns[currentPattern]
|
||||||
|
|
||||||
@@ -780,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) })
|
||||||
|
|
||||||
case .e, .s:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -789,12 +776,12 @@ final class NoiseHandshakeState {
|
|||||||
return currentPattern >= messagePatterns.count
|
return currentPattern >= messagePatterns.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
|
func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
|
||||||
guard isHandshakeComplete() else {
|
guard isHandshakeComplete() else {
|
||||||
throw NoiseError.handshakeNotComplete
|
throw NoiseError.handshakeNotComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
let (c1, c2) = symmetricState.split()
|
||||||
|
|
||||||
// Initiator uses c1 for sending, c2 for receiving
|
// Initiator uses c1 for sending, c2 for receiving
|
||||||
// Responder uses c2 for sending, c1 for receiving
|
// Responder uses c2 for sending, c1 for receiving
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
//
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -103,7 +103,7 @@ class NoiseSession {
|
|||||||
// Check if handshake is complete
|
// Check if handshake is complete
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
// Get transport ciphers
|
// Get transport ciphers
|
||||||
let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
let (send, receive) = try handshake.getTransportCiphers()
|
||||||
sendCipher = send
|
sendCipher = send
|
||||||
receiveCipher = receive
|
receiveCipher = receive
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ class NoiseSession {
|
|||||||
// Check if handshake is complete after writing
|
// Check if handshake is complete after writing
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
// Get transport ciphers
|
// Get transport ciphers
|
||||||
let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
let (send, receive) = try handshake.getTransportCiphers()
|
||||||
sendCipher = send
|
sendCipher = send
|
||||||
receiveCipher = receive
|
receiveCipher = receive
|
||||||
|
|
||||||
@@ -196,6 +196,12 @@ 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,9 +6,10 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
enum NoiseSessionError: Error, Equatable {
|
enum NoiseSessionError: Error {
|
||||||
case invalidState
|
case invalidState
|
||||||
case notEstablished
|
case notEstablished
|
||||||
case sessionNotFound
|
case sessionNotFound
|
||||||
|
case handshakeFailed(Error)
|
||||||
case alreadyEstablished
|
case alreadyEstablished
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ 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]
|
||||||
@@ -35,9 +48,14 @@ 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.removeValue(forKey: peerID) {
|
if let session = sessions[peerID] {
|
||||||
session.reset() // Clear sensitive data before removing
|
if session.isEstablished() {
|
||||||
|
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||||
|
}
|
||||||
|
// Clear sensitive data before removing
|
||||||
|
session.reset()
|
||||||
}
|
}
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +68,12 @@ 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 {
|
||||||
@@ -183,6 +207,10 @@ 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)] {
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Tor
|
import Tor
|
||||||
#if os(iOS)
|
|
||||||
import UIKit
|
|
||||||
#elseif os(macOS)
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -17,32 +12,19 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static let shared = GeoRelayDirectory()
|
static let shared = GeoRelayDirectory()
|
||||||
|
|
||||||
private(set) var entries: [Entry] = []
|
private(set) var entries: [Entry] = []
|
||||||
private let cacheFileName = "georelays_cache.csv"
|
private let cacheFileName = "georelays_cache.csv"
|
||||||
private let lastFetchKey = "georelay.lastFetchAt"
|
private let lastFetchKey = "georelay.lastFetchAt"
|
||||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
||||||
|
|
||||||
private var refreshTimer: Timer?
|
|
||||||
private var retryTask: Task<Void, Never>?
|
|
||||||
private var retryAttempt: Int = 0
|
|
||||||
private var isFetching: Bool = false
|
|
||||||
private var observers: [NSObjectProtocol] = []
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
entries = loadLocalEntries()
|
// Load cached or bundled data synchronously
|
||||||
registerObservers()
|
self.entries = self.loadLocalEntries()
|
||||||
startRefreshTimer()
|
// Fire-and-forget remote refresh if stale
|
||||||
prefetchIfNeeded()
|
prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
retryTask?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||||
let center = Geohash.decodeCenter(geohash)
|
let center = Geohash.decodeCenter(geohash)
|
||||||
@@ -51,148 +33,52 @@ 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, count > 0 else { return [] }
|
guard !entries.isEmpty else { return [] }
|
||||||
|
let sorted = entries
|
||||||
if entries.count <= count {
|
.sorted { a, b in
|
||||||
return entries
|
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||||
.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
|
||||||
func prefetchIfNeeded(force: Bool = false) {
|
func prefetchIfNeeded() {
|
||||||
guard !isFetching else { return }
|
|
||||||
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
|
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||||
if !force {
|
|
||||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
|
||||||
} else if last != .distantPast,
|
|
||||||
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
|
||||||
// Skip forced fetches if we just refreshed moments ago.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelRetry()
|
|
||||||
fetchRemote()
|
fetchRemote()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchRemote() {
|
private func fetchRemote() {
|
||||||
guard !isFetching else { return }
|
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||||
isFetching = true
|
// Ensure Tor readiness before fetching (fail-closed by default)
|
||||||
|
Task.detached {
|
||||||
let request = URLRequest(
|
|
||||||
url: remoteURL,
|
|
||||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
|
||||||
timeoutInterval: 15
|
|
||||||
)
|
|
||||||
|
|
||||||
Task.detached { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let ready = await TorManager.shared.awaitReady()
|
||||||
if !ready {
|
if !ready {
|
||||||
await self.handleFetchFailure(.torNotReady)
|
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
|
||||||
do {
|
guard let self = self else { return }
|
||||||
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||||
guard let text = String(data: data, encoding: .utf8) else {
|
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||||
await self.handleFetchFailure(.invalidData)
|
if !parsed.isEmpty {
|
||||||
return
|
Task { @MainActor in
|
||||||
|
self.entries = parsed
|
||||||
|
self.persistCache(text)
|
||||||
|
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||||
|
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
|
||||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
|
||||||
guard !parsed.isEmpty else {
|
|
||||||
await self.handleFetchFailure(.invalidData)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.handleFetchSuccess(entries: parsed, csv: text)
|
|
||||||
} catch {
|
|
||||||
await self.handleFetchFailure(.network(error))
|
|
||||||
}
|
}
|
||||||
|
task.resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum FetchFailure {
|
|
||||||
case torNotReady
|
|
||||||
case invalidData
|
|
||||||
case network(Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
|
||||||
entries = parsed
|
|
||||||
persistCache(csv)
|
|
||||||
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
|
||||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
|
||||||
isFetching = false
|
|
||||||
retryAttempt = 0
|
|
||||||
cancelRetry()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleFetchFailure(_ reason: FetchFailure) {
|
|
||||||
switch reason {
|
|
||||||
case .torNotReady:
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
|
||||||
case .invalidData:
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
|
||||||
case .network(let error):
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
|
||||||
}
|
|
||||||
isFetching = false
|
|
||||||
scheduleRetry()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func scheduleRetry() {
|
|
||||||
retryAttempt = min(retryAttempt + 1, 10)
|
|
||||||
let base = TransportConfig.geoRelayRetryInitialSeconds
|
|
||||||
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
|
||||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
|
||||||
let calculated = base * multiplier
|
|
||||||
let delay = min(maxDelay, max(base, calculated))
|
|
||||||
|
|
||||||
cancelRetry()
|
|
||||||
retryTask = Task { [weak self] in
|
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
|
||||||
await MainActor.run {
|
|
||||||
self?.prefetchIfNeeded(force: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func cancelRetry() {
|
|
||||||
retryTask?.cancel()
|
|
||||||
retryTask = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistCache(_ text: String) {
|
private func persistCache(_ text: String) {
|
||||||
guard let url = cacheURL() else { return }
|
guard let url = cacheURL() else { return }
|
||||||
do {
|
do {
|
||||||
@@ -205,35 +91,30 @@ final class GeoRelayDirectory {
|
|||||||
// MARK: - Loading
|
// MARK: - Loading
|
||||||
private func loadLocalEntries() -> [Entry] {
|
private func loadLocalEntries() -> [Entry] {
|
||||||
// Prefer cached file if present
|
// Prefer cached file if present
|
||||||
if let cache = cacheURL(),
|
if let cache = self.cacheURL(),
|
||||||
let data = try? Data(contentsOf: cache),
|
let data = try? Data(contentsOf: cache),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try bundled resource(s)
|
// Try bundled resource(s)
|
||||||
let bundleCandidates = [
|
let bundleCandidates = [
|
||||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||||
].compactMap { $0 }
|
].compactMap { $0 }
|
||||||
|
|
||||||
for url in bundleCandidates {
|
for url in bundleCandidates {
|
||||||
if let data = try? Data(contentsOf: url),
|
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
||||||
let text = String(data: data, encoding: .utf8) {
|
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
// Try filesystem path (development/test)
|
||||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
return Self.parseCSV(text)
|
return Self.parseCSV(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -241,6 +122,7 @@ final class GeoRelayDirectory {
|
|||||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||||
var result: Set<Entry> = []
|
var result: Set<Entry> = []
|
||||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
|
// Skip header if present
|
||||||
for (idx, raw) in lines.enumerated() {
|
for (idx, raw) in lines.enumerated() {
|
||||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if line.isEmpty { continue }
|
if line.isEmpty { continue }
|
||||||
@@ -261,76 +143,11 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
private func cacheURL() -> URL? {
|
private func cacheURL() -> URL? {
|
||||||
do {
|
do {
|
||||||
let base = try FileManager.default.url(
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
for: .applicationSupportDirectory,
|
|
||||||
in: .userDomainMask,
|
|
||||||
appropriateFor: nil,
|
|
||||||
create: true
|
|
||||||
)
|
|
||||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
return dir.appendingPathComponent(cacheFileName)
|
return dir.appendingPathComponent(cacheFileName)
|
||||||
} catch {
|
} catch { return nil }
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Observers & Timers
|
|
||||||
private func registerObservers() {
|
|
||||||
let center = NotificationCenter.default
|
|
||||||
|
|
||||||
let torReady = center.addObserver(
|
|
||||||
forName: .TorDidBecomeReady,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded(force: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observers.append(torReady)
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
let didBecomeActive = center.addObserver(
|
|
||||||
forName: UIApplication.didBecomeActiveNotification,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observers.append(didBecomeActive)
|
|
||||||
#elseif os(macOS)
|
|
||||||
let didBecomeActive = center.addObserver(
|
|
||||||
forName: NSApplication.didBecomeActiveNotification,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observers.append(didBecomeActive)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startRefreshTimer() {
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
|
||||||
guard interval > 0 else { return }
|
|
||||||
|
|
||||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refreshTimer = timer
|
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
|
|
||||||
struct NostrEmbeddedBitChat {
|
struct NostrEmbeddedBitChat {
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||||
// TLV-encode the private message
|
// TLV-encode the private message
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
@@ -14,12 +14,12 @@ struct NostrEmbeddedBitChat {
|
|||||||
payload.append(tlv)
|
payload.append(tlv)
|
||||||
|
|
||||||
// Determine 8-byte recipient ID to embed
|
// Determine 8-byte recipient ID to embed
|
||||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientID.id),
|
recipientID: Data(hexString: recipientIDHex),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -31,18 +31,18 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
payload.append(Data(messageID.utf8))
|
payload.append(Data(messageID.utf8))
|
||||||
|
|
||||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientID.id),
|
recipientID: Data(hexString: recipientIDHex),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -54,7 +54,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
|
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
@@ -62,7 +62,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -75,7 +75,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
|
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -96,11 +96,11 @@ struct NostrEmbeddedBitChat {
|
|||||||
return "bitchat1:" + base64URLEncode(data)
|
return "bitchat1:" + base64URLEncode(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
|
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
||||||
if let maybeData = Data(hexString: recipientPeerID.id) {
|
if let maybeData = Data(hexString: recipientPeerID) {
|
||||||
if maybeData.count == 32 {
|
if maybeData.count == 32 {
|
||||||
// Treat as Noise static public key; derive peerID from fingerprint
|
// Treat as Noise static public key; derive peerID from fingerprint
|
||||||
return PeerID(publicKey: maybeData)
|
return PeerID(publicKey: maybeData).id
|
||||||
} else if maybeData.count == 8 {
|
} else if maybeData.count == 8 {
|
||||||
// Already an 8-byte peer ID
|
// Already an 8-byte peer ID
|
||||||
return recipientPeerID
|
return recipientPeerID
|
||||||
|
|||||||
@@ -906,16 +906,6 @@ struct NostrFilter: Encodable {
|
|||||||
filter.limit = limit
|
filter.limit = limit
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
|
|
||||||
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
|
||||||
var filter = NostrFilter()
|
|
||||||
filter.kinds = [1]
|
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
|
||||||
filter.tagFilters = ["g": geohashes]
|
|
||||||
filter.limit = limit
|
|
||||||
return filter
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic coding key for tag filters
|
// Dynamic coding key for tag filters
|
||||||
|
|||||||
@@ -5,27 +5,16 @@ import CryptoKit
|
|||||||
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||||
/// as per XChaCha20 construction.
|
/// as per XChaCha20 construction.
|
||||||
enum XChaCha20Poly1305Compat {
|
enum XChaCha20Poly1305Compat {
|
||||||
|
|
||||||
/// Errors that can occur during XChaCha20-Poly1305 operations
|
|
||||||
enum Error: Swift.Error {
|
|
||||||
case invalidKeyLength(expected: Int, got: Int)
|
|
||||||
case invalidNonceLength(expected: Int, got: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SealBox {
|
struct SealBox {
|
||||||
let ciphertext: Data
|
let ciphertext: Data
|
||||||
let tag: Data
|
let tag: Data
|
||||||
}
|
}
|
||||||
|
|
||||||
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
||||||
guard key.count == 32 else {
|
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||||
}
|
|
||||||
guard nonce24.count == 24 else {
|
|
||||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||||
let chachaKey = SymmetricKey(data: subkey)
|
let chachaKey = SymmetricKey(data: subkey)
|
||||||
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||||
@@ -34,14 +23,10 @@ enum XChaCha20Poly1305Compat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
||||||
guard key.count == 32 else {
|
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||||
}
|
|
||||||
guard nonce24.count == 24 else {
|
|
||||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||||
let chachaKey = SymmetricKey(data: subkey)
|
let chachaKey = SymmetricKey(data: subkey)
|
||||||
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
||||||
@@ -58,14 +43,10 @@ enum XChaCha20Poly1305Compat {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
|
private static func hchacha20(key: Data, nonce16: Data) -> Data {
|
||||||
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||||
guard key.count == 32 else {
|
precondition(key.count == 32)
|
||||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
precondition(nonce16.count == 16)
|
||||||
}
|
|
||||||
guard nonce16.count == 16 else {
|
|
||||||
throw Error.invalidNonceLength(expected: 16, got: nonce16.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constants "expand 32-byte k"
|
// Constants "expand 32-byte k"
|
||||||
var state: [UInt32] = [
|
var state: [UInt32] = [
|
||||||
|
|||||||
@@ -137,7 +137,6 @@ struct BinaryProtocol {
|
|||||||
static let hasRecipient: UInt8 = 0x01
|
static let hasRecipient: UInt8 = 0x01
|
||||||
static let hasSignature: UInt8 = 0x02
|
static let hasSignature: UInt8 = 0x02
|
||||||
static let isCompressed: UInt8 = 0x04
|
static let isCompressed: UInt8 = 0x04
|
||||||
static let hasRoute: UInt8 = 0x08
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode BitchatPacket to binary format
|
// Encode BitchatPacket to binary format
|
||||||
@@ -161,21 +160,8 @@ struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lengthFieldBytes = lengthFieldSize(for: version)
|
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||||
let originalRoute = packet.route ?? []
|
|
||||||
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
|
|
||||||
let sanitizedRoute: [Data] = originalRoute.map { hop in
|
|
||||||
if hop.count == senderIDSize { return hop }
|
|
||||||
if hop.count > senderIDSize { return Data(hop.prefix(senderIDSize)) }
|
|
||||||
var padded = hop
|
|
||||||
padded.append(Data(repeating: 0, count: senderIDSize - hop.count))
|
|
||||||
return padded
|
|
||||||
}
|
|
||||||
guard sanitizedRoute.count <= 255 else { return nil }
|
|
||||||
|
|
||||||
let hasRoute = !sanitizedRoute.isEmpty
|
|
||||||
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
|
|
||||||
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
||||||
let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
|
let payloadDataSize = payload.count + originalSizeFieldBytes
|
||||||
|
|
||||||
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
||||||
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
||||||
@@ -199,7 +185,6 @@ struct BinaryProtocol {
|
|||||||
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
||||||
if packet.signature != nil { flags |= Flags.hasSignature }
|
if packet.signature != nil { flags |= Flags.hasSignature }
|
||||||
if isCompressed { flags |= Flags.isCompressed }
|
if isCompressed { flags |= Flags.isCompressed }
|
||||||
if hasRoute { flags |= Flags.hasRoute }
|
|
||||||
data.append(flags)
|
data.append(flags)
|
||||||
|
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
@@ -227,13 +212,6 @@ struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasRoute {
|
|
||||||
data.append(UInt8(sanitizedRoute.count))
|
|
||||||
for hop in sanitizedRoute {
|
|
||||||
data.append(hop)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isCompressed, let originalSize = originalPayloadSize {
|
if isCompressed, let originalSize = originalPayloadSize {
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
let value = UInt32(originalSize)
|
let value = UInt32(originalSize)
|
||||||
@@ -343,27 +321,9 @@ struct BinaryProtocol {
|
|||||||
if recipientID == nil { return nil }
|
if recipientID == nil { return nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
var route: [Data]? = nil
|
|
||||||
var remainingPayloadBytes = payloadLength
|
|
||||||
|
|
||||||
if (flags & Flags.hasRoute) != 0 {
|
|
||||||
guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil }
|
|
||||||
remainingPayloadBytes -= 1
|
|
||||||
if routeCount > 0 {
|
|
||||||
var hops: [Data] = []
|
|
||||||
for _ in 0..<Int(routeCount) {
|
|
||||||
guard remainingPayloadBytes >= senderIDSize,
|
|
||||||
let hop = readData(senderIDSize) else { return nil }
|
|
||||||
remainingPayloadBytes -= senderIDSize
|
|
||||||
hops.append(hop)
|
|
||||||
}
|
|
||||||
route = hops
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: Data
|
let payload: Data
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
|
guard payloadLength >= lengthFieldBytes else { return nil }
|
||||||
let originalSize: Int
|
let originalSize: Int
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
guard let rawSize = read32() else { return nil }
|
guard let rawSize = read32() else { return nil }
|
||||||
@@ -372,12 +332,15 @@ struct BinaryProtocol {
|
|||||||
guard let rawSize = read16() else { return nil }
|
guard let rawSize = read16() else { return nil }
|
||||||
originalSize = Int(rawSize)
|
originalSize = Int(rawSize)
|
||||||
}
|
}
|
||||||
remainingPayloadBytes -= lengthFieldBytes
|
// Guard to keep decompression bounded to sane BLE payload limits
|
||||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxPayloadBytes else { return nil }
|
||||||
let compressedSize = remainingPayloadBytes
|
let compressedSize = payloadLength - lengthFieldBytes
|
||||||
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
|
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
|
||||||
remainingPayloadBytes = 0
|
|
||||||
|
|
||||||
|
// 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)
|
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
||||||
guard compressionRatio <= 50_000.0 else {
|
guard compressionRatio <= 50_000.0 else {
|
||||||
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
||||||
@@ -388,9 +351,7 @@ struct BinaryProtocol {
|
|||||||
decompressed.count == originalSize else { return nil }
|
decompressed.count == originalSize else { return nil }
|
||||||
payload = decompressed
|
payload = decompressed
|
||||||
} else {
|
} else {
|
||||||
guard remainingPayloadBytes >= 0,
|
guard let rawPayload = readData(payloadLength) else { return nil }
|
||||||
let rawPayload = readData(remainingPayloadBytes) else { return nil }
|
|
||||||
remainingPayloadBytes = 0
|
|
||||||
payload = rawPayload
|
payload = rawPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,8 +371,7 @@ struct BinaryProtocol {
|
|||||||
payload: payload,
|
payload: payload,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
ttl: ttl,
|
ttl: ttl,
|
||||||
version: version,
|
version: version
|
||||||
route: route
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
|
|
||||||
// Bluetooth state updates for user notifications
|
// Bluetooth state updates for user notifications
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide default implementation to make it effectively optional
|
// Provide default implementation to make it effectively optional
|
||||||
@@ -195,7 +195,7 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,57 +119,4 @@ enum Geohash {
|
|||||||
}
|
}
|
||||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all 8 neighboring geohash cells at the same precision.
|
|
||||||
/// - Parameter geohash: Base32 geohash string.
|
|
||||||
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
|
|
||||||
static func neighbors(of geohash: String) -> [String] {
|
|
||||||
guard !geohash.isEmpty else { return [] }
|
|
||||||
|
|
||||||
let precision = geohash.count
|
|
||||||
let bounds = decodeBounds(geohash)
|
|
||||||
let center = decodeCenter(geohash)
|
|
||||||
|
|
||||||
// Calculate cell dimensions
|
|
||||||
let latHeight = bounds.latMax - bounds.latMin
|
|
||||||
let lonWidth = bounds.lonMax - bounds.lonMin
|
|
||||||
|
|
||||||
// Helper to wrap longitude around ±180
|
|
||||||
func wrapLongitude(_ lon: Double) -> Double {
|
|
||||||
var wrapped = lon
|
|
||||||
while wrapped > 180.0 { wrapped -= 360.0 }
|
|
||||||
while wrapped < -180.0 { wrapped += 360.0 }
|
|
||||||
return wrapped
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to clamp latitude to ±90
|
|
||||||
func clampLatitude(_ lat: Double) -> Double {
|
|
||||||
return max(-90.0, min(90.0, lat))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate 8 neighbor centers
|
|
||||||
let neighbors: [(lat: Double, lon: Double)] = [
|
|
||||||
(center.lat + latHeight, center.lon), // N
|
|
||||||
(center.lat + latHeight, center.lon + lonWidth), // NE
|
|
||||||
(center.lat, center.lon + lonWidth), // E
|
|
||||||
(center.lat - latHeight, center.lon + lonWidth), // SE
|
|
||||||
(center.lat - latHeight, center.lon), // S
|
|
||||||
(center.lat - latHeight, center.lon - lonWidth), // SW
|
|
||||||
(center.lat, center.lon - lonWidth), // W
|
|
||||||
(center.lat + latHeight, center.lon - lonWidth) // NW
|
|
||||||
]
|
|
||||||
|
|
||||||
// Encode each neighbor, handling boundary conditions
|
|
||||||
return neighbors.compactMap { neighbor in
|
|
||||||
let lat = clampLatitude(neighbor.lat)
|
|
||||||
let lon = wrapLongitude(neighbor.lon)
|
|
||||||
|
|
||||||
// Skip if we've crossed a pole (latitude clamped to boundary)
|
|
||||||
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return encode(latitude: lat, longitude: lon, precision: precision)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,18 +116,4 @@ enum ChannelID: Equatable, Codable {
|
|||||||
case .location(let ch): return ch.geohash
|
case .location(let ch): return ch.geohash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isMesh: Bool {
|
|
||||||
switch self {
|
|
||||||
case .mesh: true
|
|
||||||
case .location: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isLocation: Bool {
|
|
||||||
switch self {
|
|
||||||
case .mesh: false
|
|
||||||
case .location: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ struct AnnouncementPacket {
|
|||||||
let nickname: String
|
let nickname: String
|
||||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||||
let signingPublicKey: Data // Ed25519 public key for signing
|
let signingPublicKey: Data // Ed25519 public key for signing
|
||||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
|
||||||
|
|
||||||
private enum TLVType: UInt8 {
|
private enum TLVType: UInt8 {
|
||||||
case nickname = 0x01
|
case nickname = 0x01
|
||||||
case noisePublicKey = 0x02
|
case noisePublicKey = 0x02
|
||||||
case signingPublicKey = 0x03
|
case signingPublicKey = 0x03
|
||||||
case directNeighbors = 0x04
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
@@ -37,16 +35,6 @@ struct AnnouncementPacket {
|
|||||||
data.append(TLVType.signingPublicKey.rawValue)
|
data.append(TLVType.signingPublicKey.rawValue)
|
||||||
data.append(UInt8(signingPublicKey.count))
|
data.append(UInt8(signingPublicKey.count))
|
||||||
data.append(signingPublicKey)
|
data.append(signingPublicKey)
|
||||||
|
|
||||||
// TLV for direct neighbors (optional)
|
|
||||||
if let neighbors = directNeighbors, !neighbors.isEmpty {
|
|
||||||
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
|
|
||||||
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
|
|
||||||
data.append(TLVType.directNeighbors.rawValue)
|
|
||||||
data.append(UInt8(neighborsData.count))
|
|
||||||
data.append(neighborsData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
@@ -56,7 +44,6 @@ struct AnnouncementPacket {
|
|||||||
var nickname: String?
|
var nickname: String?
|
||||||
var noisePublicKey: Data?
|
var noisePublicKey: Data?
|
||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var directNeighbors: [Data]?
|
|
||||||
|
|
||||||
while offset + 2 <= data.count {
|
while offset + 2 <= data.count {
|
||||||
let typeRaw = data[offset]
|
let typeRaw = data[offset]
|
||||||
@@ -76,17 +63,6 @@ struct AnnouncementPacket {
|
|||||||
noisePublicKey = Data(value)
|
noisePublicKey = Data(value)
|
||||||
case .signingPublicKey:
|
case .signingPublicKey:
|
||||||
signingPublicKey = Data(value)
|
signingPublicKey = Data(value)
|
||||||
case .directNeighbors:
|
|
||||||
if length > 0 && length % 8 == 0 {
|
|
||||||
var neighbors = [Data]()
|
|
||||||
let count = length / 8
|
|
||||||
for i in 0..<count {
|
|
||||||
let start = value.startIndex + i * 8
|
|
||||||
let end = start + 8
|
|
||||||
neighbors.append(Data(value[start..<end]))
|
|
||||||
}
|
|
||||||
directNeighbors = neighbors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
@@ -98,8 +74,7 @@ struct AnnouncementPacket {
|
|||||||
return AnnouncementPacket(
|
return AnnouncementPacket(
|
||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey
|
||||||
directNeighbors: directNeighbors
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
//
|
|
||||||
// MimeType.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
|
|
||||||
// MARK: - Extensions for missing UTTypes
|
|
||||||
|
|
||||||
extension UTType {
|
|
||||||
static let webP = UTType(importedAs: "image/webp")
|
|
||||||
static let aac = UTType(importedAs: "audio/aac")
|
|
||||||
static let m4a = UTType(importedAs: "audio/m4a")
|
|
||||||
static let ogg = UTType(importedAs: "audio/ogg")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - MimeType Enum
|
|
||||||
|
|
||||||
enum MimeType: CaseIterable, Hashable {
|
|
||||||
case jpeg
|
|
||||||
case jpg
|
|
||||||
case png
|
|
||||||
case gif
|
|
||||||
case webp
|
|
||||||
case mp4Audio
|
|
||||||
case m4a
|
|
||||||
case aac
|
|
||||||
case mpeg
|
|
||||||
case mp3
|
|
||||||
case wav
|
|
||||||
case xWav
|
|
||||||
case ogg
|
|
||||||
case pdf
|
|
||||||
case octetStream
|
|
||||||
|
|
||||||
var utType: UTType {
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg: .jpeg
|
|
||||||
case .png: .png
|
|
||||||
case .gif: .gif
|
|
||||||
case .webp: .webP
|
|
||||||
case .aac: .aac
|
|
||||||
case .m4a: .m4a
|
|
||||||
case .mp4Audio: .mpeg4Audio
|
|
||||||
case .mp3, .mpeg: .mp3
|
|
||||||
case .wav, .xWav: .wav
|
|
||||||
case .ogg: .ogg
|
|
||||||
case .pdf: .pdf
|
|
||||||
case .octetStream: .data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var category: Category {
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg, .png, .gif, .webp:
|
|
||||||
return .image
|
|
||||||
case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:
|
|
||||||
return .audio
|
|
||||||
case .pdf, .octetStream:
|
|
||||||
return .file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
var mimeString: String {
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg: "image/jpeg"
|
|
||||||
case .png: "image/png"
|
|
||||||
case .gif: "image/gif"
|
|
||||||
case .webp: "image/webp"
|
|
||||||
case .mp4Audio: "audio/mp4"
|
|
||||||
case .m4a: "audio/m4a"
|
|
||||||
case .aac: "audio/aac"
|
|
||||||
case .mpeg: "audio/mpeg"
|
|
||||||
case .mp3: "audio/mp3"
|
|
||||||
case .wav: "audio/wav"
|
|
||||||
case .xWav: "audio/x-wav"
|
|
||||||
case .ogg: "audio/ogg"
|
|
||||||
case .pdf: "application/pdf"
|
|
||||||
case .octetStream: "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaultExtension: String {
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg: "jpg"
|
|
||||||
case .png: "png"
|
|
||||||
case .webp: "webp"
|
|
||||||
case .gif: "gif"
|
|
||||||
case .mp4Audio, .m4a, .aac: "m4a"
|
|
||||||
case .mpeg, .mp3: "mp3"
|
|
||||||
case .wav, .xWav: "wav"
|
|
||||||
case .ogg: "ogg"
|
|
||||||
case .pdf: "pdf"
|
|
||||||
case .octetStream: "bin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static var allowed: Set<MimeType> = [
|
|
||||||
.jpeg, .jpg, .png, .gif, .webp,
|
|
||||||
.mp4Audio, .m4a, .aac, .mpeg, .mp3,
|
|
||||||
.wav, .xWav, .ogg,
|
|
||||||
.pdf, .octetStream
|
|
||||||
]
|
|
||||||
|
|
||||||
var isAllowed: Bool {
|
|
||||||
Self.allowed.contains(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Byte signature validation
|
|
||||||
func matches(data: Data) -> Bool {
|
|
||||||
guard !data.isEmpty else { return false }
|
|
||||||
|
|
||||||
// Generic type → skip validation
|
|
||||||
if self == .octetStream { return true }
|
|
||||||
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg:
|
|
||||||
return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
|
|
||||||
|
|
||||||
case .png:
|
|
||||||
return data.count >= 8 &&
|
|
||||||
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
|
|
||||||
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A
|
|
||||||
|
|
||||||
case .gif:
|
|
||||||
return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
|
|
||||||
data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61
|
|
||||||
|
|
||||||
case .webp:
|
|
||||||
return data.count >= 12 &&
|
|
||||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
|
||||||
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50
|
|
||||||
|
|
||||||
case .m4a, .mp4Audio, .aac:
|
|
||||||
// AVAudioRecorder output varies by platform - be lenient
|
|
||||||
// Security: size already capped + sandboxed execution
|
|
||||||
return data.count > 100
|
|
||||||
|
|
||||||
case .mpeg, .mp3:
|
|
||||||
if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
|
|
||||||
return true // ID3 header
|
|
||||||
}
|
|
||||||
return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0
|
|
||||||
|
|
||||||
case .wav, .xWav:
|
|
||||||
return data.count >= 12 &&
|
|
||||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
|
||||||
data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45
|
|
||||||
|
|
||||||
case .ogg:
|
|
||||||
return data.count >= 4 &&
|
|
||||||
data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53
|
|
||||||
|
|
||||||
case .pdf:
|
|
||||||
return data.count >= 4 &&
|
|
||||||
data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46
|
|
||||||
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience Initializers
|
|
||||||
|
|
||||||
init?(_ mimeString: String?) {
|
|
||||||
guard let mimeString else { return nil }
|
|
||||||
|
|
||||||
let normalized = mimeString.lowercased()
|
|
||||||
|
|
||||||
// Direct match with our canonical list
|
|
||||||
if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {
|
|
||||||
self = match
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc.
|
|
||||||
if let type = UTType(mimeType: normalized),
|
|
||||||
let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {
|
|
||||||
self = match
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MimeType {
|
|
||||||
enum Category: String {
|
|
||||||
case audio, image, file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,66 +15,18 @@ enum CommandResult {
|
|||||||
case handled // Command handled, no message needed
|
case handled // Command handled, no message needed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple struct for geo participant info used by CommandProcessor
|
|
||||||
struct CommandGeoParticipant {
|
|
||||||
let id: String // pubkey hex (lowercased)
|
|
||||||
let displayName: String
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Protocol defining what CommandProcessor needs from its context.
|
|
||||||
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
|
||||||
@MainActor
|
|
||||||
protocol CommandContextProvider: AnyObject {
|
|
||||||
// MARK: - State Properties
|
|
||||||
var nickname: String { get }
|
|
||||||
var selectedPrivateChatPeer: PeerID? { get }
|
|
||||||
var blockedUsers: Set<String> { get }
|
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
|
||||||
var idBridge: NostrIdentityBridge { get }
|
|
||||||
|
|
||||||
// MARK: - Peer Lookup
|
|
||||||
func getPeerIDForNickname(_ nickname: String) -> PeerID?
|
|
||||||
func getVisibleGeoParticipants() -> [CommandGeoParticipant]
|
|
||||||
func nostrPubkeyForDisplayName(_ displayName: String) -> String?
|
|
||||||
|
|
||||||
// MARK: - Chat Actions
|
|
||||||
func startPrivateChat(with peerID: PeerID)
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID)
|
|
||||||
func clearCurrentPublicTimeline()
|
|
||||||
func sendPublicRaw(_ content: String)
|
|
||||||
|
|
||||||
// MARK: - System Messages
|
|
||||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
|
||||||
func addPublicSystemMessage(_ content: String)
|
|
||||||
|
|
||||||
// MARK: - Favorites
|
|
||||||
func toggleFavorite(peerID: PeerID)
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Processes chat commands in a focused, efficient way
|
/// Processes chat commands in a focused, efficient way
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CommandProcessor {
|
final class CommandProcessor {
|
||||||
weak var contextProvider: CommandContextProvider?
|
weak var chatViewModel: ChatViewModel?
|
||||||
weak var meshService: Transport?
|
weak var meshService: Transport?
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
|
||||||
/// Backward-compatible property for existing code
|
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||||
weak var chatViewModel: CommandContextProvider? {
|
self.chatViewModel = chatViewModel
|
||||||
get { contextProvider }
|
|
||||||
set { contextProvider = newValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
|
||||||
self.contextProvider = contextProvider
|
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backward-compatible initializer
|
|
||||||
convenience init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
|
||||||
self.init(contextProvider: chatViewModel, meshService: meshService, identityManager: identityManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process a command string
|
/// Process a command string
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -90,7 +42,7 @@ final class CommandProcessor {
|
|||||||
case .location: return true
|
case .location: return true
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let inGeoDM = contextProvider?.selectedPrivateChatPeer?.isGeoDM == true
|
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
|
||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "/m", "/msg":
|
case "/m", "/msg":
|
||||||
@@ -113,11 +65,14 @@ 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 {
|
||||||
@@ -129,15 +84,15 @@ final class CommandProcessor {
|
|||||||
let targetName = String(parts[0])
|
let targetName = String(parts[0])
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname) else {
|
||||||
return .error(message: "'\(nickname)' not found")
|
return .error(message: "'\(nickname)' not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
contextProvider?.startPrivateChat(with: peerID)
|
chatViewModel?.startPrivateChat(with: peerID)
|
||||||
|
|
||||||
if parts.count > 1 {
|
if parts.count > 1 {
|
||||||
let message = String(parts[1])
|
let message = String(parts[1])
|
||||||
contextProvider?.sendPrivateMessage(message, to: peerID)
|
chatViewModel?.sendPrivateMessage(message, to: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return .success(message: "started private chat with \(nickname)")
|
return .success(message: "started private chat with \(nickname)")
|
||||||
@@ -148,9 +103,9 @@ final class CommandProcessor {
|
|||||||
switch LocationChannelManager.shared.selectedChannel {
|
switch LocationChannelManager.shared.selectedChannel {
|
||||||
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 = contextProvider else { return .success(message: "nobody around") }
|
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||||
let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||||
let people = vm.getVisibleGeoParticipants().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
|
||||||
}
|
}
|
||||||
@@ -168,10 +123,10 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleClear() -> CommandResult {
|
private func handleClear() -> CommandResult {
|
||||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
if let peerID = chatViewModel?.selectedPrivateChatPeer {
|
||||||
contextProvider?.privateChats[peerID]?.removeAll()
|
chatViewModel?.privateChats[peerID]?.removeAll()
|
||||||
} else {
|
} else {
|
||||||
contextProvider?.clearCurrentPublicTimeline()
|
chatViewModel?.clearCurrentPublicTimeline()
|
||||||
}
|
}
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
@@ -184,18 +139,18 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname),
|
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let myNickname = contextProvider?.nickname else {
|
let myNickname = chatViewModel?.nickname else {
|
||||||
return .error(message: "cannot \(command) \(nickname): not found")
|
return .error(message: "cannot \(command) \(nickname): not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
|
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
|
||||||
|
|
||||||
if contextProvider?.selectedPrivateChatPeer != nil {
|
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||||
// In private chat
|
// In private chat
|
||||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
if let peerNickname = meshService?.peerNickname(peerID: PeerID(str: targetPeerID)) {
|
||||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
meshService?.sendPrivateMessage(personalMessage, to: PeerID(str: 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
|
||||||
@@ -207,13 +162,13 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)"
|
let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)"
|
||||||
contextProvider?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// In public chat: send to active public channel (mesh or geohash)
|
// In public chat: send to active public channel (mesh or geohash)
|
||||||
contextProvider?.sendPublicRaw(emoteContent)
|
chatViewModel?.sendPublicRaw(emoteContent)
|
||||||
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
||||||
contextProvider?.addPublicSystemMessage(publicEcho)
|
chatViewModel?.addPublicSystemMessage(publicEcho)
|
||||||
}
|
}
|
||||||
|
|
||||||
return .handled
|
return .handled
|
||||||
@@ -224,7 +179,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
if targetName.isEmpty {
|
if targetName.isEmpty {
|
||||||
// List blocked users (mesh) and geohash (Nostr) blocks
|
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||||
let meshBlocked = contextProvider?.blockedUsers ?? []
|
let meshBlocked = chatViewModel?.blockedUsers ?? []
|
||||||
var blockedNicknames: [String] = []
|
var blockedNicknames: [String] = []
|
||||||
if let peers = meshService?.getPeerNicknames() {
|
if let peers = meshService?.getPeerNicknames() {
|
||||||
for (peerID, nickname) in peers {
|
for (peerID, nickname) in peers {
|
||||||
@@ -238,8 +193,8 @@ final class CommandProcessor {
|
|||||||
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
||||||
let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
|
let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
|
||||||
var geoNames: [String] = []
|
var geoNames: [String] = []
|
||||||
if let vm = contextProvider {
|
if let vm = chatViewModel {
|
||||||
let visible = vm.getVisibleGeoParticipants()
|
let visible = vm.visibleGeohashPeople()
|
||||||
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
||||||
for pk in geoBlocked {
|
for pk in geoBlocked {
|
||||||
if let name = visibleIndex[pk.lowercased()] {
|
if let name = visibleIndex[pk.lowercased()] {
|
||||||
@@ -258,8 +213,8 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
let fingerprint = meshService?.getFingerprint(for: PeerID(str: 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")
|
||||||
}
|
}
|
||||||
@@ -283,7 +238,7 @@ final class CommandProcessor {
|
|||||||
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
||||||
}
|
}
|
||||||
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
||||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||||
return .success(message: "\(nickname) is already blocked")
|
return .success(message: "\(nickname) is already blocked")
|
||||||
}
|
}
|
||||||
@@ -302,8 +257,8 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
let fingerprint = meshService?.getFingerprint(for: PeerID(str: 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")
|
||||||
}
|
}
|
||||||
@@ -311,7 +266,7 @@ final class CommandProcessor {
|
|||||||
return .success(message: "unblocked \(nickname)")
|
return .success(message: "unblocked \(nickname)")
|
||||||
}
|
}
|
||||||
// Try geohash unblock
|
// Try geohash unblock
|
||||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||||
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||||
return .success(message: "\(nickname) is not blocked")
|
return .success(message: "\(nickname) is not blocked")
|
||||||
}
|
}
|
||||||
@@ -329,8 +284,8 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
let noisePublicKey = Data(hexString: peerID) else {
|
||||||
return .error(message: "can't find peer: \(nickname)")
|
return .error(message: "can't find peer: \(nickname)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,18 +297,33 @@ final class CommandProcessor {
|
|||||||
peerNickname: nickname
|
peerNickname: nickname
|
||||||
)
|
)
|
||||||
|
|
||||||
contextProvider?.toggleFavorite(peerID: peerID)
|
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||||
|
|
||||||
return .success(message: "added \(nickname) to favorites")
|
return .success(message: "added \(nickname) to favorites")
|
||||||
} else {
|
} else {
|
||||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||||
|
|
||||||
contextProvider?.toggleFavorite(peerID: peerID)
|
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||||
|
|
||||||
return .success(message: "removed \(nickname) from favorites")
|
return .success(message: "removed \(nickname) from favorites")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleHelp() -> CommandResult {
|
||||||
|
let helpText = """
|
||||||
|
commands:
|
||||||
|
/msg @name - start private chat
|
||||||
|
/who - list who's online
|
||||||
|
/clear - clear messages
|
||||||
|
/hug @name - send a hug
|
||||||
|
/slap @name - slap with a trout
|
||||||
|
/fav @name - add to favorites
|
||||||
|
/unfav @name - remove from favorites
|
||||||
|
/block @name - block
|
||||||
|
/unblock @name - unblock
|
||||||
|
"""
|
||||||
|
return .success(message: helpText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
import CoreLocation
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||||
|
/// - Persistence: UserDefaults (JSON string array)
|
||||||
|
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
||||||
|
final class GeohashBookmarksStore: ObservableObject {
|
||||||
|
static let shared = GeohashBookmarksStore()
|
||||||
|
|
||||||
|
@Published private(set) var bookmarks: [String] = []
|
||||||
|
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
|
||||||
|
|
||||||
|
private let storeKey = "locationChannel.bookmarks"
|
||||||
|
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||||
|
private var membership: Set<String> = []
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
private let geocoder = CLGeocoder()
|
||||||
|
private var resolving: Set<String> = []
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private let storage: UserDefaults
|
||||||
|
|
||||||
|
init(storage: UserDefaults = .standard) {
|
||||||
|
self.storage = storage
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
func isBookmarked(_ geohash: String) -> Bool {
|
||||||
|
return membership.contains(Self.normalize(geohash))
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggle(_ geohash: String) {
|
||||||
|
let gh = Self.normalize(geohash)
|
||||||
|
if membership.contains(gh) {
|
||||||
|
remove(gh)
|
||||||
|
} else {
|
||||||
|
add(gh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ geohash: String) {
|
||||||
|
let gh = Self.normalize(geohash)
|
||||||
|
guard !gh.isEmpty else { return }
|
||||||
|
guard !membership.contains(gh) else { return }
|
||||||
|
bookmarks.insert(gh, at: 0)
|
||||||
|
membership.insert(gh)
|
||||||
|
persist()
|
||||||
|
// Resolve and persist a friendly name once when added
|
||||||
|
resolveNameIfNeeded(for: gh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ geohash: String) {
|
||||||
|
let gh = Self.normalize(geohash)
|
||||||
|
guard membership.contains(gh) else { return }
|
||||||
|
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
|
||||||
|
membership.remove(gh)
|
||||||
|
// Clean up stored name to avoid stale cache growth
|
||||||
|
if bookmarkNames.removeValue(forKey: gh) != nil {
|
||||||
|
persistNames()
|
||||||
|
}
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
private func load() {
|
||||||
|
guard let data = storage.data(forKey: storeKey) else { return }
|
||||||
|
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
|
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||||
|
var seen = Set<String>()
|
||||||
|
var list: [String] = []
|
||||||
|
for raw in arr {
|
||||||
|
let gh = Self.normalize(raw)
|
||||||
|
guard !gh.isEmpty else { continue }
|
||||||
|
if !seen.contains(gh) {
|
||||||
|
seen.insert(gh)
|
||||||
|
list.append(gh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bookmarks = list
|
||||||
|
membership = seen
|
||||||
|
}
|
||||||
|
// Load any saved names
|
||||||
|
if let namesData = storage.data(forKey: namesStoreKey),
|
||||||
|
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||||
|
bookmarkNames = dict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist() {
|
||||||
|
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||||
|
storage.set(data, forKey: storeKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistNames() {
|
||||||
|
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||||
|
storage.set(data, forKey: namesStoreKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
private static func normalize(_ s: String) -> String {
|
||||||
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
return s
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
.replacingOccurrences(of: "#", with: "")
|
||||||
|
.filter { allowed.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Name Resolution
|
||||||
|
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
|
||||||
|
func resolveNameIfNeeded(for geohash: String) {
|
||||||
|
let gh = Self.normalize(geohash)
|
||||||
|
guard !gh.isEmpty else { return }
|
||||||
|
if bookmarkNames[gh] != nil { return }
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
if resolving.contains(gh) { return }
|
||||||
|
resolving.insert(gh)
|
||||||
|
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
||||||
|
if gh.count <= 2 {
|
||||||
|
let b = Geohash.decodeBounds(gh)
|
||||||
|
let pts: [CLLocation] = [
|
||||||
|
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
|
||||||
|
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
||||||
|
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
||||||
|
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
||||||
|
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
||||||
|
]
|
||||||
|
resolveCompositeAdminName(geohash: gh, points: pts)
|
||||||
|
} else {
|
||||||
|
let center = Geohash.decodeCenter(gh)
|
||||||
|
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
||||||
|
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
defer { self.resolving.remove(gh) }
|
||||||
|
if let pm = placemarks?.first {
|
||||||
|
let name = Self.nameForGeohashLength(gh.count, from: pm)
|
||||||
|
if let name = name, !name.isEmpty {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.bookmarkNames[gh] = name
|
||||||
|
self.persistNames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||||
|
var uniqueAdmins = OrderedSet<String>()
|
||||||
|
var idx = 0
|
||||||
|
func step() {
|
||||||
|
if idx >= points.count {
|
||||||
|
// Compose up to 2 names joined by ' and '
|
||||||
|
let finalName: String? = {
|
||||||
|
let names = uniqueAdmins.array
|
||||||
|
if names.count >= 2 { return names[0] + " and " + names[1] }
|
||||||
|
return names.first
|
||||||
|
}()
|
||||||
|
if let finalName = finalName, !finalName.isEmpty {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.bookmarkNames[gh] = finalName
|
||||||
|
self.persistNames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.resolving.remove(gh)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let loc = points[idx]
|
||||||
|
idx += 1
|
||||||
|
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||||
|
guard self != nil else { return }
|
||||||
|
if let pm = placemarks?.first {
|
||||||
|
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||||
|
uniqueAdmins.insert(admin)
|
||||||
|
} else if let country = pm.country, !country.isEmpty {
|
||||||
|
uniqueAdmins.insert(country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Proceed to next point
|
||||||
|
step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal ordered-set for stable joining
|
||||||
|
private struct OrderedSet<Element: Hashable> {
|
||||||
|
private var set: Set<Element> = []
|
||||||
|
private(set) var array: [Element] = []
|
||||||
|
mutating func insert(_ element: Element) {
|
||||||
|
if set.insert(element).inserted { array.append(element) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
||||||
|
switch len {
|
||||||
|
case 0...2:
|
||||||
|
// Prefer administrative area if available at this coarse level
|
||||||
|
return pm.administrativeArea ?? pm.country
|
||||||
|
case 3...4:
|
||||||
|
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
||||||
|
case 5:
|
||||||
|
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
||||||
|
case 6...7:
|
||||||
|
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
||||||
|
default:
|
||||||
|
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Testing-only reset helper
|
||||||
|
func _resetForTesting() {
|
||||||
|
bookmarks.removeAll()
|
||||||
|
membership.removeAll()
|
||||||
|
bookmarkNames.removeAll()
|
||||||
|
persist()
|
||||||
|
persistNames()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
//
|
|
||||||
// GeohashParticipantTracker.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Tracks participants in geohash-based location channels.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Represents a participant in a geohash channel
|
|
||||||
public struct GeoPerson: Identifiable, Equatable, Sendable {
|
|
||||||
public let id: String // pubkey hex (lowercased)
|
|
||||||
public let displayName: String
|
|
||||||
public let lastSeen: Date
|
|
||||||
|
|
||||||
public init(id: String, displayName: String, lastSeen: Date) {
|
|
||||||
self.id = id
|
|
||||||
self.displayName = displayName
|
|
||||||
self.lastSeen = lastSeen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Protocol for resolving display names and checking block status
|
|
||||||
@MainActor
|
|
||||||
public protocol GeohashParticipantContext: AnyObject {
|
|
||||||
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
|
|
||||||
func displayNameForPubkey(_ pubkeyHex: String) -> String
|
|
||||||
/// Returns true if the pubkey is blocked
|
|
||||||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tracks participants across multiple geohash channels
|
|
||||||
@MainActor
|
|
||||||
public final class GeohashParticipantTracker: ObservableObject {
|
|
||||||
|
|
||||||
/// Activity cutoff duration (defaults to 5 minutes)
|
|
||||||
public let activityCutoff: TimeInterval
|
|
||||||
|
|
||||||
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
|
|
||||||
private var participants: [String: [String: Date]] = [:]
|
|
||||||
|
|
||||||
/// Currently visible people for the active geohash
|
|
||||||
@Published public private(set) var visiblePeople: [GeoPerson] = []
|
|
||||||
|
|
||||||
/// The currently active geohash (if any)
|
|
||||||
private var activeGeohash: String?
|
|
||||||
|
|
||||||
/// Context for display name resolution and block checking
|
|
||||||
private weak var context: GeohashParticipantContext?
|
|
||||||
|
|
||||||
/// Timer for periodic refresh
|
|
||||||
private var refreshTimer: Timer?
|
|
||||||
|
|
||||||
public init(activityCutoff: TimeInterval = -300) { // default 5 minutes
|
|
||||||
self.activityCutoff = activityCutoff
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configure with a context provider
|
|
||||||
public func configure(context: GeohashParticipantContext) {
|
|
||||||
self.context = context
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the currently active geohash
|
|
||||||
public func setActiveGeohash(_ geohash: String?) {
|
|
||||||
activeGeohash = geohash
|
|
||||||
if geohash == nil {
|
|
||||||
visiblePeople = []
|
|
||||||
} else {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record activity from a participant in the current active geohash
|
|
||||||
public func recordParticipant(pubkeyHex: String) {
|
|
||||||
guard let gh = activeGeohash else { return }
|
|
||||||
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record activity from a participant in a specific geohash
|
|
||||||
public func recordParticipant(pubkeyHex: String, geohash: String) {
|
|
||||||
let key = pubkeyHex.lowercased()
|
|
||||||
var map = participants[geohash] ?? [:]
|
|
||||||
map[key] = Date()
|
|
||||||
participants[geohash] = map
|
|
||||||
|
|
||||||
// Only refresh visible list if this geohash is currently active
|
|
||||||
if activeGeohash == geohash {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a participant from all geohashes (used when blocking)
|
|
||||||
public func removeParticipant(pubkeyHex: String) {
|
|
||||||
let key = pubkeyHex.lowercased()
|
|
||||||
for (gh, var map) in participants {
|
|
||||||
map.removeValue(forKey: key)
|
|
||||||
participants[gh] = map
|
|
||||||
}
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get participant count for a specific geohash
|
|
||||||
public func participantCount(for geohash: String) -> Int {
|
|
||||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
|
||||||
let map = participants[geohash] ?? [:]
|
|
||||||
return map.values.filter { $0 >= cutoff }.count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the visible people list for the active geohash (read-only query)
|
|
||||||
public func getVisiblePeople() -> [GeoPerson] {
|
|
||||||
guard let gh = activeGeohash, let context = context else { return [] }
|
|
||||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
|
||||||
let map = (participants[gh] ?? [:])
|
|
||||||
.filter { $0.value >= cutoff }
|
|
||||||
.filter { !context.isBlocked($0.key) }
|
|
||||||
|
|
||||||
return map
|
|
||||||
.map { (pub, seen) in
|
|
||||||
GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen)
|
|
||||||
}
|
|
||||||
.sorted { $0.lastSeen > $1.lastSeen }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh the visible people list
|
|
||||||
public func refresh() {
|
|
||||||
visiblePeople = getVisiblePeople()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the periodic refresh timer
|
|
||||||
public func startRefreshTimer(interval: TimeInterval = 30.0) {
|
|
||||||
stopRefreshTimer()
|
|
||||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop the periodic refresh timer
|
|
||||||
public func stopRefreshTimer() {
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
refreshTimer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all participant data
|
|
||||||
public func clear() {
|
|
||||||
participants.removeAll()
|
|
||||||
visiblePeople = []
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear participant data for a specific geohash
|
|
||||||
public func clear(geohash: String) {
|
|
||||||
participants.removeValue(forKey: geohash)
|
|
||||||
if activeGeohash == geohash {
|
|
||||||
visiblePeople = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,6 +27,34 @@ 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 {
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||||
|
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
|
||||||
|
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
||||||
|
static let shared = LocationChannelManager()
|
||||||
|
|
||||||
|
enum PermissionState: Equatable {
|
||||||
|
case notDetermined
|
||||||
|
case denied
|
||||||
|
case restricted
|
||||||
|
case authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
private let cl = CLLocationManager()
|
||||||
|
private let geocoder = CLGeocoder()
|
||||||
|
private var lastLocation: CLLocation?
|
||||||
|
private var refreshTimer: Timer?
|
||||||
|
private let userDefaultsKey = "locationChannel.selected"
|
||||||
|
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
||||||
|
private var isGeocoding: Bool = false
|
||||||
|
|
||||||
|
// Published state for UI bindings
|
||||||
|
@Published private(set) var permissionState: PermissionState = .notDetermined
|
||||||
|
@Published private(set) var availableChannels: [GeohashChannel] = []
|
||||||
|
@Published private(set) var selectedChannel: ChannelID = .mesh
|
||||||
|
// True when the current location channel was selected via manual teleport
|
||||||
|
@Published var teleported: Bool = false
|
||||||
|
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
||||||
|
|
||||||
|
// Persisted set of geohashes that were selected via teleport
|
||||||
|
private var teleportedSet: Set<String> = []
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
cl.delegate = self
|
||||||
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
|
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
|
||||||
|
// Load selection
|
||||||
|
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||||
|
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
||||||
|
selectedChannel = channel
|
||||||
|
}
|
||||||
|
// Load persisted teleported set
|
||||||
|
if let data = UserDefaults.standard.data(forKey: teleportedStoreKey),
|
||||||
|
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
|
teleportedSet = Set(arr)
|
||||||
|
}
|
||||||
|
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
||||||
|
// This avoids showing teleported for in-region channels during cold start.
|
||||||
|
let status: CLAuthorizationStatus
|
||||||
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
|
status = cl.authorizationStatus
|
||||||
|
} else {
|
||||||
|
status = CLLocationManager.authorizationStatus()
|
||||||
|
}
|
||||||
|
updatePermissionState(from: status)
|
||||||
|
// If we don't have location authorization at startup, fall back to persisted teleport state
|
||||||
|
switch status {
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
|
break // will compute from location
|
||||||
|
default:
|
||||||
|
if case .location(let ch) = selectedChannel {
|
||||||
|
teleported = teleportedSet.contains(ch.geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
func enableLocationChannels() {
|
||||||
|
let status: CLAuthorizationStatus
|
||||||
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
|
status = cl.authorizationStatus
|
||||||
|
} else {
|
||||||
|
status = CLLocationManager.authorizationStatus()
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case .notDetermined:
|
||||||
|
cl.requestWhenInUseAuthorization()
|
||||||
|
case .restricted:
|
||||||
|
Task { @MainActor in self.permissionState = .restricted }
|
||||||
|
case .denied:
|
||||||
|
Task { @MainActor in self.permissionState = .denied }
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
|
Task { @MainActor in self.permissionState = .authorized }
|
||||||
|
requestOneShotLocation()
|
||||||
|
@unknown default:
|
||||||
|
Task { @MainActor in self.permissionState = .restricted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChannels() {
|
||||||
|
if permissionState == .authorized {
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
|
||||||
|
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
|
||||||
|
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||||
|
guard permissionState == .authorized else { return }
|
||||||
|
// Stop any previous polling timer
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
refreshTimer = nil
|
||||||
|
// Tighten accuracy and distance filter for live view
|
||||||
|
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||||
|
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
||||||
|
// Start continuous updates
|
||||||
|
cl.startUpdatingLocation()
|
||||||
|
// Request an immediate fix to populate UI without waiting for movement
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop continuous refreshes when selector UI is dismissed.
|
||||||
|
func endLiveRefresh() {
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
refreshTimer = nil
|
||||||
|
cl.stopUpdatingLocation()
|
||||||
|
// Restore more relaxed defaults for background/idle state
|
||||||
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
|
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(_ channel: ChannelID) {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.selectedChannel = channel
|
||||||
|
if let data = try? JSONEncoder().encode(channel) {
|
||||||
|
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
|
||||||
|
}
|
||||||
|
// Update teleported flag based on persisted state for immediate UI behavior
|
||||||
|
switch channel {
|
||||||
|
case .mesh:
|
||||||
|
self.teleported = false
|
||||||
|
case .location(let ch):
|
||||||
|
// If this geohash is in our current regional set, do NOT mark teleported.
|
||||||
|
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
||||||
|
if inRegional {
|
||||||
|
self.teleported = false
|
||||||
|
// Clear persisted teleport for this geohash to keep future selections clean
|
||||||
|
if self.teleportedSet.contains(ch.geohash) {
|
||||||
|
self.teleportedSet.remove(ch.geohash)
|
||||||
|
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||||
|
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fall back to persisted mark (set by deep link or manual teleport)
|
||||||
|
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
|
||||||
|
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||||
|
if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) }
|
||||||
|
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
|
||||||
|
UserDefaults.standard.set(data, forKey: teleportedStoreKey)
|
||||||
|
}
|
||||||
|
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
|
||||||
|
Task { @MainActor in self.teleported = flag }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CoreLocation
|
||||||
|
private func requestOneShotLocation() {
|
||||||
|
cl.requestLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS < 14
|
||||||
|
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||||||
|
updatePermissionState(from: status)
|
||||||
|
if case .authorized = permissionState {
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS 14+ / macOS 11+
|
||||||
|
@available(iOS 14.0, macOS 11.0, *)
|
||||||
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
|
updatePermissionState(from: manager.authorizationStatus)
|
||||||
|
if case .authorized = permissionState {
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
guard let loc = locations.last else { return }
|
||||||
|
lastLocation = loc
|
||||||
|
computeChannels(from: loc.coordinate)
|
||||||
|
reverseGeocodeIfNeeded(location: loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
|
// Surface as denied/restricted if relevant; otherwise keep previous state
|
||||||
|
SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
private func updatePermissionState(from status: CLAuthorizationStatus) {
|
||||||
|
let newState: PermissionState
|
||||||
|
switch status {
|
||||||
|
case .notDetermined: newState = .notDetermined
|
||||||
|
case .restricted: newState = .restricted
|
||||||
|
case .denied: newState = .denied
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
||||||
|
@unknown default: newState = .restricted
|
||||||
|
}
|
||||||
|
Task { @MainActor in self.permissionState = newState }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeChannels(from coord: CLLocationCoordinate2D) {
|
||||||
|
let levels = GeohashChannelLevel.allCases
|
||||||
|
var result: [GeohashChannel] = []
|
||||||
|
for level in levels {
|
||||||
|
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
||||||
|
result.append(GeohashChannel(level: level, geohash: gh))
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
self.availableChannels = result
|
||||||
|
// Recompute teleported status based on whether the selected geohash is in our regional set
|
||||||
|
switch self.selectedChannel {
|
||||||
|
case .mesh:
|
||||||
|
self.teleported = false
|
||||||
|
case .location(let ch):
|
||||||
|
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
||||||
|
let inRegional = result.contains { $0.geohash == ch.geohash }
|
||||||
|
if inRegional {
|
||||||
|
self.teleported = false
|
||||||
|
// Clear persisted teleport flag if present
|
||||||
|
if self.teleportedSet.contains(ch.geohash) {
|
||||||
|
self.teleportedSet.remove(ch.geohash)
|
||||||
|
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||||
|
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.teleported = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reverseGeocodeIfNeeded(location: CLLocation) {
|
||||||
|
// Always cancel previous to keep latest fresh while user moves
|
||||||
|
geocoder.cancelGeocode()
|
||||||
|
isGeocoding = true
|
||||||
|
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.isGeocoding = false
|
||||||
|
if let pm = placemarks?.first {
|
||||||
|
let names = self.namesByLevel(from: pm)
|
||||||
|
Task { @MainActor in self.locationNames = names }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
|
||||||
|
var dict: [GeohashChannelLevel: String] = [:]
|
||||||
|
// Region (country)
|
||||||
|
if let country = pm.country, !country.isEmpty {
|
||||||
|
dict[.region] = country
|
||||||
|
}
|
||||||
|
// Province (state/province or county)
|
||||||
|
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||||
|
dict[.province] = admin
|
||||||
|
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||||
|
dict[.province] = subAdmin
|
||||||
|
}
|
||||||
|
// City (locality)
|
||||||
|
if let locality = pm.locality, !locality.isEmpty {
|
||||||
|
dict[.city] = locality
|
||||||
|
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||||
|
dict[.city] = subAdmin
|
||||||
|
} else if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||||
|
dict[.city] = admin
|
||||||
|
}
|
||||||
|
// Neighborhood
|
||||||
|
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||||
|
dict[.neighborhood] = subLocality
|
||||||
|
} else if let locality = pm.locality, !locality.isEmpty {
|
||||||
|
dict[.neighborhood] = locality
|
||||||
|
}
|
||||||
|
// Block: reuse neighborhood/locality granularity
|
||||||
|
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||||
|
dict[.block] = subLocality
|
||||||
|
} else if let locality = pm.locality, !locality.isEmpty {
|
||||||
|
dict[.block] = locality
|
||||||
|
}
|
||||||
|
// Building: prefer place name/street/venue when available
|
||||||
|
if let name = pm.name, !name.isEmpty {
|
||||||
|
dict[.building] = name
|
||||||
|
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
||||||
|
dict[.building] = thoroughfare
|
||||||
|
}
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LocationNotesCounterDependencies {
|
||||||
|
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
|
||||||
|
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
|
||||||
|
typealias Unsubscribe = @MainActor (_ id: String) -> Void
|
||||||
|
|
||||||
|
var relayLookup: RelayLookup
|
||||||
|
var subscribe: Subscribe
|
||||||
|
var unsubscribe: Unsubscribe
|
||||||
|
|
||||||
|
static let live = LocationNotesCounterDependencies(
|
||||||
|
relayLookup: { geohash, count in
|
||||||
|
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||||
|
},
|
||||||
|
subscribe: { filter, id, relays, handler, onEOSE in
|
||||||
|
NostrRelayManager.shared.subscribe(
|
||||||
|
filter: filter,
|
||||||
|
id: id,
|
||||||
|
relayUrls: relays,
|
||||||
|
handler: handler,
|
||||||
|
onEOSE: onEOSE
|
||||||
|
)
|
||||||
|
},
|
||||||
|
unsubscribe: { id in
|
||||||
|
NostrRelayManager.shared.unsubscribe(id: id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
|
||||||
|
@MainActor
|
||||||
|
final class LocationNotesCounter: ObservableObject {
|
||||||
|
static let shared = LocationNotesCounter()
|
||||||
|
|
||||||
|
@Published private(set) var geohash: String? = nil
|
||||||
|
@Published private(set) var count: Int? = 0
|
||||||
|
@Published private(set) var initialLoadComplete: Bool = false
|
||||||
|
@Published private(set) var relayAvailable: Bool = true
|
||||||
|
|
||||||
|
private var subscriptionID: String? = nil
|
||||||
|
private var noteIDs = Set<String>()
|
||||||
|
private let dependencies: LocationNotesCounterDependencies
|
||||||
|
|
||||||
|
private init(dependencies: LocationNotesCounterDependencies = .live) {
|
||||||
|
self.dependencies = dependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
init(testDependencies: LocationNotesCounterDependencies) {
|
||||||
|
self.dependencies = testDependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscribe(geohash gh: String) {
|
||||||
|
let norm = gh.lowercased()
|
||||||
|
if geohash == norm, subscriptionID != nil { return }
|
||||||
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
|
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||||
|
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Unsubscribe previous without clearing count to avoid flicker
|
||||||
|
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||||
|
subscriptionID = nil
|
||||||
|
geohash = norm
|
||||||
|
noteIDs.removeAll()
|
||||||
|
initialLoadComplete = false
|
||||||
|
relayAvailable = true
|
||||||
|
|
||||||
|
// Subscribe only to the building geohash (precision 8)
|
||||||
|
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
||||||
|
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
|
||||||
|
guard !relays.isEmpty else {
|
||||||
|
relayAvailable = false
|
||||||
|
initialLoadComplete = true
|
||||||
|
count = 0
|
||||||
|
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionID = subID
|
||||||
|
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
|
||||||
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
|
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
|
||||||
|
if !self.noteIDs.contains(event.id) {
|
||||||
|
self.noteIDs.insert(event.id)
|
||||||
|
self.count = self.noteIDs.count
|
||||||
|
}
|
||||||
|
}, { [weak self] in
|
||||||
|
self?.initialLoadComplete = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||||
|
subscriptionID = nil
|
||||||
|
geohash = nil
|
||||||
|
count = 0
|
||||||
|
noteIDs.removeAll()
|
||||||
|
relayAvailable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,22 +163,14 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
|
|
||||||
subscriptionID = subID
|
subscriptionID = subID
|
||||||
initialLoadComplete = false
|
initialLoadComplete = false
|
||||||
|
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
||||||
// Subscribe to center + 8 neighbors (± 1 grid)
|
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
||||||
let neighbors = Geohash.neighbors(of: geohash)
|
|
||||||
let allGeohashes = [geohash] + neighbors
|
|
||||||
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
|
|
||||||
|
|
||||||
// Build a set of valid geohashes for tag matching (includes all 9 cells)
|
|
||||||
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
|
|
||||||
|
|
||||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
// Ensure matching tag - accept any of our 9 geohashes
|
// Ensure matching tag
|
||||||
guard event.tags.contains(where: { tag in
|
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
||||||
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
|
||||||
}) else { return }
|
|
||||||
guard !self.noteIDs.contains(event.id) else { return }
|
guard !self.noteIDs.contains(event.id) else { return }
|
||||||
self.noteIDs.insert(event.id)
|
self.noteIDs.insert(event.id)
|
||||||
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||||
|
|||||||
@@ -1,545 +0,0 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
import CoreLocation
|
|
||||||
|
|
||||||
/// Unified manager for location-based channel state including:
|
|
||||||
/// - CoreLocation permissions and one-shot location retrieval
|
|
||||||
/// - Geohash channel computation from coordinates
|
|
||||||
/// - Channel selection and teleport state
|
|
||||||
/// - Bookmark persistence and friendly name resolution
|
|
||||||
///
|
|
||||||
/// Consolidates LocationChannelManager + GeohashBookmarksStore into a single source of truth.
|
|
||||||
final class LocationStateManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
|
||||||
static let shared = LocationStateManager()
|
|
||||||
|
|
||||||
// MARK: - Permission State
|
|
||||||
|
|
||||||
enum PermissionState: Equatable {
|
|
||||||
case notDetermined
|
|
||||||
case denied
|
|
||||||
case restricted
|
|
||||||
case authorized
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Properties (CoreLocation)
|
|
||||||
|
|
||||||
private let cl = CLLocationManager()
|
|
||||||
private let geocoder = CLGeocoder()
|
|
||||||
private var lastLocation: CLLocation?
|
|
||||||
private var refreshTimer: Timer?
|
|
||||||
private var isGeocoding: Bool = false
|
|
||||||
|
|
||||||
// MARK: - Persistence Keys
|
|
||||||
|
|
||||||
private let selectedChannelKey = "locationChannel.selected"
|
|
||||||
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
|
||||||
private let bookmarksKey = "locationChannel.bookmarks"
|
|
||||||
private let bookmarkNamesKey = "locationChannel.bookmarkNames"
|
|
||||||
|
|
||||||
// MARK: - Published State (Channel)
|
|
||||||
|
|
||||||
@Published private(set) var permissionState: PermissionState = .notDetermined
|
|
||||||
@Published private(set) var availableChannels: [GeohashChannel] = []
|
|
||||||
@Published private(set) var selectedChannel: ChannelID = .mesh
|
|
||||||
@Published var teleported: Bool = false
|
|
||||||
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
|
||||||
|
|
||||||
// MARK: - Published State (Bookmarks)
|
|
||||||
|
|
||||||
@Published private(set) var bookmarks: [String] = []
|
|
||||||
@Published private(set) var bookmarkNames: [String: String] = [:]
|
|
||||||
|
|
||||||
// MARK: - Private State
|
|
||||||
|
|
||||||
private var teleportedSet: Set<String> = []
|
|
||||||
private var bookmarkMembership: Set<String> = []
|
|
||||||
private var resolvingNames: Set<String> = []
|
|
||||||
private let storage: UserDefaults
|
|
||||||
|
|
||||||
/// Returns true if running in test environment
|
|
||||||
private static var isRunningTests: Bool {
|
|
||||||
let env = ProcessInfo.processInfo.environment
|
|
||||||
return NSClassFromString("XCTestCase") != nil ||
|
|
||||||
env["XCTestConfigurationFilePath"] != nil ||
|
|
||||||
env["XCTestBundlePath"] != nil ||
|
|
||||||
env["GITHUB_ACTIONS"] != nil ||
|
|
||||||
env["CI"] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
|
|
||||||
private override init() {
|
|
||||||
self.storage = .standard
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
// Skip CoreLocation setup in test environments
|
|
||||||
guard !Self.isRunningTests else {
|
|
||||||
loadPersistedState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cl.delegate = self
|
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
|
||||||
|
|
||||||
loadPersistedState()
|
|
||||||
initializePermissionState()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal initializer for testing with custom storage
|
|
||||||
init(storage: UserDefaults) {
|
|
||||||
self.storage = storage
|
|
||||||
super.init()
|
|
||||||
loadPersistedState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadPersistedState() {
|
|
||||||
// Load selected channel
|
|
||||||
if let data = storage.data(forKey: selectedChannelKey),
|
|
||||||
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
|
||||||
selectedChannel = channel
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load teleported set
|
|
||||||
if let data = storage.data(forKey: teleportedStoreKey),
|
|
||||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
|
||||||
teleportedSet = Set(arr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load bookmarks
|
|
||||||
if let data = storage.data(forKey: bookmarksKey),
|
|
||||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
|
||||||
var seen = Set<String>()
|
|
||||||
var list: [String] = []
|
|
||||||
for raw in arr {
|
|
||||||
let gh = Self.normalizeGeohash(raw)
|
|
||||||
guard !gh.isEmpty, !seen.contains(gh) else { continue }
|
|
||||||
seen.insert(gh)
|
|
||||||
list.append(gh)
|
|
||||||
}
|
|
||||||
bookmarks = list
|
|
||||||
bookmarkMembership = seen
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load bookmark names
|
|
||||||
if let data = storage.data(forKey: bookmarkNamesKey),
|
|
||||||
let dict = try? JSONDecoder().decode([String: String].self, from: data) {
|
|
||||||
bookmarkNames = dict
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initializePermissionState() {
|
|
||||||
let status: CLAuthorizationStatus
|
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
|
||||||
status = cl.authorizationStatus
|
|
||||||
} else {
|
|
||||||
status = CLLocationManager.authorizationStatus()
|
|
||||||
}
|
|
||||||
updatePermissionState(from: status)
|
|
||||||
|
|
||||||
// Fall back to persisted teleport state if no location authorization
|
|
||||||
switch status {
|
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
|
||||||
break
|
|
||||||
case .notDetermined, .restricted, .denied:
|
|
||||||
fallthrough
|
|
||||||
@unknown default:
|
|
||||||
if case .location(let ch) = selectedChannel {
|
|
||||||
teleported = teleportedSet.contains(ch.geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API (Permissions & Location)
|
|
||||||
|
|
||||||
func enableLocationChannels() {
|
|
||||||
let status: CLAuthorizationStatus
|
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
|
||||||
status = cl.authorizationStatus
|
|
||||||
} else {
|
|
||||||
status = CLLocationManager.authorizationStatus()
|
|
||||||
}
|
|
||||||
switch status {
|
|
||||||
case .notDetermined:
|
|
||||||
cl.requestWhenInUseAuthorization()
|
|
||||||
case .restricted:
|
|
||||||
Task { @MainActor in self.permissionState = .restricted }
|
|
||||||
case .denied:
|
|
||||||
Task { @MainActor in self.permissionState = .denied }
|
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
|
||||||
Task { @MainActor in self.permissionState = .authorized }
|
|
||||||
requestOneShotLocation()
|
|
||||||
@unknown default:
|
|
||||||
Task { @MainActor in self.permissionState = .restricted }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshChannels() {
|
|
||||||
if permissionState == .authorized {
|
|
||||||
requestOneShotLocation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
|
||||||
guard permissionState == .authorized else { return }
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
refreshTimer = nil
|
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
|
||||||
cl.startUpdatingLocation()
|
|
||||||
requestOneShotLocation()
|
|
||||||
}
|
|
||||||
|
|
||||||
func endLiveRefresh() {
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
refreshTimer = nil
|
|
||||||
cl.stopUpdatingLocation()
|
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API (Channel Selection)
|
|
||||||
|
|
||||||
func select(_ channel: ChannelID) {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.selectedChannel = channel
|
|
||||||
if let data = try? JSONEncoder().encode(channel) {
|
|
||||||
self.storage.set(data, forKey: self.selectedChannelKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
self.teleported = false
|
|
||||||
case .location(let ch):
|
|
||||||
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
|
||||||
if inRegional {
|
|
||||||
self.teleported = false
|
|
||||||
if self.teleportedSet.contains(ch.geohash) {
|
|
||||||
self.teleportedSet.remove(ch.geohash)
|
|
||||||
self.persistTeleportedSet()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func markTeleported(for geohash: String, _ flag: Bool) {
|
|
||||||
if flag {
|
|
||||||
teleportedSet.insert(geohash)
|
|
||||||
} else {
|
|
||||||
teleportedSet.remove(geohash)
|
|
||||||
}
|
|
||||||
persistTeleportedSet()
|
|
||||||
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
|
|
||||||
Task { @MainActor in self.teleported = flag }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API (Bookmarks)
|
|
||||||
|
|
||||||
func isBookmarked(_ geohash: String) -> Bool {
|
|
||||||
bookmarkMembership.contains(Self.normalizeGeohash(geohash))
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleBookmark(_ geohash: String) {
|
|
||||||
let gh = Self.normalizeGeohash(geohash)
|
|
||||||
if bookmarkMembership.contains(gh) {
|
|
||||||
removeBookmark(gh)
|
|
||||||
} else {
|
|
||||||
addBookmark(gh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addBookmark(_ geohash: String) {
|
|
||||||
let gh = Self.normalizeGeohash(geohash)
|
|
||||||
guard !gh.isEmpty, !bookmarkMembership.contains(gh) else { return }
|
|
||||||
bookmarks.insert(gh, at: 0)
|
|
||||||
bookmarkMembership.insert(gh)
|
|
||||||
persistBookmarks()
|
|
||||||
resolveBookmarkNameIfNeeded(for: gh)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeBookmark(_ geohash: String) {
|
|
||||||
let gh = Self.normalizeGeohash(geohash)
|
|
||||||
guard bookmarkMembership.contains(gh) else { return }
|
|
||||||
if let idx = bookmarks.firstIndex(of: gh) {
|
|
||||||
bookmarks.remove(at: idx)
|
|
||||||
}
|
|
||||||
bookmarkMembership.remove(gh)
|
|
||||||
if bookmarkNames.removeValue(forKey: gh) != nil {
|
|
||||||
persistBookmarkNames()
|
|
||||||
}
|
|
||||||
persistBookmarks()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CLLocationManagerDelegate
|
|
||||||
|
|
||||||
private func requestOneShotLocation() {
|
|
||||||
cl.requestLocation()
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
|
||||||
updatePermissionState(from: status)
|
|
||||||
if case .authorized = permissionState {
|
|
||||||
requestOneShotLocation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 14.0, macOS 11.0, *)
|
|
||||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
||||||
updatePermissionState(from: manager.authorizationStatus)
|
|
||||||
if case .authorized = permissionState {
|
|
||||||
requestOneShotLocation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
||||||
guard let loc = locations.last else { return }
|
|
||||||
lastLocation = loc
|
|
||||||
computeChannels(from: loc.coordinate)
|
|
||||||
reverseGeocodeLocation(loc)
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
||||||
SecureLogger.error("LocationStateManager: location error: \(error.localizedDescription)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers (Permission)
|
|
||||||
|
|
||||||
private func updatePermissionState(from status: CLAuthorizationStatus) {
|
|
||||||
let newState: PermissionState
|
|
||||||
switch status {
|
|
||||||
case .notDetermined: newState = .notDetermined
|
|
||||||
case .restricted: newState = .restricted
|
|
||||||
case .denied: newState = .denied
|
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
|
||||||
@unknown default: newState = .restricted
|
|
||||||
}
|
|
||||||
Task { @MainActor in self.permissionState = newState }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers (Channel Computation)
|
|
||||||
|
|
||||||
private func computeChannels(from coord: CLLocationCoordinate2D) {
|
|
||||||
let levels = GeohashChannelLevel.allCases
|
|
||||||
var result: [GeohashChannel] = []
|
|
||||||
for level in levels {
|
|
||||||
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
|
||||||
result.append(GeohashChannel(level: level, geohash: gh))
|
|
||||||
}
|
|
||||||
Task { @MainActor in
|
|
||||||
self.availableChannels = result
|
|
||||||
switch self.selectedChannel {
|
|
||||||
case .mesh:
|
|
||||||
self.teleported = false
|
|
||||||
case .location(let ch):
|
|
||||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
|
||||||
if inRegional {
|
|
||||||
self.teleported = false
|
|
||||||
if self.teleportedSet.contains(ch.geohash) {
|
|
||||||
self.teleportedSet.remove(ch.geohash)
|
|
||||||
self.persistTeleportedSet()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.teleported = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers (Geocoding)
|
|
||||||
|
|
||||||
private func reverseGeocodeLocation(_ location: CLLocation) {
|
|
||||||
geocoder.cancelGeocode()
|
|
||||||
isGeocoding = true
|
|
||||||
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.isGeocoding = false
|
|
||||||
if let pm = placemarks?.first {
|
|
||||||
let names = self.locationNamesByLevel(from: pm)
|
|
||||||
Task { @MainActor in self.locationNames = names }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func locationNamesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
|
|
||||||
var dict: [GeohashChannelLevel: String] = [:]
|
|
||||||
if let country = pm.country, !country.isEmpty {
|
|
||||||
dict[.region] = country
|
|
||||||
}
|
|
||||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
|
||||||
dict[.province] = admin
|
|
||||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
|
||||||
dict[.province] = subAdmin
|
|
||||||
}
|
|
||||||
if let locality = pm.locality, !locality.isEmpty {
|
|
||||||
dict[.city] = locality
|
|
||||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
|
||||||
dict[.city] = subAdmin
|
|
||||||
} else if let admin = pm.administrativeArea, !admin.isEmpty {
|
|
||||||
dict[.city] = admin
|
|
||||||
}
|
|
||||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
|
||||||
dict[.neighborhood] = subLocality
|
|
||||||
} else if let locality = pm.locality, !locality.isEmpty {
|
|
||||||
dict[.neighborhood] = locality
|
|
||||||
}
|
|
||||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
|
||||||
dict[.block] = subLocality
|
|
||||||
} else if let locality = pm.locality, !locality.isEmpty {
|
|
||||||
dict[.block] = locality
|
|
||||||
}
|
|
||||||
if let name = pm.name, !name.isEmpty {
|
|
||||||
dict[.building] = name
|
|
||||||
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
|
||||||
dict[.building] = thoroughfare
|
|
||||||
}
|
|
||||||
return dict
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveBookmarkNameIfNeeded(for geohash: String) {
|
|
||||||
let gh = Self.normalizeGeohash(geohash)
|
|
||||||
guard !gh.isEmpty, bookmarkNames[gh] == nil, !resolvingNames.contains(gh) else { return }
|
|
||||||
resolvingNames.insert(gh)
|
|
||||||
|
|
||||||
if gh.count <= 2 {
|
|
||||||
let b = Geohash.decodeBounds(gh)
|
|
||||||
let pts: [CLLocation] = [
|
|
||||||
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2),
|
|
||||||
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
|
||||||
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
|
||||||
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
|
||||||
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
|
||||||
]
|
|
||||||
resolveCompositeAdminName(geohash: gh, points: pts)
|
|
||||||
} else {
|
|
||||||
let center = Geohash.decodeCenter(gh)
|
|
||||||
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
|
||||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
defer { self.resolvingNames.remove(gh) }
|
|
||||||
if let pm = placemarks?.first,
|
|
||||||
let name = Self.nameForGeohashLength(gh.count, from: pm),
|
|
||||||
!name.isEmpty {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.bookmarkNames[gh] = name
|
|
||||||
self.persistBookmarkNames()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
|
||||||
var uniqueAdmins: [String] = []
|
|
||||||
var seenAdmins = Set<String>()
|
|
||||||
var idx = 0
|
|
||||||
|
|
||||||
func step() {
|
|
||||||
if idx >= points.count {
|
|
||||||
let finalName: String? = {
|
|
||||||
if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] }
|
|
||||||
return uniqueAdmins.first
|
|
||||||
}()
|
|
||||||
if let finalName = finalName, !finalName.isEmpty {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.bookmarkNames[gh] = finalName
|
|
||||||
self.persistBookmarkNames()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.resolvingNames.remove(gh)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let loc = points[idx]
|
|
||||||
idx += 1
|
|
||||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
|
||||||
guard self != nil else { return }
|
|
||||||
if let pm = placemarks?.first {
|
|
||||||
if let admin = pm.administrativeArea, !admin.isEmpty, !seenAdmins.contains(admin) {
|
|
||||||
seenAdmins.insert(admin)
|
|
||||||
uniqueAdmins.append(admin)
|
|
||||||
} else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) {
|
|
||||||
seenAdmins.insert(country)
|
|
||||||
uniqueAdmins.append(country)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
step()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
step()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
|
||||||
switch len {
|
|
||||||
case 0...2:
|
|
||||||
return pm.administrativeArea ?? pm.country
|
|
||||||
case 3...4:
|
|
||||||
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
|
||||||
case 5:
|
|
||||||
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
|
||||||
case 6...7:
|
|
||||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
|
||||||
default:
|
|
||||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers (Persistence)
|
|
||||||
|
|
||||||
private func persistTeleportedSet() {
|
|
||||||
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
|
|
||||||
storage.set(data, forKey: teleportedStoreKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistBookmarks() {
|
|
||||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
|
||||||
storage.set(data, forKey: bookmarksKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistBookmarkNames() {
|
|
||||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
|
||||||
storage.set(data, forKey: bookmarkNamesKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeGeohash(_ s: String) -> String {
|
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
|
||||||
return s
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.lowercased()
|
|
||||||
.replacingOccurrences(of: "#", with: "")
|
|
||||||
.filter { allowed.contains($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Backward Compatibility Typealiases
|
|
||||||
|
|
||||||
typealias LocationChannelManager = LocationStateManager
|
|
||||||
typealias GeohashBookmarksStore = LocationStateManager
|
|
||||||
|
|
||||||
// MARK: - Backward Compatibility Extensions
|
|
||||||
|
|
||||||
extension LocationStateManager {
|
|
||||||
/// Backward compatibility: toggle bookmark (was GeohashBookmarksStore.toggle)
|
|
||||||
func toggle(_ geohash: String) {
|
|
||||||
toggleBookmark(geohash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backward compatibility: add bookmark (was GeohashBookmarksStore.add)
|
|
||||||
func add(_ geohash: String) {
|
|
||||||
addBookmark(geohash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove)
|
|
||||||
func remove(_ geohash: String) {
|
|
||||||
removeBookmark(geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Tracks observed mesh topology and computes hop-by-hop routes.
|
|
||||||
final class MeshTopologyTracker {
|
|
||||||
private typealias RoutingID = Data
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
|
|
||||||
private let hopSize = 8
|
|
||||||
private var adjacency: [RoutingID: Set<RoutingID>] = [:]
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
self.adjacency.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordDirectLink(between a: Data?, and b: Data?) {
|
|
||||||
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
var setA = self.adjacency[left] ?? []
|
|
||||||
setA.insert(right)
|
|
||||||
self.adjacency[left] = setA
|
|
||||||
|
|
||||||
var setB = self.adjacency[right] ?? []
|
|
||||||
setB.insert(left)
|
|
||||||
self.adjacency[right] = setB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeDirectLink(between a: Data?, and b: Data?) {
|
|
||||||
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
if var setA = self.adjacency[left] {
|
|
||||||
setA.remove(right)
|
|
||||||
self.adjacency[left] = setA.isEmpty ? nil : setA
|
|
||||||
}
|
|
||||||
if var setB = self.adjacency[right] {
|
|
||||||
setB.remove(left)
|
|
||||||
self.adjacency[right] = setB.isEmpty ? nil : setB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removePeer(_ data: Data?) {
|
|
||||||
guard let peer = sanitize(data) else { return }
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return }
|
|
||||||
for neighbor in neighbors {
|
|
||||||
if var set = self.adjacency[neighbor] {
|
|
||||||
set.remove(peer)
|
|
||||||
self.adjacency[neighbor] = set.isEmpty ? nil : set
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordRoute(_ hops: [Data]) {
|
|
||||||
let sanitized = hops.compactMap { sanitize($0) }
|
|
||||||
guard sanitized.count >= 2 else { return }
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
for idx in 0..<(sanitized.count - 1) {
|
|
||||||
let left = sanitized[idx]
|
|
||||||
let right = sanitized[idx + 1]
|
|
||||||
guard left != right else { continue }
|
|
||||||
|
|
||||||
var setA = self.adjacency[left] ?? []
|
|
||||||
setA.insert(right)
|
|
||||||
self.adjacency[left] = setA
|
|
||||||
|
|
||||||
var setB = self.adjacency[right] ?? []
|
|
||||||
setB.insert(left)
|
|
||||||
self.adjacency[right] = setB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? {
|
|
||||||
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
|
||||||
if source == target { return [source] }
|
|
||||||
|
|
||||||
let graph = queue.sync { adjacency }
|
|
||||||
guard graph[source] != nil, graph[target] != nil else { return nil }
|
|
||||||
|
|
||||||
var visited: Set<RoutingID> = [source]
|
|
||||||
var queuePaths: [[RoutingID]] = [[source]]
|
|
||||||
var index = 0
|
|
||||||
|
|
||||||
while index < queuePaths.count {
|
|
||||||
let path = queuePaths[index]
|
|
||||||
index += 1
|
|
||||||
guard path.count <= maxHops else { continue }
|
|
||||||
guard let last = path.last, let neighbors = graph[last] else { continue }
|
|
||||||
|
|
||||||
for neighbor in neighbors {
|
|
||||||
if visited.contains(neighbor) { continue }
|
|
||||||
var nextPath = path
|
|
||||||
nextPath.append(neighbor)
|
|
||||||
if neighbor == target { return nextPath }
|
|
||||||
if nextPath.count <= maxHops {
|
|
||||||
queuePaths.append(nextPath)
|
|
||||||
}
|
|
||||||
visited.insert(neighbor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
private func sanitize(_ data: Data?) -> Data? {
|
|
||||||
guard var value = data, !value.isEmpty else { return nil }
|
|
||||||
if value.count > hopSize {
|
|
||||||
value = Data(value.prefix(hopSize))
|
|
||||||
} else if value.count < hopSize {
|
|
||||||
value.append(Data(repeating: 0, count: hopSize - value.count))
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageDeduplicationService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Handles message deduplication using LRU caches.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - LRU Deduplication Cache
|
|
||||||
|
|
||||||
/// Generic LRU (Least Recently Used) cache for deduplication.
|
|
||||||
/// Uses an efficient O(1) lookup with periodic compaction.
|
|
||||||
final class LRUDeduplicationCache<Value> {
|
|
||||||
private var map: [String: Value] = [:]
|
|
||||||
private var order: [String] = []
|
|
||||||
private var head: Int = 0
|
|
||||||
private let capacity: Int
|
|
||||||
|
|
||||||
/// Creates a new LRU cache with the specified capacity.
|
|
||||||
/// - Parameter capacity: Maximum number of entries before eviction
|
|
||||||
init(capacity: Int) {
|
|
||||||
precondition(capacity > 0, "LRU cache capacity must be positive")
|
|
||||||
self.capacity = capacity
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of active entries in the cache
|
|
||||||
var count: Int {
|
|
||||||
order.count - head
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a key exists in the cache
|
|
||||||
func contains(_ key: String) -> Bool {
|
|
||||||
map[key] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the value for a key, or nil if not present
|
|
||||||
func value(for key: String) -> Value? {
|
|
||||||
map[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a key-value pair, updating if exists or inserting if new
|
|
||||||
func record(_ key: String, value: Value) {
|
|
||||||
if map[key] == nil {
|
|
||||||
order.append(key)
|
|
||||||
}
|
|
||||||
map[key] = value
|
|
||||||
trimIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes a specific key from the cache
|
|
||||||
func remove(_ key: String) {
|
|
||||||
map.removeValue(forKey: key)
|
|
||||||
// Note: key remains in order array but will be skipped during eviction
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears all entries from the cache
|
|
||||||
func clear() {
|
|
||||||
map.removeAll()
|
|
||||||
order.removeAll()
|
|
||||||
head = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private
|
|
||||||
|
|
||||||
private func trimIfNeeded() {
|
|
||||||
let activeCount = order.count - head
|
|
||||||
guard activeCount > capacity else { return }
|
|
||||||
|
|
||||||
let overflow = activeCount - capacity
|
|
||||||
for _ in 0..<overflow {
|
|
||||||
guard let victim = popOldest() else { break }
|
|
||||||
map.removeValue(forKey: victim)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func popOldest() -> String? {
|
|
||||||
// Skip keys that were already removed from map
|
|
||||||
while head < order.count {
|
|
||||||
let key = order[head]
|
|
||||||
head += 1
|
|
||||||
|
|
||||||
// Periodically compact the backing storage
|
|
||||||
if head >= 32 && head * 2 >= order.count {
|
|
||||||
order.removeFirst(head)
|
|
||||||
head = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only return if key is still in map
|
|
||||||
if map[key] != nil {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Content Normalizer
|
|
||||||
|
|
||||||
/// Normalizes message content for near-duplicate detection.
|
|
||||||
enum ContentNormalizer {
|
|
||||||
|
|
||||||
/// Regex to simplify HTTP URLs by stripping query strings and fragments
|
|
||||||
private static let simplifyHTTPURL: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(
|
|
||||||
pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?",
|
|
||||||
options: [.caseInsensitive]
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
|
|
||||||
/// Normalizes content for deduplication comparison.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - content: The raw message content
|
|
||||||
/// - prefixLength: Maximum characters to consider (default from TransportConfig)
|
|
||||||
/// - Returns: A hash-based key for comparison
|
|
||||||
static func normalizedKey(
|
|
||||||
_ content: String,
|
|
||||||
prefixLength: Int = TransportConfig.contentKeyPrefixLength
|
|
||||||
) -> String {
|
|
||||||
// Lowercase for case-insensitive comparison
|
|
||||||
let lowered = content.lowercased()
|
|
||||||
let ns = lowered as NSString
|
|
||||||
let range = NSRange(location: 0, length: ns.length)
|
|
||||||
|
|
||||||
// Simplify URLs by stripping query/fragment
|
|
||||||
var simplified = ""
|
|
||||||
var last = 0
|
|
||||||
for match in simplifyHTTPURL.matches(in: lowered, options: [], range: range) {
|
|
||||||
if match.range.location > last {
|
|
||||||
simplified += ns.substring(with: NSRange(location: last, length: match.range.location - last))
|
|
||||||
}
|
|
||||||
let url = ns.substring(with: match.range)
|
|
||||||
if let queryIndex = url.firstIndex(where: { $0 == "?" || $0 == "#" }) {
|
|
||||||
simplified += String(url[..<queryIndex])
|
|
||||||
} else {
|
|
||||||
simplified += url
|
|
||||||
}
|
|
||||||
last = match.range.location + match.range.length
|
|
||||||
}
|
|
||||||
if last < ns.length {
|
|
||||||
simplified += ns.substring(with: NSRange(location: last, length: ns.length - last))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trim and collapse whitespace
|
|
||||||
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
|
||||||
|
|
||||||
// Take prefix and hash
|
|
||||||
let prefix = String(collapsed.prefix(prefixLength))
|
|
||||||
let hash = prefix.djb2()
|
|
||||||
return String(format: "h:%016llx", hash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Deduplication Service
|
|
||||||
|
|
||||||
/// Service that manages message deduplication using LRU caches.
|
|
||||||
/// Provides separate caches for content-based dedup and Nostr event ID dedup.
|
|
||||||
final class MessageDeduplicationService {
|
|
||||||
|
|
||||||
/// Cache for content-based near-duplicate detection
|
|
||||||
private let contentCache: LRUDeduplicationCache<Date>
|
|
||||||
|
|
||||||
/// Cache for Nostr event ID deduplication
|
|
||||||
private let nostrEventCache: LRUDeduplicationCache<Bool>
|
|
||||||
|
|
||||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
|
||||||
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
|
||||||
|
|
||||||
/// Creates a new deduplication service with specified capacities.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - contentCapacity: Max entries for content cache
|
|
||||||
/// - nostrEventCapacity: Max entries for Nostr event cache
|
|
||||||
init(
|
|
||||||
contentCapacity: Int = TransportConfig.contentLRUCap,
|
|
||||||
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap
|
|
||||||
) {
|
|
||||||
self.contentCache = LRUDeduplicationCache(capacity: contentCapacity)
|
|
||||||
self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
|
||||||
self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Content Deduplication
|
|
||||||
|
|
||||||
/// Records content with its timestamp for near-duplicate detection.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - content: The message content
|
|
||||||
/// - timestamp: When the content was received
|
|
||||||
func recordContent(_ content: String, timestamp: Date) {
|
|
||||||
let key = ContentNormalizer.normalizedKey(content)
|
|
||||||
contentCache.record(key, value: timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a pre-normalized content key with its timestamp.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - key: The normalized content key
|
|
||||||
/// - timestamp: When the content was received
|
|
||||||
func recordContentKey(_ key: String, timestamp: Date) {
|
|
||||||
contentCache.record(key, value: timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the timestamp for previously seen content.
|
|
||||||
/// - Parameter content: The message content
|
|
||||||
/// - Returns: The timestamp when first seen, or nil if not seen
|
|
||||||
func contentTimestamp(for content: String) -> Date? {
|
|
||||||
let key = ContentNormalizer.normalizedKey(content)
|
|
||||||
return contentCache.value(for: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the timestamp for a pre-normalized content key.
|
|
||||||
/// - Parameter key: The normalized content key
|
|
||||||
/// - Returns: The timestamp when first seen, or nil if not seen
|
|
||||||
func contentTimestamp(forKey key: String) -> Date? {
|
|
||||||
contentCache.value(for: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalizes content to a deduplication key.
|
|
||||||
/// - Parameter content: The raw content
|
|
||||||
/// - Returns: A normalized hash key
|
|
||||||
func normalizedContentKey(_ content: String) -> String {
|
|
||||||
ContentNormalizer.normalizedKey(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr Event Deduplication
|
|
||||||
|
|
||||||
/// Checks if a Nostr event has already been processed.
|
|
||||||
/// - Parameter eventId: The event ID
|
|
||||||
/// - Returns: true if already processed
|
|
||||||
func hasProcessedNostrEvent(_ eventId: String) -> Bool {
|
|
||||||
nostrEventCache.contains(eventId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a Nostr event as processed.
|
|
||||||
/// - Parameter eventId: The event ID
|
|
||||||
func recordNostrEvent(_ eventId: String) {
|
|
||||||
nostrEventCache.record(eventId, value: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr ACK Deduplication
|
|
||||||
|
|
||||||
/// Checks if a Nostr ACK has already been processed.
|
|
||||||
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
|
|
||||||
/// - Returns: true if already processed
|
|
||||||
func hasProcessedNostrAck(_ ackKey: String) -> Bool {
|
|
||||||
nostrAckCache.contains(ackKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a Nostr ACK as processed.
|
|
||||||
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
|
|
||||||
func recordNostrAck(_ ackKey: String) {
|
|
||||||
nostrAckCache.record(ackKey, value: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates an ACK key from components.
|
|
||||||
static func ackKey(messageId: String, ackType: String, senderPubkey: String) -> String {
|
|
||||||
"\(messageId):\(ackType):\(senderPubkey)"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Clear
|
|
||||||
|
|
||||||
/// Clears all caches
|
|
||||||
func clearAll() {
|
|
||||||
contentCache.clear()
|
|
||||||
nostrEventCache.clear()
|
|
||||||
nostrAckCache.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears only the Nostr caches (events and ACKs)
|
|
||||||
func clearNostrCaches() {
|
|
||||||
nostrEventCache.clear()
|
|
||||||
nostrAckCache.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageFormattingEngine.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Handles message text formatting, including mentions, hashtags, URLs, and tokens.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Formatting Context Protocol
|
|
||||||
|
|
||||||
/// Protocol defining the context needed for message formatting.
|
|
||||||
/// Implemented by ChatViewModel to provide runtime state.
|
|
||||||
@MainActor
|
|
||||||
protocol MessageFormattingContext: AnyObject {
|
|
||||||
/// The user's current nickname
|
|
||||||
var nickname: String { get }
|
|
||||||
|
|
||||||
/// Determines if a message was sent by the current user
|
|
||||||
func isSelfMessage(_ message: BitchatMessage) -> Bool
|
|
||||||
|
|
||||||
/// Gets the color for a message's sender
|
|
||||||
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color
|
|
||||||
|
|
||||||
/// Resolves a peer ID to a clickable URL
|
|
||||||
func peerURL(for peerID: PeerID) -> URL?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Formatting Engine
|
|
||||||
|
|
||||||
/// Handles rich text formatting for chat messages.
|
|
||||||
/// Extracts mentions, hashtags, URLs, Lightning invoices, and Cashu tokens.
|
|
||||||
final class MessageFormattingEngine {
|
|
||||||
|
|
||||||
// MARK: - Precompiled Regexes
|
|
||||||
|
|
||||||
/// Precompiled regex patterns for message content parsing
|
|
||||||
enum Patterns {
|
|
||||||
static let hashtag: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let mention: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let cashu: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let bolt11: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let lnurl: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let lightningScheme: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let linkDetector: NSDataDetector? = {
|
|
||||||
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let quickCashuPresence: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let simplifyHTTPURL: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Match Types
|
|
||||||
|
|
||||||
/// Types of matches found in message content
|
|
||||||
enum MatchType: String {
|
|
||||||
case hashtag
|
|
||||||
case mention
|
|
||||||
case url
|
|
||||||
case cashu
|
|
||||||
case lightning
|
|
||||||
case bolt11
|
|
||||||
case lnurl
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A match found in message content
|
|
||||||
struct ContentMatch {
|
|
||||||
let range: NSRange
|
|
||||||
let type: MatchType
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API
|
|
||||||
|
|
||||||
/// Formats a message with rich text styling
|
|
||||||
@MainActor
|
|
||||||
static func formatMessage(
|
|
||||||
_ message: BitchatMessage,
|
|
||||||
context: MessageFormattingContext,
|
|
||||||
colorScheme: ColorScheme
|
|
||||||
) -> AttributedString {
|
|
||||||
let isDark = colorScheme == .dark
|
|
||||||
let isSelf = context.isSelfMessage(message)
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
if let cached = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = AttributedString()
|
|
||||||
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
|
|
||||||
|
|
||||||
// Format system messages differently
|
|
||||||
if message.sender == "system" {
|
|
||||||
result = formatSystemMessage(message, isDark: isDark)
|
|
||||||
} else {
|
|
||||||
// Format sender header
|
|
||||||
result = formatSenderHeader(
|
|
||||||
message: message,
|
|
||||||
baseColor: baseColor,
|
|
||||||
isSelf: isSelf,
|
|
||||||
context: context
|
|
||||||
)
|
|
||||||
|
|
||||||
// Format content
|
|
||||||
let contentResult = formatContent(
|
|
||||||
message.content,
|
|
||||||
baseColor: baseColor,
|
|
||||||
isSelf: isSelf,
|
|
||||||
isMentioned: message.mentions?.contains(context.nickname) ?? false
|
|
||||||
)
|
|
||||||
result.append(contentResult)
|
|
||||||
|
|
||||||
// Add timestamp
|
|
||||||
result.append(formatTimestamp(message.formattedTimestamp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Formats just the message header (sender portion)
|
|
||||||
@MainActor
|
|
||||||
static func formatHeader(
|
|
||||||
_ message: BitchatMessage,
|
|
||||||
context: MessageFormattingContext,
|
|
||||||
colorScheme: ColorScheme
|
|
||||||
) -> AttributedString {
|
|
||||||
let isDark = colorScheme == .dark
|
|
||||||
let isSelf = context.isSelfMessage(message)
|
|
||||||
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
|
|
||||||
|
|
||||||
if message.sender == "system" {
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = baseColor
|
|
||||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
|
||||||
return AttributedString(message.sender).mergingAttributes(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatSenderHeader(
|
|
||||||
message: message,
|
|
||||||
baseColor: baseColor,
|
|
||||||
isSelf: isSelf,
|
|
||||||
context: context
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts mentions from message content
|
|
||||||
static func extractMentions(from content: String) -> [String] {
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = Patterns.mention.matches(in: content, options: [], range: range)
|
|
||||||
|
|
||||||
return matches.compactMap { match -> String? in
|
|
||||||
guard match.numberOfRanges > 1 else { return nil }
|
|
||||||
let captureRange = match.range(at: 1)
|
|
||||||
guard let swiftRange = Range(captureRange, in: content) else { return nil }
|
|
||||||
return String(content[swiftRange])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if content contains a Cashu token
|
|
||||||
static func containsCashuToken(_ content: String) -> Bool {
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
return Patterns.quickCashuPresence.numberOfMatches(in: content, options: [], range: range) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
|
|
||||||
private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString {
|
|
||||||
var result = AttributedString()
|
|
||||||
|
|
||||||
let content = AttributedString("* \(message.content) *")
|
|
||||||
var contentStyle = AttributeContainer()
|
|
||||||
contentStyle.foregroundColor = Color.gray
|
|
||||||
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
|
|
||||||
result.append(content.mergingAttributes(contentStyle))
|
|
||||||
|
|
||||||
// Add timestamp
|
|
||||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
|
||||||
var timestampStyle = AttributeContainer()
|
|
||||||
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
|
|
||||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
|
||||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private static func formatSenderHeader(
|
|
||||||
message: BitchatMessage,
|
|
||||||
baseColor: Color,
|
|
||||||
isSelf: Bool,
|
|
||||||
context: MessageFormattingContext
|
|
||||||
) -> AttributedString {
|
|
||||||
var result = AttributedString()
|
|
||||||
|
|
||||||
let (baseName, suffix) = message.sender.splitSuffix()
|
|
||||||
var senderStyle = AttributeContainer()
|
|
||||||
senderStyle.foregroundColor = baseColor
|
|
||||||
let fontWeight: Font.Weight = isSelf ? .bold : .medium
|
|
||||||
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
|
|
||||||
|
|
||||||
// Make sender clickable
|
|
||||||
if let spid = message.senderPeerID, let url = context.peerURL(for: spid) {
|
|
||||||
senderStyle.link = url
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build: "<@baseName#suffix> "
|
|
||||||
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
|
||||||
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
|
||||||
|
|
||||||
if !suffix.isEmpty {
|
|
||||||
var suffixStyle = senderStyle
|
|
||||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
|
||||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
|
||||||
}
|
|
||||||
|
|
||||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatContent(
|
|
||||||
_ content: String,
|
|
||||||
baseColor: Color,
|
|
||||||
isSelf: Bool,
|
|
||||||
isMentioned: Bool
|
|
||||||
) -> AttributedString {
|
|
||||||
// For very long content without special tokens, use plain formatting
|
|
||||||
let containsCashu = containsCashuToken(content)
|
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
|
||||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all matches
|
|
||||||
let matches = findAllMatches(in: content)
|
|
||||||
|
|
||||||
// Build formatted content
|
|
||||||
var result = AttributedString()
|
|
||||||
var lastEnd = content.startIndex
|
|
||||||
|
|
||||||
for match in matches {
|
|
||||||
guard let swiftRange = Range(match.range, in: content) else { continue }
|
|
||||||
|
|
||||||
// Add text before match
|
|
||||||
if lastEnd < swiftRange.lowerBound {
|
|
||||||
let beforeText = String(content[lastEnd..<swiftRange.lowerBound])
|
|
||||||
result.append(formatPlainText(beforeText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add styled match
|
|
||||||
let matchText = String(content[swiftRange])
|
|
||||||
result.append(formatMatch(matchText, type: match.type, baseColor: baseColor, isSelf: isSelf))
|
|
||||||
|
|
||||||
lastEnd = swiftRange.upperBound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining text
|
|
||||||
if lastEnd < content.endIndex {
|
|
||||||
let remainingText = String(content[lastEnd...])
|
|
||||||
result.append(formatPlainText(remainingText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func findAllMatches(in content: String) -> [ContentMatch] {
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let nsLen = nsContent.length
|
|
||||||
let fullRange = NSRange(location: 0, length: nsLen)
|
|
||||||
|
|
||||||
// Quick hints to avoid unnecessary regex work
|
|
||||||
let hasMentions = content.contains("@")
|
|
||||||
let hasHashtags = content.contains("#")
|
|
||||||
let hasURLs = content.contains("://") || content.contains("www.") || content.contains("http")
|
|
||||||
let hasLightning = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
|
||||||
let hasCashu = content.lowercased().contains("cashu")
|
|
||||||
|
|
||||||
// Collect matches
|
|
||||||
let mentionMatches = hasMentions ? Patterns.mention.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let hashtagMatches = hasHashtags ? Patterns.hashtag.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let urlMatches = hasURLs ? (Patterns.linkDetector?.matches(in: content, options: [], range: fullRange) ?? []) : []
|
|
||||||
let cashuMatches = hasCashu ? Patterns.cashu.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let lightningMatches = hasLightning ? Patterns.lightningScheme.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let bolt11Matches = hasLightning ? Patterns.bolt11.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let lnurlMatches = hasLightning ? Patterns.lnurl.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
|
|
||||||
// Build mention ranges for overlap checking
|
|
||||||
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
|
||||||
|
|
||||||
func overlapsMention(_ r: NSRange) -> Bool {
|
|
||||||
mentionRanges.contains { NSIntersectionRange(r, $0).length > 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
func isStandaloneHashtag(_ r: NSRange) -> Bool {
|
|
||||||
guard let swiftRange = Range(r, in: content) else { return false }
|
|
||||||
if swiftRange.lowerBound == content.startIndex { return true }
|
|
||||||
let prev = content.index(before: swiftRange.lowerBound)
|
|
||||||
return content[prev].isWhitespace || content[prev].isNewline
|
|
||||||
}
|
|
||||||
|
|
||||||
func attachedToMention(_ r: NSRange) -> Bool {
|
|
||||||
guard let swiftRange = Range(r, in: content), swiftRange.lowerBound > content.startIndex else { return false }
|
|
||||||
var i = content.index(before: swiftRange.lowerBound)
|
|
||||||
while true {
|
|
||||||
let ch = content[i]
|
|
||||||
if ch.isWhitespace || ch.isNewline { break }
|
|
||||||
if ch == "@" { return true }
|
|
||||||
if i == content.startIndex { break }
|
|
||||||
i = content.index(before: i)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var allMatches: [ContentMatch] = []
|
|
||||||
|
|
||||||
// Add hashtags (excluding those attached to mentions)
|
|
||||||
for match in hashtagMatches {
|
|
||||||
let range = match.range(at: 0)
|
|
||||||
if !overlapsMention(range) && !attachedToMention(range) && isStandaloneHashtag(range) {
|
|
||||||
allMatches.append(ContentMatch(range: range, type: .hashtag))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add mentions
|
|
||||||
for match in mentionMatches {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .mention))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add URLs
|
|
||||||
for match in urlMatches where !overlapsMention(match.range) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range, type: .url))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Cashu tokens
|
|
||||||
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .cashu))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Lightning scheme URLs
|
|
||||||
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lightning))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add bolt11/lnurl (avoiding overlaps with lightning scheme and URLs)
|
|
||||||
let occupied = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
|
|
||||||
func overlapsOccupied(_ r: NSRange) -> Bool {
|
|
||||||
occupied.contains { NSIntersectionRange(r, $0).length > 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .bolt11))
|
|
||||||
}
|
|
||||||
|
|
||||||
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lnurl))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by position
|
|
||||||
return allMatches.sorted { $0.range.location < $1.range.location }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString {
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = baseColor
|
|
||||||
style.font = isSelf
|
|
||||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
|
||||||
: .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
return AttributedString(content).mergingAttributes(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatPlainText(_ text: String, baseColor: Color, isSelf: Bool, isMentioned: Bool) -> AttributedString {
|
|
||||||
guard !text.isEmpty else { return AttributedString() }
|
|
||||||
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = baseColor
|
|
||||||
style.font = isSelf
|
|
||||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
|
||||||
: .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
|
|
||||||
if isMentioned {
|
|
||||||
style.font = style.font?.bold()
|
|
||||||
}
|
|
||||||
|
|
||||||
return AttributedString(text).mergingAttributes(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString {
|
|
||||||
var style = AttributeContainer()
|
|
||||||
|
|
||||||
switch type {
|
|
||||||
case .mention:
|
|
||||||
// Split optional '#abcd' suffix
|
|
||||||
let (baseName, suffix) = text.splitSuffix()
|
|
||||||
var result = AttributedString()
|
|
||||||
|
|
||||||
var mentionStyle = AttributeContainer()
|
|
||||||
mentionStyle.foregroundColor = .blue
|
|
||||||
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
|
||||||
result.append(AttributedString(baseName).mergingAttributes(mentionStyle))
|
|
||||||
|
|
||||||
if !suffix.isEmpty {
|
|
||||||
var suffixStyle = mentionStyle
|
|
||||||
suffixStyle.foregroundColor = Color.gray.opacity(0.7)
|
|
||||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
case .hashtag:
|
|
||||||
style.foregroundColor = .purple
|
|
||||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
|
||||||
|
|
||||||
case .url:
|
|
||||||
style.foregroundColor = .blue
|
|
||||||
style.font = .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
style.underlineStyle = .single
|
|
||||||
if let url = URL(string: text) {
|
|
||||||
style.link = url
|
|
||||||
}
|
|
||||||
|
|
||||||
case .cashu:
|
|
||||||
style.foregroundColor = .green
|
|
||||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
|
||||||
style.backgroundColor = Color.green.opacity(0.1)
|
|
||||||
|
|
||||||
case .lightning, .bolt11, .lnurl:
|
|
||||||
style.foregroundColor = .yellow
|
|
||||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
|
||||||
style.backgroundColor = Color.yellow.opacity(0.1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return AttributedString(text).mergingAttributes(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatTimestamp(_ timestamp: String) -> AttributedString {
|
|
||||||
let text = AttributedString(" [\(timestamp)]")
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = Color.gray.opacity(0.5)
|
|
||||||
style.font = .bitchatSystem(size: 10, design: .monospaced)
|
|
||||||
return text.mergingAttributes(style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
/// Routes messages between BLE and Nostr transports
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let transports: [Transport]
|
private let mesh: Transport
|
||||||
|
private let nostr: NostrTransport
|
||||||
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||||
|
|
||||||
init(transports: [Transport]) {
|
init(mesh: Transport, nostr: NostrTransport) {
|
||||||
self.transports = transports
|
self.mesh = mesh
|
||||||
|
self.nostr = nostr
|
||||||
|
self.nostr.senderPeerID = mesh.myPeerID
|
||||||
|
|
||||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
@@ -35,70 +38,88 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
// Try to find a reachable transport
|
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
if reachableMesh {
|
||||||
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
// BLEService will initiate a handshake if needed and queue the message
|
||||||
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
|
SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Queue for later
|
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||||
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
if mesh.isPeerReachable(peerID) {
|
||||||
transport.sendReadReceipt(receipt, to: peerID)
|
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
} else if !transports.isEmpty {
|
mesh.sendReadReceipt(receipt, to: peerID)
|
||||||
// Fallback to last transport (usually Nostr) if neither is explicitly reachable?
|
} else {
|
||||||
// Or better: just try the first one that supports it?
|
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
// Existing logic preferred mesh, then nostr.
|
nostr.sendReadReceipt(receipt, to: peerID)
|
||||||
// If neither reachable, existing logic queued it (via mesh usually) or sent via nostr.
|
|
||||||
// Let's stick to "try reachable". If none, maybe pick the first one to queue?
|
|
||||||
// Actually, for READ receipts, we might want to just fire-and-forget on the "best effort" transport.
|
|
||||||
// But let's stick to the reachable check.
|
|
||||||
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
transport.sendDeliveryAck(for: messageID, to: peerID)
|
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
|
} else {
|
||||||
|
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
if let transport = transports.first(where: { $0.isPeerConnected(peerID) }) {
|
// Route via mesh when connected; else use Nostr
|
||||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
if mesh.isPeerConnected(peerID) {
|
||||||
} else if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback: try all? or just the last one?
|
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
// Old logic: if mesh connected, mesh. Else nostr.
|
|
||||||
// Note: NostrTransport.isPeerReachable now returns true if mapped.
|
|
||||||
// If not mapped, we can't send via Nostr anyway.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Outbox Management
|
// MARK: - Outbox Management
|
||||||
|
private func canSendViaNostr(peerID: PeerID) -> Bool {
|
||||||
|
// Two forms are supported:
|
||||||
|
// - 64-hex Noise public key (32 bytes)
|
||||||
|
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||||
|
if let noiseKey = peerID.noiseKey {
|
||||||
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
|
fav.peerNostrPublicKey != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else if peerID.isShort {
|
||||||
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
|
fav.peerNostrPublicKey != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func flushOutbox(for peerID: PeerID) {
|
func flushOutbox(for peerID: PeerID) {
|
||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||||
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
for (content, nickname, messageID) in queued {
|
for (content, nickname, messageID) in queued {
|
||||||
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
|
SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
|
// Keep unsent items queued
|
||||||
remaining.append((content, nickname, messageID))
|
remaining.append((content, nickname, messageID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Persist only items we could not send
|
||||||
if remaining.isEmpty {
|
if remaining.isEmpty {
|
||||||
outbox.removeValue(forKey: peerID)
|
outbox.removeValue(forKey: peerID)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -177,18 +177,18 @@ final class NoiseEncryptionService {
|
|||||||
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||||
|
|
||||||
// Add a handler for peer authentication
|
// Add a handler for peer authentication
|
||||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
||||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
self?.onPeerAuthenticatedHandlers.append(handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy support - setting this will add to the handlers array
|
// Legacy support - setting this will add to the handlers array
|
||||||
var onPeerAuthenticated: ((PeerID, String) -> Void)? {
|
var onPeerAuthenticated: ((String, String) -> Void)? {
|
||||||
get { nil } // Always return nil for backward compatibility
|
get { nil } // Always return nil for backward compatibility
|
||||||
set {
|
set {
|
||||||
if let handler = newValue {
|
if let handler = newValue {
|
||||||
@@ -546,7 +546,7 @@ final class NoiseEncryptionService {
|
|||||||
// Notify all handlers about authentication
|
// Notify all handlers about authentication
|
||||||
serviceQueue.async { [weak self] in
|
serviceQueue.async { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||||
handler(peerID, fingerprint)
|
handler(peerID.id, fingerprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Foundation
|
|||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
// Minimal Nostr transport conforming to Transport for offline sending
|
// Minimal Nostr transport conforming to Transport for offline sending
|
||||||
final class NostrTransport: Transport, @unchecked Sendable {
|
final class NostrTransport: Transport {
|
||||||
// Provide BLE short peer ID for BitChat embedding
|
// Provide BLE short peer ID for BitChat embedding
|
||||||
var senderPeerID = PeerID(str: "")
|
var senderPeerID = PeerID(str: "")
|
||||||
|
|
||||||
@@ -18,49 +18,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private let idBridge: NostrIdentityBridge
|
private let idBridge: NostrIdentityBridge
|
||||||
|
|
||||||
// Reachability Cache (thread-safe)
|
|
||||||
private var reachablePeers: Set<PeerID> = []
|
|
||||||
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
|
|
||||||
setupObservers()
|
|
||||||
|
|
||||||
// Synchronously warm the cache to avoid startup race
|
|
||||||
let favorites = FavoritesPersistenceService.shared.favorites
|
|
||||||
let reachable = favorites.values
|
|
||||||
.filter { $0.peerNostrPublicKey != nil }
|
|
||||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
|
||||||
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
self.reachablePeers = Set(reachable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupObservers() {
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
forName: .favoriteStatusChanged,
|
|
||||||
object: nil,
|
|
||||||
queue: nil
|
|
||||||
) { [weak self] _ in
|
|
||||||
self?.refreshReachablePeers()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshReachablePeers() {
|
|
||||||
Task { @MainActor in
|
|
||||||
let favorites = FavoritesPersistenceService.shared.favorites
|
|
||||||
let reachable = favorites.values
|
|
||||||
.filter { $0.peerNostrPublicKey != nil }
|
|
||||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
|
||||||
|
|
||||||
self.queue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.reachablePeers = Set(reachable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transport Protocol Conformance
|
// MARK: - Transport Protocol Conformance
|
||||||
@@ -82,19 +42,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
func emergencyDisconnectAll() { /* no-op */ }
|
func emergencyDisconnectAll() { /* no-op */ }
|
||||||
|
|
||||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||||
|
func isPeerReachable(_ peerID: PeerID) -> Bool { false }
|
||||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
|
||||||
queue.sync {
|
|
||||||
// Check if exact match
|
|
||||||
if reachablePeers.contains(peerID) { return true }
|
|
||||||
// Check for short ID match
|
|
||||||
if peerID.isShort {
|
|
||||||
return reachablePeers.contains(where: { $0.toShort() == peerID })
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||||
func getPeerNicknames() -> [PeerID : String] { [:] }
|
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||||
|
|
||||||
@@ -134,7 +82,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -165,11 +113,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
guard hrp == "npub" else { return }
|
guard hrp == "npub" else { return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch {
|
} catch { return }
|
||||||
SecureLogger.error("NostrTransport: failed to decode recipient npub for favorite notification: \(error.localizedDescription)", category: .session)
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -193,11 +138,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
guard hrp == "npub" else { return }
|
guard hrp == "npub" else { return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch {
|
} catch { return }
|
||||||
SecureLogger.error("NostrTransport: failed to decode recipient npub for delivery ack: \(error.localizedDescription)", category: .session)
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -219,7 +161,7 @@ extension NostrTransport {
|
|||||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
@@ -229,7 +171,7 @@ extension NostrTransport {
|
|||||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
@@ -242,7 +184,7 @@ extension NostrTransport {
|
|||||||
guard !recipientHex.isEmpty else { return }
|
guard !recipientHex.isEmpty else { return }
|
||||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
// Build embedded BitChat packet without recipient peer ID
|
// Build embedded BitChat packet without recipient peer ID
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -280,12 +222,8 @@ extension NostrTransport {
|
|||||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch {
|
} catch { scheduleNextReadAck(); return }
|
||||||
SecureLogger.error("NostrTransport: failed to decode recipient npub for read ack: \(error.localizedDescription)", category: .session)
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
scheduleNextReadAck()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
scheduleNextReadAck(); return
|
scheduleNextReadAck(); return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,21 +16,10 @@ import AppKit
|
|||||||
|
|
||||||
final class NotificationService {
|
final class NotificationService {
|
||||||
static let shared = NotificationService()
|
static let shared = NotificationService()
|
||||||
|
|
||||||
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
|
|
||||||
private var isRunningTests: Bool {
|
|
||||||
let env = ProcessInfo.processInfo.environment
|
|
||||||
return NSClassFromString("XCTestCase") != nil ||
|
|
||||||
env["XCTestConfigurationFilePath"] != nil ||
|
|
||||||
env["XCTestBundlePath"] != nil ||
|
|
||||||
env["GITHUB_ACTIONS"] != nil ||
|
|
||||||
env["CI"] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func requestAuthorization() {
|
func requestAuthorization() {
|
||||||
guard !isRunningTests else { return }
|
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
if granted {
|
if granted {
|
||||||
// Permission granted
|
// Permission granted
|
||||||
@@ -40,31 +29,28 @@ final class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendLocalNotification(
|
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
|
||||||
title: String,
|
// For now, skip app state check entirely to avoid thread issues
|
||||||
body: String,
|
// The NotificationDelegate will handle foreground presentation
|
||||||
identifier: String,
|
DispatchQueue.main.async {
|
||||||
userInfo: [String: Any]? = nil,
|
let content = UNMutableNotificationContent()
|
||||||
interruptionLevel: UNNotificationInterruptionLevel = .active
|
content.title = title
|
||||||
) {
|
content.body = body
|
||||||
guard !isRunningTests else { return }
|
content.sound = .default
|
||||||
let content = UNMutableNotificationContent()
|
if let userInfo = userInfo {
|
||||||
content.title = title
|
content.userInfo = userInfo
|
||||||
content.body = body
|
}
|
||||||
content.sound = .default
|
|
||||||
content.interruptionLevel = interruptionLevel
|
let request = UNNotificationRequest(
|
||||||
|
identifier: identifier,
|
||||||
if let userInfo = userInfo {
|
content: content,
|
||||||
content.userInfo = userInfo
|
trigger: nil // Deliver immediately
|
||||||
|
)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request) { _ in
|
||||||
|
// Notification added
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: identifier,
|
|
||||||
content: content,
|
|
||||||
trigger: nil // Deliver immediately
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMentionNotification(from sender: String, message: String) {
|
func sendMentionNotification(from sender: String, message: String) {
|
||||||
@@ -75,11 +61,11 @@ final class NotificationService {
|
|||||||
sendLocalNotification(title: title, body: body, identifier: identifier)
|
sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
|
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
||||||
let title = "🔒 DM from \(sender)"
|
let title = "🔒 DM from \(sender)"
|
||||||
let body = message
|
let body = message
|
||||||
let identifier = "private-\(UUID().uuidString)"
|
let identifier = "private-\(UUID().uuidString)"
|
||||||
let userInfo = ["peerID": peerID.id, "senderName": sender]
|
let userInfo = ["peerID": peerID, "senderName": sender]
|
||||||
|
|
||||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||||
}
|
}
|
||||||
@@ -97,12 +83,25 @@ final class NotificationService {
|
|||||||
let title = "👥 bitchatters nearby!"
|
let title = "👥 bitchatters nearby!"
|
||||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||||
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
||||||
|
|
||||||
sendLocalNotification(
|
// For network notifications, we want to show them even in foreground
|
||||||
title: title,
|
// No app state check - let the notification delegate handle presentation
|
||||||
body: body,
|
DispatchQueue.main.async {
|
||||||
identifier: identifier,
|
let content = UNMutableNotificationContent()
|
||||||
interruptionLevel: .timeSensitive
|
content.title = title
|
||||||
)
|
content.body = body
|
||||||
|
content.sound = .default
|
||||||
|
content.interruptionLevel = .timeSensitive // Make it more prominent
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: identifier,
|
||||||
|
content: content,
|
||||||
|
trigger: nil // Deliver immediately
|
||||||
|
)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request) { _ in
|
||||||
|
// Notification added
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,10 @@
|
|||||||
// 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) }
|
||||||
@@ -27,107 +18,64 @@ struct NotificationStreamAssembler {
|
|||||||
|
|
||||||
var frames: [Data] = []
|
var frames: [Data] = []
|
||||||
var dropped: [UInt8] = []
|
var dropped: [UInt8] = []
|
||||||
var didReset = false
|
var reset = false
|
||||||
let now = DispatchTime.now()
|
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
|
||||||
let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes
|
|
||||||
let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize
|
|
||||||
|
|
||||||
if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes {
|
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
|
||||||
SecureLogger.error("❌ Notification assembler overflow (\(buffer.count) bytes); dropping partial frame", category: .session)
|
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
|
||||||
resetState()
|
|
||||||
return ([], [], true)
|
|
||||||
}
|
|
||||||
|
|
||||||
while buffer.count >= minimumFramePrefix {
|
while buffer.count >= minFramePrefix {
|
||||||
guard let version = buffer.first else { break }
|
guard let first = buffer.first else { break }
|
||||||
guard version == 1 || version == 2 else {
|
if first != 1 {
|
||||||
dropped.append(buffer.removeFirst())
|
dropped.append(buffer.removeFirst())
|
||||||
pendingFrameStartedAt = nil
|
|
||||||
pendingFrameExpectedLength = 0
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let headerSize = BinaryProtocol.headerSize(for: version) else {
|
guard buffer.count >= minHeaderBytes else { break }
|
||||||
dropped.append(buffer.removeFirst())
|
|
||||||
pendingFrameStartedAt = nil
|
|
||||||
pendingFrameExpectedLength = 0
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let framePrefix = headerSize + BinaryProtocol.senderIDSize
|
|
||||||
guard buffer.count >= framePrefix else { break }
|
|
||||||
|
|
||||||
let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags
|
let headerBytes = Array(buffer.prefix(minFramePrefix))
|
||||||
guard flagsIndex < buffer.endIndex else { break }
|
guard headerBytes.count == minFramePrefix 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 isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
|
||||||
|
|
||||||
let lengthOffset = 12
|
var frameLength = minFramePrefix + payloadLen
|
||||||
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 {
|
||||||
SecureLogger.error("❌ Notification frame length \(frameLength) invalid (cap=\(maxFrameLength)); resetting stream", category: .session)
|
buffer.removeAll()
|
||||||
resetState()
|
reset = true
|
||||||
didReset = true
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if buffer.count < frameLength {
|
if buffer.count < frameLength {
|
||||||
let remaining = frameLength - buffer.count
|
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
|
||||||
if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength {
|
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
|
||||||
pendingFrameStartedAt = now
|
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
|
||||||
pendingFrameExpectedLength = frameLength
|
if dropCount > 0 {
|
||||||
} else if let started = pendingFrameStartedAt {
|
buffer.removeFirst(dropCount)
|
||||||
let elapsed = now.uptimeNanoseconds - started.uptimeNanoseconds
|
dropped.append(1) // treat as dropped partial start
|
||||||
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 }) {
|
||||||
resetState()
|
buffer.removeAll(keepingCapacity: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (frames, dropped, didReset)
|
return (frames, dropped, reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reset() {
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,174 +15,20 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||||
@Published var selectedPeer: PeerID? = nil
|
@Published var selectedPeer: PeerID? = nil
|
||||||
@Published var unreadMessages: Set<PeerID> = []
|
@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
|
||||||
|
|
||||||
weak var meshService: Transport?
|
weak var meshService: Transport?
|
||||||
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
// Peer service for looking up peer info during consolidation
|
|
||||||
weak var unifiedPeerService: UnifiedPeerService?
|
|
||||||
|
|
||||||
init(meshService: Transport? = nil) {
|
init(meshService: Transport? = nil) {
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap for messages stored per private chat
|
// Cap for messages stored per private chat
|
||||||
private let privateChatCap = TransportConfig.privateChatCap
|
private let privateChatCap = TransportConfig.privateChatCap
|
||||||
|
|
||||||
// MARK: - Message Consolidation
|
|
||||||
|
|
||||||
/// Consolidates messages from different peer ID representations into a single chat.
|
|
||||||
/// This ensures messages from stable Noise keys and temporary Nostr peer IDs are merged.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - peerID: The target peer ID to consolidate messages into
|
|
||||||
/// - peerNickname: The peer's display name (lowercased for matching)
|
|
||||||
/// - persistedReadReceipts: The persisted read receipts set from ChatViewModel (UserDefaults-backed)
|
|
||||||
/// - Returns: True if any unread messages were found during consolidation
|
|
||||||
@MainActor
|
|
||||||
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {
|
|
||||||
guard let meshService = meshService else { return false }
|
|
||||||
var hasUnreadMessages = false
|
|
||||||
|
|
||||||
// 1. Consolidate from stable Noise key (64-char hex)
|
|
||||||
if let peer = unifiedPeerService?.getPeer(by: peerID) {
|
|
||||||
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
|
||||||
|
|
||||||
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
|
|
||||||
if privateChats[peerID] == nil {
|
|
||||||
privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
||||||
for message in nostrMessages {
|
|
||||||
if !existingMessageIds.contains(message.id) {
|
|
||||||
// Update senderPeerID for correct read receipts
|
|
||||||
let updatedMessage = BitchatMessage(
|
|
||||||
id: message.id,
|
|
||||||
sender: message.sender,
|
|
||||||
content: message.content,
|
|
||||||
timestamp: message.timestamp,
|
|
||||||
isRelay: message.isRelay,
|
|
||||||
originalSender: message.originalSender,
|
|
||||||
isPrivate: message.isPrivate,
|
|
||||||
recipientNickname: message.recipientNickname,
|
|
||||||
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
|
|
||||||
mentions: message.mentions,
|
|
||||||
deliveryStatus: message.deliveryStatus
|
|
||||||
)
|
|
||||||
privateChats[peerID]?.append(updatedMessage)
|
|
||||||
|
|
||||||
// Check for recent unread messages (< 60s, not sent by us, not already read)
|
|
||||||
// Use persistedReadReceipts to correctly identify already-read messages after app restart
|
|
||||||
if message.senderPeerID != meshService.myPeerID {
|
|
||||||
let messageAge = Date().timeIntervalSince(message.timestamp)
|
|
||||||
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
|
|
||||||
hasUnreadMessages = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
||||||
|
|
||||||
if hasUnreadMessages {
|
|
||||||
unreadMessages.insert(peerID)
|
|
||||||
} else if unreadMessages.contains(noiseKeyHex) {
|
|
||||||
unreadMessages.remove(noiseKeyHex)
|
|
||||||
}
|
|
||||||
|
|
||||||
privateChats.removeValue(forKey: noiseKeyHex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Consolidate from temporary Nostr peer IDs (nostr_* prefixed)
|
|
||||||
let normalizedNickname = peerNickname.lowercased()
|
|
||||||
var tempPeerIDsToConsolidate: [PeerID] = []
|
|
||||||
|
|
||||||
for (storedPeerID, messages) in privateChats {
|
|
||||||
if storedPeerID.isGeoDM && storedPeerID != peerID {
|
|
||||||
let nicknamesMatch = messages.allSatisfy { $0.sender.lowercased() == normalizedNickname }
|
|
||||||
if nicknamesMatch && !messages.isEmpty {
|
|
||||||
tempPeerIDsToConsolidate.append(storedPeerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !tempPeerIDsToConsolidate.isEmpty {
|
|
||||||
if privateChats[peerID] == nil {
|
|
||||||
privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
||||||
var consolidatedCount = 0
|
|
||||||
var hadUnreadTemp = false
|
|
||||||
|
|
||||||
for tempPeerID in tempPeerIDsToConsolidate {
|
|
||||||
if unreadMessages.contains(tempPeerID) {
|
|
||||||
hadUnreadTemp = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if let tempMessages = privateChats[tempPeerID] {
|
|
||||||
for message in tempMessages {
|
|
||||||
if !existingMessageIds.contains(message.id) {
|
|
||||||
let updatedMessage = BitchatMessage(
|
|
||||||
id: message.id,
|
|
||||||
sender: message.sender,
|
|
||||||
content: message.content,
|
|
||||||
timestamp: message.timestamp,
|
|
||||||
isRelay: message.isRelay,
|
|
||||||
originalSender: message.originalSender,
|
|
||||||
isPrivate: message.isPrivate,
|
|
||||||
recipientNickname: message.recipientNickname,
|
|
||||||
senderPeerID: peerID,
|
|
||||||
mentions: message.mentions,
|
|
||||||
deliveryStatus: message.deliveryStatus
|
|
||||||
)
|
|
||||||
privateChats[peerID]?.append(updatedMessage)
|
|
||||||
consolidatedCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
privateChats.removeValue(forKey: tempPeerID)
|
|
||||||
unreadMessages.remove(tempPeerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hadUnreadTemp {
|
|
||||||
unreadMessages.insert(peerID)
|
|
||||||
hasUnreadMessages = true
|
|
||||||
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
if consolidatedCount > 0 {
|
|
||||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
||||||
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasUnreadMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Syncs the read receipt tracking between manager and view model for sent messages
|
|
||||||
@MainActor
|
|
||||||
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
|
||||||
guard let messages = privateChats[peerID] else { return }
|
|
||||||
|
|
||||||
for message in messages {
|
|
||||||
if message.sender == nickname {
|
|
||||||
if let status = message.deliveryStatus {
|
|
||||||
switch status {
|
|
||||||
case .read, .delivered:
|
|
||||||
externalReceipts.insert(message.id)
|
|
||||||
sentReadReceipts.insert(message.id)
|
|
||||||
case .failed, .partiallyDelivered, .sending, .sent:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer
|
||||||
func startChat(with peerID: PeerID) {
|
func startChat(with peerID: PeerID) {
|
||||||
@@ -259,7 +105,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
// Create read receipt using the simplified method
|
// Create read receipt using the simplified method
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
originalMessageID: message.id,
|
originalMessageID: message.id,
|
||||||
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
readerID: meshService?.myPeerID.id ?? "",
|
||||||
readerNickname: meshService?.myNickname ?? ""
|
readerNickname: meshService?.myNickname ?? ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ struct RelayController {
|
|||||||
senderIsSelf: Bool,
|
senderIsSelf: Bool,
|
||||||
isEncrypted: Bool,
|
isEncrypted: Bool,
|
||||||
isDirectedEncrypted: Bool,
|
isDirectedEncrypted: Bool,
|
||||||
isFragment: Bool,
|
|
||||||
isDirectedFragment: Bool,
|
isDirectedFragment: Bool,
|
||||||
isHandshake: Bool,
|
isHandshake: Bool,
|
||||||
isAnnounce: Bool,
|
isAnnounce: Bool,
|
||||||
@@ -37,16 +36,6 @@ struct RelayController {
|
|||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isFragment {
|
|
||||||
let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)
|
|
||||||
guard ttlLimit > 1 else {
|
|
||||||
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
|
|
||||||
}
|
|
||||||
let newTTL = ttlLimit &- 1
|
|
||||||
let delayMs = Int.random(in: TransportConfig.bleFragmentRelayMinDelayMs...TransportConfig.bleFragmentRelayMaxDelayMs)
|
|
||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TTL clamping for broadcast
|
// TTL clamping for broadcast
|
||||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||||
// - Announces get a bit more headroom
|
// - Announces get a bit more headroom
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ protocol Transport: AnyObject {
|
|||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
func sendMessage(_ content: String, mentions: [String])
|
func sendMessage(_ content: String, mentions: [String])
|
||||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
@@ -66,10 +65,6 @@ extension Transport {
|
|||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||||
func cancelTransfer(_ transferId: String) {}
|
func cancelTransfer(_ transferId: String) {}
|
||||||
|
|
||||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
|
||||||
sendMessage(content, mentions: mentions)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ enum TransportConfig {
|
|||||||
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
||||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
|
||||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
|
||||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
|
||||||
static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods
|
|
||||||
|
|
||||||
// UI / Storage Caps
|
// UI / Storage Caps
|
||||||
static let privateChatCap: Int = 1337
|
static let privateChatCap: Int = 1337
|
||||||
@@ -70,7 +66,6 @@ enum TransportConfig {
|
|||||||
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
||||||
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
||||||
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
||||||
static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0
|
|
||||||
|
|
||||||
// BLE maintenance & thresholds
|
// BLE maintenance & thresholds
|
||||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
static let bleMaintenanceInterval: TimeInterval = 5.0
|
||||||
@@ -150,9 +145,6 @@ enum TransportConfig {
|
|||||||
|
|
||||||
// Geo relay directory
|
// Geo relay directory
|
||||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
||||||
static let geoRelayRefreshCheckIntervalSeconds: TimeInterval = 60 * 60
|
|
||||||
static let geoRelayRetryInitialSeconds: TimeInterval = 60
|
|
||||||
static let geoRelayRetryMaxSeconds: TimeInterval = 60 * 60
|
|
||||||
|
|
||||||
// BLE operational delays
|
// BLE operational delays
|
||||||
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
||||||
|
|||||||
@@ -235,10 +235,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer ID for nickname
|
/// Get peer ID for nickname
|
||||||
func getPeerID(for nickname: String) -> PeerID? {
|
func getPeerID(for nickname: String) -> String? {
|
||||||
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
|
return peer.peerID.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -347,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: Set<PeerID> { connectedPeerIDs }
|
var connectedPeers: [PeerID] { Array(connectedPeerIDs) }
|
||||||
var favoritePeers: Set<String> {
|
var favoritePeers: Set<String> {
|
||||||
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,55 +8,6 @@ final class GossipSyncManager {
|
|||||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct PacketStore {
|
|
||||||
private(set) var packets: [String: BitchatPacket] = [:]
|
|
||||||
private(set) var order: [String] = []
|
|
||||||
|
|
||||||
mutating func insert(idHex: String, packet: BitchatPacket, capacity: Int) {
|
|
||||||
guard capacity > 0 else { return }
|
|
||||||
if packets[idHex] != nil {
|
|
||||||
packets[idHex] = packet
|
|
||||||
return
|
|
||||||
}
|
|
||||||
packets[idHex] = packet
|
|
||||||
order.append(idHex)
|
|
||||||
while order.count > capacity {
|
|
||||||
let victim = order.removeFirst()
|
|
||||||
packets.removeValue(forKey: victim)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func allPackets(isFresh: (BitchatPacket) -> Bool) -> [BitchatPacket] {
|
|
||||||
order.compactMap { key in
|
|
||||||
guard let packet = packets[key], isFresh(packet) else { return nil }
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func remove(where shouldRemove: (BitchatPacket) -> Bool) {
|
|
||||||
var nextOrder: [String] = []
|
|
||||||
for key in order {
|
|
||||||
guard let packet = packets[key] else { continue }
|
|
||||||
if shouldRemove(packet) {
|
|
||||||
packets.removeValue(forKey: key)
|
|
||||||
} else {
|
|
||||||
nextOrder.append(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
order = nextOrder
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeExpired(isFresh: (BitchatPacket) -> Bool) {
|
|
||||||
remove { !isFresh($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SyncSchedule {
|
|
||||||
let types: SyncTypeFlags
|
|
||||||
let interval: TimeInterval
|
|
||||||
var lastSent: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Config {
|
struct Config {
|
||||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
@@ -65,43 +16,25 @@ final class GossipSyncManager {
|
|||||||
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||||
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||||
var fragmentCapacity: Int = 600
|
|
||||||
var fileTransferCapacity: Int = 200
|
|
||||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
|
||||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
|
||||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let myPeerID: PeerID
|
private let myPeerID: PeerID
|
||||||
private let config: Config
|
private let config: Config
|
||||||
weak var delegate: Delegate?
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
// Storage: broadcast packets by type, and latest announce per sender
|
// Storage: broadcast messages (ordered by insert), and latest announce per sender
|
||||||
private var messages = PacketStore()
|
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
|
||||||
private var fragments = PacketStore()
|
private var messageOrder: [String] = []
|
||||||
private var fileTransfers = PacketStore()
|
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
|
||||||
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
|
||||||
|
|
||||||
// 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
|
private var lastStalePeerCleanup: Date = .distantPast
|
||||||
private var syncSchedules: [SyncSchedule] = []
|
|
||||||
|
|
||||||
init(myPeerID: PeerID, config: Config = Config()) {
|
init(myPeerID: PeerID, config: Config = Config()) {
|
||||||
self.myPeerID = myPeerID
|
self.myPeerID = myPeerID
|
||||||
self.config = config
|
self.config = config
|
||||||
var schedules: [SyncSchedule] = []
|
|
||||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
|
||||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
|
||||||
}
|
|
||||||
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
|
|
||||||
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
|
|
||||||
}
|
|
||||||
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
|
|
||||||
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
|
|
||||||
}
|
|
||||||
syncSchedules = schedules
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
@@ -122,18 +55,7 @@ final class GossipSyncManager {
|
|||||||
|
|
||||||
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||||
guard let self = self else { return }
|
self?.sendRequestSync(to: peerID)
|
||||||
self.sendRequestSync(to: peerID, types: .publicMessages)
|
|
||||||
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
|
||||||
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
||||||
self?.sendRequestSync(to: peerID, types: .fragment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
|
||||||
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
||||||
self?.sendRequestSync(to: peerID, types: .fileTransfer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,45 +87,47 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
guard let messageType = MessageType(rawValue: packet.type) else { return }
|
let mt = MessageType(rawValue: packet.type)
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
guard let r = packet.recipientID else { return true }
|
guard let r = packet.recipientID else { return true }
|
||||||
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
||||||
}()
|
}()
|
||||||
|
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
|
||||||
|
let isAnnounce = (mt == .announce)
|
||||||
|
guard isBroadcastMessage || isAnnounce else { return }
|
||||||
|
|
||||||
switch messageType {
|
// Reject expired packets to prevent ghost peers and old messages
|
||||||
case .announce:
|
guard isPacketFresh(packet) else { return }
|
||||||
guard isPacketFresh(packet) else { return }
|
|
||||||
|
if isAnnounce {
|
||||||
guard isAnnouncementFresh(packet) else {
|
guard isAnnouncementFresh(packet) else {
|
||||||
let sender = PeerID(hexData: packet.senderID)
|
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||||
removeState(for: sender)
|
removeState(forNormalizedPeerID: sender)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
}
|
||||||
let sender = PeerID(hexData: packet.senderID)
|
|
||||||
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
|
||||||
|
if isBroadcastMessage {
|
||||||
|
if messages[idHex] == nil {
|
||||||
|
messages[idHex] = packet
|
||||||
|
messageOrder.append(idHex)
|
||||||
|
// Enforce capacity
|
||||||
|
let cap = max(1, config.seenCapacity)
|
||||||
|
while messageOrder.count > cap {
|
||||||
|
let victim = messageOrder.removeFirst()
|
||||||
|
messages.removeValue(forKey: victim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if isAnnounce {
|
||||||
|
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||||
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||||
case .message:
|
|
||||||
guard isBroadcastRecipient else { return }
|
|
||||||
guard isPacketFresh(packet) else { return }
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
|
||||||
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
|
||||||
case .fragment:
|
|
||||||
guard isBroadcastRecipient else { return }
|
|
||||||
guard isPacketFresh(packet) else { return }
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
|
||||||
fragments.insert(idHex: idHex, packet: packet, capacity: max(1, config.fragmentCapacity))
|
|
||||||
case .fileTransfer:
|
|
||||||
guard isBroadcastRecipient else { return }
|
|
||||||
guard isPacketFresh(packet) else { return }
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
|
||||||
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync(for types: SyncTypeFlags) {
|
private func sendRequestSync() {
|
||||||
let payload = buildGcsPayload(for: types)
|
let payload = buildGcsPayload()
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.requestSync.rawValue,
|
type: MessageType.requestSync.rawValue,
|
||||||
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
@@ -217,8 +141,8 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(signed)
|
delegate?.sendPacket(signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
private func sendRequestSync(to peerID: PeerID) {
|
||||||
let payload = buildGcsPayload(for: types)
|
let payload = buildGcsPayload()
|
||||||
var recipient = Data()
|
var recipient = Data()
|
||||||
var temp = peerID.id
|
var temp = peerID.id
|
||||||
while temp.count >= 2 && recipient.count < 8 {
|
while temp.count >= 2 && recipient.count < 8 {
|
||||||
@@ -246,7 +170,6 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||||
let requestedTypes = (request.types ?? .publicMessages)
|
|
||||||
// Decode GCS into sorted set and prepare membership checker
|
// Decode GCS into sorted set and prepare membership checker
|
||||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||||
func mightContain(_ id: Data) -> Bool {
|
func mightContain(_ id: Data) -> Bool {
|
||||||
@@ -254,100 +177,60 @@ final class GossipSyncManager {
|
|||||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
if requestedTypes.contains(.announce) {
|
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
||||||
for (_, pair) in latestAnnouncementByPeer {
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
let (idHex, pkt) = pair
|
let (idHex, pkt) = pair
|
||||||
guard isPacketFresh(pkt) else { continue }
|
guard isPacketFresh(pkt) else { continue }
|
||||||
let idBytes = Data(hexString: idHex) ?? Data()
|
let idBytes = Data(hexString: idHex) ?? Data()
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if requestedTypes.contains(.message) {
|
// 2) Broadcast messages: send all missing (and not expired)
|
||||||
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
|
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||||
for pkt in toSendMsgs {
|
for pkt in toSendMsgs {
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
guard isPacketFresh(pkt) else { continue }
|
||||||
if !mightContain(idBytes) {
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
var toSend = pkt
|
if !mightContain(idBytes) {
|
||||||
toSend.ttl = 0
|
var toSend = pkt
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
toSend.ttl = 0
|
||||||
}
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestedTypes.contains(.fragment) {
|
|
||||||
let frags = fragments.allPackets(isFresh: isPacketFresh)
|
|
||||||
for pkt in frags {
|
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
|
||||||
if !mightContain(idBytes) {
|
|
||||||
var toSend = pkt
|
|
||||||
toSend.ttl = 0
|
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestedTypes.contains(.fileTransfer) {
|
|
||||||
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
|
|
||||||
for pkt in files {
|
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
|
||||||
if !mightContain(idBytes) {
|
|
||||||
var toSend = pkt
|
|
||||||
toSend.ttl = 0
|
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
|
private func buildGcsPayload() -> Data {
|
||||||
|
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
||||||
var candidates: [BitchatPacket] = []
|
var candidates: [BitchatPacket] = []
|
||||||
if types.contains(.announce) {
|
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||||
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
|
if isPacketFresh(pair.packet) {
|
||||||
candidates.append(pair.packet)
|
candidates.append(pair.packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if types.contains(.message) {
|
for id in messageOrder {
|
||||||
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
|
if let p = messages[id], isPacketFresh(p) {
|
||||||
|
candidates.append(p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if types.contains(.fragment) {
|
|
||||||
candidates.append(contentsOf: fragments.allPackets(isFresh: isPacketFresh))
|
|
||||||
}
|
|
||||||
if types.contains(.fileTransfer) {
|
|
||||||
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
|
||||||
}
|
|
||||||
if candidates.isEmpty {
|
|
||||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
|
||||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
|
||||||
return req.encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by timestamp desc
|
// Sort by timestamp desc
|
||||||
candidates.sort { $0.timestamp > $1.timestamp }
|
candidates.sort { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||||
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
||||||
let cap: Int
|
let cap = max(1, config.seenCapacity)
|
||||||
if types == .fragment {
|
|
||||||
cap = max(1, config.fragmentCapacity)
|
|
||||||
} else if types == .fileTransfer {
|
|
||||||
cap = max(1, config.fileTransferCapacity)
|
|
||||||
} else {
|
|
||||||
cap = max(1, config.seenCapacity)
|
|
||||||
}
|
|
||||||
let takeN = min(candidates.count, min(nMax, cap))
|
let takeN = min(candidates.count, min(nMax, cap))
|
||||||
if takeN <= 0 {
|
if takeN <= 0 {
|
||||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
let req = RequestSyncPacket(p: p, m: 1, data: Data())
|
||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
||||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
||||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
|
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
|
||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,21 +241,20 @@ final class GossipSyncManager {
|
|||||||
isPacketFresh(pair.packet)
|
isPacketFresh(pair.packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.removeExpired(isFresh: isPacketFresh)
|
// Remove expired messages
|
||||||
fragments.removeExpired(isFresh: isPacketFresh)
|
let expiredMessageIds = messages.compactMap { id, pkt in
|
||||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
isPacketFresh(pkt) ? nil : id
|
||||||
|
}
|
||||||
|
for id in expiredMessageIds {
|
||||||
|
messages.removeValue(forKey: id)
|
||||||
|
messageOrder.removeAll { $0 == id }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||||
cleanupExpiredMessages()
|
cleanupExpiredMessages()
|
||||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
for index in syncSchedules.indices {
|
sendRequestSync()
|
||||||
guard syncSchedules[index].interval > 0 else { continue }
|
|
||||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
|
||||||
syncSchedules[index].lastSent = now
|
|
||||||
sendRequestSync(for: syncSchedules[index].types)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
@@ -388,27 +270,40 @@ final class GossipSyncManager {
|
|||||||
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||||
guard nowMs >= timeoutMs else { return }
|
guard nowMs >= timeoutMs else { return }
|
||||||
let cutoff = nowMs - timeoutMs
|
let cutoff = nowMs - timeoutMs
|
||||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
|
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
|
||||||
pair.packet.timestamp < cutoff ? peerID : nil
|
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
|
||||||
}
|
}
|
||||||
guard !stalePeerIDs.isEmpty else { return }
|
guard !stalePeerIDs.isEmpty else { return }
|
||||||
for peerKey in stalePeerIDs {
|
for peerKey in stalePeerIDs {
|
||||||
removeState(for: peerKey)
|
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
|
||||||
self?.removeState(for: peerID)
|
self?._removeAnnouncementForPeer(peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeState(for peerID: PeerID) {
|
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
let normalizedPeerID = peerID.id.lowercased()
|
||||||
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
removeState(forNormalizedPeerID: normalizedPeerID)
|
||||||
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
}
|
||||||
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
|
||||||
|
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
|
||||||
|
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||||
|
// Remove messages from this peer
|
||||||
|
// Collect IDs to remove first to avoid concurrent modification
|
||||||
|
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
||||||
|
message.senderID.hexEncodedString().lowercased() == normalizedPeerID ? id : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove messages and update messageOrder
|
||||||
|
for id in messageIdsToRemove {
|
||||||
|
messages.removeValue(forKey: id)
|
||||||
|
messageOrder.removeAll { $0 == id }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,13 +317,13 @@ extension GossipSyncManager {
|
|||||||
|
|
||||||
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
latestAnnouncementByPeer[peerID] != nil
|
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func _messageCount(for peerID: PeerID) -> Int {
|
func _messageCount(for peerID: PeerID) -> Int {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
|
|
||||||
/// Matches the Android mapping (bit index -> message type).
|
|
||||||
struct SyncTypeFlags: OptionSet {
|
|
||||||
let rawValue: UInt64
|
|
||||||
|
|
||||||
init(rawValue: UInt64) {
|
|
||||||
self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func bitIndex(for type: MessageType) -> Int? {
|
|
||||||
switch type {
|
|
||||||
case .announce: return 0
|
|
||||||
case .message: return 1
|
|
||||||
case .leave: return 2
|
|
||||||
case .noiseHandshake: return 3
|
|
||||||
case .noiseEncrypted: return 4
|
|
||||||
case .fragment: return 5
|
|
||||||
case .requestSync: return 6
|
|
||||||
case .fileTransfer: return 7
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func type(forBit index: Int) -> MessageType? {
|
|
||||||
switch index {
|
|
||||||
case 0: return .announce
|
|
||||||
case 1: return .message
|
|
||||||
case 2: return .leave
|
|
||||||
case 3: return .noiseHandshake
|
|
||||||
case 4: return .noiseEncrypted
|
|
||||||
case 5: return .fragment
|
|
||||||
case 6: return .requestSync
|
|
||||||
case 7: return .fileTransfer
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let announce = SyncTypeFlags(messageTypes: [.announce])
|
|
||||||
static let message = SyncTypeFlags(messageTypes: [.message])
|
|
||||||
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
|
||||||
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
|
||||||
|
|
||||||
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
|
||||||
|
|
||||||
init(messageTypes: [MessageType]) {
|
|
||||||
var raw: UInt64 = 0
|
|
||||||
for type in messageTypes {
|
|
||||||
guard let bit = SyncTypeFlags.bitIndex(for: type) else { continue }
|
|
||||||
raw |= (1 << UInt64(bit))
|
|
||||||
}
|
|
||||||
self.init(rawValue: raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(_ type: MessageType) -> Bool {
|
|
||||||
guard let bit = SyncTypeFlags.bitIndex(for: type) else { return false }
|
|
||||||
return contains(SyncTypeFlags(rawValue: 1 << UInt64(bit)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func union(_ other: SyncTypeFlags) -> SyncTypeFlags {
|
|
||||||
SyncTypeFlags(rawValue: rawValue | other.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func intersection(_ other: SyncTypeFlags) -> SyncTypeFlags {
|
|
||||||
SyncTypeFlags(rawValue: rawValue & other.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func toMessageTypes() -> [MessageType] {
|
|
||||||
guard rawValue != 0 else { return [] }
|
|
||||||
var types: [MessageType] = []
|
|
||||||
for bit in 0..<64 {
|
|
||||||
guard (rawValue & (1 << UInt64(bit))) != 0 else { continue }
|
|
||||||
if let type = SyncTypeFlags.type(forBit: bit) {
|
|
||||||
types.append(type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return types
|
|
||||||
}
|
|
||||||
|
|
||||||
func toData() -> Data? {
|
|
||||||
guard rawValue != 0 else { return nil }
|
|
||||||
var value = rawValue
|
|
||||||
var bytes: [UInt8] = []
|
|
||||||
while value > 0 && bytes.count < 8 {
|
|
||||||
bytes.append(UInt8(value & 0xFF))
|
|
||||||
value >>= 8
|
|
||||||
}
|
|
||||||
while let last = bytes.last, last == 0 {
|
|
||||||
bytes.removeLast()
|
|
||||||
}
|
|
||||||
guard !bytes.isEmpty, bytes.count <= 8 else { return nil }
|
|
||||||
return Data(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(_ data: Data) -> SyncTypeFlags? {
|
|
||||||
guard (1...8).contains(data.count) else { return nil }
|
|
||||||
var raw: UInt64 = 0
|
|
||||||
for (index, byte) in data.enumerated() {
|
|
||||||
raw |= UInt64(byte) << UInt64(index * 8)
|
|
||||||
}
|
|
||||||
return SyncTypeFlags(rawValue: raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -61,13 +61,15 @@ struct CompressionUtil {
|
|||||||
// 1. Data is too small
|
// 1. Data is too small
|
||||||
// 2. Data appears to be already compressed (high entropy)
|
// 2. Data appears to be already compressed (high entropy)
|
||||||
guard data.count >= compressionThreshold else { return false }
|
guard data.count >= compressionThreshold else { return false }
|
||||||
|
|
||||||
// Quick uniqueness check — a high diversity of bytes usually means the
|
// Simple entropy check - count unique bytes
|
||||||
// payload is already compressed. We only need to know how many unique
|
var byteFrequency = [UInt8: Int]()
|
||||||
// values exist rather than keeping full frequency counts.
|
for byte in data {
|
||||||
let uniqueByteCount = Set(data).count
|
byteFrequency[byte, default: 0] += 1
|
||||||
let sampleSize = min(data.count, 256)
|
}
|
||||||
let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize)
|
|
||||||
|
// If we have very high byte diversity, data is likely already compressed
|
||||||
|
let uniqueByteRatio = Double(byteFrequency.count) / Double(min(data.count, 256))
|
||||||
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
|
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,9 @@ enum FileTransferLimits {
|
|||||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||||
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
/// Voice notes stay small for low-latency relays.
|
/// Voice notes stay small for low-latency relays.
|
||||||
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||||
static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
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 {
|
static func isValidPayload(_ size: Int) -> Bool {
|
||||||
size <= maxPayloadBytes
|
size <= maxPayloadBytes
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
|
||||||
|
|
||||||
/// Comprehensive input validation for BitChat protocol
|
/// Comprehensive input validation for BitChat protocol
|
||||||
/// Prevents injection attacks, buffer overflows, and malformed data
|
/// Prevents injection attacks, buffer overflows, and malformed data
|
||||||
@@ -17,28 +16,29 @@ struct InputValidator {
|
|||||||
// MARK: - String Content Validation
|
// MARK: - String Content Validation
|
||||||
|
|
||||||
/// Validates and sanitizes user-provided strings used in UI
|
/// Validates and sanitizes user-provided strings used in UI
|
||||||
///
|
|
||||||
/// Rejects strings containing control characters to prevent potential security issues
|
|
||||||
/// and UI rendering problems. This strict approach ensures data integrity at input time.
|
|
||||||
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||||
|
// Check empty
|
||||||
|
guard !string.isEmpty else { return nil }
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|
||||||
|
// Check length
|
||||||
guard trimmed.count <= maxLength else { return nil }
|
guard trimmed.count <= maxLength else { return nil }
|
||||||
|
|
||||||
// Reject control characters outright instead of rewriting the string.
|
// Remove control characters
|
||||||
// This prevents injection attacks and ensures consistent UI rendering.
|
|
||||||
let controlChars = CharacterSet.controlCharacters
|
let controlChars = CharacterSet.controlCharacters
|
||||||
if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) {
|
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
||||||
// Log rejection for monitoring, without exposing actual content for privacy
|
|
||||||
let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count
|
// Ensure valid UTF-8 (should already be, but double-check)
|
||||||
SecureLogger.debug(
|
guard cleaned.data(using: .utf8) != nil else { return nil }
|
||||||
"Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))",
|
|
||||||
category: .security
|
// Prevent zero-width characters and other invisible unicode
|
||||||
)
|
let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}")
|
||||||
return nil
|
let visible = cleaned.components(separatedBy: invisibleChars).joined()
|
||||||
}
|
|
||||||
|
return visible.isEmpty ? nil : visible
|
||||||
return trimmed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates nickname
|
/// Validates nickname
|
||||||
|
|||||||
@@ -2,98 +2,38 @@ import Foundation
|
|||||||
|
|
||||||
// MARK: - Message Deduplicator (shared)
|
// MARK: - Message Deduplicator (shared)
|
||||||
|
|
||||||
/// Thread-safe deduplicator with LRU eviction and time-based expiry.
|
|
||||||
/// Used for both message ID deduplication (network layer) and content key deduplication (UI layer).
|
|
||||||
final class MessageDeduplicator {
|
final class MessageDeduplicator {
|
||||||
private struct Entry {
|
private struct Entry {
|
||||||
let id: String
|
let messageID: String
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
private var entries: [Entry] = []
|
private var entries: [Entry] = []
|
||||||
private var head: Int = 0
|
private var head: Int = 0
|
||||||
private var lookup: [String: Date] = [:] // id -> timestamp for O(1) lookup
|
private var lookup = Set<String>()
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private let maxAge: TimeInterval
|
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
|
||||||
private let maxCount: Int
|
private let maxCount = TransportConfig.messageDedupMaxCount
|
||||||
|
|
||||||
/// Initialize with default config from TransportConfig
|
|
||||||
convenience init() {
|
|
||||||
self.init(
|
|
||||||
maxAge: TransportConfig.messageDedupMaxAgeSeconds,
|
|
||||||
maxCount: TransportConfig.messageDedupMaxCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize with custom config for content deduplication
|
|
||||||
init(maxAge: TimeInterval, maxCount: Int) {
|
|
||||||
self.maxAge = maxAge
|
|
||||||
self.maxCount = maxCount
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if message is duplicate and add if not
|
/// Check if message is duplicate and add if not
|
||||||
func isDuplicate(_ id: String) -> Bool {
|
func isDuplicate(_ messageID: String) -> Bool {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
|
|
||||||
cleanupOldEntries()
|
cleanupOldEntries()
|
||||||
|
|
||||||
if lookup[id] != nil {
|
if lookup.contains(messageID) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Date()
|
entries.append(Entry(messageID: messageID, timestamp: Date()))
|
||||||
entries.append(Entry(id: id, timestamp: now))
|
lookup.insert(messageID)
|
||||||
lookup[id] = now
|
|
||||||
trimIfNeeded()
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record an ID with a specific timestamp (for content key tracking)
|
|
||||||
func record(_ id: String, timestamp: Date) {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
|
|
||||||
if lookup[id] == nil {
|
|
||||||
entries.append(Entry(id: id, timestamp: timestamp))
|
|
||||||
}
|
|
||||||
lookup[id] = timestamp
|
|
||||||
trimIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add an ID without checking (for announce-back tracking)
|
|
||||||
func markProcessed(_ id: String) {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
|
|
||||||
if lookup[id] == nil {
|
|
||||||
let now = Date()
|
|
||||||
entries.append(Entry(id: id, timestamp: now))
|
|
||||||
lookup[id] = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if ID exists without adding
|
|
||||||
func contains(_ id: String) -> Bool {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return lookup[id] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get timestamp for an ID (for content deduplication time-window checks)
|
|
||||||
func timestampFor(_ id: String) -> Date? {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return lookup[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
private func trimIfNeeded() {
|
|
||||||
// Soft-cap and advance head by a chunk to avoid O(n) shifting
|
// Soft-cap and advance head by a chunk to avoid O(n) shifting
|
||||||
if (entries.count - head) > maxCount {
|
if (entries.count - head) > maxCount {
|
||||||
let removeCount = min(100, entries.count - head)
|
let removeCount = min(100, entries.count - head)
|
||||||
for i in head..<(head + removeCount) {
|
for i in head..<(head + removeCount) {
|
||||||
lookup.removeValue(forKey: entries[i].id)
|
lookup.remove(entries[i].messageID)
|
||||||
}
|
}
|
||||||
head += removeCount
|
head += removeCount
|
||||||
// Periodically compact to reclaim memory
|
// Periodically compact to reclaim memory
|
||||||
@@ -102,6 +42,26 @@ final class MessageDeduplicator {
|
|||||||
head = 0
|
head = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an ID without checking (for announce-back tracking)
|
||||||
|
func markProcessed(_ messageID: String) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
|
||||||
|
if !lookup.contains(messageID) {
|
||||||
|
entries.append(Entry(messageID: messageID, timestamp: Date()))
|
||||||
|
lookup.insert(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if ID exists without adding
|
||||||
|
func contains(_ messageID: String) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return lookup.contains(messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all entries
|
/// Clear all entries
|
||||||
@@ -129,7 +89,7 @@ final class MessageDeduplicator {
|
|||||||
private func cleanupOldEntries() {
|
private func cleanupOldEntries() {
|
||||||
let cutoff = Date().addingTimeInterval(-maxAge)
|
let cutoff = Date().addingTimeInterval(-maxAge)
|
||||||
while head < entries.count, entries[head].timestamp < cutoff {
|
while head < entries.count, entries[head].timestamp < cutoff {
|
||||||
lookup.removeValue(forKey: entries[head].id)
|
lookup.remove(entries[head].messageID)
|
||||||
head += 1
|
head += 1
|
||||||
}
|
}
|
||||||
if head > 0 && head > entries.count / 2 {
|
if head > 0 && head > entries.count / 2 {
|
||||||
|
|||||||
+3586
-668
File diff suppressed because it is too large
Load Diff
@@ -1,814 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModel+Nostr.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Geohash and Nostr logic for ChatViewModel
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import BitLogger
|
|
||||||
import SwiftUI
|
|
||||||
import Tor
|
|
||||||
|
|
||||||
extension ChatViewModel {
|
|
||||||
|
|
||||||
// MARK: - Geohash Subscription
|
|
||||||
|
|
||||||
// Resubscribe to the active geohash channel without clearing timeline
|
|
||||||
@MainActor
|
|
||||||
func resubscribeCurrentGeohash() {
|
|
||||||
guard case .location(let ch) = activeChannel else { return }
|
|
||||||
guard let subID = geoSubscriptionID else {
|
|
||||||
// No existing subscription; set it up
|
|
||||||
switchLocationChannel(to: activeChannel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Ensure participant decay timer is running
|
|
||||||
participantTracker.startRefreshTimer()
|
|
||||||
// Unsubscribe + resubscribe
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
|
||||||
let filter = NostrFilter.geohashEphemeral(
|
|
||||||
ch.geohash,
|
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
|
|
||||||
limit: TransportConfig.nostrGeohashInitialLimit
|
|
||||||
)
|
|
||||||
let subRelays = GeoRelayDirectory.shared.closestRelays(
|
|
||||||
toGeohash: ch.geohash,
|
|
||||||
count: TransportConfig.nostrGeoRelayCount
|
|
||||||
)
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
|
||||||
self?.subscribeNostrEvent(event)
|
|
||||||
}
|
|
||||||
// Resubscribe geohash DMs for this identity
|
|
||||||
if let dmSub = geoDmSubscriptionID {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
|
||||||
let dmSub = "geo-dm-\(ch.geohash)"
|
|
||||||
geoDmSubscriptionID = dmSub
|
|
||||||
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
|
||||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
|
||||||
self?.subscribeGiftWrap(giftWrap, id: id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
|
||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
|
||||||
!deduplicationService.hasProcessedNostrEvent(event.id)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
deduplicationService.recordNostrEvent(event.id)
|
|
||||||
|
|
||||||
if let gh = currentGeohash,
|
|
||||||
let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh),
|
|
||||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
|
||||||
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
|
|
||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
if Date().timeIntervalSince(eventTime) < 15 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
|
||||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
geoNicknames[event.pubkey.lowercased()] = nick
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store mapping for geohash sender IDs used in messages (ensures consistent colors)
|
|
||||||
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
|
||||||
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
|
||||||
|
|
||||||
// Update participants last-seen for this pubkey
|
|
||||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
|
||||||
|
|
||||||
// Track teleported tag (only our format ["t","teleport"]) for icon state
|
|
||||||
let hasTeleportTag = event.tags.contains(where: { tag in
|
|
||||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
|
||||||
})
|
|
||||||
|
|
||||||
if hasTeleportTag {
|
|
||||||
let key = event.pubkey.lowercased()
|
|
||||||
// Do not mark our own key from historical events; rely on manager.teleported for self
|
|
||||||
let isSelf: Bool = {
|
|
||||||
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
|
|
||||||
return my.publicKeyHex.lowercased() == key
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
if !isSelf {
|
|
||||||
Task { @MainActor in
|
|
||||||
teleportedGeo = teleportedGeo.union([key])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
|
||||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
|
|
||||||
// Clamp future timestamps to now to avoid future-dated messages skewing order
|
|
||||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
let timestamp = min(rawTs, Date())
|
|
||||||
let mentions = parseMentions(from: content)
|
|
||||||
let msg = BitchatMessage(
|
|
||||||
id: event.id,
|
|
||||||
sender: senderName,
|
|
||||||
content: content,
|
|
||||||
timestamp: timestamp,
|
|
||||||
isRelay: false,
|
|
||||||
senderPeerID: PeerID(nostr: event.pubkey),
|
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
|
||||||
)
|
|
||||||
Task { @MainActor in
|
|
||||||
handlePublicMessage(msg)
|
|
||||||
checkForMentions(msg)
|
|
||||||
sendHapticFeedback(for: msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
|
||||||
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
|
||||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
|
||||||
|
|
||||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
|
|
||||||
content.hasPrefix("bitchat1:"),
|
|
||||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
|
||||||
let packet = BitchatPacket.from(packetData),
|
|
||||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
|
||||||
let noisePayload = NoisePayload.decode(packet.payload)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
|
||||||
nostrKeyMapping[convKey] = senderPubkey
|
|
||||||
|
|
||||||
switch noisePayload.type {
|
|
||||||
case .privateMessage:
|
|
||||||
handlePrivateMessage(noisePayload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
|
|
||||||
case .delivered:
|
|
||||||
handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
case .readReceipt:
|
|
||||||
handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
case .verifyChallenge, .verifyResponse:
|
|
||||||
// QR verification payloads over Nostr are not supported; ignore in geohash DMs
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash Channel Handling
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func switchLocationChannel(to channel: ChannelID) {
|
|
||||||
// Reset pending public batches to avoid cross-channel bleed
|
|
||||||
publicMessagePipeline.reset()
|
|
||||||
|
|
||||||
activeChannel = channel
|
|
||||||
publicMessagePipeline.updateActiveChannel(channel)
|
|
||||||
|
|
||||||
// Reset deduplication set and optionally hydrate timeline for mesh
|
|
||||||
deduplicationService.clearNostrCaches()
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
refreshVisibleMessages(from: .mesh)
|
|
||||||
// Debug: log if any empty messages are present
|
|
||||||
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
|
||||||
if emptyMesh > 0 {
|
|
||||||
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
|
|
||||||
}
|
|
||||||
participantTracker.stopRefreshTimer()
|
|
||||||
participantTracker.setActiveGeohash(nil)
|
|
||||||
teleportedGeo.removeAll()
|
|
||||||
case .location:
|
|
||||||
refreshVisibleMessages(from: channel)
|
|
||||||
}
|
|
||||||
// If switching to a location channel, flush any pending geohash-only system messages
|
|
||||||
if case .location = channel {
|
|
||||||
for content in timelineStore.drainPendingGeohashSystemMessages() {
|
|
||||||
addPublicSystemMessage(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Unsubscribe previous
|
|
||||||
if let sub = geoSubscriptionID {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
|
||||||
geoSubscriptionID = nil
|
|
||||||
}
|
|
||||||
if let dmSub = geoDmSubscriptionID {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
|
||||||
geoDmSubscriptionID = nil
|
|
||||||
}
|
|
||||||
currentGeohash = nil
|
|
||||||
participantTracker.setActiveGeohash(nil)
|
|
||||||
// Reset nickname cache for geochat participants
|
|
||||||
geoNicknames.removeAll()
|
|
||||||
|
|
||||||
guard case .location(let ch) = channel else { return }
|
|
||||||
currentGeohash = ch.geohash
|
|
||||||
participantTracker.setActiveGeohash(ch.geohash)
|
|
||||||
|
|
||||||
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported
|
|
||||||
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
|
||||||
participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex)
|
|
||||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
|
||||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
|
||||||
let key = id.publicKeyHex.lowercased()
|
|
||||||
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
|
|
||||||
teleportedGeo = teleportedGeo.union([key])
|
|
||||||
SecureLogger.info("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
|
||||||
} else {
|
|
||||||
teleportedGeo.remove(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let subID = "geo-\(ch.geohash)"
|
|
||||||
geoSubscriptionID = subID
|
|
||||||
participantTracker.startRefreshTimer()
|
|
||||||
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
|
|
||||||
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
|
|
||||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
|
||||||
self?.handleNostrEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribeToGeoChat(ch)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleNostrEvent(_ event: NostrEvent) {
|
|
||||||
// Only handle ephemeral kind 20000 with matching tag
|
|
||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
|
||||||
|
|
||||||
// Deduplicate
|
|
||||||
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
|
||||||
deduplicationService.recordNostrEvent(event.id)
|
|
||||||
|
|
||||||
// Log incoming tags for diagnostics
|
|
||||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
|
||||||
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
|
||||||
|
|
||||||
// Track teleport tag for participants – only our format ["t", "teleport"]
|
|
||||||
let hasTeleportTag: Bool = event.tags.contains { tag in
|
|
||||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
|
||||||
}
|
|
||||||
|
|
||||||
let isSelf: Bool = {
|
|
||||||
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
|
|
||||||
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
|
|
||||||
if hasTeleportTag {
|
|
||||||
// Avoid marking our own key from historical events; rely on manager.teleported for self
|
|
||||||
if !isSelf {
|
|
||||||
let key = event.pubkey.lowercased()
|
|
||||||
Task { @MainActor in
|
|
||||||
teleportedGeo = teleportedGeo.union([key])
|
|
||||||
SecureLogger.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip only very recent self-echo from relay; include older self events for hydration
|
|
||||||
if isSelf {
|
|
||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
if Date().timeIntervalSince(eventTime) < 15 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache nickname from tag if present
|
|
||||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
|
||||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
geoNicknames[event.pubkey.lowercased()] = nick
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this pubkey is blocked, skip mapping, participants, and timeline
|
|
||||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store mapping for geohash DM initiation
|
|
||||||
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
|
||||||
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
|
||||||
|
|
||||||
// Update participants last-seen for this pubkey
|
|
||||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
|
||||||
|
|
||||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
|
||||||
let content = event.content
|
|
||||||
|
|
||||||
// If this is a teleport presence event (no content), don't add to timeline
|
|
||||||
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"),
|
|
||||||
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp future timestamps
|
|
||||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
let mentions = parseMentions(from: content)
|
|
||||||
let msg = BitchatMessage(
|
|
||||||
id: event.id,
|
|
||||||
sender: senderName,
|
|
||||||
content: content,
|
|
||||||
timestamp: min(rawTs, Date()),
|
|
||||||
isRelay: false,
|
|
||||||
senderPeerID: PeerID(nostr: event.pubkey),
|
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
|
||||||
)
|
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
handlePublicMessage(msg)
|
|
||||||
checkForMentions(msg)
|
|
||||||
sendHapticFeedback(for: msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func subscribeToGeoChat(_ ch: GeohashChannel) {
|
|
||||||
guard let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
|
|
||||||
|
|
||||||
let dmSub = "geo-dm-\(ch.geohash)"
|
|
||||||
geoDmSubscriptionID = dmSub
|
|
||||||
// pared back logging: subscribe debug only
|
|
||||||
// Log GeoDM subscribe only when Tor is ready to avoid early noise
|
|
||||||
if TorManager.shared.isReady {
|
|
||||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
|
||||||
}
|
|
||||||
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
|
||||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
|
||||||
self?.handleGiftWrap(giftWrap, id: id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
|
||||||
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
|
||||||
|
|
||||||
// Decrypt with per-geohash identity
|
|
||||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
|
|
||||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
|
|
||||||
|
|
||||||
guard content.hasPrefix("bitchat1:"),
|
|
||||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
|
||||||
let packet = BitchatPacket.from(packetData),
|
|
||||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
|
||||||
let payload = NoisePayload.decode(packet.payload)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
|
||||||
nostrKeyMapping[convKey] = senderPubkey
|
|
||||||
|
|
||||||
switch payload.type {
|
|
||||||
case .privateMessage:
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
|
||||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
|
|
||||||
case .delivered:
|
|
||||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
case .readReceipt:
|
|
||||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
|
|
||||||
// Explicitly list other cases so we get compile-time check if a new case is added in the future
|
|
||||||
case .verifyChallenge, .verifyResponse:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func sendGeohash(context: GeoOutgoingContext) {
|
|
||||||
let ch = context.channel
|
|
||||||
let event = context.event
|
|
||||||
let identity = context.identity
|
|
||||||
|
|
||||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
|
||||||
toGeohash: ch.geohash,
|
|
||||||
count: TransportConfig.nostrGeoRelayCount
|
|
||||||
)
|
|
||||||
|
|
||||||
if targetRelays.isEmpty {
|
|
||||||
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
|
|
||||||
} else {
|
|
||||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track ourselves as active participant
|
|
||||||
participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
|
|
||||||
nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
|
|
||||||
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session)
|
|
||||||
|
|
||||||
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
|
|
||||||
// Only when not in our regional set (and regional list is known)
|
|
||||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
|
||||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
|
||||||
|
|
||||||
if context.teleported && hasRegional && !inRegional {
|
|
||||||
let key = identity.publicKeyHex.lowercased()
|
|
||||||
teleportedGeo = teleportedGeo.union([key])
|
|
||||||
SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
deduplicationService.recordNostrEvent(event.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Sampling
|
|
||||||
|
|
||||||
/// Begin sampling multiple geohashes (used by channel sheet) without changing active channel.
|
|
||||||
@MainActor
|
|
||||||
func beginGeohashSampling(for geohashes: [String]) {
|
|
||||||
// Disable sampling when app is backgrounded (Tor is stopped there)
|
|
||||||
if !TorManager.shared.isForeground() {
|
|
||||||
endGeohashSampling()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Determine which to add and which to remove
|
|
||||||
let desired = Set(geohashes)
|
|
||||||
let current = Set(geoSamplingSubs.values)
|
|
||||||
let toAdd = desired.subtracting(current)
|
|
||||||
let toRemove = current.subtracting(desired)
|
|
||||||
|
|
||||||
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
|
||||||
geoSamplingSubs.removeValue(forKey: subID)
|
|
||||||
}
|
|
||||||
|
|
||||||
for gh in toAdd {
|
|
||||||
subscribe(gh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func subscribe(_ gh: String) {
|
|
||||||
let subID = "geo-sample-\(gh)"
|
|
||||||
geoSamplingSubs[subID] = gh
|
|
||||||
let filter = NostrFilter.geohashEphemeral(
|
|
||||||
gh,
|
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
|
|
||||||
limit: TransportConfig.nostrGeohashSampleLimit
|
|
||||||
)
|
|
||||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
|
||||||
self?.subscribeNostrEvent(event, gh: gh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
|
||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
|
||||||
|
|
||||||
// Compute current participant count (5-minute window) BEFORE updating with this event
|
|
||||||
let existingCount = participantTracker.participantCount(for: gh)
|
|
||||||
|
|
||||||
// Update participants for this specific geohash
|
|
||||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
|
||||||
|
|
||||||
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
|
||||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !content.isEmpty else { return }
|
|
||||||
|
|
||||||
// Respect geohash blocks
|
|
||||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
|
||||||
|
|
||||||
// Skip self identity for this geohash
|
|
||||||
if let my = try? idBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
|
|
||||||
|
|
||||||
// Only trigger when there were zero participants in this geohash recently
|
|
||||||
guard existingCount == 0 else { return }
|
|
||||||
|
|
||||||
// Avoid notifications for old sampled events when launching or (re)subscribing
|
|
||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
if Date().timeIntervalSince(eventTime) > 30 { return }
|
|
||||||
|
|
||||||
// Foreground-only notifications: app must be active, and not already viewing this geohash
|
|
||||||
#if os(iOS)
|
|
||||||
guard UIApplication.shared.applicationState == .active else { return }
|
|
||||||
if case .location(let ch) = activeChannel, ch.geohash == gh { return }
|
|
||||||
#elseif os(macOS)
|
|
||||||
guard NSApplication.shared.isActive else { return }
|
|
||||||
if case .location(let ch) = activeChannel, ch.geohash == gh { return }
|
|
||||||
#endif
|
|
||||||
|
|
||||||
cooldownPerGeohash(gh, content: content, event: event)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
|
||||||
let now = Date()
|
|
||||||
let last = lastGeoNotificationAt[gh] ?? .distantPast
|
|
||||||
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
|
|
||||||
|
|
||||||
// Compose a short preview
|
|
||||||
let preview: String = {
|
|
||||||
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
|
|
||||||
if content.count <= maxLen { return content }
|
|
||||||
let idx = content.index(content.startIndex, offsetBy: maxLen)
|
|
||||||
return String(content[..<idx]) + "…"
|
|
||||||
}()
|
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
lastGeoNotificationAt[gh] = now
|
|
||||||
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
|
|
||||||
let senderSuffix = String(event.pubkey.suffix(4))
|
|
||||||
let nick = geoNicknames[event.pubkey.lowercased()]
|
|
||||||
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
|
||||||
|
|
||||||
// Clamp future timestamps
|
|
||||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
let ts = min(rawTs, Date())
|
|
||||||
let mentions = self.parseMentions(from: content)
|
|
||||||
let msg = BitchatMessage(
|
|
||||||
id: event.id,
|
|
||||||
sender: senderName,
|
|
||||||
content: content,
|
|
||||||
timestamp: ts,
|
|
||||||
isRelay: false,
|
|
||||||
senderPeerID: PeerID(nostr: event.pubkey),
|
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
|
||||||
)
|
|
||||||
if timelineStore.appendIfAbsent(msg, toGeohash: gh) {
|
|
||||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop sampling all extra geohashes.
|
|
||||||
@MainActor
|
|
||||||
func endGeohashSampling() {
|
|
||||||
for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) }
|
|
||||||
geoSamplingSubs.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr DM Handling
|
|
||||||
|
|
||||||
func setupNostrMessageHandling() {
|
|
||||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else {
|
|
||||||
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", category: .session)
|
|
||||||
|
|
||||||
// Subscribe to Nostr messages
|
|
||||||
let filter = NostrFilter.giftWrapsFor(
|
|
||||||
pubkey: currentIdentity.publicKeyHex,
|
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours
|
|
||||||
)
|
|
||||||
|
|
||||||
nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
|
||||||
self?.handleNostrMessage(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
|
||||||
// Deduplicate messages by ID
|
|
||||||
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return }
|
|
||||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
|
||||||
|
|
||||||
// Ensure we're on a background queue for decryption
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
|
||||||
await self?.processNostrMessage(giftWrap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
|
||||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
|
||||||
giftWrap: giftWrap,
|
|
||||||
recipientIdentity: currentIdentity
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handle verification payloads first
|
|
||||||
if content.hasPrefix("verify:") {
|
|
||||||
// Ignore verification payloads arriving via Nostr path for now
|
|
||||||
// Verification should ideally happen over mesh for security binding
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a BitChat packet embedded in the content (bitchat1:...)
|
|
||||||
if content.hasPrefix("bitchat1:") {
|
|
||||||
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
|
||||||
let packet = BitchatPacket.from(packetData) else {
|
|
||||||
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map sender by Nostr pubkey to Noise key when possible
|
|
||||||
let actualSenderNoiseKey = findNoiseKey(for: senderPubkey)
|
|
||||||
|
|
||||||
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
|
|
||||||
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
|
|
||||||
|
|
||||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
|
||||||
if let payload = NoisePayload.decode(packet.payload) {
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
|
||||||
// Store Nostr mapping
|
|
||||||
await MainActor.run {
|
|
||||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
|
||||||
|
|
||||||
// Handle packet types
|
|
||||||
switch payload.type {
|
|
||||||
case .privateMessage:
|
|
||||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
|
||||||
case .delivered:
|
|
||||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
|
||||||
case .readReceipt:
|
|
||||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
|
||||||
case .verifyChallenge, .verifyResponse:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
|
||||||
// Check favorites for this Nostr key
|
|
||||||
let favorites = FavoritesPersistenceService.shared.favorites.values
|
|
||||||
var npubToMatch = nostrPubkey
|
|
||||||
|
|
||||||
// Convert hex to npub if needed for comparison
|
|
||||||
if !nostrPubkey.hasPrefix("npub") {
|
|
||||||
if let pubkeyData = Data(hexString: nostrPubkey),
|
|
||||||
let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) {
|
|
||||||
npubToMatch = encoded
|
|
||||||
} else {
|
|
||||||
SecureLogger.warning("⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for relationship in favorites {
|
|
||||||
// Search through favorites for matching Nostr pubkey
|
|
||||||
if let storedNostrKey = relationship.peerNostrPublicKey {
|
|
||||||
// Compare against stored key (could be hex or npub)
|
|
||||||
if storedNostrKey == npubToMatch {
|
|
||||||
// SecureLogger.debug("✅ Found Noise key for Nostr sender (npub match)", category: .session)
|
|
||||||
return relationship.peerNoisePublicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also try comparing raw hex if stored key is hex
|
|
||||||
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
|
|
||||||
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
|
|
||||||
return relationship.peerNoisePublicKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) {
|
|
||||||
// If we have a Noise key, try to route securely if possible, otherwise fallback to direct
|
|
||||||
if let _ = key {
|
|
||||||
// Ideally we would use MessageRouter here, but for simplicity in this direct callback:
|
|
||||||
// check if we have an identity
|
|
||||||
if let id = try? idBridge.getCurrentNostrIdentity() {
|
|
||||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
|
||||||
nt.senderPeerID = meshService.myPeerID
|
|
||||||
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
|
|
||||||
}
|
|
||||||
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
|
||||||
// Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey
|
|
||||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
|
||||||
nt.senderPeerID = meshService.myPeerID
|
|
||||||
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
|
|
||||||
SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same for READ receipt if viewing
|
|
||||||
if !wasReadBefore && selectedPrivateChatPeer == message.senderPeerID {
|
|
||||||
if let _ = key {
|
|
||||||
if let id = try? idBridge.getCurrentNostrIdentity() {
|
|
||||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
|
||||||
nt.senderPeerID = meshService.myPeerID
|
|
||||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
|
||||||
}
|
|
||||||
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
|
||||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
|
||||||
nt.senderPeerID = meshService.myPeerID
|
|
||||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
|
||||||
SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
|
||||||
// Try to find Noise key associated with this Nostr pubkey
|
|
||||||
guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return }
|
|
||||||
|
|
||||||
let isFavorite = content.contains("FAVORITE:TRUE")
|
|
||||||
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
|
|
||||||
|
|
||||||
// Update favorite status
|
|
||||||
if isFavorite {
|
|
||||||
FavoritesPersistenceService.shared.addFavorite(
|
|
||||||
peerNoisePublicKey: senderNoiseKey,
|
|
||||||
peerNostrPublicKey: nostrPubkey,
|
|
||||||
peerNickname: senderNickname
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Only remove if we don't have it set locally
|
|
||||||
// Logic handled by persistence service usually, here we just update remote state
|
|
||||||
// Actually for now we just process the notification
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract Nostr public key if included
|
|
||||||
var extractedNostrPubkey: String? = nil
|
|
||||||
if let range = content.range(of: "NPUB:") {
|
|
||||||
let suffix = content[range.upperBound...]
|
|
||||||
let parts = suffix.components(separatedBy: "|")
|
|
||||||
if let key = parts.first {
|
|
||||||
extractedNostrPubkey = String(key)
|
|
||||||
}
|
|
||||||
} else if content.contains(":") {
|
|
||||||
// Fallback: simple format FAVORITE:TRUE:npub...
|
|
||||||
let parts = content.components(separatedBy: ":")
|
|
||||||
if parts.count >= 3 {
|
|
||||||
extractedNostrPubkey = String(parts[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
|
|
||||||
|
|
||||||
// If they favorited us and provided their Nostr key, ensure it's stored
|
|
||||||
if isFavorite && extractedNostrPubkey != nil {
|
|
||||||
SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", category: .session)
|
|
||||||
FavoritesPersistenceService.shared.addFavorite(
|
|
||||||
peerNoisePublicKey: senderNoiseKey,
|
|
||||||
peerNostrPublicKey: extractedNostrPubkey,
|
|
||||||
peerNickname: senderNickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show notification
|
|
||||||
NotificationService.shared.sendLocalNotification(
|
|
||||||
title: isFavorite ? "New Favorite" : "Favorite Removed",
|
|
||||||
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
|
|
||||||
identifier: "fav-\(UUID().uuidString)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
|
||||||
// Find peer Nostr key
|
|
||||||
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
|
|
||||||
relationship.peerNostrPublicKey != nil else {
|
|
||||||
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let peerID = PeerID(hexData: noisePublicKey)
|
|
||||||
|
|
||||||
// Route via message router
|
|
||||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
|
||||||
|
|
||||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
|
||||||
// Look up current visible geohash participants for an exact displayName match
|
|
||||||
for p in visibleGeohashPeople() {
|
|
||||||
if p.displayName == name {
|
|
||||||
return p.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also check nickname cache directly
|
|
||||||
for (pub, nick) in geoNicknames {
|
|
||||||
if nick == name { return pub }
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func startGeohashDM(withPubkeyHex hex: String) {
|
|
||||||
let convKey = PeerID(nostr_: hex)
|
|
||||||
nostrKeyMapping[convKey] = hex
|
|
||||||
startPrivateChat(with: convKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
|
|
||||||
return nostrKeyMapping[senderID]
|
|
||||||
}
|
|
||||||
|
|
||||||
func geohashDisplayName(for convKey: PeerID) -> String {
|
|
||||||
guard let full = nostrKeyMapping[convKey] else {
|
|
||||||
return convKey.bare
|
|
||||||
}
|
|
||||||
return displayNameForNostrPubkey(full)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,64 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModel+Tor.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Tor lifecycle handling for ChatViewModel
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import Tor
|
|
||||||
|
|
||||||
extension ChatViewModel {
|
|
||||||
|
|
||||||
// MARK: - Tor notifications
|
|
||||||
|
|
||||||
@objc func handleTorWillStart() {
|
|
||||||
Task { @MainActor in
|
|
||||||
if !self.torStatusAnnounced && TorManager.shared.torEnforced {
|
|
||||||
self.torStatusAnnounced = true
|
|
||||||
// Post only in geohash channels (queue if not active)
|
|
||||||
self.addGeohashOnlySystemMessage(
|
|
||||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func handleTorWillRestart() {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.torRestartPending = true
|
|
||||||
// Post only in geohash channels (queue if not active)
|
|
||||||
self.addGeohashOnlySystemMessage(
|
|
||||||
String(localized: "system.tor.restarting", comment: "System message when Tor is restarting")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func handleTorDidBecomeReady() {
|
|
||||||
Task { @MainActor in
|
|
||||||
// Only announce "restarted" if we actually restarted this session
|
|
||||||
if self.torRestartPending {
|
|
||||||
// Post only in geohash channels (queue if not active)
|
|
||||||
self.addGeohashOnlySystemMessage(
|
|
||||||
String(localized: "system.tor.restarted", comment: "System message when Tor has restarted")
|
|
||||||
)
|
|
||||||
self.torRestartPending = false
|
|
||||||
} else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced {
|
|
||||||
// Initial start completed
|
|
||||||
self.addGeohashOnlySystemMessage(
|
|
||||||
String(localized: "system.tor.started", comment: "System message when Tor has started")
|
|
||||||
)
|
|
||||||
self.torInitialReadyAnnounced = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func handleTorPreferenceChanged(_ notification: Notification) {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.torStatusAnnounced = false
|
|
||||||
self.torInitialReadyAnnounced = false
|
|
||||||
self.torRestartPending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# ChatViewModel Extensions
|
|
||||||
|
|
||||||
This directory contains extensions to `ChatViewModel` to modularize its functionality.
|
|
||||||
|
|
||||||
- `ChatViewModel+Tor.swift`: Handles Tor lifecycle events and notifications.
|
|
||||||
- `ChatViewModel+PrivateChat.swift`: Manages private chat logic, media transfers (images, voice notes), and file handling.
|
|
||||||
- `ChatViewModel+Nostr.swift`: Contains all logic related to Nostr integration, Geohash channels, and Nostr identity management.
|
|
||||||
|
|
||||||
The main `ChatViewModel.swift` retains core state, initialization, and coordination logic.
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
//
|
|
||||||
// GeoChannelCoordinator.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Centralizes Combine wiring for location channel selection and sampling.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import Tor
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class GeoChannelCoordinator {
|
|
||||||
private let locationManager: LocationChannelManager
|
|
||||||
private let bookmarksStore: GeohashBookmarksStore
|
|
||||||
private let torManager: TorManager
|
|
||||||
|
|
||||||
private let onChannelSwitch: (ChannelID) -> Void
|
|
||||||
private let beginSampling: ([String]) -> Void
|
|
||||||
private let endSampling: () -> Void
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
private var regionalGeohashes: [String] = []
|
|
||||||
private var bookmarkedGeohashes: [String] = []
|
|
||||||
|
|
||||||
init(
|
|
||||||
locationManager: LocationChannelManager? = nil,
|
|
||||||
bookmarksStore: GeohashBookmarksStore? = nil,
|
|
||||||
torManager: TorManager? = nil,
|
|
||||||
onChannelSwitch: @escaping (ChannelID) -> Void,
|
|
||||||
beginSampling: @escaping ([String]) -> Void,
|
|
||||||
endSampling: @escaping () -> Void
|
|
||||||
) {
|
|
||||||
self.locationManager = locationManager ?? Self.defaultLocationManager()
|
|
||||||
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
|
|
||||||
self.torManager = torManager ?? Self.defaultTorManager()
|
|
||||||
self.onChannelSwitch = onChannelSwitch
|
|
||||||
self.beginSampling = beginSampling
|
|
||||||
self.endSampling = endSampling
|
|
||||||
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
regionalGeohashes = locationManager.availableChannels.map { $0.geohash }
|
|
||||||
bookmarkedGeohashes = bookmarksStore.bookmarks
|
|
||||||
|
|
||||||
locationManager.$selectedChannel
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] channel in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.onChannelSwitch(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
locationManager.$availableChannels
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] channels in
|
|
||||||
guard let self else { return }
|
|
||||||
self.regionalGeohashes = channels.map { $0.geohash }
|
|
||||||
self.updateSampling()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
bookmarksStore.$bookmarks
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] bookmarks in
|
|
||||||
guard let self else { return }
|
|
||||||
self.bookmarkedGeohashes = bookmarks
|
|
||||||
self.updateSampling()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
locationManager.$permissionState
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] state in
|
|
||||||
guard let self, state == .authorized else { return }
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.locationManager.refreshChannels()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
self.onChannelSwitch(self.locationManager.selectedChannel)
|
|
||||||
}
|
|
||||||
updateSampling()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateSampling() {
|
|
||||||
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
|
|
||||||
Task { @MainActor in
|
|
||||||
guard !union.isEmpty else {
|
|
||||||
endSampling()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if torManager.isForeground() {
|
|
||||||
beginSampling(union)
|
|
||||||
} else {
|
|
||||||
endSampling()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshSampling() {
|
|
||||||
updateSampling()
|
|
||||||
}
|
|
||||||
private static func defaultLocationManager() -> LocationChannelManager {
|
|
||||||
LocationChannelManager.shared
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private static func defaultTorManager() -> TorManager {
|
|
||||||
TorManager.shared
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageRateLimiter.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Handles per-sender and per-content token buckets for public message intake.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct MessageRateLimiter {
|
|
||||||
private struct TokenBucket {
|
|
||||||
var capacity: Double
|
|
||||||
var tokens: Double
|
|
||||||
var refillPerSec: Double
|
|
||||||
var lastRefill: Date
|
|
||||||
|
|
||||||
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
|
|
||||||
let dt = now.timeIntervalSince(lastRefill)
|
|
||||||
if dt > 0 {
|
|
||||||
tokens = min(capacity, tokens + dt * refillPerSec)
|
|
||||||
lastRefill = now
|
|
||||||
}
|
|
||||||
if tokens >= cost {
|
|
||||||
tokens -= cost
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var senderBuckets: [String: TokenBucket] = [:]
|
|
||||||
private var contentBuckets: [String: TokenBucket] = [:]
|
|
||||||
|
|
||||||
private let senderCapacity: Double
|
|
||||||
private let senderRefill: Double
|
|
||||||
private let contentCapacity: Double
|
|
||||||
private let contentRefill: Double
|
|
||||||
|
|
||||||
init(
|
|
||||||
senderCapacity: Double,
|
|
||||||
senderRefillPerSec: Double,
|
|
||||||
contentCapacity: Double,
|
|
||||||
contentRefillPerSec: Double
|
|
||||||
) {
|
|
||||||
self.senderCapacity = senderCapacity
|
|
||||||
self.senderRefill = senderRefillPerSec
|
|
||||||
self.contentCapacity = contentCapacity
|
|
||||||
self.contentRefill = contentRefillPerSec
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
|
|
||||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
|
||||||
capacity: senderCapacity,
|
|
||||||
tokens: senderCapacity,
|
|
||||||
refillPerSec: senderRefill,
|
|
||||||
lastRefill: now
|
|
||||||
)
|
|
||||||
let senderAllowed = senderBucket.allow(now: now)
|
|
||||||
senderBuckets[senderKey] = senderBucket
|
|
||||||
|
|
||||||
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
|
||||||
capacity: contentCapacity,
|
|
||||||
tokens: contentCapacity,
|
|
||||||
refillPerSec: contentRefill,
|
|
||||||
lastRefill: now
|
|
||||||
)
|
|
||||||
let contentAllowed = contentBucket.allow(now: now)
|
|
||||||
contentBuckets[contentKey] = contentBucket
|
|
||||||
|
|
||||||
return senderAllowed && contentAllowed
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func reset() {
|
|
||||||
senderBuckets.removeAll()
|
|
||||||
contentBuckets.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
//
|
|
||||||
// MinimalDistancePalette.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Lightweight palette generator that keeps peer colors evenly spaced.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
final class MinimalDistancePalette {
|
|
||||||
struct Config {
|
|
||||||
let slotCount: Int
|
|
||||||
let avoidCenterHue: Double
|
|
||||||
let avoidHueDelta: Double
|
|
||||||
let saturationLight: Double
|
|
||||||
let saturationDark: Double
|
|
||||||
let baseBrightnessLight: Double
|
|
||||||
let baseBrightnessDark: Double
|
|
||||||
let ringBrightnessDeltaLight: Double
|
|
||||||
let ringBrightnessDeltaDark: Double
|
|
||||||
let preferredBiasWeight: Double
|
|
||||||
let goldenStep: Int
|
|
||||||
|
|
||||||
init(
|
|
||||||
slotCount: Int,
|
|
||||||
avoidCenterHue: Double,
|
|
||||||
avoidHueDelta: Double,
|
|
||||||
saturationLight: Double,
|
|
||||||
saturationDark: Double,
|
|
||||||
baseBrightnessLight: Double,
|
|
||||||
baseBrightnessDark: Double,
|
|
||||||
ringBrightnessDeltaLight: Double,
|
|
||||||
ringBrightnessDeltaDark: Double,
|
|
||||||
preferredBiasWeight: Double = 0.05,
|
|
||||||
goldenStep: Int = 7
|
|
||||||
) {
|
|
||||||
self.slotCount = slotCount
|
|
||||||
self.avoidCenterHue = avoidCenterHue
|
|
||||||
self.avoidHueDelta = avoidHueDelta
|
|
||||||
self.saturationLight = saturationLight
|
|
||||||
self.saturationDark = saturationDark
|
|
||||||
self.baseBrightnessLight = baseBrightnessLight
|
|
||||||
self.baseBrightnessDark = baseBrightnessDark
|
|
||||||
self.ringBrightnessDeltaLight = ringBrightnessDeltaLight
|
|
||||||
self.ringBrightnessDeltaDark = ringBrightnessDeltaDark
|
|
||||||
self.preferredBiasWeight = preferredBiasWeight
|
|
||||||
self.goldenStep = goldenStep
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct Entry {
|
|
||||||
let slot: Int
|
|
||||||
let ring: Int
|
|
||||||
let hue: Double
|
|
||||||
}
|
|
||||||
|
|
||||||
private let config: Config
|
|
||||||
private var currentSeeds: [String: String] = [:]
|
|
||||||
private var entries: [String: Entry] = [:]
|
|
||||||
private var previousEntries: [String: Entry] = [:]
|
|
||||||
|
|
||||||
init(config: Config) {
|
|
||||||
self.config = config
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func ensurePalette(for seeds: [String: String]) {
|
|
||||||
guard seeds != currentSeeds || entries.count != seeds.count else { return }
|
|
||||||
previousEntries = entries
|
|
||||||
currentSeeds = seeds
|
|
||||||
rebuildEntries()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func color(for identifier: String, isDark: Bool) -> Color? {
|
|
||||||
guard let entry = entries[identifier] else { return nil }
|
|
||||||
let saturation = isDark ? config.saturationDark : config.saturationLight
|
|
||||||
let baseBrightness = isDark ? config.baseBrightnessDark : config.baseBrightnessLight
|
|
||||||
let ringDelta = isDark ? config.ringBrightnessDeltaDark : config.ringBrightnessDeltaLight
|
|
||||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(entry.ring)))
|
|
||||||
return Color(hue: entry.hue, saturation: saturation, brightness: brightness)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func reset() {
|
|
||||||
currentSeeds.removeAll()
|
|
||||||
entries.removeAll()
|
|
||||||
previousEntries.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func rebuildEntries() {
|
|
||||||
guard !currentSeeds.isEmpty else {
|
|
||||||
entries.removeAll()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let slotCount = max(8, config.slotCount)
|
|
||||||
var slots: [Double] = []
|
|
||||||
for idx in 0..<slotCount {
|
|
||||||
let hue = Double(idx) / Double(slotCount)
|
|
||||||
if abs(hue - config.avoidCenterHue) < config.avoidHueDelta {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slots.append(hue)
|
|
||||||
}
|
|
||||||
if slots.isEmpty {
|
|
||||||
for idx in 0..<slotCount {
|
|
||||||
slots.append(Double(idx) / Double(slotCount))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func circularDistance(_ a: Double, _ b: Double) -> Double {
|
|
||||||
let diff = abs(a - b)
|
|
||||||
return diff > 0.5 ? 1.0 - diff : diff
|
|
||||||
}
|
|
||||||
|
|
||||||
let peerIDs = currentSeeds.keys.sorted()
|
|
||||||
let preferredIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peerIDs.map { id in
|
|
||||||
let seed = currentSeeds[id] ?? id
|
|
||||||
let hash = seed.djb2()
|
|
||||||
let index = Int(hash % UInt64(slots.count))
|
|
||||||
return (id, index)
|
|
||||||
})
|
|
||||||
|
|
||||||
var mapping: [String: Entry] = [:]
|
|
||||||
var usedSlots = Set<Int>()
|
|
||||||
var usedHues: [Double] = []
|
|
||||||
|
|
||||||
let prior = entries.isEmpty ? previousEntries : entries
|
|
||||||
for (id, entry) in prior {
|
|
||||||
guard currentSeeds.keys.contains(id), entry.slot < slots.count else { continue }
|
|
||||||
let hue = slots[entry.slot]
|
|
||||||
mapping[id] = Entry(slot: entry.slot, ring: entry.ring, hue: hue)
|
|
||||||
usedSlots.insert(entry.slot)
|
|
||||||
usedHues.append(hue)
|
|
||||||
}
|
|
||||||
|
|
||||||
let unassigned = peerIDs.filter { mapping[$0] == nil }
|
|
||||||
for id in unassigned {
|
|
||||||
let preferred = preferredIndex[id] ?? 0
|
|
||||||
if !usedSlots.contains(preferred), preferred < slots.count {
|
|
||||||
let hue = slots[preferred]
|
|
||||||
mapping[id] = Entry(slot: preferred, ring: 0, hue: hue)
|
|
||||||
usedSlots.insert(preferred)
|
|
||||||
usedHues.append(hue)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var bestSlot: Int?
|
|
||||||
var bestScore = -Double.infinity
|
|
||||||
for slot in 0..<slots.count where !usedSlots.contains(slot) {
|
|
||||||
let hue = slots[slot]
|
|
||||||
let minDistance = usedHues.isEmpty ? 1.0 : usedHues.map { circularDistance(hue, $0) }.min() ?? 1.0
|
|
||||||
let bias = 1.0 - (Double((abs(slot - (preferredIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
|
||||||
let score = minDistance + config.preferredBiasWeight * bias
|
|
||||||
if score > bestScore {
|
|
||||||
bestScore = score
|
|
||||||
bestSlot = slot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let slot = bestSlot {
|
|
||||||
let hue = slots[slot]
|
|
||||||
mapping[id] = Entry(slot: slot, ring: 0, hue: hue)
|
|
||||||
usedSlots.insert(slot)
|
|
||||||
usedHues.append(hue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let remaining = peerIDs.filter { mapping[$0] == nil }
|
|
||||||
if !remaining.isEmpty {
|
|
||||||
for (index, id) in remaining.enumerated() {
|
|
||||||
let preferred = preferredIndex[id] ?? 0
|
|
||||||
let slot = (preferred + index * config.goldenStep) % slots.count
|
|
||||||
let hue = slots[slot]
|
|
||||||
mapping[id] = Entry(slot: slot, ring: 1, hue: hue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entries = mapping
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MinimalDistancePalette.Config {
|
|
||||||
static let mesh = MinimalDistancePalette.Config(
|
|
||||||
slotCount: TransportConfig.uiPeerPaletteSlots,
|
|
||||||
avoidCenterHue: 30.0 / 360.0,
|
|
||||||
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
|
|
||||||
saturationLight: 0.70,
|
|
||||||
saturationDark: 0.80,
|
|
||||||
baseBrightnessLight: 0.45,
|
|
||||||
baseBrightnessDark: 0.75,
|
|
||||||
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
|
|
||||||
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
|
|
||||||
)
|
|
||||||
|
|
||||||
static let nostr = MinimalDistancePalette.Config(
|
|
||||||
slotCount: TransportConfig.uiPeerPaletteSlots,
|
|
||||||
avoidCenterHue: 30.0 / 360.0,
|
|
||||||
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
|
|
||||||
saturationLight: 0.70,
|
|
||||||
saturationDark: 0.80,
|
|
||||||
baseBrightnessLight: 0.45,
|
|
||||||
baseBrightnessDark: 0.75,
|
|
||||||
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
|
|
||||||
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
//
|
|
||||||
// PublicMessagePipeline.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Handles batching and deduplication of public chat messages before surfacing them to the UI.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
protocol PublicMessagePipelineDelegate: AnyObject {
|
|
||||||
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage]
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage])
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date?
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date)
|
|
||||||
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline)
|
|
||||||
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage)
|
|
||||||
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class PublicMessagePipeline {
|
|
||||||
weak var delegate: PublicMessagePipelineDelegate?
|
|
||||||
|
|
||||||
private var buffer: [BitchatMessage] = []
|
|
||||||
private var timer: Timer?
|
|
||||||
private let baseFlushInterval: TimeInterval
|
|
||||||
private var dynamicFlushInterval: TimeInterval
|
|
||||||
private var recentBatchSizes: [Int] = []
|
|
||||||
private let maxRecentBatchSamples: Int
|
|
||||||
private let dedupWindow: TimeInterval
|
|
||||||
private var activeChannel: ChannelID = .mesh
|
|
||||||
|
|
||||||
init(
|
|
||||||
baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval,
|
|
||||||
maxRecentBatchSamples: Int = 10,
|
|
||||||
dedupWindow: TimeInterval = 1.0
|
|
||||||
) {
|
|
||||||
self.baseFlushInterval = baseFlushInterval
|
|
||||||
self.dynamicFlushInterval = baseFlushInterval
|
|
||||||
self.maxRecentBatchSamples = maxRecentBatchSamples
|
|
||||||
self.dedupWindow = dedupWindow
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
timer?.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateActiveChannel(_ channel: ChannelID) {
|
|
||||||
activeChannel = channel
|
|
||||||
}
|
|
||||||
|
|
||||||
func enqueue(_ message: BitchatMessage) {
|
|
||||||
buffer.append(message)
|
|
||||||
scheduleFlush()
|
|
||||||
}
|
|
||||||
|
|
||||||
func flushIfNeeded() {
|
|
||||||
flushBuffer()
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
buffer.removeAll(keepingCapacity: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension PublicMessagePipeline {
|
|
||||||
func scheduleFlush() {
|
|
||||||
guard timer == nil else { return }
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: dynamicFlushInterval, repeats: false) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.flushBuffer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func flushBuffer() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
guard !buffer.isEmpty else { return }
|
|
||||||
guard let delegate = delegate else {
|
|
||||||
buffer.removeAll(keepingCapacity: false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate.pipelineSetBatchingState(self, isBatching: true)
|
|
||||||
|
|
||||||
var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id })
|
|
||||||
var pending: [(message: BitchatMessage, contentKey: String)] = []
|
|
||||||
var batchContentLatest: [String: Date] = [:]
|
|
||||||
|
|
||||||
for message in buffer {
|
|
||||||
if existingIDs.contains(message.id) { continue }
|
|
||||||
let contentKey = delegate.pipeline(self, normalizeContent: message.content)
|
|
||||||
if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey),
|
|
||||||
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if let ts = batchContentLatest[contentKey],
|
|
||||||
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
existingIDs.insert(message.id)
|
|
||||||
pending.append((message, contentKey))
|
|
||||||
batchContentLatest[contentKey] = message.timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.removeAll(keepingCapacity: true)
|
|
||||||
guard !pending.isEmpty else {
|
|
||||||
delegate.pipelineSetBatchingState(self, isBatching: false)
|
|
||||||
if !buffer.isEmpty { scheduleFlush() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pending.sort { $0.message.timestamp < $1.message.timestamp }
|
|
||||||
|
|
||||||
var messages = delegate.pipelineCurrentMessages(self)
|
|
||||||
let threshold = lateInsertThreshold(for: activeChannel)
|
|
||||||
let lastTimestamp = messages.last?.timestamp ?? .distantPast
|
|
||||||
|
|
||||||
for item in pending {
|
|
||||||
let message = item.message
|
|
||||||
if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) {
|
|
||||||
let index = insertionIndex(for: message.timestamp, in: messages)
|
|
||||||
if index >= messages.count {
|
|
||||||
messages.append(message)
|
|
||||||
} else {
|
|
||||||
messages.insert(message, at: index)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
messages.append(message)
|
|
||||||
}
|
|
||||||
delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: message.timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate.pipeline(self, setMessages: messages)
|
|
||||||
delegate.pipelineTrimMessages(self)
|
|
||||||
|
|
||||||
updateFlushInterval(withBatchSize: pending.count)
|
|
||||||
|
|
||||||
for item in pending {
|
|
||||||
delegate.pipelinePrewarmMessage(self, message: item.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate.pipelineSetBatchingState(self, isBatching: false)
|
|
||||||
|
|
||||||
if !buffer.isEmpty {
|
|
||||||
scheduleFlush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateFlushInterval(withBatchSize size: Int) {
|
|
||||||
recentBatchSizes.append(size)
|
|
||||||
if recentBatchSizes.count > maxRecentBatchSamples {
|
|
||||||
recentBatchSizes.removeFirst(recentBatchSizes.count - maxRecentBatchSamples)
|
|
||||||
}
|
|
||||||
let avg = recentBatchSizes.isEmpty
|
|
||||||
? 0.0
|
|
||||||
: Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
|
|
||||||
dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
func lateInsertThreshold(for channel: ChannelID) -> TimeInterval {
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
return TransportConfig.uiLateInsertThreshold
|
|
||||||
case .location:
|
|
||||||
return TransportConfig.uiLateInsertThresholdGeo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func insertionIndex(for timestamp: Date, in messages: [BitchatMessage]) -> Int {
|
|
||||||
var low = 0
|
|
||||||
var high = messages.count
|
|
||||||
while low < high {
|
|
||||||
let mid = (low + high) / 2
|
|
||||||
if messages[mid].timestamp < timestamp {
|
|
||||||
low = mid + 1
|
|
||||||
} else {
|
|
||||||
high = mid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return low
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
//
|
|
||||||
// PublicTimelineStore.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Maintains mesh and geohash public timelines with simple caps and helpers.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct PublicTimelineStore {
|
|
||||||
private var meshTimeline: [BitchatMessage] = []
|
|
||||||
private var geohashTimelines: [String: [BitchatMessage]] = [:]
|
|
||||||
private var pendingGeohashSystemMessages: [String] = []
|
|
||||||
|
|
||||||
private let meshCap: Int
|
|
||||||
private let geohashCap: Int
|
|
||||||
|
|
||||||
init(meshCap: Int, geohashCap: Int) {
|
|
||||||
self.meshCap = meshCap
|
|
||||||
self.geohashCap = geohashCap
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func append(_ message: BitchatMessage, to channel: ChannelID) {
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
guard !meshTimeline.contains(where: { $0.id == message.id }) else { return }
|
|
||||||
meshTimeline.append(message)
|
|
||||||
trimMeshTimelineIfNeeded()
|
|
||||||
case .location(let channel):
|
|
||||||
append(message, toGeohash: channel.geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {
|
|
||||||
var timeline = geohashTimelines[geohash] ?? []
|
|
||||||
guard !timeline.contains(where: { $0.id == message.id }) else { return }
|
|
||||||
timeline.append(message)
|
|
||||||
trimGeohashTimelineIfNeeded(&timeline)
|
|
||||||
geohashTimelines[geohash] = timeline
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append message if absent, returning true when stored.
|
|
||||||
mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
|
|
||||||
var timeline = geohashTimelines[geohash] ?? []
|
|
||||||
guard !timeline.contains(where: { $0.id == message.id }) else { return false }
|
|
||||||
timeline.append(message)
|
|
||||||
trimGeohashTimelineIfNeeded(&timeline)
|
|
||||||
geohashTimelines[geohash] = timeline
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func messages(for channel: ChannelID) -> [BitchatMessage] {
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
return meshTimeline
|
|
||||||
case .location(let channel):
|
|
||||||
let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []
|
|
||||||
geohashTimelines[channel.geohash] = cleaned
|
|
||||||
return cleaned
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func clear(channel: ChannelID) {
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
meshTimeline.removeAll()
|
|
||||||
case .location(let channel):
|
|
||||||
geohashTimelines[channel.geohash] = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
mutating func removeMessage(withID id: String) -> BitchatMessage? {
|
|
||||||
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
|
|
||||||
return meshTimeline.remove(at: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
for key in Array(geohashTimelines.keys) {
|
|
||||||
var timeline = geohashTimelines[key] ?? []
|
|
||||||
if let index = timeline.firstIndex(where: { $0.id == id }) {
|
|
||||||
let removed = timeline.remove(at: index)
|
|
||||||
geohashTimelines[key] = timeline.isEmpty ? nil : timeline
|
|
||||||
return removed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
|
|
||||||
var timeline = geohashTimelines[geohash] ?? []
|
|
||||||
timeline.removeAll(where: predicate)
|
|
||||||
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {
|
|
||||||
var timeline = geohashTimelines[geohash] ?? []
|
|
||||||
transform(&timeline)
|
|
||||||
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func queueGeohashSystemMessage(_ content: String) {
|
|
||||||
pendingGeohashSystemMessages.append(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func drainPendingGeohashSystemMessages() -> [String] {
|
|
||||||
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
|
|
||||||
return pendingGeohashSystemMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
func geohashKeys() -> [String] {
|
|
||||||
Array(geohashTimelines.keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
private mutating func trimMeshTimelineIfNeeded() {
|
|
||||||
guard meshTimeline.count > meshCap else { return }
|
|
||||||
meshTimeline = Array(meshTimeline.suffix(meshCap))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) {
|
|
||||||
guard timeline.count > geohashCap else { return }
|
|
||||||
timeline = Array(timeline.suffix(geohashCap))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
//
|
|
||||||
// CommandSuggestionsView.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Created by Islam on 29/10/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct CommandSuggestionsView: View {
|
|
||||||
@EnvironmentObject private var viewModel: ChatViewModel
|
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
|
||||||
|
|
||||||
@Binding var messageText: String
|
|
||||||
|
|
||||||
let textColor: Color
|
|
||||||
let backgroundColor: Color
|
|
||||||
let secondaryTextColor: Color
|
|
||||||
|
|
||||||
private var filteredCommands: [CommandInfo] {
|
|
||||||
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
|
|
||||||
let isGeoPublic = locationManager.selectedChannel.isLocation
|
|
||||||
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
|
||||||
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
|
|
||||||
command.alias.starts(with: messageText.lowercased())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
ForEach(filteredCommands) { command in
|
|
||||||
Button {
|
|
||||||
messageText = command.alias + " "
|
|
||||||
} label: {
|
|
||||||
buttonRow(for: command)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(backgroundColor)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func buttonRow(for command: CommandInfo) -> some View {
|
|
||||||
HStack {
|
|
||||||
Text(command.alias)
|
|
||||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
|
|
||||||
if let placeholder = command.placeholder {
|
|
||||||
Text(placeholder)
|
|
||||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text(command.description)
|
|
||||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17, macOS 14, *)
|
|
||||||
#Preview {
|
|
||||||
@Previewable @State var messageText: String = "/"
|
|
||||||
let keychain = KeychainManager()
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: NostrIdentityBridge(),
|
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
|
||||||
)
|
|
||||||
|
|
||||||
CommandSuggestionsView(
|
|
||||||
messageText: $messageText,
|
|
||||||
textColor: .green,
|
|
||||||
backgroundColor: .primary,
|
|
||||||
secondaryTextColor: .secondary
|
|
||||||
)
|
|
||||||
.environmentObject(viewModel)
|
|
||||||
}
|
|
||||||
+337
-374
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct FingerprintView: View {
|
struct FingerprintView: View {
|
||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
let peerID: PeerID
|
let peerID: String
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@@ -65,12 +65,15 @@ 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 = viewModel.getShortIDForNoiseKey(peerID)
|
let statusPeerID: String = {
|
||||||
|
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
||||||
|
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: statusPeerID) { return p.displayName }
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
||||||
if let data = peerID.noiseKey {
|
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 }
|
||||||
let fp = data.sha256Fingerprint()
|
let fp = data.sha256Fingerprint()
|
||||||
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
||||||
@@ -236,6 +239,8 @@ struct FingerprintView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatFingerprint(_ fingerprint: String) -> String {
|
private func formatFingerprint(_ fingerprint: String) -> String {
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ struct LocationChannelsSheet: View {
|
|||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 420, minHeight: 520)
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
#endif
|
#endif
|
||||||
@@ -357,7 +360,7 @@ struct LocationChannelsSheet: View {
|
|||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.onAppear { bookmarks.resolveBookmarkNameIfNeeded(for: gh) }
|
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||||
|
|
||||||
if index < entries.count - 1 {
|
if index < entries.count - 1 {
|
||||||
sectionDivider
|
sectionDivider
|
||||||
@@ -596,7 +599,7 @@ extension LocationChannelsSheet {
|
|||||||
switch level {
|
switch level {
|
||||||
case .region:
|
case .region:
|
||||||
return ""
|
return ""
|
||||||
case .building, .block, .neighborhood, .city, .province:
|
default:
|
||||||
return "~"
|
return "~"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ struct LocationNotesView: View {
|
|||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
#endif
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.onDisappear { manager.cancel() }
|
.onDisappear { manager.cancel() }
|
||||||
.onChange(of: geohash) { newValue in
|
.onChange(of: geohash) { newValue in
|
||||||
@@ -138,7 +141,7 @@ struct LocationNotesView: View {
|
|||||||
String(
|
String(
|
||||||
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
|
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
|
||||||
locale: .current,
|
locale: .current,
|
||||||
"\(geohash) ± 1", count
|
geohash, count
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,9 +4,9 @@ struct MeshPeerList: View {
|
|||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
let textColor: Color
|
let textColor: Color
|
||||||
let secondaryTextColor: Color
|
let secondaryTextColor: Color
|
||||||
let onTapPeer: (PeerID) -> Void
|
let onTapPeer: (String) -> Void
|
||||||
let onToggleFavorite: (PeerID) -> Void
|
let onToggleFavorite: (String) -> Void
|
||||||
let onShowFingerprint: (PeerID) -> Void
|
let onShowFingerprint: (String) -> Void
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@State private var orderedIDs: [String] = []
|
@State private var orderedIDs: [String] = []
|
||||||
@@ -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)
|
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID.id)
|
||||||
let enc = viewModel.getEncryptionStatus(for: peer.peerID)
|
let enc = viewModel.getEncryptionStatus(for: peer.peerID.id)
|
||||||
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, isDark: colorScheme == .dark)
|
let assigned = viewModel.colorForMeshPeer(id: peer.peerID.id, isDark: colorScheme == .dark)
|
||||||
let baseColor = isMe ? Color.orange : assigned
|
let baseColor = isMe ? Color.orange : assigned
|
||||||
if isMe {
|
if isMe {
|
||||||
Image(systemName: "person.fill")
|
Image(systemName: "person.fill")
|
||||||
@@ -89,7 +89,7 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMe, viewModel.isPeerBlocked(peer.peerID) {
|
if !isMe, viewModel.isPeerBlocked(peer.peerID.id) {
|
||||||
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),
|
if let fp = viewModel.getFingerprint(for: peer.peerID.id),
|
||||||
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))
|
||||||
@@ -130,7 +130,7 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.peerID) }) {
|
Button(action: { onToggleFavorite(peer.peerID.id) }) {
|
||||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||||
@@ -142,8 +142,8 @@ struct MeshPeerList: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.padding(.top, idx == 0 ? 10 : 0)
|
.padding(.top, idx == 0 ? 10 : 0)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
|
.onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
|
||||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
|
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Seed and update order outside result builder
|
// Seed and update order outside result builder
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ struct VerificationSheetView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Optional: Remove verification for selected peer (if verified)
|
// Optional: Remove verification for selected peer (if verified)
|
||||||
if let pid = viewModel.selectedPrivateChatPeer,
|
if let pid = viewModel.selectedPrivateChatPeer?.id,
|
||||||
let fp = viewModel.getFingerprint(for: pid),
|
let fp = viewModel.getFingerprint(for: pid),
|
||||||
viewModel.verifiedFingerprints.contains(fp) {
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||||
@@ -388,6 +388,10 @@ struct VerificationSheetView: View {
|
|||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
|
#if os(iOS)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
#endif
|
||||||
.onDisappear { showingScanner = false }
|
.onDisappear { showingScanner = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+204
-214
@@ -6,275 +6,265 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import XCTest
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct BLEServiceTests {
|
final class BLEServiceTests: XCTestCase {
|
||||||
private let service: MockBLEService
|
|
||||||
private let myUUID = UUID()
|
|
||||||
private let bus = MockBLEBus()
|
|
||||||
|
|
||||||
init() {
|
var service: MockBLEService!
|
||||||
service = MockBLEService.init(bus: bus)
|
|
||||||
service.myPeerID = PeerID(str: myUUID.uuidString)
|
override func setUp() {
|
||||||
|
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
|
||||||
|
|
||||||
@Test func serviceInitialization() {
|
func testServiceInitialization() {
|
||||||
#expect(service.myPeerID == PeerID(str: myUUID.uuidString))
|
XCTAssertNotNil(service)
|
||||||
#expect(service.myNickname == "TestUser")
|
XCTAssertEqual(service.myPeerID, "TEST1234")
|
||||||
|
XCTAssertEqual(service.myNickname, "TestUser")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func peerConnection() {
|
func testPeerConnection() {
|
||||||
let somePeerID = PeerID(str: UUID().uuidString)
|
// Test connecting a peer
|
||||||
|
service.simulateConnectedPeer("PEER5678")
|
||||||
|
XCTAssertTrue(service.isPeerConnected("PEER5678"))
|
||||||
|
XCTAssertEqual(service.getConnectedPeers().count, 1)
|
||||||
|
|
||||||
service.simulateConnectedPeer(somePeerID)
|
// Test disconnecting a peer
|
||||||
#expect(service.isPeerConnected(somePeerID))
|
service.simulateDisconnectedPeer("PEER5678")
|
||||||
#expect(service.getConnectedPeers().count == 1)
|
XCTAssertFalse(service.isPeerConnected("PEER5678"))
|
||||||
|
XCTAssertEqual(service.getConnectedPeers().count, 0)
|
||||||
service.simulateDisconnectedPeer(somePeerID)
|
|
||||||
#expect(!service.isPeerConnected(somePeerID))
|
|
||||||
#expect(service.getConnectedPeers().count == 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func multiplePeerConnections() {
|
func testMultiplePeerConnections() {
|
||||||
let peerID1 = PeerID(str: UUID().uuidString)
|
service.simulateConnectedPeer("PEER1")
|
||||||
let peerID2 = PeerID(str: UUID().uuidString)
|
service.simulateConnectedPeer("PEER2")
|
||||||
let peerID3 = PeerID(str: UUID().uuidString)
|
service.simulateConnectedPeer("PEER3")
|
||||||
|
|
||||||
service.simulateConnectedPeer(peerID1)
|
|
||||||
service.simulateConnectedPeer(peerID2)
|
|
||||||
service.simulateConnectedPeer(peerID3)
|
|
||||||
|
|
||||||
#expect(service.getConnectedPeers().count == 3)
|
XCTAssertEqual(service.getConnectedPeers().count, 3)
|
||||||
#expect(service.isPeerConnected(peerID1))
|
XCTAssertTrue(service.isPeerConnected("PEER1"))
|
||||||
#expect(service.isPeerConnected(peerID2))
|
XCTAssertTrue(service.isPeerConnected("PEER2"))
|
||||||
#expect(service.isPeerConnected(peerID3))
|
XCTAssertTrue(service.isPeerConnected("PEER3"))
|
||||||
|
|
||||||
service.simulateDisconnectedPeer(peerID2)
|
service.simulateDisconnectedPeer("PEER2")
|
||||||
#expect(service.getConnectedPeers().count == 2)
|
XCTAssertEqual(service.getConnectedPeers().count, 2)
|
||||||
#expect(!service.isPeerConnected(peerID2))
|
XCTAssertFalse(service.isPeerConnected("PEER2"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Sending Tests
|
// MARK: - Message Sending Tests
|
||||||
|
|
||||||
@Test func sendPublicMessage() async throws {
|
func testSendPublicMessage() {
|
||||||
try await confirmation { receivedPublicMessage in
|
let expectation = XCTestExpectation(description: "Message sent")
|
||||||
let delegate = MockBitchatDelegate { message in
|
|
||||||
#expect(message.content == "Hello, world!")
|
let delegate = MockBitchatDelegate { message in
|
||||||
#expect(message.sender == "TestUser")
|
XCTAssertEqual(message.content, "Hello, world!")
|
||||||
#expect(!message.isPrivate)
|
XCTAssertEqual(message.sender, "TestUser")
|
||||||
receivedPublicMessage()
|
XCTAssertFalse(message.isPrivate)
|
||||||
}
|
expectation.fulfill()
|
||||||
service.delegate = delegate
|
|
||||||
service.sendMessage("Hello, world!")
|
|
||||||
|
|
||||||
// Allow async processing
|
|
||||||
try await sleep(0.5)
|
|
||||||
}
|
}
|
||||||
#expect(service.sentMessages.count == 1)
|
service.delegate = delegate
|
||||||
|
|
||||||
|
service.sendMessage("Hello, world!")
|
||||||
|
|
||||||
|
wait(for: [expectation], timeout: 1.0)
|
||||||
|
XCTAssertEqual(service.sentMessages.count, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func sendPrivateMessage() async throws {
|
func testSendPrivateMessage() {
|
||||||
try await confirmation { receivedPrivateMessage in
|
let expectation = XCTestExpectation(description: "Private message sent")
|
||||||
let delegate = MockBitchatDelegate { message in
|
|
||||||
#expect(message.content == "Secret message")
|
let delegate = MockBitchatDelegate { message in
|
||||||
#expect(message.sender == "TestUser")
|
XCTAssertEqual(message.content, "Secret message")
|
||||||
#expect(message.senderPeerID == PeerID(str: myUUID.uuidString))
|
XCTAssertEqual(message.sender, "TestUser")
|
||||||
#expect(message.isPrivate)
|
XCTAssertTrue(message.isPrivate)
|
||||||
#expect(message.recipientNickname == "Bob")
|
XCTAssertEqual(message.recipientNickname, "Bob")
|
||||||
receivedPrivateMessage()
|
expectation.fulfill()
|
||||||
}
|
|
||||||
service.delegate = delegate
|
|
||||||
service.sendPrivateMessage(
|
|
||||||
"Secret message",
|
|
||||||
to: PeerID(str: UUID().uuidString),
|
|
||||||
recipientNickname: "Bob",
|
|
||||||
messageID: "MSG123"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Allow async processing
|
|
||||||
try await sleep(0.5)
|
|
||||||
}
|
}
|
||||||
#expect(service.sentMessages.count == 1)
|
service.delegate = delegate
|
||||||
|
|
||||||
|
service.sendPrivateMessage("Secret message", to: "PEER5678", recipientNickname: "Bob", messageID: "MSG123")
|
||||||
|
|
||||||
|
wait(for: [expectation], timeout: 1.0)
|
||||||
|
XCTAssertEqual(service.sentMessages.count, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func sendMessageWithMentions() async throws {
|
func testSendMessageWithMentions() {
|
||||||
try await confirmation { receivedMessageWithMentions in
|
let expectation = XCTestExpectation(description: "Message with mentions sent")
|
||||||
let delegate = MockBitchatDelegate { message in
|
|
||||||
#expect(message.content == "@alice @bob check this out")
|
let delegate = MockBitchatDelegate { message in
|
||||||
#expect(message.mentions == ["alice", "bob"])
|
XCTAssertEqual(message.content, "@alice @bob check this out")
|
||||||
receivedMessageWithMentions()
|
XCTAssertEqual(message.mentions, ["alice", "bob"])
|
||||||
}
|
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
|
||||||
|
|
||||||
@Test func simulateIncomingMessage() async throws {
|
func testSimulateIncomingMessage() {
|
||||||
try await confirmation { receiveMessage in
|
let expectation = XCTestExpectation(description: "Message received")
|
||||||
let peerID = PeerID(str: UUID().uuidString)
|
|
||||||
|
let delegate = MockBitchatDelegate { message in
|
||||||
let delegate = MockBitchatDelegate { message in
|
XCTAssertEqual(message.content, "Incoming message")
|
||||||
#expect(message.content == "Incoming message")
|
XCTAssertEqual(message.sender, "RemoteUser")
|
||||||
#expect(message.sender == "RemoteUser")
|
expectation.fulfill()
|
||||||
#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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func simulateIncomingPacket() async throws {
|
func testSimulateIncomingPacket() {
|
||||||
try await confirmation { processPacket in
|
let expectation = XCTestExpectation(description: "Packet processed")
|
||||||
let peerID = PeerID(str: UUID().uuidString)
|
|
||||||
|
let delegate = MockBitchatDelegate { message in
|
||||||
let delegate = MockBitchatDelegate { message in
|
XCTAssertEqual(message.content, "Packet message")
|
||||||
#expect(message.content == "Packet message")
|
expectation.fulfill()
|
||||||
#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
|
||||||
|
|
||||||
@Test func getPeerNicknames() {
|
func testGetPeerNicknames() {
|
||||||
let peerID1 = PeerID(str: UUID().uuidString)
|
service.simulateConnectedPeer("PEER1")
|
||||||
let peerID2 = PeerID(str: UUID().uuidString)
|
service.simulateConnectedPeer("PEER2")
|
||||||
|
|
||||||
service.simulateConnectedPeer(peerID1)
|
|
||||||
service.simulateConnectedPeer(peerID2)
|
|
||||||
|
|
||||||
let nicknames = service.getPeerNicknames()
|
let nicknames = service.getPeerNicknames()
|
||||||
#expect(nicknames.count == 2)
|
XCTAssertEqual(nicknames.count, 2)
|
||||||
#expect(nicknames[peerID1] == "MockPeer_\(peerID1)")
|
XCTAssertEqual(nicknames["PEER1"], "MockPeer_PEER1")
|
||||||
#expect(nicknames[peerID2] == "MockPeer_\(peerID2)")
|
XCTAssertEqual(nicknames["PEER2"], "MockPeer_PEER2")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Service State Tests
|
// MARK: - Service State Tests
|
||||||
|
|
||||||
@Test func startStopServices() {
|
func testStartStopServices() {
|
||||||
|
// 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.simulateConnectedPeer(somePeerID)
|
// Service should still be functional after start/stop
|
||||||
#expect(service.isPeerConnected(somePeerID))
|
service.simulateConnectedPeer("PEER999")
|
||||||
|
XCTAssertTrue(service.isPeerConnected("PEER999"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Delivery Handler Tests
|
// MARK: - Message Delivery Handler Tests
|
||||||
|
|
||||||
@Test func messageDeliveryHandler() async throws {
|
func testMessageDeliveryHandler() {
|
||||||
try await confirmation { deliveryHandler in
|
let expectation = XCTestExpectation(description: "Delivery handler called")
|
||||||
service.packetDeliveryHandler = { packet in
|
|
||||||
if let msg = BitchatMessage(packet.payload) {
|
service.packetDeliveryHandler = { packet in
|
||||||
#expect(msg.content == "Test delivery")
|
if let msg = BitchatMessage(packet.payload) {
|
||||||
deliveryHandler()
|
XCTAssertEqual(msg.content, "Test delivery")
|
||||||
}
|
expectation.fulfill()
|
||||||
}
|
}
|
||||||
service.sendMessage("Test delivery")
|
|
||||||
|
|
||||||
// Allow async processing
|
|
||||||
try await sleep(0.5)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
service.sendMessage("Test delivery")
|
||||||
|
|
||||||
|
wait(for: [expectation], timeout: 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func packetDeliveryHandler() async throws {
|
func testPacketDeliveryHandler() {
|
||||||
try await confirmation("Packet handler called") { packetHandler in
|
let expectation = XCTestExpectation(description: "Packet handler called")
|
||||||
let peerID = PeerID(str: UUID().uuidString)
|
|
||||||
|
service.packetDeliveryHandler = { packet in
|
||||||
service.packetDeliveryHandler = { packet in
|
XCTAssertEqual(packet.type, 0x01)
|
||||||
#expect(packet.type == 0x01)
|
expectation.fulfill()
|
||||||
#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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,5 +288,5 @@ private final class MockBitchatDelegate: BitchatDelegate {
|
|||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
|
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModelExtensionsTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for ChatViewModel extensions (PrivateChat, Nostr, Tor).
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - Test Helpers
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let keychainHelper = MockKeychainHelper()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let transport = MockTransport()
|
|
||||||
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: identityManager,
|
|
||||||
transport: transport
|
|
||||||
)
|
|
||||||
|
|
||||||
return (viewModel, transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Chat Extension Tests
|
|
||||||
|
|
||||||
struct ChatViewModelPrivateChatExtensionTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendPrivateMessage_mesh_storesAndSends() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
// Use valid hex string for PeerID (32 bytes = 64 hex chars for Noise key usually, or just valid hex)
|
|
||||||
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
|
|
||||||
let peerID = PeerID(str: validHex)
|
|
||||||
|
|
||||||
// Simulate connection
|
|
||||||
transport.connectedPeers.insert(peerID)
|
|
||||||
transport.peerNicknames[peerID] = "MeshUser"
|
|
||||||
|
|
||||||
viewModel.sendPrivateMessage("Hello Mesh", to: peerID)
|
|
||||||
|
|
||||||
// Verify transport was called
|
|
||||||
// Note: MockTransport stores sent messages
|
|
||||||
// Since sendPrivateMessage delegates to MessageRouter which delegates to Transport...
|
|
||||||
// We need to ensure MessageRouter is using our MockTransport.
|
|
||||||
// ChatViewModel init sets up MessageRouter with the passed transport.
|
|
||||||
|
|
||||||
// Wait for async processing
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Verify message stored locally
|
|
||||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
|
||||||
#expect(viewModel.privateChats[peerID]?.first?.content == "Hello Mesh")
|
|
||||||
|
|
||||||
// Verify message sent to transport (MockTransport captures sendPrivateMessage)
|
|
||||||
// MockTransport.sendPrivateMessage is what MessageRouter calls for connected peers
|
|
||||||
// Check MockTransport implementation... it might need update or verification
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handlePrivateMessage_storesMessage() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "SENDER_001")
|
|
||||||
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "msg-1",
|
|
||||||
sender: "Sender",
|
|
||||||
content: "Private Content",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: peerID
|
|
||||||
)
|
|
||||||
|
|
||||||
// Simulate receiving a private message via the handlePrivateMessage extension method
|
|
||||||
viewModel.handlePrivateMessage(message)
|
|
||||||
|
|
||||||
// Verify stored
|
|
||||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
|
||||||
#expect(viewModel.privateChats[peerID]?.first?.content == "Private Content")
|
|
||||||
|
|
||||||
// Verify notification trigger (unread count should increase if not viewing)
|
|
||||||
#expect(viewModel.unreadPrivateMessages.contains(peerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handlePrivateMessage_deduplicates() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "SENDER_001")
|
|
||||||
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "msg-1",
|
|
||||||
sender: "Sender",
|
|
||||||
content: "Content",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
senderPeerID: peerID
|
|
||||||
)
|
|
||||||
|
|
||||||
viewModel.handlePrivateMessage(message)
|
|
||||||
viewModel.handlePrivateMessage(message) // Duplicate
|
|
||||||
|
|
||||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func handlePrivateMessage_sendsReadReceipt_whenViewing() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "SENDER_001")
|
|
||||||
|
|
||||||
// Set as currently viewing
|
|
||||||
viewModel.selectedPrivateChatPeer = peerID
|
|
||||||
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "msg-1",
|
|
||||||
sender: "Sender",
|
|
||||||
content: "Content",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
senderPeerID: peerID
|
|
||||||
)
|
|
||||||
|
|
||||||
viewModel.handlePrivateMessage(message)
|
|
||||||
|
|
||||||
// Should NOT be marked unread
|
|
||||||
#expect(!viewModel.unreadPrivateMessages.contains(peerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func migratePrivateChats_consolidatesHistory_onFingerprintMatch() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let oldPeerID = PeerID(str: "OLD_PEER")
|
|
||||||
let newPeerID = PeerID(str: "NEW_PEER")
|
|
||||||
let fingerprint = "fp_123"
|
|
||||||
|
|
||||||
// Setup old chat
|
|
||||||
let oldMessage = BitchatMessage(
|
|
||||||
id: "msg-old",
|
|
||||||
sender: "User",
|
|
||||||
content: "Old message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
senderPeerID: oldPeerID
|
|
||||||
)
|
|
||||||
viewModel.privateChats[oldPeerID] = [oldMessage]
|
|
||||||
viewModel.peerIDToPublicKeyFingerprint[oldPeerID] = fingerprint
|
|
||||||
|
|
||||||
// Setup new peer fingerprint
|
|
||||||
viewModel.peerIDToPublicKeyFingerprint[newPeerID] = fingerprint
|
|
||||||
|
|
||||||
// Trigger migration
|
|
||||||
viewModel.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: "User")
|
|
||||||
|
|
||||||
// Verify migration
|
|
||||||
#expect(viewModel.privateChats[newPeerID]?.count == 1)
|
|
||||||
#expect(viewModel.privateChats[newPeerID]?.first?.content == "Old message")
|
|
||||||
#expect(viewModel.privateChats[oldPeerID] == nil) // Old chat removed
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func isMessageBlocked_filtersBlockedUsers() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let blockedPeerID = PeerID(str: "BLOCKED_PEER")
|
|
||||||
|
|
||||||
// Block the peer
|
|
||||||
// MockIdentityManager stores state based on fingerprint
|
|
||||||
// We need to map peerID to a fingerprint
|
|
||||||
viewModel.peerIDToPublicKeyFingerprint[blockedPeerID] = "fp_blocked"
|
|
||||||
viewModel.identityManager.setBlocked("fp_blocked", isBlocked: true)
|
|
||||||
|
|
||||||
// Also ensure UnifiedPeerService can resolve the fingerprint.
|
|
||||||
// UnifiedPeerService uses its own cache or delegates to meshService/Peer list.
|
|
||||||
// Since we are mocking, we can't easily inject into UnifiedPeerService's internal cache.
|
|
||||||
// However, ChatViewModel's isMessageBlocked uses:
|
|
||||||
// 1. isPeerBlocked(peerID) -> unifiedPeerService.isBlocked(peerID) -> getFingerprint -> identityManager.isBlocked
|
|
||||||
|
|
||||||
// We need UnifiedPeerService.getFingerprint(for: blockedPeerID) to return "fp_blocked"
|
|
||||||
// UnifiedPeerService tries: cache -> meshService -> getPeer
|
|
||||||
|
|
||||||
// Option 1: Mock the transport (meshService) to return the fingerprint
|
|
||||||
// (viewModel.transport is MockTransport, but UnifiedPeerService holds a reference to it)
|
|
||||||
// Check if MockTransport has `getFingerprint`
|
|
||||||
|
|
||||||
// If not, we might need to rely on the fallback: ChatViewModel.isMessageBlocked also checks Nostr blocks.
|
|
||||||
|
|
||||||
// Let's assume MockTransport needs `getFingerprint` implementation or update it.
|
|
||||||
// For now, let's try to verify if `MockTransport` supports `getFingerprint`.
|
|
||||||
|
|
||||||
// Actually, let's just use the Nostr block path which is simpler and also tested here.
|
|
||||||
// "Check geohash (Nostr) blocks using mapping to full pubkey"
|
|
||||||
|
|
||||||
let hexPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
|
||||||
viewModel.nostrKeyMapping[blockedPeerID] = hexPubkey
|
|
||||||
viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true)
|
|
||||||
|
|
||||||
// Force isGeoChat/isGeoDM check to be true by setting prefix?
|
|
||||||
// Or ensure the logic covers it.
|
|
||||||
// The logic is:
|
|
||||||
// if peerID.isGeoChat || peerID.isGeoDM { check nostr }
|
|
||||||
// We need a peerID that looks like geo.
|
|
||||||
|
|
||||||
let geoPeerID = PeerID(nostr_: hexPubkey)
|
|
||||||
viewModel.nostrKeyMapping[geoPeerID] = hexPubkey
|
|
||||||
|
|
||||||
let geoMessage = BitchatMessage(
|
|
||||||
id: "msg-geo-blocked",
|
|
||||||
sender: "BlockedGeoUser",
|
|
||||||
content: "Spam",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
senderPeerID: geoPeerID
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(viewModel.isMessageBlocked(geoMessage))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr Extension Tests
|
|
||||||
|
|
||||||
struct ChatViewModelNostrExtensionTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func switchLocationChannel_mesh_clearsGeo() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Setup some geo state
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
|
||||||
#expect(viewModel.currentGeohash == "u4pruydq")
|
|
||||||
|
|
||||||
// Switch to mesh
|
|
||||||
viewModel.switchLocationChannel(to: .mesh)
|
|
||||||
|
|
||||||
#expect(viewModel.activeChannel == .mesh)
|
|
||||||
#expect(viewModel.currentGeohash == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let geohash = "u4pruydq"
|
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
var event = NostrEvent(
|
|
||||||
pubkey: "pub1",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: "Hello Geo"
|
|
||||||
)
|
|
||||||
event.id = "evt1"
|
|
||||||
event.sig = "sig"
|
|
||||||
|
|
||||||
viewModel.handleNostrEvent(event)
|
|
||||||
|
|
||||||
// Allow async processing
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Check timeline
|
|
||||||
// This depends on `handlePublicMessage` being called and updating `messages`
|
|
||||||
// Since `handlePublicMessage` delegates to `timelineStore` and updates `messages`...
|
|
||||||
// And we are in the correct channel...
|
|
||||||
|
|
||||||
// However, `handleNostrEvent` in the extension now calls `handlePublicMessage`.
|
|
||||||
// Let's verify if the message appears.
|
|
||||||
// Note: `handleNostrEvent` logic was refactored.
|
|
||||||
// The new logic in `ChatViewModel+Nostr.swift` calls `handlePublicMessage`.
|
|
||||||
|
|
||||||
// We need to ensure `deduplicationService` doesn't block it (new instance, so empty).
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
//
|
|
||||||
// ChatViewModelTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for ChatViewModel using MockTransport for isolation.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - Test Helpers
|
|
||||||
|
|
||||||
/// Creates a ChatViewModel with mock dependencies for testing
|
|
||||||
@MainActor
|
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
||||||
let keychain = MockKeychain()
|
|
||||||
let keychainHelper = MockKeychainHelper()
|
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
|
||||||
let identityManager = MockIdentityManager(keychain)
|
|
||||||
let transport = MockTransport()
|
|
||||||
|
|
||||||
let viewModel = ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: identityManager,
|
|
||||||
transport: transport
|
|
||||||
)
|
|
||||||
|
|
||||||
return (viewModel, transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Initialization Tests
|
|
||||||
|
|
||||||
struct ChatViewModelInitializationTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func initialization_setsDelegate() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// The viewModel should set itself as the transport delegate
|
|
||||||
#expect(transport.delegate === viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func initialization_startsServices() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Services should be started during init
|
|
||||||
#expect(transport.startServicesCallCount == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func initialization_hasEmptyMessageList() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Initial messages may include system messages, but should be limited
|
|
||||||
#expect(viewModel.messages.count < 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func initialization_setsNickname() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Nickname should be set during init
|
|
||||||
#expect(!transport.myNickname.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Sending Tests
|
|
||||||
|
|
||||||
struct ChatViewModelSendingTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendMessage_delegatesToTransport() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
viewModel.sendMessage("Hello World")
|
|
||||||
|
|
||||||
#expect(transport.sentMessages.count == 1)
|
|
||||||
#expect(transport.sentMessages.first?.content == "Hello World")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendMessage_emptyContent_ignored() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
viewModel.sendMessage("")
|
|
||||||
viewModel.sendMessage(" ")
|
|
||||||
viewModel.sendMessage("\n\t")
|
|
||||||
|
|
||||||
#expect(transport.sentMessages.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendMessage_withMentions_sendsContent() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
viewModel.sendMessage("Hello @alice")
|
|
||||||
|
|
||||||
#expect(transport.sentMessages.count == 1)
|
|
||||||
#expect(transport.sentMessages.first?.content == "Hello @alice")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendMessage_command_notSentToTransport() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
viewModel.sendMessage("/help")
|
|
||||||
|
|
||||||
// Commands are processed locally, not sent to transport
|
|
||||||
#expect(transport.sentMessages.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Receiving Tests
|
|
||||||
|
|
||||||
struct ChatViewModelReceivingTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didReceiveMessage_callsDelegate() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "msg-001",
|
|
||||||
sender: "Alice",
|
|
||||||
content: "Hello from Alice",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: PeerID(str: "PEER001"),
|
|
||||||
mentions: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
transport.simulateIncomingMessage(message)
|
|
||||||
|
|
||||||
// Give time for Task and pipeline processing
|
|
||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
|
||||||
|
|
||||||
// Message may or may not appear due to rate limiting/pipeline batching
|
|
||||||
// The important thing is no crash and delegate was called
|
|
||||||
#expect(transport.delegate != nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didReceivePublicMessage_addsToTimeline() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
transport.simulateIncomingPublicMessage(
|
|
||||||
from: PeerID(str: "PEER002"),
|
|
||||||
nickname: "Bob",
|
|
||||||
content: "Public hello from Bob",
|
|
||||||
timestamp: Date(),
|
|
||||||
messageID: "pub-001"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Give time for async Task and pipeline processing
|
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
|
|
||||||
#expect(viewModel.messages.contains { $0.content == "Public hello from Bob" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Peer Connection Tests
|
|
||||||
|
|
||||||
struct ChatViewModelPeerTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didConnectToPeer_notifiesDelegate() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "NEWPEER")
|
|
||||||
|
|
||||||
transport.simulateConnect(peerID, nickname: "NewUser")
|
|
||||||
|
|
||||||
#expect(transport.connectedPeers.contains(peerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didDisconnectFromPeer_notifiesDelegate() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "OLDPEER")
|
|
||||||
|
|
||||||
transport.simulateConnect(peerID, nickname: "OldUser")
|
|
||||||
transport.simulateDisconnect(peerID)
|
|
||||||
|
|
||||||
#expect(!transport.connectedPeers.contains(peerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func isPeerConnected_delegatesToTransport() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
let peerID = PeerID(str: "TESTPEER")
|
|
||||||
|
|
||||||
// Not connected initially
|
|
||||||
#expect(!transport.isPeerConnected(peerID))
|
|
||||||
|
|
||||||
transport.connectedPeers.insert(peerID)
|
|
||||||
|
|
||||||
#expect(transport.isPeerConnected(peerID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Deduplication Integration Tests
|
|
||||||
//
|
|
||||||
// Note: Detailed deduplication logic is tested in MessageDeduplicationServiceTests.
|
|
||||||
// These tests verify that ChatViewModel has a deduplication service configured.
|
|
||||||
|
|
||||||
struct ChatViewModelDeduplicationTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func deduplicationService_isConfigured() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Verify the deduplication service is available and functional
|
|
||||||
// by checking that we can record and query content
|
|
||||||
let testContent = "Test dedup content \(UUID().uuidString)"
|
|
||||||
let testDate = Date()
|
|
||||||
|
|
||||||
viewModel.deduplicationService.recordContent(testContent, timestamp: testDate)
|
|
||||||
|
|
||||||
let retrieved = viewModel.deduplicationService.contentTimestamp(for: testContent)
|
|
||||||
#expect(retrieved == testDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func deduplicationService_normalizedKey_consistent() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
|
|
||||||
let content = "Hello World"
|
|
||||||
let key1 = viewModel.deduplicationService.normalizedContentKey(content)
|
|
||||||
let key2 = viewModel.deduplicationService.normalizedContentKey(content)
|
|
||||||
|
|
||||||
#expect(key1 == key2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Chat Tests
|
|
||||||
|
|
||||||
struct ChatViewModelPrivateChatTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func sendPrivateMessage_delegatesToTransport() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
let recipientID = PeerID(str: "RECIPIENT")
|
|
||||||
|
|
||||||
// Set up connected peer for routing
|
|
||||||
transport.connectedPeers.insert(recipientID)
|
|
||||||
transport.peerNicknames[recipientID] = "Recipient"
|
|
||||||
|
|
||||||
viewModel.sendPrivateMessage("Secret message", to: recipientID)
|
|
||||||
|
|
||||||
// The message routing depends on connection state and other factors
|
|
||||||
// At minimum, it should not crash
|
|
||||||
#expect(true) // If we get here without crash, the test passes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Bluetooth State Tests
|
|
||||||
|
|
||||||
struct ChatViewModelBluetoothTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didUpdateBluetoothState_poweredOn_noAlert() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
transport.simulateBluetoothStateChange(.poweredOn)
|
|
||||||
|
|
||||||
// Give time for async processing
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
#expect(!viewModel.showBluetoothAlert)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didUpdateBluetoothState_poweredOff_showsAlert() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
transport.simulateBluetoothStateChange(.poweredOff)
|
|
||||||
|
|
||||||
// Give time for async processing
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
#expect(viewModel.showBluetoothAlert)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func didUpdateBluetoothState_unauthorized_showsAlert() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
transport.simulateBluetoothStateChange(.unauthorized)
|
|
||||||
|
|
||||||
// Give time for async processing
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
#expect(viewModel.showBluetoothAlert)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Panic Clear Tests
|
|
||||||
|
|
||||||
struct ChatViewModelPanicTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func panicClearAllData_delegatesToTransport() async {
|
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
// Set up some state
|
|
||||||
transport.connectedPeers.insert(PeerID(str: "PEER1"))
|
|
||||||
|
|
||||||
viewModel.panicClearAllData()
|
|
||||||
|
|
||||||
// After panic, emergency disconnect should be called
|
|
||||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Service Lifecycle Tests
|
|
||||||
|
|
||||||
struct ChatViewModelLifecycleTests {
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func startServices_calledOnInit() async {
|
|
||||||
let (_, transport) = makeTestableViewModel()
|
|
||||||
|
|
||||||
#expect(transport.startServicesCallCount == 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +1,54 @@
|
|||||||
import Testing
|
import XCTest
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct CommandProcessorTests {
|
final class CommandProcessorTests: XCTestCase {
|
||||||
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
|
||||||
@Test func slapNotFoundGrammar() {
|
func test_slap_notFoundGrammar() {
|
||||||
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):
|
||||||
#expect(message == "cannot slap system: not found")
|
XCTAssertEqual(message, "cannot slap system: not found")
|
||||||
default:
|
default:
|
||||||
Issue.record("Expected error result")
|
XCTFail("Expected error result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func hugNotFoundGrammar() {
|
func test_hug_notFoundGrammar() {
|
||||||
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):
|
||||||
#expect(message == "cannot hug system: not found")
|
XCTAssertEqual(message, "cannot hug system: not found")
|
||||||
default:
|
default:
|
||||||
Issue.record("Expected error result")
|
XCTFail("Expected error result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func slapUsageMessage() {
|
func test_slap_usageMessage() {
|
||||||
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):
|
||||||
#expect(message == "usage: /slap <nickname>")
|
XCTAssertEqual(message, "usage: /slap <nickname>")
|
||||||
default:
|
default:
|
||||||
Issue.record("Expected error result for usage message")
|
XCTFail("Expected error result for usage message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,19 +11,21 @@ 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, bus: bus)
|
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
||||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)
|
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
||||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)
|
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
||||||
|
mockKeychain = MockKeychain()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Basic Private Messaging Tests
|
// MARK: - Basic Private Messaging Tests
|
||||||
@@ -51,7 +53,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 sleep(0.1)
|
try? await Task.sleep(nanoseconds: UInt64(TestConstants.shortTimeout * 1_000_000_000))
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(!bobReceivedMessage, "Bob should not have received the message")
|
#expect(!bobReceivedMessage, "Bob should not have received the message")
|
||||||
@@ -169,7 +171,7 @@ struct PrivateChatE2ETests {
|
|||||||
// Send encrypted private message
|
// Send encrypted private message
|
||||||
alice.sendPrivateMessage(
|
alice.sendPrivateMessage(
|
||||||
TestConstants.testMessage1,
|
TestConstants.testMessage1,
|
||||||
to: bob.peerID,
|
to: TestConstants.testPeerID2,
|
||||||
recipientNickname: TestConstants.testNickname2
|
recipientNickname: TestConstants.testNickname2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -186,11 +188,11 @@ struct PrivateChatE2ETests {
|
|||||||
// Bob relays private messages for Charlie
|
// Bob relays private messages for Charlie
|
||||||
bob.packetDeliveryHandler = { packet in
|
bob.packetDeliveryHandler = { packet in
|
||||||
if let recipientID = packet.recipientID,
|
if let recipientID = packet.recipientID,
|
||||||
PeerID(data: recipientID) == charlie.peerID {
|
String(data: recipientID, encoding: .utf8) == charlie.peerID {
|
||||||
// Relay to Charlie
|
// Relay to Charlie
|
||||||
var relayPacket = packet
|
var relayPacket = packet
|
||||||
relayPacket.ttl = packet.ttl - 1
|
relayPacket.ttl = packet.ttl - 1
|
||||||
charlie.simulateIncomingPacket(relayPacket)
|
self.charlie.simulateIncomingPacket(relayPacket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +235,7 @@ struct PrivateChatE2ETests {
|
|||||||
for i in 0..<messageCount {
|
for i in 0..<messageCount {
|
||||||
alice.sendPrivateMessage(
|
alice.sendPrivateMessage(
|
||||||
"Private message \(i)",
|
"Private message \(i)",
|
||||||
to: bob.peerID,
|
to: TestConstants.testPeerID2,
|
||||||
recipientNickname: TestConstants.testNickname2
|
recipientNickname: TestConstants.testNickname2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -252,7 +254,7 @@ struct PrivateChatE2ETests {
|
|||||||
|
|
||||||
alice.sendPrivateMessage(
|
alice.sendPrivateMessage(
|
||||||
TestConstants.testLongMessage,
|
TestConstants.testLongMessage,
|
||||||
to: bob.peerID,
|
to: TestConstants.testPeerID2,
|
||||||
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, bus: bus)
|
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
||||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)
|
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
||||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)
|
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
||||||
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4, bus: bus)
|
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Basic Broadcasting Tests
|
// MARK: - Basic Broadcasting Tests
|
||||||
@@ -388,7 +388,7 @@ struct PublicChatE2ETests {
|
|||||||
|
|
||||||
if let message = BitchatMessage(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
// Don't relay own messages
|
// Don't relay own messages
|
||||||
guard message.senderPeerID != node.peerID else { return }
|
guard message.senderPeerID?.id != node.peerID else { return }
|
||||||
|
|
||||||
// Create relay message
|
// Create relay message
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ struct FragmentationTests {
|
|||||||
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(str: "1122334455667788")
|
let remoteShortID: PeerID = "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
|
||||||
@@ -45,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 = 5 * Double(i) * 0.001
|
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
||||||
Task {
|
Task {
|
||||||
try await sleep(delay)
|
try await Task.sleep(nanoseconds: delay)
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(0.5)
|
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
||||||
|
|
||||||
#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)
|
||||||
@@ -69,7 +69,7 @@ struct FragmentationTests {
|
|||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
|
let remoteShortID: PeerID = "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)
|
||||||
|
|
||||||
@@ -79,75 +79,19 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (i, fragment) in frags.enumerated() {
|
for (i, fragment) in frags.enumerated() {
|
||||||
let delay = 5 * Double(i) * 0.001
|
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
||||||
Task {
|
Task {
|
||||||
try await sleep(delay)
|
try await Task.sleep(nanoseconds: delay)
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(0.5)
|
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
||||||
|
|
||||||
#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 {
|
||||||
@@ -159,7 +103,7 @@ struct FragmentationTests {
|
|||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
let remoteShortID = PeerID(str: "0011223344556677")
|
let remoteShortID: PeerID = "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)
|
||||||
|
|
||||||
@@ -180,16 +124,16 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (i, fragment) in corrupted.enumerated() {
|
for (i, fragment) in corrupted.enumerated() {
|
||||||
let delay = 5 * Double(i) * 0.001
|
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
||||||
Task {
|
Task {
|
||||||
try await sleep(delay)
|
try await Task.sleep(nanoseconds: delay)
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await sleep(0.5)
|
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
@@ -198,10 +142,7 @@ 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)] = []
|
||||||
var receivedMessages: [BitchatMessage] = []
|
func didReceiveMessage(_ message: 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]) {}
|
||||||
@@ -209,7 +150,7 @@ extension FragmentationTests {
|
|||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||||
publicMessages.append((peerID, nickname, content))
|
publicMessages.append((peerID, nickname, content))
|
||||||
}
|
}
|
||||||
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
||||||
@@ -232,8 +173,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, pad: Bool = true) -> [BitchatPacket] {
|
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil) -> [BitchatPacket] {
|
||||||
guard let fullData = packet.toBinaryData(padding: pad) else { return [] }
|
let fullData = packet.toBinaryData() ?? Data()
|
||||||
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,23 +1,22 @@
|
|||||||
import Testing
|
import XCTest
|
||||||
import struct Foundation.Data
|
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct GCSFilterTests {
|
final class GCSFilterTests: XCTestCase {
|
||||||
@Test func buildFilterWithDuplicateIdsProducesStableEncoding() {
|
func testBuildFilterWithDuplicateIdsProducesStableEncoding() {
|
||||||
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)
|
||||||
#expect(params.m >= 1)
|
XCTAssertGreaterThanOrEqual(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)
|
||||||
#expect(decoded.count <= 1)
|
XCTAssertLessThanOrEqual(decoded.count, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func bucketAvoidsZeroCandidate() {
|
func testBucketAvoidsZeroCandidate() {
|
||||||
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)
|
||||||
#expect(bucket != 0)
|
XCTAssertNotEqual(bucket, 0)
|
||||||
#expect(bucket < 2)
|
XCTAssertLessThan(bucket, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,52 @@
|
|||||||
import Testing
|
import XCTest
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct GeohashBookmarksStoreTests {
|
final class GeohashBookmarksStoreTests: XCTestCase {
|
||||||
private let storeKey = "locationChannel.bookmarks"
|
let storeKey = "locationChannel.bookmarks"
|
||||||
private let storage = UserDefaults(suiteName: UUID().uuidString)!
|
var storage: UserDefaults!
|
||||||
private let store: GeohashBookmarksStore
|
var store: GeohashBookmarksStore!
|
||||||
|
|
||||||
init() {
|
override func setUp() {
|
||||||
store = GeohashBookmarksStore(storage: storage)
|
super.setUp()
|
||||||
|
// Unique instance for each test to avoid race condition
|
||||||
|
storage = UserDefaults(suiteName: UUID().uuidString)
|
||||||
|
store = GeohashBookmarksStore(storage: storage!)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func toggleAndNormalize() {
|
override func tearDown() {
|
||||||
|
storage.removeObject(forKey: storeKey)
|
||||||
|
store._resetForTesting()
|
||||||
|
store = nil
|
||||||
|
storage = nil
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testToggleAndNormalize() {
|
||||||
// Start clean
|
// Start clean
|
||||||
#expect(store.bookmarks.isEmpty)
|
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||||
|
|
||||||
// Add with mixed case and hash prefix
|
// Add with mixed case and hash prefix
|
||||||
store.toggle("#U4PRUY")
|
store.toggle("#U4PRUY")
|
||||||
#expect(store.isBookmarked("u4pruy"))
|
XCTAssertTrue(store.isBookmarked("u4pruy"))
|
||||||
#expect(store.bookmarks.first == "u4pruy")
|
XCTAssertEqual(store.bookmarks.first, "u4pruy")
|
||||||
|
|
||||||
// Toggling again removes
|
// Toggling again removes
|
||||||
store.toggle("u4pruy")
|
store.toggle("u4pruy")
|
||||||
#expect(!store.isBookmarked("u4pruy"))
|
XCTAssertFalse(store.isBookmarked("u4pruy"))
|
||||||
#expect(store.bookmarks.isEmpty)
|
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func persistenceWritten() throws {
|
func testPersistenceWritten() 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)
|
||||||
let data = try #require(storage.data(forKey: storeKey), "No persisted data found")
|
guard let data = storage.data(forKey: storeKey) else {
|
||||||
|
XCTFail("No persisted data found")
|
||||||
|
return
|
||||||
|
}
|
||||||
let arr = try JSONDecoder().decode([String].self, from: data)
|
let arr = try JSONDecoder().decode([String].self, from: data)
|
||||||
#expect(arr.contains("ezs42"))
|
XCTAssertTrue(arr.contains("ezs42"))
|
||||||
#expect(arr.contains("u4pruy"))
|
XCTAssertTrue(arr.contains("u4pruy"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
//
|
|
||||||
// GeohashParticipantTrackerTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for GeohashParticipantTracker.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
/// Mock context for testing
|
|
||||||
@MainActor
|
|
||||||
final class MockParticipantContext: GeohashParticipantContext {
|
|
||||||
var blockedPubkeys: Set<String> = []
|
|
||||||
var nicknameMap: [String: String] = [:]
|
|
||||||
var selfPubkey: String?
|
|
||||||
|
|
||||||
func displayNameForPubkey(_ pubkeyHex: String) -> String {
|
|
||||||
let suffix = String(pubkeyHex.suffix(4))
|
|
||||||
if let self = selfPubkey, pubkeyHex.lowercased() == self.lowercased() {
|
|
||||||
return "me#\(suffix)"
|
|
||||||
}
|
|
||||||
if let nick = nicknameMap[pubkeyHex.lowercased()] {
|
|
||||||
return "\(nick)#\(suffix)"
|
|
||||||
}
|
|
||||||
return "anon#\(suffix)"
|
|
||||||
}
|
|
||||||
|
|
||||||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
|
|
||||||
blockedPubkeys.contains(pubkeyHexLowercased.lowercased())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct GeohashParticipantTrackerTests {
|
|
||||||
|
|
||||||
// MARK: - Basic Recording Tests
|
|
||||||
|
|
||||||
@Test func recordParticipant_addsToActiveGeohash() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "deadbeef1234")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordParticipant_noActiveGeohash_noOp() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
// No active geohash set
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "deadbeef1234")
|
|
||||||
|
|
||||||
// Should not throw or crash
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordParticipant_specificGeohash() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: "geo1") == 1)
|
|
||||||
#expect(tracker.participantCount(for: "geo2") == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordParticipant_updatesLastSeen() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
// Small delay and record again
|
|
||||||
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
|
|
||||||
// Should still count as 1 participant (updated, not duplicated)
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordParticipant_lowercasesPubkey() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "DEADBEEF")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "deadbeef")
|
|
||||||
|
|
||||||
// Should be treated as same participant
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Visible People Tests
|
|
||||||
|
|
||||||
@Test func getVisiblePeople_returnsActiveGeohashParticipants() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey2")
|
|
||||||
|
|
||||||
let people = tracker.getVisiblePeople()
|
|
||||||
#expect(people.count == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func getVisiblePeople_excludesBlockedParticipants() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
context.blockedPubkeys = ["pubkey2"]
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey2")
|
|
||||||
|
|
||||||
let people = tracker.getVisiblePeople()
|
|
||||||
#expect(people.count == 1)
|
|
||||||
#expect(people.first?.id == "pubkey1")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func getVisiblePeople_usesDisplayNameFromContext() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
context.nicknameMap = ["pubkey1234": "alice"]
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1234")
|
|
||||||
|
|
||||||
let people = tracker.getVisiblePeople()
|
|
||||||
#expect(people.count == 1)
|
|
||||||
#expect(people.first?.displayName == "alice#1234")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func getVisiblePeople_sortedByLastSeen() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "older")
|
|
||||||
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
|
||||||
tracker.recordParticipant(pubkeyHex: "newer")
|
|
||||||
|
|
||||||
let people = tracker.getVisiblePeople()
|
|
||||||
#expect(people.count == 2)
|
|
||||||
#expect(people.first?.id == "newer")
|
|
||||||
#expect(people.last?.id == "older")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func getVisiblePeople_emptyWhenNoActiveGeohash() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123")
|
|
||||||
|
|
||||||
let people = tracker.getVisiblePeople()
|
|
||||||
#expect(people.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Activity Cutoff Tests
|
|
||||||
|
|
||||||
@Test func participantCount_excludesExpiredEntries() async {
|
|
||||||
// Use a very short cutoff for testing
|
|
||||||
let tracker = GeohashParticipantTracker(activityCutoff: -0.05) // 50ms cutoff
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
|
|
||||||
// Should be counted immediately
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 1)
|
|
||||||
|
|
||||||
// Wait for expiry
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
||||||
|
|
||||||
// Should be expired now
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Remove Participant Tests
|
|
||||||
|
|
||||||
@Test func removeParticipant_removesFromAllGeohashes() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo2")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo1")
|
|
||||||
|
|
||||||
tracker.removeParticipant(pubkeyHex: "pubkey1")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: "geo1") == 1)
|
|
||||||
#expect(tracker.participantCount(for: "geo2") == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Clear Tests
|
|
||||||
|
|
||||||
@Test func clear_removesAllData() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "other")
|
|
||||||
|
|
||||||
tracker.clear()
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: "abc123") == 0)
|
|
||||||
#expect(tracker.participantCount(for: "other") == 0)
|
|
||||||
#expect(tracker.visiblePeople.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func clearGeohash_removesOnlySpecificGeohash() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2")
|
|
||||||
|
|
||||||
tracker.clear(geohash: "geo1")
|
|
||||||
|
|
||||||
#expect(tracker.participantCount(for: "geo1") == 0)
|
|
||||||
#expect(tracker.participantCount(for: "geo2") == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Set Active Geohash Tests
|
|
||||||
|
|
||||||
@Test func setActiveGeohash_clearsVisiblePeopleWhenNil() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
|
||||||
|
|
||||||
#expect(!tracker.visiblePeople.isEmpty)
|
|
||||||
|
|
||||||
tracker.setActiveGeohash(nil)
|
|
||||||
|
|
||||||
#expect(tracker.visiblePeople.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func setActiveGeohash_refreshesVisiblePeople() async {
|
|
||||||
let tracker = GeohashParticipantTracker()
|
|
||||||
let context = MockParticipantContext()
|
|
||||||
tracker.configure(context: context)
|
|
||||||
|
|
||||||
// Pre-populate a geohash
|
|
||||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123")
|
|
||||||
|
|
||||||
// Set it as active
|
|
||||||
tracker.setActiveGeohash("abc123")
|
|
||||||
|
|
||||||
#expect(tracker.visiblePeople.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - GeoPerson Tests
|
|
||||||
|
|
||||||
@Test func geoPerson_identifiable() async {
|
|
||||||
let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date())
|
|
||||||
let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date())
|
|
||||||
let person3 = GeoPerson(id: "xyz", displayName: "bob", lastSeen: Date())
|
|
||||||
|
|
||||||
#expect(person1.id == person2.id)
|
|
||||||
#expect(person1.id != person3.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func geoPerson_equatable() async {
|
|
||||||
let date = Date()
|
|
||||||
let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date)
|
|
||||||
let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date)
|
|
||||||
|
|
||||||
#expect(person1 == person2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,24 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct GossipSyncManagerTests {
|
final class GossipSyncManagerTests: XCTestCase {
|
||||||
|
func testConcurrentPacketIntakeAndSyncRequest() {
|
||||||
private let myPeerID = PeerID(str: "0102030405060708")
|
let manager = GossipSyncManager(myPeerID: "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
|
||||||
|
|
||||||
try await confirmation("sync request sent") { sent in
|
let iterations = 200
|
||||||
delegate.onSend = {
|
let group = DispatchGroup()
|
||||||
sent()
|
|
||||||
}
|
|
||||||
|
|
||||||
let iterations = 200
|
for i in 0..<iterations {
|
||||||
let senderID = try #require(Data(hexString: "1122334455667788"))
|
group.enter()
|
||||||
|
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: senderID,
|
senderID: Data(hexString: "1122334455667788") ?? Data(),
|
||||||
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)]),
|
||||||
@@ -30,26 +26,35 @@ struct GossipSyncManagerTests {
|
|||||||
ttl: 1
|
ttl: 1
|
||||||
)
|
)
|
||||||
manager.onPublicPacketSeen(packet)
|
manager.onPublicPacketSeen(packet)
|
||||||
try await sleep(0.001)
|
Thread.sleep(forTimeInterval: 0.001)
|
||||||
|
group.leave()
|
||||||
}
|
}
|
||||||
|
|
||||||
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
|
||||||
try await sleep(0.002)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.002) {
|
||||||
#expect(lastPacket.type == MessageType.requestSync.rawValue)
|
manager.scheduleInitialSyncToPeer("FFFFFFFFFFFFFFFF", delaySeconds: 0.0)
|
||||||
#expect(RequestSyncPacket.decode(from: lastPacket.payload) != nil)
|
}
|
||||||
|
|
||||||
|
group.wait()
|
||||||
|
wait(for: [sendExpectation], timeout: 2.0)
|
||||||
|
|
||||||
|
guard let lastPacket = delegate.lastPacket else {
|
||||||
|
XCTFail("Expected sync packet to be sent")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue)
|
||||||
|
XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func staleAnnouncementsArePurgedWithMessages() throws {
|
func testStaleAnnouncementsArePurgedWithMessages() {
|
||||||
var config = GossipSyncManager.Config()
|
var config = GossipSyncManager.Config()
|
||||||
config.stalePeerCleanupIntervalSeconds = 0
|
config.stalePeerCleanupIntervalSeconds = 0
|
||||||
config.stalePeerTimeoutSeconds = 5
|
config.stalePeerTimeoutSeconds = 5
|
||||||
|
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
let manager = GossipSyncManager(myPeerID: "0102030405060708", config: config)
|
||||||
let peerHex = "0011223344556677"
|
let peerHex = "0011223344556677"
|
||||||
let senderData = try #require(Data(hexString: peerHex))
|
let senderData = Data(hexString: peerHex) ?? Data()
|
||||||
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
|
||||||
let announcePacket = BitchatPacket(
|
let announcePacket = BitchatPacket(
|
||||||
@@ -77,24 +82,24 @@ struct GossipSyncManagerTests {
|
|||||||
|
|
||||||
// Flush queue without triggering stale cleanup yet
|
// Flush queue without triggering stale cleanup yet
|
||||||
manager._performMaintenanceSynchronously(now: Date())
|
manager._performMaintenanceSynchronously(now: Date())
|
||||||
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)))
|
XCTAssertTrue(manager._hasAnnouncement(for: PeerID(str: peerHex)))
|
||||||
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 1)
|
XCTAssertEqual(manager._messageCount(for: PeerID(str: peerHex)), 1)
|
||||||
|
|
||||||
// Run cleanup past the timeout
|
// Run cleanup past the timeout
|
||||||
let future = Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1)
|
let future = Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1)
|
||||||
manager._performMaintenanceSynchronously(now: future)
|
manager._performMaintenanceSynchronously(now: future)
|
||||||
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
XCTAssertFalse(manager._hasAnnouncement(for: PeerID(str: peerHex)))
|
||||||
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
XCTAssertEqual(manager._messageCount(for: PeerID(str: peerHex)), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func ignoresAnnounceOlderThanStaleTimeout() throws {
|
func testIgnoresAnnounceOlderThanStaleTimeout() {
|
||||||
var config = GossipSyncManager.Config()
|
var config = GossipSyncManager.Config()
|
||||||
config.stalePeerTimeoutSeconds = 5
|
config.stalePeerTimeoutSeconds = 5
|
||||||
config.maxMessageAgeSeconds = 100
|
config.maxMessageAgeSeconds = 100
|
||||||
|
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
let manager = GossipSyncManager(myPeerID: "0102030405060708", config: config)
|
||||||
let peerHex = "8899aabbccddeeff"
|
let peerHex = "8899aabbccddeeff"
|
||||||
let senderData = try #require(Data(hexString: peerHex))
|
let senderData = Data(hexString: peerHex) ?? Data()
|
||||||
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
|
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
|
||||||
|
|
||||||
let freshMessage = BitchatPacket(
|
let freshMessage = BitchatPacket(
|
||||||
@@ -122,141 +127,19 @@ struct GossipSyncManagerTests {
|
|||||||
|
|
||||||
manager._performMaintenanceSynchronously()
|
manager._performMaintenanceSynchronously()
|
||||||
|
|
||||||
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
XCTAssertFalse(manager._hasAnnouncement(for: PeerID(str: peerHex)))
|
||||||
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
XCTAssertEqual(manager._messageCount(for: PeerID(str: peerHex)), 0)
|
||||||
}
|
|
||||||
|
|
||||||
@Test func maintenanceEmitsTypedSyncRequests() throws {
|
|
||||||
var config = GossipSyncManager.Config()
|
|
||||||
config.seenCapacity = 10
|
|
||||||
config.fragmentCapacity = 5
|
|
||||||
config.fileTransferCapacity = 4
|
|
||||||
config.messageSyncIntervalSeconds = 1
|
|
||||||
config.fragmentSyncIntervalSeconds = 1
|
|
||||||
config.fileTransferSyncIntervalSeconds = 1
|
|
||||||
config.maintenanceIntervalSeconds = 0
|
|
||||||
|
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
|
||||||
let delegate = RecordingDelegate()
|
|
||||||
manager.delegate = delegate
|
|
||||||
|
|
||||||
let sender = try #require(Data(hexString: "1122334455667788"))
|
|
||||||
let now = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
|
|
||||||
let announcePacket = BitchatPacket(
|
|
||||||
type: MessageType.announce.rawValue,
|
|
||||||
senderID: sender,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: now,
|
|
||||||
payload: Data(),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
let messagePacket = BitchatPacket(
|
|
||||||
type: MessageType.message.rawValue,
|
|
||||||
senderID: sender,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: now,
|
|
||||||
payload: Data([0x01]),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
let fragmentPacket = BitchatPacket(
|
|
||||||
type: MessageType.fragment.rawValue,
|
|
||||||
senderID: sender,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: now,
|
|
||||||
payload: Data([0xAA]),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
let filePacket = BitchatPacket(
|
|
||||||
type: MessageType.fileTransfer.rawValue,
|
|
||||||
senderID: sender,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: now,
|
|
||||||
payload: Data([0xBB]),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1,
|
|
||||||
version: 2
|
|
||||||
)
|
|
||||||
|
|
||||||
manager.onPublicPacketSeen(announcePacket)
|
|
||||||
manager.onPublicPacketSeen(messagePacket)
|
|
||||||
manager.onPublicPacketSeen(fragmentPacket)
|
|
||||||
manager.onPublicPacketSeen(filePacket)
|
|
||||||
|
|
||||||
manager._performMaintenanceSynchronously(now: Date())
|
|
||||||
|
|
||||||
let sentPackets = delegate.packets
|
|
||||||
#expect(sentPackets.count == 3)
|
|
||||||
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
|
|
||||||
#expect(decoded.count == 3)
|
|
||||||
#expect(decoded[0].types == .publicMessages)
|
|
||||||
#expect(decoded[1].types == .fragment)
|
|
||||||
#expect(decoded[2].types == .fileTransfer)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func handleRequestSyncHonorsTypeFilter() async throws {
|
|
||||||
var config = GossipSyncManager.Config()
|
|
||||||
config.seenCapacity = 5
|
|
||||||
config.fragmentCapacity = 5
|
|
||||||
config.fileTransferCapacity = 0
|
|
||||||
config.messageSyncIntervalSeconds = 0
|
|
||||||
config.fragmentSyncIntervalSeconds = 0
|
|
||||||
config.fileTransferSyncIntervalSeconds = 0
|
|
||||||
|
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
|
||||||
let delegate = RecordingDelegate()
|
|
||||||
manager.delegate = delegate
|
|
||||||
|
|
||||||
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
|
|
||||||
let now = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
|
|
||||||
let messagePacket = BitchatPacket(
|
|
||||||
type: MessageType.message.rawValue,
|
|
||||||
senderID: sender,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: now,
|
|
||||||
payload: Data([0x10]),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
|
|
||||||
let fragmentPacket = BitchatPacket(
|
|
||||||
type: MessageType.fragment.rawValue,
|
|
||||||
senderID: sender,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: now,
|
|
||||||
payload: Data([0x20]),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
|
|
||||||
manager.onPublicPacketSeen(messagePacket)
|
|
||||||
manager.onPublicPacketSeen(fragmentPacket)
|
|
||||||
|
|
||||||
let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
|
|
||||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
|
|
||||||
manager.handleRequestSync(from: peer, request: request)
|
|
||||||
|
|
||||||
try await sleep(0.01)
|
|
||||||
let sentPackets = delegate.packets
|
|
||||||
#expect(sentPackets.count == 1)
|
|
||||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||||
var onSend: (() -> Void)?
|
var onSend: (() -> Void)?
|
||||||
private(set) var lastPacket: BitchatPacket?
|
private(set) var lastPacket: BitchatPacket?
|
||||||
private(set) var packets: [BitchatPacket] = []
|
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
|
|
||||||
func sendPacket(_ packet: BitchatPacket) {
|
func sendPacket(_ packet: BitchatPacket) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
lastPacket = packet
|
lastPacket = packet
|
||||||
packets.append(packet)
|
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
onSend?()
|
onSend?()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
//
|
|
||||||
// InputValidatorTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct InputValidatorTests {
|
|
||||||
|
|
||||||
// MARK: - Basic Validation Tests
|
|
||||||
|
|
||||||
@Test func validStringPassesValidation() throws {
|
|
||||||
let result = InputValidator.validateUserString("Hello World", maxLength: 100)
|
|
||||||
#expect(result == "Hello World")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func emptyStringReturnsNil() throws {
|
|
||||||
let result = InputValidator.validateUserString("", maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func whitespaceOnlyStringReturnsNil() throws {
|
|
||||||
let result = InputValidator.validateUserString(" \n\t ", maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func stringExceedingMaxLengthReturnsNil() throws {
|
|
||||||
let longString = String(repeating: "a", count: 101)
|
|
||||||
let result = InputValidator.validateUserString(longString, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func stringAtMaxLengthIsAccepted() throws {
|
|
||||||
let exactString = String(repeating: "a", count: 100)
|
|
||||||
let result = InputValidator.validateUserString(exactString, maxLength: 100)
|
|
||||||
#expect(result == exactString)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func whitespaceIsTrimmed() throws {
|
|
||||||
let result = InputValidator.validateUserString(" Hello ", maxLength: 100)
|
|
||||||
#expect(result == "Hello")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Control Character Tests
|
|
||||||
|
|
||||||
@Test func nullCharacterIsRejected() throws {
|
|
||||||
let stringWithNull = "Hello\u{0000}World"
|
|
||||||
let result = InputValidator.validateUserString(stringWithNull, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func bellCharacterIsRejected() throws {
|
|
||||||
let stringWithBell = "Hello\u{0007}World"
|
|
||||||
let result = InputValidator.validateUserString(stringWithBell, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func backspaceCharacterIsRejected() throws {
|
|
||||||
let stringWithBackspace = "Hello\u{0008}World"
|
|
||||||
let result = InputValidator.validateUserString(stringWithBackspace, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func escapeCharacterIsRejected() throws {
|
|
||||||
let stringWithEscape = "Hello\u{001B}World"
|
|
||||||
let result = InputValidator.validateUserString(stringWithEscape, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func deleteCharacterIsRejected() throws {
|
|
||||||
let stringWithDelete = "Hello\u{007F}World"
|
|
||||||
let result = InputValidator.validateUserString(stringWithDelete, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func multipleControlCharactersAreRejected() throws {
|
|
||||||
let stringWithMultiple = "Hello\u{0000}\u{0007}\u{001B}World"
|
|
||||||
let result = InputValidator.validateUserString(stringWithMultiple, maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Unicode and Special Character Tests
|
|
||||||
|
|
||||||
@Test func emojiIsAccepted() throws {
|
|
||||||
let result = InputValidator.validateUserString("Hello 👋 World", maxLength: 100)
|
|
||||||
#expect(result == "Hello 👋 World")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func unicodeCharactersAreAccepted() throws {
|
|
||||||
let result = InputValidator.validateUserString("Hello 世界 مرحبا", maxLength: 100)
|
|
||||||
#expect(result == "Hello 世界 مرحبا")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func specialCharactersAreAccepted() throws {
|
|
||||||
let result = InputValidator.validateUserString("Hello!@#$%^&*()_+-=[]{}|;':\",./<>?", maxLength: 100)
|
|
||||||
#expect(result == "Hello!@#$%^&*()_+-=[]{}|;':\",./<>?")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nickname Validation Tests
|
|
||||||
|
|
||||||
@Test func validNicknameIsAccepted() throws {
|
|
||||||
let result = InputValidator.validateNickname("Alice")
|
|
||||||
#expect(result == "Alice")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func nicknameWithEmojiIsAccepted() throws {
|
|
||||||
let result = InputValidator.validateNickname("Alice 🚀")
|
|
||||||
#expect(result == "Alice 🚀")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func nicknameTooLongIsRejected() throws {
|
|
||||||
let longNickname = String(repeating: "a", count: 51)
|
|
||||||
let result = InputValidator.validateNickname(longNickname)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func nicknameAtMaxLengthIsAccepted() throws {
|
|
||||||
let exactNickname = String(repeating: "a", count: 50)
|
|
||||||
let result = InputValidator.validateNickname(exactNickname)
|
|
||||||
#expect(result == exactNickname)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func nicknameWithControlCharacterIsRejected() throws {
|
|
||||||
let result = InputValidator.validateNickname("Alice\u{0000}")
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Timestamp Validation Tests
|
|
||||||
|
|
||||||
@Test func currentTimestampIsValid() throws {
|
|
||||||
let now = Date()
|
|
||||||
let result = InputValidator.validateTimestamp(now)
|
|
||||||
#expect(result == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampWithinOneHourIsValid() throws {
|
|
||||||
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
|
|
||||||
let result = InputValidator.validateTimestamp(thirtyMinutesAgo)
|
|
||||||
#expect(result == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampTwoHoursAgoIsInvalid() throws {
|
|
||||||
let twoHoursAgo = Date().addingTimeInterval(-2 * 3600)
|
|
||||||
let result = InputValidator.validateTimestamp(twoHoursAgo)
|
|
||||||
#expect(result == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampTwoHoursInFutureIsInvalid() throws {
|
|
||||||
let twoHoursFromNow = Date().addingTimeInterval(2 * 3600)
|
|
||||||
let result = InputValidator.validateTimestamp(twoHoursFromNow)
|
|
||||||
#expect(result == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func timestampAtOneHourBoundaryIsValid() throws {
|
|
||||||
// Just slightly within the one-hour window
|
|
||||||
let almostOneHourAgo = Date().addingTimeInterval(-3599)
|
|
||||||
let result = InputValidator.validateTimestamp(almostOneHourAgo)
|
|
||||||
#expect(result == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Edge Cases
|
|
||||||
|
|
||||||
@Test func singleCharacterStringIsAccepted() throws {
|
|
||||||
let result = InputValidator.validateUserString("a", maxLength: 100)
|
|
||||||
#expect(result == "a")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func stringWithOnlyNewlinesIsRejected() throws {
|
|
||||||
let result = InputValidator.validateUserString("\n\n\n", maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func stringWithMixedWhitespaceIsTrimmed() throws {
|
|
||||||
let result = InputValidator.validateUserString(" \t\nHello\n\t ", maxLength: 100)
|
|
||||||
#expect(result == "Hello")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func stringWithLeadingControlCharacterIsRejected() throws {
|
|
||||||
let result = InputValidator.validateUserString("\u{0000}Hello", maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func stringWithTrailingControlCharacterIsRejected() throws {
|
|
||||||
let result = InputValidator.validateUserString("Hello\u{0000}", maxLength: 100)
|
|
||||||
#expect(result == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,31 +6,52 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import XCTest
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Testing
|
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct IntegrationTests {
|
final class IntegrationTests: XCTestCase {
|
||||||
|
|
||||||
private var helper = TestNetworkHelper()
|
var nodes: [String: MockBLEService] = [:]
|
||||||
|
var noiseManagers: [String: NoiseSessionManager] = [:]
|
||||||
|
private var mockKeychain: MockKeychain!
|
||||||
|
|
||||||
init() {
|
override func setUp() {
|
||||||
helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString))
|
super.setUp()
|
||||||
helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString))
|
// Use the in-memory test bus with autoFlood enabled to simulate
|
||||||
helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString))
|
// broadcast propagation across a larger mesh. Integration-only.
|
||||||
helper.createNode("David", peerID: PeerID(str: UUID().uuidString))
|
MockBLEService.resetTestBus()
|
||||||
|
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
|
||||||
|
|
||||||
@Test func fullMeshCommunication() async throws {
|
func testFullMeshCommunication() {
|
||||||
helper.connectFullMesh()
|
// Create full mesh - everyone connected to everyone
|
||||||
|
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] = [] }
|
|
||||||
|
|
||||||
for (receiverName, receiver) in helper.nodes {
|
// Track all receivers; parse sender name from message content "Hello from <Name>"
|
||||||
|
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") {
|
||||||
@@ -41,336 +62,370 @@ struct IntegrationTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (name, node) in helper.nodes {
|
// Each node sends a message
|
||||||
node.sendMessage("Hello from \(name)")
|
for (name, node) in nodes {
|
||||||
|
node.sendMessage("Hello from \(name)", mentions: [], to: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each sender should have reached all other nodes
|
// Wait and verify
|
||||||
for (sender, receivers) in messageMatrix {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
let expectedReceivers = Set(helper.nodes.keys.filter { $0 != sender })
|
// Each sender should have reached all other nodes
|
||||||
#expect(receivers == expectedReceivers, "\(sender) didn't reach all nodes")
|
for (sender, receivers) in messageMatrix {
|
||||||
}
|
let expectedReceivers = Set(self.nodes.keys.filter { $0 != sender })
|
||||||
}
|
XCTAssertEqual(receivers, expectedReceivers, "\(sender) didn't reach all nodes")
|
||||||
|
|
||||||
@Test func dynamicTopologyChanges() async throws {
|
|
||||||
// Start with Alice -> Bob -> Charlie
|
|
||||||
helper.connect("Alice", "Bob")
|
|
||||||
helper.connect("Bob", "Charlie")
|
|
||||||
|
|
||||||
try await confirmation("Topology changes handled") { receiveMessage in
|
|
||||||
var phase = 1
|
|
||||||
|
|
||||||
helper.nodes["Charlie"]!.messageDeliveryHandler = { message in
|
|
||||||
if phase == 1 && message.sender == "Alice" {
|
|
||||||
// Now change topology: disconnect Bob, connect Alice-Charlie
|
|
||||||
helper.disconnect("Alice", "Bob")
|
|
||||||
helper.disconnect("Bob", "Charlie")
|
|
||||||
helper.connect("Alice", "Charlie")
|
|
||||||
phase = 2
|
|
||||||
|
|
||||||
// Send another message
|
|
||||||
helper.nodes["Alice"]!.sendMessage("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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func networkPartitionRecovery() async throws {
|
func testDynamicTopologyChanges() {
|
||||||
// Create two partitions
|
// Start with Alice -> Bob -> Charlie
|
||||||
helper.connect("Alice", "Bob")
|
connect("Alice", "Bob")
|
||||||
helper.connect("Charlie", "David")
|
connect("Bob", "Charlie")
|
||||||
|
|
||||||
|
let expectation = XCTestExpectation(description: "Topology changes handled")
|
||||||
|
var phase = 1
|
||||||
|
|
||||||
|
// Phase 1: Test initial topology
|
||||||
|
nodes["Charlie"]!.messageDeliveryHandler = { message in
|
||||||
|
if phase == 1 && message.sender == "Alice" {
|
||||||
|
// Now change topology: disconnect Bob, connect Alice-Charlie
|
||||||
|
self.disconnect("Alice", "Bob")
|
||||||
|
self.disconnect("Bob", "Charlie")
|
||||||
|
self.connect("Alice", "Charlie")
|
||||||
|
phase = 2
|
||||||
|
|
||||||
|
// Send another message
|
||||||
|
self.nodes["Alice"]!.sendMessage("Direct message", mentions: [], to: nil)
|
||||||
|
} else if phase == 2 && message.content == "Direct message" {
|
||||||
|
expectation.fulfill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
// Create two partitions
|
||||||
|
connect("Alice", "Bob")
|
||||||
|
connect("Charlie", "David")
|
||||||
|
|
||||||
|
let expectation = XCTestExpectation(description: "Partitions merge and communicate")
|
||||||
let messagesBeforeMerge = 0
|
let messagesBeforeMerge = 0
|
||||||
var messagesAfterMerge = 0
|
var messagesAfterMerge = 0
|
||||||
|
|
||||||
try await confirmation("Partitions merge and communicate") { receiveMessage in
|
// Monitor cross-partition messages
|
||||||
// Monitor cross-partition messages
|
nodes["David"]!.messageDeliveryHandler = { message in
|
||||||
helper.nodes["David"]!.messageDeliveryHandler = { message in
|
if message.sender == "Alice" {
|
||||||
if message.sender == "Alice" {
|
messagesAfterMerge += 1
|
||||||
messagesAfterMerge += 1
|
if messagesAfterMerge == 1 {
|
||||||
if messagesAfterMerge == 1 {
|
expectation.fulfill()
|
||||||
receiveMessage()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to send across partition (should fail)
|
|
||||||
helper.nodes["Alice"]!.sendMessage("Before merge")
|
|
||||||
|
|
||||||
// Merge partitions after delay
|
|
||||||
try await sleep(0.05)
|
|
||||||
// Connect partitions
|
|
||||||
helper.connect("Bob", "Charlie")
|
|
||||||
|
|
||||||
// Enable relay
|
|
||||||
helper.setupRelay("Bob", nextHops: ["Charlie"])
|
|
||||||
helper.setupRelay("Charlie", nextHops: ["David"])
|
|
||||||
|
|
||||||
// Send message across merged network
|
|
||||||
helper.nodes["Alice"]!.sendMessage("After merge")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(messagesBeforeMerge == 0)
|
// Try to send across partition (should fail)
|
||||||
#expect(messagesAfterMerge == 1)
|
nodes["Alice"]!.sendMessage("Before merge", mentions: [], to: nil)
|
||||||
|
|
||||||
|
// Merge partitions after delay
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
// Connect partitions
|
||||||
|
self.connect("Bob", "Charlie")
|
||||||
|
|
||||||
|
// Enable relay
|
||||||
|
self.setupRelay("Bob", nextHops: ["Charlie"])
|
||||||
|
self.setupRelay("Charlie", nextHops: ["David"])
|
||||||
|
|
||||||
|
// Send message across merged network
|
||||||
|
self.nodes["Alice"]!.sendMessage("After merge", mentions: [], to: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||||
|
XCTAssertEqual(messagesBeforeMerge, 0)
|
||||||
|
XCTAssertEqual(messagesAfterMerge, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mixed Message Type Scenarios
|
// MARK: - Mixed Message Type Scenarios
|
||||||
|
|
||||||
@Test func mixedPublicPrivateMessages() async throws {
|
func testMixedPublicPrivateMessages() throws {
|
||||||
helper.connectFullMesh()
|
connectFullMesh()
|
||||||
|
|
||||||
|
let expectation = XCTestExpectation(description: "Mixed messages handled correctly")
|
||||||
var publicCount = 0
|
var publicCount = 0
|
||||||
var privateCount = 0
|
var privateCount = 0
|
||||||
|
|
||||||
await confirmation("Mixed messages handled correctly") { completion in
|
// Bob monitors messages
|
||||||
// Bob monitors messages
|
nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||||
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
if message.isPrivate && message.recipientNickname == "Bob" {
|
||||||
if message.isPrivate && message.recipientNickname == "Bob" {
|
privateCount += 1
|
||||||
privateCount += 1
|
} else if !message.isPrivate {
|
||||||
} else if !message.isPrivate {
|
publicCount += 1
|
||||||
publicCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if publicCount == 2 && privateCount == 1 {
|
|
||||||
completion()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alice sends mixed messages
|
if publicCount == 2 && privateCount == 1 {
|
||||||
helper.nodes["Alice"]!.sendMessage("Public 1")
|
expectation.fulfill()
|
||||||
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
}
|
||||||
helper.nodes["Alice"]!.sendMessage("Public 2")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(publicCount == 2)
|
// Alice sends mixed messages
|
||||||
#expect(privateCount == 1)
|
nodes["Alice"]!.sendMessage("Public 1", mentions: [], to: nil)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func encryptedAndUnencryptedMix() async throws {
|
func testEncryptedAndUnencryptedMix() throws {
|
||||||
helper.connect("Alice", "Bob")
|
connect("Alice", "Bob")
|
||||||
|
|
||||||
// Setup Noise session
|
// Setup Noise session
|
||||||
try helper.establishNoiseSession("Alice", "Bob")
|
try 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
|
||||||
|
|
||||||
try await confirmation("Both encrypted and plain messages work") { completion in
|
// Setup handlers
|
||||||
// Plain path: send public message and count at Bob
|
// Plain path: send public message and count at Bob
|
||||||
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||||
if message.content == "Plain message" {
|
if message.content == "Plain message" { plainCount += 1 }
|
||||||
plainCount += 1
|
if plainCount == 1 && encryptedCount == 1 { expectation.fulfill() }
|
||||||
}
|
|
||||||
if plainCount == 1 && encryptedCount == 1 {
|
|
||||||
completion()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encrypted path: use NoiseSessionManager explicitly
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@Test func messageDeliveryUnderChurn() async throws {
|
func testMessageDeliveryUnderChurn() {
|
||||||
// Start with stable network
|
// Start with stable network
|
||||||
helper.connectFullMesh()
|
connectFullMesh()
|
||||||
|
|
||||||
|
let expectation = XCTestExpectation(description: "Messages delivered despite churn")
|
||||||
|
var receivedMessages = Set<String>()
|
||||||
let totalMessages = 10
|
let totalMessages = 10
|
||||||
|
|
||||||
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
|
// David tracks received messages
|
||||||
// David tracks received messages
|
nodes["David"]!.messageDeliveryHandler = { message in
|
||||||
helper.nodes["David"]!.messageDeliveryHandler = { message in
|
receivedMessages.insert(message.content)
|
||||||
completion()
|
if receivedMessages.count == totalMessages {
|
||||||
}
|
expectation.fulfill()
|
||||||
|
|
||||||
// Send messages while churning network
|
|
||||||
for i in 0..<totalMessages {
|
|
||||||
helper.nodes["Alice"]!.sendMessage("Message \(i)")
|
|
||||||
|
|
||||||
// Simulate churn
|
|
||||||
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")
|
|
||||||
|
|
||||||
await confirmation("Delivery after reconnection") { delivered in
|
// Send messages while churning network
|
||||||
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
for i in 0..<totalMessages {
|
||||||
if message.content == "After reconnect" {
|
nodes["Alice"]!.sendMessage("Message \(i)", mentions: [], to: nil)
|
||||||
delivered()
|
|
||||||
|
// Simulate churn
|
||||||
|
if i % 3 == 0 {
|
||||||
|
// Disconnect and reconnect random connection
|
||||||
|
let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")]
|
||||||
|
let randomPair = pairs.randomElement()!
|
||||||
|
disconnect(randomPair.0, randomPair.1)
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
self.connect(randomPair.0, randomPair.1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate disconnect (out of range)
|
|
||||||
helper.disconnect("Alice", "Bob")
|
|
||||||
// Reconnect
|
|
||||||
helper.connect("Alice", "Bob")
|
|
||||||
|
|
||||||
// Send after reconnection
|
|
||||||
helper.nodes["Alice"]!.sendMessage("After reconnect")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
||||||
|
XCTAssertEqual(receivedMessages.count, totalMessages)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func encryptedMessageAfterPeerRestart() async throws {
|
func testPeerPresenceTrackingAndReconnection() {
|
||||||
helper.connect("Alice", "Bob")
|
// 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)
|
||||||
|
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 that encrypted messages work after one peer restarts
|
||||||
|
connect("Alice", "Bob")
|
||||||
do {
|
do {
|
||||||
try helper.establishNoiseSession("Alice", "Bob")
|
try establishNoiseSession("Alice", "Bob")
|
||||||
} catch {
|
} catch {
|
||||||
Issue.record("Failed to establish Noise session: \(error)")
|
XCTFail("Failed to establish Noise session: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange an encrypted message
|
// Exchange an encrypted message
|
||||||
await confirmation("First message received") { received in
|
let firstExpectation = XCTestExpectation(description: "First message received")
|
||||||
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||||
if message.content == "Before restart" && message.isPrivate {
|
if message.content == "Before restart" && message.isPrivate {
|
||||||
received()
|
firstExpectation.fulfill()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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()
|
||||||
helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain)
|
noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||||
|
|
||||||
// Re-establish Noise handshake explicitly via managers
|
// Re-establish Noise handshake explicitly via managers
|
||||||
do {
|
do {
|
||||||
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
|
let m1 = try noiseManagers["Bob"]!.initiateHandshake(with: TestConstants.testPeerID1)
|
||||||
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
|
let m2 = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m1)!
|
||||||
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
|
let m3 = try noiseManagers["Bob"]!.handleIncomingHandshake(from: TestConstants.testPeerID1, message: m2)!
|
||||||
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
|
_ = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m3)
|
||||||
} catch {
|
} catch {
|
||||||
Issue.record("Failed to re-establish Noise session after restart: \(error)")
|
XCTFail("Failed to re-establish Noise session after restart: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now messages should work again - simulate encrypted packet
|
// Now messages should work again
|
||||||
await confirmation("Message after restart received") { received in
|
let secondExpectation = XCTestExpectation(description: "Message after restart received")
|
||||||
helper.nodes["Alice"]!.messageDeliveryHandler = { message in
|
nodes["Alice"]!.messageDeliveryHandler = { message in
|
||||||
if message.content == "After restart success" && message.isPrivate {
|
if message.content == "After restart success" && message.isPrivate {
|
||||||
received()
|
secondExpectation.fulfill()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
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)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
XCTFail("Encryption after restart failed: \(error)")
|
||||||
|
}
|
||||||
|
wait(for: [secondExpectation], timeout: TestConstants.defaultTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func largeScaleNetwork() async throws {
|
func testLargeScaleNetwork() {
|
||||||
// Create larger network
|
// Create larger network
|
||||||
for i in 5...10 {
|
for i in 5...10 {
|
||||||
helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(i)"))
|
createNode("Node\(i)", peerID: "PEER\(i)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect in ring topology with cross-connections
|
// Connect in ring topology with cross-connections
|
||||||
let allNodes = Array(helper.nodes.keys).sorted()
|
let allNodes = Array(nodes.keys).sorted()
|
||||||
for i in 0..<allNodes.count {
|
for i in 0..<allNodes.count {
|
||||||
// Ring connection
|
// Ring connection
|
||||||
helper.connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
|
connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
|
||||||
|
|
||||||
// Cross connection
|
// Cross connection
|
||||||
if i + 3 < allNodes.count {
|
if i + 3 < allNodes.count {
|
||||||
helper.connect(allNodes[i], allNodes[i + 3])
|
connect(allNodes[i], allNodes[i + 3])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await confirmation("Large network handles broadcast", expectedCount: helper.nodes.count - 1) { nodeReaced in
|
let expectation = XCTestExpectation(description: "Large network handles broadcast")
|
||||||
// All nodes except Alice listen
|
var nodesReached = Set<String>()
|
||||||
for (name, node) in helper.nodes where name != "Alice" {
|
|
||||||
node.messageDeliveryHandler = { message in
|
// All nodes except Alice listen
|
||||||
if message.content == "Broadcast test" {
|
for (name, node) in nodes where name != "Alice" {
|
||||||
nodeReaced()
|
node.messageDeliveryHandler = { message in
|
||||||
|
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
|
||||||
|
|
||||||
@Test func highLoadScenario() async throws {
|
func testHighLoadScenario() {
|
||||||
helper.connectFullMesh()
|
connectFullMesh()
|
||||||
|
|
||||||
let messagesPerNode = 25
|
let messagesPerNode = 25
|
||||||
let expectedTotal = messagesPerNode * helper.nodes.count * (helper.nodes.count - 1)
|
let expectedTotal = messagesPerNode * nodes.count * (nodes.count - 1)
|
||||||
|
var receivedTotal = 0
|
||||||
|
let expectation = XCTestExpectation(description: "High load handled")
|
||||||
|
|
||||||
await confirmation("High load handled", expectedCount: expectedTotal) { received in
|
// Each node tracks messages
|
||||||
// Each node tracks messages
|
for (_, node) in nodes {
|
||||||
for (_, node) in helper.nodes {
|
node.messageDeliveryHandler = { _ in
|
||||||
node.messageDeliveryHandler = { _ in
|
receivedTotal += 1
|
||||||
received()
|
if receivedTotal >= (expectedTotal - 2) {
|
||||||
|
expectation.fulfill()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All nodes send many messages simultaneously
|
|
||||||
await withTaskGroup(of: Void.self) { group in
|
|
||||||
for (name, node) in helper.nodes {
|
|
||||||
group.addTask {
|
|
||||||
for i in 0..<messagesPerNode {
|
|
||||||
node.sendMessage("\(name) message \(i)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await group.waitForAll()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All nodes send many messages simultaneously
|
||||||
|
DispatchQueue.concurrentPerform(iterations: nodes.count) { index in
|
||||||
|
let nodeName = Array(nodes.keys).sorted()[index]
|
||||||
|
for i in 0..<messagesPerNode {
|
||||||
|
nodes[nodeName]!.sendMessage("\(nodeName) message \(i)", mentions: [], to: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
||||||
|
XCTAssertGreaterThanOrEqual(receivedTotal, expectedTotal - 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func mixedTrafficPatterns() async throws {
|
func testMixedTrafficPatterns() {
|
||||||
helper.connectFullMesh()
|
connectFullMesh()
|
||||||
|
|
||||||
|
let expectation = XCTestExpectation(description: "Mixed traffic handled")
|
||||||
var metrics = [
|
var metrics = [
|
||||||
"public": 0,
|
"public": 0,
|
||||||
"private": 0,
|
"private": 0,
|
||||||
@@ -379,7 +434,7 @@ struct IntegrationTests {
|
|||||||
]
|
]
|
||||||
|
|
||||||
// Setup complex handlers
|
// Setup complex handlers
|
||||||
for (name, node) in helper.nodes {
|
for (name, node) in nodes {
|
||||||
node.messageDeliveryHandler = { message in
|
node.messageDeliveryHandler = { message in
|
||||||
if message.isPrivate {
|
if message.isPrivate {
|
||||||
metrics["private"]! += 1
|
metrics["private"]! += 1
|
||||||
@@ -398,119 +453,222 @@ struct IntegrationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate mixed traffic
|
// Generate mixed traffic
|
||||||
helper.nodes["Alice"]!.sendMessage("Public broadcast")
|
nodes["Alice"]!.sendMessage("Public broadcast", mentions: [], to: nil)
|
||||||
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
||||||
helper.nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"])
|
nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"], to: nil)
|
||||||
|
|
||||||
// Disconnect to force relay
|
// Disconnect to force relay
|
||||||
helper.disconnect("Alice", "David")
|
disconnect("Alice", "David")
|
||||||
helper.nodes["Alice"]!.sendMessage("Needs relay to David")
|
nodes["Alice"]!.sendMessage("Needs relay to David", mentions: [], to: nil)
|
||||||
|
|
||||||
#expect(metrics["public", default: 0] > 0)
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
#expect(metrics["private", default: 0] > 0)
|
XCTAssertGreaterThan(metrics["public"]!, 0)
|
||||||
#expect(metrics["mentions", default: 0] > 0)
|
XCTAssertGreaterThan(metrics["private"]!, 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.
|
||||||
@Test func rehandshakeAfterDecryptionFailure() throws {
|
func testRehandshakeAfterDecryptionFailure() throws {
|
||||||
// Alice <-> Bob connected
|
// Alice <-> Bob connected
|
||||||
helper.connect("Alice", "Bob")
|
connect("Alice", "Bob")
|
||||||
|
|
||||||
// Establish initial Noise session
|
// Establish initial Noise session
|
||||||
try helper.establishNoiseSession("Alice", "Bob")
|
try establishNoiseSession("Alice", "Bob")
|
||||||
|
|
||||||
guard let aliceManager = helper.noiseManagers["Alice"],
|
guard let aliceManager = noiseManagers["Alice"],
|
||||||
let bobManager = helper.noiseManagers["Bob"],
|
let bobManager = noiseManagers["Bob"],
|
||||||
let alicePeerID = helper.nodes["Alice"]?.peerID,
|
let alicePeerID = nodes["Alice"]?.peerID,
|
||||||
let bobPeerID = helper.nodes["Bob"]?.peerID
|
let bobPeerID = nodes["Bob"]?.peerID else {
|
||||||
else {
|
return XCTFail("Missing managers or peer IDs")
|
||||||
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)
|
||||||
#expect(decrypted1 == plaintext1)
|
XCTAssertEqual(decrypted1, plaintext1)
|
||||||
|
|
||||||
// Simulate decryption failure by corrupting ciphertext
|
// Simulate decryption failure by corrupting ciphertext
|
||||||
let corrupted = encrypted1.prefix(15)
|
var corrupted = encrypted1
|
||||||
#expect(throws: NoiseError.invalidCiphertext) {
|
if !corrupted.isEmpty { corrupted[corrupted.count - 1] ^= 0xFF }
|
||||||
|
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 helper.establishNoiseSession("Bob", "Alice")
|
try 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)
|
||||||
#expect(decrypted2 == plaintext2)
|
XCTAssertEqual(decrypted2, plaintext2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test func endToEndSecurityScenario() async throws {
|
func testEndToEndSecurityScenario() throws {
|
||||||
helper.connect("Alice", "Bob")
|
connect("Alice", "Bob")
|
||||||
helper.connect("Bob", "Charlie") // Charlie will try to eavesdrop
|
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 helper.establishNoiseSession("Alice", "Bob")
|
try establishNoiseSession("Alice", "Bob")
|
||||||
|
|
||||||
await confirmation("Secure communication maintained", expectedCount: 2) { receivedPacket in
|
let expectation = XCTestExpectation(description: "Secure communication maintained")
|
||||||
|
var bobDecrypted = false
|
||||||
// Setup encryption at Alice
|
var charlieIntercepted = false
|
||||||
helper.nodes["Alice"]!.packetDeliveryHandler = { packet in
|
|
||||||
if packet.type == 0x01,
|
// Setup encryption at Alice
|
||||||
let message = BitchatMessage(packet.payload),
|
nodes["Alice"]!.packetDeliveryHandler = { packet in
|
||||||
message.isPrivate && packet.recipientID != nil {
|
if packet.type == 0x01,
|
||||||
// Encrypt private messages
|
let message = BitchatMessage(packet.payload),
|
||||||
if let encrypted = try? helper.noiseManagers["Alice"]!.encrypt(packet.payload, for: helper.nodes["Bob"]!.peerID) {
|
message.isPrivate && packet.recipientID != nil {
|
||||||
let encPacket = BitchatPacket(
|
// Encrypt private messages
|
||||||
type: 0x02,
|
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) {
|
||||||
senderID: packet.senderID,
|
let encPacket = BitchatPacket(
|
||||||
recipientID: packet.recipientID,
|
type: 0x02,
|
||||||
timestamp: packet.timestamp,
|
senderID: packet.senderID,
|
||||||
payload: encrypted,
|
recipientID: packet.recipientID,
|
||||||
signature: packet.signature,
|
timestamp: packet.timestamp,
|
||||||
ttl: packet.ttl
|
payload: encrypted,
|
||||||
)
|
signature: packet.signature,
|
||||||
helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
ttl: packet.ttl
|
||||||
}
|
)
|
||||||
|
self.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Bob can decrypt
|
|
||||||
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
|
// Bob can decrypt
|
||||||
if packet.type == 0x02 {
|
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||||
receivedPacket()
|
if packet.type == 0x02 {
|
||||||
if let decrypted = try? helper.noiseManagers["Bob"]!.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID) {
|
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1),
|
||||||
#expect(BitchatMessage(decrypted)?.content == "Secret message")
|
let message = BitchatMessage(decrypted) {
|
||||||
} else {
|
bobDecrypted = message.content == "Secret message"
|
||||||
Issue.record("Bob was unable to decrypt the message")
|
expectation.fulfill()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relay encrypted packet to Charlie
|
// Relay encrypted packet to Charlie
|
||||||
helper.nodes["Charlie"]!.simulateIncomingPacket(packet)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Charlie cannot decrypt
|
|
||||||
helper.nodes["Charlie"]!.packetDeliveryHandler = { packet in
|
// Send encrypted private message
|
||||||
if packet.type == 0x02 {
|
nodes["Alice"]!.sendPrivateMessage("Secret message", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
||||||
receivedPacket()
|
|
||||||
#expect(throws: NoiseSessionError.sessionNotFound, "Charlie should not be able to decrypt") {
|
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||||
_ = try helper.noiseManagers["Charlie"]?.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID)
|
XCTAssertTrue(bobDecrypted)
|
||||||
}
|
XCTAssertTrue(charlieIntercepted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
|
private func createNode(_ name: String, peerID: PeerID) {
|
||||||
|
let node = MockBLEService()
|
||||||
|
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])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send encrypted private message
|
|
||||||
helper.nodes["Alice"]!.sendPrivateMessage("Secret message", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
//
|
|
||||||
// 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,9 +1,8 @@
|
|||||||
import Testing
|
import XCTest
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct LocationChannelsTests {
|
final class LocationChannelsTests: XCTestCase {
|
||||||
@Test func geohashEncoderPrecisionMapping() {
|
func testGeohashEncoderPrecisionMapping() {
|
||||||
// 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
|
||||||
@@ -13,35 +12,35 @@ struct LocationChannelsTests {
|
|||||||
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)
|
||||||
|
|
||||||
#expect(block.count == 7)
|
XCTAssertEqual(block.count, 7)
|
||||||
#expect(neighborhood.count == 6)
|
XCTAssertEqual(neighborhood.count, 6)
|
||||||
#expect(city.count == 5)
|
XCTAssertEqual(city.count, 5)
|
||||||
#expect(region.count == 4)
|
XCTAssertEqual(region.count, 4)
|
||||||
#expect(country.count == 2)
|
XCTAssertEqual(country.count, 2)
|
||||||
|
|
||||||
// All prefixes must match progressively
|
// All prefixes must match progressively
|
||||||
#expect(block.hasPrefix(neighborhood))
|
XCTAssertTrue(block.hasPrefix(neighborhood))
|
||||||
#expect(neighborhood.hasPrefix(city))
|
XCTAssertTrue(neighborhood.hasPrefix(city))
|
||||||
#expect(city.hasPrefix(region))
|
XCTAssertTrue(city.hasPrefix(region))
|
||||||
#expect(region.hasPrefix(country))
|
XCTAssertTrue(region.hasPrefix(country))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func nostrGeohashFilterEncoding() throws {
|
func testNostrGeohashFilterEncoding() 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]
|
||||||
#expect(json.contains("20000"))
|
XCTAssertTrue(json.contains("20000"))
|
||||||
#expect(json.contains("\"#g\":[\"\(gh)\"]"))
|
XCTAssertTrue(json.contains("\"#g\":[\"\(gh)\"]"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func perGeohashIdentityDeterministic() throws {
|
func testPerGeohashIdentityDeterministic() throws {
|
||||||
// Derive twice for same geohash; should be identical
|
// Derive twice for same geohash; should be identical
|
||||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||||
let gh = "u4pruy"
|
let gh = "u4pruy"
|
||||||
let id1 = try idBridge.deriveIdentity(forGeohash: gh)
|
let id1 = try idBridge.deriveIdentity(forGeohash: gh)
|
||||||
let id2 = try idBridge.deriveIdentity(forGeohash: gh)
|
let id2 = try idBridge.deriveIdentity(forGeohash: gh)
|
||||||
#expect(id1.publicKeyHex == id2.publicKeyHex)
|
XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import Testing
|
import XCTest
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct LocationNotesManagerTests {
|
final class LocationNotesManagerTests: XCTestCase {
|
||||||
// func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
// func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
||||||
// var subscribeCalled = false
|
// var subscribeCalled = false
|
||||||
// let deps = LocationNotesDependencies(
|
// let deps = LocationNotesDependencies(
|
||||||
@@ -48,15 +47,15 @@ struct LocationNotesManagerTests {
|
|||||||
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
||||||
// }
|
// }
|
||||||
|
|
||||||
@Test func subscribeUsesGeoRelaysAndAppendsNotes() {
|
func testSubscribeUsesGeoRelaysAndAppendsNotes() {
|
||||||
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
|
||||||
#expect(filter.kinds == [1])
|
XCTAssertEqual(filter.kinds, [1])
|
||||||
#expect(!id.isEmpty)
|
XCTAssertFalse(id.isEmpty)
|
||||||
relaysCaptured = relays
|
relaysCaptured = relays
|
||||||
storedHandler = handler
|
storedHandler = handler
|
||||||
storedEOSE = eose
|
storedEOSE = eose
|
||||||
@@ -68,8 +67,8 @@ struct LocationNotesManagerTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||||
#expect(relaysCaptured == ["wss://relay.one"])
|
XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
|
||||||
#expect(manager.state == .loading)
|
XCTAssertEqual(manager.state, .loading)
|
||||||
|
|
||||||
var event = NostrEvent(
|
var event = NostrEvent(
|
||||||
pubkey: "pub",
|
pubkey: "pub",
|
||||||
@@ -82,12 +81,70 @@ struct LocationNotesManagerTests {
|
|||||||
storedHandler?(event)
|
storedHandler?(event)
|
||||||
storedEOSE?()
|
storedEOSE?()
|
||||||
|
|
||||||
#expect(manager.state == .ready)
|
XCTAssertEqual(manager.state, .ready)
|
||||||
#expect(manager.notes.count == 1)
|
XCTAssertEqual(manager.notes.count, 1)
|
||||||
#expect(manager.notes.first?.content == "hi")
|
XCTAssertEqual(manager.notes.first?.content, "hi")
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum TestError: Error {
|
private enum TestError: Error {
|
||||||
case shouldNotDerive
|
case shouldNotDerive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationNotesCounterTests: XCTestCase {
|
||||||
|
func testSubscribeWithoutRelaysMarksUnavailable() {
|
||||||
|
var subscribeCalled = false
|
||||||
|
let deps = LocationNotesCounterDependencies(
|
||||||
|
relayLookup: { _, _ in [] },
|
||||||
|
subscribe: { _, _, _, _, _ in subscribeCalled = true },
|
||||||
|
unsubscribe: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
let counter = LocationNotesCounter(testDependencies: deps)
|
||||||
|
counter.subscribe(geohash: "u4pruydq")
|
||||||
|
|
||||||
|
XCTAssertFalse(subscribeCalled)
|
||||||
|
XCTAssertFalse(counter.relayAvailable)
|
||||||
|
XCTAssertTrue(counter.initialLoadComplete)
|
||||||
|
XCTAssertEqual(counter.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubscribeCountsUniqueNotes() {
|
||||||
|
var storedHandler: ((NostrEvent) -> Void)?
|
||||||
|
var storedEOSE: (() -> Void)?
|
||||||
|
let deps = LocationNotesCounterDependencies(
|
||||||
|
relayLookup: { _, _ in ["wss://relay.geo"] },
|
||||||
|
subscribe: { filter, id, relays, handler, eose in
|
||||||
|
XCTAssertEqual(relays, ["wss://relay.geo"])
|
||||||
|
XCTAssertEqual(filter.kinds, [1])
|
||||||
|
XCTAssertFalse(id.isEmpty)
|
||||||
|
storedHandler = handler
|
||||||
|
storedEOSE = eose
|
||||||
|
},
|
||||||
|
unsubscribe: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
let counter = LocationNotesCounter(testDependencies: deps)
|
||||||
|
counter.subscribe(geohash: "u4pruydq")
|
||||||
|
|
||||||
|
var first = NostrEvent(
|
||||||
|
pubkey: "pub",
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .textNote,
|
||||||
|
tags: [["g", "u4pruydq"]],
|
||||||
|
content: "a"
|
||||||
|
)
|
||||||
|
first.id = "eventA"
|
||||||
|
storedHandler?(first)
|
||||||
|
|
||||||
|
let duplicate = first
|
||||||
|
storedHandler?(duplicate)
|
||||||
|
|
||||||
|
storedEOSE?()
|
||||||
|
|
||||||
|
XCTAssertTrue(counter.relayAvailable)
|
||||||
|
XCTAssertEqual(counter.count, 1)
|
||||||
|
XCTAssertTrue(counter.initialLoadComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,470 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageDeduplicationServiceTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for MessageDeduplicationService, LRUDeduplicationCache, and ContentNormalizer.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - LRU Deduplication Cache Tests
|
|
||||||
|
|
||||||
struct LRUDeduplicationCacheTests {
|
|
||||||
|
|
||||||
// MARK: - Basic Operations
|
|
||||||
|
|
||||||
@Test func emptyCache_containsReturnsFalse() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
|
||||||
#expect(!cache.contains("key"))
|
|
||||||
#expect(cache.value(for: "key") == nil)
|
|
||||||
#expect(cache.count == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func record_addsEntry() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
|
||||||
cache.record("key1", value: 42)
|
|
||||||
|
|
||||||
#expect(cache.contains("key1"))
|
|
||||||
#expect(cache.value(for: "key1") == 42)
|
|
||||||
#expect(cache.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func record_updatesExistingEntry() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
|
||||||
cache.record("key1", value: 42)
|
|
||||||
cache.record("key1", value: 100)
|
|
||||||
|
|
||||||
#expect(cache.value(for: "key1") == 100)
|
|
||||||
#expect(cache.count == 1) // Should not increase count
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func record_multipleEntries() {
|
|
||||||
let cache = LRUDeduplicationCache<String>(capacity: 10)
|
|
||||||
cache.record("a", value: "alpha")
|
|
||||||
cache.record("b", value: "beta")
|
|
||||||
cache.record("c", value: "gamma")
|
|
||||||
|
|
||||||
#expect(cache.count == 3)
|
|
||||||
#expect(cache.value(for: "a") == "alpha")
|
|
||||||
#expect(cache.value(for: "b") == "beta")
|
|
||||||
#expect(cache.value(for: "c") == "gamma")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func remove_removesEntry() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
|
||||||
cache.record("key1", value: 42)
|
|
||||||
cache.record("key2", value: 100)
|
|
||||||
|
|
||||||
cache.remove("key1")
|
|
||||||
|
|
||||||
#expect(!cache.contains("key1"))
|
|
||||||
#expect(cache.contains("key2"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func clear_removesAllEntries() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
|
||||||
cache.record("a", value: 1)
|
|
||||||
cache.record("b", value: 2)
|
|
||||||
cache.record("c", value: 3)
|
|
||||||
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
#expect(cache.count == 0)
|
|
||||||
#expect(!cache.contains("a"))
|
|
||||||
#expect(!cache.contains("b"))
|
|
||||||
#expect(!cache.contains("c"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Eviction Tests
|
|
||||||
|
|
||||||
@Test func eviction_removesOldestWhenOverCapacity() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 3)
|
|
||||||
cache.record("a", value: 1)
|
|
||||||
cache.record("b", value: 2)
|
|
||||||
cache.record("c", value: 3)
|
|
||||||
cache.record("d", value: 4) // Should evict "a"
|
|
||||||
|
|
||||||
#expect(cache.count == 3)
|
|
||||||
#expect(!cache.contains("a")) // Evicted
|
|
||||||
#expect(cache.contains("b"))
|
|
||||||
#expect(cache.contains("c"))
|
|
||||||
#expect(cache.contains("d"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func eviction_maintainsCapacity() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 2)
|
|
||||||
|
|
||||||
for i in 0..<100 {
|
|
||||||
cache.record("key\(i)", value: i)
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(cache.count == 2)
|
|
||||||
// Most recent entries should be present
|
|
||||||
#expect(cache.contains("key99"))
|
|
||||||
#expect(cache.contains("key98"))
|
|
||||||
// Older entries should be evicted
|
|
||||||
#expect(!cache.contains("key0"))
|
|
||||||
#expect(!cache.contains("key50"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func eviction_capacityOfOne() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 1)
|
|
||||||
cache.record("a", value: 1)
|
|
||||||
cache.record("b", value: 2)
|
|
||||||
|
|
||||||
#expect(cache.count == 1)
|
|
||||||
#expect(!cache.contains("a"))
|
|
||||||
#expect(cache.contains("b"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func eviction_skipsRemovedKeys() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 3)
|
|
||||||
cache.record("a", value: 1)
|
|
||||||
cache.record("b", value: 2)
|
|
||||||
cache.record("c", value: 3)
|
|
||||||
|
|
||||||
// Remove "a" manually
|
|
||||||
cache.remove("a")
|
|
||||||
|
|
||||||
// Add new entry - should evict "b" (next oldest still in map)
|
|
||||||
cache.record("d", value: 4)
|
|
||||||
|
|
||||||
// Cache should have b, c, d (a was removed)
|
|
||||||
// Actually after eviction it should have c, d and maybe b depending on implementation
|
|
||||||
#expect(!cache.contains("a"))
|
|
||||||
#expect(cache.count <= 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Edge Cases
|
|
||||||
|
|
||||||
@Test func emptyKey_works() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
|
||||||
cache.record("", value: 42)
|
|
||||||
|
|
||||||
#expect(cache.contains(""))
|
|
||||||
#expect(cache.value(for: "") == 42)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func largeCapacity_works() {
|
|
||||||
let cache = LRUDeduplicationCache<Int>(capacity: 10000)
|
|
||||||
|
|
||||||
for i in 0..<5000 {
|
|
||||||
cache.record("key\(i)", value: i)
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(cache.count == 5000)
|
|
||||||
#expect(cache.contains("key0"))
|
|
||||||
#expect(cache.contains("key4999"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Content Normalizer Tests
|
|
||||||
|
|
||||||
struct ContentNormalizerTests {
|
|
||||||
|
|
||||||
@Test func normalizedKey_basicContent() {
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("Hello World")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey("Hello World")
|
|
||||||
#expect(key1 == key2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_caseInsensitive() {
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("Hello World")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey("hello world")
|
|
||||||
let key3 = ContentNormalizer.normalizedKey("HELLO WORLD")
|
|
||||||
#expect(key1 == key2)
|
|
||||||
#expect(key2 == key3)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_whitespaceCollapsed() {
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("Hello World")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey("Hello World")
|
|
||||||
let key3 = ContentNormalizer.normalizedKey("Hello\t\nWorld")
|
|
||||||
#expect(key1 == key2)
|
|
||||||
#expect(key2 == key3)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_trimmed() {
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("Hello")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey(" Hello ")
|
|
||||||
let key3 = ContentNormalizer.normalizedKey("\nHello\n")
|
|
||||||
#expect(key1 == key2)
|
|
||||||
#expect(key2 == key3)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_urlQueryStripped() {
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("Check https://example.com/page")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey("Check https://example.com/page?query=value")
|
|
||||||
let key3 = ContentNormalizer.normalizedKey("Check https://example.com/page#anchor")
|
|
||||||
#expect(key1 == key2)
|
|
||||||
#expect(key2 == key3)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_httpAndHttpsDistinct() {
|
|
||||||
// URL scheme is preserved
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("http://example.com/page")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey("https://example.com/page")
|
|
||||||
#expect(key1 != key2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_differentContent() {
|
|
||||||
let key1 = ContentNormalizer.normalizedKey("Hello")
|
|
||||||
let key2 = ContentNormalizer.normalizedKey("Goodbye")
|
|
||||||
#expect(key1 != key2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_returnsHashFormat() {
|
|
||||||
let key = ContentNormalizer.normalizedKey("Test content")
|
|
||||||
#expect(key.hasPrefix("h:"))
|
|
||||||
#expect(key.count == 18) // "h:" + 16 hex chars
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_emptyContent() {
|
|
||||||
let key = ContentNormalizer.normalizedKey("")
|
|
||||||
#expect(key.hasPrefix("h:"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_longContentTruncated() {
|
|
||||||
let longContent = String(repeating: "a", count: 10000)
|
|
||||||
let key1 = ContentNormalizer.normalizedKey(longContent)
|
|
||||||
let key2 = ContentNormalizer.normalizedKey(longContent + "extra")
|
|
||||||
|
|
||||||
// Both should be the same since content is truncated before hashing
|
|
||||||
#expect(key1 == key2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_prefixLengthRespected() {
|
|
||||||
let content = "Short"
|
|
||||||
let key1 = ContentNormalizer.normalizedKey(content, prefixLength: 3)
|
|
||||||
let key2 = ContentNormalizer.normalizedKey(content, prefixLength: 100)
|
|
||||||
|
|
||||||
// Different prefix lengths may produce different keys
|
|
||||||
// "sho" vs "short"
|
|
||||||
#expect(key1 != key2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_urlsInMiddleOfContent() {
|
|
||||||
let content1 = "Check out https://example.com/path?query=1 for more info"
|
|
||||||
let content2 = "Check out https://example.com/path for more info"
|
|
||||||
let key1 = ContentNormalizer.normalizedKey(content1)
|
|
||||||
let key2 = ContentNormalizer.normalizedKey(content2)
|
|
||||||
#expect(key1 == key2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedKey_multipleUrls() {
|
|
||||||
let content1 = "Links: https://a.com?x=1 and http://b.com#y"
|
|
||||||
let content2 = "Links: https://a.com and http://b.com"
|
|
||||||
let key1 = ContentNormalizer.normalizedKey(content1)
|
|
||||||
let key2 = ContentNormalizer.normalizedKey(content2)
|
|
||||||
#expect(key1 == key2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Deduplication Service Tests
|
|
||||||
|
|
||||||
struct MessageDeduplicationServiceTests {
|
|
||||||
|
|
||||||
// MARK: - Content Deduplication
|
|
||||||
|
|
||||||
@Test func recordContent_storesTimestamp() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
service.recordContent("Hello World", timestamp: now)
|
|
||||||
|
|
||||||
let retrieved = service.contentTimestamp(for: "Hello World")
|
|
||||||
#expect(retrieved == now)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordContent_updatesTimestamp() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let early = Date(timeIntervalSince1970: 1000)
|
|
||||||
let late = Date(timeIntervalSince1970: 2000)
|
|
||||||
|
|
||||||
service.recordContent("Hello World", timestamp: early)
|
|
||||||
service.recordContent("Hello World", timestamp: late)
|
|
||||||
|
|
||||||
let retrieved = service.contentTimestamp(for: "Hello World")
|
|
||||||
#expect(retrieved == late)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func contentTimestamp_nilForUnseen() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
|
|
||||||
let timestamp = service.contentTimestamp(for: "Never seen")
|
|
||||||
#expect(timestamp == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func recordContentKey_directKeyAccess() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let now = Date()
|
|
||||||
let key = service.normalizedContentKey("Test")
|
|
||||||
|
|
||||||
service.recordContentKey(key, timestamp: now)
|
|
||||||
|
|
||||||
#expect(service.contentTimestamp(forKey: key) == now)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func normalizedContentKey_consistentWithNormalizer() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let content = "Hello World"
|
|
||||||
|
|
||||||
let serviceKey = service.normalizedContentKey(content)
|
|
||||||
let normalizerKey = ContentNormalizer.normalizedKey(content)
|
|
||||||
|
|
||||||
#expect(serviceKey == normalizerKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr Event Deduplication
|
|
||||||
|
|
||||||
@Test func recordNostrEvent_marksAsProcessed() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
|
|
||||||
#expect(!service.hasProcessedNostrEvent("event123"))
|
|
||||||
|
|
||||||
service.recordNostrEvent("event123")
|
|
||||||
|
|
||||||
#expect(service.hasProcessedNostrEvent("event123"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func hasProcessedNostrEvent_falseForUnseen() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
|
|
||||||
#expect(!service.hasProcessedNostrEvent("never-seen"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func nostrEvent_multipleEvents() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
|
|
||||||
service.recordNostrEvent("event1")
|
|
||||||
service.recordNostrEvent("event2")
|
|
||||||
service.recordNostrEvent("event3")
|
|
||||||
|
|
||||||
#expect(service.hasProcessedNostrEvent("event1"))
|
|
||||||
#expect(service.hasProcessedNostrEvent("event2"))
|
|
||||||
#expect(service.hasProcessedNostrEvent("event3"))
|
|
||||||
#expect(!service.hasProcessedNostrEvent("event4"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr ACK Deduplication
|
|
||||||
|
|
||||||
@Test func recordNostrAck_marksAsProcessed() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let ackKey = MessageDeduplicationService.ackKey(
|
|
||||||
messageId: "msg123",
|
|
||||||
ackType: "delivered",
|
|
||||||
senderPubkey: "pubkey456"
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(!service.hasProcessedNostrAck(ackKey))
|
|
||||||
|
|
||||||
service.recordNostrAck(ackKey)
|
|
||||||
|
|
||||||
#expect(service.hasProcessedNostrAck(ackKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func ackKey_format() {
|
|
||||||
let key = MessageDeduplicationService.ackKey(
|
|
||||||
messageId: "msg",
|
|
||||||
ackType: "read",
|
|
||||||
senderPubkey: "pub"
|
|
||||||
)
|
|
||||||
#expect(key == "msg:read:pub")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func ackKey_differentComponents() {
|
|
||||||
let key1 = MessageDeduplicationService.ackKey(messageId: "a", ackType: "delivered", senderPubkey: "x")
|
|
||||||
let key2 = MessageDeduplicationService.ackKey(messageId: "a", ackType: "read", senderPubkey: "x")
|
|
||||||
let key3 = MessageDeduplicationService.ackKey(messageId: "b", ackType: "delivered", senderPubkey: "x")
|
|
||||||
|
|
||||||
#expect(key1 != key2) // Different ackType
|
|
||||||
#expect(key1 != key3) // Different messageId
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Clear Operations
|
|
||||||
|
|
||||||
@Test func clearAll_clearsEverything() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
service.recordContent("Hello", timestamp: now)
|
|
||||||
service.recordNostrEvent("event1")
|
|
||||||
service.recordNostrAck("ack1")
|
|
||||||
|
|
||||||
service.clearAll()
|
|
||||||
|
|
||||||
#expect(service.contentTimestamp(for: "Hello") == nil)
|
|
||||||
#expect(!service.hasProcessedNostrEvent("event1"))
|
|
||||||
#expect(!service.hasProcessedNostrAck("ack1"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func clearNostrCaches_preservesContent() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
service.recordContent("Hello", timestamp: now)
|
|
||||||
service.recordNostrEvent("event1")
|
|
||||||
service.recordNostrAck("ack1")
|
|
||||||
|
|
||||||
service.clearNostrCaches()
|
|
||||||
|
|
||||||
#expect(service.contentTimestamp(for: "Hello") == now) // Preserved
|
|
||||||
#expect(!service.hasProcessedNostrEvent("event1")) // Cleared
|
|
||||||
#expect(!service.hasProcessedNostrAck("ack1")) // Cleared
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Capacity Tests
|
|
||||||
|
|
||||||
@Test func contentCache_respectsCapacity() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 3, nostrEventCapacity: 100)
|
|
||||||
|
|
||||||
service.recordContent("a", timestamp: Date())
|
|
||||||
service.recordContent("b", timestamp: Date())
|
|
||||||
service.recordContent("c", timestamp: Date())
|
|
||||||
service.recordContent("d", timestamp: Date())
|
|
||||||
|
|
||||||
// "a" should have been evicted
|
|
||||||
#expect(service.contentTimestamp(for: "a") == nil)
|
|
||||||
#expect(service.contentTimestamp(for: "d") != nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func nostrEventCache_respectsCapacity() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 3)
|
|
||||||
|
|
||||||
service.recordNostrEvent("e1")
|
|
||||||
service.recordNostrEvent("e2")
|
|
||||||
service.recordNostrEvent("e3")
|
|
||||||
service.recordNostrEvent("e4")
|
|
||||||
|
|
||||||
// "e1" should have been evicted
|
|
||||||
#expect(!service.hasProcessedNostrEvent("e1"))
|
|
||||||
#expect(service.hasProcessedNostrEvent("e4"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Integration Tests
|
|
||||||
|
|
||||||
@Test func realWorldDeduplication_similarMessages() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
// Record original message
|
|
||||||
service.recordContent("Check out https://example.com/page?ref=abc", timestamp: now)
|
|
||||||
|
|
||||||
// Same URL with different query params should match
|
|
||||||
let timestamp = service.contentTimestamp(for: "Check out https://example.com/page?ref=xyz")
|
|
||||||
#expect(timestamp == now)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func realWorldDeduplication_caseVariations() {
|
|
||||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
service.recordContent("HELLO WORLD", timestamp: now)
|
|
||||||
|
|
||||||
#expect(service.contentTimestamp(for: "hello world") == now)
|
|
||||||
#expect(service.contentTimestamp(for: "Hello World") == now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageFormattingEngineTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Tests for MessageFormattingEngine regex patterns and utility functions.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct MessageFormattingEngineTests {
|
|
||||||
|
|
||||||
// MARK: - Mention Extraction Tests
|
|
||||||
|
|
||||||
@Test func extractMentions_singleMention() {
|
|
||||||
let content = "Hello @alice how are you?"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
#expect(mentions == ["alice"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func extractMentions_multipleMentions() {
|
|
||||||
let content = "@alice and @bob are chatting with @charlie"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
#expect(mentions.count == 3)
|
|
||||||
#expect(mentions.contains("alice"))
|
|
||||||
#expect(mentions.contains("bob"))
|
|
||||||
#expect(mentions.contains("charlie"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func extractMentions_mentionWithSuffix() {
|
|
||||||
let content = "Hey @alice#a1b2 check this out"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
#expect(mentions == ["alice#a1b2"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func extractMentions_noMentions() {
|
|
||||||
let content = "Just a regular message with no mentions"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
#expect(mentions.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func extractMentions_unicodeNickname() {
|
|
||||||
let content = "Hello @日本語 and @émile"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
#expect(mentions.count == 2)
|
|
||||||
#expect(mentions.contains("日本語"))
|
|
||||||
#expect(mentions.contains("émile"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func extractMentions_mentionWithUnderscore() {
|
|
||||||
let content = "Thanks @user_name_123"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
#expect(mentions == ["user_name_123"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func extractMentions_emailNotCaptured() {
|
|
||||||
// Email addresses should not be captured as mentions
|
|
||||||
let content = "Contact me at test@example.com"
|
|
||||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
|
||||||
// The regex will capture "example" after @ in email - this is expected behavior
|
|
||||||
// as the regex doesn't distinguish email addresses
|
|
||||||
#expect(mentions.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Cashu Token Detection Tests
|
|
||||||
|
|
||||||
@Test func containsCashuToken_validTokenA() {
|
|
||||||
let content = "Here's a token: cashuAeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i"
|
|
||||||
#expect(MessageFormattingEngine.containsCashuToken(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func containsCashuToken_validTokenB() {
|
|
||||||
let content = "Payment: cashuBeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i"
|
|
||||||
#expect(MessageFormattingEngine.containsCashuToken(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func containsCashuToken_noToken() {
|
|
||||||
let content = "Just a regular message about cashews"
|
|
||||||
#expect(!MessageFormattingEngine.containsCashuToken(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func containsCashuToken_tooShort() {
|
|
||||||
let content = "Invalid: cashuAshort"
|
|
||||||
#expect(!MessageFormattingEngine.containsCashuToken(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Regex Pattern Tests
|
|
||||||
|
|
||||||
@Test func hashtagPattern_standaloneHashtag() {
|
|
||||||
let content = "#bitcoin is great"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func hashtagPattern_multipleHashtags() {
|
|
||||||
let content = "#bitcoin #lightning #nostr"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func hashtagPattern_hashInMiddleOfWord() {
|
|
||||||
let content = "test#notahashtag"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)
|
|
||||||
// This will match because the regex doesn't check for word boundaries
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func bolt11Pattern_mainnet() {
|
|
||||||
let content = "Pay this: lnbc10u1pjexampleinvoice0000000000000000000000000000000000000000000"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func bolt11Pattern_testnet() {
|
|
||||||
let content = "Test: lntb10u1pjexampleinvoice0000000000000000000000000000000000000000000"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func lnurlPattern_valid() {
|
|
||||||
let content = "LNURL: lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserx"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.lnurl.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func lightningSchemePattern_valid() {
|
|
||||||
let content = "Click: lightning:lnbc10u1example"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.lightningScheme.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func cashuPattern_valid() {
|
|
||||||
let content = "Token: cashuAeyJwcm9vZnMiOlt7ImlkIjoiMDAwMDAwMDAwMDAwMDAwMCJ9XX0="
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.cashu.matches(in: content, options: [], range: range)
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - URL Detection Tests
|
|
||||||
|
|
||||||
@Test func linkDetector_httpURL() {
|
|
||||||
let content = "Check out http://example.com"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func linkDetector_httpsURL() {
|
|
||||||
let content = "Visit https://example.com/path?query=value"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []
|
|
||||||
#expect(matches.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func linkDetector_multipleURLs() {
|
|
||||||
let content = "See https://a.com and http://b.com"
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []
|
|
||||||
#expect(matches.count == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - String Extension Tests
|
|
||||||
|
|
||||||
@Test func splitSuffix_withSuffix() {
|
|
||||||
let name = "alice#a1b2"
|
|
||||||
let (base, suffix) = name.splitSuffix()
|
|
||||||
#expect(base == "alice")
|
|
||||||
#expect(suffix == "#a1b2")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func splitSuffix_withoutSuffix() {
|
|
||||||
let name = "alice"
|
|
||||||
let (base, suffix) = name.splitSuffix()
|
|
||||||
#expect(base == "alice")
|
|
||||||
#expect(suffix == "")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func splitSuffix_withAtPrefix() {
|
|
||||||
let name = "@alice#a1b2"
|
|
||||||
let (base, suffix) = name.splitSuffix()
|
|
||||||
#expect(base == "alice")
|
|
||||||
#expect(suffix == "#a1b2")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func hasVeryLongToken_noLongToken() {
|
|
||||||
let content = "Short words only here"
|
|
||||||
#expect(!content.hasVeryLongToken(threshold: 50))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func hasVeryLongToken_withLongToken() {
|
|
||||||
let longToken = String(repeating: "a", count: 100)
|
|
||||||
let content = "Here is a \(longToken) token"
|
|
||||||
#expect(content.hasVeryLongToken(threshold: 50))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func hasVeryLongToken_exactThreshold() {
|
|
||||||
let exactToken = String(repeating: "a", count: 50)
|
|
||||||
let content = "Token: \(exactToken)"
|
|
||||||
// Exactly at threshold DOES trigger (uses >= comparison)
|
|
||||||
#expect(content.hasVeryLongToken(threshold: 50))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
//
|
|
||||||
// MimeTypeTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// MARK: - MimeType Mapping and Signature Tests
|
|
||||||
|
|
||||||
struct MimeTypeTests {
|
|
||||||
|
|
||||||
// MARK: MIME → Enum Parsing + Default Extension
|
|
||||||
@Test(arguments: [
|
|
||||||
("image/jpeg", MimeType.jpeg, "jpg"),
|
|
||||||
("image/jpg", MimeType.jpeg, "jpg"),
|
|
||||||
("image/png", MimeType.png, "png"),
|
|
||||||
("image/gif", MimeType.gif, "gif"),
|
|
||||||
("image/webp", MimeType.webp, "webp"),
|
|
||||||
("audio/mp4", MimeType.mp4Audio, "m4a"),
|
|
||||||
("audio/m4a", MimeType.m4a, "m4a"),
|
|
||||||
("audio/aac", MimeType.aac, "m4a"),
|
|
||||||
("audio/mpeg", MimeType.mpeg, "mp3"),
|
|
||||||
("audio/mp3", MimeType.mp3, "mp3"),
|
|
||||||
("audio/wav", MimeType.wav, "wav"),
|
|
||||||
("audio/x-wav", MimeType.xWav, "wav"),
|
|
||||||
("audio/ogg", MimeType.ogg, "ogg"),
|
|
||||||
("application/pdf", MimeType.pdf, "pdf"),
|
|
||||||
("application/octet-stream", MimeType.octetStream, "bin")
|
|
||||||
])
|
|
||||||
func mimeTypeParsingAndExtensions(
|
|
||||||
mimeString: String,
|
|
||||||
expectedType: MimeType,
|
|
||||||
expectedExt: String
|
|
||||||
) throws {
|
|
||||||
guard let mime = MimeType(mimeString) else {
|
|
||||||
Issue.record("Failed to parse \(mimeString)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(mime == expectedType, "Expected \(expectedType) for \(mimeString)")
|
|
||||||
#expect(mime.mimeString == expectedType.mimeString)
|
|
||||||
#expect(mime.defaultExtension == expectedExt)
|
|
||||||
#expect(mime.isAllowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - File Signature Validation
|
|
||||||
@Test(arguments: [
|
|
||||||
// === Image types ===
|
|
||||||
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
|
|
||||||
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
|
||||||
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
|
|
||||||
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
|
|
||||||
|
|
||||||
// === Audio types ===
|
|
||||||
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
|
|
||||||
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
|
|
||||||
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
|
|
||||||
|
|
||||||
// === Application types ===
|
|
||||||
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
|
|
||||||
])
|
|
||||||
func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
|
|
||||||
let data = Data(bytes)
|
|
||||||
#expect(mime.matches(data: data),
|
|
||||||
"Expected \(mime.mimeString) to match its signature")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Negative Tests
|
|
||||||
@Test func invalidDataDoesNotMatch() throws {
|
|
||||||
let badData = Data(repeating: 0x00, count: 16)
|
|
||||||
for mime in MimeType.allCases where mime != .octetStream {
|
|
||||||
#expect(!mime.matches(data: badData),
|
|
||||||
"Unexpectedly matched \(mime.mimeString) with zeroed data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Octet-stream (generic binary)
|
|
||||||
@Test func octetStreamAlwaysMatches() throws {
|
|
||||||
let randomData = Data([0x00, 0x11, 0x22, 0x33])
|
|
||||||
#expect(MimeType.octetStream.matches(data: randomData),
|
|
||||||
"application/octet-stream should always be considered valid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user