mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:45:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97fc21c23f | ||
|
|
79bd4af912 | ||
|
|
96c78ef5a2 | ||
|
|
01256f3041 | ||
|
|
2edbe2bbbd | ||
|
|
a91978c10e | ||
|
|
14c4e586fc | ||
|
|
83779240ae | ||
|
|
5034732515 | ||
|
|
b6ce4fae43 | ||
|
|
98fa02cc16 | ||
|
|
0812cafd55 | ||
|
|
450955525c | ||
|
|
475bc70c71 | ||
|
|
af6136c01c | ||
|
|
b839ce5f6c | ||
|
|
0776c9813c | ||
|
|
0dd999af6b | ||
|
|
c3a1af7023 | ||
|
|
880813f256 | ||
|
|
64fb634166 | ||
|
|
13b19fb8eb | ||
|
|
d4967ae9c3 | ||
|
|
435744a977 | ||
|
|
70caa9e24a | ||
|
|
5084f87fe5 | ||
|
|
8f56e4f0fb | ||
|
|
aca44f9f55 | ||
|
|
3f00cf9467 | ||
|
|
40e54a5120 | ||
|
|
7040d9ecb0 | ||
|
|
88bafb41cc |
@@ -7,6 +7,7 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-relay-data:
|
update-relay-data:
|
||||||
@@ -17,24 +18,54 @@ 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 https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
wget -q 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: Check for changes
|
- name: Configure git
|
||||||
id: git-check
|
|
||||||
run: |
|
run: |
|
||||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
git config user.email "action@github.com"
|
||||||
|
git config user.name "GitHub Action"
|
||||||
- name: Commit and push changes
|
|
||||||
if: steps.git-check.outputs.changes == 'true'
|
- name: Create update branch if changes
|
||||||
|
id: create_branch
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "action@github.com"
|
# exit early if no changes
|
||||||
git config --local user.name "GitHub Action"
|
if git diff --quiet --relays/online_relays_gps.csv; then
|
||||||
|
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)"
|
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
|
||||||
git push
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
env:
|
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||||
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"
|
||||||
@@ -98,8 +98,8 @@
|
|||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<EnvironmentVariables>
|
<EnvironmentVariables>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
key = "BITCHAT_LOG_LEVEL"
|
||||||
value = ""
|
value = "debug"
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
|
|||||||
@@ -57,11 +57,9 @@ struct BitchatApp: App {
|
|||||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
|
||||||
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
|
||||||
@@ -189,6 +187,10 @@ 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
|
||||||
|
|
||||||
@@ -246,7 +248,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 {
|
||||||
// Don't show notification if the private chat is already open
|
// Don't show notification if the private chat is already open
|
||||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
if chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
||||||
completionHandler([])
|
completionHandler([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.85
|
private static let compressionQuality: CGFloat = 0.82
|
||||||
private static let targetImageBytes: Int = 60_000
|
private static let targetImageBytes: Int = 45_000
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
|
static func processImage(at url: URL, maxDimension: CGFloat = 448) 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,21 +38,32 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
|
// Scale the image first
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
var quality = compressionQuality
|
|
||||||
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
|
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||||
|
guard let cgImage = scaled.cgImage else {
|
||||||
throw ImageUtilsError.encodingFailed
|
throw ImageUtilsError.encodingFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||||
|
var quality = compressionQuality
|
||||||
|
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress to target size
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
quality -= 0.1
|
quality -= 0.1
|
||||||
autoreleasepool {
|
autoreleasepool {
|
||||||
if let next = scaled.jpegData(compressionQuality: quality) {
|
if let next = encodeJPEG(from: cgImage, quality: 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
|
||||||
@@ -65,14 +76,37 @@ 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 = 512) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) 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 {
|
||||||
@@ -130,6 +164,7 @@ 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
|
||||||
@@ -137,8 +172,9 @@ 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 H2: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
// Don't add any metadata dictionary keys - fresh CGContext ensures clean image
|
// By only specifying compression quality and no metadata keys,
|
||||||
|
// we ensure a clean JPEG with no privacy-leaking information
|
||||||
let options: [CFString: Any] = [
|
let options: [CFString: Any] = [
|
||||||
kCGImageDestinationLossyCompressionQuality: quality
|
kCGImageDestinationLossyCompressionQuality: quality
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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?
|
||||||
@@ -75,14 +76,14 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
AVSampleRateKey: 16_000,
|
AVSampleRateKey: 16_000,
|
||||||
AVNumberOfChannelsKey: 1,
|
AVNumberOfChannelsKey: 1,
|
||||||
AVEncoderBitRateKey: 20_000
|
AVEncoderBitRateKey: 16_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()
|
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||||
|
|
||||||
recorder = audioRecorder
|
recorder = audioRecorder
|
||||||
currentURL = outputURL
|
currentURL = outputURL
|
||||||
|
|||||||
+24248
-23926
File diff suppressed because it is too large
Load Diff
@@ -21,8 +21,9 @@ 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) {
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
|
||||||
self.version = version
|
self.version = version
|
||||||
self.type = type
|
self.type = type
|
||||||
self.senderID = senderID
|
self.senderID = senderID
|
||||||
@@ -31,6 +32,7 @@ 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
|
||||||
@@ -53,6 +55,7 @@ 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? {
|
||||||
@@ -81,7 +84,8 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-18
@@ -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)
|
self.bare = String(bare).lowercased()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +76,12 @@ 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
|
||||||
@@ -130,6 +136,20 @@ 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 {
|
||||||
@@ -191,9 +211,7 @@ extension PeerID: Comparable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - String Interop Helpers
|
// MARK: - CustomStringConvertible
|
||||||
|
|
||||||
// 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
|
||||||
@@ -201,17 +219,3 @@ 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: String // Who read it
|
var readerID: PeerID // Who read it
|
||||||
let readerNickname: String
|
let readerNickname: String
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
|
|
||||||
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
init(originalMessageID: String, readerID: PeerID, 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: String, readerNickname: String, timestamp: Date) {
|
private init(originalMessageID: String, receiptID: String, readerID: PeerID, 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
|
var tempID = readerID.id
|
||||||
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 = readerIDData.hexEncodedString()
|
let readerID = PeerID(hexData: readerIDData)
|
||||||
guard PeerID(str: readerID).isValid else { return nil }
|
guard 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,6 +8,14 @@ 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()
|
||||||
@@ -25,6 +33,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +44,7 @@ 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
|
||||||
@@ -52,12 +64,16 @@ 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)
|
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
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
|
||||||
@@ -12,19 +17,32 @@ 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 // 24h
|
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
||||||
|
|
||||||
|
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() {
|
||||||
// Load cached or bundled data synchronously
|
entries = loadLocalEntries()
|
||||||
self.entries = self.loadLocalEntries()
|
registerObservers()
|
||||||
// Fire-and-forget remote refresh if stale
|
startRefreshTimer()
|
||||||
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)
|
||||||
@@ -62,42 +80,119 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
// MARK: - Remote Fetch
|
||||||
func prefetchIfNeeded() {
|
func prefetchIfNeeded(force: Bool = false) {
|
||||||
|
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() {
|
||||||
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
guard !isFetching else { return }
|
||||||
// Ensure Tor readiness before fetching (fail-closed by default)
|
isFetching = true
|
||||||
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 {
|
||||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
|
await self.handleFetchFailure(.torNotReady)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
|
|
||||||
guard let self = self else { return }
|
do {
|
||||||
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
||||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
if !parsed.isEmpty {
|
await self.handleFetchFailure(.invalidData)
|
||||||
Task { @MainActor in
|
return
|
||||||
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 {
|
||||||
@@ -110,30 +205,35 @@ 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 = self.cacheURL(),
|
if let cache = 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), let text = String(data: data, encoding: .utf8) {
|
if let data = try? Data(contentsOf: url),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
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 []
|
||||||
}
|
}
|
||||||
@@ -141,7 +241,6 @@ 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 }
|
||||||
@@ -162,11 +261,76 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
private func cacheURL() -> URL? {
|
private func cacheURL() -> URL? {
|
||||||
do {
|
do {
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
let base = try FileManager.default.url(
|
||||||
|
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 { return nil }
|
} catch {
|
||||||
|
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: String, senderPeerID: String) -> String? {
|
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> 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 recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientIDHex),
|
recipientID: Data(hexString: recipientID.id),
|
||||||
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: String, senderPeerID: String) -> String? {
|
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> 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 recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientIDHex),
|
recipientID: Data(hexString: recipientID.id),
|
||||||
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: String) -> String? {
|
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> 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) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? 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: String) -> String? {
|
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> 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) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? 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: String) -> String {
|
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
|
||||||
if let maybeData = Data(hexString: recipientPeerID) {
|
if let maybeData = Data(hexString: recipientPeerID.id) {
|
||||||
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).id
|
return PeerID(publicKey: maybeData)
|
||||||
} 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,6 +906,16 @@ 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
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ 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
|
||||||
@@ -160,8 +161,21 @@ 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 = payload.count + originalSizeFieldBytes
|
let payloadDataSize = routeLength + 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 }
|
||||||
@@ -185,6 +199,7 @@ 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 {
|
||||||
@@ -212,6 +227,13 @@ 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)
|
||||||
@@ -321,9 +343,27 @@ 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 payloadLength >= lengthFieldBytes else { return nil }
|
guard remainingPayloadBytes >= 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 }
|
||||||
@@ -332,15 +372,12 @@ struct BinaryProtocol {
|
|||||||
guard let rawSize = read16() else { return nil }
|
guard let rawSize = read16() else { return nil }
|
||||||
originalSize = Int(rawSize)
|
originalSize = Int(rawSize)
|
||||||
}
|
}
|
||||||
// Guard to keep decompression bounded to sane BLE payload limits
|
remainingPayloadBytes -= lengthFieldBytes
|
||||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxPayloadBytes else { return nil }
|
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||||
let compressedSize = payloadLength - lengthFieldBytes
|
let compressedSize = remainingPayloadBytes
|
||||||
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)
|
||||||
@@ -351,7 +388,9 @@ struct BinaryProtocol {
|
|||||||
decompressed.count == originalSize else { return nil }
|
decompressed.count == originalSize else { return nil }
|
||||||
payload = decompressed
|
payload = decompressed
|
||||||
} else {
|
} else {
|
||||||
guard let rawPayload = readData(payloadLength) else { return nil }
|
guard remainingPayloadBytes >= 0,
|
||||||
|
let rawPayload = readData(remainingPayloadBytes) else { return nil }
|
||||||
|
remainingPayloadBytes = 0
|
||||||
payload = rawPayload
|
payload = rawPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +410,8 @@ 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)
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,4 +119,57 @@ 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,4 +116,18 @@ 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,11 +6,13 @@ 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? {
|
||||||
@@ -35,6 +37,16 @@ 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
|
||||||
}
|
}
|
||||||
@@ -44,6 +56,7 @@ 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]
|
||||||
@@ -63,6 +76,17 @@ 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)
|
||||||
@@ -74,7 +98,8 @@ struct AnnouncementPacket {
|
|||||||
return AnnouncementPacket(
|
return AnnouncementPacket(
|
||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey
|
signingPublicKey: signingPublicKey,
|
||||||
|
directNeighbors: directNeighbors
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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,14 +163,22 @@ 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
|
|
||||||
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
// Subscribe to center + 8 neighbors (± 1 grid)
|
||||||
|
let neighbors = Geohash.neighbors(of: geohash)
|
||||||
|
let allGeohashes = [geohash] + neighbors
|
||||||
|
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
|
||||||
|
|
||||||
|
// Build a set of valid geohashes for tag matching (includes all 9 cells)
|
||||||
|
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
|
||||||
|
|
||||||
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
|
// Ensure matching tag - accept any of our 9 geohashes
|
||||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
guard event.tags.contains(where: { tag in
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((PeerID, 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 (String, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, 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: ((String, String) -> Void)? {
|
var onPeerAuthenticated: ((PeerID, 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.id, fingerprint)
|
handler(peerID, fingerprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ final class NostrTransport: Transport {
|
|||||||
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.id, senderPeerID: senderPeerID.id) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ final class NostrTransport: Transport {
|
|||||||
guard hrp == "npub" else { return }
|
guard hrp == "npub" else { return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch { return }
|
} catch { return }
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
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
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ final class NostrTransport: Transport {
|
|||||||
guard hrp == "npub" else { return }
|
guard hrp == "npub" else { return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch { return }
|
} catch { return }
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
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
|
||||||
}
|
}
|
||||||
@@ -161,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.id) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) 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)
|
||||||
@@ -171,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.id) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) 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)
|
||||||
@@ -184,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.id) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) 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
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ extension NostrTransport {
|
|||||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch { scheduleNextReadAck(); return }
|
} catch { scheduleNextReadAck(); return }
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,28 +29,30 @@ final class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
|
func sendLocalNotification(
|
||||||
// For now, skip app state check entirely to avoid thread issues
|
title: String,
|
||||||
// The NotificationDelegate will handle foreground presentation
|
body: String,
|
||||||
DispatchQueue.main.async {
|
identifier: String,
|
||||||
let content = UNMutableNotificationContent()
|
userInfo: [String: Any]? = nil,
|
||||||
content.title = title
|
interruptionLevel: UNNotificationInterruptionLevel = .active
|
||||||
content.body = body
|
) {
|
||||||
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) {
|
||||||
@@ -61,11 +63,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: String) {
|
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
|
||||||
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, "senderName": sender]
|
let userInfo = ["peerID": peerID.id, "senderName": sender]
|
||||||
|
|
||||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||||
}
|
}
|
||||||
@@ -83,25 +85,12 @@ 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)"
|
||||||
|
|
||||||
// For network notifications, we want to show them even in foreground
|
sendLocalNotification(
|
||||||
// No app state check - let the notification delegate handle presentation
|
title: title,
|
||||||
DispatchQueue.main.async {
|
body: body,
|
||||||
let content = UNMutableNotificationContent()
|
identifier: identifier,
|
||||||
content.title = title
|
interruptionLevel: .timeSensitive
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,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.id ?? "",
|
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
||||||
readerNickname: meshService?.myNickname ?? ""
|
readerNickname: meshService?.myNickname ?? ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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,
|
||||||
@@ -36,6 +37,16 @@ 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,6 +45,7 @@ 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)
|
||||||
@@ -65,6 +66,10 @@ 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,6 +8,10 @@ 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
|
||||||
@@ -66,6 +70,7 @@ 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
|
||||||
@@ -145,6 +150,9 @@ 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
|
||||||
|
|||||||
@@ -8,6 +8,55 @@ 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)
|
||||||
@@ -16,25 +65,43 @@ 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 messages (ordered by insert), and latest announce per sender
|
// Storage: broadcast packets by type, and latest announce per sender
|
||||||
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
|
private var messages = PacketStore()
|
||||||
private var messageOrder: [String] = []
|
private var fragments = PacketStore()
|
||||||
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
|
private var fileTransfers = PacketStore()
|
||||||
|
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() {
|
||||||
@@ -55,7 +122,18 @@ 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
|
||||||
self?.sendRequestSync(to: peerID)
|
guard let self = self else { return }
|
||||||
|
self.sendRequestSync(to: peerID, types: .publicMessages)
|
||||||
|
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
||||||
|
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
|
self?.sendRequestSync(to: peerID, types: .fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
||||||
|
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||||
|
self?.sendRequestSync(to: peerID, types: .fileTransfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,47 +165,45 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
let mt = MessageType(rawValue: packet.type)
|
guard let messageType = MessageType(rawValue: packet.type) else { return }
|
||||||
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 }
|
|
||||||
|
|
||||||
// Reject expired packets to prevent ghost peers and old messages
|
switch messageType {
|
||||||
guard isPacketFresh(packet) else { return }
|
case .announce:
|
||||||
|
guard isPacketFresh(packet) else { return }
|
||||||
if isAnnounce {
|
|
||||||
guard isAnnouncementFresh(packet) else {
|
guard isAnnouncementFresh(packet) else {
|
||||||
let sender = packet.senderID.hexEncodedString().lowercased()
|
let sender = PeerID(hexData: packet.senderID)
|
||||||
removeState(forNormalizedPeerID: sender)
|
removeState(for: 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() {
|
private func sendRequestSync(for types: SyncTypeFlags) {
|
||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload(for: types)
|
||||||
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(),
|
||||||
@@ -141,8 +217,8 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(signed)
|
delegate?.sendPacket(signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync(to peerID: PeerID) {
|
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload(for: types)
|
||||||
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 {
|
||||||
@@ -170,6 +246,7 @@ 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 {
|
||||||
@@ -177,60 +254,100 @@ final class GossipSyncManager {
|
|||||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
if requestedTypes.contains(.announce) {
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Broadcast messages: send all missing (and not expired)
|
if requestedTypes.contains(.message) {
|
||||||
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
|
||||||
for pkt in toSendMsgs {
|
for pkt in toSendMsgs {
|
||||||
guard isPacketFresh(pkt) else { continue }
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
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(.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() -> Data {
|
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
|
||||||
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
|
||||||
var candidates: [BitchatPacket] = []
|
var candidates: [BitchatPacket] = []
|
||||||
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
if types.contains(.announce) {
|
||||||
for (_, pair) in latestAnnouncementByPeer {
|
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
|
||||||
if isPacketFresh(pair.packet) {
|
|
||||||
candidates.append(pair.packet)
|
candidates.append(pair.packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for id in messageOrder {
|
if types.contains(.message) {
|
||||||
if let p = messages[id], isPacketFresh(p) {
|
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
|
||||||
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 = max(1, config.seenCapacity)
|
let cap: Int
|
||||||
|
if types == .fragment {
|
||||||
|
cap = max(1, config.fragmentCapacity)
|
||||||
|
} else if types == .fileTransfer {
|
||||||
|
cap = max(1, config.fileTransferCapacity)
|
||||||
|
} else {
|
||||||
|
cap = max(1, config.seenCapacity)
|
||||||
|
}
|
||||||
let 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())
|
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||||
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)
|
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
|
||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,20 +358,21 @@ final class GossipSyncManager {
|
|||||||
isPacketFresh(pair.packet)
|
isPacketFresh(pair.packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove expired messages
|
messages.removeExpired(isFresh: isPacketFresh)
|
||||||
let expiredMessageIds = messages.compactMap { id, pkt in
|
fragments.removeExpired(isFresh: isPacketFresh)
|
||||||
isPacketFresh(pkt) ? nil : id
|
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||||
}
|
|
||||||
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)
|
||||||
sendRequestSync()
|
for index in syncSchedules.indices {
|
||||||
|
guard syncSchedules[index].interval > 0 else { continue }
|
||||||
|
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||||
|
syncSchedules[index].lastSent = now
|
||||||
|
sendRequestSync(for: syncSchedules[index].types)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
@@ -270,40 +388,27 @@ 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 { (peerHex, pair) -> String? in
|
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
|
||||||
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
|
pair.packet.timestamp < cutoff ? peerID : nil
|
||||||
}
|
}
|
||||||
guard !stalePeerIDs.isEmpty else { return }
|
guard !stalePeerIDs.isEmpty else { return }
|
||||||
for peerKey in stalePeerIDs {
|
for peerKey in stalePeerIDs {
|
||||||
removeState(forNormalizedPeerID: peerKey)
|
removeState(for: 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?._removeAnnouncementForPeer(peerID)
|
self?.removeState(for: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
private func removeState(for peerID: PeerID) {
|
||||||
let normalizedPeerID = peerID.id.lowercased()
|
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
||||||
removeState(forNormalizedPeerID: normalizedPeerID)
|
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
}
|
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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,13 +422,13 @@ extension GossipSyncManager {
|
|||||||
|
|
||||||
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
|
latestAnnouncementByPeer[peerID] != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func _messageCount(for peerID: PeerID) -> Int {
|
func _messageCount(for peerID: PeerID) -> Int {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
|
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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,15 +61,13 @@ 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 }
|
||||||
|
|
||||||
// Simple entropy check - count unique bytes
|
// Quick uniqueness check — a high diversity of bytes usually means the
|
||||||
var byteFrequency = [UInt8: Int]()
|
// payload is already compressed. We only need to know how many unique
|
||||||
for byte in data {
|
// values exist rather than keeping full frequency counts.
|
||||||
byteFrequency[byte, default: 0] += 1
|
let uniqueByteCount = Set(data).count
|
||||||
}
|
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,9 +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 = 1 * 1024 * 1024 // 1 MiB
|
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||||
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
||||||
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
||||||
static let maxFramedFileBytes: Int = {
|
static let maxFramedFileBytes: Int = {
|
||||||
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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
|
||||||
@@ -16,29 +17,28 @@ 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 }
|
||||||
|
|
||||||
// Remove control characters
|
// Reject control characters outright instead of rewriting the string.
|
||||||
|
// This prevents injection attacks and ensures consistent UI rendering.
|
||||||
let controlChars = CharacterSet.controlCharacters
|
let controlChars = CharacterSet.controlCharacters
|
||||||
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) {
|
||||||
|
// Log rejection for monitoring, without exposing actual content for privacy
|
||||||
// Ensure valid UTF-8 (should already be, but double-check)
|
let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count
|
||||||
guard cleaned.data(using: .utf8) != nil else { return nil }
|
SecureLogger.debug(
|
||||||
|
"Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))",
|
||||||
// Prevent zero-width characters and other invisible unicode
|
category: .security
|
||||||
let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}")
|
)
|
||||||
let visible = cleaned.components(separatedBy: invisibleChars).joined()
|
return nil
|
||||||
|
}
|
||||||
return visible.isEmpty ? nil : visible
|
|
||||||
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates nickname
|
/// Validates nickname
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
+290
-318
@@ -15,9 +15,6 @@ import AppKit
|
|||||||
#endif
|
#endif
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import BitLogger
|
import BitLogger
|
||||||
#if canImport(PhotosUI)
|
|
||||||
import PhotosUI
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Supporting Types
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
@@ -38,7 +35,6 @@ struct ContentView: View {
|
|||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||||
@ObservedObject private var notesCounter = LocationNotesCounter.shared
|
|
||||||
@State private var messageText = ""
|
@State private var messageText = ""
|
||||||
@FocusState private var isTextFieldFocused: Bool
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@@ -47,11 +43,9 @@ struct ContentView: View {
|
|||||||
@State private var showPeerList = false
|
@State private var showPeerList = false
|
||||||
@State private var showSidebar = false
|
@State private var showSidebar = false
|
||||||
@State private var showAppInfo = false
|
@State private var showAppInfo = false
|
||||||
@State private var showCommandSuggestions = false
|
|
||||||
@State private var commandSuggestions: [String] = []
|
|
||||||
@State private var showMessageActions = false
|
@State private var showMessageActions = false
|
||||||
@State private var selectedMessageSender: String?
|
@State private var selectedMessageSender: String?
|
||||||
@State private var selectedMessageSenderID: String?
|
@State private var selectedMessageSenderID: PeerID?
|
||||||
@FocusState private var isNicknameFieldFocused: Bool
|
@FocusState private var isNicknameFieldFocused: Bool
|
||||||
@State private var isAtBottomPublic: Bool = true
|
@State private var isAtBottomPublic: Bool = true
|
||||||
@State private var isAtBottomPrivate: Bool = true
|
@State private var isAtBottomPrivate: Bool = true
|
||||||
@@ -72,19 +66,19 @@ struct ContentView: View {
|
|||||||
@State private var recordingDuration: TimeInterval = 0
|
@State private var recordingDuration: TimeInterval = 0
|
||||||
@State private var recordingTimer: Timer?
|
@State private var recordingTimer: Timer?
|
||||||
@State private var recordingStartDate: Date?
|
@State private var recordingStartDate: Date?
|
||||||
@State private var showFileImporter = false
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@State private var showPhotoPicker = false
|
@State private var showImagePicker = false
|
||||||
@State private var selectedPhotoPickerItem: PhotosPickerItem?
|
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera
|
||||||
|
#else
|
||||||
|
@State private var showMacImagePicker = false
|
||||||
#endif
|
#endif
|
||||||
@State private var showAttachmentActions = false
|
|
||||||
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
||||||
@ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11
|
@ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11
|
||||||
@ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12
|
@ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12
|
||||||
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
||||||
// Window sizes for rendering (infinite scroll up)
|
// Window sizes for rendering (infinite scroll up)
|
||||||
@State private var windowCountPublic: Int = 300
|
@State private var windowCountPublic: Int = 300
|
||||||
@State private var windowCountPrivate: [String: Int] = [:]
|
@State private var windowCountPrivate: [PeerID: Int] = [:]
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
@@ -128,7 +122,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
|
|
||||||
private struct PrivateHeaderContext {
|
private struct PrivateHeaderContext {
|
||||||
let headerPeerID: String
|
let headerPeerID: PeerID
|
||||||
let peer: BitchatPeer?
|
let peer: BitchatPeer?
|
||||||
let displayName: String
|
let displayName: String
|
||||||
let isNostrAvailable: Bool
|
let isNostrAvailable: Bool
|
||||||
@@ -192,10 +186,6 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
peopleSheetView
|
peopleSheetView
|
||||||
#if os(iOS)
|
|
||||||
.presentationDetents([.large])
|
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showAppInfo) {
|
.sheet(isPresented: $showAppInfo) {
|
||||||
AppInfoView()
|
AppInfoView()
|
||||||
@@ -207,19 +197,64 @@ struct ContentView: View {
|
|||||||
set: { _ in viewModel.showingFingerprintFor = nil }
|
set: { _ in viewModel.showingFingerprintFor = nil }
|
||||||
)) {
|
)) {
|
||||||
if let peerID = viewModel.showingFingerprintFor {
|
if let peerID = viewModel.showingFingerprintFor {
|
||||||
FingerprintView(viewModel: viewModel, peerID: peerID.id)
|
FingerprintView(viewModel: viewModel, peerID: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoPickerItem, matching: .images)
|
// Only present image picker from main view when NOT in a sheet
|
||||||
.onChange(of: selectedPhotoPickerItem) { newItem in
|
.fullScreenCover(isPresented: Binding(
|
||||||
guard let item = newItem else { return }
|
get: { showImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
|
||||||
Task { await handlePhotoSelection(item) }
|
set: { newValue in
|
||||||
|
if !newValue {
|
||||||
|
showImagePicker = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||||
|
showImagePicker = false
|
||||||
|
if let image = image {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let processedURL = try ImageUtils.processImage(image)
|
||||||
|
await MainActor.run {
|
||||||
|
viewModel.sendImage(from: processedURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data], allowsMultipleSelection: false) { result in
|
#if os(macOS)
|
||||||
handleImportResult(result, handler: handleImportedFile)
|
// Only present Mac image picker from main view when NOT in a sheet
|
||||||
|
.sheet(isPresented: Binding(
|
||||||
|
get: { showMacImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
|
||||||
|
set: { newValue in
|
||||||
|
if !newValue {
|
||||||
|
showMacImagePicker = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
MacImagePickerView { url in
|
||||||
|
showMacImagePicker = false
|
||||||
|
if let url = url {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let processedURL = try ImageUtils.processImage(at: url)
|
||||||
|
await MainActor.run {
|
||||||
|
viewModel.sendImage(from: processedURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
.sheet(isPresented: Binding(
|
.sheet(isPresented: Binding(
|
||||||
get: { imagePreviewURL != nil },
|
get: { imagePreviewURL != nil },
|
||||||
set: { presenting in if !presenting { imagePreviewURL = nil } }
|
set: { presenting in if !presenting { imagePreviewURL = nil } }
|
||||||
@@ -228,19 +263,6 @@ struct ContentView: View {
|
|||||||
ImagePreviewView(url: url)
|
ImagePreviewView(url: url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog("Attach", isPresented: $showAttachmentActions, titleVisibility: .visible) {
|
|
||||||
#if os(iOS)
|
|
||||||
Button("Image") {
|
|
||||||
showAttachmentActions = false
|
|
||||||
DispatchQueue.main.async { showPhotoPicker = true }
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
Button("File") {
|
|
||||||
showAttachmentActions = false
|
|
||||||
DispatchQueue.main.async { showFileImporter = true }
|
|
||||||
}
|
|
||||||
Button("Cancel", role: .cancel) {}
|
|
||||||
}
|
|
||||||
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
|
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
|
||||||
Button("OK", role: .cancel) {}
|
Button("OK", role: .cancel) {}
|
||||||
}, message: {
|
}, message: {
|
||||||
@@ -261,12 +283,12 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Button("content.actions.direct_message") {
|
Button("content.actions.direct_message") {
|
||||||
if let peerID = selectedMessageSenderID {
|
if let peerID = selectedMessageSenderID {
|
||||||
if peerID.hasPrefix("nostr:") {
|
if peerID.isGeoChat {
|
||||||
if let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)) {
|
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
viewModel.startGeohashDM(withPubkeyHex: full)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.startPrivateChat(with: PeerID(str: peerID))
|
viewModel.startPrivateChat(with: peerID)
|
||||||
}
|
}
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showSidebar = true
|
showSidebar = true
|
||||||
@@ -288,8 +310,8 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Button("content.actions.block", role: .destructive) {
|
Button("content.actions.block", role: .destructive) {
|
||||||
// Prefer direct geohash block when we have a Nostr sender ID
|
// Prefer direct geohash block when we have a Nostr sender ID
|
||||||
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
|
||||||
let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)),
|
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||||
let sender = selectedMessageSender {
|
let sender = selectedMessageSender {
|
||||||
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
||||||
} else if let sender = selectedMessageSender {
|
} else if let sender = selectedMessageSender {
|
||||||
@@ -320,9 +342,9 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// MARK: - Message List View
|
// MARK: - Message List View
|
||||||
|
|
||||||
private func messagesView(privatePeer: String?, isAtBottom: Binding<Bool>) -> some View {
|
private func messagesView(privatePeer: PeerID?, isAtBottom: Binding<Bool>) -> some View {
|
||||||
let messages: [BitchatMessage] = {
|
let messages: [BitchatMessage] = {
|
||||||
if let peerID = PeerID(str: privatePeer) {
|
if let peerID = privatePeer {
|
||||||
return viewModel.getPrivateChatMessages(for: peerID)
|
return viewModel.getPrivateChatMessages(for: peerID)
|
||||||
}
|
}
|
||||||
return viewModel.messages
|
return viewModel.messages
|
||||||
@@ -462,7 +484,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: viewModel.privateChats) { _ in
|
.onChange(of: viewModel.privateChats) { _ in
|
||||||
if let peerID = privatePeer,
|
if let peerID = privatePeer,
|
||||||
let messages = viewModel.privateChats[PeerID(str: peerID)],
|
let messages = viewModel.privateChats[peerID],
|
||||||
!messages.isEmpty {
|
!messages.isEmpty {
|
||||||
// If the newest private message is from me, always scroll
|
// If the newest private message is from me, always scroll
|
||||||
let lastMsg = messages.last!
|
let lastMsg = messages.last!
|
||||||
@@ -517,7 +539,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Also check when view appears
|
// Also check when view appears
|
||||||
if let peerID = PeerID(str: privatePeer) {
|
if let peerID = privatePeer {
|
||||||
// Try multiple times to ensure read receipts are sent
|
// Try multiple times to ensure read receipts are sent
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
|
||||||
@@ -581,77 +603,12 @@ struct ContentView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command suggestions
|
CommandSuggestionsView(
|
||||||
if showCommandSuggestions && !commandSuggestions.isEmpty {
|
messageText: $messageText,
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
textColor: textColor,
|
||||||
// Define commands with aliases and syntax
|
backgroundColor: backgroundColor,
|
||||||
let baseInfo: [(commands: [String], syntax: String?, description: String)] = [
|
secondaryTextColor: secondaryTextColor
|
||||||
(["/block"], "[nickname]", "block or list blocked peers"),
|
)
|
||||||
(["/clear"], nil, "clear chat messages"),
|
|
||||||
(["/hug"], "<nickname>", "send someone a warm hug"),
|
|
||||||
(["/m", "/msg"], "<nickname> [message]", "send private message"),
|
|
||||||
(["/slap"], "<nickname>", "slap someone with a trout"),
|
|
||||||
(["/unblock"], "<nickname>", "unblock a peer"),
|
|
||||||
(["/w"], nil, "see who's online")
|
|
||||||
]
|
|
||||||
let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }()
|
|
||||||
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
|
||||||
let favInfo: [(commands: [String], syntax: String?, description: String)] = [
|
|
||||||
(["/fav"], "<nickname>", "add to favorites"),
|
|
||||||
(["/unfav"], "<nickname>", "remove from favorites")
|
|
||||||
]
|
|
||||||
let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo)
|
|
||||||
|
|
||||||
// Build the display
|
|
||||||
let allCommands = commandInfo
|
|
||||||
|
|
||||||
// Show matching commands
|
|
||||||
ForEach(commandSuggestions, id: \.self) { command in
|
|
||||||
// Find the command info for this suggestion
|
|
||||||
if let info = allCommands.first(where: { $0.commands.contains(command) }) {
|
|
||||||
Button(action: {
|
|
||||||
// Replace current text with selected command
|
|
||||||
messageText = command + " "
|
|
||||||
showCommandSuggestions = false
|
|
||||||
commandSuggestions = []
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
// Show all aliases together
|
|
||||||
Text(info.commands.joined(separator: ", "))
|
|
||||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
|
|
||||||
// Show syntax if any
|
|
||||||
if let syntax = info.syntax {
|
|
||||||
Text(syntax)
|
|
||||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Show description
|
|
||||||
Text(info.description)
|
|
||||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(backgroundColor)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recording indicator
|
// Recording indicator
|
||||||
if isPreparingVoiceNote || isRecordingVoiceNote {
|
if isPreparingVoiceNote || isRecordingVoiceNote {
|
||||||
@@ -685,68 +642,11 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.onChange(of: messageText) { newValue in
|
.onChange(of: messageText) { newValue in
|
||||||
// Cancel previous debounce timer
|
|
||||||
autocompleteDebounceTimer?.invalidate()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
|
|
||||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
|
||||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||||
// Get cursor position (approximate - end of text for now)
|
|
||||||
let cursorPosition = newValue.count
|
let cursorPosition = newValue.count
|
||||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for command autocomplete (instant, no debounce needed)
|
|
||||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
|
||||||
// Build context-aware command list
|
|
||||||
let isGeoPublic: Bool = {
|
|
||||||
if case .location = locationManager.selectedChannel { return true }
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
|
||||||
var commandDescriptions = [
|
|
||||||
("/block", String(localized: "content.commands.block", comment: "Description for /block command")),
|
|
||||||
("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")),
|
|
||||||
("/hug", String(localized: "content.commands.hug", comment: "Description for /hug command")),
|
|
||||||
("/m", String(localized: "content.commands.message", comment: "Description for /m command")),
|
|
||||||
("/slap", String(localized: "content.commands.slap", comment: "Description for /slap command")),
|
|
||||||
("/unblock", String(localized: "content.commands.unblock", comment: "Description for /unblock command")),
|
|
||||||
("/w", String(localized: "content.commands.who", comment: "Description for /w command"))
|
|
||||||
]
|
|
||||||
// Only show favorites commands when not in geohash context
|
|
||||||
if !(isGeoPublic || isGeoDM) {
|
|
||||||
commandDescriptions.append(("/fav", String(localized: "content.commands.favorite", comment: "Description for /fav command")))
|
|
||||||
commandDescriptions.append(("/unfav", String(localized: "content.commands.unfavorite", comment: "Description for /unfav command")))
|
|
||||||
}
|
|
||||||
|
|
||||||
let input = newValue.lowercased()
|
|
||||||
|
|
||||||
// Map of aliases to primary commands
|
|
||||||
let aliases: [String: String] = [
|
|
||||||
"/join": "/j",
|
|
||||||
"/msg": "/m"
|
|
||||||
]
|
|
||||||
|
|
||||||
// Filter commands, but convert aliases to primary
|
|
||||||
commandSuggestions = commandDescriptions
|
|
||||||
.filter { $0.0.starts(with: input) }
|
|
||||||
.map { $0.0 }
|
|
||||||
|
|
||||||
// Also check if input matches an alias
|
|
||||||
for (alias, primary) in aliases {
|
|
||||||
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
|
|
||||||
if commandDescriptions.contains(where: { $0.0 == primary }) {
|
|
||||||
commandSuggestions.append(primary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove duplicates and sort
|
|
||||||
commandSuggestions = Array(Set(commandSuggestions)).sorted()
|
|
||||||
showCommandSuggestions = !commandSuggestions.isEmpty
|
|
||||||
} else {
|
|
||||||
showCommandSuggestions = false
|
|
||||||
commandSuggestions = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
@@ -763,14 +663,14 @@ struct ContentView: View {
|
|||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleOpenURL(_ url: URL) {
|
private func handleOpenURL(_ url: URL) {
|
||||||
guard url.scheme == "bitchat" else { return }
|
guard url.scheme == "bitchat" else { return }
|
||||||
switch url.host {
|
switch url.host {
|
||||||
case "user":
|
case "user":
|
||||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
||||||
selectedMessageSenderID = peerID.id
|
selectedMessageSenderID = peerID
|
||||||
|
|
||||||
if peerID.isGeoDM || peerID.isGeoChat {
|
if peerID.isGeoDM || peerID.isGeoChat {
|
||||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||||
@@ -818,10 +718,10 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func scrollToBottom(on proxy: ScrollViewProxy,
|
private func scrollToBottom(on proxy: ScrollViewProxy,
|
||||||
privatePeer: String?,
|
privatePeer: PeerID?,
|
||||||
isAtBottom: Binding<Bool>) {
|
isAtBottom: Binding<Bool>) {
|
||||||
let targetID: String? = {
|
let targetID: String? = {
|
||||||
if let peer = PeerID(str: privatePeer),
|
if let peer = privatePeer,
|
||||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
return "dm:\(peer)|\(last)"
|
return "dm:\(peer)|\(last)"
|
||||||
}
|
}
|
||||||
@@ -847,7 +747,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||||
let secondTarget: String? = {
|
let secondTarget: String? = {
|
||||||
if let peer = PeerID(str: privatePeer),
|
if let peer = privatePeer,
|
||||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
return "dm:\(peer)|\(last)"
|
return "dm:\(peer)|\(last)"
|
||||||
}
|
}
|
||||||
@@ -898,6 +798,53 @@ struct ContentView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 420, minHeight: 520)
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
#endif
|
#endif
|
||||||
|
// Present image picker from sheet context when IN a sheet (parent-child pattern)
|
||||||
|
#if os(iOS)
|
||||||
|
.fullScreenCover(isPresented: Binding(
|
||||||
|
get: { showImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) },
|
||||||
|
set: { newValue in
|
||||||
|
if !newValue {
|
||||||
|
showImagePicker = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||||
|
showImagePicker = false
|
||||||
|
if let image = image {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let processedURL = try ImageUtils.processImage(image)
|
||||||
|
await MainActor.run {
|
||||||
|
viewModel.sendImage(from: processedURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#if os(macOS)
|
||||||
|
.sheet(isPresented: $showMacImagePicker) {
|
||||||
|
MacImagePickerView { url in
|
||||||
|
showMacImagePicker = false
|
||||||
|
if let url = url {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let processedURL = try ImageUtils.processImage(at: url)
|
||||||
|
await MainActor.run {
|
||||||
|
viewModel.sendImage(from: processedURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - People Sheet Views
|
// MARK: - People Sheet Views
|
||||||
@@ -984,14 +931,14 @@ struct ContentView: View {
|
|||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
secondaryTextColor: secondaryTextColor,
|
secondaryTextColor: secondaryTextColor,
|
||||||
onTapPeer: { peerID in
|
onTapPeer: { peerID in
|
||||||
viewModel.startPrivateChat(with: PeerID(str: peerID))
|
viewModel.startPrivateChat(with: peerID)
|
||||||
showSidebar = true
|
showSidebar = true
|
||||||
},
|
},
|
||||||
onToggleFavorite: { peerID in
|
onToggleFavorite: { peerID in
|
||||||
viewModel.toggleFavorite(peerID: PeerID(str: peerID))
|
viewModel.toggleFavorite(peerID: peerID)
|
||||||
},
|
},
|
||||||
onShowFingerprint: { peerID in
|
onShowFingerprint: { peerID in
|
||||||
viewModel.showFingerprint(for: PeerID(str: peerID))
|
viewModel.showFingerprint(for: peerID)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1006,7 +953,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private var privateChatSheetView: some View {
|
private var privateChatSheetView: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if let privatePeerID = viewModel.selectedPrivateChatPeer?.id {
|
if let privatePeerID = viewModel.selectedPrivateChatPeer {
|
||||||
let headerContext = makePrivateHeaderContext(for: privatePeerID)
|
let headerContext = makePrivateHeaderContext(for: privatePeerID)
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -1030,12 +977,11 @@ struct ContentView: View {
|
|||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID)
|
privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID)
|
||||||
let peerID = PeerID(str: headerContext.headerPeerID)
|
let isFavorite = viewModel.isFavorite(peerID: headerContext.headerPeerID)
|
||||||
let isFavorite = viewModel.isFavorite(peerID: peerID)
|
|
||||||
|
|
||||||
if !privatePeerID.hasPrefix("nostr_") {
|
if !privatePeerID.isGeoDM {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.toggleFavorite(peerID: peerID)
|
viewModel.toggleFavorite(peerID: headerContext.headerPeerID)
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: isFavorite ? "star.fill" : "star")
|
Image(systemName: isFavorite ? "star.fill" : "star")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
@@ -1074,7 +1020,7 @@ struct ContentView: View {
|
|||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
messagesView(privatePeer: viewModel.selectedPrivateChatPeer?.id, isAtBottom: $isAtBottomPrivate)
|
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
Divider()
|
Divider()
|
||||||
@@ -1096,9 +1042,9 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: String) -> some View {
|
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: PeerID) -> some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.showFingerprint(for: PeerID(str: context.headerPeerID))
|
viewModel.showFingerprint(for: context.headerPeerID)
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
if let connectionState = context.peer?.connectionState {
|
if let connectionState = context.peer?.connectionState {
|
||||||
@@ -1121,7 +1067,7 @@ struct ContentView: View {
|
|||||||
case .offline:
|
case .offline:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
} else if viewModel.meshService.isPeerReachable(PeerID(str: context.headerPeerID)) {
|
} else if viewModel.meshService.isPeerReachable(context.headerPeerID) {
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -1131,7 +1077,7 @@ struct ContentView: View {
|
|||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||||
} else if viewModel.meshService.isPeerConnected(PeerID(str: context.headerPeerID)) || viewModel.connectedPeers.contains(PeerID(str: context.headerPeerID)) {
|
} else if viewModel.meshService.isPeerConnected(context.headerPeerID) || viewModel.connectedPeers.contains(context.headerPeerID) {
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -1142,14 +1088,9 @@ struct ContentView: View {
|
|||||||
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
if !privatePeerID.hasPrefix("nostr_") {
|
if !privatePeerID.isGeoDM {
|
||||||
let statusPeerID: String = {
|
let statusPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
|
||||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
return short.id
|
|
||||||
}
|
|
||||||
return context.headerPeerID
|
|
||||||
}()
|
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
|
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
@@ -1181,33 +1122,27 @@ struct ContentView: View {
|
|||||||
.frame(height: headerHeight)
|
.frame(height: headerHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makePrivateHeaderContext(for privatePeerID: String) -> PrivateHeaderContext {
|
private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext {
|
||||||
let headerPeerID: String = {
|
let headerPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
|
||||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
let peer = viewModel.getPeer(byID: headerPeerID)
|
||||||
return short.id
|
|
||||||
}
|
|
||||||
return privatePeerID
|
|
||||||
}()
|
|
||||||
|
|
||||||
let peer = viewModel.getPeer(byID: PeerID(str: headerPeerID))
|
|
||||||
|
|
||||||
let displayName: String = {
|
let displayName: String = {
|
||||||
if privatePeerID.hasPrefix("nostr_"), case .location(let ch) = locationManager.selectedChannel {
|
if privatePeerID.isGeoDM, case .location(let ch) = locationManager.selectedChannel {
|
||||||
let disp = viewModel.geohashDisplayName(for: PeerID(str: privatePeerID))
|
let disp = viewModel.geohashDisplayName(for: privatePeerID)
|
||||||
return "#\(ch.geohash)/@\(disp)"
|
return "#\(ch.geohash)/@\(disp)"
|
||||||
}
|
}
|
||||||
if let name = peer?.displayName { return name }
|
if let name = peer?.displayName { return name }
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: headerPeerID)) { return name }
|
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID.id) ?? Data()),
|
||||||
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
if headerPeerID.count == 16 {
|
if headerPeerID.id.count == 16 {
|
||||||
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: headerPeerID))
|
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||||
if let id = candidates.first,
|
if let id = candidates.first,
|
||||||
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||||
}
|
}
|
||||||
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
|
} else if let keyData = headerPeerID.noiseKey {
|
||||||
let fp = keyData.sha256Fingerprint()
|
let fp = keyData.sha256Fingerprint()
|
||||||
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
@@ -1219,7 +1154,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
let isNostrAvailable: Bool = {
|
let isNostrAvailable: Bool = {
|
||||||
guard let connectionState = peer?.connectionState else {
|
guard let connectionState = peer?.connectionState else {
|
||||||
if let noiseKey = Data(hexString: headerPeerID),
|
if let noiseKey = Data(hexString: headerPeerID.id),
|
||||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
favoriteStatus.isMutual {
|
favoriteStatus.isMutual {
|
||||||
return true
|
return true
|
||||||
@@ -1349,10 +1284,9 @@ struct ContentView: View {
|
|||||||
showLocationNotes = true
|
showLocationNotes = true
|
||||||
}) {
|
}) {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
let hasNotes = (notesCounter.count ?? 0) > 0
|
Image(systemName: "note.text")
|
||||||
Image(systemName: "long.text.page.and.pencil")
|
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor(hasNotes ? textColor : Color.gray)
|
.foregroundColor(Color.orange.opacity(0.8))
|
||||||
.padding(.top, 1)
|
.padding(.top, 1)
|
||||||
}
|
}
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
@@ -1454,7 +1388,7 @@ struct ContentView: View {
|
|||||||
}) {
|
}) {
|
||||||
Group {
|
Group {
|
||||||
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
||||||
LocationNotesView(geohash: gh, onNotesCountChanged: { cnt in sheetNotesCount = cnt })
|
LocationNotesView(geohash: gh)
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
@@ -1511,7 +1445,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updateNotesCounterSubscription()
|
|
||||||
if case .mesh = locationManager.selectedChannel,
|
if case .mesh = locationManager.selectedChannel,
|
||||||
locationManager.permissionState == .authorized,
|
locationManager.permissionState == .authorized,
|
||||||
LocationChannelManager.shared.availableChannels.isEmpty {
|
LocationChannelManager.shared.availableChannels.isEmpty {
|
||||||
@@ -1519,16 +1452,13 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: locationManager.selectedChannel) { _ in
|
.onChange(of: locationManager.selectedChannel) { _ in
|
||||||
updateNotesCounterSubscription()
|
|
||||||
if case .mesh = locationManager.selectedChannel,
|
if case .mesh = locationManager.selectedChannel,
|
||||||
locationManager.permissionState == .authorized,
|
locationManager.permissionState == .authorized,
|
||||||
LocationChannelManager.shared.availableChannels.isEmpty {
|
LocationChannelManager.shared.availableChannels.isEmpty {
|
||||||
LocationChannelManager.shared.refreshChannels()
|
LocationChannelManager.shared.refreshChannels()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: locationManager.availableChannels) { _ in updateNotesCounterSubscription() }
|
|
||||||
.onChange(of: locationManager.permissionState) { _ in
|
.onChange(of: locationManager.permissionState) { _ in
|
||||||
updateNotesCounterSubscription()
|
|
||||||
if case .mesh = locationManager.selectedChannel,
|
if case .mesh = locationManager.selectedChannel,
|
||||||
locationManager.permissionState == .authorized,
|
locationManager.permissionState == .authorized,
|
||||||
LocationChannelManager.shared.availableChannels.isEmpty {
|
LocationChannelManager.shared.availableChannels.isEmpty {
|
||||||
@@ -1545,34 +1475,6 @@ struct ContentView: View {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notes Counter Subscription Helper
|
|
||||||
extension ContentView {
|
|
||||||
private func updateNotesCounterSubscription() {
|
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh:
|
|
||||||
// Ensure we have a fresh one-shot location fix so building geohash is current
|
|
||||||
if locationManager.permissionState == .authorized {
|
|
||||||
LocationChannelManager.shared.refreshChannels()
|
|
||||||
}
|
|
||||||
if locationManager.permissionState == .authorized {
|
|
||||||
if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
|
||||||
LocationNotesCounter.shared.subscribe(geohash: building)
|
|
||||||
} else {
|
|
||||||
// Keep existing subscription if we had one to avoid flicker
|
|
||||||
// Only cancel if we have no known geohash
|
|
||||||
if LocationNotesCounter.shared.geohash == nil {
|
|
||||||
LocationNotesCounter.shared.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LocationNotesCounter.shared.cancel()
|
|
||||||
}
|
|
||||||
case .location:
|
|
||||||
LocationNotesCounter.shared.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper Views
|
// MARK: - Helper Views
|
||||||
|
|
||||||
// Rounded payment chip button
|
// Rounded payment chip button
|
||||||
@@ -1581,11 +1483,10 @@ extension ContentView {
|
|||||||
private enum MessageMedia {
|
private enum MessageMedia {
|
||||||
case voice(URL)
|
case voice(URL)
|
||||||
case image(URL)
|
case image(URL)
|
||||||
case file(URL)
|
|
||||||
|
|
||||||
var url: URL {
|
var url: URL {
|
||||||
switch self {
|
switch self {
|
||||||
case .voice(let url), .image(let url), .file(let url):
|
case .voice(let url), .image(let url):
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1623,13 +1524,6 @@ private extension ContentView {
|
|||||||
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
||||||
return .image(url)
|
return .image(url)
|
||||||
}
|
}
|
||||||
if message.content.hasPrefix("[file] ") {
|
|
||||||
let filename = String(message.content.dropFirst("[file] ".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !filename.isEmpty else { return nil }
|
|
||||||
let subdir = message.sender == viewModel.nickname ? "files/outgoing" : "files/incoming"
|
|
||||||
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
|
||||||
return .file(url)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1721,13 +1615,6 @@ private extension ContentView {
|
|||||||
} : nil
|
} : nil
|
||||||
)
|
)
|
||||||
.frame(maxWidth: 280)
|
.frame(maxWidth: 280)
|
||||||
case .file(let url):
|
|
||||||
FileAttachmentView(
|
|
||||||
url: url,
|
|
||||||
isSending: state.isSending,
|
|
||||||
progress: state.progress,
|
|
||||||
onCancel: cancelAction
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1786,7 +1673,7 @@ private extension ContentView {
|
|||||||
|
|
||||||
private func expandWindow(ifNeededFor message: BitchatMessage,
|
private func expandWindow(ifNeededFor message: BitchatMessage,
|
||||||
allMessages: [BitchatMessage],
|
allMessages: [BitchatMessage],
|
||||||
privatePeer: String?,
|
privatePeer: PeerID?,
|
||||||
proxy: ScrollViewProxy) {
|
proxy: ScrollViewProxy) {
|
||||||
let step = TransportConfig.uiWindowStepCount
|
let step = TransportConfig.uiWindowStepCount
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
@@ -1846,7 +1733,19 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var shouldShowMediaControls: Bool {
|
private var shouldShowMediaControls: Bool {
|
||||||
if viewModel.selectedPrivateChatPeer != nil {
|
if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch locationManager.selectedChannel {
|
||||||
|
case .mesh:
|
||||||
|
return true
|
||||||
|
case .location:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldShowVoiceControl: Bool {
|
||||||
|
if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
@@ -1862,26 +1761,52 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var attachmentButton: some View {
|
var attachmentButton: some View {
|
||||||
Button(action: { showAttachmentActions = true }) {
|
#if os(iOS)
|
||||||
Image(systemName: "paperclip.circle.fill")
|
Image(systemName: "camera.circle.fill")
|
||||||
|
.font(.bitchatSystem(size: 24))
|
||||||
|
.foregroundColor(composerAccentColor)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.contentShape(Circle())
|
||||||
|
.onTapGesture {
|
||||||
|
// Tap = Photo Library
|
||||||
|
imagePickerSourceType = .photoLibrary
|
||||||
|
showImagePicker = true
|
||||||
|
}
|
||||||
|
.onLongPressGesture(minimumDuration: 0.3) {
|
||||||
|
// Long press = Camera
|
||||||
|
imagePickerSourceType = .camera
|
||||||
|
showImagePicker = true
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Tap for library, long press for camera")
|
||||||
|
#else
|
||||||
|
Button(action: { showMacImagePicker = true }) {
|
||||||
|
Image(systemName: "photo.circle.fill")
|
||||||
.font(.bitchatSystem(size: 24))
|
.font(.bitchatSystem(size: 24))
|
||||||
.foregroundColor(composerAccentColor)
|
.foregroundColor(composerAccentColor)
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Choose photo")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
var sendOrMicButton: some View {
|
var sendOrMicButton: some View {
|
||||||
let hasText = !trimmedMessageText.isEmpty
|
let hasText = !trimmedMessageText.isEmpty
|
||||||
return ZStack {
|
if shouldShowVoiceControl {
|
||||||
micButtonView
|
ZStack {
|
||||||
.opacity(hasText ? 0 : 1)
|
micButtonView
|
||||||
.allowsHitTesting(!hasText)
|
.opacity(hasText ? 0 : 1)
|
||||||
|
.allowsHitTesting(!hasText)
|
||||||
|
sendButtonView(enabled: hasText)
|
||||||
|
.opacity(hasText ? 1 : 0)
|
||||||
|
.allowsHitTesting(hasText)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
} else {
|
||||||
sendButtonView(enabled: hasText)
|
sendButtonView(enabled: hasText)
|
||||||
.opacity(hasText ? 1 : 0)
|
.frame(width: 36, height: 36)
|
||||||
.allowsHitTesting(hasText)
|
|
||||||
}
|
}
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var micButtonView: some View {
|
private var micButtonView: some View {
|
||||||
@@ -1934,6 +1859,7 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startVoiceRecording() {
|
func startVoiceRecording() {
|
||||||
|
guard shouldShowVoiceControl else { return }
|
||||||
guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return }
|
guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return }
|
||||||
isPreparingVoiceNote = true
|
isPreparingVoiceNote = true
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -2037,44 +1963,6 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
func handlePhotoSelection(_ item: PhotosPickerItem) async {
|
|
||||||
defer { Task { @MainActor in selectedPhotoPickerItem = nil } }
|
|
||||||
do {
|
|
||||||
if let data = try await item.loadTransferable(type: Data.self) {
|
|
||||||
let tempURL = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent(UUID().uuidString)
|
|
||||||
.appendingPathExtension("jpg")
|
|
||||||
try data.write(to: tempURL, options: Data.WritingOptions.atomic)
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.sendImage(from: tempURL) {
|
|
||||||
try? FileManager.default.removeItem(at: tempURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Photo picker load failed: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func handleImportedFile(url: URL) async {
|
|
||||||
do {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let tempDir = fileManager.temporaryDirectory
|
|
||||||
let fileName = url.lastPathComponent.isEmpty ? "attachment" : url.lastPathComponent
|
|
||||||
let destination = tempDir.appendingPathComponent(UUID().uuidString + "_" + fileName)
|
|
||||||
if fileManager.fileExists(atPath: destination.path) {
|
|
||||||
try fileManager.removeItem(at: destination)
|
|
||||||
}
|
|
||||||
try fileManager.copyItem(at: url, to: destination)
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.sendFileAttachment(from: destination)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("File copy failed before send: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationFilesDirectory() -> URL? {
|
func applicationFilesDirectory() -> URL? {
|
||||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||||
@@ -2218,3 +2106,87 @@ struct ImagePreviewView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
// MARK: - Image Picker (Camera or Photo Library)
|
||||||
|
struct ImagePickerView: UIViewControllerRepresentable {
|
||||||
|
let sourceType: UIImagePickerController.SourceType
|
||||||
|
let completion: (UIImage?) -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
|
let picker = UIImagePickerController()
|
||||||
|
picker.sourceType = sourceType
|
||||||
|
picker.delegate = context.coordinator
|
||||||
|
picker.allowsEditing = false
|
||||||
|
|
||||||
|
// Use standard full screen - iOS handles safe areas automatically
|
||||||
|
picker.modalPresentationStyle = .fullScreen
|
||||||
|
|
||||||
|
// Force dark mode to make safe area bars black instead of white
|
||||||
|
picker.overrideUserInterfaceStyle = .dark
|
||||||
|
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
|
let completion: (UIImage?) -> Void
|
||||||
|
|
||||||
|
init(completion: @escaping (UIImage?) -> Void) {
|
||||||
|
self.completion = completion
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||||
|
let image = info[.originalImage] as? UIImage
|
||||||
|
completion(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
// MARK: - macOS Image Picker
|
||||||
|
struct MacImagePickerView: View {
|
||||||
|
let completion: (URL?) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Choose an image")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Button("Select Image") {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
|
||||||
|
panel.message = "Choose an image to send"
|
||||||
|
|
||||||
|
if panel.runModal() == .OK {
|
||||||
|
completion(panel.url)
|
||||||
|
} else {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
Button("Cancel") {
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
.frame(minWidth: 300, minHeight: 150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct FingerprintView: View {
|
struct FingerprintView: View {
|
||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
let peerID: String
|
let peerID: PeerID
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@@ -65,15 +65,12 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Prefer short mesh ID for session/encryption status
|
// Prefer short mesh ID for session/encryption status
|
||||||
let statusPeerID: String = {
|
let statusPeerID = viewModel.getShortIDForNoiseKey(peerID)
|
||||||
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short.id }
|
|
||||||
return peerID
|
|
||||||
}()
|
|
||||||
// Resolve a friendly name
|
// Resolve a friendly name
|
||||||
let peerNickname: String = {
|
let peerNickname: String = {
|
||||||
if let p = viewModel.getPeer(byID: PeerID(str: statusPeerID)) { return p.displayName }
|
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
||||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
if let data = peerID.noiseKey {
|
||||||
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) {
|
||||||
@@ -84,7 +81,7 @@ struct FingerprintView: View {
|
|||||||
return Strings.unknownPeer()
|
return Strings.unknownPeer()
|
||||||
}()
|
}()
|
||||||
// Accurate encryption state based on short ID session
|
// Accurate encryption state based on short ID session
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
@@ -115,7 +112,7 @@ struct FingerprintView: View {
|
|||||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: PeerID(str: statusPeerID)) {
|
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -176,7 +173,6 @@ struct FingerprintView: View {
|
|||||||
// Verification status
|
// Verification status
|
||||||
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
||||||
let isVerified = encryptionStatus == .noiseVerified
|
let isVerified = encryptionStatus == .noiseVerified
|
||||||
let peerID = PeerID(str: peerID)
|
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||||
@@ -240,8 +236,6 @@ 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,9 +125,6 @@ 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
|
||||||
|
|||||||
@@ -78,9 +78,6 @@ 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
|
||||||
@@ -141,7 +138,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, count
|
"\(geohash) ± 1", count
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
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: (String) -> Void
|
let onTapPeer: (PeerID) -> Void
|
||||||
let onToggleFavorite: (String) -> Void
|
let onToggleFavorite: (PeerID) -> Void
|
||||||
let onShowFingerprint: (String) -> Void
|
let onShowFingerprint: (PeerID) -> Void
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@State private var orderedIDs: [String] = []
|
@State private var orderedIDs: [String] = []
|
||||||
@@ -130,7 +130,7 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.peerID.id) }) {
|
Button(action: { onToggleFavorite(peer.peerID) }) {
|
||||||
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.id) } }
|
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
|
||||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
|
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Seed and update order outside result builder
|
// Seed and update order outside result builder
|
||||||
|
|||||||
@@ -388,10 +388,6 @@ 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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,5 +298,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: String, nickname: String, content: String, timestamp: Date) {}
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,11 +186,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,
|
||||||
String(data: recipientID, encoding: .utf8) == charlie.peerID {
|
PeerID(data: recipientID) == charlie.peerID {
|
||||||
// Relay to Charlie
|
// Relay to Charlie
|
||||||
var relayPacket = packet
|
var relayPacket = packet
|
||||||
relayPacket.ttl = packet.ttl - 1
|
relayPacket.ttl = packet.ttl - 1
|
||||||
self.charlie.simulateIncomingPacket(relayPacket)
|
charlie.simulateIncomingPacket(relayPacket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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?.id != node.peerID else { return }
|
guard message.senderPeerID != node.peerID else { return }
|
||||||
|
|
||||||
// Create relay message
|
// Create relay message
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
|
|||||||
@@ -209,7 +209,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) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
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) {}
|
||||||
|
|||||||
@@ -125,16 +125,138 @@ struct GossipSyncManagerTests {
|
|||||||
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
||||||
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
#expect(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?()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,61 +91,3 @@ struct LocationNotesManagerTests {
|
|||||||
case shouldNotDerive
|
case shouldNotDerive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct LocationNotesCounterTests {
|
|
||||||
@Test func subscribeWithoutRelaysMarksUnavailable() {
|
|
||||||
var subscribeCalled = false
|
|
||||||
let deps = LocationNotesCounterDependencies(
|
|
||||||
relayLookup: { _, _ in [] },
|
|
||||||
subscribe: { _, _, _, _, _ in subscribeCalled = true },
|
|
||||||
unsubscribe: { _ in }
|
|
||||||
)
|
|
||||||
|
|
||||||
let counter = LocationNotesCounter(testDependencies: deps)
|
|
||||||
counter.subscribe(geohash: "u4pruydq")
|
|
||||||
|
|
||||||
#expect(!subscribeCalled)
|
|
||||||
#expect(!counter.relayAvailable)
|
|
||||||
#expect(counter.initialLoadComplete)
|
|
||||||
#expect(counter.count == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func subscribeCountsUniqueNotes() {
|
|
||||||
var storedHandler: ((NostrEvent) -> Void)?
|
|
||||||
var storedEOSE: (() -> Void)?
|
|
||||||
let deps = LocationNotesCounterDependencies(
|
|
||||||
relayLookup: { _, _ in ["wss://relay.geo"] },
|
|
||||||
subscribe: { filter, id, relays, handler, eose in
|
|
||||||
#expect(relays == ["wss://relay.geo"])
|
|
||||||
#expect(filter.kinds == [1])
|
|
||||||
#expect(!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?()
|
|
||||||
|
|
||||||
#expect(counter.relayAvailable)
|
|
||||||
#expect(counter.count == 1)
|
|
||||||
#expect(counter.initialLoadComplete)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,10 +203,7 @@ final class MockBLEService: NSObject {
|
|||||||
let target = bus.service(for: recipientPeerID) {
|
let target = bus.service(for: recipientPeerID) {
|
||||||
target.simulateIncomingPacket(packet)
|
target.simulateIncomingPacket(packet)
|
||||||
} else {
|
} else {
|
||||||
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known
|
// Not directly connected: deliver to neighbors for relay
|
||||||
if let target = bus.service(for: recipientPeerID) {
|
|
||||||
target.simulateIncomingPacket(packet)
|
|
||||||
}
|
|
||||||
for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
|
for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
|
||||||
neighbor.simulateIncomingPacket(packet)
|
neighbor.simulateIncomingPacket(packet)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ struct NostrProtocolTests {
|
|||||||
|
|
||||||
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
||||||
let messageID = "TEST-MSG-DELIVERED-1"
|
let messageID = "TEST-MSG-DELIVERED-1"
|
||||||
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
|
let senderPeerID = PeerID(str: "0123456789abcdef") // 8-byte hex peer ID
|
||||||
|
|
||||||
let embedded = try #require(
|
let embedded = try #require(
|
||||||
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),
|
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),
|
||||||
@@ -176,7 +176,7 @@ struct NostrProtocolTests {
|
|||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
let messageID = "TEST-MSG-READ-1"
|
let messageID = "TEST-MSG-READ-1"
|
||||||
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
|
let senderPeerID = PeerID(str: "fedcba9876543210") // 8-byte hex peer ID
|
||||||
let embedded = try #require(
|
let embedded = try #require(
|
||||||
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),
|
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),
|
||||||
"Failed to embed read ack"
|
"Failed to embed read ack"
|
||||||
|
|||||||
@@ -54,6 +54,86 @@ struct BinaryProtocolTests {
|
|||||||
#expect(decodedPacket.signature != nil)
|
#expect(decodedPacket.signature != nil)
|
||||||
#expect(decodedPacket.signature == TestConstants.testSignature)
|
#expect(decodedPacket.signature == TestConstants.testSignature)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func packetWithRouteRoundTrip() throws {
|
||||||
|
let route: [Data] = [
|
||||||
|
try #require(Data(hexString: "0102030405060708")),
|
||||||
|
try #require(Data(hexString: "1112131415161718")),
|
||||||
|
try #require(Data(hexString: "2122232425262728"))
|
||||||
|
]
|
||||||
|
|
||||||
|
var packet = BitchatPacket(
|
||||||
|
type: 0x01,
|
||||||
|
senderID: route[0],
|
||||||
|
recipientID: route.last,
|
||||||
|
timestamp: 1_720_000_000_000,
|
||||||
|
payload: Data("route-test".utf8),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 6
|
||||||
|
)
|
||||||
|
packet.route = route
|
||||||
|
|
||||||
|
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route")
|
||||||
|
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
|
||||||
|
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0)
|
||||||
|
|
||||||
|
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route")
|
||||||
|
let decodedRoute = try #require(decoded.route)
|
||||||
|
#expect(decodedRoute.count == route.count)
|
||||||
|
for (expected, actual) in zip(route, decodedRoute) {
|
||||||
|
#expect(actual == expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func packetWithRoutePadsShortHop() throws {
|
||||||
|
let sender = try #require(Data(hexString: "0011223344556677"))
|
||||||
|
let destination = try #require(Data(hexString: "8899aabbccddeeff"))
|
||||||
|
let shortHop = Data([0xAA, 0xBB, 0xCC])
|
||||||
|
|
||||||
|
var packet = BitchatPacket(
|
||||||
|
type: 0x02,
|
||||||
|
senderID: sender,
|
||||||
|
recipientID: destination,
|
||||||
|
timestamp: 1_730_000_000_000,
|
||||||
|
payload: Data("pad-test".utf8),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 5
|
||||||
|
)
|
||||||
|
packet.route = [shortHop, destination]
|
||||||
|
|
||||||
|
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with short hop route")
|
||||||
|
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with short hop route")
|
||||||
|
let decodedRoute = try #require(decoded.route)
|
||||||
|
let firstHop = try #require(decodedRoute.first)
|
||||||
|
#expect(firstHop.count == BinaryProtocol.senderIDSize)
|
||||||
|
#expect(firstHop.prefix(shortHop.count) == shortHop)
|
||||||
|
let paddingBytes = firstHop.suffix(firstHop.count - shortHop.count)
|
||||||
|
#expect(paddingBytes.allSatisfy { $0 == 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func packetWithRouteAndCompressedPayload() throws {
|
||||||
|
let route: [Data] = [
|
||||||
|
try #require(Data(hexString: "0101010101010101")),
|
||||||
|
try #require(Data(hexString: "0202020202020202"))
|
||||||
|
]
|
||||||
|
let repeatedString = String(repeating: "compress-me", count: 150)
|
||||||
|
var packet = BitchatPacket(
|
||||||
|
type: 0x03,
|
||||||
|
senderID: route[0],
|
||||||
|
recipientID: route.last,
|
||||||
|
timestamp: 1_740_000_000_000,
|
||||||
|
payload: Data(repeatedString.utf8),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 7
|
||||||
|
)
|
||||||
|
packet.route = route
|
||||||
|
|
||||||
|
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route and compression")
|
||||||
|
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route and compression")
|
||||||
|
#expect(decoded.payload == Data(repeatedString.utf8))
|
||||||
|
let decodedRoute = try #require(decoded.route)
|
||||||
|
#expect(decodedRoute == route)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Compression Tests
|
// MARK: - Compression Tests
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//
|
||||||
|
// MeshTopologyTrackerTests.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 MeshTopologyTrackerTests {
|
||||||
|
private func hex(_ value: String) throws -> Data {
|
||||||
|
try #require(Data(hexString: value))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func directLinkProducesRoute() throws {
|
||||||
|
let tracker = MeshTopologyTracker()
|
||||||
|
let a = try hex("0102030405060708")
|
||||||
|
let b = try hex("1112131415161718")
|
||||||
|
|
||||||
|
tracker.recordDirectLink(between: a, and: b)
|
||||||
|
let route = try #require(tracker.computeRoute(from: a, to: b))
|
||||||
|
#expect(route == [a, b])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func multiHopRouteComputation() throws {
|
||||||
|
let tracker = MeshTopologyTracker()
|
||||||
|
let a = try hex("0001020304050607")
|
||||||
|
let b = try hex("1011121314151617")
|
||||||
|
let c = try hex("2021222324252627")
|
||||||
|
let d = try hex("3031323334353637")
|
||||||
|
|
||||||
|
tracker.recordDirectLink(between: a, and: b)
|
||||||
|
tracker.recordDirectLink(between: b, and: c)
|
||||||
|
tracker.recordDirectLink(between: c, and: d)
|
||||||
|
|
||||||
|
let route = try #require(tracker.computeRoute(from: a, to: d))
|
||||||
|
#expect(route == [a, b, c, d])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func recordRouteAddsEdges() throws {
|
||||||
|
let tracker = MeshTopologyTracker()
|
||||||
|
var a = Data([0xAA, 0xBB, 0xCC])
|
||||||
|
let b = try hex("4445464748494A4B")
|
||||||
|
let c = try hex("5455565758595A5B")
|
||||||
|
|
||||||
|
tracker.recordRoute([a, b, c])
|
||||||
|
|
||||||
|
a.append(Data(repeating: 0, count: BinaryProtocol.senderIDSize - a.count))
|
||||||
|
let route = try #require(tracker.computeRoute(from: a, to: c))
|
||||||
|
#expect(route.first == a)
|
||||||
|
#expect(route.last == c)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func removingDirectLinkBreaksRoute() throws {
|
||||||
|
let tracker = MeshTopologyTracker()
|
||||||
|
let a = try hex("0101010101010101")
|
||||||
|
let b = try hex("0202020202020202")
|
||||||
|
let c = try hex("0303030303030303")
|
||||||
|
|
||||||
|
tracker.recordDirectLink(between: a, and: b)
|
||||||
|
tracker.recordDirectLink(between: b, and: c)
|
||||||
|
let initialRoute = try #require(tracker.computeRoute(from: a, to: c))
|
||||||
|
#expect(initialRoute == [a, b, c])
|
||||||
|
|
||||||
|
tracker.removeDirectLink(between: b, and: c)
|
||||||
|
#expect(tracker.computeRoute(from: a, to: c) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func removingPeerClearsEdges() throws {
|
||||||
|
let tracker = MeshTopologyTracker()
|
||||||
|
let a = try hex("0F0E0D0C0B0A0908")
|
||||||
|
let b = try hex("0A0B0C0D0E0F0001")
|
||||||
|
let c = try hex("0011223344556677")
|
||||||
|
|
||||||
|
tracker.recordRoute([a, b, c])
|
||||||
|
let initialRoute = try #require(tracker.computeRoute(from: a, to: c))
|
||||||
|
#expect(initialRoute == [a, b, c])
|
||||||
|
|
||||||
|
tracker.removePeer(b)
|
||||||
|
#expect(tracker.computeRoute(from: a, to: c) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -217,7 +217,27 @@ struct PeerIDTests {
|
|||||||
let short = peerID.toShort()
|
let short = peerID.toShort()
|
||||||
#expect(short == peerID)
|
#expect(short == peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func routingData_fromShortID() throws {
|
||||||
|
let peerID = PeerID(str: hex16)
|
||||||
|
let routing = try #require(peerID.routingData)
|
||||||
|
#expect(routing.count == 8)
|
||||||
|
#expect(routing == Data(hexString: hex16))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func routingData_fromNoiseKey() throws {
|
||||||
|
let peerID = PeerID(str: hex64)
|
||||||
|
let routing = try #require(peerID.routingData)
|
||||||
|
let expectedShort = peerID.toShort()
|
||||||
|
#expect(routing == Data(hexString: expectedShort.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func routingPeerRoundTrip() throws {
|
||||||
|
let raw = try #require(Data(hexString: hex16))
|
||||||
|
let peerID = try #require(PeerID(routingData: raw))
|
||||||
|
#expect(peerID.routingData == raw)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Codable
|
// MARK: - Codable
|
||||||
|
|
||||||
@Test func codable_emptyPrefix() throws {
|
@Test func codable_emptyPrefix() throws {
|
||||||
@@ -273,7 +293,7 @@ struct PeerIDTests {
|
|||||||
@Test func comparable_sorting_and_equality() {
|
@Test func comparable_sorting_and_equality() {
|
||||||
let p1 = PeerID(str: "aaa")
|
let p1 = PeerID(str: "aaa")
|
||||||
let p2 = PeerID(str: "bbb")
|
let p2 = PeerID(str: "bbb")
|
||||||
let p3 = PeerID(str: "bbb")
|
let p3 = PeerID(str: "BBB")
|
||||||
|
|
||||||
#expect(p1 < p2)
|
#expect(p1 < p2)
|
||||||
#expect(p2 >= p1)
|
#expect(p2 >= p1)
|
||||||
@@ -284,44 +304,18 @@ struct PeerIDTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func equality() {
|
@Test func equality() {
|
||||||
let string = "aaa"
|
let peerID = PeerID(str: "aaa")
|
||||||
let peerID = PeerID(str: string)
|
|
||||||
let badString = "bbb"
|
|
||||||
|
|
||||||
// PeerID == String
|
|
||||||
#expect(peerID == string)
|
|
||||||
#expect(peerID == Optional(string))
|
|
||||||
#expect(Optional(peerID) == string)
|
|
||||||
#expect(Optional(peerID) == Optional(string))
|
|
||||||
|
|
||||||
// PeerID != String
|
|
||||||
#expect(peerID != badString)
|
|
||||||
#expect(peerID != Optional(badString))
|
|
||||||
#expect(Optional(peerID) != badString)
|
|
||||||
#expect(Optional(peerID) != Optional(badString))
|
|
||||||
|
|
||||||
// String == PeerID
|
|
||||||
#expect(string == peerID)
|
|
||||||
#expect(Optional(string) == peerID)
|
|
||||||
#expect(string == Optional(peerID))
|
|
||||||
#expect(Optional(string) == Optional(peerID))
|
|
||||||
|
|
||||||
// String != PeerID
|
|
||||||
#expect(badString != peerID)
|
|
||||||
#expect(Optional(badString) != peerID)
|
|
||||||
#expect(badString != Optional(peerID))
|
|
||||||
#expect(Optional(badString) != Optional(peerID))
|
|
||||||
|
|
||||||
// Regular PeerID <> PeerID
|
// Regular PeerID <> PeerID
|
||||||
#expect(peerID == PeerID(str: "aaa"))
|
#expect(peerID == PeerID(str: "AAA"))
|
||||||
#expect(peerID == Optional(PeerID(str: "aaa")))
|
#expect(peerID == Optional(PeerID(str: "AAA")))
|
||||||
#expect(PeerID(str: "aaa") == peerID)
|
#expect(PeerID(str: "AAA") == peerID)
|
||||||
#expect(Optional(PeerID(str: "aaa")) == Optional(peerID))
|
#expect(Optional(PeerID(str: "AAA")) == Optional(peerID))
|
||||||
|
|
||||||
#expect(peerID != PeerID(str: "bbb"))
|
#expect(peerID != PeerID(str: "BBB"))
|
||||||
#expect(peerID != Optional(PeerID(str: "bbb")))
|
#expect(peerID != Optional(PeerID(str: "BBB")))
|
||||||
#expect(PeerID(str: "bbb") != peerID)
|
#expect(PeerID(str: "BBB") != peerID)
|
||||||
#expect(Optional(PeerID(str: "bbb")) != Optional(peerID))
|
#expect(Optional(PeerID(str: "BBB")) != Optional(peerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed properties
|
// MARK: - Computed properties
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "BitLogger",
|
name: "BitLogger",
|
||||||
path: "Sources"
|
path: "Sources"
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "BitLoggerTests",
|
||||||
|
dependencies: ["BitLogger"]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,22 +68,6 @@ public final class SecureLogger {
|
|||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: - Cached Regex Patterns
|
|
||||||
|
|
||||||
private static let fingerprintPattern = #/[a-fA-F0-9]{64}/#
|
|
||||||
private static let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/#
|
|
||||||
private static let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/#
|
|
||||||
private static let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/#
|
|
||||||
|
|
||||||
// MARK: - Sanitization Cache
|
|
||||||
|
|
||||||
private static let sanitizationCache: NSCache<NSString, NSString> = {
|
|
||||||
let cache = NSCache<NSString, NSString>()
|
|
||||||
cache.countLimit = 100 // Keep last 100 sanitized strings
|
|
||||||
return cache
|
|
||||||
}()
|
|
||||||
private static let cacheQueue = DispatchQueue(label: "chat.bitchat.securelogger.cache", attributes: .concurrent)
|
|
||||||
|
|
||||||
// MARK: - Log Levels
|
// MARK: - Log Levels
|
||||||
|
|
||||||
enum LogLevel {
|
enum LogLevel {
|
||||||
@@ -161,8 +145,8 @@ public extension SecureLogger {
|
|||||||
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let sanitized = sanitize(context())
|
let sanitized = context().sanitized()
|
||||||
let errorDesc = sanitize(error.localizedDescription)
|
let errorDesc = error.localizedDescription.sanitized()
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||||
@@ -186,15 +170,15 @@ public extension SecureLogger {
|
|||||||
var message: String {
|
var message: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .handshakeStarted(let peerID):
|
case .handshakeStarted(let peerID):
|
||||||
return "Handshake started with peer: \(sanitize(peerID))"
|
return "Handshake started with peer: \(peerID.sanitized())"
|
||||||
case .handshakeCompleted(let peerID):
|
case .handshakeCompleted(let peerID):
|
||||||
return "Handshake completed with peer: \(sanitize(peerID))"
|
return "Handshake completed with peer: \(peerID.sanitized())"
|
||||||
case .handshakeFailed(let peerID, let error):
|
case .handshakeFailed(let peerID, let error):
|
||||||
return "Handshake failed with peer: \(sanitize(peerID)), error: \(error)"
|
return "Handshake failed with peer: \(peerID.sanitized()), error: \(error)"
|
||||||
case .sessionExpired(let peerID):
|
case .sessionExpired(let peerID):
|
||||||
return "Session expired for peer: \(sanitize(peerID))"
|
return "Session expired for peer: \(peerID.sanitized())"
|
||||||
case .authenticationFailed(let peerID):
|
case .authenticationFailed(let peerID):
|
||||||
return "Authentication failed for peer: \(sanitize(peerID))"
|
return "Authentication failed for peer: \(peerID.sanitized())"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,7 +233,7 @@ private extension SecureLogger {
|
|||||||
file: String, line: Int, function: String) {
|
file: String, line: Int, function: String) {
|
||||||
guard shouldLog(level) else { return }
|
guard shouldLog(level) else { return }
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let sanitized = sanitize("\(location) \(message())")
|
let sanitized = "\(location) \(message())".sanitized()
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
||||||
@@ -282,58 +266,6 @@ private extension SecureLogger {
|
|||||||
let timestamp = timestampFormatter.string(from: Date())
|
let timestamp = timestampFormatter.string(from: Date())
|
||||||
return "[\(timestamp)] [\(fileName):\(line) \(function)]"
|
return "[\(timestamp)] [\(fileName):\(line) \(function)]"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sanitize strings to remove potentially sensitive data
|
|
||||||
static func sanitize(_ input: String) -> String {
|
|
||||||
let key = input as NSString
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
var cachedValue: String?
|
|
||||||
cacheQueue.sync {
|
|
||||||
cachedValue = sanitizationCache.object(forKey: key) as String?
|
|
||||||
}
|
|
||||||
|
|
||||||
if let cached = cachedValue {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform sanitization
|
|
||||||
var sanitized = input
|
|
||||||
|
|
||||||
// Remove full fingerprints (keep first 8 chars for debugging)
|
|
||||||
sanitized = sanitized.replacing(fingerprintPattern) { match in
|
|
||||||
let fingerprint = String(match.output)
|
|
||||||
return String(fingerprint.prefix(8)) + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove base64 encoded data that might be keys
|
|
||||||
sanitized = sanitized.replacing(base64Pattern) { _ in
|
|
||||||
"<base64-data>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove potential passwords (assuming they're in quotes or after "password:")
|
|
||||||
sanitized = sanitized.replacing(passwordPattern) { _ in
|
|
||||||
"password: <redacted>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate peer IDs to first 8 characters
|
|
||||||
sanitized = sanitized.replacing(peerIDPattern) { match in
|
|
||||||
"peerID: \(match.1)..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
cacheQueue.sync {
|
|
||||||
sanitizationCache.setObject(sanitized as NSString, forKey: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitized
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sanitize individual values
|
|
||||||
static func sanitize<T>(_ value: T) -> String {
|
|
||||||
let stringValue = String(describing: value)
|
|
||||||
return sanitize(stringValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Migration Helper
|
// MARK: - Migration Helper
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// String+Sanitization.swift
|
||||||
|
// BitLogger
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
/// Sanitize strings to remove potentially sensitive data
|
||||||
|
func sanitized() -> String {
|
||||||
|
let key = self as NSString
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if let cached = Self.queue.sync(execute: { Self.cache.object(forKey: key) }) {
|
||||||
|
return cached as String
|
||||||
|
}
|
||||||
|
|
||||||
|
var sanitized = self
|
||||||
|
|
||||||
|
// Remove full fingerprints (keep first 8 chars for debugging)
|
||||||
|
let fingerprintPattern = #/[a-fA-F0-9]{64}/#
|
||||||
|
sanitized = sanitized.replacing(fingerprintPattern) { match in
|
||||||
|
let fingerprint = String(match.output)
|
||||||
|
return String(fingerprint.prefix(8)) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove base64 encoded data that might be keys
|
||||||
|
let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/#
|
||||||
|
sanitized = sanitized.replacing(base64Pattern) { _ in
|
||||||
|
"<base64-data>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove potential passwords (assuming they're in quotes or after "password:")
|
||||||
|
let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/#
|
||||||
|
sanitized = sanitized.replacing(passwordPattern) { _ in
|
||||||
|
"password: <redacted>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate peer IDs to first 8 characters
|
||||||
|
let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/#
|
||||||
|
sanitized = sanitized.replacing(peerIDPattern) { match in
|
||||||
|
"peerID: \(match.1)..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
Self.queue.sync {
|
||||||
|
Self.cache.setObject(sanitized as NSString, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache Helpers
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
static let queue = DispatchQueue(label: "chat.bitchat.securelogger.cache", attributes: .concurrent)
|
||||||
|
|
||||||
|
static let cache: NSCache<NSString, NSString> = {
|
||||||
|
let cache = NSCache<NSString, NSString>()
|
||||||
|
cache.countLimit = 100 // Keep last 100 sanitized strings
|
||||||
|
return cache
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//
|
||||||
|
// StringSanitizationTests.swift
|
||||||
|
// BitLogger
|
||||||
|
//
|
||||||
|
// Created by Islam on 19/10/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
@testable import BitLogger
|
||||||
|
|
||||||
|
struct StringSanitizationTests {
|
||||||
|
|
||||||
|
@Test("64-hex fingerprint is truncated to first 8 chars followed by ellipsis")
|
||||||
|
func fingerprintTruncation() async throws {
|
||||||
|
let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
#expect(fingerprint.count == 64)
|
||||||
|
|
||||||
|
let input = "fingerprint=\(fingerprint)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
|
||||||
|
#expect(output.contains("fingerprint=01234567..."))
|
||||||
|
// Ensure no full fingerprint remains
|
||||||
|
#expect(output.contains(fingerprint) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Multiple fingerprints in a string are all truncated")
|
||||||
|
func multipleFingerprintTruncation() async throws {
|
||||||
|
let fp1 = String(repeating: "a", count: 64)
|
||||||
|
let fp2 = String(repeating: "b", count: 64)
|
||||||
|
let input = "fp1=\(fp1) fp2=\(fp2)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output.contains("fp1=aaaaaaaa..."))
|
||||||
|
#expect(output.contains("fp2=bbbbbbbb..."))
|
||||||
|
#expect(output.contains(fp1) == false)
|
||||||
|
#expect(output.contains(fp2) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Base64-like long data is replaced with <base64-data>")
|
||||||
|
func base64Replacement() async throws {
|
||||||
|
// 44+ chars of base64 characters
|
||||||
|
let base64ish = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo5ODc2NTQzMjE="
|
||||||
|
let input = "payload=\(base64ish)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output == "payload=<base64-data>")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Base64-like without padding is replaced with <base64-data>")
|
||||||
|
func base64NoPaddingReplacement() async throws {
|
||||||
|
let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
#expect(base64ish.count >= 40)
|
||||||
|
let input = "b64:\(base64ish)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output == "b64:<base64-data>")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Short base64-like strings (below threshold) are not replaced")
|
||||||
|
func shortBase64NotReplaced() async throws {
|
||||||
|
let short = "QUJDREVGR0hJSktMTU5P" // < 40 chars
|
||||||
|
let input = "payload=\(short)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output == input)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Password redaction for key:value formats", arguments: [
|
||||||
|
"password: secret123",
|
||||||
|
"password=secret123",
|
||||||
|
"password = secret123",
|
||||||
|
"password: 'secret123'",
|
||||||
|
"password:\"secret123\"",
|
||||||
|
"password='secret123'"
|
||||||
|
])
|
||||||
|
func passwordRedactionKeyValue(password: String) async throws {
|
||||||
|
#expect(password.sanitized() == "password: <redacted>")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Password redaction inside wider messages")
|
||||||
|
func passwordRedactionInContext() async throws {
|
||||||
|
let input = "user=john password: 'p@ssW0rd' attempt=1"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output == "user=john password: <redacted> attempt=1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("PeerID is truncated to first 8 chars followed by ellipsis")
|
||||||
|
func peerIDTruncation() async throws {
|
||||||
|
let peer = "ABCDEF12GHIJKL34"
|
||||||
|
let input = "peerID: \(peer)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output == "peerID: ABCDEF12...")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("PeerID not truncated when exactly 8 chars")
|
||||||
|
func peerIDExactlyEightNotTruncated() async throws {
|
||||||
|
let peer = "ABCDEF12"
|
||||||
|
let input = "peerID: \(peer)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
// Pattern only matches when there are more than 8 trailing chars, so unchanged
|
||||||
|
#expect(output == input)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Non-matching content remains unchanged")
|
||||||
|
func nonMatchingUnchanged() async throws {
|
||||||
|
let input = "Hello world 123 - nothing sensitive here."
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output == input)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Idempotency: sanitizing twice yields same result")
|
||||||
|
func idempotentSanitization() async throws {
|
||||||
|
let input = """
|
||||||
|
fingerprint=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
|
||||||
|
password: "superSecret" \
|
||||||
|
peerID: ZYXWVUT987654321 \
|
||||||
|
payload=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
|
||||||
|
"""
|
||||||
|
let once = input.sanitized()
|
||||||
|
let twice = once.sanitized()
|
||||||
|
#expect(once == twice)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mixed content: all rules apply in a single string")
|
||||||
|
func mixedContent() async throws {
|
||||||
|
let fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||||
|
let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
let peer = "PEERID01EXTRA"
|
||||||
|
let input = "fp=\(fingerprint) password='x' peerID: \(peer) data=\(base64ish)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output.contains("fp=fedcba98..."))
|
||||||
|
#expect(output.contains("password: <redacted>"))
|
||||||
|
#expect(output.contains("peerID: PEERID01..."))
|
||||||
|
#expect(output.contains("data=<base64-data>"))
|
||||||
|
#expect(output.contains(fingerprint) == false)
|
||||||
|
#expect(output.contains(base64ish) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Cache returns consistent result for repeated inputs")
|
||||||
|
func cacheHitConsistency() async throws {
|
||||||
|
let input = "password: hunter2"
|
||||||
|
let first = input.sanitized()
|
||||||
|
let second = input.sanitized()
|
||||||
|
#expect(first == "password: <redacted>")
|
||||||
|
#expect(first == second)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user